From bad65f130e78eaea9865a252776844295c2e611c Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 15 Mar 2026 17:33:49 -0700 Subject: [PATCH] refactor: move bluebubbles to setup wizard --- extensions/bluebubbles/src/channel.ts | 62 +-- .../src/onboarding.secret-input.test.ts | 89 ---- extensions/bluebubbles/src/onboarding.ts | 289 ------------- .../bluebubbles/src/setup-surface.test.ts | 154 +++++++ extensions/bluebubbles/src/setup-surface.ts | 385 ++++++++++++++++++ src/plugin-sdk/bluebubbles.ts | 4 - 6 files changed, 543 insertions(+), 440 deletions(-) delete mode 100644 extensions/bluebubbles/src/onboarding.secret-input.test.ts delete mode 100644 extensions/bluebubbles/src/onboarding.ts create mode 100644 extensions/bluebubbles/src/setup-surface.test.ts create mode 100644 extensions/bluebubbles/src/setup-surface.ts diff --git a/extensions/bluebubbles/src/channel.ts b/extensions/bluebubbles/src/channel.ts index 747fba5b67b..a482632ebea 100644 --- a/extensions/bluebubbles/src/channel.ts +++ b/extensions/bluebubbles/src/channel.ts @@ -1,18 +1,11 @@ -import type { - ChannelAccountSnapshot, - ChannelPlugin, - OpenClawConfig, -} from "openclaw/plugin-sdk/bluebubbles"; +import type { ChannelAccountSnapshot, ChannelPlugin } from "openclaw/plugin-sdk/bluebubbles"; import { - applyAccountNameToChannelSection, buildChannelConfigSchema, buildComputedAccountStatusSnapshot, buildProbeChannelStatusSummary, collectBlueBubblesStatusIssues, DEFAULT_ACCOUNT_ID, deleteAccountFromConfigSection, - migrateBaseNameToDefaultAccount, - normalizeAccountId, PAIRING_APPROVED_MESSAGE, resolveBlueBubblesGroupRequireMention, resolveBlueBubblesGroupToolPolicy, @@ -32,14 +25,13 @@ import { resolveDefaultBlueBubblesAccountId, } from "./accounts.js"; import { bluebubblesMessageActions } from "./actions.js"; -import { applyBlueBubblesConnectionConfig } from "./config-apply.js"; import { BlueBubblesConfigSchema } from "./config-schema.js"; import { sendBlueBubblesMedia } from "./media-send.js"; import { resolveBlueBubblesMessageId } from "./monitor.js"; import { monitorBlueBubblesProvider, resolveWebhookPathFromConfig } from "./monitor.js"; -import { blueBubblesOnboardingAdapter } from "./onboarding.js"; import { probeBlueBubbles, type BlueBubblesProbe } from "./probe.js"; import { sendMessageBlueBubbles } from "./send.js"; +import { blueBubblesSetupAdapter, blueBubblesSetupWizard } from "./setup-surface.js"; import { extractHandleFromChatGuid, looksLikeBlueBubblesTargetId, @@ -88,7 +80,7 @@ export const bluebubblesPlugin: ChannelPlugin = { }, reload: { configPrefixes: ["channels.bluebubbles"] }, configSchema: buildChannelConfigSchema(BlueBubblesConfigSchema), - onboarding: blueBubblesOnboardingAdapter, + setupWizard: blueBubblesSetupWizard, config: { listAccountIds: (cfg) => listBlueBubblesAccountIds(cfg), resolveAccount: (cfg, accountId) => resolveBlueBubblesAccount({ cfg: cfg, accountId }), @@ -223,53 +215,7 @@ export const bluebubblesPlugin: ChannelPlugin = { return display?.trim() || target?.trim() || ""; }, }, - setup: { - resolveAccountId: ({ accountId }) => normalizeAccountId(accountId), - applyAccountName: ({ cfg, accountId, name }) => - applyAccountNameToChannelSection({ - cfg: cfg, - channelKey: "bluebubbles", - accountId, - name, - }), - validateInput: ({ input }) => { - if (!input.httpUrl && !input.password) { - return "BlueBubbles requires --http-url and --password."; - } - if (!input.httpUrl) { - return "BlueBubbles requires --http-url."; - } - if (!input.password) { - return "BlueBubbles requires --password."; - } - return null; - }, - applyAccountConfig: ({ cfg, accountId, input }) => { - const namedConfig = applyAccountNameToChannelSection({ - cfg: cfg, - channelKey: "bluebubbles", - accountId, - name: input.name, - }); - const next = - accountId !== DEFAULT_ACCOUNT_ID - ? migrateBaseNameToDefaultAccount({ - cfg: namedConfig, - channelKey: "bluebubbles", - }) - : namedConfig; - return applyBlueBubblesConnectionConfig({ - cfg: next, - accountId, - patch: { - serverUrl: input.httpUrl, - password: input.password, - webhookPath: input.webhookPath, - }, - onlyDefinedFields: true, - }); - }, - }, + setup: blueBubblesSetupAdapter, pairing: { idLabel: "bluebubblesSenderId", normalizeAllowEntry: (entry) => normalizeBlueBubblesHandle(entry.replace(/^bluebubbles:/i, "")), diff --git a/extensions/bluebubbles/src/onboarding.secret-input.test.ts b/extensions/bluebubbles/src/onboarding.secret-input.test.ts deleted file mode 100644 index af59594f377..00000000000 --- a/extensions/bluebubbles/src/onboarding.secret-input.test.ts +++ /dev/null @@ -1,89 +0,0 @@ -import type { WizardPrompter } from "openclaw/plugin-sdk/bluebubbles"; -import { describe, expect, it, vi } from "vitest"; - -vi.mock("openclaw/plugin-sdk/bluebubbles", () => ({ - DEFAULT_ACCOUNT_ID: "default", - addWildcardAllowFrom: vi.fn(), - formatDocsLink: (_url: string, fallback: string) => fallback, - hasConfiguredSecretInput: (value: unknown) => { - if (typeof value === "string") { - return value.trim().length > 0; - } - if (!value || typeof value !== "object" || Array.isArray(value)) { - return false; - } - const ref = value as { source?: unknown; provider?: unknown; id?: unknown }; - const validSource = ref.source === "env" || ref.source === "file" || ref.source === "exec"; - return ( - validSource && - typeof ref.provider === "string" && - ref.provider.trim().length > 0 && - typeof ref.id === "string" && - ref.id.trim().length > 0 - ); - }, - mergeAllowFromEntries: (_existing: unknown, entries: string[]) => entries, - createAccountListHelpers: () => ({ - listAccountIds: () => ["default"], - resolveDefaultAccountId: () => "default", - }), - normalizeSecretInputString: (value: unknown) => { - if (typeof value !== "string") { - return undefined; - } - const trimmed = value.trim(); - return trimmed.length > 0 ? trimmed : undefined; - }, - normalizeAccountId: (value?: string | null) => - value && value.trim().length > 0 ? value : "default", - promptAccountId: vi.fn(), - resolveAccountIdForConfigure: async (params: { - accountOverride?: string; - defaultAccountId: string; - }) => params.accountOverride?.trim() || params.defaultAccountId, -})); - -describe("bluebubbles onboarding SecretInput", () => { - it("preserves existing password SecretRef when user keeps current credential", async () => { - const { blueBubblesOnboardingAdapter } = await import("./onboarding.js"); - type ConfigureContext = Parameters< - NonNullable - >[0]; - const passwordRef = { source: "env", provider: "default", id: "BLUEBUBBLES_PASSWORD" }; - const confirm = vi - .fn() - .mockResolvedValueOnce(true) // keep server URL - .mockResolvedValueOnce(true) // keep password SecretRef - .mockResolvedValueOnce(false); // keep default webhook path - const text = vi.fn(); - const note = vi.fn(); - - const prompter = { - confirm, - text, - note, - } as unknown as WizardPrompter; - - const context = { - cfg: { - channels: { - bluebubbles: { - enabled: true, - serverUrl: "http://127.0.0.1:1234", - password: passwordRef, - }, - }, - }, - prompter, - runtime: { ...console, exit: vi.fn() } as ConfigureContext["runtime"], - forceAllowFrom: false, - accountOverrides: {}, - shouldPromptAccountIds: false, - } satisfies ConfigureContext; - - const result = await blueBubblesOnboardingAdapter.configure(context); - - expect(result.cfg.channels?.bluebubbles?.password).toEqual(passwordRef); - expect(text).not.toHaveBeenCalled(); - }); -}); diff --git a/extensions/bluebubbles/src/onboarding.ts b/extensions/bluebubbles/src/onboarding.ts deleted file mode 100644 index eb66afdfe21..00000000000 --- a/extensions/bluebubbles/src/onboarding.ts +++ /dev/null @@ -1,289 +0,0 @@ -import type { - ChannelOnboardingAdapter, - ChannelOnboardingDmPolicy, - OpenClawConfig, - DmPolicy, - WizardPrompter, -} from "openclaw/plugin-sdk/bluebubbles"; -import { - DEFAULT_ACCOUNT_ID, - formatDocsLink, - mergeAllowFromEntries, - normalizeAccountId, - patchScopedAccountConfig, - resolveAccountIdForConfigure, - setTopLevelChannelDmPolicyWithAllowFrom, -} from "openclaw/plugin-sdk/bluebubbles"; -import { - listBlueBubblesAccountIds, - resolveBlueBubblesAccount, - resolveDefaultBlueBubblesAccountId, -} from "./accounts.js"; -import { applyBlueBubblesConnectionConfig } from "./config-apply.js"; -import { hasConfiguredSecretInput, normalizeSecretInputString } from "./secret-input.js"; -import { parseBlueBubblesAllowTarget } from "./targets.js"; -import { normalizeBlueBubblesServerUrl } from "./types.js"; - -const channel = "bluebubbles" as const; - -function setBlueBubblesDmPolicy(cfg: OpenClawConfig, dmPolicy: DmPolicy): OpenClawConfig { - return setTopLevelChannelDmPolicyWithAllowFrom({ - cfg, - channel: "bluebubbles", - dmPolicy, - }); -} - -function setBlueBubblesAllowFrom( - cfg: OpenClawConfig, - accountId: string, - allowFrom: string[], -): OpenClawConfig { - return patchScopedAccountConfig({ - cfg, - channelKey: channel, - accountId, - patch: { allowFrom }, - ensureChannelEnabled: false, - ensureAccountEnabled: false, - }); -} - -function parseBlueBubblesAllowFromInput(raw: string): string[] { - return raw - .split(/[\n,]+/g) - .map((entry) => entry.trim()) - .filter(Boolean); -} - -async function promptBlueBubblesAllowFrom(params: { - cfg: OpenClawConfig; - prompter: WizardPrompter; - accountId?: string; -}): Promise { - const accountId = - params.accountId && normalizeAccountId(params.accountId) - ? (normalizeAccountId(params.accountId) ?? DEFAULT_ACCOUNT_ID) - : resolveDefaultBlueBubblesAccountId(params.cfg); - const resolved = resolveBlueBubblesAccount({ cfg: params.cfg, accountId }); - const existing = resolved.config.allowFrom ?? []; - await params.prompter.note( - [ - "Allowlist BlueBubbles DMs by handle or chat target.", - "Examples:", - "- +15555550123", - "- user@example.com", - "- chat_id:123", - "- chat_guid:iMessage;-;+15555550123", - "Multiple entries: comma- or newline-separated.", - `Docs: ${formatDocsLink("/channels/bluebubbles", "bluebubbles")}`, - ].join("\n"), - "BlueBubbles allowlist", - ); - const entry = await params.prompter.text({ - message: "BlueBubbles allowFrom (handle or chat_id)", - placeholder: "+15555550123, user@example.com, chat_id:123", - initialValue: existing[0] ? String(existing[0]) : undefined, - validate: (value) => { - const raw = String(value ?? "").trim(); - if (!raw) { - return "Required"; - } - const parts = parseBlueBubblesAllowFromInput(raw); - for (const part of parts) { - if (part === "*") { - continue; - } - const parsed = parseBlueBubblesAllowTarget(part); - if (parsed.kind === "handle" && !parsed.handle) { - return `Invalid entry: ${part}`; - } - } - return undefined; - }, - }); - const parts = parseBlueBubblesAllowFromInput(String(entry)); - const unique = mergeAllowFromEntries(undefined, parts); - return setBlueBubblesAllowFrom(params.cfg, accountId, unique); -} - -const dmPolicy: ChannelOnboardingDmPolicy = { - label: "BlueBubbles", - channel, - policyKey: "channels.bluebubbles.dmPolicy", - allowFromKey: "channels.bluebubbles.allowFrom", - getCurrent: (cfg) => cfg.channels?.bluebubbles?.dmPolicy ?? "pairing", - setPolicy: (cfg, policy) => setBlueBubblesDmPolicy(cfg, policy), - promptAllowFrom: promptBlueBubblesAllowFrom, -}; - -export const blueBubblesOnboardingAdapter: ChannelOnboardingAdapter = { - channel, - getStatus: async ({ cfg }) => { - const configured = listBlueBubblesAccountIds(cfg).some((accountId) => { - const account = resolveBlueBubblesAccount({ cfg, accountId }); - return account.configured; - }); - return { - channel, - configured, - statusLines: [`BlueBubbles: ${configured ? "configured" : "needs setup"}`], - selectionHint: configured ? "configured" : "iMessage via BlueBubbles app", - quickstartScore: configured ? 1 : 0, - }; - }, - configure: async ({ cfg, prompter, accountOverrides, shouldPromptAccountIds }) => { - const defaultAccountId = resolveDefaultBlueBubblesAccountId(cfg); - const accountId = await resolveAccountIdForConfigure({ - cfg, - prompter, - label: "BlueBubbles", - accountOverride: accountOverrides.bluebubbles, - shouldPromptAccountIds, - listAccountIds: listBlueBubblesAccountIds, - defaultAccountId, - }); - - let next = cfg; - const resolvedAccount = resolveBlueBubblesAccount({ cfg: next, accountId }); - const validateServerUrlInput = (value: unknown): string | undefined => { - const trimmed = String(value ?? "").trim(); - if (!trimmed) { - return "Required"; - } - try { - const normalized = normalizeBlueBubblesServerUrl(trimmed); - new URL(normalized); - return undefined; - } catch { - return "Invalid URL format"; - } - }; - const promptServerUrl = async (initialValue?: string): Promise => { - const entered = await prompter.text({ - message: "BlueBubbles server URL", - placeholder: "http://192.168.1.100:1234", - initialValue, - validate: validateServerUrlInput, - }); - return String(entered).trim(); - }; - - // Prompt for server URL - let serverUrl = resolvedAccount.config.serverUrl?.trim(); - if (!serverUrl) { - await prompter.note( - [ - "Enter the BlueBubbles server URL (e.g., http://192.168.1.100:1234).", - "Find this in the BlueBubbles Server app under Connection.", - `Docs: ${formatDocsLink("/channels/bluebubbles", "bluebubbles")}`, - ].join("\n"), - "BlueBubbles server URL", - ); - serverUrl = await promptServerUrl(); - } else { - const keepUrl = await prompter.confirm({ - message: `BlueBubbles server URL already set (${serverUrl}). Keep it?`, - initialValue: true, - }); - if (!keepUrl) { - serverUrl = await promptServerUrl(serverUrl); - } - } - - // Prompt for password - const existingPassword = resolvedAccount.config.password; - const existingPasswordText = normalizeSecretInputString(existingPassword); - const hasConfiguredPassword = hasConfiguredSecretInput(existingPassword); - let password: unknown = existingPasswordText; - if (!hasConfiguredPassword) { - await prompter.note( - [ - "Enter the BlueBubbles server password.", - "Find this in the BlueBubbles Server app under Settings.", - ].join("\n"), - "BlueBubbles password", - ); - const entered = await prompter.text({ - message: "BlueBubbles password", - validate: (value) => (String(value ?? "").trim() ? undefined : "Required"), - }); - password = String(entered).trim(); - } else { - const keepPassword = await prompter.confirm({ - message: "BlueBubbles password already set. Keep it?", - initialValue: true, - }); - if (!keepPassword) { - const entered = await prompter.text({ - message: "BlueBubbles password", - validate: (value) => (String(value ?? "").trim() ? undefined : "Required"), - }); - password = String(entered).trim(); - } else if (!existingPasswordText) { - password = existingPassword; - } - } - - // Prompt for webhook path (optional) - const existingWebhookPath = resolvedAccount.config.webhookPath?.trim(); - const wantsWebhook = await prompter.confirm({ - message: "Configure a custom webhook path? (default: /bluebubbles-webhook)", - initialValue: Boolean(existingWebhookPath && existingWebhookPath !== "/bluebubbles-webhook"), - }); - let webhookPath = "/bluebubbles-webhook"; - if (wantsWebhook) { - const entered = await prompter.text({ - message: "Webhook path", - placeholder: "/bluebubbles-webhook", - initialValue: existingWebhookPath || "/bluebubbles-webhook", - validate: (value) => { - const trimmed = String(value ?? "").trim(); - if (!trimmed) { - return "Required"; - } - if (!trimmed.startsWith("/")) { - return "Path must start with /"; - } - return undefined; - }, - }); - webhookPath = String(entered).trim(); - } - - // Apply config - next = applyBlueBubblesConnectionConfig({ - cfg: next, - accountId, - patch: { - serverUrl, - password, - webhookPath, - }, - accountEnabled: "preserve-or-true", - }); - - await prompter.note( - [ - "Configure the webhook URL in BlueBubbles Server:", - "1. Open BlueBubbles Server → Settings → Webhooks", - "2. Add your OpenClaw gateway URL + webhook path", - " Example: https://your-gateway-host:3000/bluebubbles-webhook", - "3. Enable the webhook and save", - "", - `Docs: ${formatDocsLink("/channels/bluebubbles", "bluebubbles")}`, - ].join("\n"), - "BlueBubbles next steps", - ); - - return { cfg: next, accountId }; - }, - dmPolicy, - disable: (cfg) => ({ - ...cfg, - channels: { - ...cfg.channels, - bluebubbles: { ...cfg.channels?.bluebubbles, enabled: false }, - }, - }), -}; diff --git a/extensions/bluebubbles/src/setup-surface.test.ts b/extensions/bluebubbles/src/setup-surface.test.ts new file mode 100644 index 00000000000..bc9c93735b7 --- /dev/null +++ b/extensions/bluebubbles/src/setup-surface.test.ts @@ -0,0 +1,154 @@ +import { describe, expect, it, vi } from "vitest"; +import { buildChannelOnboardingAdapterFromSetupWizard } from "../../../src/channels/plugins/setup-wizard.js"; +import { DEFAULT_ACCOUNT_ID } from "../../../src/routing/session-key.js"; +import type { WizardPrompter } from "../../../src/wizard/prompts.js"; +import { resolveBlueBubblesAccount } from "./accounts.js"; +import { DEFAULT_WEBHOOK_PATH } from "./monitor-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: (cfg, accountId) => resolveBlueBubblesAccount({ cfg, accountId }), + resolveAllowFrom: ({ cfg, accountId }: { cfg: unknown; accountId: string }) => + resolveBlueBubblesAccount({ + cfg: cfg as Parameters[0]["cfg"], + accountId, + }).config.allowFrom ?? [], + }, + setup: blueBubblesSetupAdapter, + } as Parameters[0]["plugin"]; + return buildChannelOnboardingAdapterFromSetupWizard({ + plugin, + wizard: blueBubblesSetupWizard, + }); +} + +describe("bluebubbles setup surface", () => { + it("preserves existing password SecretRef and keeps default webhook path", async () => { + const adapter = await createBlueBubblesConfigureAdapter(); + type ConfigureContext = Parameters>[0]; + const passwordRef = { source: "env", provider: "default", id: "BLUEBUBBLES_PASSWORD" }; + const confirm = vi + .fn() + .mockResolvedValueOnce(false) + .mockResolvedValueOnce(true) + .mockResolvedValueOnce(true); + const text = vi.fn(); + const note = vi.fn(); + + const prompter = { confirm, text, note } as unknown as WizardPrompter; + const context = { + cfg: { + channels: { + bluebubbles: { + enabled: true, + serverUrl: "http://127.0.0.1:1234", + password: passwordRef, + }, + }, + }, + prompter, + runtime: { ...console, exit: vi.fn() } as ConfigureContext["runtime"], + forceAllowFrom: false, + accountOverrides: {}, + shouldPromptAccountIds: false, + } satisfies ConfigureContext; + + const result = await adapter.configure(context); + + 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 adapter = await createBlueBubblesConfigureAdapter(); + type ConfigureContext = Parameters>[0]; + const confirm = vi + .fn() + .mockResolvedValueOnce(true) + .mockResolvedValueOnce(true) + .mockResolvedValueOnce(true); + const text = vi.fn().mockResolvedValueOnce("/custom-bluebubbles"); + const note = vi.fn(); + + const prompter = { confirm, text, note } as unknown as WizardPrompter; + const context = { + cfg: { + channels: { + bluebubbles: { + enabled: true, + serverUrl: "http://127.0.0.1:1234", + password: "secret", + }, + }, + }, + prompter, + runtime: { ...console, exit: vi.fn() } as ConfigureContext["runtime"], + forceAllowFrom: false, + accountOverrides: {}, + shouldPromptAccountIds: false, + } satisfies ConfigureContext; + + const result = await adapter.configure(context); + + 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 adapter = await createBlueBubblesConfigureAdapter(); + type ConfigureContext = Parameters>[0]; + const confirm = vi.fn().mockResolvedValueOnce(false); + const text = vi.fn().mockResolvedValueOnce("127.0.0.1:1234").mockResolvedValueOnce("secret"); + const note = vi.fn(); + + const prompter = { confirm, text, note } as unknown as WizardPrompter; + const context = { + cfg: { channels: { bluebubbles: {} } }, + prompter, + runtime: { ...console, exit: vi.fn() } as ConfigureContext["runtime"], + forceAllowFrom: false, + accountOverrides: {}, + shouldPromptAccountIds: false, + } satisfies ConfigureContext; + + await adapter.configure(context); + + 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); + }); +}); diff --git a/extensions/bluebubbles/src/setup-surface.ts b/extensions/bluebubbles/src/setup-surface.ts new file mode 100644 index 00000000000..0cb23998663 --- /dev/null +++ b/extensions/bluebubbles/src/setup-surface.ts @@ -0,0 +1,385 @@ +import type { ChannelOnboardingDmPolicy } from "../../../src/channels/plugins/onboarding-types.js"; +import { + mergeAllowFromEntries, + resolveOnboardingAccountId, + setTopLevelChannelDmPolicyWithAllowFrom, +} from "../../../src/channels/plugins/onboarding/helpers.js"; +import { + applyAccountNameToChannelSection, + migrateBaseNameToDefaultAccount, + patchScopedAccountConfig, +} from "../../../src/channels/plugins/setup-helpers.js"; +import type { ChannelSetupWizard } from "../../../src/channels/plugins/setup-wizard.js"; +import type { ChannelSetupAdapter } from "../../../src/channels/plugins/types.adapters.js"; +import type { OpenClawConfig } from "../../../src/config/config.js"; +import type { DmPolicy } from "../../../src/config/types.js"; +import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "../../../src/routing/session-key.js"; +import { formatDocsLink } from "../../../src/terminal/links.js"; +import type { WizardPrompter } from "../../../src/wizard/prompts.js"; +import { + listBlueBubblesAccountIds, + resolveBlueBubblesAccount, + resolveDefaultBlueBubblesAccountId, +} from "./accounts.js"; +import { applyBlueBubblesConnectionConfig } from "./config-apply.js"; +import { DEFAULT_WEBHOOK_PATH } from "./monitor-shared.js"; +import { hasConfiguredSecretInput, normalizeSecretInputString } from "./secret-input.js"; +import { parseBlueBubblesAllowTarget } from "./targets.js"; +import { normalizeBlueBubblesServerUrl } from "./types.js"; + +const channel = "bluebubbles" as const; +const CONFIGURE_CUSTOM_WEBHOOK_FLAG = "__bluebubblesConfigureCustomWebhookPath"; + +function setBlueBubblesDmPolicy(cfg: OpenClawConfig, dmPolicy: DmPolicy): OpenClawConfig { + return setTopLevelChannelDmPolicyWithAllowFrom({ + cfg, + channel, + dmPolicy, + }); +} + +function setBlueBubblesAllowFrom( + cfg: OpenClawConfig, + accountId: string, + allowFrom: string[], +): OpenClawConfig { + return patchScopedAccountConfig({ + cfg, + channelKey: channel, + accountId, + patch: { allowFrom }, + ensureChannelEnabled: false, + ensureAccountEnabled: false, + }); +} + +function parseBlueBubblesAllowFromInput(raw: string): string[] { + return raw + .split(/[\n,]+/g) + .map((entry) => entry.trim()) + .filter(Boolean); +} + +function validateBlueBubblesAllowFromEntry(value: string): string | null { + try { + if (value === "*") { + return value; + } + const parsed = parseBlueBubblesAllowTarget(value); + if (parsed.kind === "handle" && !parsed.handle) { + return null; + } + return value.trim() || null; + } catch { + return null; + } +} + +async function promptBlueBubblesAllowFrom(params: { + cfg: OpenClawConfig; + prompter: WizardPrompter; + accountId?: string; +}): Promise { + const accountId = resolveOnboardingAccountId({ + accountId: params.accountId, + defaultAccountId: resolveDefaultBlueBubblesAccountId(params.cfg), + }); + const resolved = resolveBlueBubblesAccount({ cfg: params.cfg, accountId }); + const existing = resolved.config.allowFrom ?? []; + await params.prompter.note( + [ + "Allowlist BlueBubbles DMs by handle or chat target.", + "Examples:", + "- +15555550123", + "- user@example.com", + "- chat_id:123", + "- chat_guid:iMessage;-;+15555550123", + "Multiple entries: comma- or newline-separated.", + `Docs: ${formatDocsLink("/channels/bluebubbles", "bluebubbles")}`, + ].join("\n"), + "BlueBubbles allowlist", + ); + const entry = await params.prompter.text({ + message: "BlueBubbles allowFrom (handle or chat_id)", + placeholder: "+15555550123, user@example.com, chat_id:123", + initialValue: existing[0] ? String(existing[0]) : undefined, + validate: (value) => { + const raw = String(value ?? "").trim(); + if (!raw) { + return "Required"; + } + const parts = parseBlueBubblesAllowFromInput(raw); + for (const part of parts) { + if (!validateBlueBubblesAllowFromEntry(part)) { + return `Invalid entry: ${part}`; + } + } + return undefined; + }, + }); + const parts = parseBlueBubblesAllowFromInput(String(entry)); + const unique = mergeAllowFromEntries(undefined, parts); + return setBlueBubblesAllowFrom(params.cfg, accountId, unique); +} + +function validateBlueBubblesServerUrlInput(value: unknown): string | undefined { + const trimmed = String(value ?? "").trim(); + if (!trimmed) { + return "Required"; + } + try { + const normalized = normalizeBlueBubblesServerUrl(trimmed); + new URL(normalized); + return undefined; + } catch { + return "Invalid URL format"; + } +} + +function applyBlueBubblesSetupPatch( + cfg: OpenClawConfig, + accountId: string, + patch: { + serverUrl?: string; + password?: unknown; + webhookPath?: string; + }, +): OpenClawConfig { + return applyBlueBubblesConnectionConfig({ + cfg, + accountId, + patch, + onlyDefinedFields: true, + accountEnabled: "preserve-or-true", + }); +} + +function resolveBlueBubblesServerUrl(cfg: OpenClawConfig, accountId: string): string | undefined { + return resolveBlueBubblesAccount({ cfg, accountId }).config.serverUrl?.trim() || undefined; +} + +function resolveBlueBubblesWebhookPath(cfg: OpenClawConfig, accountId: string): string | undefined { + return resolveBlueBubblesAccount({ cfg, accountId }).config.webhookPath?.trim() || undefined; +} + +function validateBlueBubblesWebhookPath(value: string): string | undefined { + const trimmed = String(value ?? "").trim(); + if (!trimmed) { + return "Required"; + } + if (!trimmed.startsWith("/")) { + return "Path must start with /"; + } + return undefined; +} + +const dmPolicy: ChannelOnboardingDmPolicy = { + label: "BlueBubbles", + channel, + policyKey: "channels.bluebubbles.dmPolicy", + allowFromKey: "channels.bluebubbles.allowFrom", + getCurrent: (cfg) => cfg.channels?.bluebubbles?.dmPolicy ?? "pairing", + setPolicy: (cfg, policy) => setBlueBubblesDmPolicy(cfg, policy), + promptAllowFrom: promptBlueBubblesAllowFrom, +}; + +export const blueBubblesSetupAdapter: ChannelSetupAdapter = { + resolveAccountId: ({ accountId }) => normalizeAccountId(accountId), + applyAccountName: ({ cfg, accountId, name }) => + applyAccountNameToChannelSection({ + cfg, + channelKey: channel, + accountId, + name, + }), + validateInput: ({ input }) => { + if (!input.httpUrl && !input.password) { + return "BlueBubbles requires --http-url and --password."; + } + if (!input.httpUrl) { + return "BlueBubbles requires --http-url."; + } + if (!input.password) { + return "BlueBubbles requires --password."; + } + return null; + }, + applyAccountConfig: ({ cfg, accountId, input }) => { + const namedConfig = applyAccountNameToChannelSection({ + cfg, + channelKey: channel, + accountId, + name: input.name, + }); + const next = + accountId !== DEFAULT_ACCOUNT_ID + ? migrateBaseNameToDefaultAccount({ + cfg: namedConfig, + channelKey: channel, + }) + : namedConfig; + return applyBlueBubblesConnectionConfig({ + cfg: next, + accountId, + patch: { + serverUrl: input.httpUrl, + password: input.password, + webhookPath: input.webhookPath, + }, + onlyDefinedFields: true, + }); + }, +}; + +export const blueBubblesSetupWizard: ChannelSetupWizard = { + channel, + stepOrder: "text-first", + status: { + configuredLabel: "configured", + unconfiguredLabel: "needs setup", + configuredHint: "configured", + unconfiguredHint: "iMessage via BlueBubbles app", + configuredScore: 1, + unconfiguredScore: 0, + resolveConfigured: ({ cfg }) => + listBlueBubblesAccountIds(cfg).some((accountId) => { + const account = resolveBlueBubblesAccount({ cfg, accountId }); + return account.configured; + }), + resolveStatusLines: ({ configured }) => [ + `BlueBubbles: ${configured ? "configured" : "needs setup"}`, + ], + resolveSelectionHint: ({ configured }) => + configured ? "configured" : "iMessage via BlueBubbles app", + }, + prepare: async ({ cfg, accountId, prompter, credentialValues }) => { + const existingWebhookPath = resolveBlueBubblesWebhookPath(cfg, accountId); + const wantsCustomWebhook = await prompter.confirm({ + message: `Configure a custom webhook path? (default: ${DEFAULT_WEBHOOK_PATH})`, + initialValue: Boolean(existingWebhookPath && existingWebhookPath !== DEFAULT_WEBHOOK_PATH), + }); + return { + cfg: wantsCustomWebhook + ? cfg + : applyBlueBubblesSetupPatch(cfg, accountId, { webhookPath: DEFAULT_WEBHOOK_PATH }), + credentialValues: { + ...credentialValues, + [CONFIGURE_CUSTOM_WEBHOOK_FLAG]: wantsCustomWebhook ? "1" : "0", + }, + }; + }, + credentials: [ + { + inputKey: "password", + providerHint: channel, + credentialLabel: "server password", + helpTitle: "BlueBubbles password", + helpLines: [ + "Enter the BlueBubbles server password.", + "Find this in the BlueBubbles Server app under Settings.", + ], + envPrompt: "", + keepPrompt: "BlueBubbles password already set. Keep it?", + inputPrompt: "BlueBubbles password", + inspect: ({ cfg, accountId }) => { + const existingPassword = resolveBlueBubblesAccount({ cfg, accountId }).config.password; + return { + accountConfigured: resolveBlueBubblesAccount({ cfg, accountId }).configured, + hasConfiguredValue: hasConfiguredSecretInput(existingPassword), + resolvedValue: normalizeSecretInputString(existingPassword) ?? undefined, + }; + }, + applySet: async ({ cfg, accountId, value }) => + applyBlueBubblesSetupPatch(cfg, accountId, { + password: value, + }), + }, + ], + textInputs: [ + { + inputKey: "httpUrl", + message: "BlueBubbles server URL", + placeholder: "http://192.168.1.100:1234", + helpTitle: "BlueBubbles server URL", + helpLines: [ + "Enter the BlueBubbles server URL (e.g., http://192.168.1.100:1234).", + "Find this in the BlueBubbles Server app under Connection.", + `Docs: ${formatDocsLink("/channels/bluebubbles", "bluebubbles")}`, + ], + currentValue: ({ cfg, accountId }) => resolveBlueBubblesServerUrl(cfg, accountId), + validate: ({ value }) => validateBlueBubblesServerUrlInput(value), + normalizeValue: ({ value }) => String(value).trim(), + applySet: async ({ cfg, accountId, value }) => + applyBlueBubblesSetupPatch(cfg, accountId, { + serverUrl: value, + }), + }, + { + inputKey: "webhookPath", + message: "Webhook path", + placeholder: DEFAULT_WEBHOOK_PATH, + currentValue: ({ cfg, accountId }) => { + const value = resolveBlueBubblesWebhookPath(cfg, accountId); + return value && value !== DEFAULT_WEBHOOK_PATH ? value : undefined; + }, + shouldPrompt: ({ credentialValues }) => + credentialValues[CONFIGURE_CUSTOM_WEBHOOK_FLAG] === "1", + validate: ({ value }) => validateBlueBubblesWebhookPath(value), + normalizeValue: ({ value }) => String(value).trim(), + applySet: async ({ cfg, accountId, value }) => + applyBlueBubblesSetupPatch(cfg, accountId, { + webhookPath: value, + }), + }, + ], + completionNote: { + title: "BlueBubbles next steps", + lines: [ + "Configure the webhook URL in BlueBubbles Server:", + "1. Open BlueBubbles Server -> Settings -> Webhooks", + "2. Add your OpenClaw gateway URL + webhook path", + ` Example: https://your-gateway-host:3000${DEFAULT_WEBHOOK_PATH}`, + "3. Enable the webhook and save", + "", + `Docs: ${formatDocsLink("/channels/bluebubbles", "bluebubbles")}`, + ], + }, + dmPolicy, + allowFrom: { + helpTitle: "BlueBubbles allowlist", + helpLines: [ + "Allowlist BlueBubbles DMs by handle or chat target.", + "Examples:", + "- +15555550123", + "- user@example.com", + "- chat_id:123", + "- chat_guid:iMessage;-;+15555550123", + "Multiple entries: comma- or newline-separated.", + `Docs: ${formatDocsLink("/channels/bluebubbles", "bluebubbles")}`, + ], + message: "BlueBubbles allowFrom (handle or chat_id)", + placeholder: "+15555550123, user@example.com, chat_id:123", + invalidWithoutCredentialNote: + "Use a BlueBubbles handle or chat target like +15555550123 or chat_id:123.", + parseInputs: parseBlueBubblesAllowFromInput, + parseId: (raw) => validateBlueBubblesAllowFromEntry(raw), + resolveEntries: async ({ entries }) => + entries.map((entry) => ({ + input: entry, + resolved: Boolean(validateBlueBubblesAllowFromEntry(entry)), + id: validateBlueBubblesAllowFromEntry(entry), + })), + apply: async ({ cfg, accountId, allowFrom }) => + setBlueBubblesAllowFrom(cfg, accountId, allowFrom), + }, + disable: (cfg) => ({ + ...cfg, + channels: { + ...cfg.channels, + bluebubbles: { + ...cfg.channels?.bluebubbles, + enabled: false, + }, + }, + }), +}; diff --git a/src/plugin-sdk/bluebubbles.ts b/src/plugin-sdk/bluebubbles.ts index 02619206fce..dff21c19bd7 100644 --- a/src/plugin-sdk/bluebubbles.ts +++ b/src/plugin-sdk/bluebubbles.ts @@ -31,10 +31,6 @@ export { } from "../channels/plugins/group-mentions.js"; export { formatPairingApproveHint } from "../channels/plugins/helpers.js"; export { resolveChannelMediaMaxBytes } from "../channels/plugins/media-limits.js"; -export type { - ChannelOnboardingAdapter, - ChannelOnboardingDmPolicy, -} from "../channels/plugins/onboarding-types.js"; export { addWildcardAllowFrom, mergeAllowFromEntries,