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:
Jiayuan Zhang 2026-02-14 02:28:00 +08:00
parent dde8cc542a
commit 3c911807e1
3 changed files with 1 additions and 639 deletions

View file

@ -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);
}

View file

@ -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, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;");
}
/**
* 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, "&quot;")}">${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(/^&gt; (.+)$/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;
}

View file

@ -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;
},
};