@rozek/nanoclaw 1.2.17

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.
Files changed (305) hide show
  1. package/.claude/settings.json +1 -0
  2. package/.claude/skills/add-compact/SKILL.md +135 -0
  3. package/.claude/skills/add-discord/SKILL.md +203 -0
  4. package/.claude/skills/add-gmail/SKILL.md +220 -0
  5. package/.claude/skills/add-image-vision/SKILL.md +94 -0
  6. package/.claude/skills/add-ollama-tool/SKILL.md +153 -0
  7. package/.claude/skills/add-parallel/SKILL.md +290 -0
  8. package/.claude/skills/add-pdf-reader/SKILL.md +104 -0
  9. package/.claude/skills/add-reactions/SKILL.md +117 -0
  10. package/.claude/skills/add-slack/SKILL.md +207 -0
  11. package/.claude/skills/add-telegram/SKILL.md +222 -0
  12. package/.claude/skills/add-telegram-swarm/SKILL.md +384 -0
  13. package/.claude/skills/add-voice-transcription/SKILL.md +148 -0
  14. package/.claude/skills/add-whatsapp/SKILL.md +372 -0
  15. package/.claude/skills/convert-to-apple-container/SKILL.md +175 -0
  16. package/.claude/skills/customize/SKILL.md +110 -0
  17. package/.claude/skills/debug/SKILL.md +349 -0
  18. package/.claude/skills/get-qodo-rules/SKILL.md +122 -0
  19. package/.claude/skills/get-qodo-rules/references/output-format.md +41 -0
  20. package/.claude/skills/get-qodo-rules/references/pagination.md +33 -0
  21. package/.claude/skills/get-qodo-rules/references/repository-scope.md +26 -0
  22. package/.claude/skills/qodo-pr-resolver/SKILL.md +326 -0
  23. package/.claude/skills/qodo-pr-resolver/resources/providers.md +329 -0
  24. package/.claude/skills/setup/SKILL.md +218 -0
  25. package/.claude/skills/update-nanoclaw/SKILL.md +235 -0
  26. package/.claude/skills/update-skills/SKILL.md +130 -0
  27. package/.claude/skills/use-local-whisper/SKILL.md +152 -0
  28. package/.claude/skills/x-integration/SKILL.md +417 -0
  29. package/.claude/skills/x-integration/agent.ts +243 -0
  30. package/.claude/skills/x-integration/host.ts +159 -0
  31. package/.claude/skills/x-integration/lib/browser.ts +148 -0
  32. package/.claude/skills/x-integration/lib/config.ts +62 -0
  33. package/.claude/skills/x-integration/scripts/like.ts +56 -0
  34. package/.claude/skills/x-integration/scripts/post.ts +66 -0
  35. package/.claude/skills/x-integration/scripts/quote.ts +80 -0
  36. package/.claude/skills/x-integration/scripts/reply.ts +74 -0
  37. package/.claude/skills/x-integration/scripts/retweet.ts +62 -0
  38. package/.claude/skills/x-integration/scripts/setup.ts +87 -0
  39. package/.env.example +1 -0
  40. package/.github/CODEOWNERS +10 -0
  41. package/.github/PULL_REQUEST_TEMPLATE.md +14 -0
  42. package/.github/workflows/bump-version.yml +32 -0
  43. package/.github/workflows/ci.yml +25 -0
  44. package/.github/workflows/merge-forward-skills.yml +160 -0
  45. package/.github/workflows/update-tokens.yml +42 -0
  46. package/.husky/pre-commit +1 -0
  47. package/.mcp.json +3 -0
  48. package/.nvmrc +1 -0
  49. package/.prettierrc +3 -0
  50. package/CHANGELOG.md +8 -0
  51. package/CLAUDE.md +64 -0
  52. package/CONTRIBUTING.md +23 -0
  53. package/CONTRIBUTORS.md +15 -0
  54. package/LICENSE +21 -0
  55. package/NanoClaw_with_Web-Support.md +290 -0
  56. package/README.md +261 -0
  57. package/README_zh.md +200 -0
  58. package/assets/nanoclaw-favicon.png +0 -0
  59. package/assets/nanoclaw-icon.png +0 -0
  60. package/assets/nanoclaw-logo-dark.png +0 -0
  61. package/assets/nanoclaw-logo.png +0 -0
  62. package/assets/nanoclaw-profile.jpeg +0 -0
  63. package/assets/nanoclaw-sales.png +0 -0
  64. package/assets/social-preview.jpg +0 -0
  65. package/config-examples/mount-allowlist.json +25 -0
  66. package/container/Dockerfile +70 -0
  67. package/container/agent-runner/package-lock.json +1524 -0
  68. package/container/agent-runner/package.json +21 -0
  69. package/container/agent-runner/src/index.ts +558 -0
  70. package/container/agent-runner/src/ipc-mcp-stdio.ts +338 -0
  71. package/container/agent-runner/tsconfig.json +15 -0
  72. package/container/build.sh +23 -0
  73. package/container/skills/agent-browser/SKILL.md +159 -0
  74. package/container/skills/capabilities/SKILL.md +100 -0
  75. package/container/skills/status/SKILL.md +104 -0
  76. package/dist/channels/index.d.ts +2 -0
  77. package/dist/channels/index.d.ts.map +1 -0
  78. package/dist/channels/index.js +9 -0
  79. package/dist/channels/index.js.map +1 -0
  80. package/dist/channels/registry.d.ts +13 -0
  81. package/dist/channels/registry.d.ts.map +1 -0
  82. package/dist/channels/registry.js +11 -0
  83. package/dist/channels/registry.js.map +1 -0
  84. package/dist/channels/registry.test.d.ts +2 -0
  85. package/dist/channels/registry.test.d.ts.map +1 -0
  86. package/dist/channels/registry.test.js +32 -0
  87. package/dist/channels/registry.test.js.map +1 -0
  88. package/dist/channels/web.d.ts +2 -0
  89. package/dist/channels/web.d.ts.map +1 -0
  90. package/dist/channels/web.js +1738 -0
  91. package/dist/channels/web.js.map +1 -0
  92. package/dist/cli.d.ts +11 -0
  93. package/dist/cli.d.ts.map +1 -0
  94. package/dist/cli.js +182 -0
  95. package/dist/cli.js.map +1 -0
  96. package/dist/config.d.ts +19 -0
  97. package/dist/config.d.ts.map +1 -0
  98. package/dist/config.js +36 -0
  99. package/dist/config.js.map +1 -0
  100. package/dist/container-runner.d.ts +44 -0
  101. package/dist/container-runner.d.ts.map +1 -0
  102. package/dist/container-runner.js +467 -0
  103. package/dist/container-runner.js.map +1 -0
  104. package/dist/container-runner.test.d.ts +2 -0
  105. package/dist/container-runner.test.d.ts.map +1 -0
  106. package/dist/container-runner.test.js +150 -0
  107. package/dist/container-runner.test.js.map +1 -0
  108. package/dist/container-runtime.d.ts +22 -0
  109. package/dist/container-runtime.d.ts.map +1 -0
  110. package/dist/container-runtime.js +96 -0
  111. package/dist/container-runtime.js.map +1 -0
  112. package/dist/container-runtime.test.d.ts +2 -0
  113. package/dist/container-runtime.test.d.ts.map +1 -0
  114. package/dist/container-runtime.test.js +93 -0
  115. package/dist/container-runtime.test.js.map +1 -0
  116. package/dist/credential-proxy.d.ts +21 -0
  117. package/dist/credential-proxy.d.ts.map +1 -0
  118. package/dist/credential-proxy.js +95 -0
  119. package/dist/credential-proxy.js.map +1 -0
  120. package/dist/credential-proxy.test.d.ts +2 -0
  121. package/dist/credential-proxy.test.d.ts.map +1 -0
  122. package/dist/credential-proxy.test.js +134 -0
  123. package/dist/credential-proxy.test.js.map +1 -0
  124. package/dist/db.d.ts +115 -0
  125. package/dist/db.d.ts.map +1 -0
  126. package/dist/db.js +549 -0
  127. package/dist/db.js.map +1 -0
  128. package/dist/db.test.d.ts +2 -0
  129. package/dist/db.test.d.ts.map +1 -0
  130. package/dist/db.test.js +360 -0
  131. package/dist/db.test.js.map +1 -0
  132. package/dist/env.d.ts +8 -0
  133. package/dist/env.d.ts.map +1 -0
  134. package/dist/env.js +42 -0
  135. package/dist/env.js.map +1 -0
  136. package/dist/formatting.test.d.ts +2 -0
  137. package/dist/formatting.test.d.ts.map +1 -0
  138. package/dist/formatting.test.js +183 -0
  139. package/dist/formatting.test.js.map +1 -0
  140. package/dist/group-folder.d.ts +5 -0
  141. package/dist/group-folder.d.ts.map +1 -0
  142. package/dist/group-folder.js +44 -0
  143. package/dist/group-folder.js.map +1 -0
  144. package/dist/group-folder.test.d.ts +2 -0
  145. package/dist/group-folder.test.d.ts.map +1 -0
  146. package/dist/group-folder.test.js +29 -0
  147. package/dist/group-folder.test.js.map +1 -0
  148. package/dist/group-queue.d.ts +34 -0
  149. package/dist/group-queue.d.ts.map +1 -0
  150. package/dist/group-queue.js +263 -0
  151. package/dist/group-queue.js.map +1 -0
  152. package/dist/group-queue.test.d.ts +2 -0
  153. package/dist/group-queue.test.d.ts.map +1 -0
  154. package/dist/group-queue.test.js +341 -0
  155. package/dist/group-queue.test.js.map +1 -0
  156. package/dist/index.d.ts +12 -0
  157. package/dist/index.d.ts.map +1 -0
  158. package/dist/index.js +518 -0
  159. package/dist/index.js.map +1 -0
  160. package/dist/ipc-auth.test.d.ts +2 -0
  161. package/dist/ipc-auth.test.d.ts.map +1 -0
  162. package/dist/ipc-auth.test.js +434 -0
  163. package/dist/ipc-auth.test.js.map +1 -0
  164. package/dist/ipc.d.ts +32 -0
  165. package/dist/ipc.d.ts.map +1 -0
  166. package/dist/ipc.js +311 -0
  167. package/dist/ipc.js.map +1 -0
  168. package/dist/logger.d.ts +3 -0
  169. package/dist/logger.d.ts.map +1 -0
  170. package/dist/logger.js +14 -0
  171. package/dist/logger.js.map +1 -0
  172. package/dist/mount-security.d.ts +34 -0
  173. package/dist/mount-security.d.ts.map +1 -0
  174. package/dist/mount-security.js +325 -0
  175. package/dist/mount-security.js.map +1 -0
  176. package/dist/remote-control.d.ts +32 -0
  177. package/dist/remote-control.d.ts.map +1 -0
  178. package/dist/remote-control.js +185 -0
  179. package/dist/remote-control.js.map +1 -0
  180. package/dist/remote-control.test.d.ts +2 -0
  181. package/dist/remote-control.test.d.ts.map +1 -0
  182. package/dist/remote-control.test.js +321 -0
  183. package/dist/remote-control.test.js.map +1 -0
  184. package/dist/router.d.ts +8 -0
  185. package/dist/router.d.ts.map +1 -0
  186. package/dist/router.js +37 -0
  187. package/dist/router.js.map +1 -0
  188. package/dist/routing.test.d.ts +2 -0
  189. package/dist/routing.test.d.ts.map +1 -0
  190. package/dist/routing.test.js +81 -0
  191. package/dist/routing.test.js.map +1 -0
  192. package/dist/sender-allowlist.d.ts +14 -0
  193. package/dist/sender-allowlist.d.ts.map +1 -0
  194. package/dist/sender-allowlist.js +79 -0
  195. package/dist/sender-allowlist.js.map +1 -0
  196. package/dist/sender-allowlist.test.d.ts +2 -0
  197. package/dist/sender-allowlist.test.d.ts.map +1 -0
  198. package/dist/sender-allowlist.test.js +186 -0
  199. package/dist/sender-allowlist.test.js.map +1 -0
  200. package/dist/session-commands.d.ts +47 -0
  201. package/dist/session-commands.d.ts.map +1 -0
  202. package/dist/session-commands.js +102 -0
  203. package/dist/session-commands.js.map +1 -0
  204. package/dist/session-commands.test.d.ts +2 -0
  205. package/dist/session-commands.test.d.ts.map +1 -0
  206. package/dist/session-commands.test.js +190 -0
  207. package/dist/session-commands.test.js.map +1 -0
  208. package/dist/task-scheduler.d.ts +22 -0
  209. package/dist/task-scheduler.d.ts.map +1 -0
  210. package/dist/task-scheduler.js +210 -0
  211. package/dist/task-scheduler.js.map +1 -0
  212. package/dist/task-scheduler.test.d.ts +2 -0
  213. package/dist/task-scheduler.test.d.ts.map +1 -0
  214. package/dist/task-scheduler.test.js +107 -0
  215. package/dist/task-scheduler.test.js.map +1 -0
  216. package/dist/timezone.d.ts +6 -0
  217. package/dist/timezone.d.ts.map +1 -0
  218. package/dist/timezone.js +17 -0
  219. package/dist/timezone.js.map +1 -0
  220. package/dist/timezone.test.d.ts +2 -0
  221. package/dist/timezone.test.d.ts.map +1 -0
  222. package/dist/timezone.test.js +23 -0
  223. package/dist/timezone.test.js.map +1 -0
  224. package/dist/types.d.ts +78 -0
  225. package/dist/types.d.ts.map +1 -0
  226. package/dist/types.js +2 -0
  227. package/dist/types.js.map +1 -0
  228. package/docs/APPLE-CONTAINER-NETWORKING.md +90 -0
  229. package/docs/DEBUG_CHECKLIST.md +143 -0
  230. package/docs/REQUIREMENTS.md +196 -0
  231. package/docs/SDK_DEEP_DIVE.md +643 -0
  232. package/docs/SECURITY.md +122 -0
  233. package/docs/SPEC.md +785 -0
  234. package/docs/docker-sandboxes.md +359 -0
  235. package/docs/nanoclaw-architecture-final.md +1063 -0
  236. package/docs/nanorepo-architecture.md +168 -0
  237. package/docs/skills-as-branches.md +662 -0
  238. package/groups/global/CLAUDE.md +58 -0
  239. package/groups/main/CLAUDE.md +246 -0
  240. package/launchd/com.nanoclaw.plist +32 -0
  241. package/package.json +45 -0
  242. package/repo-tokens/README.md +113 -0
  243. package/repo-tokens/action.yml +186 -0
  244. package/repo-tokens/badge.svg +23 -0
  245. package/repo-tokens/examples/green.svg +14 -0
  246. package/repo-tokens/examples/red.svg +14 -0
  247. package/repo-tokens/examples/yellow-green.svg +14 -0
  248. package/repo-tokens/examples/yellow.svg +14 -0
  249. package/scripts/run-migrations.ts +105 -0
  250. package/setup/container.ts +144 -0
  251. package/setup/environment.test.ts +121 -0
  252. package/setup/environment.ts +94 -0
  253. package/setup/groups.ts +229 -0
  254. package/setup/index.ts +58 -0
  255. package/setup/mounts.ts +115 -0
  256. package/setup/platform.test.ts +120 -0
  257. package/setup/platform.ts +132 -0
  258. package/setup/register.test.ts +257 -0
  259. package/setup/register.ts +177 -0
  260. package/setup/service.test.ts +187 -0
  261. package/setup/service.ts +362 -0
  262. package/setup/status.ts +16 -0
  263. package/setup/verify.ts +192 -0
  264. package/setup.sh +161 -0
  265. package/src/channels/index.ts +12 -0
  266. package/src/channels/registry.test.ts +42 -0
  267. package/src/channels/registry.ts +32 -0
  268. package/src/channels/web.ts +1856 -0
  269. package/src/cli.ts +209 -0
  270. package/src/config.ts +73 -0
  271. package/src/container-runner.test.ts +210 -0
  272. package/src/container-runner.ts +707 -0
  273. package/src/container-runtime.test.ts +149 -0
  274. package/src/container-runtime.ts +127 -0
  275. package/src/credential-proxy.test.ts +192 -0
  276. package/src/credential-proxy.ts +125 -0
  277. package/src/db.test.ts +484 -0
  278. package/src/db.ts +803 -0
  279. package/src/env.ts +42 -0
  280. package/src/formatting.test.ts +256 -0
  281. package/src/group-folder.test.ts +43 -0
  282. package/src/group-folder.ts +44 -0
  283. package/src/group-queue.test.ts +484 -0
  284. package/src/group-queue.ts +365 -0
  285. package/src/index.ts +731 -0
  286. package/src/ipc-auth.test.ts +679 -0
  287. package/src/ipc.ts +461 -0
  288. package/src/logger.ts +16 -0
  289. package/src/mount-security.ts +419 -0
  290. package/src/remote-control.test.ts +397 -0
  291. package/src/remote-control.ts +224 -0
  292. package/src/router.ts +52 -0
  293. package/src/routing.test.ts +170 -0
  294. package/src/sender-allowlist.test.ts +216 -0
  295. package/src/sender-allowlist.ts +128 -0
  296. package/src/session-commands.test.ts +247 -0
  297. package/src/session-commands.ts +163 -0
  298. package/src/task-scheduler.test.ts +129 -0
  299. package/src/task-scheduler.ts +295 -0
  300. package/src/timezone.test.ts +29 -0
  301. package/src/timezone.ts +16 -0
  302. package/src/types.ts +107 -0
  303. package/tsconfig.json +20 -0
  304. package/vitest.config.ts +7 -0
  305. package/vitest.skills.config.ts +7 -0
