Skip to content

Commit 790cd2a

Browse files
authored
fix(twitch): preserve UTF-16 pairs in outbound chunks (#104030)
1 parent a1571f2 commit 790cd2a

8 files changed

Lines changed: 90 additions & 48 deletions

File tree

extensions/twitch/src/constants.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export const TWITCH_CHAT_MESSAGE_LIMIT = 500;
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
import { beforeEach, describe, expect, it, vi } from "vitest";
2+
import { BASE_TWITCH_TEST_ACCOUNT } from "./test-fixtures.js";
3+
4+
const mocks = vi.hoisted(() => ({
5+
sendMessage: vi.fn(),
6+
}));
7+
8+
vi.mock("./client-manager-registry.js", () => ({
9+
getOrCreateClientManager: () => ({ sendMessage: mocks.sendMessage }),
10+
}));
11+
12+
import { testing } from "./monitor.js";
13+
14+
describe("deliverTwitchReply", () => {
15+
beforeEach(() => {
16+
mocks.sendMessage.mockReset();
17+
mocks.sendMessage.mockResolvedValue({ ok: true, messageId: "message-id" });
18+
});
19+
20+
it("routes fallback replies through the UTF-16-safe transport sender", async () => {
21+
const account = { ...BASE_TWITCH_TEST_ACCOUNT, accessToken: "oauth:test-token" };
22+
23+
const result = await testing.deliverTwitchReply({
24+
payload: { text: "**Hello** Twitch" },
25+
channel: "testchannel",
26+
account,
27+
accountId: "default",
28+
config: {},
29+
tableMode: "off",
30+
runtime: {},
31+
});
32+
33+
expect(result).toEqual({ visibleReplySent: true });
34+
expect(mocks.sendMessage).toHaveBeenCalledWith(
35+
account,
36+
"testchannel",
37+
"Hello Twitch",
38+
{},
39+
"default",
40+
);
41+
});
42+
});

extensions/twitch/src/monitor.ts

Lines changed: 15 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -190,25 +190,24 @@ async function deliverTwitchReply(params: {
190190
debug: (msg) => runtime.log?.(msg),
191191
});
192192

193-
const client = await clientManager.getClient(
194-
account,
195-
config as Parameters<typeof clientManager.getClient>[1],
196-
accountId,
197-
);
198-
if (!client) {
199-
runtime.error?.(`No client available for sending reply`);
200-
return { visibleReplySent: false };
201-
}
202-
203-
// Send the reply
204193
if (!payload.text) {
205194
runtime.error?.(`No text to send in reply payload`);
206195
return { visibleReplySent: false };
207196
}
208-
209197
const textToSend = stripMarkdownForTwitch(payload.text);
210-
211-
await client.say(channel, textToSend);
198+
if (!textToSend) {
199+
return { visibleReplySent: false };
200+
}
201+
const result = await clientManager.sendMessage(
202+
account,
203+
channel,
204+
textToSend,
205+
config as Parameters<typeof clientManager.sendMessage>[3],
206+
accountId,
207+
);
208+
if (!result.ok) {
209+
throw new Error(result.error ?? "Send failed");
210+
}
212211
return { visibleReplySent: true };
213212
} catch (err) {
214213
runtime.error?.(`Failed to send reply: ${String(err)}`);
@@ -303,3 +302,5 @@ export async function monitorTwitchProvider(
303302

304303
return { stop };
305304
}
305+
306+
export const testing = { deliverTwitchReply };

extensions/twitch/src/outbound.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import {
1414
import { normalizeStringEntries } from "openclaw/plugin-sdk/string-coerce-runtime";
1515
import { sanitizeAssistantVisibleText } from "openclaw/plugin-sdk/text-chunking";
1616
import { resolveTwitchAccountContext } from "./config.js";
17+
import { TWITCH_CHAT_MESSAGE_LIMIT } from "./constants.js";
1718
import { sendMessageTwitchInternal } from "./send.js";
1819
import type {
1920
ChannelOutboundAdapter,
@@ -42,7 +43,7 @@ export const twitchOutbound: ChannelOutboundAdapter = {
4243
},
4344

4445
/** Twitch chat message limit is 500 characters */
45-
textChunkLimit: 500,
46+
textChunkLimit: TWITCH_CHAT_MESSAGE_LIMIT,
4647

4748
/** Strip internal assistant tool-trace scaffolding before delivery */
4849
sanitizeText: ({ text }) => sanitizeAssistantVisibleText(text),

extensions/twitch/src/twitch-client.test.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -564,6 +564,18 @@ describe("TwitchClientManager", () => {
564564
expect(mockSay).toHaveBeenCalledWith("testchannel", "Hello, world!");
565565
});
566566

567+
it("should keep surrogate pairs intact when pre-chunking long messages", async () => {
568+
const prefix = "a".repeat(499);
569+
570+
const result = await manager.sendMessage(testAccount, "testchannel", `${prefix}😀b`);
571+
572+
expect(result.ok).toBe(true);
573+
expect(mockSay.mock.calls).toEqual([
574+
["testchannel", prefix],
575+
["testchannel", "😀b"],
576+
]);
577+
});
578+
567579
it("should generate unique message ID for each message", async () => {
568580
const result1 = await manager.sendMessage(testAccount, "testchannel", "First message");
569581
const result2 = await manager.sendMessage(testAccount, "testchannel", "Second message");

extensions/twitch/src/twitch-client.ts

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,9 @@ import { RefreshingAuthProvider, StaticAuthProvider } from "@twurple/auth";
33
import { ChatClient, LogLevel } from "@twurple/chat";
44
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
55
import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime";
6+
import { chunkTextForOutbound } from "openclaw/plugin-sdk/text-chunking";
67
import { sliceUtf16Safe } from "openclaw/plugin-sdk/text-utility-runtime";
8+
import { TWITCH_CHAT_MESSAGE_LIMIT } from "./constants.js";
79
import { resolveTwitchToken } from "./token.js";
810
import type { ChannelLogSink, TwitchAccountConfig, TwitchChatMessage } from "./types.js";
911
import { normalizeToken } from "./utils/twitch.js";
@@ -379,8 +381,10 @@ export class TwitchClientManager {
379381
// Generate a message ID (Twurple's say() doesn't return the message ID, so we generate one)
380382
const messageId = crypto.randomUUID();
381383

382-
// Send message (Twurple handles rate limiting)
383-
await client.say(channel, message);
384+
// Pre-chunk so Twurple's raw UTF-16 fallback cannot split surrogate pairs.
385+
for (const chunk of chunkTextForOutbound(message, TWITCH_CHAT_MESSAGE_LIMIT)) {
386+
await client.say(channel, chunk);
387+
}
384388

385389
return { ok: true, messageId };
386390
} catch (error) {
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
import { describe, expect, it } from "vitest";
2+
import { chunkTextForTwitch } from "./markdown.js";
3+
4+
describe("chunkTextForTwitch", () => {
5+
it("strips markdown and keeps surrogate pairs intact at hard boundaries", () => {
6+
const prefix = "a".repeat(499);
7+
8+
expect(chunkTextForTwitch(`**${prefix}😀b**`, 500)).toEqual([prefix, "😀b"]);
9+
});
10+
});

extensions/twitch/src/utils/markdown.ts

Lines changed: 2 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
* Twitch chat doesn't support markdown formatting, so we strip it before sending.
55
* Based on OpenClaw's markdownToText in src/agents/tools/web-fetch-utils.ts.
66
*/
7+
import { chunkTextForOutbound } from "openclaw/plugin-sdk/text-chunking";
78

89
/**
910
* Strip markdown formatting from text for Twitch compatibility.
@@ -64,35 +65,5 @@ export function chunkTextForTwitch(text: string, limit: number): string[] {
6465
if (!cleaned) {
6566
return [];
6667
}
67-
if (limit <= 0) {
68-
return [cleaned];
69-
}
70-
if (cleaned.length <= limit) {
71-
return [cleaned];
72-
}
73-
74-
const chunks: string[] = [];
75-
let remaining = cleaned;
76-
77-
while (remaining.length > limit) {
78-
// Find the last space before the limit
79-
const window = remaining.slice(0, limit);
80-
const lastSpaceIndex = window.lastIndexOf(" ");
81-
82-
if (lastSpaceIndex === -1) {
83-
// No space found, hard split at limit
84-
chunks.push(window);
85-
remaining = remaining.slice(limit);
86-
} else {
87-
// Split at the last space
88-
chunks.push(window.slice(0, lastSpaceIndex));
89-
remaining = remaining.slice(lastSpaceIndex + 1);
90-
}
91-
}
92-
93-
if (remaining) {
94-
chunks.push(remaining);
95-
}
96-
97-
return chunks;
68+
return chunkTextForOutbound(cleaned, limit);
9869
}

0 commit comments

Comments
 (0)