diff --git a/src/config/schema.ts b/src/config/schema.ts index 9b5ad8be6..226321346 100644 --- a/src/config/schema.ts +++ b/src/config/schema.ts @@ -336,6 +336,8 @@ const FIELD_LABELS: Record = { "channels.slack.userTokenReadOnly": "Slack User Token Read Only", "channels.slack.thread.historyScope": "Slack Thread History Scope", "channels.slack.thread.inheritParent": "Slack Thread Parent Inheritance", + "channels.slack.thread.followOnMention": "Slack Thread Follow on Mention", + "channels.slack.thread.followIdleMinutes": "Slack Thread Follow Idle Minutes", "channels.mattermost.botToken": "Mattermost Bot Token", "channels.mattermost.baseUrl": "Mattermost Base URL", "channels.mattermost.chatmode": "Mattermost Chat Mode", @@ -468,6 +470,10 @@ const FIELD_HELP: Record = { 'Scope for Slack thread history context ("thread" isolates per thread; "channel" reuses channel history).', "channels.slack.thread.inheritParent": "If true, Slack thread sessions inherit the parent channel transcript (default: false).", + "channels.slack.thread.followOnMention": + "If true, continue responding in threads where the bot was mentioned without requiring repeated @mentions (default: false).", + "channels.slack.thread.followIdleMinutes": + "Minutes of inactivity before a thread is no longer considered 'active' for followOnMention (default: 60).", "channels.mattermost.botToken": "Bot token from Mattermost System Console -> Integrations -> Bot Accounts.", "channels.mattermost.baseUrl": diff --git a/src/config/types.slack.ts b/src/config/types.slack.ts index 0f6b9e388..dfffce671 100644 --- a/src/config/types.slack.ts +++ b/src/config/types.slack.ts @@ -73,6 +73,16 @@ export type SlackThreadConfig = { historyScope?: "thread" | "channel"; /** If true, thread sessions inherit the parent channel transcript. Default: false. */ inheritParent?: boolean; + /** + * If true, continue responding in threads where the bot was mentioned + * without requiring repeated @mentions. Default: false. + */ + followOnMention?: boolean; + /** + * Minutes of inactivity before a thread is no longer considered "active" + * for followOnMention. Default: 60. + */ + followIdleMinutes?: number; }; export type SlackAccountConfig = { diff --git a/src/config/zod-schema.providers-core.ts b/src/config/zod-schema.providers-core.ts index ed7dda22a..218a9db55 100644 --- a/src/config/zod-schema.providers-core.ts +++ b/src/config/zod-schema.providers-core.ts @@ -392,6 +392,8 @@ export const SlackThreadSchema = z .object({ historyScope: z.enum(["thread", "channel"]).optional(), inheritParent: z.boolean().optional(), + followOnMention: z.boolean().optional(), + followIdleMinutes: z.number().int().positive().optional(), }) .strict(); diff --git a/src/slack/monitor/context.ts b/src/slack/monitor/context.ts index 876c22ae6..a1078278d 100644 --- a/src/slack/monitor/context.ts +++ b/src/slack/monitor/context.ts @@ -82,6 +82,8 @@ export type SlackMonitorContext = { replyToMode: "off" | "first" | "all"; threadHistoryScope: "thread" | "channel"; threadInheritParent: boolean; + threadFollowOnMention: boolean; + threadFollowIdleMinutes: number; slashCommand: Required; textLimit: number; ackReactionScope: string; @@ -112,6 +114,10 @@ export type SlackMonitorContext = { threadTs?: string; status: string; }) => Promise; + /** Check if a thread is active (bot was mentioned recently). */ + isActiveThread: (channelId: string, threadTs: string) => boolean; + /** Mark a thread as active (called when bot is mentioned in a thread). */ + markThreadActive: (channelId: string, threadTs: string) => void; }; export function createSlackMonitorContext(params: { @@ -143,6 +149,8 @@ export function createSlackMonitorContext(params: { replyToMode: SlackMonitorContext["replyToMode"]; threadHistoryScope: SlackMonitorContext["threadHistoryScope"]; threadInheritParent: SlackMonitorContext["threadInheritParent"]; + threadFollowOnMention?: boolean; + threadFollowIdleMinutes?: number; slashCommand: SlackMonitorContext["slashCommand"]; textLimit: number; ackReactionScope: string; @@ -152,6 +160,34 @@ export function createSlackMonitorContext(params: { const channelHistories = new Map(); const logger = getChildLogger({ module: "slack-auto-reply" }); + // Thread following: track threads where the bot was mentioned + // Key: "channelId:threadTs", Value: timestamp when last active + const activeThreads = new Map(); + const threadFollowOnMention = params.threadFollowOnMention ?? false; + const threadFollowIdleMinutes = params.threadFollowIdleMinutes ?? 60; + const threadFollowIdleMs = threadFollowIdleMinutes * 60 * 1000; + + const isActiveThread = (channelId: string, threadTs: string): boolean => { + if (!threadFollowOnMention) return false; + const key = `${channelId}:${threadTs}`; + const lastActive = activeThreads.get(key); + if (!lastActive) return false; + const now = Date.now(); + if (now - lastActive > threadFollowIdleMs) { + // Thread has gone idle, remove it + activeThreads.delete(key); + return false; + } + return true; + }; + + const markThreadActive = (channelId: string, threadTs: string): void => { + if (!threadFollowOnMention) return; + const key = `${channelId}:${threadTs}`; + activeThreads.set(key, Date.now()); + logVerbose(`slack: marked thread ${key} as active for followOnMention`); + }; + const channelCache = new Map< string, { @@ -387,6 +423,8 @@ export function createSlackMonitorContext(params: { replyToMode: params.replyToMode, threadHistoryScope: params.threadHistoryScope, threadInheritParent: params.threadInheritParent, + threadFollowOnMention, + threadFollowIdleMinutes, slashCommand: params.slashCommand, textLimit: params.textLimit, ackReactionScope: params.ackReactionScope, @@ -400,5 +438,7 @@ export function createSlackMonitorContext(params: { resolveChannelName, resolveUserName, setSlackThreadStatus, + isActiveThread, + markThreadActive, }; } diff --git a/src/slack/monitor/message-handler/prepare.ts b/src/slack/monitor/message-handler/prepare.ts index 8a2a9e111..14e3d3700 100644 --- a/src/slack/monitor/message-handler/prepare.ts +++ b/src/slack/monitor/message-handler/prepare.ts @@ -223,11 +223,16 @@ export async function prepareSlackMessage(params: { canResolveExplicit: Boolean(ctx.botUserId), }, })); + // Check if this is an "active thread" where the bot was previously mentioned + const isInActiveThread = Boolean( + !isDirectMessage && message.thread_ts && ctx.isActiveThread(message.channel, message.thread_ts), + ); + const implicitMention = Boolean( !isDirectMessage && ctx.botUserId && message.thread_ts && - message.parent_user_id === ctx.botUserId, + (message.parent_user_id === ctx.botUserId || isInActiveThread), ); const sender = message.user ? await ctx.resolveUserName(message.user) : null; @@ -306,6 +311,18 @@ export async function prepareSlackMessage(params: { commandAuthorized, }); const effectiveWasMentioned = mentionGate.effectiveWasMentioned; + + // Mark thread as active if the bot was explicitly mentioned (for followOnMention feature) + if (effectiveWasMentioned && message.thread_ts && ctx.threadFollowOnMention) { + ctx.markThreadActive(message.channel, message.thread_ts); + } + // Also mark if this is a reply that creates a thread (the reply itself starts the thread) + if (effectiveWasMentioned && !message.thread_ts && message.ts && ctx.threadFollowOnMention) { + // If the bot responds, it will create a thread with this ts - mark it preemptively + // Actually, we should mark the thread when the bot responds, not here + // For now, just handle existing threads + } + if (isRoom && shouldRequireMention && mentionGate.shouldSkip) { ctx.logger.info({ channel: message.channel, reason: "no-mention" }, "skipping channel message"); const pendingText = (message.text ?? "").trim(); diff --git a/src/slack/monitor/provider.ts b/src/slack/monitor/provider.ts index 366a32a34..20f9e969c 100644 --- a/src/slack/monitor/provider.ts +++ b/src/slack/monitor/provider.ts @@ -118,6 +118,8 @@ export async function monitorSlackProvider(opts: MonitorSlackOpts = {}) { const replyToMode = slackCfg.replyToMode ?? "off"; const threadHistoryScope = slackCfg.thread?.historyScope ?? "thread"; const threadInheritParent = slackCfg.thread?.inheritParent ?? false; + const threadFollowOnMention = slackCfg.thread?.followOnMention ?? false; + const threadFollowIdleMinutes = slackCfg.thread?.followIdleMinutes ?? 60; const slashCommand = resolveSlackSlashCommandConfig(opts.slashCommand ?? slackCfg.slashCommand); const textLimit = resolveTextChunkLimit(cfg, "slack", account.accountId); const ackReactionScope = cfg.messages?.ackReactionScope ?? "group-mentions"; @@ -199,6 +201,8 @@ export async function monitorSlackProvider(opts: MonitorSlackOpts = {}) { replyToMode, threadHistoryScope, threadInheritParent, + threadFollowOnMention, + threadFollowIdleMinutes, slashCommand, textLimit, ackReactionScope,