From 0cd7d433bbf69e5b5eb5487654d5a436a2d8239f Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 2 Aug 2026 12:29:59 -0700 Subject: [PATCH] fix(slack): deliver modal submissions and close events reliably (#118123) --- .../src/monitor/events/interactions.modal.ts | 32 +++- .../src/monitor/events/interactions.test.ts | 162 +++++++++++++++--- .../slack/src/monitor/events/interactions.ts | 52 ++---- 3 files changed, 180 insertions(+), 66 deletions(-) diff --git a/extensions/slack/src/monitor/events/interactions.modal.ts b/extensions/slack/src/monitor/events/interactions.modal.ts index 1e718a871d07..4458a512b754 100644 --- a/extensions/slack/src/monitor/events/interactions.modal.ts +++ b/extensions/slack/src/monitor/events/interactions.modal.ts @@ -1,4 +1,5 @@ // Slack plugin module implements interactions.modal behavior. +import { requestHeartbeat } from "openclaw/plugin-sdk/heartbeat-runtime"; import { enqueueSystemEvent } from "openclaw/plugin-sdk/system-event-runtime"; import { dispatchSlackPluginInteractiveHandler } from "../../interactive-dispatch.js"; import { parseSlackModalPrivateMetadata } from "../../modal-metadata.js"; @@ -50,7 +51,7 @@ type SlackModalEventBase = { type SlackModalInteractionKind = "view_submission" | "view_closed"; type SlackModalEventHandlerArgs = { ack: () => Promise; body: unknown }; -export type RegisterSlackModalHandler = ( +type RegisterSlackModalHandler = ( matcher: RegExp, handler: (args: SlackModalEventHandlerArgs) => Promise, ) => void; @@ -396,10 +397,31 @@ async function emitSlackModalLifecycleEvent(params: { } : {}; - enqueueSystemEvent(params.formatSystemEvent({ ...eventPayload, ...pluginEventFields }), { - sessionKey: sessionRouting.sessionKey, - contextKey: [params.contextPrefix, callbackId, viewId, userId].filter(Boolean).join(":"), - }); + const queued = enqueueSystemEvent( + params.formatSystemEvent({ ...eventPayload, ...pluginEventFields }), + { + sessionKey: sessionRouting.sessionKey, + contextKey: [params.contextPrefix, callbackId, viewId, userId].filter(Boolean).join(":"), + deliveryContext: { + channel: "slack", + ...(auth.channelType === "im" + ? { to: `user:${userId}` } + : sessionRouting.channelId + ? { to: `channel:${sessionRouting.channelId}` } + : {}), + accountId: params.ctx.accountId, + }, + }, + ); + if (queued) { + requestHeartbeat({ + source: "hook", + intent: "immediate", + reason: "hook:slack-interaction", + sessionKey: sessionRouting.sessionKey, + heartbeat: { target: "last" }, + }); + } } export function registerModalLifecycleHandler(params: { diff --git a/extensions/slack/src/monitor/events/interactions.test.ts b/extensions/slack/src/monitor/events/interactions.test.ts index da46e42a8bbe..90772530c553 100644 --- a/extensions/slack/src/monitor/events/interactions.test.ts +++ b/extensions/slack/src/monitor/events/interactions.test.ts @@ -166,25 +166,6 @@ type RegisteredHandler = (args: { }) => Promise; type RegisteredViewHandler = (args: { - ack: () => Promise; - body: { - user?: { id?: string }; - team?: { id?: string }; - trigger_id?: string; - view?: { - id?: string; - callback_id?: string; - private_metadata?: string; - root_view_id?: string; - previous_view_id?: string; - external_id?: string; - hash?: string; - state?: { values?: Record>> }; - }; - }; -}) => Promise; - -type RegisteredViewClosedHandler = (args: { ack: () => Promise; body: { user?: { id?: string }; @@ -231,19 +212,25 @@ function createContext(overrides?: { let handler: RegisteredHandler | null = null; let actionMatcher: RegExp | null = null; let viewHandler: RegisteredViewHandler | null = null; - let viewClosedHandler: RegisteredViewClosedHandler | null = null; + let viewClosedHandler: RegisteredViewHandler | null = null; let shortcutHandler: RegisteredShortcutHandler | null = null; const app = { action: vi.fn((matcher: RegExp, next: RegisteredHandler) => { actionMatcher = matcher; handler = next; }), - view: vi.fn((_matcher: RegExp, next: RegisteredViewHandler) => { - viewHandler = next; - }), - viewClosed: vi.fn((_matcher: RegExp, next: RegisteredViewClosedHandler) => { - viewClosedHandler = next; - }), + view: vi.fn( + ( + matcher: { callback_id: RegExp; type: "view_submission" | "view_closed" }, + next: RegisteredViewHandler, + ) => { + if (matcher.type === "view_submission") { + viewHandler = next; + } else { + viewClosedHandler = next; + } + }, + ), shortcut: vi.fn((_matcher: RegExp, next: RegisteredShortcutHandler) => { shortcutHandler = next; }), @@ -451,6 +438,25 @@ describe("registerSlackInteractionEvents", () => { }); }); + it("registers modal submission and close handlers through Bolt's supported view API", () => { + const { ctx, app, getViewHandler, getViewClosedHandler } = createContext(); + + registerSlackInteractionEvents({ ctx: ctx as never }); + + expect(app.view).toHaveBeenCalledTimes(2); + expect(app.view).toHaveBeenNthCalledWith( + 1, + { callback_id: expect.any(RegExp), type: "view_submission" }, + expect.any(Function), + ); + expect(app.view).toHaveBeenNthCalledWith( + 2, + { callback_id: expect.any(RegExp), type: "view_closed" }, + expect.any(Function), + ); + expect(getViewHandler()).not.toBe(getViewClosedHandler()); + }); + it("routes global shortcuts to the actor's direct session", async () => { const { ctx, getShortcutHandler, resolveSessionKey } = createContext(); const trackEvent = vi.fn(); @@ -3054,6 +3060,21 @@ describe("registerSlackInteractionEvents", () => { senderId: "U777", }); expect(enqueueSystemEventMock).toHaveBeenCalledTimes(1); + expect(mockCallArg(enqueueSystemEventMock, 0, "enqueueSystemEvent", 1)).toMatchObject({ + sessionKey: "agent:ops:slack:channel:C1", + deliveryContext: { + channel: "slack", + to: "user:U777", + accountId: "default", + }, + }); + expect(requestHeartbeatMock).toHaveBeenCalledWith({ + source: "hook", + intent: "immediate", + reason: "hook:slack-interaction", + sessionKey: "agent:ops:slack:channel:C1", + heartbeat: { target: "last" }, + }); const eventText = enqueueSystemEventText(); const payload = JSON.parse(eventText.replace("Slack interaction: ", "")) as { interactionType: string; @@ -3089,6 +3110,71 @@ describe("registerSlackInteractionEvents", () => { expect(trackEvent).toHaveBeenCalledTimes(1); }); + it.each(["view_submission", "view_closed"] as const)( + "routes accepted %s events back to their authorized Slack channel", + async (interactionType) => { + const { ctx, getViewHandler, getViewClosedHandler } = createContext(); + registerSlackInteractionEvents({ ctx: ctx as never }); + const handleView = + interactionType === "view_submission" ? getViewHandler() : getViewClosedHandler(); + + await handleView({ + ack: vi.fn().mockResolvedValue(undefined), + body: { + user: { id: "U777" }, + view: { + id: "V777", + callback_id: "openclaw:deploy_form", + private_metadata: JSON.stringify({ + channelId: "C777", + channelType: "channel", + userId: "U777", + }), + }, + }, + }); + + expect(mockCallArg(enqueueSystemEventMock, 0, "enqueueSystemEvent", 1)).toMatchObject({ + deliveryContext: { + channel: "slack", + to: "channel:C777", + accountId: "default", + }, + }); + expect(requestHeartbeatMock).toHaveBeenCalledOnce(); + }, + ); + + it.each(["view_submission", "view_closed"] as const)( + "does not wake the agent when a duplicate %s event is rejected", + async (interactionType) => { + enqueueSystemEventMock.mockReturnValue(false); + const { ctx, getViewHandler, getViewClosedHandler } = createContext(); + registerSlackInteractionEvents({ ctx: ctx as never }); + const handleView = + interactionType === "view_submission" ? getViewHandler() : getViewClosedHandler(); + + await handleView({ + ack: vi.fn().mockResolvedValue(undefined), + body: { + user: { id: "U777" }, + view: { + id: "V777", + callback_id: "openclaw:deploy_form", + private_metadata: JSON.stringify({ + channelId: "D777", + channelType: "im", + userId: "U777", + }), + }, + }, + }); + + expect(enqueueSystemEventMock).toHaveBeenCalledOnce(); + expect(requestHeartbeatMock).not.toHaveBeenCalled(); + }, + ); + it("dispatches plugin-owned modal submissions with full view state before compacting events", async () => { enqueueSystemEventMock.mockClear(); dispatchPluginInteractiveHandlerMock.mockResolvedValueOnce({ @@ -3359,6 +3445,7 @@ describe("registerSlackInteractionEvents", () => { expect(ack).toHaveBeenCalled(); expect(enqueueSystemEventMock).not.toHaveBeenCalled(); + expect(requestHeartbeatMock).not.toHaveBeenCalled(); }); it("blocks modal events when private metadata is missing userId", async () => { @@ -3384,6 +3471,7 @@ describe("registerSlackInteractionEvents", () => { expect(ack).toHaveBeenCalled(); expect(enqueueSystemEventMock).not.toHaveBeenCalled(); + expect(requestHeartbeatMock).not.toHaveBeenCalled(); }); it("keeps no-channel modal events open when allowFrom is unset", async () => { @@ -3410,6 +3498,18 @@ describe("registerSlackInteractionEvents", () => { expect(ack).toHaveBeenCalled(); expect(enqueueSystemEventMock).toHaveBeenCalledTimes(1); + expect(mockCallArg(enqueueSystemEventMock, 0, "enqueueSystemEvent", 1)).toMatchObject({ + deliveryContext: { channel: "slack", accountId: "default" }, + }); + const deliveryContext = requireRecord( + requireRecord( + mockCallArg(enqueueSystemEventMock, 0, "enqueueSystemEvent", 1), + "event options", + ).deliveryContext, + "delivery context", + ); + expect(deliveryContext).not.toHaveProperty("to"); + expect(requestHeartbeatMock).toHaveBeenCalledOnce(); }); it("captures modal input labels and picker values across block types", async () => { @@ -3748,6 +3848,16 @@ describe("registerSlackInteractionEvents", () => { ).toEqual(["canary"]); expect(trackEvent).toHaveBeenCalledTimes(1); expect(options.sessionKey).toBe("agent:main:slack:channel:C99"); + expect(options).toMatchObject({ + deliveryContext: { channel: "slack", accountId: "default" }, + }); + expect(requestHeartbeatMock).toHaveBeenCalledWith({ + source: "hook", + intent: "immediate", + reason: "hook:slack-interaction", + sessionKey: "agent:main:slack:channel:C99", + heartbeat: { target: "last" }, + }); }); it("defaults modal close isCleared to false when Slack omits the flag", async () => { diff --git a/extensions/slack/src/monitor/events/interactions.ts b/extensions/slack/src/monitor/events/interactions.ts index 30ddb8a1e905..6a5c2dc3220c 100644 --- a/extensions/slack/src/monitor/events/interactions.ts +++ b/extensions/slack/src/monitor/events/interactions.ts @@ -2,10 +2,7 @@ import { truncateSlackText } from "../../truncate.js"; import type { SlackMonitorContext } from "../context.js"; import { registerSlackBlockActionHandler, summarizeAction } from "./interactions.block-actions.js"; -import { - registerModalLifecycleHandler, - type RegisterSlackModalHandler, -} from "./interactions.modal.js"; +import { registerModalLifecycleHandler } from "./interactions.modal.js"; import { registerSlackShortcutHandler } from "./interactions.shortcuts.js"; import type { ModalInputSummary } from "./modal-input-summary.js"; @@ -203,36 +200,21 @@ export function registerSlackInteractionEvents(params: { } const modalMatcher = /.*/; - // Handle OpenClaw-routed modals; metadata/auth checks below drop unrelated payloads. - registerModalLifecycleHandler({ - register: (matcher, handler) => ctx.app.view(matcher, handler), - matcher: modalMatcher, - ctx, - trackEvent, - interactionType: "view_submission", - contextPrefix: "slack:interaction:view", - summarizeViewState, - formatSystemEvent: formatSlackInteractionSystemEvent, - }); - - const viewClosed = ( - ctx.app as unknown as { - viewClosed?: RegisterSlackModalHandler; - } - ).viewClosed; - if (typeof viewClosed !== "function") { - return; + // Bolt routes both modal lifecycles through view constraints; there is no viewClosed API. + for (const [interactionType, contextPrefix] of [ + ["view_submission", "slack:interaction:view"], + ["view_closed", "slack:interaction:view-closed"], + ] as const) { + registerModalLifecycleHandler({ + register: (matcher, handler) => + ctx.app.view({ callback_id: matcher, type: interactionType }, handler), + matcher: modalMatcher, + ctx, + trackEvent, + interactionType, + contextPrefix, + summarizeViewState, + formatSystemEvent: formatSlackInteractionSystemEvent, + }); } - - // Handle modal close events so agent workflows can react to cancelled forms. - registerModalLifecycleHandler({ - register: viewClosed, - matcher: modalMatcher, - ctx, - trackEvent, - interactionType: "view_closed", - contextPrefix: "slack:interaction:view-closed", - summarizeViewState, - formatSystemEvent: formatSlackInteractionSystemEvent, - }); }