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
This commit is contained in:
Vincent Koc 2026-03-04 05:44:07 -08:00 committed by GitHub
parent 7b5e64ef2e
commit c1bb07bd16
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 102 additions and 0 deletions

View File

@ -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.

View File

@ -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" },

View File

@ -77,6 +77,7 @@ type SlackInteractionContextPrefix = "slack:interaction:view" | "slack:interacti
function resolveModalSessionRouting(params: {
ctx: SlackMonitorContext;
metadata: ReturnType<typeof parseSlackModalPrivateMetadata>;
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,

View File

@ -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];

View File

@ -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

View File

@ -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",
});
});
});

View File

@ -36,6 +36,7 @@ export async function authorizeAndResolveSlackSystemEventContext(params: {
const sessionKey = ctx.resolveSlackSystemEventSessionKey({
channelId,
channelType: auth.channelType,
senderId,
});
return {
channelLabel,

View File

@ -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", () => {