From 51f4a5e8a040a6774a09765b3ad9ed490ba5456b Mon Sep 17 00:00:00 2001 From: Josh Avant <830519+joshavant@users.noreply.github.com> Date: Sat, 16 May 2026 00:16:51 -0500 Subject: [PATCH] Fix Telegram presentation-only payload sends (#82449) * fix telegram presentation payload fallback * changelog telegram presentation payload fallback * fix telegram presentation reply delivery --- CHANGELOG.md | 1 + .../telegram/src/bot/delivery.replies.ts | 13 +++++++-- extensions/telegram/src/bot/delivery.test.ts | 27 +++++++++++++++++++ .../telegram/src/interactive-fallback.ts | 18 ++++++++++--- .../telegram/src/outbound-adapter.test.ts | 27 +++++++++++++++++++ extensions/telegram/src/outbound-adapter.ts | 10 +++++-- 6 files changed, 89 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3dc17e43a7e6..a0fd5885f158 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -53,6 +53,7 @@ Docs: https://docs.openclaw.ai - Providers/embeddings: reject malformed successful OpenAI-compatible, Google Gemini, and Amazon Bedrock embedding responses instead of silently returning empty or coerced vectors. - Providers/catalogs: reject malformed successful LM Studio, GitHub Copilot, DeepInfra, Vercel AI Gateway, and Kilocode model-list responses with provider-owned errors instead of raw parser/type failures or silent fallback catalogs. - Providers/polling: reject array, null, or scalar successful operation status responses with provider-owned malformed JSON errors instead of waiting until timeout. +- Telegram: send presentation-only payloads by rendering fallback text and inline buttons instead of treating them as empty. Fixes #82404. (#82449) Thanks @joshavant. - Trajectory export: skip and report malformed session/runtime JSONL rows in `manifest.json` instead of letting wrong-shaped session rows crash support bundle export. - Voice calls: persist rejected inbound-call replay keys so duplicate carrier webhook retries stay ignored after a Gateway restart. - Config/doctor: copy fallback-enabled channel `allowFrom` entries into explicit `groupAllowFrom` allowlists during `openclaw doctor --fix`, preserving current group access without adding runtime fallback-transition flags. diff --git a/extensions/telegram/src/bot/delivery.replies.ts b/extensions/telegram/src/bot/delivery.replies.ts index 686f6359a3aa..79522729bb41 100644 --- a/extensions/telegram/src/bot/delivery.replies.ts +++ b/extensions/telegram/src/bot/delivery.replies.ts @@ -10,6 +10,10 @@ import { toPluginMessageSentEvent, } from "openclaw/plugin-sdk/hook-runtime"; import type { ReplyPayloadDelivery } from "openclaw/plugin-sdk/interactive-runtime"; +import { + normalizeMessagePresentation, + presentationToInteractiveReply, +} from "openclaw/plugin-sdk/interactive-runtime"; import { buildOutboundMediaLoadOptions, isGifMedia, @@ -755,10 +759,15 @@ export async function deliverReplies(params: { ? [reply.mediaUrl] : []; const hasMedia = mediaList.length > 0; + const presentation = normalizeMessagePresentation(reply?.presentation); + const interactive = + reply?.interactive ?? + (presentation ? presentationToInteractiveReply(presentation) : undefined); const resolvedReplyText = resolveTelegramInteractiveTextFallback({ text: reply?.text, - interactive: reply?.interactive, + interactive, + presentation, }) ?? reply?.text ?? ""; @@ -820,7 +829,7 @@ export async function deliverReplies(params: { const replyMarkup = buildInlineKeyboard( resolveTelegramInlineButtons({ buttons: telegramData?.buttons, - interactive: reply.interactive, + interactive, }), ); let firstDeliveredMessageId: number | undefined; diff --git a/extensions/telegram/src/bot/delivery.test.ts b/extensions/telegram/src/bot/delivery.test.ts index 6a4f6d0cbc08..75e5110ee65d 100644 --- a/extensions/telegram/src/bot/delivery.test.ts +++ b/extensions/telegram/src/bot/delivery.test.ts @@ -328,6 +328,33 @@ describe("deliverReplies", () => { }); }); + it("uses presentation button labels as fallback text for presentation-only replies", async () => { + const runtime = createRuntime(false); + const sendMessage = vi.fn().mockResolvedValue({ message_id: 4, chat: { id: "123" } }); + const bot = createBot({ sendMessage }); + + await deliverWith({ + replies: [ + { + presentation: { + blocks: [{ type: "buttons", buttons: [{ label: "Retry", value: "cmd:retry" }] }], + }, + }, + ], + runtime, + bot, + }); + + expect(runtime.error).not.toHaveBeenCalled(); + expect(firstMockCallArg(sendMessage, 0)).toBe("123"); + expect(firstMockCallArg(sendMessage, 1)).toContain("Retry"); + expectRecordFields(mockCallArg(sendMessage, 0, 2), { + reply_markup: { + inline_keyboard: [[{ text: "Retry", callback_data: "cmd:retry" }]], + }, + }); + }); + it("reports message_sent success=false when hooks blank out a text-only reply", async () => { messageHookRunner.hasHooks.mockImplementation( (name: string) => name === "message_sending" || name === "message_sent", diff --git a/extensions/telegram/src/interactive-fallback.ts b/extensions/telegram/src/interactive-fallback.ts index f8cb2e1b672a..fb8dbd5ac64f 100644 --- a/extensions/telegram/src/interactive-fallback.ts +++ b/extensions/telegram/src/interactive-fallback.ts @@ -1,5 +1,6 @@ import { interactiveReplyToPresentation, + normalizeMessagePresentation, normalizeInteractiveReply, renderMessagePresentationFallbackText, resolveInteractiveTextFallback, @@ -8,6 +9,7 @@ import { export function resolveTelegramInteractiveTextFallback(params: { text?: string | null; interactive?: unknown; + presentation?: unknown; }): string | undefined { const interactive = normalizeInteractiveReply(params.interactive); const text = resolveInteractiveTextFallback({ @@ -17,13 +19,23 @@ export function resolveTelegramInteractiveTextFallback(params: { if (text?.trim()) { return text; } + const presentation = normalizeMessagePresentation(params.presentation); + if (presentation) { + const fallback = renderMessagePresentationFallbackText({ + text: params.text ?? undefined, + presentation, + }); + if (fallback.trim()) { + return fallback; + } + } if (!interactive) { return text; } - const presentation = interactiveReplyToPresentation(interactive); - if (!presentation) { + const interactivePresentation = interactiveReplyToPresentation(interactive); + if (!interactivePresentation) { return text; } - const fallback = renderMessagePresentationFallbackText({ presentation }); + const fallback = renderMessagePresentationFallbackText({ presentation: interactivePresentation }); return fallback.trim() ? fallback : text; } diff --git a/extensions/telegram/src/outbound-adapter.test.ts b/extensions/telegram/src/outbound-adapter.test.ts index 800a76cfa7da..6cb200c82e2c 100644 --- a/extensions/telegram/src/outbound-adapter.test.ts +++ b/extensions/telegram/src/outbound-adapter.test.ts @@ -150,6 +150,33 @@ describe("telegramOutbound", () => { expect(result).toEqual({ channel: "telegram", messageId: "tg-buttons", chatId: "12345" }); }); + it("uses presentation button labels as fallback text for presentation-only payloads", async () => { + sendMessageTelegramMock.mockResolvedValueOnce({ + messageId: "tg-presentation-buttons", + chatId: "12345", + }); + + const result = await telegramOutbound.sendPayload!({ + cfg: {} as never, + to: "12345", + text: "", + payload: { + presentation: { + blocks: [{ type: "buttons", buttons: [{ label: "Retry", value: "cmd:retry" }] }], + }, + }, + deps: { sendTelegram: sendMessageTelegramMock }, + }); + + const options = callOptionsAt(sendMessageTelegramMock, 0, "12345", "- Retry"); + expect(options.buttons).toEqual([[{ text: "Retry", callback_data: "cmd:retry" }]]); + expect(result).toEqual({ + channel: "telegram", + messageId: "tg-presentation-buttons", + chatId: "12345", + }); + }); + it("renders presentation web app buttons for payload sends", async () => { sendMessageTelegramMock.mockResolvedValueOnce({ messageId: "tg-web-app", chatId: "12345" }); const presentation = { diff --git a/extensions/telegram/src/outbound-adapter.ts b/extensions/telegram/src/outbound-adapter.ts index e56a38bddd87..c89237be0146 100644 --- a/extensions/telegram/src/outbound-adapter.ts +++ b/extensions/telegram/src/outbound-adapter.ts @@ -4,6 +4,7 @@ import { createAttachedChannelResultAdapter, } from "openclaw/plugin-sdk/channel-send-result"; import { + normalizeMessagePresentation, presentationToInteractiveReply, renderMessagePresentationFallbackText, } from "openclaw/plugin-sdk/interactive-runtime"; @@ -116,15 +117,20 @@ export async function sendTelegramPayloadMessages(params: { | undefined; const quoteText = typeof telegramData?.quoteText === "string" ? telegramData.quoteText : undefined; + const presentation = normalizeMessagePresentation(params.payload.presentation); + const interactive = + params.payload.interactive ?? + (presentation ? presentationToInteractiveReply(presentation) : undefined); const text = resolveTelegramInteractiveTextFallback({ text: params.payload.text, - interactive: params.payload.interactive, + interactive, + presentation, }) ?? ""; const mediaUrls = resolvePayloadMediaUrls(params.payload); const buttons = resolveTelegramInlineButtons({ buttons: telegramData?.buttons, - interactive: params.payload.interactive, + interactive, }); const payloadOpts = { ...params.baseOpts,