mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-05 12:30:22 +00:00
refactor: move bluebubbles to setup wizard
This commit is contained in:
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,
|
||||
},
|
||||
},
|
||||
}),
|
||||
};
|
||||
Reference in New Issue
Block a user