mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-08 19:28:28 +00:00
feat: IRC — add first-class channel support
Adds IRC as a first-class channel with core config surfaces (schema/hints/dock), plugin auto-enable detection, routing/policy alignment, and docs/tests. Co-authored-by: Vignesh <vigneshnatarajan92@gmail.com>
This commit is contained in:
439
extensions/irc/src/client.ts
Normal file
439
extensions/irc/src/client.ts
Normal file
@@ -0,0 +1,439 @@
|
||||
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) => {
|
||||
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,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user