Merge pull request #7209 from gaord/fix/bridge-byte-chunking

fix(bridges): cap WeCom reply chunks by UTF-8 bytes
This commit is contained in:
Hunter Bown authored and GitHub committed 2026-11-06 08:33:48 +08:00
commit 51cc867aa5
3 files changed
+65 -12

No files matched your search

+38 -11
View File
@@ -559,23 +559,50 @@ export function preservedChatStateFields(state = {}, fields = ["model", "authori
return preserved;
}
export function splitMessage(text, maxChars = 3500) {
export function splitMessage(text, maxChars = 3500, maxBytes = 0) {
const value = String(text || "");
const limit = Math.max(1, Math.floor(Number(maxChars) || 3500));
// Materialize code points once; all chunking below works on index ranges
// instead of re-running Array.from over the shrinking remainder.
const chars = Array.from(value);
if (chars.length <= limit) return value ? [value] : [];
const chunks = [];
let offset = 0;
let openFence = null;
while (offset < chars.length) {
const next = takeRenderedSplitMessageChunk(chars, offset, limit, openFence);
chunks.push(next.chunk);
offset = next.offset;
openFence = next.openFence;
let chunks;
if (chars.length <= limit) {
chunks = value ? [value] : [];
} else {
chunks = [];
let offset = 0;
let openFence = null;
while (offset < chars.length) {
const next = takeRenderedSplitMessageChunk(chars, offset, limit, openFence);
chunks.push(next.chunk);
offset = next.offset;
openFence = next.openFence;
}
}
return chunks;
const byteLimit = Math.floor(Number(maxBytes) || 0);
if (byteLimit <= 0) return chunks;
// Transport caps such as WeCom's 4096-byte message limit are measured in
// UTF-8 bytes, not characters: a 3500-character CJK reply is ~10.5 KB and
// gets silently truncated. Split any oversized chunk on code-point bounds.
return chunks.flatMap((chunk) => splitChunkByBytes(chunk, byteLimit));
}
function splitChunkByBytes(chunk, maxBytes) {
const out = [];
let buf = "";
let size = 0;
for (const ch of String(chunk || "")) {
const b = Buffer.byteLength(ch, "utf8");
if (size + b > maxBytes && buf) {
out.push(buf);
buf = "";
size = 0;
}
buf += ch;
size += b;
}
if (buf) out.push(buf);
return out;
}
function takeRenderedSplitMessageChunk(chars, offset, maxChars, openFence) {
@@ -337,3 +337,26 @@ test("ThreadStore claims survive a restart: finished messages skip, an in-flight
await rm(dir, { recursive: true, force: true });
}
});
// ─── splitMessage byte cap ───────────────────────────────────────
test("splitMessage splits CJK text by bytes when a byte cap is set", () => {
const cjk = "横".repeat(4000); // 4000 chars = 12000 UTF-8 bytes
const chunks = splitMessage(cjk, 3500, 3800);
assert.ok(chunks.length >= 3, `expected >=3 chunks, got ${chunks.length}`);
for (const c of chunks) assert.ok(Buffer.byteLength(c, "utf8") <= 3800);
assert.equal(chunks.join(""), cjk);
});
test("splitMessage keeps short messages single under a byte cap", () => {
assert.deepEqual(splitMessage("hello", 3500, 3800), ["hello"]);
const mixed = "字".repeat(100) + "x".repeat(100); // 400 bytes
assert.equal(splitMessage(mixed, 3500, 3800).length, 1);
});
test("splitMessage byte cap is opt-in", () => {
const cjk = "横".repeat(4000);
const chunks = splitMessage(cjk, 3500, 0);
assert.equal(chunks.length, 2); // char limit only
assert.ok(Buffer.byteLength(chunks[0], "utf8") > 3800);
});
+4 -1
View File
@@ -54,6 +54,7 @@ const config = {
allowUnlisted: parseBool(process.env.WECOM_ALLOW_UNLISTED, false),
threadMapPath: process.env.WECOM_THREAD_MAP_PATH || "/var/lib/codewhale-wecom-bridge/thread-map.json",
maxReplyChars: Number(process.env.WECOM_MAX_REPLY_CHARS || 3500),
maxReplyBytes: Number(process.env.WECOM_MAX_REPLY_BYTES || 3800),
turnTimeoutMs: Number(process.env.CODEWHALE_TURN_TIMEOUT_MS || 900000),
approvalTimeoutMs: Number(process.env.CODEWHALE_APPROVAL_TIMEOUT_MS || 300000)
};
@@ -97,7 +98,9 @@ if (!config.allowlist.length && !config.allowUnlisted) {
client.connect();
function replyText(frame, text) {
const chunks = splitMessage(text, config.maxReplyChars);
// WeCom caps one message at 4096 UTF-8 bytes; without the byte cap a
// char-split chunk of CJK text is truncated silently.
const chunks = splitMessage(text, config.maxReplyChars, config.maxReplyBytes);
return chunks.reduce(
(chain, chunk) => chain.then(() => client.replyStream(frame, generateReqId("stream"), chunk, true)),
Promise.resolve()