feat(telegram): add sendPhoto action support

- Add sendPhoto action to channel-actions.ts mapping
- Implement sendPhotoTelegram function in send.ts
- Add sendPhoto handler in action-runtime.ts
- Support photo URL, caption, threading, and silent options
- Follows Telegram Bot API sendPhoto endpoint

Resolves: #49729
This commit is contained in:
艾恒 2026-03-18 18:48:05 +08:00
parent 7d005676cd
commit 4920ff1521
3 changed files with 163 additions and 0 deletions

View File

@ -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) {

View File

@ -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({

View File

@ -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<TelegramSendResult> {
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<typeof api.sendPhoto>[2],
) as Promise<TelegramMessageLike>,
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 };
}