fix(zalouser): honor chunk limits and CRLF fences

This commit is contained in:
Tuyen
2026-03-12 12:28:00 +07:00
parent 23641edb2b
commit 401d8e9c0e
9 changed files with 76 additions and 3 deletions

View File

@@ -43,6 +43,7 @@ describe("zalouserPlugin outbound sendPayload", () => {
channel: {
text: {
resolveChunkMode: vi.fn(() => "length"),
resolveTextChunkLimit: vi.fn(() => 1200),
},
},
} as never);
@@ -113,7 +114,12 @@ describe("zalouserPlugin outbound sendPayload", () => {
expect(mockedSend).toHaveBeenCalledWith(
"987654321",
text,
expect.objectContaining({ isGroup: false, textMode: "markdown", textChunkMode: "length" }),
expect.objectContaining({
isGroup: false,
textMode: "markdown",
textChunkMode: "length",
textChunkLimit: 1200,
}),
);
expect(result).toMatchObject({ channel: "zalouser", messageId: "zlu-code" });
});

View File

@@ -170,6 +170,12 @@ function resolveZalouserOutboundChunkMode(cfg: OpenClawConfig, accountId?: strin
return getZalouserRuntime().channel.text.resolveChunkMode(cfg, "zalouser", accountId);
}
function resolveZalouserOutboundTextChunkLimit(cfg: OpenClawConfig, accountId?: string) {
return getZalouserRuntime().channel.text.resolveTextChunkLimit(cfg, "zalouser", accountId, {
fallbackLimit: zalouserDock.outbound?.textChunkLimit ?? 2000,
});
}
function mapUser(params: {
id: string;
name?: string | null;
@@ -614,6 +620,7 @@ export const zalouserPlugin: ChannelPlugin<ResolvedZalouserAccount> = {
isGroup: target.isGroup,
textMode: "markdown",
textChunkMode: resolveZalouserOutboundChunkMode(cfg, account.accountId),
textChunkLimit: resolveZalouserOutboundTextChunkLimit(cfg, account.accountId),
});
return buildChannelSendResult("zalouser", result);
},
@@ -627,6 +634,7 @@ export const zalouserPlugin: ChannelPlugin<ResolvedZalouserAccount> = {
mediaLocalRoots,
textMode: "markdown",
textChunkMode: resolveZalouserOutboundChunkMode(cfg, account.accountId),
textChunkLimit: resolveZalouserOutboundTextChunkLimit(cfg, account.accountId),
});
return buildChannelSendResult("zalouser", result);
},

View File

@@ -171,6 +171,7 @@ function installRuntime(params: {
resolveMarkdownTableMode: vi.fn(() => "code"),
convertMarkdownTables: vi.fn((text: string) => text),
resolveChunkMode: vi.fn(() => "length"),
resolveTextChunkLimit: vi.fn(() => 1200),
chunkMarkdownTextWithMode: vi.fn((text: string) => [text]),
},
},
@@ -339,6 +340,7 @@ describe("zalouser monitor group mention gating", () => {
profile: "default",
textMode: "markdown",
textChunkMode: "length",
textChunkLimit: 1200,
}),
);
});

View File

@@ -63,6 +63,8 @@ export type ZalouserMonitorResult = {
stop: () => void;
};
const ZALOUSER_TEXT_LIMIT = 2000;
function normalizeZalouserEntry(entry: string): string {
return entry.replace(/^(zalouser|zlu):/i, "").trim();
}
@@ -702,6 +704,9 @@ async function deliverZalouserReply(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 textChunkLimit = core.channel.text.resolveTextChunkLimit(config, "zalouser", accountId, {
fallbackLimit: ZALOUSER_TEXT_LIMIT,
});
const sentMedia = await sendMediaWithLeadingCaption({
mediaUrls: resolveOutboundMediaUrls(payload),
@@ -714,6 +719,7 @@ async function deliverZalouserReply(params: {
isGroup,
textMode: "markdown",
textChunkMode: chunkMode,
textChunkLimit,
});
statusSink?.({ lastOutboundAt: Date.now() });
},
@@ -732,6 +738,7 @@ async function deliverZalouserReply(params: {
isGroup,
textMode: "markdown",
textChunkMode: chunkMode,
textChunkLimit,
});
statusSink?.({ lastOutboundAt: Date.now() });
} catch (err) {

View File

@@ -233,6 +233,47 @@ describe("zalouser send helpers", () => {
expect(result).toEqual({ ok: true, messageId: "mid-2d-4" });
});
it("respects an explicit text chunk limit when splitting formatted markdown", async () => {
const text = `**${"a".repeat(1501)}**`;
mockSendText
.mockResolvedValueOnce({ ok: true, messageId: "mid-2d-5" })
.mockResolvedValueOnce({ ok: true, messageId: "mid-2d-6" });
const result = await sendMessageZalouser("thread-2d-3", text, {
profile: "p2d-3",
isGroup: false,
textMode: "markdown",
textChunkLimit: 1200,
} as never);
expect(mockSendText).toHaveBeenCalledTimes(2);
expect(mockSendText).toHaveBeenNthCalledWith(
1,
"thread-2d-3",
"a".repeat(1200),
expect.objectContaining({
profile: "p2d-3",
isGroup: false,
textMode: "markdown",
textChunkLimit: 1200,
textStyles: [{ start: 0, len: 1200, st: TextStyle.Bold }],
}),
);
expect(mockSendText).toHaveBeenNthCalledWith(
2,
"thread-2d-3",
"a".repeat(301),
expect.objectContaining({
profile: "p2d-3",
isGroup: false,
textMode: "markdown",
textChunkLimit: 1200,
textStyles: [{ start: 0, len: 301, st: TextStyle.Bold }],
}),
);
expect(result).toEqual({ ok: true, messageId: "mid-2d-6" });
});
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

@@ -32,10 +32,11 @@ export async function sendMessageZalouser(
options.textMode === "markdown"
? parseZalouserTextStyles(text)
: { text, styles: options.textStyles };
const textChunkLimit = options.textChunkLimit ?? ZALO_TEXT_LIMIT;
const chunks = splitStyledText(
prepared.text,
(prepared.styles?.length ?? 0) > 0 ? prepared.styles : undefined,
ZALO_TEXT_LIMIT,
textChunkLimit,
options.textChunkMode,
);

View File

@@ -28,6 +28,13 @@ describe("parseZalouserTextStyles", () => {
});
});
it("closes fenced code blocks when the input uses CRLF newlines", () => {
expect(parseZalouserTextStyles("```\r\n*code*\r\n```\r\n**after**")).toEqual({
text: "*code*\nafter",
styles: [{ start: 7, len: 5, st: TextStyle.Bold }],
});
});
it("maps headings, block quotes, and lists into line styles", () => {
expect(parseZalouserTextStyles(["# Title", "> quoted", " - nested"].join("\n"))).toEqual({
text: "Title\nquoted\nnested",

View File

@@ -103,7 +103,7 @@ export function parseZalouserTextStyles(input: string): { text: string; styles:
const allStyles: Style[] = [];
const escapeMap: string[] = [];
const lines = input.split("\n");
const lines = input.replace(/\r\n?/g, "\n").split("\n");
const lineStyles: LineStyle[] = [];
const processedLines: string[] = [];
let activeFence: ActiveFence | null = null;

View File

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