import { Agent as HttpAgent } from "node:http"; import { Agent as HttpsAgent } from "node:https"; import { type ChannelAskSnapshot, type ChannelAskSubmission, type ChannelAskSubmissionResult, type ProcessJobProjection } from "@mono-agent/agent-contracts"; import { type RunnerHandle } from "@grammyjs/runner"; import { Bot } from "grammy"; import { type AgentResponder, type DownloadTelegramAttachmentsOptions, type TelegramAdapterLogger, type TelegramAdapterMessages, type TelegramAdapterStreamOptions, type TelegramFileDownloader } from "./adapter.js"; import type { TelegramCommandConfig, TelegramGroupTriggerMode, TelegramReactionsConfig } from "./config.js"; import type { TelegramChatId } from "./types.js"; type BotClientOptions = NonNullable[1]>["client"]>; /** * Thrown synchronously by {@link SerialQueue.run} when the queue is already at * its depth cap. The bot catches this sentinel to answer with the busy reply * instead of admitting an unbounded backlog. */ export declare class SerialQueueFullError extends Error { readonly code: "serial_queue_full"; constructor(maxDepth: number); } /** * Outcome of a proactive {@link TelegramBotController.notify}: whether the nudge * reached the chat, plus a machine-readable reason for the silent-failure paths * (queue-full, cancelled, empty answer, responder/delivery failure). Structurally * matches the agent-app NotifyDeliveryResult so channel hooks can return it as-is. */ export interface TelegramNotifyResult { readonly delivered: boolean; readonly reason?: string; /** Stable adapter outcome; queue-cap refusals use `conversation_busy`. */ readonly code?: string; /** True only when the adapter can prove retry has no duplicate-delivery risk. */ readonly retryable?: boolean; readonly ambiguous?: boolean; readonly deliveryId?: string; readonly channelId?: "telegram"; readonly historyRecorded?: boolean; readonly historyErrorCode?: string; /** Whether a process-job completion joined an active turn or ran after it. */ readonly disposition?: "steered" | "follow_up"; } /** * Options for {@link TelegramBotController.notify}. With `verbatim`, `text` is * posted to the chat UNCHANGED with no model call (native cron/webhook * notification — the producing run already wrote the message) and recorded to * the chat's history so a reply resumes with it in context. Without it, `text` * is run as a turn on the chat's harness and the agent's answer is delivered. */ export interface TelegramNotifyOptions { readonly verbatim?: boolean; /** Stable host delivery identity carried out of band for process-job wake binding. */ readonly deliveryKey?: string; /** Prefer the active turn while retaining this notification's fallback queue slot. */ readonly steerActive?: boolean; /** * Post the notification silently (`disable_notification`) so it arrives without * a push sound. Set by the channel driver during configured quiet hours. */ readonly silent?: boolean; } /** * Minimal per-conversation serial queue: each submitted task runs only after the * previous one settles, preserving arrival order. A task's failure does not * poison the queue (the chain swallows it; the caller still sees the rejection). * * The queue is bounded by {@link maxDepth}: once `depth` reaches the cap, `run` * rejects synchronously with a {@link SerialQueueFullError} BEFORE incrementing * or chaining, so an over-cap task never enters the chain (mirroring the harness * LiveSessionManager's maxPendingPerConversation rejection). */ export declare class SerialQueue { private tail; private depth; private readonly maxDepth; constructor(maxDepth?: number); run(task: () => Promise): Promise; /** True when no task is queued or running. */ get idle(): boolean; get full(): boolean; } /** * Interceptor for blocking ask-the-user round-trips (the app's interaction * bridge). While an ask is pending on a conversation, the user's next plain-text * message RESOLVES it (consumed pre-admission, never queued as a turn) and * `/cancel` fails it. */ export interface TelegramPendingAsks { getPendingAsk(conversationId: string): ChannelAskSnapshot | undefined | Promise; submitAskAnswers(input: ChannelAskSubmission): ChannelAskSubmissionResult | Promise; cancel(conversationId: string): void; } /** One effort choice displayed by Telegram's per-chat runtime controls. */ export interface TelegramRuntimeEffortOption { readonly value: string; readonly label: string; } /** One configured runtime model and the effort choices it supports. */ export interface TelegramRuntimeModelOption { readonly value: string; readonly label: string; readonly efforts: readonly TelegramRuntimeEffortOption[]; } /** * Display-ready model catalog supplied by the host. The adapter deliberately * does not discover models or accept arbitrary references: it only selects from * this configured primary/fallback list. */ export interface TelegramRuntimeControls { readonly defaultModel: string; readonly defaultEffort?: string; readonly models: readonly TelegramRuntimeModelOption[]; } export interface CreateTelegramBotOptions { readonly botToken: string; readonly responder: AgentResponder; readonly allowedChatIds?: readonly TelegramChatId[]; readonly allowAllChats?: boolean; /** In groups, run on every message (`any`) or only native mentions/replies (`mention`). */ readonly groupMode?: TelegramGroupTriggerMode; /** Remove matching native @mentions before passing text to the responder. Defaults to true. */ readonly stripMentionText?: boolean; readonly stream?: TelegramAdapterStreamOptions; readonly messages?: TelegramAdapterMessages; readonly logger?: TelegramAdapterLogger; /** Update types to long-poll for. Defaults to messages only. */ readonly allowedUpdates?: readonly string[]; /** * Custom command-menu entries. When non-empty the bot registers them (plus the * available built-ins) via `setMyCommands` at startup and dispatches each * command's `prompt` as a turn. Built-in start/help/cancel/new/model/effort cannot * be overridden. */ readonly commands?: readonly TelegramCommandConfig[]; /** Optional per-chat `/model` and `/effort` controls over a host-supplied catalog. */ readonly runtimeControls?: TelegramRuntimeControls; /** * Per-state lifecycle reactions via `setMessageReaction` (👀 working, 👍 done, * 👎 error). Each state can be toggled independently; a disabled terminal state * clears the working reaction instead of leaving it. Best-effort, default off. */ readonly reactions?: TelegramReactionsConfig; /** * Quiet window (ms) for aggregating a multi-photo/video album (messages sharing * a `media_group_id`) into one request. Defaults to 1000. Set 0 to flush on the * next tick (used by tests). */ readonly albumAggregationDelayMs?: number; /** Delete any configured webhook before polling. Defaults to true. */ readonly deleteWebhookOnStart?: boolean; /** * Bound (ms) for the startup `deleteWebhook` call so a flaky network cannot * stall boot. Defaults to {@link DEFAULT_DELETE_WEBHOOK_TIMEOUT_MS} (5000). */ readonly deleteWebhookTimeoutMs?: number; /** Drop updates queued before start. Defaults to false. */ readonly dropPendingUpdates?: boolean; /** * Outbound transport tuning. `ipFamily: 4` pins the Bot API HTTP client to * IPv4 (and `6` to IPv6) via a family-locked keep-alive https.Agent — a * workaround for networks whose IPv6 route to api.telegram.org is broken and * times out getUpdates. Omit for the default dual-stack behavior. */ readonly transport?: { readonly ipFamily?: 4 | 6; }; /** * Poll-liveness watchdog window (ms). If no getUpdates resolves within this * window the runner is force-restarted even though its task never rejected. * Defaults to {@link DEFAULT_POLL_WATCHDOG_MS} (120000). Set <= 0 to disable. */ readonly pollWatchdogMs?: number; /** * Called once when polling becomes degraded after a successful start (the * runner's task rejects or the poll-liveness watchdog expires). The adapter * ALWAYS restarts afterwards, so a host should treat this as "degraded, * recovering" — not terminal — and pair it with {@link onPollingRecovered}. */ readonly onPollingError?: (error: unknown) => void; /** * Called once when a (re)started runner stays up past the stability window and * completes a successful poll AFTER a prior failure — i.e. the poller has * recovered. Lets a host flip a "degraded" channel back to "running". Not * fired for the initial healthy start. */ readonly onPollingRecovered?: () => void; /** * Inbound attachment download tuning (byte cap + MIME allowlist). Inbound * Telegram media bytes are fetched via the Bot API and inlined into * `request.attachments`; failures skip the attachment without failing the run. */ readonly attachments?: DownloadTelegramAttachmentsOptions; /** * Pending-ask interceptor. Checked in `handleAgentMessage` BEFORE per-chat * admission — a reply sent while a turn is blocked on `AskUser` would * otherwise queue behind that very turn and deadlock until the ask times out. */ readonly pendingAsks?: TelegramPendingAsks; /** Clear one host-owned conversation session for the built-in `/new` command. */ readonly startNewSession?: (conversationId: string) => Promise; /** * Base URL of a self-hosted Bot API server (e.g. `http://127.0.0.1:8081`). * Applied to every API call and to file downloads; a `--local` server's * absolute file paths are read straight from disk. Omit for api.telegram.org. */ readonly apiRoot?: string; /** Test seam: build the grammY Bot (e.g. with a fake botInfo + transformer). */ readonly botFactory?: (token: string) => Bot; /** * Test seam: override the file downloader (getFile + file URL fetch). Defaults * to one backed by `bot.api.getFile` and `fetch` against the Telegram file URL. */ readonly fileDownloaderFactory?: (bot: Bot, token: string) => TelegramFileDownloader; /** Test seam: build the polling runner. Defaults to `@grammyjs/runner`'s `run`. */ readonly runnerFactory?: (bot: Bot) => RunnerHandle; } export interface TelegramBotController { /** The configured grammY bot. Exposed mainly so tests can drive `handleUpdate`. */ readonly bot: Bot; /** Start concurrent long polling. Idempotent while already running. */ start(): Promise; /** Stop polling and wait for the runner to settle. */ stop(): Promise; /** * Deliver a proactive notification to `chatId`, serialized through the same * per-chat queue as inbound messages. By default runs `text` as a turn and * delivers the answer; with `options.verbatim` posts `text` unchanged (no model * call) and records it to history. Used by cron/webhook nudges. */ notify(chatId: TelegramChatId, text: string, options?: TelegramNotifyOptions): Promise; /** Host-only lifecycle card update; never invokes the responder. */ updateProcessJob(chatId: TelegramChatId, projection: ProcessJobProjection, options?: { readonly silent?: boolean; }): Promise; /** * Post (or edit in place) a short tool-progress status line, keyed per * `(chat, key)`. A terminal state (`done`/`failed`) writes the final text and * clears the tracking so the next job with the same key starts a new message. * Best-effort: a failed send/edit never throws. */ postStatus(chatId: TelegramChatId, text: string, options: { readonly key: string; readonly state: "working" | "done" | "failed"; }): Promise; /** Present or advance one bridge-owned AskUser interaction. */ presentAsk(chatId: TelegramChatId, snapshot: ChannelAskSnapshot): Promise; updateAsk(chatId: TelegramChatId, snapshot: ChannelAskSnapshot): Promise; /** * Test seam: total in-flight AbortControllers tracked across all chats. Used to * assert the over-cap busy path does not leak an eagerly-created controller. */ activeControllerCount(): number; /** Test/health seam proving process-job message refs remain bounded. */ processJobMessageRefCount(): number; /** Test/health seam proving all lifecycle identity state remains bounded. */ processJobLifecycleStateCount(): number; } /** * Build a grammY bot that routes authorized text messages to an agent responder. * * grammY owns the transport and (via `@grammyjs/runner`) concurrent polling. * Middleware order is: authorization gate → built-in control commands → * agent run handler (`message:text`) → unsupported fallback (other messages). * * Concurrency is NOT rejected per chat. Every message is handed to the responder, * which routes through the runtime harness; the harness serializes per * conversation (queue-after-turn follow-ups answered on the warm session). For * each in-flight message the bot tracks an `AbortController` in a per-chat set so * `/cancel` can abort every live turn for the chat (in addition to clearing * queued follow-ups via `responder.cancel`). */ export declare function createTelegramBot(options: CreateTelegramBotOptions): TelegramBotController; /** * Build the grammY client options for the default Bot construction. Extracted * (and exported) so the apiRoot/agent interplay is unit-testable — the botFactory * test seam otherwise owns the whole construction. * * grammY's node platform fetches with node-fetch, which rejects an agent whose * protocol mismatches the URL — so the family-locked keep-alive-off agent (see * the ipFamily rationale on {@link CreateTelegramBotOptions.transport}) must be * an `http.Agent` when the apiRoot is plain http (a loopback self-hosted server) * and an `https.Agent` otherwise. */ export declare function buildTelegramBotClientOptions(options: { readonly apiRoot?: string; readonly ipFamily?: 4 | 6; }): { client: BotClientOptions; agent?: HttpAgent | HttpsAgent; }; export {}; //# sourceMappingURL=bot.d.ts.map