mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-15 21:06:07 +00:00
fix(twitch): preserve UTF-16 pairs in outbound chunks (#104030)
This commit is contained in:
committed by
GitHub
parent
a1571f2118
commit
790cd2aff2
1
extensions/twitch/src/constants.ts
Normal file
1
extensions/twitch/src/constants.ts
Normal file
@@ -0,0 +1 @@
|
||||
export const TWITCH_CHAT_MESSAGE_LIMIT = 500;
|
||||
42
extensions/twitch/src/monitor.test.ts
Normal file
42
extensions/twitch/src/monitor.test.ts
Normal 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",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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 };
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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) {
|
||||
|
||||
10
extensions/twitch/src/utils/markdown.test.ts
Normal file
10
extensions/twitch/src/utils/markdown.test.ts
Normal 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"]);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user