feat(telegram): add media group (album) support
Adds support for sending multiple photos/videos as a Telegram album: - New `sendMediaGroupTelegram` function in telegram/send.ts - Telegram outbound adapter now uses media groups for 2-10 images - Message tool accepts `mediaUrls` array parameter - Albums display as a single grouped message in Telegram Usage: ``` message(action='send', target='123', mediaUrls=['url1.jpg', 'url2.jpg'], caption='My album') ``` Limitations: - Telegram albums don't support inline buttons (sent as follow-up) - Only photos and videos supported (no documents in albums) - 2-10 items required for album mode
This commit is contained in:
parent
d93f8ffc13
commit
01767f3504
@ -45,6 +45,11 @@ function buildSendSchema(options: { includeButtons: boolean; includeCards: boole
|
|||||||
Type.String({ description: "Alias for effectId (e.g., invisible-ink, balloons)." }),
|
Type.String({ description: "Alias for effectId (e.g., invisible-ink, balloons)." }),
|
||||||
),
|
),
|
||||||
media: Type.Optional(Type.String()),
|
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()),
|
filename: Type.Optional(Type.String()),
|
||||||
buffer: Type.Optional(
|
buffer: Type.Optional(
|
||||||
Type.String({
|
Type.String({
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
import { markdownToTelegramHtmlChunks } from "../../../telegram/format.js";
|
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";
|
import type { ChannelOutboundAdapter } from "../types.js";
|
||||||
|
|
||||||
function parseReplyToMessageId(replyToId?: string | null) {
|
function parseReplyToMessageId(replyToId?: string | null) {
|
||||||
@ -53,6 +53,7 @@ export const telegramOutbound: ChannelOutboundAdapter = {
|
|||||||
},
|
},
|
||||||
sendPayload: async ({ to, payload, accountId, deps, replyToId, threadId }) => {
|
sendPayload: async ({ to, payload, accountId, deps, replyToId, threadId }) => {
|
||||||
const send = deps?.sendTelegram ?? sendMessageTelegram;
|
const send = deps?.sendTelegram ?? sendMessageTelegram;
|
||||||
|
const sendGroup = deps?.sendMediaGroupTelegram ?? sendMediaGroupTelegram;
|
||||||
const replyToMessageId = parseReplyToMessageId(replyToId);
|
const replyToMessageId = parseReplyToMessageId(replyToId);
|
||||||
const messageThreadId = parseThreadId(threadId);
|
const messageThreadId = parseThreadId(threadId);
|
||||||
const telegramData = payload.channelData?.telegram as
|
const telegramData = payload.channelData?.telegram as
|
||||||
@ -83,7 +84,29 @@ export const telegramOutbound: ChannelOutboundAdapter = {
|
|||||||
return { channel: "telegram", ...result };
|
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<ReturnType<typeof send>> | undefined;
|
let finalResult: Awaited<ReturnType<typeof send>> | undefined;
|
||||||
for (let i = 0; i < mediaUrls.length; i += 1) {
|
for (let i = 0; i < mediaUrls.length; i += 1) {
|
||||||
const mediaUrl = mediaUrls[i];
|
const mediaUrl = mediaUrls[i];
|
||||||
@ -96,4 +119,21 @@ export const telegramOutbound: ChannelOutboundAdapter = {
|
|||||||
}
|
}
|
||||||
return { channel: "telegram", ...(finalResult ?? { messageId: "unknown", chatId: to }) };
|
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,
|
||||||
|
};
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@ -646,6 +646,9 @@ async function handleSendAction(ctx: ResolvedActionContext): Promise<MessageActi
|
|||||||
mergedMediaUrls.push(trimmed);
|
mergedMediaUrls.push(trimmed);
|
||||||
};
|
};
|
||||||
pushMedia(mediaHint);
|
pushMedia(mediaHint);
|
||||||
|
// Read mediaUrls array from tool params (for album/media group support)
|
||||||
|
const paramMediaUrls = readStringArrayParam(params, "mediaUrls");
|
||||||
|
for (const url of paramMediaUrls ?? []) pushMedia(url);
|
||||||
for (const url of parsed.mediaUrls ?? []) pushMedia(url);
|
for (const url of parsed.mediaUrls ?? []) pushMedia(url);
|
||||||
pushMedia(parsed.mediaUrl);
|
pushMedia(parsed.mediaUrl);
|
||||||
message = parsed.text;
|
message = parsed.text;
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
export { createTelegramBot, createTelegramWebhookCallback } from "./bot.js";
|
export { createTelegramBot, createTelegramWebhookCallback } from "./bot.js";
|
||||||
export { monitorTelegramProvider } from "./monitor.js";
|
export { monitorTelegramProvider } from "./monitor.js";
|
||||||
export { reactMessageTelegram, sendMessageTelegram } from "./send.js";
|
export { reactMessageTelegram, sendMediaGroupTelegram, sendMessageTelegram } from "./send.js";
|
||||||
export { startTelegramWebhook } from "./webhook.js";
|
export { startTelegramWebhook } from "./webhook.js";
|
||||||
|
|||||||
@ -630,6 +630,176 @@ function inferFilename(kind: ReturnType<typeof mediaKindFromMime>) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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<TelegramMediaGroupResult> {
|
||||||
|
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<string, unknown> = 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 = <T>(fn: () => Promise<T>, 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 = {
|
type TelegramStickerOpts = {
|
||||||
token?: string;
|
token?: string;
|
||||||
accountId?: string;
|
accountId?: string;
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user