refactor(html): share entity decoding

This commit is contained in:
Peter Steinberger
2026-07-14 07:57:03 +01:00
parent ab95871dd5
commit b58a75d726
17 changed files with 55 additions and 129 deletions

View File

@@ -35,6 +35,7 @@ Docs: https://docs.openclaw.ai
### Fixes
- **Microsoft Teams HTML text:** decode HTML5 entities consistently in quoted and Graph-fetched messages while preserving literal escaped entity text.
- **ClawHub plugin API ranges:** delegate each supported comparator to `semver` so tilde, partial-wildcard, and prerelease caret bounds are correct while preserving OpenClaw version normalization and the existing restricted range grammar. (#106877)
- **Web Readability relative links:** seed parsed documents with the request URL so article links resolve correctly while removing the plugin's duplicate lazy-loader facade. (#106860)
- **Browser auto-routing:** fall back to the Gateway host when an implicitly selected browser node reports that its control host is unreachable, while preserving explicit node pins and ambiguous action failures.

View File

@@ -1,2 +1,2 @@
6b509179fce1f5115037be7432b2dc21dda507351296ce5ca769e24741cece87 plugin-sdk-api-baseline.json
d317f5ef0a767f557357a2f7699e27e0f436edd18298401ee7a982805bcd1f76 plugin-sdk-api-baseline.jsonl
b01a5fcd6cf5e4f6189638b28a2fe17d227d7028a15e047f3a82915ab6b96ace plugin-sdk-api-baseline.json
03d5d1c2a6cefe69677f44e7df2a8864873b3bda812bd18d996043eb861e5c4e plugin-sdk-api-baseline.jsonl

View File

@@ -336,7 +336,7 @@ usage endpoint failed or returned no usable usage data.
| `plugin-sdk/session-binding-runtime` | Current conversation binding state without configured binding routing or pairing stores |
| `plugin-sdk/context-visibility-runtime` | Context visibility resolution and supplemental context filtering without broad config/security imports |
| `plugin-sdk/string-coerce-runtime` | Narrow primitive record/string coercion and normalization helpers without markdown/logging imports |
| `plugin-sdk/text-utility-runtime` | Low-level text and path helpers, including five-entity HTML escaping |
| `plugin-sdk/text-utility-runtime` | Low-level text and path helpers, including five-entity HTML escaping and single-pass semicolon-terminated HTML5 entity decoding |
| `plugin-sdk/host-runtime` | Hostname and SCP host normalization helpers |
| `plugin-sdk/retry-runtime` | Retry config and retry runner helpers |
| `plugin-sdk/agent-runtime` | Deprecated broad barrel for agent dir/identity/workspace helpers, including `resolveAgentDir`, `resolveDefaultAgentDir`, and the deprecated `resolveOpenClawAgentDir` compatibility export; prefer focused agent/runtime subpaths |

View File

@@ -15,10 +15,19 @@ import {
wrapWebContent,
writeCache,
} from "openclaw/plugin-sdk/provider-web-search";
import { decodeHtmlEntities as decodeHtmlEntity } from "openclaw/plugin-sdk/text-utility-runtime";
import { resolveDdgRegion, resolveDdgSafeSearch, type DdgSafeSearch } from "./config.js";
const DDG_HTML_ENDPOINT = "https://html.duckduckgo.com/html";
const DEFAULT_TIMEOUT_SECONDS = 20;
const DDG_HTML_ENTITY_RE =
/&(?:lt|gt|quot|apos|#39|#x27|#x2F|nbsp|ndash|mdash|hellip|amp|#\d+|#x[0-9a-f]+);/gi;
const DDG_ENTITY_TEXT: Readonly<Record<string, string>> = {
"\u00a0": " ",
"": "-",
"—": "--",
"…": "...",
};
const DDG_SAFE_SEARCH_PARAM: Record<DdgSafeSearch, string> = {
strict: "1",
moderate: "-1",
@@ -36,56 +45,11 @@ type DuckDuckGoResult = {
snippet: string;
};
function isDecodableCodePoint(cp: number): boolean {
return Number.isInteger(cp) && cp >= 0 && cp <= 0x10ffff && (cp < 0xd800 || cp > 0xdfff);
}
function decodeHtmlEntities(text: string): string {
return text.replace(
/&(?:lt|gt|quot|apos|#39|#x27|#x2F|nbsp|ndash|mdash|hellip|amp|#\d+|#x[0-9a-f]+);/gi,
(entity) => {
const normalized = entity.toLowerCase();
if (normalized === "&lt;") {
return "<";
}
if (normalized === "&gt;") {
return ">";
}
if (normalized === "&quot;") {
return '"';
}
if (normalized === "&apos;" || normalized === "&#39;" || normalized === "&#x27;") {
return "'";
}
if (normalized === "&#x2f;") {
return "/";
}
if (normalized === "&nbsp;") {
return " ";
}
if (normalized === "&ndash;") {
return "-";
}
if (normalized === "&mdash;") {
return "--";
}
if (normalized === "&hellip;") {
return "...";
}
if (normalized === "&amp;") {
return "&";
}
if (normalized.startsWith("&#x")) {
const codePoint = Number.parseInt(normalized.slice(3, -1), 16);
return isDecodableCodePoint(codePoint) ? String.fromCodePoint(codePoint) : entity;
}
if (normalized.startsWith("&#")) {
const codePoint = Number.parseInt(normalized.slice(2, -1), 10);
return isDecodableCodePoint(codePoint) ? String.fromCodePoint(codePoint) : entity;
}
return entity;
},
);
return text.replace(DDG_HTML_ENTITY_RE, (entity) => {
const decoded = decodeHtmlEntity(entity.startsWith("&#") ? entity : entity.toLowerCase());
return DDG_ENTITY_TEXT[decoded] ?? decoded;
});
}
function stripHtml(html: string): string {

View File

@@ -1,48 +1,21 @@
// Matrix plugin module implements mentions behavior.
import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/string-coerce-runtime";
import { escapeRegExp } from "openclaw/plugin-sdk/text-utility-runtime";
import { decodeHtmlEntities, escapeRegExp } from "openclaw/plugin-sdk/text-utility-runtime";
import { getMatrixRuntime } from "../../runtime.js";
import type { RoomMessageEventContent } from "./types.js";
const HTML_ENTITY_REPLACEMENTS: Readonly<Record<string, string>> = {
amp: "&",
apos: "'",
gt: ">",
lt: "<",
nbsp: " ",
quot: '"',
};
const MAX_UNICODE_SCALAR_VALUE = 0x10ffff;
const MATRIX_HTML_ENTITY_RE = /&(?:#x?[0-9a-f]+|amp|apos|gt|lt|nbsp|quot);/gi;
function decodeNumericHtmlEntity(match: string, rawValue: string, radix: 10 | 16): string {
const codePoint = Number.parseInt(rawValue, radix);
if (
!Number.isSafeInteger(codePoint) ||
codePoint < 0 ||
codePoint > MAX_UNICODE_SCALAR_VALUE ||
(codePoint >= 0xd800 && codePoint <= 0xdfff)
) {
return match;
}
return String.fromCodePoint(codePoint);
}
function decodeHtmlEntities(value: string): string {
return value.replace(/&(#x?[0-9a-f]+|\w+);/gi, (match, entity: string) => {
const normalized = normalizeLowercaseStringOrEmpty(entity);
if (normalized.startsWith("#x")) {
return decodeNumericHtmlEntity(match, normalized.slice(2), 16);
}
if (normalized.startsWith("#")) {
return decodeNumericHtmlEntity(match, normalized.slice(1), 10);
}
return HTML_ENTITY_REPLACEMENTS[normalized] ?? match;
function decodeVisibleHtmlEntities(value: string): string {
return value.replace(MATRIX_HTML_ENTITY_RE, (entity) => {
const decoded = decodeHtmlEntities(entity.startsWith("&#") ? entity : entity.toLowerCase());
return decoded === "\u00a0" ? " " : decoded;
});
}
function normalizeVisibleMentionText(value: string): string {
return normalizeLowercaseStringOrEmpty(
decodeHtmlEntities(
decodeVisibleHtmlEntities(
value.replace(/<[^>]+>/g, " ").replace(/[\u200b-\u200f\u202a-\u202e\u2060-\u206f]/g, ""),
).replace(/\s+/g, " "),
);

View File

@@ -30,10 +30,10 @@ describe("stripHtmlFromTeamsMessage", () => {
expect(stripHtmlFromTeamsMessage("<p>Hello <b>world</b></p>")).toBe("Hello world");
});
it("decodes common HTML entities", () => {
expect(stripHtmlFromTeamsMessage("&amp; &lt;b&gt; &quot;x&quot; &#39;y&#39; &nbsp;z")).toBe(
"& <b> \"x\" 'y' z",
);
it("decodes HTML5 entities", () => {
expect(
stripHtmlFromTeamsMessage("&amp; &lt;b&gt; &quot;x&quot; &#39;y&#39; &nbsp;z &copy;"),
).toBe("& <b> \"x\" 'y' z ©");
});
it("does not double-decode escaped entities (decodes &amp; last)", () => {

View File

@@ -1,4 +1,5 @@
// Msteams plugin module implements graph thread behavior.
import { decodeHtmlEntities } from "openclaw/plugin-sdk/text-utility-runtime";
import { fetchGraphJson, type GraphResponse } from "./graph.js";
import type { MSTeamsRequestDeadline } from "./request-timeout.js";
@@ -21,16 +22,8 @@ export function stripHtmlFromTeamsMessage(html: string): string {
let text = html.replace(/<at[^>]*>(.*?)<\/at>/gi, "@$1");
// Strip remaining HTML tags.
text = text.replace(/<[^>]*>/g, " ");
// Decode common HTML entities. &amp; must be decoded LAST to prevent
// double-decoding (e.g. &amp;lt; → &lt; not <), matching decodeHtmlEntities
// in inbound.ts.
text = text
.replace(/&lt;/g, "<")
.replace(/&gt;/g, ">")
.replace(/&quot;/g, '"')
.replace(/&#39;/g, "'")
.replace(/&nbsp;/g, " ")
.replace(/&amp;/g, "&");
// Single-pass decoding preserves literally typed entity text such as "&lt;".
text = decodeHtmlEntities(text).replaceAll("\u00a0", " ");
// Normalize whitespace.
return text.replace(/\s+/g, " ").trim();
}

View File

@@ -128,11 +128,11 @@ describe("msteams inbound", () => {
content:
'<blockquote itemtype="http://schema.skype.com/Reply" itemscope>' +
'<strong itemprop="mri">Bob</strong>' +
'<p itemprop="copy">2 &lt; 3 &amp; 4 &gt; 1</p>' +
'<p itemprop="copy">2 &lt; 3 &amp; 4 &gt; 1; &copy;&Tab;keep &amp;lt; literal</p>' +
"</blockquote>",
},
]);
expect(result).toEqual({ sender: "Bob", body: "2 < 3 & 4 > 1" });
expect(result).toEqual({ sender: "Bob", body: "2 < 3 & 4 > 1; © keep &lt; literal" });
});
it("handles multiline body by collapsing whitespace", () => {

View File

@@ -1,4 +1,6 @@
// Msteams plugin module implements inbound behavior.
import { decodeHtmlEntities } from "openclaw/plugin-sdk/text-utility-runtime";
type MSTeamsQuoteInfo = {
sender: string;
body: string;
@@ -10,30 +12,14 @@ type MSTeamsQuoteInfo = {
id?: string;
};
/**
* Decode common HTML entities to plain text.
*/
function decodeHtmlEntities(html: string): string {
return html
.replace(/&lt;/g, "<")
.replace(/&gt;/g, ">")
.replace(/&quot;/g, '"')
.replace(/&#39;/g, "'")
.replace(/&#x27;/g, "'")
.replace(/&nbsp;/g, " ")
.replace(/&amp;/g, "&"); // must be last to prevent double-decoding (e.g. &amp;lt; → &lt; not <)
}
/**
* Strip HTML tags, preserving text content.
*/
function htmlToPlainText(html: string): string {
return decodeHtmlEntities(
html
.replace(/<[^>]*>/g, " ")
.replace(/\s+/g, " ")
.trim(),
);
return decodeHtmlEntities(html.replace(/<[^>]*>/g, " "))
.replaceAll("\u00a0", " ")
.replace(/\s+/g, " ")
.trim();
}
/**

View File

@@ -205,14 +205,15 @@ export function readPluginSdkSurfaceBudgets(env = process.env) {
// ScopeTree adds six channel-policy exports, mirrored by compat, including three functions.
// Its flat channel-groups builder adds one function, also mirrored by compat.
// Its case-insensitive scope-key resolver adds one function, also mirrored by compat.
// The focused text utility HTML decoder adds one public function.
publicExports: readPluginSdkSurfaceBudgetEnv(
"OPENCLAW_PLUGIN_SDK_MAX_PUBLIC_EXPORTS",
10674,
10675,
env,
),
publicFunctionExports: readPluginSdkSurfaceBudgetEnv(
"OPENCLAW_PLUGIN_SDK_MAX_PUBLIC_FUNCTION_EXPORTS",
5375,
5376,
env,
),
publicDeprecatedExports: readPluginSdkSurfaceBudgetEnv(

View File

@@ -2,10 +2,10 @@
* Decodes HTML-entity escaped tool-call arguments in stream wrappers.
*/
import { streamSimple } from "../../llm/stream.js";
import { decodeHtmlEntities } from "../../shared/html-entities.js";
import { visitObjectContentBlocks } from "../../shared/message-content-blocks.js";
import type { StreamFn } from "../runtime/index.js";
import type { MutableAssistantMessageEventStream } from "../stream-compat.js";
import { decodeHtmlEntities } from "../utils/html.js";
/**
* Decodes HTML entities inside streamed tool-call arguments before downstream execution.

View File

@@ -4,7 +4,7 @@
* Converts lightweight HTML into bounded markdown/text without pulling in a full renderer.
*/
import { truncateUtf16Safe } from "@openclaw/normalization-core/utf16-slice";
import { decodeHtmlEntities } from "../utils/html.js";
import { decodeHtmlEntities } from "../../shared/html-entities.js";
import { sanitizeHtml, stripInvisibleUnicode } from "./web-fetch-visibility.js";
/** Output mode requested by web_fetch extraction. */

View File

@@ -5,7 +5,7 @@
* maps active scopes to caller-provided text formatters.
*/
import { createRequire } from "node:module";
import { decodeHtmlEntities } from "./html.js";
import { decodeHtmlEntities } from "../../shared/html-entities.js";
type HighlightJs = {
getLanguage(name: string): unknown;

View File

@@ -1,5 +1,5 @@
import { describe, expect, it } from "vitest";
import { escapeHtml } from "./text-utility-runtime.js";
import { decodeHtmlEntities, escapeHtml } from "./text-utility-runtime.js";
describe("escapeHtml", () => {
it("escapes five HTML-sensitive characters and existing entity markers", () => {
@@ -7,3 +7,9 @@ describe("escapeHtml", () => {
expect(escapeHtml("already &amp; escaped")).toBe("already &amp;amp; escaped");
});
});
describe("decodeHtmlEntities", () => {
it("exposes single-pass HTML5 decoding to plugins", () => {
expect(decodeHtmlEntities("&copy; &amp;lt; &#128512; &#xD800;")).toBe("© &lt; 😀 &#xD800;");
});
});

View File

@@ -10,6 +10,8 @@ export function escapeHtml(value: string): string {
.replaceAll("'", "&#39;");
}
export { decodeHtmlEntities } from "../shared/html-entities.js";
export {
CONFIG_DIR,
clamp,

View File

@@ -1,6 +1,6 @@
// Shared HTML entity tests cover all agent renderers and tool-argument repair callers.
import { describe, expect, it } from "vitest";
import { decodeHtmlEntities } from "./html.js";
import { decodeHtmlEntities } from "./html-entities.js";
describe("decodeHtmlEntities", () => {
it("decodes HTML5 named entities beyond the XML subset", () => {