diff --git a/extensions/twitch/src/constants.ts b/extensions/twitch/src/constants.ts new file mode 100644 index 000000000000..f309000327bd --- /dev/null +++ b/extensions/twitch/src/constants.ts @@ -0,0 +1 @@ +export const TWITCH_CHAT_MESSAGE_LIMIT = 500; diff --git a/extensions/twitch/src/monitor.test.ts b/extensions/twitch/src/monitor.test.ts new file mode 100644 index 000000000000..5a4580f6daa7 --- /dev/null +++ b/extensions/twitch/src/monitor.test.ts @@ -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", + ); + }); +}); diff --git a/extensions/twitch/src/monitor.ts b/extensions/twitch/src/monitor.ts index 3d634d713bcf..9f12314d3bb0 100644 --- a/extensions/twitch/src/monitor.ts +++ b/extensions/twitch/src/monitor.ts @@ -190,25 +190,24 @@ async function deliverTwitchReply(params: { debug: (msg) => runtime.log?.(msg), }); - const client = await clientManager.getClient( - account, - config as Parameters[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[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 }; diff --git a/extensions/twitch/src/outbound.ts b/extensions/twitch/src/outbound.ts index c82258cbb328..47222ae71b14 100644 --- a/extensions/twitch/src/outbound.ts +++ b/extensions/twitch/src/outbound.ts @@ -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), diff --git a/extensions/twitch/src/twitch-client.test.ts b/extensions/twitch/src/twitch-client.test.ts index be00ef15f5cf..41c40b2f8c8a 100644 --- a/extensions/twitch/src/twitch-client.test.ts +++ b/extensions/twitch/src/twitch-client.test.ts @@ -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"); diff --git a/extensions/twitch/src/twitch-client.ts b/extensions/twitch/src/twitch-client.ts index 3055c7ab597d..6b2840cc93da 100644 --- a/extensions/twitch/src/twitch-client.ts +++ b/extensions/twitch/src/twitch-client.ts @@ -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) { diff --git a/extensions/twitch/src/utils/markdown.test.ts b/extensions/twitch/src/utils/markdown.test.ts new file mode 100644 index 000000000000..42990cda4100 --- /dev/null +++ b/extensions/twitch/src/utils/markdown.test.ts @@ -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"]); + }); +}); diff --git a/extensions/twitch/src/utils/markdown.ts b/extensions/twitch/src/utils/markdown.ts index 2f3c591c7036..baed235d2830 100644 --- a/extensions/twitch/src/utils/markdown.ts +++ b/extensions/twitch/src/utils/markdown.ts @@ -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); }