fix(slack): deliver modal submissions and close events reliably (#118123)

This commit is contained in:
Peter Steinberger
2026-08-02 12:29:59 -07:00
committed by GitHub
parent dd8e07c11f
commit 0cd7d433bb
3 changed files with 180 additions and 66 deletions
@@ -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<void>; body: unknown };
export type RegisterSlackModalHandler = (
type RegisterSlackModalHandler = (
matcher: RegExp,
handler: (args: SlackModalEventHandlerArgs) => Promise<void>,
) => 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: {
@@ -166,25 +166,6 @@ type RegisteredHandler = (args: {
}) => Promise<void>;
type RegisteredViewHandler = (args: {
ack: () => Promise<void>;
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<string, Record<string, Record<string, unknown>>> };
};
};
}) => Promise<void>;
type RegisteredViewClosedHandler = (args: {
ack: () => Promise<void>;
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 () => {
@@ -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,
});
}