From 76ac96b597c8f6208aae848f7993ac56d695f08d Mon Sep 17 00:00:00 2001 From: Jonas Jesus Date: Thu, 14 May 2026 11:19:09 -0300 Subject: [PATCH 1/2] feat(slack): start a new thread on every DM so each subject stays isolated MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit DMs to the bot used to flatten into a single top-level conversation — every reply piled up at the channel root and it was hard to tell which answer belonged to which question. App-mentions in channels already started a thread under the user's message (replyTo = thread_ts ?? ts); this brings the DM flow in line. Two changes in eventHandler.ts: - handleDirectMessage now uses the user's message ts as the thread_ts for the bot's thinking message and final reply. The first DM kicks off a new thread, follow-ups inside that thread go through the existing handleThreadReply path so the conversation continues in place. - handleMessage routes DMs that arrive with thread_ts (i.e. the user replied inside an existing bot thread) to handleThreadReply instead of handleDirectMessage. Previously the isDM short-circuit ignored the thread_ts and the bot answered at the channel root again. Co-Authored-By: Claude Opus 4.7 --- .../server/slack/handlers/eventHandler.ts | 32 ++++++++++++++++--- 1 file changed, 28 insertions(+), 4 deletions(-) diff --git a/slack-mcp/server/slack/handlers/eventHandler.ts b/slack-mcp/server/slack/handlers/eventHandler.ts index 8d1b6159..6bf7136c 100644 --- a/slack-mcp/server/slack/handlers/eventHandler.ts +++ b/slack-mcp/server/slack/handlers/eventHandler.ts @@ -598,7 +598,22 @@ async function handleMessage( const mediaForLLM = transcriptions.length > 0 ? media.filter((m) => m.type === "image") : media; - if (isDM) { + // 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, + ); + } else if (isDM) { await handleDirectMessage( channel, user, @@ -646,16 +661,24 @@ async function handleDirectMessage( 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) : null; + const thinkingMsg = showThinking + ? await sendThinkingMessage(channel, replyTo) + : null; if (!showOnlyFinal) { await removeReaction(channel, ts, "eyes"); } - // Resolve sender name (used both as a prefix for the LLM and as the thread_id) + // Resolve sender name (used as a prefix for the LLM context) const senderName = await resolveUserName(user); const senderText = senderName && senderName !== user @@ -673,7 +696,7 @@ async function handleDirectMessage( if (!(await isLLMAvailable(connectionId))) { const warningMsg = "Bot ainda inicializando. Por favor, tente novamente em alguns segundos."; - await sendMessage({ channel, text: warningMsg }); + await replyInThread(channel, replyTo, warningMsg); if (thinkingMsg?.ts) { await deleteMessage(channel, thinkingMsg.ts); } @@ -687,6 +710,7 @@ async function handleDirectMessage( try { await handleLLMCall(connectionId, messages, { channel, + replyTo, thinkingMessageTs: thinkingMsg?.ts, streamingEnabled: enableStreaming, userName: senderName, From 8bcb7158e5e26ee1e89731632ea3a7efe6aa5ab6 Mon Sep 17 00:00:00 2001 From: Jonas Jesus Date: Thu, 14 May 2026 11:25:29 -0300 Subject: [PATCH 2/2] feat(slack): enrich trigger payload with user_name and full thread history Subscribers of slack.message.received and slack.app_mention used to get just the bare event fields (channel_id, user_id, text, ts, thread_ts). A trigger-driven agent that wanted to reply in-thread had to round-trip to SLACK_GET_USER_INFO and SLACK_GET_THREAD_REPLIES before it could even start composing an answer. Bake both into the publisher: - user_name: display_name -> real_name -> name, resolved via getUserInfo - thread_messages: when the incoming event lives in a thread, the full ordered list of {ts, user, text, is_bot} from getThreadReplies. With the DM-per-thread change in the parent commit, every continuation message arrives with thread_ts set, so the agent sees the whole conversation in one shot. publishMessageReceived now also takes an optional extras parameter so the llm-handler fallback path can flag fallback: true while reusing the same enrichment instead of duplicating triggers.notify inline. Co-Authored-By: Claude Opus 4.7 --- slack-mcp/server/lib/event-publisher.ts | 81 +++++++++++++++++-- .../server/slack/handlers/eventHandler.ts | 4 +- .../server/slack/handlers/llm-handler.ts | 35 ++++---- 3 files changed, 97 insertions(+), 23 deletions(-) diff --git a/slack-mcp/server/lib/event-publisher.ts b/slack-mcp/server/lib/event-publisher.ts index 0e420a76..7af9fa21 100644 --- a/slack-mcp/server/lib/event-publisher.ts +++ b/slack-mcp/server/lib/event-publisher.ts @@ -3,48 +3,117 @@ * * Publishes Slack events via trigger callbacks for cross-MCP integration. * Other MCPs can subscribe to these events to react to Slack activity. + * + * For message-style events (`slack.message.received` and `slack.app_mention`) + * we enrich the payload with: + * - `user_name`: the resolved Slack display name of the author, so the + * subscriber doesn't have to call SLACK_GET_USER_INFO just to address + * the user properly. + * - `thread_messages`: when the incoming event lives in a thread, the + * full set of replies from that thread (oldest → newest, including the + * parent and the bot's own prior replies). Lets a trigger-driven agent + * see the entire conversation in one shot and answer coherently with + * SLACK_REPLY_IN_THREAD without having to fetch history itself. */ import type { SlackEvent } from "./types.ts"; import { triggers } from "./trigger-store.ts"; +import { getThreadReplies, getUserInfo } from "./slack-client.ts"; + +interface ThreadMessageSummary { + ts: string; + user?: string; + text: string; + is_bot: boolean; +} -export function publishMessageReceived( +async function resolveUserName( + userId: string | undefined, +): Promise { + if (!userId) return undefined; + try { + const info = await getUserInfo(userId); + return ( + info?.profile?.display_name || info?.real_name || info?.name || undefined + ); + } catch { + return undefined; + } +} + +async function fetchThreadMessages( + channel: string | undefined, + threadTs: string | undefined, +): Promise { + if (!channel || !threadTs) return undefined; + try { + const replies = await getThreadReplies(channel, threadTs); + return replies + .sort((a, b) => Number.parseFloat(a.ts) - Number.parseFloat(b.ts)) + .map((m) => ({ + ts: m.ts, + user: m.user, + text: m.text ?? "", + is_bot: Boolean(m.bot_id), + })); + } catch { + return undefined; + } +} + +export async function publishMessageReceived( connectionId: string, event: SlackEvent, -): void { + extras?: { fallback?: boolean }, +): Promise { + const [user_name, thread_messages] = await Promise.all([ + resolveUserName(event.user), + fetchThreadMessages(event.channel, event.thread_ts), + ]); + triggers.notify(connectionId, "slack.message.received", { event: "slack.message.received", channel_id: event.channel, user_id: event.user, + user_name, text: event.text ?? "", ts: event.ts, thread_ts: event.thread_ts, is_dm: event.channel?.startsWith("D") || (event as any).channel_type === "im", has_files: !!(event as any).files?.length, + thread_messages, timestamp: new Date().toISOString(), + ...(extras?.fallback ? { fallback: true } : {}), }); console.log( - `[Triggers] Notified slack.message.received: channel=${event.channel}`, + `[Triggers] Notified slack.message.received: channel=${event.channel}${thread_messages ? ` (${thread_messages.length} thread msgs)` : ""}`, ); } -export function publishAppMention( +export async function publishAppMention( connectionId: string, event: SlackEvent, -): void { +): Promise { + const [user_name, thread_messages] = await Promise.all([ + resolveUserName(event.user), + fetchThreadMessages(event.channel, event.thread_ts), + ]); + triggers.notify(connectionId, "slack.app_mention", { event: "slack.app_mention", channel_id: event.channel, user_id: event.user, + user_name, text: event.text ?? "", ts: event.ts, thread_ts: event.thread_ts, has_files: !!(event as any).files?.length, + thread_messages, timestamp: new Date().toISOString(), }); console.log( - `[Triggers] Notified slack.app_mention: channel=${event.channel}`, + `[Triggers] Notified slack.app_mention: channel=${event.channel}${thread_messages ? ` (${thread_messages.length} thread msgs)` : ""}`, ); } diff --git a/slack-mcp/server/slack/handlers/eventHandler.ts b/slack-mcp/server/slack/handlers/eventHandler.ts index 6bf7136c..e5adbfbc 100644 --- a/slack-mcp/server/slack/handlers/eventHandler.ts +++ b/slack-mcp/server/slack/handlers/eventHandler.ts @@ -406,7 +406,7 @@ export async function handleSlackEvent( // Publishing here AND running the LLM caused every message to be // answered twice (once by the LLM, once by the trigger subscriber). if (triggerOnly) { - publishAppMention(connectionId, payload); + await publishAppMention(connectionId, payload); } else { await handleAppMention( payload as SlackAppMentionEvent, @@ -417,7 +417,7 @@ export async function handleSlackEvent( break; case "message": if (triggerOnly) { - publishMessageReceived(connectionId, payload); + await publishMessageReceived(connectionId, payload); } else { await handleMessage( payload as SlackMessageEvent, diff --git a/slack-mcp/server/slack/handlers/llm-handler.ts b/slack-mcp/server/slack/handlers/llm-handler.ts index 19a17014..6aedb165 100644 --- a/slack-mcp/server/slack/handlers/llm-handler.ts +++ b/slack-mcp/server/slack/handlers/llm-handler.ts @@ -18,7 +18,7 @@ import { deleteMessage, } from "../../lib/slack-client.ts"; import { formatForSlack, buildResponseBlocks } from "../../lib/format.ts"; -import { triggers } from "../../lib/trigger-store.ts"; +import { publishMessageReceived } from "../../lib/event-publisher.ts"; import type { MessageWithImages } from "./context-builder.ts"; import { logger } from "../../lib/logger.ts"; @@ -246,20 +246,25 @@ export async function handleLLMCall( await deleteMessage(channel, thinkingMessageTs).catch(() => {}); } - const isDM = channel.startsWith("D") || slackEvent.channel_type === "im"; - - triggers.notify(connectionId, "slack.message.received", { - event: "slack.message.received", - channel_id: channel, - user_id: slackEvent.user, - text: slackEvent.text, - ts: slackEvent.ts, - thread_ts: slackEvent.thread_ts, - is_dm: isDM, - has_files: false, - timestamp: new Date().toISOString(), - fallback: true, - }); + // Reuse the standard publisher so the subscriber gets the same + // enriched payload (user_name, thread_messages, etc.) as the + // triggerOnly mode would deliver. + await publishMessageReceived( + connectionId, + { + type: "message", + event_ts: slackEvent.ts, + channel, + user: slackEvent.user, + text: slackEvent.text, + ts: slackEvent.ts, + thread_ts: slackEvent.thread_ts, + ...(slackEvent.channel_type + ? { channel_type: slackEvent.channel_type } + : {}), + } as Parameters[1], + { fallback: true }, + ); // Don't throw — the trigger will handle the response return;