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, }); }); });