From 7150268f840cf519f900d07f75dc74f2dd928fca Mon Sep 17 00:00:00 2001 From: spiceoogway Date: Fri, 30 Jan 2026 02:21:37 -0500 Subject: [PATCH 1/9] fix(telegram): use undici fetch for proxy to fix dispatcher option Fixes #4038 The global fetch in Node.js doesn't support undici's dispatcher option, which is required for ProxyAgent to work. This fix imports fetch from undici directly to enable proper proxy support for Telegram API calls. Root cause: makeProxyFetch() was using global fetch with { dispatcher: agent }, but Node.js's global fetch ignores the dispatcher option. Using undici.fetch ensures the ProxyAgent dispatcher is properly respected. Tested: Build passes, TypeScript compilation successful. --- src/telegram/proxy.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/telegram/proxy.ts b/src/telegram/proxy.ts index 19d53d569..84251d7fe 100644 --- a/src/telegram/proxy.ts +++ b/src/telegram/proxy.ts @@ -1,11 +1,11 @@ // @ts-nocheck -import { ProxyAgent } from "undici"; +import { ProxyAgent, fetch as undiciFetch } from "undici"; import { wrapFetchWithAbortSignal } from "../infra/fetch.js"; export function makeProxyFetch(proxyUrl: string): typeof fetch { const agent = new ProxyAgent(proxyUrl); return wrapFetchWithAbortSignal((input: RequestInfo | URL, init?: RequestInit) => { const base = init ? { ...init } : {}; - return fetch(input, { ...base, dispatcher: agent }); + return undiciFetch(input, { ...base, dispatcher: agent }); }); } From 3a85cb18330ef7b426668191d0acb7ec7b6a86cf Mon Sep 17 00:00:00 2001 From: Ayaan Zaidi Date: Fri, 30 Jan 2026 14:37:17 +0530 Subject: [PATCH 2/9] fix: honor Telegram proxy dispatcher (#4456) (thanks @spiceoogway) --- CHANGELOG.md | 1 + src/telegram/proxy.test.ts | 45 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 46 insertions(+) create mode 100644 src/telegram/proxy.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 4c0549c16..ec0fc3fb6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -72,6 +72,7 @@ Status: stable. - **BREAKING:** Gateway auth mode "none" is removed; gateway now requires token/password (Tailscale Serve identity still allowed). ### Fixes +- Telegram: use undici fetch for per-account proxy dispatcher. (#4456) Thanks @spiceoogway. - Telegram: avoid silent empty replies by tracking normalization skips before fallback. (#3796) - Telegram: scope native skill commands to bound agent per bot. (#4360) Thanks @robhparker. - Mentions: honor mentionPatterns even when explicit mentions are present. (#3303) Thanks @HirokiKobayashi-R. diff --git a/src/telegram/proxy.test.ts b/src/telegram/proxy.test.ts new file mode 100644 index 000000000..71fd5f88e --- /dev/null +++ b/src/telegram/proxy.test.ts @@ -0,0 +1,45 @@ +import { describe, expect, it, vi } from "vitest"; + +const { ProxyAgent, undiciFetch, proxyAgentSpy, getLastAgent } = vi.hoisted(() => { + const undiciFetch = vi.fn(); + const proxyAgentSpy = vi.fn(); + class ProxyAgent { + static lastCreated: ProxyAgent | undefined; + proxyUrl: string; + constructor(proxyUrl: string) { + this.proxyUrl = proxyUrl; + ProxyAgent.lastCreated = this; + proxyAgentSpy(proxyUrl); + } + } + + return { + ProxyAgent, + undiciFetch, + proxyAgentSpy, + getLastAgent: () => ProxyAgent.lastCreated, + }; +}); + +vi.mock("undici", () => ({ + ProxyAgent, + fetch: undiciFetch, +})); + +import { makeProxyFetch } from "./proxy.js"; + +describe("makeProxyFetch", () => { + it("uses undici fetch with ProxyAgent dispatcher", async () => { + const proxyUrl = "http://proxy.test:8080"; + undiciFetch.mockResolvedValue({ ok: true }); + + const proxyFetch = makeProxyFetch(proxyUrl); + await proxyFetch("https://api.telegram.org/bot123/getMe"); + + expect(proxyAgentSpy).toHaveBeenCalledWith(proxyUrl); + expect(undiciFetch).toHaveBeenCalledWith( + "https://api.telegram.org/bot123/getMe", + expect.objectContaining({ dispatcher: getLastAgent() }), + ); + }); +}); From f760aa302c4fe0fede04fc54b1fe10b7696bf711 Mon Sep 17 00:00:00 2001 From: Ayush Ojha Date: Fri, 30 Jan 2026 01:06:49 -0800 Subject: [PATCH 3/9] fix(telegram): react action accepts numeric messageId and chatId The react action used readStringParam for messageId and chatId, which rejected numeric values with a misleading "messageId required" error. Switched to readStringOrNumberParam to match the delete/edit actions. Closes #1459 Co-Authored-By: Claude Opus 4.5 --- src/channels/plugins/actions/telegram.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/channels/plugins/actions/telegram.ts b/src/channels/plugins/actions/telegram.ts index 693e94492..17df9adbc 100644 --- a/src/channels/plugins/actions/telegram.ts +++ b/src/channels/plugins/actions/telegram.ts @@ -85,7 +85,7 @@ export const telegramMessageActions: ChannelMessageActionAdapter = { } if (action === "react") { - const messageId = readStringParam(params, "messageId", { + const messageId = readStringOrNumberParam(params, "messageId", { required: true, }); const emoji = readStringParam(params, "emoji", { allowEmpty: true }); @@ -94,7 +94,9 @@ export const telegramMessageActions: ChannelMessageActionAdapter = { { action: "react", chatId: - readStringParam(params, "chatId") ?? readStringParam(params, "to", { required: true }), + readStringOrNumberParam(params, "chatId") ?? + readStringOrNumberParam(params, "channelId") ?? + readStringParam(params, "to", { required: true }), messageId, emoji, remove, From bc432d8435214b3a00556f64a4a7ecd2f2ba7616 Mon Sep 17 00:00:00 2001 From: Ayaan Zaidi Date: Fri, 30 Jan 2026 14:55:37 +0530 Subject: [PATCH 4/9] fix: accept numeric Telegram react ids (#4533) (thanks @Ayush10) --- CHANGELOG.md | 1 + README.md | 61 ++++++++++--------- src/channels/plugins/actions/telegram.test.ts | 23 +++++++ 3 files changed, 55 insertions(+), 30 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ec0fc3fb6..dfaccc1f5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -74,6 +74,7 @@ Status: stable. ### Fixes - Telegram: use undici fetch for per-account proxy dispatcher. (#4456) Thanks @spiceoogway. - Telegram: avoid silent empty replies by tracking normalization skips before fallback. (#3796) +- Telegram: accept numeric messageId/chatId in react action and honor channelId fallback. (#4533) Thanks @Ayush10. - Telegram: scope native skill commands to bound agent per bot. (#4360) Thanks @robhparker. - Mentions: honor mentionPatterns even when explicit mentions are present. (#3303) Thanks @HirokiKobayashi-R. - Discord: restore username directory lookup in target resolution. (#3131) Thanks @bonald. diff --git a/README.md b/README.md index 1fd5e074c..49085c76f 100644 --- a/README.md +++ b/README.md @@ -481,38 +481,39 @@ Thanks to all clawtributors:

