diff --git a/AGENTS.md b/AGENTS.md index 09e6241..5f26179 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -22,7 +22,9 @@ ## 3. Project Topology -- `/index.ts`: Main extension runtime, Telegram API integration, queueing, rendering, previews, menus, and tool wiring +- `/index.ts`: Main extension entrypoint and runtime composition layer for the bridge +- `/lib/*.ts`: Flat domain modules for reusable runtime logic. Favor domain files such as queueing/runtime, replies, polling, updates, attachments, registration/hooks, Telegram API/config, turns, media, setup, rendering, menu/status/model-resolution support, and other cohesive bridge subsystems; use `shared` only when a type or constant truly spans multiple domains +- `/tests/*.test.ts`: Domain-mirrored regression suites that follow the same flat naming as `/lib` - `/docs/README.md`: Documentation index for technical project docs - `/docs/architecture.md`: Runtime and subsystem overview for the bridge - `/README.md`: User-facing project entry point, install guide, and fork summary @@ -40,9 +42,10 @@ ## 5. Architectural Decisions -- The extension ships as a single runtime file for simple pi packaging, so logical sections inside `index.ts` are the primary module boundaries +- `index.ts` stays the single extension entrypoint, while reusable runtime logic should be split into flat domain files under `/lib`; prefer domain-oriented grouping over atomizing every helper into its own file, and use `shared` sparingly for genuinely cross-domain types or constants - The bridge is session-local and intentionally pairs with a single allowed Telegram user per config -- Telegram queue state is tracked locally and must stay aligned with pi agent lifecycle hooks; dispatch must respect active turns, pending dispatch, compaction, and pi pending-message state +- Telegram queue state is tracked locally and must stay aligned with pi agent lifecycle hooks; queued items now have explicit kinds and lanes so prompt turns and synthetic control actions can share one ordering model, while dispatch still respects active turns, pending dispatch, compaction, and pi pending-message state +- Prompt items should remain in the queue until `agent_start` consumes the dispatched turn; removing them earlier breaks active-turn binding, preview delivery, and end-of-turn follow-up behavior - In-flight `/model` switching is supported only for Telegram-owned active turns and is implemented as set-model plus synthetic continuation turn plus abort; if a tool call is active, the abort is delayed until that tool finishes instead of interrupting the tool mid-flight - Telegram replies render through Telegram HTML, not raw Markdown; real code blocks must stay literal and escaped - `telegram_attach` is the canonical outbound file-delivery path for Telegram-originated requests @@ -50,21 +53,25 @@ ## 6. Engineering Conventions - Treat queue handling, compaction interaction, and lifecycle-hook state transitions as regression-prone areas; validate them after changing dispatch logic -- Treat Markdown rendering as Telegram-specific output work, not generic Markdown rendering; preserve literal code content and avoid HTML chunk splits that break tags +- Treat Markdown rendering as Telegram-specific output work, not generic Markdown rendering; preserve literal code content, avoid HTML chunk splits that break tags, prefer width-efficient monospace table and list formatting for narrow clients, and flatten nested Markdown quotes into indented single-blockquote output because Telegram does not render nested blockquotes reliably - Keep comments and user-facing docs in English unless the surrounding file already follows another convention -- Prefer targeted edits inside `index.ts` over broad rewrites unless a section boundary is being intentionally restructured +- Each project `.ts` file should start with a short multi-line responsibility header comment that explains the file boundary to future maintainers +- Name extracted `/lib` modules and mirrored `/tests` suites by bare domain when the repository already supplies the Telegram scope; prefer `api.ts`, `queue.ts`, `updates.ts`, and `queue.test.ts` over redundant `telegram-*` filename prefixes +- Prefer targeted edits, keeping `index.ts` as the orchestration layer and moving reusable logic into flat `/lib` domain modules when a subsystem becomes large enough to earn extraction; current extracted domains include queueing/runtime decisions, replies, polling, updates, attachments, registration and lifecycle-hook binding, Telegram API/config support, turn-building, media extraction, setup, rendering, status rendering, menu/model-resolution/UI support, and model-switch support ## 7. Operational Conventions - When Telegram-visible behavior changes, sync `README.md` and the relevant `/docs` entry in the same pass - When durable runtime constraints or repeat bug patterns emerge, record them here instead of burying them in changelog prose - When fork identity changes, keep `README.md`, package metadata, and docs aligned so the published package does not point back at stale upstream coordinates +- Work only inside this repository during development tasks; updating the installed Pi extension checkout is a separate manual operator step, not part of normal in-repo implementation work ## 8. Integration Protocols - Telegram API methods currently used include polling, message editing, draft streaming, callback queries, reactions, file download, and media upload endpoints - pi integration depends on lifecycle hooks such as `before_agent_start`, `agent_start`, `message_start`, `message_update`, and `agent_end` - `ctx.ui.input()` provides placeholder text rather than an editable prefilled value; when a real default must appear already filled in, prefer `ctx.ui.editor()` +- For `/telegram-setup`, prefer the locally saved bot token over environment variables on repeat setup runs; env vars are the bootstrap path when no local token exists - Status/model/thinking controls are driven through Telegram inline keyboards and callback queries - Inbound files may become pi image inputs; outbound files must flow through `telegram_attach` diff --git a/CHANGELOG.md b/CHANGELOG.md index 42f2480..d7d1a48 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,11 +2,16 @@ ## Current +- `[Docs]` Added short responsibility header comments to every project `.ts` file. Impact: file boundaries are easier to understand while navigating the growing `/lib` split. +- `[Naming]` Renamed extracted domain modules and mirrored regression suites to use repo-scoped bare domain filenames such as `api.ts`, `queue.ts`, and `queue.test.ts` instead of repeating `telegram-*` in every path. Impact: the internal topology is easier to scan and stays aligned with the repository-level Telegram scope. - `[Controls]` Expanded Telegram session controls with a richer `/status` view, inline model selection, and thinking-level controls. Impact: more bridge configuration can be managed directly from Telegram. - `[Queue]` Upgraded Telegram turn queueing with previews, reaction-driven prioritization/removal, media-group handling, aborted-turn history preservation, and safer dispatch gating. Impact: follow-up handling is more transparent and less prone to lifecycle races. -- `[Rendering]` Added Telegram-oriented Markdown rendering and hardened reply streaming/chunking behavior. Impact: formatted replies render more reliably while preserving literal code blocks. -- `[Runtime]` Hardened attachment delivery, polling/runtime behavior, and Telegram session integration. Impact: the bridge is more robust as a daily Telegram frontend for pi. +- `[Rendering]` Added Telegram-oriented Markdown rendering and hardened reply streaming/chunking behavior, including narrower monospace Markdown table output without outer side borders, monospace list markers for unordered and ordered lists, and flattened nested quote indentation inside a single Telegram blockquote. Impact: formatted replies render more reliably while preserving literal code blocks and using width more efficiently on narrow Telegram clients. +- `[Runtime]` Hardened attachment delivery, polling/runtime behavior, Telegram session integration, preview-finalization and reply-transport routing into the replies domain, lazy Telegram API client routing into the Telegram API domain, turn-building extraction into its own domain, menu/model-resolution plus menu-state, pure menu-page derivation, pure menu render-payload builders, menu-message runtime, callback parsing, callback entry handling, callback mutation helpers, full model-callback planning and execution, and interface-polished callback effect ports into the menu domain, direct execute-from-update routing into the updates domain, model-switch restart glue extraction into the model-switch domain, and tool/command/lifecycle-hook registration extraction into a dedicated registration domain. Impact: the bridge is more robust as a daily Telegram frontend for pi. - `[Metadata]` Updated package repository metadata to point at the `llblab/pi-telegram` fork. Impact: published package links no longer send users to stale upstream coordinates. -- `[Validation]` Added lightweight regression tests for Telegram Markdown rendering and queue/compaction dispatch guards. Impact: key renderer and queue invariants now have repeatable automated coverage. +- `[Validation]` Added lightweight regression tests for Telegram Markdown rendering, queue/runtime/agent-loop/session/control/dispatch, replies, polling, updates, attachments, registration, turns, menu, and Telegram API/media/config helpers, including quote/list, table, link/code, mixed-link/code chunking, mixed-block chunk transitions, long multi-block, long-quote, long inline-formatting chunk boundaries, list-code-quote-prose chunk transitions, narrower monospace table rendering without outer side borders, monospace unordered and ordered list markers, flattened nested quote indentation inside one Telegram blockquote, inbound poll/pair/dispatch runtime cases, preview finalization, aborted-turn history carry-over, queued-status/model-after-agent-end sequencing, compaction gating, media-group debounce dispatch, direct menu callback planning and execution, pure menu-page derivation, pure menu render-payload builders, reaction-driven reprioritization/removal, immediate in-flight model-switch continuation, delayed abort-after-tool-completion, lazy Telegram API client routing, turn-building, and scoped-model resolution. Impact: key renderer and queue invariants now have repeatable automated coverage across the known high-risk bridge paths. - `[Model Switching]` Enabled `/model` during an active Telegram-owned run by applying the new model and continuing on the new model automatically, delaying the abort until the current tool finishes when needed. Impact: Telegram can now approximate pi's manual stop-switch-continue workflow with fewer mid-tool aborts. +- `[Queue Core]` Introduced queued item kinds and explicit queue-lane ordering semantics so prompt turns and synthetic control actions share one ordering model, then regrouped the extracted helpers into flatter domain-oriented `/lib` modules such as queue, replies, polling, updates, attachments, turns, menu, Telegram API, and registration while keeping `index.ts` as the entrypoint. Prompt items now stay queued until `agent_start` consumes the dispatched turn, which restores correct active-turn binding for previews and final delivery. Impact: the bridge now has a clearer foundation for scheduling async extension operations alongside Telegram prompts without losing a single obvious runtime entry file. +- `[Registration]` Moved extension tool, command, and lifecycle-hook binding into the registration domain and added registration-focused regression coverage. Impact: extension wiring is easier to reason about and test without dragging full runtime state into every registration change. +- `[Control Queue]` Moved `/status` and `/model` command handling onto high-priority control queue items. Impact: control actions can wait safely behind the current run while still jumping ahead of normal queued prompts. - `[Setup]` `/telegram-setup` now shows the stored bot token first, otherwise prefills from common Telegram bot environment variables before falling back to the placeholder, using an actual prefilled editor when a real default exists. Impact: repeat setup respects local saved state while first-run and secret-managed setup stay fast. diff --git a/README.md b/README.md index 88434f0..0f5bb0b 100644 --- a/README.md +++ b/README.md @@ -2,8 +2,6 @@ ![pi-telegram screenshot](screenshot.png) -> Full pi build session: [View the session transcript](https://pi.dev/session/#14acfe07b7844c8abec55ed9fbddc17f), which captures the full pi session in which `pi-telegram` was built. - Telegram DM bridge for pi. This repository is a fork of the original [`badlogic/pi-telegram`](https://github.com/badlogic/pi-telegram). @@ -22,10 +20,11 @@ Compared to upstream commit `cb34008`, this fork significantly extends and harde - Better Telegram control UI, including an improved `/status` view with inline buttons for model and thinking selection - Interactive model selection improvements, including scoped model lists, thinking-level control for reasoning-capable models, and in-flight restart on a newly selected model for active Telegram-owned runs -- Queueing and interaction upgrades, including queue previews, reaction-based prioritization/removal, media-group handling, and safer dispatch behavior -- Markdown and reply rendering improvements, with richer formatting support and multiple fixes for incorrect Telegram rendering and chunking edge cases +- Queueing and interaction upgrades, including queue previews, reaction-based prioritization/removal, media-group handling, high-priority control actions, and safer dispatch behavior +- Markdown and reply rendering improvements, with richer formatting support, narrow-client-friendly table/list rendering, quote compatibility fixes, and multiple fixes for incorrect Telegram rendering and chunking edge cases - Streaming, attachment, and delivery workflow hardening, including more robust preview updates and file handling - General runtime polish, bug fixes, and refactors across pairing, command handling, and Telegram session behavior +- Cleaner internal domain layout, with flat `/lib/*.ts` modules and mirrored `/tests/*.test.ts` suites that use repo-scoped domain names instead of redundant `telegram-*` filename prefixes In short: this fork is no longer just a repackaged copy of upstream; it is a feature-expanded and bug-fixed Telegram frontend for pi. @@ -104,8 +103,8 @@ Chat with your bot in Telegram DMs. Additional fork-specific controls: -- `/status` now has a richer view with inline buttons for model and thinking controls -- `/model` opens the interactive model selector and can restart the active Telegram-owned run on the newly selected model, waiting for the current tool call to finish when needed +- `/status` now has a richer view with inline buttons for model and thinking controls, and joins the high-priority control queue when pi is busy +- `/model` opens the interactive model selector, joins the high-priority control queue when pi is busy, and can restart the active Telegram-owned run on the newly selected model, waiting for the current tool call to finish when needed - `/compact` starts session compaction when pi and the Telegram queue are idle - Queue reactions: `πŸ‘` prioritizes a waiting turn, `πŸ‘Ž` removes it diff --git a/docs/architecture.md b/docs/architecture.md index a919d4c..e599e3e 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -11,16 +11,42 @@ ## Runtime Structure -The implementation currently lives in `index.ts` and is organized by logical sections rather than physical modules. +`index.ts` remains the extension entrypoint and composition layer. Reusable runtime logic is split into flat domain files under `/lib` rather than into a deep local module tree. -Main runtime areas: +Domain grouping rule: prefer cohesive domain files over atomizing every helper into its own file. A `shared` domain is allowed only for types or constants that genuinely span multiple bridge domains. -- Telegram API types and local bridge state -- Generic utilities and Markdown/rendering helpers -- Message delivery, previews, and attachment sending -- Interactive model/status menu state and callback handling -- Queue management for pending and active Telegram turns -- Polling loop and pi lifecycle-hook integration +Naming rule: because the repository already scopes this codebase to Telegram, extracted module and test filenames use bare domain names such as `api.ts`, `queue.ts`, `updates.ts`, and `queue.test.ts` rather than repeating `telegram-*` in every filename. + +Current runtime areas include: + +- Telegram API types and local bridge state in `index.ts` +- Queueing and queue-runtime helpers in `/lib/queue.ts` +- Reply, preview, preview-finalization, reply-transport, and rendered-message delivery helpers in `/lib/replies.ts` +- Polling request, stop-condition, and long-poll loop helpers in `/lib/polling.ts` +- Telegram API/config helpers and lazy bot-token client wrappers in `/lib/api.ts` +- Telegram turn-building helpers in `/lib/turns.ts` +- Telegram media/text extraction helpers in `/lib/media.ts` +- Telegram updates extraction, authorization, flow, execution-planning, direct execute-from-update routing, and runtime helpers in `/lib/updates.ts` +- Telegram attachment queueing and delivery helpers in `/lib/attachments.ts` +- Telegram tool, command, and lifecycle-hook registration helpers in `/lib/registration.ts` +- Setup/token prompt helpers in `/lib/setup.ts` +- Markdown and Telegram message rendering helpers in `/lib/rendering.ts` +- Status rendering helpers in `/lib/status.ts` +- Menu/model-resolution, menu-state construction, pure menu-page derivation, pure menu render-payload builders, menu-message runtime, callback parsing, callback entry handling, callback mutation helpers, full model-callback planning and execution, interface-polished callback effect ports, status-thinking callback handling, and UI helpers in `/lib/menu.ts` +- Model-switch guard, continuation, and restart helpers in `/lib/model-switch.ts` +- Telegram API-bound reply transport wiring and broader event-side orchestration in `index.ts` +- Additional domains can be extracted into `/lib/*.ts` as the bridge grows, while keeping `index.ts` as the single entrypoint +- Mirrored domain regression coverage lives in `/tests/*.test.ts` using the same bare domain naming scheme + +## Configuration UX + +`/telegram-setup` uses a progressive-enhancement flow for the bot token prompt: + +1. Show the locally saved token from `~/.pi/agent/telegram.json` when one already exists +2. Otherwise use the first configured environment variable from the supported Telegram token list +3. Fall back to the example placeholder when no real value exists + +Because `ctx.ui.input()` only exposes placeholder text, the bridge uses `ctx.ui.editor()` whenever a real default value must appear already filled in. ## Message And Queue Flow @@ -37,6 +63,15 @@ Main runtime areas: The bridge keeps its own Telegram queue and does not rely only on pi's internal pending-message state. +Queued items now use two explicit dimensions: + +- `kind`: prompt vs control +- `queueLane`: control vs priority vs default + +This lets synthetic control actions and Telegram prompts share one stable ordering model while still rendering distinctly in status output. + +A dispatched prompt remains in the queue until `agent_start` consumes it. That keeps the active Telegram turn bound correctly for previews, attachments, abort handling, and final reply delivery. + Dispatch is gated by: - No active Telegram turn @@ -59,6 +94,9 @@ Key rules: - Rich text should render cleanly in Telegram chats - Real code blocks must remain literal and escaped +- Markdown tables should keep their internal separators but drop the outer left and right borders when rendered as monospace blocks so narrow Telegram clients keep more usable width +- Unordered Markdown lists should render with a monospace `-` marker and ordered Markdown lists should render with monospace numeric markers so list indentation stays more predictable on narrow Telegram clients +- Nested Markdown quotes should flatten into one Telegram blockquote with added non-breaking-space indentation because Telegram does not render nested blockquotes reliably - Long replies must be split below Telegram's 4096-character limit - Chunking should avoid breaking HTML structure where possible - Preview rendering is intentionally simpler than final rich rendering @@ -83,9 +121,9 @@ The bridge exposes Telegram-side session controls in addition to regular chat fo Current operator controls include: -- `/status` for model, usage, cost, and context visibility +- `/status` for model, usage, cost, and context visibility, queued as a high-priority control item when needed - Inline status buttons for model and thinking adjustments -- `/model` for interactive model selection, including in-flight restart of the active Telegram-owned run on a newly selected model +- `/model` for interactive model selection, queued as a high-priority control item when needed and supporting in-flight restart of the active Telegram-owned run on a newly selected model - `/compact` for Telegram-triggered pi session compaction when the bridge is idle - Queue reactions using `πŸ‘` and `πŸ‘Ž` diff --git a/index.ts b/index.ts index 264c18a..8b791b8 100644 --- a/index.ts +++ b/index.ts @@ -1,26 +1,117 @@ -import { mkdir, readFile, stat, writeFile } from "node:fs/promises"; -import { basename, join } from "node:path"; -import { homedir } from "node:os"; +/** + * Telegram bridge extension entrypoint and orchestration layer + * Keeps the runtime wiring in one place while delegating reusable domain logic to /lib modules + */ + +import { mkdir, readFile, stat } from "node:fs/promises"; +import { homedir } from "node:os"; +import { join } from "node:path"; -import type { ImageContent, Model, TextContent } from "@mariozechner/pi-ai"; import type { AgentMessage } from "@mariozechner/pi-agent-core"; +import type { Model } from "@mariozechner/pi-ai"; import type { ExtensionAPI, ExtensionContext, } from "@mariozechner/pi-coding-agent"; import { SettingsManager } from "@mariozechner/pi-coding-agent"; -import { Type } from "@sinclair/typebox"; + +import { + createTelegramApiClient, + readTelegramConfig, + writeTelegramConfig, + type TelegramConfig, +} from "./lib/api.ts"; +import { sendQueuedTelegramAttachments } from "./lib/attachments.ts"; +import { + collectTelegramFileInfos, + extractFirstTelegramMessageText, + extractTelegramMessagesText, + guessMediaType, +} from "./lib/media.ts"; +import { + buildTelegramModelMenuState, + getCanonicalModelId, + handleTelegramMenuCallbackEntry, + handleTelegramModelMenuCallbackAction, + handleTelegramStatusMenuCallbackAction, + handleTelegramThinkingMenuCallbackAction, + sendTelegramModelMenuMessage, + sendTelegramStatusMessage, + updateTelegramModelMenuMessage, + updateTelegramStatusMessage, + updateTelegramThinkingMenuMessage, + type ScopedTelegramModel, + type TelegramModelMenuState, + type TelegramReplyMarkup, + type ThinkingLevel, +} from "./lib/menu.ts"; +import { + buildTelegramModelSwitchContinuationText, + canRestartTelegramTurnForModelSwitch, + restartTelegramModelSwitchContinuation, + shouldTriggerPendingTelegramModelSwitchAbort, +} from "./lib/model-switch.ts"; +import { runTelegramPollLoop } from "./lib/polling.ts"; +import { + buildTelegramAgentEndPlan, + buildTelegramAgentStartPlan, + buildTelegramSessionShutdownState, + buildTelegramSessionStartState, + canDispatchTelegramTurnState, + clearTelegramQueuePromptPriority, + compareTelegramQueueItems, + consumeDispatchedTelegramPrompt, + executeTelegramControlItemRuntime, + executeTelegramQueueDispatchPlan, + formatQueuedTelegramItemsStatus, + getNextTelegramToolExecutionCount, + partitionTelegramQueueItemsForHistory, + planNextTelegramQueueAction, + prioritizeTelegramQueuePrompt, + removeTelegramQueueItemsByMessageIds, + shouldDispatchAfterTelegramAgentEnd, + shouldStartTelegramPolling, + type PendingTelegramControlItem, + type PendingTelegramTurn, + type TelegramQueueItem, +} from "./lib/queue.ts"; +import { + registerTelegramAttachmentTool, + registerTelegramCommands, + registerTelegramLifecycleHooks, +} from "./lib/registration.ts"; +import { + MAX_MESSAGE_LENGTH, + renderMarkdownPreviewText, + renderTelegramMessage, + type TelegramRenderMode, +} from "./lib/rendering.ts"; +import { + buildTelegramReplyTransport, + clearTelegramPreview, + finalizeTelegramMarkdownPreview, + finalizeTelegramPreview, + flushTelegramPreview, + sendTelegramMarkdownReply, + sendTelegramPlainReply, +} from "./lib/replies.ts"; +import { + getTelegramBotTokenInputDefault, + getTelegramBotTokenPromptSpec, +} from "./lib/setup.ts"; +import { buildStatusHtml } from "./lib/status.ts"; +import { + buildTelegramPromptTurn, + truncateTelegramQueueSummary, +} from "./lib/turns.ts"; +import { + collectTelegramReactionEmojis, + executeTelegramUpdate, + getTelegramAuthorizationState, +} from "./lib/updates.ts"; // --- Telegram API Types --- -interface TelegramConfig { - botToken?: string; - botUsername?: string; - botId?: number; - allowedUserId?: number; - lastUpdateId?: number; -} - interface TelegramApiResponse { ok: boolean; result?: T; @@ -144,11 +235,14 @@ interface TelegramMessageReactionUpdated { } interface TelegramUpdate { + _: string; update_id: number; message?: TelegramMessage; edited_message?: TelegramMessage; callback_query?: TelegramCallbackQuery; message_reaction?: TelegramMessageReactionUpdated; + deleted_business_messages?: { message_ids?: unknown }; + messages?: unknown; } interface TelegramGetFileResult { @@ -173,23 +267,6 @@ interface DownloadedTelegramFile { mimeType?: string; } -interface QueuedAttachment { - path: string; - fileName: string; -} - -interface PendingTelegramTurn { - chatId: number; - replyToMessageId: number; - sourceMessageIds: number[]; - queueOrder: number; - priorityReactionOrder?: number; - queuedAttachments: QueuedAttachment[]; - content: Array; - historyText: string; - statusSummary: string; -} - type ActiveTelegramTurn = PendingTelegramTurn; interface TelegramPreviewState { @@ -206,83 +283,14 @@ interface TelegramMediaGroupState { flushTimer?: ReturnType; } -type ThinkingLevel = "off" | "minimal" | "low" | "medium" | "high" | "xhigh"; -type TelegramModelScope = "all" | "scoped"; - -interface ScopedTelegramModel { - model: Model; - thinkingLevel?: ThinkingLevel; -} - -interface TelegramModelMenuState { - chatId: number; - messageId: number; - page: number; - scope: TelegramModelScope; - scopedModels: ScopedTelegramModel[]; - allModels: ScopedTelegramModel[]; - note?: string; - mode: "status" | "model" | "thinking"; -} - -interface TelegramUsageStats { - totalInput: number; - totalOutput: number; - totalCacheRead: number; - totalCacheWrite: number; - totalCost: number; -} - -interface TelegramDispatchGuardState { - compactionInProgress: boolean; - hasActiveTelegramTurn: boolean; - hasPendingTelegramDispatch: boolean; - isIdle: boolean; - hasPendingMessages: boolean; -} - -interface TelegramInFlightModelSwitchState { - isIdle: boolean; - hasActiveTelegramTurn: boolean; - hasAbortHandler: boolean; -} - -interface TelegramBotTokenPromptSpec { - method: "input" | "editor"; - value: string; -} - -type TelegramReplyMarkup = { - inline_keyboard: Array>; -}; - const AGENT_DIR = join(homedir(), ".pi", "agent"); const CONFIG_PATH = join(AGENT_DIR, "telegram.json"); const TEMP_DIR = join(AGENT_DIR, "tmp", "telegram"); const TELEGRAM_PREFIX = "[telegram]"; -const MAX_MESSAGE_LENGTH = 4096; const MAX_ATTACHMENTS_PER_TURN = 10; const PREVIEW_THROTTLE_MS = 750; const TELEGRAM_DRAFT_ID_MAX = 2_147_483_647; const TELEGRAM_MEDIA_GROUP_DEBOUNCE_MS = 1200; -const TELEGRAM_MODEL_PAGE_SIZE = 6; -const THINKING_LEVELS: readonly ThinkingLevel[] = [ - "off", - "minimal", - "low", - "medium", - "high", - "xhigh", -]; -const TELEGRAM_BOT_TOKEN_INPUT_PLACEHOLDER = "123456:ABCDEF..."; -const TELEGRAM_BOT_TOKEN_ENV_VARS = [ - "TELEGRAM_BOT_TOKEN", - "TELEGRAM_BOT_KEY", - "TELEGRAM_TOKEN", - "TELEGRAM_KEY", - "BOT_TOKEN", - "BOT_KEY", -] as const; const SYSTEM_PROMPT_SUFFIX = ` Telegram bridge extension is active. @@ -290,7 +298,6 @@ Telegram bridge extension is active. - [telegram] messages may include local temp file paths for Telegram attachments. Read those files as needed. - If a [telegram] user asked for a file or generated artifact, use the telegram_attach tool with the local file path so the extension can send it with your next final reply. - Do not assume mentioning a local file path in plain text will send it to Telegram. Use telegram_attach.`; -const MODEL_MENU_TITLE = "Choose a model:"; // --- Generic Utilities --- @@ -302,248 +309,6 @@ function sanitizeFileName(name: string): string { return name.replace(/[^a-zA-Z0-9._-]+/g, "_"); } -function guessExtensionFromMime( - mimeType: string | undefined, - fallback: string, -): string { - if (!mimeType) return fallback; - const normalized = mimeType.toLowerCase(); - if (normalized === "image/jpeg") return ".jpg"; - if (normalized === "image/png") return ".png"; - if (normalized === "image/webp") return ".webp"; - if (normalized === "image/gif") return ".gif"; - if (normalized === "audio/ogg") return ".ogg"; - if (normalized === "audio/mpeg") return ".mp3"; - if (normalized === "audio/wav") return ".wav"; - if (normalized === "video/mp4") return ".mp4"; - if (normalized === "application/pdf") return ".pdf"; - return fallback; -} - -function guessMediaType(path: string): string | undefined { - const normalized = path.toLowerCase(); - if (normalized.endsWith(".jpg") || normalized.endsWith(".jpeg")) { - return "image/jpeg"; - } - if (normalized.endsWith(".png")) return "image/png"; - if (normalized.endsWith(".webp")) return "image/webp"; - if (normalized.endsWith(".gif")) return "image/gif"; - return undefined; -} - -function isImageMimeType(mimeType: string | undefined): boolean { - return mimeType?.toLowerCase().startsWith("image/") ?? false; -} - -function formatTokens(count: number): string { - if (count < 1000) return count.toString(); - if (count < 10000) return `${(count / 1000).toFixed(1)}k`; - if (count < 1000000) return `${Math.round(count / 1000)}k`; - if (count < 10000000) return `${(count / 1000000).toFixed(1)}M`; - return `${Math.round(count / 1000000)}M`; -} - -function modelsMatch( - a: Model | undefined, - b: Model | undefined, -): boolean { - return !!a && !!b && a.provider === b.provider && a.id === b.id; -} - -function getCanonicalModelId( - model: Pick, "provider" | "id">, -): string { - return `${model.provider}/${model.id}`; -} - -function isThinkingLevel(value: string): value is ThinkingLevel { - return THINKING_LEVELS.includes(value as ThinkingLevel); -} - -function escapeRegex(text: string): string { - return text.replace(/[|\\{}()[\]^$+?.]/g, "\\$&"); -} - -function globMatches(text: string, pattern: string): boolean { - let regex = "^"; - for (let i = 0; i < pattern.length; i++) { - const char = pattern[i]; - if (char === "*") { - regex += ".*"; - continue; - } - if (char === "?") { - regex += "."; - continue; - } - if (char === "[") { - const end = pattern.indexOf("]", i + 1); - if (end !== -1) { - const content = pattern.slice(i + 1, end); - regex += content.startsWith("!") - ? `[^${content.slice(1)}]` - : `[${content}]`; - i = end; - continue; - } - } - regex += escapeRegex(char); - } - regex += "$"; - return new RegExp(regex, "i").test(text); -} - -function isAliasModelId(id: string): boolean { - if (id.endsWith("-latest")) return true; - return !/-\d{8}$/.test(id); -} - -function findExactModelReferenceMatch( - modelReference: string, - availableModels: Model[], -): Model | undefined { - const trimmedReference = modelReference.trim(); - if (!trimmedReference) return undefined; - const normalizedReference = trimmedReference.toLowerCase(); - const canonicalMatches = availableModels.filter( - (model) => getCanonicalModelId(model).toLowerCase() === normalizedReference, - ); - if (canonicalMatches.length === 1) return canonicalMatches[0]; - if (canonicalMatches.length > 1) return undefined; - const slashIndex = trimmedReference.indexOf("/"); - if (slashIndex !== -1) { - const provider = trimmedReference.substring(0, slashIndex).trim(); - const modelId = trimmedReference.substring(slashIndex + 1).trim(); - if (provider && modelId) { - const providerMatches = availableModels.filter( - (model) => - model.provider.toLowerCase() === provider.toLowerCase() && - model.id.toLowerCase() === modelId.toLowerCase(), - ); - if (providerMatches.length === 1) return providerMatches[0]; - if (providerMatches.length > 1) return undefined; - } - } - const idMatches = availableModels.filter( - (model) => model.id.toLowerCase() === normalizedReference, - ); - return idMatches.length === 1 ? idMatches[0] : undefined; -} - -function tryMatchScopedModel( - modelPattern: string, - availableModels: Model[], -): Model | undefined { - const exactMatch = findExactModelReferenceMatch( - modelPattern, - availableModels, - ); - if (exactMatch) return exactMatch; - const matches = availableModels.filter( - (model) => - model.id.toLowerCase().includes(modelPattern.toLowerCase()) || - model.name?.toLowerCase().includes(modelPattern.toLowerCase()), - ); - if (matches.length === 0) return undefined; - const aliases = matches.filter((model) => isAliasModelId(model.id)); - const datedVersions = matches.filter((model) => !isAliasModelId(model.id)); - if (aliases.length > 0) { - aliases.sort((a, b) => b.id.localeCompare(a.id)); - return aliases[0]; - } - datedVersions.sort((a, b) => b.id.localeCompare(a.id)); - return datedVersions[0]; -} - -function parseScopedModelPattern( - pattern: string, - availableModels: Model[], -): { model: Model | undefined; thinkingLevel?: ThinkingLevel } { - const exactMatch = tryMatchScopedModel(pattern, availableModels); - if (exactMatch) { - return { model: exactMatch, thinkingLevel: undefined }; - } - const lastColonIndex = pattern.lastIndexOf(":"); - if (lastColonIndex === -1) { - return { model: undefined, thinkingLevel: undefined }; - } - const prefix = pattern.substring(0, lastColonIndex); - const suffix = pattern.substring(lastColonIndex + 1); - if (isThinkingLevel(suffix)) { - const result = parseScopedModelPattern(prefix, availableModels); - if (result.model) { - return { model: result.model, thinkingLevel: suffix }; - } - return result; - } - return parseScopedModelPattern(prefix, availableModels); -} - -function resolveScopedModelPatterns( - patterns: string[], - availableModels: Model[], -): ScopedTelegramModel[] { - const resolved: ScopedTelegramModel[] = []; - const seen = new Set(); - for (const pattern of patterns) { - if ( - pattern.includes("*") || - pattern.includes("?") || - pattern.includes("[") - ) { - const colonIndex = pattern.lastIndexOf(":"); - let globPattern = pattern; - let thinkingLevel: ThinkingLevel | undefined; - if (colonIndex !== -1) { - const suffix = pattern.substring(colonIndex + 1); - if (isThinkingLevel(suffix)) { - thinkingLevel = suffix; - globPattern = pattern.substring(0, colonIndex); - } - } - const matches = availableModels.filter( - (model) => - globMatches(getCanonicalModelId(model), globPattern) || - globMatches(model.id, globPattern), - ); - for (const model of matches) { - const key = getCanonicalModelId(model); - if (seen.has(key)) continue; - seen.add(key); - resolved.push({ model, thinkingLevel }); - } - continue; - } - const matched = parseScopedModelPattern(pattern, availableModels); - if (!matched.model) continue; - const key = getCanonicalModelId(matched.model); - if (seen.has(key)) continue; - seen.add(key); - resolved.push({ - model: matched.model, - thinkingLevel: matched.thinkingLevel, - }); - } - return resolved; -} - -function sortScopedModels( - models: ScopedTelegramModel[], - currentModel: Model | undefined, -): ScopedTelegramModel[] { - const sorted = [...models]; - sorted.sort((a, b) => { - const aIsCurrent = modelsMatch(a.model, currentModel); - const bIsCurrent = modelsMatch(b.model, currentModel); - if (aIsCurrent && !bIsCurrent) return -1; - if (!aIsCurrent && bIsCurrent) return 1; - const providerCompare = a.model.provider.localeCompare(b.model.provider); - if (providerCompare !== 0) return providerCompare; - return a.model.id.localeCompare(b.model.id); - }); - return sorted; -} - function parseTelegramCommand( text: string, ): { name: string; args: string } | undefined { @@ -585,835 +350,44 @@ function truncateTelegramButtonLabel(label: string, maxLength = 56): string { : `${label.slice(0, maxLength - 1)}…`; } -function truncateTelegramQueueSummary( - text: string, - maxWords = 4, - maxLength = 32, -): string { - const normalized = text.replace(/\s+/g, " ").trim(); - if (!normalized) return ""; - const words = normalized.split(" "); - let summary = words.slice(0, maxWords).join(" "); - if (summary.length === 0) summary = normalized; - if (summary.length > maxLength) { - summary = summary.slice(0, maxLength).trimEnd(); - } - return summary.length < normalized.length || words.length > maxWords - ? `${summary}…` - : summary; -} - -function formatTelegramTurnStatusSummary( - rawText: string, - files: DownloadedTelegramFile[], -): string { - const textSummary = truncateTelegramQueueSummary(rawText); - if (textSummary) return textSummary; - if (files.length === 1) { - const fileName = basename( - files[0]?.fileName || files[0]?.path || "attachment", - ); - return `πŸ“Ž ${truncateTelegramQueueSummary(fileName, 4, 32) || "attachment"}`; - } - if (files.length > 1) return `πŸ“Ž ${files.length} attachments`; - return "(empty message)"; -} - -function formatQueuedTelegramTurnsStatus(turns: PendingTelegramTurn[]): string { - if (turns.length === 0) return ""; - const previewCount = 4; - const summaries = turns - .slice(0, previewCount) - .map((turn) => turn.statusSummary) - .filter(Boolean); - if (summaries.length === 0) return ` +${turns.length}`; - const suffix = turns.length > summaries.length ? ", …" : ""; - return ` +${turns.length}: [${summaries.join(", ")}${suffix}]`; -} - -function formatScopedModelButtonText( - entry: ScopedTelegramModel, - currentModel: Model | undefined, -): string { - let label = `${modelsMatch(entry.model, currentModel) ? "βœ… " : ""}${entry.model.id} [${entry.model.provider}]`; - if (entry.thinkingLevel) { - label += ` Β· ${entry.thinkingLevel}`; - } - return truncateTelegramButtonLabel(label); -} - -function formatStatusButtonLabel(label: string, value: string): string { - return truncateTelegramButtonLabel(`${label}: ${value}`, 64); -} - -function getModelMenuItems( - state: TelegramModelMenuState, -): ScopedTelegramModel[] { - return state.scope === "scoped" && state.scopedModels.length > 0 - ? state.scopedModels - : state.allModels; -} - -// --- Escaping --- - -function escapeHtml(text: string): string { - return text - .replace(/&/g, "&") - .replace(//g, ">"); -} - -// --- Plain Preview Rendering --- - -function splitPlainMarkdownLine(line: string, maxLength = 1500): string[] { - if (line.length <= maxLength) return [line]; - const words = line.split(/\s+/).filter(Boolean); - if (words.length === 0) return [line]; - const parts: string[] = []; - let current = ""; - for (const word of words) { - const candidate = current.length === 0 ? word : `${current} ${word}`; - if (candidate.length <= maxLength) { - current = candidate; - continue; - } - if (current.length > 0) { - parts.push(current); - current = ""; - } - if (word.length <= maxLength) { - current = word; - continue; - } - for (let i = 0; i < word.length; i += maxLength) { - parts.push(word.slice(i, i + maxLength)); - } - } - if (current.length > 0) { - parts.push(current); - } - return parts.length > 0 ? parts : [line]; -} - -function stripInlineMarkdownToPlainText(text: string): string { - let result = text; - result = result.replace(/!\[([^\]]*)\]\((https?:\/\/[^\s)]+)\)/g, "$1"); - result = result.replace(/\[([^\]]+)\]\((https?:\/\/[^\s)]+)\)/g, "$1"); - result = result.replace(/<((?:https?:\/\/|mailto:)[^>]+)>/g, "$1"); - result = result.replace(/`([^`\n]+)`/g, "$1"); - result = result.replace(/(\*\*\*|___)(.+?)\1/g, "$2"); - result = result.replace(/(\*\*|__)(.+?)\1/g, "$2"); - result = result.replace(/(\*|_)(.+?)\1/g, "$2"); - result = result.replace(/~~(.+?)~~/g, "$1"); - result = result.replace(/\\([\\`*_{}\[\]()#+\-.!>~|])/g, "$1"); - return result; -} - -function isMarkdownTableSeparator(line: string): boolean { - return /^\s*\|?(?:\s*:?-{3,}:?\s*\|)+\s*:?-{3,}:?\s*\|?\s*$/.test(line); -} - -function parseMarkdownFence( - line: string, -): { marker: "`" | "~"; length: number; info?: string } | undefined { - const match = line.match(/^\s*([`~]{3,})(.*)$/); - if (!match) return undefined; - const fence = match[1] ?? ""; - const marker = fence[0]; - if ((marker !== "`" && marker !== "~") || /[^`~]/.test(fence)) { - return undefined; - } - if (!fence.split("").every((char) => char === marker)) return undefined; - return { - marker, - length: fence.length, - info: (match[2] ?? "").trim() || undefined, - }; -} - -function isFencedCodeStart(line: string): boolean { - return parseMarkdownFence(line) !== undefined; -} - -function isMatchingMarkdownFence( - line: string, - fence: { marker: "`" | "~"; length: number }, -): boolean { - const match = line.match(/^\s*([`~]{3,})\s*$/); - if (!match) return false; - const candidate = match[1] ?? ""; - return ( - candidate.length >= fence.length && - candidate[0] === fence.marker && - candidate.split("").every((char) => char === fence.marker) - ); -} - -function isIndentedCodeLine(line: string): boolean { - return /^(?:\t| {4,})/.test(line); -} - -function isIndentedMarkdownStructureLine(line: string): boolean { - const trimmed = line.trimStart(); - return ( - /^(?:[-*+]|\d+\.)\s+\[([ xX])\]\s+/.test(trimmed) || - /^(?:[-*+]|\d+\.)\s+/.test(trimmed) || - /^>\s?/.test(trimmed) || - /^#{1,6}\s+/.test(trimmed) || - parseMarkdownFence(trimmed) !== undefined - ); -} - -function canStartIndentedCodeBlock(lines: string[], index: number): boolean { - const line = lines[index] ?? ""; - if (!isIndentedCodeLine(line)) return false; - if (isIndentedMarkdownStructureLine(line)) return false; - if (index === 0) return true; - return (lines[index - 1] ?? "").trim().length === 0; -} - -function stripIndentedCodePrefix(line: string): string { - if (line.startsWith("\t")) return line.slice(1); - if (line.startsWith(" ")) return line.slice(4); - return line; -} - -function renderMarkdownPreviewText(markdown: string): string { - const normalized = markdown.replace(/\r\n/g, "\n").trim(); - if (normalized.length === 0) return ""; - const output: string[] = []; - const lines = normalized.split("\n"); - let activeFence: { marker: "`" | "~"; length: number } | undefined; - for (const rawLine of lines) { - const line = rawLine ?? ""; - const fence = parseMarkdownFence(line); - if (activeFence) { - if (fence && isMatchingMarkdownFence(line, activeFence)) { - activeFence = undefined; - continue; - } - if (line.trim().length === 0) { - if (output.at(-1) !== "") output.push(""); - continue; - } - output.push(line); - continue; - } - if (fence) { - activeFence = { marker: fence.marker, length: fence.length }; - continue; - } - if (line.trim().length === 0) { - if (output.at(-1) !== "") output.push(""); - continue; - } - if (isMarkdownTableSeparator(line)) { - continue; - } - const heading = line.match(/^\s*#{1,6}\s+(.+)$/); - if (heading) { - output.push(stripInlineMarkdownToPlainText(heading[1] ?? "")); - continue; - } - const task = line.match(/^(\s*)([-*+]|\d+\.)\s+\[([ xX])\]\s+(.+)$/); - if (task) { - const indent = " ".repeat((task[1] ?? "").length); - const marker = (task[3] ?? " ").toLowerCase() === "x" ? "[x]" : "[ ]"; - output.push( - `${indent}${marker} ${stripInlineMarkdownToPlainText(task[4] ?? "")}`, - ); - continue; - } - const bullet = line.match(/^(\s*)[-*+]\s+(.+)$/); - if (bullet) { - output.push( - `${" ".repeat((bullet[1] ?? "").length)}β€’ ${stripInlineMarkdownToPlainText(bullet[2] ?? "")}`, - ); - continue; - } - const numbered = line.match(/^(\s*\d+\.)\s+(.+)$/); - if (numbered) { - output.push( - `${numbered[1]} ${stripInlineMarkdownToPlainText(numbered[2] ?? "")}`, - ); - continue; - } - const quote = line.match(/^\s*>\s?(.+)$/); - if (quote) { - output.push(`> ${stripInlineMarkdownToPlainText(quote[1] ?? "")}`); - continue; - } - if (/^\s*([-*_]\s*){3,}\s*$/.test(line)) { - output.push("────────"); - continue; - } - output.push(stripInlineMarkdownToPlainText(line)); - } - return output.join("\n"); -} - -// --- Rich Markdown Rendering --- - -function renderDelimitedInlineStyle( - text: string, - delimiter: string, - render: (content: string) => string, -): string { - const escapedDelimiter = delimiter.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); - const pattern = new RegExp( - `(^|[^\\p{L}\\p{N}\\\\])(${escapedDelimiter})(?=\\S)(.+?)(?<=\\S)\\2(?=[^\\p{L}\\p{N}]|$)`, - "gu", - ); - return text.replace( - pattern, - (_match, prefix: string, _wrapped: string, content: string) => { - return `${prefix}${render(content)}`; - }, - ); -} - -function renderInlineMarkdown(text: string): string { - const tokens: string[] = []; - const makeToken = (html: string): string => { - const token = `\uE000${tokens.length}\uE001`; - tokens.push(html); - return token; - }; - let result = text; - result = result.replace( - /!\[([^\]]*)\]\((https?:\/\/[^\s)]+)\)/g, - (_match, alt: string, url: string) => { - const label = alt.trim().length > 0 ? alt : url; - return makeToken(`${escapeHtml(label)}`); - }, - ); - result = result.replace( - /\[([^\]]+)\]\((https?:\/\/[^\s)]+)\)/g, - (_match, label: string, url: string) => { - return makeToken(`${escapeHtml(label)}`); - }, - ); - result = result.replace( - /<((?:https?:\/\/|mailto:)[^>]+)>/g, - (_match, url: string) => { - return makeToken(`${escapeHtml(url)}`); - }, - ); - result = result.replace(/`([^`\n]+)`/g, (_match, code: string) => { - return makeToken(`${escapeHtml(code)}`); - }); - result = escapeHtml(result); - result = renderDelimitedInlineStyle(result, "***", (content) => { - return `${content}`; - }); - result = renderDelimitedInlineStyle(result, "___", (content) => { - return `${content}`; - }); - result = renderDelimitedInlineStyle(result, "~~", (content) => { - return `${content}`; - }); - result = renderDelimitedInlineStyle(result, "**", (content) => { - return `${content}`; - }); - result = renderDelimitedInlineStyle(result, "__", (content) => { - return `${content}`; - }); - result = renderDelimitedInlineStyle(result, "*", (content) => { - return `${content}`; - }); - result = renderDelimitedInlineStyle(result, "_", (content) => { - return `${content}`; - }); - result = result.replace( - /(^|[\s>(])(\[(?: |x|X)\])(?=($|[\s<).,:;!?]))/g, - (_match, prefix: string, checkbox: string) => { - const normalized = checkbox.toLowerCase() === "[x]" ? "[x]" : "[ ]"; - return `${prefix}${normalized}`; - }, - ); - result = result.replace(/\\([\\`*_{}\[\]()#+\-.!>~|])/g, "$1"); - return result.replace( - /\uE000(\d+)\uE001/g, - (_match, index: string) => tokens[Number(index)] ?? "", - ); -} - -function buildListIndent(level: number): string { - return "\u00A0".repeat(Math.max(0, level) * 2); -} - -function parseMarkdownTableRow(line: string): string[] { - const trimmed = line.trim().replace(/^\|/, "").replace(/\|$/, ""); - return trimmed - .split("|") - .map((cell) => stripInlineMarkdownToPlainText(cell.trim())); -} - -function renderMarkdownTextLines(block: string): string[] { - const rendered: string[] = []; - const lines = block.split("\n"); - for (const line of lines) { - if (line.trim().length === 0) continue; - const pieces = splitPlainMarkdownLine(line); - for (const piece of pieces) { - const heading = piece.match(/^(\s*)#{1,6}\s+(.+)$/); - if (heading) { - rendered.push( - `${buildListIndent(Math.floor((heading[1] ?? "").length / 2))}${renderInlineMarkdown(heading[2] ?? "")}`, - ); - continue; - } - const task = piece.match(/^(\s*)([-*+]|\d+\.)\s+\[([ xX])\]\s+(.+)$/); - if (task) { - const indent = buildListIndent(Math.floor((task[1] ?? "").length / 2)); - const marker = (task[3] ?? " ").toLowerCase() === "x" ? "[x]" : "[ ]"; - rendered.push( - `${indent}${marker} ${renderInlineMarkdown(task[4] ?? "")}`, - ); - continue; - } - const bullet = piece.match(/^(\s*)[-*+]\s+(.+)$/); - if (bullet) { - const indent = buildListIndent( - Math.floor((bullet[1] ?? "").length / 2), - ); - rendered.push(`${indent}β€’ ${renderInlineMarkdown(bullet[2] ?? "")}`); - continue; - } - const numbered = piece.match(/^(\s*)(\d+)\.\s+(.+)$/); - if (numbered) { - const indent = buildListIndent( - Math.floor((numbered[1] ?? "").length / 2), - ); - rendered.push( - `${indent}${numbered[2]}. ${renderInlineMarkdown(numbered[3] ?? "")}`, - ); - continue; - } - const quote = piece.match(/^>\s?(.+)$/); - if (quote) { - rendered.push( - `
${renderInlineMarkdown(quote[1] ?? "")}
`, - ); - continue; - } - const trimmed = piece.trim(); - if (/^([-*_]\s*){3,}$/.test(trimmed)) { - rendered.push("────────────────"); - continue; - } - rendered.push(renderInlineMarkdown(piece)); - } - } - return rendered; -} - -function renderMarkdownCodeBlock(code: string, language?: string): string[] { - const open = language - ? `
`
-    : "
";
-  const close = "
"; - const maxContentLength = MAX_MESSAGE_LENGTH - open.length - close.length; - const chunks: string[] = []; - let current = ""; - const pushCurrent = (): void => { - if (current.length === 0) return; - chunks.push(`${open}${current}${close}`); - current = ""; - }; - const appendEscapedLine = (escapedLine: string): void => { - if (escapedLine.length <= maxContentLength) { - const candidate = - current.length === 0 ? escapedLine : `${current}\n${escapedLine}`; - if (candidate.length <= maxContentLength) { - current = candidate; - return; - } - pushCurrent(); - current = escapedLine; - return; - } - pushCurrent(); - for (let i = 0; i < escapedLine.length; i += maxContentLength) { - chunks.push( - `${open}${escapedLine.slice(i, i + maxContentLength)}${close}`, - ); - } - }; - for (const line of code.split("\n")) { - appendEscapedLine(escapeHtml(line)); - } - pushCurrent(); - return chunks.length > 0 ? chunks : [`${open}${close}`]; -} - -function renderMarkdownTableBlock(lines: string[]): string[] { - const rows = lines.map(parseMarkdownTableRow); - const columnCount = Math.max(...rows.map((row) => row.length), 0); - const normalizedRows = rows.map((row) => { - const next = [...row]; - while (next.length < columnCount) { - next.push(""); - } - return next; - }); - const widths = Array.from({ length: columnCount }, (_, columnIndex) => { - return Math.max( - 3, - ...normalizedRows.map((row) => (row[columnIndex] ?? "").length), - ); - }); - const formatRow = (row: string[]): string => { - return `| ${row.map((cell, columnIndex) => (cell ?? "").padEnd(widths[columnIndex] ?? 3)).join(" | ")} |`; - }; - const separator = `| ${widths.map((width) => "-".repeat(width)).join(" | ")} |`; - const [header, ...body] = normalizedRows; - const tableLines = [ - formatRow(header ?? []), - separator, - ...body.map(formatRow), - ]; - return renderMarkdownCodeBlock(tableLines.join("\n"), "markdown"); -} - -function chunkRenderedHtmlLines( - lines: string[], - wrapper?: { open: string; close: string }, -): string[] { - if (lines.length === 0) return []; - const open = wrapper?.open ?? ""; - const close = wrapper?.close ?? ""; - const maxContentLength = MAX_MESSAGE_LENGTH - open.length - close.length; - const chunks: string[] = []; - let current = ""; - const pushCurrent = (): void => { - if (current.length === 0) return; - chunks.push(`${open}${current}${close}`); - current = ""; - }; - for (const line of lines) { - const candidate = current.length === 0 ? line : `${current}\n${line}`; - if (candidate.length <= maxContentLength) { - current = candidate; - continue; - } - pushCurrent(); - if (line.length <= maxContentLength) { - current = line; - continue; - } - for (let i = 0; i < line.length; i += maxContentLength) { - chunks.push(`${open}${line.slice(i, i + maxContentLength)}${close}`); - } - } - pushCurrent(); - return chunks; -} - -function renderMarkdownTextBlock(block: string): string[] { - return chunkRenderedHtmlLines(renderMarkdownTextLines(block)); -} - -function renderMarkdownQuoteBlock(lines: string[]): string[] { - const inner = lines.map((line) => line.replace(/^\s*>\s?/, "")).join("\n"); - return chunkRenderedHtmlLines(renderMarkdownTextLines(inner), { - open: "
", - close: "
", - }); -} - -function renderMarkdownToTelegramHtmlChunks(markdown: string): string[] { - const normalized = markdown.replace(/\r\n/g, "\n").trim(); - if (normalized.length === 0) return []; - const renderedBlocks: string[] = []; - const lines = normalized.split("\n"); - let index = 0; - while (index < lines.length) { - const line = lines[index] ?? ""; - const nextLine = lines[index + 1] ?? ""; - const fence = parseMarkdownFence(line); - if (fence) { - index += 1; - const codeLines: string[] = []; - while ( - index < lines.length && - !isMatchingMarkdownFence(lines[index] ?? "", fence) - ) { - codeLines.push(lines[index] ?? ""); - index += 1; - } - if (index < lines.length) { - index += 1; - } - renderedBlocks.push( - ...renderMarkdownCodeBlock(codeLines.join("\n"), fence.info), - ); - while (index < lines.length && (lines[index] ?? "").trim().length === 0) { - index += 1; - } - continue; - } - if (line.trim().length === 0) { - index += 1; - continue; - } - if (line.includes("|") && isMarkdownTableSeparator(nextLine)) { - const tableLines: string[] = [line]; - index += 2; - while (index < lines.length) { - const tableLine = lines[index] ?? ""; - if (tableLine.trim().length === 0 || !tableLine.includes("|")) { - break; - } - tableLines.push(tableLine); - index += 1; - } - renderedBlocks.push(...renderMarkdownTableBlock(tableLines)); - continue; - } - if (canStartIndentedCodeBlock(lines, index)) { - const codeLines: string[] = []; - while (index < lines.length) { - const rawLine = lines[index] ?? ""; - if (rawLine.trim().length === 0) { - codeLines.push(""); - index += 1; - continue; - } - if (!isIndentedCodeLine(rawLine)) break; - codeLines.push(stripIndentedCodePrefix(rawLine)); - index += 1; - } - renderedBlocks.push(...renderMarkdownCodeBlock(codeLines.join("\n"))); - continue; - } - if (/^\s*>/.test(line)) { - const quoteLines: string[] = []; - while (index < lines.length && /^\s*>/.test(lines[index] ?? "")) { - quoteLines.push(lines[index] ?? ""); - index += 1; - } - renderedBlocks.push(...renderMarkdownQuoteBlock(quoteLines)); - continue; - } - const textLines: string[] = []; - while (index < lines.length) { - const current = lines[index] ?? ""; - const following = lines[index + 1] ?? ""; - if (current.trim().length === 0) break; - if ( - isFencedCodeStart(current) || - canStartIndentedCodeBlock(lines, index) || - /^\s*>/.test(current) - ) - break; - if (current.includes("|") && isMarkdownTableSeparator(following)) break; - textLines.push(current); - index += 1; - } - renderedBlocks.push(...renderMarkdownTextBlock(textLines.join("\n"))); - } - const chunks: string[] = []; - let current = ""; - for (const block of renderedBlocks) { - const candidate = current.length === 0 ? block : `${current}\n\n${block}`; - if (candidate.length <= MAX_MESSAGE_LENGTH) { - current = candidate; - continue; - } - if (current.length > 0) { - chunks.push(current); - current = ""; - } - if (block.length <= MAX_MESSAGE_LENGTH) { - current = block; - continue; - } - for (let i = 0; i < block.length; i += MAX_MESSAGE_LENGTH) { - chunks.push(block.slice(i, i + MAX_MESSAGE_LENGTH)); - } - } - if (current.length > 0) { - chunks.push(current); - } - return chunks; -} - -// --- Unified Telegram Rendering --- - -type TelegramRenderMode = "plain" | "markdown" | "html"; - -interface TelegramRenderedChunk { - text: string; - parseMode?: "HTML"; -} - -function chunkParagraphs(text: string): string[] { - if (text.length <= MAX_MESSAGE_LENGTH) return [text]; - const normalized = text.replace(/\r\n/g, "\n"); - const paragraphs = normalized.split(/\n\n+/); - const chunks: string[] = []; - let current = ""; - const flushCurrent = (): void => { - if (current.trim().length > 0) chunks.push(current); - current = ""; - }; - const splitLongBlock = (block: string): string[] => { - if (block.length <= MAX_MESSAGE_LENGTH) return [block]; - const lines = block.split("\n"); - const lineChunks: string[] = []; - let lineCurrent = ""; - for (const line of lines) { - const candidate = - lineCurrent.length === 0 ? line : `${lineCurrent}\n${line}`; - if (candidate.length <= MAX_MESSAGE_LENGTH) { - lineCurrent = candidate; - continue; - } - if (lineCurrent.length > 0) { - lineChunks.push(lineCurrent); - lineCurrent = ""; - } - if (line.length <= MAX_MESSAGE_LENGTH) { - lineCurrent = line; - continue; - } - for (let i = 0; i < line.length; i += MAX_MESSAGE_LENGTH) { - lineChunks.push(line.slice(i, i + MAX_MESSAGE_LENGTH)); - } - } - if (lineCurrent.length > 0) { - lineChunks.push(lineCurrent); - } - return lineChunks; - }; - for (const paragraph of paragraphs) { - if (paragraph.length === 0) continue; - const parts = splitLongBlock(paragraph); - for (const part of parts) { - const candidate = current.length === 0 ? part : `${current}\n\n${part}`; - if (candidate.length <= MAX_MESSAGE_LENGTH) { - current = candidate; - } else { - flushCurrent(); - current = part; - } - } - } - flushCurrent(); - return chunks; -} - -function renderTelegramMessage( - text: string, - options?: { mode?: TelegramRenderMode }, -): TelegramRenderedChunk[] { - const mode = options?.mode ?? "plain"; - if (mode === "plain") { - return chunkParagraphs(text).map((chunk) => ({ text: chunk })); - } - if (mode === "html") { - return [{ text, parseMode: "HTML" }]; - } - return renderMarkdownToTelegramHtmlChunks(text).map((chunk) => ({ - text: chunk, - parseMode: "HTML", - })); -} - -function canDispatchTelegramTurnState( - state: TelegramDispatchGuardState, -): boolean { - return ( - !state.compactionInProgress && - !state.hasActiveTelegramTurn && - !state.hasPendingTelegramDispatch && - state.isIdle && - !state.hasPendingMessages - ); -} - -function getTelegramBotTokenInputDefault( - env: NodeJS.ProcessEnv = process.env, - configToken?: string, -): string { - const trimmedConfigToken = configToken?.trim(); - if (trimmedConfigToken) return trimmedConfigToken; - for (const key of TELEGRAM_BOT_TOKEN_ENV_VARS) { - const value = env[key]?.trim(); - if (value) return value; - } - return TELEGRAM_BOT_TOKEN_INPUT_PLACEHOLDER; -} - -function getTelegramBotTokenPromptSpec( - env: NodeJS.ProcessEnv = process.env, - configToken?: string, -): TelegramBotTokenPromptSpec { - const value = getTelegramBotTokenInputDefault(env, configToken); - return { - method: - value === TELEGRAM_BOT_TOKEN_INPUT_PLACEHOLDER ? "input" : "editor", - value, - }; -} - -function canRestartTelegramTurnForModelSwitch( - state: TelegramInFlightModelSwitchState, -): boolean { - return !state.isIdle && state.hasActiveTelegramTurn && state.hasAbortHandler; -} - -function buildTelegramModelSwitchContinuationText< - TModel extends Pick, "provider" | "id">, ->(model: TModel, thinkingLevel?: ThinkingLevel): string { - const modelLabel = getCanonicalModelId(model); - const thinkingSuffix = thinkingLevel - ? ` Keep the selected thinking level (${thinkingLevel}) if it still applies.` - : ""; - return `${TELEGRAM_PREFIX} Continue the interrupted previous Telegram request using the newly selected model (${modelLabel}). Resume from the last unfinished step instead of restarting from scratch unless necessary.${thinkingSuffix}`; -} - -// --- Persistence --- - -async function readConfig(): Promise { - try { - const content = await readFile(CONFIG_PATH, "utf8"); - const parsed = JSON.parse(content) as TelegramConfig; - return parsed; - } catch { - return {}; - } -} - -async function writeConfig(config: TelegramConfig): Promise { - await mkdir(AGENT_DIR, { recursive: true }); - await writeFile( - CONFIG_PATH, - JSON.stringify(config, null, "\t") + "\n", - "utf8", - ); -} - // --- Extension Runtime --- export const __telegramTestUtils = { MAX_MESSAGE_LENGTH, renderTelegramMessage, + compareTelegramQueueItems, + removeTelegramQueueItemsByMessageIds, + clearTelegramQueuePromptPriority, + prioritizeTelegramQueuePrompt, + partitionTelegramQueueItemsForHistory, + consumeDispatchedTelegramPrompt, + planNextTelegramQueueAction, + shouldDispatchAfterTelegramAgentEnd, + buildTelegramAgentEndPlan, canDispatchTelegramTurnState, getTelegramBotTokenInputDefault, getTelegramBotTokenPromptSpec, canRestartTelegramTurnForModelSwitch, - buildTelegramModelSwitchContinuationText, + restartTelegramModelSwitchContinuation, + shouldTriggerPendingTelegramModelSwitchAbort, + buildTelegramModelSwitchContinuationText: ( + model: Pick, "provider" | "id">, + thinkingLevel?: ThinkingLevel, + ) => + buildTelegramModelSwitchContinuationText( + TELEGRAM_PREFIX, + model, + thinkingLevel, + ), }; export default function (pi: ExtensionAPI) { let config: TelegramConfig = {}; let pollingController: AbortController | undefined; let pollingPromise: Promise | undefined; - let queuedTelegramTurns: PendingTelegramTurn[] = []; - let nextQueuedTelegramTurnOrder = 0; - let nextSyntheticTelegramTurnOrder = -1; + let queuedTelegramItems: TelegramQueueItem[] = []; + let nextQueuedTelegramItemOrder = 0; + let nextQueuedTelegramControlOrder = 0; let nextPriorityReactionOrder = 0; let activeTelegramTurn: ActiveTelegramTurn | undefined; let activeTelegramToolExecutions = 0; @@ -1448,27 +422,50 @@ export default function (pi: ExtensionAPI) { }); } + function executeQueuedTelegramControlItem( + item: PendingTelegramControlItem, + ctx: ExtensionContext, + ): void { + void executeTelegramControlItemRuntime(item, { + ctx, + sendTextReply, + onSettled: () => { + updateStatus(ctx); + dispatchNextQueuedTelegramTurn(ctx); + }, + }); + } + function dispatchNextQueuedTelegramTurn(ctx: ExtensionContext): void { - if (!canDispatchQueuedTelegramTurn(ctx)) { - updateStatus(ctx); - return; - } - const nextTurn = queuedTelegramTurns[0]; - if (!nextTurn) { - updateStatus(ctx); - return; - } - telegramTurnDispatchPending = true; - startTypingLoop(ctx, nextTurn.chatId); - updateStatus(ctx); - try { - pi.sendUserMessage(nextTurn.content); - } catch (error) { - telegramTurnDispatchPending = false; - stopTypingLoop(); - const message = error instanceof Error ? error.message : String(error); - updateStatus(ctx, `dispatch failed: ${message}`); + const dispatchPlan = planNextTelegramQueueAction( + queuedTelegramItems, + canDispatchQueuedTelegramTurn(ctx), + ); + if (dispatchPlan.kind !== "none") { + queuedTelegramItems = dispatchPlan.remainingItems; } + executeTelegramQueueDispatchPlan(dispatchPlan, { + executeControlItem: (item) => { + updateStatus(ctx); + executeQueuedTelegramControlItem(item, ctx); + }, + onPromptDispatchStart: (chatId) => { + telegramTurnDispatchPending = true; + startTypingLoop(ctx, chatId); + updateStatus(ctx); + }, + sendUserMessage: (content) => { + pi.sendUserMessage(content); + }, + onPromptDispatchFailure: (message) => { + telegramTurnDispatchPending = false; + stopTypingLoop(); + updateStatus(ctx, `dispatch failed: ${message}`); + }, + onIdle: () => { + updateStatus(ctx); + }, + }); } // --- Status --- @@ -1507,7 +504,7 @@ export default function (pi: ExtensionAPI) { if (compactionInProgress) { const queued = theme.fg( "muted", - formatQueuedTelegramTurnsStatus(queuedTelegramTurns), + formatQueuedTelegramItemsStatus(queuedTelegramItems), ); ctx.ui.setStatus( "telegram", @@ -1518,11 +515,11 @@ export default function (pi: ExtensionAPI) { if ( activeTelegramTurn || telegramTurnDispatchPending || - queuedTelegramTurns.length > 0 + queuedTelegramItems.length > 0 ) { const queued = theme.fg( "muted", - formatQueuedTelegramTurnsStatus(queuedTelegramTurns), + formatQueuedTelegramItemsStatus(queuedTelegramItems), ); ctx.ui.setStatus( "telegram", @@ -1538,99 +535,47 @@ export default function (pi: ExtensionAPI) { // --- Telegram API --- - async function callTelegram( + const telegramApi = createTelegramApiClient(() => config.botToken); + + const callTelegramApi = ( method: string, body: Record, options?: { signal?: AbortSignal }, - ): Promise { - if (!config.botToken) - throw new Error("Telegram bot token is not configured"); - const response = await fetch( - `https://api.telegram.org/bot${config.botToken}/${method}`, - { - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify(body), - signal: options?.signal, - }, - ); - const data = (await response.json()) as TelegramApiResponse; - if (!data.ok || data.result === undefined) { - throw new Error(data.description || `Telegram API ${method} failed`); - } - return data.result; - } + ): Promise => { + return telegramApi.call(method, body, options); + }; - async function callTelegramMultipart( + const callTelegramMultipartApi = ( method: string, fields: Record, fileField: string, filePath: string, fileName: string, options?: { signal?: AbortSignal }, - ): Promise { - if (!config.botToken) - throw new Error("Telegram bot token is not configured"); - const form = new FormData(); - for (const [key, value] of Object.entries(fields)) { - form.set(key, value); - } - const buffer = await readFile(filePath); - form.set(fileField, new Blob([buffer]), fileName); - const response = await fetch( - `https://api.telegram.org/bot${config.botToken}/${method}`, - { - method: "POST", - body: form, - signal: options?.signal, - }, + ): Promise => { + return telegramApi.callMultipart( + method, + fields, + fileField, + filePath, + fileName, + options, ); - const data = (await response.json()) as TelegramApiResponse; - if (!data.ok || data.result === undefined) { - throw new Error(data.description || `Telegram API ${method} failed`); - } - return data.result; - } + }; - async function downloadTelegramFile( + const downloadTelegramBridgeFile = ( fileId: string, suggestedName: string, - ): Promise { - if (!config.botToken) - throw new Error("Telegram bot token is not configured"); - const file = await callTelegram("getFile", { - file_id: fileId, - }); - await mkdir(TEMP_DIR, { recursive: true }); - const targetPath = join( - TEMP_DIR, - `${Date.now()}-${sanitizeFileName(suggestedName)}`, - ); - const response = await fetch( - `https://api.telegram.org/file/bot${config.botToken}/${file.file_path}`, - ); - if (!response.ok) - throw new Error(`Failed to download Telegram file: ${response.status}`); - const arrayBuffer = await response.arrayBuffer(); - await writeFile(targetPath, Buffer.from(arrayBuffer)); - return targetPath; - } + ): Promise => { + return telegramApi.downloadFile(fileId, suggestedName, TEMP_DIR); + }; - async function answerCallbackQuery( + const answerCallbackQuery = ( callbackQueryId: string, text?: string, - ): Promise { - try { - await callTelegram( - "answerCallbackQuery", - text - ? { callback_query_id: callbackQueryId, text } - : { callback_query_id: callbackQueryId }, - ); - } catch { - // ignore - } - } + ): Promise => { + return telegramApi.answerCallbackQuery(callbackQueryId, text); + }; // --- Message Delivery & Preview --- @@ -1640,7 +585,7 @@ export default function (pi: ExtensionAPI) { const sendTyping = async (): Promise => { try { - await callTelegram("sendChatAction", { + await callTelegramApi("sendChatAction", { chat_id: targetChatId, action: "typing", }); @@ -1706,7 +651,7 @@ export default function (pi: ExtensionAPI) { body: Record, ): Promise<"edited" | "unchanged"> { try { - await callTelegram("editMessageText", body); + await callTelegramApi("editMessageText", body); return "edited"; } catch (error) { if (isTelegramMessageNotModifiedError(error)) return "unchanged"; @@ -1714,115 +659,69 @@ export default function (pi: ExtensionAPI) { } } - async function sendRenderedChunks( - chatId: number, - chunks: TelegramRenderedChunk[], - options?: { replyMarkup?: TelegramReplyMarkup }, - ): Promise { - let lastMessageId: number | undefined; - for (const [index, chunk] of chunks.entries()) { - const sent = await callTelegram("sendMessage", { - chat_id: chatId, - text: chunk.text, - parse_mode: chunk.parseMode, - reply_markup: - index === chunks.length - 1 ? options?.replyMarkup : undefined, - }); - lastMessageId = sent.message_id; - } - return lastMessageId; - } + const replyTransport = buildTelegramReplyTransport({ + sendMessage: async (body) => { + return callTelegramApi("sendMessage", body); + }, + editMessage: async (body) => { + await editTelegramMessageText(body); + }, + }); - async function editRenderedMessage( - chatId: number, - messageId: number, - chunks: TelegramRenderedChunk[], - options?: { replyMarkup?: TelegramReplyMarkup }, - ): Promise { - if (chunks.length === 0) return messageId; - const [firstChunk, ...remainingChunks] = chunks; - await editTelegramMessageText({ - chat_id: chatId, - message_id: messageId, - text: firstChunk.text, - parse_mode: firstChunk.parseMode, - reply_markup: - remainingChunks.length === 0 ? options?.replyMarkup : undefined, - }); - if (remainingChunks.length > 0) { - return sendRenderedChunks(chatId, remainingChunks, options); - } - return messageId; + function getPreviewRuntimeDeps() { + return { + getState: () => previewState, + setState: (state: TelegramPreviewState | undefined) => { + previewState = state; + }, + clearScheduledFlush: (state: TelegramPreviewState) => { + if (!state.flushTimer) return; + clearTimeout(state.flushTimer); + state.flushTimer = undefined; + }, + maxMessageLength: MAX_MESSAGE_LENGTH, + renderPreviewText: renderMarkdownPreviewText, + getDraftSupport: () => draftSupport, + setDraftSupport: (support: "unknown" | "supported" | "unsupported") => { + draftSupport = support; + }, + allocateDraftId, + sendDraft: async (chatId: number, draftId: number, text: string) => { + await callTelegramApi("sendMessageDraft", { + chat_id: chatId, + draft_id: draftId, + text, + }); + }, + sendMessage: async (chatId: number, text: string) => { + return callTelegramApi("sendMessage", { + chat_id: chatId, + text, + }); + }, + editMessageText: async ( + chatId: number, + messageId: number, + text: string, + ) => { + await editTelegramMessageText({ + chat_id: chatId, + message_id: messageId, + text, + }); + }, + renderTelegramMessage, + sendRenderedChunks: replyTransport.sendRenderedChunks, + editRenderedMessage: replyTransport.editRenderedMessage, + }; } async function clearPreview(chatId: number): Promise { - const state = previewState; - if (!state) return; - if (state.flushTimer) { - clearTimeout(state.flushTimer); - state.flushTimer = undefined; - } - previewState = undefined; - if (state.mode === "draft" && state.draftId !== undefined) { - try { - await callTelegram("sendMessageDraft", { - chat_id: chatId, - draft_id: state.draftId, - text: "", - }); - } catch { - // ignore - } - } + await clearTelegramPreview(chatId, getPreviewRuntimeDeps()); } async function flushPreview(chatId: number): Promise { - const state = previewState; - if (!state) return; - state.flushTimer = undefined; - const rawText = state.pendingText.trim(); - const previewText = renderMarkdownPreviewText(rawText).trim(); - if (!previewText || previewText === state.lastSentText) return; - const truncated = - previewText.length > MAX_MESSAGE_LENGTH - ? previewText.slice(0, MAX_MESSAGE_LENGTH) - : previewText; - - if (draftSupport !== "unsupported") { - const draftId = state.draftId ?? allocateDraftId(); - state.draftId = draftId; - try { - await callTelegram("sendMessageDraft", { - chat_id: chatId, - draft_id: draftId, - text: truncated, - }); - draftSupport = "supported"; - state.mode = "draft"; - state.lastSentText = truncated; - return; - } catch { - draftSupport = "unsupported"; - } - } - - if (state.messageId === undefined) { - const sent = await callTelegram("sendMessage", { - chat_id: chatId, - text: truncated, - }); - state.messageId = sent.message_id; - state.mode = "message"; - state.lastSentText = truncated; - return; - } - await editTelegramMessageText({ - chat_id: chatId, - message_id: state.messageId, - text: truncated, - }); - state.mode = "message"; - state.lastSentText = truncated; + await flushTelegramPreview(chatId, getPreviewRuntimeDeps()); } function schedulePreviewFlush(chatId: number): void { @@ -1833,49 +732,18 @@ export default function (pi: ExtensionAPI) { } async function finalizePreview(chatId: number): Promise { - const state = previewState; - if (!state) return false; - await flushPreview(chatId); - const finalText = (state.pendingText.trim() || state.lastSentText).trim(); - if (!finalText) { - await clearPreview(chatId); - return false; - } - if (state.mode === "draft") { - await callTelegram("sendMessage", { - chat_id: chatId, - text: finalText, - }); - await clearPreview(chatId); - return true; - } - previewState = undefined; - return state.messageId !== undefined; + return finalizeTelegramPreview(chatId, getPreviewRuntimeDeps()); } async function finalizeMarkdownPreview( chatId: number, markdown: string, ): Promise { - const state = previewState; - if (!state) return false; - await flushPreview(chatId); - const chunks = renderTelegramMessage(markdown, { mode: "markdown" }); - if (chunks.length === 0) { - await clearPreview(chatId); - return false; - } - if (state.mode === "draft") { - await sendRenderedChunks(chatId, chunks); - await clearPreview(chatId); - return true; - } - if (state.messageId !== undefined) { - await editRenderedMessage(chatId, state.messageId, chunks); - previewState = undefined; - return true; - } - return false; + return finalizeTelegramMarkdownPreview( + chatId, + markdown, + getPreviewRuntimeDeps(), + ); } async function sendTextReply( @@ -1884,10 +752,15 @@ export default function (pi: ExtensionAPI) { text: string, options?: { parseMode?: "HTML" }, ): Promise { - const chunks = renderTelegramMessage(text, { - mode: options?.parseMode === "HTML" ? "html" : "plain", - }); - return sendRenderedChunks(chatId, chunks); + return sendTelegramPlainReply( + text, + { + renderTelegramMessage, + sendRenderedChunks: async (chunks) => + replyTransport.sendRenderedChunks(chatId, chunks), + }, + options, + ); } async function sendMarkdownReply( @@ -1895,39 +768,32 @@ export default function (pi: ExtensionAPI) { replyToMessageId: number, markdown: string, ): Promise { - const chunks = renderTelegramMessage(markdown, { mode: "markdown" }); - if (chunks.length === 0) { - return sendTextReply(chatId, replyToMessageId, markdown); - } - return sendRenderedChunks(chatId, chunks); + return sendTelegramMarkdownReply(markdown, { + renderTelegramMessage, + sendRenderedChunks: async (chunks) => { + if (chunks.length === 0) { + return sendTextReply(chatId, replyToMessageId, markdown); + } + return replyTransport.sendRenderedChunks(chatId, chunks); + }, + }); } async function sendQueuedAttachments( turn: ActiveTelegramTurn, ): Promise { - for (const attachment of turn.queuedAttachments) { - try { - const mediaType = guessMediaType(attachment.path); - const method = mediaType ? "sendPhoto" : "sendDocument"; - const fieldName = mediaType ? "photo" : "document"; - await callTelegramMultipart( + await sendQueuedTelegramAttachments(turn, { + sendMultipart: async (method, fields, fileField, filePath, fileName) => { + await callTelegramMultipartApi( method, - { - chat_id: String(turn.chatId), - }, - fieldName, - attachment.path, - attachment.fileName, + fields, + fileField, + filePath, + fileName, ); - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - await sendTextReply( - turn.chatId, - turn.replyToMessageId, - `Failed to send attachment ${attachment.fileName}: ${message}`, - ); - } - } + }, + sendTextReply, + }); } function extractAssistantText(messages: AgentMessage[]): { @@ -1984,7 +850,7 @@ export default function (pi: ExtensionAPI) { nextConfig.botId = data.result.id; nextConfig.botUsername = data.result.username; config = nextConfig; - await writeConfig(config); + await writeTelegramConfig(AGENT_DIR, CONFIG_PATH, config); ctx.ui.notify( `Telegram bot connected: @${config.botUsername ?? "unknown"}`, "info", @@ -2014,7 +880,7 @@ export default function (pi: ExtensionAPI) { { command: "compact", description: "Compact the current pi session" }, { command: "stop", description: "Abort the current pi task" }, ]; - await callTelegram("setMyCommands", { commands }); + await callTelegramApi("setMyCommands", { commands }); } function getCurrentTelegramModel( @@ -2034,102 +900,16 @@ export default function (pi: ExtensionAPI) { ctx.modelRegistry.refresh(); const activeModel = getCurrentTelegramModel(ctx); const availableModels = ctx.modelRegistry.getAvailable(); - const allModels = sortScopedModels( - availableModels.map((model) => ({ model })), - activeModel, - ); const cliScopedModels = getCliScopedModelPatterns(); const configuredScopedModels = cliScopedModels ?? settingsManager.getEnabledModels() ?? []; - const scopedModels = - configuredScopedModels.length > 0 - ? sortScopedModels( - resolveScopedModelPatterns(configuredScopedModels, availableModels), - activeModel, - ) - : []; - let note: string | undefined; - if (configuredScopedModels.length > 0 && scopedModels.length === 0) { - note = cliScopedModels - ? "No CLI scoped models matched the current auth configuration. Showing all available models." - : "No scoped models matched the current auth configuration. Showing all available models."; - } - return { + return buildTelegramModelMenuState({ chatId, - messageId: 0, - page: 0, - scope: scopedModels.length > 0 ? "scoped" : "all", - scopedModels, - allModels, - note, - mode: "status", - }; - } - - function buildThinkingMenuText(ctx: ExtensionContext): string { - const activeModel = getCurrentTelegramModel(ctx); - const lines = ["Choose a thinking level"]; - if (activeModel) { - lines.push(`Model: ${getCanonicalModelId(activeModel)}`); - } - lines.push(`Current: ${pi.getThinkingLevel()}`); - return lines.join("\n"); - } - - function buildModelMenuReplyMarkup( - state: TelegramModelMenuState, - currentModel: Model | undefined, - ): TelegramReplyMarkup { - const items = getModelMenuItems(state); - const pageCount = Math.max( - 1, - Math.ceil(items.length / TELEGRAM_MODEL_PAGE_SIZE), - ); - state.page = Math.max(0, Math.min(state.page, pageCount - 1)); - const start = state.page * TELEGRAM_MODEL_PAGE_SIZE; - const pageItems = items.slice(start, start + TELEGRAM_MODEL_PAGE_SIZE); - const rows = pageItems.map((entry, index) => [ - { - text: formatScopedModelButtonText(entry, currentModel), - callback_data: `model:pick:${start + index}`, - }, - ]); - if (pageCount > 1) { - const previousPage = state.page === 0 ? pageCount - 1 : state.page - 1; - const nextPage = state.page === pageCount - 1 ? 0 : state.page + 1; - rows.push([ - { text: "⬅️", callback_data: `model:page:${previousPage}` }, - { text: `${state.page + 1}/${pageCount}`, callback_data: "model:noop" }, - { text: "➑️", callback_data: `model:page:${nextPage}` }, - ]); - } - if (state.scopedModels.length > 0) { - rows.push([ - { - text: state.scope === "scoped" ? "βœ… Scoped" : "Scoped", - callback_data: "model:scope:scoped", - }, - { - text: state.scope === "all" ? "βœ… All" : "All", - callback_data: "model:scope:all", - }, - ]); - } - return { inline_keyboard: rows }; - } - - function buildThinkingMenuReplyMarkup( - ctx: ExtensionContext, - ): TelegramReplyMarkup { - const currentThinkingLevel = pi.getThinkingLevel(); - return { - inline_keyboard: THINKING_LEVELS.map((level) => [ - { - text: level === currentThinkingLevel ? `βœ… ${level}` : level, - callback_data: `thinking:set:${level}`, - }, - ]), - }; + activeModel, + availableModels, + configuredScopedModelPatterns: configuredScopedModels, + cliScopedModelPatterns: cliScopedModels ?? undefined, + }); } // --- Interactive Menu Actions --- @@ -2138,28 +918,21 @@ export default function (pi: ExtensionAPI) { state: TelegramModelMenuState, ctx: ExtensionContext, ): Promise { - state.mode = "model"; - const activeModel = getCurrentTelegramModel(ctx); - await editInteractiveMessage( - state.chatId, - state.messageId, - MODEL_MENU_TITLE, - "html", - buildModelMenuReplyMarkup(state, activeModel), - ); + await updateTelegramModelMenuMessage(state, getCurrentTelegramModel(ctx), { + editInteractiveMessage, + sendInteractiveMessage, + }); } async function updateThinkingMenuMessage( state: TelegramModelMenuState, ctx: ExtensionContext, ): Promise { - state.mode = "thinking"; - await editInteractiveMessage( - state.chatId, - state.messageId, - buildThinkingMenuText(ctx), - "plain", - buildThinkingMenuReplyMarkup(ctx), + await updateTelegramThinkingMenuMessage( + state, + getCurrentTelegramModel(ctx), + pi.getThinkingLevel(), + { editInteractiveMessage, sendInteractiveMessage }, ); } @@ -2170,7 +943,7 @@ export default function (pi: ExtensionAPI) { mode: TelegramRenderMode, replyMarkup: TelegramReplyMarkup, ): Promise { - await editRenderedMessage( + await replyTransport.editRenderedMessage( chatId, messageId, renderTelegramMessage(text, { mode }), @@ -2184,9 +957,11 @@ export default function (pi: ExtensionAPI) { mode: TelegramRenderMode, replyMarkup: TelegramReplyMarkup, ): Promise { - return sendRenderedChunks(chatId, renderTelegramMessage(text, { mode }), { - replyMarkup, - }); + return replyTransport.sendRenderedChunks( + chatId, + renderTelegramMessage(text, { mode }), + { replyMarkup }, + ); } async function ensureIdleOrNotify( @@ -2204,13 +979,12 @@ export default function (pi: ExtensionAPI) { state: TelegramModelMenuState, ctx: ExtensionContext, ): Promise { - state.mode = "status"; - await editInteractiveMessage( - state.chatId, - state.messageId, - buildStatusHtml(ctx), - "html", - buildStatusReplyMarkup(ctx), + await updateTelegramStatusMessage( + state, + buildStatusHtml(ctx, getCurrentTelegramModel(ctx)), + getCurrentTelegramModel(ctx), + pi.getThinkingLevel(), + { editInteractiveMessage, sendInteractiveMessage }, ); } @@ -2227,11 +1001,12 @@ export default function (pi: ExtensionAPI) { ); if (!isIdle) return; const state = await getModelMenuState(chatId, ctx); - const messageId = await sendInteractiveMessage( - chatId, - buildStatusHtml(ctx), - "html", - buildStatusReplyMarkup(ctx), + const messageId = await sendTelegramStatusMessage( + state, + buildStatusHtml(ctx, getCurrentTelegramModel(ctx)), + getCurrentTelegramModel(ctx), + pi.getThinkingLevel(), + { editInteractiveMessage, sendInteractiveMessage }, ); if (messageId === undefined) return; state.messageId = messageId; @@ -2247,6 +1022,36 @@ export default function (pi: ExtensionAPI) { }); } + function createTelegramControlItem( + chatId: number, + replyToMessageId: number, + controlType: PendingTelegramControlItem["controlType"], + statusSummary: string, + execute: PendingTelegramControlItem["execute"], + ): PendingTelegramControlItem { + const queueOrder = nextQueuedTelegramItemOrder++; + return { + kind: "control", + controlType, + chatId, + replyToMessageId, + queueOrder, + queueLane: "control", + laneOrder: nextQueuedTelegramControlOrder++, + statusSummary, + execute, + }; + } + + function enqueueTelegramControlItem( + item: PendingTelegramControlItem, + ctx: ExtensionContext, + ): void { + queuedTelegramItems.push(item); + reorderQueuedTelegramTurns(ctx); + dispatchNextQueuedTelegramTurn(ctx); + } + function createTelegramModelSwitchContinuationTurn( turn: ActiveTelegramTurn, selection: ScopedTelegramModel, @@ -2257,16 +1062,19 @@ export default function (pi: ExtensionAPI) { 32, ); return { + kind: "prompt", chatId: turn.chatId, replyToMessageId: turn.replyToMessageId, sourceMessageIds: [], - queueOrder: nextSyntheticTelegramTurnOrder--, - priorityReactionOrder: -1, + queueOrder: nextQueuedTelegramItemOrder++, + queueLane: "control", + laneOrder: nextQueuedTelegramControlOrder++, queuedAttachments: [], content: [ { type: "text", text: buildTelegramModelSwitchContinuationText( + TELEGRAM_PREFIX, selection.model, selection.thinkingLevel, ), @@ -2282,7 +1090,7 @@ export default function (pi: ExtensionAPI) { selection: ScopedTelegramModel, ctx: ExtensionContext, ): void { - queuedTelegramTurns.push( + queuedTelegramItems.push( createTelegramModelSwitchContinuationTurn(turn, selection), ); reorderQueuedTelegramTurns(ctx); @@ -2292,17 +1100,22 @@ export default function (pi: ExtensionAPI) { ctx: ExtensionContext, ): boolean { if ( - !pendingTelegramModelSwitch || - !activeTelegramTurn || - !currentAbort || - activeTelegramToolExecutions > 0 + !shouldTriggerPendingTelegramModelSwitchAbort({ + hasPendingModelSwitch: !!pendingTelegramModelSwitch, + hasActiveTelegramTurn: !!activeTelegramTurn, + hasAbortHandler: !!currentAbort, + activeToolExecutions: activeTelegramToolExecutions, + }) ) { return false; } const selection = pendingTelegramModelSwitch; + const turn = activeTelegramTurn; + const abort = currentAbort; + if (!selection || !turn || !abort) return false; pendingTelegramModelSwitch = undefined; - queueTelegramModelSwitchContinuation(activeTelegramTurn, selection, ctx); - currentAbort(); + queueTelegramModelSwitchContinuation(turn, selection, ctx); + abort(); return true; } @@ -2329,12 +1142,10 @@ export default function (pi: ExtensionAPI) { return; } const activeModel = getCurrentTelegramModel(ctx); - const messageId = await sendInteractiveMessage( - chatId, - MODEL_MENU_TITLE, - "html", - buildModelMenuReplyMarkup(state, activeModel), - ); + const messageId = await sendTelegramModelMenuMessage(state, activeModel, { + editInteractiveMessage, + sendInteractiveMessage, + }); if (messageId === undefined) return; state.messageId = messageId; state.mode = "model"; @@ -2346,23 +1157,17 @@ export default function (pi: ExtensionAPI) { state: TelegramModelMenuState, ctx: ExtensionContext, ): Promise { - if (query.data === "status:model") { - await updateModelMenuMessage(state, ctx); - await answerCallbackQuery(query.id); - return true; - } - if (query.data !== "status:thinking") return false; - const activeModel = getCurrentTelegramModel(ctx); - if (!activeModel?.reasoning) { - await answerCallbackQuery( - query.id, - "This model has no reasoning controls.", - ); - return true; - } - await updateThinkingMenuMessage(state, ctx); - await answerCallbackQuery(query.id); - return true; + return handleTelegramStatusMenuCallbackAction( + query.id, + query.data, + getCurrentTelegramModel(ctx), + { + updateModelMenuMessage: async () => updateModelMenuMessage(state, ctx), + updateThinkingMenuMessage: async () => + updateThinkingMenuMessage(state, ctx), + answerCallbackQuery, + }, + ); } async function handleThinkingCallbackAction( @@ -2370,24 +1175,17 @@ export default function (pi: ExtensionAPI) { state: TelegramModelMenuState, ctx: ExtensionContext, ): Promise { - if (!query.data?.startsWith("thinking:set:")) return false; - const level = query.data.slice("thinking:set:".length); - if (!isThinkingLevel(level)) { - await answerCallbackQuery(query.id, "Invalid thinking level."); - return true; - } - const activeModel = getCurrentTelegramModel(ctx); - if (!activeModel?.reasoning) { - await answerCallbackQuery( - query.id, - "This model has no reasoning controls.", - ); - return true; - } - pi.setThinkingLevel(level); - await showStatusMessage(state, ctx); - await answerCallbackQuery(query.id, `Thinking: ${pi.getThinkingLevel()}`); - return true; + return handleTelegramThinkingMenuCallbackAction( + query.id, + query.data, + getCurrentTelegramModel(ctx), + { + setThinkingLevel: (level) => pi.setThinkingLevel(level), + getCurrentThinkingLevel: () => pi.getThinkingLevel(), + updateStatusMessage: async () => showStatusMessage(state, ctx), + answerCallbackQuery, + }, + ); } async function handleModelCallbackAction( @@ -2395,131 +1193,48 @@ export default function (pi: ExtensionAPI) { state: TelegramModelMenuState, ctx: ExtensionContext, ): Promise { - if (!query.data?.startsWith("model:")) return false; - const [, action, value] = query.data.split(":"); - if (action === "noop") { - await answerCallbackQuery(query.id); - return true; - } - if (action === "scope") { - if (value !== "all" && value !== "scoped") { - await answerCallbackQuery(query.id, "Unknown model scope."); - return true; - } - if (value === state.scope) { - await answerCallbackQuery(query.id); - return true; - } - state.scope = value; - state.page = 0; - await updateModelMenuMessage(state, ctx); - await answerCallbackQuery( - query.id, - state.scope === "scoped" ? "Scoped models" : "All models", - ); - return true; - } - if (action === "page") { - const page = Number(value); - if (!Number.isFinite(page)) { - await answerCallbackQuery(query.id, "Invalid page."); - return true; - } - if (page === state.page) { - await answerCallbackQuery(query.id); - return true; - } - state.page = page; - await updateModelMenuMessage(state, ctx); - await answerCallbackQuery(query.id); - return true; - } - if (action !== "pick") { - await answerCallbackQuery(query.id); - return true; - } - const index = Number(value); - if (!Number.isFinite(index)) { - await answerCallbackQuery(query.id, "Invalid model selection."); - return true; - } - const selection = getModelMenuItems(state)[index]; - if (!selection) { - await answerCallbackQuery( - query.id, - "Selected model is no longer available.", - ); - return true; - } - const activeModel = getCurrentTelegramModel(ctx); - if (modelsMatch(selection.model, activeModel)) { - if ( - selection.thinkingLevel && - selection.thinkingLevel !== pi.getThinkingLevel() - ) { - pi.setThinkingLevel(selection.thinkingLevel); - } - await showStatusMessage(state, ctx); - await answerCallbackQuery(query.id, `Model: ${selection.model.id}`); - return true; - } - if (!ctx.isIdle()) { - if (!activeTelegramTurn || !currentAbort) { - await answerCallbackQuery(query.id, "Pi is busy. Send /stop first."); - return true; - } - try { - const changed = await pi.setModel(selection.model); - if (changed === false) { - await answerCallbackQuery(query.id, "Model is not available."); - return true; - } - currentTelegramModel = selection.model; - if (selection.thinkingLevel) { - pi.setThinkingLevel(selection.thinkingLevel); - } - await showStatusMessage(state, ctx); - if (activeTelegramToolExecutions > 0) { - pendingTelegramModelSwitch = selection; - await answerCallbackQuery( - query.id, - `Switched to ${selection.model.id}. Restarting after the current tool finishes…`, - ); - return true; - } - queueTelegramModelSwitchContinuation( - activeTelegramTurn, - selection, - ctx, - ); - currentAbort(); - await answerCallbackQuery( - query.id, - `Switching to ${selection.model.id} and continuing…`, - ); - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - await answerCallbackQuery(query.id, message); - } - return true; - } try { - const changed = await pi.setModel(selection.model); - if (changed === false) { - await answerCallbackQuery(query.id, "Model is not available."); - return true; - } - currentTelegramModel = selection.model; - if (selection.thinkingLevel) { - pi.setThinkingLevel(selection.thinkingLevel); - } - await showStatusMessage(state, ctx); - await answerCallbackQuery(query.id, `Switched to ${selection.model.id}`); + return await handleTelegramModelMenuCallbackAction( + query.id, + { + data: query.data, + state, + activeModel: getCurrentTelegramModel(ctx), + currentThinkingLevel: pi.getThinkingLevel(), + isIdle: ctx.isIdle(), + canRestartBusyRun: !!activeTelegramTurn && !!currentAbort, + hasActiveToolExecutions: activeTelegramToolExecutions > 0, + }, + { + updateModelMenuMessage: async () => + updateModelMenuMessage(state, ctx), + updateStatusMessage: async () => showStatusMessage(state, ctx), + answerCallbackQuery, + setModel: (model) => pi.setModel(model), + setCurrentModel: (model) => { + currentTelegramModel = model; + }, + setThinkingLevel: (level) => pi.setThinkingLevel(level), + stagePendingModelSwitch: (selection) => { + pendingTelegramModelSwitch = selection; + }, + restartInterruptedTelegramTurn: (selection) => { + return restartTelegramModelSwitchContinuation({ + activeTurn: activeTelegramTurn, + abort: currentAbort, + selection, + queueContinuation: (turn, nextSelection) => { + queueTelegramModelSwitchContinuation(turn, nextSelection, ctx); + }, + }); + }, + }, + ); } catch (error) { const message = error instanceof Error ? error.message : String(error); await answerCallbackQuery(query.id, message); + return true; } - return true; } async function handleAuthorizedTelegramCallbackQuery( @@ -2527,271 +1242,44 @@ export default function (pi: ExtensionAPI) { ctx: ExtensionContext, ): Promise { const messageId = query.message?.message_id; - if (!messageId || !query.data) { - await answerCallbackQuery(query.id); - return; - } - const state = modelMenus.get(messageId); - if (!state) { - await answerCallbackQuery(query.id, "Interactive message expired."); - return; - } - const handled = - (await handleStatusCallbackAction(query, state, ctx)) || - (await handleThinkingCallbackAction(query, state, ctx)) || - (await handleModelCallbackAction(query, state, ctx)); - if (!handled) { - await answerCallbackQuery(query.id); - } + await handleTelegramMenuCallbackEntry( + query.id, + query.data, + messageId ? modelMenus.get(messageId) : undefined, + { + handleStatusAction: async () => { + const state = messageId ? modelMenus.get(messageId) : undefined; + if (!state) return false; + return handleStatusCallbackAction(query, state, ctx); + }, + handleThinkingAction: async () => { + const state = messageId ? modelMenus.get(messageId) : undefined; + if (!state) return false; + return handleThinkingCallbackAction(query, state, ctx); + }, + handleModelAction: async () => { + const state = messageId ? modelMenus.get(messageId) : undefined; + if (!state) return false; + return handleModelCallbackAction(query, state, ctx); + }, + answerCallbackQuery, + }, + ); } // --- Status Rendering --- - function buildStatusReplyMarkup(ctx: ExtensionContext): TelegramReplyMarkup { - const activeModel = getCurrentTelegramModel(ctx); - const rows: Array> = []; - rows.push([ - { - text: formatStatusButtonLabel( - "Model", - activeModel ? getCanonicalModelId(activeModel) : "unknown", - ), - callback_data: "status:model", - }, - ]); - if (activeModel?.reasoning) { - rows.push([ - { - text: formatStatusButtonLabel("Thinking", pi.getThinkingLevel()), - callback_data: "status:thinking", - }, - ]); - } - return { inline_keyboard: rows }; - } - - function collectUsageStats(ctx: ExtensionContext): TelegramUsageStats { - const stats: TelegramUsageStats = { - totalInput: 0, - totalOutput: 0, - totalCacheRead: 0, - totalCacheWrite: 0, - totalCost: 0, - }; - for (const entry of ctx.sessionManager.getEntries()) { - if (entry.type !== "message" || entry.message.role !== "assistant") { - continue; - } - stats.totalInput += entry.message.usage.input; - stats.totalOutput += entry.message.usage.output; - stats.totalCacheRead += entry.message.usage.cacheRead; - stats.totalCacheWrite += entry.message.usage.cacheWrite; - stats.totalCost += entry.message.usage.cost.total; - } - return stats; - } - - function buildStatusRow(label: string, value: string): string { - return `${escapeHtml(label)}: ${escapeHtml(value)}`; - } - - function buildUsageSummary(stats: TelegramUsageStats): string | undefined { - const tokenParts: string[] = []; - if (stats.totalInput) tokenParts.push(`↑${formatTokens(stats.totalInput)}`); - if (stats.totalOutput) - tokenParts.push(`↓${formatTokens(stats.totalOutput)}`); - if (stats.totalCacheRead) - tokenParts.push(`R${formatTokens(stats.totalCacheRead)}`); - if (stats.totalCacheWrite) - tokenParts.push(`W${formatTokens(stats.totalCacheWrite)}`); - return tokenParts.length > 0 ? tokenParts.join(" ") : undefined; - } - - function buildCostSummary( - stats: TelegramUsageStats, - usesSubscription: boolean, - ): string | undefined { - if (!stats.totalCost && !usesSubscription) return undefined; - return `$${stats.totalCost.toFixed(3)}${usesSubscription ? " (sub)" : ""}`; - } - - function buildContextSummary( - ctx: ExtensionContext, - activeModel: Model | undefined, - ): string { - const usage = ctx.getContextUsage(); - if (!usage) return "unknown"; - const contextWindow = - usage.contextWindow ?? activeModel?.contextWindow ?? 0; - const percent = - usage.percent !== null ? `${usage.percent.toFixed(1)}%` : "?"; - return `${percent}/${formatTokens(contextWindow)}`; - } - - function buildStatusHtml(ctx: ExtensionContext): string { - const stats = collectUsageStats(ctx); - const activeModel = getCurrentTelegramModel(ctx); - const usesSubscription = activeModel - ? ctx.modelRegistry.isUsingOAuth(activeModel) - : false; - const lines: string[] = []; - const usageSummary = buildUsageSummary(stats); - const costSummary = buildCostSummary(stats, usesSubscription); - if (usageSummary) { - lines.push(buildStatusRow("Usage", usageSummary)); - } - if (costSummary) { - lines.push(buildStatusRow("Cost", costSummary)); - } - lines.push( - buildStatusRow("Context", buildContextSummary(ctx, activeModel)), - ); - if (lines.length === 0) { - lines.push(buildStatusRow("Status", "No usage data yet.")); - } - return lines.join("\n"); - } - // --- Turn Queue & Message Dispatch --- - function extractTelegramMessageText(message: TelegramMessage): string { - return (message.text || message.caption || "").trim(); - } - - function extractTelegramMessagesText(messages: TelegramMessage[]): string { - return messages - .map(extractTelegramMessageText) - .filter(Boolean) - .join("\n\n"); - } - - function extractFirstTelegramMessageText( - messages: TelegramMessage[], - ): string { - return messages.map(extractTelegramMessageText).find(Boolean) ?? ""; - } - - function collectTelegramMessageIds(messages: TelegramMessage[]): number[] { - return [...new Set(messages.map((message) => message.message_id))]; - } - - function formatTelegramHistoryText( - rawText: string, - files: DownloadedTelegramFile[], - ): string { - let summary = rawText.length > 0 ? rawText : "(no text)"; - if (files.length > 0) { - summary += `\nAttachments:`; - for (const file of files) { - summary += `\n- ${file.path}`; - } - } - return summary; - } - - function collectTelegramFileInfos( - messages: TelegramMessage[], - ): TelegramFileInfo[] { - const files: TelegramFileInfo[] = []; - for (const message of messages) { - if (Array.isArray(message.photo) && message.photo.length > 0) { - const photo = [...message.photo] - .sort((a, b) => (a.file_size ?? 0) - (b.file_size ?? 0)) - .pop(); - if (photo) { - files.push({ - file_id: photo.file_id, - fileName: `photo-${message.message_id}.jpg`, - mimeType: "image/jpeg", - isImage: true, - }); - } - } - if (message.document) { - const fileName = - message.document.file_name || - `document-${message.message_id}${guessExtensionFromMime( - message.document.mime_type, - "", - )}`; - files.push({ - file_id: message.document.file_id, - fileName, - mimeType: message.document.mime_type, - isImage: isImageMimeType(message.document.mime_type), - }); - } - if (message.video) { - const fileName = - message.video.file_name || - `video-${message.message_id}${guessExtensionFromMime( - message.video.mime_type, - ".mp4", - )}`; - files.push({ - file_id: message.video.file_id, - fileName, - mimeType: message.video.mime_type, - isImage: false, - }); - } - if (message.audio) { - const fileName = - message.audio.file_name || - `audio-${message.message_id}${guessExtensionFromMime( - message.audio.mime_type, - ".mp3", - )}`; - files.push({ - file_id: message.audio.file_id, - fileName, - mimeType: message.audio.mime_type, - isImage: false, - }); - } - if (message.voice) { - files.push({ - file_id: message.voice.file_id, - fileName: `voice-${message.message_id}${guessExtensionFromMime( - message.voice.mime_type, - ".ogg", - )}`, - mimeType: message.voice.mime_type, - isImage: false, - }); - } - if (message.animation) { - const fileName = - message.animation.file_name || - `animation-${message.message_id}${guessExtensionFromMime( - message.animation.mime_type, - ".mp4", - )}`; - files.push({ - file_id: message.animation.file_id, - fileName, - mimeType: message.animation.mime_type, - isImage: false, - }); - } - if (message.sticker) { - files.push({ - file_id: message.sticker.file_id, - fileName: `sticker-${message.message_id}.webp`, - mimeType: "image/webp", - isImage: true, - }); - } - } - return files; - } - async function buildTelegramFiles( messages: TelegramMessage[], ): Promise { const downloaded: DownloadedTelegramFile[] = []; for (const file of collectTelegramFileInfos(messages)) { - const path = await downloadTelegramFile(file.file_id, file.fileName); + const path = await downloadTelegramBridgeFile( + file.file_id, + file.fileName, + ); downloaded.push({ path, fileName: file.fileName, @@ -2802,67 +1290,11 @@ export default function (pi: ExtensionAPI) { return downloaded; } - function isTelegramMessageIdList(value: unknown): value is number[] { - return ( - Array.isArray(value) && value.every((item) => Number.isInteger(item)) - ); - } - - function normalizeTelegramReactionEmoji(emoji: string): string { - return emoji.replace(/\uFE0F/g, ""); - } - - function collectTelegramReactionEmojis( - reactions: TelegramReactionType[], - ): Set { - return new Set( - reactions - .filter( - (reaction): reaction is TelegramReactionTypeEmoji => - reaction.type === "emoji", - ) - .map((reaction) => normalizeTelegramReactionEmoji(reaction.emoji)), - ); - } - - function compareQueuedTelegramTurns( - left: PendingTelegramTurn, - right: PendingTelegramTurn, - ): number { - const leftPriority = left.priorityReactionOrder ?? Number.POSITIVE_INFINITY; - const rightPriority = - right.priorityReactionOrder ?? Number.POSITIVE_INFINITY; - if (leftPriority !== rightPriority) return leftPriority - rightPriority; - return left.queueOrder - right.queueOrder; - } - function reorderQueuedTelegramTurns(ctx: ExtensionContext): void { - queuedTelegramTurns.sort(compareQueuedTelegramTurns); + queuedTelegramItems.sort(compareTelegramQueueItems); updateStatus(ctx); } - function extractDeletedTelegramMessageIds(update: TelegramUpdate): number[] { - const deletedBusinessMessageIds = ( - update as TelegramUpdate & { - deleted_business_messages?: { message_ids?: unknown }; - } - ).deleted_business_messages?.message_ids; - if (isTelegramMessageIdList(deletedBusinessMessageIds)) { - return deletedBusinessMessageIds; - } - const rawDeleteUpdate = update as TelegramUpdate & { - _: string; - messages?: unknown; - }; - if ( - rawDeleteUpdate._ === "updateDeleteMessages" && - isTelegramMessageIdList(rawDeleteUpdate.messages) - ) { - return rawDeleteUpdate.messages; - } - return []; - } - function removePendingMediaGroupMessages(messageIds: number[]): void { if (messageIds.length === 0 || mediaGroups.size === 0) return; const deletedMessageIds = new Set(messageIds); @@ -2883,30 +1315,26 @@ export default function (pi: ExtensionAPI) { messageIds: number[], ctx: ExtensionContext, ): number { - if (messageIds.length === 0 || queuedTelegramTurns.length === 0) return 0; - const deletedMessageIds = new Set(messageIds); - const nextQueue = queuedTelegramTurns.filter( - (turn) => - !turn.sourceMessageIds.some((messageId) => - deletedMessageIds.has(messageId), - ), + const result = removeTelegramQueueItemsByMessageIds( + queuedTelegramItems, + messageIds, ); - const removedCount = queuedTelegramTurns.length - nextQueue.length; - if (removedCount === 0) return 0; - queuedTelegramTurns = nextQueue; + if (result.removedCount === 0) return 0; + queuedTelegramItems = result.items; updateStatus(ctx); - return removedCount; + return result.removedCount; } function clearQueuedTelegramTurnPriorityByMessageId( messageId: number, ctx: ExtensionContext, ): boolean { - const turn = queuedTelegramTurns.find((entry) => - entry.sourceMessageIds.includes(messageId), + const result = clearTelegramQueuePromptPriority( + queuedTelegramItems, + messageId, ); - if (!turn || turn.priorityReactionOrder === undefined) return false; - turn.priorityReactionOrder = undefined; + if (!result.changed) return false; + queuedTelegramItems = result.items; reorderQueuedTelegramTurns(ctx); return true; } @@ -2915,11 +1343,14 @@ export default function (pi: ExtensionAPI) { messageId: number, ctx: ExtensionContext, ): boolean { - const turn = queuedTelegramTurns.find((entry) => - entry.sourceMessageIds.includes(messageId), + const result = prioritizeTelegramQueuePrompt( + queuedTelegramItems, + messageId, + nextPriorityReactionOrder, ); - if (!turn) return false; - turn.priorityReactionOrder = nextPriorityReactionOrder++; + if (!result.changed) return false; + queuedTelegramItems = result.items; + nextPriorityReactionOrder += 1; reorderQueuedTelegramTurns(ctx); return true; } @@ -2965,55 +1396,16 @@ export default function (pi: ExtensionAPI) { messages: TelegramMessage[], historyTurns: PendingTelegramTurn[] = [], ): Promise { - const firstMessage = messages[0]; - if (!firstMessage) - throw new Error("Missing Telegram message for turn creation"); - const rawText = extractTelegramMessagesText(messages); - const files = await buildTelegramFiles(messages); - const content: Array = []; - let prompt = `${TELEGRAM_PREFIX}`; - - if (historyTurns.length > 0) { - prompt += `\n\nEarlier Telegram messages arrived after an aborted turn. Treat them as prior user messages, in order:`; - for (const [index, turn] of historyTurns.entries()) { - prompt += `\n\n${index + 1}. ${turn.historyText}`; - } - prompt += `\n\nCurrent Telegram message:`; - } - - if (rawText.length > 0) { - prompt += historyTurns.length > 0 ? `\n${rawText}` : ` ${rawText}`; - } - if (files.length > 0) { - prompt += `\n\nTelegram attachments were saved locally:`; - for (const file of files) { - prompt += `\n- ${file.path}`; - } - } - content.push({ type: "text", text: prompt }); - - for (const file of files) { - if (!file.isImage) continue; - const mediaType = file.mimeType || guessMediaType(file.path); - if (!mediaType) continue; - const buffer = await readFile(file.path); - content.push({ - type: "image", - data: buffer.toString("base64"), - mimeType: mediaType, - }); - } - - return { - chatId: firstMessage.chat.id, - replyToMessageId: firstMessage.message_id, - sourceMessageIds: collectTelegramMessageIds(messages), - queueOrder: nextQueuedTelegramTurnOrder++, - queuedAttachments: [], - content, - historyText: formatTelegramHistoryText(rawText, files), - statusSummary: formatTelegramTurnStatusSummary(rawText, files), - }; + return buildTelegramPromptTurn({ + telegramPrefix: TELEGRAM_PREFIX, + messages, + historyTurns, + queueOrder: nextQueuedTelegramItemOrder++, + rawText: extractTelegramMessagesText(messages), + files: await buildTelegramFiles(messages), + readBinaryFile: async (path) => readFile(path), + inferImageMimeType: guessMediaType, + }); } async function handleStopCommand( @@ -3022,7 +1414,7 @@ export default function (pi: ExtensionAPI) { ): Promise { if (currentAbort) { pendingTelegramModelSwitch = undefined; - if (queuedTelegramTurns.length > 0) { + if (queuedTelegramItems.length > 0) { preserveQueuedTurnsAsHistory = true; } currentAbort(); @@ -3046,7 +1438,7 @@ export default function (pi: ExtensionAPI) { ctx.hasPendingMessages() || activeTelegramTurn || telegramTurnDispatchPending || - queuedTelegramTurns.length > 0 || + queuedTelegramItems.length > 0 || compactionInProgress ) { await sendTextReply( @@ -3106,14 +1498,40 @@ export default function (pi: ExtensionAPI) { message: TelegramMessage, ctx: ExtensionContext, ): Promise { - await sendStatusMessage(message.chat.id, message.message_id, ctx); + enqueueTelegramControlItem( + createTelegramControlItem( + message.chat.id, + message.message_id, + "status", + "⚑ status", + async (controlCtx) => { + await sendStatusMessage( + message.chat.id, + message.message_id, + controlCtx, + ); + }, + ), + ctx, + ); } async function handleModelCommand( message: TelegramMessage, ctx: ExtensionContext, ): Promise { - await openModelMenu(message.chat.id, message.message_id, ctx); + enqueueTelegramControlItem( + createTelegramControlItem( + message.chat.id, + message.message_id, + "model", + "⚑ model", + async (controlCtx) => { + await openModelMenu(message.chat.id, message.message_id, controlCtx); + }, + ), + ctx, + ); } async function handleHelpCommand( @@ -3135,7 +1553,7 @@ export default function (pi: ExtensionAPI) { await sendTextReply(message.chat.id, message.message_id, helpText); if (config.allowedUserId === undefined && message.from) { config.allowedUserId = message.from.id; - await writeConfig(config); + await writeTelegramConfig(AGENT_DIR, CONFIG_PATH, config); updateStatus(ctx); } } @@ -3164,12 +1582,13 @@ export default function (pi: ExtensionAPI) { messages: TelegramMessage[], ctx: ExtensionContext, ): Promise { - const historyTurns = preserveQueuedTurnsAsHistory - ? queuedTelegramTurns.splice(0) - : []; + const historyResult = preserveQueuedTurnsAsHistory + ? partitionTelegramQueueItemsForHistory(queuedTelegramItems) + : { historyTurns: [], remainingItems: queuedTelegramItems }; + queuedTelegramItems = historyResult.remainingItems; preserveQueuedTurnsAsHistory = false; - const turn = await createTelegramTurn(messages, historyTurns); - queuedTelegramTurns.push(turn); + const turn = await createTelegramTurn(messages, historyResult.historyTurns); + queuedTelegramItems.push(turn); updateStatus(ctx); dispatchNextQueuedTelegramTurn(ctx); } @@ -3213,9 +1632,13 @@ export default function (pi: ExtensionAPI) { userId: number, ctx: ExtensionContext, ): Promise { - if (config.allowedUserId !== undefined) return false; - config.allowedUserId = userId; - await writeConfig(config); + const authorization = getTelegramAuthorizationState( + userId, + config.allowedUserId, + ); + if (authorization.kind !== "pair") return false; + config.allowedUserId = authorization.userId; + await writeTelegramConfig(AGENT_DIR, CONFIG_PATH, config); updateStatus(ctx); return true; } @@ -3224,62 +1647,35 @@ export default function (pi: ExtensionAPI) { update: TelegramUpdate, ctx: ExtensionContext, ): Promise { - const deletedMessageIds = extractDeletedTelegramMessageIds(update); - if (deletedMessageIds.length > 0) { - removePendingMediaGroupMessages(deletedMessageIds); - removeQueuedTelegramTurnsByMessageIds(deletedMessageIds, ctx); - return; - } - if (update.message_reaction) { - await handleAuthorizedTelegramReactionUpdate( - update.message_reaction, - ctx, - ); - return; - } - if (update.callback_query) { - const query = update.callback_query; - const message = query.message; - if (!message || message.chat.type !== "private" || query.from.is_bot) { - return; - } - await pairTelegramUserIfNeeded(query.from.id, ctx); - if (query.from.id !== config.allowedUserId) { - await answerCallbackQuery( - query.id, - "This bot is not authorized for your account.", + await executeTelegramUpdate(update, config.allowedUserId, { + ctx, + removePendingMediaGroupMessages, + removeQueuedTelegramTurnsByMessageIds, + handleAuthorizedTelegramReactionUpdate: async ( + reactionUpdate, + nextCtx, + ) => { + await handleAuthorizedTelegramReactionUpdate( + reactionUpdate as TelegramMessageReactionUpdated, + nextCtx, ); - return; - } - await handleAuthorizedTelegramCallbackQuery(query, ctx); - return; - } - const message = update.message || update.edited_message; - if ( - !message || - message.chat.type !== "private" || - !message.from || - message.from.is_bot - ) { - return; - } - const pairedNow = await pairTelegramUserIfNeeded(message.from.id, ctx); - if (pairedNow) { - await sendTextReply( - message.chat.id, - message.message_id, - "Telegram bridge paired with this account.", - ); - } - if (message.from.id !== config.allowedUserId) { - await sendTextReply( - message.chat.id, - message.message_id, - "This bot is not authorized for your account.", - ); - return; - } - await handleAuthorizedTelegramMessage(message, ctx); + }, + pairTelegramUserIfNeeded, + answerCallbackQuery, + handleAuthorizedTelegramCallbackQuery: async (query, nextCtx) => { + await handleAuthorizedTelegramCallbackQuery( + query as TelegramCallbackQuery, + nextCtx, + ); + }, + sendTextReply, + handleAuthorizedTelegramMessage: async (message, nextCtx) => { + await handleAuthorizedTelegramMessage( + message as TelegramMessage, + nextCtx, + ); + }, + }); } // --- Polling --- @@ -3296,74 +1692,47 @@ export default function (pi: ExtensionAPI) { ctx: ExtensionContext, signal: AbortSignal, ): Promise { - if (!config.botToken) return; - - try { - await callTelegram( - "deleteWebhook", - { drop_pending_updates: false }, - { signal }, - ); - } catch { - // ignore - } - - if (config.lastUpdateId === undefined) { - try { - const updates = await callTelegram( - "getUpdates", - { offset: -1, limit: 1, timeout: 0 }, - { signal }, + await runTelegramPollLoop({ + ctx, + signal, + config, + deleteWebhook: async (pollSignal) => { + await callTelegramApi( + "deleteWebhook", + { drop_pending_updates: false }, + { signal: pollSignal }, ); - const last = updates.at(-1); - if (last) { - config.lastUpdateId = last.update_id; - await writeConfig(config); - } - } catch { - // ignore - } - } - - while (!signal.aborted) { - try { - const updates = await callTelegram( - "getUpdates", - { - offset: - config.lastUpdateId !== undefined - ? config.lastUpdateId + 1 - : undefined, - limit: 10, - timeout: 30, - allowed_updates: [ - "message", - "edited_message", - "callback_query", - "message_reaction", - ], - }, - { signal }, - ); - for (const update of updates) { - config.lastUpdateId = update.update_id; - await writeConfig(config); - await handleUpdate(update, ctx); - } - } catch (error) { - if (signal.aborted) return; - if (error instanceof DOMException && error.name === "AbortError") - return; - const message = error instanceof Error ? error.message : String(error); + }, + getUpdates: async (body, pollSignal) => { + return callTelegramApi("getUpdates", body, { + signal: pollSignal, + }); + }, + persistConfig: async () => { + await writeTelegramConfig(AGENT_DIR, CONFIG_PATH, config); + }, + handleUpdate: async (update, loopCtx) => { + await handleUpdate(update, loopCtx); + }, + onErrorStatus: (message) => { updateStatus(ctx, message); - await new Promise((resolve) => setTimeout(resolve, 3000)); + }, + onStatusReset: () => { updateStatus(ctx); - } - } + }, + sleep: (ms) => new Promise((resolve) => setTimeout(resolve, ms)), + }); } async function startPolling(ctx: ExtensionContext): Promise { - if (!config.botToken || pollingPromise) return; + if ( + !shouldStartTelegramPolling({ + hasBotToken: !!config.botToken, + hasPollingPromise: !!pollingPromise, + }) + ) { + return; + } pollingController = new AbortController(); pollingPromise = pollLoop(ctx, pollingController.signal).finally(() => { pollingPromise = undefined; @@ -3375,264 +1744,225 @@ export default function (pi: ExtensionAPI) { // --- Extension Registration --- - pi.registerTool({ - name: "telegram_attach", - label: "Telegram Attach", - description: - "Queue one or more local files to be sent with the next Telegram reply.", - promptSnippet: "Queue local files to be sent with the next Telegram reply.", - promptGuidelines: [ - "When handling a [telegram] message and the user asked for a file or generated artifact, call telegram_attach with the local path instead of only mentioning the path in text.", - ], - parameters: Type.Object({ - paths: Type.Array( - Type.String({ description: "Local file path to attach" }), - { minItems: 1, maxItems: MAX_ATTACHMENTS_PER_TURN }, - ), - }), - async execute(_toolCallId, params) { - if (!activeTelegramTurn) { - throw new Error( - "telegram_attach can only be used while replying to an active Telegram turn", - ); - } - const added: string[] = []; - for (const inputPath of params.paths) { - const stats = await stat(inputPath); - if (!stats.isFile()) { - throw new Error(`Not a file: ${inputPath}`); - } - if ( - activeTelegramTurn.queuedAttachments.length >= - MAX_ATTACHMENTS_PER_TURN - ) { - throw new Error( - `Attachment limit reached (${MAX_ATTACHMENTS_PER_TURN})`, - ); - } - activeTelegramTurn.queuedAttachments.push({ - path: inputPath, - fileName: basename(inputPath), - }); - added.push(inputPath); - } - return { - content: [ - { - type: "text", - text: `Queued ${added.length} Telegram attachment(s).`, - }, - ], - details: { paths: added }, - }; - }, + registerTelegramAttachmentTool(pi, { + maxAttachmentsPerTurn: MAX_ATTACHMENTS_PER_TURN, + getActiveTurn: () => activeTelegramTurn, + statPath: stat, }); - pi.registerCommand("telegram-setup", { - description: "Configure Telegram bot token", - handler: async (_args, ctx) => { - await promptForConfig(ctx); - }, - }); - - pi.registerCommand("telegram-status", { - description: "Show Telegram bridge status", - handler: async (_args, ctx) => { - const status = [ - `bot: ${ - config.botUsername ? `@${config.botUsername}` : "not configured" - }`, + registerTelegramCommands(pi, { + promptForConfig, + getStatusLines: () => { + return [ + `bot: ${config.botUsername ? `@${config.botUsername}` : "not configured"}`, `allowed user: ${config.allowedUserId ?? "not paired"}`, `polling: ${pollingPromise ? "running" : "stopped"}`, `active telegram turn: ${activeTelegramTurn ? "yes" : "no"}`, - `queued telegram turns: ${queuedTelegramTurns.length}`, + `queued telegram turns: ${queuedTelegramItems.length}`, ]; - ctx.ui.notify(status.join(" | "), "info"); }, - }); - - pi.registerCommand("telegram-connect", { - description: "Start the Telegram bridge in this pi session", - handler: async (_args, ctx) => { - config = await readConfig(); - if (!config.botToken) { - await promptForConfig(ctx); - return; - } - await startPolling(ctx); - updateStatus(ctx); - }, - }); - - pi.registerCommand("telegram-disconnect", { - description: "Stop the Telegram bridge in this pi session", - handler: async (_args, ctx) => { - await stopPolling(); - updateStatus(ctx); + reloadConfig: async () => { + config = await readTelegramConfig(CONFIG_PATH); }, + hasBotToken: () => !!config.botToken, + startPolling, + stopPolling, + updateStatus, }); // --- Lifecycle Hooks --- - pi.on("session_start", async (_event, ctx) => { - config = await readConfig(); - currentTelegramModel = ctx.model; - activeTelegramToolExecutions = 0; - pendingTelegramModelSwitch = undefined; - nextSyntheticTelegramTurnOrder = -1; - telegramTurnDispatchPending = false; - compactionInProgress = false; - await mkdir(TEMP_DIR, { recursive: true }); - updateStatus(ctx); - }); - - pi.on("session_shutdown", async (_event, _ctx) => { - queuedTelegramTurns = []; - nextQueuedTelegramTurnOrder = 0; - nextSyntheticTelegramTurnOrder = -1; - nextPriorityReactionOrder = 0; - currentTelegramModel = undefined; - activeTelegramToolExecutions = 0; - pendingTelegramModelSwitch = undefined; - telegramTurnDispatchPending = false; - compactionInProgress = false; - for (const state of mediaGroups.values()) { - if (state.flushTimer) clearTimeout(state.flushTimer); - } - mediaGroups.clear(); - modelMenus.clear(); - if (activeTelegramTurn) { - await clearPreview(activeTelegramTurn.chatId); - } - activeTelegramTurn = undefined; - currentAbort = undefined; - preserveQueuedTurnsAsHistory = false; - await stopPolling(); - }); - - pi.on("before_agent_start", async (event) => { - const suffix = isTelegramPrompt(event.prompt) - ? `${SYSTEM_PROMPT_SUFFIX}\n- The current user message came from Telegram.` - : SYSTEM_PROMPT_SUFFIX; - return { - systemPrompt: event.systemPrompt + suffix, - }; - }); - - pi.on("model_select", async (event) => { - currentTelegramModel = event.model; - }); - - pi.on("agent_start", async (_event, ctx) => { - currentAbort = () => ctx.abort(); - activeTelegramToolExecutions = 0; - pendingTelegramModelSwitch = undefined; - if (!activeTelegramTurn && telegramTurnDispatchPending) { - const nextTurn = queuedTelegramTurns.shift(); - telegramTurnDispatchPending = false; - if (nextTurn) { - activeTelegramTurn = { ...nextTurn }; + registerTelegramLifecycleHooks(pi, { + onSessionStart: async (_event, ctx) => { + config = await readTelegramConfig(CONFIG_PATH); + const sessionStartState = buildTelegramSessionStartState(ctx.model); + currentTelegramModel = sessionStartState.currentTelegramModel; + activeTelegramToolExecutions = + sessionStartState.activeTelegramToolExecutions; + pendingTelegramModelSwitch = sessionStartState.pendingTelegramModelSwitch; + nextQueuedTelegramItemOrder = + sessionStartState.nextQueuedTelegramItemOrder; + nextQueuedTelegramControlOrder = + sessionStartState.nextQueuedTelegramControlOrder; + telegramTurnDispatchPending = + sessionStartState.telegramTurnDispatchPending; + compactionInProgress = sessionStartState.compactionInProgress; + await mkdir(TEMP_DIR, { recursive: true }); + updateStatus(ctx); + }, + onSessionShutdown: async (_event, _ctx) => { + const shutdownState = + buildTelegramSessionShutdownState(); + queuedTelegramItems = shutdownState.queuedTelegramItems; + nextQueuedTelegramItemOrder = shutdownState.nextQueuedTelegramItemOrder; + nextQueuedTelegramControlOrder = + shutdownState.nextQueuedTelegramControlOrder; + nextPriorityReactionOrder = shutdownState.nextPriorityReactionOrder; + currentTelegramModel = shutdownState.currentTelegramModel; + activeTelegramToolExecutions = shutdownState.activeTelegramToolExecutions; + pendingTelegramModelSwitch = shutdownState.pendingTelegramModelSwitch; + telegramTurnDispatchPending = shutdownState.telegramTurnDispatchPending; + compactionInProgress = shutdownState.compactionInProgress; + for (const state of mediaGroups.values()) { + if (state.flushTimer) clearTimeout(state.flushTimer); + } + mediaGroups.clear(); + modelMenus.clear(); + if (activeTelegramTurn) { + await clearPreview(activeTelegramTurn.chatId); + } + activeTelegramTurn = undefined; + currentAbort = undefined; + preserveQueuedTurnsAsHistory = false; + await stopPolling(); + }, + onBeforeAgentStart: (event) => { + const nextEvent = event as { prompt: string; systemPrompt: string }; + const suffix = isTelegramPrompt(nextEvent.prompt) + ? `${SYSTEM_PROMPT_SUFFIX}\n- The current user message came from Telegram.` + : SYSTEM_PROMPT_SUFFIX; + return { + systemPrompt: nextEvent.systemPrompt + suffix, + }; + }, + onModelSelect: (event) => { + currentTelegramModel = (event as { model: Model }).model; + }, + onAgentStart: async (_event, ctx) => { + currentAbort = () => ctx.abort(); + const startPlan = buildTelegramAgentStartPlan({ + queuedItems: queuedTelegramItems, + hasPendingDispatch: telegramTurnDispatchPending, + hasActiveTurn: !!activeTelegramTurn, + }); + if (startPlan.shouldResetToolExecutions) { + activeTelegramToolExecutions = 0; + } + if (startPlan.shouldResetPendingModelSwitch) { + pendingTelegramModelSwitch = undefined; + } + queuedTelegramItems = startPlan.remainingItems; + if (startPlan.shouldClearDispatchPending) { + telegramTurnDispatchPending = false; + } + if (startPlan.activeTurn) { + activeTelegramTurn = { ...startPlan.activeTurn }; previewState = createPreviewState(); startTypingLoop(ctx); } - } - updateStatus(ctx); - }); - - pi.on("tool_execution_start", async (_event, _ctx) => { - if (!activeTelegramTurn) return; - activeTelegramToolExecutions += 1; - }); - - pi.on("tool_execution_end", async (_event, ctx) => { - if (!activeTelegramTurn) return; - activeTelegramToolExecutions = Math.max( - 0, - activeTelegramToolExecutions - 1, - ); - triggerPendingTelegramModelSwitchAbort(ctx); - }); - - pi.on("message_start", async (event, _ctx) => { - if (!activeTelegramTurn || !isAssistantMessage(event.message)) return; - if ( - previewState && - (previewState.pendingText.trim().length > 0 || - previewState.lastSentText.trim().length > 0) - ) { - const previousText = previewState.pendingText.trim(); - if (previousText.length > 0) { - await finalizeMarkdownPreview(activeTelegramTurn.chatId, previousText); - } else { - await finalizePreview(activeTelegramTurn.chatId); + updateStatus(ctx); + }, + onToolExecutionStart: () => { + activeTelegramToolExecutions = getNextTelegramToolExecutionCount({ + hasActiveTurn: !!activeTelegramTurn, + currentCount: activeTelegramToolExecutions, + event: "start", + }); + }, + onToolExecutionEnd: (_event, ctx) => { + activeTelegramToolExecutions = getNextTelegramToolExecutionCount({ + hasActiveTurn: !!activeTelegramTurn, + currentCount: activeTelegramToolExecutions, + event: "end", + }); + if (!activeTelegramTurn) return; + triggerPendingTelegramModelSwitchAbort(ctx); + }, + onMessageStart: async (event, _ctx) => { + const nextEvent = event as { message: AgentMessage }; + if (!activeTelegramTurn || !isAssistantMessage(nextEvent.message)) return; + if ( + previewState && + (previewState.pendingText.trim().length > 0 || + previewState.lastSentText.trim().length > 0) + ) { + const previousText = previewState.pendingText.trim(); + if (previousText.length > 0) { + await finalizeMarkdownPreview( + activeTelegramTurn.chatId, + previousText, + ); + } else { + await finalizePreview(activeTelegramTurn.chatId); + } } - } - previewState = createPreviewState(); - }); - - pi.on("message_update", async (event, _ctx) => { - if (!activeTelegramTurn || !isAssistantMessage(event.message)) return; - if (!previewState) { previewState = createPreviewState(); - } - previewState.pendingText = getMessageText(event.message); - schedulePreviewFlush(activeTelegramTurn.chatId); - }); - - pi.on("agent_end", async (event, ctx) => { - const turn = activeTelegramTurn; - currentAbort = undefined; - stopTypingLoop(); - activeTelegramTurn = undefined; - activeTelegramToolExecutions = 0; - pendingTelegramModelSwitch = undefined; - telegramTurnDispatchPending = false; - updateStatus(ctx); - if (!turn) { - dispatchNextQueuedTelegramTurn(ctx); - return; - } - const assistant = extractAssistantText(event.messages); - if (assistant.stopReason === "aborted") { - await clearPreview(turn.chatId); - if (!preserveQueuedTurnsAsHistory) { - dispatchNextQueuedTelegramTurn(ctx); + }, + onMessageUpdate: async (event, _ctx) => { + const nextEvent = event as { message: AgentMessage }; + if (!activeTelegramTurn || !isAssistantMessage(nextEvent.message)) return; + if (!previewState) { + previewState = createPreviewState(); } - return; - } - if (assistant.stopReason === "error") { - await clearPreview(turn.chatId); - await sendTextReply( - turn.chatId, - turn.replyToMessageId, - assistant.errorMessage || - "Telegram bridge: pi failed while processing the request.", - ); - return; - } - const finalText = assistant.text; - if (previewState) { - previewState.pendingText = finalText ?? previewState.pendingText; - } - if (finalText) { - const finalized = await finalizeMarkdownPreview(turn.chatId, finalText); - if (!finalized) { + previewState.pendingText = getMessageText(nextEvent.message); + schedulePreviewFlush(activeTelegramTurn.chatId); + }, + onAgentEnd: async (event, ctx) => { + const turn = activeTelegramTurn; + currentAbort = undefined; + stopTypingLoop(); + activeTelegramTurn = undefined; + activeTelegramToolExecutions = 0; + pendingTelegramModelSwitch = undefined; + telegramTurnDispatchPending = false; + updateStatus(ctx); + const assistant = turn + ? extractAssistantText((event as { messages: AgentMessage[] }).messages) + : {}; + const finalText = assistant.text; + const endPlan = buildTelegramAgentEndPlan({ + hasTurn: !!turn, + stopReason: assistant.stopReason, + hasFinalText: !!finalText, + hasQueuedAttachments: (turn?.queuedAttachments.length ?? 0) > 0, + preserveQueuedTurnsAsHistory, + }); + if (!turn) { + if (endPlan.shouldDispatchNext) { + dispatchNextQueuedTelegramTurn(ctx); + } + return; + } + if (endPlan.shouldClearPreview) { await clearPreview(turn.chatId); - await sendMarkdownReply(turn.chatId, turn.replyToMessageId, finalText); } - } else { - await clearPreview(turn.chatId); - if (turn.queuedAttachments.length > 0) { + if (endPlan.shouldSendErrorMessage) { + await sendTextReply( + turn.chatId, + turn.replyToMessageId, + assistant.errorMessage || + "Telegram bridge: pi failed while processing the request.", + ); + if (endPlan.shouldDispatchNext) { + dispatchNextQueuedTelegramTurn(ctx); + } + return; + } + if (previewState) { + previewState.pendingText = finalText ?? previewState.pendingText; + } + if (endPlan.kind === "text" && finalText) { + const finalized = await finalizeMarkdownPreview(turn.chatId, finalText); + if (!finalized) { + await clearPreview(turn.chatId); + await sendMarkdownReply( + turn.chatId, + turn.replyToMessageId, + finalText, + ); + } + } + if (endPlan.shouldSendAttachmentNotice) { await sendTextReply( turn.chatId, turn.replyToMessageId, "Attached requested file(s).", ); } - } - await sendQueuedAttachments(turn); - if (!preserveQueuedTurnsAsHistory) { - dispatchNextQueuedTelegramTurn(ctx); - } + await sendQueuedAttachments(turn); + if (endPlan.shouldDispatchNext) { + dispatchNextQueuedTelegramTurn(ctx); + } + }, }); } diff --git a/lib/api.ts b/lib/api.ts new file mode 100644 index 0000000..59c7d42 --- /dev/null +++ b/lib/api.ts @@ -0,0 +1,222 @@ +/** + * Telegram API and config persistence helpers + * Wraps bot API calls, file downloads, and local config reads and writes for the bridge runtime + */ + +import { mkdir, readFile, writeFile } from "node:fs/promises"; +import { join } from "node:path"; + +export interface TelegramConfig { + botToken?: string; + botUsername?: string; + botId?: number; + allowedUserId?: number; + lastUpdateId?: number; +} + +interface TelegramApiResponse { + ok: boolean; + result?: T; + description?: string; + error_code?: number; +} + +interface TelegramGetFileResult { + file_path: string; +} + +export interface TelegramApiClient { + call: ( + method: string, + body: Record, + options?: { signal?: AbortSignal }, + ) => Promise; + callMultipart: ( + method: string, + fields: Record, + fileField: string, + filePath: string, + fileName: string, + options?: { signal?: AbortSignal }, + ) => Promise; + downloadFile: ( + fileId: string, + suggestedName: string, + tempDir: string, + ) => Promise; + answerCallbackQuery: ( + callbackQueryId: string, + text?: string, + ) => Promise; +} + +function sanitizeFileName(name: string): string { + return name.replace(/[^a-zA-Z0-9._-]+/g, "_"); +} + +export async function readTelegramConfig( + configPath: string, +): Promise { + try { + const content = await readFile(configPath, "utf8"); + return JSON.parse(content) as TelegramConfig; + } catch { + return {}; + } +} + +export async function writeTelegramConfig( + agentDir: string, + configPath: string, + config: TelegramConfig, +): Promise { + await mkdir(agentDir, { recursive: true }); + await writeFile( + configPath, + JSON.stringify(config, null, "\t") + "\n", + "utf8", + ); +} + +export async function callTelegram( + botToken: string | undefined, + method: string, + body: Record, + options?: { signal?: AbortSignal }, +): Promise { + if (!botToken) { + throw new Error("Telegram bot token is not configured"); + } + const response = await fetch( + `https://api.telegram.org/bot${botToken}/${method}`, + { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(body), + signal: options?.signal, + }, + ); + const data = (await response.json()) as TelegramApiResponse; + if (!data.ok || data.result === undefined) { + throw new Error(data.description || `Telegram API ${method} failed`); + } + return data.result; +} + +export async function callTelegramMultipart( + botToken: string | undefined, + method: string, + fields: Record, + fileField: string, + filePath: string, + fileName: string, + options?: { signal?: AbortSignal }, +): Promise { + if (!botToken) { + throw new Error("Telegram bot token is not configured"); + } + const form = new FormData(); + for (const [key, value] of Object.entries(fields)) { + form.set(key, value); + } + const buffer = await readFile(filePath); + form.set(fileField, new Blob([buffer]), fileName); + const response = await fetch( + `https://api.telegram.org/bot${botToken}/${method}`, + { + method: "POST", + body: form, + signal: options?.signal, + }, + ); + const data = (await response.json()) as TelegramApiResponse; + if (!data.ok || data.result === undefined) { + throw new Error(data.description || `Telegram API ${method} failed`); + } + return data.result; +} + +export async function downloadTelegramFile( + botToken: string | undefined, + fileId: string, + suggestedName: string, + tempDir: string, +): Promise { + if (!botToken) { + throw new Error("Telegram bot token is not configured"); + } + const file = await callTelegram(botToken, "getFile", { + file_id: fileId, + }); + await mkdir(tempDir, { recursive: true }); + const targetPath = join( + tempDir, + `${Date.now()}-${sanitizeFileName(suggestedName)}`, + ); + const response = await fetch( + `https://api.telegram.org/file/bot${botToken}/${file.file_path}`, + ); + if (!response.ok) { + throw new Error(`Failed to download Telegram file: ${response.status}`); + } + const arrayBuffer = await response.arrayBuffer(); + await writeFile(targetPath, Buffer.from(arrayBuffer)); + return targetPath; +} + +export async function answerTelegramCallbackQuery( + botToken: string | undefined, + callbackQueryId: string, + text?: string, +): Promise { + try { + await callTelegram( + botToken, + "answerCallbackQuery", + text + ? { callback_query_id: callbackQueryId, text } + : { callback_query_id: callbackQueryId }, + ); + } catch { + // ignore + } +} + +export function createTelegramApiClient( + getBotToken: () => string | undefined, +): TelegramApiClient { + return { + call: async (method, body, options) => { + return callTelegram(getBotToken(), method, body, options); + }, + callMultipart: async ( + method, + fields, + fileField, + filePath, + fileName, + options, + ) => { + return callTelegramMultipart( + getBotToken(), + method, + fields, + fileField, + filePath, + fileName, + options, + ); + }, + downloadFile: async (fileId, suggestedName, tempDir) => { + return downloadTelegramFile( + getBotToken(), + fileId, + suggestedName, + tempDir, + ); + }, + answerCallbackQuery: async (callbackQueryId, text) => { + await answerTelegramCallbackQuery(getBotToken(), callbackQueryId, text); + }, + }; +} diff --git a/lib/attachments.ts b/lib/attachments.ts new file mode 100644 index 0000000..bd546e1 --- /dev/null +++ b/lib/attachments.ts @@ -0,0 +1,98 @@ +/** + * Telegram attachment domain helpers + * Owns attachment queueing and attachment delivery so Telegram file output stays in one domain module + */ + +import { basename } from "node:path"; + +import { guessMediaType } from "./media.ts"; +import type { PendingTelegramTurn } from "./queue.ts"; + +export interface TelegramAttachmentToolResult { + content: Array<{ type: "text"; text: string }>; + details: { paths: string[] }; +} + +export interface TelegramQueuedAttachmentDeliveryDeps { + sendMultipart: ( + method: string, + fields: Record, + fileField: string, + filePath: string, + fileName: string, + ) => Promise; + sendTextReply: ( + chatId: number, + replyToMessageId: number, + text: string, + ) => Promise; +} + +export async function queueTelegramAttachments(options: { + activeTurn: PendingTelegramTurn | undefined; + paths: string[]; + maxAttachmentsPerTurn: number; + statPath: (path: string) => Promise<{ isFile(): boolean }>; +}): Promise { + if (!options.activeTurn) { + throw new Error( + "telegram_attach can only be used while replying to an active Telegram turn", + ); + } + const added: string[] = []; + for (const inputPath of options.paths) { + const stats = await options.statPath(inputPath); + if (!stats.isFile()) { + throw new Error(`Not a file: ${inputPath}`); + } + if ( + options.activeTurn.queuedAttachments.length >= + options.maxAttachmentsPerTurn + ) { + throw new Error( + `Attachment limit reached (${options.maxAttachmentsPerTurn})`, + ); + } + options.activeTurn.queuedAttachments.push({ + path: inputPath, + fileName: basename(inputPath), + }); + added.push(inputPath); + } + return { + content: [ + { + type: "text", + text: `Queued ${added.length} Telegram attachment(s).`, + }, + ], + details: { paths: added }, + }; +} + +export async function sendQueuedTelegramAttachments( + turn: PendingTelegramTurn, + deps: TelegramQueuedAttachmentDeliveryDeps, +): Promise { + for (const attachment of turn.queuedAttachments) { + try { + const mediaType = guessMediaType(attachment.path); + const method = mediaType ? "sendPhoto" : "sendDocument"; + const fieldName = mediaType ? "photo" : "document"; + await deps.sendMultipart( + method, + { chat_id: String(turn.chatId) }, + fieldName, + attachment.path, + attachment.fileName, + ); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + await deps.sendTextReply( + turn.chatId, + turn.replyToMessageId, + `Failed to send attachment ${attachment.fileName}: ${message}`, + ); + } + } +} diff --git a/lib/media.ts b/lib/media.ts new file mode 100644 index 0000000..20e64b4 --- /dev/null +++ b/lib/media.ts @@ -0,0 +1,234 @@ +/** + * Telegram media and text extraction helpers + * Normalizes inbound Telegram messages into reusable file, text, id, and history metadata + */ + +export interface TelegramPhotoSizeLike { + file_id: string; + file_size?: number; +} + +export interface TelegramDocumentLike { + file_id: string; + file_name?: string; + mime_type?: string; +} + +export interface TelegramVideoLike { + file_id: string; + file_name?: string; + mime_type?: string; +} + +export interface TelegramAudioLike { + file_id: string; + file_name?: string; + mime_type?: string; +} + +export interface TelegramVoiceLike { + file_id: string; + mime_type?: string; +} + +export interface TelegramAnimationLike { + file_id: string; + file_name?: string; + mime_type?: string; +} + +export interface TelegramStickerLike { + file_id: string; +} + +export interface TelegramMessageLike { + message_id: number; + text?: string; + caption?: string; + photo?: TelegramPhotoSizeLike[]; + document?: TelegramDocumentLike; + video?: TelegramVideoLike; + audio?: TelegramAudioLike; + voice?: TelegramVoiceLike; + animation?: TelegramAnimationLike; + sticker?: TelegramStickerLike; +} + +export interface TelegramFileInfo { + file_id: string; + fileName: string; + mimeType?: string; + isImage: boolean; +} + +export interface DownloadedTelegramFileLike { + path: string; +} + +export function guessExtensionFromMime( + mimeType: string | undefined, + fallback: string, +): string { + if (!mimeType) return fallback; + const normalized = mimeType.toLowerCase(); + if (normalized === "image/jpeg") return ".jpg"; + if (normalized === "image/png") return ".png"; + if (normalized === "image/webp") return ".webp"; + if (normalized === "image/gif") return ".gif"; + if (normalized === "audio/ogg") return ".ogg"; + if (normalized === "audio/mpeg") return ".mp3"; + if (normalized === "audio/wav") return ".wav"; + if (normalized === "video/mp4") return ".mp4"; + if (normalized === "application/pdf") return ".pdf"; + return fallback; +} + +export function guessMediaType(path: string): string | undefined { + const normalized = path.toLowerCase(); + if (normalized.endsWith(".jpg") || normalized.endsWith(".jpeg")) { + return "image/jpeg"; + } + if (normalized.endsWith(".png")) return "image/png"; + if (normalized.endsWith(".webp")) return "image/webp"; + if (normalized.endsWith(".gif")) return "image/gif"; + return undefined; +} + +export function isImageMimeType(mimeType: string | undefined): boolean { + return mimeType?.toLowerCase().startsWith("image/") ?? false; +} + +export function extractTelegramMessageText( + message: TelegramMessageLike, +): string { + return (message.text || message.caption || "").trim(); +} + +export function extractTelegramMessagesText( + messages: TelegramMessageLike[], +): string { + return messages.map(extractTelegramMessageText).filter(Boolean).join("\n\n"); +} + +export function extractFirstTelegramMessageText( + messages: TelegramMessageLike[], +): string { + return messages.map(extractTelegramMessageText).find(Boolean) ?? ""; +} + +export function collectTelegramMessageIds( + messages: TelegramMessageLike[], +): number[] { + return [...new Set(messages.map((message) => message.message_id))]; +} + +export function formatTelegramHistoryText( + rawText: string, + files: DownloadedTelegramFileLike[], +): string { + let summary = rawText.length > 0 ? rawText : "(no text)"; + if (files.length > 0) { + summary += `\nAttachments:`; + for (const file of files) { + summary += `\n- ${file.path}`; + } + } + return summary; +} + +export function collectTelegramFileInfos( + messages: TelegramMessageLike[], +): TelegramFileInfo[] { + const files: TelegramFileInfo[] = []; + for (const message of messages) { + if (Array.isArray(message.photo) && message.photo.length > 0) { + const photo = [...message.photo] + .sort((a, b) => (a.file_size ?? 0) - (b.file_size ?? 0)) + .pop(); + if (photo) { + files.push({ + file_id: photo.file_id, + fileName: `photo-${message.message_id}.jpg`, + mimeType: "image/jpeg", + isImage: true, + }); + } + } + if (message.document) { + const fileName = + message.document.file_name || + `document-${message.message_id}${guessExtensionFromMime( + message.document.mime_type, + "", + )}`; + files.push({ + file_id: message.document.file_id, + fileName, + mimeType: message.document.mime_type, + isImage: isImageMimeType(message.document.mime_type), + }); + } + if (message.video) { + const fileName = + message.video.file_name || + `video-${message.message_id}${guessExtensionFromMime( + message.video.mime_type, + ".mp4", + )}`; + files.push({ + file_id: message.video.file_id, + fileName, + mimeType: message.video.mime_type, + isImage: false, + }); + } + if (message.audio) { + const fileName = + message.audio.file_name || + `audio-${message.message_id}${guessExtensionFromMime( + message.audio.mime_type, + ".mp3", + )}`; + files.push({ + file_id: message.audio.file_id, + fileName, + mimeType: message.audio.mime_type, + isImage: false, + }); + } + if (message.voice) { + files.push({ + file_id: message.voice.file_id, + fileName: `voice-${message.message_id}${guessExtensionFromMime( + message.voice.mime_type, + ".ogg", + )}`, + mimeType: message.voice.mime_type, + isImage: false, + }); + } + if (message.animation) { + const fileName = + message.animation.file_name || + `animation-${message.message_id}${guessExtensionFromMime( + message.animation.mime_type, + ".mp4", + )}`; + files.push({ + file_id: message.animation.file_id, + fileName, + mimeType: message.animation.mime_type, + isImage: false, + }); + } + if (message.sticker) { + files.push({ + file_id: message.sticker.file_id, + fileName: `sticker-${message.message_id}.webp`, + mimeType: "image/webp", + isImage: true, + }); + } + } + return files; +} diff --git a/lib/menu.ts b/lib/menu.ts new file mode 100644 index 0000000..7d17497 --- /dev/null +++ b/lib/menu.ts @@ -0,0 +1,951 @@ +/** + * Telegram menu and inline-keyboard rendering helpers + * Owns model resolution, menu state, and inline UI text and reply-markup generation for status, model, and thinking controls + */ + +import type { Model } from "@mariozechner/pi-ai"; + +export type ThinkingLevel = + | "off" + | "minimal" + | "low" + | "medium" + | "high" + | "xhigh"; +export type TelegramModelScope = "all" | "scoped"; + +export interface ScopedTelegramModel { + model: Model; + thinkingLevel?: ThinkingLevel; +} + +export interface TelegramModelMenuState { + chatId: number; + messageId: number; + page: number; + scope: TelegramModelScope; + scopedModels: ScopedTelegramModel[]; + allModels: ScopedTelegramModel[]; + note?: string; + mode: "status" | "model" | "thinking"; +} + +export type TelegramReplyMarkup = { + inline_keyboard: Array>; +}; + +export interface TelegramMenuMessageRuntimeDeps { + editInteractiveMessage: ( + chatId: number, + messageId: number, + text: string, + mode: "html" | "plain", + replyMarkup: TelegramReplyMarkup, + ) => Promise; + sendInteractiveMessage: ( + chatId: number, + text: string, + mode: "html" | "plain", + replyMarkup: TelegramReplyMarkup, + ) => Promise; +} + +export interface TelegramMenuEffectPort { + answerCallbackQuery: ( + callbackQueryId: string, + text?: string, + ) => Promise; + updateModelMenuMessage: () => Promise; + updateThinkingMenuMessage: () => Promise; + updateStatusMessage: () => Promise; + setModel: (model: Model) => Promise; + setCurrentModel: (model: Model) => void; + setThinkingLevel: (level: ThinkingLevel) => void; + getCurrentThinkingLevel: () => ThinkingLevel; + stagePendingModelSwitch: (selection: ScopedTelegramModel) => void; + restartInterruptedTelegramTurn: ( + selection: ScopedTelegramModel, + ) => Promise | boolean; +} + +export type TelegramStatusMenuCallbackDeps = Pick< + TelegramMenuEffectPort, + "updateModelMenuMessage" | "updateThinkingMenuMessage" | "answerCallbackQuery" +>; + +export type TelegramThinkingMenuCallbackDeps = Pick< + TelegramMenuEffectPort, + "setThinkingLevel" | "getCurrentThinkingLevel" | "updateStatusMessage" | "answerCallbackQuery" +>; + +export type TelegramModelMenuCallbackDeps = Pick< + TelegramMenuEffectPort, + | "updateModelMenuMessage" + | "updateStatusMessage" + | "answerCallbackQuery" + | "setModel" + | "setCurrentModel" + | "setThinkingLevel" + | "stagePendingModelSwitch" + | "restartInterruptedTelegramTurn" +>; + +export interface TelegramMenuCallbackEntryDeps { + handleStatusAction: () => Promise; + handleThinkingAction: () => Promise; + handleModelAction: () => Promise; + answerCallbackQuery: ( + callbackQueryId: string, + text?: string, + ) => Promise; +} + +export const THINKING_LEVELS: readonly ThinkingLevel[] = [ + "off", + "minimal", + "low", + "medium", + "high", + "xhigh", +]; +export const TELEGRAM_MODEL_PAGE_SIZE = 6; +export const MODEL_MENU_TITLE = "Choose a model:"; + +export interface BuildTelegramModelMenuStateParams { + chatId: number; + activeModel: Model | undefined; + availableModels: Model[]; + configuredScopedModelPatterns: string[]; + cliScopedModelPatterns?: string[]; +} + +export type TelegramMenuCallbackAction = + | { kind: "ignore" } + | { kind: "status"; action: "model" | "thinking" } + | { kind: "thinking:set"; level: string } + | { + kind: "model"; + action: "noop" | "scope" | "page" | "pick"; + value?: string; + }; + +export type TelegramMenuMutationResult = "invalid" | "unchanged" | "changed"; +export type TelegramMenuSelectionResult = + | { kind: "invalid" } + | { kind: "missing" } + | { kind: "selected"; selection: ScopedTelegramModel }; + +export interface TelegramModelMenuPage { + page: number; + pageCount: number; + start: number; + items: ScopedTelegramModel[]; +} + +export interface TelegramMenuRenderPayload { + nextMode: TelegramModelMenuState["mode"]; + text: string; + mode: "html" | "plain"; + replyMarkup: TelegramReplyMarkup; +} + +export type TelegramModelCallbackPlan = + | { kind: "ignore" } + | { kind: "answer"; text?: string } + | { kind: "update-menu"; text?: string } + | { + kind: "refresh-status"; + selection: ScopedTelegramModel; + callbackText: string; + shouldApplyThinkingLevel: boolean; + } + | { + kind: "switch-model"; + selection: ScopedTelegramModel; + mode: "idle" | "restart-now" | "restart-after-tool"; + callbackText: string; + }; + +export interface BuildTelegramModelCallbackPlanParams { + data: string | undefined; + state: TelegramModelMenuState; + activeModel: Model | undefined; + currentThinkingLevel: ThinkingLevel; + isIdle: boolean; + canRestartBusyRun: boolean; + hasActiveToolExecutions: boolean; +} + +export function modelsMatch( + a: Pick, "provider" | "id"> | undefined, + b: Pick, "provider" | "id"> | undefined, +): boolean { + return !!a && !!b && a.provider === b.provider && a.id === b.id; +} + +export function getCanonicalModelId( + model: Pick, "provider" | "id">, +): string { + return `${model.provider}/${model.id}`; +} + +export function isThinkingLevel(value: string): value is ThinkingLevel { + return THINKING_LEVELS.includes(value as ThinkingLevel); +} + +function escapeRegex(text: string): string { + return text.replace(/[|\\{}()[\]^$+?.]/g, "\\$&"); +} + +function globMatches(text: string, pattern: string): boolean { + let regex = "^"; + for (let i = 0; i < pattern.length; i++) { + const char = pattern[i]; + if (char === "*") { + regex += ".*"; + continue; + } + if (char === "?") { + regex += "."; + continue; + } + if (char === "[") { + const end = pattern.indexOf("]", i + 1); + if (end !== -1) { + const content = pattern.slice(i + 1, end); + regex += content.startsWith("!") + ? `[^${content.slice(1)}]` + : `[${content}]`; + i = end; + continue; + } + } + regex += escapeRegex(char); + } + regex += "$"; + return new RegExp(regex, "i").test(text); +} + +function isAliasModelId(id: string): boolean { + if (id.endsWith("-latest")) return true; + return !/-\d{8}$/.test(id); +} + +function findExactModelReferenceMatch( + modelReference: string, + availableModels: Model[], +): Model | undefined { + const trimmedReference = modelReference.trim(); + if (!trimmedReference) return undefined; + const normalizedReference = trimmedReference.toLowerCase(); + const canonicalMatches = availableModels.filter( + (model) => getCanonicalModelId(model).toLowerCase() === normalizedReference, + ); + if (canonicalMatches.length === 1) return canonicalMatches[0]; + if (canonicalMatches.length > 1) return undefined; + const slashIndex = trimmedReference.indexOf("/"); + if (slashIndex !== -1) { + const provider = trimmedReference.substring(0, slashIndex).trim(); + const modelId = trimmedReference.substring(slashIndex + 1).trim(); + if (provider && modelId) { + const providerMatches = availableModels.filter( + (model) => + model.provider.toLowerCase() === provider.toLowerCase() && + model.id.toLowerCase() === modelId.toLowerCase(), + ); + if (providerMatches.length === 1) return providerMatches[0]; + if (providerMatches.length > 1) return undefined; + } + } + const idMatches = availableModels.filter( + (model) => model.id.toLowerCase() === normalizedReference, + ); + return idMatches.length === 1 ? idMatches[0] : undefined; +} + +function tryMatchScopedModel( + modelPattern: string, + availableModels: Model[], +): Model | undefined { + const exactMatch = findExactModelReferenceMatch( + modelPattern, + availableModels, + ); + if (exactMatch) return exactMatch; + const matches = availableModels.filter( + (model) => + model.id.toLowerCase().includes(modelPattern.toLowerCase()) || + model.name?.toLowerCase().includes(modelPattern.toLowerCase()), + ); + if (matches.length === 0) return undefined; + const aliases = matches.filter((model) => isAliasModelId(model.id)); + const datedVersions = matches.filter((model) => !isAliasModelId(model.id)); + if (aliases.length > 0) { + aliases.sort((a, b) => b.id.localeCompare(a.id)); + return aliases[0]; + } + datedVersions.sort((a, b) => b.id.localeCompare(a.id)); + return datedVersions[0]; +} + +function parseScopedModelPattern( + pattern: string, + availableModels: Model[], +): { model: Model | undefined; thinkingLevel?: ThinkingLevel } { + const exactMatch = tryMatchScopedModel(pattern, availableModels); + if (exactMatch) { + return { model: exactMatch, thinkingLevel: undefined }; + } + const lastColonIndex = pattern.lastIndexOf(":"); + if (lastColonIndex === -1) { + return { model: undefined, thinkingLevel: undefined }; + } + const prefix = pattern.substring(0, lastColonIndex); + const suffix = pattern.substring(lastColonIndex + 1); + if (isThinkingLevel(suffix)) { + const result = parseScopedModelPattern(prefix, availableModels); + if (result.model) { + return { model: result.model, thinkingLevel: suffix }; + } + return result; + } + return parseScopedModelPattern(prefix, availableModels); +} + +export function resolveScopedModelPatterns( + patterns: string[], + availableModels: Model[], +): ScopedTelegramModel[] { + const resolved: ScopedTelegramModel[] = []; + const seen = new Set(); + for (const pattern of patterns) { + if ( + pattern.includes("*") || + pattern.includes("?") || + pattern.includes("[") + ) { + const colonIndex = pattern.lastIndexOf(":"); + let globPattern = pattern; + let thinkingLevel: ThinkingLevel | undefined; + if (colonIndex !== -1) { + const suffix = pattern.substring(colonIndex + 1); + if (isThinkingLevel(suffix)) { + thinkingLevel = suffix; + globPattern = pattern.substring(0, colonIndex); + } + } + const matches = availableModels.filter( + (model) => + globMatches(getCanonicalModelId(model), globPattern) || + globMatches(model.id, globPattern), + ); + for (const model of matches) { + const key = getCanonicalModelId(model); + if (seen.has(key)) continue; + seen.add(key); + resolved.push({ model, thinkingLevel }); + } + continue; + } + const matched = parseScopedModelPattern(pattern, availableModels); + if (!matched.model) continue; + const key = getCanonicalModelId(matched.model); + if (seen.has(key)) continue; + seen.add(key); + resolved.push({ + model: matched.model, + thinkingLevel: matched.thinkingLevel, + }); + } + return resolved; +} + +export function sortScopedModels( + models: ScopedTelegramModel[], + currentModel: Model | undefined, +): ScopedTelegramModel[] { + const sorted = [...models]; + sorted.sort((a, b) => { + const aIsCurrent = modelsMatch(a.model, currentModel); + const bIsCurrent = modelsMatch(b.model, currentModel); + if (aIsCurrent && !bIsCurrent) return -1; + if (!aIsCurrent && bIsCurrent) return 1; + const providerCompare = a.model.provider.localeCompare(b.model.provider); + if (providerCompare !== 0) return providerCompare; + return a.model.id.localeCompare(b.model.id); + }); + return sorted; +} + +function truncateTelegramButtonLabel(label: string, maxLength = 56): string { + return label.length <= maxLength + ? label + : `${label.slice(0, maxLength - 1)}…`; +} + +export function formatScopedModelButtonText( + entry: ScopedTelegramModel, + currentModel: Model | undefined, +): string { + let label = `${modelsMatch(entry.model, currentModel) ? "βœ… " : ""}${entry.model.id} [${entry.model.provider}]`; + if (entry.thinkingLevel) { + label += ` Β· ${entry.thinkingLevel}`; + } + return truncateTelegramButtonLabel(label); +} + +export function formatStatusButtonLabel(label: string, value: string): string { + return truncateTelegramButtonLabel(`${label}: ${value}`, 64); +} + +export function getModelMenuItems( + state: TelegramModelMenuState, +): ScopedTelegramModel[] { + return state.scope === "scoped" && state.scopedModels.length > 0 + ? state.scopedModels + : state.allModels; +} + +export function buildTelegramModelMenuState( + params: BuildTelegramModelMenuStateParams, +): TelegramModelMenuState { + const allModels = sortScopedModels( + params.availableModels.map((model) => ({ model })), + params.activeModel, + ); + const scopedModels = + params.configuredScopedModelPatterns.length > 0 + ? sortScopedModels( + resolveScopedModelPatterns( + params.configuredScopedModelPatterns, + params.availableModels, + ), + params.activeModel, + ) + : []; + let note: string | undefined; + if ( + params.configuredScopedModelPatterns.length > 0 && + scopedModels.length === 0 + ) { + note = params.cliScopedModelPatterns + ? "No CLI scoped models matched the current auth configuration. Showing all available models." + : "No scoped models matched the current auth configuration. Showing all available models."; + } + return { + chatId: params.chatId, + messageId: 0, + page: 0, + scope: scopedModels.length > 0 ? "scoped" : "all", + scopedModels, + allModels, + note, + mode: "status", + }; +} + +export function parseTelegramMenuCallbackAction( + data: string | undefined, +): TelegramMenuCallbackAction { + if (data === "status:model") return { kind: "status", action: "model" }; + if (data === "status:thinking") { + return { kind: "status", action: "thinking" }; + } + if (data?.startsWith("thinking:set:")) { + return { + kind: "thinking:set", + level: data.slice("thinking:set:".length), + }; + } + if (data?.startsWith("model:")) { + const [, action, value] = data.split(":"); + if ( + action === "noop" || + action === "scope" || + action === "page" || + action === "pick" + ) { + return { kind: "model", action, value }; + } + } + return { kind: "ignore" }; +} + +export function applyTelegramModelScopeSelection( + state: TelegramModelMenuState, + value: string | undefined, +): TelegramMenuMutationResult { + if (value !== "all" && value !== "scoped") return "invalid"; + if (value === state.scope) return "unchanged"; + state.scope = value; + state.page = 0; + return "changed"; +} + +export function applyTelegramModelPageSelection( + state: TelegramModelMenuState, + value: string | undefined, +): TelegramMenuMutationResult { + const page = Number(value); + if (!Number.isFinite(page)) return "invalid"; + if (page === state.page) return "unchanged"; + state.page = page; + return "changed"; +} + +export function getTelegramModelSelection( + state: TelegramModelMenuState, + value: string | undefined, +): TelegramMenuSelectionResult { + const index = Number(value); + if (!Number.isFinite(index)) return { kind: "invalid" }; + const selection = getModelMenuItems(state)[index]; + if (!selection) return { kind: "missing" }; + return { kind: "selected", selection }; +} + +export function buildTelegramModelCallbackPlan( + params: BuildTelegramModelCallbackPlanParams, +): TelegramModelCallbackPlan { + const action = parseTelegramMenuCallbackAction(params.data); + if (action.kind !== "model") return { kind: "ignore" }; + if (action.action === "noop") return { kind: "answer" }; + if (action.action === "scope") { + const result = applyTelegramModelScopeSelection(params.state, action.value); + if (result === "invalid") { + return { kind: "answer", text: "Unknown model scope." }; + } + if (result === "unchanged") { + return { kind: "answer" }; + } + return { + kind: "update-menu", + text: params.state.scope === "scoped" ? "Scoped models" : "All models", + }; + } + if (action.action === "page") { + const result = applyTelegramModelPageSelection(params.state, action.value); + if (result === "invalid") { + return { kind: "answer", text: "Invalid page." }; + } + if (result === "unchanged") { + return { kind: "answer" }; + } + return { kind: "update-menu" }; + } + if (action.action !== "pick") { + return { kind: "answer" }; + } + const selectionResult = getTelegramModelSelection(params.state, action.value); + if (selectionResult.kind === "invalid") { + return { kind: "answer", text: "Invalid model selection." }; + } + if (selectionResult.kind === "missing") { + return { kind: "answer", text: "Selected model is no longer available." }; + } + const selection = selectionResult.selection; + if (modelsMatch(selection.model, params.activeModel)) { + return { + kind: "refresh-status", + selection, + callbackText: `Model: ${selection.model.id}`, + shouldApplyThinkingLevel: + !!selection.thinkingLevel && + selection.thinkingLevel !== params.currentThinkingLevel, + }; + } + if (!params.isIdle) { + if (!params.canRestartBusyRun) { + return { kind: "answer", text: "Pi is busy. Send /stop first." }; + } + return { + kind: "switch-model", + selection, + mode: params.hasActiveToolExecutions + ? "restart-after-tool" + : "restart-now", + callbackText: params.hasActiveToolExecutions + ? `Switched to ${selection.model.id}. Restarting after the current tool finishes…` + : `Switching to ${selection.model.id} and continuing…`, + }; + } + return { + kind: "switch-model", + selection, + mode: "idle", + callbackText: `Switched to ${selection.model.id}`, + }; +} + +export async function handleTelegramMenuCallbackEntry( + callbackQueryId: string, + data: string | undefined, + state: TelegramModelMenuState | undefined, + deps: TelegramMenuCallbackEntryDeps, +): Promise { + if (!data) { + await deps.answerCallbackQuery(callbackQueryId); + return; + } + if (!state) { + await deps.answerCallbackQuery(callbackQueryId, "Interactive message expired."); + return; + } + const handled = + (await deps.handleStatusAction()) || + (await deps.handleThinkingAction()) || + (await deps.handleModelAction()); + if (!handled) { + await deps.answerCallbackQuery(callbackQueryId); + } +} + +export async function handleTelegramModelMenuCallbackAction( + callbackQueryId: string, + params: BuildTelegramModelCallbackPlanParams, + deps: TelegramModelMenuCallbackDeps, +): Promise { + const plan = buildTelegramModelCallbackPlan(params); + if (plan.kind === "ignore") return false; + if (plan.kind === "answer") { + await deps.answerCallbackQuery(callbackQueryId, plan.text); + return true; + } + if (plan.kind === "update-menu") { + await deps.updateModelMenuMessage(); + await deps.answerCallbackQuery(callbackQueryId, plan.text); + return true; + } + if (plan.kind === "refresh-status") { + if (plan.shouldApplyThinkingLevel && plan.selection.thinkingLevel) { + deps.setThinkingLevel(plan.selection.thinkingLevel); + } + await deps.updateStatusMessage(); + await deps.answerCallbackQuery(callbackQueryId, plan.callbackText); + return true; + } + const changed = await deps.setModel(plan.selection.model); + if (changed === false) { + await deps.answerCallbackQuery(callbackQueryId, "Model is not available."); + return true; + } + deps.setCurrentModel(plan.selection.model); + if (plan.selection.thinkingLevel) { + deps.setThinkingLevel(plan.selection.thinkingLevel); + } + await deps.updateStatusMessage(); + if (plan.mode === "restart-after-tool") { + deps.stagePendingModelSwitch(plan.selection); + await deps.answerCallbackQuery(callbackQueryId, plan.callbackText); + return true; + } + if (plan.mode === "restart-now") { + const restarted = await deps.restartInterruptedTelegramTurn(plan.selection); + if (!restarted) { + await deps.answerCallbackQuery( + callbackQueryId, + "Pi is busy. Send /stop first.", + ); + return true; + } + } + await deps.answerCallbackQuery(callbackQueryId, plan.callbackText); + return true; +} + +export async function handleTelegramStatusMenuCallbackAction( + callbackQueryId: string, + data: string | undefined, + activeModel: Model | undefined, + deps: TelegramStatusMenuCallbackDeps, +): Promise { + const action = parseTelegramMenuCallbackAction(data); + if (action.kind === "status" && action.action === "model") { + await deps.updateModelMenuMessage(); + await deps.answerCallbackQuery(callbackQueryId); + return true; + } + if (!(action.kind === "status" && action.action === "thinking")) { + return false; + } + if (!activeModel?.reasoning) { + await deps.answerCallbackQuery( + callbackQueryId, + "This model has no reasoning controls.", + ); + return true; + } + await deps.updateThinkingMenuMessage(); + await deps.answerCallbackQuery(callbackQueryId); + return true; +} + +export async function handleTelegramThinkingMenuCallbackAction( + callbackQueryId: string, + data: string | undefined, + activeModel: Model | undefined, + deps: TelegramThinkingMenuCallbackDeps, +): Promise { + const action = parseTelegramMenuCallbackAction(data); + if (action.kind !== "thinking:set") return false; + if (!isThinkingLevel(action.level)) { + await deps.answerCallbackQuery(callbackQueryId, "Invalid thinking level."); + return true; + } + if (!activeModel?.reasoning) { + await deps.answerCallbackQuery( + callbackQueryId, + "This model has no reasoning controls.", + ); + return true; + } + deps.setThinkingLevel(action.level); + await deps.updateStatusMessage(); + await deps.answerCallbackQuery( + callbackQueryId, + `Thinking: ${deps.getCurrentThinkingLevel()}`, + ); + return true; +} + +export function buildThinkingMenuText( + activeModel: Model | undefined, + currentThinkingLevel: ThinkingLevel, +): string { + const lines = ["Choose a thinking level"]; + if (activeModel) { + lines.push(`Model: ${getCanonicalModelId(activeModel)}`); + } + lines.push(`Current: ${currentThinkingLevel}`); + return lines.join("\n"); +} + +export function getTelegramModelMenuPage( + state: TelegramModelMenuState, + pageSize: number, +): TelegramModelMenuPage { + const items = getModelMenuItems(state); + const pageCount = Math.max(1, Math.ceil(items.length / pageSize)); + const page = Math.max(0, Math.min(state.page, pageCount - 1)); + const start = page * pageSize; + return { + page, + pageCount, + start, + items: items.slice(start, start + pageSize), + }; +} + +export function buildModelMenuReplyMarkup( + state: TelegramModelMenuState, + currentModel: Model | undefined, + pageSize: number, +): TelegramReplyMarkup { + const menuPage = getTelegramModelMenuPage(state, pageSize); + const rows = menuPage.items.map((entry, index) => [ + { + text: formatScopedModelButtonText(entry, currentModel), + callback_data: `model:pick:${menuPage.start + index}`, + }, + ]); + if (menuPage.pageCount > 1) { + const previousPage = + menuPage.page === 0 ? menuPage.pageCount - 1 : menuPage.page - 1; + const nextPage = + menuPage.page === menuPage.pageCount - 1 ? 0 : menuPage.page + 1; + rows.push([ + { text: "⬅️", callback_data: `model:page:${previousPage}` }, + { + text: `${menuPage.page + 1}/${menuPage.pageCount}`, + callback_data: "model:noop", + }, + { text: "➑️", callback_data: `model:page:${nextPage}` }, + ]); + } + if (state.scopedModels.length > 0) { + rows.push([ + { + text: state.scope === "scoped" ? "βœ… Scoped" : "Scoped", + callback_data: "model:scope:scoped", + }, + { + text: state.scope === "all" ? "βœ… All" : "All", + callback_data: "model:scope:all", + }, + ]); + } + return { inline_keyboard: rows }; +} + +export function buildThinkingMenuReplyMarkup( + currentThinkingLevel: ThinkingLevel, +): TelegramReplyMarkup { + return { + inline_keyboard: THINKING_LEVELS.map((level) => [ + { + text: level === currentThinkingLevel ? `βœ… ${level}` : level, + callback_data: `thinking:set:${level}`, + }, + ]), + }; +} + +export function buildStatusReplyMarkup( + activeModel: Model | undefined, + currentThinkingLevel: ThinkingLevel, +): TelegramReplyMarkup { + const rows: Array> = []; + rows.push([ + { + text: formatStatusButtonLabel( + "Model", + activeModel ? getCanonicalModelId(activeModel) : "unknown", + ), + callback_data: "status:model", + }, + ]); + if (activeModel?.reasoning) { + rows.push([ + { + text: formatStatusButtonLabel("Thinking", currentThinkingLevel), + callback_data: "status:thinking", + }, + ]); + } + return { inline_keyboard: rows }; +} + +export function buildTelegramModelMenuRenderPayload( + state: TelegramModelMenuState, + activeModel: Model | undefined, +): TelegramMenuRenderPayload { + return { + nextMode: "model", + text: MODEL_MENU_TITLE, + mode: "html", + replyMarkup: buildModelMenuReplyMarkup( + state, + activeModel, + TELEGRAM_MODEL_PAGE_SIZE, + ), + }; +} + +export function buildTelegramThinkingMenuRenderPayload( + activeModel: Model | undefined, + currentThinkingLevel: ThinkingLevel, +): TelegramMenuRenderPayload { + return { + nextMode: "thinking", + text: buildThinkingMenuText(activeModel, currentThinkingLevel), + mode: "plain", + replyMarkup: buildThinkingMenuReplyMarkup(currentThinkingLevel), + }; +} + +export function buildTelegramStatusMenuRenderPayload( + statusText: string, + activeModel: Model | undefined, + currentThinkingLevel: ThinkingLevel, +): TelegramMenuRenderPayload { + return { + nextMode: "status", + text: statusText, + mode: "html", + replyMarkup: buildStatusReplyMarkup(activeModel, currentThinkingLevel), + }; +} + +export async function updateTelegramModelMenuMessage( + state: TelegramModelMenuState, + activeModel: Model | undefined, + deps: TelegramMenuMessageRuntimeDeps, +): Promise { + const payload = buildTelegramModelMenuRenderPayload(state, activeModel); + state.mode = payload.nextMode; + await deps.editInteractiveMessage( + state.chatId, + state.messageId, + payload.text, + payload.mode, + payload.replyMarkup, + ); +} + +export async function updateTelegramThinkingMenuMessage( + state: TelegramModelMenuState, + activeModel: Model | undefined, + currentThinkingLevel: ThinkingLevel, + deps: TelegramMenuMessageRuntimeDeps, +): Promise { + const payload = buildTelegramThinkingMenuRenderPayload( + activeModel, + currentThinkingLevel, + ); + state.mode = payload.nextMode; + await deps.editInteractiveMessage( + state.chatId, + state.messageId, + payload.text, + payload.mode, + payload.replyMarkup, + ); +} + +export async function updateTelegramStatusMessage( + state: TelegramModelMenuState, + statusText: string, + activeModel: Model | undefined, + currentThinkingLevel: ThinkingLevel, + deps: TelegramMenuMessageRuntimeDeps, +): Promise { + const payload = buildTelegramStatusMenuRenderPayload( + statusText, + activeModel, + currentThinkingLevel, + ); + state.mode = payload.nextMode; + await deps.editInteractiveMessage( + state.chatId, + state.messageId, + payload.text, + payload.mode, + payload.replyMarkup, + ); +} + +export async function sendTelegramStatusMessage( + state: TelegramModelMenuState, + statusText: string, + activeModel: Model | undefined, + currentThinkingLevel: ThinkingLevel, + deps: TelegramMenuMessageRuntimeDeps, +): Promise { + const payload = buildTelegramStatusMenuRenderPayload( + statusText, + activeModel, + currentThinkingLevel, + ); + state.mode = payload.nextMode; + return deps.sendInteractiveMessage( + state.chatId, + payload.text, + payload.mode, + payload.replyMarkup, + ); +} + +export async function sendTelegramModelMenuMessage( + state: TelegramModelMenuState, + activeModel: Model | undefined, + deps: TelegramMenuMessageRuntimeDeps, +): Promise { + const payload = buildTelegramModelMenuRenderPayload(state, activeModel); + state.mode = payload.nextMode; + return deps.sendInteractiveMessage( + state.chatId, + payload.text, + payload.mode, + payload.replyMarkup, + ); +} diff --git a/lib/model-switch.ts b/lib/model-switch.ts new file mode 100644 index 0000000..5b87cb2 --- /dev/null +++ b/lib/model-switch.ts @@ -0,0 +1,62 @@ +/** + * In-flight Telegram model-switch helpers + * Encodes the safe restart and continuation rules for switching models during active Telegram-owned runs + */ + +import type { Model } from "@mariozechner/pi-ai"; + +import type { TelegramInFlightModelSwitchState } from "./queue.ts"; + +export type TelegramThinkingLevel = + | "off" + | "minimal" + | "low" + | "medium" + | "high" + | "xhigh"; + +export function canRestartTelegramTurnForModelSwitch( + state: TelegramInFlightModelSwitchState, +): boolean { + return !state.isIdle && state.hasActiveTelegramTurn && state.hasAbortHandler; +} + +export function shouldTriggerPendingTelegramModelSwitchAbort(state: { + hasPendingModelSwitch: boolean; + hasActiveTelegramTurn: boolean; + hasAbortHandler: boolean; + activeToolExecutions: number; +}): boolean { + return ( + state.hasPendingModelSwitch && + state.hasActiveTelegramTurn && + state.hasAbortHandler && + state.activeToolExecutions === 0 + ); +} + +export function restartTelegramModelSwitchContinuation(state: { + activeTurn: TTurn | undefined; + abort: (() => void) | undefined; + selection: TSelection; + queueContinuation: (turn: TTurn, selection: TSelection) => void; +}): boolean { + if (!state.activeTurn || !state.abort) return false; + state.queueContinuation(state.activeTurn, state.selection); + state.abort(); + return true; +} + +export function buildTelegramModelSwitchContinuationText< + TModel extends Pick, "provider" | "id">, +>( + telegramPrefix: string, + model: TModel, + thinkingLevel?: TelegramThinkingLevel, +): string { + const modelLabel = `${model.provider}/${model.id}`; + const thinkingSuffix = thinkingLevel + ? ` Keep the selected thinking level (${thinkingLevel}) if it still applies.` + : ""; + return `${telegramPrefix} Continue the interrupted previous Telegram request using the newly selected model (${modelLabel}). Resume from the last unfinished step instead of restarting from scratch unless necessary.${thinkingSuffix}`; +} diff --git a/lib/polling.ts b/lib/polling.ts new file mode 100644 index 0000000..98453bb --- /dev/null +++ b/lib/polling.ts @@ -0,0 +1,122 @@ +/** + * Telegram polling domain helpers + * Owns polling request builders, stop conditions, and the long-poll loop runtime for Telegram updates + */ + +import type { ExtensionContext } from "@mariozechner/pi-coding-agent"; + +import type { TelegramConfig } from "./api.ts"; + +export interface TelegramUpdateLike { + update_id: number; +} + +export const TELEGRAM_ALLOWED_UPDATES = [ + "message", + "edited_message", + "callback_query", + "message_reaction", +] as const; + +export function buildTelegramInitialSyncRequest(): { + offset: number; + limit: number; + timeout: number; +} { + return { + offset: -1, + limit: 1, + timeout: 0, + }; +} + +export function buildTelegramLongPollRequest(lastUpdateId?: number): { + offset?: number; + limit: number; + timeout: number; + allowed_updates: readonly string[]; +} { + return { + offset: lastUpdateId !== undefined ? lastUpdateId + 1 : undefined, + limit: 10, + timeout: 30, + allowed_updates: TELEGRAM_ALLOWED_UPDATES, + }; +} + +export function getLatestTelegramUpdateId( + updates: TelegramUpdateLike[], +): number | undefined { + return updates.at(-1)?.update_id; +} + +export function shouldStopTelegramPolling( + signalAborted: boolean, + error: unknown, +): boolean { + return ( + signalAborted || + (error instanceof DOMException && error.name === "AbortError") + ); +} + +export interface TelegramPollLoopDeps { + ctx: ExtensionContext; + signal: AbortSignal; + config: TelegramConfig; + deleteWebhook: (signal: AbortSignal) => Promise; + getUpdates: ( + body: Record, + signal: AbortSignal, + ) => Promise; + persistConfig: () => Promise; + handleUpdate: (update: TUpdate, ctx: ExtensionContext) => Promise; + onErrorStatus: (message: string) => void; + onStatusReset: () => void; + sleep: (ms: number) => Promise; +} + +export async function runTelegramPollLoop( + deps: TelegramPollLoopDeps, +): Promise { + if (!deps.config.botToken) return; + try { + await deps.deleteWebhook(deps.signal); + } catch { + // ignore + } + if (deps.config.lastUpdateId === undefined) { + try { + const updates = await deps.getUpdates( + buildTelegramInitialSyncRequest(), + deps.signal, + ); + const lastUpdateId = getLatestTelegramUpdateId(updates); + if (lastUpdateId !== undefined) { + deps.config.lastUpdateId = lastUpdateId; + await deps.persistConfig(); + } + } catch { + // ignore + } + } + while (!deps.signal.aborted) { + try { + const updates = await deps.getUpdates( + buildTelegramLongPollRequest(deps.config.lastUpdateId), + deps.signal, + ); + for (const update of updates) { + deps.config.lastUpdateId = update.update_id; + await deps.persistConfig(); + await deps.handleUpdate(update, deps.ctx); + } + } catch (error) { + if (shouldStopTelegramPolling(deps.signal.aborted, error)) return; + const message = error instanceof Error ? error.message : String(error); + deps.onErrorStatus(message); + await deps.sleep(3000); + deps.onStatusReset(); + } + } +} diff --git a/lib/queue.ts b/lib/queue.ts new file mode 100644 index 0000000..76f4c2b --- /dev/null +++ b/lib/queue.ts @@ -0,0 +1,534 @@ +/** + * Telegram queue and queue-runtime domain helpers + * Owns queue items, queue mutations, dispatch and lifecycle planning, session resets, and queue-adjacent runtime helpers + */ + +import type { ImageContent, Model, TextContent } from "@mariozechner/pi-ai"; +import type { ExtensionContext } from "@mariozechner/pi-coding-agent"; + +// --- Queue Items --- + +export interface QueuedAttachment { + path: string; + fileName: string; +} + +export type TelegramQueueItemKind = "prompt" | "control"; +export type TelegramQueueLane = "control" | "priority" | "default"; + +export interface TelegramQueueItemBase { + kind: TelegramQueueItemKind; + chatId: number; + replyToMessageId: number; + queueOrder: number; + queueLane: TelegramQueueLane; + laneOrder: number; + statusSummary: string; +} + +export interface PendingTelegramTurn extends TelegramQueueItemBase { + kind: "prompt"; + sourceMessageIds: number[]; + queuedAttachments: QueuedAttachment[]; + content: Array; + historyText: string; +} + +export interface PendingTelegramControlItem extends TelegramQueueItemBase { + kind: "control"; + controlType: "status" | "model"; + execute: (ctx: ExtensionContext) => Promise; +} + +export type TelegramQueueItem = + | PendingTelegramTurn + | PendingTelegramControlItem; + +export interface TelegramDispatchGuardState { + compactionInProgress: boolean; + hasActiveTelegramTurn: boolean; + hasPendingTelegramDispatch: boolean; + isIdle: boolean; + hasPendingMessages: boolean; +} + +export interface TelegramInFlightModelSwitchState { + isIdle: boolean; + hasActiveTelegramTurn: boolean; + hasAbortHandler: boolean; +} + +function getTelegramQueueLaneRank(lane: TelegramQueueLane): number { + switch (lane) { + case "control": + return 0; + case "priority": + return 1; + default: + return 2; + } +} + +export function isPendingTelegramTurn( + item: TelegramQueueItem, +): item is PendingTelegramTurn { + return item.kind === "prompt"; +} + +// --- Queue Mutations --- + +export function partitionTelegramQueueItemsForHistory( + items: TelegramQueueItem[], +): { + historyTurns: PendingTelegramTurn[]; + remainingItems: TelegramQueueItem[]; +} { + const historyTurns: PendingTelegramTurn[] = []; + const remainingItems: TelegramQueueItem[] = []; + for (const item of items) { + if (isPendingTelegramTurn(item)) { + historyTurns.push(item); + continue; + } + remainingItems.push(item); + } + return { historyTurns, remainingItems }; +} + +export function compareTelegramQueueItems( + left: TelegramQueueItem, + right: TelegramQueueItem, +): number { + const laneRankDelta = + getTelegramQueueLaneRank(left.queueLane) - + getTelegramQueueLaneRank(right.queueLane); + if (laneRankDelta !== 0) return laneRankDelta; + if (left.laneOrder !== right.laneOrder) { + return left.laneOrder - right.laneOrder; + } + return left.queueOrder - right.queueOrder; +} + +export function removeTelegramQueueItemsByMessageIds( + items: TelegramQueueItem[], + messageIds: number[], +): { items: TelegramQueueItem[]; removedCount: number } { + if (messageIds.length === 0 || items.length === 0) { + return { items, removedCount: 0 }; + } + const deletedMessageIds = new Set(messageIds); + const nextItems = items.filter((item) => { + if (!isPendingTelegramTurn(item)) return true; + return !item.sourceMessageIds.some((messageId) => + deletedMessageIds.has(messageId), + ); + }); + return { + items: nextItems, + removedCount: items.length - nextItems.length, + }; +} + +export function clearTelegramQueuePromptPriority( + items: TelegramQueueItem[], + messageId: number, +): { items: TelegramQueueItem[]; changed: boolean } { + let changed = false; + const nextItems = items.map((item) => { + if ( + !isPendingTelegramTurn(item) || + !item.sourceMessageIds.includes(messageId) || + item.queueLane !== "priority" + ) { + return item; + } + changed = true; + return { + ...item, + queueLane: "default" as const, + laneOrder: item.queueOrder, + }; + }); + return { items: nextItems, changed }; +} + +export function prioritizeTelegramQueuePrompt( + items: TelegramQueueItem[], + messageId: number, + laneOrder: number, +): { items: TelegramQueueItem[]; changed: boolean } { + let changed = false; + const nextItems = items.map((item) => { + if ( + !isPendingTelegramTurn(item) || + !item.sourceMessageIds.includes(messageId) + ) { + return item; + } + changed = true; + return { + ...item, + queueLane: "priority" as const, + laneOrder, + }; + }); + return { items: nextItems, changed }; +} + +export function consumeDispatchedTelegramPrompt( + items: TelegramQueueItem[], + hasPendingDispatch: boolean, +): { activeTurn?: PendingTelegramTurn; remainingItems: TelegramQueueItem[] } { + if (!hasPendingDispatch) { + return { activeTurn: undefined, remainingItems: items }; + } + const nextItem = items[0]; + if (!nextItem || !isPendingTelegramTurn(nextItem)) { + return { activeTurn: undefined, remainingItems: items }; + } + return { activeTurn: nextItem, remainingItems: items.slice(1) }; +} + +export function formatQueuedTelegramItemsStatus( + items: TelegramQueueItem[], +): string { + if (items.length === 0) return ""; + const previewCount = 4; + const summaries = items + .slice(0, previewCount) + .map((item) => item.statusSummary) + .filter(Boolean); + if (summaries.length === 0) return ` +${items.length}`; + const suffix = items.length > summaries.length ? ", …" : ""; + return ` +${items.length}: [${summaries.join(", ")}${suffix}]`; +} + +export function canDispatchTelegramTurnState( + state: TelegramDispatchGuardState, +): boolean { + return ( + !state.compactionInProgress && + !state.hasActiveTelegramTurn && + !state.hasPendingTelegramDispatch && + state.isIdle && + !state.hasPendingMessages + ); +} + +export function canRestartTelegramTurnForModelSwitch( + state: TelegramInFlightModelSwitchState, +): boolean { + return !state.isIdle && state.hasActiveTelegramTurn && state.hasAbortHandler; +} + +export function shouldTriggerPendingTelegramModelSwitchAbort(state: { + hasPendingModelSwitch: boolean; + hasActiveTelegramTurn: boolean; + hasAbortHandler: boolean; + activeToolExecutions: number; +}): boolean { + return ( + state.hasPendingModelSwitch && + state.hasActiveTelegramTurn && + state.hasAbortHandler && + state.activeToolExecutions === 0 + ); +} + +// --- Dispatch Planning --- + +export type TelegramQueueDispatchAction = + | { kind: "none"; remainingItems: TelegramQueueItem[] } + | { + kind: "control"; + item: PendingTelegramControlItem; + remainingItems: TelegramQueueItem[]; + } + | { + kind: "prompt"; + item: PendingTelegramTurn; + remainingItems: TelegramQueueItem[]; + }; + +export function planNextTelegramQueueAction( + items: TelegramQueueItem[], + canDispatch: boolean, +): TelegramQueueDispatchAction { + if (!canDispatch || items.length === 0) { + return { kind: "none", remainingItems: items }; + } + const [firstItem, ...remainingItems] = items; + if (!firstItem) { + return { kind: "none", remainingItems: items }; + } + if (isPendingTelegramTurn(firstItem)) { + return { kind: "prompt", item: firstItem, remainingItems: items }; + } + return { kind: "control", item: firstItem, remainingItems }; +} + +export function shouldDispatchAfterTelegramAgentEnd(options: { + hasTurn: boolean; + stopReason?: string; + preserveQueuedTurnsAsHistory: boolean; +}): boolean { + if (!options.hasTurn) return true; + if (options.stopReason === "aborted") { + return !options.preserveQueuedTurnsAsHistory; + } + return true; +} + +// --- Agent Runtime --- + +export interface TelegramAgentStartPlan { + activeTurn?: PendingTelegramTurn; + remainingItems: TelegramQueueItem[]; + shouldResetPendingModelSwitch: boolean; + shouldResetToolExecutions: boolean; + shouldClearDispatchPending: boolean; +} + +export function buildTelegramAgentStartPlan(options: { + queuedItems: TelegramQueueItem[]; + hasPendingDispatch: boolean; + hasActiveTurn: boolean; +}): TelegramAgentStartPlan { + if (options.hasActiveTurn || !options.hasPendingDispatch) { + return { + activeTurn: undefined, + remainingItems: options.queuedItems, + shouldResetPendingModelSwitch: true, + shouldResetToolExecutions: true, + shouldClearDispatchPending: options.hasPendingDispatch, + }; + } + const nextDispatch = consumeDispatchedTelegramPrompt( + options.queuedItems, + options.hasPendingDispatch, + ); + return { + activeTurn: nextDispatch.activeTurn, + remainingItems: nextDispatch.remainingItems, + shouldResetPendingModelSwitch: true, + shouldResetToolExecutions: true, + shouldClearDispatchPending: options.hasPendingDispatch, + }; +} + +export function getNextTelegramToolExecutionCount(options: { + hasActiveTurn: boolean; + currentCount: number; + event: "start" | "end"; +}): number { + if (!options.hasActiveTurn) return options.currentCount; + if (options.event === "start") { + return options.currentCount + 1; + } + return Math.max(0, options.currentCount - 1); +} + +// --- Agent End Lifecycle --- + +export interface TelegramAgentEndPlan { + kind: "no-turn" | "aborted" | "error" | "text" | "attachments-only" | "empty"; + shouldClearPreview: boolean; + shouldDispatchNext: boolean; + shouldSendErrorMessage: boolean; + shouldSendAttachmentNotice: boolean; +} + +export function buildTelegramAgentEndPlan(options: { + hasTurn: boolean; + stopReason?: string; + hasFinalText: boolean; + hasQueuedAttachments: boolean; + preserveQueuedTurnsAsHistory: boolean; +}): TelegramAgentEndPlan { + const shouldDispatchNext = shouldDispatchAfterTelegramAgentEnd({ + hasTurn: options.hasTurn, + stopReason: options.stopReason, + preserveQueuedTurnsAsHistory: options.preserveQueuedTurnsAsHistory, + }); + if (!options.hasTurn) { + return { + kind: "no-turn", + shouldClearPreview: false, + shouldDispatchNext, + shouldSendErrorMessage: false, + shouldSendAttachmentNotice: false, + }; + } + if (options.stopReason === "aborted") { + return { + kind: "aborted", + shouldClearPreview: true, + shouldDispatchNext, + shouldSendErrorMessage: false, + shouldSendAttachmentNotice: false, + }; + } + if (options.stopReason === "error") { + return { + kind: "error", + shouldClearPreview: true, + shouldDispatchNext, + shouldSendErrorMessage: true, + shouldSendAttachmentNotice: false, + }; + } + if (options.hasFinalText) { + return { + kind: "text", + shouldClearPreview: false, + shouldDispatchNext, + shouldSendErrorMessage: false, + shouldSendAttachmentNotice: false, + }; + } + if (options.hasQueuedAttachments) { + return { + kind: "attachments-only", + shouldClearPreview: true, + shouldDispatchNext, + shouldSendErrorMessage: false, + shouldSendAttachmentNotice: true, + }; + } + return { + kind: "empty", + shouldClearPreview: true, + shouldDispatchNext, + shouldSendErrorMessage: false, + shouldSendAttachmentNotice: false, + }; +} + +// --- Session Runtime --- + +export interface TelegramPollingStartState { + hasBotToken: boolean; + hasPollingPromise: boolean; +} + +export function shouldStartTelegramPolling( + state: TelegramPollingStartState, +): boolean { + return state.hasBotToken && !state.hasPollingPromise; +} + +export function buildTelegramSessionStartState( + currentModel: Model | undefined, +): { + currentTelegramModel: Model | undefined; + activeTelegramToolExecutions: number; + pendingTelegramModelSwitch: undefined; + nextQueuedTelegramItemOrder: number; + nextQueuedTelegramControlOrder: number; + telegramTurnDispatchPending: boolean; + compactionInProgress: boolean; +} { + return { + currentTelegramModel: currentModel, + activeTelegramToolExecutions: 0, + pendingTelegramModelSwitch: undefined, + nextQueuedTelegramItemOrder: 0, + nextQueuedTelegramControlOrder: 0, + telegramTurnDispatchPending: false, + compactionInProgress: false, + }; +} + +export function buildTelegramSessionShutdownState(): { + queuedTelegramItems: TQueueItem[]; + nextQueuedTelegramItemOrder: number; + nextQueuedTelegramControlOrder: number; + nextPriorityReactionOrder: number; + currentTelegramModel: undefined; + activeTelegramToolExecutions: number; + pendingTelegramModelSwitch: undefined; + telegramTurnDispatchPending: boolean; + compactionInProgress: boolean; + preserveQueuedTurnsAsHistory: boolean; +} { + return { + queuedTelegramItems: [], + nextQueuedTelegramItemOrder: 0, + nextQueuedTelegramControlOrder: 0, + nextPriorityReactionOrder: 0, + currentTelegramModel: undefined, + activeTelegramToolExecutions: 0, + pendingTelegramModelSwitch: undefined, + telegramTurnDispatchPending: false, + compactionInProgress: false, + preserveQueuedTurnsAsHistory: false, + }; +} + +// --- Control Runtime --- + +export interface TelegramControlRuntimeDeps { + ctx: ExtensionContext; + sendTextReply: ( + chatId: number, + replyToMessageId: number, + text: string, + ) => Promise; + onSettled: () => void; +} + +export async function executeTelegramControlItemRuntime( + item: PendingTelegramControlItem, + deps: TelegramControlRuntimeDeps, +): Promise { + try { + await item.execute(deps.ctx); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + await deps.sendTextReply( + item.chatId, + item.replyToMessageId, + `Telegram control action failed: ${message}`, + ); + } finally { + deps.onSettled(); + } +} + +// --- Dispatch Runtime --- + +export interface TelegramDispatchRuntimeDeps { + executeControlItem: ( + item: Extract["item"], + ) => void; + onPromptDispatchStart: (chatId: number) => void; + sendUserMessage: ( + content: Extract< + TelegramQueueDispatchAction, + { kind: "prompt" } + >["item"]["content"], + ) => void; + onPromptDispatchFailure: (message: string) => void; + onIdle: () => void; +} + +export function executeTelegramQueueDispatchPlan( + plan: TelegramQueueDispatchAction, + deps: TelegramDispatchRuntimeDeps, +): void { + if (plan.kind === "none") { + deps.onIdle(); + return; + } + if (plan.kind === "control") { + deps.executeControlItem(plan.item); + return; + } + deps.onPromptDispatchStart(plan.item.chatId); + try { + deps.sendUserMessage(plan.item.content); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + deps.onPromptDispatchFailure(message); + } +} diff --git a/lib/registration.ts b/lib/registration.ts new file mode 100644 index 0000000..285b6b3 --- /dev/null +++ b/lib/registration.ts @@ -0,0 +1,163 @@ +/** + * Telegram extension registration helpers + * Owns tool, command, and lifecycle-hook registration so index.ts can stay focused on runtime orchestration state and side effects + */ + +import type { + ExtensionAPI, + ExtensionCommandContext, + ExtensionContext, +} from "@mariozechner/pi-coding-agent"; +import { Type } from "@sinclair/typebox"; + +import { queueTelegramAttachments } from "./attachments.ts"; +import type { PendingTelegramTurn } from "./queue.ts"; + +// --- Tool Registration --- + +export interface TelegramAttachmentToolRegistrationDeps { + maxAttachmentsPerTurn: number; + getActiveTurn: () => PendingTelegramTurn | undefined; + statPath: (path: string) => Promise<{ isFile(): boolean }>; +} + +export function registerTelegramAttachmentTool( + pi: ExtensionAPI, + deps: TelegramAttachmentToolRegistrationDeps, +): void { + pi.registerTool({ + name: "telegram_attach", + label: "Telegram Attach", + description: + "Queue one or more local files to be sent with the next Telegram reply.", + promptSnippet: "Queue local files to be sent with the next Telegram reply.", + promptGuidelines: [ + "When handling a [telegram] message and the user asked for a file or generated artifact, call telegram_attach with the local path instead of only mentioning the path in text.", + ], + parameters: Type.Object({ + paths: Type.Array( + Type.String({ description: "Local file path to attach" }), + { minItems: 1, maxItems: deps.maxAttachmentsPerTurn }, + ), + }), + async execute(_toolCallId, params) { + return queueTelegramAttachments({ + activeTurn: deps.getActiveTurn(), + paths: params.paths, + maxAttachmentsPerTurn: deps.maxAttachmentsPerTurn, + statPath: deps.statPath, + }); + }, + }); +} + +// --- Command Registration --- + +export interface TelegramCommandRegistrationDeps { + promptForConfig: (ctx: ExtensionCommandContext) => Promise; + getStatusLines: () => string[]; + reloadConfig: () => Promise; + hasBotToken: () => boolean; + startPolling: (ctx: ExtensionCommandContext) => Promise; + stopPolling: () => Promise; + updateStatus: (ctx: ExtensionCommandContext) => void; +} + +export function registerTelegramCommands( + pi: ExtensionAPI, + deps: TelegramCommandRegistrationDeps, +): void { + pi.registerCommand("telegram-setup", { + description: "Configure Telegram bot token", + handler: async (_args, ctx) => { + await deps.promptForConfig(ctx); + }, + }); + pi.registerCommand("telegram-status", { + description: "Show Telegram bridge status", + handler: async (_args, ctx) => { + ctx.ui.notify(deps.getStatusLines().join(" | "), "info"); + }, + }); + pi.registerCommand("telegram-connect", { + description: "Start the Telegram bridge in this pi session", + handler: async (_args, ctx) => { + await deps.reloadConfig(); + if (!deps.hasBotToken()) { + await deps.promptForConfig(ctx); + return; + } + await deps.startPolling(ctx); + deps.updateStatus(ctx); + }, + }); + pi.registerCommand("telegram-disconnect", { + description: "Stop the Telegram bridge in this pi session", + handler: async (_args, ctx) => { + await deps.stopPolling(); + deps.updateStatus(ctx); + }, + }); +} + +// --- Lifecycle Hook Registration --- + +export interface TelegramLifecycleRegistrationDeps { + onSessionStart: (event: unknown, ctx: ExtensionContext) => Promise; + onSessionShutdown: (event: unknown, ctx: ExtensionContext) => Promise; + onBeforeAgentStart: ( + event: unknown, + ctx: ExtensionContext, + ) => Promise | unknown; + onModelSelect: ( + event: unknown, + ctx: ExtensionContext, + ) => Promise | void; + onAgentStart: (event: unknown, ctx: ExtensionContext) => Promise; + onToolExecutionStart: ( + event: unknown, + ctx: ExtensionContext, + ) => Promise | void; + onToolExecutionEnd: ( + event: unknown, + ctx: ExtensionContext, + ) => Promise | void; + onMessageStart: (event: unknown, ctx: ExtensionContext) => Promise; + onMessageUpdate: (event: unknown, ctx: ExtensionContext) => Promise; + onAgentEnd: (event: unknown, ctx: ExtensionContext) => Promise; +} + +export function registerTelegramLifecycleHooks( + pi: ExtensionAPI, + deps: TelegramLifecycleRegistrationDeps, +): void { + pi.on("session_start", async (event, ctx) => { + await deps.onSessionStart(event, ctx); + }); + pi.on("session_shutdown", async (event, ctx) => { + await deps.onSessionShutdown(event, ctx); + }); + pi.on("before_agent_start", (async (event: unknown, ctx: ExtensionContext) => + deps.onBeforeAgentStart(event, ctx)) as never); + pi.on("model_select", async (event, ctx) => { + await deps.onModelSelect(event, ctx); + }); + pi.on("agent_start", async (event, ctx) => { + await deps.onAgentStart(event, ctx); + }); + pi.on("tool_execution_start", async (event, ctx) => { + await deps.onToolExecutionStart(event, ctx); + }); + pi.on("tool_execution_end", async (event, ctx) => { + await deps.onToolExecutionEnd(event, ctx); + }); + pi.on("message_start", async (event, ctx) => { + await deps.onMessageStart(event, ctx); + }); + pi.on("message_update", async (event, ctx) => { + await deps.onMessageUpdate(event, ctx); + }); + pi.on("agent_end", async (event, ctx) => { + await deps.onAgentEnd(event, ctx); + }); +} diff --git a/lib/rendering.ts b/lib/rendering.ts new file mode 100644 index 0000000..834635a --- /dev/null +++ b/lib/rendering.ts @@ -0,0 +1,697 @@ +/** + * Telegram preview and markdown rendering helpers + * Converts assistant output into Telegram-safe plain text and HTML chunks with chunk-boundary handling + */ + +export const MAX_MESSAGE_LENGTH = 4096; + +// --- Escaping --- + +function escapeHtml(text: string): string { + return text + .replace(/&/g, "&") + .replace(//g, ">"); +} + +// --- Plain Preview Rendering --- + +function splitPlainMarkdownLine(line: string, maxLength = 1500): string[] { + if (line.length <= maxLength) return [line]; + const words = line.split(/\s+/).filter(Boolean); + if (words.length === 0) return [line]; + const parts: string[] = []; + let current = ""; + for (const word of words) { + const candidate = current.length === 0 ? word : `${current} ${word}`; + if (candidate.length <= maxLength) { + current = candidate; + continue; + } + if (current.length > 0) { + parts.push(current); + current = ""; + } + if (word.length <= maxLength) { + current = word; + continue; + } + for (let i = 0; i < word.length; i += maxLength) { + parts.push(word.slice(i, i + maxLength)); + } + } + if (current.length > 0) { + parts.push(current); + } + return parts.length > 0 ? parts : [line]; +} + +function stripInlineMarkdownToPlainText(text: string): string { + let result = text; + result = result.replace(/!\[([^\]]*)\]\((https?:\/\/[^\s)]+)\)/g, "$1"); + result = result.replace(/\[([^\]]+)\]\((https?:\/\/[^\s)]+)\)/g, "$1"); + result = result.replace(/<((?:https?:\/\/|mailto:)[^>]+)>/g, "$1"); + result = result.replace(/`([^`\n]+)`/g, "$1"); + result = result.replace(/(\*\*\*|___)(.+?)\1/g, "$2"); + result = result.replace(/(\*\*|__)(.+?)\1/g, "$2"); + result = result.replace(/(\*|_)(.+?)\1/g, "$2"); + result = result.replace(/~~(.+?)~~/g, "$1"); + result = result.replace(/\\([\\`*_{}\[\]()#+\-.!>~|])/g, "$1"); + return result; +} + +function isMarkdownTableSeparator(line: string): boolean { + return /^\s*\|?(?:\s*:?-{3,}:?\s*\|)+\s*:?-{3,}:?\s*\|?\s*$/.test(line); +} + +function parseMarkdownFence( + line: string, +): { marker: "`" | "~"; length: number; info?: string } | undefined { + const match = line.match(/^\s*([`~]{3,})(.*)$/); + if (!match) return undefined; + const fence = match[1] ?? ""; + const marker = fence[0]; + if ((marker !== "`" && marker !== "~") || /[^`~]/.test(fence)) { + return undefined; + } + if (!fence.split("").every((char) => char === marker)) return undefined; + return { + marker, + length: fence.length, + info: (match[2] ?? "").trim() || undefined, + }; +} + +function isFencedCodeStart(line: string): boolean { + return parseMarkdownFence(line) !== undefined; +} + +function isMatchingMarkdownFence( + line: string, + fence: { marker: "`" | "~"; length: number }, +): boolean { + const match = line.match(/^\s*([`~]{3,})\s*$/); + if (!match) return false; + const candidate = match[1] ?? ""; + return ( + candidate.length >= fence.length && + candidate[0] === fence.marker && + candidate.split("").every((char) => char === fence.marker) + ); +} + +function isIndentedCodeLine(line: string): boolean { + return /^(?:\t| {4,})/.test(line); +} + +function isIndentedMarkdownStructureLine(line: string): boolean { + const trimmed = line.trimStart(); + return ( + /^(?:[-*+]|\d+\.)\s+\[([ xX])\]\s+/.test(trimmed) || + /^(?:[-*+]|\d+\.)\s+/.test(trimmed) || + /^>\s?/.test(trimmed) || + /^#{1,6}\s+/.test(trimmed) || + parseMarkdownFence(trimmed) !== undefined + ); +} + +function canStartIndentedCodeBlock(lines: string[], index: number): boolean { + const line = lines[index] ?? ""; + if (!isIndentedCodeLine(line)) return false; + if (isIndentedMarkdownStructureLine(line)) return false; + if (index === 0) return true; + return (lines[index - 1] ?? "").trim().length === 0; +} + +function stripIndentedCodePrefix(line: string): string { + if (line.startsWith("\t")) return line.slice(1); + if (line.startsWith(" ")) return line.slice(4); + return line; +} + +export function renderMarkdownPreviewText(markdown: string): string { + const normalized = markdown.replace(/\r\n/g, "\n").trim(); + if (normalized.length === 0) return ""; + const output: string[] = []; + const lines = normalized.split("\n"); + let activeFence: { marker: "`" | "~"; length: number } | undefined; + for (const rawLine of lines) { + const line = rawLine ?? ""; + const fence = parseMarkdownFence(line); + if (activeFence) { + if (fence && isMatchingMarkdownFence(line, activeFence)) { + activeFence = undefined; + continue; + } + if (line.trim().length === 0) { + if (output.at(-1) !== "") output.push(""); + continue; + } + output.push(line); + continue; + } + if (fence) { + activeFence = { marker: fence.marker, length: fence.length }; + continue; + } + if (line.trim().length === 0) { + if (output.at(-1) !== "") output.push(""); + continue; + } + if (isMarkdownTableSeparator(line)) { + continue; + } + const heading = line.match(/^\s*#{1,6}\s+(.+)$/); + if (heading) { + output.push(stripInlineMarkdownToPlainText(heading[1] ?? "")); + continue; + } + const task = line.match(/^(\s*)([-*+]|\d+\.)\s+\[([ xX])\]\s+(.+)$/); + if (task) { + const indent = " ".repeat((task[1] ?? "").length); + const marker = (task[3] ?? " ").toLowerCase() === "x" ? "[x]" : "[ ]"; + output.push( + `${indent}${marker} ${stripInlineMarkdownToPlainText(task[4] ?? "")}`, + ); + continue; + } + const bullet = line.match(/^(\s*)[-*+]\s+(.+)$/); + if (bullet) { + output.push( + `${" ".repeat((bullet[1] ?? "").length)}- ${stripInlineMarkdownToPlainText(bullet[2] ?? "")}`, + ); + continue; + } + const numbered = line.match(/^(\s*\d+\.)\s+(.+)$/); + if (numbered) { + output.push( + `${numbered[1]} ${stripInlineMarkdownToPlainText(numbered[2] ?? "")}`, + ); + continue; + } + const quote = line.match(/^\s*>\s?(.+)$/); + if (quote) { + output.push(`> ${stripInlineMarkdownToPlainText(quote[1] ?? "")}`); + continue; + } + if (/^\s*([-*_]\s*){3,}\s*$/.test(line)) { + output.push("────────"); + continue; + } + output.push(stripInlineMarkdownToPlainText(line)); + } + return output.join("\n"); +} + +// --- Rich Markdown Rendering --- + +function renderDelimitedInlineStyle( + text: string, + delimiter: string, + render: (content: string) => string, +): string { + const escapedDelimiter = delimiter.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + const pattern = new RegExp( + `(^|[^\\p{L}\\p{N}\\\\])(${escapedDelimiter})(?=\\S)(.+?)(?<=\\S)\\2(?=[^\\p{L}\\p{N}]|$)`, + "gu", + ); + return text.replace( + pattern, + (_match, prefix: string, _wrapped: string, content: string) => { + return `${prefix}${render(content)}`; + }, + ); +} + +function renderInlineMarkdown(text: string): string { + const tokens: string[] = []; + const makeToken = (html: string): string => { + const token = `\uE000${tokens.length}\uE001`; + tokens.push(html); + return token; + }; + let result = text; + result = result.replace( + /!\[([^\]]*)\]\((https?:\/\/[^\s)]+)\)/g, + (_match, alt: string, url: string) => { + const label = alt.trim().length > 0 ? alt : url; + return makeToken(`${escapeHtml(label)}`); + }, + ); + result = result.replace( + /\[([^\]]+)\]\((https?:\/\/[^\s)]+)\)/g, + (_match, label: string, url: string) => { + return makeToken(`${escapeHtml(label)}`); + }, + ); + result = result.replace( + /<((?:https?:\/\/|mailto:)[^>]+)>/g, + (_match, url: string) => { + return makeToken(`${escapeHtml(url)}`); + }, + ); + result = result.replace(/`([^`\n]+)`/g, (_match, code: string) => { + return makeToken(`${escapeHtml(code)}`); + }); + result = escapeHtml(result); + result = renderDelimitedInlineStyle(result, "***", (content) => { + return `${content}`; + }); + result = renderDelimitedInlineStyle(result, "___", (content) => { + return `${content}`; + }); + result = renderDelimitedInlineStyle(result, "~~", (content) => { + return `${content}`; + }); + result = renderDelimitedInlineStyle(result, "**", (content) => { + return `${content}`; + }); + result = renderDelimitedInlineStyle(result, "__", (content) => { + return `${content}`; + }); + result = renderDelimitedInlineStyle(result, "*", (content) => { + return `${content}`; + }); + result = renderDelimitedInlineStyle(result, "_", (content) => { + return `${content}`; + }); + result = result.replace( + /(^|[\s>(])(\[(?: |x|X)\])(?=($|[\s<).,:;!?]))/g, + (_match, prefix: string, checkbox: string) => { + const normalized = checkbox.toLowerCase() === "[x]" ? "[x]" : "[ ]"; + return `${prefix}${normalized}`; + }, + ); + result = result.replace(/\\([\\`*_{}\[\]()#+\-.!>~|])/g, "$1"); + return result.replace( + /\uE000(\d+)\uE001/g, + (_match, index: string) => tokens[Number(index)] ?? "", + ); +} + +function buildListIndent(level: number): string { + return "\u00A0".repeat(Math.max(0, level) * 2); +} + +function parseMarkdownTableRow(line: string): string[] { + const trimmed = line.trim().replace(/^\|/, "").replace(/\|$/, ""); + return trimmed + .split("|") + .map((cell) => stripInlineMarkdownToPlainText(cell.trim())); +} + +function parseMarkdownQuoteLine( + line: string, +): { depth: number; content: string } | undefined { + const match = line.match(/^\s*((?:>\s*)+)(.*)$/); + if (!match) return undefined; + const markers = match[1] ?? ""; + const depth = (markers.match(/>/g) ?? []).length; + return { + depth, + content: match[2] ?? "", + }; +} + +function renderMarkdownTextLines(block: string): string[] { + const rendered: string[] = []; + const lines = block.split("\n"); + for (const line of lines) { + if (line.trim().length === 0) continue; + const pieces = splitPlainMarkdownLine(line); + for (const piece of pieces) { + const heading = piece.match(/^(\s*)#{1,6}\s+(.+)$/); + if (heading) { + rendered.push( + `${buildListIndent(Math.floor((heading[1] ?? "").length / 2))}${renderInlineMarkdown(heading[2] ?? "")}`, + ); + continue; + } + const task = piece.match(/^(\s*)([-*+]|\d+\.)\s+\[([ xX])\]\s+(.+)$/); + if (task) { + const indent = buildListIndent(Math.floor((task[1] ?? "").length / 2)); + const marker = (task[3] ?? " ").toLowerCase() === "x" ? "[x]" : "[ ]"; + rendered.push( + `${indent}${marker} ${renderInlineMarkdown(task[4] ?? "")}`, + ); + continue; + } + const bullet = piece.match(/^(\s*)[-*+]\s+(.+)$/); + if (bullet) { + const indent = buildListIndent( + Math.floor((bullet[1] ?? "").length / 2), + ); + rendered.push( + `${indent}- ${renderInlineMarkdown(bullet[2] ?? "")}`, + ); + continue; + } + const numbered = piece.match(/^(\s*)(\d+)\.\s+(.+)$/); + if (numbered) { + const indent = buildListIndent( + Math.floor((numbered[1] ?? "").length / 2), + ); + rendered.push( + `${indent}${numbered[2]}. ${renderInlineMarkdown(numbered[3] ?? "")}`, + ); + continue; + } + const quote = piece.match(/^>\s?(.+)$/); + if (quote) { + rendered.push( + `
${renderInlineMarkdown(quote[1] ?? "")}
`, + ); + continue; + } + const trimmed = piece.trim(); + if (/^([-*_]\s*){3,}$/.test(trimmed)) { + rendered.push("────────────"); + continue; + } + rendered.push(renderInlineMarkdown(piece)); + } + } + return rendered; +} + +function renderMarkdownCodeBlock(code: string, language?: string): string[] { + const open = language + ? `
`
+    : "
";
+  const close = "
"; + const maxContentLength = MAX_MESSAGE_LENGTH - open.length - close.length; + const chunks: string[] = []; + let current = ""; + const pushCurrent = (): void => { + if (current.length === 0) return; + chunks.push(`${open}${current}${close}`); + current = ""; + }; + const appendEscapedLine = (escapedLine: string): void => { + if (escapedLine.length <= maxContentLength) { + const candidate = + current.length === 0 ? escapedLine : `${current}\n${escapedLine}`; + if (candidate.length <= maxContentLength) { + current = candidate; + return; + } + pushCurrent(); + current = escapedLine; + return; + } + pushCurrent(); + for (let i = 0; i < escapedLine.length; i += maxContentLength) { + chunks.push( + `${open}${escapedLine.slice(i, i + maxContentLength)}${close}`, + ); + } + }; + for (const line of code.split("\n")) { + appendEscapedLine(escapeHtml(line)); + } + pushCurrent(); + return chunks.length > 0 ? chunks : [`${open}${close}`]; +} + +function renderMarkdownTableBlock(lines: string[]): string[] { + const rows = lines.map(parseMarkdownTableRow); + const columnCount = Math.max(...rows.map((row) => row.length), 0); + const normalizedRows = rows.map((row) => { + const next = [...row]; + while (next.length < columnCount) { + next.push(""); + } + return next; + }); + const widths = Array.from({ length: columnCount }, (_, columnIndex) => { + return Math.max( + 3, + ...normalizedRows.map((row) => (row[columnIndex] ?? "").length), + ); + }); + const formatRow = (row: string[]): string => { + return row + .map((cell, columnIndex) => (cell ?? "").padEnd(widths[columnIndex] ?? 3)) + .join(" | "); + }; + const separator = widths.map((width) => "-".repeat(width)).join(" | "); + const [header, ...body] = normalizedRows; + const tableLines = [ + formatRow(header ?? []), + separator, + ...body.map(formatRow), + ]; + return renderMarkdownCodeBlock(tableLines.join("\n"), "markdown"); +} + +function chunkRenderedHtmlLines( + lines: string[], + wrapper?: { open: string; close: string }, +): string[] { + if (lines.length === 0) return []; + const open = wrapper?.open ?? ""; + const close = wrapper?.close ?? ""; + const maxContentLength = MAX_MESSAGE_LENGTH - open.length - close.length; + const chunks: string[] = []; + let current = ""; + const pushCurrent = (): void => { + if (current.length === 0) return; + chunks.push(`${open}${current}${close}`); + current = ""; + }; + for (const line of lines) { + const candidate = current.length === 0 ? line : `${current}\n${line}`; + if (candidate.length <= maxContentLength) { + current = candidate; + continue; + } + pushCurrent(); + if (line.length <= maxContentLength) { + current = line; + continue; + } + for (let i = 0; i < line.length; i += maxContentLength) { + chunks.push(`${open}${line.slice(i, i + maxContentLength)}${close}`); + } + } + pushCurrent(); + return chunks; +} + +function renderMarkdownTextBlock(block: string): string[] { + return chunkRenderedHtmlLines(renderMarkdownTextLines(block)); +} + +function renderMarkdownQuoteBlock(lines: string[]): string[] { + const inner = lines + .map((line) => { + const parsed = parseMarkdownQuoteLine(line); + if (!parsed) return line; + const nestedIndent = "\u00A0".repeat(Math.max(0, parsed.depth - 1) * 2); + return `${nestedIndent}${parsed.content}`; + }) + .join("\n"); + return chunkRenderedHtmlLines(renderMarkdownTextLines(inner), { + open: "
", + close: "
", + }); +} + +function renderMarkdownToTelegramHtmlChunks(markdown: string): string[] { + const normalized = markdown.replace(/\r\n/g, "\n").trim(); + if (normalized.length === 0) return []; + const renderedBlocks: string[] = []; + const lines = normalized.split("\n"); + let index = 0; + while (index < lines.length) { + const line = lines[index] ?? ""; + const nextLine = lines[index + 1] ?? ""; + const fence = parseMarkdownFence(line); + if (fence) { + index += 1; + const codeLines: string[] = []; + while ( + index < lines.length && + !isMatchingMarkdownFence(lines[index] ?? "", fence) + ) { + codeLines.push(lines[index] ?? ""); + index += 1; + } + if (index < lines.length) { + index += 1; + } + renderedBlocks.push( + ...renderMarkdownCodeBlock(codeLines.join("\n"), fence.info), + ); + while (index < lines.length && (lines[index] ?? "").trim().length === 0) { + index += 1; + } + continue; + } + if (line.trim().length === 0) { + index += 1; + continue; + } + if (line.includes("|") && isMarkdownTableSeparator(nextLine)) { + const tableLines: string[] = [line]; + index += 2; + while (index < lines.length) { + const tableLine = lines[index] ?? ""; + if (tableLine.trim().length === 0 || !tableLine.includes("|")) { + break; + } + tableLines.push(tableLine); + index += 1; + } + renderedBlocks.push(...renderMarkdownTableBlock(tableLines)); + continue; + } + if (canStartIndentedCodeBlock(lines, index)) { + const codeLines: string[] = []; + while (index < lines.length) { + const rawLine = lines[index] ?? ""; + if (rawLine.trim().length === 0) { + codeLines.push(""); + index += 1; + continue; + } + if (!isIndentedCodeLine(rawLine)) break; + codeLines.push(stripIndentedCodePrefix(rawLine)); + index += 1; + } + renderedBlocks.push(...renderMarkdownCodeBlock(codeLines.join("\n"))); + continue; + } + if (/^\s*>/.test(line)) { + const quoteLines: string[] = []; + while (index < lines.length && /^\s*>/.test(lines[index] ?? "")) { + quoteLines.push(lines[index] ?? ""); + index += 1; + } + renderedBlocks.push(...renderMarkdownQuoteBlock(quoteLines)); + continue; + } + const textLines: string[] = []; + while (index < lines.length) { + const current = lines[index] ?? ""; + const following = lines[index + 1] ?? ""; + if (current.trim().length === 0) break; + if ( + isFencedCodeStart(current) || + canStartIndentedCodeBlock(lines, index) || + /^\s*>/.test(current) + ) + break; + if (current.includes("|") && isMarkdownTableSeparator(following)) break; + textLines.push(current); + index += 1; + } + renderedBlocks.push(...renderMarkdownTextBlock(textLines.join("\n"))); + } + const chunks: string[] = []; + let current = ""; + for (const block of renderedBlocks) { + const candidate = current.length === 0 ? block : `${current}\n\n${block}`; + if (candidate.length <= MAX_MESSAGE_LENGTH) { + current = candidate; + continue; + } + if (current.length > 0) { + chunks.push(current); + current = ""; + } + if (block.length <= MAX_MESSAGE_LENGTH) { + current = block; + continue; + } + for (let i = 0; i < block.length; i += MAX_MESSAGE_LENGTH) { + chunks.push(block.slice(i, i + MAX_MESSAGE_LENGTH)); + } + } + if (current.length > 0) { + chunks.push(current); + } + return chunks; +} + +// --- Unified Telegram Rendering --- + +export type TelegramRenderMode = "plain" | "markdown" | "html"; + +export interface TelegramRenderedChunk { + text: string; + parseMode?: "HTML"; +} + +function chunkParagraphs(text: string): string[] { + if (text.length <= MAX_MESSAGE_LENGTH) return [text]; + const normalized = text.replace(/\r\n/g, "\n"); + const paragraphs = normalized.split(/\n\n+/); + const chunks: string[] = []; + let current = ""; + const flushCurrent = (): void => { + if (current.trim().length > 0) chunks.push(current); + current = ""; + }; + const splitLongBlock = (block: string): string[] => { + if (block.length <= MAX_MESSAGE_LENGTH) return [block]; + const lines = block.split("\n"); + const lineChunks: string[] = []; + let lineCurrent = ""; + for (const line of lines) { + const candidate = + lineCurrent.length === 0 ? line : `${lineCurrent}\n${line}`; + if (candidate.length <= MAX_MESSAGE_LENGTH) { + lineCurrent = candidate; + continue; + } + if (lineCurrent.length > 0) { + lineChunks.push(lineCurrent); + lineCurrent = ""; + } + if (line.length <= MAX_MESSAGE_LENGTH) { + lineCurrent = line; + continue; + } + for (let i = 0; i < line.length; i += MAX_MESSAGE_LENGTH) { + lineChunks.push(line.slice(i, i + MAX_MESSAGE_LENGTH)); + } + } + if (lineCurrent.length > 0) { + lineChunks.push(lineCurrent); + } + return lineChunks; + }; + for (const paragraph of paragraphs) { + if (paragraph.length === 0) continue; + const parts = splitLongBlock(paragraph); + for (const part of parts) { + const candidate = current.length === 0 ? part : `${current}\n\n${part}`; + if (candidate.length <= MAX_MESSAGE_LENGTH) { + current = candidate; + } else { + flushCurrent(); + current = part; + } + } + } + flushCurrent(); + return chunks; +} + +export function renderTelegramMessage( + text: string, + options?: { mode?: TelegramRenderMode }, +): TelegramRenderedChunk[] { + const mode = options?.mode ?? "plain"; + if (mode === "plain") { + return chunkParagraphs(text).map((chunk) => ({ text: chunk })); + } + if (mode === "html") { + return [{ text, parseMode: "HTML" }]; + } + return renderMarkdownToTelegramHtmlChunks(text).map((chunk) => ({ + text: chunk, + parseMode: "HTML", + })); +} diff --git a/lib/replies.ts b/lib/replies.ts new file mode 100644 index 0000000..8c6a63a --- /dev/null +++ b/lib/replies.ts @@ -0,0 +1,313 @@ +/** + * Telegram reply and preview domain helpers + * Owns preview text decisions, preview runtime behavior, rendered-message delivery, and plain or markdown reply sending + */ + +import type { TelegramRenderedChunk, TelegramRenderMode } from "./rendering.ts"; + +// --- Preview --- + +export interface TelegramPreviewStateLike { + mode: "draft" | "message"; + draftId?: number; + messageId?: number; + pendingText: string; + lastSentText: string; +} + +export interface TelegramPreviewRuntimeState extends TelegramPreviewStateLike { + flushTimer?: ReturnType; +} + +export interface TelegramPreviewRuntimeDeps { + getState: () => TelegramPreviewRuntimeState | undefined; + setState: (state: TelegramPreviewRuntimeState | undefined) => void; + clearScheduledFlush: (state: TelegramPreviewRuntimeState) => void; + maxMessageLength: number; + renderPreviewText: (markdown: string) => string; + getDraftSupport: () => "unknown" | "supported" | "unsupported"; + setDraftSupport: (support: "unknown" | "supported" | "unsupported") => void; + allocateDraftId: () => number; + sendDraft: (chatId: number, draftId: number, text: string) => Promise; + sendMessage: ( + chatId: number, + text: string, + ) => Promise; + editMessageText: ( + chatId: number, + messageId: number, + text: string, + ) => Promise; + renderTelegramMessage: ( + text: string, + options?: { mode?: TelegramRenderMode }, + ) => TelegramRenderedChunk[]; + sendRenderedChunks: ( + chatId: number, + chunks: TelegramRenderedChunk[], + ) => Promise; + editRenderedMessage: ( + chatId: number, + messageId: number, + chunks: TelegramRenderedChunk[], + ) => Promise; +} + +export function buildTelegramPreviewFlushText(options: { + state: TelegramPreviewStateLike; + maxMessageLength: number; + renderPreviewText: (markdown: string) => string; +}): string | undefined { + const rawText = options.state.pendingText.trim(); + const previewText = options.renderPreviewText(rawText).trim(); + if (!previewText || previewText === options.state.lastSentText) { + return undefined; + } + return previewText.length > options.maxMessageLength + ? previewText.slice(0, options.maxMessageLength) + : previewText; +} + +export function buildTelegramPreviewFinalText( + state: TelegramPreviewStateLike, +): string | undefined { + const finalText = (state.pendingText.trim() || state.lastSentText).trim(); + return finalText || undefined; +} + +export function shouldUseTelegramDraftPreview(options: { + draftSupport: "unknown" | "supported" | "unsupported"; +}): boolean { + return options.draftSupport !== "unsupported"; +} + +export async function clearTelegramPreview( + chatId: number, + deps: TelegramPreviewRuntimeDeps, +): Promise { + const state = deps.getState(); + if (!state) return; + deps.clearScheduledFlush(state); + deps.setState(undefined); + if (state.mode !== "draft" || state.draftId === undefined) return; + try { + await deps.sendDraft(chatId, state.draftId, ""); + } catch { + // ignore + } +} + +export async function flushTelegramPreview( + chatId: number, + deps: TelegramPreviewRuntimeDeps, +): Promise { + const state = deps.getState(); + if (!state) return; + state.flushTimer = undefined; + const truncated = buildTelegramPreviewFlushText({ + state, + maxMessageLength: deps.maxMessageLength, + renderPreviewText: deps.renderPreviewText, + }); + if (!truncated) return; + if (shouldUseTelegramDraftPreview({ draftSupport: deps.getDraftSupport() })) { + const draftId = state.draftId ?? deps.allocateDraftId(); + state.draftId = draftId; + try { + await deps.sendDraft(chatId, draftId, truncated); + deps.setDraftSupport("supported"); + state.mode = "draft"; + state.lastSentText = truncated; + return; + } catch { + deps.setDraftSupport("unsupported"); + } + } + if (state.messageId === undefined) { + const sent = await deps.sendMessage(chatId, truncated); + state.messageId = sent.message_id; + state.mode = "message"; + state.lastSentText = truncated; + return; + } + await deps.editMessageText(chatId, state.messageId, truncated); + state.mode = "message"; + state.lastSentText = truncated; +} + +export async function finalizeTelegramPreview( + chatId: number, + deps: TelegramPreviewRuntimeDeps, +): Promise { + const state = deps.getState(); + if (!state) return false; + await flushTelegramPreview(chatId, deps); + const finalText = buildTelegramPreviewFinalText(state); + if (!finalText) { + await clearTelegramPreview(chatId, deps); + return false; + } + if (state.mode === "draft") { + await deps.sendMessage(chatId, finalText); + await clearTelegramPreview(chatId, deps); + return true; + } + deps.setState(undefined); + return state.messageId !== undefined; +} + +export async function finalizeTelegramMarkdownPreview( + chatId: number, + markdown: string, + deps: TelegramPreviewRuntimeDeps, +): Promise { + const state = deps.getState(); + if (!state) return false; + await flushTelegramPreview(chatId, deps); + const chunks = deps.renderTelegramMessage(markdown, { mode: "markdown" }); + if (chunks.length === 0) { + await clearTelegramPreview(chatId, deps); + return false; + } + if (state.mode === "draft") { + await deps.sendRenderedChunks(chatId, chunks); + await clearTelegramPreview(chatId, deps); + return true; + } + if (state.messageId === undefined) return false; + await deps.editRenderedMessage(chatId, state.messageId, chunks); + deps.setState(undefined); + return true; +} + +// --- Delivery --- + +export interface TelegramSentMessageLike { + message_id: number; +} + +export interface TelegramReplyDeliveryDeps { + sendMessage: (body: { + chat_id: number; + text: string; + parse_mode?: "HTML"; + reply_markup?: TReplyMarkup; + }) => Promise; + editMessage: (body: { + chat_id: number; + message_id: number; + text: string; + parse_mode?: "HTML"; + reply_markup?: TReplyMarkup; + }) => Promise; +} + +export interface TelegramReplyTransport { + sendRenderedChunks: ( + chatId: number, + chunks: TelegramRenderedChunk[], + options?: { replyMarkup?: TReplyMarkup }, + ) => Promise; + editRenderedMessage: ( + chatId: number, + messageId: number, + chunks: TelegramRenderedChunk[], + options?: { replyMarkup?: TReplyMarkup }, + ) => Promise; +} + +export function buildTelegramReplyTransport( + deps: TelegramReplyDeliveryDeps, +): TelegramReplyTransport { + return { + sendRenderedChunks: async (chatId, chunks, options) => { + return sendTelegramRenderedChunks(chatId, chunks, deps, options); + }, + editRenderedMessage: async (chatId, messageId, chunks, options) => { + return editTelegramRenderedMessage( + chatId, + messageId, + chunks, + deps, + options, + ); + }, + }; +} + +export async function sendTelegramRenderedChunks( + chatId: number, + chunks: TelegramRenderedChunk[], + deps: TelegramReplyDeliveryDeps, + options?: { replyMarkup?: TReplyMarkup }, +): Promise { + let lastMessageId: number | undefined; + for (const [index, chunk] of chunks.entries()) { + const sent = await deps.sendMessage({ + chat_id: chatId, + text: chunk.text, + parse_mode: chunk.parseMode, + reply_markup: + index === chunks.length - 1 ? options?.replyMarkup : undefined, + }); + lastMessageId = sent.message_id; + } + return lastMessageId; +} + +export async function editTelegramRenderedMessage( + chatId: number, + messageId: number, + chunks: TelegramRenderedChunk[], + deps: TelegramReplyDeliveryDeps, + options?: { replyMarkup?: TReplyMarkup }, +): Promise { + if (chunks.length === 0) return messageId; + const [firstChunk, ...remainingChunks] = chunks; + await deps.editMessage({ + chat_id: chatId, + message_id: messageId, + text: firstChunk.text, + parse_mode: firstChunk.parseMode, + reply_markup: + remainingChunks.length === 0 ? options?.replyMarkup : undefined, + }); + if (remainingChunks.length > 0) { + return sendTelegramRenderedChunks(chatId, remainingChunks, deps, options); + } + return messageId; +} + +// --- Reply Runtime --- + +export interface TelegramReplyRuntimeDeps { + renderTelegramMessage: ( + text: string, + options?: { mode?: TelegramRenderMode }, + ) => TelegramRenderedChunk[]; + sendRenderedChunks: ( + chunks: TelegramRenderedChunk[], + ) => Promise; +} + +export async function sendTelegramPlainReply( + text: string, + deps: TelegramReplyRuntimeDeps, + options?: { parseMode?: "HTML" }, +): Promise { + const chunks = deps.renderTelegramMessage(text, { + mode: options?.parseMode === "HTML" ? "html" : "plain", + }); + return deps.sendRenderedChunks(chunks); +} + +export async function sendTelegramMarkdownReply( + markdown: string, + deps: TelegramReplyRuntimeDeps, +): Promise { + const chunks = deps.renderTelegramMessage(markdown, { mode: "markdown" }); + if (chunks.length === 0) { + return sendTelegramPlainReply(markdown, deps); + } + return deps.sendRenderedChunks(chunks); +} diff --git a/lib/setup.ts b/lib/setup.ts new file mode 100644 index 0000000..3a380dd --- /dev/null +++ b/lib/setup.ts @@ -0,0 +1,41 @@ +/** + * Telegram setup prompt helpers + * Computes token-prefill defaults and prompt mode selection for /telegram-setup + */ + +export interface TelegramBotTokenPromptSpec { + method: "input" | "editor"; + value: string; +} + +export const TELEGRAM_BOT_TOKEN_INPUT_PLACEHOLDER = "123456:ABCDEF..."; +export const TELEGRAM_BOT_TOKEN_ENV_VARS = [ + "TELEGRAM_BOT_TOKEN", + "TELEGRAM_BOT_KEY", + "TELEGRAM_TOKEN", + "TELEGRAM_KEY", +] as const; + +export function getTelegramBotTokenInputDefault( + env: NodeJS.ProcessEnv = process.env, + configToken?: string, +): string { + const trimmedConfigToken = configToken?.trim(); + if (trimmedConfigToken) return trimmedConfigToken; + for (const key of TELEGRAM_BOT_TOKEN_ENV_VARS) { + const value = env[key]?.trim(); + if (value) return value; + } + return TELEGRAM_BOT_TOKEN_INPUT_PLACEHOLDER; +} + +export function getTelegramBotTokenPromptSpec( + env: NodeJS.ProcessEnv = process.env, + configToken?: string, +): TelegramBotTokenPromptSpec { + const value = getTelegramBotTokenInputDefault(env, configToken); + return { + method: value === TELEGRAM_BOT_TOKEN_INPUT_PLACEHOLDER ? "input" : "editor", + value, + }; +} diff --git a/lib/status.ts b/lib/status.ts new file mode 100644 index 0000000..7de4cfd --- /dev/null +++ b/lib/status.ts @@ -0,0 +1,109 @@ +/** + * Telegram status rendering helpers + * Builds usage, cost, and context summaries for the interactive Telegram status view + */ + +import type { Model } from "@mariozechner/pi-ai"; +import type { ExtensionContext } from "@mariozechner/pi-coding-agent"; + +export interface TelegramUsageStats { + totalInput: number; + totalOutput: number; + totalCacheRead: number; + totalCacheWrite: number; + totalCost: number; +} + +function escapeHtml(text: string): string { + return text + .replace(/&/g, "&") + .replace(//g, ">"); +} + +function formatTokens(count: number): string { + if (count < 1000) return count.toString(); + if (count < 10000) return `${(count / 1000).toFixed(1)}k`; + if (count < 1000000) return `${Math.round(count / 1000)}k`; + if (count < 10000000) return `${(count / 1000000).toFixed(1)}M`; + return `${Math.round(count / 1000000)}M`; +} + +export function collectUsageStats(ctx: ExtensionContext): TelegramUsageStats { + const stats: TelegramUsageStats = { + totalInput: 0, + totalOutput: 0, + totalCacheRead: 0, + totalCacheWrite: 0, + totalCost: 0, + }; + for (const entry of ctx.sessionManager.getEntries()) { + if (entry.type !== "message" || entry.message.role !== "assistant") { + continue; + } + stats.totalInput += entry.message.usage.input; + stats.totalOutput += entry.message.usage.output; + stats.totalCacheRead += entry.message.usage.cacheRead; + stats.totalCacheWrite += entry.message.usage.cacheWrite; + stats.totalCost += entry.message.usage.cost.total; + } + return stats; +} + +function buildStatusRow(label: string, value: string): string { + return `${escapeHtml(label)}: ${escapeHtml(value)}`; +} + +function buildUsageSummary(stats: TelegramUsageStats): string | undefined { + const tokenParts: string[] = []; + if (stats.totalInput) tokenParts.push(`↑${formatTokens(stats.totalInput)}`); + if (stats.totalOutput) tokenParts.push(`↓${formatTokens(stats.totalOutput)}`); + if (stats.totalCacheRead) + tokenParts.push(`R${formatTokens(stats.totalCacheRead)}`); + if (stats.totalCacheWrite) + tokenParts.push(`W${formatTokens(stats.totalCacheWrite)}`); + return tokenParts.length > 0 ? tokenParts.join(" ") : undefined; +} + +function buildCostSummary( + stats: TelegramUsageStats, + usesSubscription: boolean, +): string | undefined { + if (!stats.totalCost && !usesSubscription) return undefined; + return `$${stats.totalCost.toFixed(3)}${usesSubscription ? " (sub)" : ""}`; +} + +function buildContextSummary( + ctx: ExtensionContext, + activeModel: Model | undefined, +): string { + const usage = ctx.getContextUsage(); + if (!usage) return "unknown"; + const contextWindow = usage.contextWindow ?? activeModel?.contextWindow ?? 0; + const percent = usage.percent !== null ? `${usage.percent.toFixed(1)}%` : "?"; + return `${percent}/${formatTokens(contextWindow)}`; +} + +export function buildStatusHtml( + ctx: ExtensionContext, + activeModel: Model | undefined, +): string { + const stats = collectUsageStats(ctx); + const usesSubscription = activeModel + ? ctx.modelRegistry.isUsingOAuth(activeModel) + : false; + const lines: string[] = []; + const usageSummary = buildUsageSummary(stats); + const costSummary = buildCostSummary(stats, usesSubscription); + if (usageSummary) { + lines.push(buildStatusRow("Usage", usageSummary)); + } + if (costSummary) { + lines.push(buildStatusRow("Cost", costSummary)); + } + lines.push(buildStatusRow("Context", buildContextSummary(ctx, activeModel))); + if (lines.length === 0) { + lines.push(buildStatusRow("Status", "No usage data yet.")); + } + return lines.join("\n"); +} diff --git a/lib/turns.ts b/lib/turns.ts new file mode 100644 index 0000000..2a719e7 --- /dev/null +++ b/lib/turns.ts @@ -0,0 +1,144 @@ +/** + * Telegram turn-building helpers + * Owns prompt-turn summary and content construction so queued Telegram turns are assembled consistently + */ + +import { basename } from "node:path"; + +import type { ImageContent, TextContent } from "@mariozechner/pi-ai"; + +import { + collectTelegramMessageIds, + formatTelegramHistoryText, +} from "./media.ts"; +import type { PendingTelegramTurn } from "./queue.ts"; + +export interface TelegramTurnMessageLike { + message_id: number; + chat: { id: number }; +} + +export interface DownloadedTelegramTurnFileLike { + path: string; + fileName: string; + isImage: boolean; + mimeType?: string; +} + +export function truncateTelegramQueueSummary( + text: string, + maxWords = 5, + maxLength = 40, +): string { + const normalized = text.replace(/\s+/g, " ").trim(); + if (!normalized) return ""; + const words = normalized.split(" "); + let summary = words.slice(0, maxWords).join(" "); + if (summary.length === 0) summary = normalized; + if (summary.length > maxLength) { + summary = summary.slice(0, maxLength).trimEnd(); + } + return summary.length < normalized.length || words.length > maxWords + ? `${summary}…` + : summary; +} + +export function formatTelegramTurnStatusSummary( + rawText: string, + files: DownloadedTelegramTurnFileLike[], +): string { + const textSummary = truncateTelegramQueueSummary(rawText); + if (textSummary) return textSummary; + if (files.length === 1) { + const fileName = basename( + files[0]?.fileName || files[0]?.path || "attachment", + ); + return `πŸ“Ž ${truncateTelegramQueueSummary(fileName, 4, 32) || "attachment"}`; + } + if (files.length > 1) return `πŸ“Ž ${files.length} attachments`; + return "(empty message)"; +} + +export function buildTelegramTurnPrompt(options: { + telegramPrefix: string; + rawText: string; + files: DownloadedTelegramTurnFileLike[]; + historyTurns?: Pick[]; +}): string { + let prompt = options.telegramPrefix; + if ((options.historyTurns?.length ?? 0) > 0) { + prompt += + "\n\nEarlier Telegram messages arrived after an aborted turn. Treat them as prior user messages, in order:"; + for (const [index, turn] of (options.historyTurns ?? []).entries()) { + prompt += `\n\n${index + 1}. ${turn.historyText}`; + } + prompt += "\n\nCurrent Telegram message:"; + } + if (options.rawText.length > 0) { + prompt += + (options.historyTurns?.length ?? 0) > 0 + ? `\n${options.rawText}` + : ` ${options.rawText}`; + } + if (options.files.length > 0) { + prompt += "\n\nTelegram attachments were saved locally:"; + for (const file of options.files) { + prompt += `\n- ${file.path}`; + } + } + return prompt; +} + +export async function buildTelegramPromptTurn(options: { + telegramPrefix: string; + messages: TelegramTurnMessageLike[]; + historyTurns?: PendingTelegramTurn[]; + queueOrder: number; + rawText: string; + files: DownloadedTelegramTurnFileLike[]; + readBinaryFile: (path: string) => Promise; + inferImageMimeType: (path: string) => string | undefined; +}): Promise { + const firstMessage = options.messages[0]; + if (!firstMessage) { + throw new Error("Missing Telegram message for turn creation"); + } + const content: Array = [ + { + type: "text", + text: buildTelegramTurnPrompt({ + telegramPrefix: options.telegramPrefix, + rawText: options.rawText, + files: options.files, + historyTurns: options.historyTurns, + }), + }, + ]; + for (const file of options.files) { + if (!file.isImage) continue; + const mediaType = file.mimeType || options.inferImageMimeType(file.path); + if (!mediaType) continue; + const buffer = await options.readBinaryFile(file.path); + content.push({ + type: "image", + data: Buffer.from(buffer).toString("base64"), + mimeType: mediaType, + }); + } + return { + kind: "prompt", + chatId: firstMessage.chat.id, + replyToMessageId: firstMessage.message_id, + sourceMessageIds: collectTelegramMessageIds(options.messages), + queueOrder: options.queueOrder, + queueLane: "default", + laneOrder: options.queueOrder, + queuedAttachments: [], + content, + historyText: formatTelegramHistoryText(options.rawText, options.files), + statusSummary: formatTelegramTurnStatusSummary( + options.rawText, + options.files, + ), + }; +} diff --git a/lib/updates.ts b/lib/updates.ts new file mode 100644 index 0000000..9d200eb --- /dev/null +++ b/lib/updates.ts @@ -0,0 +1,397 @@ +/** + * Telegram updates domain helpers + * Owns update extraction, authorization, classification, execution planning, and runtime execution for Telegram updates + */ + +import type { ExtensionContext } from "@mariozechner/pi-coding-agent"; + +// --- Extraction --- + +export interface TelegramReactionTypeEmojiLike { + type: "emoji"; + emoji: string; +} + +export interface TelegramReactionTypeNonEmojiLike { + type: string; +} + +export type TelegramReactionTypeLike = + | TelegramReactionTypeEmojiLike + | TelegramReactionTypeNonEmojiLike; + +export interface TelegramUpdateLike { + deleted_business_messages?: { message_ids?: unknown }; + _: string; + messages?: unknown; +} + +function isTelegramMessageIdList(value: unknown): value is number[] { + return Array.isArray(value) && value.every((item) => Number.isInteger(item)); +} + +export function normalizeTelegramReactionEmoji(emoji: string): string { + return emoji.replace(/\uFE0F/g, ""); +} + +export function collectTelegramReactionEmojis( + reactions: TelegramReactionTypeLike[], +): Set { + return new Set( + reactions + .filter( + (reaction): reaction is TelegramReactionTypeEmojiLike => + reaction.type === "emoji", + ) + .map((reaction) => normalizeTelegramReactionEmoji(reaction.emoji)), + ); +} + +export function extractDeletedTelegramMessageIds( + update: TelegramUpdateLike, +): number[] { + const deletedBusinessMessageIds = + update.deleted_business_messages?.message_ids; + if (isTelegramMessageIdList(deletedBusinessMessageIds)) { + return deletedBusinessMessageIds; + } + if ( + update._ === "updateDeleteMessages" && + isTelegramMessageIdList(update.messages) + ) { + return update.messages; + } + return []; +} + +// --- Routing --- + +export interface TelegramUserLike { + id: number; + is_bot: boolean; +} + +export interface TelegramChatLike { + id?: number; + type: string; +} + +export interface TelegramMessageLike { + chat: TelegramChatLike; + from?: TelegramUserLike; + message_id?: number; +} + +export interface TelegramCallbackQueryLike { + id?: string; + from: TelegramUserLike; + message?: TelegramMessageLike; +} + +export interface TelegramUpdateRoutingLike { + message?: TelegramMessageLike; + edited_message?: TelegramMessageLike; + callback_query?: TelegramCallbackQueryLike; +} + +export type TelegramAuthorizationState = + | { kind: "pair"; userId: number } + | { kind: "allow" } + | { kind: "deny" }; + +export function getTelegramAuthorizationState( + userId: number, + allowedUserId?: number, +): TelegramAuthorizationState { + if (allowedUserId === undefined) { + return { kind: "pair", userId }; + } + if (userId === allowedUserId) { + return { kind: "allow" }; + } + return { kind: "deny" }; +} + +export function getAuthorizedTelegramCallbackQuery( + update: TelegramUpdateRoutingLike, +): TelegramCallbackQueryLike | undefined { + const query = update.callback_query; + if (!query) return undefined; + const message = query.message; + if (!message || message.chat.type !== "private" || query.from.is_bot) { + return undefined; + } + return query; +} + +export function getAuthorizedTelegramMessage( + update: TelegramUpdateRoutingLike, +): TelegramMessageLike | undefined { + const message = update.message || update.edited_message; + if ( + !message || + message.chat.type !== "private" || + !message.from || + message.from.is_bot + ) { + return undefined; + } + return message; +} + +// --- Flow --- + +export interface TelegramMessageReactionUpdatedLike { + chat: { type: string }; + user?: TelegramUserLike; +} + +export interface TelegramUpdateFlowLike + extends TelegramUpdateRoutingLike, TelegramUpdateLike { + message_reaction?: TelegramMessageReactionUpdatedLike; +} + +export type TelegramUpdateFlowAction = + | { kind: "ignore" } + | { kind: "deleted"; messageIds: number[] } + | { kind: "reaction"; reactionUpdate: TelegramMessageReactionUpdatedLike } + | { + kind: "callback"; + query: TelegramCallbackQueryLike; + authorization: TelegramAuthorizationState; + } + | { + kind: "message"; + message: TelegramMessageLike & { from: TelegramUserLike }; + authorization: TelegramAuthorizationState; + }; + +export function buildTelegramUpdateFlowAction( + update: TelegramUpdateFlowLike, + allowedUserId?: number, +): TelegramUpdateFlowAction { + const deletedMessageIds = extractDeletedTelegramMessageIds(update); + if (deletedMessageIds.length > 0) { + return { kind: "deleted", messageIds: deletedMessageIds }; + } + if (update.message_reaction) { + return { kind: "reaction", reactionUpdate: update.message_reaction }; + } + const query = getAuthorizedTelegramCallbackQuery(update); + if (query) { + return { + kind: "callback", + query, + authorization: getTelegramAuthorizationState( + query.from.id, + allowedUserId, + ), + }; + } + const message = getAuthorizedTelegramMessage(update); + if (message?.from) { + return { + kind: "message", + message: message as TelegramMessageLike & { from: TelegramUserLike }, + authorization: getTelegramAuthorizationState( + message.from.id, + allowedUserId, + ), + }; + } + return { kind: "ignore" }; +} + +// --- Execution Planning --- + +export type TelegramUpdateExecutionPlan = + | { kind: "ignore" } + | { kind: "deleted"; messageIds: number[] } + | { + kind: "reaction"; + reactionUpdate: NonNullable; + } + | { + kind: "callback"; + query: TelegramCallbackQueryLike; + shouldPair: boolean; + shouldDeny: boolean; + } + | { + kind: "message"; + message: TelegramMessageLike & { from: TelegramUserLike }; + shouldPair: boolean; + shouldNotifyPaired: boolean; + shouldDeny: boolean; + }; + +export function buildTelegramUpdateExecutionPlan( + action: TelegramUpdateFlowAction, +): TelegramUpdateExecutionPlan { + switch (action.kind) { + case "ignore": + return { kind: "ignore" }; + case "deleted": + return { kind: "deleted", messageIds: action.messageIds }; + case "reaction": + return { kind: "reaction", reactionUpdate: action.reactionUpdate }; + case "callback": + return { + kind: "callback", + query: action.query, + shouldPair: action.authorization.kind === "pair", + shouldDeny: action.authorization.kind === "deny", + }; + case "message": + return { + kind: "message", + message: action.message, + shouldPair: action.authorization.kind === "pair", + shouldNotifyPaired: action.authorization.kind === "pair", + shouldDeny: action.authorization.kind === "deny", + }; + } +} + +export function buildTelegramUpdateExecutionPlanFromUpdate( + update: TelegramUpdateFlowLike, + allowedUserId?: number, +): TelegramUpdateExecutionPlan { + return buildTelegramUpdateExecutionPlan( + buildTelegramUpdateFlowAction(update, allowedUserId), + ); +} + +// --- Runtime --- + +export interface TelegramUpdateRuntimeDeps { + ctx: ExtensionContext; + removePendingMediaGroupMessages: (messageIds: number[]) => void; + removeQueuedTelegramTurnsByMessageIds: ( + messageIds: number[], + ctx: ExtensionContext, + ) => number; + handleAuthorizedTelegramReactionUpdate: ( + reactionUpdate: NonNullable< + Extract< + TelegramUpdateExecutionPlan, + { kind: "reaction" } + >["reactionUpdate"] + >, + ctx: ExtensionContext, + ) => Promise; + pairTelegramUserIfNeeded: ( + userId: number, + ctx: ExtensionContext, + ) => Promise; + answerCallbackQuery: ( + callbackQueryId: string, + text?: string, + ) => Promise; + handleAuthorizedTelegramCallbackQuery: ( + query: Extract["query"], + ctx: ExtensionContext, + ) => Promise; + sendTextReply: ( + chatId: number, + replyToMessageId: number, + text: string, + ) => Promise; + handleAuthorizedTelegramMessage: ( + message: Extract< + TelegramUpdateExecutionPlan, + { kind: "message" } + >["message"], + ctx: ExtensionContext, + ) => Promise; +} + +function getTelegramCallbackQueryId( + query: TelegramCallbackQueryLike, +): string | undefined { + return typeof query.id === "string" ? query.id : undefined; +} + +function getTelegramMessageReplyTarget( + message: TelegramMessageLike, +): { chatId: number; messageId: number } | undefined { + if ( + typeof message.chat.id !== "number" || + typeof message.message_id !== "number" + ) { + return undefined; + } + return { + chatId: message.chat.id, + messageId: message.message_id, + }; +} + +export async function executeTelegramUpdate( + update: TelegramUpdateFlowLike, + allowedUserId: number | undefined, + deps: TelegramUpdateRuntimeDeps, +): Promise { + await executeTelegramUpdatePlan( + buildTelegramUpdateExecutionPlanFromUpdate(update, allowedUserId), + deps, + ); +} + +export async function executeTelegramUpdatePlan( + plan: TelegramUpdateExecutionPlan, + deps: TelegramUpdateRuntimeDeps, +): Promise { + if (plan.kind === "ignore") return; + if (plan.kind === "deleted") { + deps.removePendingMediaGroupMessages(plan.messageIds); + deps.removeQueuedTelegramTurnsByMessageIds(plan.messageIds, deps.ctx); + return; + } + if (plan.kind === "reaction") { + await deps.handleAuthorizedTelegramReactionUpdate( + plan.reactionUpdate, + deps.ctx, + ); + return; + } + if (plan.kind === "callback") { + if (plan.shouldPair) { + await deps.pairTelegramUserIfNeeded(plan.query.from.id, deps.ctx); + } + if (plan.shouldDeny) { + const callbackQueryId = getTelegramCallbackQueryId(plan.query); + if (callbackQueryId) { + await deps.answerCallbackQuery( + callbackQueryId, + "This bot is not authorized for your account.", + ); + } + return; + } + await deps.handleAuthorizedTelegramCallbackQuery(plan.query, deps.ctx); + return; + } + const pairedNow = plan.shouldPair + ? await deps.pairTelegramUserIfNeeded(plan.message.from.id, deps.ctx) + : false; + const replyTarget = getTelegramMessageReplyTarget(plan.message); + if (pairedNow && plan.shouldNotifyPaired && replyTarget) { + await deps.sendTextReply( + replyTarget.chatId, + replyTarget.messageId, + "Telegram bridge paired with this account.", + ); + } + if (plan.shouldDeny) { + if (replyTarget) { + await deps.sendTextReply( + replyTarget.chatId, + replyTarget.messageId, + "This bot is not authorized for your account.", + ); + } + return; + } + await deps.handleAuthorizedTelegramMessage(plan.message, deps.ctx); +} diff --git a/package.json b/package.json index 3843ee6..ac676d1 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "pi-telegram", - "version": "0.1.0", + "version": "0.2.0", "private": false, "description": "Telegram DM bridge extension for pi", "type": "module", diff --git a/tests/api.test.ts b/tests/api.test.ts new file mode 100644 index 0000000..bceb776 --- /dev/null +++ b/tests/api.test.ts @@ -0,0 +1,89 @@ +/** + * Regression tests for Telegram API and config helpers + * Verifies config persistence and direct helper behavior around missing tokens and callback-query failures + */ + +import assert from "node:assert/strict"; +import { mkdtemp, readFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import test from "node:test"; + +import { + answerTelegramCallbackQuery, + callTelegram, + createTelegramApiClient, + downloadTelegramFile, + readTelegramConfig, + writeTelegramConfig, +} from "../lib/api.ts"; + +test("Telegram config helpers persist and reload config", async () => { + const agentDir = await mkdtemp(join(tmpdir(), "pi-telegram-config-")); + const configPath = join(agentDir, "telegram.json"); + const config = { + botToken: "123:abc", + botUsername: "demo_bot", + allowedUserId: 42, + }; + await writeTelegramConfig(agentDir, configPath, config); + const reloaded = await readTelegramConfig(configPath); + assert.deepEqual(reloaded, config); + const raw = await readFile(configPath, "utf8"); + assert.match(raw, /demo_bot/); +}); + +test("Telegram API helpers reject missing bot token for direct calls", async () => { + await assert.rejects(() => callTelegram(undefined, "getMe", {}), { + message: "Telegram bot token is not configured", + }); + await assert.rejects( + () => + downloadTelegramFile( + undefined, + "file-id", + "demo.txt", + join(tmpdir(), "pi-telegram-missing-token"), + ), + { + message: "Telegram bot token is not configured", + }, + ); +}); + +test("answerTelegramCallbackQuery ignores Telegram API failures", async () => { + const originalFetch = globalThis.fetch; + globalThis.fetch = (async () => { + throw new Error("network down"); + }) as typeof fetch; + try { + await assert.doesNotReject(() => + answerTelegramCallbackQuery("123:abc", "callback-id", "ok"), + ); + } finally { + globalThis.fetch = originalFetch; + } +}); + +test("Telegram API client resolves bot tokens lazily for wrapped calls", async () => { + const originalFetch = globalThis.fetch; + const calls: string[] = []; + let botToken = "123:abc"; + globalThis.fetch = (async (input) => { + calls.push(typeof input === "string" ? input : input.toString()); + return { + ok: true, + json: async () => ({ ok: true, result: true }), + } as Response; + }) as typeof fetch; + try { + const client = createTelegramApiClient(() => botToken); + await client.call("sendChatAction", { chat_id: 1, action: "typing" }); + botToken = "456:def"; + await client.answerCallbackQuery("cb-1", "ok"); + assert.match(calls[0] ?? "", /bot123:abc\/sendChatAction$/); + assert.match(calls[1] ?? "", /bot456:def\/answerCallbackQuery$/); + } finally { + globalThis.fetch = originalFetch; + } +}); diff --git a/tests/attachments.test.ts b/tests/attachments.test.ts new file mode 100644 index 0000000..fac97a9 --- /dev/null +++ b/tests/attachments.test.ts @@ -0,0 +1,132 @@ +/** + * Regression tests for the Telegram attachments domain + * Covers attachment queueing and attachment delivery behavior in one domain-level suite + */ + +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + queueTelegramAttachments, + sendQueuedTelegramAttachments, +} from "../lib/attachments.ts"; + +test("Attachment queueing adds files to the active Telegram turn", async () => { + const activeTurn = { + queuedAttachments: [], + } as unknown as { + queuedAttachments: Array<{ path: string; fileName: string }>; + } & Parameters[0]["activeTurn"]; + const result = await queueTelegramAttachments({ + activeTurn, + paths: ["/tmp/demo.txt"], + maxAttachmentsPerTurn: 2, + statPath: async () => ({ isFile: () => true }), + }); + assert.deepEqual(activeTurn.queuedAttachments, [ + { path: "/tmp/demo.txt", fileName: "demo.txt" }, + ]); + assert.deepEqual(result.details.paths, ["/tmp/demo.txt"]); +}); + +test("Attachment queueing rejects missing turns, non-files, and full queues", async () => { + await assert.rejects( + () => + queueTelegramAttachments({ + activeTurn: undefined, + paths: ["/tmp/demo.txt"], + maxAttachmentsPerTurn: 1, + statPath: async () => ({ isFile: () => true }), + }), + { message: /active Telegram turn/ }, + ); + await assert.rejects( + () => + queueTelegramAttachments({ + activeTurn: { queuedAttachments: [] } as never, + paths: ["/tmp/demo.txt"], + maxAttachmentsPerTurn: 1, + statPath: async () => ({ isFile: () => false }), + }), + { message: "Not a file: /tmp/demo.txt" }, + ); + await assert.rejects( + () => + queueTelegramAttachments({ + activeTurn: { + queuedAttachments: [{ path: "/tmp/a.txt", fileName: "a.txt" }], + } as never, + paths: ["/tmp/demo.txt"], + maxAttachmentsPerTurn: 1, + statPath: async () => ({ isFile: () => true }), + }), + { message: "Attachment limit reached (1)" }, + ); +}); + +test("Attachment delivery chooses photo vs document methods from file paths", async () => { + const sent: Array = []; + await sendQueuedTelegramAttachments( + { + kind: "prompt", + chatId: 1, + replyToMessageId: 2, + sourceMessageIds: [], + queueOrder: 1, + queueLane: "default", + laneOrder: 1, + queuedAttachments: [ + { path: "/tmp/a.png", fileName: "a.png" }, + { path: "/tmp/b.txt", fileName: "b.txt" }, + ], + content: [{ type: "text", text: "prompt" }], + historyText: "history", + statusSummary: "summary", + }, + { + sendMultipart: async ( + method, + _fields, + fileField, + _filePath, + fileName, + ) => { + sent.push(`${method}:${fileField}:${fileName}`); + }, + sendTextReply: async () => undefined, + }, + ); + assert.deepEqual(sent, [ + "sendPhoto:photo:a.png", + "sendDocument:document:b.txt", + ]); +}); + +test("Attachment delivery reports per-file failures via text replies", async () => { + const replies: string[] = []; + await sendQueuedTelegramAttachments( + { + kind: "prompt", + chatId: 1, + replyToMessageId: 2, + sourceMessageIds: [], + queueOrder: 1, + queueLane: "default", + laneOrder: 1, + queuedAttachments: [{ path: "/tmp/a.png", fileName: "a.png" }], + content: [{ type: "text", text: "prompt" }], + historyText: "history", + statusSummary: "summary", + }, + { + sendMultipart: async () => { + throw new Error("upload failed"); + }, + sendTextReply: async (_chatId, _replyToMessageId, text) => { + replies.push(text); + return undefined; + }, + }, + ); + assert.deepEqual(replies, ["Failed to send attachment a.png: upload failed"]); +}); diff --git a/tests/telegram-config.test.ts b/tests/config.test.ts similarity index 92% rename from tests/telegram-config.test.ts rename to tests/config.test.ts index fe429ca..c41626d 100644 --- a/tests/telegram-config.test.ts +++ b/tests/config.test.ts @@ -1,5 +1,10 @@ -import test from "node:test"; +/** + * Regression tests for Telegram setup prompt defaults + * Covers token-prefill priority across stored config, environment variables, and placeholder fallback + */ + import assert from "node:assert/strict"; +import test from "node:test"; import { __telegramTestUtils } from "../index.ts"; @@ -16,7 +21,6 @@ test("Bot token input prefers stored config over env vars", () => { assert.equal(value, "stored-token"); }); - test("Bot token input prefers the first configured Telegram env var when no config exists", () => { const value = __telegramTestUtils.getTelegramBotTokenInputDefault({ TELEGRAM_KEY: "key-last", diff --git a/tests/media.test.ts b/tests/media.test.ts new file mode 100644 index 0000000..2580f49 --- /dev/null +++ b/tests/media.test.ts @@ -0,0 +1,77 @@ +/** + * Regression tests for Telegram media and text extraction helpers + * Covers inbound file-info collection, text extraction, id collection, and history formatting + */ + +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + collectTelegramFileInfos, + collectTelegramMessageIds, + extractFirstTelegramMessageText, + extractTelegramMessagesText, + formatTelegramHistoryText, + guessMediaType, +} from "../lib/media.ts"; + +test("Media helpers collect file infos across Telegram message variants", () => { + const files = collectTelegramFileInfos([ + { + message_id: 1, + text: "hello", + photo: [ + { file_id: "small", file_size: 1 }, + { file_id: "large", file_size: 10 }, + ], + document: { + file_id: "doc", + file_name: "report.png", + mime_type: "image/png", + }, + voice: { + file_id: "voice", + mime_type: "audio/ogg", + }, + sticker: { + file_id: "sticker", + }, + }, + ]); + assert.deepEqual( + files.map((file) => ({ + id: file.file_id, + name: file.fileName, + image: file.isImage, + })), + [ + { id: "large", name: "photo-1.jpg", image: true }, + { id: "doc", name: "report.png", image: true }, + { id: "voice", name: "voice-1.ogg", image: false }, + { id: "sticker", name: "sticker-1.webp", image: true }, + ], + ); +}); + +test("Media helpers extract text, ids, and history summaries", () => { + const messages = [ + { message_id: 1, text: "first" }, + { message_id: 2, caption: "second" }, + { message_id: 2, text: "duplicate id" }, + ]; + assert.equal( + extractTelegramMessagesText(messages), + "first\n\nsecond\n\nduplicate id", + ); + assert.equal(extractFirstTelegramMessageText(messages), "first"); + assert.deepEqual(collectTelegramMessageIds(messages), [1, 2]); + assert.equal( + formatTelegramHistoryText("hello", [{ path: "/tmp/demo.txt" }]), + "hello\nAttachments:\n- /tmp/demo.txt", + ); +}); + +test("Media helpers infer outgoing image media types from file paths", () => { + assert.equal(guessMediaType("/tmp/demo.png"), "image/png"); + assert.equal(guessMediaType("/tmp/demo.txt"), undefined); +}); diff --git a/tests/menu.test.ts b/tests/menu.test.ts new file mode 100644 index 0000000..17f816b --- /dev/null +++ b/tests/menu.test.ts @@ -0,0 +1,645 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + applyTelegramModelPageSelection, + applyTelegramModelScopeSelection, + buildModelMenuReplyMarkup, + buildStatusReplyMarkup, + buildTelegramModelCallbackPlan, + buildTelegramModelMenuRenderPayload, + buildTelegramModelMenuState, + buildTelegramStatusMenuRenderPayload, + buildTelegramThinkingMenuRenderPayload, + buildThinkingMenuReplyMarkup, + buildThinkingMenuText, + formatScopedModelButtonText, + getCanonicalModelId, + getTelegramModelMenuPage, + getTelegramModelSelection, + getModelMenuItems, + handleTelegramMenuCallbackEntry, + handleTelegramModelMenuCallbackAction, + handleTelegramStatusMenuCallbackAction, + handleTelegramThinkingMenuCallbackAction, + isThinkingLevel, + MODEL_MENU_TITLE, + modelsMatch, + parseTelegramMenuCallbackAction, + resolveScopedModelPatterns, + sendTelegramModelMenuMessage, + sendTelegramStatusMessage, + sortScopedModels, + TELEGRAM_MODEL_PAGE_SIZE, + updateTelegramModelMenuMessage, + updateTelegramStatusMessage, + updateTelegramThinkingMenuMessage, + type TelegramModelMenuState, +} from "../lib/menu.ts"; + +test("Menu helpers match models, detect thinking levels, and expose constants", () => { + assert.equal(MODEL_MENU_TITLE, "Choose a model:"); + assert.equal(TELEGRAM_MODEL_PAGE_SIZE, 6); + assert.equal( + modelsMatch( + { provider: "openai", id: "gpt-5" }, + { provider: "openai", id: "gpt-5" }, + ), + true, + ); + assert.equal( + modelsMatch( + { provider: "openai", id: "gpt-5" }, + { provider: "anthropic", id: "gpt-5" }, + ), + false, + ); + assert.equal( + getCanonicalModelId({ provider: "openai", id: "gpt-5" }), + "openai/gpt-5", + ); + assert.equal(isThinkingLevel("high"), true); + assert.equal(isThinkingLevel("impossible"), false); +}); + +test("Menu helpers resolve scoped model patterns and sort current models first", () => { + const models = [ + { provider: "openai", id: "gpt-5", name: "GPT 5" }, + { provider: "openai", id: "gpt-5-latest", name: "GPT 5 Latest" }, + { + provider: "anthropic", + id: "claude-sonnet-20250101", + name: "Claude Sonnet", + }, + ] as const; + const resolved = resolveScopedModelPatterns( + ["gpt-5:high", "anthropic/*:low"], + models as never, + ); + assert.deepEqual( + resolved.map((entry) => ({ + id: entry.model.id, + thinking: entry.thinkingLevel, + })), + [ + { id: "gpt-5", thinking: "high" }, + { id: "claude-sonnet-20250101", thinking: "low" }, + ], + ); + const sorted = sortScopedModels(resolved, models[0] as never); + assert.equal(sorted[0]?.model.id, "gpt-5"); +}); + +test("Menu helpers build model menu state and parse callback actions", () => { + const modelA = { provider: "openai", id: "gpt-5", reasoning: true } as const; + const modelB = { + provider: "anthropic", + id: "claude-3", + reasoning: false, + } as const; + const state = buildTelegramModelMenuState({ + chatId: 1, + activeModel: modelA as never, + availableModels: [modelA, modelB] as never, + configuredScopedModelPatterns: ["missing-model"], + cliScopedModelPatterns: ["missing-model"], + }); + assert.equal(state.chatId, 1); + assert.equal(state.scope, "all"); + assert.match(state.note ?? "", /No CLI scoped models matched/); + assert.deepEqual(parseTelegramMenuCallbackAction("status:model"), { + kind: "status", + action: "model", + }); + assert.deepEqual(parseTelegramMenuCallbackAction("thinking:set:high"), { + kind: "thinking:set", + level: "high", + }); + assert.deepEqual(parseTelegramMenuCallbackAction("model:pick:2"), { + kind: "model", + action: "pick", + value: "2", + }); + assert.deepEqual(parseTelegramMenuCallbackAction("unknown"), { + kind: "ignore", + }); +}); + +test("Menu helpers apply menu mutations and resolve model selections", () => { + const modelA = { provider: "openai", id: "gpt-5", reasoning: true } as const; + const state = { + chatId: 1, + messageId: 2, + page: 0, + scope: "all" as const, + scopedModels: [{ model: modelA, thinkingLevel: "high" as const }], + allModels: [{ model: modelA }], + mode: "status" as const, + } as unknown as TelegramModelMenuState; + assert.equal(applyTelegramModelScopeSelection(state, "scoped"), "changed"); + assert.equal(state.scope, "scoped"); + assert.equal(applyTelegramModelScopeSelection(state, "scoped"), "unchanged"); + assert.equal(applyTelegramModelScopeSelection(state, "bad"), "invalid"); + assert.equal(applyTelegramModelPageSelection(state, "2"), "changed"); + assert.equal(state.page, 2); + assert.equal(applyTelegramModelPageSelection(state, "2"), "unchanged"); + assert.equal(applyTelegramModelPageSelection(state, "bad"), "invalid"); + assert.deepEqual(getTelegramModelSelection(state, "bad"), { kind: "invalid" }); + assert.deepEqual(getTelegramModelSelection(state, "9"), { kind: "missing" }); + assert.equal(getTelegramModelSelection(state, "0").kind, "selected"); +}); + +test("Menu helpers derive normalized menu pages without mutating state", () => { + const modelA = { provider: "openai", id: "gpt-5" } as const; + const modelB = { provider: "anthropic", id: "claude-3" } as const; + const state = { + chatId: 1, + messageId: 2, + page: 99, + scope: "all" as const, + scopedModels: [], + allModels: [{ model: modelA }, { model: modelB }], + mode: "model" as const, + } as unknown as TelegramModelMenuState; + const menuPage = getTelegramModelMenuPage(state, 1); + assert.equal(menuPage.page, 1); + assert.equal(menuPage.pageCount, 2); + assert.equal(menuPage.start, 1); + assert.deepEqual(menuPage.items, [{ model: modelB }]); + assert.equal(state.page, 99); + const markup = buildModelMenuReplyMarkup(state, modelA as never, 1); + assert.equal(markup.inline_keyboard[1]?.[1]?.text, "2/2"); + assert.equal(state.page, 99); +}); + +test("Menu helpers build model callback plans for paging, selection, and restart modes", () => { + const modelA = { provider: "openai", id: "gpt-5", reasoning: true } as const; + const modelB = { provider: "anthropic", id: "claude-3", reasoning: false } as const; + const state = { + chatId: 1, + messageId: 2, + page: 0, + scope: "all" as const, + scopedModels: [{ model: modelA, thinkingLevel: "high" as const }], + allModels: [{ model: modelA }, { model: modelB }], + mode: "model" as const, + } as unknown as TelegramModelMenuState; + assert.deepEqual( + buildTelegramModelCallbackPlan({ + data: "model:page:1", + state, + activeModel: modelA as never, + currentThinkingLevel: "medium", + isIdle: true, + canRestartBusyRun: false, + hasActiveToolExecutions: false, + }), + { kind: "update-menu" }, + ); + assert.deepEqual( + buildTelegramModelCallbackPlan({ + data: "model:pick:0", + state, + activeModel: modelA as never, + currentThinkingLevel: "medium", + isIdle: true, + canRestartBusyRun: false, + hasActiveToolExecutions: false, + }), + { + kind: "refresh-status", + selection: state.allModels[0], + callbackText: "Model: gpt-5", + shouldApplyThinkingLevel: false, + }, + ); + assert.deepEqual( + buildTelegramModelCallbackPlan({ + data: "model:pick:1", + state, + activeModel: modelA as never, + currentThinkingLevel: "medium", + isIdle: false, + canRestartBusyRun: true, + hasActiveToolExecutions: true, + }), + { + kind: "switch-model", + selection: state.allModels[1], + mode: "restart-after-tool", + callbackText: "Switched to claude-3. Restarting after the current tool finishes…", + }, + ); + assert.deepEqual( + buildTelegramModelCallbackPlan({ + data: "model:pick:1", + state, + activeModel: modelA as never, + currentThinkingLevel: "medium", + isIdle: false, + canRestartBusyRun: false, + hasActiveToolExecutions: false, + }), + { kind: "answer", text: "Pi is busy. Send /stop first." }, + ); +}); + +test("Menu helpers route callback entry states before action handlers", async () => { + const events: string[] = []; + await handleTelegramMenuCallbackEntry("callback-1", undefined, undefined, { + handleStatusAction: async () => false, + handleThinkingAction: async () => false, + handleModelAction: async () => false, + answerCallbackQuery: async (_id, text) => { + events.push(`answer:${text ?? ""}`); + }, + }); + await handleTelegramMenuCallbackEntry("callback-2", "status:model", undefined, { + handleStatusAction: async () => false, + handleThinkingAction: async () => false, + handleModelAction: async () => false, + answerCallbackQuery: async (_id, text) => { + events.push(`answer:${text ?? ""}`); + }, + }); + await handleTelegramMenuCallbackEntry( + "callback-3", + "status:model", + { + chatId: 1, + messageId: 2, + page: 0, + scope: "all", + scopedModels: [], + allModels: [], + mode: "status", + }, + { + handleStatusAction: async () => { + events.push("status"); + return true; + }, + handleThinkingAction: async () => false, + handleModelAction: async () => false, + answerCallbackQuery: async (_id, text) => { + events.push(`answer:${text ?? ""}`); + }, + }, + ); + assert.deepEqual(events, [ + "answer:", + "answer:Interactive message expired.", + "status", + ]); +}); + +test("Menu helpers execute model callback actions across update, switch, and restart paths", async () => { + const events: string[] = []; + const modelA = { provider: "openai", id: "gpt-5", reasoning: true } as const; + const modelB = { provider: "anthropic", id: "claude-3", reasoning: false } as const; + const state = { + chatId: 1, + messageId: 2, + page: 0, + scope: "all" as const, + scopedModels: [], + allModels: [{ model: modelA }, { model: modelB }], + mode: "model" as const, + } as unknown as TelegramModelMenuState; + assert.equal( + await handleTelegramModelMenuCallbackAction( + "callback-1", + { + data: "model:page:1", + state, + activeModel: modelA as never, + currentThinkingLevel: "medium", + isIdle: true, + canRestartBusyRun: false, + hasActiveToolExecutions: false, + }, + { + updateModelMenuMessage: async () => { + events.push("update-menu"); + }, + updateStatusMessage: async () => { + events.push("status"); + }, + answerCallbackQuery: async (_id, text) => { + events.push(`answer:${text ?? ""}`); + }, + setModel: async () => true, + setCurrentModel: (model) => { + events.push(`current:${model.id}`); + }, + setThinkingLevel: (level) => { + events.push(`thinking:${level}`); + }, + stagePendingModelSwitch: (selection) => { + events.push(`pending:${selection.model.id}`); + }, + restartInterruptedTelegramTurn: (selection) => { + events.push(`restart:${selection.model.id}`); + return true; + }, + }, + ), + true, + ); + assert.equal( + await handleTelegramModelMenuCallbackAction( + "callback-2", + { + data: "model:pick:1", + state, + activeModel: modelA as never, + currentThinkingLevel: "medium", + isIdle: false, + canRestartBusyRun: true, + hasActiveToolExecutions: true, + }, + { + updateModelMenuMessage: async () => { + events.push("unexpected:update"); + }, + updateStatusMessage: async () => { + events.push("status"); + }, + answerCallbackQuery: async (_id, text) => { + events.push(`answer:${text ?? ""}`); + }, + setModel: async () => true, + setCurrentModel: (model) => { + events.push(`current:${model.id}`); + }, + setThinkingLevel: (level) => { + events.push(`thinking:${level}`); + }, + stagePendingModelSwitch: (selection) => { + events.push(`pending:${selection.model.id}`); + }, + restartInterruptedTelegramTurn: (selection) => { + events.push(`restart:${selection.model.id}`); + return true; + }, + }, + ), + true, + ); + assert.equal( + await handleTelegramModelMenuCallbackAction( + "callback-3", + { + data: "model:pick:1", + state, + activeModel: modelA as never, + currentThinkingLevel: "medium", + isIdle: false, + canRestartBusyRun: true, + hasActiveToolExecutions: false, + }, + { + updateModelMenuMessage: async () => { + events.push("unexpected:update"); + }, + updateStatusMessage: async () => { + events.push("status"); + }, + answerCallbackQuery: async (_id, text) => { + events.push(`answer:${text ?? ""}`); + }, + setModel: async () => true, + setCurrentModel: (model) => { + events.push(`current:${model.id}`); + }, + setThinkingLevel: (level) => { + events.push(`thinking:${level}`); + }, + stagePendingModelSwitch: (selection) => { + events.push(`pending:${selection.model.id}`); + }, + restartInterruptedTelegramTurn: (selection) => { + events.push(`restart:${selection.model.id}`); + return true; + }, + }, + ), + true, + ); + assert.equal(events[0], "update-menu"); + assert.equal(events[1], "answer:"); + assert.equal(events[2], "current:claude-3"); + assert.equal(events[3], "status"); + assert.equal(events[4], "pending:claude-3"); + assert.equal( + events[5], + "answer:Switched to claude-3. Restarting after the current tool finishes…", + ); + assert.equal(events[6], "current:claude-3"); + assert.equal(events[7], "status"); + assert.equal(events[8], "restart:claude-3"); + assert.equal(events[9], "answer:Switching to claude-3 and continuing…"); +}); + +test("Menu helpers handle status and thinking callback actions", async () => { + const events: string[] = []; + const reasoningModel = { + provider: "openai", + id: "gpt-5", + reasoning: true, + } as const; + const plainModel = { + provider: "openai", + id: "gpt-4o", + reasoning: false, + } as const; + assert.equal( + await handleTelegramStatusMenuCallbackAction( + "callback-1", + "status:model", + reasoningModel as never, + { + updateModelMenuMessage: async () => { + events.push("status:model"); + }, + updateThinkingMenuMessage: async () => { + events.push("status:thinking"); + }, + answerCallbackQuery: async (_id, text) => { + events.push(`answer:${text ?? ""}`); + }, + }, + ), + true, + ); + assert.equal( + await handleTelegramThinkingMenuCallbackAction( + "callback-2", + "thinking:set:high", + reasoningModel as never, + { + setThinkingLevel: (level) => { + events.push(`set:${level}`); + }, + getCurrentThinkingLevel: () => "high", + updateStatusMessage: async () => { + events.push("status:update"); + }, + answerCallbackQuery: async (_id, text) => { + events.push(`answer:${text ?? ""}`); + }, + }, + ), + true, + ); + assert.equal( + await handleTelegramStatusMenuCallbackAction( + "callback-3", + "status:thinking", + plainModel as never, + { + updateModelMenuMessage: async () => { + events.push("unexpected:model"); + }, + updateThinkingMenuMessage: async () => { + events.push("unexpected:thinking"); + }, + answerCallbackQuery: async (_id, text) => { + events.push(`answer:${text ?? ""}`); + }, + }, + ), + true, + ); + assert.equal(events[0], "status:model"); + assert.equal(events[1], "answer:"); + assert.equal(events[2], "set:high"); + assert.equal(events[3], "status:update"); + assert.equal(events[4], "answer:Thinking: high"); + assert.equal(events[5], "answer:This model has no reasoning controls."); +}); + +test("Menu helpers build pure render payloads before transport", () => { + const modelA = { provider: "openai", id: "gpt-5", reasoning: true } as const; + const state = { + chatId: 1, + messageId: 2, + page: 0, + scope: "all" as const, + scopedModels: [], + allModels: [{ model: modelA }], + mode: "status" as const, + } as unknown as TelegramModelMenuState; + const modelPayload = buildTelegramModelMenuRenderPayload(state, modelA as never); + const thinkingPayload = buildTelegramThinkingMenuRenderPayload(modelA as never, "medium"); + const statusPayload = buildTelegramStatusMenuRenderPayload( + "Status", + modelA as never, + "medium", + ); + assert.equal(modelPayload.nextMode, "model"); + assert.equal(modelPayload.text, "Choose a model:"); + assert.equal(modelPayload.mode, "html"); + assert.equal(thinkingPayload.nextMode, "thinking"); + assert.match(thinkingPayload.text, /^Choose a thinking level/); + assert.equal(thinkingPayload.mode, "plain"); + assert.equal(statusPayload.nextMode, "status"); + assert.equal(statusPayload.text, "Status"); + assert.equal(statusPayload.mode, "html"); + assert.equal(state.mode, "status"); +}); + +test("Menu helpers update and send interactive menu messages", async () => { + const events: string[] = []; + const modelA = { provider: "openai", id: "gpt-5", reasoning: true } as const; + const state = { + chatId: 1, + messageId: 2, + page: 0, + scope: "all" as const, + scopedModels: [], + allModels: [{ model: modelA }], + mode: "status" as const, + } as unknown as TelegramModelMenuState; + const deps = { + editInteractiveMessage: async ( + chatId: number, + messageId: number, + text: string, + mode: "html" | "plain", + ) => { + events.push(`edit:${chatId}:${messageId}:${mode}:${text}`); + }, + sendInteractiveMessage: async ( + chatId: number, + text: string, + mode: "html" | "plain", + ) => { + events.push(`send:${chatId}:${mode}:${text}`); + return 99; + }, + }; + await updateTelegramModelMenuMessage(state, modelA as never, deps); + await updateTelegramThinkingMenuMessage(state, modelA as never, "medium", deps); + await updateTelegramStatusMessage( + state, + "Status", + modelA as never, + "medium", + deps, + ); + const sentStatusId = await sendTelegramStatusMessage( + state, + "Status", + modelA as never, + "medium", + deps, + ); + const sentModelId = await sendTelegramModelMenuMessage(state, modelA as never, deps); + assert.equal(sentStatusId, 99); + assert.equal(sentModelId, 99); + assert.equal(events[0], "edit:1:2:html:Choose a model:"); + assert.match(events[1] ?? "", /^edit:1:2:plain:Choose a thinking level/); + assert.equal(events[2], "edit:1:2:html:Status"); + assert.equal(events[3], "send:1:html:Status"); + assert.equal(events[4], "send:1:html:Choose a model:"); +}); + +test("Menu helpers build model, thinking, and status UI payloads", () => { + const modelA = { provider: "openai", id: "gpt-5", reasoning: true } as const; + const modelB = { + provider: "anthropic", + id: "claude-3", + reasoning: false, + } as const; + const state = { + chatId: 1, + messageId: 2, + page: 0, + scope: "scoped" as const, + scopedModels: [{ model: modelA, thinkingLevel: "high" as const }], + allModels: [{ model: modelB }], + mode: "model" as const, + } as unknown as TelegramModelMenuState; + assert.deepEqual(getModelMenuItems(state), state.scopedModels); + assert.match( + formatScopedModelButtonText(state.scopedModels[0], modelA as never), + /^βœ… /, + ); + const modelMarkup = buildModelMenuReplyMarkup(state, modelA as never, 6); + assert.equal( + modelMarkup.inline_keyboard[0]?.[0]?.callback_data, + "model:pick:0", + ); + const thinkingText = buildThinkingMenuText(modelA as never, "medium"); + assert.match(thinkingText, /Model: openai\/gpt-5/); + const thinkingMarkup = buildThinkingMenuReplyMarkup("medium"); + assert.equal( + thinkingMarkup.inline_keyboard.some((row) => row[0]?.text === "βœ… medium"), + true, + ); + const statusMarkup = buildStatusReplyMarkup(modelA as never, "medium"); + assert.equal(statusMarkup.inline_keyboard.length, 2); + const noReasoningMarkup = buildStatusReplyMarkup(modelB as never, "medium"); + assert.equal(noReasoningMarkup.inline_keyboard.length, 1); +}); diff --git a/tests/polling.test.ts b/tests/polling.test.ts new file mode 100644 index 0000000..ce49ac9 --- /dev/null +++ b/tests/polling.test.ts @@ -0,0 +1,129 @@ +/** + * Regression tests for the Telegram polling domain + * Covers polling request helpers, stop conditions, and the long-poll loop runtime in one suite + */ + +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + TELEGRAM_ALLOWED_UPDATES, + buildTelegramInitialSyncRequest, + buildTelegramLongPollRequest, + getLatestTelegramUpdateId, + runTelegramPollLoop, + shouldStopTelegramPolling, +} from "../lib/polling.ts"; + +test("Polling helpers build the initial sync request", () => { + assert.deepEqual(buildTelegramInitialSyncRequest(), { + offset: -1, + limit: 1, + timeout: 0, + }); +}); + +test("Polling helpers build long-poll requests with and without lastUpdateId", () => { + assert.deepEqual(buildTelegramLongPollRequest(), { + offset: undefined, + limit: 10, + timeout: 30, + allowed_updates: TELEGRAM_ALLOWED_UPDATES, + }); + assert.deepEqual(buildTelegramLongPollRequest(41), { + offset: 42, + limit: 10, + timeout: 30, + allowed_updates: TELEGRAM_ALLOWED_UPDATES, + }); +}); + +test("Polling helpers extract the latest update id", () => { + assert.equal(getLatestTelegramUpdateId([]), undefined); + assert.equal( + getLatestTelegramUpdateId([{ update_id: 1 }, { update_id: 7 }]), + 7, + ); +}); + +test("Polling helpers stop only for abort conditions", () => { + assert.equal(shouldStopTelegramPolling(true, new Error("ignored")), true); + assert.equal( + shouldStopTelegramPolling(false, new DOMException("aborted", "AbortError")), + true, + ); + assert.equal(shouldStopTelegramPolling(false, new Error("network")), false); +}); + +test("Poll loop initializes lastUpdateId and processes updates", async () => { + const handled: number[] = []; + const config: { botToken: string; lastUpdateId?: number } = { + botToken: "123:abc", + }; + let getUpdatesCalls = 0; + let persistCount = 0; + const signal = new AbortController().signal; + await runTelegramPollLoop({ + ctx: {} as never, + signal, + config, + deleteWebhook: async () => {}, + getUpdates: async () => { + getUpdatesCalls += 1; + if (getUpdatesCalls === 1) { + return [{ update_id: 5 }]; + } + if (getUpdatesCalls === 2) { + return [{ update_id: 6 }, { update_id: 7 }]; + } + throw new DOMException("stop", "AbortError"); + }, + persistConfig: async () => { + persistCount += 1; + }, + handleUpdate: async (update) => { + handled.push(update.update_id); + }, + onErrorStatus: () => {}, + onStatusReset: () => {}, + sleep: async () => {}, + }); + assert.equal(config.lastUpdateId, 7); + assert.deepEqual(handled, [6, 7]); + assert.equal(persistCount, 3); +}); + +test("Poll loop reports retryable errors and sleeps before retrying", async () => { + const config = { botToken: "123:abc", lastUpdateId: 1 }; + const statusMessages: string[] = []; + let calls = 0; + await runTelegramPollLoop({ + ctx: {} as never, + signal: new AbortController().signal, + config, + deleteWebhook: async () => {}, + getUpdates: async () => { + calls += 1; + if (calls === 1) { + throw new Error("network down"); + } + throw new DOMException("stop", "AbortError"); + }, + persistConfig: async () => {}, + handleUpdate: async () => {}, + onErrorStatus: (message) => { + statusMessages.push(`error:${message}`); + }, + onStatusReset: () => { + statusMessages.push("reset"); + }, + sleep: async (ms) => { + statusMessages.push(`sleep:${ms}`); + }, + }); + assert.deepEqual(statusMessages, [ + "error:network down", + "sleep:3000", + "reset", + ]); +}); diff --git a/tests/queue.test.ts b/tests/queue.test.ts new file mode 100644 index 0000000..dbeb6b0 --- /dev/null +++ b/tests/queue.test.ts @@ -0,0 +1,2982 @@ +/** + * Regression tests for Telegram queue and runtime decision helpers + * Exercises queue ordering, mutation, dispatch planning, lifecycle plans, and model-switch guard behavior + */ + +import assert from "node:assert/strict"; +import { mkdir, readFile, rm, writeFile } from "node:fs/promises"; +import { homedir } from "node:os"; +import { join } from "node:path"; +import test from "node:test"; + +import telegramExtension, { __telegramTestUtils } from "../index.ts"; +import { + buildTelegramAgentStartPlan, + buildTelegramSessionShutdownState, + buildTelegramSessionStartState, + executeTelegramControlItemRuntime, + executeTelegramQueueDispatchPlan, + getNextTelegramToolExecutionCount, + shouldStartTelegramPolling, +} from "../lib/queue.ts"; + +async function waitForCondition( + predicate: () => boolean, + timeoutMs = 2000, +): Promise { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + if (predicate()) return; + await new Promise((resolve) => setTimeout(resolve, 10)); + } + throw new Error("Timed out waiting for condition"); +} + +test("Control-lane items sort before priority and default prompt items", () => { + const queueItemType = undefined as + | Parameters[0] + | undefined; + const defaultPrompt: typeof queueItemType = { + kind: "prompt", + chatId: 1, + replyToMessageId: 1, + sourceMessageIds: [1], + queueOrder: 10, + queueLane: "default", + laneOrder: 10, + queuedAttachments: [], + content: [{ type: "text", text: "default" }], + historyText: "default", + statusSummary: "default", + }; + const priorityPrompt: typeof queueItemType = { + kind: "prompt", + chatId: 1, + replyToMessageId: 2, + sourceMessageIds: [2], + queueOrder: 11, + queueLane: "priority", + laneOrder: 0, + queuedAttachments: [], + content: [{ type: "text", text: "priority" }], + historyText: "priority", + statusSummary: "priority", + }; + const controlItem: typeof queueItemType = { + kind: "control", + controlType: "status", + chatId: 1, + replyToMessageId: 3, + queueOrder: 12, + queueLane: "control", + laneOrder: 0, + statusSummary: "control", + execute: async () => {}, + }; + const items = [defaultPrompt, controlItem, priorityPrompt].sort( + __telegramTestUtils.compareTelegramQueueItems, + ); + assert.deepEqual( + items.map((item) => item?.statusSummary), + ["control", "priority", "default"], + ); +}); + +test("Queue mutation helpers remove prompt items by Telegram message id", () => { + const queueItemType = undefined as + | Parameters< + typeof __telegramTestUtils.removeTelegramQueueItemsByMessageIds + >[0][number] + | undefined; + const promptItem: typeof queueItemType = { + kind: "prompt", + chatId: 1, + replyToMessageId: 1, + sourceMessageIds: [11, 12], + queueOrder: 1, + queueLane: "default", + laneOrder: 1, + queuedAttachments: [], + content: [{ type: "text", text: "prompt" }], + historyText: "prompt history", + statusSummary: "prompt", + }; + const controlItem: typeof queueItemType = { + kind: "control", + controlType: "status", + chatId: 1, + replyToMessageId: 2, + queueOrder: 2, + queueLane: "control", + laneOrder: 0, + statusSummary: "control", + execute: async () => {}, + }; + const result = __telegramTestUtils.removeTelegramQueueItemsByMessageIds( + [promptItem, controlItem], + [12], + ); + assert.equal(result.removedCount, 1); + assert.deepEqual( + result.items.map((item) => item.statusSummary), + ["control"], + ); +}); + +test("Queue mutation helpers apply and clear prompt priority without touching control items", () => { + const queueItemType = undefined as + | Parameters< + typeof __telegramTestUtils.prioritizeTelegramQueuePrompt + >[0][number] + | undefined; + const promptItem: typeof queueItemType = { + kind: "prompt", + chatId: 1, + replyToMessageId: 1, + sourceMessageIds: [11], + queueOrder: 4, + queueLane: "default", + laneOrder: 4, + queuedAttachments: [], + content: [{ type: "text", text: "prompt" }], + historyText: "prompt history", + statusSummary: "prompt", + }; + const controlItem: typeof queueItemType = { + kind: "control", + controlType: "status", + chatId: 1, + replyToMessageId: 2, + queueOrder: 5, + queueLane: "control", + laneOrder: 0, + statusSummary: "control", + execute: async () => {}, + }; + const prioritized = __telegramTestUtils.prioritizeTelegramQueuePrompt( + [promptItem, controlItem], + 11, + 0, + ); + assert.equal(prioritized.changed, true); + assert.equal(prioritized.items[0]?.queueLane, "priority"); + const cleared = __telegramTestUtils.clearTelegramQueuePromptPriority( + prioritized.items, + 11, + ); + assert.equal(cleared.changed, true); + assert.equal(cleared.items[0]?.queueLane, "default"); + assert.equal(cleared.items[1]?.queueLane, "control"); +}); + +test("History partition keeps control items queued and extracts prompt items", () => { + const queueItemType = undefined as + | Parameters< + typeof __telegramTestUtils.partitionTelegramQueueItemsForHistory + >[0][number] + | undefined; + const promptItem: typeof queueItemType = { + kind: "prompt", + chatId: 1, + replyToMessageId: 1, + sourceMessageIds: [1], + queueOrder: 1, + queueLane: "default", + laneOrder: 1, + queuedAttachments: [], + content: [{ type: "text", text: "prompt" }], + historyText: "prompt history", + statusSummary: "prompt", + }; + const controlItem: typeof queueItemType = { + kind: "control", + controlType: "status", + chatId: 1, + replyToMessageId: 2, + queueOrder: 2, + queueLane: "control", + laneOrder: 0, + statusSummary: "control", + execute: async () => {}, + }; + const result = __telegramTestUtils.partitionTelegramQueueItemsForHistory([ + promptItem, + controlItem, + ]); + assert.deepEqual( + result.historyTurns.map((item) => item.statusSummary), + ["prompt"], + ); + assert.deepEqual( + result.remainingItems.map((item) => item.statusSummary), + ["control"], + ); +}); + +test("Dispatch planning returns the prompt item when dispatch is allowed", () => { + const queueItemType = undefined as + | Parameters< + typeof __telegramTestUtils.planNextTelegramQueueAction + >[0][number] + | undefined; + const controlItem: typeof queueItemType = { + kind: "control", + controlType: "status", + chatId: 1, + replyToMessageId: 1, + queueOrder: 1, + queueLane: "control", + laneOrder: 0, + statusSummary: "control", + execute: async () => {}, + }; + const promptItem: typeof queueItemType = { + kind: "prompt", + chatId: 1, + replyToMessageId: 2, + sourceMessageIds: [2], + queueOrder: 2, + queueLane: "default", + laneOrder: 2, + queuedAttachments: [], + content: [{ type: "text", text: "prompt" }], + historyText: "prompt history", + statusSummary: "prompt", + }; + const result = __telegramTestUtils.planNextTelegramQueueAction( + [promptItem, controlItem], + true, + ); + assert.equal(result.kind, "prompt"); + assert.equal( + result.kind === "prompt" ? result.item.statusSummary : "", + "prompt", + ); + assert.deepEqual( + result.remainingItems.map((item) => item.statusSummary), + ["prompt", "control"], + ); +}); + +test("Dispatch planning runs control items before normal prompts", () => { + const queueItemType = undefined as + | Parameters< + typeof __telegramTestUtils.planNextTelegramQueueAction + >[0][number] + | undefined; + const controlItem: typeof queueItemType = { + kind: "control", + controlType: "status", + chatId: 1, + replyToMessageId: 1, + queueOrder: 1, + queueLane: "control", + laneOrder: 0, + statusSummary: "control", + execute: async () => {}, + }; + const promptItem: typeof queueItemType = { + kind: "prompt", + chatId: 1, + replyToMessageId: 2, + sourceMessageIds: [2], + queueOrder: 2, + queueLane: "default", + laneOrder: 2, + queuedAttachments: [], + content: [{ type: "text", text: "prompt" }], + historyText: "prompt history", + statusSummary: "prompt", + }; + const result = __telegramTestUtils.planNextTelegramQueueAction( + [controlItem, promptItem], + true, + ); + assert.equal(result.kind, "control"); + assert.equal( + result.kind === "control" ? result.item.statusSummary : "", + "control", + ); + assert.deepEqual( + result.remainingItems.map((item) => item.statusSummary), + ["prompt"], + ); +}); + +test("Dispatch planning returns none when dispatch is blocked", () => { + const queueItemType = undefined as + | Parameters< + typeof __telegramTestUtils.planNextTelegramQueueAction + >[0][number] + | undefined; + const promptItem: typeof queueItemType = { + kind: "prompt", + chatId: 1, + replyToMessageId: 2, + sourceMessageIds: [2], + queueOrder: 2, + queueLane: "default", + laneOrder: 2, + queuedAttachments: [], + content: [{ type: "text", text: "prompt" }], + historyText: "prompt history", + statusSummary: "prompt", + }; + const result = __telegramTestUtils.planNextTelegramQueueAction( + [promptItem], + false, + ); + assert.equal(result.kind, "none"); + assert.deepEqual( + result.remainingItems.map((item) => item.statusSummary), + ["prompt"], + ); +}); + +test("Control-item dispatch sequencing hands off to the next prompt", () => { + const queueItemType = undefined as + | Parameters< + typeof __telegramTestUtils.planNextTelegramQueueAction + >[0][number] + | undefined; + const controlItem: typeof queueItemType = { + kind: "control", + controlType: "status", + chatId: 1, + replyToMessageId: 1, + queueOrder: 1, + queueLane: "control", + laneOrder: 0, + statusSummary: "control", + execute: async () => {}, + }; + const promptItem: typeof queueItemType = { + kind: "prompt", + chatId: 1, + replyToMessageId: 2, + sourceMessageIds: [2], + queueOrder: 2, + queueLane: "default", + laneOrder: 2, + queuedAttachments: [], + content: [{ type: "text", text: "prompt" }], + historyText: "prompt history", + statusSummary: "prompt", + }; + const firstStep = __telegramTestUtils.planNextTelegramQueueAction( + [controlItem, promptItem], + true, + ); + assert.equal(firstStep.kind, "control"); + const secondStep = __telegramTestUtils.planNextTelegramQueueAction( + firstStep.remainingItems, + true, + ); + assert.equal(secondStep.kind, "prompt"); + assert.equal( + secondStep.kind === "prompt" ? secondStep.item.statusSummary : "", + "prompt", + ); +}); + +test("Preserved abort leaves queued prompts waiting for explicit continuation", () => { + assert.equal( + __telegramTestUtils.shouldDispatchAfterTelegramAgentEnd({ + hasTurn: true, + stopReason: "aborted", + preserveQueuedTurnsAsHistory: true, + }), + false, + ); + const queueItemType = undefined as + | Parameters< + typeof __telegramTestUtils.planNextTelegramQueueAction + >[0][number] + | undefined; + const promptItem: typeof queueItemType = { + kind: "prompt", + chatId: 1, + replyToMessageId: 2, + sourceMessageIds: [2], + queueOrder: 2, + queueLane: "default", + laneOrder: 2, + queuedAttachments: [], + content: [{ type: "text", text: "prompt" }], + historyText: "prompt history", + statusSummary: "prompt", + }; + const blockedDispatch = __telegramTestUtils.planNextTelegramQueueAction( + [promptItem], + __telegramTestUtils.shouldDispatchAfterTelegramAgentEnd({ + hasTurn: true, + stopReason: "aborted", + preserveQueuedTurnsAsHistory: true, + }), + ); + assert.equal(blockedDispatch.kind, "none"); + assert.deepEqual( + blockedDispatch.remainingItems.map((item) => item.statusSummary), + ["prompt"], + ); +}); + +test("Agent end dispatch policy resumes after success and error, but not preserved aborts", () => { + assert.equal( + __telegramTestUtils.shouldDispatchAfterTelegramAgentEnd({ + hasTurn: false, + preserveQueuedTurnsAsHistory: false, + }), + true, + ); + assert.equal( + __telegramTestUtils.shouldDispatchAfterTelegramAgentEnd({ + hasTurn: true, + stopReason: "error", + preserveQueuedTurnsAsHistory: false, + }), + true, + ); + assert.equal( + __telegramTestUtils.shouldDispatchAfterTelegramAgentEnd({ + hasTurn: true, + stopReason: "aborted", + preserveQueuedTurnsAsHistory: false, + }), + true, + ); + assert.equal( + __telegramTestUtils.shouldDispatchAfterTelegramAgentEnd({ + hasTurn: true, + stopReason: "aborted", + preserveQueuedTurnsAsHistory: true, + }), + false, + ); +}); + +test("Agent end plan classifies turn outcomes correctly", () => { + const noTurnPlan = __telegramTestUtils.buildTelegramAgentEndPlan({ + hasTurn: false, + preserveQueuedTurnsAsHistory: false, + hasFinalText: false, + hasQueuedAttachments: false, + }); + assert.equal(noTurnPlan.kind, "no-turn"); + assert.equal(noTurnPlan.shouldDispatchNext, true); + const abortedPlan = __telegramTestUtils.buildTelegramAgentEndPlan({ + hasTurn: true, + stopReason: "aborted", + preserveQueuedTurnsAsHistory: true, + hasFinalText: false, + hasQueuedAttachments: true, + }); + assert.equal(abortedPlan.kind, "aborted"); + assert.equal(abortedPlan.shouldClearPreview, true); + assert.equal(abortedPlan.shouldDispatchNext, false); + const errorPlan = __telegramTestUtils.buildTelegramAgentEndPlan({ + hasTurn: true, + stopReason: "error", + preserveQueuedTurnsAsHistory: false, + hasFinalText: false, + hasQueuedAttachments: false, + }); + assert.equal(errorPlan.kind, "error"); + assert.equal(errorPlan.shouldSendErrorMessage, true); + const attachmentPlan = __telegramTestUtils.buildTelegramAgentEndPlan({ + hasTurn: true, + preserveQueuedTurnsAsHistory: false, + hasFinalText: false, + hasQueuedAttachments: true, + }); + assert.equal(attachmentPlan.kind, "attachments-only"); + assert.equal(attachmentPlan.shouldSendAttachmentNotice, true); + const textPlan = __telegramTestUtils.buildTelegramAgentEndPlan({ + hasTurn: true, + preserveQueuedTurnsAsHistory: false, + hasFinalText: true, + hasQueuedAttachments: false, + }); + assert.equal(textPlan.kind, "text"); + assert.equal(textPlan.shouldClearPreview, false); +}); + +test("Agent start plan consumes a dispatched prompt and resets transient flags", () => { + const queueItemType = undefined as + | Parameters[0]["queuedItems"][number] + | undefined; + const promptItem: typeof queueItemType = { + kind: "prompt", + chatId: 1, + replyToMessageId: 2, + sourceMessageIds: [2], + queueOrder: 2, + queueLane: "default", + laneOrder: 2, + queuedAttachments: [], + content: [{ type: "text", text: "prompt" }], + historyText: "prompt history", + statusSummary: "prompt", + }; + const plan = buildTelegramAgentStartPlan({ + queuedItems: [promptItem], + hasPendingDispatch: true, + hasActiveTurn: false, + }); + assert.equal(plan.activeTurn?.statusSummary, "prompt"); + assert.equal(plan.shouldClearDispatchPending, true); + assert.equal(plan.shouldResetPendingModelSwitch, true); + assert.equal(plan.shouldResetToolExecutions, true); + assert.deepEqual(plan.remainingItems, []); +}); + +test("Tool execution count helper respects active-turn presence", () => { + assert.equal( + getNextTelegramToolExecutionCount({ + hasActiveTurn: true, + currentCount: 0, + event: "start", + }), + 1, + ); + assert.equal( + getNextTelegramToolExecutionCount({ + hasActiveTurn: true, + currentCount: 1, + event: "end", + }), + 0, + ); + assert.equal( + getNextTelegramToolExecutionCount({ + hasActiveTurn: false, + currentCount: 3, + event: "end", + }), + 3, + ); +}); + +test("Dispatch is allowed only when every guard is clear", () => { + assert.equal( + __telegramTestUtils.canDispatchTelegramTurnState({ + compactionInProgress: false, + hasActiveTelegramTurn: false, + hasPendingTelegramDispatch: false, + isIdle: true, + hasPendingMessages: false, + }), + true, + ); +}); + +test("Dispatch is blocked during compaction", () => { + assert.equal( + __telegramTestUtils.canDispatchTelegramTurnState({ + compactionInProgress: true, + hasActiveTelegramTurn: false, + hasPendingTelegramDispatch: false, + isIdle: true, + hasPendingMessages: false, + }), + false, + ); +}); + +test("Dispatch is blocked while a Telegram turn is active or pending", () => { + assert.equal( + __telegramTestUtils.canDispatchTelegramTurnState({ + compactionInProgress: false, + hasActiveTelegramTurn: true, + hasPendingTelegramDispatch: false, + isIdle: true, + hasPendingMessages: false, + }), + false, + ); + assert.equal( + __telegramTestUtils.canDispatchTelegramTurnState({ + compactionInProgress: false, + hasActiveTelegramTurn: false, + hasPendingTelegramDispatch: true, + isIdle: true, + hasPendingMessages: false, + }), + false, + ); +}); + +test("Dispatch is blocked when pi is busy or has pending messages", () => { + assert.equal( + __telegramTestUtils.canDispatchTelegramTurnState({ + compactionInProgress: false, + hasActiveTelegramTurn: false, + hasPendingTelegramDispatch: false, + isIdle: false, + hasPendingMessages: false, + }), + false, + ); + assert.equal( + __telegramTestUtils.canDispatchTelegramTurnState({ + compactionInProgress: false, + hasActiveTelegramTurn: false, + hasPendingTelegramDispatch: false, + isIdle: true, + hasPendingMessages: true, + }), + false, + ); +}); + +test("In-flight model switch is allowed only for active Telegram turns with abort support", () => { + assert.equal( + __telegramTestUtils.canRestartTelegramTurnForModelSwitch({ + isIdle: false, + hasActiveTelegramTurn: true, + hasAbortHandler: true, + }), + true, + ); + assert.equal( + __telegramTestUtils.canRestartTelegramTurnForModelSwitch({ + isIdle: true, + hasActiveTelegramTurn: true, + hasAbortHandler: true, + }), + false, + ); + assert.equal( + __telegramTestUtils.canRestartTelegramTurnForModelSwitch({ + isIdle: false, + hasActiveTelegramTurn: false, + hasAbortHandler: true, + }), + false, + ); + assert.equal( + __telegramTestUtils.canRestartTelegramTurnForModelSwitch({ + isIdle: false, + hasActiveTelegramTurn: true, + hasAbortHandler: false, + }), + false, + ); +}); + +test("Pending model switch abort waits until no tool executions remain", () => { + assert.equal( + __telegramTestUtils.shouldTriggerPendingTelegramModelSwitchAbort({ + hasPendingModelSwitch: true, + hasActiveTelegramTurn: true, + hasAbortHandler: true, + activeToolExecutions: 0, + }), + true, + ); + assert.equal( + __telegramTestUtils.shouldTriggerPendingTelegramModelSwitchAbort({ + hasPendingModelSwitch: true, + hasActiveTelegramTurn: true, + hasAbortHandler: true, + activeToolExecutions: 1, + }), + false, + ); + assert.equal( + __telegramTestUtils.shouldTriggerPendingTelegramModelSwitchAbort({ + hasPendingModelSwitch: false, + hasActiveTelegramTurn: true, + hasAbortHandler: true, + activeToolExecutions: 0, + }), + false, + ); +}); + +test("Model-switch continuation restart queues before abort when state is present", () => { + const events: string[] = []; + assert.equal( + __telegramTestUtils.restartTelegramModelSwitchContinuation({ + activeTurn: { id: 1 }, + abort: () => { + events.push("abort"); + }, + selection: { model: { provider: "openai", id: "gpt-5" } }, + queueContinuation: (turn, selection) => { + events.push(`queue:${turn.id}:${selection.model.id}`); + }, + }), + true, + ); + assert.deepEqual(events, ["queue:1:gpt-5", "abort"]); + assert.equal( + __telegramTestUtils.restartTelegramModelSwitchContinuation({ + activeTurn: undefined, + abort: () => {}, + selection: { model: { provider: "openai", id: "gpt-5" } }, + queueContinuation: () => { + events.push("unexpected"); + }, + }), + false, + ); +}); + +test("Continuation prompt stays Telegram-scoped and resume-oriented", () => { + const text = __telegramTestUtils.buildTelegramModelSwitchContinuationText( + { provider: "openai", id: "gpt-5" }, + "high", + ); + assert.match(text, /^\[telegram\]/); + assert.match(text, /Continue the interrupted previous Telegram request/); + assert.match(text, /openai\/gpt-5/); + assert.match(text, /thinking level \(high\)/); +}); + +test("Control runtime runs the control item and always settles", async () => { + const events: string[] = []; + await executeTelegramControlItemRuntime( + { + kind: "control", + controlType: "status", + chatId: 1, + replyToMessageId: 2, + queueOrder: 1, + queueLane: "control", + laneOrder: 0, + statusSummary: "status", + execute: async () => { + events.push("execute"); + }, + }, + { + ctx: {} as never, + sendTextReply: async () => { + events.push("reply"); + return undefined; + }, + onSettled: () => { + events.push("settled"); + }, + }, + ); + assert.deepEqual(events, ["execute", "settled"]); +}); + +test("Control runtime reports failures before settling", async () => { + const events: string[] = []; + await executeTelegramControlItemRuntime( + { + kind: "control", + controlType: "model", + chatId: 3, + replyToMessageId: 4, + queueOrder: 2, + queueLane: "control", + laneOrder: 1, + statusSummary: "model", + execute: async () => { + throw new Error("boom"); + }, + }, + { + ctx: {} as never, + sendTextReply: async (_chatId, _replyToMessageId, text) => { + events.push(text); + return undefined; + }, + onSettled: () => { + events.push("settled"); + }, + }, + ); + assert.deepEqual(events, ["Telegram control action failed: boom", "settled"]); +}); + +test("Dispatch runtime idles on none and executes control items directly", () => { + const events: string[] = []; + executeTelegramQueueDispatchPlan( + { kind: "none", remainingItems: [] }, + { + executeControlItem: () => { + events.push("control"); + }, + onPromptDispatchStart: () => { + events.push("prompt-start"); + }, + sendUserMessage: () => { + events.push("prompt"); + }, + onPromptDispatchFailure: (message) => { + events.push(`error:${message}`); + }, + onIdle: () => { + events.push("idle"); + }, + }, + ); + executeTelegramQueueDispatchPlan( + { + kind: "control", + item: { + kind: "control", + controlType: "status", + chatId: 1, + replyToMessageId: 1, + queueOrder: 1, + queueLane: "control", + laneOrder: 0, + statusSummary: "status", + execute: async () => {}, + }, + remainingItems: [], + }, + { + executeControlItem: () => { + events.push("control"); + }, + onPromptDispatchStart: () => { + events.push("prompt-start"); + }, + sendUserMessage: () => { + events.push("prompt"); + }, + onPromptDispatchFailure: (message) => { + events.push(`error:${message}`); + }, + onIdle: () => { + events.push("idle"); + }, + }, + ); + assert.deepEqual(events, ["idle", "control"]); +}); + +test("Dispatch runtime reports prompt dispatch failures after starting", () => { + const events: string[] = []; + executeTelegramQueueDispatchPlan( + { + kind: "prompt", + item: { + kind: "prompt", + chatId: 2, + replyToMessageId: 3, + sourceMessageIds: [3], + queueOrder: 2, + queueLane: "default", + laneOrder: 2, + queuedAttachments: [], + content: [{ type: "text", text: "prompt" }], + historyText: "prompt", + statusSummary: "prompt", + }, + remainingItems: [], + }, + { + executeControlItem: () => { + events.push("control"); + }, + onPromptDispatchStart: (chatId) => { + events.push(`start:${chatId}`); + }, + sendUserMessage: () => { + throw new Error("boom"); + }, + onPromptDispatchFailure: (message) => { + events.push(`error:${message}`); + }, + onIdle: () => { + events.push("idle"); + }, + }, + ); + assert.deepEqual(events, ["start:2", "error:boom"]); +}); + +test("Session runtime helper starts polling only when a bot token exists and polling is idle", () => { + assert.equal( + shouldStartTelegramPolling({ + hasBotToken: true, + hasPollingPromise: false, + }), + true, + ); + assert.equal( + shouldStartTelegramPolling({ + hasBotToken: false, + hasPollingPromise: false, + }), + false, + ); + assert.equal( + shouldStartTelegramPolling({ + hasBotToken: true, + hasPollingPromise: true, + }), + false, + ); +}); + +test("Session runtime helper resets session start state", () => { + const currentModel = { provider: "openai", id: "gpt-5" } as const; + const state = buildTelegramSessionStartState(currentModel as never); + assert.equal(state.currentTelegramModel, currentModel); + assert.equal(state.activeTelegramToolExecutions, 0); + assert.equal(state.nextQueuedTelegramItemOrder, 0); + assert.equal(state.nextQueuedTelegramControlOrder, 0); + assert.equal(state.telegramTurnDispatchPending, false); + assert.equal(state.compactionInProgress, false); +}); + +test("Session runtime helper clears shutdown state", () => { + const state = buildTelegramSessionShutdownState(); + assert.deepEqual(state.queuedTelegramItems, []); + assert.equal(state.nextQueuedTelegramItemOrder, 0); + assert.equal(state.nextQueuedTelegramControlOrder, 0); + assert.equal(state.nextPriorityReactionOrder, 0); + assert.equal(state.currentTelegramModel, undefined); + assert.equal(state.activeTelegramToolExecutions, 0); + assert.equal(state.telegramTurnDispatchPending, false); + assert.equal(state.compactionInProgress, false); + assert.equal(state.preserveQueuedTurnsAsHistory, false); +}); + +test("Extension runtime polls, pairs, and dispatches an inbound Telegram turn into pi", async () => { + const agentDir = join(homedir(), ".pi", "agent"); + const configPath = join(agentDir, "telegram.json"); + const previousConfig = await readFile(configPath, "utf8").catch( + () => undefined, + ); + const handlers = new Map< + string, + (event: unknown, ctx: unknown) => Promise + >(); + const commands = new Map< + string, + { handler: (args: string, ctx: unknown) => Promise } + >(); + const sentMessages: Array> = + []; + let resolveDispatch: + | ((value: string | Array<{ type: string; text?: string }>) => void) + | undefined; + const dispatched = new Promise< + string | Array<{ type: string; text?: string }> + >((resolve) => { + resolveDispatch = resolve; + }); + const pi = { + on: ( + event: string, + handler: (event: unknown, ctx: unknown) => Promise, + ) => { + handlers.set(event, handler); + }, + registerCommand: ( + name: string, + definition: { handler: (args: string, ctx: unknown) => Promise }, + ) => { + commands.set(name, definition); + }, + registerTool: () => {}, + sendUserMessage: ( + content: string | Array<{ type: string; text?: string }>, + ) => { + sentMessages.push(content); + resolveDispatch?.(content); + }, + getThinkingLevel: () => "medium", + } as never; + const originalFetch = globalThis.fetch; + let getUpdatesCalls = 0; + const apiCalls: string[] = []; + globalThis.fetch = async (input) => { + const url = typeof input === "string" ? input : input.toString(); + const method = url.split("/").at(-1) ?? ""; + apiCalls.push(method); + if (method === "deleteWebhook") { + return { json: async () => ({ ok: true, result: true }) } as Response; + } + if (method === "getUpdates") { + getUpdatesCalls += 1; + if (getUpdatesCalls === 1) { + return { + json: async () => ({ + ok: true, + result: [ + { + _: "other", + update_id: 1, + message: { + message_id: 42, + chat: { id: 99, type: "private" }, + from: { id: 77, is_bot: false, first_name: "Test" }, + text: "hello from telegram", + }, + }, + ], + }), + } as Response; + } + throw new DOMException("stop", "AbortError"); + } + if (method === "sendMessage") { + return { + json: async () => ({ ok: true, result: { message_id: 100 } }), + } as Response; + } + if (method === "sendChatAction") { + return { json: async () => ({ ok: true, result: true }) } as Response; + } + throw new Error(`Unexpected Telegram API method: ${method}`); + }; + try { + await mkdir(agentDir, { recursive: true }); + await writeFile( + configPath, + JSON.stringify({ botToken: "123:abc", lastUpdateId: 0 }, null, "\t") + + "\n", + "utf8", + ); + telegramExtension(pi); + const ctx = { + hasUI: true, + model: undefined, + signal: undefined, + ui: { + theme: { + fg: (_token: string, text: string) => text, + }, + setStatus: () => {}, + notify: () => {}, + }, + isIdle: () => true, + hasPendingMessages: () => false, + abort: () => {}, + } as never; + await handlers.get("session_start")?.({}, ctx); + await commands.get("telegram-connect")?.handler("", ctx); + const dispatchedContent = await dispatched; + assert.equal(sentMessages.length, 1); + assert.equal(Array.isArray(dispatchedContent), true); + assert.equal(apiCalls.includes("sendMessage"), true); + assert.equal(apiCalls.includes("sendChatAction"), true); + const promptBlocks = dispatchedContent as Array<{ + type: string; + text?: string; + }>; + assert.equal(promptBlocks[0]?.type, "text"); + assert.match( + promptBlocks[0]?.text ?? "", + /^\[telegram\] hello from telegram$/, + ); + await handlers.get("session_shutdown")?.({}, ctx); + } finally { + globalThis.fetch = originalFetch; + if (previousConfig === undefined) { + await rm(configPath, { force: true }); + } else { + await writeFile(configPath, previousConfig, "utf8"); + } + } +}); + +test("Extension runtime finalizes a drafted preview into the final Telegram reply on agent end", async () => { + const agentDir = join(homedir(), ".pi", "agent"); + const configPath = join(agentDir, "telegram.json"); + const previousConfig = await readFile(configPath, "utf8").catch( + () => undefined, + ); + const handlers = new Map< + string, + (event: unknown, ctx: unknown) => Promise + >(); + const commands = new Map< + string, + { handler: (args: string, ctx: unknown) => Promise } + >(); + let resolveDispatch: (() => void) | undefined; + const dispatched = new Promise((resolve) => { + resolveDispatch = resolve; + }); + const draftTexts: string[] = []; + const sentTexts: string[] = []; + const pi = { + on: ( + event: string, + handler: (event: unknown, ctx: unknown) => Promise, + ) => { + handlers.set(event, handler); + }, + registerCommand: ( + name: string, + definition: { handler: (args: string, ctx: unknown) => Promise }, + ) => { + commands.set(name, definition); + }, + registerTool: () => {}, + sendUserMessage: () => { + resolveDispatch?.(); + }, + getThinkingLevel: () => "medium", + } as never; + const originalFetch = globalThis.fetch; + let getUpdatesCalls = 0; + globalThis.fetch = async (input, init) => { + const url = typeof input === "string" ? input : input.toString(); + const method = url.split("/").at(-1) ?? ""; + const body = + typeof init?.body === "string" + ? (JSON.parse(init.body) as Record) + : undefined; + if (method === "deleteWebhook") { + return { json: async () => ({ ok: true, result: true }) } as Response; + } + if (method === "getUpdates") { + getUpdatesCalls += 1; + if (getUpdatesCalls === 1) { + return { + json: async () => ({ + ok: true, + result: [ + { + _: "other", + update_id: 1, + message: { + message_id: 7, + chat: { id: 99, type: "private" }, + from: { id: 77, is_bot: false, first_name: "Test" }, + text: "please answer", + }, + }, + ], + }), + } as Response; + } + throw new DOMException("stop", "AbortError"); + } + if (method === "sendMessageDraft") { + draftTexts.push(String(body?.text ?? "")); + return { json: async () => ({ ok: true, result: true }) } as Response; + } + if (method === "sendMessage") { + sentTexts.push(String(body?.text ?? "")); + return { + json: async () => ({ + ok: true, + result: { message_id: 100 + sentTexts.length }, + }), + } as Response; + } + if (method === "sendChatAction") { + return { json: async () => ({ ok: true, result: true }) } as Response; + } + if (method === "editMessageText") { + return { json: async () => ({ ok: true, result: true }) } as Response; + } + throw new Error(`Unexpected Telegram API method: ${method}`); + }; + try { + await mkdir(agentDir, { recursive: true }); + await writeFile( + configPath, + JSON.stringify( + { botToken: "123:abc", allowedUserId: 77, lastUpdateId: 0 }, + null, + "\t", + ) + "\n", + "utf8", + ); + telegramExtension(pi); + const ctx = { + hasUI: true, + model: undefined, + signal: undefined, + ui: { + theme: { + fg: (_token: string, text: string) => text, + }, + setStatus: () => {}, + notify: () => {}, + }, + isIdle: () => true, + hasPendingMessages: () => false, + abort: () => {}, + } as never; + await handlers.get("session_start")?.({}, ctx); + await commands.get("telegram-connect")?.handler("", ctx); + await dispatched; + await handlers.get("agent_start")?.({}, ctx); + await handlers.get("message_update")?.( + { + message: { + role: "assistant", + content: [{ type: "text", text: "Draft **preview**" }], + }, + }, + ctx, + ); + await new Promise((resolve) => setTimeout(resolve, 850)); + await handlers.get("agent_end")?.( + { + messages: [ + { + role: "assistant", + content: [{ type: "text", text: "Final **answer**" }], + }, + ], + }, + ctx, + ); + assert.deepEqual(draftTexts, ["Draft preview", "Final answer", ""]); + assert.equal(sentTexts.length, 1); + assert.match(sentTexts[0] ?? "", /Final answer<\/b>/); + await handlers.get("session_shutdown")?.({}, ctx); + } finally { + globalThis.fetch = originalFetch; + if (previousConfig === undefined) { + await rm(configPath, { force: true }); + } else { + await writeFile(configPath, previousConfig, "utf8"); + } + } +}); + +test("Extension runtime carries queued follow-ups into history after an aborted turn", async () => { + const agentDir = join(homedir(), ".pi", "agent"); + const configPath = join(agentDir, "telegram.json"); + const previousConfig = await readFile(configPath, "utf8").catch( + () => undefined, + ); + const handlers = new Map< + string, + (event: unknown, ctx: unknown) => Promise + >(); + const commands = new Map< + string, + { handler: (args: string, ctx: unknown) => Promise } + >(); + const sentMessages: Array> = + []; + let firstDispatchResolved = false; + let secondUpdatesResolve: ((value: Response) => void) | undefined; + let thirdUpdatesResolve: ((value: Response) => void) | undefined; + let fourthUpdatesResolve: ((value: Response) => void) | undefined; + const secondUpdates = new Promise((resolve) => { + secondUpdatesResolve = resolve; + }); + const thirdUpdates = new Promise((resolve) => { + thirdUpdatesResolve = resolve; + }); + const fourthUpdates = new Promise((resolve) => { + fourthUpdatesResolve = resolve; + }); + const pi = { + on: ( + event: string, + handler: (event: unknown, ctx: unknown) => Promise, + ) => { + handlers.set(event, handler); + }, + registerCommand: ( + name: string, + definition: { handler: (args: string, ctx: unknown) => Promise }, + ) => { + commands.set(name, definition); + }, + registerTool: () => {}, + sendUserMessage: ( + content: string | Array<{ type: string; text?: string }>, + ) => { + sentMessages.push(content); + firstDispatchResolved = true; + }, + getThinkingLevel: () => "medium", + } as never; + const originalFetch = globalThis.fetch; + let getUpdatesCalls = 0; + const sendTexts: string[] = []; + globalThis.fetch = async (input, init) => { + const url = typeof input === "string" ? input : input.toString(); + const method = url.split("/").at(-1) ?? ""; + const body = + typeof init?.body === "string" + ? (JSON.parse(init.body) as Record) + : undefined; + if (method === "deleteWebhook") { + return { json: async () => ({ ok: true, result: true }) } as Response; + } + if (method === "getUpdates") { + getUpdatesCalls += 1; + if (getUpdatesCalls === 1) { + return { + json: async () => ({ + ok: true, + result: [ + { + _: "other", + update_id: 1, + message: { + message_id: 10, + chat: { id: 99, type: "private" }, + from: { id: 77, is_bot: false, first_name: "Test" }, + text: "first request", + }, + }, + ], + }), + } as Response; + } + if (getUpdatesCalls === 2) return secondUpdates; + if (getUpdatesCalls === 3) return thirdUpdates; + if (getUpdatesCalls === 4) return fourthUpdates; + throw new DOMException("stop", "AbortError"); + } + if (method === "sendMessage") { + sendTexts.push(String(body?.text ?? "")); + return { + json: async () => ({ + ok: true, + result: { message_id: 100 + sendTexts.length }, + }), + } as Response; + } + if (method === "sendChatAction") { + return { json: async () => ({ ok: true, result: true }) } as Response; + } + throw new Error(`Unexpected Telegram API method: ${method}`); + }; + try { + await mkdir(agentDir, { recursive: true }); + await writeFile( + configPath, + JSON.stringify( + { botToken: "123:abc", allowedUserId: 77, lastUpdateId: 0 }, + null, + "\t", + ) + "\n", + "utf8", + ); + telegramExtension(pi); + const baseCtx = { + hasUI: true, + model: undefined, + signal: undefined, + ui: { + theme: { + fg: (_token: string, text: string) => text, + }, + setStatus: () => {}, + notify: () => {}, + }, + hasPendingMessages: () => false, + }; + const idleCtx = { + ...baseCtx, + isIdle: () => true, + abort: () => {}, + } as never; + let aborted = false; + const activeCtx = { + ...baseCtx, + isIdle: () => false, + abort: () => { + aborted = true; + }, + } as never; + await handlers.get("session_start")?.({}, idleCtx); + await commands.get("telegram-connect")?.handler("", idleCtx); + await waitForCondition(() => firstDispatchResolved); + await handlers.get("agent_start")?.({}, activeCtx); + secondUpdatesResolve?.({ + json: async () => ({ + ok: true, + result: [ + { + _: "other", + update_id: 2, + message: { + message_id: 11, + chat: { id: 99, type: "private" }, + from: { id: 77, is_bot: false, first_name: "Test" }, + text: "follow up", + }, + }, + ], + }), + } as Response); + await waitForCondition(() => getUpdatesCalls >= 3); + thirdUpdatesResolve?.({ + json: async () => ({ + ok: true, + result: [ + { + _: "other", + update_id: 3, + message: { + message_id: 12, + chat: { id: 99, type: "private" }, + from: { id: 77, is_bot: false, first_name: "Test" }, + text: "/stop", + }, + }, + ], + }), + } as Response); + await waitForCondition(() => aborted); + await handlers.get("agent_end")?.( + { + messages: [ + { + role: "assistant", + stopReason: "aborted", + content: [{ type: "text", text: "" }], + }, + ], + }, + idleCtx, + ); + const dispatchCountBeforeNextTurn = sentMessages.length; + fourthUpdatesResolve?.({ + json: async () => ({ + ok: true, + result: [ + { + _: "other", + update_id: 4, + message: { + message_id: 13, + chat: { id: 99, type: "private" }, + from: { id: 77, is_bot: false, first_name: "Test" }, + text: "new request", + }, + }, + ], + }), + } as Response); + await waitForCondition( + () => sentMessages.length === dispatchCountBeforeNextTurn + 1, + ); + const promptBlocks = sentMessages.at(-1) as Array<{ + type: string; + text?: string; + }>; + const promptText = promptBlocks[0]?.text ?? ""; + assert.match(promptText, /^\[telegram\]/); + assert.match( + promptText, + /Earlier Telegram messages arrived after an aborted turn/, + ); + assert.match(promptText, /1\. follow up/); + assert.match(promptText, /Current Telegram message:\nnew request/); + assert.equal(sendTexts.includes("Aborted current turn."), true); + await handlers.get("session_shutdown")?.({}, idleCtx); + } finally { + globalThis.fetch = originalFetch; + if (previousConfig === undefined) { + await rm(configPath, { force: true }); + } else { + await writeFile(configPath, previousConfig, "utf8"); + } + } +}); + +test("Extension runtime runs queued status control before the next queued prompt after agent end", async () => { + const agentDir = join(homedir(), ".pi", "agent"); + const configPath = join(agentDir, "telegram.json"); + const previousConfig = await readFile(configPath, "utf8").catch( + () => undefined, + ); + const handlers = new Map< + string, + (event: unknown, ctx: unknown) => Promise + >(); + const commands = new Map< + string, + { handler: (args: string, ctx: unknown) => Promise } + >(); + const runtimeEvents: string[] = []; + let firstDispatchResolved = false; + let secondUpdatesResolve: ((value: Response) => void) | undefined; + let thirdUpdatesResolve: ((value: Response) => void) | undefined; + const secondUpdates = new Promise((resolve) => { + secondUpdatesResolve = resolve; + }); + const thirdUpdates = new Promise((resolve) => { + thirdUpdatesResolve = resolve; + }); + const pi = { + on: ( + event: string, + handler: (event: unknown, ctx: unknown) => Promise, + ) => { + handlers.set(event, handler); + }, + registerCommand: ( + name: string, + definition: { handler: (args: string, ctx: unknown) => Promise }, + ) => { + commands.set(name, definition); + }, + registerTool: () => {}, + sendUserMessage: ( + content: string | Array<{ type: string; text?: string }>, + ) => { + const promptBlocks = content as Array<{ type: string; text?: string }>; + runtimeEvents.push(`dispatch:${promptBlocks[0]?.text ?? ""}`); + firstDispatchResolved = true; + }, + getThinkingLevel: () => "medium", + } as never; + const originalFetch = globalThis.fetch; + let getUpdatesCalls = 0; + globalThis.fetch = async (input, init) => { + const url = typeof input === "string" ? input : input.toString(); + const method = url.split("/").at(-1) ?? ""; + const body = + typeof init?.body === "string" + ? (JSON.parse(init.body) as Record) + : undefined; + if (method === "deleteWebhook") { + return { json: async () => ({ ok: true, result: true }) } as Response; + } + if (method === "getUpdates") { + getUpdatesCalls += 1; + if (getUpdatesCalls === 1) { + return { + json: async () => ({ + ok: true, + result: [ + { + _: "other", + update_id: 1, + message: { + message_id: 20, + chat: { id: 99, type: "private" }, + from: { id: 77, is_bot: false, first_name: "Test" }, + text: "first request", + }, + }, + ], + }), + } as Response; + } + if (getUpdatesCalls === 2) return secondUpdates; + if (getUpdatesCalls === 3) return thirdUpdates; + throw new DOMException("stop", "AbortError"); + } + if (method === "sendMessage") { + runtimeEvents.push(`send:${String(body?.text ?? "")}`); + return { + json: async () => ({ + ok: true, + result: { message_id: 100 + runtimeEvents.length }, + }), + } as Response; + } + if (method === "sendChatAction") { + return { json: async () => ({ ok: true, result: true }) } as Response; + } + throw new Error(`Unexpected Telegram API method: ${method}`); + }; + try { + await mkdir(agentDir, { recursive: true }); + await writeFile( + configPath, + JSON.stringify( + { botToken: "123:abc", allowedUserId: 77, lastUpdateId: 0 }, + null, + "\t", + ) + "\n", + "utf8", + ); + telegramExtension(pi); + const baseCtx = { + hasUI: true, + cwd: process.cwd(), + model: undefined, + signal: undefined, + ui: { + theme: { + fg: (_token: string, text: string) => text, + }, + setStatus: () => {}, + notify: () => {}, + }, + sessionManager: { + getEntries: () => [], + }, + modelRegistry: { + refresh: () => {}, + getAvailable: () => [], + isUsingOAuth: () => false, + }, + getContextUsage: () => undefined, + hasPendingMessages: () => false, + abort: () => {}, + }; + const idleCtx = { + ...baseCtx, + isIdle: () => true, + } as never; + const activeCtx = { + ...baseCtx, + isIdle: () => false, + } as never; + await handlers.get("session_start")?.({}, idleCtx); + await commands.get("telegram-connect")?.handler("", idleCtx); + await waitForCondition(() => firstDispatchResolved); + await handlers.get("agent_start")?.({}, activeCtx); + secondUpdatesResolve?.({ + json: async () => ({ + ok: true, + result: [ + { + _: "other", + update_id: 2, + message: { + message_id: 21, + chat: { id: 99, type: "private" }, + from: { id: 77, is_bot: false, first_name: "Test" }, + text: "/status", + }, + }, + ], + }), + } as Response); + await waitForCondition(() => getUpdatesCalls >= 3); + thirdUpdatesResolve?.({ + json: async () => ({ + ok: true, + result: [ + { + _: "other", + update_id: 3, + message: { + message_id: 22, + chat: { id: 99, type: "private" }, + from: { id: 77, is_bot: false, first_name: "Test" }, + text: "follow up after status", + }, + }, + ], + }), + } as Response); + await waitForCondition(() => runtimeEvents.length >= 1); + await handlers.get("agent_end")?.( + { + messages: [ + { + role: "assistant", + content: [{ type: "text", text: "" }], + }, + ], + }, + idleCtx, + ); + await waitForCondition(() => runtimeEvents.length >= 3); + assert.equal(runtimeEvents[0], "dispatch:[telegram] first request"); + assert.match(runtimeEvents[1] ?? "", /^send:Context:<\/b>/); + assert.equal( + runtimeEvents[2], + "dispatch:[telegram] follow up after status", + ); + await handlers.get("session_shutdown")?.({}, idleCtx); + } finally { + globalThis.fetch = originalFetch; + if (previousConfig === undefined) { + await rm(configPath, { force: true }); + } else { + await writeFile(configPath, previousConfig, "utf8"); + } + } +}); + +test("Extension runtime runs queued model control before the next queued prompt after agent end", async () => { + const agentDir = join(homedir(), ".pi", "agent"); + const configPath = join(agentDir, "telegram.json"); + const previousConfig = await readFile(configPath, "utf8").catch( + () => undefined, + ); + const handlers = new Map< + string, + (event: unknown, ctx: unknown) => Promise + >(); + const commands = new Map< + string, + { handler: (args: string, ctx: unknown) => Promise } + >(); + const runtimeEvents: string[] = []; + const modelA = { + provider: "openai", + id: "gpt-a", + reasoning: true, + } as const; + const modelB = { + provider: "anthropic", + id: "claude-b", + reasoning: false, + } as const; + let firstDispatchResolved = false; + let secondUpdatesResolve: ((value: Response) => void) | undefined; + let thirdUpdatesResolve: ((value: Response) => void) | undefined; + const secondUpdates = new Promise((resolve) => { + secondUpdatesResolve = resolve; + }); + const thirdUpdates = new Promise((resolve) => { + thirdUpdatesResolve = resolve; + }); + const pi = { + on: ( + event: string, + handler: (event: unknown, ctx: unknown) => Promise, + ) => { + handlers.set(event, handler); + }, + registerCommand: ( + name: string, + definition: { handler: (args: string, ctx: unknown) => Promise }, + ) => { + commands.set(name, definition); + }, + registerTool: () => {}, + sendUserMessage: ( + content: string | Array<{ type: string; text?: string }>, + ) => { + const promptBlocks = content as Array<{ type: string; text?: string }>; + runtimeEvents.push(`dispatch:${promptBlocks[0]?.text ?? ""}`); + firstDispatchResolved = true; + }, + getThinkingLevel: () => "medium", + } as never; + const originalFetch = globalThis.fetch; + let getUpdatesCalls = 0; + globalThis.fetch = async (input, init) => { + const url = typeof input === "string" ? input : input.toString(); + const method = url.split("/").at(-1) ?? ""; + const body = + typeof init?.body === "string" + ? (JSON.parse(init.body) as Record) + : undefined; + if (method === "deleteWebhook") { + return { json: async () => ({ ok: true, result: true }) } as Response; + } + if (method === "getUpdates") { + getUpdatesCalls += 1; + if (getUpdatesCalls === 1) { + return { + json: async () => ({ + ok: true, + result: [ + { + _: "other", + update_id: 1, + message: { + message_id: 23, + chat: { id: 99, type: "private" }, + from: { id: 77, is_bot: false, first_name: "Test" }, + text: "first request", + }, + }, + ], + }), + } as Response; + } + if (getUpdatesCalls === 2) return secondUpdates; + if (getUpdatesCalls === 3) return thirdUpdates; + throw new DOMException("stop", "AbortError"); + } + if (method === "sendMessage") { + runtimeEvents.push(`send:${String(body?.text ?? "")}`); + return { + json: async () => ({ + ok: true, + result: { message_id: 100 + runtimeEvents.length }, + }), + } as Response; + } + if (method === "sendChatAction") { + return { json: async () => ({ ok: true, result: true }) } as Response; + } + throw new Error(`Unexpected Telegram API method: ${method}`); + }; + try { + await mkdir(agentDir, { recursive: true }); + await writeFile( + configPath, + JSON.stringify( + { botToken: "123:abc", allowedUserId: 77, lastUpdateId: 0 }, + null, + "\t", + ) + "\n", + "utf8", + ); + telegramExtension(pi); + const baseCtx = { + hasUI: true, + cwd: process.cwd(), + model: modelA, + signal: undefined, + ui: { + theme: { + fg: (_token: string, text: string) => text, + }, + setStatus: () => {}, + notify: () => {}, + }, + sessionManager: { + getEntries: () => [], + }, + modelRegistry: { + refresh: () => {}, + getAvailable: () => [modelA, modelB], + isUsingOAuth: () => false, + }, + getContextUsage: () => undefined, + hasPendingMessages: () => false, + abort: () => {}, + }; + const idleCtx = { + ...baseCtx, + isIdle: () => true, + } as never; + const activeCtx = { + ...baseCtx, + isIdle: () => false, + } as never; + await handlers.get("session_start")?.({}, idleCtx); + await commands.get("telegram-connect")?.handler("", idleCtx); + await waitForCondition(() => firstDispatchResolved); + await handlers.get("agent_start")?.({}, activeCtx); + secondUpdatesResolve?.({ + json: async () => ({ + ok: true, + result: [ + { + _: "other", + update_id: 2, + message: { + message_id: 24, + chat: { id: 99, type: "private" }, + from: { id: 77, is_bot: false, first_name: "Test" }, + text: "/model", + }, + }, + ], + }), + } as Response); + await waitForCondition(() => getUpdatesCalls >= 3); + thirdUpdatesResolve?.({ + json: async () => ({ + ok: true, + result: [ + { + _: "other", + update_id: 3, + message: { + message_id: 25, + chat: { id: 99, type: "private" }, + from: { id: 77, is_bot: false, first_name: "Test" }, + text: "follow up after model", + }, + }, + ], + }), + } as Response); + await waitForCondition(() => runtimeEvents.length >= 1); + await handlers.get("agent_end")?.( + { + messages: [ + { + role: "assistant", + content: [{ type: "text", text: "" }], + }, + ], + }, + idleCtx, + ); + await waitForCondition(() => runtimeEvents.length >= 3); + assert.equal(runtimeEvents[0], "dispatch:[telegram] first request"); + assert.equal(runtimeEvents[1], "send:Choose a model:"); + assert.equal(runtimeEvents[2], "dispatch:[telegram] follow up after model"); + await handlers.get("session_shutdown")?.({}, idleCtx); + } finally { + globalThis.fetch = originalFetch; + if (previousConfig === undefined) { + await rm(configPath, { force: true }); + } else { + await writeFile(configPath, previousConfig, "utf8"); + } + } +}); + +test("Extension runtime keeps queued turns blocked until compaction completes", async () => { + const agentDir = join(homedir(), ".pi", "agent"); + const configPath = join(agentDir, "telegram.json"); + const previousConfig = await readFile(configPath, "utf8").catch( + () => undefined, + ); + const handlers = new Map< + string, + (event: unknown, ctx: unknown) => Promise + >(); + const commands = new Map< + string, + { handler: (args: string, ctx: unknown) => Promise } + >(); + const runtimeEvents: string[] = []; + let compactHooks: + | { + onComplete: () => void; + onError: (error: unknown) => void; + } + | undefined; + let secondUpdatesResolve: ((value: Response) => void) | undefined; + const secondUpdates = new Promise((resolve) => { + secondUpdatesResolve = resolve; + }); + const pi = { + on: ( + event: string, + handler: (event: unknown, ctx: unknown) => Promise, + ) => { + handlers.set(event, handler); + }, + registerCommand: ( + name: string, + definition: { handler: (args: string, ctx: unknown) => Promise }, + ) => { + commands.set(name, definition); + }, + registerTool: () => {}, + sendUserMessage: ( + content: string | Array<{ type: string; text?: string }>, + ) => { + const promptBlocks = content as Array<{ type: string; text?: string }>; + runtimeEvents.push(`dispatch:${promptBlocks[0]?.text ?? ""}`); + }, + getThinkingLevel: () => "medium", + } as never; + const originalFetch = globalThis.fetch; + let getUpdatesCalls = 0; + globalThis.fetch = async (input, init) => { + const url = typeof input === "string" ? input : input.toString(); + const method = url.split("/").at(-1) ?? ""; + const body = + typeof init?.body === "string" + ? (JSON.parse(init.body) as Record) + : undefined; + if (method === "deleteWebhook") { + return { json: async () => ({ ok: true, result: true }) } as Response; + } + if (method === "getUpdates") { + getUpdatesCalls += 1; + if (getUpdatesCalls === 1) { + return { + json: async () => ({ + ok: true, + result: [ + { + _: "other", + update_id: 1, + message: { + message_id: 30, + chat: { id: 99, type: "private" }, + from: { id: 77, is_bot: false, first_name: "Test" }, + text: "/compact", + }, + }, + ], + }), + } as Response; + } + if (getUpdatesCalls === 2) { + return secondUpdates; + } + throw new DOMException("stop", "AbortError"); + } + if (method === "sendMessage") { + runtimeEvents.push(`send:${String(body?.text ?? "")}`); + return { + json: async () => ({ + ok: true, + result: { message_id: 100 + runtimeEvents.length }, + }), + } as Response; + } + throw new Error(`Unexpected Telegram API method: ${method}`); + }; + try { + await mkdir(agentDir, { recursive: true }); + await writeFile( + configPath, + JSON.stringify( + { botToken: "123:abc", allowedUserId: 77, lastUpdateId: 0 }, + null, + "\t", + ) + "\n", + "utf8", + ); + telegramExtension(pi); + const ctx = { + hasUI: true, + model: undefined, + signal: undefined, + ui: { + theme: { + fg: (_token: string, text: string) => text, + }, + setStatus: () => {}, + notify: () => {}, + }, + isIdle: () => true, + hasPendingMessages: () => false, + abort: () => {}, + compact: (hooks: { + onComplete: () => void; + onError: (error: unknown) => void; + }) => { + compactHooks = hooks; + runtimeEvents.push("compact:start"); + }, + } as never; + await handlers.get("session_start")?.({}, ctx); + await commands.get("telegram-connect")?.handler("", ctx); + await waitForCondition(() => runtimeEvents.includes("compact:start")); + assert.equal(runtimeEvents.includes("send:Compaction started."), true); + secondUpdatesResolve?.({ + json: async () => ({ + ok: true, + result: [ + { + _: "other", + update_id: 2, + message: { + message_id: 31, + chat: { id: 99, type: "private" }, + from: { id: 77, is_bot: false, first_name: "Test" }, + text: "follow up after compaction", + }, + }, + ], + }), + } as Response); + await waitForCondition(() => getUpdatesCalls >= 3); + assert.equal( + runtimeEvents.some( + (event) => event === "dispatch:[telegram] follow up after compaction", + ), + false, + ); + compactHooks?.onComplete(); + await waitForCondition(() => + runtimeEvents.includes("dispatch:[telegram] follow up after compaction"), + ); + await waitForCondition(() => + runtimeEvents.includes("send:Compaction completed."), + ); + await handlers.get("session_shutdown")?.({}, ctx); + } finally { + globalThis.fetch = originalFetch; + if (previousConfig === undefined) { + await rm(configPath, { force: true }); + } else { + await writeFile(configPath, previousConfig, "utf8"); + } + } +}); + +test("Extension runtime coalesces media-group updates into one delayed dispatch", async () => { + const agentDir = join(homedir(), ".pi", "agent"); + const configPath = join(agentDir, "telegram.json"); + const previousConfig = await readFile(configPath, "utf8").catch( + () => undefined, + ); + const handlers = new Map< + string, + (event: unknown, ctx: unknown) => Promise + >(); + const commands = new Map< + string, + { handler: (args: string, ctx: unknown) => Promise } + >(); + const runtimeEvents: string[] = []; + const pi = { + on: ( + event: string, + handler: (event: unknown, ctx: unknown) => Promise, + ) => { + handlers.set(event, handler); + }, + registerCommand: ( + name: string, + definition: { handler: (args: string, ctx: unknown) => Promise }, + ) => { + commands.set(name, definition); + }, + registerTool: () => {}, + sendUserMessage: ( + content: string | Array<{ type: string; text?: string }>, + ) => { + const promptBlocks = content as Array<{ type: string; text?: string }>; + runtimeEvents.push(`dispatch:${promptBlocks[0]?.text ?? ""}`); + }, + getThinkingLevel: () => "medium", + } as never; + const originalFetch = globalThis.fetch; + let getUpdatesCalls = 0; + globalThis.fetch = async (input) => { + const url = typeof input === "string" ? input : input.toString(); + const method = url.split("/").at(-1) ?? ""; + if (method === "deleteWebhook") { + return { json: async () => ({ ok: true, result: true }) } as Response; + } + if (method === "getUpdates") { + getUpdatesCalls += 1; + if (getUpdatesCalls === 1) { + return { + json: async () => ({ + ok: true, + result: [ + { + _: "other", + update_id: 1, + message: { + message_id: 40, + media_group_id: "album-1", + chat: { id: 99, type: "private" }, + from: { id: 77, is_bot: false, first_name: "Test" }, + caption: "first caption", + }, + }, + { + _: "other", + update_id: 2, + message: { + message_id: 41, + media_group_id: "album-1", + chat: { id: 99, type: "private" }, + from: { id: 77, is_bot: false, first_name: "Test" }, + caption: "second caption", + }, + }, + ], + }), + } as Response; + } + throw new DOMException("stop", "AbortError"); + } + throw new Error(`Unexpected Telegram API method: ${method}`); + }; + try { + await mkdir(agentDir, { recursive: true }); + await writeFile( + configPath, + JSON.stringify( + { botToken: "123:abc", allowedUserId: 77, lastUpdateId: 0 }, + null, + "\t", + ) + "\n", + "utf8", + ); + telegramExtension(pi); + const ctx = { + hasUI: true, + model: undefined, + signal: undefined, + ui: { + theme: { + fg: (_token: string, text: string) => text, + }, + setStatus: () => {}, + notify: () => {}, + }, + isIdle: () => true, + hasPendingMessages: () => false, + abort: () => {}, + } as never; + await handlers.get("session_start")?.({}, ctx); + await commands.get("telegram-connect")?.handler("", ctx); + await new Promise((resolve) => setTimeout(resolve, 300)); + assert.equal(runtimeEvents.length, 0); + await waitForCondition(() => runtimeEvents.length === 1, 2500); + assert.equal( + runtimeEvents[0], + "dispatch:[telegram] first caption\n\nsecond caption", + ); + await handlers.get("session_shutdown")?.({}, ctx); + } finally { + globalThis.fetch = originalFetch; + if (previousConfig === undefined) { + await rm(configPath, { force: true }); + } else { + await writeFile(configPath, previousConfig, "utf8"); + } + } +}); + +test("Extension runtime applies reaction priority and removal before the next dispatch", async () => { + const agentDir = join(homedir(), ".pi", "agent"); + const configPath = join(agentDir, "telegram.json"); + const previousConfig = await readFile(configPath, "utf8").catch( + () => undefined, + ); + const handlers = new Map< + string, + (event: unknown, ctx: unknown) => Promise + >(); + const commands = new Map< + string, + { handler: (args: string, ctx: unknown) => Promise } + >(); + const runtimeEvents: string[] = []; + let firstDispatchResolved = false; + let secondUpdatesResolve: ((value: Response) => void) | undefined; + let thirdUpdatesResolve: ((value: Response) => void) | undefined; + let fourthUpdatesResolve: ((value: Response) => void) | undefined; + let fifthUpdatesResolve: ((value: Response) => void) | undefined; + const secondUpdates = new Promise((resolve) => { + secondUpdatesResolve = resolve; + }); + const thirdUpdates = new Promise((resolve) => { + thirdUpdatesResolve = resolve; + }); + const fourthUpdates = new Promise((resolve) => { + fourthUpdatesResolve = resolve; + }); + const fifthUpdates = new Promise((resolve) => { + fifthUpdatesResolve = resolve; + }); + const pi = { + on: ( + event: string, + handler: (event: unknown, ctx: unknown) => Promise, + ) => { + handlers.set(event, handler); + }, + registerCommand: ( + name: string, + definition: { handler: (args: string, ctx: unknown) => Promise }, + ) => { + commands.set(name, definition); + }, + registerTool: () => {}, + sendUserMessage: ( + content: string | Array<{ type: string; text?: string }>, + ) => { + const promptBlocks = content as Array<{ type: string; text?: string }>; + runtimeEvents.push(`dispatch:${promptBlocks[0]?.text ?? ""}`); + firstDispatchResolved = true; + }, + getThinkingLevel: () => "medium", + } as never; + const originalFetch = globalThis.fetch; + let getUpdatesCalls = 0; + globalThis.fetch = async (input) => { + const url = typeof input === "string" ? input : input.toString(); + const method = url.split("/").at(-1) ?? ""; + if (method === "deleteWebhook") { + return { json: async () => ({ ok: true, result: true }) } as Response; + } + if (method === "getUpdates") { + getUpdatesCalls += 1; + if (getUpdatesCalls === 1) { + return { + json: async () => ({ + ok: true, + result: [ + { + _: "other", + update_id: 1, + message: { + message_id: 30, + chat: { id: 99, type: "private" }, + from: { id: 77, is_bot: false, first_name: "Test" }, + text: "first request", + }, + }, + ], + }), + } as Response; + } + if (getUpdatesCalls === 2) return secondUpdates; + if (getUpdatesCalls === 3) return thirdUpdates; + if (getUpdatesCalls === 4) return fourthUpdates; + if (getUpdatesCalls === 5) return fifthUpdates; + throw new DOMException("stop", "AbortError"); + } + if (method === "sendChatAction") { + return { json: async () => ({ ok: true, result: true }) } as Response; + } + throw new Error(`Unexpected Telegram API method: ${method}`); + }; + try { + await mkdir(agentDir, { recursive: true }); + await writeFile( + configPath, + JSON.stringify( + { botToken: "123:abc", allowedUserId: 77, lastUpdateId: 0 }, + null, + "\t", + ) + "\n", + "utf8", + ); + telegramExtension(pi); + const baseCtx = { + hasUI: true, + model: undefined, + signal: undefined, + ui: { + theme: { + fg: (_token: string, text: string) => text, + }, + setStatus: () => {}, + notify: () => {}, + }, + hasPendingMessages: () => false, + abort: () => {}, + }; + const idleCtx = { + ...baseCtx, + isIdle: () => true, + } as never; + const activeCtx = { + ...baseCtx, + isIdle: () => false, + } as never; + await handlers.get("session_start")?.({}, idleCtx); + await commands.get("telegram-connect")?.handler("", idleCtx); + await waitForCondition(() => firstDispatchResolved); + await handlers.get("agent_start")?.({}, activeCtx); + secondUpdatesResolve?.({ + json: async () => ({ + ok: true, + result: [ + { + _: "other", + update_id: 2, + message: { + message_id: 31, + chat: { id: 99, type: "private" }, + from: { id: 77, is_bot: false, first_name: "Test" }, + text: "older waiting", + }, + }, + ], + }), + } as Response); + await waitForCondition(() => getUpdatesCalls >= 3); + thirdUpdatesResolve?.({ + json: async () => ({ + ok: true, + result: [ + { + _: "other", + update_id: 3, + message: { + message_id: 32, + chat: { id: 99, type: "private" }, + from: { id: 77, is_bot: false, first_name: "Test" }, + text: "newer waiting", + }, + }, + ], + }), + } as Response); + await waitForCondition(() => getUpdatesCalls >= 4); + fourthUpdatesResolve?.({ + json: async () => ({ + ok: true, + result: [ + { + _: "other", + update_id: 4, + message_reaction: { + chat: { id: 99, type: "private" }, + message_id: 32, + user: { id: 77, is_bot: false, first_name: "Test" }, + old_reaction: [], + new_reaction: [{ type: "emoji", emoji: "πŸ‘" }], + date: 1, + }, + }, + ], + }), + } as Response); + await waitForCondition(() => getUpdatesCalls >= 5); + fifthUpdatesResolve?.({ + json: async () => ({ + ok: true, + result: [ + { + _: "other", + update_id: 5, + message_reaction: { + chat: { id: 99, type: "private" }, + message_id: 31, + user: { id: 77, is_bot: false, first_name: "Test" }, + old_reaction: [], + new_reaction: [{ type: "emoji", emoji: "πŸ‘Ž" }], + date: 2, + }, + }, + ], + }), + } as Response); + await waitForCondition(() => getUpdatesCalls >= 6); + await handlers.get("agent_end")?.( + { + messages: [ + { + role: "assistant", + content: [{ type: "text", text: "" }], + }, + ], + }, + idleCtx, + ); + await waitForCondition(() => runtimeEvents.length === 2); + assert.equal(runtimeEvents[0], "dispatch:[telegram] first request"); + assert.equal(runtimeEvents[1], "dispatch:[telegram] newer waiting"); + await handlers.get("agent_start")?.({}, activeCtx); + await handlers.get("agent_end")?.( + { + messages: [ + { + role: "assistant", + content: [{ type: "text", text: "" }], + }, + ], + }, + idleCtx, + ); + await new Promise((resolve) => setTimeout(resolve, 50)); + assert.deepEqual(runtimeEvents, [ + "dispatch:[telegram] first request", + "dispatch:[telegram] newer waiting", + ]); + await handlers.get("session_shutdown")?.({}, idleCtx); + } finally { + globalThis.fetch = originalFetch; + if (previousConfig === undefined) { + await rm(configPath, { force: true }); + } else { + await writeFile(configPath, previousConfig, "utf8"); + } + } +}); + +test("Extension runtime switches model in flight and dispatches a continuation turn after abort", async () => { + const agentDir = join(homedir(), ".pi", "agent"); + const configPath = join(agentDir, "telegram.json"); + const previousConfig = await readFile(configPath, "utf8").catch( + () => undefined, + ); + const handlers = new Map< + string, + (event: unknown, ctx: unknown) => Promise + >(); + const commands = new Map< + string, + { handler: (args: string, ctx: unknown) => Promise } + >(); + const runtimeEvents: string[] = []; + const modelA = { + provider: "openai", + id: "gpt-a", + reasoning: true, + } as const; + const modelB = { + provider: "anthropic", + id: "claude-b", + reasoning: false, + } as const; + let idle = true; + let aborted = false; + const setModels: Array = []; + let secondUpdatesResolve: ((value: Response) => void) | undefined; + let thirdUpdatesResolve: ((value: Response) => void) | undefined; + const secondUpdates = new Promise((resolve) => { + secondUpdatesResolve = resolve; + }); + const thirdUpdates = new Promise((resolve) => { + thirdUpdatesResolve = resolve; + }); + const pi = { + on: ( + event: string, + handler: (event: unknown, ctx: unknown) => Promise, + ) => { + handlers.set(event, handler); + }, + registerCommand: ( + name: string, + definition: { handler: (args: string, ctx: unknown) => Promise }, + ) => { + commands.set(name, definition); + }, + registerTool: () => {}, + sendUserMessage: ( + content: string | Array<{ type: string; text?: string }>, + ) => { + const promptBlocks = content as Array<{ type: string; text?: string }>; + runtimeEvents.push(`dispatch:${promptBlocks[0]?.text ?? ""}`); + }, + getThinkingLevel: () => "medium", + setModel: async (model: { provider: string; id: string }) => { + setModels.push(`${model.provider}/${model.id}`); + return true; + }, + setThinkingLevel: () => {}, + } as never; + const originalFetch = globalThis.fetch; + let getUpdatesCalls = 0; + let nextMessageId = 100; + const callbackAnswers: string[] = []; + globalThis.fetch = async (input, init) => { + const url = typeof input === "string" ? input : input.toString(); + const method = url.split("/").at(-1) ?? ""; + const body = + typeof init?.body === "string" + ? (JSON.parse(init.body) as Record) + : undefined; + if (method === "deleteWebhook") { + return { json: async () => ({ ok: true, result: true }) } as Response; + } + if (method === "getUpdates") { + getUpdatesCalls += 1; + if (getUpdatesCalls === 1) { + return { + json: async () => ({ + ok: true, + result: [ + { + _: "other", + update_id: 1, + message: { + message_id: 40, + chat: { id: 99, type: "private" }, + from: { id: 77, is_bot: false, first_name: "Test" }, + text: "/model", + }, + }, + ], + }), + } as Response; + } + if (getUpdatesCalls === 2) return secondUpdates; + if (getUpdatesCalls === 3) return thirdUpdates; + throw new DOMException("stop", "AbortError"); + } + if (method === "sendMessage") { + runtimeEvents.push(`send:${String(body?.text ?? "")}`); + return { + json: async () => ({ + ok: true, + result: { message_id: nextMessageId++ }, + }), + } as Response; + } + if (method === "editMessageText") { + runtimeEvents.push(`edit:${String(body?.text ?? "")}`); + return { json: async () => ({ ok: true, result: true }) } as Response; + } + if (method === "answerCallbackQuery") { + callbackAnswers.push(String(body?.text ?? "")); + return { json: async () => ({ ok: true, result: true }) } as Response; + } + if (method === "sendChatAction") { + return { json: async () => ({ ok: true, result: true }) } as Response; + } + throw new Error(`Unexpected Telegram API method: ${method}`); + }; + try { + await mkdir(agentDir, { recursive: true }); + await writeFile( + configPath, + JSON.stringify( + { botToken: "123:abc", allowedUserId: 77, lastUpdateId: 0 }, + null, + "\t", + ) + "\n", + "utf8", + ); + telegramExtension(pi); + const ctx = { + hasUI: true, + cwd: process.cwd(), + model: modelA, + signal: undefined, + ui: { + theme: { + fg: (_token: string, text: string) => text, + }, + setStatus: () => {}, + notify: () => {}, + }, + sessionManager: { + getEntries: () => [], + }, + modelRegistry: { + refresh: () => {}, + getAvailable: () => [modelA, modelB], + isUsingOAuth: () => false, + }, + getContextUsage: () => undefined, + hasPendingMessages: () => false, + isIdle: () => idle, + abort: () => { + aborted = true; + }, + } as never; + await handlers.get("session_start")?.({}, ctx); + await commands.get("telegram-connect")?.handler("", ctx); + await waitForCondition(() => + runtimeEvents.some((event) => event === "send:Choose a model:"), + ); + secondUpdatesResolve?.({ + json: async () => ({ + ok: true, + result: [ + { + _: "other", + update_id: 2, + message: { + message_id: 41, + chat: { id: 99, type: "private" }, + from: { id: 77, is_bot: false, first_name: "Test" }, + text: "first request", + }, + }, + ], + }), + } as Response); + await waitForCondition(() => + runtimeEvents.some( + (event) => event === "dispatch:[telegram] first request", + ), + ); + idle = false; + await handlers.get("agent_start")?.({}, ctx); + thirdUpdatesResolve?.({ + json: async () => ({ + ok: true, + result: [ + { + _: "other", + update_id: 3, + callback_query: { + id: "cb-1", + from: { id: 77, is_bot: false, first_name: "Test" }, + data: "model:pick:1", + message: { + message_id: 100, + chat: { id: 99, type: "private" }, + }, + }, + }, + ], + }), + } as Response); + await waitForCondition(() => aborted); + assert.deepEqual(setModels, ["anthropic/claude-b"]); + assert.equal( + callbackAnswers.includes("Switching to claude-b and continuing…"), + true, + ); + idle = true; + await handlers.get("agent_end")?.( + { + messages: [ + { + role: "assistant", + stopReason: "aborted", + content: [{ type: "text", text: "" }], + }, + ], + }, + ctx, + ); + await waitForCondition(() => + runtimeEvents.some((event) => + event.includes( + "Continue the interrupted previous Telegram request using the newly selected model (anthropic/claude-b)", + ), + ), + ); + assert.equal( + runtimeEvents.includes("dispatch:[telegram] first request"), + true, + ); + assert.equal( + runtimeEvents.some((event) => + event.includes( + "dispatch:[telegram] Continue the interrupted previous Telegram request using the newly selected model (anthropic/claude-b)", + ), + ), + true, + ); + await handlers.get("session_shutdown")?.({}, ctx); + } finally { + globalThis.fetch = originalFetch; + if (previousConfig === undefined) { + await rm(configPath, { force: true }); + } else { + await writeFile(configPath, previousConfig, "utf8"); + } + } +}); + +test("Extension runtime delays model-switch abort until the active tool finishes", async () => { + const agentDir = join(homedir(), ".pi", "agent"); + const configPath = join(agentDir, "telegram.json"); + const previousConfig = await readFile(configPath, "utf8").catch( + () => undefined, + ); + const handlers = new Map< + string, + (event: unknown, ctx: unknown) => Promise + >(); + const commands = new Map< + string, + { handler: (args: string, ctx: unknown) => Promise } + >(); + const runtimeEvents: string[] = []; + const modelA = { + provider: "openai", + id: "gpt-a", + reasoning: true, + } as const; + const modelB = { + provider: "anthropic", + id: "claude-b", + reasoning: false, + } as const; + let idle = true; + let aborted = false; + const setModels: Array = []; + let secondUpdatesResolve: ((value: Response) => void) | undefined; + let thirdUpdatesResolve: ((value: Response) => void) | undefined; + const secondUpdates = new Promise((resolve) => { + secondUpdatesResolve = resolve; + }); + const thirdUpdates = new Promise((resolve) => { + thirdUpdatesResolve = resolve; + }); + const pi = { + on: ( + event: string, + handler: (event: unknown, ctx: unknown) => Promise, + ) => { + handlers.set(event, handler); + }, + registerCommand: ( + name: string, + definition: { handler: (args: string, ctx: unknown) => Promise }, + ) => { + commands.set(name, definition); + }, + registerTool: () => {}, + sendUserMessage: ( + content: string | Array<{ type: string; text?: string }>, + ) => { + const promptBlocks = content as Array<{ type: string; text?: string }>; + runtimeEvents.push(`dispatch:${promptBlocks[0]?.text ?? ""}`); + }, + getThinkingLevel: () => "medium", + setModel: async (model: { provider: string; id: string }) => { + setModels.push(`${model.provider}/${model.id}`); + return true; + }, + setThinkingLevel: () => {}, + } as never; + const originalFetch = globalThis.fetch; + let getUpdatesCalls = 0; + let nextMessageId = 100; + const callbackAnswers: string[] = []; + globalThis.fetch = async (input, init) => { + const url = typeof input === "string" ? input : input.toString(); + const method = url.split("/").at(-1) ?? ""; + const body = + typeof init?.body === "string" + ? (JSON.parse(init.body) as Record) + : undefined; + if (method === "deleteWebhook") { + return { json: async () => ({ ok: true, result: true }) } as Response; + } + if (method === "getUpdates") { + getUpdatesCalls += 1; + if (getUpdatesCalls === 1) { + return { + json: async () => ({ + ok: true, + result: [ + { + _: "other", + update_id: 1, + message: { + message_id: 50, + chat: { id: 99, type: "private" }, + from: { id: 77, is_bot: false, first_name: "Test" }, + text: "/model", + }, + }, + ], + }), + } as Response; + } + if (getUpdatesCalls === 2) return secondUpdates; + if (getUpdatesCalls === 3) return thirdUpdates; + throw new DOMException("stop", "AbortError"); + } + if (method === "sendMessage") { + runtimeEvents.push(`send:${String(body?.text ?? "")}`); + return { + json: async () => ({ + ok: true, + result: { message_id: nextMessageId++ }, + }), + } as Response; + } + if (method === "editMessageText") { + runtimeEvents.push(`edit:${String(body?.text ?? "")}`); + return { json: async () => ({ ok: true, result: true }) } as Response; + } + if (method === "answerCallbackQuery") { + callbackAnswers.push(String(body?.text ?? "")); + return { json: async () => ({ ok: true, result: true }) } as Response; + } + if (method === "sendChatAction") { + return { json: async () => ({ ok: true, result: true }) } as Response; + } + throw new Error(`Unexpected Telegram API method: ${method}`); + }; + try { + await mkdir(agentDir, { recursive: true }); + await writeFile( + configPath, + JSON.stringify( + { botToken: "123:abc", allowedUserId: 77, lastUpdateId: 0 }, + null, + "\t", + ) + "\n", + "utf8", + ); + telegramExtension(pi); + const ctx = { + hasUI: true, + cwd: process.cwd(), + model: modelA, + signal: undefined, + ui: { + theme: { + fg: (_token: string, text: string) => text, + }, + setStatus: () => {}, + notify: () => {}, + }, + sessionManager: { + getEntries: () => [], + }, + modelRegistry: { + refresh: () => {}, + getAvailable: () => [modelA, modelB], + isUsingOAuth: () => false, + }, + getContextUsage: () => undefined, + hasPendingMessages: () => false, + isIdle: () => idle, + abort: () => { + aborted = true; + }, + } as never; + await handlers.get("session_start")?.({}, ctx); + await commands.get("telegram-connect")?.handler("", ctx); + await waitForCondition(() => + runtimeEvents.some((event) => event === "send:Choose a model:"), + ); + secondUpdatesResolve?.({ + json: async () => ({ + ok: true, + result: [ + { + _: "other", + update_id: 2, + message: { + message_id: 51, + chat: { id: 99, type: "private" }, + from: { id: 77, is_bot: false, first_name: "Test" }, + text: "first request", + }, + }, + ], + }), + } as Response); + await waitForCondition(() => + runtimeEvents.some( + (event) => event === "dispatch:[telegram] first request", + ), + ); + idle = false; + await handlers.get("agent_start")?.({}, ctx); + await handlers.get("tool_execution_start")?.({}, ctx); + thirdUpdatesResolve?.({ + json: async () => ({ + ok: true, + result: [ + { + _: "other", + update_id: 3, + callback_query: { + id: "cb-2", + from: { id: 77, is_bot: false, first_name: "Test" }, + data: "model:pick:1", + message: { + message_id: 100, + chat: { id: 99, type: "private" }, + }, + }, + }, + ], + }), + } as Response); + await waitForCondition(() => + callbackAnswers.includes( + "Switched to claude-b. Restarting after the current tool finishes…", + ), + ); + assert.deepEqual(setModels, ["anthropic/claude-b"]); + assert.equal(aborted, false); + await handlers.get("tool_execution_end")?.({}, ctx); + await waitForCondition(() => aborted); + idle = true; + await handlers.get("agent_end")?.( + { + messages: [ + { + role: "assistant", + stopReason: "aborted", + content: [{ type: "text", text: "" }], + }, + ], + }, + ctx, + ); + await waitForCondition(() => + runtimeEvents.some((event) => + event.includes( + "dispatch:[telegram] Continue the interrupted previous Telegram request using the newly selected model (anthropic/claude-b)", + ), + ), + ); + await handlers.get("session_shutdown")?.({}, ctx); + } finally { + globalThis.fetch = originalFetch; + if (previousConfig === undefined) { + await rm(configPath, { force: true }); + } else { + await writeFile(configPath, previousConfig, "utf8"); + } + } +}); diff --git a/tests/registration.test.ts b/tests/registration.test.ts new file mode 100644 index 0000000..aca3d73 --- /dev/null +++ b/tests/registration.test.ts @@ -0,0 +1,268 @@ +/** + * Regression tests for the Telegram registration domain + * Covers tool registration and command registration behavior without exercising the full extension runtime + */ + +import assert from "node:assert/strict"; +import test from "node:test"; + +import telegramExtension from "../index.ts"; +import { + registerTelegramAttachmentTool, + registerTelegramCommands, + registerTelegramLifecycleHooks, +} from "../lib/registration.ts"; + +function createRegistrationApiHarness() { + let tool: any; + const commands = new Map(); + const handlers = new Map(); + return { + tool: () => tool, + commands, + handlers, + api: { + on: (event: string, handler: unknown) => { + handlers.set(event, handler); + }, + registerTool: (definition: unknown) => { + tool = definition; + }, + registerCommand: (name: string, definition: unknown) => { + commands.set(name, definition); + }, + } as never, + }; +} + +test("Registration registers the attachment tool and delegates queueing", async () => { + const harness = createRegistrationApiHarness(); + const activeTurn = { + queuedAttachments: [], + } as unknown as { + queuedAttachments: Array<{ path: string; fileName: string }>; + } & ReturnType< + Parameters[1]["getActiveTurn"] + >; + registerTelegramAttachmentTool(harness.api, { + maxAttachmentsPerTurn: 2, + getActiveTurn: () => activeTurn, + statPath: async () => ({ isFile: () => true }), + }); + const tool = harness.tool(); + assert.equal(tool?.name, "telegram_attach"); + const result = await tool.execute("tool-call", { paths: ["/tmp/report.md"] }); + assert.deepEqual(activeTurn.queuedAttachments, [ + { path: "/tmp/report.md", fileName: "report.md" }, + ]); + assert.deepEqual(result.details.paths, ["/tmp/report.md"]); +}); + +test("Registration commands expose setup and status behaviors", async () => { + const harness = createRegistrationApiHarness(); + const events: string[] = []; + registerTelegramCommands(harness.api, { + promptForConfig: async () => { + events.push("setup"); + }, + getStatusLines: () => ["bot: @demo", "polling: stopped"], + reloadConfig: async () => { + events.push("reload"); + }, + hasBotToken: () => false, + startPolling: async () => { + events.push("start"); + }, + stopPolling: async () => { + events.push("stop"); + }, + updateStatus: () => { + events.push("update-status"); + }, + }); + const setupCommand = harness.commands.get("telegram-setup"); + const statusCommand = harness.commands.get("telegram-status"); + const notifications: string[] = []; + const ctx = { + ui: { + notify: (message: string) => { + notifications.push(message); + }, + }, + } as never; + await setupCommand.handler("", ctx); + await statusCommand.handler("", ctx); + assert.deepEqual(events, ["setup"]); + assert.deepEqual(notifications, ["bot: @demo | polling: stopped"]); +}); + +test("Registration connect and disconnect commands reload config and control polling", async () => { + const harness = createRegistrationApiHarness(); + const events: string[] = []; + let hasToken = false; + registerTelegramCommands(harness.api, { + promptForConfig: async () => { + events.push("setup"); + }, + getStatusLines: () => [], + reloadConfig: async () => { + events.push("reload"); + }, + hasBotToken: () => hasToken, + startPolling: async () => { + events.push("start"); + }, + stopPolling: async () => { + events.push("stop"); + }, + updateStatus: () => { + events.push("update-status"); + }, + }); + const connectCommand = harness.commands.get("telegram-connect"); + const disconnectCommand = harness.commands.get("telegram-disconnect"); + const ctx = { ui: { notify: () => {} } } as never; + await connectCommand.handler("", ctx); + hasToken = true; + await connectCommand.handler("", ctx); + await disconnectCommand.handler("", ctx); + assert.deepEqual(events, [ + "reload", + "setup", + "reload", + "start", + "update-status", + "stop", + "update-status", + ]); +}); + +test("Registration lifecycle hooks are registered and delegate to the provided handlers", async () => { + const harness = createRegistrationApiHarness(); + const events: string[] = []; + registerTelegramLifecycleHooks(harness.api, { + onSessionStart: async () => { + events.push("session-start"); + }, + onSessionShutdown: async () => { + events.push("session-shutdown"); + }, + onBeforeAgentStart: () => { + events.push("before-agent-start"); + return { systemPrompt: "prompt" }; + }, + onModelSelect: () => { + events.push("model-select"); + }, + onAgentStart: async () => { + events.push("agent-start"); + }, + onToolExecutionStart: () => { + events.push("tool-start"); + }, + onToolExecutionEnd: () => { + events.push("tool-end"); + }, + onMessageStart: async () => { + events.push("message-start"); + }, + onMessageUpdate: async () => { + events.push("message-update"); + }, + onAgentEnd: async () => { + events.push("agent-end"); + }, + }); + assert.deepEqual( + [...harness.handlers.keys()], + [ + "session_start", + "session_shutdown", + "before_agent_start", + "model_select", + "agent_start", + "tool_execution_start", + "tool_execution_end", + "message_start", + "message_update", + "agent_end", + ], + ); + const ctx = {} as never; + await harness.handlers.get("session_start")({}, ctx); + await harness.handlers.get("session_shutdown")({}, ctx); + const beforeAgentStartResult = await harness.handlers.get( + "before_agent_start", + )({}, ctx); + await harness.handlers.get("model_select")({}, ctx); + await harness.handlers.get("agent_start")({}, ctx); + await harness.handlers.get("tool_execution_start")({}, ctx); + await harness.handlers.get("tool_execution_end")({}, ctx); + await harness.handlers.get("message_start")({}, ctx); + await harness.handlers.get("message_update")({}, ctx); + await harness.handlers.get("agent_end")({}, ctx); + assert.deepEqual(beforeAgentStartResult, { systemPrompt: "prompt" }); + assert.deepEqual(events, [ + "session-start", + "session-shutdown", + "before-agent-start", + "model-select", + "agent-start", + "tool-start", + "tool-end", + "message-start", + "message-update", + "agent-end", + ]); +}); + +test("Extension entrypoint wires registration domains into the pi API", () => { + const harness = createRegistrationApiHarness(); + telegramExtension(harness.api); + assert.equal(harness.tool()?.name, "telegram_attach"); + assert.deepEqual( + [...harness.commands.keys()], + [ + "telegram-setup", + "telegram-status", + "telegram-connect", + "telegram-disconnect", + ], + ); + assert.deepEqual( + [...harness.handlers.keys()], + [ + "session_start", + "session_shutdown", + "before_agent_start", + "model_select", + "agent_start", + "tool_execution_start", + "tool_execution_end", + "message_start", + "message_update", + "agent_end", + ], + ); +}); + +test("Extension before-agent-start hook appends Telegram-specific system prompt guidance", async () => { + const harness = createRegistrationApiHarness(); + telegramExtension(harness.api); + const handler = harness.handlers.get("before_agent_start"); + const basePrompt = "System base"; + const telegramResult = await handler( + { systemPrompt: basePrompt, prompt: "[telegram] hello" }, + {} as never, + ); + const localResult = await handler( + { systemPrompt: basePrompt, prompt: "hello" }, + {} as never, + ); + assert.match( + telegramResult.systemPrompt, + /current user message came from Telegram/, + ); + assert.match(telegramResult.systemPrompt, /telegram_attach/); + assert.equal(localResult.systemPrompt.includes("came from Telegram"), false); +}); diff --git a/tests/rendering.test.ts b/tests/rendering.test.ts new file mode 100644 index 0000000..722113a --- /dev/null +++ b/tests/rendering.test.ts @@ -0,0 +1,308 @@ +/** + * Regression tests for Telegram markdown rendering helpers + * Covers nested lists, code blocks, tables, links, quotes, chunking, and other Telegram-specific render edge cases + */ + +import assert from "node:assert/strict"; +import test from "node:test"; + +import { __telegramTestUtils } from "../index.ts"; + +test("Nested lists stay out of code blocks", () => { + const chunks = __telegramTestUtils.renderTelegramMessage( + "- Level 1\n - Level 2\n - Level 3 with **bold** text", + { mode: "markdown" }, + ); + assert.ok(chunks.length > 0); + assert.equal( + chunks.some((chunk) => chunk.text.includes("
")),
+    false,
+  );
+  assert.equal(
+    chunks.some((chunk) =>
+      chunk.text.includes("- Level 3 with bold text"),
+    ),
+    true,
+  );
+});
+
+test("Fenced code blocks preserve literal markdown", () => {
+  const chunks = __telegramTestUtils.renderTelegramMessage(
+    '~~~ts\nconst value = "**raw**";\n~~~',
+    { mode: "markdown" },
+  );
+  assert.equal(chunks.length, 1);
+  assert.match(chunks[0]?.text ?? "", /
/);
+  assert.match(chunks[0]?.text ?? "", /\*\*raw\*\*/);
+});
+
+test("Underscores inside words do not become italic", () => {
+  const chunks = __telegramTestUtils.renderTelegramMessage(
+    "Path: foo_bar_baz.txt and **bold**",
+    { mode: "markdown" },
+  );
+  assert.equal(chunks.length, 1);
+  assert.equal((chunks[0]?.text ?? "").includes("bar"), false);
+  assert.match(chunks[0]?.text ?? "", /bold<\/b>/);
+});
+
+test("Quoted nested lists stay in blockquote rendering", () => {
+  const chunks = __telegramTestUtils.renderTelegramMessage(
+    "> Quoted intro\n> - nested item\n>   - deeper item",
+    { mode: "markdown" },
+  );
+  assert.equal(chunks.length, 1);
+  assert.match(chunks[0]?.text ?? "", /
/); + assert.match(chunks[0]?.text ?? "", /nested item/); + assert.match(chunks[0]?.text ?? "", /-<\/code> nested item/); + assert.equal((chunks[0]?.text ?? "").includes("
"), false);
+});
+
+test("Numbered lists use monospace numeric markers", () => {
+  const chunks = __telegramTestUtils.renderTelegramMessage(
+    "1. first\n  2. second",
+    { mode: "markdown" },
+  );
+  assert.equal(chunks.length, 1);
+  assert.match(chunks[0]?.text ?? "", /1\.<\/code> first/);
+  assert.match(chunks[0]?.text ?? "", /2\.<\/code> second/);
+});
+
+test("Nested blockquotes flatten into one Telegram blockquote with indentation", () => {
+  const chunks = __telegramTestUtils.renderTelegramMessage(
+    "> outer\n>> inner\n>>> deepest",
+    { mode: "markdown" },
+  );
+  assert.equal(chunks.length, 1);
+  assert.equal((chunks[0]?.text.match(/
/g) ?? []).length, 1); + assert.equal((chunks[0]?.text.match(/<\/blockquote>/g) ?? []).length, 1); + assert.match(chunks[0]?.text ?? "", /outer/); + assert.match(chunks[0]?.text ?? "", /\u00A0\u00A0inner/); + assert.match(chunks[0]?.text ?? "", /\u00A0\u00A0\u00A0\u00A0deepest/); +}); + +test("Markdown tables render as literal monospace blocks without outer side borders", () => { + const chunks = __telegramTestUtils.renderTelegramMessage( + "| Name | Value |\n| --- | --- |\n| **x** | `y` |", + { mode: "markdown" }, + ); + assert.equal(chunks.length, 1); + assert.match(chunks[0]?.text ?? "", /
/);
+  assert.equal((chunks[0]?.text ?? "").includes("x"), false);
+  assert.match(chunks[0]?.text ?? "", /Name\s+\|\s+Value/);
+  assert.match(chunks[0]?.text ?? "", /x\s+\|\s+y/);
+  assert.equal((chunks[0]?.text ?? "").includes("| Name |"), false);
+  assert.equal((chunks[0]?.text ?? "").includes("| x |"), false);
+});
+
+test("Links, code spans, and underscore-heavy text coexist safely", () => {
+  const chunks = __telegramTestUtils.renderTelegramMessage(
+    "See [docs](https://example.com), run `foo_bar()` and keep foo_bar.txt literal",
+    { mode: "markdown" },
+  );
+  assert.equal(chunks.length, 1);
+  assert.match(
+    chunks[0]?.text ?? "",
+    /docs<\/a>/,
+  );
+  assert.match(chunks[0]?.text ?? "", /foo_bar\(\)<\/code>/);
+  assert.equal((chunks[0]?.text ?? "").includes("bar"), false);
+});
+
+test("Long quoted blocks stay chunked with balanced blockquote tags", () => {
+  const markdown = Array.from(
+    { length: 500 },
+    (_, index) => `> quoted **${index}** line`,
+  ).join("\n");
+  const chunks = __telegramTestUtils.renderTelegramMessage(markdown, {
+    mode: "markdown",
+  });
+  assert.ok(chunks.length > 1);
+  for (const chunk of chunks) {
+    assert.ok(chunk.text.length <= __telegramTestUtils.MAX_MESSAGE_LENGTH);
+    assert.equal(
+      (chunk.text.match(/
/g) ?? []).length, + (chunk.text.match(/<\/blockquote>/g) ?? []).length, + ); + } +}); + +test("Long markdown replies stay chunked below Telegram limits", () => { + const markdown = Array.from( + { length: 600 }, + (_, index) => `- item **${index}**`, + ).join("\n"); + const chunks = __telegramTestUtils.renderTelegramMessage(markdown, { + mode: "markdown", + }); + assert.ok(chunks.length > 1); + for (const chunk of chunks) { + assert.ok(chunk.text.length <= __telegramTestUtils.MAX_MESSAGE_LENGTH); + assert.equal( + (chunk.text.match(//g) ?? []).length, + (chunk.text.match(/<\/b>/g) ?? []).length, + ); + } +}); + +test("Long mixed links and code spans stay chunked with balanced inline tags", () => { + const markdown = Array.from( + { length: 450 }, + (_, index) => + `Paragraph ${index}: see [docs ${index}](https://example.com/${index}), run \`code_${index}()\`, and keep foo_bar_${index}.txt literal`, + ).join("\n\n"); + const chunks = __telegramTestUtils.renderTelegramMessage(markdown, { + mode: "markdown", + }); + assert.ok(chunks.length > 1); + for (const chunk of chunks) { + assert.ok(chunk.text.length <= __telegramTestUtils.MAX_MESSAGE_LENGTH); + assert.equal( + (chunk.text.match(//g) ?? []).length, + ); + assert.equal( + (chunk.text.match(//g) ?? []).length, + (chunk.text.match(/<\/code>/g) ?? []).length, + ); + assert.equal((chunk.text ?? "").includes("bar"), false); + } +}); + +test("Long multi-block markdown keeps quotes and code fences structurally balanced", () => { + const markdown = Array.from({ length: 120 }, (_, index) => { + return [ + `## Section ${index}`, + `> quoted **${index}** line`, + `- item ${index}`, + "```ts", + `const value_${index} = \"**raw**\";`, + "```", + ].join("\n"); + }).join("\n\n"); + const chunks = __telegramTestUtils.renderTelegramMessage(markdown, { + mode: "markdown", + }); + assert.ok(chunks.length > 1); + for (const chunk of chunks) { + assert.ok(chunk.text.length <= __telegramTestUtils.MAX_MESSAGE_LENGTH); + assert.equal( + (chunk.text.match(/
/g) ?? []).length, + (chunk.text.match(/<\/blockquote>/g) ?? []).length, + ); + assert.equal( + (chunk.text.match(/
<\/pre>/g) ?? []).length,
+    );
+  }
+});
+
+test("Chunked mixed block transitions keep quote and list structure balanced", () => {
+  const markdown = Array.from({ length: 260 }, (_, index) => {
+    return [
+      `> quoted **${index}** intro`,
+      `> continuation ${index}`,
+      `- item ${index}`,
+      `plain paragraph ${index} with [link](https://example.com/${index})`,
+    ].join("\n");
+  }).join("\n\n");
+  const chunks = __telegramTestUtils.renderTelegramMessage(markdown, {
+    mode: "markdown",
+  });
+  assert.ok(chunks.length > 1);
+  for (const chunk of chunks) {
+    assert.ok(chunk.text.length <= __telegramTestUtils.MAX_MESSAGE_LENGTH);
+    assert.equal(
+      (chunk.text.match(/
/g) ?? []).length, + (chunk.text.match(/<\/blockquote>/g) ?? []).length, + ); + assert.equal( + (chunk.text.match(//g) ?? []).length, + ); + } +}); + +test("Chunked code fence transitions keep code blocks closed before following prose", () => { + const markdown = Array.from({ length: 220 }, (_, index) => { + return [ + "```ts", + `const block_${index} = \"value_${index}\";`, + "```", + `After code **${index}** and \`inline_${index}()\``, + ].join("\n"); + }).join("\n\n"); + const chunks = __telegramTestUtils.renderTelegramMessage(markdown, { + mode: "markdown", + }); + assert.ok(chunks.length > 1); + for (const chunk of chunks) { + assert.ok(chunk.text.length <= __telegramTestUtils.MAX_MESSAGE_LENGTH); + assert.equal( + (chunk.text.match(/
<\/pre>/g) ?? []).length,
+    );
+    assert.equal(
+      (chunk.text.match(//g) ?? []).length,
+      (chunk.text.match(/<\/code>/g) ?? []).length,
+    );
+  }
+});
+
+test("Long inline formatting paragraphs stay balanced across chunk boundaries", () => {
+  const markdown = Array.from({ length: 500 }, (_, index) => {
+    return `Segment ${index} keeps **bold_${index}** with \`code_${index}()\`, [link_${index}](https://example.com/${index}), and foo_bar_${index}.txt literal.`;
+  }).join(" ");
+  const chunks = __telegramTestUtils.renderTelegramMessage(markdown, {
+    mode: "markdown",
+  });
+  assert.ok(chunks.length > 1);
+  for (const chunk of chunks) {
+    assert.ok(chunk.text.length <= __telegramTestUtils.MAX_MESSAGE_LENGTH);
+    assert.equal(
+      (chunk.text.match(//g) ?? []).length,
+      (chunk.text.match(/<\/b>/g) ?? []).length,
+    );
+    assert.equal(
+      (chunk.text.match(//g) ?? []).length,
+    );
+    assert.equal(
+      (chunk.text.match(//g) ?? []).length,
+      (chunk.text.match(/<\/code>/g) ?? []).length,
+    );
+    assert.equal(chunk.text.includes("bar"), false);
+  }
+});
+
+test("Chunked list, code, quote, and prose cycles stay balanced across transitions", () => {
+  const markdown = Array.from({ length: 180 }, (_, index) => {
+    return [
+      `- list item **${index}**`,
+      "```ts",
+      `const cycle_${index} = \"value_${index}\";`,
+      "```",
+      `> quoted ${index} with [link](https://example.com/${index})`,
+      `Plain paragraph ${index} with \`inline_${index}()\``,
+    ].join("\n");
+  }).join("\n\n");
+  const chunks = __telegramTestUtils.renderTelegramMessage(markdown, {
+    mode: "markdown",
+  });
+  assert.ok(chunks.length > 1);
+  for (const chunk of chunks) {
+    assert.ok(chunk.text.length <= __telegramTestUtils.MAX_MESSAGE_LENGTH);
+    assert.equal(
+      (chunk.text.match(/
<\/pre>/g) ?? []).length,
+    );
+    assert.equal(
+      (chunk.text.match(/
/g) ?? []).length, + (chunk.text.match(/<\/blockquote>/g) ?? []).length, + ); + assert.equal( + (chunk.text.match(//g) ?? []).length, + ); + } +}); diff --git a/tests/replies.test.ts b/tests/replies.test.ts new file mode 100644 index 0000000..03cbd57 --- /dev/null +++ b/tests/replies.test.ts @@ -0,0 +1,362 @@ +/** + * Regression tests for the Telegram replies domain + * Covers preview decisions, rendered-message delivery, and plain or markdown reply sending in one suite + */ + +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + buildTelegramPreviewFinalText, + buildTelegramPreviewFlushText, + buildTelegramReplyTransport, + clearTelegramPreview, + editTelegramRenderedMessage, + finalizeTelegramMarkdownPreview, + finalizeTelegramPreview, + flushTelegramPreview, + sendTelegramMarkdownReply, + sendTelegramPlainReply, + sendTelegramRenderedChunks, + shouldUseTelegramDraftPreview, +} from "../lib/replies.ts"; + +function createPreviewRuntimeHarness(state?: { + mode: "draft" | "message"; + draftId?: number; + messageId?: number; + pendingText: string; + lastSentText: string; + flushTimer?: ReturnType; +}) { + let previewState = state; + let draftSupport: "unknown" | "supported" | "unsupported" = "unknown"; + let nextDraftId = 10; + const events: string[] = []; + return { + events, + getState: () => previewState, + getDraftSupport: () => draftSupport, + setDraftSupport: (support: "unknown" | "supported" | "unsupported") => { + draftSupport = support; + }, + deps: { + getState: () => previewState, + setState: (nextState: typeof previewState) => { + previewState = nextState; + }, + clearScheduledFlush: (nextState: NonNullable) => { + if (!nextState.flushTimer) return; + clearTimeout(nextState.flushTimer); + nextState.flushTimer = undefined; + events.push("clear-timer"); + }, + maxMessageLength: 5, + renderPreviewText: (markdown: string) => markdown.replaceAll("*", ""), + getDraftSupport: () => draftSupport, + setDraftSupport: (support: "unknown" | "supported" | "unsupported") => { + draftSupport = support; + }, + allocateDraftId: () => nextDraftId++, + sendDraft: async (chatId: number, draftId: number, text: string) => { + events.push(`draft:${chatId}:${draftId}:${text}`); + }, + sendMessage: async (chatId: number, text: string) => { + events.push(`send:${chatId}:${text}`); + return { message_id: 77 }; + }, + editMessageText: async ( + chatId: number, + messageId: number, + text: string, + ) => { + events.push(`edit:${chatId}:${messageId}:${text}`); + }, + renderTelegramMessage: (text: string, options?: { mode?: string }) => [ + { text: `${options?.mode ?? "plain"}:${text}` }, + ], + sendRenderedChunks: async ( + chatId: number, + chunks: Array<{ text: string }>, + ) => { + events.push( + `render-send:${chatId}:${chunks.map((chunk) => chunk.text).join("|")}`, + ); + return 88; + }, + editRenderedMessage: async ( + chatId: number, + messageId: number, + chunks: Array<{ text: string }>, + ) => { + events.push( + `render-edit:${chatId}:${messageId}:${chunks.map((chunk) => chunk.text).join("|")}`, + ); + return messageId; + }, + }, + }; +} + +test("Reply previews build flush text only when the preview changed", () => { + assert.equal( + buildTelegramPreviewFlushText({ + state: { + mode: "draft", + pendingText: "**hello**", + lastSentText: "", + }, + maxMessageLength: 4096, + renderPreviewText: (markdown) => markdown.replaceAll("*", ""), + }), + "hello", + ); + assert.equal( + buildTelegramPreviewFlushText({ + state: { + mode: "draft", + pendingText: "**hello**", + lastSentText: "hello", + }, + maxMessageLength: 4096, + renderPreviewText: (markdown) => markdown.replaceAll("*", ""), + }), + undefined, + ); +}); + +test("Reply previews truncate long flush text and compute final text fallback", () => { + assert.equal( + buildTelegramPreviewFlushText({ + state: { + mode: "message", + pendingText: "abcdef", + lastSentText: "", + }, + maxMessageLength: 3, + renderPreviewText: (markdown) => markdown, + }), + "abc", + ); + assert.equal( + buildTelegramPreviewFinalText({ + mode: "message", + pendingText: " ", + lastSentText: "saved", + }), + "saved", + ); + assert.equal( + buildTelegramPreviewFinalText({ + mode: "message", + pendingText: " ", + lastSentText: " ", + }), + undefined, + ); +}); + +test("Reply previews use drafts unless support is explicitly disabled", () => { + assert.equal( + shouldUseTelegramDraftPreview({ draftSupport: "unknown" }), + true, + ); + assert.equal( + shouldUseTelegramDraftPreview({ draftSupport: "supported" }), + true, + ); + assert.equal( + shouldUseTelegramDraftPreview({ draftSupport: "unsupported" }), + false, + ); +}); + +test("Reply preview runtime prefers draft updates and can clear draft previews", async () => { + const harness = createPreviewRuntimeHarness({ + mode: "draft", + pendingText: "**hello**", + lastSentText: "", + flushTimer: setTimeout(() => {}, 1000), + }); + await flushTelegramPreview(7, harness.deps); + assert.deepEqual(harness.events, ["draft:7:10:hello"]); + assert.equal(harness.getState()?.mode, "draft"); + assert.equal(harness.getState()?.draftId, 10); + assert.equal(harness.getState()?.lastSentText, "hello"); + assert.equal(harness.getDraftSupport(), "supported"); + await clearTelegramPreview(7, harness.deps); + assert.deepEqual(harness.events, ["draft:7:10:hello", "draft:7:10:"]); + assert.equal(harness.getState(), undefined); +}); + +test("Reply preview runtime falls back to editable messages when draft delivery fails", async () => { + const harness = createPreviewRuntimeHarness({ + mode: "draft", + pendingText: "abcdef", + lastSentText: "", + }); + harness.deps.sendDraft = async () => { + throw new Error("draft unsupported"); + }; + await flushTelegramPreview(7, harness.deps); + assert.deepEqual(harness.events, ["send:7:abcde"]); + assert.equal(harness.getState()?.mode, "message"); + assert.equal(harness.getState()?.messageId, 77); + assert.equal(harness.getDraftSupport(), "unsupported"); +}); + +test("Reply preview runtime finalizes plain and markdown previews", async () => { + const plainHarness = createPreviewRuntimeHarness({ + mode: "message", + messageId: 44, + pendingText: "done", + lastSentText: "", + }); + plainHarness.setDraftSupport("unsupported"); + assert.equal(await finalizeTelegramPreview(7, plainHarness.deps), true); + assert.deepEqual(plainHarness.events, ["edit:7:44:done"]); + assert.equal(plainHarness.getState(), undefined); + const markdownHarness = createPreviewRuntimeHarness({ + mode: "message", + messageId: 55, + pendingText: "done", + lastSentText: "", + }); + markdownHarness.setDraftSupport("unsupported"); + assert.equal( + await finalizeTelegramMarkdownPreview(7, "**done**", markdownHarness.deps), + true, + ); + assert.deepEqual(markdownHarness.events, [ + "edit:7:55:done", + "render-edit:7:55:markdown:**done**", + ]); + assert.equal(markdownHarness.getState(), undefined); +}); + +test("Reply transport forwards send and edit operations through delivery helpers", async () => { + const events: string[] = []; + const transport = buildTelegramReplyTransport({ + sendMessage: async (body) => { + events.push(`send:${body.chat_id}:${body.text}`); + return { message_id: 5 }; + }, + editMessage: async (body) => { + events.push(`edit:${body.chat_id}:${body.message_id}:${body.text}`); + }, + }); + assert.equal(await transport.sendRenderedChunks(7, [{ text: "one" }]), 5); + assert.equal(await transport.editRenderedMessage(7, 9, [{ text: "two" }]), 9); + assert.deepEqual(events, ["send:7:one", "edit:7:9:two"]); +}); + +test("Reply delivery sends chunks and applies reply markup only to the last chunk", async () => { + const sentBodies: Array> = []; + const messageId = await sendTelegramRenderedChunks( + 7, + [{ text: "one" }, { text: "two", parseMode: "HTML" }], + { + sendMessage: async (body) => { + sentBodies.push(body); + return { message_id: sentBodies.length }; + }, + editMessage: async () => {}, + }, + { + replyMarkup: { + inline_keyboard: [[{ text: "ok", callback_data: "noop" }]], + }, + }, + ); + assert.equal(messageId, 2); + assert.deepEqual(sentBodies, [ + { chat_id: 7, text: "one", parse_mode: undefined, reply_markup: undefined }, + { + chat_id: 7, + text: "two", + parse_mode: "HTML", + reply_markup: { + inline_keyboard: [[{ text: "ok", callback_data: "noop" }]], + }, + }, + ]); +}); + +test("Reply delivery edits the first chunk and sends remaining chunks separately", async () => { + const editedBodies: Array> = []; + const sentBodies: Array> = []; + const result = await editTelegramRenderedMessage( + 7, + 99, + [{ text: "first", parseMode: "HTML" }, { text: "second" }], + { + sendMessage: async (body) => { + sentBodies.push(body); + return { message_id: 123 }; + }, + editMessage: async (body) => { + editedBodies.push(body); + }, + }, + { + replyMarkup: { + inline_keyboard: [[{ text: "ok", callback_data: "noop" }]], + }, + }, + ); + assert.equal(result, 123); + assert.deepEqual(editedBodies, [ + { + chat_id: 7, + message_id: 99, + text: "first", + parse_mode: "HTML", + reply_markup: undefined, + }, + ]); + assert.deepEqual(sentBodies, [ + { + chat_id: 7, + text: "second", + parse_mode: undefined, + reply_markup: { + inline_keyboard: [[{ text: "ok", callback_data: "noop" }]], + }, + }, + ]); +}); + +test("Reply runtime sends plain replies using the requested parse mode", async () => { + const sent: string[] = []; + const messageId = await sendTelegramPlainReply( + "hello", + { + renderTelegramMessage: (_text, options) => [ + { text: options?.mode === "html" ? "html" : "plain" }, + ], + sendRenderedChunks: async (chunks) => { + sent.push(chunks[0]?.text ?? ""); + return 7; + }, + }, + { parseMode: "HTML" }, + ); + assert.equal(messageId, 7); + assert.deepEqual(sent, ["html"]); +}); + +test("Reply runtime falls back to plain delivery when markdown rendering yields no chunks", async () => { + const calls: Array = []; + const messageId = await sendTelegramMarkdownReply("hello", { + renderTelegramMessage: (_text, options) => { + if (options?.mode === "markdown") return []; + return [{ text: options?.mode ?? "plain" }]; + }, + sendRenderedChunks: async (chunks) => { + calls.push(chunks[0]?.text ?? ""); + return 9; + }, + }); + assert.equal(messageId, 9); + assert.deepEqual(calls, ["plain"]); +}); diff --git a/tests/telegram-queue.test.ts b/tests/telegram-queue.test.ts deleted file mode 100644 index fa6b65a..0000000 --- a/tests/telegram-queue.test.ts +++ /dev/null @@ -1,122 +0,0 @@ -import test from "node:test"; -import assert from "node:assert/strict"; - -import { __telegramTestUtils } from "../index.ts"; - -test("Dispatch is allowed only when every guard is clear", () => { - assert.equal( - __telegramTestUtils.canDispatchTelegramTurnState({ - compactionInProgress: false, - hasActiveTelegramTurn: false, - hasPendingTelegramDispatch: false, - isIdle: true, - hasPendingMessages: false, - }), - true, - ); -}); - -test("Dispatch is blocked during compaction", () => { - assert.equal( - __telegramTestUtils.canDispatchTelegramTurnState({ - compactionInProgress: true, - hasActiveTelegramTurn: false, - hasPendingTelegramDispatch: false, - isIdle: true, - hasPendingMessages: false, - }), - false, - ); -}); - -test("Dispatch is blocked while a Telegram turn is active or pending", () => { - assert.equal( - __telegramTestUtils.canDispatchTelegramTurnState({ - compactionInProgress: false, - hasActiveTelegramTurn: true, - hasPendingTelegramDispatch: false, - isIdle: true, - hasPendingMessages: false, - }), - false, - ); - assert.equal( - __telegramTestUtils.canDispatchTelegramTurnState({ - compactionInProgress: false, - hasActiveTelegramTurn: false, - hasPendingTelegramDispatch: true, - isIdle: true, - hasPendingMessages: false, - }), - false, - ); -}); - -test("Dispatch is blocked when pi is busy or has pending messages", () => { - assert.equal( - __telegramTestUtils.canDispatchTelegramTurnState({ - compactionInProgress: false, - hasActiveTelegramTurn: false, - hasPendingTelegramDispatch: false, - isIdle: false, - hasPendingMessages: false, - }), - false, - ); - assert.equal( - __telegramTestUtils.canDispatchTelegramTurnState({ - compactionInProgress: false, - hasActiveTelegramTurn: false, - hasPendingTelegramDispatch: false, - isIdle: true, - hasPendingMessages: true, - }), - false, - ); -}); - -test("In-flight model switch is allowed only for active Telegram turns with abort support", () => { - assert.equal( - __telegramTestUtils.canRestartTelegramTurnForModelSwitch({ - isIdle: false, - hasActiveTelegramTurn: true, - hasAbortHandler: true, - }), - true, - ); - assert.equal( - __telegramTestUtils.canRestartTelegramTurnForModelSwitch({ - isIdle: true, - hasActiveTelegramTurn: true, - hasAbortHandler: true, - }), - false, - ); - assert.equal( - __telegramTestUtils.canRestartTelegramTurnForModelSwitch({ - isIdle: false, - hasActiveTelegramTurn: false, - hasAbortHandler: true, - }), - false, - ); - assert.equal( - __telegramTestUtils.canRestartTelegramTurnForModelSwitch({ - isIdle: false, - hasActiveTelegramTurn: true, - hasAbortHandler: false, - }), - false, - ); -}); - -test("Continuation prompt stays Telegram-scoped and resume-oriented", () => { - const text = __telegramTestUtils.buildTelegramModelSwitchContinuationText( - { provider: "openai", id: "gpt-5", name: "GPT-5" }, - "high", - ); - assert.match(text, /^\[telegram\]/); - assert.match(text, /Continue the interrupted previous Telegram request/); - assert.match(text, /openai\/gpt-5/); - assert.match(text, /thinking level \(high\)/); -}); diff --git a/tests/telegram-rendering.test.ts b/tests/telegram-rendering.test.ts deleted file mode 100644 index 8562a5d..0000000 --- a/tests/telegram-rendering.test.ts +++ /dev/null @@ -1,60 +0,0 @@ -import test from "node:test"; -import assert from "node:assert/strict"; - -import { __telegramTestUtils } from "../index.ts"; - -test("Nested lists stay out of code blocks", () => { - const chunks = __telegramTestUtils.renderTelegramMessage( - "- Level 1\n - Level 2\n - Level 3 with **bold** text", - { mode: "markdown" }, - ); - assert.ok(chunks.length > 0); - assert.equal( - chunks.some((chunk) => chunk.text.includes("
")),
-    false,
-  );
-  assert.equal(
-    chunks.some((chunk) =>
-      chunk.text.includes("β€’ Level 3 with bold text"),
-    ),
-    true,
-  );
-});
-
-test("Fenced code blocks preserve literal markdown", () => {
-  const chunks = __telegramTestUtils.renderTelegramMessage(
-    '~~~ts\nconst value = "**raw**";\n~~~',
-    { mode: "markdown" },
-  );
-  assert.equal(chunks.length, 1);
-  assert.match(chunks[0]?.text ?? "", /
/);
-  assert.match(chunks[0]?.text ?? "", /\*\*raw\*\*/);
-});
-
-test("Underscores inside words do not become italic", () => {
-  const chunks = __telegramTestUtils.renderTelegramMessage(
-    "Path: foo_bar_baz.txt and **bold**",
-    { mode: "markdown" },
-  );
-  assert.equal(chunks.length, 1);
-  assert.equal((chunks[0]?.text ?? "").includes("bar"), false);
-  assert.match(chunks[0]?.text ?? "", /bold<\/b>/);
-});
-
-test("Long markdown replies stay chunked below Telegram limits", () => {
-  const markdown = Array.from(
-    { length: 600 },
-    (_, index) => `- item **${index}**`,
-  ).join("\n");
-  const chunks = __telegramTestUtils.renderTelegramMessage(markdown, {
-    mode: "markdown",
-  });
-  assert.ok(chunks.length > 1);
-  for (const chunk of chunks) {
-    assert.ok(chunk.text.length <= __telegramTestUtils.MAX_MESSAGE_LENGTH);
-    assert.equal(
-      (chunk.text.match(//g) ?? []).length,
-      (chunk.text.match(/<\/b>/g) ?? []).length,
-    );
-  }
-});
diff --git a/tests/turns.test.ts b/tests/turns.test.ts
new file mode 100644
index 0000000..05036a7
--- /dev/null
+++ b/tests/turns.test.ts
@@ -0,0 +1,132 @@
+/**
+ * Regression tests for the Telegram turn-building domain
+ * Covers queue-summary formatting, prompt construction, and prompt-turn assembly from messages and downloaded files
+ */
+
+import assert from "node:assert/strict";
+import test from "node:test";
+
+import {
+  buildTelegramPromptTurn,
+  buildTelegramTurnPrompt,
+  formatTelegramTurnStatusSummary,
+  truncateTelegramQueueSummary,
+} from "../lib/turns.ts";
+
+test("Turn helpers truncate queue summaries predictably", () => {
+  assert.equal(
+    truncateTelegramQueueSummary("one two three four"),
+    "one two three four",
+  );
+  assert.equal(
+    truncateTelegramQueueSummary("one two three four five six"),
+    "one two three four five…",
+  );
+  assert.equal(truncateTelegramQueueSummary("   "), "");
+});
+
+test("Turn helpers build prompt text with history and attachments", () => {
+  const prompt = buildTelegramTurnPrompt({
+    telegramPrefix: "[telegram]",
+    rawText: "current message",
+    files: [{ path: "/tmp/demo.png", fileName: "demo.png", isImage: true }],
+    historyTurns: [{ historyText: "older message" }],
+  });
+  assert.match(prompt, /^\[telegram\]/);
+  assert.match(
+    prompt,
+    /Earlier Telegram messages arrived after an aborted turn/,
+  );
+  assert.match(prompt, /1\. older message/);
+  assert.match(prompt, /Current Telegram message:\ncurrent message/);
+  assert.match(
+    prompt,
+    /Telegram attachments were saved locally:\n- \/tmp\/demo.png/,
+  );
+});
+
+test("Turn helpers summarize text and attachment-only turns", () => {
+  assert.equal(
+    formatTelegramTurnStatusSummary("hello there from telegram", []),
+    "hello there from telegram",
+  );
+  assert.equal(
+    formatTelegramTurnStatusSummary("", [
+      {
+        path: "/tmp/report-final-version.txt",
+        fileName: "report-final-version.txt",
+        isImage: false,
+      },
+    ]),
+    "πŸ“Ž report-final-version.txt",
+  );
+  assert.equal(
+    formatTelegramTurnStatusSummary("", [
+      { path: "/tmp/a.txt", fileName: "a.txt", isImage: false },
+      { path: "/tmp/b.txt", fileName: "b.txt", isImage: false },
+    ]),
+    "πŸ“Ž 2 attachments",
+  );
+});
+
+test("Turn helpers assemble prompt turns with text, ids, history, and image payloads", async () => {
+  const turn = await buildTelegramPromptTurn({
+    telegramPrefix: "[telegram]",
+    messages: [
+      { message_id: 10, chat: { id: 99 } },
+      { message_id: 11, chat: { id: 99 } },
+    ],
+    historyTurns: [
+      {
+        kind: "prompt",
+        chatId: 99,
+        replyToMessageId: 1,
+        sourceMessageIds: [1],
+        queueOrder: 1,
+        queueLane: "default",
+        laneOrder: 1,
+        queuedAttachments: [],
+        content: [{ type: "text", text: "ignored" }],
+        historyText: "older message",
+        statusSummary: "older",
+      },
+    ],
+    queueOrder: 7,
+    rawText: "current message",
+    files: [
+      {
+        path: "/tmp/demo.png",
+        fileName: "demo.png",
+        isImage: true,
+        mimeType: "image/png",
+      },
+      {
+        path: "/tmp/report.txt",
+        fileName: "report.txt",
+        isImage: false,
+      },
+    ],
+    readBinaryFile: async () => new Uint8Array([1, 2, 3]),
+    inferImageMimeType: () => undefined,
+  });
+  assert.equal(turn.chatId, 99);
+  assert.equal(turn.replyToMessageId, 10);
+  assert.deepEqual(turn.sourceMessageIds, [10, 11]);
+  assert.equal(turn.queueOrder, 7);
+  assert.equal(turn.statusSummary, "current message");
+  assert.equal(
+    turn.historyText,
+    "current message\nAttachments:\n- /tmp/demo.png\n- /tmp/report.txt",
+  );
+  assert.equal(turn.content.length, 2);
+  assert.equal(turn.content[0]?.type, "text");
+  assert.match(
+    (turn.content[0] as { type: "text"; text: string }).text,
+    /Earlier Telegram messages arrived after an aborted turn/,
+  );
+  assert.deepEqual(turn.content[1], {
+    type: "image",
+    data: Buffer.from([1, 2, 3]).toString("base64"),
+    mimeType: "image/png",
+  });
+});
diff --git a/tests/updates.test.ts b/tests/updates.test.ts
new file mode 100644
index 0000000..0108973
--- /dev/null
+++ b/tests/updates.test.ts
@@ -0,0 +1,366 @@
+/**
+ * Regression tests for the Telegram updates domain
+ * Covers extraction, authorization, flow classification, execution planning, and runtime execution in one suite
+ */
+
+import test from "node:test";
+import assert from "node:assert/strict";
+
+import {
+  buildTelegramUpdateExecutionPlan,
+  buildTelegramUpdateExecutionPlanFromUpdate,
+  buildTelegramUpdateFlowAction,
+  collectTelegramReactionEmojis,
+  executeTelegramUpdate,
+  executeTelegramUpdatePlan,
+  extractDeletedTelegramMessageIds,
+  getAuthorizedTelegramCallbackQuery,
+  getAuthorizedTelegramMessage,
+  getTelegramAuthorizationState,
+  normalizeTelegramReactionEmoji,
+} from "../lib/updates.ts";
+
+test("Update helpers normalize emoji reactions and collect emoji-only entries", () => {
+  assert.equal(normalizeTelegramReactionEmoji("πŸ‘οΈ"), "πŸ‘");
+  const emojis = collectTelegramReactionEmojis([
+    { type: "emoji", emoji: "πŸ‘οΈ" },
+    { type: "emoji", emoji: "πŸ‘Ž" },
+    { type: "custom_emoji" },
+  ]);
+  assert.deepEqual([...emojis], ["πŸ‘", "πŸ‘Ž"]);
+});
+
+test("Update helpers extract deleted message ids from Telegram update variants", () => {
+  assert.deepEqual(
+    extractDeletedTelegramMessageIds({
+      _: "other",
+      deleted_business_messages: { message_ids: [1, 2] },
+    }),
+    [1, 2],
+  );
+  assert.deepEqual(
+    extractDeletedTelegramMessageIds({
+      _: "updateDeleteMessages",
+      messages: [3, 4],
+    }),
+    [3, 4],
+  );
+  assert.deepEqual(
+    extractDeletedTelegramMessageIds({
+      _: "updateDeleteMessages",
+      messages: [3, "bad"],
+    }),
+    [],
+  );
+});
+
+test("Update routing classifies authorization state for pair, allow, and deny", () => {
+  assert.deepEqual(getTelegramAuthorizationState(10), {
+    kind: "pair",
+    userId: 10,
+  });
+  assert.deepEqual(getTelegramAuthorizationState(10, 10), { kind: "allow" });
+  assert.deepEqual(getTelegramAuthorizationState(10, 11), { kind: "deny" });
+});
+
+test("Update routing extracts only private human callback queries", () => {
+  assert.equal(
+    getAuthorizedTelegramCallbackQuery({
+      callback_query: {
+        from: { id: 1, is_bot: true },
+        message: { chat: { type: "private" } },
+      },
+    }),
+    undefined,
+  );
+  const query = getAuthorizedTelegramCallbackQuery({
+    callback_query: {
+      from: { id: 1, is_bot: false },
+      message: { chat: { type: "private" } },
+    },
+  });
+  assert.ok(query);
+});
+
+test("Update routing extracts private human messages from message or edited_message", () => {
+  assert.equal(
+    getAuthorizedTelegramMessage({
+      message: {
+        chat: { type: "group" },
+        from: { id: 1, is_bot: false },
+      },
+    }),
+    undefined,
+  );
+  const directMessage = getAuthorizedTelegramMessage({
+    edited_message: {
+      chat: { type: "private" },
+      from: { id: 1, is_bot: false },
+    },
+  });
+  assert.ok(directMessage);
+});
+
+test("Update flow prioritizes deleted-message handling over other update kinds", () => {
+  const action = buildTelegramUpdateFlowAction(
+    {
+      _: "updateDeleteMessages",
+      messages: [1, 2],
+      message_reaction: {
+        chat: { type: "private" },
+        user: { id: 1, is_bot: false },
+      },
+    },
+    1,
+  );
+  assert.deepEqual(action, { kind: "deleted", messageIds: [1, 2] });
+});
+
+test("Update flow returns authorized callback and message actions", () => {
+  const callbackAction = buildTelegramUpdateFlowAction(
+    {
+      _: "other",
+      callback_query: {
+        from: { id: 7, is_bot: false },
+        message: { chat: { type: "private" } },
+      },
+    },
+    7,
+  );
+  assert.equal(callbackAction.kind, "callback");
+  assert.deepEqual(
+    callbackAction.kind === "callback" ? callbackAction.authorization : undefined,
+    { kind: "allow" },
+  );
+  const messageAction = buildTelegramUpdateFlowAction({
+    _: "other",
+    message: {
+      chat: { type: "private" },
+      from: { id: 9, is_bot: false },
+    },
+  });
+  assert.equal(messageAction.kind, "message");
+  assert.deepEqual(
+    messageAction.kind === "message" ? messageAction.authorization : undefined,
+    { kind: "pair", userId: 9 },
+  );
+});
+
+test("Update flow ignores unauthorized transport shapes and preserves reaction events", () => {
+  const reactionAction = buildTelegramUpdateFlowAction({
+    _: "other",
+    message_reaction: {
+      chat: { type: "private" },
+      user: { id: 1, is_bot: false },
+    },
+  });
+  assert.equal(reactionAction.kind, "reaction");
+  const ignored = buildTelegramUpdateFlowAction({
+    _: "other",
+    callback_query: {
+      from: { id: 1, is_bot: true },
+      message: { chat: { type: "private" } },
+    },
+  });
+  assert.deepEqual(ignored, { kind: "ignore" });
+});
+
+test("Update execution plan maps callback and message authorization to side-effect flags", () => {
+  const callbackPlan = buildTelegramUpdateExecutionPlan({
+    kind: "callback",
+    query: {
+      from: { id: 1, is_bot: false },
+      message: { chat: { type: "private" } },
+    },
+    authorization: { kind: "deny" },
+  });
+  assert.deepEqual(callbackPlan, {
+    kind: "callback",
+    query: {
+      from: { id: 1, is_bot: false },
+      message: { chat: { type: "private" } },
+    },
+    shouldPair: false,
+    shouldDeny: true,
+  });
+  const messagePlan = buildTelegramUpdateExecutionPlan({
+    kind: "message",
+    message: {
+      chat: { type: "private" },
+      from: { id: 2, is_bot: false },
+    },
+    authorization: { kind: "pair", userId: 2 },
+  });
+  assert.equal(messagePlan.kind, "message");
+  assert.equal(messagePlan.shouldPair, true);
+  assert.equal(messagePlan.shouldNotifyPaired, true);
+  assert.equal(messagePlan.shouldDeny, false);
+});
+
+test("Update execution plan preserves deleted and reaction actions", () => {
+  assert.deepEqual(
+    buildTelegramUpdateExecutionPlan({ kind: "deleted", messageIds: [1, 2] }),
+    { kind: "deleted", messageIds: [1, 2] },
+  );
+  const reactionUpdate = {
+    chat: { type: "private" },
+    user: { id: 1, is_bot: false },
+  };
+  assert.deepEqual(
+    buildTelegramUpdateExecutionPlan({
+      kind: "reaction",
+      reactionUpdate,
+    }),
+    { kind: "reaction", reactionUpdate },
+  );
+});
+
+test("Update execution plan can be built directly from updates", () => {
+  const plan = buildTelegramUpdateExecutionPlanFromUpdate(
+    {
+      _: "other",
+      callback_query: {
+        from: { id: 4, is_bot: false },
+        message: { chat: { type: "private" } },
+      },
+    },
+    5,
+  );
+  assert.equal(plan.kind, "callback");
+  assert.equal(plan.kind === "callback" ? plan.shouldDeny : false, true);
+});
+
+test("Update runtime executes delete and reaction plans through the right side effects", async () => {
+  const events: string[] = [];
+  await executeTelegramUpdatePlan(
+    { kind: "deleted", messageIds: [1, 2] },
+    {
+      ctx: {} as never,
+      removePendingMediaGroupMessages: (ids) => {
+        events.push(`media:${ids.join(',')}`);
+      },
+      removeQueuedTelegramTurnsByMessageIds: (ids) => {
+        events.push(`queue:${ids.join(',')}`);
+        return ids.length;
+      },
+      handleAuthorizedTelegramReactionUpdate: async () => {
+        events.push("reaction");
+      },
+      pairTelegramUserIfNeeded: async () => false,
+      answerCallbackQuery: async () => {},
+      handleAuthorizedTelegramCallbackQuery: async () => {},
+      sendTextReply: async () => undefined,
+      handleAuthorizedTelegramMessage: async () => {},
+    },
+  );
+  assert.deepEqual(events, ["media:1,2", "queue:1,2"]);
+});
+
+test("Update runtime can execute directly from raw updates", async () => {
+  const events: string[] = [];
+  await executeTelegramUpdate(
+    {
+      _: "other",
+      message: {
+        chat: { id: 10, type: "private" },
+        message_id: 20,
+        from: { id: 7, is_bot: false },
+      },
+    },
+    undefined,
+    {
+      ctx: {} as never,
+      removePendingMediaGroupMessages: () => {},
+      removeQueuedTelegramTurnsByMessageIds: () => 0,
+      handleAuthorizedTelegramReactionUpdate: async () => {},
+      pairTelegramUserIfNeeded: async () => {
+        events.push("pair");
+        return true;
+      },
+      answerCallbackQuery: async () => {},
+      handleAuthorizedTelegramCallbackQuery: async () => {},
+      sendTextReply: async (_chatId, _replyToMessageId, text) => {
+        events.push(`reply:${text}`);
+        return undefined;
+      },
+      handleAuthorizedTelegramMessage: async () => {
+        events.push("message");
+      },
+    },
+  );
+  assert.deepEqual(events, ["pair", "reply:Telegram bridge paired with this account.", "message"]);
+});
+
+test("Update runtime handles callback deny and message pair flows", async () => {
+  const events: string[] = [];
+  await executeTelegramUpdatePlan(
+    {
+      kind: "callback",
+      query: {
+        id: "cb",
+        from: { id: 1, is_bot: false },
+        message: { chat: { type: "private" } },
+      },
+      shouldPair: true,
+      shouldDeny: true,
+    },
+    {
+      ctx: {} as never,
+      removePendingMediaGroupMessages: () => {},
+      removeQueuedTelegramTurnsByMessageIds: () => 0,
+      handleAuthorizedTelegramReactionUpdate: async () => {},
+      pairTelegramUserIfNeeded: async (userId) => {
+        events.push(`pair:${userId}`);
+        return true;
+      },
+      answerCallbackQuery: async (id, text) => {
+        events.push(`answer:${id}:${text}`);
+      },
+      handleAuthorizedTelegramCallbackQuery: async () => {
+        events.push("callback");
+      },
+      sendTextReply: async (chatId, replyToMessageId, text) => {
+        events.push(`reply:${chatId}:${replyToMessageId}:${text}`);
+        return undefined;
+      },
+      handleAuthorizedTelegramMessage: async () => {
+        events.push("message");
+      },
+    },
+  );
+  await executeTelegramUpdatePlan(
+    {
+      kind: "message",
+      message: {
+        chat: { id: 7, type: "private" },
+        from: { id: 2, is_bot: false },
+        message_id: 9,
+      },
+      shouldPair: true,
+      shouldNotifyPaired: true,
+      shouldDeny: false,
+    },
+    {
+      ctx: {} as never,
+      removePendingMediaGroupMessages: () => {},
+      removeQueuedTelegramTurnsByMessageIds: () => 0,
+      handleAuthorizedTelegramReactionUpdate: async () => {},
+      pairTelegramUserIfNeeded: async () => true,
+      answerCallbackQuery: async () => {},
+      handleAuthorizedTelegramCallbackQuery: async () => {},
+      sendTextReply: async (chatId, replyToMessageId, text) => {
+        events.push(`reply:${chatId}:${replyToMessageId}:${text}`);
+        return undefined;
+      },
+      handleAuthorizedTelegramMessage: async () => {
+        events.push("message");
+      },
+    },
+  );
+  assert.deepEqual(events, [
+    "pair:1",
+    "answer:cb:This bot is not authorized for your account.",
+    "reply:7:9:Telegram bridge paired with this account.",
+    "message",
+  ]);
+});