mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-20 17:41:33 -06:00
fix(slack): deliver modal submissions and close events reliably (#118123)
This commit is contained in:
committed by
GitHub
parent
dd8e07c11f
commit
0cd7d433bb
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user