Slack: route modal interactions via private metadata

This commit is contained in:
Colin
2026-02-16 11:59:15 -05:00
committed by Peter Steinberger
parent d57cbcf713
commit bd17587b2a
2 changed files with 84 additions and 7 deletions

View File

@@ -202,6 +202,7 @@ describe("registerSlackInteractionEvents", () => {
view: {
id: "V123",
callback_id: "openclaw:deploy_form",
private_metadata: JSON.stringify({ channelId: "D123", channelType: "im" }),
state: {
values: {
env_block: {
@@ -226,7 +227,10 @@ describe("registerSlackInteractionEvents", () => {
});
expect(ack).toHaveBeenCalled();
expect(resolveSessionKey).toHaveBeenCalledWith({});
expect(resolveSessionKey).toHaveBeenCalledWith({
channelId: "D123",
channelType: "im",
});
expect(enqueueSystemEventMock).toHaveBeenCalledTimes(1);
const [eventText] = enqueueSystemEventMock.mock.calls[0] as [string];
const payload = JSON.parse(eventText.replace("Slack interaction: ", "")) as {
@@ -235,6 +239,7 @@ describe("registerSlackInteractionEvents", () => {
callbackId: string;
viewId: string;
userId: string;
routedChannelId?: string;
inputs: Array<{ actionId: string; selectedValues?: string[]; inputValue?: string }>;
};
expect(payload).toMatchObject({
@@ -243,6 +248,7 @@ describe("registerSlackInteractionEvents", () => {
callbackId: "openclaw:deploy_form",
viewId: "V123",
userId: "U777",
routedChannelId: "D123",
});
expect(payload.inputs).toEqual(
expect.arrayContaining([
@@ -269,7 +275,7 @@ describe("registerSlackInteractionEvents", () => {
view: {
id: "V900",
callback_id: "openclaw:deploy_form",
private_metadata: "run:123",
private_metadata: JSON.stringify({ sessionKey: "agent:main:slack:channel:C99" }),
state: {
values: {
env_block: {
@@ -288,9 +294,12 @@ describe("registerSlackInteractionEvents", () => {
});
expect(ack).toHaveBeenCalled();
expect(resolveSessionKey).toHaveBeenCalledWith({});
expect(resolveSessionKey).not.toHaveBeenCalled();
expect(enqueueSystemEventMock).toHaveBeenCalledTimes(1);
const [eventText] = enqueueSystemEventMock.mock.calls[0] as [string];
const [eventText, options] = enqueueSystemEventMock.mock.calls[0] as [
string,
{ sessionKey?: string },
];
const payload = JSON.parse(eventText.replace("Slack interaction: ", "")) as {
interactionType: string;
actionId: string;
@@ -308,12 +317,13 @@ describe("registerSlackInteractionEvents", () => {
viewId: "V900",
userId: "U900",
isCleared: true,
privateMetadata: "run:123",
privateMetadata: JSON.stringify({ sessionKey: "agent:main:slack:channel:C99" }),
});
expect(payload.inputs).toEqual(
expect.arrayContaining([
expect.objectContaining({ actionId: "env_select", selectedValues: ["canary"] }),
]),
);
expect(options.sessionKey).toBe("agent:main:slack:channel:C99");
});
});

View File

@@ -47,6 +47,12 @@ type ModalInputSummary = {
inputValue?: string;
};
type ModalPrivateMetadata = {
sessionKey?: string;
channelId?: string;
channelType?: string;
};
function readOptionValues(options: unknown): string[] | undefined {
if (!Array.isArray(options)) {
return undefined;
@@ -146,6 +152,53 @@ function summarizeViewState(values: unknown): ModalInputSummary[] {
return entries;
}
function parseModalPrivateMetadata(raw: unknown): ModalPrivateMetadata {
if (typeof raw !== "string" || raw.trim().length === 0) {
return {};
}
try {
const parsed = JSON.parse(raw) as Record<string, unknown>;
const sessionKey =
typeof parsed.sessionKey === "string" && parsed.sessionKey.trim().length > 0
? parsed.sessionKey
: undefined;
const channelId =
typeof parsed.channelId === "string" && parsed.channelId.trim().length > 0
? parsed.channelId
: undefined;
const channelType =
typeof parsed.channelType === "string" && parsed.channelType.trim().length > 0
? parsed.channelType
: undefined;
return { sessionKey, channelId, channelType };
} catch {
return {};
}
}
function resolveModalSessionRouting(params: {
ctx: SlackMonitorContext;
privateMetadata: unknown;
}): { sessionKey: string; channelId?: string; channelType?: string } {
const metadata = parseModalPrivateMetadata(params.privateMetadata);
if (metadata.sessionKey) {
return { sessionKey: metadata.sessionKey };
}
if (metadata.channelId) {
return {
sessionKey: params.ctx.resolveSlackSystemEventSessionKey({
channelId: metadata.channelId,
channelType: metadata.channelType,
}),
channelId: metadata.channelId,
channelType: metadata.channelType,
};
}
return {
sessionKey: params.ctx.resolveSlackSystemEventSessionKey({}),
};
}
export function registerSlackInteractionEvents(params: { ctx: SlackMonitorContext }) {
const { ctx } = params;
if (typeof ctx.app.action !== "function") {
@@ -292,6 +345,7 @@ export function registerSlackInteractionEvents(params: { ctx: SlackMonitorContex
view?: {
id?: string;
callback_id?: string;
private_metadata?: string;
state?: { values?: unknown };
};
};
@@ -300,6 +354,10 @@ export function registerSlackInteractionEvents(params: { ctx: SlackMonitorContex
const userId = typedBody.user?.id ?? "unknown";
const viewId = typedBody.view?.id;
const inputs = summarizeViewState(typedBody.view?.state?.values);
const sessionRouting = resolveModalSessionRouting({
ctx,
privateMetadata: typedBody.view?.private_metadata,
});
const eventPayload = {
interactionType: "view_submission",
actionId: `view:${callbackId}`,
@@ -307,6 +365,9 @@ export function registerSlackInteractionEvents(params: { ctx: SlackMonitorContex
viewId,
userId,
teamId: typedBody.team?.id,
privateMetadata: typedBody.view?.private_metadata,
routedChannelId: sessionRouting.channelId,
routedChannelType: sessionRouting.channelType,
inputs,
};
@@ -315,7 +376,7 @@ export function registerSlackInteractionEvents(params: { ctx: SlackMonitorContex
);
enqueueSystemEvent(`Slack interaction: ${JSON.stringify(eventPayload)}`, {
sessionKey: ctx.resolveSlackSystemEventSessionKey({}),
sessionKey: sessionRouting.sessionKey,
contextKey: ["slack:interaction:view", callbackId, viewId, userId]
.filter(Boolean)
.join(":"),
@@ -357,6 +418,10 @@ export function registerSlackInteractionEvents(params: { ctx: SlackMonitorContex
const userId = typedBody.user?.id ?? "unknown";
const viewId = typedBody.view?.id;
const inputs = summarizeViewState(typedBody.view?.state?.values);
const sessionRouting = resolveModalSessionRouting({
ctx,
privateMetadata: typedBody.view?.private_metadata,
});
const eventPayload = {
interactionType: "view_closed",
actionId: `view:${callbackId}`,
@@ -366,6 +431,8 @@ export function registerSlackInteractionEvents(params: { ctx: SlackMonitorContex
teamId: typedBody.team?.id,
isCleared: typedBody.is_cleared === true,
privateMetadata: typedBody.view?.private_metadata,
routedChannelId: sessionRouting.channelId,
routedChannelType: sessionRouting.channelType,
inputs,
};
@@ -376,7 +443,7 @@ export function registerSlackInteractionEvents(params: { ctx: SlackMonitorContex
);
enqueueSystemEvent(`Slack interaction: ${JSON.stringify(eventPayload)}`, {
sessionKey: ctx.resolveSlackSystemEventSessionKey({}),
sessionKey: sessionRouting.sessionKey,
contextKey: ["slack:interaction:view-closed", callbackId, viewId, userId]
.filter(Boolean)
.join(":"),