diff --git a/extensions/telegram/src/bot-message-dispatch-delivery.ts b/extensions/telegram/src/bot-message-dispatch-delivery.ts index fbba6854717d..c6995e04a4ce 100644 --- a/extensions/telegram/src/bot-message-dispatch-delivery.ts +++ b/extensions/telegram/src/bot-message-dispatch-delivery.ts @@ -52,6 +52,7 @@ import { type TelegramPromptContextSource, } from "./prompt-context-projection.js"; import { editMessageTelegram } from "./send.js"; +import { resolveTelegramTargetChatType } from "./targets.js"; export function createTelegramDeliveryController(params: { bot: Bot; @@ -560,7 +561,11 @@ export function createTelegramDeliveryController(params: { delete payloadForPlan.isReasoning; } const normalized = projectPayloadForDelivery(payloadForPlan); - return normalized ? canonicalizeTelegramPresentationPayload(normalized) : undefined; + return normalized + ? canonicalizeTelegramPresentationPayload(normalized, { + allowWebAppButtons: resolveTelegramTargetChatType(String(context.chatId)) === "direct", + }) + : undefined; }, sendPayload, snapshot: deliveryState.snapshot, diff --git a/extensions/telegram/src/bot/delivery.replies.ts b/extensions/telegram/src/bot/delivery.replies.ts index ffffb428d2a4..ec7de0cf38ed 100644 --- a/extensions/telegram/src/bot/delivery.replies.ts +++ b/extensions/telegram/src/bot/delivery.replies.ts @@ -54,6 +54,7 @@ import { } from "../rich-message.js"; import { isTelegramHtmlParseError } from "../rich-plain-fallback.js"; import { buildInlineKeyboard, reactMessageTelegram } from "../send.js"; +import { resolveTelegramTargetChatType } from "../targets.js"; import { resolveTelegramVoiceSend } from "../voice.js"; import { buildTelegramSendParams, @@ -844,7 +845,9 @@ export async function deliverReplies(params: { }), ); for (const originalReply of normalizedReplies) { - let reply = canonicalizeTelegramPresentationPayload(originalReply); + let reply = canonicalizeTelegramPresentationPayload(originalReply, { + allowWebAppButtons: resolveTelegramTargetChatType(params.chatId) === "direct", + }); const mediaList = reply?.mediaUrls?.length ? reply.mediaUrls : reply?.mediaUrl diff --git a/extensions/telegram/src/button-types.test-helpers.ts b/extensions/telegram/src/button-types.test-helpers.ts index 35c25b1a57de..48be060ddbd8 100644 --- a/extensions/telegram/src/button-types.test-helpers.ts +++ b/extensions/telegram/src/button-types.test-helpers.ts @@ -34,7 +34,6 @@ export function describeTelegramInteractiveButtonBehavior(): void { { text: "Reject", callback_data: "reject", style: "danger" }, ], [ - { text: "Launch", web_app: { url: "https://example.com/app" }, style: undefined }, { text: "Later", callback_data: "later", style: undefined }, { text: "Archive", callback_data: "archive", style: undefined }, ], diff --git a/extensions/telegram/src/button-types.test.ts b/extensions/telegram/src/button-types.test.ts index ab1f411eee0b..998129d04d73 100644 --- a/extensions/telegram/src/button-types.test.ts +++ b/extensions/telegram/src/button-types.test.ts @@ -408,22 +408,25 @@ describe("buildTelegramPresentationButtons", () => { it("renders typed and legacy URL and Web App actions natively", () => { expect( - buildTelegramPresentationButtons({ - blocks: [ - { - type: "buttons", - buttons: [ - { label: "Typed URL", action: { type: "url", url: "https://example.com/typed" } }, - { - label: "Typed App", - action: { type: "web-app", url: "https://example.com/app" }, - }, - { label: "Legacy URL", url: "https://example.com/legacy" }, - { label: "Legacy App", webApp: { url: "https://example.com/legacy-app" } }, - ], - }, - ], - }), + buildTelegramPresentationButtons( + { + blocks: [ + { + type: "buttons", + buttons: [ + { label: "Typed URL", action: { type: "url", url: "https://example.com/typed" } }, + { + label: "Typed App", + action: { type: "web-app", url: "https://example.com/app" }, + }, + { label: "Legacy URL", url: "https://example.com/legacy" }, + { label: "Legacy App", webApp: { url: "https://example.com/legacy-app" } }, + ], + }, + ], + }, + { allowWebAppButtons: true }, + ), ).toEqual([ [ { text: "Typed URL", url: "https://example.com/typed", style: undefined }, @@ -444,6 +447,21 @@ describe("buildTelegramPresentationButtons", () => { ]); }); + it("skips Web App actions unless a direct target was confirmed", () => { + expect( + buildTelegramPresentationButtons({ + blocks: [ + { + type: "buttons", + buttons: [ + { label: "App", action: { type: "web-app", url: "https://example.com/app" } }, + ], + }, + ], + }), + ).toBeUndefined(); + }); + it("skips hosted widget actions without a Telegram web app URL", () => { expect( buildTelegramPresentationButtons({ diff --git a/extensions/telegram/src/button-types.ts b/extensions/telegram/src/button-types.ts index e5b7aefd95f4..2e905fba00a3 100644 --- a/extensions/telegram/src/button-types.ts +++ b/extensions/telegram/src/button-types.ts @@ -48,6 +48,7 @@ function toTelegramButtonStyle( function toTelegramInlineButton( button: MessagePresentationButton, optionIndex: number, + options?: { allowWebAppButtons?: boolean }, ): TelegramInlineButton | undefined { const style = toTelegramButtonStyle(button.style); const action = resolveMessagePresentationButtonAction(button); @@ -58,7 +59,9 @@ function toTelegramInlineButton( return { text: button.label, url: action.url, style }; } if (action.type === "web-app") { - return action.url ? { text: button.label, web_app: { url: action.url }, style } : undefined; + return options?.allowWebAppButtons === true && action.url + ? { text: button.label, web_app: { url: action.url }, style } + : undefined; } if (action.type === "approval") { const callbackData = buildTelegramApprovalCallbackData(action); @@ -99,12 +102,13 @@ function toTelegramInlineButton( function chunkInteractiveButtons( buttons: readonly MessagePresentationButton[], rows: TelegramInlineButton[][], + options?: { allowWebAppButtons?: boolean }, ) { // Index is position in the question's options; core emits one buttons block in option order. for (let i = 0; i < buttons.length; i += TELEGRAM_INTERACTIVE_ROW_SIZE) { const row = buttons .slice(i, i + TELEGRAM_INTERACTIVE_ROW_SIZE) - .map((button, offset) => toTelegramInlineButton(button, i + offset)) + .map((button, offset) => toTelegramInlineButton(button, i + offset, options)) .filter((button): button is TelegramInlineButton => Boolean(button)); if (row.length > 0) { rows.push(row); @@ -145,6 +149,7 @@ function buildTelegramInteractiveButtons( /** Convert portable presentation controls to Telegram inline keyboard rows. */ export function buildTelegramPresentationButtons( presentation?: MessagePresentation, + options?: { allowWebAppButtons?: boolean }, ): TelegramInlineButtons | undefined { const rows: TelegramInlineButton[][] = []; for (const block of presentation?.blocks ?? []) { @@ -152,7 +157,7 @@ export function buildTelegramPresentationButtons( continue; } if (block.type === "buttons") { - chunkInteractiveButtons(block.buttons, rows); + chunkInteractiveButtons(block.buttons, rows, options); continue; } chunkInteractiveButtons( diff --git a/extensions/telegram/src/interactive-fallback.test.ts b/extensions/telegram/src/interactive-fallback.test.ts index 5860fd446a42..0f0ee0e10084 100644 --- a/extensions/telegram/src/interactive-fallback.test.ts +++ b/extensions/telegram/src/interactive-fallback.test.ts @@ -125,6 +125,41 @@ describe("canonicalizeTelegramPresentationPayload", () => { }); }); + it("uses native web_app only for a confirmed direct target", () => { + const payload = { + text: "Open app:", + presentation: { + blocks: [ + { + type: "buttons" as const, + buttons: [ + { + label: "Launch", + action: { type: "web-app" as const, url: "https://example.com/app" }, + }, + ], + }, + ], + }, + }; + + expect( + canonicalizeTelegramPresentationPayload(payload, { allowWebAppButtons: true }), + ).toMatchObject({ + text: "Open app:", + channelData: { + telegram: { + buttons: [[{ text: "Launch", web_app: { url: "https://example.com/app" } }]], + }, + }, + }); + expect(canonicalizeTelegramPresentationPayload(payload, { allowWebAppButtons: false })).toEqual( + { + text: "Open app:\n\n- Launch: https://example.com/app", + }, + ); + }); + it("falls back presentation controls when explicit Telegram buttons take precedence", () => { const nativeButtons = [[{ text: "Native", callback_data: "native" }]]; const result = canonicalizeTelegramPresentationPayload({ diff --git a/extensions/telegram/src/interactive-fallback.ts b/extensions/telegram/src/interactive-fallback.ts index 370a0ee92ad5..eb1281637f41 100644 --- a/extensions/telegram/src/interactive-fallback.ts +++ b/extensions/telegram/src/interactive-fallback.ts @@ -40,13 +40,17 @@ export const TELEGRAM_PRESENTATION_CAPABILITIES = { }, }; -function canEncodeTelegramPresentationControl(block: MessagePresentationInteractiveBlock): boolean { - return Boolean(buildTelegramPresentationButtons({ blocks: [block] })?.length); +function canEncodeTelegramPresentationControl( + block: MessagePresentationInteractiveBlock, + options?: { allowWebAppButtons?: boolean }, +): boolean { + return Boolean(buildTelegramPresentationButtons({ blocks: [block] }, options)?.length); } function partitionTelegramPresentationBlocks(params: { presentation: MessagePresentation; presentationControlsSelected: boolean; + allowWebAppButtons: boolean; }): { fallbackBlocks: MessagePresentation["blocks"]; nativeControlBlocks: MessagePresentationInteractiveBlock[]; @@ -66,7 +70,10 @@ function partitionTelegramPresentationBlocks(params: { const nativeButtons: typeof block.buttons = []; const fallbackButtons: typeof block.buttons = []; for (const button of block.buttons) { - const target = canEncodeTelegramPresentationControl({ type: "buttons", buttons: [button] }) + const target = canEncodeTelegramPresentationControl( + { type: "buttons", buttons: [button] }, + { allowWebAppButtons: params.allowWebAppButtons }, + ) ? nativeButtons : fallbackButtons; target.push(button); @@ -102,7 +109,10 @@ function partitionTelegramPresentationBlocks(params: { } /** Convert portable presentation into the one Telegram payload shape used by every send funnel. */ -export function canonicalizeTelegramPresentationPayload(payload: ReplyPayload): ReplyPayload { +export function canonicalizeTelegramPresentationPayload( + payload: ReplyPayload, + options?: { allowWebAppButtons?: boolean }, +): ReplyPayload { const normalizedPresentation = normalizeMessagePresentation(payload.presentation); const telegramData = payload.channelData?.telegram as | (Record & { @@ -131,10 +141,14 @@ export function canonicalizeTelegramPresentationPayload(payload: ReplyPayload): const { fallbackBlocks, nativeControlBlocks } = partitionTelegramPresentationBlocks({ presentation, presentationControlsSelected, + allowWebAppButtons: options?.allowWebAppButtons === true, }); - const presentationButtons = buildTelegramPresentationButtons({ - blocks: nativeControlBlocks, - }); + const presentationButtons = buildTelegramPresentationButtons( + { + blocks: nativeControlBlocks, + }, + options, + ); const buttons = existingButtons ?? presentationButtons; const fallbackText = renderMessagePresentationFallbackText({ diff --git a/extensions/telegram/src/outbound-adapter.test.ts b/extensions/telegram/src/outbound-adapter.test.ts index ab0616f5592d..d6cddfa6718a 100644 --- a/extensions/telegram/src/outbound-adapter.test.ts +++ b/extensions/telegram/src/outbound-adapter.test.ts @@ -488,7 +488,7 @@ describe("telegramOutbound", () => { const rendered = await telegramOutbound.renderPresentation?.({ payload: { text: "Open app:" }, presentation, - ctx: {} as never, + ctx: { to: "12345" } as never, }); if (!rendered) { throw new Error("expected rendered Telegram presentation"); diff --git a/extensions/telegram/src/outbound-adapter.ts b/extensions/telegram/src/outbound-adapter.ts index a7509e7d6891..efd2db0a6df7 100644 --- a/extensions/telegram/src/outbound-adapter.ts +++ b/extensions/telegram/src/outbound-adapter.ts @@ -145,7 +145,9 @@ export async function sendTelegramPayloadMessages(params: { payload: ReplyPayload; baseOpts: Omit, "buttons" | "mediaUrl" | "quoteText">; }): Promise>> { - const payload = canonicalizeTelegramPresentationPayload(params.payload); + const payload = canonicalizeTelegramPresentationPayload(params.payload, { + allowWebAppButtons: parseTelegramTarget(params.to).chatType === "direct", + }); const telegramData = payload.channelData?.telegram as | { buttons?: TelegramInlineButtons; @@ -315,8 +317,11 @@ export function createTelegramOutboundAdapter( batch: true, }, }, - renderPresentation: ({ payload, presentation }) => - canonicalizeTelegramPresentationPayload({ ...payload, presentation }), + renderPresentation: ({ payload, presentation, ctx }) => + canonicalizeTelegramPresentationPayload( + { ...payload, presentation }, + { allowWebAppButtons: parseTelegramTarget(ctx.to ?? "").chatType === "direct" }, + ), afterDeliverPayload: ({ cfg, target, payload, results }) => { const questionId = questionGatewayRuntime.readAskUserQuestionId(payload); const telegramResults = results.filter( diff --git a/extensions/telegram/src/outbound-adapter.web-app.test.ts b/extensions/telegram/src/outbound-adapter.web-app.test.ts new file mode 100644 index 000000000000..458dbf061ec7 --- /dev/null +++ b/extensions/telegram/src/outbound-adapter.web-app.test.ts @@ -0,0 +1,33 @@ +import { describe, expect, it } from "vitest"; +import { telegramOutbound } from "./outbound-adapter.js"; + +const webAppPresentation = { + blocks: [ + { + type: "buttons" as const, + buttons: [ + { + label: "Launch", + action: { type: "web-app" as const, url: "https://example.com/app" }, + }, + ], + }, + ], +}; + +describe("Telegram outbound web app presentation", () => { + it.each(["-1001234567890", "@channelname"])( + "falls back to a link for non-DM target %s", + async (to) => { + const rendered = await telegramOutbound.renderPresentation?.({ + payload: { text: "Open app:" }, + presentation: webAppPresentation, + ctx: { to } as never, + }); + + expect(rendered).toEqual({ + text: "Open app:\n\n- Launch: https://example.com/app", + }); + }, + ); +});