refactor: deduplicate setup wizard helpers

This commit is contained in:
Peter Steinberger 2026-03-18 03:58:15 +00:00
parent 1c81b82f48
commit 1a9114a169
29 changed files with 1196 additions and 508 deletions

View File

@ -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<OpenClawConfig> {
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: {

View File

@ -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<ChannelSetupWizard>) {
return createAllowlistSetupWizardProxy({
loadWizard: async () => (await loadWizard()).discordSetupWizard,
loadWizard,
createBase: createDiscordSetupWizardBase,
fallbackResolvedGroupAllowlist: (entries) =>
entries.map((input) => ({ input, resolved: false })),

View File

@ -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 }),

View File

@ -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<NonNullable<ChannelSetupDmPolicy["promptAllowFrom"]>>[0]["prompter"];
}): Promise<OpenClawConfig> {
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(

View File

@ -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<ResolvedIMessageAccount>({
channelKey: "imessage",
resolvePolicy: (account) => account.config.dmPolicy,
resolveAllowFrom: (account) => account.config.allowFrom,
policyPathSuffix: "dmPolicy",
});
function buildIMessageBaseSessionKey(params: {
cfg: Parameters<typeof resolveIMessageAccount>[0]["cfg"];
agentId: string;
@ -136,19 +130,8 @@ export const imessagePlugin: ChannelPlugin<ResolvedIMessageAccount> = {
}),
},
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,

View File

@ -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<OpenClawConfig> {
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["shouldPrompt"]>,
): 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<ChannelSetupWizard>) {
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;
});
}

View File

@ -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 }) => {

View File

@ -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<ResolvedIMessageAccount>({
sectionKey: IMESSAGE_CHANNEL,
listAccountIds: listIMessageAccountIds,
resolveAccount: (cfg, accountId) => resolveIMessageAccount({ cfg, accountId }),
defaultAccountId: resolveDefaultIMessageAccountId,
clearBaseFields: ["cliPath", "dbPath", "service", "region", "name"],
});
export const imessageResolveDmPolicy = createScopedDmSecurityResolver<ResolvedIMessageAccount>({
channelKey: IMESSAGE_CHANNEL,
resolvePolicy: (account) => account.config.dmPolicy,
resolveAllowFrom: (account) => account.config.allowFrom,
policyPathSuffix: "dmPolicy",
});
export function collectIMessageSecurityWarnings(params: {
account: ResolvedIMessageAccount;
cfg: Parameters<typeof resolveIMessageAccount>[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<ChannelPlugin<ResolvedIMessageAccount>["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<

View File

@ -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<CoreConfig> {
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;

View File

@ -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",

View File

@ -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<CoreConfig> {
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,
}),
});
}

View File

@ -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<OpenClawConfig> {
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({

View File

@ -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<ResolvedSignalAccount>({
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<typeof getSignalRuntime>["channel"]["signal"]["sendMessageSignal"];
function resolveSignalSendContext(params: {
@ -304,19 +298,8 @@ export const signalPlugin: ChannelPlugin<ResolvedSignalAccount> = {
}),
},
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,

View File

@ -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<OpenClawConfig> {
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["shouldPrompt"]>,
): 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<ChannelSetupWizard>) {
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;
});
}

View File

@ -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;

View File

@ -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<ResolvedSignalAccount>({
sectionKey: SIGNAL_CHANNEL,
listAccountIds: listSignalAccountIds,
resolveAccount: (cfg, accountId) => resolveSignalAccount({ cfg, accountId }),
defaultAccountId: resolveDefaultSignalAccountId,
clearBaseFields: ["account", "httpUrl", "httpHost", "httpPort", "cliPath", "name"],
});
export const signalResolveDmPolicy = createScopedDmSecurityResolver<ResolvedSignalAccount>({
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<typeof resolveSignalAccount>[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<ChannelPlugin<ResolvedSignalAccount>["setupWizard"]>;
setup: NonNullable<ChannelPlugin<ResolvedSignalAccount>["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<

View File

@ -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: [

View File

@ -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),
};

View File

@ -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,

View File

@ -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<ResolvedWhatsAppAccount>({
sectionKey: WHATSAPP_CHANNEL,
listAccountIds: listWhatsAppAccountIds,
resolveAccount: (cfg, accountId) => resolveWhatsAppAccount({ cfg, accountId }),
defaultAccountId: resolveDefaultWhatsAppAccountId,
clearBaseFields: [],
allowTopLevel: false,
});
const whatsappConfigAccessors = createScopedAccountConfigAccessors<ResolvedWhatsAppAccount>({
resolveAccount: ({ cfg, accountId }) => resolveWhatsAppAccount({ cfg, accountId }),
resolveAllowFrom: (account) => account.allowFrom,
formatAllowFrom: (allowFrom) => formatWhatsAppConfigAllowFromEntries(allowFrom),
resolveDefaultTo: (account) => account.defaultTo,
});
const whatsappResolveDmPolicy = createScopedDmSecurityResolver<ResolvedWhatsAppAccount>({
channelKey: WHATSAPP_CHANNEL,
resolvePolicy: (account) => account.dmPolicy,
resolveAllowFrom: (account) => account.allowFrom,
policyPathSuffix: "dmPolicy",
normalizeEntry: (raw) => normalizeE164(raw),
});
export function createWhatsAppSetupWizardProxy(
loadWizard: () => Promise<{
whatsappSetupWizard: NonNullable<ChannelPlugin<ResolvedWhatsAppAccount>["setupWizard"]>;
}>,
loadWizard: () => Promise<NonNullable<ChannelPlugin<ResolvedWhatsAppAccount>["setupWizard"]>>,
): NonNullable<ChannelPlugin<ResolvedWhatsAppAccount>["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;

View File

@ -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<ChannelSetupWizard> => ({
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<ChannelSetupWizard> => ({
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);
});
});

View File

@ -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<NonNullable<ChannelSetupWizardTextInput["currentValue"]>>[0];
type SetupStatusParams = Parameters<NonNullable<ChannelSetupWizardStatus["resolveStatusLines"]>>[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<boolean>;
resolveBinaryPath: (params: { cfg: OpenClawConfig }) => string;
detectBinary?: (path: string) => Promise<boolean>;
}): 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<ChannelSetupWizardTextInput["shouldPrompt"]>;
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<ChannelSetupWizard>,
): 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<ChannelSetupWizard>;
inputKey: ChannelSetupWizardTextInput["inputKey"];
}): NonNullable<ChannelSetupWizardTextInput["shouldPrompt"]> {
return async (inputParams) => {
const input = (await params.loadWizard()).textInputs?.find(
(entry) => entry.inputKey === params.inputKey,
);
return (await input?.shouldPrompt?.(inputParams)) ?? false;
};
}

View File

@ -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(

View File

@ -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<TConfig extends OpenClawConfig>(params: {
cfg: TConfig;
accountId?: string;
defaultAccountId: string;
prompter: Pick<WizardPrompter, "note" | "text">;
@ -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<string | number>;
mergeEntries?: (params: { existing: Array<string | number>; parsed: string[] }) => string[];
applyAllowFrom: (params: {
cfg: TConfig;
accountId: string;
}) => Array<string | number>;
}): Promise<OpenClawConfig> {
allowFrom: string[];
}) => TConfig | Promise<TConfig>;
}): Promise<TConfig> {
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<WizardPrompter, "note" | "text">;
noteTitle: string;
noteLines: string[];
message: string;
placeholder: string;
parseEntries: (raw: string) => ParsedAllowFromResult;
getExistingAllowFrom: (params: {
cfg: OpenClawConfig;
accountId: string;
}) => Array<string | number>;
}): Promise<OpenClawConfig> {
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<ChannelSetupWizard["allowFrom"]>["credentialInputKey"];
message: string;
placeholder: string;
invalidWithoutCredentialNote: string;
parseInputs?: NonNullable<NonNullable<ChannelSetupWizard["allowFrom"]>["parseInputs"]>;
parseId: NonNullable<NonNullable<ChannelSetupWizard["allowFrom"]>["parseId"]>;
resolveEntries?: NonNullable<NonNullable<ChannelSetupWizard["allowFrom"]>["resolveEntries"]>;
apply: NonNullable<NonNullable<ChannelSetupWizard["allowFrom"]>["apply"]>;
}): NonNullable<ChannelSetupWizard["allowFrom"]> {
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<WizardPrompter, "note">;
label: string;

View File

@ -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<ChannelSetupWizard> => ({
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<ChannelSetupWizard> => ({
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<ChannelSetupWizard> => ({
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 },
},
},
});
});
});

