@bytesbrains/pi-telegram-bridge 1.0.2 → 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.
@@ -12,40 +12,41 @@ const TEST_CHAT_ID = "999";
12
12
  // ── Mock Extension API ───────────────────────────────────────────
13
13
 
14
14
  function createMockPI() {
15
- const tools: Record<string, any> = {};
16
- const listeners: Record<string, Array<(...args: any[]) => void>> = {};
17
- const entries: Array<{ type: string; customType?: string; data: unknown }> = [];
18
-
19
- return {
20
- registerTool: vi.fn((tool: any) => {
21
- tools[tool.name] = tool;
22
- }),
23
- on: vi.fn((event: string, handler: (...args: any[]) => void) => {
24
- if (!listeners[event]) listeners[event] = [];
25
- listeners[event].push(handler);
26
- }),
27
- sendUserMessage: vi.fn(),
28
- appendEntry: vi.fn((customType: string, data: unknown) => {
29
- entries.push({ type: "custom", customType, data });
30
- }),
31
- // Helpers for tests
32
- _tools: tools,
33
- _listeners: listeners,
34
- _entries: entries,
35
- _fireEvent(event: string, ...args: any[]) {
36
- for (const h of listeners[event] || []) {
37
- h(...args);
38
- }
39
- },
40
- _getTool(name: string) {
41
- return tools[name];
42
- },
43
- _reset() {
44
- Object.keys(tools).forEach((k) => delete tools[k]);
45
- Object.keys(listeners).forEach((k) => delete listeners[k]);
46
- entries.length = 0;
47
- },
48
- };
15
+ const tools: Record<string, any> = {};
16
+ const listeners: Record<string, Array<(...args: any[]) => void>> = {};
17
+ const entries: Array<{ type: string; customType?: string; data: unknown }> =
18
+ [];
19
+
20
+ return {
21
+ registerTool: vi.fn((tool: any) => {
22
+ tools[tool.name] = tool;
23
+ }),
24
+ on: vi.fn((event: string, handler: (...args: any[]) => void) => {
25
+ if (!listeners[event]) listeners[event] = [];
26
+ listeners[event].push(handler);
27
+ }),
28
+ sendUserMessage: vi.fn(),
29
+ appendEntry: vi.fn((customType: string, data: unknown) => {
30
+ entries.push({ type: "custom", customType, data });
31
+ }),
32
+ // Helpers for tests
33
+ _tools: tools,
34
+ _listeners: listeners,
35
+ _entries: entries,
36
+ _fireEvent(event: string, ...args: any[]) {
37
+ for (const h of listeners[event] || []) {
38
+ h(...args);
39
+ }
40
+ },
41
+ _getTool(name: string) {
42
+ return tools[name];
43
+ },
44
+ _reset() {
45
+ Object.keys(tools).forEach((k) => delete tools[k]);
46
+ Object.keys(listeners).forEach((k) => delete listeners[k]);
47
+ entries.length = 0;
48
+ },
49
+ };
49
50
  }
50
51
 
51
52
  // We need to mock the helpers module. We'll import the extension
@@ -58,502 +59,521 @@ let mockSeedLastUpdateId: any;
58
59
  let mockPollUpdates: any;
59
60
 
60
61
  vi.mock("../helpers", () => ({
61
- getToken: () => {
62
- if (!process.env.TELEGRAM_BOT_TOKEN) throw new Error("TELEGRAM_BOT_TOKEN not set");
63
- return process.env.TELEGRAM_BOT_TOKEN;
64
- },
65
- getChatId: () => {
66
- if (!process.env.TELEGRAM_CHAT_ID) throw new Error("TELEGRAM_CHAT_ID not set");
67
- return process.env.TELEGRAM_CHAT_ID;
68
- },
69
- telegramApi: (...args: any[]) => mockTelegramApi(...args),
70
- sendMsg: (...args: any[]) => mockSendMsg(...args),
71
- pollReply: (...args: any[]) => mockPollReply(...args),
72
- getBotId: () => mockGetBotId(),
73
- seedLastUpdateId: (...args: any[]) => mockSeedLastUpdateId(...args),
74
- pollUpdates: (...args: any[]) => mockPollUpdates(...args),
62
+ getToken: () => {
63
+ if (!process.env.TELEGRAM_BOT_TOKEN)
64
+ throw new Error("TELEGRAM_BOT_TOKEN not set");
65
+ return process.env.TELEGRAM_BOT_TOKEN;
66
+ },
67
+ getChatId: () => {
68
+ if (!process.env.TELEGRAM_CHAT_ID)
69
+ throw new Error("TELEGRAM_CHAT_ID not set");
70
+ return process.env.TELEGRAM_CHAT_ID;
71
+ },
72
+ telegramApi: (...args: any[]) => mockTelegramApi(...args),
73
+ sendMsg: (...args: any[]) => mockSendMsg(...args),
74
+ pollReply: (...args: any[]) => mockPollReply(...args),
75
+ getBotId: () => mockGetBotId(),
76
+ seedLastUpdateId: (...args: any[]) => mockSeedLastUpdateId(...args),
77
+ pollUpdates: (...args: any[]) => mockPollUpdates(...args),
75
78
  }));
76
79
 
77
80
  // Import after mocks
78
81
  import telegramBridge from "../index";
79
82
 
