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/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
- authorizedChatId: string;
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.#orchestrator = new Orchestrator({
31
- model: config.model,
32
- workspace: config.workspace,
33
- timeZone: config.timeZone,
34
- settingsDir: config.settingsDir,
35
- claude: config.claude,
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
- await this.#orchestrator.dispose();
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.#orchestrator.handleCron(name, prompt, model, missed),
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({ username: botInfo.username, chatId: this.#config.authorizedChatId }, "Bot connected");
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
- async #deliverResponse(response: OrchestratorResponse) {
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, this.#config.authorizedChatId, filePath);
153
+ await sendFile(this.#bot, chatId, filePath);
75
154
  }
76
155
  }
77
- await sendResponse(this.#bot, this.#config.authorizedChatId, response.message, response.buttons);
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
- if (ctx.chat.id.toString() !== this.#config.authorizedChatId) return;
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, this.#config.authorizedChatId, "Usage: /bg &lt;prompt&gt;");
171
+ sendResponse(this.#bot, ctx.chat.id.toString(), "Usage: /bg &lt;prompt&gt;");
92
172
  return;
93
173
  }
94
174
  log.debug({ prompt }, "Command /bg spawn");
95
- this.#orchestrator.handleBackgroundCommand(prompt);
175
+ orch.handleBackgroundCommand(prompt);
96
176
  });
97
177
 
98
178
  this.#bot.command("sessions", (ctx) => {
99
- if (ctx.chat.id.toString() !== this.#config.authorizedChatId) return;
179
+ const orch = this.#resolveOrchestrator(ctx.chat.id);
180
+ if (!orch) return;
100
181
  log.debug("Command /sessions");
101
- this.#orchestrator.handleSessions();
182
+ orch.handleSessions();
102
183
  });
103
184
 
104
185
  this.#bot.command("clear", (ctx) => {
105
- if (ctx.chat.id.toString() !== this.#config.authorizedChatId) return;
186
+ const orch = this.#resolveOrchestrator(ctx.chat.id);
187
+ if (!orch) return;
106
188
  log.debug("Command /clear");
107
- this.#orchestrator.handleClear();
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 &lt;chatId&gt; &lt;name&gt;");
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
- if (ctx.chat.id.toString() !== this.#config.authorizedChatId) return;
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
- this.#orchestrator.handleMessage(ctx.message.caption ?? "", [path]);
270
+ orch.handleMessage(ctx.message.caption ?? "", [path]);
117
271
  } catch (err) {
118
272
  log.error({ err }, "Photo download failed");
119
- this.#orchestrator.handleMessage(`[File download failed: photo.jpg]\n${ctx.message.caption ?? ""}`);
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
- if (ctx.chat.id.toString() !== this.#config.authorizedChatId) return;
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
- this.#orchestrator.handleMessage(ctx.message.caption ?? "", [path]);
284
+ orch.handleMessage(ctx.message.caption ?? "", [path]);
130
285
  } catch (err) {
131
286
  log.error({ err }, "Document download failed");
132
- this.#orchestrator.handleMessage(`[File download failed: ${name}]\n${ctx.message.caption ?? ""}`);
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
- if (ctx.chat.id.toString() !== this.#config.authorizedChatId) return;
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, this.#config.authorizedChatId, "[Voice messages not available — set openaiApiKey in settings to enable]");
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, this.#config.authorizedChatId, "[Could not understand audio]");
303
+ await sendResponse(this.#bot, chatIdStr, "[Could not understand audio]");
147
304
  return;
148
305
  }
149
- await sendResponse(this.#bot, this.#config.authorizedChatId, `[Received audio]: ${text}`);
150
- this.#orchestrator.handleMessage(text);
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, this.#config.authorizedChatId, "[Failed to transcribe audio]");
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
- if (ctx.chat?.id.toString() !== this.#config.authorizedChatId) return;
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
- this.#orchestrator.handleDetail(sessionId);
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
- this.#orchestrator.handlePeek(sessionId);
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
- this.#orchestrator.handleKill(sessionId);
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
- this.#orchestrator.handleButton(data);
364
+ orch.handleButton(data);
195
365
  });
196
366
 
197
367
  this.#bot.on("message:text", (ctx) => {
198
- if (ctx.chat.id.toString() !== this.#config.authorizedChatId) {
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
- this.#orchestrator.handleMessage(ctx.message.text);
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
+ });