OverviewFilesCommitsIssuesDocsReleasesPeers

hypercore-kb / hyper-social / bridge-matrix / lib / bridge.js

169 lines · javascript ref:
import { MatrixClient } from './matrix-client.js'
import { KeetClient } from './keet-client.js'
import { Router } from './router.js'
import { matrixToKeetText, keetToMatrixText } from './adapter.js'

class Bridge {
  constructor (opts) {
    this.matrixHomeserver = opts.matrixHomeserver || 'matrix.org'
    this.matrixUser = opts.matrixUser
    this.matrixPassword = opts.matrixPassword
    this.matrixRoom = opts.matrixRoom

    this.keetRoom = opts.keetRoom
    this.keetHost = opts.keetHost || 'localhost'
    this.keetPort = opts.keetPort || 3117

    this._matrix = null
    this._keet = null
    this._router = null
    this._running = false
    this._bridgeIdentity = null
    this._lastSeq = -1
    this._pollTimer = null

    this.onLog = opts.onLog || console.log
  }

  async start () {
    this.onLog('[bridge] Starting Matrix <-> Keet bridge')
    this.onLog(`[bridge] Matrix: ${this.matrixHomeserver} room: ${this.matrixRoom}`)
    this.onLog(`[bridge] Keet room: ${this.keetRoom}`)

    // Initialize Keet client
    this._keet = new KeetClient({
      host: this.keetHost,
      port: this.keetPort
    })

    // Get bridge identity so we can filter our own messages
    try {
      const identity = await this._keet.identity()
      this._bridgeIdentity = identity.memberId || identity.id
      this.onLog(`[bridge] Keet identity: ${identity.displayName || identity.username || 'unknown'}`)
    } catch (err) {
      this.onLog(`[bridge] Warning: could not get Keet identity: ${err.message}`)
    }

    // Snapshot the latest message seq to distinguish history from new
    try {
      const msgs = await this._keet.messages(this.keetRoom, 1)
      if (msgs && msgs.length > 0) {
        this._lastSeq = msgs[msgs.length - 1].seq || 0
        this.onLog(`[bridge] History cutoff: seq ${this._lastSeq}`)
      }
    } catch (err) {
      this.onLog(`[bridge] Warning: could not get message history: ${err.message}`)
    }

    // Initialize Matrix client
    this._matrix = new MatrixClient({
      homeserver: this.matrixHomeserver,
      onMessage: (roomId, sender, text, eventId) => {
        // Only relay messages from the configured room
        if (roomId !== this.matrixRoom) return

        this.onLog(`[matrix->keet] <${sender}> ${text}`)
        const keetText = matrixToKeetText(sender, text)
        this._router.routeToKeet(sender, keetText)
      }
    })

    // Login to Matrix
    this.onLog('[bridge] Logging in to Matrix...')
    const loginRes = await this._matrix.login(this.matrixHomeserver, this.matrixUser, this.matrixPassword)
    this.onLog(`[bridge] Matrix logged in as ${loginRes.user_id}`)

    // Join the Matrix room
    try {
      await this._matrix.joinRoom(this.matrixRoom)
      const roomName = await this._matrix.getRoomName(this.matrixRoom)
      this.onLog(`[bridge] Joined Matrix room: ${roomName || this.matrixRoom}`)
    } catch (err) {
      this.onLog(`[bridge] Warning: join room error: ${err.message}`)
    }

    // Initialize router with dedup
    this._router = new Router({
      onKeetMessage: (author, text) => {
        this._keet.send(this.keetRoom, text).catch((err) => {
          this.onLog(`[bridge] Keet send error: ${err.message}`)
        })
      },
      onExternalMessage: (name, text) => {
        this._matrix.sendMessage(this.matrixRoom, text).catch((err) => {
          this.onLog(`[bridge] Matrix send error: ${err.message}`)
        })
      }
    })

    // Verify Keet daemon is reachable
    try {
      const status = await this._keet.status()
      this.onLog(`[bridge] Keet daemon online (${status.rooms || '?'} rooms)`)
    } catch (err) {
      this.onLog(`[bridge] Warning: Keet daemon unreachable: ${err.message}`)
    }

    // Start Matrix sync loop
    this._matrix._startSync()

    // Start polling Keet room for new messages
    this._startKeetPoll()

    this._running = true
    this.onLog('[bridge] Bridge active -- messages flowing bidirectionally')
  }

  _startKeetPoll () {
    this._pollTimer = setInterval(async () => {
      try {
        const msgs = await this._keet.messages(this.keetRoom, 10)
        if (!msgs || msgs.length === 0) return

        for (const msg of msgs) {
          if (msg.seq === undefined || msg.seq <= this._lastSeq) continue

          this._lastSeq = msg.seq

          const relayed = keetToMatrixText(msg, this._bridgeIdentity)
          if (!relayed) continue

          // Skip messages bridged from Matrix (prevents echo)
          if (msg.text && msg.text.startsWith('[Matrix]')) continue

          const displayText = `<${relayed.sender}> ${relayed.text}`
          this.onLog(`[keet->matrix] ${displayText}`)
          this._router.routeToExternal(relayed.sender, displayText)
        }
      } catch (err) {
        this.onLog(`[bridge] Keet poll error: ${err.message}`)
      }
    }, 2000)
  }

  async stop () {
    this.onLog('[bridge] Shutting down...')
    this._running = false

    if (this._pollTimer) clearInterval(this._pollTimer)
    if (this._matrix) this._matrix.stopSync()

    this.onLog('[bridge] Stopped')
  }

  get running () {
    return this._running
  }

  get stats () {
    return {
      running: this._running,
      matrixUser: this._matrix?.userId || null,
      deduped: this._router?.seenCount || 0
    }
  }
}

export { Bridge }