Skip to content

Commit 685c22d

Browse files
Leon-SK668steipete
andauthored
fix(irc): chunk PRIVMSG on UTF-16 boundary to avoid lone surrogates (#96572)
* fix(irc): chunk PRIVMSG on UTF-16 boundary to avoid lone surrogates sendPrivmsg split long messages with String.slice on a UTF-16 code-unit index, so an emoji (or other astral character) straddling the split point was cut into a lone high/low surrogate, sending broken bytes in the PRIVMSG to the IRC server. Slice each chunk with sliceUtf16Safe so a surrogate pair is never split. When the budget is too small to fit even the leading astral character, emit that character whole so chunking still makes progress. Adds a socket-level test (fake net/tls socket) asserting no PRIVMSG chunk contains a lone surrogate, including the one-code-unit budget edge case, while the existing space-preferring split is preserved. * refactor(irc): make surrogate-safe chunking direct * fix(irc): preserve one-unit chunk limits --------- Co-authored-by: Peter Steinberger <[email protected]>
1 parent ad118f4 commit 685c22d

2 files changed

Lines changed: 192 additions & 3 deletions

File tree

Lines changed: 178 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,178 @@
1+
// Irc tests cover client PRIVMSG surrogate-safe chunking.
2+
import { afterEach, describe, expect, it, vi } from "vitest";
3+
4+
type FakeSocket = {
5+
writes: string[];
6+
emit(eventName: string, ...args: unknown[]): boolean;
7+
setEncoding(encoding: BufferEncoding): FakeSocket;
8+
write(data: string | Uint8Array): boolean;
9+
end(): FakeSocket;
10+
destroy(): FakeSocket;
11+
};
12+
13+
const socketMocks = await vi.hoisted(async () => {
14+
const { EventEmitter } = await import("node:events");
15+
16+
class FakeSocketImpl extends EventEmitter implements FakeSocket {
17+
readonly writes: string[] = [];
18+
19+
setEncoding(_encoding: BufferEncoding): this {
20+
return this;
21+
}
22+
23+
write(data: string | Uint8Array): boolean {
24+
this.writes.push(String(data));
25+
return true;
26+
}
27+
28+
end(): this {
29+
this.emit("close");
30+
return this;
31+
}
32+
33+
destroy(): this {
34+
this.emit("close");
35+
return this;
36+
}
37+
}
38+
39+
const sockets: FakeSocket[] = [];
40+
const connect = vi.fn(() => {
41+
const socket = new FakeSocketImpl();
42+
sockets.push(socket);
43+
setImmediate(() => socket.emit("connect"));
44+
return socket;
45+
});
46+
47+
return {
48+
connect,
49+
sockets,
50+
};
51+
});
52+
53+
vi.mock("node:net", () => ({
54+
connect: socketMocks.connect,
55+
default: {
56+
connect: socketMocks.connect,
57+
},
58+
}));
59+
60+
vi.mock("node:tls", () => ({
61+
connect: socketMocks.connect,
62+
default: {
63+
connect: socketMocks.connect,
64+
},
65+
}));
66+
67+
import { connectIrcClient } from "./client.js";
68+
69+
const LONE_SURROGATE = /[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(?<![\uD800-\uDBFF])[\uDC00-\uDFFF]/;
70+
71+
function containsLoneSurrogate(text: string): boolean {
72+
return LONE_SURROGATE.test(text);
73+
}
74+
75+
function requireLastSocket(): FakeSocket {
76+
const socket = socketMocks.sockets.at(-1);
77+
if (!socket) {
78+
throw new Error("expected fake IRC socket");
79+
}
80+
return socket;
81+
}
82+
83+
function privmsgBodies(socket: FakeSocket): string[] {
84+
return socket.writes
85+
.filter((line) => line.startsWith("PRIVMSG "))
86+
.map((line) => line.replace(/^PRIVMSG \S+ :/, "").replace(/\r\n$/, ""));
87+
}
88+
89+
async function connectReadyClient(messageChunkMaxChars: number) {
90+
const clientPromise = connectIrcClient({
91+
host: "irc.example.com",
92+
port: 6667,
93+
tls: false,
94+
nick: "bot",
95+
username: "bot",
96+
realname: "OpenClaw Bot",
97+
connectTimeoutMs: 1000,
98+
messageChunkMaxChars,
99+
});
100+
101+
await new Promise<void>((resolve) => {
102+
setImmediate(resolve);
103+
});
104+
const socket = requireLastSocket();
105+
socket.emit("data", ":server 001 bot :welcome\r\n");
106+
const client = await clientPromise;
107+
socket.writes.length = 0;
108+
return { client, socket };
109+
}
110+
111+
afterEach(() => {
112+
socketMocks.connect.mockClear();
113+
socketMocks.sockets.length = 0;
114+
});
115+
116+
describe("irc client PRIVMSG chunking", () => {
117+
it("does not split an emoji surrogate pair across PRIVMSG chunks", async () => {
118+
const { client, socket } = await connectReadyClient(10);
119+
const text = `${"x".repeat(9)}\u{1F642}rest`;
120+
121+
client.sendPrivmsg("#room", text);
122+
123+
const bodies = privmsgBodies(socket);
124+
expect(bodies).toEqual(["xxxxxxxxx", "\u{1F642}rest"]);
125+
expect(bodies.join("")).toBe(text);
126+
expect(bodies.some(containsLoneSurrogate)).toBe(false);
127+
128+
client.close();
129+
});
130+
131+
it("keeps a leading emoji whole when the UTF-16 budget is one code unit", async () => {
132+
const { client, socket } = await connectReadyClient(1);
133+
const text = "\u{1F642}A";
134+
135+
client.sendPrivmsg("#room", text);
136+
137+
const bodies = privmsgBodies(socket);
138+
expect(bodies).toEqual(["\u{1F642}", "A"]);
139+
expect(bodies.join("")).toBe(text);
140+
expect(bodies.some(containsLoneSurrogate)).toBe(false);
141+
142+
client.close();
143+
});
144+
145+
it("preserves one-unit chunks for BMP text", async () => {
146+
const { client, socket } = await connectReadyClient(1);
147+
148+
client.sendPrivmsg("#room", "ABC");
149+
150+
expect(privmsgBodies(socket)).toEqual(["A", "B", "C"]);
151+
152+
client.close();
153+
});
154+
155+
it("splits an all-emoji message into whole emoji when the budget is one code unit", async () => {
156+
const { client, socket } = await connectReadyClient(1);
157+
const text = "\u{1F642}\u{1F642}\u{1F642}";
158+
159+
client.sendPrivmsg("#room", text);
160+
161+
const bodies = privmsgBodies(socket);
162+
expect(bodies).toEqual(["\u{1F642}", "\u{1F642}", "\u{1F642}"]);
163+
expect(bodies.join("")).toBe(text);
164+
expect(bodies.some(containsLoneSurrogate)).toBe(false);
165+
166+
client.close();
167+
});
168+
169+
it("still prefers a nearby space when splitting long PRIVMSG text", async () => {
170+
const { client, socket } = await connectReadyClient(10);
171+
172+
client.sendPrivmsg("#room", "alpha beta gamma");
173+
174+
expect(privmsgBodies(socket)).toEqual(["alpha beta", "gamma"]);
175+
176+
client.close();
177+
});
178+
});

extensions/irc/src/client.ts

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,13 +3,25 @@ import net from "node:net";
33
import tls from "node:tls";
44
import { withTimeout } from "openclaw/plugin-sdk/security-runtime";
55
import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/string-coerce-runtime";
6+
import { sliceUtf16Safe } from "openclaw/plugin-sdk/text-utility-runtime";
67
import {
78
parseIrcLine,
89
parseIrcPrefix,
910
sanitizeIrcOutboundText,
1011
sanitizeIrcTarget,
1112
} from "./protocol.js";
1213

14+
function sliceIrcPrivmsgChunk(text: string, end: number): string {
15+
const sliced = sliceUtf16Safe(text, 0, end);
16+
if (sliced || end <= 0) {
17+
return sliced;
18+
}
19+
// A one-unit budget cannot contain an astral code point. Emit that point
20+
// whole so chunking advances without changing one-unit behavior for BMP text.
21+
const firstCodePoint = text.codePointAt(0);
22+
return firstCodePoint !== undefined && firstCodePoint > 0xffff ? text.slice(0, 2) : sliced;
23+
}
24+
1325
const IRC_ERROR_CODES = new Set(["432", "464", "465"]);
1426
const IRC_NICK_COLLISION_CODES = new Set(["433", "436"]);
1527

@@ -107,8 +119,7 @@ export function buildIrcNickServCommands(options?: IrcNickServOptions): string[]
107119

108120
export async function connectIrcClient(options: IrcClientOptions): Promise<IrcClient> {
109121
const timeoutMs = options.connectTimeoutMs != null ? options.connectTimeoutMs : 15000;
110-
const messageChunkMaxChars =
111-
options.messageChunkMaxChars != null ? options.messageChunkMaxChars : 350;
122+
const messageChunkMaxChars = Math.max(1, Math.floor(options.messageChunkMaxChars ?? 350));
112123

113124
if (!options.host.trim()) {
114125
throw new Error("IRC host is required");
@@ -220,7 +231,7 @@ export async function connectIrcClient(options: IrcClientOptions): Promise<IrcCl
220231
if (splitAt < Math.floor(messageChunkMaxChars / 2)) {
221232
splitAt = messageChunkMaxChars;
222233
}
223-
chunk = chunk.slice(0, splitAt).trim();
234+
chunk = sliceIrcPrivmsgChunk(chunk, splitAt).trim();
224235
}
225236
if (!chunk) {
226237
break;

0 commit comments

Comments
 (0)