From 561ba217cbc91802996ef4efb904d05f98fc430c Mon Sep 17 00:00:00 2001 From: Keagan Stokoe Date: Thu, 12 Mar 2026 14:52:58 +0200 Subject: [PATCH 1/2] feat: add Slack as a first-class channel MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds full Slack channel support across AlphaClaw — onboarding wizard, channel dashboard, pairings, gateway sync, watchdog notifications, and the channel creation modal. Slack uses Socket Mode which requires two tokens (Bot Token + App Token) rather than one. This is handled via a new `extraEnvKeys` property on channel definitions and dual-token logic in the gateway sync and onboarding flows. Changes: - Add Slack to channel definitions, env keys, and known vars - Add Slack Bot Token and App Token fields to onboarding wizard - Wire Slack into openclaw.json generation (fresh + import flows) - Handle dual-token channel sync in gateway (--bot-token / --app-token) - Use Object.keys(kChannelDefs) instead of hardcoded channel arrays - Add Slack to channel dashboard, pairings UI, and pairing routes - Add App Token field to create-channel modal (Slack is single-account) - Add watchdog crash notifications via Slack DM - Add slack-api.js (minimal Web API wrapper matching existing patterns) - Add Slack SVG icon - Update agents-service test for Slack channel account Ref: chrysb/alphaclaw#8 Co-Authored-By: Claude Opus 4.6 --- lib/public/assets/icons/slack.svg | 13 +++++++ .../agents-tab/create-channel-modal.js | 38 +++++++++++++++++- lib/public/js/components/channels.js | 3 +- .../js/components/onboarding/pairing-utils.js | 1 + .../components/onboarding/welcome-config.js | 32 ++++++++++++++- lib/public/js/components/pairings.js | 2 +- lib/server.js | 4 +- lib/server/agents/shared.js | 3 ++ lib/server/constants.js | 13 +++++++ lib/server/gateway.js | 39 +++++++++++++------ lib/server/onboarding/openclaw.js | 30 ++++++++++++++ lib/server/onboarding/validation.js | 2 +- lib/server/routes/pairings.js | 4 +- lib/server/slack-api.js | 38 ++++++++++++++++++ lib/server/watchdog-notify.js | 23 +++++++++-- tests/server/agents-service.test.js | 14 +++++++ 16 files changed, 236 insertions(+), 23 deletions(-) create mode 100644 lib/public/assets/icons/slack.svg create mode 100644 lib/server/slack-api.js diff --git a/lib/public/assets/icons/slack.svg b/lib/public/assets/icons/slack.svg new file mode 100644 index 00000000..1f4c5e0c --- /dev/null +++ b/lib/public/assets/icons/slack.svg @@ -0,0 +1,13 @@ + + Slack + + + + + + + + + + + diff --git a/lib/public/js/components/agents-tab/create-channel-modal.js b/lib/public/js/components/agents-tab/create-channel-modal.js index 29eedf91..410400ae 100644 --- a/lib/public/js/components/agents-tab/create-channel-modal.js +++ b/lib/public/js/components/agents-tab/create-channel-modal.js @@ -14,6 +14,11 @@ const html = htm.bind(h); const kChannelEnvKeys = { telegram: "TELEGRAM_BOT_TOKEN", discord: "DISCORD_BOT_TOKEN", + slack: "SLACK_BOT_TOKEN", +}; + +const kChannelExtraEnvKeys = { + slack: "SLACK_APP_TOKEN", }; const slugifyChannelAccountId = (value) => @@ -50,6 +55,7 @@ export const CreateChannelModal = ({ const [name, setName] = useState(""); const [token, setToken] = useState(""); const [initialToken, setInitialToken] = useState(""); + const [appToken, setAppToken] = useState(""); const [agentId, setAgentId] = useState(""); const [error, setError] = useState(""); const [nameEditedManually, setNameEditedManually] = useState(false); @@ -92,6 +98,7 @@ export const CreateChannelModal = ({ : ""; setToken(nextToken); setInitialToken(nextToken); + setAppToken(""); setAgentId(nextAgentId); setError(""); setNameEditedManually(isEditMode); @@ -126,7 +133,8 @@ export const CreateChannelModal = ({ } setName(providerLabel); }, [provider, providerHasAccounts, nameEditedManually, isEditMode]); - const isSingleAccountProvider = String(provider || "").trim() === "discord"; + const isSingleAccountProvider = String(provider || "").trim() === "discord" || String(provider || "").trim() === "slack"; + const needsAppToken = String(provider || "").trim() === "slack"; const accountId = useMemo(() => { if (isEditMode) { @@ -184,6 +192,7 @@ export const CreateChannelModal = ({ && !!String(accountId || "").trim() && !!String(agentId || "").trim() && (isEditMode || !!String(token || "").trim()) + && (isEditMode || !needsAppToken || !!String(appToken || "").trim()) && (isEditMode || !accountExists) && !loadingToken; @@ -202,6 +211,10 @@ export const CreateChannelModal = ({ setError("Token is required"); return; } + if (!isEditMode && needsAppToken && !String(appToken || "").trim()) { + setError("App Token is required for Slack"); + return; + } if (!String(agentId || "").trim()) { setError("Agent is required"); return; @@ -214,12 +227,14 @@ export const CreateChannelModal = ({ setError(""); const trimmedToken = String(token || "").trim(); const tokenWasUpdated = trimmedToken && trimmedToken !== String(initialToken || "").trim(); + const trimmedAppToken = String(appToken || "").trim(); await onSubmit({ provider, name: String(name || "").trim(), accountId, agentId, ...(tokenWasUpdated ? { token: trimmedToken } : {}), + ...(needsAppToken && trimmedAppToken ? { appToken: trimmedAppToken } : {}), }); }; @@ -272,7 +287,7 @@ export const CreateChannelModal = ({ ${isEditMode ? "Channel id is fixed after creation." : isSingleAccountProvider - ? "Discord supports one channel account and uses the default id." + ? `${getChannelMeta(provider).label} supports one channel account and uses the default id.` : providerHasAccounts ? "Derived from the channel name." : "First account uses the default id for this provider."} @@ -295,6 +310,25 @@ export const CreateChannelModal = ({

