import { adaptScopedAccountAccessor } from "openclaw/plugin-sdk/channel-config-helpers"; import { describe, expect, it, vi } from "vitest"; import { DEFAULT_ACCOUNT_ID } from "../../../src/routing/session-key.js"; import { createSetupWizardAdapter, createTestWizardPrompter, runSetupWizardConfigure, type WizardPrompter, } from "../../../test/helpers/extensions/setup-wizard.js"; import { resolveBlueBubblesAccount } from "./accounts.js"; import { BlueBubblesConfigSchema } from "./config-schema.js"; import { resolveBlueBubblesGroupRequireMention, resolveBlueBubblesGroupToolPolicy, } from "./group-policy.js"; import { inferBlueBubblesTargetChatType, isAllowedBlueBubblesSender, looksLikeBlueBubblesExplicitTargetId, looksLikeBlueBubblesTargetId, normalizeBlueBubblesMessagingTarget, parseBlueBubblesAllowTarget, parseBlueBubblesTarget, } from "./targets.js"; import { DEFAULT_WEBHOOK_PATH } from "./webhook-shared.js"; async function createBlueBubblesConfigureAdapter() { const { blueBubblesSetupAdapter, blueBubblesSetupWizard } = await import("./setup-surface.js"); const plugin = { id: "bluebubbles", meta: { id: "bluebubbles", label: "BlueBubbles", selectionLabel: "BlueBubbles", docsPath: "/channels/bluebubbles", blurb: "iMessage via BlueBubbles", }, config: { listAccountIds: () => [DEFAULT_ACCOUNT_ID], defaultAccountId: () => DEFAULT_ACCOUNT_ID, resolveAccount: adaptScopedAccountAccessor(resolveBlueBubblesAccount), resolveAllowFrom: ({ cfg, accountId }: { cfg: unknown; accountId: string }) => resolveBlueBubblesAccount({ cfg: cfg as Parameters[0]["cfg"], accountId, }).config.allowFrom ?? [], }, setup: blueBubblesSetupAdapter, } as Parameters[0]["plugin"]; return createSetupWizardAdapter({ plugin, wizard: blueBubblesSetupWizard, }); } async function runBlueBubblesConfigure(params: { cfg: unknown; prompter: WizardPrompter }) { const adapter = await createBlueBubblesConfigureAdapter(); type ConfigureContext = Parameters>[0]; return await runSetupWizardConfigure({ configure: adapter.configure, cfg: params.cfg as ConfigureContext["cfg"], runtime: { ...console, exit: vi.fn() } as ConfigureContext["runtime"], prompter: params.prompter, }); } describe("bluebubbles setup surface", () => { it("preserves existing password SecretRef and keeps default webhook path", async () => { const passwordRef = { source: "env", provider: "default", id: "BLUEBUBBLES_PASSWORD" }; const confirm = vi .fn() .mockResolvedValueOnce(false) .mockResolvedValueOnce(true) .mockResolvedValueOnce(true); const text = vi.fn(); const result = await runBlueBubblesConfigure({ cfg: { channels: { bluebubbles: { enabled: true, serverUrl: "http://127.0.0.1:1234", password: passwordRef, }, }, }, prompter: createTestWizardPrompter({ confirm, text }), }); expect(result.cfg.channels?.bluebubbles?.password).toEqual(passwordRef); expect(result.cfg.channels?.bluebubbles?.webhookPath).toBe(DEFAULT_WEBHOOK_PATH); expect(text).not.toHaveBeenCalled(); }); it("applies a custom webhook path when requested", async () => { const confirm = vi .fn() .mockResolvedValueOnce(true) .mockResolvedValueOnce(true) .mockResolvedValueOnce(true); const text = vi.fn().mockResolvedValueOnce("/custom-bluebubbles"); const result = await runBlueBubblesConfigure({ cfg: { channels: { bluebubbles: { enabled: true, serverUrl: "http://127.0.0.1:1234", password: "secret", }, }, }, prompter: createTestWizardPrompter({ confirm, text }), }); expect(result.cfg.channels?.bluebubbles?.webhookPath).toBe("/custom-bluebubbles"); expect(text).toHaveBeenCalledWith( expect.objectContaining({ message: "Webhook path", placeholder: DEFAULT_WEBHOOK_PATH, }), ); }); it("validates server URLs before accepting input", async () => { const confirm = vi.fn().mockResolvedValueOnce(false); const text = vi.fn().mockResolvedValueOnce("127.0.0.1:1234").mockResolvedValueOnce("secret"); await runBlueBubblesConfigure({ cfg: { channels: { bluebubbles: {} } }, prompter: createTestWizardPrompter({ confirm, text }), }); const serverUrlPrompt = text.mock.calls[0]?.[0] as { validate?: (value: string) => string | undefined; }; expect(serverUrlPrompt.validate?.("bad url")).toBe("Invalid URL format"); expect(serverUrlPrompt.validate?.("127.0.0.1:1234")).toBeUndefined(); }); it("disables the channel through the setup wizard", async () => { const { blueBubblesSetupWizard } = await import("./setup-surface.js"); const next = blueBubblesSetupWizard.disable?.({ channels: { bluebubbles: { enabled: true, serverUrl: "http://127.0.0.1:1234", }, }, }); expect(next?.channels?.bluebubbles?.enabled).toBe(false); }); }); describe("resolveBlueBubblesAccount", () => { it("treats SecretRef passwords as configured when serverUrl exists", () => { const resolved = resolveBlueBubblesAccount({ cfg: { channels: { bluebubbles: { enabled: true, serverUrl: "http://localhost:1234", password: { source: "env", provider: "default", id: "BLUEBUBBLES_PASSWORD", }, }, }, }, }); expect(resolved.configured).toBe(true); expect(resolved.baseUrl).toBe("http://localhost:1234"); }); }); describe("BlueBubblesConfigSchema", () => { it("accepts account config when serverUrl and password are both set", () => { const parsed = BlueBubblesConfigSchema.safeParse({ serverUrl: "http://localhost:1234", password: "secret", // pragma: allowlist secret }); expect(parsed.success).toBe(true); }); it("accepts SecretRef password when serverUrl is set", () => { const parsed = BlueBubblesConfigSchema.safeParse({ serverUrl: "http://localhost:1234", password: { source: "env", provider: "default", id: "BLUEBUBBLES_PASSWORD", }, }); expect(parsed.success).toBe(true); }); it("requires password when top-level serverUrl is configured", () => { const parsed = BlueBubblesConfigSchema.safeParse({ serverUrl: "http://localhost:1234", }); expect(parsed.success).toBe(false); if (parsed.success) { return; } expect(parsed.error.issues[0]?.path).toEqual(["password"]); expect(parsed.error.issues[0]?.message).toBe( "password is required when serverUrl is configured", ); }); it("requires password when account serverUrl is configured", () => { const parsed = BlueBubblesConfigSchema.safeParse({ accounts: { work: { serverUrl: "http://localhost:1234", }, }, }); expect(parsed.success).toBe(false); if (parsed.success) { return; } expect(parsed.error.issues[0]?.path).toEqual(["accounts", "work", "password"]); expect(parsed.error.issues[0]?.message).toBe( "password is required when serverUrl is configured", ); }); it("allows password omission when serverUrl is not configured", () => { const parsed = BlueBubblesConfigSchema.safeParse({ accounts: { work: { name: "Work iMessage", }, }, }); expect(parsed.success).toBe(true); }); it("defaults enrichGroupParticipantsFromContacts to true", () => { const parsed = BlueBubblesConfigSchema.safeParse({ serverUrl: "http://localhost:1234", password: "secret", // pragma: allowlist secret }); expect(parsed.success).toBe(true); if (!parsed.success) { return; } expect(parsed.data.enrichGroupParticipantsFromContacts).toBe(true); }); it("defaults account enrichGroupParticipantsFromContacts to true", () => { const parsed = BlueBubblesConfigSchema.safeParse({ accounts: { work: { serverUrl: "http://localhost:1234", password: "secret", // pragma: allowlist secret }, }, }); expect(parsed.success).toBe(true); if (!parsed.success) { return; } const accountConfig = ( parsed.data as { accounts?: { work?: { enrichGroupParticipantsFromContacts?: boolean } } } ).accounts?.work; expect(accountConfig?.enrichGroupParticipantsFromContacts).toBe(true); }); }); describe("bluebubbles group policy", () => { it("uses generic channel group policy helpers", () => { const cfg = { channels: { bluebubbles: { groups: { "chat:primary": { requireMention: false, tools: { deny: ["exec"] }, }, "*": { requireMention: true, tools: { allow: ["message.send"] }, }, }, }, }, // oxlint-disable-next-line typescript/no-explicit-any } as any; expect(resolveBlueBubblesGroupRequireMention({ cfg, groupId: "chat:primary" })).toBe(false); expect(resolveBlueBubblesGroupRequireMention({ cfg, groupId: "chat:other" })).toBe(true); expect(resolveBlueBubblesGroupToolPolicy({ cfg, groupId: "chat:primary" })).toEqual({ deny: ["exec"], }); expect(resolveBlueBubblesGroupToolPolicy({ cfg, groupId: "chat:other" })).toEqual({ allow: ["message.send"], }); }); }); describe("normalizeBlueBubblesMessagingTarget", () => { it("normalizes chat_guid targets", () => { expect(normalizeBlueBubblesMessagingTarget("chat_guid:ABC-123")).toBe("chat_guid:ABC-123"); }); it("normalizes group numeric targets to chat_id", () => { expect(normalizeBlueBubblesMessagingTarget("group:123")).toBe("chat_id:123"); }); it("strips provider prefix and normalizes handles", () => { expect(normalizeBlueBubblesMessagingTarget("bluebubbles:imessage:User@Example.com")).toBe( "imessage:user@example.com", ); }); it("extracts handle from DM chat_guid for cross-context matching", () => { expect(normalizeBlueBubblesMessagingTarget("chat_guid:iMessage;-;+19257864429")).toBe( "+19257864429", ); expect(normalizeBlueBubblesMessagingTarget("chat_guid:SMS;-;+15551234567")).toBe( "+15551234567", ); expect(normalizeBlueBubblesMessagingTarget("chat_guid:iMessage;-;user@example.com")).toBe( "user@example.com", ); }); it("preserves group chat_guid format", () => { expect(normalizeBlueBubblesMessagingTarget("chat_guid:iMessage;+;chat123456789")).toBe( "chat_guid:iMessage;+;chat123456789", ); }); it("normalizes raw chat_guid values", () => { expect(normalizeBlueBubblesMessagingTarget("iMessage;+;chat660250192681427962")).toBe( "chat_guid:iMessage;+;chat660250192681427962", ); expect(normalizeBlueBubblesMessagingTarget("iMessage;-;+19257864429")).toBe("+19257864429"); }); it("normalizes chat pattern to chat_identifier format", () => { expect(normalizeBlueBubblesMessagingTarget("chat660250192681427962")).toBe( "chat_identifier:chat660250192681427962", ); expect(normalizeBlueBubblesMessagingTarget("chat123")).toBe("chat_identifier:chat123"); expect(normalizeBlueBubblesMessagingTarget("Chat456789")).toBe("chat_identifier:Chat456789"); }); it("normalizes UUID/hex chat identifiers", () => { expect(normalizeBlueBubblesMessagingTarget("8b9c1a10536d4d86a336ea03ab7151cc")).toBe( "chat_identifier:8b9c1a10536d4d86a336ea03ab7151cc", ); expect(normalizeBlueBubblesMessagingTarget("1C2D3E4F-1234-5678-9ABC-DEF012345678")).toBe( "chat_identifier:1C2D3E4F-1234-5678-9ABC-DEF012345678", ); }); }); describe("looksLikeBlueBubblesTargetId", () => { it("accepts chat targets", () => { expect(looksLikeBlueBubblesTargetId("chat_guid:ABC-123")).toBe(true); }); it("accepts email handles", () => { expect(looksLikeBlueBubblesTargetId("user@example.com")).toBe(true); }); it("accepts phone numbers with punctuation", () => { expect(looksLikeBlueBubblesTargetId("+1 (555) 123-4567")).toBe(true); }); it("accepts raw chat_guid values", () => { expect(looksLikeBlueBubblesTargetId("iMessage;+;chat660250192681427962")).toBe(true); }); it("accepts chat pattern as chat_id", () => { expect(looksLikeBlueBubblesTargetId("chat660250192681427962")).toBe(true); expect(looksLikeBlueBubblesTargetId("chat123")).toBe(true); expect(looksLikeBlueBubblesTargetId("Chat456789")).toBe(true); }); it("accepts UUID/hex chat identifiers", () => { expect(looksLikeBlueBubblesTargetId("8b9c1a10536d4d86a336ea03ab7151cc")).toBe(true); expect(looksLikeBlueBubblesTargetId("1C2D3E4F-1234-5678-9ABC-DEF012345678")).toBe(true); }); it("rejects display names", () => { expect(looksLikeBlueBubblesTargetId("Jane Doe")).toBe(false); }); }); describe("looksLikeBlueBubblesExplicitTargetId", () => { it("treats explicit chat targets as immediate ids", () => { expect(looksLikeBlueBubblesExplicitTargetId("chat_guid:ABC-123")).toBe(true); expect(looksLikeBlueBubblesExplicitTargetId("imessage:+15551234567")).toBe(true); }); it("prefers directory fallback for bare handles and phone numbers", () => { expect(looksLikeBlueBubblesExplicitTargetId("+1 (555) 123-4567")).toBe(false); expect(looksLikeBlueBubblesExplicitTargetId("user@example.com")).toBe(false); }); }); describe("inferBlueBubblesTargetChatType", () => { it("infers direct chat for handles and dm chat_guids", () => { expect(inferBlueBubblesTargetChatType("+15551234567")).toBe("direct"); expect(inferBlueBubblesTargetChatType("chat_guid:iMessage;-;+15551234567")).toBe("direct"); }); it("infers group chat for explicit group targets", () => { expect(inferBlueBubblesTargetChatType("chat_id:123")).toBe("group"); expect(inferBlueBubblesTargetChatType("chat_guid:iMessage;+;chat123")).toBe("group"); }); }); describe("parseBlueBubblesTarget", () => { it("parses chat pattern as chat_identifier", () => { expect(parseBlueBubblesTarget("chat660250192681427962")).toEqual({ kind: "chat_identifier", chatIdentifier: "chat660250192681427962", }); expect(parseBlueBubblesTarget("chat123")).toEqual({ kind: "chat_identifier", chatIdentifier: "chat123", }); expect(parseBlueBubblesTarget("Chat456789")).toEqual({ kind: "chat_identifier", chatIdentifier: "Chat456789", }); }); it("parses UUID/hex chat identifiers as chat_identifier", () => { expect(parseBlueBubblesTarget("8b9c1a10536d4d86a336ea03ab7151cc")).toEqual({ kind: "chat_identifier", chatIdentifier: "8b9c1a10536d4d86a336ea03ab7151cc", }); expect(parseBlueBubblesTarget("1C2D3E4F-1234-5678-9ABC-DEF012345678")).toEqual({ kind: "chat_identifier", chatIdentifier: "1C2D3E4F-1234-5678-9ABC-DEF012345678", }); }); it("parses explicit chat_id: prefix", () => { expect(parseBlueBubblesTarget("chat_id:123")).toEqual({ kind: "chat_id", chatId: 123 }); }); it("parses phone numbers as handles", () => { expect(parseBlueBubblesTarget("+19257864429")).toEqual({ kind: "handle", to: "+19257864429", service: "auto", }); }); it("parses raw chat_guid format", () => { expect(parseBlueBubblesTarget("iMessage;+;chat660250192681427962")).toEqual({ kind: "chat_guid", chatGuid: "iMessage;+;chat660250192681427962", }); }); }); describe("parseBlueBubblesAllowTarget", () => { it("parses chat pattern as chat_identifier", () => { expect(parseBlueBubblesAllowTarget("chat660250192681427962")).toEqual({ kind: "chat_identifier", chatIdentifier: "chat660250192681427962", }); expect(parseBlueBubblesAllowTarget("chat123")).toEqual({ kind: "chat_identifier", chatIdentifier: "chat123", }); }); it("parses UUID/hex chat identifiers as chat_identifier", () => { expect(parseBlueBubblesAllowTarget("8b9c1a10536d4d86a336ea03ab7151cc")).toEqual({ kind: "chat_identifier", chatIdentifier: "8b9c1a10536d4d86a336ea03ab7151cc", }); expect(parseBlueBubblesAllowTarget("1C2D3E4F-1234-5678-9ABC-DEF012345678")).toEqual({ kind: "chat_identifier", chatIdentifier: "1C2D3E4F-1234-5678-9ABC-DEF012345678", }); }); it("parses explicit chat_id: prefix", () => { expect(parseBlueBubblesAllowTarget("chat_id:456")).toEqual({ kind: "chat_id", chatId: 456 }); }); it("parses phone numbers as handles", () => { expect(parseBlueBubblesAllowTarget("+19257864429")).toEqual({ kind: "handle", handle: "+19257864429", }); }); }); describe("isAllowedBlueBubblesSender", () => { it("denies when allowFrom is empty", () => { const allowed = isAllowedBlueBubblesSender({ allowFrom: [], sender: "+15551234567", }); expect(allowed).toBe(false); }); it("allows wildcard entries", () => { const allowed = isAllowedBlueBubblesSender({ allowFrom: ["*"], sender: "+15551234567", }); expect(allowed).toBe(true); }); });