0.2.0: refactor into domain modules

This commit is contained in:
LLB
2026-04-11 10:31:25 +04:00
parent 233b20b089
commit 8dcf761937
34 changed files with 10450 additions and 2672 deletions
+12 -5
View File
@@ -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`
+8 -3
View File
@@ -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.
+5 -6
View File
@@ -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
+48 -10
View File
@@ -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 `👎`
+692 -2362
View File
File diff suppressed because it is too large Load Diff
+222
View File
@@ -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<T> {
ok: boolean;
result?: T;
description?: string;
error_code?: number;
}
interface TelegramGetFileResult {
file_path: string;
}
export interface TelegramApiClient {
call: <TResponse>(
method: string,
body: Record<string, unknown>,
options?: { signal?: AbortSignal },
) => Promise<TResponse>;
callMultipart: <TResponse>(
method: string,
fields: Record<string, string>,
fileField: string,
filePath: string,
fileName: string,
options?: { signal?: AbortSignal },
) => Promise<TResponse>;
downloadFile: (
fileId: string,
suggestedName: string,
tempDir: string,
) => Promise<string>;
answerCallbackQuery: (
callbackQueryId: string,
text?: string,
) => Promise<void>;
}
function sanitizeFileName(name: string): string {
return name.replace(/[^a-zA-Z0-9._-]+/g, "_");
}
export async function readTelegramConfig(
configPath: string,
): Promise<TelegramConfig> {
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<void> {
await mkdir(agentDir, { recursive: true });
await writeFile(
configPath,
JSON.stringify(config, null, "\t") + "\n",
"utf8",
);
}
export async function callTelegram<TResponse>(
botToken: string | undefined,
method: string,
body: Record<string, unknown>,
options?: { signal?: AbortSignal },
): Promise<TResponse> {
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<TResponse>;
if (!data.ok || data.result === undefined) {
throw new Error(data.description || `Telegram API ${method} failed`);
}
return data.result;
}
export async function callTelegramMultipart<TResponse>(
botToken: string | undefined,
method: string,
fields: Record<string, string>,
fileField: string,
filePath: string,
fileName: string,
options?: { signal?: AbortSignal },
): Promise<TResponse> {
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<TResponse>;
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<string> {
if (!botToken) {
throw new Error("Telegram bot token is not configured");
}
const file = await callTelegram<TelegramGetFileResult>(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<void> {
try {
await callTelegram<boolean>(
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);
},
};
}
+98
View File
@@ -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<string, string>,
fileField: string,
filePath: string,
fileName: string,
) => Promise<unknown>;
sendTextReply: (
chatId: number,
replyToMessageId: number,
text: string,
) => Promise<unknown>;
}
export async function queueTelegramAttachments(options: {
activeTurn: PendingTelegramTurn | undefined;
paths: string[];
maxAttachmentsPerTurn: number;
statPath: (path: string) => Promise<{ isFile(): boolean }>;
}): Promise<TelegramAttachmentToolResult> {
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<void> {
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}`,
);
}
}
}
+234
View File
@@ -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;
}
+951
View File
@@ -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<any>;
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<Array<{ text: string; callback_data: string }>>;
};
export interface TelegramMenuMessageRuntimeDeps {
editInteractiveMessage: (
chatId: number,
messageId: number,
text: string,
mode: "html" | "plain",
replyMarkup: TelegramReplyMarkup,
) => Promise<void>;
sendInteractiveMessage: (
chatId: number,
text: string,
mode: "html" | "plain",
replyMarkup: TelegramReplyMarkup,
) => Promise<number | undefined>;
}
export interface TelegramMenuEffectPort {
answerCallbackQuery: (
callbackQueryId: string,
text?: string,
) => Promise<void>;
updateModelMenuMessage: () => Promise<void>;
updateThinkingMenuMessage: () => Promise<void>;
updateStatusMessage: () => Promise<void>;
setModel: (model: Model<any>) => Promise<boolean>;
setCurrentModel: (model: Model<any>) => void;
setThinkingLevel: (level: ThinkingLevel) => void;
getCurrentThinkingLevel: () => ThinkingLevel;
stagePendingModelSwitch: (selection: ScopedTelegramModel) => void;
restartInterruptedTelegramTurn: (
selection: ScopedTelegramModel,
) => Promise<boolean> | 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<boolean>;
handleThinkingAction: () => Promise<boolean>;
handleModelAction: () => Promise<boolean>;
answerCallbackQuery: (
callbackQueryId: string,
text?: string,
) => Promise<void>;
}
export const THINKING_LEVELS: readonly ThinkingLevel[] = [
"off",
"minimal",
"low",
"medium",
"high",
"xhigh",
];
export const TELEGRAM_MODEL_PAGE_SIZE = 6;
export const MODEL_MENU_TITLE = "<b>Choose a model:</b>";
export interface BuildTelegramModelMenuStateParams {
chatId: number;
activeModel: Model<any> | undefined;
availableModels: Model<any>[];
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<any> | undefined;
currentThinkingLevel: ThinkingLevel;
isIdle: boolean;
canRestartBusyRun: boolean;
hasActiveToolExecutions: boolean;
}
export function modelsMatch(
a: Pick<Model<any>, "provider" | "id"> | undefined,
b: Pick<Model<any>, "provider" | "id"> | undefined,
): boolean {
return !!a && !!b && a.provider === b.provider && a.id === b.id;
}
export function getCanonicalModelId(
model: Pick<Model<any>, "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<any>[],
): Model<any> | 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<any>[],
): Model<any> | 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<any>[],
): { model: Model<any> | 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<any>[],
): ScopedTelegramModel[] {
const resolved: ScopedTelegramModel[] = [];
const seen = new Set<string>();
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<any> | 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<any> | 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<void> {
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<boolean> {
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<any> | undefined,
deps: TelegramStatusMenuCallbackDeps,
): Promise<boolean> {
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<any> | undefined,
deps: TelegramThinkingMenuCallbackDeps,
): Promise<boolean> {
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<any> | 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<any> | 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<any> | undefined,
currentThinkingLevel: ThinkingLevel,
): TelegramReplyMarkup {
const rows: Array<Array<{ text: string; callback_data: string }>> = [];
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<any> | undefined,
): TelegramMenuRenderPayload {
return {
nextMode: "model",
text: MODEL_MENU_TITLE,
mode: "html",
replyMarkup: buildModelMenuReplyMarkup(
state,
activeModel,
TELEGRAM_MODEL_PAGE_SIZE,
),
};
}
export function buildTelegramThinkingMenuRenderPayload(
activeModel: Model<any> | undefined,
currentThinkingLevel: ThinkingLevel,
): TelegramMenuRenderPayload {
return {
nextMode: "thinking",
text: buildThinkingMenuText(activeModel, currentThinkingLevel),
mode: "plain",
replyMarkup: buildThinkingMenuReplyMarkup(currentThinkingLevel),
};
}
export function buildTelegramStatusMenuRenderPayload(
statusText: string,
activeModel: Model<any> | undefined,
currentThinkingLevel: ThinkingLevel,
): TelegramMenuRenderPayload {
return {
nextMode: "status",
text: statusText,
mode: "html",
replyMarkup: buildStatusReplyMarkup(activeModel, currentThinkingLevel),
};
}
export async function updateTelegramModelMenuMessage(
state: TelegramModelMenuState,
activeModel: Model<any> | undefined,
deps: TelegramMenuMessageRuntimeDeps,
): Promise<void> {
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<any> | undefined,
currentThinkingLevel: ThinkingLevel,
deps: TelegramMenuMessageRuntimeDeps,
): Promise<void> {
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<any> | undefined,
currentThinkingLevel: ThinkingLevel,
deps: TelegramMenuMessageRuntimeDeps,
): Promise<void> {
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<any> | undefined,
currentThinkingLevel: ThinkingLevel,
deps: TelegramMenuMessageRuntimeDeps,
): Promise<number | undefined> {
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<any> | undefined,
deps: TelegramMenuMessageRuntimeDeps,
): Promise<number | undefined> {
const payload = buildTelegramModelMenuRenderPayload(state, activeModel);
state.mode = payload.nextMode;
return deps.sendInteractiveMessage(
state.chatId,
payload.text,
payload.mode,
payload.replyMarkup,
);
}
+62
View File
@@ -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<TTurn, TSelection>(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<Model<any>, "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}`;
}
+122
View File
@@ -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<TUpdate extends TelegramUpdateLike> {
ctx: ExtensionContext;
signal: AbortSignal;
config: TelegramConfig;
deleteWebhook: (signal: AbortSignal) => Promise<void>;
getUpdates: (
body: Record<string, unknown>,
signal: AbortSignal,
) => Promise<TUpdate[]>;
persistConfig: () => Promise<void>;
handleUpdate: (update: TUpdate, ctx: ExtensionContext) => Promise<void>;
onErrorStatus: (message: string) => void;
onStatusReset: () => void;
sleep: (ms: number) => Promise<void>;
}
export async function runTelegramPollLoop<TUpdate extends TelegramUpdateLike>(
deps: TelegramPollLoopDeps<TUpdate>,
): Promise<void> {
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();
}
}
}
+534
View File
@@ -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<TextContent | ImageContent>;
historyText: string;
}
export interface PendingTelegramControlItem extends TelegramQueueItemBase {
kind: "control";
controlType: "status" | "model";
execute: (ctx: ExtensionContext) => Promise<void>;
}
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<any> | undefined,
): {
currentTelegramModel: Model<any> | 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<TQueueItem>(): {
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<number | undefined>;
onSettled: () => void;
}
export async function executeTelegramControlItemRuntime(
item: PendingTelegramControlItem,
deps: TelegramControlRuntimeDeps,
): Promise<void> {
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<TelegramQueueDispatchAction, { kind: "control" }>["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);
}
}
+163
View File
@@ -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<void>;
getStatusLines: () => string[];
reloadConfig: () => Promise<void>;
hasBotToken: () => boolean;
startPolling: (ctx: ExtensionCommandContext) => Promise<void>;
stopPolling: () => Promise<void>;
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<void>;
onSessionShutdown: (event: unknown, ctx: ExtensionContext) => Promise<void>;
onBeforeAgentStart: (
event: unknown,
ctx: ExtensionContext,
) => Promise<unknown> | unknown;
onModelSelect: (
event: unknown,
ctx: ExtensionContext,
) => Promise<void> | void;
onAgentStart: (event: unknown, ctx: ExtensionContext) => Promise<void>;
onToolExecutionStart: (
event: unknown,
ctx: ExtensionContext,
) => Promise<void> | void;
onToolExecutionEnd: (
event: unknown,
ctx: ExtensionContext,
) => Promise<void> | void;
onMessageStart: (event: unknown, ctx: ExtensionContext) => Promise<void>;
onMessageUpdate: (event: unknown, ctx: ExtensionContext) => Promise<void>;
onAgentEnd: (event: unknown, ctx: ExtensionContext) => Promise<void>;
}
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);
});
}
+697
View File
@@ -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, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;");
}
// --- 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(`<a href="${escapeHtml(url)}">${escapeHtml(label)}</a>`);
},
);
result = result.replace(
/\[([^\]]+)\]\((https?:\/\/[^\s)]+)\)/g,
(_match, label: string, url: string) => {
return makeToken(`<a href="${escapeHtml(url)}">${escapeHtml(label)}</a>`);
},
);
result = result.replace(
/<((?:https?:\/\/|mailto:)[^>]+)>/g,
(_match, url: string) => {
return makeToken(`<a href="${escapeHtml(url)}">${escapeHtml(url)}</a>`);
},
);
result = result.replace(/`([^`\n]+)`/g, (_match, code: string) => {
return makeToken(`<code>${escapeHtml(code)}</code>`);
});
result = escapeHtml(result);
result = renderDelimitedInlineStyle(result, "***", (content) => {
return `<b><i>${content}</i></b>`;
});
result = renderDelimitedInlineStyle(result, "___", (content) => {
return `<b><i>${content}</i></b>`;
});
result = renderDelimitedInlineStyle(result, "~~", (content) => {
return `<s>${content}</s>`;
});
result = renderDelimitedInlineStyle(result, "**", (content) => {
return `<b>${content}</b>`;
});
result = renderDelimitedInlineStyle(result, "__", (content) => {
return `<b>${content}</b>`;
});
result = renderDelimitedInlineStyle(result, "*", (content) => {
return `<i>${content}</i>`;
});
result = renderDelimitedInlineStyle(result, "_", (content) => {
return `<i>${content}</i>`;
});
result = result.replace(
/(^|[\s>(])(\[(?: |x|X)\])(?=($|[\s<).,:;!?]))/g,
(_match, prefix: string, checkbox: string) => {
const normalized = checkbox.toLowerCase() === "[x]" ? "[x]" : "[ ]";
return `${prefix}<code>${normalized}</code>`;
},
);
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))}<b>${renderInlineMarkdown(heading[2] ?? "")}</b>`,
);
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}<code>${marker}</code> ${renderInlineMarkdown(task[4] ?? "")}`,
);
continue;
}
const bullet = piece.match(/^(\s*)[-*+]\s+(.+)$/);
if (bullet) {
const indent = buildListIndent(
Math.floor((bullet[1] ?? "").length / 2),
);
rendered.push(
`${indent}<code>-</code> ${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}<code>${numbered[2]}.</code> ${renderInlineMarkdown(numbered[3] ?? "")}`,
);
continue;
}
const quote = piece.match(/^>\s?(.+)$/);
if (quote) {
rendered.push(
`<blockquote>${renderInlineMarkdown(quote[1] ?? "")}</blockquote>`,
);
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
? `<pre><code class="language-${escapeHtml(language)}">`
: "<pre><code>";
const close = "</code></pre>";
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: "<blockquote>",
close: "</blockquote>",
});
}
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",
}));
}
+313
View File
@@ -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<typeof setTimeout>;
}
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<void>;
sendMessage: (
chatId: number,
text: string,
) => Promise<TelegramSentMessageLike>;
editMessageText: (
chatId: number,
messageId: number,
text: string,
) => Promise<void>;
renderTelegramMessage: (
text: string,
options?: { mode?: TelegramRenderMode },
) => TelegramRenderedChunk[];
sendRenderedChunks: (
chatId: number,
chunks: TelegramRenderedChunk[],
) => Promise<number | undefined>;
editRenderedMessage: (
chatId: number,
messageId: number,
chunks: TelegramRenderedChunk[],
) => Promise<number | undefined>;
}
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<void> {
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<void> {
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<boolean> {
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<boolean> {
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<TReplyMarkup> {
sendMessage: (body: {
chat_id: number;
text: string;
parse_mode?: "HTML";
reply_markup?: TReplyMarkup;
}) => Promise<TelegramSentMessageLike>;
editMessage: (body: {
chat_id: number;
message_id: number;
text: string;
parse_mode?: "HTML";
reply_markup?: TReplyMarkup;
}) => Promise<void>;
}
export interface TelegramReplyTransport<TReplyMarkup> {
sendRenderedChunks: (
chatId: number,
chunks: TelegramRenderedChunk[],
options?: { replyMarkup?: TReplyMarkup },
) => Promise<number | undefined>;
editRenderedMessage: (
chatId: number,
messageId: number,
chunks: TelegramRenderedChunk[],
options?: { replyMarkup?: TReplyMarkup },
) => Promise<number | undefined>;
}
export function buildTelegramReplyTransport<TReplyMarkup>(
deps: TelegramReplyDeliveryDeps<TReplyMarkup>,
): TelegramReplyTransport<TReplyMarkup> {
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<TReplyMarkup>(
chatId: number,
chunks: TelegramRenderedChunk[],
deps: TelegramReplyDeliveryDeps<TReplyMarkup>,
options?: { replyMarkup?: TReplyMarkup },
): Promise<number | undefined> {
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<TReplyMarkup>(
chatId: number,
messageId: number,
chunks: TelegramRenderedChunk[],
deps: TelegramReplyDeliveryDeps<TReplyMarkup>,
options?: { replyMarkup?: TReplyMarkup },
): Promise<number | undefined> {
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<number | undefined>;
}
export async function sendTelegramPlainReply(
text: string,
deps: TelegramReplyRuntimeDeps,
options?: { parseMode?: "HTML" },
): Promise<number | undefined> {
const chunks = deps.renderTelegramMessage(text, {
mode: options?.parseMode === "HTML" ? "html" : "plain",
});
return deps.sendRenderedChunks(chunks);
}
export async function sendTelegramMarkdownReply(
markdown: string,
deps: TelegramReplyRuntimeDeps,
): Promise<number | undefined> {
const chunks = deps.renderTelegramMessage(markdown, { mode: "markdown" });
if (chunks.length === 0) {
return sendTelegramPlainReply(markdown, deps);
}
return deps.sendRenderedChunks(chunks);
}
+41
View File
@@ -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,
};
}
+109
View File
@@ -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, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;");
}
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 `<b>${escapeHtml(label)}:</b> <code>${escapeHtml(value)}</code>`;
}
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<any> | 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<any> | 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");
}
+144
View File
@@ -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<PendingTelegramTurn, "historyText">[];
}): 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<Uint8Array>;
inferImageMimeType: (path: string) => string | undefined;
}): Promise<PendingTelegramTurn> {
const firstMessage = options.messages[0];
if (!firstMessage) {
throw new Error("Missing Telegram message for turn creation");
}
const content: Array<TextContent | ImageContent> = [
{
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,
),
};
}
+397
View File
@@ -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<string> {
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<TelegramUpdateFlowLike["message_reaction"]>;
}
| {
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<void>;
pairTelegramUserIfNeeded: (
userId: number,
ctx: ExtensionContext,
) => Promise<boolean>;
answerCallbackQuery: (
callbackQueryId: string,
text?: string,
) => Promise<void>;
handleAuthorizedTelegramCallbackQuery: (
query: Extract<TelegramUpdateExecutionPlan, { kind: "callback" }>["query"],
ctx: ExtensionContext,
) => Promise<void>;
sendTextReply: (
chatId: number,
replyToMessageId: number,
text: string,
) => Promise<number | undefined>;
handleAuthorizedTelegramMessage: (
message: Extract<
TelegramUpdateExecutionPlan,
{ kind: "message" }
>["message"],
ctx: ExtensionContext,
) => Promise<void>;
}
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<void> {
await executeTelegramUpdatePlan(
buildTelegramUpdateExecutionPlanFromUpdate(update, allowedUserId),
deps,
);
}
export async function executeTelegramUpdatePlan(
plan: TelegramUpdateExecutionPlan,
deps: TelegramUpdateRuntimeDeps,
): Promise<void> {
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);
}
+1 -1
View File
@@ -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",
+89
View File
@@ -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;
}
});
+132
View File
@@ -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<typeof queueTelegramAttachments>[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<string> = [];
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"]);
});
@@ -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",
+77
View File
@@ -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);
});
+645
View File
@@ -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, "<b>Choose a model:</b>");
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(
"<b>Status</b>",
modelA as never,
"medium",
);
assert.equal(modelPayload.nextMode, "model");
assert.equal(modelPayload.text, "<b>Choose a model:</b>");
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, "<b>Status</b>");
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,
"<b>Status</b>",
modelA as never,
"medium",
deps,
);
const sentStatusId = await sendTelegramStatusMessage(
state,
"<b>Status</b>",
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:<b>Choose a model:</b>");
assert.match(events[1] ?? "", /^edit:1:2:plain:Choose a thinking level/);
assert.equal(events[2], "edit:1:2:html:<b>Status</b>");
assert.equal(events[3], "send:1:html:<b>Status</b>");
assert.equal(events[4], "send:1:html:<b>Choose a model:</b>");
});
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);
});
+129
View File
@@ -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",
]);
});
+2982
View File
File diff suppressed because it is too large Load Diff
+268
View File
@@ -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<string, any>();
const handlers = new Map<string, any>();
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<typeof registerTelegramAttachmentTool>[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);
});
+308
View File
@@ -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("<pre><code>")),
false,
);
assert.equal(
chunks.some((chunk) =>
chunk.text.includes("<code>-</code> Level 3 with <b>bold</b> 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 ?? "", /<pre><code class="language-ts">/);
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("<i>bar</i>"), false);
assert.match(chunks[0]?.text ?? "", /<b>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 ?? "", /<blockquote>/);
assert.match(chunks[0]?.text ?? "", /nested item/);
assert.match(chunks[0]?.text ?? "", /<code>-<\/code> nested item/);
assert.equal((chunks[0]?.text ?? "").includes("<pre><code>"), 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 ?? "", /<code>1\.<\/code> first/);
assert.match(chunks[0]?.text ?? "", /<code>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(/<blockquote>/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 ?? "", /<pre><code class="language-markdown">/);
assert.equal((chunks[0]?.text ?? "").includes("<b>x</b>"), 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 ?? "",
/<a href="https:\/\/example.com">docs<\/a>/,
);
assert.match(chunks[0]?.text ?? "", /<code>foo_bar\(\)<\/code>/);
assert.equal((chunks[0]?.text ?? "").includes("<i>bar</i>"), 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(/<blockquote>/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(/<b>/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(/<a /g) ?? []).length,
(chunk.text.match(/<\/a>/g) ?? []).length,
);
assert.equal(
(chunk.text.match(/<code>/g) ?? []).length,
(chunk.text.match(/<\/code>/g) ?? []).length,
);
assert.equal((chunk.text ?? "").includes("<i>bar</i>"), 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(/<blockquote>/g) ?? []).length,
(chunk.text.match(/<\/blockquote>/g) ?? []).length,
);
assert.equal(
(chunk.text.match(/<pre><code/g) ?? []).length,
(chunk.text.match(/<\/code><\/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(/<blockquote>/g) ?? []).length,
(chunk.text.match(/<\/blockquote>/g) ?? []).length,
);
assert.equal(
(chunk.text.match(/<a /g) ?? []).length,
(chunk.text.match(/<\/a>/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><code/g) ?? []).length,
(chunk.text.match(/<\/code><\/pre>/g) ?? []).length,
);
assert.equal(
(chunk.text.match(/<code(?: class="[^"]+")?>/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(/<b>/g) ?? []).length,
(chunk.text.match(/<\/b>/g) ?? []).length,
);
assert.equal(
(chunk.text.match(/<a /g) ?? []).length,
(chunk.text.match(/<\/a>/g) ?? []).length,
);
assert.equal(
(chunk.text.match(/<code>/g) ?? []).length,
(chunk.text.match(/<\/code>/g) ?? []).length,
);
assert.equal(chunk.text.includes("<i>bar</i>"), 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><code/g) ?? []).length,
(chunk.text.match(/<\/code><\/pre>/g) ?? []).length,
);
assert.equal(
(chunk.text.match(/<blockquote>/g) ?? []).length,
(chunk.text.match(/<\/blockquote>/g) ?? []).length,
);
assert.equal(
(chunk.text.match(/<a /g) ?? []).length,
(chunk.text.match(/<\/a>/g) ?? []).length,
);
}
});
+362
View File
@@ -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<typeof setTimeout>;
}) {
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<typeof previewState>) => {
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<Record<string, unknown>> = [];
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<Record<string, unknown>> = [];
const sentBodies: Array<Record<string, unknown>> = [];
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<string> = [];
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"]);
});
-122
View File
@@ -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\)/);
});
-60
View File
@@ -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("<pre><code>")),
false,
);
assert.equal(
chunks.some((chunk) =>
chunk.text.includes("• Level 3 with <b>bold</b> 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 ?? "", /<pre><code class="language-ts">/);
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("<i>bar</i>"), false);
assert.match(chunks[0]?.text ?? "", /<b>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(/<b>/g) ?? []).length,
(chunk.text.match(/<\/b>/g) ?? []).length,
);
}
});
+132
View File
@@ -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",
});
});
+366
View File
@@ -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",
]);
});