UNPKG

@gguf/claw

Version:

Multi-channel AI gateway with extensible messaging integrations

170 lines (153 loc) 4.22 kB
import { randomUUID } from "node:crypto"; import { hasIrcControlChars, stripIrcControlChars } from "./control-chars.js"; const IRC_TARGET_PATTERN = /^[^\s:]+$/u; export type ParsedIrcLine = { raw: string; prefix?: string; command: string; params: string[]; trailing?: string; }; export 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))) .replace(/\\u([0-9a-fA-F]{4})/g, (_, hex) => String.fromCharCode(Number.parseInt(hex, 16))); } 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 splitIrcText(text: string, maxChars = 350): string[] { const cleaned = sanitizeIrcOutboundText(text); if (!cleaned) { return []; } if (cleaned.length <= maxChars) { return [cleaned]; } const chunks: string[] = []; let remaining = cleaned; while (remaining.length > maxChars) { let splitAt = remaining.lastIndexOf(" ", maxChars); if (splitAt < Math.floor(maxChars * 0.5)) { splitAt = maxChars; } chunks.push(remaining.slice(0, splitAt).trim()); remaining = remaining.slice(splitAt).trimStart(); } if (remaining) { chunks.push(remaining); } return chunks.filter(Boolean); } export function makeIrcMessageId() { return randomUUID(); }