diff --git a/packages/core/src/channels/index.ts b/packages/core/src/channels/index.ts index cd1043f1..e75379fb 100644 --- a/packages/core/src/channels/index.ts +++ b/packages/core/src/channels/index.ts @@ -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); } diff --git a/packages/core/src/channels/plugins/telegram-format.ts b/packages/core/src/channels/plugins/telegram-format.ts deleted file mode 100644 index 2b3c305e..00000000 --- a/packages/core/src/channels/plugins/telegram-format.ts +++ /dev/null @@ -1,183 +0,0 @@ -/** - * Markdown → Telegram HTML converter. - * - * Telegram supports a subset of HTML: - * , , , , ,
, , 
- * - * 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, ">"); -} - -/** - * 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(`
${escaped}
`); - }); - - // 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(`${escapeHtml(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) => - `
${label}`, - ); - - // 6. Bold: **text** or __text__ - text = text.replace(/\*\*(.+?)\*\*/g, "$1"); - text = text.replace(/__(.+?)__/g, "$1"); - - // 7. Italic: *text* or _text_ (but not inside words with underscores) - text = text.replace(/(?$1
"); - text = text.replace(/(?$1"); - - // 8. Strikethrough: ~~text~~ - text = text.replace(/~~(.+?)~~/g, "$1"); - - // 9. Blockquotes: > text (at line start) - text = text.replace(/^> (.+)$/gm, "
$1
"); - // Merge adjacent blockquotes - text = text.replace(/<\/blockquote>\n
/g, "\n"); - - // 10. Headings: strip # markers, make bold - text = text.replace(/^#{1,6}\s+(.+)$/gm, "$1"); - - // Restore placeholders - text = text.replace(/\x00PH(\d+)\x00/g, (_match, idx: string) => placeholders[Number(idx)]!); - - return text; -} diff --git a/packages/core/src/channels/plugins/telegram.ts b/packages/core/src/channels/plugins/telegram.ts deleted file mode 100644 index dca4116c..00000000 --- a/packages/core/src/channels/plugins/telegram.ts +++ /dev/null @@ -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(); - -/** 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, -): Promise { - 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 { - 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 | undefined { - return config["telegram"]?.[accountId]; - }, - - isConfigured(account: Record): boolean { - return Boolean((account as unknown as TelegramAccountConfig).botToken); - }, - } satisfies ChannelConfigAdapter, - - gateway: { - async start( - accountId: string, - config: Record, - onMessage: (message: ChannelMessage) => void, - signal: AbortSignal, - ): Promise { - 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>(); - 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 { - 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 { - 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 { - 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 { - 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 { - 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 { - 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 = {}; - 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 { - 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 { - 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 { - 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; - }, -};