Skip to content

Commit fa2379d

Browse files
fix(telegram): clip progress text on code-point boundaries to avoid lone surrogates (#96456)
Merged via squash. Prepared head SHA: 765d6c0 Co-authored-by: he-yufeng <[email protected]> Co-authored-by: vincentkoc <[email protected]> Reviewed-by: @vincentkoc
1 parent ce6d97d commit fa2379d

3 files changed

Lines changed: 72 additions & 11 deletions

File tree

extensions/telegram/src/bot-message-dispatch.ts

Lines changed: 4 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,7 @@ import {
6969
resolveDefaultModelForAgent,
7070
} from "./bot-message-dispatch.agent.runtime.js";
7171
import { deduplicateBlockSentMedia } from "./bot-message-dispatch.media-dedup.js";
72+
import { clipTelegramProgressText } from "./truncate.js";
7273
import {
7374
generateTopicLabel,
7475
getAgentScopedMediaLocalRoots,
@@ -364,22 +365,14 @@ async function mirrorTelegramAssistantReplyToTranscript(params: {
364365
}
365366
}
366367

367-
const MAX_PROGRESS_MARKDOWN_TEXT_CHARS = 300;
368368
const TELEGRAM_GENERAL_TOPIC_ID = 1;
369369

370-
function clipProgressMarkdownText(text: string): string {
371-
if (text.length <= MAX_PROGRESS_MARKDOWN_TEXT_CHARS) {
372-
return text;
373-
}
374-
return `${text.slice(0, MAX_PROGRESS_MARKDOWN_TEXT_CHARS - 1).trimEnd()}…`;
375-
}
376-
377370
function sanitizeProgressMarkdownText(text: string): string {
378371
return text.replaceAll("`", "'");
379372
}
380373

381374
function formatProgressAsMarkdownCode(text: string): string {
382-
const clipped = clipProgressMarkdownText(text);
375+
const clipped = clipTelegramProgressText(text);
383376
return `\`${sanitizeProgressMarkdownText(clipped)}\``;
384377
}
385378

@@ -399,7 +392,7 @@ function escapeTelegramProgressHtml(text: string): string {
399392
}
400393

401394
function renderTelegramProgressStringLine(text: string): string {
402-
const clipped = clipProgressMarkdownText(text.trim());
395+
const clipped = clipTelegramProgressText(text.trim());
403396
const italic = clipped.match(/^_(.*)_$/u);
404397
if (italic) {
405398
return `<i>${escapeTelegramProgressHtml(italic[1] ?? "")}</i>`;
@@ -418,7 +411,7 @@ function renderTelegramProgressLine(line: ChannelProgressDraftCompositorLine): s
418411
const parts = [`<b>${escapeTelegramProgressHtml(label)}</b>`];
419412
const detail = line.detail && line.detail !== line.label ? line.detail : undefined;
420413
if (detail) {
421-
parts.push(`<code>${escapeTelegramProgressHtml(clipProgressMarkdownText(detail))}</code>`);
414+
parts.push(`<code>${escapeTelegramProgressHtml(clipTelegramProgressText(detail))}</code>`);
422415
} else {
423416
const text = line.text.trim();
424417
if (text && text !== label) {
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
// Telegram tests cover progress text clipping behavior.
2+
import { describe, expect, it } from "vitest";
3+
import { clipTelegramProgressText, TELEGRAM_PROGRESS_MAX_CHARS } from "./truncate.js";
4+
5+
describe("clipTelegramProgressText", () => {
6+
it("drops a surrogate-pair emoji whole when it straddles the limit", () => {
7+
// 😀 is U+1F600, encoded as two UTF-16 code units (high \uD83D + low \uDE00).
8+
// Placing the emoji at positions [MAX-2, MAX-1] (0-indexed) puts its high
9+
// surrogate right on the .slice(0, MAX-1) cut edge. A raw .slice keeps only
10+
// \uD83D — an unpaired high surrogate — which is invalid in a Telegram payload.
11+
const base = "a".repeat(TELEGRAM_PROGRESS_MAX_CHARS - 2); // 298 'a's
12+
const out = clipTelegramProgressText(`${base}😀tail`);
13+
expect(out).toBe(`${base}…`);
14+
// No dangling high surrogate (high not followed by a low surrogate).
15+
expect(/[\uD800-\uDBFF](?![\uDC00-\uDFFF])/.test(out)).toBe(false);
16+
});
17+
18+
it("keeps an emoji that fits entirely before the cut", () => {
19+
// 296 'a's + '😀' (2 units) + 'xyz' (3 units) = 301 total > 300.
20+
// The emoji sits at [296, 297] — entirely before the cut at 299 — so it stays.
21+
const base = "a".repeat(TELEGRAM_PROGRESS_MAX_CHARS - 4); // 296 'a's
22+
const out = clipTelegramProgressText(`${base}😀xyz`);
23+
expect(out).toBe(`${base}😀x…`);
24+
expect(/[\uD800-\uDBFF](?![\uDC00-\uDFFF])/.test(out)).toBe(false);
25+
});
26+
27+
it("returns text unchanged when it is within the limit", () => {
28+
const short = "hello 😀 world";
29+
expect(clipTelegramProgressText(short)).toBe(short);
30+
});
31+
32+
it("trims trailing whitespace before the ellipsis", () => {
33+
// The sliced portion may end in spaces when trailing spaces straddle the cut.
34+
const text = `${"a".repeat(TELEGRAM_PROGRESS_MAX_CHARS - 2)} rest`;
35+
const out = clipTelegramProgressText(text);
36+
expect(out).not.toContain(" …");
37+
expect(out.endsWith("…")).toBe(true);
38+
});
39+
40+
it("handles plain ASCII that fills exactly to the limit", () => {
41+
const exact = "x".repeat(TELEGRAM_PROGRESS_MAX_CHARS);
42+
expect(clipTelegramProgressText(exact)).toBe(exact);
43+
const oneOver = `${"x".repeat(TELEGRAM_PROGRESS_MAX_CHARS)}y`;
44+
const out = clipTelegramProgressText(oneOver);
45+
expect(out.length).toBeLessThanOrEqual(TELEGRAM_PROGRESS_MAX_CHARS);
46+
expect(out.endsWith("…")).toBe(true);
47+
});
48+
});
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
// Telegram tests cover progress text clipping behavior.
2+
import { sliceUtf16Safe } from "openclaw/plugin-sdk/text-utility-runtime";
3+
4+
export const TELEGRAM_PROGRESS_MAX_CHARS = 300;
5+
6+
/**
7+
* Clips Telegram progress text to at most {@link TELEGRAM_PROGRESS_MAX_CHARS} UTF-16 code units,
8+
* slicing on a code-point boundary so a surrogate pair straddling the limit is
9+
* dropped whole rather than leaving a lone high surrogate in the payload.
10+
*/
11+
export function clipTelegramProgressText(text: string): string {
12+
if (text.length <= TELEGRAM_PROGRESS_MAX_CHARS) {
13+
return text;
14+
}
15+
// Slice on a code-point boundary so an emoji (or any astral character) that
16+
// straddles the limit is dropped whole instead of leaving a lone \uD83D-style
17+
// high surrogate before the ellipsis, which serializes to an invalid character
18+
// in the Telegram Bot API payload.
19+
return `${sliceUtf16Safe(text, 0, TELEGRAM_PROGRESS_MAX_CHARS - 1).trimEnd()}…`;
20+
}

0 commit comments

Comments
 (0)