fix: refine Slack user token routing (#981) (thanks @jalehman)
This commit is contained in:
parent
bb568b2a45
commit
dcfde2822a
@ -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.
|
||||
|
||||
2
Peekaboo
2
Peekaboo
@ -1 +1 @@
|
||||
Subproject commit c1243a7978b71137060a82aa4a451e1720e36aff
|
||||
Subproject commit 95ad7532c15b6b4e67cdf2c8c5dea399c8483fe1
|
||||
@ -33,6 +33,7 @@ Minimal config:
|
||||
- `app_mention`
|
||||
- `reaction_added`, `reaction_removed`
|
||||
- `member_joined_channel`, `member_left_channel`
|
||||
- `channel_id_changed`
|
||||
- `channel_rename`
|
||||
- `pin_added`, `pin_removed`
|
||||
6) Invite the bot to channels you want it to read.
|
||||
@ -112,6 +113,21 @@ Example with userTokenReadOnly explicitly set (allow user token writes):
|
||||
## History context
|
||||
- `channels.slack.historyLimit` (or `channels.slack.accounts.*.historyLimit`) controls how many recent channel/group messages are wrapped into the prompt.
|
||||
- Falls back to `messages.groupChat.historyLimit`. Set `0` to disable (default 50).
|
||||
- DM history can be limited with `channels.slack.dmHistoryLimit` (user turns). Per-user overrides: `channels.slack.dms["<user_id>"].historyLimit`.
|
||||
|
||||
## Config writes
|
||||
By default, Slack is allowed to write config updates triggered by channel events or `/config set|unset`.
|
||||
|
||||
This happens when:
|
||||
- Slack emits `channel_id_changed` (e.g. Slack Connect channel ID changes). Clawdbot can migrate `channels.slack.channels` automatically.
|
||||
- You run `/config set` or `/config unset` in Slack (requires `commands.config: true`).
|
||||
|
||||
Disable with:
|
||||
```json5
|
||||
{
|
||||
channels: { slack: { configWrites: false } }
|
||||
}
|
||||
```
|
||||
|
||||
## Manifest (optional)
|
||||
Use this Slack app manifest to create the app quickly (adjust the name/command if you want). Include the
|
||||
@ -196,6 +212,7 @@ user scopes if you plan to configure a user token.
|
||||
"reaction_removed",
|
||||
"member_joined_channel",
|
||||
"member_left_channel",
|
||||
"channel_id_changed",
|
||||
"channel_rename",
|
||||
"pin_added",
|
||||
"pin_removed"
|
||||
@ -326,6 +343,11 @@ By default, Clawdbot replies in the main channel. Use `channels.slack.replyToMod
|
||||
|
||||
The mode applies to both auto-replies and agent tool calls (`slack sendMessage`).
|
||||
|
||||
### Thread session isolation
|
||||
Slack thread sessions are isolated by default. Configure with:
|
||||
- `channels.slack.thread.historyScope`: `thread` (default) keeps per-thread history; `channel` shares history across the channel.
|
||||
- `channels.slack.thread.inheritParent`: `false` (default) starts a clean thread session; `true` copies the parent channel transcript into the thread session.
|
||||
|
||||
### Manual threading tags
|
||||
For fine-grained control, use these tags in agent responses:
|
||||
- `[[reply_to_current]]` — reply to the triggering message (start/continue thread).
|
||||
@ -390,4 +412,5 @@ Slack tool actions can be gated with `channels.slack.actions.*`:
|
||||
- Bot-authored messages are ignored by default; enable via `channels.slack.allowBots` or `channels.slack.channels.<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.
|
||||
|
||||
@ -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`.
|
||||
|
||||
@ -373,6 +373,28 @@ describe("handleSlackAction", () => {
|
||||
expect(opts?.token).toBe("xoxp-1");
|
||||
});
|
||||
|
||||
it("uses user token for pin reads when available", async () => {
|
||||
const cfg = {
|
||||
channels: { slack: { botToken: "xoxb-1", userToken: "xoxp-1" } },
|
||||
} as ClawdbotConfig;
|
||||
listSlackPins.mockClear();
|
||||
listSlackPins.mockResolvedValueOnce([]);
|
||||
await handleSlackAction({ action: "listPins", channelId: "C1" }, cfg);
|
||||
const [, opts] = listSlackPins.mock.calls[0] ?? [];
|
||||
expect(opts?.token).toBe("xoxp-1");
|
||||
});
|
||||
|
||||
it("uses user token for member info reads when available", async () => {
|
||||
const cfg = {
|
||||
channels: { slack: { botToken: "xoxb-1", userToken: "xoxp-1" } },
|
||||
} as ClawdbotConfig;
|
||||
getSlackMemberInfo.mockClear();
|
||||
getSlackMemberInfo.mockResolvedValueOnce({});
|
||||
await handleSlackAction({ action: "memberInfo", userId: "U1" }, cfg);
|
||||
const [, opts] = getSlackMemberInfo.mock.calls[0] ?? [];
|
||||
expect(opts?.token).toBe("xoxp-1");
|
||||
});
|
||||
|
||||
it("falls back to bot token for reads when user token missing", async () => {
|
||||
const cfg = {
|
||||
channels: { slack: { botToken: "xoxb-1" } },
|
||||
@ -405,4 +427,16 @@ describe("handleSlackAction", () => {
|
||||
const [, , opts] = sendSlackMessage.mock.calls[0] ?? [];
|
||||
expect(opts?.token).toBe("xoxp-1");
|
||||
});
|
||||
|
||||
it("prefers bot token for writes when both tokens are available", async () => {
|
||||
const cfg = {
|
||||
channels: {
|
||||
slack: { botToken: "xoxb-1", userToken: "xoxp-1", userTokenReadOnly: false },
|
||||
},
|
||||
} as ClawdbotConfig;
|
||||
sendSlackMessage.mockClear();
|
||||
await handleSlackAction({ action: "sendMessage", to: "channel:C1", content: "Hello" }, cfg);
|
||||
const [, , opts] = sendSlackMessage.mock.calls[0] ?? [];
|
||||
expect(opts?.token).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
@ -2,6 +2,7 @@ import type { AgentToolResult } from "@mariozechner/pi-agent-core";
|
||||
|
||||
import type { ClawdbotConfig } from "../../config/config.js";
|
||||
import { resolveSlackAccount } from "../../slack/accounts.js";
|
||||
import { resolveSlackTokenOverride } from "../../slack/token.js";
|
||||
import {
|
||||
deleteSlackMessage,
|
||||
editSlackMessage,
|
||||
@ -81,20 +82,14 @@ export async function handleSlackAction(
|
||||
const account = resolveSlackAccount({ cfg, accountId });
|
||||
const actionConfig = account.actions ?? cfg.channels?.slack?.actions;
|
||||
const isActionEnabled = createActionGate(actionConfig);
|
||||
const userToken = account.config.userToken?.trim() || undefined;
|
||||
const botToken = account.botToken?.trim();
|
||||
const allowUserWrites = account.config.userTokenReadOnly === false;
|
||||
|
||||
// Choose the most appropriate token for Slack read/write operations.
|
||||
const getTokenForOperation = (operation: "read" | "write") => {
|
||||
if (operation === "read") return userToken ?? botToken;
|
||||
if (!allowUserWrites) return botToken;
|
||||
return botToken ?? userToken;
|
||||
};
|
||||
|
||||
const buildActionOpts = (operation: "read" | "write") => {
|
||||
const token = getTokenForOperation(operation);
|
||||
const tokenOverride = token && token !== botToken ? token : undefined;
|
||||
const tokenOverride = resolveSlackTokenOverride({
|
||||
botToken: account.botToken,
|
||||
userToken: account.userToken,
|
||||
userTokenReadOnly: account.config.userTokenReadOnly,
|
||||
operation,
|
||||
});
|
||||
if (!accountId && !tokenOverride) return undefined;
|
||||
return {
|
||||
...(accountId ? { accountId } : {}),
|
||||
@ -260,7 +255,7 @@ export async function handleSlackAction(
|
||||
}
|
||||
return jsonResult({ ok: true });
|
||||
}
|
||||
const pins = writeOpts
|
||||
const pins = readOpts
|
||||
? await listSlackPins(channelId, readOpts)
|
||||
: await listSlackPins(channelId);
|
||||
const normalizedPins = pins.map((pin) => {
|
||||
@ -280,7 +275,7 @@ export async function handleSlackAction(
|
||||
throw new Error("Slack member info is disabled.");
|
||||
}
|
||||
const userId = readStringParam(params, "userId", { required: true });
|
||||
const info = writeOpts
|
||||
const info = readOpts
|
||||
? await getSlackMemberInfo(userId, readOpts)
|
||||
: await getSlackMemberInfo(userId);
|
||||
return jsonResult({ ok: true, info });
|
||||
|
||||
@ -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 };
|
||||
},
|
||||
|
||||
@ -1,9 +1,5 @@
|
||||
import { createActionGate, readNumberParam, readStringParam } from "../../agents/tools/common.js";
|
||||
import { handleSlackAction } from "../../agents/tools/slack-actions.js";
|
||||
import { loadConfig } from "../../config/config.js";
|
||||
import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "../../routing/session-key.js";
|
||||
import {
|
||||
listEnabledSlackAccounts,
|
||||
listSlackAccountIds,
|
||||
type ResolvedSlackAccount,
|
||||
resolveDefaultSlackAccountId,
|
||||
@ -11,6 +7,7 @@ import {
|
||||
} from "../../slack/accounts.js";
|
||||
import { probeSlack } from "../../slack/probe.js";
|
||||
import { sendMessageSlack } from "../../slack/send.js";
|
||||
import { resolveSlackTokenOverride } from "../../slack/token.js";
|
||||
import { getChatChannelMeta } from "../registry.js";
|
||||
import {
|
||||
deleteAccountFromConfigSection,
|
||||
@ -25,23 +22,11 @@ import {
|
||||
applyAccountNameToChannelSection,
|
||||
migrateBaseNameToDefaultAccount,
|
||||
} from "./setup-helpers.js";
|
||||
import type { ChannelMessageActionName, ChannelPlugin } from "./types.js";
|
||||
import { createSlackActions } from "./slack.actions.js";
|
||||
import type { ChannelPlugin } from "./types.js";
|
||||
|
||||
const meta = getChatChannelMeta("slack");
|
||||
|
||||
// Select the appropriate Slack token for read/write operations.
|
||||
function getTokenForOperation(
|
||||
account: ResolvedSlackAccount,
|
||||
operation: "read" | "write",
|
||||
): string | undefined {
|
||||
const userToken = account.config.userToken?.trim() || undefined;
|
||||
const botToken = account.botToken?.trim();
|
||||
const allowUserWrites = account.config.userTokenReadOnly === false;
|
||||
if (operation === "read") return userToken ?? botToken;
|
||||
if (!allowUserWrites) return botToken;
|
||||
return botToken ?? userToken;
|
||||
}
|
||||
|
||||
export const slackPlugin: ChannelPlugin<ResolvedSlackAccount> = {
|
||||
id: "slack",
|
||||
meta: {
|
||||
@ -51,22 +36,19 @@ export const slackPlugin: ChannelPlugin<ResolvedSlackAccount> = {
|
||||
pairing: {
|
||||
idLabel: "slackUserId",
|
||||
normalizeAllowEntry: (entry) => entry.replace(/^(slack|user):/i, ""),
|
||||
notifyApproval: async ({ id }) => {
|
||||
const cfg = loadConfig();
|
||||
const account = resolveSlackAccount({
|
||||
cfg,
|
||||
accountId: DEFAULT_ACCOUNT_ID,
|
||||
notifyApproval: async ({ cfg, id }) => {
|
||||
const account = resolveSlackAccount({ cfg, accountId: DEFAULT_ACCOUNT_ID });
|
||||
const tokenOverride = resolveSlackTokenOverride({
|
||||
botToken: account.botToken,
|
||||
userToken: account.userToken,
|
||||
userTokenReadOnly: account.config.userTokenReadOnly,
|
||||
operation: "write",
|
||||
});
|
||||
const token = getTokenForOperation(account, "write");
|
||||
const botToken = account.botToken?.trim();
|
||||
const tokenOverride = token && token !== botToken ? token : undefined;
|
||||
if (tokenOverride) {
|
||||
await sendMessageSlack(`user:${id}`, PAIRING_APPROVED_MESSAGE, {
|
||||
token: tokenOverride,
|
||||
});
|
||||
} else {
|
||||
await sendMessageSlack(`user:${id}`, PAIRING_APPROVED_MESSAGE);
|
||||
}
|
||||
await sendMessageSlack(
|
||||
`user:${id}`,
|
||||
PAIRING_APPROVED_MESSAGE,
|
||||
tokenOverride ? { token: tokenOverride } : undefined,
|
||||
);
|
||||
},
|
||||
},
|
||||
capabilities: {
|
||||
@ -169,197 +151,7 @@ export const slackPlugin: ChannelPlugin<ResolvedSlackAccount> = {
|
||||
messaging: {
|
||||
normalizeTarget: normalizeSlackMessagingTarget,
|
||||
},
|
||||
actions: {
|
||||
listActions: ({ cfg }) => {
|
||||
const accounts = listEnabledSlackAccounts(cfg).filter(
|
||||
(account) => account.botTokenSource !== "none",
|
||||
);
|
||||
if (accounts.length === 0) return [];
|
||||
const isActionEnabled = (key: string, defaultValue = true) => {
|
||||
for (const account of accounts) {
|
||||
const gate = createActionGate(
|
||||
(account.actions ?? cfg.channels?.slack?.actions) as Record<
|
||||
string,
|
||||
boolean | undefined
|
||||
>,
|
||||
);
|
||||
if (gate(key, defaultValue)) return true;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
const actions = new Set<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}.`);
|
||||
},
|
||||
},
|
||||
actions: createSlackActions(meta.id),
|
||||
setup: {
|
||||
resolveAccountId: ({ accountId }) => normalizeAccountId(accountId),
|
||||
applyAccountName: ({ cfg, accountId, name }) =>
|
||||
@ -448,9 +240,12 @@ export const slackPlugin: ChannelPlugin<ResolvedSlackAccount> = {
|
||||
sendText: async ({ to, text, accountId, deps, replyToId, cfg }) => {
|
||||
const send = deps?.sendSlack ?? sendMessageSlack;
|
||||
const account = resolveSlackAccount({ cfg, accountId });
|
||||
const token = getTokenForOperation(account, "write");
|
||||
const botToken = account.botToken?.trim();
|
||||
const tokenOverride = token && token !== botToken ? token : undefined;
|
||||
const tokenOverride = resolveSlackTokenOverride({
|
||||
botToken: account.botToken,
|
||||
userToken: account.userToken,
|
||||
userTokenReadOnly: account.config.userTokenReadOnly,
|
||||
operation: "write",
|
||||
});
|
||||
const result = await send(to, text, {
|
||||
threadTs: replyToId ?? undefined,
|
||||
accountId: accountId ?? undefined,
|
||||
@ -461,9 +256,12 @@ export const slackPlugin: ChannelPlugin<ResolvedSlackAccount> = {
|
||||
sendMedia: async ({ to, text, mediaUrl, accountId, deps, replyToId, cfg }) => {
|
||||
const send = deps?.sendSlack ?? sendMessageSlack;
|
||||
const account = resolveSlackAccount({ cfg, accountId });
|
||||
const token = getTokenForOperation(account, "write");
|
||||
const botToken = account.botToken?.trim();
|
||||
const tokenOverride = token && token !== botToken ? token : undefined;
|
||||
const tokenOverride = resolveSlackTokenOverride({
|
||||
botToken: account.botToken,
|
||||
userToken: account.userToken,
|
||||
userTokenReadOnly: account.config.userTokenReadOnly,
|
||||
operation: "write",
|
||||
});
|
||||
const result = await send(to, text, {
|
||||
mediaUrl,
|
||||
threadTs: replyToId ?? undefined,
|
||||
|
||||
@ -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