Files
bare-operating-system/packages/bare-os-coreutils/lib/irc/irc-client.js
T
2026-08-18 18:11:28 -04:00

568 lines
16 KiB
JavaScript

/** IRC connection + registration + dispatch (modern client protocol). */
var BARE_IRC_WANT_CAPS = [
'cap-notify',
'multi-prefix',
'extended-join',
'account-tag',
'account-notify',
'away-notify',
'chghost',
'server-time',
'message-tags',
'echo-message',
'sasl'
]
function bareIrcNow() {
return Date.now()
}
function bareIrcCreateClient(opts) {
opts = opts || {}
var listeners = {}
var sock = null
var lines = bareIrcCreateLineBuffer()
var sendTimes = []
var nickTries = 0
var capLsDone = false
var awaitingCapAck = false
var saslPending = false
var client = {
opts: opts,
state: bareIrcCreateState(opts),
on: function (ev, fn) {
if (!listeners[ev]) listeners[ev] = []
listeners[ev].push(fn)
return client
},
off: function (ev, fn) {
var arr = listeners[ev]
if (!arr) return
listeners[ev] = arr.filter(function (x) {
return x !== fn
})
}
}
function emit(ev, payload) {
var arr = listeners[ev] || []
for (var i = 0; i < arr.length; i++) {
try {
arr[i](payload)
} catch (e) {
/* ignore */
}
}
if (typeof opts.onEvent === 'function') {
try {
opts.onEvent(ev, payload)
} catch (e2) {
/* ignore */
}
}
}
function writeRaw(s) {
if (!sock || typeof sock.write !== 'function') return false
try {
sock.write(s)
return true
} catch (e) {
return false
}
}
client.sendRaw = function (command, params) {
var msg =
typeof command === 'string' && params
? { command: command, params: params }
: typeof command === 'object'
? command
: { command: String(command || ''), params: [] }
var line = bareIrcSerializeMessage(msg)
return writeRaw(line)
}
client.rateOk = function () {
var now = bareIrcNow()
sendTimes = sendTimes.filter(function (t) {
return now - t < 1000
})
if (sendTimes.length >= 5) return false
sendTimes.push(now)
return true
}
client.sendPrivmsg = function (target, text) {
if (!client.rateOk()) return false
var ok = client.sendRaw('PRIVMSG', [target, text])
if (ok && !client.state.capsEnabled['echo-message']) {
client._ingestLocalEcho(target, text)
}
return ok
}
client.join = function (chan, key) {
return client.sendRaw('JOIN', key ? [chan, key] : [chan])
}
client.list = function (pattern) {
client.state.channelList = []
client.state.listBusy = true
var pat = String(pattern || '').trim()
if (pat && pat.indexOf('*') < 0 && pat.charAt(0) !== '#')
pat = '*' + pat + '*'
return client.sendRaw('LIST', pat ? [pat] : [])
}
client.part = function (chan, reason) {
return client.sendRaw('PART', reason ? [chan, reason] : [chan])
}
client.quit = function (reason) {
client.state.connection = 'closing'
client.sendRaw('QUIT', [reason || 'Leaving'])
try {
if (sock && typeof sock.end === 'function') sock.end()
} catch (e) {
/* ignore */
}
}
client._ingestLocalEcho = function (target, text) {
var mapping = bareIrcCasemapping(client.state.isupport)
var kind = bareIrcIsChannel(target, bareIrcChantypes(client.state.isupport))
? 'channel'
: 'query'
var buf = bareIrcEnsureBuffer(client.state, kind, target)
bareIrcPushLine(client.state, buf, {
atMs: bareIrcNow(),
kind: 'privmsg',
from: client.state.nick,
text: text,
local: true
})
emit('privmsg', {
target: target,
nick: client.state.nick,
text: text,
local: true
})
}
function requestCaps() {
var offered = client.state.capsOffered
var want = []
for (var i = 0; i < BARE_IRC_WANT_CAPS.length; i++) {
var c = BARE_IRC_WANT_CAPS[i]
if (offered[c] != null) want.push(c)
}
if (!want.length) {
finishCap()
return
}
awaitingCapAck = true
client.sendRaw('CAP', ['REQ', want.join(' ')])
}
function finishCap() {
awaitingCapAck = false
if (
client.state.capsEnabled.sasl &&
opts.saslUser &&
opts.saslPass != null &&
String(opts.saslPass) !== ''
) {
saslPending = true
client.sendRaw('AUTHENTICATE', ['PLAIN'])
return
}
client.sendRaw('CAP', ['END'])
}
function applyCapList(text, ack) {
var bits = String(text || '').split(/\s+/)
for (var i = 0; i < bits.length; i++) {
if (!bits[i]) continue
var name = bits[i]
var val = true
var eq = name.indexOf('=')
if (eq >= 0) {
val = name.slice(eq + 1)
name = name.slice(0, eq)
}
client.state.capsOffered[name] = val
if (ack) client.state.capsEnabled[name] = val
}
}
function handleNumeric(msg) {
var n = parseInt(msg.command, 10)
var p = msg.params || []
if (n === 1) {
if (p[0]) client.state.nick = p[0]
client.state.registered = true
client.state.connection = 'registered'
emit('registered', { nick: client.state.nick })
} else if (n === 5) {
client.state.isupport = bareIrcMergeIsupport(
client.state.isupport,
bareIrcParseIsupport(p)
)
if (client.state.isupport.NETWORK) {
client.state.network = String(client.state.isupport.NETWORK)
}
} else if (n === 332) {
var ch = p[1] || p[0]
var buf = bareIrcEnsureBuffer(client.state, 'channel', ch)
buf.topic = p[p.length - 1] || ''
} else if (n === 353) {
var chan = p.length >= 3 ? p[p.length - 2] : p[1]
var names = String(p[p.length - 1] || '').split(/\s+/)
var b = bareIrcEnsureBuffer(client.state, 'channel', chan)
for (var i = 0; i < names.length; i++) {
if (!names[i]) continue
var mem = bareIrcMemberPrefixes(client.state, names[i])
b.members[
bareIrcCasemap(mem.nick, bareIrcCasemapping(client.state.isupport))
] = mem
}
} else if (n === 366) {
emit('names', { channel: p[1] || p[0] })
} else if (n === 321) {
client.state.channelList = []
client.state.listBusy = true
emit('liststart', {})
} else if (n === 322) {
var types = bareIrcChantypes(client.state.isupport)
var lchan = p[1] || ''
var lcount = parseInt(p[2], 10)
var ltopic = p[p.length - 1] || ''
if (!bareIrcIsChannel(lchan, types) && bareIrcIsChannel(p[0], types)) {
lchan = p[0]
lcount = parseInt(p[1], 10)
}
if (bareIrcIsChannel(lchan, types)) {
if (!client.state.channelList) client.state.channelList = []
if (client.state.channelList.length < 400) {
client.state.channelList.push({
name: lchan,
users: Number.isFinite(lcount) ? lcount : 0,
topic: ltopic
})
}
}
emit('list', { channel: lchan, users: lcount, topic: ltopic })
} else if (n === 323) {
client.state.listBusy = false
if (client.state.channelList) {
client.state.channelList.sort(function (a, b) {
return (b.users || 0) - (a.users || 0)
})
}
emit('listend', { count: (client.state.channelList || []).length })
} else if (n === 376 || n === 422) {
emit('motd_end', {})
} else if (n === 433) {
nickTries++
if (nickTries <= 8) {
var base = String(
opts.nick || client.state.requestedNick || 'os'
).replace(/_+$/, '')
var next = (base.slice(0, 10) + '_' + bareIrcRandomSuffix(3)).slice(
0,
16
)
client.state.requestedNick = next
client.sendRaw('NICK', [next])
} else emit('nickinuse', { nick: p[1] || p[0] })
} else if (n === 470) {
var fromCh = p[1] || ''
var toCh = p[2] || ''
if (toCh) {
var fb = bareIrcEnsureBuffer(client.state, 'channel', toCh)
client.state.currentId = fb.id
bareIrcPushLine(client.state, fb, {
atMs: bareIrcNow(),
kind: 'info',
text: 'forwarded ' + fromCh + ' → ' + toCh
})
}
emit('forward', { from: fromCh, to: toCh })
} else if (n === 900 || n === 903) {
client.state.saslOk = true
if (p.length >= 3) client.state.account = String(p[2] || p[0] || '')
if (saslPending) {
saslPending = false
client.sendRaw('CAP', ['END'])
}
emit('sasl', { ok: true })
} else if (n === 904 || n === 905 || n === 906 || n === 907) {
client.state.saslOk = false
if (saslPending) {
saslPending = false
client.sendRaw('CAP', ['END'])
}
emit('sasl', { ok: false, numeric: n })
}
if (n !== 321 && n !== 322 && n !== 323 && n !== 353 && n !== 366) {
var srv = bareIrcEnsureBuffer(client.state, 'server', '*server')
bareIrcPushLine(client.state, srv, {
atMs: bareIrcNow(),
kind: 'numeric',
command: msg.command,
text: (p.slice(1).join(' ') || p.join(' ')).trim()
})
}
}
function handlePrivmsg(msg, notice) {
var target = msg.params[0] || ''
var text = msg.params[1] || ''
var from = msg.nick || msg.source || ''
var mapping = bareIrcCasemapping(client.state.isupport)
if (
from &&
client.state.ignore &&
client.state.ignore[bareIrcCasemap(from, mapping)]
) {
return
}
var types = bareIrcChantypes(client.state.isupport)
var kind
var name
if (bareIrcIsChannel(target, types)) {
kind = 'channel'
name = target
} else {
kind = 'query'
name = bareIrcEqual(target, client.state.nick, mapping) ? from : target
}
var buf = bareIrcEnsureBuffer(client.state, kind, name)
var ctcp = /^\x01([A-Z]+)(?: ([\s\S]*))?\x01$/.exec(text)
var rec = {
atMs: bareIrcNow(),
kind: notice
? 'notice'
: ctcp && ctcp[1] === 'ACTION'
? 'action'
: 'privmsg',
from: from,
text: ctcp && ctcp[1] === 'ACTION' ? ctcp[2] || '' : text,
highlight: false,
tags: msg.tags
}
var self = client.state.nick
if (
self &&
!notice &&
rec.kind === 'privmsg' &&
text.toLowerCase().indexOf(String(self).toLowerCase()) >= 0
) {
rec.highlight = true
}
bareIrcPushLine(client.state, buf, rec)
emit(notice ? 'notice' : 'privmsg', {
target: target,
nick: from,
text: rec.text,
action: rec.kind === 'action',
tags: msg.tags
})
if (ctcp && !notice && ctcp[1] === 'VERSION') {
client.sendRaw('NOTICE', [from, '\x01VERSION Bare OS irc\x01'])
} else if (ctcp && !notice && ctcp[1] === 'PING') {
client.sendRaw('NOTICE', [from, text])
}
}
client.handleMessage = function (msg) {
if (!msg || !msg.command) return
emit('message', msg)
var cmd = String(msg.command).toUpperCase()
if (cmd === 'PING') {
client.sendRaw('PONG', [msg.params[0] || ''])
emit('ping', msg.params[0])
return
}
if (cmd === 'PONG') return
if (cmd === 'CAP') {
var sub = String(msg.params[1] || msg.params[0] || '').toUpperCase()
var payload = msg.params[msg.params.length - 1] || ''
if (sub === 'LS') {
applyCapList(payload, false)
var more = msg.params[2] === '*' || msg.params[1] === '*'
if (!more) {
capLsDone = true
requestCaps()
}
} else if (sub === 'ACK') {
applyCapList(payload, true)
if (awaitingCapAck) finishCap()
} else if (sub === 'NAK') {
if (awaitingCapAck) finishCap()
} else if (sub === 'NEW') applyCapList(payload, false)
return
}
if (cmd === 'AUTHENTICATE') {
if (String(msg.params[0] || '') === '+' && saslPending) {
var chunks = bareIrcSaslPlainChunks(opts.saslUser, opts.saslPass)
for (var c = 0; c < chunks.length; c++)
client.sendRaw('AUTHENTICATE', [chunks[c]])
}
return
}
if (/^\d{3}$/.test(cmd)) {
handleNumeric(msg)
return
}
if (cmd === 'PRIVMSG') {
handlePrivmsg(msg, false)
return
}
if (cmd === 'NOTICE') {
handlePrivmsg(msg, true)
return
}
if (cmd === 'JOIN') {
var jc = msg.params[0] || ''
var jb = bareIrcEnsureBuffer(client.state, 'channel', jc)
var jm = bareIrcMemberPrefixes(client.state, msg.nick || '')
var jmap = bareIrcCasemapping(client.state.isupport)
if (jm.nick) {
jb.members[bareIrcCasemap(jm.nick, jmap)] = jm
}
var selfJoin = bareIrcEqual(msg.nick || '', client.state.nick, jmap)
if (selfJoin) {
client.state.currentId = jb.id
bareIrcPushLine(client.state, jb, {
atMs: bareIrcNow(),
kind: 'info',
text: 'joined ' + jc
})
} else {
bareIrcPushLine(client.state, jb, {
atMs: bareIrcNow(),
kind: 'info',
text: (msg.nick || '') + ' joined'
})
}
emit('join', { channel: jc, nick: msg.nick, self: selfJoin })
return
}
if (cmd === 'TOPIC') {
var tc = msg.params[0] || ''
var tb = bareIrcEnsureBuffer(client.state, 'channel', tc)
tb.topic = msg.params[1] || ''
bareIrcPushLine(client.state, tb, {
atMs: bareIrcNow(),
kind: 'info',
text: (msg.nick || 'topic') + ': ' + tb.topic
})
emit('topic', { channel: tc, topic: tb.topic, nick: msg.nick })
return
}
if (cmd === 'INVITE') {
var invTo = msg.params[1] || ''
var srvI = bareIrcEnsureBuffer(client.state, 'server', '*server')
bareIrcPushLine(client.state, srvI, {
atMs: bareIrcNow(),
kind: 'info',
text:
(msg.nick || '') +
' invited you to ' +
invTo +
' (/join ' +
invTo +
')'
})
emit('invite', { nick: msg.nick, channel: invTo })
return
}
if (cmd === 'PART' || cmd === 'KICK') {
var pc = msg.params[0] || ''
var pb =
client.state.buffers[
bareIrcBufferId(
'channel',
pc,
bareIrcCasemapping(client.state.isupport)
)
]
var who = cmd === 'KICK' ? msg.params[1] : msg.nick
if (pb && who) {
delete pb.members[
bareIrcCasemap(who, bareIrcCasemapping(client.state.isupport))
]
}
emit(cmd.toLowerCase(), { channel: pc, nick: who })
return
}
if (cmd === 'NICK' && msg.nick) {
var nn = msg.params[0] || ''
if (
bareIrcEqual(
msg.nick,
client.state.nick,
bareIrcCasemapping(client.state.isupport)
)
) {
client.state.nick = nn
}
emit('nick', { from: msg.nick, to: nn })
return
}
if (cmd === 'ERROR') {
emit('error', { text: msg.params[0] || '' })
return
}
var srv = bareIrcEnsureBuffer(client.state, 'server', '*server')
bareIrcPushLine(client.state, srv, {
atMs: bareIrcNow(),
kind: 'info',
command: cmd,
text: (msg.params || []).join(' ')
})
}
client.attach = function (socket) {
sock = socket
client.state.connection = 'registering'
bareIrcEnsureBuffer(client.state, 'server', '*server')
client.state.currentId = 'server'
if (sock && typeof sock.on === 'function') {
sock.on('data', function (chunk) {
var got = lines.push(chunk)
for (var i = 0; i < got.length; i++) {
var parsed = bareIrcParseMessage(got[i])
if (parsed) client.handleMessage(parsed)
}
})
sock.on('error', function (err) {
client.state.connection = 'closed'
emit('error', { error: err })
})
sock.on('close', function () {
client.state.connection = 'closed'
emit('close', {})
})
}
var nick = opts.nick || 'bareos'
client.state.nick = nick
client.state.requestedNick = nick
client.sendRaw('CAP', ['LS', '302'])
if (opts.pass) client.sendRaw('PASS', [opts.pass])
client.sendRaw('NICK', [nick])
client.sendRaw('USER', [
opts.username || 'bareos',
'0',
'*',
opts.realname || 'Bare OS irc'
])
return client
}
return client
}