diff --git a/src/slack/monitor/events/interactions.test.ts b/src/slack/monitor/events/interactions.test.ts new file mode 100644 index 00000000000..82571053ba6 --- /dev/null +++ b/src/slack/monitor/events/interactions.test.ts @@ -0,0 +1,144 @@ +import { describe, expect, it, vi } from "vitest"; +import { registerSlackInteractionEvents } from "./interactions.js"; + +const enqueueSystemEventMock = vi.fn(); + +vi.mock("../../../infra/system-events.js", () => ({ + enqueueSystemEvent: (...args: unknown[]) => enqueueSystemEventMock(...args), +})); + +type RegisteredHandler = (args: { + ack: () => Promise; + body: { + user: { id: string }; + channel?: { id?: string }; + message?: { ts?: string; text?: string; blocks?: unknown[] }; + }; + action: Record; + respond?: (payload: { text: string; response_type: string }) => Promise; +}) => Promise; + +function createContext() { + let handler: RegisteredHandler | null = null; + const app = { + action: vi.fn((_matcher: RegExp, next: RegisteredHandler) => { + handler = next; + }), + client: { + chat: { + update: vi.fn().mockResolvedValue(undefined), + }, + }, + }; + const runtimeLog = vi.fn(); + const resolveSessionKey = vi.fn().mockReturnValue("agent:ops:slack:channel:C1"); + const ctx = { + app, + runtime: { log: runtimeLog }, + resolveSlackSystemEventSessionKey: resolveSessionKey, + }; + return { ctx, app, runtimeLog, resolveSessionKey, getHandler: () => handler }; +} + +describe("registerSlackInteractionEvents", () => { + it("enqueues structured events and updates button rows", async () => { + enqueueSystemEventMock.mockReset(); + const { ctx, app, getHandler } = createContext(); + registerSlackInteractionEvents({ ctx: ctx as never }); + + const handler = getHandler(); + expect(handler).toBeTruthy(); + + const ack = vi.fn().mockResolvedValue(undefined); + const respond = vi.fn().mockResolvedValue(undefined); + await handler!({ + ack, + respond, + body: { + user: { id: "U123" }, + channel: { id: "C1" }, + message: { + ts: "100.200", + text: "fallback", + blocks: [ + { + type: "actions", + block_id: "verify_block", + elements: [{ type: "button", action_id: "openclaw:verify" }], + }, + ], + }, + }, + action: { + type: "button", + action_id: "openclaw:verify", + block_id: "verify_block", + value: "approved", + text: { type: "plain_text", text: "Approve" }, + }, + }); + + expect(ack).toHaveBeenCalled(); + expect(enqueueSystemEventMock).toHaveBeenCalledTimes(1); + const [eventText] = enqueueSystemEventMock.mock.calls[0] as [string]; + expect(eventText.startsWith("Slack interaction: ")).toBe(true); + const payload = JSON.parse(eventText.replace("Slack interaction: ", "")) as { + actionId: string; + actionType: string; + value: string; + userId: string; + channelId: string; + messageTs: string; + }; + expect(payload).toMatchObject({ + actionId: "openclaw:verify", + actionType: "button", + value: "approved", + userId: "U123", + channelId: "C1", + messageTs: "100.200", + }); + expect(app.client.chat.update).toHaveBeenCalledTimes(1); + }); + + it("captures select values and skips chat.update for non-button actions", async () => { + enqueueSystemEventMock.mockReset(); + const { ctx, app, getHandler } = createContext(); + registerSlackInteractionEvents({ ctx: ctx as never }); + const handler = getHandler(); + expect(handler).toBeTruthy(); + + const ack = vi.fn().mockResolvedValue(undefined); + await handler!({ + ack, + body: { + user: { id: "U555" }, + channel: { id: "C1" }, + message: { + ts: "111.222", + blocks: [{ type: "actions", block_id: "select_block", elements: [] }], + }, + }, + action: { + type: "static_select", + action_id: "openclaw:pick", + block_id: "select_block", + selected_option: { + text: { type: "plain_text", text: "Canary" }, + value: "canary", + }, + }, + }); + + expect(ack).toHaveBeenCalled(); + expect(enqueueSystemEventMock).toHaveBeenCalledTimes(1); + const [eventText] = enqueueSystemEventMock.mock.calls[0] as [string]; + const payload = JSON.parse(eventText.replace("Slack interaction: ", "")) as { + actionType: string; + selectedValues?: string[]; + }; + expect(payload.actionType).toBe("static_select"); + expect(payload.selectedValues).toEqual(["canary"]); + expect(app.client.chat.update).not.toHaveBeenCalled(); + }); +}); diff --git a/src/slack/monitor/events/interactions.ts b/src/slack/monitor/events/interactions.ts new file mode 100644 index 00000000000..4869891f70e --- /dev/null +++ b/src/slack/monitor/events/interactions.ts @@ -0,0 +1,225 @@ +import type { BlockAction, SlackActionMiddlewareArgs } from "@slack/bolt"; +import type { Block, KnownBlock } from "@slack/web-api"; +import type { SlackMonitorContext } from "../context.js"; +import { enqueueSystemEvent } from "../../../infra/system-events.js"; + +// Prefix for OpenClaw-generated action IDs to scope our handler +const OPENCLAW_ACTION_PREFIX = "openclaw:"; + +type InteractionMessageBlock = { + type?: string; + block_id?: string; + elements?: Array<{ action_id?: string }>; +}; + +type SelectOption = { + value?: string; + text?: { text?: string }; +}; + +type InteractionSummary = { + actionId: string; + blockId?: string; + actionType?: string; + value?: string; + selectedValues?: string[]; + selectedLabels?: string[]; + selectedDate?: string; + selectedTime?: string; + selectedDateTime?: number; + inputValue?: string; + userId?: string; + channelId?: string; + messageTs?: string; +}; + +function readOptionValues(options: unknown): string[] | undefined { + if (!Array.isArray(options)) { + return undefined; + } + const values = options + .map((option) => (option && typeof option === "object" ? (option as SelectOption).value : null)) + .filter((value): value is string => typeof value === "string" && value.trim().length > 0); + return values.length > 0 ? values : undefined; +} + +function readOptionLabels(options: unknown): string[] | undefined { + if (!Array.isArray(options)) { + return undefined; + } + const labels = options + .map((option) => + option && typeof option === "object" ? ((option as SelectOption).text?.text ?? null) : null, + ) + .filter((label): label is string => typeof label === "string" && label.trim().length > 0); + return labels.length > 0 ? labels : undefined; +} + +function summarizeAction(action: BlockAction): Omit { + const typed = action as BlockAction & { + selected_option?: SelectOption; + selected_options?: SelectOption[]; + selected_user?: string; + selected_users?: string[]; + selected_channel?: string; + selected_channels?: string[]; + selected_conversation?: string; + selected_conversations?: string[]; + selected_date?: string; + selected_time?: string; + selected_date_time?: number; + value?: string; + }; + const actionType = typed.type; + const selectedValues = [ + ...(typed.selected_option?.value ? [typed.selected_option.value] : []), + ...(readOptionValues(typed.selected_options) ?? []), + ...(typed.selected_user ? [typed.selected_user] : []), + ...(Array.isArray(typed.selected_users) ? typed.selected_users : []), + ...(typed.selected_channel ? [typed.selected_channel] : []), + ...(Array.isArray(typed.selected_channels) ? typed.selected_channels : []), + ...(typed.selected_conversation ? [typed.selected_conversation] : []), + ...(Array.isArray(typed.selected_conversations) ? typed.selected_conversations : []), + ].filter((entry) => typeof entry === "string" && entry.trim().length > 0); + const selectedLabels = readOptionLabels(typed.selected_options); + + return { + actionType, + value: typed.value, + selectedValues: selectedValues.length > 0 ? selectedValues : undefined, + selectedLabels, + selectedDate: typed.selected_date, + selectedTime: typed.selected_time, + selectedDateTime: + typeof typed.selected_date_time === "number" ? typed.selected_date_time : undefined, + inputValue: actionType === "plain_text_input" ? typed.value : undefined, + }; +} + +function isBulkActionsBlock(block: InteractionMessageBlock): boolean { + return ( + block.type === "actions" && + Array.isArray(block.elements) && + block.elements.length > 0 && + block.elements.every((el) => typeof el.action_id === "string" && el.action_id.includes("_all_")) + ); +} + +export function registerSlackInteractionEvents(params: { ctx: SlackMonitorContext }) { + const { ctx } = params; + if (typeof ctx.app.action !== "function") { + return; + } + + // Handle Block Kit button clicks from OpenClaw-generated messages + // Only matches action_ids that start with our prefix to avoid interfering + // with other Slack integrations or future features + ctx.app.action( + new RegExp(`^${OPENCLAW_ACTION_PREFIX}`), + async (args: SlackActionMiddlewareArgs) => { + const { ack, body, action, respond } = args; + + // Acknowledge the action immediately to prevent the warning icon + await ack(); + + // Extract action details using proper Bolt types + const actionId = action.action_id; + const blockId = action.block_id; + const userId = body.user.id; + const channelId = body.channel?.id; + const messageTs = body.message?.ts; + const actionSummary = summarizeAction(action); + const eventPayload: InteractionSummary = { + actionId, + blockId, + ...actionSummary, + userId, + channelId, + messageTs, + }; + + // Log the interaction for debugging + ctx.runtime.log?.( + `slack:interaction action=${actionId} type=${actionSummary.actionType ?? "unknown"} user=${userId} channel=${channelId}`, + ); + + // Send a system event to notify the agent about the button click + // Pass undefined (not "unknown") to allow proper main session fallback + const sessionKey = ctx.resolveSlackSystemEventSessionKey({ + channelId: channelId, + channelType: "channel", + }); + + // Build context key - only include defined values to avoid "unknown" noise + const contextParts = ["slack:interaction", channelId, messageTs, actionId].filter(Boolean); + const contextKey = contextParts.join(":"); + + enqueueSystemEvent(`Slack interaction: ${JSON.stringify(eventPayload)}`, { + sessionKey, + contextKey, + }); + + const originalBlocks = (body.message as { blocks?: unknown[] } | undefined)?.blocks; + if (!Array.isArray(originalBlocks) || !channelId || !messageTs) { + return; + } + + if (action.type !== "button") { + return; + } + + const buttonText = action.text?.text ?? actionId; + let updatedBlocks = originalBlocks.map((block) => { + const typedBlock = block as InteractionMessageBlock; + if (typedBlock.type === "actions" && typedBlock.block_id === blockId) { + return { + type: "context", + elements: [{ type: "mrkdwn", text: `:white_check_mark: *${buttonText}* selected` }], + }; + } + return block; + }); + + const hasRemainingIndividualActionRows = updatedBlocks.some((block) => { + const typedBlock = block as InteractionMessageBlock; + return typedBlock.type === "actions" && !isBulkActionsBlock(typedBlock); + }); + + if (!hasRemainingIndividualActionRows) { + updatedBlocks = updatedBlocks.filter((block, index) => { + const typedBlock = block as InteractionMessageBlock; + if (isBulkActionsBlock(typedBlock)) { + return false; + } + if (typedBlock.type !== "divider") { + return true; + } + const next = updatedBlocks[index + 1] as InteractionMessageBlock | undefined; + return !next || !isBulkActionsBlock(next); + }); + } + + try { + await ctx.app.client.chat.update({ + channel: channelId, + ts: messageTs, + text: (body.message as { text?: string } | undefined)?.text ?? "", + blocks: updatedBlocks as (Block | KnownBlock)[], + }); + } catch { + // If update fails, fallback to ephemeral confirmation for immediate UX feedback. + if (!respond) { + return; + } + try { + await respond({ + text: `Button "${actionId}" clicked!`, + response_type: "ephemeral", + }); + } catch { + // Action was acknowledged and system event enqueued even when response updates fail. + } + } + }, + ); +}