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.
@@ -17,7 +17,7 @@ function readScheduleConfig() {
17
17
  }
18
18
 
19
19
  function makeOnJob() {
20
- return mock((_name: string, _prompt: string, _model?: string, _missed?: { missedBy: string; scheduledAt: string }) => {});
20
+ return mock((_name: string, _prompt: string, _model?: string, _missed?: { missedBy: string; scheduledAt: string }, _chat?: string) => {});
21
21
  }
22
22
 
23
23
  // Build a cron expression that matches the current minute
@@ -77,7 +77,7 @@ describe("Scheduler — cron jobs", () => {
77
77
  s.start();
78
78
  s.stop();
79
79
 
80
- expect(onJob).toHaveBeenCalledWith("test-job", "do something", undefined);
80
+ expect(onJob).toHaveBeenCalledWith("test-job", "do something", undefined, undefined, undefined);
81
81
  });
82
82
 
83
83
  it("does not call onJob for non-matching jobs", () => {
@@ -107,7 +107,7 @@ describe("Scheduler — cron jobs", () => {
107
107
  s.stop();
108
108
 
109
109
  expect(onJob).toHaveBeenCalledTimes(1);
110
- expect(onJob).toHaveBeenCalledWith("good", "good", undefined);
110
+ expect(onJob).toHaveBeenCalledWith("good", "good", undefined, undefined, undefined);
111
111
  });
112
112
 
113
113
  it("handles multiple matching jobs", () => {
@@ -124,8 +124,8 @@ describe("Scheduler — cron jobs", () => {
124
124
  s.stop();
125
125
 
126
126
  expect(onJob).toHaveBeenCalledTimes(2);
127
- expect(onJob).toHaveBeenCalledWith("first", "first", undefined);
128
- expect(onJob).toHaveBeenCalledWith("second", "second", undefined);
127
+ expect(onJob).toHaveBeenCalledWith("first", "first", undefined, undefined, undefined);
128
+ expect(onJob).toHaveBeenCalledWith("second", "second", undefined, undefined, undefined);
129
129
  });
130
130
 
131
131
  it("passes model override to onJob", () => {
@@ -138,7 +138,20 @@ describe("Scheduler — cron jobs", () => {
138
138
  s.start();
139
139
  s.stop();
140
140
 
141
- expect(onJob).toHaveBeenCalledWith("smart", "think hard", "opus");
141
+ expect(onJob).toHaveBeenCalledWith("smart", "think hard", "opus", undefined, undefined);
142
+ });
143
+
144
+ it("passes chat field to onJob for cron jobs", () => {
145
+ writeScheduleConfig({
146
+ jobs: [{ name: "targeted-cron", cron: currentMinuteCron(), prompt: "family update", chat: "family" }],
147
+ });
148
+
149
+ const onJob = makeOnJob();
150
+ const s = new Scheduler(TEST_DIR, { timeZone: "UTC", onJob });
151
+ s.start();
152
+ s.stop();
153
+
154
+ expect(onJob).toHaveBeenCalledWith("targeted-cron", "family update", undefined, undefined, "family");
142
155
  });
143
156
 
144
157
  it("never removes cron jobs after firing", () => {
@@ -168,7 +181,7 @@ describe("Scheduler — fireAt jobs", () => {
168
181
  s.start();
169
182
  s.stop();
170
183
 
171
- expect(onJob).toHaveBeenCalledWith("now", "do it", undefined);
184
+ expect(onJob).toHaveBeenCalledWith("now", "do it", undefined, undefined, undefined);
172
185
  expect(onJob.mock.calls[0][3]).toBeUndefined();
173
186
  });
174
187
 
@@ -294,7 +307,7 @@ describe("Scheduler — fireAt jobs", () => {
294
307
  s.start();
295
308
  s.stop();
296
309
 
297
- expect(onJob).toHaveBeenCalledWith("smart", "think", "opus");
310
+ expect(onJob).toHaveBeenCalledWith("smart", "think", "opus", undefined, undefined);
298
311
  });
299
312
 
300
313
  it("interprets offset-less fireAt in the configured time zone", () => {
@@ -316,7 +329,7 @@ describe("Scheduler — fireAt jobs", () => {
316
329
  s.start();
317
330
  s.stop();
318
331
 
319
- expect(onJob).toHaveBeenCalledWith("local", "local time", undefined);
332
+ expect(onJob).toHaveBeenCalledWith("local", "local time", undefined, undefined, undefined);
320
333
  });
321
334
 
322
335
  it("preserves explicit offset in fireAt (ignores configured time zone)", () => {
@@ -330,7 +343,20 @@ describe("Scheduler — fireAt jobs", () => {
330
343
  s.start();
331
344
  s.stop();
332
345
 
333
- expect(onJob).toHaveBeenCalledWith("explicit", "with offset", undefined);
346
+ expect(onJob).toHaveBeenCalledWith("explicit", "with offset", undefined, undefined, undefined);
347
+ });
348
+
349
+ it("passes chat field to onJob", () => {
350
+ writeScheduleConfig({
351
+ jobs: [{ name: "targeted", fireAt: justNowFireAt(), prompt: "hi family", chat: "family" }],
352
+ });
353
+
354
+ const onJob = makeOnJob();
355
+ const s = new Scheduler(TEST_DIR, { timeZone: "UTC", onJob });
356
+ s.start();
357
+ s.stop();
358
+
359
+ expect(onJob).toHaveBeenCalledWith("targeted", "hi family", undefined, undefined, "family");
334
360
  });
335
361
 
336
362
  it("still fires when write-back of schedule.json fails", () => {
package/src/scheduler.ts CHANGED
@@ -13,6 +13,7 @@ const jobSchema = z.object({
13
13
  model: z.string().optional(),
14
14
  cron: z.string().optional(),
15
15
  fireAt: z.string().optional(),
16
+ chat: z.string().optional(),
16
17
  });
17
18
 
18
19
  const scheduleConfigSchema = z.object({
@@ -29,7 +30,7 @@ export interface MissedInfo {
29
30
 
30
31
  export interface SchedulerConfig {
31
32
  timeZone: string;
32
- onJob: (name: string, prompt: string, model?: string, missed?: MissedInfo) => void;
33
+ onJob: (name: string, prompt: string, model?: string, missed?: MissedInfo, chat?: string) => void;
33
34
  }
34
35
 
35
36
  const TICK_INTERVAL = 10_000; // 10 seconds
@@ -118,14 +119,14 @@ export class Scheduler {
118
119
  }
119
120
  }
120
121
 
121
- #evaluateCronJob(job: { name: string; cron: string; prompt: string; model?: string }, now: Date): void {
122
+ #evaluateCronJob(job: { name: string; cron: string; prompt: string; model?: string; chat?: string }, now: Date): void {
122
123
  try {
123
124
  const interval = CronExpressionParser.parse(job.cron, { tz: this.#timeZone });
124
125
  const prev = interval.prev();
125
126
  const diff = Math.abs(now.getTime() - prev.getTime());
126
127
  if (diff < 60_000) {
127
128
  log.debug({ name: job.name, cron: job.cron }, "Cron job triggered");
128
- this.#config.onJob(job.name, job.prompt, job.model);
129
+ this.#config.onJob(job.name, job.prompt, job.model, undefined, job.chat);
129
130
  }
130
131
  } catch (err) {
131
132
  log.warn({ cron: job.cron, err: err instanceof Error ? err.message : err }, "Invalid cron expression");
@@ -144,7 +145,7 @@ export class Scheduler {
144
145
  }
145
146
 
146
147
  #evaluateFireAtJob(
147
- job: { name: string; fireAt: string; prompt: string; model?: string },
148
+ job: { name: string; fireAt: string; prompt: string; model?: string; chat?: string },
148
149
  now: Date,
149
150
  ): "remove" | "keep" {
150
151
  const fireAt = Scheduler.#parseFireAt(job.fireAt, this.#timeZone);
@@ -162,7 +163,7 @@ export class Scheduler {
162
163
 
163
164
  if (diff < 60_000) {
164
165
  log.debug({ name: job.name, fireAt: job.fireAt }, "One-shot job triggered");
165
- this.#config.onJob(job.name, job.prompt, job.model);
166
+ this.#config.onJob(job.name, job.prompt, job.model, undefined, job.chat);
166
167
  return "remove";
167
168
  }
168
169
 
@@ -172,7 +173,7 @@ export class Scheduler {
172
173
  this.#config.onJob(job.name, job.prompt, job.model, {
173
174
  missedBy: `${missedMinutes}m`,
174
175
  scheduledAt: job.fireAt,
175
- });
176
+ }, job.chat);
176
177
  return "remove";
177
178
  }
178
179
 
@@ -1,7 +1,14 @@
1
1
  import { afterEach, beforeEach, describe, expect, it } from "bun:test";
2
- import { existsSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
2
+ import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
3
3
  import { join } from "node:path";
4
- import { loadSessions, newSessionId, saveSessions } from "./sessions";
4
+ import {
5
+ clearMainSession,
6
+ getMainSession,
7
+ loadSessions,
8
+ newSessionId,
9
+ saveSessions,
10
+ setMainSession,
11
+ } from "./sessions";
5
12
 
6
13
  const tmpDir = "/tmp/macroclaw-sessions-test";
7
14
 
@@ -13,46 +20,105 @@ beforeEach(cleanup);
13
20
  afterEach(cleanup);
14
21
 
15
22
  describe("loadSessions", () => {
16
- it("returns empty object when dir does not exist", () => {
17
- expect(loadSessions(tmpDir)).toEqual({});
23
+ it("returns empty sessions when dir does not exist", () => {
24
+ expect(loadSessions(tmpDir)).toEqual({ mainSessions: {} });
18
25
  });
19
26
 
20
- it("returns empty object when file does not exist", () => {
27
+ it("returns empty sessions when file does not exist", () => {
21
28
  mkdirSync(tmpDir, { recursive: true });
22
- expect(loadSessions(tmpDir)).toEqual({});
29
+ expect(loadSessions(tmpDir)).toEqual({ mainSessions: {} });
23
30
  });
24
31
 
25
32
  it("reads sessions from file", () => {
26
33
  mkdirSync(tmpDir, { recursive: true });
27
- writeFileSync(join(tmpDir, "sessions.json"), JSON.stringify({ mainSessionId: "abc-123" }));
28
- expect(loadSessions(tmpDir)).toEqual({ mainSessionId: "abc-123" });
34
+ writeFileSync(
35
+ join(tmpDir, "sessions.json"),
36
+ JSON.stringify({ mainSessions: { admin: "abc-123", family: "def-456" } }),
37
+ );
38
+ expect(loadSessions(tmpDir)).toEqual({
39
+ mainSessions: { admin: "abc-123", family: "def-456" },
40
+ });
29
41
  });
30
42
 
31
- it("returns empty object when file is corrupt", () => {
43
+ it("returns empty sessions when file is corrupt", () => {
32
44
  mkdirSync(tmpDir, { recursive: true });
33
45
  writeFileSync(join(tmpDir, "sessions.json"), "not json");
34
- expect(loadSessions(tmpDir)).toEqual({});
46
+ expect(loadSessions(tmpDir)).toEqual({ mainSessions: {} });
35
47
  });
36
48
 
37
49
  it("strips unknown fields via schema", () => {
38
50
  mkdirSync(tmpDir, { recursive: true });
39
- writeFileSync(join(tmpDir, "sessions.json"), JSON.stringify({ mainSessionId: "abc", extra: true }));
40
- const result = loadSessions(tmpDir);
41
- expect(result).toEqual({ mainSessionId: "abc" });
51
+ writeFileSync(
52
+ join(tmpDir, "sessions.json"),
53
+ JSON.stringify({ mainSessions: { admin: "abc" }, extra: true }),
54
+ );
55
+ expect(loadSessions(tmpDir)).toEqual({ mainSessions: { admin: "abc" } });
56
+ });
57
+
58
+ it("migrates legacy mainSessionId to mainSessions.admin", () => {
59
+ mkdirSync(tmpDir, { recursive: true });
60
+ writeFileSync(join(tmpDir, "sessions.json"), JSON.stringify({ mainSessionId: "legacy-id" }));
61
+ expect(loadSessions(tmpDir)).toEqual({ mainSessions: { admin: "legacy-id" } });
62
+ });
63
+
64
+ it("defaults to empty mainSessions when field is missing", () => {
65
+ mkdirSync(tmpDir, { recursive: true });
66
+ writeFileSync(join(tmpDir, "sessions.json"), JSON.stringify({}));
67
+ expect(loadSessions(tmpDir)).toEqual({ mainSessions: {} });
42
68
  });
43
69
  });
44
70
 
45
71
  describe("saveSessions", () => {
46
72
  it("creates directory and writes file", () => {
47
- saveSessions({ mainSessionId: "new-id" }, tmpDir);
48
- const saved = loadSessions(tmpDir);
49
- expect(saved).toEqual({ mainSessionId: "new-id" });
73
+ saveSessions({ mainSessions: { admin: "new-id" } }, tmpDir);
74
+ expect(loadSessions(tmpDir)).toEqual({ mainSessions: { admin: "new-id" } });
50
75
  });
51
76
 
52
77
  it("overwrites existing file", () => {
53
- saveSessions({ mainSessionId: "first" }, tmpDir);
54
- saveSessions({ mainSessionId: "second" }, tmpDir);
55
- expect(loadSessions(tmpDir)).toEqual({ mainSessionId: "second" });
78
+ saveSessions({ mainSessions: { admin: "first" } }, tmpDir);
79
+ saveSessions({ mainSessions: { admin: "second" } }, tmpDir);
80
+ expect(loadSessions(tmpDir)).toEqual({ mainSessions: { admin: "second" } });
81
+ });
82
+ });
83
+
84
+ describe("getMainSession / setMainSession / clearMainSession", () => {
85
+ it("returns undefined when no session for chat", () => {
86
+ expect(getMainSession("admin", tmpDir)).toBeUndefined();
87
+ });
88
+
89
+ it("returns stored session id", () => {
90
+ saveSessions({ mainSessions: { admin: "abc", family: "def" } }, tmpDir);
91
+ expect(getMainSession("admin", tmpDir)).toBe("abc");
92
+ expect(getMainSession("family", tmpDir)).toBe("def");
93
+ });
94
+
95
+ it("setMainSession preserves other chats", () => {
96
+ saveSessions({ mainSessions: { admin: "abc", family: "def" } }, tmpDir);
97
+ setMainSession("admin", "new-admin-id", tmpDir);
98
+ expect(loadSessions(tmpDir)).toEqual({
99
+ mainSessions: { admin: "new-admin-id", family: "def" },
100
+ });
101
+ });
102
+
103
+ it("setMainSession adds new chat without touching others", () => {
104
+ saveSessions({ mainSessions: { admin: "abc" } }, tmpDir);
105
+ setMainSession("work", "work-id", tmpDir);
106
+ expect(loadSessions(tmpDir)).toEqual({
107
+ mainSessions: { admin: "abc", work: "work-id" },
108
+ });
109
+ });
110
+
111
+ it("clearMainSession removes one chat, preserves others", () => {
112
+ saveSessions({ mainSessions: { admin: "abc", family: "def" } }, tmpDir);
113
+ clearMainSession("family", tmpDir);
114
+ expect(loadSessions(tmpDir)).toEqual({ mainSessions: { admin: "abc" } });
115
+ });
116
+
117
+ it("clearMainSession is a no-op when chat is not present", () => {
118
+ saveSessions({ mainSessions: { admin: "abc" } }, tmpDir);
119
+ clearMainSession("nobody", tmpDir);
120
+ const contents = readFileSync(join(tmpDir, "sessions.json"), "utf-8");
121
+ expect(JSON.parse(contents)).toEqual({ mainSessions: { admin: "abc" } });
56
122
  });
57
123
  });
58
124
 
package/src/sessions.ts CHANGED
@@ -7,22 +7,32 @@ import { createLogger } from "./logger";
7
7
  const log = createLogger("sessions");
8
8
 
9
9
  const sessionsSchema = z.object({
10
- mainSessionId: z.string().optional(),
10
+ mainSessions: z.record(z.string(), z.string()).default({}),
11
11
  });
12
12
 
13
13
  export type Sessions = z.infer<typeof sessionsSchema>;
14
14
 
15
15
  const defaultDir = resolve(process.env.HOME || "~", ".macroclaw");
16
16
 
17
+ function migrateLegacy(raw: unknown): unknown {
18
+ if (raw && typeof raw === "object" && !Array.isArray(raw)) {
19
+ const obj = raw as Record<string, unknown>;
20
+ if (typeof obj.mainSessionId === "string" && obj.mainSessions === undefined) {
21
+ return { mainSessions: { admin: obj.mainSessionId } };
22
+ }
23
+ }
24
+ return raw;
25
+ }
26
+
17
27
  export function loadSessions(dir: string = defaultDir): Sessions {
18
28
  try {
19
29
  const path = join(dir, "sessions.json");
20
- if (!existsSync(path)) return {};
21
- const raw = readFileSync(path, "utf-8");
22
- return sessionsSchema.parse(JSON.parse(raw));
30
+ if (!existsSync(path)) return { mainSessions: {} };
31
+ const raw = migrateLegacy(JSON.parse(readFileSync(path, "utf-8")));
32
+ return sessionsSchema.parse(raw);
23
33
  } catch (err) {
24
34
  log.warn({ err }, "Failed to load sessions.json, resetting to empty");
25
- return {};
35
+ return { mainSessions: {} };
26
36
  }
27
37
  }
28
38
 
@@ -31,6 +41,24 @@ export function saveSessions(sessions: Sessions, dir: string = defaultDir): void
31
41
  writeFileSync(join(dir, "sessions.json"), `${JSON.stringify(sessions, null, 2)}\n`);
32
42
  }
33
43
 
44
+ export function getMainSession(chatName: string, dir: string = defaultDir): string | undefined {
45
+ return loadSessions(dir).mainSessions[chatName];
46
+ }
47
+
48
+ export function setMainSession(chatName: string, sessionId: string, dir: string = defaultDir): void {
49
+ const sessions = loadSessions(dir);
50
+ sessions.mainSessions[chatName] = sessionId;
51
+ saveSessions(sessions, dir);
52
+ }
53
+
54
+ export function clearMainSession(chatName: string, dir: string = defaultDir): void {
55
+ const sessions = loadSessions(dir);
56
+ if (chatName in sessions.mainSessions) {
57
+ delete sessions.mainSessions[chatName];
58
+ saveSessions(sessions, dir);
59
+ }
60
+ }
61
+
34
62
  export function newSessionId(): string {
35
63
  return randomUUID();
36
64
  }
@@ -7,7 +7,7 @@ const tmpDir = "/tmp/macroclaw-settings-test";
7
7
 
8
8
  const validSettings: Settings = {
9
9
  botToken: "123:ABC",
10
- chatId: "12345678",
10
+ adminChatId: "12345678",
11
11
  model: "sonnet",
12
12
  workspace: "~/.macroclaw-workspace",
13
13
  timeZone: "UTC",
@@ -32,12 +32,12 @@ describe("SettingsManager.load", () => {
32
32
  mkdirSync(tmpDir, { recursive: true });
33
33
  writeFileSync(join(tmpDir, "settings.json"), JSON.stringify({
34
34
  botToken: "123:ABC",
35
- chatId: "12345678",
35
+ adminChatId: "12345678",
36
36
  }));
37
37
  const settings = new SettingsManager(tmpDir).load();
38
38
  expect(settings).toEqual({
39
39
  botToken: "123:ABC",
40
- chatId: "12345678",
40
+ adminChatId: "12345678",
41
41
  model: "sonnet",
42
42
  workspace: "~/.macroclaw-workspace",
43
43
  timeZone: "UTC",
@@ -49,7 +49,7 @@ describe("SettingsManager.load", () => {
49
49
  mkdirSync(tmpDir, { recursive: true });
50
50
  writeFileSync(join(tmpDir, "settings.json"), JSON.stringify({
51
51
  botToken: " 123:ABC ",
52
- chatId: " 12345678 ",
52
+ adminChatId: " 12345678 ",
53
53
  model: " opus ",
54
54
  workspace: " /custom/workspace ",
55
55
  timeZone: " Europe/Prague ",
@@ -60,7 +60,7 @@ describe("SettingsManager.load", () => {
60
60
  const settings = new SettingsManager(tmpDir).load();
61
61
  expect(settings).toEqual({
62
62
  botToken: "123:ABC",
63
- chatId: "12345678",
63
+ adminChatId: "12345678",
64
64
  model: "opus",
65
65
  workspace: "/custom/workspace",
66
66
  timeZone: "Europe/Prague",
@@ -74,7 +74,7 @@ describe("SettingsManager.load", () => {
74
74
  mkdirSync(tmpDir, { recursive: true });
75
75
  writeFileSync(join(tmpDir, "settings.json"), JSON.stringify({
76
76
  botToken: "tok",
77
- chatId: "123",
77
+ adminChatId: "123",
78
78
  model: "opus",
79
79
  workspace: "/custom",
80
80
  openaiApiKey: "sk-test",
@@ -84,7 +84,7 @@ describe("SettingsManager.load", () => {
84
84
  const settings = new SettingsManager(tmpDir).load();
85
85
  expect(settings).toEqual({
86
86
  botToken: "tok",
87
- chatId: "123",
87
+ adminChatId: "123",
88
88
  model: "opus",
89
89
  workspace: "/custom",
90
90
  timeZone: "UTC",
@@ -114,7 +114,7 @@ describe("SettingsManager.load", () => {
114
114
 
115
115
  it("exits with code 1 when validation fails (missing required field)", () => {
116
116
  mkdirSync(tmpDir, { recursive: true });
117
- writeFileSync(join(tmpDir, "settings.json"), JSON.stringify({ chatId: "123" }));
117
+ writeFileSync(join(tmpDir, "settings.json"), JSON.stringify({ adminChatId: "123" }));
118
118
 
119
119
  const mockExit = mock(() => { throw new Error("exit"); });
120
120
  const origExit = process.exit;
@@ -134,7 +134,7 @@ describe("SettingsManager.load", () => {
134
134
  mkdirSync(tmpDir, { recursive: true });
135
135
  writeFileSync(join(tmpDir, "settings.json"), JSON.stringify({
136
136
  botToken: "tok",
137
- chatId: "123",
137
+ adminChatId: "123",
138
138
  logLevel: "verbose",
139
139
  }));
140
140
 
@@ -156,7 +156,7 @@ describe("SettingsManager.load", () => {
156
156
  mkdirSync(tmpDir, { recursive: true });
157
157
  writeFileSync(join(tmpDir, "settings.json"), JSON.stringify({
158
158
  botToken: "tok",
159
- chatId: "123",
159
+ adminChatId: "123",
160
160
  timeZone: "Europe/Prgaaue",
161
161
  }));
162
162
 
@@ -199,8 +199,14 @@ describe("SettingsManager.loadRaw", () => {
199
199
 
200
200
  it("reads and parses valid settings file", () => {
201
201
  mkdirSync(tmpDir, { recursive: true });
202
- writeFileSync(join(tmpDir, "settings.json"), JSON.stringify({ botToken: "tok", chatId: "123" }));
203
- expect(new SettingsManager(tmpDir).loadRaw()).toEqual({ botToken: "tok", chatId: "123" });
202
+ writeFileSync(join(tmpDir, "settings.json"), JSON.stringify({ botToken: "tok", adminChatId: "123" }));
203
+ expect(new SettingsManager(tmpDir).loadRaw()).toEqual({ botToken: "tok", adminChatId: "123" });
204
+ });
205
+
206
+ it("migrates legacy chatId to adminChatId", () => {
207
+ mkdirSync(tmpDir, { recursive: true });
208
+ writeFileSync(join(tmpDir, "settings.json"), JSON.stringify({ botToken: "tok", chatId: "legacy-id" }));
209
+ expect(new SettingsManager(tmpDir).loadRaw()).toEqual({ botToken: "tok", adminChatId: "legacy-id" });
204
210
  });
205
211
 
206
212
  it("returns null for invalid JSON", () => {
@@ -218,7 +224,7 @@ describe("SettingsManager.loadRaw", () => {
218
224
 
219
225
  describe("SettingsManager.applyEnvOverrides", () => {
220
226
  const envVars = [
221
- "TELEGRAM_BOT_TOKEN", "AUTHORIZED_CHAT_ID", "MODEL",
227
+ "TELEGRAM_BOT_TOKEN", "ADMIN_CHAT_ID", "AUTHORIZED_CHAT_ID", "MODEL",
222
228
  "WORKSPACE", "TIMEZONE", "OPENAI_API_KEY", "LOG_LEVEL", "PINORAMA_URL",
223
229
  ];
224
230
  const savedEnv: Record<string, string | undefined> = {};
@@ -252,15 +258,29 @@ describe("SettingsManager.applyEnvOverrides", () => {
252
258
 
253
259
  it("overrides multiple fields and tracks them", () => {
254
260
  process.env.TELEGRAM_BOT_TOKEN = "override-token";
255
- process.env.AUTHORIZED_CHAT_ID = "99999";
261
+ process.env.ADMIN_CHAT_ID = "99999";
256
262
  process.env.OPENAI_API_KEY = "sk-override";
257
263
  const { settings, overrides } = new SettingsManager(tmpDir).applyEnvOverrides(validSettings);
258
264
  expect(settings.botToken).toBe("override-token");
259
- expect(settings.chatId).toBe("99999");
265
+ expect(settings.adminChatId).toBe("99999");
260
266
  expect(settings.openaiApiKey).toBe("sk-override");
261
267
  expect(overrides.size).toBe(3);
262
268
  });
263
269
 
270
+ it("falls back to legacy AUTHORIZED_CHAT_ID when ADMIN_CHAT_ID is unset", () => {
271
+ process.env.AUTHORIZED_CHAT_ID = "88888";
272
+ const { settings, overrides } = new SettingsManager(tmpDir).applyEnvOverrides(validSettings);
273
+ expect(settings.adminChatId).toBe("88888");
274
+ expect(overrides.has("adminChatId")).toBe(true);
275
+ });
276
+
277
+ it("prefers ADMIN_CHAT_ID over legacy AUTHORIZED_CHAT_ID when both set", () => {
278
+ process.env.ADMIN_CHAT_ID = "111";
279
+ process.env.AUTHORIZED_CHAT_ID = "222";
280
+ const { settings } = new SettingsManager(tmpDir).applyEnvOverrides(validSettings);
281
+ expect(settings.adminChatId).toBe("111");
282
+ });
283
+
264
284
  it("overrides workspace and log-related fields", () => {
265
285
  process.env.WORKSPACE = "/override/path";
266
286
  process.env.LOG_LEVEL = "error";
@@ -332,7 +352,11 @@ describe("SettingsManager.print", () => {
332
352
  describe("SettingsManager.envMapping", () => {
333
353
  it("is a static property with all settings keys", () => {
334
354
  expect(SettingsManager.envMapping.botToken).toBe("TELEGRAM_BOT_TOKEN");
335
- expect(SettingsManager.envMapping.chatId).toBe("AUTHORIZED_CHAT_ID");
355
+ expect(SettingsManager.envMapping.adminChatId).toBe("ADMIN_CHAT_ID");
336
356
  expect(SettingsManager.envMapping.model).toBe("MODEL");
337
357
  });
358
+
359
+ it("maps legacy AUTHORIZED_CHAT_ID to adminChatId for migration", () => {
360
+ expect(SettingsManager.envLegacy.adminChatId).toBe("AUTHORIZED_CHAT_ID");
361
+ });
338
362
  });
package/src/settings.ts CHANGED
@@ -8,7 +8,7 @@ const log = createLogger("settings");
8
8
 
9
9
  export const settingsSchema = z.object({
10
10
  botToken: z.string().trim(),
11
- chatId: z.string().trim().regex(/^-?\d+$/, "Must be a numeric Telegram chat ID"),
11
+ adminChatId: z.string().trim().regex(/^-?\d+$/, "Must be a numeric Telegram chat ID"),
12
12
  model: z.string().trim().pipe(z.enum(["haiku", "sonnet", "opus"])).default("sonnet"),
13
13
  workspace: z.string().trim().default("~/.macroclaw-workspace"),
14
14
  timeZone: z.string().trim().refine((tz) => IANAZone.isValidZone(tz), "Must be a valid IANA timezone").default("UTC"),
@@ -29,12 +29,24 @@ export function maskValue(key: string, value: string | undefined): string {
29
29
 
30
30
  const defaultDir = resolve(process.env.HOME || "~", ".macroclaw");
31
31
 
32
+ function migrateLegacy(raw: unknown): unknown {
33
+ if (raw && typeof raw === "object" && !Array.isArray(raw)) {
34
+ const obj = raw as Record<string, unknown>;
35
+ if (typeof obj.chatId === "string" && obj.adminChatId === undefined) {
36
+ const { chatId, ...rest } = obj;
37
+ log.warn("settings.json uses legacy `chatId` field; migrating to `adminChatId`");
38
+ return { ...rest, adminChatId: chatId };
39
+ }
40
+ }
41
+ return raw;
42
+ }
43
+
32
44
  export class SettingsManager {
33
45
  readonly #dir: string;
34
46
 
35
47
  static readonly envMapping: Record<keyof Settings, string> = {
36
48
  botToken: "TELEGRAM_BOT_TOKEN",
37
- chatId: "AUTHORIZED_CHAT_ID",
49
+ adminChatId: "ADMIN_CHAT_ID",
38
50
  model: "MODEL",
39
51
  workspace: "WORKSPACE",
40
52
  timeZone: "TIMEZONE",
@@ -43,6 +55,11 @@ export class SettingsManager {
43
55
  pinoramaUrl: "PINORAMA_URL",
44
56
  };
45
57
 
58
+ /** Legacy env var names still honored as fallbacks for one release. */
59
+ static readonly envLegacy: Partial<Record<keyof Settings, string>> = {
60
+ adminChatId: "AUTHORIZED_CHAT_ID",
61
+ };
62
+
46
63
  constructor(dir: string = defaultDir) {
47
64
  this.#dir = dir;
48
65
  }
@@ -60,7 +77,7 @@ export class SettingsManager {
60
77
 
61
78
  let raw: unknown;
62
79
  try {
63
- raw = JSON.parse(readFileSync(path, "utf-8"));
80
+ raw = migrateLegacy(JSON.parse(readFileSync(path, "utf-8")));
64
81
  } catch {
65
82
  raw = null;
66
83
  }
@@ -78,8 +95,8 @@ export class SettingsManager {
78
95
  const path = join(this.#dir, "settings.json");
79
96
  if (!existsSync(path)) return null;
80
97
  try {
81
- const raw = JSON.parse(readFileSync(path, "utf-8"));
82
- return typeof raw === "object" && raw !== null ? raw : null;
98
+ const raw = migrateLegacy(JSON.parse(readFileSync(path, "utf-8")));
99
+ return typeof raw === "object" && raw !== null ? (raw as Record<string, unknown>) : null;
83
100
  } catch {
84
101
  return null;
85
102
  }
@@ -99,6 +116,13 @@ export class SettingsManager {
99
116
  if (value !== undefined) {
100
117
  merged[key] = value;
101
118
  overrides.add(key);
119
+ continue;
120
+ }
121
+ const legacy = SettingsManager.envLegacy[key as keyof Settings];
122
+ if (legacy && process.env[legacy] !== undefined) {
123
+ merged[key] = process.env[legacy];
124
+ overrides.add(key);
125
+ log.warn({ legacy, preferred: envVar }, "using legacy env var; rename to the new one");
102
126
  }
103
127
  }
104
128
 
package/src/setup.test.ts CHANGED
@@ -46,6 +46,12 @@ mock.module("grammy", () => ({
46
46
  await mockBotStop();
47
47
  }
48
48
  },
49
+ InlineKeyboard: class {
50
+ inline_keyboard: never[] = [];
51
+ text() { return this; }
52
+ row() { return this; }
53
+ },
54
+ InputFile: class { constructor(public path: string) {} },
49
55
  }));
50
56
 
51
57
  const mockInstall = mock(() => "tail -f /mock/logs");
@@ -79,7 +85,7 @@ function createMockIO(inputs: string[]): SetupIo & { written: string[] } {
79
85
  }
80
86
 
81
87
  // Save/restore env vars
82
- const envVars = ["TELEGRAM_BOT_TOKEN", "AUTHORIZED_CHAT_ID", "MODEL", "WORKSPACE", "TIMEZONE", "OPENAI_API_KEY", "LOG_LEVEL"];
88
+ const envVars = ["TELEGRAM_BOT_TOKEN", "ADMIN_CHAT_ID", "MODEL", "WORKSPACE", "TIMEZONE", "OPENAI_API_KEY", "LOG_LEVEL"];
83
89
  const savedEnv: Record<string, string | undefined> = {};
84
90
 
85
91
  beforeEach(() => {
@@ -119,7 +125,7 @@ describe("SetupWizard", () => {
119
125
  const settings = await runSetup(io);
120
126
 
121
127
  expect(settings.botToken).toBe("123:ABC");
122
- expect(settings.chatId).toBe("12345678");
128
+ expect(settings.adminChatId).toBe("12345678");
123
129
  expect(settings.model).toBe("opus");
124
130
  expect(settings.workspace).toBe("/my/ws");
125
131
  expect(settings.timeZone).toBe("Europe/Prague");
@@ -129,7 +135,7 @@ describe("SetupWizard", () => {
129
135
 
130
136
  it("uses defaults from env vars when user presses enter", async () => {
131
137
  process.env.TELEGRAM_BOT_TOKEN = "env-token";
132
- process.env.AUTHORIZED_CHAT_ID = "99887766";
138
+ process.env.ADMIN_CHAT_ID = "99887766";
133
139
  process.env.MODEL = "haiku";
134
140
  process.env.WORKSPACE = "/env/ws";
135
141
  process.env.TIMEZONE = "America/New_York";
@@ -148,7 +154,7 @@ describe("SetupWizard", () => {
148
154
  const settings = await runSetup(io);
149
155
 
150
156
  expect(settings.botToken).toBe("env-token");
151
- expect(settings.chatId).toBe("99887766");
157
+ expect(settings.adminChatId).toBe("99887766");
152
158
  expect(settings.model).toBe("haiku");
153
159
  expect(settings.workspace).toBe("/env/ws");
154
160
  expect(settings.timeZone).toBe("America/New_York");
@@ -246,7 +252,7 @@ describe("SetupWizard", () => {
246
252
 
247
253
  const settings = await runSetup(io);
248
254
 
249
- expect(settings.chatId).toBe("456");
255
+ expect(settings.adminChatId).toBe("456");
250
256
  });
251
257
 
252
258
  it("re-prompts when chat ID is not numeric", async () => {
@@ -263,7 +269,7 @@ describe("SetupWizard", () => {
263
269
 
264
270
  const settings = await runSetup(io);
265
271
 
266
- expect(settings.chatId).toBe("456");
272
+ expect(settings.adminChatId).toBe("456");
267
273
  expect(io.written).toContainEqual(expect.stringContaining("Invalid value"));
268
274
  });
269
275