Spaces:
Running
Running
| import type { OpenClawConfig } from "openclaw/plugin-sdk"; | |
| import { describe, expect, it, vi, beforeEach } from "vitest"; | |
| import { bluebubblesMessageActions } from "./actions.js"; | |
| vi.mock("./accounts.js", () => ({ | |
| resolveBlueBubblesAccount: vi.fn(({ cfg, accountId }) => { | |
| const config = cfg?.channels?.bluebubbles ?? {}; | |
| return { | |
| accountId: accountId ?? "default", | |
| enabled: config.enabled !== false, | |
| configured: Boolean(config.serverUrl && config.password), | |
| config, | |
| }; | |
| }), | |
| })); | |
| vi.mock("./reactions.js", () => ({ | |
| sendBlueBubblesReaction: vi.fn().mockResolvedValue(undefined), | |
| })); | |
| vi.mock("./send.js", () => ({ | |
| resolveChatGuidForTarget: vi.fn().mockResolvedValue("iMessage;-;+15551234567"), | |
| sendMessageBlueBubbles: vi.fn().mockResolvedValue({ messageId: "msg-123" }), | |
| })); | |
| vi.mock("./chat.js", () => ({ | |
| editBlueBubblesMessage: vi.fn().mockResolvedValue(undefined), | |
| unsendBlueBubblesMessage: vi.fn().mockResolvedValue(undefined), | |
| renameBlueBubblesChat: vi.fn().mockResolvedValue(undefined), | |
| setGroupIconBlueBubbles: vi.fn().mockResolvedValue(undefined), | |
| addBlueBubblesParticipant: vi.fn().mockResolvedValue(undefined), | |
| removeBlueBubblesParticipant: vi.fn().mockResolvedValue(undefined), | |
| leaveBlueBubblesChat: vi.fn().mockResolvedValue(undefined), | |
| })); | |
| vi.mock("./attachments.js", () => ({ | |
| sendBlueBubblesAttachment: vi.fn().mockResolvedValue({ messageId: "att-msg-123" }), | |
| })); | |
| vi.mock("./monitor.js", () => ({ | |
| resolveBlueBubblesMessageId: vi.fn((id: string) => id), | |
| })); | |
| describe("bluebubblesMessageActions", () => { | |
| beforeEach(() => { | |
| vi.clearAllMocks(); | |
| }); | |
| describe("listActions", () => { | |
| it("returns empty array when account is not enabled", () => { | |
| const cfg: OpenClawConfig = { | |
| channels: { bluebubbles: { enabled: false } }, | |
| }; | |
| const actions = bluebubblesMessageActions.listActions({ cfg }); | |
| expect(actions).toEqual([]); | |
| }); | |
| it("returns empty array when account is not configured", () => { | |
| const cfg: OpenClawConfig = { | |
| channels: { bluebubbles: { enabled: true } }, | |
| }; | |
| const actions = bluebubblesMessageActions.listActions({ cfg }); | |
| expect(actions).toEqual([]); | |
| }); | |
| it("returns react action when enabled and configured", () => { | |
| const cfg: OpenClawConfig = { | |
| channels: { | |
| bluebubbles: { | |
| enabled: true, | |
| serverUrl: "http://localhost:1234", | |
| password: "test-password", | |
| }, | |
| }, | |
| }; | |
| const actions = bluebubblesMessageActions.listActions({ cfg }); | |
| expect(actions).toContain("react"); | |
| }); | |
| it("excludes react action when reactions are gated off", () => { | |
| const cfg: OpenClawConfig = { | |
| channels: { | |
| bluebubbles: { | |
| enabled: true, | |
| serverUrl: "http://localhost:1234", | |
| password: "test-password", | |
| actions: { reactions: false }, | |
| }, | |
| }, | |
| }; | |
| const actions = bluebubblesMessageActions.listActions({ cfg }); | |
| expect(actions).not.toContain("react"); | |
| // Other actions should still be present | |
| expect(actions).toContain("edit"); | |
| expect(actions).toContain("unsend"); | |
| }); | |
| }); | |
| describe("supportsAction", () => { | |
| it("returns true for react action", () => { | |
| expect(bluebubblesMessageActions.supportsAction({ action: "react" })).toBe(true); | |
| }); | |
| it("returns true for all supported actions", () => { | |
| expect(bluebubblesMessageActions.supportsAction({ action: "edit" })).toBe(true); | |
| expect(bluebubblesMessageActions.supportsAction({ action: "unsend" })).toBe(true); | |
| expect(bluebubblesMessageActions.supportsAction({ action: "reply" })).toBe(true); | |
| expect(bluebubblesMessageActions.supportsAction({ action: "sendWithEffect" })).toBe(true); | |
| expect(bluebubblesMessageActions.supportsAction({ action: "renameGroup" })).toBe(true); | |
| expect(bluebubblesMessageActions.supportsAction({ action: "setGroupIcon" })).toBe(true); | |
| expect(bluebubblesMessageActions.supportsAction({ action: "addParticipant" })).toBe(true); | |
| expect(bluebubblesMessageActions.supportsAction({ action: "removeParticipant" })).toBe(true); | |
| expect(bluebubblesMessageActions.supportsAction({ action: "leaveGroup" })).toBe(true); | |
| expect(bluebubblesMessageActions.supportsAction({ action: "sendAttachment" })).toBe(true); | |
| }); | |
| it("returns false for unsupported actions", () => { | |
| expect(bluebubblesMessageActions.supportsAction({ action: "delete" })).toBe(false); | |
| expect(bluebubblesMessageActions.supportsAction({ action: "unknown" })).toBe(false); | |
| }); | |
| }); | |
| describe("extractToolSend", () => { | |
| it("extracts send params from sendMessage action", () => { | |
| const result = bluebubblesMessageActions.extractToolSend({ | |
| args: { | |
| action: "sendMessage", | |
| to: "+15551234567", | |
| accountId: "test-account", | |
| }, | |
| }); | |
| expect(result).toEqual({ | |
| to: "+15551234567", | |
| accountId: "test-account", | |
| }); | |
| }); | |
| it("returns null for non-sendMessage action", () => { | |
| const result = bluebubblesMessageActions.extractToolSend({ | |
| args: { action: "react", to: "+15551234567" }, | |
| }); | |
| expect(result).toBeNull(); | |
| }); | |
| it("returns null when to is missing", () => { | |
| const result = bluebubblesMessageActions.extractToolSend({ | |
| args: { action: "sendMessage" }, | |
| }); | |
| expect(result).toBeNull(); | |
| }); | |
| }); | |
| describe("handleAction", () => { | |
| it("throws for unsupported actions", async () => { | |
| const cfg: OpenClawConfig = { | |
| channels: { | |
| bluebubbles: { | |
| serverUrl: "http://localhost:1234", | |
| password: "test-password", | |
| }, | |
| }, | |
| }; | |
| await expect( | |
| bluebubblesMessageActions.handleAction({ | |
| action: "unknownAction", | |
| params: {}, | |
| cfg, | |
| accountId: null, | |
| }), | |
| ).rejects.toThrow("is not supported"); | |
| }); | |
| it("throws when emoji is missing for react action", async () => { | |
| const cfg: OpenClawConfig = { | |
| channels: { | |
| bluebubbles: { | |
| serverUrl: "http://localhost:1234", | |
| password: "test-password", | |
| }, | |
| }, | |
| }; | |
| await expect( | |
| bluebubblesMessageActions.handleAction({ | |
| action: "react", | |
| params: { messageId: "msg-123" }, | |
| cfg, | |
| accountId: null, | |
| }), | |
| ).rejects.toThrow(/emoji/i); | |
| }); | |
| it("throws when messageId is missing", async () => { | |
| const cfg: OpenClawConfig = { | |
| channels: { | |
| bluebubbles: { | |
| serverUrl: "http://localhost:1234", | |
| password: "test-password", | |
| }, | |
| }, | |
| }; | |
| await expect( | |
| bluebubblesMessageActions.handleAction({ | |
| action: "react", | |
| params: { emoji: "❤️" }, | |
| cfg, | |
| accountId: null, | |
| }), | |
| ).rejects.toThrow("messageId"); | |
| }); | |
| it("throws when chatGuid cannot be resolved", async () => { | |
| const { resolveChatGuidForTarget } = await import("./send.js"); | |
| vi.mocked(resolveChatGuidForTarget).mockResolvedValueOnce(null); | |
| const cfg: OpenClawConfig = { | |
| channels: { | |
| bluebubbles: { | |
| serverUrl: "http://localhost:1234", | |
| password: "test-password", | |
| }, | |
| }, | |
| }; | |
| await expect( | |
| bluebubblesMessageActions.handleAction({ | |
| action: "react", | |
| params: { emoji: "❤️", messageId: "msg-123", to: "+15551234567" }, | |
| cfg, | |
| accountId: null, | |
| }), | |
| ).rejects.toThrow("chatGuid not found"); | |
| }); | |
| it("sends reaction successfully with chatGuid", async () => { | |
| const { sendBlueBubblesReaction } = await import("./reactions.js"); | |
| const cfg: OpenClawConfig = { | |
| channels: { | |
| bluebubbles: { | |
| serverUrl: "http://localhost:1234", | |
| password: "test-password", | |
| }, | |
| }, | |
| }; | |
| const result = await bluebubblesMessageActions.handleAction({ | |
| action: "react", | |
| params: { | |
| emoji: "❤️", | |
| messageId: "msg-123", | |
| chatGuid: "iMessage;-;+15551234567", | |
| }, | |
| cfg, | |
| accountId: null, | |
| }); | |
| expect(sendBlueBubblesReaction).toHaveBeenCalledWith( | |
| expect.objectContaining({ | |
| chatGuid: "iMessage;-;+15551234567", | |
| messageGuid: "msg-123", | |
| emoji: "❤️", | |
| }), | |
| ); | |
| // jsonResult returns { content: [...], details: payload } | |
| expect(result).toMatchObject({ | |
| details: { ok: true, added: "❤️" }, | |
| }); | |
| }); | |
| it("sends reaction removal successfully", async () => { | |
| const { sendBlueBubblesReaction } = await import("./reactions.js"); | |
| const cfg: OpenClawConfig = { | |
| channels: { | |
| bluebubbles: { | |
| serverUrl: "http://localhost:1234", | |
| password: "test-password", | |
| }, | |
| }, | |
| }; | |
| const result = await bluebubblesMessageActions.handleAction({ | |
| action: "react", | |
| params: { | |
| emoji: "❤️", | |
| messageId: "msg-123", | |
| chatGuid: "iMessage;-;+15551234567", | |
| remove: true, | |
| }, | |
| cfg, | |
| accountId: null, | |
| }); | |
| expect(sendBlueBubblesReaction).toHaveBeenCalledWith( | |
| expect.objectContaining({ | |
| remove: true, | |
| }), | |
| ); | |
| // jsonResult returns { content: [...], details: payload } | |
| expect(result).toMatchObject({ | |
| details: { ok: true, removed: true }, | |
| }); | |
| }); | |
| it("resolves chatGuid from to parameter", async () => { | |
| const { sendBlueBubblesReaction } = await import("./reactions.js"); | |
| const { resolveChatGuidForTarget } = await import("./send.js"); | |
| vi.mocked(resolveChatGuidForTarget).mockResolvedValueOnce("iMessage;-;+15559876543"); | |
| const cfg: OpenClawConfig = { | |
| channels: { | |
| bluebubbles: { | |
| serverUrl: "http://localhost:1234", | |
| password: "test-password", | |
| }, | |
| }, | |
| }; | |
| await bluebubblesMessageActions.handleAction({ | |
| action: "react", | |
| params: { | |
| emoji: "👍", | |
| messageId: "msg-456", | |
| to: "+15559876543", | |
| }, | |
| cfg, | |
| accountId: null, | |
| }); | |
| expect(resolveChatGuidForTarget).toHaveBeenCalled(); | |
| expect(sendBlueBubblesReaction).toHaveBeenCalledWith( | |
| expect.objectContaining({ | |
| chatGuid: "iMessage;-;+15559876543", | |
| }), | |
| ); | |
| }); | |
| it("passes partIndex when provided", async () => { | |
| const { sendBlueBubblesReaction } = await import("./reactions.js"); | |
| const cfg: OpenClawConfig = { | |
| channels: { | |
| bluebubbles: { | |
| serverUrl: "http://localhost:1234", | |
| password: "test-password", | |
| }, | |
| }, | |
| }; | |
| await bluebubblesMessageActions.handleAction({ | |
| action: "react", | |
| params: { | |
| emoji: "😂", | |
| messageId: "msg-789", | |
| chatGuid: "iMessage;-;chat-guid", | |
| partIndex: 2, | |
| }, | |
| cfg, | |
| accountId: null, | |
| }); | |
| expect(sendBlueBubblesReaction).toHaveBeenCalledWith( | |
| expect.objectContaining({ | |
| partIndex: 2, | |
| }), | |
| ); | |
| }); | |
| it("uses toolContext currentChannelId when no explicit target is provided", async () => { | |
| const { sendBlueBubblesReaction } = await import("./reactions.js"); | |
| const { resolveChatGuidForTarget } = await import("./send.js"); | |
| vi.mocked(resolveChatGuidForTarget).mockResolvedValueOnce("iMessage;-;+15550001111"); | |
| const cfg: OpenClawConfig = { | |
| channels: { | |
| bluebubbles: { | |
| serverUrl: "http://localhost:1234", | |
| password: "test-password", | |
| }, | |
| }, | |
| }; | |
| await bluebubblesMessageActions.handleAction({ | |
| action: "react", | |
| params: { | |
| emoji: "👍", | |
| messageId: "msg-456", | |
| }, | |
| cfg, | |
| accountId: null, | |
| toolContext: { | |
| currentChannelId: "bluebubbles:chat_guid:iMessage;-;+15550001111", | |
| }, | |
| }); | |
| expect(resolveChatGuidForTarget).toHaveBeenCalledWith( | |
| expect.objectContaining({ | |
| target: { kind: "chat_guid", chatGuid: "iMessage;-;+15550001111" }, | |
| }), | |
| ); | |
| expect(sendBlueBubblesReaction).toHaveBeenCalledWith( | |
| expect.objectContaining({ | |
| chatGuid: "iMessage;-;+15550001111", | |
| }), | |
| ); | |
| }); | |
| it("resolves short messageId before reacting", async () => { | |
| const { resolveBlueBubblesMessageId } = await import("./monitor.js"); | |
| const { sendBlueBubblesReaction } = await import("./reactions.js"); | |
| vi.mocked(resolveBlueBubblesMessageId).mockReturnValueOnce("resolved-uuid"); | |
| const cfg: OpenClawConfig = { | |
| channels: { | |
| bluebubbles: { | |
| serverUrl: "http://localhost:1234", | |
| password: "test-password", | |
| }, | |
| }, | |
| }; | |
| await bluebubblesMessageActions.handleAction({ | |
| action: "react", | |
| params: { | |
| emoji: "❤️", | |
| messageId: "1", | |
| chatGuid: "iMessage;-;+15551234567", | |
| }, | |
| cfg, | |
| accountId: null, | |
| }); | |
| expect(resolveBlueBubblesMessageId).toHaveBeenCalledWith("1", { requireKnownShortId: true }); | |
| expect(sendBlueBubblesReaction).toHaveBeenCalledWith( | |
| expect.objectContaining({ | |
| messageGuid: "resolved-uuid", | |
| }), | |
| ); | |
| }); | |
| it("propagates short-id errors from the resolver", async () => { | |
| const { resolveBlueBubblesMessageId } = await import("./monitor.js"); | |
| vi.mocked(resolveBlueBubblesMessageId).mockImplementationOnce(() => { | |
| throw new Error("short id expired"); | |
| }); | |
| const cfg: OpenClawConfig = { | |
| channels: { | |
| bluebubbles: { | |
| serverUrl: "http://localhost:1234", | |
| password: "test-password", | |
| }, | |
| }, | |
| }; | |
| await expect( | |
| bluebubblesMessageActions.handleAction({ | |
| action: "react", | |
| params: { | |
| emoji: "❤️", | |
| messageId: "999", | |
| chatGuid: "iMessage;-;+15551234567", | |
| }, | |
| cfg, | |
| accountId: null, | |
| }), | |
| ).rejects.toThrow("short id expired"); | |
| }); | |
| it("accepts message param for edit action", async () => { | |
| const { editBlueBubblesMessage } = await import("./chat.js"); | |
| const cfg: OpenClawConfig = { | |
| channels: { | |
| bluebubbles: { | |
| serverUrl: "http://localhost:1234", | |
| password: "test-password", | |
| }, | |
| }, | |
| }; | |
| await bluebubblesMessageActions.handleAction({ | |
| action: "edit", | |
| params: { messageId: "msg-123", message: "updated" }, | |
| cfg, | |
| accountId: null, | |
| }); | |
| expect(editBlueBubblesMessage).toHaveBeenCalledWith( | |
| "msg-123", | |
| "updated", | |
| expect.objectContaining({ cfg, accountId: undefined }), | |
| ); | |
| }); | |
| it("accepts message/target aliases for sendWithEffect", async () => { | |
| const { sendMessageBlueBubbles } = await import("./send.js"); | |
| const cfg: OpenClawConfig = { | |
| channels: { | |
| bluebubbles: { | |
| serverUrl: "http://localhost:1234", | |
| password: "test-password", | |
| }, | |
| }, | |
| }; | |
| const result = await bluebubblesMessageActions.handleAction({ | |
| action: "sendWithEffect", | |
| params: { | |
| message: "peekaboo", | |
| target: "+15551234567", | |
| effect: "invisible ink", | |
| }, | |
| cfg, | |
| accountId: null, | |
| }); | |
| expect(sendMessageBlueBubbles).toHaveBeenCalledWith( | |
| "+15551234567", | |
| "peekaboo", | |
| expect.objectContaining({ effectId: "invisible ink" }), | |
| ); | |
| expect(result).toMatchObject({ | |
| details: { ok: true, messageId: "msg-123", effect: "invisible ink" }, | |
| }); | |
| }); | |
| it("passes asVoice through sendAttachment", async () => { | |
| const { sendBlueBubblesAttachment } = await import("./attachments.js"); | |
| const cfg: OpenClawConfig = { | |
| channels: { | |
| bluebubbles: { | |
| serverUrl: "http://localhost:1234", | |
| password: "test-password", | |
| }, | |
| }, | |
| }; | |
| const base64Buffer = Buffer.from("voice").toString("base64"); | |
| await bluebubblesMessageActions.handleAction({ | |
| action: "sendAttachment", | |
| params: { | |
| to: "+15551234567", | |
| filename: "voice.mp3", | |
| buffer: base64Buffer, | |
| contentType: "audio/mpeg", | |
| asVoice: true, | |
| }, | |
| cfg, | |
| accountId: null, | |
| }); | |
| expect(sendBlueBubblesAttachment).toHaveBeenCalledWith( | |
| expect.objectContaining({ | |
| filename: "voice.mp3", | |
| contentType: "audio/mpeg", | |
| asVoice: true, | |
| }), | |
| ); | |
| }); | |
| it("throws when buffer is missing for setGroupIcon", async () => { | |
| const cfg: OpenClawConfig = { | |
| channels: { | |
| bluebubbles: { | |
| serverUrl: "http://localhost:1234", | |
| password: "test-password", | |
| }, | |
| }, | |
| }; | |
| await expect( | |
| bluebubblesMessageActions.handleAction({ | |
| action: "setGroupIcon", | |
| params: { chatGuid: "iMessage;-;chat-guid" }, | |
| cfg, | |
| accountId: null, | |
| }), | |
| ).rejects.toThrow(/requires an image/i); | |
| }); | |
| it("sets group icon successfully with chatGuid and buffer", async () => { | |
| const { setGroupIconBlueBubbles } = await import("./chat.js"); | |
| const cfg: OpenClawConfig = { | |
| channels: { | |
| bluebubbles: { | |
| serverUrl: "http://localhost:1234", | |
| password: "test-password", | |
| }, | |
| }, | |
| }; | |
| // Base64 encode a simple test buffer | |
| const testBuffer = Buffer.from("fake-image-data"); | |
| const base64Buffer = testBuffer.toString("base64"); | |
| const result = await bluebubblesMessageActions.handleAction({ | |
| action: "setGroupIcon", | |
| params: { | |
| chatGuid: "iMessage;-;chat-guid", | |
| buffer: base64Buffer, | |
| filename: "group-icon.png", | |
| contentType: "image/png", | |
| }, | |
| cfg, | |
| accountId: null, | |
| }); | |
| expect(setGroupIconBlueBubbles).toHaveBeenCalledWith( | |
| "iMessage;-;chat-guid", | |
| expect.any(Uint8Array), | |
| "group-icon.png", | |
| expect.objectContaining({ contentType: "image/png" }), | |
| ); | |
| expect(result).toMatchObject({ | |
| details: { ok: true, chatGuid: "iMessage;-;chat-guid", iconSet: true }, | |
| }); | |
| }); | |
| it("uses default filename when not provided for setGroupIcon", async () => { | |
| const { setGroupIconBlueBubbles } = await import("./chat.js"); | |
| const cfg: OpenClawConfig = { | |
| channels: { | |
| bluebubbles: { | |
| serverUrl: "http://localhost:1234", | |
| password: "test-password", | |
| }, | |
| }, | |
| }; | |
| const base64Buffer = Buffer.from("test").toString("base64"); | |
| await bluebubblesMessageActions.handleAction({ | |
| action: "setGroupIcon", | |
| params: { | |
| chatGuid: "iMessage;-;chat-guid", | |
| buffer: base64Buffer, | |
| }, | |
| cfg, | |
| accountId: null, | |
| }); | |
| expect(setGroupIconBlueBubbles).toHaveBeenCalledWith( | |
| "iMessage;-;chat-guid", | |
| expect.any(Uint8Array), | |
| "icon.png", | |
| expect.anything(), | |
| ); | |
| }); | |
| }); | |
| }); | |