fix(telegram): fall back for web apps outside DMs (#111116)

This commit is contained in:
Jason (Json)
2026-07-18 19:52:19 -06:00
committed by GitHub
parent 0a8c485395
commit 047232bd16
10 changed files with 150 additions and 33 deletions

View File

@@ -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,

View File

@@ -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

View File

@@ -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 },
],

View File

@@ -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({

View File

@@ -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(

View File

@@ -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({

View File

@@ -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<string, unknown> & {
@@ -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({

View File

@@ -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");

View File

@@ -145,7 +145,9 @@ export async function sendTelegramPayloadMessages(params: {
payload: ReplyPayload;
baseOpts: Omit<NonNullable<TelegramSendOpts>, "buttons" | "mediaUrl" | "quoteText">;
}): Promise<Awaited<ReturnType<TelegramSendFn>>> {
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(

View File

@@ -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",
});
},
);
});