feat(slack): add thread.followOnMention config

When enabled, the bot continues responding in threads where it was
mentioned without requiring repeated @mentions.

New config options:
- thread.followOnMention (boolean, default false)
- thread.followIdleMinutes (number, default 60)

Active threads are tracked in memory and expire after the idle timeout.
This commit is contained in:
Samir Rayani 2026-01-27 20:42:33 -06:00
parent 2930ebfd43
commit 1b9b43771e
6 changed files with 80 additions and 1 deletions

View File

@ -336,6 +336,8 @@ const FIELD_LABELS: Record<string, string> = {
"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<string, string> = {
'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":

View File

@ -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 = {

View File

@ -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();

View File

@ -82,6 +82,8 @@ export type SlackMonitorContext = {
replyToMode: "off" | "first" | "all";
threadHistoryScope: "thread" | "channel";
threadInheritParent: boolean;
threadFollowOnMention: boolean;
threadFollowIdleMinutes: number;
slashCommand: Required<import("../../config/config.js").SlackSlashCommandConfig>;
textLimit: number;
ackReactionScope: string;
@ -112,6 +114,10 @@ export type SlackMonitorContext = {
threadTs?: string;
status: string;
}) => Promise<void>;
/** 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<string, HistoryEntry[]>();
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<string, number>();
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,
};
}

View File

@ -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();

View File

@ -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,