diff --git a/CHANGELOG.md b/CHANGELOG.md index 5c2ceb2e51e..8638fdbdb7d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,7 @@ Docs: https://docs.openclaw.ai - Control UI/WebChat: focus the composer when users click the visible input chrome and restore larger, labeled desktop composer controls while preserving compact mobile taps. Fixes #45656. Thanks @BunsDev. - System events: keep owner downgrades in structured metadata while rendering queued prompt text as plain `System:` lines, preserving least-privilege wakeups without prompt-visible trust labels. (#82067) - Providers/Xiaomi: preserve MiMo `reasoning_content` on multi-turn tool-call replay, including custom Xiaomi-compatible proxy routes, so follow-up turns no longer fail with `400 Param Incorrect`. Fixes #81419. (#81589) Thanks @lovelefeng-glitch and @jimdawdy-hub. +- Slack/plugins: route plugin-owned modal `view_submission` and `view_closed` events through Slack interactive handlers before compacting the agent-visible system event, so plugins can persist full submitted form state while the transcript stays compact. Fixes #82102. Thanks @shannon0430. - Memory search: stop using chokidar write-stability polling for memory and QMD watchers so large Markdown extraPath trees no longer build up regular file descriptors; changed files now settle through the existing debounced sync queue. Fixes #77327 and #78224. (#81802) Thanks @frankekn, @loyur, and @JanPlessow. ## 2026.5.14 diff --git a/docs/channels/slack.md b/docs/channels/slack.md index 5c0c8888319..afb6a219b5c 100644 --- a/docs/channels/slack.md +++ b/docs/channels/slack.md @@ -1178,6 +1178,27 @@ Notes: - The interactive callback values are OpenClaw-generated opaque tokens, not raw agent-authored values. - If generated interactive blocks would exceed Slack Block Kit limits, OpenClaw falls back to the original text reply instead of sending an invalid blocks payload. +### Plugin-owned modal submissions + +Slack plugins that register an interactive handler can also receive modal +`view_submission` and `view_closed` lifecycle events before OpenClaw compacts +the payload for the agent-visible system event. Use one of these routing +patterns when opening a Slack modal: + +- Set `callback_id` to `openclaw::`. +- Or keep an existing `callback_id` and put `pluginInteractiveData: +":"` in the modal `private_metadata`. + +The handler receives `ctx.interaction.kind` as `view_submission` or +`view_closed`, normalized `inputs`, and the full raw `stateValues` object from +Slack. Callback-id-only routing is enough to invoke the plugin handler; include +the existing modal `private_metadata` user/session routing fields when the +modal should also produce an agent-visible system event. The agent receives a +compact, redacted `Slack interaction: ...` system event. If the handler returns +`systemEvent.summary`, `systemEvent.reference`, or `systemEvent.data`, those +fields are included in that compact event so the agent can reference +plugin-owned storage without seeing the complete form payload. + ## Exec approvals in Slack Slack can act as a native approval client with interactive buttons and interactions, instead of falling back to the Web UI or terminal. diff --git a/extensions/slack/src/blocks.test.ts b/extensions/slack/src/blocks.test.ts index 71ed681ba0e..814d9b3a2fb 100644 --- a/extensions/slack/src/blocks.test.ts +++ b/extensions/slack/src/blocks.test.ts @@ -105,6 +105,7 @@ describe("parseSlackModalPrivateMetadata", () => { channelId: "D123", channelType: "im", userId: "U123", + pluginInteractiveData: "dean.contract:confirm", ignored: "x", }), ), @@ -113,6 +114,7 @@ describe("parseSlackModalPrivateMetadata", () => { channelId: "D123", channelType: "im", userId: "U123", + pluginInteractiveData: "dean.contract:confirm", }); }); }); @@ -126,12 +128,14 @@ describe("encodeSlackModalPrivateMetadata", () => { channelId: "", channelType: "im", userId: "U123", + pluginInteractiveData: "dean.contract:confirm", }), ), ).toEqual({ sessionKey: "agent:main:slack:channel:C1", channelType: "im", userId: "U123", + pluginInteractiveData: "dean.contract:confirm", }); }); diff --git a/extensions/slack/src/interactive-dispatch.ts b/extensions/slack/src/interactive-dispatch.ts index b5593715dfe..d6dc10ec6b8 100644 --- a/extensions/slack/src/interactive-dispatch.ts +++ b/extensions/slack/src/interactive-dispatch.ts @@ -6,6 +6,49 @@ import { type PluginConversationBindingRequestResult, type PluginInteractiveRegistration, } from "openclaw/plugin-sdk/plugin-runtime"; +import type { ModalInputSummary } from "./monitor/events/interactions.modal.js"; + +export type SlackInteractiveHandlerResult = { + handled?: boolean; + systemEvent?: { + summary?: string; + reference?: string; + data?: Record; + }; +} | void; + +type SlackBlockInteractivePayload = { + kind: "button" | "select"; + data: string; + namespace: string; + payload: string; + actionId: string; + blockId?: string; + messageTs?: string; + threadTs?: string; + value?: string; + selectedValues?: string[]; + selectedLabels?: string[]; + triggerId?: string; + responseUrl?: string; +}; + +type SlackModalInteractivePayload = { + kind: "view_submission" | "view_closed"; + data: string; + namespace: string; + payload: string; + callbackId: string; + viewId?: string; + rootViewId?: string; + previousViewId?: string; + externalId?: string; + isStackedView?: boolean; + isCleared?: boolean; + inputs: ModalInputSummary[]; + stateValues?: unknown; + triggerId?: string; +}; export type SlackInteractiveHandlerContext = { channel: "slack"; @@ -19,21 +62,7 @@ export type SlackInteractiveHandlerContext = { auth: { isAuthorizedSender: boolean; }; - interaction: { - kind: "button" | "select"; - data: string; - namespace: string; - payload: string; - actionId: string; - blockId?: string; - messageTs?: string; - threadTs?: string; - value?: string; - selectedValues?: string[]; - selectedLabels?: string[]; - triggerId?: string; - responseUrl?: string; - }; + interaction: SlackBlockInteractivePayload | SlackModalInteractivePayload; respond: { acknowledge: () => Promise; reply: (params: { text: string; responseType?: "ephemeral" | "in_channel" }) => Promise; @@ -52,7 +81,8 @@ export type SlackInteractiveHandlerContext = { export type SlackInteractiveHandlerRegistration = PluginInteractiveRegistration< SlackInteractiveHandlerContext, - "slack" + "slack", + SlackInteractiveHandlerResult >; type SlackInteractiveDispatchContext = Omit< @@ -64,10 +94,9 @@ type SlackInteractiveDispatchContext = Omit< | "detachConversationBinding" | "getCurrentConversationBinding" > & { - interaction: Omit< - SlackInteractiveHandlerContext["interaction"], - "data" | "namespace" | "payload" - >; + interaction: + | Omit + | Omit; }; export async function dispatchSlackPluginInteractiveHandler(params: { diff --git a/extensions/slack/src/modal-metadata.ts b/extensions/slack/src/modal-metadata.ts index 5ce3201c262..f53f7056389 100644 --- a/extensions/slack/src/modal-metadata.ts +++ b/extensions/slack/src/modal-metadata.ts @@ -5,6 +5,7 @@ type SlackModalPrivateMetadata = { channelId?: string; channelType?: string; userId?: string; + pluginInteractiveData?: string; }; const SLACK_PRIVATE_METADATA_MAX = 3000; @@ -20,6 +21,7 @@ export function parseSlackModalPrivateMetadata(raw: unknown): SlackModalPrivateM channelId: normalizeOptionalString(parsed.channelId), channelType: normalizeOptionalString(parsed.channelType), userId: normalizeOptionalString(parsed.userId), + pluginInteractiveData: normalizeOptionalString(parsed.pluginInteractiveData), }; } catch { return {}; @@ -32,6 +34,7 @@ export function encodeSlackModalPrivateMetadata(input: SlackModalPrivateMetadata ...(input.channelId ? { channelId: input.channelId } : {}), ...(input.channelType ? { channelType: input.channelType } : {}), ...(input.userId ? { userId: input.userId } : {}), + ...(input.pluginInteractiveData ? { pluginInteractiveData: input.pluginInteractiveData } : {}), }; const encoded = JSON.stringify(payload); if (encoded.length > SLACK_PRIVATE_METADATA_MAX) { diff --git a/extensions/slack/src/monitor/events/interactions.modal.ts b/extensions/slack/src/monitor/events/interactions.modal.ts index 185b15b07af..4f12226536a 100644 --- a/extensions/slack/src/monitor/events/interactions.modal.ts +++ b/extensions/slack/src/monitor/events/interactions.modal.ts @@ -1,4 +1,5 @@ import { enqueueSystemEvent } from "openclaw/plugin-sdk/system-event-runtime"; +import { dispatchSlackPluginInteractiveHandler } from "../../interactive-dispatch.js"; import { parseSlackModalPrivateMetadata } from "../../modal-metadata.js"; import { authorizeSlackSystemEventSender } from "../auth.js"; import type { SlackMonitorContext } from "../context.js"; @@ -28,6 +29,7 @@ export type ModalInputSummary = { type SlackModalBody = { user?: { id?: string }; team?: { id?: string }; + trigger_id?: string; view?: { id?: string; callback_id?: string; @@ -47,6 +49,7 @@ type SlackModalEventBase = { expectedUserId?: string; viewId?: string; sessionRouting: ReturnType; + stateValues?: unknown; payload: { actionId: string; callbackId: string; @@ -73,6 +76,68 @@ export type RegisterSlackModalHandler = ( ) => void; type SlackInteractionContextPrefix = "slack:interaction:view" | "slack:interaction:view-closed"; +const OPENCLAW_MODAL_CALLBACK_PREFIX = "openclaw:"; + +function resolveSlackModalPluginInteractiveData(params: { + callbackId: string; + metadata: ReturnType; +}): string | undefined { + const metadataData = params.metadata.pluginInteractiveData?.trim(); + if (metadataData) { + return metadataData; + } + if (!params.callbackId.startsWith(OPENCLAW_MODAL_CALLBACK_PREFIX)) { + return undefined; + } + const callbackData = params.callbackId.slice(OPENCLAW_MODAL_CALLBACK_PREFIX.length).trim(); + return callbackData || undefined; +} + +function shouldHandleSlackModalLifecycleBody(body: unknown): boolean { + const typed = body as SlackModalBody; + const callbackId = typed.view?.callback_id ?? ""; + if (callbackId.startsWith(OPENCLAW_MODAL_CALLBACK_PREFIX)) { + return true; + } + const metadata = parseSlackModalPrivateMetadata(typed.view?.private_metadata); + return Boolean(metadata.pluginInteractiveData?.trim()); +} + +function resolveSlackModalPluginNamespace(data: string | undefined): string | undefined { + if (!data) { + return undefined; + } + const separatorIndex = data.indexOf(":"); + return separatorIndex >= 0 ? data.slice(0, separatorIndex) : data; +} + +function resolveSlackPluginSystemEventPayload( + result: unknown, +): Record | undefined { + if (!result || typeof result !== "object") { + return undefined; + } + const systemEvent = (result as { systemEvent?: unknown }).systemEvent; + if (!systemEvent || typeof systemEvent !== "object") { + return undefined; + } + const typed = systemEvent as { + summary?: unknown; + reference?: unknown; + data?: unknown; + }; + const output: Record = {}; + if (typeof typed.summary === "string" && typed.summary.trim()) { + output.summary = typed.summary; + } + if (typeof typed.reference === "string" && typed.reference.trim()) { + output.reference = typed.reference; + } + if (typed.data && typeof typed.data === "object" && !Array.isArray(typed.data)) { + output.data = typed.data; + } + return Object.keys(output).length > 0 ? output : undefined; +} function resolveModalSessionRouting(params: { ctx: SlackMonitorContext; @@ -149,6 +214,7 @@ function resolveSlackModalEventBase(params: { expectedUserId: metadata.userId, viewId, sessionRouting, + stateValues: params.body.view?.state?.values, payload: { actionId: `view:${callbackId}`, callbackId, @@ -169,6 +235,75 @@ function resolveSlackModalEventBase(params: { }; } +async function dispatchSlackModalPluginInteractiveHandler(params: { + ctx: SlackMonitorContext; + body: SlackModalBody; + interactionType: SlackModalInteractionKind; + data: string | undefined; + auth: { isAuthorizedSender: boolean }; + payload: SlackModalEventBase["payload"]; + stateValues?: unknown; + sessionRouting: SlackModalEventBase["sessionRouting"]; +}): Promise<{ + matched: boolean; + handled: boolean; + duplicate: boolean; + namespace?: string; + systemEvent?: Record; +}> { + if (!params.data) { + return { matched: false, handled: false, duplicate: false }; + } + + const isViewClosed = params.interactionType === "view_closed"; + const interactionId = [ + params.interactionType, + params.payload.callbackId, + params.payload.viewId, + params.payload.userId, + ] + .filter(Boolean) + .join(":"); + const result = await dispatchSlackPluginInteractiveHandler({ + data: params.data, + interactionId, + ctx: { + accountId: params.ctx.accountId, + interactionId, + conversationId: params.sessionRouting.channelId ?? "", + parentConversationId: undefined, + threadId: undefined, + senderId: params.payload.userId, + senderUsername: undefined, + auth: params.auth, + interaction: { + kind: params.interactionType, + callbackId: params.payload.callbackId, + viewId: params.payload.viewId, + rootViewId: params.payload.rootViewId, + previousViewId: params.payload.previousViewId, + externalId: params.payload.externalId, + isStackedView: params.payload.isStackedView, + isCleared: isViewClosed ? params.body.is_cleared === true : undefined, + inputs: params.payload.inputs, + stateValues: params.stateValues, + triggerId: params.body.trigger_id, + }, + }, + respond: { + acknowledge: async () => {}, + reply: async () => {}, + followUp: async () => {}, + editMessage: async () => {}, + }, + }); + return { + ...result, + namespace: result.matched ? resolveSlackModalPluginNamespace(params.data) : undefined, + systemEvent: result.matched ? resolveSlackPluginSystemEventPayload(result.result) : undefined, + }; +} + async function emitSlackModalLifecycleEvent(params: { ctx: SlackMonitorContext; body: SlackModalBody; @@ -177,12 +312,17 @@ async function emitSlackModalLifecycleEvent(params: { summarizeViewState: (values: unknown) => ModalInputSummary[]; formatSystemEvent: (payload: Record) => string; }): Promise { - const { callbackId, userId, expectedUserId, viewId, sessionRouting, payload } = + const { callbackId, userId, expectedUserId, viewId, sessionRouting, stateValues, payload } = resolveSlackModalEventBase({ ctx: params.ctx, body: params.body, summarizeViewState: params.summarizeViewState, }); + const metadata = parseSlackModalPrivateMetadata(params.body.view?.private_metadata); + const pluginInteractiveData = resolveSlackModalPluginInteractiveData({ + callbackId, + metadata, + }); const isViewClosed = params.interactionType === "view_closed"; const isCleared = params.body.is_cleared === true; const eventPayload = isViewClosed @@ -207,6 +347,24 @@ async function emitSlackModalLifecycleEvent(params: { } if (!expectedUserId) { + if (pluginInteractiveData) { + try { + await dispatchSlackModalPluginInteractiveHandler({ + ctx: params.ctx, + body: params.body, + interactionType: params.interactionType, + data: pluginInteractiveData, + auth: { isAuthorizedSender: false }, + payload, + stateValues, + sessionRouting, + }); + } catch (error) { + params.ctx.runtime.log?.( + `slack:interaction modal plugin dispatch failed callback=${callbackId} error=${error instanceof Error ? error.message : String(error)}`, + ); + } + } params.ctx.runtime.log?.( `slack:interaction drop modal callback=${callbackId} user=${userId} reason=missing-expected-user`, ); @@ -228,7 +386,37 @@ async function emitSlackModalLifecycleEvent(params: { return; } - enqueueSystemEvent(params.formatSystemEvent(eventPayload), { + let pluginDispatch: + | Awaited> + | undefined; + try { + pluginDispatch = await dispatchSlackModalPluginInteractiveHandler({ + ctx: params.ctx, + body: params.body, + interactionType: params.interactionType, + data: pluginInteractiveData, + auth: { isAuthorizedSender: auth.allowed }, + payload, + stateValues, + sessionRouting, + }); + } catch (error) { + params.ctx.runtime.log?.( + `slack:interaction modal plugin dispatch failed callback=${callbackId} error=${error instanceof Error ? error.message : String(error)}`, + ); + } + + const pluginEventFields = + pluginDispatch?.matched === true + ? { + pluginHandled: pluginDispatch.handled, + pluginNamespace: pluginDispatch.namespace, + pluginDuplicate: pluginDispatch.duplicate || undefined, + pluginSystemEvent: pluginDispatch.systemEvent, + } + : {}; + + enqueueSystemEvent(params.formatSystemEvent({ ...eventPayload, ...pluginEventFields }), { sessionKey: sessionRouting.sessionKey, contextKey: [params.contextPrefix, callbackId, viewId, userId].filter(Boolean).join(":"), }); @@ -245,6 +433,9 @@ export function registerModalLifecycleHandler(params: { formatSystemEvent: (payload: Record) => string; }) { params.register(params.matcher, async ({ ack, body }: SlackModalEventHandlerArgs) => { + if (!shouldHandleSlackModalLifecycleBody(body)) { + return; + } await ack(); if (params.ctx.shouldDropMismatchedSlackEvent?.(body)) { params.ctx.runtime.log?.( diff --git a/extensions/slack/src/monitor/events/interactions.test.ts b/extensions/slack/src/monitor/events/interactions.test.ts index 0aac8863359..fd38f00eda2 100644 --- a/extensions/slack/src/monitor/events/interactions.test.ts +++ b/extensions/slack/src/monitor/events/interactions.test.ts @@ -6,6 +6,7 @@ type DispatchPluginInteractiveHandlerResult = { matched: boolean; handled: boolean; duplicate: boolean; + result?: unknown; }; const dispatchPluginInteractiveHandlerMock = vi.hoisted(() => vi.fn<(arg: unknown) => Promise>(async () => ({ @@ -144,6 +145,7 @@ type RegisteredViewHandler = (args: { body: { user?: { id?: string }; team?: { id?: string }; + trigger_id?: string; view?: { id?: string; callback_id?: string; @@ -162,6 +164,7 @@ type RegisteredViewClosedHandler = (args: { body: { user?: { id?: string }; team?: { id?: string }; + trigger_id?: string; view?: { id?: string; callback_id?: string; @@ -1380,6 +1383,30 @@ describe("registerSlackInteractionEvents", () => { expect(enqueueSystemEventMock).not.toHaveBeenCalled(); }); + it("does not ack unrelated modal lifecycle payloads", async () => { + enqueueSystemEventMock.mockClear(); + const { ctx, getViewHandler } = createContext(); + registerSlackInteractionEvents({ ctx: ctx as never }); + const viewHandler = getViewHandler(); + + const ack = vi.fn().mockResolvedValue(undefined); + await viewHandler({ + ack, + body: { + user: { id: "U123" }, + team: { id: "T9" }, + view: { + id: "V123", + callback_id: "third_party_modal", + }, + }, + }); + + expect(ack).not.toHaveBeenCalled(); + expect(enqueueSystemEventMock).not.toHaveBeenCalled(); + expect(dispatchPluginInteractiveHandlerMock).not.toHaveBeenCalled(); + }); + it("captures select values and updates action rows for non-button actions", async () => { enqueueSystemEventMock.mockClear(); const { ctx, app, getHandler } = createContext(); @@ -2181,6 +2208,252 @@ describe("registerSlackInteractionEvents", () => { expect(trackEvent).toHaveBeenCalledTimes(1); }); + it("dispatches plugin-owned modal submissions with full view state before compacting events", async () => { + enqueueSystemEventMock.mockClear(); + dispatchPluginInteractiveHandlerMock.mockResolvedValueOnce({ + matched: true, + handled: true, + duplicate: false, + result: { + systemEvent: { + summary: "Contract form stored", + reference: "contract-submission-123", + }, + }, + }); + const { ctx, getViewHandler } = createContext(); + registerSlackInteractionEvents({ ctx: ctx as never }); + const viewHandler = getViewHandler(); + const values: Record>> = {}; + for (let index = 0; index < 8; index += 1) { + values[`field_block_${index}`] = { + [`field_${index}`]: { + type: "plain_text_input", + value: `value-${index}-${"x".repeat(500)}`, + }, + }; + } + + const ack = vi.fn().mockResolvedValue(undefined); + await viewHandler({ + ack, + body: { + user: { id: "U777" }, + team: { id: "T1" }, + trigger_id: "trigger-777", + view: { + id: "V777", + callback_id: "openclaw:contract_confirm_hearing", + private_metadata: JSON.stringify({ + channelId: "D777", + channelType: "im", + userId: "U777", + pluginInteractiveData: "dean.contract:confirm_hearing", + }), + state: { + values, + }, + }, + }, + }); + + expect(ack).toHaveBeenCalled(); + const dispatchCall = mockCallArg( + dispatchPluginInteractiveHandlerMock, + 0, + "plugin interactive dispatcher", + ) as + | { + channel?: string; + data?: string; + dedupeId?: string; + invoke?: (params: { + registration: { handler: (ctx: unknown) => unknown }; + namespace: string; + payload: string; + }) => Promise; + } + | undefined; + expectRecordFields(requireRecord(dispatchCall, "dispatch call"), { + channel: "slack", + data: "dean.contract:confirm_hearing", + dedupeId: "view_submission:openclaw:contract_confirm_hearing:V777:U777", + }); + + const registrationHandler = vi.fn(); + await dispatchCall?.invoke?.({ + registration: { handler: registrationHandler }, + namespace: "dean.contract", + payload: "confirm_hearing", + }); + const registrationCtx = requireRecord( + mockCallArg(registrationHandler, 0, "registration handler"), + "registration handler ctx", + ); + expectRecordFields(registrationCtx, { + accountId: ctx.accountId, + conversationId: "D777", + senderId: "U777", + }); + expect(requireRecord(registrationCtx.auth, "registration auth").isAuthorizedSender).toBe(true); + const interaction = requireRecord(registrationCtx.interaction, "registration interaction") as { + inputs?: unknown[]; + stateValues?: unknown; + }; + expectRecordFields(interaction, { + kind: "view_submission", + data: "dean.contract:confirm_hearing", + namespace: "dean.contract", + payload: "confirm_hearing", + callbackId: "openclaw:contract_confirm_hearing", + viewId: "V777", + triggerId: "trigger-777", + }); + expect(interaction.inputs).toHaveLength(8); + expect(interaction.stateValues).toEqual(values); + + expect(enqueueSystemEventMock).toHaveBeenCalledTimes(1); + const eventText = enqueueSystemEventText(); + expect(eventText.length).toBeLessThanOrEqual(2400); + const payload = JSON.parse(eventText.replace("Slack interaction: ", "")) as { + pluginHandled?: boolean; + pluginNamespace?: string; + pluginSystemEvent?: { summary?: string; reference?: string }; + inputs?: unknown[]; + inputsOmitted?: number; + payloadTruncated?: boolean; + }; + expectRecordFields(payload as unknown as Record, { + pluginHandled: true, + pluginNamespace: "dean.contract", + }); + expect(payload.pluginSystemEvent).toEqual({ + summary: "Contract form stored", + reference: "contract-submission-123", + }); + expect(Array.isArray(payload.inputs) ? payload.inputs.length : 0).toBeLessThanOrEqual(3); + expect(payload.inputsOmitted).toBe(5); + expect(payload.payloadTruncated).toBe(true); + }); + + it("dispatches callback-id-only plugin modal submissions without agent routing metadata", async () => { + enqueueSystemEventMock.mockClear(); + dispatchPluginInteractiveHandlerMock.mockResolvedValueOnce({ + matched: true, + handled: true, + duplicate: false, + }); + const { ctx, getViewHandler } = createContext(); + registerSlackInteractionEvents({ ctx: ctx as never }); + const viewHandler = getViewHandler(); + + const ack = vi.fn().mockResolvedValue(undefined); + await viewHandler({ + ack, + body: { + user: { id: "U777" }, + view: { + id: "V778", + callback_id: "openclaw:dean.contract:confirm_hearing", + state: { + values: { + contract: { + name: { type: "plain_text_input", value: "Ari" }, + }, + }, + }, + }, + }, + }); + + expect(ack).toHaveBeenCalled(); + const dispatchCall = mockCallArg( + dispatchPluginInteractiveHandlerMock, + 0, + "plugin interactive dispatcher", + ) as + | { + channel?: string; + data?: string; + dedupeId?: string; + invoke?: (params: { + registration: { handler: (ctx: unknown) => unknown }; + namespace: string; + payload: string; + }) => Promise; + } + | undefined; + expectRecordFields(requireRecord(dispatchCall, "dispatch call"), { + channel: "slack", + data: "dean.contract:confirm_hearing", + dedupeId: "view_submission:openclaw:dean.contract:confirm_hearing:V778:U777", + }); + + const registrationHandler = vi.fn(); + await dispatchCall?.invoke?.({ + registration: { handler: registrationHandler }, + namespace: "dean.contract", + payload: "confirm_hearing", + }); + const registrationCtx = requireRecord( + mockCallArg(registrationHandler, 0, "registration handler"), + "registration handler ctx", + ); + expect(requireRecord(registrationCtx.auth, "registration auth").isAuthorizedSender).toBe(false); + expectRecordFields(requireRecord(registrationCtx.interaction, "registration interaction"), { + kind: "view_submission", + data: "dean.contract:confirm_hearing", + namespace: "dean.contract", + payload: "confirm_hearing", + callbackId: "openclaw:dean.contract:confirm_hearing", + viewId: "V778", + }); + expect(enqueueSystemEventMock).not.toHaveBeenCalled(); + }); + + it("dispatches metadata-routed plugin modal submissions with non-openclaw callback ids", async () => { + enqueueSystemEventMock.mockClear(); + dispatchPluginInteractiveHandlerMock.mockResolvedValueOnce({ + matched: true, + handled: true, + duplicate: false, + }); + const { ctx, getViewHandler } = createContext(); + registerSlackInteractionEvents({ ctx: ctx as never }); + const viewHandler = getViewHandler(); + + const ack = vi.fn().mockResolvedValue(undefined); + await viewHandler({ + ack, + body: { + user: { id: "U777" }, + view: { + id: "V779", + callback_id: "contract_confirm_hearing", + private_metadata: JSON.stringify({ + channelId: "D777", + channelType: "im", + userId: "U777", + pluginInteractiveData: "dean.contract:confirm_hearing", + }), + }, + }, + }); + + expect(ack).toHaveBeenCalled(); + const dispatchCall = mockCallArg( + dispatchPluginInteractiveHandlerMock, + 0, + "plugin interactive dispatcher", + ) as { channel?: string; data?: string; dedupeId?: string } | undefined; + expectRecordFields(requireRecord(dispatchCall, "dispatch call"), { + channel: "slack", + data: "dean.contract:confirm_hearing", + dedupeId: "view_submission:contract_confirm_hearing:V779:U777", + }); + expect(enqueueSystemEventMock).toHaveBeenCalledTimes(1); + }); + it("blocks modal events when private metadata userId does not match submitter", async () => { enqueueSystemEventMock.mockClear(); const { ctx, getViewHandler } = createContext(); diff --git a/extensions/slack/src/monitor/events/interactions.ts b/extensions/slack/src/monitor/events/interactions.ts index 6cdfa3f7c66..58628ca6499 100644 --- a/extensions/slack/src/monitor/events/interactions.ts +++ b/extensions/slack/src/monitor/events/interactions.ts @@ -7,8 +7,6 @@ import { type RegisterSlackModalHandler, } from "./interactions.modal.js"; -// Prefix for OpenClaw-generated action IDs to scope our handler -const OPENCLAW_ACTION_PREFIX = "openclaw:"; const SLACK_INTERACTION_EVENT_PREFIX = "Slack interaction: "; const REDACTED_INTERACTION_VALUE = "[redacted]"; const SLACK_INTERACTION_EVENT_MAX_CHARS = 2400; @@ -114,6 +112,10 @@ function buildCompactSlackInteractionPayload( selectedDateTime: payload.selectedDateTime, workflowId: payload.workflowId, routedChannelType: payload.routedChannelType, + pluginHandled: payload.pluginHandled, + pluginNamespace: payload.pluginNamespace, + pluginDuplicate: payload.pluginDuplicate, + pluginSystemEvent: payload.pluginSystemEvent, inputs: compactInputs.length > 0 ? compactInputs : undefined, inputsOmitted: rawInputs.length > SLACK_INTERACTION_COMPACT_INPUTS_MAX_ITEMS @@ -189,9 +191,9 @@ export function registerSlackInteractionEvents(params: { if (typeof ctx.app.view !== "function") { return; } - const modalMatcher = new RegExp(`^${OPENCLAW_ACTION_PREFIX}`); + const modalMatcher = /.*/; - // Handle OpenClaw modal submissions with callback_ids scoped by our prefix. + // Handle OpenClaw-routed modals; metadata/auth checks below drop unrelated payloads. registerModalLifecycleHandler({ register: (matcher, handler) => ctx.app.view(matcher, handler), matcher: modalMatcher, diff --git a/src/plugins/interactive.ts b/src/plugins/interactive.ts index e42bb02f426..d303f90688b 100644 --- a/src/plugins/interactive.ts +++ b/src/plugins/interactive.ts @@ -6,9 +6,9 @@ import { type RegisteredInteractiveHandler, } from "./interactive-state.js"; -type InteractiveDispatchResult = +type InteractiveDispatchResult = | { matched: false; handled: false; duplicate: false } - | { matched: true; handled: boolean; duplicate: boolean }; + | { matched: true; handled: boolean; duplicate: boolean; result?: TResult }; type PluginInteractiveDispatchRegistration = { channel: string; @@ -30,15 +30,14 @@ export type { InteractiveRegistrationResult } from "./interactive-registry.js"; export async function dispatchPluginInteractiveHandler< TRegistration extends PluginInteractiveDispatchRegistration, + TResult extends { handled?: boolean } | void = { handled?: boolean } | void, >(params: { channel: TRegistration["channel"]; data: string; dedupeId?: string; onMatched?: () => Promise | void; - invoke: ( - match: PluginInteractiveMatch, - ) => Promise<{ handled?: boolean } | void> | { handled?: boolean } | void; -}): Promise { + invoke: (match: PluginInteractiveMatch) => Promise | TResult; +}): Promise> { const match = resolvePluginInteractiveNamespaceMatch(params.channel, params.data); if (!match) { return { matched: false, handled: false, duplicate: false }; @@ -55,11 +54,16 @@ export async function dispatchPluginInteractiveHandler< if (dedupeKey) { commitPluginInteractiveCallbackDedupe(dedupeKey); } + const shouldExposeResult = + !!resolved && + typeof resolved === "object" && + Object.keys(resolved as Record).some((key) => key !== "handled"); return { matched: true, handled: resolved?.handled ?? true, duplicate: false, + ...(shouldExposeResult ? { result: resolved } : {}), }; } catch (error) { if (dedupeKey) {