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:
parent
54e2ac4a17
commit
ceb960c390
4 changed files with 149 additions and 3 deletions
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
79
src/channels/plugins/telegram-format.ts
Normal file
79
src/channels/plugins/telegram-format.ts
Normal 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, "&")
|
||||
.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. 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(/^> (.+)$/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;
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
},
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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 ───
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue