@bytesbrains/pi-telegram-bridge 1.0.0

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/AGENTS.md ADDED
@@ -0,0 +1,170 @@
1
+ # Telegram Bridge — Agent Usage Guide
2
+
3
+ > You are an AI agent. Use telegram-bridge tools to communicate with humans via Telegram.
4
+ > Ask questions, send status updates, and check for incoming messages.
5
+
6
+ ## Quickstart
7
+
8
+ ```bash
9
+ telegram_status() # Check if bridge is configured
10
+ telegram_send(message="Hello! I'm working on the task.") # Send a status message
11
+ telegram_listen() # Check for new messages
12
+ telegram_ask(question="Should I proceed?", options=["Yes","No"]) # Ask and wait for reply
13
+ telegram_override(command="git push --force", reason="Force push detected") # Override blocked action
14
+ ```
15
+
16
+ ## Tools
17
+
18
+ ### telegram_listen
19
+
20
+ Check for any new inbound messages from the human in the configured chat. Returns the latest message text, or indicates no new messages. The background listener automatically forwards messages, so this is for explicit polling.
21
+
22
+ ```bash
23
+ telegram_listen()
24
+ ```
25
+
26
+ ### telegram_send
27
+
28
+ Send a one-way message. Use for:
29
+ - Status updates ("Build started", "Tests passed")
30
+ - Progress reports ("Step 3/5 complete")
31
+ - Notifications ("Deployment finished")
32
+ - Errors or alerts
33
+
34
+ ```bash
35
+ telegram_send(message="✅ All tests passed. Ready to deploy.")
36
+ telegram_send(message="⚠️ Found 3 linting issues. Fixing now...")
37
+ ```
38
+
39
+ ### telegram_ask
40
+
41
+ Ask a question and **WAIT** for a human reply. This tool **blocks** until the human responds or the timeout is reached. Use when you genuinely need human input — don't overuse it.
42
+
43
+ **Parameters:**
44
+ - `question` (required) — The question to ask
45
+ - `options` (optional) — Array of button options (default: `["Yes", "No", "Explain"]`)
46
+ - `timeoutMinutes` (optional) — How long to wait (default: 30 minutes)
47
+
48
+ ```bash
49
+ # Simple yes/no
50
+ telegram_ask(question="Deploy v2.1.0 to production?")
51
+
52
+ # Custom options
53
+ telegram_ask(
54
+ question="Which environment should I target?",
55
+ options=["production", "staging", "development"]
56
+ )
57
+
58
+ # Short timeout
59
+ telegram_ask(
60
+ question="Approve this database migration?",
61
+ options=["Approve", "Reject"],
62
+ timeoutMinutes=5
63
+ )
64
+ ```
65
+
66
+ **Returns:**
67
+ - On reply: `Reply: "Yes"` with `details.answer = "Yes"`
68
+ - On timeout: `Timeout — proceeding autonomously` with `details.timedOut = true`
69
+
70
+ ### telegram_override
71
+
72
+ Ask a human to approve or reject a **blocked action**. Use this when the supervisor blocks a dangerous operation (force push, destructive git ops, file deletion outside project boundary, etc.). This tool sends a structured message with the blocked command, reason, and context, then waits for the human to choose an action.
73
+
74
+ **Parameters:**
75
+ | Param | Required | Default | Description |
76
+ |-------|----------|---------|-------------|
77
+ | `command` | ✅ | — | The blocked command exactly as the supervisor reported it |
78
+ | `reason` | ✅ | — | Why the supervisor blocked it |
79
+ | `context` | ❌ | — | Additional context (what the user originally asked for) |
80
+ | `options` | ❌ | `["Yes, proceed", "No, cancel", "Explain more"]` | Custom button labels — 1st = proceed, 2nd = cancel, rest = explain |
81
+ | `timeoutMinutes` | ❌ | `30` | How long to wait for a response |
82
+
83
+ ```bash
84
+ # Force push override
85
+ telegram_override(
86
+ command="git push gitea feat/add-check --force",
87
+ reason="Force push can overwrite remote history"
88
+ )
89
+
90
+ # Destructive operation with extra context
91
+ telegram_override(
92
+ command="rm -rf node_modules",
93
+ reason="Recursive deletion outside project boundary",
94
+ context="User requested a clean reinstall of dependencies"
95
+ )
96
+
97
+ # Custom options
98
+ telegram_override(
99
+ command="chmod -R 777 /var/www",
100
+ reason="Broad permission changes detected",
101
+ options=["Yes, it's safe", "No, cancel", "Use 755 instead", "Explain more"],
102
+ timeoutMinutes=10
103
+ )
104
+ ```
105
+
106
+ **Returns `details`:**
107
+ | Field | Type | Description |
108
+ |-------|------|-------------|
109
+ | `choice` | `string` | The exact button label the human clicked |
110
+ | `action` | `"proceed"` \| `"abort"` \| `"explain"` | Mapped action based on which button was clicked |
111
+ | `command` | `string` | The original blocked command (echoed back) |
112
+ | `timedOut` | `boolean` | `true` if no response within timeout |
113
+
114
+ **Mandatory workflow when blocked:**
115
+ 1. Supervisor blocks your command → you see the reason and blocked command
116
+ 2. Call `telegram_override(command, reason, context)` to ask the human
117
+ 3. Check `details.action`:
118
+ - **`"proceed"`** → call `supervisor_override(reason="<human approved>", command="<blocked command>")` then re-run
119
+ - **`"abort"`** → tell the user the action was cancelled: `telegram_send(message="Action cancelled as requested.")`
120
+ - **`"explain"`** → send more context via `telegram_send()`, then call `telegram_ask()` for follow-up
121
+ 4. If **`details.timedOut === true`** → abort the action and tell the user: `telegram_send(message="No response received. Action aborted.")`
122
+
123
+ **On timeout:** Sends "⏰ No reply to override request. Aborting action." to Telegram and returns `details.timedOut = true`.
124
+
125
+ **Behavior on timeout:** The agent proceeds without human input. A "No reply" message is sent to Telegram.
126
+
127
+ ### telegram_status
128
+
129
+ Check if the bridge is configured and the background listener is running.
130
+
131
+ ```bash
132
+ telegram_status()
133
+ ```
134
+
135
+ **Possible responses:**
136
+ - `Not configured. Set TELEGRAM_BOT_TOKEN and TELEGRAM_CHAT_ID.` — Missing env vars
137
+ - `Active. Bot: @mybot (listener: 🟢 running)` — Fully operational
138
+ - `Token set but unreachable.` — Network or auth issue
139
+
140
+ ## Background Listener
141
+
142
+ A background listener starts automatically on session start and runs throughout the session. It:
143
+ - Polls Telegram every 5 seconds for new messages
144
+ - Forwards any non-bot text messages from the configured chat to the agent
145
+ - Sends a confirmation (`👂 Got it! Working on: ...`) to acknowledge receipt
146
+ - Stops cleanly on session shutdown
147
+
148
+ The listener ignores:
149
+ - Messages from bots (including itself)
150
+ - Messages from other chats
151
+ - Callback queries (button presses) — these are handled by `telegram_ask`
152
+
153
+ ## Best Practices
154
+
155
+ 1. **Use `telegram_send` for routine updates** — it's non-blocking
156
+ 2. **Use `telegram_ask` sparingly** — only when you truly need human input
157
+ 3. **Use `telegram_override` for blocked actions** — always ask before calling `supervisor_override`
158
+ 4. **Set reasonable timeouts** — don't block the agent indefinitely
159
+ 5. **Handle timeouts gracefully** — the agent should be able to proceed without human input
160
+ 6. **Check `telegram_status` first** — verify the bridge is configured before sending messages
161
+
162
+ ## Troubleshooting
163
+
164
+ | Problem | Solution |
165
+ |---------|----------|
166
+ | `telegram_status` says not configured | Set `TELEGRAM_BOT_TOKEN` and `TELEGRAM_CHAT_ID` env vars |
167
+ | Messages not being delivered | Check that the bot token is valid and the chat ID is correct |
168
+ | Bot unreachable | Check network connectivity and Telegram API status |
169
+ | Duplicate messages | The bridge tracks update IDs to avoid duplicates. If it persists, restart the session |
170
+ | Listener stops unexpectedly | Check logs for errors. The listener auto-recovers from transient failures |
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 nandal
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,136 @@
1
+ # pi-telegram-bridge
2
+
3
+ Telegram bot bridge for pi agents — send messages, ask questions, and listen for human replies via Telegram.
4
+
5
+ ## Install
6
+
7
+ ```bash
8
+ pi install npm:@bytesbrains/pi-telegram-bridge
9
+ ```
10
+
11
+ ## Configuration
12
+
13
+ Set the following environment variables:
14
+
15
+ ```bash
16
+ export TELEGRAM_BOT_TOKEN="123456:ABC-DEF1234ghikl-zyx57W2v1u123ew11"
17
+ export TELEGRAM_CHAT_ID="123456789"
18
+ ```
19
+
20
+ | Variable | Required | Description |
21
+ |----------|----------|-------------|
22
+ | `TELEGRAM_BOT_TOKEN` | ✅ | Bot token from [@BotFather](https://t.me/BotFather) |
23
+ | `TELEGRAM_CHAT_ID` | ✅ | Target chat ID (your personal chat or a group) |
24
+
25
+ ## Tools
26
+
27
+ ### telegram_listen
28
+
29
+ Check for new inbound messages from the human.
30
+
31
+ ```
32
+ telegram_listen()
33
+ ```
34
+
35
+ ### telegram_send
36
+
37
+ Send a one-way message. Use for status updates and progress reports.
38
+
39
+ ```
40
+ telegram_send(message="Build completed successfully ✅")
41
+ ```
42
+
43
+ ### telegram_ask
44
+
45
+ Ask a question with inline keyboard options and **wait** for a human reply. Blocks until answered or timeout (default 30 min).
46
+
47
+ ```
48
+ telegram_ask(question="Deploy to production?")
49
+ telegram_ask(question="Which branch?", options=["main", "staging", "dev"])
50
+ telegram_ask(question="Approve this change?", options=["Approve", "Reject", "Need changes"], timeoutMinutes=5)
51
+ ```
52
+
53
+ ### telegram_override
54
+
55
+ Ask a human to approve or reject a blocked action (force push, destructive git ops, file deletion, etc.). Designed for supervisor override flows. Returns `details.action = "proceed"|"abort"|"explain"` so the agent can call `supervisor_override()` or abort.
56
+
57
+ ```
58
+ telegram_override(
59
+ command="git push gitea feat/add-check --force",
60
+ reason="Force push can overwrite remote history"
61
+ )
62
+
63
+ telegram_override(
64
+ command="rm -rf node_modules",
65
+ reason="Recursive deletion outside project boundary",
66
+ context="User requested a clean reinstall of dependencies",
67
+ options=["Yes, it's fine", "No, cancel", "Use npx instead", "Explain more"],
68
+ timeoutMinutes=10
69
+ )
70
+ ```
71
+
72
+ **Parameters:**
73
+ | Param | Required | Default | Description |
74
+ |-------|----------|---------|-------------|
75
+ | `command` | ✅ | — | The blocked command or action |
76
+ | `reason` | ✅ | — | Why it was blocked by the supervisor |
77
+ | `context` | ❌ | — | Additional context (what the user asked for, etc.) |
78
+ | `options` | ❌ | `["Yes, proceed", "No, cancel", "Explain more"]` | Custom button labels |
79
+ | `timeoutMinutes` | ❌ | `30` | How long to wait for a response |
80
+
81
+ **Returns `details`:**
82
+ | Field | Type | Description |
83
+ |-------|------|-------------|
84
+ | `choice` | `string` | The exact button label the human clicked |
85
+ | `action` | `string` | `"proceed"` (first option), `"abort"` (second option), or `"explain"` (anything else) |
86
+ | `command` | `string` | The original blocked command |
87
+ | `timedOut` | `boolean` | Whether the request timed out |
88
+
89
+ **Agent workflow:**
90
+ 1. Agent gets blocked by supervisor
91
+ 2. Calls `telegram_override(command, reason, context)`
92
+ 3. Checks `details.action`:
93
+ - `"proceed"` → call `supervisor_override(reason, command)`
94
+ - `"abort"` → tell user action was cancelled
95
+ - `"explain"` → call `telegram_send(message="...")` with more context, then re-ask
96
+ 4. If `details.timedOut` → abort the action
97
+
98
+ ### telegram_status
99
+
100
+ Check if the bridge is configured and running.
101
+
102
+ ```
103
+ telegram_status()
104
+ ```
105
+
106
+ ## Background Listener
107
+
108
+ The extension starts a background listener on session start that polls for incoming Telegram messages. Any non-bot text message in the configured chat is forwarded to the agent as a user message. The agent responds with a confirmation.
109
+
110
+ The listener runs automatically — no manual setup needed.
111
+
112
+ ## How It Works
113
+
114
+ 1. **Session start**: The bridge restores the last processed update ID from session state and starts polling
115
+ 2. **Inbound messages**: Non-bot text messages in the configured chat are forwarded to the agent
116
+ 3. **Session end**: The listener stops cleanly on session shutdown
117
+ 4. **Update ID is persisted** across sessions to avoid processing duplicate messages
118
+
119
+ ## Get a Bot Token
120
+
121
+ 1. Open Telegram and chat with [@BotFather](https://t.me/BotFather)
122
+ 2. Send `/newbot` and follow the prompts
123
+ 3. Copy the token and set `TELEGRAM_BOT_TOKEN`
124
+ 4. Send a message to your bot, then visit:
125
+ `https://api.telegram.org/bot<YOUR_TOKEN>/getUpdates`
126
+ 5. Copy the `chat.id` from the response and set `TELEGRAM_CHAT_ID`
127
+
128
+ ## Requirements
129
+
130
+ - Node.js >= 18
131
+ - A Telegram bot token
132
+ - A chat ID to send/receive messages
133
+
134
+ ## License
135
+
136
+ MIT
package/package.json ADDED
@@ -0,0 +1,51 @@
1
+ {
2
+ "name": "@bytesbrains/pi-telegram-bridge",
3
+ "version": "1.0.0",
4
+ "description": "Telegram bot bridge for pi agents — send messages, ask questions, and listen for human replies via Telegram.",
5
+ "keywords": [
6
+ "pi-package",
7
+ "pi-extension",
8
+ "telegram",
9
+ "bot",
10
+ "bridge",
11
+ "messaging"
12
+ ],
13
+ "author": "nandal <nandal@users.noreply.github.com>",
14
+ "repository": {
15
+ "type": "git",
16
+ "url": "git+https://github.com/nandal/pi-ext.git",
17
+ "directory": "telegram-bridge"
18
+ },
19
+ "homepage": "https://github.com/nandal/pi-ext/tree/main/telegram-bridge",
20
+ "bugs": {
21
+ "url": "https://github.com/nandal/pi-ext/issues"
22
+ },
23
+ "license": "MIT",
24
+ "main": "./src/index.ts",
25
+ "engines": {
26
+ "node": ">=18"
27
+ },
28
+ "files": [
29
+ "src/",
30
+ "README.md",
31
+ "AGENTS.md",
32
+ "LICENSE"
33
+ ],
34
+ "peerDependencies": {
35
+ "@earendil-works/pi-coding-agent": "*",
36
+ "typebox": "*"
37
+ },
38
+ "pi": {
39
+ "extensions": [
40
+ "./src/index.ts"
41
+ ]
42
+ },
43
+ "scripts": {
44
+ "test": "vitest run",
45
+ "test:watch": "vitest"
46
+ },
47
+ "devDependencies": {
48
+ "typescript": "^6.0.3",
49
+ "vitest": "^2.1.9"
50
+ }
51
+ }
package/src/helpers.ts ADDED
@@ -0,0 +1,234 @@
1
+ /**
2
+ * pi-telegram-bridge — Telegram API Helpers
3
+ */
4
+ const BASE_URL = "https://api.telegram.org/bot";
5
+
6
+ // ── Config ──────────────────────────────────────────────────────────
7
+
8
+ export function getToken(): string {
9
+ const tok = process.env.TELEGRAM_BOT_TOKEN;
10
+ if (!tok) throw new Error("TELEGRAM_BOT_TOKEN not set");
11
+ return tok;
12
+ }
13
+
14
+ export function getChatId(): string {
15
+ const id = process.env.TELEGRAM_CHAT_ID;
16
+ if (!id) throw new Error("TELEGRAM_CHAT_ID not set");
17
+ return id;
18
+ }
19
+
20
+ // ── API ─────────────────────────────────────────────────────────────
21
+
22
+ export async function telegramApi(
23
+ method: string,
24
+ body: Record<string, unknown>,
25
+ ): Promise<unknown> {
26
+ const url = `${BASE_URL}${getToken()}/${method}`;
27
+ const res = await fetch(url, {
28
+ method: "POST",
29
+ headers: { "Content-Type": "application/json" },
30
+ body: JSON.stringify(body),
31
+ });
32
+ if (!res.ok) {
33
+ throw new Error(`Telegram API ${res.status}: ${await res.text()}`);
34
+ }
35
+ return res.json();
36
+ }
37
+
38
+ // ── Messaging ───────────────────────────────────────────────────────
39
+
40
+ export async function sendMsg(
41
+ text: string,
42
+ replyMarkup?: object,
43
+ ): Promise<number> {
44
+ const body: Record<string, unknown> = {
45
+ chat_id: getChatId(),
46
+ text,
47
+ parse_mode: "Markdown",
48
+ };
49
+ if (replyMarkup) body.reply_markup = JSON.stringify(replyMarkup);
50
+ const r = (await telegramApi("sendMessage", body)) as {
51
+ ok: boolean;
52
+ result: { message_id: number };
53
+ };
54
+ return r.result.message_id;
55
+ }
56
+
57
+ // ── Polling ─────────────────────────────────────────────────────────
58
+
59
+ export async function pollReply(
60
+ sentId: number,
61
+ chatId: string,
62
+ timeoutMs: number,
63
+ ): Promise<string | null> {
64
+ const token = getToken();
65
+ const deadline = Date.now() + timeoutMs;
66
+
67
+ // Drain pending updates to start fresh
68
+ let lastId = 0;
69
+ try {
70
+ const drain = await fetch(`${BASE_URL}${token}/getUpdates?timeout=0`);
71
+ if (drain.ok) {
72
+ const d = (await drain.json()) as {
73
+ ok: boolean;
74
+ result: Array<{ update_id: number }>;
75
+ };
76
+ if (d.ok && d.result.length > 0) {
77
+ lastId = d.result[d.result.length - 1].update_id;
78
+ }
79
+ }
80
+ } catch {
81
+ /* ignore drain errors */
82
+ }
83
+
84
+ while (Date.now() < deadline) {
85
+ const res = await fetch(
86
+ `${BASE_URL}${token}/getUpdates?offset=${lastId + 1}&timeout=5`,
87
+ );
88
+ if (!res.ok) {
89
+ await new Promise((r) => setTimeout(r, 3000));
90
+ continue;
91
+ }
92
+ const data = (await res.json()) as {
93
+ ok: boolean;
94
+ result: Array<{
95
+ update_id: number;
96
+ message?: {
97
+ message_id: number;
98
+ chat: { id: number };
99
+ text?: string;
100
+ };
101
+ callback_query?: {
102
+ id: string;
103
+ message: { message_id: number; chat: { id: number } };
104
+ data: string;
105
+ };
106
+ }>;
107
+ };
108
+ if (!data.ok) {
109
+ await new Promise((r) => setTimeout(r, 2000));
110
+ continue;
111
+ }
112
+ for (const u of data.result) {
113
+ lastId = u.update_id;
114
+ // Check for text reply
115
+ if (
116
+ u.message &&
117
+ u.message.chat.id === Number(chatId) &&
118
+ u.message.message_id > sentId &&
119
+ u.message.text
120
+ ) {
121
+ return u.message.text;
122
+ }
123
+ // Check for callback query (inline button)
124
+ if (
125
+ u.callback_query &&
126
+ u.callback_query.message.chat.id === Number(chatId)
127
+ ) {
128
+ await telegramApi("answerCallbackQuery", {
129
+ callback_query_id: u.callback_query.id,
130
+ });
131
+ return u.callback_query.data;
132
+ }
133
+ }
134
+ await new Promise((r) => setTimeout(r, 2000));
135
+ }
136
+ return null; // timeout
137
+ }
138
+
139
+ // ── Listener State ──────────────────────────────────────────────────
140
+
141
+ export async function getBotId(): Promise<number | null> {
142
+ try {
143
+ const r = (await telegramApi("getMe", {})) as { result: { id: number } };
144
+ return r.result.id;
145
+ } catch {
146
+ return null;
147
+ }
148
+ }
149
+
150
+ export async function seedLastUpdateId(
151
+ lastUpdateId: number,
152
+ signal?: AbortSignal,
153
+ ): Promise<number> {
154
+ if (lastUpdateId !== 0) return lastUpdateId;
155
+ const token = getToken();
156
+ try {
157
+ const res = await fetch(`${BASE_URL}${token}/getUpdates?timeout=0`, {
158
+ signal,
159
+ });
160
+ if (res.ok) {
161
+ const d = (await res.json()) as {
162
+ ok: boolean;
163
+ result: Array<{ update_id: number }>;
164
+ };
165
+ if (d.ok && d.result.length > 0) {
166
+ return d.result[d.result.length - 1].update_id;
167
+ }
168
+ }
169
+ } catch {
170
+ /* ignore */
171
+ }
172
+ return lastUpdateId;
173
+ }
174
+
175
+ export async function pollUpdates(
176
+ token: string,
177
+ chatId: string,
178
+ botId: number | null,
179
+ lastUpdateId: number,
180
+ signal: AbortSignal,
181
+ onMessage: (text: string) => void,
182
+ onUpdateId: (id: number) => void,
183
+ ): Promise<void> {
184
+ while (!signal.aborted) {
185
+ try {
186
+ const res = await fetch(
187
+ `${BASE_URL}${token}/getUpdates?offset=${lastUpdateId + 1}&timeout=10`,
188
+ { signal },
189
+ );
190
+ if (!res.ok) {
191
+ await new Promise((r) => setTimeout(r, 3000));
192
+ continue;
193
+ }
194
+ const data = (await res.json()) as {
195
+ ok: boolean;
196
+ result: Array<{
197
+ update_id: number;
198
+ message?: {
199
+ message_id: number;
200
+ chat: { id: number };
201
+ from?: { id: number; is_bot?: boolean };
202
+ text?: string;
203
+ };
204
+ callback_query?: unknown;
205
+ }>;
206
+ };
207
+ if (!data.ok) {
208
+ await new Promise((r) => setTimeout(r, 2000));
209
+ continue;
210
+ }
211
+ for (const u of data.result) {
212
+ lastUpdateId = u.update_id;
213
+ onUpdateId(lastUpdateId);
214
+ // Skip callback queries (handled by telegram_ask)
215
+ if (u.callback_query) continue;
216
+ // Only process text messages from the configured chat, not from our bot
217
+ if (
218
+ u.message &&
219
+ u.message.text &&
220
+ String(u.message.chat.id) === chatId
221
+ ) {
222
+ if (u.message.from?.is_bot || u.message.from?.id === botId) {
223
+ continue;
224
+ }
225
+ onMessage(u.message.text);
226
+ }
227
+ }
228
+ } catch (e: unknown) {
229
+ if (e instanceof Error && e.name === "AbortError") break;
230
+ await new Promise((r) => setTimeout(r, 3000));
231
+ }
232
+ await new Promise((r) => setTimeout(r, 5000));
233
+ }
234
+ }
package/src/index.ts ADDED
@@ -0,0 +1,381 @@
1
+ /**
2
+ * pi-telegram-bridge — Telegram Bot Bridge
3
+ *
4
+ * Tools: telegram_listen, telegram_send, telegram_ask, telegram_override, telegram_status
5
+ * Config: TELEGRAM_BOT_TOKEN and TELEGRAM_CHAT_ID environment variables
6
+ *
7
+ * Starts a background listener on session start that polls for incoming
8
+ * messages and forwards them to the agent as user messages.
9
+ */
10
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
11
+ import {
12
+ getToken,
13
+ getChatId,
14
+ telegramApi,
15
+ sendMsg,
16
+ pollReply,
17
+ getBotId,
18
+ seedLastUpdateId,
19
+ pollUpdates,
20
+ } from "./helpers";
21
+ import {
22
+ listenSchema,
23
+ sendSchema,
24
+ askSchema,
25
+ statusSchema,
26
+ overrideSchema,
27
+ } from "./tools/telegram";
28
+
29
+ export default function telegramBridge(pi: ExtensionAPI) {
30
+ let botId: number | null = null;
31
+ let listenerAbort: AbortController | null = null;
32
+ let lastUpdateId = 0;
33
+
34
+ // ── Session lifecycle ───────────────────────────────────────────
35
+
36
+ pi.on("session_start", async (_event, ctx) => {
37
+ // Restore lastUpdateId from session state
38
+ for (const entry of ctx.sessionManager.getEntries()) {
39
+ if (entry.type === "custom" && entry.customType === "tg-last-update") {
40
+ lastUpdateId = (entry.data as { update_id: number }).update_id;
41
+ }
42
+ }
43
+ // Start background listener
44
+ startListener();
45
+ });
46
+
47
+ pi.on("session_shutdown", () => {
48
+ listenerAbort?.abort();
49
+ listenerAbort = null;
50
+ });
51
+
52
+ // ── Background listener ─────────────────────────────────────────
53
+
54
+ async function startListener() {
55
+ if (listenerAbort) return; // already running
56
+ listenerAbort = new AbortController();
57
+ const signal = listenerAbort.signal;
58
+ const token = getToken();
59
+ const chatId = getChatId();
60
+
61
+ // Get bot's own ID once to filter out self-messages
62
+ if (!botId) {
63
+ botId = await getBotId();
64
+ }
65
+
66
+ // Seed lastUpdateId
67
+ lastUpdateId = await seedLastUpdateId(lastUpdateId, signal);
68
+
69
+ // Fire and forget — poll in background
70
+ pollUpdates(
71
+ token,
72
+ chatId,
73
+ botId,
74
+ lastUpdateId,
75
+ signal,
76
+ // onMessage: forward to pi as user message
77
+ async (text: string) => {
78
+ pi.sendUserMessage(text);
79
+ await sendMsg(`👂 Got it! Working on: _${text.slice(0, 100)}_`);
80
+ },
81
+ // onUpdateId: persist offset
82
+ (id: number) => {
83
+ lastUpdateId = id;
84
+ pi.appendEntry("tg-last-update", { update_id: id });
85
+ },
86
+ );
87
+ }
88
+
89
+ // ── Tools ───────────────────────────────────────────────────────
90
+
91
+ // telegram_listen
92
+ pi.registerTool({
93
+ name: "telegram_listen",
94
+ label: "Telegram Listen",
95
+ description:
96
+ "Check Telegram for any new inbound messages from the human. Returns the latest message text, or indicates no new messages.",
97
+ parameters: listenSchema,
98
+ async execute() {
99
+ try {
100
+ const token = getToken();
101
+ const chatId = getChatId();
102
+ if (!botId) botId = await getBotId();
103
+
104
+ const res = await fetch(
105
+ `https://api.telegram.org/bot${token}/getUpdates?offset=${lastUpdateId + 1}&timeout=5`,
106
+ );
107
+ if (!res.ok)
108
+ return {
109
+ content: [{ type: "text", text: "Could not reach Telegram." }],
110
+ isError: true,
111
+ };
112
+
113
+ const data = (await res.json()) as {
114
+ ok: boolean;
115
+ result: Array<{
116
+ update_id: number;
117
+ message?: {
118
+ message_id: number;
119
+ chat: { id: number };
120
+ from?: { id: number; is_bot?: boolean };
121
+ text?: string;
122
+ };
123
+ callback_query?: unknown;
124
+ }>;
125
+ };
126
+ if (!data.ok)
127
+ return {
128
+ content: [{ type: "text", text: "Telegram API error." }],
129
+ isError: true,
130
+ };
131
+
132
+ for (const u of data.result) {
133
+ lastUpdateId = u.update_id;
134
+ pi.appendEntry("tg-last-update", { update_id: lastUpdateId });
135
+ if (u.callback_query) continue;
136
+ if (
137
+ u.message &&
138
+ u.message.text &&
139
+ String(u.message.chat.id) === chatId
140
+ ) {
141
+ if (u.message.from?.is_bot || u.message.from?.id === botId)
142
+ continue;
143
+ return {
144
+ content: [
145
+ {
146
+ type: "text",
147
+ text: `📩 New message: "${u.message.text}"`,
148
+ },
149
+ ],
150
+ details: { message: u.message.text },
151
+ };
152
+ }
153
+ }
154
+ return { content: [{ type: "text", text: "No new messages." }] };
155
+ } catch (e: unknown) {
156
+ return {
157
+ content: [
158
+ {
159
+ type: "text",
160
+ text: `Failed: ${e instanceof Error ? e.message : e}`,
161
+ },
162
+ ],
163
+ isError: true,
164
+ };
165
+ }
166
+ },
167
+ });
168
+
169
+ // telegram_send
170
+ pi.registerTool({
171
+ name: "telegram_send",
172
+ label: "Telegram Send",
173
+ description:
174
+ "Send a one-way message to Telegram. Use for status updates.",
175
+ parameters: sendSchema,
176
+ async execute(_id: string, params: { message: string }) {
177
+ try {
178
+ const mid = await sendMsg(params.message);
179
+ return { content: [{ type: "text", text: `Sent (id:${mid})` }] };
180
+ } catch (e: unknown) {
181
+ return {
182
+ content: [
183
+ {
184
+ type: "text",
185
+ text: `Failed: ${e instanceof Error ? e.message : e}`,
186
+ },
187
+ ],
188
+ isError: true,
189
+ };
190
+ }
191
+ },
192
+ });
193
+
194
+ // telegram_ask
195
+ pi.registerTool({
196
+ name: "telegram_ask",
197
+ label: "Telegram Ask",
198
+ description:
199
+ "Ask a question on Telegram and WAIT for a human reply. BLOCKS until you answer. Use when you need human input.",
200
+ parameters: askSchema,
201
+ async execute(
202
+ _id: string,
203
+ params: {
204
+ question: string;
205
+ options?: string[];
206
+ timeoutMinutes?: number;
207
+ },
208
+ ) {
209
+ try {
210
+ const chatId = getChatId();
211
+ const opts = params.options ?? ["Yes", "No", "Explain"];
212
+ const kb = opts.map((o: string) => [{ text: o, callback_data: o }]);
213
+ const timeoutMs = (params.timeoutMinutes ?? 30) * 60000;
214
+ const mid = await sendMsg(`❓ *${params.question}*`, {
215
+ inline_keyboard: kb,
216
+ });
217
+ const reply = await pollReply(mid, chatId, timeoutMs);
218
+ if (!reply) {
219
+ await sendMsg("⏰ No reply. Proceeding autonomously.");
220
+ return {
221
+ content: [
222
+ { type: "text", text: "Timeout — proceeding autonomously" },
223
+ ],
224
+ details: { timedOut: true },
225
+ };
226
+ }
227
+ return {
228
+ content: [{ type: "text", text: `Reply: "${reply}"` }],
229
+ details: { answer: reply },
230
+ };
231
+ } catch (e: unknown) {
232
+ return {
233
+ content: [
234
+ {
235
+ type: "text",
236
+ text: `Failed: ${e instanceof Error ? e.message : e}`,
237
+ },
238
+ ],
239
+ isError: true,
240
+ };
241
+ }
242
+ },
243
+ });
244
+
245
+ // telegram_override
246
+ pi.registerTool({
247
+ name: "telegram_override",
248
+ label: "Telegram Override",
249
+ description:
250
+ "Ask a human on Telegram to approve or reject a blocked action. " +
251
+ "Use when the supervisor blocks a dangerous command (force push, " +
252
+ "destructive git ops, file deletion, etc.) and you need human approval. " +
253
+ "Returns the human's choice so you can call supervisor_override or abort.",
254
+ parameters: overrideSchema,
255
+ async execute(
256
+ _id: string,
257
+ params: {
258
+ command: string;
259
+ reason: string;
260
+ context?: string;
261
+ options?: string[];
262
+ timeoutMinutes?: number;
263
+ },
264
+ ) {
265
+ try {
266
+ const chatId = getChatId();
267
+ const opts = params.options ?? [
268
+ "Yes, proceed",
269
+ "No, cancel",
270
+ "Explain more",
271
+ ];
272
+ const timeoutMs = (params.timeoutMinutes ?? 30) * 60000;
273
+
274
+ let message = `🛑 *Supervisor blocked an action*\n\n`;
275
+ message += `*Command:* \`${params.command.slice(0, 200)}\`\n`;
276
+ message += `*Reason:* ${params.reason.slice(0, 300)}\n`;
277
+ if (params.context) {
278
+ message += `*Context:* ${params.context.slice(0, 300)}\n`;
279
+ }
280
+ message += `\n_What should I do?_`;
281
+
282
+ const kb = opts.map((o: string) => [{ text: o, callback_data: o }]);
283
+ const mid = await sendMsg(message, { inline_keyboard: kb });
284
+ const reply = await pollReply(mid, chatId, timeoutMs);
285
+
286
+ if (!reply) {
287
+ await sendMsg("⏰ No reply to override request. Aborting action.");
288
+ return {
289
+ content: [
290
+ {
291
+ type: "text",
292
+ text: "Timeout — no human response. Action aborted.",
293
+ },
294
+ ],
295
+ details: {
296
+ timedOut: true,
297
+ action: "abort",
298
+ command: params.command,
299
+ },
300
+ };
301
+ }
302
+
303
+ // Map the reply to an action
304
+ const choice = reply.trim();
305
+ let action: string;
306
+ if (choice === opts[0]) {
307
+ action = "proceed";
308
+ } else if (choice === opts[1]) {
309
+ action = "abort";
310
+ } else {
311
+ action = "explain";
312
+ }
313
+
314
+ await sendMsg(`✅ Choice received: _${choice}_`);
315
+
316
+ return {
317
+ content: [
318
+ {
319
+ type: "text",
320
+ text: `Human chose: "${choice}" → action: ${action}`,
321
+ },
322
+ ],
323
+ details: {
324
+ choice,
325
+ action,
326
+ command: params.command,
327
+ timedOut: false,
328
+ },
329
+ };
330
+ } catch (e: unknown) {
331
+ return {
332
+ content: [
333
+ {
334
+ type: "text",
335
+ text: `Failed: ${e instanceof Error ? e.message : e}`,
336
+ },
337
+ ],
338
+ isError: true,
339
+ };
340
+ }
341
+ },
342
+ });
343
+
344
+ // telegram_status
345
+ pi.registerTool({
346
+ name: "telegram_status",
347
+ label: "Telegram Status",
348
+ description:
349
+ "Check if the Telegram bridge is configured and running.",
350
+ parameters: statusSchema,
351
+ async execute() {
352
+ if (!process.env.TELEGRAM_BOT_TOKEN || !process.env.TELEGRAM_CHAT_ID) {
353
+ return {
354
+ content: [
355
+ {
356
+ type: "text",
357
+ text: "Not configured. Set TELEGRAM_BOT_TOKEN and TELEGRAM_CHAT_ID.",
358
+ },
359
+ ],
360
+ };
361
+ }
362
+ try {
363
+ const r = (await telegramApi("getMe", {})) as {
364
+ result: { username: string };
365
+ };
366
+ return {
367
+ content: [
368
+ {
369
+ type: "text",
370
+ text: `Active. Bot: @${r.result.username} (listener: ${listenerAbort ? "🟢 running" : "🔴 stopped"})`,
371
+ },
372
+ ],
373
+ };
374
+ } catch {
375
+ return {
376
+ content: [{ type: "text", text: "Token set but unreachable." }],
377
+ };
378
+ }
379
+ },
380
+ });
381
+ }
@@ -0,0 +1,27 @@
1
+ /**
2
+ * pi-telegram-bridge — Tool Schemas
3
+ *
4
+ * Tool parameter schemas and metadata. Execute functions are wired in
5
+ * index.ts so they can capture the pi ExtensionAPI via closure.
6
+ */
7
+ import { Type } from "typebox";
8
+
9
+ export const listenSchema = Type.Object({});
10
+
11
+ export const sendSchema = Type.Object({ message: Type.String() });
12
+
13
+ export const askSchema = Type.Object({
14
+ question: Type.String(),
15
+ options: Type.Optional(Type.Array(Type.String())),
16
+ timeoutMinutes: Type.Optional(Type.Number()),
17
+ });
18
+
19
+ export const statusSchema = Type.Object({});
20
+
21
+ export const overrideSchema = Type.Object({
22
+ command: Type.String(),
23
+ reason: Type.String(),
24
+ context: Type.Optional(Type.String()),
25
+ options: Type.Optional(Type.Array(Type.String())),
26
+ timeoutMinutes: Type.Optional(Type.Number()),
27
+ });
package/src/types.ts ADDED
@@ -0,0 +1,15 @@
1
+ /**
2
+ * pi-telegram-bridge — Types
3
+ */
4
+
5
+ export interface TelegramConfig {
6
+ /** Telegram Bot API token from @BotFather */
7
+ botToken: string;
8
+ /** Target chat ID to send messages to and listen from */
9
+ chatId: string;
10
+ }
11
+
12
+ /** Session state persisted across restarts */
13
+ export interface TelegramSessionState {
14
+ update_id: number;
15
+ }