diff --git a/CHANGELOG.md b/CHANGELOG.md index 5d24d467d55..f85f84f2560 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -33,6 +33,7 @@ Docs: https://docs.openclaw.ai - Discord/voice: apply per-channel Discord `systemPrompt` overrides to voice transcript turns by forwarding the trusted channel prompt through the voice agent run. Fixes #47095. Thanks @qearlyao. - Discord/native commands: send component-only interaction replies from slash command and status handlers instead of treating renderable Discord components as an empty response. Thanks @vincentkoc. - Slack/slash commands: send block-only slash command replies instead of dropping Slack block payloads with no plain-text fallback. Thanks @vincentkoc. +- Telegram/messages: derive fallback text from interactive button/select labels before sending button-only payloads, so Telegram replies are not rejected as empty messages. Thanks @vincentkoc. - Gateway/agent: reject strict `openclaw agent --deliver` requests with missing delivery targets before starting the agent run, so users do not wait for a completed turn that cannot send anywhere. Thanks @vincentkoc. - Setup/import: honor non-interactive `--import-from` onboarding flags by running the migration import path instead of silently completing normal setup without importing anything. Thanks @vincentkoc. - Discord/voice: run voice-channel turns under a voice-output policy that hides the agent `tts` tool and asks for spoken reply text, so `/vc join` sessions synthesize and play agent replies instead of ending with `NO_REPLY`. Fixes #61536. Thanks @aounakram. diff --git a/extensions/telegram/src/action-runtime.test.ts b/extensions/telegram/src/action-runtime.test.ts index ec2dbef091c..0fe92d52034 100644 --- a/extensions/telegram/src/action-runtime.test.ts +++ b/extensions/telegram/src/action-runtime.test.ts @@ -852,6 +852,26 @@ describe("handleTelegramAction", () => { expect(sendMessageTelegram).toHaveBeenCalled(); }); + it("uses interactive button labels as fallback text when message text is omitted", async () => { + await handleTelegramAction( + { + action: "sendMessage", + to: "@testchannel", + interactive: { + blocks: [{ type: "buttons", buttons: [{ label: "Retry", value: "cmd:retry" }] }], + }, + }, + telegramConfig({ capabilities: { inlineButtons: "all" } }), + ); + expect(sendMessageTelegram).toHaveBeenCalledWith( + "@testchannel", + "- Retry", + expect.objectContaining({ + buttons: [[{ text: "Retry", callback_data: "cmd:retry" }]], + }), + ); + }); + it.each([ { name: "scope is off", diff --git a/extensions/telegram/src/action-runtime.ts b/extensions/telegram/src/action-runtime.ts index defdf45ab30..f417304385a 100644 --- a/extensions/telegram/src/action-runtime.ts +++ b/extensions/telegram/src/action-runtime.ts @@ -23,6 +23,7 @@ import { resolveTelegramInlineButtonsScope, resolveTelegramTargetChatType, } from "./inline-buttons.js"; +import { resolveTelegramInteractiveTextFallback } from "./interactive-fallback.js"; import { resolveTelegramPollVisibility } from "./poll-visibility.js"; import { resolveTelegramReactionLevel } from "./reaction-level.js"; import { @@ -134,6 +135,7 @@ function readTelegramSendContent(params: { args: Record; mediaUrl?: string; hasButtons: boolean; + interactive?: unknown; presentation?: MessagePresentation; }) { const explicitContent = @@ -144,7 +146,20 @@ function readTelegramSendContent(params: { explicitContent == null && params.presentation ? renderMessagePresentationFallbackText({ presentation: params.presentation }) : undefined; - const content = explicitContent ?? (presentationText?.trim() ? presentationText : undefined); + const interactiveText = + explicitContent == null && !params.presentation + ? resolveTelegramInteractiveTextFallback({ interactive: params.interactive }) + : undefined; + let content = + explicitContent ?? + (presentationText?.trim() ? presentationText : undefined) ?? + (interactiveText?.trim() ? interactiveText : undefined); + if ((content == null || content.trim().length === 0) && !params.mediaUrl && params.hasButtons) { + const fallback = presentationText?.trim() ? presentationText : interactiveText; + if (fallback?.trim()) { + content = fallback; + } + } if (content == null && !params.mediaUrl && !params.hasButtons) { throw new Error("content required."); } @@ -321,6 +336,7 @@ export async function handleTelegramAction( args: params, mediaUrl: mediaUrl ?? undefined, hasButtons: Array.isArray(buttons) && buttons.length > 0, + interactive: params.interactive, presentation, }); if (buttons) { diff --git a/extensions/telegram/src/interactive-fallback.ts b/extensions/telegram/src/interactive-fallback.ts new file mode 100644 index 00000000000..f8cb2e1b672 --- /dev/null +++ b/extensions/telegram/src/interactive-fallback.ts @@ -0,0 +1,29 @@ +import { + interactiveReplyToPresentation, + normalizeInteractiveReply, + renderMessagePresentationFallbackText, + resolveInteractiveTextFallback, +} from "openclaw/plugin-sdk/interactive-runtime"; + +export function resolveTelegramInteractiveTextFallback(params: { + text?: string | null; + interactive?: unknown; +}): string | undefined { + const interactive = normalizeInteractiveReply(params.interactive); + const text = resolveInteractiveTextFallback({ + text: params.text ?? undefined, + interactive, + }); + if (text?.trim()) { + return text; + } + if (!interactive) { + return text; + } + const presentation = interactiveReplyToPresentation(interactive); + if (!presentation) { + return text; + } + const fallback = renderMessagePresentationFallbackText({ presentation }); + return fallback.trim() ? fallback : text; +} diff --git a/extensions/telegram/src/outbound-adapter.test.ts b/extensions/telegram/src/outbound-adapter.test.ts index c9649000349..d66cce66387 100644 --- a/extensions/telegram/src/outbound-adapter.test.ts +++ b/extensions/telegram/src/outbound-adapter.test.ts @@ -98,6 +98,31 @@ describe("telegramOutbound", () => { expect(result).toEqual({ channel: "telegram", messageId: "tg-2", chatId: "12345" }); }); + it("uses interactive button labels as fallback text for button-only payloads", async () => { + sendMessageTelegramMock.mockResolvedValueOnce({ messageId: "tg-buttons", chatId: "12345" }); + + const result = await telegramOutbound.sendPayload!({ + cfg: {} as never, + to: "12345", + text: "", + payload: { + interactive: { + blocks: [{ type: "buttons", buttons: [{ label: "Retry", value: "cmd:retry" }] }], + }, + }, + deps: { sendTelegram: sendMessageTelegramMock }, + }); + + expect(sendMessageTelegramMock).toHaveBeenCalledWith( + "12345", + "- Retry", + expect.objectContaining({ + buttons: [[{ text: "Retry", callback_data: "cmd:retry" }]], + }), + ); + expect(result).toEqual({ channel: "telegram", messageId: "tg-buttons", chatId: "12345" }); + }); + it("forwards audioAsVoice payload media to Telegram voice sends", async () => { sendMessageTelegramMock.mockResolvedValueOnce({ messageId: "tg-voice", chatId: "12345" }); diff --git a/extensions/telegram/src/outbound-adapter.ts b/extensions/telegram/src/outbound-adapter.ts index 167ee0488b0..6c4bc2c4d10 100644 --- a/extensions/telegram/src/outbound-adapter.ts +++ b/extensions/telegram/src/outbound-adapter.ts @@ -6,7 +6,6 @@ import { import { presentationToInteractiveReply, renderMessagePresentationFallbackText, - resolveInteractiveTextFallback, } from "openclaw/plugin-sdk/interactive-runtime"; import { sanitizeForPlainText } from "openclaw/plugin-sdk/outbound-runtime"; import { @@ -21,6 +20,7 @@ import type { ReplyPayload } from "openclaw/plugin-sdk/reply-runtime"; import type { TelegramInlineButtons } from "./button-types.js"; import { resolveTelegramInlineButtons } from "./button-types.js"; import { markdownToTelegramHtmlChunks } from "./format.js"; +import { resolveTelegramInteractiveTextFallback } from "./interactive-fallback.js"; import { parseTelegramReplyToMessageId, parseTelegramThreadId } from "./outbound-params.js"; import { pinMessageTelegram } from "./send.js"; @@ -84,7 +84,7 @@ export async function sendTelegramPayloadMessages(params: { const quoteText = typeof telegramData?.quoteText === "string" ? telegramData.quoteText : undefined; const text = - resolveInteractiveTextFallback({ + resolveTelegramInteractiveTextFallback({ text: params.payload.text, interactive: params.payload.interactive, }) ?? "";