From aa12e71de7b4ef80f6275b9c4a7d3485d6f250e0 Mon Sep 17 00:00:00 2001 From: moguangyu5-design Date: Sun, 12 Jul 2026 03:03:11 +0800 Subject: [PATCH] fix(outbound): preserve attributed inline formatting (#104118) * fix(outbound): preserve backticks on tags with attributes The plain-text sanitizer only matched bare openers, so attributed variants such as lost their backtick wrapping and were stripped to raw text before channel delivery. Allow optional attributes on the opening tag, consistent with the existing handling for and
  • in the same function. Fixes #104117 * fix(outbound): preserve attributed inline formatting Co-authored-by: chengzhichao-xydt * test(outbound): compact attributed tag coverage * fix(outbound): normalize attributed formatting tags * docs(outbound): clarify attribute normalization invariant * fix(outbound): preserve native formatting semantics * docs(plugin-sdk): document sanitizer markup styles * docs(plugin-sdk): refresh docs map --------- Co-authored-by: moguangyu5-design Co-authored-by: Peter Steinberger Co-authored-by: chengzhichao-xydt --- docs/docs_map.md | 1 + docs/plugins/sdk-channel-outbound.md | 18 +++++++++++++++ extensions/imessage/src/channel.ts | 4 +++- extensions/imessage/src/test-plugin.test.ts | 15 +++++++++++++ extensions/telegram/src/outbound-adapter.ts | 4 +++- .../telegram/src/telegram-outbound.test.ts | 11 +++++++++- src/infra/outbound/sanitize-text.test.ts | 14 ++++++++++++ src/infra/outbound/sanitize-text.ts | 22 +++++++++++++------ 8 files changed, 79 insertions(+), 10 deletions(-) diff --git a/docs/docs_map.md b/docs/docs_map.md index 3588a72e5bce..560e5e94d575 100644 --- a/docs/docs_map.md +++ b/docs/docs_map.md @@ -7103,6 +7103,7 @@ Do not edit it by hand; run `pnpm docs:map:gen`. - Route: /plugins/sdk-channel-outbound - Headings: - H2: Adapter + - H2: Plain-text sanitization - H2: Delivery Evidence - H2: Existing outbound adapters - H2: Durable sends diff --git a/docs/plugins/sdk-channel-outbound.md b/docs/plugins/sdk-channel-outbound.md index 74048db450cb..83b3fd04c7b4 100644 --- a/docs/plugins/sdk-channel-outbound.md +++ b/docs/plugins/sdk-channel-outbound.md @@ -66,6 +66,24 @@ Only declare capabilities the native transport actually preserves. Cover each declared send, receipt, live-preview, and receive-ack capability with the contract helpers exported from this subpath. +## Plain-text sanitization + +Use `sanitizeForPlainText(...)` when an outbound adapter needs to convert the +supported HTML formatting tags into lightweight text markup. The default keeps +the existing chat-style bold and strikethrough markers. Pass +`{ style: "markdown" }` only when the channel reparses the result as Markdown: + +```ts +import { sanitizeForPlainText } from "openclaw/plugin-sdk/channel-outbound"; + +const chatText = sanitizeForPlainText(text); +const markdownText = sanitizeForPlainText(text, { style: "markdown" }); +``` + +The Markdown style uses `**bold**` and `~~strikethrough~~`; italic and inline +code keep `_italic_` and backtick markers in both styles. Select the style at +the channel boundary instead of rewriting marker text after sanitization. + ## Delivery Evidence A `MessageReceipt` records the result returned by a channel adapter. Concrete diff --git a/extensions/imessage/src/channel.ts b/extensions/imessage/src/channel.ts index d5619eedfe6a..694d211efed5 100644 --- a/extensions/imessage/src/channel.ts +++ b/extensions/imessage/src/channel.ts @@ -351,7 +351,9 @@ export const imessagePlugin: ChannelPlugin sanitizeForPlainText(sanitizeOutboundText(text)), + // Native formatting consumes Markdown ranges, so preserve bold and strike semantics. + sanitizeText: ({ text }) => + sanitizeForPlainText(sanitizeOutboundText(text), { style: "markdown" }), shouldSuppressLocalPayloadPrompt: ({ cfg, accountId, payload, hint }) => shouldSuppressLocalIMessageExecApprovalPrompt({ cfg, accountId, payload, hint }), deliveryCapabilities: { diff --git a/extensions/imessage/src/test-plugin.test.ts b/extensions/imessage/src/test-plugin.test.ts index 54b0d627400c..ce8125863f59 100644 --- a/extensions/imessage/src/test-plugin.test.ts +++ b/extensions/imessage/src/test-plugin.test.ts @@ -12,6 +12,7 @@ import { import { afterEach, beforeEach, describe, expect, it } from "vitest"; import { imessagePlugin } from "./channel.js"; import { createIMessageTestPlugin } from "./imessage.test-plugin.js"; +import { extractMarkdownFormatRuns } from "./markdown-format.js"; beforeEach(() => { resetFacadeRuntimeStateForTest(); @@ -112,6 +113,20 @@ describe("createIMessageTestPlugin", () => { }); }); + it("preserves sanitized HTML formatting as native ranges", () => { + const text = `bold strike`; + const sanitized = imessagePlugin.outbound?.sanitizeText?.({ text, payload: { text } }); + + expect(sanitized).toBe("**bold** ~~strike~~"); + expect(extractMarkdownFormatRuns(sanitized ?? "")).toEqual({ + text: "bold strike", + ranges: [ + { start: 0, length: 4, styles: ["bold"] }, + { start: 5, length: 6, styles: ["strikethrough"] }, + ], + }); + }); + it("declares native iMessage voice memo TTS delivery", () => { expect(imessagePlugin.capabilities.tts?.voice).toStrictEqual({ synthesisTarget: "audio-file", diff --git a/extensions/telegram/src/outbound-adapter.ts b/extensions/telegram/src/outbound-adapter.ts index 9297c1f1e319..b3dc71579b73 100644 --- a/extensions/telegram/src/outbound-adapter.ts +++ b/extensions/telegram/src/outbound-adapter.ts @@ -249,7 +249,9 @@ export function createTelegramOutboundAdapter( chunkerMode: "markdown", extractMarkdownImages: true, textChunkLimit: TELEGRAM_TEXT_CHUNK_LIMIT, - sanitizeText: ({ text }) => sanitizeForPlainText(sanitizeAssistantVisibleText(text)), + // Default Telegram delivery reparses this result as Markdown; use its bold and strike delimiters. + sanitizeText: ({ text }) => + sanitizeForPlainText(sanitizeAssistantVisibleText(text), { style: "markdown" }), shouldSuppressLocalPayloadPrompt: options.shouldSuppressLocalPayloadPrompt, beforeDeliverPayload: options.beforeDeliverPayload, shouldTreatDeliveredTextAsVisible: options.shouldTreatDeliveredTextAsVisible, diff --git a/extensions/telegram/src/telegram-outbound.test.ts b/extensions/telegram/src/telegram-outbound.test.ts index 68fdcc1289ef..f73c57365e5b 100644 --- a/extensions/telegram/src/telegram-outbound.test.ts +++ b/extensions/telegram/src/telegram-outbound.test.ts @@ -2,7 +2,7 @@ import { chunkMarkdownTextWithMode } from "openclaw/plugin-sdk/reply-chunking"; import { sendTextMediaPayload } from "openclaw/plugin-sdk/reply-payload"; // Telegram tests cover telegram outbound plugin behavior. import { describe, expect, it, vi } from "vitest"; -import { splitTelegramHtmlChunks } from "./format.js"; +import { markdownToTelegramHtml, splitTelegramHtmlChunks } from "./format.js"; import { telegramOutbound } from "./outbound-adapter.js"; import { clearTelegramRuntime } from "./runtime.js"; @@ -47,6 +47,15 @@ describe("telegramPlugin outbound", () => { expect(telegramOutbound.sanitizeText?.({ text, payload: { text } })).toBe(text); }); + it("uses Telegram markdown markers for sanitized HTML formatting", () => { + clearTelegramRuntime(); + const text = `bold strike`; + const sanitized = telegramOutbound.sanitizeText?.({ text, payload: { text } }); + + expect(sanitized).toBe("**bold** ~~strike~~"); + expect(markdownToTelegramHtml(sanitized ?? "")).toBe("bold strike"); + }); + it("preserves explicit HTML parse mode before chunking", () => { clearTelegramRuntime(); const text = "hi"; diff --git a/src/infra/outbound/sanitize-text.test.ts b/src/infra/outbound/sanitize-text.test.ts index c046d519beb9..0717ee108ea0 100644 --- a/src/infra/outbound/sanitize-text.test.ts +++ b/src/infra/outbound/sanitize-text.test.ts @@ -42,6 +42,17 @@ describe("sanitizeForPlainText", () => { expect(sanitizeForPlainText("foo()")).toBe("`foo()`"); }); + it("converts attributed inline tags without matching tag-name prefixes", () => { + const attributed = `x`; + expect(sanitizeForPlainText(attributed)).toBe("*_~`x`~_*"); + expect(sanitizeForPlainText(attributed, { style: "markdown" })).toBe("**_~~`x`~~_**"); + expect( + sanitizeForPlainText( + 'bsc', + ), + ).toBe("bsc"); + }); + // --- block elements ----------------------------------------------------- it("converts

    and

    to newlines", () => { @@ -51,6 +62,9 @@ describe("sanitizeForPlainText", () => { it("converts headings to bold text with newlines", () => { expect(sanitizeForPlainText("

    Title

    ")).toBe("\n*Title*\n"); expect(sanitizeForPlainText("

    Section

    ")).toBe("\n*Section*\n"); + expect(sanitizeForPlainText('

    Markdown

    ', { style: "markdown" })).toBe( + "\n**Markdown**\n", + ); }); it("converts
  • to bullet points", () => { diff --git a/src/infra/outbound/sanitize-text.ts b/src/infra/outbound/sanitize-text.ts index eb4c0ca8f954..a61df4e6dcee 100644 --- a/src/infra/outbound/sanitize-text.ts +++ b/src/infra/outbound/sanitize-text.ts @@ -7,6 +7,10 @@ export { stripInternalRuntimeScaffolding }; const HTML_TAG_RE = /<\/?[a-z][a-z0-9_-]*\b[^>]*>/gi; +// Quoted attribute values may contain `>`; normalize convertible openers without leaking attribute text. +const CONVERTIBLE_HTML_OPEN_TAG_RE = + /<(b|strong|i|em|s|strike|del|code|h[1-6]|li)(?=\s|>)(?:[^"'<>]|"[^"]*"|'[^']*')*>/gi; + function stripRemainingHtmlTags(text: string): string { let previous: string; let current = text; @@ -25,26 +29,30 @@ function stripRemainingHtmlTags(text: string): string { * are known to produce and avoids false positives on angle brackets in normal * prose (e.g. `a < b`). */ -export function sanitizeForPlainText(text: string): string { +export function sanitizeForPlainText(text: string, options: { style?: "markdown" } = {}): string { + const boldMarker = options.style === "markdown" ? "**" : "*"; + const strikeMarker = options.style === "markdown" ? "~~" : "~"; const converted = stripInternalRuntimeScaffolding(text) // Preserve angle-bracket autolinks as plain URLs before tag stripping. .replace(/<((?:https?:\/\/|mailto:)[^<>\s]+)>/gi, "$1") + // Normalize attributes once; conversions below only need exact bare tag names. + .replace(CONVERTIBLE_HTML_OPEN_TAG_RE, "<$1>") // Line breaks .replace(//gi, "\n") // Block elements → newlines .replace(/<\/?(p|div)>/gi, "\n") - // Bold → WhatsApp/Signal bold - .replace(/<(b|strong)>(.*?)<\/\1>/gi, "*$2*") + // Bold → selected lightweight markup + .replace(/<(b|strong)>(.*?)<\/\1>/gi, `${boldMarker}$2${boldMarker}`) // Italic → WhatsApp/Signal italic .replace(/<(i|em)>(.*?)<\/\1>/gi, "_$2_") - // Strikethrough → WhatsApp/Signal strikethrough - .replace(/<(s|strike|del)>(.*?)<\/\1>/gi, "~$2~") + // Strikethrough → selected lightweight markup + .replace(/<(s|strike|del)>(.*?)<\/\1>/gi, `${strikeMarker}$2${strikeMarker}`) // Inline code .replace(/(.*?)<\/code>/gi, "`$1`") // Headings → bold text with newline - .replace(/]*>(.*?)<\/h[1-6]>/gi, "\n*$1*\n") + .replace(/(.*?)<\/h[1-6]>/gi, `\n${boldMarker}$1${boldMarker}\n`) // List items → bullet points - .replace(/]*>(.*?)<\/li>/gi, "• $1\n"); + .replace(/
  • (.*?)<\/li>/gi, "• $1\n"); return stripRemainingHtmlTags(converted).replace(/\n{3,}/g, "\n\n"); }