diff --git a/extensions/telegram/src/action-runtime.ts b/extensions/telegram/src/action-runtime.ts index c07dae07681..a90167e7090 100644 --- a/extensions/telegram/src/action-runtime.ts +++ b/extensions/telegram/src/action-runtime.ts @@ -43,6 +43,7 @@ export const telegramActionRuntime = { reactMessageTelegram, searchStickers, sendMessageTelegram, + sendPhotoTelegram, sendPollTelegram, sendStickerTelegram, }; @@ -60,6 +61,7 @@ const TELEGRAM_ACTION_ALIASES = { searchSticker: "searchSticker", send: "sendMessage", sendMessage: "sendMessage", + sendPhoto: "sendPhoto", sendSticker: "sendSticker", sticker: "sendSticker", stickerCacheStats: "stickerCacheStats", @@ -348,6 +350,42 @@ export async function handleTelegramAction( }); } + if (action === "sendPhoto") { + if (!isActionEnabled("sendMessage")) { + throw new Error("Telegram sendPhoto is disabled (requires sendMessage permission)."); + } + const to = readStringParam(params, "to", { required: true }); + const photoUrl = + readStringParam(params, "photoUrl") ?? + readStringParam(params, "mediaUrl") ?? + readStringParam(params, "photo", { required: true }); + const caption = + readStringParam(params, "caption") ?? + readStringParam(params, "message", { allowEmpty: true }); + const replyToMessageId = readTelegramReplyToMessageId(params); + const messageThreadId = readTelegramThreadId(params); + const token = resolveTelegramToken(cfg, { accountId }).token; + if (!token) { + throw new Error( + "Telegram bot token missing. Set TELEGRAM_BOT_TOKEN or channels.telegram.botToken.", + ); + } + const result = await telegramActionRuntime.sendPhotoTelegram(to, photoUrl, caption ?? undefined, { + cfg, + token, + accountId: accountId ?? undefined, + mediaLocalRoots: options?.mediaLocalRoots, + replyToMessageId: replyToMessageId ?? undefined, + messageThreadId: messageThreadId ?? undefined, + silent: readBooleanParam(params, "silent"), + }); + return jsonResult({ + ok: true, + messageId: result.messageId, + chatId: result.chatId, + }); + } + if (action === "poll") { const pollActionState = resolveTelegramPollActionGateState(isActionEnabled); if (!pollActionState.sendMessageEnabled) { diff --git a/extensions/telegram/src/channel-actions.ts b/extensions/telegram/src/channel-actions.ts index 867a0951a42..9526eac0b2e 100644 --- a/extensions/telegram/src/channel-actions.ts +++ b/extensions/telegram/src/channel-actions.ts @@ -29,6 +29,7 @@ const TELEGRAM_MESSAGE_ACTION_MAP = { poll: "poll", react: "react", send: "sendMessage", + sendPhoto: "sendPhoto", sticker: "sendSticker", "sticker-search": "searchSticker", "topic-create": "createForumTopic", @@ -104,6 +105,7 @@ function describeTelegramMessageTool({ if (discovery.isEnabled("editForumTopic")) { actions.add("topic-edit"); } + // sendPhoto is always available when send is enabled (uses same infrastructure) const schema: ChannelMessageToolSchemaContribution[] = []; if (discovery.buttonsEnabled) { schema.push({ diff --git a/extensions/telegram/src/send.ts b/extensions/telegram/src/send.ts index ec824d88ec7..e018161a3d7 100644 --- a/extensions/telegram/src/send.ts +++ b/extensions/telegram/src/send.ts @@ -1676,3 +1676,126 @@ export async function createForumTopicTelegram( chatId: normalizedChatId, }; } + +/** + * Send a photo to a Telegram chat. + * This is a specialized wrapper for sendPhoto API endpoint. + * + * @param to - Chat ID or username + * @param photoUrl - URL of the photo to send + * @param caption - Optional caption for the photo + * @param opts - Optional configuration + */ +export async function sendPhotoTelegram( + to: string, + photoUrl: string, + caption?: string, + opts: TelegramSendOpts = {}, +): Promise { + const { cfg, account, api } = resolveTelegramApiContext(opts); + const target = parseTelegramTarget(to); + const chatId = await resolveAndPersistChatId({ + cfg, + api, + lookupTarget: target.chatId, + persistTarget: to, + verbose: opts.verbose, + }); + + const threadParams = buildTelegramThreadReplyParams({ + targetMessageThreadId: target.messageThreadId, + messageThreadId: opts.messageThreadId, + chatType: target.chatType, + replyToMessageId: opts.replyToMessageId, + quoteText: opts.quoteText, + }); + + const requestWithDiag = createTelegramNonIdempotentRequestWithDiag({ + cfg, + account, + retry: opts.retry, + verbose: opts.verbose, + }); + const requestWithChatNotFound = createRequestWithChatNotFound({ + requestWithDiag, + chatId, + input: to, + }); + + // Load the photo from URL + const photoMaxBytes = + opts.maxBytes ?? + (typeof account.config.mediaMaxMb === "number" ? account.config.mediaMaxMb : 100) * 1024 * 1024; + const photo = await loadWebMedia( + photoUrl, + buildOutboundMediaLoadOptions({ + maxBytes: photoMaxBytes, + mediaLocalRoots: opts.mediaLocalRoots, + }), + ); + + const fileName = photo.fileName ?? "photo.jpg"; + const file = new InputFile(photo.buffer, fileName); + + // Process caption with HTML rendering + let htmlCaption: string | undefined; + let followUpText: string | undefined; + if (caption) { + const split = splitTelegramCaption(caption); + const renderedCaption = renderTelegramHtmlText(split.caption, { + textMode: opts.textMode ?? "markdown", + tableMode: resolveMarkdownTableMode({ + cfg, + channel: "telegram", + accountId: account.accountId, + }), + }); + htmlCaption = renderedCaption; + followUpText = split.followUpText; + } + + const baseParams = { + ...(Object.keys(threadParams).length > 0 ? threadParams : {}), + ...(opts.silent === true ? { disable_notification: true } : {}), + }; + + const sendPhotoParams = { + ...baseParams, + ...(htmlCaption ? { caption: htmlCaption, parse_mode: "HTML" as const } : {}), + }; + + const result = await withTelegramThreadFallback( + sendPhotoParams, + "photo", + opts.verbose, + async (effectiveParams, label) => + requestWithChatNotFound( + () => + api.sendPhoto( + chatId, + file, + effectiveParams as Parameters[2], + ) as Promise, + label, + ), + ); + + const messageId = resolveTelegramMessageIdOrThrow(result, "photo send"); + const resolvedChatId = String(result?.chat?.id ?? chatId); + recordSentMessage(chatId, messageId); + recordChannelActivity({ + channel: "telegram", + accountId: account.accountId, + direction: "outbound", + }); + + // If caption was too long, send follow-up text + if (followUpText) { + await sendMessageTelegram(to, followUpText, { + ...opts, + replyToMessageId: messageId, + }); + } + + return { messageId: String(messageId), chatId: resolvedChatId }; +}