@bytesbrains/pi-telegram-bridge 1.0.1 → 1.1.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/src/index.ts CHANGED
@@ -9,373 +9,414 @@
9
9
  */
10
10
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
11
11
  import {
12
- getToken,
13
- getChatId,
14
- telegramApi,
15
- sendMsg,
16
- pollReply,
17
- getBotId,
18
- seedLastUpdateId,
19
- pollUpdates,
12
+ getToken,
13
+ getChatId,
14
+ telegramApi,
15
+ sendMsg,
16
+ pollReply,
17
+ getBotId,
18
+ seedLastUpdateId,
19
+ pollUpdates,
20
20
  } from "./helpers";
21
21
  import {
22
- listenSchema,
23
- sendSchema,
24
- askSchema,
25
- statusSchema,
26
- overrideSchema,
22
+ listenSchema,
23
+ sendSchema,
24
+ askSchema,
25
+ statusSchema,
26
+ overrideSchema,
27
+ notifySchema,
27
28
  } from "./tools/telegram";
28
29
 
29
30
  export default function telegramBridge(pi: ExtensionAPI) {
30
- let botId: number | null = null;
31
- let listenerAbort: AbortController | null = null;
32
- let lastUpdateId = 0;
31
+ let botId: number | null = null;
32
+ let listenerAbort: AbortController | null = null;
33
+ let lastUpdateId = 0;
33
34
 
34
- // ── Session lifecycle ───────────────────────────────────────────
35
+ // ── Session lifecycle ───────────────────────────────────────────
35
36
 
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
- });
37
+ pi.on("session_start", async (_event, ctx) => {
38
+ // Restore lastUpdateId from session state
39
+ for (const entry of ctx.sessionManager.getEntries()) {
40
+ if (entry.type === "custom" && entry.customType === "tg-last-update") {
41
+ lastUpdateId = (entry.data as { update_id: number }).update_id;
42
+ }
43
+ }
44
+ // Start background listener
45
+ startListener();
46
+ });
46
47
 
47
- pi.on("session_shutdown", () => {
48
- listenerAbort?.abort();
49
- listenerAbort = null;
50
- });
48
+ pi.on("session_shutdown", () => {
49
+ listenerAbort?.abort();
50
+ listenerAbort = null;
51
+ });
51
52
 
52
- // ── Background listener ─────────────────────────────────────────
53
+ // ── Background listener ─────────────────────────────────────────
53
54
 
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();
55
+ async function startListener() {
56
+ if (listenerAbort) return; // already running
57
+ listenerAbort = new AbortController();
58
+ const signal = listenerAbort.signal;
59
+ const token = getToken();
60
+ const chatId = getChatId();
60
61
 
61
- // Get bot's own ID once to filter out self-messages
62
- if (!botId) {
63
- botId = await getBotId();
64
- }
62
+ // Get bot's own ID once to filter out self-messages
63
+ if (!botId) {
64
+ botId = await getBotId();
65
+ }
65
66
 
66
- // Seed lastUpdateId
67
- lastUpdateId = await seedLastUpdateId(lastUpdateId, signal);
67
+ // Seed lastUpdateId
68
+ lastUpdateId = await seedLastUpdateId(lastUpdateId, signal);
68
69
 
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
- }
70
+ // Fire and forget — poll in background
71
+ pollUpdates(
72
+ token,
73
+ chatId,
74
+ botId,
75
+ lastUpdateId,
76
+ signal,
77
+ // onMessage: forward to pi as user message
78
+ async (text: string) => {
79
+ pi.sendUserMessage(text);
80
+ await sendMsg(`👂 Got it! Working on: _${text.slice(0, 100)}_`);
81
+ },
82
+ // onUpdateId: persist offset
83
+ (id: number) => {
84
+ lastUpdateId = id;
85
+ pi.appendEntry("tg-last-update", { update_id: id });
86
+ },
87
+ );
88
+ }
88
89
 
