From 38ca7cbfeaafea812d87af1e919d446410ce7568 Mon Sep 17 00:00:00 2001 From: EugeneTes Date: Tue, 22 Sep 2026 08:42:59 +0000 Subject: [PATCH] Add buildReplyHeader helper with truncation and media descriptors --- src/telegram/inbound.test.ts | 111 ++++++++++++++++++++++++++++++++++- src/telegram/inbound.ts | 40 +++++++++++++ 2 files changed, 150 insertions(+), 1 deletion(-) diff --git a/src/telegram/inbound.test.ts b/src/telegram/inbound.test.ts index 5cc7e5f..f8e1446 100644 --- a/src/telegram/inbound.test.ts +++ b/src/telegram/inbound.test.ts @@ -3,7 +3,7 @@ import os from "node:os"; import path from "node:path"; import { afterEach, beforeEach, describe, expect, it } from "vitest"; import type { Transcriber } from "../stt/openai.js"; -import { type InboundDeps, normalizeMessage, saveToInbox } from "./inbound.js"; +import { buildReplyHeader, type InboundDeps, normalizeMessage, saveToInbox } from "./inbound.js"; describe("normalizeMessage", () => { let dir: string; @@ -177,3 +177,112 @@ describe("saveToInbox", () => { } }); }); + +describe("buildReplyHeader", () => { + const BOT_ID = 42; + + it("returns undefined when there is no reply and no quote", () => { + expect(buildReplyHeader(undefined, undefined, BOT_ID)).toBeUndefined(); + }); + + it("labels a plain reply to a bot message with 'your message'", () => { + const header = buildReplyHeader({ from: { id: BOT_ID, is_bot: true }, text: "Which file?" }, undefined, BOT_ID); + expect(header).toBe('[Reply to your message: "Which file?"]'); + }); + + it("labels a plain reply to the owner's own earlier message", () => { + const header = buildReplyHeader({ from: { id: 99, is_bot: false }, text: "Buy milk" }, undefined, BOT_ID); + expect(header).toBe('[Reply to your own earlier message: "Buy milk"]'); + }); + + it("falls back to 'the earlier message' when `from` is absent", () => { + const header = buildReplyHeader({ text: "hello" }, undefined, BOT_ID); + expect(header).toBe('[Reply to the earlier message: "hello"]'); + }); + + it("uses the quote text and 'quoting your message' wording for a reply-with-quote to the bot", () => { + const header = buildReplyHeader( + { from: { id: BOT_ID, is_bot: true }, text: "The full assistant answer with lots of context." }, + { text: "assistant answer" }, + BOT_ID, + ); + expect(header).toBe('[Reply quoting your message: "assistant answer"]'); + }); + + it("uses 'quoting your own earlier message' when the source is the owner's own", () => { + const header = buildReplyHeader( + { from: { id: 99, is_bot: false }, text: "shopping list" }, + { text: "shopping" }, + BOT_ID, + ); + expect(header).toBe('[Reply quoting your own earlier message: "shopping"]'); + }); + + it("treats an empty quote.text as if there were no quote (falls through to plain reply)", () => { + const header = buildReplyHeader( + { from: { id: BOT_ID, is_bot: true }, text: "The answer" }, + { text: "" }, + BOT_ID, + ); + expect(header).toBe('[Reply to your message: "The answer"]'); + }); + + it("describes a photo-only replied-to message", () => { + const header = buildReplyHeader({ from: { id: BOT_ID, is_bot: true }, photo: [{}] }, undefined, BOT_ID); + expect(header).toBe('[Reply to your message: "(photo)"]'); + }); + + it("describes a voice-only replied-to message", () => { + const header = buildReplyHeader({ from: { id: BOT_ID, is_bot: true }, voice: {} }, undefined, BOT_ID); + expect(header).toBe('[Reply to your message: "(voice message)"]'); + }); + + it("describes a document with a filename", () => { + const header = buildReplyHeader( + { from: { id: BOT_ID, is_bot: true }, document: { file_name: "notes.txt" } }, + undefined, + BOT_ID, + ); + expect(header).toBe('[Reply to your message: "(document: notes.txt)"]'); + }); + + it("describes an unnamed document", () => { + const header = buildReplyHeader({ from: { id: BOT_ID, is_bot: true }, document: {} }, undefined, BOT_ID); + expect(header).toBe('[Reply to your message: "(document)"]'); + }); + + it("uses the caption instead of the media descriptor when both are present", () => { + const header = buildReplyHeader( + { from: { id: BOT_ID, is_bot: true }, photo: [{}], caption: "the sunset" }, + undefined, + BOT_ID, + ); + expect(header).toBe('[Reply to your message: "the sunset"]'); + }); + + it("truncates replied-to text longer than 500 characters with an ellipsis", () => { + const long = "a".repeat(600); + const header = buildReplyHeader({ from: { id: BOT_ID, is_bot: true }, text: long }, undefined, BOT_ID); + expect(header).toBe(`[Reply to your message: "${"a".repeat(499)}…"]`); + }); + + it("truncates a very long quote in the same way", () => { + const long = "b".repeat(600); + const header = buildReplyHeader( + { from: { id: BOT_ID, is_bot: true }, text: "irrelevant" }, + { text: long }, + BOT_ID, + ); + expect(header).toBe(`[Reply quoting your message: "${"b".repeat(499)}…"]`); + }); + + it("labels as 'your message' when botUserId is missing but is_bot is true", () => { + const header = buildReplyHeader({ from: { id: 0, is_bot: true }, text: "hi" }, undefined, undefined); + expect(header).toBe('[Reply to your message: "hi"]'); + }); + + it("falls back to '(message)' when the replied-to message has no usable fields", () => { + const header = buildReplyHeader({ from: { id: BOT_ID, is_bot: true } }, undefined, BOT_ID); + expect(header).toBe('[Reply to your message: "(message)"]'); + }); +}); diff --git a/src/telegram/inbound.ts b/src/telegram/inbound.ts index 10fa3ee..7fc12b6 100644 --- a/src/telegram/inbound.ts +++ b/src/telegram/inbound.ts @@ -164,3 +164,43 @@ export async function saveToInbox(workspaceDir: string, originalName: string, by function errorText(err: unknown): string { return err instanceof Error ? err.message : String(err); } + +/** Builds the `[Reply ...]` header line for the agent, or returns undefined when there is nothing to reply to. */ +export function buildReplyHeader( + reply: RepliedMessage | undefined, + quote: Quote | undefined, + botUserId: number | undefined, +): string | undefined { + if (!reply) return undefined; + const isFromBot = + reply.from !== undefined && + (botUserId !== undefined ? reply.from.id === botUserId : reply.from.is_bot === true); + const isFromOwner = reply.from !== undefined && !isFromBot; + const who = isFromBot + ? "your message" + : isFromOwner + ? "your own earlier message" + : "the earlier message"; + const quoted = quote?.text ?? ""; + if (quoted.length > 0) { + return `[Reply quoting ${who}: "${truncateReplyContext(quoted)}"]`; + } + const content = truncateReplyContext(replyContent(reply)); + return `[Reply to ${who}: "${content}"]`; +} + +function replyContent(reply: RepliedMessage): string { + if (reply.text && reply.text.length > 0) return reply.text; + if (reply.caption && reply.caption.length > 0) return reply.caption; + if (reply.photo && reply.photo.length > 0) return "(photo)"; + if (reply.voice) return "(voice message)"; + if (reply.audio) return reply.audio.file_name ? `(audio: ${reply.audio.file_name})` : "(audio)"; + if (reply.document) return reply.document.file_name ? `(document: ${reply.document.file_name})` : "(document)"; + return "(message)"; +} + +function truncateReplyContext(text: string): string { + const chars = Array.from(text); + if (chars.length <= MAX_REPLY_CONTEXT_CHARS) return text; + return `${chars.slice(0, MAX_REPLY_CONTEXT_CHARS - 1).join("")}…`; +}