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 <noreply@anthropic.com>
This commit is contained in:
Jiayuan Zhang
2026-02-13 01:37:08 +08:00
parent ade7f2b056
commit 6e96fd1306
2 changed files with 82 additions and 2 deletions

View File

@@ -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<void> {
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<string> {

View File

@@ -88,6 +88,23 @@ export interface ChannelGatewayAdapter {
): Promise<void>;
}
// ─── 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<void>;
/** Reply to a specific message */
replyText(ctx: DeliveryContext, text: string): Promise<void>;
/** Send a media file (photo, document, video, audio, voice) to a conversation (optional) */
sendMedia?(ctx: DeliveryContext, media: OutboundMedia): Promise<void>;
/** Send "typing" indicator (optional, not all platforms support it) */
sendTyping?(ctx: DeliveryContext): Promise<void>;
/**