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 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; export function createSlackMessageHandler(params: { ctx: SlackMonitorContext; account: ResolvedSlackAccount; }): SlackMessageHandler { const { ctx, account } = 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) => { const senderId = entry.message.user ?? entry.message.bot_id; if (!senderId) return null; const messageTs = entry.message.ts ?? entry.message.event_ts; // If Slack flags a thread reply but omits thread_ts, isolate it from root debouncing. const threadKey = entry.message.thread_ts ? `${entry.message.channel}:${entry.message.thread_ts}` : entry.message.parent_user_id && messageTs ? `${entry.message.channel}:maybe-thread:${messageTs}` : entry.message.channel; return `slack:${ctx.accountId}:${threadKey}:${senderId}`; }, shouldDebounce: (entry) => { const text = entry.message.text ?? ""; if (!text.trim()) return false; if (entry.message.files && entry.message.files.length > 0) return false; return !hasControlCommand(text, 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; const resolvedMessage = await threadTsResolver.resolve({ message, source: opts.source }); await debouncer.enqueue({ message: resolvedMessage, opts }); }; }