diff --git a/CHANGELOG.md b/CHANGELOG.md index cfc40256767..d2a17fd0711 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/extensions/telegram/src/send.test.ts b/extensions/telegram/src/send.test.ts index 83c2288b5e6..7160c360dff 100644 --- a/extensions/telegram/src/send.test.ts +++ b/extensions/telegram/src/send.test.ts @@ -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(""); + 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(/^[\s\S]*<\/b>$/); + expect(String(secondCall[1] ?? "")).toMatch(/^[\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 = `${"A".repeat(5000)}`; diff --git a/extensions/telegram/src/send.ts b/extensions/telegram/src/send.ts index 61885e818a1..d3fdec0d754 100644 --- a/extensions/telegram/src/send.ts +++ b/extensions/telegram/src/send.ts @@ -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,