refactor: share telegram payload send flow

This commit is contained in:
Peter Steinberger
2026-03-10 20:43:56 +00:00
parent a455c0cc3d
commit bc1cc2e50f
4 changed files with 140 additions and 85 deletions

View File

@@ -313,6 +313,68 @@ describe("telegramPlugin duplicate token guard", () => {
expect(result).toMatchObject({ channel: "telegram", messageId: "tg-2" }); 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<string, unknown>)?.buttons,
).toBeUndefined();
expect(result).toMatchObject({ channel: "telegram", messageId: "tg-4" });
});
it("ignores accounts with missing tokens during duplicate-token checks", async () => { it("ignores accounts with missing tokens during duplicate-token checks", async () => {
const cfg = createCfg(); const cfg = createCfg();
cfg.channels!.telegram!.accounts!.ops = {} as never; cfg.channels!.telegram!.accounts!.ops = {} as never;

View File

@@ -31,6 +31,7 @@ import {
resolveTelegramAccount, resolveTelegramAccount,
resolveTelegramGroupRequireMention, resolveTelegramGroupRequireMention,
resolveTelegramGroupToolPolicy, resolveTelegramGroupToolPolicy,
sendTelegramPayloadMessages,
telegramOnboardingAdapter, telegramOnboardingAdapter,
TelegramConfigSchema, TelegramConfigSchema,
type ChannelMessageActionAdapter, 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({ const telegramConfigAccessors = createScopedAccountConfigAccessors({
resolveAccount: ({ cfg, accountId }) => resolveTelegramAccount({ cfg, accountId }), resolveAccount: ({ cfg, accountId }) => resolveTelegramAccount({ cfg, accountId }),
resolveAllowFrom: (account: ResolvedTelegramAccount) => account.config.allowFrom, resolveAllowFrom: (account: ResolvedTelegramAccount) => account.config.allowFrom,
@@ -332,47 +329,21 @@ export const telegramPlugin: ChannelPlugin<ResolvedTelegramAccount, TelegramProb
const send = deps?.sendTelegram ?? getTelegramRuntime().channel.telegram.sendMessageTelegram; const send = deps?.sendTelegram ?? getTelegramRuntime().channel.telegram.sendMessageTelegram;
const replyToMessageId = parseTelegramReplyToMessageId(replyToId); const replyToMessageId = parseTelegramReplyToMessageId(replyToId);
const messageThreadId = parseTelegramThreadId(threadId); const messageThreadId = parseTelegramThreadId(threadId);
const telegramData = payload.channelData?.telegram as const result = await sendTelegramPayloadMessages({
| { buttons?: TelegramInlineButtons; quoteText?: string } send,
| undefined; to,
const quoteText = payload,
typeof telegramData?.quoteText === "string" ? telegramData.quoteText : undefined; baseOpts: {
const text = payload.text ?? ""; verbose: false,
const mediaUrls = payload.mediaUrls?.length cfg,
? payload.mediaUrls mediaLocalRoots,
: payload.mediaUrl messageThreadId,
? [payload.mediaUrl] replyToMessageId,
: []; accountId: accountId ?? undefined,
const baseOpts = { silent: silent ?? undefined,
verbose: false, },
cfg, });
mediaLocalRoots, return { channel: "telegram", ...result };
messageThreadId,
replyToMessageId,
quoteText,
accountId: accountId ?? undefined,
silent: silent ?? undefined,
};
if (mediaUrls.length === 0) {
const result = await send(to, text, {
...baseOpts,
buttons: telegramData?.buttons,
});
return { channel: "telegram", ...result };
}
let finalResult: Awaited<ReturnType<typeof send>> | 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 }) };
}, },
sendText: async ({ cfg, to, text, accountId, deps, replyToId, threadId, silent }) => { sendText: async ({ cfg, to, text, accountId, deps, replyToId, threadId, silent }) => {
const send = deps?.sendTelegram ?? getTelegramRuntime().channel.telegram.sendMessageTelegram; const send = deps?.sendTelegram ?? getTelegramRuntime().channel.telegram.sendMessageTelegram;

View File

@@ -1,3 +1,4 @@
import type { ReplyPayload } from "../../../auto-reply/types.js";
import type { OutboundSendDeps } from "../../../infra/outbound/deliver.js"; import type { OutboundSendDeps } from "../../../infra/outbound/deliver.js";
import type { TelegramInlineButtons } from "../../../telegram/button-types.js"; import type { TelegramInlineButtons } from "../../../telegram/button-types.js";
import { markdownToTelegramHtmlChunks } from "../../../telegram/format.js"; import { markdownToTelegramHtmlChunks } from "../../../telegram/format.js";
@@ -8,16 +9,19 @@ import {
import { sendMessageTelegram } from "../../../telegram/send.js"; import { sendMessageTelegram } from "../../../telegram/send.js";
import type { ChannelOutboundAdapter } from "../types.js"; import type { ChannelOutboundAdapter } from "../types.js";
type TelegramSendFn = typeof sendMessageTelegram;
type TelegramSendOpts = Parameters<TelegramSendFn>[2];
function resolveTelegramSendContext(params: { function resolveTelegramSendContext(params: {
cfg: NonNullable<Parameters<typeof sendMessageTelegram>[2]>["cfg"]; cfg: NonNullable<TelegramSendOpts>["cfg"];
deps?: OutboundSendDeps; deps?: OutboundSendDeps;
accountId?: string | null; accountId?: string | null;
replyToId?: string | null; replyToId?: string | null;
threadId?: string | number | null; threadId?: string | number | null;
}): { }): {
send: typeof sendMessageTelegram; send: TelegramSendFn;
baseOpts: { baseOpts: {
cfg: NonNullable<Parameters<typeof sendMessageTelegram>[2]>["cfg"]; cfg: NonNullable<TelegramSendOpts>["cfg"];
verbose: false; verbose: false;
textMode: "html"; textMode: "html";
messageThreadId?: number; messageThreadId?: number;
@@ -39,6 +43,49 @@ function resolveTelegramSendContext(params: {
}; };
} }
export async function sendTelegramPayloadMessages(params: {
send: TelegramSendFn;
to: string;
payload: ReplyPayload;
baseOpts: Omit<NonNullable<TelegramSendOpts>, "buttons" | "mediaUrl" | "quoteText">;
}): Promise<Awaited<ReturnType<TelegramSendFn>>> {
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<ReturnType<TelegramSendFn>> | 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 = { export const telegramOutbound: ChannelOutboundAdapter = {
deliveryMode: "direct", deliveryMode: "direct",
chunker: markdownToTelegramHtmlChunks, chunker: markdownToTelegramHtmlChunks,
@@ -92,48 +139,22 @@ export const telegramOutbound: ChannelOutboundAdapter = {
replyToId, replyToId,
threadId, threadId,
}) => { }) => {
const { send, baseOpts: contextOpts } = resolveTelegramSendContext({ const { send, baseOpts } = resolveTelegramSendContext({
cfg, cfg,
deps, deps,
accountId, accountId,
replyToId, replyToId,
threadId, threadId,
}); });
const telegramData = payload.channelData?.telegram as const result = await sendTelegramPayloadMessages({
| { buttons?: TelegramInlineButtons; quoteText?: string } send,
| undefined; to,
const quoteText = payload,
typeof telegramData?.quoteText === "string" ? telegramData.quoteText : undefined; baseOpts: {
const text = payload.text ?? ""; ...baseOpts,
const mediaUrls = payload.mediaUrls?.length mediaLocalRoots,
? payload.mediaUrls },
: payload.mediaUrl });
? [payload.mediaUrl] return { channel: "telegram", ...result };
: [];
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<ReturnType<typeof send>> | 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 }) };
}, },
}; };

View File

@@ -53,6 +53,7 @@ export {
parseTelegramThreadId, parseTelegramThreadId,
} from "../telegram/outbound-params.js"; } from "../telegram/outbound-params.js";
export { collectTelegramStatusIssues } from "../channels/plugins/status-issues/telegram.js"; export { collectTelegramStatusIssues } from "../channels/plugins/status-issues/telegram.js";
export { sendTelegramPayloadMessages } from "../channels/plugins/outbound/telegram.js";
export { export {
resolveAllowlistProviderRuntimeGroupPolicy, resolveAllowlistProviderRuntimeGroupPolicy,