From bd17587b2ab0d22ee5866926632946097a6ce38d Mon Sep 17 00:00:00 2001 From: Colin Date: Mon, 16 Feb 2026 11:59:15 -0500 Subject: [PATCH] Slack: route modal interactions via private metadata --- src/slack/monitor/events/interactions.test.ts | 20 ++++-- src/slack/monitor/events/interactions.ts | 71 ++++++++++++++++++- 2 files changed, 84 insertions(+), 7 deletions(-) diff --git a/src/slack/monitor/events/interactions.test.ts b/src/slack/monitor/events/interactions.test.ts index cfd44607b..7bf543e9f 100644 --- a/src/slack/monitor/events/interactions.test.ts +++ b/src/slack/monitor/events/interactions.test.ts @@ -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"); }); }); diff --git a/src/slack/monitor/events/interactions.ts b/src/slack/monitor/events/interactions.ts index 14a874c13..861255723 100644 --- a/src/slack/monitor/events/interactions.ts +++ b/src/slack/monitor/events/interactions.ts @@ -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; + 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(":"),