/** * Channel-agnostic vocabulary shared between the `dsh-connect` core and every * channel adapter (Feishu first, DingTalk/WeChat later). * @module dsh-connect/types */ import type { ChannelConnectionStatus } from "./settings/channel-status.js"; /** Coarse chat surface classification: one-on-one or group. */ export type ChatType = "p2p" | "group"; /** A normalized inbound message from any channel. */ export interface InboundMessage { /** Channel adapter id that produced this message, e.g. `"feishu"`. */ readonly channel: string; /** Stable chat key the adapter owns (open_id / chat_id, …). */ readonly chatKey: string; readonly chatType: ChatType; /** Stable sender key (open_id / user_id, …). */ readonly senderKey: string; /** Plain-text body (adapters normalize rich media to text + tags). */ readonly text: string; /** Opaque reference to the triggering message, for replying in-thread. */ readonly replyRef?: string; /** Local file paths of images attached to the message (downloaded by the adapter). */ readonly images?: readonly string[]; /** Local file paths of non-image attachments (files/audio/video) downloaded by the adapter. */ readonly files?: readonly string[]; /** Set when image download failed (e.g. missing im:resource permission). */ readonly imageError?: string; /** Set when a non-image attachment download failed. */ readonly fileError?: string; } /** Where an outbound message goes. */ export interface OutboundTarget { readonly chatKey: string; readonly chatType: ChatType; readonly replyRef?: string; /** Channel-specific user ids to @-mention on delivery (groups only; best-effort). */ readonly atUsers?: readonly string[]; } /** A brief status card pushed on turn completion or error. */ export interface SummaryCard { readonly markdown: string; } /** One selectable option in an interactive choice prompt. */ export interface ChoiceOption { /** Opaque id returned to the caller when this option is chosen. */ readonly id: string; readonly label: string; } /** One named group of a choice prompt; `ids` refer to `ChoicePrompt.options`. */ export interface ChoiceSection { readonly title?: string; /** Option ids belonging to this section, in display order. */ readonly ids: readonly string[]; /** Number of columns per row for this section (overrides prompt default). */ readonly columnsPerRow?: number; } /** How a channel should render a choice prompt. */ export type ChoiceRender = "buttons" | "dropdown" | "auto"; /** An interactive single-choice prompt rendered by the channel as buttons. */ export interface ChoicePrompt { readonly title: string; readonly description?: string; readonly options: readonly ChoiceOption[]; /** Optional named groups — channels render these as separated sections. */ readonly sections?: readonly ChoiceSection[]; /** Optional footnote rendered at the bottom of the card. */ readonly footer?: string; /** Number of columns per row for button grid (default: 2). Use 1 for full-width items. */ readonly columnsPerRow?: number; /** * Rendering mode: `buttons` (explicit grid), `dropdown` (single-select * list — Feishu `select_static`; other channels fall back to their default), * or `auto` (channel picks: small sets -> buttons, large sets -> dropdown). * Defaults to `buttons` for backward compatibility. */ readonly render?: ChoiceRender; /** Optional option id to pre-select when a channel renders a dropdown. */ readonly initialOption?: string; /** * Keep listening, but do not repaint the card. * * Set while a question has gone unanswered long enough that redrawing it every expiry is * just noise. The listener must stay armed regardless — dropping it is what made a card * tapped on return do nothing at all. An adapter that cannot separate the two may ignore * this; it then keeps repainting, which is the previous behaviour rather than a regression. */ readonly quiet?: boolean; } /** Result of an interactive choice: the picked option and the card's message id. */ export interface ChoiceResult { readonly choice: string | undefined; /** Message id of the card; pass it back as `updateMessageId` to reuse the card. */ readonly messageId: string; } /** Why a driven turn ended. */ export type TurnReason = "completed" | "aborted" | "blocked" | "error" | "max-tokens" | "interrupted" | "unknown"; /** Settled outcome of one agent turn. */ export interface TurnOutcome { readonly reason: TurnReason; /** Last non-empty assistant text of the turn. */ readonly text: string; readonly code?: string; readonly message?: string; /** `provider/model` used by the turn's last LLM request. */ readonly model?: string; /** Total input tokens (sum of every request) across the turn. */ readonly inputTokens?: number; /** Total output tokens across the turn. */ readonly outputTokens?: number; /** Total cache-read tokens across the turn. */ readonly cacheReadTokens?: number; /** Input tokens of the turn's last request (≈ current context size). */ readonly contextSize?: number; /** Context window of the turn's last request (for compaction advice). */ readonly contextWindow?: number; /** Number of steps executed in the turn. */ readonly steps?: number; /** Wall-clock duration of the turn (from `turn/start` to `turn/end`). */ readonly elapsedMs?: number; } /** Push-based async iterable bridge: producers push, consumers `for await`. */ export interface AsyncQueue extends AsyncIterable { push(value: T): void; end(): void; } /** * A channel's transport face. The core drives agents and hands the adapter * normalized text; the adapter owns wire encoding, streaming, and auth policy. */ export interface ChannelAdapter { readonly id: string; /** * Whether this channel can actually present an interactive prompt. Defaults * to `true` when omitted. A mirror/observer channel (`web`) has no inbound * face of its own, so its `promptChoice` is a no-op that can never report a * tap — it sets this to `false` so the interaction bridge leaves the request * to whichever client *can* show it instead of spinning on a card nobody * will ever see. */ readonly supportsChoices?: boolean; /** Establish the long-connection / subscription; must be reconnect-safe. */ start(): Promise; stop(): Promise; /** Deliver one text/markdown message (best-effort, non-streaming). */ sendText(target: OutboundTarget, text: string): Promise; /** * Post a text message and report its id, so it can be rewritten later. * * Optional, and separate from `sendText` for the same reason the card pair is split: a * repeatable status line — the progress reminder on a turn nobody is watching — should * own one message that gets updated, not append a new one every interval. Measured * before this existed: a 13-hour turn produced over 160 identical reminder bubbles. */ sendTextWithId?(target: OutboundTarget, text: string): Promise<{ messageId: string; }>; /** * Rewrite a text message previously posted by {@link sendTextWithId}. * * Optional and paired with it. A channel that cannot edit text simply keeps posting new * messages, which is the old behaviour rather than a regression. */ updateText?(messageId: string, text: string): Promise; /** Deliver a status/summary card. */ sendCard(target: OutboundTarget, card: SummaryCard): Promise; /** * Post a card and report its message id, so the same card can be rewritten later. * * Optional, and separate from `sendCard` rather than replacing it: most cards are * final when they are sent, and only a *live* one — a subtask list whose entries * change as the work proceeds — needs its id back. An adapter that cannot edit a * card simply omits this, and the caller falls back to posting a new one. */ sendCardWithId?(target: OutboundTarget, card: SummaryCard): Promise<{ messageId: string; }>; /** * Rewrite a card previously posted by {@link sendCardWithId}. * * Optional, and paired with it: without the id there is nothing to address, so an * adapter implementing this but not `sendCardWithId` has no way to be called. */ updateCard?(messageId: string, card: SummaryCard): Promise; /** * Deliver a local file (image / document / audio / video) to the chat. * Optional: channels without file support omit it, and the runner falls * back to sending the file's path as text. */ sendFile?(target: OutboundTarget, filePath: string, options?: { filename?: string; }): Promise; /** * Stream `chunks` into one progressively-updated reply. Resolves when the * producer is exhausted (`chunks` ends) and the adapter has finalized it. */ streamText(target: OutboundTarget, chunks: AsyncIterable): Promise; /** * Present an interactive single-choice prompt. When `updateMessageId` is given, * reuse that card (replace its content in place) instead of sending a new one — * this is what lets a menu chain navigate on a single card. Resolves with the * picked option id (or `undefined` on dismiss/timeout) plus the card message id. * * When `signal` aborts, the prompt must retire immediately: resolve with * `{ choice: undefined }` and stop accepting taps, leaving the card for the * caller to replace or close. Adapters that cannot be cancelled ignore it. * * `onPresented` reports the card's message id as soon as it exists on screen, which * is *before* the choice resolves. The caller needs it early: a question can be * answered by typing instead of tapping, and the card must still be closed when that * happens — but the returned `ChoiceResult` only arrives when the card itself * settles, which on the typed path is never. Without this the buttons stay live on a * question that has already been answered, and a tap on them is absorbed silently, * so it reads as the bot ignoring the user. Optional: adapters that cannot report a * stable id simply never call it. */ promptChoice(target: OutboundTarget, prompt: ChoicePrompt, updateMessageId?: string, signal?: AbortSignal, onPresented?: (messageId: string) => void): Promise; /** Replace a menu card with a completion notice (closes the interaction). */ closeMenu(messageId: string, summary: string): Promise; /** Register the inbound handler; called for every normalized message. */ onInbound(handler: (msg: InboundMessage) => void | Promise): void; /** * Live transport state, for channels whose long-connection library can report * one (Feishu delegates to the SDK's `getConnectionStatus`). Optional, and its * *absence* is meaningful: a channel that cannot answer leaves the settings * pane reporting only what the runtime knows (`running`/`stopped`/`failed`), * rather than a hard-coded 「已连接」 nobody verified. * * Returns `undefined` when the transport exists but has nothing to say yet — * e.g. the webhook transport, which holds no socket. */ connectionStatus?(): ChannelConnectionStatus | undefined; } //# sourceMappingURL=types.d.ts.map