diff --git a/extensions/discord/src/channel.runtime.ts b/extensions/discord/src/channel.runtime.ts new file mode 100644 index 00000000000..bc22b64706a --- /dev/null +++ b/extensions/discord/src/channel.runtime.ts @@ -0,0 +1 @@ +export { discordSetupWizard } from "./setup-surface.js"; diff --git a/extensions/discord/src/channel.ts b/extensions/discord/src/channel.ts index 0123553fcb7..0af60e096bc 100644 --- a/extensions/discord/src/channel.ts +++ b/extensions/discord/src/channel.ts @@ -35,7 +35,7 @@ import { } from "openclaw/plugin-sdk/discord"; import { resolveOutboundSendDep } from "../../../src/infra/outbound/send-deps.js"; import { getDiscordRuntime } from "./runtime.js"; -import { discordSetupAdapter, discordSetupWizard } from "./setup-surface.js"; +import { createDiscordSetupWizardProxy, discordSetupAdapter } from "./setup-core.js"; type DiscordSendFn = ReturnType< typeof getDiscordRuntime @@ -43,6 +43,10 @@ type DiscordSendFn = ReturnType< const meta = getChatChannelMeta("discord"); +async function loadDiscordChannelRuntime() { + return await import("./channel.runtime.js"); +} + const discordMessageActions: ChannelMessageActionAdapter = { listActions: (ctx) => getDiscordRuntime().channel.discord.messageActions?.listActions?.(ctx) ?? [], @@ -73,6 +77,10 @@ const discordConfigBase = createScopedChannelConfigBase({ clearBaseFields: ["token", "name"], }); +const discordSetupWizard = createDiscordSetupWizardProxy(async () => ({ + discordSetupWizard: (await loadDiscordChannelRuntime()).discordSetupWizard, +})); + export const discordPlugin: ChannelPlugin = { id: "discord", meta: { diff --git a/extensions/discord/src/setup-core.ts b/extensions/discord/src/setup-core.ts new file mode 100644 index 00000000000..cec63dd01ec --- /dev/null +++ b/extensions/discord/src/setup-core.ts @@ -0,0 +1,348 @@ +import type { ChannelOnboardingDmPolicy } from "../../../src/channels/plugins/onboarding-types.js"; +import { + noteChannelLookupFailure, + noteChannelLookupSummary, + parseMentionOrPrefixedId, + patchChannelConfigForAccount, + setLegacyChannelDmPolicyWithAllowFrom, + setOnboardingChannelEnabled, +} from "../../../src/channels/plugins/onboarding/helpers.js"; +import { + applyAccountNameToChannelSection, + migrateBaseNameToDefaultAccount, +} from "../../../src/channels/plugins/setup-helpers.js"; +import type { ChannelSetupWizard } from "../../../src/channels/plugins/setup-wizard.js"; +import type { ChannelSetupAdapter } from "../../../src/channels/plugins/types.adapters.js"; +import type { OpenClawConfig } from "../../../src/config/config.js"; +import type { DiscordGuildEntry } from "../../../src/config/types.discord.js"; +import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "../../../src/routing/session-key.js"; +import { formatDocsLink } from "../../../src/terminal/links.js"; +import { inspectDiscordAccount } from "./account-inspect.js"; +import { listDiscordAccountIds, resolveDiscordAccount } from "./accounts.js"; + +const channel = "discord" as const; + +export const DISCORD_TOKEN_HELP_LINES = [ + "1) Discord Developer Portal -> Applications -> New Application", + "2) Bot -> Add Bot -> Reset Token -> copy token", + "3) OAuth2 -> URL Generator -> scope 'bot' -> invite to your server", + "Tip: enable Message Content Intent if you need message text. (Bot -> Privileged Gateway Intents -> Message Content Intent)", + `Docs: ${formatDocsLink("/discord", "discord")}`, +]; + +export function setDiscordGuildChannelAllowlist( + cfg: OpenClawConfig, + accountId: string, + entries: Array<{ + guildKey: string; + channelKey?: string; + }>, +): OpenClawConfig { + const baseGuilds = + accountId === DEFAULT_ACCOUNT_ID + ? (cfg.channels?.discord?.guilds ?? {}) + : (cfg.channels?.discord?.accounts?.[accountId]?.guilds ?? {}); + const guilds: Record = { ...baseGuilds }; + for (const entry of entries) { + const guildKey = entry.guildKey || "*"; + const existing = guilds[guildKey] ?? {}; + if (entry.channelKey) { + const channels = { ...existing.channels }; + channels[entry.channelKey] = { allow: true }; + guilds[guildKey] = { ...existing, channels }; + } else { + guilds[guildKey] = existing; + } + } + return patchChannelConfigForAccount({ + cfg, + channel, + accountId, + patch: { guilds }, + }); +} + +export function parseDiscordAllowFromId(value: string): string | null { + return parseMentionOrPrefixedId({ + value, + mentionPattern: /^<@!?(\d+)>$/, + prefixPattern: /^(user:|discord:)/i, + idPattern: /^\d+$/, + }); +} + +export const discordSetupAdapter: ChannelSetupAdapter = { + resolveAccountId: ({ accountId }) => normalizeAccountId(accountId), + applyAccountName: ({ cfg, accountId, name }) => + applyAccountNameToChannelSection({ + cfg, + channelKey: channel, + accountId, + name, + }), + validateInput: ({ accountId, input }) => { + if (input.useEnv && accountId !== DEFAULT_ACCOUNT_ID) { + return "DISCORD_BOT_TOKEN can only be used for the default account."; + } + if (!input.useEnv && !input.token) { + return "Discord requires token (or --use-env)."; + } + return null; + }, + applyAccountConfig: ({ cfg, accountId, input }) => { + const namedConfig = applyAccountNameToChannelSection({ + cfg, + channelKey: channel, + accountId, + name: input.name, + }); + const next = + accountId !== DEFAULT_ACCOUNT_ID + ? migrateBaseNameToDefaultAccount({ + cfg: namedConfig, + channelKey: channel, + }) + : namedConfig; + if (accountId === DEFAULT_ACCOUNT_ID) { + return { + ...next, + channels: { + ...next.channels, + discord: { + ...next.channels?.discord, + enabled: true, + ...(input.useEnv ? {} : input.token ? { token: input.token } : {}), + }, + }, + }; + } + return { + ...next, + channels: { + ...next.channels, + discord: { + ...next.channels?.discord, + enabled: true, + accounts: { + ...next.channels?.discord?.accounts, + [accountId]: { + ...next.channels?.discord?.accounts?.[accountId], + enabled: true, + ...(input.token ? { token: input.token } : {}), + }, + }, + }, + }, + }; + }, +}; + +export function createDiscordSetupWizardProxy( + loadWizard: () => Promise<{ discordSetupWizard: ChannelSetupWizard }>, +) { + const discordDmPolicy: ChannelOnboardingDmPolicy = { + 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: async ({ cfg, prompter, accountId }) => { + const wizard = (await loadWizard()).discordSetupWizard; + if (!wizard.dmPolicy?.promptAllowFrom) { + return cfg; + } + return await wizard.dmPolicy.promptAllowFrom({ cfg, prompter, accountId }); + }, + }; + + return { + channel, + status: { + configuredLabel: "configured", + unconfiguredLabel: "needs token", + configuredHint: "configured", + unconfiguredHint: "needs token", + configuredScore: 2, + unconfiguredScore: 1, + resolveConfigured: ({ cfg }) => + listDiscordAccountIds(cfg).some((accountId) => { + const account = inspectDiscordAccount({ cfg, accountId }); + return account.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: string }) => accountId === DEFAULT_ACCOUNT_ID, + inspect: ({ cfg, accountId }: { cfg: OpenClawConfig; accountId: string }) => { + 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 }: { cfg: OpenClawConfig; accountId: string }) => + resolveDiscordAccount({ cfg, accountId }).config.groupPolicy ?? "allowlist", + currentEntries: ({ cfg, accountId }: { cfg: OpenClawConfig; accountId: string }) => + 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 }: { 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 }; + }) => { + const wizard = (await loadWizard()).discordSetupWizard; + if (!wizard.groupAccess) { + return entries.map((input) => ({ input, resolved: false })); + } + try { + return await wizard.groupAccess.resolveAllowlist({ + 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 })); + } + }, + applyAllowlist: ({ + cfg, + accountId, + resolved, + }: { + cfg: OpenClawConfig; + accountId: string; + resolved: unknown; + }) => setDiscordGuildChannelAllowlist(cfg, accountId, resolved as never), + }, + 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, + }: { + 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, + }); + }, + apply: async ({ + cfg, + accountId, + allowFrom, + }: { + cfg: OpenClawConfig; + accountId: string; + allowFrom: string[]; + }) => + patchChannelConfigForAccount({ + cfg, + channel, + accountId, + patch: { dmPolicy: "allowlist", allowFrom }, + }), + }, + dmPolicy: discordDmPolicy, + disable: (cfg: OpenClawConfig) => setOnboardingChannelEnabled(cfg, channel, false), + } satisfies ChannelSetupWizard; +} diff --git a/extensions/discord/src/setup-surface.ts b/extensions/discord/src/setup-surface.ts index e03c7ef1e16..610b79a5efa 100644 --- a/extensions/discord/src/setup-surface.ts +++ b/extensions/discord/src/setup-surface.ts @@ -9,15 +9,9 @@ import { setLegacyChannelDmPolicyWithAllowFrom, setOnboardingChannelEnabled, } from "../../../src/channels/plugins/onboarding/helpers.js"; -import { - applyAccountNameToChannelSection, - migrateBaseNameToDefaultAccount, -} from "../../../src/channels/plugins/setup-helpers.js"; import type { ChannelSetupWizard } from "../../../src/channels/plugins/setup-wizard.js"; -import type { ChannelSetupAdapter } from "../../../src/channels/plugins/types.adapters.js"; import type { OpenClawConfig } from "../../../src/config/config.js"; -import type { DiscordGuildEntry } from "../../../src/config/types.discord.js"; -import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "../../../src/routing/session-key.js"; +import { DEFAULT_ACCOUNT_ID } from "../../../src/routing/session-key.js"; import { formatDocsLink } from "../../../src/terminal/links.js"; import type { WizardPrompter } from "../../../src/wizard/prompts.js"; import { inspectDiscordAccount } from "./account-inspect.js"; @@ -32,58 +26,15 @@ import { type DiscordChannelResolution, } from "./resolve-channels.js"; import { resolveDiscordUserAllowlist } from "./resolve-users.js"; +import { + discordSetupAdapter, + DISCORD_TOKEN_HELP_LINES, + parseDiscordAllowFromId, + setDiscordGuildChannelAllowlist, +} from "./setup-core.js"; const channel = "discord" as const; -const DISCORD_TOKEN_HELP_LINES = [ - "1) Discord Developer Portal -> Applications -> New Application", - "2) Bot -> Add Bot -> Reset Token -> copy token", - "3) OAuth2 -> URL Generator -> scope 'bot' -> invite to your server", - "Tip: enable Message Content Intent if you need message text. (Bot -> Privileged Gateway Intents -> Message Content Intent)", - `Docs: ${formatDocsLink("/discord", "discord")}`, -]; - -function setDiscordGuildChannelAllowlist( - cfg: OpenClawConfig, - accountId: string, - entries: Array<{ - guildKey: string; - channelKey?: string; - }>, -): OpenClawConfig { - const baseGuilds = - accountId === DEFAULT_ACCOUNT_ID - ? (cfg.channels?.discord?.guilds ?? {}) - : (cfg.channels?.discord?.accounts?.[accountId]?.guilds ?? {}); - const guilds: Record = { ...baseGuilds }; - for (const entry of entries) { - const guildKey = entry.guildKey || "*"; - const existing = guilds[guildKey] ?? {}; - if (entry.channelKey) { - const channels = { ...existing.channels }; - channels[entry.channelKey] = { allow: true }; - guilds[guildKey] = { ...existing, channels }; - } else { - guilds[guildKey] = existing; - } - } - return patchChannelConfigForAccount({ - cfg, - channel, - accountId, - patch: { guilds }, - }); -} - -function parseDiscordAllowFromId(value: string): string | null { - return parseMentionOrPrefixedId({ - value, - mentionPattern: /^<@!?(\d+)>$/, - prefixPattern: /^(user:|discord:)/i, - idPattern: /^\d+$/, - }); -} - async function resolveDiscordAllowFromEntries(params: { token?: string; entries: string[] }) { if (!params.token?.trim()) { return params.entries.map((input) => ({ @@ -157,72 +108,6 @@ const discordDmPolicy: ChannelOnboardingDmPolicy = { promptAllowFrom: promptDiscordAllowFrom, }; -export const discordSetupAdapter: ChannelSetupAdapter = { - resolveAccountId: ({ accountId }) => normalizeAccountId(accountId), - applyAccountName: ({ cfg, accountId, name }) => - applyAccountNameToChannelSection({ - cfg, - channelKey: channel, - accountId, - name, - }), - validateInput: ({ accountId, input }) => { - if (input.useEnv && accountId !== DEFAULT_ACCOUNT_ID) { - return "DISCORD_BOT_TOKEN can only be used for the default account."; - } - if (!input.useEnv && !input.token) { - return "Discord requires token (or --use-env)."; - } - return null; - }, - applyAccountConfig: ({ cfg, accountId, input }) => { - const namedConfig = applyAccountNameToChannelSection({ - cfg, - channelKey: channel, - accountId, - name: input.name, - }); - const next = - accountId !== DEFAULT_ACCOUNT_ID - ? migrateBaseNameToDefaultAccount({ - cfg: namedConfig, - channelKey: channel, - }) - : namedConfig; - if (accountId === DEFAULT_ACCOUNT_ID) { - return { - ...next, - channels: { - ...next.channels, - discord: { - ...next.channels?.discord, - enabled: true, - ...(input.useEnv ? {} : input.token ? { token: input.token } : {}), - }, - }, - }; - } - return { - ...next, - channels: { - ...next.channels, - discord: { - ...next.channels?.discord, - enabled: true, - accounts: { - ...next.channels?.discord?.accounts, - [accountId]: { - ...next.channels?.discord?.accounts?.[accountId], - enabled: true, - ...(input.token ? { token: input.token } : {}), - }, - }, - }, - }, - }; - }, -}; - export const discordSetupWizard: ChannelSetupWizard = { channel, status: { diff --git a/src/plugin-sdk/discord.ts b/src/plugin-sdk/discord.ts index f4ffe6ef809..27f6c17bdff 100644 --- a/src/plugin-sdk/discord.ts +++ b/src/plugin-sdk/discord.ts @@ -35,10 +35,8 @@ export { resolveDiscordGroupRequireMention, resolveDiscordGroupToolPolicy, } from "../channels/plugins/group-mentions.js"; -export { - discordSetupAdapter, - discordSetupWizard, -} from "../../extensions/discord/src/setup-surface.js"; +export { discordSetupWizard } from "../../extensions/discord/src/setup-surface.js"; +export { discordSetupAdapter } from "../../extensions/discord/src/setup-core.js"; export { DiscordConfigSchema } from "../config/zod-schema.providers-core.js"; export { diff --git a/src/plugin-sdk/index.ts b/src/plugin-sdk/index.ts index 90292907149..a6044a0da84 100644 --- a/src/plugin-sdk/index.ts +++ b/src/plugin-sdk/index.ts @@ -690,10 +690,8 @@ export { export { inspectDiscordAccount } from "../../extensions/discord/src/account-inspect.js"; export type { InspectedDiscordAccount } from "../../extensions/discord/src/account-inspect.js"; export { collectDiscordAuditChannelIds } from "../../extensions/discord/src/audit.js"; -export { - discordSetupAdapter, - discordSetupWizard, -} from "../../extensions/discord/src/setup-surface.js"; +export { discordSetupWizard } from "../../extensions/discord/src/setup-surface.js"; +export { discordSetupAdapter } from "../../extensions/discord/src/setup-core.js"; export { looksLikeDiscordTargetId, normalizeDiscordMessagingTarget,