mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-28 23:51:17 +00:00
fix(telegram): fall back for web apps outside DMs (#111116)
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 },
|
||||
],
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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(
|
||||
|
||||
33
extensions/telegram/src/outbound-adapter.web-app.test.ts
Normal file
33
extensions/telegram/src/outbound-adapter.web-app.test.ts
Normal 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",
|
||||
});
|
||||
},
|
||||
);
|
||||
});
|
||||
Reference in New Issue
Block a user