diff --git a/extensions/telegram/src/format.test.ts b/extensions/telegram/src/format.test.ts index 8d4e5dc189e4..8abda0c38156 100644 --- a/extensions/telegram/src/format.test.ts +++ b/extensions/telegram/src/format.test.ts @@ -206,6 +206,28 @@ describe("markdownToTelegramHtml", () => { ).toBe('docs'); }); + it("keeps unsupported markdown link hrefs as visible text in rich HTML", () => { + expect( + markdownToTelegramRichHtml( + "[scripts/yougile.py](/home/dankar/.openclaw/workspace-yougile/scripts/yougile.py#L41)", + ), + ).toBe("scripts/yougile.py"); + expect(markdownToTelegramRichHtml("[config](./openclaw.json)")).toBe("config"); + expect(markdownToTelegramRichHtml("[docs](https://example.com/docs)")).toBe( + 'docs', + ); + expect(markdownToTelegramRichHtml("[user](tg://user?id=123)")).toBe( + 'user', + ); + expect(markdownToTelegramRichHtml("[support](mailto:user@example.com)")).toBe( + 'support', + ); + expect(markdownToTelegramRichHtml("[call](tel:+123456789)")).toBe( + 'call', + ); + expect(markdownToTelegramRichHtml("[back](#top)")).toBe('back'); + }); + it("preserves Markdown heading levels in rich HTML", () => { expect(markdownToTelegramRichHtml("# Title\n\n### Detail")).toBe( "

Title

\n\n

Detail

", diff --git a/extensions/telegram/src/format.ts b/extensions/telegram/src/format.ts index 8de2530ca9d5..58baa683dc50 100644 --- a/extensions/telegram/src/format.ts +++ b/extensions/telegram/src/format.ts @@ -32,6 +32,10 @@ function escapeHtmlAttr(text: string): string { return escapeHtml(text).replace(/"/g, """); } +function isTelegramRichLinkHref(href: string): boolean { + return /^(?:https?:\/\/|tg:\/\/|mailto:|tel:|#)/i.test(href); +} + /** * File extensions that share TLDs and commonly appear in code/documentation. * These are wrapped in tags to prevent Telegram from generating @@ -51,6 +55,11 @@ function buildTelegramLink(link: MarkdownLinkSpan, text: string) { if (link.start === link.end) { return null; } + // Telegram rich links reject local or relative hrefs; keep the label visible + // instead of letting one unsupported link drop the whole message. + if (!isTelegramRichLinkHref(href)) { + return null; + } // Suppress auto-linkified file references (e.g. README.md → http://README.md) const label = text.slice(link.start, link.end); if (isAutoLinkedFileRef(href, label)) { diff --git a/extensions/telegram/src/send.test.ts b/extensions/telegram/src/send.test.ts index 02820156e6c7..f7030679b33a 100644 --- a/extensions/telegram/src/send.test.ts +++ b/extensions/telegram/src/send.test.ts @@ -1044,6 +1044,35 @@ describe("sendMessageTelegram", () => { expect(botRawApi.sendRichMessage.mock.calls[0]?.[0]?.rich_message.html).toBe(markdown); }); + it.each([ + { + name: "local path", + markdown: + "See [scripts/yougile.py](/home/user/.openclaw/workspace/scripts/yougile.py#L41) and [docs](https://example.com/docs)", + rejectedAnchor: 'scripts/yougile.py", + }, + { + name: "relative path", + markdown: "Edit [config](./openclaw.json) or see [docs](https://example.com/docs)", + rejectedAnchor: ' { + botApi.sendMessage.mockResolvedValue({ message_id: 48, chat: { id: "123" } }); + + await sendMessageTelegram("123", testCase.markdown, { + cfg: { channels: { telegram: { richMessages: true } } }, + token: "tok", + }); + + expect(botRawApi.sendRichMessage).toHaveBeenCalledTimes(1); + const richHtml = String(botRawApi.sendRichMessage.mock.calls[0]?.[0]?.rich_message.html ?? ""); + expect(richHtml).not.toContain(testCase.rejectedAnchor); + expect(richHtml).toContain(testCase.visibleLabel); + expect(richHtml).toContain('docs'); + }); + it("renders complex markdown into HTML text", async () => { botApi.sendMessage.mockResolvedValue({ message_id: 46, chat: { id: "123" } }); const markdown = [