89
- // ── Tools ───────────────────────────────────────────────────────
90
+ // ── Tools ───────────────────────────────────────────────────────
90
91
 
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();
92
+ // telegram_listen
93
+ pi.registerTool({
94
+ name: "telegram_listen",
95
+ label: "Telegram Listen",
96
+ description:
97
+ "Check Telegram for any new inbound messages from the human. Returns the latest message text, or indicates no new messages.",
98
+ parameters: listenSchema,
99
+ async execute() {
100
+ try {
101
+ const token = getToken();
102
+ const chatId = getChatId();
103
+ if (!botId) botId = await getBotId();
103
104
 
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
- };
105
+ const res = await fetch(
106
+ `https://api.telegram.org/bot${token}/getUpdates`,
107
+ {
108
+ method: "POST",
109
+ headers: { "Content-Type": "application/json" },
110
+ body: JSON.stringify({ offset: lastUpdateId + 1, timeout: 5 }),
111
+ },
112
+ );
113
+ if (!res.ok)
114
+ return {
115
+ content: [{ type: "text", text: "Could not reach Telegram." }],
116
+ isError: true,
117
+ };
112
118
 
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
- };
119
+ const data = (await res.json()) as {
120
+ ok: boolean;
121
+ result: Array<{
122
+ update_id: number;
123
+ message?: {
124
+ message_id: number;
125
+ chat: { id: number };
126
+ from?: { id: number; is_bot?: boolean };
127
+ text?: string;
128
+ };
129
+ callback_query?: unknown;
130
+ }>;
131
+ };
132
+ if (!data.ok)
133
+ return {
134
+ content: [{ type: "text", text: "Telegram API error." }],
135
+ isError: true,
136
+ };
131
137
 
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
- });
138
+ for (const u of data.result) {
139
+ lastUpdateId = u.update_id;
140
+ pi.appendEntry("tg-last-update", { update_id: lastUpdateId });
141
+ if (u.callback_query) continue;
142
+ if (
143
+ u.message &&
144
+ u.message.text &&
145
+ String(u.message.chat.id) === chatId
146
+ ) {
147
+ if (u.message.from?.is_bot || u.message.from?.id === botId)
148
+ continue;
149
+ return {
150
+ content: [
151
+ {
152
+ type: "text",
153
+ text: `📩 New message: "${u.message.text}"`,
154
+ },
155
+ ],
156
+ details: { message: u.message.text },
157
+ };
158
+ }
159
+ }
160
+ return { content: [{ type: "text", text: "No new messages." }] };
161
+ } catch (e: unknown) {
162
+ return {
163
+ content: [
164
+ {
165
+ type: "text",
166
+ text: `Failed: ${e instanceof Error ? e.message : e}`,
167
+ },
168
+ ],
169
+ isError: true,
170
+ };
171
+ }
172
+ },
173
+ });
168
174
 
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
- });
175
+ // telegram_send
176
+ pi.registerTool({
177
+ name: "telegram_send",
178
+ label: "Telegram Send",
179
+ description: "Send a one-way message to Telegram. Use for status updates.",
180
+ parameters: sendSchema,
181
+ async execute(_id: string, params: { message: string }) {
182
+ try {
183
+ const mid = await sendMsg(params.message);
184
+ return { content: [{ type: "text", text: `Sent (id:${mid})` }] };
185
+ } catch (e: unknown) {
186
+ return {
187
+ content: [
188
+ {
189
+ type: "text",
190
+ text: `Failed: ${e instanceof Error ? e.message : e}`,
191
+ },
192
+ ],
193
+ isError: true,
194
+ };
195
+ }
196
+ },
197
+ });
193
198
 
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
- });
199
+ // telegram_ask
200
+ pi.registerTool({
201
+ name: "telegram_ask",
202
+ label: "Telegram Ask",
203
+ description:
204
+ "Ask a question on Telegram and WAIT for a human reply. BLOCKS until you answer. Use when you need human input.",
205
+ parameters: askSchema,
206
+ async execute(
207
+ _id: string,
208
+ params: {
209
+ question: string;
210
+ options?: string[];
211
+ timeoutMinutes?: number;
212
+ },
213
+ ) {
214
+ try {
215
+ const chatId = getChatId();
216
+ const opts = params.options ?? ["Yes", "No", "Explain"];
217
+ const kb = opts.map((o: string) => [{ text: o, callback_data: o }]);
218
+ const timeoutMs = (params.timeoutMinutes ?? 30) * 60000;
219
+ const mid = await sendMsg(`❓ *${params.question}*`, {
220
+ inline_keyboard: kb,
221
+ });
222
+ const reply = await pollReply(mid, chatId, timeoutMs);
223
+ if (!reply) {
224
+ await sendMsg("⏰ No reply. Proceeding autonomously.");
225
+ return {
226
+ content: [
227
+ { type: "text", text: "Timeout — proceeding autonomously" },
228
+ ],
229
+ details: { timedOut: true },
230
+ };
231
+ }
232
+ return {
233
+ content: [{ type: "text", text: `Reply: "${reply}"` }],
234
+ details: { answer: reply },
235
+ };
236
+ } catch (e: unknown) {
237
+ return {
238
+ content: [
239
+ {
240
+ type: "text",
241
+ text: `Failed: ${e instanceof Error ? e.message : e}`,
242
+ },
243
+ ],
244
+ isError: true,
245
+ };
246
+ }
247
+ },
248
+ });
244
249
 
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;
250
+ // telegram_override
251
+ pi.registerTool({
252
+ name: "telegram_override",
253
+ label: "Telegram Override",
254
+ description:
255
+ "Ask a human on Telegram to approve or reject a blocked action. " +
256
+ "Use when the supervisor blocks a dangerous command (force push, " +
257
+ "destructive git ops, file deletion, etc.) and you need human approval. " +
258
+ "Returns the human's choice so you can call supervisor_override or abort.",
259
+ parameters: overrideSchema,
260
+ async execute(
261
+ _id: string,
262
+ params: {
263
+ command: string;
264
+ reason: string;
265
+ context?: string;
266
+ options?: string[];
267
+ timeoutMinutes?: number;
268
+ },
269
+ ) {
270
+ try {
271
+ const chatId = getChatId();
272
+ const opts = params.options ?? [
273
+ "Yes, proceed",
274
+ "No, cancel",
275
+ "Explain more",
276
+ ];
277
+ const timeoutMs = (params.timeoutMinutes ?? 30) * 60000;
273
278
 
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?_`;
279
+ let message = `🛑 *Supervisor blocked an action*\n\n`;
280
+ message += `*Command:* \`${params.command.slice(0, 200)}\`\n`;
281
+ message += `*Reason:* ${params.reason.slice(0, 300)}\n`;
282
+ if (params.context) {
283
+ message += `*Context:* ${params.context.slice(0, 300)}\n`;
284
+ }
285
+ message += `\n_What should I do?_`;
281
286
 
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);
287
+ const kb = opts.map((o: string) => [{ text: o, callback_data: o }]);
288
+ const mid = await sendMsg(message, { inline_keyboard: kb });
289
+ const reply = await pollReply(mid, chatId, timeoutMs);
285
290
 
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
- }
291
+ if (!reply) {
292
+ await sendMsg("⏰ No reply to override request. Aborting action.");
293
+ return {
294
+ content: [
295
+ {
296
+ type: "text",
297
+ text: "Timeout — no human response. Action aborted.",
298
+ },
299
+ ],
300
+ details: {
301
+ timedOut: true,
302
+ action: "abort",
303
+ command: params.command,
304
+ },
305
+ };
306
+ }
302
307
 
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
- }
308
+ // Map the reply to an action
309
+ const choice = reply.trim();
310
+ let action: string;
311
+ if (choice === opts[0]) {
312
+ action = "proceed";
313
+ } else if (choice === opts[1]) {
314
+ action = "abort";
315
+ } else {
316
+ action = "explain";
317
+ }
313
318
 
314
- await sendMsg(`✅ Choice received: _${choice}_`);
319
+ await sendMsg(`✅ Choice received: _${choice}_`);
315
320
 
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
- });
321
+ return {
322
+ content: [
323
+ {
324
+ type: "text",
325
+ text: `Human chose: "${choice}" → action: ${action}`,
326
+ },
327
+ ],
328
+ details: {
329
+ choice,
330
+ action,
331
+ command: params.command,
332
+ timedOut: false,
333
+ },
334
+ };
335
+ } catch (e: unknown) {
336
+ return {
337
+ content: [
338
+ {
339
+ type: "text",
340
+ text: `Failed: ${e instanceof Error ? e.message : e}`,
341
+ },
342
+ ],
343
+ isError: true,
344
+ };
345
+ }
346
+ },
347
+ });
343
348
 
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
- });
349
+ // telegram_notify — rich formatted notifications
350
+ pi.registerTool({
351
+ name: "telegram_notify",
352
+ label: "Telegram Notify",
353
+ description:
354
+ "Send a rich, well-formatted notification to Telegram. " +
355
+ "Choose a kind: issue, pr, task, pipeline, session, alert, " +
356
+ "factory-job, standup, diff, decision, or ack. " +
357
+ "Fill the matching fields for that kind. " +
358
+ "All other fields are ignored — only provide fields relevant to your chosen kind.",
359
+ parameters: notifySchema,
360
+ async execute(_id: string, params: Record<string, unknown>) {
361
+ try {
362
+ const { buildNotification } = await import("./templates");
363
+ const message = buildNotification(
364
+ params as unknown as import("./templates").NotifyInput,
365
+ );
366
+ const mid = await sendMsg(message);
367
+ return {
368
+ content: [{ type: "text", text: `Notification sent (id:${mid})` }],
369
+ details: {},
370
+ };
371
+ } catch (e: unknown) {
372
+ return {
373
+ content: [
374
+ {
375
+ type: "text",
376
+ text: `Failed: ${e instanceof Error ? e.message : e}`,
377
+ },
378
+ ],
379
+ details: {},
380
+ isError: true,
381
+ };
382
+ }
383
+ },
384
+ });
385
+
386
+ // telegram_status
387
+ pi.registerTool({
388
+ name: "telegram_status",
389
+ label: "Telegram Status",
390
+ description: "Check if the Telegram bridge is configured and running.",
391
+ parameters: statusSchema,
392
+ async execute() {
393
+ if (!process.env.TELEGRAM_BOT_TOKEN || !process.env.TELEGRAM_CHAT_ID) {
394
+ return {
395
+ content: [
396
+ {
397
+ type: "text",
398
+ text: "Not configured. Set TELEGRAM_BOT_TOKEN and TELEGRAM_CHAT_ID.",
399
+ },
400
+ ],
401
+ };
402
+ }
403
+ try {
404
+ const r = (await telegramApi("getMe", {})) as {
405
+ result: { username: string };
406
+ };
407
+ return {
408
+ content: [
409
+ {
410
+ type: "text",
411
+ text: `Active. Bot: @${r.result.username} (listener: ${listenerAbort ? "🟢 running" : "🔴 stopped"})`,
412
+ },
413
+ ],
414
+ };
415
+ } catch {
416
+ return {
417
+ content: [{ type: "text", text: "Token set but unreachable." }],
418
+ };
419
+ }
420
+ },
421
+ });
381
422
  }