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 +318 -0
- package/index.ts +327 -0
- package/lib/attachments.ts +154 -0
- package/lib/callback-protocol.ts +9 -0
- package/lib/command-parser.ts +15 -0
- package/lib/commands/auth.ts +365 -0
- package/lib/commands/info.ts +158 -0
- package/lib/commands/lifecycle.ts +65 -0
- package/lib/commands/model.ts +188 -0
- package/lib/commands/register.ts +40 -0
- package/lib/commands/session.ts +281 -0
- package/lib/commands/settings.ts +129 -0
- package/lib/commands/telegram-commands.ts +162 -0
- package/lib/commands/tg-config.ts +128 -0
- package/lib/config.ts +171 -0
- package/lib/controller.ts +406 -0
- package/lib/heartbeat.ts +95 -0
- package/lib/html.ts +6 -0
- package/lib/markdown.ts +132 -0
- package/lib/menu-commands.ts +72 -0
- package/lib/polling.ts +255 -0
- package/lib/renderer.ts +284 -0
- package/lib/session-capture.ts +95 -0
- package/lib/status.ts +46 -0
- package/lib/telegram-api.ts +327 -0
- package/lib/telegram-ui.ts +208 -0
- package/lib/text-split.ts +59 -0
- package/lib/types.ts +123 -0
- package/package.json +53 -0
- package/pi-host.d.ts +8 -0
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
|
+
}
|