mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-18 21:04:45 +00:00
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:
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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",
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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?.(
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user