Compare commits

...

2 Commits

Author SHA1 Message Date
Peter Steinberger
dcfde2822a fix: refine Slack user token routing (#981) (thanks @jalehman) 2026-01-16 00:11:12 +00:00
Josh Lehman
bb568b2a45 feat(slack): add userToken for read-only access to DMs and private channels
- Add userToken and userTokenReadOnly (default: true) config fields
- Implement token routing: reads prefer user token, writes use bot token
- Add tests for token routing logic
- Update documentation with required OAuth scopes

User tokens enable reading DMs and private channels without requiring
bot membership. The userTokenReadOnly flag (true by default) ensures
the user token can only be used for reads, preventing accidental
sends as the user.

Required user token scopes:
- 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
2026-01-15 23:57:39 +00:00
13 changed files with 355 additions and 37 deletions

View File

@ -2,6 +2,7 @@
## 2026.1.15 (unreleased) ## 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. - 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: 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. - Daemon: share profile/state-dir resolution across service helpers and honor `CLAWDBOT_STATE_DIR` for Windows task scripts.

View File

@ -27,7 +27,8 @@ Minimal config:
1) Create a Slack app (From scratch) in https://api.channels.slack.com/apps. 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-...`). 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-...`). 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) - `message.*` (includes edits/deletes/thread broadcasts)
- `app_mention` - `app_mention`
- `reaction_added`, `reaction_removed` - `reaction_added`, `reaction_removed`
@ -35,9 +36,9 @@ Minimal config:
- `channel_id_changed` - `channel_id_changed`
- `channel_rename` - `channel_rename`
- `pin_added`, `pin_removed` - `pin_added`, `pin_removed`
5) Invite the bot to channels you want it to read. 6) 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) 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. 8) App Home → enable the **Messages Tab** so users can DM the bot.
Use the manifest below so scopes and events stay in sync. Use the manifest below so scopes and events stay in sync.
@ -63,6 +64,52 @@ Or via config:
} }
``` ```
## 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.
User tokens are configured in the config file (no env var support). For
multi-account, set `channels.slack.accounts.<id>.userToken`.
Example with bot + app + user tokens:
```json5
{
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 ## History context
- `channels.slack.historyLimit` (or `channels.slack.accounts.*.historyLimit`) controls how many recent channel/group messages are wrapped into the prompt. - `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). - Falls back to `messages.groupChat.historyLimit`. Set `0` to disable (default 50).
@ -83,7 +130,8 @@ Disable with:
``` ```
## Manifest (optional) ## 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 ```json
{ {
@ -133,6 +181,21 @@ Use this Slack app manifest to create the app quickly (adjust the name/command i
"commands", "commands",
"files:read", "files:read",
"files:write" "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"
] ]
} }
}, },
@ -166,7 +229,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 conversation types you actually touch (channels, groups, im, mpim). See
https://api.channels.slack.com/docs/conversations-api for the overview. 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`) - `chat:write` (send/update/delete messages via `chat.postMessage`)
https://api.channels.slack.com/methods/chat.postMessage https://api.channels.slack.com/methods/chat.postMessage
- `im:write` (open DMs via `conversations.open` for user DMs) - `im:write` (open DMs via `conversations.open` for user DMs)
@ -188,6 +251,17 @@ https://api.channels.slack.com/docs/conversations-api for the overview.
- `files:write` (uploads via `files.uploadV2`) - `files:write` (uploads via `files.uploadV2`)
https://api.channels.slack.com/messaging/files/uploading 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) ### Not needed today (but likely future)
- `mpim:write` (only if we add group-DM open/DM start via `conversations.open`) - `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) - `groups:write` (only if we add private-channel management: create/rename/invite/archive)
@ -320,6 +394,17 @@ Slack tool actions can be gated with `channels.slack.actions.*`:
| memberInfo | enabled | Member info | | memberInfo | enabled | Member info |
| emojiList | enabled | Custom emoji list | | 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 ## 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. - 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`. - Multi-agent override: set per-agent patterns on `agents.list[].groupChat.mentionPatterns`.

View File

