Fix Telegram presentation-only payload sends (#82449)

* fix telegram presentation payload fallback

* changelog telegram presentation payload fallback

* fix telegram presentation reply delivery
This commit is contained in:
Josh Avant
2026-05-16 00:16:51 -05:00
committed by GitHub
parent 92492ecdd0
commit 51f4a5e8a0
6 changed files with 89 additions and 7 deletions
+1
View File
@@ -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.
@@ -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;
@@ -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",
@@ -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;
}
@@ -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 = {
+8 -2
View File
@@ -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,