From 6217fd3d2c3dfdeb23eeb77c65c1f5a3c2ef46b3 Mon Sep 17 00:00:00 2001 From: benpankow Date: Wed, 29 Jul 2026 12:53:34 -0700 Subject: [PATCH] feat(eve): handle authorized approvals in Slack Signed-off-by: benpankow --- packages/eve/src/channel/routes.ts | 1 + packages/eve/src/channel/send.test.ts | 25 +++ packages/eve/src/channel/send.ts | 3 +- .../public/channels/slack/defaults.test.ts | 68 +++++++ .../eve/src/public/channels/slack/defaults.ts | 61 ++++++ .../src/public/channels/slack/interactions.ts | 177 ++---------------- .../channels/slack/slackChannel.test.ts | 79 ++------ .../src/public/channels/slack/slackChannel.ts | 23 +++ .../eve/src/public/definitions/channel.ts | 4 +- packages/eve/src/shared/channel-definition.ts | 4 +- 10 files changed, 216 insertions(+), 229 deletions(-) diff --git a/packages/eve/src/channel/routes.ts b/packages/eve/src/channel/routes.ts index d2f5f2bfc..cd67677ae 100644 --- a/packages/eve/src/channel/routes.ts +++ b/packages/eve/src/channel/routes.ts @@ -43,6 +43,7 @@ export interface RouteHandlerArgs { export interface SendPayload { readonly message?: string | UserContent; + readonly [key: string]: unknown; readonly inputResponses?: readonly InputResponse[]; /** * Context strings contributed by the channel. eve appends each entry diff --git a/packages/eve/src/channel/send.test.ts b/packages/eve/src/channel/send.test.ts index 3ddc8194e..a17139fa9 100644 --- a/packages/eve/src/channel/send.test.ts +++ b/packages/eve/src/channel/send.test.ts @@ -77,6 +77,31 @@ describe("createSendFn", () => { expect(runtime.run).not.toHaveBeenCalled(); }); + it("preserves channel-specific fields through delivery", async () => { + const runtime: Runtime = { + cancelTurn: vi.fn(), + deliver: vi.fn().mockResolvedValue({ sessionId: "existing-session-id" }), + resolveSession: vi.fn(), + run: vi.fn(), + getEventStream: vi.fn(), + getStreamTailIndex: vi.fn(), + terminateSession: vi.fn(), + }; + const send = createSendFn(runtime, ADAPTER, "test"); + + await send( + { + inputResponses: [{ optionId: "approve", requestId: "approval-1" }], + pendingApprovalCards: { "approval-1": { messageTs: "123.456" } }, + }, + { auth: null, continuationToken: "token" }, + ); + + expect(vi.mocked(runtime.deliver).mock.calls[0]?.[0].payload).toMatchObject({ + pendingApprovalCards: { "approval-1": { messageTs: "123.456" } }, + }); + }); + it("forwards context through deliver and run payloads", async () => { const context = ["thread background"]; const deliverRuntime: Runtime = { diff --git a/packages/eve/src/channel/send.ts b/packages/eve/src/channel/send.ts index 2ed42b423..57653a923 100644 --- a/packages/eve/src/channel/send.ts +++ b/packages/eve/src/channel/send.ts @@ -31,6 +31,7 @@ export function createSendFn( inputResponses, context, outputSchema, + ...channelPayload } = normalizeSendInput(input); const message = serializeUrlFilePartsInMessage(rawMessage); @@ -39,7 +40,7 @@ export function createSendFn( auth, continuationToken, requestId: metadata.requestId, - payload: { inputResponses, message, context, outputSchema }, + payload: { ...channelPayload, inputResponses, message, context, outputSchema }, }; const { sessionId } = await runtime.deliver(deliverInput); diff --git a/packages/eve/src/public/channels/slack/defaults.test.ts b/packages/eve/src/public/channels/slack/defaults.test.ts index 804814b09..38cabe867 100644 --- a/packages/eve/src/public/channels/slack/defaults.test.ts +++ b/packages/eve/src/public/channels/slack/defaults.test.ts @@ -51,6 +51,74 @@ function authRequiredEvent( }; } +describe("defaultEvents approval lifecycle", () => { + it("sends candidate progress privately", async () => { + const { channel, postEphemeral } = buildChannelStub(); + const ctx = sessionContext({ + attributes: { user_id: "U777" }, + authenticator: "slack-webhook", + principalId: "slack:T1:U777", + principalType: "user", + }); + + await defaultEvents["approval.candidate"]!( + { + candidateId: "candidate-1", + outcome: "pending", + requestId: "approval-1", + sequence: 1, + stepIndex: 0, + turnId: "turn-1", + }, + channel, + ctx, + ); + + expect(postEphemeral).toHaveBeenCalledWith( + "U777", + "Checking whether you can approve this action…", + ); + }); + + it("updates the shared card only after settlement", async () => { + const { channel, request } = buildChannelStub({ + pendingApprovalCards: { + "approval-1": { + actionId: "eve_input:approval-1:button:1", + messageBlocks: [ + { + actions: [{ action_id: "eve_input:approval-1:button:1" }], + body: { text: "Approve?", type: "mrkdwn" }, + type: "card", + }, + ], + messageTs: "123.456", + userId: "U777", + }, + }, + }); + + await defaultEvents["approval.settled"]!( + { + outcome: "approved", + requestId: "approval-1", + responderPrincipalId: "slack:T1:U777", + sequence: 1, + stepIndex: 0, + turnId: "turn-1", + }, + channel, + sessionCtx, + ); + + expect(request).toHaveBeenCalledWith( + "chat.update", + expect.objectContaining({ channel: "C123", text: "Answered: Approve", ts: "123.456" }), + ); + expect(channel.state.pendingApprovalCards).toEqual({}); + }); +}); + describe("defaultEvents authorization.required", () => { it("posts a public status and delivers the challenge ephemerally to the triggering user", async () => { const { channel, post, postEphemeral } = buildChannelStub({ triggeringUserId: "U777" }); diff --git a/packages/eve/src/public/channels/slack/defaults.ts b/packages/eve/src/public/channels/slack/defaults.ts index 48008eaa2..e92ccae4f 100644 --- a/packages/eve/src/public/channels/slack/defaults.ts +++ b/packages/eve/src/public/channels/slack/defaults.ts @@ -11,6 +11,7 @@ import { type ConnectionAuthorizationOutcome, } from "#public/channels/slack/connections.js"; import { + buildAnsweredBlocks, formatInputRequestFallbackText, renderInputRequestBlocks, } from "#public/channels/slack/hitl.js"; @@ -32,6 +33,21 @@ const log = createLogger("slack.defaults"); const REASONING_TYPING_REFRESH_INTERVAL_MS = 5_000; const REASONING_TYPING_MIN_PROGRESS_CHARS = 4; +function blockContainsAction(block: unknown, actionId: string): boolean { + if (typeof block !== "object" || block === null) return false; + const candidate = block as { actions?: unknown; elements?: unknown }; + return [candidate.actions, candidate.elements].some( + (entries) => + Array.isArray(entries) && + entries.some( + (entry) => + typeof entry === "object" && + entry !== null && + (entry as { action_id?: unknown }).action_id === actionId, + ), + ); +} + /** * Workspace-scoped projection of the Slack actor that produced * `message`, derived into a {@link SessionAuthContext}. Used by both @@ -146,6 +162,51 @@ function buildInputRequestPosts( * which user overrides cannot express. */ export const defaultEvents: SlackChannelInternalEvents = { + async "approval.candidate"(event, channel, ctx) { + const userId = slackUserIdFromAuthContext(ctx.session.auth.current); + if (userId === undefined) return; + if (event.outcome === "pending") { + await channel.thread.postEphemeral(userId, "Checking whether you can approve this action…"); + return; + } + if (event.outcome === "rejected" || event.outcome === "failed") { + await channel.thread.postEphemeral( + userId, + event.safeReason ?? "We couldn’t verify your approval. Please try again.", + ); + } + }, + + async "approval.settled"(event, channel, _ctx) { + const cards = channel.state.pendingApprovalCards ?? {}; + const card = cards[event.requestId]; + if (card === undefined || channel.state.channelId === null) return; + const answerLabel = event.outcome === "approved" ? "Approve" : "Cancel"; + const blocks = card.messageBlocks.flatMap((block) => { + if (!blockContainsAction(block, card.actionId)) return [block]; + if (typeof block !== "object" || block === null) return []; + const candidate = block as Record; + if (candidate.type !== "card") { + return buildAnsweredBlocks({ answerLabel, promptBlocks: [], userId: card.userId }); + } + const { actions: _actions, ...withoutActions } = candidate; + return buildAnsweredBlocks({ + answerLabel, + promptBlocks: [withoutActions], + userId: card.userId, + }); + }); + await channel.slack.request("chat.update", { + blocks, + channel: channel.state.channelId, + text: `Answered: ${answerLabel}`, + ts: card.messageTs, + }); + const next = { ...cards }; + delete next[event.requestId]; + channel.state.pendingApprovalCards = next; + }, + async "turn.started"(_event, channel, _ctx) { channel.state.pendingToolCallMessage = null; channel.state.lastReasoningTypingAtMs = null; diff --git a/packages/eve/src/public/channels/slack/interactions.ts b/packages/eve/src/public/channels/slack/interactions.ts index b8ca0d119..0d575712c 100644 --- a/packages/eve/src/public/channels/slack/interactions.ts +++ b/packages/eve/src/public/channels/slack/interactions.ts @@ -42,10 +42,6 @@ import { isHitlAction, type HitlFreeformModalMetadata, } from "#public/channels/slack/hitl.js"; -import { - SLACK_CARD_SUBTEXT_MAX_LENGTH, - truncateCardSubtext, -} from "#public/channels/slack/limits.js"; import type { SlackChannelConfig, SlackChannelState, @@ -180,6 +176,23 @@ function extractActionLabel(action: Record): string | undefined return undefined; } +function buildPendingApprovalCards( + interaction: ParsedBlockActionsPayload, +): NonNullable { + const cards: NonNullable = {}; + for (const action of interaction.actions) { + const response = deriveHitlResponse(action); + if (response === null || !action.messageTs) continue; + cards[response.requestId] = { + actionId: action.actionId, + messageBlocks: interaction.messageBlocks, + messageTs: action.messageTs, + userId: action.user.id, + }; + } + return cards; +} + function findPromptBlock(blocks: readonly unknown[]): unknown { return findPromptBlocks(blocks)[0]; } @@ -207,117 +220,6 @@ function readPromptTextFromBlocks(blocks: readonly unknown[]): string | undefine return typeof text === "string" && text.length > 0 ? text : undefined; } -function buildAnsweredHitlMessageBlocks(input: { - readonly actionId: string; - readonly answerLabel: string; - readonly messageBlocks: readonly unknown[]; - readonly userId: string; -}): unknown[] { - const actionBlockIndex = findActionBlockIndex(input.messageBlocks, input.actionId); - if (actionBlockIndex === -1) { - return buildAnsweredBlocks({ - promptBlocks: findPromptBlocks(input.messageBlocks), - answerLabel: input.answerLabel, - userId: input.userId, - }); - } - - const actionBlock = input.messageBlocks[actionBlockIndex]; - const answeredBlocks = - answeredBlocksFromActionBlock({ - answerLabel: input.answerLabel, - block: actionBlock, - userId: input.userId, - }) ?? - buildAnsweredBlocks({ - promptBlocks: promptBlocksFromActionBlock(actionBlock), - answerLabel: input.answerLabel, - userId: input.userId, - }); - return [ - ...input.messageBlocks.slice(0, actionBlockIndex), - ...answeredBlocks, - ...input.messageBlocks.slice(actionBlockIndex + 1), - ]; -} - -function findActionBlockIndex(blocks: readonly unknown[], actionId: string): number { - return blocks.findIndex((block) => blockContainsActionId(block, actionId)); -} - -function blockContainsActionId(block: unknown, actionId: string): boolean { - if (!isObjectRecord(block)) return false; - return ( - actionsContainActionId(block.elements, actionId) || - actionsContainActionId(block.actions, actionId) - ); -} - -function actionsContainActionId(actions: unknown, actionId: string): boolean { - if (!Array.isArray(actions)) return false; - return actions.some((element) => isObjectRecord(element) && element.action_id === actionId); -} - -function isObjectRecord(value: unknown): value is Record { - return typeof value === "object" && value !== null; -} - -function answeredBlocksFromActionBlock(input: { - readonly answerLabel: string; - readonly block: unknown; - readonly userId: string; -}): unknown[] | undefined { - if (!isObjectRecord(input.block) || input.block.type !== "card") return undefined; - - const { actions: _actions, subtext: _subtext, ...blockWithoutActions } = input.block; - const answeredCard = { - ...blockWithoutActions, - subtext: { - type: "mrkdwn", - text: formatAnsweredCardSubtext(input), - verbatim: false, - }, - }; - return hasCardContent(answeredCard) ? [answeredCard] : undefined; -} - -const ANSWERED_CARD_SUBTEXT_PREFIX = ":white_check_mark: *"; -const ANSWERED_CARD_SUBTEXT_SUFFIX = "*"; - -function formatAnsweredCardSubtext(input: { - readonly answerLabel: string; - readonly userId: string; -}): string { - const attribution = input.userId.length > 0 ? ` by <@${input.userId}>` : ""; - const labelBudget = - SLACK_CARD_SUBTEXT_MAX_LENGTH - - ANSWERED_CARD_SUBTEXT_PREFIX.length - - ANSWERED_CARD_SUBTEXT_SUFFIX.length - - attribution.length; - const label = truncateWithEllipsis(input.answerLabel, labelBudget); - return truncateCardSubtext( - `${ANSWERED_CARD_SUBTEXT_PREFIX}${label}${ANSWERED_CARD_SUBTEXT_SUFFIX}${attribution}`, - ); -} - -function truncateWithEllipsis(value: string, maxLength: number): string { - if (maxLength <= 0) return ""; - if (value.length <= maxLength) return value; - const sliceLength = Math.max(0, maxLength - 3); - return `${value.slice(0, sliceLength).trimEnd()}...`; -} - -function promptBlocksFromActionBlock(block: unknown): unknown[] { - if (!isObjectRecord(block) || block.type !== "card") return []; - - const { actions: _actions, ...blockWithoutActions } = block; - return hasCardContent(blockWithoutActions) ? [blockWithoutActions] : []; -} - -function hasCardContent(block: Record): boolean { - return block.body !== undefined || block.title !== undefined || block.hero_image !== undefined; -} - /** * Channel-supplied dependencies for {@link handleInteractionPost}. * @@ -385,7 +287,7 @@ export async function handleInteractionPost( ctx.waitUntil( ctx .send( - { inputResponses }, + { inputResponses, pendingApprovalCards: buildPendingApprovalCards(interaction) }, { auth: buildSlackAuthContext({ channelId: interaction.channelId, @@ -400,6 +302,7 @@ export async function handleInteractionPost( threadTs: interaction.threadTs, teamId: interaction.teamId ?? null, triggeringUserId: user.id, + pendingApprovalCards: buildPendingApprovalCards(interaction), }, }, ) @@ -407,12 +310,6 @@ export async function handleInteractionPost( log.error("HITL interaction delivery failed", { error }); }), ); - - ctx.waitUntil( - updateAnsweredHitlCard(interaction, deps).catch((error: unknown) => { - log.error("HITL answered-card update failed", { error }); - }), - ); } const onInteraction = deps.config.onInteraction; @@ -589,42 +486,6 @@ async function handleViewSubmission( return ack; } -async function updateAnsweredHitlCard( - interaction: ParsedBlockActionsPayload, - deps: InteractionHandlerDeps, -): Promise { - const hitlAction = interaction.actions.find((a) => isHitlAction(a.actionId)); - if (!hitlAction || !hitlAction.messageTs) return; - - const answerLabel = hitlAction.label ?? hitlAction.selectedOptionValue ?? hitlAction.value; - if (!answerLabel) return; - - const blocks = buildAnsweredHitlMessageBlocks({ - actionId: hitlAction.actionId, - answerLabel, - messageBlocks: interaction.messageBlocks, - userId: hitlAction.user.id, - }); - - const token = await resolveSlackBotToken(deps.config.credentials?.botToken); - const response = await fetch("https://slack.com/api/chat.update", { - method: "POST", - headers: { - authorization: `Bearer ${token}`, - "content-type": "application/json; charset=utf-8", - }, - body: JSON.stringify({ - channel: interaction.channelId, - ts: hitlAction.messageTs, - blocks, - text: `Answered: ${answerLabel}`, - }), - }); - if (!response.ok) { - throw new Error(`Slack chat.update returned HTTP ${response.status}`); - } -} - async function updateAnsweredFreeformCard(input: { readonly channelId: string; readonly messageTs: string; diff --git a/packages/eve/src/public/channels/slack/slackChannel.test.ts b/packages/eve/src/public/channels/slack/slackChannel.test.ts index f86fb194e..13f920a34 100644 --- a/packages/eve/src/public/channels/slack/slackChannel.test.ts +++ b/packages/eve/src/public/channels/slack/slackChannel.test.ts @@ -18,7 +18,6 @@ import { } from "#public/channels/slack/hitl.js"; import { SLACK_CARD_BODY_TEXT_MAX_LENGTH, - SLACK_CARD_SUBTEXT_MAX_LENGTH, SLACK_MAX_BLOCKS_PER_MESSAGE, SLACK_MESSAGE_TEXT_MAX_LENGTH, SLACK_SECTION_TEXT_MAX_LENGTH, @@ -2177,8 +2176,9 @@ describe("slackChannel() HITL interaction pipeline", () => { expect(send).toHaveBeenCalledTimes(1); const [payload, options] = send.mock.calls[0]!; - expect(payload).toEqual({ + expect(payload).toMatchObject({ inputResponses: [{ optionId: "approve", requestId: "approval_abc123" }], + pendingApprovalCards: { approval_abc123: expect.objectContaining({ userId: "U_APPROVER" }) }, }); expect(options).toMatchObject({ auth: { @@ -2304,47 +2304,14 @@ describe("slackChannel() HITL interaction pipeline", () => { ); expect(send).toHaveBeenCalledTimes(1); - expect(send.mock.calls[0]?.[0]).toEqual({ + expect(send.mock.calls[0]?.[0]).toMatchObject({ inputResponses: [{ optionId: "approve", requestId: "approval_451" }], + pendingApprovalCards: { approval_451: expect.any(Object) }, }); - const updateCall = fetchMock.mock.calls.find( - ([url]) => String(url) === "https://slack.com/api/chat.update", - ); - expect(updateCall).toBeDefined(); - const body = parseSlackRequestBody(updateCall?.[1] as RequestInit) as { - blocks: Array<{ - actions?: Array<{ action_id?: string }>; - elements?: Array<{ action_id?: string }>; - subtext?: { text?: string }; - text?: { text?: string }; - }>; - channel: string; - text: string; - ts: string; - }; - - expect(body).toMatchObject({ - channel: "C01", - text: "Answered: Approve", - ts: "1700000000.000010", - }); - expect(JSON.stringify(body.blocks)).toContain("Approve issue 451?"); - expect(JSON.stringify(body.blocks)).toContain("Approve"); - expect(JSON.stringify(body.blocks)).toContain("Tool input"); - expect(JSON.stringify(body.blocks)).toContain("Approve issue 508?"); - expect(body.blocks[0]?.subtext?.text).toBe(":white_check_mark: *Approve* by <@U_APPROVER>"); - expect(body.blocks[0]?.subtext?.text?.length).toBeLessThanOrEqual( - SLACK_CARD_SUBTEXT_MAX_LENGTH, - ); - - const remainingActionIds = body.blocks.flatMap( - (block) => - (block.actions ?? block.elements) - ?.map((element) => element.action_id) - .filter((actionId): actionId is string => typeof actionId === "string") ?? [], - ); - expect(remainingActionIds).toEqual([secondDenyActionId, secondApproveActionId]); + expect( + fetchMock.mock.calls.find(([url]) => String(url) === "https://slack.com/api/chat.update"), + ).toBeUndefined(); }); it("covers the observed e0 batched escalation approval run", async () => { @@ -2448,36 +2415,14 @@ describe("slackChannel() HITL interaction pipeline", () => { ); expect(send).toHaveBeenCalledTimes(1); - expect(send.mock.calls[0]?.[0]).toEqual({ + expect(send.mock.calls[0]?.[0]).toMatchObject({ inputResponses: [{ optionId: "cancel", requestId: "approval_451" }], + pendingApprovalCards: { approval_451: expect.any(Object) }, }); - const updateCall = fetchMock.mock.calls.find( - ([url]) => String(url) === "https://slack.com/api/chat.update", - ); - expect(updateCall).toBeDefined(); - const body = parseSlackRequestBody(updateCall?.[1] as RequestInit) as { - blocks: Array<{ - actions?: Array<{ action_id?: string }>; - child_blocks?: Array<{ text?: { text?: string } }>; - elements?: Array<{ action_id?: string }>; - subtext?: { text?: string }; - }>; - }; - - expect(body.blocks[0]?.subtext?.text).toBe(":white_check_mark: *Cancel* by <@U0AT7H56S90>"); - expect(body.blocks[1]?.child_blocks?.[0]?.text?.text).toContain('"issueNumber": 451'); - expect(body.blocks[3]?.child_blocks?.[0]?.text?.text).toContain('"issueNumber": 508'); - const remainingActionIds = body.blocks.flatMap( - (block) => - (block.actions ?? block.elements) - ?.map((element) => element.action_id) - .filter((actionId): actionId is string => typeof actionId === "string") ?? [], - ); - expect(remainingActionIds).toEqual([ - `${HITL_ACTION_PREFIX}approval_508:button:0`, - `${HITL_ACTION_PREFIX}approval_508:button:1`, - ]); + expect( + fetchMock.mock.calls.find(([url]) => String(url) === "https://slack.com/api/chat.update"), + ).toBeUndefined(); }); it("resumes freeform modal answers with the submitting Slack user auth", async () => { diff --git a/packages/eve/src/public/channels/slack/slackChannel.ts b/packages/eve/src/public/channels/slack/slackChannel.ts index 7399bf6b9..873140c4c 100644 --- a/packages/eve/src/public/channels/slack/slackChannel.ts +++ b/packages/eve/src/public/channels/slack/slackChannel.ts @@ -1,5 +1,6 @@ import { parseSlackWebhookBody } from "#compiled/@chat-adapter/slack/webhook.js"; +import { defaultDeliverResult } from "#channel/adapter.js"; import type { CrossChannelReceiveOptions } from "#channel/cross-channel-receive.js"; import type { Session, SessionHandle } from "#channel/session.js"; import type { CancelTurnResult, SessionAuthContext } from "#channel/types.js"; @@ -167,6 +168,13 @@ type SlackSessionFailedHandler = ( * step boundaries. Anything written here must round-trip through * `JSON.stringify` / `JSON.parse`. */ +export interface SlackPendingApprovalCard { + readonly actionId: string; + readonly messageBlocks: readonly unknown[]; + readonly messageTs: string; + readonly userId: string; +} + export interface SlackChannelState { /** Slack channel id seeded by the inbound mention. */ channelId: string | null; @@ -204,6 +212,7 @@ export interface SlackChannelState { * resolution outcome. */ pendingAuthMessageTs?: Record; + pendingApprovalCards?: Record; } /** @@ -448,6 +457,8 @@ export type SlackInboundResultOrPromise = SlackMentionResultOrPromise; * {@link SessionContext}; `session.failed` receives only data and context. */ export interface SlackChannelEvents { + readonly "approval.candidate"?: SlackEventHandler<"approval.candidate">; + readonly "approval.settled"?: SlackEventHandler<"approval.settled">; readonly "turn.started"?: SlackEventHandler<"turn.started">; readonly "actions.requested"?: SlackEventHandler<"actions.requested">; readonly "action.result"?: SlackEventHandler<"action.result">; @@ -686,6 +697,7 @@ export function slackChannel(config: SlackChannelConfig = {}): SlackChannel { lastReasoningTypingAtMs: null, lastReasoningTypingStatus: null, pendingAuthMessageTs: {}, + pendingApprovalCards: {}, }, fetchFile: slackFetchFile, metadata(state): SlackInstrumentationMetadata { @@ -701,6 +713,17 @@ export function slackChannel(config: SlackChannelConfig = {}): SlackChannel { return rebuildSlackContext(state, session, config.credentials); }, + deliver(payload, channel) { + const incoming = payload["pendingApprovalCards"]; + if (typeof incoming === "object" && incoming !== null) { + channel.state.pendingApprovalCards = { + ...channel.state.pendingApprovalCards, + ...(incoming as SlackChannelState["pendingApprovalCards"]), + }; + } + return defaultDeliverResult(payload); + }, + routes: [ POST( config.route ?? SLACK_CHANNEL_DEFAULT_ROUTE, diff --git a/packages/eve/src/public/definitions/channel.ts b/packages/eve/src/public/definitions/channel.ts index f42d255a0..07977c065 100644 --- a/packages/eve/src/public/definitions/channel.ts +++ b/packages/eve/src/public/definitions/channel.ts @@ -433,8 +433,8 @@ function buildAdapter, session: SessionHandle): TCtx; readonly routes: readonly RouteDefinition[]; + deliver?(payload: DeliverPayload, ctx: TCtx): StepInput | void | Promise; receive?( input: GenericReceiveInput, args: { send: SendFn },