fix(telegram): preserve word boundaries in proactive sends (#108801)

* fix(telegram): preserve proactive message word boundaries

* fix(markdown): retain whitespace across rendered chunks

* fix(telegram): preserve links in plain fallbacks

* fix(channels): preserve chunk boundary semantics

* test(sms): verify lossless chunk whitespace

---------

Co-authored-by: Peter Steinberger <steipete@gmail.com>
This commit is contained in:
DaigoSoup
2026-07-17 02:18:17 +08:00
committed by GitHub
parent c64a16196a
commit fa08ab28ca
6 changed files with 110 additions and 34 deletions

View File

@@ -49,7 +49,9 @@ describe("sendSmsTextChunks", () => {
});
expect(sendSmsViaTwilio).toHaveBeenCalledTimes(2);
expect(sendSmsViaTwilio.mock.calls.map(([call]) => call.text)).toEqual(["alpha", "beta"]);
const texts = sendSmsViaTwilio.mock.calls.map(([call]) => call.text);
expect(texts).toEqual(["alpha", " beta"]);
expect(texts.join("")).toBe("alpha beta");
});
it("labels transcript-role headers promoted to an SMS chunk boundary", async () => {

View File

@@ -244,7 +244,8 @@ describe("markdownToTelegramChunks - file reference wrapping", () => {
it("falls back to in-paren word boundaries when the parenthesis is unbalanced", () => {
const input = "**foo (bar baz qux quux**";
const chunks = markdownToTelegramChunks(input, 20);
expect(chunks.map((chunk) => chunk.text)).toEqual(["foo", "(bar baz qux ", "quux"]);
expect(chunks.map((chunk) => chunk.text)).toEqual(["foo ", "(bar baz qux ", "quux"]);
expect(chunks.map((chunk) => chunk.text).join("")).toBe("foo (bar baz qux quux");
expectHtmlChunkLengthsAtMost(chunks, 20);
});

View File

@@ -1668,6 +1668,29 @@ describe("sendMessageTelegram", () => {
expect(cursor.nextPartIndex).toBe(1);
});
it("preserves markdown link targets when Telegram rejects HTML", async () => {
botApi.sendMessage
.mockRejectedValueOnce(createHtmlParseError())
.mockResolvedValueOnce({ message_id: 157, chat: { id: "123" } });
await sendMessageTelegram("123", "Read [docs](https://example.com/guide)", {
cfg: TELEGRAM_TEST_CFG,
token: "tok",
});
expect(botApi.sendMessage).toHaveBeenNthCalledWith(
1,
"123",
'Read <a href="https://example.com/guide">docs</a>',
{ parse_mode: "HTML" },
);
expect(botApi.sendMessage).toHaveBeenNthCalledWith(
2,
"123",
"Read docs (https://example.com/guide)",
);
});
it("reports the first Telegram chunk before a later chunk fails", async () => {
const storePath = `/tmp/openclaw-telegram-projection-partial-${process.pid}-${Date.now()}.json`;
const cursor = createTelegramPromptContextProjectionCursor({
@@ -1735,6 +1758,28 @@ describe("sendMessageTelegram", () => {
expect(chunks.every((chunk) => chunk.length <= 4000)).toBe(true);
});
it("preserves word boundaries when rendered markdown exceeds the text limit", async () => {
botApi.sendMessage.mockResolvedValue({ message_id: 53, chat: { id: "123" } });
const visibleText = Array.from({ length: 260 }, () => "alpha beta gamma").join(" ");
const markdown = `**${visibleText}**`;
await sendMessageTelegram("123", markdown, {
cfg: TELEGRAM_TEST_CFG,
token: "tok",
});
const chunks = sendMessageTexts(botApi.sendMessage);
const visibleChunks = chunks.map((chunk) => telegramHtmlToPlainTextFallback(chunk));
expect(chunks.length).toBeGreaterThan(1);
expect(chunks.every((chunk) => chunk.length <= 4000)).toBe(true);
expect(visibleChunks.join("")).toBe(visibleText);
for (let index = 0; index < visibleChunks.length - 1; index += 1) {
const left = visibleChunks[index] ?? "";
const right = visibleChunks[index + 1] ?? "";
expect(`${left.at(-1) ?? ""}${right.at(0) ?? ""}`).not.toMatch(/^[A-Za-z]{2}$/);
}
});
it("chunks long markdown headings on the text path", async () => {
botApi.sendMessage.mockResolvedValue({ message_id: 54, chat: { id: "123" } });
const markdown = Array.from({ length: 600 }, (_, index) => `# Heading ${index + 1}`).join("\n");
@@ -2131,7 +2176,8 @@ describe("sendMessageTelegram", () => {
const sendMessage = vi
.fn()
.mockResolvedValueOnce({ message_id: 73, chat: { id: chatId } })
.mockResolvedValueOnce({ message_id: 74, chat: { id: chatId } });
.mockResolvedValueOnce({ message_id: 74, chat: { id: chatId } })
.mockResolvedValueOnce({ message_id: 75, chat: { id: chatId } });
const api = { sendPhoto, sendMessage } as unknown as {
sendPhoto: typeof sendPhoto;
sendMessage: typeof sendMessage;
@@ -2154,18 +2200,18 @@ describe("sendMessageTelegram", () => {
expectMediaSendCall(firstMockCall(sendPhoto, "send photo call"), "send photo call", chatId, {
caption: undefined,
});
expect(sendMessage).toHaveBeenCalledTimes(2);
expect(sendMessage).toHaveBeenCalledTimes(3);
expect(sendMessage.mock.calls.every((call) => call[2]?.parse_mode === "HTML")).toBe(true);
expect(sendMessage.mock.calls.map((call) => String(call[1] ?? "")).join("")).toContain("A");
expect(res.messageId).toBe("74");
expect(res.messageId).toBe("75");
expect(res.receipt?.primaryPlatformMessageId).toBe("73");
expect(res.receipt?.platformMessageIds).toEqual(["73", "74"]);
expect(res.receipt?.parts.map((part) => part.kind)).toEqual(["text", "text"]);
expect(res.receipt?.platformMessageIds).toEqual(["73", "74", "75"]);
expect(res.receipt?.parts.map((part) => part.kind)).toEqual(["text", "text", "text"]);
const cache = createTelegramMessageCache({
scope: resolveTelegramMessageCacheScope(storePath),
});
const projections = await Promise.all(
["72", "73", "74"].map(
["72", "73", "74", "75"].map(
async (messageId) =>
(await cache.get({ accountId: "default", chatId, messageId }))
?.promptContextProjectionMarker,
@@ -2180,9 +2226,13 @@ describe("sendMessageTelegram", () => {
kind: "valid",
projection: { ...cursor.source, partIndex: 1, finalPart: false },
},
{ kind: "valid", projection: { ...cursor.source, partIndex: 2, finalPart: true } },
{
kind: "valid",
projection: { ...cursor.source, partIndex: 2, finalPart: false },
},
{ kind: "valid", projection: { ...cursor.source, partIndex: 3, finalPart: true } },
]);
expect(cursor.nextPartIndex).toBe(3);
expect(cursor.nextPartIndex).toBe(4);
});
it("uses caption when text is within 1024 char limit", async () => {
@@ -3178,7 +3228,8 @@ describe("sendMessageTelegram", () => {
const sendMessage = vi
.fn()
.mockResolvedValueOnce({ message_id: 101, chat: { id: "-1001234567890" } })
.mockResolvedValueOnce({ message_id: 102, chat: { id: "-1001234567890" } });
.mockResolvedValueOnce({ message_id: 102, chat: { id: "-1001234567890" } })
.mockResolvedValueOnce({ message_id: 103, chat: { id: "-1001234567890" } });
const api = { sendMessage } as unknown as {
sendMessage: typeof sendMessage;
};
@@ -3193,18 +3244,12 @@ describe("sendMessageTelegram", () => {
replyToMode: "first",
});
expect(sendMessage).toHaveBeenCalledTimes(2);
expect(sendMessage.mock.calls[0]?.[2]).toEqual({
parse_mode: "HTML",
message_thread_id: 271,
});
expect(sendMessage.mock.calls[1]?.[2]).toEqual({
parse_mode: "HTML",
message_thread_id: 271,
});
expect(result.messageId).toBe("102");
expect(sendMessage).toHaveBeenCalledTimes(3);
expect(sendMessage.mock.calls.every((call) => call[2]?.parse_mode === "HTML")).toBe(true);
expect(sendMessage.mock.calls.every((call) => call[2]?.message_thread_id === 271)).toBe(true);
expect(result.messageId).toBe("103");
expect(result.receipt?.primaryPlatformMessageId).toBe("101");
expect(result.receipt?.platformMessageIds).toEqual(["101", "102"]);
expect(result.receipt?.platformMessageIds).toEqual(["101", "102", "103"]);
expect(result.receipt?.threadId).toBe("271");
expect(result.receipt?.replyToId).toBeUndefined();
expect(
@@ -3230,6 +3275,13 @@ describe("sendMessageTelegram", () => {
threadId: "271",
replyToId: undefined,
},
{
platformMessageId: "103",
kind: "text",
index: 2,
threadId: "271",
replyToId: undefined,
},
]);
});
@@ -3237,7 +3289,8 @@ describe("sendMessageTelegram", () => {
const sendMessage = vi
.fn()
.mockResolvedValueOnce({ message_id: 101, chat: { id: "-1001234567890" } })
.mockResolvedValueOnce({ message_id: 102, chat: { id: "-1001234567890" } });
.mockResolvedValueOnce({ message_id: 102, chat: { id: "-1001234567890" } })
.mockResolvedValueOnce({ message_id: 103, chat: { id: "-1001234567890" } });
const api = { sendMessage } as unknown as {
sendMessage: typeof sendMessage;
};
@@ -3251,14 +3304,13 @@ describe("sendMessageTelegram", () => {
replyToMode: "first",
});
expect(sendMessage.mock.calls[0]?.[2]).toMatchObject({
reply_to_message_id: 500,
allow_sending_without_reply: true,
});
expect(sendMessage.mock.calls[1]?.[2]).toMatchObject({
reply_to_message_id: 500,
allow_sending_without_reply: true,
});
expect(sendMessage).toHaveBeenCalledTimes(3);
for (const call of sendMessage.mock.calls) {
expect(call[2]).toMatchObject({
reply_to_message_id: 500,
allow_sending_without_reply: true,
});
}
});
it("fails topic sends instead of retrying without message_thread_id", async () => {

View File

@@ -33,6 +33,7 @@ import { splitTelegramCaption } from "./caption.js";
import { asTelegramClientFetch, createTelegramClientFetch } from "./client-fetch.js";
import { resolveTelegramTransport, type TelegramTransport } from "./fetch.js";
import {
markdownToTelegramChunks,
renderTelegramHtmlText,
splitTelegramHtmlChunks,
telegramHtmlToPlainTextFallback,
@@ -1094,8 +1095,16 @@ async function sendMessageTelegramWithContext(
};
const buildChunkedTextPlan = (rawText: string, context: string): TelegramTextChunk[] => {
if (textMode === "markdown") {
// Chunk Markdown before rendering so HTML expansion cannot introduce a
// second mid-word split. Caller-authored HTML keeps its safe splitter below.
return markdownToTelegramChunks(rawText, 4000, { tableMode }).map((chunk) => ({
htmlText: chunk.html,
plainText: telegramHtmlToPlainTextFallback(chunk.html),
}));
}
const htmlText = renderHtmlText(rawText);
const fallbackText = textMode === "html" ? telegramHtmlToPlainTextFallback(htmlText) : rawText;
const fallbackText = telegramHtmlToPlainTextFallback(htmlText);
let htmlChunks: string[];
try {
htmlChunks = splitTelegramHtmlChunks(htmlText, 4000);

View File

@@ -72,6 +72,19 @@ describe("renderMarkdownIRChunksWithinLimit", () => {
expect(chunks.map((chunk) => chunk.source.text)).toEqual(["README.md", "<"]);
});
it("preserves separator whitespace in the initial rendered-size split", () => {
const ir = markdownToIR("alpha beta gamma");
const chunks = renderMarkdownIRChunksWithinLimit({
ir,
limit: 10,
renderChunk: (chunk) => chunk.text,
measureRendered: (rendered) => rendered.length,
});
expect(chunks.map((chunk) => chunk.source.text)).toEqual(["alpha ", "beta gamma"]);
expect(chunks.map((chunk) => chunk.source.text).join("")).toBe(ir.text);
});
it("normalizes non-finite limits before chunking", () => {
const ir = markdownToIR("abc");
const chunks = renderMarkdownIRChunksWithinLimit({

View File

@@ -3,7 +3,6 @@ import { avoidTrailingHighSurrogateBreak } from "./chunk-text.js";
import { annotateAssistantTranscriptRoleMessageBoundary } from "./ir-annotations.js";
import { mergeAnnotationSpans, type MarkdownAnnotationSpan } from "./ir-spans.js";
import {
chunkMarkdownIR,
sliceMarkdownIR,
type MarkdownIR,
type MarkdownLinkSpan,
@@ -77,7 +76,7 @@ export function renderMarkdownIRChunksWithinLimit<TRendered>(
// Treat the pending worklist as a stack so each dequeue/enqueue stays O(1).
// The initial reverse keeps the final order stable while avoiding shift/unshift
// moving every remaining chunk for long messages.
const pending = chunkMarkdownIR(options.ir, normalizedLimit).toReversed();
const pending = splitMarkdownIRPreserveWhitespace(options.ir, normalizedLimit).toReversed();
const finalized: MarkdownIR[] = [];
while (pending.length > 0) {