refactor(src): split oversized modules

This commit is contained in:
Peter Steinberger
2026-01-14 01:08:15 +00:00
parent b2179de839
commit bcbfb357be
675 changed files with 91476 additions and 73453 deletions

View File

@@ -0,0 +1,51 @@
export 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, "");
}
export function normalizeAllowList(list?: Array<string | number>) {
return (list ?? []).map((entry) => String(entry).trim()).filter(Boolean);
}
export function normalizeAllowListLower(list?: Array<string | number>) {
return normalizeAllowList(list).map((entry) => entry.toLowerCase());
}
export 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));
}
export 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,
});
}

33
src/slack/monitor/auth.ts Normal file
View File

@@ -0,0 +1,33 @@
import { readChannelAllowFromStore } from "../../pairing/pairing-store.js";
import {
allowListMatches,
normalizeAllowList,
normalizeAllowListLower,
} from "./allow-list.js";
import type { SlackMonitorContext } from "./context.js";
export async function resolveSlackEffectiveAllowFrom(ctx: SlackMonitorContext) {
const storeAllowFrom = await readChannelAllowFromStore("slack").catch(
() => [],
);
const allowFrom = normalizeAllowList([...ctx.allowFrom, ...storeAllowFrom]);
const allowFromLower = normalizeAllowListLower(allowFrom);
return { allowFrom, allowFromLower };
}
export function isSlackSenderAllowListed(params: {
allowListLower: string[];
senderId: string;
senderName?: string;
}) {
const { allowListLower, senderId, senderName } = params;
return (
allowListLower.length === 0 ||
allowListMatches({
allowList: allowListLower,
id: senderId,
name: senderName,
})
);
}

View File

