Files
clawdbot/src/slack/monitor.ts
2026-01-08 01:55:59 +01:00

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 });
}
}