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 }