From 2e84017f23266a10200c99da7c9de86ef5a694fc Mon Sep 17 00:00:00 2001 From: Sid Date: Wed, 25 Feb 2026 12:54:51 +0800 Subject: [PATCH] fix(markdown): require paired || delimiters for spoiler detection (#26105) * fix(markdown): require paired || delimiters for spoiler detection An unpaired || (odd count across all inline tokens) would open a spoiler that never closes, causing closeRemainingStyles to extend it to the end of the text. This made all content after an unpaired || appear as hidden/spoiler in Telegram. Pre-count || delimiters across the entire inline token group and skip spoiler injection entirely when the count is less than 2 or odd. This prevents single | characters and unpaired || from triggering spoiler formatting. Closes #26068 Co-authored-by: Cursor * fix: preserve valid spoiler pairs with trailing unmatched delimiters (#26105) (thanks @Sid-Qin) --------- Co-authored-by: Cursor Co-authored-by: Peter Steinberger --- CHANGELOG.md | 1 + src/markdown/ir.ts | 28 ++++++++++++++++++++++++++++ src/telegram/format.test.ts | 18 ++++++++++++++++++ 3 files changed, 47 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 47dcf207ad8..627ee478a6d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,7 @@ Docs: https://docs.openclaw.ai - Agents/Model fallback: keep explicit text + image fallback chains reachable even when `agents.defaults.models` allowlists are present, prefer explicit run `agentId` over session-key parsing for followup fallback override resolution (with session-key fallback), treat agent-level fallback overrides as configured in embedded runner preflight, and classify `model_cooldown` / `cooling down` errors as `rate_limit` so failover continues. (#11972, #24137, #17231) - Followups/Routing: when explicit origin routing fails, allow same-channel fallback dispatch (while still blocking cross-channel fallback) so followup replies do not get dropped on transient origin-adapter failures. (#26109) Thanks @Sid-Qin. - Agents/Model fallback: continue fallback traversal on unrecognized errors when candidates remain, while still throwing the original unknown error on the last candidate. (#26106) Thanks @Sid-Qin. +- Telegram/Markdown spoilers: keep valid `||spoiler||` pairs while leaving unmatched trailing `||` delimiters as literal text, avoiding false all-or-nothing spoiler suppression. (#26105) Thanks @Sid-Qin. ## 2026.2.24 diff --git a/src/markdown/ir.ts b/src/markdown/ir.ts index 17203c6972d..bab451bc3e6 100644 --- a/src/markdown/ir.ts +++ b/src/markdown/ir.ts @@ -144,8 +144,31 @@ function applySpoilerTokens(tokens: MarkdownToken[]): void { } function injectSpoilersIntoInline(tokens: MarkdownToken[]): MarkdownToken[] { + let totalDelims = 0; + for (const token of tokens) { + if (token.type !== "text") { + continue; + } + const content = token.content ?? ""; + let i = 0; + while (i < content.length) { + const next = content.indexOf("||", i); + if (next === -1) { + break; + } + totalDelims += 1; + i = next + 2; + } + } + + if (totalDelims < 2) { + return tokens; + } + const usableDelims = totalDelims - (totalDelims % 2); + const result: MarkdownToken[] = []; const state = { spoilerOpen: false }; + let consumedDelims = 0; for (const token of tokens) { if (token.type !== "text") { @@ -168,9 +191,14 @@ function injectSpoilersIntoInline(tokens: MarkdownToken[]): MarkdownToken[] { } break; } + if (consumedDelims >= usableDelims) { + result.push(createTextToken(token, content.slice(index))); + break; + } if (next > index) { result.push(createTextToken(token, content.slice(index, next))); } + consumedDelims += 1; state.spoilerOpen = !state.spoilerOpen; result.push({ type: state.spoilerOpen ? "spoiler_open" : "spoiler_close", diff --git a/src/telegram/format.test.ts b/src/telegram/format.test.ts index 0e27bc074e3..ac4163b96f0 100644 --- a/src/telegram/format.test.ts +++ b/src/telegram/format.test.ts @@ -94,4 +94,22 @@ describe("markdownToTelegramHtml", () => { const res = markdownToTelegramHtml("||**secret** text||"); expect(res).toBe("secret text"); }); + + it("does not treat single pipe as spoiler", () => { + const res = markdownToTelegramHtml("( ̄_ ̄|) face"); + expect(res).not.toContain("tg-spoiler"); + expect(res).toContain("|"); + }); + + it("does not treat unpaired || as spoiler", () => { + const res = markdownToTelegramHtml("before || after"); + expect(res).not.toContain("tg-spoiler"); + expect(res).toContain("||"); + }); + + it("keeps valid spoiler pairs when a trailing || is unmatched", () => { + const res = markdownToTelegramHtml("||secret|| trailing ||"); + expect(res).toContain("secret"); + expect(res).toContain("trailing ||"); + }); });