392 lines
12 KiB
TypeScript
392 lines
12 KiB
TypeScript
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 { isSlackChannelAllowedByPolicy } from "./policy.js";
|
|
|
|
export function inferSlackChannelType(
|
|
channelId?: string | null,
|
|
): SlackMessageEvent["channel_type"] | undefined {
|
|
const trimmed = channelId?.trim();
|
|
if (!trimmed) return undefined;
|
|
if (trimmed.startsWith("D")) return "im";
|
|
if (trimmed.startsWith("C")) return "channel";
|
|
if (trimmed.startsWith("G")) return "group";
|
|
return undefined;
|
|
}
|
|
|
|
export function normalizeSlackChannelType(
|
|
channelType?: string | null,
|
|
channelId?: string | null,
|
|
): SlackMessageEvent["channel_type"] {
|
|
const normalized = channelType?.trim().toLowerCase();
|
|
if (
|
|
normalized === "im" ||
|
|
normalized === "mpim" ||
|
|
normalized === "channel" ||
|
|
normalized === "group"
|
|
) {
|
|
return normalized;
|
|
}
|
|
return inferSlackChannelType(channelId) ?? "channel";
|
|
}
|
|
|
|
export type SlackMonitorContext = {
|
|
cfg: ClawdbotConfig;
|
|
accountId: string;
|
|
botToken: string;
|
|
app: App;
|
|
runtime: RuntimeEnv;
|
|
|
|
botUserId: string;
|
|
teamId: string;
|
|
apiAppId: string;
|
|
|
|
historyLimit: number;
|
|
channelHistories: Map<string, HistoryEntry[]>;
|
|
sessionScope: SessionScope;
|
|
mainKey: string;
|
|
|
|
dmEnabled: boolean;
|
|
dmPolicy: DmPolicy;
|
|
allowFrom: string[];
|
|
groupDmEnabled: boolean;
|
|
groupDmChannels: string[];
|
|
defaultRequireMention: boolean;
|
|
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";
|
|
threadHistoryScope: "thread" | "channel";
|
|
threadInheritParent: boolean;
|
|
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;
|
|
shouldDropMismatchedSlackEvent: (body: unknown) => 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;
|
|
apiAppId: string;
|
|
|
|
historyLimit: number;
|
|
sessionScope: SessionScope;
|
|
mainKey: string;
|
|
|
|
dmEnabled: boolean;
|
|
dmPolicy: DmPolicy;
|
|
allowFrom: Array<string | number> | undefined;
|
|
groupDmEnabled: boolean;
|
|
groupDmChannels: Array<string | number> | undefined;
|
|
defaultRequireMention?: boolean;
|
|
channelsConfig?: SlackMonitorContext["channelsConfig"];
|
|
groupPolicy: SlackMonitorContext["groupPolicy"];
|
|
useAccessGroups: boolean;
|
|
reactionMode: SlackReactionNotificationMode;
|
|
reactionAllowlist: Array<string | number>;
|
|
replyToMode: SlackMonitorContext["replyToMode"];
|
|
threadHistoryScope: SlackMonitorContext["threadHistoryScope"];
|
|
threadInheritParent: SlackMonitorContext["threadInheritParent"];
|
|
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 defaultRequireMention = params.defaultRequireMention ?? true;
|
|
|
|
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 = normalizeSlackChannelType(p.channelType, channelId);
|
|
const isDirectMessage = channelType === "im";
|
|
const isGroup = channelType === "mpim";
|
|
const from = isDirectMessage
|
|
? `slack:${channelId}`
|
|
: isGroup
|
|
? `slack:group:${channelId}`
|
|
: `slack:channel:${channelId}`;
|
|
const chatType = isDirectMessage ? "direct" : isGroup ? "group" : "channel";
|
|
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 = normalizeSlackChannelType(p.channelType, p.channelId);
|
|
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,
|
|
defaultRequireMention,
|
|
});
|
|
const channelAllowed = channelConfig?.allowed !== false;
|
|
const channelAllowlistConfigured =
|
|
Boolean(params.channelsConfig) && Object.keys(params.channelsConfig ?? {}).length > 0;
|
|
if (
|
|
!isSlackChannelAllowedByPolicy({
|
|
groupPolicy: params.groupPolicy,
|
|
channelAllowlistConfigured,
|
|
channelAllowed,
|
|
})
|
|
) {
|
|
return false;
|
|
}
|
|
if (!channelAllowed) return false;
|
|
}
|
|
|
|
return true;
|
|
};
|
|
|
|
const shouldDropMismatchedSlackEvent = (body: unknown) => {
|
|
if (!body || typeof body !== "object") return false;
|
|
const raw = body as { api_app_id?: unknown; team_id?: unknown };
|
|
const incomingApiAppId = typeof raw.api_app_id === "string" ? raw.api_app_id : "";
|
|
const incomingTeamId = typeof raw.team_id === "string" ? raw.team_id : "";
|
|
|
|
if (params.apiAppId && incomingApiAppId && incomingApiAppId !== params.apiAppId) {
|
|
logVerbose(
|
|
`slack: drop event with api_app_id=${incomingApiAppId} (expected ${params.apiAppId})`,
|
|
);
|
|
return true;
|
|
}
|
|
if (params.teamId && incomingTeamId && incomingTeamId !== params.teamId) {
|
|
logVerbose(`slack: drop event with team_id=${incomingTeamId} (expected ${params.teamId})`);
|
|
return true;
|
|
}
|
|
return false;
|
|
};
|
|
|
|
return {
|
|
cfg: params.cfg,
|
|
accountId: params.accountId,
|
|
botToken: params.botToken,
|
|
app: params.app,
|
|
runtime: params.runtime,
|
|
botUserId: params.botUserId,
|
|
teamId: params.teamId,
|
|
apiAppId: params.apiAppId,
|
|
historyLimit: params.historyLimit,
|
|
channelHistories,
|
|
sessionScope: params.sessionScope,
|
|
mainKey: params.mainKey,
|
|
dmEnabled: params.dmEnabled,
|
|
dmPolicy: params.dmPolicy,
|
|
allowFrom,
|
|
groupDmEnabled: params.groupDmEnabled,
|
|
groupDmChannels,
|
|
defaultRequireMention,
|
|
channelsConfig: params.channelsConfig,
|
|
groupPolicy: params.groupPolicy,
|
|
useAccessGroups: params.useAccessGroups,
|
|
reactionMode: params.reactionMode,
|
|
reactionAllowlist: params.reactionAllowlist,
|
|
replyToMode: params.replyToMode,
|
|
threadHistoryScope: params.threadHistoryScope,
|
|
threadInheritParent: params.threadInheritParent,
|
|
slashCommand: params.slashCommand,
|
|
textLimit: params.textLimit,
|
|
ackReactionScope: params.ackReactionScope,
|
|
mediaMaxBytes: params.mediaMaxBytes,
|
|
removeAckAfterReply: params.removeAckAfterReply,
|
|
logger,
|
|
markMessageSeen,
|
|
shouldDropMismatchedSlackEvent,
|
|
resolveSlackSystemEventSessionKey,
|
|
isChannelAllowed,
|
|
resolveChannelName,
|
|
resolveUserName,
|
|
setSlackThreadStatus,
|
|
};
|
|
}
|