feat(matrix): render spoilers, underline, and native tables (#113199)

* feat(matrix): add native markdown capabilities

* chore(matrix): remove stale runtime accessor

* chore(matrix): satisfy extension lint

* test(matrix): complete formatter runtime coverage
This commit is contained in:
Peter Steinberger
2026-07-23 19:05:38 -07:00
committed by GitHub
parent 90aee82793
commit 21103ff8e9
12 changed files with 1329 additions and 122 deletions
@@ -259,7 +259,7 @@ describe("matrix channel message adapter", () => {
});
});
it("declares bullets as the markdown table default", () => {
expect(matrixPlugin.messaging?.defaultMarkdownTableMode).toBe("bullets");
it("declares native blocks as the markdown table default", () => {
expect(matrixPlugin.messaging?.defaultMarkdownTableMode).toBe("block");
});
});
+1 -1
View File
@@ -475,7 +475,7 @@ export const matrixPlugin: ChannelPlugin<ResolvedMatrixAccount, MatrixProbe> =
}).map(projectMatrixConversationBinding),
},
messaging: {
defaultMarkdownTableMode: "bullets",
defaultMarkdownTableMode: "block",
targetPrefixes: ["matrix"],
targetIdComparison: "case-sensitive",
normalizeTarget: normalizeMatrixMessagingTarget,
@@ -19,6 +19,7 @@ function installMatrixActionTestRuntime(): void {
channel: {
text: {
resolveMarkdownTableMode: () => "code",
resolveTextChunkLimit: () => 4_000,
convertMarkdownTables: (text: string) => text,
},
},
@@ -0,0 +1,83 @@
// Matrix helper module declares formatting capabilities and shared projections.
import type { MarkdownTableMode } from "openclaw/plugin-sdk/config-contracts";
import {
convertMarkdownTables,
type FormatCapabilityProfile,
renderMarkdownWithMarkers,
} from "openclaw/plugin-sdk/text-chunking";
export type MatrixSpoilerMarkers = { open: string; close: string; padding: string };
export type MatrixSpoilerProtection = { markdown: string; markers?: MatrixSpoilerMarkers };
export function createMatrixPrivateMarkers(
markdown: string,
exhaustedMessage: string,
): MatrixSpoilerMarkers {
const used = new Set(Array.from(markdown, (character) => character.charCodeAt(0)));
for (const match of markdown.matchAll(/&#(?:x([0-9a-f]+)|(\d+));/giu)) {
const radix = match[1] ? 16 : 10;
const value = Number.parseInt(match[1] ?? match[2] ?? "", radix);
if (Number.isFinite(value) && value <= 0xffff) {
used.add(value);
}
}
const markers: string[] = [];
for (let code = 0xe000; code <= 0xf8ff && markers.length < 3; code += 1) {
if (!used.has(code)) {
markers.push(String.fromCharCode(code));
}
}
if (markers.length < 3) {
throw new Error(exhaustedMessage);
}
return { open: markers[0] ?? "", close: markers[1] ?? "", padding: markers[2] ?? "" };
}
export const MATRIX_FORMAT_PROFILE = {
mechanism: "html",
constructs: {
bold: "native",
italic: "native",
underline: "native",
strikethrough: "native",
spoiler: "native",
codeInline: "native",
codeBlock: "native",
codeLanguage: "native",
linkLabel: "native",
heading: "native",
bulletList: "native",
orderedList: "native",
taskList: "fallback",
table: "native",
blockquote: "native",
image: "fallback",
mention: "native",
},
chunk: { limit: 4_000, unit: "chars" },
} satisfies FormatCapabilityProfile;
export function isMarkdownEscaped(markdown: string, index: number): boolean {
let slashCount = 0;
let cursor = index - 1;
while (cursor >= 0 && markdown[cursor] === "\\") {
slashCount += 1;
cursor -= 1;
}
return slashCount % 2 === 1;
}
export function projectMatrixMarkdown(markdown: string): string {
const normalized = (markdown ?? "").replace(/\r\n?/gu, "\n");
return renderMarkdownWithMarkers(
{ text: normalized, styles: [], links: [] },
{ styleMarkers: {}, escapeText: (text) => text },
MATRIX_FORMAT_PROFILE,
);
}
export function renderMatrixMarkdownTables(markdown: string, mode: MarkdownTableMode): string {
const useNativeTable =
MATRIX_FORMAT_PROFILE.constructs.table === "native" && (mode === "off" || mode === "block");
return useNativeTable ? markdown : convertMarkdownTables(markdown, mode);
}
@@ -0,0 +1,212 @@
// Matrix helper module resolves spoiler delimiters in ordinary Markdown inline blocks.
import MarkdownIt from "markdown-it";
import { findCodeRegions, isInsideCode, tokenizeHtmlTags } from "openclaw/plugin-sdk/text-chunking";
import { isMarkdownEscaped, projectMatrixMarkdown } from "./format-profile.js";
import { findMatrixTableSourceRanges } from "./format-table-ranges.js";
const spoilerParser = new MarkdownIt({ html: false, linkify: true, typographer: false });
function findInlineMetadataRanges(
markdown: string,
references: ReadonlySet<string>,
): Array<{ start: number; end: number }> {
const ranges: Array<{ start: number; end: number }> = [];
const labelStack: number[] = [];
const codeRegions = findCodeRegions(markdown);
const underlineTags = [...tokenizeHtmlTags(markdown)].filter(
(tag) => tag.name === "u" || tag.name === "ins",
);
for (let index = 0; index < markdown.length - 1; index += 1) {
const underlineTag = underlineTags.find((tag) => tag.start === index);
if (underlineTag) {
index = underlineTag.end - 1;
continue;
}
if (isInsideCode(index, codeRegions)) {
continue;
}
if (markdown[index] === "\n" && markdown[index + 1] === "\n") {
labelStack.length = 0;
continue;
}
if (markdown[index] === "[" && !isMarkdownEscaped(markdown, index)) {
labelStack.push(index);
continue;
}
if (
markdown[index] === "]" &&
markdown[index + 1] === "(" &&
!isMarkdownEscaped(markdown, index) &&
labelStack.pop() !== undefined
) {
const start = index + 2;
let cursor = start;
while (/[\s]/u.test(markdown[cursor] ?? "")) {
cursor += 1;
}
const destination = spoilerParser.helpers.parseLinkDestination(
markdown,
cursor,
markdown.length,
);
if (destination.ok) {
cursor = destination.pos;
while (/[\s]/u.test(markdown[cursor] ?? "")) {
cursor += 1;
}
const title = spoilerParser.helpers.parseLinkTitle(markdown, cursor, markdown.length);
if (title.ok) {
cursor = title.pos;
while (/[\s]/u.test(markdown[cursor] ?? "")) {
cursor += 1;
}
}
}
if (destination.ok && markdown[cursor] === ")") {
ranges.push({ start, end: cursor + 1 });
index = cursor;
}
continue;
}
if (
markdown[index] === "]" &&
markdown[index + 1] === "[" &&
!isMarkdownEscaped(markdown, index) &&
labelStack.pop() !== undefined
) {
let end = index + 2;
while (end < markdown.length && (markdown[end] !== "]" || isMarkdownEscaped(markdown, end))) {
end += 1;
}
const reference = spoilerParser.utils.normalizeReference(markdown.slice(index + 2, end));
if (end < markdown.length && references.has(reference)) {
ranges.push({ start: index + 2, end });
index = end;
}
continue;
}
if (markdown[index] === "]" && !isMarkdownEscaped(markdown, index)) {
labelStack.pop();
}
const autolink = /^<[A-Za-z][A-Za-z0-9+.-]{1,31}:[^<>\s]*>/u.exec(markdown.slice(index));
if (autolink && !isMarkdownEscaped(markdown, index)) {
ranges.push({ start: index, end: index + autolink[0].length });
index += autolink[0].length - 1;
continue;
}
const emailAutolink = /^<[^<>\s@]+@[^<>\s@]+>/u.exec(markdown.slice(index));
if (emailAutolink && !isMarkdownEscaped(markdown, index)) {
ranges.push({ start: index, end: index + emailAutolink[0].length });
index += emailAutolink[0].length - 1;
}
}
return ranges;
}
export function findMatrixMarkdownMetadataRanges(
markdown: string,
): Array<{ start: number; end: number }> {
const env: { references?: Record<string, unknown> } = {};
const tokens = spoilerParser.parse(markdown, env);
const references = new Set(Object.keys(env.references ?? {}));
const lineStarts = [0];
for (let index = 0; index < markdown.length; index += 1) {
if (markdown[index] === "\n") {
lineStarts.push(index + 1);
}
}
lineStarts.push(markdown.length);
const ranges = tokens.flatMap((token) => {
if (token.type !== "inline" || !token.map) {
return [];
}
const start = lineStarts[token.map[0]] ?? 0;
const end = lineStarts[token.map[1]] ?? markdown.length;
return findInlineMetadataRanges(markdown.slice(start, end), references).map((range) => ({
start: start + range.start,
end: start + range.end,
}));
});
for (const match of markdown.matchAll(/^\s*\[[^\]\n]+\]:\s*.+$/gmu)) {
const start = match.index ?? 0;
const labelEnd = match[0].indexOf("]:");
const reference = spoilerParser.utils.normalizeReference(match[0].slice(1, labelEnd));
if (references.has(reference)) {
let end = start + match[0].length;
const continuation = /^\n[ \t]+(?:"[^"\n]*"|'[^'\n]*'|\([^\n)]*\))[ \t]*/u.exec(
markdown.slice(end),
);
end += continuation?.[0].length ?? 0;
ranges.push({ start, end });
}
}
const codeRegions = findCodeRegions(markdown);
for (const match of spoilerParser.linkify.match(markdown) ?? []) {
if (!isInsideCode(match.index, codeRegions)) {
ranges.push({ start: match.index, end: match.lastIndex });
}
}
return ranges;
}
export function findMatrixSpoilerDelimiterOffsets(markdown: string): number[] {
const projected = projectMatrixMarkdown(markdown);
const tokens = spoilerParser.parse(projected, {});
const lineStarts = [0];
for (let index = 0; index < projected.length; index += 1) {
if (projected[index] === "\n") {
lineStarts.push(index + 1);
}
}
lineStarts.push(projected.length);
const excludedRanges = [
...findCodeRegions(projected),
...findMatrixMarkdownMetadataRanges(projected),
...[...tokenizeHtmlTags(projected)].flatMap((tag) =>
tag.name === "u" || tag.name === "ins" ? [{ start: tag.start, end: tag.end }] : [],
),
];
const offsets: number[] = [];
for (const token of tokens) {
// Table-cell inline tokens have no source map because pipes belong to GFM table grammar.
if (token.type !== "inline" || !token.map) {
continue;
}
const start = lineStarts[token.map[0]] ?? 0;
const end = lineStarts[token.map[1]] ?? projected.length;
const candidates: number[] = [];
for (let index = start; index < end - 1; index += 1) {
if (projected[index] !== "|" || projected[index + 1] !== "|") {
continue;
}
const excluded = excludedRanges.some((range) => index >= range.start && index < range.end);
if (isMarkdownEscaped(projected, index) || excluded) {
continue;
}
candidates.push(index);
index += 1;
}
candidates.length -= candidates.length % 2;
offsets.push(...candidates);
}
return [...new Set(offsets)].toSorted((left, right) => left - right);
}
export function hasMatrixSpoilerMetadataCollision(markdown: string): boolean {
const projected = projectMatrixMarkdown(markdown);
const ordinary = new Set(findMatrixSpoilerDelimiterOffsets(projected));
const tables = findMatrixTableSourceRanges(projected);
for (let index = 0; index < projected.length - 1; index += 1) {
if (projected[index] !== "|" || projected[index + 1] !== "|") {
continue;
}
if (ordinary.has(index) || isMarkdownEscaped(projected, index)) {
continue;
}
if (tables.some((range) => index >= range.start && index < range.end)) {
continue;
}
return true;
}
return false;
}
@@ -0,0 +1,23 @@
// Matrix helper module resolves source ranges owned by Markdown table blocks.
import MarkdownIt from "markdown-it";
const tableParser = new MarkdownIt({ html: false, linkify: false, typographer: false });
export function findMatrixTableSourceRanges(
markdown: string,
): Array<{ start: number; end: number }> {
const lineStarts = [0];
for (let index = 0; index < markdown.length; index += 1) {
if (markdown[index] === "\n") {
lineStarts.push(index + 1);
}
}
lineStarts.push(markdown.length);
return tableParser.parse(markdown, {}).flatMap((token) => {
if (token.type !== "table_open" || !token.map) {
return [];
}
const start = lineStarts[token.map[0]] ?? 0;
return [{ start, end: lineStarts[token.map[1]] ?? markdown.length }];
});
}
+186 -1
View File
@@ -1,6 +1,13 @@
// Matrix tests cover format plugin behavior.
import { describe, expect, it } from "vitest";
import { markdownToMatrixHtml, renderMarkdownToMatrixHtmlWithMentions } from "./format.js";
import { findMatrixSpoilerDelimiterOffsets } from "./format-spoiler-ranges.js";
import {
MATRIX_FORMAT_PROFILE,
markdownToMatrixBody,
markdownToMatrixHtml,
renderMarkdownToMatrixHtmlWithMentions,
renderMatrixMarkdownTables,
} from "./format.js";
function createMentionClient(selfUserId = "@bot:example.org") {
return {
@@ -8,6 +15,184 @@ function createMentionClient(selfUserId = "@bot:example.org") {
} as unknown as import("./sdk.js").MatrixClient;
}
const MATRIX_FORMAT_GOLDENS = [
{
name: "spoiler",
markdown: "before ||secret|| after",
previousHtml: "<p>before ||secret|| after</p>",
html: "<p>before <span data-mx-spoiler>secret</span> after</p>",
body: "before [Spoiler] after",
},
{
name: "authored underline",
markdown: "<u>under</u> and <ins>inserted</ins>",
previousHtml: "<p>&lt;u&gt;under&lt;/u&gt; and &lt;ins&gt;inserted&lt;/ins&gt;</p>",
html: "<p><u>under</u> and <u>inserted</u></p>",
body: "<u>under</u> and <ins>inserted</ins>",
},
{
name: "native table",
markdown: "| Name | Age |\n|---|---|\n| Alice | 30 |",
previousHtml: "<p><strong>Alice</strong><br>\n• Age: 30</p>",
html: "<table>\n<thead>\n<tr>\n<th>Name</th>\n<th>Age</th>\n</tr>\n</thead>\n<tbody>\n<tr>\n<td>Alice</td>\n<td>30</td>\n</tr>\n</tbody>\n</table>",
body: "| Name | Age |\n|---|---|\n| Alice | 30 |",
},
] as const;
describe("Matrix formatting migration goldens", () => {
for (const golden of MATRIX_FORMAT_GOLDENS) {
it(`${golden.name}: emits the authorized before-to-after payload`, () => {
expect(markdownToMatrixHtml(golden.markdown)).toBe(golden.html);
expect(markdownToMatrixBody(golden.markdown)).toBe(golden.body);
expect(golden.html).not.toBe(golden.previousHtml);
});
}
it("declares the Matrix HTML profile and keeps explicit table fallbacks", () => {
expect(MATRIX_FORMAT_PROFILE).toMatchObject({
mechanism: "html",
constructs: { spoiler: "native", underline: "native", table: "native" },
chunk: { limit: 4_000, unit: "chars" },
});
expect(renderMatrixMarkdownTables(MATRIX_FORMAT_GOLDENS[2].markdown, "block")).toBe(
MATRIX_FORMAT_GOLDENS[2].markdown,
);
expect(renderMatrixMarkdownTables(MATRIX_FORMAT_GOLDENS[2].markdown, "bullets")).toBe(
"**Alice**\n• Age: 30",
);
expect(
markdownToMatrixHtml(MATRIX_FORMAT_GOLDENS[2].markdown, { tableMode: "off" }),
).not.toContain("<table>");
});
it("keeps escaped literal pipes separate from a following spoiler", () => {
const markdown = "\\|\\| literal ||secret||";
expect(markdownToMatrixHtml(markdown)).toBe(
"<p>|| literal <span data-mx-spoiler>secret</span></p>",
);
expect(markdownToMatrixBody(markdown)).toBe("|| literal [Spoiler]");
});
it("does not treat pipes in link destinations as spoiler delimiters", () => {
const markdown = "[docs\nmore](https://example.test/a(b)||literal||) ||secret||";
expect(findMatrixSpoilerDelimiterOffsets(markdown)).toEqual([
markdown.indexOf("||secret||"),
markdown.lastIndexOf("||"),
]);
const html = markdownToMatrixHtml(markdown);
expect(html).not.toContain("secret");
});
it("recognizes an overlapping spoiler opener after an escaped pipe", () => {
const markdown = "\\|||secret||";
expect(markdownToMatrixHtml(markdown)).toBe("<p>|<span data-mx-spoiler>secret</span></p>");
expect(markdownToMatrixBody(markdown)).toBe("|[Spoiler]");
});
it("pairs spoilers across a soft line break within one paragraph", () => {
const markdown = "before ||first\nsecond|| after";
expect(markdownToMatrixHtml(markdown)).toContain(
"<span data-mx-spoiler>first<br>\nsecond</span>",
);
expect(markdownToMatrixBody(markdown)).toBe("before [Spoiler] after");
});
it("does not mistake an escaped closing bracket for a link label", () => {
const markdown = "\\](||secret||)";
expect(markdownToMatrixHtml(markdown)).toContain("<span data-mx-spoiler>secret</span>");
expect(markdownToMatrixBody(markdown)).not.toContain("secret");
});
it("does not reuse a completed link label for later visible text", () => {
const markdown = "[x](https://example.test) then ](||secret||)";
expect(markdownToMatrixHtml(markdown)).toContain("](<span data-mx-spoiler>secret</span>)");
expect(markdownToMatrixBody(markdown)).not.toContain("secret");
});
it("excludes spoiler-looking pipes in valid link titles", () => {
const markdown = '[x](https://example.test "note ) ||literal||") ||secret||';
const html = markdownToMatrixHtml(markdown);
expect(html).not.toContain("secret");
expect(markdownToMatrixBody(markdown)).not.toContain("secret");
});
it("scopes link metadata to blocks and preserves reference identifiers", () => {
const stale = "[unfinished\n \n](||secret||)";
expect(markdownToMatrixHtml(stale)).toContain("<span data-mx-spoiler>secret</span>");
expect(markdownToMatrixBody(stale)).not.toContain("secret");
const reference = "[visible][id||x||]\n\n[id||x||]: https://example.test";
expect(markdownToMatrixHtml(reference)).not.toContain("secret");
});
it("keeps invalid autolinks and code-span brackets in visible spoiler parsing", () => {
const invalidAutolink = "<https://example.test/ ||secret||>";
expect(markdownToMatrixBody(invalidAutolink)).not.toContain("secret");
const codeBracket = "[x `]`](https://example.test/a||b) ||secret||";
expect(markdownToMatrixHtml(codeBracket)).not.toContain("secret");
expect(markdownToMatrixBody(codeBracket)).not.toContain("secret");
});
it("finds unescaped ends of reference identifiers", () => {
const markdown = "[x][id\\]||x] then ||secret||\n\n[id\\]||x]: https://example.test";
expect(markdownToMatrixHtml(markdown)).not.toContain("secret");
expect(markdownToMatrixBody(markdown)).not.toContain("secret");
});
it("keeps spoiler formatting inside image fallback labels", () => {
const markdown = "![||secret||](https://example.test/image.png)";
expect(markdownToMatrixHtml(markdown)).toContain("<span data-mx-spoiler>secret</span>");
expect(markdownToMatrixBody(markdown)).not.toContain("secret");
});
it("keeps spoiler spans nested when they cross bold formatting", () => {
const markdown = "**||secret** more||";
const html = markdownToMatrixHtml(markdown);
expect(html).not.toContain("</strong> more</span>");
expect(markdownToMatrixBody(markdown)).not.toContain("secret");
});
it("follows parsed autolink and resolved-reference metadata", () => {
const autolink = "<ftp://example.test/a||literal||> ||secret||";
expect(markdownToMatrixHtml(autolink)).not.toContain("secret");
const unresolved = "[x][missing||secret||]";
expect(markdownToMatrixHtml(unresolved)).toContain("<span data-mx-spoiler>secret</span>");
const invalidDefinition = "[id]: <broken destination> ||secret||";
expect(markdownToMatrixHtml(invalidDefinition)).toContain(
"<span data-mx-spoiler>secret</span>",
);
});
it("excludes bare linkified URLs and underline tag attributes", () => {
const bare = "https://example.test/a||literal|| then ||secret||";
expect(markdownToMatrixHtml(bare)).not.toContain("secret");
expect(markdownToMatrixBody(bare)).not.toContain("secret");
const underline = '<u title="||">text</u> then ||secret||';
expect(markdownToMatrixHtml(underline)).not.toContain("secret");
expect(markdownToMatrixBody(underline)).not.toContain("secret");
});
it("leaves compact empty-cell pipes to native table grammar", () => {
const markdown = "| A | B | C |\n|---|---|---|\n| x || y || z |";
expect(findMatrixSpoilerDelimiterOffsets(markdown)).toEqual([]);
expect(markdownToMatrixHtml(markdown)).toContain("<table>");
expect(markdownToMatrixBody(markdown)).toBe(markdown);
});
it("fails closed when every private marker is already present", () => {
const privateUse = Array.from({ length: 0x1900 }, (_, index) =>
String.fromCharCode(0xe000 + index),
).join("");
expect(() => markdownToMatrixHtml(`${privateUse} ||secret||`)).toThrow(
"exhausted its private marker pool",
);
});
});
describe("markdownToMatrixHtml", () => {
it("renders basic inline formatting", () => {
const html = markdownToMatrixHtml("hi _there_ **boss** `code`");
+256 -15
View File
@@ -1,10 +1,32 @@
// Matrix helper module supports format behavior.
import MarkdownIt from "markdown-it";
import type { MarkdownTableMode } from "openclaw/plugin-sdk/config-contracts";
import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/string-coerce-runtime";
import { isAutoLinkedFileRef } from "openclaw/plugin-sdk/text-autolink-runtime";
import {
markdownToIR,
renderMarkdownWithMarkers,
tokenizeHtmlTags,
} from "openclaw/plugin-sdk/text-chunking";
import {
createMatrixPrivateMarkers,
isMarkdownEscaped,
MATRIX_FORMAT_PROFILE,
projectMatrixMarkdown,
} from "./format-profile.js";
import type { MatrixSpoilerMarkers, MatrixSpoilerProtection } from "./format-profile.js";
import {
findMatrixSpoilerDelimiterOffsets,
hasMatrixSpoilerMetadataCollision,
} from "./format-spoiler-ranges.js";
import type { MatrixClient } from "./sdk.js";
import { isMatrixQualifiedUserId } from "./target-ids.js";
export { MATRIX_FORMAT_PROFILE, renderMatrixMarkdownTables } from "./format-profile.js";
const MATRIX_STYLE_MARKERS = {
underline: { open: "<u>", close: "</u>" },
spoiler: { open: "<span data-mx-spoiler>", close: "</span>" },
} as const;
const md = new MarkdownIt({
html: false,
linkify: true,
@@ -23,6 +45,7 @@ export type MatrixMentions = {
type MarkdownToken = ReturnType<typeof md.parse>[number];
type MarkdownInlineToken = NonNullable<MarkdownToken["children"]>[number];
type MarkdownInlineRule = Parameters<typeof md.inline.ruler.before>[2];
type MatrixMentionCandidate = {
raw: string;
start: number;
@@ -40,6 +63,48 @@ const MATRIX_MENTION_USER_ID_PATTERN = new RegExp(
);
const TRIMMABLE_MENTION_SUFFIX = /[),.!?:;\]]/;
const parseMatrixUnderline: MarkdownInlineRule = (state, silent) => {
if (state.src.charCodeAt(state.pos) !== 0x3c) {
return false;
}
const tag = tokenizeHtmlTags(state.src.slice(state.pos)).next().value;
if (!tag || tag.start !== 0 || (tag.name !== "u" && tag.name !== "ins")) {
return false;
}
if (!silent) {
const token = state.push(
tag.selfClosing ? "text" : tag.closing ? "matrix_underline_close" : "matrix_underline_open",
tag.selfClosing ? "" : "u",
tag.selfClosing ? 0 : tag.closing ? -1 : 1,
);
if (tag.selfClosing) {
token.content = tag.raw;
}
}
state.pos += tag.end;
return true;
};
md.inline.ruler.before("html_inline", "matrix_underline", parseMatrixUnderline);
md.renderer.rules.matrix_underline_open = () => MATRIX_STYLE_MARKERS.underline.open;
md.renderer.rules.matrix_underline_close = () => MATRIX_STYLE_MARKERS.underline.close;
md.renderer.rules.matrix_spoiler_open = () => MATRIX_STYLE_MARKERS.spoiler.open;
md.renderer.rules.matrix_spoiler_close = () => MATRIX_STYLE_MARKERS.spoiler.close;
md.core.ruler.after("inline", "matrix_spoilers", (state) => {
const markers = (state.env as { matrixSpoilerMarkers?: MatrixSpoilerMarkers })
.matrixSpoilerMarkers;
if (!markers) {
return;
}
for (const token of state.tokens as MarkdownToken[]) {
if (token.children?.length) {
token.children = normalizeMatrixSpoilerNesting(
injectProtectedMatrixSpoilers(token.children, markers),
);
}
}
});
function shouldSuppressAutoLink(
tokens: Parameters<NonNullable<typeof md.renderer.rules.link_open>>[0],
idx: number,
@@ -53,7 +118,12 @@ function shouldSuppressAutoLink(
return Boolean(href && label && isAutoLinkedFileRef(href, label));
}
md.renderer.rules.image = (tokens, idx) => escapeHtml(tokens[idx]?.content ?? "");
md.renderer.rules.image = (tokens, idx, options, env, self) => {
const token = tokens[idx];
return token?.children?.length
? self.renderInline(token.children, options, env)
: escapeHtml(token?.content ?? "");
};
md.renderer.rules.html_block = (tokens, idx) => escapeHtml(tokens[idx]?.content ?? "");
md.renderer.rules.html_inline = (tokens, idx) => escapeHtml(tokens[idx]?.content ?? "");
@@ -99,16 +169,6 @@ function maskEscapedMentions(markdown: string): string {
return masked;
}
function isMarkdownEscaped(markdown: string, idx: number): boolean {
let slashCount = 0;
let cursor = idx - 1;
while (cursor >= 0 && markdown[cursor] === "\\") {
slashCount += 1;
cursor -= 1;
}
return slashCount % 2 === 1;
}
function restoreEscapedMentions(text: string): string {
return text.replaceAll(ESCAPED_MENTION_SENTINEL, "@");
}
@@ -216,6 +276,100 @@ function createTextToken(sample: MarkdownInlineToken, content: string): Markdown
return token;
}
function injectProtectedMatrixSpoilers(
tokens: MarkdownInlineToken[],
markers: MatrixSpoilerMarkers,
): MarkdownInlineToken[] {
const result: MarkdownInlineToken[] = [];
for (const token of tokens) {
if (token.type !== "text") {
if (token.children?.length) {
token.children = normalizeMatrixSpoilerNesting(
injectProtectedMatrixSpoilers(token.children, markers),
);
}
result.push(token);
continue;
}
let cursor = 0;
for (let index = 0; index < token.content.length; index += 1) {
const marker = token.content[index];
if (
(marker !== markers.open && marker !== markers.close) ||
token.content[index + 1] !== markers.padding
) {
continue;
}
if (index > cursor) {
result.push(createTextToken(token, token.content.slice(cursor, index)));
}
result.push(
createToken(
token,
marker === markers.open ? "matrix_spoiler_open" : "matrix_spoiler_close",
"span",
marker === markers.open ? 1 : -1,
),
);
index += 1;
cursor = index + 1;
}
if (cursor < token.content.length) {
result.push(createTextToken(token, token.content.slice(cursor)));
}
}
return result;
}
function copyInlineToken(
sample: MarkdownInlineToken,
type: string,
tag: string,
nesting: number,
): MarkdownInlineToken {
const token = createToken(sample, type, tag, nesting);
token.markup = sample.markup;
token.attrs = sample.attrs ? [...sample.attrs] : null;
return token;
}
function normalizeMatrixSpoilerNesting(tokens: MarkdownInlineToken[]): MarkdownInlineToken[] {
const result: MarkdownInlineToken[] = [];
const stack: MarkdownInlineToken[] = [];
for (const token of tokens) {
if (token.nesting === 1) {
stack.push(token);
result.push(token);
continue;
}
if (token.nesting !== -1) {
result.push(token);
continue;
}
const openIndex = stack.findLastIndex((open) => open.tag === token.tag);
if (openIndex < 0) {
result.push(token);
continue;
}
if (openIndex === stack.length - 1) {
stack.pop();
result.push(token);
continue;
}
const crossing = stack.splice(openIndex + 1);
for (const open of crossing.toReversed()) {
result.push(copyInlineToken(open, open.type.replace(/_open$/u, "_close"), open.tag, -1));
}
stack.pop();
result.push(token);
for (const open of crossing) {
result.push(copyInlineToken(open, open.type, open.tag, 1));
stack.push(open);
}
}
return result;
}
function createMentionLinkTokens(params: {
sample: MarkdownInlineToken;
href: string;
@@ -374,18 +528,96 @@ function compactLooseListTokens(tokens: MarkdownToken[]): void {
}
}
export function markdownToMatrixHtml(markdown: string): string {
const tokens = md.parse(markdown ?? "", {});
export function markdownToMatrixHtml(
markdown: string,
options: { tableMode?: MarkdownTableMode } = {},
): string {
if (hasMatrixSpoilerMetadataCollision(markdown)) {
return renderMatrixFallbackHtml(markdown);
}
const tokens = parseMatrixMarkdown(projectMatrixMarkdown(markdown), options.tableMode);
compactLooseListTokens(tokens);
return md.renderer.render(tokens, md.options, {}).trimEnd();
}
export function protectMatrixSpoilerDelimiters(markdown: string): MatrixSpoilerProtection {
const offsets = findMatrixSpoilerDelimiterOffsets(markdown);
if (offsets.length === 0) {
return { markdown };
}
const markers = createMatrixPrivateMarkers(
markdown,
"Matrix spoiler formatting exhausted its private marker pool",
);
let protectedMarkdown = "";
let cursor = 0;
for (const [index, offset] of offsets.entries()) {
const marker = index % 2 === 0 ? markers.open : markers.close;
protectedMarkdown += `${markdown.slice(cursor, offset)}${marker}${markers.padding}`;
cursor = offset + 2;
}
protectedMarkdown += markdown.slice(cursor);
return { markdown: protectedMarkdown, markers };
}
function parseMatrixMarkdown(markdown: string, tableMode?: MarkdownTableMode): MarkdownToken[] {
const protectedSpoilers = protectMatrixSpoilerDelimiters(markdown);
if (tableMode === "off") {
md.disable("table");
}
try {
return md.parse(protectedSpoilers.markdown, {
matrixSpoilerMarkers: protectedSpoilers.markers,
});
} finally {
if (tableMode === "off") {
md.enable("table");
}
}
}
export function markdownToMatrixBody(markdown: string): string {
const projected = projectMatrixMarkdown(markdown);
const offsets = findMatrixSpoilerDelimiterOffsets(projected);
const metadataCollision = hasMatrixSpoilerMetadataCollision(projected);
if (offsets.length === 0 && !metadataCollision) {
return projected;
}
let body = projected;
if (metadataCollision) {
body = "[Spoiler]";
} else {
for (let index = offsets.length - 2; index >= 0; index -= 2) {
const open = offsets[index];
const close = offsets[index + 1];
if (open !== undefined && close !== undefined) {
body = `${body.slice(0, open)}[Spoiler]${body.slice(close + 2)}`;
}
}
}
const ir = markdownToIR(body, {
enableHtmlUnderline: true,
headingStyle: "rich",
linkify: true,
});
return renderMarkdownWithMarkers(
ir,
{ styleMarkers: {}, escapeText: (text) => text },
MATRIX_FORMAT_PROFILE,
);
}
function renderMatrixFallbackHtml(markdown: string): string {
return `<p>${escapeHtml(markdownToMatrixBody(markdown)).replaceAll("\n", "<br>\n")}</p>`;
}
async function resolveMarkdownMentionState(params: {
markdown: string;
client: MatrixClient;
tableMode?: MarkdownTableMode;
}): Promise<{ tokens: MarkdownToken[]; mentions: MatrixMentions }> {
const markdown = maskEscapedMentions(params.markdown ?? "");
const tokens = md.parse(markdown, {});
const markdown = maskEscapedMentions(projectMatrixMarkdown(params.markdown));
const tokens = parseMatrixMarkdown(markdown, params.tableMode);
restoreEscapedMentionsInBlockTokens(tokens);
const selfUserId = await resolveMatrixSelfUserId(params.client);
const userIds: string[] = [];
@@ -430,8 +662,17 @@ export async function resolveMatrixMentionsInMarkdown(params: {
export async function renderMarkdownToMatrixHtmlWithMentions(params: {
markdown: string;
client: MatrixClient;
tableMode?: MarkdownTableMode;
}): Promise<{ html?: string; mentions: MatrixMentions }> {
const state = await resolveMarkdownMentionState(params);
if (hasMatrixSpoilerMetadataCollision(params.markdown)) {
const redacted = markdownToMatrixBody(params.markdown);
const redactedState = await resolveMarkdownMentionState({
...params,
markdown: redacted,
});
return { html: renderMatrixFallbackHtml(params.markdown), mentions: redactedState.mentions };
}
compactLooseListTokens(state.tokens);
const html = md.renderer.render(state.tokens, md.options, {}).trimEnd();
return {
+251 -16
View File
@@ -3,7 +3,9 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
import type { PluginRuntime } from "../../runtime-api.js";
import { setMatrixRuntime } from "../runtime.js";
import { voteMatrixPoll } from "./actions/polls.js";
import { markdownToMatrixBody, markdownToMatrixHtml } from "./format.js";
import {
chunkMatrixText,
editMessageMatrix,
sendMessageMatrix,
sendPollMatrix,
@@ -30,9 +32,10 @@ const isVoiceCompatibleAudioMock = vi.fn(
const resolveTextChunkLimitMock = vi.fn<
(cfg: unknown, channel: unknown, accountId?: unknown) => number
>(() => 4000);
const resolveMarkdownTableModeMock = vi.fn(() => "code");
const convertMarkdownTablesMock = vi.fn((text: string) => text);
const chunkMarkdownTextWithModeMock = vi.fn((text: string) => (text ? [text] : []));
const resolveMarkdownTableModeMock = vi.fn((_params?: unknown) => "code");
const chunkMarkdownTextWithModeMock = vi.fn<
(text: string, limit?: number, mode?: unknown) => string[]
>((text) => (text ? [text] : []));
vi.mock("openclaw/plugin-sdk/plugin-config-runtime", async () => {
const actual = await vi.importActual<typeof import("openclaw/plugin-sdk/plugin-config-runtime")>(
@@ -70,9 +73,10 @@ const runtimeStub = {
resolveTextChunkLimitMock(cfg, channel, accountId),
resolveChunkMode: () => "length",
chunkMarkdownText: (text: string) => (text ? [text] : []),
chunkMarkdownTextWithMode: (text: string) => chunkMarkdownTextWithModeMock(text),
resolveMarkdownTableMode: () => resolveMarkdownTableModeMock(),
convertMarkdownTables: (text: string) => convertMarkdownTablesMock(text),
chunkMarkdownTextWithMode: (text: string, limit: number, mode: unknown) =>
chunkMarkdownTextWithModeMock(text, limit, mode),
resolveMarkdownTableMode: (params: unknown) => resolveMarkdownTableModeMock(params),
convertMarkdownTables: (text: string) => text,
},
},
} as unknown as PluginRuntime;
@@ -167,6 +171,12 @@ function expectTextReceiptPart(value: unknown, platformMessageId: string) {
expect(part.kind).toBe("text");
}
function splitTextAtLimit(text: string, limit = text.length): string[] {
return Array.from({ length: Math.ceil(text.length / limit) }, (_, index) =>
text.slice(index * limit, (index + 1) * limit),
);
}
function resetMatrixSendRuntimeMocks() {
setMatrixRuntime(runtimeStub);
loadOutboundMediaFromUrlMock.mockReset().mockImplementation(
@@ -211,13 +221,196 @@ function resetMatrixSendRuntimeMocks() {
isVoiceCompatibleAudioMock.mockReset().mockReturnValue(false);
resolveTextChunkLimitMock.mockReset().mockReturnValue(4000);
resolveMarkdownTableModeMock.mockReset().mockReturnValue("code");
convertMarkdownTablesMock.mockReset().mockImplementation((text: string) => text);
chunkMarkdownTextWithModeMock
.mockReset()
.mockImplementation((text: string) => (text ? [text] : []));
applyMatrixSendRuntimeStub();
}
describe("Matrix formatted chunk boundaries", () => {
beforeEach(() => {
resetMatrixSendRuntimeMocks();
});
it("closes and reopens spoilers without exposing chunked secret text", () => {
const secret = "secret ".repeat(8).trim();
resolveTextChunkLimitMock.mockReturnValue(20);
chunkMarkdownTextWithModeMock.mockImplementation(splitTextAtLimit);
const { chunks } = chunkMatrixText(`before ||${secret}|| after`, {
cfg: {} as never,
tableMode: "block",
});
expect(chunks.length).toBeGreaterThan(1);
expect(chunks.every((chunk) => chunk.length <= 20)).toBe(true);
for (const chunk of chunks) {
expect(markdownToMatrixBody(chunk)).not.toContain("secret");
expect(markdownToMatrixHtml(chunk)).not.toContain("||");
}
});
it("does not pair an unmatched paragraph delimiter with a later spoiler", () => {
const secret = "secret ".repeat(8).trim();
resolveTextChunkLimitMock.mockReturnValue(20);
chunkMarkdownTextWithModeMock.mockImplementation(splitTextAtLimit);
const { chunks } = chunkMatrixText(`first ||\n\nsecond ||${secret}||`, {
cfg: {} as never,
tableMode: "block",
});
expect(chunks.join("")).toContain("[Spoiler]");
expect(chunks.every((chunk) => !markdownToMatrixBody(chunk).includes("secret"))).toBe(true);
});
it("keeps a spoiler-bearing message whole when it already fits", () => {
const markdown = `||${"x".repeat(14)}||`;
resolveTextChunkLimitMock.mockReturnValue(20);
expect(chunkMatrixText(markdown, { cfg: {} as never, tableMode: "block" }).chunks).toEqual([
markdown,
]);
expect(chunkMarkdownTextWithModeMock).not.toHaveBeenCalled();
});
it("closes and reopens authored underline across chunk boundaries", () => {
const markdown = `<u>${"underlined ".repeat(6).trim()}</u>`;
resolveTextChunkLimitMock.mockReturnValue(20);
chunkMarkdownTextWithModeMock.mockImplementation(splitTextAtLimit);
const { chunks } = chunkMatrixText(markdown, { cfg: {} as never, tableMode: "block" });
expect(chunks.length).toBeGreaterThan(1);
expect(chunks.every((chunk) => chunk.length <= 20)).toBe(true);
expect(chunks.every((chunk) => markdownToMatrixHtml(chunk).includes("<u>"))).toBe(true);
});
it("keeps underline-looking tags inside code literal", () => {
const markdown = `\`<ins>\` \\<u> ${"plain ".repeat(8)}`;
resolveTextChunkLimitMock.mockReturnValue(20);
chunkMarkdownTextWithModeMock.mockImplementation(splitTextAtLimit);
const { chunks } = chunkMatrixText(markdown, { cfg: {} as never, tableMode: "block" });
expect(chunks.join("")).toBe(markdown.trim());
expect(chunks.join("")).not.toContain("</u>");
});
it("keeps underline-looking tags inside link metadata literal", () => {
const markdown = `[x](https://example.test "literal <u>") ${"plain ".repeat(8)}`;
resolveTextChunkLimitMock.mockReturnValue(28);
chunkMarkdownTextWithModeMock.mockImplementation(splitTextAtLimit);
const { chunks } = chunkMatrixText(markdown, { cfg: {} as never, tableMode: "block" });
expect(chunks.join("")).toBe(markdown.trim());
expect(chunks.join("")).not.toContain("</u>");
});
it("keeps nested underline depth across chunk boundaries", () => {
const markdown = `<u>outer <ins>inner</ins> ${"tail ".repeat(8)}</u>`;
resolveTextChunkLimitMock.mockReturnValue(24);
chunkMarkdownTextWithModeMock.mockImplementation(splitTextAtLimit);
const { chunks } = chunkMatrixText(markdown, { cfg: {} as never, tableMode: "block" });
expect(chunks.length).toBeGreaterThan(1);
expect(chunks.every((chunk) => chunk.length <= 24)).toBe(true);
expect(chunks.every((chunk) => markdownToMatrixHtml(chunk).includes("<u>"))).toBe(true);
});
it("drops padding-only chunks from long authored underline tags", () => {
const markdown = `<u title="${"x".repeat(60)}">content</u>`;
resolveTextChunkLimitMock.mockReturnValue(20);
chunkMarkdownTextWithModeMock.mockImplementation(splitTextAtLimit);
const { chunks } = chunkMatrixText(markdown, { cfg: {} as never, tableMode: "block" });
expect(
chunks.every((chunk) => chunk.replaceAll("<u>", "").replaceAll("</u>", "").trim().length > 0),
).toBe(true);
});
it("keeps spoiler and underline nesting valid across chunks", () => {
const markdown = `||<u>${"nested ".repeat(8).trim()}</u>||`;
resolveTextChunkLimitMock.mockReturnValue(24);
chunkMarkdownTextWithModeMock.mockImplementation(splitTextAtLimit);
const { chunks } = chunkMatrixText(markdown, { cfg: {} as never, tableMode: "block" });
expect(chunks.every((chunk) => chunk.length <= 24)).toBe(true);
expect(
chunks.every((chunk) => {
const html = markdownToMatrixHtml(chunk);
return html.includes("<span data-mx-spoiler>") && html.includes("<u>");
}),
).toBe(true);
});
it("falls back to table bullets when a native table cannot fit one event", () => {
resolveTextChunkLimitMock.mockReturnValue(30);
chunkMarkdownTextWithModeMock.mockImplementation(splitTextAtLimit);
const prepared = chunkMatrixText(
"| Name | Description |\n|---|---|\n| Alice | a long description that crosses the limit |",
{ cfg: {} as never, tableMode: "block" },
);
const rendered = prepared.chunks.join("\n");
expect(rendered).toContain("**Alice**");
expect(rendered).toContain("• Description:");
expect(rendered).toContain("description that crosses the");
expect(rendered).not.toContain("|---|---|");
});
it("keeps a small native table in a long message", () => {
const table = "| A | B |\n|---|---|\n| 1 | 2 |";
resolveTextChunkLimitMock.mockReturnValue(40);
chunkMarkdownTextWithModeMock.mockImplementation(splitTextAtLimit);
const { chunks } = chunkMatrixText(`${"prose ".repeat(10)}\n\n${table}`, {
cfg: {} as never,
tableMode: "block",
});
expect(chunks).toContain(table);
});
it("preserves indentation after a native table segment", () => {
const table = "| A | B |\n|---|---|\n| 1 | 2 |";
const code = " indented code";
resolveTextChunkLimitMock.mockReturnValue(40);
chunkMarkdownTextWithModeMock.mockImplementation(splitTextAtLimit);
const { chunks } = chunkMatrixText(`${"prose ".repeat(10)}\n\n${table}\n\n${code}`, {
cfg: {} as never,
tableMode: "block",
});
expect(chunks).toContain(code);
});
it("recognizes aligned tables and ignores table examples inside fences", () => {
const aligned = "| A | B |\n| ---: | :---: |\n| 1 | 2 |\n| 3 | 4 |";
resolveTextChunkLimitMock.mockReturnValue(35);
chunkMarkdownTextWithModeMock.mockImplementation(splitTextAtLimit);
expect(
chunkMatrixText(aligned, { cfg: {} as never, tableMode: "block" }).chunks.join("\n"),
).toContain("• B:");
const fenced = `\`\`\`\n${aligned}\n\`\`\``;
expect(chunkMatrixText(fenced, { cfg: {} as never, tableMode: "block" }).chunks.join("")).toBe(
fenced,
);
const shortDivider = "A|B\n-| -\nbar";
resolveTextChunkLimitMock.mockReturnValue(8);
expect(
chunkMatrixText(shortDivider, { cfg: {} as never, tableMode: "block" }).chunks.join("\n"),
).toContain("**bar**");
});
});
describe("sendMessageMatrix media", () => {
beforeEach(() => {
resetMatrixSendRuntimeMocks();
@@ -661,15 +854,15 @@ describe("sendMessageMatrix threads", () => {
it("returns ordered event ids for chunked text sends", async () => {
const { client, sendMessage } = makeClient();
resolveTextChunkLimitMock.mockReturnValue(6);
sendMessage
.mockReset()
.mockResolvedValueOnce("$m1")
.mockResolvedValueOnce("$m2")
.mockResolvedValueOnce("$m3");
convertMarkdownTablesMock.mockImplementation(() => "part1|part2|part3");
chunkMarkdownTextWithModeMock.mockImplementation((text: string) => text.split("|"));
const result = await sendMessageMatrix("room:!room:example", "ignored", {
const result = await sendMessageMatrix("room:!room:example", "part1|part2|part3", {
client,
cfg: {} as never,
});
@@ -687,16 +880,16 @@ describe("sendMessageMatrix threads", () => {
it("reports the first Matrix event before a later event fails", async () => {
const { client, sendMessage } = makeClient();
resolveTextChunkLimitMock.mockReturnValue(5);
sendMessage
.mockReset()
.mockResolvedValueOnce("$m1")
.mockRejectedValueOnce(new Error("second event failed"));
convertMarkdownTablesMock.mockImplementation(() => "part1|part2");
chunkMarkdownTextWithModeMock.mockImplementation((text: string) => text.split("|"));
const onDeliveryResult = vi.fn();
await expect(
sendMessageMatrix("room:!room:example", "ignored", {
sendMessageMatrix("room:!room:example", "part1|part2", {
client,
cfg: {} as never,
onDeliveryResult,
@@ -708,10 +901,10 @@ describe("sendMessageMatrix threads", () => {
it("merges extra content into only the first chunked text event", async () => {
const { client, sendMessage } = makeClient();
convertMarkdownTablesMock.mockImplementation(() => "first|second|third");
resolveTextChunkLimitMock.mockReturnValue(6);
chunkMarkdownTextWithModeMock.mockImplementation((text: string) => text.split("|"));
await sendMessageMatrix("room:!room:example", "ignored", {
await sendMessageMatrix("room:!room:example", "first|second|third", {
client,
cfg: {} as never,
extraContent: { "com.openclaw.approval": { id: "req-1" } },
@@ -733,13 +926,12 @@ describe("sendSingleTextMessageMatrix", () => {
resetMatrixSendRuntimeMocks();
});
it("rejects single-event sends when converted text exceeds the Matrix limit", async () => {
it("rejects single-event sends when rendered text exceeds the Matrix limit", async () => {
const { client, sendMessage } = makeClient();
resolveTextChunkLimitMock.mockReturnValue(5);
convertMarkdownTablesMock.mockImplementation(() => "123456");
await expect(
sendSingleTextMessageMatrix("room:!room:example", "1234", {
sendSingleTextMessageMatrix("room:!room:example", "123456", {
client,
cfg: {} as never,
}),
@@ -748,6 +940,38 @@ describe("sendSingleTextMessageMatrix", () => {
expect(sendMessage).not.toHaveBeenCalled();
});
it("keeps native tables in the body and formatted body when the profile selects blocks", async () => {
const { client, sendMessage } = makeClient();
const markdown = "| Name | Age |\n|---|---|\n| Alice | 30 |";
resolveMarkdownTableModeMock.mockReturnValue("block");
await sendSingleTextMessageMatrix("room:!room:example", markdown, {
client,
cfg: {} as never,
});
const content = sentContent(sendMessage);
expect(content.body).toBe(markdown);
expect(content.formatted_body).toContain("<table>");
expect(content.formatted_body).toContain("<td>Alice</td>");
expect(resolveMarkdownTableModeMock).toHaveBeenCalledWith(
expect.objectContaining({ channel: "matrix", supportsBlockTables: true }),
);
});
it("keeps spoiler text out of the Matrix plain fallback", async () => {
const { client, sendMessage } = makeClient();
await sendSingleTextMessageMatrix("room:!room:example", "before ||secret|| after", {
client,
cfg: {} as never,
});
const content = sentContent(sendMessage);
expect(content.body).toBe("before [Spoiler] after");
expect(content.formatted_body).toBe("<p>before <span data-mx-spoiler>secret</span> after</p>");
});
it("supports quiet draft preview sends without mention metadata", async () => {
const { client, sendMessage } = makeClient();
@@ -963,6 +1187,17 @@ describe("editMessageMatrix mentions", () => {
expect(newContent(content)[MATRIX_OPENCLAW_FINALIZED_PREVIEW_KEY]).toBe(true);
});
it("preserves Markdown-significant indentation in edits", async () => {
const { client, sendMessage } = makeClient();
await editMessageMatrix("room:!room:example", "$original", " code", {
client,
cfg: {} as never,
});
expect(newContent(sentContent(sendMessage)).body).toBe(" code");
});
it("edits threaded originals with a pure replace relation", async () => {
const { client, getEvent, sendMessage } = makeClient();
getEvent.mockResolvedValue({
+27 -82
View File
@@ -3,15 +3,14 @@ import {
createMessageReceiptFromOutboundResults,
type MessageReceiptPartKind,
} from "openclaw/plugin-sdk/channel-outbound";
import type { MarkdownTableMode } from "openclaw/plugin-sdk/markdown-table-runtime";
import { requireRuntimeConfig } from "openclaw/plugin-sdk/plugin-config-runtime";
import type { PollInput } from "../runtime-api.js";
import { getMatrixRuntime } from "../runtime.js";
import type { CoreConfig } from "../types.js";
import { loadOutboundMediaFromUrl } from "./outbound-media-runtime.js";
import { buildPollStartContent, M_POLL_START } from "./poll-types.js";
import { buildMatrixReactionContent } from "./reaction-common.js";
import type { MatrixClient } from "./sdk.js";
import { chunkMatrixText, prepareMatrixSingleText } from "./send/chunking.js";
import {
resolveMediaMaxBytes,
withResolvedMatrixControlClient,
@@ -47,23 +46,10 @@ import {
type MatrixTextMsgType,
} from "./send/types.js";
const MATRIX_TEXT_LIMIT = 4000;
const getCore = () => getMatrixRuntime();
export { chunkMatrixText, prepareMatrixSingleText } from "./send/chunking.js";
export { resolveMatrixMentionsForBody } from "./send/formatting.js";
export { resolveMatrixRoomId } from "./send/targets.js";
type MatrixPreparedSingleText = {
trimmedText: string;
convertedText: string;
singleEventLimit: number;
fitsInSingleEvent: boolean;
};
type MatrixPreparedChunkedText = MatrixPreparedSingleText & {
chunks: string[];
};
type MatrixClientResolveOpts = {
client?: MatrixClient;
cfg?: CoreConfig;
@@ -179,57 +165,6 @@ async function resolvePreviousEditMentions(params: {
});
}
export function prepareMatrixSingleText(
text: string,
opts: {
cfg: CoreConfig;
accountId?: string;
tableMode?: MarkdownTableMode;
},
): MatrixPreparedSingleText {
const trimmedText = text.trim();
const cfg = requireRuntimeConfig(opts.cfg, "Matrix text preparation") as CoreConfig;
const tableMode =
opts.tableMode ??
getCore().channel.text.resolveMarkdownTableMode({
cfg,
channel: "matrix",
accountId: opts.accountId,
});
const convertedText = getCore().channel.text.convertMarkdownTables(trimmedText, tableMode);
const singleEventLimit = Math.min(
getCore().channel.text.resolveTextChunkLimit(cfg, "matrix", opts.accountId),
MATRIX_TEXT_LIMIT,
);
return {
trimmedText,
convertedText,
singleEventLimit,
fitsInSingleEvent: convertedText.length <= singleEventLimit,
};
}
export function chunkMatrixText(
text: string,
opts: {
cfg: CoreConfig;
accountId?: string;
tableMode?: MarkdownTableMode;
},
): MatrixPreparedChunkedText {
const preparedText = prepareMatrixSingleText(text, opts);
const cfg = requireRuntimeConfig(opts.cfg, "Matrix text chunking") as CoreConfig;
const chunkMode = getCore().channel.text.resolveChunkMode(cfg, "matrix", opts.accountId);
return {
...preparedText,
chunks: getCore().channel.text.chunkMarkdownTextWithMode(
preparedText.convertedText,
preparedText.singleEventLimit,
chunkMode,
),
};
}
export async function sendMessageMatrix(
to: string,
message: string | undefined,
@@ -249,7 +184,7 @@ export async function sendMessageMatrix(
async (client) => {
const roomId = await resolveMatrixRoomId(client, to);
const cfg = requireRuntimeConfig(opts.cfg, "Matrix send") as CoreConfig;
const { chunks } = chunkMatrixText(trimmedMessage, {
const { chunks, tableMode } = chunkMatrixText(trimmedMessage, {
cfg,
accountId: opts.accountId,
});
@@ -336,6 +271,7 @@ export async function sendMessageMatrix(
client,
content,
markdown: captionMarkdown,
tableMode,
});
const eventId = await sendContent(content, receiptKind);
lastMessageId = eventId ?? lastMessageId;
@@ -347,8 +283,8 @@ export async function sendMessageMatrix(
// transcript follow-up attached to the same reply/thread context.
const followupRelation = useVoice || threadId ? relation : undefined;
for (const chunk of textChunks) {
const text = chunk.trim();
if (!text) {
const text = chunk;
if (!text.trim()) {
continue;
}
const followup = buildTextContent(text, followupRelation);
@@ -356,6 +292,7 @@ export async function sendMessageMatrix(
client,
content: followup,
markdown: text,
tableMode,
});
const followupEventId = await sendContent(followup, "text");
lastMessageId = followupEventId ?? lastMessageId;
@@ -365,8 +302,8 @@ export async function sendMessageMatrix(
}
} else {
for (const chunk of chunks.length ? chunks : [""]) {
const text = chunk.trim();
if (!text) {
const text = chunk;
if (!text.trim()) {
continue;
}
const content = buildTextContent(text, relation);
@@ -374,6 +311,7 @@ export async function sendMessageMatrix(
client,
content,
markdown: text,
tableMode,
});
const eventId = await sendContent(content, "text");
lastMessageId = eventId ?? lastMessageId;
@@ -499,17 +437,23 @@ export async function sendSingleTextMessageMatrix(
live?: boolean;
},
): Promise<MatrixSendResult> {
const { trimmedText, convertedText, singleEventLimit, fitsInSingleEvent } =
prepareMatrixSingleText(text, {
cfg: opts.cfg,
accountId: opts.accountId,
});
const {
trimmedText,
convertedText,
singleEventLimit,
eventTextLength,
fitsInSingleEvent,
tableMode,
} = prepareMatrixSingleText(text, {
cfg: opts.cfg,
accountId: opts.accountId,
});
if (!trimmedText) {
throw new Error("Matrix single-message send requires text");
}
if (!fitsInSingleEvent) {
throw new Error(
`Matrix single-message text exceeds limit (${convertedText.length} > ${singleEventLimit})`,
`Matrix single-message text exceeds limit (${eventTextLength} > ${singleEventLimit})`,
);
}
return await withResolvedMatrixSendClient(
@@ -535,6 +479,7 @@ export async function sendSingleTextMessageMatrix(
content,
markdown: convertedText,
includeMentions: opts.includeMentions,
tableMode,
});
// MSC4357: mark the initial message as live so supporting clients start
// rendering a streaming animation immediately.
@@ -602,12 +547,11 @@ export async function editMessageMatrix(
async (client) => {
const resolvedRoom = await resolveMatrixRoomId(client, roomId);
const cfg = requireRuntimeConfig(opts.cfg, "Matrix message edit") as CoreConfig;
const tableMode = getCore().channel.text.resolveMarkdownTableMode({
const { convertedText, tableMode } = prepareMatrixSingleText(newText, {
cfg,
channel: "matrix",
accountId: opts.accountId,
preserveWhitespace: true,
});
const convertedText = getCore().channel.text.convertMarkdownTables(newText, tableMode);
const newContent = withMatrixExtraContentFields(
buildTextContent(convertedText, undefined, {
msgtype: opts.msgtype,
@@ -619,6 +563,7 @@ export async function editMessageMatrix(
content: newContent,
markdown: convertedText,
includeMentions: opts.includeMentions,
tableMode,
});
const previousEvent = await getPreviousMatrixEvent(client, resolvedRoom, originalEventId);
const replaceMentions =
@@ -649,7 +594,7 @@ export async function editMessageMatrix(
// m.new_content still see properly formatted text (with HTML).
const content: Record<string, unknown> = {
...newContent,
body: `* ${convertedText}`,
body: `* ${newContent.body}`,
...(typeof newContent.formatted_body === "string"
? { formatted_body: `* ${newContent.formatted_body}` }
: {}),
@@ -0,0 +1,274 @@
// Matrix helper module prepares and chunks outbound formatted text.
import type { MarkdownTableMode } from "openclaw/plugin-sdk/markdown-table-runtime";
import { requireRuntimeConfig } from "openclaw/plugin-sdk/plugin-config-runtime";
import { findCodeRegions, isInsideCode, tokenizeHtmlTags } from "openclaw/plugin-sdk/text-chunking";
import { getMatrixRuntime } from "../../runtime.js";
import type { CoreConfig } from "../../types.js";
import {
createMatrixPrivateMarkers,
isMarkdownEscaped,
type MatrixSpoilerMarkers,
type MatrixSpoilerProtection,
} from "../format-profile.js";
import {
findMatrixMarkdownMetadataRanges,
hasMatrixSpoilerMetadataCollision,
} from "../format-spoiler-ranges.js";
import { findMatrixTableSourceRanges } from "../format-table-ranges.js";
import {
markdownToMatrixBody,
MATRIX_FORMAT_PROFILE,
protectMatrixSpoilerDelimiters,
renderMatrixMarkdownTables,
} from "../format.js";
type MatrixPreparedSingleText = {
trimmedText: string;
convertedText: string;
singleEventLimit: number;
eventTextLength: number;
fitsInSingleEvent: boolean;
tableMode: MarkdownTableMode;
};
type MatrixPreparedChunkedText = MatrixPreparedSingleText & {
chunks: string[];
};
const getCore = () => getMatrixRuntime();
function protectMatrixUnderlineTags(markdown: string): MatrixSpoilerProtection {
const codeRegions = findCodeRegions(markdown);
const metadataRanges = findMatrixMarkdownMetadataRanges(markdown);
const tags = [...tokenizeHtmlTags(markdown)].filter(
(tag) =>
(tag.name === "u" || tag.name === "ins") &&
!tag.selfClosing &&
!isInsideCode(tag.start, codeRegions) &&
!isMarkdownEscaped(markdown, tag.start) &&
!metadataRanges.some((range) => tag.start >= range.start && tag.start < range.end),
);
if (tags.length === 0) {
return { markdown };
}
const markers = createMatrixPrivateMarkers(
markdown,
"Matrix underline chunking exhausted its private marker pool",
);
let depth = 0;
const replacements = tags.flatMap((tag) => {
if (!tag.closing) {
depth += 1;
return [{ tag, marker: depth === 1 ? markers.open : "" }];
}
if (depth === 0) {
return [];
}
depth -= 1;
return [{ tag, marker: depth === 0 ? markers.close : "" }];
});
let protectedMarkdown = markdown;
for (const { tag, marker } of replacements.toReversed()) {
protectedMarkdown = `${protectedMarkdown.slice(0, tag.start)}${marker}${markers.padding.repeat(tag.raw.length - marker.length)}${protectedMarkdown.slice(tag.end)}`;
}
return { markdown: protectedMarkdown, markers };
}
type MatrixChunkStyle = "spoiler" | "underline";
function restoreMatrixStyleChunks(
chunks: string[],
spoiler: MatrixSpoilerMarkers | undefined,
underline: MatrixSpoilerMarkers | undefined,
): string[] {
const stack: MatrixChunkStyle[] = [];
const syntax = {
spoiler: { open: "||", close: "||", markers: spoiler },
underline: { open: "<u>", close: "</u>", markers: underline },
} as const;
return chunks.map((chunk) => {
let restored = stack.map((style) => syntax[style].open).join("");
for (const character of chunk) {
const opening = (Object.keys(syntax) as MatrixChunkStyle[]).find(
(style) => character === syntax[style].markers?.open,
);
const closing = (Object.keys(syntax) as MatrixChunkStyle[]).find(
(style) => character === syntax[style].markers?.close,
);
if (opening) {
stack.push(opening);
restored += syntax[opening].open;
} else if (closing) {
const stackIndex = stack.lastIndexOf(closing);
if (stackIndex >= 0) {
const above = stack.slice(stackIndex + 1);
restored += above
.toReversed()
.map((style) => syntax[style].close)
.join("");
restored += syntax[closing].close;
stack.splice(stackIndex, 1);
restored += above.map((style) => syntax[style].open).join("");
}
} else if (character !== spoiler?.padding && character !== underline?.padding) {
restored += character;
}
}
return (
restored +
stack
.toReversed()
.map((style) => syntax[style].close)
.join("")
);
});
}
function splitMatrixTableSegments(markdown: string): Array<{ table: boolean; text: string }> {
const segments: Array<{ table: boolean; text: string }> = [];
let cursor = 0;
for (const range of findMatrixTableSourceRanges(markdown)) {
const plain = markdown.slice(cursor, range.start).replace(/(?:[ \t]*\n)+$/u, "");
if (plain.trim()) {
segments.push({ table: false, text: plain });
}
const rawTable = markdown.slice(range.start, range.end).trimEnd();
const indent = /^ +/u.exec(rawTable)?.[0] ?? "";
const table = indent
? rawTable
.split("\n")
.map((line) => (line.startsWith(indent) ? line.slice(indent.length) : line))
.join("\n")
: rawTable;
segments.push({ table: true, text: table });
cursor = range.end;
}
const tail = markdown.slice(cursor).replace(/^(?:[ \t]*\n)+/u, "");
if (tail.trim()) {
segments.push({ table: false, text: tail });
}
return segments;
}
export function prepareMatrixSingleText(
text: string,
opts: {
cfg: CoreConfig;
accountId?: string;
tableMode?: MarkdownTableMode;
preserveWhitespace?: boolean;
},
): MatrixPreparedSingleText {
const normalizedText = text.replace(/\r\n?/gu, "\n");
const trimmedText = opts.preserveWhitespace ? normalizedText : normalizedText.trim();
const cfg = requireRuntimeConfig(opts.cfg, "Matrix text preparation") as CoreConfig;
const tableMode =
opts.tableMode ??
getCore().channel.text.resolveMarkdownTableMode({
cfg,
channel: "matrix",
accountId: opts.accountId,
supportsBlockTables: MATRIX_FORMAT_PROFILE.constructs.table === "native",
});
const singleEventLimit = Math.min(
getCore().channel.text.resolveTextChunkLimit(cfg, "matrix", opts.accountId),
MATRIX_FORMAT_PROFILE.chunk.limit,
);
const convertedText = renderMatrixMarkdownTables(trimmedText, tableMode);
const eventTextLength = Math.max(
convertedText.length,
markdownToMatrixBody(convertedText).length,
);
return {
trimmedText,
convertedText,
singleEventLimit,
eventTextLength,
fitsInSingleEvent: eventTextLength <= singleEventLimit,
tableMode,
};
}
export function chunkMatrixText(
text: string,
opts: {
cfg: CoreConfig;
accountId?: string;
tableMode?: MarkdownTableMode;
preserveWhitespace?: boolean;
},
): MatrixPreparedChunkedText {
const preparedText = prepareMatrixSingleText(text, opts);
if (preparedText.fitsInSingleEvent) {
return {
...preparedText,
chunks: preparedText.convertedText ? [preparedText.convertedText] : [],
};
}
const cfg = requireRuntimeConfig(opts.cfg, "Matrix text chunking") as CoreConfig;
const chunkMode = getCore().channel.text.resolveChunkMode(cfg, "matrix", opts.accountId);
const collisionRedacted = hasMatrixSpoilerMetadataCollision(preparedText.convertedText)
? markdownToMatrixBody(preparedText.convertedText)
: undefined;
const chunkSegment = (segmentText: string): string[] => {
const sourceText = hasMatrixSpoilerMetadataCollision(segmentText)
? markdownToMatrixBody(segmentText)
: segmentText;
const protectedUnderline = protectMatrixUnderlineTags(sourceText);
const protectedSpoilers = protectMatrixSpoilerDelimiters(protectedUnderline.markdown);
const wrapperReserve =
(protectedSpoilers.markers ? 4 : 0) + (protectedUnderline.markers ? 7 : 0);
const privateMarkers = [protectedSpoilers.markers, protectedUnderline.markers].flatMap(
(markers) => (markers ? [markers.open, markers.close, markers.padding] : []),
);
let reserve = wrapperReserve;
while (reserve < preparedText.singleEventLimit) {
const protectedChunks = getCore().channel.text.chunkMarkdownTextWithMode(
protectedSpoilers.markdown,
preparedText.singleEventLimit - reserve,
chunkMode,
);
const restored = restoreMatrixStyleChunks(
protectedChunks,
protectedSpoilers.markers,
protectedUnderline.markers,
).filter((_, index) => {
const source = privateMarkers.reduce(
(value, marker) => value.replaceAll(marker, ""),
protectedChunks[index] ?? "",
);
return source.length > 0;
});
const overflow = Math.max(
0,
...restored.map(
(chunk) =>
Math.max(chunk.length, markdownToMatrixBody(chunk).length) -
preparedText.singleEventLimit,
),
);
if (overflow === 0) {
return restored;
}
reserve += overflow;
}
throw new Error("Matrix text chunk limit is too small for formatted content");
};
const chunks =
collisionRedacted !== undefined
? chunkSegment(collisionRedacted)
: preparedText.tableMode === "block"
? splitMatrixTableSegments(preparedText.convertedText).flatMap((segment) => {
if (!segment.table) {
return chunkSegment(segment.text);
}
return segment.text.length <= preparedText.singleEventLimit
? [segment.text]
: chunkSegment(renderMatrixMarkdownTables(segment.text, "bullets"));
})
: chunkSegment(preparedText.convertedText);
return {
...preparedText,
chunks,
};
}
@@ -1,7 +1,9 @@
import type { MarkdownTableMode } from "openclaw/plugin-sdk/markdown-table-runtime";
// Matrix helper module supports formatting behavior.
import { isVoiceMessageCompatibleAudio } from "openclaw/plugin-sdk/media-runtime";
import { getMatrixRuntime } from "../../runtime.js";
import {
markdownToMatrixBody,
markdownToMatrixHtml,
resolveMatrixMentionsInMarkdown,
renderMarkdownToMatrixHtmlWithMentions,
@@ -26,17 +28,20 @@ async function renderMatrixFormattedContent(params: {
client: MatrixClient;
markdown?: string | null;
includeMentions?: boolean;
}): Promise<{ html?: string; mentions?: MatrixMentions }> {
tableMode?: MarkdownTableMode;
}): Promise<{ body: string; html?: string; mentions?: MatrixMentions }> {
const markdown = params.markdown ?? "";
const body = markdownToMatrixBody(markdown);
if (params.includeMentions === false) {
const html = markdownToMatrixHtml(markdown).trimEnd();
return { html: html || undefined };
const html = markdownToMatrixHtml(markdown, { tableMode: params.tableMode }).trimEnd();
return { body, html: html || undefined };
}
const { html, mentions } = await renderMarkdownToMatrixHtmlWithMentions({
markdown,
client: params.client,
tableMode: params.tableMode,
});
return { html, mentions };
return { body, html, mentions };
}
export function buildTextContent(
@@ -64,12 +69,15 @@ export async function enrichMatrixFormattedContent(params: {
content: MatrixFormattedContent;
markdown?: string | null;
includeMentions?: boolean;
tableMode?: MarkdownTableMode;
}): Promise<void> {
const { html, mentions } = await renderMatrixFormattedContent({
const { body, html, mentions } = await renderMatrixFormattedContent({
client: params.client,
markdown: params.markdown,
includeMentions: params.includeMentions,
tableMode: params.tableMode,
});
params.content.body = body || params.content.body;
if (mentions) {
params.content["m.mentions"] = mentions;
} else {