mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-11 01:06:02 +00:00
Two-step decode in decodeLiteralEscapes:
- Step 1: regex anchored to high-surrogate range (U+D800–U+DBFF) so a
preceding BMP escape (e.g. \u0041) cannot consume the high-surrogate
half of a valid pair like \uD83D\uDE00 (😀), leaving \uDE00 lone.
- Step 2: decode remaining BMP codepoints; preserve lone surrogates as
six-character literals instead of corrupting them to U+FFFD in the
outbound IRC UTF-8 stream.
162 lines
4.3 KiB
TypeScript
162 lines
4.3 KiB
TypeScript
// Irc plugin module implements protocol behavior.
|
||
import { randomUUID } from "node:crypto";
|
||
import { hasIrcControlChars, stripIrcControlChars } from "./control-chars.js";
|
||
|
||
const IRC_TARGET_PATTERN = /^[^\s:]+$/u;
|
||
|
||
type ParsedIrcLine = {
|
||
raw: string;
|
||
prefix?: string;
|
||
command: string;
|
||
params: string[];
|
||
trailing?: string;
|
||
};
|
||
|
||
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)))
|
||
// Step 1: decode surrogate pairs — first group locked to high surrogate range
|
||
// (U+D800–U+DBFF: [dD][89abAB]xx), second to low surrogate range
|
||
// (U+DC00–U+DFFF: [dD][c-fC-F]xx). This prevents a non-surrogate \uXXXX
|
||
// from being greedily paired with the following high surrogate and consuming it.
|
||
.replace(/\\u([dD][89abAB][0-9a-fA-F]{2})\\u([dD][c-fC-F][0-9a-fA-F]{2})/g, (_match, h, l) =>
|
||
String.fromCodePoint(
|
||
0x10000 + ((Number.parseInt(h, 16) - 0xd800) << 10) + (Number.parseInt(l, 16) - 0xdc00),
|
||
),
|
||
)
|
||
// Step 2: decode BMP codepoints; preserve lone surrogates as literals
|
||
.replace(/\\u([0-9a-fA-F]{4})/g, (match, hex) => {
|
||
const codePoint = Number.parseInt(hex, 16);
|
||
return codePoint >= 0xd800 && codePoint <= 0xdfff ? match : String.fromCharCode(codePoint);
|
||
})
|
||
);
|
||
}
|
||
|
||
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 makeIrcMessageId() {
|
||
return randomUUID();
|
||
}
|