@ -1052,6 +1052,8 @@ Slack runs in Socket Mode and requires both a bot token and app token:
enabled: true, enabled: true,
botToken: "xoxb-...", botToken: "xoxb-...",
appToken: "xapp-...", appToken: "xapp-...",
userToken: "xoxp-...", // optional (read-only by default)
userTokenReadOnly: true, // default: true
dm: { dm: {
enabled: true, enabled: true,
policy: "pairing", // pairing | allowlist | open | disabled 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. 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:<id>` (DM) or `channel:<id>` when specifying delivery targets for cron/CLI commands. Clawdbot starts Slack when the provider is enabled and both tokens are set (via config or `SLACK_BOT_TOKEN` + `SLACK_APP_TOKEN`). Use `user:<id>` (DM) or `channel:<id>` 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`). 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.<id>.allowBots`. Bot-authored messages are ignored by default. Enable with `channels.slack.allowBots` or `channels.slack.channels.<id>.allowBots`.

View File

@ -361,4 +361,82 @@ describe("handleSlackAction", () => {
expect(payload.pins[0].message?.timestampMs).toBe(expectedMs); expect(payload.pins[0].message?.timestampMs).toBe(expectedMs);
expect(payload.pins[0].message?.timestampUtc).toBe(new Date(expectedMs).toISOString()); 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("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" } },
} 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");
});
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();
});
}); });

View File

