diff --git a/extensions/zalouser/src/channel.sendpayload.test.ts b/extensions/zalouser/src/channel.sendpayload.test.ts index bdfab4666a2..ac0664d19fb 100644 --- a/extensions/zalouser/src/channel.sendpayload.test.ts +++ b/extensions/zalouser/src/channel.sendpayload.test.ts @@ -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 { diff --git a/extensions/zalouser/src/channel.ts b/extensions/zalouser/src/channel.ts index f539d648644..d9c733b24d8 100644 --- a/extensions/zalouser/src/channel.ts +++ b/extensions/zalouser/src/channel.ts @@ -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 = { }, 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 = { 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 = { mediaUrl, mediaLocalRoots, textMode: "markdown", + textChunkMode: resolveZalouserOutboundChunkMode(cfg, account.accountId), }); return buildChannelSendResult("zalouser", result); }, diff --git a/extensions/zalouser/src/monitor.group-gating.test.ts b/extensions/zalouser/src/monitor.group-gating.test.ts index b3e38efecd6..7570f46f103 100644 --- a/extensions/zalouser/src/monitor.group-gating.test.ts +++ b/extensions/zalouser/src/monitor.group-gating.test.ts @@ -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, diff --git a/extensions/zalouser/src/monitor.ts b/extensions/zalouser/src/monitor.ts index 9516f79d216..a084fe64b0b 100644 --- a/extensions/zalouser/src/monitor.ts +++ b/extensions/zalouser/src/monitor.ts @@ -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)}`); } } } diff --git a/extensions/zalouser/src/send.test.ts b/extensions/zalouser/src/send.test.ts index 406c11b556b..c81590abbef 100644 --- a/extensions/zalouser/src/send.test.ts +++ b/extensions/zalouser/src/send.test.ts @@ -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); diff --git a/extensions/zalouser/src/send.ts b/extensions/zalouser/src/send.ts index fa51dabfa6f..f27ec94c6ac 100644 --- a/extensions/zalouser/src/send.ts +++ b/extensions/zalouser/src/send.ts @@ -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; + 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; +} diff --git a/extensions/zalouser/src/text-styles.test.ts b/extensions/zalouser/src/text-styles.test.ts index b764a88ae2c..fb2b1c8a09a 100644 --- a/extensions/zalouser/src/text-styles.test.ts +++ b/extensions/zalouser/src/text-styles.test.ts @@ -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", diff --git a/extensions/zalouser/src/text-styles.ts b/extensions/zalouser/src/text-styles.ts index d51c915d816..f18464bfc86 100644 --- a/extensions/zalouser/src/text-styles.ts +++ b/extensions/zalouser/src/text-styles.ts @@ -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 = diff --git a/extensions/zalouser/src/types.ts b/extensions/zalouser/src/types.ts index b473de25aca..6b5e2b3471b 100644 --- a/extensions/zalouser/src/types.ts +++ b/extensions/zalouser/src/types.ts @@ -62,6 +62,7 @@ export type ZaloSendOptions = { isGroup?: boolean; mediaLocalRoots?: readonly string[]; textMode?: "markdown" | "plain"; + textChunkMode?: "length" | "newline"; textStyles?: Style[]; };