diff --git a/CHANGELOG.md b/CHANGELOG.md index 9ed563487..29171751d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,7 @@ ## 2026.1.15 (unreleased) +- Slack: add optional user token routing for read access with safe write fallback. (#981) — thanks @jalehman. - Fix: list model picker entries as provider/model pairs for explicit selection. (#970) — thanks @mcinteerj. - Daemon: fix profile-aware service label resolution (env-driven) and add coverage for launchd/systemd/schtasks. (#969) — thanks @bjesuiter. - Daemon: share profile/state-dir resolution across service helpers and honor `CLAWDBOT_STATE_DIR` for Windows task scripts. diff --git a/Peekaboo b/Peekaboo index c1243a797..95ad7532c 160000 --- a/Peekaboo +++ b/Peekaboo @@ -1 +1 @@ -Subproject commit c1243a7978b71137060a82aa4a451e1720e36aff +Subproject commit 95ad7532c15b6b4e67cdf2c8c5dea399c8483fe1 diff --git a/docs/channels/slack.md b/docs/channels/slack.md index aabe7ac6b..efbcb69df 100644 --- a/docs/channels/slack.md +++ b/docs/channels/slack.md @@ -33,6 +33,7 @@ Minimal config: - `app_mention` - `reaction_added`, `reaction_removed` - `member_joined_channel`, `member_left_channel` + - `channel_id_changed` - `channel_rename` - `pin_added`, `pin_removed` 6) Invite the bot to channels you want it to read. @@ -112,6 +113,21 @@ Example with userTokenReadOnly explicitly set (allow user token writes): ## History context - `channels.slack.historyLimit` (or `channels.slack.accounts.*.historyLimit`) controls how many recent channel/group messages are wrapped into the prompt. - Falls back to `messages.groupChat.historyLimit`. Set `0` to disable (default 50). +- DM history can be limited with `channels.slack.dmHistoryLimit` (user turns). Per-user overrides: `channels.slack.dms[""].historyLimit`. + +## Config writes +By default, Slack is allowed to write config updates triggered by channel events or `/config set|unset`. + +This happens when: +- Slack emits `channel_id_changed` (e.g. Slack Connect channel ID changes). Clawdbot can migrate `channels.slack.channels` automatically. +- You run `/config set` or `/config unset` in Slack (requires `commands.config: true`). + +Disable with: +```json5 +{ + channels: { slack: { configWrites: false } } +} +``` ## Manifest (optional) Use this Slack app manifest to create the app quickly (adjust the name/command if you want). Include the @@ -196,6 +212,7 @@ user scopes if you plan to configure a user token. "reaction_removed", "member_joined_channel", "member_left_channel", + "channel_id_changed", "channel_rename", "pin_added", "pin_removed" @@ -326,6 +343,11 @@ By default, Clawdbot replies in the main channel. Use `channels.slack.replyToMod The mode applies to both auto-replies and agent tool calls (`slack sendMessage`). +### Thread session isolation +Slack thread sessions are isolated by default. Configure with: +- `channels.slack.thread.historyScope`: `thread` (default) keeps per-thread history; `channel` shares history across the channel. +- `channels.slack.thread.inheritParent`: `false` (default) starts a clean thread session; `true` copies the parent channel transcript into the thread session. + ### Manual threading tags For fine-grained control, use these tags in agent responses: - `[[reply_to_current]]` — reply to the triggering message (start/continue thread). @@ -390,4 +412,5 @@ Slack tool actions can be gated with `channels.slack.actions.*`: - Bot-authored messages are ignored by default; enable via `channels.slack.allowBots` or `channels.slack.channels..allowBots`. - Warning: If you allow replies to other bots (`channels.slack.allowBots=true` or `channels.slack.channels..allowBots=true`), prevent bot-to-bot reply loops with `requireMention`, `channels.slack.channels..users` allowlists, and/or clear guardrails in `AGENTS.md` and `SOUL.md`. - For the Slack tool, reaction removal semantics are in [/tools/reactions](/tools/reactions). +- Read/pin tool payloads include normalized `timestampMs` (UTC epoch ms) and `timestampUtc` alongside raw Slack `ts`. - Attachments are downloaded to the media store when permitted and under the size limit. diff --git a/docs/gateway/configuration.md b/docs/gateway/configuration.md index 65988c1a3..acfcd3540 100644 --- a/docs/gateway/configuration.md +++ b/docs/gateway/configuration.md @@ -1052,6 +1052,8 @@ Slack runs in Socket Mode and requires both a bot token and app token: enabled: true, botToken: "xoxb-...", appToken: "xapp-...", + userToken: "xoxp-...", // optional (read-only by default) + userTokenReadOnly: true, // default: true dm: { enabled: true, policy: "pairing", // pairing | allowlist | open | disabled @@ -1102,6 +1104,7 @@ Slack runs in Socket Mode and requires both a bot token and app token: Multi-account support lives under `channels.slack.accounts` (see the multi-account section above). Env tokens only apply to the default account. Clawdbot starts Slack when the provider is enabled and both tokens are set (via config or `SLACK_BOT_TOKEN` + `SLACK_APP_TOKEN`). Use `user:` (DM) or `channel:` when specifying delivery targets for cron/CLI commands. +Optional user tokens (`xoxp-...`) are configured in the config file (no env var support). Reads prefer the user token when present; writes use the bot token unless you set `channels.slack.userTokenReadOnly: false` and no bot token is available. Set `channels.slack.configWrites: false` to block Slack-initiated config writes (including channel ID migrations and `/config set|unset`). Bot-authored messages are ignored by default. Enable with `channels.slack.allowBots` or `channels.slack.channels..allowBots`. diff --git a/src/agents/tools/slack-actions.test.ts b/src/agents/tools/slack-actions.test.ts index 9e2959a07..b8fcb842e 100644 --- a/src/agents/tools/slack-actions.test.ts +++ b/src/agents/tools/slack-actions.test.ts @@ -373,6 +373,28 @@ describe("handleSlackAction", () => { expect(opts?.token).toBe("xoxp-1"); }); + it("uses user token for pin reads when available", async () => { + const cfg = { + channels: { slack: { botToken: "xoxb-1", userToken: "xoxp-1" } }, + } as ClawdbotConfig; + listSlackPins.mockClear(); + listSlackPins.mockResolvedValueOnce([]); + await handleSlackAction({ action: "listPins", channelId: "C1" }, cfg); + const [, opts] = listSlackPins.mock.calls[0] ?? []; + expect(opts?.token).toBe("xoxp-1"); + }); + + it("uses user token for member info reads when available", async () => { + const cfg = { + channels: { slack: { botToken: "xoxb-1", userToken: "xoxp-1" } }, + } as ClawdbotConfig; + getSlackMemberInfo.mockClear(); + getSlackMemberInfo.mockResolvedValueOnce({}); + await handleSlackAction({ action: "memberInfo", userId: "U1" }, cfg); + const [, opts] = getSlackMemberInfo.mock.calls[0] ?? []; + expect(opts?.token).toBe("xoxp-1"); + }); + it("falls back to bot token for reads when user token missing", async () => { const cfg = { channels: { slack: { botToken: "xoxb-1" } }, @@ -405,4 +427,16 @@ describe("handleSlackAction", () => { const [, , opts] = sendSlackMessage.mock.calls[0] ?? []; expect(opts?.token).toBe("xoxp-1"); }); + + it("prefers bot token for writes when both tokens are available", async () => { + const cfg = { + channels: { + slack: { botToken: "xoxb-1", userToken: "xoxp-1", userTokenReadOnly: false }, + }, + } as ClawdbotConfig; + sendSlackMessage.mockClear(); + await handleSlackAction({ action: "sendMessage", to: "channel:C1", content: "Hello" }, cfg); + const [, , opts] = sendSlackMessage.mock.calls[0] ?? []; + expect(opts?.token).toBeUndefined(); + }); }); diff --git a/src/agents/tools/slack-actions.ts b/src/agents/tools/slack-actions.ts index 84659cd8f..809968045 100644 --- a/src/agents/tools/slack-actions.ts +++ b/src/agents/tools/slack-actions.ts @@ -2,6 +2,7 @@ import type { AgentToolResult } from "@mariozechner/pi-agent-core"; import type { ClawdbotConfig } from "../../config/config.js"; import { resolveSlackAccount } from "../../slack/accounts.js"; +import { resolveSlackTokenOverride } from "../../slack/token.js"; import { deleteSlackMessage, editSlackMessage, @@ -81,20 +82,14 @@ 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 botToken = account.botToken?.trim(); - const allowUserWrites = account.config.userTokenReadOnly === false; - - // Choose the most appropriate token for Slack read/write operations. - const getTokenForOperation = (operation: "read" | "write") => { - if (operation === "read") return userToken ?? botToken; - if (!allowUserWrites) return botToken; - return botToken ?? userToken; - }; const buildActionOpts = (operation: "read" | "write") => { - const token = getTokenForOperation(operation); - const tokenOverride = token && token !== botToken ? token : undefined; + const tokenOverride = resolveSlackTokenOverride({ + botToken: account.botToken, + userToken: account.userToken, + userTokenReadOnly: account.config.userTokenReadOnly, + operation, + }); if (!accountId && !tokenOverride) return undefined; return { ...(accountId ? { accountId } : {}), @@ -260,7 +255,7 @@ export async function handleSlackAction( } return jsonResult({ ok: true }); } - const pins = writeOpts + const pins = readOpts ? await listSlackPins(channelId, readOpts) : await listSlackPins(channelId); const normalizedPins = pins.map((pin) => { @@ -280,7 +275,7 @@ export async function handleSlackAction( throw new Error("Slack member info is disabled."); } const userId = readStringParam(params, "userId", { required: true }); - const info = writeOpts + const info = readOpts ? await getSlackMemberInfo(userId, readOpts) : await getSlackMemberInfo(userId); return jsonResult({ ok: true, info }); diff --git a/src/channels/plugins/outbound/slack.ts b/src/channels/plugins/outbound/slack.ts index 48bfc58c4..2f40976a7 100644 --- a/src/channels/plugins/outbound/slack.ts +++ b/src/channels/plugins/outbound/slack.ts @@ -1,4 +1,6 @@ +import { resolveSlackAccount } from "../../../slack/accounts.js"; import { sendMessageSlack } from "../../../slack/send.js"; +import { resolveSlackTokenOverride } from "../../../slack/token.js"; import type { ChannelOutboundAdapter } from "../types.js"; export const slackOutbound: ChannelOutboundAdapter = { @@ -15,20 +17,36 @@ export const slackOutbound: ChannelOutboundAdapter = { } return { ok: true, to: trimmed }; }, - sendText: async ({ to, text, accountId, deps, replyToId }) => { + sendText: async ({ cfg, to, text, accountId, deps, replyToId }) => { const send = deps?.sendSlack ?? sendMessageSlack; + const account = resolveSlackAccount({ cfg, accountId }); + const tokenOverride = resolveSlackTokenOverride({ + botToken: account.botToken, + userToken: account.userToken, + userTokenReadOnly: account.config.userTokenReadOnly, + operation: "write", + }); const result = await send(to, text, { threadTs: replyToId ?? undefined, accountId: accountId ?? undefined, + ...(tokenOverride ? { token: tokenOverride } : {}), }); return { channel: "slack", ...result }; }, - sendMedia: async ({ to, text, mediaUrl, accountId, deps, replyToId }) => { + sendMedia: async ({ cfg, to, text, mediaUrl, accountId, deps, replyToId }) => { const send = deps?.sendSlack ?? sendMessageSlack; + const account = resolveSlackAccount({ cfg, accountId }); + const tokenOverride = resolveSlackTokenOverride({ + botToken: account.botToken, + userToken: account.userToken, + userTokenReadOnly: account.config.userTokenReadOnly, + operation: "write", + }); const result = await send(to, text, { mediaUrl, threadTs: replyToId ?? undefined, accountId: accountId ?? undefined, + ...(tokenOverride ? { token: tokenOverride } : {}), }); return { channel: "slack", ...result }; }, diff --git a/src/channels/plugins/slack.ts b/src/channels/plugins/slack.ts index f55964327..42dc9581b 100644 --- a/src/channels/plugins/slack.ts +++ b/src/channels/plugins/slack.ts @@ -1,9 +1,5 @@ -import { createActionGate, readNumberParam, readStringParam } from "../../agents/tools/common.js"; -import { handleSlackAction } from "../../agents/tools/slack-actions.js"; -import { loadConfig } from "../../config/config.js"; import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "../../routing/session-key.js"; import { - listEnabledSlackAccounts, listSlackAccountIds, type ResolvedSlackAccount, resolveDefaultSlackAccountId, @@ -11,6 +7,7 @@ import { } from "../../slack/accounts.js"; import { probeSlack } from "../../slack/probe.js"; import { sendMessageSlack } from "../../slack/send.js"; +import { resolveSlackTokenOverride } from "../../slack/token.js"; import { getChatChannelMeta } from "../registry.js"; import { deleteAccountFromConfigSection, @@ -25,23 +22,11 @@ import { applyAccountNameToChannelSection, migrateBaseNameToDefaultAccount, } from "./setup-helpers.js"; -import type { ChannelMessageActionName, ChannelPlugin } from "./types.js"; +import { createSlackActions } from "./slack.actions.js"; +import type { ChannelPlugin } from "./types.js"; const meta = getChatChannelMeta("slack"); -// Select the appropriate Slack token for read/write operations. -function getTokenForOperation( - account: ResolvedSlackAccount, - operation: "read" | "write", -): string | undefined { - const userToken = account.config.userToken?.trim() || undefined; - const botToken = account.botToken?.trim(); - const allowUserWrites = account.config.userTokenReadOnly === false; - if (operation === "read") return userToken ?? botToken; - if (!allowUserWrites) return botToken; - return botToken ?? userToken; -} - export const slackPlugin: ChannelPlugin = { id: "slack", meta: { @@ -51,22 +36,19 @@ export const slackPlugin: ChannelPlugin = { pairing: { idLabel: "slackUserId", normalizeAllowEntry: (entry) => entry.replace(/^(slack|user):/i, ""), - notifyApproval: async ({ id }) => { - const cfg = loadConfig(); - const account = resolveSlackAccount({ - cfg, - accountId: DEFAULT_ACCOUNT_ID, + notifyApproval: async ({ cfg, id }) => { + const account = resolveSlackAccount({ cfg, accountId: DEFAULT_ACCOUNT_ID }); + const tokenOverride = resolveSlackTokenOverride({ + botToken: account.botToken, + userToken: account.userToken, + userTokenReadOnly: account.config.userTokenReadOnly, + operation: "write", }); - const token = getTokenForOperation(account, "write"); - const botToken = account.botToken?.trim(); - const tokenOverride = token && token !== botToken ? token : undefined; - if (tokenOverride) { - await sendMessageSlack(`user:${id}`, PAIRING_APPROVED_MESSAGE, { - token: tokenOverride, - }); - } else { - await sendMessageSlack(`user:${id}`, PAIRING_APPROVED_MESSAGE); - } + await sendMessageSlack( + `user:${id}`, + PAIRING_APPROVED_MESSAGE, + tokenOverride ? { token: tokenOverride } : undefined, + ); }, }, capabilities: { @@ -169,197 +151,7 @@ export const slackPlugin: ChannelPlugin = { messaging: { normalizeTarget: normalizeSlackMessagingTarget, }, - actions: { - listActions: ({ cfg }) => { - const accounts = listEnabledSlackAccounts(cfg).filter( - (account) => account.botTokenSource !== "none", - ); - if (accounts.length === 0) return []; - const isActionEnabled = (key: string, defaultValue = true) => { - for (const account of accounts) { - const gate = createActionGate( - (account.actions ?? cfg.channels?.slack?.actions) as Record< - string, - boolean | undefined - >, - ); - if (gate(key, defaultValue)) return true; - } - return false; - }; - - const actions = new Set(["send"]); - if (isActionEnabled("reactions")) { - actions.add("react"); - actions.add("reactions"); - } - if (isActionEnabled("messages")) { - actions.add("read"); - actions.add("edit"); - actions.add("delete"); - } - if (isActionEnabled("pins")) { - actions.add("pin"); - actions.add("unpin"); - actions.add("list-pins"); - } - if (isActionEnabled("memberInfo")) actions.add("member-info"); - if (isActionEnabled("emojiList")) actions.add("emoji-list"); - return Array.from(actions); - }, - extractToolSend: ({ args }) => { - const action = typeof args.action === "string" ? args.action.trim() : ""; - if (action !== "sendMessage") return null; - const to = typeof args.to === "string" ? args.to : undefined; - if (!to) return null; - const accountId = typeof args.accountId === "string" ? args.accountId.trim() : undefined; - return { to, accountId }; - }, - handleAction: async ({ action, params, cfg, accountId, toolContext }) => { - const resolveChannelId = () => - readStringParam(params, "channelId") ?? readStringParam(params, "to", { required: true }); - - if (action === "send") { - const to = readStringParam(params, "to", { required: true }); - const content = readStringParam(params, "message", { - required: true, - allowEmpty: true, - }); - const mediaUrl = readStringParam(params, "media", { trim: false }); - const threadId = readStringParam(params, "threadId"); - const replyTo = readStringParam(params, "replyTo"); - return await handleSlackAction( - { - action: "sendMessage", - to, - content, - mediaUrl: mediaUrl ?? undefined, - accountId: accountId ?? undefined, - threadTs: threadId ?? replyTo ?? undefined, - }, - cfg, - toolContext, - ); - } - - if (action === "react") { - const messageId = readStringParam(params, "messageId", { - required: true, - }); - const emoji = readStringParam(params, "emoji", { allowEmpty: true }); - const remove = typeof params.remove === "boolean" ? params.remove : undefined; - return await handleSlackAction( - { - action: "react", - channelId: resolveChannelId(), - messageId, - emoji, - remove, - accountId: accountId ?? undefined, - }, - cfg, - ); - } - - if (action === "reactions") { - const messageId = readStringParam(params, "messageId", { - required: true, - }); - const limit = readNumberParam(params, "limit", { integer: true }); - return await handleSlackAction( - { - action: "reactions", - channelId: resolveChannelId(), - messageId, - limit, - accountId: accountId ?? undefined, - }, - cfg, - ); - } - - if (action === "read") { - const limit = readNumberParam(params, "limit", { integer: true }); - return await handleSlackAction( - { - action: "readMessages", - channelId: resolveChannelId(), - limit, - before: readStringParam(params, "before"), - after: readStringParam(params, "after"), - accountId: accountId ?? undefined, - }, - cfg, - ); - } - - if (action === "edit") { - const messageId = readStringParam(params, "messageId", { - required: true, - }); - const content = readStringParam(params, "message", { required: true }); - return await handleSlackAction( - { - action: "editMessage", - channelId: resolveChannelId(), - messageId, - content, - accountId: accountId ?? undefined, - }, - cfg, - ); - } - - if (action === "delete") { - const messageId = readStringParam(params, "messageId", { - required: true, - }); - return await handleSlackAction( - { - action: "deleteMessage", - channelId: resolveChannelId(), - messageId, - accountId: accountId ?? undefined, - }, - cfg, - ); - } - - if (action === "pin" || action === "unpin" || action === "list-pins") { - const messageId = - action === "list-pins" - ? undefined - : readStringParam(params, "messageId", { required: true }); - return await handleSlackAction( - { - action: - action === "pin" ? "pinMessage" : action === "unpin" ? "unpinMessage" : "listPins", - channelId: resolveChannelId(), - messageId, - accountId: accountId ?? undefined, - }, - cfg, - ); - } - - if (action === "member-info") { - const userId = readStringParam(params, "userId", { required: true }); - return await handleSlackAction( - { action: "memberInfo", userId, accountId: accountId ?? undefined }, - cfg, - ); - } - - if (action === "emoji-list") { - return await handleSlackAction( - { action: "emojiList", accountId: accountId ?? undefined }, - cfg, - ); - } - - throw new Error(`Action ${action} is not supported for provider ${meta.id}.`); - }, - }, + actions: createSlackActions(meta.id), setup: { resolveAccountId: ({ accountId }) => normalizeAccountId(accountId), applyAccountName: ({ cfg, accountId, name }) => @@ -448,9 +240,12 @@ export const slackPlugin: ChannelPlugin = { sendText: async ({ to, text, accountId, deps, replyToId, cfg }) => { const send = deps?.sendSlack ?? sendMessageSlack; const account = resolveSlackAccount({ cfg, accountId }); - const token = getTokenForOperation(account, "write"); - const botToken = account.botToken?.trim(); - const tokenOverride = token && token !== botToken ? token : undefined; + const tokenOverride = resolveSlackTokenOverride({ + botToken: account.botToken, + userToken: account.userToken, + userTokenReadOnly: account.config.userTokenReadOnly, + operation: "write", + }); const result = await send(to, text, { threadTs: replyToId ?? undefined, accountId: accountId ?? undefined, @@ -461,9 +256,12 @@ export const slackPlugin: ChannelPlugin = { sendMedia: async ({ to, text, mediaUrl, accountId, deps, replyToId, cfg }) => { const send = deps?.sendSlack ?? sendMessageSlack; const account = resolveSlackAccount({ cfg, accountId }); - const token = getTokenForOperation(account, "write"); - const botToken = account.botToken?.trim(); - const tokenOverride = token && token !== botToken ? token : undefined; + const tokenOverride = resolveSlackTokenOverride({ + botToken: account.botToken, + userToken: account.userToken, + userTokenReadOnly: account.config.userTokenReadOnly, + operation: "write", + }); const result = await send(to, text, { mediaUrl, threadTs: replyToId ?? undefined, diff --git a/src/slack/accounts.ts b/src/slack/accounts.ts index a8ba2317d..2d812cbff 100644 --- a/src/slack/accounts.ts +++ b/src/slack/accounts.ts @@ -1,7 +1,7 @@ import type { ClawdbotConfig } from "../config/config.js"; import type { SlackAccountConfig } from "../config/types.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"; @@ -11,6 +11,7 @@ export type ResolvedSlackAccount = { name?: string; botToken?: string; appToken?: string; + userToken?: string; botTokenSource: SlackTokenSource; appTokenSource: SlackTokenSource; config: SlackAccountConfig; @@ -75,6 +76,7 @@ export function resolveSlackAccount(params: { const envApp = allowEnv ? resolveSlackAppToken(process.env.SLACK_APP_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 botTokenSource: SlackTokenSource = configBot ? "config" : envBot ? "env" : "none"; @@ -86,6 +88,7 @@ export function resolveSlackAccount(params: { name: merged.name?.trim() || undefined, botToken, appToken, + userToken: configUser, botTokenSource, appTokenSource, config: merged, diff --git a/src/slack/token.ts b/src/slack/token.ts index 2fbf215df..e4e708c65 100644 --- a/src/slack/token.ts +++ b/src/slack/token.ts @@ -10,3 +10,44 @@ 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); +} + +export type SlackTokenOperation = "read" | "write"; + +export type SlackTokenSelection = { + botToken?: string; + userToken?: string; + userTokenReadOnly?: boolean; + operation: SlackTokenOperation; +}; + +function selectSlackTokenNormalized(params: SlackTokenSelection): string | undefined { + const allowUserWrites = params.userTokenReadOnly === false; + if (params.operation === "read") return params.userToken ?? params.botToken; + if (!allowUserWrites) return params.botToken; + return params.botToken ?? params.userToken; +} + +export function selectSlackToken(params: SlackTokenSelection): string | undefined { + const botToken = resolveSlackBotToken(params.botToken); + const userToken = resolveSlackUserToken(params.userToken); + return selectSlackTokenNormalized({ + ...params, + botToken, + userToken, + }); +} + +export function resolveSlackTokenOverride(params: SlackTokenSelection): string | undefined { + const botToken = resolveSlackBotToken(params.botToken); + const userToken = resolveSlackUserToken(params.userToken); + const token = selectSlackTokenNormalized({ + ...params, + botToken, + userToken, + }); + return token && token !== botToken ? token : undefined; +}