My agentic slop goes here. Not intended for anyone else!
1(* Vicuna Bot - User Registration and Management Bot for Zulip *)
2
3open Zulip_bot
4
5(* Set up logging *)
6let src = Logs.Src.create "vicuna" ~doc:"Vicuna User Registration Bot"
7module Log = (val Logs.src_log src : Logs.LOG)
8
9let run_vicuna_bot config_file verbosity env =
10 (* Set up logging based on verbosity *)
11 Logs.set_reporter (Logs_fmt.reporter ());
12 let log_level = match verbosity with
13 | 0 -> Logs.Info
14 | 1 -> Logs.Debug
15 | _ -> Logs.Debug (* Cap at debug level *)
16 in
17 Logs.set_level (Some log_level);
18 Logs.Src.set_level src (Some log_level);
19
20 Log.app (fun m -> m "Starting Vicuna Bot - User Registration Manager");
21 Log.app (fun m -> m "Log level: %s" (Logs.level_to_string (Some log_level)));
22 Log.app (fun m -> m "========================================\n");
23
24 (* Load authentication from .zuliprc file *)
25 let auth = match Zulip.Auth.from_zuliprc ?path:config_file () with
26 | Ok a ->
27 Log.info (fun m -> m "Loaded authentication for: %s" (Zulip.Auth.email a));
28 Log.info (fun m -> m "Server: %s" (Zulip.Auth.server_url a));
29 a
30 | Error e ->
31 Log.err (fun m -> m "Failed to load .zuliprc: %s" (Zulip.error_message e));
32 Log.app (fun m -> m "\nPlease create a ~/.zuliprc file with:");
33 Log.app (fun m -> m "[api]");
34 Log.app (fun m -> m "email=bot@example.com");
35 Log.app (fun m -> m "key=your-api-key");
36 Log.app (fun m -> m "site=https://your-domain.zulipchat.com");
37 exit 1
38 in
39
40 Eio.Switch.run @@ fun sw ->
41 Log.debug (fun m -> m "Creating Zulip client");
42 let client = Zulip.Client.create ~sw env auth in
43
44 (* Create bot configuration *)
45 let config = Bot_config.create [] in
46 let bot_email = Zulip.Auth.email auth in
47
48 Log.debug (fun m -> m "Creating bot storage for %s" bot_email);
49 let storage = Bot_storage.create client ~bot_email in
50
51 let identity = Bot_handler.Identity.create
52 ~full_name:"Vicuna Bot"
53 ~email:bot_email
54 ~mention_name:"vicuna"
55 in
56 Log.info (fun m -> m "Bot identity created: %s (%s)"
57 (Bot_handler.Identity.full_name identity)
58 (Bot_handler.Identity.email identity));
59
60 (* Create the bot handler using the Vicuna bot library *)
61 Log.debug (fun m -> m "Creating Vicuna bot handler");
62 let handler = Vicuna_bot.create_handler config storage identity in
63
64 Log.debug (fun m -> m "Creating bot runner");
65 let runner = Bot_runner.create ~env ~client ~handler in
66
67 Log.app (fun m -> m "✨ Vicuna bot is running!");
68 Log.app (fun m -> m "📬 Send me a direct message to get started.");
69 Log.app (fun m -> m "🤖 Commands: 'register', 'whoami', 'whois', 'list', 'help'");
70 Log.app (fun m -> m "⛔ Press Ctrl+C to stop.\n");
71
72 (* Run in real-time mode *)
73 Log.info (fun m -> m "Starting real-time event loop");
74 try
75 Bot_runner.run_realtime runner;
76 Log.info (fun m -> m "Bot runner exited normally")
77 with
78 | Sys.Break ->
79 Log.info (fun m -> m "Received interrupt signal, shutting down");
80 Bot_runner.shutdown runner
81 | exn ->
82 Log.err (fun m -> m "Bot crashed with exception: %s" (Printexc.to_string exn));
83 Log.debug (fun m -> m "Backtrace: %s" (Printexc.get_backtrace ()));
84 raise exn
85
86(* Command-line interface *)
87open Cmdliner
88
89let config_file =
90 let doc = "Path to .zuliprc configuration file" in
91 Arg.(value & opt (some string) None & info ["c"; "config"] ~docv:"FILE" ~doc)
92
93let verbosity =
94 let doc = "Increase verbosity. Use multiple times for more verbose output." in
95 Arg.(value & flag_all & info ["v"; "verbose"] ~doc)
96
97let verbosity_term =
98 Term.(const List.length $ verbosity)
99
100(* CLI management commands *)
101let cli_add_user config_file user_id email full_name is_admin env =
102 Logs.set_reporter (Logs_fmt.reporter ());
103 Logs.set_level (Some Logs.Info);
104
105 let auth = match Zulip.Auth.from_zuliprc ?path:config_file () with
106 | Ok a -> a
107 | Error e ->
108 Printf.eprintf "Error loading config: %s\n" (Zulip.error_message e);
109 exit 1
110 in
111
112 Eio.Switch.run @@ fun sw ->
113 let client = Zulip.Client.create ~sw env auth in
114 let bot_email = Zulip.Auth.email auth in
115 let storage = Bot_storage.create client ~bot_email in
116
117 match Vicuna_bot.register_user ~is_admin storage email user_id full_name with
118 | Ok () ->
119 let admin_str = if is_admin then " (admin)" else "" in
120 Printf.printf "✅ User added%s:\n" admin_str;
121 Printf.printf " • Email: %s\n" email;
122 Printf.printf " • Zulip ID: %d\n" user_id;
123 Printf.printf " • Name: %s\n" full_name;
124 exit 0
125 | Error e ->
126 Printf.eprintf "❌ Failed to add user: %s\n" (Zulip.error_message e);
127 exit 1
128
129let cli_remove_user config_file user_id env =
130 Logs.set_reporter (Logs_fmt.reporter ());
131 Logs.set_level (Some Logs.Info);
132
133 let auth = match Zulip.Auth.from_zuliprc ?path:config_file () with
134 | Ok a -> a
135 | Error e ->
136 Printf.eprintf "Error loading config: %s\n" (Zulip.error_message e);
137 exit 1
138 in
139
140 Eio.Switch.run @@ fun sw ->
141 let client = Zulip.Client.create ~sw env auth in
142 let bot_email = Zulip.Auth.email auth in
143 let storage = Bot_storage.create client ~bot_email in
144
145 match Vicuna_bot.delete_user storage user_id with
146 | Ok () ->
147 Printf.printf "✅ User %d removed\n" user_id;
148 exit 0
149 | Error e ->
150 Printf.eprintf "❌ Failed to remove user: %s\n" (Zulip.error_message e);
151 exit 1
152
153let cli_set_admin config_file user_id is_admin env =
154 Logs.set_reporter (Logs_fmt.reporter ());
155 Logs.set_level (Some Logs.Info);
156
157 let auth = match Zulip.Auth.from_zuliprc ?path:config_file () with
158 | Ok a -> a
159 | Error e ->
160 Printf.eprintf "Error loading config: %s\n" (Zulip.error_message e);
161 exit 1
162 in
163
164 Eio.Switch.run @@ fun sw ->
165 let client = Zulip.Client.create ~sw env auth in
166 let bot_email = Zulip.Auth.email auth in
167 let storage = Bot_storage.create client ~bot_email in
168
169 match Vicuna_bot.set_admin storage user_id is_admin with
170 | Ok () ->
171 let action = if is_admin then "granted to" else "removed from" in
172 Printf.printf "✅ Admin privileges %s user %d\n" action user_id;
173 exit 0
174 | Error e ->
175 Printf.eprintf "❌ Failed to set admin: %s\n" (Zulip.error_message e);
176 exit 1
177
178let cli_list_users config_file show_admins_only env =
179 Logs.set_reporter (Logs_fmt.reporter ());
180 Logs.set_level (Some Logs.Warning);
181
182 let auth = match Zulip.Auth.from_zuliprc ?path:config_file () with
183 | Ok a -> a
184 | Error e ->
185 Printf.eprintf "Error loading config: %s\n" (Zulip.error_message e);
186 exit 1
187 in
188
189 Eio.Switch.run @@ fun sw ->
190 let client = Zulip.Client.create ~sw env auth in
191 let bot_email = Zulip.Auth.email auth in
192 let storage = Bot_storage.create client ~bot_email in
193
194 let user_ids = Vicuna_bot.get_all_user_ids storage in
195 let users = List.filter_map (fun id ->
196 match Vicuna_bot.lookup_user_by_id storage id with
197 | Some (user : Vicuna_bot.user_registration) when (not show_admins_only) || user.is_admin -> Some user
198 | _ -> None
199 ) user_ids in
200
201 if users = [] then (
202 if show_admins_only then
203 Printf.printf "No admin users found.\n"
204 else
205 Printf.printf "No users registered.\n";
206 exit 0
207 ) else (
208 let title = if show_admins_only then "Admin users" else "Registered users" in
209 Printf.printf "%s (%d):\n" title (List.length users);
210 List.iter (fun (user : Vicuna_bot.user_registration) ->
211 let admin_badge = if user.is_admin then " 👑" else "" in
212 Printf.printf " • %s (%s) - ID: %d%s\n"
213 user.full_name user.email user.zulip_id admin_badge
214 ) users;
215 exit 0
216 )
217
218(* Storage management commands *)
219let cli_storage_list config_file show_all env =
220 Logs.set_reporter (Logs_fmt.reporter ());
221 Logs.set_level (Some Logs.Warning);
222
223 let auth = match Zulip.Auth.from_zuliprc ?path:config_file () with
224 | Ok a -> a
225 | Error e ->
226 Printf.eprintf "Error loading config: %s\n" (Zulip.error_message e);
227 exit 1
228 in
229
230 Eio.Switch.run @@ fun sw ->
231 let client = Zulip.Client.create ~sw env auth in
232 let bot_email = Zulip.Auth.email auth in
233 let storage = Bot_storage.create client ~bot_email in
234
235 if show_all then (
236 (* Show ALL keys including deleted (empty) ones *)
237 match Bot_storage.keys storage with
238 | Ok keys ->
239 if keys = [] then (
240 Printf.printf "No storage keys found.\n";
241 exit 0
242 ) else (
243 Printf.printf "All storage keys including deleted (%d):\n" (List.length keys);
244 List.iter (fun key ->
245 match Vicuna_bot.get_storage_value storage key with
246 | Some value when value = "" ->
247 Printf.printf " • %s [DELETED]\n" key
248 | Some _ ->
249 Printf.printf " • %s\n" key
250 | None ->
251 Printf.printf " • %s [NOT FOUND]\n" key
252 ) keys;
253 exit 0
254 )
255 | Error e ->
256 Printf.eprintf "❌ Failed to list storage keys: %s\n" (Zulip.error_message e);
257 exit 1
258 ) else (
259 (* Normal list - only non-empty keys *)
260 match Vicuna_bot.get_storage_keys storage with
261 | Ok keys ->
262 if keys = [] then (
263 Printf.printf "No storage keys found.\n";
264 exit 0
265 ) else (
266 Printf.printf "Storage keys (%d):\n" (List.length keys);
267 List.iter (fun key ->
268 Printf.printf " • %s\n" key
269 ) keys;
270 exit 0
271 )
272 | Error e ->
273 Printf.eprintf "❌ Failed to list storage keys: %s\n" (Zulip.error_message e);
274 exit 1
275 )
276
277let cli_storage_view config_file key env =
278 Logs.set_reporter (Logs_fmt.reporter ());
279 Logs.set_level (Some Logs.Warning);
280
281 let auth = match Zulip.Auth.from_zuliprc ?path:config_file () with
282 | Ok a -> a
283 | Error e ->
284 Printf.eprintf "Error loading config: %s\n" (Zulip.error_message e);
285 exit 1
286 in
287
288 Eio.Switch.run @@ fun sw ->
289 let client = Zulip.Client.create ~sw env auth in
290 let bot_email = Zulip.Auth.email auth in
291 let storage = Bot_storage.create client ~bot_email in
292
293 match Vicuna_bot.get_storage_value storage key with
294 | Some value ->
295 Printf.printf "Key: %s\n" key;
296 Printf.printf "Value:\n%s\n" value;
297 exit 0
298 | None ->
299 Printf.eprintf "❌ Key not found: %s\n" key;
300 exit 1
301
302let cli_storage_delete config_file key env =
303 Logs.set_reporter (Logs_fmt.reporter ());
304 Logs.set_level (Some Logs.Info);
305
306 let auth = match Zulip.Auth.from_zuliprc ?path:config_file () with
307 | Ok a -> a
308 | Error e ->
309 Printf.eprintf "Error loading config: %s\n" (Zulip.error_message e);
310 exit 1
311 in
312
313 Eio.Switch.run @@ fun sw ->
314 let client = Zulip.Client.create ~sw env auth in
315 let bot_email = Zulip.Auth.email auth in
316 let storage = Bot_storage.create client ~bot_email in
317
318 match Vicuna_bot.delete_storage_key storage key with
319 | Ok () ->
320 Printf.printf "✅ Deleted storage key: %s\n" key;
321 exit 0
322 | Error e ->
323 Printf.eprintf "❌ Failed to delete storage key: %s\n" (Zulip.error_message e);
324 exit 1
325
326let cli_storage_clear config_file env =
327 Logs.set_reporter (Logs_fmt.reporter ());
328 Logs.set_level (Some Logs.Info);
329
330 let auth = match Zulip.Auth.from_zuliprc ?path:config_file () with
331 | Ok a -> a
332 | Error e ->
333 Printf.eprintf "Error loading config: %s\n" (Zulip.error_message e);
334 exit 1
335 in
336
337 Eio.Switch.run @@ fun sw ->
338 let client = Zulip.Client.create ~sw env auth in
339 let bot_email = Zulip.Auth.email auth in
340 let storage = Bot_storage.create client ~bot_email in
341
342 (* Get count before clearing *)
343 match Vicuna_bot.get_storage_keys storage with
344 | Error e ->
345 Printf.eprintf "❌ Failed to list storage keys: %s\n" (Zulip.error_message e);
346 exit 1
347 | Ok keys ->
348 let count = List.length keys in
349 if count = 0 then (
350 Printf.printf "Storage is already empty.\n";
351 exit 0
352 ) else (
353 match Vicuna_bot.clear_storage storage with
354 | Ok () ->
355 Printf.printf "✅ Cleared all storage (%d keys deleted)\n" count;
356 exit 0
357 | Error e ->
358 Printf.eprintf "❌ Failed to clear storage: %s\n" (Zulip.error_message e);
359 exit 1
360 )
361
362(* CLI command definitions *)
363let user_id_arg =
364 let doc = "Zulip user ID" in
365 Arg.(required & pos 0 (some int) None & info [] ~docv:"USER_ID" ~doc)
366
367let email_arg =
368 let doc = "User's email address" in
369 Arg.(required & pos 1 (some string) None & info [] ~docv:"EMAIL" ~doc)
370
371let full_name_arg =
372 let doc = "User's full name" in
373 Arg.(required & pos 2 (some string) None & info [] ~docv:"FULL_NAME" ~doc)
374
375let admin_flag =
376 let doc = "Grant admin privileges" in
377 Arg.(value & flag & info ["admin"] ~doc)
378
379let admins_only_flag =
380 let doc = "Show only admin users" in
381 Arg.(value & flag & info ["admins-only"] ~doc)
382
383let user_add_cmd eio_env =
384 let doc = "Add a user to the bot's registry" in
385 let info = Cmd.info "user-add" ~doc in
386 Cmd.v info Term.(const cli_add_user $ config_file $ user_id_arg $ email_arg $ full_name_arg $ admin_flag $ const eio_env)
387
388let user_remove_cmd eio_env =
389 let doc = "Remove a user from the bot's registry" in
390 let info = Cmd.info "user-remove" ~doc in
391 Cmd.v info Term.(const cli_remove_user $ config_file $ user_id_arg $ const eio_env)
392
393let admin_add_cmd eio_env =
394 let doc = "Grant admin privileges to a user" in
395 let info = Cmd.info "admin-add" ~doc in
396 Cmd.v info Term.(const cli_set_admin $ config_file $ user_id_arg $ const true $ const eio_env)
397
398let admin_remove_cmd eio_env =
399 let doc = "Remove admin privileges from a user" in
400 let info = Cmd.info "admin-remove" ~doc in
401 Cmd.v info Term.(const cli_set_admin $ config_file $ user_id_arg $ const false $ const eio_env)
402
403let user_list_cmd eio_env =
404 let doc = "List all registered users" in
405 let info = Cmd.info "user-list" ~doc in
406 Cmd.v info Term.(const cli_list_users $ config_file $ admins_only_flag $ const eio_env)
407
408(* Storage command arguments *)
409let storage_key_arg =
410 let doc = "Storage key" in
411 Arg.(required & pos 0 (some string) None & info [] ~docv:"KEY" ~doc)
412
413let storage_all_flag =
414 let doc = "Show all keys including deleted ones (with empty values)" in
415 Arg.(value & flag & info ["all"] ~doc)
416
417(* Storage subcommands *)
418let storage_list_cmd eio_env =
419 let doc = "List all storage keys" in
420 let info = Cmd.info "list" ~doc in
421 Cmd.v info Term.(const cli_storage_list $ config_file $ storage_all_flag $ const eio_env)
422
423let storage_view_cmd eio_env =
424 let doc = "View the value of a specific storage key" in
425 let info = Cmd.info "view" ~doc in
426 Cmd.v info Term.(const cli_storage_view $ config_file $ storage_key_arg $ const eio_env)
427
428let storage_delete_cmd eio_env =
429 let doc = "Delete a specific storage key" in
430 let info = Cmd.info "delete" ~doc in
431 Cmd.v info Term.(const cli_storage_delete $ config_file $ storage_key_arg $ const eio_env)
432
433let storage_clear_cmd eio_env =
434 let doc = "Clear all storage (delete all keys)" in
435 let info = Cmd.info "clear" ~doc in
436 Cmd.v info Term.(const cli_storage_clear $ config_file $ const eio_env)
437
438let storage_group eio_env =
439 let doc = "Manage bot storage" in
440 let info = Cmd.info "storage" ~doc in
441 let default_term = Term.(ret (const (`Help (`Auto, None)))) in
442 let cmds = [
443 storage_list_cmd eio_env;
444 storage_view_cmd eio_env;
445 storage_delete_cmd eio_env;
446 storage_clear_cmd eio_env;
447 ] in
448 Cmd.group info ~default:default_term cmds
449
450let main_group eio_env =
451 let default_info = Cmd.info "vicuna" ~version:"1.0.0" ~doc:"Vicuna - User Registration and Management Bot for Zulip" in
452 let default_term = Term.(const run_vicuna_bot $ config_file $ verbosity_term $ const eio_env) in
453 let cmds = [
454 user_add_cmd eio_env;
455 user_remove_cmd eio_env;
456 admin_add_cmd eio_env;
457 admin_remove_cmd eio_env;
458 user_list_cmd eio_env;
459 storage_group eio_env;
460 ] in
461 Cmd.group default_info ~default:default_term cmds
462
463let () =
464 (* Initialize the cryptographic RNG for the application *)
465 Mirage_crypto_rng_unix.use_default ();
466 Eio_main.run @@ fun env ->
467 exit (Cmd.eval (main_group env))