| import type { OpenClawConfig } from "../../../config/config.js"; |
| import { hasConfiguredSecretInput } from "../../../config/types.secrets.js"; |
| import { DEFAULT_ACCOUNT_ID } from "../../../routing/session-key.js"; |
| import { inspectSlackAccount } from "../../../slack/account-inspect.js"; |
| import { |
| listSlackAccountIds, |
| resolveDefaultSlackAccountId, |
| resolveSlackAccount, |
| } from "../../../slack/accounts.js"; |
| import { resolveSlackChannelAllowlist } from "../../../slack/resolve-channels.js"; |
| import { resolveSlackUserAllowlist } from "../../../slack/resolve-users.js"; |
| import { formatDocsLink } from "../../../terminal/links.js"; |
| import type { WizardPrompter } from "../../../wizard/prompts.js"; |
| import type { ChannelOnboardingAdapter, ChannelOnboardingDmPolicy } from "../onboarding-types.js"; |
| import { configureChannelAccessWithAllowlist } from "./channel-access-configure.js"; |
| import { |
| parseMentionOrPrefixedId, |
| noteChannelLookupFailure, |
| noteChannelLookupSummary, |
| patchChannelConfigForAccount, |
| promptLegacyChannelAllowFrom, |
| resolveAccountIdForConfigure, |
| resolveOnboardingAccountId, |
| runSingleChannelSecretStep, |
| setAccountGroupPolicyForChannel, |
| setLegacyChannelDmPolicyWithAllowFrom, |
| setOnboardingChannelEnabled, |
| } from "./helpers.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); |
| } |
|
|
| async function noteSlackTokenHelp(prompter: WizardPrompter, botName: string): Promise<void> { |
| const manifest = buildSlackManifest(botName); |
| await prompter.note( |
| [ |
| "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):", |
| manifest, |
| ].join("\n"), |
| "Slack socket mode tokens", |
| ); |
| } |
|
|
| function setSlackChannelAllowlist( |
| cfg: OpenClawConfig, |
| accountId: string, |
| channelKeys: string[], |
| ): OpenClawConfig { |
| const channels = Object.fromEntries(channelKeys.map((key) => [key, { allow: true }])); |
| return patchChannelConfigForAccount({ |
| cfg, |
| channel: "slack", |
| accountId, |
| patch: { channels }, |
| }); |
| } |
|
|
| async function promptSlackAllowFrom(params: { |
| cfg: OpenClawConfig; |
| prompter: WizardPrompter; |
| accountId?: string; |
| }): Promise<OpenClawConfig> { |
| const accountId = resolveOnboardingAccountId({ |
| 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, |
| mentionPattern: /^<@([A-Z0-9]+)>$/i, |
| prefixPattern: /^(slack:|user:)/i, |
| idPattern: /^[A-Z][A-Z0-9]+$/i, |
| normalizeId: (id) => id.toUpperCase(), |
| }); |
|
|
| return promptLegacyChannelAllowFrom({ |
| cfg: params.cfg, |
| channel: "slack", |
| prompter: params.prompter, |
| existing, |
| token, |
| noteTitle: "Slack allowlist", |
| noteLines: [ |
| "Allowlist Slack DMs by username (we resolve to user ids).", |
| "Examples:", |
| "- U12345678", |
| "- @alice", |
| "Multiple entries: comma-separated.", |
| `Docs: ${formatDocsLink("/slack", "slack")}`, |
| ], |
| message: "Slack allowFrom (usernames or ids)", |
| placeholder: "@alice, U12345678", |
| parseId, |
| invalidWithoutTokenNote: "Slack token missing; use user ids (or mention form) only.", |
| resolveEntries: ({ token, entries }) => |
| resolveSlackUserAllowlist({ |
| token, |
| entries, |
| }), |
| }); |
| } |
|
|
| const dmPolicy: ChannelOnboardingDmPolicy = { |
| label: "Slack", |
| channel, |
| policyKey: "channels.slack.dmPolicy", |
| allowFromKey: "channels.slack.allowFrom", |
| getCurrent: (cfg) => |
| cfg.channels?.slack?.dmPolicy ?? cfg.channels?.slack?.dm?.policy ?? "pairing", |
| setPolicy: (cfg, policy) => |
| setLegacyChannelDmPolicyWithAllowFrom({ |
| cfg, |
| channel: "slack", |
| dmPolicy: policy, |
| }), |
| promptAllowFrom: promptSlackAllowFrom, |
| }; |
|
|
| export const slackOnboardingAdapter: ChannelOnboardingAdapter = { |
| channel, |
| getStatus: async ({ cfg }) => { |
| const configured = listSlackAccountIds(cfg).some((accountId) => { |
| const account = inspectSlackAccount({ cfg, accountId }); |
| return account.configured; |
| }); |
| return { |
| channel, |
| configured, |
| statusLines: [`Slack: ${configured ? "configured" : "needs tokens"}`], |
| selectionHint: configured ? "configured" : "needs tokens", |
| quickstartScore: configured ? 2 : 1, |
| }; |
| }, |
| configure: async ({ cfg, prompter, options, accountOverrides, shouldPromptAccountIds }) => { |
| const defaultSlackAccountId = resolveDefaultSlackAccountId(cfg); |
| const slackAccountId = await resolveAccountIdForConfigure({ |
| cfg, |
| prompter, |
| label: "Slack", |
| accountOverride: accountOverrides.slack, |
| shouldPromptAccountIds, |
| listAccountIds: listSlackAccountIds, |
| defaultAccountId: defaultSlackAccountId, |
| }); |
|
|
| let next = cfg; |
| const resolvedAccount = resolveSlackAccount({ |
| cfg: next, |
| accountId: slackAccountId, |
| }); |
| const hasConfiguredBotToken = hasConfiguredSecretInput(resolvedAccount.config.botToken); |
| const hasConfiguredAppToken = hasConfiguredSecretInput(resolvedAccount.config.appToken); |
| const hasConfigTokens = hasConfiguredBotToken && hasConfiguredAppToken; |
| const accountConfigured = |
| Boolean(resolvedAccount.botToken && resolvedAccount.appToken) || hasConfigTokens; |
| const allowEnv = slackAccountId === DEFAULT_ACCOUNT_ID; |
| let resolvedBotTokenForAllowlist = resolvedAccount.botToken; |
| const slackBotName = String( |
| await prompter.text({ |
| message: "Slack bot display name (used for manifest)", |
| initialValue: "OpenClaw", |
| }), |
| ).trim(); |
| if (!accountConfigured) { |
| await noteSlackTokenHelp(prompter, slackBotName); |
| } |
| const botTokenStep = await runSingleChannelSecretStep({ |
| cfg: next, |
| prompter, |
| providerHint: "slack-bot", |
| credentialLabel: "Slack bot token", |
| secretInputMode: options?.secretInputMode, |
| accountConfigured: Boolean(resolvedAccount.botToken) || hasConfiguredBotToken, |
| hasConfigToken: hasConfiguredBotToken, |
| allowEnv, |
| envValue: process.env.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-...)", |
| preferredEnvVar: allowEnv ? "SLACK_BOT_TOKEN" : undefined, |
| applySet: async (cfg, value) => |
| patchChannelConfigForAccount({ |
| cfg, |
| channel: "slack", |
| accountId: slackAccountId, |
| patch: { botToken: value }, |
| }), |
| }); |
| next = botTokenStep.cfg; |
| if (botTokenStep.resolvedValue) { |
| resolvedBotTokenForAllowlist = botTokenStep.resolvedValue; |
| } |
|
|
| const appTokenStep = await runSingleChannelSecretStep({ |
| cfg: next, |
| prompter, |
| providerHint: "slack-app", |
| credentialLabel: "Slack app token", |
| secretInputMode: options?.secretInputMode, |
| accountConfigured: Boolean(resolvedAccount.appToken) || hasConfiguredAppToken, |
| hasConfigToken: hasConfiguredAppToken, |
| allowEnv, |
| envValue: process.env.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-...)", |
| preferredEnvVar: allowEnv ? "SLACK_APP_TOKEN" : undefined, |
| applySet: async (cfg, value) => |
| patchChannelConfigForAccount({ |
| cfg, |
| channel: "slack", |
| accountId: slackAccountId, |
| patch: { appToken: value }, |
| }), |
| }); |
| next = appTokenStep.cfg; |
|
|
| next = await configureChannelAccessWithAllowlist({ |
| cfg: next, |
| prompter, |
| label: "Slack channels", |
| currentPolicy: resolvedAccount.config.groupPolicy ?? "allowlist", |
| currentEntries: Object.entries(resolvedAccount.config.channels ?? {}) |
| .filter(([, value]) => value?.allow !== false && value?.enabled !== false) |
| .map(([key]) => key), |
| placeholder: "#general, #private, C123", |
| updatePrompt: Boolean(resolvedAccount.config.channels), |
| setPolicy: (cfg, policy) => |
| setAccountGroupPolicyForChannel({ |
| cfg, |
| channel: "slack", |
| accountId: slackAccountId, |
| groupPolicy: policy, |
| }), |
| resolveAllowlist: async ({ cfg, entries }) => { |
| let keys = entries; |
| const accountWithTokens = resolveSlackAccount({ |
| cfg, |
| accountId: slackAccountId, |
| }); |
| const activeBotToken = accountWithTokens.botToken || resolvedBotTokenForAllowlist || ""; |
| if (activeBotToken && entries.length > 0) { |
| try { |
| const resolved = await resolveSlackChannelAllowlist({ |
| token: activeBotToken, |
| entries, |
| }); |
| const resolvedKeys = resolved |
| .filter((entry) => entry.resolved && entry.id) |
| .map((entry) => entry.id as string); |
| const unresolved = resolved |
| .filter((entry) => !entry.resolved) |
| .map((entry) => entry.input); |
| keys = [...resolvedKeys, ...unresolved.map((entry) => entry.trim()).filter(Boolean)]; |
| await noteChannelLookupSummary({ |
| prompter, |
| label: "Slack channels", |
| resolvedSections: [{ title: "Resolved", values: resolvedKeys }], |
| unresolved, |
| }); |
| } catch (err) { |
| await noteChannelLookupFailure({ |
| prompter, |
| label: "Slack channels", |
| error: err, |
| }); |
| } |
| } |
| return keys; |
| }, |
| applyAllowlist: ({ cfg, resolved }) => { |
| return setSlackChannelAllowlist(cfg, slackAccountId, resolved); |
| }, |
| }); |
|
|
| return { cfg: next, accountId: slackAccountId }; |
| }, |
| dmPolicy, |
| disable: (cfg) => setOnboardingChannelEnabled(cfg, channel, false), |
| }; |
|
|