mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-12 21:53:00 -06:00
fix(slack): seed thread routing for implicit-conversation channels (#78522)
When a Slack channel has `requireMention: false` and a non-`off` reply mode, every top-level bot reply creates a Slack thread (because `replyToMode` does). Without seeding the inbound root, the root turn landed on the channel session while later thread replies landed on a fresh `🧵<root_ts>` session, breaking conversational continuity. Extend `seedTopLevelRoomThreadBySource` to also fire for those channels, mirroring how `app_mention` / `explicitlyMentioned` roots already get seeded. The thread session key is now consistent on both sides of the turn, so follow-up thread messages route back to the originating session. Fixes #78505
This commit is contained in:
@@ -1388,6 +1388,80 @@ describe("slack prepareSlackMessage inbound contract", () => {
|
||||
expect(new Set([root!.ctxPayload.SessionKey, followUp!.ctxPayload.SessionKey]).size).toBe(1);
|
||||
});
|
||||
|
||||
it("keeps an implicit-conversation root and its Slack thread follow-up on one parent session in `requireMention: false` channels (#78505)", async () => {
|
||||
const { storePath } = storeFixture.makeTmpStorePath();
|
||||
const rootTs = "1778073105.769279";
|
||||
const expectedSessionKey = `agent:main:slack:channel:c0agg76cp1s:thread:${rootTs}`;
|
||||
const replies = vi.fn().mockResolvedValue({
|
||||
messages: [
|
||||
{
|
||||
text: "What day is it?",
|
||||
user: "U_TRAJCHE",
|
||||
ts: rootTs,
|
||||
},
|
||||
],
|
||||
response_metadata: { next_cursor: "" },
|
||||
});
|
||||
const slackCtx = createInboundSlackCtx({
|
||||
cfg: {
|
||||
session: { store: storePath },
|
||||
channels: {
|
||||
slack: {
|
||||
enabled: true,
|
||||
replyToMode: "first",
|
||||
groupPolicy: "open",
|
||||
channels: { C0AGG76CP1S: { enabled: true, requireMention: false } },
|
||||
},
|
||||
},
|
||||
} as OpenClawConfig,
|
||||
appClient: { conversations: { replies } } as unknown as App["client"],
|
||||
defaultRequireMention: true,
|
||||
replyToMode: "first",
|
||||
channelsConfig: { C0AGG76CP1S: { enabled: true, requireMention: false } },
|
||||
});
|
||||
slackCtx.resolveChannelName = async () => ({ name: "genai", type: "channel" });
|
||||
slackCtx.resolveUserName = async () => ({ name: "Trajche" });
|
||||
|
||||
const root = await prepareSlackMessage({
|
||||
ctx: slackCtx,
|
||||
account: createSlackAccount({ replyToMode: "first" }),
|
||||
message: {
|
||||
type: "message",
|
||||
channel: "C0AGG76CP1S",
|
||||
channel_type: "channel",
|
||||
user: "U_TRAJCHE",
|
||||
text: "What day is it?",
|
||||
ts: rootTs,
|
||||
} as SlackMessageEvent,
|
||||
opts: { source: "message" },
|
||||
});
|
||||
recordSlackThreadParticipation("default", "C0AGG76CP1S", rootTs);
|
||||
|
||||
const followUp = await prepareSlackMessage({
|
||||
ctx: slackCtx,
|
||||
account: createSlackAccount({ replyToMode: "first" }),
|
||||
message: {
|
||||
type: "message",
|
||||
channel: "C0AGG76CP1S",
|
||||
channel_type: "channel",
|
||||
user: "U_TRAJCHE",
|
||||
text: "and the time?",
|
||||
ts: "1778073128.229409",
|
||||
thread_ts: rootTs,
|
||||
} as SlackMessageEvent,
|
||||
opts: { source: "message" },
|
||||
});
|
||||
|
||||
expect(root).toBeTruthy();
|
||||
expect(followUp).toBeTruthy();
|
||||
// Without the seeding fix, root would land on `agent:main:slack:channel:c0agg76cp1s`
|
||||
// while followUp would land on `:thread:<rootTs>`, splitting the conversation
|
||||
// across two sessions. Both must share one session key.
|
||||
expect(root!.ctxPayload.SessionKey).toBe(expectedSessionKey);
|
||||
expect(followUp!.ctxPayload.SessionKey).toBe(expectedSessionKey);
|
||||
expect(new Set([root!.ctxPayload.SessionKey, followUp!.ctxPayload.SessionKey]).size).toBe(1);
|
||||
});
|
||||
|
||||
it("treats Slack user-group mentions as explicit mentions when the bot is a member", async () => {
|
||||
const usergroupsUsersList = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
|
||||
@@ -30,6 +30,7 @@ import {
|
||||
normalizeLowercaseStringOrEmpty,
|
||||
normalizeOptionalString,
|
||||
} from "openclaw/plugin-sdk/text-runtime";
|
||||
import { resolveSlackReplyToMode } from "../../account-reply-mode.js";
|
||||
import type { ResolvedSlackAccount } from "../../accounts.js";
|
||||
import { reactSlackMessage } from "../../actions.js";
|
||||
import { formatSlackFileReference } from "../../file-reference.js";
|
||||
@@ -303,8 +304,26 @@ export async function prepareSlackMessage(params: {
|
||||
log: logVerbose,
|
||||
})))),
|
||||
);
|
||||
// Channels with `requireMention: false` and a non-`off` reply mode produce
|
||||
// a Slack-side thread on every top-level bot reply (because `replyToMode`
|
||||
// creates one). Seed thread routing for the root turn too, so the inbound
|
||||
// root and its later thread replies share one parent session — same way
|
||||
// app_mention / explicitly mentioned roots already do. Without this gate,
|
||||
// the root lands on the channel session while later thread replies land on
|
||||
// a fresh `:thread:<root_ts>` session, breaking continuity.
|
||||
const channelRequireMention = channelConfig?.requireMention ?? ctx.defaultRequireMention ?? true;
|
||||
const channelChatType: "direct" | "group" | "channel" = isDirectMessage
|
||||
? "direct"
|
||||
: isGroupDm
|
||||
? "group"
|
||||
: "channel";
|
||||
const willImplicitlyThreadReply =
|
||||
isRoom && !channelRequireMention && resolveSlackReplyToMode(account, channelChatType) !== "off";
|
||||
const seedTopLevelRoomThreadBySource =
|
||||
opts.source === "app_mention" || opts.wasMentioned === true || explicitlyMentioned;
|
||||
opts.source === "app_mention" ||
|
||||
opts.wasMentioned === true ||
|
||||
explicitlyMentioned ||
|
||||
willImplicitlyThreadReply;
|
||||
let routing = resolveSlackRoutingContext({
|
||||
ctx,
|
||||
account,
|
||||
|
||||
Reference in New Issue
Block a user