From 87a83fba1aeefcbf233a702e442a2e9c2acb7685 Mon Sep 17 00:00:00 2001 From: Jiayuan Zhang Date: Sun, 15 Feb 2026 21:14:56 +0800 Subject: [PATCH] feat(telegram): add reply context parsing and send new messages for tool narration - Extract reply_to_message text from Telegram replies and prepend as [Replying to: "..."] context so the agent can reference specific messages - Replace edit-message pattern with new messages for each tool narration, improving clarity in the chat flow - Remove dead code: sendOrEditStatus, editFormatted, statusMessages map Co-Authored-By: Claude Opus 4.6 --- apps/gateway/telegram/telegram.service.ts | 87 ++++++++--------------- 1 file changed, 28 insertions(+), 59 deletions(-) diff --git a/apps/gateway/telegram/telegram.service.ts b/apps/gateway/telegram/telegram.service.ts index 40c88427..5120ffbb 100644 --- a/apps/gateway/telegram/telegram.service.ts +++ b/apps/gateway/telegram/telegram.service.ts @@ -87,6 +87,7 @@ interface MediaAttachment { const VERIFY_TIMEOUT_MS = 30_000; const TYPING_TIMEOUT_MS = 60_000; const MAX_CHARS_PER_MESSAGE = 4000; // Telegram limit is 4096; leave room for HTML overhead +const REPLY_CONTEXT_MAX_CHARS = 300; // Max chars of quoted text when user replies to a message // ── Callback data identifiers ── @@ -103,6 +104,23 @@ function isParseError(err: unknown): boolean { return err instanceof GrammyError && err.description.includes("can't parse entities"); } +/** + * Extract reply context when a user replies to a specific message in Telegram. + * Returns a formatted annotation like `[Replying to: "original text..."]` or null. + */ +function extractReplyContext(ctx: Context): string | null { + const replyMsg = ctx.message?.reply_to_message; + if (!replyMsg) return null; + + const text = replyMsg.text || replyMsg.caption; + if (!text) return null; + + const truncated = text.length > REPLY_CONTEXT_MAX_CHARS + ? text.slice(0, REPLY_CONTEXT_MAX_CHARS) + "..." + : text; + return `[Replying to: "${truncated}"]`; +} + /** * Split text at natural boundaries so each chunk stays within Telegram's message limit. * Prefers paragraph breaks > line breaks > spaces > hard cut. @@ -152,8 +170,6 @@ export class TelegramService implements OnModuleInit, OnModuleDestroy { private typingTimers = new Map>(); /** Tracks the originating message for reply_to & reaction cleanup, keyed by deviceId */ private messageContexts = new Map(); - /** Editable status message IDs for tool narration, keyed by deviceId */ - private statusMessages = new Map(); private readonly logger = new Logger(TelegramService.name); @@ -632,7 +648,10 @@ export class TelegramService implements OnModuleInit, OnModuleDestroy { // ACK: 👀 reaction on the original message await this.addReaction(msg.chat.id, msg.message_id, "👀"); this.storeMessageContext(user.deviceId, msg.chat.id, msg.message_id); - await this.routeToHub(user, text, ctx); + // Prepend reply context if user is replying to a specific message + const replyContext = extractReplyContext(ctx); + const content = replyContext ? `${replyContext}\n${text}` : text; + await this.routeToHub(user, content, ctx); return; } @@ -673,7 +692,10 @@ export class TelegramService implements OnModuleInit, OnModuleDestroy { // Process media → text description (async, may take a few seconds) const processedText = await this.processMedia({ ...media, caption: caption ?? undefined }); - await this.routeToHub(user, processedText, ctx); + // Prepend reply context if user is replying to a specific message + const replyContext = extractReplyContext(ctx); + const content = replyContext ? `${replyContext}\n${processedText}` : processedText; + await this.routeToHub(user, content, ctx); } // ── Media processing ── @@ -826,58 +848,6 @@ export class TelegramService implements OnModuleInit, OnModuleDestroy { } } - /** - * Edit an existing message with HTML formatting, fallback to plain text. - */ - private async editFormatted( - chatId: number, - messageId: number, - text: string, - ): Promise { - if (!this.bot) return; - - const html = markdownToTelegramHtml(text); - try { - await this.bot.api.editMessageText(chatId, messageId, html, { parse_mode: "HTML" }); - } catch (err) { - if (isParseError(err)) { - this.logger.warn("HTML parse failed on edit, retrying as plain text"); - await this.bot.api.editMessageText(chatId, messageId, text); - } else { - throw err; - } - } - } - - /** - * Send or edit a status message for tool narration. - * First call sends a new reply; subsequent calls edit the same message. - */ - private async sendOrEditStatus(deviceId: string, text: string): Promise { - if (!this.bot) return; - - const user = await this.userStore.findByDeviceId(deviceId); - if (!user) return; - - const context = this.messageContexts.get(deviceId); - const chatId = context?.telegramChatId ?? Number(user.telegramUserId); - const existing = this.statusMessages.get(deviceId); - - try { - if (existing) { - await this.editFormatted(existing.chatId, existing.messageId, text); - } else { - const html = markdownToTelegramHtml(text); - const extra: Record = { parse_mode: "HTML" }; - if (context) extra["reply_to_message_id"] = context.telegramMessageId; - const msg = await this.bot.api.sendMessage(chatId, html, extra); - this.statusMessages.set(deviceId, { chatId, messageId: msg.message_id }); - } - } catch (err) { - this.logger.warn(`Failed to send/edit status: ${err}`); - } - } - /** Send a file (photo/document/video/audio) to a Telegram user */ private async sendFileToTelegram( deviceId: string, @@ -1023,7 +993,6 @@ export class TelegramService implements OnModuleInit, OnModuleDestroy { await this.removeReaction(context.telegramChatId, context.telegramMessageId); this.messageContexts.delete(deviceId); } - this.statusMessages.delete(deviceId); } // ── Connection & routing ── @@ -1276,8 +1245,8 @@ export class TelegramService implements OnModuleInit, OnModuleDestroy { .map((c) => c.text!) .join("") ?? ""; if (narration) { - void this.sendOrEditStatus(deviceId, narration).then(() => { - // Re-send typing indicator — Telegram clears it when a message is sent/edited + void this.sendToTelegram(deviceId, narration).then(() => { + // Re-send typing indicator — Telegram clears it when a message is sent const ctx = this.messageContexts.get(deviceId); if (ctx) { void this.bot?.api.sendChatAction(ctx.telegramChatId, "typing").catch(() => {});