diff --git a/extensions/bluebubbles/src/setup-surface.ts b/extensions/bluebubbles/src/setup-surface.ts index f6922ed4861..823b49908c8 100644 --- a/extensions/bluebubbles/src/setup-surface.ts +++ b/extensions/bluebubbles/src/setup-surface.ts @@ -1,11 +1,10 @@ import { + createAllowFromSection, DEFAULT_ACCOUNT_ID, formatDocsLink, - mergeAllowFromEntries, - resolveSetupAccountId, + promptParsedAllowFromForAccount, type ChannelSetupDmPolicy, type ChannelSetupWizard, - type DmPolicy, type OpenClawConfig, type WizardPrompter, } from "openclaw/plugin-sdk/setup"; @@ -55,14 +54,13 @@ async function promptBlueBubblesAllowFrom(params: { prompter: WizardPrompter; accountId?: string; }): Promise { - const accountId = resolveSetupAccountId({ + return await promptParsedAllowFromForAccount({ + cfg: params.cfg, accountId: params.accountId, defaultAccountId: resolveDefaultBlueBubblesAccountId(params.cfg), - }); - const resolved = resolveBlueBubblesAccount({ cfg: params.cfg, accountId }); - const existing = resolved.config.allowFrom ?? []; - await params.prompter.note( - [ + prompter: params.prompter, + noteTitle: "BlueBubbles allowlist", + noteLines: [ "Allowlist BlueBubbles DMs by handle or chat target.", "Examples:", "- +15555550123", @@ -71,30 +69,23 @@ async function promptBlueBubblesAllowFrom(params: { "- 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}`; + parseEntries: (raw) => { + const entries = parseBlueBubblesAllowFromInput(raw); + for (const entry of entries) { + if (!validateBlueBubblesAllowFromEntry(entry)) { + return { entries: [], error: `Invalid entry: ${entry}` }; } } - return undefined; + return { entries }; }, + getExistingAllowFrom: ({ cfg, accountId }) => + resolveBlueBubblesAccount({ cfg, accountId }).config.allowFrom ?? [], + applyAllowFrom: ({ cfg, accountId, allowFrom }) => + setBlueBubblesAllowFrom(cfg, accountId, allowFrom), }); - const parts = parseBlueBubblesAllowFromInput(String(entry)); - const unique = mergeAllowFromEntries(undefined, parts); - return setBlueBubblesAllowFrom(params.cfg, accountId, unique); } function validateBlueBubblesServerUrlInput(value: unknown): string | undefined { @@ -272,7 +263,7 @@ export const blueBubblesSetupWizard: ChannelSetupWizard = { ], }, dmPolicy, - allowFrom: { + allowFrom: createAllowFromSection({ helpTitle: "BlueBubbles allowlist", helpLines: [ "Allowlist BlueBubbles DMs by handle or chat target.", @@ -290,15 +281,9 @@ export const blueBubblesSetupWizard: ChannelSetupWizard = { "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: { diff --git a/extensions/discord/src/setup-core.ts b/extensions/discord/src/setup-core.ts index ba0ba5e66be..7e82a9bcc35 100644 --- a/extensions/discord/src/setup-core.ts +++ b/extensions/discord/src/setup-core.ts @@ -191,11 +191,9 @@ export function createDiscordSetupWizardBase(handlers: { disable: (cfg: OpenClawConfig) => setSetupChannelEnabled(cfg, channel, false), } satisfies ChannelSetupWizard; } -export function createDiscordSetupWizardProxy( - loadWizard: () => Promise<{ discordSetupWizard: ChannelSetupWizard }>, -) { +export function createDiscordSetupWizardProxy(loadWizard: () => Promise) { return createAllowlistSetupWizardProxy({ - loadWizard: async () => (await loadWizard()).discordSetupWizard, + loadWizard, createBase: createDiscordSetupWizardBase, fallbackResolvedGroupAllowlist: (entries) => entries.map((input) => ({ input, resolved: false })), diff --git a/extensions/discord/src/shared.ts b/extensions/discord/src/shared.ts index 08b37a8ec41..242d2d163a7 100644 --- a/extensions/discord/src/shared.ts +++ b/extensions/discord/src/shared.ts @@ -23,9 +23,9 @@ async function loadDiscordChannelRuntime() { return await import("./channel.runtime.js"); } -export const discordSetupWizard = createDiscordSetupWizardProxy(async () => ({ - discordSetupWizard: (await loadDiscordChannelRuntime()).discordSetupWizard, -})); +export const discordSetupWizard = createDiscordSetupWizardProxy( + async () => (await loadDiscordChannelRuntime()).discordSetupWizard, +); export const discordConfigAccessors = createScopedAccountConfigAccessors({ resolveAccount: ({ cfg, accountId }) => resolveDiscordAccount({ cfg, accountId }), diff --git a/extensions/feishu/src/setup-surface.ts b/extensions/feishu/src/setup-surface.ts index 9a98f171bca..6dcb770bd0c 100644 --- a/extensions/feishu/src/setup-surface.ts +++ b/extensions/feishu/src/setup-surface.ts @@ -8,6 +8,7 @@ import { hasConfiguredSecretInput, mergeAllowFromEntries, patchTopLevelChannelConfigSection, + promptParsedAllowFromForAccount, promptSingleChannelSecretInput, splitSetupEntries, type ChannelSetupDmPolicy, @@ -96,34 +97,25 @@ async function promptFeishuAllowFrom(params: { cfg: OpenClawConfig; prompter: Parameters>[0]["prompter"]; }): Promise { - const existing = params.cfg.channels?.feishu?.allowFrom ?? []; - await params.prompter.note( - [ + return await promptParsedAllowFromForAccount({ + cfg: params.cfg, + defaultAccountId: DEFAULT_ACCOUNT_ID, + prompter: params.prompter, + noteTitle: "Feishu allowlist", + noteLines: [ "Allowlist Feishu DMs by open_id or user_id.", "You can find user open_id in Feishu admin console or via API.", "Examples:", "- ou_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", "- on_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", - ].join("\n"), - "Feishu allowlist", - ); - - while (true) { - const entry = await params.prompter.text({ - message: "Feishu allowFrom (user open_ids)", - placeholder: "ou_xxxxx, ou_yyyyy", - initialValue: existing[0] ? String(existing[0]) : undefined, - validate: (value) => (String(value ?? "").trim() ? undefined : "Required"), - }); - const parts = splitSetupEntries(String(entry)); - if (parts.length === 0) { - await params.prompter.note("Enter at least one user.", "Feishu allowlist"); - continue; - } - - const unique = mergeAllowFromEntries(existing, parts); - return setFeishuAllowFrom(params.cfg, unique); - } + ], + message: "Feishu allowFrom (user open_ids)", + placeholder: "ou_xxxxx, ou_yyyyy", + parseEntries: (raw) => ({ entries: splitSetupEntries(raw) }), + getExistingAllowFrom: ({ cfg }) => cfg.channels?.feishu?.allowFrom ?? [], + mergeEntries: ({ existing, parsed }) => mergeAllowFromEntries(existing, parsed), + applyAllowFrom: ({ cfg, allowFrom }) => setFeishuAllowFrom(cfg, allowFrom), + }); } async function noteFeishuCredentialHelp( diff --git a/extensions/imessage/src/channel.ts b/extensions/imessage/src/channel.ts index 5178f1f883c..b287cb79c54 100644 --- a/extensions/imessage/src/channel.ts +++ b/extensions/imessage/src/channel.ts @@ -1,8 +1,4 @@ import { buildAccountScopedAllowlistConfigEditor } from "openclaw/plugin-sdk/allowlist-config-edit"; -import { - createScopedDmSecurityResolver, - collectAllowlistProviderRestrictSendersWarnings, -} from "openclaw/plugin-sdk/channel-config-helpers"; import { resolveOutboundSendDep } from "openclaw/plugin-sdk/channel-runtime"; import { buildOutboundBaseSessionKey } from "openclaw/plugin-sdk/core"; import { @@ -23,18 +19,16 @@ import { } from "./group-policy.js"; import { getIMessageRuntime } from "./runtime.js"; import { imessageSetupAdapter } from "./setup-core.js"; -import { createIMessagePluginBase, imessageSetupWizard } from "./shared.js"; +import { + collectIMessageSecurityWarnings, + createIMessagePluginBase, + imessageResolveDmPolicy, + imessageSetupWizard, +} from "./shared.js"; import { normalizeIMessageHandle, parseIMessageTarget } from "./targets.js"; const loadIMessageChannelRuntime = createLazyRuntimeModule(() => import("./channel.runtime.js")); -const resolveIMessageDmPolicy = createScopedDmSecurityResolver({ - channelKey: "imessage", - resolvePolicy: (account) => account.config.dmPolicy, - resolveAllowFrom: (account) => account.config.allowFrom, - policyPathSuffix: "dmPolicy", -}); - function buildIMessageBaseSessionKey(params: { cfg: Parameters[0]["cfg"]; agentId: string; @@ -136,19 +130,8 @@ export const imessagePlugin: ChannelPlugin = { }), }, security: { - resolveDmPolicy: resolveIMessageDmPolicy, - collectWarnings: ({ account, cfg }) => { - return collectAllowlistProviderRestrictSendersWarnings({ - cfg, - providerConfigPresent: cfg.channels?.imessage !== undefined, - configuredGroupPolicy: account.config.groupPolicy, - surface: "iMessage groups", - openScope: "any member", - groupPolicyPath: "channels.imessage.groupPolicy", - groupAllowFromPath: "channels.imessage.groupAllowFrom", - mentionGated: false, - }); - }, + resolveDmPolicy: imessageResolveDmPolicy, + collectWarnings: collectIMessageSecurityWarnings, }, groups: { resolveRequireMention: resolveIMessageGroupRequireMention, diff --git a/extensions/imessage/src/setup-core.ts b/extensions/imessage/src/setup-core.ts index 6ea7382106a..f78ccde9d7d 100644 --- a/extensions/imessage/src/setup-core.ts +++ b/extensions/imessage/src/setup-core.ts @@ -1,7 +1,11 @@ import { + createCliPathTextInput, + createDelegatedSetupWizardProxy, + createDelegatedTextInputShouldPrompt, createPatchedAccountSetupAdapter, parseSetupEntriesAllowingWildcard, - promptParsedAllowFromForScopedChannel, + promptParsedAllowFromForAccount, + setAccountAllowFromForChannel, setChannelDmPolicyWithAllowFrom, setSetupChannelEnabled, type OpenClawConfig, @@ -71,9 +75,8 @@ export async function promptIMessageAllowFrom(params: { prompter: WizardPrompter; accountId?: string; }): Promise { - return promptParsedAllowFromForScopedChannel({ + return promptParsedAllowFromForAccount({ cfg: params.cfg, - channel, accountId: params.accountId, defaultAccountId: resolveDefaultIMessageAccountId(params.cfg), prompter: params.prompter, @@ -93,6 +96,13 @@ export async function promptIMessageAllowFrom(params: { parseEntries: parseIMessageAllowFromEntries, getExistingAllowFrom: ({ cfg, accountId }) => resolveIMessageAccount({ cfg, accountId }).config.allowFrom ?? [], + applyAllowFrom: ({ cfg, accountId, allowFrom }) => + setAccountAllowFromForChannel({ + cfg, + channel, + accountId, + allowFrom, + }), }); } @@ -118,17 +128,14 @@ function resolveIMessageCliPath(params: { cfg: OpenClawConfig; accountId: string export function createIMessageCliPathTextInput( shouldPrompt: NonNullable, ): ChannelSetupWizardTextInput { - return { + return createCliPathTextInput({ inputKey: "cliPath", message: "imsg CLI path", - initialValue: ({ cfg, accountId }) => resolveIMessageCliPath({ cfg, accountId }), - currentValue: ({ cfg, accountId }) => resolveIMessageCliPath({ cfg, accountId }), + resolvePath: ({ cfg, accountId }) => resolveIMessageCliPath({ cfg, accountId }), shouldPrompt, - confirmCurrentValue: false, - applyCurrentValue: true, helpTitle: "iMessage", helpLines: ["imsg CLI path required to enable iMessage."], - }; + }); } export const imessageCompletionNote = { @@ -167,31 +174,29 @@ export const imessageSetupStatusBase = { }), }; -export function createIMessageSetupWizardProxy( - loadWizard: () => Promise<{ imessageSetupWizard: ChannelSetupWizard }>, -) { - return { +export function createIMessageSetupWizardProxy(loadWizard: () => Promise) { + return createDelegatedSetupWizardProxy({ channel, + loadWizard, status: { - ...imessageSetupStatusBase, - resolveStatusLines: async (params) => - (await loadWizard()).imessageSetupWizard.status.resolveStatusLines?.(params) ?? [], - resolveSelectionHint: async (params) => - await (await loadWizard()).imessageSetupWizard.status.resolveSelectionHint?.(params), - resolveQuickstartScore: async (params) => - await (await loadWizard()).imessageSetupWizard.status.resolveQuickstartScore?.(params), + configuredLabel: imessageSetupStatusBase.configuredLabel, + unconfiguredLabel: imessageSetupStatusBase.unconfiguredLabel, + configuredHint: imessageSetupStatusBase.configuredHint, + unconfiguredHint: imessageSetupStatusBase.unconfiguredHint, + configuredScore: imessageSetupStatusBase.configuredScore, + unconfiguredScore: imessageSetupStatusBase.unconfiguredScore, }, credentials: [], textInputs: [ - createIMessageCliPathTextInput(async (params) => { - const input = (await loadWizard()).imessageSetupWizard.textInputs?.find( - (entry) => entry.inputKey === "cliPath", - ); - return (await input?.shouldPrompt?.(params)) ?? false; - }), + createIMessageCliPathTextInput( + createDelegatedTextInputShouldPrompt({ + loadWizard, + inputKey: "cliPath", + }), + ), ], completionNote: imessageCompletionNote, dmPolicy: imessageDmPolicy, disable: (cfg: OpenClawConfig) => setSetupChannelEnabled(cfg, channel, false), - } satisfies ChannelSetupWizard; + }); } diff --git a/extensions/imessage/src/setup-surface.ts b/extensions/imessage/src/setup-surface.ts index ae6cdb2fcc1..9e5f29d7588 100644 --- a/extensions/imessage/src/setup-surface.ts +++ b/extensions/imessage/src/setup-surface.ts @@ -1,4 +1,8 @@ -import { setSetupChannelEnabled, type ChannelSetupWizard } from "openclaw/plugin-sdk/setup"; +import { + createDetectedBinaryStatus, + setSetupChannelEnabled, + type ChannelSetupWizard, +} from "openclaw/plugin-sdk/setup"; import { detectBinary } from "openclaw/plugin-sdk/setup-tools"; import { listIMessageAccountIds, resolveIMessageAccount } from "./accounts.js"; import { @@ -14,25 +18,19 @@ const channel = "imessage" as const; export const imessageSetupWizard: ChannelSetupWizard = { channel, - status: { - ...imessageSetupStatusBase, - resolveStatusLines: async ({ cfg, configured }) => { - const cliPath = cfg.channels?.imessage?.cliPath ?? "imsg"; - const cliDetected = await detectBinary(cliPath); - return [ - `iMessage: ${configured ? "configured" : "needs setup"}`, - `imsg: ${cliDetected ? "found" : "missing"} (${cliPath})`, - ]; - }, - resolveSelectionHint: async ({ cfg }) => { - const cliPath = cfg.channels?.imessage?.cliPath ?? "imsg"; - return (await detectBinary(cliPath)) ? "imsg found" : "imsg missing"; - }, - resolveQuickstartScore: async ({ cfg }) => { - const cliPath = cfg.channels?.imessage?.cliPath ?? "imsg"; - return (await detectBinary(cliPath)) ? 1 : 0; - }, - }, + status: createDetectedBinaryStatus({ + channelLabel: "iMessage", + binaryLabel: "imsg", + configuredLabel: imessageSetupStatusBase.configuredLabel, + unconfiguredLabel: imessageSetupStatusBase.unconfiguredLabel, + configuredHint: imessageSetupStatusBase.configuredHint, + unconfiguredHint: imessageSetupStatusBase.unconfiguredHint, + configuredScore: imessageSetupStatusBase.configuredScore, + unconfiguredScore: imessageSetupStatusBase.unconfiguredScore, + resolveConfigured: imessageSetupStatusBase.resolveConfigured, + resolveBinaryPath: ({ cfg }) => cfg.channels?.imessage?.cliPath ?? "imsg", + detectBinary, + }), credentials: [], textInputs: [ createIMessageCliPathTextInput(async ({ currentValue }) => { diff --git a/extensions/imessage/src/shared.ts b/extensions/imessage/src/shared.ts index 5f38cd9ef84..6ea2f29d2c4 100644 --- a/extensions/imessage/src/shared.ts +++ b/extensions/imessage/src/shared.ts @@ -1,18 +1,14 @@ import { - buildAccountScopedDmSecurityPolicy, collectAllowlistProviderRestrictSendersWarnings, -} from "openclaw/plugin-sdk/channel-policy"; + createScopedAccountConfigAccessors, + createScopedChannelConfigBase, + createScopedDmSecurityResolver, +} from "openclaw/plugin-sdk/channel-config-helpers"; import { createChannelPluginBase } from "openclaw/plugin-sdk/core"; import { buildChannelConfigSchema, - DEFAULT_ACCOUNT_ID, - deleteAccountFromConfigSection, - formatTrimmedAllowFromEntries, getChatChannelMeta, IMessageConfigSchema, - resolveIMessageConfigAllowFrom, - resolveIMessageConfigDefaultTo, - setAccountEnabledInConfigSection, type ChannelPlugin, } from "openclaw/plugin-sdk/imessage-core"; import { @@ -29,9 +25,47 @@ async function loadIMessageChannelRuntime() { return await import("./channel.runtime.js"); } -export const imessageSetupWizard = createIMessageSetupWizardProxy(async () => ({ - imessageSetupWizard: (await loadIMessageChannelRuntime()).imessageSetupWizard, -})); +export const imessageSetupWizard = createIMessageSetupWizardProxy( + async () => (await loadIMessageChannelRuntime()).imessageSetupWizard, +); + +export const imessageConfigAccessors = createScopedAccountConfigAccessors({ + resolveAccount: ({ cfg, accountId }) => resolveIMessageAccount({ cfg, accountId }), + resolveAllowFrom: (account: ResolvedIMessageAccount) => account.config.allowFrom, + formatAllowFrom: (allowFrom) => allowFrom.map((entry) => String(entry).trim()).filter(Boolean), + resolveDefaultTo: (account: ResolvedIMessageAccount) => account.config.defaultTo, +}); + +export const imessageConfigBase = createScopedChannelConfigBase({ + sectionKey: IMESSAGE_CHANNEL, + listAccountIds: listIMessageAccountIds, + resolveAccount: (cfg, accountId) => resolveIMessageAccount({ cfg, accountId }), + defaultAccountId: resolveDefaultIMessageAccountId, + clearBaseFields: ["cliPath", "dbPath", "service", "region", "name"], +}); + +export const imessageResolveDmPolicy = createScopedDmSecurityResolver({ + channelKey: IMESSAGE_CHANNEL, + resolvePolicy: (account) => account.config.dmPolicy, + resolveAllowFrom: (account) => account.config.allowFrom, + policyPathSuffix: "dmPolicy", +}); + +export function collectIMessageSecurityWarnings(params: { + account: ResolvedIMessageAccount; + cfg: Parameters[0]["cfg"]; +}) { + return collectAllowlistProviderRestrictSendersWarnings({ + cfg: params.cfg, + providerConfigPresent: params.cfg.channels?.imessage !== undefined, + configuredGroupPolicy: params.account.config.groupPolicy, + surface: "iMessage groups", + openScope: "any member", + groupPolicyPath: "channels.imessage.groupPolicy", + groupAllowFromPath: "channels.imessage.groupAllowFrom", + mentionGated: false, + }); +} export function createIMessagePluginBase(params: { setupWizard?: NonNullable["setupWizard"]>; @@ -63,24 +97,7 @@ export function createIMessagePluginBase(params: { reload: { configPrefixes: ["channels.imessage"] }, configSchema: buildChannelConfigSchema(IMessageConfigSchema), config: { - listAccountIds: (cfg) => listIMessageAccountIds(cfg), - resolveAccount: (cfg, accountId) => resolveIMessageAccount({ cfg, accountId }), - defaultAccountId: (cfg) => resolveDefaultIMessageAccountId(cfg), - setAccountEnabled: ({ cfg, accountId, enabled }) => - setAccountEnabledInConfigSection({ - cfg, - sectionKey: IMESSAGE_CHANNEL, - accountId, - enabled, - allowTopLevel: true, - }), - deleteAccount: ({ cfg, accountId }) => - deleteAccountFromConfigSection({ - cfg, - sectionKey: IMESSAGE_CHANNEL, - accountId, - clearBaseFields: ["cliPath", "dbPath", "service", "region", "name"], - }), + ...imessageConfigBase, isConfigured: (account) => account.configured, describeAccount: (account) => ({ accountId: account.accountId, @@ -88,32 +105,11 @@ export function createIMessagePluginBase(params: { enabled: account.enabled, configured: account.configured, }), - resolveAllowFrom: ({ cfg, accountId }) => resolveIMessageConfigAllowFrom({ cfg, accountId }), - formatAllowFrom: ({ allowFrom }) => formatTrimmedAllowFromEntries(allowFrom), - resolveDefaultTo: ({ cfg, accountId }) => resolveIMessageConfigDefaultTo({ cfg, accountId }), + ...imessageConfigAccessors, }, security: { - resolveDmPolicy: ({ cfg, accountId, account }) => - buildAccountScopedDmSecurityPolicy({ - cfg, - channelKey: IMESSAGE_CHANNEL, - accountId, - fallbackAccountId: account.accountId ?? DEFAULT_ACCOUNT_ID, - policy: account.config.dmPolicy, - allowFrom: account.config.allowFrom ?? [], - policyPathSuffix: "dmPolicy", - }), - collectWarnings: ({ account, cfg }) => - collectAllowlistProviderRestrictSendersWarnings({ - cfg, - providerConfigPresent: cfg.channels?.imessage !== undefined, - configuredGroupPolicy: account.config.groupPolicy, - surface: "iMessage groups", - openScope: "any member", - groupPolicyPath: "channels.imessage.groupPolicy", - groupAllowFromPath: "channels.imessage.groupAllowFrom", - mentionGated: false, - }), + resolveDmPolicy: imessageResolveDmPolicy, + collectWarnings: collectIMessageSecurityWarnings, }, setup: params.setup, }) as Pick< diff --git a/extensions/irc/src/setup-surface.ts b/extensions/irc/src/setup-surface.ts index cdadcffbaec..ef86fbf7052 100644 --- a/extensions/irc/src/setup-surface.ts +++ b/extensions/irc/src/setup-surface.ts @@ -1,6 +1,10 @@ import type { DmPolicy } from "openclaw/plugin-sdk/config-runtime"; import { DEFAULT_ACCOUNT_ID } from "openclaw/plugin-sdk/routing"; -import { resolveSetupAccountId, setSetupChannelEnabled } from "openclaw/plugin-sdk/setup"; +import { + createAllowFromSection, + promptParsedAllowFromForAccount, + setSetupChannelEnabled, +} from "openclaw/plugin-sdk/setup"; import type { ChannelSetupDmPolicy } from "openclaw/plugin-sdk/setup"; import type { ChannelSetupWizard } from "openclaw/plugin-sdk/setup"; import { formatDocsLink } from "openclaw/plugin-sdk/setup"; @@ -53,36 +57,30 @@ async function promptIrcAllowFrom(params: { prompter: WizardPrompter; accountId?: string; }): Promise { - const existing = params.cfg.channels?.irc?.allowFrom ?? []; - - await params.prompter.note( - [ + return await promptParsedAllowFromForAccount({ + cfg: params.cfg, + accountId: params.accountId, + defaultAccountId: resolveDefaultIrcAccountId(params.cfg), + prompter: params.prompter, + noteTitle: "IRC allowlist", + noteLines: [ "Allowlist IRC DMs by sender.", "Examples:", "- alice", "- alice!ident@example.org", "Multiple entries: comma-separated.", - ].join("\n"), - "IRC allowlist", - ); - - const raw = await params.prompter.text({ + ], message: "IRC allowFrom (nick or nick!user@host)", placeholder: "alice, bob!ident@example.org", - initialValue: existing[0] ? String(existing[0]) : undefined, - validate: (value) => (String(value ?? "").trim() ? undefined : "Required"), - }); - - const parsed = parseListInput(String(raw)); - const normalized = [ - ...new Set( - parsed + parseEntries: (raw) => ({ + entries: parseListInput(raw) .map((entry) => normalizeIrcAllowEntry(entry)) .map((entry) => entry.trim()) .filter(Boolean), - ), - ]; - return setIrcAllowFrom(params.cfg, normalized); + }), + getExistingAllowFrom: ({ cfg }) => cfg.channels?.irc?.allowFrom ?? [], + applyAllowFrom: ({ cfg, allowFrom }) => setIrcAllowFrom(cfg, allowFrom), + }); } async function promptIrcNickServConfig(params: { @@ -173,10 +171,7 @@ const ircDmPolicy: ChannelSetupDmPolicy = { await promptIrcAllowFrom({ cfg: cfg as CoreConfig, prompter, - accountId: resolveSetupAccountId({ - accountId, - defaultAccountId: resolveDefaultIrcAccountId(cfg as CoreConfig), - }), + accountId, }), }; @@ -388,7 +383,7 @@ export const ircSetupWizard: ChannelSetupWizard = { normalizeGroupEntry, ), }, - allowFrom: { + allowFrom: createAllowFromSection({ helpTitle: "IRC allowlist", helpLines: [ "Allowlist IRC DMs by sender.", @@ -404,17 +399,8 @@ export const ircSetupWizard: ChannelSetupWizard = { const normalized = normalizeIrcAllowEntry(raw); return normalized || null; }, - resolveEntries: async ({ entries }) => - entries.map((entry) => { - const normalized = normalizeIrcAllowEntry(entry); - return { - input: entry, - resolved: Boolean(normalized), - id: normalized || null, - }; - }), apply: async ({ cfg, allowFrom }) => setIrcAllowFrom(cfg as CoreConfig, allowFrom), - }, + }), finalize: async ({ cfg, accountId, prompter }) => { let next = cfg as CoreConfig; diff --git a/extensions/line/src/setup-surface.ts b/extensions/line/src/setup-surface.ts index c4f8c3b7da3..640ad3812b8 100644 --- a/extensions/line/src/setup-surface.ts +++ b/extensions/line/src/setup-surface.ts @@ -1,5 +1,4 @@ import { - createTopLevelChannelDmPolicy, DEFAULT_ACCOUNT_ID, formatDocsLink, resolveLineAccount, @@ -8,6 +7,7 @@ import { type ChannelSetupDmPolicy, type ChannelSetupWizard, } from "openclaw/plugin-sdk/line-core"; +import { createAllowFromSection, createTopLevelChannelDmPolicy } from "openclaw/plugin-sdk/setup"; import { isLineConfigured, listLineAccountIds, @@ -156,7 +156,7 @@ export const lineSetupWizard: ChannelSetupWizard = { }), }, ], - allowFrom: { + allowFrom: createAllowFromSection({ helpTitle: "LINE allowlist", helpLines: LINE_ALLOW_FROM_HELP_LINES, message: "LINE allowFrom (user id)", @@ -165,15 +165,6 @@ export const lineSetupWizard: ChannelSetupWizard = { "LINE allowFrom requires raw user ids like U1234567890abcdef1234567890abcdef.", parseInputs: splitSetupEntries, parseId: parseLineAllowFromId, - resolveEntries: async ({ entries }) => - entries.map((entry) => { - const id = parseLineAllowFromId(entry); - return { - input: entry, - resolved: Boolean(id), - id, - }; - }), apply: ({ cfg, accountId, allowFrom }) => patchLineAccountConfig({ cfg, @@ -181,7 +172,7 @@ export const lineSetupWizard: ChannelSetupWizard = { enabled: true, patch: { dmPolicy: "allowlist", allowFrom }, }), - }, + }), dmPolicy: lineDmPolicy, completionNote: { title: "LINE webhook", diff --git a/extensions/nextcloud-talk/src/setup-core.ts b/extensions/nextcloud-talk/src/setup-core.ts index 5994890f8b2..6aaf7aafbe8 100644 --- a/extensions/nextcloud-talk/src/setup-core.ts +++ b/extensions/nextcloud-talk/src/setup-core.ts @@ -9,6 +9,7 @@ import { import { mergeAllowFromEntries, createTopLevelChannelDmPolicy, + promptParsedAllowFromForAccount, resolveSetupAccountId, setSetupChannelEnabled, } from "openclaw/plugin-sdk/setup"; @@ -112,41 +113,38 @@ async function promptNextcloudTalkAllowFrom(params: { prompter: WizardPrompter; accountId: string; }): Promise { - const resolved = resolveNextcloudTalkAccount({ cfg: params.cfg, accountId: params.accountId }); - const existingAllowFrom = resolved.config.allowFrom ?? []; - await params.prompter.note( - [ + return await promptParsedAllowFromForAccount({ + cfg: params.cfg, + accountId: params.accountId, + defaultAccountId: params.accountId, + prompter: params.prompter, + noteTitle: "Nextcloud Talk user id", + noteLines: [ "1) Check the Nextcloud admin panel for user IDs", "2) Or look at the webhook payload logs when someone messages", "3) User IDs are typically lowercase usernames in Nextcloud", `Docs: ${formatDocsLink("/channels/nextcloud-talk", "nextcloud-talk")}`, - ].join("\n"), - "Nextcloud Talk user id", - ); - - let resolvedIds: string[] = []; - while (resolvedIds.length === 0) { - const entry = await params.prompter.text({ - message: "Nextcloud Talk allowFrom (user id)", - placeholder: "username", - initialValue: existingAllowFrom[0] ? String(existingAllowFrom[0]) : undefined, - validate: (value) => (String(value ?? "").trim() ? undefined : "Required"), - }); - resolvedIds = String(entry) - .split(/[\n,;]+/g) - .map((value) => value.trim().toLowerCase()) - .filter(Boolean); - if (resolvedIds.length === 0) { - await params.prompter.note("Please enter at least one valid user ID.", "Nextcloud Talk"); - } - } - - return setNextcloudTalkAccountConfig(params.cfg, params.accountId, { - dmPolicy: "allowlist", - allowFrom: mergeAllowFromEntries( - existingAllowFrom.map((value) => String(value).trim().toLowerCase()), - resolvedIds, - ), + ], + message: "Nextcloud Talk allowFrom (user id)", + placeholder: "username", + parseEntries: (raw) => ({ + entries: String(raw) + .split(/[\n,;]+/g) + .map((value) => value.trim().toLowerCase()) + .filter(Boolean), + }), + getExistingAllowFrom: ({ cfg, accountId }) => + resolveNextcloudTalkAccount({ cfg, accountId }).config.allowFrom ?? [], + mergeEntries: ({ existing, parsed }) => + mergeAllowFromEntries( + existing.map((value) => String(value).trim().toLowerCase()), + parsed, + ), + applyAllowFrom: ({ cfg, accountId, allowFrom }) => + setNextcloudTalkAccountConfig(cfg, accountId, { + dmPolicy: "allowlist", + allowFrom, + }), }); } diff --git a/extensions/nostr/src/setup-surface.ts b/extensions/nostr/src/setup-surface.ts index 2cf2fb46d61..9c7a1512624 100644 --- a/extensions/nostr/src/setup-surface.ts +++ b/extensions/nostr/src/setup-surface.ts @@ -7,6 +7,7 @@ import { mergeAllowFromEntries, parseSetupEntriesWithParser, patchTopLevelChannelConfigSection, + promptParsedAllowFromForAccount, splitSetupEntries, } from "openclaw/plugin-sdk/setup"; import type { ChannelSetupDmPolicy } from "openclaw/plugin-sdk/setup"; @@ -71,22 +72,19 @@ async function promptNostrAllowFrom(params: { cfg: OpenClawConfig; prompter: WizardPrompter; }): Promise { - const existing = params.cfg.channels?.nostr?.allowFrom ?? []; - await params.prompter.note(NOSTR_ALLOW_FROM_HELP_LINES.join("\n"), "Nostr allowlist"); - const entry = await params.prompter.text({ + return await promptParsedAllowFromForAccount({ + cfg: params.cfg, + defaultAccountId: DEFAULT_ACCOUNT_ID, + prompter: params.prompter, + noteTitle: "Nostr allowlist", + noteLines: NOSTR_ALLOW_FROM_HELP_LINES, message: "Nostr allowFrom", placeholder: "npub1..., 0123abcd...", - initialValue: existing[0] ? String(existing[0]) : undefined, - validate: (value) => { - const raw = String(value ?? "").trim(); - if (!raw) { - return "Required"; - } - return parseNostrAllowFrom(raw).error; - }, + parseEntries: parseNostrAllowFrom, + getExistingAllowFrom: ({ cfg }) => cfg.channels?.nostr?.allowFrom ?? [], + mergeEntries: ({ existing, parsed }) => mergeAllowFromEntries(existing, parsed), + applyAllowFrom: ({ cfg, allowFrom }) => setNostrAllowFrom(cfg, allowFrom), }); - const parsed = parseNostrAllowFrom(String(entry)); - return setNostrAllowFrom(params.cfg, mergeAllowFromEntries(existing, parsed.entries)); } const nostrDmPolicy: ChannelSetupDmPolicy = createTopLevelChannelDmPolicy({ diff --git a/extensions/signal/src/channel.ts b/extensions/signal/src/channel.ts index b9d10dd25f5..8552a26c8df 100644 --- a/extensions/signal/src/channel.ts +++ b/extensions/signal/src/channel.ts @@ -1,8 +1,4 @@ import { buildAccountScopedAllowlistConfigEditor } from "openclaw/plugin-sdk/allowlist-config-edit"; -import { - collectAllowlistProviderRestrictSendersWarnings, - createScopedDmSecurityResolver, -} from "openclaw/plugin-sdk/channel-config-helpers"; import { resolveOutboundSendDep } from "openclaw/plugin-sdk/channel-runtime"; import { resolveMarkdownTableMode } from "openclaw/plugin-sdk/config-runtime"; import { buildOutboundBaseSessionKey } from "openclaw/plugin-sdk/core"; @@ -33,15 +29,13 @@ import { signalMessageActions } from "./message-actions.js"; import type { SignalProbe } from "./probe.js"; import { getSignalRuntime } from "./runtime.js"; import { signalSetupAdapter } from "./setup-core.js"; -import { createSignalPluginBase, signalConfigAccessors, signalSetupWizard } from "./shared.js"; - -const resolveSignalDmPolicy = createScopedDmSecurityResolver({ - channelKey: "signal", - resolvePolicy: (account) => account.config.dmPolicy, - resolveAllowFrom: (account) => account.config.allowFrom, - policyPathSuffix: "dmPolicy", - normalizeEntry: (raw) => normalizeE164(raw.replace(/^signal:/i, "").trim()), -}); +import { + collectSignalSecurityWarnings, + createSignalPluginBase, + signalConfigAccessors, + signalResolveDmPolicy, + signalSetupWizard, +} from "./shared.js"; type SignalSendFn = ReturnType["channel"]["signal"]["sendMessageSignal"]; function resolveSignalSendContext(params: { @@ -304,19 +298,8 @@ export const signalPlugin: ChannelPlugin = { }), }, security: { - resolveDmPolicy: resolveSignalDmPolicy, - collectWarnings: ({ account, cfg }) => { - return collectAllowlistProviderRestrictSendersWarnings({ - cfg, - providerConfigPresent: cfg.channels?.signal !== undefined, - configuredGroupPolicy: account.config.groupPolicy, - surface: "Signal groups", - openScope: "any member", - groupPolicyPath: "channels.signal.groupPolicy", - groupAllowFromPath: "channels.signal.groupAllowFrom", - mentionGated: false, - }); - }, + resolveDmPolicy: signalResolveDmPolicy, + collectWarnings: collectSignalSecurityWarnings, }, messaging: { normalizeTarget: normalizeSignalMessagingTarget, diff --git a/extensions/signal/src/setup-core.ts b/extensions/signal/src/setup-core.ts index a89f25dc268..7924758f4e5 100644 --- a/extensions/signal/src/setup-core.ts +++ b/extensions/signal/src/setup-core.ts @@ -1,8 +1,12 @@ import { + createCliPathTextInput, + createDelegatedSetupWizardProxy, + createDelegatedTextInputShouldPrompt, createPatchedAccountSetupAdapter, normalizeE164, parseSetupEntriesAllowingWildcard, - promptParsedAllowFromForScopedChannel, + promptParsedAllowFromForAccount, + setAccountAllowFromForChannel, setChannelDmPolicyWithAllowFrom, setSetupChannelEnabled, type OpenClawConfig, @@ -89,9 +93,8 @@ export async function promptSignalAllowFrom(params: { prompter: WizardPrompter; accountId?: string; }): Promise { - return promptParsedAllowFromForScopedChannel({ + return promptParsedAllowFromForAccount({ cfg: params.cfg, - channel, accountId: params.accountId, defaultAccountId: resolveDefaultSignalAccountId(params.cfg), prompter: params.prompter, @@ -109,6 +112,13 @@ export async function promptSignalAllowFrom(params: { parseEntries: parseSignalAllowFromEntries, getExistingAllowFrom: ({ cfg, accountId }) => resolveSignalAccount({ cfg, accountId }).config.allowFrom ?? [], + applyAllowFrom: ({ cfg, accountId, allowFrom }) => + setAccountAllowFromForChannel({ + cfg, + channel, + accountId, + allowFrom, + }), }); } @@ -144,21 +154,17 @@ function resolveSignalCliPath(params: { export function createSignalCliPathTextInput( shouldPrompt: NonNullable, ): ChannelSetupWizardTextInput { - return { + return createCliPathTextInput({ inputKey: "cliPath", message: "signal-cli path", - currentValue: ({ cfg, accountId, credentialValues }) => - resolveSignalCliPath({ cfg, accountId, credentialValues }), - initialValue: ({ cfg, accountId, credentialValues }) => + resolvePath: ({ cfg, accountId, credentialValues }) => resolveSignalCliPath({ cfg, accountId, credentialValues }), shouldPrompt, - confirmCurrentValue: false, - applyCurrentValue: true, helpTitle: "Signal", helpLines: [ "signal-cli not found. Install it, then rerun this step or set channels.signal.cliPath.", ], - }; + }); } export const signalNumberTextInput: ChannelSetupWizardTextInput = { @@ -200,11 +206,10 @@ export const signalSetupAdapter: ChannelSetupAdapter = createPatchedAccountSetup buildPatch: (input) => buildSignalSetupPatch(input), }); -export function createSignalSetupWizardProxy( - loadWizard: () => Promise<{ signalSetupWizard: ChannelSetupWizard }>, -) { - return { +export function createSignalSetupWizardProxy(loadWizard: () => Promise) { + return createDelegatedSetupWizardProxy({ channel, + loadWizard, status: { configuredLabel: "configured", unconfiguredLabel: "needs setup", @@ -212,30 +217,20 @@ export function createSignalSetupWizardProxy( unconfiguredHint: "signal-cli missing", configuredScore: 1, unconfiguredScore: 0, - resolveConfigured: ({ cfg }) => - listSignalAccountIds(cfg).some( - (accountId) => resolveSignalAccount({ cfg, accountId }).configured, - ), - resolveStatusLines: async (params) => - (await loadWizard()).signalSetupWizard.status.resolveStatusLines?.(params) ?? [], - resolveSelectionHint: async (params) => - await (await loadWizard()).signalSetupWizard.status.resolveSelectionHint?.(params), - resolveQuickstartScore: async (params) => - await (await loadWizard()).signalSetupWizard.status.resolveQuickstartScore?.(params), }, - prepare: async (params) => await (await loadWizard()).signalSetupWizard.prepare?.(params), + delegatePrepare: true, credentials: [], textInputs: [ - createSignalCliPathTextInput(async (params) => { - const input = (await loadWizard()).signalSetupWizard.textInputs?.find( - (entry) => entry.inputKey === "cliPath", - ); - return (await input?.shouldPrompt?.(params)) ?? false; - }), + createSignalCliPathTextInput( + createDelegatedTextInputShouldPrompt({ + loadWizard, + inputKey: "cliPath", + }), + ), signalNumberTextInput, ], completionNote: signalCompletionNote, dmPolicy: signalDmPolicy, disable: (cfg: OpenClawConfig) => setSetupChannelEnabled(cfg, channel, false), - } satisfies ChannelSetupWizard; + }); } diff --git a/extensions/signal/src/setup-surface.ts b/extensions/signal/src/setup-surface.ts index 88d4d07a212..ab10986e4b3 100644 --- a/extensions/signal/src/setup-surface.ts +++ b/extensions/signal/src/setup-surface.ts @@ -1,4 +1,8 @@ -import { setSetupChannelEnabled, type ChannelSetupWizard } from "openclaw/plugin-sdk/setup"; +import { + createDetectedBinaryStatus, + setSetupChannelEnabled, + type ChannelSetupWizard, +} from "openclaw/plugin-sdk/setup"; import { detectBinary, installSignalCli } from "openclaw/plugin-sdk/setup-tools"; import { listSignalAccountIds, resolveSignalAccount } from "./accounts.js"; import { @@ -15,7 +19,9 @@ const channel = "signal" as const; export const signalSetupWizard: ChannelSetupWizard = { channel, - status: { + status: createDetectedBinaryStatus({ + channelLabel: "Signal", + binaryLabel: "signal-cli", configuredLabel: "configured", unconfiguredLabel: "needs setup", configuredHint: "signal-cli found", @@ -26,23 +32,9 @@ export const signalSetupWizard: ChannelSetupWizard = { listSignalAccountIds(cfg).some( (accountId) => resolveSignalAccount({ cfg, accountId }).configured, ), - resolveStatusLines: async ({ cfg, configured }) => { - const signalCliPath = cfg.channels?.signal?.cliPath ?? "signal-cli"; - const signalCliDetected = await detectBinary(signalCliPath); - return [ - `Signal: ${configured ? "configured" : "needs setup"}`, - `signal-cli: ${signalCliDetected ? "found" : "missing"} (${signalCliPath})`, - ]; - }, - resolveSelectionHint: async ({ cfg }) => { - const signalCliPath = cfg.channels?.signal?.cliPath ?? "signal-cli"; - return (await detectBinary(signalCliPath)) ? "signal-cli found" : "signal-cli missing"; - }, - resolveQuickstartScore: async ({ cfg }) => { - const signalCliPath = cfg.channels?.signal?.cliPath ?? "signal-cli"; - return (await detectBinary(signalCliPath)) ? 1 : 0; - }, - }, + resolveBinaryPath: ({ cfg }) => cfg.channels?.signal?.cliPath ?? "signal-cli", + detectBinary, + }), prepare: async ({ cfg, accountId, credentialValues, runtime, prompter, options }) => { if (!options?.allowSignalInstall) { return; diff --git a/extensions/signal/src/shared.ts b/extensions/signal/src/shared.ts index 76bc8b630b2..8e738fd0776 100644 --- a/extensions/signal/src/shared.ts +++ b/extensions/signal/src/shared.ts @@ -1,16 +1,14 @@ -import { createScopedAccountConfigAccessors } from "openclaw/plugin-sdk/channel-config-helpers"; import { - buildAccountScopedDmSecurityPolicy, collectAllowlistProviderRestrictSendersWarnings, -} from "openclaw/plugin-sdk/channel-policy"; + createScopedAccountConfigAccessors, + createScopedChannelConfigBase, + createScopedDmSecurityResolver, +} from "openclaw/plugin-sdk/channel-config-helpers"; import { createChannelPluginBase } from "openclaw/plugin-sdk/core"; import { buildChannelConfigSchema, - DEFAULT_ACCOUNT_ID, - deleteAccountFromConfigSection, getChatChannelMeta, normalizeE164, - setAccountEnabledInConfigSection, SignalConfigSchema, type ChannelPlugin, } from "openclaw/plugin-sdk/signal-core"; @@ -28,9 +26,9 @@ async function loadSignalChannelRuntime() { return await import("./channel.runtime.js"); } -export const signalSetupWizard = createSignalSetupWizardProxy(async () => ({ - signalSetupWizard: (await loadSignalChannelRuntime()).signalSetupWizard, -})); +export const signalSetupWizard = createSignalSetupWizardProxy( + async () => (await loadSignalChannelRuntime()).signalSetupWizard, +); export const signalConfigAccessors = createScopedAccountConfigAccessors({ resolveAccount: ({ cfg, accountId }) => resolveSignalAccount({ cfg, accountId }), @@ -44,6 +42,38 @@ export const signalConfigAccessors = createScopedAccountConfigAccessors({ resolveDefaultTo: (account: ResolvedSignalAccount) => account.config.defaultTo, }); +export const signalConfigBase = createScopedChannelConfigBase({ + sectionKey: SIGNAL_CHANNEL, + listAccountIds: listSignalAccountIds, + resolveAccount: (cfg, accountId) => resolveSignalAccount({ cfg, accountId }), + defaultAccountId: resolveDefaultSignalAccountId, + clearBaseFields: ["account", "httpUrl", "httpHost", "httpPort", "cliPath", "name"], +}); + +export const signalResolveDmPolicy = createScopedDmSecurityResolver({ + channelKey: SIGNAL_CHANNEL, + resolvePolicy: (account) => account.config.dmPolicy, + resolveAllowFrom: (account) => account.config.allowFrom, + policyPathSuffix: "dmPolicy", + normalizeEntry: (raw) => normalizeE164(raw.replace(/^signal:/i, "").trim()), +}); + +export function collectSignalSecurityWarnings(params: { + account: ResolvedSignalAccount; + cfg: Parameters[0]["cfg"]; +}) { + return collectAllowlistProviderRestrictSendersWarnings({ + cfg: params.cfg, + providerConfigPresent: params.cfg.channels?.signal !== undefined, + configuredGroupPolicy: params.account.config.groupPolicy, + surface: "Signal groups", + openScope: "any member", + groupPolicyPath: "channels.signal.groupPolicy", + groupAllowFromPath: "channels.signal.groupAllowFrom", + mentionGated: false, + }); +} + export function createSignalPluginBase(params: { setupWizard?: NonNullable["setupWizard"]>; setup: NonNullable["setup"]>; @@ -77,24 +107,7 @@ export function createSignalPluginBase(params: { reload: { configPrefixes: ["channels.signal"] }, configSchema: buildChannelConfigSchema(SignalConfigSchema), config: { - listAccountIds: (cfg) => listSignalAccountIds(cfg), - resolveAccount: (cfg, accountId) => resolveSignalAccount({ cfg, accountId }), - defaultAccountId: (cfg) => resolveDefaultSignalAccountId(cfg), - setAccountEnabled: ({ cfg, accountId, enabled }) => - setAccountEnabledInConfigSection({ - cfg, - sectionKey: SIGNAL_CHANNEL, - accountId, - enabled, - allowTopLevel: true, - }), - deleteAccount: ({ cfg, accountId }) => - deleteAccountFromConfigSection({ - cfg, - sectionKey: SIGNAL_CHANNEL, - accountId, - clearBaseFields: ["account", "httpUrl", "httpHost", "httpPort", "cliPath", "name"], - }), + ...signalConfigBase, isConfigured: (account) => account.configured, describeAccount: (account) => ({ accountId: account.accountId, @@ -106,28 +119,8 @@ export function createSignalPluginBase(params: { ...signalConfigAccessors, }, security: { - resolveDmPolicy: ({ cfg, accountId, account }) => - buildAccountScopedDmSecurityPolicy({ - cfg, - channelKey: SIGNAL_CHANNEL, - accountId, - fallbackAccountId: account.accountId ?? DEFAULT_ACCOUNT_ID, - policy: account.config.dmPolicy, - allowFrom: account.config.allowFrom ?? [], - policyPathSuffix: "dmPolicy", - normalizeEntry: (raw) => normalizeE164(raw.replace(/^signal:/i, "").trim()), - }), - collectWarnings: ({ account, cfg }) => - collectAllowlistProviderRestrictSendersWarnings({ - cfg, - providerConfigPresent: cfg.channels?.signal !== undefined, - configuredGroupPolicy: account.config.groupPolicy, - surface: "Signal groups", - openScope: "any member", - groupPolicyPath: "channels.signal.groupPolicy", - groupAllowFromPath: "channels.signal.groupAllowFrom", - mentionGated: false, - }), + resolveDmPolicy: signalResolveDmPolicy, + collectWarnings: collectSignalSecurityWarnings, }, setup: params.setup, }) as Pick< diff --git a/extensions/synology-chat/src/setup-surface.ts b/extensions/synology-chat/src/setup-surface.ts index 7985199eda6..854a0cc8bdd 100644 --- a/extensions/synology-chat/src/setup-surface.ts +++ b/extensions/synology-chat/src/setup-surface.ts @@ -1,4 +1,5 @@ import { + createAllowFromSection, DEFAULT_ACCOUNT_ID, formatDocsLink, mergeAllowFromEntries, @@ -281,7 +282,7 @@ export const synologyChatSetupWizard: ChannelSetupWizard = { }), }, ], - allowFrom: { + allowFrom: createAllowFromSection({ helpTitle: "Synology Chat allowlist", helpLines: SYNOLOGY_ALLOW_FROM_HELP_LINES, message: "Allowed Synology Chat user ids", @@ -289,15 +290,6 @@ export const synologyChatSetupWizard: ChannelSetupWizard = { invalidWithoutCredentialNote: "Synology Chat user ids must be numeric.", parseInputs: splitSetupEntries, parseId: parseSynologyUserId, - resolveEntries: async ({ entries }) => - entries.map((entry) => { - const id = parseSynologyUserId(entry); - return { - input: entry, - resolved: Boolean(id), - id, - }; - }), apply: async ({ cfg, accountId, allowFrom }) => patchSynologyChatAccountConfig({ cfg, @@ -311,7 +303,7 @@ export const synologyChatSetupWizard: ChannelSetupWizard = { ), }, }), - }, + }), completionNote: { title: "Synology Chat access control", lines: [ diff --git a/extensions/telegram/src/setup-surface.ts b/extensions/telegram/src/setup-surface.ts index 934fa0688e9..ceb23876352 100644 --- a/extensions/telegram/src/setup-surface.ts +++ b/extensions/telegram/src/setup-surface.ts @@ -1,4 +1,5 @@ import { + createAllowFromSection, DEFAULT_ACCOUNT_ID, hasConfiguredSecretInput, type OpenClawConfig, @@ -80,7 +81,7 @@ export const telegramSetupWizard: ChannelSetupWizard = { }, }, ], - allowFrom: { + allowFrom: createAllowFromSection({ helpTitle: "Telegram user id", helpLines: TELEGRAM_USER_ID_HELP_LINES, credentialInputKey: "token", @@ -102,7 +103,7 @@ export const telegramSetupWizard: ChannelSetupWizard = { accountId, patch: { dmPolicy: "allowlist", allowFrom }, }), - }, + }), dmPolicy, disable: (cfg) => setSetupChannelEnabled(cfg, channel, false), }; diff --git a/extensions/whatsapp/src/accounts.ts b/extensions/whatsapp/src/accounts.ts index d2a4e277846..4cb02fb0be5 100644 --- a/extensions/whatsapp/src/accounts.ts +++ b/extensions/whatsapp/src/accounts.ts @@ -18,6 +18,7 @@ export type ResolvedWhatsAppAccount = { enabled: boolean; sendReadReceipts: boolean; messagePrefix?: string; + defaultTo?: string; authDir: string; isLegacyAuthDir: boolean; selfChatMode?: boolean; @@ -135,6 +136,7 @@ export function resolveWhatsAppAccount(params: { sendReadReceipts: accountCfg?.sendReadReceipts ?? rootCfg?.sendReadReceipts ?? true, messagePrefix: accountCfg?.messagePrefix ?? rootCfg?.messagePrefix ?? params.cfg.messages?.messagePrefix, + defaultTo: accountCfg?.defaultTo ?? rootCfg?.defaultTo, authDir, isLegacyAuthDir: isLegacy, selfChatMode: accountCfg?.selfChatMode ?? rootCfg?.selfChatMode, diff --git a/extensions/whatsapp/src/shared.ts b/extensions/whatsapp/src/shared.ts index 11177f41f66..2854db5d61f 100644 --- a/extensions/whatsapp/src/shared.ts +++ b/extensions/whatsapp/src/shared.ts @@ -1,17 +1,17 @@ import { - buildAccountScopedDmSecurityPolicy, collectAllowlistProviderGroupPolicyWarnings, collectOpenGroupPolicyRouteAllowlistWarnings, -} from "openclaw/plugin-sdk/channel-policy"; + createScopedAccountConfigAccessors, + createScopedChannelConfigBase, + createScopedDmSecurityResolver, +} from "openclaw/plugin-sdk/channel-config-helpers"; import { createChannelPluginBase } from "openclaw/plugin-sdk/core"; +import { createDelegatedSetupWizardProxy } from "openclaw/plugin-sdk/setup"; import { buildChannelConfigSchema, - DEFAULT_ACCOUNT_ID, formatWhatsAppConfigAllowFromEntries, getChatChannelMeta, normalizeE164, - resolveWhatsAppConfigAllowFrom, - resolveWhatsAppConfigDefaultTo, resolveWhatsAppGroupIntroHint, WhatsAppConfigSchema, type ChannelPlugin, @@ -33,17 +33,40 @@ export async function loadWhatsAppChannelRuntime() { return await import("./channel.runtime.js"); } -export const whatsappSetupWizardProxy = createWhatsAppSetupWizardProxy(async () => ({ - whatsappSetupWizard: (await loadWhatsAppChannelRuntime()).whatsappSetupWizard, -})); +export const whatsappSetupWizardProxy = createWhatsAppSetupWizardProxy( + async () => (await loadWhatsAppChannelRuntime()).whatsappSetupWizard, +); + +const whatsappConfigBase = createScopedChannelConfigBase({ + sectionKey: WHATSAPP_CHANNEL, + listAccountIds: listWhatsAppAccountIds, + resolveAccount: (cfg, accountId) => resolveWhatsAppAccount({ cfg, accountId }), + defaultAccountId: resolveDefaultWhatsAppAccountId, + clearBaseFields: [], + allowTopLevel: false, +}); + +const whatsappConfigAccessors = createScopedAccountConfigAccessors({ + resolveAccount: ({ cfg, accountId }) => resolveWhatsAppAccount({ cfg, accountId }), + resolveAllowFrom: (account) => account.allowFrom, + formatAllowFrom: (allowFrom) => formatWhatsAppConfigAllowFromEntries(allowFrom), + resolveDefaultTo: (account) => account.defaultTo, +}); + +const whatsappResolveDmPolicy = createScopedDmSecurityResolver({ + channelKey: WHATSAPP_CHANNEL, + resolvePolicy: (account) => account.dmPolicy, + resolveAllowFrom: (account) => account.allowFrom, + policyPathSuffix: "dmPolicy", + normalizeEntry: (raw) => normalizeE164(raw), +}); export function createWhatsAppSetupWizardProxy( - loadWizard: () => Promise<{ - whatsappSetupWizard: NonNullable["setupWizard"]>; - }>, + loadWizard: () => Promise["setupWizard"]>>, ): NonNullable["setupWizard"]> { - return { + return createDelegatedSetupWizardProxy({ channel: WHATSAPP_CHANNEL, + loadWizard, status: { configuredLabel: "linked", unconfiguredLabel: "not linked", @@ -51,20 +74,11 @@ export function createWhatsAppSetupWizardProxy( unconfiguredHint: "not linked", configuredScore: 5, unconfiguredScore: 4, - resolveConfigured: async ({ cfg }) => - await (await loadWizard()).whatsappSetupWizard.status.resolveConfigured({ cfg }), - resolveStatusLines: async ({ cfg, configured }) => - (await ( - await loadWizard() - ).whatsappSetupWizard.status.resolveStatusLines?.({ - cfg, - configured, - })) ?? [], }, resolveShouldPromptAccountIds: (params) => (params.shouldPromptAccountIds || params.options?.promptWhatsAppAccountId) ?? false, credentials: [], - finalize: async (params) => await (await loadWizard()).whatsappSetupWizard.finalize!(params), + delegateFinalize: true, disable: (cfg) => ({ ...cfg, channels: { @@ -78,7 +92,7 @@ export function createWhatsAppSetupWizardProxy( onAccountRecorded: (accountId, options) => { options?.onWhatsAppAccountId?.(accountId); }, - }; + }); } export function createWhatsAppPluginBase(params: { @@ -119,45 +133,7 @@ export function createWhatsAppPluginBase(params: { gatewayMethods: ["web.login.start", "web.login.wait"], configSchema: buildChannelConfigSchema(WhatsAppConfigSchema), config: { - listAccountIds: (cfg) => listWhatsAppAccountIds(cfg), - resolveAccount: (cfg, accountId) => resolveWhatsAppAccount({ cfg, accountId }), - defaultAccountId: (cfg) => resolveDefaultWhatsAppAccountId(cfg), - setAccountEnabled: ({ cfg, accountId, enabled }) => { - const accountKey = accountId || DEFAULT_ACCOUNT_ID; - const accounts = { ...cfg.channels?.whatsapp?.accounts }; - const existing = accounts[accountKey] ?? {}; - return { - ...cfg, - channels: { - ...cfg.channels, - whatsapp: { - ...cfg.channels?.whatsapp, - accounts: { - ...accounts, - [accountKey]: { - ...existing, - enabled, - }, - }, - }, - }, - }; - }, - deleteAccount: ({ cfg, accountId }) => { - const accountKey = accountId || DEFAULT_ACCOUNT_ID; - const accounts = { ...cfg.channels?.whatsapp?.accounts }; - delete accounts[accountKey]; - return { - ...cfg, - channels: { - ...cfg.channels, - whatsapp: { - ...cfg.channels?.whatsapp, - accounts: Object.keys(accounts).length ? accounts : undefined, - }, - }, - }; - }, + ...whatsappConfigBase, isEnabled: (account, cfg) => account.enabled && cfg.web?.enabled !== false, disabledReason: () => "disabled", isConfigured: params.isConfigured, @@ -171,22 +147,10 @@ export function createWhatsAppPluginBase(params: { dmPolicy: account.dmPolicy, allowFrom: account.allowFrom, }), - resolveAllowFrom: ({ cfg, accountId }) => resolveWhatsAppConfigAllowFrom({ cfg, accountId }), - formatAllowFrom: ({ allowFrom }) => formatWhatsAppConfigAllowFromEntries(allowFrom), - resolveDefaultTo: ({ cfg, accountId }) => resolveWhatsAppConfigDefaultTo({ cfg, accountId }), + ...whatsappConfigAccessors, }, security: { - resolveDmPolicy: ({ cfg, accountId, account }) => - buildAccountScopedDmSecurityPolicy({ - cfg, - channelKey: WHATSAPP_CHANNEL, - accountId, - fallbackAccountId: account.accountId ?? DEFAULT_ACCOUNT_ID, - policy: account.dmPolicy, - allowFrom: account.allowFrom ?? [], - policyPathSuffix: "dmPolicy", - normalizeEntry: (raw) => normalizeE164(raw), - }), + resolveDmPolicy: whatsappResolveDmPolicy, collectWarnings: ({ account, cfg }) => { const groupAllowlistConfigured = Boolean(account.groups) && Object.keys(account.groups ?? {}).length > 0; diff --git a/src/channels/plugins/setup-wizard-binary.test.ts b/src/channels/plugins/setup-wizard-binary.test.ts new file mode 100644 index 00000000000..b38fdd0ea44 --- /dev/null +++ b/src/channels/plugins/setup-wizard-binary.test.ts @@ -0,0 +1,128 @@ +import { describe, expect, it, vi } from "vitest"; +import { + createCliPathTextInput, + createDelegatedSetupWizardStatusResolvers, + createDelegatedTextInputShouldPrompt, + createDetectedBinaryStatus, +} from "./setup-wizard-binary.js"; +import type { ChannelSetupWizard } from "./setup-wizard.js"; + +describe("createDetectedBinaryStatus", () => { + it("builds status lines, hint, and score from binary detection", async () => { + const status = createDetectedBinaryStatus({ + channelLabel: "Signal", + binaryLabel: "signal-cli", + configuredLabel: "configured", + unconfiguredLabel: "needs setup", + configuredHint: "signal-cli found", + unconfiguredHint: "signal-cli missing", + configuredScore: 1, + unconfiguredScore: 0, + resolveConfigured: () => true, + resolveBinaryPath: () => "/usr/local/bin/signal-cli", + detectBinary: vi.fn(async () => true), + }); + + expect(await status.resolveConfigured({ cfg: {} })).toBe(true); + expect(await status.resolveStatusLines?.({ cfg: {}, configured: true })).toEqual([ + "Signal: configured", + "signal-cli: found (/usr/local/bin/signal-cli)", + ]); + expect(await status.resolveSelectionHint?.({ cfg: {}, configured: true })).toBe( + "signal-cli found", + ); + expect(await status.resolveQuickstartScore?.({ cfg: {}, configured: true })).toBe(1); + }); +}); + +describe("createCliPathTextInput", () => { + it("reuses the same path resolver for current and initial values", async () => { + const textInput = createCliPathTextInput({ + inputKey: "cliPath", + message: "CLI path", + resolvePath: () => "imsg", + shouldPrompt: async () => false, + helpTitle: "iMessage", + helpLines: ["help"], + }); + + expect( + await textInput.currentValue?.({ cfg: {}, accountId: "default", credentialValues: {} }), + ).toBe("imsg"); + expect( + await textInput.initialValue?.({ cfg: {}, accountId: "default", credentialValues: {} }), + ).toBe("imsg"); + expect(textInput.helpTitle).toBe("iMessage"); + expect(textInput.helpLines).toEqual(["help"]); + }); +}); + +describe("createDelegatedSetupWizardStatusResolvers", () => { + it("forwards optional status resolvers to the loaded wizard", async () => { + const loadWizard = vi.fn( + async (): Promise => ({ + channel: "demo", + status: { + configuredLabel: "configured", + unconfiguredLabel: "needs setup", + resolveConfigured: () => true, + resolveStatusLines: async () => ["line"], + resolveSelectionHint: async () => "hint", + resolveQuickstartScore: async () => 7, + }, + credentials: [], + }), + ); + + const status = createDelegatedSetupWizardStatusResolvers(loadWizard); + + expect(await status.resolveStatusLines?.({ cfg: {}, configured: true })).toEqual(["line"]); + expect(await status.resolveSelectionHint?.({ cfg: {}, configured: true })).toBe("hint"); + expect(await status.resolveQuickstartScore?.({ cfg: {}, configured: true })).toBe(7); + }); +}); + +describe("createDelegatedTextInputShouldPrompt", () => { + it("forwards shouldPrompt for the requested input key", async () => { + const loadWizard = vi.fn( + async (): Promise => ({ + channel: "demo", + status: { + configuredLabel: "configured", + unconfiguredLabel: "needs setup", + resolveConfigured: () => true, + }, + credentials: [], + textInputs: [ + { + inputKey: "cliPath", + message: "CLI path", + shouldPrompt: async ({ currentValue }) => currentValue !== "imsg", + }, + ], + }), + ); + + const shouldPrompt = createDelegatedTextInputShouldPrompt({ + loadWizard, + inputKey: "cliPath", + }); + + expect( + await shouldPrompt({ + cfg: {}, + accountId: "default", + credentialValues: {}, + currentValue: "imsg", + }), + ).toBe(false); + expect( + await shouldPrompt({ + cfg: {}, + accountId: "default", + credentialValues: {}, + currentValue: "other", + }), + ).toBe(true); + }); +}); diff --git a/src/channels/plugins/setup-wizard-binary.ts b/src/channels/plugins/setup-wizard-binary.ts new file mode 100644 index 00000000000..d199caf3bb9 --- /dev/null +++ b/src/channels/plugins/setup-wizard-binary.ts @@ -0,0 +1,100 @@ +import type { OpenClawConfig } from "../../config/config.js"; +import { detectBinary as defaultDetectBinary } from "../../plugins/setup-binary.js"; +import type { + ChannelSetupWizard, + ChannelSetupWizardStatus, + ChannelSetupWizardTextInput, +} from "./setup-wizard.js"; + +type SetupTextInputParams = Parameters>[0]; +type SetupStatusParams = Parameters>[0]; + +export function createDetectedBinaryStatus(params: { + channelLabel: string; + binaryLabel: string; + configuredLabel: string; + unconfiguredLabel: string; + configuredHint: string; + unconfiguredHint: string; + configuredScore: number; + unconfiguredScore: number; + resolveConfigured: (params: { cfg: OpenClawConfig }) => boolean | Promise; + resolveBinaryPath: (params: { cfg: OpenClawConfig }) => string; + detectBinary?: (path: string) => Promise; +}): ChannelSetupWizardStatus { + const detectBinary = params.detectBinary ?? defaultDetectBinary; + return { + configuredLabel: params.configuredLabel, + unconfiguredLabel: params.unconfiguredLabel, + configuredHint: params.configuredHint, + unconfiguredHint: params.unconfiguredHint, + configuredScore: params.configuredScore, + unconfiguredScore: params.unconfiguredScore, + resolveConfigured: params.resolveConfigured, + resolveStatusLines: async ({ cfg, configured }: SetupStatusParams) => { + const binaryPath = params.resolveBinaryPath({ cfg }); + const detected = await detectBinary(binaryPath); + return [ + `${params.channelLabel}: ${configured ? params.configuredLabel : params.unconfiguredLabel}`, + `${params.binaryLabel}: ${detected ? "found" : "missing"} (${binaryPath})`, + ]; + }, + resolveSelectionHint: async ({ cfg }) => + (await detectBinary(params.resolveBinaryPath({ cfg }))) + ? params.configuredHint + : params.unconfiguredHint, + resolveQuickstartScore: async ({ cfg }) => + (await detectBinary(params.resolveBinaryPath({ cfg }))) + ? params.configuredScore + : params.unconfiguredScore, + }; +} + +export function createCliPathTextInput(params: { + inputKey: ChannelSetupWizardTextInput["inputKey"]; + message: string; + resolvePath: (params: SetupTextInputParams) => string | undefined; + shouldPrompt: NonNullable; + helpTitle?: string; + helpLines?: string[]; +}): ChannelSetupWizardTextInput { + return { + inputKey: params.inputKey, + message: params.message, + currentValue: params.resolvePath, + initialValue: params.resolvePath, + shouldPrompt: params.shouldPrompt, + confirmCurrentValue: false, + applyCurrentValue: true, + ...(params.helpTitle ? { helpTitle: params.helpTitle } : {}), + ...(params.helpLines ? { helpLines: params.helpLines } : {}), + }; +} + +export function createDelegatedSetupWizardStatusResolvers( + loadWizard: () => Promise, +): Pick< + ChannelSetupWizardStatus, + "resolveStatusLines" | "resolveSelectionHint" | "resolveQuickstartScore" +> { + return { + resolveStatusLines: async (params) => + (await loadWizard()).status.resolveStatusLines?.(params) ?? [], + resolveSelectionHint: async (params) => + await (await loadWizard()).status.resolveSelectionHint?.(params), + resolveQuickstartScore: async (params) => + await (await loadWizard()).status.resolveQuickstartScore?.(params), + }; +} + +export function createDelegatedTextInputShouldPrompt(params: { + loadWizard: () => Promise; + inputKey: ChannelSetupWizardTextInput["inputKey"]; +}): NonNullable { + return async (inputParams) => { + const input = (await params.loadWizard()).textInputs?.find( + (entry) => entry.inputKey === params.inputKey, + ); + return (await input?.shouldPrompt?.(inputParams)) ?? false; + }; +} diff --git a/src/channels/plugins/setup-wizard-helpers.test.ts b/src/channels/plugins/setup-wizard-helpers.test.ts index 87c6a6de61c..bcdc09917fb 100644 --- a/src/channels/plugins/setup-wizard-helpers.test.ts +++ b/src/channels/plugins/setup-wizard-helpers.test.ts @@ -6,6 +6,7 @@ import { buildSingleChannelSecretPromptState, createAccountScopedAllowFromSection, createAccountScopedGroupAccessSection, + createAllowFromSection, createLegacyCompatChannelDmPolicy, createNestedChannelAllowFromSetter, createNestedChannelDmPolicy, @@ -25,6 +26,7 @@ import { patchTopLevelChannelConfigSection, promptLegacyChannelAllowFrom, promptLegacyChannelAllowFromForAccount, + promptParsedAllowFromForAccount, parseSetupEntriesWithParser, promptParsedAllowFromForScopedChannel, promptSingleChannelSecretInput, @@ -33,6 +35,7 @@ import { resolveAccountIdForConfigure, resolveEntriesWithOptionalToken, resolveGroupAllowlistWithLookupNotes, + resolveParsedAllowFromEntries, resolveSetupAccountId, setAccountDmAllowFromForChannel, setAccountAllowFromForChannel, @@ -582,6 +585,76 @@ describe("promptParsedAllowFromForScopedChannel", () => { }); }); +describe("promptParsedAllowFromForAccount", () => { + it("applies parsed allowFrom values through the provided writer", async () => { + const prompter = createPrompter(["Alice, ALICE"]); + + const next = await promptParsedAllowFromForAccount({ + cfg: { + channels: { + bluebubbles: { + accounts: { + alt: { + allowFrom: ["old"], + }, + }, + }, + }, + } as OpenClawConfig, + accountId: "alt", + defaultAccountId: DEFAULT_ACCOUNT_ID, + prompter, + noteTitle: "BlueBubbles allowlist", + noteLines: ["line"], + message: "msg", + placeholder: "placeholder", + parseEntries: (raw) => + parseSetupEntriesWithParser(raw, (entry) => ({ value: entry.toLowerCase() })), + getExistingAllowFrom: ({ cfg, accountId }) => + cfg.channels?.bluebubbles?.accounts?.[accountId]?.allowFrom ?? [], + applyAllowFrom: ({ cfg, accountId, allowFrom }) => + patchChannelConfigForAccount({ + cfg, + channel: "bluebubbles", + accountId, + patch: { allowFrom }, + }), + }); + + expect(next.channels?.bluebubbles?.accounts?.alt?.allowFrom).toEqual(["alice"]); + expect(prompter.note).toHaveBeenCalledWith("line", "BlueBubbles allowlist"); + }); + + it("can merge parsed values with existing entries", async () => { + const next = await promptParsedAllowFromForAccount({ + cfg: { + channels: { + nostr: { + allowFrom: ["old"], + }, + }, + } as OpenClawConfig, + defaultAccountId: DEFAULT_ACCOUNT_ID, + prompter: createPrompter(["new"]), + noteTitle: "Nostr allowlist", + noteLines: ["line"], + message: "msg", + placeholder: "placeholder", + parseEntries: (raw) => ({ entries: [raw.trim()] }), + getExistingAllowFrom: ({ cfg }) => cfg.channels?.nostr?.allowFrom ?? [], + mergeEntries: ({ existing, parsed }) => [...existing.map(String), ...parsed], + applyAllowFrom: ({ cfg, allowFrom }) => + patchTopLevelChannelConfigSection({ + cfg, + channel: "nostr", + patch: { allowFrom }, + }), + }); + + expect(next.channels?.nostr?.allowFrom).toEqual(["old", "new"]); + }); +}); + describe("channel lookup note helpers", () => { it("emits summary lines for resolved and unresolved entries", async () => { const prompter = { note: vi.fn(async () => undefined) }; @@ -1402,6 +1475,44 @@ describe("createAccountScopedAllowFromSection", () => { }); }); +describe("createAllowFromSection", () => { + it("builds a parsed allowFrom section with default local resolution", async () => { + const section = createAllowFromSection({ + helpTitle: "LINE allowlist", + helpLines: ["line"], + credentialInputKey: "token", + message: "LINE allowFrom", + placeholder: "U123", + invalidWithoutCredentialNote: "need ids", + parseId: (value) => value.trim().toUpperCase() || null, + apply: ({ cfg, accountId, allowFrom }) => + patchChannelConfigForAccount({ + cfg, + channel: "line", + accountId, + patch: { dmPolicy: "allowlist", allowFrom }, + }), + }); + + expect(section.helpTitle).toBe("LINE allowlist"); + await expect( + section.resolveEntries({ + cfg: {}, + accountId: DEFAULT_ACCOUNT_ID, + credentialValues: {}, + entries: ["u1"], + }), + ).resolves.toEqual([{ input: "u1", resolved: true, id: "U1" }]); + + const next = await section.apply({ + cfg: {}, + accountId: DEFAULT_ACCOUNT_ID, + allowFrom: ["U1"], + }); + expect(next.channels?.line?.allowFrom).toEqual(["U1"]); + }); +}); + describe("createAccountScopedGroupAccessSection", () => { it("builds group access with shared setPolicy and fallback lookup notes", async () => { const prompter = createPrompter([]); @@ -1544,6 +1655,20 @@ describe("resolveEntriesWithOptionalToken", () => { }); }); +describe("resolveParsedAllowFromEntries", () => { + it("maps parsed ids into resolved/unresolved entries", () => { + expect( + resolveParsedAllowFromEntries({ + entries: ["alice", " "], + parseId: (raw) => raw.trim() || null, + }), + ).toEqual([ + { input: "alice", resolved: true, id: "alice" }, + { input: " ", resolved: false, id: null }, + ]); + }); +}); + describe("parseMentionOrPrefixedId", () => { it("parses mention ids", () => { expect( diff --git a/src/channels/plugins/setup-wizard-helpers.ts b/src/channels/plugins/setup-wizard-helpers.ts index 187036bcfff..50a29404b30 100644 --- a/src/channels/plugins/setup-wizard-helpers.ts +++ b/src/channels/plugins/setup-wizard-helpers.ts @@ -16,7 +16,7 @@ import type { PromptAccountId, PromptAccountIdParams, } from "./setup-wizard-types.js"; -import type { ChannelSetupWizard } from "./setup-wizard.js"; +import type { ChannelSetupWizard, ChannelSetupWizardAllowFromEntry } from "./setup-wizard.js"; export const promptAccountId: PromptAccountId = async (params: PromptAccountIdParams) => { const existingIds = params.listAccountIds(params.cfg); @@ -1051,9 +1051,8 @@ export async function promptSingleChannelSecretInput(params: { type ParsedAllowFromResult = { entries: string[]; error?: string }; -export async function promptParsedAllowFromForScopedChannel(params: { - cfg: OpenClawConfig; - channel: "imessage" | "signal"; +export async function promptParsedAllowFromForAccount(params: { + cfg: TConfig; accountId?: string; defaultAccountId: string; prompter: Pick; @@ -1062,11 +1061,14 @@ export async function promptParsedAllowFromForScopedChannel(params: { message: string; placeholder: string; parseEntries: (raw: string) => ParsedAllowFromResult; - getExistingAllowFrom: (params: { - cfg: OpenClawConfig; + getExistingAllowFrom: (params: { cfg: TConfig; accountId: string }) => Array; + mergeEntries?: (params: { existing: Array; parsed: string[] }) => string[]; + applyAllowFrom: (params: { + cfg: TConfig; accountId: string; - }) => Array; -}): Promise { + allowFrom: string[]; + }) => TConfig | Promise; +}): Promise { const accountId = resolveSetupAccountId({ accountId: params.accountId, defaultAccountId: params.defaultAccountId, @@ -1089,15 +1091,97 @@ export async function promptParsedAllowFromForScopedChannel(params: { }, }); const parsed = params.parseEntries(String(entry)); - const unique = mergeAllowFromEntries(undefined, parsed.entries); - return setAccountAllowFromForChannel({ + const unique = + params.mergeEntries?.({ + existing, + parsed: parsed.entries, + }) ?? mergeAllowFromEntries(undefined, parsed.entries); + return await params.applyAllowFrom({ cfg: params.cfg, - channel: params.channel, accountId, allowFrom: unique, }); } +export async function promptParsedAllowFromForScopedChannel(params: { + cfg: OpenClawConfig; + channel: "imessage" | "signal"; + accountId?: string; + defaultAccountId: string; + prompter: Pick; + noteTitle: string; + noteLines: string[]; + message: string; + placeholder: string; + parseEntries: (raw: string) => ParsedAllowFromResult; + getExistingAllowFrom: (params: { + cfg: OpenClawConfig; + accountId: string; + }) => Array; +}): Promise { + return await promptParsedAllowFromForAccount({ + cfg: params.cfg, + accountId: params.accountId, + defaultAccountId: params.defaultAccountId, + prompter: params.prompter, + noteTitle: params.noteTitle, + noteLines: params.noteLines, + message: params.message, + placeholder: params.placeholder, + parseEntries: params.parseEntries, + getExistingAllowFrom: params.getExistingAllowFrom, + applyAllowFrom: ({ cfg, accountId, allowFrom }) => + setAccountAllowFromForChannel({ + cfg, + channel: params.channel, + accountId, + allowFrom, + }), + }); +} + +export function resolveParsedAllowFromEntries(params: { + entries: string[]; + parseId: (raw: string) => string | null; +}): ChannelSetupWizardAllowFromEntry[] { + return params.entries.map((entry) => { + const id = params.parseId(entry); + return { + input: entry, + resolved: Boolean(id), + id, + }; + }); +} + +export function createAllowFromSection(params: { + helpTitle?: string; + helpLines?: string[]; + credentialInputKey?: NonNullable["credentialInputKey"]; + message: string; + placeholder: string; + invalidWithoutCredentialNote: string; + parseInputs?: NonNullable["parseInputs"]>; + parseId: NonNullable["parseId"]>; + resolveEntries?: NonNullable["resolveEntries"]>; + apply: NonNullable["apply"]>; +}): NonNullable { + return { + ...(params.helpTitle ? { helpTitle: params.helpTitle } : {}), + ...(params.helpLines ? { helpLines: params.helpLines } : {}), + ...(params.credentialInputKey ? { credentialInputKey: params.credentialInputKey } : {}), + message: params.message, + placeholder: params.placeholder, + invalidWithoutCredentialNote: params.invalidWithoutCredentialNote, + ...(params.parseInputs ? { parseInputs: params.parseInputs } : {}), + parseId: params.parseId, + resolveEntries: + params.resolveEntries ?? + (async ({ entries }) => resolveParsedAllowFromEntries({ entries, parseId: params.parseId })), + apply: params.apply, + }; +} + export async function noteChannelLookupSummary(params: { prompter: Pick; label: string; diff --git a/src/channels/plugins/setup-wizard-proxy.test.ts b/src/channels/plugins/setup-wizard-proxy.test.ts new file mode 100644 index 00000000000..f4b3db0d725 --- /dev/null +++ b/src/channels/plugins/setup-wizard-proxy.test.ts @@ -0,0 +1,266 @@ +import { describe, expect, it, vi } from "vitest"; +import { + createAllowlistSetupWizardProxy, + createDelegatedFinalize, + createDelegatedPrepare, + createDelegatedResolveConfigured, + createDelegatedSetupWizardProxy, +} from "./setup-wizard-proxy.js"; +import type { ChannelSetupWizard } from "./setup-wizard.js"; + +describe("createDelegatedResolveConfigured", () => { + it("forwards configured resolution to the loaded wizard", async () => { + const loadWizard = vi.fn( + async (): Promise => ({ + channel: "demo", + status: { + configuredLabel: "configured", + unconfiguredLabel: "needs setup", + resolveConfigured: async ({ cfg }) => Boolean(cfg.channels?.demo), + }, + credentials: [], + }), + ); + + const resolveConfigured = createDelegatedResolveConfigured(loadWizard); + + expect(await resolveConfigured({ cfg: {} })).toBe(false); + expect(await resolveConfigured({ cfg: { channels: { demo: {} } } })).toBe(true); + }); +}); + +describe("createDelegatedPrepare", () => { + it("forwards prepare when the loaded wizard implements it", async () => { + const loadWizard = vi.fn( + async (): Promise => ({ + channel: "demo", + status: { + configuredLabel: "configured", + unconfiguredLabel: "needs setup", + resolveConfigured: () => true, + }, + credentials: [], + prepare: async ({ cfg }) => ({ cfg: { ...cfg, channels: { demo: { enabled: true } } } }), + }), + ); + + const prepare = createDelegatedPrepare(loadWizard); + + expect( + await prepare({ + cfg: {}, + accountId: "default", + credentialValues: {}, + runtime: {} as never, + prompter: {} as never, + }), + ).toEqual({ + cfg: { + channels: { + demo: { enabled: true }, + }, + }, + }); + }); +}); + +describe("createDelegatedFinalize", () => { + it("forwards finalize when the loaded wizard implements it", async () => { + const loadWizard = vi.fn( + async (): Promise => ({ + channel: "demo", + status: { + configuredLabel: "configured", + unconfiguredLabel: "needs setup", + resolveConfigured: () => true, + }, + credentials: [], + finalize: async ({ cfg, forceAllowFrom }) => ({ + cfg: { + ...cfg, + channels: { + demo: { forceAllowFrom }, + }, + }, + }), + }), + ); + + const finalize = createDelegatedFinalize(loadWizard); + + expect( + await finalize({ + cfg: {}, + accountId: "default", + credentialValues: {}, + runtime: {} as never, + prompter: {} as never, + forceAllowFrom: true, + }), + ).toEqual({ + cfg: { + channels: { + demo: { forceAllowFrom: true }, + }, + }, + }); + }); +}); + +describe("createAllowlistSetupWizardProxy", () => { + it("falls back when delegated surfaces are absent", async () => { + const wizard = createAllowlistSetupWizardProxy({ + loadWizard: async () => + ({ + channel: "demo", + status: { + configuredLabel: "configured", + unconfiguredLabel: "needs setup", + resolveConfigured: () => true, + }, + credentials: [], + }) satisfies ChannelSetupWizard, + createBase: ({ promptAllowFrom, resolveAllowFromEntries, resolveGroupAllowlist }) => ({ + channel: "demo", + status: { + configuredLabel: "configured", + unconfiguredLabel: "needs setup", + resolveConfigured: () => true, + }, + credentials: [], + dmPolicy: { + label: "Demo", + channel: "demo" as never, + policyKey: "channels.demo.dmPolicy", + allowFromKey: "channels.demo.allowFrom", + getCurrent: () => "pairing", + setPolicy: (cfg) => cfg, + promptAllowFrom, + }, + allowFrom: { + message: "Allow from", + placeholder: "id", + invalidWithoutCredentialNote: "need id", + parseId: () => null, + resolveEntries: resolveAllowFromEntries, + apply: (params) => params.cfg, + }, + groupAccess: { + label: "Groups", + placeholder: "group", + currentPolicy: () => "allowlist", + currentEntries: () => [], + updatePrompt: () => false, + setPolicy: (params) => params.cfg, + resolveAllowlist: resolveGroupAllowlist, + }, + }), + fallbackResolvedGroupAllowlist: (entries) => entries.map((input) => ({ input })), + }); + + expect( + await wizard.dmPolicy?.promptAllowFrom?.({ + cfg: {}, + prompter: {} as never, + accountId: "default", + }), + ).toEqual({}); + expect( + await wizard.allowFrom?.resolveEntries({ + cfg: {}, + accountId: "default", + credentialValues: {}, + entries: ["alice"], + }), + ).toEqual([{ input: "alice", resolved: false, id: null }]); + expect( + await wizard.groupAccess?.resolveAllowlist?.({ + cfg: {}, + accountId: "default", + credentialValues: {}, + entries: ["general"], + prompter: {} as never, + }), + ).toEqual([{ input: "general" }]); + }); +}); + +describe("createDelegatedSetupWizardProxy", () => { + it("builds a direct proxy wizard with delegated status/prepare/finalize", async () => { + const wizard = createDelegatedSetupWizardProxy({ + channel: "demo", + loadWizard: async () => + ({ + channel: "demo", + status: { + configuredLabel: "configured", + unconfiguredLabel: "needs setup", + configuredHint: "ready", + unconfiguredHint: "missing", + configuredScore: 1, + unconfiguredScore: 0, + resolveConfigured: async ({ cfg }) => Boolean(cfg.channels?.demo), + resolveStatusLines: async () => ["line"], + resolveSelectionHint: async () => "hint", + resolveQuickstartScore: async () => 3, + }, + credentials: [], + prepare: async ({ cfg }) => ({ + cfg: { ...cfg, channels: { demo: { prepared: true } } }, + }), + finalize: async ({ cfg }) => ({ + cfg: { ...cfg, channels: { demo: { finalized: true } } }, + }), + }) satisfies ChannelSetupWizard, + status: { + configuredLabel: "configured", + unconfiguredLabel: "needs setup", + configuredHint: "ready", + unconfiguredHint: "missing", + configuredScore: 1, + unconfiguredScore: 0, + }, + credentials: [], + textInputs: [], + completionNote: { title: "Done", lines: ["line"] }, + delegatePrepare: true, + delegateFinalize: true, + }); + + expect(await wizard.status.resolveConfigured({ cfg: {} })).toBe(false); + expect(await wizard.status.resolveStatusLines?.({ cfg: {}, configured: false })).toEqual([ + "line", + ]); + expect( + await wizard.prepare?.({ + cfg: {}, + accountId: "default", + credentialValues: {}, + runtime: {} as never, + prompter: {} as never, + }), + ).toEqual({ + cfg: { + channels: { + demo: { prepared: true }, + }, + }, + }); + expect( + await wizard.finalize?.({ + cfg: {}, + accountId: "default", + credentialValues: {}, + runtime: {} as never, + prompter: {} as never, + forceAllowFrom: false, + }), + ).toEqual({ + cfg: { + channels: { + demo: { finalized: true }, + }, + }, + }); + }); +}); diff --git a/src/channels/plugins/setup-wizard-proxy.ts b/src/channels/plugins/setup-wizard-proxy.ts index 195254374cb..ecc328da035 100644 --- a/src/channels/plugins/setup-wizard-proxy.ts +++ b/src/channels/plugins/setup-wizard-proxy.ts @@ -1,8 +1,10 @@ import type { OpenClawConfig } from "../../config/config.js"; +import { createDelegatedSetupWizardStatusResolvers } from "./setup-wizard-binary.js"; import type { ChannelSetupDmPolicy } from "./setup-wizard-types.js"; import type { ChannelSetupWizard } from "./setup-wizard.js"; type PromptAllowFromParams = Parameters>[0]; +type ResolveConfiguredParams = Parameters[0]; type ResolveAllowFromEntriesParams = Parameters< NonNullable["resolveEntries"] >[0]; @@ -13,6 +15,61 @@ type ResolveGroupAllowlistParams = Parameters< NonNullable["resolveAllowlist"]> >[0]; +export function createDelegatedResolveConfigured(loadWizard: () => Promise) { + return async ({ cfg }: ResolveConfiguredParams) => + await (await loadWizard()).status.resolveConfigured({ cfg }); +} + +export function createDelegatedPrepare(loadWizard: () => Promise) { + return async (params: Parameters>[0]) => + await (await loadWizard()).prepare?.(params); +} + +export function createDelegatedFinalize(loadWizard: () => Promise) { + return async (params: Parameters>[0]) => + await (await loadWizard()).finalize?.(params); +} + +type DelegatedStatusBase = Omit< + ChannelSetupWizard["status"], + "resolveConfigured" | "resolveStatusLines" | "resolveSelectionHint" | "resolveQuickstartScore" +>; + +export function createDelegatedSetupWizardProxy(params: { + channel: string; + loadWizard: () => Promise; + status: DelegatedStatusBase; + credentials?: ChannelSetupWizard["credentials"]; + textInputs?: ChannelSetupWizard["textInputs"]; + completionNote?: ChannelSetupWizard["completionNote"]; + dmPolicy?: ChannelSetupWizard["dmPolicy"]; + disable?: ChannelSetupWizard["disable"]; + resolveShouldPromptAccountIds?: ChannelSetupWizard["resolveShouldPromptAccountIds"]; + onAccountRecorded?: ChannelSetupWizard["onAccountRecorded"]; + delegatePrepare?: boolean; + delegateFinalize?: boolean; +}): ChannelSetupWizard { + return { + channel: params.channel, + status: { + ...params.status, + resolveConfigured: createDelegatedResolveConfigured(params.loadWizard), + ...createDelegatedSetupWizardStatusResolvers(params.loadWizard), + }, + ...(params.resolveShouldPromptAccountIds + ? { resolveShouldPromptAccountIds: params.resolveShouldPromptAccountIds } + : {}), + ...(params.delegatePrepare ? { prepare: createDelegatedPrepare(params.loadWizard) } : {}), + credentials: params.credentials ?? [], + ...(params.textInputs ? { textInputs: params.textInputs } : {}), + ...(params.delegateFinalize ? { finalize: createDelegatedFinalize(params.loadWizard) } : {}), + ...(params.completionNote ? { completionNote: params.completionNote } : {}), + ...(params.dmPolicy ? { dmPolicy: params.dmPolicy } : {}), + ...(params.disable ? { disable: params.disable } : {}), + ...(params.onAccountRecorded ? { onAccountRecorded: params.onAccountRecorded } : {}), + } satisfies ChannelSetupWizard; +} + export function createAllowlistSetupWizardProxy(params: { loadWizard: () => Promise; createBase: (handlers: { diff --git a/src/plugin-sdk/channel-config-helpers.test.ts b/src/plugin-sdk/channel-config-helpers.test.ts index 084d6e26532..7753c4c745e 100644 --- a/src/plugin-sdk/channel-config-helpers.test.ts +++ b/src/plugin-sdk/channel-config-helpers.test.ts @@ -110,6 +110,54 @@ describe("createScopedChannelConfigBase", () => { }).channels, ).toBeUndefined(); }); + + it("can force default account config into accounts.default", () => { + const base = createScopedChannelConfigBase({ + sectionKey: "demo", + listAccountIds: () => ["default", "alt"], + resolveAccount: (_cfg, accountId) => ({ accountId: accountId ?? "default" }), + defaultAccountId: () => "default", + clearBaseFields: [], + allowTopLevel: false, + }); + + expect( + base.setAccountEnabled!({ + cfg: { + channels: { + demo: { + token: "secret", + }, + }, + }, + accountId: "default", + enabled: true, + }).channels?.demo, + ).toEqual({ + token: "secret", + accounts: { + default: { enabled: true }, + }, + }); + expect( + base.deleteAccount!({ + cfg: { + channels: { + demo: { + token: "secret", + accounts: { + default: { enabled: true }, + }, + }, + }, + }, + accountId: "default", + }).channels?.demo, + ).toEqual({ + token: "secret", + accounts: undefined, + }); + }); }); describe("createScopedDmSecurityResolver", () => { diff --git a/src/plugin-sdk/setup.ts b/src/plugin-sdk/setup.ts index 5865de6396e..3ebce5a8f47 100644 --- a/src/plugin-sdk/setup.ts +++ b/src/plugin-sdk/setup.ts @@ -35,6 +35,7 @@ export { buildSingleChannelSecretPromptState, createAccountScopedAllowFromSection, createAccountScopedGroupAccessSection, + createAllowFromSection, createLegacyCompatChannelDmPolicy, createNestedChannelAllowFromSetter, createNestedChannelDmPolicy, @@ -55,13 +56,16 @@ export { patchChannelConfigForAccount, promptLegacyChannelAllowFrom, promptLegacyChannelAllowFromForAccount, + promptParsedAllowFromForAccount, promptParsedAllowFromForScopedChannel, promptSingleChannelSecretInput, promptResolvedAllowFrom, + resolveParsedAllowFromEntries, resolveEntriesWithOptionalToken, resolveSetupAccountId, resolveGroupAllowlistWithLookupNotes, runSingleChannelSecretStep, + setAccountAllowFromForChannel, setAccountDmAllowFromForChannel, setAccountGroupPolicyForChannel, setChannelDmPolicyWithAllowFrom, @@ -75,5 +79,17 @@ export { splitSetupEntries, } from "../channels/plugins/setup-wizard-helpers.js"; export { createAllowlistSetupWizardProxy } from "../channels/plugins/setup-wizard-proxy.js"; +export { + createDelegatedFinalize, + createDelegatedPrepare, + createDelegatedResolveConfigured, + createDelegatedSetupWizardProxy, +} from "../channels/plugins/setup-wizard-proxy.js"; +export { + createCliPathTextInput, + createDelegatedSetupWizardStatusResolvers, + createDelegatedTextInputShouldPrompt, + createDetectedBinaryStatus, +} from "../channels/plugins/setup-wizard-binary.js"; export { formatResolvedUnresolvedNote } from "./resolution-notes.js"; diff --git a/src/plugin-sdk/subpaths.test.ts b/src/plugin-sdk/subpaths.test.ts index bf7943bbfc5..052b9629421 100644 --- a/src/plugin-sdk/subpaths.test.ts +++ b/src/plugin-sdk/subpaths.test.ts @@ -99,6 +99,15 @@ describe("plugin-sdk subpath exports", () => { expect(typeof setupSdk.DEFAULT_ACCOUNT_ID).toBe("string"); expect(typeof setupSdk.createAccountScopedAllowFromSection).toBe("function"); expect(typeof setupSdk.createAccountScopedGroupAccessSection).toBe("function"); + expect(typeof setupSdk.createAllowFromSection).toBe("function"); + expect(typeof setupSdk.createCliPathTextInput).toBe("function"); + expect(typeof setupSdk.createDelegatedFinalize).toBe("function"); + expect(typeof setupSdk.createDelegatedPrepare).toBe("function"); + expect(typeof setupSdk.createDelegatedResolveConfigured).toBe("function"); + expect(typeof setupSdk.createDelegatedSetupWizardProxy).toBe("function"); + expect(typeof setupSdk.createDelegatedSetupWizardStatusResolvers).toBe("function"); + expect(typeof setupSdk.createDelegatedTextInputShouldPrompt).toBe("function"); + expect(typeof setupSdk.createDetectedBinaryStatus).toBe("function"); expect(typeof setupSdk.createLegacyCompatChannelDmPolicy).toBe("function"); expect(typeof setupSdk.createNestedChannelDmPolicy).toBe("function"); expect(typeof setupSdk.createTopLevelChannelDmPolicy).toBe("function"); @@ -107,7 +116,10 @@ describe("plugin-sdk subpath exports", () => { expect(typeof setupSdk.mergeAllowFromEntries).toBe("function"); expect(typeof setupSdk.patchNestedChannelConfigSection).toBe("function"); expect(typeof setupSdk.patchTopLevelChannelConfigSection).toBe("function"); + expect(typeof setupSdk.promptParsedAllowFromForAccount).toBe("function"); + expect(typeof setupSdk.resolveParsedAllowFromEntries).toBe("function"); expect(typeof setupSdk.resolveGroupAllowlistWithLookupNotes).toBe("function"); + expect(typeof setupSdk.setAccountAllowFromForChannel).toBe("function"); expect(typeof setupSdk.setAccountDmAllowFromForChannel).toBe("function"); expect(typeof setupSdk.setTopLevelChannelDmPolicyWithAllowFrom).toBe("function"); expect(typeof setupSdk.formatResolvedUnresolvedNote).toBe("function");