From cf0ca47a82897f6efba420eb15b8813d38a845bd Mon Sep 17 00:00:00 2001 From: Colin Date: Mon, 16 Feb 2026 11:20:32 -0500 Subject: [PATCH] Slack: capture Block Kit view closed events --- src/slack/monitor/events/interactions.test.ts | 85 +++++++++++++++++++ src/slack/monitor/events/interactions.ts | 63 +++++++++++++- 2 files changed, 147 insertions(+), 1 deletion(-) diff --git a/src/slack/monitor/events/interactions.test.ts b/src/slack/monitor/events/interactions.test.ts index 999f7885827..cfd44607bb0 100644 --- a/src/slack/monitor/events/interactions.test.ts +++ b/src/slack/monitor/events/interactions.test.ts @@ -31,9 +31,25 @@ type RegisteredViewHandler = (args: { }; }) => Promise; +type RegisteredViewClosedHandler = (args: { + ack: () => Promise; + body: { + user?: { id?: string }; + team?: { id?: string }; + view?: { + id?: string; + callback_id?: string; + private_metadata?: string; + state?: { values?: Record>> }; + }; + is_cleared?: boolean; + }; +}) => Promise; + function createContext() { let handler: RegisteredHandler | null = null; let viewHandler: RegisteredViewHandler | null = null; + let viewClosedHandler: RegisteredViewClosedHandler | null = null; const app = { action: vi.fn((_matcher: RegExp, next: RegisteredHandler) => { handler = next; @@ -41,6 +57,9 @@ function createContext() { view: vi.fn((_matcher: RegExp, next: RegisteredViewHandler) => { viewHandler = next; }), + viewClosed: vi.fn((_matcher: RegExp, next: RegisteredViewClosedHandler) => { + viewClosedHandler = next; + }), client: { chat: { update: vi.fn().mockResolvedValue(undefined), @@ -61,6 +80,7 @@ function createContext() { resolveSessionKey, getHandler: () => handler, getViewHandler: () => viewHandler, + getViewClosedHandler: () => viewClosedHandler, }; } @@ -231,4 +251,69 @@ describe("registerSlackInteractionEvents", () => { ]), ); }); + + it("captures modal close events and enqueues view closed event", async () => { + enqueueSystemEventMock.mockReset(); + const { ctx, getViewClosedHandler, resolveSessionKey } = createContext(); + registerSlackInteractionEvents({ ctx: ctx as never }); + const viewClosedHandler = getViewClosedHandler(); + expect(viewClosedHandler).toBeTruthy(); + + const ack = vi.fn().mockResolvedValue(undefined); + await viewClosedHandler!({ + ack, + body: { + user: { id: "U900" }, + team: { id: "T1" }, + is_cleared: true, + view: { + id: "V900", + callback_id: "openclaw:deploy_form", + private_metadata: "run:123", + state: { + values: { + env_block: { + env_select: { + type: "static_select", + selected_option: { + text: { type: "plain_text", text: "Canary" }, + value: "canary", + }, + }, + }, + }, + }, + }, + }, + }); + + expect(ack).toHaveBeenCalled(); + expect(resolveSessionKey).toHaveBeenCalledWith({}); + expect(enqueueSystemEventMock).toHaveBeenCalledTimes(1); + const [eventText] = enqueueSystemEventMock.mock.calls[0] as [string]; + const payload = JSON.parse(eventText.replace("Slack interaction: ", "")) as { + interactionType: string; + actionId: string; + callbackId: string; + viewId: string; + userId: string; + isCleared: boolean; + privateMetadata: string; + inputs: Array<{ actionId: string; selectedValues?: string[] }>; + }; + expect(payload).toMatchObject({ + interactionType: "view_closed", + actionId: "view:openclaw:deploy_form", + callbackId: "openclaw:deploy_form", + viewId: "V900", + userId: "U900", + isCleared: true, + privateMetadata: "run:123", + }); + expect(payload.inputs).toEqual( + expect.arrayContaining([ + expect.objectContaining({ actionId: "env_select", selectedValues: ["canary"] }), + ]), + ); + }); }); diff --git a/src/slack/monitor/events/interactions.ts b/src/slack/monitor/events/interactions.ts index bb302dbc9be..14a874c139b 100644 --- a/src/slack/monitor/events/interactions.ts +++ b/src/slack/monitor/events/interactions.ts @@ -18,7 +18,7 @@ type SelectOption = { }; type InteractionSummary = { - interactionType?: "block_action" | "view_submission"; + interactionType?: "block_action" | "view_submission" | "view_closed"; actionId: string; blockId?: string; actionType?: string; @@ -322,4 +322,65 @@ export function registerSlackInteractionEvents(params: { ctx: SlackMonitorContex }); }, ); + + const viewClosed = ( + ctx.app as unknown as { + viewClosed?: ( + matcher: RegExp, + handler: (args: { ack: () => Promise; body: unknown }) => Promise, + ) => void; + } + ).viewClosed; + if (typeof viewClosed !== "function") { + return; + } + + // Handle modal close events so agent workflows can react to cancelled forms. + viewClosed( + new RegExp(`^${OPENCLAW_ACTION_PREFIX}`), + async ({ ack, body }: { ack: () => Promise; body: unknown }) => { + await ack(); + + const typedBody = body as { + user?: { id?: string }; + team?: { id?: string }; + view?: { + id?: string; + callback_id?: string; + private_metadata?: string; + state?: { values?: unknown }; + }; + is_cleared?: boolean; + }; + + const callbackId = typedBody.view?.callback_id ?? "unknown"; + const userId = typedBody.user?.id ?? "unknown"; + const viewId = typedBody.view?.id; + const inputs = summarizeViewState(typedBody.view?.state?.values); + const eventPayload = { + interactionType: "view_closed", + actionId: `view:${callbackId}`, + callbackId, + viewId, + userId, + teamId: typedBody.team?.id, + isCleared: typedBody.is_cleared === true, + privateMetadata: typedBody.view?.private_metadata, + inputs, + }; + + ctx.runtime.log?.( + `slack:interaction view_closed callback=${callbackId} user=${userId} cleared=${ + typedBody.is_cleared === true + }`, + ); + + enqueueSystemEvent(`Slack interaction: ${JSON.stringify(eventPayload)}`, { + sessionKey: ctx.resolveSlackSystemEventSessionKey({}), + contextKey: ["slack:interaction:view-closed", callbackId, viewId, userId] + .filter(Boolean) + .join(":"), + }); + }, + ); }