@bytesbrains/pi-telegram-bridge 1.1.5 → 1.2.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/README.md CHANGED
@@ -1,45 +1,153 @@
1
1
  # pi-telegram-bridge
2
2
 
3
- Telegram bot bridge for pi agents — send messages, ask questions, and listen for human replies via Telegram.
3
+ Telegram bot bridge for pi agents — send messages, ask questions, receive photos, and get a live project dashboard on "hi".
4
4
 
5
5
  ## Install
6
6
 
7
7
  ```bash
8
- pi install npm:@bytesbrains/pi-telegram-bridge
8
+ pi install npm:@bytesbrains/pi-telegram-bridge@1.1.6
9
9
  ```
10
10
 
11
- ## Configuration
11
+ ## Quick Setup
12
12
 
13
- Set the following environment variables:
13
+ ### 1. Create a bot with @BotFather
14
+
15
+ Open Telegram and chat with [@BotFather](https://t.me/BotFather):
16
+
17
+ ```
18
+ /newbot
19
+ ```
20
+
21
+ Follow the prompts. You'll get a token like:
22
+
23
+ ```
24
+ 123456:ABC-DEF1234ghikl-zyx57W2v1u123ew11
25
+ ```
26
+
27
+ ### 2. Get your Chat ID
28
+
29
+ Send ANY message to your new bot on Telegram, then visit this URL in your browser:
30
+
31
+ ```
32
+ https://api.telegram.org/bot<YOUR_TOKEN>/getUpdates
33
+ ```
34
+
35
+ Look for `"chat":{"id":123456789}` in the response. That's your chat ID.
36
+
37
+ > **For group chats:** Add the bot to a group, send a message mentioning the bot, then check the same URL. Use the group chat ID.
38
+
39
+ ### 3. Set environment variables
14
40
 
15
41
  ```bash
16
42
  export TELEGRAM_BOT_TOKEN="123456:ABC-DEF1234ghikl-zyx57W2v1u123ew11"
17
43
  export TELEGRAM_CHAT_ID="123456789"
18
44
  ```
19
45
 
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) |
46
+ Add them to your shell profile (`~/.zshrc`, `~/.bashrc`) to persist across sessions.
47
+
48
+ ### 4. Verify
49
+
50
+ Start pi and run `telegram_status()`. You should see:
51
+
52
+ ```
53
+ Active. Bot: @your_bot_name (listener: 🟢 running)
54
+ ```
55
+
56
+ ## Environment Variables
57
+
58
+ | Variable | Required | Default | Description |
59
+ | -------------------- | -------- | ----------------------- | --------------------------------------------------------------- |
60
+ | `TELEGRAM_BOT_TOKEN` | ✅ | — | Bot token from @BotFather |
61
+ | `TELEGRAM_CHAT_ID` | ✅ | — | Chat ID to send/receive messages |
62
+ | `TELEGRAM_MENTION` | ❌ | `@pi` (or bot username) | Only respond to messages containing this mention in group chats |
63
+
64
+ ### TELEGRAM_MENTION
65
+
66
+ Controls which messages the agent responds to in **group chats**:
67
+
68
+ ```bash
69
+ # Use a custom mention trigger
70
+ export TELEGRAM_MENTION="@mybot"
71
+
72
+ # Or let it auto-detect from the bot username
73
+ export TELEGRAM_MENTION="" # defaults to @bot_username
74
+ ```
75
+
76
+ - **Private chat** (direct message to bot): All messages are treated as directed — no mention needed
77
+ - **Group chat**: Only messages containing `@botname` are forwarded to the agent. Everything else is ignored.
78
+
79
+ ## Features
80
+
81
+ ### Status Dashboard
82
+
83
+ Send `hi`, `/status`, or `status` to your bot on Telegram. It replies with a live project summary:
84
+
85
+ ```
86
+ 📊 pi-ext Status
87
+
88
+ 📋 Open Issues: 3
89
+ #1 telegram-bridge HTML parse crash
90
+ #5 status dashboard on hi message
91
+
92
+ 🔀 Open PRs: 2
93
+ ✅ #2 fix(telegram-bridge): escape HTML entities
94
+
95
+ 🔧 Last CI: ⏳ queued (5 recent runs)
96
+ ```
97
+
98
+ ### Photo Support
99
+
100
+ **Send photos to the agent:** Send any photo to the bot — the agent downloads it and can use `read()` to view it. Add a caption for context:
101
+
102
+ > 📸 Photo: `login page broken, 404 in console`
103
+
104
+ **Send photos from the agent:** Use `telegram_send_photo()` to share screenshots or diagrams:
105
+
106
+ ```
107
+ telegram_send_photo(photoPath="/tmp/screenshot.png", caption="Build output")
108
+ ```
109
+
110
+ ### @Mention Support
111
+
112
+ In group chats, the bot only responds to messages that mention it. This lets humans talk freely without triggering the agent.
113
+
114
+ | Chat type | Message | Bot responds? |
115
+ | --------- | ------------------------ | ---------------------------------------- |
116
+ | Private | `hi` | ✅ |
117
+ | Private | `@pi what's the status?` | ✅ (mention stripped) |
118
+ | Group | `hi` | ❌ |
119
+ | Group | `@pi what's the status?` | ✅ (agent receives `what's the status?`) |
24
120
 
