openclaw/extensions/discord/src/setup-core.ts

207 lines
7.8 KiB
TypeScript

import { DEFAULT_ACCOUNT_ID } from "openclaw/plugin-sdk/account-id";
import type { DiscordGuildEntry } from "openclaw/plugin-sdk/config-runtime";
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime";
import { createEnvPatchedAccountSetupAdapter } from "openclaw/plugin-sdk/setup-adapter-runtime";
import type {
ChannelSetupAdapter,
ChannelSetupDmPolicy,
ChannelSetupWizard,
} from "openclaw/plugin-sdk/setup-runtime";
import { createStandardChannelSetupStatus } from "openclaw/plugin-sdk/setup-runtime";
import { formatDocsLink } from "openclaw/plugin-sdk/setup-tools";
import {
inspectDiscordSetupAccount,
listDiscordSetupAccountIds,
resolveDiscordSetupAccountConfig,
} from "./setup-account-state.js";
import {
createAccountScopedAllowFromSection,
createAccountScopedGroupAccessSection,
createAllowlistSetupWizardProxy,
createLegacyCompatChannelDmPolicy,
parseMentionOrPrefixedId,
patchChannelConfigForAccount,
setSetupChannelEnabled,
} from "./setup-runtime-helpers.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<string, DiscordGuildEntry> = { ...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 = createEnvPatchedAccountSetupAdapter({
channelKey: channel,
defaultAccountOnlyEnvError: "DISCORD_BOT_TOKEN can only be used for the default account.",
missingCredentialError: "Discord requires token (or --use-env).",
hasCredentials: (input) => Boolean(input.token),
buildPatch: (input) => (input.token ? { token: input.token } : {}),
});
export function createDiscordSetupWizardBase(handlers: {
promptAllowFrom: NonNullable<ChannelSetupDmPolicy["promptAllowFrom"]>;
resolveAllowFromEntries: NonNullable<
NonNullable<ChannelSetupWizard["allowFrom"]>["resolveEntries"]
>;
resolveGroupAllowlist: NonNullable<
NonNullable<NonNullable<ChannelSetupWizard["groupAccess"]>["resolveAllowlist"]>
>;
}) {
const discordDmPolicy: ChannelSetupDmPolicy = createLegacyCompatChannelDmPolicy({
label: "Discord",
channel,
promptAllowFrom: handlers.promptAllowFrom,
});
return {
channel,
status: createStandardChannelSetupStatus({
channelLabel: "Discord",
configuredLabel: "configured",
unconfiguredLabel: "needs token",
configuredHint: "configured",
unconfiguredHint: "needs token",
configuredScore: 2,
unconfiguredScore: 1,
resolveConfigured: ({ cfg }) =>
listDiscordSetupAccountIds(cfg).some((accountId) => {
const account = inspectDiscordSetupAccount({ 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 = inspectDiscordSetupAccount({ 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: createAccountScopedGroupAccessSection({
channel,
label: "Discord channels",
placeholder: "My Server/#general, guildId/channelId, #support",
currentPolicy: ({ cfg, accountId }: { cfg: OpenClawConfig; accountId: string }) =>
resolveDiscordSetupAccountConfig({ cfg, accountId }).config.groupPolicy ?? "allowlist",
currentEntries: ({ cfg, accountId }: { cfg: OpenClawConfig; accountId: string }) =>
Object.entries(
resolveDiscordSetupAccountConfig({ 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(resolveDiscordSetupAccountConfig({ cfg, accountId }).config.guilds),
resolveAllowlist: handlers.resolveGroupAllowlist,
fallbackResolved: (entries) => entries.map((input) => ({ input, resolved: false })),
applyAllowlist: ({
cfg,
accountId,
resolved,
}: {
cfg: OpenClawConfig;
accountId: string;
resolved: unknown;
}) => setDiscordGuildChannelAllowlist(cfg, accountId, resolved as never),
}),
allowFrom: createAccountScopedAllowFromSection({
channel,
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: handlers.resolveAllowFromEntries,
}),
dmPolicy: discordDmPolicy,
disable: (cfg: OpenClawConfig) => setSetupChannelEnabled(cfg, channel, false),
} satisfies ChannelSetupWizard;
}
export function createDiscordSetupWizardProxy(loadWizard: () => Promise<ChannelSetupWizard>) {
return createAllowlistSetupWizardProxy({
loadWizard,
createBase: createDiscordSetupWizardBase,
fallbackResolvedGroupAllowlist: (entries) =>
entries.map((input) => ({ input, resolved: false })),
});
}