From 6dbbc58a8d005cc858dca6578fc2b589c1120cb0 Mon Sep 17 00:00:00 2001 From: Glucksberg <80581902+Glucksberg@users.noreply.github.com> Date: Sun, 1 Mar 2026 13:05:35 -0400 Subject: [PATCH] fix(slack): use SLACK_USER_TOKEN when connecting to Slack (#28103) * fix(slack): use SLACK_USER_TOKEN when connecting to Slack (closes #26480) * test(slack): fix account fixture typing for user token source --------- Co-authored-by: Tak Hoffman <781889+Takhoffman@users.noreply.github.com> --- CHANGELOG.md | 1 + src/agents/tools/slack-actions.ts | 2 +- src/auto-reply/reply/commands-allowlist.ts | 2 +- src/channels/plugins/onboarding/slack.ts | 2 +- src/commands/channels/capabilities.test.ts | 1 + src/commands/channels/capabilities.ts | 4 +--- src/infra/outbound/outbound-session.ts | 4 +--- src/slack/accounts.ts | 10 +++++++++- src/slack/directory-live.ts | 3 +-- src/slack/monitor/message-handler/prepare.test.ts | 5 +++++ src/slack/monitor/provider.ts | 2 +- src/slack/token.ts | 4 ++++ 12 files changed, 27 insertions(+), 13 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 31dd563f3..d7b01d53b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -26,6 +26,7 @@ Docs: https://docs.openclaw.ai - CLI/Cron: clarify `cron list` output by renaming `Agent` to `Agent ID` and adding a `Model` column for isolated agent-turn jobs. (#26259) - Cron/Delivery: disable the agent messaging tool when `delivery.mode` is `"none"` so cron output is not sent to Telegram or other channels. (#21808) - Feishu/Reply media attachments: send Feishu reply `mediaUrl`/`mediaUrls` payloads as attachments alongside text/streamed replies in the reply dispatcher, including legacy fallback when `mediaUrls` is empty. (#28959) +- Slack/User-token resolution: normalize Slack account user-token sourcing through resolved account metadata (`SLACK_USER_TOKEN` env + config) so monitor reads, Slack actions, directory lookups, onboarding allow-from resolution, and capabilities probing consistently use the effective user token. (#28103) Thanks @Glucksberg. - Feishu/Reaction notifications: add `channels.feishu.reactionNotifications` (`off | own | all`, default `own`) so operators can disable reaction ingress or allow all verified reaction events (not only bot-authored message reactions). (#28529) - Feishu/Outbound session routing: stop assuming bare `oc_` identifiers are always group chats, honor explicit `dm:`/`group:` prefixes for `oc_` chat IDs, and default ambiguous bare `oc_` targets to direct routing to avoid DM session misclassification. (#10407) Thanks @Bermudarat. - Feishu/Group session routing: add configurable group session scopes (`group`, `group_sender`, `group_topic`, `group_topic_sender`) with legacy `topicSessionMode=enabled` compatibility so Feishu group conversations can isolate sessions by sender/topic as configured. (#17798) diff --git a/src/agents/tools/slack-actions.ts b/src/agents/tools/slack-actions.ts index 7eaa2dbfa..54adea00a 100644 --- a/src/agents/tools/slack-actions.ts +++ b/src/agents/tools/slack-actions.ts @@ -108,7 +108,7 @@ export async function handleSlackAction( const account = resolveSlackAccount({ cfg, accountId }); const actionConfig = account.actions ?? cfg.channels?.slack?.actions; const isActionEnabled = createActionGate(actionConfig); - const userToken = account.config.userToken?.trim() || undefined; + const userToken = account.userToken; const botToken = account.botToken?.trim(); const allowUserWrites = account.config.userTokenReadOnly === false; diff --git a/src/auto-reply/reply/commands-allowlist.ts b/src/auto-reply/reply/commands-allowlist.ts index 079e03437..e4b9b7af5 100644 --- a/src/auto-reply/reply/commands-allowlist.ts +++ b/src/auto-reply/reply/commands-allowlist.ts @@ -327,7 +327,7 @@ async function resolveSlackNames(params: { entries: string[]; }) { const account = resolveSlackAccount({ cfg: params.cfg, accountId: params.accountId }); - const token = account.config.userToken?.trim() || account.botToken?.trim(); + const token = account.userToken || account.botToken?.trim(); if (!token) { return new Map(); } diff --git a/src/channels/plugins/onboarding/slack.ts b/src/channels/plugins/onboarding/slack.ts index cd892bc0a..d5427348d 100644 --- a/src/channels/plugins/onboarding/slack.ts +++ b/src/channels/plugins/onboarding/slack.ts @@ -157,7 +157,7 @@ async function promptSlackAllowFrom(params: { defaultAccountId: resolveDefaultSlackAccountId(params.cfg), }); const resolved = resolveSlackAccount({ cfg: params.cfg, accountId }); - const token = resolved.config.userToken ?? resolved.config.botToken ?? ""; + const token = resolved.userToken ?? resolved.botToken ?? ""; const existing = params.cfg.channels?.slack?.allowFrom ?? params.cfg.channels?.slack?.dm?.allowFrom ?? []; const parseId = (value: string) => diff --git a/src/commands/channels/capabilities.test.ts b/src/commands/channels/capabilities.test.ts index ba3353ea5..b85cd750a 100644 --- a/src/commands/channels/capabilities.test.ts +++ b/src/commands/channels/capabilities.test.ts @@ -90,6 +90,7 @@ describe("channelsCapabilitiesCommand", () => { account: { accountId: "default", botToken: "xoxb-bot", + userToken: "xoxp-user", config: { userToken: "xoxp-user" }, }, probe: { ok: true, bot: { name: "openclaw" }, team: { name: "team" } }, diff --git a/src/commands/channels/capabilities.ts b/src/commands/channels/capabilities.ts index f311b70e4..37c682448 100644 --- a/src/commands/channels/capabilities.ts +++ b/src/commands/channels/capabilities.ts @@ -381,9 +381,7 @@ async function resolveChannelReports(params: { let slackScopes: ChannelCapabilitiesReport["slackScopes"]; if (plugin.id === "slack" && configured && enabled) { const botToken = (resolvedAccount as { botToken?: string }).botToken?.trim(); - const userToken = ( - resolvedAccount as { config?: { userToken?: string } } - ).config?.userToken?.trim(); + const userToken = (resolvedAccount as { userToken?: string }).userToken?.trim(); const scopeReports: NonNullable = []; if (botToken) { scopeReports.push({ diff --git a/src/infra/outbound/outbound-session.ts b/src/infra/outbound/outbound-session.ts index fa2727f9c..1d8140f2e 100644 --- a/src/infra/outbound/outbound-session.ts +++ b/src/infra/outbound/outbound-session.ts @@ -161,9 +161,7 @@ async function resolveSlackChannelType(params: { return "channel"; } - const token = - account.botToken?.trim() || - (typeof account.config.userToken === "string" ? account.config.userToken.trim() : ""); + const token = account.botToken?.trim() || account.userToken || ""; if (!token) { SLACK_CHANNEL_TYPE_CACHE.set(`${account.accountId}:${channelId}`, "unknown"); return "unknown"; diff --git a/src/slack/accounts.ts b/src/slack/accounts.ts index 65c49cfaa..5958e3376 100644 --- a/src/slack/accounts.ts +++ b/src/slack/accounts.ts @@ -4,7 +4,7 @@ import type { OpenClawConfig } from "../config/config.js"; import type { SlackAccountConfig } from "../config/types.js"; import { resolveAccountEntry } from "../routing/account-lookup.js"; import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "../routing/session-key.js"; -import { resolveSlackAppToken, resolveSlackBotToken } from "./token.js"; +import { resolveSlackAppToken, resolveSlackBotToken, resolveSlackUserToken } from "./token.js"; export type SlackTokenSource = "env" | "config" | "none"; @@ -14,8 +14,10 @@ export type ResolvedSlackAccount = { name?: string; botToken?: string; appToken?: string; + userToken?: string; botTokenSource: SlackTokenSource; appTokenSource: SlackTokenSource; + userTokenSource: SlackTokenSource; config: SlackAccountConfig; groupPolicy?: SlackAccountConfig["groupPolicy"]; textChunkLimit?: SlackAccountConfig["textChunkLimit"]; @@ -61,12 +63,16 @@ export function resolveSlackAccount(params: { const allowEnv = accountId === DEFAULT_ACCOUNT_ID; const envBot = allowEnv ? resolveSlackBotToken(process.env.SLACK_BOT_TOKEN) : undefined; const envApp = allowEnv ? resolveSlackAppToken(process.env.SLACK_APP_TOKEN) : undefined; + const envUser = allowEnv ? resolveSlackUserToken(process.env.SLACK_USER_TOKEN) : undefined; const configBot = resolveSlackBotToken(merged.botToken); const configApp = resolveSlackAppToken(merged.appToken); + const configUser = resolveSlackUserToken(merged.userToken); const botToken = configBot ?? envBot; const appToken = configApp ?? envApp; + const userToken = configUser ?? envUser; const botTokenSource: SlackTokenSource = configBot ? "config" : envBot ? "env" : "none"; const appTokenSource: SlackTokenSource = configApp ? "config" : envApp ? "env" : "none"; + const userTokenSource: SlackTokenSource = configUser ? "config" : envUser ? "env" : "none"; return { accountId, @@ -74,8 +80,10 @@ export function resolveSlackAccount(params: { name: merged.name?.trim() || undefined, botToken, appToken, + userToken, botTokenSource, appTokenSource, + userTokenSource, config: merged, groupPolicy: merged.groupPolicy, textChunkLimit: merged.textChunkLimit, diff --git a/src/slack/directory-live.ts b/src/slack/directory-live.ts index 05387ee2e..bb105bae5 100644 --- a/src/slack/directory-live.ts +++ b/src/slack/directory-live.ts @@ -36,8 +36,7 @@ type SlackListChannelsResponse = { function resolveReadToken(params: DirectoryConfigParams): string | undefined { const account = resolveSlackAccount({ cfg: params.cfg, accountId: params.accountId }); - const userToken = account.config.userToken?.trim() || undefined; - return userToken ?? account.botToken?.trim(); + return account.userToken ?? account.botToken?.trim(); } function normalizeQuery(value?: string | null): string { diff --git a/src/slack/monitor/message-handler/prepare.test.ts b/src/slack/monitor/message-handler/prepare.test.ts index 286792b27..f40e00631 100644 --- a/src/slack/monitor/message-handler/prepare.test.ts +++ b/src/slack/monitor/message-handler/prepare.test.ts @@ -101,6 +101,7 @@ describe("slack prepareSlackMessage inbound contract", () => { enabled: true, botTokenSource: "config", appTokenSource: "config", + userTokenSource: "none", config: {}, }; @@ -119,6 +120,7 @@ describe("slack prepareSlackMessage inbound contract", () => { enabled: true, botTokenSource: "config", appTokenSource: "config", + userTokenSource: "none", config, replyToMode: config.replyToMode, replyToModeByChatType: config.replyToModeByChatType, @@ -165,6 +167,7 @@ describe("slack prepareSlackMessage inbound contract", () => { enabled: true, botTokenSource: "config", appTokenSource: "config", + userTokenSource: "none", config: { replyToMode: "all", thread: { initialHistoryLimit: 20 }, @@ -378,6 +381,7 @@ describe("slack prepareSlackMessage inbound contract", () => { enabled: true, botTokenSource: "config", appTokenSource: "config", + userTokenSource: "none", config: {}, }; @@ -461,6 +465,7 @@ describe("slack prepareSlackMessage inbound contract", () => { enabled: true, botTokenSource: "config", appTokenSource: "config", + userTokenSource: "none", config: {}, }; diff --git a/src/slack/monitor/provider.ts b/src/slack/monitor/provider.ts index 4263ecb34..28debf859 100644 --- a/src/slack/monitor/provider.ts +++ b/src/slack/monitor/provider.ts @@ -225,7 +225,7 @@ export async function monitorSlackProvider(opts: MonitorSlackOpts = {}) { log: (message) => runtime.log?.(warn(message)), }); - const resolveToken = slackCfg.userToken?.trim() || botToken; + const resolveToken = account.userToken || botToken; const useAccessGroups = cfg.commands?.useAccessGroups !== false; const reactionMode = slackCfg.reactionNotifications ?? "own"; const reactionAllowlist = slackCfg.reactionAllowlist ?? []; diff --git a/src/slack/token.ts b/src/slack/token.ts index 2fbf215df..29d3cbb9d 100644 --- a/src/slack/token.ts +++ b/src/slack/token.ts @@ -10,3 +10,7 @@ export function resolveSlackBotToken(raw?: string): string | undefined { export function resolveSlackAppToken(raw?: string): string | undefined { return normalizeSlackToken(raw); } + +export function resolveSlackUserToken(raw?: string): string | undefined { + return normalizeSlackToken(raw); +}