feat(slack): track thread participation for auto-reply without @mention (#29165)

* feat(slack): track thread participation for auto-reply without @mention

* fix(slack): scope thread participation cache by accountId and capture actual reply thread ts

* fix(slack): capture reply thread ts from all delivery paths and only after success

* Slack: add changelog for thread participation cache behavior

---------

Co-authored-by: Tak Hoffman <781889+Takhoffman@users.noreply.github.com>
This commit is contained in:
Luis Conde
2026-03-01 12:42:12 -04:00
committed by GitHub
parent dfbdab5a29
commit bd78a74298
6 changed files with 170 additions and 8 deletions

View File

@@ -12,6 +12,7 @@ import { danger, logVerbose, shouldLogVerbose } from "../../../globals.js";
import { resolveAgentOutboundIdentity } from "../../../infra/outbound/identity.js";
import { removeSlackReaction } from "../../actions.js";
import { createSlackDraftStream } from "../../draft-stream.js";
import { recordSlackThreadParticipation } from "../../sent-thread-cache.js";
import {
applyAppendOnlyStreamUpdate,
buildStatusFinalPreviewText,
@@ -189,6 +190,7 @@ export async function dispatchPreparedSlackMessage(prepared: PreparedSlackMessag
});
let streamSession: SlackStreamSession | null = null;
let streamFailed = false;
let usedReplyThreadTs: string | undefined;
const deliverNormally = async (payload: ReplyPayload, forcedThreadTs?: string): Promise<void> => {
const replyThreadTs = forcedThreadTs ?? replyPlan.nextThreadTs();
@@ -203,6 +205,10 @@ export async function dispatchPreparedSlackMessage(prepared: PreparedSlackMessag
replyToMode: prepared.replyToMode,
...(slackIdentity ? { identity: slackIdentity } : {}),
});
// Record the thread ts only after confirmed delivery success.
if (replyThreadTs) {
usedReplyThreadTs ??= replyThreadTs;
}
replyPlan.markSent();
};
@@ -235,6 +241,7 @@ export async function dispatchPreparedSlackMessage(prepared: PreparedSlackMessag
teamId: ctx.teamId,
userId: message.user,
});
usedReplyThreadTs ??= streamThreadTs;
replyPlan.markSent();
return;
}
@@ -324,7 +331,13 @@ export async function dispatchPreparedSlackMessage(prepared: PreparedSlackMessag
token: ctx.botToken,
accountId: account.accountId,
maxChars: Math.min(ctx.textLimit, 4000),
resolveThreadTs: () => replyPlan.nextThreadTs(),
resolveThreadTs: () => {
const ts = replyPlan.nextThreadTs();
if (ts) {
usedReplyThreadTs ??= ts;
}
return ts;
},
onMessageSent: () => replyPlan.markSent(),
log: logVerbose,
warn: logVerbose,
@@ -425,6 +438,14 @@ export async function dispatchPreparedSlackMessage(prepared: PreparedSlackMessag
const anyReplyDelivered = queuedFinal || (counts.block ?? 0) > 0 || (counts.final ?? 0) > 0;
// Record thread participation only when we actually delivered a reply and
// know the thread ts that was used (set by deliverNormally, streaming start,
// or draft stream). Falls back to statusThreadTs for edge cases.
const participationThreadTs = usedReplyThreadTs ?? statusThreadTs;
if (anyReplyDelivered && participationThreadTs) {
recordSlackThreadParticipation(account.accountId, message.channel, participationThreadTs);
}
if (!anyReplyDelivered) {
await draftStream.clear();
if (prepared.isRoomish) {

View File

@@ -32,6 +32,7 @@ import { resolveThreadSessionKeys } from "../../../routing/session-key.js";
import { resolveSlackReplyToMode, type ResolvedSlackAccount } from "../../accounts.js";
import { reactSlackMessage } from "../../actions.js";
import { sendMessageSlack } from "../../send.js";
import { hasSlackThreadParticipation } from "../../sent-thread-cache.js";
import { resolveSlackThreadContext } from "../../threading.js";
import type { SlackMessageEvent } from "../../types.js";
import { resolveSlackAllowListMatch, resolveSlackUserAllowed } from "../allow-list.js";
@@ -210,7 +211,8 @@ export async function prepareSlackMessage(params: {
!isDirectMessage &&
ctx.botUserId &&
message.thread_ts &&
message.parent_user_id === ctx.botUserId,
(message.parent_user_id === ctx.botUserId ||
hasSlackThreadParticipation(account.accountId, message.channel, message.thread_ts)),
);
const sender = message.user ? await ctx.resolveUserName(message.user) : null;
@@ -259,7 +261,10 @@ export async function prepareSlackMessage(params: {
useAccessGroups: ctx.useAccessGroups,
authorizers: [
{ configured: allowFromLower.length > 0, allowed: ownerAuthorized },
{ configured: channelUsersAllowlistConfigured, allowed: channelCommandAuthorized },
{
configured: channelUsersAllowlistConfigured,
allowed: channelCommandAuthorized,
},
],
allowTextCommands,
hasControlCommand: hasControlCommandInMessage,