eve
Version:
Filesystem-first framework for durable backend AI agents that run anywhere.
372 lines (371 loc) • 18.2 kB
TypeScript
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"]>;