feat(slack): add userToken for read-only access to DMs and private channels (#981)

- 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
This commit is contained in:
Josh Lehman
2026-01-15 16:11:33 -08:00
committed by GitHub
parent 8312a19f02
commit a139d35fa2
9 changed files with 459 additions and 57 deletions

View File

@@ -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["<user_id>"].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.<id>.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.<id>.allowBots`.
- Warning: If you allow replies to other bots (`channels.slack.allowBots=true` or `channels.slack.channels.<id>.allowBots=true`), prevent bot-to-bot reply loops with `requireMention`, `channels.slack.channels.<id>.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.

View File

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

View File

@@ -78,10 +78,32 @@ export async function handleSlackAction(
): Promise<AgentToolResult<unknown>> {
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 });
}

View File

@@ -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<ResolvedSlackAccount> = {
id: "slack",
meta: {
@@ -36,7 +52,21 @@ export const slackPlugin: ChannelPlugin<ResolvedSlackAccount> = {
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<ResolvedSlackAccount> = {
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<ChannelMessageActionName>(["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<ResolvedSlackAccount> = {
}
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 };
},

View File

@@ -204,6 +204,8 @@ const FIELD_LABELS: Record<string, string> = {
"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",

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;
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). */

View File

@@ -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"),