fix(slack): recover missing thread_ts when parent_user_id indicates thread reply
Root cause: Slack occasionally delivers events that include parent_user_id (indicating a thread reply) but omit thread_ts. When this happens, the message was treated as a channel root message and replies leaked out of threads. Changes: - message-handler.ts: Debouncer uses 'maybe-thread:messageTs' key when parent_user_id is present but thread_ts is missing - prepare.ts: Added resolveSlackThreadTsFromHistory() to recover missing thread_ts by fetching the message from Slack API - dispatch.ts: Added verbose logging for thread debugging - replies.ts: Added threadTs to delivery log messages - New test: monitor.threading.missing-thread-ts.test.ts
This commit is contained in:
parent
246216b1f8
commit
40d2842443
@ -32,7 +32,9 @@ export function createSlackMessageHandler(params: {
|
||||
const senderId = entry.message.user ?? entry.message.bot_id;
|
||||
if (!senderId) return null;
|
||||
const messageTs = entry.message.ts ?? entry.message.event_ts;
|
||||
// If Slack flags a thread reply but omits thread_ts, isolate it from root debouncing.
|
||||
// If we get a Slack event that looks like a thread reply (has parent_user_id)
|
||||
// but is missing thread_ts, avoid debouncing it into the channel root bucket.
|
||||
// We'll attempt to resolve the missing thread_ts later in prepareSlackMessage.
|
||||
const threadKey = entry.message.thread_ts
|
||||
? `${entry.message.channel}:${entry.message.thread_ts}`
|
||||
: entry.message.parent_user_id && messageTs
|
||||
|
||||
@ -47,6 +47,12 @@ export async function dispatchPreparedSlackMessage(prepared: PreparedSlackMessag
|
||||
const toolThreadTs = incomingThreadTs ?? (ctx.replyToMode === "all" ? messageTs : undefined);
|
||||
let didSetStatus = false;
|
||||
|
||||
if (shouldLogVerbose()) {
|
||||
logVerbose(
|
||||
`slack threading: inbound channel=${message.channel} ts=${messageTs ?? "unknown"} thread_ts=${incomingThreadTs ?? "none"} replyToMode=${ctx.replyToMode}`,
|
||||
);
|
||||
}
|
||||
|
||||
// Shared mutable ref for "replyToMode=first". Both tool + auto-reply flows
|
||||
// mark this to ensure only the first reply is threaded.
|
||||
const hasRepliedRef = { value: false };
|
||||
|
||||
@ -1,3 +1,5 @@
|
||||
import type { WebClient as SlackWebClient } from "@slack/web-api";
|
||||
|
||||
import { resolveAckReaction } from "../../../agents/identity.js";
|
||||
import { hasControlCommand } from "../../../auto-reply/command-detection.js";
|
||||
import { shouldHandleTextCommands } from "../../../auto-reply/commands-registry.js";
|
||||
@ -48,13 +50,41 @@ import { resolveSlackMedia, resolveSlackThreadStarter } from "../media.js";
|
||||
|
||||
import type { PreparedSlackMessage } from "./types.js";
|
||||
|
||||
async function resolveSlackThreadTsFromHistory(params: {
|
||||
client: SlackWebClient;
|
||||
channelId: string;
|
||||
messageTs: string;
|
||||
}): Promise<string | undefined> {
|
||||
try {
|
||||
const response = (await params.client.conversations.history({
|
||||
channel: params.channelId,
|
||||
latest: params.messageTs,
|
||||
oldest: params.messageTs,
|
||||
inclusive: true,
|
||||
limit: 1,
|
||||
})) as { messages?: Array<{ ts?: string; thread_ts?: string }> };
|
||||
const message =
|
||||
response.messages?.find((entry) => entry.ts === params.messageTs) ?? response.messages?.[0];
|
||||
const threadTs = message?.thread_ts?.trim();
|
||||
return threadTs || undefined;
|
||||
} catch (err) {
|
||||
if (shouldLogVerbose()) {
|
||||
logVerbose(
|
||||
`slack inbound: failed to resolve thread_ts via conversations.history for channel=${params.channelId} ts=${params.messageTs}: ${String(err)}`,
|
||||
);
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
export async function prepareSlackMessage(params: {
|
||||
ctx: SlackMonitorContext;
|
||||
account: ResolvedSlackAccount;
|
||||
message: SlackMessageEvent;
|
||||
opts: { source: "message" | "app_mention"; wasMentioned?: boolean };
|
||||
}): Promise<PreparedSlackMessage | null> {
|
||||
const { ctx, account, message, opts } = params;
|
||||
const { ctx, account, opts } = params;
|
||||
let message = params.message;
|
||||
const cfg = ctx.cfg;
|
||||
|
||||
let channelInfo: {
|
||||
@ -193,6 +223,33 @@ export async function prepareSlackMessage(params: {
|
||||
},
|
||||
});
|
||||
|
||||
// Slack occasionally delivers events that include parent_user_id (so they're thread replies)
|
||||
// but omit thread_ts. If we don't recover the thread_ts, replies can end up in the channel root.
|
||||
if (!message.thread_ts && message.parent_user_id && message.ts) {
|
||||
if (shouldLogVerbose()) {
|
||||
logVerbose(
|
||||
`slack inbound: missing thread_ts for thread reply channel=${message.channel} ts=${message.ts} source=${opts.source}`,
|
||||
);
|
||||
}
|
||||
const resolved = await resolveSlackThreadTsFromHistory({
|
||||
client: ctx.app.client,
|
||||
channelId: message.channel,
|
||||
messageTs: message.ts,
|
||||
});
|
||||
if (resolved) {
|
||||
message = { ...message, thread_ts: resolved };
|
||||
if (shouldLogVerbose()) {
|
||||
logVerbose(
|
||||
`slack inbound: resolved missing thread_ts channel=${message.channel} ts=${message.ts} -> thread_ts=${resolved}`,
|
||||
);
|
||||
}
|
||||
} else if (shouldLogVerbose()) {
|
||||
logVerbose(
|
||||
`slack inbound: could not resolve missing thread_ts channel=${message.channel} ts=${message.ts}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const baseSessionKey = route.sessionKey;
|
||||
const threadContext = resolveSlackThreadContext({ message, replyToMode: ctx.replyToMode });
|
||||
const threadTs = threadContext.incomingThreadTs;
|
||||
|
||||
@ -44,7 +44,7 @@ export async function deliverReplies(params: {
|
||||
});
|
||||
}
|
||||
}
|
||||
params.runtime.log?.(`delivered reply to ${params.target}`);
|
||||
params.runtime.log?.(`delivered reply to ${params.target} threadTs=${threadTs ?? "none"}`);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user