Slack: capture Block Kit view closed events

This commit is contained in:
Colin
2026-02-16 11:20:32 -05:00
committed by Peter Steinberger
parent e7cded82b2
commit cf0ca47a82
2 changed files with 147 additions and 1 deletions

View File

@@ -31,9 +31,25 @@ type RegisteredViewHandler = (args: {
};
}) => Promise<void>;
type RegisteredViewClosedHandler = (args: {
ack: () => Promise<void>;
body: {
user?: { id?: string };
team?: { id?: string };
view?: {
id?: string;
callback_id?: string;
private_metadata?: string;
state?: { values?: Record<string, Record<string, Record<string, unknown>>> };
};
is_cleared?: boolean;
};
}) => Promise<void>;
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"] }),
]),
);
});
});

View File

@@ -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<void>; body: unknown }) => Promise<void>,
) => 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<void>; 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(":"),
});
},
);
}