mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-26 04:15:48 -06:00
fix(telegram): preserve inbound Markdown link destinations and labels (#128297)
This commit is contained in:
committed by
GitHub
parent
000aea1d7a
commit
f256c63526
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user