80
83
  beforeEach(() => {
81
- vi.stubEnv("TELEGRAM_BOT_TOKEN", TEST_TOKEN);
82
- vi.stubEnv("TELEGRAM_CHAT_ID", TEST_CHAT_ID);
83
-
84
- mockTelegramApi = vi.fn().mockResolvedValue({ ok: true, result: { id: 123, username: "testbot" } });
85
- mockSendMsg = vi.fn().mockResolvedValue(42);
86
- mockPollReply = vi.fn().mockResolvedValue("Yes");
87
- mockGetBotId = vi.fn().mockResolvedValue(123);
88
- mockSeedLastUpdateId = vi.fn().mockImplementation((id: number) => Promise.resolve(id));
89
- mockPollUpdates = vi.fn(); // fire and forget, no-op
84
+ vi.stubEnv("TELEGRAM_BOT_TOKEN", TEST_TOKEN);
85
+ vi.stubEnv("TELEGRAM_CHAT_ID", TEST_CHAT_ID);
86
+
87
+ mockTelegramApi = vi
88
+ .fn()
89
+ .mockResolvedValue({ ok: true, result: { id: 123, username: "testbot" } });
90
+ mockSendMsg = vi.fn().mockResolvedValue(42);
91
+ mockPollReply = vi.fn().mockResolvedValue("Yes");
92
+ mockGetBotId = vi.fn().mockResolvedValue(123);
93
+ mockSeedLastUpdateId = vi
94
+ .fn()
95
+ .mockImplementation((id: number) => Promise.resolve(id));
96
+ mockPollUpdates = vi.fn(); // fire and forget, no-op
90
97
  });
91
98
 
92
99
  afterEach(() => {
93
- vi.unstubAllEnvs();
94
- vi.clearAllMocks();
100
+ vi.unstubAllEnvs();
101
+ vi.clearAllMocks();
95
102
  });
96
103
 
97
104
  // ── Tool Registration ─────────────────────────────────────────────
98
105
 
99
106
  describe("extension registration", () => {
100
- it("registers all 5 tools", () => {
101
- const pi = createMockPI();
102
- telegramBridge(pi as any);
103
-
104
- expect(pi.registerTool).toHaveBeenCalledTimes(5);
105
- expect(pi._getTool("telegram_listen")).toBeDefined();
106
- expect(pi._getTool("telegram_send")).toBeDefined();
107
- expect(pi._getTool("telegram_ask")).toBeDefined();
108
- expect(pi._getTool("telegram_override")).toBeDefined();
109
- expect(pi._getTool("telegram_status")).toBeDefined();
110
- });
111
-
112
- it("registers session_start and session_shutdown handlers", () => {
113
- const pi = createMockPI();
114
- telegramBridge(pi as any);
115
-
116
- expect(pi.on).toHaveBeenCalledWith("session_start", expect.any(Function));
117
- expect(pi.on).toHaveBeenCalledWith("session_shutdown", expect.any(Function));
118
- });
107
+ it("registers all 6 tools", () => {
108
+ const pi = createMockPI();
109
+ telegramBridge(pi as any);
110
+
111
+ expect(pi.registerTool).toHaveBeenCalledTimes(6);
112
+ expect(pi._getTool("telegram_listen")).toBeDefined();
113
+ expect(pi._getTool("telegram_send")).toBeDefined();
114
+ expect(pi._getTool("telegram_ask")).toBeDefined();
115
+ expect(pi._getTool("telegram_override")).toBeDefined();
116
+ expect(pi._getTool("telegram_status")).toBeDefined();
117
+ expect(pi._getTool("telegram_notify")).toBeDefined();
118
+ });
119
+
120
+ it("registers session_start and session_shutdown handlers", () => {
121
+ const pi = createMockPI();
122
+ telegramBridge(pi as any);
123
+
124
+ expect(pi.on).toHaveBeenCalledWith("session_start", expect.any(Function));
125
+ expect(pi.on).toHaveBeenCalledWith(
126
+ "session_shutdown",
127
+ expect.any(Function),
128
+ );
129
+ });
119
130
  });
120
131
 
121
132
  // ── telegram_listen Tool ──────────────────────────────────────────
122
133
 
