Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/slack-interaction-modals.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"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`.
1 change: 1 addition & 0 deletions packages/eve/src/public/channels/slack/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ export {
type SlackInstrumentationMetadata,
type SlackInitialMessage,
type SlackInteractionAction,
type SlackViewSubmission,
type SlackMentionResult,
type SlackMentionResultOrPromise,
type SlackReceiveTarget,
Expand Down
130 changes: 129 additions & 1 deletion packages/eve/src/public/channels/slack/interactions.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown>): Record<string, unknown> {
return {
Expand Down Expand Up @@ -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, unknown>): 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<unknown>) => {
void task;
},
};

it("forwards foreign-callback view submissions to onViewSubmission", async () => {
const received: SlackViewSubmission[] = [];
const config = {
onViewSubmission(view: SlackViewSubmission) {
received.push(view);
},
} 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 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 SlackChannelConfig,
});
expect(response.status).toBe(200);
});
});
54 changes: 51 additions & 3 deletions packages/eve/src/public/channels/slack/interactions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ import type {
SlackContext,
SlackInteractionAction,
SlackInteractionUser,
SlackViewSubmission,
} from "#public/channels/slack/slackChannel.js";
import type { SendFn } from "#public/definitions/channel.js";

Expand Down Expand Up @@ -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<string, unknown>) => ({
Expand All @@ -126,6 +128,7 @@ export function parseBlockActionsPayload(
messageTs: message?.ts,
label: extractActionLabel(a),
user,
triggerId,
})),
channelId: channel,
threadTs,
Expand Down Expand Up @@ -158,6 +161,7 @@ function parseSharedBlockActionsPayload(
username: action.user?.username,
name: action.user?.name,
},
triggerId: body.triggerId,
})),
channelId: body.channelId,
threadTs: body.threadTs,
Expand Down Expand Up @@ -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<unknown>) => 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<SlackChannelState>;
waitUntil: (task: Promise<unknown>) => void;
},
_deps: InteractionHandlerDeps,
deps: InteractionHandlerDeps,
): Promise<Response> {
// 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 {
Expand Down Expand Up @@ -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 });
}),
Expand Down
43 changes: 43 additions & 0 deletions packages/eve/src/public/channels/slack/slackChannel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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`. */
Expand Down Expand Up @@ -439,6 +470,18 @@ export interface SlackChannelConfig {
*/
onInteraction?(action: SlackInteractionAction, ctx: SlackContext): void | Promise<void>;

/**
* 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<void>;

readonly events?: SlackChannelEvents;
}

Expand Down
Loading