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 ||");
+ });
});