123
134
  describe("telegram_listen tool", () => {
124
- it("returns no messages when update queue is empty", async () => {
125
- const pi = createMockPI();
126
- telegramBridge(pi as any);
127
-
128
- const fetchMock = vi.fn().mockResolvedValue({
129
- ok: true,
130
- json: async () => ({ ok: true, result: [] }),
131
- });
132
- vi.stubGlobal("fetch", fetchMock);
133
-
134
- const tool = pi._getTool("telegram_listen");
135
- const result = await tool.execute();
136
-
137
- expect(result.content[0].text).toBe("No new messages.");
138
- expect(result.isError).toBeFalsy();
139
- });
140
-
141
- it("detects new text messages and returns them", async () => {
142
- const pi = createMockPI();
143
- telegramBridge(pi as any);
144
-
145
- const fetchMock = vi.fn().mockResolvedValue({
146
- ok: true,
147
- json: async () => ({
148
- ok: true,
149
- result: [
150
- {
151
- update_id: 42,
152
- message: {
153
- message_id: 1,
154
- chat: { id: Number(TEST_CHAT_ID) },
155
- from: { id: 999, is_bot: false },
156
- text: "Hello from Telegram!",
157
- },
158
- },
159
- ],
160
- }),
161
- });
162
- vi.stubGlobal("fetch", fetchMock);
163
-
164
- const tool = pi._getTool("telegram_listen");
165
- const result = await tool.execute();
166
-
167
- expect(result.content[0].text).toContain("Hello from Telegram!");
168
- expect(result.details.message).toBe("Hello from Telegram!");
169
- // Should have persisted the update id
170
- expect(pi.appendEntry).toHaveBeenCalledWith("tg-last-update", {
171
- update_id: 42,
172
- });
173
- });
174
-
175
- it("returns error when Telegram is unreachable", async () => {
176
- const pi = createMockPI();
177
- telegramBridge(pi as any);
178
-
179
- const fetchMock = vi.fn().mockResolvedValue({
180
- ok: false,
181
- status: 500,
182
- });
183
- vi.stubGlobal("fetch", fetchMock);
184
-
185
- const tool = pi._getTool("telegram_listen");
186
- const result = await tool.execute();
187
-
188
- expect(result.isError).toBe(true);
189
- expect(result.content[0].text).toBe("Could not reach Telegram.");
190
- });
191
-
192
- it("returns error on network failure", async () => {
193
- const pi = createMockPI();
194
- telegramBridge(pi as any);
195
-
196
- vi.stubGlobal("fetch", vi.fn().mockRejectedValue(new Error("ECONNREFUSED")));
197
-
198
- const tool = pi._getTool("telegram_listen");
199
- const result = await tool.execute();
200
-
201
- expect(result.isError).toBe(true);
202
- expect(result.content[0].text).toContain("ECONNREFUSED");
203
- });
204
-
205
- it("skips callback_query updates", async () => {
206
- const pi = createMockPI();
207
- telegramBridge(pi as any);
208
-
209
- const fetchMock = vi.fn().mockResolvedValue({
210
- ok: true,
211
- json: async () => ({
212
- ok: true,
213
- result: [
214
- {
215
- update_id: 10,
216
- callback_query: { id: "cb-1", data: "yes" },
217
- },
218
- ],
219
- }),
220
- });
221
- vi.stubGlobal("fetch", fetchMock);
222
-
223
- const tool = pi._getTool("telegram_listen");
224
- const result = await tool.execute();
225
-
226
- expect(result.content[0].text).toBe("No new messages.");
227
- });
135
+ it("returns no messages when update queue is empty", async () => {
136
+ const pi = createMockPI();
137
+ telegramBridge(pi as any);
138
+
139
+ const fetchMock = vi.fn().mockResolvedValue({
140
+ ok: true,
141
+ json: async () => ({ ok: true, result: [] }),
142
+ });
143
+ vi.stubGlobal("fetch", fetchMock);
144
+
145
+ const tool = pi._getTool("telegram_listen");
146
+ const result = await tool.execute();
147
+
148
+ expect(result.content[0].text).toBe("No new messages.");
149
+ expect(result.isError).toBeFalsy();
150
+ });
151
+
152
+ it("detects new text messages and returns them", async () => {
153
+ const pi = createMockPI();
154
+ telegramBridge(pi as any);
155
+
156
+ const fetchMock = vi.fn().mockResolvedValue({
157
+ ok: true,
158
+ json: async () => ({
159
+ ok: true,
160
+ result: [
161
+ {
162
+ update_id: 42,
163
+ message: {
164
+ message_id: 1,
165
+ chat: { id: Number(TEST_CHAT_ID) },
166
+ from: { id: 999, is_bot: false },
167
+ text: "Hello from Telegram!",
168
+ },
169
+ },
170
+ ],
171
+ }),
172
+ });
173
+ vi.stubGlobal("fetch", fetchMock);
174
+
175
+ const tool = pi._getTool("telegram_listen");
176
+ const result = await tool.execute();
177
+
178
+ expect(result.content[0].text).toContain("Hello from Telegram!");
179
+ expect(result.details.message).toBe("Hello from Telegram!");
180
+ // Should have persisted the update id
181
+ expect(pi.appendEntry).toHaveBeenCalledWith("tg-last-update", {
182
+ update_id: 42,
183
+ });
184
+ });
185
+
186
+ it("returns error when Telegram is unreachable", async () => {
187
+ const pi = createMockPI();
188
+ telegramBridge(pi as any);
189
+
190
+ const fetchMock = vi.fn().mockResolvedValue({
191
+ ok: false,
192
+ status: 500,
193
+ });
194
+ vi.stubGlobal("fetch", fetchMock);
195
+
196
+ const tool = pi._getTool("telegram_listen");
197
+ const result = await tool.execute();
198
+
199
+ expect(result.isError).toBe(true);
200
+ expect(result.content[0].text).toBe("Could not reach Telegram.");
201
+ });
202
+
203
+ it("returns error on network failure", async () => {
204
+ const pi = createMockPI();
205
+ telegramBridge(pi as any);
206
+
207
+ vi.stubGlobal(
208
+ "fetch",
209
+ vi.fn().mockRejectedValue(new Error("ECONNREFUSED")),
210
+ );
211
+
212
+ const tool = pi._getTool("telegram_listen");
213
+ const result = await tool.execute();
214
+
215
+ expect(result.isError).toBe(true);
216
+ expect(result.content[0].text).toContain("ECONNREFUSED");
217
+ });
218
+
219
+ it("skips callback_query updates", async () => {
220
+ const pi = createMockPI();
221
+ telegramBridge(pi as any);
222
+
223
+ const fetchMock = vi.fn().mockResolvedValue({
224
+ ok: true,
225
+ json: async () => ({
226
+ ok: true,
227
+ result: [
228
+ {
229
+ update_id: 10,
230
+ callback_query: { id: "cb-1", data: "yes" },
231
+ },
232
+ ],
233
+ }),
234
+ });
235
+ vi.stubGlobal("fetch", fetchMock);
236
+
237
+ const tool = pi._getTool("telegram_listen");
238
+ const result = await tool.execute();
239
+
240
+ expect(result.content[0].text).toBe("No new messages.");
241
+ });
228
242
  });