@@ -0,0 +1,141 @@
import type { SlackReactionNotificationMode } from "../../config/config.js";
import type { SlackMessageEvent } from "../types.js";
import {
allowListMatches,
normalizeAllowListLower,
normalizeSlackSlug,
} from "./allow-list.js";
export type SlackChannelConfigResolved = {
allowed: boolean;
requireMention: boolean;
allowBots?: boolean;
users?: Array<string | number>;
skills?: string[];
systemPrompt?: string;
};
function firstDefined<T>(...values: Array<T | undefined>) {
for (const value of values) {
if (typeof value !== "undefined") return value;
}
return undefined;
}
export 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;
}
export 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";
}
export function resolveSlackChannelConfig(params: {
channelId: string;
channelName?: string;
channels?: Record<
string,
{
enabled?: boolean;
allow?: boolean;
requireMention?: boolean;
allowBots?: 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;
allowBots?: 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 allowBots = firstDefined(resolved.allowBots, fallback?.allowBots);
const users = firstDefined(resolved.users, fallback?.users);
const skills = firstDefined(resolved.skills, fallback?.skills);
const systemPrompt = firstDefined(
resolved.systemPrompt,
fallback?.systemPrompt,
);
return { allowed, requireMention, allowBots, users, skills, systemPrompt };
}
export type { SlackMessageEvent };

View File

@@ -0,0 +1,25 @@
import type { SlackSlashCommandConfig } from "../../config/config.js";
export function normalizeSlackSlashCommandName(raw: string) {
return raw.replace(/^\/+/, "");
}
export function resolveSlackSlashCommandConfig(
raw?: SlackSlashCommandConfig,
): Required<SlackSlashCommandConfig> {
const normalizedName = normalizeSlackSlashCommandName(
raw?.name?.trim() || "clawd",
);
const name = normalizedName || "clawd";
return {
enabled: raw?.enabled === true,
name,
sessionPrefix: raw?.sessionPrefix?.trim() || "slack:slash",
ephemeral: raw?.ephemeral !== false,
};
}
export function buildSlackSlashCommandMatcher(name: string) {
const escaped = name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
return new RegExp(`^/?${escaped}$`);
}

View File

@@ -0,0 +1,352 @@
import type { App } from "@slack/bolt";
import type { HistoryEntry } from "../../auto-reply/reply/history.js";
import type {
ClawdbotConfig,
SlackReactionNotificationMode,
} from "../../config/config.js";
import { resolveSessionKey, type SessionScope } from "../../config/sessions.js";
import type { DmPolicy, GroupPolicy } from "../../config/types.js";
import { logVerbose } from "../../globals.js";
import { createDedupeCache } from "../../infra/dedupe.js";
import { getChildLogger } from "../../logging.js";
import type { RuntimeEnv } from "../../runtime.js";
import type { SlackMessageEvent } from "../types.js";
import {
normalizeAllowList,
normalizeAllowListLower,
normalizeSlackSlug,
} from "./allow-list.js";
import { resolveSlackChannelConfig } from "./channel-config.js";
import { isSlackRoomAllowedByPolicy } from "./policy.js";
export type SlackMonitorContext = {
cfg: ClawdbotConfig;
accountId: string;
botToken: string;
app: App;
runtime: RuntimeEnv;
botUserId: string;
teamId: string;
historyLimit: number;
channelHistories: Map<string, HistoryEntry[]>;
sessionScope: SessionScope;
mainKey: string;
dmEnabled: boolean;
dmPolicy: DmPolicy;
allowFrom: string[];
groupDmEnabled: boolean;
groupDmChannels: string[];
channelsConfig?: Record<
string,
{
enabled?: boolean;
allow?: boolean;
requireMention?: boolean;
allowBots?: boolean;
users?: Array<string | number>;
skills?: string[];
systemPrompt?: string;
}
>;
groupPolicy: GroupPolicy;
useAccessGroups: boolean;
reactionMode: SlackReactionNotificationMode;
reactionAllowlist: Array<string | number>;
replyToMode: "off" | "first" | "all";
slashCommand: Required<
import("../../config/config.js").SlackSlashCommandConfig
>;
textLimit: number;
ackReactionScope: string;
mediaMaxBytes: number;
removeAckAfterReply: boolean;
logger: ReturnType<typeof getChildLogger>;
markMessageSeen: (channelId: string | undefined, ts?: string) => boolean;
resolveSlackSystemEventSessionKey: (params: {
channelId?: string | null;
channelType?: string | null;
}) => string;
isChannelAllowed: (params: {
channelId?: string;
channelName?: string;
channelType?: SlackMessageEvent["channel_type"];
}) => boolean;
resolveChannelName: (channelId: string) => Promise<{
name?: string;
type?: SlackMessageEvent["channel_type"];
topic?: string;
purpose?: string;
}>;
resolveUserName: (userId: string) => Promise<{ name?: string }>;
setSlackThreadStatus: (params: {
channelId: string;
threadTs?: string;
status: string;
}) => Promise<void>;
};
export function createSlackMonitorContext(params: {
cfg: ClawdbotConfig;
accountId: string;
botToken: string;
app: App;
runtime: RuntimeEnv;
botUserId: string;
teamId: string;
historyLimit: number;
sessionScope: SessionScope;
mainKey: string;
dmEnabled: boolean;
dmPolicy: DmPolicy;
allowFrom: Array<string | number> | undefined;
groupDmEnabled: boolean;
groupDmChannels: Array<string | number> | undefined;
channelsConfig?: SlackMonitorContext["channelsConfig"];
groupPolicy: SlackMonitorContext["groupPolicy"];
useAccessGroups: boolean;
reactionMode: SlackReactionNotificationMode;
reactionAllowlist: Array<string | number>;
replyToMode: SlackMonitorContext["replyToMode"];
slashCommand: SlackMonitorContext["slashCommand"];
textLimit: number;
ackReactionScope: string;
mediaMaxBytes: number;
removeAckAfterReply: boolean;
}): SlackMonitorContext {
const channelHistories = new Map<string, HistoryEntry[]>();
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 = createDedupeCache({ ttlMs: 60_000, maxSize: 500 });
const allowFrom = normalizeAllowList(params.allowFrom);
const groupDmChannels = normalizeAllowList(params.groupDmChannels);
const markMessageSeen = (channelId: string | undefined, ts?: string) => {
if (!channelId || !ts) return false;
return seenMessages.check(`${channelId}:${ts}`);
};
const resolveSlackSystemEventSessionKey = (p: {
channelId?: string | null;
channelType?: string | null;
}) => {
const channelId = p.channelId?.trim() ?? "";
if (!channelId) return params.mainKey;
const channelType = p.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(
params.sessionScope,
{ From: from, ChatType: chatType, Provider: "slack" },
params.mainKey,
);
};
const resolveChannelName = async (channelId: string) => {
const cached = channelCache.get(channelId);
if (cached) return cached;
try {
const info = await params.app.client.conversations.info({
token: params.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 params.app.client.users.info({
token: params.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 (p: {
channelId: string;
threadTs?: string;
status: string;
}) => {
if (!p.threadTs) return;
const payload = {
token: params.botToken,
channel_id: p.channelId,
thread_ts: p.threadTs,
status: p.status,
};
const client = params.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 ${p.channelId}: ${String(err)}`,
);
}
};
const isChannelAllowed = (p: {
channelId?: string;
channelName?: string;
channelType?: SlackMessageEvent["channel_type"];
}) => {
const channelType = p.channelType;
const isDirectMessage = channelType === "im";
const isGroupDm = channelType === "mpim";
const isRoom = channelType === "channel" || channelType === "group";
if (isDirectMessage && !params.dmEnabled) return false;
if (isGroupDm && !params.groupDmEnabled) return false;
if (isGroupDm && groupDmChannels.length > 0) {
const allowList = normalizeAllowListLower(groupDmChannels);
const candidates = [
p.channelId,
p.channelName ? `#${p.channelName}` : undefined,
p.channelName,
p.channelName ? normalizeSlackSlug(p.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 && p.channelId) {
const channelConfig = resolveSlackChannelConfig({
channelId: p.channelId,
channelName: p.channelName,
channels: params.channelsConfig,
});
const channelAllowed = channelConfig?.allowed !== false;
const channelAllowlistConfigured =
Boolean(params.channelsConfig) &&
Object.keys(params.channelsConfig ?? {}).length > 0;
if (
!isSlackRoomAllowedByPolicy({
groupPolicy: params.groupPolicy,
channelAllowlistConfigured,
channelAllowed,
})
) {
return false;
}
if (!channelAllowed) return false;
}
return true;
};
return {
cfg: params.cfg,
accountId: params.accountId,
botToken: params.botToken,
app: params.app,
runtime: params.runtime,
botUserId: params.botUserId,
teamId: params.teamId,
historyLimit: params.historyLimit,
channelHistories,
sessionScope: params.sessionScope,
mainKey: params.mainKey,
dmEnabled: params.dmEnabled,
dmPolicy: params.dmPolicy,
allowFrom,
groupDmEnabled: params.groupDmEnabled,
groupDmChannels,
channelsConfig: params.channelsConfig,
groupPolicy: params.groupPolicy,
useAccessGroups: params.useAccessGroups,
reactionMode: params.reactionMode,
reactionAllowlist: params.reactionAllowlist,
replyToMode: params.replyToMode,
slashCommand: params.slashCommand,
textLimit: params.textLimit,
ackReactionScope: params.ackReactionScope,
mediaMaxBytes: params.mediaMaxBytes,
removeAckAfterReply: params.removeAckAfterReply,
logger,
markMessageSeen,
resolveSlackSystemEventSessionKey,
isChannelAllowed,
resolveChannelName,
resolveUserName,
setSlackThreadStatus,
};
}

View File

@@ -0,0 +1,24 @@
import type { ResolvedSlackAccount } from "../accounts.js";
import type { SlackMonitorContext } from "./context.js";
import { registerSlackChannelEvents } from "./events/channels.js";
import { registerSlackMemberEvents } from "./events/members.js";
import { registerSlackMessageEvents } from "./events/messages.js";
import { registerSlackPinEvents } from "./events/pins.js";
import { registerSlackReactionEvents } from "./events/reactions.js";
import type { SlackMessageHandler } from "./message-handler.js";
export function registerSlackMonitorEvents(params: {
ctx: SlackMonitorContext;
account: ResolvedSlackAccount;
handleSlackMessage: SlackMessageHandler;
}) {
registerSlackMessageEvents({
ctx: params.ctx,
handleSlackMessage: params.handleSlackMessage,
});
registerSlackReactionEvents({ ctx: params.ctx });
registerSlackMemberEvents({ ctx: params.ctx });
registerSlackChannelEvents({ ctx: params.ctx });
registerSlackPinEvents({ ctx: params.ctx });
}

View File

@@ -0,0 +1,84 @@
import type { SlackEventMiddlewareArgs } from "@slack/bolt";
import { danger } from "../../../globals.js";
import { enqueueSystemEvent } from "../../../infra/system-events.js";
import { resolveSlackChannelLabel } from "../channel-config.js";
import type { SlackMonitorContext } from "../context.js";
import type {
SlackChannelCreatedEvent,
SlackChannelRenamedEvent,
} from "../types.js";
export function registerSlackChannelEvents(params: {
ctx: SlackMonitorContext;
}) {
const { ctx } = params;
ctx.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 (
!ctx.isChannelAllowed({
channelId,
channelName,
channelType: "channel",
})
) {
return;
}
const label = resolveSlackChannelLabel({ channelId, channelName });
const sessionKey = ctx.resolveSlackSystemEventSessionKey({
channelId,
channelType: "channel",
});
enqueueSystemEvent(`Slack channel created: ${label}.`, {
sessionKey,
contextKey: `slack:channel:created:${channelId ?? channelName ?? "unknown"}`,
});
} catch (err) {
ctx.runtime.error?.(
danger(`slack channel created handler failed: ${String(err)}`),
);
}
},
);
ctx.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 (
!ctx.isChannelAllowed({
channelId,
channelName,
channelType: "channel",
})
) {
return;
}
const label = resolveSlackChannelLabel({ channelId, channelName });
const sessionKey = ctx.resolveSlackSystemEventSessionKey({
channelId,
channelType: "channel",
});
enqueueSystemEvent(`Slack channel renamed: ${label}.`, {
sessionKey,
contextKey: `slack:channel:renamed:${channelId ?? channelName ?? "unknown"}`,
});
} catch (err) {
ctx.runtime.error?.(
danger(`slack channel rename handler failed: ${String(err)}`),
);
}
},
);
}

View File

@@ -0,0 +1,100 @@
import type { SlackEventMiddlewareArgs } from "@slack/bolt";
import { danger } from "../../../globals.js";
import { enqueueSystemEvent } from "../../../infra/system-events.js";
import { resolveSlackChannelLabel } from "../channel-config.js";
import type { SlackMonitorContext } from "../context.js";
import type { SlackMemberChannelEvent } from "../types.js";
export function registerSlackMemberEvents(params: {
ctx: SlackMonitorContext;
}) {
const { ctx } = params;
ctx.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 ctx.resolveChannelName(channelId)
: {};
const channelType = payload.channel_type ?? channelInfo?.type;
if (
!ctx.isChannelAllowed({
channelId,
channelName: channelInfo?.name,
channelType,
})
) {
return;
}
const userInfo = payload.user
? await ctx.resolveUserName(payload.user)
: {};
const userLabel = userInfo?.name ?? payload.user ?? "someone";
const label = resolveSlackChannelLabel({
channelId,
channelName: channelInfo?.name,
});
const sessionKey = ctx.resolveSlackSystemEventSessionKey({
channelId,
channelType,
});
enqueueSystemEvent(`Slack: ${userLabel} joined ${label}.`, {
sessionKey,
contextKey: `slack:member:joined:${channelId ?? "unknown"}:${payload.user ?? "unknown"}`,
});
} catch (err) {
ctx.runtime.error?.(
danger(`slack join handler failed: ${String(err)}`),
);
}
},
);
ctx.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 ctx.resolveChannelName(channelId)
: {};
const channelType = payload.channel_type ?? channelInfo?.type;
if (
!ctx.isChannelAllowed({
channelId,
channelName: channelInfo?.name,
channelType,
})
) {
return;
}
const userInfo = payload.user
? await ctx.resolveUserName(payload.user)
: {};
const userLabel = userInfo?.name ?? payload.user ?? "someone";
const label = resolveSlackChannelLabel({
channelId,
channelName: channelInfo?.name,
});
const sessionKey = ctx.resolveSlackSystemEventSessionKey({
channelId,
channelType,
});
enqueueSystemEvent(`Slack: ${userLabel} left ${label}.`, {
sessionKey,
contextKey: `slack:member:left:${channelId ?? "unknown"}:${payload.user ?? "unknown"}`,
});
} catch (err) {
ctx.runtime.error?.(
danger(`slack leave handler failed: ${String(err)}`),
);
}
},
);
}