View File

@ -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<NonNullable<ChannelSetupDmPolicy["promptAllowFrom"]>>[0];
type ResolveConfiguredParams = Parameters<ChannelSetupWizard["status"]["resolveConfigured"]>[0];
type ResolveAllowFromEntriesParams = Parameters<
NonNullable<ChannelSetupWizard["allowFrom"]>["resolveEntries"]
>[0];
@ -13,6 +15,61 @@ type ResolveGroupAllowlistParams = Parameters<
NonNullable<NonNullable<ChannelSetupWizard["groupAccess"]>["resolveAllowlist"]>
>[0];
export function createDelegatedResolveConfigured(loadWizard: () => Promise<ChannelSetupWizard>) {
return async ({ cfg }: ResolveConfiguredParams) =>
await (await loadWizard()).status.resolveConfigured({ cfg });
}
export function createDelegatedPrepare(loadWizard: () => Promise<ChannelSetupWizard>) {
return async (params: Parameters<NonNullable<ChannelSetupWizard["prepare"]>>[0]) =>
await (await loadWizard()).prepare?.(params);
}
export function createDelegatedFinalize(loadWizard: () => Promise<ChannelSetupWizard>) {
return async (params: Parameters<NonNullable<ChannelSetupWizard["finalize"]>>[0]) =>
await (await loadWizard()).finalize?.(params);
}
type DelegatedStatusBase = Omit<
ChannelSetupWizard["status"],
"resolveConfigured" | "resolveStatusLines" | "resolveSelectionHint" | "resolveQuickstartScore"
>;
export function createDelegatedSetupWizardProxy(params: {
channel: string;
loadWizard: () => Promise<ChannelSetupWizard>;
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<TGroupResolved>(params: {
loadWizard: () => Promise<ChannelSetupWizard>;
createBase: (handlers: {

View File

@ -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", () => {

View File

@ -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";

View File

@ -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");