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