+ Allow users to send Slash commands and messages from the messages tab
+ .
+ Subscribe to bot events
+ ${" "}
+ and add
+ message.im.
+ xoxb-...)
+ with scopes:
+
+ ${kSlackBotScopes.join(", ")}
+
+ xapp-...)
+ with
+ connections:write.
+ - ${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 339a1700..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,15 +20,17 @@ 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"; const html = htm.bind(h); -const ALL_CHANNELS = ["telegram", "discord"]; +const ALL_CHANNELS = ["telegram", "discord", "slack"]; const kChannelMeta = { telegram: { label: "Telegram", iconSrc: "/assets/icons/telegram.svg" }, discord: { label: "Discord", iconSrc: "/assets/icons/discord.svg" }, + slack: { label: "Slack", iconSrc: "/assets/icons/slack.svg" }, }; const getChannelMeta = (channelId = "") => { @@ -271,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 []; @@ -477,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} - ${OverflowMenuItem}> - `; - })} - ${OverflowMenu}> + 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/components/onboarding/pairing-utils.js b/lib/public/js/components/onboarding/pairing-utils.js index b27e6574..02f594a4 100644 --- a/lib/public/js/components/onboarding/pairing-utils.js +++ b/lib/public/js/components/onboarding/pairing-utils.js @@ -1,6 +1,7 @@ export const getPreferredPairingChannel = (vals = {}) => { if (vals.TELEGRAM_BOT_TOKEN) return "telegram"; if (vals.DISCORD_BOT_TOKEN) return "discord"; + if (vals.SLACK_BOT_TOKEN && vals.SLACK_APP_TOKEN) return "slack"; return ""; }; diff --git a/lib/public/js/components/onboarding/welcome-config.js b/lib/public/js/components/onboarding/welcome-config.js index f23b95c4..28470a0b 100644 --- a/lib/public/js/components/onboarding/welcome-config.js +++ b/lib/public/js/components/onboarding/welcome-config.js @@ -121,8 +121,38 @@ export const kWelcomeGroups = [ >`, placeholder: "MTQ3...", }, + { + key: "SLACK_BOT_TOKEN", + label: "Slack Bot Token", + hint: html`From your Slack app's${" "}OAuth & Permissions${" "}page${" "}·${" "}full guide`, + placeholder: "xoxb-...", + }, + { + key: "SLACK_APP_TOKEN", + label: "Slack App Token (Socket Mode)", + hint: html`From${" "}Basic Information${" "}→ App-Level Tokens (needs${" "}connections:write${" "}scope)`,
+ placeholder: "xapp-...",
+ },
],
- validate: (vals) => !!(vals.TELEGRAM_BOT_TOKEN || vals.DISCORD_BOT_TOKEN),
+ validate: (vals) => !!(vals.TELEGRAM_BOT_TOKEN || vals.DISCORD_BOT_TOKEN || (vals.SLACK_BOT_TOKEN && vals.SLACK_APP_TOKEN)),
},
{
id: "tools",
diff --git a/lib/public/js/components/pairings.js b/lib/public/js/components/pairings.js
index aa112a88..f865525a 100644
--- a/lib/public/js/components/pairings.js
+++ b/lib/public/js/components/pairings.js
@@ -62,7 +62,7 @@ export const PairingRow = ({ p, onApprove, onReject }) => {
`;
};
-const ALL_CHANNELS = ['telegram', 'discord'];
+const ALL_CHANNELS = ['telegram', 'discord', 'slack'];
const capitalize = (s) => s.charAt(0).toUpperCase() + s.slice(1);
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.js b/lib/server.js
index d5907ed5..e65fc12f 100644
--- a/lib/server.js
+++ b/lib/server.js
@@ -103,6 +103,7 @@ const {
const { installGogCliSkill } = require("./server/gog-skill");
const { createTelegramApi } = require("./server/telegram-api");
const { createDiscordApi } = require("./server/discord-api");
+const { createSlackApi } = require("./server/slack-api");
const { createWatchdogNotifier } = require("./server/watchdog-notify");
const { createWatchdog } = require("./server/watchdog");
const { createDoctorService } = require("./server/doctor/service");
@@ -299,7 +300,8 @@ const gmailWatchService = registerGmailRoutes({
});
const telegramApi = createTelegramApi(() => process.env.TELEGRAM_BOT_TOKEN);
const discordApi = createDiscordApi(() => process.env.DISCORD_BOT_TOKEN);
-const watchdogNotifier = createWatchdogNotifier({ telegramApi, discordApi });
+const slackApi = createSlackApi(() => process.env.SLACK_BOT_TOKEN);
+const watchdogNotifier = createWatchdogNotifier({ telegramApi, discordApi, slackApi });
const watchdog = createWatchdog({
clawCmd,
launchGatewayProcess,
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 85a279f9..0c80d1e4 100644
--- a/lib/server/agents/shared.js
+++ b/lib/server/agents/shared.js
@@ -13,14 +13,23 @@ const kDefaultAgentFiles = ["SOUL.md", "AGENTS.md", "USER.md", "IDENTITY.md"];
const kChannelEnvKeys = {
telegram: "TELEGRAM_BOT_TOKEN",
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",
+ slack: "Slack",
};
const kMaskedChannelToken = "********";
@@ -152,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 =
@@ -164,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;
@@ -208,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}`,
+ );
+ }
+ }
}
}
};
@@ -223,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,
)) {
@@ -239,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;
}
}
@@ -266,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]
@@ -615,6 +683,7 @@ module.exports = {
isValidChannelAccountId,
normalizeChannelProvider,
deriveChannelEnvKey,
+ deriveChannelExtraEnvKeys,
getConfiguredChannelEnvKeys,
assertActiveChannelTokenEnvVars,
normalizeChannelConfig,
diff --git a/lib/server/constants.js b/lib/server/constants.js
index 4219deea..352484af 100644
--- a/lib/server/constants.js
+++ b/lib/server/constants.js
@@ -235,6 +235,18 @@ const kKnownVars = [
group: "channels",
hint: "From Discord Developer Portal",
},
+ {
+ key: "SLACK_BOT_TOKEN",
+ label: "Slack Bot Token",
+ group: "channels",
+ hint: "From your Slack app's OAuth & Permissions page (xoxb-...)",
+ },
+ {
+ key: "SLACK_APP_TOKEN",
+ label: "Slack App Token",
+ group: "channels",
+ hint: "From Basic Information → App-Level Tokens (xapp-...)",
+ },
{
key: "MISTRAL_API_KEY",
label: "Mistral API Key",
@@ -336,6 +348,7 @@ const API_TEST_COMMANDS = {
const kChannelDefs = {
telegram: { envKey: "TELEGRAM_BOT_TOKEN" },
discord: { envKey: "DISCORD_BOT_TOKEN" },
+ slack: { envKey: "SLACK_BOT_TOKEN", extraEnvKeys: ["SLACK_APP_TOKEN"] },
};
const kProtectedBrowsePaths = new Set(
Array.isArray(kBrowseFilePolicies?.protectedPaths)
diff --git a/lib/server/gateway.js b/lib/server/gateway.js
index 0ea3b27c..f1965e79 100644
--- a/lib/server/gateway.js
+++ b/lib/server/gateway.js
@@ -302,17 +302,34 @@ const syncChannelConfig = (savedVars, mode = "all") => {
if (token && !isConfigured && (mode === "add" || mode === "all")) {
console.log(`[alphaclaw] Adding channel: ${ch}`);
try {
- execSync(`openclaw channels add --channel ${ch} --token "${token}"`, {
- env,
- timeout: 15000,
- encoding: "utf8",
- });
- const raw = fs.readFileSync(configPath, "utf8");
- if (raw.includes(token)) {
- fs.writeFileSync(
- configPath,
- raw.split(token).join("${" + def.envKey + "}"),
+ if (ch === "slack") {
+ const appToken = savedMap[def.extraEnvKeys?.[0]];
+ if (!appToken) continue;
+ execSync(
+ `openclaw channels add --channel slack --bot-token "${token}" --app-token "${appToken}"`,
+ { env, timeout: 15000, encoding: "utf8" },
);
+ let raw = fs.readFileSync(configPath, "utf8");
+ if (raw.includes(token)) {
+ raw = raw.split(token).join("${" + def.envKey + "}");
+ }
+ if (raw.includes(appToken)) {
+ raw = raw.split(appToken).join("${" + def.extraEnvKeys[0] + "}");
+ }
+ fs.writeFileSync(configPath, raw);
+ } else {
+ execSync(`openclaw channels add --channel ${ch} --token "${token}"`, {
+ env,
+ timeout: 15000,
+ encoding: "utf8",
+ });
+ const raw = fs.readFileSync(configPath, "utf8");
+ if (raw.includes(token)) {
+ fs.writeFileSync(
+ configPath,
+ raw.split(token).join("${" + def.envKey + "}"),
+ );
+ }
}
console.log(`[alphaclaw] Channel ${ch} added`);
} catch (e) {
@@ -353,7 +370,7 @@ const getChannelStatus = () => {
const credDir = `${OPENCLAW_DIR}/credentials`;
const channels = {};
- for (const ch of ["telegram", "discord"]) {
+ for (const ch of Object.keys(kChannelDefs)) {
const channelConfig =
config.channels?.[ch] && typeof config.channels[ch] === "object"
? config.channels[ch]
diff --git a/lib/server/onboarding/openclaw.js b/lib/server/onboarding/openclaw.js
index d05eefcb..cec84c13 100644
--- a/lib/server/onboarding/openclaw.js
+++ b/lib/server/onboarding/openclaw.js
@@ -198,6 +198,19 @@ const applyFreshOnboardingChannels = ({ cfg, varMap }) => {
ensurePluginAllowed(cfg, "discord");
console.log("[onboard] Discord configured");
}
+ if (varMap.SLACK_BOT_TOKEN && varMap.SLACK_APP_TOKEN) {
+ cfg.channels.slack = {
+ enabled: true,
+ botToken: varMap.SLACK_BOT_TOKEN,
+ appToken: varMap.SLACK_APP_TOKEN,
+ mode: "socket",
+ dmPolicy: "pairing",
+ groupPolicy: "open",
+ };
+ cfg.plugins.entries.slack = { enabled: true };
+ ensurePluginAllowed(cfg, "slack");
+ console.log("[onboard] Slack configured");
+ }
if (!cfg.plugins.load.paths.includes(kUsageTrackerPluginPath)) {
cfg.plugins.load.paths.push(kUsageTrackerPluginPath);
}
@@ -274,6 +287,23 @@ const writeManagedImportOpenclawConfig = ({ fs, openclawDir, varMap }) => {
ensurePluginAllowed(cfg, "discord");
}
+ if (varMap.SLACK_BOT_TOKEN && varMap.SLACK_APP_TOKEN) {
+ cfg.channels.slack = {
+ ...(cfg.channels.slack || {}),
+ enabled: true,
+ botToken: "${SLACK_BOT_TOKEN}",
+ appToken: "${SLACK_APP_TOKEN}",
+ mode: cfg.channels.slack?.mode || "socket",
+ dmPolicy: getSafeImportedDmPolicy(cfg.channels.slack),
+ groupPolicy: cfg.channels.slack?.groupPolicy || "open",
+ };
+ cfg.plugins.entries.slack = {
+ ...(cfg.plugins.entries.slack || {}),
+ enabled: true,
+ };
+ ensurePluginAllowed(cfg, "slack");
+ }
+
fs.writeFileSync(configPath, JSON.stringify(cfg, null, 2));
};
diff --git a/lib/server/onboarding/validation.js b/lib/server/onboarding/validation.js
index 1c046e12..9b1ccbd8 100644
--- a/lib/server/onboarding/validation.js
+++ b/lib/server/onboarding/validation.js
@@ -89,7 +89,7 @@ const validateOnboardingInput = ({ vars, modelKey, resolveModelProvider, hasCode
? hasAiByProvider[selectedProvider]
: hasAnyAi;
const hasGithub = !!(githubToken && githubRepoInput);
- const hasChannel = !!(varMap.TELEGRAM_BOT_TOKEN || varMap.DISCORD_BOT_TOKEN);
+ const hasChannel = !!(varMap.TELEGRAM_BOT_TOKEN || varMap.DISCORD_BOT_TOKEN || (varMap.SLACK_BOT_TOKEN && varMap.SLACK_APP_TOKEN));
if (!hasAi) {
if (selectedProvider === "openai-codex") {
diff --git a/lib/server/routes/pairings.js b/lib/server/routes/pairings.js
index e2189606..035789f8 100644
--- a/lib/server/routes/pairings.js
+++ b/lib/server/routes/pairings.js
@@ -5,7 +5,7 @@ const { buildManagedPaths } = require("../internal-files-migration");
const { parseJsonObjectFromNoisyOutput } = require("../utils/json");
const { quoteShellArg } = require("../utils/shell");
-const kAllowedPairingChannels = new Set(["telegram", "discord"]);
+const kAllowedPairingChannels = new Set(["telegram", "discord", "slack"]);
const kSafePairingArgPattern = /^[\w\-:.]+$/;
const quoteCliArg = (value) => quoteShellArg(value, { strategy: "single" });
@@ -111,7 +111,7 @@ const registerPairingRoutes = ({ app, clawCmd, isOnboarded, fsModule = fs, openc
}
const pending = [];
- const channels = ["telegram", "discord"];
+ const channels = ["telegram", "discord", "slack"];
for (const ch of channels) {
try {
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/lib/server/slack-api.js b/lib/server/slack-api.js
new file mode 100644
index 00000000..509cc01d
--- /dev/null
+++ b/lib/server/slack-api.js
@@ -0,0 +1,38 @@
+const kSlackApiBase = "https://slack.com/api";
+
+const createSlackApi = (getToken) => {
+ const call = async (method, body = {}) => {
+ const token = typeof getToken === "function" ? getToken() : getToken;
+ if (!token) throw new Error("SLACK_BOT_TOKEN is not set");
+ const res = await fetch(`${kSlackApiBase}/${method}`, {
+ method: "POST",
+ headers: {
+ Authorization: `Bearer ${token}`,
+ "Content-Type": "application/json",
+ },
+ body: JSON.stringify(body),
+ });
+ if (!res.ok) {
+ throw new Error(`Slack API ${method}: HTTP ${res.status}`);
+ }
+ const data = await res.json();
+ if (!data.ok) {
+ const err = new Error(data.error || `Slack API error: ${method}`);
+ err.slackError = data.error;
+ throw err;
+ }
+ return data;
+ };
+
+ const authTest = () => call("auth.test");
+
+ const postMessage = (channel, text) =>
+ call("chat.postMessage", { channel, text: String(text || "") });
+
+ return {
+ authTest,
+ postMessage,
+ };
+};
+
+module.exports = { createSlackApi };
diff --git a/lib/server/watchdog-notify.js b/lib/server/watchdog-notify.js
index b1ddb6b6..8cc1128f 100644
--- a/lib/server/watchdog-notify.js
+++ b/lib/server/watchdog-notify.js
@@ -36,11 +36,12 @@ const getPairedIds = (channel) => {
const formatDiscordMessage = (message) =>
String(message || "").replace(/(? {
+const createWatchdogNotifier = ({ telegramApi, discordApi, slackApi }) => {
const notify = async (message) => {
const summary = {
telegram: { sent: 0, failed: 0, skipped: false, targets: 0 },
discord: { sent: 0, failed: 0, skipped: false, targets: 0 },
+ slack: { sent: 0, failed: 0, skipped: false, targets: 0 },
};
const telegramTargets = getPairedIds("telegram");
summary.telegram.targets = telegramTargets.length;
@@ -77,8 +78,24 @@ const createWatchdogNotifier = ({ telegramApi, discordApi }) => {
}
}
- const sent = summary.telegram.sent + summary.discord.sent;
- const failed = summary.telegram.failed + summary.discord.failed;
+ const slackTargets = getPairedIds("slack");
+ summary.slack.targets = slackTargets.length;
+ if (!slackApi?.postMessage || !process.env.SLACK_BOT_TOKEN || slackTargets.length === 0) {
+ summary.slack.skipped = true;
+ } else {
+ for (const userId of slackTargets) {
+ try {
+ await slackApi.postMessage(userId, String(message || ""));
+ summary.slack.sent += 1;
+ } catch (err) {
+ summary.slack.failed += 1;
+ console.error(`[watchdog] slack notification failed for ${userId}: ${err.message}`);
+ }
+ }
+ }
+
+ const sent = summary.telegram.sent + summary.discord.sent + summary.slack.sent;
+ const failed = summary.telegram.failed + summary.discord.failed + summary.slack.failed;
return {
ok: sent > 0,
sent,
diff --git a/tests/server/agents-service.test.js b/tests/server/agents-service.test.js
index 75c8e308..6264339c 100644
--- a/tests/server/agents-service.test.js
+++ b/tests/server/agents-service.test.js
@@ -377,6 +377,20 @@ describe("server/agents/service", () => {
},
],
},
+ {
+ channel: "slack",
+ accounts: [
+ {
+ id: "default",
+ name: "",
+ envKey: "SLACK_BOT_TOKEN",
+ token: "",
+ boundAgentId: "",
+ paired: 0,
+ status: "configured",
+ },
+ ],
+ },
]);
});
@@ -1005,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: {
@@ -1148,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: {
@@ -1294,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: {
@@ -1425,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: {
@@ -1639,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);