mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-09 16:14:31 +00:00
refactor(slack): share system-event ingress and test harness
This commit is contained in:
@@ -1,6 +1,9 @@
|
|||||||
import { describe, expect, it, vi } from "vitest";
|
import { describe, expect, it, vi } from "vitest";
|
||||||
import type { SlackMonitorContext } from "../context.js";
|
|
||||||
import { registerSlackPinEvents } from "./pins.js";
|
import { registerSlackPinEvents } from "./pins.js";
|
||||||
|
import {
|
||||||
|
createSlackSystemEventTestHarness,
|
||||||
|
type SlackSystemEventTestOverrides,
|
||||||
|
} from "./system-event-test-harness.js";
|
||||||
|
|
||||||
const enqueueSystemEventMock = vi.fn();
|
const enqueueSystemEventMock = vi.fn();
|
||||||
const readAllowFromStoreMock = vi.fn();
|
const readAllowFromStoreMock = vi.fn();
|
||||||
@@ -15,55 +18,12 @@ vi.mock("../../../pairing/pairing-store.js", () => ({
|
|||||||
|
|
||||||
type SlackPinHandler = (args: { event: Record<string, unknown>; body: unknown }) => Promise<void>;
|
type SlackPinHandler = (args: { event: Record<string, unknown>; body: unknown }) => Promise<void>;
|
||||||
|
|
||||||
function createPinContext(overrides?: {
|
function createPinContext(overrides?: SlackSystemEventTestOverrides) {
|
||||||
dmPolicy?: "open" | "pairing" | "allowlist" | "disabled";
|
const harness = createSlackSystemEventTestHarness(overrides);
|
||||||
allowFrom?: string[];
|
registerSlackPinEvents({ ctx: harness.ctx });
|
||||||
channelType?: "im" | "channel";
|
|
||||||
channelUsers?: string[];
|
|
||||||
}) {
|
|
||||||
let addedHandler: SlackPinHandler | null = null;
|
|
||||||
let removedHandler: SlackPinHandler | null = null;
|
|
||||||
const channelType = overrides?.channelType ?? "im";
|
|
||||||
const app = {
|
|
||||||
event: vi.fn((name: string, handler: SlackPinHandler) => {
|
|
||||||
if (name === "pin_added") {
|
|
||||||
addedHandler = handler;
|
|
||||||
} else if (name === "pin_removed") {
|
|
||||||
removedHandler = handler;
|
|
||||||
}
|
|
||||||
}),
|
|
||||||
};
|
|
||||||
const ctx = {
|
|
||||||
app,
|
|
||||||
runtime: { error: vi.fn() },
|
|
||||||
dmEnabled: true,
|
|
||||||
dmPolicy: overrides?.dmPolicy ?? "open",
|
|
||||||
defaultRequireMention: true,
|
|
||||||
channelsConfig: overrides?.channelUsers
|
|
||||||
? {
|
|
||||||
C1: {
|
|
||||||
users: overrides.channelUsers,
|
|
||||||
allow: true,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
: undefined,
|
|
||||||
groupPolicy: "open",
|
|
||||||
allowFrom: overrides?.allowFrom ?? [],
|
|
||||||
allowNameMatching: false,
|
|
||||||
shouldDropMismatchedSlackEvent: vi.fn().mockReturnValue(false),
|
|
||||||
isChannelAllowed: vi.fn().mockReturnValue(true),
|
|
||||||
resolveChannelName: vi.fn().mockResolvedValue({
|
|
||||||
name: channelType === "im" ? "direct" : "general",
|
|
||||||
type: channelType,
|
|
||||||
}),
|
|
||||||
resolveUserName: vi.fn().mockResolvedValue({ name: "alice" }),
|
|
||||||
resolveSlackSystemEventSessionKey: vi.fn().mockReturnValue("agent:main:main"),
|
|
||||||
} as unknown as SlackMonitorContext;
|
|
||||||
registerSlackPinEvents({ ctx });
|
|
||||||
return {
|
return {
|
||||||
ctx,
|
getAddedHandler: () => harness.getHandler("pin_added") as SlackPinHandler | null,
|
||||||
getAddedHandler: () => addedHandler,
|
getRemovedHandler: () => harness.getHandler("pin_removed") as SlackPinHandler | null,
|
||||||
getRemovedHandler: () => removedHandler,
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,10 +1,9 @@
|
|||||||
import type { SlackEventMiddlewareArgs } from "@slack/bolt";
|
import type { SlackEventMiddlewareArgs } from "@slack/bolt";
|
||||||
import { danger, logVerbose } from "../../../globals.js";
|
import { danger } from "../../../globals.js";
|
||||||
import { enqueueSystemEvent } from "../../../infra/system-events.js";
|
import { enqueueSystemEvent } from "../../../infra/system-events.js";
|
||||||
import { authorizeSlackSystemEventSender } from "../auth.js";
|
|
||||||
import { resolveSlackChannelLabel } from "../channel-config.js";
|
|
||||||
import type { SlackMonitorContext } from "../context.js";
|
import type { SlackMonitorContext } from "../context.js";
|
||||||
import type { SlackPinEvent } from "../types.js";
|
import type { SlackPinEvent } from "../types.js";
|
||||||
|
import { authorizeAndResolveSlackSystemEventContext } from "./system-event-context.js";
|
||||||
|
|
||||||
async function handleSlackPinEvent(params: {
|
async function handleSlackPinEvent(params: {
|
||||||
ctx: SlackMonitorContext;
|
ctx: SlackMonitorContext;
|
||||||
@@ -23,33 +22,26 @@ async function handleSlackPinEvent(params: {
|
|||||||
|
|
||||||
const payload = event as SlackPinEvent;
|
const payload = event as SlackPinEvent;
|
||||||
const channelId = payload.channel_id;
|
const channelId = payload.channel_id;
|
||||||
const auth = await authorizeSlackSystemEventSender({
|
const ingressContext = await authorizeAndResolveSlackSystemEventContext({
|
||||||
ctx,
|
ctx,
|
||||||
senderId: payload.user,
|
senderId: payload.user,
|
||||||
channelId,
|
channelId,
|
||||||
|
eventKind: "pin",
|
||||||
});
|
});
|
||||||
if (!auth.allowed) {
|
if (!ingressContext) {
|
||||||
logVerbose(
|
|
||||||
`slack: drop pin sender ${payload.user ?? "unknown"} channel=${channelId ?? "unknown"} reason=${auth.reason ?? "unauthorized"}`,
|
|
||||||
);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const label = resolveSlackChannelLabel({
|
|
||||||
channelId,
|
|
||||||
channelName: auth.channelName,
|
|
||||||
});
|
|
||||||
const userInfo = payload.user ? await ctx.resolveUserName(payload.user) : {};
|
const userInfo = payload.user ? await ctx.resolveUserName(payload.user) : {};
|
||||||
const userLabel = userInfo?.name ?? payload.user ?? "someone";
|
const userLabel = userInfo?.name ?? payload.user ?? "someone";
|
||||||
const itemType = payload.item?.type ?? "item";
|
const itemType = payload.item?.type ?? "item";
|
||||||
const messageId = payload.item?.message?.ts ?? payload.event_ts;
|
const messageId = payload.item?.message?.ts ?? payload.event_ts;
|
||||||
const sessionKey = ctx.resolveSlackSystemEventSessionKey({
|
enqueueSystemEvent(
|
||||||
channelId,
|
`Slack: ${userLabel} ${action} a ${itemType} in ${ingressContext.channelLabel}.`,
|
||||||
channelType: auth.channelType,
|
{
|
||||||
});
|
sessionKey: ingressContext.sessionKey,
|
||||||
enqueueSystemEvent(`Slack: ${userLabel} ${action} a ${itemType} in ${label}.`, {
|
contextKey: `slack:pin:${contextKeySuffix}:${channelId ?? "unknown"}:${messageId ?? "unknown"}`,
|
||||||
sessionKey,
|
},
|
||||||
contextKey: `slack:pin:${contextKeySuffix}:${channelId ?? "unknown"}:${messageId ?? "unknown"}`,
|
);
|
||||||
});
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
ctx.runtime.error?.(danger(`slack ${errorLabel} handler failed: ${String(err)}`));
|
ctx.runtime.error?.(danger(`slack ${errorLabel} handler failed: ${String(err)}`));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
import { describe, expect, it, vi } from "vitest";
|
import { describe, expect, it, vi } from "vitest";
|
||||||
import type { SlackMonitorContext } from "../context.js";
|
|
||||||
import { registerSlackReactionEvents } from "./reactions.js";
|
import { registerSlackReactionEvents } from "./reactions.js";
|
||||||
|
import {
|
||||||
|
createSlackSystemEventTestHarness,
|
||||||
|
type SlackSystemEventTestOverrides,
|
||||||
|
} from "./system-event-test-harness.js";
|
||||||
|
|
||||||
const enqueueSystemEventMock = vi.fn();
|
const enqueueSystemEventMock = vi.fn();
|
||||||
const readAllowFromStoreMock = vi.fn();
|
const readAllowFromStoreMock = vi.fn();
|
||||||
@@ -18,55 +21,12 @@ type SlackReactionHandler = (args: {
|
|||||||
body: unknown;
|
body: unknown;
|
||||||
}) => Promise<void>;
|
}) => Promise<void>;
|
||||||
|
|
||||||
function createReactionContext(overrides?: {
|
function createReactionContext(overrides?: SlackSystemEventTestOverrides) {
|
||||||
dmPolicy?: "open" | "pairing" | "allowlist" | "disabled";
|
const harness = createSlackSystemEventTestHarness(overrides);
|
||||||
allowFrom?: string[];
|
registerSlackReactionEvents({ ctx: harness.ctx });
|
||||||
channelType?: "im" | "channel";
|
|
||||||
channelUsers?: string[];
|
|
||||||
}) {
|
|
||||||
let addedHandler: SlackReactionHandler | null = null;
|
|
||||||
let removedHandler: SlackReactionHandler | null = null;
|
|
||||||
const channelType = overrides?.channelType ?? "im";
|
|
||||||
const app = {
|
|
||||||
event: vi.fn((name: string, handler: SlackReactionHandler) => {
|
|
||||||
if (name === "reaction_added") {
|
|
||||||
addedHandler = handler;
|
|
||||||
} else if (name === "reaction_removed") {
|
|
||||||
removedHandler = handler;
|
|
||||||
}
|
|
||||||
}),
|
|
||||||
};
|
|
||||||
const ctx = {
|
|
||||||
app,
|
|
||||||
runtime: { error: vi.fn() },
|
|
||||||
dmEnabled: true,
|
|
||||||
dmPolicy: overrides?.dmPolicy ?? "open",
|
|
||||||
defaultRequireMention: true,
|
|
||||||
channelsConfig: overrides?.channelUsers
|
|
||||||
? {
|
|
||||||
C1: {
|
|
||||||
users: overrides.channelUsers,
|
|
||||||
allow: true,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
: undefined,
|
|
||||||
groupPolicy: "open",
|
|
||||||
allowFrom: overrides?.allowFrom ?? [],
|
|
||||||
allowNameMatching: false,
|
|
||||||
shouldDropMismatchedSlackEvent: vi.fn().mockReturnValue(false),
|
|
||||||
isChannelAllowed: vi.fn().mockReturnValue(true),
|
|
||||||
resolveChannelName: vi.fn().mockResolvedValue({
|
|
||||||
name: channelType === "im" ? "direct" : "general",
|
|
||||||
type: channelType,
|
|
||||||
}),
|
|
||||||
resolveUserName: vi.fn().mockResolvedValue({ name: "alice" }),
|
|
||||||
resolveSlackSystemEventSessionKey: vi.fn().mockReturnValue("agent:main:main"),
|
|
||||||
} as unknown as SlackMonitorContext;
|
|
||||||
registerSlackReactionEvents({ ctx });
|
|
||||||
return {
|
return {
|
||||||
ctx,
|
getAddedHandler: () => harness.getHandler("reaction_added") as SlackReactionHandler | null,
|
||||||
getAddedHandler: () => addedHandler,
|
getRemovedHandler: () => harness.getHandler("reaction_removed") as SlackReactionHandler | null,
|
||||||
getRemovedHandler: () => removedHandler,
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,10 +1,9 @@
|
|||||||
import type { SlackEventMiddlewareArgs } from "@slack/bolt";
|
import type { SlackEventMiddlewareArgs } from "@slack/bolt";
|
||||||
import { danger, logVerbose } from "../../../globals.js";
|
import { danger } from "../../../globals.js";
|
||||||
import { enqueueSystemEvent } from "../../../infra/system-events.js";
|
import { enqueueSystemEvent } from "../../../infra/system-events.js";
|
||||||
import { authorizeSlackSystemEventSender } from "../auth.js";
|
|
||||||
import { resolveSlackChannelLabel } from "../channel-config.js";
|
|
||||||
import type { SlackMonitorContext } from "../context.js";
|
import type { SlackMonitorContext } from "../context.js";
|
||||||
import type { SlackReactionEvent } from "../types.js";
|
import type { SlackReactionEvent } from "../types.js";
|
||||||
|
import { authorizeAndResolveSlackSystemEventContext } from "./system-event-context.js";
|
||||||
|
|
||||||
export function registerSlackReactionEvents(params: { ctx: SlackMonitorContext }) {
|
export function registerSlackReactionEvents(params: { ctx: SlackMonitorContext }) {
|
||||||
const { ctx } = params;
|
const { ctx } = params;
|
||||||
@@ -16,35 +15,30 @@ export function registerSlackReactionEvents(params: { ctx: SlackMonitorContext }
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const auth = await authorizeSlackSystemEventSender({
|
const ingressContext = await authorizeAndResolveSlackSystemEventContext({
|
||||||
ctx,
|
ctx,
|
||||||
senderId: event.user,
|
senderId: event.user,
|
||||||
channelId: item.channel,
|
channelId: item.channel,
|
||||||
|
eventKind: "reaction",
|
||||||
});
|
});
|
||||||
if (!auth.allowed) {
|
if (!ingressContext) {
|
||||||
logVerbose(
|
|
||||||
`slack: drop reaction sender ${event.user ?? "unknown"} channel=${item.channel ?? "unknown"} reason=${auth.reason ?? "unauthorized"}`,
|
|
||||||
);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const channelLabel = resolveSlackChannelLabel({
|
const actorInfoPromise: Promise<{ name?: string } | undefined> = event.user
|
||||||
channelId: item.channel,
|
? ctx.resolveUserName(event.user)
|
||||||
channelName: auth.channelName,
|
: Promise.resolve(undefined);
|
||||||
});
|
const authorInfoPromise: Promise<{ name?: string } | undefined> = event.item_user
|
||||||
const actorInfo = event.user ? await ctx.resolveUserName(event.user) : undefined;
|
? ctx.resolveUserName(event.item_user)
|
||||||
|
: Promise.resolve(undefined);
|
||||||
|
const [actorInfo, authorInfo] = await Promise.all([actorInfoPromise, authorInfoPromise]);
|
||||||
const actorLabel = actorInfo?.name ?? event.user;
|
const actorLabel = actorInfo?.name ?? event.user;
|
||||||
const emojiLabel = event.reaction ?? "emoji";
|
const emojiLabel = event.reaction ?? "emoji";
|
||||||
const authorInfo = event.item_user ? await ctx.resolveUserName(event.item_user) : undefined;
|
|
||||||
const authorLabel = authorInfo?.name ?? event.item_user;
|
const authorLabel = authorInfo?.name ?? event.item_user;
|
||||||
const baseText = `Slack reaction ${action}: :${emojiLabel}: by ${actorLabel} in ${channelLabel} msg ${item.ts}`;
|
const baseText = `Slack reaction ${action}: :${emojiLabel}: by ${actorLabel} in ${ingressContext.channelLabel} msg ${item.ts}`;
|
||||||
const text = authorLabel ? `${baseText} from ${authorLabel}` : baseText;
|
const text = authorLabel ? `${baseText} from ${authorLabel}` : baseText;
|
||||||
const sessionKey = ctx.resolveSlackSystemEventSessionKey({
|
|
||||||
channelId: item.channel,
|
|
||||||
channelType: auth.channelType,
|
|
||||||
});
|
|
||||||
enqueueSystemEvent(text, {
|
enqueueSystemEvent(text, {
|
||||||
sessionKey,
|
sessionKey: ingressContext.sessionKey,
|
||||||
contextKey: `slack:reaction:${action}:${item.channel}:${item.ts}:${event.user}:${emojiLabel}`,
|
contextKey: `slack:reaction:${action}:${item.channel}:${item.ts}:${event.user}:${emojiLabel}`,
|
||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|||||||
44
src/slack/monitor/events/system-event-context.ts
Normal file
44
src/slack/monitor/events/system-event-context.ts
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
import { logVerbose } from "../../../globals.js";
|
||||||
|
import { authorizeSlackSystemEventSender } from "../auth.js";
|
||||||
|
import { resolveSlackChannelLabel } from "../channel-config.js";
|
||||||
|
import type { SlackMonitorContext } from "../context.js";
|
||||||
|
|
||||||
|
export type SlackAuthorizedSystemEventContext = {
|
||||||
|
channelLabel: string;
|
||||||
|
sessionKey: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function authorizeAndResolveSlackSystemEventContext(params: {
|
||||||
|
ctx: SlackMonitorContext;
|
||||||
|
senderId?: string;
|
||||||
|
channelId?: string;
|
||||||
|
channelType?: string | null;
|
||||||
|
eventKind: string;
|
||||||
|
}): Promise<SlackAuthorizedSystemEventContext | undefined> {
|
||||||
|
const { ctx, senderId, channelId, channelType, eventKind } = params;
|
||||||
|
const auth = await authorizeSlackSystemEventSender({
|
||||||
|
ctx,
|
||||||
|
senderId,
|
||||||
|
channelId,
|
||||||
|
channelType,
|
||||||
|
});
|
||||||
|
if (!auth.allowed) {
|
||||||
|
logVerbose(
|
||||||
|
`slack: drop ${eventKind} sender ${senderId ?? "unknown"} channel=${channelId ?? "unknown"} reason=${auth.reason ?? "unauthorized"}`,
|
||||||
|
);
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const channelLabel = resolveSlackChannelLabel({
|
||||||
|
channelId,
|
||||||
|
channelName: auth.channelName,
|
||||||
|
});
|
||||||
|
const sessionKey = ctx.resolveSlackSystemEventSessionKey({
|
||||||
|
channelId,
|
||||||
|
channelType: auth.channelType,
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
channelLabel,
|
||||||
|
sessionKey,
|
||||||
|
};
|
||||||
|
}
|
||||||
56
src/slack/monitor/events/system-event-test-harness.ts
Normal file
56
src/slack/monitor/events/system-event-test-harness.ts
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
import type { SlackMonitorContext } from "../context.js";
|
||||||
|
|
||||||
|
export type SlackSystemEventHandler = (args: {
|
||||||
|
event: Record<string, unknown>;
|
||||||
|
body: unknown;
|
||||||
|
}) => Promise<void>;
|
||||||
|
|
||||||
|
export type SlackSystemEventTestOverrides = {
|
||||||
|
dmPolicy?: "open" | "pairing" | "allowlist" | "disabled";
|
||||||
|
allowFrom?: string[];
|
||||||
|
channelType?: "im" | "channel";
|
||||||
|
channelUsers?: string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export function createSlackSystemEventTestHarness(overrides?: SlackSystemEventTestOverrides) {
|
||||||
|
const handlers: Record<string, SlackSystemEventHandler> = {};
|
||||||
|
const channelType = overrides?.channelType ?? "im";
|
||||||
|
const app = {
|
||||||
|
event: (name: string, handler: SlackSystemEventHandler) => {
|
||||||
|
handlers[name] = handler;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const ctx = {
|
||||||
|
app,
|
||||||
|
runtime: { error: () => {} },
|
||||||
|
dmEnabled: true,
|
||||||
|
dmPolicy: overrides?.dmPolicy ?? "open",
|
||||||
|
defaultRequireMention: true,
|
||||||
|
channelsConfig: overrides?.channelUsers
|
||||||
|
? {
|
||||||
|
C1: {
|
||||||
|
users: overrides.channelUsers,
|
||||||
|
allow: true,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
: undefined,
|
||||||
|
groupPolicy: "open",
|
||||||
|
allowFrom: overrides?.allowFrom ?? [],
|
||||||
|
allowNameMatching: false,
|
||||||
|
shouldDropMismatchedSlackEvent: () => false,
|
||||||
|
isChannelAllowed: () => true,
|
||||||
|
resolveChannelName: async () => ({
|
||||||
|
name: channelType === "im" ? "direct" : "general",
|
||||||
|
type: channelType,
|
||||||
|
}),
|
||||||
|
resolveUserName: async () => ({ name: "alice" }),
|
||||||
|
resolveSlackSystemEventSessionKey: () => "agent:main:main",
|
||||||
|
} as unknown as SlackMonitorContext;
|
||||||
|
|
||||||
|
return {
|
||||||
|
ctx,
|
||||||
|
getHandler(name: string): SlackSystemEventHandler | null {
|
||||||
|
return handlers[name] ?? null;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user