diff --git a/src/telegram/format.ts b/src/telegram/format.ts index 7feab62aeadf..c970e6f8ce8f 100644 --- a/src/telegram/format.ts +++ b/src/telegram/format.ts @@ -41,7 +41,6 @@ const FILE_EXTENSIONS_WITH_TLD = new Set([ "at", // Assembly (Austria) "be", // Backend files (Belgium) "cc", // C++ source (Cocos Islands) - "co", // Configuration (Colombia) ]); /** Detects when markdown-it linkify auto-generated a link from a bare filename (e.g. README.md → http://README.md) */ @@ -150,7 +149,7 @@ export function wrapFileReferencesInHtml(html: string): string { return `${escapeHtml(label)}`; }); const filePattern = new RegExp( - `(^|>|[\\s])([a-zA-Z0-9_.\\-./]+\\.(?:${extensionsPattern}))(?=$|[\\s<])`, + `(^|[^a-zA-Z0-9_\\-/])([a-zA-Z0-9_.\\-./]+\\.(?:${extensionsPattern}))(?=$|[^a-zA-Z0-9_\\-/])`, "gi", ); @@ -173,11 +172,18 @@ export function wrapFileReferencesInHtml(html: string): string { // Process text before this tag const textBefore = html.slice(lastIndex, tagStart); - result += textBefore.replace(filePattern, (m, prefix, filename) => { + result += textBefore.replace(filePattern, (m, prefix, filename, offset, source) => { // Skip if inside protected tags or if it's a URL if (codeDepth > 0 || preDepth > 0 || anchorDepth > 0) { return m; } + // Skip if we're inside any HTML tag (e.g., attributes on tags other than code/pre/a) + const filenameOffset = Number(offset) + String(prefix).length; + const lastOpen = String(source).lastIndexOf("<", filenameOffset); + const lastClose = String(source).lastIndexOf(">", filenameOffset); + if (lastOpen > lastClose) { + return m; + } if (filename.startsWith("//")) { return m; } @@ -203,10 +209,16 @@ export function wrapFileReferencesInHtml(html: string): string { // Process remaining text const remainingText = html.slice(lastIndex); - result += remainingText.replace(filePattern, (m, prefix, filename) => { + result += remainingText.replace(filePattern, (m, prefix, filename, offset, source) => { if (codeDepth > 0 || preDepth > 0 || anchorDepth > 0) { return m; } + const filenameOffset = Number(offset) + String(prefix).length; + const lastOpen = String(source).lastIndexOf("<", filenameOffset); + const lastClose = String(source).lastIndexOf(">", filenameOffset); + if (lastOpen > lastClose) { + return m; + } if (filename.startsWith("//")) { return m; } diff --git a/src/telegram/format.wrap-md.test.ts b/src/telegram/format.wrap-md.test.ts index e882ef837b70..9fc3e386f323 100644 --- a/src/telegram/format.wrap-md.test.ts +++ b/src/telegram/format.wrap-md.test.ts @@ -73,6 +73,13 @@ describe("wrapFileReferencesInHtml", () => { expect(wrapFileReferencesInHtml("Ends with file.md")).toContain("file.md"); }); + it("wraps file refs with punctuation boundaries", () => { + expect(wrapFileReferencesInHtml("See README.md.")).toContain("README.md."); + expect(wrapFileReferencesInHtml("See README.md,")).toContain("README.md,"); + expect(wrapFileReferencesInHtml("(README.md)")).toContain("(README.md)"); + expect(wrapFileReferencesInHtml("README.md:")).toContain("README.md:"); + }); + it("de-linkifies auto-linkified file ref anchors", () => { const input = 'README.md'; expect(wrapFileReferencesInHtml(input)).toBe("README.md"); @@ -193,15 +200,12 @@ describe("edge cases", () => { expect(result).toBe("README.md"); }); - it("wraps supported TLD extensions (.am, .at, .be, .cc, .co)", () => { - const result = markdownToTelegramHtml( - "Makefile.am and code.at and app.be and main.cc and config.co", - ); + it("wraps supported TLD extensions (.am, .at, .be, .cc)", () => { + const result = markdownToTelegramHtml("Makefile.am and code.at and app.be and main.cc"); expect(result).toContain("Makefile.am"); expect(result).toContain("code.at"); expect(result).toContain("app.be"); expect(result).toContain("main.cc"); - expect(result).toContain("config.co"); }); it("does not wrap popular domain TLDs (.ai, .io, .tv, .fm)", () => { @@ -214,6 +218,14 @@ describe("edge cases", () => { expect(result).toContain(''); }); + it("keeps .co domains as links", () => { + const result = markdownToTelegramHtml("Visit t.co and openclaw.co"); + expect(result).toContain(''); + expect(result).toContain(''); + expect(result).not.toContain("t.co"); + expect(result).not.toContain("openclaw.co"); + }); + it("does not wrap non-TLD extensions", () => { const result = markdownToTelegramHtml("image.png and style.css and script.js"); expect(result).not.toContain("image.png");