My agentic slop goes here. Not intended for anyone else!

vicuna

+32
stack/vicuna/.gitignore
···
+
# Dune build artifacts
+
_build/
+
*.install
+
*.merlin
+
+
# OCaml artifacts
+
*.cmi
+
*.cmo
+
*.cmx
+
*.cma
+
*.cmxa
+
*.cmxs
+
*.o
+
*.a
+
*.so
+
*.annot
+
*.cmt
+
*.cmti
+
+
# Editor files
+
*.swp
+
*~
+
.vscode/
+
.idea/
+
+
# Configuration files with credentials
+
.zuliprc
+
*.zuliprc
+
+
# OS files
+
.DS_Store
+
Thumbs.db
+14
stack/vicuna/.zuliprc.example
···
+
# Example Zulip configuration file for Vicuna bot
+
# Copy this to ~/.zuliprc and fill in your bot's credentials
+
+
[api]
+
email=vicuna-bot@your-domain.zulipchat.com
+
key=your-bot-api-key-here
+
site=https://your-domain.zulipchat.com
+
+
# To get your bot's API key:
+
# 1. Go to your Zulip instance's settings
+
# 2. Navigate to Settings > Your bots
+
# 3. Create a new bot or select an existing one
+
# 4. Copy the API key and email address
+
# 5. Update the values above
+265
stack/vicuna/README.md
···
+
# Vicuna Bot
+
+
A Zulip bot for user registration and management. Vicuna helps users register their email addresses and Zulip IDs, creating a persistent directory for easy @mentions and user tracking.
+
+
## Features
+
+
- **User Registration**: Users can register themselves by sending a simple command
+
- **Custom Email Support**: Register with your actual email address (not just the auto-generated Zulip email)
+
- **Persistent Storage**: All registrations are stored on the server using Zulip's bot storage API
+
- **Bidirectional Lookup**: Look up users by email address or Zulip ID
+
- **Message-based Configuration**: All configuration happens via messages to the bot
+
- **User Directory**: List all registered users
+
+
## OAuth/SSO Email Issue & Solution
+
+
**Problem:** Many Zulip instances that use OAuth, SSO, or other authentication methods assign auto-generated emails to users like `user123@zulipchat.com` instead of their actual email addresses.
+
+
**Smart Solution:** Vicuna automatically tries to find your real email address!
+
+
When you type `register`, the bot uses this priority:
+
1. **Custom email** you provide: `register alice@company.com` (highest priority)
+
2. **delivery_email** from your Zulip profile (the real email associated with your account)
+
3. **user.email** from your Zulip profile API (may differ from message sender email)
+
4. **Zulip message email** as a last resort (the internal `user@zulipchat.com` style)
+
+
This means **most users can just type `register`** and the bot will automatically use their real email address!
+
+
### Manual Email Registration
+
+
If needed, you can still manually specify your email:
+
+
```bash
+
register your-actual-email@example.com
+
```
+
+
The bot will:
+
- Store your actual email for lookups
+
- Still track your Zulip ID for @mentions
+
- Show you both your registered email and your Zulip-internal email
+
+
This way, colleagues can find you by your real email address while the bot maintains the proper Zulip ID mapping.
+
+
## Installation
+
+
```bash
+
# Build the bot
+
cd vicuna
+
dune build
+
+
# Install (optional)
+
dune install
+
```
+
+
## Configuration
+
+
Create a `~/.zuliprc` file with your bot credentials:
+
+
```ini
+
[api]
+
email=vicuna-bot@your-domain.zulipchat.com
+
key=your-bot-api-key
+
site=https://your-domain.zulipchat.com
+
```
+
+
## Usage
+
+
### Running the Bot
+
+
```bash
+
# Run with default configuration
+
vicuna
+
+
# Run with verbose logging
+
vicuna -v
+
+
# Run with debug logging
+
vicuna -vv
+
+
# Run with custom config file
+
vicuna -c /path/to/.zuliprc
+
```
+
+
### Bot Commands
+
+
Send these commands to the bot via direct message or by mentioning it in a channel:
+
+
#### `register` or `register <your-email@example.com>`
+
Register your email and Zulip ID in the system.
+
+
**Smart auto-detection** (recommended - just type `register`):
+
```
+
> register
+
✅ Successfully registered!
+
• Email: `alice@mycompany.com`
+
• Zulip ID: `12345`
+
• Full Name: `Alice Smith`
+
+
💡 Your Zulip email is: `user123@zulipchat.com`
+
📧 Using your delivery email from your profile
+
You can now be @mentioned by your email or Zulip ID!
+
```
+
+
The bot automatically fetched `alice@mycompany.com` from your Zulip profile's `delivery_email` field!
+
+
**Manual registration** (if you want to override):
+
```
+
> register alice@different-email.com
+
✅ Successfully registered!
+
• Email: `alice@different-email.com`
+
• Zulip ID: `12345`
+
• Full Name: `Alice Smith`
+
+
💡 Your Zulip email is: `user123@zulipchat.com`
+
📝 Using the custom email you provided
+
You can now be @mentioned by your email or Zulip ID!
+
```
+
+
**Note:** The bot tries four sources in order:
+
1. Custom email you provide (highest priority)
+
2. `delivery_email` from your Zulip profile (auto-detected)
+
3. `user.email` from your Zulip profile API (may be real email depending on permissions)
+
4. Zulip message sender email (fallback)
+
+
#### `whoami`
+
Check your registration status.
+
+
```
+
> whoami
+
📋 Your registration info:
+
• Email: `alice@example.com`
+
• Zulip ID: `12345`
+
• Full Name: `Alice Smith`
+
• Registered: 2025-01-15 10:30:45
+
```
+
+
#### `whois <email|id>`
+
Look up a registered user by their email or Zulip ID.
+
+
```
+
> whois bob@example.com
+
👤 User found:
+
• Email: `bob@example.com`
+
• Zulip ID: `67890`
+
• Full Name: `Bob Jones`
+
• Registered: 2025-01-14 09:15:22
+
+
> whois 67890
+
👤 User found:
+
• Email: `bob@example.com`
+
• Zulip ID: `67890`
+
• Full Name: `Bob Jones`
+
• Registered: 2025-01-14 09:15:22
+
```
+
+
#### `list`
+
List all registered users.
+
+
```
+
> list
+
📋 Registered users (3):
+
• **Alice Smith** (`alice@example.com`) - ID: 12345
+
• **Bob Jones** (`bob@example.com`) - ID: 67890
+
• **Carol White** (`carol@example.com`) - ID: 54321
+
```
+
+
#### `help`
+
Show available commands and usage information.
+
+
```
+
> help
+
👋 Hi Alice! I'm **Vicuna**, your user registration assistant.
+
+
Available Commands:
+
• `register` - Register with your Zulip email
+
• `register <your-email@example.com>` - Register with a custom email
+
• `whoami` - Show your registration status
+
• `whois <email|id>` - Look up a registered user
+
• `list` - List all registered users
+
• `help` - Show this help message
+
+
Examples:
+
• `register` - Register with Zulip email (`user123@zulipchat.com`)
+
• `register alice@mycompany.com` - Register with your actual email
+
• `whois alice@example.com` - Look up Alice by email
+
• `whois 12345` - Look up user by Zulip ID
+
+
Note: Many Zulip instances use auto-generated emails like `user@zulipchat.com`.
+
You can provide your actual email address during registration!
+
+
Send me a direct message to get started!
+
```
+
+
## Architecture
+
+
### Libraries Used
+
+
- **zulip**: OCaml bindings for the Zulip REST API
+
- **zulip_bot**: Bot framework for building interactive Zulip bots
+
- **eio**: Effects-based I/O for async operations
+
- **logs**: Structured logging
+
- **cmdliner**: Command-line interface
+
+
### Storage
+
+
Vicuna uses Zulip's bot storage API to persist user registrations. The storage format is:
+
+
- **User by email**: `user:email:<email>` → `<email>|<zulip_id>|<full_name>|<timestamp>`
+
- **User by ID**: `user:id:<zulip_id>` → `<email>|<zulip_id>|<full_name>|<timestamp>`
+
- **User list**: `users:all` → `<email1>,<email2>,<email3>,...`
+
+
This allows for efficient bidirectional lookups and maintains a master list of all registered users.
+
+
## Development
+
+
### Project Structure
+
+
```
+
vicuna/
+
├── dune-project # Project definition
+
├── README.md # This file
+
├── lib/ # Bot library
+
│ ├── dune # Library build config
+
│ ├── vicuna_bot.ml # Bot implementation
+
│ └── vicuna_bot.mli # Bot interface
+
└── bin/ # Executable
+
├── dune # Executable build config
+
└── main.ml # Main entry point
+
```
+
+
### Building
+
+
```bash
+
# Build the project
+
dune build
+
+
# Build with verbose output
+
dune build --verbose
+
+
# Clean build artifacts
+
dune clean
+
```
+
+
### Testing
+
+
You can test the bot by running it and sending messages to it in your Zulip instance:
+
+
1. Create a bot account in your Zulip instance
+
2. Download the bot's `.zuliprc` file
+
3. Run `vicuna -c path/to/.zuliprc -vv`
+
4. Send a direct message to the bot with `help`
+
+
## License
+
+
This project is part of the knot/slop/stack collection.
+
+
## Dependencies
+
+
- OCaml 4.08+
+
- Dune 3.0+
+
- eio
+
- zulip (from ../zulip)
+
- zulip_bot (from ../zulip)
+
- logs
+
- cmdliner
+
- mirage-crypto-rng-unix
+170
stack/vicuna/USAGE.md
···
+
# Vicuna Bot - Quick Usage Guide
+
+
## The Email Problem
+
+
When you authenticate to Zulip using OAuth, SSO, or other third-party authentication methods, Zulip often assigns you an auto-generated email address like:
+
+
- `user123@zulipchat.com`
+
- `person456@zulip.example.com`
+
+
This isn't your actual email address - it's just an internal identifier.
+
+
## The Smart Solution
+
+
**Good news!** Vicuna now automatically detects your real email address from your Zulip profile!
+
+
Most users can simply type `register` and the bot will try multiple sources:
+
1. Your `delivery_email` from Zulip profile (the real email associated with your account)
+
2. Your `user.email` from Zulip profile API (may be your real email depending on server config)
+
3. Your Zulip message sender email as a fallback
+
+
**No need to manually provide your email in most cases!**
+
+
## Registration Options
+
+
### Option 1: Smart Auto-Detection (RECOMMENDED - Just type `register`)
+
+
```
+
> register
+
```
+
+
The bot will automatically fetch your real email from your Zulip profile!
+
+
**Response (when delivery_email is available):**
+
```
+
✅ Successfully registered!
+
• Email: `alice@mycompany.com`
+
• Zulip ID: `12345`
+
• Full Name: `Alice Smith`
+
+
💡 Your Zulip email is: `user123@zulipchat.com`
+
📧 Using your delivery email from your profile
+
You can now be @mentioned by your email or Zulip ID!
+
```
+
+
The bot automatically found `alice@mycompany.com` from your profile!
+
+
**Response (when delivery_email is NOT available):**
+
```
+
✅ Successfully registered!
+
• Email: `user123@zulipchat.com`
+
• Zulip ID: `12345`
+
• Full Name: `Alice Smith`
+
+
💡 Your Zulip email is: `user123@zulipchat.com`
+
You can now be @mentioned by your email or Zulip ID!
+
```
+
+
Falls back to Zulip email if delivery_email isn't available.
+
+
### Option 2: Manual Email Override
+
+
```
+
> register alice@mycompany.com
+
```
+
+
Manually specify a custom email (overrides auto-detection).
+
+
**Response:**
+
```
+
✅ Successfully registered!
+
• Email: `alice@mycompany.com`
+
• Zulip ID: `12345`
+
• Full Name: `Alice Smith`
+
+
💡 Your Zulip email is: `user123@zulipchat.com`
+
📝 Using the custom email you provided
+
You can now be @mentioned by your email or Zulip ID!
+
```
+
+
## Lookup Examples
+
+
### Find Someone by Email
+
+
```
+
> whois alice@mycompany.com
+
+
👤 User found:
+
• Email: `alice@mycompany.com`
+
• Zulip ID: `12345`
+
• Full Name: `Alice Smith`
+
• Registered: 2025-01-15 10:30:45
+
```
+
+
### Find Someone by Zulip ID
+
+
```
+
> whois 12345
+
+
👤 User found:
+
• Email: `alice@mycompany.com`
+
• Zulip ID: `12345`
+
• Full Name: `Alice Smith`
+
• Registered: 2025-01-15 10:30:45
+
```
+
+
## Check Your Status
+
+
```
+
> whoami
+
+
📋 Your registration info:
+
• Email: `alice@mycompany.com`
+
• Zulip ID: `12345`
+
• Full Name: `Alice Smith`
+
• Registered: 2025-01-15 10:30:45
+
```
+
+
## List All Users
+
+
```
+
> list
+
+
📋 Registered users (3):
+
• **Alice Smith** (`alice@mycompany.com`) - ID: 12345
+
• **Bob Jones** (`bob@company.org`) - ID: 67890
+
• **Carol White** (`carol@example.com`) - ID: 54321
+
```
+
+
## Get Help
+
+
```
+
> help
+
```
+
+
Shows all available commands and examples.
+
+
## Pro Tips
+
+
1. **Just type `register`** - In most cases, the bot will automatically find your real email from your profile!
+
2. **Check if it worked** - Use `whoami` after registering to see which email was used
+
3. **Manual override if needed** - If the auto-detection didn't work, use `register your-email@example.com`
+
4. **Encourage your team to register** - The more people registered, the more useful the directory
+
5. **Update your registration** - If your email changes, just register again with the new one
+
6. **The bot stores both emails** - It knows your registered email AND your Zulip internal email
+
+
## How Smart Detection Works
+
+
The bot uses this priority order:
+
+
1. **Custom email** (if you provide one): `register alice@company.com` - Highest priority
+
2. **delivery_email** (from Zulip profile API): Your real email address associated with the account
+
3. **user.email** (from Zulip profile API): Email from profile (if different from message sender)
+
4. **Message sender email** (fallback): The internal `user@zulipchat.com` style email
+
+
This means the bot will use the best available email automatically!
+
+
### Why Multiple Sources?
+
+
- **delivery_email**: The most reliable source for real email, but may be `null` depending on permissions
+
- **user.email**: Another source from the API that may contain the real email depending on server configuration
+
- **Message sender email**: Always available but often an auto-generated internal identifier
+
+
The bot tries each in order until it finds a usable email address.
+
+
## Admin Considerations
+
+
- **No admin permissions needed** - Users can register themselves
+
- **Server-side storage** - All data is stored using Zulip's bot storage API
+
- **No custom profile fields required** - Works out of the box on any Zulip instance
+
- **Privacy** - Only registered users appear in the directory (opt-in)
+14
stack/vicuna/bin/dune
···
+
(executable
+
(name main)
+
(public_name vicuna)
+
(libraries
+
vicuna.bot
+
zulip
+
zulip_bot
+
eio
+
eio_main
+
logs
+
logs.fmt
+
fmt
+
cmdliner
+
mirage-crypto-rng.unix))
+467
stack/vicuna/bin/main.ml
···
+
(* Vicuna Bot - User Registration and Management Bot for Zulip *)
+
+
open Zulip_bot
+
+
(* Set up logging *)
+
let src = Logs.Src.create "vicuna" ~doc:"Vicuna User Registration Bot"
+
module Log = (val Logs.src_log src : Logs.LOG)
+
+
let run_vicuna_bot config_file verbosity env =
+
(* Set up logging based on verbosity *)
+
Logs.set_reporter (Logs_fmt.reporter ());
+
let log_level = match verbosity with
+
| 0 -> Logs.Info
+
| 1 -> Logs.Debug
+
| _ -> Logs.Debug (* Cap at debug level *)
+
in
+
Logs.set_level (Some log_level);
+
Logs.Src.set_level src (Some log_level);
+
+
Log.app (fun m -> m "Starting Vicuna Bot - User Registration Manager");
+
Log.app (fun m -> m "Log level: %s" (Logs.level_to_string (Some log_level)));
+
Log.app (fun m -> m "========================================\n");
+
+
(* Load authentication from .zuliprc file *)
+
let auth = match Zulip.Auth.from_zuliprc ?path:config_file () with
+
| Ok a ->
+
Log.info (fun m -> m "Loaded authentication for: %s" (Zulip.Auth.email a));
+
Log.info (fun m -> m "Server: %s" (Zulip.Auth.server_url a));
+
a
+
| Error e ->
+
Log.err (fun m -> m "Failed to load .zuliprc: %s" (Zulip.error_message e));
+
Log.app (fun m -> m "\nPlease create a ~/.zuliprc file with:");
+
Log.app (fun m -> m "[api]");
+
Log.app (fun m -> m "email=bot@example.com");
+
Log.app (fun m -> m "key=your-api-key");
+
Log.app (fun m -> m "site=https://your-domain.zulipchat.com");
+
exit 1
+
in
+
+
Eio.Switch.run @@ fun sw ->
+
Log.debug (fun m -> m "Creating Zulip client");
+
let client = Zulip.Client.create ~sw env auth in
+
+
(* Create bot configuration *)
+
let config = Bot_config.create [] in
+
let bot_email = Zulip.Auth.email auth in
+
+
Log.debug (fun m -> m "Creating bot storage for %s" bot_email);
+
let storage = Bot_storage.create client ~bot_email in
+
+
let identity = Bot_handler.Identity.create
+
~full_name:"Vicuna Bot"
+
~email:bot_email
+
~mention_name:"vicuna"
+
in
+
Log.info (fun m -> m "Bot identity created: %s (%s)"
+
(Bot_handler.Identity.full_name identity)
+
(Bot_handler.Identity.email identity));
+
+
(* Create the bot handler using the Vicuna bot library *)
+
Log.debug (fun m -> m "Creating Vicuna bot handler");
+
let handler = Vicuna_bot.create_handler config storage identity in
+
+
Log.debug (fun m -> m "Creating bot runner");
+
let runner = Bot_runner.create ~env ~client ~handler in
+
+
Log.app (fun m -> m "✨ Vicuna bot is running!");
+
Log.app (fun m -> m "📬 Send me a direct message to get started.");
+
Log.app (fun m -> m "🤖 Commands: 'register', 'whoami', 'whois', 'list', 'help'");
+
Log.app (fun m -> m "⛔ Press Ctrl+C to stop.\n");
+
+
(* Run in real-time mode *)
+
Log.info (fun m -> m "Starting real-time event loop");
+
try
+
Bot_runner.run_realtime runner;
+
Log.info (fun m -> m "Bot runner exited normally")
+
with
+
| Sys.Break ->
+
Log.info (fun m -> m "Received interrupt signal, shutting down");
+
Bot_runner.shutdown runner
+
| exn ->
+
Log.err (fun m -> m "Bot crashed with exception: %s" (Printexc.to_string exn));
+
Log.debug (fun m -> m "Backtrace: %s" (Printexc.get_backtrace ()));
+
raise exn
+
+
(* Command-line interface *)
+
open Cmdliner
+
+
let config_file =
+
let doc = "Path to .zuliprc configuration file" in
+
Arg.(value & opt (some string) None & info ["c"; "config"] ~docv:"FILE" ~doc)
+
+
let verbosity =
+
let doc = "Increase verbosity. Use multiple times for more verbose output." in
+
Arg.(value & flag_all & info ["v"; "verbose"] ~doc)
+
+
let verbosity_term =
+
Term.(const List.length $ verbosity)
+
+
(* CLI management commands *)
+
let cli_add_user config_file user_id email full_name is_admin env =
+
Logs.set_reporter (Logs_fmt.reporter ());
+
Logs.set_level (Some Logs.Info);
+
+
let auth = match Zulip.Auth.from_zuliprc ?path:config_file () with
+
| Ok a -> a
+
| Error e ->
+
Printf.eprintf "Error loading config: %s\n" (Zulip.error_message e);
+
exit 1
+
in
+
+
Eio.Switch.run @@ fun sw ->
+
let client = Zulip.Client.create ~sw env auth in
+
let bot_email = Zulip.Auth.email auth in
+
let storage = Bot_storage.create client ~bot_email in
+
+
match Vicuna_bot.register_user ~is_admin storage email user_id full_name with
+
| Ok () ->
+
let admin_str = if is_admin then " (admin)" else "" in
+
Printf.printf "✅ User added%s:\n" admin_str;
+
Printf.printf " • Email: %s\n" email;
+
Printf.printf " • Zulip ID: %d\n" user_id;
+
Printf.printf " • Name: %s\n" full_name;
+
exit 0
+
| Error e ->
+
Printf.eprintf "❌ Failed to add user: %s\n" (Zulip.error_message e);
+
exit 1
+
+
let cli_remove_user config_file user_id env =
+
Logs.set_reporter (Logs_fmt.reporter ());
+
Logs.set_level (Some Logs.Info);
+
+
let auth = match Zulip.Auth.from_zuliprc ?path:config_file () with
+
| Ok a -> a
+
| Error e ->
+
Printf.eprintf "Error loading config: %s\n" (Zulip.error_message e);
+
exit 1
+
in
+
+
Eio.Switch.run @@ fun sw ->
+
let client = Zulip.Client.create ~sw env auth in
+
let bot_email = Zulip.Auth.email auth in
+
let storage = Bot_storage.create client ~bot_email in
+
+
match Vicuna_bot.delete_user storage user_id with
+
| Ok () ->
+
Printf.printf "✅ User %d removed\n" user_id;
+
exit 0
+
| Error e ->
+
Printf.eprintf "❌ Failed to remove user: %s\n" (Zulip.error_message e);
+
exit 1
+
+
let cli_set_admin config_file user_id is_admin env =
+
Logs.set_reporter (Logs_fmt.reporter ());
+
Logs.set_level (Some Logs.Info);
+
+
let auth = match Zulip.Auth.from_zuliprc ?path:config_file () with
+
| Ok a -> a
+
| Error e ->
+
Printf.eprintf "Error loading config: %s\n" (Zulip.error_message e);
+
exit 1
+
in
+
+
Eio.Switch.run @@ fun sw ->
+
let client = Zulip.Client.create ~sw env auth in
+
let bot_email = Zulip.Auth.email auth in
+
let storage = Bot_storage.create client ~bot_email in
+
+
match Vicuna_bot.set_admin storage user_id is_admin with
+
| Ok () ->
+
let action = if is_admin then "granted to" else "removed from" in
+
Printf.printf "✅ Admin privileges %s user %d\n" action user_id;
+
exit 0
+
| Error e ->
+
Printf.eprintf "❌ Failed to set admin: %s\n" (Zulip.error_message e);
+
exit 1
+
+
let cli_list_users config_file show_admins_only env =
+
Logs.set_reporter (Logs_fmt.reporter ());
+
Logs.set_level (Some Logs.Warning);
+
+
let auth = match Zulip.Auth.from_zuliprc ?path:config_file () with
+
| Ok a -> a
+
| Error e ->
+
Printf.eprintf "Error loading config: %s\n" (Zulip.error_message e);
+
exit 1
+
in
+
+
Eio.Switch.run @@ fun sw ->
+
let client = Zulip.Client.create ~sw env auth in
+
let bot_email = Zulip.Auth.email auth in
+
let storage = Bot_storage.create client ~bot_email in
+
+
let user_ids = Vicuna_bot.get_all_user_ids storage in
+
let users = List.filter_map (fun id ->
+
match Vicuna_bot.lookup_user_by_id storage id with
+
| Some (user : Vicuna_bot.user_registration) when (not show_admins_only) || user.is_admin -> Some user
+
| _ -> None
+
) user_ids in
+
+
if users = [] then (
+
if show_admins_only then
+
Printf.printf "No admin users found.\n"
+
else
+
Printf.printf "No users registered.\n";
+
exit 0
+
) else (
+
let title = if show_admins_only then "Admin users" else "Registered users" in
+
Printf.printf "%s (%d):\n" title (List.length users);
+
List.iter (fun (user : Vicuna_bot.user_registration) ->
+
let admin_badge = if user.is_admin then " 👑" else "" in
+
Printf.printf " • %s (%s) - ID: %d%s\n"
+
user.full_name user.email user.zulip_id admin_badge
+
) users;
+
exit 0
+
)
+
+
(* Storage management commands *)
+
let cli_storage_list config_file show_all env =
+
Logs.set_reporter (Logs_fmt.reporter ());
+
Logs.set_level (Some Logs.Warning);
+
+
let auth = match Zulip.Auth.from_zuliprc ?path:config_file () with
+
| Ok a -> a
+
| Error e ->
+
Printf.eprintf "Error loading config: %s\n" (Zulip.error_message e);
+
exit 1
+
in
+
+
Eio.Switch.run @@ fun sw ->
+
let client = Zulip.Client.create ~sw env auth in
+
let bot_email = Zulip.Auth.email auth in
+
let storage = Bot_storage.create client ~bot_email in
+
+
if show_all then (
+
(* Show ALL keys including deleted (empty) ones *)
+
match Bot_storage.keys storage with
+
| Ok keys ->
+
if keys = [] then (
+
Printf.printf "No storage keys found.\n";
+
exit 0
+
) else (
+
Printf.printf "All storage keys including deleted (%d):\n" (List.length keys);
+
List.iter (fun key ->
+
match Vicuna_bot.get_storage_value storage key with
+
| Some value when value = "" ->
+
Printf.printf " • %s [DELETED]\n" key
+
| Some _ ->
+
Printf.printf " • %s\n" key
+
| None ->
+
Printf.printf " • %s [NOT FOUND]\n" key
+
) keys;
+
exit 0
+
)
+
| Error e ->
+
Printf.eprintf "❌ Failed to list storage keys: %s\n" (Zulip.error_message e);
+
exit 1
+
) else (
+
(* Normal list - only non-empty keys *)
+
match Vicuna_bot.get_storage_keys storage with
+
| Ok keys ->
+
if keys = [] then (
+
Printf.printf "No storage keys found.\n";
+
exit 0
+
) else (
+
Printf.printf "Storage keys (%d):\n" (List.length keys);
+
List.iter (fun key ->
+
Printf.printf " • %s\n" key
+
) keys;
+
exit 0
+
)
+
| Error e ->
+
Printf.eprintf "❌ Failed to list storage keys: %s\n" (Zulip.error_message e);
+
exit 1
+
)
+
+
let cli_storage_view config_file key env =
+
Logs.set_reporter (Logs_fmt.reporter ());
+
Logs.set_level (Some Logs.Warning);
+
+
let auth = match Zulip.Auth.from_zuliprc ?path:config_file () with
+
| Ok a -> a
+
| Error e ->
+
Printf.eprintf "Error loading config: %s\n" (Zulip.error_message e);
+
exit 1
+
in
+
+
Eio.Switch.run @@ fun sw ->
+
let client = Zulip.Client.create ~sw env auth in
+
let bot_email = Zulip.Auth.email auth in
+
let storage = Bot_storage.create client ~bot_email in
+
+
match Vicuna_bot.get_storage_value storage key with
+
| Some value ->
+
Printf.printf "Key: %s\n" key;
+
Printf.printf "Value:\n%s\n" value;
+
exit 0
+
| None ->
+
Printf.eprintf "❌ Key not found: %s\n" key;
+
exit 1
+
+
let cli_storage_delete config_file key env =
+
Logs.set_reporter (Logs_fmt.reporter ());
+
Logs.set_level (Some Logs.Info);
+
+
let auth = match Zulip.Auth.from_zuliprc ?path:config_file () with
+
| Ok a -> a
+
| Error e ->
+
Printf.eprintf "Error loading config: %s\n" (Zulip.error_message e);
+
exit 1
+
in
+
+
Eio.Switch.run @@ fun sw ->
+
let client = Zulip.Client.create ~sw env auth in
+
let bot_email = Zulip.Auth.email auth in
+
let storage = Bot_storage.create client ~bot_email in
+
+
match Vicuna_bot.delete_storage_key storage key with
+
| Ok () ->
+
Printf.printf "✅ Deleted storage key: %s\n" key;
+
exit 0
+
| Error e ->
+
Printf.eprintf "❌ Failed to delete storage key: %s\n" (Zulip.error_message e);
+
exit 1
+
+
let cli_storage_clear config_file env =
+
Logs.set_reporter (Logs_fmt.reporter ());
+
Logs.set_level (Some Logs.Info);
+
+
let auth = match Zulip.Auth.from_zuliprc ?path:config_file () with
+
| Ok a -> a
+
| Error e ->
+
Printf.eprintf "Error loading config: %s\n" (Zulip.error_message e);
+
exit 1
+
in
+
+
Eio.Switch.run @@ fun sw ->
+
let client = Zulip.Client.create ~sw env auth in
+
let bot_email = Zulip.Auth.email auth in
+
let storage = Bot_storage.create client ~bot_email in
+
+
(* Get count before clearing *)
+
match Vicuna_bot.get_storage_keys storage with
+
| Error e ->
+
Printf.eprintf "❌ Failed to list storage keys: %s\n" (Zulip.error_message e);
+
exit 1
+
| Ok keys ->
+
let count = List.length keys in
+
if count = 0 then (
+
Printf.printf "Storage is already empty.\n";
+
exit 0
+
) else (
+
match Vicuna_bot.clear_storage storage with
+
| Ok () ->
+
Printf.printf "✅ Cleared all storage (%d keys deleted)\n" count;
+
exit 0
+
| Error e ->
+
Printf.eprintf "❌ Failed to clear storage: %s\n" (Zulip.error_message e);
+
exit 1
+
)
+
+
(* CLI command definitions *)
+
let user_id_arg =
+
let doc = "Zulip user ID" in
+
Arg.(required & pos 0 (some int) None & info [] ~docv:"USER_ID" ~doc)
+
+
let email_arg =
+
let doc = "User's email address" in
+
Arg.(required & pos 1 (some string) None & info [] ~docv:"EMAIL" ~doc)
+
+
let full_name_arg =
+
let doc = "User's full name" in
+
Arg.(required & pos 2 (some string) None & info [] ~docv:"FULL_NAME" ~doc)
+
+
let admin_flag =
+
let doc = "Grant admin privileges" in
+
Arg.(value & flag & info ["admin"] ~doc)
+
+
let admins_only_flag =
+
let doc = "Show only admin users" in
+
Arg.(value & flag & info ["admins-only"] ~doc)
+
+
let user_add_cmd eio_env =
+
let doc = "Add a user to the bot's registry" in
+
let info = Cmd.info "user-add" ~doc in
+
Cmd.v info Term.(const cli_add_user $ config_file $ user_id_arg $ email_arg $ full_name_arg $ admin_flag $ const eio_env)
+
+
let user_remove_cmd eio_env =
+
let doc = "Remove a user from the bot's registry" in
+
let info = Cmd.info "user-remove" ~doc in
+
Cmd.v info Term.(const cli_remove_user $ config_file $ user_id_arg $ const eio_env)
+
+
let admin_add_cmd eio_env =
+
let doc = "Grant admin privileges to a user" in
+
let info = Cmd.info "admin-add" ~doc in
+
Cmd.v info Term.(const cli_set_admin $ config_file $ user_id_arg $ const true $ const eio_env)
+
+
let admin_remove_cmd eio_env =
+
let doc = "Remove admin privileges from a user" in
+
let info = Cmd.info "admin-remove" ~doc in
+
Cmd.v info Term.(const cli_set_admin $ config_file $ user_id_arg $ const false $ const eio_env)
+
+
let user_list_cmd eio_env =
+
let doc = "List all registered users" in
+
let info = Cmd.info "user-list" ~doc in
+
Cmd.v info Term.(const cli_list_users $ config_file $ admins_only_flag $ const eio_env)
+
+
(* Storage command arguments *)
+
let storage_key_arg =
+
let doc = "Storage key" in
+
Arg.(required & pos 0 (some string) None & info [] ~docv:"KEY" ~doc)
+
+
let storage_all_flag =
+
let doc = "Show all keys including deleted ones (with empty values)" in
+
Arg.(value & flag & info ["all"] ~doc)
+
+
(* Storage subcommands *)
+
let storage_list_cmd eio_env =
+
let doc = "List all storage keys" in
+
let info = Cmd.info "list" ~doc in
+
Cmd.v info Term.(const cli_storage_list $ config_file $ storage_all_flag $ const eio_env)
+
+
let storage_view_cmd eio_env =
+
let doc = "View the value of a specific storage key" in
+
let info = Cmd.info "view" ~doc in
+
Cmd.v info Term.(const cli_storage_view $ config_file $ storage_key_arg $ const eio_env)
+
+
let storage_delete_cmd eio_env =
+
let doc = "Delete a specific storage key" in
+
let info = Cmd.info "delete" ~doc in
+
Cmd.v info Term.(const cli_storage_delete $ config_file $ storage_key_arg $ const eio_env)
+
+
let storage_clear_cmd eio_env =
+
let doc = "Clear all storage (delete all keys)" in
+
let info = Cmd.info "clear" ~doc in
+
Cmd.v info Term.(const cli_storage_clear $ config_file $ const eio_env)
+
+
let storage_group eio_env =
+
let doc = "Manage bot storage" in
+
let info = Cmd.info "storage" ~doc in
+
let default_term = Term.(ret (const (`Help (`Auto, None)))) in
+
let cmds = [
+
storage_list_cmd eio_env;
+
storage_view_cmd eio_env;
+
storage_delete_cmd eio_env;
+
storage_clear_cmd eio_env;
+
] in
+
Cmd.group info ~default:default_term cmds
+
+
let main_group eio_env =
+
let default_info = Cmd.info "vicuna" ~version:"1.0.0" ~doc:"Vicuna - User Registration and Management Bot for Zulip" in
+
let default_term = Term.(const run_vicuna_bot $ config_file $ verbosity_term $ const eio_env) in
+
let cmds = [
+
user_add_cmd eio_env;
+
user_remove_cmd eio_env;
+
admin_add_cmd eio_env;
+
admin_remove_cmd eio_env;
+
user_list_cmd eio_env;
+
storage_group eio_env;
+
] in
+
Cmd.group default_info ~default:default_term cmds
+
+
let () =
+
(* Initialize the cryptographic RNG for the application *)
+
Mirage_crypto_rng_unix.use_default ();
+
Eio_main.run @@ fun env ->
+
exit (Cmd.eval (main_group env))
+19
stack/vicuna/dune-project
···
+
(lang dune 3.0)
+
+
(name vicuna)
+
+
(package
+
(name vicuna)
+
(synopsis "Vicuna - A user registration and management bot for Zulip")
+
(description "A Zulip bot that manages user registrations, mapping email addresses to Zulip IDs for easy @mentions and user tracking")
+
(depends
+
ocaml
+
dune
+
eio
+
eio_main
+
zulip
+
zulip_bot
+
logs
+
fmt
+
cmdliner
+
mirage-crypto-rng))
+4
stack/vicuna/lib/dune
···
+
(library
+
(name vicuna_bot)
+
(public_name vicuna.bot)
+
(libraries zulip zulip_bot eio logs fmt))
+502
stack/vicuna/lib/vicuna_bot.ml
···
+
(* Vicuna Bot - User Registration and Management Bot for Zulip *)
+
+
open Zulip_bot
+
+
(* Set up logging *)
+
let src = Logs.Src.create "vicuna_bot" ~doc:"Vicuna User Registration Bot"
+
module Log = (val Logs.src_log src : Logs.LOG)
+
+
(** User registration record *)
+
type user_registration = {
+
email: string;
+
zulip_id: int;
+
full_name: string;
+
registered_at: float;
+
is_admin: bool;
+
}
+
+
(** Parse a user registration from JSON-like string format *)
+
let user_registration_of_string s : user_registration option =
+
try
+
(* Format: "email|zulip_id|full_name|timestamp|is_admin" *)
+
match String.split_on_char '|' s with
+
| [email; zulip_id_str; full_name; timestamp_str; is_admin_str] ->
+
Some {
+
email;
+
zulip_id = int_of_string zulip_id_str;
+
full_name;
+
registered_at = float_of_string timestamp_str;
+
is_admin = bool_of_string is_admin_str;
+
}
+
| [email; zulip_id_str; full_name; timestamp_str] ->
+
(* Backward compatibility - old format without is_admin *)
+
Some {
+
email;
+
zulip_id = int_of_string zulip_id_str;
+
full_name;
+
registered_at = float_of_string timestamp_str;
+
is_admin = false;
+
}
+
| _ -> None
+
with _ -> None
+
+
(** Convert a user registration to string format *)
+
let user_registration_to_string (reg : user_registration) : string =
+
Printf.sprintf "%s|%d|%s|%f|%b"
+
reg.email
+
reg.zulip_id
+
reg.full_name
+
reg.registered_at
+
reg.is_admin
+
+
(** Storage key for a user registration by Zulip ID - this is the only storage key we use *)
+
let storage_key_for_id zulip_id = Printf.sprintf "user:id:%d" zulip_id
+
+
(** Storage key for the list of all registered user IDs *)
+
let all_users_key = "users:all"
+
+
(** Default admin user ID *)
+
let default_admin_id = 939008
+
+
(** Get all registered user IDs from storage *)
+
let get_all_user_ids storage =
+
match Bot_storage.get storage ~key:all_users_key with
+
| Some s when s <> "" ->
+
String.split_on_char ',' s
+
|> List.filter_map (fun id_str ->
+
try Some (int_of_string (String.trim id_str))
+
with _ -> None)
+
| _ -> []
+
+
(** Add a user ID to the list of all users (ensures uniqueness) *)
+
let add_user_id_to_list storage zulip_id =
+
let existing = get_all_user_ids storage in
+
if List.mem zulip_id existing then
+
Ok ()
+
else
+
let new_list = zulip_id :: existing in
+
let value = String.concat "," (List.map string_of_int new_list) in
+
Bot_storage.put storage ~key:all_users_key ~value
+
+
(** Remove a user ID from the list of all users *)
+
let remove_user_id_from_list storage zulip_id =
+
let existing = get_all_user_ids storage in
+
let new_list = List.filter ((<>) zulip_id) existing in
+
let value = String.concat "," (List.map string_of_int new_list) in
+
Bot_storage.put storage ~key:all_users_key ~value
+
+
(** Look up a user by Zulip ID *)
+
let lookup_user_by_id storage zulip_id =
+
match Bot_storage.get storage ~key:(storage_key_for_id zulip_id) with
+
| Some s -> user_registration_of_string s
+
| None -> None
+
+
(** Look up a user by email - scans through all users *)
+
let lookup_user_by_email storage email =
+
let user_ids = get_all_user_ids storage in
+
List.find_map (fun zulip_id ->
+
match lookup_user_by_id storage zulip_id with
+
| Some reg when reg.email = email -> Some reg
+
| _ -> None
+
) user_ids
+
+
(** Check if user is admin *)
+
let is_admin storage zulip_id =
+
match lookup_user_by_id storage zulip_id with
+
| Some reg -> reg.is_admin
+
| None -> zulip_id = default_admin_id (* Default admin always has admin rights *)
+
+
(** Set admin status for a user *)
+
let set_admin storage zulip_id is_admin_flag =
+
match lookup_user_by_id storage zulip_id with
+
| Some reg ->
+
let updated_reg = { reg with is_admin = is_admin_flag } in
+
let reg_str = user_registration_to_string updated_reg in
+
(* Update ID storage key only *)
+
Bot_storage.put storage ~key:(storage_key_for_id zulip_id) ~value:reg_str
+
| None ->
+
Error (Zulip.create_error ~code:(Other "user_not_found") ~msg:"User not registered" ())
+
+
(** Register a new user in storage (with optional admin flag) *)
+
let register_user ?(is_admin=false) storage email zulip_id full_name =
+
(* Check if user already exists by ID to prevent duplicates *)
+
let existing_by_id = lookup_user_by_id storage zulip_id in
+
+
(* Preserve admin status if user already exists, unless explicitly setting *)
+
let final_is_admin = match existing_by_id with
+
| Some existing -> existing.is_admin || is_admin
+
| None -> is_admin || (zulip_id = default_admin_id)
+
in
+
+
let reg = {
+
email;
+
zulip_id;
+
full_name;
+
registered_at = Unix.gettimeofday ();
+
is_admin = final_is_admin;
+
} in
+
let reg_str = user_registration_to_string reg in
+
+
(* Store only by ID - we'll use in-memory scanning for email lookups *)
+
match Bot_storage.put storage ~key:(storage_key_for_id zulip_id) ~value:reg_str with
+
| Error e -> Error e
+
| Ok () ->
+
(* Add to all users list (by ID, ensures uniqueness) *)
+
add_user_id_to_list storage zulip_id
+
+
(** Delete a user from storage by Zulip ID *)
+
let delete_user storage zulip_id =
+
match lookup_user_by_id storage zulip_id with
+
| Some _reg ->
+
(* Remove from ID key only *)
+
let _ = Bot_storage.remove storage ~key:(storage_key_for_id zulip_id) in
+
(* Remove from all users list *)
+
remove_user_id_from_list storage zulip_id
+
| None ->
+
Error (Zulip.create_error ~code:(Other "user_not_found") ~msg:"User not found" ())
+
+
(** Format a timestamp as a human-readable date *)
+
let format_timestamp timestamp =
+
let tm = Unix.localtime timestamp in
+
Printf.sprintf "%04d-%02d-%02d %02d:%02d:%02d"
+
(tm.tm_year + 1900)
+
(tm.tm_mon + 1)
+
tm.tm_mday
+
tm.tm_hour
+
tm.tm_min
+
tm.tm_sec
+
+
(** Validate email format (basic check) *)
+
let is_valid_email email =
+
let email = String.trim email in
+
String.length email > 0 &&
+
String.contains email '@' &&
+
match String.split_on_char '@' email with
+
| [local; domain] ->
+
String.length local > 0 &&
+
String.length domain > 0 &&
+
String.contains domain '.'
+
| _ -> false
+
+
(** Handle the 'register' command *)
+
let handle_register storage sender_email sender_id sender_name custom_email_opt =
+
(* First, try to fetch the user's profile from the Zulip API to get delivery_email and email *)
+
let client = Bot_storage.client storage in
+
let (delivery_email_from_api, user_email_from_api) =
+
match Zulip.Users.get_by_id client ~user_id:sender_id with
+
| Ok user ->
+
let delivery = match Zulip.User.delivery_email user with
+
| Some email when email <> "" ->
+
Log.info (fun m -> m "Found delivery_email from API: %s" email);
+
Some email
+
| _ ->
+
Log.debug (fun m -> m "No delivery_email available from API");
+
None
+
in
+
let user_email = Zulip.User.email user in
+
(* Check if the user.email from API is different from sender_email (message context) *)
+
let api_email =
+
if user_email <> "" && user_email <> sender_email then (
+
Log.info (fun m -> m "Found user.email from API: %s (differs from message sender: %s)" user_email sender_email);
+
Some user_email
+
) else (
+
Log.debug (fun m -> m "API user.email same as sender_email or empty");
+
None
+
)
+
in
+
(delivery, api_email)
+
| Error e ->
+
Log.warn (fun m -> m "Failed to fetch user profile: %s" (Zulip.error_message e));
+
(None, None)
+
in
+
+
(* Determine the email to register with priority:
+
1. Custom email provided by user
+
2. delivery_email from API
+
3. user.email from API (if different from sender_email)
+
4. Zulip sender email (fallback) *)
+
let email_to_register = match custom_email_opt with
+
| Some email ->
+
let email = String.trim email in
+
if is_valid_email email then
+
email
+
else (
+
Log.warn (fun m -> m "Invalid email format provided: %s, trying API emails or falling back to sender email" email);
+
match delivery_email_from_api with
+
| Some email -> email
+
| None ->
+
(match user_email_from_api with
+
| Some email -> email
+
| None -> sender_email)
+
)
+
| None ->
+
(* No custom email provided, try delivery_email first, then user.email, then fallback *)
+
(match delivery_email_from_api with
+
| Some email -> email
+
| None ->
+
(match user_email_from_api with
+
| Some email -> email
+
| None -> sender_email))
+
in
+
+
Log.info (fun m -> m "Registering user: %s (ID: %d)" email_to_register sender_id);
+
+
(* Build info message about email source *)
+
let email_source_note =
+
if custom_email_opt <> None then
+
"\n📝 Using the custom email you provided"
+
else if custom_email_opt = None && delivery_email_from_api <> None then
+
"\n📧 Using your delivery email from your profile"
+
else if custom_email_opt = None && user_email_from_api <> None then
+
"\n📧 Using your email from your profile (user.email)"
+
else
+
""
+
in
+
+
(* Check if already registered *)
+
match lookup_user_by_email storage email_to_register with
+
| Some existing ->
+
if existing.zulip_id = sender_id then (
+
(* Ensure user is in the master list (idempotent) *)
+
let _ = add_user_id_to_list storage sender_id in
+
Printf.sprintf "You are already registered!\n\
+
• Email: `%s`\n\
+
• Zulip ID: `%d`\n\
+
• Registered: %s\n\n\
+
💡 Your Zulip email is: `%s`%s"
+
existing.email
+
existing.zulip_id
+
(format_timestamp existing.registered_at)
+
sender_email
+
email_source_note
+
) else
+
(* Email exists but different ID - update it *)
+
(match register_user storage email_to_register sender_id sender_name with
+
| Ok () ->
+
Log.info (fun m -> m "Updated registration for %s" email_to_register);
+
Printf.sprintf "✅ Updated your registration!\n\
+
• Email: `%s`\n\
+
• Zulip ID: `%d`\n\n\
+
💡 Your Zulip email is: `%s`%s"
+
email_to_register sender_id sender_email email_source_note
+
| Error e ->
+
Log.err (fun m -> m "Failed to update registration: %s" (Zulip.error_message e));
+
Printf.sprintf "❌ Failed to update registration: %s" (Zulip.error_message e))
+
| None ->
+
(* New registration *)
+
(match register_user storage email_to_register sender_id sender_name with
+
| Ok () ->
+
Log.info (fun m -> m "Successfully registered %s" email_to_register);
+
Printf.sprintf "✅ Successfully registered!\n\
+
• Email: `%s`\n\
+
• Zulip ID: `%d`\n\
+
• Full Name: `%s`\n\n\
+
💡 Your Zulip email is: `%s`%s\n\
+
You can now be @mentioned by your email or Zulip ID!"
+
email_to_register sender_id sender_name sender_email email_source_note
+
| Error e ->
+
Log.err (fun m -> m "Failed to register user: %s" (Zulip.error_message e));
+
Printf.sprintf "❌ Failed to register: %s" (Zulip.error_message e))
+
+
(** Handle the 'whoami' command *)
+
let handle_whoami storage sender_email _sender_id =
+
match lookup_user_by_email storage sender_email with
+
| Some reg ->
+
Printf.sprintf "📋 Your registration info:\n\
+
• Email: `%s`\n\
+
• Zulip ID: `%d`\n\
+
• Full Name: `%s`\n\
+
• Registered: %s"
+
reg.email
+
reg.zulip_id
+
reg.full_name
+
(format_timestamp reg.registered_at)
+
| None ->
+
Printf.sprintf "You are not registered yet. Use `register` to register yourself!"
+
+
(** Handle the 'whois' command *)
+
let handle_whois storage query =
+
(* Try to parse as email or ID *)
+
match int_of_string_opt query with
+
| Some id ->
+
(* Query is a number, look up by ID *)
+
(match lookup_user_by_id storage id with
+
| Some reg ->
+
Printf.sprintf "👤 User found:\n\
+
• Email: `%s`\n\
+
• Zulip ID: `%d`\n\
+
• Full Name: `%s`\n\
+
• Registered: %s"
+
reg.email
+
reg.zulip_id
+
reg.full_name
+
(format_timestamp reg.registered_at)
+
| None ->
+
Printf.sprintf "❓ No user found with ID: %d" id)
+
| None ->
+
(* Query is not a number, treat as email *)
+
let email = String.trim query in
+
(match lookup_user_by_email storage email with
+
| Some reg ->
+
Printf.sprintf "👤 User found:\n\
+
• Email: `%s`\n\
+
• Zulip ID: `%d`\n\
+
• Full Name: `%s`\n\
+
• Registered: %s"
+
reg.email
+
reg.zulip_id
+
reg.full_name
+
(format_timestamp reg.registered_at)
+
| None ->
+
Printf.sprintf "❓ No user found with email: %s" email)
+
+
(** Handle the 'list' command *)
+
let handle_list storage =
+
let user_ids = get_all_user_ids storage in
+
if user_ids = [] then
+
"📋 No users registered yet."
+
else
+
let user_lines = List.filter_map (fun zulip_id ->
+
match lookup_user_by_id storage zulip_id with
+
| Some reg ->
+
let admin_badge = if reg.is_admin then " 👑" else "" in
+
Some (Printf.sprintf "• **%s** (`%s`) - ID: %d%s"
+
reg.full_name reg.email reg.zulip_id admin_badge)
+
| None -> None
+
) user_ids in
+
Printf.sprintf "📋 Registered users (%d):\n%s"
+
(List.length user_lines)
+
(String.concat "\n" user_lines)
+
+
(** Handle the 'help' command *)
+
let handle_help sender_name sender_email =
+
Printf.sprintf "👋 Hi %s! I'm **Vicuna**, your user registration assistant.\n\n\
+
**Available Commands:**\n\
+
• `register` - Auto-detect your real email or use Zulip email\n\
+
• `register <your-email@example.com>` - Register with a specific email\n\
+
• `whoami` - Show your registration status\n\
+
• `whois <email|id>` - Look up a registered user\n\
+
• `list` - List all registered users\n\
+
• `help` - Show this help message\n\n\
+
**Examples:**\n\
+
• `register` - Auto-detect your email (your Zulip email is `%s`)\n\
+
• `register alice@mycompany.com` - Register with a specific email\n\
+
• `whois alice@example.com` - Look up Alice by email\n\
+
• `whois 12345` - Look up user by Zulip ID\n\n\
+
**Smart Email Detection:**\n\
+
When you use `register` without an email, I'll try to:\n\
+
1. Find your delivery email from your Zulip profile (delivery_email)\n\
+
2. Use your profile email if available (user.email)\n\
+
3. Fall back to your Zulip message email if needed\n\n\
+
This means you usually don't need to manually provide your email!\n\n\
+
Send me a direct message to get started!"
+
sender_name sender_email
+
+
(** Parse command from message content *)
+
let parse_command content =
+
let trimmed = String.trim content in
+
match String.index_opt trimmed ' ' with
+
| None -> (trimmed, "")
+
| Some idx ->
+
let cmd = String.sub trimmed 0 idx in
+
let args = String.sub trimmed (idx + 1) (String.length trimmed - idx - 1) |> String.trim in
+
(cmd, args)
+
+
(** Main bot handler implementation *)
+
module Vicuna_handler : Bot_handler.S = struct
+
let initialize _config =
+
Log.info (fun m -> m "Initializing Vicuna bot handler");
+
Ok ()
+
+
let usage () =
+
"Vicuna - User Registration and Management Bot"
+
+
let description () =
+
"A bot that helps users register and manage their email to Zulip ID mappings. \
+
Register with 'register', check your status with 'whoami', and look up others with 'whois'."
+
+
let handle_message ~config:_ ~storage ~identity ~message ~env:_ =
+
(* Log the message *)
+
Log.debug (fun m -> m "@[<h>Received: %a@]" (Message.pp_ansi ~show_json:false) message);
+
+
(* Get sender information *)
+
let sender_email = Message.sender_email message in
+
let sender_id = Message.sender_id message in
+
let sender_name = Message.sender_full_name message in
+
let bot_email = Bot_handler.Identity.email identity in
+
+
(* Ignore our own messages *)
+
if sender_email = bot_email then (
+
Log.debug (fun m -> m "Ignoring own message");
+
Ok Bot_handler.Response.None
+
) else
+
(* Clean the message content *)
+
let cleaned_msg = Message.strip_mention message ~user_email:bot_email in
+
let (command, args) = parse_command cleaned_msg in
+
let command_lower = String.lowercase_ascii command in
+
+
Log.info (fun m -> m "Command: %s, Args: %s" command_lower args);
+
+
(* Handle commands *)
+
let response_content =
+
match command_lower with
+
| "" | "hi" | "hello" ->
+
handle_help sender_name sender_email
+
| "help" ->
+
handle_help sender_name sender_email
+
| "register" ->
+
let custom_email = if args = "" then None else Some args in
+
handle_register storage sender_email sender_id sender_name custom_email
+
| "whoami" ->
+
handle_whoami storage sender_email sender_id
+
| "whois" ->
+
if args = "" then
+
"Usage: `whois <email|id>` - Example: `whois alice@example.com` or `whois 12345`"
+
else
+
handle_whois storage args
+
| "list" ->
+
handle_list storage
+
| _ ->
+
Printf.sprintf "Unknown command: `%s`. Use `help` to see available commands." command
+
in
+
+
Ok (Bot_handler.Response.Reply response_content)
+
end
+
+
(** {1 Storage Management Functions} *)
+
+
(** Get all storage keys (excluding deleted keys with empty values) *)
+
let get_storage_keys storage =
+
match Bot_storage.keys storage with
+
| Error e -> Error e
+
| Ok keys ->
+
(* Filter out keys with empty values (these are deleted keys) *)
+
let non_empty_keys = List.filter (fun key ->
+
match Bot_storage.get storage ~key with
+
| Some value when value <> "" -> true
+
| _ -> false
+
) keys in
+
Ok non_empty_keys
+
+
(** Get the value of a specific storage key *)
+
let get_storage_value storage key =
+
Bot_storage.get storage ~key
+
+
(** Delete a specific storage key *)
+
let delete_storage_key storage key =
+
Bot_storage.remove storage ~key
+
+
(** Clear all storage (delete all keys) *)
+
let clear_storage storage =
+
match Bot_storage.keys storage with
+
| Error e -> Error e
+
| Ok keys ->
+
List.fold_left (fun acc key ->
+
match acc with
+
| Error _ as err -> err
+
| Ok () -> Bot_storage.remove storage ~key
+
) (Ok ()) keys
+
+
(** Create the bot handler instance *)
+
let create_handler config storage identity =
+
Bot_handler.create (module Vicuna_handler) ~config ~storage ~identity
+84
stack/vicuna/lib/vicuna_bot.mli
···
+
(** Vicuna Bot - User Registration and Management Bot for Zulip *)
+
+
(** Create a Vicuna bot handler instance *)
+
val create_handler :
+
Zulip_bot.Bot_config.t ->
+
Zulip_bot.Bot_storage.t ->
+
Zulip_bot.Bot_handler.Identity.t ->
+
Zulip_bot.Bot_handler.t
+
+
(** {1 CLI Management Functions} *)
+
+
(** Default admin user ID *)
+
val default_admin_id : int
+
+
(** Register a user with optional admin flag *)
+
val register_user :
+
?is_admin:bool ->
+
Zulip_bot.Bot_storage.t ->
+
string ->
+
int ->
+
string ->
+
(unit, Zulip.zerror) result
+
+
(** Delete a user by Zulip ID *)
+
val delete_user :
+
Zulip_bot.Bot_storage.t ->
+
int ->
+
(unit, Zulip.zerror) result
+
+
(** Check if a user is an admin *)
+
val is_admin :
+
Zulip_bot.Bot_storage.t ->
+
int ->
+
bool
+
+
(** Set admin status for a user *)
+
val set_admin :
+
Zulip_bot.Bot_storage.t ->
+
int ->
+
bool ->
+
(unit, Zulip.zerror) result
+
+
(** Get all registered user IDs *)
+
val get_all_user_ids :
+
Zulip_bot.Bot_storage.t ->
+
int list
+
+
(** Look up a user by Zulip ID *)
+
type user_registration = {
+
email: string;
+
zulip_id: int;
+
full_name: string;
+
registered_at: float;
+
is_admin: bool;
+
}
+
+
val lookup_user_by_id :
+
Zulip_bot.Bot_storage.t ->
+
int ->
+
user_registration option
+
+
(** {1 Storage Management Functions} *)
+
+
(** Get all storage keys *)
+
val get_storage_keys :
+
Zulip_bot.Bot_storage.t ->
+
(string list, Zulip.zerror) result
+
+
(** Get the value of a specific storage key *)
+
val get_storage_value :
+
Zulip_bot.Bot_storage.t ->
+
string ->
+
string option
+
+
(** Delete a specific storage key *)
+
val delete_storage_key :
+
Zulip_bot.Bot_storage.t ->
+
string ->
+
(unit, Zulip.zerror) result
+
+
(** Clear all storage (delete all keys) *)
+
val clear_storage :
+
Zulip_bot.Bot_storage.t ->
+
(unit, Zulip.zerror) result