25
121
  ## Tools
26
122
 
27
123
  ### telegram_listen
28
124
 
29
- Check for new inbound messages from the human.
125
+ Check for new inbound messages (text and photos) from the human.
30
126
 
31
127
  ```
32
128
  telegram_listen()
33
129
  ```
34
130
 
131
+ Returns photo paths and captions if a photo was received.
132
+
35
133
  ### telegram_send
36
134
 
37
- Send a one-way message. Use for status updates and progress reports.
135
+ Send a one-way text message.
38
136
 
39
137
  ```
40
138
  telegram_send(message="Build completed successfully ✅")
41
139
  ```
42
140
 
141
+ ### telegram_send_photo
142
+
143
+ Send a photo — supports local file paths and remote URLs.
144
+
145
+ ```
146
+ telegram_send_photo(photoPath="/tmp/screenshot.png")
147
+ telegram_send_photo(photoPath="/tmp/screenshot.png", caption="Error in console")
148
+ telegram_send_photo(photoPath="https://example.com/image.jpg")
149
+ ```
150
+
43
151
  ### telegram_ask
44
152
 
45
153
  Ask a question with inline keyboard options and **wait** for a human reply. Blocks until answered or timeout (default 30 min).
@@ -87,6 +195,7 @@ telegram_override(
87
195
  | `timedOut` | `boolean` | Whether the request timed out |
88
196
 
89
197
  **Agent workflow:**
198
+
90
199
  1. Agent gets blocked by supervisor
91
200
  2. Calls `telegram_override(command, reason, context)`
92
201
  3. Checks `details.action`:
@@ -103,33 +212,11 @@ Check if the bridge is configured and running.
103
212
  telegram_status()
104
213
  ```
105
214
 
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
215
  ## Requirements
129
216
 
130
217
  - Node.js >= 18
131
- - A Telegram bot token
132
- - A chat ID to send/receive messages
218
+ - A Telegram bot token (from @BotFather)
219
+ - A chat ID
133
220
 
134
221
  ## License
135
222
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bytesbrains/pi-telegram-bridge",
3
- "version": "1.1.5",
3
+ "version": "1.2.0",
4
4
  "description": "Telegram bot bridge for pi agents — send messages, ask questions, and listen for human replies via Telegram.",
5
5
  "keywords": [
6
6
  "pi-package",
@@ -437,10 +437,10 @@ describe("pollUpdates", () => {
437
437
  // pollUpdates has 5s delay between polls — this test takes ~5s
438
438
  await helpers.pollUpdates(
439
439
  TEST_TOKEN, TEST_CHAT_ID, 12345, 0,
440
- controller.signal, onMessage, onUpdateId,
440
+ controller.signal, onMessage, vi.fn(), onUpdateId,
441
441
  );
442
442
 
443
- expect(onMessage).toHaveBeenCalledWith("Hello agent!");
443
+ expect(onMessage).toHaveBeenCalledWith('Hello agent!', '987654321');
444
444
  expect(onUpdateId).toHaveBeenCalledWith(100);
445
445
  }, 15000);
446
446
 
@@ -482,7 +482,7 @@ describe("pollUpdates", () => {
482
482
 
483
483
  await helpers.pollUpdates(
484
484
  TEST_TOKEN, TEST_CHAT_ID, 12345, 0,
485
- controller.signal, onMessage, onUpdateId,
485
+ controller.signal, onMessage, vi.fn(), onUpdateId,
486
486
  );
487
487
 
488
488
  expect(onMessage).not.toHaveBeenCalled();
@@ -527,7 +527,7 @@ describe("pollUpdates", () => {
527
527
 
528
528
  await helpers.pollUpdates(
529
529
  TEST_TOKEN, TEST_CHAT_ID, 12345, 0,
530
- controller.signal, onMessage, onUpdateId,
530
+ controller.signal, onMessage, vi.fn(), onUpdateId,
531
531
  );
532
532
 
533
533
  expect(onMessage).not.toHaveBeenCalled();
@@ -567,7 +567,7 @@ describe("pollUpdates", () => {
567
567
 
568
568
  await helpers.pollUpdates(
569
569
  TEST_TOKEN, TEST_CHAT_ID, 12345, 0,
570
- controller.signal, onMessage, onUpdateId,
570
+ controller.signal, onMessage, vi.fn(), onUpdateId,
571
571
  );
572
572
 
573
573
  expect(onMessage).not.toHaveBeenCalled();
@@ -615,10 +615,10 @@ describe("pollUpdates", () => {
615
615
 
616
616
  await helpers.pollUpdates(
617
617
  TEST_TOKEN, TEST_CHAT_ID, 12345, 0,
618
- controller.signal, onMessage, onUpdateId,
618
+ controller.signal, onMessage, vi.fn(), onUpdateId,
619
619
  );
620
620
 
621
- expect(onMessage).toHaveBeenCalledWith("recovered");
621
+ expect(onMessage).toHaveBeenCalledWith('recovered', '987654321');
622
622
  }, 20000);
623
623
 
624
624
  it("handles network errors with retry", async () => {
@@ -660,10 +660,10 @@ describe("pollUpdates", () => {
660
660
 
661
661
  await helpers.pollUpdates(
662
662
  TEST_TOKEN, TEST_CHAT_ID, 12345, 0,
663
- controller.signal, onMessage, onUpdateId,
663
+ controller.signal, onMessage, vi.fn(), onUpdateId,
664
664
  );
665
665
 
666
- expect(onMessage).toHaveBeenCalledWith("eventually");
666
+ expect(onMessage).toHaveBeenCalledWith('eventually', '987654321');
667
667
  }, 25000);
668
668
 
669
669
  it("stops gracefully when aborted immediately", async () => {
@@ -681,7 +681,7 @@ describe("pollUpdates", () => {
681
681
 
682
682
  await helpers.pollUpdates(
683
683
  TEST_TOKEN, TEST_CHAT_ID, 12345, 0,
684
- controller.signal, onMessage, onUpdateId,
684
+ controller.signal, onMessage, vi.fn(), onUpdateId,
685
685
  );
686
686
 
687
687
  expect(onMessage).not.toHaveBeenCalled();
@@ -731,10 +731,10 @@ describe("pollUpdates", () => {
731
731
 
732
732
  await helpers.pollUpdates(
733
733
  TEST_TOKEN, TEST_CHAT_ID, 12345, 0,
734
- controller.signal, onMessage, onUpdateId,
734
+ controller.signal, onMessage, vi.fn(), onUpdateId,
735
735
  );
736
736
 
737
- expect(onMessage).toHaveBeenCalledWith("after not-ok");
737
+ expect(onMessage).toHaveBeenCalledWith('after not-ok', '987654321');
738
738
  }, 20000);
739
739
 
740
740
  it("ignores messages without text field", async () => {
@@ -775,7 +775,7 @@ describe("pollUpdates", () => {
775
775
 
776
776
  await helpers.pollUpdates(
777
777
  TEST_TOKEN, TEST_CHAT_ID, 12345, 0,
778
- controller.signal, onMessage, onUpdateId,
778
+ controller.signal, onMessage, vi.fn(), onUpdateId,
779
779
  );
780
780
 
781
781
  expect(onMessage).not.toHaveBeenCalled();
@@ -104,17 +104,18 @@ afterEach(() => {
104
104
  // ── Tool Registration ─────────────────────────────────────────────
105
105
 
106
106
  describe("extension registration", () => {
107
- it("registers all 6 tools", () => {
107
+ it("registers all 7 tools", () => {
108
108
  const pi = createMockPI();
109
109
  telegramBridge(pi as any);
110
110
 
111
- expect(pi.registerTool).toHaveBeenCalledTimes(6);
111
+ expect(pi.registerTool).toHaveBeenCalledTimes(7);
112
112
  expect(pi._getTool("telegram_listen")).toBeDefined();
113
113
  expect(pi._getTool("telegram_send")).toBeDefined();
114
114
  expect(pi._getTool("telegram_ask")).toBeDefined();
115
115
  expect(pi._getTool("telegram_override")).toBeDefined();
116
116
  expect(pi._getTool("telegram_status")).toBeDefined();
117
117
  expect(pi._getTool("telegram_notify")).toBeDefined();
118
+ expect(pi._getTool("telegram_send_photo")).toBeDefined();
118
119
  });
119
120
 
120
121
  it("registers session_start and session_shutdown handlers", () => {
package/src/helpers.ts CHANGED
@@ -48,11 +48,140 @@ export async function sendMsg(
48
48
  parse_mode: parseMode,
49
49
  };
50
50
  if (replyMarkup) body.reply_markup = JSON.stringify(replyMarkup);
51
- const r = (await telegramApi("sendMessage", body)) as {
51
+ try {
52
+ const r = (await telegramApi("sendMessage", body)) as {
53
+ ok: boolean;
54
+ result: { message_id: number };
55
+ };
56
+ return r.result.message_id;
57
+ } catch (e: unknown) {
58
+ // Retry without parse_mode if formatting caused a 400 parse error
59
+ const msg = e instanceof Error ? e.message : String(e);
60
+ if (
61
+ parseMode &&
62
+ msg.includes("400") &&
63
+ (msg.includes("parse") || msg.includes("entity"))
64
+ ) {
65
+ delete body.parse_mode;
66
+ const r2 = (await telegramApi("sendMessage", body)) as {
67
+ ok: boolean;
68
+ result: { message_id: number };
69
+ };
70
+ return r2.result.message_id;
71
+ }
72
+ throw e;
73
+ }
74
+ }
75
+
76
+ // ── Photo / File Support ────────────────────────────────────────────
77
+
78
+ /**
79
+ * Get a Telegram file URL for downloading.
80
+ * Returns { file_path } from the getFile API.
81
+ */
82
+ export async function getFileUrl(fileId: string): Promise<string | null> {
83
+ const token = getToken();
84
+ try {
85
+ const res = await fetch(`${BASE_URL}${token}/getFile`, {
86
+ method: "POST",
87
+ headers: { "Content-Type": "application/json" },
88
+ body: JSON.stringify({ file_id: fileId }),
89
+ });
90
+ if (!res.ok) return null;
91
+ const data = (await res.json()) as {
92
+ ok: boolean;
93
+ result: { file_path: string };
94
+ };
95
+ if (!data.ok || !data.result?.file_path) return null;
96
+ return `https://api.telegram.org/file/bot${token}/${data.result.file_path}`;
97
+ } catch {
98
+ return null;
99
+ }
100
+ }
101
+
102
+ /**
103
+ * Download a Telegram file to a local path.
104
+ * Returns the file path on success, null on failure.
105
+ * Creates parent directories automatically.
106
+ */
107
+ export async function downloadFile(
108
+ fileId: string,
109
+ destPath: string,
110
+ ): Promise<string | null> {
111
+ try {
112
+ const fileUrl = await getFileUrl(fileId);
113
+ if (!fileUrl) return null;
114
+ const res = await fetch(fileUrl);
115
+ if (!res.ok) return null;
116
+ const buffer = Buffer.from(await res.arrayBuffer());
117
+ const path = await import("node:path");
118
+ const fsPromises = await import("node:fs/promises");
119
+ const dir = path.dirname(destPath);
120
+ await fsPromises.mkdir(dir, { recursive: true });
121
+ await fsPromises.writeFile(destPath, buffer);
122
+ return destPath;
123
+ } catch {
124
+ return null;
125
+ }
126
+ }
127
+
128
+ /**
129
+ * Send a photo to the Telegram chat.
130
+ * Supports sending from a local file path or a remote URL.
131
+ */
132
+ export async function sendPhoto(
133
+ photoPath: string,
134
+ caption?: string,
135
+ ): Promise<number> {
136
+ const fs = await import("node:fs/promises");
137
+ let fileData: Buffer;
138
+ let filename: string;
139
+
140
+ // Check if it's a URL
141
+ if (photoPath.startsWith("http://") || photoPath.startsWith("https://")) {
142
+ filename = "photo.jpg";
143
+ const res = await fetch(photoPath);
144
+ if (!res.ok) throw new Error(`Failed to fetch photo: ${res.status}`);
145
+ fileData = Buffer.from(await res.arrayBuffer());
146
+ } else {
147
+ filename = photoPath;
148
+ fileData = await fs.readFile(photoPath);
149
+ }
150
+
151
+ const token = getToken();
152
+ const formData = new FormData();
153
+ formData.set("chat_id", getChatId());
154
+ const blob = new Blob([fileData] as any);
155
+ formData.set("photo", blob, filename);
156
+ if (caption) {
157
+ formData.set("caption", caption);
158
+ formData.set("parse_mode", "HTML");
159
+ }
160
+
161
+ const url = `${BASE_URL}${token}/sendPhoto`;
162
+ const res = await fetch(url, {
163
+ method: "POST",
164
+ body: formData,
165
+ });
166
+
167
+ if (!res.ok) {
168
+ const errText = await res.text();
169
+ // Retry without caption if parse_mode caused a 400
170
+ if (
171
+ caption &&
172
+ res.status === 400 &&
173
+ (errText.includes("parse") || errText.includes("entity"))
174
+ ) {
175
+ return sendPhoto(photoPath);
176
+ }
177
+ throw new Error(`sendPhoto failed: ${res.status} ${errText}`);
178
+ }
179
+
180
+ const data = (await res.json()) as {
52
181
  ok: boolean;
53
182
  result: { message_id: number };
54
183
  };
55
- return r.result.message_id;
184
+ return data.result.message_id;
56
185
  }
