This commit is contained in:
Samir Rayani 2026-01-30 05:49:32 -06:00 committed by GitHub
commit 85ffde1882
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 80 additions and 1 deletions

View File

@ -337,6 +337,8 @@ const FIELD_LABELS: Record<string, string> = {
"channels.slack.userTokenReadOnly": "Slack User Token Read Only", "channels.slack.userTokenReadOnly": "Slack User Token Read Only",
"channels.slack.thread.historyScope": "Slack Thread History Scope", "channels.slack.thread.historyScope": "Slack Thread History Scope",
"channels.slack.thread.inheritParent": "Slack Thread Parent Inheritance", "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.botToken": "Mattermost Bot Token",
"channels.mattermost.baseUrl": "Mattermost Base URL", "channels.mattermost.baseUrl": "Mattermost Base URL",
"channels.mattermost.chatmode": "Mattermost Chat Mode", "channels.mattermost.chatmode": "Mattermost Chat Mode",
@ -469,6 +471,10 @@ const FIELD_HELP: Record<string, string> = {
'Scope for Slack thread history context ("thread" isolates per thread; "channel" reuses channel history).', 'Scope for Slack thread history context ("thread" isolates per thread; "channel" reuses channel history).',
"channels.slack.thread.inheritParent": "channels.slack.thread.inheritParent":
"If true, Slack thread sessions inherit the parent channel transcript (default: false).", "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": "channels.mattermost.botToken":
"Bot token from Mattermost System Console -> Integrations -> Bot Accounts.", "Bot token from Mattermost System Console -> Integrations -> Bot Accounts.",
"channels.mattermost.baseUrl": "channels.mattermost.baseUrl":

View File

@ -73,6 +73,16 @@ export type SlackThreadConfig = {
historyScope?: "thread" | "channel"; historyScope?: "thread" | "channel";
/** If true, thread sessions inherit the parent channel transcript. Default: false. */ /** If true, thread sessions inherit the parent channel transcript. Default: false. */
inheritParent?: boolean; 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 = { export type SlackAccountConfig = {

View File

@ -392,6 +392,8 @@ export const SlackThreadSchema = z
.object({ .object({
historyScope: z.enum(["thread", "channel"]).optional(), historyScope: z.enum(["thread", "channel"]).optional(),
inheritParent: z.boolean().optional(), inheritParent: z.boolean().optional(),
followOnMention: z.boolean().optional(),
followIdleMinutes: z.number().int().positive().optional(),
}) })
.strict(); .strict();

View File

@ -82,6 +82,8 @@ export type SlackMonitorContext = {
replyToMode: "off" | "first" | "all"; replyToMode: "off" | "first" | "all";
threadHistoryScope: "thread" | "channel"; threadHistoryScope: "thread" | "channel";
threadInheritParent: boolean; threadInheritParent: boolean;
threadFollowOnMention: boolean;
threadFollowIdleMinutes: number;
slashCommand: Required<import("../../config/config.js").SlackSlashCommandConfig>; slashCommand: Required<import("../../config/config.js").SlackSlashCommandConfig>;
textLimit: number; textLimit: number;
ackReactionScope: string; ackReactionScope: string;
@ -112,6 +114,10 @@ export type SlackMonitorContext = {
threadTs?: string; threadTs?: string;
status: string; status: string;
}) => Promise<void>; }) => 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: { export function createSlackMonitorContext(params: {
@ -143,6 +149,8 @@ export function createSlackMonitorContext(params: {
replyToMode: SlackMonitorContext["replyToMode"]; replyToMode: SlackMonitorContext["replyToMode"];
threadHistoryScope: SlackMonitorContext["threadHistoryScope"]; threadHistoryScope: SlackMonitorContext["threadHistoryScope"];
threadInheritParent: SlackMonitorContext["threadInheritParent"]; threadInheritParent: SlackMonitorContext["threadInheritParent"];
threadFollowOnMention?: boolean;
threadFollowIdleMinutes?: number;
slashCommand: SlackMonitorContext["slashCommand"]; slashCommand: SlackMonitorContext["slashCommand"];
textLimit: number; textLimit: number;
ackReactionScope: string; ackReactionScope: string;
@ -152,6 +160,34 @@ export function createSlackMonitorContext(params: {
const channelHistories = new Map<string, HistoryEntry[]>(); const channelHistories = new Map<string, HistoryEntry[]>();
const logger = getChildLogger({ module: "slack-auto-reply" }); 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< const channelCache = new Map<
string, string,
{ {
@ -387,6 +423,8 @@ export function createSlackMonitorContext(params: {
replyToMode: params.replyToMode, replyToMode: params.replyToMode,
threadHistoryScope: params.threadHistoryScope, threadHistoryScope: params.threadHistoryScope,
threadInheritParent: params.threadInheritParent, threadInheritParent: params.threadInheritParent,
threadFollowOnMention,
threadFollowIdleMinutes,
slashCommand: params.slashCommand, slashCommand: params.slashCommand,
textLimit: params.textLimit, textLimit: params.textLimit,
ackReactionScope: params.ackReactionScope, ackReactionScope: params.ackReactionScope,
@ -400,5 +438,7 @@ export function createSlackMonitorContext(params: {
resolveChannelName, resolveChannelName,
resolveUserName, resolveUserName,
setSlackThreadStatus, setSlackThreadStatus,
isActiveThread,
markThreadActive,
}; };
} }

View File

@ -223,11 +223,16 @@ export async function prepareSlackMessage(params: {
canResolveExplicit: Boolean(ctx.botUserId), 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( const implicitMention = Boolean(
!isDirectMessage && !isDirectMessage &&
ctx.botUserId && ctx.botUserId &&
message.thread_ts && 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; const sender = message.user ? await ctx.resolveUserName(message.user) : null;
@ -306,6 +311,18 @@ export async function prepareSlackMessage(params: {
commandAuthorized, commandAuthorized,
}); });
const effectiveWasMentioned = mentionGate.effectiveWasMentioned; 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) { if (isRoom && shouldRequireMention && mentionGate.shouldSkip) {
ctx.logger.info({ channel: message.channel, reason: "no-mention" }, "skipping channel message"); ctx.logger.info({ channel: message.channel, reason: "no-mention" }, "skipping channel message");
const pendingText = (message.text ?? "").trim(); const pendingText = (message.text ?? "").trim();

View File

@ -118,6 +118,8 @@ export async function monitorSlackProvider(opts: MonitorSlackOpts = {}) {
const replyToMode = slackCfg.replyToMode ?? "off"; const replyToMode = slackCfg.replyToMode ?? "off";
const threadHistoryScope = slackCfg.thread?.historyScope ?? "thread"; const threadHistoryScope = slackCfg.thread?.historyScope ?? "thread";
const threadInheritParent = slackCfg.thread?.inheritParent ?? false; 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 slashCommand = resolveSlackSlashCommandConfig(opts.slashCommand ?? slackCfg.slashCommand);
const textLimit = resolveTextChunkLimit(cfg, "slack", account.accountId); const textLimit = resolveTextChunkLimit(cfg, "slack", account.accountId);
const ackReactionScope = cfg.messages?.ackReactionScope ?? "group-mentions"; const ackReactionScope = cfg.messages?.ackReactionScope ?? "group-mentions";
@ -199,6 +201,8 @@ export async function monitorSlackProvider(opts: MonitorSlackOpts = {}) {
replyToMode, replyToMode,
threadHistoryScope, threadHistoryScope,
threadInheritParent, threadInheritParent,
threadFollowOnMention,
threadFollowIdleMinutes,
slashCommand, slashCommand,
textLimit, textLimit,
ackReactionScope, ackReactionScope,