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`
+
+ App Token (Socket Mode)
+ <${SecretInput}
+ value=${appToken}
+ onInput=${(event) => setAppToken(event.target.value)}
+ placeholder="xapp-..."
+ isSecret=${true}
+ inputClass="w-full bg-black/30 border border-border rounded-lg px-3 py-2 text-sm font-mono text-gray-200 outline-none focus:border-gray-500"
+ />
+
+ From Basic Information → App-Level Tokens. Needs
+ connections:write scope.
+
+
+ `
+ : null}
+
Agent
{
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/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/shared.js b/lib/server/agents/shared.js
index 85a279f9..86b131ee 100644
--- a/lib/server/agents/shared.js
+++ b/lib/server/agents/shared.js
@@ -13,14 +13,17 @@ 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 kChannelTokenFields = {
telegram: "botToken",
discord: "token",
+ slack: "botToken",
};
const kChannelLabels = {
telegram: "Telegram",
discord: "Discord",
+ slack: "Slack",
};
const kMaskedChannelToken = "********";
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/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..5135b855 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",
+ },
+ ],
+ },
]);
});
From 0b619d20bfb37970ec2198f8dcd948b1223272a3 Mon Sep 17 00:00:00 2001
From: Chrys Bader
Date: Thu, 12 Mar 2026 17:41:52 -0700
Subject: [PATCH 2/2] fix: complete Slack channel follow-ups and unify add-menu
behavior
Close Slack channel setup gaps by persisting app tokens in edit/manage flows, hiding managed Slack tokens from Envars, and enforcing single-account rules consistently in UI and server logic. Align channel add UX across views by reusing one add-menu codepath and tighten setup guidance copy.
---
AGENTS.md | 1 +
lib/public/assets/icons/slack.svg | 28 +-
lib/public/js/components/add-channel-menu.js | 59 ++++
.../agent-bindings-section/index.js | 52 +--
.../use-channel-items.js | 6 -
.../agents-tab/create-channel-modal.js | 238 ++++++++++----
lib/public/js/components/channels.js | 56 +---
lib/public/js/components/envars.js | 7 +-
.../js/lib/channel-provider-availability.js | 23 ++
lib/server/agents/channels.js | 116 ++++++-
lib/server/agents/shared.js | 68 +++-
lib/server/routes/system.js | 11 +-
tests/server/agents-service.test.js | 302 ++++++++++++++++++
tests/server/routes-agents.test.js | 22 ++
tests/server/routes-system.test.js | 28 ++
15 files changed, 839 insertions(+), 178 deletions(-)
create mode 100644 lib/public/js/components/add-channel-menu.js
create mode 100644 lib/public/js/lib/channel-provider-availability.js
diff --git a/AGENTS.md b/AGENTS.md
index d2121e9b..a025d6d7 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -132,6 +132,7 @@ Use these conventions for all UI work under `lib/public/js` and `lib/public/css`
- Use the `htm` + `preact` pattern:
- `const html = htm.bind(h);`
- return `html\`...\``
+- In `htm` templates, be explicit with inline spacing around styled inline tags (``, ``, ``): use ` ${" "}` where needed, and verify rendered copy so words never collapse (`eventsand`) or gain double spaces.
- Prefer early return for hidden states (e.g. `if (!visible) return null;`).
- Use ` ` for tab/page headers that need a title and right-side actions.
- Use card shells consistently: `bg-surface border border-border rounded-xl`.
diff --git a/lib/public/assets/icons/slack.svg b/lib/public/assets/icons/slack.svg
index 1f4c5e0c..79fc764e 100644
--- a/lib/public/assets/icons/slack.svg
+++ b/lib/public/assets/icons/slack.svg
@@ -1,13 +1,17 @@
-
- Slack
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/lib/public/js/components/add-channel-menu.js b/lib/public/js/components/add-channel-menu.js
new file mode 100644
index 00000000..44115dcd
--- /dev/null
+++ b/lib/public/js/components/add-channel-menu.js
@@ -0,0 +1,59 @@
+import { h } from "https://esm.sh/preact";
+import htm from "https://esm.sh/htm";
+import { ActionButton } from "./action-button.js";
+import { AddLineIcon } from "./icons.js";
+import { OverflowMenu, OverflowMenuItem } from "./overflow-menu.js";
+
+const html = htm.bind(h);
+
+export const AddChannelMenu = ({
+ open = false,
+ onClose = () => {},
+ onToggle = () => {},
+ triggerDisabled = false,
+ channelIds = [],
+ getChannelMeta = () => ({ label: "Channel", iconSrc: "" }),
+ isChannelDisabled = () => false,
+ onSelectChannel = () => {},
+}) => html`
+ <${OverflowMenu}
+ open=${open}
+ ariaLabel="Add channel"
+ title="Add channel"
+ onClose=${onClose}
+ onToggle=${onToggle}
+ renderTrigger=${({ onToggle: handleToggle, ariaLabel, title }) => html`
+ <${ActionButton}
+ onClick=${handleToggle}
+ disabled=${triggerDisabled}
+ 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}
+ />
+ `}
+ >
+ ${channelIds.map((channelId) => {
+ const channelMeta = getChannelMeta(channelId);
+ const disabled = !!isChannelDisabled(channelId);
+ return html`
+ <${OverflowMenuItem}
+ key=${channelId}
+ iconSrc=${channelMeta.iconSrc}
+ disabled=${disabled}
+ onClick=${() => onSelectChannel(channelId)}
+ >
+ ${channelMeta.label}
+ ${OverflowMenuItem}>
+ `;
+ })}
+ ${OverflowMenu}>
+`;
+
diff --git a/lib/public/js/components/agents-tab/agent-bindings-section/index.js b/lib/public/js/components/agents-tab/agent-bindings-section/index.js
index d028bce5..525dc216 100644
--- a/lib/public/js/components/agents-tab/agent-bindings-section/index.js
+++ b/lib/public/js/components/agents-tab/agent-bindings-section/index.js
@@ -1,10 +1,11 @@
import { h } from "https://esm.sh/preact";
import htm from "https://esm.sh/htm";
+import { isChannelProviderDisabledForAdd } from "../../../lib/channel-provider-availability.js";
+import { AddChannelMenu } from "../../add-channel-menu.js";
import { ActionButton } from "../../action-button.js";
import { ALL_CHANNELS, ChannelsCard, getChannelMeta } from "../../channels.js";
import { ConfirmDialog } from "../../confirm-dialog.js";
import { AddLineIcon } from "../../icons.js";
-import { OverflowMenu, OverflowMenuItem } from "../../overflow-menu.js";
import { CreateChannelModal } from "../create-channel-modal.js";
import { ChannelCardItem } from "./channel-item-trailing.js";
import { useAgentBindings } from "./use-agent-bindings.js";
@@ -50,7 +51,7 @@ export const AgentBindingsSection = ({
setShowCreateModal,
showCreateModal,
} = useAgentBindings({ agent, agents });
- const { hasDiscordAccount, mergedChannelItems } = useChannelItems({
+ const { mergedChannelItems } = useChannelItems({
agentId,
agentNameMap,
channelStatus,
@@ -108,48 +109,23 @@ export const AgentBindingsSection = ({
/>`;
}}
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}
- loading=${false}
- loadingMode="inline"
- tone="subtle"
- size="sm"
- loadingLabel="Opening..."
- idleIcon=${AddLineIcon}
- idleIconClassName="h-3.5 w-3.5"
- iconOnly=${true}
- title=${title}
- ariaLabel=${ariaLabel}
- idleLabel="Add channel"
- />
- `}
- >
- ${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}
+ channelIds=${ALL_CHANNELS}
+ getChannelMeta=${getChannelMeta}
+ isChannelDisabled=${(channelId) =>
+ isChannelProviderDisabledForAdd({
+ configuredChannelMap,
+ provider: channelId,
+ })}
+ onSelectChannel=${openCreateChannelModal}
+ />
`}
/>
diff --git a/lib/public/js/components/agents-tab/agent-bindings-section/use-channel-items.js b/lib/public/js/components/agents-tab/agent-bindings-section/use-channel-items.js
index dc87a54c..799ae5aa 100644
--- a/lib/public/js/components/agents-tab/agent-bindings-section/use-channel-items.js
+++ b/lib/public/js/components/agents-tab/agent-bindings-section/use-channel-items.js
@@ -20,11 +20,6 @@ export const useChannelItems = ({
defaultAgentId = "",
isDefaultAgent = false,
}) => {
- const hasDiscordAccount = useMemo(() => {
- const discordChannel = configuredChannelMap.get("discord");
- return Array.isArray(discordChannel?.accounts) && discordChannel.accounts.length > 0;
- }, [configuredChannelMap]);
-
const [showAssignedElsewhere, setShowAssignedElsewhere] = useState(false);
const channelItemData = useMemo(() => {
@@ -205,7 +200,6 @@ export const useChannelItems = ({
}, [assignedElsewhereItems, showAssignedElsewhere, visibleChannelItems]);
return {
- hasDiscordAccount,
mergedChannelItems,
};
};
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 410400ae..fd689787 100644
--- a/lib/public/js/components/agents-tab/create-channel-modal.js
+++ b/lib/public/js/components/agents-tab/create-channel-modal.js
@@ -20,6 +20,21 @@ const kChannelEnvKeys = {
const kChannelExtraEnvKeys = {
slack: "SLACK_APP_TOKEN",
};
+const kSlackBotScopes = [
+ "app_mentions:read",
+ "channels:history",
+ "channels:read",
+ "chat:write",
+ "groups:history",
+ "im:history",
+ "im:read",
+ "im:write",
+ "mpim:history",
+ "reactions:read",
+ "reactions:write",
+ "users:read",
+];
+const kSlackInstructionsLink = "https://docs.openclaw.ai/channels/slack";
const slugifyChannelAccountId = (value) =>
String(value || "")
@@ -72,22 +87,23 @@ export const CreateChannelModal = ({
const nextSelectedChannel =
existingChannels.find(
(entry) =>
- String(entry?.channel || "").trim() === String(nextProvider || "").trim(),
+ String(entry?.channel || "").trim() ===
+ String(nextProvider || "").trim(),
) || null;
const nextProviderHasAccounts =
- Array.isArray(nextSelectedChannel?.accounts)
- && nextSelectedChannel.accounts.length > 0;
+ Array.isArray(nextSelectedChannel?.accounts) &&
+ nextSelectedChannel.accounts.length > 0;
const nextName = isEditMode
? String(account?.name || "").trim() || providerLabel
: nextProviderHasAccounts
? ""
: providerLabel;
const nextAgentId = isEditMode
- ? String(account?.ownerAgentId || "").trim()
- || String(initialAgentId || "").trim()
- || String(agents[0]?.id || "").trim()
- : String(initialAgentId || "").trim()
- || String(agents[0]?.id || "").trim();
+ ? String(account?.ownerAgentId || "").trim() ||
+ String(initialAgentId || "").trim() ||
+ String(agents[0]?.id || "").trim()
+ : String(initialAgentId || "").trim() ||
+ String(agents[0]?.id || "").trim();
setProvider(nextProvider);
setName(nextName);
const nextToken = isEditMode
@@ -115,13 +131,16 @@ export const CreateChannelModal = ({
const selectedChannel = useMemo(
() =>
existingChannels.find(
- (entry) => String(entry?.channel || "").trim() === String(provider || "").trim(),
+ (entry) =>
+ String(entry?.channel || "").trim() === String(provider || "").trim(),
) || null,
[existingChannels, provider],
);
const providerHasAccounts = useMemo(
- () => Array.isArray(selectedChannel?.accounts) && selectedChannel.accounts.length > 0,
+ () =>
+ Array.isArray(selectedChannel?.accounts) &&
+ selectedChannel.accounts.length > 0,
[selectedChannel],
);
useEffect(() => {
@@ -133,7 +152,9 @@ export const CreateChannelModal = ({
}
setName(providerLabel);
}, [provider, providerHasAccounts, nameEditedManually, isEditMode]);
- const isSingleAccountProvider = String(provider || "").trim() === "discord" || String(provider || "").trim() === "slack";
+ const isSingleAccountProvider =
+ String(provider || "").trim() === "discord" ||
+ String(provider || "").trim() === "slack";
const needsAppToken = String(provider || "").trim() === "slack";
const accountId = useMemo(() => {
@@ -152,9 +173,10 @@ export const CreateChannelModal = ({
const accountExists = useMemo(
() =>
- Array.isArray(selectedChannel?.accounts)
- && selectedChannel.accounts.some(
- (entry) => String(entry?.id || "").trim() === String(accountId || "").trim(),
+ Array.isArray(selectedChannel?.accounts) &&
+ selectedChannel.accounts.some(
+ (entry) =>
+ String(entry?.id || "").trim() === String(accountId || "").trim(),
),
[selectedChannel, accountId],
);
@@ -170,8 +192,10 @@ export const CreateChannelModal = ({
});
if (cancelled) return;
const nextToken = String(result?.token || "");
+ const nextAppToken = String(result?.appToken || "");
setToken(nextToken);
setInitialToken(nextToken);
+ setAppToken(nextAppToken);
} catch {
// Keep existing fallback value.
} finally {
@@ -187,14 +211,14 @@ export const CreateChannelModal = ({
}, [visible, isEditMode, provider, accountId]);
const canSubmit =
- !!String(provider || "").trim()
- && !!String(name || "").trim()
- && !!String(accountId || "").trim()
- && !!String(agentId || "").trim()
- && (isEditMode || !!String(token || "").trim())
- && (isEditMode || !needsAppToken || !!String(appToken || "").trim())
- && (isEditMode || !accountExists)
- && !loadingToken;
+ !!String(provider || "").trim() &&
+ !!String(name || "").trim() &&
+ !!String(accountId || "").trim() &&
+ !!String(agentId || "").trim() &&
+ (isEditMode || !!String(token || "").trim()) &&
+ (isEditMode || !needsAppToken || !!String(appToken || "").trim()) &&
+ (isEditMode || !accountExists) &&
+ !loadingToken;
if (!visible) return null;
@@ -226,7 +250,8 @@ export const CreateChannelModal = ({
setError("");
const trimmedToken = String(token || "").trim();
- const tokenWasUpdated = trimmedToken && trimmedToken !== String(initialToken || "").trim();
+ const tokenWasUpdated =
+ trimmedToken && trimmedToken !== String(initialToken || "").trim();
const trimmedAppToken = String(appToken || "").trim();
await onSubmit({
provider,
@@ -234,7 +259,9 @@ export const CreateChannelModal = ({
accountId,
agentId,
...(tokenWasUpdated ? { token: trimmedToken } : {}),
- ...(needsAppToken && trimmedAppToken ? { appToken: trimmedAppToken } : {}),
+ ...(needsAppToken && trimmedAppToken
+ ? { appToken: trimmedAppToken }
+ : {}),
});
};
@@ -245,9 +272,11 @@ export const CreateChannelModal = ({
panelClassName="bg-modal border border-border rounded-xl p-6 max-w-lg w-full space-y-4"
>
<${PageHeader}
- title=${isEditMode
- ? "Edit Channel"
- : `Add ${getChannelMeta(provider).label || "Channel"} Channel`}
+ title=${
+ isEditMode
+ ? "Edit Channel"
+ : `Add ${getChannelMeta(provider).label || "Channel"} Channel`
+ }
actions=${html`
- ${isEditMode
- ? "Channel id is fixed after creation."
- : isSingleAccountProvider
- ? `${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."}
+ ${
+ isEditMode
+ ? "Channel id is fixed after creation."
+ : isSingleAccountProvider
+ ? `${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."
+ }
- Token
+
+ ${needsAppToken ? "Bot Token" : "Token"}
+
<${SecretInput}
value=${token}
onInput=${(event) => setToken(event.target.value)}
@@ -310,24 +343,91 @@ export const CreateChannelModal = ({
- ${needsAppToken
- ? html`
-
- App Token (Socket Mode)
- <${SecretInput}
- value=${appToken}
- onInput=${(event) => setAppToken(event.target.value)}
- placeholder="xapp-..."
- isSecret=${true}
- inputClass="w-full bg-black/30 border border-border rounded-lg px-3 py-2 text-sm font-mono text-gray-200 outline-none focus:border-gray-500"
- />
-
- From Basic Information → App-Level Tokens. Needs
- connections:write scope.
-
-
- `
- : null}
+ ${
+ needsAppToken
+ ? html`
+
+ App Token (Socket Mode)
+ <${SecretInput}
+ value=${appToken}
+ onInput=${(event) => setAppToken(event.target.value)}
+ placeholder="xapp-..."
+ isSecret=${true}
+ inputClass="w-full bg-black/30 border border-border rounded-lg px-3 py-2 text-sm font-mono text-gray-200 outline-none focus:border-gray-500"
+ />
+
+ Saved behind the scenes as
+
+ ${kChannelExtraEnvKeys.slack}
+
+ .
+
+
+ `
+ : null
+ }
+ ${
+ needsAppToken
+ ? html`
+
+
+ Slack-specific instructions (step-by-step)
+
+
+
+
+ In Slack app settings, turn on
+ ${" "}
+ Socket Mode .
+
+
+ In
+ ${" "}
+ App Home , enable
+
+ Allow users to send Slash commands and messages from the messages tab
+ .
+
+
+ In
+ ${" "}
+ Event Subscriptions , toggle on
+ Subscribe to bot events
+ ${" "}
+ and add
+ message.im.
+
+
+ Create a Bot Token (xoxb-...)
+ with scopes:
+
+ ${kSlackBotScopes.join(", ")}
+
+
+
+ Create an App Token (xapp-...)
+ with
+ connections:write.
+
+
+ Reinstall the app after changing scopes.
+
+
+
+ Open full Slack setup guide
+
+
+
+ `
+ : null
+ }
Agent
@@ -336,23 +436,27 @@ export const CreateChannelModal = ({
onInput=${(event) => setAgentId(event.target.value)}
class="w-full bg-black/30 border border-border rounded-lg px-3 py-2 text-sm text-gray-200 outline-none focus:border-gray-500"
>
- ${agents.map((agent) => html`
-
- ${agent.name || agent.id}
-
- `)}
+ ${agents.map(
+ (agent) => html`
+
+ ${agent.name || agent.id}
+
+ `,
+ )}
- ${!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}
- ${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/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);