fix(telegram): preserve authored file-reference links (#105911)

* fix(telegram): preserve authored file links

* fix(telegram): preserve authored file links after wrapping

* refactor(telegram): rely on markdown link provenance

---------

Co-authored-by: Peter Steinberger <steipete@gmail.com>
This commit is contained in:
Momo
2026-07-18 20:33:05 -07:00
committed by GitHub
co-authored by Peter Steinberger
parent 2f00a417ed
commit 198d2f000f
10 changed files with 224 additions and 75 deletions
+5 -1
View File
@@ -15,7 +15,11 @@ export function renderTelegramMarkdownIR(
ir: MarkdownIR,
options: {
escapeText: (text: string) => string;
buildLink: (link: MarkdownLinkSpan, text: string) => TelegramRenderLink | null;
buildLink: (
link: MarkdownLinkSpan,
text: string,
context: { origin: "authored" | "linkify" },
) => TelegramRenderLink | null;
buildCodeBlockOpen: (span: { language?: string }) => string;
},
): string {
+10 -16
View File
@@ -49,7 +49,11 @@ function isTelegramRichLinkHref(href: string): boolean {
*
* Excluded: .ai, .io, .tv, .fm (popular domain TLDs like x.ai, vercel.io, github.io)
*/
function buildTelegramLink(link: MarkdownLinkSpan, text: string) {
function buildTelegramLink(
link: MarkdownLinkSpan,
text: string,
context: { origin: "authored" | "linkify" },
) {
const href = link.href.trim();
if (!href) {
return null;
@@ -64,7 +68,7 @@ function buildTelegramLink(link: MarkdownLinkSpan, text: string) {
}
// Suppress auto-linkified file references (e.g. README.md → http://README.md)
const label = text.slice(link.start, link.end);
if (isAutoLinkedFileRef(href, label)) {
if (context.origin === "linkify" && isAutoLinkedFileRef(href, label)) {
return null;
}
const safeHref = escapeHtmlAttr(href);
@@ -178,7 +182,6 @@ function escapeRegex(str: string): string {
return str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
const AUTO_LINKED_ANCHOR_PATTERN = /<a\s+href="https?:\/\/([^"]+)"[^>]*>\1<\/a>/gi;
const HTML_MODE_TAG_PATTERN = /^<(\/?)([a-zA-Z][a-zA-Z0-9-]*)([^<>]*)>$/;
const ESCAPED_HTML_TAG_PATTERN = /&lt;(\/?)([a-zA-Z][a-zA-Z0-9-]*)(.*?)&gt;/g;
const TELEGRAM_HTML_ANCHOR_PATTERN =
@@ -499,15 +502,6 @@ function wrapSegmentFileRefs(
}
export function wrapFileReferencesInHtml(html: string): string {
// Safety-net: de-linkify auto-generated anchors where href="http://<label>" (defense in depth for textMode: "html")
AUTO_LINKED_ANCHOR_PATTERN.lastIndex = 0;
const deLinkified = html.replace(AUTO_LINKED_ANCHOR_PATTERN, (_match, label: string) => {
if (!isAutoLinkedFileRef(`http://${label}`, label)) {
return _match;
}
return `<code>${escapeHtml(label)}</code>`;
});
// Track nesting depth for tags that should not be modified
let codeDepth = 0;
let preDepth = 0;
@@ -516,14 +510,14 @@ export function wrapFileReferencesInHtml(html: string): string {
let lastIndex = 0;
// Process tags token-by-token so we can skip protected regions while wrapping plain text.
for (const tag of tokenizeHtmlTags(deLinkified)) {
for (const tag of tokenizeHtmlTags(html)) {
const tagStart = tag.start;
const tagEnd = tag.end;
const isClosing = tag.closing;
const tagName = tag.name;
// Process text before this tag
const textBefore = deLinkified.slice(lastIndex, tagStart);
const textBefore = html.slice(lastIndex, tagStart);
result += wrapSegmentFileRefs(textBefore, codeDepth, preDepth, anchorDepth);
// Update tag depth (clamp at 0 for malformed HTML with stray closing tags)
@@ -536,12 +530,12 @@ export function wrapFileReferencesInHtml(html: string): string {
}
// Add the tag itself
result += deLinkified.slice(tagStart, tagEnd);
result += html.slice(tagStart, tagEnd);
lastIndex = tagEnd;
}
// Process remaining text
const remainingText = deLinkified.slice(lastIndex);
const remainingText = html.slice(lastIndex);
result += wrapSegmentFileRefs(remainingText, codeDepth, preDepth, anchorDepth);
return result;
+24 -19
View File
@@ -85,24 +85,10 @@ describe("wrapFileReferencesInHtml", () => {
}
});
it("de-linkifies auto-linkified anchors for plain files and paths", () => {
const cases = [
{
input: '<a href="http://README.md">README.md</a>',
expected: "<code>README.md</code>",
},
{
input: '<a href="http://squad/friday/HEARTBEAT.md">squad/friday/HEARTBEAT.md</a>',
expected: "<code>squad/friday/HEARTBEAT.md</code>",
},
] as const;
for (const testCase of cases) {
expect(wrapFileReferencesInHtml(testCase.input)).toBe(testCase.expected);
}
});
it("preserves explicit links where label differs from href", () => {
it("preserves explicit links, including file-style labels", () => {
const cases = [
'<a href="http://README.md">README.md</a>',
'<a href="http://squad/friday/HEARTBEAT.md">squad/friday/HEARTBEAT.md</a>',
'<a href="http://README.md">click here</a>',
'<a href="http://other.md">README.md</a>',
] as const;
@@ -162,8 +148,18 @@ describe("markdownToTelegramHtml - file reference wrapping", () => {
});
it("preserves explicit markdown links even when href looks like a file ref", () => {
const result = markdownToTelegramHtml("[docs](http://README.md)");
expect(result).toContain('<a href="http://README.md">docs</a>');
expect(markdownToTelegramHtml("[docs](http://README.md)")).toContain(
'<a href="http://README.md">docs</a>',
);
expect(markdownToTelegramHtml("[README.md](https://README.md)")).toContain(
'<a href="https://README.md">README.md</a>',
);
});
it("keeps plain and authored file-style links distinct in the same message", () => {
expect(markdownToTelegramHtml("README.md [README.md](https://README.md)")).toBe(
'<code>README.md</code> <a href="https://README.md">README.md</a>',
);
});
it("wraps file ref after real URL in same message", () => {
@@ -184,6 +180,15 @@ describe("markdownToTelegramChunks - file reference wrapping", () => {
]);
});
it("preserves authored file-style links in chunked output", () => {
expect(markdownToTelegramChunks("README.md [README.md](https://README.md)", 4096)).toEqual([
{
html: '<code>README.md</code> <a href="https://README.md">README.md</a>',
text: "README.md README.md",
},
]);
});
it("keeps rendered html chunks within the provided limit", () => {
const input = "<".repeat(1500);
const chunks = markdownToTelegramChunks(input, 512);
@@ -165,6 +165,13 @@ describe("markdownToTelegramRichBlocks", () => {
expect(hasStyle(text, "code")).toBe(true);
});
it("preserves authored file-style links while wrapping bare file refs as code", () => {
const { blocks } = markdownToTelegramRichBlocks("README.md [README.md](https://README.md)");
const text = blocks[0] && blocks[0].type === "paragraph" ? blocks[0].text : "";
expect(collectUrls(text)).toEqual(["https://README.md"]);
expect(hasStyle(text, "code")).toBe(true);
});
it("derives plainText from the block projection", () => {
const { plainText } = markdownToTelegramRichBlocks("**hello** world");
expect(plainText).toContain("hello");
+26 -7
View File
@@ -3,6 +3,7 @@ import type { MarkdownTableMode } from "openclaw/plugin-sdk/config-contracts";
import {
isAutoLinkedFileRef,
markdownToIRWithMeta,
renderMarkdownWithMarkers,
sliceMarkdownIR,
type MarkdownIR,
type MarkdownLinkSpan,
@@ -83,13 +84,14 @@ type TelegramLinkAction =
function resolveTelegramLinkAction(
link: MarkdownLinkSpan,
source: string,
context: { origin: "authored" | "linkify" },
): TelegramLinkAction | null {
const href = link.href.trim();
if (!href || link.start === link.end) {
return null;
}
const label = source.slice(link.start, link.end);
if (isAutoLinkedFileRef(href, label)) {
if (context.origin === "linkify" && isAutoLinkedFileRef(href, label)) {
// Bare file refs (README.md, openclaw.json) must render as code, not links:
// Telegram's server-side entity detection would otherwise re-linkify them
// and show spurious domain previews for TLD-like extensions.
@@ -105,6 +107,24 @@ function resolveTelegramLinkAction(
return { kind: "url", href };
}
function collectTelegramLinkActions(
ir: MarkdownIR,
): Array<{ start: number; end: number; action: TelegramLinkAction }> {
const links: Array<{ start: number; end: number; action: TelegramLinkAction }> = [];
renderMarkdownWithMarkers(ir, {
styleMarkers: {},
escapeText: (text) => text,
buildLink: (link, source, context) => {
const action = resolveTelegramLinkAction(link, source, context);
if (action) {
links.push({ start: link.start, end: link.end, action });
}
return null;
},
});
return links;
}
/**
* Build nested RichText from IR spans over [rangeStart, rangeEnd).
* Spans that partially overlap are split at shared boundaries (IR contract).
@@ -132,12 +152,11 @@ function irRangeToRichText(ir: MarkdownIR, rangeStart: number, rangeEnd: number)
const annotationSpans = (slice.annotations ?? []).filter(
(span) => span.type === "assistant_transcript_role",
);
const links = slice.links
.filter((link) => !suppressed(link.start, link.end))
.flatMap((link) => {
const action = resolveTelegramLinkAction(link, text);
return action ? [{ start: link.start, end: link.end, action }] : [];
});
const links = collectTelegramLinkActions({
text,
styles: [],
links: slice.links.filter((link) => !suppressed(link.start, link.end)),
});
const boundaries = new Set<number>([0, text.length]);
for (const span of styleSpans) {
+36 -6
View File
@@ -31,6 +31,35 @@ export type MarkdownLinkSpan = {
href: string;
};
// Link provenance is renderer metadata, not part of the public Markdown IR shape.
// Every span transform must use copyMarkdownLinkSpan so the private fact survives.
const autoLinkedMarkdownLinks = new WeakSet<MarkdownLinkSpan>();
export function createMarkdownLinkSpan(
span: MarkdownLinkSpan,
options: { autoLinked?: boolean } = {},
): MarkdownLinkSpan {
const created = { ...span };
if (options.autoLinked) {
autoLinkedMarkdownLinks.add(created);
}
return created;
}
export function copyMarkdownLinkSpan(
span: MarkdownLinkSpan,
overrides: Partial<MarkdownLinkSpan> = {},
): MarkdownLinkSpan {
return createMarkdownLinkSpan(
{ ...span, ...overrides },
{ autoLinked: autoLinkedMarkdownLinks.has(span) },
);
}
export function isAutoLinkedMarkdownLink(span: MarkdownLinkSpan): boolean {
return autoLinkedMarkdownLinks.has(span);
}
export type MarkdownAnnotationSpan = {
start: number;
end: number;
@@ -72,7 +101,7 @@ export function clampLinkSpans(spans: MarkdownLinkSpan[], maxLength: number): Ma
const start = Math.max(0, Math.min(span.start, maxLength));
const end = Math.max(start, Math.min(span.end, maxLength));
if (end > start) {
clamped.push({ start, end, href: span.href });
clamped.push(copyMarkdownLinkSpan(span, { start, end }));
}
}
return clamped;
@@ -183,11 +212,12 @@ export function sliceLinkSpans(
for (const span of spans) {
const bounds = resolveSliceBounds(span, start, end);
if (bounds) {
sliced.push({
start: bounds.start - start,
end: bounds.end - start,
href: span.href,
});
sliced.push(
copyMarkdownLinkSpan(span, {
start: bounds.start - start,
end: bounds.end - start,
}),
);
}
}
return sliced;
@@ -0,0 +1,53 @@
import { describe, expect, it } from "vitest";
import { markdownToIR, sliceMarkdownIR, type MarkdownIR } from "./ir.js";
import { renderMarkdownWithMarkers } from "./render.js";
function collectRenderedLinks(ir: MarkdownIR) {
const links: Array<{ href: string; label: string; origin: "authored" | "linkify" }> = [];
renderMarkdownWithMarkers(ir, {
styleMarkers: {},
escapeText: (text) => text,
buildLink: (link, text, context) => {
links.push({
href: link.href,
label: text.slice(link.start, link.end),
origin: context.origin,
});
return null;
},
});
return links;
}
describe("markdownToIR link provenance", () => {
it("keeps provenance out of the public link span while exposing it to renderers", () => {
const ir = markdownToIR("README.md [main.ts](https://main.ts)");
expect(ir.links).toEqual([
{ start: 0, end: 9, href: "http://README.md" },
{ start: 10, end: 17, href: "https://main.ts" },
]);
expect(collectRenderedLinks(ir)).toEqual([
{ href: "http://README.md", label: "README.md", origin: "linkify" },
{ href: "https://main.ts", label: "main.ts", origin: "authored" },
]);
});
it("preserves link provenance through slicing", () => {
const ir = markdownToIR("prefix README.md suffix");
expect(collectRenderedLinks(sliceMarkdownIR(ir, 7, 16))).toEqual([
{ href: "http://README.md", label: "README.md", origin: "linkify" },
]);
});
it("preserves link provenance through table rendering", () => {
const ir = markdownToIR("| File |\n| --- |\n| README.md |", { tableMode: "bullets" });
expect(collectRenderedLinks(ir)).toContainEqual({
href: "http://README.md",
label: "README.md",
origin: "linkify",
});
});
});
+18 -12
View File
@@ -18,6 +18,8 @@ import {
clampAnnotationSpans,
clampLinkSpans,
clampStyleSpans,
copyMarkdownLinkSpan,
createMarkdownLinkSpan,
createStyleSpan,
mergeAnnotationSpans,
mergeStyleSpans,
@@ -42,6 +44,7 @@ type ListState = {
type LinkState = {
href: string;
labelStart: number;
autoLinked: boolean;
};
const OPEN_MARKDOWN_HTML_TAG_PATTERN = /<\/?[a-zA-Z][a-zA-Z0-9-]*\b[^<>]*$/;
@@ -63,6 +66,7 @@ type MarkdownToken = {
hidden?: boolean;
level?: number;
map?: [number, number] | null;
markup?: string;
meta?: unknown;
};
@@ -450,11 +454,8 @@ function handleLinkClose(state: RenderState) {
}
const start = link.labelStart;
const end = target.text.length;
if (end <= start) {
target.links.push({ start, end, href });
return;
}
target.links.push({ start, end, href });
const span = createMarkdownLinkSpan({ start, end, href }, { autoLinked: link.autoLinked });
target.links.push(span);
}
function headingStyleFromToken(token: MarkdownToken): MarkdownStyle | null {
@@ -536,7 +537,7 @@ function trimCell(cell: TableCell): TableCell {
const sliceStart = Math.max(0, span.start - start);
const sliceEnd = Math.min(trimmedLength, span.end - start);
if (sliceEnd > sliceStart) {
trimmedLinks.push({ start: sliceStart, end: sliceEnd, href: span.href });
trimmedLinks.push(copyMarkdownLinkSpan(span, { start: sliceStart, end: sliceEnd }));
}
}
const trimmedAnnotations = sliceAnnotationSpans(cell.annotations ?? [], start, end);
@@ -562,11 +563,12 @@ function appendCell(state: RenderState, cell: TableCell) {
});
}
for (const link of cell.links) {
state.links.push({
start: start + link.start,
end: start + link.end,
href: link.href,
});
state.links.push(
copyMarkdownLinkSpan(link, {
start: start + link.start,
end: start + link.end,
}),
);
}
for (const annotation of cell.annotations ?? []) {
state.annotations.push({
@@ -814,7 +816,11 @@ function renderTokens(tokens: MarkdownToken[], state: RenderState): void {
case "link_open": {
const target = resolveRenderTarget(state);
const href = isInsideMarkdownHtmlTag(target.text) ? "" : (getAttr(token, "href") ?? "");
target.linkStack.push({ href, labelStart: target.text.length });
target.linkStack.push({
href,
labelStart: target.text.length,
autoLinked: token.markup === "linkify",
});
break;
}
case "link_close":
@@ -1,7 +1,12 @@
import { avoidTrailingHighSurrogateBreak } from "./chunk-text.js";
// Markdown Core module implements render aware chunking behavior.
import { annotateAssistantTranscriptRoleMessageBoundary } from "./ir-annotations.js";
import { mergeAnnotationSpans, type MarkdownAnnotationSpan } from "./ir-spans.js";
import {
copyMarkdownLinkSpan,
isAutoLinkedMarkdownLink,
mergeAnnotationSpans,
type MarkdownAnnotationSpan,
} from "./ir-spans.js";
import {
sliceMarkdownIR,
type MarkdownIR,
@@ -287,11 +292,16 @@ function mergeAdjacentLinkSpans(links: MarkdownLinkSpan[]): MarkdownLinkSpan[] {
const merged: MarkdownLinkSpan[] = [];
for (const link of links) {
const last = merged.at(-1);
if (last && last.href === link.href && link.start <= last.end) {
if (
last &&
last.href === link.href &&
isAutoLinkedMarkdownLink(last) === isAutoLinkedMarkdownLink(link) &&
link.start <= last.end
) {
last.end = Math.max(last.end, link.end);
continue;
}
merged.push({ ...link });
merged.push(copyMarkdownLinkSpan(link));
}
return merged;
}
@@ -316,11 +326,12 @@ function mergeMarkdownIRChunks(left: MarkdownIR, right: MarkdownIR): MarkdownIR
}
const shiftedLinks: MarkdownLinkSpan[] = [];
for (const link of right.links) {
shiftedLinks.push({
...link,
start: link.start + offset,
end: link.end + offset,
});
shiftedLinks.push(
copyMarkdownLinkSpan(link, {
start: link.start + offset,
end: link.end + offset,
}),
);
}
const annotations = mergeAnnotationSpans([...(left.annotations ?? []), ...shiftedAnnotations]);
return {
+26 -6
View File
@@ -1,4 +1,8 @@
import type { MarkdownAnnotationSpan } from "./ir-spans.js";
import {
copyMarkdownLinkSpan,
isAutoLinkedMarkdownLink,
type MarkdownAnnotationSpan,
} from "./ir-spans.js";
// Markdown Core module implements render behavior.
import type { MarkdownIR, MarkdownLinkSpan, MarkdownStyle, MarkdownStyleSpan } from "./ir.js";
@@ -29,12 +33,22 @@ export type RenderLink = {
close: string;
};
type MarkdownLinkOrigin = "authored" | "linkify";
function getMarkdownLinkOrigin(link: MarkdownLinkSpan): MarkdownLinkOrigin {
return isAutoLinkedMarkdownLink(link) ? "linkify" : "authored";
}
/** Renderer hooks for converting Markdown IR into a marker-based target format. */
export type RenderOptions = {
styleMarkers: RenderStyleMap;
annotationMarkers?: RenderAnnotationMap;
escapeText: (text: string) => string;
buildLink?: (link: MarkdownLinkSpan, text: string) => RenderLink | null;
buildLink?: (
link: MarkdownLinkSpan,
text: string,
context: { origin: MarkdownLinkOrigin },
) => RenderLink | null;
};
const STYLE_ORDER: MarkdownStyle[] = [
@@ -253,15 +267,21 @@ export function renderMarkdownWithMarkers(ir: MarkdownIR, options: RenderOptions
const linkStarts = new Map<number, RenderLink[]>();
if (options.buildLink) {
const links = ir.links.flatMap((span) =>
subtractRanges(span, dominantAnnotationRanges).flatMap((piece) =>
splitAtBoundaries(piece, annotationBoundaries),
),
subtractRanges(span, dominantAnnotationRanges)
.flatMap((piece) => splitAtBoundaries(piece, annotationBoundaries))
.map((piece) =>
copyMarkdownLinkSpan(span, {
start: piece.start,
end: piece.end,
href: piece.href,
}),
),
);
for (const link of links) {
if (link.start === link.end) {
continue;
}
const rendered = options.buildLink(link, text);
const rendered = options.buildLink(link, text, { origin: getMarkdownLinkOrigin(link) });
if (!rendered) {
continue;
}