fix(slack): preserve string thread context in queue + DM route (#23804)

* fix(slack): preserve thread_ts in queue drain and deliveryContext

Two related fixes for Slack thread reply routing:

1. Queue drain drops string thread_ts (#11195)
   - `typeof threadId === "number"` in drain.ts only matches Telegram numeric
     topic IDs. Slack thread_ts is a string like "1770474140.187459" which
     fails the check, causing threadKey to become empty.
   - Changed to `threadId != null && threadId !== ""` to accept both number
     and string thread IDs.
   - Applies to all 3 occurrences in drain.ts: cross-channel detection,
     thread key building, and collected originatingThreadId extraction.

2. DM deliveryContext missing thread_ts (#10837)
   - updateLastRoute calls for Slack DMs in both prepare.ts and dispatch.ts
     built deliveryContext without threadId, so the session's delivery context
     never included thread_ts for DM threads.
   - Added threadId from threadContext.messageThreadId / ctxPayload.MessageThreadId
     to both updateLastRoute call sites.

Tests: 3 new cases in queue.collect-routing.test.ts
- Collects messages with matching string thread_ts (same Slack thread)
- Separates messages with different string thread_ts (different threads)
- Treats empty string threadId same as absent

Closes #10837, closes #11195

* fix(slack): preserve string thread context in queue + DM route updates

---------

Co-authored-by: RobClawd <clawd@RobClawds-Mac-mini.local>
This commit is contained in:
Vincent Koc
2026-02-22 13:26:31 -05:00
committed by GitHub
parent b13bba9c35
commit cd7b2814af
4 changed files with 9 additions and 4 deletions

View File

@@ -99,6 +99,7 @@ Docs: https://docs.openclaw.ai
- Telegram/Polling: force-restart stuck runner instances when recoverable unhandled network rejections escape the polling task path, so polling resumes instead of silently stalling. (#19721) Thanks @jg-noncelogic.
- Slack/Slash commands: preserve the Bolt app receiver when registering external select options handlers so monitor startup does not crash on runtimes that require bound `app.options` calls. (#23209) Thanks @0xgaia.
- Slack/Telegram slash sessions: await session metadata persistence before dispatch so first-turn native slash runs do not race session-origin metadata updates. (#23065) thanks @hydro13.
- Slack/Queue routing: preserve string `thread_ts` values through collect-mode queue drain and DM `deliveryContext` updates so threaded follow-ups do not leak to the main channel when Slack thread IDs are strings. (#11934) Thanks @sandieman2.
- Telegram/Native commands: set `ctx.Provider="telegram"` for native slash-command context so elevated gate checks resolve provider correctly (fixes `provider (ctx.Provider)` failures in `/elevated` flows). (#23748) Thanks @serhii12.
- Agents/Ollama: preserve unsafe integer tool-call arguments as exact strings during NDJSON parsing, preventing large numeric IDs from being rounded before tool execution. (#23170) Thanks @BestJoester.
- Cron/Gateway: keep `cron.list` and `cron.status` responsive during startup catch-up by avoiding a long-held cron lock while missed jobs execute. (#23106) Thanks @jayleekr.

View File

@@ -30,7 +30,7 @@ export function scheduleFollowupDrain(
// Once the batch is mixed, never collect again within this drain.
// Prevents “collect after shift” collapsing different targets.
//
// Debug: `pnpm test src/auto-reply/reply/queue.collect-routing.test.ts`
// Debug: `pnpm test src/auto-reply/reply/reply-flow.test.ts`
// Check if messages span multiple channels.
// If so, process individually to preserve per-message routing.
const isCrossChannel = hasCrossChannelItems(queue.items, (item) => {
@@ -38,13 +38,14 @@ export function scheduleFollowupDrain(
const to = item.originatingTo;
const accountId = item.originatingAccountId;
const threadId = item.originatingThreadId;
if (!channel && !to && !accountId && threadId == null) {
if (!channel && !to && !accountId && (threadId == null || threadId === "")) {
return {};
}
if (!isRoutableChannel(channel) || !to) {
return { cross: true };
}
const threadKey = threadId != null ? String(threadId) : "";
// Support both number (Telegram topic IDs) and string (Slack thread_ts) thread IDs.
const threadKey = threadId != null && threadId !== "" ? String(threadId) : "";
return {
key: [channel, to, accountId || "", threadKey].join("|"),
};
@@ -76,8 +77,9 @@ export function scheduleFollowupDrain(
const originatingAccountId = items.find(
(i) => i.originatingAccountId,
)?.originatingAccountId;
// Support both number (Telegram topic) and string (Slack thread_ts) thread IDs.
const originatingThreadId = items.find(
(i) => i.originatingThreadId != null,
(i) => i.originatingThreadId != null && i.originatingThreadId !== "",
)?.originatingThreadId;
const prompt = buildCollectPrompt({

View File

@@ -80,6 +80,7 @@ export async function dispatchPreparedSlackMessage(prepared: PreparedSlackMessag
channel: "slack",
to: `user:${message.user}`,
accountId: route.accountId,
threadId: prepared.ctxPayload.MessageThreadId,
},
ctx: prepared.ctxPayload,
});

View File

@@ -642,6 +642,7 @@ export async function prepareSlackMessage(params: {
channel: "slack",
to: `user:${message.user}`,
accountId: route.accountId,
threadId: threadContext.messageThreadId,
}
: undefined,
onRecordError: (err) => {