229
243
 
230
244
  // ── telegram_send Tool ────────────────────────────────────────────
231
245
 
232
246
  describe("telegram_send tool", () => {
233
- it("sends a message and returns success", async () => {
234
- const pi = createMockPI();
235
- telegramBridge(pi as any);
247
+ it("sends a message and returns success", async () => {
248
+ const pi = createMockPI();
249
+ telegramBridge(pi as any);
236
250
 
237
- const tool = pi._getTool("telegram_send");
238
- const result = await tool.execute("id-1", { message: "Test message" });
251
+ const tool = pi._getTool("telegram_send");
252
+ const result = await tool.execute("id-1", { message: "Test message" });
239
253
 
240
- expect(mockSendMsg).toHaveBeenCalledWith("Test message");
241
- expect(result.content[0].text).toContain("Sent");
242
- expect(result.isError).toBeFalsy();
243
- });
254
+ expect(mockSendMsg).toHaveBeenCalledWith("Test message");
255
+ expect(result.content[0].text).toContain("Sent");
256
+ expect(result.isError).toBeFalsy();
257
+ });
244
258
 
245
- it("returns error when sending fails", async () => {
246
- const pi = createMockPI();
247
- mockSendMsg = vi.fn().mockRejectedValue(new Error("Chat not found"));
248
- telegramBridge(pi as any);
259
+ it("returns error when sending fails", async () => {
260
+ const pi = createMockPI();
261
+ mockSendMsg = vi.fn().mockRejectedValue(new Error("Chat not found"));
262
+ telegramBridge(pi as any);
249
263
 
250
- const tool = pi._getTool("telegram_send");
251
- const result = await tool.execute("id-1", { message: "Test" });
264
+ const tool = pi._getTool("telegram_send");
265
+ const result = await tool.execute("id-1", { message: "Test" });
252
266
 
253
- expect(result.isError).toBe(true);
254
- expect(result.content[0].text).toContain("Chat not found");
255
- });
267
+ expect(result.isError).toBe(true);
268
+ expect(result.content[0].text).toContain("Chat not found");
269
+ });
256
270
  });
257
271
 
258
272
  // ── telegram_ask Tool ─────────────────────────────────────────────
259
273
 
