568 lines
16 KiB
JavaScript
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
|
|
}
|