Skip to content

Commit 84e5327

Browse files
vincentkocAlix-007
andauthored
fix(text): keep bounded outputs UTF-16 safe (#101355)
Co-authored-by: Alix-007 <[email protected]>
1 parent da20b65 commit 84e5327

18 files changed

Lines changed: 230 additions & 13 deletions
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
// Discord tests cover error body summary behavior.
2+
import { describe, expect, it } from "vitest";
3+
import { summarizeDiscordResponseBody } from "./error-body.js";
4+
5+
describe("summarizeDiscordResponseBody", () => {
6+
it("keeps truncated summaries on a UTF-16 boundary", () => {
7+
const summary = summarizeDiscordResponseBody(`${"a".repeat(239)}😀tail`);
8+
9+
expect(summary).toBe("a".repeat(239));
10+
});
11+
});

extensions/discord/src/error-body.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
11
// Discord plugin module implements error body behavior.
2+
import { truncateUtf16Safe } from "openclaw/plugin-sdk/text-utility-runtime";
3+
24
const DISCORD_RESPONSE_BODY_SUMMARY_MAX_CHARS = 240;
35

46
export function summarizeDiscordResponseBody(
@@ -18,7 +20,7 @@ export function summarizeDiscordResponseBody(
1820
if (!summary) {
1921
return opts.emptyText;
2022
}
21-
return summary.slice(0, DISCORD_RESPONSE_BODY_SUMMARY_MAX_CHARS);
23+
return truncateUtf16Safe(summary, DISCORD_RESPONSE_BODY_SUMMARY_MAX_CHARS);
2224
}
2325

2426
export function isDiscordHtmlResponseBody(body: string, contentType?: string | null): boolean {

extensions/telegram/src/bot-core.raw-update-log.test.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -173,4 +173,19 @@ describe("stringifyTelegramRawUpdateForLog", () => {
173173
expect(rawLog).not.toContain(privateValue);
174174
}
175175
});
176+
177+
it("truncates long raw update strings without splitting UTF-16 surrogate pairs", () => {
178+
const prefix = "a".repeat(499);
179+
const rawLog = stringifyTelegramRawUpdateForLog({
180+
update_id: 123,
181+
diagnostic: `${prefix}\uD83D\uDE80tail`,
182+
});
183+
const parsed = JSON.parse(rawLog) as { diagnostic: string };
184+
185+
expect(parsed.diagnostic).toBe(`${prefix}...`);
186+
expect(parsed.diagnostic).not.toContain("\uD83D");
187+
expect(parsed.diagnostic).not.toContain("\uDE80");
188+
expect(rawLog).not.toContain("\\ud83d");
189+
expect(rawLog).not.toContain("\\ude80");
190+
});
176191
});