57
186
 
58
187
  // ── Polling ─────────────────────────────────────────────────────────
@@ -188,7 +317,8 @@ export async function pollUpdates(
188
317
  botId: number | null,
189
318
  lastUpdateId: number,
190
319
  signal: AbortSignal,
191
- onMessage: (text: string) => void,
320
+ onMessage: (text: string, msgChatId: string) => void,
321
+ onPhoto: (photoId: string, caption?: string) => void,
192
322
  onUpdateId: (id: number) => void,
193
323
  ): Promise<void> {
194
324
  while (!signal.aborted) {
@@ -212,6 +342,13 @@ export async function pollUpdates(
212
342
  chat: { id: number };
213
343
  from?: { id: number; is_bot?: boolean };
214
344
  text?: string;
345
+ photo?: Array<{
346
+ file_id: string;
347
+ file_unique_id: string;
348
+ width: number;
349
+ height: number;
350
+ }>;
351
+ caption?: string;
215
352
  };
216
353
  callback_query?: unknown;
217
354
  }>;
@@ -225,16 +362,21 @@ export async function pollUpdates(
225
362
  onUpdateId(lastUpdateId);
226
363
  // Skip callback queries (handled by telegram_ask)
227
364
  if (u.callback_query) continue;
228
- // Only process text messages from the configured chat, not from our bot
229
- if (
230
- u.message &&
231
- u.message.text &&
232
- String(u.message.chat.id) === chatId
233
- ) {
365
+ // Process messages from the configured chat, not from our bot
366
+ if (u.message && String(u.message.chat.id) === chatId) {
234
367
  if (u.message.from?.is_bot || u.message.from?.id === botId) {
235
368
  continue;
236
369
  }
237
- onMessage(u.message.text);
370
+ // Photo message
371
+ if (u.message.photo && u.message.photo.length > 0) {
372
+ const largest = u.message.photo[u.message.photo.length - 1];
373
+ onPhoto(largest.file_id, u.message.caption);
374
+ continue;
375
+ }
376
+ // Text message
377
+ if (u.message.text) {
378
+ onMessage(u.message.text, String(u.message.chat.id));
379
+ }
238
380
  }
239
381
  }
240
382
  } catch (e: unknown) {
package/src/index.ts CHANGED
@@ -13,11 +13,16 @@ import {
13
13
  getChatId,
14
14
  telegramApi,
15
15
  sendMsg,
16
+ sendPhoto,
17
+ getFileUrl,
18
+ downloadFile,
16
19
  pollReply,
17
20
  getBotId,
18
21
  seedLastUpdateId,
19
22
  pollUpdates,
20
23
  } from "./helpers";
24
+ import * as path from "node:path";
25
+ import * as os from "node:os";
21
26
  import {
22
27
  listenSchema,
23
28
  sendSchema,
@@ -25,16 +30,159 @@ import {
25
30
  statusSchema,
26
31
  overrideSchema,
27
32
  notifySchema,
33
+ sendPhotoSchema,
28
34
  } from "./tools/telegram";
29
35
 
30
36
  export default function telegramBridge(pi: ExtensionAPI) {
31
37
  let botId: number | null = null;
32
38
  let listenerAbort: AbortController | null = null;
33
39
  let lastUpdateId = 0;
40
+ let mentionTrigger = "@pi"; // configurable via TELEGRAM_MENTION env var
41
+ const sessionStartTime = Date.now();
42
+ let messageCount = 0;
43
+
44
+ // ── Mention support ────────────────────────────────────────────
45
+
46
+ async function resolveMentionTrigger(): Promise<string> {
47
+ // 1. Explicit env var overrides everything
48
+ const envMention = process.env.TELEGRAM_MENTION;
49
+ if (envMention)
50
+ return envMention.startsWith("@") ? envMention : "@" + envMention;
51
+ // 2. Try bot username from getMe
52
+ try {
53
+ const r = (await telegramApi("getMe", {})) as {
54
+ result: { username: string };
55
+ };
56
+ if (r.result?.username) return "@" + r.result.username;
57
+ } catch {
58
+ /* fall through */
59
+ }
60
+ return "@pi";
61
+ }
62
+
63
+ /** Strip mention from message text and return cleaned text + whether it was mentioned. */
64
+ function checkMention(
65
+ text: string,
66
+ isPrivateChat: boolean,
67
+ ): { mentioned: boolean; cleanedText: string } {
68
+ if (isPrivateChat) return { mentioned: true, cleanedText: text };
69
+ const trigger = mentionTrigger.slice(1).toLowerCase(); // strip @
70
+ const patterns = [
71
+ new RegExp(`@${trigger}\\b`, "i"), // @pi at start or middle
72
+ new RegExp(`^${trigger}\\b`, "i"), // pi at start (no @)
73
+ ];
74
+ for (const re of patterns) {
75
+ if (re.test(text)) {
76
+ const cleaned = text.replace(re, "").trim();
77
+ return { mentioned: true, cleanedText: cleaned || text };
78
+ }
79
+ }
80
+ return { mentioned: false, cleanedText: text };
81
+ }
82
+
83
+ // ── Dashboard helper ────────────────────────────────────────────
84
+
85
+ async function sendDashboard() {
86
+ const token = getToken();
87
+ const giteaToken = process.env.GITEA_TOKEN || process.env.GIT_TOKEN || "";
88
+ const apiBase = "http://127.0.0.1:3001/api/v1/repos/factory/pi-ext";
89
+ const headers: Record<string, string> = {
90
+ "Content-Type": "application/json",
91
+ };
92
+ if (giteaToken) headers["Authorization"] = `token ${giteaToken}`;
93
+
94
+ try {
95
+ const [issuesR, prsR, runsR] = await Promise.all([
96
+ fetch(`${apiBase}/issues?state=open&limit=10`, { headers })
97
+ .then((r) => r.json())
98
+ .catch(() => []),
99
+ fetch(`${apiBase}/pulls?state=open&limit=10`, { headers })
100
+ .then((r) => r.json())
101
+ .catch(() => []),
102
+ fetch(`${apiBase}/actions/runs?limit=3`, { headers })
103
+ .then((r) => r.json())
104
+ .catch(() => ({ workflow_runs: [] })),
105
+ ]);
106
+
107
+ const issues = Array.isArray(issuesR) ? issuesR : [];
108
+ const prs = Array.isArray(prsR) ? prsR : [];
109
+ const runs = (runsR as any).workflow_runs ?? [];
110
+
111
+ const lines: string[] = [];
112
+ lines.push("📊 <b>pi-ext Status</b>");
113
+ lines.push("");
114
+
115
+ // Open issues
116
+ lines.push(`📋 <b>Open Issues:</b> ${issues.length}`);
117
+ if (issues.length > 0) {
118
+ for (const i of (issues as any[]).slice(0, 5)) {
119
+ const labels = i.labels?.length
120
+ ? ` [${i.labels.map((l: any) => l.name).join(", ")}]`
121
+ : "";
122
+ lines.push(` #${i.number} ${i.title.slice(0, 60)}${labels}`);
123
+ }
124
+ } else {
125
+ lines.push(" (none)");
126
+ }
127
+ lines.push("");
128
+
129
+ // Open PRs
130
+ lines.push(`🔀 <b>Open PRs:</b> ${prs.length}`);
131
+ if (prs.length > 0) {
132
+ for (const p of (prs as any[]).slice(0, 5)) {
133
+ const mergeIcon = p.mergeable ? "✅" : "❌";
134
+ lines.push(` ${mergeIcon} #${p.number} ${p.title.slice(0, 55)}`);
135
+ }
136
+ } else {
137
+ lines.push(" (none)");
138
+ }
139
+ lines.push("");
140
+
141
+ // CI status
142
+ if (runs.length > 0) {
143
+ const latest = runs[0];
144
+ const statusIcon =
145
+ latest.status === "success"
146
+ ? "✅"
147
+ : latest.status === "failure"
148
+ ? "❌"
149
+ : latest.status === "running"
150
+ ? "🔄"
151
+ : "⏳";
152
+ lines.push(
153
+ `🔧 <b>Last CI:</b> ${statusIcon} ${latest.status} (${runs.length} recent runs)`,
154
+ );
155
+ } else {
156
+ lines.push("🔧 <b>Last CI:</b> no runs");
157
+ }
158
+ lines.push("");
159
+
160
+ // Session metrics
161
+ const uptimeMin = Math.floor((Date.now() - sessionStartTime) / 60000);
162
+ const uptimeStr =
163
+ uptimeMin < 60
164
+ ? `${uptimeMin}m`
165
+ : `${Math.floor(uptimeMin / 60)}h ${uptimeMin % 60}m`;
166
+ lines.push("🤖 <b>Session</b>");
167
+ lines.push(` Uptime: ${uptimeStr}`);
168
+ lines.push(` Messages: ${messageCount}`);
169
+ lines.push(` Mention: ${mentionTrigger}`);
170
+
171
+ await sendMsg(lines.join("\n"), undefined, "HTML");
172
+ } catch {
173
+ await sendMsg(
174
+ "⚠️ Could not fetch project dashboard. Gitea may be unreachable.",
175
+ );
176
+ }
177
+ }
34
178
 
35
179
  // ── Session lifecycle ───────────────────────────────────────────
36
180
 
37
181
  pi.on("session_start", async (_event, ctx) => {
182
+ // Ensure photo download directory exists
183
+ const { mkdir } = await import("node:fs/promises");
184
+ const photoDir = path.join(os.tmpdir(), "telegram-photos");
185
+ await mkdir(photoDir, { recursive: true }).catch(() => {});
38
186
  // Restore lastUpdateId from session state
39
187
  for (const entry of ctx.sessionManager.getEntries()) {
40
188
  if (entry.type === "custom" && entry.customType === "tg-last-update") {
@@ -42,6 +190,7 @@ export default function telegramBridge(pi: ExtensionAPI) {
42
190
  }
43
191
  }
44
192
  // Start background listener
193
+ mentionTrigger = await resolveMentionTrigger();
45
194
  startListener();
46
195
  });
47
196
 
@@ -67,6 +216,11 @@ export default function telegramBridge(pi: ExtensionAPI) {
67
216
  // Seed lastUpdateId
68
217
  lastUpdateId = await seedLastUpdateId(lastUpdateId, signal);
69
218
 
219
+ // Create photo download directory
220
+ const fsPromises = await import("node:fs/promises");
221
+ const photoDir = path.join(os.tmpdir(), "telegram-photos");
222
+ await fsPromises.mkdir(photoDir, { recursive: true }).catch(() => {});
223
+
70
224
  // Fire and forget — poll in background
71
225
  pollUpdates(
72
226
  token,
@@ -74,15 +228,46 @@ export default function telegramBridge(pi: ExtensionAPI) {
74
228
  botId,
75
229
  lastUpdateId,
76
230
  signal,
77
- // onMessage: forward to pi as user message
78
- async (text: string) => {
79
- pi.sendUserMessage(text);
231
+ // onMessage: forward to pi (with mention check in groups), or dashboard on "hi"
232
+ async (text: string, msgChatId: string) => {
233
+ const isPrivate = msgChatId === chatId;
234
+ const { mentioned, cleanedText } = checkMention(text, isPrivate);
235
+ if (!mentioned) return; // ignore unmentioned messages in groups
236
+
237
+ const trimmed = cleanedText.trim().toLowerCase();
238
+ if (trimmed === "hi" || trimmed === "/status" || trimmed === "status") {
239
+ await sendDashboard();
240
+ return;
241
+ }
242
+ messageCount++;
243
+ pi.sendUserMessage(cleanedText);
80
244
  await sendMsg(
81
- `👂 Got it! Working on: _${text.slice(0, 100)}_`,
245
+ `👂 Got it! Working on: _${cleanedText.slice(0, 100)}_`,
82
246
  undefined,
83
247
  "Markdown",
84
248
  );
85
249
  },
250
+ // onPhoto: download and forward photo + caption to pi
251
+ async (photoId: string, caption?: string) => {
252
+ const timestamp = Date.now();
253
+ const filename = `photo_${timestamp}.jpg`;
254
+ const destPath = path.join(photoDir, filename);
255
+ const result = await downloadFile(photoId, destPath);
256
+ if (result) {
257
+ const captionText = caption ? `\n\n📝 Caption: ${caption}` : "";
258
+ pi.sendUserMessage(
259
+ `📸 Photo received from Telegram\nPath: ${result}${captionText}\n\nUse read("${result}") to view the image.`,
260
+ );
261
+ await sendMsg("📸 Photo received! Processing...");
262
+ } else {
263
+ pi.sendUserMessage(
264
+ "📸 Photo received from Telegram but could not be downloaded.",
265
+ );
266
+ await sendMsg(
267
+ "⚠️ Could not download your photo. Please check the bot configuration.",
268
+ );
269
+ }
270
+ },
86
271
  // onUpdateId: persist offset
87
272
  (id: number) => {
88
273
  lastUpdateId = id;
@@ -145,22 +330,67 @@ export default function telegramBridge(pi: ExtensionAPI) {
145
330
  lastUpdateId = u.update_id;
146
331
  pi.appendEntry("tg-last-update", { update_id: lastUpdateId });
147
332
  if (u.callback_query) continue;
148
- if (
149
- u.message &&
150
- u.message.text &&
151
- String(u.message.chat.id) === chatId
152
- ) {
333
+ if (u.message && String(u.message.chat.id) === chatId) {
153
334
  if (u.message.from?.is_bot || u.message.from?.id === botId)
154
335
  continue;
155
- return {
156
- content: [
157
- {
158
- type: "text",
159
- text: `📩 New message: "${u.message.text}"`,
336
+ // Photo message
337
+ if (u.message.photo && u.message.photo.length > 0) {
338
+ const photoId =
339
+ u.message.photo[u.message.photo.length - 1].file_id;
340
+ const caption = u.message.caption || "";
341
+ const timestamp = Date.now();
342
+ const fsPromises = await import("node:fs/promises");
343
+ const photoDirLocal = path.join(os.tmpdir(), "telegram-photos");
344
+ await fsPromises
345
+ .mkdir(photoDirLocal, { recursive: true })
346
+ .catch(() => {});
347
+ const destFile = path.join(
348
+ photoDirLocal,
349
+ `photo_${timestamp}.jpg`,
350
+ );
351
+ const result = await downloadFile(photoId, destFile);
352
+ const captionInfo = caption ? `\nCaption: "${caption}"` : "";
353
+ if (result) {
354
+ return {
355
+ content: [
356
+ {
357
+ type: "text",
358
+ text: `📸 Photo received from Telegram\nPath: ${result}${captionInfo}\n\nUse read("${result}") to view the image.`,
359
+ },
360
+ ],
361
+ details: {
362
+ message: caption,
363
+ photoPath: result,
364
+ hasPhoto: true,
365
+ },
366
+ };
367
+ }
368
+ return {
369
+ content: [
370
+ {
371
+ type: "text",
372
+ text: `📸 Photo received from Telegram but could not be downloaded.${captionInfo}`,
373
+ },
374
+ ],
375
+ details: {
376
+ message: caption,
377
+ hasPhoto: true,
378
+ downloadFailed: true,
160
379
  },
161
- ],
162
- details: { message: u.message.text },
163
- };
380
+ };
381
+ }
382
+ // Text message
383
+ if (u.message.text) {
384
+ return {
385
+ content: [
386
+ {
387
+ type: "text",
388
+ text: `📩 New message: "${u.message.text}"`,
389
+ },
390
+ ],
391
+ details: { message: u.message.text },
392
+ };
393
+ }
164
394
  }
165
395
  }
166
396
  return {
@@ -336,7 +566,11 @@ export default function telegramBridge(pi: ExtensionAPI) {
336
566
  action = "explain";
337
567
  }
338
568
 
339
- await sendMsg(`✅ Choice received: <i>${choice}</i>`, undefined, "HTML");
569
+ await sendMsg(
570
+ `✅ Choice received: <i>${choice}</i>`,
571
+ undefined,
572
+ "HTML",
573
+ );
340
574
 
341
575
  return {
342
576
  content: [
@@ -409,6 +643,38 @@ export default function telegramBridge(pi: ExtensionAPI) {
409
643
  },
410
644
  });
411
645
 
646
+ // telegram_send_photo
647
+ pi.registerTool({
648
+ name: "telegram_send_photo",
649
+ label: "Send Photo",
650
+ description:
651
+ "Send a photo to the Telegram chat. Supports local file paths and remote URLs.",
652
+ parameters: sendPhotoSchema,
653
+ async execute(
654
+ _id: string,
655
+ params: { photoPath: string; caption?: string },
656
+ ) {
657
+ try {
658
+ const mid = await sendPhoto(params.photoPath, params.caption);
659
+ return {
660
+ content: [{ type: "text", text: `Photo sent (id:${mid})` }],
661
+ details: { messageId: mid },
662
+ };
663
+ } catch (e: unknown) {
664
+ return {
665
+ content: [
666
+ {
667
+ type: "text",
668
+ text: `Failed to send photo: ${e instanceof Error ? e.message : e}`,
669
+ },
670
+ ],
671
+ details: {},
672
+ isError: true,
673
+ };
674
+ }
675
+ },
676
+ });
677
+
412
678
  // telegram_status
413
679
  pi.registerTool({
414
680
  name: "telegram_status",
package/src/templates.ts CHANGED
@@ -10,23 +10,31 @@
10
10
 
11
11
  const BAR = "━".repeat(20);
12
12
 
13
+ /** Escape user text for safe embedding inside HTML tags. */
14
+ function escapeHtml(s: string): string {
15
+ return s
16
+ .replace(/&/g, "&amp;")
17
+ .replace(/</g, "&lt;")
18
+ .replace(/>/g, "&gt;");
19
+ }
20
+
13
21
  function bold(s: string) {
14
- return `<b>${s}</b>`;
22
+ return `<b>${escapeHtml(s)}</b>`;
15
23
  }
16
24
  function italic(s: string) {
17
- return `<i>${s}</i>`;
25
+ return `<i>${escapeHtml(s)}</i>`;
18
26
  }
19
27
  function code(s: string) {
20
- return `<code>${s}</code>`;
28
+ return `<code>${escapeHtml(s)}</code>`;
21
29
  }
22
30
  function strike(s: string) {
23
- return `<s>${s}</s>`;
31
+ return `<s>${escapeHtml(s)}</s>`;
24
32
  }
25
33
  function labelValue(label: string, value: string) {
26
- return `${bold(label + ":")} ${value}`;
34
+ return `${bold(label + ":")} ${escapeHtml(value)}`;
27
35
  }
28
36
  function link(url: string, text: string) {
29
- return `<a href="${url}">${text}</a>`;
37
+ return `<a href="${url}">${escapeHtml(text)}</a>`;
30
38
  }
31
39
  function progressBar(pct: number, width = 12) {
32
40
  const filled = Math.round((pct / 100) * width);
@@ -199,6 +199,11 @@ export const notifySchema = Type.Object({
199
199
  ackEmoji: Type.Optional(Type.String()),
200
200
  });
201
201
 
202
+ export const sendPhotoSchema = Type.Object({
203
+ photoPath: Type.String({ description: "Local file path or remote URL of the photo to send" }),
204
+ caption: Type.Optional(Type.String({ description: "Optional caption text (HTML formatting supported)" })),
205
+ });
206
+
202
207
  export const listenSchema = Type.Object({});
203
208
 
204
209
  export const sendSchema = Type.Object({ message: Type.String() });