steipete plum-dawg bohdanpodvirnyi iHildy jaydenfyi joaohlisboa mneves75 MatthieuBizien MaudeBot Glucksberg - rahthakor vrknetha radek-paclt vignesh07 Tobias Bischoff joshp123 czekaj mukhtharcm sebslight maxsumrall - xadenryan rodrigouroz juanpablodlc hsrvc magimetal zerone0x tyler6204 meaningfool patelhiren NicholasSpisak + rahthakor vrknetha radek-paclt vignesh07 joshp123 Tobias Bischoff czekaj mukhtharcm sebslight maxsumrall + xadenryan rodrigouroz juanpablodlc tyler6204 hsrvc magimetal zerone0x meaningfool patelhiren NicholasSpisak jonisjongithub abhisekbasu1 jamesgroat claude JustYannicc Mariano Belinky Hyaxia dantelex SocialNerd42069 daveonkels - google-labs-jules[bot] lc0rp mousberg adam91holt hougangdev shakkernerd gumadeiras mteam88 hirefrank joeynyc + google-labs-jules[bot] lc0rp mousberg adam91holt hougangdev gumadeiras shakkernerd mteam88 hirefrank joeynyc orlyjamie dbhurley Eng. Juan Combetto TSavo julianengel bradleypriest benithors rohannagpal timolins f-trycua benostein elliotsecops nachx639 pvoo sreekaransrinath gupsammy cristip73 stefangalescu nachoiacovino Vasanth Rao Naik Sabavat petter-b thewilloftheshadow cpojer scald andranik-sahakyan davidguttman sleontenko denysvitali sircrumpet peschee - nonggialiang rafaelreis-r dominicnunez lploc94 ratulsarna lutr0 danielz1z AdeboyeDN Alg0rix papago2355 - emanuelst KristijanJovanovski rdev rhuanssauro joshrad-dev kiranjd osolmaz adityashaw2 CashWilliams sheeek - ryancontent artuskg Takhoffman onutc pauloportella neooriginal manuelhettich minghinmatthewlam myfunc travisirby - obviyus buddyh connorshea kyleok mcinteerj dependabot[bot] John-Rood timkrase uos-status gerardward2007 - roshanasingh4 tosh-hamburg azade-c dlauer JonUleis shivamraut101 bjesuiter cheeeee robbyczgw-cla Josh Phillips - YuriNachos pookNast Whoaa512 chriseidhof ngutman ysqander aj47 kennyklee superman32432432 Yurii Chukhlib - grp06 antons austinm911 blacksmith-sh[bot] damoahdominic dan-dr HeimdallStrategy imfing jalehman jarvis-medmatic - kkarimi mahmoudashraf93 pkrmf RandyVentures Ryan Lisse dougvk erikpr1994 fal3 Ghost jonasjancarik - Keith the Silly Goose L36 Server Marc mitschabaude-bot mkbehr neist sibbl chrisrodz Friederike Seiler gabriel-trigo - iamadig Jonathan D. Rhyne (DJ-D) Joshua Mitchell Kit koala73 manmal ogulcancelik pasogott petradonka rubyrunsstuff - siddhantjain suminhthanh svkozak VACInc wes-davis zats 24601 ameno- Chris Taylor dguido - Django Navarro evalexpr henrino3 humanwritten larlyssa Lukavyi odysseus0 oswalpalash pcty-nextgen-service-account pi0 - rmorse Roopak Nijhara Syhids Aaron Konyer aaronveklabs andreabadesso Andrii cash-echo-bot Clawd ClawdFx - EnzeD erik-agens Evizero fcatuhe itsjaydesu ivancasco ivanrvpereira Jarvis jayhickey jeffersonwarrior - jeffersonwarrior jverdi longmaba MarvinCui mickahouan mjrussell odnxe p6l-richard philipp-spiess Pocket Clawd - robaxelsen Sash Catanzarite Suksham-sharma T5-AndyML tewatia travisp VAC william arzt zknicker 0oAstro - abhaymundhara aduk059 alejandro maza Alex-Alaniz alexstyl andrewting19 anpoirier araa47 arthyn Asleep123 - bguidolim bolismauro chenyuan99 OpenClaw Maintainers conhecendoia dasilva333 David-Marsh-Photo Developer Dimitrios Ploutarchos Drake Thomsen - dylanneve1 Felix Krause foeken frankekn ganghyun kim grrowl gtsifrikas HazAT hrdwdmrbl hugobarauna - Jamie Openshaw Jane Jarvis Deploy Jefferson Nunn jogi47 kentaro Kevin Lin kira-ariaki kitze Kiwitwitter - levifig Lloyd longjos loukotal louzhixian martinpucik Matt mini mertcicekci0 Miles mrdbstn - MSch Mustafa Tag Eldeen ndraiman nexty5870 Noctivoro ppamment prathamdby ptn1411 reeltimeapps RLTCmpe - Rolf Fredheim Rony Kelner Samrat Jha senoldogann sergical shiv19 shiyuanhai siraht snopoke techboss - testingabc321 The Admiral thesash Ubuntu voidserf Vultr-Clawd Admin Wimmie wolfred wstock YangHuang2280 - yazinsai YiWang24 ymat19 Zach Knickerbocker zackerthescar 0xJonHoldsCrypto aaronn Alphonse-arianee atalovesyou Azade - carlulsoe ddyo Erik latitudeki5223 Manuel Maly Mourad Boustani odrobnik pcty-nextgen-ios-builder Quentin Randy Torres - rhjoh ronak-guliani William Stock + nonggialiang rafaelreis-r dominicnunez lploc94 ratulsarna lutr0 kiranjd danielz1z AdeboyeDN Alg0rix + papago2355 emanuelst KristijanJovanovski rdev rhuanssauro joshrad-dev osolmaz adityashaw2 CashWilliams sheeek + ryancontent artuskg Takhoffman onutc pauloportella HirokiKobayashi-R neooriginal obviyus manuelhettich minghinmatthewlam + manikv12 myfunc travisirby buddyh connorshea kyleok mcinteerj dependabot[bot] John-Rood timkrase + uos-status gerardward2007 roshanasingh4 tosh-hamburg azade-c dlauer JonUleis shivamraut101 bjesuiter cheeeee + robbyczgw-cla conroywhitney Josh Phillips YuriNachos pookNast Whoaa512 chriseidhof ngutman ysqander aj47 + kennyklee superman32432432 Yurii Chukhlib grp06 antons austinm911 blacksmith-sh[bot] damoahdominic dan-dr HeimdallStrategy + imfing jalehman jarvis-medmatic kkarimi mahmoudashraf93 pkrmf RandyVentures robhparker Ryan Lisse dougvk + erikpr1994 fal3 Ghost jonasjancarik Keith the Silly Goose L36 Server Marc mitschabaude-bot mkbehr neist + sibbl chrisrodz Friederike Seiler gabriel-trigo iamadig Jonathan D. Rhyne (DJ-D) Joshua Mitchell Kit koala73 manmal + ogulcancelik pasogott petradonka rubyrunsstuff siddhantjain suminhthanh svkozak VACInc wes-davis zats + 24601 ameno- Chris Taylor dguido Django Navarro evalexpr henrino3 humanwritten larlyssa Lukavyi + odysseus0 oswalpalash pcty-nextgen-service-account pi0 rmorse Roopak Nijhara Syhids Aaron Konyer aaronveklabs andreabadesso + Andrii cash-echo-bot Clawd ClawdFx EnzeD erik-agens Evizero fcatuhe itsjaydesu ivancasco + ivanrvpereira Jarvis jayhickey jeffersonwarrior jeffersonwarrior jverdi longmaba MarvinCui mjrussell odnxe + optimikelabs p6l-richard philipp-spiess Pocket Clawd robaxelsen Sash Catanzarite Suksham-sharma T5-AndyML tewatia travisp + VAC william arzt zknicker 0oAstro abhaymundhara aduk059 alejandro maza Alex-Alaniz alexstyl andrewting19 + anpoirier araa47 arthyn Asleep123 bguidolim bolismauro chenyuan99 Chloe-VP conhecendoia dasilva333 + David-Marsh-Photo Developer Dimitrios Ploutarchos Drake Thomsen dylanneve1 Felix Krause foeken frankekn ganghyun kim grrowl + gtsifrikas HazAT hrdwdmrbl hugobarauna Jamie Openshaw Jane Jarvis Deploy Jefferson Nunn jogi47 kentaro + Kevin Lin kira-ariaki kitze Kiwitwitter levifig Lloyd longjos loukotal louzhixian martinpucik + Matt mini mertcicekci0 Miles mrdbstn MSch Mustafa Tag Eldeen mylukin nathanbosse ndraiman nexty5870 + Noctivoro ppamment prathamdby ptn1411 reeltimeapps RLTCmpe Rolf Fredheim Rony Kelner Samrat Jha senoldogann + Seredeep sergical shiv19 shiyuanhai siraht snopoke spiceoogway techboss testingabc321 The Admiral + thesash Ubuntu Vibe Kanban voidserf Vultr-Clawd Admin Wimmie wolfred wstock YangHuang2280 yazinsai + YiWang24 ymat19 Zach Knickerbocker zackerthescar 0xJonHoldsCrypto aaronn Alphonse-arianee atalovesyou Azade carlulsoe + ddyo Erik latitudeki5223 Manuel Maly Mourad Boustani odrobnik pcty-nextgen-ios-builder Quentin Randy Torres rhjoh + ronak-guliani William Stock

