macroclaw 0.0.0-dev
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/LICENSE +21 -0
- package/README.md +114 -0
- package/bin/macroclaw.js +2 -0
- package/package.json +41 -0
- package/src/app.test.ts +699 -0
- package/src/app.ts +164 -0
- package/src/claude.integration-test.ts +108 -0
- package/src/claude.test.ts +247 -0
- package/src/claude.ts +136 -0
- package/src/cron.test.ts +265 -0
- package/src/cron.ts +108 -0
- package/src/history.test.ts +92 -0
- package/src/history.ts +37 -0
- package/src/index.ts +42 -0
- package/src/logger.test.ts +33 -0
- package/src/logger.ts +28 -0
- package/src/orchestrator.test.ts +631 -0
- package/src/orchestrator.ts +396 -0
- package/src/prompts.test.ts +43 -0
- package/src/prompts.ts +48 -0
- package/src/queue.test.ts +150 -0
- package/src/queue.ts +42 -0
- package/src/settings.test.ts +55 -0
- package/src/settings.ts +36 -0
- package/src/stt.ts +31 -0
- package/src/telegram.test.ts +283 -0
- package/src/telegram.ts +121 -0
- package/src/test-setup.ts +1 -0
- package/workspace-template/.claude/hooks/pre-compact.sh +2 -0
- package/workspace-template/.claude/settings.json +19 -0
- package/workspace-template/.claude/skills/add-cronjob/SKILL.md +77 -0
- package/workspace-template/.macroclaw/cron.json +16 -0
- package/workspace-template/CLAUDE.md +97 -0
- package/workspace-template/MEMORY.md +3 -0
package/src/app.test.ts
ADDED
|
@@ -0,0 +1,699 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, it, mock } from "bun:test";
|
|
2
|
+
import { existsSync, rmSync } from "node:fs";
|
|
3
|
+
import { App, type AppConfig } from "./app";
|
|
4
|
+
import type { Claude, ClaudeDeferredResult, ClaudeResult, ClaudeRunOptions } from "./claude";
|
|
5
|
+
import { saveSettings } from "./settings";
|
|
6
|
+
|
|
7
|
+
const mockOpenAICreate = mock(async () => ({ text: "transcribed text" }));
|
|
8
|
+
|
|
9
|
+
mock.module("openai", () => ({
|
|
10
|
+
default: class MockOpenAI {
|
|
11
|
+
audio = {
|
|
12
|
+
transcriptions: {
|
|
13
|
+
create: mockOpenAICreate,
|
|
14
|
+
},
|
|
15
|
+
};
|
|
16
|
+
},
|
|
17
|
+
}));
|
|
18
|
+
|
|
19
|
+
// Mock Grammy Bot
|
|
20
|
+
mock.module("grammy", () => ({
|
|
21
|
+
Bot: class MockBot {
|
|
22
|
+
token: string;
|
|
23
|
+
commandHandlers = new Map<string, Function>();
|
|
24
|
+
filterHandlers = new Map<string, Function[]>();
|
|
25
|
+
errorHandler: Function | null = null;
|
|
26
|
+
|
|
27
|
+
api = {
|
|
28
|
+
sendMessage: mock(async () => {}),
|
|
29
|
+
sendChatAction: mock(async () => {}),
|
|
30
|
+
setMyCommands: mock(async () => {}),
|
|
31
|
+
getFile: mock(async () => ({ file_path: "photos/test.jpg" })),
|
|
32
|
+
sendPhoto: mock(async () => {}),
|
|
33
|
+
sendDocument: mock(async () => {}),
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
constructor(token: string) {
|
|
37
|
+
this.token = token;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
command(name: string, handler: Function) {
|
|
41
|
+
this.commandHandlers.set(name, handler);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
on(filter: string, handler: Function) {
|
|
45
|
+
const existing = this.filterHandlers.get(filter) || [];
|
|
46
|
+
existing.push(handler);
|
|
47
|
+
this.filterHandlers.set(filter, existing);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
catch(handler: Function) {
|
|
51
|
+
this.errorHandler = handler;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
start(opts: { onStart: (info: any) => void }) {
|
|
55
|
+
opts.onStart({ username: "test_bot", id: 123 });
|
|
56
|
+
}
|
|
57
|
+
},
|
|
58
|
+
}));
|
|
59
|
+
|
|
60
|
+
const tmpSettingsDir = "/tmp/macroclaw-test-settings";
|
|
61
|
+
|
|
62
|
+
const savedOpenAIKey = process.env.OPENAI_API_KEY;
|
|
63
|
+
|
|
64
|
+
beforeEach(() => {
|
|
65
|
+
process.env.OPENAI_API_KEY = "test-key";
|
|
66
|
+
if (existsSync(tmpSettingsDir)) rmSync(tmpSettingsDir, { recursive: true });
|
|
67
|
+
saveSettings({ sessionId: "test-session" }, tmpSettingsDir);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
afterEach(() => {
|
|
71
|
+
if (savedOpenAIKey) process.env.OPENAI_API_KEY = savedOpenAIKey;
|
|
72
|
+
else delete process.env.OPENAI_API_KEY;
|
|
73
|
+
if (existsSync(tmpSettingsDir)) rmSync(tmpSettingsDir, { recursive: true });
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
function successResult(output: unknown, sessionId = "test-session-id"): ClaudeResult {
|
|
77
|
+
return { structuredOutput: output, sessionId, duration: "1.0s", cost: "$0.05" };
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function mockClaude(handler: (opts: ClaudeRunOptions) => Promise<ClaudeResult | ClaudeDeferredResult>): Claude {
|
|
81
|
+
const claude = { run: mock(handler) };
|
|
82
|
+
return claude as unknown as Claude;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function makeConfig(overrides?: Partial<AppConfig>): AppConfig {
|
|
86
|
+
return {
|
|
87
|
+
botToken: "test-token",
|
|
88
|
+
authorizedChatId: "12345",
|
|
89
|
+
workspace: "/tmp/macroclaw-test-workspace",
|
|
90
|
+
settingsDir: tmpSettingsDir,
|
|
91
|
+
claude: mockClaude(async (opts: ClaudeRunOptions): Promise<ClaudeResult> =>
|
|
92
|
+
successResult({ action: "send", message: `Response to: ${opts.prompt}`, actionReason: "user message" }),
|
|
93
|
+
),
|
|
94
|
+
...overrides,
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
describe("App", () => {
|
|
99
|
+
it("creates bot", () => {
|
|
100
|
+
const app = new App(makeConfig());
|
|
101
|
+
expect(app.bot).toBeDefined();
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it("registers message:text, message:photo, message:document, message:voice, and callback_query:data handlers", () => {
|
|
105
|
+
const app = new App(makeConfig());
|
|
106
|
+
const bot = app.bot as any;
|
|
107
|
+
expect(bot.filterHandlers.has("message:text")).toBe(true);
|
|
108
|
+
expect(bot.filterHandlers.has("message:photo")).toBe(true);
|
|
109
|
+
expect(bot.filterHandlers.has("message:document")).toBe(true);
|
|
110
|
+
expect(bot.filterHandlers.has("message:voice")).toBe(true);
|
|
111
|
+
expect(bot.filterHandlers.has("callback_query:data")).toBe(true);
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
it("registers chatid, session, and bg commands", () => {
|
|
115
|
+
const app = new App(makeConfig());
|
|
116
|
+
const bot = app.bot as any;
|
|
117
|
+
expect(bot.commandHandlers.has("chatid")).toBe(true);
|
|
118
|
+
expect(bot.commandHandlers.has("session")).toBe(true);
|
|
119
|
+
expect(bot.commandHandlers.has("bg")).toBe(true);
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
it("registers error handler", () => {
|
|
123
|
+
const app = new App(makeConfig());
|
|
124
|
+
const bot = app.bot as any;
|
|
125
|
+
expect(bot.errorHandler).not.toBeNull();
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
describe("message handler", () => {
|
|
129
|
+
it("routes messages from authorized chat to orchestrator", async () => {
|
|
130
|
+
const config = makeConfig();
|
|
131
|
+
const app = new App(config);
|
|
132
|
+
const bot = app.bot as any;
|
|
133
|
+
const handler = bot.filterHandlers.get("message:text")![0];
|
|
134
|
+
|
|
135
|
+
handler({ chat: { id: 12345 }, message: { text: "hello" } });
|
|
136
|
+
await new Promise((r) => setTimeout(r, 50));
|
|
137
|
+
|
|
138
|
+
const claude = config.claude as any;
|
|
139
|
+
const opts = claude.run.mock.calls[0][0] as ClaudeRunOptions;
|
|
140
|
+
expect(opts.prompt).toBe("hello");
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
it("ignores messages from unauthorized chats", async () => {
|
|
144
|
+
const config = makeConfig();
|
|
145
|
+
const app = new App(config);
|
|
146
|
+
const bot = app.bot as any;
|
|
147
|
+
const handler = bot.filterHandlers.get("message:text")![0];
|
|
148
|
+
|
|
149
|
+
handler({ chat: { id: 99999 }, message: { text: "hello" } });
|
|
150
|
+
await new Promise((r) => setTimeout(r, 50));
|
|
151
|
+
|
|
152
|
+
expect((config.claude as any).run).not.toHaveBeenCalled();
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
it("sends [No output] for empty claude response", async () => {
|
|
156
|
+
const config = makeConfig({
|
|
157
|
+
claude: mockClaude(async (): Promise<ClaudeResult> => successResult({ action: "send", message: "", actionReason: "empty" })),
|
|
158
|
+
});
|
|
159
|
+
const app = new App(config);
|
|
160
|
+
const bot = app.bot as any;
|
|
161
|
+
const handler = bot.filterHandlers.get("message:text")![0];
|
|
162
|
+
|
|
163
|
+
handler({ chat: { id: 12345 }, message: { text: "hello" } });
|
|
164
|
+
await new Promise((r) => setTimeout(r, 50));
|
|
165
|
+
|
|
166
|
+
const calls = (bot.api.sendMessage as any).mock.calls;
|
|
167
|
+
const lastText = calls[calls.length - 1][1];
|
|
168
|
+
expect(lastText).toBe("[No output]");
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
it("skips sending when action is silent", async () => {
|
|
172
|
+
const config = makeConfig({
|
|
173
|
+
claude: mockClaude(async (): Promise<ClaudeResult> => successResult({ action: "silent", actionReason: "no new results" })),
|
|
174
|
+
});
|
|
175
|
+
const app = new App(config);
|
|
176
|
+
const bot = app.bot as any;
|
|
177
|
+
const handler = bot.filterHandlers.get("message:text")![0];
|
|
178
|
+
|
|
179
|
+
handler({ chat: { id: 12345 }, message: { text: "hello" } });
|
|
180
|
+
await new Promise((r) => setTimeout(r, 50));
|
|
181
|
+
|
|
182
|
+
expect(bot.api.sendMessage).not.toHaveBeenCalled();
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
it("does not treat bg: prefix as special", async () => {
|
|
186
|
+
const config = makeConfig();
|
|
187
|
+
const app = new App(config);
|
|
188
|
+
const bot = app.bot as any;
|
|
189
|
+
const handler = bot.filterHandlers.get("message:text")![0];
|
|
190
|
+
|
|
191
|
+
handler({ chat: { id: 12345 }, message: { text: "bg: research pricing" } });
|
|
192
|
+
await new Promise((r) => setTimeout(r, 50));
|
|
193
|
+
|
|
194
|
+
const opts = (config.claude as any).run.mock.calls[0][0] as ClaudeRunOptions;
|
|
195
|
+
expect(opts.prompt).toBe("bg: research pricing");
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
it("sends error wrapped in ClaudeResponse", async () => {
|
|
199
|
+
const { ClaudeProcessError } = await import("./claude");
|
|
200
|
+
const config = makeConfig({
|
|
201
|
+
claude: mockClaude(async (): Promise<ClaudeResult> => {
|
|
202
|
+
throw new ClaudeProcessError(1, "spawn failed");
|
|
203
|
+
}),
|
|
204
|
+
});
|
|
205
|
+
const app = new App(config);
|
|
206
|
+
const bot = app.bot as any;
|
|
207
|
+
const handler = bot.filterHandlers.get("message:text")![0];
|
|
208
|
+
|
|
209
|
+
handler({ chat: { id: 12345 }, message: { text: "hello" } });
|
|
210
|
+
await new Promise((r) => setTimeout(r, 50));
|
|
211
|
+
|
|
212
|
+
const calls = (bot.api.sendMessage as any).mock.calls;
|
|
213
|
+
const lastText = calls[calls.length - 1][1];
|
|
214
|
+
expect(lastText).toContain("[Error]");
|
|
215
|
+
expect(lastText).toContain("spawn failed");
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
it("handles photo messages by downloading and routing with files", async () => {
|
|
219
|
+
const origFetch = globalThis.fetch;
|
|
220
|
+
globalThis.fetch = mock(() =>
|
|
221
|
+
Promise.resolve(new Response("fake-image", { status: 200 })),
|
|
222
|
+
) as any;
|
|
223
|
+
|
|
224
|
+
const config = makeConfig();
|
|
225
|
+
const app = new App(config);
|
|
226
|
+
const bot = app.bot as any;
|
|
227
|
+
const handler = bot.filterHandlers.get("message:photo")![0];
|
|
228
|
+
|
|
229
|
+
await handler({
|
|
230
|
+
chat: { id: 12345 },
|
|
231
|
+
message: {
|
|
232
|
+
caption: "check this",
|
|
233
|
+
photo: [
|
|
234
|
+
{ file_id: "small", width: 90, height: 90 },
|
|
235
|
+
{ file_id: "large", width: 800, height: 600 },
|
|
236
|
+
],
|
|
237
|
+
},
|
|
238
|
+
});
|
|
239
|
+
await new Promise((r) => setTimeout(r, 50));
|
|
240
|
+
|
|
241
|
+
expect(bot.api.getFile).toHaveBeenCalledWith("large");
|
|
242
|
+
expect((config.claude as any).run).toHaveBeenCalled();
|
|
243
|
+
const opts = (config.claude as any).run.mock.calls[0][0] as ClaudeRunOptions;
|
|
244
|
+
expect(opts.prompt).toContain("[File:");
|
|
245
|
+
|
|
246
|
+
globalThis.fetch = origFetch;
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
it("handles document messages by downloading and routing with files", async () => {
|
|
250
|
+
const origFetch = globalThis.fetch;
|
|
251
|
+
globalThis.fetch = mock(() =>
|
|
252
|
+
Promise.resolve(new Response("fake-doc", { status: 200 })),
|
|
253
|
+
) as any;
|
|
254
|
+
|
|
255
|
+
const config = makeConfig();
|
|
256
|
+
const app = new App(config);
|
|
257
|
+
const bot = app.bot as any;
|
|
258
|
+
const handler = bot.filterHandlers.get("message:document")![0];
|
|
259
|
+
|
|
260
|
+
await handler({
|
|
261
|
+
chat: { id: 12345 },
|
|
262
|
+
message: {
|
|
263
|
+
caption: "review this",
|
|
264
|
+
document: { file_id: "doc-id", file_name: "report.pdf" },
|
|
265
|
+
},
|
|
266
|
+
});
|
|
267
|
+
await new Promise((r) => setTimeout(r, 50));
|
|
268
|
+
|
|
269
|
+
expect(bot.api.getFile).toHaveBeenCalledWith("doc-id");
|
|
270
|
+
expect((config.claude as any).run).toHaveBeenCalled();
|
|
271
|
+
const opts = (config.claude as any).run.mock.calls[0][0] as ClaudeRunOptions;
|
|
272
|
+
expect(opts.prompt).toContain("[File:");
|
|
273
|
+
|
|
274
|
+
globalThis.fetch = origFetch;
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
it("routes error message when photo download fails", async () => {
|
|
278
|
+
const config = makeConfig();
|
|
279
|
+
const app = new App(config);
|
|
280
|
+
const bot = app.bot as any;
|
|
281
|
+
bot.api.getFile = mock(async () => { throw new Error("too large"); });
|
|
282
|
+
const handler = bot.filterHandlers.get("message:photo")![0];
|
|
283
|
+
|
|
284
|
+
await handler({
|
|
285
|
+
chat: { id: 12345 },
|
|
286
|
+
message: { caption: "big photo", photo: [{ file_id: "big" }] },
|
|
287
|
+
});
|
|
288
|
+
await new Promise((r) => setTimeout(r, 50));
|
|
289
|
+
|
|
290
|
+
expect((config.claude as any).run).toHaveBeenCalled();
|
|
291
|
+
const opts = (config.claude as any).run.mock.calls[0][0] as ClaudeRunOptions;
|
|
292
|
+
expect(opts.prompt).toContain("[File download failed: photo.jpg]");
|
|
293
|
+
});
|
|
294
|
+
|
|
295
|
+
it("routes error message when document download fails", async () => {
|
|
296
|
+
const config = makeConfig();
|
|
297
|
+
const app = new App(config);
|
|
298
|
+
const bot = app.bot as any;
|
|
299
|
+
bot.api.getFile = mock(async () => { throw new Error("too large"); });
|
|
300
|
+
const handler = bot.filterHandlers.get("message:document")![0];
|
|
301
|
+
|
|
302
|
+
await handler({
|
|
303
|
+
chat: { id: 12345 },
|
|
304
|
+
message: { caption: "big doc", document: { file_id: "big", file_name: "huge.pdf" } },
|
|
305
|
+
});
|
|
306
|
+
await new Promise((r) => setTimeout(r, 50));
|
|
307
|
+
|
|
308
|
+
expect((config.claude as any).run).toHaveBeenCalled();
|
|
309
|
+
const opts = (config.claude as any).run.mock.calls[0][0] as ClaudeRunOptions;
|
|
310
|
+
expect(opts.prompt).toContain("[File download failed: huge.pdf]");
|
|
311
|
+
});
|
|
312
|
+
|
|
313
|
+
it("handles voice messages by transcribing and routing text to orchestrator", async () => {
|
|
314
|
+
const origFetch = globalThis.fetch;
|
|
315
|
+
globalThis.fetch = mock(() =>
|
|
316
|
+
Promise.resolve(new Response("fake-audio", { status: 200 })),
|
|
317
|
+
) as any;
|
|
318
|
+
mockOpenAICreate.mockImplementationOnce(async () => ({ text: "hello from voice" }));
|
|
319
|
+
|
|
320
|
+
const config = makeConfig();
|
|
321
|
+
const app = new App(config);
|
|
322
|
+
const bot = app.bot as any;
|
|
323
|
+
const handler = bot.filterHandlers.get("message:voice")![0];
|
|
324
|
+
|
|
325
|
+
await handler({
|
|
326
|
+
chat: { id: 12345 },
|
|
327
|
+
message: { voice: { file_id: "voice-id", duration: 5 } },
|
|
328
|
+
});
|
|
329
|
+
await new Promise((r) => setTimeout(r, 50));
|
|
330
|
+
|
|
331
|
+
// Should echo transcription to user
|
|
332
|
+
const sendCalls = (bot.api.sendMessage as any).mock.calls;
|
|
333
|
+
const echoCall = sendCalls.find((c: any) => c[1].includes("[Received audio]"));
|
|
334
|
+
expect(echoCall).toBeDefined();
|
|
335
|
+
expect(echoCall[1]).toContain("hello from voice");
|
|
336
|
+
|
|
337
|
+
// Should route transcribed text to orchestrator
|
|
338
|
+
expect((config.claude as any).run).toHaveBeenCalled();
|
|
339
|
+
const opts = (config.claude as any).run.mock.calls[0][0] as ClaudeRunOptions;
|
|
340
|
+
expect(opts.prompt).toBe("hello from voice");
|
|
341
|
+
|
|
342
|
+
globalThis.fetch = origFetch;
|
|
343
|
+
});
|
|
344
|
+
|
|
345
|
+
it("sends error message when voice transcription fails", async () => {
|
|
346
|
+
const origFetch = globalThis.fetch;
|
|
347
|
+
globalThis.fetch = mock(() =>
|
|
348
|
+
Promise.resolve(new Response("fake-audio", { status: 200 })),
|
|
349
|
+
) as any;
|
|
350
|
+
mockOpenAICreate.mockImplementationOnce(async () => { throw new Error("API error"); });
|
|
351
|
+
|
|
352
|
+
const config = makeConfig();
|
|
353
|
+
const app = new App(config);
|
|
354
|
+
const bot = app.bot as any;
|
|
355
|
+
const handler = bot.filterHandlers.get("message:voice")![0];
|
|
356
|
+
|
|
357
|
+
await handler({
|
|
358
|
+
chat: { id: 12345 },
|
|
359
|
+
message: { voice: { file_id: "voice-id", duration: 5 } },
|
|
360
|
+
});
|
|
361
|
+
await new Promise((r) => setTimeout(r, 50));
|
|
362
|
+
|
|
363
|
+
const sendCalls = (bot.api.sendMessage as any).mock.calls;
|
|
364
|
+
const errorCall = sendCalls.find((c: any) => c[1].includes("[Failed to transcribe audio]"));
|
|
365
|
+
expect(errorCall).toBeDefined();
|
|
366
|
+
expect((config.claude as any).run).not.toHaveBeenCalled();
|
|
367
|
+
|
|
368
|
+
globalThis.fetch = origFetch;
|
|
369
|
+
});
|
|
370
|
+
|
|
371
|
+
it("sends message when voice transcription returns empty text", async () => {
|
|
372
|
+
const origFetch = globalThis.fetch;
|
|
373
|
+
globalThis.fetch = mock(() =>
|
|
374
|
+
Promise.resolve(new Response("fake-audio", { status: 200 })),
|
|
375
|
+
) as any;
|
|
376
|
+
mockOpenAICreate.mockImplementationOnce(async () => ({ text: " " }));
|
|
377
|
+
|
|
378
|
+
const config = makeConfig();
|
|
379
|
+
const app = new App(config);
|
|
380
|
+
const bot = app.bot as any;
|
|
381
|
+
const handler = bot.filterHandlers.get("message:voice")![0];
|
|
382
|
+
|
|
383
|
+
await handler({
|
|
384
|
+
chat: { id: 12345 },
|
|
385
|
+
message: { voice: { file_id: "voice-id", duration: 2 } },
|
|
386
|
+
});
|
|
387
|
+
await new Promise((r) => setTimeout(r, 50));
|
|
388
|
+
|
|
389
|
+
const sendCalls = (bot.api.sendMessage as any).mock.calls;
|
|
390
|
+
const emptyCall = sendCalls.find((c: any) => c[1].includes("[Could not understand audio]"));
|
|
391
|
+
expect(emptyCall).toBeDefined();
|
|
392
|
+
expect((config.claude as any).run).not.toHaveBeenCalled();
|
|
393
|
+
|
|
394
|
+
globalThis.fetch = origFetch;
|
|
395
|
+
});
|
|
396
|
+
|
|
397
|
+
it("ignores voice messages from unauthorized chats", async () => {
|
|
398
|
+
const config = makeConfig();
|
|
399
|
+
const app = new App(config);
|
|
400
|
+
const bot = app.bot as any;
|
|
401
|
+
const handler = bot.filterHandlers.get("message:voice")![0];
|
|
402
|
+
|
|
403
|
+
await handler({
|
|
404
|
+
chat: { id: 99999 },
|
|
405
|
+
message: { voice: { file_id: "voice-id", duration: 5 } },
|
|
406
|
+
});
|
|
407
|
+
await new Promise((r) => setTimeout(r, 50));
|
|
408
|
+
|
|
409
|
+
expect((config.claude as any).run).not.toHaveBeenCalled();
|
|
410
|
+
});
|
|
411
|
+
|
|
412
|
+
it("responds with unavailable message when OPENAI_API_KEY is not set", async () => {
|
|
413
|
+
delete process.env.OPENAI_API_KEY;
|
|
414
|
+
const config = makeConfig();
|
|
415
|
+
const app = new App(config);
|
|
416
|
+
const bot = app.bot as any;
|
|
417
|
+
const handler = bot.filterHandlers.get("message:voice")![0];
|
|
418
|
+
|
|
419
|
+
await handler({
|
|
420
|
+
chat: { id: 12345 },
|
|
421
|
+
message: { voice: { file_id: "voice-id", duration: 5 } },
|
|
422
|
+
});
|
|
423
|
+
|
|
424
|
+
const sendCalls = (bot.api.sendMessage as any).mock.calls;
|
|
425
|
+
const call = sendCalls.find((c: any) => c[1].includes("OPENAI_API_KEY"));
|
|
426
|
+
expect(call).toBeDefined();
|
|
427
|
+
expect((config.claude as any).run).not.toHaveBeenCalled();
|
|
428
|
+
});
|
|
429
|
+
|
|
430
|
+
it("ignores photo messages from unauthorized chats", async () => {
|
|
431
|
+
const config = makeConfig();
|
|
432
|
+
const app = new App(config);
|
|
433
|
+
const bot = app.bot as any;
|
|
434
|
+
const handler = bot.filterHandlers.get("message:photo")![0];
|
|
435
|
+
|
|
436
|
+
await handler({
|
|
437
|
+
chat: { id: 99999 },
|
|
438
|
+
message: { photo: [{ file_id: "x" }] },
|
|
439
|
+
});
|
|
440
|
+
await new Promise((r) => setTimeout(r, 50));
|
|
441
|
+
|
|
442
|
+
expect((config.claude as any).run).not.toHaveBeenCalled();
|
|
443
|
+
});
|
|
444
|
+
|
|
445
|
+
it("sends outbound files before text message (onResponse delivery)", async () => {
|
|
446
|
+
const tmpFile = `/tmp/macroclaw-test-outbound-${Date.now()}.png`;
|
|
447
|
+
await Bun.write(tmpFile, "fake png");
|
|
448
|
+
|
|
449
|
+
const config = makeConfig({
|
|
450
|
+
claude: mockClaude(async (): Promise<ClaudeResult> =>
|
|
451
|
+
successResult({
|
|
452
|
+
action: "send",
|
|
453
|
+
message: "Here's your chart",
|
|
454
|
+
actionReason: "ok",
|
|
455
|
+
files: [tmpFile],
|
|
456
|
+
}),
|
|
457
|
+
),
|
|
458
|
+
});
|
|
459
|
+
const app = new App(config);
|
|
460
|
+
const bot = app.bot as any;
|
|
461
|
+
const handler = bot.filterHandlers.get("message:text")![0];
|
|
462
|
+
|
|
463
|
+
handler({ chat: { id: 12345 }, message: { text: "make a chart" } });
|
|
464
|
+
await new Promise((r) => setTimeout(r, 50));
|
|
465
|
+
|
|
466
|
+
expect(bot.api.sendPhoto).toHaveBeenCalledTimes(1);
|
|
467
|
+
expect(bot.api.sendMessage).toHaveBeenCalled();
|
|
468
|
+
|
|
469
|
+
const { rm } = await import("node:fs/promises");
|
|
470
|
+
await rm(tmpFile, { force: true });
|
|
471
|
+
});
|
|
472
|
+
|
|
473
|
+
it("passes buttons to sendResponse (onResponse delivery)", async () => {
|
|
474
|
+
const config = makeConfig({
|
|
475
|
+
claude: mockClaude(async (): Promise<ClaudeResult> =>
|
|
476
|
+
successResult({
|
|
477
|
+
action: "send",
|
|
478
|
+
message: "Choose one",
|
|
479
|
+
actionReason: "ok",
|
|
480
|
+
buttons: ["Yes", "No"],
|
|
481
|
+
}),
|
|
482
|
+
),
|
|
483
|
+
});
|
|
484
|
+
const app = new App(config);
|
|
485
|
+
const bot = app.bot as any;
|
|
486
|
+
const handler = bot.filterHandlers.get("message:text")![0];
|
|
487
|
+
|
|
488
|
+
handler({ chat: { id: 12345 }, message: { text: "hello" } });
|
|
489
|
+
await new Promise((r) => setTimeout(r, 50));
|
|
490
|
+
|
|
491
|
+
const calls = (bot.api.sendMessage as any).mock.calls;
|
|
492
|
+
const lastCall = calls[calls.length - 1];
|
|
493
|
+
expect(lastCall[2].reply_markup).toBeDefined();
|
|
494
|
+
});
|
|
495
|
+
|
|
496
|
+
it("handles callback_query by routing button event to orchestrator", async () => {
|
|
497
|
+
const config = makeConfig();
|
|
498
|
+
const app = new App(config);
|
|
499
|
+
const bot = app.bot as any;
|
|
500
|
+
const handler = bot.filterHandlers.get("callback_query:data")![0];
|
|
501
|
+
|
|
502
|
+
const ctx = {
|
|
503
|
+
chat: { id: 12345 },
|
|
504
|
+
callbackQuery: { data: "Yes", message: { text: "Choose one" } },
|
|
505
|
+
answerCallbackQuery: mock(async () => {}),
|
|
506
|
+
editMessageReplyMarkup: mock(async () => {}),
|
|
507
|
+
};
|
|
508
|
+
|
|
509
|
+
await handler(ctx);
|
|
510
|
+
await new Promise((r) => setTimeout(r, 50));
|
|
511
|
+
|
|
512
|
+
expect(ctx.answerCallbackQuery).toHaveBeenCalled();
|
|
513
|
+
expect(ctx.editMessageReplyMarkup).toHaveBeenCalledWith({ reply_markup: { inline_keyboard: [[{ text: "✓ Yes", callback_data: "_noop" }]] } });
|
|
514
|
+
expect((config.claude as any).run).toHaveBeenCalled();
|
|
515
|
+
const opts = (config.claude as any).run.mock.calls[0][0] as ClaudeRunOptions;
|
|
516
|
+
expect(opts.prompt).toBe('[Context: button-click] User tapped "Yes"');
|
|
517
|
+
});
|
|
518
|
+
|
|
519
|
+
it("ignores callback_query from unauthorized chats", async () => {
|
|
520
|
+
const config = makeConfig();
|
|
521
|
+
const app = new App(config);
|
|
522
|
+
const bot = app.bot as any;
|
|
523
|
+
const handler = bot.filterHandlers.get("callback_query:data")![0];
|
|
524
|
+
|
|
525
|
+
const ctx = {
|
|
526
|
+
chat: { id: 99999 },
|
|
527
|
+
callbackQuery: { data: "Yes", message: { text: "Choose" } },
|
|
528
|
+
answerCallbackQuery: mock(async () => {}),
|
|
529
|
+
editMessageReplyMarkup: mock(async () => {}),
|
|
530
|
+
};
|
|
531
|
+
|
|
532
|
+
await handler(ctx);
|
|
533
|
+
await new Promise((r) => setTimeout(r, 50));
|
|
534
|
+
|
|
535
|
+
expect(ctx.answerCallbackQuery).toHaveBeenCalled();
|
|
536
|
+
expect((config.claude as any).run).not.toHaveBeenCalled();
|
|
537
|
+
});
|
|
538
|
+
|
|
539
|
+
it("skips outbound files that don't exist", async () => {
|
|
540
|
+
const config = makeConfig({
|
|
541
|
+
claude: mockClaude(async (): Promise<ClaudeResult> =>
|
|
542
|
+
successResult({
|
|
543
|
+
action: "send",
|
|
544
|
+
message: "Done",
|
|
545
|
+
actionReason: "ok",
|
|
546
|
+
files: ["/tmp/nonexistent-xyz.png"],
|
|
547
|
+
}),
|
|
548
|
+
),
|
|
549
|
+
});
|
|
550
|
+
const app = new App(config);
|
|
551
|
+
const bot = app.bot as any;
|
|
552
|
+
const handler = bot.filterHandlers.get("message:text")![0];
|
|
553
|
+
|
|
554
|
+
handler({ chat: { id: 12345 }, message: { text: "hello" } });
|
|
555
|
+
await new Promise((r) => setTimeout(r, 50));
|
|
556
|
+
|
|
557
|
+
expect(bot.api.sendPhoto).not.toHaveBeenCalled();
|
|
558
|
+
expect(bot.api.sendMessage).toHaveBeenCalled();
|
|
559
|
+
});
|
|
560
|
+
});
|
|
561
|
+
|
|
562
|
+
describe("commands", () => {
|
|
563
|
+
it("/chatid replies with chat ID", () => {
|
|
564
|
+
const app = new App(makeConfig());
|
|
565
|
+
const bot = app.bot as any;
|
|
566
|
+
const handler = bot.commandHandlers.get("chatid")!;
|
|
567
|
+
const ctx = { chat: { id: 12345 }, reply: mock(() => {}) };
|
|
568
|
+
|
|
569
|
+
handler(ctx);
|
|
570
|
+
expect(ctx.reply).toHaveBeenCalledWith("Chat ID: `12345`", { parse_mode: "Markdown" });
|
|
571
|
+
});
|
|
572
|
+
|
|
573
|
+
it("/session sends session ID via sendMessage", async () => {
|
|
574
|
+
const app = new App(makeConfig());
|
|
575
|
+
const bot = app.bot as any;
|
|
576
|
+
const handler = bot.commandHandlers.get("session")!;
|
|
577
|
+
const ctx = { chat: { id: 12345 } };
|
|
578
|
+
|
|
579
|
+
handler(ctx);
|
|
580
|
+
await new Promise((r) => setTimeout(r, 50));
|
|
581
|
+
|
|
582
|
+
const calls = (bot.api.sendMessage as any).mock.calls;
|
|
583
|
+
expect(calls.length).toBeGreaterThan(0);
|
|
584
|
+
const text = calls[calls.length - 1][1];
|
|
585
|
+
expect(text).toBe("Session: <code>test-session</code>");
|
|
586
|
+
});
|
|
587
|
+
|
|
588
|
+
it("/session is ignored for unauthorized chats", async () => {
|
|
589
|
+
const app = new App(makeConfig());
|
|
590
|
+
const bot = app.bot as any;
|
|
591
|
+
const handler = bot.commandHandlers.get("session")!;
|
|
592
|
+
const ctx = { chat: { id: 99999 } };
|
|
593
|
+
|
|
594
|
+
handler(ctx);
|
|
595
|
+
await new Promise((r) => setTimeout(r, 50));
|
|
596
|
+
|
|
597
|
+
expect((bot.api.sendMessage as any).mock.calls.length).toBe(0);
|
|
598
|
+
});
|
|
599
|
+
|
|
600
|
+
it("/bg shows no agents via sendMessage when none are running", async () => {
|
|
601
|
+
const app = new App(makeConfig());
|
|
602
|
+
const bot = app.bot as any;
|
|
603
|
+
const handler = bot.commandHandlers.get("bg")!;
|
|
604
|
+
const ctx = { chat: { id: 12345 }, match: "" };
|
|
605
|
+
|
|
606
|
+
handler(ctx);
|
|
607
|
+
await new Promise((r) => setTimeout(r, 50));
|
|
608
|
+
|
|
609
|
+
const calls = (bot.api.sendMessage as any).mock.calls;
|
|
610
|
+
const text = calls[calls.length - 1][1];
|
|
611
|
+
expect(text).toBe("No background agents running.");
|
|
612
|
+
});
|
|
613
|
+
|
|
614
|
+
it("/bg with prompt spawns a background agent via sendMessage", async () => {
|
|
615
|
+
const config = makeConfig({
|
|
616
|
+
claude: mockClaude(() => new Promise<ClaudeResult>(() => {})),
|
|
617
|
+
});
|
|
618
|
+
const app = new App(config);
|
|
619
|
+
const bot = app.bot as any;
|
|
620
|
+
const handler = bot.commandHandlers.get("bg")!;
|
|
621
|
+
const ctx = { chat: { id: 12345 }, match: "research pricing" };
|
|
622
|
+
|
|
623
|
+
handler(ctx);
|
|
624
|
+
await new Promise((r) => setTimeout(r, 50));
|
|
625
|
+
|
|
626
|
+
const calls = (bot.api.sendMessage as any).mock.calls;
|
|
627
|
+
const text = calls[calls.length - 1][1];
|
|
628
|
+
expect(text).toBe('Background agent "research-pricing" started.');
|
|
629
|
+
expect((config.claude as any).run).toHaveBeenCalledTimes(1);
|
|
630
|
+
});
|
|
631
|
+
|
|
632
|
+
it("/bg lists active background agents via sendMessage", async () => {
|
|
633
|
+
const config = makeConfig({
|
|
634
|
+
claude: mockClaude(() => new Promise<ClaudeResult>(() => {})),
|
|
635
|
+
});
|
|
636
|
+
const app = new App(config);
|
|
637
|
+
const bot = app.bot as any;
|
|
638
|
+
const bgHandler = bot.commandHandlers.get("bg")!;
|
|
639
|
+
|
|
640
|
+
// Spawn via /bg command
|
|
641
|
+
const spawnCtx = { chat: { id: 12345 }, match: "long task" };
|
|
642
|
+
bgHandler(spawnCtx);
|
|
643
|
+
await new Promise((r) => setTimeout(r, 10));
|
|
644
|
+
|
|
645
|
+
// List via /bg with no args
|
|
646
|
+
const listCtx = { chat: { id: 12345 }, match: "" };
|
|
647
|
+
bgHandler(listCtx);
|
|
648
|
+
await new Promise((r) => setTimeout(r, 10));
|
|
649
|
+
|
|
650
|
+
const calls = (bot.api.sendMessage as any).mock.calls;
|
|
651
|
+
const lastText = calls[calls.length - 1][1];
|
|
652
|
+
expect(lastText).toContain("long-task");
|
|
653
|
+
expect(lastText).toMatch(/\d+s/);
|
|
654
|
+
});
|
|
655
|
+
|
|
656
|
+
it("generates new session ID when settings is empty", async () => {
|
|
657
|
+
if (existsSync(tmpSettingsDir)) rmSync(tmpSettingsDir, { recursive: true });
|
|
658
|
+
const app = new App(makeConfig());
|
|
659
|
+
const bot = app.bot as any;
|
|
660
|
+
const handler = bot.commandHandlers.get("session")!;
|
|
661
|
+
const ctx = { chat: { id: 12345 } };
|
|
662
|
+
|
|
663
|
+
handler(ctx);
|
|
664
|
+
await new Promise((r) => setTimeout(r, 50));
|
|
665
|
+
|
|
666
|
+
const calls = (bot.api.sendMessage as any).mock.calls;
|
|
667
|
+
const text = calls[calls.length - 1][1];
|
|
668
|
+
expect(text).toMatch(/Session: <code>[0-9a-f]{8}-/);
|
|
669
|
+
});
|
|
670
|
+
});
|
|
671
|
+
|
|
672
|
+
describe("error handler", () => {
|
|
673
|
+
it("does not throw on bot errors", () => {
|
|
674
|
+
const app = new App(makeConfig());
|
|
675
|
+
const bot = app.bot as any;
|
|
676
|
+
|
|
677
|
+
expect(() => bot.errorHandler({ message: "connection lost" })).not.toThrow();
|
|
678
|
+
});
|
|
679
|
+
});
|
|
680
|
+
|
|
681
|
+
describe("start", () => {
|
|
682
|
+
it("starts the bot and logs info", () => {
|
|
683
|
+
const app = new App(makeConfig());
|
|
684
|
+
expect(() => app.start()).not.toThrow();
|
|
685
|
+
});
|
|
686
|
+
|
|
687
|
+
it("registers commands with Telegram on start", () => {
|
|
688
|
+
const app = new App(makeConfig());
|
|
689
|
+
const bot = app.bot as any;
|
|
690
|
+
|
|
691
|
+
app.start();
|
|
692
|
+
expect(bot.api.setMyCommands).toHaveBeenCalledWith([
|
|
693
|
+
{ command: "chatid", description: "Show current chat ID" },
|
|
694
|
+
{ command: "session", description: "Show current session ID" },
|
|
695
|
+
{ command: "bg", description: "List or spawn background agents" },
|
|
696
|
+
]);
|
|
697
|
+
});
|
|
698
|
+
});
|
|
699
|
+
});
|