Files
clawdbot/src/slack/monitor/provider.ts

377 lines
15 KiB
TypeScript

import type { IncomingMessage, ServerResponse } from "node:http";
import SlackBoltDefault, * as SlackBoltModule from "@slack/bolt";
import { resolveTextChunkLimit } from "../../auto-reply/chunk.js";
import { DEFAULT_GROUP_HISTORY_LIMIT } from "../../auto-reply/reply/history.js";
import { mergeAllowlist, summarizeMapping } from "../../channels/allowlists/resolve-utils.js";
import { loadConfig } from "../../config/config.js";
import type { SessionScope } from "../../config/sessions.js";
import type { DmPolicy, GroupPolicy } from "../../config/types.js";
import { warn } from "../../globals.js";
import { normalizeMainKey } from "../../routing/session-key.js";
import type { RuntimeEnv } from "../../runtime.js";
import { resolveSlackAccount } from "../accounts.js";
import { resolveSlackChannelAllowlist } from "../resolve-channels.js";
import { resolveSlackUserAllowlist } from "../resolve-users.js";
import { resolveSlackAppToken, resolveSlackBotToken } from "../token.js";
import { normalizeSlackWebhookPath, registerSlackHttpHandler } from "../http/index.js";
import { resolveSlackSlashCommandConfig } from "./commands.js";
import { createSlackMonitorContext } from "./context.js";
import { registerSlackMonitorEvents } from "./events.js";
import { createSlackMessageHandler } from "./message-handler.js";
import { registerSlackMonitorSlashCommands } from "./slash.js";
import { normalizeAllowList } from "./allow-list.js";
import type { MonitorSlackOpts } from "./types.js";
type SlackBoltNamespace = typeof import("@slack/bolt");
type SlackBoltDefault = SlackBoltNamespace | SlackBoltNamespace["App"];
const slackBoltDefaultImport = SlackBoltDefault as SlackBoltDefault | undefined;
const slackBoltModuleDefault = (SlackBoltModule as { default?: SlackBoltDefault }).default;
const slackBoltDefault = slackBoltDefaultImport ?? slackBoltModuleDefault;
const slackBoltNamespace =
typeof slackBoltDefault === "object" && slackBoltDefault
? (slackBoltDefault as SlackBoltNamespace)
: typeof slackBoltModuleDefault === "object" && slackBoltModuleDefault
? (slackBoltModuleDefault as SlackBoltNamespace)
: undefined;
// Bun allows named imports from CJS; Node ESM doesn't. Resolve default/module shapes for compatibility.
const App = ((typeof slackBoltDefault === "function" ? slackBoltDefault : undefined) ??
slackBoltNamespace?.App ??
SlackBoltModule.App) as SlackBoltNamespace["App"];
const HTTPReceiver = (slackBoltNamespace?.HTTPReceiver ??
SlackBoltModule.HTTPReceiver) as SlackBoltNamespace["HTTPReceiver"];
function parseApiAppIdFromAppToken(raw?: string) {
const token = raw?.trim();
if (!token) return undefined;
const match = /^xapp-\d-([a-z0-9]+)-/i.exec(token);
return match?.[1]?.toUpperCase();
}
export async function monitorSlackProvider(opts: MonitorSlackOpts = {}) {
const cfg = opts.config ?? loadConfig();
let account = resolveSlackAccount({
cfg,
accountId: opts.accountId,
});
const historyLimit = Math.max(
0,
account.config.historyLimit ??
cfg.messages?.groupChat?.historyLimit ??
DEFAULT_GROUP_HISTORY_LIMIT,
);
const sessionCfg = cfg.session;
const sessionScope: SessionScope = sessionCfg?.scope ?? "per-sender";
const mainKey = normalizeMainKey(sessionCfg?.mainKey);
const slackMode = opts.mode ?? account.config.mode ?? "socket";
const slackWebhookPath = normalizeSlackWebhookPath(account.config.webhookPath);
const signingSecret = account.config.signingSecret?.trim();
const botToken = resolveSlackBotToken(opts.botToken ?? account.botToken);
const appToken = resolveSlackAppToken(opts.appToken ?? account.appToken);
if (!botToken || (slackMode !== "http" && !appToken)) {
const missing =
slackMode === "http"
? `Slack bot token missing for account "${account.accountId}" (set channels.slack.accounts.${account.accountId}.botToken or SLACK_BOT_TOKEN for default).`
: `Slack bot + app tokens missing for account "${account.accountId}" (set channels.slack.accounts.${account.accountId}.botToken/appToken or SLACK_BOT_TOKEN/SLACK_APP_TOKEN for default).`;
throw new Error(missing);
}
if (slackMode === "http" && !signingSecret) {
throw new Error(
`Slack signing secret missing for account "${account.accountId}" (set channels.slack.signingSecret or channels.slack.accounts.${account.accountId}.signingSecret).`,
);
}
const runtime: RuntimeEnv = opts.runtime ?? {
log: console.log,
error: console.error,
exit: (code: number): never => {
throw new Error(`exit ${code}`);
},
};
const slackCfg = account.config;
const dmConfig = slackCfg.dm;
const dmEnabled = dmConfig?.enabled ?? true;
const dmPolicy = (dmConfig?.policy ?? "pairing") as DmPolicy;
let allowFrom = dmConfig?.allowFrom;
const groupDmEnabled = dmConfig?.groupEnabled ?? false;
const groupDmChannels = dmConfig?.groupChannels;
let channelsConfig = slackCfg.channels;
const defaultGroupPolicy = cfg.channels?.defaults?.groupPolicy;
const groupPolicy = (slackCfg.groupPolicy ?? defaultGroupPolicy ?? "open") as GroupPolicy;
if (
slackCfg.groupPolicy === undefined &&
slackCfg.channels === undefined &&
defaultGroupPolicy === undefined &&
groupPolicy === "open"
) {
runtime.log?.(
warn(
'slack: groupPolicy defaults to "open" when channels.slack is missing; set channels.slack.groupPolicy (or channels.defaults.groupPolicy) or add channels.slack.channels to restrict access.',
),
);
}
const resolveToken = slackCfg.userToken?.trim() || botToken;
const useAccessGroups = cfg.commands?.useAccessGroups !== false;
const reactionMode = slackCfg.reactionNotifications ?? "own";
const reactionAllowlist = slackCfg.reactionAllowlist ?? [];
const replyToMode = slackCfg.replyToMode ?? "off";
const threadHistoryScope = slackCfg.thread?.historyScope ?? "thread";
const threadInheritParent = slackCfg.thread?.inheritParent ?? false;
const slashCommand = resolveSlackSlashCommandConfig(opts.slashCommand ?? slackCfg.slashCommand);
const textLimit = resolveTextChunkLimit(cfg, "slack", account.accountId);
const ackReactionScope = cfg.messages?.ackReactionScope ?? "group-mentions";
const mediaMaxBytes = (opts.mediaMaxMb ?? slackCfg.mediaMaxMb ?? 20) * 1024 * 1024;
const removeAckAfterReply = cfg.messages?.removeAckAfterReply ?? false;
if (!App) {
throw new Error("Slack Bolt App export missing; check @slack/bolt installation.");
}
if (slackMode === "http" && !HTTPReceiver) {
throw new Error("Slack Bolt HTTPReceiver export missing; check @slack/bolt installation.");
}
const receiver =
slackMode === "http"
? new HTTPReceiver({
signingSecret: signingSecret ?? "",
endpoints: slackWebhookPath,
})
: null;
const app = new App(
slackMode === "socket"
? {
token: botToken,
appToken,
socketMode: true,
}
: {
token: botToken,
receiver: receiver ?? undefined,
},
);
const slackHttpHandler =
slackMode === "http" && receiver
? async (req: IncomingMessage, res: ServerResponse) => {
await Promise.resolve(receiver.requestListener(req, res));
}
: null;
let unregisterHttpHandler: (() => void) | null = null;
let botUserId = "";
let teamId = "";
let apiAppId = "";
const expectedApiAppIdFromAppToken = parseApiAppIdFromAppToken(appToken);
try {
const auth = await app.client.auth.test({ token: botToken });
botUserId = auth.user_id ?? "";
teamId = auth.team_id ?? "";
apiAppId = (auth as { api_app_id?: string }).api_app_id ?? "";
} catch {
// auth test failing is non-fatal; message handler falls back to regex mentions.
}
if (apiAppId && expectedApiAppIdFromAppToken && apiAppId !== expectedApiAppIdFromAppToken) {
runtime.error?.(
`slack token mismatch: bot token api_app_id=${apiAppId} but app token looks like api_app_id=${expectedApiAppIdFromAppToken}`,
);
}
const ctx = createSlackMonitorContext({
cfg,
accountId: account.accountId,
botToken,
app,
runtime,
botUserId,
teamId,
apiAppId,
historyLimit,
sessionScope,
mainKey,
dmEnabled,
dmPolicy,
allowFrom,
groupDmEnabled,
groupDmChannels,
defaultRequireMention: slackCfg.requireMention,
channelsConfig,
groupPolicy,
useAccessGroups,
reactionMode,
reactionAllowlist,
replyToMode,
threadHistoryScope,
threadInheritParent,
slashCommand,
textLimit,
ackReactionScope,
mediaMaxBytes,
removeAckAfterReply,
});
const handleSlackMessage = createSlackMessageHandler({ ctx, account });
registerSlackMonitorEvents({ ctx, account, handleSlackMessage });
registerSlackMonitorSlashCommands({ ctx, account });
if (slackMode === "http" && slackHttpHandler) {
unregisterHttpHandler = registerSlackHttpHandler({
path: slackWebhookPath,
handler: slackHttpHandler,
log: runtime.log,
accountId: account.accountId,
});
}
if (resolveToken) {
void (async () => {
if (opts.abortSignal?.aborted) return;
if (channelsConfig && Object.keys(channelsConfig).length > 0) {
try {
const entries = Object.keys(channelsConfig).filter((key) => key !== "*");
if (entries.length > 0) {
const resolved = await resolveSlackChannelAllowlist({
token: resolveToken,
entries,
});
const nextChannels = { ...channelsConfig };
const mapping: string[] = [];
const unresolved: string[] = [];
for (const entry of resolved) {
const source = channelsConfig?.[entry.input];
if (!source) continue;
if (!entry.resolved || !entry.id) {
unresolved.push(entry.input);
continue;
}
mapping.push(`${entry.input}${entry.id}${entry.archived ? " (archived)" : ""}`);
const existing = nextChannels[entry.id] ?? {};
nextChannels[entry.id] = { ...source, ...existing };
}
channelsConfig = nextChannels;
ctx.channelsConfig = nextChannels;
summarizeMapping("slack channels", mapping, unresolved, runtime);
}
} catch (err) {
runtime.log?.(`slack channel resolve failed; using config entries. ${String(err)}`);
}
}
const allowEntries =
allowFrom?.filter((entry) => String(entry).trim() && String(entry).trim() !== "*") ?? [];
if (allowEntries.length > 0) {
try {
const resolvedUsers = await resolveSlackUserAllowlist({
token: resolveToken,
entries: allowEntries.map((entry) => String(entry)),
});
const mapping: string[] = [];
const unresolved: string[] = [];
const additions: string[] = [];
for (const entry of resolvedUsers) {
if (entry.resolved && entry.id) {
const note = entry.note ? ` (${entry.note})` : "";
mapping.push(`${entry.input}${entry.id}${note}`);
additions.push(entry.id);
} else {
unresolved.push(entry.input);
}
}
allowFrom = mergeAllowlist({ existing: allowFrom, additions });
ctx.allowFrom = normalizeAllowList(allowFrom);
summarizeMapping("slack users", mapping, unresolved, runtime);
} catch (err) {
runtime.log?.(`slack user resolve failed; using config entries. ${String(err)}`);
}
}
if (channelsConfig && Object.keys(channelsConfig).length > 0) {
const userEntries = new Set<string>();
for (const channel of Object.values(channelsConfig)) {
if (!channel || typeof channel !== "object") continue;
const channelUsers = (channel as { users?: Array<string | number> }).users;
if (!Array.isArray(channelUsers)) continue;
for (const entry of channelUsers) {
const trimmed = String(entry).trim();
if (trimmed && trimmed !== "*") userEntries.add(trimmed);
}
}
if (userEntries.size > 0) {
try {
const resolvedUsers = await resolveSlackUserAllowlist({
token: resolveToken,
entries: Array.from(userEntries),
});
const resolvedMap = new Map(resolvedUsers.map((entry) => [entry.input, entry]));
const mapping = resolvedUsers
.filter((entry) => entry.resolved && entry.id)
.map((entry) => `${entry.input}${entry.id}`);
const unresolved = resolvedUsers
.filter((entry) => !entry.resolved)
.map((entry) => entry.input);
const nextChannels = { ...channelsConfig };
for (const [channelKey, channelConfig] of Object.entries(channelsConfig)) {
if (!channelConfig || typeof channelConfig !== "object") continue;
const channelUsers = (channelConfig as { users?: Array<string | number> }).users;
if (!Array.isArray(channelUsers) || channelUsers.length === 0) continue;
const additions: string[] = [];
for (const entry of channelUsers) {
const trimmed = String(entry).trim();
const resolved = resolvedMap.get(trimmed);
if (resolved?.resolved && resolved.id) additions.push(resolved.id);
}
nextChannels[channelKey] = {
...channelConfig,
users: mergeAllowlist({ existing: channelUsers, additions }),
};
}
channelsConfig = nextChannels;
ctx.channelsConfig = nextChannels;
summarizeMapping("slack channel users", mapping, unresolved, runtime);
} catch (err) {
runtime.log?.(
`slack channel user resolve failed; using config entries. ${String(err)}`,
);
}
}
}
})();
}
const stopOnAbort = () => {
if (opts.abortSignal?.aborted && slackMode === "socket") void app.stop();
};
opts.abortSignal?.addEventListener("abort", stopOnAbort, { once: true });
try {
if (slackMode === "socket") {
await app.start();
runtime.log?.("slack socket mode connected");
} else {
runtime.log?.(`slack http mode listening at ${slackWebhookPath}`);
}
if (opts.abortSignal?.aborted) return;
await new Promise<void>((resolve) => {
opts.abortSignal?.addEventListener("abort", () => resolve(), {
once: true,
});
});
} finally {
opts.abortSignal?.removeEventListener("abort", stopOnAbort);
unregisterHttpHandler?.();
await app.stop().catch(() => undefined);
}
}