fix(twitch): preserve UTF-16 pairs in outbound chunks (#104030)

This commit is contained in:
Peter Steinberger
2026-07-10 18:20:29 -07:00
committed by GitHub
parent a1571f2118
commit 790cd2aff2
8 changed files with 90 additions and 48 deletions

View File

@@ -0,0 +1 @@
export const TWITCH_CHAT_MESSAGE_LIMIT = 500;

View File

@@ -0,0 +1,42 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { BASE_TWITCH_TEST_ACCOUNT } from "./test-fixtures.js";
const mocks = vi.hoisted(() => ({
sendMessage: vi.fn(),
}));
vi.mock("./client-manager-registry.js", () => ({
getOrCreateClientManager: () => ({ sendMessage: mocks.sendMessage }),
}));
import { testing } from "./monitor.js";
describe("deliverTwitchReply", () => {
beforeEach(() => {
mocks.sendMessage.mockReset();
mocks.sendMessage.mockResolvedValue({ ok: true, messageId: "message-id" });
});
it("routes fallback replies through the UTF-16-safe transport sender", async () => {
const account = { ...BASE_TWITCH_TEST_ACCOUNT, accessToken: "oauth:test-token" };
const result = await testing.deliverTwitchReply({
payload: { text: "**Hello** Twitch" },
channel: "testchannel",
account,
accountId: "default",
config: {},
tableMode: "off",
runtime: {},
});
expect(result).toEqual({ visibleReplySent: true });
expect(mocks.sendMessage).toHaveBeenCalledWith(
account,
"testchannel",
"Hello Twitch",
{},
"default",
);
});
});

View File

@@ -190,25 +190,24 @@ async function deliverTwitchReply(params: {
debug: (msg) => runtime.log?.(msg),
});
const client = await clientManager.getClient(
account,
config as Parameters<typeof clientManager.getClient>[1],
accountId,
);
if (!client) {
runtime.error?.(`No client available for sending reply`);
return { visibleReplySent: false };
}
// Send the reply
if (!payload.text) {
runtime.error?.(`No text to send in reply payload`);
return { visibleReplySent: false };
}
const textToSend = stripMarkdownForTwitch(payload.text);
await client.say(channel, textToSend);
if (!textToSend) {
return { visibleReplySent: false };
}
const result = await clientManager.sendMessage(
account,
channel,
textToSend,
config as Parameters<typeof clientManager.sendMessage>[3],
accountId,
);
if (!result.ok) {
throw new Error(result.error ?? "Send failed");
}
return { visibleReplySent: true };
} catch (err) {
runtime.error?.(`Failed to send reply: ${String(err)}`);
@@ -303,3 +302,5 @@ export async function monitorTwitchProvider(
return { stop };
}
export const testing = { deliverTwitchReply };

View File

@@ -14,6 +14,7 @@ import {
import { normalizeStringEntries } from "openclaw/plugin-sdk/string-coerce-runtime";
import { sanitizeAssistantVisibleText } from "openclaw/plugin-sdk/text-chunking";
import { resolveTwitchAccountContext } from "./config.js";
import { TWITCH_CHAT_MESSAGE_LIMIT } from "./constants.js";
import { sendMessageTwitchInternal } from "./send.js";
import type {
ChannelOutboundAdapter,
@@ -42,7 +43,7 @@ export const twitchOutbound: ChannelOutboundAdapter = {
},
/** Twitch chat message limit is 500 characters */
textChunkLimit: 500,
textChunkLimit: TWITCH_CHAT_MESSAGE_LIMIT,
/** Strip internal assistant tool-trace scaffolding before delivery */
sanitizeText: ({ text }) => sanitizeAssistantVisibleText(text),

View File

@@ -564,6 +564,18 @@ describe("TwitchClientManager", () => {
expect(mockSay).toHaveBeenCalledWith("testchannel", "Hello, world!");
});
it("should keep surrogate pairs intact when pre-chunking long messages", async () => {
const prefix = "a".repeat(499);
const result = await manager.sendMessage(testAccount, "testchannel", `${prefix}😀b`);
expect(result.ok).toBe(true);
expect(mockSay.mock.calls).toEqual([
["testchannel", prefix],
["testchannel", "😀b"],
]);
});
it("should generate unique message ID for each message", async () => {
const result1 = await manager.sendMessage(testAccount, "testchannel", "First message");
const result2 = await manager.sendMessage(testAccount, "testchannel", "Second message");

View File

@@ -3,7 +3,9 @@ import { RefreshingAuthProvider, StaticAuthProvider } from "@twurple/auth";
import { ChatClient, LogLevel } from "@twurple/chat";
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime";
import { chunkTextForOutbound } from "openclaw/plugin-sdk/text-chunking";
import { sliceUtf16Safe } from "openclaw/plugin-sdk/text-utility-runtime";
import { TWITCH_CHAT_MESSAGE_LIMIT } from "./constants.js";
import { resolveTwitchToken } from "./token.js";
import type { ChannelLogSink, TwitchAccountConfig, TwitchChatMessage } from "./types.js";
import { normalizeToken } from "./utils/twitch.js";
@@ -379,8 +381,10 @@ export class TwitchClientManager {
// Generate a message ID (Twurple's say() doesn't return the message ID, so we generate one)
const messageId = crypto.randomUUID();
// Send message (Twurple handles rate limiting)
await client.say(channel, message);
// Pre-chunk so Twurple's raw UTF-16 fallback cannot split surrogate pairs.
for (const chunk of chunkTextForOutbound(message, TWITCH_CHAT_MESSAGE_LIMIT)) {
await client.say(channel, chunk);
}
return { ok: true, messageId };
} catch (error) {

View File

@@ -0,0 +1,10 @@
import { describe, expect, it } from "vitest";
import { chunkTextForTwitch } from "./markdown.js";
describe("chunkTextForTwitch", () => {
it("strips markdown and keeps surrogate pairs intact at hard boundaries", () => {
const prefix = "a".repeat(499);
expect(chunkTextForTwitch(`**${prefix}😀b**`, 500)).toEqual([prefix, "😀b"]);
});
});

View File

@@ -4,6 +4,7 @@
* Twitch chat doesn't support markdown formatting, so we strip it before sending.
* Based on OpenClaw's markdownToText in src/agents/tools/web-fetch-utils.ts.
*/
import { chunkTextForOutbound } from "openclaw/plugin-sdk/text-chunking";
/**
* Strip markdown formatting from text for Twitch compatibility.
@@ -64,35 +65,5 @@ export function chunkTextForTwitch(text: string, limit: number): string[] {
if (!cleaned) {
return [];
}
if (limit <= 0) {
return [cleaned];
}
if (cleaned.length <= limit) {
return [cleaned];
}
const chunks: string[] = [];
let remaining = cleaned;
while (remaining.length > limit) {
// Find the last space before the limit
const window = remaining.slice(0, limit);
const lastSpaceIndex = window.lastIndexOf(" ");
if (lastSpaceIndex === -1) {
// No space found, hard split at limit
chunks.push(window);
remaining = remaining.slice(limit);
} else {
// Split at the last space
chunks.push(window.slice(0, lastSpaceIndex));
remaining = remaining.slice(lastSpaceIndex + 1);
}
}
if (remaining) {
chunks.push(remaining);
}
return chunks;
return chunkTextForOutbound(cleaned, limit);
}