260
274
  describe("telegram_ask tool", () => {
261
- it("sends question and returns reply", async () => {
262
- const pi = createMockPI();
263
- mockPollReply = vi.fn().mockResolvedValue("No");
264
- telegramBridge(pi as any);
265
-
266
- const tool = pi._getTool("telegram_ask");
267
- const result = await tool.execute("id-1", { question: "Deploy?" });
268
-
269
- expect(mockSendMsg).toHaveBeenCalled();
270
- const sendCall = mockSendMsg.mock.calls[0];
271
- expect(sendCall[0]).toContain("Deploy?");
272
- // Should have inline keyboard markup
273
- expect(sendCall[1]).toBeDefined();
274
- expect(sendCall[1].inline_keyboard).toHaveLength(3); // Yes, No, Explain
275
-
276
- expect(result.content[0].text).toContain("No");
277
- expect(result.details.answer).toBe("No");
278
- });
279
-
280
- it("uses custom options for keyboard", async () => {
281
- const pi = createMockPI();
282
- mockPollReply = vi.fn().mockResolvedValue("Approve");
283
- telegramBridge(pi as any);
284
-
285
- const tool = pi._getTool("telegram_ask");
286
- await tool.execute("id-1", {
287
- question: "Merge?",
288
- options: ["Approve", "Reject", "Request changes"],
289
- });
290
-
291
- const sendCall = mockSendMsg.mock.calls[0];
292
- expect(sendCall[1].inline_keyboard).toEqual([
293
- [{ text: "Approve", callback_data: "Approve" }],
294
- [{ text: "Reject", callback_data: "Reject" }],
295
- [{ text: "Request changes", callback_data: "Request changes" }],
296
- ]);
297
- });
298
-
299
- it("returns timeout when no reply", async () => {
300
- const pi = createMockPI();
301
- mockPollReply = vi.fn().mockResolvedValue(null);
302
- telegramBridge(pi as any);
303
-
304
- const tool = pi._getTool("telegram_ask");
305
- const result = await tool.execute("id-1", {
306
- question: "Hello?",
307
- timeoutMinutes: 1,
308
- });
309
-
310
- expect(result.details.timedOut).toBe(true);
311
- expect(result.content[0].text).toContain("Timeout");
312
- // Should send a timeout notification
313
- expect(mockSendMsg).toHaveBeenCalledTimes(2); // question + timeout msg
314
- });
315
-
316
- it("handles API errors", async () => {
317
- const pi = createMockPI();
318
- mockSendMsg = vi.fn().mockRejectedValue(new Error("API down"));
319
- telegramBridge(pi as any);
320
-
321
- const tool = pi._getTool("telegram_ask");
322
- const result = await tool.execute("id-1", { question: "Test" });
323
-
324
- expect(result.isError).toBe(true);
325
- expect(result.content[0].text).toContain("API down");
326
- });
275
+ it("sends question and returns reply", async () => {
276
+ const pi = createMockPI();
277
+ mockPollReply = vi.fn().mockResolvedValue("No");
278
+ telegramBridge(pi as any);
279
+
280
+ const tool = pi._getTool("telegram_ask");
281
+ const result = await tool.execute("id-1", { question: "Deploy?" });
282
+
283
+ expect(mockSendMsg).toHaveBeenCalled();
284
+ const sendCall = mockSendMsg.mock.calls[0];
285
+ expect(sendCall[0]).toContain("Deploy?");
286
+ // Should have inline keyboard markup
287
+ expect(sendCall[1]).toBeDefined();
288
+ expect(sendCall[1].inline_keyboard).toHaveLength(3); // Yes, No, Explain
289
+
290
+ expect(result.content[0].text).toContain("No");
291
+ expect(result.details.answer).toBe("No");
292
+ });
293
+
294
+ it("uses custom options for keyboard", async () => {
295
+ const pi = createMockPI();
296
+ mockPollReply = vi.fn().mockResolvedValue("Approve");
297
+ telegramBridge(pi as any);
298
+
299
+ const tool = pi._getTool("telegram_ask");
300
+ await tool.execute("id-1", {
301
+ question: "Merge?",
302
+ options: ["Approve", "Reject", "Request changes"],
303
+ });
304
+
305
+ const sendCall = mockSendMsg.mock.calls[0];
306
+ expect(sendCall[1].inline_keyboard).toEqual([
307
+ [{ text: "Approve", callback_data: "Approve" }],
308
+ [{ text: "Reject", callback_data: "Reject" }],
309
+ [{ text: "Request changes", callback_data: "Request changes" }],
310
+ ]);
311
+ });
312
+
313
+ it("returns timeout when no reply", async () => {
314
+ const pi = createMockPI();
315
+ mockPollReply = vi.fn().mockResolvedValue(null);
316
+ telegramBridge(pi as any);
317
+
318
+ const tool = pi._getTool("telegram_ask");
319
+ const result = await tool.execute("id-1", {
320
+ question: "Hello?",
321
+ timeoutMinutes: 1,
322
+ });
323
+
324
+ expect(result.details.timedOut).toBe(true);
325
+ expect(result.content[0].text).toContain("Timeout");
326
+ // Should send a timeout notification
327
+ expect(mockSendMsg).toHaveBeenCalledTimes(2); // question + timeout msg
328
+ });
329
+
330
+ it("handles API errors", async () => {
331
+ const pi = createMockPI();
332
+ mockSendMsg = vi.fn().mockRejectedValue(new Error("API down"));
333
+ telegramBridge(pi as any);
334
+
335
+ const tool = pi._getTool("telegram_ask");
336
+ const result = await tool.execute("id-1", { question: "Test" });
337
+
338
+ expect(result.isError).toBe(true);
339
+ expect(result.content[0].text).toContain("API down");
340
+ });
327
341
  });
328
342
 
329
343
  // ── telegram_override Tool ────────────────────────────────────────
330
344
 
