1979 lines
61 KiB
TypeScript
1979 lines
61 KiB
TypeScript
import {
|
|
App,
|
|
type SlackCommandMiddlewareArgs,
|
|
type SlackEventMiddlewareArgs,
|
|
} from "@slack/bolt";
|
|
import type { WebClient as SlackWebClient } from "@slack/web-api";
|
|
import {
|
|
chunkMarkdownText,
|
|
resolveTextChunkLimit,
|
|
} from "../auto-reply/chunk.js";
|
|
import { hasControlCommand } from "../auto-reply/command-detection.js";
|
|
import {
|
|
buildCommandText,
|
|
listNativeCommandSpecs,
|
|
shouldHandleTextCommands,
|
|
} from "../auto-reply/commands-registry.js";
|
|
import {
|
|
formatAgentEnvelope,
|
|
formatThreadStarterEnvelope,
|
|
} from "../auto-reply/envelope.js";
|
|
import { dispatchReplyFromConfig } from "../auto-reply/reply/dispatch-from-config.js";
|
|
import {
|
|
buildMentionRegexes,
|
|
matchesMentionPatterns,
|
|
} from "../auto-reply/reply/mentions.js";
|
|
import { createReplyDispatcherWithTyping } from "../auto-reply/reply/reply-dispatcher.js";
|
|
import { getReplyFromConfig } from "../auto-reply/reply.js";
|
|
import { SILENT_REPLY_TOKEN } from "../auto-reply/tokens.js";
|
|
import type { ReplyPayload } from "../auto-reply/types.js";
|
|
import type {
|
|
ClawdbotConfig,
|
|
SlackReactionNotificationMode,
|
|
SlackSlashCommandConfig,
|
|
} from "../config/config.js";
|
|
import { loadConfig } from "../config/config.js";
|
|
import {
|
|
resolveSessionKey,
|
|
resolveStorePath,
|
|
updateLastRoute,
|
|
} from "../config/sessions.js";
|
|
import { danger, logVerbose, shouldLogVerbose } from "../globals.js";
|
|
import { enqueueSystemEvent } from "../infra/system-events.js";
|
|
import { getChildLogger } from "../logging.js";
|
|
import { detectMime } from "../media/mime.js";
|
|
import { saveMediaBuffer } from "../media/store.js";
|
|
import {
|
|
readProviderAllowFromStore,
|
|
upsertProviderPairingRequest,
|
|
} from "../pairing/pairing-store.js";
|
|
import { resolveAgentRoute } from "../routing/resolve-route.js";
|
|
import { resolveThreadSessionKeys } from "../routing/session-key.js";
|
|
import type { RuntimeEnv } from "../runtime.js";
|
|
import { resolveSlackAccount } from "./accounts.js";
|
|
import { reactSlackMessage } from "./actions.js";
|
|
import { sendMessageSlack } from "./send.js";
|
|
import { resolveSlackAppToken, resolveSlackBotToken } from "./token.js";
|
|
|
|
export type MonitorSlackOpts = {
|
|
botToken?: string;
|
|
appToken?: string;
|
|
accountId?: string;
|
|
config?: ClawdbotConfig;
|
|
runtime?: RuntimeEnv;
|
|
abortSignal?: AbortSignal;
|
|
mediaMaxMb?: number;
|
|
slashCommand?: SlackSlashCommandConfig;
|
|
};
|
|
|
|
type SlackFile = {
|
|
id?: string;
|
|
name?: string;
|
|
mimetype?: string;
|
|
size?: number;
|
|
url_private?: string;
|
|
url_private_download?: string;
|
|
};
|
|
|
|
type SlackMessageEvent = {
|
|
type: "message";
|
|
user?: string;
|
|
bot_id?: string;
|
|
subtype?: string;
|
|
text?: string;
|
|
ts?: string;
|
|
thread_ts?: string;
|
|
parent_user_id?: string;
|
|
channel: string;
|
|
channel_type?: "im" | "mpim" | "channel" | "group";
|
|
files?: SlackFile[];
|
|
};
|
|
|
|
type SlackAppMentionEvent = {
|
|
type: "app_mention";
|
|
user?: string;
|
|
bot_id?: string;
|
|
text?: string;
|
|
ts?: string;
|
|
thread_ts?: string;
|
|
parent_user_id?: string;
|
|
channel: string;
|
|
channel_type?: "im" | "mpim" | "channel" | "group";
|
|
};
|
|
|
|
type SlackReactionEvent = {
|
|
type: "reaction_added" | "reaction_removed";
|
|
user?: string;
|
|
reaction?: string;
|
|
item?: {
|
|
type?: string;
|
|
channel?: string;
|
|
ts?: string;
|
|
};
|
|
item_user?: string;
|
|
event_ts?: string;
|
|
};
|
|
|
|
type SlackMemberChannelEvent = {
|
|
type: "member_joined_channel" | "member_left_channel";
|
|
user?: string;
|
|
channel?: string;
|
|
channel_type?: SlackMessageEvent["channel_type"];
|
|
event_ts?: string;
|
|
};
|
|
|
|
type SlackChannelCreatedEvent = {
|
|
type: "channel_created";
|
|
channel?: { id?: string; name?: string };
|
|
event_ts?: string;
|
|
};
|
|
|
|
type SlackChannelRenamedEvent = {
|
|
type: "channel_rename";
|
|
channel?: { id?: string; name?: string; name_normalized?: string };
|
|
event_ts?: string;
|
|
};
|
|
|
|
type SlackPinEvent = {
|
|
type: "pin_added" | "pin_removed";
|
|
channel_id?: string;
|
|
user?: string;
|
|
item?: { type?: string; message?: { ts?: string } };
|
|
event_ts?: string;
|
|
};
|
|
|
|
type SlackMessageChangedEvent = {
|
|
type: "message";
|
|
subtype: "message_changed";
|
|
channel?: string;
|
|
message?: { ts?: string };
|
|
previous_message?: { ts?: string };
|
|
event_ts?: string;
|
|
};
|
|
|
|
type SlackMessageDeletedEvent = {
|
|
type: "message";
|
|
subtype: "message_deleted";
|
|
channel?: string;
|
|
deleted_ts?: string;
|
|
event_ts?: string;
|
|
};
|
|
|
|
type SlackThreadBroadcastEvent = {
|
|
type: "message";
|
|
subtype: "thread_broadcast";
|
|
channel?: string;
|
|
message?: { ts?: string };
|
|
event_ts?: string;
|
|
};
|
|
|
|
type SlackChannelConfigResolved = {
|
|
allowed: boolean;
|
|
requireMention: boolean;
|
|
users?: Array<string | number>;
|
|
skills?: string[];
|
|
systemPrompt?: string;
|
|
};
|
|
|
|
function normalizeSlackSlug(raw?: string) {
|
|
const trimmed = raw?.trim().toLowerCase() ?? "";
|
|
if (!trimmed) return "";
|
|
const dashed = trimmed.replace(/\s+/g, "-");
|
|
const cleaned = dashed.replace(/[^a-z0-9#@._+-]+/g, "-");
|
|
return cleaned.replace(/-{2,}/g, "-").replace(/^[-.]+|[-.]+$/g, "");
|
|
}
|
|
|
|
function normalizeAllowList(list?: Array<string | number>) {
|
|
return (list ?? []).map((entry) => String(entry).trim()).filter(Boolean);
|
|
}
|
|
|
|
function normalizeAllowListLower(list?: Array<string | number>) {
|
|
return normalizeAllowList(list).map((entry) => entry.toLowerCase());
|
|
}
|
|
|
|
function firstDefined<T>(...values: Array<T | undefined>) {
|
|
for (const value of values) {
|
|
if (typeof value !== "undefined") return value;
|
|
}
|
|
return undefined;
|
|
}
|
|
|
|
function allowListMatches(params: {
|
|
allowList: string[];
|
|
id?: string;
|
|
name?: string;
|
|
}) {
|
|
const allowList = params.allowList;
|
|
if (allowList.length === 0) return false;
|
|
if (allowList.includes("*")) return true;
|
|
const id = params.id?.toLowerCase();
|
|
const name = params.name?.toLowerCase();
|
|
const slug = normalizeSlackSlug(name);
|
|
const candidates = [
|
|
id,
|
|
id ? `slack:${id}` : undefined,
|
|
id ? `user:${id}` : undefined,
|
|
name,
|
|
name ? `slack:${name}` : undefined,
|
|
slug,
|
|
].filter(Boolean) as string[];
|
|
return candidates.some((value) => allowList.includes(value));
|
|
}
|
|
|
|
function resolveSlackUserAllowed(params: {
|
|
allowList?: Array<string | number>;
|
|
userId?: string;
|
|
userName?: string;
|
|
}) {
|
|
const allowList = normalizeAllowListLower(params.allowList);
|
|
if (allowList.length === 0) return true;
|
|
return allowListMatches({
|
|
allowList,
|
|
id: params.userId,
|
|
name: params.userName,
|
|
});
|
|
}
|
|
|
|
function resolveSlackSlashCommandConfig(
|
|
raw?: SlackSlashCommandConfig,
|
|
): Required<SlackSlashCommandConfig> {
|
|
return {
|
|
enabled: raw?.enabled === true,
|
|
name: raw?.name?.trim() || "clawd",
|
|
sessionPrefix: raw?.sessionPrefix?.trim() || "slack:slash",
|
|
ephemeral: raw?.ephemeral !== false,
|
|
};
|
|
}
|
|
|
|
function shouldEmitSlackReactionNotification(params: {
|
|
mode: SlackReactionNotificationMode | undefined;
|
|
botId?: string | null;
|
|
messageAuthorId?: string | null;
|
|
userId: string;
|
|
userName?: string | null;
|
|
allowlist?: Array<string | number> | null;
|
|
}) {
|
|
const { mode, botId, messageAuthorId, userId, userName, allowlist } = params;
|
|
const effectiveMode = mode ?? "own";
|
|
if (effectiveMode === "off") return false;
|
|
if (effectiveMode === "own") {
|
|
if (!botId || !messageAuthorId) return false;
|
|
return messageAuthorId === botId;
|
|
}
|
|
if (effectiveMode === "allowlist") {
|
|
if (!Array.isArray(allowlist) || allowlist.length === 0) return false;
|
|
const users = normalizeAllowListLower(allowlist);
|
|
return allowListMatches({
|
|
allowList: users,
|
|
id: userId,
|
|
name: userName ?? undefined,
|
|
});
|
|
}
|
|
return true;
|
|
}
|
|
|
|
function resolveSlackChannelLabel(params: {
|
|
channelId?: string;
|
|
channelName?: string;
|
|
}) {
|
|
const channelName = params.channelName?.trim();
|
|
if (channelName) {
|
|
const slug = normalizeSlackSlug(channelName);
|
|
return `#${slug || channelName}`;
|
|
}
|
|
const channelId = params.channelId?.trim();
|
|
return channelId ? `#${channelId}` : "unknown channel";
|
|
}
|
|
|
|
function resolveSlackChannelConfig(params: {
|
|
channelId: string;
|
|
channelName?: string;
|
|
channels?: Record<
|
|
string,
|
|
{
|
|
enabled?: boolean;
|
|
allow?: boolean;
|
|
requireMention?: boolean;
|
|
users?: Array<string | number>;
|
|
skills?: string[];
|
|
systemPrompt?: string;
|
|
}
|
|
>;
|
|
}): SlackChannelConfigResolved | null {
|
|
const { channelId, channelName, channels } = params;
|
|
const entries = channels ?? {};
|
|
const keys = Object.keys(entries);
|
|
const normalizedName = channelName ? normalizeSlackSlug(channelName) : "";
|
|
const directName = channelName ? channelName.trim() : "";
|
|
const candidates = [
|
|
channelId,
|
|
channelName ? `#${directName}` : "",
|
|
directName,
|
|
normalizedName,
|
|
].filter(Boolean);
|
|
|
|
let matched:
|
|
| {
|
|
enabled?: boolean;
|
|
allow?: boolean;
|
|
requireMention?: boolean;
|
|
users?: Array<string | number>;
|
|
skills?: string[];
|
|
systemPrompt?: string;
|
|
}
|
|
| undefined;
|
|
for (const candidate of candidates) {
|
|
if (candidate && entries[candidate]) {
|
|
matched = entries[candidate];
|
|
break;
|
|
}
|
|
}
|
|
const fallback = entries["*"];
|
|
|
|
if (keys.length === 0) {
|
|
return { allowed: true, requireMention: true };
|
|
}
|
|
if (!matched && !fallback) {
|
|
return { allowed: false, requireMention: true };
|
|
}
|
|
|
|
const resolved = matched ?? fallback ?? {};
|
|
const allowed =
|
|
firstDefined(
|
|
resolved.enabled,
|
|
resolved.allow,
|
|
fallback?.enabled,
|
|
fallback?.allow,
|
|
true,
|
|
) ?? true;
|
|
const requireMention =
|
|
firstDefined(resolved.requireMention, fallback?.requireMention, true) ??
|
|
true;
|
|
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 };
|
|
}
|
|
|
|
async function resolveSlackMedia(params: {
|
|
files?: SlackFile[];
|
|
token: string;
|
|
maxBytes: number;
|
|
}): Promise<{
|
|
path: string;
|
|
contentType?: string;
|
|
placeholder: string;
|
|
} | null> {
|
|
const files = params.files ?? [];
|
|
for (const file of files) {
|
|
const url = file.url_private_download ?? file.url_private;
|
|
if (!url) continue;
|
|
try {
|
|
const res = await fetch(url, {
|
|
headers: { Authorization: `Bearer ${params.token}` },
|
|
});
|
|
if (!res.ok) continue;
|
|
const buffer = Buffer.from(await res.arrayBuffer());
|
|
if (buffer.byteLength > params.maxBytes) continue;
|
|
const contentType = await detectMime({
|
|
buffer,
|
|
headerMime: res.headers.get("content-type"),
|
|
filePath: file.name,
|
|
});
|
|
const saved = await saveMediaBuffer(
|
|
buffer,
|
|
contentType ?? file.mimetype,
|
|
"inbound",
|
|
params.maxBytes,
|
|
);
|
|
return {
|
|
path: saved.path,
|
|
contentType: saved.contentType,
|
|
placeholder: file.name ? `[Slack file: ${file.name}]` : "[Slack file]",
|
|
};
|
|
} catch {
|
|
// Ignore download failures and fall through to the next file.
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
type SlackThreadStarter = {
|
|
text: string;
|
|
userId?: string;
|
|
ts?: string;
|
|
};
|
|
|
|
const THREAD_STARTER_CACHE = new Map<string, SlackThreadStarter>();
|
|
|
|
async function resolveSlackThreadStarter(params: {
|
|
channelId: string;
|
|
threadTs: string;
|
|
client: SlackWebClient;
|
|
}): Promise<SlackThreadStarter | null> {
|
|
const cacheKey = `${params.channelId}:${params.threadTs}`;
|
|
const cached = THREAD_STARTER_CACHE.get(cacheKey);
|
|
if (cached) return cached;
|
|
try {
|
|
const response = (await params.client.conversations.replies({
|
|
channel: params.channelId,
|
|
ts: params.threadTs,
|
|
limit: 1,
|
|
inclusive: true,
|
|
})) as { messages?: Array<{ text?: string; user?: string; ts?: string }> };
|
|
const message = response?.messages?.[0];
|
|
const text = (message?.text ?? "").trim();
|
|
if (!message || !text) return null;
|
|
const starter: SlackThreadStarter = {
|
|
text,
|
|
userId: message.user,
|
|
ts: message.ts,
|
|
};
|
|
THREAD_STARTER_CACHE.set(cacheKey, starter);
|
|
return starter;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
export async function monitorSlackProvider(opts: MonitorSlackOpts = {}) {
|
|
const cfg = opts.config ?? loadConfig();
|
|
const account = resolveSlackAccount({
|
|
cfg,
|
|
accountId: opts.accountId,
|
|
});
|
|
const sessionCfg = cfg.session;
|
|
const sessionScope = sessionCfg?.scope ?? "per-sender";
|
|
const mainKey = (sessionCfg?.mainKey ?? "main").trim() || "main";
|
|
|
|
const resolveSlackSystemEventSessionKey = (params: {
|
|
channelId?: string | null;
|
|
channelType?: string | null;
|
|
}) => {
|
|
const channelId = params.channelId?.trim() ?? "";
|
|
if (!channelId) return mainKey;
|
|
const channelType = params.channelType?.trim().toLowerCase() ?? "";
|
|
const isRoom = channelType === "channel" || channelType === "group";
|
|
const isGroup = channelType === "mpim";
|
|
const from = isRoom
|
|
? `slack:channel:${channelId}`
|
|
: isGroup
|
|
? `slack:group:${channelId}`
|
|
: `slack:${channelId}`;
|
|
const chatType = isRoom ? "room" : isGroup ? "group" : "direct";
|
|
return resolveSessionKey(
|
|
sessionScope,
|
|
{ From: from, ChatType: chatType, Provider: "slack" },
|
|
mainKey,
|
|
);
|
|
};
|
|
const botToken = resolveSlackBotToken(opts.botToken ?? account.botToken);
|
|
const appToken = resolveSlackAppToken(opts.appToken ?? account.appToken);
|
|
if (!botToken || !appToken) {
|
|
throw new Error(
|
|
`Slack bot + app tokens missing for account "${account.accountId}" (set slack.accounts.${account.accountId}.botToken/appToken or SLACK_BOT_TOKEN/SLACK_APP_TOKEN for default).`,
|
|
);
|
|
}
|
|
|
|
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 dmPolicy = dmConfig?.policy ?? "pairing";
|
|
const allowFrom = normalizeAllowList(dmConfig?.allowFrom);
|
|
const groupDmEnabled = dmConfig?.groupEnabled ?? false;
|
|
const groupDmChannels = normalizeAllowList(dmConfig?.groupChannels);
|
|
const channelsConfig = slackCfg.channels;
|
|
const dmEnabled = dmConfig?.enabled ?? true;
|
|
const groupPolicy = slackCfg.groupPolicy ?? "open";
|
|
const useAccessGroups = cfg.commands?.useAccessGroups !== false;
|
|
const reactionMode = slackCfg.reactionNotifications ?? "own";
|
|
const reactionAllowlist = slackCfg.reactionAllowlist ?? [];
|
|
const slashCommand = resolveSlackSlashCommandConfig(
|
|
opts.slashCommand ?? slackCfg.slashCommand,
|
|
);
|
|
const textLimit = resolveTextChunkLimit(cfg, "slack", account.accountId);
|
|
const mentionRegexes = buildMentionRegexes(cfg);
|
|
const ackReaction = (cfg.messages?.ackReaction ?? "").trim();
|
|
const ackReactionScope = cfg.messages?.ackReactionScope ?? "group-mentions";
|
|
const mediaMaxBytes =
|
|
(opts.mediaMaxMb ?? slackCfg.mediaMaxMb ?? 20) * 1024 * 1024;
|
|
|
|
const logger = getChildLogger({ module: "slack-auto-reply" });
|
|
const channelCache = new Map<
|
|
string,
|
|
{
|
|
name?: string;
|
|
type?: SlackMessageEvent["channel_type"];
|
|
topic?: string;
|
|
purpose?: string;
|
|
}
|
|
>();
|
|
const userCache = new Map<string, { name?: string }>();
|
|
const seenMessages = new Map<string, number>();
|
|
|
|
const markMessageSeen = (channelId: string | undefined, ts?: string) => {
|
|
if (!channelId || !ts) return false;
|
|
const key = `${channelId}:${ts}`;
|
|
if (seenMessages.has(key)) return true;
|
|
seenMessages.set(key, Date.now());
|
|
if (seenMessages.size > 500) {
|
|
const cutoff = Date.now() - 60_000;
|
|
for (const [entry, seenAt] of seenMessages) {
|
|
if (seenAt < cutoff || seenMessages.size > 450) {
|
|
seenMessages.delete(entry);
|
|
} else {
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
return false;
|
|
};
|
|
|
|
const app = new App({
|
|
token: botToken,
|
|
appToken,
|
|
socketMode: true,
|
|
});
|
|
|
|
let botUserId = "";
|
|
let teamId = "";
|
|
try {
|
|
const auth = await app.client.auth.test({ token: botToken });
|
|
botUserId = auth.user_id ?? "";
|
|
teamId = auth.team_id ?? "";
|
|
} catch (err) {
|
|
runtime.error?.(danger(`slack auth failed: ${String(err)}`));
|
|
}
|
|
|
|
const resolveChannelName = async (channelId: string) => {
|
|
const cached = channelCache.get(channelId);
|
|
if (cached) return cached;
|
|
try {
|
|
const info = await app.client.conversations.info({
|
|
token: botToken,
|
|
channel: channelId,
|
|
});
|
|
const name =
|
|
info.channel && "name" in info.channel ? info.channel.name : undefined;
|
|
const channel = info.channel ?? undefined;
|
|
const type: SlackMessageEvent["channel_type"] | undefined = channel?.is_im
|
|
? "im"
|
|
: channel?.is_mpim
|
|
? "mpim"
|
|
: channel?.is_channel
|
|
? "channel"
|
|
: channel?.is_group
|
|
? "group"
|
|
: undefined;
|
|
const topic =
|
|
channel && "topic" in channel
|
|
? (channel.topic?.value ?? undefined)
|
|
: undefined;
|
|
const purpose =
|
|
channel && "purpose" in channel
|
|
? (channel.purpose?.value ?? undefined)
|
|
: undefined;
|
|
const entry = { name, type, topic, purpose };
|
|
channelCache.set(channelId, entry);
|
|
return entry;
|
|
} catch {
|
|
return {};
|
|
}
|
|
};
|
|
|
|
const resolveUserName = async (userId: string) => {
|
|
const cached = userCache.get(userId);
|
|
if (cached) return cached;
|
|
try {
|
|
const info = await app.client.users.info({
|
|
token: botToken,
|
|
user: userId,
|
|
});
|
|
const profile = info.user?.profile;
|
|
const name =
|
|
profile?.display_name ||
|
|
profile?.real_name ||
|
|
info.user?.name ||
|
|
undefined;
|
|
const entry = { name };
|
|
userCache.set(userId, entry);
|
|
return entry;
|
|
} catch {
|
|
return {};
|
|
}
|
|
};
|
|
|
|
const setSlackThreadStatus = async (params: {
|
|
channelId: string;
|
|
threadTs?: string;
|
|
status: string;
|
|
}) => {
|
|
if (!params.threadTs) return;
|
|
const payload = {
|
|
token: botToken,
|
|
channel_id: params.channelId,
|
|
thread_ts: params.threadTs,
|
|
status: params.status,
|
|
};
|
|
const client = app.client as unknown as {
|
|
assistant?: {
|
|
threads?: {
|
|
setStatus?: (args: typeof payload) => Promise<unknown>;
|
|
};
|
|
};
|
|
apiCall?: (method: string, args: typeof payload) => Promise<unknown>;
|
|
};
|
|
try {
|
|
if (client.assistant?.threads?.setStatus) {
|
|
await client.assistant.threads.setStatus(payload);
|
|
return;
|
|
}
|
|
if (typeof client.apiCall === "function") {
|
|
await client.apiCall("assistant.threads.setStatus", payload);
|
|
}
|
|
} catch (err) {
|
|
logVerbose(
|
|
`slack status update failed for channel ${params.channelId}: ${String(err)}`,
|
|
);
|
|
}
|
|
};
|
|
|
|
const isChannelAllowed = (params: {
|
|
channelId?: string;
|
|
channelName?: string;
|
|
channelType?: SlackMessageEvent["channel_type"];
|
|
}) => {
|
|
const channelType = params.channelType;
|
|
const isDirectMessage = channelType === "im";
|
|
const isGroupDm = channelType === "mpim";
|
|
const isRoom = channelType === "channel" || channelType === "group";
|
|
|
|
if (isDirectMessage && !dmEnabled) return false;
|
|
if (isGroupDm && !groupDmEnabled) return false;
|
|
|
|
if (isGroupDm && groupDmChannels.length > 0) {
|
|
const allowList = normalizeAllowListLower(groupDmChannels);
|
|
const candidates = [
|
|
params.channelId,
|
|
params.channelName ? `#${params.channelName}` : undefined,
|
|
params.channelName,
|
|
params.channelName ? normalizeSlackSlug(params.channelName) : undefined,
|
|
]
|
|
.filter((value): value is string => Boolean(value))
|
|
.map((value) => value.toLowerCase());
|
|
const permitted =
|
|
allowList.includes("*") ||
|
|
candidates.some((candidate) => allowList.includes(candidate));
|
|
if (!permitted) return false;
|
|
}
|
|
|
|
if (isRoom && params.channelId) {
|
|
const channelConfig = resolveSlackChannelConfig({
|
|
channelId: params.channelId,
|
|
channelName: params.channelName,
|
|
channels: channelsConfig,
|
|
});
|
|
const channelAllowed = channelConfig?.allowed !== false;
|
|
const channelAllowlistConfigured =
|
|
Boolean(channelsConfig) && Object.keys(channelsConfig ?? {}).length > 0;
|
|
if (
|
|
!isSlackRoomAllowedByPolicy({
|
|
groupPolicy,
|
|
channelAllowlistConfigured,
|
|
channelAllowed,
|
|
})
|
|
) {
|
|
return false;
|
|
}
|
|
if (!channelAllowed) return false;
|
|
}
|
|
|
|
return true;
|
|
};
|
|
|
|
const handleSlackMessage = async (
|
|
message: SlackMessageEvent,
|
|
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"
|
|
) {
|
|
return;
|
|
}
|
|
if (!message.user) return;
|
|
if (markMessageSeen(message.channel, message.ts)) return;
|
|
|
|
let channelInfo: {
|
|
name?: string;
|
|
type?: SlackMessageEvent["channel_type"];
|
|
topic?: string;
|
|
purpose?: string;
|
|
} = {};
|
|
let channelType = message.channel_type;
|
|
if (!channelType || channelType !== "im") {
|
|
channelInfo = await resolveChannelName(message.channel);
|
|
channelType = channelType ?? channelInfo.type;
|
|
}
|
|
const channelName = channelInfo?.name;
|
|
const resolvedChannelType = channelType;
|
|
const isDirectMessage = resolvedChannelType === "im";
|
|
const isGroupDm = resolvedChannelType === "mpim";
|
|
const isRoom =
|
|
resolvedChannelType === "channel" || resolvedChannelType === "group";
|
|
|
|
if (
|
|
!isChannelAllowed({
|
|
channelId: message.channel,
|
|
channelName,
|
|
channelType: resolvedChannelType,
|
|
})
|
|
) {
|
|
logVerbose("slack: drop message (channel not allowed)");
|
|
return;
|
|
}
|
|
|
|
const storeAllowFrom = await readProviderAllowFromStore("slack").catch(
|
|
() => [],
|
|
);
|
|
const effectiveAllowFrom = normalizeAllowList([
|
|
...allowFrom,
|
|
...storeAllowFrom,
|
|
]);
|
|
const effectiveAllowFromLower = normalizeAllowListLower(effectiveAllowFrom);
|
|
|
|
if (isDirectMessage) {
|
|
if (!dmEnabled || dmPolicy === "disabled") {
|
|
logVerbose("slack: drop dm (dms disabled)");
|
|
return;
|
|
}
|
|
if (dmPolicy !== "open") {
|
|
const permitted = allowListMatches({
|
|
allowList: effectiveAllowFromLower,
|
|
id: message.user,
|
|
});
|
|
if (!permitted) {
|
|
if (dmPolicy === "pairing") {
|
|
const sender = await resolveUserName(message.user);
|
|
const senderName = sender?.name ?? undefined;
|
|
const { code, created } = await upsertProviderPairingRequest({
|
|
provider: "slack",
|
|
id: message.user,
|
|
meta: { name: senderName },
|
|
});
|
|
if (created) {
|
|
logVerbose(
|
|
`slack pairing request sender=${message.user} name=${senderName ?? "unknown"}`,
|
|
);
|
|
try {
|
|
await sendMessageSlack(
|
|
message.channel,
|
|
[
|
|
"Clawdbot: access not configured.",
|
|
"",
|
|
`Pairing code: ${code}`,
|
|
"",
|
|
"Ask the bot owner to approve with:",
|
|
"clawdbot pairing approve --provider slack <code>",
|
|
].join("\n"),
|
|
{
|
|
token: botToken,
|
|
client: app.client,
|
|
accountId: account.accountId,
|
|
},
|
|
);
|
|
} catch (err) {
|
|
logVerbose(
|
|
`slack pairing reply failed for ${message.user}: ${String(err)}`,
|
|
);
|
|
}
|
|
}
|
|
} else {
|
|
logVerbose(
|
|
`Blocked unauthorized slack sender ${message.user} (dmPolicy=${dmPolicy})`,
|
|
);
|
|
}
|
|
return;
|
|
}
|
|
}
|
|
}
|
|
|
|
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 channelUserAuthorized = isRoom
|
|
? resolveSlackUserAllowed({
|
|
allowList: channelConfig?.users,
|
|
userId: message.user,
|
|
userName: senderName,
|
|
})
|
|
: true;
|
|
if (isRoom && !channelUserAuthorized) {
|
|
logVerbose(
|
|
`Blocked unauthorized slack sender ${message.user} (not in channel users)`,
|
|
);
|
|
return;
|
|
}
|
|
const allowList = effectiveAllowFromLower;
|
|
const commandAuthorized =
|
|
(allowList.length === 0 ||
|
|
allowListMatches({
|
|
allowList,
|
|
id: message.user,
|
|
name: senderName,
|
|
})) &&
|
|
channelUserAuthorized;
|
|
const hasAnyMention = /<@[^>]+>/.test(message.text ?? "");
|
|
const allowTextCommands = shouldHandleTextCommands({
|
|
cfg,
|
|
surface: "slack",
|
|
});
|
|
const shouldRequireMention = isRoom
|
|
? (channelConfig?.requireMention ?? true)
|
|
: false;
|
|
const shouldBypassMention =
|
|
allowTextCommands &&
|
|
isRoom &&
|
|
shouldRequireMention &&
|
|
!wasMentioned &&
|
|
!hasAnyMention &&
|
|
commandAuthorized &&
|
|
hasControlCommand(message.text ?? "");
|
|
const canDetectMention = Boolean(botUserId) || mentionRegexes.length > 0;
|
|
if (
|
|
isRoom &&
|
|
shouldRequireMention &&
|
|
canDetectMention &&
|
|
!wasMentioned &&
|
|
!shouldBypassMention
|
|
) {
|
|
logger.info(
|
|
{ channel: message.channel, reason: "no-mention" },
|
|
"skipping room message",
|
|
);
|
|
return;
|
|
}
|
|
|
|
const media = await resolveSlackMedia({
|
|
files: message.files,
|
|
token: botToken,
|
|
maxBytes: mediaMaxBytes,
|
|
});
|
|
const rawBody = (message.text ?? "").trim() || media?.placeholder || "";
|
|
if (!rawBody) return;
|
|
const shouldAckReaction = () => {
|
|
if (!ackReaction) return false;
|
|
if (ackReactionScope === "all") return true;
|
|
if (ackReactionScope === "direct") return isDirectMessage;
|
|
const isGroupChat = isRoom || isGroupDm;
|
|
if (ackReactionScope === "group-all") return isGroupChat;
|
|
if (ackReactionScope === "group-mentions") {
|
|
if (!isRoom) return false;
|
|
if (!shouldRequireMention) return false;
|
|
if (!canDetectMention) return false;
|
|
return wasMentioned || shouldBypassMention;
|
|
}
|
|
return false;
|
|
};
|
|
if (shouldAckReaction() && message.ts) {
|
|
reactSlackMessage(message.channel, message.ts, ackReaction, {
|
|
token: botToken,
|
|
client: app.client,
|
|
}).catch((err) => {
|
|
logVerbose(
|
|
`slack react failed for channel ${message.channel}: ${String(err)}`,
|
|
);
|
|
});
|
|
}
|
|
|
|
const roomLabel = channelName ? `#${channelName}` : `#${message.channel}`;
|
|
|
|
const preview = rawBody.replace(/\s+/g, " ").slice(0, 160);
|
|
const inboundLabel = isDirectMessage
|
|
? `Slack DM from ${senderName}`
|
|
: `Slack message in ${roomLabel} from ${senderName}`;
|
|
const slackFrom = isDirectMessage
|
|
? `slack:${message.user}`
|
|
: isRoom
|
|
? `slack:channel:${message.channel}`
|
|
: `slack:group:${message.channel}`;
|
|
const route = resolveAgentRoute({
|
|
cfg,
|
|
provider: "slack",
|
|
accountId: account.accountId,
|
|
teamId: teamId || undefined,
|
|
peer: {
|
|
kind: isDirectMessage ? "dm" : isRoom ? "channel" : "group",
|
|
id: isDirectMessage ? (message.user ?? "unknown") : message.channel,
|
|
},
|
|
});
|
|
const baseSessionKey = route.sessionKey;
|
|
const threadTs = message.thread_ts;
|
|
const hasThreadTs = typeof threadTs === "string" && threadTs.length > 0;
|
|
const isThreadReply =
|
|
hasThreadTs &&
|
|
(threadTs !== message.ts || Boolean(message.parent_user_id));
|
|
const threadKeys = resolveThreadSessionKeys({
|
|
baseSessionKey,
|
|
threadId: isThreadReply ? threadTs : undefined,
|
|
parentSessionKey: isThreadReply ? baseSessionKey : undefined,
|
|
});
|
|
const sessionKey = threadKeys.sessionKey;
|
|
enqueueSystemEvent(`${inboundLabel}: ${preview}`, {
|
|
sessionKey,
|
|
contextKey: `slack:message:${message.channel}:${message.ts ?? "unknown"}`,
|
|
});
|
|
|
|
const textWithId = `${rawBody}\n[slack message id: ${message.ts} channel: ${message.channel}]`;
|
|
const body = formatAgentEnvelope({
|
|
provider: "Slack",
|
|
from: senderName,
|
|
timestamp: message.ts ? Math.round(Number(message.ts) * 1000) : undefined,
|
|
body: textWithId,
|
|
});
|
|
|
|
const isRoomish = isRoom || isGroupDm;
|
|
const slackTo = isDirectMessage
|
|
? `user:${message.user}`
|
|
: `channel:${message.channel}`;
|
|
const channelDescription = [channelInfo?.topic, channelInfo?.purpose]
|
|
.map((entry) => entry?.trim())
|
|
.filter((entry): entry is string => Boolean(entry))
|
|
.filter((entry, index, list) => list.indexOf(entry) === index)
|
|
.join("\n");
|
|
const systemPromptParts = [
|
|
channelDescription ? `Channel description: ${channelDescription}` : null,
|
|
channelConfig?.systemPrompt?.trim() || null,
|
|
].filter((entry): entry is string => Boolean(entry));
|
|
const groupSystemPrompt =
|
|
systemPromptParts.length > 0 ? systemPromptParts.join("\n\n") : undefined;
|
|
let threadStarterBody: string | undefined;
|
|
let threadLabel: string | undefined;
|
|
if (isThreadReply && threadTs) {
|
|
const starter = await resolveSlackThreadStarter({
|
|
channelId: message.channel,
|
|
threadTs,
|
|
client: app.client,
|
|
});
|
|
if (starter?.text) {
|
|
const starterUser = starter.userId
|
|
? await resolveUserName(starter.userId)
|
|
: null;
|
|
const starterName = starterUser?.name ?? starter.userId ?? "Unknown";
|
|
const starterWithId = `${starter.text}\n[slack message id: ${starter.ts ?? threadTs} channel: ${message.channel}]`;
|
|
threadStarterBody = formatThreadStarterEnvelope({
|
|
provider: "Slack",
|
|
author: starterName,
|
|
timestamp: starter.ts
|
|
? Math.round(Number(starter.ts) * 1000)
|
|
: undefined,
|
|
body: starterWithId,
|
|
});
|
|
const snippet = starter.text.replace(/\s+/g, " ").slice(0, 80);
|
|
threadLabel = `Slack thread ${roomLabel}${snippet ? `: ${snippet}` : ""}`;
|
|
} else {
|
|
threadLabel = `Slack thread ${roomLabel}`;
|
|
}
|
|
}
|
|
const ctxPayload = {
|
|
Body: body,
|
|
From: slackFrom,
|
|
To: slackTo,
|
|
SessionKey: sessionKey,
|
|
AccountId: route.accountId,
|
|
ChatType: isDirectMessage ? "direct" : isRoom ? "room" : "group",
|
|
GroupSubject: isRoomish ? roomLabel : undefined,
|
|
GroupSystemPrompt: isRoomish ? groupSystemPrompt : undefined,
|
|
SenderName: senderName,
|
|
SenderId: message.user,
|
|
Provider: "slack" as const,
|
|
Surface: "slack" as const,
|
|
MessageSid: message.ts,
|
|
ReplyToId: message.thread_ts ?? message.ts,
|
|
ParentSessionKey: threadKeys.parentSessionKey,
|
|
ThreadStarterBody: threadStarterBody,
|
|
ThreadLabel: threadLabel,
|
|
Timestamp: message.ts ? Math.round(Number(message.ts) * 1000) : undefined,
|
|
WasMentioned: isRoomish ? wasMentioned : undefined,
|
|
MediaPath: media?.path,
|
|
MediaType: media?.contentType,
|
|
MediaUrl: media?.path,
|
|
CommandAuthorized: commandAuthorized,
|
|
// Originating channel for reply routing.
|
|
OriginatingChannel: "slack" as const,
|
|
OriginatingTo: slackTo,
|
|
};
|
|
|
|
const replyTarget = ctxPayload.To ?? undefined;
|
|
if (!replyTarget) {
|
|
runtime.error?.(danger("slack: missing reply target"));
|
|
return;
|
|
}
|
|
|
|
if (isDirectMessage) {
|
|
const sessionCfg = cfg.session;
|
|
const storePath = resolveStorePath(sessionCfg?.store, {
|
|
agentId: route.agentId,
|
|
});
|
|
await updateLastRoute({
|
|
storePath,
|
|
sessionKey: route.mainSessionKey,
|
|
provider: "slack",
|
|
to: `user:${message.user}`,
|
|
accountId: route.accountId,
|
|
});
|
|
}
|
|
|
|
if (shouldLogVerbose()) {
|
|
logVerbose(
|
|
`slack inbound: channel=${message.channel} from=${ctxPayload.From} preview="${preview}"`,
|
|
);
|
|
}
|
|
|
|
// Only thread replies if the incoming message was in a thread.
|
|
const incomingThreadTs = message.thread_ts;
|
|
const statusThreadTs = message.thread_ts ?? message.ts;
|
|
let didSetStatus = false;
|
|
const onReplyStart = async () => {
|
|
didSetStatus = true;
|
|
await setSlackThreadStatus({
|
|
channelId: message.channel,
|
|
threadTs: statusThreadTs,
|
|
status: "is typing...",
|
|
});
|
|
};
|
|
const { dispatcher, replyOptions, markDispatchIdle } =
|
|
createReplyDispatcherWithTyping({
|
|
responsePrefix: cfg.messages?.responsePrefix,
|
|
deliver: async (payload) => {
|
|
await deliverReplies({
|
|
replies: [payload],
|
|
target: replyTarget,
|
|
token: botToken,
|
|
accountId: account.accountId,
|
|
runtime,
|
|
textLimit,
|
|
threadTs: incomingThreadTs,
|
|
});
|
|
},
|
|
onError: (err, info) => {
|
|
runtime.error?.(
|
|
danger(`slack ${info.kind} reply failed: ${String(err)}`),
|
|
);
|
|
if (didSetStatus) {
|
|
void setSlackThreadStatus({
|
|
channelId: message.channel,
|
|
threadTs: statusThreadTs,
|
|
status: "",
|
|
});
|
|
}
|
|
},
|
|
onReplyStart,
|
|
});
|
|
|
|
const { queuedFinal, counts } = await dispatchReplyFromConfig({
|
|
ctx: ctxPayload,
|
|
cfg,
|
|
dispatcher,
|
|
replyOptions: { ...replyOptions, skillFilter: channelConfig?.skills },
|
|
});
|
|
markDispatchIdle();
|
|
if (didSetStatus) {
|
|
await setSlackThreadStatus({
|
|
channelId: message.channel,
|
|
threadTs: statusThreadTs,
|
|
status: "",
|
|
});
|
|
}
|
|
if (!queuedFinal) return;
|
|
if (shouldLogVerbose()) {
|
|
const finalCount = counts.final;
|
|
logVerbose(
|
|
`slack: delivered ${finalCount} reply${finalCount === 1 ? "" : "ies"} to ${replyTarget}`,
|
|
);
|
|
}
|
|
};
|
|
|
|
app.event(
|
|
"message",
|
|
async ({ event }: SlackEventMiddlewareArgs<"message">) => {
|
|
try {
|
|
const message = event as SlackMessageEvent;
|
|
if (message.subtype === "message_changed") {
|
|
const changed = event as SlackMessageChangedEvent;
|
|
const channelId = changed.channel;
|
|
const channelInfo = channelId
|
|
? await resolveChannelName(channelId)
|
|
: {};
|
|
const channelType = channelInfo?.type;
|
|
if (
|
|
!isChannelAllowed({
|
|
channelId,
|
|
channelName: channelInfo?.name,
|
|
channelType,
|
|
})
|
|
) {
|
|
return;
|
|
}
|
|
const messageId = changed.message?.ts ?? changed.previous_message?.ts;
|
|
const label = resolveSlackChannelLabel({
|
|
channelId,
|
|
channelName: channelInfo?.name,
|
|
});
|
|
const sessionKey = resolveSlackSystemEventSessionKey({
|
|
channelId,
|
|
channelType,
|
|
});
|
|
enqueueSystemEvent(`Slack message edited in ${label}.`, {
|
|
sessionKey,
|
|
contextKey: `slack:message:changed:${channelId ?? "unknown"}:${messageId ?? changed.event_ts ?? "unknown"}`,
|
|
});
|
|
return;
|
|
}
|
|
if (message.subtype === "message_deleted") {
|
|
const deleted = event as SlackMessageDeletedEvent;
|
|
const channelId = deleted.channel;
|
|
const channelInfo = channelId
|
|
? await resolveChannelName(channelId)
|
|
: {};
|
|
const channelType = channelInfo?.type;
|
|
if (
|
|
!isChannelAllowed({
|
|
channelId,
|
|
channelName: channelInfo?.name,
|
|
channelType,
|
|
})
|
|
) {
|
|
return;
|
|
}
|
|
const label = resolveSlackChannelLabel({
|
|
channelId,
|
|
channelName: channelInfo?.name,
|
|
});
|
|
const sessionKey = resolveSlackSystemEventSessionKey({
|
|
channelId,
|
|
channelType,
|
|
});
|
|
enqueueSystemEvent(`Slack message deleted in ${label}.`, {
|
|
sessionKey,
|
|
contextKey: `slack:message:deleted:${channelId ?? "unknown"}:${deleted.deleted_ts ?? deleted.event_ts ?? "unknown"}`,
|
|
});
|
|
return;
|
|
}
|
|
if (message.subtype === "thread_broadcast") {
|
|
const thread = event as SlackThreadBroadcastEvent;
|
|
const channelId = thread.channel;
|
|
const channelInfo = channelId
|
|
? await resolveChannelName(channelId)
|
|
: {};
|
|
const channelType = channelInfo?.type;
|
|
if (
|
|
!isChannelAllowed({
|
|
channelId,
|
|
channelName: channelInfo?.name,
|
|
channelType,
|
|
})
|
|
) {
|
|
return;
|
|
}
|
|
const label = resolveSlackChannelLabel({
|
|
channelId,
|
|
channelName: channelInfo?.name,
|
|
});
|
|
const messageId = thread.message?.ts ?? thread.event_ts;
|
|
const sessionKey = resolveSlackSystemEventSessionKey({
|
|
channelId,
|
|
channelType,
|
|
});
|
|
enqueueSystemEvent(`Slack thread reply broadcast in ${label}.`, {
|
|
sessionKey,
|
|
contextKey: `slack:thread:broadcast:${channelId ?? "unknown"}:${messageId ?? "unknown"}`,
|
|
});
|
|
return;
|
|
}
|
|
await handleSlackMessage(message, { source: "message" });
|
|
} catch (err) {
|
|
runtime.error?.(danger(`slack handler failed: ${String(err)}`));
|
|
}
|
|
},
|
|
);
|
|
|
|
app.event(
|
|
"app_mention",
|
|
async ({ event }: SlackEventMiddlewareArgs<"app_mention">) => {
|
|
try {
|
|
const mention = event as SlackAppMentionEvent;
|
|
await handleSlackMessage(mention as unknown as SlackMessageEvent, {
|
|
source: "app_mention",
|
|
wasMentioned: true,
|
|
});
|
|
} catch (err) {
|
|
runtime.error?.(danger(`slack mention handler failed: ${String(err)}`));
|
|
}
|
|
},
|
|
);
|
|
|
|
const handleReactionEvent = async (
|
|
event: SlackReactionEvent,
|
|
action: "added" | "removed",
|
|
) => {
|
|
try {
|
|
const item = event.item;
|
|
if (!event.user) return;
|
|
if (!item?.channel || !item?.ts) return;
|
|
if (item.type && item.type !== "message") return;
|
|
if (botUserId && event.user === botUserId) return;
|
|
|
|
const channelInfo = await resolveChannelName(item.channel);
|
|
const channelType = channelInfo?.type;
|
|
const isDirectMessage = channelType === "im";
|
|
const isGroupDm = channelType === "mpim";
|
|
const isRoom = channelType === "channel" || channelType === "group";
|
|
const channelName = channelInfo?.name;
|
|
|
|
if (isDirectMessage && !dmEnabled) return;
|
|
if (isGroupDm && !groupDmEnabled) return;
|
|
if (isGroupDm && groupDmChannels.length > 0) {
|
|
const allowList = normalizeAllowListLower(groupDmChannels);
|
|
const candidates = [
|
|
item.channel,
|
|
channelName ? `#${channelName}` : undefined,
|
|
channelName,
|
|
channelName ? normalizeSlackSlug(channelName) : undefined,
|
|
]
|
|
.filter((value): value is string => Boolean(value))
|
|
.map((value) => value.toLowerCase());
|
|
const permitted =
|
|
allowList.includes("*") ||
|
|
candidates.some((candidate) => allowList.includes(candidate));
|
|
if (!permitted) return;
|
|
}
|
|
|
|
if (isRoom) {
|
|
const channelConfig = resolveSlackChannelConfig({
|
|
channelId: item.channel,
|
|
channelName,
|
|
channels: channelsConfig,
|
|
});
|
|
if (channelConfig?.allowed === false) return;
|
|
}
|
|
|
|
const actor = await resolveUserName(event.user);
|
|
const shouldNotify = shouldEmitSlackReactionNotification({
|
|
mode: reactionMode,
|
|
botId: botUserId,
|
|
messageAuthorId: event.item_user ?? undefined,
|
|
userId: event.user,
|
|
userName: actor?.name ?? undefined,
|
|
allowlist: reactionAllowlist,
|
|
});
|
|
if (!shouldNotify) return;
|
|
|
|
const emojiLabel = event.reaction ?? "emoji";
|
|
const actorLabel = actor?.name ?? event.user;
|
|
const channelLabel = channelName
|
|
? `#${normalizeSlackSlug(channelName) || channelName}`
|
|
: `#${item.channel}`;
|
|
const authorInfo = event.item_user
|
|
? await resolveUserName(event.item_user)
|
|
: undefined;
|
|
const authorLabel = authorInfo?.name ?? event.item_user;
|
|
const baseText = `Slack reaction ${action}: :${emojiLabel}: by ${actorLabel} in ${channelLabel} msg ${item.ts}`;
|
|
const text = authorLabel ? `${baseText} from ${authorLabel}` : baseText;
|
|
const sessionKey = resolveSlackSystemEventSessionKey({
|
|
channelId: item.channel,
|
|
channelType,
|
|
});
|
|
enqueueSystemEvent(text, {
|
|
sessionKey,
|
|
contextKey: `slack:reaction:${action}:${item.channel}:${item.ts}:${event.user}:${emojiLabel}`,
|
|
});
|
|
} catch (err) {
|
|
runtime.error?.(danger(`slack reaction handler failed: ${String(err)}`));
|
|
}
|
|
};
|
|
|
|
app.event(
|
|
"reaction_added",
|
|
async ({ event }: SlackEventMiddlewareArgs<"reaction_added">) => {
|
|
await handleReactionEvent(event as SlackReactionEvent, "added");
|
|
},
|
|
);
|
|
|
|
app.event(
|
|
"reaction_removed",
|
|
async ({ event }: SlackEventMiddlewareArgs<"reaction_removed">) => {
|
|
await handleReactionEvent(event as SlackReactionEvent, "removed");
|
|
},
|
|
);
|
|
|
|
app.event(
|
|
"member_joined_channel",
|
|
async ({ event }: SlackEventMiddlewareArgs<"member_joined_channel">) => {
|
|
try {
|
|
const payload = event as SlackMemberChannelEvent;
|
|
const channelId = payload.channel;
|
|
const channelInfo = channelId
|
|
? await resolveChannelName(channelId)
|
|
: {};
|
|
const channelType = payload.channel_type ?? channelInfo?.type;
|
|
if (
|
|
!isChannelAllowed({
|
|
channelId,
|
|
channelName: channelInfo?.name,
|
|
channelType,
|
|
})
|
|
) {
|
|
return;
|
|
}
|
|
const userInfo = payload.user
|
|
? await resolveUserName(payload.user)
|
|
: {};
|
|
const userLabel = userInfo?.name ?? payload.user ?? "someone";
|
|
const label = resolveSlackChannelLabel({
|
|
channelId,
|
|
channelName: channelInfo?.name,
|
|
});
|
|
const sessionKey = resolveSlackSystemEventSessionKey({
|
|
channelId,
|
|
channelType,
|
|
});
|
|
enqueueSystemEvent(`Slack: ${userLabel} joined ${label}.`, {
|
|
sessionKey,
|
|
contextKey: `slack:member:joined:${channelId ?? "unknown"}:${payload.user ?? "unknown"}`,
|
|
});
|
|
} catch (err) {
|
|
runtime.error?.(danger(`slack join handler failed: ${String(err)}`));
|
|
}
|
|
},
|
|
);
|
|
|
|
app.event(
|
|
"member_left_channel",
|
|
async ({ event }: SlackEventMiddlewareArgs<"member_left_channel">) => {
|
|
try {
|
|
const payload = event as SlackMemberChannelEvent;
|
|
const channelId = payload.channel;
|
|
const channelInfo = channelId
|
|
? await resolveChannelName(channelId)
|
|
: {};
|
|
const channelType = payload.channel_type ?? channelInfo?.type;
|
|
if (
|
|
!isChannelAllowed({
|
|
channelId,
|
|
channelName: channelInfo?.name,
|
|
channelType,
|
|
})
|
|
) {
|
|
return;
|
|
}
|
|
const userInfo = payload.user
|
|
? await resolveUserName(payload.user)
|
|
: {};
|
|
const userLabel = userInfo?.name ?? payload.user ?? "someone";
|
|
const label = resolveSlackChannelLabel({
|
|
channelId,
|
|
channelName: channelInfo?.name,
|
|
});
|
|
const sessionKey = resolveSlackSystemEventSessionKey({
|
|
channelId,
|
|
channelType,
|
|
});
|
|
enqueueSystemEvent(`Slack: ${userLabel} left ${label}.`, {
|
|
sessionKey,
|
|
contextKey: `slack:member:left:${channelId ?? "unknown"}:${payload.user ?? "unknown"}`,
|
|
});
|
|
} catch (err) {
|
|
runtime.error?.(danger(`slack leave handler failed: ${String(err)}`));
|
|
}
|
|
},
|
|
);
|
|
|
|
app.event(
|
|
"channel_created",
|
|
async ({ event }: SlackEventMiddlewareArgs<"channel_created">) => {
|
|
try {
|
|
const payload = event as SlackChannelCreatedEvent;
|
|
const channelId = payload.channel?.id;
|
|
const channelName = payload.channel?.name;
|
|
if (
|
|
!isChannelAllowed({
|
|
channelId,
|
|
channelName,
|
|
channelType: "channel",
|
|
})
|
|
) {
|
|
return;
|
|
}
|
|
const label = resolveSlackChannelLabel({ channelId, channelName });
|
|
const sessionKey = resolveSlackSystemEventSessionKey({
|
|
channelId,
|
|
channelType: "channel",
|
|
});
|
|
enqueueSystemEvent(`Slack channel created: ${label}.`, {
|
|
sessionKey,
|
|
contextKey: `slack:channel:created:${channelId ?? channelName ?? "unknown"}`,
|
|
});
|
|
} catch (err) {
|
|
runtime.error?.(
|
|
danger(`slack channel created handler failed: ${String(err)}`),
|
|
);
|
|
}
|
|
},
|
|
);
|
|
|
|
app.event(
|
|
"channel_rename",
|
|
async ({ event }: SlackEventMiddlewareArgs<"channel_rename">) => {
|
|
try {
|
|
const payload = event as SlackChannelRenamedEvent;
|
|
const channelId = payload.channel?.id;
|
|
const channelName =
|
|
payload.channel?.name_normalized ?? payload.channel?.name;
|
|
if (
|
|
!isChannelAllowed({
|
|
channelId,
|
|
channelName,
|
|
channelType: "channel",
|
|
})
|
|
) {
|
|
return;
|
|
}
|
|
const label = resolveSlackChannelLabel({ channelId, channelName });
|
|
const sessionKey = resolveSlackSystemEventSessionKey({
|
|
channelId,
|
|
channelType: "channel",
|
|
});
|
|
enqueueSystemEvent(`Slack channel renamed: ${label}.`, {
|
|
sessionKey,
|
|
contextKey: `slack:channel:renamed:${channelId ?? channelName ?? "unknown"}`,
|
|
});
|
|
} catch (err) {
|
|
runtime.error?.(
|
|
danger(`slack channel rename handler failed: ${String(err)}`),
|
|
);
|
|
}
|
|
},
|
|
);
|
|
|
|
app.event(
|
|
"pin_added",
|
|
async ({ event }: SlackEventMiddlewareArgs<"pin_added">) => {
|
|
try {
|
|
const payload = event as SlackPinEvent;
|
|
const channelId = payload.channel_id;
|
|
const channelInfo = channelId
|
|
? await resolveChannelName(channelId)
|
|
: {};
|
|
if (
|
|
!isChannelAllowed({
|
|
channelId,
|
|
channelName: channelInfo?.name,
|
|
channelType: channelInfo?.type,
|
|
})
|
|
) {
|
|
return;
|
|
}
|
|
const label = resolveSlackChannelLabel({
|
|
channelId,
|
|
channelName: channelInfo?.name,
|
|
});
|
|
const userInfo = payload.user
|
|
? await resolveUserName(payload.user)
|
|
: {};
|
|
const userLabel = userInfo?.name ?? payload.user ?? "someone";
|
|
const itemType = payload.item?.type ?? "item";
|
|
const messageId = payload.item?.message?.ts ?? payload.event_ts;
|
|
const sessionKey = resolveSlackSystemEventSessionKey({
|
|
channelId,
|
|
channelType: channelInfo?.type ?? undefined,
|
|
});
|
|
enqueueSystemEvent(
|
|
`Slack: ${userLabel} pinned a ${itemType} in ${label}.`,
|
|
{
|
|
sessionKey,
|
|
contextKey: `slack:pin:added:${channelId ?? "unknown"}:${messageId ?? "unknown"}`,
|
|
},
|
|
);
|
|
} catch (err) {
|
|
runtime.error?.(
|
|
danger(`slack pin added handler failed: ${String(err)}`),
|
|
);
|
|
}
|
|
},
|
|
);
|
|
|
|
app.event(
|
|
"pin_removed",
|
|
async ({ event }: SlackEventMiddlewareArgs<"pin_removed">) => {
|
|
try {
|
|
const payload = event as SlackPinEvent;
|
|
const channelId = payload.channel_id;
|
|
const channelInfo = channelId
|
|
? await resolveChannelName(channelId)
|
|
: {};
|
|
if (
|
|
!isChannelAllowed({
|
|
channelId,
|
|
channelName: channelInfo?.name,
|
|
channelType: channelInfo?.type,
|
|
})
|
|
) {
|
|
return;
|
|
}
|
|
const label = resolveSlackChannelLabel({
|
|
channelId,
|
|
channelName: channelInfo?.name,
|
|
});
|
|
const userInfo = payload.user
|
|
? await resolveUserName(payload.user)
|
|
: {};
|
|
const userLabel = userInfo?.name ?? payload.user ?? "someone";
|
|
const itemType = payload.item?.type ?? "item";
|
|
const messageId = payload.item?.message?.ts ?? payload.event_ts;
|
|
const sessionKey = resolveSlackSystemEventSessionKey({
|
|
channelId,
|
|
channelType: channelInfo?.type ?? undefined,
|
|
});
|
|
enqueueSystemEvent(
|
|
`Slack: ${userLabel} unpinned a ${itemType} in ${label}.`,
|
|
{
|
|
sessionKey,
|
|
contextKey: `slack:pin:removed:${channelId ?? "unknown"}:${messageId ?? "unknown"}`,
|
|
},
|
|
);
|
|
} catch (err) {
|
|
runtime.error?.(
|
|
danger(`slack pin removed handler failed: ${String(err)}`),
|
|
);
|
|
}
|
|
},
|
|
);
|
|
|
|
const handleSlashCommand = async (params: {
|
|
command: SlackCommandMiddlewareArgs["command"];
|
|
ack: SlackCommandMiddlewareArgs["ack"];
|
|
respond: SlackCommandMiddlewareArgs["respond"];
|
|
prompt: string;
|
|
}) => {
|
|
const { command, ack, respond, prompt } = params;
|
|
try {
|
|
if (!prompt.trim()) {
|
|
await ack({
|
|
text: "Message required.",
|
|
response_type: "ephemeral",
|
|
});
|
|
return;
|
|
}
|
|
await ack();
|
|
|
|
if (botUserId && command.user_id === botUserId) return;
|
|
|
|
const channelInfo = await resolveChannelName(command.channel_id);
|
|
const channelType =
|
|
channelInfo?.type ??
|
|
(command.channel_name === "directmessage" ? "im" : undefined);
|
|
const isDirectMessage = channelType === "im";
|
|
const isGroupDm = channelType === "mpim";
|
|
const isRoom = channelType === "channel" || channelType === "group";
|
|
|
|
if (isDirectMessage && !dmEnabled) {
|
|
await respond({
|
|
text: "Slack DMs are disabled.",
|
|
response_type: "ephemeral",
|
|
});
|
|
return;
|
|
}
|
|
if (isGroupDm && !groupDmEnabled) {
|
|
await respond({
|
|
text: "Slack group DMs are disabled.",
|
|
response_type: "ephemeral",
|
|
});
|
|
return;
|
|
}
|
|
if (isGroupDm && groupDmChannels.length > 0) {
|
|
const allowList = normalizeAllowListLower(groupDmChannels);
|
|
const channelName = channelInfo?.name;
|
|
const candidates = [
|
|
command.channel_id,
|
|
channelName ? `#${channelName}` : undefined,
|
|
channelName,
|
|
channelName ? normalizeSlackSlug(channelName) : undefined,
|
|
]
|
|
.filter((value): value is string => Boolean(value))
|
|
.map((value) => value.toLowerCase());
|
|
const permitted =
|
|
allowList.includes("*") ||
|
|
candidates.some((candidate) => allowList.includes(candidate));
|
|
if (!permitted) {
|
|
await respond({
|
|
text: "This group DM is not allowed.",
|
|
response_type: "ephemeral",
|
|
});
|
|
return;
|
|
}
|
|
}
|
|
|
|
const storeAllowFrom = await readProviderAllowFromStore("slack").catch(
|
|
() => [],
|
|
);
|
|
const effectiveAllowFrom = normalizeAllowList([
|
|
...allowFrom,
|
|
...storeAllowFrom,
|
|
]);
|
|
const effectiveAllowFromLower =
|
|
normalizeAllowListLower(effectiveAllowFrom);
|
|
|
|
let commandAuthorized = true;
|
|
let channelConfig: SlackChannelConfigResolved | null = null;
|
|
if (isDirectMessage) {
|
|
if (!dmEnabled || dmPolicy === "disabled") {
|
|
await respond({
|
|
text: "Slack DMs are disabled.",
|
|
response_type: "ephemeral",
|
|
});
|
|
return;
|
|
}
|
|
if (dmPolicy !== "open") {
|
|
const sender = await resolveUserName(command.user_id);
|
|
const senderName = sender?.name ?? undefined;
|
|
const permitted = allowListMatches({
|
|
allowList: effectiveAllowFromLower,
|
|
id: command.user_id,
|
|
name: senderName,
|
|
});
|
|
if (!permitted) {
|
|
if (dmPolicy === "pairing") {
|
|
const { code, created } = await upsertProviderPairingRequest({
|
|
provider: "slack",
|
|
id: command.user_id,
|
|
meta: { name: senderName },
|
|
});
|
|
if (created) {
|
|
await respond({
|
|
text: [
|
|
"Clawdbot: access not configured.",
|
|
"",
|
|
`Pairing code: ${code}`,
|
|
"",
|
|
"Ask the bot owner to approve with:",
|
|
"clawdbot pairing approve --provider slack <code>",
|
|
].join("\n"),
|
|
response_type: "ephemeral",
|
|
});
|
|
}
|
|
} else {
|
|
await respond({
|
|
text: "You are not authorized to use this command.",
|
|
response_type: "ephemeral",
|
|
});
|
|
}
|
|
return;
|
|
}
|
|
commandAuthorized = true;
|
|
}
|
|
}
|
|
|
|
if (isRoom) {
|
|
channelConfig = resolveSlackChannelConfig({
|
|
channelId: command.channel_id,
|
|
channelName: channelInfo?.name,
|
|
channels: channelsConfig,
|
|
});
|
|
if (
|
|
useAccessGroups &&
|
|
!isSlackRoomAllowedByPolicy({
|
|
groupPolicy,
|
|
channelAllowlistConfigured:
|
|
Boolean(channelsConfig) &&
|
|
Object.keys(channelsConfig ?? {}).length > 0,
|
|
channelAllowed: channelConfig?.allowed !== false,
|
|
})
|
|
) {
|
|
await respond({
|
|
text: "This channel is not allowed.",
|
|
response_type: "ephemeral",
|
|
});
|
|
return;
|
|
}
|
|
if (useAccessGroups && channelConfig?.allowed === false) {
|
|
await respond({
|
|
text: "This channel is not allowed.",
|
|
response_type: "ephemeral",
|
|
});
|
|
return;
|
|
}
|
|
}
|
|
|
|
const sender = await resolveUserName(command.user_id);
|
|
const senderName = sender?.name ?? command.user_name ?? command.user_id;
|
|
const channelUserAllowed = isRoom
|
|
? resolveSlackUserAllowed({
|
|
allowList: channelConfig?.users,
|
|
userId: command.user_id,
|
|
userName: senderName,
|
|
})
|
|
: true;
|
|
if (isRoom && !channelUserAllowed) {
|
|
await respond({
|
|
text: "You are not authorized to use this command here.",
|
|
response_type: "ephemeral",
|
|
});
|
|
return;
|
|
}
|
|
const channelName = channelInfo?.name;
|
|
const roomLabel = channelName
|
|
? `#${channelName}`
|
|
: `#${command.channel_id}`;
|
|
const isRoomish = isRoom || isGroupDm;
|
|
const route = resolveAgentRoute({
|
|
cfg,
|
|
provider: "slack",
|
|
accountId: account.accountId,
|
|
teamId: teamId || undefined,
|
|
peer: {
|
|
kind: isDirectMessage ? "dm" : isRoom ? "channel" : "group",
|
|
id: isDirectMessage ? command.user_id : command.channel_id,
|
|
},
|
|
});
|
|
const channelDescription = [channelInfo?.topic, channelInfo?.purpose]
|
|
.map((entry) => entry?.trim())
|
|
.filter((entry): entry is string => Boolean(entry))
|
|
.filter((entry, index, list) => list.indexOf(entry) === index)
|
|
.join("\n");
|
|
const systemPromptParts = [
|
|
channelDescription
|
|
? `Channel description: ${channelDescription}`
|
|
: null,
|
|
channelConfig?.systemPrompt?.trim() || null,
|
|
].filter((entry): entry is string => Boolean(entry));
|
|
const groupSystemPrompt =
|
|
systemPromptParts.length > 0
|
|
? systemPromptParts.join("\n\n")
|
|
: undefined;
|
|
|
|
const ctxPayload = {
|
|
Body: prompt,
|
|
From: isDirectMessage
|
|
? `slack:${command.user_id}`
|
|
: isRoom
|
|
? `slack:channel:${command.channel_id}`
|
|
: `slack:group:${command.channel_id}`,
|
|
To: `slash:${command.user_id}`,
|
|
ChatType: isDirectMessage ? "direct" : isRoom ? "room" : "group",
|
|
GroupSubject: isRoomish ? roomLabel : undefined,
|
|
GroupSystemPrompt: isRoomish ? groupSystemPrompt : undefined,
|
|
SenderName: senderName,
|
|
SenderId: command.user_id,
|
|
Provider: "slack" as const,
|
|
Surface: "slack" as const,
|
|
WasMentioned: true,
|
|
MessageSid: command.trigger_id,
|
|
Timestamp: Date.now(),
|
|
SessionKey: `agent:${route.agentId}:${slashCommand.sessionPrefix}:${command.user_id}`,
|
|
CommandTargetSessionKey: route.sessionKey,
|
|
AccountId: route.accountId,
|
|
CommandSource: "native" as const,
|
|
CommandAuthorized: commandAuthorized,
|
|
// Originating channel for reply routing.
|
|
OriginatingChannel: "slack" as const,
|
|
OriginatingTo: `user:${command.user_id}`,
|
|
};
|
|
|
|
const replyResult = await getReplyFromConfig(
|
|
ctxPayload,
|
|
{ skillFilter: channelConfig?.skills },
|
|
cfg,
|
|
);
|
|
const replies = replyResult
|
|
? Array.isArray(replyResult)
|
|
? replyResult
|
|
: [replyResult]
|
|
: [];
|
|
|
|
await deliverSlackSlashReplies({
|
|
replies,
|
|
respond,
|
|
ephemeral: slashCommand.ephemeral,
|
|
textLimit,
|
|
});
|
|
} catch (err) {
|
|
runtime.error?.(danger(`slack slash handler failed: ${String(err)}`));
|
|
await respond({
|
|
text: "Sorry, something went wrong handling that command.",
|
|
response_type: "ephemeral",
|
|
});
|
|
}
|
|
};
|
|
|
|
const nativeCommands =
|
|
cfg.commands?.native === true ? listNativeCommandSpecs() : [];
|
|
if (nativeCommands.length > 0) {
|
|
for (const command of nativeCommands) {
|
|
app.command(
|
|
`/${command.name}`,
|
|
async ({ command: cmd, ack, respond }: SlackCommandMiddlewareArgs) => {
|
|
const prompt = buildCommandText(command.name, cmd.text);
|
|
await handleSlashCommand({ command: cmd, ack, respond, prompt });
|
|
},
|
|
);
|
|
}
|
|
} else if (slashCommand.enabled) {
|
|
app.command(
|
|
slashCommand.name,
|
|
async ({ command, ack, respond }: SlackCommandMiddlewareArgs) => {
|
|
await handleSlashCommand({
|
|
command,
|
|
ack,
|
|
respond,
|
|
prompt: command.text?.trim() ?? "",
|
|
});
|
|
},
|
|
);
|
|
}
|
|
|
|
const stopOnAbort = () => {
|
|
if (opts.abortSignal?.aborted) void app.stop();
|
|
};
|
|
opts.abortSignal?.addEventListener("abort", stopOnAbort, { once: true });
|
|
|
|
try {
|
|
await app.start();
|
|
runtime.log?.("slack socket mode connected");
|
|
if (opts.abortSignal?.aborted) return;
|
|
await new Promise<void>((resolve) => {
|
|
opts.abortSignal?.addEventListener("abort", () => resolve(), {
|
|
once: true,
|
|
});
|
|
});
|
|
} finally {
|
|
opts.abortSignal?.removeEventListener("abort", stopOnAbort);
|
|
await app.stop().catch(() => undefined);
|
|
}
|
|
}
|
|
|
|
async function deliverReplies(params: {
|
|
replies: ReplyPayload[];
|
|
target: string;
|
|
token: string;
|
|
accountId?: string;
|
|
runtime: RuntimeEnv;
|
|
textLimit: number;
|
|
threadTs?: string;
|
|
}) {
|
|
const chunkLimit = Math.min(params.textLimit, 4000);
|
|
for (const payload of params.replies) {
|
|
const mediaList =
|
|
payload.mediaUrls ?? (payload.mediaUrl ? [payload.mediaUrl] : []);
|
|
const text = payload.text ?? "";
|
|
if (!text && mediaList.length === 0) continue;
|
|
|
|
if (mediaList.length === 0) {
|
|
for (const chunk of chunkMarkdownText(text, chunkLimit)) {
|
|
const trimmed = chunk.trim();
|
|
if (!trimmed || trimmed === SILENT_REPLY_TOKEN) continue;
|
|
await sendMessageSlack(params.target, trimmed, {
|
|
token: params.token,
|
|
threadTs: params.threadTs,
|
|
accountId: params.accountId,
|
|
});
|
|
}
|
|
} else {
|
|
let first = true;
|
|
for (const mediaUrl of mediaList) {
|
|
const caption = first ? text : "";
|
|
first = false;
|
|
await sendMessageSlack(params.target, caption, {
|
|
token: params.token,
|
|
mediaUrl,
|
|
threadTs: params.threadTs,
|
|
accountId: params.accountId,
|
|
});
|
|
}
|
|
}
|
|
params.runtime.log?.(`delivered reply to ${params.target}`);
|
|
}
|
|
}
|
|
|
|
type SlackRespondFn = (payload: {
|
|
text: string;
|
|
response_type?: "ephemeral" | "in_channel";
|
|
}) => Promise<unknown>;
|
|
|
|
export function isSlackRoomAllowedByPolicy(params: {
|
|
groupPolicy: "open" | "disabled" | "allowlist";
|
|
channelAllowlistConfigured: boolean;
|
|
channelAllowed: boolean;
|
|
}): boolean {
|
|
const { groupPolicy, channelAllowlistConfigured, channelAllowed } = params;
|
|
if (groupPolicy === "disabled") return false;
|
|
if (groupPolicy === "open") return true;
|
|
if (!channelAllowlistConfigured) return false;
|
|
return channelAllowed;
|
|
}
|
|
|
|
async function deliverSlackSlashReplies(params: {
|
|
replies: ReplyPayload[];
|
|
respond: SlackRespondFn;
|
|
ephemeral: boolean;
|
|
textLimit: number;
|
|
}) {
|
|
const messages: string[] = [];
|
|
const chunkLimit = Math.min(params.textLimit, 4000);
|
|
for (const payload of params.replies) {
|
|
const textRaw = payload.text?.trim() ?? "";
|
|
const text =
|
|
textRaw && textRaw !== SILENT_REPLY_TOKEN ? textRaw : undefined;
|
|
const mediaList =
|
|
payload.mediaUrls ?? (payload.mediaUrl ? [payload.mediaUrl] : []);
|
|
const combined = [
|
|
text ?? "",
|
|
...mediaList.map((url) => url.trim()).filter(Boolean),
|
|
]
|
|
.filter(Boolean)
|
|
.join("\n");
|
|
if (!combined) continue;
|
|
for (const chunk of chunkMarkdownText(combined, chunkLimit)) {
|
|
messages.push(chunk);
|
|
}
|
|
}
|
|
|
|
if (messages.length === 0) {
|
|
await params.respond({
|
|
text: "No response was generated for that command.",
|
|
response_type: "ephemeral",
|
|
});
|
|
return;
|
|
}
|
|
|
|
const responseType = params.ephemeral ? "ephemeral" : "in_channel";
|
|
for (const message of messages) {
|
|
await params.respond({ text: message, response_type: responseType });
|
|
}
|
|
}
|