import type { App } from "@slack/bolt"; import type { HistoryEntry } from "../../auto-reply/reply/history.js"; import type { OpenClawConfig, SlackReactionNotificationMode } from "../../config/config.js"; import type { DmPolicy, GroupPolicy } from "../../config/types.js"; import type { RuntimeEnv } from "../../runtime.js"; import type { SlackMessageEvent } from "../types.js"; import { formatAllowlistMatchMeta } from "../../channels/allowlist-match.js"; import { resolveSessionKey, type SessionScope } from "../../config/sessions.js"; import { logVerbose } from "../../globals.js"; import { createDedupeCache } from "../../infra/dedupe.js"; import { getChildLogger } from "../../logging.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: OpenClawConfig; accountId: string; botToken: string; app: App; runtime: RuntimeEnv; botUserId: string; teamId: string; apiAppId: string; historyLimit: number; channelHistories: Map; 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; skills?: string[]; systemPrompt?: string; } >; groupPolicy: GroupPolicy; useAccessGroups: boolean; reactionMode: SlackReactionNotificationMode; reactionAllowlist: Array; replyToMode: "off" | "first" | "all"; threadHistoryScope: "thread" | "channel"; threadInheritParent: boolean; slashCommand: Required; textLimit: number; ackReactionScope: string; mediaMaxBytes: number; removeAckAfterReply: boolean; logger: ReturnType; 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; }; export function createSlackMonitorContext(params: { cfg: OpenClawConfig; 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 | undefined; groupDmEnabled: boolean; groupDmChannels: Array | undefined; defaultRequireMention?: boolean; channelsConfig?: SlackMonitorContext["channelsConfig"]; groupPolicy: SlackMonitorContext["groupPolicy"]; useAccessGroups: boolean; reactionMode: SlackReactionNotificationMode; reactionAllowlist: Array; 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(); 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(); 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; }; }; apiCall?: (method: string, args: typeof payload) => Promise; }; 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 channelMatchMeta = formatAllowlistMatchMeta(channelConfig); const channelAllowed = channelConfig?.allowed !== false; const channelAllowlistConfigured = Boolean(params.channelsConfig) && Object.keys(params.channelsConfig ?? {}).length > 0; if ( !isSlackChannelAllowedByPolicy({ groupPolicy: params.groupPolicy, channelAllowlistConfigured, channelAllowed, }) ) { logVerbose( `slack: drop channel ${p.channelId} (groupPolicy=${params.groupPolicy}, ${channelMatchMeta})`, ); return false; } // When groupPolicy is "open", only block channels that are EXPLICITLY denied // (i.e., have a matching config entry with allow:false). Channels not in the // config (matchSource undefined) should be allowed under open policy. const hasExplicitConfig = Boolean(channelConfig?.matchSource); if (!channelAllowed && (params.groupPolicy !== "open" || hasExplicitConfig)) { logVerbose(`slack: drop channel ${p.channelId} (${channelMatchMeta})`); return false; } logVerbose(`slack: allow channel ${p.channelId} (${channelMatchMeta})`); } 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, }; }