mirror of
https://github.com/openclaw/openclaw.git
synced 2026-03-13 11:00:50 +00:00
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>
170 lines
4.1 KiB
TypeScript
170 lines
4.1 KiB
TypeScript
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();
|
|
}
|