mirror of
https://github.com/moltbot/moltbot.git
synced 2026-03-29 16:54:30 +00:00
refactor: move bluebubbles to setup wizard
This commit is contained in:
@@ -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<ResolvedBlueBubblesAccount> = {
|
||||
},
|
||||
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<ResolvedBlueBubblesAccount> = {
|
||||
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, "")),
|
||||
|
||||
@@ -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<typeof blueBubblesOnboardingAdapter.configure>
|
||||
>[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();
|
||||
});
|
||||
});
|
||||
@@ -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<OpenClawConfig> {
|
||||
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<string> => {
|
||||
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 },
|
||||
},
|
||||
}),
|
||||
};
|
||||
154
extensions/bluebubbles/src/setup-surface.test.ts
Normal file
154
extensions/bluebubbles/src/setup-surface.test.ts
Normal file
@@ -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<typeof resolveBlueBubblesAccount>[0]["cfg"],
|
||||
accountId,
|
||||
}).config.allowFrom ?? [],
|
||||
},
|
||||
setup: blueBubblesSetupAdapter,
|
||||
} as Parameters<typeof buildChannelOnboardingAdapterFromSetupWizard>[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<NonNullable<typeof adapter.configure>>[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<NonNullable<typeof adapter.configure>>[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<NonNullable<typeof adapter.configure>>[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);
|
||||
});
|
||||
});
|
||||
385
extensions/bluebubbles/src/setup-surface.ts
Normal file
385
extensions/bluebubbles/src/setup-surface.ts
Normal file
@@ -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<OpenClawConfig> {
|
||||
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,
|
||||
},
|
||||
},
|
||||
}),
|
||||
};
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user