Skip to content

Commit 2823741

Browse files
ly-wang19claude
andcommitted
fix(feishu): truncate streaming-card summary on a code-point boundary
truncateSummary used clean.slice(0, max - 3), which can cut between the two UTF-16 halves of a surrogate pair (emoji / astral char) straddling the limit. The serialized card summary then carries a lone high surrogate that Feishu renders as the replacement char. Slice with the surrogate-safe sliceUtf16Safe helper instead, matching the pattern already used in extensions/slack/src/truncate.ts, so a straddling code point is dropped whole. Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
1 parent e4763b0 commit 2823741

2 files changed

Lines changed: 71 additions & 1 deletion

File tree

extensions/feishu/src/streaming-card.test.ts

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -341,6 +341,72 @@ describe("FeishuStreamingSession", () => {
341341
});
342342
});
343343

344+
it("drops a surrogate pair whole when truncating the closeout summary", async () => {
345+
vi.useFakeTimers();
346+
vi.setSystemTime(4_200);
347+
// 46 'a' + 😀 (U+1F600, UTF-16 indices 46-47) + 20 'b' = 68-char string.
348+
// truncateSummary's default max is 50, so it slices at max-3 = 47, which
349+
// lands between the high and low surrogate halves of the emoji.
350+
const finalText = `${"a".repeat(46)}\u{1F600}${"b".repeat(20)}`;
351+
const settingsBodies: string[] = [];
352+
const release = vi.fn(async () => {});
353+
fetchWithSsrFGuardMock.mockImplementation(
354+
async ({ url, init }: { url: string; init?: { body?: string } }) => {
355+
if (url.includes("/auth/")) {
356+
return {
357+
response: {
358+
ok: true,
359+
json: async () => ({
360+
code: 0,
361+
msg: "ok",
362+
tenant_access_token: "token",
363+
expire: 7200,
364+
}),
365+
},
366+
release,
367+
};
368+
}
369+
if (url.includes("/settings")) {
370+
settingsBodies.push(init?.body ?? "");
371+
}
372+
return {
373+
response: { ok: true, status: 200, json: async () => ({ code: 0, msg: "ok" }) },
374+
release,
375+
};
376+
},
377+
);
378+
379+
const session = new FeishuStreamingSession({} as never, {
380+
appId: "app_summary_surrogate",
381+
appSecret: "secret",
382+
});
383+
setStreamingSessionInternals(session, {
384+
state: {
385+
cardId: "card_surrogate",
386+
messageId: "om_surrogate",
387+
sequence: 1,
388+
currentText: "",
389+
sentText: "",
390+
hasNote: false,
391+
},
392+
lastUpdateTime: 3_000,
393+
});
394+
395+
await session.close(finalText);
396+
397+
expect(settingsBodies).toHaveLength(1);
398+
const settingsPayload = JSON.parse(settingsBodies[0] ?? "{}") as { settings?: string };
399+
const settings = JSON.parse(settingsPayload.settings ?? "{}") as {
400+
config?: { summary?: { content?: string } };
401+
};
402+
const summary = settings.config?.summary?.content ?? "";
403+
// The half-emoji must be dropped whole: 46 a's + "...", and the summary
404+
// must NOT end with a lone high surrogate (which Feishu renders as �).
405+
expect(summary).toBe(`${"a".repeat(46)}...`);
406+
expect(summary).not.toContain("\uD83D");
407+
expect(summary.charCodeAt(summary.length - 4)).not.toBe(0xd83d);
408+
});
409+
344410
it("logs a final replacement failure when CardKit returns non-OK", async () => {
345411
vi.useFakeTimers();
346412
vi.setSystemTime(4_500);

extensions/feishu/src/streaming-card.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import {
99
resolveExpiresAtMsFromDurationSeconds,
1010
} from "openclaw/plugin-sdk/number-runtime";
1111
import { fetchWithSsrFGuard } from "openclaw/plugin-sdk/ssrf-runtime";
12+
import { sliceUtf16Safe } from "openclaw/plugin-sdk/text-utility-runtime";
1213
import { getFeishuUserAgent } from "./client.js";
1314
import { requestFeishuApi } from "./comment-shared.js";
1415
import { resolveFeishuCardTemplate, type CardHeaderConfig } from "./send.js";
@@ -138,7 +139,10 @@ function truncateSummary(text: string, max = 50): string {
138139
return "";
139140
}
140141
const clean = text.replace(/\n/g, " ").trim();
141-
return clean.length <= max ? clean : clean.slice(0, max - 3) + "...";
142+
// Slice on a code-point boundary so a surrogate pair (emoji / astral char)
143+
// straddling the limit is dropped whole, instead of leaving a lone surrogate
144+
// half that Feishu renders as the replacement char.
145+
return clean.length <= max ? clean : sliceUtf16Safe(clean, 0, max - 3) + "...";
142146
}
143147

144148
function hasNaturalStreamingBoundary(text: string): boolean {

0 commit comments

Comments
 (0)