Files
bare-operating-system/packages/bare-os-seeder/kernel/bin/irc
T
Raven Scott 3965c30352
Release rolling / release (push) Successful in 13m2s
fix IRC user list
2026-08-13 01:10:39 -04:00

2999 lines
83 KiB
Plaintext
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* BARE_OS_BIN_API 1.0.0 — bump when staged /bin script semantics change (see developer guide). */
/** Shared helpers for drive-resident /bin scripts (prepended before each command). */
function bareStdin(ctx) {
return typeof ctx.shellStdin === 'string' ? ctx.shellStdin : ''
}
/** @param {number} mode @param {'file' | 'directory' | 'symlink'} type */
function bareFormatModeString(mode, type) {
const typeChar = type === 'directory' ? 'd' : type === 'symlink' ? 'l' : '-'
const perm = mode & 0o777
const r = (bit) => (perm & bit ? 'r' : '-')
const w = (bit) => (perm & bit ? 'w' : '-')
const x = (bit) => (perm & bit ? 'x' : '-')
return (
typeChar +
r(0o400) +
w(0o200) +
x(0o100) +
r(0o040) +
w(0o020) +
x(0o010) +
r(0o004) +
w(0o002) +
x(0o001)
)
}
/** @param {number} mtimeMs @param {number} [nowMs] */
function bareFormatLsMtime(mtimeMs, nowMs) {
const now = nowMs != null ? nowMs : Date.now()
const d = new Date(mtimeMs)
const months = [
'Jan',
'Feb',
'Mar',
'Apr',
'May',
'Jun',
'Jul',
'Aug',
'Sep',
'Oct',
'Nov',
'Dec'
]
const mon = months[d.getMonth()]
const day = String(d.getDate()).padStart(2, ' ')
const sixMo = 180 * 24 * 3600 * 1000
if (Math.abs(now - mtimeMs) > sixMo) {
const yr = String(d.getFullYear()).padStart(4, ' ')
return mon + ' ' + day + ' ' + yr
}
const hh = String(d.getHours()).padStart(2, '0')
const mm = String(d.getMinutes()).padStart(2, '0')
return mon + ' ' + day + ' ' + hh + ':' + mm
}
/** @param {number} size */
function barePosixBlocks(size) {
return Math.ceil(Number(size) / 512) || 0
}
/**
* Raw stdout for NUL/binary when **`process.stdout.write`** is missing.
* If **`ctx.bareOsBinWrite(Uint8Array|string)`** is set (tests / host), use it.
* @param {Record<string, unknown>} ctx
* @param {string | Uint8Array} chunk
* @returns {boolean}
*/
function bareOsEmitRaw(ctx, chunk) {
if (typeof ctx.bareOsBinWrite === 'function') {
const b4 = ctx.b4a
const u8 =
typeof chunk === 'string'
? b4 && typeof b4.from === 'function'
? b4.from(chunk)
: new TextEncoder().encode(chunk)
: chunk
ctx.bareOsBinWrite(u8 instanceof Uint8Array ? u8 : new Uint8Array(u8))
return true
}
const w = globalThis.process?.stdout?.write
if (typeof w === 'function') {
w.call(globalThis.process.stdout, chunk)
return true
}
return false
}
/** Session env map (`vfs.env`, then `ctx.env`). Never throws. */
function bareOsEnv(ctx) {
const v = ctx && ctx.vfs && ctx.vfs.env
if (v && typeof v === 'object') return v
const e = ctx && ctx.env
if (e && typeof e === 'object') return e
return {}
}
/**
* Strict POSIX-ish decimal integer (no octal, no exponent, no empty).
* @param {unknown} s
* @returns {number}
*/
function bareOsParseDecInt(s) {
const t = String(s == null ? '' : s).trim()
if (!/^[+-]?(?:0|[1-9][0-9]*)$/.test(t)) return NaN
const n = Number.parseInt(t, 10)
return Number.isSafeInteger(n) ? n : NaN
}
/** @param {unknown} s */
function bareOsParseNonNegInt(s) {
const n = bareOsParseDecInt(s)
return n >= 0 ? n : NaN
}
/**
* @param {Record<string, unknown>} ctx
* @param {string} name
* @param {number} fallback
* @param {number} [min]
* @param {number} [max]
*/
function bareOsEnvInt(ctx, name, fallback, min, max) {
const raw = bareOsEnv(ctx)[name]
if (raw == null || raw === '') return fallback
const n = Number.parseInt(String(raw), 10)
if (!Number.isFinite(n)) return fallback
let v = n
if (min != null && v < min) v = min
if (max != null && v > max) v = max
return v
}
/**
* @param {Record<string, unknown>} ctx
* @param {string} msg
* @param {number} [code]
*/
function bareOsFail(ctx, msg, code) {
if (msg) ctx.console.error(msg)
ctx.exitCode = code == null ? 1 : code
}
/** @param {unknown} e */
function bareOsIsNotFoundErr(e) {
const code = e && typeof e === 'object' ? e.code : ''
if (code === 'ENOENT') return true
const msg = String((e && e.message) || e || '')
return /ENOENT|No such file|not found/i.test(msg)
}
/**
* @param {Record<string, unknown>} ctx
* @param {unknown} buf
* @returns {Uint8Array}
*/
function bareOsToU8(ctx, buf) {
if (!buf) return new Uint8Array(0)
if (buf instanceof Uint8Array) return buf
if (ctx && ctx.b4a && typeof ctx.b4a.from === 'function') return ctx.b4a.from(buf)
return new Uint8Array(buf)
}
/** @param {string} dir @param {string} name */
function bareOsJoinPath(dir, name) {
const d = String(dir || '').replace(/\/+$/, '')
const n = String(name || '').replace(/^\/+/, '')
if (!d || d === '/') return '/' + n
return d + '/' + n
}
/** @param {string} p */
function bareOsBaseName(p) {
const t = String(p || '').replace(/\/+$/, '')
if (!t || t === '/') return t === '/' ? '/' : ''
const i = t.lastIndexOf('/')
return i < 0 ? t : t.slice(i + 1) || t
}
/** @param {string} p */
function bareOsParentDir(p) {
const t = String(p || '').replace(/\/+$/, '') || '/'
if (t === '/') return '/'
const i = t.lastIndexOf('/')
return i <= 0 ? '/' : t.slice(0, i) || '/'
}
/** @param {string} p */
function bareOsNormPath(p) {
return String(p || '').replace(/\/+$/, '') || '/'
}
/**
* @param {Record<string, unknown>} ctx
* @param {string} p
*/
function bareOsResolvePath(ctx, p) {
if (ctx && ctx.vfs && typeof ctx.vfs.resolveLogical === 'function') {
try {
return String(ctx.vfs.resolveLogical(p) || p)
} catch {
/* fall through */
}
}
return String(p || '')
}
/**
* True when dest is src or lives under src (self-copy / self-move).
* @param {Record<string, unknown>} ctx
* @param {string} src
* @param {string} dest
*/
function bareOsDestInsideSrc(ctx, src, dest) {
const s = bareOsNormPath(bareOsResolvePath(ctx, src))
const d = bareOsNormPath(bareOsResolvePath(ctx, dest))
if (s === d) return true
if (s === '/') return d !== '/'
return d === s || d.startsWith(s + '/')
}
const BARE_OS_B64_ALPH =
'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'
/** @param {Uint8Array} u8 */
function bareOsB64Encode(u8) {
let out = ''
let i = 0
for (; i + 2 < u8.length; i += 3) {
const n = (u8[i] << 16) | (u8[i + 1] << 8) | u8[i + 2]
out +=
BARE_OS_B64_ALPH[(n >> 18) & 63] +
BARE_OS_B64_ALPH[(n >> 12) & 63] +
BARE_OS_B64_ALPH[(n >> 6) & 63] +
BARE_OS_B64_ALPH[n & 63]
}
const rest = u8.length - i
if (rest === 1) {
const n = u8[i] << 16
out += BARE_OS_B64_ALPH[(n >> 18) & 63] + BARE_OS_B64_ALPH[(n >> 12) & 63] + '=='
} else if (rest === 2) {
const n = (u8[i] << 16) | (u8[i + 1] << 8)
out +=
BARE_OS_B64_ALPH[(n >> 18) & 63] +
BARE_OS_B64_ALPH[(n >> 12) & 63] +
BARE_OS_B64_ALPH[(n >> 6) & 63] +
'='
}
return out
}
/**
* RFC 4648 Base64 decode (also accepts URL-safe alphabet). Rejects junk.
* @param {string} s
* @returns {Uint8Array}
*/
function bareOsB64Decode(s) {
const t = String(s).replace(/\s+/g, '')
if (!t) return new Uint8Array(0)
if (t.length % 4 === 1) throw new Error('invalid base64 length')
let pad = 0
if (t.endsWith('==')) pad = 2
else if (t.endsWith('=')) pad = 1
const body = pad ? t.slice(0, t.length - pad) : t
const bytes = []
let buf = 0
let bits = 0
for (let i = 0; i < body.length; i++) {
const c = body[i]
let v = BARE_OS_B64_ALPH.indexOf(c)
if (v < 0) {
if (c === '-') v = 62
else if (c === '_') v = 63
else throw new Error('invalid base64 character')
}
buf = (buf << 6) | v
bits += 6
if (bits >= 8) {
bits -= 8
bytes.push((buf >> bits) & 255)
}
}
if (pad) {
const want = Math.floor((body.length * 6) / 8)
if (bytes.length > want) bytes.length = want
}
return new Uint8Array(bytes)
}
/**
* @param {string} s
* @returns {Uint8Array}
*/
function bareOsHexDecode(s) {
const t = String(s).replace(/\s+/g, '')
if (t.length % 2 !== 0) throw new Error('odd hex length')
const out = new Uint8Array(t.length / 2)
for (let i = 0; i < out.length; i++) {
const pair = t.slice(i * 2, i * 2 + 2)
if (!/^[0-9a-fA-F]{2}$/.test(pair)) throw new Error('invalid hex')
out[i] = Number.parseInt(pair, 16)
}
return out
}
/** @param {Uint8Array} u8 */
function bareOsHexEncode(u8) {
let s = ''
for (let i = 0; i < u8.length; i++) s += u8[i].toString(16).padStart(2, '0')
return s
}
/** IRC message codec + casemap (modern.ircdocs.horse). No import. */
var BARE_IRC_MAX_LINE = 512
var BARE_IRC_MAX_TAGS = 8191
function bareIrcStripCrlf(s) {
var t = String(s == null ? '' : s)
if (t.charAt(t.length - 1) === '\n') t = t.slice(0, -1)
if (t.charAt(t.length - 1) === '\r') t = t.slice(0, -1)
return t
}
function bareIrcUnescapeTagValue(v) {
var s = String(v == null ? '' : v)
var out = ''
for (var i = 0; i < s.length; i++) {
var c = s.charAt(i)
if (c !== '\\') {
out += c
continue
}
var n = s.charAt(i + 1)
if (n === ':') {
out += ';'
i++
} else if (n === 's') {
out += ' '
i++
} else if (n === '\\') {
out += '\\'
i++
} else if (n === 'r') {
out += '\r'
i++
} else if (n === 'n') {
out += '\n'
i++
} else if (n) {
out += n
i++
}
}
return out
}
function bareIrcEscapeTagValue(v) {
var s = String(v == null ? '' : v)
var out = ''
for (var i = 0; i < s.length; i++) {
var c = s.charAt(i)
if (c === ';') out += '\\:'
else if (c === ' ') out += '\\s'
else if (c === '\\') out += '\\\\'
else if (c === '\r') out += '\\r'
else if (c === '\n') out += '\\n'
else out += c
}
return out
}
/**
* @param {string} line
* @returns {{ tags: Record<string, string>, source: string, nick: string, user: string, host: string, command: string, params: string[] } | null}
*/
function bareIrcParseMessage(line) {
var raw = bareIrcStripCrlf(line)
if (!raw) return null
var tags = {}
var i = 0
if (raw.charAt(0) === '@') {
var sp = raw.indexOf(' ')
if (sp < 0) return null
var tagStr = raw.slice(1, sp)
i = sp + 1
while (i < raw.length && raw.charAt(i) === ' ') i++
var parts = tagStr.split(';')
for (var t = 0; t < parts.length; t++) {
var item = parts[t]
if (!item) continue
var eq = item.indexOf('=')
if (eq < 0) tags[item] = ''
else tags[item.slice(0, eq)] = bareIrcUnescapeTagValue(item.slice(eq + 1))
}
raw = raw.slice(i)
i = 0
}
var source = ''
var nick = ''
var user = ''
var host = ''
if (raw.charAt(0) === ':') {
var sp2 = raw.indexOf(' ')
if (sp2 < 0) return null
source = raw.slice(1, sp2)
raw = raw.slice(sp2 + 1)
while (raw.charAt(0) === ' ') raw = raw.slice(1)
var bang = source.indexOf('!')
var at = source.indexOf('@')
if (bang >= 0) {
nick = source.slice(0, bang)
if (at > bang) {
user = source.slice(bang + 1, at)
host = source.slice(at + 1)
} else user = source.slice(bang + 1)
} else if (at >= 0) {
nick = source.slice(0, at)
host = source.slice(at + 1)
} else nick = source
}
while (raw.charAt(0) === ' ') raw = raw.slice(1)
var cmdEnd = raw.indexOf(' ')
var command
var rest
if (cmdEnd < 0) {
command = raw
rest = ''
} else {
command = raw.slice(0, cmdEnd)
rest = raw.slice(cmdEnd + 1)
}
if (!command) return null
var params = []
while (rest.length) {
while (rest.charAt(0) === ' ') rest = rest.slice(1)
if (!rest) break
if (rest.charAt(0) === ':') {
params.push(rest.slice(1))
break
}
var nsp = rest.indexOf(' ')
if (nsp < 0) {
params.push(rest)
break
}
params.push(rest.slice(0, nsp))
rest = rest.slice(nsp + 1)
}
return {
tags: tags,
source: source,
nick: nick,
user: user,
host: host,
command: command,
params: params
}
}
/**
* @param {{ tags?: Record<string, string>, source?: string, command: string, params?: string[] }} msg
*/
function bareIrcSerializeMessage(msg) {
var m = msg || {}
var out = ''
var tags = m.tags
if (tags && typeof tags === 'object') {
var keys = []
var k
for (k in tags) {
if (Object.prototype.hasOwnProperty.call(tags, k)) keys.push(k)
}
if (keys.length) {
out += '@'
for (var i = 0; i < keys.length; i++) {
if (i) out += ';'
var val = tags[keys[i]]
if (val == null || val === '') out += keys[i]
else out += keys[i] + '=' + bareIrcEscapeTagValue(val)
}
out += ' '
}
}
if (m.source) out += ':' + m.source + ' '
out += String(m.command || '')
var params = Array.isArray(m.params) ? m.params : []
for (var p = 0; p < params.length; p++) {
var part = String(params[p] == null ? '' : params[p])
var last = p === params.length - 1
if (last) {
out += ' :' + part
} else {
out += ' ' + part
}
}
return out + '\r\n'
}
/**
* Byte/string accumulator. Returns complete lines without the delimiter.
*/
function bareIrcCreateLineBuffer() {
var acc = ''
return {
push: function (chunk) {
if (chunk == null) return []
var s
if (typeof chunk === 'string') s = chunk
else if (typeof TextDecoder !== 'undefined') {
s = new TextDecoder('utf-8', { fatal: false }).decode(
chunk instanceof Uint8Array ? chunk : new Uint8Array(chunk)
)
} else {
var u = chunk instanceof Uint8Array ? chunk : []
s = ''
for (var i = 0; i < u.length; i++) s += String.fromCharCode(u[i])
}
acc += s.replace(/\r\n/g, '\n').replace(/\r/g, '\n')
var out = []
for (;;) {
var n = acc.indexOf('\n')
if (n < 0) break
var line = acc.slice(0, n)
acc = acc.slice(n + 1)
if (line) out.push(line)
}
return out
},
pending: function () {
return acc
}
}
}
function bareIrcRfc1459LowerChar(c) {
var code = c.charCodeAt(0)
if (code >= 65 && code <= 90) return String.fromCharCode(code + 32)
if (c === '[') return '{'
if (c === ']') return '}'
if (c === '\\') return '|'
if (c === '~') return '^'
return c
}
function bareIrcCasemap(s, mapping) {
var t = String(s == null ? '' : s)
var kind = String(mapping || 'rfc1459').toLowerCase()
if (kind === 'ascii') return t.toLowerCase()
var out = ''
for (var i = 0; i < t.length; i++) out += bareIrcRfc1459LowerChar(t.charAt(i))
return out
}
function bareIrcEqual(a, b, mapping) {
return bareIrcCasemap(a, mapping) === bareIrcCasemap(b, mapping)
}
function bareIrcIsChannel(name, chantypes) {
var n = String(name || '')
if (!n) return false
var types = chantypes || '#'
return types.indexOf(n.charAt(0)) >= 0
}
/** RPL_ISUPPORT (005) token map. */
function bareIrcParseIsupport(params) {
var tokens = {}
var list = Array.isArray(params) ? params : []
var last = list.length
if (last && String(list[last - 1]).indexOf('are supported') >= 0) last--
var start = 0
if (
list.length &&
!/=/.test(String(list[0])) &&
!/^[A-Z]+$/.test(String(list[0]))
) {
start = 1
}
for (var i = start; i < last; i++) {
var raw = String(list[i] || '')
if (!raw) continue
var eq = raw.indexOf('=')
if (eq < 0) tokens[raw] = true
else tokens[raw.slice(0, eq)] = raw.slice(eq + 1)
}
return tokens
}
function bareIrcIsupportDefaults() {
return {
CASEMAPPING: 'rfc1459',
CHANTYPES: '#&',
PREFIX: '(ov)@+',
NICKLEN: '16',
CHANNELLEN: '50',
TOPICLEN: '390',
NETWORK: ''
}
}
function bareIrcMergeIsupport(cur, incoming) {
var out = cur && typeof cur === 'object' ? cur : bareIrcIsupportDefaults()
var next = incoming && typeof incoming === 'object' ? incoming : {}
var k
for (k in next) {
if (Object.prototype.hasOwnProperty.call(next, k)) out[k] = next[k]
}
return out
}
function bareIrcPrefixMap(isupport) {
var raw =
isupport && isupport.PREFIX != null ? String(isupport.PREFIX) : '(ov)@+'
var m = /^\(([^)]+)\)(.*)$/.exec(raw)
var modes = m ? m[1] : 'ov'
var prefs = m ? m[2] : '@+'
var byMode = {}
var byPref = {}
var i
for (i = 0; i < modes.length && i < prefs.length; i++) {
byMode[modes.charAt(i)] = prefs.charAt(i)
byPref[prefs.charAt(i)] = modes.charAt(i)
}
return { modes: modes, prefixes: prefs, byMode: byMode, byPref: byPref }
}
function bareIrcNicklen(isupport) {
var n = parseInt(isupport && isupport.NICKLEN, 10)
return Number.isFinite(n) && n > 0 ? n : 16
}
function bareIrcChantypes(isupport) {
return (isupport && isupport.CHANTYPES) || '#'
}
function bareIrcCasemapping(isupport) {
return (isupport && isupport.CASEMAPPING) || 'rfc1459'
}
/** SASL PLAIN (IRCv3). No import. */
function bareIrcB64Encode(bytes) {
var table = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'
var s = ''
var i = 0
while (i < bytes.length) {
var a = bytes[i++]
var b = i < bytes.length ? bytes[i++] : NaN
var c = i < bytes.length ? bytes[i++] : NaN
var triple =
((a & 255) << 16) |
((Number.isFinite(b) ? b & 255 : 0) << 8) |
(Number.isFinite(c) ? c & 255 : 0)
s += table.charAt((triple >> 18) & 63)
s += table.charAt((triple >> 12) & 63)
s += Number.isFinite(b) ? table.charAt((triple >> 6) & 63) : '='
s += Number.isFinite(c) ? table.charAt(triple & 63) : '='
}
return s
}
function bareIrcUtf8Bytes(str) {
var s = String(str == null ? '' : str)
if (typeof TextEncoder !== 'undefined')
return Array.from(new TextEncoder().encode(s))
var out = []
for (var i = 0; i < s.length; i++) out.push(s.charCodeAt(i) & 255)
return out
}
/**
* AUTHENTICATE PLAIN payload (account \0 account \0 password), chunked ≤400.
* @returns {string[]}
*/
function bareIrcSaslPlainChunks(user, pass) {
var u = String(user || '')
var p = String(pass || '')
var raw = bareIrcUtf8Bytes('\0' + u + '\0' + p)
var b64 = bareIrcB64Encode(raw)
var chunks = []
var i = 0
if (!b64) return ['+']
while (i < b64.length) {
chunks.push(b64.slice(i, i + 400))
i += 400
}
return chunks
}
/** IRC session buffers and members. */
var BARE_IRC_MAX_TRANSCRIPT = 2000
function bareIrcCreateState(opts) {
opts = opts || {}
var max =
opts.maxTranscript > 0 ? opts.maxTranscript : BARE_IRC_MAX_TRANSCRIPT
return {
connection: 'idle',
nick: opts.nick || '',
requestedNick: opts.nick || '',
account: '',
away: false,
network: opts.network || '',
isupport: bareIrcIsupportDefaults(),
capsOffered: {},
capsEnabled: {},
buffers: {},
currentId: 'server',
maxTranscript: max,
registered: false,
tls: opts.tls !== false,
saslOk: false,
channelList: [],
listBusy: false,
ignore: {}
}
}
function bareIrcBufferId(kind, name, mapping) {
if (kind === 'server') return 'server'
return kind + ':' + bareIrcCasemap(name, mapping)
}
function bareIrcEnsureBuffer(state, kind, name) {
var mapping = bareIrcCasemapping(state.isupport)
var id = bareIrcBufferId(kind, name, mapping)
if (!state.buffers[id]) {
state.buffers[id] = {
id: id,
kind: kind,
name: name || '*server',
topic: '',
members: {},
lines: [],
unread: 0,
highlight: false
}
}
return state.buffers[id]
}
function bareIrcPushLine(state, buf, rec) {
if (!buf) return
buf.lines.push(rec)
while (buf.lines.length > state.maxTranscript) buf.lines.shift()
if (buf.id !== state.currentId) {
buf.unread++
if (rec && rec.highlight) buf.highlight = true
}
}
function bareIrcListBuffers(state) {
var ids = Object.keys(state.buffers)
ids.sort(function (a, b) {
if (a === 'server') return -1
if (b === 'server') return 1
return a < b ? -1 : a > b ? 1 : 0
})
return ids.map(function (id) {
return state.buffers[id]
})
}
function bareIrcMemberPrefixes(state, nickRaw) {
var pref = bareIrcPrefixMap(state.isupport)
var nick = String(nickRaw || '')
var modes = ''
while (nick && pref.byPref[nick.charAt(0)]) {
modes += pref.byPref[nick.charAt(0)]
nick = nick.slice(1)
}
return { nick: nick, modes: modes }
}
/** Libera defaults + ~/.irc config/secrets. */
function bareIrcRandomSuffix(n) {
var alphabet = 'abcdefghijkmnpqrstuvwxyz23456789'
var out = ''
var i
var len = n > 0 ? n : 4
for (i = 0; i < len; i++) {
out += alphabet.charAt(Math.floor(Math.random() * alphabet.length))
}
return out
}
function bareIrcRandomNick() {
return ('os' + bareIrcRandomSuffix(5)).slice(0, 16)
}
function bareIrcNickLooksMachine(nick) {
var s = String(nick || '')
if (!s) return true
if (/^[0-9a-f]{8,16}$/i.test(s)) return true
if (/^[0-9a-f]{12}$/i.test(s)) return true
if (/^[a-z0-9]{10,}-[a-z0-9]{4,}$/i.test(s)) return true
return false
}
function bareIrcHostLooksLibera(host) {
var h = String(host || '')
.trim()
.toLowerCase()
return h === 'libera.chat' || h.endsWith('.libera.chat')
}
var BARE_IRC_LIBERA = {
name: 'libera',
host: 'irc.libera.chat',
port: 6697,
tls: true,
regional: {
eu: 'irc.eu.libera.chat',
us: 'irc.us.libera.chat',
au: 'irc.au.libera.chat',
ea: 'irc.ea.libera.chat',
ipv4: 'irc.ipv4.libera.chat',
ipv6: 'irc.ipv6.libera.chat'
}
}
function bareIrcHome(ctx) {
var env = ctx && ctx.env && typeof ctx.env === 'object' ? ctx.env : {}
if (env.HOME) return String(env.HOME)
if (typeof ctx.bareOsResolveHome === 'function') {
try {
return String(ctx.bareOsResolveHome() || '')
} catch (e) {
/* ignore */
}
}
return '/home/guest'
}
function bareIrcConfigPaths(ctx) {
var home = bareIrcHome(ctx).replace(/\/+$/, '')
return {
dir: home + '/.irc',
config: home + '/.irc/config.json',
secrets: home + '/.irc/secrets.json',
bindings: home + '/.irc/bindings.json'
}
}
function bareIrcReadJsonFile(ctx, path) {
if (!ctx || !ctx.vfs || typeof ctx.vfs.readFile !== 'function') return null
try {
var buf = ctx.vfs.readFile(path)
if (buf && typeof buf.then === 'function') return buf
if (!buf) return null
var t =
ctx.b4a && typeof ctx.b4a.toString === 'function'
? ctx.b4a.toString(buf)
: String(buf)
t = String(t || '').trim()
if (!t) return null
return JSON.parse(t)
} catch (e) {
return null
}
}
async function bareIrcLoadConfig(ctx) {
var paths = bareIrcConfigPaths(ctx)
var raw = bareIrcReadJsonFile(ctx, paths.config)
if (raw && typeof raw.then === 'function') {
try {
var buf = await raw
if (!buf) raw = null
else {
var t =
ctx.b4a && typeof ctx.b4a.toString === 'function'
? ctx.b4a.toString(buf)
: String(buf)
raw = t && String(t).trim() ? JSON.parse(String(t).trim()) : null
}
} catch (e) {
raw = null
}
}
var sec = bareIrcReadJsonFile(ctx, paths.secrets)
if (sec && typeof sec.then === 'function') {
try {
var sb = await sec
if (!sb) sec = null
else {
var st =
ctx.b4a && typeof ctx.b4a.toString === 'function'
? ctx.b4a.toString(sb)
: String(sb)
sec = st && String(st).trim() ? JSON.parse(String(st).trim()) : null
}
} catch (e2) {
sec = null
}
}
return {
paths: paths,
config: raw && typeof raw === 'object' ? raw : {},
secrets: sec && typeof sec === 'object' ? sec : {}
}
}
function bareIrcResolveConnect(ctx, flags, loaded) {
var env = ctx && ctx.env && typeof ctx.env === 'object' ? ctx.env : {}
var cfg = (loaded && loaded.config) || {}
var sec = (loaded && loaded.secrets) || {}
var servers = Array.isArray(cfg.servers) ? cfg.servers : []
var named = flags.network || flags.host || ''
var srv = servers[0] || {}
if (named) {
for (var i = 0; i < servers.length; i++) {
if (servers[i] && servers[i].name === named) srv = servers[i]
}
if (named === 'libera' || named === 'irc.libera.chat') {
srv = Object.assign({}, BARE_IRC_LIBERA, srv)
} else if (BARE_IRC_LIBERA.regional[named]) {
srv = Object.assign({}, BARE_IRC_LIBERA, srv, {
host: BARE_IRC_LIBERA.regional[named]
})
}
}
var host = flags.host || srv.host || BARE_IRC_LIBERA.host
if (host === 'libera') host = BARE_IRC_LIBERA.host
var port = flags.port != null ? flags.port : srv.port || BARE_IRC_LIBERA.port
var tls = flags.tls != null ? flags.tls : srv.tls !== false
var nick = flags.nick || cfg.nick || env.BARE_IRC_NICK || ''
var generatedNick = false
if (!nick) {
var candidate = env.USER || env.LOGNAME || ''
if (candidate && !bareIrcNickLooksMachine(candidate)) nick = candidate
}
if (!nick) {
nick = bareIrcRandomNick()
generatedNick = true
}
var saslUser = flags.saslUser || cfg.saslUser || sec.saslUser || ''
var saslPass = flags.saslPass != null ? flags.saslPass : sec.saslPass || ''
var autojoin = Array.isArray(flags.autojoin)
? flags.autojoin
: Array.isArray(cfg.autojoin)
? cfg.autojoin
: []
return {
host: host,
port: port,
tls: tls,
insecurePlain: !!flags.insecurePlain,
nick: String(nick).slice(0, 16),
generatedNick: generatedNick,
username: flags.username || cfg.username || 'bareos',
realname: flags.realname || cfg.realname || 'Bare OS irc',
pass: flags.pass || sec.serverPass || '',
saslUser: saslUser,
saslPass: saslPass,
autojoin: autojoin,
shareChannels: cfg.shareChannels === true,
network: srv.name || (bareIrcHostLooksLibera(host) ? 'libera' : host)
}
}
function bareIrcParseFlags(argv) {
var args = argv.slice(1)
var flags = {
help: false,
json: false,
tls: null,
insecurePlain: false,
host: '',
port: null,
nick: '',
saslUser: '',
network: '',
rest: []
}
for (var i = 0; i < args.length; i++) {
var a = args[i]
if (a === '-h' || a === '--help') flags.help = true
else if (a === '--json') flags.json = true
else if (a === '--tls') flags.tls = true
else if (a === '--plain') flags.tls = false
else if (a === '--insecure-plain') flags.insecurePlain = true
else if (a === '--host' && args[i + 1]) flags.host = args[++i]
else if (a === '--port' && args[i + 1]) flags.port = parseInt(args[++i], 10)
else if (a === '--nick' && args[i + 1]) flags.nick = args[++i]
else if (a === '--sasl-user' && args[i + 1]) flags.saslUser = args[++i]
else if (a === '--network' && args[i + 1]) flags.network = args[++i]
else if (a && a.charAt(0) !== '-') flags.rest.push(a)
}
if (flags.rest[0] && !flags.host && !flags.network) {
var r0 = flags.rest[0]
if (
r0 === 'libera' ||
r0 === 'eu' ||
r0 === 'us' ||
r0 === 'au' ||
r0 === 'ea' ||
r0.indexOf('.') >= 0
) {
flags.network = r0
flags.rest = flags.rest.slice(1)
}
}
return flags
}
async function bareIrcRememberNick(ctx, nick) {
var n = String(nick || '').trim()
if (!n) return false
if (!ctx || !ctx.vfs || typeof ctx.vfs.writeFile !== 'function') return false
var paths = bareIrcConfigPaths(ctx)
var loaded = await bareIrcLoadConfig(ctx)
var cfg = loaded.config || {}
if (cfg.nick) return false
cfg.nick = n
try {
if (typeof ctx.vfs.mkdir === 'function') {
var mk = ctx.vfs.mkdir(paths.dir, { recursive: true })
if (mk && typeof mk.then === 'function') await mk
}
var body = JSON.stringify(cfg, null, 2)
var w = ctx.vfs.writeFile(paths.config, body)
if (w && typeof w.then === 'function') await w
return true
} catch (e) {
return false
}
}
/** 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
}
/** Resolve a TCP/TLS socket for /bin/irc without requiring ctx.bareOsTlsConnect. */
function bareIrcTryHostTls(ctx) {
var req = null
try {
if (typeof globalThis.require === 'function') req = globalThis.require
} catch (e) {
req = null
}
if (
!req &&
typeof Bare !== 'undefined' &&
Bare &&
typeof Bare.require === 'function'
) {
req = Bare.require
}
if (!req) return null
var names = ['bare-tls', 'tls', 'node:tls']
var i
for (i = 0; i < names.length; i++) {
try {
var m = req(names[i])
if (m) return m
} catch (e2) {
/* next */
}
}
return null
}
function bareIrcTlsLib(ctx) {
if (ctx && ctx.bare) {
if (ctx.bare.bareTls) return ctx.bare.bareTls
if (ctx.bare.tls) return ctx.bare.tls
}
return bareIrcTryHostTls(ctx)
}
function bareIrcOpenPlain(ctx, spec) {
if (typeof ctx.bareOsTelnetConnect === 'function') {
return ctx.bareOsTelnetConnect(spec.host, spec.port, {})
}
var net =
ctx && ctx.bare && (ctx.bare.bareTcp || ctx.bare.bareNet || ctx.bare.net)
if (net && typeof net.createConnection === 'function') {
return net.createConnection({ host: spec.host, port: spec.port })
}
throw new Error('irc: no plaintext connector (ctx.bareOsTelnetConnect)')
}
function bareIrcOpenTls(ctx, spec) {
var opts = {
host: spec.host,
port: spec.port,
servername: spec.host,
rejectUnauthorized: true
}
if (typeof ctx.bareOsTlsConnect === 'function') {
return ctx.bareOsTlsConnect(spec.host, spec.port, opts)
}
var tls = bareIrcTlsLib(ctx)
if (tls && typeof tls.connect === 'function') {
return tls.connect(opts)
}
if (tls && typeof tls.createConnection === 'function') {
return tls.createConnection(opts)
}
var Sock = tls && (tls.Socket || tls.TLSSocket)
if (typeof Sock === 'function') {
var tcp = bareIrcOpenPlain(ctx, spec)
return new Sock(tcp, { host: spec.host })
}
throw new Error(
'irc: no TLS connector (need ctx.bareOsTlsConnect or ctx.bare.bareTls)'
)
}
function bareIrcDial(ctx, spec) {
if (typeof ctx.bareOsIrcHostAllowed === 'function') {
var gate = ctx.bareOsIrcHostAllowed(spec.host)
if (gate && gate.ok === false) {
throw new Error(
'irc: host blocked (' + (gate.reason || 'policy') + '): ' + spec.host
)
}
}
if (spec.tls === false) {
var plainOk =
typeof ctx.bareOsIrcPlaintextAllowed === 'function'
? ctx.bareOsIrcPlaintextAllowed(spec.host, {
tls: false,
insecurePlain: spec.insecurePlain
})
: { ok: !bareIrcHostLooksLibera(spec.host) || spec.insecurePlain }
if (plainOk && plainOk.ok === false) {
throw new Error('irc: ' + (plainOk.reason || 'plaintext blocked'))
}
return bareIrcOpenPlain(ctx, spec)
}
return bareIrcOpenTls(ctx, spec)
}
/** Slash-command table for /bin/irc (modern client + common aliases). */
var BARE_IRC_FEATURED_ROOMS = [
{ name: '#libera', users: '', topic: 'Official Libera.Chat help' },
{ name: '#linux', users: '', topic: 'Linux discussion' },
{ name: '#programming', users: '', topic: 'Programming' },
{ name: '#javascript', users: '', topic: 'JavaScript' },
{ name: '#python', users: '', topic: 'Python' },
{ name: '#rust', users: '', topic: 'Rust' },
{ name: '#archlinux', users: '', topic: 'Arch Linux' },
{ name: '#debian', users: '', topic: 'Debian' },
{ name: '#ubuntu', users: '', topic: 'Ubuntu' },
{ name: '#vim', users: '', topic: 'Vim' },
{ name: '#emacs', users: '', topic: 'Emacs' },
{ name: '#git', users: '', topic: 'Git' }
]
function bareIrcNeedTarget(buf) {
return buf && buf.kind && buf.kind !== 'server' ? buf.name : ''
}
function bareIrcModeNicks(client, chan, flag, nicks) {
if (!chan || !nicks.length) return false
var i
for (i = 0; i < nicks.length; i++) {
client.sendRaw('MODE', [chan, flag, nicks[i]])
}
return true
}
function bareIrcRunSlash(client, rawLine, opts) {
opts = opts || {}
var buf = opts.buf || null
var ctx = opts.ctx
var line = String(rawLine || '').trim()
var out = {
ok: true,
help: false,
rooms: false,
roomsQuery: '',
quit: false,
close: false,
clear: false,
usage: '',
note: ''
}
if (!line || line.charAt(0) !== '/') {
out.ok = false
return out
}
var body = line.slice(1)
var sp = body.search(/\s/)
var cmd = (sp < 0 ? body : body.slice(0, sp)).toLowerCase()
var rest = sp < 0 ? '' : body.slice(sp + 1).trim()
var parts = rest ? rest.split(/\s+/) : []
var chan = bareIrcNeedTarget(buf)
if (cmd === 'help' || cmd === 'h' || cmd === '?') {
out.help = true
return out
}
if (cmd === 'quit' || cmd === 'exit') {
client.quit(rest || 'Leaving')
out.quit = true
return out
}
if (cmd === 'disconnect') {
client.quit(rest || 'Disconnect')
return out
}
if (cmd === 'reconnect') {
if (opts.reconnect) opts.reconnect()
else out.note = 'reconnect unavailable'
return out
}
if (cmd === 'join' || cmd === 'j') {
if (!parts[0]) {
out.usage = '/join #channel [key]'
return out
}
client.join(parts[0], parts[1])
return out
}
if (cmd === 'part' || cmd === 'leave') {
var pchan = parts[0] && parts[0].charAt(0) === '#' ? parts.shift() : chan
if (!pchan) {
out.usage = '/part [#channel] [reason]'
return out
}
client.part(pchan, parts.join(' '))
return out
}
if (cmd === 'cycle' || cmd === 'hop') {
var cchan = parts[0] || chan
if (!cchan) {
out.usage = '/cycle [#channel]'
return out
}
client.part(cchan, 'cycling')
client.join(cchan)
return out
}
if (cmd === 'query' || cmd === 'q') {
if (!parts[0]) {
out.usage = '/query NICK [text]'
return out
}
var qb = bareIrcEnsureBuffer(client.state, 'query', parts[0])
client.state.currentId = qb.id
if (parts.slice(1).join(' '))
client.sendPrivmsg(parts[0], parts.slice(1).join(' '))
return out
}
if (cmd === 'msg' || cmd === 'privmsg' || cmd === 'm') {
if (!parts[0] || !parts.slice(1).join(' ')) {
out.usage = '/msg TARGET text'
return out
}
client.sendPrivmsg(parts[0], parts.slice(1).join(' '))
return out
}
if (cmd === 'notice') {
if (!parts[0] || !parts.slice(1).join(' ')) {
out.usage = '/notice TARGET text'
return out
}
client.sendRaw('NOTICE', [parts[0], parts.slice(1).join(' ')])
return out
}
if (cmd === 'nick') {
if (!parts[0]) {
out.usage = '/nick NEWNICK'
return out
}
client.sendRaw('NICK', [parts[0]])
return out
}
if (cmd === 'whois') {
if (!parts[0]) {
out.usage = '/whois NICK'
return out
}
client.sendRaw(
'WHOIS',
parts.length > 1 ? [parts[0], parts[1]] : [parts[0]]
)
return out
}
if (cmd === 'whowas') {
if (!parts[0]) {
out.usage = '/whowas NICK'
return out
}
client.sendRaw('WHOWAS', [parts[0]])
return out
}
if (cmd === 'who') {
client.sendRaw('WHO', [parts[0] || chan || '*'])
return out
}
if (cmd === 'names') {
client.sendRaw('NAMES', [parts[0] || chan || ''].filter(Boolean))
return out
}
if (cmd === 'topic') {
var tchan = chan
var topic = rest
if (
parts[0] &&
(parts[0].charAt(0) === '#' || parts[0].charAt(0) === '&')
) {
tchan = parts[0]
topic = parts.slice(1).join(' ')
}
if (!tchan) {
out.usage = '/topic [#channel] [text]'
return out
}
if (topic) client.sendRaw('TOPIC', [tchan, topic])
else client.sendRaw('TOPIC', [tchan])
return out
}
if (cmd === 'me' || cmd === 'action') {
if (!rest || !chan) {
out.usage = '/me action text (in a channel or query)'
return out
}
client.sendPrivmsg(chan, '\x01ACTION ' + rest + '\x01')
return out
}
if (cmd === 'away') {
client.sendRaw('AWAY', rest ? [rest] : [])
return out
}
if (cmd === 'back') {
client.sendRaw('AWAY', [])
return out
}
if (cmd === 'quote' || cmd === 'raw') {
if (!rest) {
out.usage = '/quote COMMAND [params]'
return out
}
var qparts = rest.split(/\s+/)
client.sendRaw(qparts[0], qparts.slice(1))
return out
}
if (cmd === 'list' || cmd === 'rooms' || cmd === 'browse') {
out.rooms = true
out.roomsQuery = rest
return out
}
if (cmd === 'motd') {
client.sendRaw('MOTD', parts[0] ? [parts[0]] : [])
return out
}
if (cmd === 'lusers') {
client.sendRaw('LUSERS', parts)
return out
}
if (cmd === 'admin') {
client.sendRaw('ADMIN', parts[0] ? [parts[0]] : [])
return out
}
if (cmd === 'info') {
client.sendRaw('INFO', parts[0] ? [parts[0]] : [])
return out
}
if (cmd === 'time') {
client.sendRaw('TIME', parts[0] ? [parts[0]] : [])
return out
}
if (cmd === 'links') {
client.sendRaw('LINKS', parts)
return out
}
if (cmd === 'map') {
client.sendRaw('MAP', [])
return out
}
if (cmd === 'rules') {
client.sendRaw('RULES', [])
return out
}
if (cmd === 'stats') {
client.sendRaw('STATS', parts[0] ? parts : ['u'])
return out
}
if (cmd === 'version') {
if (parts[0] && parts[0].charAt(0) !== '#') {
client.sendRaw('PRIVMSG', [parts[0], '\x01VERSION\x01'])
return out
}
client.sendRaw('VERSION', parts[0] ? [parts[0]] : [])
return out
}
if (cmd === 'userhost') {
if (!parts[0]) {
out.usage = '/userhost NICK [NICK...]'
return out
}
client.sendRaw('USERHOST', parts.slice(0, 5))
return out
}
if (cmd === 'ison') {
if (!parts[0]) {
out.usage = '/ison NICK [NICK...]'
return out
}
client.sendRaw('ISON', parts)
return out
}
if (cmd === 'mode') {
if (!parts[0]) {
out.usage = '/mode TARGET [modes] [args]'
return out
}
client.sendRaw('MODE', parts)
return out
}
if (cmd === 'umode') {
client.sendRaw(
'MODE',
[client.state.nick].concat(parts.length ? parts : [])
)
return out
}
if (cmd === 'kick') {
var kchan = chan
var knick = parts[0]
var kreason = parts.slice(1).join(' ')
if (
parts[0] &&
(parts[0].charAt(0) === '#' || parts[0].charAt(0) === '&')
) {
kchan = parts[0]
knick = parts[1]
kreason = parts.slice(2).join(' ')
}
if (!kchan || !knick) {
out.usage = '/kick [channel] NICK [reason]'
return out
}
client.sendRaw('KICK', kreason ? [kchan, knick, kreason] : [kchan, knick])
return out
}
if (cmd === 'invite') {
if (!parts[0]) {
out.usage = '/invite NICK [#channel]'
return out
}
var ichan = parts[1] || chan
if (!ichan) {
out.usage = '/invite NICK #channel'
return out
}
client.sendRaw('INVITE', [parts[0], ichan])
return out
}
if (cmd === 'ban') {
if (!chan) {
out.usage = '/ban [mask] (in a channel)'
return out
}
if (!parts[0]) client.sendRaw('MODE', [chan, '+b'])
else client.sendRaw('MODE', [chan, '+b', parts[0]])
return out
}
if (cmd === 'unban') {
if (!chan || !parts[0]) {
out.usage = '/unban mask (in a channel)'
return out
}
client.sendRaw('MODE', [chan, '-b', parts[0]])
return out
}
if (cmd === 'op') {
if (!chan || !parts[0]) {
out.usage = '/op NICK [NICK...] (in a channel)'
return out
}
bareIrcModeNicks(client, chan, '+o', parts)
return out
}
if (cmd === 'deop') {
if (!chan || !parts[0]) {
out.usage = '/deop NICK [NICK...]'
return out
}
bareIrcModeNicks(client, chan, '-o', parts)
return out
}
if (cmd === 'voice') {
if (!chan || !parts[0]) {
out.usage = '/voice NICK [NICK...]'
return out
}
bareIrcModeNicks(client, chan, '+v', parts)
return out
}
if (cmd === 'devoice') {
if (!chan || !parts[0]) {
out.usage = '/devoice NICK [NICK...]'
return out
}
bareIrcModeNicks(client, chan, '-v', parts)
return out
}
if (cmd === 'knock') {
if (!parts[0]) {
out.usage = '/knock #channel [message]'
return out
}
client.sendRaw(
'KNOCK',
parts.slice(1).join(' ')
? [parts[0], parts.slice(1).join(' ')]
: [parts[0]]
)
return out
}
if (cmd === 'ctcp') {
if (!parts[0] || !parts[1]) {
out.usage = '/ctcp NICK CMD [args]'
return out
}
var ctcp =
parts[1].toUpperCase() +
(parts.slice(2).join(' ') ? ' ' + parts.slice(2).join(' ') : '')
client.sendRaw('PRIVMSG', [parts[0], '\x01' + ctcp + '\x01'])
return out
}
if (cmd === 'ping') {
if (parts[0]) {
client.sendRaw('PRIVMSG', [parts[0], '\x01PING ' + Date.now() + '\x01'])
} else {
client.sendRaw('PING', [client.state.nick || 'irc'])
}
return out
}
if (cmd === 'ignore') {
if (!parts[0]) {
var keys = Object.keys(client.state.ignore || {})
out.note = keys.length
? 'ignoring: ' + keys.join(', ')
: 'ignore list empty'
return out
}
if (!client.state.ignore) client.state.ignore = {}
client.state.ignore[
bareIrcCasemap(parts[0], bareIrcCasemapping(client.state.isupport))
] = parts[0]
out.note = 'ignoring ' + parts[0]
return out
}
if (cmd === 'unignore') {
if (!parts[0]) {
out.usage = '/unignore NICK'
return out
}
if (client.state.ignore) {
delete client.state.ignore[
bareIrcCasemap(parts[0], bareIrcCasemapping(client.state.isupport))
]
}
out.note = 'unignored ' + parts[0]
return out
}
if (cmd === 'clear') {
out.clear = true
return out
}
if (cmd === 'close' || cmd === 'wc') {
out.close = true
return out
}
if (cmd === 'win' || cmd === 'buffer' || cmd === 'window') {
var n = parseInt(parts[0], 10)
var list = bareIrcListBuffers(client.state)
if (n >= 1 && n <= list.length) client.state.currentId = list[n - 1].id
else out.usage = '/win N (1' + list.length + ')'
return out
}
if (cmd === 'p2p') {
if (parts[0] === 'open' && parts[1]) {
var name = String(parts[1])
if (name.indexOf('[p2p]') < 0) name = name + ' [p2p]'
var pb = bareIrcEnsureBuffer(client.state, 'p2p', name)
client.state.currentId = pb.id
bareIrcPushLine(client.state, pb, {
atMs: Date.now(),
kind: 'info',
text: 'Shadow room — not Libera. Messages stay on the swarm sideband.'
})
if (ctx && typeof ctx.bareOsIrcShadowOpen === 'function') {
try {
ctx.bareOsIrcShadowOpen(parts[1])
} catch (e) {
/* ignore */
}
}
return out
}
out.usage = '/p2p open #name'
return out
}
if (cmd === 'oper') {
if (parts.length < 2) {
out.usage = '/oper USER PASS'
return out
}
client.sendRaw('OPER', [parts[0], parts[1]])
return out
}
if (cmd === 'squery') {
if (!parts[0] || !parts.slice(1).join(' ')) {
out.usage = '/squery SERVICE text'
return out
}
client.sendRaw('SQUERY', [parts[0], parts.slice(1).join(' ')])
return out
}
if (cmd === 'set') {
out.note = 'config is ~/.irc/config.json (nick, autojoin, saslUser)'
return out
}
if (cmd === 'server') {
out.note =
(client.state.network || 'irc') +
' ' +
(client.opts.host || '') +
':' +
(client.opts.port || '')
return out
}
out.ok = false
out.usage = 'unknown command /' + cmd + ' try /help'
return out
}
/** TEA IRC TUI. Cell buffer; protocol lives in irc-client.js. */
function bareIrcPad(s, w) {
s = String(s == null ? '' : s)
if (w <= 0) return ''
if (s.length > w) return s.slice(0, w)
while (s.length < w) s += ' '
return s
}
function bareIrcNickColWidth(cols, buf, roomsOpen) {
if (roomsOpen || !buf || buf.kind !== 'channel') return 0
if (cols < 80) return 0
return cols >= 140 ? 20 : 16
}
function bareIrcMemberLabel(m, key) {
var pref =
m && m.modes && m.modes.indexOf('o') >= 0
? '@'
: m && m.modes && m.modes.indexOf('v') >= 0
? '+'
: ' '
return pref + (m && m.nick ? m.nick : key)
}
function bareIrcFmtClock(ms) {
var d = new Date(typeof ms === 'number' ? ms : Date.now())
function z(n) {
return (n < 10 ? '0' : '') + n
}
return z(d.getHours()) + ':' + z(d.getMinutes())
}
function bareIrcCreateTuiApp(ctx, client) {
var tui = ctx.tui
var size0 = tui && typeof tui.size === 'function' ? tui.size() : {}
return {
client: client,
width: size0.width || 80,
height: size0.height || 24,
help: false,
quitConfirm: false,
roomsOpen: false,
roomsCursor: 0,
roomsFilterEdit: false,
openedRoomsOnce: false,
filterOpen: false,
status: 'connecting…',
input: tui.textinput.create({ prompt: '', focused: true, charLimit: 450 }),
viewport: tui.viewport.create({
width: size0.width || 80,
height: Math.max(6, (size0.height || 24) - 6)
}),
init: function () {
var self = this
client.on('registered', function () {
if (tui && typeof tui.send === 'function')
tui.send({ type: 'irc.paint' })
})
client.on('privmsg', function () {
if (tui && typeof tui.send === 'function')
tui.send({ type: 'irc.paint' })
})
client.on('notice', function () {
if (tui && typeof tui.send === 'function')
tui.send({ type: 'irc.paint' })
})
client.on('sasl', function (p) {
self.status = p && p.ok ? 'sasl ok' : 'sasl failed'
if (tui && typeof tui.send === 'function')
tui.send({ type: 'irc.paint' })
})
client.on('error', function (p) {
self.status =
'error: ' +
((p && (p.text || (p.error && p.error.message))) || 'error')
if (tui && typeof tui.send === 'function')
tui.send({ type: 'irc.paint' })
})
client.on('close', function () {
self.status = 'disconnected'
if (tui && typeof tui.send === 'function')
tui.send({ type: 'irc.paint' })
})
client.on('join', function () {
self.roomsOpen = false
self.roomsFilterEdit = false
if (tui && typeof tui.send === 'function')
tui.send({ type: 'irc.paint' })
})
client.on('list', function () {
if (tui && typeof tui.send === 'function')
tui.send({ type: 'irc.paint' })
})
client.on('listend', function (p) {
self.status = 'rooms ' + ((p && p.count) || 0)
if (tui && typeof tui.send === 'function')
tui.send({ type: 'irc.paint' })
})
client.on('motd_end', function () {
var joins = (client.opts && client.opts.autojoin) || []
if (!self.openedRoomsOnce && (!joins || !joins.length)) {
self.openedRoomsOnce = true
self.roomsOpen = true
self.roomsCursor = 0
self.status = 'pick a room (Enter join / filter Esc close)'
}
var srv = bareIrcEnsureBuffer(client.state, 'server', '*server')
bareIrcPushLine(client.state, srv, {
atMs: Date.now(),
kind: 'info',
text:
'You are ' +
(client.state.nick || 'guest') +
'. /rooms browse /join #libera help /query nick ? keys'
})
if (tui && typeof tui.send === 'function')
tui.send({ type: 'irc.paint' })
})
this._sync()
return null
},
_roomRows: function () {
var listed = this.client.state.channelList || []
if (listed.length) return listed
return BARE_IRC_FEATURED_ROOMS
},
_openRooms: function (query) {
this.roomsOpen = true
this.roomsCursor = 0
this.roomsFilterEdit = false
if (query) this.client.list(query)
this.status = query
? 'listing ' + query
: 'featured rooms (type /rooms linux to search)'
},
_joinRoomAtCursor: function () {
var rows = this._roomRows()
var hit = rows[this.roomsCursor]
if (!hit || !hit.name) return
this.roomsOpen = false
this.roomsFilterEdit = false
this.client.join(hit.name)
this.status = 'joining ' + hit.name
},
_current: function () {
var st = this.client.state
return (
st.buffers[st.currentId] || bareIrcEnsureBuffer(st, 'server', '*server')
)
},
_sync: function () {
var buf = this._current()
buf.unread = 0
buf.highlight = false
var cols = Math.max(40, this.width || 80)
var nickW = bareIrcNickColWidth(cols, buf, this.roomsOpen)
this.viewport.width = Math.max(20, cols - (nickW ? nickW + 1 : 0))
this.viewport.height = Math.max(4, (this.height || 24) - 6)
if (this.roomsOpen) {
var rows = this._roomRows()
if (this.roomsCursor >= rows.length)
this.roomsCursor = Math.max(0, rows.length - 1)
var rlines = []
var ri
for (ri = 0; ri < rows.length; ri++) {
var rr = rows[ri]
var mark = ri === this.roomsCursor ? '>' : ' '
var users =
rr.users === '' || rr.users == null
? ' '
: String(rr.users).padStart
? String(rr.users).padStart(4, ' ')
: String(rr.users)
rlines.push(
mark +
' ' +
String(rr.name || '')
.slice(0, 18)
.padEnd(18, ' ') +
' ' +
users +
' ' +
String(rr.topic || '')
)
}
if (!rlines.length) rlines.push('(no rooms — /rooms linux to search)')
this.viewport.setContent(rlines.join('\n'))
var mid = Math.floor((this.viewport.height || 8) / 2)
this.viewport.setYOffset(Math.max(0, this.roomsCursor - mid))
return
}
var lines = []
for (var i = 0; i < buf.lines.length; i++) {
var rec = buf.lines[i]
var clock = bareIrcFmtClock(rec.atMs)
if (rec.kind === 'action') {
lines.push(clock + ' * ' + rec.from + ' ' + rec.text)
} else if (rec.kind === 'notice') {
lines.push(clock + ' -' + rec.from + '- ' + rec.text)
} else if (rec.kind === 'privmsg') {
lines.push(clock + ' <' + rec.from + '> ' + rec.text)
} else {
lines.push(clock + ' ' + (rec.text || rec.command || ''))
}
}
this.viewport.setContent(lines.join('\n'))
this.viewport.gotoBottom()
},
_nextBuffer: function (dir) {
var list = bareIrcListBuffers(this.client.state)
if (!list.length) return
var cur = 0
for (var i = 0; i < list.length; i++) {
if (list[i].id === this.client.state.currentId) cur = i
}
var n = (cur + dir + list.length) % list.length
this.client.state.currentId = list[n].id
this._sync()
},
_completeNick: function () {
var buf = this._current()
var val = this.input.value || ''
var m = /(?:^|\s)(\S+)$/.exec(val)
var prefix = m ? m[1] : ''
if (!prefix) return
var mapping = bareIrcCasemapping(this.client.state.isupport)
var keys = Object.keys(buf.members || {})
var hit = ''
for (var i = 0; i < keys.length; i++) {
var mem = buf.members[keys[i]]
var nick = mem && mem.nick ? mem.nick : keys[i]
if (
bareIrcCasemap(nick, mapping).indexOf(
bareIrcCasemap(prefix, mapping)
) === 0
) {
hit = nick
break
}
}
if (!hit) return
this.input.setValue(val.slice(0, val.length - prefix.length) + hit)
},
_applySlash: function (res) {
var buf = this._current()
if (res.help) this.help = true
if (res.rooms) this._openRooms(res.roomsQuery || '')
if (res.note) {
bareIrcPushLine(this.client.state, buf, {
atMs: Date.now(),
kind: 'info',
text: res.note
})
}
if (res.usage) {
bareIrcPushLine(this.client.state, buf, {
atMs: Date.now(),
kind: 'info',
text: res.usage
})
}
if (res.clear && buf) buf.lines = []
if (res.close) {
if (buf.kind === 'channel') this.client.part(buf.name, '')
var id = buf.id
if (id !== 'server') {
delete this.client.state.buffers[id]
this.client.state.currentId = 'server'
}
}
if (res.quit) return tui.quit
this._sync()
return null
},
_runSlash: function (line) {
var self = this
var res = bareIrcRunSlash(this.client, line, {
buf: this._current(),
ctx: ctx,
reconnect: function () {
try {
if (self.client.opts) {
self.client.attach(bareIrcDial(ctx, self.client.opts))
self.status = 'reconnecting\u2026'
}
} catch (err) {
self.status = 'reconnect failed'
}
}
})
return this._applySlash(res)
},
update: function (msg) {
if (msg && msg.type === 'resize') {
this.width = msg.width || this.width
this.height = msg.height || this.height
this._sync()
return [this, null]
}
if (msg && msg.type === 'irc.paint') {
this.status = this.client.state.registered
? this.client.state.saslOk
? 'sasl ok'
: 'registered'
: this.client.state.connection
this._sync()
return [this, null]
}
if (this.help) {
if (msg && msg.type === 'key') this.help = false
return [this, null]
}
if (this.roomsOpen) {
if (this.roomsFilterEdit) {
if (tui.key.matches(msg, 'escape')) {
this.roomsFilterEdit = false
this.input.reset()
return [this, null]
}
if (tui.key.matches(msg, 'enter')) {
var q = String(this.input.value || '').trim()
this.input.reset()
this.roomsFilterEdit = false
if (q) this.client.list(q)
this.status = q ? 'listing ' + q : 'rooms'
this._sync()
return [this, null]
}
var rpair = this.input.update(msg)
this.input = rpair[0]
return [this, rpair[1]]
}
if (tui.key.matches(msg, 'escape', 'q')) {
this.roomsOpen = false
this._sync()
return [this, null]
}
if (tui.key.matches(msg, '/', 'f')) {
this.roomsFilterEdit = true
this.input.reset()
return [this, null]
}
if (tui.key.matches(msg, 'enter')) {
this._joinRoomAtCursor()
this._sync()
return [this, null]
}
if (tui.key.matches(msg, 'j', 'down')) {
this.roomsCursor++
this._sync()
return [this, null]
}
if (tui.key.matches(msg, 'k', 'up')) {
this.roomsCursor = Math.max(0, this.roomsCursor - 1)
this._sync()
return [this, null]
}
if (tui.key.matches(msg, 'pageup', 'pagedown')) {
var step = this.viewport.height || 10
if (tui.key.matches(msg, 'pageup'))
this.roomsCursor = Math.max(0, this.roomsCursor - step)
else this.roomsCursor += step
this._sync()
return [this, null]
}
}
if (this.quitConfirm) {
if (tui.key.matches(msg, 'y', 'Y')) {
this.client.quit('Quit')
return [this, tui.quit]
}
if (msg && msg.type === 'key') this.quitConfirm = false
return [this, null]
}
if (tui.key.matches(msg, 'ctrl+c')) {
this.quitConfirm = true
return [this, null]
}
if (tui.key.matches(msg, 'f10')) {
this.client.quit('Quit')
return [this, tui.quit]
}
if (tui.key.matches(msg, '?')) {
this.help = true
return [this, null]
}
if (tui.key.matches(msg, 'ctrl+l')) {
this._openRooms('')
this._sync()
return [this, null]
}
if (tui.key.matches(msg, 'ctrl+n', 'alt+right')) {
this._nextBuffer(1)
return [this, null]
}
if (tui.key.matches(msg, 'ctrl+p', 'alt+left')) {
this._nextBuffer(-1)
return [this, null]
}
if (tui.key.matches(msg, 'tab')) {
this._completeNick()
return [this, null]
}
if (tui.key.matches(msg, 'pageup', 'pagedown', 'up', 'down')) {
var vp = this.viewport.update(msg)
this.viewport = vp[0]
return [this, vp[1]]
}
if (tui.key.matches(msg, 'enter')) {
var line = String(this.input.value || '').trim()
this.input.reset()
if (!line) return [this, null]
if (line.charAt(0) === '/') {
var cmd = this._runSlash(line)
return [this, cmd]
}
var cur = this._current()
if (cur.kind === 'server') {
bareIrcPushLine(this.client.state, cur, {
atMs: Date.now(),
kind: 'info',
text: 'join a channel or /query someone first'
})
} else if (cur.kind === 'p2p') {
bareIrcPushLine(this.client.state, cur, {
atMs: Date.now(),
kind: 'privmsg',
from: this.client.state.nick || 'me',
text: line,
local: true
})
if (typeof ctx.bareOsChatSend === 'function') {
try {
ctx.bareOsChatSend(
'[bare-os-irc-shadow] ' + cur.name + ' ' + line
)
} catch (e) {
/* ignore */
}
}
} else {
this.client.sendPrivmsg(cur.name, line)
}
this._sync()
return [this, null]
}
var pair = this.input.update(msg)
this.input = pair[0]
return [this, pair[1]]
},
view: function () {
var cols = Math.max(40, this.width || 80)
var rows = Math.max(12, this.height || 24)
var st = tui.style
var buf = this._current()
var net = this.client.state.network || 'irc'
var tls = this.client.state.tls ? 'tls' : 'plain'
var titleRaw =
' ' +
net +
' ' +
buf.name +
(buf.unread ? ' ● ' + buf.unread : '') +
' ' +
tls +
(this.client.state.saslOk ? ' sasl' : '') +
' ' +
this.status +
' ' +
cols +
'x' +
rows +
' '
var title = st
? st()
.foreground('brightwhite')
.background('blue')
.width(cols)
.render(titleRaw)
: titleRaw
var tabs = bareIrcListBuffers(this.client.state)
.map(function (b) {
var mark = b.id === buf.id ? '[' + b.name + ']' : b.name
if (b.unread) mark += '(' + b.unread + ')'
return mark
})
.join(' ')
var tabBar = st ? st().dim().width(cols).render(tabs) : tabs
var nickW = bareIrcNickColWidth(cols, buf, this.roomsOpen)
var bodyH = this.viewport.height || Math.max(4, rows - 6)
var body = this.viewport.view()
if (nickW && buf.kind === 'channel') {
var keys = Object.keys(buf.members || {})
keys.sort(function (a, b) {
var ma = buf.members[a]
var mb = buf.members[b]
var pa = ma && ma.modes && ma.modes.indexOf('o') >= 0 ? 0 : 1
var pb = mb && mb.modes && mb.modes.indexOf('o') >= 0 ? 0 : 1
if (pa !== pb) return pa - pb
var na = ma && ma.nick ? ma.nick : a
var nb = mb && mb.nick ? mb.nick : b
return na < nb ? -1 : na > nb ? 1 : 0
})
var nicks = keys.map(function (k) {
return bareIrcMemberLabel(buf.members[k], k)
})
var left = String(body).split('\n')
var chatW = Math.max(8, cols - nickW - 1)
var joined = []
var r
for (r = 0; r < bodyH; r++) {
var L = bareIrcPad(
st ? st.truncate(left[r] || '', chatW) : left[r] || '',
chatW
)
var R = bareIrcPad(nicks[r] || '', nickW)
joined.push(L + '\u2502' + R)
}
body = joined.join('\n')
}
var prompt
if (this.roomsOpen && this.roomsFilterEdit) {
prompt =
'Filter: ' +
(this.input.view ? this.input.view() : this.input.value || '')
} else {
prompt =
(buf.kind === 'p2p' ? '#p2p ' : '') +
'[' +
(this.roomsOpen ? 'rooms' : buf.name) +
'] ' +
(this.input.view ? this.input.view() : this.input.value || '')
}
var footRaw = this.roomsOpen
? this.roomsFilterEdit
? 'Filter + Enter search LIST Esc back'
: 'Enter join j/k move / filter Esc close featured or /rooms linux'
: 'Enter send /rooms /join /query Ctrl+L rooms Ctrl+n/p Tab nick ?'
var foot = st ? st().dim().width(cols).render(footRaw) : footRaw
var rule = st
? st()
.dim()
.width(cols)
.render('\u2500'.repeat(Math.min(cols, 120)))
: ''
var lines = [title, tabBar, rule]
.concat(String(body).split('\n'))
.concat([rule, prompt, foot])
while (lines.length < rows) lines.push('')
var out = []
for (var i = 0; i < rows; i++) {
out.push(
st
? st.truncate(lines[i] || '', cols)
: String(lines[i] || '').slice(0, cols)
)
}
return out.join('\n')
},
overlay: function (size) {
if (!this.help && !this.quitConfirm) return null
var cols = (size && size.width) || this.width || 80
var rows = (size && size.height) || this.height || 24
var st = tui.style
var body = this.quitConfirm
? 'Disconnect and quit?\n\nY yes any other key cancel'
: 'Bare OS irc\n\n' +
'/rooms [pat] browse/list /join #chan /part /query nick\n' +
'/msg /notice /me /nick /whois /whowas /who /names /topic\n' +
'/kick /invite /op /deop /voice /mode /ban /away /back\n' +
'/list /motd /quote /ctcp /ping /ignore /clear /close /quit\n' +
'/p2p open #chan shadow room (not Libera)\n' +
'Ctrl+L rooms Ctrl+n/p buffers Tab nick\n\n' +
'Default: irc.libera.chat:6697 TLS random nick if unset\n' +
'Press any key to close.'
var boxed = st
? st()
.border(st.borders.rounded)
.padding(1, 2)
.background('black')
.render(body)
: body
var h = st ? st.height(boxed) : boxed.split('\n').length
var w = st ? st.width(boxed) : 48
return {
row: Math.max(0, Math.floor((rows - h) / 2)),
col: Math.max(0, Math.floor((cols - w) / 2)),
text: boxed
}
}
}
}
function bareIrcTuiRunOpts() {
return { buffer: 'cell' }
}
/**
* Modern IRC client. Default: Libera.Chat TLS irc.libera.chat:6697.
* TTY → ctx.tui; otherwise scriptable: status | send | join | query | whois.
*/
function ircUsage(argv0) {
return (
'usage: ' +
(argv0 || 'irc') +
' [libera|HOST] [--tls|--plain] [--insecure-plain] [--nick N] [--sasl-user U]\n' +
' ' +
(argv0 || 'irc') +
' send TARGET TEXT | join #chan | query NICK [TEXT] | whois NICK | status\n' +
'Default network: irc.libera.chat:6697 (TLS). Config: ~/.irc/config.json\n' +
'See man irc.'
)
}
async function bareIrcEnsureLiveClient(ctx, spec) {
if (
ctx._bareIrcClient &&
ctx._bareIrcClient.state.connection === 'registered'
) {
return ctx._bareIrcClient
}
var sock = bareIrcDial(ctx, spec)
var client = bareIrcCreateClient(spec)
client.attach(sock)
ctx._bareIrcClient = client
return client
}
function bareIrcWaitRegistered(client, ms) {
return new Promise(function (resolve, reject) {
if (client.state.registered) {
resolve(client)
return
}
var t = setTimeout(function () {
reject(new Error('irc: register timeout'))
}, ms || 20000)
client.on('registered', function () {
clearTimeout(t)
resolve(client)
})
client.on('error', function (p) {
clearTimeout(t)
reject(
p && p.error ? p.error : new Error(p && p.text ? p.text : 'irc error')
)
})
})
}
async function run(ctx, argv) {
var flags = bareIrcParseFlags(argv)
if (flags.help) {
ctx.console.log(ircUsage(argv[0]))
return
}
var loaded = await bareIrcLoadConfig(ctx)
var spec = bareIrcResolveConnect(ctx, flags, loaded)
var sub = flags.rest[0] || ''
if (sub === 'status') {
var c = ctx._bareIrcClient
var info = {
connection: c ? c.state.connection : 'idle',
nick: c ? c.state.nick : spec.nick,
host: spec.host,
port: spec.port,
tls: spec.tls,
network: spec.network
}
ctx.console.log(
flags.json ? JSON.stringify(info) : JSON.stringify(info, null, 2)
)
return
}
if (sub === 'send' || sub === 'join' || sub === 'query' || sub === 'whois') {
try {
var client = await bareIrcEnsureLiveClient(ctx, spec)
await bareIrcWaitRegistered(client, 25000)
if (sub === 'send') {
var target = flags.rest[1]
var text = flags.rest.slice(2).join(' ')
if (!target || !text) {
ctx.console.error('irc: send TARGET TEXT')
ctx.exitCode = 1
return
}
if (!client.sendPrivmsg(target, text)) {
ctx.console.error('irc: send failed (rate limit or no socket)')
ctx.exitCode = 1
}
return
}
if (sub === 'join') {
if (!flags.rest[1]) {
ctx.console.error('irc: join #channel')
ctx.exitCode = 1
return
}
client.join(flags.rest[1])
return
}
if (sub === 'query') {
if (!flags.rest[1]) {
ctx.console.error('irc: query NICK [TEXT]')
ctx.exitCode = 1
return
}
if (flags.rest[2])
client.sendPrivmsg(flags.rest[1], flags.rest.slice(2).join(' '))
return
}
if (sub === 'whois') {
if (!flags.rest[1]) {
ctx.console.error('irc: whois NICK')
ctx.exitCode = 1
return
}
client.sendRaw('WHOIS', [flags.rest[1]])
return
}
} catch (e) {
ctx.console.error('irc: ' + ((e && e.message) || String(e)))
ctx.exitCode = 1
}
return
}
var stdin = ctx.replStdin
var isTTY = Boolean(stdin && stdin.isTTY)
if (!isTTY || !ctx.tui || typeof ctx.tui.run !== 'function') {
ctx.console.error(
'irc: TTY + ctx.tui required for full-screen mode; use: irc send TARGET TEXT'
)
ctx.exitCode = 1
return
}
try {
if (typeof ctx.bareOsIrcHostAllowed === 'function') {
var allow = ctx.bareOsIrcHostAllowed(spec.host)
if (allow && allow.ok === false) {
ctx.console.error(
'irc: host blocked (' + (allow.reason || 'policy') + '): ' + spec.host
)
ctx.exitCode = 1
return
}
}
var socket = bareIrcDial(ctx, spec)
var live = bareIrcCreateClient(spec)
live.attach(socket)
live.on('registered', function () {
if (spec.generatedNick && live.state.nick) {
bareIrcRememberNick(ctx, live.state.nick)
}
var joins = spec.autojoin || []
for (var i = 0; i < joins.length; i++) live.join(joins[i])
if (typeof ctx.bareOsIrcPresencePublish === 'function') {
try {
ctx.bareOsIrcPresencePublish({
schema: 1,
network: spec.network || 'libera',
nick: live.state.nick,
account: live.state.account || '',
channels: spec.shareChannels ? joins : [],
atMs: Date.now()
})
} catch (e) {
/* ignore */
}
}
})
var app = bareIrcCreateTuiApp(ctx, live)
try {
await ctx.tui.run(app, bareIrcTuiRunOpts())
} finally {
try {
live.quit('Client closed')
} catch (e2) {
/* ignore */
}
}
} catch (err) {
ctx.console.error('irc: ' + ((err && err.message) || String(err)))
ctx.exitCode = 1
}
}