fix(slack): keep top-level dms on stable session

This commit is contained in:
Peter Steinberger
2026-05-02 04:26:30 +01:00
parent 9c307a3a50
commit d964488a23
4 changed files with 45 additions and 10 deletions

View File

@@ -35,6 +35,7 @@ Docs: https://docs.openclaw.ai
- Providers/configure: preserve the existing default model when adding or reauthing a provider whose plugin returns a default-model config patch. Fixes #50268. Thanks @rixcorp-oc.
- Slack/message actions: send media before the follow-up Block Kit message when Slack `send` includes a file plus presentation or interactive controls, so file attachments are no longer rejected. Fixes #51458. Thanks @HirokiKobayashi-R.
- Slack/DMs: honor `dmHistoryLimit` for fresh 1:1 Slack DM sessions by backfilling recent conversation history before the current reply. Fixes #64427. Thanks @brantley-creator.
- Slack/DMs: keep top-level direct messages on the stable DM session even when `replyToMode` targets Slack thread replies, preserving context across DM turns. Fixes #58832. Thanks @daye-jjeong.
- Slack/mentions: resolve `<!subteam^...>` user-group mentions through Slack `usergroups.users.list` and treat them as explicit mentions only when the bot user is a member, so mention-gated agent channels wake for real user-group mentions without config-only allowlists. Fixes #73827. Thanks @CG-Intelligence-Agent-Jack.
- Slack/message tool: let `read` fetch an exact Slack message timestamp, including a specific thread reply when paired with `threadId`, instead of returning only the parent thread or recent channel history. Fixes #53943. Thanks @zomars.
- Web search: point missing-key errors to `web_fetch` for known URLs and the browser tool for interactive pages. Thanks @zhaoyang97.

View File

@@ -92,9 +92,9 @@ export function resolveSlackRoutingContext(params: {
const threadContext = resolveSlackThreadContext({ message, replyToMode });
const threadTs = threadContext.incomingThreadTs;
const isThreadReply = threadContext.isThreadReply;
// Keep true thread replies thread-scoped, but preserve channel-level sessions
// for top-level room turns when replyToMode is off.
// For DMs, preserve existing auto-thread behavior when replyToMode="all".
// Keep true thread replies thread-scoped, while top-level DMs keep their
// stable direct-message session even when reply delivery targets a Slack UI
// thread.
const autoThreadId =
!isThreadReply && replyToMode === "all" && threadContext.messageTs
? threadContext.messageTs
@@ -115,7 +115,15 @@ export function resolveSlackRoutingContext(params: {
? seedCandidateThreadId
: undefined;
const roomThreadId = isThreadReply && threadTs ? threadTs : undefined;
const canonicalThreadId = isRoomish ? roomThreadId : isThreadReply ? threadTs : autoThreadId;
const canonicalThreadId = isDirectMessage
? isThreadReply
? threadTs
: undefined
: isRoomish
? roomThreadId
: isThreadReply
? threadTs
: autoThreadId;
const routedThreadId = canonicalThreadId ?? (isRoomish ? seededRoomThreadId : undefined);
const baseConversationId = resolveSlackBaseConversationId({ message, isDirectMessage });
const boundThreadRoute = routedThreadId

View File

@@ -1073,11 +1073,11 @@ describe("slack prepareSlackMessage inbound contract", () => {
expect(prepared!.ctxPayload.Body).not.toContain("parent_user_id");
});
it("creates thread session for top-level DM when replyToMode=all", async () => {
it("keeps top-level DM session stable when replyToMode=all", async () => {
const { storePath } = storeFixture.makeTmpStorePath();
const slackCtx = createInboundSlackCtx({
cfg: {
session: { store: storePath },
session: { store: storePath, dmScope: "per-channel-peer" },
channels: { slack: { enabled: true, replyToMode: "all" } },
} as OpenClawConfig,
replyToMode: "all",
@@ -1092,9 +1092,7 @@ describe("slack prepareSlackMessage inbound contract", () => {
);
expect(prepared).toBeTruthy();
// Session key should include :thread:500.000 for the auto-threaded message
expect(prepared!.ctxPayload.SessionKey).toContain(":thread:500.000");
// MessageThreadId should be set for the reply
expect(prepared!.ctxPayload.SessionKey).toBe("agent:main:slack:direct:u1");
expect(prepared!.ctxPayload.MessageThreadId).toBe("500.000");
});

View File

@@ -4,10 +4,14 @@ import type { ResolvedSlackAccount } from "../../accounts.js";
import type { SlackMessageEvent } from "../../types.js";
import { resolveSlackRoutingContext, type SlackRoutingContextDeps } from "./prepare-routing.js";
function buildCtx(overrides?: { replyToMode?: "all" | "first" | "off" | "batched" }) {
function buildCtx(overrides?: {
replyToMode?: "all" | "first" | "off" | "batched";
dmScope?: "main" | "per-sender" | "per-channel-peer";
}) {
const replyToMode = overrides?.replyToMode ?? "all";
return {
cfg: {
session: { dmScope: overrides?.dmScope },
channels: {
slack: { enabled: true, replyToMode },
},
@@ -321,4 +325,28 @@ describe("thread-level session keys", () => {
const sessionKey = routing.sessionKey;
expect(sessionKey).not.toContain(":thread:");
});
it("keeps top-level DMs on the direct session when replyToMode=all", () => {
const ctx = buildCtx({ replyToMode: "all", dmScope: "per-channel-peer" });
const account = buildAccount("all");
const routing = resolveSlackRoutingContext({
ctx,
account,
message: {
channel: "D456",
channel_type: "im",
user: "U3",
text: "dm message",
ts: "1770408530.000000",
} as SlackMessageEvent,
isDirectMessage: true,
isGroupDm: false,
isRoom: false,
isRoomish: false,
});
expect(routing.sessionKey).toBe("agent:main:slack:direct:u3");
expect(routing.threadContext.messageThreadId).toBe("1770408530.000000");
});
});