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/README.md
CHANGED
|
@@ -8,8 +8,10 @@ A lightweight bridge that turns a Telegram chat into a personal AI assistant —
|
|
|
8
8
|
|
|
9
9
|
Macroclaw runs with `dangerouslySkipPermissions` enabled. This is intentional — the bot
|
|
10
10
|
is designed to run in an isolated environment (container or VM) where the workspace is
|
|
11
|
-
the entire world.
|
|
12
|
-
|
|
11
|
+
the entire world. Access is gated by an allowlist of authorized chat IDs: an **admin
|
|
12
|
+
chat** configured at setup, plus any additional chats the admin authorizes at runtime
|
|
13
|
+
via `/chatsadd`. Every other chat is silently ignored. See [Docker](#docker) for the
|
|
14
|
+
recommended containerized setup.
|
|
13
15
|
|
|
14
16
|
## Requirements
|
|
15
17
|
|
|
@@ -37,16 +39,18 @@ On subsequent runs, settings are pre-filled from the file.
|
|
|
37
39
|
|
|
38
40
|
Settings are stored in `~/.macroclaw/settings.json` and validated on startup.
|
|
39
41
|
|
|
40
|
-
| Setting
|
|
41
|
-
|
|
42
|
-
| `botToken`
|
|
43
|
-
| `
|
|
44
|
-
| `model`
|
|
45
|
-
| `workspace`
|
|
46
|
-
| `timeZone`
|
|
47
|
-
| `openaiApiKey`
|
|
48
|
-
| `logLevel`
|
|
49
|
-
| `pinoramaUrl`
|
|
42
|
+
| Setting | Env var override | Default | Required |
|
|
43
|
+
|-----------------|------------------------|----------------------------|----------|
|
|
44
|
+
| `botToken` | `TELEGRAM_BOT_TOKEN` | — | Yes |
|
|
45
|
+
| `adminChatId` | `ADMIN_CHAT_ID` | — | Yes |
|
|
46
|
+
| `model` | `MODEL` | `sonnet` | No |
|
|
47
|
+
| `workspace` | `WORKSPACE` | `~/.macroclaw-workspace` | No |
|
|
48
|
+
| `timeZone` | `TIMEZONE` | `UTC` | No |
|
|
49
|
+
| `openaiApiKey` | `OPENAI_API_KEY` | — | No |
|
|
50
|
+
| `logLevel` | `LOG_LEVEL` | `info` | No |
|
|
51
|
+
| `pinoramaUrl` | `PINORAMA_URL` | — | No |
|
|
52
|
+
|
|
53
|
+
`AUTHORIZED_CHAT_ID` is still accepted as a legacy alias for `ADMIN_CHAT_ID`.
|
|
50
54
|
|
|
51
55
|
**`timeZone`** sets the agent's local time zone (IANA format, e.g. `Europe/Prague`, `America/New_York`). Used for the agent's clock display and scheduled event timing.
|
|
52
56
|
|
|
@@ -54,7 +58,36 @@ Settings are stored in `~/.macroclaw/settings.json` and validated on startup.
|
|
|
54
58
|
|
|
55
59
|
Env vars take precedence over settings file values. On startup, a masked settings summary is printed showing which values were overridden by env vars.
|
|
56
60
|
|
|
57
|
-
Session state (Claude session IDs) is stored separately in `~/.macroclaw/sessions.json`.
|
|
61
|
+
Session state (Claude session IDs) is stored separately in `~/.macroclaw/sessions.json`, keyed by chat name. Runtime-authorized chats are persisted in `~/.macroclaw/authorized-chats.json`.
|
|
62
|
+
|
|
63
|
+
### Multi-chat
|
|
64
|
+
|
|
65
|
+
One admin chat is configured at setup and is always authorized. To authorize additional chats at runtime, send these commands from the admin chat:
|
|
66
|
+
|
|
67
|
+
| Command | Description |
|
|
68
|
+
|---------|-------------|
|
|
69
|
+
| `/chats` | List authorized chats |
|
|
70
|
+
| `/chatsadd <chatId> <name>` | Authorize a new chat. `name` is a lowercase-kebab-case label (e.g. `family`, `work`) used in cron routing and session storage. |
|
|
71
|
+
| `/chatsremove <name>` | Revoke authorization and clear the chat's session |
|
|
72
|
+
| `/chatsremove` (no arg) | Admin: show a button picker. Non-admin authorized chats: self-removal. |
|
|
73
|
+
|
|
74
|
+
Each authorized chat has its own independent Claude session — memory, context, and tone stay isolated per chat. The agent knows which chat it's responding to via a `chat="<name>"` attribute on every event.
|
|
75
|
+
|
|
76
|
+
### Group chats and Telegram Privacy Mode
|
|
77
|
+
|
|
78
|
+
By default, Telegram puts bots in **Privacy Mode** when they're added to groups, which means the bot only receives:
|
|
79
|
+
|
|
80
|
+
- Messages that start with `/` (commands)
|
|
81
|
+
- Messages that @mention the bot
|
|
82
|
+
- Replies to the bot's own messages
|
|
83
|
+
|
|
84
|
+
Plain text in the group is filtered out by Telegram before it reaches the bot, so the bot appears silent. To let the bot see every message in a group:
|
|
85
|
+
|
|
86
|
+
1. Message [@BotFather](https://t.me/BotFather)
|
|
87
|
+
2. `/mybots` → select your bot → **Bot Settings** → **Group Privacy** → **Turn off**
|
|
88
|
+
3. Kick the bot from the group and re-add it — the privacy change only applies to fresh group memberships
|
|
89
|
+
|
|
90
|
+
Private (1:1) chats are unaffected.
|
|
58
91
|
|
|
59
92
|
## Commands
|
|
60
93
|
|
package/package.json
CHANGED
package/src/app.test.ts
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import { afterEach, beforeEach, describe, expect, it, mock } from "bun:test";
|
|
2
2
|
import { existsSync, rmSync } from "node:fs";
|
|
3
3
|
import { App, type AppConfig } from "./app";
|
|
4
|
+
import { AuthorizedChats } from "./authorized-chats";
|
|
4
5
|
import { type Claude, type ClaudeProcess, type ProcessState, QueryProcessError, type QueryResult } from "./claude";
|
|
5
|
-
import { saveSessions } from "./sessions";
|
|
6
|
+
import { loadSessions, saveSessions } from "./sessions";
|
|
6
7
|
import type { SpeechToText } from "./speech-to-text";
|
|
7
8
|
|
|
8
9
|
const mockTranscribe = mock(async (_filePath: string) => "transcribed text");
|
|
@@ -50,6 +51,20 @@ mock.module("grammy", () => ({
|
|
|
50
51
|
opts.onStart({ username: "test_bot", id: 123 });
|
|
51
52
|
}
|
|
52
53
|
},
|
|
54
|
+
InlineKeyboard: class MockInlineKeyboard {
|
|
55
|
+
inline_keyboard: Array<Array<{ text: string; callback_data: string }>> = [[]];
|
|
56
|
+
text(label: string, data: string) {
|
|
57
|
+
this.inline_keyboard[this.inline_keyboard.length - 1].push({ text: label, callback_data: data });
|
|
58
|
+
return this;
|
|
59
|
+
}
|
|
60
|
+
row() {
|
|
61
|
+
this.inline_keyboard.push([]);
|
|
62
|
+
return this;
|
|
63
|
+
}
|
|
64
|
+
},
|
|
65
|
+
InputFile: class MockInputFile {
|
|
66
|
+
constructor(public path: string) {}
|
|
67
|
+
},
|
|
53
68
|
}));
|
|
54
69
|
|
|
55
70
|
const tmpSettingsDir = "/tmp/macroclaw-test-settings";
|
|
@@ -59,7 +74,7 @@ beforeEach(() => {
|
|
|
59
74
|
mockTranscribe.mockReset();
|
|
60
75
|
mockTranscribe.mockImplementation(async () => "transcribed text");
|
|
61
76
|
if (existsSync(tmpSettingsDir)) rmSync(tmpSettingsDir, { recursive: true });
|
|
62
|
-
saveSessions({
|
|
77
|
+
saveSessions({ mainSessions: { admin: "test-session" } }, tmpSettingsDir);
|
|
63
78
|
});
|
|
64
79
|
|
|
65
80
|
afterEach(async () => {
|
|
@@ -130,7 +145,7 @@ function sentPrompts(claude: { processes: ClaudeProcess<unknown>[] }): string[]
|
|
|
130
145
|
function makeConfig(overrides?: Partial<AppConfig>): AppConfig {
|
|
131
146
|
return {
|
|
132
147
|
botToken: "test-token",
|
|
133
|
-
|
|
148
|
+
adminChatId: "12345",
|
|
134
149
|
workspace: "/tmp/macroclaw-test-workspace",
|
|
135
150
|
model: "sonnet",
|
|
136
151
|
timeZone: "UTC",
|
|
@@ -169,13 +184,16 @@ describe("App", () => {
|
|
|
169
184
|
expect(bot.filterHandlers.has("callback_query:data")).toBe(true);
|
|
170
185
|
});
|
|
171
186
|
|
|
172
|
-
it("registers chatid, bg, sessions, and
|
|
187
|
+
it("registers chatid, bg, sessions, clear, and chats commands", () => {
|
|
173
188
|
const app = makeApp();
|
|
174
189
|
const bot = app.bot as any;
|
|
175
190
|
expect(bot.commandHandlers.has("chatid")).toBe(true);
|
|
176
191
|
expect(bot.commandHandlers.has("bg")).toBe(true);
|
|
177
192
|
expect(bot.commandHandlers.has("sessions")).toBe(true);
|
|
178
193
|
expect(bot.commandHandlers.has("clear")).toBe(true);
|
|
194
|
+
expect(bot.commandHandlers.has("chats")).toBe(true);
|
|
195
|
+
expect(bot.commandHandlers.has("chatsadd")).toBe(true);
|
|
196
|
+
expect(bot.commandHandlers.has("chatsremove")).toBe(true);
|
|
179
197
|
});
|
|
180
198
|
|
|
181
199
|
it("registers error handler", () => {
|
|
@@ -793,11 +811,373 @@ describe("App", () => {
|
|
|
793
811
|
handler(ctx);
|
|
794
812
|
await new Promise((r) => setTimeout(r, 50));
|
|
795
813
|
|
|
796
|
-
// No sendMessage calls for unauthorized
|
|
797
814
|
expect((bot.api.sendMessage as any).mock.calls.length).toBe(0);
|
|
798
815
|
});
|
|
799
816
|
|
|
817
|
+
describe("/chats", () => {
|
|
818
|
+
it("lists authorized chats for admin", async () => {
|
|
819
|
+
const authorizedChats = new AuthorizedChats(tmpSettingsDir);
|
|
820
|
+
authorizedChats.add("55555", "family");
|
|
821
|
+
const app = makeApp({ authorizedChats });
|
|
822
|
+
const bot = app.bot as any;
|
|
823
|
+
const handler = bot.commandHandlers.get("chats")!;
|
|
824
|
+
|
|
825
|
+
handler({ chat: { id: 12345 } });
|
|
826
|
+
await new Promise((r) => setTimeout(r, 50));
|
|
827
|
+
|
|
828
|
+
const calls = (bot.api.sendMessage as any).mock.calls;
|
|
829
|
+
const text = calls[calls.length - 1][1];
|
|
830
|
+
expect(text).toContain("family");
|
|
831
|
+
expect(text).toContain("55555");
|
|
832
|
+
});
|
|
833
|
+
|
|
834
|
+
it("reports no chats when list is empty", async () => {
|
|
835
|
+
const app = makeApp();
|
|
836
|
+
const bot = app.bot as any;
|
|
837
|
+
const handler = bot.commandHandlers.get("chats")!;
|
|
838
|
+
|
|
839
|
+
handler({ chat: { id: 12345 } });
|
|
840
|
+
await new Promise((r) => setTimeout(r, 50));
|
|
841
|
+
|
|
842
|
+
const calls = (bot.api.sendMessage as any).mock.calls;
|
|
843
|
+
expect(calls[calls.length - 1][1]).toBe("No authorized chats.");
|
|
844
|
+
});
|
|
845
|
+
|
|
846
|
+
it("is ignored for non-admin chats", async () => {
|
|
847
|
+
const app = makeApp();
|
|
848
|
+
const bot = app.bot as any;
|
|
849
|
+
const handler = bot.commandHandlers.get("chats")!;
|
|
850
|
+
|
|
851
|
+
handler({ chat: { id: 99999 } });
|
|
852
|
+
await new Promise((r) => setTimeout(r, 50));
|
|
853
|
+
|
|
854
|
+
expect((bot.api.sendMessage as any).mock.calls.length).toBe(0);
|
|
855
|
+
});
|
|
856
|
+
});
|
|
857
|
+
|
|
858
|
+
describe("/chatsadd", () => {
|
|
859
|
+
it("adds chat and creates orchestrator for it", async () => {
|
|
860
|
+
const app = makeApp();
|
|
861
|
+
const bot = app.bot as any;
|
|
862
|
+
const handler = bot.commandHandlers.get("chatsadd")!;
|
|
863
|
+
|
|
864
|
+
handler({ chat: { id: 12345 }, match: "55555 family" });
|
|
865
|
+
await new Promise((r) => setTimeout(r, 50));
|
|
866
|
+
|
|
867
|
+
const calls = (bot.api.sendMessage as any).mock.calls;
|
|
868
|
+
expect(calls[calls.length - 1][1]).toBe('Chat "family" (55555) authorized.');
|
|
869
|
+
});
|
|
870
|
+
|
|
871
|
+
it("sends usage hint when args are missing", async () => {
|
|
872
|
+
const app = makeApp();
|
|
873
|
+
const bot = app.bot as any;
|
|
874
|
+
const handler = bot.commandHandlers.get("chatsadd")!;
|
|
875
|
+
|
|
876
|
+
handler({ chat: { id: 12345 }, match: "55555" });
|
|
877
|
+
await new Promise((r) => setTimeout(r, 50));
|
|
878
|
+
|
|
879
|
+
const calls = (bot.api.sendMessage as any).mock.calls;
|
|
880
|
+
expect(calls[calls.length - 1][1]).toContain("Usage:");
|
|
881
|
+
});
|
|
882
|
+
|
|
883
|
+
it("sends usage hint when match is empty", async () => {
|
|
884
|
+
const app = makeApp();
|
|
885
|
+
const bot = app.bot as any;
|
|
886
|
+
const handler = bot.commandHandlers.get("chatsadd")!;
|
|
887
|
+
|
|
888
|
+
handler({ chat: { id: 12345 }, match: "" });
|
|
889
|
+
await new Promise((r) => setTimeout(r, 50));
|
|
890
|
+
|
|
891
|
+
const calls = (bot.api.sendMessage as any).mock.calls;
|
|
892
|
+
expect(calls[calls.length - 1][1]).toContain("Usage:");
|
|
893
|
+
});
|
|
894
|
+
|
|
895
|
+
it("sends error for invalid chat name", async () => {
|
|
896
|
+
const app = makeApp();
|
|
897
|
+
const bot = app.bot as any;
|
|
898
|
+
const handler = bot.commandHandlers.get("chatsadd")!;
|
|
899
|
+
|
|
900
|
+
handler({ chat: { id: 12345 }, match: "55555 InvalidName" });
|
|
901
|
+
await new Promise((r) => setTimeout(r, 50));
|
|
902
|
+
|
|
903
|
+
const calls = (bot.api.sendMessage as any).mock.calls;
|
|
904
|
+
expect(calls[calls.length - 1][1]).toContain("Error:");
|
|
905
|
+
});
|
|
906
|
+
|
|
907
|
+
it("sends error for duplicate chat", async () => {
|
|
908
|
+
const authorizedChats = new AuthorizedChats(tmpSettingsDir);
|
|
909
|
+
authorizedChats.add("55555", "family");
|
|
910
|
+
const app = makeApp({ authorizedChats });
|
|
911
|
+
const bot = app.bot as any;
|
|
912
|
+
const handler = bot.commandHandlers.get("chatsadd")!;
|
|
913
|
+
|
|
914
|
+
handler({ chat: { id: 12345 }, match: "55555 family" });
|
|
915
|
+
await new Promise((r) => setTimeout(r, 50));
|
|
916
|
+
|
|
917
|
+
const calls = (bot.api.sendMessage as any).mock.calls;
|
|
918
|
+
expect(calls[calls.length - 1][1]).toContain("Error:");
|
|
919
|
+
});
|
|
920
|
+
|
|
921
|
+
it("rejects chatId that matches the admin chat", async () => {
|
|
922
|
+
const app = makeApp();
|
|
923
|
+
const bot = app.bot as any;
|
|
924
|
+
const handler = bot.commandHandlers.get("chatsadd")!;
|
|
925
|
+
|
|
926
|
+
handler({ chat: { id: 12345 }, match: "12345 family" });
|
|
927
|
+
await new Promise((r) => setTimeout(r, 50));
|
|
928
|
+
|
|
929
|
+
const calls = (bot.api.sendMessage as any).mock.calls;
|
|
930
|
+
expect(calls[calls.length - 1][1]).toContain("already the admin chat");
|
|
931
|
+
});
|
|
932
|
+
|
|
933
|
+
it("is ignored for non-admin chats", async () => {
|
|
934
|
+
const app = makeApp();
|
|
935
|
+
const bot = app.bot as any;
|
|
936
|
+
const handler = bot.commandHandlers.get("chatsadd")!;
|
|
937
|
+
|
|
938
|
+
handler({ chat: { id: 99999 }, match: "55555 family" });
|
|
939
|
+
await new Promise((r) => setTimeout(r, 50));
|
|
940
|
+
|
|
941
|
+
expect((bot.api.sendMessage as any).mock.calls.length).toBe(0);
|
|
942
|
+
});
|
|
943
|
+
|
|
944
|
+
it("newly added chat can send messages", async () => {
|
|
945
|
+
const config = makeConfig();
|
|
946
|
+
const app = trackApp(new App(config));
|
|
947
|
+
const bot = app.bot as any;
|
|
948
|
+
|
|
949
|
+
const addHandler = bot.commandHandlers.get("chatsadd")!;
|
|
950
|
+
addHandler({ chat: { id: 12345 }, match: "55555 family" });
|
|
951
|
+
await new Promise((r) => setTimeout(r, 50));
|
|
952
|
+
|
|
953
|
+
const claude = config.claude as Claude & { calls: CallInfo[]; processes: ClaudeProcess<unknown>[] };
|
|
954
|
+
const callsBefore = claude.calls.length;
|
|
955
|
+
|
|
956
|
+
const textHandler = bot.filterHandlers.get("message:text")![0];
|
|
957
|
+
textHandler({ chat: { id: 55555 }, message: { text: "hello from family" } });
|
|
958
|
+
await new Promise((r) => setTimeout(r, 50));
|
|
959
|
+
|
|
960
|
+
expect(claude.calls.length).toBeGreaterThan(callsBefore);
|
|
961
|
+
});
|
|
962
|
+
});
|
|
963
|
+
|
|
964
|
+
describe("/chatsremove", () => {
|
|
965
|
+
it("removes chat and clears its session", async () => {
|
|
966
|
+
saveSessions({ mainSessions: { admin: "admin-sid", family: "fam-sid" } }, tmpSettingsDir);
|
|
967
|
+
const authorizedChats = new AuthorizedChats(tmpSettingsDir);
|
|
968
|
+
authorizedChats.add("55555", "family");
|
|
969
|
+
const app = makeApp({ authorizedChats });
|
|
970
|
+
const bot = app.bot as any;
|
|
971
|
+
const handler = bot.commandHandlers.get("chatsremove")!;
|
|
972
|
+
|
|
973
|
+
await handler({ chat: { id: 12345 }, match: "family" });
|
|
974
|
+
await new Promise((r) => setTimeout(r, 50));
|
|
975
|
+
|
|
976
|
+
const calls = (bot.api.sendMessage as any).mock.calls;
|
|
977
|
+
expect(calls[calls.length - 1][1]).toBe('Chat "family" removed.');
|
|
978
|
+
|
|
979
|
+
const sessions = loadSessions(tmpSettingsDir);
|
|
980
|
+
expect(sessions.mainSessions.family).toBeUndefined();
|
|
981
|
+
expect(sessions.mainSessions.admin).toBe("admin-sid");
|
|
982
|
+
});
|
|
983
|
+
|
|
984
|
+
it("reports when no chats to remove and no arg given", async () => {
|
|
985
|
+
const app = makeApp();
|
|
986
|
+
const bot = app.bot as any;
|
|
987
|
+
const handler = bot.commandHandlers.get("chatsremove")!;
|
|
988
|
+
|
|
989
|
+
await handler({ chat: { id: 12345 }, match: "" });
|
|
990
|
+
await new Promise((r) => setTimeout(r, 50));
|
|
991
|
+
|
|
992
|
+
const calls = (bot.api.sendMessage as any).mock.calls;
|
|
993
|
+
expect(calls[calls.length - 1][1]).toBe("No authorized chats to remove.");
|
|
994
|
+
});
|
|
995
|
+
|
|
996
|
+
it("sends chat-picker buttons when no arg and chats exist", async () => {
|
|
997
|
+
const authorizedChats = new AuthorizedChats(tmpSettingsDir);
|
|
998
|
+
authorizedChats.add("55555", "family");
|
|
999
|
+
authorizedChats.add("66666", "work");
|
|
1000
|
+
const app = makeApp({ authorizedChats });
|
|
1001
|
+
const bot = app.bot as any;
|
|
1002
|
+
const handler = bot.commandHandlers.get("chatsremove")!;
|
|
1003
|
+
|
|
1004
|
+
await handler({ chat: { id: 12345 }, match: "" });
|
|
1005
|
+
await new Promise((r) => setTimeout(r, 50));
|
|
1006
|
+
|
|
1007
|
+
const calls = (bot.api.sendMessage as any).mock.calls;
|
|
1008
|
+
const lastCall = calls[calls.length - 1];
|
|
1009
|
+
expect(lastCall[1]).toBe("Which chat to remove?");
|
|
1010
|
+
const keyboard = lastCall[2].reply_markup.inline_keyboard.flat();
|
|
1011
|
+
const labels = keyboard.map((b: any) => b.text);
|
|
1012
|
+
expect(labels).toEqual(["family", "work", "Dismiss"]);
|
|
1013
|
+
expect(keyboard.find((b: any) => b.text === "family").callback_data).toBe("chatsremove:family");
|
|
1014
|
+
});
|
|
1015
|
+
|
|
1016
|
+
it("button callback removes the selected chat", async () => {
|
|
1017
|
+
saveSessions({ mainSessions: { admin: "admin-sid", family: "fam-sid" } }, tmpSettingsDir);
|
|
1018
|
+
const authorizedChats = new AuthorizedChats(tmpSettingsDir);
|
|
1019
|
+
authorizedChats.add("55555", "family");
|
|
1020
|
+
const app = makeApp({ authorizedChats });
|
|
1021
|
+
const bot = app.bot as any;
|
|
1022
|
+
const handler = bot.filterHandlers.get("callback_query:data")![0];
|
|
1023
|
+
|
|
1024
|
+
const ctx = {
|
|
1025
|
+
chat: { id: 12345 },
|
|
1026
|
+
callbackQuery: { data: "chatsremove:family" },
|
|
1027
|
+
answerCallbackQuery: mock(async () => {}),
|
|
1028
|
+
editMessageReplyMarkup: mock(async () => {}),
|
|
1029
|
+
};
|
|
1030
|
+
|
|
1031
|
+
await handler(ctx);
|
|
1032
|
+
await new Promise((r) => setTimeout(r, 50));
|
|
1033
|
+
|
|
1034
|
+
expect(ctx.editMessageReplyMarkup).toHaveBeenCalledWith({ reply_markup: { inline_keyboard: [[{ text: "✓ Removed family", callback_data: "_noop" }]] } });
|
|
1035
|
+
const calls = (bot.api.sendMessage as any).mock.calls;
|
|
1036
|
+
expect(calls[calls.length - 1][1]).toBe('Chat "family" removed.');
|
|
1037
|
+
|
|
1038
|
+
const sessions = loadSessions(tmpSettingsDir);
|
|
1039
|
+
expect(sessions.mainSessions.family).toBeUndefined();
|
|
1040
|
+
});
|
|
1041
|
+
|
|
1042
|
+
it("button callback is ignored for non-admin chats", async () => {
|
|
1043
|
+
const authorizedChats = new AuthorizedChats(tmpSettingsDir);
|
|
1044
|
+
authorizedChats.add("99999", "intruder");
|
|
1045
|
+
const app = makeApp({ authorizedChats });
|
|
1046
|
+
const bot = app.bot as any;
|
|
1047
|
+
const handler = bot.filterHandlers.get("callback_query:data")![0];
|
|
1048
|
+
|
|
1049
|
+
const ctx = {
|
|
1050
|
+
chat: { id: 99999 },
|
|
1051
|
+
callbackQuery: { data: "chatsremove:intruder" },
|
|
1052
|
+
answerCallbackQuery: mock(async () => {}),
|
|
1053
|
+
editMessageReplyMarkup: mock(async () => {}),
|
|
1054
|
+
};
|
|
1055
|
+
|
|
1056
|
+
await handler(ctx);
|
|
1057
|
+
await new Promise((r) => setTimeout(r, 50));
|
|
1058
|
+
|
|
1059
|
+
// Intruder is still authorized
|
|
1060
|
+
expect(authorizedChats.byName("intruder")).toBeDefined();
|
|
1061
|
+
});
|
|
1062
|
+
|
|
1063
|
+
it("sends error for unknown chat name", async () => {
|
|
1064
|
+
const app = makeApp();
|
|
1065
|
+
const bot = app.bot as any;
|
|
1066
|
+
const handler = bot.commandHandlers.get("chatsremove")!;
|
|
1067
|
+
|
|
1068
|
+
await handler({ chat: { id: 12345 }, match: "ghost" });
|
|
1069
|
+
await new Promise((r) => setTimeout(r, 50));
|
|
1070
|
+
|
|
1071
|
+
const calls = (bot.api.sendMessage as any).mock.calls;
|
|
1072
|
+
expect(calls[calls.length - 1][1]).toContain("Error:");
|
|
1073
|
+
});
|
|
1074
|
+
|
|
1075
|
+
it("ignores explicit-name removal from non-admin chats", async () => {
|
|
1076
|
+
const app = makeApp();
|
|
1077
|
+
const bot = app.bot as any;
|
|
1078
|
+
const handler = bot.commandHandlers.get("chatsremove")!;
|
|
1079
|
+
|
|
1080
|
+
await handler({ chat: { id: 99999 }, match: "family" });
|
|
1081
|
+
await new Promise((r) => setTimeout(r, 50));
|
|
1082
|
+
|
|
1083
|
+
expect((bot.api.sendMessage as any).mock.calls.length).toBe(0);
|
|
1084
|
+
});
|
|
1085
|
+
|
|
1086
|
+
it("self-removes when called from non-admin chat with no arg", async () => {
|
|
1087
|
+
const authorizedChats = new AuthorizedChats(tmpSettingsDir);
|
|
1088
|
+
authorizedChats.add("55555", "family");
|
|
1089
|
+
saveSessions({ mainSessions: { admin: "a", family: "f" } }, tmpSettingsDir);
|
|
1090
|
+
const app = makeApp({ authorizedChats });
|
|
1091
|
+
const bot = app.bot as any;
|
|
1092
|
+
const handler = bot.commandHandlers.get("chatsremove")!;
|
|
1093
|
+
|
|
1094
|
+
await handler({ chat: { id: 55555 }, match: "" });
|
|
1095
|
+
await new Promise((r) => setTimeout(r, 50));
|
|
1096
|
+
|
|
1097
|
+
const calls = (bot.api.sendMessage as any).mock.calls;
|
|
1098
|
+
expect(calls[calls.length - 1][1]).toBe('Chat "family" removed.');
|
|
1099
|
+
expect(authorizedChats.byName("family")).toBeUndefined();
|
|
1100
|
+
expect(loadSessions(tmpSettingsDir).mainSessions.family).toBeUndefined();
|
|
1101
|
+
});
|
|
1102
|
+
|
|
1103
|
+
it("ignores no-arg self-removal from unauthorized non-admin chat", async () => {
|
|
1104
|
+
const app = makeApp();
|
|
1105
|
+
const bot = app.bot as any;
|
|
1106
|
+
const handler = bot.commandHandlers.get("chatsremove")!;
|
|
1107
|
+
|
|
1108
|
+
await handler({ chat: { id: 77777 }, match: "" });
|
|
1109
|
+
await new Promise((r) => setTimeout(r, 50));
|
|
1110
|
+
|
|
1111
|
+
expect((bot.api.sendMessage as any).mock.calls.length).toBe(0);
|
|
1112
|
+
});
|
|
1113
|
+
});
|
|
1114
|
+
});
|
|
1115
|
+
|
|
1116
|
+
describe("handleCron", () => {
|
|
1117
|
+
it("routes cron job with no chat to admin orchestrator", async () => {
|
|
1118
|
+
const config = makeConfig();
|
|
1119
|
+
const app = trackApp(new App(config));
|
|
1120
|
+
|
|
1121
|
+
const claude = config.claude as Claude & { calls: CallInfo[] };
|
|
1122
|
+
const callsBefore = claude.calls.length;
|
|
1123
|
+
app.handleCron("check-in", "any news?", undefined, undefined, undefined);
|
|
1124
|
+
await new Promise((r) => setTimeout(r, 50));
|
|
1125
|
+
|
|
1126
|
+
expect(claude.calls.length).toBeGreaterThan(callsBefore);
|
|
1127
|
+
});
|
|
1128
|
+
|
|
1129
|
+
it("routes cron job with chat:'admin' to admin orchestrator", async () => {
|
|
1130
|
+
const config = makeConfig();
|
|
1131
|
+
const app = trackApp(new App(config));
|
|
1132
|
+
|
|
1133
|
+
const claude = config.claude as Claude & { calls: CallInfo[] };
|
|
1134
|
+
const callsBefore = claude.calls.length;
|
|
1135
|
+
app.handleCron("check-in", "any news?", undefined, undefined, "admin");
|
|
1136
|
+
await new Promise((r) => setTimeout(r, 50));
|
|
1137
|
+
|
|
1138
|
+
expect(claude.calls.length).toBeGreaterThan(callsBefore);
|
|
1139
|
+
});
|
|
1140
|
+
|
|
1141
|
+
it("routes cron job with chat name to that chat's orchestrator", async () => {
|
|
1142
|
+
const authorizedChats = new AuthorizedChats(tmpSettingsDir);
|
|
1143
|
+
authorizedChats.add("55555", "family");
|
|
1144
|
+
const config = makeConfig({ authorizedChats });
|
|
1145
|
+
const app = trackApp(new App(config));
|
|
1146
|
+
|
|
1147
|
+
const claude = config.claude as Claude & { calls: CallInfo[] };
|
|
1148
|
+
const callsBefore = claude.calls.length;
|
|
1149
|
+
app.handleCron("family-update", "hello family", undefined, undefined, "family");
|
|
1150
|
+
await new Promise((r) => setTimeout(r, 50));
|
|
1151
|
+
|
|
1152
|
+
expect(claude.calls.length).toBeGreaterThan(callsBefore);
|
|
1153
|
+
});
|
|
1154
|
+
|
|
1155
|
+
it("broadcasts cron job with chat:'*' to all orchestrators", async () => {
|
|
1156
|
+
const authorizedChats = new AuthorizedChats(tmpSettingsDir);
|
|
1157
|
+
authorizedChats.add("55555", "family");
|
|
1158
|
+
const config = makeConfig({ authorizedChats });
|
|
1159
|
+
const app = trackApp(new App(config));
|
|
1160
|
+
|
|
1161
|
+
const claude = config.claude as Claude & { calls: CallInfo[] };
|
|
1162
|
+
const callsBefore = claude.calls.length;
|
|
1163
|
+
app.handleCron("broadcast", "everyone", undefined, undefined, "*");
|
|
1164
|
+
await new Promise((r) => setTimeout(r, 50));
|
|
1165
|
+
|
|
1166
|
+
// 2 orchestrators (admin + family) → at least 2 additional claude calls
|
|
1167
|
+
expect(claude.calls.length).toBeGreaterThanOrEqual(callsBefore + 2);
|
|
1168
|
+
});
|
|
1169
|
+
|
|
1170
|
+
it("warns and does nothing for unknown chat name", async () => {
|
|
1171
|
+
const config = makeConfig();
|
|
1172
|
+
const app = trackApp(new App(config));
|
|
800
1173
|
|
|
1174
|
+
const claude = config.claude as Claude & { calls: CallInfo[] };
|
|
1175
|
+
const callsBefore = claude.calls.length;
|
|
1176
|
+
app.handleCron("orphan", "hello?", undefined, undefined, "nobody");
|
|
1177
|
+
await new Promise((r) => setTimeout(r, 50));
|
|
1178
|
+
|
|
1179
|
+
expect(claude.calls.length).toBe(callsBefore);
|
|
1180
|
+
});
|
|
801
1181
|
});
|
|
802
1182
|
|
|
803
1183
|
describe("error handler", () => {
|
|
@@ -825,6 +1205,9 @@ describe("App", () => {
|
|
|
825
1205
|
{ command: "bg", description: "Spawn a background agent" },
|
|
826
1206
|
{ command: "sessions", description: "List running sessions" },
|
|
827
1207
|
{ command: "clear", description: "Clear session and start fresh" },
|
|
1208
|
+
{ command: "chats", description: "List authorized chats (admin only)" },
|
|
1209
|
+
{ command: "chatsadd", description: "Authorize a new chat (admin only)" },
|
|
1210
|
+
{ command: "chatsremove", description: "Remove an authorized chat (admin only)" },
|
|
828
1211
|
]);
|
|
829
1212
|
});
|
|
830
1213
|
});
|