fix(telegram): preserve inbound Markdown link destinations and labels (#128297)

This commit is contained in:
Peter Steinberger
2026-08-23 11:09:36 -07:00
committed by GitHub
parent 000aea1d7a
commit f256c63526
3 changed files with 148 additions and 2 deletions
@@ -1,4 +1,5 @@
import type { Message, MessageEntity } from "grammy/types";
import { markdownToIR } from "openclaw/plugin-sdk/text-chunking";
import { describe, expect, it } from "vitest";
import { getTelegramTextParts, joinTelegramTextParts } from "./body-helpers.js";
import { renderTelegramTextEntities } from "./inbound-text-entities.js";
@@ -50,6 +51,31 @@ describe("getTelegramTextParts", () => {
entities: [],
});
});
it("preserves native poll links with Markdown-sensitive labels and destinations", () => {
const label = "docs]more";
const url = "https://example.com/report)final";
const result = getTelegramTextParts(
asTelegramMessage({
poll: {
id: "poll-links",
question: "Review the report?",
options: [{ text: "Yes", voter_count: 1 }],
total_voter_count: 1,
is_closed: false,
is_anonymous: true,
type: "regular",
allows_multiple_answers: false,
description: `Read ${label}`,
description_entities: [{ type: "text_link", offset: 5, length: label.length, url }],
},
}),
);
const parsed = markdownToIR(result.text);
expect(parsed.text).toContain(`Read ${label}`);
expect(parsed.links.map((link) => link.href)).toEqual([url]);
});
});
describe("joinTelegramTextParts", () => {
@@ -97,6 +123,35 @@ describe("joinTelegramTextParts", () => {
entities: [{ type: "bold", offset: 0, length: 4 }],
});
});
it("preserves links from joined messages and captions through the real Markdown parser", () => {
const messageLabel = "😀 report]";
const captionLabel = "[caption";
const messageUrl = "https://example.com/message)final";
const captionUrl = "https://example.com/caption(a)b)";
const result = joinTelegramTextParts(
[
asTelegramMessage({
text: `Read ${messageLabel}`,
entities: [
{ type: "text_link", offset: 5, length: messageLabel.length, url: messageUrl },
],
}),
asTelegramMessage({
caption: `Open ${captionLabel}`,
caption_entities: [
{ type: "text_link", offset: 5, length: captionLabel.length, url: captionUrl },
],
}),
],
"\n",
);
const parsed = markdownToIR(renderTelegramTextEntities(result.text, result.entities));
expect(parsed.text).toBe(result.text);
expect(parsed.links.map((link) => link.href)).toEqual([messageUrl, captionUrl]);
});
});
describe("renderTelegramTextEntities quoted blocks", () => {
@@ -1,5 +1,6 @@
// Telegram tests cover helpers plugin behavior.
import type { MessageEntity } from "grammy/types";
import { markdownToIR } from "openclaw/plugin-sdk/text-chunking";
import { afterEach, describe, expect, it, vi } from "vitest";
import {
describeReplyTarget,
@@ -956,4 +957,83 @@ describe("renderTelegramTextEntities", () => {
expect(renderTelegramTextEntities(text, entities)).toBe("Hi 😀 **bold**");
});
it.each([
{
description: "an unmatched closing parenthesis",
label: "docs",
url: "https://example.com/report)final",
expectedHref: "https://example.com/report)final",
},
{
description: "nested and trailing parentheses",
label: "docs",
url: "https://example.com/quarter(a)b)",
expectedHref: "https://example.com/quarter(a)b)",
},
{
description: "a literal destination backslash",
label: "docs",
url: String.raw`https://example.com/a\b)`,
expectedHref: "https://example.com/a%5Cb)",
},
{
description: "angle brackets and whitespace",
label: "docs",
url: "https://example.com/<report final>",
expectedHref: "https://example.com/%3Creport%20final%3E",
},
{
description: "a closing bracket in the linked label",
label: "docs]more",
url: "https://example.com/report)final",
expectedHref: "https://example.com/report)final",
},
{
description: "a literal backslash before a bracket in the linked label",
label: String.raw`docs\]more`,
url: "https://example.com/report)final",
expectedHref: "https://example.com/report)final",
},
{
description: "an opening bracket and UTF-16 emoji in the linked label",
label: "😀 [docs",
url: "https://example.com/report)final",
expectedHref: "https://example.com/report)final",
},
{
description: "a newline in a provider link destination",
label: "docs",
url: "https://example.com/report\nfinal",
expectedHref: "https://example.com/report%0Afinal",
},
{
description: "an already percent-encoded parenthesis",
label: "docs",
url: "https://example.com/report%29final",
expectedHref: "https://example.com/report%29final",
},
])(
"preserves $description through the actual Markdown parser",
({ label, url, expectedHref }) => {
const text = `Read ${label} now`;
const offset = "Read ".length;
const entities = [
{ type: "bold", offset, length: label.length },
{ type: "text_link", offset, length: label.length, url },
] satisfies MessageEntity[];
const parsed = markdownToIR(renderTelegramTextEntities(text, entities));
expect(parsed.text).toBe(text);
expect(parsed.links).toEqual([
{ start: offset, end: offset + label.length, href: expectedHref },
]);
expect(parsed.styles).toContainEqual({
start: offset,
end: offset + label.length,
style: "bold",
});
},
);
});
@@ -101,7 +101,12 @@ function markdownAffixesForTelegramEntity(
case "pre":
return markdownPreAffixes(entity, content);
case "text_link":
return ["[", `](${entity.url})`];
return [
"[",
`](${entity.url.replace(/[\\()<>\s]/gu, (character) =>
character === "(" || character === ")" ? `\\${character}` : encodeURIComponent(character),
)})`,
];
default:
return null;
}
@@ -191,6 +196,7 @@ export function renderTelegramTextEntities(
const sortedQuoteEdges = [...quoteEdges].toSorted((left, right) => left - right);
const boundaries = new Map<number, TelegramMarkdownBoundary[]>();
const escapedLinkLabelOffsets = new Set<number>();
const addBoundary = (offset: number, boundary: TelegramMarkdownBoundary) => {
const entries = boundaries.get(offset);
if (entries) {
@@ -205,6 +211,11 @@ export function renderTelegramTextEntities(
}
for (const segment of splitTelegramFormattingAtQuoteEdges(text, entity, sortedQuoteEdges)) {
const content = text.slice(segment.offset, segment.offset + segment.length);
if (segment.type === "text_link") {
for (const match of content.matchAll(/[\\[\]]/gu)) {
escapedLinkLabelOffsets.add(segment.offset + match.index);
}
}
const affixes = markdownAffixesForTelegramEntity(segment, content);
if (!affixes) {
continue;
@@ -252,7 +263,7 @@ export function renderTelegramTextEntities(
});
}
if (offset < text.length) {
result += text[offset];
result += escapedLinkLabelOffsets.has(offset) ? `\\${text[offset]}` : text[offset];
}
}
return result;