fix: chunk telegram markdown sends

This commit is contained in:
Peter Steinberger
2026-05-02 04:47:51 +01:00
parent 332df49d2c
commit 2f828dbde9
3 changed files with 80 additions and 18 deletions

View File

@@ -23,6 +23,7 @@ Docs: https://docs.openclaw.ai
- Config: log the "newer OpenClaw" version warning once per process instead of once per config snapshot read. (#75927) Thanks @romneyda.
- Telegram/message actions: treat benign delete-message 400s as no-op warnings instead of runtime errors, so stale or already-removed messages do not create noisy delete failures. Fixes #73726. Thanks @Avicennasis.
- Telegram: split long default markdown sends and media follow-up text into safe HTML chunks, so outbound messages over Telegram's limit no longer fail as one oversized Bot API request. Fixes #75868. Thanks @zhengsx.
- Gateway/chat history: merge Claude CLI transcript imports for Anthropic-routed sessions that still have a Claude CLI binding, so local chat history does not hide CLI JSONL turns. Fixes #75850. Thanks @alfredjbclaw.
- Media: trim serialized JSON suffixes after local `MEDIA:` directive file extensions, so generated-image metadata cannot pollute the parsed media path and cause false `ENOENT` delivery failures. Fixes #75182. Thanks @TnzGit and @hclsys.
- Cron: make scheduler reload schedule comparison tolerate malformed persisted jobs, so one bad cron entry no longer aborts the whole tick. Fixes #75886. Thanks @samfox-ai.

View File

@@ -843,6 +843,46 @@ describe("sendMessageTelegram", () => {
expect(res.messageId).toBe("71");
});
it("chunks long default markdown media follow-up text", async () => {
const chatId = "123";
const longText = `**${"A".repeat(5000)}**`;
const sendPhoto = vi.fn().mockResolvedValue({
message_id: 72,
chat: { id: chatId },
});
const sendMessage = vi
.fn()
.mockResolvedValueOnce({ message_id: 73, chat: { id: chatId } })
.mockResolvedValueOnce({ message_id: 74, chat: { id: chatId } });
const api = { sendPhoto, sendMessage } as unknown as {
sendPhoto: typeof sendPhoto;
sendMessage: typeof sendMessage;
};
mockLoadedMedia({
buffer: Buffer.from("fake-image"),
contentType: "image/jpeg",
fileName: "photo.jpg",
});
const res = await sendMessageTelegram(chatId, longText, {
cfg: TELEGRAM_TEST_CFG,
token: "tok",
api,
mediaUrl: "https://example.com/photo.jpg",
});
expect(sendPhoto).toHaveBeenCalledWith(chatId, expect.anything(), {
caption: undefined,
});
expect(sendMessage).toHaveBeenCalledTimes(2);
expect(sendMessage.mock.calls.every((call) => call[2]?.parse_mode === "HTML")).toBe(true);
expect(sendMessage.mock.calls.every((call) => String(call[1] ?? "").length <= 4000)).toBe(true);
expect(sendMessage.mock.calls.map((call) => String(call[1] ?? "")).join("")).toContain("<b>");
expect(res.messageId).toBe("74");
});
it("uses caption when text is within 1024 char limit", async () => {
const chatId = "123";
const shortText = "B".repeat(1024);
@@ -1898,6 +1938,41 @@ describe("sendMessageTelegram", () => {
expect(res.messageId).toBe("91");
});
it("chunks long default markdown text and keeps buttons on the last chunk only", async () => {
const chatId = "123";
const markdownText = `**${"A".repeat(5000)}**`;
const sendMessage = vi
.fn()
.mockResolvedValueOnce({ message_id: 90, chat: { id: chatId } })
.mockResolvedValueOnce({ message_id: 91, chat: { id: chatId } });
const api = { sendMessage } as unknown as { sendMessage: typeof sendMessage };
const res = await sendMessageTelegram(chatId, markdownText, {
cfg: TELEGRAM_TEST_CFG,
token: "tok",
api,
buttons: [[{ text: "OK", callback_data: "ok" }]],
});
expect(sendMessage).toHaveBeenCalledTimes(2);
const firstCall = sendMessage.mock.calls[0];
const secondCall = sendMessage.mock.calls[1];
expect(firstCall).toBeDefined();
expect(secondCall).toBeDefined();
expect(String(firstCall[1] ?? "").length).toBeLessThanOrEqual(4000);
expect(String(secondCall[1] ?? "").length).toBeLessThanOrEqual(4000);
expect(firstCall[2]?.parse_mode).toBe("HTML");
expect(secondCall[2]?.parse_mode).toBe("HTML");
expect(String(firstCall[1] ?? "")).toMatch(/^<b>[\s\S]*<\/b>$/);
expect(String(secondCall[1] ?? "")).toMatch(/^<b>[\s\S]*<\/b>$/);
expect(firstCall[2]?.reply_markup).toBeUndefined();
expect(secondCall[2]?.reply_markup).toEqual({
inline_keyboard: [[{ text: "OK", callback_data: "ok" }]],
});
expect(res.messageId).toBe("91");
});
it("preserves caller plain-text fallback across chunked html parse retries", async () => {
const chatId = "123";
const htmlText = `<b>${"A".repeat(5000)}</b>`;

View File

@@ -720,10 +720,11 @@ export async function sendMessageTelegram(
};
const buildChunkedTextPlan = (rawText: string, context: string): TelegramTextChunk[] => {
const htmlText = renderHtmlText(rawText);
const fallbackText = opts.plainText ?? rawText;
let htmlChunks: string[];
try {
htmlChunks = splitTelegramHtmlChunks(rawText, 4000);
htmlChunks = splitTelegramHtmlChunks(htmlText, 4000);
} catch (error) {
logVerbose(
`telegram ${context} failed HTML chunk planning, retrying as plain text: ${formatErrorMessage(
@@ -951,14 +952,7 @@ export async function sendMessageTelegram(
// If text was too long for a caption, send it as a separate follow-up message.
// Use HTML conversion so markdown renders like captions.
if (needsSeparateText && followUpText) {
if (textMode === "html") {
const textResult = await sendChunkedText(followUpText, "text follow-up send");
return { messageId: textResult.messageId, chatId: resolvedChatId };
}
const textResult = await sendTelegramTextChunks(
[{ plainText: followUpText, htmlText: renderHtmlText(followUpText) }],
"text follow-up send",
);
const textResult = await sendChunkedText(followUpText, "text follow-up send");
return { messageId: textResult.messageId, chatId: resolvedChatId };
}
@@ -968,15 +962,7 @@ export async function sendMessageTelegram(
if (!text || !text.trim()) {
throw new Error("Message must be non-empty for Telegram sends");
}
let textResult: { messageId: string; chatId: string };
if (textMode === "html") {
textResult = await sendChunkedText(text, "text send");
} else {
textResult = await sendTelegramTextChunks(
[{ plainText: opts.plainText ?? text, htmlText: renderHtmlText(text) }],
"text send",
);
}
const textResult = await sendChunkedText(text, "text send");
recordChannelActivity({
channel: "telegram",
accountId: account.accountId,