@@ -0,0 +1,170 @@
1
+ import { describe, it, expect, beforeEach } from 'vitest';
2
+
3
+ import { _initTestDatabase, getAllChats, storeChatMetadata } from './db.js';
4
+ import { getAvailableGroups, _setRegisteredGroups } from './index.js';
5
+
6
+ beforeEach(() => {
7
+ _initTestDatabase();
8
+ _setRegisteredGroups({});
9
+ });
10
+
11
+ // --- JID ownership patterns ---
12
+
13
+ describe('JID ownership patterns', () => {
14
+ // These test the patterns that will become ownsJid() on the Channel interface
15
+
16
+ it('WhatsApp group JID: ends with @g.us', () => {
17
+ const jid = '12345678@g.us';
18
+ expect(jid.endsWith('@g.us')).toBe(true);
19
+ });
20
+
21
+ it('WhatsApp DM JID: ends with @s.whatsapp.net', () => {
22
+ const jid = '12345678@s.whatsapp.net';
23
+ expect(jid.endsWith('@s.whatsapp.net')).toBe(true);
24
+ });
25
+ });
26
+
27
+ // --- getAvailableGroups ---
28
+
29
+ describe('getAvailableGroups', () => {
30
+ it('returns only groups, excludes DMs', () => {
31
+ storeChatMetadata(
32
+ 'group1@g.us',
33
+ '2024-01-01T00:00:01.000Z',
34
+ 'Group 1',
35
+ 'whatsapp',
36
+ true,
37
+ );
38
+ storeChatMetadata(
39
+ 'user@s.whatsapp.net',
40
+ '2024-01-01T00:00:02.000Z',
41
+ 'User DM',
42
+ 'whatsapp',
43
+ false,
44
+ );
45
+ storeChatMetadata(
46
+ 'group2@g.us',
47
+ '2024-01-01T00:00:03.000Z',
48
+ 'Group 2',
49
+ 'whatsapp',
50
+ true,
51
+ );
52
+
53
+ const groups = getAvailableGroups();
54
+ expect(groups).toHaveLength(2);
55
+ expect(groups.map((g) => g.jid)).toContain('group1@g.us');
56
+ expect(groups.map((g) => g.jid)).toContain('group2@g.us');
57
+ expect(groups.map((g) => g.jid)).not.toContain('user@s.whatsapp.net');
58
+ });
59
+
60
+ it('excludes __group_sync__ sentinel', () => {
61
+ storeChatMetadata('__group_sync__', '2024-01-01T00:00:00.000Z');
62
+ storeChatMetadata(
63
+ 'group@g.us',
64
+ '2024-01-01T00:00:01.000Z',
65
+ 'Group',
66
+ 'whatsapp',
67
+ true,
68
+ );
69
+
70
+ const groups = getAvailableGroups();
71
+ expect(groups).toHaveLength(1);
72
+ expect(groups[0].jid).toBe('group@g.us');
73
+ });
74
+
75
+ it('marks registered groups correctly', () => {
76
+ storeChatMetadata(
77
+ 'reg@g.us',
78
+ '2024-01-01T00:00:01.000Z',
79
+ 'Registered',
80
+ 'whatsapp',
81
+ true,
82
+ );
83
+ storeChatMetadata(
84
+ 'unreg@g.us',
85
+ '2024-01-01T00:00:02.000Z',
86
+ 'Unregistered',
87
+ 'whatsapp',
88
+ true,
89
+ );
90
+
91
+ _setRegisteredGroups({
92
+ 'reg@g.us': {
93
+ name: 'Registered',
94
+ folder: 'registered',
95
+ trigger: '@Andy',
96
+ added_at: '2024-01-01T00:00:00.000Z',
97
+ },
98
+ });
99
+
100
+ const groups = getAvailableGroups();
101
+ const reg = groups.find((g) => g.jid === 'reg@g.us');
102
+ const unreg = groups.find((g) => g.jid === 'unreg@g.us');
103
+
104
+ expect(reg?.isRegistered).toBe(true);
105
+ expect(unreg?.isRegistered).toBe(false);
106
+ });
107
+
108
+ it('returns groups ordered by most recent activity', () => {
109
+ storeChatMetadata(
110
+ 'old@g.us',
111
+ '2024-01-01T00:00:01.000Z',
112
+ 'Old',
113
+ 'whatsapp',
114
+ true,
115
+ );
116
+ storeChatMetadata(
117
+ 'new@g.us',
118
+ '2024-01-01T00:00:05.000Z',
119
+ 'New',
120
+ 'whatsapp',
121
+ true,
122
+ );
123
+ storeChatMetadata(
124
+ 'mid@g.us',
125
+ '2024-01-01T00:00:03.000Z',
126
+ 'Mid',
127
+ 'whatsapp',
128
+ true,
129
+ );
130
+
131
+ const groups = getAvailableGroups();
132
+ expect(groups[0].jid).toBe('new@g.us');
133
+ expect(groups[1].jid).toBe('mid@g.us');
134
+ expect(groups[2].jid).toBe('old@g.us');
135
+ });
136
+
137
+ it('excludes non-group chats regardless of JID format', () => {
138
+ // Unknown JID format stored without is_group should not appear
139
+ storeChatMetadata(
140
+ 'unknown-format-123',
141
+ '2024-01-01T00:00:01.000Z',
142
+ 'Unknown',
143
+ );
144
+ // Explicitly non-group with unusual JID
145
+ storeChatMetadata(
146
+ 'custom:abc',
147
+ '2024-01-01T00:00:02.000Z',
148
+ 'Custom DM',
149
+ 'custom',
150
+ false,
151
+ );
152
+ // A real group for contrast
153
+ storeChatMetadata(
154
+ 'group@g.us',
155
+ '2024-01-01T00:00:03.000Z',
156
+ 'Group',
157
+ 'whatsapp',
158
+ true,
159
+ );
160
+
161
+ const groups = getAvailableGroups();
162
+ expect(groups).toHaveLength(1);
163
+ expect(groups[0].jid).toBe('group@g.us');
164
+ });
165
+
166
+ it('returns empty array when no chats exist', () => {
167
+ const groups = getAvailableGroups();
168
+ expect(groups).toHaveLength(0);
169
+ });
170
+ });
@@ -0,0 +1,216 @@
1
+ import fs from 'fs';
2
+ import os from 'os';
3
+ import path from 'path';
4
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
5
+
6
+ import {
7
+ isSenderAllowed,
8
+ isTriggerAllowed,
9
+ loadSenderAllowlist,
10
+ SenderAllowlistConfig,
11
+ shouldDropMessage,
12
+ } from './sender-allowlist.js';
13
+
14
+ let tmpDir: string;
15
+
16
+ function cfgPath(name = 'sender-allowlist.json'): string {
17
+ return path.join(tmpDir, name);
18
+ }
19
+
20
+ function writeConfig(config: unknown, name?: string): string {
21
+ const p = cfgPath(name);
22
+ fs.writeFileSync(p, JSON.stringify(config));
23
+ return p;
24
+ }
25
+
26
+ beforeEach(() => {
27
+ tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'allowlist-test-'));
28
+ });
29
+
30
+ afterEach(() => {
31
+ fs.rmSync(tmpDir, { recursive: true, force: true });
32
+ });
33
+
34
+ describe('loadSenderAllowlist', () => {
35
+ it('returns allow-all defaults when file is missing', () => {
36
+ const cfg = loadSenderAllowlist(cfgPath());
37
+ expect(cfg.default.allow).toBe('*');
38
+ expect(cfg.default.mode).toBe('trigger');
39
+ expect(cfg.logDenied).toBe(true);
40
+ });
41
+
42
+ it('loads allow=* config', () => {
43
+ const p = writeConfig({
44
+ default: { allow: '*', mode: 'trigger' },
45
+ chats: {},
46
+ logDenied: false,
47
+ });
48
+ const cfg = loadSenderAllowlist(p);
49
+ expect(cfg.default.allow).toBe('*');
50
+ expect(cfg.logDenied).toBe(false);
51
+ });
52
+
53
+ it('loads allow=[] (deny all)', () => {
54
+ const p = writeConfig({
55
+ default: { allow: [], mode: 'trigger' },
56
+ chats: {},
57
+ });
58
+ const cfg = loadSenderAllowlist(p);
59
+ expect(cfg.default.allow).toEqual([]);
60
+ });
61
+
62
+ it('loads allow=[list]', () => {
63
+ const p = writeConfig({
64
+ default: { allow: ['alice', 'bob'], mode: 'drop' },
65
+ chats: {},
66
+ });
67
+ const cfg = loadSenderAllowlist(p);
68
+ expect(cfg.default.allow).toEqual(['alice', 'bob']);
69
+ expect(cfg.default.mode).toBe('drop');
70
+ });
71
+
72
+ it('per-chat override beats default', () => {
73
+ const p = writeConfig({
74
+ default: { allow: '*', mode: 'trigger' },
75
+ chats: { 'group-a': { allow: ['alice'], mode: 'drop' } },
76
+ });
77
+ const cfg = loadSenderAllowlist(p);
78
+ expect(cfg.chats['group-a'].allow).toEqual(['alice']);
79
+ expect(cfg.chats['group-a'].mode).toBe('drop');
80
+ });
81
+
82
+ it('returns allow-all on invalid JSON', () => {
83
+ const p = cfgPath();
84
+ fs.writeFileSync(p, '{ not valid json }}}');
85
+ const cfg = loadSenderAllowlist(p);
86
+ expect(cfg.default.allow).toBe('*');
87
+ });
88
+
89
+ it('returns allow-all on invalid schema', () => {
90
+ const p = writeConfig({ default: { oops: true } });
91
+ const cfg = loadSenderAllowlist(p);
92
+ expect(cfg.default.allow).toBe('*');
93
+ });
94
+
95
+ it('rejects non-string allow array items', () => {
96
+ const p = writeConfig({
97
+ default: { allow: [123, null, true], mode: 'trigger' },
98
+ chats: {},
99
+ });
100
+ const cfg = loadSenderAllowlist(p);
101
+ expect(cfg.default.allow).toBe('*'); // falls back to default
102
+ });
103
+
104
+ it('skips invalid per-chat entries', () => {
105
+ const p = writeConfig({
106
+ default: { allow: '*', mode: 'trigger' },
107
+ chats: {
108
+ good: { allow: ['alice'], mode: 'trigger' },
109
+ bad: { allow: 123 },
110
+ },
111
+ });
112
+ const cfg = loadSenderAllowlist(p);
113
+ expect(cfg.chats['good']).toBeDefined();
114
+ expect(cfg.chats['bad']).toBeUndefined();
115
+ });
116
+ });
117
+
118
+ describe('isSenderAllowed', () => {
119
+ it('allow=* allows any sender', () => {
120
+ const cfg: SenderAllowlistConfig = {
121
+ default: { allow: '*', mode: 'trigger' },
122
+ chats: {},
123
+ logDenied: true,
124
+ };
125
+ expect(isSenderAllowed('g1', 'anyone', cfg)).toBe(true);
126
+ });
127
+
128
+ it('allow=[] denies any sender', () => {
129
+ const cfg: SenderAllowlistConfig = {
130
+ default: { allow: [], mode: 'trigger' },
131
+ chats: {},
132
+ logDenied: true,
133
+ };
134
+ expect(isSenderAllowed('g1', 'anyone', cfg)).toBe(false);
135
+ });
136
+
137
+ it('allow=[list] allows exact match only', () => {
138
+ const cfg: SenderAllowlistConfig = {
139
+ default: { allow: ['alice', 'bob'], mode: 'trigger' },
140
+ chats: {},
141
+ logDenied: true,
142
+ };
143
+ expect(isSenderAllowed('g1', 'alice', cfg)).toBe(true);
144
+ expect(isSenderAllowed('g1', 'eve', cfg)).toBe(false);
145
+ });
146
+
147
+ it('uses per-chat entry over default', () => {
148
+ const cfg: SenderAllowlistConfig = {
149
+ default: { allow: '*', mode: 'trigger' },
150
+ chats: { g1: { allow: ['alice'], mode: 'trigger' } },
151
+ logDenied: true,
152
+ };
153
+ expect(isSenderAllowed('g1', 'bob', cfg)).toBe(false);
154
+ expect(isSenderAllowed('g2', 'bob', cfg)).toBe(true);
155
+ });
156
+ });
157
+
158
+ describe('shouldDropMessage', () => {
159
+ it('returns false for trigger mode', () => {
160
+ const cfg: SenderAllowlistConfig = {
161
+ default: { allow: '*', mode: 'trigger' },
162
+ chats: {},
163
+ logDenied: true,
164
+ };
165
+ expect(shouldDropMessage('g1', cfg)).toBe(false);
166
+ });
167
+
168
+ it('returns true for drop mode', () => {
169
+ const cfg: SenderAllowlistConfig = {
170
+ default: { allow: '*', mode: 'drop' },
171
+ chats: {},
172
+ logDenied: true,
173
+ };
174
+ expect(shouldDropMessage('g1', cfg)).toBe(true);
175
+ });
176
+
177
+ it('per-chat mode override', () => {
178
+ const cfg: SenderAllowlistConfig = {
179
+ default: { allow: '*', mode: 'trigger' },
180
+ chats: { g1: { allow: '*', mode: 'drop' } },
181
+ logDenied: true,
182
+ };
183
+ expect(shouldDropMessage('g1', cfg)).toBe(true);
184
+ expect(shouldDropMessage('g2', cfg)).toBe(false);
185
+ });
186
+ });
187
+
188
+ describe('isTriggerAllowed', () => {
189
+ it('allows trigger for allowed sender', () => {
190
+ const cfg: SenderAllowlistConfig = {
191
+ default: { allow: ['alice'], mode: 'trigger' },
192
+ chats: {},
193
+ logDenied: false,
194
+ };
195
+ expect(isTriggerAllowed('g1', 'alice', cfg)).toBe(true);
196
+ });
197
+
198
+ it('denies trigger for disallowed sender', () => {
199
+ const cfg: SenderAllowlistConfig = {
200
+ default: { allow: ['alice'], mode: 'trigger' },
201
+ chats: {},
202
+ logDenied: false,
203
+ };
204
+ expect(isTriggerAllowed('g1', 'eve', cfg)).toBe(false);
205
+ });
206
+
207
+ it('logs when logDenied is true', () => {
208
+ const cfg: SenderAllowlistConfig = {
209
+ default: { allow: ['alice'], mode: 'trigger' },
210
+ chats: {},
211
+ logDenied: true,
212
+ };
213
+ isTriggerAllowed('g1', 'eve', cfg);
214
+ // Logger.debug is called — we just verify no crash; logger is a real pino instance
215
+ });
216
+ });
@@ -0,0 +1,128 @@
1
+ import fs from 'fs';
2
+
3
+ import { SENDER_ALLOWLIST_PATH } from './config.js';
4
+ import { logger } from './logger.js';
5
+
6
+ export interface ChatAllowlistEntry {
7
+ allow: '*' | string[];
8
+ mode: 'trigger' | 'drop';
9
+ }
10
+
11
+ export interface SenderAllowlistConfig {
12
+ default: ChatAllowlistEntry;
13
+ chats: Record<string, ChatAllowlistEntry>;
14
+ logDenied: boolean;
15
+ }
16
+
17
+ const DEFAULT_CONFIG: SenderAllowlistConfig = {
18
+ default: { allow: '*', mode: 'trigger' },
19
+ chats: {},
20
+ logDenied: true,
21
+ };
22
+
23
+ function isValidEntry(entry: unknown): entry is ChatAllowlistEntry {
24
+ if (!entry || typeof entry !== 'object') return false;
25
+ const e = entry as Record<string, unknown>;
26
+ const validAllow =
27
+ e.allow === '*' ||
28
+ (Array.isArray(e.allow) && e.allow.every((v) => typeof v === 'string'));
29
+ const validMode = e.mode === 'trigger' || e.mode === 'drop';
30
+ return validAllow && validMode;
31
+ }
32
+
33
+ export function loadSenderAllowlist(
34
+ pathOverride?: string,
35
+ ): SenderAllowlistConfig {
36
+ const filePath = pathOverride ?? SENDER_ALLOWLIST_PATH;
37
+
38
+ let raw: string;
39
+ try {
40
+ raw = fs.readFileSync(filePath, 'utf-8');
41
+ } catch (err: unknown) {
42
+ if ((err as NodeJS.ErrnoException).code === 'ENOENT') return DEFAULT_CONFIG;
43
+ logger.warn(
44
+ { err, path: filePath },
45
+ 'sender-allowlist: cannot read config',
46
+ );
47
+ return DEFAULT_CONFIG;
48
+ }
49
+
50
+ let parsed: unknown;
51
+ try {
52
+ parsed = JSON.parse(raw);
53
+ } catch {
54
+ logger.warn({ path: filePath }, 'sender-allowlist: invalid JSON');
55
+ return DEFAULT_CONFIG;
56
+ }
57
+
58
+ const obj = parsed as Record<string, unknown>;
59
+
60
+ if (!isValidEntry(obj.default)) {
61
+ logger.warn(
62
+ { path: filePath },
63
+ 'sender-allowlist: invalid or missing default entry',
64
+ );
65
+ return DEFAULT_CONFIG;
66
+ }
67
+
68
+ const chats: Record<string, ChatAllowlistEntry> = {};
69
+ if (obj.chats && typeof obj.chats === 'object') {
70
+ for (const [jid, entry] of Object.entries(
71
+ obj.chats as Record<string, unknown>,
72
+ )) {
73
+ if (isValidEntry(entry)) {
74
+ chats[jid] = entry;
75
+ } else {
76
+ logger.warn(
77
+ { jid, path: filePath },
78
+ 'sender-allowlist: skipping invalid chat entry',
79
+ );
80
+ }
81
+ }
82
+ }
83
+
84
+ return {
85
+ default: obj.default as ChatAllowlistEntry,
86
+ chats,
87
+ logDenied: obj.logDenied !== false,
88
+ };
89
+ }
90
+
91
+ function getEntry(
92
+ chatJid: string,
93
+ cfg: SenderAllowlistConfig,
94
+ ): ChatAllowlistEntry {
95
+ return cfg.chats[chatJid] ?? cfg.default;
96
+ }
97
+
98
+ export function isSenderAllowed(
99
+ chatJid: string,
100
+ sender: string,
101
+ cfg: SenderAllowlistConfig,
102
+ ): boolean {
103
+ const entry = getEntry(chatJid, cfg);
104
+ if (entry.allow === '*') return true;
105
+ return entry.allow.includes(sender);
106
+ }
107
+
108
+ export function shouldDropMessage(
109
+ chatJid: string,
110
+ cfg: SenderAllowlistConfig,
111
+ ): boolean {
112
+ return getEntry(chatJid, cfg).mode === 'drop';
113
+ }
114
+
115
+ export function isTriggerAllowed(
116
+ chatJid: string,
117
+ sender: string,
118
+ cfg: SenderAllowlistConfig,
119
+ ): boolean {
120
+ const allowed = isSenderAllowed(chatJid, sender, cfg);
121
+ if (!allowed && cfg.logDenied) {
122
+ logger.debug(
123
+ { chatJid, sender },
124
+ 'sender-allowlist: trigger denied for sender',
125
+ );
126
+ }
127
+ return allowed;
128
+ }