From 6efd013c25d515cd175b03ad545eedfedb381814 Mon Sep 17 00:00:00 2001 From: Sarah Fortune Date: Sun, 9 Aug 2026 17:07:25 -0700 Subject: [PATCH] fix(slack): support Grid reaction and pin event listeners (#120944) * fix(slack): support Grid reaction and pin events * refactor(slack): isolate system event routing * test(slack): align Grid event scope fixture * refactor(slack): pass optional event scope directly * fix(slack): support Grid member events --------- Co-authored-by: Sarah Fortune --- docs/channels/slack.md | 43 +++-- extensions/slack/src/monitor/auth.ts | 5 +- extensions/slack/src/monitor/context.test.ts | 35 +++++ extensions/slack/src/monitor/context.ts | 79 +++------- .../src/monitor/events.enterprise.test.ts | 81 ++++++++++ extensions/slack/src/monitor/events.ts | 6 +- .../slack/src/monitor/events/members.test.ts | 85 +++++++++- .../slack/src/monitor/events/members.ts | 86 ++++++---- .../slack/src/monitor/events/messages.ts | 1 + .../slack/src/monitor/events/pins.test.ts | 88 ++++++++++- extensions/slack/src/monitor/events/pins.ts | 148 ++++++++++++------ .../src/monitor/events/reactions.test.ts | 91 ++++++++++- .../slack/src/monitor/events/reactions.ts | 124 +++++++++------ .../monitor/events/system-event-context.ts | 25 +++ .../events/system-event-test-harness.ts | 3 + .../message-handler/prepare-routing.ts | 27 ++-- .../slack/src/monitor/system-event-session.ts | 101 ++++++++++++ .../slack/src/monitor/workspace-routing.ts | 32 ++++ 18 files changed, 844 insertions(+), 216 deletions(-) create mode 100644 extensions/slack/src/monitor/events.enterprise.test.ts create mode 100644 extensions/slack/src/monitor/system-event-session.ts create mode 100644 extensions/slack/src/monitor/workspace-routing.ts diff --git a/docs/channels/slack.md b/docs/channels/slack.md index ee0d8eafdbd7..df15c3264cc9 100644 --- a/docs/channels/slack.md +++ b/docs/channels/slack.md @@ -72,8 +72,9 @@ The relay URL must use `wss://` unless it targets localhost. Treat the bearer to One Slack account can receive messages from every workspace covered by an Enterprise Grid org-wide installation. Choose direct Socket Mode or HTTP Request URLs; relay mode is not supported for enterprise accounts. Both -least-privilege manifests below enable only the V1 `message` and `app_mention` -event path, immediate replies, and listener-owned status reactions. +least-privilege manifests below enable the V1 message, mention, membership, +reaction, and pin event paths, immediate replies, and listener-owned status +reactions. #### Socket Mode @@ -101,6 +102,8 @@ event path, immediate replies, and listener-owned status reactions. "im:read", "mpim:history", "mpim:read", + "pins:read", + "reactions:read", "reactions:write", "users:read" ] @@ -115,7 +118,13 @@ event path, immediate replies, and listener-owned status reactions. "message.channels", "message.groups", "message.im", - "message.mpim" + "message.mpim", + "member_joined_channel", + "member_left_channel", + "pin_added", + "pin_removed", + "reaction_added", + "reaction_removed" ] } } @@ -179,6 +188,8 @@ Socket Mode connection. Replace the example URL with the Gateway's public "im:read", "mpim:history", "mpim:read", + "pins:read", + "reactions:read", "reactions:write", "users:read" ] @@ -193,7 +204,13 @@ Socket Mode connection. Replace the example URL with the Gateway's public "message.channels", "message.groups", "message.im", - "message.mpim" + "message.mpim", + "member_joined_channel", + "member_left_channel", + "pin_added", + "pin_removed", + "reaction_added", + "reaction_removed" ] } } @@ -240,14 +257,16 @@ bot-authored `message` and `app_mention` events before dispatch, regardless of `allowBots`, because org installs do not provide a stable workspace-qualified bot identity for loop prevention. -Enterprise support accepts direct Socket Mode or HTTP `message` and -`app_mention` events plus workspace-qualified outbound messages. Relay mode, -slash commands, interactions, App Home, reaction event listeners, pins, -Slack-native approvals, and bindings remain unavailable for an enterprise -account. Slack action tools remain unavailable except for file uploads and -adding or removing emoji reactions. Outbound acknowledgment, typing, and -status reactions are supported and require `reactions:write`; inbound reaction -notifications remain unavailable. +Enterprise support accepts direct Socket Mode or HTTP message, mention, +membership, reaction, and pin events plus workspace-qualified outbound +messages. Relay mode, slash commands, channel lifecycle events, interactions, +App Home, Agent and Assistant lifecycle events, Slack-native approvals, and +bindings remain unavailable for an enterprise account. Slack action tools +remain unavailable except for file uploads and adding or removing emoji +reactions. Inbound membership, reaction, and pin notifications use the +listener-owned, workspace-scoped Slack client. Outbound acknowledgment, typing, +and status reactions are also supported through that client and require +`reactions:write`. OpenClaw records Enterprise Grid destinations as `team::channel:` or `team::user:`. diff --git a/extensions/slack/src/monitor/auth.ts b/extensions/slack/src/monitor/auth.ts index 04bc3d832971..82aaa388fa53 100644 --- a/extensions/slack/src/monitor/auth.ts +++ b/extensions/slack/src/monitor/auth.ts @@ -490,6 +490,7 @@ export async function authorizeSlackSystemEventSender(params: { senderId?: string; channelId?: string; channelType?: string | null; + eventScope?: SlackEventScope; expectedSenderId?: string; /** When true, requires expectedSenderId, rejects ambiguous channel types, * and applies interactive-only owner allowFrom checks without changing the @@ -518,7 +519,7 @@ export async function authorizeSlackSystemEventSender(params: { const info: { name?: string; type?: "im" | "mpim" | "channel" | "group"; - } = await params.ctx.resolveChannelName(channelId).catch(() => ({})); + } = await params.ctx.resolveChannelName(channelId, params.eventScope).catch(() => ({})); channelName = info.name; const resolvedTypeSource = params.channelType ?? info.type; channelType = normalizeSlackChannelType(resolvedTypeSource, channelId); @@ -564,7 +565,7 @@ export async function authorizeSlackSystemEventSender(params: { } const senderInfo: { name?: string } = await params.ctx - .resolveUserName(senderId) + .resolveUserName(senderId, params.eventScope) .catch(() => ({})); const senderName = senderInfo.name; const ingressChannelType = channelType ?? "channel"; diff --git a/extensions/slack/src/monitor/context.test.ts b/extensions/slack/src/monitor/context.test.ts index 5f43d85d5a8a..47fb6e4e118d 100644 --- a/extensions/slack/src/monitor/context.test.ts +++ b/extensions/slack/src/monitor/context.test.ts @@ -58,6 +58,13 @@ function createTestContext(params?: { }); } +function createEnterpriseEventScope(teamId: string): SlackEventScope { + return { + teamId, + client: {} as SlackEventScope["client"], + }; +} + beforeEach(() => setSlackRuntime(null as never)); afterEach(() => setSlackRuntime(null as never)); @@ -143,6 +150,34 @@ describe("createSlackMonitorContext resolveSlackSystemEventSessionKey", () => { }), ).toBe("agent:main:slack:group:c0mpdm42"); }); + + it("partitions enterprise channel system events by workspace", () => { + const ctx = createTestContext(); + const resolveForTeam = (teamId: string) => + ctx.resolveSlackSystemEventSessionKey({ + channelId: "C_SHARED", + channelType: "channel", + senderId: "U_ACTOR", + eventScope: createEnterpriseEventScope(teamId), + }); + + expect(resolveForTeam("T111")).toBe("agent:main:slack:channel:team:t111:channel:c_shared"); + expect(resolveForTeam("T222")).toBe("agent:main:slack:channel:team:t222:channel:c_shared"); + }); + + it("partitions enterprise main DM system events by workspace", () => { + const ctx = createTestContext({ dmScope: "main" }); + const resolveForTeam = (teamId: string) => + ctx.resolveSlackSystemEventSessionKey({ + channelId: "D_SHARED", + channelType: "im", + senderId: "U_SHARED", + eventScope: createEnterpriseEventScope(teamId), + }); + + expect(resolveForTeam("T111")).toBe("agent:main:main:account:default:team:t111"); + expect(resolveForTeam("T222")).toBe("agent:main:main:account:default:team:t222"); + }); }); describe("createSlackMonitorContext channel metadata cache", () => { diff --git a/extensions/slack/src/monitor/context.ts b/extensions/slack/src/monitor/context.ts index 16bc5cc11e22..7916b12d8faa 100644 --- a/extensions/slack/src/monitor/context.ts +++ b/extensions/slack/src/monitor/context.ts @@ -9,10 +9,8 @@ import type { } from "openclaw/plugin-sdk/config-contracts"; import type { SessionScope } from "openclaw/plugin-sdk/config-contracts"; import type { DmPolicy, GroupPolicy } from "openclaw/plugin-sdk/config-contracts"; -import { resolveRuntimeConversationBindingRoute } from "openclaw/plugin-sdk/conversation-runtime"; import { createDedupeCache } from "openclaw/plugin-sdk/dedupe-runtime"; import type { HistoryEntry } from "openclaw/plugin-sdk/reply-history"; -import { resolveAgentRoute } from "openclaw/plugin-sdk/routing"; import { resolveThreadSessionKeys } from "openclaw/plugin-sdk/routing"; import { logVerbose } from "openclaw/plugin-sdk/runtime-env"; import { getChildLogger } from "openclaw/plugin-sdk/runtime-env"; @@ -37,6 +35,8 @@ import { type SlackSuggestedPromptsInput, updateSlackSuggestedPrompts, } from "./suggested-prompts.js"; +import { resolveSlackSystemEventRouteSessionKey } from "./system-event-session.js"; +import { qualifySlackRoutePeerId } from "./workspace-routing.js"; export { normalizeSlackChannelType, resolveSlackChatType } from "./channel-type.js"; export { DEFAULT_SLACK_SUGGESTED_PROMPTS } from "./suggested-prompts.js"; @@ -162,6 +162,7 @@ export type SlackMonitorContext = { channelType?: string | null; senderId?: string | null; threadTs?: string | null; + eventScope?: SlackEventScope; }) => string; isChannelAllowed: (params: { channelId?: string; @@ -381,13 +382,14 @@ export function createSlackMonitorContext(params: { channelType?: string | null; senderId?: string | null; threadTs?: string | null; + eventScope?: SlackEventScope; }) => { const channelId = normalizeOptionalString(p.channelId) ?? ""; const senderId = normalizeOptionalString(p.senderId) ?? ""; // System events can omit channel_type too; prefer a type already seen on events // for this channel over C-prefix inference so they key the same session (#102676). const channelType = normalizeSlackChannelType( - p.channelType ?? recallSlackChannelType(channelId), + p.channelType ?? recallSlackChannelType(channelId, p.eventScope), channelId, ); const isDirectMessage = channelType === "im"; @@ -401,60 +403,31 @@ export function createSlackMonitorContext(params: { ? `slack:group:${channelId}` : `slack:channel:${channelId}`; const chatType = isDirectMessage ? "direct" : isGroup ? "group" : "channel"; - // 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 }, - }); - const threadTs = normalizeOptionalString(p.threadTs); - const baseConversationId = isDirectMessage ? `user:${senderId}` : channelId; - const threadBindingRoute = threadTs - ? resolveRuntimeConversationBindingRoute({ - route, - conversation: { - channel: "slack", - accountId: params.accountId, - conversationId: threadTs, - parentConversationId: baseConversationId, - }, - }) - : null; - const runtimeRoute = - threadBindingRoute?.boundSessionKey || threadBindingRoute?.bindingRecord - ? threadBindingRoute - : resolveRuntimeConversationBindingRoute({ - route, - conversation: { - channel: "slack", - accountId: params.accountId, - conversationId: baseConversationId, - }, - }); - if (runtimeRoute.boundSessionKey) { - return runtimeRoute.route.sessionKey; - } - return resolveThreadSessionKeys({ - baseSessionKey: runtimeRoute.route.sessionKey, - threadId: threadTs, - parentSessionKey: - threadTs && params.threadInheritParent ? runtimeRoute.route.sessionKey : undefined, - }).sessionKey; - } - } catch { - // Fall through to legacy key derivation. + const routedSessionKey = resolveSlackSystemEventRouteSessionKey({ + cfg: params.cfg, + accountId: params.accountId, + teamId: params.teamId, + threadInheritParent: params.threadInheritParent, + channelId, + channelType, + senderId, + threadTs: p.threadTs, + eventScope: p.eventScope, + }); + if (routedSessionKey) { + return routedSessionKey; } + const fallbackFrom = p.eventScope + ? `slack:${qualifySlackRoutePeerId({ + id: isDirectMessage ? senderId : channelId, + kind: isDirectMessage ? "user" : "channel", + eventScope: p.eventScope, + })}` + : from; const legacySessionKey = resolveSessionKey( params.sessionScope, - { From: from, ChatType: chatType, Provider: "slack" }, + { From: fallbackFrom, ChatType: chatType, Provider: "slack" }, params.mainKey, resolveDefaultAgentId(params.cfg), ); diff --git a/extensions/slack/src/monitor/events.enterprise.test.ts b/extensions/slack/src/monitor/events.enterprise.test.ts new file mode 100644 index 000000000000..1e3692c5e640 --- /dev/null +++ b/extensions/slack/src/monitor/events.enterprise.test.ts @@ -0,0 +1,81 @@ +// Slack tests cover Enterprise Grid event registration boundaries. +import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; +import type { ResolvedSlackAccount } from "../accounts.js"; +import type { SlackMonitorContext } from "./context.js"; +import type { SlackMessageHandler } from "./message-handler.js"; + +const registrations = vi.hoisted(() => ({ + agent: vi.fn(), + assistant: vi.fn(), + channel: vi.fn(), + home: vi.fn(), + interaction: vi.fn(), + member: vi.fn(), + message: vi.fn(), + pin: vi.fn(), + reaction: vi.fn(), +})); + +vi.mock("./events/agent.js", () => ({ registerSlackAgentEvents: registrations.agent })); +vi.mock("./events/assistant.js", () => ({ + registerSlackAssistantEvents: registrations.assistant, +})); +vi.mock("./events/channels.js", () => ({ registerSlackChannelEvents: registrations.channel })); +vi.mock("./events/home.js", () => ({ registerSlackHomeEvents: registrations.home })); +vi.mock("./events/interactions.js", () => ({ + registerSlackInteractionEvents: registrations.interaction, +})); +vi.mock("./events/members.js", () => ({ registerSlackMemberEvents: registrations.member })); +vi.mock("./events/messages.js", () => ({ registerSlackMessageEvents: registrations.message })); +vi.mock("./events/pins.js", () => ({ registerSlackPinEvents: registrations.pin })); +vi.mock("./events/reactions.js", () => ({ + registerSlackReactionEvents: registrations.reaction, +})); + +let registerSlackMonitorEvents: typeof import("./events.js").registerSlackMonitorEvents; + +function registerForInstallation(kind: "enterprise" | "workspace") { + const installationIdentity = + kind === "enterprise" + ? ({ kind, enterpriseId: "E_TEST" } as const) + : ({ kind, teamId: "T_TEST" } as const); + registerSlackMonitorEvents({ + ctx: { installationIdentity } as SlackMonitorContext, + account: {} as ResolvedSlackAccount, + handleSlackMessage: vi.fn() as SlackMessageHandler, + }); +} + +describe("registerSlackMonitorEvents", () => { + beforeAll(async () => { + ({ registerSlackMonitorEvents } = await import("./events.js")); + }); + + beforeEach(() => { + for (const registration of Object.values(registrations)) { + registration.mockClear(); + } + }); + + it("registers messages, reactions, pins, and member events for enterprise installs", () => { + registerForInstallation("enterprise"); + + expect(registrations.message).toHaveBeenCalledOnce(); + expect(registrations.reaction).toHaveBeenCalledOnce(); + expect(registrations.pin).toHaveBeenCalledOnce(); + expect(registrations.member).toHaveBeenCalledOnce(); + expect(registrations.channel).not.toHaveBeenCalled(); + expect(registrations.home).not.toHaveBeenCalled(); + expect(registrations.agent).not.toHaveBeenCalled(); + expect(registrations.interaction).not.toHaveBeenCalled(); + expect(registrations.assistant).not.toHaveBeenCalled(); + }); + + it("preserves the full event set for workspace installs", () => { + registerForInstallation("workspace"); + + for (const registration of Object.values(registrations)) { + expect(registration).toHaveBeenCalledOnce(); + } + }); +}); diff --git a/extensions/slack/src/monitor/events.ts b/extensions/slack/src/monitor/events.ts index f2e8d1fa3cc1..26edcc2660ae 100644 --- a/extensions/slack/src/monitor/events.ts +++ b/extensions/slack/src/monitor/events.ts @@ -24,13 +24,13 @@ export function registerSlackMonitorEvents(params: { ctx: params.ctx, handleSlackMessage: params.handleSlackMessage, }); + registerSlackReactionEvents({ ctx: params.ctx, trackEvent: params.trackEvent }); + registerSlackPinEvents({ ctx: params.ctx, trackEvent: params.trackEvent }); + registerSlackMemberEvents({ ctx: params.ctx, trackEvent: params.trackEvent }); if (params.ctx.installationIdentity.kind === "enterprise") { return; } - registerSlackReactionEvents({ ctx: params.ctx, trackEvent: params.trackEvent }); - registerSlackMemberEvents({ ctx: params.ctx, trackEvent: params.trackEvent }); registerSlackChannelEvents({ ctx: params.ctx, trackEvent: params.trackEvent }); - registerSlackPinEvents({ ctx: params.ctx, trackEvent: params.trackEvent }); registerSlackHomeEvents({ ctx: params.ctx, slashCommandName: params.appHomeSlashCommandName, diff --git a/extensions/slack/src/monitor/events/members.test.ts b/extensions/slack/src/monitor/events/members.test.ts index b0839bfca9d8..e42aa6906529 100644 --- a/extensions/slack/src/monitor/events/members.test.ts +++ b/extensions/slack/src/monitor/events/members.test.ts @@ -1,4 +1,5 @@ // Slack tests cover members plugin behavior. +import type { AllMiddlewareArgs } from "@slack/bolt"; import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; const memberMocks = vi.hoisted(() => ({ @@ -11,11 +12,13 @@ type MemberOverrides = import("./system-event-test-harness.js").SlackSystemEvent vi.mock("openclaw/plugin-sdk/system-event-runtime", () => ({ enqueueSystemEvent: (...args: unknown[]) => memberMocks.enqueue(...args), })); -type MemberHandler = (args: { event: Record; body: unknown }) => Promise; +type MemberHandler = import("./system-event-test-harness.js").SlackSystemEventHandler; type MemberCaseArgs = { event?: Record; body?: unknown; + context?: AllMiddlewareArgs["context"]; + client?: AllMiddlewareArgs["client"]; overrides?: MemberOverrides; handler?: "joined" | "left"; trackEvent?: () => void; @@ -62,6 +65,8 @@ async function runMemberCase(args: MemberCaseArgs = {}): Promise { await handler({ event: (args.event ?? makeMemberEvent()) as Record, body: args.body ?? { event_id: "Ev-member-default" }, + context: args.context, + client: args.client, }); } @@ -150,4 +155,82 @@ describe("registerSlackMemberEvents", () => { }), ); }); + + it("keeps enterprise member events isolated by listener workspace", async () => { + const harness = initSlackHarness(); + harness.ctx.installationIdentity = { + kind: "enterprise", + apiAppId: "A_GRID", + enterpriseId: "E_GRID", + }; + const resolveChannelName = vi.fn(harness.ctx.resolveChannelName); + const resolveUserName = vi.fn(harness.ctx.resolveUserName); + const resolveSessionKey = vi.fn( + (input: Parameters[0]) => + `session:${input.eventScope?.teamId ?? "workspace"}`, + ); + harness.ctx.resolveChannelName = resolveChannelName; + harness.ctx.resolveUserName = resolveUserName; + harness.ctx.resolveSlackSystemEventSessionKey = resolveSessionKey; + registerSlackMemberEvents({ ctx: harness.ctx }); + const handler = harness.getHandler("member_joined_channel"); + if (!handler) { + throw new Error("expected Slack member joined handler"); + } + + for (const teamId of ["T111", "T222"]) { + await handler({ + event: makeMemberEvent(), + body: { api_app_id: "A_GRID", event_id: `Ev-member-${teamId}` }, + context: { + isEnterpriseInstall: true, + enterpriseId: "E_GRID", + teamId, + } as AllMiddlewareArgs["context"], + client: { token: `listener-${teamId}` } as AllMiddlewareArgs["client"], + }); + } + + expect(memberMocks.enqueue).toHaveBeenNthCalledWith(1, expect.any(String), { + sessionKey: "session:T111", + contextKey: "slack:member:T111:joined:D1:U1:Ev-member-T111", + }); + expect(memberMocks.enqueue).toHaveBeenNthCalledWith(2, expect.any(String), { + sessionKey: "session:T222", + contextKey: "slack:member:T222:joined:D1:U1:Ev-member-T222", + }); + expect(resolveChannelName).toHaveBeenCalledWith( + "D1", + expect.objectContaining({ teamId: "T111" }), + ); + expect(resolveUserName).toHaveBeenCalledWith("U1", expect.objectContaining({ teamId: "T222" })); + }); + + it("rejects enterprise member events without validated listener scope", async () => { + const trackEvent = vi.fn(); + const harness = initSlackHarness(); + harness.ctx.installationIdentity = { + kind: "enterprise", + apiAppId: "A_GRID", + enterpriseId: "E_GRID", + }; + registerSlackMemberEvents({ ctx: harness.ctx, trackEvent }); + const handler = harness.getHandler("member_joined_channel"); + if (!handler) { + throw new Error("expected Slack member joined handler"); + } + + await handler({ + event: makeMemberEvent(), + body: { api_app_id: "A_GRID" }, + context: { + isEnterpriseInstall: true, + enterpriseId: "E_GRID", + } as AllMiddlewareArgs["context"], + client: { token: "listener" } as AllMiddlewareArgs["client"], + }); + + expect(trackEvent).not.toHaveBeenCalled(); + expect(memberMocks.enqueue).not.toHaveBeenCalled(); + }); }); diff --git a/extensions/slack/src/monitor/events/members.ts b/extensions/slack/src/monitor/events/members.ts index 6a78ce8156ac..3670c6b5b35f 100644 --- a/extensions/slack/src/monitor/events/members.ts +++ b/extensions/slack/src/monitor/events/members.ts @@ -1,9 +1,14 @@ // Slack plugin module implements members behavior. -import type { SlackEventMiddlewareArgs } from "@slack/bolt"; +import type { AllMiddlewareArgs, SlackEventMiddlewareArgs } from "@slack/bolt"; +import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime"; +import { danger } from "openclaw/plugin-sdk/runtime-env"; import { enqueueSystemEvent } from "openclaw/plugin-sdk/system-event-runtime"; import type { SlackMonitorContext } from "../context.js"; import type { SlackMemberChannelEvent } from "../types.js"; -import { authorizeAndResolveSlackSystemEventContext } from "./system-event-context.js"; +import { + authorizeAndResolveSlackSystemEventContext, + resolveSlackListenerEventScope, +} from "./system-event-context.js"; export function registerSlackMemberEvents(params: { ctx: SlackMonitorContext; @@ -16,53 +21,80 @@ export function registerSlackMemberEvents(params: { event: SlackMemberChannelEvent; body: unknown; eventId: string; + context: AllMiddlewareArgs["context"]; + client: AllMiddlewareArgs["client"]; }) => { - if (ctx.shouldDropMismatchedSlackEvent(paramsLocal.body)) { - return; + try { + const eventScope = resolveSlackListenerEventScope({ + ctx, + body: paramsLocal.body, + context: paramsLocal.context, + client: paramsLocal.client, + }); + if (eventScope === null) { + return; + } + if (ctx.shouldDropMismatchedSlackEvent(paramsLocal.body)) { + return; + } + trackEvent?.(); + const payload = paramsLocal.event; + const channelId = payload.channel; + const channelInfo = channelId ? await ctx.resolveChannelName(channelId, eventScope) : {}; + const channelType = payload.channel_type ?? channelInfo?.type; + const ingressContext = await authorizeAndResolveSlackSystemEventContext({ + ctx, + senderId: payload.user, + channelId, + channelType, + eventKind: `member-${paramsLocal.verb}`, + eventScope, + }); + if (!ingressContext) { + return; + } + const userInfo = payload.user ? await ctx.resolveUserName(payload.user, eventScope) : {}; + const userLabel = userInfo?.name ?? payload.user ?? "someone"; + enqueueSystemEvent( + `Slack: ${userLabel} ${paramsLocal.verb} ${ingressContext.channelLabel}.`, + { + sessionKey: ingressContext.sessionKey, + contextKey: `slack:member:${eventScope ? `${eventScope.teamId}:` : ""}${paramsLocal.verb}:${channelId ?? "unknown"}:${payload.user ?? "unknown"}:${paramsLocal.eventId}`, + }, + ); + } catch (err) { + ctx.runtime.error?.( + danger(`slack ${paramsLocal.verb} handler failed: ${formatErrorMessage(err)}`), + ); } - trackEvent?.(); - const payload = paramsLocal.event; - const channelId = payload.channel; - const channelInfo = channelId ? await ctx.resolveChannelName(channelId) : {}; - const channelType = payload.channel_type ?? channelInfo?.type; - const ingressContext = await authorizeAndResolveSlackSystemEventContext({ - ctx, - senderId: payload.user, - channelId, - channelType, - eventKind: `member-${paramsLocal.verb}`, - }); - if (!ingressContext) { - return; - } - const userInfo = payload.user ? await ctx.resolveUserName(payload.user) : {}; - const userLabel = userInfo?.name ?? payload.user ?? "someone"; - enqueueSystemEvent(`Slack: ${userLabel} ${paramsLocal.verb} ${ingressContext.channelLabel}.`, { - sessionKey: ingressContext.sessionKey, - contextKey: `slack:member:${paramsLocal.verb}:${channelId ?? "unknown"}:${payload.user ?? "unknown"}:${paramsLocal.eventId}`, - }); }; ctx.app.event( "member_joined_channel", - async ({ event, body }: SlackEventMiddlewareArgs<"member_joined_channel">) => { + async (args: SlackEventMiddlewareArgs<"member_joined_channel"> & AllMiddlewareArgs) => { + const { event, body, context, client } = args; await handleMemberChannelEvent({ verb: "joined", event: event as SlackMemberChannelEvent, body, eventId: body.event_id, + context, + client, }); }, ); ctx.app.event( "member_left_channel", - async ({ event, body }: SlackEventMiddlewareArgs<"member_left_channel">) => { + async (args: SlackEventMiddlewareArgs<"member_left_channel"> & AllMiddlewareArgs) => { + const { event, body, context, client } = args; await handleMemberChannelEvent({ verb: "left", event: event as SlackMemberChannelEvent, body, eventId: body.event_id, + context, + client, }); }, ); diff --git a/extensions/slack/src/monitor/events/messages.ts b/extensions/slack/src/monitor/events/messages.ts index 35fe82f278df..6bec5cd4db8d 100644 --- a/extensions/slack/src/monitor/events/messages.ts +++ b/extensions/slack/src/monitor/events/messages.ts @@ -300,6 +300,7 @@ export function registerSlackMessageEvents(params: { channelId, channelType: subtypeHandler.resolveChannelType(message), eventKind: subtypeHandler.eventKind, + ...(eventScope ? { eventScope } : {}), }); if (!ingressContext) { return; diff --git a/extensions/slack/src/monitor/events/pins.test.ts b/extensions/slack/src/monitor/events/pins.test.ts index 53bfea104b88..79a928f3dff1 100644 --- a/extensions/slack/src/monitor/events/pins.test.ts +++ b/extensions/slack/src/monitor/events/pins.test.ts @@ -1,4 +1,5 @@ // Slack tests cover pins plugin behavior. +import type { AllMiddlewareArgs } from "@slack/bolt"; import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; const pinEnqueueMock = vi.hoisted(() => vi.fn()); @@ -9,7 +10,7 @@ type PinOverrides = import("./system-event-test-harness.js").SlackSystemEventTes vi.mock("openclaw/plugin-sdk/system-event-runtime", () => ({ enqueueSystemEvent: (...args: unknown[]) => pinEnqueueMock(...args), })); -type PinHandler = (args: { event: Record; body: unknown }) => Promise; +type PinHandler = import("./system-event-test-harness.js").SlackSystemEventHandler; type PinCase = { body?: unknown; @@ -33,6 +34,18 @@ function makePinEvent(overrides?: { channel?: string; user?: string }) { }; } +function buildEnterpriseListenerArgs(teamId: string) { + return { + body: { api_app_id: "A_GRID" }, + context: { + isEnterpriseInstall: true, + enterpriseId: "E_GRID", + teamId, + } as AllMiddlewareArgs["context"], + client: { token: `listener-${teamId}` } as AllMiddlewareArgs["client"], + }; +} + function installPinHandlers(args: { overrides?: PinOverrides; trackEvent?: () => void; @@ -151,4 +164,77 @@ describe("registerSlackPinEvents", () => { contextKey: "slack:pin:added:D1:123.456:Ev-pin-2", }); }); + + it("keeps enterprise pin events isolated by listener workspace", async () => { + const harness = buildPinHarness(); + harness.ctx.installationIdentity = { + kind: "enterprise", + apiAppId: "A_GRID", + enterpriseId: "E_GRID", + }; + const resolveChannelName = vi.fn(harness.ctx.resolveChannelName); + const resolveUserName = vi.fn(harness.ctx.resolveUserName); + const resolveSessionKey = vi.fn( + (input: Parameters[0]) => + `session:${input.eventScope?.teamId ?? "workspace"}`, + ); + harness.ctx.resolveChannelName = resolveChannelName; + harness.ctx.resolveUserName = resolveUserName; + harness.ctx.resolveSlackSystemEventSessionKey = resolveSessionKey; + registerSlackPinEvents({ ctx: harness.ctx }); + const handler = harness.getHandler("pin_added") as PinHandler | null; + if (!handler) { + throw new Error("expected Slack pin added handler"); + } + + for (const teamId of ["T111", "T222"]) { + await handler({ + event: makePinEvent(), + ...buildEnterpriseListenerArgs(teamId), + body: { api_app_id: "A_GRID", event_id: `Ev-pin-${teamId}` }, + }); + } + + expect(pinEnqueueMock).toHaveBeenNthCalledWith(1, expect.any(String), { + sessionKey: "session:T111", + contextKey: "slack:pin:T111:added:D1:123.456:Ev-pin-T111", + }); + expect(pinEnqueueMock).toHaveBeenNthCalledWith(2, expect.any(String), { + sessionKey: "session:T222", + contextKey: "slack:pin:T222:added:D1:123.456:Ev-pin-T222", + }); + expect(resolveChannelName).toHaveBeenCalledWith( + "D1", + expect.objectContaining({ teamId: "T111" }), + ); + expect(resolveUserName).toHaveBeenCalledWith("U1", expect.objectContaining({ teamId: "T222" })); + }); + + it("rejects enterprise pin events without validated listener scope", async () => { + const trackEvent = vi.fn(); + const harness = buildPinHarness(); + harness.ctx.installationIdentity = { + kind: "enterprise", + apiAppId: "A_GRID", + enterpriseId: "E_GRID", + }; + registerSlackPinEvents({ ctx: harness.ctx, trackEvent }); + const handler = harness.getHandler("pin_added") as PinHandler | null; + if (!handler) { + throw new Error("expected Slack pin added handler"); + } + + await handler({ + event: makePinEvent(), + body: { api_app_id: "A_GRID" }, + context: { + isEnterpriseInstall: true, + enterpriseId: "E_GRID", + } as AllMiddlewareArgs["context"], + client: { token: "listener" } as AllMiddlewareArgs["client"], + }); + + expect(trackEvent).not.toHaveBeenCalled(); + expect(pinEnqueueMock).not.toHaveBeenCalled(); + }); }); diff --git a/extensions/slack/src/monitor/events/pins.ts b/extensions/slack/src/monitor/events/pins.ts index 47f865713d73..471ac595aeac 100644 --- a/extensions/slack/src/monitor/events/pins.ts +++ b/extensions/slack/src/monitor/events/pins.ts @@ -1,48 +1,80 @@ // Slack plugin module implements pins behavior. -import type { SlackEventMiddlewareArgs } from "@slack/bolt"; +import type { AllMiddlewareArgs, SlackEventMiddlewareArgs } from "@slack/bolt"; +import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime"; +import { danger } from "openclaw/plugin-sdk/runtime-env"; import { enqueueSystemEvent } from "openclaw/plugin-sdk/system-event-runtime"; import type { SlackMonitorContext } from "../context.js"; import type { SlackPinEvent } from "../types.js"; -import { authorizeAndResolveSlackSystemEventContext } from "./system-event-context.js"; +import { + authorizeAndResolveSlackSystemEventContext, + resolveSlackListenerEventScope, +} from "./system-event-context.js"; async function handleSlackPinEvent(params: { ctx: SlackMonitorContext; trackEvent?: () => void; body: unknown; + context: AllMiddlewareArgs["context"] | undefined; + client: AllMiddlewareArgs["client"] | undefined; event: unknown; eventId: string; action: "pinned" | "unpinned"; contextKeySuffix: "added" | "removed"; + errorLabel: string; }): Promise { - const { ctx, trackEvent, body, event, eventId, action, contextKeySuffix } = params; - - if (ctx.shouldDropMismatchedSlackEvent(body)) { - return; - } - trackEvent?.(); - - const payload = event as SlackPinEvent; - const channelId = payload.channel_id; - const ingressContext = await authorizeAndResolveSlackSystemEventContext({ + const { ctx, - senderId: payload.user, - channelId, - eventKind: "pin", - }); - if (!ingressContext) { - return; + trackEvent, + body, + context, + client, + event, + eventId, + action, + contextKeySuffix, + errorLabel, + } = params; + + try { + const eventScope = resolveSlackListenerEventScope({ ctx, body, context, client }); + if (eventScope === null) { + return; + } + if (ctx.shouldDropMismatchedSlackEvent(body)) { + return; + } + trackEvent?.(); + + const payload = event as SlackPinEvent; + const channelId = payload.channel_id; + const ingressContext = await authorizeAndResolveSlackSystemEventContext({ + ctx, + senderId: payload.user, + channelId, + eventKind: "pin", + eventScope, + }); + if (!ingressContext) { + return; + } + const userInfo = payload.user + ? await (eventScope + ? ctx.resolveUserName(payload.user, eventScope) + : ctx.resolveUserName(payload.user)) + : {}; + const userLabel = userInfo?.name ?? payload.user ?? "someone"; + const itemType = payload.item?.type ?? "item"; + const messageId = payload.item?.message?.ts ?? payload.event_ts; + enqueueSystemEvent( + `Slack: ${userLabel} ${action} a ${itemType} in ${ingressContext.channelLabel}.`, + { + sessionKey: ingressContext.sessionKey, + contextKey: `slack:pin:${eventScope ? `${eventScope.teamId}:` : ""}${contextKeySuffix}:${channelId ?? "unknown"}:${messageId ?? "unknown"}:${eventId}`, + }, + ); + } catch (err) { + ctx.runtime.error?.(danger(`slack ${errorLabel} handler failed: ${formatErrorMessage(err)}`)); } - const userInfo = payload.user ? await ctx.resolveUserName(payload.user) : {}; - const userLabel = userInfo?.name ?? payload.user ?? "someone"; - const itemType = payload.item?.type ?? "item"; - const messageId = payload.item?.message?.ts ?? payload.event_ts; - enqueueSystemEvent( - `Slack: ${userLabel} ${action} a ${itemType} in ${ingressContext.channelLabel}.`, - { - sessionKey: ingressContext.sessionKey, - contextKey: `slack:pin:${contextKeySuffix}:${channelId ?? "unknown"}:${messageId ?? "unknown"}:${eventId}`, - }, - ); } export function registerSlackPinEvents(params: { @@ -51,27 +83,41 @@ export function registerSlackPinEvents(params: { }) { const { ctx, trackEvent } = params; - ctx.app.event("pin_added", async ({ event, body }: SlackEventMiddlewareArgs<"pin_added">) => { - await handleSlackPinEvent({ - ctx, - trackEvent, - body, - event, - eventId: body.event_id, - action: "pinned", - contextKeySuffix: "added", - }); - }); + ctx.app.event( + "pin_added", + async (args: SlackEventMiddlewareArgs<"pin_added"> & AllMiddlewareArgs) => { + const { event, body, context, client } = args; + await handleSlackPinEvent({ + ctx, + trackEvent, + body, + context, + client, + event, + eventId: body.event_id, + action: "pinned", + contextKeySuffix: "added", + errorLabel: "pin added", + }); + }, + ); - ctx.app.event("pin_removed", async ({ event, body }: SlackEventMiddlewareArgs<"pin_removed">) => { - await handleSlackPinEvent({ - ctx, - trackEvent, - body, - event, - eventId: body.event_id, - action: "unpinned", - contextKeySuffix: "removed", - }); - }); + ctx.app.event( + "pin_removed", + async (args: SlackEventMiddlewareArgs<"pin_removed"> & AllMiddlewareArgs) => { + const { event, body, context, client } = args; + await handleSlackPinEvent({ + ctx, + trackEvent, + body, + context, + client, + event, + eventId: body.event_id, + action: "unpinned", + contextKeySuffix: "removed", + errorLabel: "pin removed", + }); + }, + ); } diff --git a/extensions/slack/src/monitor/events/reactions.test.ts b/extensions/slack/src/monitor/events/reactions.test.ts index fb2d0b91d0cb..37a9231b43ba 100644 --- a/extensions/slack/src/monitor/events/reactions.test.ts +++ b/extensions/slack/src/monitor/events/reactions.test.ts @@ -1,4 +1,5 @@ // Slack tests cover reactions plugin behavior. +import type { AllMiddlewareArgs } from "@slack/bolt"; import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; const reactionQueueMock = vi.hoisted(() => vi.fn()); @@ -10,7 +11,7 @@ type SlackSystemEventTestOverrides = vi.mock("openclaw/plugin-sdk/system-event-runtime", () => ({ enqueueSystemEvent: (...args: unknown[]) => reactionQueueMock(...args), })); -type ReactionHandler = (args: { event: Record; body: unknown }) => Promise; +type ReactionHandler = import("./system-event-test-harness.js").SlackSystemEventHandler; type ReactionRunInput = { handler?: "added" | "removed"; @@ -35,6 +36,18 @@ function buildReactionEvent(overrides?: { user?: string; channel?: string }) { }; } +function buildEnterpriseListenerArgs(teamId: string) { + return { + body: { api_app_id: "A_GRID", event_id: `Ev-reaction-${teamId}` }, + context: { + isEnterpriseInstall: true, + enterpriseId: "E_GRID", + teamId, + } as AllMiddlewareArgs["context"], + client: { token: `listener-${teamId}` } as AllMiddlewareArgs["client"], + }; +} + function createReactionHandlers(params: { overrides?: SlackSystemEventTestOverrides; trackEvent?: () => void; @@ -302,4 +315,80 @@ describe("registerSlackReactionEvents", () => { senderId: "U777", }); }); + + it("keeps enterprise reaction events isolated by listener workspace", async () => { + const harness = createSlackSystemEventTestHarness(); + harness.ctx.installationIdentity = { + kind: "enterprise", + apiAppId: "A_GRID", + enterpriseId: "E_GRID", + }; + const resolveChannelName = vi.fn(harness.ctx.resolveChannelName); + const resolveUserName = vi.fn(harness.ctx.resolveUserName); + const resolveSessionKey = vi.fn( + (input: Parameters[0]) => + `session:${input.eventScope?.teamId ?? "workspace"}`, + ); + harness.ctx.resolveChannelName = resolveChannelName; + harness.ctx.resolveUserName = resolveUserName; + harness.ctx.resolveSlackSystemEventSessionKey = resolveSessionKey; + registerSlackReactionEvents({ ctx: harness.ctx }); + const handler = requireReactionHandler( + harness.getHandler("reaction_added") as ReactionHandler | null, + "added", + ); + + for (const teamId of ["T111", "T222"]) { + await handler({ + event: buildReactionEvent(), + ...buildEnterpriseListenerArgs(teamId), + }); + } + + expect(reactionQueueMock).toHaveBeenNthCalledWith(1, expect.any(String), { + sessionKey: "session:T111", + contextKey: "slack:reaction:T111:added:D1:123.456:U1:thumbsup:Ev-reaction-T111", + }); + expect(reactionQueueMock).toHaveBeenNthCalledWith(2, expect.any(String), { + sessionKey: "session:T222", + contextKey: "slack:reaction:T222:added:D1:123.456:U1:thumbsup:Ev-reaction-T222", + }); + expect(resolveChannelName).toHaveBeenCalledWith( + "D1", + expect.objectContaining({ teamId: "T111" }), + ); + expect(resolveChannelName).toHaveBeenCalledWith( + "D1", + expect.objectContaining({ teamId: "T222" }), + ); + expect(resolveUserName).toHaveBeenCalledWith("U1", expect.objectContaining({ teamId: "T111" })); + }); + + it("rejects enterprise reaction events without validated listener scope", async () => { + const trackEvent = vi.fn(); + const harness = createSlackSystemEventTestHarness(); + harness.ctx.installationIdentity = { + kind: "enterprise", + apiAppId: "A_GRID", + enterpriseId: "E_GRID", + }; + registerSlackReactionEvents({ ctx: harness.ctx, trackEvent }); + const handler = requireReactionHandler( + harness.getHandler("reaction_added") as ReactionHandler | null, + "added", + ); + + await handler({ + event: buildReactionEvent(), + body: { api_app_id: "A_GRID" }, + context: { + isEnterpriseInstall: true, + enterpriseId: "E_GRID", + } as AllMiddlewareArgs["context"], + client: { token: "listener" } as AllMiddlewareArgs["client"], + }); + + expect(trackEvent).not.toHaveBeenCalled(); + expect(reactionQueueMock).not.toHaveBeenCalled(); + }); }); diff --git a/extensions/slack/src/monitor/events/reactions.ts b/extensions/slack/src/monitor/events/reactions.ts index a226eebce53e..f2efc8a78a37 100644 --- a/extensions/slack/src/monitor/events/reactions.ts +++ b/extensions/slack/src/monitor/events/reactions.ts @@ -1,10 +1,16 @@ // Slack plugin module implements reactions behavior. -import type { SlackEventMiddlewareArgs } from "@slack/bolt"; +import type { AllMiddlewareArgs, SlackEventMiddlewareArgs } from "@slack/bolt"; +import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime"; +import { danger } from "openclaw/plugin-sdk/runtime-env"; import { enqueueSystemEvent } from "openclaw/plugin-sdk/system-event-runtime"; import { allowListMatches, normalizeAllowListLower } from "../allow-list.js"; import type { SlackMonitorContext } from "../context.js"; +import type { SlackEventScope } from "../event-scope.js"; import type { SlackReactionEvent } from "../types.js"; -import { authorizeAndResolveSlackSystemEventContext } from "./system-event-context.js"; +import { + authorizeAndResolveSlackSystemEventContext, + resolveSlackListenerEventScope, +} from "./system-event-context.js"; function shouldEmitSlackReactionNotification(params: { ctx: SlackMonitorContext; @@ -38,78 +44,96 @@ export function registerSlackReactionEvents(params: { trackEvent?: () => void; }) { const { ctx, trackEvent } = params; + const resolveUserName = (userId: string, eventScope?: SlackEventScope) => + eventScope ? ctx.resolveUserName(userId, eventScope) : ctx.resolveUserName(userId); const handleReactionEvent = async ( event: SlackReactionEvent, action: "added" | "removed", + eventScope: SlackEventScope | undefined, eventId: string, ) => { - const item = event.item; - if (!item || item.type !== "message") { - return; - } - if (ctx.reactionMode === "off") { - return; - } - if (ctx.reactionMode === "own" && (!ctx.botUserId || event.item_user !== ctx.botUserId)) { - return; - } - trackEvent?.(); + try { + const item = event.item; + if (!item || item.type !== "message") { + return; + } + if (ctx.reactionMode === "off") { + return; + } + if (ctx.reactionMode === "own" && (!ctx.botUserId || event.item_user !== ctx.botUserId)) { + return; + } + trackEvent?.(); - const ingressContext = await authorizeAndResolveSlackSystemEventContext({ - ctx, - senderId: event.user, - channelId: item.channel, - eventKind: "reaction", - }); - if (!ingressContext) { - return; - } - - const actorInfoPromise: Promise<{ name?: string } | undefined> = event.user - ? ctx.resolveUserName(event.user) - : Promise.resolve(undefined); - const authorInfoPromise: Promise<{ name?: string } | undefined> = event.item_user - ? ctx.resolveUserName(event.item_user) - : Promise.resolve(undefined); - const [actorInfo, authorInfo] = await Promise.all([actorInfoPromise, authorInfoPromise]); - if ( - !shouldEmitSlackReactionNotification({ + const ingressContext = await authorizeAndResolveSlackSystemEventContext({ ctx, - event, - actorName: actorInfo?.name, - }) - ) { - return; + senderId: event.user, + channelId: item.channel, + eventKind: "reaction", + eventScope, + }); + if (!ingressContext) { + return; + } + + const actorInfoPromise: Promise<{ name?: string } | undefined> = event.user + ? resolveUserName(event.user, eventScope) + : Promise.resolve(undefined); + const authorInfoPromise: Promise<{ name?: string } | undefined> = event.item_user + ? resolveUserName(event.item_user, eventScope) + : Promise.resolve(undefined); + const [actorInfo, authorInfo] = await Promise.all([actorInfoPromise, authorInfoPromise]); + if ( + !shouldEmitSlackReactionNotification({ + ctx, + event, + actorName: actorInfo?.name, + }) + ) { + return; + } + const actorLabel = actorInfo?.name ?? event.user; + const emojiLabel = event.reaction ?? "emoji"; + const authorLabel = authorInfo?.name ?? event.item_user; + const baseText = `Slack reaction ${action}: :${emojiLabel}: by ${actorLabel} in ${ingressContext.channelLabel} msg ${item.ts}`; + const text = authorLabel ? `${baseText} from ${authorLabel}` : baseText; + enqueueSystemEvent(text, { + sessionKey: ingressContext.sessionKey, + contextKey: `slack:reaction:${eventScope ? `${eventScope.teamId}:` : ""}${action}:${item.channel}:${item.ts}:${event.user}:${emojiLabel}:${eventId}`, + }); + } catch (err) { + ctx.runtime.error?.(danger(`slack reaction handler failed: ${formatErrorMessage(err)}`)); } - const actorLabel = actorInfo?.name ?? event.user; - const emojiLabel = event.reaction ?? "emoji"; - const authorLabel = authorInfo?.name ?? event.item_user; - const baseText = `Slack reaction ${action}: :${emojiLabel}: by ${actorLabel} in ${ingressContext.channelLabel} msg ${item.ts}`; - const text = authorLabel ? `${baseText} from ${authorLabel}` : baseText; - enqueueSystemEvent(text, { - sessionKey: ingressContext.sessionKey, - contextKey: `slack:reaction:${action}:${item.channel}:${item.ts}:${event.user}:${emojiLabel}:${eventId}`, - }); }; ctx.app.event( "reaction_added", - async ({ event, body }: SlackEventMiddlewareArgs<"reaction_added">) => { + async (args: SlackEventMiddlewareArgs<"reaction_added"> & AllMiddlewareArgs) => { + const { event, body, context, client } = args; + const eventScope = resolveSlackListenerEventScope({ ctx, body, context, client }); + if (eventScope === null) { + return; + } if (ctx.shouldDropMismatchedSlackEvent(body)) { return; } - await handleReactionEvent(event as SlackReactionEvent, "added", body.event_id); + await handleReactionEvent(event as SlackReactionEvent, "added", eventScope, body.event_id); }, ); ctx.app.event( "reaction_removed", - async ({ event, body }: SlackEventMiddlewareArgs<"reaction_removed">) => { + async (args: SlackEventMiddlewareArgs<"reaction_removed"> & AllMiddlewareArgs) => { + const { event, body, context, client } = args; + const eventScope = resolveSlackListenerEventScope({ ctx, body, context, client }); + if (eventScope === null) { + return; + } if (ctx.shouldDropMismatchedSlackEvent(body)) { return; } - await handleReactionEvent(event as SlackReactionEvent, "removed", body.event_id); + await handleReactionEvent(event as SlackReactionEvent, "removed", eventScope, body.event_id); }, ); } diff --git a/extensions/slack/src/monitor/events/system-event-context.ts b/extensions/slack/src/monitor/events/system-event-context.ts index 75994a01a09f..5b5754e4a0dc 100644 --- a/extensions/slack/src/monitor/events/system-event-context.ts +++ b/extensions/slack/src/monitor/events/system-event-context.ts @@ -1,8 +1,10 @@ // Slack plugin module implements system event context behavior. +import type { AllMiddlewareArgs } from "@slack/bolt"; import { logVerbose } from "openclaw/plugin-sdk/runtime-env"; import { authorizeSlackSystemEventSender } from "../auth.js"; import { resolveSlackChannelLabel } from "../channel-config.js"; import type { SlackMonitorContext } from "../context.js"; +import { resolveSlackEventScope, type SlackEventScope } from "../event-scope.js"; type SlackAuthorizedSystemEventContext = { channelLabel: string; @@ -15,6 +17,7 @@ export async function authorizeAndResolveSlackSystemEventContext(params: { channelId?: string; channelType?: string | null; eventKind: string; + eventScope?: SlackEventScope; }): Promise { const { ctx, senderId, channelId, channelType, eventKind } = params; const auth = await authorizeSlackSystemEventSender({ @@ -22,6 +25,7 @@ export async function authorizeAndResolveSlackSystemEventContext(params: { senderId, channelId, channelType, + eventScope: params.eventScope, }); if (!auth.allowed) { logVerbose( @@ -38,9 +42,30 @@ export async function authorizeAndResolveSlackSystemEventContext(params: { channelId, channelType: auth.channelType, senderId, + ...(params.eventScope ? { eventScope: params.eventScope } : {}), }); return { channelLabel, sessionKey, }; } + +export function resolveSlackListenerEventScope(params: { + ctx: SlackMonitorContext; + body: unknown; + context: AllMiddlewareArgs["context"] | undefined; + client: AllMiddlewareArgs["client"] | undefined; +}): SlackEventScope | null | undefined { + const resolved = resolveSlackEventScope({ + identity: params.ctx.installationIdentity, + body: params.body, + context: params.context, + client: params.client, + clientOptions: params.ctx.app.webClientOptions, + }); + if (!resolved.ok) { + logVerbose(`slack: drop listener event (${resolved.reason})`); + return null; + } + return resolved.scope; +} diff --git a/extensions/slack/src/monitor/events/system-event-test-harness.ts b/extensions/slack/src/monitor/events/system-event-test-harness.ts index 8c0271177102..6b6959e18ec0 100644 --- a/extensions/slack/src/monitor/events/system-event-test-harness.ts +++ b/extensions/slack/src/monitor/events/system-event-test-harness.ts @@ -1,9 +1,12 @@ // Slack plugin module implements system event test harness behavior. +import type { AllMiddlewareArgs } from "@slack/bolt"; import type { SlackMonitorContext } from "../context.js"; export type SlackSystemEventHandler = (args: { event: Record; body: unknown; + context?: AllMiddlewareArgs["context"]; + client?: AllMiddlewareArgs["client"]; }) => Promise; export type SlackSystemEventTestOverrides = { diff --git a/extensions/slack/src/monitor/message-handler/prepare-routing.ts b/extensions/slack/src/monitor/message-handler/prepare-routing.ts index cf4129417086..3b439a4745e0 100644 --- a/extensions/slack/src/monitor/message-handler/prepare-routing.ts +++ b/extensions/slack/src/monitor/message-handler/prepare-routing.ts @@ -15,6 +15,11 @@ import { resolveSlackThreadContext } from "../../threading.js"; import type { SlackMessageEvent } from "../../types.js"; import type { SlackChannelConfigResolved } from "../channel-config.js"; import type { SlackEventScope } from "../event-scope.js"; +import { + qualifySlackConversationId, + qualifySlackRoutePeerId, + resolveSlackEnterpriseMainDmSessionKey, +} from "../workspace-routing.js"; type SlackRoutingContextDeps = { cfg: OpenClawConfig; @@ -130,18 +135,7 @@ function resolveSlackBaseConversationId(params: { const raw = params.isDirectMessage ? `user:${params.message.user ?? "unknown"}` : params.message.channel; - return params.eventScope ? `team:${encodeURIComponent(params.eventScope.teamId)}:${raw}` : raw; -} - -function qualifySlackPeerId(params: { - id: string; - kind: "user" | "channel"; - eventScope?: SlackEventScope; -}): string { - if (!params.eventScope) { - return params.id; - } - return `team:${encodeURIComponent(params.eventScope.teamId)}:${params.kind}:${encodeURIComponent(params.id)}`; + return qualifySlackConversationId(raw, params.eventScope); } function resolveSlackInitialAgentRoute(params: { @@ -159,7 +153,7 @@ function resolveSlackInitialAgentRoute(params: { teamId: params.eventScope?.teamId || params.ctx.teamId || undefined, peer: { kind: params.isDirectMessage ? "direct" : params.isRoom ? "channel" : "group", - id: qualifySlackPeerId({ + id: qualifySlackRoutePeerId({ id: params.isDirectMessage ? (params.message.user ?? "unknown") : params.message.channel, kind: params.isDirectMessage ? "user" : "channel", eventScope: params.eventScope, @@ -169,8 +163,11 @@ function resolveSlackInitialAgentRoute(params: { if (!params.eventScope || !params.isDirectMessage || route.dmScope !== "main") { return route; } - const partition = `account:${encodeURIComponent(params.account.accountId).toLowerCase()}:team:${encodeURIComponent(params.eventScope.teamId).toLowerCase()}`; - const sessionKey = `${route.sessionKey}:${partition}`; + const sessionKey = resolveSlackEnterpriseMainDmSessionKey({ + baseSessionKey: route.sessionKey, + accountId: params.account.accountId, + eventScope: params.eventScope, + }); return { ...route, sessionKey, mainSessionKey: sessionKey }; } diff --git a/extensions/slack/src/monitor/system-event-session.ts b/extensions/slack/src/monitor/system-event-session.ts new file mode 100644 index 000000000000..c652ed684eb9 --- /dev/null +++ b/extensions/slack/src/monitor/system-event-session.ts @@ -0,0 +1,101 @@ +// Slack plugin module resolves system events to the same sessions as message events. +import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts"; +import { resolveRuntimeConversationBindingRoute } from "openclaw/plugin-sdk/conversation-runtime"; +import { resolveAgentRoute, resolveThreadSessionKeys } from "openclaw/plugin-sdk/routing"; +import { normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime"; +import type { SlackMessageEvent } from "../types.js"; +import type { SlackEventScope } from "./event-scope.js"; +import { + qualifySlackConversationId, + qualifySlackRoutePeerId, + resolveSlackEnterpriseMainDmSessionKey, +} from "./workspace-routing.js"; + +export function resolveSlackSystemEventRouteSessionKey(params: { + cfg: OpenClawConfig; + accountId: string; + teamId: string; + threadInheritParent: boolean; + channelId: string; + channelType: SlackMessageEvent["channel_type"]; + senderId: string; + threadTs?: string | null; + eventScope?: SlackEventScope; +}): string | undefined { + const isDirectMessage = params.channelType === "im"; + const peerId = isDirectMessage ? params.senderId : params.channelId; + if (!peerId) { + return undefined; + } + + try { + const peerKind = isDirectMessage + ? "direct" + : params.channelType === "mpim" + ? "group" + : "channel"; + let route = resolveAgentRoute({ + cfg: params.cfg, + channel: "slack", + accountId: params.accountId, + teamId: params.eventScope?.teamId ?? params.teamId, + peer: { + kind: peerKind, + id: qualifySlackRoutePeerId({ + id: peerId, + kind: isDirectMessage ? "user" : "channel", + eventScope: params.eventScope, + }), + }, + }); + if (params.eventScope && isDirectMessage && route.dmScope === "main") { + const sessionKey = resolveSlackEnterpriseMainDmSessionKey({ + baseSessionKey: route.sessionKey, + accountId: params.accountId, + eventScope: params.eventScope, + }); + route = { ...route, sessionKey, mainSessionKey: sessionKey }; + } + + const threadTs = normalizeOptionalString(params.threadTs); + const baseConversationId = qualifySlackConversationId( + isDirectMessage ? `user:${params.senderId}` : params.channelId, + params.eventScope, + ); + const threadBindingRoute = + !params.eventScope && threadTs + ? resolveRuntimeConversationBindingRoute({ + route, + conversation: { + channel: "slack", + accountId: params.accountId, + conversationId: threadTs, + parentConversationId: baseConversationId, + }, + }) + : null; + const runtimeRoute = params.eventScope + ? { route, bindingRecord: null, boundSessionKey: undefined } + : threadBindingRoute?.boundSessionKey || threadBindingRoute?.bindingRecord + ? threadBindingRoute + : resolveRuntimeConversationBindingRoute({ + route, + conversation: { + channel: "slack", + accountId: params.accountId, + conversationId: baseConversationId, + }, + }); + if (runtimeRoute.boundSessionKey) { + return runtimeRoute.route.sessionKey; + } + return resolveThreadSessionKeys({ + baseSessionKey: runtimeRoute.route.sessionKey, + threadId: threadTs, + parentSessionKey: + threadTs && params.threadInheritParent ? runtimeRoute.route.sessionKey : undefined, + }).sessionKey; + } catch { + return undefined; + } +} diff --git a/extensions/slack/src/monitor/workspace-routing.ts b/extensions/slack/src/monitor/workspace-routing.ts new file mode 100644 index 000000000000..9a2b207082af --- /dev/null +++ b/extensions/slack/src/monitor/workspace-routing.ts @@ -0,0 +1,32 @@ +// Slack plugin module keeps Enterprise Grid routing identities workspace-qualified. +import type { SlackEventScope } from "./event-scope.js"; + +export function resolveSlackEnterpriseMainDmSessionKey(params: { + baseSessionKey: string; + accountId: string; + eventScope: SlackEventScope; +}): string { + const accountId = encodeURIComponent(params.accountId).toLowerCase(); + const teamId = encodeURIComponent(params.eventScope.teamId).toLowerCase(); + return `${params.baseSessionKey}:account:${accountId}:team:${teamId}`; +} + +export function qualifySlackRoutePeerId(params: { + id: string; + kind: "user" | "channel"; + eventScope?: SlackEventScope; +}): string { + if (!params.eventScope) { + return params.id; + } + return `team:${encodeURIComponent(params.eventScope.teamId)}:${params.kind}:${encodeURIComponent(params.id)}`; +} + +export function qualifySlackConversationId( + conversationId: string, + eventScope?: SlackEventScope, +): string { + return eventScope + ? `team:${encodeURIComponent(eventScope.teamId)}:${conversationId}` + : conversationId; +}