@ -2,6 +2,7 @@ import type { AgentToolResult } from "@mariozechner/pi-agent-core";
import type { ClawdbotConfig } from "../../config/config.js"; import type { ClawdbotConfig } from "../../config/config.js";
import { resolveSlackAccount } from "../../slack/accounts.js"; import { resolveSlackAccount } from "../../slack/accounts.js";
import { resolveSlackTokenOverride } from "../../slack/token.js";
import { import {
deleteSlackMessage, deleteSlackMessage,
editSlackMessage, editSlackMessage,
@ -78,11 +79,27 @@ export async function handleSlackAction(
): Promise<AgentToolResult<unknown>> { ): Promise<AgentToolResult<unknown>> {
const action = readStringParam(params, "action", { required: true }); const action = readStringParam(params, "action", { required: true });
const accountId = readStringParam(params, "accountId"); const accountId = readStringParam(params, "accountId");
const accountOpts = accountId ? { accountId } : undefined;
const account = resolveSlackAccount({ cfg, accountId }); const account = resolveSlackAccount({ cfg, accountId });
const actionConfig = account.actions ?? cfg.channels?.slack?.actions; const actionConfig = account.actions ?? cfg.channels?.slack?.actions;
const isActionEnabled = createActionGate(actionConfig); const isActionEnabled = createActionGate(actionConfig);
const buildActionOpts = (operation: "read" | "write") => {
const tokenOverride = resolveSlackTokenOverride({
botToken: account.botToken,
userToken: account.userToken,
userTokenReadOnly: account.config.userTokenReadOnly,
operation,
});
if (!accountId && !tokenOverride) return undefined;
return {
...(accountId ? { accountId } : {}),
...(tokenOverride ? { token: tokenOverride } : {}),
};
};
const readOpts = buildActionOpts("read");
const writeOpts = buildActionOpts("write");
if (reactionsActions.has(action)) { if (reactionsActions.has(action)) {
if (!isActionEnabled("reactions")) { if (!isActionEnabled("reactions")) {
throw new Error("Slack reactions are disabled."); throw new Error("Slack reactions are disabled.");
@ -94,28 +111,28 @@ export async function handleSlackAction(
removeErrorMessage: "Emoji is required to remove a Slack reaction.", removeErrorMessage: "Emoji is required to remove a Slack reaction.",
}); });
if (remove) { if (remove) {
if (accountOpts) { if (writeOpts) {
await removeSlackReaction(channelId, messageId, emoji, accountOpts); await removeSlackReaction(channelId, messageId, emoji, writeOpts);
} else { } else {
await removeSlackReaction(channelId, messageId, emoji); await removeSlackReaction(channelId, messageId, emoji);
} }
return jsonResult({ ok: true, removed: emoji }); return jsonResult({ ok: true, removed: emoji });
} }
if (isEmpty) { if (isEmpty) {
const removed = accountOpts const removed = writeOpts
? await removeOwnSlackReactions(channelId, messageId, accountOpts) ? await removeOwnSlackReactions(channelId, messageId, writeOpts)
: await removeOwnSlackReactions(channelId, messageId); : await removeOwnSlackReactions(channelId, messageId);
return jsonResult({ ok: true, removed }); return jsonResult({ ok: true, removed });
} }
if (accountOpts) { if (writeOpts) {
await reactSlackMessage(channelId, messageId, emoji, accountOpts); await reactSlackMessage(channelId, messageId, emoji, writeOpts);
} else { } else {
await reactSlackMessage(channelId, messageId, emoji); await reactSlackMessage(channelId, messageId, emoji);
} }
return jsonResult({ ok: true, added: emoji }); return jsonResult({ ok: true, added: emoji });
} }
const reactions = accountOpts const reactions = readOpts
? await listSlackReactions(channelId, messageId, accountOpts) ? await listSlackReactions(channelId, messageId, readOpts)
: await listSlackReactions(channelId, messageId); : await listSlackReactions(channelId, messageId);
return jsonResult({ ok: true, reactions }); return jsonResult({ ok: true, reactions });
} }
@ -135,7 +152,7 @@ export async function handleSlackAction(
context, context,
); );
const result = await sendSlackMessage(to, content, { const result = await sendSlackMessage(to, content, {
accountId: accountId ?? undefined, ...writeOpts,
mediaUrl: mediaUrl ?? undefined, mediaUrl: mediaUrl ?? undefined,
threadTs: threadTs ?? undefined, threadTs: threadTs ?? undefined,
}); });
@ -162,8 +179,8 @@ export async function handleSlackAction(
const content = readStringParam(params, "content", { const content = readStringParam(params, "content", {
required: true, required: true,
}); });
if (accountOpts) { if (writeOpts) {
await editSlackMessage(channelId, messageId, content, accountOpts); await editSlackMessage(channelId, messageId, content, writeOpts);
} else { } else {
await editSlackMessage(channelId, messageId, content); await editSlackMessage(channelId, messageId, content);
} }
@ -176,8 +193,8 @@ export async function handleSlackAction(
const messageId = readStringParam(params, "messageId", { const messageId = readStringParam(params, "messageId", {
required: true, required: true,
}); });
if (accountOpts) { if (writeOpts) {
await deleteSlackMessage(channelId, messageId, accountOpts); await deleteSlackMessage(channelId, messageId, writeOpts);
} else { } else {
await deleteSlackMessage(channelId, messageId); await deleteSlackMessage(channelId, messageId);
} }
@ -193,7 +210,7 @@ export async function handleSlackAction(
const before = readStringParam(params, "before"); const before = readStringParam(params, "before");
const after = readStringParam(params, "after"); const after = readStringParam(params, "after");
const result = await readSlackMessages(channelId, { const result = await readSlackMessages(channelId, {
accountId: accountId ?? undefined, ...readOpts,
limit, limit,
before: before ?? undefined, before: before ?? undefined,
after: after ?? undefined, after: after ?? undefined,
@ -220,8 +237,8 @@ export async function handleSlackAction(
const messageId = readStringParam(params, "messageId", { const messageId = readStringParam(params, "messageId", {
required: true, required: true,
}); });
if (accountOpts) { if (writeOpts) {
await pinSlackMessage(channelId, messageId, accountOpts); await pinSlackMessage(channelId, messageId, writeOpts);
} else { } else {
await pinSlackMessage(channelId, messageId); await pinSlackMessage(channelId, messageId);
} }
@ -231,15 +248,15 @@ export async function handleSlackAction(
const messageId = readStringParam(params, "messageId", { const messageId = readStringParam(params, "messageId", {
required: true, required: true,
}); });
if (accountOpts) { if (writeOpts) {
await unpinSlackMessage(channelId, messageId, accountOpts); await unpinSlackMessage(channelId, messageId, writeOpts);
} else { } else {
await unpinSlackMessage(channelId, messageId); await unpinSlackMessage(channelId, messageId);
} }
return jsonResult({ ok: true }); return jsonResult({ ok: true });
} }
const pins = accountOpts const pins = readOpts
? await listSlackPins(channelId, accountOpts) ? await listSlackPins(channelId, readOpts)
: await listSlackPins(channelId); : await listSlackPins(channelId);
const normalizedPins = pins.map((pin) => { const normalizedPins = pins.map((pin) => {
const message = pin.message const message = pin.message
@ -258,8 +275,8 @@ export async function handleSlackAction(
throw new Error("Slack member info is disabled."); throw new Error("Slack member info is disabled.");
} }
const userId = readStringParam(params, "userId", { required: true }); const userId = readStringParam(params, "userId", { required: true });
const info = accountOpts const info = readOpts
? await getSlackMemberInfo(userId, accountOpts) ? await getSlackMemberInfo(userId, readOpts)
: await getSlackMemberInfo(userId); : await getSlackMemberInfo(userId);
return jsonResult({ ok: true, info }); return jsonResult({ ok: true, info });
} }
@ -268,7 +285,7 @@ export async function handleSlackAction(
if (!isActionEnabled("emojiList")) { if (!isActionEnabled("emojiList")) {
throw new Error("Slack emoji list is disabled."); 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 }); return jsonResult({ ok: true, emojis });
} }

View File

@ -1,4 +1,6 @@
import { resolveSlackAccount } from "../../../slack/accounts.js";
import { sendMessageSlack } from "../../../slack/send.js"; import { sendMessageSlack } from "../../../slack/send.js";
import { resolveSlackTokenOverride } from "../../../slack/token.js";
import type { ChannelOutboundAdapter } from "../types.js"; import type { ChannelOutboundAdapter } from "../types.js";
export const slackOutbound: ChannelOutboundAdapter = { export const slackOutbound: ChannelOutboundAdapter = {
@ -15,20 +17,36 @@ export const slackOutbound: ChannelOutboundAdapter = {
} }
return { ok: true, to: trimmed }; 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 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, { const result = await send(to, text, {
threadTs: replyToId ?? undefined, threadTs: replyToId ?? undefined,
accountId: accountId ?? undefined, accountId: accountId ?? undefined,
...(tokenOverride ? { token: tokenOverride } : {}),
}); });
return { channel: "slack", ...result }; 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 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, { const result = await send(to, text, {
mediaUrl, mediaUrl,
threadTs: replyToId ?? undefined, threadTs: replyToId ?? undefined,
accountId: accountId ?? undefined, accountId: accountId ?? undefined,
...(tokenOverride ? { token: tokenOverride } : {}),
}); });
return { channel: "slack", ...result }; return { channel: "slack", ...result };
}, },

View File

@ -7,6 +7,7 @@ import {
} from "../../slack/accounts.js"; } from "../../slack/accounts.js";
import { probeSlack } from "../../slack/probe.js"; import { probeSlack } from "../../slack/probe.js";
import { sendMessageSlack } from "../../slack/send.js"; import { sendMessageSlack } from "../../slack/send.js";
import { resolveSlackTokenOverride } from "../../slack/token.js";
import { getChatChannelMeta } from "../registry.js"; import { getChatChannelMeta } from "../registry.js";
import { import {
deleteAccountFromConfigSection, deleteAccountFromConfigSection,
@ -35,8 +36,19 @@ export const slackPlugin: ChannelPlugin<ResolvedSlackAccount> = {
pairing: { pairing: {
idLabel: "slackUserId", idLabel: "slackUserId",
normalizeAllowEntry: (entry) => entry.replace(/^(slack|user):/i, ""), normalizeAllowEntry: (entry) => entry.replace(/^(slack|user):/i, ""),
notifyApproval: async ({ id }) => { notifyApproval: async ({ cfg, id }) => {
await sendMessageSlack(`user:${id}`, PAIRING_APPROVED_MESSAGE); const account = resolveSlackAccount({ cfg, accountId: DEFAULT_ACCOUNT_ID });
const tokenOverride = resolveSlackTokenOverride({
botToken: account.botToken,
userToken: account.userToken,
userTokenReadOnly: account.config.userTokenReadOnly,
operation: "write",
});
await sendMessageSlack(
`user:${id}`,
PAIRING_APPROVED_MESSAGE,
tokenOverride ? { token: tokenOverride } : undefined,
);
}, },
}, },
capabilities: { capabilities: {
@ -225,20 +237,36 @@ export const slackPlugin: ChannelPlugin<ResolvedSlackAccount> = {
} }
return { ok: true, to: trimmed }; 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 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, { const result = await send(to, text, {
threadTs: replyToId ?? undefined, threadTs: replyToId ?? undefined,
accountId: accountId ?? undefined, accountId: accountId ?? undefined,
...(tokenOverride ? { token: tokenOverride } : {}),
}); });
return { channel: "slack", ...result }; 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 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, { const result = await send(to, text, {
mediaUrl, mediaUrl,
threadTs: replyToId ?? undefined, threadTs: replyToId ?? undefined,
accountId: accountId ?? undefined, accountId: accountId ?? undefined,
...(tokenOverride ? { token: tokenOverride } : {}),
}); });
return { channel: "slack", ...result }; return { channel: "slack", ...result };
}, },

View File

@ -204,6 +204,8 @@ const FIELD_LABELS: Record<string, string> = {
"channels.discord.token": "Discord Bot Token", "channels.discord.token": "Discord Bot Token",
"channels.slack.botToken": "Slack Bot Token", "channels.slack.botToken": "Slack Bot Token",
"channels.slack.appToken": "Slack App 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.historyScope": "Slack Thread History Scope",
"channels.slack.thread.inheritParent": "Slack Thread Parent Inheritance", "channels.slack.thread.inheritParent": "Slack Thread Parent Inheritance",
"channels.signal.account": "Signal Account", "channels.signal.account": "Signal Account",

View File

@ -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);
});
});