extensions/telegram/src/raw-update-log.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
11
// Telegram plugin module implements raw update log behavior.
2+
import { truncateUtf16Safe } from "openclaw/plugin-sdk/text-utility-runtime";
3+
24
const MAX_RAW_UPDATE_STRING = 500;
35
const MAX_RAW_UPDATE_ARRAY = 20;
46
const REDACTED_TELEGRAM_FIELD = "[redacted]";
@@ -80,7 +82,7 @@ export function stringifyTelegramRawUpdateForLog(update: unknown): string {
8082
}
8183
if (typeof value === "string") {
8284
return value.length > MAX_RAW_UPDATE_STRING
83-
? `${value.slice(0, MAX_RAW_UPDATE_STRING)}...`
85+
? `${truncateUtf16Safe(value, MAX_RAW_UPDATE_STRING)}...`
8486
: value;
8587
}
8688
if (Array.isArray(value)) {

extensions/voice-call/src/media-stream.test.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -444,6 +444,13 @@ describe("MediaStreamHandler security hardening", () => {
444444
expect(reason).toContain("forged line entry");
445445
});
446446

447+
it("truncates websocket close reason without splitting UTF-16 surrogate pairs", () => {
448+
const reason = sanitizeLogText(`abc\uD83D\uDE80tail`, 4);
449+
expect(reason).toBe("abc...");
450+
expect(reason).not.toContain("\uD83D");
451+
expect(reason).not.toContain("\uDE80");
452+
});
453+
447454
it("closes idle pre-start connections after timeout", async () => {
448455
const shouldAcceptStreamCalls: Array<{ callId: string; streamSid: string; token?: string }> =
449456
[];

extensions/voice-call/src/media-stream.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ import {
2323
type TalkEventInput,
2424
type TalkSessionController,
2525
} from "openclaw/plugin-sdk/realtime-voice";
26+
import { truncateUtf16Safe } from "openclaw/plugin-sdk/text-utility-runtime";
2627
import { type RawData, WebSocket, WebSocketServer } from "ws";
2728

2829
/**
@@ -109,7 +110,7 @@ export function sanitizeLogText(value: string, maxChars: number): string {
109110
if (sanitized.length <= maxChars) {
110111
return sanitized;
111112
}
112-
return `${sanitized.slice(0, maxChars)}...`;
113+
return `${truncateUtf16Safe(sanitized, maxChars)}...`;
113114
}
114115

115116
function normalizeWsMessageData(data: RawData): Buffer {

extensions/voice-call/src/webhook.test.ts

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1930,6 +1930,48 @@ describe("VoiceCallWebhookServer barge-in suppression during initial message", (
19301930
};
19311931
};
19321932

1933+
it("logs streaming transcripts without splitting UTF-16 surrogate pairs", async () => {
1934+
const manager = {
1935+
getActiveCalls: () => [],
1936+
getCallByProviderCallId: vi.fn(() => undefined),
1937+
endCall: vi.fn(async () => ({ success: true })),
1938+
speakInitialMessage: vi.fn(async () => {}),
1939+
processEvent: vi.fn(),
1940+
} as unknown as CallManager;
1941+
const config = createConfig({
1942+
provider: "twilio",
1943+
streaming: {
1944+
...createConfig().streaming,
1945+
enabled: true,
1946+
providers: {
1947+
openai: {
1948+
apiKey: "test-key", // pragma: allowlist secret
1949+
},
1950+
},
1951+
},
1952+
});
1953+
const server = new VoiceCallWebhookServer(config, manager, createTwilioProvider(vi.fn()));
1954+
await server.start();
1955+
const logSpy = vi.spyOn(console, "log").mockImplementation(() => {});
1956+
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
1957+
1958+
try {
1959+
const transcript = `${"a".repeat(199)}\uD83D\uDE80tail`;
1960+
getMediaCallbacks(server).config.onTranscript?.("CA-utf16", transcript);
1961+
1962+
const transcriptLog = logSpy.mock.calls
1963+
.map(([message]) => String(message))
1964+
.find((message) => message.includes("Transcript for CA-utf16"));
1965+
expect(transcriptLog).toContain(`${"a".repeat(199)}...`);
1966+
expect(transcriptLog).not.toContain("\uD83D");
1967+
expect(transcriptLog).not.toContain("\uDE80");
1968+
} finally {
1969+
logSpy.mockRestore();
1970+
warnSpy.mockRestore();
1971+
await server.stop();
1972+
}
1973+
});
1974+
19331975
it("suppresses barge-in clear while outbound conversation initial message is pending", async () => {
19341976
const call = createCall(Date.now() - 1_000);
19351977
call.callId = "call-barge";

extensions/voice-call/src/webhook.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import {
1313
normalizeOptionalString,
1414
normalizeStringEntries,
1515
} from "openclaw/plugin-sdk/string-coerce-runtime";
16+
import { truncateUtf16Safe } from "openclaw/plugin-sdk/text-utility-runtime";
1617
import {
1718
createWebhookInFlightLimiter,
1819
normalizeWebhookPath,
@@ -79,7 +80,7 @@ function sanitizeTranscriptForLog(value: string): string {
7980
if (sanitized.length <= TRANSCRIPT_LOG_MAX_CHARS) {
8081
return sanitized;
8182
}
82-
return `${sanitized.slice(0, TRANSCRIPT_LOG_MAX_CHARS)}...`;
83+
return `${truncateUtf16Safe(sanitized, TRANSCRIPT_LOG_MAX_CHARS)}...`;
8384
}
8485

8586
function appendRecentTalkEventMetadata(call: CallRecord, event: TalkEvent): void {

packages/speech-core/src/tts.test.ts

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -115,6 +115,8 @@ const {
115115
getTtsProvider,
116116
maybeApplyTtsToPayload,
117117
resolveTtsConfig,
118+
setSummarizationEnabled,
119+
setTtsMaxLength,
118120
synthesizeSpeech,
119121
textToSpeechTelephony,
120122
} = await import("./tts.js");
@@ -938,6 +940,35 @@ describe("speech-core native voice-note routing", () => {
938940
}
939941
});
940942

943+
it("truncates long TTS text on a UTF-16 boundary", async () => {
944+
const prefsName = "openclaw-speech-core-utf16-truncate-test";
945+
const prefsPath = `/tmp/${prefsName}.json`;
946+
const cfg = createTtsConfig(prefsName);
947+
setTtsMaxLength(prefsPath, 11);
948+
setSummarizationEnabled(prefsPath, false);
949+
let mediaDir: string | undefined;
950+
try {
951+
const result = await maybeApplyTtsToPayload({
952+
payload: { text: `${"a".repeat(7)}😀tail long enough for TTS` },
953+
cfg,
954+
channel: "telegram",
955+
kind: "final",
956+
});
957+
958+
expect(synthesizeMock).toHaveBeenCalled();
959+
const request = requireFirstSynthesisRequest("utf16 truncated TTS request");
960+
const spokenText = String(request.text);
961+
expect(spokenText).toBe(`${"a".repeat(7)}...`);
962+
expect(result.spokenText).toBe(spokenText);
963+
mediaDir = result.mediaUrl ? path.dirname(result.mediaUrl) : undefined;
964+
} finally {
965+
rmSync(prefsPath, { force: true });
966+
if (mediaDir) {
967+
rmSync(mediaDir, { recursive: true, force: true });
968+
}
969+
}
970+
});
971+
941972
it("skips block delivery kind in final mode (accumulated final tail synthesizes instead)", async () => {
942973
synthesizeMock.mockClear();
943974
const cfg = createTtsConfig("openclaw-speech-core-block-kind-tts-test");

packages/speech-core/src/tts.ts

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,11 @@ import {
3333
normalizeOptionalString,
3434
} from "openclaw/plugin-sdk/string-coerce-runtime";
3535
import { stripMarkdown } from "openclaw/plugin-sdk/text-chunking";
36-
import { resolveConfigDir, resolveUserPath } from "openclaw/plugin-sdk/text-utility-runtime";
36+
import {
37+
resolveConfigDir,
38+
resolveUserPath,
39+
truncateUtf16Safe,
40+
} from "openclaw/plugin-sdk/text-utility-runtime";
3741
import {
3842
canonicalizeSpeechProviderId,
3943
getSpeechProvider,
@@ -2028,7 +2032,7 @@ export async function maybeApplyTtsToPayload(params: {
20282032
logVerbose(
20292033
`TTS: truncating long text (${textForAudio.length} > ${maxLength}), summarization disabled.`,
20302034
);
2031-
textForAudio = `${textForAudio.slice(0, maxLength - 3)}...`;
2035+
textForAudio = `${truncateUtf16Safe(textForAudio, maxLength - 3)}...`;
20322036
} else {
20332037
try {
20342038
const summary = await summarizeText({
@@ -2044,12 +2048,12 @@ export async function maybeApplyTtsToPayload(params: {
20442048
logVerbose(
20452049
`TTS: summary exceeded hard limit (${textForAudio.length} > ${config.maxTextLength}); truncating.`,
20462050
);
2047-
textForAudio = `${textForAudio.slice(0, config.maxTextLength - 3)}...`;
2051+
textForAudio = `${truncateUtf16Safe(textForAudio, config.maxTextLength - 3)}...`;
20482052
}
20492053
} catch (err) {
20502054
const error = err as Error;
20512055
logVerbose(`TTS: summarization failed, truncating instead: ${error.message}`);
2052-
textForAudio = `${textForAudio.slice(0, maxLength - 3)}...`;
2056+
textForAudio = `${truncateUtf16Safe(textForAudio, maxLength - 3)}...`;
20532057
}
20542058
}
20552059
}

0 commit comments

Comments
 (0)