View File

@@ -0,0 +1,142 @@
import type { SlackEventMiddlewareArgs } from "@slack/bolt";
import { danger } from "../../../globals.js";
import { enqueueSystemEvent } from "../../../infra/system-events.js";
import type { SlackAppMentionEvent, SlackMessageEvent } from "../../types.js";
import { resolveSlackChannelLabel } from "../channel-config.js";
import type { SlackMonitorContext } from "../context.js";
import type { SlackMessageHandler } from "../message-handler.js";
import type {
SlackMessageChangedEvent,
SlackMessageDeletedEvent,
SlackThreadBroadcastEvent,
} from "../types.js";
export function registerSlackMessageEvents(params: {
ctx: SlackMonitorContext;
handleSlackMessage: SlackMessageHandler;
}) {
const { ctx, handleSlackMessage } = params;
ctx.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 ctx.resolveChannelName(channelId)
: {};
const channelType = channelInfo?.type;
if (
!ctx.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 = ctx.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 ctx.resolveChannelName(channelId)
: {};
const channelType = channelInfo?.type;
if (
!ctx.isChannelAllowed({
channelId,
channelName: channelInfo?.name,
channelType,
})
) {
return;
}
const label = resolveSlackChannelLabel({
channelId,
channelName: channelInfo?.name,
});
const sessionKey = ctx.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 ctx.resolveChannelName(channelId)
: {};
const channelType = channelInfo?.type;
if (
!ctx.isChannelAllowed({
channelId,
channelName: channelInfo?.name,
channelType,
})
) {
return;
}
const label = resolveSlackChannelLabel({
channelId,
channelName: channelInfo?.name,
});
const messageId = thread.message?.ts ?? thread.event_ts;
const sessionKey = ctx.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) {
ctx.runtime.error?.(danger(`slack handler failed: ${String(err)}`));
}
},
);
ctx.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) {
ctx.runtime.error?.(
danger(`slack mention handler failed: ${String(err)}`),
);
}
},
);
}

View File

@@ -0,0 +1,106 @@
import type { SlackEventMiddlewareArgs } from "@slack/bolt";
import { danger } from "../../../globals.js";
import { enqueueSystemEvent } from "../../../infra/system-events.js";
import { resolveSlackChannelLabel } from "../channel-config.js";
import type { SlackMonitorContext } from "../context.js";
import type { SlackPinEvent } from "../types.js";
export function registerSlackPinEvents(params: { ctx: SlackMonitorContext }) {
const { ctx } = params;
ctx.app.event(
"pin_added",
async ({ event }: SlackEventMiddlewareArgs<"pin_added">) => {
try {
const payload = event as SlackPinEvent;
const channelId = payload.channel_id;
const channelInfo = channelId
? await ctx.resolveChannelName(channelId)
: {};
if (
!ctx.isChannelAllowed({
channelId,
channelName: channelInfo?.name,
channelType: channelInfo?.type,
})
) {
return;
}
const label = resolveSlackChannelLabel({
channelId,
channelName: channelInfo?.name,
});
const userInfo = payload.user
? await ctx.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 = ctx.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) {
ctx.runtime.error?.(
danger(`slack pin added handler failed: ${String(err)}`),
);
}
},
);
ctx.app.event(
"pin_removed",
async ({ event }: SlackEventMiddlewareArgs<"pin_removed">) => {
try {
const payload = event as SlackPinEvent;
const channelId = payload.channel_id;
const channelInfo = channelId
? await ctx.resolveChannelName(channelId)
: {};
if (
!ctx.isChannelAllowed({
channelId,
channelName: channelInfo?.name,
channelType: channelInfo?.type,
})
) {
return;
}
const label = resolveSlackChannelLabel({
channelId,
channelName: channelInfo?.name,
});
const userInfo = payload.user
? await ctx.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 = ctx.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) {
ctx.runtime.error?.(
danger(`slack pin removed handler failed: ${String(err)}`),
);
}
},
);
}

View File

