diff --git a/src/agents/tools/message-tool.ts b/src/agents/tools/message-tool.ts index 4ea178a54..b29f143c1 100644 --- a/src/agents/tools/message-tool.ts +++ b/src/agents/tools/message-tool.ts @@ -45,6 +45,11 @@ function buildSendSchema(options: { includeButtons: boolean; includeCards: boole Type.String({ description: "Alias for effectId (e.g., invisible-ink, balloons)." }), ), media: Type.Optional(Type.String()), + mediaUrls: Type.Optional( + Type.Array(Type.String(), { + description: "Array of media URLs to send as album/group (2-10 items, Telegram only).", + }), + ), filename: Type.Optional(Type.String()), buffer: Type.Optional( Type.String({ diff --git a/src/channels/plugins/outbound/telegram.ts b/src/channels/plugins/outbound/telegram.ts index 04abb77e0..e817a24b8 100644 --- a/src/channels/plugins/outbound/telegram.ts +++ b/src/channels/plugins/outbound/telegram.ts @@ -1,5 +1,5 @@ import { markdownToTelegramHtmlChunks } from "../../../telegram/format.js"; -import { sendMessageTelegram } from "../../../telegram/send.js"; +import { sendMediaGroupTelegram, sendMessageTelegram } from "../../../telegram/send.js"; import type { ChannelOutboundAdapter } from "../types.js"; function parseReplyToMessageId(replyToId?: string | null) { @@ -53,6 +53,7 @@ export const telegramOutbound: ChannelOutboundAdapter = { }, sendPayload: async ({ to, payload, accountId, deps, replyToId, threadId }) => { const send = deps?.sendTelegram ?? sendMessageTelegram; + const sendGroup = deps?.sendMediaGroupTelegram ?? sendMediaGroupTelegram; const replyToMessageId = parseReplyToMessageId(replyToId); const messageThreadId = parseThreadId(threadId); const telegramData = payload.channelData?.telegram as @@ -83,7 +84,29 @@ export const telegramOutbound: ChannelOutboundAdapter = { return { channel: "telegram", ...result }; } - // Telegram allows reply_markup on media; attach buttons only to first send. + // Use media group (album) for 2-10 images - sends as a single album + if (mediaUrls.length >= 2 && mediaUrls.length <= 10) { + const groupResult = await sendGroup(to, mediaUrls, text, { + messageThreadId, + replyToMessageId, + accountId: accountId ?? undefined, + }); + // If there are buttons, send them in a follow-up message (media groups don't support buttons) + if (telegramData?.buttons?.length) { + await send(to, "", { + ...baseOpts, + buttons: telegramData.buttons, + }); + } + return { + channel: "telegram", + messageId: groupResult.messageIds[0] ?? "unknown", + messageIds: groupResult.messageIds, + chatId: groupResult.chatId, + }; + } + + // Single media or fallback for >10 items: send one at a time let finalResult: Awaited> | undefined; for (let i = 0; i < mediaUrls.length; i += 1) { const mediaUrl = mediaUrls[i]; @@ -96,4 +119,21 @@ export const telegramOutbound: ChannelOutboundAdapter = { } return { channel: "telegram", ...(finalResult ?? { messageId: "unknown", chatId: to }) }; }, + sendMediaGroup: async ({ to, mediaUrls, caption, accountId, deps, replyToId, threadId }) => { + const sendGroup = deps?.sendMediaGroupTelegram ?? sendMediaGroupTelegram; + const replyToMessageId = parseReplyToMessageId(replyToId); + const messageThreadId = parseThreadId(threadId); + const result = await sendGroup(to, mediaUrls, caption, { + messageThreadId, + replyToMessageId, + accountId: accountId ?? undefined, + }); + // Return first message ID as the main one + return { + channel: "telegram", + messageId: result.messageIds[0] ?? "unknown", + messageIds: result.messageIds, + chatId: result.chatId, + }; + }, }; diff --git a/src/infra/outbound/message-action-runner.ts b/src/infra/outbound/message-action-runner.ts index 8f99ad791..be06e6d75 100644 --- a/src/infra/outbound/message-action-runner.ts +++ b/src/infra/outbound/message-action-runner.ts @@ -646,6 +646,9 @@ async function handleSendAction(ctx: ResolvedActionContext): Promise) { } } +type TelegramMediaGroupOpts = { + token?: string; + accountId?: string; + verbose?: boolean; + api?: Bot["api"]; + retry?: RetryConfig; + maxBytes?: number; + /** Send message silently (no notification). Defaults to false. */ + silent?: boolean; + /** Message ID to reply to (for threading) */ + replyToMessageId?: number; + /** Forum topic thread ID (for forum supergroups) */ + messageThreadId?: number; +}; + +type TelegramMediaGroupResult = { + messageIds: string[]; + chatId: string; +}; + +/** + * Send multiple photos/videos as a media group (album) to a Telegram chat. + * Supports 2-10 media items. Only the first item's caption is displayed. + * + * @param to - Chat ID or username (e.g., "123456789" or "@username") + * @param mediaUrls - Array of media URLs (2-10 items) + * @param caption - Optional caption (displayed under first media item) + * @param opts - Optional configuration + */ +export async function sendMediaGroupTelegram( + to: string, + mediaUrls: string[], + caption?: string, + opts: TelegramMediaGroupOpts = {}, +): Promise { + if (!mediaUrls?.length || mediaUrls.length < 2) { + throw new Error("Media group requires at least 2 media items"); + } + if (mediaUrls.length > 10) { + throw new Error("Media group supports at most 10 media items"); + } + + const cfg = loadConfig(); + const account = resolveTelegramAccount({ + cfg, + accountId: opts.accountId, + }); + const token = resolveToken(opts.token, account); + const target = parseTelegramTarget(to); + const chatId = normalizeChatId(target.chatId); + const client = resolveTelegramClientOptions(account); + const api = opts.api ?? new Bot(token, client ? { client } : undefined).api; + + const messageThreadId = + opts.messageThreadId != null ? opts.messageThreadId : target.messageThreadId; + const threadIdParams = buildTelegramThreadParams(messageThreadId); + const threadParams: Record = threadIdParams ? { ...threadIdParams } : {}; + if (opts.replyToMessageId != null) { + threadParams.reply_to_message_id = Math.trunc(opts.replyToMessageId); + } + + const request = createTelegramRetryRunner({ + retry: opts.retry, + configRetry: account.config.retry, + verbose: opts.verbose, + shouldRetry: (err) => isRecoverableTelegramNetworkError(err, { context: "send" }), + }); + const logHttpError = createTelegramHttpLogger(cfg); + const requestWithDiag = (fn: () => Promise, label?: string) => + withTelegramApiErrorLogging({ + operation: label ?? "request", + fn: () => request(fn, label), + }).catch((err) => { + logHttpError(label ?? "request", err); + throw err; + }); + + const wrapChatNotFound = (err: unknown) => { + if (!/400: Bad Request: chat not found/i.test(formatErrorMessage(err))) return err; + return new Error( + [ + `Telegram send failed: chat not found (chat_id=${chatId}).`, + "Likely: bot not started in DM, bot removed from group/channel, group migrated (new -100… id), or wrong bot token.", + `Input was: ${JSON.stringify(to)}.`, + ].join(" "), + ); + }; + + const textMode = "markdown"; + const tableMode = resolveMarkdownTableMode({ + cfg, + channel: "telegram", + accountId: account.accountId, + }); + const renderHtmlText = (value: string) => renderTelegramHtmlText(value, { textMode, tableMode }); + + // Load all media files + const mediaItems: Array<{ + type: "photo" | "video"; + media: InputFile; + }> = []; + + for (const url of mediaUrls) { + const trimmedUrl = url.trim(); + if (!trimmedUrl) continue; + + const media = await loadWebMedia(trimmedUrl, opts.maxBytes); + const kind = mediaKindFromMime(media.contentType ?? undefined); + const isGif = isGifMedia({ + contentType: media.contentType, + fileName: media.fileName, + }); + + // Media groups only support photos and videos + if (kind !== "image" && kind !== "video" && !isGif) { + throw new Error(`Media group only supports photos and videos. Got: ${kind} for ${trimmedUrl}`); + } + + const fileName = media.fileName ?? inferFilename(kind) ?? "file"; + const file = new InputFile(media.buffer, fileName); + + // GIFs are sent as videos in media groups + const mediaType = kind === "video" || isGif ? "video" : "photo"; + mediaItems.push({ type: mediaType, media: file }); + } + + if (mediaItems.length < 2) { + throw new Error("Media group requires at least 2 valid media items"); + } + + // Build media group array - only first item gets caption + const htmlCaption = caption?.trim() ? renderHtmlText(caption.trim()) : undefined; + const mediaGroup = mediaItems.map((item, index) => ({ + type: item.type, + media: item.media, + ...(index === 0 && htmlCaption + ? { caption: htmlCaption, parse_mode: "HTML" as const } + : {}), + })); + + const groupParams = { + ...threadParams, + ...(opts.silent === true ? { disable_notification: true } : {}), + }; + + const results = await requestWithDiag( + () => api.sendMediaGroup(chatId, mediaGroup, groupParams), + "mediaGroup", + ).catch((err) => { + throw wrapChatNotFound(err); + }); + + const messageIds = results.map((r) => String(r.message_id)); + const resolvedChatId = String(results[0]?.chat?.id ?? chatId); + + for (const result of results) { + if (result?.message_id) { + recordSentMessage(chatId, result.message_id); + } + } + + recordChannelActivity({ + channel: "telegram", + accountId: account.accountId, + direction: "outbound", + }); + + return { messageIds, chatId: resolvedChatId }; +} + type TelegramStickerOpts = { token?: string; accountId?: string;