diff --git a/src/slack/monitor/events/interactions.test.ts b/src/slack/monitor/events/interactions.test.ts index 82571053ba6..999f7885827 100644 --- a/src/slack/monitor/events/interactions.test.ts +++ b/src/slack/monitor/events/interactions.test.ts @@ -18,12 +18,29 @@ type RegisteredHandler = (args: { respond?: (payload: { text: string; response_type: string }) => Promise; }) => Promise; +type RegisteredViewHandler = (args: { + ack: () => Promise; + body: { + user?: { id?: string }; + team?: { id?: string }; + view?: { + id?: string; + callback_id?: string; + state?: { values?: Record>> }; + }; + }; +}) => Promise; + function createContext() { let handler: RegisteredHandler | null = null; + let viewHandler: RegisteredViewHandler | null = null; const app = { action: vi.fn((_matcher: RegExp, next: RegisteredHandler) => { handler = next; }), + view: vi.fn((_matcher: RegExp, next: RegisteredViewHandler) => { + viewHandler = next; + }), client: { chat: { update: vi.fn().mockResolvedValue(undefined), @@ -37,7 +54,14 @@ function createContext() { runtime: { log: runtimeLog }, resolveSlackSystemEventSessionKey: resolveSessionKey, }; - return { ctx, app, runtimeLog, resolveSessionKey, getHandler: () => handler }; + return { + ctx, + app, + runtimeLog, + resolveSessionKey, + getHandler: () => handler, + getViewHandler: () => viewHandler, + }; } describe("registerSlackInteractionEvents", () => { @@ -141,4 +165,70 @@ describe("registerSlackInteractionEvents", () => { expect(payload.selectedValues).toEqual(["canary"]); expect(app.client.chat.update).not.toHaveBeenCalled(); }); + + it("captures modal submissions and enqueues view submission event", async () => { + enqueueSystemEventMock.mockReset(); + const { ctx, getViewHandler, resolveSessionKey } = createContext(); + registerSlackInteractionEvents({ ctx: ctx as never }); + const viewHandler = getViewHandler(); + expect(viewHandler).toBeTruthy(); + + const ack = vi.fn().mockResolvedValue(undefined); + await viewHandler!({ + ack, + body: { + user: { id: "U777" }, + team: { id: "T1" }, + view: { + id: "V123", + callback_id: "openclaw:deploy_form", + state: { + values: { + env_block: { + env_select: { + type: "static_select", + selected_option: { + text: { type: "plain_text", text: "Production" }, + value: "prod", + }, + }, + }, + notes_block: { + notes_input: { + type: "plain_text_input", + value: "ship now", + }, + }, + }, + }, + }, + }, + }); + + 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; + inputs: Array<{ actionId: string; selectedValues?: string[]; inputValue?: string }>; + }; + expect(payload).toMatchObject({ + interactionType: "view_submission", + actionId: "view:openclaw:deploy_form", + callbackId: "openclaw:deploy_form", + viewId: "V123", + userId: "U777", + }); + expect(payload.inputs).toEqual( + expect.arrayContaining([ + expect.objectContaining({ actionId: "env_select", selectedValues: ["prod"] }), + expect.objectContaining({ actionId: "notes_input", inputValue: "ship now" }), + ]), + ); + }); }); diff --git a/src/slack/monitor/events/interactions.ts b/src/slack/monitor/events/interactions.ts index 113f534a2fa..bb302dbc9be 100644 --- a/src/slack/monitor/events/interactions.ts +++ b/src/slack/monitor/events/interactions.ts @@ -18,6 +18,7 @@ type SelectOption = { }; type InteractionSummary = { + interactionType?: "block_action" | "view_submission"; actionId: string; blockId?: string; actionType?: string; @@ -33,6 +34,19 @@ type InteractionSummary = { messageTs?: string; }; +type ModalInputSummary = { + blockId: string; + actionId: string; + actionType?: string; + value?: string; + selectedValues?: string[]; + selectedLabels?: string[]; + selectedDate?: string; + selectedTime?: string; + selectedDateTime?: number; + inputValue?: string; +}; + function readOptionValues(options: unknown): string[] | undefined { if (!Array.isArray(options)) { return undefined; @@ -108,6 +122,30 @@ function isBulkActionsBlock(block: InteractionMessageBlock): boolean { ); } +function summarizeViewState(values: unknown): ModalInputSummary[] { + if (!values || typeof values !== "object") { + return []; + } + const entries: ModalInputSummary[] = []; + for (const [blockId, blockValue] of Object.entries(values as Record)) { + if (!blockValue || typeof blockValue !== "object") { + continue; + } + for (const [actionId, rawAction] of Object.entries(blockValue as Record)) { + if (!rawAction || typeof rawAction !== "object") { + continue; + } + const actionSummary = summarizeAction(rawAction as Record); + entries.push({ + blockId, + actionId, + ...actionSummary, + }); + } + } + return entries; +} + export function registerSlackInteractionEvents(params: { ctx: SlackMonitorContext }) { const { ctx } = params; if (typeof ctx.app.action !== "function") { @@ -144,6 +182,7 @@ export function registerSlackInteractionEvents(params: { ctx: SlackMonitorContex const messageTs = typedBody.message?.ts; const actionSummary = summarizeAction(typedAction); const eventPayload: InteractionSummary = { + interactionType: "block_action", actionId, blockId, ...actionSummary, @@ -236,4 +275,51 @@ export function registerSlackInteractionEvents(params: { ctx: SlackMonitorContex } }, ); + + if (typeof ctx.app.view !== "function") { + return; + } + + // Handle OpenClaw modal submissions with callback_ids scoped by our prefix. + ctx.app.view( + 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; + state?: { values?: unknown }; + }; + }; + + 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_submission", + actionId: `view:${callbackId}`, + callbackId, + viewId, + userId, + teamId: typedBody.team?.id, + inputs, + }; + + ctx.runtime.log?.( + `slack:interaction view_submission callback=${callbackId} user=${userId} inputs=${inputs.length}`, + ); + + enqueueSystemEvent(`Slack interaction: ${JSON.stringify(eventPayload)}`, { + sessionKey: ctx.resolveSlackSystemEventSessionKey({}), + contextKey: ["slack:interaction:view", callbackId, viewId, userId] + .filter(Boolean) + .join(":"), + }); + }, + ); }