fix(slack): route plugin modal submissions

Co-authored-by: shannon0430 <258282406+shannon0430@users.noreply.github.com>

Co-authored-by: Vincent Koc <25068+vincentkoc@users.noreply.github.com>

Co-authored-by: Jin Kim <198280395+jink-ucla@users.noreply.github.com>
This commit is contained in:
Peter Steinberger
2026-05-15 13:00:35 +01:00
parent cda4689d71
commit d89732efca
9 changed files with 560 additions and 32 deletions

View File

@@ -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

View File

@@ -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:<namespace>:<payload>`.
- Or keep an existing `callback_id` and put `pluginInteractiveData:
"<namespace>:<payload>"` 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.

View File

@@ -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",
});
});

View File

@@ -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<string, unknown>;
};
} | 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<void>;
reply: (params: { text: string; responseType?: "ephemeral" | "in_channel" }) => Promise<void>;
@@ -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<SlackBlockInteractivePayload, "data" | "namespace" | "payload">
| Omit<SlackModalInteractivePayload, "data" | "namespace" | "payload">;
};
export async function dispatchSlackPluginInteractiveHandler(params: {

View File

@@ -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) {

View File

@@ -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<typeof resolveModalSessionRouting>;
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<typeof parseSlackModalPrivateMetadata>;
}): 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<string, unknown> | 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<string, unknown> = {};
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<string, unknown>;
}> {
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, unknown>) => string;
}): Promise<void> {
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<ReturnType<typeof dispatchSlackModalPluginInteractiveHandler>>
| 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, unknown>) => string;
}) {
params.register(params.matcher, async ({ ack, body }: SlackModalEventHandlerArgs) => {
if (!shouldHandleSlackModalLifecycleBody(body)) {
return;
}
await ack();
if (params.ctx.shouldDropMismatchedSlackEvent?.(body)) {
params.ctx.runtime.log?.(

View File

@@ -6,6 +6,7 @@ type DispatchPluginInteractiveHandlerResult = {
matched: boolean;
handled: boolean;
duplicate: boolean;
result?: unknown;
};
const dispatchPluginInteractiveHandlerMock = vi.hoisted(() =>
vi.fn<(arg: unknown) => Promise<DispatchPluginInteractiveHandlerResult>>(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<string, Record<string, Record<string, unknown>>> = {};
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<unknown>;
}
| 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<string, unknown>, {
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<unknown>;
}
| 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();

View File

@@ -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,

View File

@@ -6,9 +6,9 @@ import {
type RegisteredInteractiveHandler,
} from "./interactive-state.js";
type InteractiveDispatchResult =
type InteractiveDispatchResult<TResult = unknown> =
| { 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> | void;
invoke: (
match: PluginInteractiveMatch<TRegistration>,
) => Promise<{ handled?: boolean } | void> | { handled?: boolean } | void;
}): Promise<InteractiveDispatchResult> {
invoke: (match: PluginInteractiveMatch<TRegistration>) => Promise<TResult> | TResult;
}): Promise<InteractiveDispatchResult<TResult>> {
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<string, unknown>).some((key) => key !== "handled");
return {
matched: true,
handled: resolved?.handled ?? true,
duplicate: false,
...(shouldExposeResult ? { result: resolved } : {}),
};
} catch (error) {
if (dedupeKey) {