mirror of
https://github.com/codewhale-hq/Codewhale.git
synced 2026-11-09 17:10:44 -10:00
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:
3 files changed
+65
-12
No files matched your search
@@ -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);
|
||||
});
|
||||
@@ -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()
|
||||
|
||||
Reference in new issue
Block a user