@bytesbrains/pi-telegram-bridge 1.1.4 → 1.1.6

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/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@bytesbrains/pi-telegram-bridge",
3
- "version": "1.1.4",
4
- "description": "Telegram bot bridge for pi agents send messages, ask questions, and listen for human replies via Telegram.",
3
+ "version": "1.1.6",
4
+ "description": "Telegram bot bridge for pi agents \u2014 send messages, ask questions, and listen for human replies via Telegram.",
5
5
  "keywords": [
6
6
  "pi-package",
7
7
  "pi-extension",
@@ -437,7 +437,7 @@ 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
443
  expect(onMessage).toHaveBeenCalledWith("Hello agent!");
@@ -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,7 +615,7 @@ 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
621
  expect(onMessage).toHaveBeenCalledWith("recovered");
@@ -660,7 +660,7 @@ 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
666
  expect(onMessage).toHaveBeenCalledWith("eventually");
@@ -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,7 +731,7 @@ 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
737
  expect(onMessage).toHaveBeenCalledWith("after not-ok");
@@ -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,131 @@ 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 (parseMode && msg.includes("400") && (msg.includes("parse") || msg.includes("entity"))) {
61
+ delete body.parse_mode;
62
+ const r2 = (await telegramApi("sendMessage", body)) as {
63
+ ok: boolean;
64
+ result: { message_id: number };
65
+ };
66
+ return r2.result.message_id;
67
+ }
68
+ throw e;
69
+ }}
70
+
71
+ // ── Photo / File Support ────────────────────────────────────────────
72
+
73
+ /**
74
+ * Get a Telegram file URL for downloading.
75
+ * Returns { file_path } from the getFile API.
76
+ */
77
+ export async function getFileUrl(fileId: string): Promise<string | null> {
78
+ const token = getToken();
79
+ try {
80
+ const res = await fetch(
81
+ `${BASE_URL}${token}/getFile`,
82
+ {
83
+ method: "POST",
84
+ headers: { "Content-Type": "application/json" },
85
+ body: JSON.stringify({ file_id: fileId }),
86
+ },
87
+ );
88
+ if (!res.ok) return null;
89
+ const data = (await res.json()) as {
90
+ ok: boolean;
91
+ result: { file_path: string };
92
+ };
93
+ if (!data.ok || !data.result?.file_path) return null;
94
+ return `https://api.telegram.org/file/bot${token}/${data.result.file_path}`;
95
+ } catch {
96
+ return null;
97
+ }
98
+ }
99
+
100
+ /**
101
+ * Download a Telegram file to a local path.
102
+ * Returns the file path on success, null on failure.
103
+ * Creates parent directories automatically.
104
+ */
105
+ export async function downloadFile(fileId: string, destPath: string): Promise<string | null> {
106
+ try {
107
+ const fileUrl = await getFileUrl(fileId);
108
+ if (!fileUrl) return null;
109
+ const res = await fetch(fileUrl);
110
+ if (!res.ok) return null;
111
+ const buffer = Buffer.from(await res.arrayBuffer());
112
+ const path = await import("node:path");
113
+ const fsPromises = await import("node:fs/promises");
114
+ const dir = path.dirname(destPath);
115
+ await fsPromises.mkdir(dir, { recursive: true });
116
+ await fsPromises.writeFile(destPath, buffer);
117
+ return destPath;
118
+ } catch {
119
+ return null;
120
+ }
121
+ }
122
+
123
+ /**
124
+ * Send a photo to the Telegram chat.
125
+ * Supports sending from a local file path or a remote URL.
126
+ */
127
+ export async function sendPhoto(
128
+ photoPath: string,
129
+ caption?: string,
130
+ ): Promise<number> {
131
+ const fs = await import("node:fs/promises");
132
+ let fileData: Buffer;
133
+ let filename: string;
134
+
135
+ // Check if it's a URL
136
+ if (photoPath.startsWith("http://") || photoPath.startsWith("https://")) {
137
+ filename = "photo.jpg";
138
+ const res = await fetch(photoPath);
139
+ if (!res.ok) throw new Error(`Failed to fetch photo: ${res.status}`);
140
+ fileData = Buffer.from(await res.arrayBuffer());
141
+ } else {
142
+ filename = photoPath;
143
+ fileData = await fs.readFile(photoPath);
144
+ }
145
+
146
+ const token = getToken();
147
+ const formData = new FormData();
148
+ formData.set("chat_id", getChatId());
149
+ const blob = new Blob([fileData] as any);
150
+ formData.set("photo", blob, filename);
151
+ if (caption) {
152
+ formData.set("caption", caption);
153
+ formData.set("parse_mode", "HTML");
154
+ }
155
+
156
+ const url = `${BASE_URL}${token}/sendPhoto`;
157
+ const res = await fetch(url, {
158
+ method: "POST",
159
+ body: formData,
160
+ });
161
+
162
+ if (!res.ok) {
163
+ const errText = await res.text();
164
+ // Retry without caption if parse_mode caused a 400
165
+ if (caption && res.status === 400 && (errText.includes("parse") || errText.includes("entity"))) {
166
+ return sendPhoto(photoPath);
167
+ }
168
+ throw new Error(`sendPhoto failed: ${res.status} ${errText}`);
169
+ }
170
+
171
+ const data = (await res.json()) as {
52
172
  ok: boolean;
53
173
  result: { message_id: number };
54
174
  };
55
- return r.result.message_id;
175
+ return data.result.message_id;
56
176
  }
