diff --git a/slack-mcp/server/lib/config-cache.ts b/slack-mcp/server/lib/config-cache.ts index daa6bf54..05bfa617 100644 --- a/slack-mcp/server/lib/config-cache.ts +++ b/slack-mcp/server/lib/config-cache.ts @@ -27,6 +27,40 @@ import { const CONFIG_PREFIX = "config:"; +/** + * In-process hot cache for connection configs. + * + * Every webhook hit calls getCachedConnectionConfig — without this layer that + * is one Supabase/Redis/KV round-trip per Slack event (~50-200ms each). The + * config rarely changes (only on `onChange` from studio), so we cache it in + * memory for 24h and invalidate on writes/deletes. + * + * Write-through: cacheConnectionConfig populates memCache; remove…Config + * clears it. A pod restart clears the cache naturally. + */ +const MEM_CACHE_TTL_MS = 24 * 60 * 60 * 1000; +const memCache = new Map< + string, + { config: ConnectionConfig; expiresAt: number } +>(); + +function memCacheGet(key: string): ConnectionConfig | null { + const entry = memCache.get(key); + if (!entry) return null; + if (entry.expiresAt <= Date.now()) { + memCache.delete(key); + return null; + } + return entry.config; +} + +function memCacheSet(key: string, config: ConnectionConfig): void { + memCache.set(key, { + config, + expiresAt: Date.now() + MEM_CACHE_TTL_MS, + }); +} + /** * Connection configuration stored in KV */ @@ -95,6 +129,9 @@ export async function cacheConnectionConfig( } else { await _saveToRedisOrKV(key, configWithTimestamps); } + + // Write-through to in-memory cache — fresh writes always win. + memCacheSet(key, configWithTimestamps); } /** @@ -151,13 +188,22 @@ async function _getConfig(key: string): Promise { } /** - * Read config from persistent storage (used by webhook router) + * Read config from persistent storage (used by webhook router). + * + * Hits the in-process memCache first (24h TTL). On miss falls through to + * Supabase → Redis → KV. The memCache is populated on first read and on + * every write via cacheConnectionConfig. */ export async function getCachedConnectionConfig( connectionId: string, ): Promise { const key = `${CONFIG_PREFIX}${connectionId}`; - return await _getConfig(key); + const cached = memCacheGet(key); + if (cached) return cached; + + const config = await _getConfig(key); + if (config) memCacheSet(key, config); + return config; } /** @@ -189,6 +235,8 @@ export async function removeCachedConnectionConfig( const kv = getKvStore(); await kv.delete(key); + + memCache.delete(key); } } diff --git a/slack-mcp/server/lib/event-publisher.ts b/slack-mcp/server/lib/event-publisher.ts index 6820f4be..b2078d5d 100644 --- a/slack-mcp/server/lib/event-publisher.ts +++ b/slack-mcp/server/lib/event-publisher.ts @@ -45,6 +45,9 @@ async function fetchThreadMessages( channel: string | undefined, threadTs: string | undefined, ): Promise { + // Top-level messages (no thread_ts) do NOT pay this RTT — only thread + // continuations fetch history. Returning undefined here also avoids + // hitting Slack's conversations.replies when there is nothing to fetch. if (!channel || !threadTs) return undefined; try { const replies = await getThreadReplies(channel, threadTs); @@ -80,11 +83,27 @@ function replyInThreadTs(event: SlackEvent): string | undefined { * Reply instruction baked into the trigger payload so a trigger-driven * agent knows exactly how to respond without prompt engineering on the * subscriber side. + * + * When a "Pensando..." placeholder is already in the thread (thinkingTs + * present), we want the agent to EDIT it instead of stacking another + * message — keeps the thread clean (one final message per turn). */ function buildReplyInstruction( channelId: string | undefined, threadTs: string | undefined, + thinkingTs: string | undefined, ): string { + if (thinkingTs) { + return [ + "A 'Pensando...' placeholder is already in the thread. To respond:", + " Call SLACK_EDIT_MESSAGE with:", + ` channel = "${channelId ?? ""}"`, + ` ts = "${thinkingTs}"`, + " text = ", + "This replaces the placeholder with the answer in place, keeping the", + "thread free of bot clutter.", + ].join("\n"); + } return [ "When you respond, ALWAYS call the SLACK_REPLY_IN_THREAD tool with:", ` channel = "${channelId ?? ""}"`, @@ -94,10 +113,21 @@ function buildReplyInstruction( ].join("\n"); } +export interface PublishExtras { + /** Marks this notification as fired from a fallback path. */ + fallback?: boolean; + /** + * ts of the "Pensando..." placeholder the agent should edit with its + * final answer (via SLACK_EDIT_MESSAGE). When omitted, the agent should + * post a fresh reply via SLACK_REPLY_IN_THREAD. + */ + thinking_message_ts?: string; +} + export async function publishMessageReceived( connectionId: string, event: SlackEvent, - extras?: { fallback?: boolean }, + extras?: PublishExtras, ): Promise { const [user_name, thread_messages] = await Promise.all([ resolveUserName(event.user), @@ -115,7 +145,12 @@ export async function publishMessageReceived( ts: event.ts, thread_ts: event.thread_ts, reply_in_thread_ts, - reply_instruction: buildReplyInstruction(event.channel, reply_in_thread_ts), + thinking_message_ts: extras?.thinking_message_ts, + reply_instruction: buildReplyInstruction( + event.channel, + reply_in_thread_ts, + extras?.thinking_message_ts, + ), is_dm: event.channel?.startsWith("D") || (event as any).channel_type === "im", has_files: !!(event as any).files?.length, @@ -131,6 +166,7 @@ export async function publishMessageReceived( export async function publishAppMention( connectionId: string, event: SlackEvent, + extras?: PublishExtras, ): Promise { const [user_name, thread_messages] = await Promise.all([ resolveUserName(event.user), @@ -148,7 +184,12 @@ export async function publishAppMention( ts: event.ts, thread_ts: event.thread_ts, reply_in_thread_ts, - reply_instruction: buildReplyInstruction(event.channel, reply_in_thread_ts), + thinking_message_ts: extras?.thinking_message_ts, + reply_instruction: buildReplyInstruction( + event.channel, + reply_in_thread_ts, + extras?.thinking_message_ts, + ), has_files: !!(event as any).files?.length, thread_messages, timestamp: new Date().toISOString(), diff --git a/slack-mcp/server/lib/trigger-store.ts b/slack-mcp/server/lib/trigger-store.ts index f891b4dc..6f3918ac 100644 --- a/slack-mcp/server/lib/trigger-store.ts +++ b/slack-mcp/server/lib/trigger-store.ts @@ -46,11 +46,16 @@ export const triggers = createTriggers({ type: "slack.message.received", description: "Triggered when a message is sent in a Slack channel or DM. " + - "The payload carries `channel_id`, `reply_in_thread_ts`, `text`, " + - "`user_name`, and (when applicable) `thread_messages` with the full " + - "thread history. To respond, ALWAYS call SLACK_REPLY_IN_THREAD with " + - "channel=channel_id and thread_ts=reply_in_thread_ts so every answer " + - "lives inside the user's thread and each subject stays isolated.", + "Payload carries: `channel_id`, `reply_in_thread_ts`, `text`, " + + "`user_name`, `thinking_message_ts` (when slack-mcp already posted " + + "a 'Pensando...' placeholder in the thread), and `thread_messages` " + + "(full thread history when this is a thread continuation). " + + "How to respond: if `thinking_message_ts` is set, call " + + "SLACK_EDIT_MESSAGE(channel=channel_id, ts=thinking_message_ts, " + + "text=) — that replaces the placeholder in place. " + + "Otherwise call SLACK_REPLY_IN_THREAD(channel=channel_id, " + + "thread_ts=reply_in_thread_ts, text=). Never send a " + + "top-level message — every answer lives inside the user's thread.", params: z.object({ channel_id: z .string() @@ -65,12 +70,15 @@ export const triggers = createTriggers({ { type: "slack.app_mention", description: - "Triggered when the bot is @mentioned in a channel. The payload " + - "carries `channel_id`, `reply_in_thread_ts`, `text`, `user_name`, " + - "and (when applicable) `thread_messages`. To respond, ALWAYS call " + - "SLACK_REPLY_IN_THREAD with channel=channel_id and " + - "thread_ts=reply_in_thread_ts so the answer lives inside the user's " + - "thread.", + "Triggered when the bot is @mentioned in a channel. Payload carries: " + + "`channel_id`, `reply_in_thread_ts`, `text`, `user_name`, " + + "`thinking_message_ts` (when slack-mcp already posted a 'Pensando...' " + + "placeholder), and `thread_messages` (when the mention is inside an " + + "existing thread). How to respond: if `thinking_message_ts` is set, " + + "call SLACK_EDIT_MESSAGE(channel=channel_id, ts=thinking_message_ts, " + + "text=). Otherwise call SLACK_REPLY_IN_THREAD(" + + "channel=channel_id, thread_ts=reply_in_thread_ts, text=). The answer must live inside the user's thread.", params: z.object({ channel_id: z .string() diff --git a/slack-mcp/server/slack/handlers/eventHandler.ts b/slack-mcp/server/slack/handlers/eventHandler.ts index e5adbfbc..d37e9531 100644 --- a/slack-mcp/server/slack/handlers/eventHandler.ts +++ b/slack-mcp/server/slack/handlers/eventHandler.ts @@ -1,8 +1,11 @@ /** * Slack Event Handler * - * Main event router for Slack events. - * Uses modular handlers for context building and LLM calls. + * Main event router for Slack events. For chat-style events (app_mention, + * message) the handlers send a "Pensando..." placeholder and publish an + * enriched trigger so a subscribed agent answers via SLACK_EDIT_MESSAGE + * (or SLACK_REPLY_IN_THREAD). The legacy direct-LLM path (handleLLMCall, + * buildLLMMessages) is no longer wired up — see PR description. */ import { @@ -16,17 +19,12 @@ import { appendAssistantMessage } from "../../lib/thread.ts"; import { sendMessage, replyInThread, - getBotInfo, getThreadReplies, sendThinkingMessage, processSlackFiles, - addReaction, - removeReaction, deleteMessage, - getUserInfo, } from "../../lib/slack-client.ts"; import type { - SlackEvent, SlackAppMentionEvent, SlackMessageEvent, } from "../../lib/types.ts"; @@ -37,13 +35,8 @@ import { shouldIgnoreEvent } from "../../webhook.ts"; import { configureContext as setContextConfig, setBotUserId as setContextBotUserId, - buildContextMessages, - formatMessagesForLLM, - buildCurrentContent, - isContextEnabled, type ContextConfig, } from "./context-builder.ts"; -import { isLLMAvailable, handleLLMCall } from "./llm-handler.ts"; // Whisper configuration interface WhisperConfig { @@ -336,50 +329,33 @@ async function processAttachedFiles( } /** - * Resolve a Slack user's display name (display_name → real_name → name → userId). - * Used as the stable decopilot thread_id so the agent has memory per person. + * Append transcribed audio + text-file contents to the user's original text + * so they flow through the trigger payload's `text` field. The lazy import of + * `getLanguageFromFilename` mirrors the original eventHandler path and keeps + * the language map out of the hot path when there are no files. */ -async function resolveUserName(userId: string): Promise { - try { - const info = await getUserInfo(userId); - return ( - info?.profile?.display_name || info?.real_name || info?.name || userId - ); - } catch { - return userId; - } -} - -/** - * Build messages for LLM with context - */ -async function buildLLMMessages( - channel: string, +async function appendFileContext( text: string, - ts: string, - threadTs: string | undefined, - media: Array<{ - type: "image" | "audio"; - data: string; - mimeType: string; - name: string; - }>, - cleanMention: boolean = false, -) { - let contextMessages: Array<{ role: "user" | "assistant"; content: string }> = - []; - - if (isContextEnabled()) { - contextMessages = await buildContextMessages(channel, threadTs, ts); + textFiles: Array<{ name: string; content: string; mimeType: string }>, + transcriptions: string[], +): Promise { + let out = text; + if (transcriptions.length > 0) { + out += `\n\n${transcriptions.join("\n\n")}`; } - - const currentContent = buildCurrentContent(text, media.length, cleanMention); - - return formatMessagesForLLM( - contextMessages, - currentContent, - media.length > 0 ? media : undefined, - ); + if (textFiles.length > 0) { + const { getLanguageFromFilename } = await import( + "../../lib/slack-client.ts" + ); + const textFileContent = textFiles + .map( + (file) => + `[File: ${file.name}]\n\`\`\`${getLanguageFromFilename(file.name)}\n${file.content}\n\`\`\``, + ) + .join("\n\n"); + out += `\n\n${textFileContent}`; + } + return out; } // ============================================================================ @@ -441,110 +417,68 @@ export async function handleSlackEvent( } /** - * Handle @bot mentions + * Handle @bot mentions. + * + * Fast path: send the "Pensando..." placeholder (which also creates the + * thread under the user's message) and publish the enriched trigger so a + * subscribed agent can answer. We do NOT call the LLM directly — that path + * has been broken in production and the trigger flow is the source of truth + * for the response now. */ async function handleAppMention( event: SlackAppMentionEvent, teamConfig: SlackTeamConfig, connectionId: string, ): Promise { - const { channel, user, text, ts, thread_ts, files } = event; + const { channel, text, ts, thread_ts, files } = event; const showOnlyFinal = teamConfig.responseConfig?.showOnlyFinalResponse ?? false; - - if (!showOnlyFinal) { - await addReaction(channel, ts, "eyes"); - } - const replyTo = thread_ts ?? ts; const showThinking = showOnlyFinal ? false : (teamConfig.responseConfig?.showThinkingMessage ?? true); + const thinkingMsg = showThinking ? await sendThinkingMessage(channel, replyTo) : null; - if (!showOnlyFinal) { - await removeReaction(channel, ts, "eyes"); - } - - const { media, textFiles, transcriptions, audioWithoutWhisper } = + const { textFiles, transcriptions, audioWithoutWhisper } = await processAttachedFiles(files); if (audioWithoutWhisper) { const warningMsg = "Audio detectado! Para processar arquivos de audio, e necessario ativar a integracao **Whisper** no Mesh."; await replyInThread(channel, replyTo, warningMsg); + if (thinkingMsg?.ts) { + await deleteMessage(channel, thinkingMsg.ts); + } return; } - const { getLanguageFromFilename } = await import("../../lib/slack-client.ts"); - const textFileContent = textFiles - .map((file) => { - const language = getLanguageFromFilename(file.name); - return `[File: ${file.name}]\n\`\`\`${language}\n${file.content}\n\`\`\``; - }) - .join("\n\n"); - - let fullText = text; - if (transcriptions.length > 0) { - fullText += `\n\n${transcriptions.join("\n\n")}`; - } - if (textFileContent) { - fullText += `\n\n${textFileContent}`; - } - - const mediaForLLM = - transcriptions.length > 0 ? media.filter((m) => m.type === "image") : media; + const fullText = await appendFileContext(text, textFiles, transcriptions); - const messages = await buildLLMMessages( - channel, - fullText, - ts, - thread_ts, - mediaForLLM, - true, + await publishAppMention( + connectionId, + { ...event, text: fullText }, + { thinking_message_ts: thinkingMsg?.ts }, ); - - if (!(await isLLMAvailable(connectionId))) { - const warningMsg = - "Bot ainda inicializando. Por favor, tente novamente em alguns segundos."; - await replyInThread(channel, replyTo, warningMsg); - return; - } - - const enableStreaming = showOnlyFinal - ? false - : (teamConfig.responseConfig?.enableStreaming ?? true); - - try { - await handleLLMCall(connectionId, messages, { - channel, - replyTo, - thinkingMessageTs: thinkingMsg?.ts, - streamingEnabled: enableStreaming, - userName: await resolveUserName(user), - slackEvent: { text: fullText, user, ts, thread_ts }, - }); - } catch (error) { - logger.error("App mention LLM error", { - channel, - userId: user, - error: String(error), - }); - } } /** - * Handle direct messages and channel messages + * Handle direct messages and channel messages. + * + * Routes by event shape — the handlers themselves are responsible for + * sending "Pensando..." and publishing the trigger. We deliberately do NOT + * process attached files here so the thinking message can fire before any + * Whisper / file-download work. */ async function handleMessage( event: SlackMessageEvent, teamConfig: SlackTeamConfig, connectionId: string, ): Promise { - const { channel, user, text, ts, thread_ts, channel_type, files } = event; + const { channel, text, thread_ts, channel_type } = event; const isDM = channel_type === "im" || channel?.startsWith("D"); const botUserId = globalBotUserId ?? teamConfig.botUserId; @@ -553,7 +487,7 @@ async function handleMessage( return; } - // For threads, check if bot participated + // For channel threads, only respond if the bot has participated if (thread_ts && !isDM) { const botParticipated = await botParticipatedInThread( channel, @@ -565,137 +499,50 @@ async function handleMessage( } } - const { media, textFiles, transcriptions, audioWithoutWhisper } = - await processAttachedFiles(files); - - if (audioWithoutWhisper) { - const warningMsg = - "Audio detectado! Para processar arquivos de audio, e necessario ativar a integracao **Whisper** no Mesh."; - if (isDM) { - await sendMessage({ channel, text: warningMsg }); - } else if (thread_ts) { - await replyInThread(channel, thread_ts, warningMsg); - } - return; - } - - const { getLanguageFromFilename } = await import("../../lib/slack-client.ts"); - const textFileContent = textFiles - .map((file) => { - const language = getLanguageFromFilename(file.name); - return `[File: ${file.name}]\n\`\`\`${language}\n${file.content}\n\`\`\``; - }) - .join("\n\n"); - - let fullText = text; - if (transcriptions.length > 0) { - fullText += `\n\n${transcriptions.join("\n\n")}`; - } - if (textFileContent) { - fullText += `\n\n${textFileContent}`; - } - - const mediaForLLM = - transcriptions.length > 0 ? media.filter((m) => m.type === "image") : media; - // A DM that is a reply inside an existing thread continues in that thread; // a fresh top-level DM starts a brand-new thread under the user's message // (see handleDirectMessage). Channel messages only reach this branch when // thread_ts is set AND the bot has participated in that thread. if (isDM && thread_ts) { - await handleThreadReply( - channel, - user, - fullText, - ts, - thread_ts, - mediaForLLM, - teamConfig, - connectionId, - ); + await handleThreadReply(event, thread_ts, teamConfig, connectionId); } else if (isDM) { - await handleDirectMessage( - channel, - user, - fullText, - ts, - mediaForLLM, - teamConfig, - connectionId, - ); + await handleDirectMessage(event, teamConfig, connectionId); } else if (thread_ts) { - await handleThreadReply( - channel, - user, - fullText, - ts, - thread_ts, - mediaForLLM, - teamConfig, - connectionId, - ); + await handleThreadReply(event, thread_ts, teamConfig, connectionId); } } /** - * Handle direct messages + * Handle top-level direct messages. + * + * Every top-level DM kicks off a brand-new thread under the user's message + * — "Pensando..." is sent with thread_ts=ts, which is what visually creates + * the thread. Each subject ends up in its own thread. */ async function handleDirectMessage( - channel: string, - user: string, - text: string, - ts: string, - media: Array<{ - type: "image" | "audio"; - data: string; - mimeType: string; - name: string; - }>, + event: SlackMessageEvent, teamConfig: SlackTeamConfig, connectionId: string, ): Promise { + const { channel, text, ts, files } = event; + const showOnlyFinal = teamConfig.responseConfig?.showOnlyFinalResponse ?? false; - - if (!showOnlyFinal) { - await addReaction(channel, ts, "eyes"); - } - - // Every top-level DM kicks off a brand-new thread under the user's - // message — bot's thinking message and final reply both live inside that - // thread. Subsequent replies from the user in the thread continue there - // (handled by handleThreadReply), so each subject stays isolated. const replyTo = ts; - const showThinking = showOnlyFinal ? false : (teamConfig.responseConfig?.showThinkingMessage ?? true); + const thinkingMsg = showThinking ? await sendThinkingMessage(channel, replyTo) : null; - if (!showOnlyFinal) { - await removeReaction(channel, ts, "eyes"); - } - - // Resolve sender name (used as a prefix for the LLM context) - const senderName = await resolveUserName(user); - const senderText = - senderName && senderName !== user - ? `[Mensagem de ${senderName}]\n${text}` - : text; - - const messages = await buildLLMMessages( - channel, - senderText, - ts, - undefined, - media, - ); + const { textFiles, transcriptions, audioWithoutWhisper } = + await processAttachedFiles(files); - if (!(await isLLMAvailable(connectionId))) { + if (audioWithoutWhisper) { const warningMsg = - "Bot ainda inicializando. Por favor, tente novamente em alguns segundos."; + "Audio detectado! Para processar arquivos de audio, e necessario ativar a integracao **Whisper** no Mesh."; await replyInThread(channel, replyTo, warningMsg); if (thinkingMsg?.ts) { await deleteMessage(channel, thinkingMsg.ts); @@ -703,72 +550,42 @@ async function handleDirectMessage( return; } - const enableStreaming = showOnlyFinal - ? false - : (teamConfig.responseConfig?.enableStreaming ?? true); + const fullText = await appendFileContext(text, textFiles, transcriptions); - try { - await handleLLMCall(connectionId, messages, { - channel, - replyTo, - thinkingMessageTs: thinkingMsg?.ts, - streamingEnabled: enableStreaming, - userName: senderName, - slackEvent: { text, user, ts, channel_type: "im" }, - }); - } catch (error) { - logger.error("Direct message LLM error", { - channel, - userId: user, - error: String(error), - errorStack: error instanceof Error ? error.stack : undefined, - errorMessage: error instanceof Error ? error.message : String(error), - messagesCount: messages.length, - }); - } + await publishMessageReceived( + connectionId, + { ...event, text: fullText }, + { thinking_message_ts: thinkingMsg?.ts }, + ); } /** - * Handle thread replies + * Handle thread replies (in a channel or inside a DM thread). */ async function handleThreadReply( - channel: string, - user: string, - text: string, - ts: string, + event: SlackMessageEvent, threadTs: string, - media: Array<{ - type: "image" | "audio"; - data: string; - mimeType: string; - name: string; - }>, teamConfig: SlackTeamConfig, connectionId: string, ): Promise { + const { channel, text, files } = event; + const showOnlyFinal = teamConfig.responseConfig?.showOnlyFinalResponse ?? false; - - if (!showOnlyFinal) { - await addReaction(channel, ts, "eyes"); - } - const showThinking = showOnlyFinal ? false : (teamConfig.responseConfig?.showThinkingMessage ?? true); + const thinkingMsg = showThinking ? await sendThinkingMessage(channel, threadTs) : null; - if (!showOnlyFinal) { - await removeReaction(channel, ts, "eyes"); - } - - const messages = await buildLLMMessages(channel, text, ts, threadTs, media); + const { textFiles, transcriptions, audioWithoutWhisper } = + await processAttachedFiles(files); - if (!(await isLLMAvailable(connectionId))) { + if (audioWithoutWhisper) { const warningMsg = - "Bot ainda inicializando. Por favor, tente novamente em alguns segundos."; + "Audio detectado! Para processar arquivos de audio, e necessario ativar a integracao **Whisper** no Mesh."; await replyInThread(channel, threadTs, warningMsg); if (thinkingMsg?.ts) { await deleteMessage(channel, thinkingMsg.ts); @@ -776,27 +593,13 @@ async function handleThreadReply( return; } - const enableStreaming = showOnlyFinal - ? false - : (teamConfig.responseConfig?.enableStreaming ?? true); + const fullText = await appendFileContext(text, textFiles, transcriptions); - try { - await handleLLMCall(connectionId, messages, { - channel, - replyTo: threadTs, - thinkingMessageTs: thinkingMsg?.ts, - streamingEnabled: enableStreaming, - userName: await resolveUserName(user), - slackEvent: { text, user, ts, thread_ts: threadTs }, - }); - } catch (error) { - logger.error("Thread reply LLM error", { - channel, - userId: user, - threadTs, - error: String(error), - }); - } + await publishMessageReceived( + connectionId, + { ...event, text: fullText }, + { thinking_message_ts: thinkingMsg?.ts }, + ); } // ============================================================================