mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-12 21:53:00 -06:00
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:
committed by
GitHub
parent
90aee82793
commit
21103ff8e9
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 }];
|
||||
});
|
||||
}
|
||||
@@ -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><u>under</u> and <ins>inserted</ins></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 = "";
|
||||
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`");
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user