import { hasControlCommand } from "../../auto-reply/command-detection.js"; import { createInboundDebouncer, resolveInboundDebounceMs, } from "../../auto-reply/inbound-debounce.js"; import type { ResolvedSlackAccount } from "../accounts.js"; import type { SlackMessageEvent } from "../types.js"; import { stripSlackMentionsForCommandDetection } from "./commands.js"; import type { SlackMonitorContext } from "./context.js"; import { dispatchPreparedSlackMessage } from "./message-handler/dispatch.js"; import { prepareSlackMessage } from "./message-handler/prepare.js"; import { createSlackThreadTsResolver } from "./thread-resolution.js"; export type SlackMessageHandler = ( message: SlackMessageEvent, opts: { source: "message" | "app_mention"; wasMentioned?: boolean }, ) => Promise; /** * Build a debounce key that isolates messages by thread (or by message timestamp * for top-level channel messages). Without per-message scoping, concurrent * top-level messages from the same sender would share a key and get merged * into a single reply on the wrong thread. */ export function buildSlackDebounceKey( message: SlackMessageEvent, accountId: string, ): string | null { const senderId = message.user ?? message.bot_id; if (!senderId) { return null; } const messageTs = message.ts ?? message.event_ts; const threadKey = message.thread_ts ? `${message.channel}:${message.thread_ts}` : message.parent_user_id && messageTs ? `${message.channel}:maybe-thread:${messageTs}` : messageTs ? `${message.channel}:${messageTs}` : message.channel; return `slack:${accountId}:${threadKey}:${senderId}`; } export function createSlackMessageHandler(params: { ctx: SlackMonitorContext; account: ResolvedSlackAccount; /** Called on each inbound event to update liveness tracking. */ trackEvent?: () => void; }): SlackMessageHandler { const { ctx, account, trackEvent } = params; const debounceMs = resolveInboundDebounceMs({ cfg: ctx.cfg, channel: "slack" }); const threadTsResolver = createSlackThreadTsResolver({ client: ctx.app.client }); const debouncer = createInboundDebouncer<{ message: SlackMessageEvent; opts: { source: "message" | "app_mention"; wasMentioned?: boolean }; }>({ debounceMs, buildKey: (entry) => buildSlackDebounceKey(entry.message, ctx.accountId), shouldDebounce: (entry) => { const text = entry.message.text ?? ""; if (!text.trim()) { return false; } if (entry.message.files && entry.message.files.length > 0) { return false; } const textForCommandDetection = stripSlackMentionsForCommandDetection(text); return !hasControlCommand(textForCommandDetection, ctx.cfg); }, onFlush: async (entries) => { const last = entries.at(-1); if (!last) { return; } const combinedText = entries.length === 1 ? (last.message.text ?? "") : entries .map((entry) => entry.message.text ?? "") .filter(Boolean) .join("\n"); const combinedMentioned = entries.some((entry) => Boolean(entry.opts.wasMentioned)); const syntheticMessage: SlackMessageEvent = { ...last.message, text: combinedText, }; const prepared = await prepareSlackMessage({ ctx, account, message: syntheticMessage, opts: { ...last.opts, wasMentioned: combinedMentioned || last.opts.wasMentioned, }, }); if (!prepared) { return; } if (entries.length > 1) { const ids = entries.map((entry) => entry.message.ts).filter(Boolean) as string[]; if (ids.length > 0) { prepared.ctxPayload.MessageSids = ids; prepared.ctxPayload.MessageSidFirst = ids[0]; prepared.ctxPayload.MessageSidLast = ids[ids.length - 1]; } } await dispatchPreparedSlackMessage(prepared); }, onError: (err) => { ctx.runtime.error?.(`slack inbound debounce flush failed: ${String(err)}`); }, }); 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; } trackEvent?.(); const resolvedMessage = await threadTsResolver.resolve({ message, source: opts.source }); await debouncer.enqueue({ message: resolvedMessage, opts }); }; }