diff --git a/docs/channels/slack.md b/docs/channels/slack.md index c4e95c21cf3..1297fd49457 100644 --- a/docs/channels/slack.md +++ b/docs/channels/slack.md @@ -201,6 +201,12 @@ For actions/directory reads, user token can be preferred when configured. For wr - Enable native Slack command handlers with `channels.slack.commands.native: true` (or global `commands.native: true`). - When native commands are enabled, register matching slash commands in Slack (`/` names). - If native commands are not enabled, you can run a single configured slash command via `channels.slack.slashCommand`. +- Native arg menus now adapt their rendering strategy: + - up to 5 options: button blocks + - 6-100 options: static select menu + - more than 100 options: external select with async option filtering when interactivity options handlers are available + - if encoded option values exceed Slack limits, the flow falls back to buttons +- For long option payloads, Slash command argument menus use a confirm dialog before dispatching a selected value. Default slash command settings: @@ -286,6 +292,9 @@ Available action groups in current Slack tooling: - Member join/leave, channel created/renamed, and pin add/remove events are mapped into system events. - `channel_id_changed` can migrate channel config keys when `configWrites` is enabled. - Channel topic/purpose metadata is treated as untrusted context and can be injected into routing context. +- Block actions and modal interactions emit structured `Slack interaction: ...` system events with rich payload fields: + - block actions: selected values, labels, picker values, and `workflow_*` metadata + - modal `view_submission` and `view_closed` events with routed channel metadata and form inputs ## Ack reactions diff --git a/src/slack/monitor/events/interactions.test.ts b/src/slack/monitor/events/interactions.test.ts index b21ab2fb95b..c5a1660ace4 100644 --- a/src/slack/monitor/events/interactions.test.ts +++ b/src/slack/monitor/events/interactions.test.ts @@ -228,6 +228,39 @@ describe("registerSlackInteractionEvents", () => { ); }); + it("ignores malformed action payloads after ack and logs warning", async () => { + const { ctx, app, getHandler, runtimeLog } = createContext(); + registerSlackInteractionEvents({ ctx: ctx as never }); + const handler = getHandler(); + expect(handler).toBeTruthy(); + + const ack = vi.fn().mockResolvedValue(undefined); + await handler!({ + ack, + body: { + user: { id: "U666" }, + channel: { id: "C1" }, + message: { + ts: "777.888", + text: "fallback", + blocks: [ + { + type: "actions", + block_id: "verify_block", + elements: [{ type: "button", action_id: "openclaw:verify" }], + }, + ], + }, + }, + action: "not-an-action-object" as unknown as Record, + }); + + expect(ack).toHaveBeenCalled(); + expect(app.client.chat.update).not.toHaveBeenCalled(); + expect(enqueueSystemEventMock).not.toHaveBeenCalled(); + expect(runtimeLog).toHaveBeenCalledWith(expect.stringContaining("slack:interaction malformed")); + }); + it("escapes mrkdwn characters in confirmation labels", async () => { enqueueSystemEventMock.mockReset(); const { ctx, app, getHandler } = createContext(); diff --git a/src/slack/monitor/events/interactions.ts b/src/slack/monitor/events/interactions.ts index ff41f2d24e4..38367cdd40c 100644 --- a/src/slack/monitor/events/interactions.ts +++ b/src/slack/monitor/events/interactions.ts @@ -1,8 +1,8 @@ import type { 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"; import { parseSlackModalPrivateMetadata } from "../../modal-metadata.js"; -import type { SlackMonitorContext } from "../context.js"; // Prefix for OpenClaw-generated action IDs to scope our handler const OPENCLAW_ACTION_PREFIX = "openclaw:"; @@ -135,6 +135,13 @@ function summarizeRichTextPreview(value: unknown): string | undefined { return joined.length <= max ? joined : `${joined.slice(0, max - 1)}…`; } +function readInteractionAction(raw: unknown) { + if (!raw || typeof raw !== "object" || Array.isArray(raw)) { + return undefined; + } + return raw as Record; +} + function summarizeAction( action: Record, ): Omit { @@ -394,14 +401,26 @@ export function registerSlackInteractionEvents(params: { ctx: SlackMonitorContex await ack(); // Extract action details using proper Bolt types - const typedAction = action as unknown as Record & { + const typedAction = readInteractionAction(action); + if (!typedAction) { + ctx.runtime.log?.( + `slack:interaction malformed action payload channel=${typedBody.channel?.id ?? typedBody.container?.channel_id ?? "unknown"} user=${ + typedBody.user?.id ?? "unknown" + }`, + ); + return; + } + const typedActionWithText = typedAction as { action_id?: string; block_id?: string; type?: string; text?: { text?: string }; }; - const actionId = typedAction.action_id ?? "unknown"; - const blockId = typedAction.block_id; + const actionId = + typeof typedActionWithText.action_id === "string" + ? typedActionWithText.action_id + : "unknown"; + const blockId = typedActionWithText.block_id; const userId = typedBody.user?.id ?? "unknown"; const channelId = typedBody.channel?.id ?? typedBody.container?.channel_id; const messageTs = typedBody.message?.ts ?? typedBody.container?.message_ts; @@ -454,7 +473,7 @@ export function registerSlackInteractionEvents(params: { ctx: SlackMonitorContex const selectedLabel = formatInteractionSelectionLabel({ actionId, summary: actionSummary, - buttonText: typedAction.text?.text, + buttonText: typedActionWithText.text?.text, }); let updatedBlocks = originalBlocks.map((block) => { const typedBlock = block as InteractionMessageBlock;