331
345
  describe("telegram_override tool", () => {
332
- it("formats override message with command and reason", async () => {
333
- const pi = createMockPI();
334
- mockPollReply = vi.fn().mockResolvedValue("Yes, proceed");
335
- telegramBridge(pi as any);
336
-
337
- const tool = pi._getTool("telegram_override");
338
- const result = await tool.execute("id-1", {
339
- command: "git push --force",
340
- reason: "Force push detected",
341
- });
342
-
343
- const sendCall = mockSendMsg.mock.calls[0];
344
- expect(sendCall[0]).toContain("Supervisor blocked an action");
345
- expect(sendCall[0]).toContain("git push --force");
346
- expect(sendCall[0]).toContain("Force push detected");
347
- expect(sendCall[1].inline_keyboard).toHaveLength(3);
348
- expect(sendCall[1].inline_keyboard[0][0].text).toBe("Yes, proceed");
349
-
350
- expect(result.details.action).toBe("proceed");
351
- expect(result.details.choice).toBe("Yes, proceed");
352
- expect(result.details.timedOut).toBe(false);
353
- });
354
-
355
- it("returns abort for second option", async () => {
356
- const pi = createMockPI();
357
- mockPollReply = vi.fn().mockResolvedValue("No, cancel");
358
- telegramBridge(pi as any);
359
-
360
- const tool = pi._getTool("telegram_override");
361
- const result = await tool.execute("id-1", {
362
- command: "rm -rf /",
363
- reason: "Dangerous",
364
- });
365
-
366
- expect(result.details.action).toBe("abort");
367
- expect(result.details.choice).toBe("No, cancel");
368
- });
369
-
370
- it("returns explain for custom third+ options", async () => {
371
- const pi = createMockPI();
372
- mockPollReply = vi.fn().mockResolvedValue("Give me details");
373
- telegramBridge(pi as any);
374
-
375
- const tool = pi._getTool("telegram_override");
376
- const result = await tool.execute("id-1", {
377
- command: "test",
378
- reason: "test",
379
- options: ["Go", "Stop", "Give me details", "Do it differently"],
380
- });
381
-
382
- expect(result.details.action).toBe("explain");
383
- expect(result.details.choice).toBe("Give me details");
384
- });
385
-
386
- it("includes optional context in message", async () => {
387
- const pi = createMockPI();
388
- mockPollReply = vi.fn().mockResolvedValue("Yes, proceed");
389
- telegramBridge(pi as any);
390
-
391
- const tool = pi._getTool("telegram_override");
392
- await tool.execute("id-1", {
393
- command: "test",
394
- reason: "test",
395
- context: "User asked for a clean rebuild",
396
- });
397
-
398
- const sendCall = mockSendMsg.mock.calls[0];
399
- expect(sendCall[0]).toContain("User asked for a clean rebuild");
400
- });
401
-
402
- it("returns timeout when no reply", async () => {
403
- const pi = createMockPI();
404
- mockPollReply = vi.fn().mockResolvedValue(null);
405
- telegramBridge(pi as any);
406
-
407
- const tool = pi._getTool("telegram_override");
408
- const result = await tool.execute("id-1", {
409
- command: "test",
410
- reason: "test",
411
- timeoutMinutes: 1,
412
- });
413
-
414
- expect(result.details.timedOut).toBe(true);
415
- expect(result.details.action).toBe("abort");
416
- expect(mockSendMsg).toHaveBeenCalledTimes(2); // question + timeout
417
- });
418
-
419
- it("handles errors gracefully", async () => {
420
- const pi = createMockPI();
421
- mockSendMsg = vi.fn().mockRejectedValue(new Error("Token invalid"));
422
- telegramBridge(pi as any);
423
-
424
- const tool = pi._getTool("telegram_override");
425
- const result = await tool.execute("id-1", {
426
- command: "test",
427
- reason: "test",
428
- });
429
-
430
- expect(result.isError).toBe(true);
431
- expect(result.content[0].text).toContain("Token invalid");
432
- });
433
-
434
- it("truncates long command in message", async () => {
435
- const pi = createMockPI();
436
- mockPollReply = vi.fn().mockResolvedValue("Yes, proceed");
437
- telegramBridge(pi as any);
438
-
439
- const longCommand = "x".repeat(500);
440
- const tool = pi._getTool("telegram_override");
441
- await tool.execute("id-1", {
442
- command: longCommand,
443
- reason: "test",
444
- });
445
-
446
- const sendCall = mockSendMsg.mock.calls[0];
447
- // Should truncate to 200 chars
448
- expect(sendCall[0]).toContain("x".repeat(200));
449
- expect(sendCall[0]).not.toContain("x".repeat(201));
450
- });
346
+ it("formats override message with command and reason", async () => {
347
+ const pi = createMockPI();
348
+ mockPollReply = vi.fn().mockResolvedValue("Yes, proceed");
349
+ telegramBridge(pi as any);
350
+
351
+ const tool = pi._getTool("telegram_override");
352
+ const result = await tool.execute("id-1", {
353
+ command: "git push --force",
354
+ reason: "Force push detected",
355
+ });
356
+
357
+ const sendCall = mockSendMsg.mock.calls[0];
358
+ expect(sendCall[0]).toContain("Supervisor blocked an action");
359
+ expect(sendCall[0]).toContain("git push --force");
360
+ expect(sendCall[0]).toContain("Force push detected");
361
+ expect(sendCall[1].inline_keyboard).toHaveLength(3);
362
+ expect(sendCall[1].inline_keyboard[0][0].text).toBe("Yes, proceed");
363
+
364
+ expect(result.details.action).toBe("proceed");
365
+ expect(result.details.choice).toBe("Yes, proceed");
366
+ expect(result.details.timedOut).toBe(false);
367
+ });
368
+
369
+ it("returns abort for second option", async () => {
370
+ const pi = createMockPI();
371
+ mockPollReply = vi.fn().mockResolvedValue("No, cancel");
372
+ telegramBridge(pi as any);
373
+
374
+ const tool = pi._getTool("telegram_override");
375
+ const result = await tool.execute("id-1", {
376
+ command: "rm -rf /",
377
+ reason: "Dangerous",
378
+ });
379
+
380
+ expect(result.details.action).toBe("abort");
381
+ expect(result.details.choice).toBe("No, cancel");
382
+ });
383
+
384
+ it("returns explain for custom third+ options", async () => {
385
+ const pi = createMockPI();
386
+ mockPollReply = vi.fn().mockResolvedValue("Give me details");
387
+ telegramBridge(pi as any);
388
+
389
+ const tool = pi._getTool("telegram_override");
390
+ const result = await tool.execute("id-1", {
391
+ command: "test",
392
+ reason: "test",
393
+ options: ["Go", "Stop", "Give me details", "Do it differently"],
394
+ });
395
+
396
+ expect(result.details.action).toBe("explain");
397
+ expect(result.details.choice).toBe("Give me details");
398
+ });
399
+
400
+ it("includes optional context in message", async () => {
401
+ const pi = createMockPI();
402
+ mockPollReply = vi.fn().mockResolvedValue("Yes, proceed");
403
+ telegramBridge(pi as any);
404
+
405
+ const tool = pi._getTool("telegram_override");
406
+ await tool.execute("id-1", {
407
+ command: "test",
408
+ reason: "test",
409
+ context: "User asked for a clean rebuild",
410
+ });
411
+
412
+ const sendCall = mockSendMsg.mock.calls[0];
413
+ expect(sendCall[0]).toContain("User asked for a clean rebuild");
414
+ });
415
+
416
+ it("returns timeout when no reply", async () => {
417
+ const pi = createMockPI();
418
+ mockPollReply = vi.fn().mockResolvedValue(null);
419
+ telegramBridge(pi as any);
420
+
421
+ const tool = pi._getTool("telegram_override");
422
+ const result = await tool.execute("id-1", {
423
+ command: "test",
424
+ reason: "test",
425
+ timeoutMinutes: 1,
426
+ });
427
+
428
+ expect(result.details.timedOut).toBe(true);
429
+ expect(result.details.action).toBe("abort");
430
+ expect(mockSendMsg).toHaveBeenCalledTimes(2); // question + timeout
431
+ });
432
+
433
+ it("handles errors gracefully", async () => {
434
+ const pi = createMockPI();
435
+ mockSendMsg = vi.fn().mockRejectedValue(new Error("Token invalid"));
436
+ telegramBridge(pi as any);
437
+
438
+ const tool = pi._getTool("telegram_override");
439
+ const result = await tool.execute("id-1", {
440
+ command: "test",
441
+ reason: "test",
442
+ });
443
+
444
+ expect(result.isError).toBe(true);
445
+ expect(result.content[0].text).toContain("Token invalid");
446
+ });
447
+
448
+ it("truncates long command in message", async () => {
449
+ const pi = createMockPI();
450
+ mockPollReply = vi.fn().mockResolvedValue("Yes, proceed");
451
+ telegramBridge(pi as any);
452
+
453
+ const longCommand = "x".repeat(500);
454
+ const tool = pi._getTool("telegram_override");
455
+ await tool.execute("id-1", {
456
+ command: longCommand,
457
+ reason: "test",
458
+ });
459
+
460
+ const sendCall = mockSendMsg.mock.calls[0];
461
+ // Should truncate to 200 chars
462
+ expect(sendCall[0]).toContain("x".repeat(200));
463
+ expect(sendCall[0]).not.toContain("x".repeat(201));
464
+ });
451
465
  });
