Compare commits
2 Commits
main
...
feature/sl
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dcfde2822a | ||
|
|
bb568b2a45 |
@ -2,6 +2,7 @@
|
||||
|
||||
## 2026.1.15 (unreleased)
|
||||
|
||||
- Slack: add optional user token routing for read access with safe write fallback. (#981) — thanks @jalehman.
|
||||
- Fix: list model picker entries as provider/model pairs for explicit selection. (#970) — thanks @mcinteerj.
|
||||
- Daemon: fix profile-aware service label resolution (env-driven) and add coverage for launchd/systemd/schtasks. (#969) — thanks @bjesuiter.
|
||||
- Daemon: share profile/state-dir resolution across service helpers and honor `CLAWDBOT_STATE_DIR` for Windows task scripts.
|
||||
|
||||
@ -27,7 +27,8 @@ 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`
|
||||
@ -35,9 +36,9 @@ Minimal config:
|
||||
- `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,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
|
||||
- `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).
|
||||
@ -83,7 +130,8 @@ Disable with:
|
||||
```
|
||||
|
||||
## 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 +181,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"
|
||||
]
|
||||
}
|
||||
},
|
||||
@ -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
|
||||
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 +251,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)
|
||||
@ -320,6 +394,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`.
|
||||
|
||||
@ -1052,6 +1052,8 @@ Slack runs in Socket Mode and requires both a bot token and app token:
|
||||
enabled: true,
|
||||
botToken: "xoxb-...",
|
||||
appToken: "xapp-...",
|
||||
userToken: "xoxp-...", // optional (read-only by default)
|
||||
userTokenReadOnly: true, // default: true
|
||||
dm: {
|
||||
enabled: true,
|
||||
policy: "pairing", // pairing | allowlist | open | disabled
|
||||
@ -1102,6 +1104,7 @@ Slack runs in Socket Mode and requires both a bot token and app token:
|
||||
Multi-account support lives under `channels.slack.accounts` (see the multi-account section above). Env tokens only apply to the default account.
|
||||
|
||||
Clawdbot starts Slack when the provider is enabled and both tokens are set (via config or `SLACK_BOT_TOKEN` + `SLACK_APP_TOKEN`). Use `user:<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`).
|
||||
|
||||
Bot-authored messages are ignored by default. Enable with `channels.slack.allowBots` or `channels.slack.channels.<id>.allowBots`.
|
||||
|
||||
@ -361,4 +361,82 @@ 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("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();
|
||||
});
|
||||
});
|
||||
|
||||
@ -2,6 +2,7 @@ import type { AgentToolResult } from "@mariozechner/pi-agent-core";
|
||||
|
||||
import type { ClawdbotConfig } from "../../config/config.js";
|
||||
import { resolveSlackAccount } from "../../slack/accounts.js";
|
||||
import { resolveSlackTokenOverride } from "../../slack/token.js";
|
||||
import {
|
||||
deleteSlackMessage,
|
||||
editSlackMessage,
|
||||
@ -78,11 +79,27 @@ 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 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 (!isActionEnabled("reactions")) {
|
||||
throw new Error("Slack reactions are disabled.");
|
||||
@ -94,28 +111,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 +152,7 @@ export async function handleSlackAction(
|
||||
context,
|
||||
);
|
||||
const result = await sendSlackMessage(to, content, {
|
||||
accountId: accountId ?? undefined,
|
||||
...writeOpts,
|
||||
mediaUrl: mediaUrl ?? undefined,
|
||||
threadTs: threadTs ?? undefined,
|
||||
});
|
||||
@ -162,8 +179,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 +193,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 +210,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 +237,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 +248,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 = readOpts
|
||||
? await listSlackPins(channelId, readOpts)
|
||||
: await listSlackPins(channelId);
|
||||
const normalizedPins = pins.map((pin) => {
|
||||
const message = pin.message
|
||||
@ -258,8 +275,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 = readOpts
|
||||
? await getSlackMemberInfo(userId, readOpts)
|
||||
: await getSlackMemberInfo(userId);
|
||||
return jsonResult({ ok: true, info });
|
||||
}
|
||||
@ -268,7 +285,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 });
|
||||
}
|
||||
|
||||
|
||||
@ -1,4 +1,6 @@
|
||||
import { resolveSlackAccount } from "../../../slack/accounts.js";
|
||||
import { sendMessageSlack } from "../../../slack/send.js";
|
||||
import { resolveSlackTokenOverride } from "../../../slack/token.js";
|
||||
import type { ChannelOutboundAdapter } from "../types.js";
|
||||
|
||||
export const slackOutbound: ChannelOutboundAdapter = {
|
||||
@ -15,20 +17,36 @@ export const slackOutbound: ChannelOutboundAdapter = {
|
||||
}
|
||||
return { ok: true, to: trimmed };
|
||||
},
|
||||
sendText: async ({ to, text, accountId, deps, replyToId }) => {
|
||||
sendText: async ({ cfg, to, text, accountId, deps, replyToId }) => {
|
||||
const send = deps?.sendSlack ?? sendMessageSlack;
|
||||
const account = resolveSlackAccount({ cfg, accountId });
|
||||
const tokenOverride = resolveSlackTokenOverride({
|
||||
botToken: account.botToken,
|
||||
userToken: account.userToken,
|
||||
userTokenReadOnly: account.config.userTokenReadOnly,
|
||||
operation: "write",
|
||||
});
|
||||
const result = await send(to, text, {
|
||||
threadTs: replyToId ?? undefined,
|
||||
accountId: accountId ?? undefined,
|
||||
...(tokenOverride ? { token: tokenOverride } : {}),
|
||||
});
|
||||
return { channel: "slack", ...result };
|
||||
},
|
||||
sendMedia: async ({ to, text, mediaUrl, accountId, deps, replyToId }) => {
|
||||
sendMedia: async ({ cfg, to, text, mediaUrl, accountId, deps, replyToId }) => {
|
||||
const send = deps?.sendSlack ?? sendMessageSlack;
|
||||
const account = resolveSlackAccount({ cfg, accountId });
|
||||
const tokenOverride = resolveSlackTokenOverride({
|
||||
botToken: account.botToken,
|
||||
userToken: account.userToken,
|
||||
userTokenReadOnly: account.config.userTokenReadOnly,
|
||||
operation: "write",
|
||||
});
|
||||
const result = await send(to, text, {
|
||||
mediaUrl,
|
||||
threadTs: replyToId ?? undefined,
|
||||
accountId: accountId ?? undefined,
|
||||
...(tokenOverride ? { token: tokenOverride } : {}),
|
||||
});
|
||||
return { channel: "slack", ...result };
|
||||
},
|
||||
|
||||
@ -7,6 +7,7 @@ import {
|
||||
} from "../../slack/accounts.js";
|
||||
import { probeSlack } from "../../slack/probe.js";
|
||||
import { sendMessageSlack } from "../../slack/send.js";
|
||||
import { resolveSlackTokenOverride } from "../../slack/token.js";
|
||||
import { getChatChannelMeta } from "../registry.js";
|
||||
import {
|
||||
deleteAccountFromConfigSection,
|
||||
@ -35,8 +36,19 @@ export const slackPlugin: ChannelPlugin<ResolvedSlackAccount> = {
|
||||
pairing: {
|
||||
idLabel: "slackUserId",
|
||||
normalizeAllowEntry: (entry) => entry.replace(/^(slack|user):/i, ""),
|
||||
notifyApproval: async ({ id }) => {
|
||||
await sendMessageSlack(`user:${id}`, PAIRING_APPROVED_MESSAGE);
|
||||
notifyApproval: async ({ cfg, id }) => {
|
||||
const account = resolveSlackAccount({ cfg, accountId: DEFAULT_ACCOUNT_ID });
|
||||
const tokenOverride = resolveSlackTokenOverride({
|
||||
botToken: account.botToken,
|
||||
userToken: account.userToken,
|
||||
userTokenReadOnly: account.config.userTokenReadOnly,
|
||||
operation: "write",
|
||||
});
|
||||
await sendMessageSlack(
|
||||
`user:${id}`,
|
||||
PAIRING_APPROVED_MESSAGE,
|
||||
tokenOverride ? { token: tokenOverride } : undefined,
|
||||
);
|
||||
},
|
||||
},
|
||||
capabilities: {
|
||||
@ -225,20 +237,36 @@ 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 tokenOverride = resolveSlackTokenOverride({
|
||||
botToken: account.botToken,
|
||||
userToken: account.userToken,
|
||||
userTokenReadOnly: account.config.userTokenReadOnly,
|
||||
operation: "write",
|
||||
});
|
||||
const result = await send(to, text, {
|
||||
threadTs: replyToId ?? undefined,
|
||||
accountId: accountId ?? undefined,
|
||||
...(tokenOverride ? { token: tokenOverride } : {}),
|
||||
});
|
||||
return { channel: "slack", ...result };
|
||||
},
|
||||
sendMedia: async ({ to, text, mediaUrl, accountId, deps, replyToId }) => {
|
||||
sendMedia: async ({ to, text, mediaUrl, accountId, deps, replyToId, cfg }) => {
|
||||
const send = deps?.sendSlack ?? sendMessageSlack;
|
||||
const account = resolveSlackAccount({ cfg, accountId });
|
||||
const tokenOverride = resolveSlackTokenOverride({
|
||||
botToken: account.botToken,
|
||||
userToken: account.userToken,
|
||||
userTokenReadOnly: account.config.userTokenReadOnly,
|
||||
operation: "write",
|
||||
});
|
||||
const result = await send(to, text, {
|
||||
mediaUrl,
|
||||
threadTs: replyToId ?? undefined,
|
||||
accountId: accountId ?? undefined,
|
||||
...(tokenOverride ? { token: tokenOverride } : {}),
|
||||
});
|
||||
return { channel: "slack", ...result };
|
||||
},
|
||||
|
||||
@ -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",
|
||||
|
||||
37
src/config/slack-token-validation.test.ts
Normal file
37
src/config/slack-token-validation.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
@ -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). */
|
||||
|
||||
@ -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"),
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
import type { ClawdbotConfig } from "../config/config.js";
|
||||
import type { SlackAccountConfig } from "../config/types.js";
|
||||
import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "../routing/session-key.js";
|
||||
import { resolveSlackAppToken, resolveSlackBotToken } from "./token.js";
|
||||
import { resolveSlackAppToken, resolveSlackBotToken, resolveSlackUserToken } from "./token.js";
|
||||
|
||||
export type SlackTokenSource = "env" | "config" | "none";
|
||||
|
||||
@ -11,6 +11,7 @@ export type ResolvedSlackAccount = {
|
||||
name?: string;
|
||||
botToken?: string;
|
||||
appToken?: string;
|
||||
userToken?: string;
|
||||
botTokenSource: SlackTokenSource;
|
||||
appTokenSource: SlackTokenSource;
|
||||
config: SlackAccountConfig;
|
||||
@ -75,6 +76,7 @@ export function resolveSlackAccount(params: {
|
||||
const envApp = allowEnv ? resolveSlackAppToken(process.env.SLACK_APP_TOKEN) : undefined;
|
||||
const configBot = resolveSlackBotToken(merged.botToken);
|
||||
const configApp = resolveSlackAppToken(merged.appToken);
|
||||
const configUser = resolveSlackUserToken(merged.userToken);
|
||||
const botToken = configBot ?? envBot;
|
||||
const appToken = configApp ?? envApp;
|
||||
const botTokenSource: SlackTokenSource = configBot ? "config" : envBot ? "env" : "none";
|
||||
@ -86,6 +88,7 @@ export function resolveSlackAccount(params: {
|
||||
name: merged.name?.trim() || undefined,
|
||||
botToken,
|
||||
appToken,
|
||||
userToken: configUser,
|
||||
botTokenSource,
|
||||
appTokenSource,
|
||||
config: merged,
|
||||
|
||||
@ -10,3 +10,44 @@ export function resolveSlackBotToken(raw?: string): string | undefined {
|
||||
export function resolveSlackAppToken(raw?: string): string | undefined {
|
||||
return normalizeSlackToken(raw);
|
||||
}
|
||||
|
||||
export function resolveSlackUserToken(raw?: string): string | undefined {
|
||||
return normalizeSlackToken(raw);
|
||||
}
|
||||
|
||||
export type SlackTokenOperation = "read" | "write";
|
||||
|
||||
export type SlackTokenSelection = {
|
||||
botToken?: string;
|
||||
userToken?: string;
|
||||
userTokenReadOnly?: boolean;
|
||||
operation: SlackTokenOperation;
|
||||
};
|
||||
|
||||
function selectSlackTokenNormalized(params: SlackTokenSelection): string | undefined {
|
||||
const allowUserWrites = params.userTokenReadOnly === false;
|
||||
if (params.operation === "read") return params.userToken ?? params.botToken;
|
||||
if (!allowUserWrites) return params.botToken;
|
||||
return params.botToken ?? params.userToken;
|
||||
}
|
||||
|
||||
export function selectSlackToken(params: SlackTokenSelection): string | undefined {
|
||||
const botToken = resolveSlackBotToken(params.botToken);
|
||||
const userToken = resolveSlackUserToken(params.userToken);
|
||||
return selectSlackTokenNormalized({
|
||||
...params,
|
||||
botToken,
|
||||
userToken,
|
||||
});
|
||||
}
|
||||
|
||||
export function resolveSlackTokenOverride(params: SlackTokenSelection): string | undefined {
|
||||
const botToken = resolveSlackBotToken(params.botToken);
|
||||
const userToken = resolveSlackUserToken(params.userToken);
|
||||
const token = selectSlackTokenNormalized({
|
||||
...params,
|
||||
botToken,
|
||||
userToken,
|
||||
});
|
||||
return token && token !== botToken ? token : undefined;
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user