Files
openclaw/extensions/zalouser/src/send.test.ts
darkamenosa a6711afdc2 feat(zalouser): add markdown-to-Zalo text style parsing (#43324)
* feat(zalouser): add markdown-to-Zalo text style parsing

Parse markdown formatting (bold, italic, strikethrough, headings, lists,
code blocks, blockquotes, custom color/style tags) into Zalo native
TextStyle ranges so outbound messages render with rich formatting.

- Add text-styles.ts with parseZalouserTextStyles() converter
- Wire markdown mode into send pipeline (sendMessageZalouser)
- Export TextStyle enum and Style type from zca-client
- Add textMode/textStyles to ZaloSendOptions
- Pass textStyles through sendZaloTextMessage to zca-js API
- Enable textMode:"markdown" in outbound sendText/sendMedia and monitor
- Add comprehensive tests for parsing, send, and channel integration

* fix(zalouser): harden markdown text parsing

* fix(zalouser): mirror zca-js text style types

* fix(zalouser): support tilde fenced code blocks

* fix(zalouser): handle quoted fenced code blocks

* fix(zalouser): preserve literal quote lines in code fences

* fix(zalouser): support indented quoted fences

* fix(zalouser): preserve quoted markdown blocks

* fix(zalouser): rechunk formatted messages

* fix(zalouser): preserve markdown structure across chunks

* fix(zalouser): honor chunk limits and CRLF fences
2026-03-12 16:24:15 +07:00

396 lines
12 KiB
TypeScript

import { beforeEach, describe, expect, it, vi } from "vitest";
import {
sendDeliveredZalouser,
sendImageZalouser,
sendLinkZalouser,
sendMessageZalouser,
sendReactionZalouser,
sendSeenZalouser,
sendTypingZalouser,
} from "./send.js";
import { parseZalouserTextStyles } from "./text-styles.js";
import {
sendZaloDeliveredEvent,
sendZaloLink,
sendZaloReaction,
sendZaloSeenEvent,
sendZaloTextMessage,
sendZaloTypingEvent,
} from "./zalo-js.js";
import { TextStyle } from "./zca-client.js";
vi.mock("./zalo-js.js", () => ({
sendZaloTextMessage: vi.fn(),
sendZaloLink: vi.fn(),
sendZaloTypingEvent: vi.fn(),
sendZaloReaction: vi.fn(),
sendZaloDeliveredEvent: vi.fn(),
sendZaloSeenEvent: vi.fn(),
}));
const mockSendText = vi.mocked(sendZaloTextMessage);
const mockSendLink = vi.mocked(sendZaloLink);
const mockSendTyping = vi.mocked(sendZaloTypingEvent);
const mockSendReaction = vi.mocked(sendZaloReaction);
const mockSendDelivered = vi.mocked(sendZaloDeliveredEvent);
const mockSendSeen = vi.mocked(sendZaloSeenEvent);
describe("zalouser send helpers", () => {
beforeEach(() => {
mockSendText.mockReset();
mockSendLink.mockReset();
mockSendTyping.mockReset();
mockSendReaction.mockReset();
mockSendDelivered.mockReset();
mockSendSeen.mockReset();
});
it("keeps plain text literal by default", async () => {
mockSendText.mockResolvedValueOnce({ ok: true, messageId: "mid-1" });
const result = await sendMessageZalouser("thread-1", "**hello**", {
profile: "default",
isGroup: true,
});
expect(mockSendText).toHaveBeenCalledWith(
"thread-1",
"**hello**",
expect.objectContaining({
profile: "default",
isGroup: true,
}),
);
expect(result).toEqual({ ok: true, messageId: "mid-1" });
});
it("formats markdown text when markdown mode is enabled", async () => {
mockSendText.mockResolvedValueOnce({ ok: true, messageId: "mid-1b" });
await sendMessageZalouser("thread-1", "**hello**", {
profile: "default",
isGroup: true,
textMode: "markdown",
});
expect(mockSendText).toHaveBeenCalledWith(
"thread-1",
"hello",
expect.objectContaining({
profile: "default",
isGroup: true,
textMode: "markdown",
textStyles: [{ start: 0, len: 5, st: TextStyle.Bold }],
}),
);
});
it("formats image captions in markdown mode", async () => {
mockSendText.mockResolvedValueOnce({ ok: true, messageId: "mid-2" });
await sendImageZalouser("thread-2", "https://example.com/a.png", {
profile: "p2",
caption: "_cap_",
isGroup: false,
textMode: "markdown",
});
expect(mockSendText).toHaveBeenCalledWith(
"thread-2",
"cap",
expect.objectContaining({
profile: "p2",
caption: undefined,
isGroup: false,
mediaUrl: "https://example.com/a.png",
textMode: "markdown",
textStyles: [{ start: 0, len: 3, st: TextStyle.Italic }],
}),
);
});
it("does not keep the raw markdown caption as a media fallback after formatting", async () => {
mockSendText.mockResolvedValueOnce({ ok: true, messageId: "mid-2b" });
await sendImageZalouser("thread-2", "https://example.com/a.png", {
profile: "p2",
caption: "```\n```",
isGroup: false,
textMode: "markdown",
});
expect(mockSendText).toHaveBeenCalledWith(
"thread-2",
"",
expect.objectContaining({
profile: "p2",
caption: undefined,
isGroup: false,
mediaUrl: "https://example.com/a.png",
textMode: "markdown",
textStyles: undefined,
}),
);
});
it("rechunks normalized markdown text before sending to avoid transport truncation", async () => {
const text = "\t".repeat(500) + "a".repeat(1500);
const formatted = parseZalouserTextStyles(text);
mockSendText
.mockResolvedValueOnce({ ok: true, messageId: "mid-2c-1" })
.mockResolvedValueOnce({ ok: true, messageId: "mid-2c-2" });
const result = await sendMessageZalouser("thread-2c", text, {
profile: "p2c",
isGroup: false,
textMode: "markdown",
});
expect(formatted.text.length).toBeGreaterThan(2000);
expect(mockSendText).toHaveBeenCalledTimes(2);
expect(mockSendText.mock.calls.map((call) => call[1]).join("")).toBe(formatted.text);
expect(mockSendText.mock.calls.every((call) => (call[1] as string).length <= 2000)).toBe(true);
expect(result).toEqual({ ok: true, messageId: "mid-2c-2" });
});
it("preserves text styles when splitting long formatted markdown", async () => {
const text = `**${"a".repeat(2501)}**`;
mockSendText
.mockResolvedValueOnce({ ok: true, messageId: "mid-2d-1" })
.mockResolvedValueOnce({ ok: true, messageId: "mid-2d-2" });
const result = await sendMessageZalouser("thread-2d", text, {
profile: "p2d",
isGroup: false,
textMode: "markdown",
});
expect(mockSendText).toHaveBeenNthCalledWith(
1,
"thread-2d",
"a".repeat(2000),
expect.objectContaining({
profile: "p2d",
isGroup: false,
textMode: "markdown",
textStyles: [{ start: 0, len: 2000, st: TextStyle.Bold }],
}),
);
expect(mockSendText).toHaveBeenNthCalledWith(
2,
"thread-2d",
"a".repeat(501),
expect.objectContaining({
profile: "p2d",
isGroup: false,
textMode: "markdown",
textStyles: [{ start: 0, len: 501, st: TextStyle.Bold }],
}),
);
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("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);
mockSendText
.mockResolvedValueOnce({ ok: true, messageId: "mid-2e-1" })
.mockResolvedValueOnce({ ok: true, messageId: "mid-2e-2" });
const result = await sendImageZalouser("thread-2e", "https://example.com/long.png", {
profile: "p2e",
caption,
isGroup: false,
textMode: "markdown",
});
expect(mockSendText).toHaveBeenCalledTimes(2);
expect(mockSendText.mock.calls.map((call) => call[1]).join("")).toBe(formatted.text);
expect(mockSendText).toHaveBeenNthCalledWith(
1,
"thread-2e",
expect.any(String),
expect.objectContaining({
profile: "p2e",
caption: undefined,
isGroup: false,
mediaUrl: "https://example.com/long.png",
textMode: "markdown",
}),
);
expect(mockSendText).toHaveBeenNthCalledWith(
2,
"thread-2e",
expect.any(String),
expect.not.objectContaining({
mediaUrl: "https://example.com/long.png",
}),
);
expect(result).toEqual({ ok: true, messageId: "mid-2e-2" });
});
it("delegates link helper to JS transport", async () => {
mockSendLink.mockResolvedValueOnce({ ok: false, error: "boom" });
const result = await sendLinkZalouser("thread-3", "https://openclaw.ai", {
profile: "p3",
isGroup: true,
});
expect(mockSendLink).toHaveBeenCalledWith("thread-3", "https://openclaw.ai", {
profile: "p3",
isGroup: true,
});
expect(result).toEqual({ ok: false, error: "boom" });
});
it("delegates typing helper to JS transport", async () => {
await sendTypingZalouser("thread-4", { profile: "p4", isGroup: true });
expect(mockSendTyping).toHaveBeenCalledWith("thread-4", {
profile: "p4",
isGroup: true,
});
});
it("delegates reaction helper to JS transport", async () => {
mockSendReaction.mockResolvedValueOnce({ ok: true });
const result = await sendReactionZalouser({
threadId: "thread-5",
profile: "p5",
isGroup: true,
msgId: "100",
cliMsgId: "200",
emoji: "👍",
});
expect(mockSendReaction).toHaveBeenCalledWith({
profile: "p5",
threadId: "thread-5",
isGroup: true,
msgId: "100",
cliMsgId: "200",
emoji: "👍",
remove: undefined,
});
expect(result).toEqual({ ok: true, error: undefined });
});
it("delegates delivered+seen helpers to JS transport", async () => {
mockSendDelivered.mockResolvedValueOnce();
mockSendSeen.mockResolvedValueOnce();
const message = {
msgId: "100",
cliMsgId: "200",
uidFrom: "1",
idTo: "2",
msgType: "webchat",
st: 1,
at: 0,
cmd: 0,
ts: "123",
};
await sendDeliveredZalouser({ profile: "p6", isGroup: true, message, isSeen: false });
await sendSeenZalouser({ profile: "p6", isGroup: true, message });
expect(mockSendDelivered).toHaveBeenCalledWith({
profile: "p6",
isGroup: true,
message,
isSeen: false,
});
expect(mockSendSeen).toHaveBeenCalledWith({
profile: "p6",
isGroup: true,
message,
});
});
});