mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-12 21:53:00 -06:00
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:
committed by
GitHub
parent
4b751ce48a
commit
aa12e71de7
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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>";
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user