@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/package.json +1 -1
- package/src/__tests__/index.test.ts +503 -483
- package/src/helpers.ts +22 -5
- package/src/index.ts +382 -341
- package/src/templates.ts +789 -0
- package/src/tools/telegram.ts +201 -8
|
@@ -12,40 +12,41 @@ const TEST_CHAT_ID = "999";
|
|
|
12
12
|
// ── Mock Extension API ───────────────────────────────────────────
|
|
13
13
|
|
|
14
14
|
function createMockPI() {
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
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
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
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
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
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
|
-
|
|
94
|
-
|
|
100
|
+
vi.unstubAllEnvs();
|
|
101
|
+
vi.clearAllMocks();
|
|
95
102
|
});
|
|
96
103
|
|
|
97
104
|
// ── Tool Registration ─────────────────────────────────────────────
|
|
98
105
|
|
|
99
106
|
describe("extension registration", () => {
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
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
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
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
|
-
|
|
234
|
-
|
|
235
|
-
|
|
247
|
+
it("sends a message and returns success", async () => {
|
|
248
|
+
const pi = createMockPI();
|
|
249
|
+
telegramBridge(pi as any);
|
|
236
250
|
|
|
237
|
-
|
|
238
|
-
|
|
251
|
+
const tool = pi._getTool("telegram_send");
|
|
252
|
+
const result = await tool.execute("id-1", { message: "Test message" });
|
|
239
253
|
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
254
|
+
expect(mockSendMsg).toHaveBeenCalledWith("Test message");
|
|
255
|
+
expect(result.content[0].text).toContain("Sent");
|
|
256
|
+
expect(result.isError).toBeFalsy();
|
|
257
|
+
});
|
|
244
258
|
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
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
|
-
|
|
251
|
-
|
|
264
|
+
const tool = pi._getTool("telegram_send");
|
|
265
|
+
const result = await tool.execute("id-1", { message: "Test" });
|
|
252
266
|
|
|
253
|
-
|
|
254
|
-
|
|
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
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
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
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
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
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
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
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
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
|
});
|