diff --git a/src/channels/plugins/actions/telegram.test.ts b/src/channels/plugins/actions/telegram.test.ts index d41628888..1ccc1e628 100644 --- a/src/channels/plugins/actions/telegram.test.ts +++ b/src/channels/plugins/actions/telegram.test.ts @@ -118,4 +118,27 @@ describe("telegramMessageActions", () => { expect(handleTelegramAction).not.toHaveBeenCalled(); }); + + it("accepts numeric messageId and channelId for reactions", async () => { + handleTelegramAction.mockClear(); + const cfg = { channels: { telegram: { botToken: "tok" } } } as OpenClawConfig; + + await telegramMessageActions.handleAction({ + action: "react", + params: { + channelId: 123, + messageId: 456, + emoji: "ok", + }, + cfg, + accountId: undefined, + }); + + expect(handleTelegramAction).toHaveBeenCalledTimes(1); + const call = handleTelegramAction.mock.calls[0]?.[0] as Record; + expect(call.action).toBe("react"); + expect(String(call.chatId)).toBe("123"); + expect(String(call.messageId)).toBe("456"); + expect(call.emoji).toBe("ok"); + }); }); From 1168f5989031bf940acaa4efe0974d17f8efb687 Mon Sep 17 00:00:00 2001 From: Ayaan Zaidi Date: Fri, 30 Jan 2026 16:09:41 +0530 Subject: [PATCH 5/9] perf: skip redundant ui install in Dockerfile --- Dockerfile | 1 - 1 file changed, 1 deletion(-) diff --git a/Dockerfile b/Dockerfile index 904d1d97d..ad08bd37c 100644 --- a/Dockerfile +++ b/Dockerfile @@ -27,7 +27,6 @@ COPY . . RUN OPENCLAW_A2UI_SKIP_MISSING=1 pnpm build # Force pnpm for UI build (Bun may fail on ARM/Synology architectures) ENV OPENCLAW_PREFER_PNPM=1 -RUN pnpm ui:install RUN pnpm ui:build ENV NODE_ENV=production From fa9ec6e85452a81d21fcb046c770e519a16e8dda Mon Sep 17 00:00:00 2001 From: Ayaan Zaidi Date: Fri, 30 Jan 2026 16:24:54 +0530 Subject: [PATCH 6/9] fix: add docker ui install changelog entry (#4584) (thanks @obviyus) --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index dfaccc1f5..8b8b0e115 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -68,6 +68,7 @@ Status: stable. - Docs: credit both contributors for Control UI refresh. (#1852) Thanks @EnzeD. - Docs: keep docs header sticky so navbar stays visible while scrolling. (#2445) Thanks @chenyuan99. - Docs: update exe.dev install instructions. (#https://github.com/openclaw/openclaw/pull/3047) Thanks @zackerthescar. +- Build: skip redundant UI install step in the Dockerfile. (#4584) Thanks @obviyus. ### Breaking - **BREAKING:** Gateway auth mode "none" is removed; gateway now requires token/password (Tailscale Serve identity still allowed). From b05d57964b97216cd03ef7ed89351d4120bb9aa9 Mon Sep 17 00:00:00 2001 From: ThanhNguyxn Date: Fri, 30 Jan 2026 17:33:49 +0700 Subject: [PATCH 7/9] fix(telegram): properly nest overlapping HTML tags (#4071) Unify style and link closing in render.ts to use LIFO order across both element types, fixing cases where bold/italic spans containing autolinks produced invalid HTML like . --- src/markdown/render.ts | 22 +++++++++------------- src/telegram/format.test.ts | 10 ++++++++++ 2 files changed, 19 insertions(+), 13 deletions(-) diff --git a/src/markdown/render.ts b/src/markdown/render.ts index 502ab69ef..dad833a05 100644 --- a/src/markdown/render.ts +++ b/src/markdown/render.ts @@ -87,40 +87,36 @@ export function renderMarkdownWithMarkers(ir: MarkdownIR, options: RenderOptions } const points = [...boundaries].sort((a, b) => a - b); - const stack: MarkdownStyleSpan[] = []; + // Unified stack for both styles and links, tracking close string and end position + const stack: { close: string; end: number }[] = []; let out = ""; for (let i = 0; i < points.length; i += 1) { const pos = points[i]; + // Close ALL elements (styles and links) in LIFO order at this position while (stack.length && stack[stack.length - 1]?.end === pos) { - const span = stack.pop(); - if (!span) break; - const marker = styleMarkers[span.style]; - if (marker) out += marker.close; - } - - const closingLinks = linkEnds.get(pos); - if (closingLinks && closingLinks.length > 0) { - for (const link of closingLinks) { - out += link.close; - } + const item = stack.pop(); + if (item) out += item.close; } + // Open links first (so they close after styles that start at the same position) const openingLinks = linkStarts.get(pos); if (openingLinks && openingLinks.length > 0) { for (const link of openingLinks) { out += link.open; + stack.push({ close: link.close, end: link.end }); } } + // Open styles second (so they close before links that start at the same position) const openingStyles = startsAt.get(pos); if (openingStyles) { for (const span of openingStyles) { const marker = styleMarkers[span.style]; if (!marker) continue; - stack.push(span); out += marker.open; + stack.push({ close: marker.close, end: span.end }); } } diff --git a/src/telegram/format.test.ts b/src/telegram/format.test.ts index 831782815..9b2cb60c6 100644 --- a/src/telegram/format.test.ts +++ b/src/telegram/format.test.ts @@ -47,4 +47,14 @@ describe("markdownToTelegramHtml", () => { const res = markdownToTelegramHtml("```js\nconst x = 1;\n```"); expect(res).toBe("
const x = 1;\n
"); }); + + it("properly nests overlapping bold and autolink (#4071)", () => { + const res = markdownToTelegramHtml("**start https://example.com** end"); + expect(res).toMatch(/start https:\/\/example\.com<\/a><\/b> end/); + }); + + it("properly nests link inside bold", () => { + const res = markdownToTelegramHtml("**bold [link](https://example.com) text**"); + expect(res).toBe('bold link text'); + }); }); From 8e5a68444584b272257f94b06151f1ce5224bd22 Mon Sep 17 00:00:00 2001 From: ThanhNguyxn Date: Fri, 30 Jan 2026 17:36:16 +0700 Subject: [PATCH 8/9] style: format test file --- src/telegram/format.test.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/telegram/format.test.ts b/src/telegram/format.test.ts index 9b2cb60c6..fbc811b72 100644 --- a/src/telegram/format.test.ts +++ b/src/telegram/format.test.ts @@ -50,7 +50,9 @@ describe("markdownToTelegramHtml", () => { it("properly nests overlapping bold and autolink (#4071)", () => { const res = markdownToTelegramHtml("**start https://example.com** end"); - expect(res).toMatch(/start https:\/\/example\.com<\/a><\/b> end/); + expect(res).toMatch( + /start https:\/\/example\.com<\/a><\/b> end/, + ); }); it("properly nests link inside bold", () => { From da71eaebd2f613f8afaddccdff12cb28c1d7e0f4 Mon Sep 17 00:00:00 2001 From: Ayaan Zaidi Date: Fri, 30 Jan 2026 16:45:45 +0530 Subject: [PATCH 9/9] fix: correct telegram html nesting (#4578) (thanks @ThanhNguyxn) --- CHANGELOG.md | 1 + src/markdown/render.ts | 58 +++++++++++++++++++++++++++++-------- src/telegram/format.test.ts | 10 +++++++ 3 files changed, 57 insertions(+), 12 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8b8b0e115..191c2172d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -74,6 +74,7 @@ Status: stable. ### Fixes - Telegram: use undici fetch for per-account proxy dispatcher. (#4456) Thanks @spiceoogway. +- Telegram: fix HTML nesting for overlapping styles and links. (#4578) Thanks @ThanhNguyxn. - Telegram: avoid silent empty replies by tracking normalization skips before fallback. (#3796) - Telegram: accept numeric messageId/chatId in react action and honor channelId fallback. (#4533) Thanks @Ayush10. - Telegram: scope native skill commands to bound agent per bot. (#4360) Thanks @robhparker. diff --git a/src/markdown/render.ts b/src/markdown/render.ts index dad833a05..9793ab167 100644 --- a/src/markdown/render.ts +++ b/src/markdown/render.ts @@ -69,7 +69,6 @@ export function renderMarkdownWithMarkers(ir: MarkdownIR, options: RenderOptions } const linkStarts = new Map(); - const linkEnds = new Map(); if (options.buildLink) { for (const link of ir.links) { if (link.start === link.end) continue; @@ -80,15 +79,22 @@ export function renderMarkdownWithMarkers(ir: MarkdownIR, options: RenderOptions const openBucket = linkStarts.get(rendered.start); if (openBucket) openBucket.push(rendered); else linkStarts.set(rendered.start, [rendered]); - const closeBucket = linkEnds.get(rendered.end); - if (closeBucket) closeBucket.push(rendered); - else linkEnds.set(rendered.end, [rendered]); } } const points = [...boundaries].sort((a, b) => a - b); // Unified stack for both styles and links, tracking close string and end position const stack: { close: string; end: number }[] = []; + type OpeningItem = + | { end: number; open: string; close: string; kind: "link"; index: number } + | { + end: number; + open: string; + close: string; + kind: "style"; + style: MarkdownStyle; + index: number; + }; let out = ""; for (let i = 0; i < points.length; i += 1) { @@ -100,23 +106,51 @@ export function renderMarkdownWithMarkers(ir: MarkdownIR, options: RenderOptions if (item) out += item.close; } - // Open links first (so they close after styles that start at the same position) + const openingItems: OpeningItem[] = []; + const openingLinks = linkStarts.get(pos); if (openingLinks && openingLinks.length > 0) { - for (const link of openingLinks) { - out += link.open; - stack.push({ close: link.close, end: link.end }); + for (const [index, link] of openingLinks.entries()) { + openingItems.push({ + end: link.end, + open: link.open, + close: link.close, + kind: "link", + index, + }); } } - // Open styles second (so they close before links that start at the same position) const openingStyles = startsAt.get(pos); if (openingStyles) { - for (const span of openingStyles) { + for (const [index, span] of openingStyles.entries()) { const marker = styleMarkers[span.style]; if (!marker) continue; - out += marker.open; - stack.push({ close: marker.close, end: span.end }); + openingItems.push({ + end: span.end, + open: marker.open, + close: marker.close, + kind: "style", + style: span.style, + index, + }); + } + } + + if (openingItems.length > 0) { + openingItems.sort((a, b) => { + if (a.end !== b.end) return b.end - a.end; + if (a.kind !== b.kind) return a.kind === "link" ? -1 : 1; + if (a.kind === "style" && b.kind === "style") { + return (STYLE_RANK.get(a.style) ?? 0) - (STYLE_RANK.get(b.style) ?? 0); + } + return a.index - b.index; + }); + + // Open outer spans first (larger end) so LIFO closes stay valid for same-start overlaps. + for (const item of openingItems) { + out += item.open; + stack.push({ close: item.close, end: item.end }); } } diff --git a/src/telegram/format.test.ts b/src/telegram/format.test.ts index fbc811b72..e267719a8 100644 --- a/src/telegram/format.test.ts +++ b/src/telegram/format.test.ts @@ -59,4 +59,14 @@ describe("markdownToTelegramHtml", () => { const res = markdownToTelegramHtml("**bold [link](https://example.com) text**"); expect(res).toBe('bold link text'); }); + + it("properly nests bold wrapping a link with trailing text", () => { + const res = markdownToTelegramHtml("**[link](https://example.com) rest**"); + expect(res).toBe('link rest'); + }); + + it("properly nests bold inside a link", () => { + const res = markdownToTelegramHtml("[**bold**](https://example.com)"); + expect(res).toBe('bold'); + }); });