refactor(discord): share setup wizard base

This commit is contained in:
Peter Steinberger
2026-03-17 03:32:46 +00:00
parent 4f7ee60a8f
commit a3474dda33
2 changed files with 141 additions and 253 deletions

View File

@@ -138,9 +138,43 @@ export const discordSetupAdapter: ChannelSetupAdapter = {
},
};
export function createDiscordSetupWizardProxy(
loadWizard: () => Promise<{ discordSetupWizard: ChannelSetupWizard }>,
) {
type DiscordAllowFromResolverParams = {
cfg: OpenClawConfig;
accountId: string;
credentialValues: { token?: string };
entries: string[];
};
type DiscordGroupAllowlistResolverParams = DiscordAllowFromResolverParams & {
prompter: { note: (message: string, title?: string) => Promise<void> };
};
type DiscordGroupAllowlistResolution = Array<{
input: string;
resolved: boolean;
}>;
type DiscordSetupWizardHandlers = {
promptAllowFrom: (params: {
cfg: OpenClawConfig;
prompter: import("../../../src/plugin-sdk-internal/setup.js").WizardPrompter;
accountId?: string;
}) => Promise<OpenClawConfig>;
resolveAllowFromEntries: (params: DiscordAllowFromResolverParams) => Promise<
Array<{
input: string;
resolved: boolean;
id: string | null;
}>
>;
resolveGroupAllowlist: (
params: DiscordGroupAllowlistResolverParams,
) => Promise<DiscordGroupAllowlistResolution>;
};
export function createDiscordSetupWizardBase(
handlers: DiscordSetupWizardHandlers,
): ChannelSetupWizard {
const discordDmPolicy: ChannelSetupDmPolicy = {
label: "Discord",
channel,
@@ -154,13 +188,7 @@ export function createDiscordSetupWizardProxy(
channel,
dmPolicy: policy,
}),
promptAllowFrom: async ({ cfg, prompter, accountId }) => {
const wizard = (await loadWizard()).discordSetupWizard;
if (!wizard.dmPolicy?.promptAllowFrom) {
return cfg;
}
return await wizard.dmPolicy.promptAllowFrom({ cfg, prompter, accountId });
},
promptAllowFrom: handlers.promptAllowFrom,
};
return {
@@ -238,44 +266,22 @@ export function createDiscordSetupWizardProxy(
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> };
}) => {
const wizard = (await loadWizard()).discordSetupWizard;
if (!wizard.groupAccess?.resolveAllowlist) {
return entries.map((input) => ({ input, resolved: false }));
}
resolveAllowlist: async (params: DiscordGroupAllowlistResolverParams) => {
try {
return await wizard.groupAccess.resolveAllowlist({
cfg,
accountId,
credentialValues,
entries,
prompter,
});
return await handlers.resolveGroupAllowlist(params);
} catch (error) {
await noteChannelLookupFailure({
prompter,
prompter: params.prompter,
label: "Discord channels",
error,
});
await noteChannelLookupSummary({
prompter,
prompter: params.prompter,
label: "Discord channels",
resolvedSections: [],
unresolved: entries,
unresolved: params.entries,
});
return entries.map((input) => ({ input, resolved: false }));
return params.entries.map((input) => ({ input, resolved: false }));
}
},
applyAllowlist: ({
@@ -305,28 +311,7 @@ export function createDiscordSetupWizardProxy(
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[];
}) => {
const wizard = (await loadWizard()).discordSetupWizard;
if (!wizard.allowFrom) {
return entries.map((input) => ({ input, resolved: false, id: null }));
}
return await wizard.allowFrom.resolveEntries({
cfg,
accountId,
credentialValues,
entries,
});
},
resolveEntries: handlers.resolveAllowFromEntries,
apply: async ({
cfg,
accountId,
@@ -347,3 +332,42 @@ export function createDiscordSetupWizardProxy(
disable: (cfg: OpenClawConfig) => setSetupChannelEnabled(cfg, channel, false),
} satisfies ChannelSetupWizard;
}
export function createDiscordSetupWizardProxy(
loadWizard: () => Promise<{ discordSetupWizard: ChannelSetupWizard }>,
) {
return createDiscordSetupWizardBase({
promptAllowFrom: async ({ cfg, prompter, accountId }) => {
const wizard = (await loadWizard()).discordSetupWizard;
if (!wizard.dmPolicy?.promptAllowFrom) {
return cfg;
}
return await wizard.dmPolicy.promptAllowFrom({ cfg, prompter, accountId });
},
resolveAllowFromEntries: async ({ cfg, accountId, credentialValues, entries }) => {
const wizard = (await loadWizard()).discordSetupWizard;
if (!wizard.allowFrom) {
return entries.map((input) => ({ input, resolved: false, id: null }));
}
return await wizard.allowFrom.resolveEntries({
cfg,
accountId,
credentialValues,
entries,
});
},
resolveGroupAllowlist: async ({ cfg, accountId, credentialValues, entries, prompter }) => {
const wizard = (await loadWizard()).discordSetupWizard;
if (!wizard.groupAccess?.resolveAllowlist) {
return entries.map((input) => ({ input, resolved: false }));
}
return (await wizard.groupAccess.resolveAllowlist({
cfg,
accountId,
credentialValues,
entries,
prompter,
})) as DiscordGroupAllowlistResolution;
},
});
}

View File

@@ -1,27 +1,14 @@
import {
DEFAULT_ACCOUNT_ID,
noteChannelLookupFailure,
noteChannelLookupSummary,
type OpenClawConfig,
parseMentionOrPrefixedId,
patchChannelConfigForAccount,
promptLegacyChannelAllowFrom,
resolveSetupAccountId,
setLegacyChannelDmPolicyWithAllowFrom,
setSetupChannelEnabled,
type WizardPrompter,
} from "../../../src/plugin-sdk-internal/setup.js";
import {
type ChannelSetupDmPolicy,
type ChannelSetupWizard,
} from "../../../src/plugin-sdk-internal/setup.js";
import { type ChannelSetupWizard } from "../../../src/plugin-sdk-internal/setup.js";
import { formatDocsLink } from "../../../src/terminal/links.js";
import { inspectDiscordAccount } from "./account-inspect.js";
import {
listDiscordAccountIds,
resolveDefaultDiscordAccountId,
resolveDiscordAccount,
} from "./accounts.js";
import { resolveDefaultDiscordAccountId, resolveDiscordAccount } from "./accounts.js";
import { normalizeDiscordSlug } from "./monitor/allow-list.js";
import {
resolveDiscordChannelAllowlist,
@@ -29,6 +16,7 @@ import {
} from "./resolve-channels.js";
import { resolveDiscordUserAllowlist } from "./resolve-users.js";
import {
createDiscordSetupWizardBase,
discordSetupAdapter,
DISCORD_TOKEN_HELP_LINES,
parseDiscordAllowFromId,
@@ -94,186 +82,62 @@ async function promptDiscordAllowFrom(params: {
});
}
const discordDmPolicy: ChannelSetupDmPolicy = {
label: "Discord",
channel,
policyKey: "channels.discord.dmPolicy",
allowFromKey: "channels.discord.allowFrom",
getCurrent: (cfg) =>
cfg.channels?.discord?.dmPolicy ?? cfg.channels?.discord?.dm?.policy ?? "pairing",
setPolicy: (cfg, policy) =>
setLegacyChannelDmPolicyWithAllowFrom({
cfg,
channel,
dmPolicy: policy,
}),
export const discordSetupWizard: ChannelSetupWizard = createDiscordSetupWizardBase({
promptAllowFrom: promptDiscordAllowFrom,
};
export const discordSetupWizard: ChannelSetupWizard = {
channel,
status: {
configuredLabel: "configured",
unconfiguredLabel: "needs token",
configuredHint: "configured",
unconfiguredHint: "needs token",
configuredScore: 2,
unconfiguredScore: 1,
resolveConfigured: ({ cfg }) =>
listDiscordAccountIds(cfg).some(
(accountId) => inspectDiscordAccount({ cfg, accountId }).configured,
),
},
credentials: [
{
inputKey: "token",
providerHint: channel,
credentialLabel: "Discord bot token",
preferredEnvVar: "DISCORD_BOT_TOKEN",
helpTitle: "Discord bot token",
helpLines: DISCORD_TOKEN_HELP_LINES,
envPrompt: "DISCORD_BOT_TOKEN detected. Use env var?",
keepPrompt: "Discord token already configured. Keep it?",
inputPrompt: "Enter Discord bot token",
allowEnv: ({ accountId }) => accountId === DEFAULT_ACCOUNT_ID,
inspect: ({ cfg, accountId }) => {
const account = inspectDiscordAccount({ cfg, accountId });
return {
accountConfigured: account.configured,
hasConfiguredValue: account.tokenStatus !== "missing",
resolvedValue: account.token?.trim() || undefined,
envValue:
accountId === DEFAULT_ACCOUNT_ID
? process.env.DISCORD_BOT_TOKEN?.trim() || undefined
: undefined,
};
},
},
],
groupAccess: {
label: "Discord channels",
placeholder: "My Server/#general, guildId/channelId, #support",
currentPolicy: ({ cfg, accountId }) =>
resolveDiscordAccount({ cfg, accountId }).config.groupPolicy ?? "allowlist",
currentEntries: ({ cfg, accountId }) =>
Object.entries(resolveDiscordAccount({ cfg, accountId }).config.guilds ?? {}).flatMap(
([guildKey, value]) => {
const channels = value?.channels ?? {};
const channelKeys = Object.keys(channels);
if (channelKeys.length === 0) {
const input = /^\d+$/.test(guildKey) ? `guild:${guildKey}` : guildKey;
return [input];
}
return channelKeys.map((channelKey) => `${guildKey}/${channelKey}`);
},
),
updatePrompt: ({ cfg, accountId }) =>
Boolean(resolveDiscordAccount({ cfg, accountId }).config.guilds),
setPolicy: ({ cfg, accountId, policy }) =>
patchChannelConfigForAccount({
cfg,
channel,
accountId,
patch: { groupPolicy: policy },
}),
resolveAllowlist: async ({ cfg, accountId, credentialValues, entries, prompter }) => {
const token =
resolveAllowFromEntries: async ({ cfg, accountId, credentialValues, entries }) =>
await resolveDiscordAllowFromEntries({
token:
resolveDiscordAccount({ cfg, accountId }).token ||
(typeof credentialValues.token === "string" ? credentialValues.token : "");
let resolved: DiscordChannelResolution[] = entries.map((input) => ({
input,
resolved: false,
}));
if (!token || entries.length === 0) {
return resolved;
}
try {
resolved = await resolveDiscordChannelAllowlist({
token,
entries,
});
const resolvedChannels = resolved.filter((entry) => entry.resolved && entry.channelId);
const resolvedGuilds = resolved.filter(
(entry) => entry.resolved && entry.guildId && !entry.channelId,
);
const unresolved = resolved.filter((entry) => !entry.resolved).map((entry) => entry.input);
await noteChannelLookupSummary({
prompter,
label: "Discord channels",
resolvedSections: [
{
title: "Resolved channels",
values: resolvedChannels
.map((entry) => entry.channelId)
.filter((value): value is string => Boolean(value)),
},
{
title: "Resolved guilds",
values: resolvedGuilds
.map((entry) => entry.guildId)
.filter((value): value is string => Boolean(value)),
},
],
unresolved,
});
} catch (error) {
await noteChannelLookupFailure({
prompter,
label: "Discord channels",
error,
});
}
(typeof credentialValues.token === "string" ? credentialValues.token : ""),
entries,
}),
resolveGroupAllowlist: async ({ cfg, accountId, credentialValues, entries, prompter }) => {
const token =
resolveDiscordAccount({ cfg, accountId }).token ||
(typeof credentialValues.token === "string" ? credentialValues.token : "");
let resolved: DiscordChannelResolution[] = entries.map((input) => ({
input,
resolved: false,
}));
if (!token || entries.length === 0) {
return resolved;
},
applyAllowlist: ({ cfg, accountId, resolved }) => {
const allowlistEntries: Array<{ guildKey: string; channelKey?: string }> = [];
for (const entry of resolved as DiscordChannelResolution[]) {
const guildKey =
entry.guildId ??
(entry.guildName ? normalizeDiscordSlug(entry.guildName) : undefined) ??
"*";
const channelKey =
entry.channelId ??
(entry.channelName ? normalizeDiscordSlug(entry.channelName) : undefined);
if (!channelKey && guildKey === "*") {
continue;
}
allowlistEntries.push({ guildKey, ...(channelKey ? { channelKey } : {}) });
}
return setDiscordGuildChannelAllowlist(cfg, accountId, allowlistEntries);
},
},
allowFrom: {
credentialInputKey: "token",
helpTitle: "Discord allowlist",
helpLines: [
"Allowlist Discord DMs by username (we resolve to user ids).",
"Examples:",
"- 123456789012345678",
"- @alice",
"- alice#1234",
"Multiple entries: comma-separated.",
`Docs: ${formatDocsLink("/discord", "discord")}`,
],
message: "Discord allowFrom (usernames or ids)",
placeholder: "@alice, 123456789012345678",
invalidWithoutCredentialNote: "Bot token missing; use numeric user ids (or mention form) only.",
parseId: parseDiscordAllowFromId,
resolveEntries: async ({ cfg, accountId, credentialValues, entries }) =>
await resolveDiscordAllowFromEntries({
token:
resolveDiscordAccount({ cfg, accountId }).token ||
(typeof credentialValues.token === "string" ? credentialValues.token : ""),
}
try {
resolved = await resolveDiscordChannelAllowlist({
token,
entries,
}),
apply: async ({ cfg, accountId, allowFrom }) =>
patchChannelConfigForAccount({
cfg,
channel,
accountId,
patch: { dmPolicy: "allowlist", allowFrom },
}),
});
const resolvedChannels = resolved.filter((entry) => entry.resolved && entry.channelId);
const resolvedGuilds = resolved.filter(
(entry) => entry.resolved && entry.guildId && !entry.channelId,
);
const unresolved = resolved.filter((entry) => !entry.resolved).map((entry) => entry.input);
await noteChannelLookupSummary({
prompter,
label: "Discord channels",
resolvedSections: [
{
title: "Resolved channels",
values: resolvedChannels
.map((entry) => entry.channelId)
.filter((value): value is string => Boolean(value)),
},
{
title: "Resolved guilds",
values: resolvedGuilds
.map((entry) => entry.guildId)
.filter((value): value is string => Boolean(value)),
},
],
unresolved,
});
} catch (error) {
await noteChannelLookupFailure({
prompter,
label: "Discord channels",
error,
});
}
return resolved;
},
dmPolicy: discordDmPolicy,
disable: (cfg) => setSetupChannelEnabled(cfg, channel, false),
};
});