fix(outbound): preserve attributed inline formatting (#104118)

* fix(outbound): preserve backticks on <code> tags with attributes

The plain-text sanitizer only matched bare <code> openers, so attributed
variants such as <code class="language-ts"> lost their backtick wrapping
and were stripped to raw text before channel delivery.

Allow optional attributes on the opening <code> tag, consistent with the
existing handling for <h[1-6]> and <li> in the same function.

Fixes #104117

* fix(outbound): preserve attributed inline formatting

Co-authored-by: chengzhichao-xydt <chengzhichao-xydt@users.noreply.github.com>

* 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 <moguangyu5-design@users.noreply.github.com>
Co-authored-by: Peter Steinberger <steipete@gmail.com>
Co-authored-by: chengzhichao-xydt <chengzhichao-xydt@users.noreply.github.com>
This commit is contained in:
moguangyu5-design
2026-07-12 03:03:11 +08:00
committed by GitHub
parent 4b751ce48a
commit aa12e71de7
8 changed files with 79 additions and 10 deletions
+1
View File
@@ -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
+18
View File
@@ -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
+3 -1
View File
@@ -351,7 +351,9 @@ export const imessagePlugin: ChannelPlugin<ResolvedIMessageAccount, IMessageProb
chunker: chunkTextForOutbound,
chunkerMode: "text",
textChunkLimit: 4000,
sanitizeText: ({ text }) => 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: {
@@ -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 = `<strong title="b>">bold</strong> <del data-note='s>'>strike</del>`;
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",
+3 -1
View File
@@ -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,
@@ -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 = `<strong title="b>">bold</strong> <del data-note='s>'>strike</del>`;
const sanitized = telegramOutbound.sanitizeText?.({ text, payload: { text } });
expect(sanitized).toBe("**bold** ~~strike~~");
expect(markdownToTelegramHtml(sanitized ?? "")).toBe("<b>bold</b> <s>strike</s>");
});
it("preserves explicit HTML parse mode before chunking", () => {
clearTelegramRuntime();
const text = "<b>hi</b>";
+14
View File
@@ -42,6 +42,17 @@ describe("sanitizeForPlainText", () => {
expect(sanitizeForPlainText("<code>foo()</code>")).toBe("`foo()`");
});
it("converts attributed inline tags without matching tag-name prefixes", () => {
const attributed = `<strong title="b>"><em title='i>'><del data-note="s>"><code class='c>'>x</code></del></em></strong>`;
expect(sanitizeForPlainText(attributed)).toBe("*_~`x`~_*");
expect(sanitizeForPlainText(attributed, { style: "markdown" })).toBe("**_~~`x`~~_**");
expect(
sanitizeForPlainText(
'<bold title="b">b</bold><strikeout title="s">s</strikeout><codebase>c</codebase>',
),
).toBe("bsc");
});
// --- block elements -----------------------------------------------------
it("converts <p> and <div> to newlines", () => {
@@ -51,6 +62,9 @@ describe("sanitizeForPlainText", () => {
it("converts headings to bold text with newlines", () => {
expect(sanitizeForPlainText("<h1>Title</h1>")).toBe("\n*Title*\n");
expect(sanitizeForPlainText("<h3>Section</h3>")).toBe("\n*Section*\n");
expect(sanitizeForPlainText('<h2 title="section">Markdown</h2>', { style: "markdown" })).toBe(
"\n**Markdown**\n",
);
});
it("converts <li> to bullet points", () => {
+15 -7
View File
@@ -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(/<br\s*\/?>/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>(.*?)<\/code>/gi, "`$1`")
// Headings → bold text with newline
.replace(/<h[1-6][^>]*>(.*?)<\/h[1-6]>/gi, "\n*$1*\n")
.replace(/<h[1-6]>(.*?)<\/h[1-6]>/gi, `\n${boldMarker}$1${boldMarker}\n`)
// List items → bullet points
.replace(/<li[^>]*>(.*?)<\/li>/gi, "• $1\n");
.replace(/<li>(.*?)<\/li>/gi, "• $1\n");
return stripRemainingHtmlTags(converted).replace(/\n{3,}/g, "\n\n");
}