452
466
 
453
467
  // ── telegram_status Tool ──────────────────────────────────────────
454
468
 
455
469
  describe("telegram_status tool", () => {
456
- it("reports not configured when env vars missing", async () => {
457
- vi.stubEnv("TELEGRAM_BOT_TOKEN", "");
458
- vi.stubEnv("TELEGRAM_CHAT_ID", "");
459
- const pi = createMockPI();
460
- telegramBridge(pi as any);
461
-
462
- const tool = pi._getTool("telegram_status");
463
- const result = await tool.execute();
464
-
465
- expect(result.content[0].text).toContain("Not configured");
466
- });
467
-
468
- it("reports active when configured and reachable", async () => {
469
- const pi = createMockPI();
470
- mockTelegramApi = vi.fn().mockResolvedValue({
471
- ok: true,
472
- result: { username: "mycoolbot" },
473
- });
474
- telegramBridge(pi as any);
475
-
476
- const tool = pi._getTool("telegram_status");
477
- const result = await tool.execute();
478
-
479
- expect(result.content[0].text).toContain("Active. Bot: @mycoolbot");
480
- });
481
-
482
- it("reports unreachable when API fails", async () => {
483
- const pi = createMockPI();
484
- mockTelegramApi = vi.fn().mockRejectedValue(new Error("timeout"));
485
- telegramBridge(pi as any);
486
-
487
- const tool = pi._getTool("telegram_status");
488
- const result = await tool.execute();
489
-
490
- expect(result.content[0].text).toContain("Token set but unreachable");
491
- });
470
+ it("reports not configured when env vars missing", async () => {
471
+ vi.stubEnv("TELEGRAM_BOT_TOKEN", "");
472
+ vi.stubEnv("TELEGRAM_CHAT_ID", "");
473
+ const pi = createMockPI();
474
+ telegramBridge(pi as any);
475
+
476
+ const tool = pi._getTool("telegram_status");
477
+ const result = await tool.execute();
478
+
479
+ expect(result.content[0].text).toContain("Not configured");
480
+ });
481
+
482
+ it("reports active when configured and reachable", async () => {
483
+ const pi = createMockPI();
484
+ mockTelegramApi = vi.fn().mockResolvedValue({
485
+ ok: true,
486
+ result: { username: "mycoolbot" },
487
+ });
488
+ telegramBridge(pi as any);
489
+
490
+ const tool = pi._getTool("telegram_status");
491
+ const result = await tool.execute();
492
+
493
+ expect(result.content[0].text).toContain("Active. Bot: @mycoolbot");
494
+ });
495
+
496
+ it("reports unreachable when API fails", async () => {
497
+ const pi = createMockPI();
498
+ mockTelegramApi = vi.fn().mockRejectedValue(new Error("timeout"));
499
+ telegramBridge(pi as any);
500
+
501
+ const tool = pi._getTool("telegram_status");
502
+ const result = await tool.execute();
503
+
504
+ expect(result.content[0].text).toContain("Token set but unreachable");
505
+ });
492
506
  });
