| import net from "node:net"; |
| import tls from "node:tls"; |
| import { |
| parseIrcLine, |
| parseIrcPrefix, |
| sanitizeIrcOutboundText, |
| sanitizeIrcTarget, |
| } from "./protocol.js"; |
|
|
| const IRC_ERROR_CODES = new Set(["432", "464", "465"]); |
| const IRC_NICK_COLLISION_CODES = new Set(["433", "436"]); |
|
|
| export type IrcPrivmsgEvent = { |
| senderNick: string; |
| senderUser?: string; |
| senderHost?: string; |
| target: string; |
| text: string; |
| rawLine: string; |
| }; |
|
|
| export type IrcClientOptions = { |
| host: string; |
| port: number; |
| tls: boolean; |
| nick: string; |
| username: string; |
| realname: string; |
| password?: string; |
| nickserv?: IrcNickServOptions; |
| channels?: string[]; |
| connectTimeoutMs?: number; |
| messageChunkMaxChars?: number; |
| abortSignal?: AbortSignal; |
| onPrivmsg?: (event: IrcPrivmsgEvent) => void | Promise<void>; |
| onNotice?: (text: string, target?: string) => void; |
| onError?: (error: Error) => void; |
| onLine?: (line: string) => void; |
| }; |
|
|
| export type IrcNickServOptions = { |
| enabled?: boolean; |
| service?: string; |
| password?: string; |
| register?: boolean; |
| registerEmail?: string; |
| }; |
|
|
| export type IrcClient = { |
| nick: string; |
| isReady: () => boolean; |
| sendRaw: (line: string) => void; |
| join: (channel: string) => void; |
| sendPrivmsg: (target: string, text: string) => void; |
| quit: (reason?: string) => void; |
| close: () => void; |
| }; |
|
|
| function toError(err: unknown): Error { |
| if (err instanceof Error) { |
| return err; |
| } |
| return new Error(typeof err === "string" ? err : JSON.stringify(err)); |
| } |
|
|
| function withTimeout<T>(promise: Promise<T>, timeoutMs: number, label: string): Promise<T> { |
| return new Promise((resolve, reject) => { |
| const timer = setTimeout( |
| () => reject(new Error(`${label} timed out after ${timeoutMs}ms`)), |
| timeoutMs, |
| ); |
| promise |
| .then((result) => { |
| clearTimeout(timer); |
| resolve(result); |
| }) |
| .catch((error) => { |
| clearTimeout(timer); |
| reject(error); |
| }); |
| }); |
| } |
|
|
| function buildFallbackNick(nick: string): string { |
| const normalized = nick.replace(/\s+/g, ""); |
| const safe = normalized.replace(/[^A-Za-z0-9_\-\[\]\\`^{}|]/g, ""); |
| const base = safe || "openclaw"; |
| const suffix = "_"; |
| const maxNickLen = 30; |
| if (base.length >= maxNickLen) { |
| return `${base.slice(0, maxNickLen - suffix.length)}${suffix}`; |
| } |
| return `${base}${suffix}`; |
| } |
|
|
| export function buildIrcNickServCommands(options?: IrcNickServOptions): string[] { |
| if (!options || options.enabled === false) { |
| return []; |
| } |
| const password = sanitizeIrcOutboundText(options.password ?? ""); |
| if (!password) { |
| return []; |
| } |
| const service = sanitizeIrcTarget(options.service?.trim() || "NickServ"); |
| const commands = [`PRIVMSG ${service} :IDENTIFY ${password}`]; |
| if (options.register) { |
| const registerEmail = sanitizeIrcOutboundText(options.registerEmail ?? ""); |
| if (!registerEmail) { |
| throw new Error("IRC NickServ register requires registerEmail"); |
| } |
| commands.push(`PRIVMSG ${service} :REGISTER ${password} ${registerEmail}`); |
| } |
| return commands; |
| } |
|
|
| export async function connectIrcClient(options: IrcClientOptions): Promise<IrcClient> { |
| const timeoutMs = options.connectTimeoutMs != null ? options.connectTimeoutMs : 15000; |
| const messageChunkMaxChars = |
| options.messageChunkMaxChars != null ? options.messageChunkMaxChars : 350; |
|
|
| if (!options.host.trim()) { |
| throw new Error("IRC host is required"); |
| } |
| if (!options.nick.trim()) { |
| throw new Error("IRC nick is required"); |
| } |
|
|
| const desiredNick = options.nick.trim(); |
| let currentNick = desiredNick; |
| let ready = false; |
| let closed = false; |
| let nickServRecoverAttempted = false; |
| let fallbackNickAttempted = false; |
|
|
| const socket = options.tls |
| ? tls.connect({ |
| host: options.host, |
| port: options.port, |
| servername: options.host, |
| }) |
| : net.connect({ host: options.host, port: options.port }); |
|
|
| socket.setEncoding("utf8"); |
|
|
| let resolveReady: (() => void) | null = null; |
| let rejectReady: ((error: Error) => void) | null = null; |
| const readyPromise = new Promise<void>((resolve, reject) => { |
| resolveReady = resolve; |
| rejectReady = reject; |
| }); |
|
|
| const fail = (err: unknown) => { |
| const error = toError(err); |
| if (options.onError) { |
| options.onError(error); |
| } |
| if (!ready && rejectReady) { |
| rejectReady(error); |
| rejectReady = null; |
| resolveReady = null; |
| } |
| }; |
|
|
| const sendRaw = (line: string) => { |
| const cleaned = line.replace(/[\r\n]+/g, "").trim(); |
| if (!cleaned) { |
| throw new Error("IRC command cannot be empty"); |
| } |
| socket.write(`${cleaned}\r\n`); |
| }; |
|
|
| const tryRecoverNickCollision = (): boolean => { |
| const nickServEnabled = options.nickserv?.enabled !== false; |
| const nickservPassword = sanitizeIrcOutboundText(options.nickserv?.password ?? ""); |
| if (nickServEnabled && !nickServRecoverAttempted && nickservPassword) { |
| nickServRecoverAttempted = true; |
| try { |
| const service = sanitizeIrcTarget(options.nickserv?.service?.trim() || "NickServ"); |
| sendRaw(`PRIVMSG ${service} :GHOST ${desiredNick} ${nickservPassword}`); |
| sendRaw(`NICK ${desiredNick}`); |
| return true; |
| } catch (err) { |
| fail(err); |
| } |
| } |
|
|
| if (!fallbackNickAttempted) { |
| fallbackNickAttempted = true; |
| const fallbackNick = buildFallbackNick(desiredNick); |
| if (fallbackNick.toLowerCase() !== currentNick.toLowerCase()) { |
| try { |
| sendRaw(`NICK ${fallbackNick}`); |
| currentNick = fallbackNick; |
| return true; |
| } catch (err) { |
| fail(err); |
| } |
| } |
| } |
| return false; |
| }; |
|
|
| const join = (channel: string) => { |
| const target = sanitizeIrcTarget(channel); |
| if (!target.startsWith("#") && !target.startsWith("&")) { |
| throw new Error(`IRC JOIN target must be a channel: ${channel}`); |
| } |
| sendRaw(`JOIN ${target}`); |
| }; |
|
|
| const sendPrivmsg = (target: string, text: string) => { |
| const normalizedTarget = sanitizeIrcTarget(target); |
| const cleaned = sanitizeIrcOutboundText(text); |
| if (!cleaned) { |
| return; |
| } |
| let remaining = cleaned; |
| while (remaining.length > 0) { |
| let chunk = remaining; |
| if (chunk.length > messageChunkMaxChars) { |
| let splitAt = chunk.lastIndexOf(" ", messageChunkMaxChars); |
| if (splitAt < Math.floor(messageChunkMaxChars / 2)) { |
| splitAt = messageChunkMaxChars; |
| } |
| chunk = chunk.slice(0, splitAt).trim(); |
| } |
| if (!chunk) { |
| break; |
| } |
| sendRaw(`PRIVMSG ${normalizedTarget} :${chunk}`); |
| remaining = remaining.slice(chunk.length).trimStart(); |
| } |
| }; |
|
|
| const quit = (reason?: string) => { |
| if (closed) { |
| return; |
| } |
| closed = true; |
| const safeReason = sanitizeIrcOutboundText(reason != null ? reason : "bye"); |
| try { |
| if (safeReason) { |
| sendRaw(`QUIT :${safeReason}`); |
| } else { |
| sendRaw("QUIT"); |
| } |
| } catch { |
| |
| } |
| socket.end(); |
| }; |
|
|
| const close = () => { |
| if (closed) { |
| return; |
| } |
| closed = true; |
| socket.destroy(); |
| }; |
|
|
| let buffer = ""; |
| socket.on("data", (chunk: string) => { |
| buffer += chunk; |
| let idx = buffer.indexOf("\n"); |
| while (idx !== -1) { |
| const rawLine = buffer.slice(0, idx).replace(/\r$/, ""); |
| buffer = buffer.slice(idx + 1); |
| idx = buffer.indexOf("\n"); |
|
|
| if (!rawLine) { |
| continue; |
| } |
| if (options.onLine) { |
| options.onLine(rawLine); |
| } |
|
|
| const line = parseIrcLine(rawLine); |
| if (!line) { |
| continue; |
| } |
|
|
| if (line.command === "PING") { |
| const payload = |
| line.trailing != null ? line.trailing : line.params[0] != null ? line.params[0] : ""; |
| sendRaw(`PONG :${payload}`); |
| continue; |
| } |
|
|
| if (line.command === "NICK") { |
| const prefix = parseIrcPrefix(line.prefix); |
| if (prefix.nick && prefix.nick.toLowerCase() === currentNick.toLowerCase()) { |
| const next = |
| line.trailing != null |
| ? line.trailing |
| : line.params[0] != null |
| ? line.params[0] |
| : currentNick; |
| currentNick = String(next).trim(); |
| } |
| continue; |
| } |
|
|
| if (!ready && IRC_NICK_COLLISION_CODES.has(line.command)) { |
| if (tryRecoverNickCollision()) { |
| continue; |
| } |
| const detail = |
| line.trailing != null ? line.trailing : line.params.join(" ") || "nickname in use"; |
| fail(new Error(`IRC login failed (${line.command}): ${detail}`)); |
| close(); |
| return; |
| } |
|
|
| if (!ready && IRC_ERROR_CODES.has(line.command)) { |
| const detail = |
| line.trailing != null ? line.trailing : line.params.join(" ") || "login rejected"; |
| fail(new Error(`IRC login failed (${line.command}): ${detail}`)); |
| close(); |
| return; |
| } |
|
|
| if (line.command === "001") { |
| ready = true; |
| const nickParam = line.params[0]; |
| if (nickParam && nickParam.trim()) { |
| currentNick = nickParam.trim(); |
| } |
| try { |
| const nickServCommands = buildIrcNickServCommands(options.nickserv); |
| for (const command of nickServCommands) { |
| sendRaw(command); |
| } |
| } catch (err) { |
| fail(err); |
| } |
| for (const channel of options.channels || []) { |
| const trimmed = channel.trim(); |
| if (!trimmed) { |
| continue; |
| } |
| try { |
| join(trimmed); |
| } catch (err) { |
| fail(err); |
| } |
| } |
| if (resolveReady) { |
| resolveReady(); |
| } |
| resolveReady = null; |
| rejectReady = null; |
| continue; |
| } |
|
|
| if (line.command === "NOTICE") { |
| if (options.onNotice) { |
| options.onNotice(line.trailing != null ? line.trailing : "", line.params[0]); |
| } |
| continue; |
| } |
|
|
| if (line.command === "PRIVMSG") { |
| const targetParam = line.params[0]; |
| const target = targetParam ? targetParam.trim() : ""; |
| const text = line.trailing != null ? line.trailing : ""; |
| const prefix = parseIrcPrefix(line.prefix); |
| const senderNick = prefix.nick ? prefix.nick.trim() : ""; |
| if (!target || !senderNick || !text.trim()) { |
| continue; |
| } |
| if (options.onPrivmsg) { |
| void Promise.resolve( |
| options.onPrivmsg({ |
| senderNick, |
| senderUser: prefix.user ? prefix.user.trim() : undefined, |
| senderHost: prefix.host ? prefix.host.trim() : undefined, |
| target, |
| text, |
| rawLine, |
| }), |
| ).catch((error) => { |
| fail(error); |
| }); |
| } |
| } |
| } |
| }); |
|
|
| socket.once("connect", () => { |
| try { |
| if (options.password && options.password.trim()) { |
| sendRaw(`PASS ${options.password.trim()}`); |
| } |
| sendRaw(`NICK ${options.nick.trim()}`); |
| sendRaw(`USER ${options.username.trim()} 0 * :${sanitizeIrcOutboundText(options.realname)}`); |
| } catch (err) { |
| fail(err); |
| close(); |
| } |
| }); |
|
|
| socket.once("error", (err: unknown) => { |
| fail(err); |
| }); |
|
|
| socket.once("close", () => { |
| if (!closed) { |
| closed = true; |
| if (!ready) { |
| fail(new Error("IRC connection closed before ready")); |
| } |
| } |
| }); |
|
|
| if (options.abortSignal) { |
| const abort = () => { |
| quit("shutdown"); |
| }; |
| if (options.abortSignal.aborted) { |
| abort(); |
| } else { |
| options.abortSignal.addEventListener("abort", abort, { once: true }); |
| } |
| } |
|
|
| await withTimeout(readyPromise, timeoutMs, "IRC connect"); |
|
|
| return { |
| get nick() { |
| return currentNick; |
| }, |
| isReady: () => ready && !closed, |
| sendRaw, |
| join, |
| sendPrivmsg, |
| quit, |
| close, |
| }; |
| } |
|
|