chore(channels): remove unused Desktop Telegram channel plugin
The Telegram bot is handled entirely by the Gateway (apps/gateway/telegram/). The Desktop channel plugin was never registered (commented out in initChannels) and is dead code. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
dde8cc542a
commit
3c911807e1
3 changed files with 1 additions and 639 deletions
|
|
@ -15,8 +15,7 @@ export type {
|
|||
|
||||
/** Register all built-in channel plugins. Call once at startup. */
|
||||
export function initChannels(): void {
|
||||
// Telegram: use official bot via Gateway webhook instead of user-created bots.
|
||||
// The long-polling plugin is kept in plugins/telegram.ts but not registered.
|
||||
// Telegram is handled by the Gateway (apps/gateway/telegram/).
|
||||
// Future: registerChannel(discordChannel);
|
||||
// Future: registerChannel(feishuChannel);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,183 +0,0 @@
|
|||
/**
|
||||
* Markdown → Telegram HTML converter.
|
||||
*
|
||||
* Telegram supports a subset of HTML:
|
||||
* <b>, <i>, <u>, <s>, <code>, <pre>, <a href="...">, <blockquote>
|
||||
*
|
||||
* Strategy:
|
||||
* 1. Extract code blocks (protect from further processing)
|
||||
* 2. Convert Markdown tables to vertical list format
|
||||
* 3. Extract inline code
|
||||
* 4. Escape HTML entities in remaining text
|
||||
* 5. Convert Markdown syntax to HTML tags
|
||||
* 6. Restore code blocks
|
||||
*/
|
||||
|
||||
/**
|
||||
* Parse a Markdown table row into trimmed cell values.
|
||||
* e.g. "| A | B | C |" → ["A", "B", "C"]
|
||||
*/
|
||||
function parseTableRow(line: string): string[] {
|
||||
const cells = line.split("|").map((c) => c.trim());
|
||||
// Remove empty first/last elements from leading/trailing |
|
||||
if (cells.length >= 2 && cells[0] === "") cells.shift();
|
||||
if (cells.length >= 1 && cells[cells.length - 1] === "") cells.pop();
|
||||
return cells;
|
||||
}
|
||||
|
||||
/** Check if a line is a Markdown table separator (|---|---|) */
|
||||
function isTableSeparator(line: string): boolean {
|
||||
return /^\s*\|[\s\-:]+(\|[\s\-:]+)*\|\s*$/.test(line);
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a block of Markdown table lines into a vertical list format.
|
||||
*
|
||||
* Input:
|
||||
* | Name | Code | Type |
|
||||
* |--------|------|------------|
|
||||
* | Slack | WORK | Messaging |
|
||||
* | Notion | 私有 | Docs |
|
||||
*
|
||||
* Output:
|
||||
* • **Slack**
|
||||
* Code: WORK
|
||||
* Type: Messaging
|
||||
*
|
||||
* • **Notion**
|
||||
* Code: 私有
|
||||
* Type: Docs
|
||||
*/
|
||||
function convertTableBlock(tableLines: string[]): string {
|
||||
if (tableLines.length < 2) return tableLines.join("\n");
|
||||
|
||||
const headers = parseTableRow(tableLines[0]!);
|
||||
if (headers.length === 0) return tableLines.join("\n");
|
||||
|
||||
// Skip separator row if present
|
||||
let dataStart = 1;
|
||||
if (tableLines[1] && isTableSeparator(tableLines[1])) {
|
||||
dataStart = 2;
|
||||
}
|
||||
|
||||
if (dataStart >= tableLines.length) return tableLines.join("\n");
|
||||
|
||||
const rows: string[] = [];
|
||||
for (let i = dataStart; i < tableLines.length; i++) {
|
||||
const cells = parseTableRow(tableLines[i]!);
|
||||
if (cells.length === 0) continue;
|
||||
|
||||
const parts: string[] = [];
|
||||
// First column as bold title
|
||||
parts.push(`**${cells[0]}**`);
|
||||
// Remaining columns as "Header: Value"
|
||||
for (let j = 1; j < Math.min(headers.length, cells.length); j++) {
|
||||
const val = cells[j]?.trim();
|
||||
if (val) {
|
||||
parts.push(` ${headers[j]}: ${val}`);
|
||||
}
|
||||
}
|
||||
rows.push(parts.join("\n"));
|
||||
}
|
||||
|
||||
return rows.join("\n\n");
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert all Markdown tables in text to vertical list format.
|
||||
* Tables are detected as consecutive lines starting with |.
|
||||
*/
|
||||
function convertMarkdownTables(text: string): string {
|
||||
const lines = text.split("\n");
|
||||
const result: string[] = [];
|
||||
let i = 0;
|
||||
|
||||
while (i < lines.length) {
|
||||
if (lines[i]!.trimStart().startsWith("|")) {
|
||||
// Collect consecutive table lines
|
||||
const tableLines: string[] = [];
|
||||
while (i < lines.length && lines[i]!.trimStart().startsWith("|")) {
|
||||
tableLines.push(lines[i]!);
|
||||
i++;
|
||||
}
|
||||
result.push(convertTableBlock(tableLines));
|
||||
} else {
|
||||
result.push(lines[i]!);
|
||||
i++;
|
||||
}
|
||||
}
|
||||
|
||||
return result.join("\n");
|
||||
}
|
||||
|
||||
/** Escape HTML special characters */
|
||||
function escapeHtml(text: string): string {
|
||||
return text
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">");
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert Markdown text to Telegram-compatible HTML.
|
||||
* Handles: bold, italic, strikethrough, inline code, code blocks, links, blockquotes.
|
||||
*/
|
||||
export function markdownToTelegramHtml(markdown: string): string {
|
||||
// Placeholder system: replace code blocks/inline code with placeholders,
|
||||
// process markdown on the rest, then restore.
|
||||
const placeholders: string[] = [];
|
||||
const placeholder = (content: string): string => {
|
||||
const idx = placeholders.length;
|
||||
placeholders.push(content);
|
||||
return `\x00PH${idx}\x00`;
|
||||
};
|
||||
|
||||
let text = markdown;
|
||||
|
||||
// 1. Fenced code blocks: ```lang\n...\n```
|
||||
text = text.replace(/```(\w*)\n([\s\S]*?)```/g, (_match, lang: string, code: string) => {
|
||||
const escaped = escapeHtml(code.replace(/\n$/, ""));
|
||||
const langAttr = lang ? ` class="language-${escapeHtml(lang)}"` : "";
|
||||
return placeholder(`<pre><code${langAttr}>${escaped}</code></pre>`);
|
||||
});
|
||||
|
||||
// 2. Convert Markdown tables to vertical list format (before further processing)
|
||||
text = convertMarkdownTables(text);
|
||||
|
||||
// 3. Inline code: `...`
|
||||
text = text.replace(/`([^`\n]+)`/g, (_match, code: string) => {
|
||||
return placeholder(`<code>${escapeHtml(code)}</code>`);
|
||||
});
|
||||
|
||||
// 4. Escape HTML in remaining text
|
||||
text = escapeHtml(text);
|
||||
|
||||
// 5. Links: [text](url) — escape quotes in URL to prevent attribute breakout
|
||||
text = text.replace(/\[([^\]]+)\]\(([^)]+)\)/g, (_m, label: string, url: string) =>
|
||||
`<a href="${url.replace(/"/g, """)}">${label}</a>`,
|
||||
);
|
||||
|
||||
// 6. Bold: **text** or __text__
|
||||
text = text.replace(/\*\*(.+?)\*\*/g, "<b>$1</b>");
|
||||
text = text.replace(/__(.+?)__/g, "<b>$1</b>");
|
||||
|
||||
// 7. Italic: *text* or _text_ (but not inside words with underscores)
|
||||
text = text.replace(/(?<!\w)\*(?!\s)(.+?)(?<!\s)\*(?!\w)/g, "<i>$1</i>");
|
||||
text = text.replace(/(?<!\w)_(?!\s)(.+?)(?<!\s)_(?!\w)/g, "<i>$1</i>");
|
||||
|
||||
// 8. Strikethrough: ~~text~~
|
||||
text = text.replace(/~~(.+?)~~/g, "<s>$1</s>");
|
||||
|
||||
// 9. Blockquotes: > text (at line start)
|
||||
text = text.replace(/^> (.+)$/gm, "<blockquote>$1</blockquote>");
|
||||
// Merge adjacent blockquotes
|
||||
text = text.replace(/<\/blockquote>\n<blockquote>/g, "\n");
|
||||
|
||||
// 10. Headings: strip # markers, make bold
|
||||
text = text.replace(/^#{1,6}\s+(.+)$/gm, "<b>$1</b>");
|
||||
|
||||
// Restore placeholders
|
||||
text = text.replace(/\x00PH(\d+)\x00/g, (_match, idx: string) => placeholders[Number(idx)]!);
|
||||
|
||||
return text;
|
||||
}
|
||||
|
|
@ -1,454 +0,0 @@
|
|||
/**
|
||||
* Telegram channel plugin.
|
||||
*
|
||||
* Uses grammy to connect to Telegram Bot API via long polling.
|
||||
* - Private chats: all messages are processed
|
||||
* - Group chats: only messages that @mention the bot or reply to the bot
|
||||
*
|
||||
* @see docs/channels/README.md — Channel system overview
|
||||
* @see docs/channels/media-handling.md — Media processing pipeline
|
||||
*/
|
||||
|
||||
import { writeFile, mkdir } from "node:fs/promises";
|
||||
import { join, extname } from "node:path";
|
||||
import { v7 as uuidv7 } from "uuid";
|
||||
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";
|
||||
|
||||
/** Telegram account config shape */
|
||||
interface TelegramAccountConfig {
|
||||
botToken: string;
|
||||
}
|
||||
|
||||
/** Keep bot instances per account for outbound use */
|
||||
const bots = new Map<string, Bot>();
|
||||
|
||||
/** Check if a GrammyError is an HTML parse failure */
|
||||
function isParseError(err: unknown): boolean {
|
||||
return err instanceof GrammyError && err.description.includes("can't parse entities");
|
||||
}
|
||||
|
||||
/** Send a message with HTML formatting, fallback to plain text on parse error. Returns message ID. */
|
||||
async function sendFormatted(
|
||||
bot: Bot,
|
||||
chatId: number,
|
||||
text: string,
|
||||
extra?: Record<string, unknown>,
|
||||
): Promise<number> {
|
||||
const html = markdownToTelegramHtml(text);
|
||||
try {
|
||||
const msg = await bot.api.sendMessage(chatId, html, { ...extra, parse_mode: "HTML" });
|
||||
return msg.message_id;
|
||||
} catch (err) {
|
||||
if (isParseError(err)) {
|
||||
console.warn("[Telegram] HTML parse failed, retrying as plain text");
|
||||
const msg = await bot.api.sendMessage(chatId, text, extra);
|
||||
return msg.message_id;
|
||||
} else {
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Edit an existing message with HTML formatting, fallback to plain text on parse error */
|
||||
async function editFormatted(
|
||||
bot: Bot,
|
||||
chatId: number,
|
||||
messageId: number,
|
||||
text: string,
|
||||
): Promise<void> {
|
||||
const html = markdownToTelegramHtml(text);
|
||||
try {
|
||||
await bot.api.editMessageText(chatId, messageId, html, { parse_mode: "HTML" });
|
||||
} catch (err) {
|
||||
if (isParseError(err)) {
|
||||
console.warn("[Telegram] HTML parse failed on edit, retrying as plain text");
|
||||
await bot.api.editMessageText(chatId, messageId, text);
|
||||
} else {
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const telegramChannel: ChannelPlugin = {
|
||||
id: "telegram",
|
||||
meta: {
|
||||
name: "Telegram",
|
||||
description: "Telegram bot integration via long polling",
|
||||
},
|
||||
chunkerConfig: {
|
||||
minChars: 3800, // Buffer the full response; only chunk when approaching platform limit
|
||||
maxChars: 4000, // Telegram API limit: 4096; leave room for HTML formatting overhead
|
||||
breakPreference: "paragraph",
|
||||
},
|
||||
|
||||
config: {
|
||||
listAccountIds(config: ChannelsConfig): string[] {
|
||||
const section = config["telegram"];
|
||||
return section ? Object.keys(section) : [];
|
||||
},
|
||||
|
||||
resolveAccount(config: ChannelsConfig, accountId: string): Record<string, unknown> | undefined {
|
||||
return config["telegram"]?.[accountId];
|
||||
},
|
||||
|
||||
isConfigured(account: Record<string, unknown>): boolean {
|
||||
return Boolean((account as unknown as TelegramAccountConfig).botToken);
|
||||
},
|
||||
} satisfies ChannelConfigAdapter,
|
||||
|
||||
gateway: {
|
||||
async start(
|
||||
accountId: string,
|
||||
config: Record<string, unknown>,
|
||||
onMessage: (message: ChannelMessage) => void,
|
||||
signal: AbortSignal,
|
||||
): Promise<void> {
|
||||
const { botToken } = config as unknown as TelegramAccountConfig;
|
||||
|
||||
const bot = new Bot(botToken);
|
||||
bots.set(accountId, bot);
|
||||
|
||||
// Get bot info for mention/reply detection
|
||||
const botInfo = await bot.api.getMe();
|
||||
const botId = botInfo.id;
|
||||
const botUsername = botInfo.username;
|
||||
console.log(`[Telegram] Starting bot: @${botUsername} (id=${botId})`);
|
||||
|
||||
// ── Sequentialize middleware ──
|
||||
// Ensures updates from the same chat are processed one at a time,
|
||||
// preventing race conditions on shared state (e.g. ChannelManager.lastRoute).
|
||||
// Grammy processes updates concurrently by default — without this,
|
||||
// two messages arriving near-simultaneously could interleave.
|
||||
// Lightweight alternative to @grammyjs/runner's sequentialize().
|
||||
// @see docs/channel/openclaw-research.md — Grammy middleware pipeline
|
||||
const chatQueues = new Map<string, Promise<void>>();
|
||||
bot.use(async (ctx, next) => {
|
||||
const chatId = ctx.chat?.id;
|
||||
if (!chatId) return next();
|
||||
|
||||
const key = String(chatId);
|
||||
const prev = chatQueues.get(key) ?? Promise.resolve();
|
||||
|
||||
// Chain this handler onto the per-chat queue
|
||||
const current = prev.then(() => next()).catch(() => {});
|
||||
chatQueues.set(key, current);
|
||||
await current;
|
||||
|
||||
// Clean up resolved entries to prevent memory leak
|
||||
if (chatQueues.get(key) === current) {
|
||||
chatQueues.delete(key);
|
||||
}
|
||||
});
|
||||
|
||||
// Handle text messages
|
||||
bot.on("message:text", (ctx) => {
|
||||
const msg = ctx.message;
|
||||
const isGroup = msg.chat.type === "group" || msg.chat.type === "supergroup";
|
||||
|
||||
// In groups, only respond if bot is mentioned or replied to
|
||||
if (isGroup) {
|
||||
const isMentioned = msg.entities?.some(
|
||||
(e) =>
|
||||
e.type === "mention" &&
|
||||
msg.text.substring(e.offset, e.offset + e.length).toLowerCase() === `@${botUsername?.toLowerCase()}`,
|
||||
);
|
||||
const isReplyToBot = msg.reply_to_message?.from?.id === botId;
|
||||
|
||||
if (!isMentioned && !isReplyToBot) {
|
||||
return; // Ignore group messages not directed at bot
|
||||
}
|
||||
console.log(`[Telegram] Received message: chatId=${msg.chat.id} from=${msg.from?.id} type=group text="${msg.text.slice(0, 50)}"`);
|
||||
} else {
|
||||
console.log(`[Telegram] Received message: chatId=${msg.chat.id} from=${msg.from?.id} type=direct text="${msg.text.slice(0, 50)}"`);
|
||||
}
|
||||
|
||||
// Strip @mention from text for cleaner agent input
|
||||
let text = msg.text;
|
||||
if (botUsername) {
|
||||
text = text.replace(new RegExp(`@${botUsername}\\s*`, "gi"), "").trim();
|
||||
}
|
||||
if (!text) return;
|
||||
|
||||
onMessage({
|
||||
messageId: String(msg.message_id),
|
||||
conversationId: String(msg.chat.id),
|
||||
senderId: String(msg.from?.id ?? "unknown"),
|
||||
text,
|
||||
chatType: isGroup ? "group" : "direct",
|
||||
});
|
||||
});
|
||||
|
||||
// Handle media messages (voice, audio, photo, video, document)
|
||||
const mediaTypes = [
|
||||
{ filter: "message:voice" as const, getMedia: (msg: any) => ({
|
||||
type: "audio" as const,
|
||||
fileId: msg.voice.file_id as string,
|
||||
mimeType: msg.voice.mime_type as string | undefined,
|
||||
duration: msg.voice.duration as number | undefined,
|
||||
})},
|
||||
{ filter: "message:audio" as const, getMedia: (msg: any) => ({
|
||||
type: "audio" as const,
|
||||
fileId: msg.audio.file_id as string,
|
||||
mimeType: msg.audio.mime_type as string | undefined,
|
||||
duration: msg.audio.duration as number | undefined,
|
||||
})},
|
||||
{ filter: "message:photo" as const, getMedia: (msg: any) => {
|
||||
// Pick the largest photo size (last in array)
|
||||
const photos = msg.photo as Array<{ file_id: string }>;
|
||||
const largest = photos[photos.length - 1]!;
|
||||
return {
|
||||
type: "image" as const,
|
||||
fileId: largest.file_id,
|
||||
mimeType: "image/jpeg",
|
||||
};
|
||||
}},
|
||||
{ filter: "message:video" as const, getMedia: (msg: any) => ({
|
||||
type: "video" as const,
|
||||
fileId: msg.video.file_id as string,
|
||||
mimeType: msg.video.mime_type as string | undefined,
|
||||
duration: msg.video.duration as number | undefined,
|
||||
})},
|
||||
{ filter: "message:document" as const, getMedia: (msg: any) => ({
|
||||
type: "document" as const,
|
||||
fileId: msg.document.file_id as string,
|
||||
mimeType: msg.document.mime_type as string | undefined,
|
||||
})},
|
||||
] as const;
|
||||
|
||||
for (const { filter, getMedia } of mediaTypes) {
|
||||
bot.on(filter, (ctx) => {
|
||||
const msg = ctx.message;
|
||||
const isGroup = msg.chat.type === "group" || msg.chat.type === "supergroup";
|
||||
|
||||
if (isGroup) {
|
||||
const isReplyToBot = msg.reply_to_message?.from?.id === botId;
|
||||
const caption = (msg as any).caption as string | undefined;
|
||||
const isMentionedInCaption = caption && botUsername
|
||||
? caption.toLowerCase().includes(`@${botUsername.toLowerCase()}`)
|
||||
: false;
|
||||
if (!isReplyToBot && !isMentionedInCaption) return;
|
||||
}
|
||||
|
||||
const media = getMedia(msg);
|
||||
const caption = (msg as any).caption as string | undefined;
|
||||
console.log(`[Telegram] Received ${media.type}: chatId=${msg.chat.id} from=${msg.from?.id} fileId=${media.fileId}`);
|
||||
|
||||
onMessage({
|
||||
messageId: String(msg.message_id),
|
||||
conversationId: String(msg.chat.id),
|
||||
senderId: String(msg.from?.id ?? "unknown"),
|
||||
text: caption ?? "",
|
||||
chatType: isGroup ? "group" : "direct",
|
||||
media: {
|
||||
type: media.type,
|
||||
fileId: media.fileId,
|
||||
mimeType: media.mimeType,
|
||||
duration: (media as any).duration,
|
||||
caption,
|
||||
},
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Graceful shutdown on abort
|
||||
signal.addEventListener("abort", () => {
|
||||
console.log("[Telegram] Bot stopped");
|
||||
bot.stop();
|
||||
bots.delete(accountId);
|
||||
});
|
||||
|
||||
// Start long polling (fire-and-forget, errors are caught here)
|
||||
console.log("[Telegram] Bot is polling for messages");
|
||||
bot.start({
|
||||
onStart: () => {
|
||||
// Already logged above
|
||||
},
|
||||
}).catch((err: unknown) => {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
if (msg.includes("409") || msg.includes("Conflict")) {
|
||||
console.error(`[Telegram] Bot conflict: another instance is already polling with this token. Stop the other process and restart.`);
|
||||
} else {
|
||||
console.error(`[Telegram] Bot polling error: ${msg}`);
|
||||
}
|
||||
bots.delete(accountId);
|
||||
});
|
||||
},
|
||||
},
|
||||
|
||||
outbound: {
|
||||
async sendText(ctx: DeliveryContext, text: string): Promise<void> {
|
||||
const bot = bots.get(ctx.accountId);
|
||||
if (!bot) throw new Error(`No Telegram bot for account ${ctx.accountId}`);
|
||||
|
||||
console.log(`[Telegram] Sending message to chatId=${ctx.conversationId}`);
|
||||
await sendFormatted(bot, Number(ctx.conversationId), text);
|
||||
},
|
||||
|
||||
async replyText(ctx: DeliveryContext, text: string): Promise<void> {
|
||||
const bot = bots.get(ctx.accountId);
|
||||
if (!bot) throw new Error(`No Telegram bot for account ${ctx.accountId}`);
|
||||
|
||||
if (ctx.replyToMessageId) {
|
||||
console.log(`[Telegram] Sending reply to chatId=${ctx.conversationId} (replyTo=${ctx.replyToMessageId})`);
|
||||
await sendFormatted(bot, Number(ctx.conversationId), text, {
|
||||
reply_to_message_id: Number(ctx.replyToMessageId),
|
||||
});
|
||||
} else {
|
||||
await telegramChannel.outbound.sendText(ctx, text);
|
||||
}
|
||||
},
|
||||
|
||||
async sendTyping(ctx: DeliveryContext): Promise<void> {
|
||||
const bot = bots.get(ctx.accountId);
|
||||
if (!bot) return;
|
||||
|
||||
try {
|
||||
await bot.api.sendChatAction(Number(ctx.conversationId), "typing");
|
||||
} catch {
|
||||
// Best-effort — typing indicator failure is not critical
|
||||
}
|
||||
},
|
||||
|
||||
async addReaction(ctx: DeliveryContext, emoji: string): Promise<void> {
|
||||
const bot = bots.get(ctx.accountId);
|
||||
if (!bot || !ctx.replyToMessageId) return;
|
||||
|
||||
try {
|
||||
await bot.api.setMessageReaction(
|
||||
Number(ctx.conversationId),
|
||||
Number(ctx.replyToMessageId),
|
||||
// Grammy expects a specific emoji union type; cast since our interface accepts any string
|
||||
[{ type: "emoji", emoji } as unknown as { type: "emoji"; emoji: "👀" }],
|
||||
);
|
||||
} catch {
|
||||
// Best-effort — reaction failure is not critical
|
||||
// (e.g. bot may lack permission in some groups)
|
||||
}
|
||||
},
|
||||
|
||||
async removeReaction(ctx: DeliveryContext): Promise<void> {
|
||||
const bot = bots.get(ctx.accountId);
|
||||
if (!bot || !ctx.replyToMessageId) return;
|
||||
|
||||
try {
|
||||
await bot.api.setMessageReaction(
|
||||
Number(ctx.conversationId),
|
||||
Number(ctx.replyToMessageId),
|
||||
[], // Empty array clears all bot reactions
|
||||
);
|
||||
} catch {
|
||||
// Best-effort
|
||||
}
|
||||
},
|
||||
|
||||
async replyTextEditable(ctx: DeliveryContext, text: string): Promise<string> {
|
||||
const bot = bots.get(ctx.accountId);
|
||||
if (!bot) throw new Error(`No Telegram bot for account ${ctx.accountId}`);
|
||||
|
||||
const chatId = Number(ctx.conversationId);
|
||||
const extra: Record<string, unknown> = {};
|
||||
if (ctx.replyToMessageId) {
|
||||
extra["reply_to_message_id"] = Number(ctx.replyToMessageId);
|
||||
}
|
||||
|
||||
console.log(`[Telegram] Sending editable status to chatId=${chatId}`);
|
||||
const msgId = await sendFormatted(bot, chatId, text, extra);
|
||||
return String(msgId);
|
||||
},
|
||||
|
||||
async editText(ctx: DeliveryContext, messageId: string, text: string): Promise<void> {
|
||||
const bot = bots.get(ctx.accountId);
|
||||
if (!bot) throw new Error(`No Telegram bot for account ${ctx.accountId}`);
|
||||
|
||||
console.log(`[Telegram] Editing message ${messageId} in chatId=${ctx.conversationId}`);
|
||||
await editFormatted(bot, Number(ctx.conversationId), Number(messageId), text);
|
||||
},
|
||||
|
||||
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> {
|
||||
const bot = bots.get(accountId);
|
||||
if (!bot) throw new Error(`No Telegram bot for account ${accountId}`);
|
||||
|
||||
const file = await bot.api.getFile(fileId);
|
||||
const filePath = file.file_path;
|
||||
if (!filePath) throw new Error(`Telegram returned no file_path for fileId=${fileId}`);
|
||||
|
||||
const url = `https://api.telegram.org/file/bot${bot.token}/${filePath}`;
|
||||
const ext = extname(filePath) || ".bin";
|
||||
const localPath = join(MEDIA_CACHE_DIR, `${uuidv7()}${ext}`);
|
||||
|
||||
await mkdir(MEDIA_CACHE_DIR, { recursive: true });
|
||||
|
||||
const res = await fetch(url);
|
||||
if (!res.ok) throw new Error(`Failed to download file: HTTP ${res.status}`);
|
||||
const buffer = Buffer.from(await res.arrayBuffer());
|
||||
await writeFile(localPath, buffer);
|
||||
|
||||
console.log(`[Telegram] Downloaded media: ${filePath} → ${localPath}`);
|
||||
return localPath;
|
||||
},
|
||||
};
|
||||
Loading…
Add table
Add a link
Reference in a new issue