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 <sarah.fortune@gmail.com>
This commit is contained in:
Sarah Fortune
2026-08-09 17:07:25 -07:00
committed by GitHub
parent af708e6628
commit 6efd013c25
18 changed files with 844 additions and 216 deletions
+31 -12
View File
@@ -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:<team-id>:channel:<channel-id>` or `team:<team-id>:user:<user-id>`.
+3 -2
View File
@@ -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";
@@ -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", () => {
+26 -53
View File
@@ -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),
);
@@ -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();
}
});
});
+3 -3
View File
@@ -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,
@@ -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<string, unknown>; body: unknown }) => Promise<void>;
type MemberHandler = import("./system-event-test-harness.js").SlackSystemEventHandler;
type MemberCaseArgs = {
event?: Record<string, unknown>;
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<void> {
await handler({
event: (args.event ?? makeMemberEvent()) as Record<string, unknown>,
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<typeof harness.ctx.resolveSlackSystemEventSessionKey>[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();
});
});
+59 -27
View File
@@ -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,
});
},
);
@@ -300,6 +300,7 @@ export function registerSlackMessageEvents(params: {
channelId,
channelType: subtypeHandler.resolveChannelType(message),
eventKind: subtypeHandler.eventKind,
...(eventScope ? { eventScope } : {}),
});
if (!ingressContext) {
return;
@@ -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<string, unknown>; body: unknown }) => Promise<void>;
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<typeof harness.ctx.resolveSlackSystemEventSessionKey>[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();
});
});
+97 -51
View File
@@ -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<void> {
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",
});
},
);
}
@@ -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<string, unknown>; body: unknown }) => Promise<void>;
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<typeof harness.ctx.resolveSlackSystemEventSessionKey>[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();
});
});
@@ -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);
},
);
}
@@ -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<SlackAuthorizedSystemEventContext | undefined> {
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;
}
@@ -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<string, unknown>;
body: unknown;
context?: AllMiddlewareArgs["context"];
client?: AllMiddlewareArgs["client"];
}) => Promise<void>;
export type SlackSystemEventTestOverrides = {
@@ -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 };
}
@@ -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;
}
}
@@ -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;
}