···11+# Example Zulip configuration file for Vicuna bot
22+# Copy this to ~/.zuliprc and fill in your bot's credentials
33+44+[api]
55+email=vicuna-bot@your-domain.zulipchat.com
66+key=your-bot-api-key-here
77+site=https://your-domain.zulipchat.com
88+99+# To get your bot's API key:
1010+# 1. Go to your Zulip instance's settings
1111+# 2. Navigate to Settings > Your bots
1212+# 3. Create a new bot or select an existing one
1313+# 4. Copy the API key and email address
1414+# 5. Update the values above
+265
stack/vicuna/README.md
···11+# Vicuna Bot
22+33+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.
44+55+## Features
66+77+- **User Registration**: Users can register themselves by sending a simple command
88+- **Custom Email Support**: Register with your actual email address (not just the auto-generated Zulip email)
99+- **Persistent Storage**: All registrations are stored on the server using Zulip's bot storage API
1010+- **Bidirectional Lookup**: Look up users by email address or Zulip ID
1111+- **Message-based Configuration**: All configuration happens via messages to the bot
1212+- **User Directory**: List all registered users
1313+1414+## OAuth/SSO Email Issue & Solution
1515+1616+**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.
1717+1818+**Smart Solution:** Vicuna automatically tries to find your real email address!
1919+2020+When you type `register`, the bot uses this priority:
2121+1. **Custom email** you provide: `register alice@company.com` (highest priority)
2222+2. **delivery_email** from your Zulip profile (the real email associated with your account)
2323+3. **user.email** from your Zulip profile API (may differ from message sender email)
2424+4. **Zulip message email** as a last resort (the internal `user@zulipchat.com` style)
2525+2626+This means **most users can just type `register`** and the bot will automatically use their real email address!
2727+2828+### Manual Email Registration
2929+3030+If needed, you can still manually specify your email:
3131+3232+```bash
3333+register your-actual-email@example.com
3434+```
3535+3636+The bot will:
3737+- Store your actual email for lookups
3838+- Still track your Zulip ID for @mentions
3939+- Show you both your registered email and your Zulip-internal email
4040+4141+This way, colleagues can find you by your real email address while the bot maintains the proper Zulip ID mapping.
4242+4343+## Installation
4444+4545+```bash
4646+# Build the bot
4747+cd vicuna
4848+dune build
4949+5050+# Install (optional)
5151+dune install
5252+```
5353+5454+## Configuration
5555+5656+Create a `~/.zuliprc` file with your bot credentials:
5757+5858+```ini
5959+[api]
6060+email=vicuna-bot@your-domain.zulipchat.com
6161+key=your-bot-api-key
6262+site=https://your-domain.zulipchat.com
6363+```
6464+6565+## Usage
6666+6767+### Running the Bot
6868+6969+```bash
7070+# Run with default configuration
7171+vicuna
7272+7373+# Run with verbose logging
7474+vicuna -v
7575+7676+# Run with debug logging
7777+vicuna -vv
7878+7979+# Run with custom config file
8080+vicuna -c /path/to/.zuliprc
8181+```
8282+8383+### Bot Commands
8484+8585+Send these commands to the bot via direct message or by mentioning it in a channel:
8686+8787+#### `register` or `register <your-email@example.com>`
8888+Register your email and Zulip ID in the system.
8989+9090+**Smart auto-detection** (recommended - just type `register`):
9191+```
9292+> register
9393+✅ Successfully registered!
9494+• Email: `alice@mycompany.com`
9595+• Zulip ID: `12345`
9696+• Full Name: `Alice Smith`
9797+9898+💡 Your Zulip email is: `user123@zulipchat.com`
9999+📧 Using your delivery email from your profile
100100+You can now be @mentioned by your email or Zulip ID!
101101+```
102102+103103+The bot automatically fetched `alice@mycompany.com` from your Zulip profile's `delivery_email` field!
104104+105105+**Manual registration** (if you want to override):
106106+```
107107+> register alice@different-email.com
108108+✅ Successfully registered!
109109+• Email: `alice@different-email.com`
110110+• Zulip ID: `12345`
111111+• Full Name: `Alice Smith`
112112+113113+💡 Your Zulip email is: `user123@zulipchat.com`
114114+📝 Using the custom email you provided
115115+You can now be @mentioned by your email or Zulip ID!
116116+```
117117+118118+**Note:** The bot tries four sources in order:
119119+1. Custom email you provide (highest priority)
120120+2. `delivery_email` from your Zulip profile (auto-detected)
121121+3. `user.email` from your Zulip profile API (may be real email depending on permissions)
122122+4. Zulip message sender email (fallback)
123123+124124+#### `whoami`
125125+Check your registration status.
126126+127127+```
128128+> whoami
129129+📋 Your registration info:
130130+• Email: `alice@example.com`
131131+• Zulip ID: `12345`
132132+• Full Name: `Alice Smith`
133133+• Registered: 2025-01-15 10:30:45
134134+```
135135+136136+#### `whois <email|id>`
137137+Look up a registered user by their email or Zulip ID.
138138+139139+```
140140+> whois bob@example.com
141141+👤 User found:
142142+• Email: `bob@example.com`
143143+• Zulip ID: `67890`
144144+• Full Name: `Bob Jones`
145145+• Registered: 2025-01-14 09:15:22
146146+147147+> whois 67890
148148+👤 User found:
149149+• Email: `bob@example.com`
150150+• Zulip ID: `67890`
151151+• Full Name: `Bob Jones`
152152+• Registered: 2025-01-14 09:15:22
153153+```
154154+155155+#### `list`
156156+List all registered users.
157157+158158+```
159159+> list
160160+📋 Registered users (3):
161161+• **Alice Smith** (`alice@example.com`) - ID: 12345
162162+• **Bob Jones** (`bob@example.com`) - ID: 67890
163163+• **Carol White** (`carol@example.com`) - ID: 54321
164164+```
165165+166166+#### `help`
167167+Show available commands and usage information.
168168+169169+```
170170+> help
171171+👋 Hi Alice! I'm **Vicuna**, your user registration assistant.
172172+173173+Available Commands:
174174+• `register` - Register with your Zulip email
175175+• `register <your-email@example.com>` - Register with a custom email
176176+• `whoami` - Show your registration status
177177+• `whois <email|id>` - Look up a registered user
178178+• `list` - List all registered users
179179+• `help` - Show this help message
180180+181181+Examples:
182182+• `register` - Register with Zulip email (`user123@zulipchat.com`)
183183+• `register alice@mycompany.com` - Register with your actual email
184184+• `whois alice@example.com` - Look up Alice by email
185185+• `whois 12345` - Look up user by Zulip ID
186186+187187+Note: Many Zulip instances use auto-generated emails like `user@zulipchat.com`.
188188+You can provide your actual email address during registration!
189189+190190+Send me a direct message to get started!
191191+```
192192+193193+## Architecture
194194+195195+### Libraries Used
196196+197197+- **zulip**: OCaml bindings for the Zulip REST API
198198+- **zulip_bot**: Bot framework for building interactive Zulip bots
199199+- **eio**: Effects-based I/O for async operations
200200+- **logs**: Structured logging
201201+- **cmdliner**: Command-line interface
202202+203203+### Storage
204204+205205+Vicuna uses Zulip's bot storage API to persist user registrations. The storage format is:
206206+207207+- **User by email**: `user:email:<email>` → `<email>|<zulip_id>|<full_name>|<timestamp>`
208208+- **User by ID**: `user:id:<zulip_id>` → `<email>|<zulip_id>|<full_name>|<timestamp>`
209209+- **User list**: `users:all` → `<email1>,<email2>,<email3>,...`
210210+211211+This allows for efficient bidirectional lookups and maintains a master list of all registered users.
212212+213213+## Development
214214+215215+### Project Structure
216216+217217+```
218218+vicuna/
219219+├── dune-project # Project definition
220220+├── README.md # This file
221221+├── lib/ # Bot library
222222+│ ├── dune # Library build config
223223+│ ├── vicuna_bot.ml # Bot implementation
224224+│ └── vicuna_bot.mli # Bot interface
225225+└── bin/ # Executable
226226+ ├── dune # Executable build config
227227+ └── main.ml # Main entry point
228228+```
229229+230230+### Building
231231+232232+```bash
233233+# Build the project
234234+dune build
235235+236236+# Build with verbose output
237237+dune build --verbose
238238+239239+# Clean build artifacts
240240+dune clean
241241+```
242242+243243+### Testing
244244+245245+You can test the bot by running it and sending messages to it in your Zulip instance:
246246+247247+1. Create a bot account in your Zulip instance
248248+2. Download the bot's `.zuliprc` file
249249+3. Run `vicuna -c path/to/.zuliprc -vv`
250250+4. Send a direct message to the bot with `help`
251251+252252+## License
253253+254254+This project is part of the knot/slop/stack collection.
255255+256256+## Dependencies
257257+258258+- OCaml 4.08+
259259+- Dune 3.0+
260260+- eio
261261+- zulip (from ../zulip)
262262+- zulip_bot (from ../zulip)
263263+- logs
264264+- cmdliner
265265+- mirage-crypto-rng-unix
+170
stack/vicuna/USAGE.md
···11+# Vicuna Bot - Quick Usage Guide
22+33+## The Email Problem
44+55+When you authenticate to Zulip using OAuth, SSO, or other third-party authentication methods, Zulip often assigns you an auto-generated email address like:
66+77+- `user123@zulipchat.com`
88+- `person456@zulip.example.com`
99+1010+This isn't your actual email address - it's just an internal identifier.
1111+1212+## The Smart Solution
1313+1414+**Good news!** Vicuna now automatically detects your real email address from your Zulip profile!
1515+1616+Most users can simply type `register` and the bot will try multiple sources:
1717+1. Your `delivery_email` from Zulip profile (the real email associated with your account)
1818+2. Your `user.email` from Zulip profile API (may be your real email depending on server config)
1919+3. Your Zulip message sender email as a fallback
2020+2121+**No need to manually provide your email in most cases!**
2222+2323+## Registration Options
2424+2525+### Option 1: Smart Auto-Detection (RECOMMENDED - Just type `register`)
2626+2727+```
2828+> register
2929+```
3030+3131+The bot will automatically fetch your real email from your Zulip profile!
3232+3333+**Response (when delivery_email is available):**
3434+```
3535+✅ Successfully registered!
3636+• Email: `alice@mycompany.com`
3737+• Zulip ID: `12345`
3838+• Full Name: `Alice Smith`
3939+4040+💡 Your Zulip email is: `user123@zulipchat.com`
4141+📧 Using your delivery email from your profile
4242+You can now be @mentioned by your email or Zulip ID!
4343+```
4444+4545+The bot automatically found `alice@mycompany.com` from your profile!
4646+4747+**Response (when delivery_email is NOT available):**
4848+```
4949+✅ Successfully registered!
5050+• Email: `user123@zulipchat.com`
5151+• Zulip ID: `12345`
5252+• Full Name: `Alice Smith`
5353+5454+💡 Your Zulip email is: `user123@zulipchat.com`
5555+You can now be @mentioned by your email or Zulip ID!
5656+```
5757+5858+Falls back to Zulip email if delivery_email isn't available.
5959+6060+### Option 2: Manual Email Override
6161+6262+```
6363+> register alice@mycompany.com
6464+```
6565+6666+Manually specify a custom email (overrides auto-detection).
6767+6868+**Response:**
6969+```
7070+✅ Successfully registered!
7171+• Email: `alice@mycompany.com`
7272+• Zulip ID: `12345`
7373+• Full Name: `Alice Smith`
7474+7575+💡 Your Zulip email is: `user123@zulipchat.com`
7676+📝 Using the custom email you provided
7777+You can now be @mentioned by your email or Zulip ID!
7878+```
7979+8080+## Lookup Examples
8181+8282+### Find Someone by Email
8383+8484+```
8585+> whois alice@mycompany.com
8686+8787+👤 User found:
8888+• Email: `alice@mycompany.com`
8989+• Zulip ID: `12345`
9090+• Full Name: `Alice Smith`
9191+• Registered: 2025-01-15 10:30:45
9292+```
9393+9494+### Find Someone by Zulip ID
9595+9696+```
9797+> whois 12345
9898+9999+👤 User found:
100100+• Email: `alice@mycompany.com`
101101+• Zulip ID: `12345`
102102+• Full Name: `Alice Smith`
103103+• Registered: 2025-01-15 10:30:45
104104+```
105105+106106+## Check Your Status
107107+108108+```
109109+> whoami
110110+111111+📋 Your registration info:
112112+• Email: `alice@mycompany.com`
113113+• Zulip ID: `12345`
114114+• Full Name: `Alice Smith`
115115+• Registered: 2025-01-15 10:30:45
116116+```
117117+118118+## List All Users
119119+120120+```
121121+> list
122122+123123+📋 Registered users (3):
124124+• **Alice Smith** (`alice@mycompany.com`) - ID: 12345
125125+• **Bob Jones** (`bob@company.org`) - ID: 67890
126126+• **Carol White** (`carol@example.com`) - ID: 54321
127127+```
128128+129129+## Get Help
130130+131131+```
132132+> help
133133+```
134134+135135+Shows all available commands and examples.
136136+137137+## Pro Tips
138138+139139+1. **Just type `register`** - In most cases, the bot will automatically find your real email from your profile!
140140+2. **Check if it worked** - Use `whoami` after registering to see which email was used
141141+3. **Manual override if needed** - If the auto-detection didn't work, use `register your-email@example.com`
142142+4. **Encourage your team to register** - The more people registered, the more useful the directory
143143+5. **Update your registration** - If your email changes, just register again with the new one
144144+6. **The bot stores both emails** - It knows your registered email AND your Zulip internal email
145145+146146+## How Smart Detection Works
147147+148148+The bot uses this priority order:
149149+150150+1. **Custom email** (if you provide one): `register alice@company.com` - Highest priority
151151+2. **delivery_email** (from Zulip profile API): Your real email address associated with the account
152152+3. **user.email** (from Zulip profile API): Email from profile (if different from message sender)
153153+4. **Message sender email** (fallback): The internal `user@zulipchat.com` style email
154154+155155+This means the bot will use the best available email automatically!
156156+157157+### Why Multiple Sources?
158158+159159+- **delivery_email**: The most reliable source for real email, but may be `null` depending on permissions
160160+- **user.email**: Another source from the API that may contain the real email depending on server configuration
161161+- **Message sender email**: Always available but often an auto-generated internal identifier
162162+163163+The bot tries each in order until it finds a usable email address.
164164+165165+## Admin Considerations
166166+167167+- **No admin permissions needed** - Users can register themselves
168168+- **Server-side storage** - All data is stored using Zulip's bot storage API
169169+- **No custom profile fields required** - Works out of the box on any Zulip instance
170170+- **Privacy** - Only registered users appear in the directory (opt-in)
···11+(* Vicuna Bot - User Registration and Management Bot for Zulip *)
22+33+open Zulip_bot
44+55+(* Set up logging *)
66+let src = Logs.Src.create "vicuna_bot" ~doc:"Vicuna User Registration Bot"
77+module Log = (val Logs.src_log src : Logs.LOG)
88+99+(** User registration record *)
1010+type user_registration = {
1111+ email: string;
1212+ zulip_id: int;
1313+ full_name: string;
1414+ registered_at: float;
1515+ is_admin: bool;
1616+}
1717+1818+(** Parse a user registration from JSON-like string format *)
1919+let user_registration_of_string s : user_registration option =
2020+ try
2121+ (* Format: "email|zulip_id|full_name|timestamp|is_admin" *)
2222+ match String.split_on_char '|' s with
2323+ | [email; zulip_id_str; full_name; timestamp_str; is_admin_str] ->
2424+ Some {
2525+ email;
2626+ zulip_id = int_of_string zulip_id_str;
2727+ full_name;
2828+ registered_at = float_of_string timestamp_str;
2929+ is_admin = bool_of_string is_admin_str;
3030+ }
3131+ | [email; zulip_id_str; full_name; timestamp_str] ->
3232+ (* Backward compatibility - old format without is_admin *)
3333+ Some {
3434+ email;
3535+ zulip_id = int_of_string zulip_id_str;
3636+ full_name;
3737+ registered_at = float_of_string timestamp_str;
3838+ is_admin = false;
3939+ }
4040+ | _ -> None
4141+ with _ -> None
4242+4343+(** Convert a user registration to string format *)
4444+let user_registration_to_string (reg : user_registration) : string =
4545+ Printf.sprintf "%s|%d|%s|%f|%b"
4646+ reg.email
4747+ reg.zulip_id
4848+ reg.full_name
4949+ reg.registered_at
5050+ reg.is_admin
5151+5252+(** Storage key for a user registration by Zulip ID - this is the only storage key we use *)
5353+let storage_key_for_id zulip_id = Printf.sprintf "user:id:%d" zulip_id
5454+5555+(** Storage key for the list of all registered user IDs *)
5656+let all_users_key = "users:all"
5757+5858+(** Default admin user ID *)
5959+let default_admin_id = 939008
6060+6161+(** Get all registered user IDs from storage *)
6262+let get_all_user_ids storage =
6363+ match Bot_storage.get storage ~key:all_users_key with
6464+ | Some s when s <> "" ->
6565+ String.split_on_char ',' s
6666+ |> List.filter_map (fun id_str ->
6767+ try Some (int_of_string (String.trim id_str))
6868+ with _ -> None)
6969+ | _ -> []
7070+7171+(** Add a user ID to the list of all users (ensures uniqueness) *)
7272+let add_user_id_to_list storage zulip_id =
7373+ let existing = get_all_user_ids storage in
7474+ if List.mem zulip_id existing then
7575+ Ok ()
7676+ else
7777+ let new_list = zulip_id :: existing in
7878+ let value = String.concat "," (List.map string_of_int new_list) in
7979+ Bot_storage.put storage ~key:all_users_key ~value
8080+8181+(** Remove a user ID from the list of all users *)
8282+let remove_user_id_from_list storage zulip_id =
8383+ let existing = get_all_user_ids storage in
8484+ let new_list = List.filter ((<>) zulip_id) existing in
8585+ let value = String.concat "," (List.map string_of_int new_list) in
8686+ Bot_storage.put storage ~key:all_users_key ~value
8787+8888+(** Look up a user by Zulip ID *)
8989+let lookup_user_by_id storage zulip_id =
9090+ match Bot_storage.get storage ~key:(storage_key_for_id zulip_id) with
9191+ | Some s -> user_registration_of_string s
9292+ | None -> None
9393+9494+(** Look up a user by email - scans through all users *)
9595+let lookup_user_by_email storage email =
9696+ let user_ids = get_all_user_ids storage in
9797+ List.find_map (fun zulip_id ->
9898+ match lookup_user_by_id storage zulip_id with
9999+ | Some reg when reg.email = email -> Some reg
100100+ | _ -> None
101101+ ) user_ids
102102+103103+(** Check if user is admin *)
104104+let is_admin storage zulip_id =
105105+ match lookup_user_by_id storage zulip_id with
106106+ | Some reg -> reg.is_admin
107107+ | None -> zulip_id = default_admin_id (* Default admin always has admin rights *)
108108+109109+(** Set admin status for a user *)
110110+let set_admin storage zulip_id is_admin_flag =
111111+ match lookup_user_by_id storage zulip_id with
112112+ | Some reg ->
113113+ let updated_reg = { reg with is_admin = is_admin_flag } in
114114+ let reg_str = user_registration_to_string updated_reg in
115115+ (* Update ID storage key only *)
116116+ Bot_storage.put storage ~key:(storage_key_for_id zulip_id) ~value:reg_str
117117+ | None ->
118118+ Error (Zulip.create_error ~code:(Other "user_not_found") ~msg:"User not registered" ())
119119+120120+(** Register a new user in storage (with optional admin flag) *)
121121+let register_user ?(is_admin=false) storage email zulip_id full_name =
122122+ (* Check if user already exists by ID to prevent duplicates *)
123123+ let existing_by_id = lookup_user_by_id storage zulip_id in
124124+125125+ (* Preserve admin status if user already exists, unless explicitly setting *)
126126+ let final_is_admin = match existing_by_id with
127127+ | Some existing -> existing.is_admin || is_admin
128128+ | None -> is_admin || (zulip_id = default_admin_id)
129129+ in
130130+131131+ let reg = {
132132+ email;
133133+ zulip_id;
134134+ full_name;
135135+ registered_at = Unix.gettimeofday ();
136136+ is_admin = final_is_admin;
137137+ } in
138138+ let reg_str = user_registration_to_string reg in
139139+140140+ (* Store only by ID - we'll use in-memory scanning for email lookups *)
141141+ match Bot_storage.put storage ~key:(storage_key_for_id zulip_id) ~value:reg_str with
142142+ | Error e -> Error e
143143+ | Ok () ->
144144+ (* Add to all users list (by ID, ensures uniqueness) *)
145145+ add_user_id_to_list storage zulip_id
146146+147147+(** Delete a user from storage by Zulip ID *)
148148+let delete_user storage zulip_id =
149149+ match lookup_user_by_id storage zulip_id with
150150+ | Some _reg ->
151151+ (* Remove from ID key only *)
152152+ let _ = Bot_storage.remove storage ~key:(storage_key_for_id zulip_id) in
153153+ (* Remove from all users list *)
154154+ remove_user_id_from_list storage zulip_id
155155+ | None ->
156156+ Error (Zulip.create_error ~code:(Other "user_not_found") ~msg:"User not found" ())
157157+158158+(** Format a timestamp as a human-readable date *)
159159+let format_timestamp timestamp =
160160+ let tm = Unix.localtime timestamp in
161161+ Printf.sprintf "%04d-%02d-%02d %02d:%02d:%02d"
162162+ (tm.tm_year + 1900)
163163+ (tm.tm_mon + 1)
164164+ tm.tm_mday
165165+ tm.tm_hour
166166+ tm.tm_min
167167+ tm.tm_sec
168168+169169+(** Validate email format (basic check) *)
170170+let is_valid_email email =
171171+ let email = String.trim email in
172172+ String.length email > 0 &&
173173+ String.contains email '@' &&
174174+ match String.split_on_char '@' email with
175175+ | [local; domain] ->
176176+ String.length local > 0 &&
177177+ String.length domain > 0 &&
178178+ String.contains domain '.'
179179+ | _ -> false
180180+181181+(** Handle the 'register' command *)
182182+let handle_register storage sender_email sender_id sender_name custom_email_opt =
183183+ (* First, try to fetch the user's profile from the Zulip API to get delivery_email and email *)
184184+ let client = Bot_storage.client storage in
185185+ let (delivery_email_from_api, user_email_from_api) =
186186+ match Zulip.Users.get_by_id client ~user_id:sender_id with
187187+ | Ok user ->
188188+ let delivery = match Zulip.User.delivery_email user with
189189+ | Some email when email <> "" ->
190190+ Log.info (fun m -> m "Found delivery_email from API: %s" email);
191191+ Some email
192192+ | _ ->
193193+ Log.debug (fun m -> m "No delivery_email available from API");
194194+ None
195195+ in
196196+ let user_email = Zulip.User.email user in
197197+ (* Check if the user.email from API is different from sender_email (message context) *)
198198+ let api_email =
199199+ if user_email <> "" && user_email <> sender_email then (
200200+ Log.info (fun m -> m "Found user.email from API: %s (differs from message sender: %s)" user_email sender_email);
201201+ Some user_email
202202+ ) else (
203203+ Log.debug (fun m -> m "API user.email same as sender_email or empty");
204204+ None
205205+ )
206206+ in
207207+ (delivery, api_email)
208208+ | Error e ->
209209+ Log.warn (fun m -> m "Failed to fetch user profile: %s" (Zulip.error_message e));
210210+ (None, None)
211211+ in
212212+213213+ (* Determine the email to register with priority:
214214+ 1. Custom email provided by user
215215+ 2. delivery_email from API
216216+ 3. user.email from API (if different from sender_email)
217217+ 4. Zulip sender email (fallback) *)
218218+ let email_to_register = match custom_email_opt with
219219+ | Some email ->
220220+ let email = String.trim email in
221221+ if is_valid_email email then
222222+ email
223223+ else (
224224+ Log.warn (fun m -> m "Invalid email format provided: %s, trying API emails or falling back to sender email" email);
225225+ match delivery_email_from_api with
226226+ | Some email -> email
227227+ | None ->
228228+ (match user_email_from_api with
229229+ | Some email -> email
230230+ | None -> sender_email)
231231+ )
232232+ | None ->
233233+ (* No custom email provided, try delivery_email first, then user.email, then fallback *)
234234+ (match delivery_email_from_api with
235235+ | Some email -> email
236236+ | None ->
237237+ (match user_email_from_api with
238238+ | Some email -> email
239239+ | None -> sender_email))
240240+ in
241241+242242+ Log.info (fun m -> m "Registering user: %s (ID: %d)" email_to_register sender_id);
243243+244244+ (* Build info message about email source *)
245245+ let email_source_note =
246246+ if custom_email_opt <> None then
247247+ "\n📝 Using the custom email you provided"
248248+ else if custom_email_opt = None && delivery_email_from_api <> None then
249249+ "\n📧 Using your delivery email from your profile"
250250+ else if custom_email_opt = None && user_email_from_api <> None then
251251+ "\n📧 Using your email from your profile (user.email)"
252252+ else
253253+ ""
254254+ in
255255+256256+ (* Check if already registered *)
257257+ match lookup_user_by_email storage email_to_register with
258258+ | Some existing ->
259259+ if existing.zulip_id = sender_id then (
260260+ (* Ensure user is in the master list (idempotent) *)
261261+ let _ = add_user_id_to_list storage sender_id in
262262+ Printf.sprintf "You are already registered!\n\
263263+ • Email: `%s`\n\
264264+ • Zulip ID: `%d`\n\
265265+ • Registered: %s\n\n\
266266+ 💡 Your Zulip email is: `%s`%s"
267267+ existing.email
268268+ existing.zulip_id
269269+ (format_timestamp existing.registered_at)
270270+ sender_email
271271+ email_source_note
272272+ ) else
273273+ (* Email exists but different ID - update it *)
274274+ (match register_user storage email_to_register sender_id sender_name with
275275+ | Ok () ->
276276+ Log.info (fun m -> m "Updated registration for %s" email_to_register);
277277+ Printf.sprintf "✅ Updated your registration!\n\
278278+ • Email: `%s`\n\
279279+ • Zulip ID: `%d`\n\n\
280280+ 💡 Your Zulip email is: `%s`%s"
281281+ email_to_register sender_id sender_email email_source_note
282282+ | Error e ->
283283+ Log.err (fun m -> m "Failed to update registration: %s" (Zulip.error_message e));
284284+ Printf.sprintf "❌ Failed to update registration: %s" (Zulip.error_message e))
285285+ | None ->
286286+ (* New registration *)
287287+ (match register_user storage email_to_register sender_id sender_name with
288288+ | Ok () ->
289289+ Log.info (fun m -> m "Successfully registered %s" email_to_register);
290290+ Printf.sprintf "✅ Successfully registered!\n\
291291+ • Email: `%s`\n\
292292+ • Zulip ID: `%d`\n\
293293+ • Full Name: `%s`\n\n\
294294+ 💡 Your Zulip email is: `%s`%s\n\
295295+ You can now be @mentioned by your email or Zulip ID!"
296296+ email_to_register sender_id sender_name sender_email email_source_note
297297+ | Error e ->
298298+ Log.err (fun m -> m "Failed to register user: %s" (Zulip.error_message e));
299299+ Printf.sprintf "❌ Failed to register: %s" (Zulip.error_message e))
300300+301301+(** Handle the 'whoami' command *)
302302+let handle_whoami storage sender_email _sender_id =
303303+ match lookup_user_by_email storage sender_email with
304304+ | Some reg ->
305305+ Printf.sprintf "📋 Your registration info:\n\
306306+ • Email: `%s`\n\
307307+ • Zulip ID: `%d`\n\
308308+ • Full Name: `%s`\n\
309309+ • Registered: %s"
310310+ reg.email
311311+ reg.zulip_id
312312+ reg.full_name
313313+ (format_timestamp reg.registered_at)
314314+ | None ->
315315+ Printf.sprintf "You are not registered yet. Use `register` to register yourself!"
316316+317317+(** Handle the 'whois' command *)
318318+let handle_whois storage query =
319319+ (* Try to parse as email or ID *)
320320+ match int_of_string_opt query with
321321+ | Some id ->
322322+ (* Query is a number, look up by ID *)
323323+ (match lookup_user_by_id storage id with
324324+ | Some reg ->
325325+ Printf.sprintf "👤 User found:\n\
326326+ • Email: `%s`\n\
327327+ • Zulip ID: `%d`\n\
328328+ • Full Name: `%s`\n\
329329+ • Registered: %s"
330330+ reg.email
331331+ reg.zulip_id
332332+ reg.full_name
333333+ (format_timestamp reg.registered_at)
334334+ | None ->
335335+ Printf.sprintf "❓ No user found with ID: %d" id)
336336+ | None ->
337337+ (* Query is not a number, treat as email *)
338338+ let email = String.trim query in
339339+ (match lookup_user_by_email storage email with
340340+ | Some reg ->
341341+ Printf.sprintf "👤 User found:\n\
342342+ • Email: `%s`\n\
343343+ • Zulip ID: `%d`\n\
344344+ • Full Name: `%s`\n\
345345+ • Registered: %s"
346346+ reg.email
347347+ reg.zulip_id
348348+ reg.full_name
349349+ (format_timestamp reg.registered_at)
350350+ | None ->
351351+ Printf.sprintf "❓ No user found with email: %s" email)
352352+353353+(** Handle the 'list' command *)
354354+let handle_list storage =
355355+ let user_ids = get_all_user_ids storage in
356356+ if user_ids = [] then
357357+ "📋 No users registered yet."
358358+ else
359359+ let user_lines = List.filter_map (fun zulip_id ->
360360+ match lookup_user_by_id storage zulip_id with
361361+ | Some reg ->
362362+ let admin_badge = if reg.is_admin then " 👑" else "" in
363363+ Some (Printf.sprintf "• **%s** (`%s`) - ID: %d%s"
364364+ reg.full_name reg.email reg.zulip_id admin_badge)
365365+ | None -> None
366366+ ) user_ids in
367367+ Printf.sprintf "📋 Registered users (%d):\n%s"
368368+ (List.length user_lines)
369369+ (String.concat "\n" user_lines)
370370+371371+(** Handle the 'help' command *)
372372+let handle_help sender_name sender_email =
373373+ Printf.sprintf "👋 Hi %s! I'm **Vicuna**, your user registration assistant.\n\n\
374374+ **Available Commands:**\n\
375375+ • `register` - Auto-detect your real email or use Zulip email\n\
376376+ • `register <your-email@example.com>` - Register with a specific email\n\
377377+ • `whoami` - Show your registration status\n\
378378+ • `whois <email|id>` - Look up a registered user\n\
379379+ • `list` - List all registered users\n\
380380+ • `help` - Show this help message\n\n\
381381+ **Examples:**\n\
382382+ • `register` - Auto-detect your email (your Zulip email is `%s`)\n\
383383+ • `register alice@mycompany.com` - Register with a specific email\n\
384384+ • `whois alice@example.com` - Look up Alice by email\n\
385385+ • `whois 12345` - Look up user by Zulip ID\n\n\
386386+ **Smart Email Detection:**\n\
387387+ When you use `register` without an email, I'll try to:\n\
388388+ 1. Find your delivery email from your Zulip profile (delivery_email)\n\
389389+ 2. Use your profile email if available (user.email)\n\
390390+ 3. Fall back to your Zulip message email if needed\n\n\
391391+ This means you usually don't need to manually provide your email!\n\n\
392392+ Send me a direct message to get started!"
393393+ sender_name sender_email
394394+395395+(** Parse command from message content *)
396396+let parse_command content =
397397+ let trimmed = String.trim content in
398398+ match String.index_opt trimmed ' ' with
399399+ | None -> (trimmed, "")
400400+ | Some idx ->
401401+ let cmd = String.sub trimmed 0 idx in
402402+ let args = String.sub trimmed (idx + 1) (String.length trimmed - idx - 1) |> String.trim in
403403+ (cmd, args)
404404+405405+(** Main bot handler implementation *)
406406+module Vicuna_handler : Bot_handler.S = struct
407407+ let initialize _config =
408408+ Log.info (fun m -> m "Initializing Vicuna bot handler");
409409+ Ok ()
410410+411411+ let usage () =
412412+ "Vicuna - User Registration and Management Bot"
413413+414414+ let description () =
415415+ "A bot that helps users register and manage their email to Zulip ID mappings. \
416416+ Register with 'register', check your status with 'whoami', and look up others with 'whois'."
417417+418418+ let handle_message ~config:_ ~storage ~identity ~message ~env:_ =
419419+ (* Log the message *)
420420+ Log.debug (fun m -> m "@[<h>Received: %a@]" (Message.pp_ansi ~show_json:false) message);
421421+422422+ (* Get sender information *)
423423+ let sender_email = Message.sender_email message in
424424+ let sender_id = Message.sender_id message in
425425+ let sender_name = Message.sender_full_name message in
426426+ let bot_email = Bot_handler.Identity.email identity in
427427+428428+ (* Ignore our own messages *)
429429+ if sender_email = bot_email then (
430430+ Log.debug (fun m -> m "Ignoring own message");
431431+ Ok Bot_handler.Response.None
432432+ ) else
433433+ (* Clean the message content *)
434434+ let cleaned_msg = Message.strip_mention message ~user_email:bot_email in
435435+ let (command, args) = parse_command cleaned_msg in
436436+ let command_lower = String.lowercase_ascii command in
437437+438438+ Log.info (fun m -> m "Command: %s, Args: %s" command_lower args);
439439+440440+ (* Handle commands *)
441441+ let response_content =
442442+ match command_lower with
443443+ | "" | "hi" | "hello" ->
444444+ handle_help sender_name sender_email
445445+ | "help" ->
446446+ handle_help sender_name sender_email
447447+ | "register" ->
448448+ let custom_email = if args = "" then None else Some args in
449449+ handle_register storage sender_email sender_id sender_name custom_email
450450+ | "whoami" ->
451451+ handle_whoami storage sender_email sender_id
452452+ | "whois" ->
453453+ if args = "" then
454454+ "Usage: `whois <email|id>` - Example: `whois alice@example.com` or `whois 12345`"
455455+ else
456456+ handle_whois storage args
457457+ | "list" ->
458458+ handle_list storage
459459+ | _ ->
460460+ Printf.sprintf "Unknown command: `%s`. Use `help` to see available commands." command
461461+ in
462462+463463+ Ok (Bot_handler.Response.Reply response_content)
464464+end
465465+466466+(** {1 Storage Management Functions} *)
467467+468468+(** Get all storage keys (excluding deleted keys with empty values) *)
469469+let get_storage_keys storage =
470470+ match Bot_storage.keys storage with
471471+ | Error e -> Error e
472472+ | Ok keys ->
473473+ (* Filter out keys with empty values (these are deleted keys) *)
474474+ let non_empty_keys = List.filter (fun key ->
475475+ match Bot_storage.get storage ~key with
476476+ | Some value when value <> "" -> true
477477+ | _ -> false
478478+ ) keys in
479479+ Ok non_empty_keys
480480+481481+(** Get the value of a specific storage key *)
482482+let get_storage_value storage key =
483483+ Bot_storage.get storage ~key
484484+485485+(** Delete a specific storage key *)
486486+let delete_storage_key storage key =
487487+ Bot_storage.remove storage ~key
488488+489489+(** Clear all storage (delete all keys) *)
490490+let clear_storage storage =
491491+ match Bot_storage.keys storage with
492492+ | Error e -> Error e
493493+ | Ok keys ->
494494+ List.fold_left (fun acc key ->
495495+ match acc with
496496+ | Error _ as err -> err
497497+ | Ok () -> Bot_storage.remove storage ~key
498498+ ) (Ok ()) keys
499499+500500+(** Create the bot handler instance *)
501501+let create_handler config storage identity =
502502+ Bot_handler.create (module Vicuna_handler) ~config ~storage ~identity
+84
stack/vicuna/lib/vicuna_bot.mli
···11+(** Vicuna Bot - User Registration and Management Bot for Zulip *)
22+33+(** Create a Vicuna bot handler instance *)
44+val create_handler :
55+ Zulip_bot.Bot_config.t ->
66+ Zulip_bot.Bot_storage.t ->
77+ Zulip_bot.Bot_handler.Identity.t ->
88+ Zulip_bot.Bot_handler.t
99+1010+(** {1 CLI Management Functions} *)
1111+1212+(** Default admin user ID *)
1313+val default_admin_id : int
1414+1515+(** Register a user with optional admin flag *)
1616+val register_user :
1717+ ?is_admin:bool ->
1818+ Zulip_bot.Bot_storage.t ->
1919+ string ->
2020+ int ->
2121+ string ->
2222+ (unit, Zulip.zerror) result
2323+2424+(** Delete a user by Zulip ID *)
2525+val delete_user :
2626+ Zulip_bot.Bot_storage.t ->
2727+ int ->
2828+ (unit, Zulip.zerror) result
2929+3030+(** Check if a user is an admin *)
3131+val is_admin :
3232+ Zulip_bot.Bot_storage.t ->
3333+ int ->
3434+ bool
3535+3636+(** Set admin status for a user *)
3737+val set_admin :
3838+ Zulip_bot.Bot_storage.t ->
3939+ int ->
4040+ bool ->
4141+ (unit, Zulip.zerror) result
4242+4343+(** Get all registered user IDs *)
4444+val get_all_user_ids :
4545+ Zulip_bot.Bot_storage.t ->
4646+ int list
4747+4848+(** Look up a user by Zulip ID *)
4949+type user_registration = {
5050+ email: string;
5151+ zulip_id: int;
5252+ full_name: string;
5353+ registered_at: float;
5454+ is_admin: bool;
5555+}
5656+5757+val lookup_user_by_id :
5858+ Zulip_bot.Bot_storage.t ->
5959+ int ->
6060+ user_registration option
6161+6262+(** {1 Storage Management Functions} *)
6363+6464+(** Get all storage keys *)
6565+val get_storage_keys :
6666+ Zulip_bot.Bot_storage.t ->
6767+ (string list, Zulip.zerror) result
6868+6969+(** Get the value of a specific storage key *)
7070+val get_storage_value :
7171+ Zulip_bot.Bot_storage.t ->
7272+ string ->
7373+ string option
7474+7575+(** Delete a specific storage key *)
7676+val delete_storage_key :
7777+ Zulip_bot.Bot_storage.t ->
7878+ string ->
7979+ (unit, Zulip.zerror) result
8080+8181+(** Clear all storage (delete all keys) *)
8282+val clear_storage :
8383+ Zulip_bot.Bot_storage.t ->
8484+ (unit, Zulip.zerror) result