@@ -0,0 +1,104 @@
import type { SlackEventMiddlewareArgs } from "@slack/bolt";
import { danger } from "../../../globals.js";
import { enqueueSystemEvent } from "../../../infra/system-events.js";
import { normalizeSlackSlug } from "../allow-list.js";
import {
resolveSlackChannelConfig,
shouldEmitSlackReactionNotification,
} from "../channel-config.js";
import type { SlackMonitorContext } from "../context.js";
import type { SlackReactionEvent } from "../types.js";
export function registerSlackReactionEvents(params: {
ctx: SlackMonitorContext;
}) {
const { ctx } = params;
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 (ctx.botUserId && event.user === ctx.botUserId) return;
const channelInfo = await ctx.resolveChannelName(item.channel);
const channelType = channelInfo?.type;
const channelName = channelInfo?.name;
if (
!ctx.isChannelAllowed({
channelId: item.channel,
channelName,
channelType,
})
) {
return;
}
const isRoom = channelType === "channel" || channelType === "group";
if (isRoom) {
const channelConfig = resolveSlackChannelConfig({
channelId: item.channel,
channelName,
channels: ctx.channelsConfig,
});
if (channelConfig?.allowed === false) return;
}
const actor = await ctx.resolveUserName(event.user);
const shouldNotify = shouldEmitSlackReactionNotification({
mode: ctx.reactionMode,
botId: ctx.botUserId,
messageAuthorId: event.item_user ?? undefined,
userId: event.user,
userName: actor?.name ?? undefined,
allowlist: ctx.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 ctx.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 = ctx.resolveSlackSystemEventSessionKey({
channelId: item.channel,
channelType,
});
enqueueSystemEvent(text, {
sessionKey,
contextKey: `slack:reaction:${action}:${item.channel}:${item.ts}:${event.user}:${emojiLabel}`,
});
} catch (err) {
ctx.runtime.error?.(
danger(`slack reaction handler failed: ${String(err)}`),
);
}
};
ctx.app.event(
"reaction_added",
async ({ event }: SlackEventMiddlewareArgs<"reaction_added">) => {
await handleReactionEvent(event as SlackReactionEvent, "added");
},
);
ctx.app.event(
"reaction_removed",
async ({ event }: SlackEventMiddlewareArgs<"reaction_removed">) => {
await handleReactionEvent(event as SlackReactionEvent, "removed");
},
);
}

View File

@@ -0,0 +1,88 @@
import type { WebClient as SlackWebClient } from "@slack/web-api";
import type { FetchLike } from "../../media/fetch.js";
import { fetchRemoteMedia } from "../../media/fetch.js";
import { saveMediaBuffer } from "../../media/store.js";
import type { SlackFile } from "../types.js";
export 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 fetchImpl: FetchLike = (input, init) => {
const headers = new Headers(init?.headers);
headers.set("Authorization", `Bearer ${params.token}`);
return fetch(input, { ...init, headers });
};
const fetched = await fetchRemoteMedia({
url,
fetchImpl,
filePathHint: file.name,
});
if (fetched.buffer.byteLength > params.maxBytes) continue;
const saved = await saveMediaBuffer(
fetched.buffer,
fetched.contentType ?? file.mimetype,
"inbound",
params.maxBytes,
);
const label = fetched.fileName ?? file.name;
return {
path: saved.path,
contentType: saved.contentType,
placeholder: label ? `[Slack file: ${label}]` : "[Slack file]",
};
} catch {
// Ignore download failures and fall through to the next file.
}
}
return null;
}
export type SlackThreadStarter = {
text: string;
userId?: string;
ts?: string;
};
const THREAD_STARTER_CACHE = new Map<string, SlackThreadStarter>();
export 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;
}
}

View File

@@ -0,0 +1,33 @@
import type { ResolvedSlackAccount } from "../accounts.js";
import type { SlackMessageEvent } from "../types.js";
import type { SlackMonitorContext } from "./context.js";
import { dispatchPreparedSlackMessage } from "./message-handler/dispatch.js";
import { prepareSlackMessage } from "./message-handler/prepare.js";
export type SlackMessageHandler = (
message: SlackMessageEvent,
opts: { source: "message" | "app_mention"; wasMentioned?: boolean },
) => Promise<void>;
export function createSlackMessageHandler(params: {
ctx: SlackMonitorContext;
account: ResolvedSlackAccount;
}): SlackMessageHandler {
const { ctx, account } = params;
return async (message, opts) => {
if (opts.source === "message" && message.type !== "message") return;
if (
opts.source === "message" &&
message.subtype &&
message.subtype !== "file_share" &&
message.subtype !== "bot_message"
) {
return;
}
if (ctx.markMessageSeen(message.channel, message.ts)) return;
const prepared = await prepareSlackMessage({ ctx, account, message, opts });
if (!prepared) return;
await dispatchPreparedSlackMessage(prepared);
};
}

View File

@@ -0,0 +1,168 @@
import {
resolveEffectiveMessagesConfig,
resolveHumanDelayConfig,
} from "../../../agents/identity.js";
import { dispatchReplyFromConfig } from "../../../auto-reply/reply/dispatch-from-config.js";
import { clearHistoryEntries } from "../../../auto-reply/reply/history.js";
import { createReplyDispatcherWithTyping } from "../../../auto-reply/reply/reply-dispatcher.js";
import { resolveStorePath, updateLastRoute } from "../../../config/sessions.js";
import { danger, logVerbose, shouldLogVerbose } from "../../../globals.js";
import { removeSlackReaction } from "../../actions.js";
import { resolveSlackThreadTargets } from "../../threading.js";
import { createSlackReplyDeliveryPlan, deliverReplies } from "../replies.js";
import type { PreparedSlackMessage } from "./types.js";
export async function dispatchPreparedSlackMessage(
prepared: PreparedSlackMessage,
) {
const { ctx, account, message, route } = prepared;
const cfg = ctx.cfg;
const runtime = ctx.runtime;
if (prepared.isDirectMessage) {
const sessionCfg = cfg.session;
const storePath = resolveStorePath(sessionCfg?.store, {
agentId: route.agentId,
});
await updateLastRoute({
storePath,
sessionKey: route.mainSessionKey,
channel: "slack",
to: `user:${message.user}`,
accountId: route.accountId,
});
}
const { statusThreadTs } = resolveSlackThreadTargets({
message,
replyToMode: ctx.replyToMode,
});
const messageTs = message.ts ?? message.event_ts;
const incomingThreadTs = message.thread_ts;
let didSetStatus = false;
// Shared mutable ref for "replyToMode=first". Both tool + auto-reply flows
// mark this to ensure only the first reply is threaded.
const hasRepliedRef = { value: false };
const replyPlan = createSlackReplyDeliveryPlan({
replyToMode: ctx.replyToMode,
incomingThreadTs,
messageTs,
hasRepliedRef,
});
const onReplyStart = async () => {
didSetStatus = true;
await ctx.setSlackThreadStatus({
channelId: message.channel,
threadTs: statusThreadTs,
status: "is typing...",
});
};
let didSendReply = false;
const { dispatcher, replyOptions, markDispatchIdle } =
createReplyDispatcherWithTyping({
responsePrefix: resolveEffectiveMessagesConfig(cfg, route.agentId)
.responsePrefix,
humanDelay: resolveHumanDelayConfig(cfg, route.agentId),
deliver: async (payload) => {
const replyThreadTs = replyPlan.nextThreadTs();
await deliverReplies({
replies: [payload],
target: prepared.replyTarget,
token: ctx.botToken,
accountId: account.accountId,
runtime,
textLimit: ctx.textLimit,
replyThreadTs,
});
didSendReply = true;
replyPlan.markSent();
},
onError: (err, info) => {
runtime.error?.(
danger(`slack ${info.kind} reply failed: ${String(err)}`),
);
if (didSetStatus) {
void ctx.setSlackThreadStatus({
channelId: message.channel,
threadTs: statusThreadTs,
status: "",
});
}
},
onReplyStart,
});
const { queuedFinal, counts } = await dispatchReplyFromConfig({
ctx: prepared.ctxPayload,
cfg,
dispatcher,
replyOptions: {
...replyOptions,
skillFilter: prepared.channelConfig?.skills,
hasRepliedRef,
disableBlockStreaming:
typeof account.config.blockStreaming === "boolean"
? !account.config.blockStreaming
: undefined,
},
});
markDispatchIdle();
if (didSetStatus) {
await ctx.setSlackThreadStatus({
channelId: message.channel,
threadTs: statusThreadTs,
status: "",
});
}
if (!queuedFinal) {
if (prepared.isRoomish && ctx.historyLimit > 0 && didSendReply) {
clearHistoryEntries({
historyMap: ctx.channelHistories,
historyKey: prepared.historyKey,
});
}
return;
}
if (shouldLogVerbose()) {
const finalCount = counts.final;
logVerbose(
`slack: delivered ${finalCount} reply${finalCount === 1 ? "" : "ies"} to ${prepared.replyTarget}`,
);
}
if (
ctx.removeAckAfterReply &&
prepared.ackReactionPromise &&
prepared.ackReactionMessageTs
) {
const messageTs = prepared.ackReactionMessageTs;
const ackValue = prepared.ackReactionValue;
void prepared.ackReactionPromise.then((didAck) => {
if (!didAck) return;
removeSlackReaction(message.channel, messageTs, ackValue, {
token: ctx.botToken,
client: ctx.app.client,
}).catch((err) => {
logVerbose(
`slack: failed to remove ack reaction from ${message.channel}/${message.ts}: ${String(err)}`,
);
});
});
}
if (prepared.isRoomish && ctx.historyLimit > 0 && didSendReply) {
clearHistoryEntries({
historyMap: ctx.channelHistories,
historyKey: prepared.historyKey,
});
}
}

