From f256c635267b787cbab9cd16db0e4e8279e089aa Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 23 Aug 2026 11:09:36 -0700 Subject: [PATCH] fix(telegram): preserve inbound Markdown link destinations and labels (#128297) --- .../src/bot/body-helpers.inbound.test.ts | 55 +++++++++++++ extensions/telegram/src/bot/helpers.test.ts | 80 +++++++++++++++++++ .../telegram/src/bot/inbound-text-entities.ts | 15 +++- 3 files changed, 148 insertions(+), 2 deletions(-) diff --git a/extensions/telegram/src/bot/body-helpers.inbound.test.ts b/extensions/telegram/src/bot/body-helpers.inbound.test.ts index efbd9dcf854b..10ab52c9e90b 100644 --- a/extensions/telegram/src/bot/body-helpers.inbound.test.ts +++ b/extensions/telegram/src/bot/body-helpers.inbound.test.ts @@ -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", () => { diff --git a/extensions/telegram/src/bot/helpers.test.ts b/extensions/telegram/src/bot/helpers.test.ts index b339d10e8bee..c0afd9ae3caa 100644 --- a/extensions/telegram/src/bot/helpers.test.ts +++ b/extensions/telegram/src/bot/helpers.test.ts @@ -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/", + 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", + }); + }, + ); }); diff --git a/extensions/telegram/src/bot/inbound-text-entities.ts b/extensions/telegram/src/bot/inbound-text-entities.ts index 2a5cc6c037c4..fb300e0e2248 100644 --- a/extensions/telegram/src/bot/inbound-text-entities.ts +++ b/extensions/telegram/src/bot/inbound-text-entities.ts @@ -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(); + const escapedLinkLabelOffsets = new Set(); 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;