import { mergeAllowFromEntries, resolveSetupAccountId, } from "../../../src/channels/plugins/setup-wizard-helpers.js"; import type { ChannelSetupDmPolicy } from "../../../src/channels/plugins/setup-wizard-types.js"; import type { ChannelSetupWizard } from "../../../src/channels/plugins/setup-wizard.js"; import type { OpenClawConfig } from "../../../src/config/config.js"; import type { DmPolicy } from "../../../src/config/types.js"; import { DEFAULT_ACCOUNT_ID } 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 { blueBubblesSetupAdapter, setBlueBubblesAllowFrom, setBlueBubblesDmPolicy, } from "./setup-core.js"; import { parseBlueBubblesAllowTarget } from "./targets.js"; import { normalizeBlueBubblesServerUrl } from "./types.js"; const channel = "bluebubbles" as const; const CONFIGURE_CUSTOM_WEBHOOK_FLAG = "__bluebubblesConfigureCustomWebhookPath"; 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 = resolveSetupAccountId({ 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: ChannelSetupDmPolicy = { 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 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, }, }, }), }; export { blueBubblesSetupAdapter };