feat: add slack multi-account routing

This commit is contained in:
Peter Steinberger
2026-01-08 08:49:16 +01:00
parent 00c1403f5c
commit 8930ec32cb
31 changed files with 878 additions and 93 deletions

View File

@@ -17,6 +17,16 @@ export type ResolvedSlackAccount = {
botTokenSource: SlackTokenSource;
appTokenSource: SlackTokenSource;
config: SlackAccountConfig;
groupPolicy?: SlackAccountConfig["groupPolicy"];
textChunkLimit?: SlackAccountConfig["textChunkLimit"];
mediaMaxMb?: SlackAccountConfig["mediaMaxMb"];
reactionNotifications?: SlackAccountConfig["reactionNotifications"];
reactionAllowlist?: SlackAccountConfig["reactionAllowlist"];
replyToMode?: SlackAccountConfig["replyToMode"];
actions?: SlackAccountConfig["actions"];
slashCommand?: SlackAccountConfig["slashCommand"];
dm?: SlackAccountConfig["dm"];
channels?: SlackAccountConfig["channels"];
};
function listConfiguredAccountIds(cfg: ClawdbotConfig): string[] {
@@ -66,31 +76,26 @@ export function resolveSlackAccount(params: {
const accountEnabled = merged.enabled !== false;
const enabled = baseEnabled && accountEnabled;
const allowEnv = accountId === DEFAULT_ACCOUNT_ID;
const botToken = resolveSlackBotToken(
merged.botToken ??
(allowEnv ? process.env.SLACK_BOT_TOKEN : undefined) ??
(allowEnv ? params.cfg.slack?.botToken : undefined),
);
const appToken = resolveSlackAppToken(
merged.appToken ??
(allowEnv ? process.env.SLACK_APP_TOKEN : undefined) ??
(allowEnv ? params.cfg.slack?.appToken : undefined),
);
const botTokenSource: SlackTokenSource = merged.botToken
const envBot = allowEnv
? resolveSlackBotToken(process.env.SLACK_BOT_TOKEN)
: undefined;
const envApp = allowEnv
? resolveSlackAppToken(process.env.SLACK_APP_TOKEN)
: undefined;
const configBot = resolveSlackBotToken(merged.botToken);
const configApp = resolveSlackAppToken(merged.appToken);
const botToken = configBot ?? envBot;
const appToken = configApp ?? envApp;
const botTokenSource: SlackTokenSource = configBot
? "config"
: allowEnv && process.env.SLACK_BOT_TOKEN
: envBot
? "env"
: allowEnv && params.cfg.slack?.botToken
? "config"
: "none";
const appTokenSource: SlackTokenSource = merged.appToken
: "none";
const appTokenSource: SlackTokenSource = configApp
? "config"
: allowEnv && process.env.SLACK_APP_TOKEN
: envApp
? "env"
: allowEnv && params.cfg.slack?.appToken
? "config"
: "none";
: "none";
return {
accountId,
@@ -101,6 +106,16 @@ export function resolveSlackAccount(params: {
botTokenSource,
appTokenSource,
config: merged,
groupPolicy: merged.groupPolicy,
textChunkLimit: merged.textChunkLimit,
mediaMaxMb: merged.mediaMaxMb,
reactionNotifications: merged.reactionNotifications,
reactionAllowlist: merged.reactionAllowlist,
replyToMode: merged.replyToMode,
actions: merged.actions,
slashCommand: merged.slashCommand,
dm: merged.dm,
channels: merged.channels,
};
}

View File

@@ -1,10 +1,13 @@
import { WebClient } from "@slack/web-api";
import { loadConfig } from "../config/config.js";
import { logVerbose } from "../globals.js";
import { resolveSlackAccount } from "./accounts.js";
import { sendMessageSlack } from "./send.js";
import { resolveSlackBotToken } from "./token.js";
export type SlackActionClientOpts = {
accountId?: string;
token?: string;
client?: WebClient;
};
@@ -28,12 +31,16 @@ export type SlackPin = {
file?: { id?: string; name?: string };
};
function resolveToken(explicit?: string) {
const cfgToken = loadConfig().slack?.botToken;
const token = resolveSlackBotToken(
explicit ?? process.env.SLACK_BOT_TOKEN ?? cfgToken ?? undefined,
);
function resolveToken(explicit?: string, accountId?: string) {
const cfg = loadConfig();
const account = resolveSlackAccount({ cfg, accountId });
const token = resolveSlackBotToken(explicit ?? account.botToken ?? undefined);
if (!token) {
logVerbose(
`slack actions: missing bot token for account=${account.accountId} explicit=${Boolean(
explicit,
)} source=${account.botTokenSource ?? "unknown"}`,
);
throw new Error(
"SLACK_BOT_TOKEN or slack.botToken is required for Slack actions",
);
@@ -50,7 +57,7 @@ function normalizeEmoji(raw: string) {
}
async function getClient(opts: SlackActionClientOpts = {}) {
const token = resolveToken(opts.token);
const token = resolveToken(opts.token, opts.accountId);
return opts.client ?? new WebClient(token);
}
@@ -141,6 +148,7 @@ export async function sendSlackMessage(
opts: SlackActionClientOpts & { mediaUrl?: string } = {},
) {
return await sendMessageSlack(to, content, {
accountId: opts.accountId,
token: opts.token,
mediaUrl: opts.mediaUrl,
client: opts.client,

View File

@@ -1,3 +1,9 @@
export {
listEnabledSlackAccounts,
listSlackAccountIds,
resolveDefaultSlackAccountId,
resolveSlackAccount,
} from "./accounts.js";
export {
deleteSlackMessage,
editSlackMessage,

View File

@@ -80,6 +80,7 @@ type SlackMessageEvent = {
user?: string;
bot_id?: string;
subtype?: string;
username?: string;
text?: string;
ts?: string;
thread_ts?: string;
@@ -93,6 +94,7 @@ type SlackAppMentionEvent = {
type: "app_mention";
user?: string;
bot_id?: string;
username?: string;
text?: string;
ts?: string;
thread_ts?: string;
@@ -170,6 +172,7 @@ type SlackThreadBroadcastEvent = {
type SlackChannelConfigResolved = {
allowed: boolean;
requireMention: boolean;
allowBots?: boolean;
users?: Array<string | number>;
skills?: string[];
systemPrompt?: string;
@@ -294,6 +297,7 @@ function resolveSlackChannelConfig(params: {
enabled?: boolean;
allow?: boolean;
requireMention?: boolean;
allowBots?: boolean;
users?: Array<string | number>;
skills?: string[];
systemPrompt?: string;
@@ -317,6 +321,7 @@ function resolveSlackChannelConfig(params: {
enabled?: boolean;
allow?: boolean;
requireMention?: boolean;
allowBots?: boolean;
users?: Array<string | number>;
skills?: string[];
systemPrompt?: string;
@@ -349,13 +354,14 @@ function resolveSlackChannelConfig(params: {
const requireMention =
firstDefined(resolved.requireMention, fallback?.requireMention, true) ??
true;
const allowBots = firstDefined(resolved.allowBots, fallback?.allowBots);
const users = firstDefined(resolved.users, fallback?.users);
const skills = firstDefined(resolved.skills, fallback?.skills);
const systemPrompt = firstDefined(
resolved.systemPrompt,
fallback?.systemPrompt,
);
return { allowed, requireMention, users, skills, systemPrompt };
return { allowed, requireMention, allowBots, users, skills, systemPrompt };
}
async function resolveSlackMedia(params: {
@@ -706,15 +712,14 @@ export async function monitorSlackProvider(opts: MonitorSlackOpts = {}) {
opts: { source: "message" | "app_mention"; wasMentioned?: boolean },
) => {
if (opts.source === "message" && message.type !== "message") return;
if (message.bot_id) return;
if (
opts.source === "message" &&
message.subtype &&
message.subtype !== "file_share"
message.subtype !== "file_share" &&
message.subtype !== "bot_message"
) {
return;
}
if (!message.user) return;
if (markMessageSeen(message.channel, message.ts)) return;
let channelInfo: {
@@ -735,6 +740,40 @@ export async function monitorSlackProvider(opts: MonitorSlackOpts = {}) {
const isRoom =
resolvedChannelType === "channel" || resolvedChannelType === "group";
const channelConfig = isRoom
? resolveSlackChannelConfig({
channelId: message.channel,
channelName,
channels: channelsConfig,
})
: null;
const allowBots =
channelConfig?.allowBots ??
account.config?.allowBots ??
cfg.slack?.allowBots ??
false;
const isBotMessage = Boolean(message.bot_id);
if (isBotMessage) {
if (message.user && botUserId && message.user === botUserId) return;
if (!allowBots) {
logVerbose(
`slack: drop bot message ${message.bot_id ?? "unknown"} (allowBots=false)`,
);
return;
}
}
if (isDirectMessage && !message.user) {
logVerbose("slack: drop dm message (missing user id)");
return;
}
const senderId =
message.user ?? (isBotMessage ? message.bot_id : undefined);
if (!senderId) {
logVerbose("slack: drop message (missing sender id)");
return;
}
if (
!isChannelAllowed({
channelId: message.channel,
@@ -756,6 +795,11 @@ export async function monitorSlackProvider(opts: MonitorSlackOpts = {}) {
const effectiveAllowFromLower = normalizeAllowListLower(effectiveAllowFrom);
if (isDirectMessage) {
const directUserId = message.user;
if (!directUserId) {
logVerbose("slack: drop dm message (missing user id)");
return;
}
if (!dmEnabled || dmPolicy === "disabled") {
logVerbose("slack: drop dm (dms disabled)");
return;
@@ -763,20 +807,20 @@ export async function monitorSlackProvider(opts: MonitorSlackOpts = {}) {
if (dmPolicy !== "open") {
const permitted = allowListMatches({
allowList: effectiveAllowFromLower,
id: message.user,
id: directUserId,
});
if (!permitted) {
if (dmPolicy === "pairing") {
const sender = await resolveUserName(message.user);
const sender = await resolveUserName(directUserId);
const senderName = sender?.name ?? undefined;
const { code, created } = await upsertProviderPairingRequest({
provider: "slack",
id: message.user,
id: directUserId,
meta: { name: senderName },
});
if (created) {
logVerbose(
`slack pairing request sender=${message.user} name=${senderName ?? "unknown"}`,
`slack pairing request sender=${directUserId} name=${senderName ?? "unknown"}`,
);
try {
await sendMessageSlack(
@@ -811,31 +855,28 @@ export async function monitorSlackProvider(opts: MonitorSlackOpts = {}) {
}
}
const channelConfig = isRoom
? resolveSlackChannelConfig({
channelId: message.channel,
channelName,
channels: channelsConfig,
})
: null;
const wasMentioned =
opts.wasMentioned ??
(!isDirectMessage &&
(Boolean(botUserId && message.text?.includes(`<@${botUserId}>`)) ||
matchesMentionPatterns(message.text ?? "", mentionRegexes)));
const sender = await resolveUserName(message.user);
const senderName = sender?.name ?? message.user;
const sender = message.user ? await resolveUserName(message.user) : null;
const senderName =
sender?.name ??
message.username?.trim() ??
message.user ??
message.bot_id ??
"unknown";
const channelUserAuthorized = isRoom
? resolveSlackUserAllowed({
allowList: channelConfig?.users,
userId: message.user,
userId: senderId,
userName: senderName,
})
: true;
if (isRoom && !channelUserAuthorized) {
logVerbose(
`Blocked unauthorized slack sender ${message.user} (not in channel users)`,
`Blocked unauthorized slack sender ${senderId} (not in channel users)`,
);
return;
}
@@ -844,7 +885,7 @@ export async function monitorSlackProvider(opts: MonitorSlackOpts = {}) {
(allowList.length === 0 ||
allowListMatches({
allowList,
id: message.user,
id: senderId,
name: senderName,
})) &&
channelUserAuthorized;
@@ -1010,7 +1051,7 @@ export async function monitorSlackProvider(opts: MonitorSlackOpts = {}) {
GroupSubject: isRoomish ? roomLabel : undefined,
GroupSystemPrompt: isRoomish ? groupSystemPrompt : undefined,
SenderName: senderName,
SenderId: message.user,
SenderId: senderId,
Provider: "slack" as const,
Surface: "slack" as const,
MessageSid: message.ts,

View File

@@ -5,7 +5,9 @@ import {
resolveTextChunkLimit,
} from "../auto-reply/chunk.js";
import { loadConfig } from "../config/config.js";
import { logVerbose } from "../globals.js";
import { loadWebMedia } from "../web/media.js";
import type { SlackTokenSource } from "./accounts.js";
import { resolveSlackAccount } from "./accounts.js";
import { resolveSlackBotToken } from "./token.js";
@@ -38,11 +40,17 @@ function resolveToken(params: {
explicit?: string;
accountId: string;
fallbackToken?: string;
fallbackSource?: SlackTokenSource;
}) {
const explicit = resolveSlackBotToken(params.explicit);
if (explicit) return explicit;
const fallback = resolveSlackBotToken(params.fallbackToken);
if (!fallback) {
logVerbose(
`slack send: missing bot token for account=${params.accountId} explicit=${Boolean(
params.explicit,
)} source=${params.fallbackSource ?? "unknown"}`,
);
throw new Error(
`Slack bot token missing for account "${params.accountId}" (set slack.accounts.${params.accountId}.botToken or SLACK_BOT_TOKEN for default).`,
);
@@ -154,6 +162,7 @@ export async function sendMessageSlack(
explicit: opts.token,
accountId: account.accountId,
fallbackToken: account.botToken,
fallbackSource: account.botTokenSource,
});
const client = opts.client ?? new WebClient(token);
const recipient = parseRecipient(to);