mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-05 13:30:22 +00:00
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
This commit is contained in:
@@ -8,6 +8,7 @@ import {
|
||||
sendSeenZalouser,
|
||||
sendTypingZalouser,
|
||||
} from "./send.js";
|
||||
import { parseZalouserTextStyles } from "./text-styles.js";
|
||||
import {
|
||||
sendZaloDeliveredEvent,
|
||||
sendZaloLink,
|
||||
@@ -16,6 +17,7 @@ import {
|
||||
sendZaloTextMessage,
|
||||
sendZaloTypingEvent,
|
||||
} from "./zalo-js.js";
|
||||
import { TextStyle } from "./zca-client.js";
|
||||
|
||||
vi.mock("./zalo-js.js", () => ({
|
||||
sendZaloTextMessage: vi.fn(),
|
||||
@@ -43,36 +45,272 @@ describe("zalouser send helpers", () => {
|
||||
mockSendSeen.mockReset();
|
||||
});
|
||||
|
||||
it("delegates text send to JS transport", async () => {
|
||||
it("keeps plain text literal by default", async () => {
|
||||
mockSendText.mockResolvedValueOnce({ ok: true, messageId: "mid-1" });
|
||||
|
||||
const result = await sendMessageZalouser("thread-1", "hello", {
|
||||
const result = await sendMessageZalouser("thread-1", "**hello**", {
|
||||
profile: "default",
|
||||
isGroup: true,
|
||||
});
|
||||
|
||||
expect(mockSendText).toHaveBeenCalledWith("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("maps image helper to media send", async () => {
|
||||
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",
|
||||
caption: "_cap_",
|
||||
isGroup: false,
|
||||
textMode: "markdown",
|
||||
});
|
||||
|
||||
expect(mockSendText).toHaveBeenCalledWith("thread-2", "cap", {
|
||||
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: "cap",
|
||||
caption: "```\n```",
|
||||
isGroup: false,
|
||||
mediaUrl: "https://example.com/a.png",
|
||||
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 () => {
|
||||
|
||||
Reference in New Issue
Block a user