From 961f42e0cf9def1b1771394928c5e95d4cb68f8b Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Sun, 15 Mar 2026 18:28:39 -0700 Subject: [PATCH] Slack: lazy-load setup wizard surface --- extensions/slack/src/channel.runtime.ts | 1 + extensions/slack/src/channel.ts | 10 +- extensions/slack/src/setup-core.ts | 495 ++++++++++++++++++++++++ extensions/slack/src/setup-surface.ts | 78 +--- src/plugin-sdk/index.ts | 3 +- src/plugin-sdk/slack.ts | 3 +- 6 files changed, 510 insertions(+), 80 deletions(-) create mode 100644 extensions/slack/src/channel.runtime.ts create mode 100644 extensions/slack/src/setup-core.ts diff --git a/extensions/slack/src/channel.runtime.ts b/extensions/slack/src/channel.runtime.ts new file mode 100644 index 00000000000..eefcc2c6215 --- /dev/null +++ b/extensions/slack/src/channel.runtime.ts @@ -0,0 +1 @@ +export { slackSetupWizard } from "./setup-surface.js"; diff --git a/extensions/slack/src/channel.ts b/extensions/slack/src/channel.ts index 5903e5755b2..1a2232bb5e7 100644 --- a/extensions/slack/src/channel.ts +++ b/extensions/slack/src/channel.ts @@ -37,10 +37,14 @@ import { import { resolveOutboundSendDep } from "../../../src/infra/outbound/send-deps.js"; import { buildPassiveProbedChannelStatusSummary } from "../../shared/channel-status-summary.js"; import { getSlackRuntime } from "./runtime.js"; -import { slackSetupAdapter, slackSetupWizard } from "./setup-surface.js"; +import { createSlackSetupWizardProxy, slackSetupAdapter } from "./setup-core.js"; const meta = getChatChannelMeta("slack"); +async function loadSlackChannelRuntime() { + return await import("./channel.runtime.js"); +} + // Select the appropriate Slack token for read/write operations. function getTokenForOperation( account: ResolvedSlackAccount, @@ -106,6 +110,10 @@ const slackConfigBase = createScopedChannelConfigBase({ clearBaseFields: ["botToken", "appToken", "name"], }); +const slackSetupWizard = createSlackSetupWizardProxy(async () => ({ + slackSetupWizard: (await loadSlackChannelRuntime()).slackSetupWizard, +})); + export const slackPlugin: ChannelPlugin = { id: "slack", meta: { diff --git a/extensions/slack/src/setup-core.ts b/extensions/slack/src/setup-core.ts new file mode 100644 index 00000000000..0cf7903e6d4 --- /dev/null +++ b/extensions/slack/src/setup-core.ts @@ -0,0 +1,495 @@ +import type { ChannelOnboardingDmPolicy } from "../../../src/channels/plugins/onboarding-types.js"; +import { + noteChannelLookupFailure, + noteChannelLookupSummary, + parseMentionOrPrefixedId, + patchChannelConfigForAccount, + setAccountGroupPolicyForChannel, + setLegacyChannelDmPolicyWithAllowFrom, + setOnboardingChannelEnabled, +} from "../../../src/channels/plugins/onboarding/helpers.js"; +import { + applyAccountNameToChannelSection, + migrateBaseNameToDefaultAccount, +} from "../../../src/channels/plugins/setup-helpers.js"; +import type { + ChannelSetupWizard, + ChannelSetupWizardAllowFromEntry, +} 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 { hasConfiguredSecretInput } from "../../../src/config/types.secrets.js"; +import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "../../../src/routing/session-key.js"; +import { formatDocsLink } from "../../../src/terminal/links.js"; +import { inspectSlackAccount } from "./account-inspect.js"; +import { listSlackAccountIds, resolveSlackAccount, type ResolvedSlackAccount } from "./accounts.js"; + +const channel = "slack" as const; + +function buildSlackManifest(botName: string) { + const safeName = botName.trim() || "OpenClaw"; + const manifest = { + display_information: { + name: safeName, + description: `${safeName} connector for OpenClaw`, + }, + features: { + bot_user: { + display_name: safeName, + always_online: false, + }, + app_home: { + messages_tab_enabled: true, + messages_tab_read_only_enabled: false, + }, + slash_commands: [ + { + command: "/openclaw", + description: "Send a message to OpenClaw", + should_escape: false, + }, + ], + }, + oauth_config: { + scopes: { + bot: [ + "chat:write", + "channels:history", + "channels:read", + "groups:history", + "im:history", + "mpim:history", + "users:read", + "app_mentions:read", + "reactions:read", + "reactions:write", + "pins:read", + "pins:write", + "emoji:read", + "commands", + "files:read", + "files:write", + ], + }, + }, + settings: { + socket_mode_enabled: true, + event_subscriptions: { + bot_events: [ + "app_mention", + "message.channels", + "message.groups", + "message.im", + "message.mpim", + "reaction_added", + "reaction_removed", + "member_joined_channel", + "member_left_channel", + "channel_rename", + "pin_added", + "pin_removed", + ], + }, + }, + }; + return JSON.stringify(manifest, null, 2); +} + +function buildSlackSetupLines(botName = "OpenClaw"): string[] { + return [ + "1) Slack API -> Create App -> From scratch or From manifest (with the JSON below)", + "2) Add Socket Mode + enable it to get the app-level token (xapp-...)", + "3) Install App to workspace to get the xoxb- bot token", + "4) Enable Event Subscriptions (socket) for message events", + "5) App Home -> enable the Messages tab for DMs", + "Tip: set SLACK_BOT_TOKEN + SLACK_APP_TOKEN in your env.", + `Docs: ${formatDocsLink("/slack", "slack")}`, + "", + "Manifest (JSON):", + buildSlackManifest(botName), + ]; +} + +function enableSlackAccount(cfg: OpenClawConfig, accountId: string): OpenClawConfig { + return patchChannelConfigForAccount({ + cfg, + channel, + accountId, + patch: { enabled: true }, + }); +} + +function setSlackChannelAllowlist( + cfg: OpenClawConfig, + accountId: string, + channelKeys: string[], +): OpenClawConfig { + const channels = Object.fromEntries(channelKeys.map((key) => [key, { allow: true }])); + return patchChannelConfigForAccount({ + cfg, + channel, + accountId, + patch: { channels }, + }); +} + +function isSlackAccountConfigured(account: ResolvedSlackAccount): boolean { + const hasConfiguredBotToken = + Boolean(account.botToken?.trim()) || hasConfiguredSecretInput(account.config.botToken); + const hasConfiguredAppToken = + Boolean(account.appToken?.trim()) || hasConfiguredSecretInput(account.config.appToken); + return hasConfiguredBotToken && hasConfiguredAppToken; +} + +export const slackSetupAdapter: 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 "Slack env tokens can only be used for the default account."; + } + if (!input.useEnv && (!input.botToken || !input.appToken)) { + return "Slack requires --bot-token and --app-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, + slack: { + ...next.channels?.slack, + enabled: true, + ...(input.useEnv + ? {} + : { + ...(input.botToken ? { botToken: input.botToken } : {}), + ...(input.appToken ? { appToken: input.appToken } : {}), + }), + }, + }, + }; + } + return { + ...next, + channels: { + ...next.channels, + slack: { + ...next.channels?.slack, + enabled: true, + accounts: { + ...next.channels?.slack?.accounts, + [accountId]: { + ...next.channels?.slack?.accounts?.[accountId], + enabled: true, + ...(input.botToken ? { botToken: input.botToken } : {}), + ...(input.appToken ? { appToken: input.appToken } : {}), + }, + }, + }, + }, + }; + }, +}; + +export function createSlackSetupWizardProxy( + loadWizard: () => Promise<{ slackSetupWizard: ChannelSetupWizard }>, +) { + const slackDmPolicy: ChannelOnboardingDmPolicy = { + 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: async ({ cfg, prompter, accountId }) => { + const wizard = (await loadWizard()).slackSetupWizard; + if (!wizard.dmPolicy?.promptAllowFrom) { + return cfg; + } + return await wizard.dmPolicy.promptAllowFrom({ cfg, prompter, accountId }); + }, + }; + + return { + channel, + status: { + configuredLabel: "configured", + unconfiguredLabel: "needs tokens", + configuredHint: "configured", + unconfiguredHint: "needs tokens", + configuredScore: 2, + unconfiguredScore: 1, + resolveConfigured: ({ cfg }) => + listSlackAccountIds(cfg).some((accountId) => { + const account = inspectSlackAccount({ cfg, accountId }); + return account.configured; + }), + }, + introNote: { + title: "Slack socket mode tokens", + lines: buildSlackSetupLines(), + shouldShow: ({ cfg, accountId }) => + !isSlackAccountConfigured(resolveSlackAccount({ cfg, accountId })), + }, + envShortcut: { + prompt: "SLACK_BOT_TOKEN + SLACK_APP_TOKEN detected. Use env vars?", + preferredEnvVar: "SLACK_BOT_TOKEN", + isAvailable: ({ cfg, accountId }) => + accountId === DEFAULT_ACCOUNT_ID && + Boolean(process.env.SLACK_BOT_TOKEN?.trim()) && + Boolean(process.env.SLACK_APP_TOKEN?.trim()) && + !isSlackAccountConfigured(resolveSlackAccount({ cfg, accountId })), + apply: ({ cfg, accountId }) => enableSlackAccount(cfg, accountId), + }, + credentials: [ + { + inputKey: "botToken", + providerHint: "slack-bot", + credentialLabel: "Slack bot token", + preferredEnvVar: "SLACK_BOT_TOKEN", + envPrompt: "SLACK_BOT_TOKEN detected. Use env var?", + keepPrompt: "Slack bot token already configured. Keep it?", + inputPrompt: "Enter Slack bot token (xoxb-...)", + allowEnv: ({ accountId }: { accountId: string }) => accountId === DEFAULT_ACCOUNT_ID, + inspect: ({ cfg, accountId }: { cfg: OpenClawConfig; accountId: string }) => { + const resolved = resolveSlackAccount({ cfg, accountId }); + return { + accountConfigured: + Boolean(resolved.botToken) || hasConfiguredSecretInput(resolved.config.botToken), + hasConfiguredValue: hasConfiguredSecretInput(resolved.config.botToken), + resolvedValue: resolved.botToken?.trim() || undefined, + envValue: + accountId === DEFAULT_ACCOUNT_ID ? process.env.SLACK_BOT_TOKEN?.trim() : undefined, + }; + }, + applyUseEnv: ({ cfg, accountId }: { cfg: OpenClawConfig; accountId: string }) => + enableSlackAccount(cfg, accountId), + applySet: ({ + cfg, + accountId, + value, + }: { + cfg: OpenClawConfig; + accountId: string; + value: unknown; + }) => + patchChannelConfigForAccount({ + cfg, + channel, + accountId, + patch: { + enabled: true, + botToken: value, + }, + }), + }, + { + inputKey: "appToken", + providerHint: "slack-app", + credentialLabel: "Slack app token", + preferredEnvVar: "SLACK_APP_TOKEN", + envPrompt: "SLACK_APP_TOKEN detected. Use env var?", + keepPrompt: "Slack app token already configured. Keep it?", + inputPrompt: "Enter Slack app token (xapp-...)", + allowEnv: ({ accountId }: { accountId: string }) => accountId === DEFAULT_ACCOUNT_ID, + inspect: ({ cfg, accountId }: { cfg: OpenClawConfig; accountId: string }) => { + const resolved = resolveSlackAccount({ cfg, accountId }); + return { + accountConfigured: + Boolean(resolved.appToken) || hasConfiguredSecretInput(resolved.config.appToken), + hasConfiguredValue: hasConfiguredSecretInput(resolved.config.appToken), + resolvedValue: resolved.appToken?.trim() || undefined, + envValue: + accountId === DEFAULT_ACCOUNT_ID ? process.env.SLACK_APP_TOKEN?.trim() : undefined, + }; + }, + applyUseEnv: ({ cfg, accountId }: { cfg: OpenClawConfig; accountId: string }) => + enableSlackAccount(cfg, accountId), + applySet: ({ + cfg, + accountId, + value, + }: { + cfg: OpenClawConfig; + accountId: string; + value: unknown; + }) => + patchChannelConfigForAccount({ + cfg, + channel, + accountId, + patch: { + enabled: true, + appToken: value, + }, + }), + }, + ], + dmPolicy: slackDmPolicy, + allowFrom: { + helpTitle: "Slack allowlist", + helpLines: [ + "Allowlist Slack DMs by username (we resolve to user ids).", + "Examples:", + "- U12345678", + "- @alice", + "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.", + parseId: (value: string) => + parseMentionOrPrefixedId({ + value, + mentionPattern: /^<@([A-Z0-9]+)>$/i, + prefixPattern: /^(slack:|user:)/i, + 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[]; + }) => { + const wizard = (await loadWizard()).slackSetupWizard; + if (!wizard.allowFrom) { + return entries.map((input) => ({ input, resolved: false, id: null })); + } + return await wizard.allowFrom.resolveEntries({ + cfg, + accountId, + credentialValues, + entries, + }); + }, + apply: ({ + cfg, + accountId, + allowFrom, + }: { + cfg: OpenClawConfig; + accountId: string; + allowFrom: string[]; + }) => + patchChannelConfigForAccount({ + cfg, + channel, + accountId, + patch: { dmPolicy: "allowlist", allowFrom }, + }), + }, + groupAccess: { + label: "Slack channels", + placeholder: "#general, #private, C123", + currentPolicy: ({ cfg, accountId }: { cfg: OpenClawConfig; accountId: string }) => + resolveSlackAccount({ cfg, accountId }).config.groupPolicy ?? "allowlist", + currentEntries: ({ cfg, accountId }: { cfg: OpenClawConfig; accountId: string }) => + Object.entries(resolveSlackAccount({ cfg, accountId }).config.channels ?? {}) + .filter(([, value]) => value?.allow !== false && value?.enabled !== false) + .map(([key]) => key), + updatePrompt: ({ cfg, accountId }: { cfg: OpenClawConfig; accountId: string }) => + Boolean(resolveSlackAccount({ cfg, accountId }).config.channels), + setPolicy: ({ + cfg, + accountId, + policy, + }: { + cfg: OpenClawConfig; + accountId: string; + policy: "open" | "allowlist" | "disabled"; + }) => + setAccountGroupPolicyForChannel({ + cfg, + channel, + accountId, + groupPolicy: policy, + }), + resolveAllowlist: async ({ + cfg, + accountId, + credentialValues, + entries, + prompter, + }: { + cfg: OpenClawConfig; + accountId: string; + credentialValues: { botToken?: string }; + entries: string[]; + prompter: { note: (message: string, title?: string) => Promise }; + }) => { + try { + const wizard = (await loadWizard()).slackSetupWizard; + if (!wizard.groupAccess) { + return entries; + } + return await wizard.groupAccess.resolveAllowlist({ + 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; + } + }, + applyAllowlist: ({ + cfg, + accountId, + resolved, + }: { + cfg: OpenClawConfig; + accountId: string; + resolved: unknown; + }) => setSlackChannelAllowlist(cfg, accountId, resolved as string[]), + }, + disable: (cfg: OpenClawConfig) => setOnboardingChannelEnabled(cfg, channel, false), + } satisfies ChannelSetupWizard; +} diff --git a/extensions/slack/src/setup-surface.ts b/extensions/slack/src/setup-surface.ts index ad743ffa080..dafcad32f74 100644 --- a/extensions/slack/src/setup-surface.ts +++ b/extensions/slack/src/setup-surface.ts @@ -10,15 +10,10 @@ import { setLegacyChannelDmPolicyWithAllowFrom, setOnboardingChannelEnabled, } from "../../../src/channels/plugins/onboarding/helpers.js"; -import { - applyAccountNameToChannelSection, - migrateBaseNameToDefaultAccount, -} from "../../../src/channels/plugins/setup-helpers.js"; import type { ChannelSetupWizard, ChannelSetupWizardAllowFromEntry, } 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 { hasConfiguredSecretInput } from "../../../src/config/types.secrets.js"; import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "../../../src/routing/session-key.js"; @@ -33,6 +28,7 @@ import { } from "./accounts.js"; import { resolveSlackChannelAllowlist } from "./resolve-channels.js"; import { resolveSlackUserAllowlist } from "./resolve-users.js"; +import { slackSetupAdapter } from "./setup-core.js"; const channel = "slack" as const; @@ -238,78 +234,6 @@ function isSlackAccountConfigured(account: ResolvedSlackAccount): boolean { return hasConfiguredBotToken && hasConfiguredAppToken; } -export const slackSetupAdapter: 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 "Slack env tokens can only be used for the default account."; - } - if (!input.useEnv && (!input.botToken || !input.appToken)) { - return "Slack requires --bot-token and --app-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, - slack: { - ...next.channels?.slack, - enabled: true, - ...(input.useEnv - ? {} - : { - ...(input.botToken ? { botToken: input.botToken } : {}), - ...(input.appToken ? { appToken: input.appToken } : {}), - }), - }, - }, - }; - } - return { - ...next, - channels: { - ...next.channels, - slack: { - ...next.channels?.slack, - enabled: true, - accounts: { - ...next.channels?.slack?.accounts, - [accountId]: { - ...next.channels?.slack?.accounts?.[accountId], - enabled: true, - ...(input.botToken ? { botToken: input.botToken } : {}), - ...(input.appToken ? { appToken: input.appToken } : {}), - }, - }, - }, - }, - }; - }, -}; - export const slackSetupWizard: ChannelSetupWizard = { channel, status: { diff --git a/src/plugin-sdk/index.ts b/src/plugin-sdk/index.ts index 089876dc7bc..90292907149 100644 --- a/src/plugin-sdk/index.ts +++ b/src/plugin-sdk/index.ts @@ -745,7 +745,8 @@ export { extractSlackToolSend, listSlackMessageActions, } from "../../extensions/slack/src/message-actions.js"; -export { slackSetupAdapter, slackSetupWizard } from "../../extensions/slack/src/setup-surface.js"; +export { slackSetupAdapter } from "../../extensions/slack/src/setup-core.js"; +export { slackSetupWizard } from "../../extensions/slack/src/setup-surface.js"; export { looksLikeSlackTargetId, normalizeSlackMessagingTarget, diff --git a/src/plugin-sdk/slack.ts b/src/plugin-sdk/slack.ts index 779560b930b..7e200ab5995 100644 --- a/src/plugin-sdk/slack.ts +++ b/src/plugin-sdk/slack.ts @@ -39,7 +39,8 @@ export { resolveSlackGroupRequireMention, resolveSlackGroupToolPolicy, } from "../channels/plugins/group-mentions.js"; -export { slackSetupAdapter, slackSetupWizard } from "../../extensions/slack/src/setup-surface.js"; +export { slackSetupAdapter } from "../../extensions/slack/src/setup-core.js"; +export { slackSetupWizard } from "../../extensions/slack/src/setup-surface.js"; export { SlackConfigSchema } from "../config/zod-schema.providers-core.js"; export { handleSlackMessageAction } from "./slack-message-actions.js";