View File

@@ -0,0 +1,473 @@
import { resolveAckReaction } from "../../../agents/identity.js";
import { hasControlCommand } from "../../../auto-reply/command-detection.js";
import { shouldHandleTextCommands } from "../../../auto-reply/commands-registry.js";
import {
formatAgentEnvelope,
formatThreadStarterEnvelope,
} from "../../../auto-reply/envelope.js";
import { buildHistoryContextFromMap } from "../../../auto-reply/reply/history.js";
import {
buildMentionRegexes,
matchesMentionPatterns,
} from "../../../auto-reply/reply/mentions.js";
import { logVerbose, shouldLogVerbose } from "../../../globals.js";
import { enqueueSystemEvent } from "../../../infra/system-events.js";
import { buildPairingReply } from "../../../pairing/pairing-messages.js";
import { upsertChannelPairingRequest } from "../../../pairing/pairing-store.js";
import { resolveAgentRoute } from "../../../routing/resolve-route.js";
import { resolveThreadSessionKeys } from "../../../routing/session-key.js";
import type { ResolvedSlackAccount } from "../../accounts.js";
import { reactSlackMessage } from "../../actions.js";
import { sendMessageSlack } from "../../send.js";
import type { SlackMessageEvent } from "../../types.js";
import { allowListMatches, resolveSlackUserAllowed } from "../allow-list.js";
import {
isSlackSenderAllowListed,
resolveSlackEffectiveAllowFrom,
} from "../auth.js";
import { resolveSlackChannelConfig } from "../channel-config.js";
import type { SlackMonitorContext } from "../context.js";
import { resolveSlackMedia, resolveSlackThreadStarter } from "../media.js";
import type { PreparedSlackMessage } from "./types.js";
export async function prepareSlackMessage(params: {
ctx: SlackMonitorContext;
account: ResolvedSlackAccount;
message: SlackMessageEvent;
opts: { source: "message" | "app_mention"; wasMentioned?: boolean };
}): Promise<PreparedSlackMessage | null> {
const { ctx, account, message, opts } = params;
const cfg = ctx.cfg;
let channelInfo: {
name?: string;
type?: SlackMessageEvent["channel_type"];
topic?: string;
purpose?: string;
} = {};
let channelType = message.channel_type;
if (!channelType || channelType !== "im") {
channelInfo = await ctx.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";
const isRoomish = isRoom || isGroupDm;
const channelConfig = isRoom
? resolveSlackChannelConfig({
channelId: message.channel,
channelName,
channels: ctx.channelsConfig,
})
: null;
const allowBots =
channelConfig?.allowBots ??
account.config?.allowBots ??
cfg.channels?.slack?.allowBots ??
false;
const isBotMessage = Boolean(message.bot_id);
if (isBotMessage) {
if (message.user && ctx.botUserId && message.user === ctx.botUserId)
return null;
if (!allowBots) {
logVerbose(
`slack: drop bot message ${message.bot_id ?? "unknown"} (allowBots=false)`,
);
return null;
}
}
if (isDirectMessage && !message.user) {
logVerbose("slack: drop dm message (missing user id)");
return null;
}
const senderId = message.user ?? (isBotMessage ? message.bot_id : undefined);
if (!senderId) {
logVerbose("slack: drop message (missing sender id)");
return null;
}
if (
!ctx.isChannelAllowed({
channelId: message.channel,
channelName,
channelType: resolvedChannelType,
})
) {
logVerbose("slack: drop message (channel not allowed)");
return null;
}
const { allowFromLower } = await resolveSlackEffectiveAllowFrom(ctx);
if (isDirectMessage) {
const directUserId = message.user;
if (!directUserId) {
logVerbose("slack: drop dm message (missing user id)");
return null;
}
if (!ctx.dmEnabled || ctx.dmPolicy === "disabled") {
logVerbose("slack: drop dm (dms disabled)");
return null;
}
if (ctx.dmPolicy !== "open") {
const permitted = allowListMatches({
allowList: allowFromLower,
id: directUserId,
});
if (!permitted) {
if (ctx.dmPolicy === "pairing") {
const sender = await ctx.resolveUserName(directUserId);
const senderName = sender?.name ?? undefined;
const { code, created } = await upsertChannelPairingRequest({
channel: "slack",
id: directUserId,
meta: { name: senderName },
});
if (created) {
logVerbose(
`slack pairing request sender=${directUserId} name=${senderName ?? "unknown"}`,
);
try {
await sendMessageSlack(
message.channel,
buildPairingReply({
channel: "slack",
idLine: `Your Slack user id: ${directUserId}`,
code,
}),
{
token: ctx.botToken,
client: ctx.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=${ctx.dmPolicy})`,
);
}
return null;
}
}
}
const route = resolveAgentRoute({
cfg,
channel: "slack",
accountId: account.accountId,
teamId: ctx.teamId || undefined,
peer: {
kind: isDirectMessage ? "dm" : isRoom ? "channel" : "group",
id: isDirectMessage ? (message.user ?? "unknown") : message.channel,
},
});
const mentionRegexes = buildMentionRegexes(cfg, route.agentId);
const wasMentioned =
opts.wasMentioned ??
(!isDirectMessage &&
(Boolean(
ctx.botUserId && message.text?.includes(`<@${ctx.botUserId}>`),
) ||
matchesMentionPatterns(message.text ?? "", mentionRegexes)));
const sender = message.user ? await ctx.resolveUserName(message.user) : null;
const senderName =
sender?.name ??
message.username?.trim() ??
message.user ??
message.bot_id ??
"unknown";
const channelUserAuthorized = isRoom
? resolveSlackUserAllowed({
allowList: channelConfig?.users,
userId: senderId,
userName: senderName,
})
: true;
if (isRoom && !channelUserAuthorized) {
logVerbose(
`Blocked unauthorized slack sender ${senderId} (not in channel users)`,
);
return null;
}
const commandAuthorized =
isSlackSenderAllowListed({
allowListLower: allowFromLower,
senderId,
senderName,
}) && channelUserAuthorized;
const hasAnyMention = /<@[^>]+>/.test(message.text ?? "");
const allowTextCommands = shouldHandleTextCommands({
cfg,
surface: "slack",
});
const shouldRequireMention = isRoom
? (channelConfig?.requireMention ?? true)
: false;
// Allow "control commands" to bypass mention gating if sender is authorized.
const shouldBypassMention =
allowTextCommands &&
isRoom &&
shouldRequireMention &&
!wasMentioned &&
!hasAnyMention &&
commandAuthorized &&
hasControlCommand(message.text ?? "", cfg);
const effectiveWasMentioned = wasMentioned || shouldBypassMention;
const canDetectMention = Boolean(ctx.botUserId) || mentionRegexes.length > 0;
if (
isRoom &&
shouldRequireMention &&
canDetectMention &&
!wasMentioned &&
!shouldBypassMention
) {
ctx.logger.info(
{ channel: message.channel, reason: "no-mention" },
"skipping room message",
);
return null;
}
const media = await resolveSlackMedia({
files: message.files,
token: ctx.botToken,
maxBytes: ctx.mediaMaxBytes,
});
const rawBody = (message.text ?? "").trim() || media?.placeholder || "";
if (!rawBody) return null;
const ackReaction = resolveAckReaction(cfg, route.agentId);
const ackReactionValue = ackReaction ?? "";
const shouldAckReaction = () => {
if (!ackReaction) return false;
if (ctx.ackReactionScope === "all") return true;
if (ctx.ackReactionScope === "direct") return isDirectMessage;
if (ctx.ackReactionScope === "group-all") return isRoomish;
if (ctx.ackReactionScope === "group-mentions") {
if (!isRoom) return false;
if (!shouldRequireMention) return false;
if (!canDetectMention) return false;
return wasMentioned || shouldBypassMention;
}
return false;
};
const ackReactionMessageTs = message.ts;
const ackReactionPromise =
shouldAckReaction() && ackReactionMessageTs && ackReactionValue
? reactSlackMessage(
message.channel,
ackReactionMessageTs,
ackReactionValue,
{
token: ctx.botToken,
client: ctx.app.client,
},
).then(
() => true,
(err) => {
logVerbose(
`slack react failed for channel ${message.channel}: ${String(err)}`,
);
return false;
},
)
: null;
const roomLabel = channelName ? `#${channelName}` : `#${message.channel}`;
const historyKey = message.channel;
const historyEntry =
isRoomish && ctx.historyLimit > 0
? {
sender: senderName,
body: rawBody,
timestamp: message.ts
? Math.round(Number(message.ts) * 1000)
: undefined,
messageId: message.ts,
}
: undefined;
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 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({
channel: "Slack",
from: senderName,
timestamp: message.ts ? Math.round(Number(message.ts) * 1000) : undefined,
body: textWithId,
});
let combinedBody = body;
if (isRoomish && ctx.historyLimit > 0) {
combinedBody = buildHistoryContextFromMap({
historyMap: ctx.channelHistories,
historyKey,
limit: ctx.historyLimit,
entry: historyEntry,
currentMessage: combinedBody,
formatEntry: (entry) =>
formatAgentEnvelope({
channel: "Slack",
from: roomLabel,
timestamp: entry.timestamp,
body: `${entry.sender}: ${entry.body}${
entry.messageId
? ` [id:${entry.messageId} channel:${message.channel}]`
: ""
}`,
}),
});
}
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: ctx.app.client,
});
if (starter?.text) {
const starterUser = starter.userId
? await ctx.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({
channel: "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: combinedBody,
RawBody: rawBody,
CommandBody: rawBody,
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: senderId,
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 ? effectiveWasMentioned : undefined,
MediaPath: media?.path,
MediaType: media?.contentType,
MediaUrl: media?.path,
CommandAuthorized: commandAuthorized,
OriginatingChannel: "slack" as const,
OriginatingTo: slackTo,
} satisfies Record<string, unknown>;
const replyTarget = ctxPayload.To ?? undefined;
if (!replyTarget) return null;
if (shouldLogVerbose()) {
logVerbose(
`slack inbound: channel=${message.channel} from=${slackFrom} preview="${preview}"`,
);
}
return {
ctx,
account,
message,
route,
channelConfig,
replyTarget,
ctxPayload,
isDirectMessage,
isRoomish,
historyKey,
preview,
ackReactionMessageTs,
ackReactionValue,
ackReactionPromise,
};
}

View File

@@ -0,0 +1,22 @@
import type { ResolvedAgentRoute } from "../../../routing/resolve-route.js";
import type { ResolvedSlackAccount } from "../../accounts.js";
import type { SlackMessageEvent } from "../../types.js";
import type { SlackChannelConfigResolved } from "../channel-config.js";
import type { SlackMonitorContext } from "../context.js";
export type PreparedSlackMessage = {
ctx: SlackMonitorContext;
account: ResolvedSlackAccount;
message: SlackMessageEvent;
route: ResolvedAgentRoute;
channelConfig: SlackChannelConfigResolved | null;
replyTarget: string;
ctxPayload: Record<string, unknown>;
isDirectMessage: boolean;
isRoomish: boolean;
historyKey: string;
preview: string;
ackReactionMessageTs?: string;
ackReactionValue: string;
ackReactionPromise: Promise<boolean> | null;
};

View File

@@ -0,0 +1,11 @@
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;
}

View File

@@ -0,0 +1,147 @@
import { App } from "@slack/bolt";
import { resolveTextChunkLimit } from "../../auto-reply/chunk.js";
import { DEFAULT_GROUP_HISTORY_LIMIT } from "../../auto-reply/reply/history.js";
import { loadConfig } from "../../config/config.js";
import type { SessionScope } from "../../config/sessions.js";
import type { DmPolicy, GroupPolicy } from "../../config/types.js";
import { normalizeMainKey } from "../../routing/session-key.js";
import type { RuntimeEnv } from "../../runtime.js";
import { resolveSlackAccount } from "../accounts.js";
import { resolveSlackAppToken, resolveSlackBotToken } from "../token.js";
import { resolveSlackSlashCommandConfig } from "./commands.js";
import { createSlackMonitorContext } from "./context.js";
import { registerSlackMonitorEvents } from "./events.js";
import { createSlackMessageHandler } from "./message-handler.js";
import { registerSlackMonitorSlashCommands } from "./slash.js";
import type { MonitorSlackOpts } from "./types.js";
export async function monitorSlackProvider(opts: MonitorSlackOpts = {}) {
const cfg = opts.config ?? loadConfig();
const account = resolveSlackAccount({
cfg,
accountId: opts.accountId,
});
const historyLimit = Math.max(
0,
account.config.historyLimit ??
cfg.messages?.groupChat?.historyLimit ??
DEFAULT_GROUP_HISTORY_LIMIT,
);
const sessionCfg = cfg.session;
const sessionScope: SessionScope = sessionCfg?.scope ?? "per-sender";
const mainKey = normalizeMainKey(sessionCfg?.mainKey);
const 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 channels.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 dmEnabled = dmConfig?.enabled ?? true;
const dmPolicy = (dmConfig?.policy ?? "pairing") as DmPolicy;
const allowFrom = dmConfig?.allowFrom;
const groupDmEnabled = dmConfig?.groupEnabled ?? false;
const groupDmChannels = dmConfig?.groupChannels;
const channelsConfig = slackCfg.channels;
const groupPolicy = (slackCfg.groupPolicy ?? "open") as GroupPolicy;
const useAccessGroups = cfg.commands?.useAccessGroups !== false;
const reactionMode = slackCfg.reactionNotifications ?? "own";
const reactionAllowlist = slackCfg.reactionAllowlist ?? [];
const replyToMode = slackCfg.replyToMode ?? "off";
const slashCommand = resolveSlackSlashCommandConfig(
opts.slashCommand ?? slackCfg.slashCommand,
);
const textLimit = resolveTextChunkLimit(cfg, "slack", account.accountId);
const ackReactionScope = cfg.messages?.ackReactionScope ?? "group-mentions";
const mediaMaxBytes =
(opts.mediaMaxMb ?? slackCfg.mediaMaxMb ?? 20) * 1024 * 1024;
const removeAckAfterReply = cfg.messages?.removeAckAfterReply ?? false;
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 {
// auth test failing is non-fatal; message handler falls back to regex mentions.
}
const ctx = createSlackMonitorContext({
cfg,
accountId: account.accountId,
botToken,
app,
runtime,
botUserId,
teamId,
historyLimit,
sessionScope,
mainKey,
dmEnabled,
dmPolicy,
allowFrom,
groupDmEnabled,
groupDmChannels,
channelsConfig,
groupPolicy,
useAccessGroups,
reactionMode,
reactionAllowlist,
replyToMode,
slashCommand,
textLimit,
ackReactionScope,
mediaMaxBytes,
removeAckAfterReply,
});
const handleSlackMessage = createSlackMessageHandler({ ctx, account });
registerSlackMonitorEvents({ ctx, account, handleSlackMessage });
registerSlackMonitorSlashCommands({ ctx, account });
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);
}
}

View File

@@ -0,0 +1,157 @@
import { chunkMarkdownText } from "../../auto-reply/chunk.js";
import { createReplyReferencePlanner } from "../../auto-reply/reply/reply-reference.js";
import {
isSilentReplyText,
SILENT_REPLY_TOKEN,
} from "../../auto-reply/tokens.js";
import type { ReplyPayload } from "../../auto-reply/types.js";
import type { RuntimeEnv } from "../../runtime.js";
import { sendMessageSlack } from "../send.js";
export async function deliverReplies(params: {
replies: ReplyPayload[];
target: string;
token: string;
accountId?: string;
runtime: RuntimeEnv;
textLimit: number;
replyThreadTs?: string;
}) {
const chunkLimit = Math.min(params.textLimit, 4000);
for (const payload of params.replies) {
const threadTs = payload.replyToId ?? params.replyThreadTs;
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 || isSilentReplyText(trimmed, SILENT_REPLY_TOKEN))
continue;
await sendMessageSlack(params.target, trimmed, {
token: params.token,
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,
accountId: params.accountId,
});
}
}
params.runtime.log?.(`delivered reply to ${params.target}`);
}
}
export type SlackRespondFn = (payload: {
text: string;
response_type?: "ephemeral" | "in_channel";
}) => Promise<unknown>;
/**
* Compute effective threadTs for a Slack reply based on replyToMode.
* - "off": stay in thread if already in one, otherwise main channel
* - "first": first reply goes to thread, subsequent replies to main channel
* - "all": all replies go to thread
*/
export function resolveSlackThreadTs(params: {
replyToMode: "off" | "first" | "all";
incomingThreadTs: string | undefined;
messageTs: string | undefined;
hasReplied: boolean;
}): string | undefined {
const planner = createSlackReplyReferencePlanner({
replyToMode: params.replyToMode,
incomingThreadTs: params.incomingThreadTs,
messageTs: params.messageTs,
hasReplied: params.hasReplied,
});
return planner.use();
}
type SlackReplyDeliveryPlan = {
nextThreadTs: () => string | undefined;
markSent: () => void;
};
function createSlackReplyReferencePlanner(params: {
replyToMode: "off" | "first" | "all";
incomingThreadTs: string | undefined;
messageTs: string | undefined;
hasReplied?: boolean;
}) {
return createReplyReferencePlanner({
replyToMode: params.replyToMode,
existingId: params.incomingThreadTs,
startId: params.messageTs,
hasReplied: params.hasReplied,
});
}
export function createSlackReplyDeliveryPlan(params: {
replyToMode: "off" | "first" | "all";
incomingThreadTs: string | undefined;
messageTs: string | undefined;
hasRepliedRef: { value: boolean };
}): SlackReplyDeliveryPlan {
const replyReference = createSlackReplyReferencePlanner({
replyToMode: params.replyToMode,
incomingThreadTs: params.incomingThreadTs,
messageTs: params.messageTs,
hasReplied: params.hasRepliedRef.value,
});
return {
nextThreadTs: () => replyReference.use(),
markSent: () => {
replyReference.markSent();
params.hasRepliedRef.value = replyReference.hasReplied();
},
};
}
export 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 && !isSilentReplyText(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) return;
// Slack slash command responses can be multi-part by sending follow-ups via response_url.
const responseType = params.ephemeral ? "ephemeral" : "in_channel";
for (const text of messages) {
await params.respond({ text, response_type: responseType });
}
}

330
src/slack/monitor/slash.ts Normal file
View File

@@ -0,0 +1,330 @@
import type { SlackCommandMiddlewareArgs } from "@slack/bolt";
import { resolveEffectiveMessagesConfig } from "../../agents/identity.js";
import {
buildCommandText,
listNativeCommandSpecsForConfig,
} from "../../auto-reply/commands-registry.js";
import { dispatchReplyWithDispatcher } from "../../auto-reply/reply/provider-dispatcher.js";
import { resolveNativeCommandsEnabled } from "../../config/commands.js";
import { danger, logVerbose } from "../../globals.js";
import { buildPairingReply } from "../../pairing/pairing-messages.js";
import {
readChannelAllowFromStore,
upsertChannelPairingRequest,
} from "../../pairing/pairing-store.js";
import { resolveAgentRoute } from "../../routing/resolve-route.js";
import type { ResolvedSlackAccount } from "../accounts.js";
import {
allowListMatches,
normalizeAllowList,
normalizeAllowListLower,
resolveSlackUserAllowed,
} from "./allow-list.js";
import {
resolveSlackChannelConfig,
type SlackChannelConfigResolved,
} from "./channel-config.js";
import {
buildSlackSlashCommandMatcher,
resolveSlackSlashCommandConfig,
} from "./commands.js";
import type { SlackMonitorContext } from "./context.js";
import { isSlackRoomAllowedByPolicy } from "./policy.js";
import { deliverSlackSlashReplies } from "./replies.js";
export function registerSlackMonitorSlashCommands(params: {
ctx: SlackMonitorContext;
account: ResolvedSlackAccount;
}) {
const { ctx, account } = params;
const cfg = ctx.cfg;
const runtime = ctx.runtime;
const slashCommand = resolveSlackSlashCommandConfig(
ctx.slashCommand ?? account.config.slashCommand,
);
const handleSlashCommand = async (p: {
command: SlackCommandMiddlewareArgs["command"];
ack: SlackCommandMiddlewareArgs["ack"];
respond: SlackCommandMiddlewareArgs["respond"];
prompt: string;
}) => {
const { command, ack, respond, prompt } = p;
try {
if (!prompt.trim()) {
await ack({
text: "Message required.",
response_type: "ephemeral",
});
return;
}
await ack();
if (ctx.botUserId && command.user_id === ctx.botUserId) return;
const channelInfo = await ctx.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 (
!ctx.isChannelAllowed({
channelId: command.channel_id,
channelName: channelInfo?.name,
channelType,
})
) {
await respond({
text: "This channel is not allowed.",
response_type: "ephemeral",
});
return;
}
const storeAllowFrom = await readChannelAllowFromStore("slack").catch(
() => [],
);
const effectiveAllowFrom = normalizeAllowList([
...ctx.allowFrom,
...storeAllowFrom,
]);
const effectiveAllowFromLower =
normalizeAllowListLower(effectiveAllowFrom);
let commandAuthorized = true;
let channelConfig: SlackChannelConfigResolved | null = null;
if (isDirectMessage) {
if (!ctx.dmEnabled || ctx.dmPolicy === "disabled") {
await respond({
text: "Slack DMs are disabled.",
response_type: "ephemeral",
});
return;
}
if (ctx.dmPolicy !== "open") {
const sender = await ctx.resolveUserName(command.user_id);
const senderName = sender?.name ?? undefined;
const permitted = allowListMatches({
allowList: effectiveAllowFromLower,
id: command.user_id,
name: senderName,
});
if (!permitted) {
if (ctx.dmPolicy === "pairing") {
const { code, created } = await upsertChannelPairingRequest({
channel: "slack",
id: command.user_id,
meta: { name: senderName },
});
if (created) {
await respond({
text: buildPairingReply({
channel: "slack",
idLine: `Your Slack user id: ${command.user_id}`,
code,
}),
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: ctx.channelsConfig,
});
if (ctx.useAccessGroups) {
const channelAllowlistConfigured =
Boolean(ctx.channelsConfig) &&
Object.keys(ctx.channelsConfig ?? {}).length > 0;
const channelAllowed = channelConfig?.allowed !== false;
if (
!isSlackRoomAllowedByPolicy({
groupPolicy: ctx.groupPolicy,
channelAllowlistConfigured,
channelAllowed,
}) ||
!channelAllowed
) {
await respond({
text: "This channel is not allowed.",
response_type: "ephemeral",
});
return;
}
}
if (ctx.useAccessGroups && channelConfig?.allowed === false) {
await respond({
text: "This channel is not allowed.",
response_type: "ephemeral",
});
return;
}
}
const sender = await ctx.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,
channel: "slack",
accountId: account.accountId,
teamId: ctx.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,
OriginatingChannel: "slack" as const,
OriginatingTo: `user:${command.user_id}`,
};
const { counts } = await dispatchReplyWithDispatcher({
ctx: ctxPayload,
cfg,
dispatcherOptions: {
responsePrefix: resolveEffectiveMessagesConfig(cfg, route.agentId)
.responsePrefix,
deliver: async (payload) => {
await deliverSlackSlashReplies({
replies: [payload],
respond,
ephemeral: slashCommand.ephemeral,
textLimit: ctx.textLimit,
});
},
onError: (err, info) => {
runtime.error?.(
danger(`slack slash ${info.kind} reply failed: ${String(err)}`),
);
},
},
replyOptions: { skillFilter: channelConfig?.skills },
});
if (counts.final + counts.tool + counts.block === 0) {
await deliverSlackSlashReplies({
replies: [],
respond,
ephemeral: slashCommand.ephemeral,
textLimit: ctx.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 nativeEnabled = resolveNativeCommandsEnabled({
providerId: "slack",
providerSetting: account.config.commands?.native,
globalSetting: cfg.commands?.native,
});
const nativeCommands = nativeEnabled
? listNativeCommandSpecsForConfig(cfg)
: [];
if (nativeCommands.length > 0) {
for (const command of nativeCommands) {
ctx.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) {
ctx.app.command(
buildSlackSlashCommandMatcher(slashCommand.name),
async ({ command, ack, respond }: SlackCommandMiddlewareArgs) => {
await handleSlashCommand({
command,
ack,
respond,
prompt: command.text?.trim() ?? "",
});
},
);
} else {
logVerbose("slack: slash commands disabled");
}
}

View File

@@ -0,0 +1,85 @@
import type {
ClawdbotConfig,
SlackSlashCommandConfig,
} from "../../config/config.js";
import type { RuntimeEnv } from "../../runtime.js";
import type { SlackFile, SlackMessageEvent } from "../types.js";
export type MonitorSlackOpts = {
botToken?: string;
appToken?: string;
accountId?: string;
config?: ClawdbotConfig;
runtime?: RuntimeEnv;
abortSignal?: AbortSignal;
mediaMaxMb?: number;
slashCommand?: SlackSlashCommandConfig;
};
export type SlackReactionEvent = {
type: "reaction_added" | "reaction_removed";
user?: string;
reaction?: string;
item?: {
type?: string;
channel?: string;
ts?: string;
};
item_user?: string;
event_ts?: string;
};
export type SlackMemberChannelEvent = {
type: "member_joined_channel" | "member_left_channel";
user?: string;
channel?: string;
channel_type?: SlackMessageEvent["channel_type"];
event_ts?: string;
};
export type SlackChannelCreatedEvent = {
type: "channel_created";
channel?: { id?: string; name?: string };
event_ts?: string;
};
export type SlackChannelRenamedEvent = {
type: "channel_rename";
channel?: { id?: string; name?: string; name_normalized?: string };
event_ts?: string;
};
export type SlackPinEvent = {
type: "pin_added" | "pin_removed";
channel_id?: string;
user?: string;
item?: { type?: string; message?: { ts?: string } };
event_ts?: string;
};
export type SlackMessageChangedEvent = {
type: "message";
subtype: "message_changed";
channel?: string;
message?: { ts?: string };
previous_message?: { ts?: string };
event_ts?: string;
};
export type SlackMessageDeletedEvent = {
type: "message";
subtype: "message_deleted";
channel?: string;
deleted_ts?: string;
event_ts?: string;
};
export type SlackThreadBroadcastEvent = {
type: "message";
subtype: "thread_broadcast";
channel?: string;
message?: { ts?: string };
event_ts?: string;
};
export type { SlackFile, SlackMessageEvent };