View File

@ -80,6 +80,9 @@ export type SlackAccountConfig = {
enabled?: boolean; enabled?: boolean;
botToken?: string; botToken?: string;
appToken?: 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). */ /** Allow bot-authored messages to trigger replies (default: false). */
allowBots?: boolean; allowBots?: boolean;
/** Default mention requirement for channel messages (default: true). */ /** Default mention requirement for channel messages (default: true). */

View File

@ -220,6 +220,8 @@ export const SlackAccountSchema = z.object({
configWrites: z.boolean().optional(), configWrites: z.boolean().optional(),
botToken: z.string().optional(), botToken: z.string().optional(),
appToken: z.string().optional(), appToken: z.string().optional(),
userToken: z.string().optional(),
userTokenReadOnly: z.boolean().optional().default(true),
allowBots: z.boolean().optional(), allowBots: z.boolean().optional(),
requireMention: z.boolean().optional(), requireMention: z.boolean().optional(),
groupPolicy: GroupPolicySchema.optional().default("allowlist"), groupPolicy: GroupPolicySchema.optional().default("allowlist"),

View File

@ -1,7 +1,7 @@
import type { ClawdbotConfig } from "../config/config.js"; import type { ClawdbotConfig } from "../config/config.js";
import type { SlackAccountConfig } from "../config/types.js"; import type { SlackAccountConfig } from "../config/types.js";
import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "../routing/session-key.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"; export type SlackTokenSource = "env" | "config" | "none";
@ -11,6 +11,7 @@ export type ResolvedSlackAccount = {
name?: string; name?: string;
botToken?: string; botToken?: string;
appToken?: string; appToken?: string;
userToken?: string;
botTokenSource: SlackTokenSource; botTokenSource: SlackTokenSource;
appTokenSource: SlackTokenSource; appTokenSource: SlackTokenSource;
config: SlackAccountConfig; config: SlackAccountConfig;
@ -75,6 +76,7 @@ export function resolveSlackAccount(params: {
const envApp = allowEnv ? resolveSlackAppToken(process.env.SLACK_APP_TOKEN) : undefined; const envApp = allowEnv ? resolveSlackAppToken(process.env.SLACK_APP_TOKEN) : undefined;
const configBot = resolveSlackBotToken(merged.botToken); const configBot = resolveSlackBotToken(merged.botToken);
const configApp = resolveSlackAppToken(merged.appToken); const configApp = resolveSlackAppToken(merged.appToken);
const configUser = resolveSlackUserToken(merged.userToken);
const botToken = configBot ?? envBot; const botToken = configBot ?? envBot;
const appToken = configApp ?? envApp; const appToken = configApp ?? envApp;
const botTokenSource: SlackTokenSource = configBot ? "config" : envBot ? "env" : "none"; const botTokenSource: SlackTokenSource = configBot ? "config" : envBot ? "env" : "none";
@ -86,6 +88,7 @@ export function resolveSlackAccount(params: {
name: merged.name?.trim() || undefined, name: merged.name?.trim() || undefined,
botToken, botToken,
appToken, appToken,
userToken: configUser,
botTokenSource, botTokenSource,
appTokenSource, appTokenSource,
config: merged, config: merged,

View File

@ -10,3 +10,44 @@ export function resolveSlackBotToken(raw?: string): string | undefined {
export function resolveSlackAppToken(raw?: string): string | undefined { export function resolveSlackAppToken(raw?: string): string | undefined {
return normalizeSlackToken(raw); 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;
}