mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-26 20:35:39 -06:00
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:
+31
-12
@@ -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>`.
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user