From 70f84286c9ae20fd47dcf775b55ebb99296794de Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Thu, 6 Aug 2026 08:41:02 -0700 Subject: [PATCH] fix(telegram): preserve inbound quote attribution Preserve native Telegram quote attribution in agent-visible input and terminate quotes before following ordinary text. Co-authored-by: Peter Steinberger --- .../src/bot-message-context.body.test.ts | 10 +- .../telegram/src/bot-message-context.body.ts | 2 +- ...ot-message-context.forwarded-batch.test.ts | 21 +- .../src/bot-message-context.session.ts | 2 +- .../src/bot/body-helpers.inbound.test.ts | 42 ++- extensions/telegram/src/bot/body-helpers.ts | 154 +---------- extensions/telegram/src/bot/helpers.test.ts | 17 +- .../telegram/src/bot/inbound-text-entities.ts | 259 ++++++++++++++++++ 8 files changed, 328 insertions(+), 179 deletions(-) create mode 100644 extensions/telegram/src/bot/inbound-text-entities.ts diff --git a/extensions/telegram/src/bot-message-context.body.test.ts b/extensions/telegram/src/bot-message-context.body.test.ts index e47660b6b3e2..ff80e81eef8f 100644 --- a/extensions/telegram/src/bot-message-context.body.test.ts +++ b/extensions/telegram/src/bot-message-context.body.test.ts @@ -361,15 +361,17 @@ describe("resolveTelegramInboundBody", () => { privateBodyTest( "renders Telegram text entities before building the agent body", { - text: "Hello world docs", + text: "Hello world\nquoted\nordinary docs", entities: [ { type: "bold", offset: 6, length: 5 }, - { type: "text_link", offset: 12, length: 4, url: "https://docs.example" }, + { type: "blockquote", offset: 12, length: 6 }, + { type: "text_link", offset: 28, length: 4, url: "https://docs.example" }, ], }, (result) => { - expect(result?.rawBody).toBe("Hello **world** [docs](https://docs.example)"); - expect(result?.bodyText).toBe("Hello **world** [docs](https://docs.example)"); + const expected = "Hello **world**\n> quoted\n\nordinary [docs](https://docs.example)"; + expect(result?.rawBody).toBe(expected); + expect(result?.bodyText).toBe(expected); }, ); diff --git a/extensions/telegram/src/bot-message-context.body.ts b/extensions/telegram/src/bot-message-context.body.ts index 4f6e63f7a9a8..be1e08b652c8 100644 --- a/extensions/telegram/src/bot-message-context.body.ts +++ b/extensions/telegram/src/bot-message-context.body.ts @@ -47,12 +47,12 @@ import { hasLeadingBotCommandAddressedToOtherBot, hasBotMentionInText, hasBotMention, - renderTelegramTextEntities, resolveTelegramPrimaryMedia, resolveTelegramRichMessagePlaceholder, resolveTelegramRichMessageText, } from "./bot/body-helpers.js"; import { buildTelegramGroupPeerId, buildTelegramInboundOriginTarget } from "./bot/helpers.js"; +import { renderTelegramTextEntities } from "./bot/inbound-text-entities.js"; import type { TelegramContext } from "./bot/types.js"; import { isTelegramForumServiceMessage } from "./forum-service-message.js"; import { resolveTelegramGroupIngestEnabled } from "./group-config-helpers.js"; diff --git a/extensions/telegram/src/bot-message-context.forwarded-batch.test.ts b/extensions/telegram/src/bot-message-context.forwarded-batch.test.ts index 9b105b11e069..2d75448a7006 100644 --- a/extensions/telegram/src/bot-message-context.forwarded-batch.test.ts +++ b/extensions/telegram/src/bot-message-context.forwarded-batch.test.ts @@ -10,10 +10,11 @@ describe("buildTelegramMessageContext forwarded debounce batches", () => { message_id: 2, chat, from: sender, - text: "šŸ˜€ bold\nread docs", + text: "šŸ˜€ quoted\nsecond\nread docs", entities: [ - { type: "bold", offset: 3, length: 4 }, - { type: "text_link", offset: 13, length: 4, url: "https://docs.example" }, + { type: "blockquote", offset: 0, length: 16 }, + { type: "bold", offset: 3, length: 6 }, + { type: "text_link", offset: 22, length: 4, url: "https://docs.example" }, ], }, options: { @@ -23,8 +24,11 @@ describe("buildTelegramMessageContext forwarded debounce batches", () => { date: 1_700_000_000, chat, from: sender, - text: "šŸ˜€ bold", - entities: [{ type: "bold", offset: 3, length: 4 }], + text: "šŸ˜€ quoted\nsecond", + entities: [ + { type: "blockquote", offset: 0, length: 16 }, + { type: "bold", offset: 3, length: 6 }, + ], }, { message_id: 2, @@ -38,9 +42,10 @@ describe("buildTelegramMessageContext forwarded debounce batches", () => { }, }); - expect(context?.ctxPayload.RawBody).toBe("šŸ˜€ **bold**\nread [docs](https://docs.example)"); - expect(context?.ctxPayload.BodyForAgent).toBe("šŸ˜€ **bold**\nread [docs](https://docs.example)"); - expect(context?.ctxPayload.CommandBody).toBe("šŸ˜€ **bold**\nread [docs](https://docs.example)"); + const expected = "> šŸ˜€ **quoted**\n> second\n\nread [docs](https://docs.example)"; + expect(context?.ctxPayload.RawBody).toBe(expected); + expect(context?.ctxPayload.BodyForAgent).toBe(expected); + expect(context?.ctxPayload.CommandBody).toBe(expected); }); it("keeps ordinary text plain while attributing only the forwarded segment", async () => { diff --git a/extensions/telegram/src/bot-message-context.session.ts b/extensions/telegram/src/bot-message-context.session.ts index a598c4fca607..bba7ebd71ff9 100644 --- a/extensions/telegram/src/bot-message-context.session.ts +++ b/extensions/telegram/src/bot-message-context.session.ts @@ -33,7 +33,7 @@ import type { TelegramMessageContextSessionRuntimeOverrides, TelegramPromptContextEntry, } from "./bot-message-context.types.js"; -import { renderTelegramTextEntities } from "./bot/body-helpers.js"; +import { renderTelegramTextEntities } from "./bot/inbound-text-entities.js"; import { resolveTelegramPromptMediaPath } from "./prompt-media-path.js"; type TelegramMentionFacts = NonNullable< diff --git a/extensions/telegram/src/bot/body-helpers.inbound.test.ts b/extensions/telegram/src/bot/body-helpers.inbound.test.ts index e92387dc810b..efbd9dcf854b 100644 --- a/extensions/telegram/src/bot/body-helpers.inbound.test.ts +++ b/extensions/telegram/src/bot/body-helpers.inbound.test.ts @@ -1,10 +1,7 @@ -import type { Message } from "grammy/types"; +import type { Message, MessageEntity } from "grammy/types"; import { describe, expect, it } from "vitest"; -import { - getTelegramTextParts, - joinTelegramTextParts, - renderTelegramTextEntities, -} from "./body-helpers.js"; +import { getTelegramTextParts, joinTelegramTextParts } from "./body-helpers.js"; +import { renderTelegramTextEntities } from "./inbound-text-entities.js"; function asTelegramMessage(message: unknown): Message { return message as Message; @@ -101,3 +98,36 @@ describe("joinTelegramTextParts", () => { }); }); }); + +describe("renderTelegramTextEntities quoted blocks", () => { + it.each(["blockquote", "expandable_blockquote"] as const)( + "preserves multiline %s entities and nested formatting", + (type) => { + const text = "Before\nšŸ˜€ quoted\nsecond link\nAfter"; + const quote = "šŸ˜€ quoted\nsecond link"; + const quoteOffset = text.indexOf(quote); + + const entities: MessageEntity[] = [ + { type, offset: quoteOffset, length: quote.length }, + { type: "bold", offset: quoteOffset + "šŸ˜€ ".length, length: "quoted".length }, + ]; + + expect(renderTelegramTextEntities(text, entities)).toBe( + "Before\n> šŸ˜€ **quoted**\n> second link\n\nAfter", + ); + }, + ); + + it("reopens enclosing formatting across a quote block", () => { + const text = "bold before\nquoted\nbold after"; + const quoteOffset = text.indexOf("quoted"); + const entities: MessageEntity[] = [ + { type: "bold", offset: 0, length: text.length }, + { type: "blockquote", offset: quoteOffset, length: "quoted".length }, + ]; + + expect(renderTelegramTextEntities(text, entities)).toBe( + "**bold before**\n> **quoted**\n\n**bold after**", + ); + }); +}); diff --git a/extensions/telegram/src/bot/body-helpers.ts b/extensions/telegram/src/bot/body-helpers.ts index eec086486176..07450d7196a7 100644 --- a/extensions/telegram/src/bot/body-helpers.ts +++ b/extensions/telegram/src/bot/body-helpers.ts @@ -10,6 +10,7 @@ import { normalizeOptionalString, } from "openclaw/plugin-sdk/string-coerce-runtime"; import { telegramHtmlToPlainTextFallback } from "../format.js"; +import { renderTelegramTextEntities } from "./inbound-text-entities.js"; type TelegramMediaMessage = Pick< Message, @@ -350,159 +351,6 @@ export function hasBotMentionInText(text: string, botUsername: string): boolean ); } -type TelegramMarkdownEntity = { - type: string; - offset: number; - length: number; - url?: string; - language?: string; -}; - -type TelegramMarkdownBoundary = { - open: string; - close: string; - start: number; - end: number; - length: number; - priority: number; - index: number; -}; - -const TELEGRAM_ENTITY_MARKDOWN_PRIORITY: Record = { - bold: 10, - italic: 20, - underline: 30, - strikethrough: 40, - spoiler: 50, - text_link: 60, - code: 70, - pre: 80, -}; - -function longestBacktickRun(text: string): number { - let longest = 0; - let current = 0; - for (const char of text) { - if (char === "`") { - current += 1; - longest = Math.max(longest, current); - } else { - current = 0; - } - } - return longest; -} - -function markdownInlineCodeDelimiters(content: string): [string, string] { - const delimiter = "`".repeat(longestBacktickRun(content) + 1); - if (content.startsWith(" ") || content.endsWith(" ")) { - return [`${delimiter} `, ` ${delimiter}`]; - } - return [delimiter, delimiter]; -} - -function markdownPreAffixes(entity: TelegramMarkdownEntity, content: string): [string, string] { - const language = entity.language?.replace(/[\s`]+/g, "").trim(); - const fence = "`".repeat(Math.max(3, longestBacktickRun(content) + 1)); - const opener = language ? `${fence}${language}\n` : `${fence}\n`; - const closer = content.endsWith("\n") ? fence : `\n${fence}`; - return [opener, closer]; -} - -function markdownAffixesForTelegramEntity( - entity: TelegramMarkdownEntity, - content: string, -): [string, string] | null { - switch (entity.type) { - case "bold": - return ["**", "**"]; - case "italic": - return ["_", "_"]; - case "underline": - return ["__", "__"]; - case "strikethrough": - return ["~~", "~~"]; - case "spoiler": - return ["||", "||"]; - case "code": - return markdownInlineCodeDelimiters(content); - case "pre": - return markdownPreAffixes(entity, content); - case "text_link": - return entity.url ? ["[", `](${entity.url})`] : null; - default: - return null; - } -} - -export function renderTelegramTextEntities( - text: string, - entities?: TelegramMarkdownEntity[] | null, -): string { - if (!text || !entities?.length) { - return text; - } - - const boundaries = new Map(); - const addBoundary = (offset: number, boundary: TelegramMarkdownBoundary) => { - boundaries.set(offset, [...(boundaries.get(offset) ?? []), boundary]); - }; - entities.forEach((entity, index) => { - if ( - !Number.isInteger(entity.offset) || - !Number.isInteger(entity.length) || - entity.offset < 0 || - entity.length <= 0 || - entity.offset + entity.length > text.length - ) { - return; - } - const content = text.slice(entity.offset, entity.offset + entity.length); - const affixes = markdownAffixesForTelegramEntity(entity, content); - if (!affixes) { - return; - } - const boundary: TelegramMarkdownBoundary = { - open: affixes[0], - close: affixes[1], - start: entity.offset, - end: entity.offset + entity.length, - length: entity.length, - priority: TELEGRAM_ENTITY_MARKDOWN_PRIORITY[entity.type] ?? 100, - index, - }; - addBoundary(boundary.start, boundary); - addBoundary(boundary.end, boundary); - }); - - if (boundaries.size === 0) { - return text; - } - - let result = ""; - for (let offset = 0; offset <= text.length; offset += 1) { - const boundary = boundaries.get(offset); - if (boundary) { - boundary - .filter((entity) => entity.end === offset) - .toSorted((a, b) => a.length - b.length || b.priority - a.priority || b.index - a.index) - .forEach((entity) => { - result += entity.close; - }); - boundary - .filter((entity) => entity.start === offset) - .toSorted((a, b) => b.length - a.length || a.priority - b.priority || a.index - b.index) - .forEach((entity) => { - result += entity.open; - }); - } - if (offset < text.length) { - result += text[offset]; - } - } - return result; -} - export type TelegramForwardedContext = { from: string; date?: number; diff --git a/extensions/telegram/src/bot/helpers.test.ts b/extensions/telegram/src/bot/helpers.test.ts index 34ccecc0677b..88db205d2b62 100644 --- a/extensions/telegram/src/bot/helpers.test.ts +++ b/extensions/telegram/src/bot/helpers.test.ts @@ -1,6 +1,6 @@ // Telegram tests cover helpers plugin behavior. +import type { MessageEntity } from "grammy/types"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { renderTelegramTextEntities } from "./body-helpers.js"; import { buildTelegramInboundOriginTarget, buildTelegramRoutingTarget, @@ -18,6 +18,7 @@ import { resetTelegramForumFlagCacheForTest, shouldUseTelegramDmThreadSession, } from "./helpers.js"; +import { renderTelegramTextEntities } from "./inbound-text-entities.js"; type TelegramMessage = Parameters[0]; @@ -1015,7 +1016,7 @@ describe("renderTelegramTextEntities", () => { { type: "strikethrough", offset: 17, length: 6 }, { type: "underline", offset: 24, length: 9 }, { type: "spoiler", offset: 34, length: 7 }, - ]; + ] satisfies MessageEntity[]; expect(renderTelegramTextEntities(text, entities)).toBe( "**bold** _italic_ `code` ~~strike~~ __underline__ ||spoiler||", @@ -1024,14 +1025,18 @@ describe("renderTelegramTextEntities", () => { it("renders pre entities with language fences", () => { const text = "const value = 1;"; - const entities = [{ type: "pre", offset: 0, length: text.length, language: "ts" }]; + const entities = [ + { type: "pre", offset: 0, length: text.length, language: "ts" }, + ] satisfies MessageEntity[]; expect(renderTelegramTextEntities(text, entities)).toBe("```ts\nconst value = 1;\n```"); }); it("uses a pre fence that cannot close inside content", () => { const text = "before\n```\ninside"; - const entities = [{ type: "pre", offset: 0, length: text.length, language: "md" }]; + const entities = [ + { type: "pre", offset: 0, length: text.length, language: "md" }, + ] satisfies MessageEntity[]; expect(renderTelegramTextEntities(text, entities)).toBe("````md\nbefore\n```\ninside\n````"); }); @@ -1042,7 +1047,7 @@ describe("renderTelegramTextEntities", () => { { type: "bold", offset: 5, length: 4 }, { type: "text_link", offset: 5, length: 4, url: "https://docs.example" }, { type: "italic", offset: 10, length: 3 }, - ]; + ] satisfies MessageEntity[]; expect(renderTelegramTextEntities(text, entities)).toBe( "Read **[docs](https://docs.example)** _now_", @@ -1051,7 +1056,7 @@ describe("renderTelegramTextEntities", () => { it("uses UTF-16 Telegram offsets", () => { const text = "Hi šŸ˜€ bold"; - const entities = [{ type: "bold", offset: 6, length: 4 }]; + const entities = [{ type: "bold", offset: 6, length: 4 }] satisfies MessageEntity[]; expect(renderTelegramTextEntities(text, entities)).toBe("Hi šŸ˜€ **bold**"); }); diff --git a/extensions/telegram/src/bot/inbound-text-entities.ts b/extensions/telegram/src/bot/inbound-text-entities.ts new file mode 100644 index 000000000000..2a5cc6c037c4 --- /dev/null +++ b/extensions/telegram/src/bot/inbound-text-entities.ts @@ -0,0 +1,259 @@ +import type { MessageEntity } from "grammy/types"; + +type TelegramMarkdownBoundary = { + open: string; + close: string; + start: number; + end: number; + length: number; + priority: number; + index: number; +}; + +const TELEGRAM_ENTITY_MARKDOWN_PRIORITY: Partial> = { + blockquote: 0, + expandable_blockquote: 0, + bold: 10, + italic: 20, + underline: 30, + strikethrough: 40, + spoiler: 50, + text_link: 60, + code: 70, + pre: 80, +}; + +const SPLITTABLE_FORMATTING_ENTITY_TYPES = new Set([ + "bold", + "italic", + "underline", + "strikethrough", + "spoiler", +]); + +function isTelegramBlockquoteEntity(entity: MessageEntity): boolean { + return entity.type === "blockquote" || entity.type === "expandable_blockquote"; +} + +function hasValidTelegramEntityRange(text: string, entity: MessageEntity): boolean { + return ( + Number.isInteger(entity.offset) && + Number.isInteger(entity.length) && + entity.offset >= 0 && + entity.length > 0 && + entity.offset + entity.length <= text.length + ); +} + +function longestBacktickRun(text: string): number { + let longest = 0; + let current = 0; + for (const char of text) { + if (char === "`") { + current += 1; + longest = Math.max(longest, current); + } else { + current = 0; + } + } + return longest; +} + +function markdownInlineCodeDelimiters(content: string): [string, string] { + const delimiter = "`".repeat(longestBacktickRun(content) + 1); + if (content.startsWith(" ") || content.endsWith(" ")) { + return [`${delimiter} `, ` ${delimiter}`]; + } + return [delimiter, delimiter]; +} + +function markdownPreAffixes( + entity: Extract, + content: string, +): [string, string] { + const language = entity.language?.replace(/[\s`]+/g, "").trim(); + const fence = "`".repeat(Math.max(3, longestBacktickRun(content) + 1)); + const opener = language ? `${fence}${language}\n` : `${fence}\n`; + const closer = content.endsWith("\n") ? fence : `\n${fence}`; + return [opener, closer]; +} + +function markdownAffixesForTelegramEntity( + entity: MessageEntity, + content: string, +): [string, string] | null { + switch (entity.type) { + case "blockquote": + case "expandable_blockquote": + return ["> ", ""]; + case "bold": + return ["**", "**"]; + case "italic": + return ["_", "_"]; + case "underline": + return ["__", "__"]; + case "strikethrough": + return ["~~", "~~"]; + case "spoiler": + return ["||", "||"]; + case "code": + return markdownInlineCodeDelimiters(content); + case "pre": + return markdownPreAffixes(entity, content); + case "text_link": + return ["[", `](${entity.url})`]; + default: + return null; + } +} + +function splitTelegramFormattingAtQuoteEdges( + text: string, + entity: MessageEntity, + quoteEdges: readonly number[], +): MessageEntity[] { + if (!SPLITTABLE_FORMATTING_ENTITY_TYPES.has(entity.type)) { + return [entity]; + } + const entityEnd = entity.offset + entity.length; + const interiorEdges = quoteEdges.filter((offset) => entity.offset < offset && offset < entityEnd); + if (interiorEdges.length === 0) { + return [entity]; + } + + // Markdown formatting cannot cross a block boundary. Reopen it around each quote. + const segments: MessageEntity[] = []; + let segmentStart = entity.offset; + for (const edge of [...interiorEdges, entityEnd]) { + let segmentEnd = edge; + while (segmentStart < segmentEnd && /\s/u.test(text.charAt(segmentStart))) { + segmentStart += 1; + } + while (segmentEnd > segmentStart && /\s/u.test(text.charAt(segmentEnd - 1))) { + segmentEnd -= 1; + } + if (segmentStart < segmentEnd) { + segments.push({ ...entity, offset: segmentStart, length: segmentEnd - segmentStart }); + } + segmentStart = edge; + } + return segments; +} + +function resolveTelegramBlockquoteClose(text: string, start: number, end: number): string { + let presentBreaks = 0; + let offset = end; + while (offset > start && text.charAt(offset - 1) === "\n") { + presentBreaks += 1; + offset -= text.charAt(offset - 2) === "\r" ? 2 : 1; + } + offset = end; + while (offset < text.length) { + if (text.charAt(offset) === "\n") { + presentBreaks += 1; + offset += 1; + } else if (text.charAt(offset) === "\r" && text.charAt(offset + 1) === "\n") { + presentBreaks += 1; + offset += 2; + } else { + break; + } + } + const requiredBreaks = end < text.length ? 2 : 1; + const missingBreaks = requiredBreaks - presentBreaks; + const lineBreak = text.charAt(end) === "\r" || text.charAt(end - 2) === "\r" ? "\r\n" : "\n"; + return lineBreak.repeat(Math.max(0, missingBreaks)); +} + +export function renderTelegramTextEntities( + text: string, + entities?: readonly MessageEntity[] | null, +): string { + if (!text || !entities?.length) { + return text; + } + + const quotedLineStarts = new Set(); + const quoteEdges = new Set(); + for (const entity of entities) { + if (!isTelegramBlockquoteEntity(entity) || !hasValidTelegramEntityRange(text, entity)) { + continue; + } + const end = entity.offset + entity.length; + quoteEdges.add(entity.offset); + quoteEdges.add(end); + for (let offset = entity.offset + 1; offset < end; offset += 1) { + if (text[offset - 1] === "\n") { + quotedLineStarts.add(offset); + } + } + } + + const sortedQuoteEdges = [...quoteEdges].toSorted((left, right) => left - right); + const boundaries = new Map(); + const addBoundary = (offset: number, boundary: TelegramMarkdownBoundary) => { + const entries = boundaries.get(offset); + if (entries) { + entries.push(boundary); + } else { + boundaries.set(offset, [boundary]); + } + }; + entities.forEach((entity, index) => { + if (!hasValidTelegramEntityRange(text, entity)) { + return; + } + for (const segment of splitTelegramFormattingAtQuoteEdges(text, entity, sortedQuoteEdges)) { + const content = text.slice(segment.offset, segment.offset + segment.length); + const affixes = markdownAffixesForTelegramEntity(segment, content); + if (!affixes) { + continue; + } + const end = segment.offset + segment.length; + if (isTelegramBlockquoteEntity(segment)) { + affixes[1] = resolveTelegramBlockquoteClose(text, segment.offset, end); + } + const boundary: TelegramMarkdownBoundary = { + open: affixes[0], + close: affixes[1], + start: segment.offset, + end, + length: segment.length, + priority: TELEGRAM_ENTITY_MARKDOWN_PRIORITY[segment.type] ?? 100, + index, + }; + addBoundary(boundary.start, boundary); + addBoundary(boundary.end, boundary); + } + }); + + if (boundaries.size === 0) { + return text; + } + + let result = ""; + for (let offset = 0; offset <= text.length; offset += 1) { + if (quotedLineStarts.has(offset)) { + result += "> "; + } + const boundary = boundaries.get(offset); + if (boundary) { + boundary + .filter((entity) => entity.end === offset) + .toSorted((a, b) => a.length - b.length || b.priority - a.priority || b.index - a.index) + .forEach((entity) => { + result += entity.close; + }); + boundary + .filter((entity) => entity.start === offset) + .toSorted((a, b) => b.length - a.length || a.priority - b.priority || a.index - b.index) + .forEach((entity) => { + result += entity.open; + }); + } + if (offset < text.length) { + result += text[offset]; + } + } + return result; +}