OverviewFilesCommitsIssuesDocsReleasesPeers

hyper-social / bridge-irc / lib / bridge.js

182 lines · javascript ref:
import { IrcClient } from './irc-client.js'
import { KeetClient } from './keet-client.js'
import { Router } from './router.js'
import { ircToEnvelope, keetToIrc } from './adapter.js'

class Bridge {
  constructor (opts) {
    this.ircServer = opts.ircServer
    this.ircPort = opts.ircPort || 6667
    this.ircNick = opts.ircNick || 'keet-bridge'
    this.ircChannel = opts.ircChannel
    this.ircPassword = opts.ircPassword || null
    this.ircTls = opts.ircTls

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

    this._irc = null
    this._keet = null
    this._router = null
    this._running = false
    this._bridgeIdentity = null
    this._lastSeq = -1 // highest seq seen at startup — skip history
    this._sendQueue = []
    this._sending = false

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

  async start () {
    this.onLog(`[bridge] Starting IRC ↔ Keet bridge`)
    this.onLog(`[bridge] IRC: ${this.ircServer}:${this.ircPort} ${this.ircChannel}`)
    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 messages
    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 router with dedup
    this._router = new Router({
      onIrcMessage: (name, text) => {
        this._queueSay(`<${name}> ${text}`)
      },
      onKeetMessage: (nick, text) => {
        this._keet.send(this.keetRoom, `[IRC] <${nick}> ${text}`).catch((err) => {
          this.onLog(`[bridge] Keet send error: ${err.message}`)
        })
      }
    })

    // Initialize IRC client
    this._irc = new IrcClient({
      server: this.ircServer,
      port: this.ircPort,
      nick: this.ircNick,
      channel: this.ircChannel,
      password: this.ircPassword,
      tls: this.ircTls,
      onMessage: (nick, text) => {
        if (nick === this.ircNick) return // ignore self
        this.onLog(`[irc→keet] <${nick}> ${text}`)
        this._router.routeToKeet(nick, text)
      },
      onJoin: (nick) => {
        if (nick === this.ircNick) return
        this.onLog(`[irc] ${nick} joined ${this.ircChannel}`)
      },
      onPart: (nick) => {
        this.onLog(`[irc] ${nick} left ${this.ircChannel}`)
      }
    })

    // Connect to IRC
    await this._irc.connect()
    this.onLog(`[bridge] Connected to IRC as ${this.ircNick}`)

    // 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 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 = keetToIrc(msg, this._bridgeIdentity)
          if (!relayed) continue

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

          this.onLog(`[keet→irc] ${relayed.text}`)
          this._router.routeToIrc(relayed.nick, msg.text)
        }
      } catch (err) {
        this.onLog(`[bridge] Keet poll error: ${err.message}`)
      }
    }, 2000)
  }

  // Rate-limited IRC send — 1 message per second to avoid flood
  _queueSay (text) {
    this._sendQueue.push(text)
    if (!this._sending) this._drainQueue()
  }

  _drainQueue () {
    if (this._sendQueue.length === 0) {
      this._sending = false
      return
    }
    this._sending = true
    const text = this._sendQueue.shift()
    this._irc.say(text)
    setTimeout(() => this._drainQueue(), 1000)
  }

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

    if (this._pollTimer) clearInterval(this._pollTimer)
    if (this._irc) this._irc.disconnect('Bridge stopped')

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

  get running () {
    return this._running
  }

  get stats () {
    return {
      running: this._running,
      ircConnected: this._irc?.connected || false,
      deduped: this._router?.seenCount || 0
    }
  }
}

export { Bridge }