feat(channels): add typing indicators and Telegram HTML formatting

- Add sendTyping to ChannelOutboundAdapter (optional per platform)
- Implement typing lifecycle in ChannelManager (5s interval, cleanup on message_end/error/clear)
- Convert Markdown to Telegram HTML subset (bold, italic, code, links, blockquotes)
- Fallback to plain text on HTML parse errors

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Naiyuan Qing 2026-02-09 08:37:34 +08:00
parent 54e2ac4a17
commit ceb960c390
4 changed files with 149 additions and 3 deletions

View file

@ -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<typeof setInterval> | 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;
}

View file

@ -0,0 +1,79 @@
/**
* Markdown Telegram HTML converter.
*
* Telegram supports a subset of HTML:
* <b>, <i>, <u>, <s>, <code>, <pre>, <a href="...">, <blockquote>
*
* 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, "&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. Inline code: `...`
text = text.replace(/`([^`\n]+)`/g, (_match, code: string) => {
return placeholder(`<code>${escapeHtml(code)}</code>`);
});
// 3. Escape HTML in remaining text
text = escapeHtml(text);
// 4. Links: [text](url)
text = text.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '<a href="$2">$1</a>');
// 5. Bold: **text** or __text__
text = text.replace(/\*\*(.+?)\*\*/g, "<b>$1</b>");
text = text.replace(/__(.+?)__/g, "<b>$1</b>");
// 6. 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>");
// 7. Strikethrough: ~~text~~
text = text.replace(/~~(.+?)~~/g, "<s>$1</s>");
// 8. Blockquotes: > text (at line start)
text = text.replace(/^&gt; (.+)$/gm, "<blockquote>$1</blockquote>");
// Merge adjacent blockquotes
text = text.replace(/<\/blockquote>\n<blockquote>/g, "\n");
// 9. 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

@ -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<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 */
async function sendFormatted(
bot: Bot,
chatId: number,
text: string,
extra?: Record<string, unknown>,
): Promise<void> {
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<void> {
@ -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<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
}
},
},
};

View file

@ -73,6 +73,8 @@ export interface ChannelOutboundAdapter {
sendText(ctx: DeliveryContext, text: string): Promise<void>;
/** Reply to a specific message */
replyText(ctx: DeliveryContext, text: string): Promise<void>;
/** Send "typing" indicator (optional, not all platforms support it) */
sendTyping?(ctx: DeliveryContext): Promise<void>;
}
// ─── Channel Plugin ───