fix(zalouser): preserve markdown structure across chunks

This commit is contained in:
Tuyen
2026-03-12 12:09:13 +07:00
parent 5c8061b229
commit 23641edb2b
9 changed files with 242 additions and 40 deletions

View File

@@ -1,6 +1,5 @@
import type { ReplyPayload } from "openclaw/plugin-sdk/zalouser";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { chunkMarkdownText } from "../../../src/auto-reply/chunk.js";
import {
installSendPayloadContractSuite,
primeSendMock,
@@ -43,7 +42,7 @@ describe("zalouserPlugin outbound sendPayload", () => {
setZalouserRuntime({
channel: {
text: {
chunkMarkdownText,
resolveChunkMode: vi.fn(() => "length"),
},
},
} as never);
@@ -101,10 +100,8 @@ describe("zalouserPlugin outbound sendPayload", () => {
expect(result).toMatchObject({ channel: "zalouser", messageId: "zlu-g-native" });
});
it("uses markdown-aware chunking for long fenced code payloads", async () => {
const codeLine = "const value = 1234567890;";
const text = `\`\`\`ts\n${Array.from({ length: 140 }, () => codeLine).join("\n")}\n\`\`\``;
const expectedChunks = chunkMarkdownText(text, 2000);
it("passes long markdown through once so formatting happens before chunking", async () => {
const text = `**${"a".repeat(2501)}**`;
mockedSend.mockResolvedValue({ ok: true, messageId: "zlu-code" });
const result = await zalouserPlugin.outbound!.sendPayload!({
@@ -112,13 +109,18 @@ describe("zalouserPlugin outbound sendPayload", () => {
to: "987654321",
});
expect(mockedSend.mock.calls.map((call) => call[1])).toEqual(expectedChunks);
expect(mockedSend).toHaveBeenCalledTimes(1);
expect(mockedSend).toHaveBeenCalledWith(
"987654321",
text,
expect.objectContaining({ isGroup: false, textMode: "markdown", textChunkMode: "length" }),
);
expect(result).toMatchObject({ channel: "zalouser", messageId: "zlu-code" });
});
installSendPayloadContractSuite({
channel: "zalouser",
chunking: { mode: "split", longTextLength: 3000, maxChunkLength: 2000 },
chunking: { mode: "passthrough", longTextLength: 3000 },
createHarness: ({ payload, sendResults }) => {
primeSendMock(mockedSend, { ok: true, messageId: "zlu-1" }, sendResults);
return {

View File

@@ -166,6 +166,10 @@ function resolveZalouserQrProfile(accountId?: string | null): string {
return normalized;
}
function resolveZalouserOutboundChunkMode(cfg: OpenClawConfig, accountId?: string) {
return getZalouserRuntime().channel.text.resolveChunkMode(cfg, "zalouser", accountId);
}
function mapUser(params: {
id: string;
name?: string | null;
@@ -595,14 +599,9 @@ export const zalouserPlugin: ChannelPlugin<ResolvedZalouserAccount> = {
},
outbound: {
deliveryMode: "direct",
chunker: (text, limit) => getZalouserRuntime().channel.text.chunkMarkdownText(text, limit),
chunkerMode: "markdown",
textChunkLimit: 2000,
sendPayload: async (ctx) =>
await sendPayloadWithChunkedTextAndMedia({
ctx,
textChunkLimit: zalouserPlugin.outbound!.textChunkLimit,
chunker: zalouserPlugin.outbound!.chunker,
sendText: (nextCtx) => zalouserPlugin.outbound!.sendText!(nextCtx),
sendMedia: (nextCtx) => zalouserPlugin.outbound!.sendMedia!(nextCtx),
emptyResult: { channel: "zalouser", messageId: "" },
@@ -614,6 +613,7 @@ export const zalouserPlugin: ChannelPlugin<ResolvedZalouserAccount> = {
profile: account.profile,
isGroup: target.isGroup,
textMode: "markdown",
textChunkMode: resolveZalouserOutboundChunkMode(cfg, account.accountId),
});
return buildChannelSendResult("zalouser", result);
},
@@ -626,6 +626,7 @@ export const zalouserPlugin: ChannelPlugin<ResolvedZalouserAccount> = {
mediaUrl,
mediaLocalRoots,
textMode: "markdown",
textChunkMode: resolveZalouserOutboundChunkMode(cfg, account.accountId),
});
return buildChannelSendResult("zalouser", result);
},

View File

@@ -51,6 +51,7 @@ function createRuntimeEnv(): RuntimeEnv {
function installRuntime(params: {
commandAuthorized?: boolean;
replyPayload?: { text?: string; mediaUrl?: string; mediaUrls?: string[] };
resolveCommandAuthorizedFromAuthorizers?: (params: {
useAccessGroups: boolean;
authorizers: Array<{ configured: boolean; allowed: boolean }>;
@@ -58,6 +59,9 @@ function installRuntime(params: {
}) {
const dispatchReplyWithBufferedBlockDispatcher = vi.fn(async ({ dispatcherOptions, ctx }) => {
await dispatcherOptions.typingCallbacks?.onReplyStart?.();
if (params.replyPayload) {
await dispatcherOptions.deliver(params.replyPayload);
}
return { queuedFinal: false, counts: { tool: 0, block: 0, final: 0 }, ctx };
});
const resolveCommandAuthorizedFromAuthorizers = vi.fn(
@@ -166,7 +170,7 @@ function installRuntime(params: {
text: {
resolveMarkdownTableMode: vi.fn(() => "code"),
convertMarkdownTables: vi.fn((text: string) => text),
resolveChunkMode: vi.fn(() => "line"),
resolveChunkMode: vi.fn(() => "length"),
chunkMarkdownTextWithMode: vi.fn((text: string) => [text]),
},
},
@@ -304,6 +308,41 @@ describe("zalouser monitor group mention gating", () => {
expect(callArg?.ctx?.WasMentioned).toBe(true);
});
it("passes long markdown replies through once so formatting happens before chunking", async () => {
const replyText = `**${"a".repeat(2501)}**`;
installRuntime({
commandAuthorized: false,
replyPayload: { text: replyText },
});
await __testing.processMessage({
message: createDmMessage({
content: "hello",
}),
account: {
...createAccount(),
config: {
...createAccount().config,
dmPolicy: "open",
},
},
config: createConfig(),
runtime: createRuntimeEnv(),
});
expect(sendMessageZalouserMock).toHaveBeenCalledTimes(1);
expect(sendMessageZalouserMock).toHaveBeenCalledWith(
"u-1",
replyText,
expect.objectContaining({
isGroup: false,
profile: "default",
textMode: "markdown",
textChunkMode: "length",
}),
);
});
it("uses commandContent for mention-prefixed control commands", async () => {
const { dispatchReplyWithBufferedBlockDispatcher } = installRuntime({
commandAuthorized: true,

View File

@@ -63,8 +63,6 @@ export type ZalouserMonitorResult = {
stop: () => void;
};
const ZALOUSER_TEXT_LIMIT = 2000;
function normalizeZalouserEntry(entry: string): string {
return entry.replace(/^(zalouser|zlu):/i, "").trim();
}
@@ -703,6 +701,7 @@ async function deliverZalouserReply(params: {
params;
const tableMode = params.tableMode ?? "code";
const text = core.channel.text.convertMarkdownTables(payload.text ?? "", tableMode);
const chunkMode = core.channel.text.resolveChunkMode(config, "zalouser", accountId);
const sentMedia = await sendMediaWithLeadingCaption({
mediaUrls: resolveOutboundMediaUrls(payload),
@@ -714,6 +713,7 @@ async function deliverZalouserReply(params: {
mediaUrl,
isGroup,
textMode: "markdown",
textChunkMode: chunkMode,
});
statusSink?.({ lastOutboundAt: Date.now() });
},
@@ -726,24 +726,16 @@ async function deliverZalouserReply(params: {
}
if (text) {
const chunkMode = core.channel.text.resolveChunkMode(config, "zalouser", accountId);
const chunks = core.channel.text.chunkMarkdownTextWithMode(
text,
ZALOUSER_TEXT_LIMIT,
chunkMode,
);
logVerbose(core, runtime, `Sending ${chunks.length} text chunk(s) to ${chatId}`);
for (const chunk of chunks) {
try {
await sendMessageZalouser(chatId, chunk, {
profile,
isGroup,
textMode: "markdown",
});
statusSink?.({ lastOutboundAt: Date.now() });
} catch (err) {
runtime.error(`Zalouser message send failed: ${String(err)}`);
}
try {
await sendMessageZalouser(chatId, text, {
profile,
isGroup,
textMode: "markdown",
textChunkMode: chunkMode,
});
statusSink?.({ lastOutboundAt: Date.now() });
} catch (err) {
runtime.error(`Zalouser message send failed: ${String(err)}`);
}
}
}

View File

@@ -190,6 +190,49 @@ describe("zalouser send helpers", () => {
expect(result).toEqual({ ok: true, messageId: "mid-2d-2" });
});
it("preserves formatted text and styles when newline chunk mode splits after parsing", async () => {
const text = `**${"a".repeat(1995)}**\n\nsecond paragraph`;
const formatted = parseZalouserTextStyles(text);
mockSendText
.mockResolvedValueOnce({ ok: true, messageId: "mid-2d-3" })
.mockResolvedValueOnce({ ok: true, messageId: "mid-2d-4" });
const result = await sendMessageZalouser("thread-2d-2", text, {
profile: "p2d-2",
isGroup: false,
textMode: "markdown",
textChunkMode: "newline",
});
expect(mockSendText).toHaveBeenCalledTimes(2);
expect(mockSendText.mock.calls.map((call) => call[1]).join("")).toBe(formatted.text);
expect(mockSendText).toHaveBeenNthCalledWith(
1,
"thread-2d-2",
`${"a".repeat(1995)}\n\n`,
expect.objectContaining({
profile: "p2d-2",
isGroup: false,
textMode: "markdown",
textChunkMode: "newline",
textStyles: [{ start: 0, len: 1995, st: TextStyle.Bold }],
}),
);
expect(mockSendText).toHaveBeenNthCalledWith(
2,
"thread-2d-2",
"second paragraph",
expect.objectContaining({
profile: "p2d-2",
isGroup: false,
textMode: "markdown",
textChunkMode: "newline",
textStyles: undefined,
}),
);
expect(result).toEqual({ ok: true, messageId: "mid-2d-4" });
});
it("sends overflow markdown captions as follow-up text after the media message", async () => {
const caption = "\t".repeat(500) + "a".repeat(1500);
const formatted = parseZalouserTextStyles(caption);

View File

@@ -14,12 +14,15 @@ export type ZalouserSendOptions = ZaloSendOptions;
export type ZalouserSendResult = ZaloSendResult;
const ZALO_TEXT_LIMIT = 2000;
const DEFAULT_TEXT_CHUNK_MODE = "length";
type StyledTextChunk = {
text: string;
styles?: ZaloSendOptions["textStyles"];
};
type TextChunkMode = NonNullable<ZaloSendOptions["textChunkMode"]>;
export async function sendMessageZalouser(
threadId: string,
text: string,
@@ -33,6 +36,7 @@ export async function sendMessageZalouser(
prepared.text,
(prepared.styles?.length ?? 0) > 0 ? prepared.styles : undefined,
ZALO_TEXT_LIMIT,
options.textChunkMode,
);
let lastResult: ZalouserSendResult | null = null;
@@ -129,14 +133,15 @@ function splitStyledText(
text: string,
styles: ZaloSendOptions["textStyles"],
limit: number,
mode: ZaloSendOptions["textChunkMode"],
): StyledTextChunk[] {
if (text.length === 0) {
return [{ text, styles: undefined }];
}
const chunks: StyledTextChunk[] = [];
for (let start = 0; start < text.length; start += limit) {
const end = Math.min(text.length, start + limit);
for (const range of splitTextRanges(text, limit, mode ?? DEFAULT_TEXT_CHUNK_MODE)) {
const { start, end } = range;
chunks.push({
text: text.slice(start, end),
styles: sliceTextStyles(styles, start, end),
@@ -181,3 +186,86 @@ function sliceTextStyles(
return chunkStyles.length > 0 ? chunkStyles : undefined;
}
function splitTextRanges(
text: string,
limit: number,
mode: TextChunkMode,
): Array<{ start: number; end: number }> {
if (mode === "newline") {
return splitTextRangesByPreferredBreaks(text, limit);
}
const ranges: Array<{ start: number; end: number }> = [];
for (let start = 0; start < text.length; start += limit) {
ranges.push({
start,
end: Math.min(text.length, start + limit),
});
}
return ranges;
}
function splitTextRangesByPreferredBreaks(
text: string,
limit: number,
): Array<{ start: number; end: number }> {
const ranges: Array<{ start: number; end: number }> = [];
let start = 0;
while (start < text.length) {
const maxEnd = Math.min(text.length, start + limit);
let end = maxEnd;
if (maxEnd < text.length) {
end =
findParagraphBreak(text, start, maxEnd) ??
findLastBreak(text, "\n", start, maxEnd) ??
findLastWhitespaceBreak(text, start, maxEnd) ??
maxEnd;
}
if (end <= start) {
end = maxEnd;
}
ranges.push({ start, end });
start = end;
}
return ranges;
}
function findParagraphBreak(text: string, start: number, end: number): number | undefined {
const slice = text.slice(start, end);
const matches = slice.matchAll(/\n[\t ]*\n+/g);
let lastMatch: RegExpMatchArray | undefined;
for (const match of matches) {
lastMatch = match;
}
if (!lastMatch || lastMatch.index === undefined) {
return undefined;
}
return start + lastMatch.index + lastMatch[0].length;
}
function findLastBreak(
text: string,
marker: string,
start: number,
end: number,
): number | undefined {
const index = text.lastIndexOf(marker, end - 1);
if (index < start) {
return undefined;
}
return index + marker.length;
}
function findLastWhitespaceBreak(text: string, start: number, end: number): number | undefined {
for (let index = end - 1; index > start; index -= 1) {
if (/\s/.test(text[index])) {
return index + 1;
}
}
return undefined;
}

View File

@@ -35,12 +35,23 @@ describe("parseZalouserTextStyles", () => {
{ start: 0, len: 5, st: TextStyle.Bold },
{ start: 0, len: 5, st: TextStyle.Big },
{ start: 6, len: 6, st: TextStyle.Indent, indentSize: 1 },
{ start: 13, len: 6, st: TextStyle.Indent, indentSize: 1 },
{ start: 13, len: 6, st: TextStyle.UnorderedList },
],
});
});
it("treats 1-3 leading spaces as markdown padding for headings and lists", () => {
expect(parseZalouserTextStyles(" # Title\n 1. item\n - bullet")).toEqual({
text: "Title\nitem\nbullet",
styles: [
{ start: 0, len: 5, st: TextStyle.Bold },
{ start: 0, len: 5, st: TextStyle.Big },
{ start: 6, len: 4, st: TextStyle.OrderedList },
{ start: 11, len: 6, st: TextStyle.UnorderedList },
],
});
});
it("strips fenced code markers and preserves leading indentation with nbsp", () => {
expect(parseZalouserTextStyles("```ts\n const x = 1\n\treturn x\n```")).toEqual({
text: "\u00A0\u00A0const x = 1\n\u00A0\u00A0\u00A0\u00A0return x",

View File

@@ -156,7 +156,9 @@ export function parseZalouserTextStyles(input: string): { text: string; styles:
continue;
}
const headingMatch = line.match(/^(#{1,4})\s(.*)$/);
const { text: markdownLine, size: markdownPadding } = stripOptionalMarkdownPadding(line);
const headingMatch = markdownLine.match(/^(#{1,4})\s(.*)$/);
if (headingMatch) {
const depth = headingMatch[1].length;
lineStyles.push({ lineIndex: outputLineIndex, style: TextStyle.Bold });
@@ -174,9 +176,9 @@ export function parseZalouserTextStyles(input: string): { text: string; styles:
continue;
}
const indentMatch = line.match(/^(\s+)(.*)$/);
const indentMatch = markdownLine.match(/^(\s+)(.*)$/);
let indentLevel = 0;
let content = line;
let content = markdownLine;
if (indentMatch) {
indentLevel = clampIndent(indentMatch[1].length);
content = indentMatch[2];
@@ -223,6 +225,18 @@ export function parseZalouserTextStyles(input: string): { text: string; styles:
continue;
}
if (markdownPadding > 0) {
if (baseIndent > 0) {
lineStyles.push({
lineIndex: outputLineIndex,
style: TextStyle.Indent,
indentSize: baseIndent,
});
}
processedLines.push(line);
continue;
}
if (totalIndent > 0) {
lineStyles.push({
lineIndex: outputLineIndex,
@@ -312,6 +326,17 @@ function clampIndent(spaceCount: number): number {
return Math.min(5, Math.max(1, Math.floor(spaceCount / 2)));
}
function stripOptionalMarkdownPadding(line: string): { text: string; size: number } {
const match = line.match(/^( {1,3})(?=\S)/);
if (!match) {
return { text: line, size: 0 };
}
return {
text: line.slice(match[1].length),
size: match[1].length,
};
}
function hasClosingFence(lines: string[], startIndex: number, fence: ActiveFence): boolean {
for (let index = startIndex; index < lines.length; index += 1) {
const candidate =

View File

@@ -62,6 +62,7 @@ export type ZaloSendOptions = {
isGroup?: boolean;
mediaLocalRoots?: readonly string[];
textMode?: "markdown" | "plain";
textChunkMode?: "length" | "newline";
textStyles?: Style[];
};