mirror of
https://github.com/openclaw/openclaw.git
synced 2026-03-29 19:01:44 +00:00
520 lines
17 KiB
TypeScript
520 lines
17 KiB
TypeScript
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<typeof resolveBlueBubblesAccount>[0]["cfg"],
|
|
accountId,
|
|
}).config.allowFrom ?? [],
|
|
},
|
|
setup: blueBubblesSetupAdapter,
|
|
} as Parameters<typeof createSetupWizardAdapter>[0]["plugin"];
|
|
return createSetupWizardAdapter({
|
|
plugin,
|
|
wizard: blueBubblesSetupWizard,
|
|
});
|
|
}
|
|
|
|
async function runBlueBubblesConfigure(params: { cfg: unknown; prompter: WizardPrompter }) {
|
|
const adapter = await createBlueBubblesConfigureAdapter();
|
|
type ConfigureContext = Parameters<NonNullable<typeof adapter.configure>>[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<digits> 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<digits> 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<digits> 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<digits> 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);
|
|
});
|
|
});
|