@gguf/claw
Version:
Multi-channel AI gateway with extensible messaging integrations
440 lines (400 loc) • 12 kB
text/typescript
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 {
// Ignore quit failures while shutting down.
}
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,
};
}