openclaw/extensions/bluebubbles/src/setup-surface.ts

315 lines
11 KiB
TypeScript

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<OpenClawConfig> {
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 };