refactor: unify Teams and QQ markdown rendering (#113100)

* refactor(channels): unify Teams and QQ markdown

* fix(channels): narrow markdown formatter types

* style(channels): satisfy markdown lint

* fix(channels): keep format profiles private

* fix(msteams): make raw table scan deterministic
This commit is contained in:
Peter Steinberger
2026-07-23 10:46:06 -07:00
committed by GitHub
parent 1f051da39d
commit 1c102d419f
7 changed files with 1658 additions and 98 deletions
+303
View File
@@ -0,0 +1,303 @@
import { describe, expect, it } from "vitest";
import { formatMSTeamsMarkdown } from "./format.js";
describe("formatMSTeamsMarkdown", () => {
const fixtures = [
{
name: "falls headings back to bold text",
before: "# Deployment status",
after: "**Deployment status**",
},
{
name: "falls unordered lists back to mobile-safe bullets",
before: "- alpha\n- beta",
after: "• alpha\n• beta",
},
{
name: "falls ordered lists back to numbered text",
before: "1. alpha\n2. beta",
after: "1. alpha\n2. beta",
},
{
name: "falls task lists back to checkbox text",
before: "- [x] shipped\n- [ ] pending",
after: "[x] shipped\n[ ] pending",
},
{
name: "keeps partially supported strikethrough markers",
before: "~~obsolete~~",
after: "~~obsolete~~",
},
{
name: "keeps supported blockquote markers",
before: "> quoted",
after: "> quoted",
},
{
name: "keeps every paragraph inside a blockquote",
before: "> one\n>\n> two",
after: "> one\n> \n> two",
},
{
name: "stops blockquote prefixes before following text",
before: "> quoted\n\noutside",
after: "> quoted\n\noutside",
},
{
name: "does not linkify plain filenames",
before: "See README.md",
after: "See README.md",
},
{
name: "preserves entity-encoded markdown literals",
before: "**literal**",
after: "**literal**",
},
{
name: "preserves transport-owned mentions",
before: "@[Alice](29:abc)",
after: "@[Alice](29:abc)",
},
{
name: "preserves escaped brackets in transport-owned mentions",
before: String.raw`@[Alice \[Ops\]](29:abc)`,
after: String.raw`@[Alice \[Ops\]](29:abc)`,
},
{
name: "preserves transport-owned markdown images",
before: "![chart](https://example.com/chart_(final).png)",
after: "![chart](https://example.com/chart_(final).png)",
},
{
name: "preserves images containing nested opener text",
before: "![plot](https://example.com/a![b].png)",
after: "![plot](https://example.com/a![b].png)",
},
{
name: "includes protected image backticks when choosing code delimiters",
before: "``![x`](https://example.com/x.png)``",
after: "``![x`](https://example.com/x.png)``",
},
{
name: "keeps every fenced-code line inside a blockquote",
before: "> ```\n> one\n> two\n> ```",
after: "> ```\n> one\n> two\n> ```",
},
{
name: "keeps surrounding blockquote text around inline code",
before: "> Run `status` now.",
after: "> Run `status` now.",
},
{
name: "keeps escaped markdown literal",
before: String.raw`\*literal\*`,
after: String.raw`\*literal\*`,
},
{
name: "keeps escaped literal backticks",
before: String.raw`\`literal\``,
after: String.raw`\`literal\``,
},
{
name: "restores escaped markdown nested inside code",
before: "`\\*`",
after: "`\\*`",
},
{
name: "falls nested lists back without treating indentation as code",
before: "- parent\n - child",
after: "• parent\n • child",
},
{
name: "keeps inline code delimiters that protect embedded backticks",
before: "``value `with` ticks``",
after: "``value `with` ticks``",
},
{
name: "includes escaped backticks when choosing inline code delimiters",
before: "``a \\` b``",
after: "``a \\` b``",
},
{
name: "preserves inline code semantics while normalizing boundary spaces",
before: "` foo `",
after: "` foo`",
},
{
name: "serializes link destinations with angle brackets",
before: "[x](https://host/a)",
after: "[x](<https://host/a>)",
},
{
name: "drops code language while keeping a collision-safe fence",
before: ["````md", "```", "example", "```", "````"].join("\n"),
after: ["````", "```", "example", "```", "````"].join("\n"),
},
{
name: "normalizes indented code to a collision-safe fence",
before: " **literal code**",
after: ["```", "**literal code**", "```"].join("\n"),
},
];
for (const fixture of fixtures) {
it(fixture.name, () => {
expect(formatMSTeamsMarkdown(fixture.before, "off")).toBe(fixture.after);
});
}
it("keeps raw tables when table conversion is disabled", () => {
const table = ["| Name | State |", "|---|---|", "| deploy | ready |"].join("\n");
expect(formatMSTeamsMarkdown(table, "off")).toBe(table);
});
it("keeps one-column raw tables when table conversion is disabled", () => {
const table = ["| Name |", "|---|", "| deploy |"].join("\n");
expect(formatMSTeamsMarkdown(table, "off")).toBe(table);
});
it("keeps raw tables with tab-padded delimiter cells", () => {
const table = ["| A | B |", "|\t---\t|\t---\t|", "| x | y |"].join("\n");
expect(formatMSTeamsMarkdown(table, "off")).toBe(table);
});
it("keeps pipe-less body rows in raw tables when conversion is disabled", () => {
const table = ["| Name | State |", "|---|---|", "[deploy](https://host/a)"].join("\n");
expect(formatMSTeamsMarkdown(table, "off")).toBe(table);
});
it("does not treat tables inside fenced code as raw table blocks", () => {
const before = ["```", "| A | B |", "|---|---|", "| x | y |", "```", "", "# Next"].join("\n");
const after = ["```", "| A | B |", "|---|---|", "| x | y |", "```", "**Next**"].join("\n");
expect(formatMSTeamsMarkdown(before, "off")).toBe(after);
});
it("protects table-looking fenced blocks inside blockquotes", () => {
const fence = [
"> ```",
"> | A | B |",
"> |---|---|",
"> ![x](https://e.test/a?x=1&amp;y=2)",
"> ```",
].join("\n");
expect(formatMSTeamsMarkdown(`${fence}\n\n# Next`, "off")).toBe(`${fence}\n**Next**`);
});
it("stops blockquoted raw tables at quote-only lines", () => {
const before = ["> | A | B |", "> |---|---|", "> | x | y |", ">", "> # Next"].join("\n");
const after = ["> | A | B |", "> |---|---|", "> | x | y |", "> ", "> **Next**"].join("\n");
expect(formatMSTeamsMarkdown(before, "off")).toBe(after);
});
it("stops quoted raw tables when following content leaves the quote", () => {
const before = ["> | A | B |", "> |---|---|", "> | x | y |", "# Next"].join("\n");
const after = ["> | A | B |", "> |---|---|", "> | x | y |", "", "**Next**"].join("\n");
expect(formatMSTeamsMarkdown(before, "off")).toBe(after);
});
it("ends unclosed quoted fences when the quote container ends", () => {
const before = ["> ```", "> code", "", "| A | B |", "|---|---|", "| x | y |"].join("\n");
const after = ["> ```", "> code", "> ```", "| A | B |", "|---|---|", "| x | y |"].join("\n");
expect(formatMSTeamsMarkdown(before, "off")).toBe(after);
});
it("preserves quoted text around fenced code", () => {
const before = ["> Before", ">", "> ```", "> code", "> ```", ">", "> After"].join("\n");
const output = formatMSTeamsMarkdown(before, "off");
expect(output).toContain("> Before");
expect(output).toContain("> ```\n> code\n> ```");
expect(output).toContain("> After");
expect(output).not.toContain("```> ");
});
it("measures raw table quote depth from leading markers only", () => {
const table = ["| A > B | State |", "|---|---|", "[x](https://host/a)"].join("\n");
expect(formatMSTeamsMarkdown(table, "off")).toBe(table);
});
it("stops nested quoted tables at quote-only lines", () => {
const before = ["> > | A | B |", "> > |---|---|", "> > | x | y |", "> >", "> > # Next"].join(
"\n",
);
const output = formatMSTeamsMarkdown(before, "off");
expect(output).toContain("**Next**");
expect(output).not.toContain("# Next");
});
it("stops quoted tables at quote-only lines with trailing whitespace", () => {
const before = ["> | A | B |", "> |---|---|", "> | x | y |", "> ", "> # Next"].join("\n");
expect(formatMSTeamsMarkdown(before, "off")).toContain("**Next**");
});
it("ends list-contained fence state on outdent", () => {
const before = ["- ```", " code", "", "| A | B |", "|---|---|", "[x](https://host/a)"].join(
"\n",
);
expect(formatMSTeamsMarkdown(before, "off")).toContain("[x](https://host/a)");
});
it("stops raw tables at interrupting headings without a blank line", () => {
const before = ["| A | B |", "|---|---|", "| x | y |", "# Next"].join("\n");
expect(formatMSTeamsMarkdown(before, "off")).toContain("**Next**");
});
it("does not hide later blocks behind malformed images", () => {
const output = formatMSTeamsMarkdown("![x](bad\n\n# Next)", "off");
expect(output).toContain("**Next");
expect(output).not.toContain("# Next");
});
it("does not let nested images complete malformed outer candidates", () => {
const output = formatMSTeamsMarkdown("![broken\n# Next ![x](https://e.test/x.png)", "off");
expect(output).toContain("**Next");
expect(output).toContain("![x](https://e.test/x.png)");
});
it("tracks fences opened on list continuation lines", () => {
const before = [
"- item",
" ```",
" code",
"",
"| A | B |",
"|---|---|",
"[x](https://host/a)",
].join("\n");
expect(formatMSTeamsMarkdown(before, "off")).toContain("[x](https://host/a)");
});
it("rejects backticks in backtick fence info strings", () => {
const before = ["```bad`", "", "| A | B |", "|---|---|", "[x](https://host/a)"].join("\n");
expect(formatMSTeamsMarkdown(before, "off")).toContain("[x](https://host/a)");
});
it("treats tab-indented fence markers as indented code", () => {
const before = ["\t```", "", "| A | B |", "|---|---|", "[x](https://host/a)"].join("\n");
expect(formatMSTeamsMarkdown(before, "off")).toContain("[x](https://host/a)");
});
it("treats over-indented quoted fence markers as indented code", () => {
const before = [" > ```", "", "> | A | B |", "> |---|---|", "> [x](https://host/a)"].join(
"\n",
);
expect(formatMSTeamsMarkdown(before, "off")).toContain("> [x](https://host/a)");
});
it("formats surrounding constructs while preserving a disabled raw table", () => {
const table = ["| Name | State |", "|---|---|", "| deploy | ready |"].join("\n");
const before = `# Status\n\n${table}\n\n- next`;
expect(formatMSTeamsMarkdown(before, "off")).toBe(`**Status**\n\n${table}\n\n• next`);
});
it("keeps blockquoted tables raw when table conversion is disabled", () => {
const table = ["> | Name | State |", "> |---|---|", "> | deploy | ready |"].join("\n");
expect(formatMSTeamsMarkdown(table, "off")).toBe(table);
});
it("does not restore forged placeholders decoded from character references", () => {
const source = "&#xE000;msteamsformat&#xE001;m0&#xE002; @[Alice](29:abc)";
const output = formatMSTeamsMarkdown(source, "off");
expect(output.match(/@\[Alice\]/gu)).toHaveLength(1);
expect(output).toContain("&#xE000;msteamsformat&#xE001;m0&#xE002;");
});
});
+575
View File
@@ -0,0 +1,575 @@
import { randomUUID } from "node:crypto";
import {
convertMarkdownTables,
type FormatCapabilityProfile,
type MarkdownIR,
markdownToIR,
renderMarkdownWithMarkers,
} from "openclaw/plugin-sdk/text-chunking";
import type { MarkdownTableMode } from "../runtime-api.js";
const ESCAPED_MARKDOWN_RE = /\\[\\`*_{}[\]()#+\-.!|>~]/gu;
const MARKDOWN_ENTITY_RE = /&(?:#\d+|#x[\da-f]+|[a-z][a-z\d]+);/giu;
const TOKEN_END = "\u{E002}";
const MSTEAMS_FORMAT_CAPABILITIES = {
mechanism: "markdown",
constructs: {
bold: "native",
italic: "native",
underline: "strip",
// Teams supports strikethrough on desktop and iOS, but not Android.
strikethrough: "native",
spoiler: "fallback",
codeInline: "native",
codeBlock: "native",
codeLanguage: "fallback",
linkLabel: "native",
heading: "fallback",
bulletList: "fallback",
orderedList: "fallback",
taskList: "fallback",
table: "fallback",
blockquote: "native",
image: "native",
mention: "native",
},
chunk: { limit: 80_000, unit: "utf16", hardCap: 100_000 },
} satisfies FormatCapabilityProfile;
const MSTEAMS_MARKERS = {
bold: { open: "**", close: "**" },
italic: { open: "*", close: "*" },
strikethrough: { open: "~~", close: "~~" },
} as const;
function createTokenPrefix(text: string, label: string): string {
const normalized = markdownToIR(text, { autolink: false, linkify: false }).text;
let prefix: string;
do {
prefix = `\u{E000}${label}-${randomUUID()}\u{E001}`;
} while (text.includes(prefix) || normalized.includes(prefix));
return prefix;
}
function restoreTokens(text: string, prefix: string, values: readonly string[]): string {
const escapedPrefix = prefix.replace(/[.*+?^${}()|[\]\\]/gu, "\\$&");
return text.replace(
new RegExp(`${escapedPrefix}(\\d+)${TOKEN_END}`, "gu"),
(_token, index: string) => values[Number(index)] ?? "",
);
}
type TextEdit = { start: number; end: number; text: string };
function rewriteMarkdownIR(ir: MarkdownIR, edits: readonly TextEdit[]): MarkdownIR {
const ordered = [...edits].toSorted((a, b) => a.start - b.start);
let text = "";
let cursor = 0;
for (const edit of ordered) {
text += ir.text.slice(cursor, edit.start) + edit.text;
cursor = edit.end;
}
text += ir.text.slice(cursor);
const cumulativeDeltas: number[] = [];
let delta = 0;
for (const edit of ordered) {
delta += edit.text.length - (edit.end - edit.start);
cumulativeDeltas.push(delta);
}
const exactEdits = new Map(ordered.map((edit) => [`${edit.start}:${edit.end}`, edit]));
const mapOffset = (offset: number): number => {
let low = 0;
let high = ordered.length;
while (low < high) {
const middle = low + Math.floor((high - low) / 2);
if ((ordered[middle]?.end ?? Number.POSITIVE_INFINITY) <= offset) {
low = middle + 1;
} else {
high = middle;
}
}
return offset + (low > 0 ? (cumulativeDeltas[low - 1] ?? 0) : 0);
};
const mapRange = <T extends { start: number; end: number }>(range: T): T => {
const exact = exactEdits.get(`${range.start}:${range.end}`);
const start = mapOffset(range.start);
return { ...range, start, end: exact ? start + exact.text.length : mapOffset(range.end) };
};
return {
...ir,
text,
styles: ir.styles.map(mapRange),
links: ir.links.map(mapRange),
...(ir.annotations ? { annotations: ir.annotations.map(mapRange) } : {}),
...(ir.listItems
? {
listItems: ir.listItems.map((item) => ({
...item,
...(item.listMarker ? { listMarker: mapRange(item.listMarker) } : {}),
...(item.taskMarker ? { taskMarker: mapRange(item.taskMarker) } : {}),
})),
}
: {}),
};
}
function prefixMSTeamsBlockquotes(ir: MarkdownIR): MarkdownIR {
const quoteSpans = ir.styles.filter((span) => span.style === "blockquote");
const edits = quoteSpans.flatMap((span) => {
const positions = [span.start];
for (let index = span.start; index < span.end; index += 1) {
if (ir.text[index] === "\n" && index + 1 < span.end) {
positions.push(index + 1);
}
}
return positions.map((position) => ({ start: position, end: position, text: "> " }));
});
const rewritten = rewriteMarkdownIR(ir, edits);
return {
...rewritten,
styles: rewritten.styles.filter((span) => span.style !== "blockquote"),
};
}
function longestBacktickRun(text: string): number {
return Math.max(0, ...(text.match(/`+/gu)?.map((run) => run.length) ?? []));
}
function renderMSTeamsCode(style: "code" | "code_block", text: string): string {
const marker = "`".repeat(Math.max(style === "code_block" ? 3 : 1, longestBacktickRun(text) + 1));
if (style === "code_block") {
return `${marker}\n${text}${marker}`;
}
const needsPadding =
text.startsWith("`") ||
text.endsWith("`") ||
(text.startsWith(" ") && text.endsWith(" ") && text.trim().length > 0);
return `${marker}${needsPadding ? " " : ""}${text}${needsPadding ? " " : ""}${marker}`;
}
function serializeMarkdownDestination(href: string): string {
return `<${href.replace(/([\\<>])/gu, "\\$1")}>`;
}
type ImageCandidateScan = { end: number } | { next: number } | undefined;
function blankBlockEnd(text: string, index: number): number | undefined {
const match = /^(?:\r?\n)[ \t]*(?:\r?\n)/u.exec(text.slice(index));
return match ? index + match[0].length : undefined;
}
function scanDelimitedMarkdown(
text: string,
start: number,
nestedOpener: "![" | "@[",
): ImageCandidateScan {
let bracketDepth = 1;
let altEnd: number | undefined;
let fallbackNext: number | undefined;
for (let index = start + 2; index < text.length; index += 1) {
const blankEnd = blankBlockEnd(text, index);
if (blankEnd !== undefined) {
return { next: fallbackNext ?? blankEnd };
}
if (text[index] === "\\") {
index += 1;
} else if (text.startsWith(nestedOpener, index)) {
fallbackNext = index;
bracketDepth += 1;
index += 1;
} else if (text[index] === "[") {
bracketDepth += 1;
} else if (text[index] === "]" && --bracketDepth === 0) {
altEnd = index;
break;
}
}
if (altEnd === undefined || text[altEnd + 1] !== "(") {
const next =
fallbackNext ?? text.indexOf(nestedOpener, altEnd === undefined ? start + 2 : altEnd + 1);
return next < 0 ? undefined : { next };
}
let parenDepth = 1;
for (let index = altEnd + 2; index < text.length; index += 1) {
const blankEnd = blankBlockEnd(text, index);
if (blankEnd !== undefined) {
return { next: fallbackNext ?? blankEnd };
}
if (text[index] === "\\") {
index += 1;
} else if (text.startsWith(nestedOpener, index)) {
fallbackNext = index;
} else if (text[index] === "(") {
parenDepth += 1;
} else if (text[index] === ")" && --parenDepth === 0) {
return { end: index + 1 };
}
}
return fallbackNext === undefined ? undefined : { next: fallbackNext };
}
function protectMarkdownImages(text: string, tokenPrefix: string, images: string[]): string {
let protectedText = "";
let cursor = 0;
let searchFrom = 0;
while (searchFrom < text.length) {
const start = text.indexOf("![", searchFrom);
if (start < 0) {
break;
}
const scan = scanDelimitedMarkdown(text, start, "![");
if (!scan) {
break;
}
if ("next" in scan) {
searchFrom = scan.next;
continue;
}
protectedText += text.slice(cursor, start);
const index = images.push(text.slice(start, scan.end)) - 1;
protectedText += `${tokenPrefix}i${index}${TOKEN_END}`;
cursor = scan.end;
searchFrom = scan.end;
}
return protectedText + text.slice(cursor);
}
function protectMSTeamsMentions(text: string, tokenPrefix: string, mentions: string[]): string {
let protectedText = "";
let cursor = 0;
let searchFrom = 0;
while (searchFrom < text.length) {
const start = text.indexOf("@[", searchFrom);
if (start < 0) {
break;
}
const scan = scanDelimitedMarkdown(text, start, "@[");
if (!scan) {
break;
}
if ("next" in scan) {
searchFrom = scan.next;
continue;
}
protectedText += text.slice(cursor, start);
const index = mentions.push(text.slice(start, scan.end)) - 1;
protectedText += `${tokenPrefix}m${index}${TOKEN_END}`;
cursor = scan.end;
searchFrom = scan.end;
}
return protectedText + text.slice(cursor);
}
function parseQuotePrefix(line: string): { content: string; depth: number; prefix: string } {
let cursor = 0;
let depth = 0;
while (cursor < line.length) {
const checkpoint = cursor;
let spaces = 0;
while (spaces < 3 && line[cursor] === " ") {
cursor += 1;
spaces += 1;
}
if (line[cursor] !== ">") {
cursor = checkpoint;
break;
}
cursor += 1;
depth += 1;
if (line[cursor] === " " || line[cursor] === "\t") {
cursor += 1;
}
}
return { content: line.slice(cursor).replace(/\r$/u, ""), depth, prefix: line.slice(0, cursor) };
}
function isTableDelimiterLine(content: string): boolean {
const trimmed = content.trim();
const inner = trimmed.replace(/^\|/u, "").replace(/\|$/u, "");
const cells = inner.split("|").map((cell) => cell.trim());
return cells.length > 0 && cells.every((cell) => /^:?-+:?$/u.test(cell));
}
function protectRawTablesInSegment(text: string, tokenPrefix: string, rawTables: string[]): string {
const lines = text.split("\n");
const output: string[] = [];
for (let index = 0; index < lines.length;) {
const line = lines[index] ?? "";
const header = parseQuotePrefix(line);
const delimiter = parseQuotePrefix(lines[index + 1] ?? "");
if (
header.content.includes("|") &&
delimiter.depth === header.depth &&
isTableDelimiterLine(delimiter.content)
) {
let end = index + 2;
while (end < lines.length) {
const row = parseQuotePrefix(lines[end] ?? "");
if (
row.depth !== header.depth ||
!row.content.trim() ||
isInterruptingBlock(lines[end] ?? "")
) {
break;
}
end += 1;
}
const table = lines.slice(index, end).join("\n");
if (convertMarkdownTables(table, "code") !== table) {
const tableIndex = rawTables.push(table.slice(header.prefix.length)) - 1;
output.push(`${header.prefix}${tokenPrefix}t${tableIndex}${TOKEN_END}`);
index = end;
continue;
}
for (let lineIndex = index; lineIndex < end; lineIndex += 1) {
output.push(lines[lineIndex] ?? "");
}
index = end;
continue;
}
output.push(line);
index += 1;
}
return output.join("\n");
}
function isInterruptingBlock(line: string): boolean {
const content = parseQuotePrefix(line).content;
return /^[ \t]{0,3}(?:#{1,6}(?:[ \t]|$)|`{3,}|~{3,}|(?:[-+*]|\d+[.)])[ \t]+)/u.test(content);
}
function leadingQuoteDepth(line: string): number {
return parseQuotePrefix(line).depth;
}
function parseFenceLine(
line: string,
):
| { marker: string; quoteDepth: number; trailing: string; listIndent: number; indent: number }
| undefined {
const match =
/^((?: {0,3}>[ \t]?)*)(?:((?:[-+*]|\d+[.)])[ \t]+))?( {0,3})(`{3,}|~{3,})(.*)$/u.exec(line);
const marker = match?.[4];
const trailing = match?.[5] ?? "";
if (!marker || (marker.startsWith("`") && trailing.includes("`"))) {
return undefined;
}
return {
marker,
quoteDepth: match?.[1]?.match(/>/gu)?.length ?? 0,
trailing,
listIndent: match?.[2]?.length ?? 0,
indent: match?.[3]?.length ?? 0,
};
}
function protectRawTablesOutsideFences(
text: string,
tokenPrefix: string,
rawTables: string[],
): string {
let result = "";
let outsideStart = 0;
let fenceStart: number | undefined;
let active: { marker: string; quoteDepth: number; listIndent: number } | undefined;
let listContextIndent = 0;
let offset = 0;
while (offset <= text.length) {
const nextNewline = text.indexOf("\n", offset);
const lineEnd = nextNewline < 0 ? text.length : nextNewline;
const line = text.slice(offset, lineEnd).replace(/\r$/u, "");
let fence = parseFenceLine(line);
const lineQuoteDepth = leadingQuoteDepth(line);
const lineWithoutQuotes = line.replace(/^(?:[ \t]*>[ \t]?)+/u, "");
const lineIndent = /^[ \t]*/u.exec(lineWithoutQuotes)?.[0].length ?? 0;
const listMarkerIndent = /^((?:[-+*]|\d+[.)])[ \t]+)/u.exec(lineWithoutQuotes)?.[1]?.length;
if (listMarkerIndent) {
listContextIndent = listMarkerIndent;
} else if (line.trim() && lineIndent < listContextIndent && !fence) {
listContextIndent = 0;
}
if (
fence &&
fence.listIndent === 0 &&
listContextIndent > 0 &&
fence.indent >= listContextIndent
) {
fence = { ...fence, listIndent: listContextIndent };
}
const listOutdented = Boolean(
active?.listIndent && line.trim() && lineIndent < active.listIndent && !fence,
);
if (active && (active.quoteDepth > lineQuoteDepth || listOutdented)) {
result += text.slice(fenceStart, offset);
outsideStart = offset;
active = undefined;
fenceStart = undefined;
}
if (!active && fence) {
result += protectRawTablesInSegment(text.slice(outsideStart, offset), tokenPrefix, rawTables);
fenceStart = offset;
active = {
marker: fence.marker,
quoteDepth: fence.quoteDepth,
listIndent: fence.listIndent,
};
} else if (
active &&
fence &&
fence.marker[0] === active.marker[0] &&
fence.marker.length >= active.marker.length &&
fence.quoteDepth === active.quoteDepth &&
/^[ \t]*$/u.test(fence.trailing)
) {
const fenceEnd = nextNewline < 0 ? lineEnd : nextNewline + 1;
result += text.slice(fenceStart, fenceEnd);
outsideStart = fenceEnd;
active = undefined;
fenceStart = undefined;
}
if (nextNewline < 0) {
break;
}
offset = nextNewline + 1;
}
if (active && fenceStart !== undefined) {
result += text.slice(fenceStart);
return result;
}
return result + protectRawTablesInSegment(text.slice(outsideStart), tokenPrefix, rawTables);
}
function protectMSTeamsCode(
ir: MarkdownIR,
tokenPrefix: string,
code: string[],
protectedValues: readonly { prefix: string; values: readonly string[] }[],
): MarkdownIR {
const codeSpans = ir.styles.filter(
(span) => span.style === "code" || span.style === "code_block",
);
const codeBlocks = codeSpans.filter((span) => span.style === "code_block");
const adjustedStyles = ir.styles.flatMap((span) => {
if (span.style !== "blockquote") {
return [span];
}
let segments = [span];
for (const codeBlock of codeBlocks) {
segments = segments.flatMap((segment) => {
if (codeBlock.end <= segment.start || codeBlock.start >= segment.end) {
return [segment];
}
return [
...(segment.start < codeBlock.start ? [{ ...segment, end: codeBlock.start }] : []),
...(codeBlock.end < segment.end ? [{ ...segment, start: codeBlock.end }] : []),
];
});
}
return segments;
});
const rewritten = rewriteMarkdownIR(
{ ...ir, styles: adjustedStyles },
codeSpans.map((span) => {
const codeStyle = span.style === "code_block" ? "code_block" : "code";
const source = protectedValues.reduce(
(text, protectedValue) => restoreTokens(text, protectedValue.prefix, protectedValue.values),
ir.text.slice(span.start, span.end),
);
const quoteDepth =
codeStyle === "code_block"
? ir.styles.filter(
(candidate) =>
candidate.style === "blockquote" &&
span.start >= candidate.start &&
span.start < candidate.end,
).length
: 0;
const rendered = renderMSTeamsCode(codeStyle, source);
let quoted =
quoteDepth > 0
? `${"> ".repeat(quoteDepth)}${rendered.replaceAll("\n", `\n${"> ".repeat(quoteDepth)}`)}`
: rendered;
const hasTrailingQuotedText =
codeStyle === "code_block" &&
ir.styles.some(
(candidate) =>
candidate.style === "blockquote" &&
span.start >= candidate.start &&
candidate.end > span.end,
);
if (hasTrailingQuotedText && !quoted.endsWith("\n")) {
quoted += "\n";
}
const index = code.push(quoted) - 1;
return { start: span.start, end: span.end, text: `${tokenPrefix}c${index}${TOKEN_END}` };
}),
);
return {
...rewritten,
styles: rewritten.styles.filter((span) => span.style !== "code" && span.style !== "code_block"),
};
}
export function formatMSTeamsMarkdown(markdown: string, tableMode: MarkdownTableMode): string {
const rawTables: string[] = [];
const escapedMarkdown: string[] = [];
const codeRegions: string[] = [];
const images: string[] = [];
const mentions: string[] = [];
const entities: string[] = [];
const tokenPrefix = createTokenPrefix(markdown, "msteamsformat");
const entitiesProtected = markdown.replace(MARKDOWN_ENTITY_RE, (entity) => {
const index = entities.push(entity) - 1;
return `${tokenPrefix}h${index}${TOKEN_END}`;
});
const imagesProtected = protectMarkdownImages(entitiesProtected, tokenPrefix, images);
const mentionsProtected = protectMSTeamsMentions(imagesProtected, tokenPrefix, mentions);
const tableInput = convertMarkdownTables(mentionsProtected, tableMode);
const converted =
tableMode === "off"
? protectRawTablesOutsideFences(tableInput, tokenPrefix, rawTables)
: tableInput;
const protectedMarkdown = converted.replace(ESCAPED_MARKDOWN_RE, (escaped) => {
const index = escapedMarkdown.push(escaped) - 1;
return `${tokenPrefix}e${index}${TOKEN_END}`;
});
const parsed = markdownToIR(protectedMarkdown, {
autolink: false,
enableSpoilers: true,
enableTaskLists: true,
headingStyle: "rich",
linkify: false,
blockquotePrefix: "",
});
const ir = prefixMSTeamsBlockquotes(
protectMSTeamsCode(parsed, tokenPrefix, codeRegions, [
{ prefix: `${tokenPrefix}e`, values: escapedMarkdown },
{ prefix: `${tokenPrefix}t`, values: rawTables },
{ prefix: `${tokenPrefix}m`, values: mentions },
{ prefix: `${tokenPrefix}i`, values: images },
{ prefix: `${tokenPrefix}h`, values: entities },
]),
);
const rendered = renderMarkdownWithMarkers(
ir,
{
styleMarkers: MSTEAMS_MARKERS,
escapeText: (text) => text,
buildLink: (link) => ({
start: link.start,
end: link.end,
open: "[",
close: `](${serializeMarkdownDestination(link.href)})`,
}),
},
MSTEAMS_FORMAT_CAPABILITIES,
);
let restored = restoreTokens(rendered, `${tokenPrefix}c`, codeRegions);
restored = restoreTokens(restored, `${tokenPrefix}e`, escapedMarkdown);
restored = restoreTokens(restored, `${tokenPrefix}t`, rawTables);
restored = restoreTokens(restored, `${tokenPrefix}m`, mentions);
restored = restoreTokens(restored, `${tokenPrefix}i`, images);
return restoreTokens(restored, `${tokenPrefix}h`, entities);
}
+2 -1
View File
@@ -16,6 +16,7 @@ import type { MSTeamsAccessTokenProvider } from "./attachments/types.js";
import type { StoredConversationReference } from "./conversation-store.js";
import { classifyMSTeamsSendError } from "./errors.js";
import { prepareFileConsentActivity, requiresFileConsent } from "./file-consent-helpers.js";
import { formatMSTeamsMarkdown } from "./format.js";
import { buildTeamsFileInfoCard } from "./graph-chat.js";
import {
getDriveItemProperties,
@@ -231,7 +232,7 @@ export function renderReplyPayloadsToMessages(
for (const payload of replies) {
const reply = resolveSendableOutboundReplyParts(payload, {
text: getMSTeamsRuntime().channel.text.convertMarkdownTables(payload.text ?? "", tableMode),
text: formatMSTeamsMarkdown(payload.text ?? "", tableMode),
});
if (!reply.hasContent) {
+2 -2
View File
@@ -6,7 +6,6 @@ import {
type MessageReceiptPartKind,
} from "openclaw/plugin-sdk/channel-outbound";
import { resolveMarkdownTableMode } from "openclaw/plugin-sdk/markdown-table-runtime";
import { convertMarkdownTables } from "openclaw/plugin-sdk/text-chunking";
import { loadOutboundMediaFromUrl, type OpenClawConfig } from "../runtime-api.js";
import {
classifyMSTeamsSendError,
@@ -14,6 +13,7 @@ import {
formatUnknownError,
} from "./errors.js";
import { prepareFileConsentActivityFs, requiresFileConsent } from "./file-consent-helpers.js";
import { formatMSTeamsMarkdown } from "./format.js";
import { buildTeamsFileInfoCard } from "./graph-chat.js";
import {
getDriveItemProperties,
@@ -180,7 +180,7 @@ export async function sendMessageMSTeams(
cfg,
channel: "msteams",
});
const messageText = convertMarkdownTables(text ?? "", tableMode);
const messageText = formatMSTeamsMarkdown(text ?? "", tableMode);
const ctx = await resolveMSTeamsSendContext({ cfg, to });
const {
app,
@@ -0,0 +1,492 @@
// QQ Bot Markdown formatting declares dialect capabilities and applies shared fallbacks.
import {
type FormatCapabilityProfile,
type MarkdownIR,
markdownToIR,
renderMarkdownIRChunksWithinLimit,
renderMarkdownWithMarkers,
sliceMarkdownIR,
} from "openclaw/plugin-sdk/text-chunking";
const QQBOT_MARKDOWN_SAFE_CHUNK_BYTE_LIMIT = 3600;
const QQBOT_MARKDOWN_ESCAPE_RE = /([\\`*_{}[\]()#+\-.!|>~])/gu;
const ESCAPED_MARKDOWN_RE = /\\[\\`*_{}[\]()#+\-.!|>~]/gu;
const MARKDOWN_ENTITY_RE = /&(?:#\d+|#x[\da-f]+|[a-z][a-z\d]+);/giu;
const PROTECTED_TOKEN_RANGES = [
[0xe000, 0xf8ff],
[0x3400, 0x9fff],
[0xac00, 0xd7a3],
] as const;
const PROTECTED_TOKEN_RE = /[\u3400-\u9FFF\uAC00-\uD7A3\uE000-\uF8FF]/gu;
const PROTECTED_IMAGE_OVERHEAD_BYTES = 64;
function resolveQQBotMarkdownChunkLimit(limit: number): number {
return Math.min(limit, QQBOT_MARKDOWN_SAFE_CHUNK_BYTE_LIMIT);
}
function utf8ByteLength(text: string): number {
return Buffer.byteLength(text, "utf8");
}
const QQBOT_FORMAT_CAPABILITIES = {
mechanism: "markdown",
constructs: {
bold: "native",
italic: "native",
underline: "strip",
strikethrough: "native",
spoiler: "strip",
codeInline: "fallback",
codeBlock: "fallback",
codeLanguage: "fallback",
linkLabel: "native",
heading: "native",
bulletList: "native",
orderedList: "native",
taskList: "native",
table: "fallback",
blockquote: "native",
image: "native",
mention: "native",
},
chunk: { limit: QQBOT_MARKDOWN_SAFE_CHUNK_BYTE_LIMIT, unit: "bytes" },
} satisfies FormatCapabilityProfile;
const QQBOT_MARKERS = {
bold: { open: "**", close: "**" },
italic: { open: "*", close: "*" },
strikethrough: { open: "~~", close: "~~" },
heading_1: { open: "# ", close: "" },
heading_2: { open: "## ", close: "" },
heading_3: { open: "### ", close: "" },
heading_4: { open: "#### ", close: "" },
heading_5: { open: "##### ", close: "" },
heading_6: { open: "###### ", close: "" },
} as const;
function createProtectedTokenStore(source: string) {
const normalized = markdownToIR(source, { autolink: false, linkify: false }).text;
const occupied = new Set<string>();
for (const text of [source, normalized]) {
for (const character of text) {
occupied.add(character);
}
}
const values = new Map<string, string>();
const reusable = new Map<string, string>();
let rangeIndex = 0;
let codePoint: number = PROTECTED_TOKEN_RANGES[0][0];
const next = (value: string): string => {
while (rangeIndex < PROTECTED_TOKEN_RANGES.length) {
const range = PROTECTED_TOKEN_RANGES[rangeIndex];
if (!range) {
break;
}
if (codePoint > range[1]) {
rangeIndex += 1;
codePoint = PROTECTED_TOKEN_RANGES[rangeIndex]?.[0] ?? Number.POSITIVE_INFINITY;
continue;
}
const token = String.fromCharCode(codePoint++);
if (!occupied.has(token) && !values.has(token)) {
values.set(token, value);
return token;
}
}
return value;
};
return {
next,
reuse: (value: string) => {
const existing = reusable.get(value);
if (existing) {
return existing;
}
const token = next(value);
reusable.set(value, token);
return token;
},
restore: (text: string) =>
text.replace(PROTECTED_TOKEN_RE, (token) => values.get(token) ?? token),
};
}
function escapeQQMarkdownSyntax(text: string): string {
return text.replace(QQBOT_MARKDOWN_ESCAPE_RE, "\\$1");
}
type TextEdit = { start: number; end: number; text: string };
function rewriteMarkdownIR(ir: MarkdownIR, edits: readonly TextEdit[]): MarkdownIR {
if (edits.length === 0) {
return ir;
}
const ordered = [...edits].toSorted((a, b) => a.start - b.start);
let text = "";
let cursor = 0;
for (const edit of ordered) {
text += ir.text.slice(cursor, edit.start) + edit.text;
cursor = edit.end;
}
text += ir.text.slice(cursor);
const cumulativeDeltas: number[] = [];
let delta = 0;
for (const edit of ordered) {
delta += edit.text.length - (edit.end - edit.start);
cumulativeDeltas.push(delta);
}
const exactEdits = new Map(ordered.map((edit) => [`${edit.start}:${edit.end}`, edit]));
const mapOffset = (offset: number): number => {
let low = 0;
let high = ordered.length;
while (low < high) {
const middle = low + Math.floor((high - low) / 2);
if ((ordered[middle]?.end ?? Number.POSITIVE_INFINITY) <= offset) {
low = middle + 1;
} else {
high = middle;
}
}
return offset + (low > 0 ? (cumulativeDeltas[low - 1] ?? 0) : 0);
};
const mapRange = <T extends { start: number; end: number }>(range: T): T => {
const exact = exactEdits.get(`${range.start}:${range.end}`);
const start = mapOffset(range.start);
return { ...range, start, end: exact ? start + exact.text.length : mapOffset(range.end) };
};
return {
...ir,
text,
styles: ir.styles.map(mapRange),
links: ir.links.map(mapRange),
...(ir.annotations ? { annotations: ir.annotations.map(mapRange) } : {}),
...(ir.listItems
? {
listItems: ir.listItems.map((item) => ({
...item,
...(item.listMarker ? { listMarker: mapRange(item.listMarker) } : {}),
...(item.taskMarker ? { taskMarker: mapRange(item.taskMarker) } : {}),
})),
}
: {}),
};
}
function prefixQQBotBlockquotes(ir: MarkdownIR): MarkdownIR {
const quoteSpans = ir.styles.filter((span) => span.style === "blockquote");
const edits = quoteSpans.flatMap((span) => {
const positions: number[] = [];
for (let index = span.start; index < span.end; index += 1) {
if (ir.text[index] === "\n" && index + 1 < span.end) {
positions.push(index + 1);
}
}
return positions.map((position) => ({ start: position, end: position, text: "> " }));
});
return rewriteMarkdownIR(ir, edits);
}
function escapeQQFallbackCode(
ir: MarkdownIR,
protectEscape: (escaped: string) => string,
): MarkdownIR {
return rewriteMarkdownIR(
ir,
ir.styles
.filter((span) => span.style === "code" || span.style === "code_block")
.map((span) => ({
start: span.start,
end: span.end,
text: ir.text
.slice(span.start, span.end)
.replace(QQBOT_MARKDOWN_ESCAPE_RE, (char) => protectEscape(`\\${char}`)),
})),
);
}
function specializeProtectedTokensInCode(
ir: MarkdownIR,
tokens: readonly string[],
protectedTokens: ReturnType<typeof createProtectedTokenStore>,
): MarkdownIR {
const codeStyles = ir.styles.filter(
(span) => span.style === "code" || span.style === "code_block",
);
const edits: TextEdit[] = [];
const protectedSet = new Set(tokens);
for (let start = 0; start < ir.text.length; start += 1) {
const token = ir.text[start] ?? "";
if (
protectedSet.has(token) &&
codeStyles.some((span) => start >= span.start && start + token.length <= span.end)
) {
const escaped = escapeQQMarkdownSyntax(protectedTokens.restore(token));
edits.push({ start, end: start + token.length, text: protectedTokens.reuse(escaped) });
}
}
return rewriteMarkdownIR(ir, edits);
}
type ImageCandidateScan = { end: number } | { next: number } | undefined;
function blankBlockEnd(text: string, index: number): number | undefined {
const match = /^(?:\r?\n)[ \t]*(?:\r?\n)/u.exec(text.slice(index));
return match ? index + match[0].length : undefined;
}
function scanQQBotMarkdownImage(text: string, start: number): ImageCandidateScan {
let bracketDepth = 1;
let altEnd: number | undefined;
let fallbackNext: number | undefined;
for (let index = start + 2; index < text.length; index += 1) {
const blankEnd = blankBlockEnd(text, index);
if (blankEnd !== undefined) {
return { next: fallbackNext ?? blankEnd };
}
if (text[index] === "\\") {
index += 1;
} else if (text.startsWith("![", index)) {
fallbackNext = index;
bracketDepth += 1;
index += 1;
} else if (text[index] === "[") {
bracketDepth += 1;
} else if (text[index] === "]" && --bracketDepth === 0) {
altEnd = index;
break;
}
}
if (altEnd === undefined || text[altEnd + 1] !== "(") {
const next = fallbackNext ?? text.indexOf("![", altEnd === undefined ? start + 2 : altEnd + 1);
return next < 0 ? undefined : { next };
}
let parenDepth = 1;
for (let index = altEnd + 2; index < text.length; index += 1) {
const blankEnd = blankBlockEnd(text, index);
if (blankEnd !== undefined) {
return { next: fallbackNext ?? blankEnd };
}
if (text[index] === "\\") {
index += 1;
} else if (text.startsWith("![", index)) {
fallbackNext = index;
} else if (text[index] === "(") {
parenDepth += 1;
} else if (text[index] === ")" && --parenDepth === 0) {
return { end: index + 1 };
}
}
return fallbackNext === undefined ? undefined : { next: fallbackNext };
}
function protectQQBotMarkdownImages(
text: string,
createToken: (image: string) => string,
byteLimit: number,
): { text: string; tokens: string[] } {
let protectedText = "";
let cursor = 0;
let searchFrom = 0;
const tokens: string[] = [];
while (searchFrom < text.length) {
const start = text.indexOf("![", searchFrom);
if (start < 0) {
break;
}
const scan = scanQQBotMarkdownImage(text, start);
if (!scan) {
break;
}
if ("next" in scan) {
searchFrom = scan.next;
continue;
}
let slashStart = start;
while (slashStart > cursor && text[slashStart - 1] === "\\") {
slashStart -= 1;
}
const escaped = (start - slashStart) % 2 === 1;
const image = text.slice(start, scan.end);
const protectedSize = Math.max(
utf8ByteLength(image),
utf8ByteLength(escapeQQMarkdownSyntax(image)),
);
if (protectedSize + PROTECTED_IMAGE_OVERHEAD_BYTES > byteLimit) {
searchFrom = scan.end;
continue;
}
protectedText += text.slice(cursor, escaped ? start - 1 : start);
const token = createToken(escaped ? `\\${image}` : image);
tokens.push(token);
protectedText += token;
cursor = scan.end;
searchFrom = scan.end;
}
return { text: protectedText + text.slice(cursor), tokens };
}
function serializeMarkdownDestination(href: string): string {
return `<${href.replace(/([\\<>])/gu, "\\$1")}>`;
}
function fallbackOversizedQQLinks(
ir: MarkdownIR,
byteLimit: number,
render: (ir: MarkdownIR) => string,
protectEscape: (escaped: string) => string,
): MarkdownIR {
const oversizedIndexes = new Set<number>();
for (const [index, link] of ir.links.entries()) {
const rendered = render(sliceMarkdownIR(ir, link.start, link.end));
if (utf8ByteLength(rendered) > byteLimit) {
oversizedIndexes.add(index);
}
}
if (oversizedIndexes.size === 0) {
return ir;
}
const oversized = ir.links.filter((_link, index) => oversizedIndexes.has(index));
const rewritten = rewriteMarkdownIR(
ir,
oversized.map((link) => ({
start: link.end,
end: link.end,
text: ` (${link.href.replace(QQBOT_MARKDOWN_ESCAPE_RE, (char) => protectEscape(`\\${char}`))})`,
})),
);
return {
...rewritten,
links: rewritten.links.filter((_link, index) => !oversizedIndexes.has(index)),
};
}
function fallbackOversizedProtectedImages(
ir: MarkdownIR,
byteLimit: number,
render: (ir: MarkdownIR) => string,
protectedTokens: ReturnType<typeof createProtectedTokenStore>,
): MarkdownIR {
const edits: TextEdit[] = [];
for (let start = 0; start < ir.text.length; start += 1) {
const token = ir.text[start] ?? "";
const protectedValue = protectedTokens.restore(token);
const escapedLiteral = protectedValue.startsWith("\\![");
const unescaped = protectedValue.replace(/\\(.)/gu, "$1");
if (
/^!?\[[\s\S]*\]\([\s\S]*\)$/u.test(unescaped) &&
utf8ByteLength(render(sliceMarkdownIR(ir, start, start + token.length))) > byteLimit
) {
if (escapedLiteral) {
const literal = protectedValue.replace(QQBOT_MARKDOWN_ESCAPE_RE, (char) =>
protectedTokens.reuse(`\\${char}`),
);
edits.push({ start, end: start + token.length, text: literal });
continue;
}
const altStart = protectedValue.startsWith("![") ? 2 : 1;
let depth = 1;
let altEnd = altStart;
let alt = "";
for (; altEnd < protectedValue.length; altEnd += 1) {
if (protectedValue[altEnd] === "\\" && protectedValue[altEnd + 1]) {
alt += protectedValue[++altEnd];
} else if (protectedValue[altEnd] === "[") {
depth += 1;
alt += "[";
} else if (protectedValue[altEnd] === "]" && --depth === 0) {
break;
} else {
alt += protectedValue[altEnd] ?? "";
}
}
edits.push({ start, end: start + token.length, text: alt });
}
}
return rewriteMarkdownIR(ir, edits);
}
export function formatQQBotMarkdown(markdown: string, limit: number): string[] {
const protectedTokens = createProtectedTokenStore(markdown);
const chunkLimit = resolveQQBotMarkdownChunkLimit(limit);
const images = protectQQBotMarkdownImages(markdown, protectedTokens.reuse, chunkLimit);
const entityTokens: string[] = [];
const entitiesProtected = images.text.replace(MARKDOWN_ENTITY_RE, (entity) => {
const protectedSize = Math.max(
utf8ByteLength(entity),
utf8ByteLength(escapeQQMarkdownSyntax(entity)),
);
if (protectedSize + PROTECTED_IMAGE_OVERHEAD_BYTES > chunkLimit) {
return entity;
}
const token = protectedTokens.reuse(entity);
entityTokens.push(token);
return token;
});
const escapeTokens: string[] = [];
const protectedMarkdown = entitiesProtected.replace(ESCAPED_MARKDOWN_RE, (escaped) => {
const token = protectedTokens.reuse(escaped);
escapeTokens.push(token);
return token;
});
const parsed = markdownToIR(protectedMarkdown, {
autolink: false,
enableSpoilers: true,
enableTaskLists: true,
headingStyle: "rich",
linkify: false,
blockquotePrefix: "",
});
const specialized = specializeProtectedTokensInCode(
specializeProtectedTokensInCode(parsed, images.tokens, protectedTokens),
[...escapeTokens, ...entityTokens],
protectedTokens,
);
const renderChunk = (chunk: MarkdownIR): string =>
protectedTokens.restore(
renderMarkdownWithMarkers(
chunk,
{
styleMarkers: {
...QQBOT_MARKERS,
blockquote: {
open: (span: { start: number }) =>
chunk.text.slice(span.start, span.start + 2) === "> " ? "" : "> ",
close: "",
},
},
escapeText: (text) => text,
buildLink: (link) => ({
start: link.start,
end: link.end,
open: "[",
close: `](${serializeMarkdownDestination(link.href)})`,
}),
},
QQBOT_FORMAT_CAPABILITIES,
),
);
const formatted = prefixQQBotBlockquotes(
escapeQQFallbackCode(specialized, protectedTokens.reuse),
);
const imagesSized = fallbackOversizedProtectedImages(
formatted,
chunkLimit,
renderChunk,
protectedTokens,
);
const ir = fallbackOversizedQQLinks(imagesSized, chunkLimit, renderChunk, protectedTokens.reuse);
const chunks = renderMarkdownIRChunksWithinLimit({
ir,
limit: chunkLimit,
measureRendered: utf8ByteLength,
renderChunk,
}).map((chunk) => chunk.rendered);
const last = chunks.length - 1;
if (last >= 0) {
chunks[last] = chunks[last]?.trimEnd() ?? "";
}
return chunks;
}
@@ -6,6 +6,194 @@ const baseChunker = (text: string, limit: number): string[] =>
text.length <= limit ? [text] : [text.slice(0, limit), text.slice(limit)];
describe("chunkQQBotMarkdownText", () => {
it("falls unsupported inline code back to plain text", () => {
expect(chunkQQBotMarkdownText("Run `openclaw status` now.", 120, baseChunker)).toEqual([
"Run openclaw status now.",
]);
});
it("preserves transport-owned markdown images beside fallback code", () => {
const image = "![chart #800px #600px](https://example.com/chart.png)";
expect(chunkQQBotMarkdownText(`Run \`status\`.\n\n${image}`, 200, baseChunker)).toEqual([
`Run status.\n\n${image}`,
]);
});
it("preserves transport-owned image URLs with balanced parentheses", () => {
const image = "![plot](https://example.com/chart_(final).png)";
expect(chunkQQBotMarkdownText(`Run \`status\`.\n\n${image}`, 200, baseChunker)).toEqual([
`Run status.\n\n${image}`,
]);
});
it("preserves images containing nested opener text", () => {
const image = "![plot](https://example.com/a![b].png)";
expect(chunkQQBotMarkdownText(image, 200, baseChunker)).toEqual([image]);
});
it("keeps BMP protected image tokens atomic at the chunk boundary", () => {
const image = "![x](https://example.com/x.png)";
const output = chunkQQBotMarkdownText(`${"A".repeat(3_597)}${image}`, 3_600, baseChunker);
expect(output.join("")).toBe(`${"A".repeat(3_597)}${image}`);
expect(output.every((chunk) => !chunk.includes(""))).toBe(true);
});
it("keeps escaped images atomic at the chunk boundary", () => {
const image = String.raw`\![x](https://example.com/x.png)`;
const chunks = chunkQQBotMarkdownText(`${"A".repeat(3_599)}${image}`, 3_600, baseChunker);
expect(chunks.join("")).toBe(`${"A".repeat(3_599)}${image}`);
expect(chunks.some((chunk) => chunk.startsWith("!["))).toBe(false);
});
it("falls protected images back when final quote context exceeds the limit", () => {
const image = `![x](https://example.com/${"a".repeat(3_400)}.png)`;
const chunks = chunkQQBotMarkdownText(`${"> ".repeat(40)}${image}`, 3_600, baseChunker);
expect(chunks.every((chunk) => Buffer.byteLength(chunk, "utf8") <= 3_600)).toBe(true);
expect(chunks.join("")).toContain("![x]");
});
it("does not hide later code behind malformed images", () => {
const output = chunkQQBotMarkdownText("![x](bad\n\n`code`\n)", 200, baseChunker).join("");
expect(output).toContain("code");
expect(output).not.toContain("`code`");
});
it("does not let nested images complete malformed outer candidates", () => {
const output = chunkQQBotMarkdownText(
"![broken `code` ![x](https://e.test/x.png)",
200,
baseChunker,
).join("");
expect(output).not.toContain("`code`");
expect(output).toContain("![x](https://e.test/x.png)");
});
it("continues image protection after many malformed candidates", () => {
const image = "![plot](https://example.com/chart.png)";
const chunks = chunkQQBotMarkdownText(`${"![".repeat(81)}${image}`, 500, baseChunker);
expect(chunks.join("")).toContain(image);
});
it("does not restore forged protected tokens decoded from character references", () => {
const image = "![x](https://example.com/x.png)";
const output = chunkQQBotMarkdownText(`&#xF0000; ${image}`, 200, baseChunker).join("");
expect(output.startsWith("&#xF0000; ")).toBe(true);
expect(output.match(/!\[x\]/gu)).toHaveLength(1);
});
it("preserves entity-encoded markdown literals", () => {
const source = "&#42;&#42;literal&#42;&#42;";
expect(chunkQQBotMarkdownText(source, 200, baseChunker)).toEqual([source]);
});
it("restores entities nested inside protected images", () => {
const image = "![x](https://e.test/a?x=1&amp;y=2)";
expect(chunkQQBotMarkdownText(image, 200, baseChunker)).toEqual([image]);
});
it("keeps oversized entities chunkable", () => {
const source = `&#${"1".repeat(300)};`;
const chunks = chunkQQBotMarkdownText(source, 100, baseChunker);
expect(chunks.every((chunk) => Buffer.byteLength(chunk, "utf8") <= 100)).toBe(true);
});
it("falls oversized images back to chunkable plain content", () => {
const image = `![x](https://example.com/${"a".repeat(4_000)}.png)`;
const chunks = chunkQQBotMarkdownText(image, 3_600, baseChunker);
expect(chunks.every((chunk) => Buffer.byteLength(chunk, "utf8") <= 3_600)).toBe(true);
expect(chunks.join("")).toBe("x");
});
it("falls oversized links back to chunkable plain content", () => {
const href = `https://example.com/${"a".repeat(4_000)}`;
const escapedHref = href.replaceAll(".", "\\.");
const chunks = chunkQQBotMarkdownText(`[x](${href})`, 3_600, baseChunker);
expect(chunks.every((chunk) => Buffer.byteLength(chunk, "utf8") <= 3_600)).toBe(true);
expect(chunks.join("")).toBe(`x (${escapedHref})`);
});
it("removes only the oversized occurrence when link destinations repeat", () => {
const href = `https://e.co/${"a".repeat(3_575)}`;
const escapedHref = href.replaceAll(".", "\\.");
const source = `[x](${href})\n[${"long".repeat(8)}](${href})`;
const output = chunkQQBotMarkdownText(source, 3_600, baseChunker).join("");
expect(output).toContain(`[x](<${href}>)`);
expect(output).toContain(`${"long".repeat(8)} (${escapedHref})`);
});
it("supports more authored escapes than the BMP private-use block", () => {
const source = "\\*".repeat(6_401);
expect(chunkQQBotMarkdownText(source, 3_600, baseChunker).join("")).toBe(source);
});
it("escapes markdown-looking inline code after removing code markers", () => {
expect(chunkQQBotMarkdownText("`![x](https://example.com/x.png)`", 200, baseChunker)).toEqual([
String.raw`\!\[x\]\(https://example\.com/x\.png\)`,
]);
});
it("matches equal-length inline delimiters around shorter backtick runs", () => {
expect(
chunkQQBotMarkdownText("``a `![x](https://example.com/x.png)` b``", 200, baseChunker),
).toEqual([String.raw`a \`\!\[x\]\(https://example\.com/x\.png\)\` b`]);
});
it("preserves escaped literal backticks", () => {
expect(chunkQQBotMarkdownText(String.raw`\`literal\``, 200, baseChunker)).toEqual([
String.raw`\`literal\``,
]);
});
it("re-escapes protected backslashes inside fallback code", () => {
expect(chunkQQBotMarkdownText("`\\*`", 200, baseChunker)).toEqual([String.raw`\\\*`]);
});
it("serializes link destinations with angle brackets", () => {
expect(chunkQQBotMarkdownText("[x](https://host/a)", 200, baseChunker)).toEqual([
"[x](<https://host/a>)",
]);
});
it("keeps every paragraph inside a blockquote", () => {
expect(chunkQQBotMarkdownText("> one\n>\n> two", 200, baseChunker)).toEqual([
"> one\n> \n> two",
]);
});
it("stops blockquote prefixes before following text", () => {
expect(chunkQQBotMarkdownText("> quoted\n\noutside", 200, baseChunker)).toEqual([
"> quoted\n\noutside",
]);
});
it("prefixes every chunk of a long blockquote", () => {
const chunks = chunkQQBotMarkdownText(`> ${"a".repeat(5_000)}`, 200, baseChunker);
expect(chunks.length).toBeGreaterThan(1);
expect(chunks.every((chunk) => chunk.startsWith("> "))).toBe(true);
});
it("does not duplicate blockquote prefixes at continuation boundaries", () => {
const chunks = chunkQQBotMarkdownText(`> ${"a".repeat(3_597)}\n> second`, 3_600, baseChunker);
expect(chunks.some((chunk) => chunk.startsWith("> > "))).toBe(false);
expect(chunks.join("")).toContain("> second");
});
it("keeps fallback code lines inside a blockquote", () => {
expect(chunkQQBotMarkdownText("> ```\n> one\n> two\n> ```", 200, baseChunker)).toEqual([
"> one\n> two",
]);
});
it("does not linkify plain filenames", () => {
expect(chunkQQBotMarkdownText("See README.md", 200, baseChunker)).toEqual(["See README.md"]);
});
it("keeps nested list indentation out of code fallback", () => {
expect(chunkQQBotMarkdownText("- parent\n - child", 200, baseChunker)).toEqual([
"• parent\n • child",
]);
});
it("prefixes continuation chunks with the active table header", () => {
const text = [
"| Id | Value |",
@@ -162,12 +350,39 @@ describe("chunkQQBotMarkdownText", () => {
expect(chunker.flushPendingText(160)).toEqual(["5 reportbuilder.ts generatemonthly_sales"]);
});
it("keeps fenced code blocks self-contained across streaming block flushes", () => {
it("falls fenced code blocks back to plain text across streaming block flushes", () => {
const chunker = createQQBotMarkdownChunker((text) => [text]);
expect(chunker.chunkText(["```ts", "const a = 1;"].join("\n"), 200)).toEqual([]);
expect(chunker.chunkText(["const b = 2;", "```"].join("\n"), 200)).toEqual([
["```ts", "const a = 1;", "const b = 2;", "```"].join("\n"),
["const a = 1;", "const b = 2;"].join("\n"),
]);
});
it("keeps streamed template-literal backticks as escaped plain text", () => {
const chunker = createQQBotMarkdownChunker((text) => [text]);
expect(chunker.chunkText(["```ts", "const value = `hello`;"].join("\n"), 200)).toEqual([]);
expect(chunker.chunkText("```", 200)).toEqual([String.raw`const value = \`hello\`;`]);
});
it("keeps markdown-looking streamed fence bodies in code fallback", () => {
const chunker = createQQBotMarkdownChunker((text) => [text]);
expect(chunker.chunkText(["```", "**literal**"].join("\n"), 200)).toEqual([]);
expect(chunker.chunkText("```", 200)).toEqual([String.raw`\*\*literal\*\*`]);
});
it("handles longer fences containing shorter fence examples", () => {
const markdown = ["````md", "```", "inside", "```", "```` "].join("\n");
expect(chunkQQBotMarkdownText(markdown, 200, baseChunker)).toEqual([
[String.raw`\`\`\``, "inside", String.raw`\`\`\``].join("\n"),
]);
});
it("escapes markdown-looking indented code after fallback", () => {
const markdown = [" **literal**", " ![x](https://example.com/x.png)"].join("\n");
expect(chunkQQBotMarkdownText(markdown, 200, baseChunker)).toEqual([
[String.raw`\*\*literal\*\*`, String.raw`\!\[x\]\(https://example\.com/x\.png\)`].join("\n"),
]);
});
@@ -180,13 +395,14 @@ describe("chunkQQBotMarkdownText", () => {
expect(
chunker.chunkText(["0", " def get_dsn(self) -> str:", "```"].join("\n"), 200),
).toEqual([
["```python", " pool_timeout: float = 30.0", " def get_dsn(self) -> str:", "```"].join(
"\n",
),
[
String.raw` pool\_timeout: float = 30\.0`,
String.raw` def get\_dsn\(self\) \-\> str:`,
].join("\n"),
]);
});
it("keeps long fenced chunks under the QQ markdown byte safety limit", () => {
it("keeps long fallback code chunks under the QQ markdown byte safety limit", () => {
const lines = Array.from(
{ length: 90 },
(_, index) =>
@@ -198,11 +414,26 @@ describe("chunkQQBotMarkdownText", () => {
expect(chunks.length).toBeGreaterThan(1);
for (const chunk of chunks) {
expect(Buffer.byteLength(chunk, "utf8")).toBeLessThanOrEqual(3600);
expect(chunk.startsWith("```python\n")).toBe(true);
expect(chunk.endsWith("\n```")).toBe(true);
expect(chunk).not.toContain("```");
}
});
it("does not split generated markdown escape pairs across byte chunks", () => {
const chunks = chunkQQBotMarkdownText(
["```", "*".repeat(5_000), "```"].join("\n"),
3_600,
baseChunker,
);
expect(chunks.join("")).toBe("\\*".repeat(5_000));
expect(chunks.every((chunk) => !/(^|[^\\])(?:\\\\)*\\$/u.test(chunk))).toBe(true);
});
it("keeps generated markdown escape pairs atomic at odd byte limits", () => {
const chunks = chunkQQBotMarkdownText(["```", "***", "```"].join("\n"), 3, baseChunker);
expect(chunks.join("")).toBe("\\*".repeat(3));
expect(chunks.every((chunk) => !chunk.endsWith("\\"))).toBe(true);
});
it("allows ASCII fenced chunks past the old 1800 character fallback", () => {
const lines = Array.from(
{ length: 90 },
@@ -214,17 +445,16 @@ describe("chunkQQBotMarkdownText", () => {
expect(chunks.some((chunk) => chunk.length > 1800)).toBe(true);
for (const chunk of chunks) {
expect(Buffer.byteLength(chunk, "utf8")).toBeLessThanOrEqual(3600);
expect(chunk.startsWith("```python\n")).toBe(true);
expect(chunk.endsWith("\n```")).toBe(true);
expect(chunk).not.toContain("```");
}
});
it("keeps fenced formula blocks self-contained across streaming block flushes", () => {
it("falls fenced formula blocks back to plain text across streaming block flushes", () => {
const chunker = createQQBotMarkdownChunker((text) => [text]);
expect(chunker.chunkText(["```math", "E = mc^2"].join("\n"), 200)).toEqual([]);
expect(chunker.chunkText(["a^2 + b^2 = c^2", "```"].join("\n"), 200)).toEqual([
["```math", "E = mc^2", "a^2 + b^2 = c^2", "```"].join("\n"),
["E = mc^2", String.raw`a^2 \+ b^2 = c^2`].join("\n"),
]);
});
@@ -241,10 +471,7 @@ describe("chunkQQBotMarkdownText", () => {
...chunker.flushPendingText(limit),
];
expect(chunks).toEqual([
["```ts", firstLine, "```"].join("\n"),
["```ts", secondLine, "```"].join("\n"),
]);
expect(chunks).toEqual([firstLine, secondLine]);
}
});
@@ -1,5 +1,7 @@
// QQ Bot Markdown chunking keeps each sent message self-contained.
import { formatQQBotMarkdown } from "./markdown-format.js";
type QQBotBaseMarkdownChunker = (text: string, limit: number) => string[];
const QQBOT_MARKDOWN_SAFE_CHUNK_BYTE_LIMIT = 3600;
@@ -233,9 +235,7 @@ class QQBotMarkdownChunkingState {
if (this.textLines.length === 0) {
return;
}
if (this.flushFenceText(chunks, limit)) {
return;
}
const continuedFenceOpenLine = this.activeFence?.openLine;
let text = this.textLines.join("\n");
this.textLines = [];
if (this.pendingTextFenceOpenLine) {
@@ -248,44 +248,23 @@ class QQBotMarkdownChunkingState {
if (!text) {
return;
}
pushBaseChunks(chunks, text, limit, this.baseChunker);
}
private flushFenceText(chunks: string[], limit: number): boolean {
const pendingFenceOpenLine = this.pendingTextFenceOpenLine;
const firstLineFence = pendingFenceOpenLine ? null : parseFenceLine(this.textLines[0] ?? "");
const fence = pendingFenceOpenLine ? parseFenceLine(pendingFenceOpenLine) : firstLineFence;
if (!fence) {
return false;
chunks.push(...formatQQBotMarkdown(text, limit));
if (continuedFenceOpenLine) {
this.pendingTextFenceOpenLine = continuedFenceOpenLine;
}
const bodyLines = pendingFenceOpenLine ? [...this.textLines] : this.textLines.slice(1);
this.textLines = [];
this.pendingTextFenceOpenLine = null;
const lastBodyLine = bodyLines.at(-1);
if (lastBodyLine !== undefined && isClosingFenceLine(lastBodyLine, fence)) {
bodyLines.pop();
}
if (this.activeFence && bodyLines.length === 0) {
return true;
}
pushFenceLineChunks({
chunks,
openLine: fence.openLine,
closeLine: fence.closeLine,
bodyLines,
limit,
baseChunker: this.baseChunker,
});
return true;
}
private consumePendingFenceLinePrefix(text: string): string {
if (!this.pendingFenceLineFragment) {
return text;
}
const separator = shouldJoinFenceLineFragments(this.pendingFenceLineFragment, text) ? "" : "\n";
const firstLine = text.split("\n", 1)[0] ?? "";
const startsWithClosingFence =
this.activeFence && isClosingFenceLine(firstLine, this.activeFence);
const separator =
!startsWithClosingFence && shouldJoinFenceLineFragments(this.pendingFenceLineFragment, text)
? ""
: "\n";
const merged = `${this.pendingFenceLineFragment}${separator}${text}`;
this.pendingFenceLineFragment = null;
return merged;
@@ -376,7 +355,24 @@ function pushBaseChunks(
byteLimit: number,
baseChunker: QQBotBaseMarkdownChunker,
): void {
for (const chunk of baseChunker(text, byteLimit)) {
const baseChunks = baseChunker(text, byteLimit).filter(Boolean);
for (let index = 0; index + 1 < baseChunks.length; index += 1) {
const chunk = baseChunks[index] ?? "";
if (!/(^|[^\\])(?:\\\\)*\\$/u.test(chunk)) {
continue;
}
const next = baseChunks[index + 1] ?? "";
const firstCodePoint = next.codePointAt(0);
const first = firstCodePoint === undefined ? "" : String.fromCodePoint(firstCodePoint);
if (first && utf8ByteLength(chunk + first) <= byteLimit) {
baseChunks[index] = chunk + first;
baseChunks[index + 1] = next.slice(first.length);
} else {
baseChunks[index] = chunk.slice(0, -1);
baseChunks[index + 1] = `\\${next}`;
}
}
for (const chunk of baseChunks) {
if (!chunk) {
continue;
}
@@ -395,15 +391,20 @@ function splitByUtf8ByteLimit(text: string, byteLimit: number): string[] {
const chunks: string[] = [];
let current = "";
let currentBytes = 0;
for (const char of text) {
const charBytes = utf8ByteLength(char);
if (current && currentBytes + charBytes > byteLimit) {
const chars = Array.from(text);
for (let index = 0; index < chars.length; index += 1) {
const char = chars[index] ?? "";
const escapedUnit = char === "\\" && chars[index + 1] ? `${char}${chars[index + 1]}` : "";
const unit =
escapedUnit && utf8ByteLength(escapedUnit) <= byteLimit ? `${char}${chars[++index]}` : char;
const unitBytes = utf8ByteLength(unit);
if (current && currentBytes + unitBytes > byteLimit) {
chunks.push(current);
current = "";
currentBytes = 0;
}
current += char;
currentBytes += charBytes;
current += unit;
currentBytes += unitBytes;
}
if (current) {
chunks.push(current);
@@ -559,45 +560,6 @@ function renderTableRowAsFields(headers: string[], cells: string[]): string {
.join("\n");
}
function pushFenceLineChunks(params: {
chunks: string[];
openLine: string;
closeLine: string;
bodyLines: string[];
limit: number;
baseChunker: QQBotBaseMarkdownChunker;
}): void {
const { chunks, openLine, closeLine, bodyLines, limit, baseChunker } = params;
let currentLines: string[] = [];
const render = (lines: string[]) => [openLine, ...lines, closeLine].join("\n");
const flushCurrent = (): void => {
if (currentLines.length === 0) {
return;
}
chunks.push(render(currentLines));
currentLines = [];
};
for (const line of bodyLines) {
const candidate = [...currentLines, line];
if (utf8ByteLength(render(candidate)) <= limit) {
currentLines = candidate;
continue;
}
flushCurrent();
const singleLineChunk = render([line]);
if (utf8ByteLength(singleLineChunk) <= limit) {
currentLines = [line];
continue;
}
pushBaseChunks(chunks, singleLineChunk, limit, baseChunker);
}
if (currentLines.length > 0 || bodyLines.length === 0) {
chunks.push(render(currentLines));
}
}
function parseFenceLine(line: string): ActiveFence | null {
const match = line.match(/^(\s*)(`{3,}|~{3,})/);
if (!match?.[2]) {