From ffe75f3e20e2b10c3bb0ccf748c18b427e0d3f41 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 23 Dec 2025 02:25:26 +0100 Subject: [PATCH] =?UTF-8?q?=F0=9F=A4=96=20codex:=20add=20telegram=20reply?= =?UTF-8?q?=20context?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit # Conflicts: # src/telegram/bot.ts --- src/auto-reply/templating.ts | 3 ++ src/telegram/bot.test.ts | 69 ++++++++++++++++++++++++++++++ src/telegram/bot.ts | 82 +++++++++++++++++++++++++++++------- 3 files changed, 138 insertions(+), 16 deletions(-) diff --git a/src/auto-reply/templating.ts b/src/auto-reply/templating.ts index 14a4a76e6..964c0aa47 100644 --- a/src/auto-reply/templating.ts +++ b/src/auto-reply/templating.ts @@ -3,6 +3,9 @@ export type MsgContext = { From?: string; To?: string; MessageSid?: string; + ReplyToId?: string; + ReplyToBody?: string; + ReplyToSender?: string; MediaPath?: string; MediaUrl?: string; MediaType?: string; diff --git a/src/telegram/bot.test.ts b/src/telegram/bot.test.ts index b012ff565..a968329ca 100644 --- a/src/telegram/bot.test.ts +++ b/src/telegram/bot.test.ts @@ -6,13 +6,16 @@ const useSpy = vi.fn(); const onSpy = vi.fn(); const stopSpy = vi.fn(); const sendChatActionSpy = vi.fn(); +const sendMessageSpy = vi.fn(async () => ({ message_id: 77 })); type ApiStub = { config: { use: (arg: unknown) => void }; sendChatAction: typeof sendChatActionSpy; + sendMessage: typeof sendMessageSpy; }; const apiStub: ApiStub = { config: { use: useSpy }, sendChatAction: sendChatActionSpy, + sendMessage: sendMessageSpy, }; vi.mock("grammy", () => ({ @@ -107,4 +110,70 @@ describe("createTelegramBot", () => { expect(sendChatActionSpy).toHaveBeenCalledWith(42, "typing"); }); + + it("includes reply-to context when a Telegram reply is received", async () => { + onSpy.mockReset(); + sendMessageSpy.mockReset(); + const replySpy = replyModule.__replySpy as unknown as ReturnType< + typeof vi.fn + >; + replySpy.mockReset(); + + createTelegramBot({ token: "tok" }); + const handler = onSpy.mock.calls[0][1] as ( + ctx: Record, + ) => Promise; + + await handler({ + message: { + chat: { id: 7, type: "private" }, + text: "Sure, see below", + date: 1736380800, + reply_to_message: { + message_id: 9001, + text: "Can you summarize this?", + from: { first_name: "Ada" }, + }, + }, + me: { username: "clawdis_bot" }, + getFile: async () => ({ download: async () => new Uint8Array() }), + }); + + expect(replySpy).toHaveBeenCalledTimes(1); + const payload = replySpy.mock.calls[0][0]; + expect(payload.Body).not.toContain("Reply to Ada: Can you summarize this?"); + expect(payload.ReplyToId).toBe("9001"); + expect(payload.ReplyToBody).toBe("Can you summarize this?"); + expect(payload.ReplyToSender).toBe("Ada"); + }); + + it("sends replies as native replies without chaining", async () => { + onSpy.mockReset(); + sendMessageSpy.mockReset(); + const replySpy = replyModule.__replySpy as unknown as ReturnType< + typeof vi.fn + >; + replySpy.mockReset(); + replySpy.mockResolvedValue({ text: "a".repeat(4500) }); + + createTelegramBot({ token: "tok" }); + const handler = onSpy.mock.calls[0][1] as ( + ctx: Record, + ) => Promise; + await handler({ + message: { + chat: { id: 5, type: "private" }, + text: "hi", + date: 1736380800, + message_id: 101, + }, + me: { username: "clawdis_bot" }, + getFile: async () => ({ download: async () => new Uint8Array() }), + }); + + expect(sendMessageSpy.mock.calls.length).toBeGreaterThan(1); + for (const call of sendMessageSpy.mock.calls) { + expect(call[2]?.reply_to_message_id).toBe(101); + } + }); }); diff --git a/src/telegram/bot.ts b/src/telegram/bot.ts index 6f3e812e1..d4dfe7a7b 100644 --- a/src/telegram/bot.ts +++ b/src/telegram/bot.ts @@ -11,8 +11,7 @@ import { getReplyFromConfig } from "../auto-reply/reply.js"; import type { ReplyPayload } from "../auto-reply/types.js"; import { loadConfig } from "../config/config.js"; import { resolveStorePath, updateLastRoute } from "../config/sessions.js"; -import { danger, logVerbose } from "../globals.js"; -import { formatErrorMessage } from "../infra/errors.js"; +import { danger, isVerbose, logVerbose } from "../globals.js"; import { getChildLogger } from "../logging.js"; import { mediaKindFromMime } from "../media/constants.js"; import { detectMime } from "../media/mime.js"; @@ -117,6 +116,7 @@ export function createTelegramBot(opts: TelegramBotOptions) { opts.token, opts.proxyFetch, ); + const replyTarget = describeReplyTarget(msg); const rawBody = ( msg.text ?? msg.caption ?? @@ -124,7 +124,6 @@ export function createTelegramBot(opts: TelegramBotOptions) { "" ).trim(); if (!rawBody) return; - const body = formatAgentEnvelope({ surface: "Telegram", from: isGroup @@ -143,12 +142,22 @@ export function createTelegramBot(opts: TelegramBotOptions) { SenderName: buildSenderName(msg), Surface: "telegram", MessageSid: String(msg.message_id), + ReplyToId: replyTarget?.id, + ReplyToBody: replyTarget?.body, + ReplyToSender: replyTarget?.sender, Timestamp: msg.date ? msg.date * 1000 : undefined, MediaPath: media?.path, MediaType: media?.contentType, MediaUrl: media?.path, }; + if (replyTarget && isVerbose()) { + const preview = replyTarget.body.replace(/\s+/g, " ").slice(0, 120); + logVerbose( + `telegram reply-context: replyToId=${replyTarget.id} replyToSender=${replyTarget.sender} replyToBody="${preview}"`, + ); + } + if (!isGroup) { const sessionCfg = cfg.inbound?.session; const mainKey = (sessionCfg?.mainKey ?? "main").trim() || "main"; @@ -161,7 +170,7 @@ export function createTelegramBot(opts: TelegramBotOptions) { }); } - if (logVerbose()) { + if (isVerbose()) { const preview = body.slice(0, 200).replace(/\n/g, "\\n"); logVerbose( `telegram inbound: chatId=${chatId} from=${ctxPayload.From} len=${body.length} preview="${preview}"`, @@ -186,6 +195,7 @@ export function createTelegramBot(opts: TelegramBotOptions) { token: opts.token, runtime, bot, + replyToMessageId: msg.message_id, }); } catch (err) { runtime.error?.(danger(`Telegram handler failed: ${String(err)}`)); @@ -208,8 +218,10 @@ async function deliverReplies(params: { token: string; runtime: RuntimeEnv; bot: Bot; + replyToMessageId?: number; }) { const { replies, chatId, runtime, bot } = params; + const replyTarget = params.replyToMessageId; for (const reply of replies) { if (!reply?.text && !reply?.mediaUrl && !(reply?.mediaUrls?.length ?? 0)) { runtime.error?.(danger("Telegram reply missing text/media")); @@ -220,9 +232,14 @@ async function deliverReplies(params: { : reply.mediaUrl ? [reply.mediaUrl] : []; + if (replyTarget && isVerbose()) { + logVerbose( + `telegram reply-send: chatId=${chatId} replyToMessageId=${replyTarget} kind=${mediaList.length ? "media" : "text"}`, + ); + } if (mediaList.length === 0) { for (const chunk of chunkText(reply.text || "", 4000)) { - await sendTelegramText(bot, chatId, chunk, runtime); + await sendTelegramText(bot, chatId, chunk, runtime, replyTarget); } continue; } @@ -234,14 +251,18 @@ async function deliverReplies(params: { const file = new InputFile(media.buffer, media.fileName ?? "file"); const caption = first ? (reply.text ?? undefined) : undefined; first = false; + const replyOpts = replyTarget ? { reply_to_message_id: replyTarget } : {}; if (kind === "image") { - await bot.api.sendPhoto(chatId, file, { caption }); + await bot.api.sendPhoto(chatId, file, { caption, ...replyOpts }); } else if (kind === "video") { - await bot.api.sendVideo(chatId, file, { caption }); + await bot.api.sendVideo(chatId, file, { caption, ...replyOpts }); } else if (kind === "audio") { - await bot.api.sendAudio(chatId, file, { caption }); + await bot.api.sendAudio(chatId, file, { caption, ...replyOpts }); } else { - await bot.api.sendDocument(chatId, file, { caption }); + await bot.api.sendDocument(chatId, file, { + caption, + ...replyOpts, + }); } } } @@ -338,18 +359,47 @@ async function sendTelegramText( chatId: string, text: string, runtime: RuntimeEnv, -) { + replyToMessageId?: number, +): Promise { try { - await bot.api.sendMessage(chatId, text, { parse_mode: "Markdown" }); + const res = await bot.api.sendMessage(chatId, text, { + parse_mode: "Markdown", + reply_to_message_id: replyToMessageId, + }); + return res.message_id; } catch (err) { - const errText = formatErrorMessage(err); - if (PARSE_ERR_RE.test(errText)) { + if (PARSE_ERR_RE.test(String(err ?? ""))) { runtime.log?.( - `telegram markdown parse failed; retrying without formatting: ${errText}`, + `telegram markdown parse failed; retrying without formatting: ${String( + err, + )}`, ); - await bot.api.sendMessage(chatId, text); - return; + const res = await bot.api.sendMessage(chatId, text, { + reply_to_message_id: replyToMessageId, + }); + return res.message_id; } throw err; } } + +function describeReplyTarget(msg: TelegramMessage) { + const reply = msg.reply_to_message as TelegramMessage | undefined; + if (!reply) return null; + const replyBody = (reply.text ?? reply.caption ?? "").trim(); + let body = replyBody; + if (!body) { + if (reply.photo) body = ""; + else if (reply.video) body = ""; + else if (reply.audio || reply.voice) body = ""; + else if (reply.document) body = ""; + } + if (!body) return null; + const sender = buildSenderName(reply); + const senderLabel = sender ? `${sender}` : "unknown sender"; + return { + id: reply.message_id ? String(reply.message_id) : undefined, + sender: senderLabel, + body, + }; +}