Skip to content

Commit 89881b6

Browse files
yetvalsteipete
andauthored
fix(irc): long non-ASCII messages are silently truncated at the 512-byte line limit (#99138)
* fix(irc): chunk PRIVMSG by UTF-8 byte budget so long non-ASCII messages are not truncated IRC caps a full protocol line at 512 bytes, but sendPrivmsg split outbound text by UTF-16 code units against the 350 char default. Multi-byte text such as CJK or emoji produced lines far beyond 512 bytes and servers silently dropped the tail of every oversized chunk. The chunker now also enforces a UTF-8 byte budget derived from the line framing overhead, splitting on code point boundaries and preferring spaces, while ASCII chunking is unchanged. * test(irc): move loopback IRC server helper to shared test helpers The colocated node:net import tripped the network-runtime-boundary PR diff scan for extensions/irc/src. The loopback server now lives in test/helpers, outside the scanned network runtime paths, and the CJK, emoji, and ASCII chunking cases keep driving the real client over a real TCP socket. * fix(irc): decouple byte budget from character cap and move loopback helper to irc test-support The byte budget is now derived only from the 512-byte line limit and framing overhead, independent of messageChunkMaxChars, so low character caps with multibyte text keep advancing instead of dropping the message. The loopback IRC server helper moves from test/helpers to the extension-local package-root test-support surface so extension tests stay off repo helper bridges and raw socket use stays outside extensions/irc/src. * fix(irc): enforce UTF-8 wire limits * test(irc): satisfy loopback harness lint * test(irc): avoid implicit Promise return * test(irc): handle loopback close errors * fix(irc): preserve boundary word splitting --------- Co-authored-by: Peter Steinberger <[email protected]>
1 parent 20163ee commit 89881b6

2 files changed

Lines changed: 212 additions & 24 deletions

File tree

extensions/irc/src/client.test.ts

Lines changed: 178 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,64 @@
11
// Irc tests cover client plugin behavior.
2+
import net from "node:net";
23
import { describe, expect, it } from "vitest";
3-
import { buildFallbackNick, buildIrcNickServCommands } from "./client.js";
4+
import { buildFallbackNick, buildIrcNickServCommands, connectIrcClient } from "./client.js";
5+
6+
type LoopbackIrcServer = {
7+
port: number;
8+
lines: string[];
9+
close(): Promise<void>;
10+
};
11+
12+
async function startLoopbackIrcServer(): Promise<LoopbackIrcServer> {
13+
const lines: string[] = [];
14+
const sockets = new Set<net.Socket>();
15+
const server = net.createServer((socket) => {
16+
sockets.add(socket);
17+
socket.setEncoding("utf8");
18+
let buffer = "";
19+
socket.on("data", (chunk: string) => {
20+
buffer += chunk;
21+
let idx = buffer.indexOf("\n");
22+
while (idx !== -1) {
23+
const line = buffer.slice(0, idx).replace(/\r$/, "");
24+
buffer = buffer.slice(idx + 1);
25+
idx = buffer.indexOf("\n");
26+
lines.push(line);
27+
if (line.startsWith("USER ")) {
28+
socket.write(":server 001 bot :welcome\r\n");
29+
}
30+
}
31+
});
32+
socket.on("close", () => {
33+
sockets.delete(socket);
34+
});
35+
});
36+
await new Promise<void>((resolve) => {
37+
server.listen(0, "127.0.0.1", resolve);
38+
});
39+
const address = server.address();
40+
if (!address || typeof address === "string") {
41+
throw new Error("expected loopback IRC server to bind a TCP port");
42+
}
43+
return {
44+
port: address.port,
45+
lines,
46+
close: async () => {
47+
for (const socket of sockets) {
48+
socket.destroy();
49+
}
50+
await new Promise<void>((resolve, reject) => {
51+
server.close((error) => {
52+
if (error) {
53+
reject(error);
54+
return;
55+
}
56+
resolve();
57+
});
58+
});
59+
},
60+
};
61+
}
462

563
describe("irc client nickserv", () => {
664
it("builds IDENTIFY command when password is set", () => {
@@ -77,3 +135,122 @@ describe("irc client fallback nick", () => {
77135
expect(nick).toMatch(/^a+_\d*$/);
78136
});
79137
});
138+
139+
async function collectPrivmsgBodies(
140+
server: LoopbackIrcServer,
141+
text: string,
142+
messageChunkMaxChars?: number,
143+
): Promise<string[]> {
144+
const client = await connectIrcClient({
145+
host: "127.0.0.1",
146+
port: server.port,
147+
tls: false,
148+
nick: "bot",
149+
username: "bot",
150+
realname: "OpenClaw Bot",
151+
connectTimeoutMs: 5000,
152+
messageChunkMaxChars,
153+
});
154+
const receivedBodies = () =>
155+
server.lines
156+
.filter((line) => line.startsWith("PRIVMSG #general :"))
157+
.map((line) => line.slice("PRIVMSG #general :".length));
158+
try {
159+
client.sendPrivmsg("#general", text);
160+
const deadline = Date.now() + 5000;
161+
while (receivedBodies().join("").length < text.length && Date.now() < deadline) {
162+
await new Promise((resolve) => {
163+
setTimeout(resolve, 10);
164+
});
165+
}
166+
} finally {
167+
client.close();
168+
}
169+
return receivedBodies();
170+
}
171+
172+
const LONE_SURROGATE = /[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(?<![\uD800-\uDBFF])[\uDC00-\uDFFF]/;
173+
174+
function maxLineBytes(bodies: string[]): number {
175+
return Math.max(
176+
...bodies.map((body) => Buffer.byteLength(`PRIVMSG #general :${body}\r\n`, "utf8")),
177+
);
178+
}
179+
180+
describe("irc client privmsg byte-limit chunking", () => {
181+
it("splits multi-byte text so every line fits the 512-byte IRC limit", async () => {
182+
const server = await startLoopbackIrcServer();
183+
try {
184+
const text = "漢".repeat(900);
185+
const bodies = await collectPrivmsgBodies(server, text);
186+
expect(bodies.length).toBeGreaterThan(1);
187+
expect(maxLineBytes(bodies)).toBeLessThanOrEqual(512);
188+
expect(bodies.join("")).toBe(text);
189+
} finally {
190+
await server.close();
191+
}
192+
});
193+
194+
it("keeps emoji code points intact while honoring the byte limit", async () => {
195+
const server = await startLoopbackIrcServer();
196+
try {
197+
const text = "\u{1F600}".repeat(300);
198+
const bodies = await collectPrivmsgBodies(server, text);
199+
expect(maxLineBytes(bodies)).toBeLessThanOrEqual(512);
200+
for (const body of bodies) {
201+
expect(LONE_SURROGATE.test(body)).toBe(false);
202+
}
203+
expect(bodies.join("")).toBe(text);
204+
} finally {
205+
await server.close();
206+
}
207+
});
208+
209+
it("preserves the existing 350-char chunking for ASCII text", async () => {
210+
const server = await startLoopbackIrcServer();
211+
try {
212+
const text = "a".repeat(900);
213+
const bodies = await collectPrivmsgBodies(server, text);
214+
expect(bodies.map((body) => body.length)).toEqual([350, 350, 200]);
215+
expect(bodies.join("")).toBe(text);
216+
} finally {
217+
await server.close();
218+
}
219+
});
220+
221+
it("honors a low character cap for multibyte text without shrinking chunks to the byte budget", async () => {
222+
const server = await startLoopbackIrcServer();
223+
try {
224+
const text = "漢".repeat(250);
225+
const bodies = await collectPrivmsgBodies(server, text, 100);
226+
expect(bodies.map((body) => body.length)).toEqual([100, 100, 50]);
227+
expect(bodies.join("")).toBe(text);
228+
} finally {
229+
await server.close();
230+
}
231+
});
232+
233+
it("still advances when the character cap is smaller than one multibyte code point's bytes", async () => {
234+
const server = await startLoopbackIrcServer();
235+
try {
236+
const text = "漢".repeat(10);
237+
const bodies = await collectPrivmsgBodies(server, text, 2);
238+
expect(bodies.map((body) => body.length)).toEqual([2, 2, 2, 2, 2]);
239+
expect(bodies.join("")).toBe(text);
240+
} finally {
241+
await server.close();
242+
}
243+
});
244+
245+
it("keeps one astral code point whole when the legacy character cap is one UTF-16 unit", async () => {
246+
const server = await startLoopbackIrcServer();
247+
try {
248+
const text = "\u{1F600}".repeat(10);
249+
const bodies = await collectPrivmsgBodies(server, text, 1);
250+
expect(bodies.map((body) => body.length)).toEqual(Array(10).fill(2));
251+
expect(bodies.join("")).toBe(text);
252+
} finally {
253+
await server.close();
254+
}
255+
});
256+
});

extensions/irc/src/client.ts

Lines changed: 34 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -3,27 +3,46 @@ 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";
76
import {
87
parseIrcLine,
98
parseIrcPrefix,
109
sanitizeIrcOutboundText,
1110
sanitizeIrcTarget,
1211
} from "./protocol.js";
1312

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-
2513
const IRC_ERROR_CODES = new Set(["432", "464", "465"]);
2614
const IRC_NICK_COLLISION_CODES = new Set(["433", "436"]);
15+
const IRC_MAX_LINE_BYTES = 512;
16+
17+
function takeIrcPrivmsgChunk(text: string, maxChars: number, maxBytes: number): string {
18+
let end = 0;
19+
let bytes = 0;
20+
for (const codePoint of text) {
21+
const codePointBytes = Buffer.byteLength(codePoint, "utf8");
22+
const exceedsCharCap = end > 0 && end + codePoint.length > maxChars;
23+
if (exceedsCharCap || bytes + codePointBytes > maxBytes) {
24+
break;
25+
}
26+
end += codePoint.length;
27+
bytes += codePointBytes;
28+
}
29+
if (end === 0) {
30+
throw new Error("IRC target leaves no room for message text within the 512-byte line limit");
31+
}
32+
if (end === text.length) {
33+
return text;
34+
}
35+
const fitted = text.slice(0, end);
36+
// A delimiter just beyond the cap already gives this chunk a clean word boundary.
37+
if (text[end] === " ") {
38+
return fitted;
39+
}
40+
const splitAt = fitted.lastIndexOf(" ");
41+
if (splitAt >= Math.floor(fitted.length / 2)) {
42+
return fitted.slice(0, splitAt);
43+
}
44+
return fitted;
45+
}
2746

2847
type IrcPrivmsgEvent = {
2948
senderNick: string;
@@ -223,19 +242,11 @@ export async function connectIrcClient(options: IrcClientOptions): Promise<IrcCl
223242
if (!cleaned) {
224243
return;
225244
}
245+
const lineOverheadBytes = Buffer.byteLength(`PRIVMSG ${normalizedTarget} :\r\n`, "utf8");
246+
const maxChunkBytes = IRC_MAX_LINE_BYTES - lineOverheadBytes;
226247
let remaining = cleaned;
227248
while (remaining.length > 0) {
228-
let chunk = remaining;
229-
if (chunk.length > messageChunkMaxChars) {
230-
let splitAt = chunk.lastIndexOf(" ", messageChunkMaxChars);
231-
if (splitAt < Math.floor(messageChunkMaxChars / 2)) {
232-
splitAt = messageChunkMaxChars;
233-
}
234-
chunk = sliceIrcPrivmsgChunk(chunk, splitAt).trim();
235-
}
236-
if (!chunk) {
237-
break;
238-
}
249+
const chunk = takeIrcPrivmsgChunk(remaining, messageChunkMaxChars, maxChunkBytes).trim();
239250
sendRaw(`PRIVMSG ${normalizedTarget} :${chunk}`);
240251
remaining = remaining.slice(chunk.length).trimStart();
241252
}

0 commit comments

Comments
 (0)