diff --git a/Peekaboo b/Peekaboo index 95ad7532c..c1243a797 160000 --- a/Peekaboo +++ b/Peekaboo @@ -1 +1 @@ -Subproject commit 95ad7532c15b6b4e67cdf2c8c5dea399c8483fe1 +Subproject commit c1243a7978b71137060a82aa4a451e1720e36aff diff --git a/docs/channels/slack.md b/docs/channels/slack.md index 1260c6286..aabe7ac6b 100644 --- a/docs/channels/slack.md +++ b/docs/channels/slack.md @@ -27,17 +27,17 @@ Minimal config: 1) Create a Slack app (From scratch) in https://api.channels.slack.com/apps. 2) **Socket Mode** → toggle on. Then go to **Basic Information** → **App-Level Tokens** → **Generate Token and Scopes** with scope `connections:write`. Copy the **App Token** (`xapp-...`). 3) **OAuth & Permissions** → add bot token scopes (use the manifest below). Click **Install to Workspace**. Copy the **Bot User OAuth Token** (`xoxb-...`). -4) **Event Subscriptions** → enable events and subscribe to: +4) Optional: **OAuth & Permissions** → add **User Token Scopes** (see the read-only list below). Reinstall the app and copy the **User OAuth Token** (`xoxp-...`). +5) **Event Subscriptions** → enable events and subscribe to: - `message.*` (includes edits/deletes/thread broadcasts) - `app_mention` - `reaction_added`, `reaction_removed` - `member_joined_channel`, `member_left_channel` - - `channel_id_changed` - `channel_rename` - `pin_added`, `pin_removed` -5) Invite the bot to channels you want it to read. -6) Slash Commands → create `/clawd` if you use `channels.slack.slashCommand`. If you enable native commands, add one slash command per built-in command (same names as `/help`). Native defaults to off for Slack unless you set `channels.slack.commands.native: true` (global `commands.native` is `"auto"` which leaves Slack off). -7) App Home → enable the **Messages Tab** so users can DM the bot. +6) Invite the bot to channels you want it to read. +7) Slash Commands → create `/clawd` if you use `channels.slack.slashCommand`. If you enable native commands, add one slash command per built-in command (same names as `/help`). Native defaults to off for Slack unless you set `channels.slack.commands.native: true` (global `commands.native` is `"auto"` which leaves Slack off). +8) App Home → enable the **Messages Tab** so users can DM the bot. Use the manifest below so scopes and events stay in sync. @@ -63,27 +63,59 @@ Or via config: } ``` -## 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`. +## User token (optional) +Clawdbot can use a Slack user token (`xoxp-...`) for read operations (history, +pins, reactions, emoji, member info). By default this stays read-only: reads +prefer the user token when present, and writes still use the bot token unless +you explicitly opt in. Even with `userTokenReadOnly: false`, the bot token stays +preferred for writes when it is available. -## Config writes -By default, Slack is allowed to write config updates triggered by channel events or `/config set|unset`. +User tokens are configured in the config file (no env var support). For +multi-account, set `channels.slack.accounts..userToken`. -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: +Example with bot + app + user tokens: ```json5 { - channels: { slack: { configWrites: false } } + channels: { + slack: { + enabled: true, + appToken: "xapp-...", + botToken: "xoxb-...", + userToken: "xoxp-..." + } + } } ``` +Example with userTokenReadOnly explicitly set (allow user token writes): +```json5 +{ + channels: { + slack: { + enabled: true, + appToken: "xapp-...", + botToken: "xoxb-...", + userToken: "xoxp-...", + userTokenReadOnly: false + } + } +} +``` + +### Token usage +- Read operations (history, reactions list, pins list, emoji list, member info, + search) prefer the user token when configured, otherwise the bot token. +- Write operations (send/edit/delete messages, add/remove reactions, pin/unpin, + file uploads) use the bot token by default. If `userTokenReadOnly: false` and + no bot token is available, Clawdbot falls back to the user token. + +## 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). + ## Manifest (optional) -Use this Slack app manifest to create the app quickly (adjust the name/command if you want). +Use this Slack app manifest to create the app quickly (adjust the name/command if you want). Include the +user scopes if you plan to configure a user token. ```json { @@ -133,6 +165,21 @@ Use this Slack app manifest to create the app quickly (adjust the name/command i "commands", "files:read", "files:write" + ], + "user": [ + "channels:history", + "channels:read", + "groups:history", + "groups:read", + "im:history", + "im:read", + "mpim:history", + "mpim:read", + "users:read", + "reactions:read", + "pins:read", + "emoji:read", + "search:read" ] } }, @@ -149,7 +196,6 @@ Use this Slack app manifest to create the app quickly (adjust the name/command i "reaction_removed", "member_joined_channel", "member_left_channel", - "channel_id_changed", "channel_rename", "pin_added", "pin_removed" @@ -166,7 +212,7 @@ Slack's Conversations API is type-scoped: you only need the scopes for the conversation types you actually touch (channels, groups, im, mpim). See https://api.channels.slack.com/docs/conversations-api for the overview. -### Required scopes +### Bot token scopes (required) - `chat:write` (send/update/delete messages via `chat.postMessage`) https://api.channels.slack.com/methods/chat.postMessage - `im:write` (open DMs via `conversations.open` for user DMs) @@ -188,6 +234,17 @@ https://api.channels.slack.com/docs/conversations-api for the overview. - `files:write` (uploads via `files.uploadV2`) https://api.channels.slack.com/messaging/files/uploading +### User token scopes (optional, read-only by default) +Add these under **User Token Scopes** if you configure `channels.slack.userToken`. + +- `channels:history`, `groups:history`, `im:history`, `mpim:history` +- `channels:read`, `groups:read`, `im:read`, `mpim:read` +- `users:read` +- `reactions:read` +- `pins:read` +- `emoji:read` +- `search:read` + ### Not needed today (but likely future) - `mpim:write` (only if we add group-DM open/DM start via `conversations.open`) - `groups:write` (only if we add private-channel management: create/rename/invite/archive) @@ -269,11 +326,6 @@ 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). @@ -320,6 +372,17 @@ Slack tool actions can be gated with `channels.slack.actions.*`: | memberInfo | enabled | Member info | | emojiList | enabled | Custom emoji list | +## Security notes +- Writes default to the bot token so state-changing actions stay scoped to the + app's bot permissions and identity. +- Setting `userTokenReadOnly: false` allows the user token to be used for write + operations when a bot token is unavailable, which means actions run with the + installing user's access. Treat the user token as highly privileged and keep + action gates and allowlists tight. +- If you enable user-token writes, make sure the user token includes the write + scopes you expect (`chat:write`, `reactions:write`, `pins:write`, + `files:write`) or those operations will fail. + ## Notes - Mention gating is controlled via `channels.slack.channels` (set `requireMention` to `true`); `agents.list[].groupChat.mentionPatterns` (or `messages.groupChat.mentionPatterns`) also count as mentions. - Multi-agent override: set per-agent patterns on `agents.list[].groupChat.mentionPatterns`. @@ -327,5 +390,4 @@ 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/src/agents/tools/slack-actions.test.ts b/src/agents/tools/slack-actions.test.ts index 611721e02..9e2959a07 100644 --- a/src/agents/tools/slack-actions.test.ts +++ b/src/agents/tools/slack-actions.test.ts @@ -361,4 +361,48 @@ describe("handleSlackAction", () => { expect(payload.pins[0].message?.timestampMs).toBe(expectedMs); expect(payload.pins[0].message?.timestampUtc).toBe(new Date(expectedMs).toISOString()); }); + + it("uses user token for reads when available", async () => { + const cfg = { + channels: { slack: { botToken: "xoxb-1", userToken: "xoxp-1" } }, + } as ClawdbotConfig; + readSlackMessages.mockClear(); + readSlackMessages.mockResolvedValueOnce({ messages: [], hasMore: false }); + await handleSlackAction({ action: "readMessages", channelId: "C1" }, cfg); + const [, opts] = readSlackMessages.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" } }, + } as ClawdbotConfig; + readSlackMessages.mockClear(); + readSlackMessages.mockResolvedValueOnce({ messages: [], hasMore: false }); + await handleSlackAction({ action: "readMessages", channelId: "C1" }, cfg); + const [, opts] = readSlackMessages.mock.calls[0] ?? []; + expect(opts?.token).toBeUndefined(); + }); + + it("uses bot token for writes when userTokenReadOnly is true", async () => { + const cfg = { + channels: { slack: { botToken: "xoxb-1", userToken: "xoxp-1" } }, + } as ClawdbotConfig; + sendSlackMessage.mockClear(); + await handleSlackAction({ action: "sendMessage", to: "channel:C1", content: "Hello" }, cfg); + const [, , opts] = sendSlackMessage.mock.calls[0] ?? []; + expect(opts?.token).toBeUndefined(); + }); + + it("allows user token writes when bot token is missing", async () => { + const cfg = { + channels: { + slack: { 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).toBe("xoxp-1"); + }); }); diff --git a/src/agents/tools/slack-actions.ts b/src/agents/tools/slack-actions.ts index 9b14bd850..84659cd8f 100644 --- a/src/agents/tools/slack-actions.ts +++ b/src/agents/tools/slack-actions.ts @@ -78,10 +78,32 @@ export async function handleSlackAction( ): Promise> { const action = readStringParam(params, "action", { required: true }); const accountId = readStringParam(params, "accountId"); - const accountOpts = accountId ? { accountId } : undefined; 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; + if (!accountId && !tokenOverride) return undefined; + return { + ...(accountId ? { accountId } : {}), + ...(tokenOverride ? { token: tokenOverride } : {}), + }; + }; + + const readOpts = buildActionOpts("read"); + const writeOpts = buildActionOpts("write"); if (reactionsActions.has(action)) { if (!isActionEnabled("reactions")) { @@ -94,28 +116,28 @@ export async function handleSlackAction( removeErrorMessage: "Emoji is required to remove a Slack reaction.", }); if (remove) { - if (accountOpts) { - await removeSlackReaction(channelId, messageId, emoji, accountOpts); + if (writeOpts) { + await removeSlackReaction(channelId, messageId, emoji, writeOpts); } else { await removeSlackReaction(channelId, messageId, emoji); } return jsonResult({ ok: true, removed: emoji }); } if (isEmpty) { - const removed = accountOpts - ? await removeOwnSlackReactions(channelId, messageId, accountOpts) + const removed = writeOpts + ? await removeOwnSlackReactions(channelId, messageId, writeOpts) : await removeOwnSlackReactions(channelId, messageId); return jsonResult({ ok: true, removed }); } - if (accountOpts) { - await reactSlackMessage(channelId, messageId, emoji, accountOpts); + if (writeOpts) { + await reactSlackMessage(channelId, messageId, emoji, writeOpts); } else { await reactSlackMessage(channelId, messageId, emoji); } return jsonResult({ ok: true, added: emoji }); } - const reactions = accountOpts - ? await listSlackReactions(channelId, messageId, accountOpts) + const reactions = readOpts + ? await listSlackReactions(channelId, messageId, readOpts) : await listSlackReactions(channelId, messageId); return jsonResult({ ok: true, reactions }); } @@ -135,7 +157,7 @@ export async function handleSlackAction( context, ); const result = await sendSlackMessage(to, content, { - accountId: accountId ?? undefined, + ...writeOpts, mediaUrl: mediaUrl ?? undefined, threadTs: threadTs ?? undefined, }); @@ -162,8 +184,8 @@ export async function handleSlackAction( const content = readStringParam(params, "content", { required: true, }); - if (accountOpts) { - await editSlackMessage(channelId, messageId, content, accountOpts); + if (writeOpts) { + await editSlackMessage(channelId, messageId, content, writeOpts); } else { await editSlackMessage(channelId, messageId, content); } @@ -176,8 +198,8 @@ export async function handleSlackAction( const messageId = readStringParam(params, "messageId", { required: true, }); - if (accountOpts) { - await deleteSlackMessage(channelId, messageId, accountOpts); + if (writeOpts) { + await deleteSlackMessage(channelId, messageId, writeOpts); } else { await deleteSlackMessage(channelId, messageId); } @@ -193,7 +215,7 @@ export async function handleSlackAction( const before = readStringParam(params, "before"); const after = readStringParam(params, "after"); const result = await readSlackMessages(channelId, { - accountId: accountId ?? undefined, + ...readOpts, limit, before: before ?? undefined, after: after ?? undefined, @@ -220,8 +242,8 @@ export async function handleSlackAction( const messageId = readStringParam(params, "messageId", { required: true, }); - if (accountOpts) { - await pinSlackMessage(channelId, messageId, accountOpts); + if (writeOpts) { + await pinSlackMessage(channelId, messageId, writeOpts); } else { await pinSlackMessage(channelId, messageId); } @@ -231,15 +253,15 @@ export async function handleSlackAction( const messageId = readStringParam(params, "messageId", { required: true, }); - if (accountOpts) { - await unpinSlackMessage(channelId, messageId, accountOpts); + if (writeOpts) { + await unpinSlackMessage(channelId, messageId, writeOpts); } else { await unpinSlackMessage(channelId, messageId); } return jsonResult({ ok: true }); } - const pins = accountOpts - ? await listSlackPins(channelId, accountOpts) + const pins = writeOpts + ? await listSlackPins(channelId, readOpts) : await listSlackPins(channelId); const normalizedPins = pins.map((pin) => { const message = pin.message @@ -258,8 +280,8 @@ export async function handleSlackAction( throw new Error("Slack member info is disabled."); } const userId = readStringParam(params, "userId", { required: true }); - const info = accountOpts - ? await getSlackMemberInfo(userId, accountOpts) + const info = writeOpts + ? await getSlackMemberInfo(userId, readOpts) : await getSlackMemberInfo(userId); return jsonResult({ ok: true, info }); } @@ -268,7 +290,7 @@ export async function handleSlackAction( if (!isActionEnabled("emojiList")) { throw new Error("Slack emoji list is disabled."); } - const emojis = accountOpts ? await listSlackEmojis(accountOpts) : await listSlackEmojis(); + const emojis = readOpts ? await listSlackEmojis(readOpts) : await listSlackEmojis(); return jsonResult({ ok: true, emojis }); } diff --git a/src/channels/plugins/slack.ts b/src/channels/plugins/slack.ts index 4cfbfa993..f55964327 100644 --- a/src/channels/plugins/slack.ts +++ b/src/channels/plugins/slack.ts @@ -1,5 +1,9 @@ +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, @@ -21,11 +25,23 @@ import { applyAccountNameToChannelSection, migrateBaseNameToDefaultAccount, } from "./setup-helpers.js"; -import { createSlackActions } from "./slack.actions.js"; -import type { ChannelPlugin } from "./types.js"; +import type { ChannelMessageActionName, 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: { @@ -36,7 +52,21 @@ export const slackPlugin: ChannelPlugin = { idLabel: "slackUserId", normalizeAllowEntry: (entry) => entry.replace(/^(slack|user):/i, ""), notifyApproval: async ({ id }) => { - await sendMessageSlack(`user:${id}`, PAIRING_APPROVED_MESSAGE); + const cfg = loadConfig(); + const account = resolveSlackAccount({ + cfg, + accountId: DEFAULT_ACCOUNT_ID, + }); + 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); + } }, }, capabilities: { @@ -139,7 +169,197 @@ export const slackPlugin: ChannelPlugin = { messaging: { normalizeTarget: normalizeSlackMessagingTarget, }, - actions: createSlackActions(meta.id), + 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}.`); + }, + }, setup: { resolveAccountId: ({ accountId }) => normalizeAccountId(accountId), applyAccountName: ({ cfg, accountId, name }) => @@ -225,20 +445,30 @@ export const slackPlugin: ChannelPlugin = { } return { ok: true, to: trimmed }; }, - sendText: async ({ to, text, accountId, deps, replyToId }) => { + 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 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 ({ 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 result = await send(to, text, { mediaUrl, threadTs: replyToId ?? undefined, accountId: accountId ?? undefined, + ...(tokenOverride ? { token: tokenOverride } : {}), }); return { channel: "slack", ...result }; }, diff --git a/src/config/schema.ts b/src/config/schema.ts index f3b09fc45..adc17721f 100644 --- a/src/config/schema.ts +++ b/src/config/schema.ts @@ -204,6 +204,8 @@ const FIELD_LABELS: Record = { "channels.discord.token": "Discord Bot Token", "channels.slack.botToken": "Slack Bot Token", "channels.slack.appToken": "Slack App Token", + "channels.slack.userToken": "Slack User Token", + "channels.slack.userTokenReadOnly": "Slack User Token Read Only", "channels.slack.thread.historyScope": "Slack Thread History Scope", "channels.slack.thread.inheritParent": "Slack Thread Parent Inheritance", "channels.signal.account": "Signal Account", diff --git a/src/config/slack-token-validation.test.ts b/src/config/slack-token-validation.test.ts new file mode 100644 index 000000000..ce25a5f42 --- /dev/null +++ b/src/config/slack-token-validation.test.ts @@ -0,0 +1,37 @@ +import { describe, expect, it } from "vitest"; + +import { validateConfigObject } from "./config.js"; + +describe("Slack token config fields", () => { + it("accepts user token config fields", () => { + const res = validateConfigObject({ + channels: { + slack: { + botToken: "xoxb-any", + appToken: "xapp-any", + userToken: "xoxp-any", + userTokenReadOnly: false, + }, + }, + }); + expect(res.ok).toBe(true); + }); + + it("accepts account-level user token config", () => { + const res = validateConfigObject({ + channels: { + slack: { + accounts: { + work: { + botToken: "xoxb-any", + appToken: "xapp-any", + userToken: "xoxp-any", + userTokenReadOnly: true, + }, + }, + }, + }, + }); + expect(res.ok).toBe(true); + }); +}); diff --git a/src/config/types.slack.ts b/src/config/types.slack.ts index 23f2e36a6..1609850c1 100644 --- a/src/config/types.slack.ts +++ b/src/config/types.slack.ts @@ -80,6 +80,9 @@ export type SlackAccountConfig = { enabled?: boolean; botToken?: string; appToken?: string; + userToken?: string; + /** If true, restrict user token to read operations only. Default: true. */ + userTokenReadOnly?: boolean; /** Allow bot-authored messages to trigger replies (default: false). */ allowBots?: boolean; /** Default mention requirement for channel messages (default: true). */ diff --git a/src/config/zod-schema.providers-core.ts b/src/config/zod-schema.providers-core.ts index 4bccca5f9..3ff045ef1 100644 --- a/src/config/zod-schema.providers-core.ts +++ b/src/config/zod-schema.providers-core.ts @@ -220,6 +220,8 @@ export const SlackAccountSchema = z.object({ configWrites: z.boolean().optional(), botToken: z.string().optional(), appToken: z.string().optional(), + userToken: z.string().optional(), + userTokenReadOnly: z.boolean().optional().default(true), allowBots: z.boolean().optional(), requireMention: z.boolean().optional(), groupPolicy: GroupPolicySchema.optional().default("allowlist"),