diff --git a/src/channels/manager.ts b/src/channels/manager.ts index de371f9a..bccd05b8 100644 --- a/src/channels/manager.ts +++ b/src/channels/manager.ts @@ -43,6 +43,8 @@ export class ChannelManager { private agentUnsubscribe: (() => void) | null = null; /** Current aggregator for buffering streaming responses */ private aggregator: MessageAggregator | null = null; + /** Typing indicator interval (repeats every 5s to keep Telegram typing visible) */ + private typingTimer: ReturnType | null = null; constructor(hub: Hub) { this.hub = hub; @@ -163,6 +165,7 @@ export class ChannelManager { // Handle agent errors — notify the channel user if (event.type === "agent_error") { + this.stopTyping(); const errorMsg = (event as { error?: string }).error ?? "Unknown error"; console.error(`[Channels] Agent error: ${errorMsg}`); const route = this.lastRoute; @@ -196,6 +199,7 @@ export class ChannelManager { // Clean up after response complete if (event.type === "message_end" && role === "assistant") { + this.stopTyping(); this.aggregator = null; } }); @@ -259,13 +263,36 @@ export class ChannelManager { console.log(`[Channels] lastRoute updated → ${plugin.id}:${conversationId}`); console.log(`[Channels] Forwarding to agent ${agent.sessionId}`); + // Show typing indicator while agent processes + this.startTyping(); + // Same as typing in the desktop chat agent.write(text); } + /** Start sending typing indicators (repeats every 5s until stopped) */ + private startTyping(): void { + this.stopTyping(); + const route = this.lastRoute; + if (!route?.plugin.outbound.sendTyping) return; + + const send = () => route.plugin.outbound.sendTyping!(route.deliveryCtx).catch(() => {}); + void send(); + this.typingTimer = setInterval(send, 5000); + } + + /** Stop typing indicator interval */ + private stopTyping(): void { + if (this.typingTimer) { + clearInterval(this.typingTimer); + this.typingTimer = null; + } + } + /** Stop all running channel accounts */ stopAll(): void { console.log("[Channels] Stopping all channels..."); + this.stopTyping(); if (this.agentUnsubscribe) { this.agentUnsubscribe(); this.agentUnsubscribe = null; @@ -283,6 +310,7 @@ export class ChannelManager { /** Clear the last route (e.g. when desktop user sends a message) */ clearLastRoute(): void { if (this.lastRoute) { + this.stopTyping(); console.log("[Channels] lastRoute cleared (non-channel message received)"); this.lastRoute = null; } diff --git a/src/channels/plugins/telegram-format.ts b/src/channels/plugins/telegram-format.ts new file mode 100644 index 00000000..c5f22cc7 --- /dev/null +++ b/src/channels/plugins/telegram-format.ts @@ -0,0 +1,79 @@ +/** + * Markdown → Telegram HTML converter. + * + * Telegram supports a subset of HTML: + * , , , , ,
, , 
+ * + * Strategy: + * 1. Extract code blocks and inline code (protect from further processing) + * 2. Escape HTML entities in remaining text + * 3. Convert Markdown syntax to HTML tags + * 4. Restore code blocks + */ + +/** 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. Inline code: `...` + text = text.replace(/`([^`\n]+)`/g, (_match, code: string) => { + return placeholder(`${escapeHtml(code)}`); + }); + + // 3. Escape HTML in remaining text + text = escapeHtml(text); + + // 4. Links: [text](url) + text = text.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '
$1'); + + // 5. Bold: **text** or __text__ + text = text.replace(/\*\*(.+?)\*\*/g, "$1"); + text = text.replace(/__(.+?)__/g, "$1"); + + // 6. Italic: *text* or _text_ (but not inside words with underscores) + text = text.replace(/(?$1
"); + text = text.replace(/(?$1"); + + // 7. Strikethrough: ~~text~~ + text = text.replace(/~~(.+?)~~/g, "$1"); + + // 8. Blockquotes: > text (at line start) + text = text.replace(/^> (.+)$/gm, "
$1
"); + // Merge adjacent blockquotes + text = text.replace(/<\/blockquote>\n
/g, "\n"); + + // 9. 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/src/channels/plugins/telegram.ts b/src/channels/plugins/telegram.ts index 9e2c4dce..e1922b57 100644 --- a/src/channels/plugins/telegram.ts +++ b/src/channels/plugins/telegram.ts @@ -6,8 +6,9 @@ * - Group chats: only messages that @mention the bot or reply to the bot */ -import { Bot } from "grammy"; +import { Bot, GrammyError } from "grammy"; import type { ChannelPlugin, ChannelMessage, ChannelConfigAdapter, ChannelsConfig, DeliveryContext } from "../types.js"; +import { markdownToTelegramHtml } from "./telegram-format.js"; /** Telegram account config shape */ interface TelegramAccountConfig { @@ -17,6 +18,31 @@ interface TelegramAccountConfig { /** 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 */ +async function sendFormatted( + bot: Bot, + chatId: number, + text: string, + extra?: Record, +): Promise { + const html = markdownToTelegramHtml(text); + try { + await bot.api.sendMessage(chatId, html, { ...extra, parse_mode: "HTML" }); + } catch (err) { + if (isParseError(err)) { + console.warn("[Telegram] HTML parse failed, retrying as plain text"); + await bot.api.sendMessage(chatId, text, extra); + } else { + throw err; + } + } +} + export const telegramChannel: ChannelPlugin = { id: "telegram", meta: { @@ -125,7 +151,7 @@ export const telegramChannel: ChannelPlugin = { if (!bot) throw new Error(`No Telegram bot for account ${ctx.accountId}`); console.log(`[Telegram] Sending message to chatId=${ctx.conversationId}`); - await bot.api.sendMessage(Number(ctx.conversationId), text); + await sendFormatted(bot, Number(ctx.conversationId), text); }, async replyText(ctx: DeliveryContext, text: string): Promise { @@ -134,12 +160,23 @@ export const telegramChannel: ChannelPlugin = { if (ctx.replyToMessageId) { console.log(`[Telegram] Sending reply to chatId=${ctx.conversationId} (replyTo=${ctx.replyToMessageId})`); - await bot.api.sendMessage(Number(ctx.conversationId), text, { + 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 + } + }, }, }; diff --git a/src/channels/types.ts b/src/channels/types.ts index 32f165fb..0832e6ed 100644 --- a/src/channels/types.ts +++ b/src/channels/types.ts @@ -73,6 +73,8 @@ export interface ChannelOutboundAdapter { sendText(ctx: DeliveryContext, text: string): Promise; /** Reply to a specific message */ replyText(ctx: DeliveryContext, text: string): Promise; + /** Send "typing" indicator (optional, not all platforms support it) */ + sendTyping?(ctx: DeliveryContext): Promise; } // ─── Channel Plugin ───