From 6e96fd13068c796848624c63bb5a8937b78677f1 Mon Sep 17 00:00:00 2001 From: Jiayuan Zhang Date: Fri, 13 Feb 2026 01:37:08 +0800 Subject: [PATCH] feat(channels): add outbound media types and sendMedia to channel adapter Add OutboundMedia interface and OutboundMediaType to the channel type system. Implement sendMedia in the Telegram plugin using grammy's InputFile API with HTML caption formatting and plain-text fallback. Co-Authored-By: Claude Opus 4.6 --- .../core/src/channels/plugins/telegram.ts | 65 ++++++++++++++++++- packages/core/src/channels/types.ts | 19 ++++++ 2 files changed, 82 insertions(+), 2 deletions(-) diff --git a/packages/core/src/channels/plugins/telegram.ts b/packages/core/src/channels/plugins/telegram.ts index fa6f103ea..f7f31ce60 100644 --- a/packages/core/src/channels/plugins/telegram.ts +++ b/packages/core/src/channels/plugins/telegram.ts @@ -12,8 +12,8 @@ import { writeFile, mkdir } from "node:fs/promises"; import { join, extname } from "node:path"; import { v7 as uuidv7 } from "uuid"; -import { Bot, GrammyError } from "grammy"; -import type { ChannelPlugin, ChannelMessage, ChannelConfigAdapter, ChannelsConfig, DeliveryContext } from "../types.js"; +import { Bot, GrammyError, InputFile } from "grammy"; +import type { ChannelPlugin, ChannelMessage, ChannelConfigAdapter, ChannelsConfig, DeliveryContext, OutboundMedia } from "../types.js"; import { markdownToTelegramHtml } from "./telegram-format.js"; import { MEDIA_CACHE_DIR } from "@multica/utils"; @@ -321,6 +321,67 @@ export const telegramChannel: ChannelPlugin = { // Best-effort } }, + + async sendMedia(ctx: DeliveryContext, media: OutboundMedia): Promise { + const bot = bots.get(ctx.accountId); + if (!bot) throw new Error(`No Telegram bot for account ${ctx.accountId}`); + + const chatId = Number(ctx.conversationId); + const inputFile = new InputFile(media.source); + // Telegram caption limit: 1024 chars. Truncate if needed. + const caption = media.caption?.slice(0, 1024); + const captionHtml = caption ? markdownToTelegramHtml(caption) : undefined; + const extra = captionHtml ? { caption: captionHtml, parse_mode: "HTML" as const } : {}; + + console.log(`[Telegram] Sending ${media.type} to chatId=${chatId}`); + + try { + switch (media.type) { + case "photo": + await bot.api.sendPhoto(chatId, inputFile, extra); + break; + case "video": + await bot.api.sendVideo(chatId, inputFile, extra); + break; + case "audio": + await bot.api.sendAudio(chatId, inputFile, extra); + break; + case "voice": + await bot.api.sendVoice(chatId, inputFile, extra); + break; + case "document": + default: + await bot.api.sendDocument(chatId, inputFile, extra); + break; + } + } catch (err) { + // If HTML caption fails, retry without formatting + if (isParseError(err) && caption) { + console.warn("[Telegram] Media caption HTML parse failed, retrying as plain text"); + const plainExtra = { caption }; + switch (media.type) { + case "photo": + await bot.api.sendPhoto(chatId, inputFile, plainExtra); + break; + case "video": + await bot.api.sendVideo(chatId, inputFile, plainExtra); + break; + case "audio": + await bot.api.sendAudio(chatId, inputFile, plainExtra); + break; + case "voice": + await bot.api.sendVoice(chatId, inputFile, plainExtra); + break; + case "document": + default: + await bot.api.sendDocument(chatId, inputFile, plainExtra); + break; + } + } else { + throw err; + } + } + }, }, async downloadMedia(fileId: string, accountId: string): Promise { diff --git a/packages/core/src/channels/types.ts b/packages/core/src/channels/types.ts index 439677590..fd9fe2c12 100644 --- a/packages/core/src/channels/types.ts +++ b/packages/core/src/channels/types.ts @@ -88,6 +88,23 @@ export interface ChannelGatewayAdapter { ): Promise; } +// ─── Outbound Media ─── + +/** Media type for outbound messages */ +export type OutboundMediaType = "photo" | "document" | "video" | "audio" | "voice"; + +/** Media payload for sending files back to the platform */ +export interface OutboundMedia { + /** Media type (determines which API method to use) */ + type: OutboundMediaType; + /** Local file path */ + source: string; + /** Caption text (optional, may be truncated per platform limits) */ + caption?: string | undefined; + /** Filename hint (optional, used for documents) */ + filename?: string | undefined; +} + // ─── Outbound Adapter ─── /** Sends messages back to the platform */ @@ -96,6 +113,8 @@ export interface ChannelOutboundAdapter { sendText(ctx: DeliveryContext, text: string): Promise; /** Reply to a specific message */ replyText(ctx: DeliveryContext, text: string): Promise; + /** Send a media file (photo, document, video, audio, voice) to a conversation (optional) */ + sendMedia?(ctx: DeliveryContext, media: OutboundMedia): Promise; /** Send "typing" indicator (optional, not all platforms support it) */ sendTyping?(ctx: DeliveryContext): Promise; /**