refactor: deduplicate plugin setup and channel config helpers

This commit is contained in:
Peter Steinberger 2026-03-18 03:26:31 +00:00
parent 9e556f75f5
commit 9350cb19dd
38 changed files with 1933 additions and 1082 deletions

View File

@ -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<ResolvedBlueBubblesAccount>({
sectionKey: "bluebubbles",
listAccountIds: listBlueBubblesAccountIds,
resolveAccount: (cfg, accountId) => resolveBlueBubblesAccount({ cfg, accountId }),
defaultAccountId: resolveDefaultBlueBubblesAccountId,
clearBaseFields: ["serverUrl", "password", "name", "webhookPath"],
});
const resolveBlueBubblesDmPolicy = createScopedDmSecurityResolver<ResolvedBlueBubblesAccount>({
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<ResolvedBlueBubblesAccount> = {
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<ResolvedBlueBubblesAccount> = {
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({

View File

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

View File

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

View File

@ -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<NonNullable<ChannelSetupWizard["groupAccess"]>["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<void> };
}) => {
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;

View File

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

View File

@ -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<ResolvedFeishuAccount, ClawdbotConfig>({
sectionKey: "feishu",
listAccountIds: listFeishuAccountIds,
resolveAccount: (cfg, accountId) => resolveFeishuAccount({ cfg, accountId }),
defaultAccountId: resolveDefaultFeishuAccountId,
clearBaseFields: [],
});
const feishuConfigAccessors = createScopedAccountConfigAccessors<ResolvedFeishuAccount>({
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<ResolvedFeishuAccount> = {
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<ResolvedFeishuAccount> = {
},
};
}
// For named accounts, set enabled in accounts[accountId]
return setFeishuNamedAccountEnabled(cfg, accountId, enabled);
},
deleteAccount: ({ cfg, accountId }) => {
@ -442,11 +453,7 @@ export const feishuPlugin: ChannelPlugin<ResolvedFeishuAccount> = {
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,

View File

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

View File

@ -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<NonNullable<ChannelSetupDmPolicy["promptAllowFrom"]>>[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";

View File

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

View File

@ -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<ResolvedIrcAccount, CoreConfig>({
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<ResolvedIrcAccount>({
channelKey: "irc",
resolvePolicy: (account) => account.config.dmPolicy,
resolveAllowFrom: (account) => account.config.allowFrom,
policyPathSuffix: "dmPolicy",
normalizeEntry: (raw) => normalizeIrcAllowEntry(raw),
});
export const ircPlugin: ChannelPlugin<ResolvedIrcAccount, IrcProbe> = {
id: "irc",
meta: {
@ -88,35 +116,7 @@ export const ircPlugin: ChannelPlugin<ResolvedIrcAccount, IrcProbe> = {
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<ResolvedIrcAccount, IrcProbe> = {
...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,

View File

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

View File

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

View File

@ -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<void> {
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<ChannelSetupWizard["groupAccess"]> = {
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";

View File

@ -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<ResolvedMattermostAccount>({
sectionKey: "mattermost",
listAccountIds: listMattermostAccountIds,
resolveAccount: (cfg, accountId) => resolveMattermostAccount({ cfg, accountId }),
defaultAccountId: resolveDefaultMattermostAccountId,
clearBaseFields: ["botToken", "baseUrl", "name"],
});
const resolveMattermostDmPolicy = createScopedDmSecurityResolver<ResolvedMattermostAccount>({
channelKey: "mattermost",
resolvePolicy: (account) => account.config.dmPolicy,
resolveAllowFrom: (account) => account.config.allowFrom,
policyPathSuffix: "dmPolicy",
normalizeEntry: (raw) => normalizeAllowEntry(raw),
});
export const mattermostPlugin: ChannelPlugin<ResolvedMattermostAccount> = {
id: "mattermost",
meta: {
@ -295,24 +310,7 @@ export const mattermostPlugin: ChannelPlugin<ResolvedMattermostAccount> = {
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<ResolvedMattermostAccount> = {
...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,

View File

@ -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<ResolvedMSTeamsAccount>({
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<string | number>;
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<ResolvedMSTeamsAccount> = {
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 }) => {

View File

@ -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<void
);
}
function setMSTeamsGroupPolicy(
cfg: OpenClawConfig,
groupPolicy: "open" | "allowlist" | "disabled",
): OpenClawConfig {
return setTopLevelChannelGroupPolicy({
cfg,
channel,
groupPolicy,
enabled: true,
});
}
function setMSTeamsTeamsAllowlist(
cfg: OpenClawConfig,
entries: Array<{ teamKey: string; channelKey?: string }>,
@ -281,15 +259,14 @@ const msteamsGroupAccess: NonNullable<ChannelSetupWizard["groupAccess"]> = {
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";

View File

@ -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<ResolvedNextcloudTalkAccount>({
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<ResolvedNextcloudTalkAccount>({
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<ResolvedNextcloudTalkAccount> = {
id: "nextcloud-talk",
meta,
@ -72,25 +104,7 @@ export const nextcloudTalkPlugin: ChannelPlugin<ResolvedNextcloudTalkAccount> =
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<ResolvedNextcloudTalkAccount> =
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;

View File

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

View File

@ -1,3 +1,4 @@
import { createScopedDmSecurityResolver } from "openclaw/plugin-sdk/channel-config-helpers";
import {
buildChannelConfigSchema,
collectStatusIssuesFromLastError,
@ -31,6 +32,22 @@ const activeBuses = new Map<string, NostrBusHandle>();
// Store metrics snapshots per account (for status reporting)
const metricsSnapshots = new Map<string, MetricsSnapshot>();
const resolveNostrDmPolicy = createScopedDmSecurityResolver<ResolvedNostrAccount>({
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<ResolvedNostrAccount> = {
id: "nostr",
meta: {
@ -101,22 +118,7 @@ export const nostrPlugin: ChannelPlugin<ResolvedNostrAccount> = {
},
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: {

View File

@ -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<string, unknown>;
clearFields?: string[];
enabled?: boolean;
}): OpenClawConfig {
const existing = (params.cfg.channels?.nostr ?? {}) as Record<string, unknown>;
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 },
}),
};

View File

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

View File

@ -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<string, "channel" | "group" | "dm" | "unknown">();
const resolveSlackDmPolicy = createScopedDmSecurityResolver<ResolvedSlackAccount>({
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<ResolvedSlackAccount> = {
}),
},
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;

View File

@ -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<NonNullable<ChannelSetupWizard["groupAccess"]>["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<void> };
}) => {
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;
}

View File

@ -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<ChannelSetupWizardAllowFromEntry[]> {
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<OpenClawConfig> {
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)

View File

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

View File

@ -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<string, () => void>();
const resolveSynologyChatDmPolicy = createScopedDmSecurityResolver<ResolvedSynologyChatAccount>({
channelKey: CHANNEL_ID,
resolvePolicy: (account) => account.dmPolicy,
resolveAllowFrom: (account) => account.allowedUserIds,
policyPathSuffix: "dmPolicy",
defaultPolicy: "allowlist",
approveHint: "openclaw pairing approve synology-chat <code>",
normalizeEntry: (raw) => raw.toLowerCase().trim(),
});
const synologyChatConfigBase = createHybridChannelConfigBase<ResolvedSynologyChatAccount>({
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<void> {
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 <code>",
normalizeEntry: (raw: string) => raw.toLowerCase().trim(),
};
},
resolveDmPolicy: resolveSynologyChatDmPolicy,
collectWarnings: ({ account }: { account: ResolvedSynologyChatAccount }) => {
const warnings: string[] = [];
if (!account.token) {

View File

@ -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<ChannelPlugin["setupWizard"]>;
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,

View File

@ -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<ResolvedZaloAccount>({
sectionKey: "zalo",
listAccountIds: listZaloAccountIds,
resolveAccount: (cfg, accountId) => resolveZaloAccount({ cfg, accountId }),
defaultAccountId: resolveDefaultZaloAccountId,
clearBaseFields: ["botToken", "tokenFile", "name"],
});
const resolveZaloDmPolicy = createScopedDmSecurityResolver<ResolvedZaloAccount>({
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<ResolvedZaloAccount> = {
id: "zalo",
meta,
@ -76,24 +101,7 @@ export const zaloPlugin: ChannelPlugin<ResolvedZaloAccount> = {
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<ResolvedZaloAccount> = {
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,

View File

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

View File

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

View File

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

View File

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

View File

@ -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<string, unknown>;
}): OpenClawConfig {
const channelConfig =
(params.cfg.channels?.[params.channel] as Record<string, unknown> | undefined) ?? {};
const channelConfig = {
...(params.cfg.channels?.[params.channel] as Record<string, unknown> | 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<string, unknown>;
}): OpenClawConfig {
const channelConfig = {
...(params.cfg.channels?.[params.channel] as Record<string, unknown> | undefined),
};
const sectionConfig = {
...(channelConfig[params.section] as Record<string, unknown> | 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<string | number> | undefined;
enabled?: boolean;
}): OpenClawConfig {
const channelConfig =
(params.cfg.channels?.[params.channel] as Record<string, unknown> | undefined) ?? {};
const sectionConfig =
(channelConfig[params.section] as Record<string, unknown> | undefined) ?? {};
const existingAllowFrom =
params.getAllowFrom?.(params.cfg) ??
(sectionConfig.allowFrom as Array<string | number> | 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<string | number> | 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<string | number> | 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<string | number> | 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<string | number> | 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<TResolved>(params: {
label: string;
prompter: Pick<WizardPrompter, "note">;
entries: string[];
fallback: TResolved;
resolve: () => Promise<TResolved>;
}): Promise<TResolved> {
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<ChannelSetupWizard["allowFrom"]>["credentialInputKey"];
helpTitle?: string;
helpLines?: string[];
message: string;
placeholder: string;
invalidWithoutCredentialNote: string;
parseId: NonNullable<NonNullable<ChannelSetupWizard["allowFrom"]>["parseId"]>;
resolveEntries: NonNullable<NonNullable<ChannelSetupWizard["allowFrom"]>["resolveEntries"]>;
}): NonNullable<ChannelSetupWizard["allowFrom"]> {
return {
...(params.helpTitle ? { helpTitle: params.helpTitle } : {}),
...(params.helpLines ? { helpLines: params.helpLines } : {}),
...(params.credentialInputKey ? { credentialInputKey: params.credentialInputKey } : {}),
message: params.message,
placeholder: params.placeholder,
invalidWithoutCredentialNote: params.invalidWithoutCredentialNote,
parseId: params.parseId,
resolveEntries: params.resolveEntries,
apply: ({ cfg, accountId, allowFrom }) =>
setAccountDmAllowFromForChannel({
cfg,
channel: params.channel,
accountId,
allowFrom,
}),
};
}
export function createAccountScopedGroupAccessSection<TResolved>(params: {
channel: "discord" | "slack";
label: string;
placeholder: string;
helpTitle?: string;
helpLines?: string[];
skipAllowlistEntries?: boolean;
currentPolicy: NonNullable<ChannelSetupWizard["groupAccess"]>["currentPolicy"];
currentEntries: NonNullable<ChannelSetupWizard["groupAccess"]>["currentEntries"];
updatePrompt: NonNullable<ChannelSetupWizard["groupAccess"]>["updatePrompt"];
resolveAllowlist?: NonNullable<
NonNullable<ChannelSetupWizard["groupAccess"]>["resolveAllowlist"]
>;
fallbackResolved: (entries: string[]) => TResolved;
applyAllowlist: (params: {
cfg: OpenClawConfig;
accountId: string;
resolved: TResolved;
}) => OpenClawConfig;
}): NonNullable<ChannelSetupWizard["groupAccess"]> {
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<TResult>(params: {
token?: string | null;
entries: string[];
buildWithoutToken: (input: string) => TResult;
resolveEntries: (params: { token: string; entries: string[] }) => Promise<TResult[]>;
}): Promise<TResult[]> {
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<string | number>;
@ -838,3 +1237,41 @@ export async function promptLegacyChannelAllowFrom(params: {
allowFrom: unique,
});
}
export async function promptLegacyChannelAllowFromForAccount<TAccount>(params: {
cfg: OpenClawConfig;
channel: LegacyDmChannel;
prompter: WizardPrompter;
accountId?: string;
defaultAccountId: string;
resolveAccount: (cfg: OpenClawConfig, accountId: string) => TAccount;
resolveExisting: (account: TAccount, cfg: OpenClawConfig) => Array<string | number>;
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<AllowFromResolution[]>;
}): Promise<OpenClawConfig> {
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,
});
}

View File

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

View File

@ -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<Config extends OpenClawConfig>(params: {
cfg: Config;
sectionKey: string;
enabled: boolean;
}): Config {
const section = params.cfg.channels?.[params.sectionKey] as Record<string, unknown> | undefined;
return {
...params.cfg,
channels: {
...params.cfg.channels,
[params.sectionKey]: {
...section,
enabled: params.enabled,
},
},
} as Config;
}
function removeTopLevelChannelConfigSection<Config extends OpenClawConfig>(params: {
cfg: Config;
sectionKey: string;
}): Config {
const nextChannels = { ...params.cfg.channels } as Record<string, unknown>;
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<Config extends OpenClawConfig>(params: {
cfg: Config;
sectionKey: string;
clearBaseFields: string[];
}): Config {
const section = params.cfg.channels?.[params.sectionKey] as Record<string, unknown> | 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<ResolvedAccount>,
| "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<ResolvedAccount>,
| "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 },

View File

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

View File

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

View File

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