// Irc plugin module implements protocol behavior. import { randomUUID } from "node:crypto"; import { hasIrcControlChars, stripIrcControlChars } from "./control-chars.js"; const IRC_TARGET_PATTERN = /^[^\s:]+$/u; type ParsedIrcLine = { raw: string; prefix?: string; command: string; params: string[]; trailing?: string; }; type ParsedIrcPrefix = { nick?: string; user?: string; host?: string; server?: string; }; export function parseIrcLine(line: string): ParsedIrcLine | null { const raw = line.replace(/[\r\n]+/g, "").trim(); if (!raw) { return null; } let cursor = raw; let prefix: string | undefined; if (cursor.startsWith(":")) { const idx = cursor.indexOf(" "); if (idx <= 1) { return null; } prefix = cursor.slice(1, idx); cursor = cursor.slice(idx + 1).trimStart(); } if (!cursor) { return null; } const firstSpace = cursor.indexOf(" "); const command = (firstSpace === -1 ? cursor : cursor.slice(0, firstSpace)).trim(); if (!command) { return null; } cursor = firstSpace === -1 ? "" : cursor.slice(firstSpace + 1); const params: string[] = []; let trailing: string | undefined; while (cursor.length > 0) { cursor = cursor.trimStart(); if (!cursor) { break; } if (cursor.startsWith(":")) { trailing = cursor.slice(1); break; } const spaceIdx = cursor.indexOf(" "); if (spaceIdx === -1) { params.push(cursor); break; } params.push(cursor.slice(0, spaceIdx)); cursor = cursor.slice(spaceIdx + 1); } return { raw, prefix, command: command.toUpperCase(), params, trailing, }; } export function parseIrcPrefix(prefix?: string): ParsedIrcPrefix { if (!prefix) { return {}; } const nickPart = prefix.match(/^([^!@]+)!([^@]+)@(.+)$/); if (nickPart) { return { nick: nickPart[1], user: nickPart[2], host: nickPart[3], }; } const nickHostPart = prefix.match(/^([^@]+)@(.+)$/); if (nickHostPart) { return { nick: nickHostPart[1], host: nickHostPart[2], }; } if (prefix.includes("!")) { const [nick, user] = prefix.split("!", 2); return { nick, user }; } if (prefix.includes(".")) { return { server: prefix }; } return { nick: prefix }; } function decodeLiteralEscapes(input: string): string { // Defensive: this is not a full JS string unescaper. // It's just enough to catch common "\r\n" / "\u0001" style payloads. return ( input .replace(/\\r/g, "\r") .replace(/\\n/g, "\n") .replace(/\\t/g, "\t") .replace(/\\0/g, "\0") .replace(/\\x([0-9a-fA-F]{2})/g, (_, hex) => String.fromCharCode(Number.parseInt(hex, 16))) // Step 1: decode surrogate pairs — first group locked to high surrogate range // (U+D800–U+DBFF: [dD][89abAB]xx), second to low surrogate range // (U+DC00–U+DFFF: [dD][c-fC-F]xx). This prevents a non-surrogate \uXXXX // from being greedily paired with the following high surrogate and consuming it. .replace(/\\u([dD][89abAB][0-9a-fA-F]{2})\\u([dD][c-fC-F][0-9a-fA-F]{2})/g, (_match, h, l) => String.fromCodePoint( 0x10000 + ((Number.parseInt(h, 16) - 0xd800) << 10) + (Number.parseInt(l, 16) - 0xdc00), ), ) // Step 2: decode BMP codepoints; preserve lone surrogates as literals .replace(/\\u([0-9a-fA-F]{4})/g, (match, hex) => { const codePoint = Number.parseInt(hex, 16); return codePoint >= 0xd800 && codePoint <= 0xdfff ? match : String.fromCharCode(codePoint); }) ); } export function sanitizeIrcOutboundText(text: string): string { const decoded = decodeLiteralEscapes(text); return stripIrcControlChars(decoded.replace(/\r?\n/g, " ")).trim(); } export function sanitizeIrcTarget(raw: string): string { const decoded = decodeLiteralEscapes(raw); if (!decoded) { throw new Error("IRC target is required"); } // Reject any surrounding whitespace instead of trimming it away. if (decoded !== decoded.trim()) { throw new Error(`Invalid IRC target: ${raw}`); } if (hasIrcControlChars(decoded)) { throw new Error(`Invalid IRC target: ${raw}`); } if (!IRC_TARGET_PATTERN.test(decoded)) { throw new Error(`Invalid IRC target: ${raw}`); } return decoded; } export function makeIrcMessageId() { return randomUUID(); }