57
177
 
58
178
  // ── Polling ─────────────────────────────────────────────────────────
@@ -189,6 +309,7 @@ export async function pollUpdates(
189
309
  lastUpdateId: number,
190
310
  signal: AbortSignal,
191
311
  onMessage: (text: string) => void,
312
+ onPhoto: (photoId: string, caption?: string) => void,
192
313
  onUpdateId: (id: number) => void,
193
314
  ): Promise<void> {
194
315
  while (!signal.aborted) {
@@ -212,6 +333,8 @@ export async function pollUpdates(
212
333
  chat: { id: number };
213
334
  from?: { id: number; is_bot?: boolean };
214
335
  text?: string;
336
+ photo?: Array<{ file_id: string; file_unique_id: string; width: number; height: number }>;
337
+ caption?: string;
215
338
  };
216
339
  callback_query?: unknown;
217
340
  }>;
@@ -225,16 +348,24 @@ export async function pollUpdates(
225
348
  onUpdateId(lastUpdateId);
226
349
  // Skip callback queries (handled by telegram_ask)
227
350
  if (u.callback_query) continue;
228
- // Only process text messages from the configured chat, not from our bot
351
+ // Process messages from the configured chat, not from our bot
229
352
  if (
230
353
  u.message &&
231
- u.message.text &&
232
354
  String(u.message.chat.id) === chatId
233
355
  ) {
234
356
  if (u.message.from?.is_bot || u.message.from?.id === botId) {
235
357
  continue;
236
358
  }
237
- onMessage(u.message.text);
359
+ // Photo message
360
+ if (u.message.photo && u.message.photo.length > 0) {
361
+ const largest = u.message.photo[u.message.photo.length - 1];
362
+ onPhoto(largest.file_id, u.message.caption);
363
+ continue;
364
+ }
365
+ // Text message
366
+ if (u.message.text) {
367
+ onMessage(u.message.text);
368
+ }
238
369
  }
239
370
  }
240
371
  } 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,6 +30,7 @@ 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) {
@@ -35,6 +41,10 @@ export default function telegramBridge(pi: ExtensionAPI) {
35
41
  // ── Session lifecycle ───────────────────────────────────────────
36
42
 
37
43
  pi.on("session_start", async (_event, ctx) => {
44
+ // Ensure photo download directory exists
45
+ const { mkdir } = await import("node:fs/promises");
46
+ const photoDir = path.join(os.tmpdir(), "telegram-photos");
47
+ await mkdir(photoDir, { recursive: true }).catch(() => {});
38
48
  // Restore lastUpdateId from session state
39
49
  for (const entry of ctx.sessionManager.getEntries()) {
40
50
  if (entry.type === "custom" && entry.customType === "tg-last-update") {
@@ -67,6 +77,11 @@ export default function telegramBridge(pi: ExtensionAPI) {
67
77
  // Seed lastUpdateId
68
78
  lastUpdateId = await seedLastUpdateId(lastUpdateId, signal);
69
79
 
80
+ // Create photo download directory
81
+ const fsPromises = await import("node:fs/promises");
82
+ const photoDir = path.join(os.tmpdir(), "telegram-photos");
83
+ await fsPromises.mkdir(photoDir, { recursive: true }).catch(() => {});
84
+
70
85
  // Fire and forget — poll in background
71
86
  pollUpdates(
72
87
  token,
@@ -74,7 +89,7 @@ export default function telegramBridge(pi: ExtensionAPI) {
74
89
  botId,
75
90
  lastUpdateId,
76
91
  signal,
77
- // onMessage: forward to pi as user message
92
+ // onMessage: forward text or photo to pi as user message
78
93
  async (text: string) => {
79
94
  pi.sendUserMessage(text);
80
95
  await sendMsg(
@@ -83,6 +98,23 @@ export default function telegramBridge(pi: ExtensionAPI) {
83
98
  "Markdown",
84
99
  );
85
100
  },
101
+ // onPhoto: download and forward photo + caption to pi
102
+ async (photoId: string, caption?: string) => {
103
+ const timestamp = Date.now();
104
+ const filename = `photo_${timestamp}.jpg`;
105
+ const destPath = path.join(photoDir, filename);
106
+ const result = await downloadFile(photoId, destPath);
107
+ if (result) {
108
+ const captionText = caption ? `\n\n📝 Caption: ${caption}` : "";
109
+ pi.sendUserMessage(
110
+ `📸 Photo received from Telegram\nPath: ${result}${captionText}\n\nUse read("${result}") to view the image.`,
111
+ );
112
+ await sendMsg("📸 Photo received! Processing...");
113
+ } else {
114
+ pi.sendUserMessage("📸 Photo received from Telegram but could not be downloaded.");
115
+ await sendMsg("⚠️ Could not download your photo. Please check the bot configuration.");
116
+ }
117
+ },
86
118
  // onUpdateId: persist offset
87
119
  (id: number) => {
88
120
  lastUpdateId = id;
@@ -147,20 +179,50 @@ export default function telegramBridge(pi: ExtensionAPI) {
147
179
  if (u.callback_query) continue;
148
180
  if (
149
181
  u.message &&
150
- u.message.text &&
151
182
  String(u.message.chat.id) === chatId
152
183
  ) {
153
184
  if (u.message.from?.is_bot || u.message.from?.id === botId)
154
185
  continue;
155
- return {
156
- content: [
157
- {
186
+ // Photo message
187
+ if (u.message.photo && u.message.photo.length > 0) {
188
+ const photoId = u.message.photo[u.message.photo.length - 1].file_id;
189
+ const caption = u.message.caption || "";
190
+ const timestamp = Date.now();
191
+ const fsPromises = await import("node:fs/promises");
192
+ const photoDirLocal = path.join(os.tmpdir(), "telegram-photos");
193
+ await fsPromises.mkdir(photoDirLocal, { recursive: true }).catch(() => {});
194
+ const destFile = path.join(photoDirLocal, `photo_${timestamp}.jpg`);
195
+ const result = await downloadFile(photoId, destFile);
196
+ const captionInfo = caption ? `\nCaption: "${caption}"` : "";
197
+ if (result) {
198
+ return {
199
+ content: [{
200
+ type: "text",
201
+ text: `📸 Photo received from Telegram\nPath: ${result}${captionInfo}\n\nUse read("${result}") to view the image.`,
202
+ }],
203
+ details: { message: caption, photoPath: result, hasPhoto: true },
204
+ };
205
+ }
206
+ return {
207
+ content: [{
158
208
  type: "text",
159
- text: `📩 New message: "${u.message.text}"`,
160
- },
161
- ],
162
- details: { message: u.message.text },
163
- };
209
+ text: `📸 Photo received from Telegram but could not be downloaded.${captionInfo}`,
210
+ }],
211
+ details: { message: caption, hasPhoto: true, downloadFailed: true },
212
+ };
213
+ }
214
+ // Text message
215
+ if (u.message.text) {
216
+ return {
217
+ content: [
218
+ {
219
+ type: "text",
220
+ text: `📩 New message: "${u.message.text}"`,
221
+ },
222
+ ],
223
+ details: { message: u.message.text },
224
+ };
225
+ }
164
226
  }
165
227
  }
166
228
  return {
@@ -295,16 +357,16 @@ export default function telegramBridge(pi: ExtensionAPI) {
295
357
  ];
296
358
  const timeoutMs = (params.timeoutMinutes ?? 30) * 60000;
297
359
 
298
- let message = `🛑 *Supervisor blocked an action*\n\n`;
299
- message += `*Command:* \`${params.command.slice(0, 200)}\`\n`;
300
- message += `*Reason:* ${params.reason.slice(0, 300)}\n`;
360
+ let message = `🛑 <b>Supervisor blocked an action</b>\n\n`;
361
+ message += `<b>Command:</b> <code>${params.command.slice(0, 200)}</code>\n`;
362
+ message += `<b>Reason:</b> ${params.reason.slice(0, 300)}\n`;
301
363
  if (params.context) {
302
- message += `*Context:* ${params.context.slice(0, 300)}\n`;
364
+ message += `<b>Context:</b> ${params.context.slice(0, 300)}\n`;
303
365
  }
304
- message += `\n_What should I do?_`;
366
+ message += `\n<i>What should I do?</i>`;
305
367
 
306
368
  const kb = opts.map((o: string) => [{ text: o, callback_data: o }]);
307
- const mid = await sendMsg(message, { inline_keyboard: kb }, "Markdown");
369
+ const mid = await sendMsg(message, { inline_keyboard: kb }, "HTML");
308
370
  const reply = await pollReply(mid, chatId, timeoutMs);
309
371
 
310
372
  if (!reply) {
@@ -336,7 +398,7 @@ export default function telegramBridge(pi: ExtensionAPI) {
336
398
  action = "explain";
337
399
  }
338
400
 
339
- await sendMsg(`✅ Choice received: _${choice}_`, undefined, "Markdown");
401
+ await sendMsg(`✅ Choice received: <i>${choice}</i>`, undefined, "HTML");
340
402
 
341
403
  return {
342
404
  content: [
@@ -409,6 +471,35 @@ export default function telegramBridge(pi: ExtensionAPI) {
409
471
  },
410
472
  });
411
473
 
474
+ // telegram_send_photo
475
+ pi.registerTool({
476
+ name: "telegram_send_photo",
477
+ label: "Send Photo",
478
+ description:
479
+ "Send a photo to the Telegram chat. Supports local file paths and remote URLs.",
480
+ parameters: sendPhotoSchema,
481
+ async execute(_id: string, params: { photoPath: string; caption?: string }) {
482
+ try {
483
+ const mid = await sendPhoto(params.photoPath, params.caption);
484
+ return {
485
+ content: [{ type: "text", text: `Photo sent (id:${mid})` }],
486
+ details: { messageId: mid },
487
+ };
488
+ } catch (e: unknown) {
489
+ return {
490
+ content: [
491
+ {
492
+ type: "text",
493
+ text: `Failed to send photo: ${e instanceof Error ? e.message : e}`,
494
+ },
495
+ ],
496
+ details: {},
497
+ isError: true,
498
+ };
499
+ }
500
+ },
501
+ });
502
+
412
503
  // telegram_status
413
504
  pi.registerTool({
414
505
  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() });