macroclaw 0.45.0 → 0.47.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/README.md +46 -13
- package/package.json +1 -1
- package/src/app.test.ts +388 -5
- package/src/app.ts +216 -45
- package/src/authorized-chats.test.ts +196 -0
- package/src/authorized-chats.ts +122 -0
- package/src/claude.test.ts +1 -1
- package/src/claude.ts +1 -1
- package/src/cli.test.ts +7 -7
- package/src/cli.ts +3 -3
- package/src/index.ts +1 -1
- package/src/orchestrator.test.ts +25 -25
- package/src/orchestrator.ts +10 -6
- package/src/prompt-builder.test.ts +28 -3
- package/src/prompt-builder.ts +20 -11
- package/src/scheduler.test.ts +36 -10
- package/src/scheduler.ts +7 -6
- package/src/sessions.test.ts +85 -19
- package/src/sessions.ts +33 -5
- package/src/settings.test.ts +40 -16
- package/src/settings.ts +29 -5
- package/src/setup.test.ts +12 -6
- package/src/setup.ts +8 -7
- package/workspace-template/.claude/skills/{schedule → schedule-event}/SKILL.md +29 -8
- package/workspace-template/CLAUDE.md +3 -1
- package/workspace-template/data/schedule.json +2 -0
package/src/app.ts
CHANGED
|
@@ -1,15 +1,19 @@
|
|
|
1
1
|
import type { Bot } from "grammy";
|
|
2
|
+
import { AuthorizedChats, DuplicateChatError, InvalidChatNameError, UnknownChatError } from "./authorized-chats";
|
|
2
3
|
import { createLogger } from "./logger";
|
|
3
|
-
import { type Claude, Orchestrator, type OrchestratorResponse } from "./orchestrator";
|
|
4
|
-
import { Scheduler } from "./scheduler";
|
|
4
|
+
import { type ButtonSpec, type Claude, Orchestrator, type OrchestratorResponse } from "./orchestrator";
|
|
5
|
+
import { type MissedInfo, Scheduler } from "./scheduler";
|
|
6
|
+
import { clearMainSession } from "./sessions";
|
|
5
7
|
import type { SpeechToText } from "./speech-to-text";
|
|
6
8
|
import { createBot, downloadFile, sendFile, sendResponse } from "./telegram";
|
|
7
9
|
|
|
8
10
|
const log = createLogger("app");
|
|
9
11
|
|
|
12
|
+
const ADMIN_CHAT_NAME = "admin";
|
|
13
|
+
|
|
10
14
|
export interface AppConfig {
|
|
11
15
|
botToken: string;
|
|
12
|
-
|
|
16
|
+
adminChatId: string;
|
|
13
17
|
workspace: string;
|
|
14
18
|
model: string;
|
|
15
19
|
timeZone: string;
|
|
@@ -17,25 +21,25 @@ export interface AppConfig {
|
|
|
17
21
|
claude?: Claude;
|
|
18
22
|
stt?: SpeechToText;
|
|
19
23
|
healthCheckInterval?: number;
|
|
24
|
+
/** Injected for tests. If omitted, a fresh AuthorizedChats is constructed from settingsDir. */
|
|
25
|
+
authorizedChats?: AuthorizedChats;
|
|
20
26
|
}
|
|
21
27
|
|
|
22
28
|
export class App {
|
|
23
29
|
#bot: Bot;
|
|
24
|
-
#orchestrator: Orchestrator;
|
|
25
30
|
#config: AppConfig;
|
|
31
|
+
#authorizedChats: AuthorizedChats;
|
|
32
|
+
#orchestrators = new Map<string, Orchestrator>();
|
|
26
33
|
|
|
27
34
|
constructor(config: AppConfig) {
|
|
28
35
|
this.#config = config;
|
|
29
36
|
this.#bot = createBot(config.botToken);
|
|
30
|
-
this.#
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
healthCheckInterval: config.healthCheckInterval,
|
|
37
|
-
onResponse: (r) => this.#deliverResponse(r),
|
|
38
|
-
});
|
|
37
|
+
this.#authorizedChats = config.authorizedChats ?? new AuthorizedChats(config.settingsDir);
|
|
38
|
+
|
|
39
|
+
this.#createOrchestrator(ADMIN_CHAT_NAME, config.adminChatId);
|
|
40
|
+
for (const chat of this.#authorizedChats.list()) {
|
|
41
|
+
this.#createOrchestrator(chat.name, chat.chatId);
|
|
42
|
+
}
|
|
39
43
|
|
|
40
44
|
this.#setupHandlers();
|
|
41
45
|
}
|
|
@@ -45,14 +49,33 @@ export class App {
|
|
|
45
49
|
}
|
|
46
50
|
|
|
47
51
|
async dispose(): Promise<void> {
|
|
48
|
-
|
|
52
|
+
for (const orch of this.#orchestrators.values()) {
|
|
53
|
+
await orch.dispose();
|
|
54
|
+
}
|
|
55
|
+
this.#orchestrators.clear();
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
handleCron(name: string, prompt: string, model?: string, missed?: MissedInfo, chat?: string): void {
|
|
59
|
+
const chatName = chat ?? ADMIN_CHAT_NAME;
|
|
60
|
+
if (chatName === "*") {
|
|
61
|
+
for (const orch of this.#orchestrators.values()) {
|
|
62
|
+
orch.handleCron(name, prompt, model, missed);
|
|
63
|
+
}
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
66
|
+
const orch = this.#orchestratorByName(chatName);
|
|
67
|
+
if (orch) {
|
|
68
|
+
orch.handleCron(name, prompt, model, missed);
|
|
69
|
+
} else {
|
|
70
|
+
log.warn({ chatName, jobName: name }, "Cron job targets unknown chat");
|
|
71
|
+
}
|
|
49
72
|
}
|
|
50
73
|
|
|
51
74
|
start() {
|
|
52
75
|
log.info("Starting macroclaw...");
|
|
53
76
|
const scheduler = new Scheduler(this.#config.workspace, {
|
|
54
77
|
timeZone: this.#config.timeZone,
|
|
55
|
-
onJob: (name, prompt, model, missed) => this
|
|
78
|
+
onJob: (name, prompt, model, missed, chat) => this.handleCron(name, prompt, model, missed, chat),
|
|
56
79
|
});
|
|
57
80
|
scheduler.start();
|
|
58
81
|
this.#bot.api.setMyCommands([
|
|
@@ -60,21 +83,77 @@ export class App {
|
|
|
60
83
|
{ command: "bg", description: "Spawn a background agent" },
|
|
61
84
|
{ command: "sessions", description: "List running sessions" },
|
|
62
85
|
{ command: "clear", description: "Clear session and start fresh" },
|
|
86
|
+
{ command: "chats", description: "List authorized chats (admin only)" },
|
|
87
|
+
{ command: "chatsadd", description: "Authorize a new chat (admin only)" },
|
|
88
|
+
{ command: "chatsremove", description: "Remove an authorized chat (admin only)" },
|
|
63
89
|
]).catch((err) => log.error({ err }, "Failed to set commands"));
|
|
64
90
|
this.#bot.start({
|
|
65
91
|
onStart: (botInfo) => {
|
|
66
|
-
log.info(
|
|
92
|
+
log.info(
|
|
93
|
+
{ username: botInfo.username, adminChatId: this.#config.adminChatId, authorizedChats: this.#authorizedChats.list().length },
|
|
94
|
+
"Bot connected",
|
|
95
|
+
);
|
|
67
96
|
},
|
|
68
97
|
});
|
|
69
98
|
}
|
|
70
99
|
|
|
71
|
-
|
|
100
|
+
#createOrchestrator(chatName: string, chatId: string): Orchestrator {
|
|
101
|
+
const orch = new Orchestrator({
|
|
102
|
+
chatName,
|
|
103
|
+
model: this.#config.model,
|
|
104
|
+
workspace: this.#config.workspace,
|
|
105
|
+
timeZone: this.#config.timeZone,
|
|
106
|
+
settingsDir: this.#config.settingsDir,
|
|
107
|
+
claude: this.#config.claude,
|
|
108
|
+
healthCheckInterval: this.#config.healthCheckInterval,
|
|
109
|
+
onResponse: (r) => this.#deliverResponse(chatId, r),
|
|
110
|
+
});
|
|
111
|
+
this.#orchestrators.set(chatId, orch);
|
|
112
|
+
return orch;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
#orchestratorByName(chatName: string): Orchestrator | undefined {
|
|
116
|
+
if (chatName === ADMIN_CHAT_NAME) return this.#orchestrators.get(this.#config.adminChatId);
|
|
117
|
+
const chat = this.#authorizedChats.byName(chatName);
|
|
118
|
+
if (!chat) return undefined;
|
|
119
|
+
return this.#orchestrators.get(chat.chatId);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
#isAdminChat(chatId: number | string): boolean {
|
|
123
|
+
return chatId.toString() === this.#config.adminChatId;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
async #removeChatByName(name: string, replyChatId: string): Promise<void> {
|
|
127
|
+
try {
|
|
128
|
+
const chat = this.#authorizedChats.remove(name);
|
|
129
|
+
const orch = this.#orchestrators.get(chat.chatId);
|
|
130
|
+
if (orch) {
|
|
131
|
+
await orch.dispose();
|
|
132
|
+
this.#orchestrators.delete(chat.chatId);
|
|
133
|
+
}
|
|
134
|
+
clearMainSession(name, this.#config.settingsDir);
|
|
135
|
+
log.info({ chatId: chat.chatId, name }, "Authorized chat removed");
|
|
136
|
+
sendResponse(this.#bot, replyChatId, `Chat "${name}" removed.`);
|
|
137
|
+
} catch (err) {
|
|
138
|
+
if (err instanceof UnknownChatError) {
|
|
139
|
+
sendResponse(this.#bot, replyChatId, `Error: ${err.message}`);
|
|
140
|
+
} else {
|
|
141
|
+
throw err;
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
#resolveOrchestrator(chatId: number | string): Orchestrator | undefined {
|
|
147
|
+
return this.#orchestrators.get(chatId.toString());
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
async #deliverResponse(chatId: string, response: OrchestratorResponse) {
|
|
72
151
|
if (response.files?.length) {
|
|
73
152
|
for (const filePath of response.files) {
|
|
74
|
-
await sendFile(this.#bot,
|
|
153
|
+
await sendFile(this.#bot, chatId, filePath);
|
|
75
154
|
}
|
|
76
155
|
}
|
|
77
|
-
await sendResponse(this.#bot,
|
|
156
|
+
await sendResponse(this.#bot, chatId, response.message, response.buttons);
|
|
78
157
|
}
|
|
79
158
|
|
|
80
159
|
#setupHandlers() {
|
|
@@ -84,73 +163,151 @@ export class App {
|
|
|
84
163
|
});
|
|
85
164
|
|
|
86
165
|
this.#bot.command("bg", (ctx) => {
|
|
87
|
-
|
|
166
|
+
const orch = this.#resolveOrchestrator(ctx.chat.id);
|
|
167
|
+
if (!orch) return;
|
|
88
168
|
const prompt = ctx.match?.trim();
|
|
89
169
|
if (!prompt) {
|
|
90
170
|
log.debug("Command /bg without prompt");
|
|
91
|
-
sendResponse(this.#bot,
|
|
171
|
+
sendResponse(this.#bot, ctx.chat.id.toString(), "Usage: /bg <prompt>");
|
|
92
172
|
return;
|
|
93
173
|
}
|
|
94
174
|
log.debug({ prompt }, "Command /bg spawn");
|
|
95
|
-
|
|
175
|
+
orch.handleBackgroundCommand(prompt);
|
|
96
176
|
});
|
|
97
177
|
|
|
98
178
|
this.#bot.command("sessions", (ctx) => {
|
|
99
|
-
|
|
179
|
+
const orch = this.#resolveOrchestrator(ctx.chat.id);
|
|
180
|
+
if (!orch) return;
|
|
100
181
|
log.debug("Command /sessions");
|
|
101
|
-
|
|
182
|
+
orch.handleSessions();
|
|
102
183
|
});
|
|
103
184
|
|
|
104
185
|
this.#bot.command("clear", (ctx) => {
|
|
105
|
-
|
|
186
|
+
const orch = this.#resolveOrchestrator(ctx.chat.id);
|
|
187
|
+
if (!orch) return;
|
|
106
188
|
log.debug("Command /clear");
|
|
107
|
-
|
|
189
|
+
orch.handleClear();
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
this.#bot.command("chats", (ctx) => {
|
|
193
|
+
if (!this.#isAdminChat(ctx.chat.id)) return;
|
|
194
|
+
log.debug("Command /chats");
|
|
195
|
+
const chatIdStr = ctx.chat.id.toString();
|
|
196
|
+
const chats = this.#authorizedChats.list();
|
|
197
|
+
if (chats.length === 0) {
|
|
198
|
+
sendResponse(this.#bot, chatIdStr, "No authorized chats.");
|
|
199
|
+
return;
|
|
200
|
+
}
|
|
201
|
+
const lines = chats.map((c) => `- ${c.name} (${c.chatId})`).join("\n");
|
|
202
|
+
sendResponse(this.#bot, chatIdStr, `Authorized chats:\n${lines}`);
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
this.#bot.command("chatsadd", (ctx) => {
|
|
206
|
+
if (!this.#isAdminChat(ctx.chat.id)) return;
|
|
207
|
+
log.debug("Command /chatsadd");
|
|
208
|
+
const chatIdStr = ctx.chat.id.toString();
|
|
209
|
+
const args = ctx.match?.trim().split(/\s+/);
|
|
210
|
+
if (!args || args.length < 2 || !args[0] || !args[1]) {
|
|
211
|
+
sendResponse(this.#bot, chatIdStr, "Usage: /chatsadd <chatId> <name>");
|
|
212
|
+
return;
|
|
213
|
+
}
|
|
214
|
+
const [newChatId, name] = args;
|
|
215
|
+
if (newChatId === this.#config.adminChatId) {
|
|
216
|
+
sendResponse(this.#bot, chatIdStr, "Error: chatId is already the admin chat");
|
|
217
|
+
return;
|
|
218
|
+
}
|
|
219
|
+
try {
|
|
220
|
+
const chat = this.#authorizedChats.add(newChatId, name);
|
|
221
|
+
this.#createOrchestrator(chat.name, chat.chatId);
|
|
222
|
+
log.info({ chatId: chat.chatId, name: chat.name }, "Authorized chat added");
|
|
223
|
+
sendResponse(this.#bot, chatIdStr, `Chat "${chat.name}" (${chat.chatId}) authorized.`);
|
|
224
|
+
} catch (err) {
|
|
225
|
+
if (err instanceof DuplicateChatError || err instanceof InvalidChatNameError) {
|
|
226
|
+
sendResponse(this.#bot, chatIdStr, `Error: ${err.message}`);
|
|
227
|
+
} else {
|
|
228
|
+
throw err;
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
this.#bot.command("chatsremove", async (ctx) => {
|
|
234
|
+
log.debug("Command /chatsremove");
|
|
235
|
+
const chatIdStr = ctx.chat.id.toString();
|
|
236
|
+
const name = ctx.match?.trim();
|
|
237
|
+
const isAdmin = this.#isAdminChat(ctx.chat.id);
|
|
238
|
+
|
|
239
|
+
if (!name) {
|
|
240
|
+
if (!isAdmin) {
|
|
241
|
+
// Non-admin chat with no arg: self-removal
|
|
242
|
+
const self = this.#authorizedChats.byChatId(chatIdStr);
|
|
243
|
+
if (!self) return;
|
|
244
|
+
await this.#removeChatByName(self.name, chatIdStr);
|
|
245
|
+
return;
|
|
246
|
+
}
|
|
247
|
+
const chats = this.#authorizedChats.list();
|
|
248
|
+
if (chats.length === 0) {
|
|
249
|
+
sendResponse(this.#bot, chatIdStr, "No authorized chats to remove.");
|
|
250
|
+
return;
|
|
251
|
+
}
|
|
252
|
+
const buttons: ButtonSpec[] = chats.map((c) => ({ text: c.name, data: `chatsremove:${c.name}` }));
|
|
253
|
+
buttons.push({ text: "Dismiss", data: "_dismiss" });
|
|
254
|
+
sendResponse(this.#bot, chatIdStr, "Which chat to remove?", buttons);
|
|
255
|
+
return;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
// With explicit name: admin only
|
|
259
|
+
if (!isAdmin) return;
|
|
260
|
+
await this.#removeChatByName(name, chatIdStr);
|
|
108
261
|
});
|
|
109
262
|
|
|
110
263
|
this.#bot.on("message:photo", async (ctx) => {
|
|
111
|
-
|
|
264
|
+
const orch = this.#resolveOrchestrator(ctx.chat.id);
|
|
265
|
+
if (!orch) return;
|
|
112
266
|
const photos = ctx.message.photo;
|
|
113
267
|
const largest = photos[photos.length - 1];
|
|
114
268
|
try {
|
|
115
269
|
const path = await downloadFile(this.#bot, largest.file_id, this.#config.botToken, "photo.jpg");
|
|
116
|
-
|
|
270
|
+
orch.handleMessage(ctx.message.caption ?? "", [path]);
|
|
117
271
|
} catch (err) {
|
|
118
272
|
log.error({ err }, "Photo download failed");
|
|
119
|
-
|
|
273
|
+
orch.handleMessage(`[File download failed: photo.jpg]\n${ctx.message.caption ?? ""}`);
|
|
120
274
|
}
|
|
121
275
|
});
|
|
122
276
|
|
|
123
277
|
this.#bot.on("message:document", async (ctx) => {
|
|
124
|
-
|
|
278
|
+
const orch = this.#resolveOrchestrator(ctx.chat.id);
|
|
279
|
+
if (!orch) return;
|
|
125
280
|
const doc = ctx.message.document;
|
|
126
281
|
const name = doc.file_name ?? "file";
|
|
127
282
|
try {
|
|
128
283
|
const path = await downloadFile(this.#bot, doc.file_id, this.#config.botToken, name);
|
|
129
|
-
|
|
284
|
+
orch.handleMessage(ctx.message.caption ?? "", [path]);
|
|
130
285
|
} catch (err) {
|
|
131
286
|
log.error({ err }, "Document download failed");
|
|
132
|
-
|
|
287
|
+
orch.handleMessage(`[File download failed: ${name}]\n${ctx.message.caption ?? ""}`);
|
|
133
288
|
}
|
|
134
289
|
});
|
|
135
290
|
|
|
136
291
|
this.#bot.on("message:voice", async (ctx) => {
|
|
137
|
-
|
|
292
|
+
const orch = this.#resolveOrchestrator(ctx.chat.id);
|
|
293
|
+
if (!orch) return;
|
|
294
|
+
const chatIdStr = ctx.chat.id.toString();
|
|
138
295
|
if (!this.#config.stt) {
|
|
139
|
-
await sendResponse(this.#bot,
|
|
296
|
+
await sendResponse(this.#bot, chatIdStr, "[Voice messages not available — set openaiApiKey in settings to enable]");
|
|
140
297
|
return;
|
|
141
298
|
}
|
|
142
299
|
try {
|
|
143
300
|
const path = await downloadFile(this.#bot, ctx.message.voice.file_id, this.#config.botToken, "voice.ogg");
|
|
144
301
|
const text = await this.#config.stt.transcribe(path);
|
|
145
302
|
if (!text.trim()) {
|
|
146
|
-
await sendResponse(this.#bot,
|
|
303
|
+
await sendResponse(this.#bot, chatIdStr, "[Could not understand audio]");
|
|
147
304
|
return;
|
|
148
305
|
}
|
|
149
|
-
await sendResponse(this.#bot,
|
|
150
|
-
|
|
306
|
+
await sendResponse(this.#bot, chatIdStr, `[Received audio]: ${text}`);
|
|
307
|
+
orch.handleMessage(text);
|
|
151
308
|
} catch (err) {
|
|
152
309
|
log.error({ err }, "Voice transcription failed");
|
|
153
|
-
await sendResponse(this.#bot,
|
|
310
|
+
await sendResponse(this.#bot, chatIdStr, "[Failed to transcribe audio]");
|
|
154
311
|
}
|
|
155
312
|
});
|
|
156
313
|
|
|
@@ -158,7 +315,11 @@ export class App {
|
|
|
158
315
|
await ctx.answerCallbackQuery();
|
|
159
316
|
const data = ctx.callbackQuery.data;
|
|
160
317
|
if (data === "_noop") return;
|
|
161
|
-
|
|
318
|
+
|
|
319
|
+
const chatId = ctx.chat?.id;
|
|
320
|
+
if (chatId === undefined) return;
|
|
321
|
+
const orch = this.#resolveOrchestrator(chatId);
|
|
322
|
+
if (!orch) return;
|
|
162
323
|
|
|
163
324
|
if (data === "_dismiss") {
|
|
164
325
|
await ctx.editMessageReplyMarkup({ reply_markup: undefined });
|
|
@@ -169,7 +330,7 @@ export class App {
|
|
|
169
330
|
const sessionId = data.slice(7);
|
|
170
331
|
await ctx.editMessageReplyMarkup({ reply_markup: { inline_keyboard: [[{ text: "✓ Opened", callback_data: "_noop" }]] } });
|
|
171
332
|
log.debug({ sessionId }, "Detail requested");
|
|
172
|
-
|
|
333
|
+
orch.handleDetail(sessionId);
|
|
173
334
|
return;
|
|
174
335
|
}
|
|
175
336
|
|
|
@@ -177,7 +338,7 @@ export class App {
|
|
|
177
338
|
const sessionId = data.slice(5);
|
|
178
339
|
await ctx.editMessageReplyMarkup({ reply_markup: { inline_keyboard: [[{ text: "✓ Peeked", callback_data: "_noop" }]] } });
|
|
179
340
|
log.debug({ sessionId }, "Peek requested");
|
|
180
|
-
|
|
341
|
+
orch.handlePeek(sessionId);
|
|
181
342
|
return;
|
|
182
343
|
}
|
|
183
344
|
|
|
@@ -185,22 +346,32 @@ export class App {
|
|
|
185
346
|
const sessionId = data.slice(5);
|
|
186
347
|
await ctx.editMessageReplyMarkup({ reply_markup: { inline_keyboard: [[{ text: "✓ Killed", callback_data: "_noop" }]] } });
|
|
187
348
|
log.debug({ sessionId }, "Kill requested");
|
|
188
|
-
|
|
349
|
+
orch.handleKill(sessionId);
|
|
350
|
+
return;
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
if (data.startsWith("chatsremove:")) {
|
|
354
|
+
if (!this.#isAdminChat(chatId)) return;
|
|
355
|
+
const name = data.slice("chatsremove:".length);
|
|
356
|
+
await ctx.editMessageReplyMarkup({ reply_markup: { inline_keyboard: [[{ text: `✓ Removed ${name}`, callback_data: "_noop" }]] } });
|
|
357
|
+
log.debug({ name }, "Chat removal requested via button");
|
|
358
|
+
await this.#removeChatByName(name, chatId.toString());
|
|
189
359
|
return;
|
|
190
360
|
}
|
|
191
361
|
|
|
192
362
|
await ctx.editMessageReplyMarkup({ reply_markup: { inline_keyboard: [[{ text: `✓ ${data}`, callback_data: "_noop" }]] } });
|
|
193
363
|
log.debug({ label: data }, "Button clicked");
|
|
194
|
-
|
|
364
|
+
orch.handleButton(data);
|
|
195
365
|
});
|
|
196
366
|
|
|
197
367
|
this.#bot.on("message:text", (ctx) => {
|
|
198
|
-
|
|
368
|
+
const orch = this.#resolveOrchestrator(ctx.chat.id);
|
|
369
|
+
if (!orch) {
|
|
199
370
|
log.debug({ chatId: ctx.chat.id }, "Unauthorized message");
|
|
200
371
|
return;
|
|
201
372
|
}
|
|
202
373
|
|
|
203
|
-
|
|
374
|
+
orch.handleMessage(ctx.message.text);
|
|
204
375
|
});
|
|
205
376
|
|
|
206
377
|
this.#bot.catch((err) => {
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, it } from "bun:test";
|
|
2
|
+
import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import {
|
|
5
|
+
AuthorizedChats,
|
|
6
|
+
DuplicateChatError,
|
|
7
|
+
InvalidChatNameError,
|
|
8
|
+
UnknownChatError,
|
|
9
|
+
} from "./authorized-chats";
|
|
10
|
+
|
|
11
|
+
const tmpDir = "/tmp/macroclaw-authorized-chats-test";
|
|
12
|
+
|
|
13
|
+
function cleanup() {
|
|
14
|
+
if (existsSync(tmpDir)) rmSync(tmpDir, { recursive: true });
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
beforeEach(cleanup);
|
|
18
|
+
afterEach(cleanup);
|
|
19
|
+
|
|
20
|
+
describe("AuthorizedChats.list", () => {
|
|
21
|
+
it("returns empty array when file does not exist", () => {
|
|
22
|
+
expect(new AuthorizedChats(tmpDir).list()).toEqual([]);
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it("reads chats from file", () => {
|
|
26
|
+
mkdirSync(tmpDir, { recursive: true });
|
|
27
|
+
writeFileSync(
|
|
28
|
+
join(tmpDir, "authorized-chats.json"),
|
|
29
|
+
JSON.stringify({
|
|
30
|
+
chats: [
|
|
31
|
+
{ chatId: "-1001", name: "family", addedAt: "2026-04-20T14:00:00.000Z" },
|
|
32
|
+
{ chatId: "987", name: "work", addedAt: "2026-04-21T09:30:00.000Z" },
|
|
33
|
+
],
|
|
34
|
+
}),
|
|
35
|
+
);
|
|
36
|
+
expect(new AuthorizedChats(tmpDir).list()).toEqual([
|
|
37
|
+
{ chatId: "-1001", name: "family", addedAt: "2026-04-20T14:00:00.000Z" },
|
|
38
|
+
{ chatId: "987", name: "work", addedAt: "2026-04-21T09:30:00.000Z" },
|
|
39
|
+
]);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it("returns empty array when file is corrupt", () => {
|
|
43
|
+
mkdirSync(tmpDir, { recursive: true });
|
|
44
|
+
writeFileSync(join(tmpDir, "authorized-chats.json"), "not json");
|
|
45
|
+
expect(new AuthorizedChats(tmpDir).list()).toEqual([]);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it("returns empty array when schema validation fails", () => {
|
|
49
|
+
mkdirSync(tmpDir, { recursive: true });
|
|
50
|
+
writeFileSync(
|
|
51
|
+
join(tmpDir, "authorized-chats.json"),
|
|
52
|
+
JSON.stringify({ chats: [{ chatId: "not-numeric", name: "x", addedAt: "now" }] }),
|
|
53
|
+
);
|
|
54
|
+
expect(new AuthorizedChats(tmpDir).list()).toEqual([]);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it("defaults to empty chats when field is missing", () => {
|
|
58
|
+
mkdirSync(tmpDir, { recursive: true });
|
|
59
|
+
writeFileSync(join(tmpDir, "authorized-chats.json"), JSON.stringify({}));
|
|
60
|
+
expect(new AuthorizedChats(tmpDir).list()).toEqual([]);
|
|
61
|
+
});
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
describe("AuthorizedChats.add", () => {
|
|
65
|
+
it("persists new chat to disk", () => {
|
|
66
|
+
const chats = new AuthorizedChats(tmpDir);
|
|
67
|
+
const now = new Date("2026-04-20T14:00:00.000Z");
|
|
68
|
+
chats.add("12345", "family", now);
|
|
69
|
+
const raw = JSON.parse(readFileSync(join(tmpDir, "authorized-chats.json"), "utf-8"));
|
|
70
|
+
expect(raw).toEqual({
|
|
71
|
+
chats: [{ chatId: "12345", name: "family", addedAt: "2026-04-20T14:00:00.000Z" }],
|
|
72
|
+
});
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it("uses current time by default", () => {
|
|
76
|
+
const chats = new AuthorizedChats(tmpDir);
|
|
77
|
+
const chat = chats.add("12345", "family");
|
|
78
|
+
expect(chat.addedAt).toMatch(/^\d{4}-\d{2}-\d{2}T/);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it("returns the added chat", () => {
|
|
82
|
+
const chats = new AuthorizedChats(tmpDir);
|
|
83
|
+
const chat = chats.add("12345", "family", new Date("2026-01-01T00:00:00.000Z"));
|
|
84
|
+
expect(chat).toEqual({ chatId: "12345", name: "family", addedAt: "2026-01-01T00:00:00.000Z" });
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it("rejects duplicate name", () => {
|
|
88
|
+
const chats = new AuthorizedChats(tmpDir);
|
|
89
|
+
chats.add("111", "family");
|
|
90
|
+
expect(() => chats.add("222", "family")).toThrow(DuplicateChatError);
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it("rejects duplicate chatId", () => {
|
|
94
|
+
const chats = new AuthorizedChats(tmpDir);
|
|
95
|
+
chats.add("111", "family");
|
|
96
|
+
expect(() => chats.add("111", "work")).toThrow(DuplicateChatError);
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it("rejects reserved name 'admin'", () => {
|
|
100
|
+
const chats = new AuthorizedChats(tmpDir);
|
|
101
|
+
expect(() => chats.add("111", "admin")).toThrow(InvalidChatNameError);
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it("rejects invalid name format", () => {
|
|
105
|
+
const chats = new AuthorizedChats(tmpDir);
|
|
106
|
+
expect(() => chats.add("111", "Family")).toThrow(InvalidChatNameError);
|
|
107
|
+
expect(() => chats.add("111", "with space")).toThrow(InvalidChatNameError);
|
|
108
|
+
expect(() => chats.add("111", "")).toThrow(InvalidChatNameError);
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it("accepts valid names with digits and dashes", () => {
|
|
112
|
+
const chats = new AuthorizedChats(tmpDir);
|
|
113
|
+
expect(() => chats.add("111", "family-1")).not.toThrow();
|
|
114
|
+
expect(() => chats.add("222", "project2")).not.toThrow();
|
|
115
|
+
});
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
describe("AuthorizedChats.remove", () => {
|
|
119
|
+
it("removes chat and persists", () => {
|
|
120
|
+
const chats = new AuthorizedChats(tmpDir);
|
|
121
|
+
chats.add("111", "family");
|
|
122
|
+
chats.add("222", "work");
|
|
123
|
+
chats.remove("family");
|
|
124
|
+
expect(chats.list().map((c) => c.name)).toEqual(["work"]);
|
|
125
|
+
|
|
126
|
+
// Re-load from disk to confirm persistence
|
|
127
|
+
const reloaded = new AuthorizedChats(tmpDir);
|
|
128
|
+
expect(reloaded.list().map((c) => c.name)).toEqual(["work"]);
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
it("returns the removed chat", () => {
|
|
132
|
+
const chats = new AuthorizedChats(tmpDir);
|
|
133
|
+
const added = chats.add("111", "family");
|
|
134
|
+
const removed = chats.remove("family");
|
|
135
|
+
expect(removed).toEqual(added);
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
it("throws UnknownChatError for missing name", () => {
|
|
139
|
+
const chats = new AuthorizedChats(tmpDir);
|
|
140
|
+
expect(() => chats.remove("nonexistent")).toThrow(UnknownChatError);
|
|
141
|
+
});
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
describe("AuthorizedChats.byName / byChatId", () => {
|
|
145
|
+
it("byName finds a chat", () => {
|
|
146
|
+
const chats = new AuthorizedChats(tmpDir);
|
|
147
|
+
chats.add("111", "family");
|
|
148
|
+
expect(chats.byName("family")?.chatId).toBe("111");
|
|
149
|
+
expect(chats.byName("unknown")).toBeUndefined();
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
it("byChatId finds a chat", () => {
|
|
153
|
+
const chats = new AuthorizedChats(tmpDir);
|
|
154
|
+
chats.add("111", "family");
|
|
155
|
+
expect(chats.byChatId("111")?.name).toBe("family");
|
|
156
|
+
expect(chats.byChatId("999")).toBeUndefined();
|
|
157
|
+
});
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
describe("AuthorizedChats.validateName", () => {
|
|
161
|
+
it("throws for 'admin'", () => {
|
|
162
|
+
expect(() => AuthorizedChats.validateName("admin")).toThrow(InvalidChatNameError);
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
it("throws for invalid characters", () => {
|
|
166
|
+
expect(() => AuthorizedChats.validateName("Capital")).toThrow(InvalidChatNameError);
|
|
167
|
+
expect(() => AuthorizedChats.validateName("with_underscore")).toThrow(InvalidChatNameError);
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
it("accepts valid names", () => {
|
|
171
|
+
expect(() => AuthorizedChats.validateName("family")).not.toThrow();
|
|
172
|
+
expect(() => AuthorizedChats.validateName("a-b-c")).not.toThrow();
|
|
173
|
+
expect(() => AuthorizedChats.validateName("x123")).not.toThrow();
|
|
174
|
+
});
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
describe("errors", () => {
|
|
178
|
+
it("DuplicateChatError carries kind and value", () => {
|
|
179
|
+
const err = new DuplicateChatError("name", "family");
|
|
180
|
+
expect(err.kind).toBe("name");
|
|
181
|
+
expect(err.value).toBe("family");
|
|
182
|
+
expect(err.message).toContain("family");
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
it("UnknownChatError carries chatName", () => {
|
|
186
|
+
const err = new UnknownChatError("ghost");
|
|
187
|
+
expect(err.chatName).toBe("ghost");
|
|
188
|
+
expect(err.message).toContain("ghost");
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
it("InvalidChatNameError carries chatName", () => {
|
|
192
|
+
const err = new InvalidChatNameError("bad name", "contains space");
|
|
193
|
+
expect(err.chatName).toBe("bad name");
|
|
194
|
+
expect(err.message).toContain("bad name");
|
|
195
|
+
});
|
|
196
|
+
});
|