diff --git a/extensions/bluebubbles/src/channel.ts b/extensions/bluebubbles/src/channel.ts index 7f52e2b8a15..c9ab4c7dc47 100644 --- a/extensions/bluebubbles/src/channel.ts +++ b/extensions/bluebubbles/src/channel.ts @@ -1,10 +1,11 @@ import { formatNormalizedAllowFromEntries } from "openclaw/plugin-sdk/allow-from"; -import { mapAllowFromEntries } from "openclaw/plugin-sdk/channel-config-helpers"; -import { createAccountStatusSink } from "openclaw/plugin-sdk/channel-lifecycle"; import { - buildAccountScopedDmSecurityPolicy, - collectOpenGroupPolicyRestrictSendersWarnings, -} from "openclaw/plugin-sdk/channel-policy"; + createScopedAccountConfigAccessors, + createScopedChannelConfigBase, + createScopedDmSecurityResolver, +} from "openclaw/plugin-sdk/channel-config-helpers"; +import { createAccountStatusSink } from "openclaw/plugin-sdk/channel-lifecycle"; +import { collectOpenGroupPolicyRestrictSendersWarnings } from "openclaw/plugin-sdk/channel-policy"; import { createLazyRuntimeNamedExport } from "openclaw/plugin-sdk/lazy-runtime"; import { listBlueBubblesAccountIds, @@ -22,11 +23,9 @@ import { buildProbeChannelStatusSummary, collectBlueBubblesStatusIssues, DEFAULT_ACCOUNT_ID, - deleteAccountFromConfigSection, PAIRING_APPROVED_MESSAGE, resolveBlueBubblesGroupRequireMention, resolveBlueBubblesGroupToolPolicy, - setAccountEnabledInConfigSection, } from "./runtime-api.js"; import { blueBubblesSetupAdapter } from "./setup-core.js"; import { blueBubblesSetupWizard } from "./setup-surface.js"; @@ -43,6 +42,32 @@ const loadBlueBubblesChannelRuntime = createLazyRuntimeNamedExport( "blueBubblesChannelRuntime", ); +const bluebubblesConfigAccessors = createScopedAccountConfigAccessors({ + resolveAccount: ({ cfg, accountId }) => resolveBlueBubblesAccount({ cfg, accountId }), + resolveAllowFrom: (account: ResolvedBlueBubblesAccount) => account.config.allowFrom, + formatAllowFrom: (allowFrom) => + formatNormalizedAllowFromEntries({ + allowFrom, + normalizeEntry: (entry) => normalizeBlueBubblesHandle(entry.replace(/^bluebubbles:/i, "")), + }), +}); + +const bluebubblesConfigBase = createScopedChannelConfigBase({ + sectionKey: "bluebubbles", + listAccountIds: listBlueBubblesAccountIds, + resolveAccount: (cfg, accountId) => resolveBlueBubblesAccount({ cfg, accountId }), + defaultAccountId: resolveDefaultBlueBubblesAccountId, + clearBaseFields: ["serverUrl", "password", "name", "webhookPath"], +}); + +const resolveBlueBubblesDmPolicy = createScopedDmSecurityResolver({ + channelKey: "bluebubbles", + resolvePolicy: (account) => account.config.dmPolicy, + resolveAllowFrom: (account) => account.config.allowFrom, + policyPathSuffix: "dmPolicy", + normalizeEntry: (raw) => normalizeBlueBubblesHandle(raw.replace(/^bluebubbles:/i, "")), +}); + const meta = { id: "bluebubbles", label: "BlueBubbles", @@ -85,24 +110,7 @@ export const bluebubblesPlugin: ChannelPlugin = { configSchema: buildChannelConfigSchema(BlueBubblesConfigSchema), setupWizard: blueBubblesSetupWizard, config: { - listAccountIds: (cfg) => listBlueBubblesAccountIds(cfg), - resolveAccount: (cfg, accountId) => resolveBlueBubblesAccount({ cfg: cfg, accountId }), - defaultAccountId: (cfg) => resolveDefaultBlueBubblesAccountId(cfg), - setAccountEnabled: ({ cfg, accountId, enabled }) => - setAccountEnabledInConfigSection({ - cfg: cfg, - sectionKey: "bluebubbles", - accountId, - enabled, - allowTopLevel: true, - }), - deleteAccount: ({ cfg, accountId }) => - deleteAccountFromConfigSection({ - cfg: cfg, - sectionKey: "bluebubbles", - accountId, - clearBaseFields: ["serverUrl", "password", "name", "webhookPath"], - }), + ...bluebubblesConfigBase, isConfigured: (account) => account.configured, describeAccount: (account): ChannelAccountSnapshot => ({ accountId: account.accountId, @@ -111,28 +119,11 @@ export const bluebubblesPlugin: ChannelPlugin = { configured: account.configured, baseUrl: account.baseUrl, }), - resolveAllowFrom: ({ cfg, accountId }) => - mapAllowFromEntries(resolveBlueBubblesAccount({ cfg: cfg, accountId }).config.allowFrom), - formatAllowFrom: ({ allowFrom }) => - formatNormalizedAllowFromEntries({ - allowFrom, - normalizeEntry: (entry) => normalizeBlueBubblesHandle(entry.replace(/^bluebubbles:/i, "")), - }), + ...bluebubblesConfigAccessors, }, actions: bluebubblesMessageActions, security: { - resolveDmPolicy: ({ cfg, accountId, account }) => { - return buildAccountScopedDmSecurityPolicy({ - cfg, - channelKey: "bluebubbles", - accountId, - fallbackAccountId: account.accountId ?? DEFAULT_ACCOUNT_ID, - policy: account.config.dmPolicy, - allowFrom: account.config.allowFrom ?? [], - policyPathSuffix: "dmPolicy", - normalizeEntry: (raw) => normalizeBlueBubblesHandle(raw.replace(/^bluebubbles:/i, "")), - }); - }, + resolveDmPolicy: resolveBlueBubblesDmPolicy, collectWarnings: ({ account }) => { const groupPolicy = account.config.groupPolicy ?? "allowlist"; return collectOpenGroupPolicyRestrictSendersWarnings({ diff --git a/extensions/bluebubbles/src/setup-core.ts b/extensions/bluebubbles/src/setup-core.ts index a8d3261b7ff..df8cf016b0b 100644 --- a/extensions/bluebubbles/src/setup-core.ts +++ b/extensions/bluebubbles/src/setup-core.ts @@ -1,8 +1,8 @@ import { + createTopLevelChannelDmPolicySetter, normalizeAccountId, patchScopedAccountConfig, prepareScopedSetupConfig, - setTopLevelChannelDmPolicyWithAllowFrom, type ChannelSetupAdapter, type DmPolicy, type OpenClawConfig, @@ -10,13 +10,12 @@ import { import { applyBlueBubblesConnectionConfig } from "./config-apply.js"; const channel = "bluebubbles" as const; +const setBlueBubblesTopLevelDmPolicy = createTopLevelChannelDmPolicySetter({ + channel, +}); export function setBlueBubblesDmPolicy(cfg: OpenClawConfig, dmPolicy: DmPolicy): OpenClawConfig { - return setTopLevelChannelDmPolicyWithAllowFrom({ - cfg, - channel, - dmPolicy, - }); + return setBlueBubblesTopLevelDmPolicy(cfg, dmPolicy); } export function setBlueBubblesAllowFrom( diff --git a/extensions/discord/src/channel.ts b/extensions/discord/src/channel.ts index a99ba1c3e0c..21348036a46 100644 --- a/extensions/discord/src/channel.ts +++ b/extensions/discord/src/channel.ts @@ -3,8 +3,8 @@ import { buildAccountScopedAllowlistConfigEditor, resolveLegacyDmAllowlistConfigPaths, } from "openclaw/plugin-sdk/allowlist-config-edit"; +import { createScopedDmSecurityResolver } from "openclaw/plugin-sdk/channel-config-helpers"; import { - buildAccountScopedDmSecurityPolicy, collectOpenGroupPolicyConfiguredRouteWarnings, collectOpenProviderGroupPolicyWarnings, } from "openclaw/plugin-sdk/channel-config-helpers"; @@ -61,6 +61,14 @@ type DiscordSendFn = ReturnType< const meta = getChatChannelMeta("discord"); const REQUIRED_DISCORD_PERMISSIONS = ["ViewChannel", "SendMessages"] as const; +const resolveDiscordDmPolicy = createScopedDmSecurityResolver({ + channelKey: "discord", + resolvePolicy: (account) => account.config.dm?.policy, + resolveAllowFrom: (account) => account.config.dm?.allowFrom, + allowFromPathSuffix: "dm.", + normalizeEntry: (raw) => raw.replace(/^(discord|user):/i, "").replace(/^<@!?(\d+)>$/, "$1"), +}); + function formatDiscordIntents(intents?: { messageContent?: string; guildMembers?: string; @@ -300,18 +308,7 @@ export const discordPlugin: ChannelPlugin = { }), }, security: { - resolveDmPolicy: ({ cfg, accountId, account }) => { - return buildAccountScopedDmSecurityPolicy({ - cfg, - channelKey: "discord", - accountId, - fallbackAccountId: account.accountId ?? DEFAULT_ACCOUNT_ID, - policy: account.config.dm?.policy, - allowFrom: account.config.dm?.allowFrom ?? [], - allowFromPathSuffix: "dm.", - normalizeEntry: (raw) => raw.replace(/^(discord|user):/i, "").replace(/^<@!?(\d+)>$/, "$1"), - }); - }, + resolveDmPolicy: resolveDiscordDmPolicy, collectWarnings: ({ account, cfg }) => { const guildEntries = account.config.guilds ?? {}; const guildsConfigured = Object.keys(guildEntries).length > 0; diff --git a/extensions/discord/src/setup-core.ts b/extensions/discord/src/setup-core.ts index a05a9af65b1..ba0ba5e66be 100644 --- a/extensions/discord/src/setup-core.ts +++ b/extensions/discord/src/setup-core.ts @@ -1,12 +1,12 @@ import type { DiscordGuildEntry } from "openclaw/plugin-sdk/config-runtime"; import { + createAccountScopedAllowFromSection, + createAccountScopedGroupAccessSection, + createLegacyCompatChannelDmPolicy, DEFAULT_ACCOUNT_ID, createEnvPatchedAccountSetupAdapter, - noteChannelLookupFailure, - noteChannelLookupSummary, parseMentionOrPrefixedId, patchChannelConfigForAccount, - setLegacyChannelDmPolicyWithAllowFrom, setSetupChannelEnabled, type OpenClawConfig, } from "openclaw/plugin-sdk/setup"; @@ -88,21 +88,11 @@ export function createDiscordSetupWizardBase(handlers: { NonNullable["resolveAllowlist"]> >; }) { - const discordDmPolicy: ChannelSetupDmPolicy = { + const discordDmPolicy: ChannelSetupDmPolicy = createLegacyCompatChannelDmPolicy({ label: "Discord", channel, - policyKey: "channels.discord.dmPolicy", - allowFromKey: "channels.discord.allowFrom", - getCurrent: (cfg: OpenClawConfig) => - cfg.channels?.discord?.dmPolicy ?? cfg.channels?.discord?.dm?.policy ?? "pairing", - setPolicy: (cfg: OpenClawConfig, policy) => - setLegacyChannelDmPolicyWithAllowFrom({ - cfg, - channel, - dmPolicy: policy, - }), promptAllowFrom: handlers.promptAllowFrom, - }; + }); return { channel, @@ -145,7 +135,8 @@ export function createDiscordSetupWizardBase(handlers: { }, }, ], - groupAccess: { + groupAccess: createAccountScopedGroupAccessSection({ + channel, label: "Discord channels", placeholder: "My Server/#general, guildId/channelId, #support", currentPolicy: ({ cfg, accountId }: { cfg: OpenClawConfig; accountId: string }) => @@ -164,57 +155,8 @@ export function createDiscordSetupWizardBase(handlers: { ), updatePrompt: ({ cfg, accountId }: { cfg: OpenClawConfig; accountId: string }) => Boolean(resolveDiscordAccount({ cfg, accountId }).config.guilds), - setPolicy: ({ - cfg, - accountId, - policy, - }: { - cfg: OpenClawConfig; - accountId: string; - policy: "open" | "allowlist" | "disabled"; - }) => - patchChannelConfigForAccount({ - cfg, - channel, - accountId, - patch: { groupPolicy: policy }, - }), - resolveAllowlist: async ({ - cfg, - accountId, - credentialValues, - entries, - prompter, - }: { - cfg: OpenClawConfig; - accountId: string; - credentialValues: { token?: string }; - entries: string[]; - prompter: { note: (message: string, title?: string) => Promise }; - }) => { - try { - return await handlers.resolveGroupAllowlist({ - cfg, - accountId, - credentialValues, - entries, - prompter, - }); - } catch (error) { - await noteChannelLookupFailure({ - prompter, - label: "Discord channels", - error, - }); - await noteChannelLookupSummary({ - prompter, - label: "Discord channels", - resolvedSections: [], - unresolved: entries, - }); - return entries.map((input) => ({ input, resolved: false })); - } - }, + resolveAllowlist: handlers.resolveGroupAllowlist, + fallbackResolved: (entries) => entries.map((input) => ({ input, resolved: false })), applyAllowlist: ({ cfg, accountId, @@ -224,8 +166,9 @@ export function createDiscordSetupWizardBase(handlers: { accountId: string; resolved: unknown; }) => setDiscordGuildChannelAllowlist(cfg, accountId, resolved as never), - }, - allowFrom: { + }), + allowFrom: createAccountScopedAllowFromSection({ + channel, credentialInputKey: "token", helpTitle: "Discord allowlist", helpLines: [ @@ -242,33 +185,8 @@ export function createDiscordSetupWizardBase(handlers: { invalidWithoutCredentialNote: "Bot token missing; use numeric user ids (or mention form) only.", parseId: parseDiscordAllowFromId, - resolveEntries: async ({ - cfg, - accountId, - credentialValues, - entries, - }: { - cfg: OpenClawConfig; - accountId: string; - credentialValues: { token?: string }; - entries: string[]; - }) => await handlers.resolveAllowFromEntries({ cfg, accountId, credentialValues, entries }), - apply: async ({ - cfg, - accountId, - allowFrom, - }: { - cfg: OpenClawConfig; - accountId: string; - allowFrom: string[]; - }) => - patchChannelConfigForAccount({ - cfg, - channel, - accountId, - patch: { dmPolicy: "allowlist", allowFrom }, - }), - }, + resolveEntries: handlers.resolveAllowFromEntries, + }), dmPolicy: discordDmPolicy, disable: (cfg: OpenClawConfig) => setSetupChannelEnabled(cfg, channel, false), } satisfies ChannelSetupWizard; diff --git a/extensions/discord/src/setup-surface.ts b/extensions/discord/src/setup-surface.ts index d27c7862c99..fae95d56916 100644 --- a/extensions/discord/src/setup-surface.ts +++ b/extensions/discord/src/setup-surface.ts @@ -1,17 +1,13 @@ import { + resolveEntriesWithOptionalToken, type OpenClawConfig, - promptLegacyChannelAllowFrom, - resolveSetupAccountId, + promptLegacyChannelAllowFromForAccount, type WizardPrompter, } from "openclaw/plugin-sdk/setup"; import { type ChannelSetupWizard } from "openclaw/plugin-sdk/setup"; import { formatDocsLink } from "openclaw/plugin-sdk/setup-tools"; import { resolveDefaultDiscordAccountId, resolveDiscordAccount } from "./accounts.js"; -import { normalizeDiscordSlug } from "./monitor/allow-list.js"; -import { - resolveDiscordChannelAllowlist, - type DiscordChannelResolution, -} from "./resolve-channels.js"; +import { resolveDiscordChannelAllowlist } from "./resolve-channels.js"; import { resolveDiscordUserAllowlist } from "./resolve-users.js"; import { createDiscordSetupWizardBase, @@ -23,22 +19,26 @@ import { const channel = "discord" as const; async function resolveDiscordAllowFromEntries(params: { token?: string; entries: string[] }) { - if (!params.token?.trim()) { - return params.entries.map((input) => ({ + return await resolveEntriesWithOptionalToken({ + token: params.token, + entries: params.entries, + buildWithoutToken: (input) => ({ input, resolved: false, id: null, - })); - } - const resolved = await resolveDiscordUserAllowlist({ - token: params.token, - entries: params.entries, + }), + resolveEntries: async ({ token, entries }) => + ( + await resolveDiscordUserAllowlist({ + token, + entries, + }) + ).map((entry) => ({ + input: entry.input, + resolved: entry.resolved, + id: entry.id ?? null, + })), }); - return resolved.map((entry) => ({ - input: entry.input, - resolved: entry.resolved, - id: entry.id ?? null, - })); } async function promptDiscordAllowFrom(params: { @@ -46,17 +46,15 @@ async function promptDiscordAllowFrom(params: { prompter: WizardPrompter; accountId?: string; }): Promise { - const accountId = resolveSetupAccountId({ - accountId: params.accountId, - defaultAccountId: resolveDefaultDiscordAccountId(params.cfg), - }); - const resolved = resolveDiscordAccount({ cfg: params.cfg, accountId }); - return promptLegacyChannelAllowFrom({ + return await promptLegacyChannelAllowFromForAccount({ cfg: params.cfg, channel, prompter: params.prompter, - existing: resolved.config.allowFrom ?? resolved.config.dm?.allowFrom ?? [], - token: resolved.token, + accountId: params.accountId, + defaultAccountId: resolveDefaultDiscordAccountId(params.cfg), + resolveAccount: (cfg, accountId) => resolveDiscordAccount({ cfg, accountId }), + resolveExisting: (account) => account.config.allowFrom ?? account.config.dm?.allowFrom ?? [], + resolveToken: (account) => account.token, noteTitle: "Discord allowlist", noteLines: [ "Allowlist Discord DMs by username (we resolve to user ids).", @@ -71,11 +69,17 @@ async function promptDiscordAllowFrom(params: { placeholder: "@alice, 123456789012345678", parseId: parseDiscordAllowFromId, invalidWithoutTokenNote: "Bot token missing; use numeric user ids (or mention form) only.", - resolveEntries: ({ token, entries }) => - resolveDiscordUserAllowlist({ - token, - entries, - }), + resolveEntries: async ({ token, entries }) => + ( + await resolveDiscordUserAllowlist({ + token, + entries, + }) + ).map((entry) => ({ + input: entry.input, + resolved: entry.resolved, + id: entry.id ?? null, + })), }); } @@ -85,18 +89,20 @@ async function resolveDiscordGroupAllowlist(params: { credentialValues: { token?: string }; entries: string[]; }) { - const token = - resolveDiscordAccount({ cfg: params.cfg, accountId: params.accountId }).token || - (typeof params.credentialValues.token === "string" ? params.credentialValues.token : ""); - if (!token || params.entries.length === 0) { - return params.entries.map((input) => ({ + return await resolveEntriesWithOptionalToken({ + token: + resolveDiscordAccount({ cfg: params.cfg, accountId: params.accountId }).token || + (typeof params.credentialValues.token === "string" ? params.credentialValues.token : ""), + entries: params.entries, + buildWithoutToken: (input) => ({ input, resolved: false, - })); - } - return await resolveDiscordChannelAllowlist({ - token, - entries: params.entries, + }), + resolveEntries: async ({ token, entries }) => + await resolveDiscordChannelAllowlist({ + token, + entries, + }), }); } diff --git a/extensions/feishu/src/channel.ts b/extensions/feishu/src/channel.ts index 5bac3945608..649c51a7ad9 100644 --- a/extensions/feishu/src/channel.ts +++ b/extensions/feishu/src/channel.ts @@ -1,5 +1,8 @@ import { formatAllowFromLowercase } from "openclaw/plugin-sdk/allow-from"; -import { mapAllowFromEntries } from "openclaw/plugin-sdk/channel-config-helpers"; +import { + createHybridChannelConfigBase, + createScopedAccountConfigAccessors, +} from "openclaw/plugin-sdk/channel-config-helpers"; import { collectAllowlistProviderRestrictSendersWarnings } from "openclaw/plugin-sdk/channel-policy"; import { createMessageToolCardSchema } from "openclaw/plugin-sdk/channel-runtime"; import type { @@ -126,6 +129,21 @@ function setFeishuNamedAccountEnabled( }; } +const feishuConfigBase = createHybridChannelConfigBase({ + sectionKey: "feishu", + listAccountIds: listFeishuAccountIds, + resolveAccount: (cfg, accountId) => resolveFeishuAccount({ cfg, accountId }), + defaultAccountId: resolveDefaultFeishuAccountId, + clearBaseFields: [], +}); + +const feishuConfigAccessors = createScopedAccountConfigAccessors({ + resolveAccount: ({ cfg, accountId }) => + resolveFeishuAccount({ cfg: cfg as ClawdbotConfig, accountId }), + resolveAllowFrom: (account) => account.config.allowFrom, + formatAllowFrom: (allowFrom) => formatAllowFromLowercase({ allowFrom }), +}); + function isFeishuReactionsActionEnabled(params: { cfg: ClawdbotConfig; account: ResolvedFeishuAccount; @@ -377,15 +395,10 @@ export const feishuPlugin: ChannelPlugin = { reload: { configPrefixes: ["channels.feishu"] }, configSchema: buildChannelConfigSchema(FeishuConfigSchema), config: { - listAccountIds: (cfg) => listFeishuAccountIds(cfg), - resolveAccount: (cfg, accountId) => resolveFeishuAccount({ cfg, accountId }), - defaultAccountId: (cfg) => resolveDefaultFeishuAccountId(cfg), + ...feishuConfigBase, setAccountEnabled: ({ cfg, accountId, enabled }) => { - const account = resolveFeishuAccount({ cfg, accountId }); const isDefault = accountId === DEFAULT_ACCOUNT_ID; - if (isDefault) { - // For default account, set top-level enabled return { ...cfg, channels: { @@ -397,8 +410,6 @@ export const feishuPlugin: ChannelPlugin = { }, }; } - - // For named accounts, set enabled in accounts[accountId] return setFeishuNamedAccountEnabled(cfg, accountId, enabled); }, deleteAccount: ({ cfg, accountId }) => { @@ -442,11 +453,7 @@ export const feishuPlugin: ChannelPlugin = { appId: account.appId, domain: account.domain, }), - resolveAllowFrom: ({ cfg, accountId }) => { - const account = resolveFeishuAccount({ cfg, accountId }); - return mapAllowFromEntries(account.config?.allowFrom); - }, - formatAllowFrom: ({ allowFrom }) => formatAllowFromLowercase({ allowFrom }), + ...feishuConfigAccessors, }, actions: { describeMessageTool: describeFeishuMessageTool, diff --git a/extensions/feishu/src/setup-surface.ts b/extensions/feishu/src/setup-surface.ts index e990f308624..9a98f171bca 100644 --- a/extensions/feishu/src/setup-surface.ts +++ b/extensions/feishu/src/setup-surface.ts @@ -1,17 +1,17 @@ import { buildSingleChannelSecretPromptState, + createTopLevelChannelAllowFromSetter, + createTopLevelChannelDmPolicy, + createTopLevelChannelGroupPolicySetter, DEFAULT_ACCOUNT_ID, formatDocsLink, hasConfiguredSecretInput, mergeAllowFromEntries, + patchTopLevelChannelConfigSection, promptSingleChannelSecretInput, - setTopLevelChannelAllowFrom, - setTopLevelChannelDmPolicyWithAllowFrom, - setTopLevelChannelGroupPolicy, splitSetupEntries, type ChannelSetupDmPolicy, type ChannelSetupWizard, - type DmPolicy, type OpenClawConfig, type SecretInput, } from "openclaw/plugin-sdk/setup"; @@ -21,6 +21,13 @@ import { feishuSetupAdapter } from "./setup-core.js"; import type { FeishuConfig } from "./types.js"; const channel = "feishu" as const; +const setFeishuAllowFrom = createTopLevelChannelAllowFromSetter({ + channel, +}); +const setFeishuGroupPolicy = createTopLevelChannelGroupPolicySetter({ + channel, + enabled: true, +}); function normalizeString(value: unknown): string | undefined { if (typeof value !== "string") { @@ -30,34 +37,6 @@ function normalizeString(value: unknown): string | undefined { return trimmed || undefined; } -function setFeishuDmPolicy(cfg: OpenClawConfig, dmPolicy: DmPolicy): OpenClawConfig { - return setTopLevelChannelDmPolicyWithAllowFrom({ - cfg, - channel, - dmPolicy, - }) as OpenClawConfig; -} - -function setFeishuAllowFrom(cfg: OpenClawConfig, allowFrom: string[]): OpenClawConfig { - return setTopLevelChannelAllowFrom({ - cfg, - channel, - allowFrom, - }) as OpenClawConfig; -} - -function setFeishuGroupPolicy( - cfg: OpenClawConfig, - groupPolicy: "open" | "allowlist" | "disabled", -): OpenClawConfig { - return setTopLevelChannelGroupPolicy({ - cfg, - channel, - groupPolicy, - enabled: true, - }) as OpenClawConfig; -} - function setFeishuGroupAllowFrom(cfg: OpenClawConfig, groupAllowFrom: string[]): OpenClawConfig { return { ...cfg, @@ -177,15 +156,14 @@ async function promptFeishuAppId(params: { ).trim(); } -const feishuDmPolicy: ChannelSetupDmPolicy = { +const feishuDmPolicy: ChannelSetupDmPolicy = createTopLevelChannelDmPolicy({ label: "Feishu", channel, policyKey: "channels.feishu.dmPolicy", allowFromKey: "channels.feishu.allowFrom", getCurrent: (cfg) => (cfg.channels?.feishu as FeishuConfig | undefined)?.dmPolicy ?? "pairing", - setPolicy: (cfg, policy) => setFeishuDmPolicy(cfg as OpenClawConfig, policy), promptAllowFrom: promptFeishuAllowFrom, -}; +}); export { feishuSetupAdapter } from "./setup-core.js"; @@ -263,13 +241,12 @@ export const feishuSetupWizard: ChannelSetupWizard = { }); if (appSecretResult.action === "use-env") { - next = { - ...next, - channels: { - ...next.channels, - feishu: { ...next.channels?.feishu, enabled: true }, - }, - }; + next = patchTopLevelChannelConfigSection({ + cfg: next, + channel, + enabled: true, + patch: {}, + }) as OpenClawConfig; } else if (appSecretResult.action === "set") { appSecret = appSecretResult.value; appSecretProbeValue = appSecretResult.resolvedValue; @@ -281,18 +258,15 @@ export const feishuSetupWizard: ChannelSetupWizard = { } if (appId && appSecret) { - next = { - ...next, - channels: { - ...next.channels, - feishu: { - ...next.channels?.feishu, - enabled: true, - appId, - appSecret, - }, + next = patchTopLevelChannelConfigSection({ + cfg: next, + channel, + enabled: true, + patch: { + appId, + appSecret, }, - }; + }) as OpenClawConfig; try { const probe = await probeFeishu({ @@ -326,16 +300,11 @@ export const feishuSetupWizard: ChannelSetupWizard = { ], initialValue: currentMode, })) as "websocket" | "webhook"; - next = { - ...next, - channels: { - ...next.channels, - feishu: { - ...next.channels?.feishu, - connectionMode, - }, - }, - }; + next = patchTopLevelChannelConfigSection({ + cfg: next, + channel, + patch: { connectionMode }, + }) as OpenClawConfig; if (connectionMode === "webhook") { const currentVerificationToken = (next.channels?.feishu as FeishuConfig | undefined) @@ -357,16 +326,11 @@ export const feishuSetupWizard: ChannelSetupWizard = { preferredEnvVar: "FEISHU_VERIFICATION_TOKEN", }); if (verificationTokenResult.action === "set") { - next = { - ...next, - channels: { - ...next.channels, - feishu: { - ...next.channels?.feishu, - verificationToken: verificationTokenResult.value, - }, - }, - }; + next = patchTopLevelChannelConfigSection({ + cfg: next, + channel, + patch: { verificationToken: verificationTokenResult.value }, + }) as OpenClawConfig; } const currentEncryptKey = (next.channels?.feishu as FeishuConfig | undefined)?.encryptKey; @@ -387,16 +351,11 @@ export const feishuSetupWizard: ChannelSetupWizard = { preferredEnvVar: "FEISHU_ENCRYPT_KEY", }); if (encryptKeyResult.action === "set") { - next = { - ...next, - channels: { - ...next.channels, - feishu: { - ...next.channels?.feishu, - encryptKey: encryptKeyResult.value, - }, - }, - }; + next = patchTopLevelChannelConfigSection({ + cfg: next, + channel, + patch: { encryptKey: encryptKeyResult.value }, + }) as OpenClawConfig; } const currentWebhookPath = (next.channels?.feishu as FeishuConfig | undefined)?.webhookPath; @@ -407,16 +366,11 @@ export const feishuSetupWizard: ChannelSetupWizard = { validate: (value) => (String(value ?? "").trim() ? undefined : "Required"), }), ).trim(); - next = { - ...next, - channels: { - ...next.channels, - feishu: { - ...next.channels?.feishu, - webhookPath, - }, - }, - }; + next = patchTopLevelChannelConfigSection({ + cfg: next, + channel, + patch: { webhookPath }, + }) as OpenClawConfig; } const currentDomain = (next.channels?.feishu as FeishuConfig | undefined)?.domain ?? "feishu"; @@ -428,16 +382,11 @@ export const feishuSetupWizard: ChannelSetupWizard = { ], initialValue: currentDomain, }); - next = { - ...next, - channels: { - ...next.channels, - feishu: { - ...next.channels?.feishu, - domain: domain as "feishu" | "lark", - }, - }, - }; + next = patchTopLevelChannelConfigSection({ + cfg: next, + channel, + patch: { domain: domain as "feishu" | "lark" }, + }) as OpenClawConfig; const groupPolicy = (await prompter.select({ message: "Group chat policy", @@ -468,11 +417,10 @@ export const feishuSetupWizard: ChannelSetupWizard = { return { cfg: next }; }, dmPolicy: feishuDmPolicy, - disable: (cfg) => ({ - ...cfg, - channels: { - ...cfg.channels, - feishu: { ...cfg.channels?.feishu, enabled: false }, - }, - }), + disable: (cfg) => + patchTopLevelChannelConfigSection({ + cfg, + channel, + patch: { enabled: false }, + }), }; diff --git a/extensions/googlechat/src/setup-surface.ts b/extensions/googlechat/src/setup-surface.ts index 0af6e3d4f54..93f6d37d82e 100644 --- a/extensions/googlechat/src/setup-surface.ts +++ b/extensions/googlechat/src/setup-surface.ts @@ -1,15 +1,14 @@ import { - addWildcardAllowFrom, applySetupAccountConfigPatch, + createNestedChannelDmPolicy, DEFAULT_ACCOUNT_ID, formatDocsLink, mergeAllowFromEntries, migrateBaseNameToDefaultAccount, - setTopLevelChannelDmPolicyWithAllowFrom, + patchNestedChannelConfigSection, splitSetupEntries, type ChannelSetupDmPolicy, type ChannelSetupWizard, - type DmPolicy, type OpenClawConfig, } from "openclaw/plugin-sdk/setup"; import { @@ -25,25 +24,6 @@ const ENV_SERVICE_ACCOUNT_FILE = "GOOGLE_CHAT_SERVICE_ACCOUNT_FILE"; const USE_ENV_FLAG = "__googlechatUseEnv"; const AUTH_METHOD_FLAG = "__googlechatAuthMethod"; -function setGoogleChatDmPolicy(cfg: OpenClawConfig, policy: DmPolicy) { - const allowFrom = - policy === "open" ? addWildcardAllowFrom(cfg.channels?.googlechat?.dm?.allowFrom) : undefined; - return { - ...cfg, - channels: { - ...cfg.channels, - googlechat: { - ...cfg.channels?.googlechat, - dm: { - ...cfg.channels?.googlechat?.dm, - policy, - ...(allowFrom ? { allowFrom } : {}), - }, - }, - }, - }; -} - async function promptAllowFrom(params: { cfg: OpenClawConfig; prompter: Parameters>[0]["prompter"]; @@ -57,32 +37,28 @@ async function promptAllowFrom(params: { }); const parts = splitSetupEntries(String(entry)); const unique = mergeAllowFromEntries(undefined, parts); - return { - ...params.cfg, - channels: { - ...params.cfg.channels, - googlechat: { - ...params.cfg.channels?.googlechat, - enabled: true, - dm: { - ...params.cfg.channels?.googlechat?.dm, - policy: "allowlist", - allowFrom: unique, - }, - }, + return patchNestedChannelConfigSection({ + cfg: params.cfg, + channel, + section: "dm", + enabled: true, + patch: { + policy: "allowlist", + allowFrom: unique, }, - }; + }); } -const googlechatDmPolicy: ChannelSetupDmPolicy = { +const googlechatDmPolicy: ChannelSetupDmPolicy = createNestedChannelDmPolicy({ label: "Google Chat", channel, + section: "dm", policyKey: "channels.googlechat.dm.policy", allowFromKey: "channels.googlechat.dm.allowFrom", getCurrent: (cfg) => cfg.channels?.googlechat?.dm?.policy ?? "pairing", - setPolicy: (cfg, policy) => setGoogleChatDmPolicy(cfg, policy), promptAllowFrom, -}; + enabled: true, +}); export { googlechatSetupAdapter } from "./setup-core.js"; diff --git a/extensions/imessage/src/channel.ts b/extensions/imessage/src/channel.ts index 3c34cea1be7..2eadc5a8a90 100644 --- a/extensions/imessage/src/channel.ts +++ b/extensions/imessage/src/channel.ts @@ -1,6 +1,6 @@ import { buildAccountScopedAllowlistConfigEditor } from "openclaw/plugin-sdk/allowlist-config-edit"; import { - buildAccountScopedDmSecurityPolicy, + createScopedDmSecurityResolver, collectAllowlistProviderRestrictSendersWarnings, } from "openclaw/plugin-sdk/channel-config-helpers"; import { resolveOutboundSendDep } from "openclaw/plugin-sdk/channel-runtime"; @@ -26,6 +26,13 @@ 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; @@ -127,17 +134,7 @@ export const imessagePlugin: ChannelPlugin = { }), }, security: { - resolveDmPolicy: ({ cfg, accountId, account }) => { - return buildAccountScopedDmSecurityPolicy({ - cfg, - channelKey: "imessage", - accountId, - fallbackAccountId: account.accountId ?? DEFAULT_ACCOUNT_ID, - policy: account.config.dmPolicy, - allowFrom: account.config.allowFrom ?? [], - policyPathSuffix: "dmPolicy", - }); - }, + resolveDmPolicy: resolveIMessageDmPolicy, collectWarnings: ({ account, cfg }) => { return collectAllowlistProviderRestrictSendersWarnings({ cfg, diff --git a/extensions/irc/src/channel.ts b/extensions/irc/src/channel.ts index ed754933e68..18fa8953045 100644 --- a/extensions/irc/src/channel.ts +++ b/extensions/irc/src/channel.ts @@ -1,7 +1,10 @@ import { formatNormalizedAllowFromEntries } from "openclaw/plugin-sdk/allow-from"; -import { createScopedAccountConfigAccessors } from "openclaw/plugin-sdk/channel-config-helpers"; import { - buildAccountScopedDmSecurityPolicy, + createScopedAccountConfigAccessors, + createScopedChannelConfigBase, + createScopedDmSecurityResolver, +} from "openclaw/plugin-sdk/channel-config-helpers"; +import { buildOpenGroupPolicyWarning, collectAllowlistProviderGroupPolicyWarnings, } from "openclaw/plugin-sdk/channel-policy"; @@ -11,10 +14,8 @@ import { buildChannelConfigSchema, createAccountStatusSink, DEFAULT_ACCOUNT_ID, - deleteAccountFromConfigSection, getChatChannelMeta, PAIRING_APPROVED_MESSAGE, - setAccountEnabledInConfigSection, type ChannelPlugin, } from "openclaw/plugin-sdk/irc"; import { runStoppablePassiveMonitor } from "../../shared/passive-monitor.js"; @@ -61,6 +62,33 @@ const ircConfigAccessors = createScopedAccountConfigAccessors({ resolveDefaultTo: (account: ResolvedIrcAccount) => account.config.defaultTo, }); +const ircConfigBase = createScopedChannelConfigBase({ + sectionKey: "irc", + listAccountIds: listIrcAccountIds, + resolveAccount: (cfg, accountId) => resolveIrcAccount({ cfg, accountId }), + defaultAccountId: resolveDefaultIrcAccountId, + clearBaseFields: [ + "name", + "host", + "port", + "tls", + "nick", + "username", + "realname", + "password", + "passwordFile", + "channels", + ], +}); + +const resolveIrcDmPolicy = createScopedDmSecurityResolver({ + channelKey: "irc", + resolvePolicy: (account) => account.config.dmPolicy, + resolveAllowFrom: (account) => account.config.allowFrom, + policyPathSuffix: "dmPolicy", + normalizeEntry: (raw) => normalizeIrcAllowEntry(raw), +}); + export const ircPlugin: ChannelPlugin = { id: "irc", meta: { @@ -88,35 +116,7 @@ export const ircPlugin: ChannelPlugin = { reload: { configPrefixes: ["channels.irc"] }, configSchema: buildChannelConfigSchema(IrcConfigSchema), config: { - listAccountIds: (cfg) => listIrcAccountIds(cfg as CoreConfig), - resolveAccount: (cfg, accountId) => resolveIrcAccount({ cfg: cfg as CoreConfig, accountId }), - defaultAccountId: (cfg) => resolveDefaultIrcAccountId(cfg as CoreConfig), - setAccountEnabled: ({ cfg, accountId, enabled }) => - setAccountEnabledInConfigSection({ - cfg: cfg as CoreConfig, - sectionKey: "irc", - accountId, - enabled, - allowTopLevel: true, - }), - deleteAccount: ({ cfg, accountId }) => - deleteAccountFromConfigSection({ - cfg: cfg as CoreConfig, - sectionKey: "irc", - accountId, - clearBaseFields: [ - "name", - "host", - "port", - "tls", - "nick", - "username", - "realname", - "password", - "passwordFile", - "channels", - ], - }), + ...ircConfigBase, isConfigured: (account) => account.configured, describeAccount: (account) => ({ accountId: account.accountId, @@ -132,18 +132,7 @@ export const ircPlugin: ChannelPlugin = { ...ircConfigAccessors, }, security: { - resolveDmPolicy: ({ cfg, accountId, account }) => { - return buildAccountScopedDmSecurityPolicy({ - cfg, - channelKey: "irc", - accountId, - fallbackAccountId: account.accountId ?? DEFAULT_ACCOUNT_ID, - policy: account.config.dmPolicy, - allowFrom: account.config.allowFrom ?? [], - policyPathSuffix: "dmPolicy", - normalizeEntry: (raw) => normalizeIrcAllowEntry(raw), - }); - }, + resolveDmPolicy: resolveIrcDmPolicy, collectWarnings: ({ account, cfg }) => { const warnings = collectAllowlistProviderGroupPolicyWarnings({ cfg, diff --git a/extensions/irc/src/setup-core.ts b/extensions/irc/src/setup-core.ts index 23422e30ba0..8e3a347e35a 100644 --- a/extensions/irc/src/setup-core.ts +++ b/extensions/irc/src/setup-core.ts @@ -4,15 +4,19 @@ import type { DmPolicy } from "openclaw/plugin-sdk/config-runtime"; import { normalizeAccountId } from "openclaw/plugin-sdk/routing"; import { applyAccountNameToChannelSection, + createTopLevelChannelAllowFromSetter, + createTopLevelChannelDmPolicySetter, patchScopedAccountConfig, } from "openclaw/plugin-sdk/setup"; -import { - setTopLevelChannelAllowFrom, - setTopLevelChannelDmPolicyWithAllowFrom, -} from "openclaw/plugin-sdk/setup"; import type { CoreConfig, IrcAccountConfig, IrcNickServConfig } from "./types.js"; const channel = "irc" as const; +const setIrcTopLevelDmPolicy = createTopLevelChannelDmPolicySetter({ + channel, +}); +const setIrcTopLevelAllowFrom = createTopLevelChannelAllowFromSetter({ + channel, +}); type IrcSetupInput = ChannelSetupInput & { host?: string; @@ -53,19 +57,11 @@ export function updateIrcAccountConfig( } export function setIrcDmPolicy(cfg: CoreConfig, dmPolicy: DmPolicy): CoreConfig { - return setTopLevelChannelDmPolicyWithAllowFrom({ - cfg, - channel, - dmPolicy, - }) as CoreConfig; + return setIrcTopLevelDmPolicy(cfg, dmPolicy) as CoreConfig; } export function setIrcAllowFrom(cfg: CoreConfig, allowFrom: string[]): CoreConfig { - return setTopLevelChannelAllowFrom({ - cfg, - channel, - allowFrom, - }) as CoreConfig; + return setIrcTopLevelAllowFrom(cfg, allowFrom) as CoreConfig; } export function setIrcNickServ( diff --git a/extensions/line/src/setup-surface.ts b/extensions/line/src/setup-surface.ts index 154419d7527..c4f8c3b7da3 100644 --- a/extensions/line/src/setup-surface.ts +++ b/extensions/line/src/setup-surface.ts @@ -1,9 +1,9 @@ import { + createTopLevelChannelDmPolicy, DEFAULT_ACCOUNT_ID, formatDocsLink, resolveLineAccount, setSetupChannelEnabled, - setTopLevelChannelDmPolicyWithAllowFrom, splitSetupEntries, type ChannelSetupDmPolicy, type ChannelSetupWizard, @@ -35,19 +35,13 @@ const LINE_ALLOW_FROM_HELP_LINES = [ `Docs: ${formatDocsLink("/channels/line", "channels/line")}`, ]; -const lineDmPolicy: ChannelSetupDmPolicy = { +const lineDmPolicy: ChannelSetupDmPolicy = createTopLevelChannelDmPolicy({ label: "LINE", channel, policyKey: "channels.line.dmPolicy", allowFromKey: "channels.line.allowFrom", getCurrent: (cfg) => cfg.channels?.line?.dmPolicy ?? "pairing", - setPolicy: (cfg, policy) => - setTopLevelChannelDmPolicyWithAllowFrom({ - cfg, - channel, - dmPolicy: policy, - }), -}; +}); export { lineSetupAdapter } from "./setup-core.js"; diff --git a/extensions/matrix/src/setup-surface.ts b/extensions/matrix/src/setup-surface.ts index 09e9438a410..bf2a3769d96 100644 --- a/extensions/matrix/src/setup-surface.ts +++ b/extensions/matrix/src/setup-surface.ts @@ -1,16 +1,16 @@ import { - addWildcardAllowFrom, buildSingleChannelSecretPromptState, + createNestedChannelDmPolicy, + createTopLevelChannelGroupPolicySetter, DEFAULT_ACCOUNT_ID, formatDocsLink, formatResolvedUnresolvedNote, hasConfiguredSecretInput, mergeAllowFromEntries, + patchNestedChannelConfigSection, promptSingleChannelSecretInput, - setTopLevelChannelGroupPolicy, type ChannelSetupDmPolicy, type ChannelSetupWizard, - type DmPolicy, type OpenClawConfig, type SecretInput, type WizardPrompter, @@ -23,25 +23,10 @@ import { buildMatrixConfigUpdate, matrixSetupAdapter } from "./setup-core.js"; import type { CoreConfig } from "./types.js"; const channel = "matrix" as const; - -function setMatrixDmPolicy(cfg: CoreConfig, policy: DmPolicy) { - const allowFrom = - policy === "open" ? addWildcardAllowFrom(cfg.channels?.matrix?.dm?.allowFrom) : undefined; - return { - ...cfg, - channels: { - ...cfg.channels, - matrix: { - ...cfg.channels?.matrix, - dm: { - ...cfg.channels?.matrix?.dm, - policy, - ...(allowFrom ? { allowFrom } : {}), - }, - }, - }, - }; -} +const setMatrixGroupPolicy = createTopLevelChannelGroupPolicySetter({ + channel, + enabled: true, +}); async function noteMatrixAuthHelp(prompter: WizardPrompter): Promise { await prompter.note( @@ -128,33 +113,19 @@ async function promptMatrixAllowFrom(params: { } const unique = mergeAllowFromEntries(existingAllowFrom, resolvedIds); - return { - ...cfg, - channels: { - ...cfg.channels, - matrix: { - ...cfg.channels?.matrix, - enabled: true, - dm: { - ...cfg.channels?.matrix?.dm, - policy: "allowlist", - allowFrom: unique, - }, - }, + return patchNestedChannelConfigSection({ + cfg, + channel, + section: "dm", + enabled: true, + patch: { + policy: "allowlist", + allowFrom: unique, }, - }; + }) as CoreConfig; } } -function setMatrixGroupPolicy(cfg: CoreConfig, groupPolicy: "open" | "allowlist" | "disabled") { - return setTopLevelChannelGroupPolicy({ - cfg, - channel: "matrix", - groupPolicy, - enabled: true, - }) as CoreConfig; -} - function setMatrixGroupRooms(cfg: CoreConfig, roomKeys: string[]) { const groups = Object.fromEntries(roomKeys.map((key) => [key, { allow: true }])); return { @@ -242,15 +213,16 @@ const matrixGroupAccess: NonNullable = { setMatrixGroupRooms(cfg as CoreConfig, resolved as string[]), }; -const matrixDmPolicy: ChannelSetupDmPolicy = { +const matrixDmPolicy: ChannelSetupDmPolicy = createNestedChannelDmPolicy({ label: "Matrix", channel, + section: "dm", policyKey: "channels.matrix.dm.policy", allowFromKey: "channels.matrix.dm.allowFrom", getCurrent: (cfg) => (cfg as CoreConfig).channels?.matrix?.dm?.policy ?? "pairing", - setPolicy: (cfg, policy) => setMatrixDmPolicy(cfg as CoreConfig, policy), promptAllowFrom: promptMatrixAllowFrom, -}; + enabled: true, +}); export { matrixSetupAdapter } from "./setup-core.js"; diff --git a/extensions/mattermost/src/channel.ts b/extensions/mattermost/src/channel.ts index 4bc716ac27e..e0392728b41 100644 --- a/extensions/mattermost/src/channel.ts +++ b/extensions/mattermost/src/channel.ts @@ -1,9 +1,10 @@ import { formatNormalizedAllowFromEntries } from "openclaw/plugin-sdk/allow-from"; -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 { collectAllowlistProviderRestrictSendersWarnings } from "openclaw/plugin-sdk/channel-policy"; import { createMessageToolButtonsSchema } from "openclaw/plugin-sdk/channel-runtime"; import type { ChannelMessageToolDiscovery } from "openclaw/plugin-sdk/channel-runtime"; import { buildPassiveProbedChannelStatusSummary } from "../../shared/channel-status-summary.js"; @@ -31,10 +32,8 @@ import { buildChannelConfigSchema, createAccountStatusSink, DEFAULT_ACCOUNT_ID, - deleteAccountFromConfigSection, resolveAllowlistProviderRuntimeGroupPolicy, resolveDefaultGroupPolicy, - setAccountEnabledInConfigSection, type ChannelMessageActionAdapter, type ChannelMessageActionName, type ChannelPlugin, @@ -258,6 +257,22 @@ const mattermostConfigAccessors = createScopedAccountConfigAccessors({ }), }); +const mattermostConfigBase = createScopedChannelConfigBase({ + sectionKey: "mattermost", + listAccountIds: listMattermostAccountIds, + resolveAccount: (cfg, accountId) => resolveMattermostAccount({ cfg, accountId }), + defaultAccountId: resolveDefaultMattermostAccountId, + clearBaseFields: ["botToken", "baseUrl", "name"], +}); + +const resolveMattermostDmPolicy = createScopedDmSecurityResolver({ + channelKey: "mattermost", + resolvePolicy: (account) => account.config.dmPolicy, + resolveAllowFrom: (account) => account.config.allowFrom, + policyPathSuffix: "dmPolicy", + normalizeEntry: (raw) => normalizeAllowEntry(raw), +}); + export const mattermostPlugin: ChannelPlugin = { id: "mattermost", meta: { @@ -295,24 +310,7 @@ export const mattermostPlugin: ChannelPlugin = { reload: { configPrefixes: ["channels.mattermost"] }, configSchema: buildChannelConfigSchema(MattermostConfigSchema), config: { - listAccountIds: (cfg) => listMattermostAccountIds(cfg), - resolveAccount: (cfg, accountId) => resolveMattermostAccount({ cfg, accountId }), - defaultAccountId: (cfg) => resolveDefaultMattermostAccountId(cfg), - setAccountEnabled: ({ cfg, accountId, enabled }) => - setAccountEnabledInConfigSection({ - cfg, - sectionKey: "mattermost", - accountId, - enabled, - allowTopLevel: true, - }), - deleteAccount: ({ cfg, accountId }) => - deleteAccountFromConfigSection({ - cfg, - sectionKey: "mattermost", - accountId, - clearBaseFields: ["botToken", "baseUrl", "name"], - }), + ...mattermostConfigBase, isConfigured: (account) => Boolean(account.botToken && account.baseUrl), describeAccount: (account) => ({ accountId: account.accountId, @@ -325,18 +323,7 @@ export const mattermostPlugin: ChannelPlugin = { ...mattermostConfigAccessors, }, security: { - resolveDmPolicy: ({ cfg, accountId, account }) => { - return buildAccountScopedDmSecurityPolicy({ - cfg, - channelKey: "mattermost", - accountId, - fallbackAccountId: account.accountId ?? DEFAULT_ACCOUNT_ID, - policy: account.config.dmPolicy, - allowFrom: account.config.allowFrom ?? [], - policyPathSuffix: "dmPolicy", - normalizeEntry: (raw) => normalizeAllowEntry(raw), - }); - }, + resolveDmPolicy: resolveMattermostDmPolicy, collectWarnings: ({ account, cfg }) => { return collectAllowlistProviderRestrictSendersWarnings({ cfg, diff --git a/extensions/msteams/src/channel.ts b/extensions/msteams/src/channel.ts index 77061d037de..730d425f9a0 100644 --- a/extensions/msteams/src/channel.ts +++ b/extensions/msteams/src/channel.ts @@ -1,4 +1,8 @@ import { formatAllowFromLowercase } from "openclaw/plugin-sdk/allow-from"; +import { + createScopedAccountConfigAccessors, + createTopLevelChannelConfigBase, +} from "openclaw/plugin-sdk/channel-config-helpers"; import { collectAllowlistProviderRestrictSendersWarnings } from "openclaw/plugin-sdk/channel-policy"; import { createMessageToolCardSchema } from "openclaw/plugin-sdk/channel-runtime"; import type { @@ -63,6 +67,30 @@ const loadMSTeamsChannelRuntime = createLazyRuntimeNamedExport( "msTeamsChannelRuntime", ); +const resolveMSTeamsChannelConfig = (cfg: OpenClawConfig) => ({ + allowFrom: cfg.channels?.msteams?.allowFrom, + defaultTo: cfg.channels?.msteams?.defaultTo, +}); + +const msteamsConfigBase = createTopLevelChannelConfigBase({ + sectionKey: "msteams", + resolveAccount: (cfg) => ({ + accountId: DEFAULT_ACCOUNT_ID, + enabled: cfg.channels?.msteams?.enabled !== false, + configured: Boolean(resolveMSTeamsCredentials(cfg.channels?.msteams)), + }), +}); + +const msteamsConfigAccessors = createScopedAccountConfigAccessors<{ + allowFrom?: Array; + defaultTo?: string; +}>({ + resolveAccount: ({ cfg }) => resolveMSTeamsChannelConfig(cfg), + resolveAllowFrom: (account) => account.allowFrom, + formatAllowFrom: (allowFrom) => formatAllowFromLowercase({ allowFrom }), + resolveDefaultTo: (account) => account.defaultTo, +}); + function describeMSTeamsMessageTool({ cfg, }: Parameters< @@ -128,43 +156,14 @@ export const msteamsPlugin: ChannelPlugin = { reload: { configPrefixes: ["channels.msteams"] }, configSchema: buildChannelConfigSchema(MSTeamsConfigSchema), config: { - listAccountIds: () => [DEFAULT_ACCOUNT_ID], - resolveAccount: (cfg) => ({ - accountId: DEFAULT_ACCOUNT_ID, - enabled: cfg.channels?.msteams?.enabled !== false, - configured: Boolean(resolveMSTeamsCredentials(cfg.channels?.msteams)), - }), - defaultAccountId: () => DEFAULT_ACCOUNT_ID, - setAccountEnabled: ({ cfg, enabled }) => ({ - ...cfg, - channels: { - ...cfg.channels, - msteams: { - ...cfg.channels?.msteams, - enabled, - }, - }, - }), - deleteAccount: ({ cfg }) => { - const next = { ...cfg } as OpenClawConfig; - const nextChannels = { ...cfg.channels }; - delete nextChannels.msteams; - if (Object.keys(nextChannels).length > 0) { - next.channels = nextChannels; - } else { - delete next.channels; - } - return next; - }, + ...msteamsConfigBase, isConfigured: (_account, cfg) => Boolean(resolveMSTeamsCredentials(cfg.channels?.msteams)), describeAccount: (account) => ({ accountId: account.accountId, enabled: account.enabled, configured: account.configured, }), - resolveAllowFrom: ({ cfg }) => cfg.channels?.msteams?.allowFrom ?? [], - formatAllowFrom: ({ allowFrom }) => formatAllowFromLowercase({ allowFrom }), - resolveDefaultTo: ({ cfg }) => cfg.channels?.msteams?.defaultTo?.trim() || undefined, + ...msteamsConfigAccessors, }, security: { collectWarnings: ({ cfg }) => { diff --git a/extensions/msteams/src/setup-surface.ts b/extensions/msteams/src/setup-surface.ts index 769e68cd58c..3407a25187f 100644 --- a/extensions/msteams/src/setup-surface.ts +++ b/extensions/msteams/src/setup-surface.ts @@ -1,14 +1,13 @@ import { + createTopLevelChannelAllowFromSetter, + createTopLevelChannelDmPolicy, + createTopLevelChannelGroupPolicySetter, DEFAULT_ACCOUNT_ID, formatDocsLink, mergeAllowFromEntries, - setTopLevelChannelAllowFrom, - setTopLevelChannelDmPolicyWithAllowFrom, - setTopLevelChannelGroupPolicy, splitSetupEntries, type ChannelSetupDmPolicy, type ChannelSetupWizard, - type DmPolicy, type OpenClawConfig, type WizardPrompter, } from "openclaw/plugin-sdk/setup"; @@ -23,22 +22,13 @@ import { msteamsSetupAdapter } from "./setup-core.js"; import { hasConfiguredMSTeamsCredentials, resolveMSTeamsCredentials } from "./token.js"; const channel = "msteams" as const; - -function setMSTeamsDmPolicy(cfg: OpenClawConfig, dmPolicy: DmPolicy) { - return setTopLevelChannelDmPolicyWithAllowFrom({ - cfg, - channel, - dmPolicy, - }); -} - -function setMSTeamsAllowFrom(cfg: OpenClawConfig, allowFrom: string[]): OpenClawConfig { - return setTopLevelChannelAllowFrom({ - cfg, - channel, - allowFrom, - }); -} +const setMSTeamsAllowFrom = createTopLevelChannelAllowFromSetter({ + channel, +}); +const setMSTeamsGroupPolicy = createTopLevelChannelGroupPolicySetter({ + channel, + enabled: true, +}); function looksLikeGuid(value: string): boolean { return /^[0-9a-fA-F-]{16,}$/.test(value); @@ -146,18 +136,6 @@ async function noteMSTeamsCredentialHelp(prompter: WizardPrompter): Promise, @@ -281,15 +259,14 @@ const msteamsGroupAccess: NonNullable = { setMSTeamsTeamsAllowlist(cfg, resolved as Array<{ teamKey: string; channelKey?: string }>), }; -const msteamsDmPolicy: ChannelSetupDmPolicy = { +const msteamsDmPolicy: ChannelSetupDmPolicy = createTopLevelChannelDmPolicy({ label: "MS Teams", channel, policyKey: "channels.msteams.dmPolicy", allowFromKey: "channels.msteams.allowFrom", getCurrent: (cfg) => cfg.channels?.msteams?.dmPolicy ?? "pairing", - setPolicy: (cfg, policy) => setMSTeamsDmPolicy(cfg, policy), promptAllowFrom: promptMSTeamsAllowFrom, -}; +}); export { msteamsSetupAdapter } from "./setup-core.js"; diff --git a/extensions/nextcloud-talk/src/channel.ts b/extensions/nextcloud-talk/src/channel.ts index 16910b7371e..a9dbad6018d 100644 --- a/extensions/nextcloud-talk/src/channel.ts +++ b/extensions/nextcloud-talk/src/channel.ts @@ -1,8 +1,11 @@ import { formatAllowFromLowercase } from "openclaw/plugin-sdk/allow-from"; -import { mapAllowFromEntries } from "openclaw/plugin-sdk/channel-config-helpers"; +import { + createScopedAccountConfigAccessors, + createScopedChannelConfigBase, + createScopedDmSecurityResolver, +} from "openclaw/plugin-sdk/channel-config-helpers"; import { createAccountStatusSink } from "openclaw/plugin-sdk/channel-lifecycle"; import { - buildAccountScopedDmSecurityPolicy, collectAllowlistProviderGroupPolicyWarnings, collectOpenGroupPolicyRouteAllowlistWarnings, } from "openclaw/plugin-sdk/channel-policy"; @@ -13,8 +16,6 @@ import { buildRuntimeAccountStatusSnapshot, clearAccountEntryFields, DEFAULT_ACCOUNT_ID, - deleteAccountFromConfigSection, - setAccountEnabledInConfigSection, type ChannelPlugin, type OpenClawConfig, } from "../runtime-api.js"; @@ -49,6 +50,37 @@ const meta = { quickstartAllowFrom: true, }; +const nextcloudTalkConfigAccessors = + createScopedAccountConfigAccessors({ + resolveAccount: ({ cfg, accountId }) => + resolveNextcloudTalkAccount({ cfg: cfg as CoreConfig, accountId }), + resolveAllowFrom: (account) => account.config.allowFrom, + formatAllowFrom: (allowFrom) => + formatAllowFromLowercase({ + allowFrom, + stripPrefixRe: /^(nextcloud-talk|nc-talk|nc):/i, + }), + }); + +const nextcloudTalkConfigBase = createScopedChannelConfigBase< + ResolvedNextcloudTalkAccount, + CoreConfig +>({ + sectionKey: "nextcloud-talk", + listAccountIds: listNextcloudTalkAccountIds, + resolveAccount: (cfg, accountId) => resolveNextcloudTalkAccount({ cfg, accountId }), + defaultAccountId: resolveDefaultNextcloudTalkAccountId, + clearBaseFields: ["botSecret", "botSecretFile", "baseUrl", "name"], +}); + +const resolveNextcloudTalkDmPolicy = createScopedDmSecurityResolver({ + channelKey: "nextcloud-talk", + resolvePolicy: (account) => account.config.dmPolicy, + resolveAllowFrom: (account) => account.config.allowFrom, + policyPathSuffix: "dmPolicy", + normalizeEntry: (raw) => raw.replace(/^(nextcloud-talk|nc-talk|nc):/i, "").toLowerCase(), +}); + export const nextcloudTalkPlugin: ChannelPlugin = { id: "nextcloud-talk", meta, @@ -72,25 +104,7 @@ export const nextcloudTalkPlugin: ChannelPlugin = reload: { configPrefixes: ["channels.nextcloud-talk"] }, configSchema: buildChannelConfigSchema(NextcloudTalkConfigSchema), config: { - listAccountIds: (cfg) => listNextcloudTalkAccountIds(cfg as CoreConfig), - resolveAccount: (cfg, accountId) => - resolveNextcloudTalkAccount({ cfg: cfg as CoreConfig, accountId }), - defaultAccountId: (cfg) => resolveDefaultNextcloudTalkAccountId(cfg as CoreConfig), - setAccountEnabled: ({ cfg, accountId, enabled }) => - setAccountEnabledInConfigSection({ - cfg, - sectionKey: "nextcloud-talk", - accountId, - enabled, - allowTopLevel: true, - }), - deleteAccount: ({ cfg, accountId }) => - deleteAccountFromConfigSection({ - cfg, - sectionKey: "nextcloud-talk", - accountId, - clearBaseFields: ["botSecret", "botSecretFile", "baseUrl", "name"], - }), + ...nextcloudTalkConfigBase, isConfigured: (account) => Boolean(account.secret?.trim() && account.baseUrl?.trim()), describeAccount: (account) => ({ accountId: account.accountId, @@ -100,29 +114,10 @@ export const nextcloudTalkPlugin: ChannelPlugin = secretSource: account.secretSource, baseUrl: account.baseUrl ? "[set]" : "[missing]", }), - resolveAllowFrom: ({ cfg, accountId }) => - mapAllowFromEntries( - resolveNextcloudTalkAccount({ cfg: cfg as CoreConfig, accountId }).config.allowFrom, - ).map((entry) => entry.toLowerCase()), - formatAllowFrom: ({ allowFrom }) => - formatAllowFromLowercase({ - allowFrom, - stripPrefixRe: /^(nextcloud-talk|nc-talk|nc):/i, - }), + ...nextcloudTalkConfigAccessors, }, security: { - resolveDmPolicy: ({ cfg, accountId, account }) => { - return buildAccountScopedDmSecurityPolicy({ - cfg, - channelKey: "nextcloud-talk", - accountId, - fallbackAccountId: account.accountId ?? DEFAULT_ACCOUNT_ID, - policy: account.config.dmPolicy, - allowFrom: account.config.allowFrom ?? [], - policyPathSuffix: "dmPolicy", - normalizeEntry: (raw) => raw.replace(/^(nextcloud-talk|nc-talk|nc):/i, "").toLowerCase(), - }); - }, + resolveDmPolicy: resolveNextcloudTalkDmPolicy, collectWarnings: ({ account, cfg }) => { const roomAllowlistConfigured = account.config.rooms && Object.keys(account.config.rooms).length > 0; diff --git a/extensions/nextcloud-talk/src/setup-core.ts b/extensions/nextcloud-talk/src/setup-core.ts index 4e976605b85..5994890f8b2 100644 --- a/extensions/nextcloud-talk/src/setup-core.ts +++ b/extensions/nextcloud-talk/src/setup-core.ts @@ -8,9 +8,9 @@ import { } from "openclaw/plugin-sdk/setup"; import { mergeAllowFromEntries, + createTopLevelChannelDmPolicy, resolveSetupAccountId, setSetupChannelEnabled, - setTopLevelChannelDmPolicyWithAllowFrom, } from "openclaw/plugin-sdk/setup"; import type { ChannelSetupDmPolicy } from "openclaw/plugin-sdk/setup"; import { type ChannelSetupWizard } from "openclaw/plugin-sdk/setup"; @@ -21,7 +21,7 @@ import { resolveDefaultNextcloudTalkAccountId, resolveNextcloudTalkAccount, } from "./accounts.js"; -import type { CoreConfig, DmPolicy } from "./types.js"; +import type { CoreConfig } from "./types.js"; const channel = "nextcloud-talk" as const; @@ -46,14 +46,6 @@ export function validateNextcloudTalkBaseUrl(value: string): string | undefined return undefined; } -function setNextcloudTalkDmPolicy(cfg: CoreConfig, dmPolicy: DmPolicy): CoreConfig { - return setTopLevelChannelDmPolicyWithAllowFrom({ - cfg, - channel, - dmPolicy, - }) as CoreConfig; -} - export function setNextcloudTalkAccountConfig( cfg: CoreConfig, accountId: string, @@ -174,15 +166,14 @@ async function promptNextcloudTalkAllowFromForAccount(params: { }); } -export const nextcloudTalkDmPolicy: ChannelSetupDmPolicy = { +export const nextcloudTalkDmPolicy: ChannelSetupDmPolicy = createTopLevelChannelDmPolicy({ label: "Nextcloud Talk", channel, policyKey: "channels.nextcloud-talk.dmPolicy", allowFromKey: "channels.nextcloud-talk.allowFrom", getCurrent: (cfg) => cfg.channels?.["nextcloud-talk"]?.dmPolicy ?? "pairing", - setPolicy: (cfg, policy) => setNextcloudTalkDmPolicy(cfg as CoreConfig, policy as DmPolicy), promptAllowFrom: promptNextcloudTalkAllowFromForAccount, -}; +}); export const nextcloudTalkSetupAdapter: ChannelSetupAdapter = { resolveAccountId: ({ accountId }) => normalizeAccountId(accountId), diff --git a/extensions/nostr/src/channel.ts b/extensions/nostr/src/channel.ts index 21dfce3a9da..b75ad26b0ba 100644 --- a/extensions/nostr/src/channel.ts +++ b/extensions/nostr/src/channel.ts @@ -1,3 +1,4 @@ +import { createScopedDmSecurityResolver } from "openclaw/plugin-sdk/channel-config-helpers"; import { buildChannelConfigSchema, collectStatusIssuesFromLastError, @@ -31,6 +32,22 @@ const activeBuses = new Map(); // Store metrics snapshots per account (for status reporting) const metricsSnapshots = new Map(); +const resolveNostrDmPolicy = createScopedDmSecurityResolver({ + channelKey: "nostr", + resolvePolicy: (account) => account.config.dmPolicy, + resolveAllowFrom: (account) => account.config.allowFrom, + policyPathSuffix: "dmPolicy", + defaultPolicy: "pairing", + approveHint: formatPairingApproveHint("nostr"), + normalizeEntry: (raw) => { + try { + return normalizePubkey(raw.replace(/^nostr:/i, "").trim()); + } catch { + return raw.trim(); + } + }, +}); + export const nostrPlugin: ChannelPlugin = { id: "nostr", meta: { @@ -101,22 +118,7 @@ export const nostrPlugin: ChannelPlugin = { }, security: { - resolveDmPolicy: ({ account }) => { - return { - policy: account.config.dmPolicy ?? "pairing", - allowFrom: account.config.allowFrom ?? [], - policyPath: "channels.nostr.dmPolicy", - allowFromPath: "channels.nostr.allowFrom", - approveHint: formatPairingApproveHint("nostr"), - normalizeEntry: (raw) => { - try { - return normalizePubkey(raw.replace(/^nostr:/i, "").trim()); - } catch { - return raw.trim(); - } - }, - }; - }, + resolveDmPolicy: resolveNostrDmPolicy, }, messaging: { diff --git a/extensions/nostr/src/setup-surface.ts b/extensions/nostr/src/setup-surface.ts index fca302e75fb..2cf2fb46d61 100644 --- a/extensions/nostr/src/setup-surface.ts +++ b/extensions/nostr/src/setup-surface.ts @@ -1,12 +1,12 @@ import type { ChannelSetupAdapter } from "openclaw/plugin-sdk/channel-runtime"; import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime"; -import type { DmPolicy } from "openclaw/plugin-sdk/config-runtime"; import { DEFAULT_ACCOUNT_ID } from "openclaw/plugin-sdk/routing"; import { + createTopLevelChannelAllowFromSetter, + createTopLevelChannelDmPolicy, mergeAllowFromEntries, parseSetupEntriesWithParser, - setTopLevelChannelAllowFrom, - setTopLevelChannelDmPolicyWithAllowFrom, + patchTopLevelChannelConfigSection, splitSetupEntries, } from "openclaw/plugin-sdk/setup"; import type { ChannelSetupDmPolicy } from "openclaw/plugin-sdk/setup"; @@ -18,6 +18,9 @@ import { getPublicKeyFromPrivate, normalizePubkey } from "./nostr-bus.js"; import { resolveNostrAccount } from "./types.js"; const channel = "nostr" as const; +const setNostrAllowFrom = createTopLevelChannelAllowFromSetter({ + channel, +}); const NOSTR_SETUP_HELP_LINES = [ "Use a Nostr private key in nsec or 64-character hex format.", @@ -36,46 +39,6 @@ const NOSTR_ALLOW_FROM_HELP_LINES = [ `Docs: ${formatDocsLink("/channels/nostr", "channels/nostr")}`, ]; -function patchNostrConfig(params: { - cfg: OpenClawConfig; - patch: Record; - clearFields?: string[]; - enabled?: boolean; -}): OpenClawConfig { - const existing = (params.cfg.channels?.nostr ?? {}) as Record; - const nextNostr = { ...existing }; - for (const field of params.clearFields ?? []) { - delete nextNostr[field]; - } - return { - ...params.cfg, - channels: { - ...params.cfg.channels, - nostr: { - ...nextNostr, - ...(params.enabled ? { enabled: true } : {}), - ...params.patch, - }, - }, - }; -} - -function setNostrDmPolicy(cfg: OpenClawConfig, dmPolicy: DmPolicy): OpenClawConfig { - return setTopLevelChannelDmPolicyWithAllowFrom({ - cfg, - channel, - dmPolicy, - }); -} - -function setNostrAllowFrom(cfg: OpenClawConfig, allowFrom: string[]): OpenClawConfig { - return setTopLevelChannelAllowFrom({ - cfg, - channel, - allowFrom, - }); -} - function parseRelayUrls(raw: string): { relays: string[]; error?: string } { const entries = splitSetupEntries(raw); const relays: string[] = []; @@ -126,21 +89,21 @@ async function promptNostrAllowFrom(params: { return setNostrAllowFrom(params.cfg, mergeAllowFromEntries(existing, parsed.entries)); } -const nostrDmPolicy: ChannelSetupDmPolicy = { +const nostrDmPolicy: ChannelSetupDmPolicy = createTopLevelChannelDmPolicy({ label: "Nostr", channel, policyKey: "channels.nostr.dmPolicy", allowFromKey: "channels.nostr.allowFrom", getCurrent: (cfg) => cfg.channels?.nostr?.dmPolicy ?? "pairing", - setPolicy: (cfg, policy) => setNostrDmPolicy(cfg, policy), promptAllowFrom: promptNostrAllowFrom, -}; +}); export const nostrSetupAdapter: ChannelSetupAdapter = { resolveAccountId: () => DEFAULT_ACCOUNT_ID, applyAccountName: ({ cfg, name }) => - patchNostrConfig({ + patchTopLevelChannelConfigSection({ cfg, + channel, patch: name?.trim() ? { name: name.trim() } : {}, }), validateInput: ({ input }) => { @@ -174,8 +137,9 @@ export const nostrSetupAdapter: ChannelSetupAdapter = { const relayResult = typedInput.relayUrls?.trim() ? parseRelayUrls(typedInput.relayUrls) : { relays: [] }; - return patchNostrConfig({ + return patchTopLevelChannelConfigSection({ cfg, + channel, enabled: true, clearFields: typedInput.useEnv ? ["privateKey"] : undefined, patch: { @@ -218,8 +182,9 @@ export const nostrSetupWizard: ChannelSetupWizard = { Boolean(process.env.NOSTR_PRIVATE_KEY?.trim()) && !resolveNostrAccount({ cfg, accountId }).config.privateKey?.trim(), apply: async ({ cfg }) => - patchNostrConfig({ + patchTopLevelChannelConfigSection({ cfg, + channel, enabled: true, clearFields: ["privateKey"], patch: {}, @@ -247,15 +212,17 @@ export const nostrSetupWizard: ChannelSetupWizard = { }; }, applyUseEnv: async ({ cfg }) => - patchNostrConfig({ + patchTopLevelChannelConfigSection({ cfg, + channel, enabled: true, clearFields: ["privateKey"], patch: {}, }), applySet: async ({ cfg, resolvedValue }) => - patchNostrConfig({ + patchTopLevelChannelConfigSection({ cfg, + channel, enabled: true, patch: { privateKey: resolvedValue }, }), @@ -280,8 +247,9 @@ export const nostrSetupWizard: ChannelSetupWizard = { validate: ({ value }) => parseRelayUrls(value).error, applySet: async ({ cfg, value }) => { const relayResult = parseRelayUrls(value); - return patchNostrConfig({ + return patchTopLevelChannelConfigSection({ cfg, + channel, enabled: true, clearFields: relayResult.relays.length > 0 ? undefined : ["relays"], patch: relayResult.relays.length > 0 ? { relays: relayResult.relays } : {}, @@ -291,8 +259,9 @@ export const nostrSetupWizard: ChannelSetupWizard = { ], dmPolicy: nostrDmPolicy, disable: (cfg) => - patchNostrConfig({ + patchTopLevelChannelConfigSection({ cfg, + channel, patch: { enabled: false }, }), }; diff --git a/extensions/signal/src/channel.ts b/extensions/signal/src/channel.ts index 454eaa2cb9f..b9d10dd25f5 100644 --- a/extensions/signal/src/channel.ts +++ b/extensions/signal/src/channel.ts @@ -1,7 +1,7 @@ import { buildAccountScopedAllowlistConfigEditor } from "openclaw/plugin-sdk/allowlist-config-edit"; import { - buildAccountScopedDmSecurityPolicy, 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"; @@ -35,6 +35,13 @@ 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()), +}); type SignalSendFn = ReturnType["channel"]["signal"]["sendMessageSignal"]; function resolveSignalSendContext(params: { @@ -297,18 +304,7 @@ export const signalPlugin: ChannelPlugin = { }), }, security: { - resolveDmPolicy: ({ cfg, accountId, account }) => { - return buildAccountScopedDmSecurityPolicy({ - cfg, - channelKey: "signal", - 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()), - }); - }, + resolveDmPolicy: resolveSignalDmPolicy, collectWarnings: ({ account, cfg }) => { return collectAllowlistProviderRestrictSendersWarnings({ cfg, diff --git a/extensions/slack/src/channel.ts b/extensions/slack/src/channel.ts index d2c59c25468..6024f7b5ed6 100644 --- a/extensions/slack/src/channel.ts +++ b/extensions/slack/src/channel.ts @@ -3,7 +3,7 @@ import { resolveLegacyDmAllowlistConfigPaths, } from "openclaw/plugin-sdk/allowlist-config-edit"; import { - buildAccountScopedDmSecurityPolicy, + createScopedDmSecurityResolver, collectOpenGroupPolicyConfiguredRouteWarnings, collectOpenProviderGroupPolicyWarnings, } from "openclaw/plugin-sdk/channel-config-helpers"; @@ -54,6 +54,14 @@ import { buildSlackThreadingToolContext } from "./threading-tool-context.js"; const SLACK_CHANNEL_TYPE_CACHE = new Map(); +const resolveSlackDmPolicy = createScopedDmSecurityResolver({ + channelKey: "slack", + resolvePolicy: (account) => account.dm?.policy, + resolveAllowFrom: (account) => account.dm?.allowFrom, + allowFromPathSuffix: "dm.", + normalizeEntry: (raw) => raw.replace(/^(slack|user):/i, ""), +}); + // Select the appropriate Slack token for read/write operations. function getTokenForOperation( account: ResolvedSlackAccount, @@ -351,18 +359,7 @@ export const slackPlugin: ChannelPlugin = { }), }, security: { - resolveDmPolicy: ({ cfg, accountId, account }) => { - return buildAccountScopedDmSecurityPolicy({ - cfg, - channelKey: "slack", - accountId, - fallbackAccountId: account.accountId ?? DEFAULT_ACCOUNT_ID, - policy: account.dm?.policy, - allowFrom: account.dm?.allowFrom ?? [], - allowFromPathSuffix: "dm.", - normalizeEntry: (raw) => raw.replace(/^(slack|user):/i, ""), - }); - }, + resolveDmPolicy: resolveSlackDmPolicy, collectWarnings: ({ account, cfg }) => { const channelAllowlistConfigured = Boolean(account.config.channels) && Object.keys(account.config.channels ?? {}).length > 0; diff --git a/extensions/slack/src/setup-core.ts b/extensions/slack/src/setup-core.ts index 5a8fe1feab4..bb9495767b0 100644 --- a/extensions/slack/src/setup-core.ts +++ b/extensions/slack/src/setup-core.ts @@ -1,15 +1,14 @@ import { createAllowlistSetupWizardProxy, + createAccountScopedAllowFromSection, + createAccountScopedGroupAccessSection, + createLegacyCompatChannelDmPolicy, DEFAULT_ACCOUNT_ID, createEnvPatchedAccountSetupAdapter, hasConfiguredSecretInput, type OpenClawConfig, - noteChannelLookupFailure, - noteChannelLookupSummary, parseMentionOrPrefixedId, patchChannelConfigForAccount, - setAccountGroupPolicyForChannel, - setLegacyChannelDmPolicyWithAllowFrom, setSetupChannelEnabled, } from "openclaw/plugin-sdk/setup"; import { @@ -112,21 +111,11 @@ export function createSlackSetupWizardBase(handlers: { NonNullable["resolveAllowlist"]> >; }) { - const slackDmPolicy: ChannelSetupDmPolicy = { + const slackDmPolicy: ChannelSetupDmPolicy = createLegacyCompatChannelDmPolicy({ label: "Slack", channel, - policyKey: "channels.slack.dmPolicy", - allowFromKey: "channels.slack.allowFrom", - getCurrent: (cfg: OpenClawConfig) => - cfg.channels?.slack?.dmPolicy ?? cfg.channels?.slack?.dm?.policy ?? "pairing", - setPolicy: (cfg: OpenClawConfig, policy) => - setLegacyChannelDmPolicyWithAllowFrom({ - cfg, - channel, - dmPolicy: policy, - }), promptAllowFrom: handlers.promptAllowFrom, - }; + }); return { channel, @@ -178,7 +167,9 @@ export function createSlackSetupWizardBase(handlers: { }), ], dmPolicy: slackDmPolicy, - allowFrom: { + allowFrom: createAccountScopedAllowFromSection({ + channel, + credentialInputKey: "botToken", helpTitle: "Slack allowlist", helpLines: [ "Allowlist Slack DMs by username (we resolve to user ids).", @@ -188,7 +179,6 @@ export function createSlackSetupWizardBase(handlers: { "Multiple entries: comma-separated.", `Docs: ${formatDocsLink("/slack", "slack")}`, ], - credentialInputKey: "botToken", message: "Slack allowFrom (usernames or ids)", placeholder: "@alice, U12345678", invalidWithoutCredentialNote: "Slack token missing; use user ids (or mention form) only.", @@ -200,34 +190,10 @@ export function createSlackSetupWizardBase(handlers: { idPattern: /^[A-Z][A-Z0-9]+$/i, normalizeId: (id) => id.toUpperCase(), }), - resolveEntries: async ({ - cfg, - accountId, - credentialValues, - entries, - }: { - cfg: OpenClawConfig; - accountId: string; - credentialValues: { botToken?: string }; - entries: string[]; - }) => await handlers.resolveAllowFromEntries({ cfg, accountId, credentialValues, entries }), - apply: ({ - cfg, - accountId, - allowFrom, - }: { - cfg: OpenClawConfig; - accountId: string; - allowFrom: string[]; - }) => - patchChannelConfigForAccount({ - cfg, - channel, - accountId, - patch: { dmPolicy: "allowlist", allowFrom }, - }), - }, - groupAccess: { + resolveEntries: handlers.resolveAllowFromEntries, + }), + groupAccess: createAccountScopedGroupAccessSection({ + channel, label: "Slack channels", placeholder: "#general, #private, C123", currentPolicy: ({ cfg, accountId }: { cfg: OpenClawConfig; accountId: string }) => @@ -238,57 +204,8 @@ export function createSlackSetupWizardBase(handlers: { .map(([key]) => key), updatePrompt: ({ cfg, accountId }: { cfg: OpenClawConfig; accountId: string }) => Boolean(resolveSlackAccount({ cfg, accountId }).config.channels), - setPolicy: ({ - cfg, - accountId, - policy, - }: { - cfg: OpenClawConfig; - accountId: string; - policy: "open" | "allowlist" | "disabled"; - }) => - setAccountGroupPolicyForChannel({ - cfg, - channel, - accountId, - groupPolicy: policy, - }), - resolveAllowlist: async ({ - cfg, - accountId, - credentialValues, - entries, - prompter, - }: { - cfg: OpenClawConfig; - accountId: string; - credentialValues: { botToken?: string }; - entries: string[]; - prompter: { note: (message: string, title?: string) => Promise }; - }) => { - try { - return await handlers.resolveGroupAllowlist({ - cfg, - accountId, - credentialValues, - entries, - prompter, - }); - } catch (error) { - await noteChannelLookupFailure({ - prompter, - label: "Slack channels", - error, - }); - await noteChannelLookupSummary({ - prompter, - label: "Slack channels", - resolvedSections: [], - unresolved: entries, - }); - return entries; - } - }, + resolveAllowlist: handlers.resolveGroupAllowlist, + fallbackResolved: (entries) => entries, applyAllowlist: ({ cfg, accountId, @@ -298,7 +215,7 @@ export function createSlackSetupWizardBase(handlers: { accountId: string; resolved: unknown; }) => setSlackChannelAllowlist(cfg, accountId, resolved as string[]), - }, + }), disable: (cfg: OpenClawConfig) => setSetupChannelEnabled(cfg, channel, false), } satisfies ChannelSetupWizard; } diff --git a/extensions/slack/src/setup-surface.ts b/extensions/slack/src/setup-surface.ts index 6731ddff84b..3f3e17301f5 100644 --- a/extensions/slack/src/setup-surface.ts +++ b/extensions/slack/src/setup-surface.ts @@ -1,10 +1,10 @@ import { noteChannelLookupFailure, noteChannelLookupSummary, + resolveEntriesWithOptionalToken, type OpenClawConfig, parseMentionOrPrefixedId, - promptLegacyChannelAllowFrom, - resolveSetupAccountId, + promptLegacyChannelAllowFromForAccount, type WizardPrompter, } from "openclaw/plugin-sdk/setup"; import type { @@ -22,22 +22,26 @@ async function resolveSlackAllowFromEntries(params: { token?: string; entries: string[]; }): Promise { - if (!params.token?.trim()) { - return params.entries.map((input) => ({ + return await resolveEntriesWithOptionalToken({ + token: params.token, + entries: params.entries, + buildWithoutToken: (input) => ({ input, resolved: false, id: null, - })); - } - const resolved = await resolveSlackUserAllowlist({ - token: params.token, - entries: params.entries, + }), + resolveEntries: async ({ token, entries }) => + ( + await resolveSlackUserAllowlist({ + token, + entries, + }) + ).map((entry) => ({ + input: entry.input, + resolved: entry.resolved, + id: entry.id ?? null, + })), }); - return resolved.map((entry) => ({ - input: entry.input, - resolved: entry.resolved, - id: entry.id ?? null, - })); } async function promptSlackAllowFrom(params: { @@ -45,14 +49,6 @@ async function promptSlackAllowFrom(params: { prompter: WizardPrompter; accountId?: string; }): Promise { - const accountId = resolveSetupAccountId({ - accountId: params.accountId, - defaultAccountId: resolveDefaultSlackAccountId(params.cfg), - }); - const resolved = resolveSlackAccount({ cfg: params.cfg, accountId }); - const token = resolved.userToken ?? resolved.botToken ?? ""; - const existing = - params.cfg.channels?.slack?.allowFrom ?? params.cfg.channels?.slack?.dm?.allowFrom ?? []; const parseId = (value: string) => parseMentionOrPrefixedId({ value, @@ -62,12 +58,16 @@ async function promptSlackAllowFrom(params: { normalizeId: (id) => id.toUpperCase(), }); - return promptLegacyChannelAllowFrom({ + return await promptLegacyChannelAllowFromForAccount({ cfg: params.cfg, channel, prompter: params.prompter, - existing, - token, + accountId: params.accountId, + defaultAccountId: resolveDefaultSlackAccountId(params.cfg), + resolveAccount: (cfg, accountId) => resolveSlackAccount({ cfg, accountId }), + resolveExisting: (_account, cfg) => + cfg.channels?.slack?.allowFrom ?? cfg.channels?.slack?.dm?.allowFrom ?? [], + resolveToken: (account) => account.userToken ?? account.botToken ?? "", noteTitle: "Slack allowlist", noteLines: [ "Allowlist Slack DMs by username (we resolve to user ids).", @@ -81,11 +81,17 @@ async function promptSlackAllowFrom(params: { placeholder: "@alice, U12345678", parseId, invalidWithoutTokenNote: "Slack token missing; use user ids (or mention form) only.", - resolveEntries: ({ token, entries }) => - resolveSlackUserAllowlist({ - token, - entries, - }), + resolveEntries: async ({ token, entries }) => + ( + await resolveSlackUserAllowlist({ + token, + entries, + }) + ).map((entry) => ({ + input: entry.input, + resolved: entry.resolved, + id: entry.id ?? null, + })), }); } @@ -102,11 +108,21 @@ async function resolveSlackGroupAllowlist(params: { accountId: params.accountId, }); const activeBotToken = accountWithTokens.botToken || params.credentialValues.botToken || ""; - if (activeBotToken && params.entries.length > 0) { + if (params.entries.length > 0) { try { - const resolved = await resolveSlackChannelAllowlist({ + const resolved = await resolveEntriesWithOptionalToken<{ + input: string; + resolved: boolean; + id?: string; + }>({ token: activeBotToken, entries: params.entries, + buildWithoutToken: (input) => ({ input, resolved: false, id: undefined }), + resolveEntries: async ({ token, entries }) => + await resolveSlackChannelAllowlist({ + token, + entries, + }), }); const resolvedKeys = resolved .filter((entry) => entry.resolved && entry.id) diff --git a/extensions/synology-chat/src/channel.test.ts b/extensions/synology-chat/src/channel.test.ts index 4e2b9a27890..851b6e92561 100644 --- a/extensions/synology-chat/src/channel.test.ts +++ b/extensions/synology-chat/src/channel.test.ts @@ -55,7 +55,7 @@ describe("createSynologyChatPlugin", () => { it("defaultAccountId returns 'default'", () => { const plugin = createSynologyChatPlugin(); - expect(plugin.config.defaultAccountId({})).toBe("default"); + expect(plugin.config.defaultAccountId?.({})).toBe("default"); }); }); @@ -79,7 +79,7 @@ describe("createSynologyChatPlugin", () => { expect(result.policy).toBe("allowlist"); expect(result.allowFrom).toEqual(["user1"]); expect(typeof result.normalizeEntry).toBe("function"); - expect(result.normalizeEntry(" USER1 ")).toBe("user1"); + expect(result.normalizeEntry?.(" USER1 ")).toBe("user1"); }); }); diff --git a/extensions/synology-chat/src/channel.ts b/extensions/synology-chat/src/channel.ts index 67aadff1c12..3a3cbb99eb2 100644 --- a/extensions/synology-chat/src/channel.ts +++ b/extensions/synology-chat/src/channel.ts @@ -4,13 +4,12 @@ * Implements the ChannelPlugin interface following the LINE pattern. */ -import { z } from "zod"; import { - DEFAULT_ACCOUNT_ID, - setAccountEnabledInConfigSection, - registerPluginHttpRoute, - buildChannelConfigSchema, -} from "../api.js"; + createHybridChannelConfigBase, + createScopedDmSecurityResolver, +} from "openclaw/plugin-sdk/channel-config-helpers"; +import { z } from "zod"; +import { DEFAULT_ACCOUNT_ID, registerPluginHttpRoute, buildChannelConfigSchema } from "../api.js"; import { listAccountIds, resolveAccount } from "./accounts.js"; import { sendMessage, sendFileUrl } from "./client.js"; import { getSynologyRuntime } from "./runtime.js"; @@ -23,6 +22,34 @@ const SynologyChatConfigSchema = buildChannelConfigSchema(z.object({}).passthrou const activeRouteUnregisters = new Map void>(); +const resolveSynologyChatDmPolicy = createScopedDmSecurityResolver({ + channelKey: CHANNEL_ID, + resolvePolicy: (account) => account.dmPolicy, + resolveAllowFrom: (account) => account.allowedUserIds, + policyPathSuffix: "dmPolicy", + defaultPolicy: "allowlist", + approveHint: "openclaw pairing approve synology-chat ", + normalizeEntry: (raw) => raw.toLowerCase().trim(), +}); + +const synologyChatConfigBase = createHybridChannelConfigBase({ + sectionKey: CHANNEL_ID, + listAccountIds: (cfg: any) => listAccountIds(cfg), + resolveAccount: (cfg: any, accountId?: string | null) => resolveAccount(cfg, accountId), + defaultAccountId: () => DEFAULT_ACCOUNT_ID, + clearBaseFields: [ + "token", + "incomingUrl", + "nasHost", + "webhookPath", + "dmPolicy", + "allowedUserIds", + "rateLimitPerMinute", + "botName", + "allowInsecureSsl", + ], +}); + function waitUntilAbort(signal?: AbortSignal, onAbort?: () => void): Promise { return new Promise((resolve) => { const complete = () => { @@ -73,30 +100,7 @@ export function createSynologyChatPlugin() { setupWizard: synologyChatSetupWizard, config: { - listAccountIds: (cfg: any) => listAccountIds(cfg), - - resolveAccount: (cfg: any, accountId?: string | null) => resolveAccount(cfg, accountId), - - defaultAccountId: (_cfg: any) => DEFAULT_ACCOUNT_ID, - - setAccountEnabled: ({ cfg, accountId, enabled }: any) => { - const channelConfig = cfg?.channels?.[CHANNEL_ID] ?? {}; - if (accountId === DEFAULT_ACCOUNT_ID) { - return { - ...cfg, - channels: { - ...cfg.channels, - [CHANNEL_ID]: { ...channelConfig, enabled }, - }, - }; - } - return setAccountEnabledInConfigSection({ - cfg, - sectionKey: `channels.${CHANNEL_ID}`, - accountId, - enabled, - }); - }, + ...synologyChatConfigBase, }, pairing: { @@ -115,30 +119,7 @@ export function createSynologyChatPlugin() { }, security: { - resolveDmPolicy: ({ - cfg, - accountId, - account, - }: { - cfg: any; - accountId?: string | null; - account: ResolvedSynologyChatAccount; - }) => { - const resolvedAccountId = accountId ?? account.accountId ?? DEFAULT_ACCOUNT_ID; - const channelCfg = (cfg as any).channels?.["synology-chat"]; - const useAccountPath = Boolean(channelCfg?.accounts?.[resolvedAccountId]); - const basePath = useAccountPath - ? `channels.synology-chat.accounts.${resolvedAccountId}.` - : "channels.synology-chat."; - return { - policy: account.dmPolicy ?? "allowlist", - allowFrom: account.allowedUserIds ?? [], - policyPath: `${basePath}dmPolicy`, - allowFromPath: basePath, - approveHint: "openclaw pairing approve synology-chat ", - normalizeEntry: (raw: string) => raw.toLowerCase().trim(), - }; - }, + resolveDmPolicy: resolveSynologyChatDmPolicy, collectWarnings: ({ account }: { account: ResolvedSynologyChatAccount }) => { const warnings: string[] = []; if (!account.token) { diff --git a/extensions/tlon/src/channel.ts b/extensions/tlon/src/channel.ts index ea23ab19815..bf60086c653 100644 --- a/extensions/tlon/src/channel.ts +++ b/extensions/tlon/src/channel.ts @@ -1,3 +1,4 @@ +import { createHybridChannelConfigBase } from "openclaw/plugin-sdk/channel-config-helpers"; import type { ChannelAccountSnapshot, ChannelPlugin } from "openclaw/plugin-sdk/channel-runtime"; import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime"; import { createLazyRuntimeModule } from "openclaw/plugin-sdk/lazy-runtime"; @@ -37,6 +38,16 @@ const tlonSetupWizardProxy = createTlonSetupWizardBase({ ).tlonSetupWizard.finalize!(params), }) satisfies NonNullable; +const tlonConfigBase = createHybridChannelConfigBase({ + sectionKey: TLON_CHANNEL_ID, + listAccountIds: (cfg: OpenClawConfig) => listTlonAccountIds(cfg), + resolveAccount: (cfg: OpenClawConfig, accountId?: string | null) => + resolveTlonAccount(cfg, accountId ?? undefined), + defaultAccountId: () => "default", + clearBaseFields: ["ship", "code", "url", "name"], + preserveSectionOnDefaultDelete: true, +}); + export const tlonPlugin: ChannelPlugin = { id: TLON_CHANNEL_ID, meta: { @@ -60,70 +71,7 @@ export const tlonPlugin: ChannelPlugin = { reload: { configPrefixes: ["channels.tlon"] }, configSchema: tlonChannelConfigSchema, config: { - listAccountIds: (cfg) => listTlonAccountIds(cfg), - resolveAccount: (cfg, accountId) => resolveTlonAccount(cfg, accountId ?? undefined), - defaultAccountId: () => "default", - setAccountEnabled: ({ cfg, accountId, enabled }) => { - const useDefault = !accountId || accountId === "default"; - if (useDefault) { - return { - ...cfg, - channels: { - ...cfg.channels, - tlon: { - ...cfg.channels?.tlon, - enabled, - }, - }, - } as OpenClawConfig; - } - return { - ...cfg, - channels: { - ...cfg.channels, - tlon: { - ...cfg.channels?.tlon, - accounts: { - ...cfg.channels?.tlon?.accounts, - [accountId]: { - ...cfg.channels?.tlon?.accounts?.[accountId], - enabled, - }, - }, - }, - }, - } as OpenClawConfig; - }, - deleteAccount: ({ cfg, accountId }) => { - const useDefault = !accountId || accountId === "default"; - if (useDefault) { - const { - ship: _ship, - code: _code, - url: _url, - name: _name, - ...rest - } = cfg.channels?.tlon ?? {}; - return { - ...cfg, - channels: { - ...cfg.channels, - tlon: rest, - }, - } as OpenClawConfig; - } - const { [accountId]: _removed, ...remainingAccounts } = cfg.channels?.tlon?.accounts ?? {}; - return { - ...cfg, - channels: { - ...cfg.channels, - tlon: { - ...cfg.channels?.tlon, - accounts: remainingAccounts, - }, - }, - } as OpenClawConfig; - }, + ...tlonConfigBase, isConfigured: (account) => account.configured, describeAccount: (account) => ({ accountId: account.accountId, diff --git a/extensions/zalo/src/channel.ts b/extensions/zalo/src/channel.ts index 80b03ea00c5..3828664e2f0 100644 --- a/extensions/zalo/src/channel.ts +++ b/extensions/zalo/src/channel.ts @@ -1,6 +1,10 @@ -import { mapAllowFromEntries } from "openclaw/plugin-sdk/channel-config-helpers"; import { - buildAccountScopedDmSecurityPolicy, + createScopedAccountConfigAccessors, + createScopedChannelConfigBase, + createScopedDmSecurityResolver, + mapAllowFromEntries, +} from "openclaw/plugin-sdk/channel-config-helpers"; +import { buildOpenGroupPolicyRestrictSendersWarning, buildOpenGroupPolicyWarning, collectOpenProviderGroupPolicyWarnings, @@ -17,13 +21,11 @@ import { buildTokenChannelStatusSummary, buildChannelSendResult, DEFAULT_ACCOUNT_ID, - deleteAccountFromConfigSection, chunkTextForOutbound, formatAllowFromLowercase, listDirectoryUserEntriesFromAllowFrom, isNumericTargetId, sendPayloadWithChunkedTextAndMedia, - setAccountEnabledInConfigSection, } from "openclaw/plugin-sdk/zalo"; import { listZaloAccountIds, @@ -59,6 +61,29 @@ function normalizeZaloMessagingTarget(raw: string): string | undefined { const loadZaloChannelRuntime = createLazyRuntimeModule(() => import("./channel.runtime.js")); +const zaloConfigAccessors = createScopedAccountConfigAccessors({ + resolveAccount: ({ cfg, accountId }) => resolveZaloAccount({ cfg, accountId }), + resolveAllowFrom: (account: ResolvedZaloAccount) => account.config.allowFrom, + formatAllowFrom: (allowFrom) => + formatAllowFromLowercase({ allowFrom, stripPrefixRe: /^(zalo|zl):/i }), +}); + +const zaloConfigBase = createScopedChannelConfigBase({ + sectionKey: "zalo", + listAccountIds: listZaloAccountIds, + resolveAccount: (cfg, accountId) => resolveZaloAccount({ cfg, accountId }), + defaultAccountId: resolveDefaultZaloAccountId, + clearBaseFields: ["botToken", "tokenFile", "name"], +}); + +const resolveZaloDmPolicy = createScopedDmSecurityResolver({ + channelKey: "zalo", + resolvePolicy: (account) => account.config.dmPolicy, + resolveAllowFrom: (account) => account.config.allowFrom, + policyPathSuffix: "dmPolicy", + normalizeEntry: (raw) => raw.replace(/^(zalo|zl):/i, ""), +}); + export const zaloPlugin: ChannelPlugin = { id: "zalo", meta, @@ -76,24 +101,7 @@ export const zaloPlugin: ChannelPlugin = { reload: { configPrefixes: ["channels.zalo"] }, configSchema: buildChannelConfigSchema(ZaloConfigSchema), config: { - listAccountIds: (cfg) => listZaloAccountIds(cfg), - resolveAccount: (cfg, accountId) => resolveZaloAccount({ cfg: cfg, accountId }), - defaultAccountId: (cfg) => resolveDefaultZaloAccountId(cfg), - setAccountEnabled: ({ cfg, accountId, enabled }) => - setAccountEnabledInConfigSection({ - cfg: cfg, - sectionKey: "zalo", - accountId, - enabled, - allowTopLevel: true, - }), - deleteAccount: ({ cfg, accountId }) => - deleteAccountFromConfigSection({ - cfg: cfg, - sectionKey: "zalo", - accountId, - clearBaseFields: ["botToken", "tokenFile", "name"], - }), + ...zaloConfigBase, isConfigured: (account) => Boolean(account.token?.trim()), describeAccount: (account): ChannelAccountSnapshot => ({ accountId: account.accountId, @@ -102,24 +110,10 @@ export const zaloPlugin: ChannelPlugin = { configured: Boolean(account.token?.trim()), tokenSource: account.tokenSource, }), - resolveAllowFrom: ({ cfg, accountId }) => - mapAllowFromEntries(resolveZaloAccount({ cfg: cfg, accountId }).config.allowFrom), - formatAllowFrom: ({ allowFrom }) => - formatAllowFromLowercase({ allowFrom, stripPrefixRe: /^(zalo|zl):/i }), + ...zaloConfigAccessors, }, security: { - resolveDmPolicy: ({ cfg, accountId, account }) => { - return buildAccountScopedDmSecurityPolicy({ - cfg, - channelKey: "zalo", - accountId, - fallbackAccountId: account.accountId ?? DEFAULT_ACCOUNT_ID, - policy: account.config.dmPolicy, - allowFrom: account.config.allowFrom ?? [], - policyPathSuffix: "dmPolicy", - normalizeEntry: (raw) => raw.replace(/^(zalo|zl):/i, ""), - }); - }, + resolveDmPolicy: resolveZaloDmPolicy, collectWarnings: ({ account, cfg }) => { return collectOpenProviderGroupPolicyWarnings({ cfg, diff --git a/extensions/zalo/src/setup-surface.ts b/extensions/zalo/src/setup-surface.ts index 50e6761b35a..c97e189ba4a 100644 --- a/extensions/zalo/src/setup-surface.ts +++ b/extensions/zalo/src/setup-surface.ts @@ -1,5 +1,6 @@ import { buildSingleChannelSecretPromptState, + createTopLevelChannelDmPolicy, DEFAULT_ACCOUNT_ID, formatDocsLink, hasConfiguredSecretInput, @@ -7,7 +8,6 @@ import { normalizeAccountId, promptSingleChannelSecretInput, runSingleChannelSecretStep, - setTopLevelChannelDmPolicyWithAllowFrom, type ChannelSetupDmPolicy, type ChannelSetupWizard, type OpenClawConfig, @@ -20,17 +20,6 @@ const channel = "zalo" as const; type UpdateMode = "polling" | "webhook"; -function setZaloDmPolicy( - cfg: OpenClawConfig, - dmPolicy: "pairing" | "allowlist" | "open" | "disabled", -) { - return setTopLevelChannelDmPolicyWithAllowFrom({ - cfg, - channel, - dmPolicy, - }) as OpenClawConfig; -} - function setZaloUpdateMode( cfg: OpenClawConfig, accountId: string, @@ -183,13 +172,12 @@ async function promptZaloAllowFrom(params: { } as OpenClawConfig; } -const zaloDmPolicy: ChannelSetupDmPolicy = { +const zaloDmPolicy: ChannelSetupDmPolicy = createTopLevelChannelDmPolicy({ label: "Zalo", channel, policyKey: "channels.zalo.dmPolicy", allowFromKey: "channels.zalo.allowFrom", getCurrent: (cfg) => (cfg.channels?.zalo?.dmPolicy ?? "pairing") as "pairing", - setPolicy: (cfg, policy) => setZaloDmPolicy(cfg as OpenClawConfig, policy), promptAllowFrom: async ({ cfg, prompter, accountId }) => { const id = accountId && normalizeAccountId(accountId) @@ -201,7 +189,7 @@ const zaloDmPolicy: ChannelSetupDmPolicy = { accountId: id, }); }, -}; +}); export { zaloSetupAdapter } from "./setup-core.js"; diff --git a/extensions/zalouser/src/channel.ts b/extensions/zalouser/src/channel.ts index 61318d84e20..f0170af4aa1 100644 --- a/extensions/zalouser/src/channel.ts +++ b/extensions/zalouser/src/channel.ts @@ -1,5 +1,5 @@ +import { createScopedDmSecurityResolver } from "openclaw/plugin-sdk/channel-config-helpers"; import { createAccountStatusSink } from "openclaw/plugin-sdk/channel-lifecycle"; -import { buildAccountScopedDmSecurityPolicy } from "openclaw/plugin-sdk/channel-policy"; import type { ChannelAccountSnapshot, ChannelDirectoryEntry, @@ -217,6 +217,14 @@ function resolveZalouserRequireMention(params: ChannelGroupContext): boolean { return true; } +const resolveZalouserDmPolicy = createScopedDmSecurityResolver({ + channelKey: "zalouser", + resolvePolicy: (account) => account.config.dmPolicy, + resolveAllowFrom: (account) => account.config.allowFrom, + policyPathSuffix: "dmPolicy", + normalizeEntry: (raw) => raw.replace(/^(zalouser|zlu):/i, ""), +}); + const zalouserMessageActions: ChannelMessageActionAdapter = { describeMessageTool: ({ cfg }) => { const accounts = listZalouserAccountIds(cfg) @@ -292,18 +300,7 @@ export const zalouserPlugin: ChannelPlugin = { setup: zalouserSetupAdapter, }), security: { - resolveDmPolicy: ({ cfg, accountId, account }) => { - return buildAccountScopedDmSecurityPolicy({ - cfg, - channelKey: "zalouser", - accountId, - fallbackAccountId: account.accountId ?? DEFAULT_ACCOUNT_ID, - policy: account.config.dmPolicy, - allowFrom: account.config.allowFrom ?? [], - policyPathSuffix: "dmPolicy", - normalizeEntry: (raw) => raw.replace(/^(zalouser|zlu):/i, ""), - }); - }, + resolveDmPolicy: resolveZalouserDmPolicy, }, groups: { resolveRequireMention: resolveZalouserRequireMention, diff --git a/extensions/zalouser/src/setup-surface.ts b/extensions/zalouser/src/setup-surface.ts index 1249bf9b5de..b8eb5bc022b 100644 --- a/extensions/zalouser/src/setup-surface.ts +++ b/extensions/zalouser/src/setup-surface.ts @@ -1,4 +1,6 @@ import { + createTopLevelChannelDmPolicy, + createTopLevelChannelDmPolicySetter, DEFAULT_ACCOUNT_ID, formatCliCommand, formatDocsLink, @@ -6,7 +8,6 @@ import { mergeAllowFromEntries, normalizeAccountId, patchScopedAccountConfig, - setTopLevelChannelDmPolicyWithAllowFrom, type ChannelSetupDmPolicy, type ChannelSetupWizard, type DmPolicy, @@ -29,6 +30,9 @@ import { } from "./zalo-js.js"; const channel = "zalouser" as const; +const setZalouserDmPolicy = createTopLevelChannelDmPolicySetter({ + channel, +}); const ZALOUSER_ALLOW_FROM_PLACEHOLDER = "Alice, 123456789, or leave empty to configure later"; const ZALOUSER_GROUPS_PLACEHOLDER = "Family, Work, 123456789, or leave empty for now"; const ZALOUSER_DM_ACCESS_TITLE = "Zalo Personal DM access"; @@ -57,14 +61,6 @@ function setZalouserAccountScopedConfig( }) as OpenClawConfig; } -function setZalouserDmPolicy(cfg: OpenClawConfig, dmPolicy: DmPolicy): OpenClawConfig { - return setTopLevelChannelDmPolicyWithAllowFrom({ - cfg, - channel, - dmPolicy, - }) as OpenClawConfig; -} - function setZalouserGroupPolicy( cfg: OpenClawConfig, accountId: string, @@ -193,13 +189,12 @@ async function promptZalouserAllowFrom(params: { } } -const zalouserDmPolicy: ChannelSetupDmPolicy = { +const zalouserDmPolicy: ChannelSetupDmPolicy = createTopLevelChannelDmPolicy({ label: "Zalo Personal", channel, policyKey: "channels.zalouser.dmPolicy", allowFromKey: "channels.zalouser.allowFrom", getCurrent: (cfg) => (cfg.channels?.zalouser?.dmPolicy ?? "pairing") as DmPolicy, - setPolicy: (cfg, policy) => setZalouserDmPolicy(cfg as OpenClawConfig, policy), promptAllowFrom: async ({ cfg, prompter, accountId }) => { const id = accountId && normalizeAccountId(accountId) @@ -211,7 +206,7 @@ const zalouserDmPolicy: ChannelSetupDmPolicy = { accountId: id, }); }, -}; +}); async function promptZalouserQuickstartDmPolicy(params: { cfg: OpenClawConfig; diff --git a/src/channels/plugins/setup-wizard-helpers.test.ts b/src/channels/plugins/setup-wizard-helpers.test.ts index 3c20f51242f..87c6a6de61c 100644 --- a/src/channels/plugins/setup-wizard-helpers.test.ts +++ b/src/channels/plugins/setup-wizard-helpers.test.ts @@ -4,27 +4,45 @@ import { DEFAULT_ACCOUNT_ID } from "../../routing/session-key.js"; import { applySingleTokenPromptResult, buildSingleChannelSecretPromptState, + createAccountScopedAllowFromSection, + createAccountScopedGroupAccessSection, + createLegacyCompatChannelDmPolicy, + createNestedChannelAllowFromSetter, + createNestedChannelDmPolicy, + createNestedChannelDmPolicySetter, + createTopLevelChannelAllowFromSetter, + createTopLevelChannelDmPolicy, + createTopLevelChannelDmPolicySetter, + createTopLevelChannelGroupPolicySetter, normalizeAllowFromEntries, noteChannelLookupFailure, noteChannelLookupSummary, parseMentionOrPrefixedId, parseSetupEntriesAllowingWildcard, patchChannelConfigForAccount, + patchNestedChannelConfigSection, patchLegacyDmChannelConfig, + patchTopLevelChannelConfigSection, promptLegacyChannelAllowFrom, + promptLegacyChannelAllowFromForAccount, parseSetupEntriesWithParser, promptParsedAllowFromForScopedChannel, promptSingleChannelSecretInput, promptSingleChannelToken, promptResolvedAllowFrom, resolveAccountIdForConfigure, + resolveEntriesWithOptionalToken, + resolveGroupAllowlistWithLookupNotes, resolveSetupAccountId, + setAccountDmAllowFromForChannel, setAccountAllowFromForChannel, setAccountGroupPolicyForChannel, setChannelDmPolicyWithAllowFrom, setTopLevelChannelAllowFrom, setTopLevelChannelDmPolicyWithAllowFrom, setTopLevelChannelGroupPolicy, + setNestedChannelAllowFrom, + setNestedChannelDmPolicyWithAllowFrom, setLegacyChannelAllowFrom, setLegacyChannelDmPolicyWithAllowFrom, setSetupChannelEnabled, @@ -265,6 +283,45 @@ describe("promptLegacyChannelAllowFrom", () => { }); }); +describe("promptLegacyChannelAllowFromForAccount", () => { + it("resolves the account before delegating to the shared prompt flow", async () => { + const prompter = createPrompter(["alice"]); + + const next = await promptLegacyChannelAllowFromForAccount({ + cfg: { + channels: { + slack: { + dm: { + allowFrom: ["U0"], + }, + }, + }, + } as OpenClawConfig, + channel: "slack", + // oxlint-disable-next-line typescript/no-explicit-any + prompter: prompter as any, + defaultAccountId: DEFAULT_ACCOUNT_ID, + resolveAccount: () => ({ + botToken: "xoxb-token", + dmAllowFrom: ["U0"], + }), + resolveExisting: (account) => account.dmAllowFrom, + resolveToken: (account) => account.botToken, + noteTitle: "Slack allowlist", + noteLines: ["line"], + message: "Slack allowFrom", + placeholder: "@alice", + parseId: () => null, + invalidWithoutTokenNote: "need ids", + resolveEntries: async ({ entries }) => + entries.map((input) => ({ input, resolved: true, id: input.toUpperCase() })), + }); + + expect(next.channels?.slack?.allowFrom).toEqual(["U0", "ALICE"]); + expect(prompter.note).toHaveBeenCalledWith("line", "Slack allowlist"); + }); +}); + describe("promptSingleChannelToken", () => { it("uses env tokens when confirmed", async () => { const prompter = createTokenPrompter({ confirms: [true], texts: [] }); @@ -1005,6 +1062,400 @@ describe("setTopLevelChannelGroupPolicy", () => { }); }); +describe("patchTopLevelChannelConfigSection", () => { + it("clears requested fields before applying a patch", () => { + const next = patchTopLevelChannelConfigSection({ + cfg: { + channels: { + nostr: { + privateKey: "nsec1", + relays: ["wss://old.example"], + }, + }, + }, + channel: "nostr", + clearFields: ["privateKey"], + patch: { relays: ["wss://new.example"] }, + enabled: true, + }); + + expect(next.channels?.nostr?.privateKey).toBeUndefined(); + expect(next.channels?.nostr?.relays).toEqual(["wss://new.example"]); + expect(next.channels?.nostr?.enabled).toBe(true); + }); +}); + +describe("patchNestedChannelConfigSection", () => { + it("clears requested nested fields before applying a patch", () => { + const next = patchNestedChannelConfigSection({ + cfg: { + channels: { + matrix: { + dm: { + policy: "pairing", + allowFrom: ["@alice:example.org"], + }, + }, + }, + }, + channel: "matrix", + section: "dm", + clearFields: ["allowFrom"], + enabled: true, + patch: { policy: "disabled" }, + }); + + expect(next.channels?.matrix?.enabled).toBe(true); + expect(next.channels?.matrix?.dm?.policy).toBe("disabled"); + expect(next.channels?.matrix?.dm?.allowFrom).toBeUndefined(); + }); +}); + +describe("createTopLevelChannelDmPolicy", () => { + it("creates a reusable dm policy definition", () => { + const dmPolicy = createTopLevelChannelDmPolicy({ + label: "LINE", + channel: "line", + policyKey: "channels.line.dmPolicy", + allowFromKey: "channels.line.allowFrom", + getCurrent: (cfg) => cfg.channels?.line?.dmPolicy ?? "pairing", + }); + + const next = dmPolicy.setPolicy( + { + channels: { + line: { + dmPolicy: "pairing", + allowFrom: ["U123"], + }, + }, + }, + "open", + ); + + expect(dmPolicy.getCurrent({})).toBe("pairing"); + expect(next.channels?.line?.dmPolicy).toBe("open"); + expect(next.channels?.line?.allowFrom).toEqual(["U123", "*"]); + }); +}); + +describe("createTopLevelChannelDmPolicySetter", () => { + it("reuses the shared top-level dmPolicy writer", () => { + const setPolicy = createTopLevelChannelDmPolicySetter({ + channel: "zalo", + }); + const next = setPolicy( + { + channels: { + zalo: { + allowFrom: ["12345"], + }, + }, + }, + "open", + ); + + expect(next.channels?.zalo?.dmPolicy).toBe("open"); + expect(next.channels?.zalo?.allowFrom).toEqual(["12345", "*"]); + }); +}); + +describe("setNestedChannelAllowFrom", () => { + it("writes nested allowFrom and can force enabled state", () => { + const next = setNestedChannelAllowFrom({ + cfg: {}, + channel: "googlechat", + section: "dm", + allowFrom: ["users/123"], + enabled: true, + }); + + expect(next.channels?.googlechat?.enabled).toBe(true); + expect(next.channels?.googlechat?.dm?.allowFrom).toEqual(["users/123"]); + }); +}); + +describe("setNestedChannelDmPolicyWithAllowFrom", () => { + it("adds wildcard allowFrom for open policy inside a nested section", () => { + const next = setNestedChannelDmPolicyWithAllowFrom({ + cfg: { + channels: { + matrix: { + dm: { + policy: "pairing", + allowFrom: ["@alice:example.org"], + }, + }, + }, + }, + channel: "matrix", + section: "dm", + dmPolicy: "open", + enabled: true, + }); + + expect(next.channels?.matrix?.enabled).toBe(true); + expect(next.channels?.matrix?.dm?.policy).toBe("open"); + expect(next.channels?.matrix?.dm?.allowFrom).toEqual(["@alice:example.org", "*"]); + }); +}); + +describe("createNestedChannelDmPolicy", () => { + it("creates a reusable nested dm policy definition", () => { + const dmPolicy = createNestedChannelDmPolicy({ + label: "Matrix", + channel: "matrix", + section: "dm", + policyKey: "channels.matrix.dm.policy", + allowFromKey: "channels.matrix.dm.allowFrom", + getCurrent: (cfg) => cfg.channels?.matrix?.dm?.policy ?? "pairing", + enabled: true, + }); + + const next = dmPolicy.setPolicy( + { + channels: { + matrix: { + dm: { + allowFrom: ["@alice:example.org"], + }, + }, + }, + }, + "open", + ); + + expect(next.channels?.matrix?.enabled).toBe(true); + expect(next.channels?.matrix?.dm?.policy).toBe("open"); + expect(next.channels?.matrix?.dm?.allowFrom).toEqual(["@alice:example.org", "*"]); + }); +}); + +describe("createNestedChannelDmPolicySetter", () => { + it("reuses the shared nested dmPolicy writer", () => { + const setPolicy = createNestedChannelDmPolicySetter({ + channel: "googlechat", + section: "dm", + enabled: true, + }); + const next = setPolicy({}, "disabled"); + + expect(next.channels?.googlechat?.enabled).toBe(true); + expect(next.channels?.googlechat?.dm?.policy).toBe("disabled"); + }); +}); + +describe("createNestedChannelAllowFromSetter", () => { + it("reuses the shared nested allowFrom writer", () => { + const setAllowFrom = createNestedChannelAllowFromSetter({ + channel: "googlechat", + section: "dm", + enabled: true, + }); + const next = setAllowFrom({}, ["users/123"]); + + expect(next.channels?.googlechat?.enabled).toBe(true); + expect(next.channels?.googlechat?.dm?.allowFrom).toEqual(["users/123"]); + }); +}); + +describe("createTopLevelChannelAllowFromSetter", () => { + it("reuses the shared top-level allowFrom writer", () => { + const setAllowFrom = createTopLevelChannelAllowFromSetter({ + channel: "msteams", + enabled: true, + }); + const next = setAllowFrom({}, ["user-1"]); + + expect(next.channels?.msteams?.allowFrom).toEqual(["user-1"]); + expect(next.channels?.msteams?.enabled).toBe(true); + }); +}); + +describe("createLegacyCompatChannelDmPolicy", () => { + it("reads nested legacy dm policy and writes top-level compat fields", () => { + const dmPolicy = createLegacyCompatChannelDmPolicy({ + label: "Slack", + channel: "slack", + }); + + expect( + dmPolicy.getCurrent({ + channels: { + slack: { + dm: { + policy: "open", + }, + }, + }, + }), + ).toBe("open"); + + const next = dmPolicy.setPolicy( + { + channels: { + slack: { + dm: { + allowFrom: ["U123"], + }, + }, + }, + }, + "open", + ); + + expect(next.channels?.slack?.dmPolicy).toBe("open"); + expect(next.channels?.slack?.allowFrom).toEqual(["U123", "*"]); + }); +}); + +describe("createTopLevelChannelGroupPolicySetter", () => { + it("reuses the shared top-level groupPolicy writer", () => { + const setGroupPolicy = createTopLevelChannelGroupPolicySetter({ + channel: "feishu", + enabled: true, + }); + const next = setGroupPolicy({}, "allowlist"); + + expect(next.channels?.feishu?.groupPolicy).toBe("allowlist"); + expect(next.channels?.feishu?.enabled).toBe(true); + }); +}); + +describe("setAccountDmAllowFromForChannel", () => { + it("writes account-scoped allowlist dm config", () => { + const next = setAccountDmAllowFromForChannel({ + cfg: {}, + channel: "discord", + accountId: DEFAULT_ACCOUNT_ID, + allowFrom: ["123"], + }); + + expect(next.channels?.discord?.dmPolicy).toBe("allowlist"); + expect(next.channels?.discord?.allowFrom).toEqual(["123"]); + }); +}); + +describe("resolveGroupAllowlistWithLookupNotes", () => { + it("returns resolved values when lookup succeeds", async () => { + const prompter = createPrompter([]); + await expect( + resolveGroupAllowlistWithLookupNotes({ + label: "Discord channels", + prompter, + entries: ["general"], + fallback: [], + resolve: async () => ["guild/channel"], + }), + ).resolves.toEqual(["guild/channel"]); + expect(prompter.note).not.toHaveBeenCalled(); + }); + + it("notes lookup failure and returns the fallback", async () => { + const prompter = createPrompter([]); + await expect( + resolveGroupAllowlistWithLookupNotes({ + label: "Slack channels", + prompter, + entries: ["general"], + fallback: ["general"], + resolve: async () => { + throw new Error("boom"); + }, + }), + ).resolves.toEqual(["general"]); + expect(prompter.note).toHaveBeenCalledTimes(2); + }); +}); + +describe("createAccountScopedAllowFromSection", () => { + it("builds an account-scoped allowFrom section with shared apply wiring", async () => { + const section = createAccountScopedAllowFromSection({ + channel: "discord", + credentialInputKey: "token", + message: "Discord allowFrom", + placeholder: "@alice", + invalidWithoutCredentialNote: "need ids", + parseId: (value) => value.trim() || null, + resolveEntries: async ({ entries }) => + entries.map((input) => ({ input, resolved: true, id: input.toUpperCase() })), + }); + + expect(section.credentialInputKey).toBe("token"); + await expect( + section.resolveEntries({ + cfg: {}, + accountId: DEFAULT_ACCOUNT_ID, + credentialValues: {}, + entries: ["alice"], + }), + ).resolves.toEqual([{ input: "alice", resolved: true, id: "ALICE" }]); + + const next = await section.apply({ + cfg: {}, + accountId: DEFAULT_ACCOUNT_ID, + allowFrom: ["123"], + }); + + expect(next.channels?.discord?.dmPolicy).toBe("allowlist"); + expect(next.channels?.discord?.allowFrom).toEqual(["123"]); + }); +}); + +describe("createAccountScopedGroupAccessSection", () => { + it("builds group access with shared setPolicy and fallback lookup notes", async () => { + const prompter = createPrompter([]); + const section = createAccountScopedGroupAccessSection({ + channel: "slack", + label: "Slack channels", + placeholder: "#general", + currentPolicy: () => "allowlist", + currentEntries: () => [], + updatePrompt: () => false, + resolveAllowlist: async () => { + throw new Error("boom"); + }, + fallbackResolved: (entries) => entries, + applyAllowlist: ({ cfg, resolved, accountId }) => + patchChannelConfigForAccount({ + cfg, + channel: "slack", + accountId, + patch: { + channels: Object.fromEntries(resolved.map((entry) => [entry, { allow: true }])), + }, + }), + }); + + const policyNext = section.setPolicy({ + cfg: {}, + accountId: DEFAULT_ACCOUNT_ID, + policy: "open", + }); + expect(policyNext.channels?.slack?.groupPolicy).toBe("open"); + + await expect( + section.resolveAllowlist?.({ + cfg: {}, + accountId: DEFAULT_ACCOUNT_ID, + credentialValues: {}, + entries: ["general"], + prompter, + }), + ).resolves.toEqual(["general"]); + expect(prompter.note).toHaveBeenCalledTimes(2); + + const allowlistNext = section.applyAllowlist?.({ + cfg: {}, + accountId: DEFAULT_ACCOUNT_ID, + resolved: ["C123"], + }); + expect(allowlistNext?.channels?.slack?.channels).toEqual({ + C123: { allow: true }, + }); + }); +}); + describe("splitSetupEntries", () => { it("splits comma/newline/semicolon input and trims blanks", () => { expect(splitSetupEntries(" alice, bob \ncarol; ;\n")).toEqual(["alice", "bob", "carol"]); @@ -1060,6 +1511,39 @@ describe("parseSetupEntriesAllowingWildcard", () => { }); }); +describe("resolveEntriesWithOptionalToken", () => { + it("returns unresolved entries when token is missing", async () => { + await expect( + resolveEntriesWithOptionalToken({ + entries: ["alice", "bob"], + buildWithoutToken: (input) => ({ input, resolved: false, id: null }), + resolveEntries: async () => { + throw new Error("should not run"); + }, + }), + ).resolves.toEqual([ + { input: "alice", resolved: false, id: null }, + { input: "bob", resolved: false, id: null }, + ]); + }); + + it("delegates to the resolver when token exists", async () => { + await expect( + resolveEntriesWithOptionalToken<{ + input: string; + resolved: boolean; + id: string | null; + }>({ + token: "xoxb-test", + entries: ["alice"], + buildWithoutToken: (input) => ({ input, resolved: false, id: null }), + resolveEntries: async ({ token, entries }) => + entries.map((input) => ({ input, resolved: true, id: `${token}:${input}` })), + }), + ).resolves.toEqual([{ input: "alice", resolved: true, id: "xoxb-test:alice" }]); + }); +}); + 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 c80a00dd324..187036bcfff 100644 --- a/src/channels/plugins/setup-wizard-helpers.ts +++ b/src/channels/plugins/setup-wizard-helpers.ts @@ -11,7 +11,12 @@ import { moveSingleAccountChannelSectionToDefaultAccount, patchScopedAccountConfig, } from "./setup-helpers.js"; -import type { PromptAccountId, PromptAccountIdParams } from "./setup-wizard-types.js"; +import type { + ChannelSetupDmPolicy, + PromptAccountId, + PromptAccountIdParams, +} from "./setup-wizard-types.js"; +import type { ChannelSetupWizard } from "./setup-wizard.js"; export const promptAccountId: PromptAccountId = async (params: PromptAccountIdParams) => { const existingIds = params.listAccountIds(params.cfg); @@ -192,14 +197,19 @@ export function setAccountAllowFromForChannel(params: { }); } -function patchTopLevelChannelConfig(params: { +export function patchTopLevelChannelConfigSection(params: { cfg: OpenClawConfig; channel: string; enabled?: boolean; + clearFields?: string[]; patch: Record; }): OpenClawConfig { - const channelConfig = - (params.cfg.channels?.[params.channel] as Record | undefined) ?? {}; + const channelConfig = { + ...(params.cfg.channels?.[params.channel] as Record | undefined), + }; + for (const field of params.clearFields ?? []) { + delete channelConfig[field]; + } return { ...params.cfg, channels: { @@ -213,13 +223,46 @@ function patchTopLevelChannelConfig(params: { }; } +export function patchNestedChannelConfigSection(params: { + cfg: OpenClawConfig; + channel: string; + section: string; + enabled?: boolean; + clearFields?: string[]; + patch: Record; +}): OpenClawConfig { + const channelConfig = { + ...(params.cfg.channels?.[params.channel] as Record | undefined), + }; + const sectionConfig = { + ...(channelConfig[params.section] as Record | undefined), + }; + for (const field of params.clearFields ?? []) { + delete sectionConfig[field]; + } + return { + ...params.cfg, + channels: { + ...params.cfg.channels, + [params.channel]: { + ...channelConfig, + ...(params.enabled ? { enabled: true } : {}), + [params.section]: { + ...sectionConfig, + ...params.patch, + }, + }, + }, + }; +} + export function setTopLevelChannelAllowFrom(params: { cfg: OpenClawConfig; channel: string; allowFrom: string[]; enabled?: boolean; }): OpenClawConfig { - return patchTopLevelChannelConfig({ + return patchTopLevelChannelConfigSection({ cfg: params.cfg, channel: params.channel, enabled: params.enabled, @@ -227,6 +270,22 @@ export function setTopLevelChannelAllowFrom(params: { }); } +export function setNestedChannelAllowFrom(params: { + cfg: OpenClawConfig; + channel: string; + section: string; + allowFrom: string[]; + enabled?: boolean; +}): OpenClawConfig { + return patchNestedChannelConfigSection({ + cfg: params.cfg, + channel: params.channel, + section: params.section, + enabled: params.enabled, + patch: { allowFrom: params.allowFrom }, + }); +} + export function setTopLevelChannelDmPolicyWithAllowFrom(params: { cfg: OpenClawConfig; channel: string; @@ -241,7 +300,7 @@ export function setTopLevelChannelDmPolicyWithAllowFrom(params: { undefined; const allowFrom = params.dmPolicy === "open" ? addWildcardAllowFrom(existingAllowFrom) : undefined; - return patchTopLevelChannelConfig({ + return patchTopLevelChannelConfigSection({ cfg: params.cfg, channel: params.channel, patch: { @@ -251,13 +310,43 @@ export function setTopLevelChannelDmPolicyWithAllowFrom(params: { }); } +export function setNestedChannelDmPolicyWithAllowFrom(params: { + cfg: OpenClawConfig; + channel: string; + section: string; + dmPolicy: DmPolicy; + getAllowFrom?: (cfg: OpenClawConfig) => Array | undefined; + enabled?: boolean; +}): OpenClawConfig { + const channelConfig = + (params.cfg.channels?.[params.channel] as Record | undefined) ?? {}; + const sectionConfig = + (channelConfig[params.section] as Record | undefined) ?? {}; + const existingAllowFrom = + params.getAllowFrom?.(params.cfg) ?? + (sectionConfig.allowFrom as Array | undefined) ?? + undefined; + const allowFrom = + params.dmPolicy === "open" ? addWildcardAllowFrom(existingAllowFrom) : undefined; + return patchNestedChannelConfigSection({ + cfg: params.cfg, + channel: params.channel, + section: params.section, + enabled: params.enabled, + patch: { + policy: params.dmPolicy, + ...(allowFrom ? { allowFrom } : {}), + }, + }); +} + export function setTopLevelChannelGroupPolicy(params: { cfg: OpenClawConfig; channel: string; groupPolicy: GroupPolicy; enabled?: boolean; }): OpenClawConfig { - return patchTopLevelChannelConfig({ + return patchTopLevelChannelConfigSection({ cfg: params.cfg, channel: params.channel, enabled: params.enabled, @@ -265,6 +354,129 @@ export function setTopLevelChannelGroupPolicy(params: { }); } +export function createTopLevelChannelDmPolicy(params: { + label: string; + channel: string; + policyKey: string; + allowFromKey: string; + getCurrent: (cfg: OpenClawConfig) => DmPolicy; + promptAllowFrom?: ChannelSetupDmPolicy["promptAllowFrom"]; + getAllowFrom?: (cfg: OpenClawConfig) => Array | undefined; +}): ChannelSetupDmPolicy { + const setPolicy = createTopLevelChannelDmPolicySetter({ + channel: params.channel, + getAllowFrom: params.getAllowFrom, + }); + return { + label: params.label, + channel: params.channel, + policyKey: params.policyKey, + allowFromKey: params.allowFromKey, + getCurrent: params.getCurrent, + setPolicy, + ...(params.promptAllowFrom ? { promptAllowFrom: params.promptAllowFrom } : {}), + }; +} + +export function createNestedChannelDmPolicy(params: { + label: string; + channel: string; + section: string; + policyKey: string; + allowFromKey: string; + getCurrent: (cfg: OpenClawConfig) => DmPolicy; + promptAllowFrom?: ChannelSetupDmPolicy["promptAllowFrom"]; + getAllowFrom?: (cfg: OpenClawConfig) => Array | undefined; + enabled?: boolean; +}): ChannelSetupDmPolicy { + const setPolicy = createNestedChannelDmPolicySetter({ + channel: params.channel, + section: params.section, + getAllowFrom: params.getAllowFrom, + enabled: params.enabled, + }); + return { + label: params.label, + channel: params.channel, + policyKey: params.policyKey, + allowFromKey: params.allowFromKey, + getCurrent: params.getCurrent, + setPolicy, + ...(params.promptAllowFrom ? { promptAllowFrom: params.promptAllowFrom } : {}), + }; +} + +export function createTopLevelChannelDmPolicySetter(params: { + channel: string; + getAllowFrom?: (cfg: OpenClawConfig) => Array | undefined; +}): (cfg: OpenClawConfig, dmPolicy: DmPolicy) => OpenClawConfig { + return (cfg, dmPolicy) => + setTopLevelChannelDmPolicyWithAllowFrom({ + cfg, + channel: params.channel, + dmPolicy, + getAllowFrom: params.getAllowFrom, + }); +} + +export function createNestedChannelDmPolicySetter(params: { + channel: string; + section: string; + getAllowFrom?: (cfg: OpenClawConfig) => Array | undefined; + enabled?: boolean; +}): (cfg: OpenClawConfig, dmPolicy: DmPolicy) => OpenClawConfig { + return (cfg, dmPolicy) => + setNestedChannelDmPolicyWithAllowFrom({ + cfg, + channel: params.channel, + section: params.section, + dmPolicy, + getAllowFrom: params.getAllowFrom, + enabled: params.enabled, + }); +} + +export function createTopLevelChannelAllowFromSetter(params: { + channel: string; + enabled?: boolean; +}): (cfg: OpenClawConfig, allowFrom: string[]) => OpenClawConfig { + return (cfg, allowFrom) => + setTopLevelChannelAllowFrom({ + cfg, + channel: params.channel, + allowFrom, + enabled: params.enabled, + }); +} + +export function createNestedChannelAllowFromSetter(params: { + channel: string; + section: string; + enabled?: boolean; +}): (cfg: OpenClawConfig, allowFrom: string[]) => OpenClawConfig { + return (cfg, allowFrom) => + setNestedChannelAllowFrom({ + cfg, + channel: params.channel, + section: params.section, + allowFrom, + enabled: params.enabled, + }); +} + +export function createTopLevelChannelGroupPolicySetter(params: { + channel: string; + enabled?: boolean; +}): (cfg: OpenClawConfig, groupPolicy: "open" | "allowlist" | "disabled") => OpenClawConfig { + return (cfg, groupPolicy) => + setTopLevelChannelGroupPolicy({ + cfg, + channel: params.channel, + groupPolicy, + enabled: params.enabled, + }); +} + export function setChannelDmPolicyWithAllowFrom(params: { cfg: OpenClawConfig; channel: "imessage" | "signal" | "telegram"; @@ -339,6 +551,177 @@ export function setAccountGroupPolicyForChannel(params: { }); } +export function setAccountDmAllowFromForChannel(params: { + cfg: OpenClawConfig; + channel: "discord" | "slack"; + accountId: string; + allowFrom: string[]; +}): OpenClawConfig { + return patchChannelConfigForAccount({ + cfg: params.cfg, + channel: params.channel, + accountId: params.accountId, + patch: { dmPolicy: "allowlist", allowFrom: params.allowFrom }, + }); +} + +export function createLegacyCompatChannelDmPolicy(params: { + label: string; + channel: LegacyDmChannel; + promptAllowFrom?: ChannelSetupDmPolicy["promptAllowFrom"]; +}): ChannelSetupDmPolicy { + return { + label: params.label, + channel: params.channel, + policyKey: `channels.${params.channel}.dmPolicy`, + allowFromKey: `channels.${params.channel}.allowFrom`, + getCurrent: (cfg) => + ( + cfg.channels?.[params.channel] as + | { + dmPolicy?: DmPolicy; + dm?: { policy?: DmPolicy }; + } + | undefined + )?.dmPolicy ?? + ( + cfg.channels?.[params.channel] as + | { + dmPolicy?: DmPolicy; + dm?: { policy?: DmPolicy }; + } + | undefined + )?.dm?.policy ?? + "pairing", + setPolicy: (cfg, policy) => + setLegacyChannelDmPolicyWithAllowFrom({ + cfg, + channel: params.channel, + dmPolicy: policy, + }), + ...(params.promptAllowFrom ? { promptAllowFrom: params.promptAllowFrom } : {}), + }; +} + +export async function resolveGroupAllowlistWithLookupNotes(params: { + label: string; + prompter: Pick; + entries: string[]; + fallback: TResolved; + resolve: () => Promise; +}): Promise { + try { + return await params.resolve(); + } catch (error) { + await noteChannelLookupFailure({ + prompter: params.prompter, + label: params.label, + error, + }); + await noteChannelLookupSummary({ + prompter: params.prompter, + label: params.label, + resolvedSections: [], + unresolved: params.entries, + }); + return params.fallback; + } +} + +export function createAccountScopedAllowFromSection(params: { + channel: "discord" | "slack"; + credentialInputKey?: NonNullable["credentialInputKey"]; + helpTitle?: string; + helpLines?: string[]; + message: string; + placeholder: string; + invalidWithoutCredentialNote: string; + parseId: NonNullable["parseId"]>; + resolveEntries: NonNullable["resolveEntries"]>; +}): 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, + parseId: params.parseId, + resolveEntries: params.resolveEntries, + apply: ({ cfg, accountId, allowFrom }) => + setAccountDmAllowFromForChannel({ + cfg, + channel: params.channel, + accountId, + allowFrom, + }), + }; +} + +export function createAccountScopedGroupAccessSection(params: { + channel: "discord" | "slack"; + label: string; + placeholder: string; + helpTitle?: string; + helpLines?: string[]; + skipAllowlistEntries?: boolean; + currentPolicy: NonNullable["currentPolicy"]; + currentEntries: NonNullable["currentEntries"]; + updatePrompt: NonNullable["updatePrompt"]; + resolveAllowlist?: NonNullable< + NonNullable["resolveAllowlist"] + >; + fallbackResolved: (entries: string[]) => TResolved; + applyAllowlist: (params: { + cfg: OpenClawConfig; + accountId: string; + resolved: TResolved; + }) => OpenClawConfig; +}): NonNullable { + return { + label: params.label, + placeholder: params.placeholder, + ...(params.helpTitle ? { helpTitle: params.helpTitle } : {}), + ...(params.helpLines ? { helpLines: params.helpLines } : {}), + ...(params.skipAllowlistEntries ? { skipAllowlistEntries: true } : {}), + currentPolicy: params.currentPolicy, + currentEntries: params.currentEntries, + updatePrompt: params.updatePrompt, + setPolicy: ({ cfg, accountId, policy }) => + setAccountGroupPolicyForChannel({ + cfg, + channel: params.channel, + accountId, + groupPolicy: policy, + }), + ...(params.resolveAllowlist + ? { + resolveAllowlist: ({ cfg, accountId, credentialValues, entries, prompter }) => + resolveGroupAllowlistWithLookupNotes({ + label: params.label, + prompter, + entries, + fallback: params.fallbackResolved(entries), + resolve: async () => + await params.resolveAllowlist!({ + cfg, + accountId, + credentialValues, + entries, + prompter, + }), + }), + } + : {}), + applyAllowlist: ({ cfg, accountId, resolved }) => + params.applyAllowlist({ + cfg, + accountId, + resolved: resolved as TResolved, + }), + }; +} + type AccountScopedChannel = "discord" | "slack" | "telegram" | "imessage" | "signal"; type LegacyDmChannel = "discord" | "slack"; @@ -753,6 +1136,22 @@ type AllowFromResolution = { id?: string | null; }; +export async function resolveEntriesWithOptionalToken(params: { + token?: string | null; + entries: string[]; + buildWithoutToken: (input: string) => TResult; + resolveEntries: (params: { token: string; entries: string[] }) => Promise; +}): Promise { + const token = params.token?.trim(); + if (!token) { + return params.entries.map(params.buildWithoutToken); + } + return await params.resolveEntries({ + token, + entries: params.entries, + }); +} + export async function promptResolvedAllowFrom(params: { prompter: WizardPrompter; existing: Array; @@ -838,3 +1237,41 @@ export async function promptLegacyChannelAllowFrom(params: { allowFrom: unique, }); } + +export async function promptLegacyChannelAllowFromForAccount(params: { + cfg: OpenClawConfig; + channel: LegacyDmChannel; + prompter: WizardPrompter; + accountId?: string; + defaultAccountId: string; + resolveAccount: (cfg: OpenClawConfig, accountId: string) => TAccount; + resolveExisting: (account: TAccount, cfg: OpenClawConfig) => Array; + resolveToken: (account: TAccount) => string | null | undefined; + noteTitle: string; + noteLines: string[]; + message: string; + placeholder: string; + parseId: (value: string) => string | null; + invalidWithoutTokenNote: string; + resolveEntries: (params: { token: string; entries: string[] }) => Promise; +}): Promise { + const accountId = resolveSetupAccountId({ + accountId: params.accountId, + defaultAccountId: params.defaultAccountId, + }); + const account = params.resolveAccount(params.cfg, accountId); + return await promptLegacyChannelAllowFrom({ + cfg: params.cfg, + channel: params.channel, + prompter: params.prompter, + existing: params.resolveExisting(account, params.cfg), + token: params.resolveToken(account), + noteTitle: params.noteTitle, + noteLines: params.noteLines, + message: params.message, + placeholder: params.placeholder, + parseId: params.parseId, + invalidWithoutTokenNote: params.invalidWithoutTokenNote, + resolveEntries: params.resolveEntries, + }); +} diff --git a/src/plugin-sdk/channel-config-helpers.test.ts b/src/plugin-sdk/channel-config-helpers.test.ts index 3a432006b6b..084d6e26532 100644 --- a/src/plugin-sdk/channel-config-helpers.test.ts +++ b/src/plugin-sdk/channel-config-helpers.test.ts @@ -1,6 +1,10 @@ import { describe, expect, it } from "vitest"; import { createScopedAccountConfigAccessors, + createScopedChannelConfigBase, + createScopedDmSecurityResolver, + createTopLevelChannelConfigBase, + createHybridChannelConfigBase, mapAllowFromEntries, resolveOptionalConfigString, } from "./channel-config-helpers.js"; @@ -72,3 +76,188 @@ describe("createScopedAccountConfigAccessors", () => { expect(accessors.resolveDefaultTo).toBeUndefined(); }); }); + +describe("createScopedChannelConfigBase", () => { + it("wires shared account config CRUD through the section helper", () => { + const base = createScopedChannelConfigBase({ + sectionKey: "demo", + listAccountIds: () => ["default", "alt"], + resolveAccount: (_cfg, accountId) => ({ accountId: accountId ?? "default" }), + defaultAccountId: () => "default", + clearBaseFields: ["token"], + }); + + expect(base.listAccountIds({})).toEqual(["default", "alt"]); + expect(base.resolveAccount({}, "alt")).toEqual({ accountId: "alt" }); + expect(base.defaultAccountId!({})).toBe("default"); + expect( + base.setAccountEnabled!({ + cfg: {}, + accountId: "default", + enabled: true, + }).channels?.demo, + ).toEqual({ enabled: true }); + expect( + base.deleteAccount!({ + cfg: { + channels: { + demo: { + token: "secret", + }, + }, + }, + accountId: "default", + }).channels, + ).toBeUndefined(); + }); +}); + +describe("createScopedDmSecurityResolver", () => { + it("builds account-aware DM policy payloads", () => { + const resolveDmPolicy = createScopedDmSecurityResolver<{ + accountId?: string | null; + dmPolicy?: string; + allowFrom?: string[]; + }>({ + channelKey: "demo", + resolvePolicy: (account) => account.dmPolicy, + resolveAllowFrom: (account) => account.allowFrom, + policyPathSuffix: "dmPolicy", + normalizeEntry: (raw) => raw.toLowerCase(), + }); + + expect( + resolveDmPolicy({ + cfg: { + channels: { + demo: { + accounts: { + alt: {}, + }, + }, + }, + }, + accountId: "alt", + account: { + accountId: "alt", + dmPolicy: "allowlist", + allowFrom: ["Owner"], + }, + }), + ).toEqual({ + policy: "allowlist", + allowFrom: ["Owner"], + policyPath: "channels.demo.accounts.alt.dmPolicy", + allowFromPath: "channels.demo.accounts.alt.", + approveHint: "Approve via: openclaw pairing list demo / openclaw pairing approve demo ", + normalizeEntry: expect.any(Function), + }); + }); +}); + +describe("createTopLevelChannelConfigBase", () => { + it("wires top-level enable/delete semantics", () => { + const base = createTopLevelChannelConfigBase({ + sectionKey: "demo", + resolveAccount: () => ({ accountId: "default" }), + }); + + expect(base.listAccountIds({})).toEqual(["default"]); + expect(base.defaultAccountId!({})).toBe("default"); + expect( + base.setAccountEnabled!({ + cfg: {}, + accountId: "default", + enabled: true, + }).channels?.demo, + ).toEqual({ enabled: true }); + expect( + base.deleteAccount!({ + cfg: { + channels: { + demo: { + enabled: true, + }, + }, + }, + accountId: "default", + }).channels, + ).toBeUndefined(); + }); +}); + +describe("createHybridChannelConfigBase", () => { + it("writes default account enable at the channel root and named accounts under accounts", () => { + const base = createHybridChannelConfigBase({ + sectionKey: "demo", + listAccountIds: () => ["default", "alt"], + resolveAccount: (_cfg, accountId) => ({ accountId: accountId ?? "default" }), + defaultAccountId: () => "default", + clearBaseFields: ["token"], + }); + + expect( + base.setAccountEnabled!({ + cfg: { + channels: { + demo: { + accounts: { + alt: { enabled: false }, + }, + }, + }, + }, + accountId: "default", + enabled: true, + }).channels?.demo, + ).toEqual({ + accounts: { + alt: { enabled: false }, + }, + enabled: true, + }); + expect( + base.setAccountEnabled!({ + cfg: {}, + accountId: "alt", + enabled: true, + }).channels?.demo, + ).toEqual({ + accounts: { + alt: { enabled: true }, + }, + }); + }); + + it("can preserve the section when deleting the default account", () => { + const base = createHybridChannelConfigBase({ + sectionKey: "demo", + listAccountIds: () => ["default", "alt"], + resolveAccount: (_cfg, accountId) => ({ accountId: accountId ?? "default" }), + defaultAccountId: () => "default", + clearBaseFields: ["token", "name"], + preserveSectionOnDefaultDelete: true, + }); + + expect( + base.deleteAccount!({ + cfg: { + channels: { + demo: { + token: "secret", + name: "bot", + accounts: { + alt: { enabled: true }, + }, + }, + }, + }, + accountId: "default", + }).channels?.demo, + ).toEqual({ + accounts: { + alt: { enabled: true }, + }, + }); + }); +}); diff --git a/src/plugin-sdk/channel-config-helpers.ts b/src/plugin-sdk/channel-config-helpers.ts index 556e2a0c1c1..af6813e13a1 100644 --- a/src/plugin-sdk/channel-config-helpers.ts +++ b/src/plugin-sdk/channel-config-helpers.ts @@ -14,7 +14,7 @@ import { normalizeWhatsAppAllowFromEntries } from "../channels/plugins/normalize import { getChannelPlugin } from "../channels/plugins/registry.js"; import type { ChannelConfigAdapter } from "../channels/plugins/types.adapters.js"; import type { OpenClawConfig } from "../config/config.js"; -import { normalizeAccountId } from "../routing/session-key.js"; +import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "../routing/session-key.js"; import { normalizeStringEntries } from "../shared/string-normalization.js"; /** Coerce mixed allowlist config values into plain strings without trimming or deduping. */ @@ -116,6 +116,178 @@ export function createScopedChannelConfigBase< }; } +function setTopLevelChannelEnabledInConfigSection(params: { + cfg: Config; + sectionKey: string; + enabled: boolean; +}): Config { + const section = params.cfg.channels?.[params.sectionKey] as Record | undefined; + return { + ...params.cfg, + channels: { + ...params.cfg.channels, + [params.sectionKey]: { + ...section, + enabled: params.enabled, + }, + }, + } as Config; +} + +function removeTopLevelChannelConfigSection(params: { + cfg: Config; + sectionKey: string; +}): Config { + const nextChannels = { ...params.cfg.channels } as Record; + delete nextChannels[params.sectionKey]; + const nextCfg = { ...params.cfg }; + if (Object.keys(nextChannels).length > 0) { + nextCfg.channels = nextChannels as Config["channels"]; + } else { + delete nextCfg.channels; + } + return nextCfg; +} + +function clearTopLevelChannelConfigFields(params: { + cfg: Config; + sectionKey: string; + clearBaseFields: string[]; +}): Config { + const section = params.cfg.channels?.[params.sectionKey] as Record | undefined; + if (!section) { + return params.cfg; + } + const nextSection = { ...section }; + for (const field of params.clearBaseFields) { + delete nextSection[field]; + } + return { + ...params.cfg, + channels: { + ...params.cfg.channels, + [params.sectionKey]: nextSection, + }, + } as Config; +} + +/** Build CRUD/config helpers for top-level single-account channels. */ +export function createTopLevelChannelConfigBase< + ResolvedAccount, + Config extends OpenClawConfig = OpenClawConfig, +>(params: { + sectionKey: string; + resolveAccount: (cfg: Config) => ResolvedAccount; + listAccountIds?: (cfg: Config) => string[]; + defaultAccountId?: (cfg: Config) => string; + inspectAccount?: (cfg: Config) => unknown; + deleteMode?: "remove-section" | "clear-fields"; + clearBaseFields?: string[]; +}): Pick< + ChannelConfigAdapter, + | "listAccountIds" + | "resolveAccount" + | "inspectAccount" + | "defaultAccountId" + | "setAccountEnabled" + | "deleteAccount" +> { + return { + listAccountIds: (cfg) => params.listAccountIds?.(cfg as Config) ?? [DEFAULT_ACCOUNT_ID], + resolveAccount: (cfg) => params.resolveAccount(cfg as Config), + inspectAccount: params.inspectAccount + ? (cfg) => params.inspectAccount?.(cfg as Config) + : undefined, + defaultAccountId: (cfg) => params.defaultAccountId?.(cfg as Config) ?? DEFAULT_ACCOUNT_ID, + setAccountEnabled: ({ cfg, enabled }) => + setTopLevelChannelEnabledInConfigSection({ + cfg: cfg as Config, + sectionKey: params.sectionKey, + enabled, + }), + deleteAccount: ({ cfg }) => + params.deleteMode === "clear-fields" + ? clearTopLevelChannelConfigFields({ + cfg: cfg as Config, + sectionKey: params.sectionKey, + clearBaseFields: params.clearBaseFields ?? [], + }) + : removeTopLevelChannelConfigSection({ + cfg: cfg as Config, + sectionKey: params.sectionKey, + }), + }; +} + +/** Build CRUD/config helpers for channels where the default account lives at channel root and named accounts live under `accounts`. */ +export function createHybridChannelConfigBase< + ResolvedAccount, + Config extends OpenClawConfig = OpenClawConfig, +>(params: { + sectionKey: string; + listAccountIds: (cfg: Config) => string[]; + resolveAccount: (cfg: Config, accountId?: string | null) => ResolvedAccount; + defaultAccountId: (cfg: Config) => string; + inspectAccount?: (cfg: Config, accountId?: string | null) => unknown; + clearBaseFields: string[]; + preserveSectionOnDefaultDelete?: boolean; +}): Pick< + ChannelConfigAdapter, + | "listAccountIds" + | "resolveAccount" + | "inspectAccount" + | "defaultAccountId" + | "setAccountEnabled" + | "deleteAccount" +> { + return { + listAccountIds: (cfg) => params.listAccountIds(cfg as Config), + resolveAccount: (cfg, accountId) => params.resolveAccount(cfg as Config, accountId), + inspectAccount: params.inspectAccount + ? (cfg, accountId) => params.inspectAccount?.(cfg as Config, accountId) + : undefined, + defaultAccountId: (cfg) => params.defaultAccountId(cfg as Config), + setAccountEnabled: ({ cfg, accountId, enabled }) => { + if (normalizeAccountId(accountId) === DEFAULT_ACCOUNT_ID) { + return setTopLevelChannelEnabledInConfigSection({ + cfg: cfg as Config, + sectionKey: params.sectionKey, + enabled, + }); + } + return setAccountEnabledInConfigSection({ + cfg: cfg as Config, + sectionKey: params.sectionKey, + accountId, + enabled, + }); + }, + deleteAccount: ({ cfg, accountId }) => { + if (normalizeAccountId(accountId) === DEFAULT_ACCOUNT_ID) { + if (params.preserveSectionOnDefaultDelete) { + return clearTopLevelChannelConfigFields({ + cfg: cfg as Config, + sectionKey: params.sectionKey, + clearBaseFields: params.clearBaseFields, + }); + } + return deleteAccountFromConfigSection({ + cfg: cfg as Config, + sectionKey: params.sectionKey, + accountId, + clearBaseFields: params.clearBaseFields, + }); + } + return deleteAccountFromConfigSection({ + cfg: cfg as Config, + sectionKey: params.sectionKey, + accountId, + clearBaseFields: params.clearBaseFields, + }); + }, + }; +} + /** Convert account-specific DM security fields into the shared runtime policy resolver shape. */ export function createScopedDmSecurityResolver< ResolvedAccount extends { accountId?: string | null }, diff --git a/src/plugin-sdk/compat.ts b/src/plugin-sdk/compat.ts index ad8d9ff5293..05a85d56e2a 100644 --- a/src/plugin-sdk/compat.ts +++ b/src/plugin-sdk/compat.ts @@ -25,9 +25,11 @@ export { createPluginRuntimeStore } from "./runtime-store.js"; export { KeyedAsyncQueue } from "./keyed-async-queue.js"; export { + createHybridChannelConfigBase, createScopedAccountConfigAccessors, createScopedChannelConfigBase, createScopedDmSecurityResolver, + createTopLevelChannelConfigBase, mapAllowFromEntries, } from "./channel-config-helpers.js"; export { formatAllowFromLowercase, formatNormalizedAllowFromEntries } from "./allow-from.js"; diff --git a/src/plugin-sdk/setup.ts b/src/plugin-sdk/setup.ts index 065fbfeed9c..5865de6396e 100644 --- a/src/plugin-sdk/setup.ts +++ b/src/plugin-sdk/setup.ts @@ -33,6 +33,16 @@ export { export { addWildcardAllowFrom, buildSingleChannelSecretPromptState, + createAccountScopedAllowFromSection, + createAccountScopedGroupAccessSection, + createLegacyCompatChannelDmPolicy, + createNestedChannelAllowFromSetter, + createNestedChannelDmPolicy, + createNestedChannelDmPolicySetter, + createTopLevelChannelAllowFromSetter, + createTopLevelChannelDmPolicy, + createTopLevelChannelDmPolicySetter, + createTopLevelChannelGroupPolicySetter, mergeAllowFromEntries, normalizeAllowFromEntries, noteChannelLookupFailure, @@ -40,16 +50,24 @@ export { parseMentionOrPrefixedId, parseSetupEntriesAllowingWildcard, parseSetupEntriesWithParser, + patchNestedChannelConfigSection, + patchTopLevelChannelConfigSection, patchChannelConfigForAccount, promptLegacyChannelAllowFrom, + promptLegacyChannelAllowFromForAccount, promptParsedAllowFromForScopedChannel, promptSingleChannelSecretInput, promptResolvedAllowFrom, + resolveEntriesWithOptionalToken, resolveSetupAccountId, + resolveGroupAllowlistWithLookupNotes, runSingleChannelSecretStep, + setAccountDmAllowFromForChannel, setAccountGroupPolicyForChannel, setChannelDmPolicyWithAllowFrom, setLegacyChannelDmPolicyWithAllowFrom, + setNestedChannelAllowFrom, + setNestedChannelDmPolicyWithAllowFrom, setSetupChannelEnabled, setTopLevelChannelAllowFrom, setTopLevelChannelDmPolicyWithAllowFrom, diff --git a/src/plugin-sdk/subpaths.test.ts b/src/plugin-sdk/subpaths.test.ts index 06a1108a45e..bf7943bbfc5 100644 --- a/src/plugin-sdk/subpaths.test.ts +++ b/src/plugin-sdk/subpaths.test.ts @@ -97,8 +97,18 @@ describe("plugin-sdk subpath exports", () => { it("exports shared setup helpers from the dedicated subpath", () => { expect(typeof setupSdk.DEFAULT_ACCOUNT_ID).toBe("string"); + expect(typeof setupSdk.createAccountScopedAllowFromSection).toBe("function"); + expect(typeof setupSdk.createAccountScopedGroupAccessSection).toBe("function"); + expect(typeof setupSdk.createLegacyCompatChannelDmPolicy).toBe("function"); + expect(typeof setupSdk.createNestedChannelDmPolicy).toBe("function"); + expect(typeof setupSdk.createTopLevelChannelDmPolicy).toBe("function"); + expect(typeof setupSdk.createTopLevelChannelDmPolicySetter).toBe("function"); expect(typeof setupSdk.formatDocsLink).toBe("function"); expect(typeof setupSdk.mergeAllowFromEntries).toBe("function"); + expect(typeof setupSdk.patchNestedChannelConfigSection).toBe("function"); + expect(typeof setupSdk.patchTopLevelChannelConfigSection).toBe("function"); + expect(typeof setupSdk.resolveGroupAllowlistWithLookupNotes).toBe("function"); + expect(typeof setupSdk.setAccountDmAllowFromForChannel).toBe("function"); expect(typeof setupSdk.setTopLevelChannelDmPolicyWithAllowFrom).toBe("function"); expect(typeof setupSdk.formatResolvedUnresolvedNote).toBe("function"); });