From 496e0caae5f7b7148cdd3d8f3c82c356663bc6ed Mon Sep 17 00:00:00 2001 From: Allen Zhou <46854522+allenzhou101@users.noreply.github.com> Date: Thu, 9 Jul 2026 12:24:55 -0700 Subject: [PATCH 1/3] feat(eve): expose trigger_id and forward foreign view_submissions on the Slack channel MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Slack channels can open and receive their own modals: SlackInteractionAction gains triggerId (valid ~3s, required for views.open), and the new optional onViewSubmission hook receives any view_submission whose callback_id is not the framework's HITL freeform modal — previously those were acked and dropped before reaching app code. View submissions carry no channel/thread, so hosts stash routing context in private_metadata when opening the modal. Signed-off-by: Allen Zhou <46854522+allenzhou101@users.noreply.github.com> --- .changeset/slack-interaction-modals.md | 5 + .../eve/src/public/channels/slack/index.ts | 1 + .../channels/slack/interactions.test.ts | 130 +++++++++++++++++- .../src/public/channels/slack/interactions.ts | 54 +++++++- .../src/public/channels/slack/slackChannel.ts | 43 ++++++ 5 files changed, 229 insertions(+), 4 deletions(-) create mode 100644 .changeset/slack-interaction-modals.md diff --git a/.changeset/slack-interaction-modals.md b/.changeset/slack-interaction-modals.md new file mode 100644 index 000000000..18630425b --- /dev/null +++ b/.changeset/slack-interaction-modals.md @@ -0,0 +1,5 @@ +--- +"eve": minor +--- + +Slack channels can now open and receive their own modals. `SlackInteractionAction` gains `triggerId` (the payload's `trigger_id`, valid ~3 seconds, required for `views.open`), and a new optional `onViewSubmission` hook receives any `view_submission` whose `callback_id` is not the framework's HITL freeform modal — previously those were acked and dropped. Slack sends no channel or thread on view submissions, so stash routing context in `private_metadata` when opening the modal and read it back from the forwarded `SlackViewSubmission`. diff --git a/packages/eve/src/public/channels/slack/index.ts b/packages/eve/src/public/channels/slack/index.ts index 84f733493..155dd9651 100644 --- a/packages/eve/src/public/channels/slack/index.ts +++ b/packages/eve/src/public/channels/slack/index.ts @@ -25,6 +25,7 @@ export { type SlackInstrumentationMetadata, type SlackInitialMessage, type SlackInteractionAction, + type SlackViewSubmission, type SlackMentionResult, type SlackMentionResultOrPromise, type SlackReceiveTarget, diff --git a/packages/eve/src/public/channels/slack/interactions.test.ts b/packages/eve/src/public/channels/slack/interactions.test.ts index b992fa89f..4f09f8c21 100644 --- a/packages/eve/src/public/channels/slack/interactions.test.ts +++ b/packages/eve/src/public/channels/slack/interactions.test.ts @@ -1,7 +1,14 @@ import { describe, expect, it } from "vitest"; import { parseSlackWebhookBody } from "#compiled/@chat-adapter/slack/webhook.js"; -import { parseBlockActionsPayload } from "#public/channels/slack/interactions.js"; +import { + handleInteractionPost, + parseBlockActionsPayload, +} from "#public/channels/slack/interactions.js"; +import type { + SlackChannelConfig, + SlackViewSubmission, +} from "#public/channels/slack/slackChannel.js"; function makePayload(overrides: Record): Record { return { @@ -89,3 +96,124 @@ describe("parseBlockActionsPayload", () => { }); }); }); + +describe("triggerId propagation", () => { + it("copies the payload trigger_id onto every parsed action", () => { + const parsed = parseBlockActionsPayload( + makePayload({ + trigger_id: "13345224609.738474920.8088930838d88f008e0", + actions: [ + { action_id: "approve", value: "v1" }, + { action_id: "dismiss", value: "v2" }, + ], + }), + ); + expect(parsed?.actions).toHaveLength(2); + for (const action of parsed?.actions ?? []) { + expect(action.triggerId).toBe("13345224609.738474920.8088930838d88f008e0"); + } + }); + + it("carries trigger_id through the shared webhook parser branch", () => { + const body = new URLSearchParams({ + payload: JSON.stringify( + makePayload({ + type: "block_actions", + trigger_id: "999.888.777", + }), + ), + }).toString(); + const payload = parseSlackWebhookBody(body, { + contentType: "application/x-www-form-urlencoded", + }); + if (payload.kind !== "block_actions") throw new Error("expected block_actions"); + const parsed = parseBlockActionsPayload(payload); + expect(parsed?.actions[0]?.triggerId).toBe("999.888.777"); + }); +}); + +function viewSubmissionBody(overrides: Record): string { + return new URLSearchParams({ + payload: JSON.stringify({ + type: "view_submission", + team: { id: "T0123456789" }, + user: { id: "U0123456789", username: "jane.doe", name: "jane.doe" }, + view: { + id: "V0123456789", + callback_id: "my-app-modal", + private_metadata: JSON.stringify({ suggestionId: "cold:C1:2.0" }), + state: { + values: { + note_block: { + note_input: { type: "plain_text_input", value: "right goal, wrong channel" }, + }, + }, + }, + }, + ...overrides, + }), + }).toString(); +} + +describe("handleInteractionPost view_submission forwarding", () => { + const ctx = { + send: (() => Promise.resolve({})) as never, + waitUntil: (task: Promise) => { + void task; + }, + }; + + it("forwards foreign-callback view submissions to onViewSubmission", async () => { + const received: SlackViewSubmission[] = []; + const config = { + onViewSubmission(view: SlackViewSubmission) { + received.push(view); + }, + } as unknown as SlackChannelConfig; + + const response = await handleInteractionPost(viewSubmissionBody({}), ctx, { config }); + expect(response.status).toBe(200); + expect(received).toHaveLength(1); + expect(received[0]).toMatchObject({ + callbackId: "my-app-modal", + privateMetadata: JSON.stringify({ suggestionId: "cold:C1:2.0" }), + user: { id: "U0123456789", username: "jane.doe" }, + teamId: "T0123456789", + }); + expect(received[0]?.values).toContainEqual( + expect.objectContaining({ + blockId: "note_block", + actionId: "note_input", + value: "right goal, wrong channel", + }), + ); + }); + + it("never forwards the framework's own HITL freeform modal", async () => { + const received: SlackViewSubmission[] = []; + const config = { + onViewSubmission(view: SlackViewSubmission) { + received.push(view); + }, + } as unknown as SlackChannelConfig; + + const body = viewSubmissionBody({ + view: { + id: "V0123456789", + callback_id: "eve_input_freeform_submit", + private_metadata: "{}", + state: { values: {} }, + }, + }); + const response = await handleInteractionPost(body, ctx, { config }); + expect(response.status).toBe(200); + expect(received).toHaveLength(0); + }); + + it("acks foreign view submissions without a handler configured", async () => { + const response = await handleInteractionPost(viewSubmissionBody({}), ctx, { + config: {} as unknown as SlackChannelConfig, + }); + expect(response.status).toBe(200); + }); +}); diff --git a/packages/eve/src/public/channels/slack/interactions.ts b/packages/eve/src/public/channels/slack/interactions.ts index 78112793e..3b2ba5470 100644 --- a/packages/eve/src/public/channels/slack/interactions.ts +++ b/packages/eve/src/public/channels/slack/interactions.ts @@ -52,6 +52,7 @@ import type { SlackContext, SlackInteractionAction, SlackInteractionUser, + SlackViewSubmission, } from "#public/channels/slack/slackChannel.js"; import type { SendFn } from "#public/definitions/channel.js"; @@ -116,6 +117,7 @@ export function parseBlockActionsPayload( }; const messageBlocks = message?.blocks ?? []; + const triggerId = typeof rawBody.trigger_id === "string" ? rawBody.trigger_id : undefined; return { actions: actions.map((a: Record) => ({ @@ -126,6 +128,7 @@ export function parseBlockActionsPayload( messageTs: message?.ts, label: extractActionLabel(a), user, + triggerId, })), channelId: channel, threadTs, @@ -158,6 +161,7 @@ function parseSharedBlockActionsPayload( username: action.user?.username, name: action.user?.name, }, + triggerId: body.triggerId, })), channelId: body.channelId, threadTs: body.threadTs, @@ -490,17 +494,61 @@ async function openFreeformModal(input: { } } +/** + * Non-HITL modal submits flow to the user-supplied `onViewSubmission` + * hook (dropped, with a debug log, when the channel does not define + * one). Runs under `waitUntil` after the ack — the modal is already + * closing; errors are caught and logged. + */ +function forwardViewSubmission( + payload: SlackViewSubmissionPayload, + ctx: { waitUntil: (task: Promise) => void }, + deps: InteractionHandlerDeps, +): void { + const onViewSubmission = deps.config.onViewSubmission; + if (!onViewSubmission) { + log.debug("view_submission with a foreign callback_id and no onViewSubmission handler", { + callbackId: payload.callbackId, + }); + return; + } + const view: SlackViewSubmission = { + callbackId: payload.callbackId ?? "", + privateMetadata: payload.privateMetadata, + values: (payload.values ?? []).map((value) => ({ + blockId: value.blockId, + actionId: value.actionId, + value: value.value, + selectedOptionValue: value.selectedOptionValue, + })), + user: { + id: payload.userId, + username: payload.user?.username, + name: payload.user?.name, + }, + teamId: payload.user?.teamId ?? payload.teamId, + }; + ctx.waitUntil( + Promise.resolve(onViewSubmission(view)).catch((error: unknown) => { + log.error("custom view_submission handler failed", { error }); + }), + ); +} + async function handleViewSubmission( payload: SlackViewSubmissionPayload, ctx: { send: SendFn; waitUntil: (task: Promise) => void; }, - _deps: InteractionHandlerDeps, + deps: InteractionHandlerDeps, ): Promise { // Slack view submissions require an empty 200 body to close the modal. const ack = new Response(null, { status: 200 }); - if (payload.callbackId !== HITL_FREEFORM_MODAL_CALLBACK_ID) return ack; + if (payload.callbackId !== HITL_FREEFORM_MODAL_CALLBACK_ID) { + forwardViewSubmission(payload, ctx, deps); + return ack; + } let metadata: HitlFreeformModalMetadata; try { @@ -565,7 +613,7 @@ async function handleViewSubmission( messageTs: metadata.messageTs, answerLabel: text, userId: triggeringUserId ?? undefined, - deps: _deps, + deps, }).catch((error: unknown) => { log.error("freeform answered-card update failed", { error }); }), diff --git a/packages/eve/src/public/channels/slack/slackChannel.ts b/packages/eve/src/public/channels/slack/slackChannel.ts index af6f012b6..0986dd4ff 100644 --- a/packages/eve/src/public/channels/slack/slackChannel.ts +++ b/packages/eve/src/public/channels/slack/slackChannel.ts @@ -281,6 +281,37 @@ export interface SlackInteractionAction { * `block_actions` payload. */ readonly user: SlackInteractionUser; + /** + * Slack `trigger_id` off the interaction payload, required to open a + * modal via `views.open`. Slack invalidates it roughly three seconds + * after the click, so a handler that opens a modal must call + * `views.open` inline, not behind queued work. + */ + readonly triggerId?: string; +} + +/** + * A Slack modal (`view_submission`) payload not consumed by the + * framework's HITL pipeline, forwarded to `onViewSubmission`. Slack + * carries no channel or thread on view submissions — a channel that + * needs routing context should stash it in `private_metadata` when it + * opens the modal and read it back here. + */ +export interface SlackViewSubmission { + /** The `callback_id` the modal was opened with. */ + readonly callbackId: string; + /** Verbatim `private_metadata` from the modal view, when set. */ + readonly privateMetadata?: string; + /** Flattened `view.state.values`: one entry per input block. */ + readonly values: readonly { + readonly blockId: string; + readonly actionId: string; + readonly value?: string; + readonly selectedOptionValue?: string; + }[]; + /** Slack actor who submitted the modal. */ + readonly user: SlackInteractionUser; + readonly teamId?: string; } /** Slack actor on {@link SlackInteractionAction.user}, mirroring `body.user`. */ @@ -439,6 +470,18 @@ export interface SlackChannelConfig { */ onInteraction?(action: SlackInteractionAction, ctx: SlackContext): void | Promise; + /** + * Handler for Slack `view_submission` payloads (modal submits) whose + * `callback_id` is **not** the framework's HITL freeform modal. Pair + * with {@link SlackInteractionAction.triggerId}: `onInteraction` + * opens a modal with `views.open`, stashing any routing context in + * `private_metadata`, and the submit arrives here. Runs via + * `waitUntil()` after the empty-200 ack that closes the modal, so + * submissions cannot be rejected with validation errors. Errors are + * caught and logged. + */ + onViewSubmission?(view: SlackViewSubmission): void | Promise; + readonly events?: SlackChannelEvents; } From e909dad5c4e91ecec99c3dfacd00e54297a2182b Mon Sep 17 00:00:00 2001 From: Allen Zhou <46854522+allenzhou101@users.noreply.github.com> Date: Thu, 9 Jul 2026 12:37:41 -0700 Subject: [PATCH 2/3] fix(eve): direct casts in interaction tests per invariant guard Signed-off-by: Allen Zhou <46854522+allenzhou101@users.noreply.github.com> --- packages/eve/src/public/channels/slack/interactions.test.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/eve/src/public/channels/slack/interactions.test.ts b/packages/eve/src/public/channels/slack/interactions.test.ts index 4f09f8c21..9d04e1f41 100644 --- a/packages/eve/src/public/channels/slack/interactions.test.ts +++ b/packages/eve/src/public/channels/slack/interactions.test.ts @@ -169,7 +169,7 @@ describe("handleInteractionPost view_submission forwarding", () => { onViewSubmission(view: SlackViewSubmission) { received.push(view); }, - } as unknown as SlackChannelConfig; + } as SlackChannelConfig; const response = await handleInteractionPost(viewSubmissionBody({}), ctx, { config }); expect(response.status).toBe(200); @@ -195,7 +195,7 @@ describe("handleInteractionPost view_submission forwarding", () => { onViewSubmission(view: SlackViewSubmission) { received.push(view); }, - } as unknown as SlackChannelConfig; + } as SlackChannelConfig; const body = viewSubmissionBody({ view: { @@ -212,7 +212,7 @@ describe("handleInteractionPost view_submission forwarding", () => { it("acks foreign view submissions without a handler configured", async () => { const response = await handleInteractionPost(viewSubmissionBody({}), ctx, { - config: {} as unknown as SlackChannelConfig, + config: {} as SlackChannelConfig, }); expect(response.status).toBe(200); }); From 31f32fec5904c0d114be71c2d56837821c4479a4 Mon Sep 17 00:00:00 2001 From: Allen Zhou <46854522+allenzhou101@users.noreply.github.com> Date: Thu, 9 Jul 2026 12:52:03 -0700 Subject: [PATCH 3/3] chore: patch changeset per repo convention Signed-off-by: Allen Zhou <46854522+allenzhou101@users.noreply.github.com> --- .changeset/slack-interaction-modals.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/slack-interaction-modals.md b/.changeset/slack-interaction-modals.md index 18630425b..c0e8692e9 100644 --- a/.changeset/slack-interaction-modals.md +++ b/.changeset/slack-interaction-modals.md @@ -1,5 +1,5 @@ --- -"eve": minor +"eve": patch --- Slack channels can now open and receive their own modals. `SlackInteractionAction` gains `triggerId` (the payload's `trigger_id`, valid ~3 seconds, required for `views.open`), and a new optional `onViewSubmission` hook receives any `view_submission` whose `callback_id` is not the framework's HITL freeform modal — previously those were acked and dropped. Slack sends no channel or thread on view submissions, so stash routing context in `private_metadata` when opening the modal and read it back from the forwarded `SlackViewSubmission`.