From c1bb07bd165f636744d9d7ed9d351d96c7fe89c4 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Wed, 4 Mar 2026 05:44:07 -0800 Subject: [PATCH] fix(slack): route system events to bound agent sessions (#34045) * fix(slack): route system events via binding-aware session keys * fix(slack): pass sender to system event session resolver * fix(slack): include sender context for interaction session routing * fix(slack): include modal submitter in session routing * test(slack): cover binding-aware system event routing * test(slack): update interaction session key assertions * test(slack): assert reaction session routing carries sender * docs(changelog): note slack system event routing fix * Update CHANGELOG.md --- CHANGELOG.md | 1 + src/slack/monitor/context.ts | 24 ++++++++++ .../monitor/events/interactions.modal.ts | 3 ++ src/slack/monitor/events/interactions.test.ts | 3 ++ src/slack/monitor/events/interactions.ts | 1 + src/slack/monitor/events/reactions.test.ts | 22 +++++++++ .../monitor/events/system-event-context.ts | 1 + src/slack/monitor/monitor.test.ts | 47 +++++++++++++++++++ 8 files changed, 102 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index fb53bd78081..0fb849832b4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,7 @@ Docs: https://docs.openclaw.ai ### Fixes +- Slack/system-event session routing: resolve reaction/member/pin/interaction system-event session keys through channel/account bindings (with sender-aware DM routing) so inbound Slack events target the correct agent session in multi-account setups instead of defaulting to `agent:main`. (#34045) Thanks @paulomcg, @daht-mad and @vincentkoc. - Gateway/HTTP tools invoke media compatibility: preserve raw media payload access for direct `/tools/invoke` clients by allowing media `nodes` invoke commands only in HTTP tool context, while keeping agent-context media invoke blocking to prevent base64 prompt bloat. (#34365) Thanks @obviyus. - Agents/Nodes media outputs: add dedicated `photos_latest` action handling, block media-returning `nodes invoke` commands, keep metadata-only `camera.list` invoke allowed, and normalize empty `photos_latest` results to a consistent response shape to prevent base64 context bloat. (#34332) Thanks @obviyus. - TUI/session-key canonicalization: normalize `openclaw tui --session` values to lowercase so uppercase session names no longer drop real-time streaming updates due to gateway/TUI key mismatches. (#33866, #34013) thanks @lynnzc. diff --git a/src/slack/monitor/context.ts b/src/slack/monitor/context.ts index 84633320427..1d75af03650 100644 --- a/src/slack/monitor/context.ts +++ b/src/slack/monitor/context.ts @@ -7,6 +7,7 @@ import type { DmPolicy, GroupPolicy } from "../../config/types.js"; import { logVerbose } from "../../globals.js"; import { createDedupeCache } from "../../infra/dedupe.js"; import { getChildLogger } from "../../logging.js"; +import { resolveAgentRoute } from "../../routing/resolve-route.js"; import type { RuntimeEnv } from "../../runtime.js"; import type { SlackMessageEvent } from "../types.js"; import { normalizeAllowList, normalizeAllowListLower, normalizeSlackSlug } from "./allow-list.js"; @@ -62,6 +63,7 @@ export type SlackMonitorContext = { resolveSlackSystemEventSessionKey: (params: { channelId?: string | null; channelType?: string | null; + senderId?: string | null; }) => string; isChannelAllowed: (params: { channelId?: string; @@ -151,6 +153,7 @@ export function createSlackMonitorContext(params: { const resolveSlackSystemEventSessionKey = (p: { channelId?: string | null; channelType?: string | null; + senderId?: string | null; }) => { const channelId = p.channelId?.trim() ?? ""; if (!channelId) { @@ -165,6 +168,27 @@ export function createSlackMonitorContext(params: { ? `slack:group:${channelId}` : `slack:channel:${channelId}`; const chatType = isDirectMessage ? "direct" : isGroup ? "group" : "channel"; + const senderId = p.senderId?.trim() ?? ""; + + // Resolve through shared channel/account bindings so system events route to + // the same agent session as regular inbound messages. + try { + const peerKind = isDirectMessage ? "direct" : isGroup ? "group" : "channel"; + const peerId = isDirectMessage ? senderId : channelId; + if (peerId) { + const route = resolveAgentRoute({ + cfg: params.cfg, + channel: "slack", + accountId: params.accountId, + teamId: params.teamId, + peer: { kind: peerKind, id: peerId }, + }); + return route.sessionKey; + } + } catch { + // Fall through to legacy key derivation. + } + return resolveSessionKey( params.sessionScope, { From: from, ChatType: chatType, Provider: "slack" }, diff --git a/src/slack/monitor/events/interactions.modal.ts b/src/slack/monitor/events/interactions.modal.ts index 603b1ab79e2..99d1a3711b6 100644 --- a/src/slack/monitor/events/interactions.modal.ts +++ b/src/slack/monitor/events/interactions.modal.ts @@ -77,6 +77,7 @@ type SlackInteractionContextPrefix = "slack:interaction:view" | "slack:interacti function resolveModalSessionRouting(params: { ctx: SlackMonitorContext; metadata: ReturnType; + userId?: string; }): { sessionKey: string; channelId?: string; channelType?: string } { const metadata = params.metadata; if (metadata.sessionKey) { @@ -91,6 +92,7 @@ function resolveModalSessionRouting(params: { sessionKey: params.ctx.resolveSlackSystemEventSessionKey({ channelId: metadata.channelId, channelType: metadata.channelType, + senderId: params.userId, }), channelId: metadata.channelId, channelType: metadata.channelType, @@ -139,6 +141,7 @@ function resolveSlackModalEventBase(params: { const sessionRouting = resolveModalSessionRouting({ ctx: params.ctx, metadata, + userId, }); return { callbackId, diff --git a/src/slack/monitor/events/interactions.test.ts b/src/slack/monitor/events/interactions.test.ts index be47f6ac8a7..21fd6d173d4 100644 --- a/src/slack/monitor/events/interactions.test.ts +++ b/src/slack/monitor/events/interactions.test.ts @@ -223,6 +223,7 @@ describe("registerSlackInteractionEvents", () => { expect(resolveSessionKey).toHaveBeenCalledWith({ channelId: "C1", channelType: "channel", + senderId: "U123", }); expect(app.client.chat.update).toHaveBeenCalledTimes(1); }); @@ -554,6 +555,7 @@ describe("registerSlackInteractionEvents", () => { expect(resolveSessionKey).toHaveBeenCalledWith({ channelId: "C222", channelType: "channel", + senderId: "U111", }); expect(enqueueSystemEventMock).toHaveBeenCalledTimes(1); const [eventText] = enqueueSystemEventMock.mock.calls[0] as [string]; @@ -952,6 +954,7 @@ describe("registerSlackInteractionEvents", () => { expect(resolveSessionKey).toHaveBeenCalledWith({ channelId: "D123", channelType: "im", + senderId: "U777", }); expect(enqueueSystemEventMock).toHaveBeenCalledTimes(1); const [eventText] = enqueueSystemEventMock.mock.calls[0] as [string]; diff --git a/src/slack/monitor/events/interactions.ts b/src/slack/monitor/events/interactions.ts index 3a242652bc9..4f92df32be7 100644 --- a/src/slack/monitor/events/interactions.ts +++ b/src/slack/monitor/events/interactions.ts @@ -571,6 +571,7 @@ export function registerSlackInteractionEvents(params: { ctx: SlackMonitorContex const sessionKey = ctx.resolveSlackSystemEventSessionKey({ channelId: channelId, channelType: auth.channelType, + senderId: userId, }); // Build context key - only include defined values to avoid "unknown" noise diff --git a/src/slack/monitor/events/reactions.test.ts b/src/slack/monitor/events/reactions.test.ts index 8105b2047fc..3581d8b5380 100644 --- a/src/slack/monitor/events/reactions.test.ts +++ b/src/slack/monitor/events/reactions.test.ts @@ -153,4 +153,26 @@ describe("registerSlackReactionEvents", () => { expect(trackEvent).toHaveBeenCalledTimes(1); }); + + it("passes sender context when resolving reaction session keys", async () => { + reactionQueueMock.mockClear(); + reactionAllowMock.mockReset().mockResolvedValue([]); + const harness = createSlackSystemEventTestHarness(); + const resolveSessionKey = vi.fn().mockReturnValue("agent:ops:main"); + harness.ctx.resolveSlackSystemEventSessionKey = resolveSessionKey; + registerSlackReactionEvents({ ctx: harness.ctx }); + const handler = harness.getHandler("reaction_added"); + expect(handler).toBeTruthy(); + + await handler!({ + event: buildReactionEvent({ user: "U777", channel: "D123" }), + body: {}, + }); + + expect(resolveSessionKey).toHaveBeenCalledWith({ + channelId: "D123", + channelType: "im", + senderId: "U777", + }); + }); }); diff --git a/src/slack/monitor/events/system-event-context.ts b/src/slack/monitor/events/system-event-context.ts index 5df48dfd167..0c89ec2ce47 100644 --- a/src/slack/monitor/events/system-event-context.ts +++ b/src/slack/monitor/events/system-event-context.ts @@ -36,6 +36,7 @@ export async function authorizeAndResolveSlackSystemEventContext(params: { const sessionKey = ctx.resolveSlackSystemEventSessionKey({ channelId, channelType: auth.channelType, + senderId, }); return { channelLabel, diff --git a/src/slack/monitor/monitor.test.ts b/src/slack/monitor/monitor.test.ts index c1fac686971..d6e819ca46d 100644 --- a/src/slack/monitor/monitor.test.ts +++ b/src/slack/monitor/monitor.test.ts @@ -184,6 +184,53 @@ describe("resolveSlackSystemEventSessionKey", () => { "agent:main:slack:channel:c123", ); }); + + it("routes channel system events through account bindings", () => { + const ctx = createSlackMonitorContext({ + ...baseParams(), + accountId: "work", + cfg: { + bindings: [ + { + agentId: "ops", + match: { + channel: "slack", + accountId: "work", + }, + }, + ], + }, + }); + expect( + ctx.resolveSlackSystemEventSessionKey({ channelId: "C123", channelType: "channel" }), + ).toBe("agent:ops:slack:channel:c123"); + }); + + it("routes DM system events through direct-peer bindings when sender is known", () => { + const ctx = createSlackMonitorContext({ + ...baseParams(), + accountId: "work", + cfg: { + bindings: [ + { + agentId: "ops-dm", + match: { + channel: "slack", + accountId: "work", + peer: { kind: "direct", id: "U123" }, + }, + }, + ], + }, + }); + expect( + ctx.resolveSlackSystemEventSessionKey({ + channelId: "D123", + channelType: "im", + senderId: "U123", + }), + ).toBe("agent:ops-dm:main"); + }); }); describe("isChannelAllowed with groupPolicy and channelsConfig", () => {