+ ${needsAppToken + ? html` + + ` + : null} + - ${!isEditMode && accountExists - ? html` -

- ${isSingleAccountProvider - ? "Discord already has a configured channel account." - : `A ${getChannelMeta(provider).label} account with this id already exists.`} -

- ` - : null} + ${ + !isEditMode && accountExists + ? html` +

+ ${isSingleAccountProvider + ? `${getChannelMeta(provider).label} already has a configured channel account.` + : `A ${getChannelMeta(provider).label} account with this id already exists.`} +

+ ` + : null + } ${error ? html`

${error}

` : null} diff --git a/lib/public/js/components/channels.js b/lib/public/js/components/channels.js index 51980e6c..7b844788 100644 --- a/lib/public/js/components/channels.js +++ b/lib/public/js/components/channels.js @@ -6,10 +6,9 @@ import { useState, } from "https://esm.sh/preact/hooks"; import htm from "https://esm.sh/htm"; -import { ActionButton } from "./action-button.js"; +import { AddChannelMenu } from "./add-channel-menu.js"; import { ChannelAccountStatusBadge } from "./channel-account-status-badge.js"; import { ConfirmDialog } from "./confirm-dialog.js"; -import { AddLineIcon } from "./icons.js"; import { OverflowMenu, OverflowMenuItem } from "./overflow-menu.js"; import { deleteChannelAccount, @@ -21,6 +20,7 @@ import { resolveChannelAccountLabel, } from "../lib/channel-accounts.js"; import { createChannelAccountWithProgress } from "../lib/channel-create-operation.js"; +import { isChannelProviderDisabledForAdd } from "../lib/channel-provider-availability.js"; import { CreateChannelModal } from "./agents-tab/create-channel-modal.js"; import { showToast } from "./toast.js"; @@ -272,11 +272,6 @@ export const Channels = ({ mode: "create", }); }; - const hasDiscordAccount = useMemo(() => { - const discordChannel = configuredChannelMap.get("discord"); - return Array.isArray(discordChannel?.accounts) && discordChannel.accounts.length > 0; - }, [configuredChannelMap]); - const items = useMemo( () => { if (loadingAccounts || !channels) return []; @@ -478,48 +473,23 @@ export const Channels = ({ ? "Loading..." : "No channels configured"} actions=${html` - <${OverflowMenu} + <${AddChannelMenu} open=${menuOpenId === "__create_channel"} - ariaLabel="Add channel" - title="Add channel" onClose=${() => setMenuOpenId("")} onToggle=${() => setMenuOpenId((current) => current === "__create_channel" ? "" : "__create_channel", )} - renderTrigger=${({ onToggle, ariaLabel, title }) => html` - <${ActionButton} - onClick=${onToggle} - disabled=${saving || loadingAccounts} - loading=${false} - loadingMode="inline" - tone="subtle" - size="sm" - idleLabel="Add channel" - loadingLabel="Opening..." - idleIcon=${AddLineIcon} - idleIconClassName="h-3.5 w-3.5" - iconOnly=${true} - title=${title} - ariaLabel=${ariaLabel} - /> - `} - > - ${ALL_CHANNELS.map((channelId) => { - const channelMeta = getChannelMeta(channelId); - const isDisabled = channelId === "discord" && hasDiscordAccount; - return html` - <${OverflowMenuItem} - key=${channelId} - iconSrc=${channelMeta.iconSrc} - disabled=${isDisabled} - onClick=${() => openCreateChannelModal(channelId)} - > - ${channelMeta.label} - - `; - })} - + triggerDisabled=${saving || loadingAccounts} + channelIds=${ALL_CHANNELS} + getChannelMeta=${getChannelMeta} + isChannelDisabled=${(channelId) => + isChannelProviderDisabledForAdd({ + configuredChannelMap, + provider: channelId, + })} + onSelectChannel=${openCreateChannelModal} + /> `} /> <${CreateChannelModal} diff --git a/lib/public/js/components/envars.js b/lib/public/js/components/envars.js index 23c9bd8d..30140bf4 100644 --- a/lib/public/js/components/envars.js +++ b/lib/public/js/components/envars.js @@ -45,7 +45,8 @@ const kFeatureIconByName = { }, }; const normalizeEnvVarKey = (raw) => raw.trim().toUpperCase().replace(/[^A-Z0-9_]/g, "_"); -const kManagedChannelTokenPattern = /^(TELEGRAM|DISCORD)_BOT_TOKEN(?:_[A-Z0-9_]+)?$/; +const kManagedChannelTokenPattern = + /^(?:TELEGRAM_BOT_TOKEN|DISCORD_BOT_TOKEN|SLACK_BOT_TOKEN|SLACK_APP_TOKEN)(?:_[A-Z0-9_]+)?$/; const stripSurroundingQuotes = (raw) => { const value = String(raw || "").trim(); if (value.length < 2) return value; @@ -342,7 +343,7 @@ export const Envars = ({ onRestartRequired = () => {} }) => { if (managedChannelKeys.length) { const uniqueManagedKeys = Array.from(new Set(managedChannelKeys)); showToast( - `Channel bot tokens are managed from Channels: ${uniqueManagedKeys.join(", ")}`, + `Channel tokens are managed from Channels: ${uniqueManagedKeys.join(", ")}`, "error", ); } @@ -383,7 +384,7 @@ export const Envars = ({ onRestartRequired = () => {} }) => { const key = normalizeEnvVarKey(newKey); if (!key) return; if (isManagedChannelTokenKey(key)) { - showToast(`Channel bot tokens are managed from Channels: ${key}`, "error"); + showToast(`Channel tokens are managed from Channels: ${key}`, "error"); return; } if (reservedKeys.has(key)) { diff --git a/lib/public/js/lib/channel-provider-availability.js b/lib/public/js/lib/channel-provider-availability.js new file mode 100644 index 00000000..531975b8 --- /dev/null +++ b/lib/public/js/lib/channel-provider-availability.js @@ -0,0 +1,23 @@ +const kSingleAccountChannelProviders = new Set(["discord", "slack"]); + +const hasConfiguredAccounts = ({ configuredChannelMap, provider }) => { + const channelEntry = configuredChannelMap instanceof Map + ? configuredChannelMap.get(String(provider || "").trim()) + : null; + return ( + Array.isArray(channelEntry?.accounts) && + channelEntry.accounts.length > 0 + ); +}; + +export const isSingleAccountChannelProvider = (provider = "") => + kSingleAccountChannelProviders.has(String(provider || "").trim()); + +export const isChannelProviderDisabledForAdd = ({ + configuredChannelMap = new Map(), + provider = "", +} = {}) => { + if (!isSingleAccountChannelProvider(provider)) return false; + return hasConfiguredAccounts({ configuredChannelMap, provider }); +}; + diff --git a/lib/server/agents/channels.js b/lib/server/agents/channels.js index 8e736cc2..58334043 100644 --- a/lib/server/agents/channels.js +++ b/lib/server/agents/channels.js @@ -15,6 +15,7 @@ const { isValidChannelAccountId, normalizeChannelProvider, deriveChannelEnvKey, + deriveChannelExtraEnvKeys, getConfiguredChannelEnvKeys, assertActiveChannelTokenEnvVars, normalizeChannelConfig, @@ -63,15 +64,28 @@ const createChannelsDomain = ({ throw new Error(`Channel account "${provider}/${accountId}" not found`); } const envKey = deriveChannelEnvKey({ provider, accountId }); + const extraEnvKeys = deriveChannelExtraEnvKeys({ provider, accountId }); const envVars = readEnvFile(); const envEntry = (Array.isArray(envVars) ? envVars : []).find( (entry) => String(entry?.key || "").trim() === envKey, ); + const appEnvKey = extraEnvKeys[0] || ""; + const appEnvEntry = appEnvKey + ? (Array.isArray(envVars) ? envVars : []).find( + (entry) => String(entry?.key || "").trim() === appEnvKey, + ) + : null; return { provider, accountId, envKey, token: String(envEntry?.value || ""), + ...(provider === "slack" + ? { + appEnvKey, + appToken: String(appEnvEntry?.value || ""), + } + : {}), }; }; @@ -129,11 +143,21 @@ const createChannelsDomain = ({ `Channel account "${provider}/${accountId}" already exists`, ); } - if (provider === "discord" && Object.keys(existingAccounts).length > 0) { - throw new Error("Discord supports a single channel account"); + if ( + (provider === "discord" || provider === "slack") && + Object.keys(existingAccounts).length > 0 + ) { + throw new Error( + `${kChannelLabels[provider] || "This provider"} supports a single channel account`, + ); } const envKey = deriveChannelEnvKey({ provider, accountId }); + const extraEnvKeys = deriveChannelExtraEnvKeys({ provider, accountId }); + const appToken = String(input.appToken || "").trim(); + if (provider === "slack" && !appToken) { + throw new Error("Slack App Token is required"); + } const tokenField = kChannelTokenFields[provider]; const currentEnvVars = readEnvFile(); const previousEnvVars = Array.isArray(currentEnvVars) ? currentEnvVars : []; @@ -156,11 +180,41 @@ const createChannelsDomain = ({ `[alphaclaw] Overwriting orphaned channel env var ${dupKey} (no matching config entry)`, ); } + let orphanedExtraEnvKey = null; + if (provider === "slack") { + const appEnvKey = extraEnvKeys[0]; + const duplicateAppTokenEntry = previousEnvVars.find((entry) => { + const existingKey = String(entry?.key || "").trim(); + const existingValue = String(entry?.value || "").trim(); + if (!existingKey || !existingValue) return false; + if (existingKey === envKey || existingKey === appEnvKey) return false; + return existingValue === appToken; + }); + if (duplicateAppTokenEntry) { + const dupKey = String(duplicateAppTokenEntry.key || "").trim(); + const configuredKeys = getConfiguredChannelEnvKeys(cfg); + if (configuredKeys.has(dupKey)) { + throw new Error(`Channel token already exists in ${dupKey}`); + } + orphanedExtraEnvKey = dupKey; + console.log( + `[alphaclaw] Overwriting orphaned channel env var ${dupKey} (no matching config entry)`, + ); + } + } const nextEnvVars = previousEnvVars.filter((entry) => { const key = String(entry?.key || "").trim(); - return key !== envKey && key !== orphanedEnvKey; + return ( + key !== envKey && + key !== orphanedEnvKey && + !extraEnvKeys.includes(key) && + key !== orphanedExtraEnvKey + ); }); nextEnvVars.push({ key: envKey, value: token }); + if (provider === "slack" && extraEnvKeys[0]) { + nextEnvVars.push({ key: extraEnvKeys[0], value: appToken }); + } const previousConfig = cloneJson(cfg); try { @@ -188,7 +242,12 @@ const createChannelsDomain = ({ ? `--account ${shellEscapeArg(accountId)}` : "", name ? `--name ${shellEscapeArg(name)}` : "", - `--token ${shellEscapeArg(token)}`, + provider === "slack" + ? `--bot-token ${shellEscapeArg(token)}` + : `--token ${shellEscapeArg(token)}`, + provider === "slack" && appToken + ? `--app-token ${shellEscapeArg(appToken)}` + : "", ].filter(Boolean); const addResult = await clawCmd(addArgs.join(" "), { quiet: true, @@ -225,6 +284,9 @@ const createChannelsDomain = ({ : {}), ...(name ? { name } : {}), [tokenField]: `\${${envKey}}`, + ...(provider === "slack" && extraEnvKeys[0] + ? { appToken: `\${${extraEnvKeys[0]}}` } + : {}), dmPolicy: "pairing", }; nextProviderConfig.accounts = nextAccounts; @@ -308,6 +370,7 @@ const createChannelsDomain = ({ const nextName = String(input.name || "").trim(); const nextAgentId = String(input.agentId || "").trim(); const nextToken = String(input.token || "").trim(); + const nextAppToken = String(input.appToken || "").trim(); if (!nextName) throw new Error("Channel name is required"); if (!nextAgentId) throw new Error("Agent is required"); @@ -337,6 +400,41 @@ const createChannelsDomain = ({ } let tokenUpdated = false; + if (provider === "slack" && nextAppToken) { + const appEnvKey = deriveChannelExtraEnvKeys({ provider, accountId })[0]; + const currentEnvVars = readEnvFile(); + const previousEnvVars = Array.isArray(currentEnvVars) + ? currentEnvVars + : []; + const existingAppToken = String( + previousEnvVars.find( + (entry) => String(entry?.key || "").trim() === appEnvKey, + )?.value || "", + ); + const duplicateEnvEntry = previousEnvVars.find((entry) => { + const existingKey = String(entry?.key || "").trim(); + const existingValue = String(entry?.value || "").trim(); + if (!existingKey || !existingValue) return false; + if (existingKey === appEnvKey) return false; + return existingValue === nextAppToken; + }); + if (duplicateEnvEntry) { + const dupKey = String(duplicateEnvEntry.key || "").trim(); + const configuredKeys = getConfiguredChannelEnvKeys(cfg); + if (configuredKeys.has(dupKey)) { + throw new Error(`Channel token already exists in ${dupKey}`); + } + } + if (existingAppToken !== nextAppToken) { + const nextEnvVars = previousEnvVars.filter( + (entry) => String(entry?.key || "").trim() !== appEnvKey, + ); + nextEnvVars.push({ key: appEnvKey, value: nextAppToken }); + writeEnvFile(nextEnvVars); + reloadEnv(); + tokenUpdated = true; + } + } if (nextToken) { const envKey = deriveChannelEnvKey({ provider, accountId }); const currentEnvVars = readEnvFile(); @@ -542,12 +640,15 @@ const createChannelsDomain = ({ saveConfig({ fsImpl, OPENCLAW_DIR, config: nextCfg }); const envKey = deriveChannelEnvKey({ provider, accountId }); + const extraEnvKeys = deriveChannelExtraEnvKeys({ provider, accountId }); const currentEnvVars = readEnvFile(); const previousEnvVars = Array.isArray(currentEnvVars) ? currentEnvVars : []; const nextEnvVars = previousEnvVars.filter( - (entry) => String(entry?.key || "").trim() !== envKey, + (entry) => + String(entry?.key || "").trim() !== envKey && + !extraEnvKeys.includes(String(entry?.key || "").trim()), ); if (nextEnvVars.length !== previousEnvVars.length) { writeEnvFile(nextEnvVars); @@ -577,10 +678,13 @@ const createChannelsDomain = ({ } const envKey = deriveChannelEnvKey({ provider, accountId }); + const extraEnvKeys = deriveChannelExtraEnvKeys({ provider, accountId }); const currentEnvVars = readEnvFile(); const previousEnvVars = Array.isArray(currentEnvVars) ? currentEnvVars : []; const nextEnvVars = previousEnvVars.filter( - (entry) => String(entry?.key || "").trim() !== envKey, + (entry) => + String(entry?.key || "").trim() !== envKey && + !extraEnvKeys.includes(String(entry?.key || "").trim()), ); if (nextEnvVars.length !== previousEnvVars.length) { writeEnvFile(nextEnvVars); diff --git a/lib/server/agents/shared.js b/lib/server/agents/shared.js index 86b131ee..0c80d1e4 100644 --- a/lib/server/agents/shared.js +++ b/lib/server/agents/shared.js @@ -15,11 +15,17 @@ const kChannelEnvKeys = { discord: "DISCORD_BOT_TOKEN", slack: "SLACK_BOT_TOKEN", }; +const kChannelExtraEnvKeys = { + slack: ["SLACK_APP_TOKEN"], +}; const kChannelTokenFields = { telegram: "botToken", discord: "token", slack: "botToken", }; +const kChannelExtraTokenFields = { + slack: ["appToken"], +}; const kChannelLabels = { telegram: "Telegram", discord: "Discord", @@ -155,6 +161,19 @@ const deriveChannelEnvKey = ({ provider, accountId }) => { return `${envKey}_${normalizedAccountId.replace(/-/g, "_").toUpperCase()}`; }; +const deriveChannelExtraEnvKeys = ({ provider, accountId }) => { + const normalizedProvider = normalizeChannelProvider(provider); + const baseEnvKeys = Array.isArray(kChannelExtraEnvKeys[normalizedProvider]) + ? kChannelExtraEnvKeys[normalizedProvider] + : []; + const normalizedAccountId = String(accountId || "").trim(); + if (!normalizedAccountId || normalizedAccountId === "default") { + return [...baseEnvKeys]; + } + const suffix = normalizedAccountId.replace(/-/g, "_").toUpperCase(); + return baseEnvKeys.map((baseKey) => `${baseKey}_${suffix}`); +}; + const getConfiguredChannelEnvKeys = (cfg) => { const keys = new Set(); const channels = @@ -167,9 +186,19 @@ const getConfiguredChannelEnvKeys = (cfg) => { : {}; for (const accountId of Object.keys(accounts)) { keys.add(deriveChannelEnvKey({ provider, accountId })); + const extraEnvKeys = deriveChannelExtraEnvKeys({ provider, accountId }); + for (const extraEnvKey of extraEnvKeys) { + keys.add(extraEnvKey); + } } if (Object.keys(accounts).length === 0 && providerConfig?.enabled) { keys.add(kChannelEnvKeys[provider]); + for (const extraEnvKey of deriveChannelExtraEnvKeys({ + provider, + accountId: "default", + })) { + keys.add(extraEnvKey); + } } } return keys; @@ -211,6 +240,15 @@ const assertActiveChannelTokenEnvVars = ({ cfg, envVars }) => { `Missing required channel token env var ${envKey} for active channel ${provider}/${accountId}`, ); } + const extraEnvKeys = deriveChannelExtraEnvKeys({ provider, accountId }); + for (const extraEnvKey of extraEnvKeys) { + const extraEnvValue = String(envMap.get(extraEnvKey) || "").trim(); + if (!extraEnvValue) { + throw new Error( + `Missing required channel token env var ${extraEnvKey} for active channel ${provider}/${accountId}`, + ); + } + } } } }; @@ -226,8 +264,13 @@ const normalizeChannelConfig = ({ provider, channelConfig }) => { ? { ...nextConfig.accounts } : {}; const tokenField = kChannelTokenFields[normalizedProvider]; + const extraTokenFields = Array.isArray( + kChannelExtraTokenFields[normalizedProvider], + ) + ? kChannelExtraTokenFields[normalizedProvider] + : []; if (Object.keys(existingAccounts).length > 0) { - if (tokenField) { + if (tokenField || extraTokenFields.length > 0) { for (const [accountId, accountConfig] of Object.entries( existingAccounts, )) { @@ -242,6 +285,17 @@ const normalizeChannelConfig = ({ provider, channelConfig }) => { accountId, })}}`; } + const extraEnvKeys = deriveChannelExtraEnvKeys({ + provider: normalizedProvider, + accountId, + }); + extraTokenFields.forEach((fieldName, index) => { + const rawValue = String(nextAccountConfig[fieldName] || "").trim(); + if (!rawValue) return; + if (isEnvRef(rawValue)) return; + if (!extraEnvKeys[index]) return; + nextAccountConfig[fieldName] = `\${${extraEnvKeys[index]}}`; + }); existingAccounts[accountId] = nextAccountConfig; } } @@ -269,6 +323,17 @@ const normalizeChannelConfig = ({ provider, channelConfig }) => { defaultAccountConfig[tokenField] = defaultTokenEnvRef; } } + const defaultExtraEnvKeys = deriveChannelExtraEnvKeys({ + provider: normalizedProvider, + accountId: "default", + }); + extraTokenFields.forEach((fieldName, index) => { + const rawValue = String(defaultAccountConfig[fieldName] || "").trim(); + if (!rawValue) return; + if (isEnvRef(rawValue)) return; + if (!defaultExtraEnvKeys[index]) return; + defaultAccountConfig[fieldName] = `\${${defaultExtraEnvKeys[index]}}`; + }); if ( Object.keys(defaultAccountConfig).length > 0 || defaultAccountConfig[tokenField] @@ -618,6 +683,7 @@ module.exports = { isValidChannelAccountId, normalizeChannelProvider, deriveChannelEnvKey, + deriveChannelExtraEnvKeys, getConfiguredChannelEnvKeys, assertActiveChannelTokenEnvVars, normalizeChannelConfig, diff --git a/lib/server/routes/system.js b/lib/server/routes/system.js index f97eb8a0..9f6699a9 100644 --- a/lib/server/routes/system.js +++ b/lib/server/routes/system.js @@ -25,7 +25,8 @@ const registerSystemRoutes = ({ authProfiles, }) => { let envRestartPending = false; - const kManagedChannelTokenPattern = /^(TELEGRAM|DISCORD)_BOT_TOKEN(?:_[A-Z0-9_]+)?$/; + const kManagedChannelTokenPattern = + /^(?:TELEGRAM_BOT_TOKEN|DISCORD_BOT_TOKEN|SLACK_BOT_TOKEN|SLACK_APP_TOKEN)(?:_[A-Z0-9_]+)?$/; const kEnvVarsReservedForUserInput = new Set([ "GITHUB_WORKSPACE_REPO", "GOG_KEYRING_PASSWORD", @@ -311,7 +312,13 @@ const registerSystemRoutes = ({ } for (const v of fileVars) { - if (kKnownKeys.has(v.key) || isReservedUserEnvVar(v.key)) continue; + if ( + kKnownKeys.has(v.key) || + isReservedUserEnvVar(v.key) || + isManagedChannelTokenKey(v.key) + ) { + continue; + } merged.push({ key: v.key, value: v.value, diff --git a/tests/server/agents-service.test.js b/tests/server/agents-service.test.js index 5135b855..6264339c 100644 --- a/tests/server/agents-service.test.js +++ b/tests/server/agents-service.test.js @@ -1019,6 +1019,108 @@ describe("server/agents/service", () => { ); }); + it("creates a slack channel account with bot and app tokens", async () => { + const fsMock = buildFsMock({ + initialConfig: { + agents: { + list: [{ id: "main", default: true }], + }, + }, + }); + const readEnvFile = vi.fn(() => [ + { key: "OPENAI_API_KEY", value: "sk-test" }, + ]); + const writeEnvFile = vi.fn(); + const reloadEnv = vi.fn(); + const clawCmd = vi.fn(async () => ({ ok: true, stdout: "", stderr: "" })); + const service = createAgentsService({ + fs: fsMock, + OPENCLAW_DIR: "/tmp/openclaw", + readEnvFile, + writeEnvFile, + reloadEnv, + clawCmd, + }); + + const result = await service.createChannelAccount({ + provider: "slack", + name: "Slack", + accountId: "default", + token: "xoxb-bot-token", + appToken: "xapp-app-token", + agentId: "main", + }); + + expect(result).toEqual({ + channel: "slack", + account: { + id: "default", + name: "Slack", + envKey: "SLACK_BOT_TOKEN", + }, + binding: { + agentId: "main", + match: { channel: "slack", accountId: "default" }, + }, + }); + expect(writeEnvFile).toHaveBeenCalledWith([ + { key: "OPENAI_API_KEY", value: "sk-test" }, + { key: "SLACK_BOT_TOKEN", value: "xoxb-bot-token" }, + { key: "SLACK_APP_TOKEN", value: "xapp-app-token" }, + ]); + expect(clawCmd).toHaveBeenNthCalledWith( + 1, + "channels add --channel 'slack' --name 'Slack' --bot-token 'xoxb-bot-token' --app-token 'xapp-app-token'", + { quiet: true, timeoutMs: 30000 }, + ); + expect(fsMock.readConfig()).toEqual( + expect.objectContaining({ + channels: { + slack: { + enabled: true, + defaultAccount: "default", + accounts: { + default: { + name: "Slack", + botToken: "${SLACK_BOT_TOKEN}", + appToken: "${SLACK_APP_TOKEN}", + dmPolicy: "pairing", + }, + }, + }, + }, + }), + ); + }); + + it("requires app token when creating a slack channel account", async () => { + const fsMock = buildFsMock({ + initialConfig: { + agents: { + list: [{ id: "main", default: true }], + }, + }, + }); + const service = createAgentsService({ + fs: fsMock, + OPENCLAW_DIR: "/tmp/openclaw", + readEnvFile: vi.fn(() => []), + writeEnvFile: vi.fn(), + reloadEnv: vi.fn(), + clawCmd: vi.fn(async () => ({ ok: true, stdout: "", stderr: "" })), + }); + + await expect( + service.createChannelAccount({ + provider: "slack", + name: "Slack", + accountId: "default", + token: "xoxb-bot-token", + agentId: "main", + }), + ).rejects.toThrow("Slack App Token is required"); + }); + it("rejects concurrent channel account creation requests", async () => { const fsMock = buildFsMock({ initialConfig: { @@ -1162,6 +1264,51 @@ describe("server/agents/service", () => { ).rejects.toThrow("Discord supports a single channel account"); }); + it("prevents creating multiple slack channel accounts", async () => { + const fsMock = buildFsMock({ + initialConfig: { + agents: { + list: [{ id: "main", default: true }], + }, + channels: { + slack: { + enabled: true, + defaultAccount: "default", + accounts: { + default: { + botToken: "${SLACK_BOT_TOKEN}", + appToken: "${SLACK_APP_TOKEN}", + dmPolicy: "pairing", + }, + }, + }, + }, + }, + }); + const service = createAgentsService({ + fs: fsMock, + OPENCLAW_DIR: "/tmp/openclaw", + readEnvFile: vi.fn(() => [ + { key: "SLACK_BOT_TOKEN", value: "xoxb-bot-token" }, + { key: "SLACK_APP_TOKEN", value: "xapp-app-token" }, + ]), + writeEnvFile: vi.fn(), + reloadEnv: vi.fn(), + clawCmd: vi.fn(async () => ({ ok: true, stdout: "", stderr: "" })), + }); + + await expect( + service.createChannelAccount({ + provider: "slack", + name: "Slack 2", + accountId: "alerts", + token: "xoxb-bot-token-2", + appToken: "xapp-app-token-2", + agentId: "main", + }), + ).rejects.toThrow("Slack supports a single channel account"); + }); + it("updates channel account name and bound agent", () => { const fsMock = buildFsMock({ initialConfig: { @@ -1308,6 +1455,63 @@ describe("server/agents/service", () => { expect(result.tokenUpdated).toBe(true); }); + it("updates slack app token when provided", () => { + const fsMock = buildFsMock({ + initialConfig: { + agents: { + list: [{ id: "main", default: true }], + }, + channels: { + slack: { + enabled: true, + defaultAccount: "default", + accounts: { + default: { + botToken: "${SLACK_BOT_TOKEN}", + appToken: "${SLACK_APP_TOKEN}", + name: "Slack", + }, + }, + }, + }, + bindings: [ + { + agentId: "main", + match: { channel: "slack", accountId: "default" }, + }, + ], + }, + }); + const readEnvFile = vi.fn(() => [ + { key: "SLACK_BOT_TOKEN", value: "xoxb-old" }, + { key: "SLACK_APP_TOKEN", value: "xapp-old" }, + ]); + const writeEnvFile = vi.fn(); + const reloadEnv = vi.fn(); + const service = createAgentsService({ + fs: fsMock, + OPENCLAW_DIR: "/tmp/openclaw", + readEnvFile, + writeEnvFile, + reloadEnv, + }); + + const result = service.updateChannelAccount({ + provider: "slack", + accountId: "default", + name: "Slack", + agentId: "main", + appToken: "xapp-new", + }); + + expect(writeEnvFile).toHaveBeenCalledWith([ + { key: "SLACK_BOT_TOKEN", value: "xoxb-old" }, + { key: "SLACK_APP_TOKEN", value: "xapp-new" }, + ]); + expect(reloadEnv).toHaveBeenCalled(); + expect(result.tokenUpdated).toBe(true); + }); + it("does not rewrite env when updated token is unchanged", () => { const fsMock = buildFsMock({ initialConfig: { @@ -1439,6 +1643,51 @@ describe("server/agents/service", () => { }); }); + it("loads slack channel bot and app tokens by provider/account id", () => { + const fsMock = buildFsMock({ + initialConfig: { + agents: { + list: [{ id: "main", default: true }], + }, + channels: { + slack: { + enabled: true, + defaultAccount: "default", + accounts: { + default: { + botToken: "${SLACK_BOT_TOKEN}", + appToken: "${SLACK_APP_TOKEN}", + name: "Slack", + }, + }, + }, + }, + }, + }); + const service = createAgentsService({ + fs: fsMock, + OPENCLAW_DIR: "/tmp/openclaw", + readEnvFile: vi.fn(() => [ + { key: "SLACK_BOT_TOKEN", value: "xoxb-token-123" }, + { key: "SLACK_APP_TOKEN", value: "xapp-token-123" }, + ]), + }); + + const result = service.getChannelAccountToken({ + provider: "slack", + accountId: "default", + }); + + expect(result).toEqual({ + provider: "slack", + accountId: "default", + envKey: "SLACK_BOT_TOKEN", + token: "xoxb-token-123", + appEnvKey: "SLACK_APP_TOKEN", + appToken: "xapp-token-123", + }); + }); + it("deletes channel accounts and removes their env entry", async () => { const fsMock = buildFsMock({ initialConfig: { @@ -1653,6 +1902,59 @@ describe("server/agents/service", () => { ); }); + it("deletes slack channel env vars including app token", async () => { + const fsMock = buildFsMock({ + initialConfig: { + agents: { + list: [{ id: "main", default: true }], + }, + channels: { + slack: { + enabled: true, + defaultAccount: "default", + accounts: { + default: { + botToken: "${SLACK_BOT_TOKEN}", + appToken: "${SLACK_APP_TOKEN}", + name: "Slack", + }, + }, + }, + }, + bindings: [ + { + agentId: "main", + match: { channel: "slack", accountId: "default" }, + }, + ], + }, + }); + const readEnvFile = vi.fn(() => [ + { key: "SLACK_BOT_TOKEN", value: "xoxb-token" }, + { key: "SLACK_APP_TOKEN", value: "xapp-token" }, + ]); + const writeEnvFile = vi.fn(); + const reloadEnv = vi.fn(); + const clawCmd = vi.fn(async () => ({ ok: true, stdout: "", stderr: "" })); + const service = createAgentsService({ + fs: fsMock, + OPENCLAW_DIR: "/tmp/openclaw", + readEnvFile, + writeEnvFile, + reloadEnv, + clawCmd, + }); + + const result = await service.deleteChannelAccount({ + provider: "slack", + accountId: "default", + }); + + expect(result).toEqual({ ok: true }); + expect(writeEnvFile).toHaveBeenCalledWith([]); + expect(reloadEnv).toHaveBeenCalled(); + }); + it("overwrites orphaned env var when channel is not in config", async () => { const fsMock = buildFsMock({ initialConfig: { diff --git a/tests/server/routes-agents.test.js b/tests/server/routes-agents.test.js index d4f1c50e..d67dc2bf 100644 --- a/tests/server/routes-agents.test.js +++ b/tests/server/routes-agents.test.js @@ -260,6 +260,28 @@ describe("server/routes/agents", () => { }); }); + it("returns slack app token fields on GET /api/channels/accounts/token", async () => { + const agentsService = createAgentsServiceMock(); + agentsService.getChannelAccountToken.mockReturnValueOnce({ + provider: "slack", + accountId: "default", + envKey: "SLACK_BOT_TOKEN", + token: "xoxb-token", + appEnvKey: "SLACK_APP_TOKEN", + appToken: "xapp-token", + }); + const app = createApp(agentsService); + + const response = await request(app).get( + "/api/channels/accounts/token?provider=slack&accountId=default", + ); + + expect(response.status).toBe(200); + expect(response.body.ok).toBe(true); + expect(response.body.token).toBe("xoxb-token"); + expect(response.body.appToken).toBe("xapp-token"); + }); + it("deletes a configured channel account on DELETE /api/channels/accounts", async () => { const agentsService = createAgentsServiceMock(); const app = createApp(agentsService); diff --git a/tests/server/routes-system.test.js b/tests/server/routes-system.test.js index 78c31257..9bda6432 100644 --- a/tests/server/routes-system.test.js +++ b/tests/server/routes-system.test.js @@ -222,6 +222,34 @@ describe("server/routes/system", () => { ]); }); + it("hides and preserves managed slack channel tokens on /api/env", async () => { + const deps = createSystemDeps(); + deps.readEnvFile.mockReturnValue([ + { key: "SLACK_BOT_TOKEN", value: "xoxb-hidden" }, + { key: "SLACK_APP_TOKEN", value: "xapp-hidden" }, + ]); + const app = createApp(deps); + + const getRes = await request(app).get("/api/env"); + expect(getRes.status).toBe(200); + expect(getRes.body.vars.some((entry) => entry.key === "SLACK_BOT_TOKEN")).toBe( + false, + ); + expect(getRes.body.vars.some((entry) => entry.key === "SLACK_APP_TOKEN")).toBe( + false, + ); + + const putRes = await request(app).put("/api/env").send({ + vars: [{ key: "OPENAI_API_KEY", value: "same" }], + }); + expect(putRes.status).toBe(200); + expect(deps.writeEnvFile).toHaveBeenCalledWith([ + { key: "OPENAI_API_KEY", value: "same" }, + { key: "SLACK_BOT_TOKEN", value: "xoxb-hidden" }, + { key: "SLACK_APP_TOKEN", value: "xapp-hidden" }, + ]); + }); + it("syncs API-key auth profiles from known env vars on save", async () => { const deps = createSystemDeps(); const app = createApp(deps);