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 <steipete@macos.shared>
This commit is contained in:
Peter Steinberger
2026-08-06 08:41:02 -07:00
committed by GitHub
parent 9213792cdf
commit 70f84286c9
8 changed files with 328 additions and 179 deletions
@@ -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);
},
);
@@ -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";
@@ -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 () => {
@@ -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<
@@ -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**",
);
});
});
+1 -153
View File
@@ -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<string, number> = {
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<number, TelegramMarkdownBoundary[]>();
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;
+11 -6
View File
@@ -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<typeof normalizeForwardedContext>[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**");
});
@@ -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<Record<MessageEntity["type"], number>> = {
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<MessageEntity["type"]>([
"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<MessageEntity, { type: "pre" }>,
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<number>();
const quoteEdges = new Set<number>();
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<number, TelegramMarkdownBoundary[]>();
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;
}