From bc1cc2e50f2f2953c2eae02e45571ab9e41d2e78 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 10 Mar 2026 20:43:56 +0000 Subject: [PATCH] refactor: share telegram payload send flow --- extensions/telegram/src/channel.test.ts | 62 +++++++++++++ extensions/telegram/src/channel.ts | 61 ++++--------- src/channels/plugins/outbound/telegram.ts | 101 +++++++++++++--------- src/plugin-sdk/telegram.ts | 1 + 4 files changed, 140 insertions(+), 85 deletions(-) diff --git a/extensions/telegram/src/channel.test.ts b/extensions/telegram/src/channel.test.ts index 2bf1b681497..f0736069015 100644 --- a/extensions/telegram/src/channel.test.ts +++ b/extensions/telegram/src/channel.test.ts @@ -313,6 +313,68 @@ describe("telegramPlugin duplicate token guard", () => { expect(result).toMatchObject({ channel: "telegram", messageId: "tg-2" }); }); + it("sends outbound payload media lists and keeps buttons on the first message only", async () => { + const sendMessageTelegram = vi + .fn() + .mockResolvedValueOnce({ messageId: "tg-3", chatId: "12345" }) + .mockResolvedValueOnce({ messageId: "tg-4", chatId: "12345" }); + setTelegramRuntime({ + channel: { + telegram: { + sendMessageTelegram, + }, + }, + } as unknown as PluginRuntime); + + const result = await telegramPlugin.outbound!.sendPayload!({ + cfg: createCfg(), + to: "12345", + text: "", + payload: { + text: "Approval required", + mediaUrls: ["https://example.com/1.jpg", "https://example.com/2.jpg"], + channelData: { + telegram: { + quoteText: "quoted", + buttons: [[{ text: "Allow Once", callback_data: "/approve abc allow-once" }]], + }, + }, + }, + mediaLocalRoots: ["/tmp/media"], + accountId: "ops", + silent: true, + }); + + expect(sendMessageTelegram).toHaveBeenCalledTimes(2); + expect(sendMessageTelegram).toHaveBeenNthCalledWith( + 1, + "12345", + "Approval required", + expect.objectContaining({ + mediaUrl: "https://example.com/1.jpg", + mediaLocalRoots: ["/tmp/media"], + quoteText: "quoted", + silent: true, + buttons: [[{ text: "Allow Once", callback_data: "/approve abc allow-once" }]], + }), + ); + expect(sendMessageTelegram).toHaveBeenNthCalledWith( + 2, + "12345", + "", + expect.objectContaining({ + mediaUrl: "https://example.com/2.jpg", + mediaLocalRoots: ["/tmp/media"], + quoteText: "quoted", + silent: true, + }), + ); + expect( + (sendMessageTelegram.mock.calls[1]?.[2] as Record)?.buttons, + ).toBeUndefined(); + expect(result).toMatchObject({ channel: "telegram", messageId: "tg-4" }); + }); + it("ignores accounts with missing tokens during duplicate-token checks", async () => { const cfg = createCfg(); cfg.channels!.telegram!.accounts!.ops = {} as never; diff --git a/extensions/telegram/src/channel.ts b/extensions/telegram/src/channel.ts index f5fb8e2acb4..52ae2b15ea8 100644 --- a/extensions/telegram/src/channel.ts +++ b/extensions/telegram/src/channel.ts @@ -31,6 +31,7 @@ import { resolveTelegramAccount, resolveTelegramGroupRequireMention, resolveTelegramGroupToolPolicy, + sendTelegramPayloadMessages, telegramOnboardingAdapter, TelegramConfigSchema, type ChannelMessageActionAdapter, @@ -91,10 +92,6 @@ const telegramMessageActions: ChannelMessageActionAdapter = { }, }; -type TelegramInlineButtons = ReadonlyArray< - ReadonlyArray<{ text: string; callback_data: string; style?: "danger" | "success" | "primary" }> ->; - const telegramConfigAccessors = createScopedAccountConfigAccessors({ resolveAccount: ({ cfg, accountId }) => resolveTelegramAccount({ cfg, accountId }), resolveAllowFrom: (account: ResolvedTelegramAccount) => account.config.allowFrom, @@ -332,47 +329,21 @@ export const telegramPlugin: ChannelPlugin> | undefined; - for (let i = 0; i < mediaUrls.length; i += 1) { - const mediaUrl = mediaUrls[i]; - const isFirst = i === 0; - finalResult = await send(to, isFirst ? text : "", { - ...baseOpts, - mediaUrl, - ...(isFirst ? { buttons: telegramData?.buttons } : {}), - }); - } - return { channel: "telegram", ...(finalResult ?? { messageId: "unknown", chatId: to }) }; + const result = await sendTelegramPayloadMessages({ + send, + to, + payload, + baseOpts: { + verbose: false, + cfg, + mediaLocalRoots, + messageThreadId, + replyToMessageId, + accountId: accountId ?? undefined, + silent: silent ?? undefined, + }, + }); + return { channel: "telegram", ...result }; }, sendText: async ({ cfg, to, text, accountId, deps, replyToId, threadId, silent }) => { const send = deps?.sendTelegram ?? getTelegramRuntime().channel.telegram.sendMessageTelegram; diff --git a/src/channels/plugins/outbound/telegram.ts b/src/channels/plugins/outbound/telegram.ts index 2afc67d439d..8af1b5831ee 100644 --- a/src/channels/plugins/outbound/telegram.ts +++ b/src/channels/plugins/outbound/telegram.ts @@ -1,3 +1,4 @@ +import type { ReplyPayload } from "../../../auto-reply/types.js"; import type { OutboundSendDeps } from "../../../infra/outbound/deliver.js"; import type { TelegramInlineButtons } from "../../../telegram/button-types.js"; import { markdownToTelegramHtmlChunks } from "../../../telegram/format.js"; @@ -8,16 +9,19 @@ import { import { sendMessageTelegram } from "../../../telegram/send.js"; import type { ChannelOutboundAdapter } from "../types.js"; +type TelegramSendFn = typeof sendMessageTelegram; +type TelegramSendOpts = Parameters[2]; + function resolveTelegramSendContext(params: { - cfg: NonNullable[2]>["cfg"]; + cfg: NonNullable["cfg"]; deps?: OutboundSendDeps; accountId?: string | null; replyToId?: string | null; threadId?: string | number | null; }): { - send: typeof sendMessageTelegram; + send: TelegramSendFn; baseOpts: { - cfg: NonNullable[2]>["cfg"]; + cfg: NonNullable["cfg"]; verbose: false; textMode: "html"; messageThreadId?: number; @@ -39,6 +43,49 @@ function resolveTelegramSendContext(params: { }; } +export async function sendTelegramPayloadMessages(params: { + send: TelegramSendFn; + to: string; + payload: ReplyPayload; + baseOpts: Omit, "buttons" | "mediaUrl" | "quoteText">; +}): Promise>> { + const telegramData = params.payload.channelData?.telegram as + | { buttons?: TelegramInlineButtons; quoteText?: string } + | undefined; + const quoteText = + typeof telegramData?.quoteText === "string" ? telegramData.quoteText : undefined; + const text = params.payload.text ?? ""; + const mediaUrls = params.payload.mediaUrls?.length + ? params.payload.mediaUrls + : params.payload.mediaUrl + ? [params.payload.mediaUrl] + : []; + const payloadOpts = { + ...params.baseOpts, + quoteText, + }; + + if (mediaUrls.length === 0) { + return await params.send(params.to, text, { + ...payloadOpts, + buttons: telegramData?.buttons, + }); + } + + // Telegram allows reply_markup on media; attach buttons only to the first send. + let finalResult: Awaited> | undefined; + for (let i = 0; i < mediaUrls.length; i += 1) { + const mediaUrl = mediaUrls[i]; + const isFirst = i === 0; + finalResult = await params.send(params.to, isFirst ? text : "", { + ...payloadOpts, + mediaUrl, + ...(isFirst ? { buttons: telegramData?.buttons } : {}), + }); + } + return finalResult ?? { messageId: "unknown", chatId: params.to }; +} + export const telegramOutbound: ChannelOutboundAdapter = { deliveryMode: "direct", chunker: markdownToTelegramHtmlChunks, @@ -92,48 +139,22 @@ export const telegramOutbound: ChannelOutboundAdapter = { replyToId, threadId, }) => { - const { send, baseOpts: contextOpts } = resolveTelegramSendContext({ + const { send, baseOpts } = resolveTelegramSendContext({ cfg, deps, accountId, replyToId, threadId, }); - const telegramData = payload.channelData?.telegram as - | { buttons?: TelegramInlineButtons; quoteText?: string } - | undefined; - const quoteText = - typeof telegramData?.quoteText === "string" ? telegramData.quoteText : undefined; - const text = payload.text ?? ""; - const mediaUrls = payload.mediaUrls?.length - ? payload.mediaUrls - : payload.mediaUrl - ? [payload.mediaUrl] - : []; - const payloadOpts = { - ...contextOpts, - quoteText, - mediaLocalRoots, - }; - if (mediaUrls.length === 0) { - const result = await send(to, text, { - ...payloadOpts, - buttons: telegramData?.buttons, - }); - return { channel: "telegram", ...result }; - } - - // Telegram allows reply_markup on media; attach buttons only to first send. - let finalResult: Awaited> | undefined; - for (let i = 0; i < mediaUrls.length; i += 1) { - const mediaUrl = mediaUrls[i]; - const isFirst = i === 0; - finalResult = await send(to, isFirst ? text : "", { - ...payloadOpts, - mediaUrl, - ...(isFirst ? { buttons: telegramData?.buttons } : {}), - }); - } - return { channel: "telegram", ...(finalResult ?? { messageId: "unknown", chatId: to }) }; + const result = await sendTelegramPayloadMessages({ + send, + to, + payload, + baseOpts: { + ...baseOpts, + mediaLocalRoots, + }, + }); + return { channel: "telegram", ...result }; }, }; diff --git a/src/plugin-sdk/telegram.ts b/src/plugin-sdk/telegram.ts index 53167998404..cdbfc317208 100644 --- a/src/plugin-sdk/telegram.ts +++ b/src/plugin-sdk/telegram.ts @@ -53,6 +53,7 @@ export { parseTelegramThreadId, } from "../telegram/outbound-params.js"; export { collectTelegramStatusIssues } from "../channels/plugins/status-issues/telegram.js"; +export { sendTelegramPayloadMessages } from "../channels/plugins/outbound/telegram.js"; export { resolveAllowlistProviderRuntimeGroupPolicy,