UNPKG

eve

Version:

Filesystem-first framework for durable backend AI agents that run anywhere.

372 lines (371 loc) 18.2 kB
import type { SessionAuthContext } from "#channel/types.js"; import type { CardElement } from "#compiled/chat/index.js"; import type { SessionContext } from "#public/definitions/callback-context.js"; import type { ChannelSessionOps } from "#public/definitions/defineChannel.js"; import type { HandleMessageStreamEvent } from "#protocol/message.js"; import { type SlackBotToken, type SlackHandle, type SlackThread } from "#public/channels/slack/api.js"; import { type SlackMessage } from "#public/channels/slack/inbound.js"; import { type UploadPolicyInput } from "#public/channels/upload-policy.js"; import { type SlackWebhookVerifier } from "#public/channels/slack/verify.js"; import { type Channel } from "#public/definitions/defineChannel.js"; type EventData<T extends HandleMessageStreamEvent["type"]> = Extract<HandleMessageStreamEvent, { type: T; }> extends { data: infer D; } ? D : undefined; /** * Pre-dispatch Slack context passed to `onAppMention` and * `onInteraction`. These hooks run on the inbound webhook side before the * runtime hydrates session state, so `state` is absent here. * {@link thread} owns thread-scoped operations (`post`, `postEphemeral`, * `startTyping`, `refresh`, `recentMessages`, `mentionUser`); {@link slack} * owns Slack identity (`channelId`, `threadTs`, `teamId`) plus the raw-API * escape hatch (`request`, `uploadFiles`). */ export interface SlackContext { readonly thread: SlackThread; readonly slack: SlackHandle; } /** * {@link SlackContext} plus the persisted per-session * {@link SlackChannelState}. Built by the channel's `context()` hook and * extended by {@link SlackEventContext}. */ export interface SlackChannelContext extends SlackContext { state: SlackChannelState; } /** * Slack context handed to `events[type]` handlers. Extends * {@link SlackChannelContext} (`thread`, `slack`, hydrated `state`) with * session operations ({@link ChannelSessionOps}). Unlike the pre-dispatch * {@link SlackContext}, `state` is hydrated here. */ export interface SlackEventContext extends SlackChannelContext, ChannelSessionOps { } export type { SlackApiResponse, SlackBotToken, SlackHandle, SlackThread, } from "#public/channels/slack/api.js"; export type { SlackWebhookVerifier } from "#public/channels/slack/verify.js"; type SlackEventHandler<T extends HandleMessageStreamEvent["type"]> = (data: EventData<T>, channel: SlackEventContext, ctx: SessionContext) => void | Promise<void>; /** * Delivery surface handed to `authorization.required` overrides. The * connection challenge is a credential: anyone who completes the sign-in * binds their identity to this session's connection. So the only * delivery capabilities here are private ones, an ephemeral reply in the * thread or a direct message. There is deliberately no public `post`, * no raw `slack.request` escape hatch, and no full thread handle. An * override can change the words, not the audience. */ export interface SlackAuthorizationEventContext { /** * Ephemeral message in the current thread, visible only to `userId`. * Same contract as {@link SlackThread.postEphemeral}. */ readonly postEphemeral: SlackThread["postEphemeral"]; /** * Direct message to `userId`'s IM conversation with the bot. Same * contract as {@link SlackThread.postDirectMessage} (requires the * `im:write` scope). */ readonly postDirectMessage: SlackThread["postDirectMessage"]; /** * Hydrated per-session channel state — read `triggeringUserId` to * target the delivery. */ readonly state: SlackChannelState; } /** * Signature of an `authorization.required` override. Unlike every other * event handler, it receives {@link SlackAuthorizationEventContext} * instead of the full {@link SlackEventContext} — see the context type * for why. */ export type SlackAuthorizationRequiredHandler = (data: EventData<"authorization.required">, channel: SlackAuthorizationEventContext, ctx: SessionContext) => void | Promise<void>; type SlackSessionFailedHandler = (data: EventData<"session.failed">, channel: SlackEventContext) => void | Promise<void>; /** * JSON-serializable per-session state, stored verbatim across workflow * step boundaries. Anything written here must round-trip through * `JSON.stringify` / `JSON.parse`. */ export interface SlackChannelState { /** Slack channel id seeded by the inbound mention. */ channelId: string | null; /** Slack thread root ts. */ threadTs: string | null; /** Slack team id, when the inbound event carried one. */ teamId: string | null; /** * Slack user id of the actor that triggered the current session/turn. * Captured on every inbound mention so default handlers (e.g. * `authorization.required`) can target ephemeral feedback at the right * user without re-parsing the mention payload. */ triggeringUserId?: string | null; /** * Buffered text from a `message.completed` event whose `finishReason` * was `"tool-calls"`. The default `actions.requested` handler uses the * first non-empty line as the next typing indicator, surfacing the * model's pre-tool narration instead of "Running ...". Cleared at * `turn.started` and after use. */ pendingToolCallMessage?: string | null; /** * Last reasoning-derived typing indicator sent by the default * `reasoning.appended` handler. Used to surface substantial progressive * extensions immediately while throttling smaller streamed deltas. */ lastReasoningTypingAtMs?: number | null; lastReasoningTypingStatus?: string | null; /** * Connection name to Slack message ts. Each entry is the public * link-free status post created by the default * `authorization.required` handler; the matching * `authorization.completed` handler edits it in place to surface the * resolution outcome. */ pendingAuthMessageTs?: Record<string, string>; } /** * Per-session metadata attached to tracing spans, projected by the * channel's `metadata(state)` hook. Fields mirror the inbound mention * (channel, team, thread, triggering user) and are `null` until an inbound * event seeds them. Open-ended (`Record<string, unknown>`) so deployments * can attach extra span attributes. */ export interface SlackInstrumentationMetadata extends Record<string, unknown> { readonly channelId: string | null; readonly teamId: string | null; readonly threadTs: string | null; readonly triggeringUserId: string | null; } /** * Slack channel credentials: outbound bot token plus inbound webhook * verification. Any field may be omitted to fall back to its env-var / * signing-secret default. */ export interface SlackChannelCredentials { /** * Bot token for all outbound Slack Web API calls. Falls back to * `process.env.SLACK_BOT_TOKEN` when omitted. */ readonly botToken?: SlackBotToken; /** * Signing secret used to HMAC-verify inbound webhook requests. Falls * back to `process.env.SLACK_SIGNING_SECRET` when neither this nor * `webhookVerifier` is supplied. */ readonly signingSecret?: string; /** * Custom inbound webhook verifier. When supplied, eve skips the * `SLACK_SIGNING_SECRET` fallback and delegates to it. Typically set by * integrations (e.g. Connect) that authenticate webhooks out-of-band. */ readonly webhookVerifier?: SlackWebhookVerifier; } /** Target accepted by `receive(slack, { target })` for proactive sessions. */ export interface SlackReceiveTarget { readonly channelId: string; readonly threadTs?: string; /** * Optional message posted into the Slack channel before the agent runs. * The post becomes the thread root and the first turn is threaded under * it, giving cross-channel handoffs a visible context anchor. Mutually * exclusive with {@link threadTs}. */ readonly initialMessage?: SlackInitialMessage; } /** * Pre-agent post issued by `slackChannel().receive` when the caller * provides `target.initialMessage`. Mirrors `ctx.thread.post`'s card * variant so the same `Card({...})` construction can be reused. */ export interface SlackInitialMessage { readonly card: CardElement; readonly fallbackText?: string; } export interface SlackInteractionAction { readonly actionId: string; readonly value?: string; readonly blockId?: string; /** * `selected_option.value` for radio / select / external_select * widgets. `undefined` for buttons and multi-select widgets. */ readonly selectedOptionValue?: string; /** * `ts` of the Slack message hosting the clicked component. Required to * update that message in place via `chat.update`, since `ctx.slack.threadTs` * resolves to the thread root (not the clicked message) for components * inside thread replies. */ readonly messageTs?: string; /** * Display label of the clicked widget: `text.text` for buttons, * `selected_option.text.text` for radio/static_select. Renders the * "answered" card without re-fetching the original request. */ readonly label?: string; /** * Slack actor who triggered the interaction, letting `onInteraction` * handlers attribute resolutions back to the clicker without re-parsing * the raw payload. Always present, since Slack requires `user` on every * `block_actions` payload. */ readonly user: SlackInteractionUser; } /** Slack actor on {@link SlackInteractionAction.user}, mirroring `body.user`. */ export interface SlackInteractionUser { readonly id: string; /** Modern canonical display handle. */ readonly username?: string; /** Legacy display handle, kept for older workspaces. */ readonly name?: string; } /** * Result of an `onAppMention` or `onDirectMessage` callback. Return an * object (auth may be `null`) to dispatch a turn, or `null` to drop the * inbound message. `context` strings are appended as user messages to * session history before the delivery message. */ export type SlackMentionResult = { readonly auth: SessionAuthContext | null; readonly context?: readonly string[]; } | null; export type SlackMentionResultOrPromise = SlackMentionResult | Promise<SlackMentionResult>; /** * Alias of {@link SlackMentionResult} for the `onDirectMessage` signature, * so DM handlers do not read in terms of "mention". */ export type SlackInboundResult = SlackMentionResult; /** {@link SlackInboundResult}, or a promise resolving to one. */ export type SlackInboundResultOrPromise = SlackMentionResultOrPromise; /** * Per-event Slack handlers keyed by harness stream-event type, passed to * `slackChannel({ events })`. Each key is optional; supplying one replaces * only that event's built-in default (see {@link defaultEvents}). Handlers * receive the event data, the {@link SlackEventContext}, and the session * {@link SessionContext}; `session.failed` receives only data and context. */ export interface SlackChannelEvents { readonly "turn.started"?: SlackEventHandler<"turn.started">; readonly "actions.requested"?: SlackEventHandler<"actions.requested">; readonly "action.result"?: SlackEventHandler<"action.result">; readonly "message.completed"?: SlackEventHandler<"message.completed">; readonly "message.appended"?: SlackEventHandler<"message.appended">; readonly "reasoning.appended"?: SlackEventHandler<"reasoning.appended">; readonly "reasoning.completed"?: SlackEventHandler<"reasoning.completed">; readonly "input.requested"?: SlackEventHandler<"input.requested">; readonly "turn.failed"?: SlackEventHandler<"turn.failed">; readonly "turn.completed"?: SlackEventHandler<"turn.completed">; readonly "session.failed"?: SlackSessionFailedHandler; readonly "session.completed"?: SlackEventHandler<"session.completed">; readonly "session.waiting"?: SlackEventHandler<"session.waiting">; /** * Override receives {@link SlackAuthorizationEventContext}, a * private-delivery context (ephemeral or DM), not the full * {@link SlackEventContext}. The challenge is a credential, so a * public post is not expressible here. */ readonly "authorization.required"?: SlackAuthorizationRequiredHandler; readonly "authorization.completed"?: SlackEventHandler<"authorization.completed">; } /** * Full-context variant of {@link SlackChannelEvents} consumed by the * channel internals. The framework's default `authorization.required` * handler keeps the full {@link SlackEventContext} because it owns the * public link-free status while user overrides remain private-only. The * factory adapts user overrides into this shape with * {@link constrainAuthorizationRequired}. */ export interface SlackChannelInternalEvents extends Omit<SlackChannelEvents, "authorization.required"> { readonly "authorization.required"?: SlackEventHandler<"authorization.required">; } export interface SlackChannelConfig { readonly credentials?: SlackChannelCredentials; readonly botName?: string; /** Override the default webhook route path (`/eve/v1/slack`). */ readonly route?: string; /** * Inbound upload policy applied to file attachments before they reach * the harness. Violating attachments are dropped with a warning so the * mention's text portion still gets delivered. Pass `"disabled"` to * reject every attachment. Defaults to the framework's 25 MB cap with * unrestricted media types. */ readonly uploadPolicy?: UploadPolicyInput; /** * Invoked when a Slack `app_mention` event arrives (only `app_mention`; * other event types are ignored). Decides whether to dispatch and with * what auth, and may run pre-dispatch side effects (e.g. * `ctx.thread.startTyping("Thinking...")`) on the inbound webhook side * before the runtime cold-starts. * * Return `{ auth }` to dispatch with that session auth context, or `null` * to drop the mention. May be sync or async; the result is awaited before * dispatching. Thrown errors are caught and logged and the mention is * dropped; wrap best-effort side effects in `try/catch` to keep them * non-fatal. Defaults to a workspace-scoped auth derivation that posts a * `"Thinking..."` typing indicator; replacing this replaces both. */ onAppMention?(ctx: SlackContext, message: SlackMessage): SlackMentionResultOrPromise; /** * Invoked on a direct message: a Slack `message` event with * `channel_type: "im"`. Subtype messages (edits, deletes, joins, etc.) * and bot messages (`bot_id` set, including the bot's own replies) are * filtered out first, so handlers only see plain user-authored DMs. * Decides whether to dispatch and with what auth, and may run * pre-dispatch side effects on the inbound webhook side before cold-start. * * Return `{ auth }` to dispatch with that session auth context, or `null` * to drop the message. May be sync or async; the result is awaited before * dispatching. Thrown errors are caught and logged and the message is * dropped; wrap best-effort side effects in `try/catch` to keep them * non-fatal. Defaults to a workspace-scoped auth derivation that posts a * `"Thinking..."` typing indicator; replacing this replaces both. * Requires the bot's Slack app to subscribe to `message.im` with the * `im:history` scope. */ onDirectMessage?(ctx: SlackContext, message: SlackMessage): SlackInboundResultOrPromise; /** * Handler for Slack `block_actions` interactive callbacks (button * clicks, select changes, etc.) **not** consumed by the framework's * HITL pipeline. Slack POSTs interactive payloads to the same webhook * route as mentions; the framework decodes them, routes any action whose * `action_id` starts with `eve_input:` to the runtime as an HITL * response (resuming a paused session), and forwards everything else * here, one invocation per non-HITL action. * * Runs on the inbound webhook side via `waitUntil()`, so the channel * returns `200 OK` immediately. Errors are caught and logged; they do * not affect the webhook response or sibling invocations. * * The `SlackContext` here is rebuilt from the interaction payload * (channel id, thread ts, team id), **not** the persisted thread state * used by event handlers. Use `ctx.slack.request(...)` for arbitrary * Slack Web API calls and `action.messageTs` to target `chat.update`. */ onInteraction?(action: SlackInteractionAction, ctx: SlackContext): void | Promise<void>; readonly events?: SlackChannelEvents; } /** * Concrete return type of {@link slackChannel}. Named so consumers can * default-export a `slackChannel(...)` call under `declaration: true` * without TypeScript emitting an internal path for `Channel`. */ export interface SlackChannel extends Channel<SlackChannelState, SlackReceiveTarget, SlackInstrumentationMetadata> { } /** * Slack channel factory. Wires up the webhook route, mention dispatch, * interaction handling, and a baseline set of typing / error / * connection-auth event handlers. Defaults apply per field: pass * `onAppMention` to fully replace the default mention pipeline (auth * derivation plus `"Thinking..."` typing), or an `events[type]` handler to * replace only that one event. Unsupplied fields keep their defaults. */ export declare function slackChannel(config?: SlackChannelConfig): SlackChannel; /** * Adapts a user-supplied `authorization.required` override to the full * internal event signature while handing it only the private-delivery * surface ({@link SlackAuthorizationEventContext}). Override code never * receives `thread.post` or the raw `slack.request` escape hatch, so the * challenge it renders cannot be addressed to the shared thread. */ export declare function constrainAuthorizationRequired(handler: SlackAuthorizationRequiredHandler): NonNullable<SlackChannelInternalEvents["authorization.required"]>;