493
507
 
494
508
  // ── Session Lifecycle ─────────────────────────────────────────────
495
509
 
496
510
  describe("session lifecycle", () => {
497
- it("starts listener on session_start", async () => {
498
- const pi = createMockPI();
499
- telegramBridge(pi as any);
500
-
501
- const ctx = {
502
- sessionManager: {
503
- getEntries: () => [],
504
- },
505
- };
506
-
507
- // Fire session_start
508
- await pi._listeners["session_start"][0]({}, ctx);
509
-
510
- // startListener() is fire-and-forget async — wait for it
511
- await new Promise((r) => setTimeout(r, 100));
512
-
513
- // pollUpdates should have been called (fire and forget)
514
- expect(mockPollUpdates).toHaveBeenCalledTimes(1);
515
- expect(mockSeedLastUpdateId).toHaveBeenCalledWith(0, expect.any(AbortSignal));
516
- });
517
-
518
- it("restores lastUpdateId from session state", async () => {
519
- const pi = createMockPI();
520
- telegramBridge(pi as any);
521
-
522
- // Fire session_start with existing state
523
- const ctx = {
524
- sessionManager: {
525
- getEntries: () => [
526
- {
527
- type: "custom",
528
- customType: "tg-last-update",
529
- data: { update_id: 999 },
530
- },
531
- ],
532
- },
533
- };
534
-
535
- await pi._listeners["session_start"][0]({}, ctx);
536
- await new Promise((r) => setTimeout(r, 100));
537
-
538
- // seedLastUpdateId should be called with 999
539
- expect(mockSeedLastUpdateId).toHaveBeenCalledWith(999, expect.any(AbortSignal));
540
- });
541
-
542
- it("does not start listener twice on duplicate session_start", async () => {
543
- const pi = createMockPI();
544
- telegramBridge(pi as any);
545
-
546
- const ctx = {
547
- sessionManager: { getEntries: () => [] },
548
- };
549
-
550
- await pi._listeners["session_start"][0]({}, ctx);
551
- await new Promise((r) => setTimeout(r, 100));
552
- expect(mockPollUpdates).toHaveBeenCalledTimes(1);
553
-
554
- // Fire again — should not create another listener
555
- await pi._listeners["session_start"][0]({}, ctx);
556
- await new Promise((r) => setTimeout(r, 100));
557
- expect(mockPollUpdates).toHaveBeenCalledTimes(1);
558
- });
511
+ it("starts listener on session_start", async () => {
512
+ const pi = createMockPI();
513
+ telegramBridge(pi as any);
514
+
515
+ const ctx = {
516
+ sessionManager: {
517
+ getEntries: () => [],
518
+ },
519
+ };
520
+
521
+ // Fire session_start
522
+ await pi._listeners["session_start"][0]({}, ctx);
523
+
524
+ // startListener() is fire-and-forget async — wait for it
525
+ await new Promise((r) => setTimeout(r, 100));
526
+
527
+ // pollUpdates should have been called (fire and forget)
528
+ expect(mockPollUpdates).toHaveBeenCalledTimes(1);
529
+ expect(mockSeedLastUpdateId).toHaveBeenCalledWith(
530
+ 0,
531
+ expect.any(AbortSignal),
532
+ );
533
+ });
534
+
535
+ it("restores lastUpdateId from session state", async () => {
536
+ const pi = createMockPI();
537
+ telegramBridge(pi as any);
538
+
539
+ // Fire session_start with existing state
540
+ const ctx = {
541
+ sessionManager: {
542
+ getEntries: () => [
543
+ {
544
+ type: "custom",
545
+ customType: "tg-last-update",
546
+ data: { update_id: 999 },
547
+ },
548
+ ],
549
+ },
550
+ };
551
+
552
+ await pi._listeners["session_start"][0]({}, ctx);
553
+ await new Promise((r) => setTimeout(r, 100));
554
+
555
+ // seedLastUpdateId should be called with 999
556
+ expect(mockSeedLastUpdateId).toHaveBeenCalledWith(
557
+ 999,
558
+ expect.any(AbortSignal),
559
+ );
560
+ });
561
+
562
+ it("does not start listener twice on duplicate session_start", async () => {
563
+ const pi = createMockPI();
564
+ telegramBridge(pi as any);
565
+
566
+ const ctx = {
567
+ sessionManager: { getEntries: () => [] },
568
+ };
569
+
570
+ await pi._listeners["session_start"][0]({}, ctx);
571
+ await new Promise((r) => setTimeout(r, 100));
572
+ expect(mockPollUpdates).toHaveBeenCalledTimes(1);
573
+
574
+ // Fire again — should not create another listener
575
+ await pi._listeners["session_start"][0]({}, ctx);
576
+ await new Promise((r) => setTimeout(r, 100));
577
+ expect(mockPollUpdates).toHaveBeenCalledTimes(1);
578
+ });
559
579
  });