pi-telegram-plus 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,318 @@
1
+ # pi-telegram-plus
2
+
3
+ **Full Telegram control of [pi coding agent](https://github.com/earendil-works/pi-coding-agent) — commands, interactive UI, model/session management, file transfer, and real-time streaming output, all from Telegram.**
4
+
5
+ `pi-telegram-plus` is a pi extension that turns Telegram into a full-featured remote control surface for the pi coding agent. It's not just a notification bot — it mirrors the core pi TUI experience into Telegram, with interactive menus, inline keyboards, file attachments, and live agent output rendering.
6
+
7
+ ---
8
+
9
+ ## Features
10
+
11
+ ### 🤖 Bot Connectivity
12
+ - **Long polling** — receives messages and callback queries in real time
13
+ - **Multi-instance safe** — file-based polling lock prevents multiple pi instances from racing on the same bot token
14
+ - **Automatic reconnection** — exponential backoff on transient failures
15
+ - **Bot command menu sync** — automatically syncs available commands to Telegram's BotMenu (up to 100 commands)
16
+
17
+ ### 🎮 Full Session Control
18
+ All pi session lifecycle commands available via Telegram:
19
+
20
+ | Command | Description |
21
+ |---------|-------------|
22
+ | `/new` | Start a new session |
23
+ | `/fork` | Fork from a previous user message |
24
+ | `/clone` | Clone at a previous user message |
25
+ | `/tree` | Navigate session tree |
26
+ | `/resume` | Resume a previous session |
27
+ | `/compact` | Compact session context |
28
+ | `/name` | Set or show session name |
29
+ | `/session` | Show session statistics |
30
+
31
+ ### 🧠 Model & Authentication Management
32
+ - `/model` — View available models / switch current model via interactive selection
33
+ - `/scoped-models` — Toggle scoped model sets
34
+ - `/thinking` — Adjust thinking level (off/minimal/low/medium/high/xhigh)
35
+ - `/login` — OAuth or API key authentication with full interactive flow
36
+ - `/logout` — Remove stored credentials
37
+
38
+ ### 📨 Message Modes
39
+ Two modes for handling incoming messages while the agent is running:
40
+
41
+ - **`steer`** (default) — New messages inject into the current turn via `streamingBehavior: "steer"`. The agent stays streaming while receiving new input.
42
+ - **`queue`** — Messages wait in a per-chat queue for the current turn to finish.
43
+
44
+ ### 🖥️ Interactive Telegram UI
45
+ Full interactive UI components built on inline keyboards:
46
+
47
+ - **Notify** — status/error messages
48
+ - **Confirm** — Yes / No / Cancel buttons
49
+ - **Input** — text input with Cancel button; replies are captured as input
50
+ - **InputSecret** — same as Input, but the prompt message is auto-deleted after reply to protect sensitive data
51
+ - **Select** — paginated option list with Prev/Next navigation
52
+ - **Editor** — multi-line text input prompt
53
+
54
+ ### 🎨 Message Rendering
55
+ - **Markdown → Telegram HTML** — Full conversion via `marked` (tables, code blocks, blockquotes, lists, inline formatting)
56
+ - **Tool execution rendering** — Configurable level (`hidden` / `brief` / `full`) for tool call visibility
57
+ - **Thinking rendering** — Configurable level (`hidden` / `brief` / `full`) for agent thinking blocks
58
+ - **Output splitting** — Safe UTF-8-aware splitting at Telegram's 4096-byte limit
59
+ - **Image output** — Automatically sends agent-generated images as Telegram photos
60
+
61
+ ### 📎 File Attachments
62
+
63
+ **Upload (agent → Telegram):**
64
+ - Custom `tg_attach` tool available to the agent
65
+ - Sends files/documents/photos to the active Telegram chat
66
+ - Size limit enforcement (default 50 MB)
67
+ - Sensitive path blocking (e.g., `/etc`, `~/.ssh`)
68
+ - Automatic photo detection (jpg/png/webp → send as photo, fallback to document)
69
+
70
+ **Download (Telegram → user → agent):**
71
+ - Automatically saves incoming photos, documents, videos, audio, voice, stickers to the working directory
72
+ - Reports saved paths back to the user
73
+ - Handles name sanitization and deduplication
74
+
75
+ ### ⚙️ Telegram-Specific Commands
76
+
77
+ | Command | Description |
78
+ |---------|-------------|
79
+ | `/tg-setup` | Configure Telegram bot token (first-time setup) |
80
+ | `/tg-connect` | Enable / start Telegram connection |
81
+ | `/tg-disconnect` | Disable / stop connection (keeps token) |
82
+ | `/tg-config` | Configure rendering levels and message mode |
83
+ | `/tg-bind-cwd` | Bind a workspace directory to a separate bot token |
84
+ | `/tg-unbind-cwd` | Remove workspace bot binding |
85
+ | `/tg-list` | List all Telegram bot bindings |
86
+
87
+ ### 🔧 Utility Commands
88
+
89
+ | Command | Description |
90
+ |---------|-------------|
91
+ | `/cwd` | Show current working directory |
92
+ | `/cd` | Switch pi working directory |
93
+ | `/stop` | Abort the current agent turn |
94
+ | `/debug` | Show debug info (model, thinking, streaming, entries) |
95
+ | `/settings` | Open settings menu |
96
+ | `/copy` | Copy last assistant text |
97
+ | `/export` | Export session to HTML/JSONL |
98
+ | `/import` | Import a session JSONL file |
99
+ | `/share` | Export session for sharing (gist) |
100
+ | `/reload` | Reload extensions, skills, prompts |
101
+ | `/quit` | Shut down pi |
102
+ | `/changelog` | Show changelog link |
103
+ | `/hotkeys` | Show keyboard shortcuts reference |
104
+
105
+ ---
106
+
107
+ ## Installation
108
+
109
+ ### Prerequisites
110
+
111
+ - Node.js >= 22.19.0
112
+ - [pi coding agent](https://github.com/earendil-works/pi-coding-agent) installed globally
113
+ - A Telegram bot token from [@BotFather](https://t.me/BotFather)
114
+
115
+ ### Setup
116
+
117
+ 1. **Clone or create the extension directory:**
118
+
119
+ ```bash
120
+ git clone <repo-url> pi-telegram-plus
121
+ cd pi-telegram-plus
122
+ npm install
123
+ ```
124
+
125
+ 2. **Configure pi to load the extension:**
126
+
127
+ ```bash
128
+ # Inside the pi-telegram-plus directory
129
+ pi packages add .
130
+ ```
131
+
132
+ This registers the extension via `.pi/settings.json`.
133
+
134
+ 3. **Start pi and configure your Telegram bot:**
135
+
136
+ ```bash
137
+ pi
138
+ ```
139
+
140
+ Inside pi, run:
141
+
142
+ ```
143
+ /tg-setup
144
+ ```
145
+
146
+ Paste your bot token when prompted. The bot will connect automatically.
147
+
148
+ > **Note:** On first message from any user, pi-telegram-plus automatically authorizes that user ID. Only that user can control the bot afterward.
149
+
150
+ ---
151
+
152
+ ## Configuration
153
+
154
+ ### Per-Workspace Bot Tokens
155
+
156
+ You can bind different bot tokens to different directories:
157
+
158
+ ```
159
+ /tg-bind-cwd /path/to/project
160
+ ```
161
+
162
+ This creates a workspace-specific binding in `~/.pi/agent/tg.json`. Workspace bindings take priority over the global configuration.
163
+
164
+ ### Rendering & Mode Settings
165
+
166
+ Via interactive menu:
167
+
168
+ ```
169
+ /tg-config
170
+ ```
171
+
172
+ Or directly:
173
+
174
+ ```
175
+ /tg-config tool brief # Tool rendering: hidden | brief | full
176
+ /tg-config thinking full # Thinking rendering: hidden | brief | full
177
+ /tg-config mode steer # Message mode: queue | steer
178
+ /tg-config retry 3 # API retry count: 0-10
179
+ ```
180
+
181
+ ---
182
+
183
+ ## How It Works
184
+
185
+ ```
186
+ ┌─────────────────┐ Long Polling ┌──────────────────────┐
187
+ │ Telegram Bot │ ◄──────────────────► │ pi-telegram-plus │
188
+ │ (api.telegram) │ Callback Queries │ (pi extension) │
189
+ └─────────────────┘ │ │
190
+ ▲ │ ┌────────────────┐ │
191
+ │ Messages / Files │ │ Controller │ │
192
+ │ │ │ ├─ handleMsg │ │
193
+ ┌─────────┐ │ │ ├─ handleCB │ │
194
+ │ User │ │ │ └─ runPrompt │ │
195
+ │ (Telegram)│ │ │ │ │
196
+ └─────────┘ │ │ ┌─────────────┐ │ │
197
+ ▲ │ │ │ TelegramUI │ │ │
198
+ │ Streaming Output │ │ │ (interactive)│ │ │
199
+ │ Live Tool/Thinking Events │ │ └─────────────┘ │ │
200
+ │ File Attachments (tg_attach) │ │ │ │
201
+ └───────────────────────────────────┤ ┌─────────────┐ │ │
202
+ │ │ Renderer │ │ │
203
+ │ │ (Markdown→ │ │ │
204
+ │ │ Telegram │ │ │
205
+ │ │ HTML) │ │ │
206
+ │ └─────────────┘ │ │
207
+ │ │ │ │
208
+ │ ▼ │ │
209
+ │ ┌─────────────┐ │ │
210
+ │ │ pi Agent │ │ │
211
+ │ │ (session, │ │ │
212
+ │ │ models, │ │ │
213
+ │ │ tools, ...) │ │ │
214
+ │ └─────────────┘ │ │
215
+ └──────────────────────┘
216
+ ```
217
+
218
+ ### Architecture Overview
219
+
220
+ - **`index.ts`** — Extension entry point. Wires all modules together and handles `session_start`/`session_shutdown` lifecycle events.
221
+ - **`lib/telegram-api.ts`** — Raw Telegram Bot API client with retry logic and file upload/download.
222
+ - **`lib/polling.ts`** — Long polling loop with multi-instance file lock. Re-reads config while holding the lock to prevent offset regressions.
223
+ - **`lib/controller.ts`** — Message routing: slash commands, text prompts, media attachments, callback queries.
224
+ - **`lib/telegram-ui.ts`** — Interactive UI layer: notify, confirm, input, select (pagination), editor.
225
+ - **`lib/renderer.ts`** — Hooks into agent lifecycle events (`agent_start`, `tool_execution_*`, `message_end`) and streams rendered output to Telegram.
226
+ - **`lib/markdown.ts`** — Custom `marked` renderer that converts Markdown to Telegram-compatible HTML.
227
+ - **`lib/config.ts`** — Configuration persistence with file locking for concurrent-safe writes. Supports global + workspace scopes.
228
+ - **`lib/attachments.ts`** — `tg_attach` tool registration and outbound attachment delivery.
229
+ - **`lib/commands/`** — Command handler modules: model, session, auth (login/logout), lifecycle, settings, tg-config, telegram-commands.
230
+ - **`lib/heartbeat.ts`** — Periodic typing indicator while processing.
231
+ - **`lib/status.ts`** — TUI status line integration showing connection state.
232
+ - **`lib/session-capture.ts`** — Monkeys patches `AgentSession.bindExtensions` to intercept session lifecycle and capture the active session reference.
233
+ - **`lib/menu-commands.ts`** — Builds and syncs the Telegram BotMenu command list.
234
+ - **`lib/callback-protocol.ts`** — Encodes/decodes inline button callback data.
235
+
236
+ ---
237
+
238
+ ## Project Structure
239
+
240
+ ```
241
+ pi-telegram-plus/
242
+ ├── index.ts # Extension entry point
243
+ ├── pi-host.d.ts # Type augmentation for @earendil-works/pi-ai
244
+ ├── package.json
245
+ ├── tsconfig.json
246
+ ├── vitest.config.ts
247
+ ├── .pi/
248
+ │ └── settings.json # pi package registration
249
+ └── lib/
250
+ ├── types.ts # All TypeScript interfaces & types
251
+ ├── telegram-api.ts # Telegram Bot HTTP API client
252
+ ├── polling.ts # Long polling with multi-instance lock
253
+ ├── controller.ts # Message router & prompt executor
254
+ ├── telegram-ui.ts # Interactive UI (notify, confirm, input, select)
255
+ ├── renderer.ts # Agent event → Telegram output renderer
256
+ ├── markdown.ts # Markdown → Telegram HTML converter
257
+ ├── html.ts # HTML escaping utilities
258
+ ├── text-split.ts # UTF-8-safe text splitter for 4096 byte limit
259
+ ├── command-parser.ts # Slash command parser & bot-username normalizer
260
+ ├── config.ts # Configuration store (global + workspace scopes)
261
+ ├── attachments.ts # Outbound file attachment tool (tg_attach)
262
+ ├── heartbeat.ts # Typing indicator pulse
263
+ ├── status.ts # TUI status line formatter
264
+ ├── session-capture.ts # Agent session capture & handler patching
265
+ ├── menu-commands.ts # Telegram BotMenu sync
266
+ ├── callback-protocol.ts # UI callback encoding/decoding
267
+ ├── commands/
268
+ │ ├── register.ts # Command registry aggregator
269
+ │ ├── model.ts # /model, /scoped-models, /thinking
270
+ │ ├── session.ts # /new, /fork, /clone, /tree, /resume, /cd, /cwd, /name, /session
271
+ │ ├── auth.ts # /login (OAuth + API key), /logout
272
+ │ ├── info.ts # /copy, /export, /import, /share, /changelog, /hotkeys, /debug
273
+ │ ├── lifecycle.ts # /compact, /reload, /stop, /quit
274
+ │ ├── settings.ts # /settings menu
275
+ │ ├── tg-config.ts # /tg-config
276
+ │ └── telegram-commands.ts # /tg-setup, /tg-connect, /tg-disconnect, /tg-bind-cwd, /tg-list
277
+ └── __tests__/
278
+ ├── attachments.test.ts
279
+ ├── callback-protocol.test.ts
280
+ ├── config.test.ts
281
+ ├── controller.test.ts
282
+ ├── html.test.ts
283
+ ├── markdown.test.ts
284
+ ├── status.test.ts
285
+ └── text-split.test.ts
286
+ ```
287
+
288
+ ---
289
+
290
+ ## Development
291
+
292
+ ```bash
293
+ # Type checking
294
+ npm run typecheck
295
+
296
+ # Run tests
297
+ npm test
298
+
299
+ # Watch mode
300
+ npm run test:watch
301
+ ```
302
+
303
+ ---
304
+
305
+ ## Security
306
+
307
+ - **No secrets in source code** — All bot tokens and API keys are read from `~/.pi/agent/tg.json` and `~/.pi/agent/auth.json` at runtime.
308
+ - **File permissions** — Configuration files are created with `chmod 600` (owner-only access).
309
+ - **Sensitive input protection** — API key inputs use `inputSecret` which automatically deletes the user's message after reading.
310
+ - **Attachment path safety** — Outbound `tg_attach` blocks sensitive paths (`/etc`, `~/.ssh`, etc.).
311
+ - **Polling lock** — File-based lock prevents multiple pi instances from polling the same bot token simultaneously.
312
+ - **First-user authorization** — The first Telegram user to message the bot is automatically authorized; subsequent users are rejected.
313
+
314
+ ---
315
+
316
+ ## License
317
+
318
+ MIT
package/index.ts ADDED
@@ -0,0 +1,327 @@
1
+ import { mkdir, writeFile } from "node:fs/promises";
2
+ import { extname, resolve } from "node:path";
3
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
4
+ import { registerTelegramAttachmentTool } from "./lib/attachments.ts";
5
+ import { readResolvedTelegramConfig, writeResolvedTelegramConfig } from "./lib/config.ts";
6
+ import { createTelegramController, type TelegramCommandHandler } from "./lib/controller.ts";
7
+ import { createHeartbeat } from "./lib/heartbeat.ts";
8
+ import { registerTelegramRenderer } from "./lib/renderer.ts";
9
+ import { getActiveSession, installAgentSessionCapture } from "./lib/session-capture.ts";
10
+ import { createTelegramTransport, downloadTelegramFile, getTelegramBotUsername, getTelegramFile } from "./lib/telegram-api.ts";
11
+ import { createTelegramUiRuntime } from "./lib/telegram-ui.ts";
12
+ import { formatTelegramStatusLine, clearTelegramStatus, TELEGRAM_STATUS_KEY } from "./lib/status.ts";
13
+ import { createTelegramPollingRuntime } from "./lib/polling.ts";
14
+ import { registerAllCommands } from "./lib/commands/register.ts";
15
+ import { registerTelegramCommands } from "./lib/commands/telegram-commands.ts";
16
+ import { syncTelegramCommands } from "./lib/menu-commands.ts";
17
+ import type { ResolvedTelegramConfig, TelegramConfig, TelegramTurn } from "./lib/types.ts";
18
+
19
+ type TelegramPlusRuntimeState = {
20
+ dispose?: () => void;
21
+ };
22
+
23
+ const TELEGRAM_PLUS_RUNTIME_STATE = Symbol.for("pi-telegram-plus.runtime-state");
24
+
25
+ function getTelegramPlusRuntimeState(): TelegramPlusRuntimeState {
26
+ const g = globalThis as typeof globalThis & Record<symbol, TelegramPlusRuntimeState | undefined>;
27
+ g[TELEGRAM_PLUS_RUNTIME_STATE] ??= {};
28
+ return g[TELEGRAM_PLUS_RUNTIME_STATE];
29
+ }
30
+
31
+ export default function piTelegramPlus(pi: ExtensionAPI): void {
32
+ installAgentSessionCapture();
33
+ const runtimeState = getTelegramPlusRuntimeState();
34
+ runtimeState.dispose?.();
35
+
36
+ let config: TelegramConfig = {};
37
+ let resolvedConfig: ResolvedTelegramConfig | undefined;
38
+ // Per-chat active turns: prevents interleaving across chats and allows
39
+ // beginTelegramTurn to reject when a chat is already busy.
40
+ const activeTurns = new Map<number, TelegramTurn>();
41
+ let lastStatusError: string | undefined;
42
+
43
+ const setConfig = (nextConfig: TelegramConfig) => {
44
+ config = nextConfig;
45
+ if (resolvedConfig) resolvedConfig.config = nextConfig;
46
+ refreshStatus();
47
+ };
48
+
49
+ const currentSessionCwd = (): string => {
50
+ const session = getActiveSession();
51
+ return session?.extensionRunner?.createCommandContext?.().cwd ?? process.cwd();
52
+ };
53
+
54
+ const sanitizeIncomingFileName = (value: string): string => {
55
+ const trimmed = value.trim().replace(/\.[^./\\]+$/, "");
56
+ const sanitized = trimmed
57
+ .replace(/[^a-zA-Z0-9._-]/g, "_")
58
+ .replace(/_+/g, "_");
59
+ const compact = sanitized.replace(/^\.+/, "").replace(/\.+$/, "");
60
+ return compact.slice(0, 120) || "attachment";
61
+ };
62
+
63
+ const inferIncomingExtension = (fileName: string | undefined, filePath: string | undefined): string => {
64
+ const source = filePath || fileName;
65
+ if (!source) return ".bin";
66
+ const extension = extname(source).toLowerCase();
67
+ return extension || ".bin";
68
+ };
69
+
70
+ const buildIncomingAttachmentPath = (fileId: string, fileName: string | undefined, filePath: string): string => {
71
+ const ext = inferIncomingExtension(fileName, filePath);
72
+ const base = fileName
73
+ ? sanitizeIncomingFileName(fileName)
74
+ : sanitizeIncomingFileName(filePath || "telegram-file");
75
+ const safeFileId = fileId.replace(/[^a-zA-Z0-9_-]/g, "_");
76
+ return resolve(currentSessionCwd(), `${Date.now()}-${safeFileId.slice(0, 18)}-${base}${ext}`);
77
+ };
78
+
79
+ const persistCurrentConfig = async (nextConfig = config): Promise<void> => {
80
+ if (!resolvedConfig) resolvedConfig = await readResolvedTelegramConfig(currentSessionCwd());
81
+ resolvedConfig = await writeResolvedTelegramConfig(resolvedConfig, nextConfig);
82
+ config = resolvedConfig.config;
83
+ };
84
+
85
+ const switchResolvedConfig = (next: ResolvedTelegramConfig) => {
86
+ resolvedConfig = next;
87
+ config = next.config;
88
+ refreshStatus();
89
+ };
90
+
91
+ const isTelegramEnabled = (): boolean => config.telegramEnabled ?? resolvedConfig?.scope !== "global";
92
+
93
+ const transport = createTelegramTransport(() => config);
94
+ const ui = createTelegramUiRuntime({
95
+ getSession: getActiveSession,
96
+ transport,
97
+ });
98
+
99
+ const heartbeat = createHeartbeat({
100
+ getConfig: () => config,
101
+ getActiveTurn: () => { for (const turn of activeTurns.values()) return turn; return undefined; },
102
+ sendChatAction: (chatId, action) => transport.sendChatAction(chatId, action),
103
+ ensurePollingStarted: () => { if (config.botToken && isTelegramEnabled() && !polling.isActive()) polling.start(); },
104
+ });
105
+
106
+ const telegramCommands = new Map<string, TelegramCommandHandler>();
107
+ const sessionDeps = { getSession: getActiveSession };
108
+ const sessionNameDeps = {
109
+ ...sessionDeps,
110
+ setSessionName: (name: string) => { const s = getActiveSession(); if (s) pi.setSessionName(name); },
111
+ getSessionName: () => pi.getSessionName(),
112
+ };
113
+ const tgConfigDeps = {
114
+ ...sessionDeps,
115
+ getConfig: () => config,
116
+ setConfig,
117
+ persistConfig: persistCurrentConfig,
118
+ };
119
+
120
+ // Custom pi-telegram-plus commands that should also appear in the TUI slash menu.
121
+ // Pi built-in commands (model, session, new, etc.) are already registered by pi core.
122
+ const TUI_VISIBLE_COMMANDS = new Set([
123
+ // tg-* commands
124
+ "tg-setup", "tg-connect", "tg-disconnect", "tg-config",
125
+ "tg-bind-cwd", "tg-unbind-cwd", "tg-list",
126
+ // other pi-telegram-plus custom commands (TUI-only command list excludes /import, which is now
127
+ // a built-in pi command; keep Telegram handler registration only.
128
+ "cwd", "cd", "thinking", "stop", "debug",
129
+ ]);
130
+
131
+ registerAllCommands({
132
+ registerCommand: (name: string, options: { description?: string; handler: TelegramCommandHandler }) => {
133
+ telegramCommands.set(name, options.handler);
134
+ if (TUI_VISIBLE_COMMANDS.has(name) && options.description) {
135
+ pi.registerCommand(name, { description: options.description, handler: options.handler });
136
+ }
137
+ },
138
+ }, sessionDeps, sessionNameDeps, tgConfigDeps);
139
+
140
+ registerTelegramCommands({
141
+ registerCommand: (name: string, options: { description?: string; handler: TelegramCommandHandler }) => {
142
+ telegramCommands.set(name, options.handler);
143
+ if (options.description) {
144
+ pi.registerCommand(name, { description: options.description, handler: options.handler });
145
+ }
146
+ },
147
+ }, {
148
+ getConfig: () => config,
149
+ setConfig,
150
+ persistConfig: persistCurrentConfig,
151
+ getResolvedConfig: () => resolvedConfig,
152
+ switchResolvedConfig,
153
+ isTelegramEnabled,
154
+ transport,
155
+ getPolling: () => polling,
156
+ refreshStatus,
157
+ syncTelegramCommands: () => syncTelegramCommands(config.botToken, pi),
158
+ startStatusHeartbeat: () => heartbeat.startStatusHeartbeat(refreshStatus),
159
+ clearStatusError: () => { lastStatusError = undefined; },
160
+ });
161
+
162
+ registerTelegramAttachmentTool(pi, {
163
+ getActiveTurn: () => { for (const turn of activeTurns.values()) return turn; return undefined; },
164
+ getDefaultChatId: () => config.activeChatId,
165
+ transport,
166
+ });
167
+
168
+ registerTelegramRenderer(pi, {
169
+ getConfig: () => config,
170
+ transport,
171
+ getActiveTurn: (chatId?: number) => {
172
+ if (chatId !== undefined) return activeTurns.get(chatId);
173
+ for (const turn of activeTurns.values()) return turn;
174
+ return undefined;
175
+ },
176
+ });
177
+
178
+ const controller = createTelegramController({
179
+ getSession: getActiveSession,
180
+ transport,
181
+ ui,
182
+ authorizeUser: async (userId) => {
183
+ if (userId === undefined) return false;
184
+ if (config.allowedUserId === undefined) {
185
+ config = { ...config, allowedUserId: userId };
186
+ await persistCurrentConfig(config);
187
+ refreshStatus();
188
+ }
189
+ return config.allowedUserId === userId;
190
+ },
191
+ telegramCommands,
192
+ saveIncomingTelegramAttachment: async (fileId, fileName, kind) => {
193
+ const token = config.botToken;
194
+ if (!token) {
195
+ throw new Error("Telegram bot token is not configured");
196
+ }
197
+ const fileInfo = await getTelegramFile(token, fileId);
198
+ const data = await downloadTelegramFile(token, fileInfo.file_path);
199
+ await mkdir(currentSessionCwd(), { recursive: true });
200
+ const candidateName = buildIncomingAttachmentPath(fileId, fileName || kind, fileInfo.file_path);
201
+ const outputPath = candidateName;
202
+ await writeFile(outputPath, data);
203
+ return outputPath;
204
+ },
205
+ getActiveTurn: (chatId: number) => activeTurns.get(chatId),
206
+ beginTelegramTurn: (chatId, replaceMessageId) => {
207
+ if (activeTurns.has(chatId)) return undefined; // reject if busy
208
+ const turn: TelegramTurn = { chatId, replaceMessageId, queuedAttachments: [] };
209
+ activeTurns.set(chatId, turn);
210
+ refreshStatus();
211
+ return turn;
212
+ },
213
+ endTelegramTurn: (chatId, turn) => {
214
+ if (activeTurns.get(chatId) === turn) activeTurns.delete(chatId);
215
+ refreshStatus();
216
+ },
217
+ setActiveChatId: async (chatId) => {
218
+ if (config.activeChatId === chatId) return;
219
+ config = { ...config, activeChatId: chatId };
220
+ await persistCurrentConfig(config);
221
+ refreshStatus();
222
+ },
223
+ getBotUsername: () => config.botUsername,
224
+ getMessageMode: () => config.messageMode ?? "steer",
225
+ });
226
+
227
+ const polling = createTelegramPollingRuntime({
228
+ getConfig: () => config,
229
+ setConfig,
230
+ persistConfig: persistCurrentConfig,
231
+ reloadConfig: async () => switchResolvedConfig(await readResolvedTelegramConfig(currentSessionCwd())),
232
+ handleUpdate: async (update) => {
233
+ refreshStatus();
234
+ if (update.callback_query) await controller.handleCallbackQuery(update.callback_query);
235
+ if (update.message) await controller.handleMessage(update.message);
236
+ lastStatusError = undefined;
237
+ refreshStatus();
238
+ },
239
+ onSuccess: () => {
240
+ if (lastStatusError !== undefined) { lastStatusError = undefined; refreshStatus(); }
241
+ },
242
+ onError: (error) => {
243
+ const message = error instanceof Error ? error.message : String(error);
244
+ lastStatusError = message;
245
+ refreshStatus(message);
246
+ if (message.startsWith("Telegram polling skipped:")) {
247
+ getActiveSession()?.extensionRunner.getUIContext().notify(message, "warning");
248
+ return;
249
+ }
250
+ const chatId = config.activeChatId;
251
+ if (chatId !== undefined && config.botToken) {
252
+ transport.sendText(chatId, `<b>error</b>\nTelegram polling failed`).catch(() => undefined);
253
+ } else {
254
+ getActiveSession()?.extensionRunner.getUIContext().notify(`Telegram polling failed: ${message}`, "error");
255
+ }
256
+ },
257
+ });
258
+
259
+ function buildStatusState(error?: string): Parameters<typeof formatTelegramStatusLine>[1] {
260
+ return {
261
+ hasBotToken: !!config.botToken,
262
+ pollingActive: polling.isActive(),
263
+ paired: config.allowedUserId !== undefined,
264
+ processing: activeTurns.size > 0,
265
+ error,
266
+ botUsername: config.botUsername,
267
+ };
268
+ }
269
+
270
+ function refreshStatus(error = lastStatusError): void {
271
+ const state = buildStatusState(error);
272
+ const session = getActiveSession();
273
+ const ctx = session?.extensionRunner?.createCommandContext?.();
274
+ if (ctx?.ui?.setStatus) {
275
+ ctx.ui.setStatus(TELEGRAM_STATUS_KEY, formatTelegramStatusLine(ctx.ui.theme, state));
276
+ }
277
+ heartbeat.refreshStatus(state);
278
+ }
279
+
280
+ function clearStatus(): void {
281
+ heartbeat.stopTypingOnly();
282
+ const session = getActiveSession();
283
+ const ctx = session?.extensionRunner?.createCommandContext?.();
284
+ if (ctx?.ui?.setStatus) clearTelegramStatus(ctx);
285
+ }
286
+
287
+ function disposeRuntime(): void {
288
+ void polling.stop();
289
+ heartbeat.dispose();
290
+ for (const turn of activeTurns.values()) activeTurns.delete(turn.chatId);
291
+ ui.dispose();
292
+ clearStatus();
293
+ }
294
+
295
+ runtimeState.dispose = disposeRuntime;
296
+
297
+ pi.on("session_start", async () => {
298
+ try {
299
+ switchResolvedConfig(await readResolvedTelegramConfig(currentSessionCwd()));
300
+ } catch (error) {
301
+ switchResolvedConfig({ store: { version: 2, global: {}, workspaces: [] }, scope: "global", config: {} });
302
+ getActiveSession()?.extensionRunner.getUIContext().notify(
303
+ `Telegram config is not v2 yet. Run /tg-setup to recreate it. ${error instanceof Error ? error.message : String(error)}`,
304
+ "error",
305
+ );
306
+ }
307
+ if (config.botToken && !config.botUsername) {
308
+ try {
309
+ const botUsername = await getTelegramBotUsername(config.botToken);
310
+ if (botUsername) {
311
+ config = { ...config, botUsername };
312
+ await persistCurrentConfig(config);
313
+ }
314
+ } catch { /* non-critical */ }
315
+ }
316
+ if (config.botToken && isTelegramEnabled() && !polling.isActive()) polling.start();
317
+ try { await syncTelegramCommands(config.botToken, pi); } catch { /* non-critical */ }
318
+ lastStatusError = undefined;
319
+ heartbeat.startStatusHeartbeat(refreshStatus);
320
+ refreshStatus();
321
+ });
322
+
323
+ pi.on("session_shutdown", () => {
324
+ disposeRuntime();
325
+ if (runtimeState.dispose === disposeRuntime) runtimeState.dispose = undefined;
326
+ });
327
+ }