···11+ISC License
22+33+Copyright (c) 2025 Anil Madhavapeddy <anil@recoil.org>
44+55+Permission to use, copy, modify, and distribute this software for any
66+purpose with or without fee is hereby granted, provided that the above
77+copyright notice and this permission notice appear in all copies.
88+99+THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
1010+WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
1111+MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
1212+ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
1313+WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
1414+ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
1515+OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
+85
README.md
···11+# OCaml Zulip Library
22+33+A complete OCaml implementation of the Zulip REST API using the `requests` HTTP
44+library and Eio for async operations.
55+66+## Features
77+88+- Full Zulip REST API client implementation
99+- Uses the modern `requests` library for HTTP communication
1010+- Eio-based asynchronous operations
1111+- Bot framework for building interactive bots (`zulip.bot` subpackage)
1212+- Support for Atom/RSS feed bots
1313+1414+## Installation
1515+1616+```bash
1717+opam install zulip
1818+```
1919+2020+Or from source:
2121+2222+```bash
2323+dune build
2424+dune install
2525+```
2626+2727+## Configuration
2828+2929+Create a `~/.zuliprc` file with your Zulip credentials:
3030+3131+```ini
3232+[api]
3333+email = bot@example.com
3434+key = your-api-key-here
3535+site = https://your-domain.zulipchat.com
3636+```
3737+3838+## Usage
3939+4040+### Basic Client
4141+4242+```ocaml
4343+let () =
4444+ Eio_main.run @@ fun env ->
4545+ Eio.Switch.run @@ fun sw ->
4646+4747+ (* Load authentication *)
4848+ let auth = Zulip.Auth.from_zuliprc () in
4949+5050+ (* Create client *)
5151+ let client = Zulip.Client.create ~sw env auth in
5252+5353+ (* Send a message *)
5454+ let message = Zulip.Message.create
5555+ ~type_:`Channel
5656+ ~to_:["general"]
5757+ ~topic:"Hello"
5858+ ~content:"Hello from OCaml!"
5959+ ()
6060+ in
6161+ let response = Zulip.Messages.send client message in
6262+ Printf.printf "Sent message %d\n" (Zulip.Message_response.id response)
6363+```
6464+6565+### Bot Framework
6666+6767+The `zulip.bot` subpackage provides a fiber-based framework for building Zulip bots:
6868+6969+```ocaml
7070+open Zulip_bot
7171+7272+let echo_handler ~storage:_ ~identity:_ msg =
7373+ Response.reply ("Echo: " ^ Message.content msg)
7474+7575+let () =
7676+ Eio_main.run @@ fun env ->
7777+ Eio.Switch.run @@ fun sw ->
7878+ let fs = Eio.Stdenv.fs env in
7979+ let config = Config.load ~fs "echo-bot" in
8080+ Bot.run ~sw ~env ~config ~handler:echo_handler
8181+```
8282+8383+## License
8484+8585+ISC License. See LICENSE.md for details.
···11+(lang dune 3.0)
22+33+(name zulip)
44+55+(generate_opam_files true)
66+77+(license ISC)
88+(authors "Anil Madhavapeddy")
99+(maintainers "Anil Madhavapeddy <anil@recoil.org>")
1010+(homepage "https://tangled.org/@anil.recoil.org/ocaml-zulip")
1111+(bug_reports "https://tangled.org/@anil.recoil.org/ocaml-zulip/issues")
1212+1313+(package
1414+ (name zulip)
1515+ (synopsis "OCaml bindings for the Zulip REST API with bot framework")
1616+ (description
1717+ "High-quality OCaml bindings to the Zulip REST API using Eio for async operations. Includes a fiber-based bot framework (zulip.bot) with XDG configuration support.")
1818+ (depends
1919+ (ocaml (>= 5.1.0))
2020+ eio
2121+ requests
2222+ uri
2323+ base64
2424+ init
2525+ jsont
2626+ logs
2727+ fmt
2828+ xdge
2929+ eio_main
3030+ cmdliner
3131+ (odoc :with-doc)
3232+ (alcotest :with-test)
3333+ (mirage-crypto-rng :with-test)))
+230
examples/README_ECHO_BOT.md
···11+# Zulip Echo Bot with Verbose Logging
22+33+An enhanced echo bot that demonstrates the Zulip bot framework with comprehensive logging and CLI configuration. The bot responds to direct messages and mentions by echoing back the message content, with detailed logging at every step.
44+55+## Features
66+77+- **Responds to direct messages and @mentions in channels**
88+- **Comprehensive logging** with multiple verbosity levels
99+- **Command-line interface** with help and configuration options
1010+- **Detailed message tracing** for debugging
1111+- **Structured logging** using the OCaml logs library
1212+- **Built-in commands**: help, ping, and echo
1313+- **Avoids infinite loops** by ignoring its own messages
1414+1515+## Prerequisites
1616+1717+1. A Zulip account and server
1818+2. A bot user created in Zulip (Settings → Bots → Add a new bot)
1919+3. OCaml and dependencies installed
2020+2121+## Setup
2222+2323+### 1. Create a Bot in Zulip
2424+2525+1. Go to your Zulip settings
2626+2. Navigate to "Bots" section
2727+3. Click "Add a new bot"
2828+4. Choose "Generic bot" type
2929+5. Give it a name (e.g., "Echo Bot")
3030+6. Note down the bot email and API key
3131+3232+### 2. Configure Authentication
3333+3434+Create a `~/.zuliprc` file with your bot's credentials:
3535+3636+```ini
3737+[api]
3838+email=echo-bot@your-domain.zulipchat.com
3939+key=your-bot-api-key
4040+site=https://your-domain.zulipchat.com
4141+```
4242+4343+Replace the values with your actual bot credentials.
4444+4545+### 3. Build the Bot
4646+4747+```bash
4848+# From the zulip directory
4949+dune build
5050+5151+# Or build just the echo bot
5252+dune build zulip/examples/echo_bot.exe
5353+```
5454+5555+## Running the Bot
5656+5757+### Basic Usage
5858+5959+```bash
6060+# Show help and available options
6161+dune exec echo_bot -- --help
6262+6363+# Run with default settings (info level logging)
6464+dune exec echo_bot
6565+6666+# Run with verbose logging (shows all info messages)
6767+dune exec echo_bot -- -v
6868+6969+# Run with debug logging (shows everything)
7070+dune exec echo_bot -- -vv
7171+7272+# Use custom config file
7373+dune exec echo_bot -- -c /path/to/bot.zuliprc
7474+7575+# Combine options
7676+dune exec echo_bot -- -vv -c ~/my-bot.zuliprc
7777+```
7878+7979+### Example Output with Different Verbosity Levels
8080+8181+**Default (no flags) - Info level:**
8282+```
8383+echo_bot: [INFO] Starting Zulip Echo Bot
8484+echo_bot: [INFO] Log level: Info
8585+echo_bot: [INFO] =============================
8686+8787+echo_bot: [INFO] Echo bot is running!
8888+echo_bot: [INFO] Send a direct message or mention @echobot in a channel.
8989+echo_bot: [INFO] Commands: 'help', 'ping', or any message to echo
9090+echo_bot: [INFO] Press Ctrl+C to stop.
9191+```
9292+9393+**Verbose (-v) - Info level with more details:**
9494+```
9595+echo_bot: [INFO] Starting Zulip Echo Bot
9696+echo_bot: [INFO] Log level: Info
9797+echo_bot: [INFO] =============================
9898+9999+echo_bot: [INFO] Loaded authentication for: echo-bot@your-domain.zulipchat.com
100100+echo_bot: [INFO] Server: https://your-domain.zulipchat.com
101101+echo_bot: [INFO] Bot identity created: Echo Bot (echo-bot@your-domain.zulipchat.com)
102102+echo_bot: [INFO] Echo bot is running!
103103+echo_bot: [INFO] Processing message with 12 fields
104104+echo_bot: [INFO] Message metadata: type=private, sender=John Doe (john@example.com), id=12345
105105+echo_bot: [INFO] Processing message from John Doe (ID: 123): Hello bot!
106106+echo_bot: [INFO] Sending private reply: Echo from John Doe: Hello bot!
107107+```
108108+109109+**Debug (-vv) - Full debug output:**
110110+```
111111+echo_bot: [INFO] Starting Zulip Echo Bot
112112+echo_bot: [DEBUG] Creating Zulip client
113113+echo_bot: [DEBUG] Creating bot storage for echo-bot@your-domain.zulipchat.com
114114+echo_bot: [DEBUG] Creating bot handler
115115+echo_bot: [DEBUG] Creating bot runner
116116+echo_bot: [DEBUG] Received message for processing
117117+echo_bot: [DEBUG] Extracted field content: Hello bot!
118118+echo_bot: [DEBUG] Extracted field sender_email: john@example.com
119119+echo_bot: [DEBUG] Extracted field sender_full_name: John Doe
120120+echo_bot: [DEBUG] Extracted field type: private
121121+echo_bot: [DEBUG] Extracted field sender_id: 123
122122+echo_bot: [DEBUG] Extracted field id: 12345
123123+echo_bot: [DEBUG] No bot mention to remove
124124+echo_bot: [DEBUG] Generated response: Echo from John Doe: Hello bot!
125125+```
126126+127127+## Testing the Bot
128128+129129+### Direct Message Test
130130+1. Open Zulip
131131+2. Send a direct message to your bot
132132+3. Type: `Hello bot!`
133133+4. The bot should respond: `Echo from [Your Name]: Hello bot!`
134134+135135+### Channel Mention Test
136136+1. In any channel, type: `@echobot Hello everyone!`
137137+2. The bot should respond: `Echo from [Your Name]: Hello everyone!`
138138+139139+### Special Commands
140140+141141+- **`help`** - Get usage information
142142+- **`ping`** - Bot responds with "Pong! 🏓"
143143+- Any other text - Bot echoes it back
144144+145145+## How It Works
146146+147147+The echo bot:
148148+1. **Initializes logging** based on CLI verbosity flags
149149+2. **Connects to Zulip** using the real-time events API
150150+3. **Listens for messages** where it's mentioned or direct messaged
151151+4. **Logs message details** at various verbosity levels
152152+5. **Extracts fields** with debug logging for each field
153153+6. **Processes content** and removes bot mentions
154154+7. **Generates responses** with appropriate logging
155155+8. **Sends back echo** with type-appropriate reply
156156+9. **Ignores own messages** to prevent loops
157157+158158+## Code Structure
159159+160160+- **Logging Setup**: Creates a custom log source `echo_bot` for structured logging
161161+- **Bot Handler Module**: Implements the `Bot_handler.S` signature with comprehensive logging
162162+- **Field Extraction**: Helper functions with debug logging for each field
163163+- **Message Processing**: Detailed logging of message metadata and content
164164+- **Response Generation**: Logs response creation and type determination
165165+- **CLI Interface**: Cmdliner-based argument parsing with help text
166166+- **Verbosity Control**: Maps CLI flags to log levels (Info/Debug)
167167+- **Error Handling**: Catches and logs exceptions with backtraces
168168+169169+## Customization
170170+171171+You can modify the echo bot to:
172172+- Add more commands (parse for specific keywords)
173173+- Store conversation history (use `Bot_storage`)
174174+- Integrate with external APIs
175175+- Format responses differently
176176+- Add emoji reactions
177177+178178+## Troubleshooting
179179+180180+### Bot doesn't respond
181181+- Check that the bot is actually running (look for console output)
182182+- Verify the bot has permissions in the channel
183183+- Check that you're mentioning the bot correctly (@botname)
184184+- Look for error messages in the console
185185+186186+### Authentication errors
187187+- Verify your `.zuliprc` file has the correct credentials
188188+- Ensure the API key hasn't been regenerated
189189+- Check that the bot user is active in Zulip
190190+191191+### Build errors
192192+- Make sure all dependencies are installed: `opam install zulip zulip_bot eio_main`
193193+- Clean and rebuild: `dune clean && dune build`
194194+195195+## Next Steps
196196+197197+Once you have the echo bot working, you can:
198198+1. Extend it with more complex command parsing
199199+2. Add persistent storage for user preferences
200200+3. Integrate with external services
201201+4. Build more sophisticated bots using the same framework
202202+203203+## Example Extensions
204204+205205+### Adding a Command Parser
206206+```ocaml
207207+let parse_command content =
208208+ match String.split_on_char ' ' content with
209209+ | "!echo" :: rest -> Some ("echo", String.concat " " rest)
210210+ | "!reverse" :: rest -> Some ("reverse", String.concat " " rest)
211211+ | _ -> None
212212+```
213213+214214+### Using Bot Storage
215215+```ocaml
216216+(* Store user preferences *)
217217+let _ = Bot_storage.put storage ~key:"user_prefs" ~value:"{...}" in
218218+219219+(* Retrieve later *)
220220+let prefs = Bot_storage.get storage ~key:"user_prefs" in
221221+```
222222+223223+### Sending to Specific Channels
224224+```ocaml
225225+Bot_handler.Response.ChannelMessage {
226226+ channel = "general";
227227+ topic = "Bot Updates";
228228+ content = "Echo bot is online!";
229229+}
230230+```
+408
examples/atom_feed_bot.ml
···11+(*---------------------------------------------------------------------------
22+ Copyright (c) 2025 Anil Madhavapeddy <anil@recoil.org>. All rights reserved.
33+ SPDX-License-Identifier: ISC
44+ ---------------------------------------------------------------------------*)
55+66+(** Atom Feed Bot for Zulip.
77+88+ Posts Atom/RSS feed entries to Zulip channels organized by topic.
99+ Supports both interactive mode (responds to !feed commands) and
1010+ scheduled mode (periodically fetches configured feeds). *)
1111+1212+(* Logging setup *)
1313+let src = Logs.Src.create "atom_feed_bot" ~doc:"Atom feed bot for Zulip"
1414+1515+module Log = (val Logs.src_log src : Logs.LOG)
1616+1717+module Feed_parser = struct
1818+ type entry = {
1919+ title : string;
2020+ link : string;
2121+ summary : string option;
2222+ published : string option;
2323+ author : string option;
2424+ }
2525+2626+ type feed = { title : string; entries : entry list }
2727+2828+ (* Simple XML parser for Atom/RSS feeds *)
2929+ let parse_xml_element xml element_name =
3030+ let open_tag = "<" ^ element_name ^ ">" in
3131+ let close_tag = "</" ^ element_name ^ ">" in
3232+ try
3333+ match String.index_opt xml '<' with
3434+ | None -> None
3535+ | Some _ -> (
3636+ let pattern = open_tag in
3737+ let pattern_start =
3838+ try
3939+ Some
4040+ (String.index
4141+ (String.lowercase_ascii xml)
4242+ (String.lowercase_ascii pattern).[0])
4343+ with Not_found -> None
4444+ in
4545+ match pattern_start with
4646+ | None -> None
4747+ | Some _ -> (
4848+ let rec find_substring str sub start =
4949+ if start + String.length sub > String.length str then None
5050+ else if String.sub str start (String.length sub) = sub then
5151+ Some start
5252+ else find_substring str sub (start + 1)
5353+ in
5454+ match find_substring xml open_tag 0 with
5555+ | None -> None
5656+ | Some start_pos -> (
5757+ let content_start = start_pos + String.length open_tag in
5858+ match find_substring xml close_tag content_start with
5959+ | None -> None
6060+ | Some end_pos ->
6161+ let content =
6262+ String.sub xml content_start (end_pos - content_start)
6363+ in
6464+ Some (String.trim content))))
6565+ with _ -> None
6666+6767+ let parse_entry entry_xml =
6868+ let title = parse_xml_element entry_xml "title" in
6969+ let link = parse_xml_element entry_xml "link" in
7070+ let summary = parse_xml_element entry_xml "summary" in
7171+ let published = parse_xml_element entry_xml "published" in
7272+ let author = parse_xml_element entry_xml "author" in
7373+ match (title, link) with
7474+ | Some t, Some l -> Some { title = t; link = l; summary; published; author }
7575+ | _ -> None
7676+7777+ let _parse_feed xml =
7878+ let feed_title =
7979+ parse_xml_element xml "title" |> Option.value ~default:"Unknown Feed"
8080+ in
8181+ let entries = ref [] in
8282+ let rec extract_entries str pos =
8383+ try
8484+ let entry_start =
8585+ try String.index_from str pos '<'
8686+ with Not_found -> String.length str
8787+ in
8888+ if entry_start >= String.length str then ()
8989+ else
9090+ let tag_end = String.index_from str entry_start '>' in
9191+ let tag =
9292+ String.sub str (entry_start + 1) (tag_end - entry_start - 1)
9393+ in
9494+ if tag = "entry" || tag = "item" then (
9595+ let entry_end =
9696+ try String.index_from str tag_end '<'
9797+ with Not_found -> String.length str
9898+ in
9999+ let entry_xml =
100100+ String.sub str entry_start (entry_end - entry_start)
101101+ in
102102+ (match parse_entry entry_xml with
103103+ | Some e -> entries := e :: !entries
104104+ | None -> ());
105105+ extract_entries str entry_end)
106106+ else extract_entries str (tag_end + 1)
107107+ with _ -> ()
108108+ in
109109+ extract_entries xml 0;
110110+ { title = feed_title; entries = List.rev !entries }
111111+end
112112+113113+module Feed_bot = struct
114114+ type config = {
115115+ feeds : (string * string * string) list;
116116+ refresh_interval : float;
117117+ state_file : string;
118118+ }
119119+120120+ type state = { last_seen : (string, string) Hashtbl.t }
121121+122122+ let load_state path =
123123+ try
124124+ let ic = open_in path in
125125+ let state = { last_seen = Hashtbl.create 10 } in
126126+ (try
127127+ while true do
128128+ let line = input_line ic in
129129+ match String.split_on_char '|' line with
130130+ | [ url; id ] -> Hashtbl.add state.last_seen url id
131131+ | _ -> ()
132132+ done
133133+ with End_of_file -> ());
134134+ close_in ic;
135135+ state
136136+ with _ -> { last_seen = Hashtbl.create 10 }
137137+138138+ let save_state path state =
139139+ let oc = open_out path in
140140+ Hashtbl.iter
141141+ (fun url id -> output_string oc (url ^ "|" ^ id ^ "\n"))
142142+ state.last_seen;
143143+ close_out oc
144144+145145+ let fetch_feed _url =
146146+ Feed_parser.
147147+ {
148148+ title = "Mock Feed";
149149+ entries =
150150+ [
151151+ {
152152+ title = "Test Entry";
153153+ link = "https://example.com/1";
154154+ summary = Some "This is a test entry";
155155+ published = Some "2024-01-01T00:00:00Z";
156156+ author = Some "Test Author";
157157+ };
158158+ ];
159159+ }
160160+161161+ let format_entry (entry : Feed_parser.entry) =
162162+ let lines = [ Printf.sprintf "**[%s](%s)**" entry.title entry.link ] in
163163+ let lines =
164164+ match entry.author with
165165+ | Some a -> lines @ [ Printf.sprintf "*By %s*" a ]
166166+ | None -> lines
167167+ in
168168+ let lines =
169169+ match entry.published with
170170+ | Some p -> lines @ [ Printf.sprintf "*Published: %s*" p ]
171171+ | None -> lines
172172+ in
173173+ let lines =
174174+ match entry.summary with Some s -> lines @ [ ""; s ] | None -> lines
175175+ in
176176+ String.concat "\n" lines
177177+178178+ let post_entry client channel topic entry =
179179+ let open Feed_parser in
180180+ let message =
181181+ Zulip.Message.create ~type_:`Channel ~to_:[ channel ] ~topic
182182+ ~content:(format_entry entry) ()
183183+ in
184184+ try
185185+ let _ = Zulip.Messages.send client message in
186186+ Printf.printf "Posted: %s\n" entry.title
187187+ with Eio.Exn.Io _ as e ->
188188+ Printf.eprintf "Error posting: %s\n" (Printexc.to_string e)
189189+190190+ let process_feed client state (url, channel, topic) =
191191+ Printf.printf "Processing feed: %s -> #%s/%s\n" url channel topic;
192192+ let feed = fetch_feed url in
193193+ let last_id = Hashtbl.find_opt state.last_seen url in
194194+ let new_entries =
195195+ match last_id with
196196+ | Some id ->
197197+ List.filter (fun e -> Feed_parser.(e.link <> id)) feed.entries
198198+ | None -> feed.entries
199199+ in
200200+ List.iter (post_entry client channel topic) new_entries;
201201+ match feed.entries with
202202+ | h :: _ -> Hashtbl.replace state.last_seen url Feed_parser.(h.link)
203203+ | [] -> ()
204204+205205+ let run_bot env config =
206206+ let auth =
207207+ try Zulip.Auth.from_zuliprc ()
208208+ with Eio.Exn.Io _ as e ->
209209+ Printf.eprintf "Failed to load auth: %s\n" (Printexc.to_string e);
210210+ exit 1
211211+ in
212212+ Eio.Switch.run @@ fun sw ->
213213+ let client = Zulip.Client.create ~sw env auth in
214214+ let state = load_state config.state_file in
215215+ let rec loop () =
216216+ Printf.printf "Checking feeds...\n";
217217+ List.iter (process_feed client state) config.feeds;
218218+ save_state config.state_file state;
219219+ Printf.printf "Sleeping for %.0f seconds...\n" config.refresh_interval;
220220+ Eio.Time.sleep (Eio.Stdenv.clock env) config.refresh_interval;
221221+ loop ()
222222+ in
223223+ loop ()
224224+end
225225+226226+(* Interactive bot that responds to commands *)
227227+module Interactive_feed_bot = struct
228228+ open Zulip_bot
229229+230230+ type t = {
231231+ feeds : (string, string * string) Hashtbl.t;
232232+ mutable default_channel : string;
233233+ }
234234+235235+ let create () = { feeds = Hashtbl.create 10; default_channel = "general" }
236236+237237+ let handle_command bot_state command args =
238238+ match command with
239239+ | "add" -> (
240240+ match args with
241241+ | name :: url :: topic ->
242242+ let topic_str = String.concat " " topic in
243243+ Hashtbl.replace bot_state.feeds name (url, topic_str);
244244+ Printf.sprintf "Added feed '%s' -> %s (topic: %s)" name url
245245+ topic_str
246246+ | _ -> "Usage: !feed add <name> <url> <topic>")
247247+ | "remove" -> (
248248+ match args with
249249+ | name :: _ ->
250250+ if Hashtbl.mem bot_state.feeds name then (
251251+ Hashtbl.remove bot_state.feeds name;
252252+ Printf.sprintf "Removed feed '%s'" name)
253253+ else Printf.sprintf "Feed '%s' not found" name
254254+ | _ -> "Usage: !feed remove <name>")
255255+ | "list" ->
256256+ if Hashtbl.length bot_state.feeds = 0 then "No feeds configured"
257257+ else
258258+ let lines =
259259+ Hashtbl.fold
260260+ (fun name (url, topic) acc ->
261261+ Printf.sprintf "* **%s**: %s -> topic: %s" name url topic :: acc)
262262+ bot_state.feeds []
263263+ in
264264+ String.concat "\n" lines
265265+ | "fetch" -> (
266266+ match args with
267267+ | name :: _ -> (
268268+ match Hashtbl.find_opt bot_state.feeds name with
269269+ | Some (url, _topic) ->
270270+ Printf.sprintf "Fetching feed '%s' from %s..." name url
271271+ | None -> Printf.sprintf "Feed '%s' not found" name)
272272+ | _ -> "Usage: !feed fetch <name>")
273273+ | "channel" -> (
274274+ match args with
275275+ | channel :: _ ->
276276+ bot_state.default_channel <- channel;
277277+ Printf.sprintf "Default channel set to: %s" channel
278278+ | _ ->
279279+ Printf.sprintf "Current default channel: %s"
280280+ bot_state.default_channel)
281281+ | "help" | _ ->
282282+ String.concat "\n"
283283+ [
284284+ "**Atom Feed Bot Commands:**";
285285+ "* `!feed add <name> <url> <topic>` - Add a new feed";
286286+ "* `!feed remove <name>` - Remove a feed";
287287+ "* `!feed list` - List all configured feeds";
288288+ "* `!feed fetch <name>` - Manually fetch a feed";
289289+ "* `!feed channel <name>` - Set default channel";
290290+ "* `!feed help` - Show this help message";
291291+ ]
292292+293293+ (* Create a handler function for the bot *)
294294+ let create_handler bot_state ~storage:_ ~identity message =
295295+ let content = Message.content message in
296296+ let bot_email = identity.Bot.email in
297297+ if Message.is_from_email message ~email:bot_email then Response.silent
298298+ else if String.starts_with ~prefix:"!feed" content then
299299+ let parts = String.split_on_char ' ' (String.trim content) in
300300+ match parts with
301301+ | _ :: command :: args ->
302302+ let response = handle_command bot_state command args in
303303+ Response.reply response
304304+ | _ ->
305305+ let response = handle_command bot_state "help" [] in
306306+ Response.reply response
307307+ else Response.silent
308308+end
309309+310310+(* Run interactive bot mode *)
311311+let run_interactive verbosity env =
312312+ Logs.set_reporter (Logs_fmt.reporter ());
313313+ Logs.set_level
314314+ (Some
315315+ (match verbosity with
316316+ | 0 -> Logs.Info
317317+ | 1 -> Logs.Debug
318318+ | _ -> Logs.Debug));
319319+320320+ Log.info (fun m -> m "Starting interactive Atom feed bot...");
321321+322322+ let bot_state = Interactive_feed_bot.create () in
323323+324324+ let auth =
325325+ try Zulip.Auth.from_zuliprc ()
326326+ with Eio.Exn.Io _ as e ->
327327+ Log.err (fun m -> m "Failed to load auth: %s" (Printexc.to_string e));
328328+ exit 1
329329+ in
330330+331331+ Eio.Switch.run @@ fun sw ->
332332+ let config =
333333+ Zulip_bot.Config.create ~name:"atom-feed-bot"
334334+ ~site:(Zulip.Auth.server_url auth)
335335+ ~email:(Zulip.Auth.email auth) ~api_key:(Zulip.Auth.api_key auth)
336336+ ~description:"Bot for managing and posting Atom/RSS feeds to Zulip" ()
337337+ in
338338+339339+ Log.info (fun m -> m "Feed bot is running! Use !feed help for commands.");
340340+341341+ Zulip_bot.Bot.run ~sw ~env ~config
342342+ ~handler:(Interactive_feed_bot.create_handler bot_state)
343343+344344+(* Run scheduled fetcher mode *)
345345+let run_scheduled verbosity env =
346346+ Logs.set_reporter (Logs_fmt.reporter ());
347347+ Logs.set_level
348348+ (Some
349349+ (match verbosity with
350350+ | 0 -> Logs.Info
351351+ | 1 -> Logs.Debug
352352+ | _ -> Logs.Debug));
353353+354354+ Log.info (fun m -> m "Starting scheduled Atom feed fetcher...");
355355+356356+ let config =
357357+ Feed_bot.
358358+ {
359359+ feeds =
360360+ [
361361+ ("https://example.com/feed.xml", "general", "News");
362362+ ("https://blog.example.com/atom.xml", "general", "Blog Posts");
363363+ ];
364364+ refresh_interval = 300.0;
365365+ state_file = "feed_bot_state.txt";
366366+ }
367367+ in
368368+369369+ Feed_bot.run_bot env config
370370+371371+(* Command-line interface *)
372372+open Cmdliner
373373+374374+let verbosity =
375375+ let doc = "Increase verbosity (can be used multiple times)" in
376376+ let verbosity_flags = Arg.(value & flag_all & info [ "v"; "verbose" ] ~doc) in
377377+ Term.(const List.length $ verbosity_flags)
378378+379379+let mode =
380380+ let doc = "Bot mode (interactive or scheduled)" in
381381+ let modes = [ ("interactive", `Interactive); ("scheduled", `Scheduled) ] in
382382+ Arg.(
383383+ value
384384+ & opt (enum modes) `Interactive
385385+ & info [ "m"; "mode" ] ~docv:"MODE" ~doc)
386386+387387+let main_cmd =
388388+ let doc = "Atom feed bot for Zulip" in
389389+ let man =
390390+ [
391391+ `S Manpage.s_description;
392392+ `P "This bot can run in two modes:";
393393+ `P "- Interactive mode: Responds to !feed commands in Zulip";
394394+ `P "- Scheduled mode: Periodically fetches configured feeds";
395395+ `P "The bot requires a configured ~/.zuliprc file with API credentials.";
396396+ ]
397397+ in
398398+ let info = Cmd.info "atom_feed_bot" ~version:"2.0.0" ~doc ~man in
399399+ let run verbosity mode =
400400+ Eio_main.run @@ fun env ->
401401+ match mode with
402402+ | `Interactive -> run_interactive verbosity env
403403+ | `Scheduled -> run_scheduled verbosity env
404404+ in
405405+ let term = Term.(const run $ verbosity $ mode) in
406406+ Cmd.v info term
407407+408408+let () = exit (Cmd.eval main_cmd)
+10
examples/atom_feed_bot.mli
···11+(*---------------------------------------------------------------------------
22+ Copyright (c) 2025 Anil Madhavapeddy <anil@recoil.org>. All rights reserved.
33+ SPDX-License-Identifier: ISC
44+ ---------------------------------------------------------------------------*)
55+66+(** Atom Feed Bot for Zulip.
77+88+ Posts Atom/RSS feed entries to Zulip channels. Supports both interactive
99+ mode (responds to !feed commands) and scheduled mode (periodically fetches
1010+ configured feeds). *)
···11+(*---------------------------------------------------------------------------
22+ Copyright (c) 2025 Anil Madhavapeddy <anil@recoil.org>. All rights reserved.
33+ SPDX-License-Identifier: ISC
44+ ---------------------------------------------------------------------------*)
55+66+(** Enhanced Echo Bot for Zulip with Logging and CLI.
77+88+ Responds to direct messages and mentions by echoing back the message. Uses
99+ the Zulip_bot API with cmdliner integration. *)
1010+1111+open Zulip_bot
1212+1313+let src = Logs.Src.create "echo_bot" ~doc:"Zulip Echo Bot"
1414+1515+module Log = (val Logs.src_log src : Logs.LOG)
1616+1717+(* The handler is now just a function *)
1818+let echo_handler ~storage ~identity msg =
1919+ Log.debug (fun m ->
2020+ m "@[<h>Received: %a@]" (Message.pp_ansi ~show_json:false) msg);
2121+2222+ let bot_email = identity.Bot.email in
2323+ let sender_email = Message.sender_email msg in
2424+ let sender_name = Message.sender_full_name msg in
2525+2626+ (* Ignore our own messages *)
2727+ if sender_email = bot_email then Response.silent
2828+ else
2929+ (* Remove bot mention *)
3030+ let cleaned_msg = Message.strip_mention msg ~user_email:bot_email in
3131+ Log.debug (fun m -> m "Cleaned message: %s" cleaned_msg);
3232+3333+ (* Process command or echo *)
3434+ let lower_msg = String.lowercase_ascii cleaned_msg in
3535+ let response_content =
3636+ if cleaned_msg = "" then
3737+ Printf.sprintf "Hello %s! Send me a message and I'll echo it back!"
3838+ sender_name
3939+ else if lower_msg = "help" then
4040+ Printf.sprintf
4141+ "Hi %s! I'm an echo bot with storage. Commands:\n\
4242+ • `help` - Show this help\n\
4343+ • `ping` - Test if I'm alive\n\
4444+ • `store <key> <value>` - Store a value\n\
4545+ • `get <key>` - Retrieve a value\n\
4646+ • `delete <key>` - Delete a stored value\n\
4747+ • `list` - List all stored keys\n\
4848+ • Any other message - I'll echo it back!"
4949+ sender_name
5050+ else if lower_msg = "ping" then (
5151+ Log.info (fun m -> m "Responding to ping from %s" sender_name);
5252+ Printf.sprintf "Pong! (from %s)" sender_name)
5353+ else if String.starts_with ~prefix:"store " lower_msg then
5454+ let parts =
5555+ String.sub cleaned_msg 6 (String.length cleaned_msg - 6)
5656+ |> String.trim
5757+ in
5858+ match String.index_opt parts ' ' with
5959+ | Some idx -> (
6060+ let key = String.sub parts 0 idx |> String.trim in
6161+ let value =
6262+ String.sub parts (idx + 1) (String.length parts - idx - 1)
6363+ |> String.trim
6464+ in
6565+ try
6666+ Storage.set storage key value;
6767+ Log.info (fun m ->
6868+ m "Stored key=%s value=%s for user %s" key value sender_name);
6969+ Printf.sprintf "Stored: `%s` = `%s`" key value
7070+ with Eio.Exn.Io _ as e ->
7171+ Log.err (fun m ->
7272+ m "Failed to store key=%s: %s" key (Printexc.to_string e));
7373+ Printf.sprintf "Failed to store: %s" (Printexc.to_string e))
7474+ | None -> "Usage: `store <key> <value>` - Example: `store name John`"
7575+ else if String.starts_with ~prefix:"get " lower_msg then (
7676+ let key =
7777+ String.sub cleaned_msg 4 (String.length cleaned_msg - 4)
7878+ |> String.trim
7979+ in
8080+ match Storage.get storage key with
8181+ | Some value ->
8282+ Log.info (fun m ->
8383+ m "Retrieved key=%s value=%s for user %s" key value sender_name);
8484+ Printf.sprintf "`%s` = `%s`" key value
8585+ | None ->
8686+ Log.info (fun m -> m "Key not found: %s" key);
8787+ Printf.sprintf "Key not found: `%s`" key)
8888+ else if String.starts_with ~prefix:"delete " lower_msg then (
8989+ let key =
9090+ String.sub cleaned_msg 7 (String.length cleaned_msg - 7)
9191+ |> String.trim
9292+ in
9393+ try
9494+ Storage.remove storage key;
9595+ Log.info (fun m -> m "Deleted key=%s for user %s" key sender_name);
9696+ Printf.sprintf "Deleted key: `%s`" key
9797+ with Eio.Exn.Io _ as e ->
9898+ Log.err (fun m ->
9999+ m "Failed to delete key=%s: %s" key (Printexc.to_string e));
100100+ Printf.sprintf "Failed to delete: %s" (Printexc.to_string e))
101101+ else if lower_msg = "list" then
102102+ try
103103+ let keys = Storage.keys storage in
104104+ if keys = [] then
105105+ "No keys stored yet. Use `store <key> <value>` to add data!"
106106+ else
107107+ let key_list =
108108+ String.concat "\n" (List.map (fun k -> "* `" ^ k ^ "`") keys)
109109+ in
110110+ Printf.sprintf
111111+ "Stored keys:\n%s\n\nUse `get <key>` to retrieve values." key_list
112112+ with Eio.Exn.Io _ as e ->
113113+ Printf.sprintf "Failed to list keys: %s" (Printexc.to_string e)
114114+ else Printf.sprintf "Echo from %s: %s" sender_name cleaned_msg
115115+ in
116116+ Log.debug (fun m -> m "Generated response: %s" response_content);
117117+ Response.reply response_content
118118+119119+let run_echo_bot config env =
120120+ Log.app (fun m -> m "Starting Zulip Echo Bot");
121121+ Log.app (fun m -> m "=============================\n");
122122+123123+ Eio.Switch.run @@ fun sw ->
124124+ Log.info (fun m -> m "Loaded configuration for: %s" config.Config.email);
125125+ Log.info (fun m -> m "Server: %s" config.Config.site);
126126+127127+ Log.app (fun m -> m "Echo bot is running!");
128128+ Log.app (fun m -> m "Send a direct message or mention the bot in a channel.");
129129+ Log.app (fun m -> m "Commands: 'help', 'ping', or any message to echo");
130130+ Log.app (fun m -> m "Press Ctrl+C to stop.\n");
131131+132132+ try Bot.run ~sw ~env ~config ~handler:echo_handler with
133133+ | Sys.Break ->
134134+ Log.info (fun m -> m "Received interrupt signal, shutting down")
135135+ | exn ->
136136+ Log.err (fun m ->
137137+ m "Bot crashed with exception: %s" (Printexc.to_string exn));
138138+ Log.debug (fun m -> m "Backtrace: %s" (Printexc.get_backtrace ()));
139139+ raise exn
140140+141141+open Cmdliner
142142+143143+let bot_cmd eio_env =
144144+ let doc = "Zulip Echo Bot with verbose logging" in
145145+ let man =
146146+ [
147147+ `S Manpage.s_description;
148148+ `P
149149+ "A simple echo bot for Zulip that responds to messages by echoing them \
150150+ back. Features verbose logging for debugging and development.";
151151+ `S "CONFIGURATION";
152152+ `P
153153+ "The bot reads configuration from XDG config directory \
154154+ (~/.config/zulip-bot/echo-bot/config) or from a .zuliprc file.";
155155+ `S "LOGGING";
156156+ `P "Use -v for debug level logging, --verbose-http for HTTP-level details.";
157157+ `S "COMMANDS";
158158+ `P "The bot responds to:";
159159+ `P "- 'help' - Show usage information";
160160+ `P "- 'ping' - Respond with 'Pong!'";
161161+ `P "- 'store <key> <value>' - Store a value";
162162+ `P "- 'get <key>' - Retrieve a value";
163163+ `P "- 'delete <key>' - Delete a stored value";
164164+ `P "- 'list' - List all stored keys";
165165+ `P "- Any other message - Echo it back";
166166+ ]
167167+ in
168168+ let info = Cmd.info "echo_bot" ~version:"2.0.0" ~doc ~man in
169169+ let config_term = Zulip_bot.Cmd.config_term "echo-bot" eio_env in
170170+ Cmd.v info Term.(const (fun config -> run_echo_bot config eio_env) $ config_term)
171171+172172+let () =
173173+ Mirage_crypto_rng_unix.use_default ();
174174+ Eio_main.run @@ fun env -> exit (Cmd.eval (bot_cmd env))
+10
examples/echo_bot.mli
···11+(*---------------------------------------------------------------------------
22+ Copyright (c) 2025 Anil Madhavapeddy <anil@recoil.org>. All rights reserved.
33+ SPDX-License-Identifier: ISC
44+ ---------------------------------------------------------------------------*)
55+66+(** Echo Bot for Zulip.
77+88+ A simple bot that echoes messages back to the sender, with support for
99+ storage commands (store, get, delete, list). See {!README_ECHO_BOT.md}
1010+ for full documentation. *)
···11+(*---------------------------------------------------------------------------
22+ Copyright (c) 2025 Anil Madhavapeddy <anil@recoil.org>. All rights reserved.
33+ SPDX-License-Identifier: ISC
44+ ---------------------------------------------------------------------------*)
55+66+(** Zulip API Regression Test Bot
77+88+ This bot exercises many features of the Zulip OCaml API to verify
99+ the protocol implementation works correctly. Send a DM with "regress"
1010+ to trigger the tests.
1111+1212+ Usage:
1313+ dune exec regression_test -- --channel "Sandbox-test"
1414+*)
1515+1616+open Zulip_bot
1717+1818+let src = Logs.Src.create "regression_test" ~doc:"Zulip API Regression Test"
1919+2020+module Log = (val Logs.src_log src : Logs.LOG)
2121+2222+(** Test result tracking *)
2323+type test_result = {
2424+ name : string;
2525+ passed : bool;
2626+ message : string;
2727+ duration_ms : float;
2828+}
2929+3030+let results : test_result list ref = ref []
3131+3232+let record_result name passed message duration_ms =
3333+ results := { name; passed; message; duration_ms } :: !results;
3434+ if passed then Log.info (fun m -> m "PASS: %s (%s)" name message)
3535+ else Log.err (fun m -> m "FAIL: %s (%s)" name message)
3636+3737+(** Run a test with timing and error handling *)
3838+let run_test name f =
3939+ let start = Unix.gettimeofday () in
4040+ try
4141+ let result = f () in
4242+ let duration = (Unix.gettimeofday () -. start) *. 1000.0 in
4343+ record_result name true result duration
4444+ with
4545+ | Eio.Exn.Io (Zulip.Error.E err, _) ->
4646+ let duration = (Unix.gettimeofday () -. start) *. 1000.0 in
4747+ record_result name false
4848+ (Printf.sprintf "API Error: %s" (Zulip.Error.message err))
4949+ duration
5050+ | exn ->
5151+ let duration = (Unix.gettimeofday () -. start) *. 1000.0 in
5252+ record_result name false
5353+ (Printf.sprintf "Exception: %s" (Printexc.to_string exn))
5454+ duration
5555+5656+(** Format results as markdown table *)
5757+let format_results () =
5858+ let passed = List.filter (fun r -> r.passed) !results in
5959+ let failed = List.filter (fun r -> not r.passed) !results in
6060+ let total = List.length !results in
6161+ let pass_count = List.length passed in
6262+ let buf = Buffer.create 1024 in
6363+ Buffer.add_string buf
6464+ (Printf.sprintf "## Regression Test Results\n\n**%d/%d tests passed**\n\n" pass_count total);
6565+ Buffer.add_string buf "| Test | Status | Details | Time (ms) |\n";
6666+ Buffer.add_string buf "|------|--------|---------|----------|\n";
6767+ List.iter
6868+ (fun r ->
6969+ let status = if r.passed then ":check:" else ":x:" in
7070+ Buffer.add_string buf
7171+ (Printf.sprintf "| %s | %s | %s | %.1f |\n" r.name status r.message r.duration_ms))
7272+ (List.rev !results);
7373+ if List.length failed > 0 then (
7474+ Buffer.add_string buf "\n### Failed Tests\n\n";
7575+ List.iter
7676+ (fun r -> Buffer.add_string buf (Printf.sprintf "- **%s**: %s\n" r.name r.message))
7777+ failed);
7878+ Buffer.contents buf
7979+8080+(** Test: Get current user *)
8181+let test_get_me client =
8282+ let user = Zulip.Users.me client in
8383+ Printf.sprintf "Got user: %s <%s>" (Zulip.User.full_name user) (Zulip.User.email user)
8484+8585+(** Test: List users *)
8686+let test_list_users client =
8787+ let users = Zulip.Users.list client in
8888+ Printf.sprintf "Found %d users" (List.length users)
8989+9090+(** Test: List channels *)
9191+let test_list_channels client =
9292+ let channels = Zulip.Channels.list client in
9393+ Printf.sprintf "Found %d channels" (List.length channels)
9494+9595+(** Test: Get subscriptions *)
9696+let test_get_subscriptions client =
9797+ let subs = Zulip.Channels.get_subscriptions client in
9898+ Printf.sprintf "Subscribed to %d channels" (List.length subs)
9999+100100+(** Test: Get message history *)
101101+let test_get_messages client =
102102+ let _json =
103103+ Zulip.Messages.get_messages client ~anchor:Newest ~num_before:5 ~num_after:0
104104+ ()
105105+ in
106106+ "Retrieved recent messages"
107107+108108+(** Test: Edit a message *)
109109+let test_edit_message client ~message_id ~new_content =
110110+ Zulip.Messages.edit client ~message_id ~content:new_content ();
111111+ Printf.sprintf "Edited message %d" message_id
112112+113113+(** Test: Add reaction *)
114114+let test_add_reaction client ~message_id ~emoji =
115115+ Zulip.Messages.add_reaction client ~message_id ~emoji_name:emoji ();
116116+ Printf.sprintf "Added :%s: reaction to message %d" emoji message_id
117117+118118+(** Test: Remove reaction *)
119119+let test_remove_reaction client ~message_id ~emoji =
120120+ Zulip.Messages.remove_reaction client ~message_id ~emoji_name:emoji ();
121121+ Printf.sprintf "Removed :%s: reaction from message %d" emoji message_id
122122+123123+(** Test: Mark message as read *)
124124+let test_mark_read client ~message_id =
125125+ Zulip.Messages.update_flags client ~messages:[ message_id ]
126126+ ~op:Zulip.Message_flag.Add ~flag:`Read;
127127+ Printf.sprintf "Marked message %d as read" message_id
128128+129129+(** Test: Star a message *)
130130+let test_star_message client ~message_id =
131131+ Zulip.Messages.update_flags client ~messages:[ message_id ]
132132+ ~op:Zulip.Message_flag.Add ~flag:`Starred;
133133+ Printf.sprintf "Starred message %d" message_id
134134+135135+(** Test: Unstar a message *)
136136+let test_unstar_message client ~message_id =
137137+ Zulip.Messages.update_flags client ~messages:[ message_id ]
138138+ ~op:Zulip.Message_flag.Remove ~flag:`Starred;
139139+ Printf.sprintf "Unstarred message %d" message_id
140140+141141+(** Test: Send typing notification *)
142142+let test_typing client ~env ~stream_id ~topic =
143143+ Zulip.Typing.set_channel client ~op:Start ~stream_id ~topic;
144144+ Eio.Time.sleep env#clock 0.1;
145145+ Zulip.Typing.set_channel client ~op:Stop ~stream_id ~topic;
146146+ Printf.sprintf "Sent typing start/stop to stream %d topic %s" stream_id topic
147147+148148+(** Test: Get alert words *)
149149+let test_get_alert_words client =
150150+ let words = Zulip.Users.get_alert_words client in
151151+ Printf.sprintf "Got %d alert words" (List.length words)
152152+153153+(** Test: Add and remove alert word *)
154154+let test_alert_words client =
155155+ let test_word = "zulip_api_test_word_" ^ string_of_int (Random.int 10000) in
156156+ let _ = Zulip.Users.add_alert_words client ~words:[ test_word ] in
157157+ let _ = Zulip.Users.remove_alert_words client ~words:[ test_word ] in
158158+ Printf.sprintf "Added and removed alert word: %s" test_word
159159+160160+(** Test: Get server settings *)
161161+let test_server_settings client =
162162+ let _settings = Zulip.Server.get_settings client in
163163+ "Retrieved server settings"
164164+165165+(** Test: Render message content *)
166166+let test_render_message client =
167167+ let html = Zulip.Messages.render client ~content:"**bold** and _italic_" in
168168+ Printf.sprintf "Rendered %d bytes of HTML" (String.length html)
169169+170170+(** Test: Get channel topics *)
171171+let test_get_topics client ~stream_id =
172172+ let topics = Zulip.Channels.get_topics client ~stream_id in
173173+ Printf.sprintf "Found %d topics" (List.length topics)
174174+175175+(** Test: Get channel subscribers *)
176176+let test_get_subscribers client ~stream_id =
177177+ let subs = Zulip.Channels.get_subscribers client ~stream_id in
178178+ Printf.sprintf "Found %d subscribers" (List.length subs)
179179+180180+(** Test: Send a direct message *)
181181+let test_send_dm client ~recipient ~content =
182182+ let msg = Zulip.Message.create ~type_:`Direct ~to_:[ recipient ] ~content () in
183183+ let resp = Zulip.Messages.send client msg in
184184+ Printf.sprintf "Sent DM (ID %d) to %s" (Zulip.Message_response.id resp) recipient
185185+186186+(** Test: Get presence for all users *)
187187+let test_get_all_presence client =
188188+ let presence = Zulip.Presence.get_all client in
189189+ Printf.sprintf "Got presence for %d users" (List.length presence)
190190+191191+(** Run all regression tests *)
192192+let run_tests ~env ~client ~channel ~trigger_user =
193193+ (* Clear previous results *)
194194+ results := [];
195195+196196+ Log.app (fun m -> m "Starting Zulip API Regression Tests");
197197+ Log.app (fun m -> m "Test channel: %s" channel);
198198+ Log.app (fun m -> m "Triggered by: %s" trigger_user);
199199+ Log.app (fun m -> m "========================================\n");
200200+201201+ (* Get stream_id for test channel *)
202202+ let stream_id =
203203+ try Some (Zulip.Channels.get_id client ~name:channel)
204204+ with _ ->
205205+ Log.warn (fun m -> m "Could not find channel %s" channel);
206206+ None
207207+ in
208208+209209+ let topic = "regression-test-" ^ string_of_int (Random.int 10000) in
210210+211211+ (* Run basic user/channel tests *)
212212+ run_test "Get current user" (fun () -> test_get_me client);
213213+ run_test "List users" (fun () -> test_list_users client);
214214+ run_test "List channels" (fun () -> test_list_channels client);
215215+ run_test "Get subscriptions" (fun () -> test_get_subscriptions client);
216216+ run_test "Get alert words" (fun () -> test_get_alert_words client);
217217+ run_test "Add/remove alert word" (fun () -> test_alert_words client);
218218+ run_test "Get server settings" (fun () -> test_server_settings client);
219219+ (* Note: get_user_settings, get_muted_users, and update_presence don't work with bots *)
220220+ run_test "Get all presence" (fun () -> test_get_all_presence client);
221221+ run_test "Render message" (fun () -> test_render_message client);
222222+223223+ (* Test message operations if we have a test channel *)
224224+ (match stream_id with
225225+ | Some sid ->
226226+ run_test "Get channel topics" (fun () -> test_get_topics client ~stream_id:sid);
227227+ run_test "Get channel subscribers" (fun () -> test_get_subscribers client ~stream_id:sid);
228228+229229+ (* Send test message *)
230230+ let test_msg_id = ref None in
231231+ run_test "Send channel message" (fun () ->
232232+ let content =
233233+ Printf.sprintf
234234+ "**Regression Test Started**\n\nTriggered by: %s\nTest run at: %s\n\nThis message will be edited and have reactions added."
235235+ trigger_user
236236+ (string_of_float (Unix.gettimeofday ()))
237237+ in
238238+ let msg =
239239+ Zulip.Message.create ~type_:`Channel ~to_:[ channel ] ~topic ~content ()
240240+ in
241241+ let resp = Zulip.Messages.send client msg in
242242+ test_msg_id := Some (Zulip.Message_response.id resp);
243243+ Printf.sprintf "Sent message ID %d" (Zulip.Message_response.id resp));
244244+245245+ (match !test_msg_id with
246246+ | Some mid ->
247247+ run_test "Add reaction (robot)" (fun () ->
248248+ test_add_reaction client ~message_id:mid ~emoji:"robot");
249249+ run_test "Add reaction (thumbs_up)" (fun () ->
250250+ test_add_reaction client ~message_id:mid ~emoji:"thumbs_up");
251251+ run_test "Remove reaction" (fun () ->
252252+ test_remove_reaction client ~message_id:mid ~emoji:"thumbs_up");
253253+ run_test "Mark as read" (fun () -> test_mark_read client ~message_id:mid);
254254+ run_test "Star message" (fun () -> test_star_message client ~message_id:mid);
255255+ run_test "Unstar message" (fun () -> test_unstar_message client ~message_id:mid);
256256+ run_test "Edit message" (fun () ->
257257+ test_edit_message client ~message_id:mid
258258+ ~new_content:
259259+ "**Regression Test - EDITED**\n\nThis message was successfully edited.\n\nPlease react with :tada: to verify reactions work!");
260260+ run_test "Typing indicator" (fun () ->
261261+ test_typing client ~env ~stream_id:sid ~topic)
262262+ | None -> Log.warn (fun m -> m "Skipping message-specific tests - no message ID"))
263263+ | None -> Log.warn (fun m -> m "Skipping channel tests - no stream ID"));
264264+265265+ run_test "Get messages" (fun () -> test_get_messages client);
266266+267267+ (* Send DM to trigger user *)
268268+ run_test "Send DM reply" (fun () ->
269269+ test_send_dm client ~recipient:trigger_user
270270+ ~content:
271271+ ("Regression test in progress! I'll send you the results shortly.\n\n"
272272+ ^ "Test run: "
273273+ ^ string_of_float (Unix.gettimeofday ())));
274274+275275+ (* Post results summary to channel *)
276276+ let summary = format_results () in
277277+ Log.app (fun m -> m "\n%s" summary);
278278+279279+ (match stream_id with
280280+ | Some _sid ->
281281+ run_test "Post results to channel" (fun () ->
282282+ let msg =
283283+ Zulip.Message.create ~type_:`Channel ~to_:[ channel ] ~topic
284284+ ~content:(format_results ())
285285+ ()
286286+ in
287287+ let resp = Zulip.Messages.send client msg in
288288+ Printf.sprintf "Posted results (message ID %d)" (Zulip.Message_response.id resp))
289289+ | None -> ());
290290+291291+ let passed = List.filter (fun r -> r.passed) !results in
292292+ let total = List.length !results in
293293+ Log.app (fun m -> m "\n========================================");
294294+ Log.app (fun m -> m "SUMMARY: %d/%d tests passed" (List.length passed) total);
295295+296296+ (* Return summary for DM *)
297297+ format_results ()
298298+299299+(** Bot handler - triggers on "regress" DM *)
300300+let make_handler ~env ~channel =
301301+ fun ~storage ~identity:_ msg ->
302302+ let content = String.lowercase_ascii (String.trim (Message.content msg)) in
303303+ let sender_email = Message.sender_email msg in
304304+305305+ (* Only respond to DMs containing "regress" *)
306306+ if Message.is_private msg && String.sub content 0 (min 7 (String.length content)) = "regress"
307307+ then (
308308+ Log.info (fun m -> m "Regression test triggered by %s" sender_email);
309309+310310+ (* Get the client from storage *)
311311+ let client = Storage.client storage in
312312+313313+ (* Run the tests *)
314314+ let summary = run_tests ~env ~client ~channel ~trigger_user:sender_email in
315315+316316+ (* Reply with results *)
317317+ Response.reply summary)
318318+ else if Message.is_private msg then
319319+ Response.reply
320320+ "Send me `regress` to trigger the Zulip API regression test suite."
321321+ else Response.silent
322322+323323+(** Main entry point *)
324324+let run_bot ~env ~channel config =
325325+ Log.app (fun m -> m "Starting Regression Test Bot");
326326+ Log.app (fun m -> m "Test channel: %s" channel);
327327+ Log.app (fun m -> m "Send a DM with 'regress' to trigger tests");
328328+ Log.app (fun m -> m "========================================\n");
329329+330330+ Random.self_init ();
331331+ Eio.Switch.run @@ fun sw ->
332332+ let handler = make_handler ~env ~channel in
333333+ Bot.run ~sw ~env ~config ~handler
334334+335335+open Cmdliner
336336+337337+let channel_arg =
338338+ let doc = "Channel name for test messages (default: Sandbox-test)" in
339339+ Arg.(
340340+ value
341341+ & opt string "Sandbox-test"
342342+ & info [ "channel" ] ~docv:"CHANNEL" ~doc)
343343+344344+let run_cmd eio_env =
345345+ let doc = "Zulip API Regression Test Bot" in
346346+ let man =
347347+ [
348348+ `S Manpage.s_description;
349349+ `P
350350+ "A bot that runs comprehensive regression tests against the Zulip API. \
351351+ Send a DM with 'regress' to trigger the test suite.";
352352+ `S "TESTS";
353353+ `P "The following API features are tested:";
354354+ `P "- User operations (get self, list users)";
355355+ `P "- Channel operations (list, get topics, get subscribers)";
356356+ `P "- Message operations (send, edit)";
357357+ `P "- Reactions (add, remove)";
358358+ `P "- Message flags (read, starred)";
359359+ `P "- Typing indicators";
360360+ `P "- Presence updates";
361361+ `P "- Alert words";
362362+ `P "- Direct messages";
363363+ `S "USAGE";
364364+ `P "1. Start the bot: dune exec regression_test -- --channel 'Sandbox-test'";
365365+ `P "2. Send a DM to the bot containing 'regress'";
366366+ `P "3. The bot will run tests and post results to the channel and DM you";
367367+ ]
368368+ in
369369+ let info = Cmd.info "regression_test" ~version:"1.0.0" ~doc ~man in
370370+ let config_term = Zulip_bot.Cmd.config_term "regression-test" eio_env in
371371+ Cmd.v info
372372+ Term.(
373373+ const (fun channel config -> run_bot ~env:eio_env ~channel config)
374374+ $ channel_arg
375375+ $ config_term)
376376+377377+let () =
378378+ Eio_main.run @@ fun env ->
379379+ exit (Cmd.eval (run_cmd env))
+10
examples/regression_test.mli
···11+(*---------------------------------------------------------------------------
22+ Copyright (c) 2025 Anil Madhavapeddy <anil@recoil.org>. All rights reserved.
33+ SPDX-License-Identifier: ISC
44+ ---------------------------------------------------------------------------*)
55+66+(** Zulip API Regression Test Bot.
77+88+ Exercises many features of the Zulip OCaml API to verify the protocol
99+ implementation works correctly. Send a DM with "regress" to trigger
1010+ the tests. *)
···11+(*---------------------------------------------------------------------------
22+ Copyright (c) 2025 Anil Madhavapeddy <anil@recoil.org>. All rights reserved.
33+ SPDX-License-Identifier: ISC
44+ ---------------------------------------------------------------------------*)
55+66+type t = { server_url : string; email : string; api_key : string }
77+88+let create ~server_url ~email ~api_key = { server_url; email; api_key }
99+1010+type zuliprc_api = {
1111+ zuliprc_email : string;
1212+ zuliprc_key : string;
1313+ zuliprc_site : string;
1414+}
1515+(** INI section record for parsing the [api] section of zuliprc *)
1616+1717+(** Codec for parsing the [api] section of zuliprc. Note: zuliprc uses "key" not
1818+ "api_key" *)
1919+let api_section_codec =
2020+ Init.Section.(
2121+ obj (fun email key site ->
2222+ { zuliprc_email = email; zuliprc_key = key; zuliprc_site = site })
2323+ |> mem "email" Init.string ~enc:(fun c -> c.zuliprc_email)
2424+ |> mem "key" Init.string ~enc:(fun c -> c.zuliprc_key)
2525+ |> mem "site" Init.string ~enc:(fun c -> c.zuliprc_site)
2626+ |> skip_unknown |> finish)
2727+2828+(** Document codec for zuliprc with [api] section *)
2929+let zuliprc_codec =
3030+ Init.Document.(
3131+ obj (fun api -> api)
3232+ |> section "api" api_section_codec ~enc:Fun.id
3333+ |> skip_unknown |> finish)
3434+3535+(** Codec for zuliprc without section headers (bare key=value pairs) *)
3636+let zuliprc_bare_codec =
3737+ Init.Document.(
3838+ obj (fun defaults -> defaults)
3939+ |> defaults api_section_codec ~enc:Fun.id
4040+ |> skip_unknown |> finish)
4141+4242+let from_zuliprc ?(path = "~/.zuliprc") () =
4343+ try
4444+ (* Expand ~ to home directory *)
4545+ let expanded_path =
4646+ if String.length path > 0 && path.[0] = '~' then
4747+ let home = try Sys.getenv "HOME" with Not_found -> "" in
4848+ home ^ String.sub path 1 (String.length path - 1)
4949+ else path
5050+ in
5151+5252+ (* Read file content *)
5353+ let content =
5454+ let ic = open_in expanded_path in
5555+ let content = really_input_string ic (in_channel_length ic) in
5656+ close_in ic;
5757+ content
5858+ in
5959+6060+ (* Parse using Init library *)
6161+ let api =
6262+ match Init_bytesrw.decode_string zuliprc_codec content with
6363+ | Ok c -> c
6464+ | Error _ -> (
6565+ (* Try bare config format (no section headers) *)
6666+ match Init_bytesrw.decode_string zuliprc_bare_codec content with
6767+ | Ok c -> c
6868+ | Error msg ->
6969+ Error.raise_with_context
7070+ (Error.make ~code:(Other "parse_error")
7171+ ~message:("Error parsing zuliprc: " ^ msg)
7272+ ())
7373+ "reading %s" path)
7474+ in
7575+7676+ (* Ensure server_url has proper protocol *)
7777+ let server_url =
7878+ if
7979+ String.starts_with ~prefix:"http://" api.zuliprc_site
8080+ || String.starts_with ~prefix:"https://" api.zuliprc_site
8181+ then api.zuliprc_site
8282+ else "https://" ^ api.zuliprc_site
8383+ in
8484+ { server_url; email = api.zuliprc_email; api_key = api.zuliprc_key }
8585+ with
8686+ | Eio.Exn.Io _ as ex -> raise ex
8787+ | Sys_error msg ->
8888+ Error.raise_with_context
8989+ (Error.make ~code:(Other "file_error")
9090+ ~message:("Cannot read zuliprc file: " ^ msg)
9191+ ())
9292+ "reading %s" path
9393+ | exn ->
9494+ Error.raise_with_context
9595+ (Error.make ~code:(Other "parse_error")
9696+ ~message:("Error parsing zuliprc: " ^ Printexc.to_string exn)
9797+ ())
9898+ "reading %s" path
9999+100100+let server_url t = t.server_url
101101+let email t = t.email
102102+let api_key t = t.api_key
103103+104104+let to_basic_auth_header t =
105105+ match Base64.encode (t.email ^ ":" ^ t.api_key) with
106106+ | Ok encoded -> "Basic " ^ encoded
107107+ | Error (`Msg msg) -> failwith ("Base64 encoding failed: " ^ msg)
108108+109109+let pp fmt t =
110110+ Format.fprintf fmt "Auth{server=%s, email=%s}" t.server_url t.email
+26
lib/zulip/auth.mli
···11+(*---------------------------------------------------------------------------
22+ Copyright (c) 2025 Anil Madhavapeddy <anil@recoil.org>. All rights reserved.
33+ SPDX-License-Identifier: ISC
44+ ---------------------------------------------------------------------------*)
55+66+(** Authentication for the Zulip API.
77+88+ This module handles authentication credentials for connecting to a Zulip
99+ server.
1010+ @raise Eio.Io with [Error.E error] on authentication/config errors *)
1111+1212+type t
1313+1414+val create : server_url:string -> email:string -> api_key:string -> t
1515+(** Create authentication credentials directly. *)
1616+1717+val from_zuliprc : ?path:string -> unit -> t
1818+(** Load credentials from a zuliprc file.
1919+ @param path Path to zuliprc file (default: ~/.zuliprc)
2020+ @raise Eio.Io on file read or parse errors *)
2121+2222+val server_url : t -> string
2323+val email : t -> string
2424+val api_key : t -> string
2525+val to_basic_auth_header : t -> string
2626+val pp : Format.formatter -> t -> unit
+193
lib/zulip/channel.ml
···11+(*---------------------------------------------------------------------------
22+ Copyright (c) 2025 Anil Madhavapeddy <anil@recoil.org>. All rights reserved.
33+ SPDX-License-Identifier: ISC
44+ ---------------------------------------------------------------------------*)
55+66+type t = {
77+ name : string;
88+ stream_id : int option;
99+ description : string;
1010+ invite_only : bool;
1111+ is_web_public : bool;
1212+ history_public_to_subscribers : bool;
1313+ is_default : bool;
1414+ message_retention_days : int option option;
1515+ first_message_id : int option;
1616+ date_created : float option;
1717+ stream_post_policy : int;
1818+}
1919+2020+let create ~name ?stream_id ?(description = "") ?(invite_only = false)
2121+ ?(is_web_public = false) ?(history_public_to_subscribers = true)
2222+ ?(is_default = false) ?message_retention_days ?first_message_id
2323+ ?date_created ?(stream_post_policy = 1) () =
2424+ {
2525+ name;
2626+ stream_id;
2727+ description;
2828+ invite_only;
2929+ is_web_public;
3030+ history_public_to_subscribers;
3131+ is_default;
3232+ message_retention_days;
3333+ first_message_id;
3434+ date_created;
3535+ stream_post_policy;
3636+ }
3737+3838+let name t = t.name
3939+let stream_id t = t.stream_id
4040+let description t = t.description
4141+let invite_only t = t.invite_only
4242+let is_web_public t = t.is_web_public
4343+let history_public_to_subscribers t = t.history_public_to_subscribers
4444+let is_default t = t.is_default
4545+let message_retention_days t = t.message_retention_days
4646+let first_message_id t = t.first_message_id
4747+let date_created t = t.date_created
4848+let stream_post_policy t = t.stream_post_policy
4949+5050+let pp fmt t =
5151+ Format.fprintf fmt "Channel{name=%s, stream_id=%s, description=%s}" t.name
5252+ (match t.stream_id with Some id -> string_of_int id | None -> "none")
5353+ t.description
5454+5555+module Subscription = struct
5656+ type channel = t
5757+5858+ type t = {
5959+ channel : channel;
6060+ color : string option;
6161+ is_muted : bool;
6262+ pin_to_top : bool;
6363+ desktop_notifications : bool option;
6464+ audible_notifications : bool option;
6565+ push_notifications : bool option;
6666+ email_notifications : bool option;
6767+ wildcard_mentions_notify : bool option;
6868+ }
6969+7070+ let channel t = t.channel
7171+ let color t = t.color
7272+ let is_muted t = t.is_muted
7373+ let pin_to_top t = t.pin_to_top
7474+ let desktop_notifications t = t.desktop_notifications
7575+ let audible_notifications t = t.audible_notifications
7676+ let push_notifications t = t.push_notifications
7777+ let email_notifications t = t.email_notifications
7878+ let wildcard_mentions_notify t = t.wildcard_mentions_notify
7979+8080+ let jsont =
8181+ let kind = "Subscription" in
8282+ let doc = "A Zulip channel subscription" in
8383+ let make name stream_id description invite_only is_web_public
8484+ history_public_to_subscribers is_default message_retention_days
8585+ first_message_id date_created stream_post_policy color is_muted
8686+ pin_to_top desktop_notifications audible_notifications
8787+ push_notifications email_notifications wildcard_mentions_notify =
8888+ let channel =
8989+ {
9090+ name;
9191+ stream_id;
9292+ description;
9393+ invite_only;
9494+ is_web_public;
9595+ history_public_to_subscribers;
9696+ is_default;
9797+ message_retention_days;
9898+ first_message_id;
9999+ date_created;
100100+ stream_post_policy;
101101+ }
102102+ in
103103+ {
104104+ channel;
105105+ color;
106106+ is_muted;
107107+ pin_to_top;
108108+ desktop_notifications;
109109+ audible_notifications;
110110+ push_notifications;
111111+ email_notifications;
112112+ wildcard_mentions_notify;
113113+ }
114114+ in
115115+ Jsont.Object.map ~kind ~doc make
116116+ |> Jsont.Object.mem "name" Jsont.string ~enc:(fun t -> t.channel.name)
117117+ |> Jsont.Object.opt_mem "stream_id" Jsont.int ~enc:(fun t ->
118118+ t.channel.stream_id)
119119+ |> Jsont.Object.mem "description" Jsont.string ~dec_absent:"" ~enc:(fun t ->
120120+ t.channel.description)
121121+ |> Jsont.Object.mem "invite_only" Jsont.bool ~dec_absent:false
122122+ ~enc:(fun t -> t.channel.invite_only)
123123+ |> Jsont.Object.mem "is_web_public" Jsont.bool ~dec_absent:false
124124+ ~enc:(fun t -> t.channel.is_web_public)
125125+ |> Jsont.Object.mem "history_public_to_subscribers" Jsont.bool
126126+ ~dec_absent:true ~enc:(fun t ->
127127+ t.channel.history_public_to_subscribers)
128128+ |> Jsont.Object.mem "is_default" Jsont.bool ~dec_absent:false ~enc:(fun t ->
129129+ t.channel.is_default)
130130+ |> Jsont.Object.opt_mem "message_retention_days" (Jsont.option Jsont.int)
131131+ ~enc:(fun t -> t.channel.message_retention_days)
132132+ |> Jsont.Object.opt_mem "first_message_id" Jsont.int ~enc:(fun t ->
133133+ t.channel.first_message_id)
134134+ |> Jsont.Object.opt_mem "date_created" Jsont.number ~enc:(fun t ->
135135+ t.channel.date_created)
136136+ |> Jsont.Object.mem "stream_post_policy" Jsont.int ~dec_absent:1
137137+ ~enc:(fun t -> t.channel.stream_post_policy)
138138+ |> Jsont.Object.opt_mem "color" Jsont.string ~enc:color
139139+ |> Jsont.Object.mem "is_muted" Jsont.bool ~dec_absent:false ~enc:is_muted
140140+ |> Jsont.Object.mem "pin_to_top" Jsont.bool ~dec_absent:false
141141+ ~enc:pin_to_top
142142+ |> Jsont.Object.mem "desktop_notifications" (Jsont.option Jsont.bool)
143143+ ~dec_absent:None ~enc:desktop_notifications
144144+ |> Jsont.Object.mem "audible_notifications" (Jsont.option Jsont.bool)
145145+ ~dec_absent:None ~enc:audible_notifications
146146+ |> Jsont.Object.mem "push_notifications" (Jsont.option Jsont.bool)
147147+ ~dec_absent:None ~enc:push_notifications
148148+ |> Jsont.Object.mem "email_notifications" (Jsont.option Jsont.bool)
149149+ ~dec_absent:None ~enc:email_notifications
150150+ |> Jsont.Object.mem "wildcard_mentions_notify" (Jsont.option Jsont.bool)
151151+ ~dec_absent:None ~enc:wildcard_mentions_notify
152152+ |> Jsont.Object.finish
153153+end
154154+155155+(* Jsont codec for channel *)
156156+let jsont =
157157+ let kind = "Channel" in
158158+ let doc = "A Zulip channel (stream)" in
159159+ let make name stream_id description invite_only is_web_public
160160+ history_public_to_subscribers is_default message_retention_days
161161+ first_message_id date_created stream_post_policy =
162162+ {
163163+ name;
164164+ stream_id;
165165+ description;
166166+ invite_only;
167167+ is_web_public;
168168+ history_public_to_subscribers;
169169+ is_default;
170170+ message_retention_days;
171171+ first_message_id;
172172+ date_created;
173173+ stream_post_policy;
174174+ }
175175+ in
176176+ Jsont.Object.map ~kind ~doc make
177177+ |> Jsont.Object.mem "name" Jsont.string ~enc:name
178178+ |> Jsont.Object.opt_mem "stream_id" Jsont.int ~enc:stream_id
179179+ |> Jsont.Object.mem "description" Jsont.string ~dec_absent:"" ~enc:description
180180+ |> Jsont.Object.mem "invite_only" Jsont.bool ~dec_absent:false
181181+ ~enc:invite_only
182182+ |> Jsont.Object.mem "is_web_public" Jsont.bool ~dec_absent:false
183183+ ~enc:is_web_public
184184+ |> Jsont.Object.mem "history_public_to_subscribers" Jsont.bool
185185+ ~dec_absent:true ~enc:history_public_to_subscribers
186186+ |> Jsont.Object.mem "is_default" Jsont.bool ~dec_absent:false ~enc:is_default
187187+ |> Jsont.Object.opt_mem "message_retention_days" (Jsont.option Jsont.int)
188188+ ~enc:message_retention_days
189189+ |> Jsont.Object.opt_mem "first_message_id" Jsont.int ~enc:first_message_id
190190+ |> Jsont.Object.opt_mem "date_created" Jsont.number ~enc:date_created
191191+ |> Jsont.Object.mem "stream_post_policy" Jsont.int ~dec_absent:1
192192+ ~enc:stream_post_policy
193193+ |> Jsont.Object.finish
+137
lib/zulip/channel.mli
···11+(*---------------------------------------------------------------------------
22+ Copyright (c) 2025 Anil Madhavapeddy <anil@recoil.org>. All rights reserved.
33+ SPDX-License-Identifier: ISC
44+ ---------------------------------------------------------------------------*)
55+66+(** Zulip channels (streams).
77+88+ This module represents channel/stream information from the Zulip API. Use
99+ {!jsont} with Bytesrw-eio for wire serialization.
1010+1111+ Note: Zulip uses "stream" in the API but "channel" in the UI. This library
1212+ uses "channel" to match the current Zulip terminology. *)
1313+1414+(** {1 Channel Type} *)
1515+1616+type t
1717+(** A Zulip channel/stream. *)
1818+1919+(** {1 Construction} *)
2020+2121+val create :
2222+ name:string ->
2323+ ?stream_id:int ->
2424+ ?description:string ->
2525+ ?invite_only:bool ->
2626+ ?is_web_public:bool ->
2727+ ?history_public_to_subscribers:bool ->
2828+ ?is_default:bool ->
2929+ ?message_retention_days:int option ->
3030+ ?first_message_id:int ->
3131+ ?date_created:float ->
3232+ ?stream_post_policy:int ->
3333+ unit ->
3434+ t
3535+(** Create a channel record.
3636+3737+ @param name Channel name (required)
3838+ @param stream_id Server-assigned channel ID
3939+ @param description Channel description
4040+ @param invite_only Whether the channel is private
4141+ @param is_web_public Whether the channel is web-public
4242+ @param history_public_to_subscribers
4343+ Whether history is visible to new subscribers
4444+ @param is_default Whether this is a default channel for new users
4545+ @param message_retention_days Message retention policy (None = forever)
4646+ @param first_message_id ID of the first message in the channel
4747+ @param date_created Unix timestamp of creation
4848+ @param stream_post_policy
4949+ Who can post (1=any, 2=admins, 3=full members, 4=moderators) *)
5050+5151+(** {1 Accessors} *)
5252+5353+val name : t -> string
5454+(** Channel name. *)
5555+5656+val stream_id : t -> int option
5757+(** Server-assigned channel ID. *)
5858+5959+val description : t -> string
6060+(** Channel description. *)
6161+6262+val invite_only : t -> bool
6363+(** Whether the channel is private (invite-only). *)
6464+6565+val is_web_public : t -> bool
6666+(** Whether the channel is web-public (visible without authentication). *)
6767+6868+val history_public_to_subscribers : t -> bool
6969+(** Whether new subscribers can see message history. *)
7070+7171+val is_default : t -> bool
7272+(** Whether new users are automatically subscribed. *)
7373+7474+val message_retention_days : t -> int option option
7575+(** Message retention policy. [None] if not set (use organization default),
7676+ [Some None] for unlimited retention, [Some (Some n)] for n days. *)
7777+7878+val first_message_id : t -> int option
7979+(** ID of the first message in the channel. *)
8080+8181+val date_created : t -> float option
8282+(** Unix timestamp when the channel was created. *)
8383+8484+val stream_post_policy : t -> int
8585+(** Who can post to the channel. 1 = any member, 2 = admins only, 3 = full
8686+ members, 4 = moderators only. *)
8787+8888+(** {1 Subscription Info}
8989+9090+ When retrieved via subscriptions API, channels include additional
9191+ subscription-specific fields. *)
9292+9393+module Subscription : sig
9494+ type channel := t
9595+9696+ type t
9797+ (** A channel subscription with user-specific settings. *)
9898+9999+ val channel : t -> channel
100100+ (** The underlying channel. *)
101101+102102+ val color : t -> string option
103103+ (** User's color preference for the channel (hex string). *)
104104+105105+ val is_muted : t -> bool
106106+ (** Whether the user has muted this channel. *)
107107+108108+ val pin_to_top : t -> bool
109109+ (** Whether the channel is pinned. *)
110110+111111+ val desktop_notifications : t -> bool option
112112+ (** Desktop notification setting (None = use global default). *)
113113+114114+ val audible_notifications : t -> bool option
115115+ (** Sound notification setting. *)
116116+117117+ val push_notifications : t -> bool option
118118+ (** Push notification setting. *)
119119+120120+ val email_notifications : t -> bool option
121121+ (** Email notification setting. *)
122122+123123+ val wildcard_mentions_notify : t -> bool option
124124+ (** Whether to notify on @all/@everyone mentions. *)
125125+126126+ val jsont : t Jsont.t
127127+ (** Jsont codec for subscription. *)
128128+end
129129+130130+(** {1 JSON Codec} *)
131131+132132+val jsont : t Jsont.t
133133+(** Jsont codec for the channel type. *)
134134+135135+(** {1 Pretty Printing} *)
136136+137137+val pp : Format.formatter -> t -> unit
+478
lib/zulip/channels.ml
···11+(*---------------------------------------------------------------------------
22+ Copyright (c) 2025 Anil Madhavapeddy <anil@recoil.org>. All rights reserved.
33+ SPDX-License-Identifier: ISC
44+ ---------------------------------------------------------------------------*)
55+66+let streams_codec =
77+ Jsont.Object.(
88+ map ~kind:"StreamsResponse" Fun.id
99+ |> mem "streams" (Jsont.list Channel.jsont) ~enc:Fun.id
1010+ |> finish)
1111+1212+let list client =
1313+ let json = Client.request client ~method_:`GET ~path:"/api/v1/streams" () in
1414+ Error.decode_or_raise streams_codec json "parsing channels list"
1515+1616+let list_all client ?include_public ?include_web_public ?include_subscribed
1717+ ?include_all_active ?include_default ?include_owner_subscribed () =
1818+ let params =
1919+ List.filter_map Fun.id
2020+ [
2121+ Option.map
2222+ (fun v -> ("include_public", string_of_bool v))
2323+ include_public;
2424+ Option.map
2525+ (fun v -> ("include_web_public", string_of_bool v))
2626+ include_web_public;
2727+ Option.map
2828+ (fun v -> ("include_subscribed", string_of_bool v))
2929+ include_subscribed;
3030+ Option.map
3131+ (fun v -> ("include_all_active", string_of_bool v))
3232+ include_all_active;
3333+ Option.map
3434+ (fun v -> ("include_default", string_of_bool v))
3535+ include_default;
3636+ Option.map
3737+ (fun v -> ("include_owner_subscribed", string_of_bool v))
3838+ include_owner_subscribed;
3939+ ]
4040+ in
4141+ let json =
4242+ Client.request client ~method_:`GET ~path:"/api/v1/streams" ~params ()
4343+ in
4444+ Error.decode_or_raise streams_codec json "parsing channels list"
4545+4646+let get_id client ~name =
4747+ let encoded_name = Uri.pct_encode name in
4848+ let response_codec =
4949+ Jsont.Object.(
5050+ map ~kind:"StreamIdResponse" Fun.id
5151+ |> mem "stream_id" Jsont.int ~enc:Fun.id
5252+ |> finish)
5353+ in
5454+ let json =
5555+ Client.request client ~method_:`GET
5656+ ~path:("/api/v1/get_stream_id?stream=" ^ encoded_name)
5757+ ()
5858+ in
5959+ Error.decode_or_raise response_codec json
6060+ (Printf.sprintf "getting stream id for %s" name)
6161+6262+let get_by_id client ~stream_id =
6363+ let response_codec =
6464+ Jsont.Object.(
6565+ map ~kind:"StreamResponse" Fun.id
6666+ |> mem "stream" Channel.jsont ~enc:Fun.id
6767+ |> finish)
6868+ in
6969+ let json =
7070+ Client.request client ~method_:`GET
7171+ ~path:("/api/v1/streams/" ^ string_of_int stream_id)
7272+ ()
7373+ in
7474+ Error.decode_or_raise response_codec json
7575+ (Printf.sprintf "getting stream %d" stream_id)
7676+7777+type create_options = {
7878+ name : string;
7979+ description : string option;
8080+ invite_only : bool option;
8181+ is_web_public : bool option;
8282+ history_public_to_subscribers : bool option;
8383+ message_retention_days : int option option;
8484+ can_remove_subscribers_group : int option;
8585+}
8686+8787+let create client opts =
8888+ let make_string s = Jsont.String (s, Jsont.Meta.none) in
8989+ let subs =
9090+ Jsont.Array
9191+ ( [
9292+ Jsont.Object
9393+ ( List.filter_map Fun.id
9494+ [
9595+ Some (("name", Jsont.Meta.none), make_string opts.name);
9696+ Option.map
9797+ (fun d -> (("description", Jsont.Meta.none), make_string d))
9898+ opts.description;
9999+ ],
100100+ Jsont.Meta.none );
101101+ ],
102102+ Jsont.Meta.none )
103103+ in
104104+ let params =
105105+ [ ("subscriptions", Encode.to_json_string Jsont.json subs) ]
106106+ @ List.filter_map Fun.id
107107+ [
108108+ Option.map
109109+ (fun v -> ("invite_only", string_of_bool v))
110110+ opts.invite_only;
111111+ Option.map
112112+ (fun v -> ("is_web_public", string_of_bool v))
113113+ opts.is_web_public;
114114+ Option.map
115115+ (fun v -> ("history_public_to_subscribers", string_of_bool v))
116116+ opts.history_public_to_subscribers;
117117+ ]
118118+ in
119119+ let response_codec =
120120+ Jsont.Object.(
121121+ map ~kind:"CreateResponse" (fun created -> created)
122122+ |> mem "subscribed" Jsont.json ~enc:(fun _ ->
123123+ Jsont.Object ([], Jsont.Meta.none))
124124+ |> finish)
125125+ in
126126+ let json =
127127+ Client.request client ~method_:`POST ~path:"/api/v1/users/me/subscriptions"
128128+ ~params ()
129129+ in
130130+ ignore (Encode.from_json response_codec json);
131131+ (* Return the stream_id - we need to look it up *)
132132+ get_id client ~name:opts.name
133133+134134+let create_simple client ~name ?description ?invite_only () =
135135+ create client
136136+ {
137137+ name;
138138+ description;
139139+ invite_only;
140140+ is_web_public = None;
141141+ history_public_to_subscribers = None;
142142+ message_retention_days = None;
143143+ can_remove_subscribers_group = None;
144144+ }
145145+146146+let update client ~stream_id ?description ?new_name ?is_private ?is_web_public
147147+ ?history_public_to_subscribers ?message_retention_days ?stream_post_policy
148148+ () =
149149+ let params =
150150+ List.filter_map Fun.id
151151+ [
152152+ Option.map (fun v -> ("description", v)) description;
153153+ Option.map (fun v -> ("new_name", v)) new_name;
154154+ Option.map (fun v -> ("is_private", string_of_bool v)) is_private;
155155+ Option.map (fun v -> ("is_web_public", string_of_bool v)) is_web_public;
156156+ Option.map
157157+ (fun v -> ("history_public_to_subscribers", string_of_bool v))
158158+ history_public_to_subscribers;
159159+ Option.map
160160+ (fun v ->
161161+ ( "message_retention_days",
162162+ match v with None -> "unlimited" | Some d -> string_of_int d ))
163163+ message_retention_days;
164164+ Option.map
165165+ (fun v -> ("stream_post_policy", string_of_int v))
166166+ stream_post_policy;
167167+ ]
168168+ in
169169+ let _response =
170170+ Client.request client ~method_:`PATCH
171171+ ~path:("/api/v1/streams/" ^ string_of_int stream_id)
172172+ ~params ()
173173+ in
174174+ ()
175175+176176+let delete client ~stream_id =
177177+ let _response =
178178+ Client.request client ~method_:`DELETE
179179+ ~path:("/api/v1/streams/" ^ string_of_int stream_id)
180180+ ()
181181+ in
182182+ ()
183183+184184+let archive = delete
185185+186186+let add_default client ~stream_id =
187187+ let params = [ ("stream_id", string_of_int stream_id) ] in
188188+ let _response =
189189+ Client.request client ~method_:`POST ~path:"/api/v1/default_streams" ~params
190190+ ()
191191+ in
192192+ ()
193193+194194+let remove_default client ~stream_id =
195195+ let params = [ ("stream_id", string_of_int stream_id) ] in
196196+ let _response =
197197+ Client.request client ~method_:`DELETE ~path:"/api/v1/default_streams"
198198+ ~params ()
199199+ in
200200+ ()
201201+202202+type subscription_request = {
203203+ name : string;
204204+ color : string option;
205205+ description : string option;
206206+}
207207+208208+let subscribe client ~subscriptions ?principals ?authorization_errors_fatal
209209+ ?announce ?invite_only ?history_public_to_subscribers () =
210210+ let make_string s = Jsont.String (s, Jsont.Meta.none) in
211211+ let subs_json =
212212+ Jsont.Array
213213+ ( List.map
214214+ (fun s ->
215215+ Jsont.Object
216216+ ( List.filter_map Fun.id
217217+ [
218218+ Some (("name", Jsont.Meta.none), make_string s.name);
219219+ Option.map
220220+ (fun c -> (("color", Jsont.Meta.none), make_string c))
221221+ s.color;
222222+ Option.map
223223+ (fun d ->
224224+ (("description", Jsont.Meta.none), make_string d))
225225+ s.description;
226226+ ],
227227+ Jsont.Meta.none ))
228228+ subscriptions,
229229+ Jsont.Meta.none )
230230+ in
231231+ let params =
232232+ [ ("subscriptions", Encode.to_json_string Jsont.json subs_json) ]
233233+ @ List.filter_map Fun.id
234234+ [
235235+ Option.map
236236+ (fun p ->
237237+ ( "principals",
238238+ match p with
239239+ | `Emails emails ->
240240+ Encode.to_json_string (Jsont.list Jsont.string) emails
241241+ | `User_ids ids ->
242242+ Encode.to_json_string (Jsont.list Jsont.int) ids ))
243243+ principals;
244244+ Option.map
245245+ (fun v -> ("authorization_errors_fatal", string_of_bool v))
246246+ authorization_errors_fatal;
247247+ Option.map (fun v -> ("announce", string_of_bool v)) announce;
248248+ Option.map (fun v -> ("invite_only", string_of_bool v)) invite_only;
249249+ Option.map
250250+ (fun v -> ("history_public_to_subscribers", string_of_bool v))
251251+ history_public_to_subscribers;
252252+ ]
253253+ in
254254+ Client.request client ~method_:`POST ~path:"/api/v1/users/me/subscriptions"
255255+ ~params ()
256256+257257+let subscribe_simple client ~channels =
258258+ let subscriptions =
259259+ List.map (fun name -> { name; color = None; description = None }) channels
260260+ in
261261+ let _ = subscribe client ~subscriptions () in
262262+ ()
263263+264264+let unsubscribe client ~subscriptions ?principals () =
265265+ let params =
266266+ [
267267+ ( "subscriptions",
268268+ Encode.to_json_string (Jsont.list Jsont.string) subscriptions );
269269+ ]
270270+ @ List.filter_map Fun.id
271271+ [
272272+ Option.map
273273+ (fun p ->
274274+ ( "principals",
275275+ match p with
276276+ | `Emails emails ->
277277+ Encode.to_json_string (Jsont.list Jsont.string) emails
278278+ | `User_ids ids ->
279279+ Encode.to_json_string (Jsont.list Jsont.int) ids ))
280280+ principals;
281281+ ]
282282+ in
283283+ Client.request client ~method_:`DELETE ~path:"/api/v1/users/me/subscriptions"
284284+ ~params ()
285285+286286+let unsubscribe_simple client ~channels =
287287+ let _ = unsubscribe client ~subscriptions:channels () in
288288+ ()
289289+290290+let get_subscriptions client =
291291+ let response_codec =
292292+ Jsont.Object.(
293293+ map ~kind:"SubscriptionsResponse" Fun.id
294294+ |> mem "subscriptions" (Jsont.list Channel.Subscription.jsont) ~enc:Fun.id
295295+ |> finish)
296296+ in
297297+ let json =
298298+ Client.request client ~method_:`GET ~path:"/api/v1/users/me/subscriptions"
299299+ ()
300300+ in
301301+ Error.decode_or_raise response_codec json "parsing subscriptions"
302302+303303+let get_subscription_status client ~user_id ~stream_id =
304304+ let response_codec =
305305+ Jsont.Object.(
306306+ map ~kind:"SubscriptionStatusResponse" Fun.id
307307+ |> mem "is_subscribed" Jsont.bool ~enc:Fun.id
308308+ |> finish)
309309+ in
310310+ let json =
311311+ Client.request client ~method_:`GET
312312+ ~path:
313313+ ("/api/v1/users/" ^ string_of_int user_id ^ "/subscriptions/"
314314+ ^ string_of_int stream_id)
315315+ ()
316316+ in
317317+ Error.decode_or_raise response_codec json "checking subscription status"
318318+319319+let update_subscription_settings client ~stream_id ?color ?is_muted ?pin_to_top
320320+ ?desktop_notifications ?audible_notifications ?push_notifications
321321+ ?email_notifications ?wildcard_mentions_notify () =
322322+ let data =
323323+ [
324324+ {|[{"stream_id":|} ^ string_of_int stream_id
325325+ ^ (List.filter_map Fun.id
326326+ [
327327+ Option.map
328328+ (fun v -> Printf.sprintf {|,"property":"color","value":"%s"|} v)
329329+ color;
330330+ Option.map
331331+ (fun v -> Printf.sprintf {|,"property":"is_muted","value":%b|} v)
332332+ is_muted;
333333+ Option.map
334334+ (fun v ->
335335+ Printf.sprintf {|,"property":"pin_to_top","value":%b|} v)
336336+ pin_to_top;
337337+ Option.map
338338+ (fun v ->
339339+ Printf.sprintf
340340+ {|,"property":"desktop_notifications","value":%b|} v)
341341+ desktop_notifications;
342342+ Option.map
343343+ (fun v ->
344344+ Printf.sprintf
345345+ {|,"property":"audible_notifications","value":%b|} v)
346346+ audible_notifications;
347347+ Option.map
348348+ (fun v ->
349349+ Printf.sprintf {|,"property":"push_notifications","value":%b|}
350350+ v)
351351+ push_notifications;
352352+ Option.map
353353+ (fun v ->
354354+ Printf.sprintf {|,"property":"email_notifications","value":%b|}
355355+ v)
356356+ email_notifications;
357357+ Option.map
358358+ (fun v ->
359359+ Printf.sprintf
360360+ {|,"property":"wildcard_mentions_notify","value":%b|} v)
361361+ wildcard_mentions_notify;
362362+ ]
363363+ |> String.concat "")
364364+ ^ "}]";
365365+ ]
366366+ in
367367+ let params = [ ("subscription_data", String.concat "" data) ] in
368368+ let _response =
369369+ Client.request client ~method_:`POST
370370+ ~path:"/api/v1/users/me/subscriptions/properties" ~params ()
371371+ in
372372+ ()
373373+374374+module Topic = struct
375375+ type t = { name : string; max_id : int }
376376+377377+ let name t = t.name
378378+ let max_id t = t.max_id
379379+380380+ let jsont =
381381+ Jsont.Object.(
382382+ map ~kind:"Topic" (fun name max_id -> { name; max_id })
383383+ |> mem "name" Jsont.string ~enc:name
384384+ |> mem "max_id" Jsont.int ~enc:max_id
385385+ |> finish)
386386+end
387387+388388+let get_topics client ~stream_id =
389389+ let response_codec =
390390+ Jsont.Object.(
391391+ map ~kind:"TopicsResponse" Fun.id
392392+ |> mem "topics" (Jsont.list Topic.jsont) ~enc:Fun.id
393393+ |> finish)
394394+ in
395395+ let json =
396396+ Client.request client ~method_:`GET
397397+ ~path:("/api/v1/users/me/" ^ string_of_int stream_id ^ "/topics")
398398+ ()
399399+ in
400400+ Error.decode_or_raise response_codec json
401401+ (Printf.sprintf "getting topics for stream %d" stream_id)
402402+403403+let delete_topic client ~stream_id ~topic =
404404+ let params = [ ("topic_name", topic) ] in
405405+ let _response =
406406+ Client.request client ~method_:`POST
407407+ ~path:("/api/v1/streams/" ^ string_of_int stream_id ^ "/delete_topic")
408408+ ~params ()
409409+ in
410410+ ()
411411+412412+type mute_op = Mute | Unmute
413413+414414+let set_topic_mute client ~stream_id ~topic ~op =
415415+ let params =
416416+ [
417417+ ("stream_id", string_of_int stream_id);
418418+ ("topic", topic);
419419+ ("op", match op with Mute -> "add" | Unmute -> "remove");
420420+ ]
421421+ in
422422+ let _response =
423423+ Client.request client ~method_:`PATCH
424424+ ~path:"/api/v1/users/me/subscriptions/muted_topics" ~params ()
425425+ in
426426+ ()
427427+428428+let get_muted_topics client =
429429+ let response_codec =
430430+ Jsont.Object.(
431431+ map ~kind:"MutedTopicsResponse" Fun.id
432432+ |> mem "muted_topics"
433433+ (Jsont.list
434434+ Jsont.Object.(
435435+ map ~kind:"MutedTopic" (fun stream_id topic _ts ->
436436+ (stream_id, topic))
437437+ |> mem "stream_id" Jsont.int ~enc:fst
438438+ |> mem "topic_name" Jsont.string ~enc:snd
439439+ |> mem "date_muted" Jsont.int ~dec_absent:0 ~enc:(Fun.const 0)
440440+ |> finish))
441441+ ~enc:Fun.id
442442+ |> finish)
443443+ in
444444+ let json =
445445+ Client.request client ~method_:`GET
446446+ ~path:"/api/v1/users/me/subscriptions/muted_topics" ()
447447+ in
448448+ Error.decode_or_raise response_codec json "getting muted topics"
449449+450450+let get_subscribers client ~stream_id =
451451+ let response_codec =
452452+ Jsont.Object.(
453453+ map ~kind:"SubscribersResponse" Fun.id
454454+ |> mem "subscribers" (Jsont.list Jsont.int) ~enc:Fun.id
455455+ |> finish)
456456+ in
457457+ let json =
458458+ Client.request client ~method_:`GET
459459+ ~path:("/api/v1/streams/" ^ string_of_int stream_id ^ "/members")
460460+ ()
461461+ in
462462+ Error.decode_or_raise response_codec json
463463+ (Printf.sprintf "getting subscribers for stream %d" stream_id)
464464+465465+let get_email_address client ~stream_id =
466466+ let response_codec =
467467+ Jsont.Object.(
468468+ map ~kind:"EmailAddressResponse" Fun.id
469469+ |> mem "email" Jsont.string ~enc:Fun.id
470470+ |> finish)
471471+ in
472472+ let json =
473473+ Client.request client ~method_:`GET
474474+ ~path:("/api/v1/streams/" ^ string_of_int stream_id ^ "/email_address")
475475+ ()
476476+ in
477477+ Error.decode_or_raise response_codec json
478478+ (Printf.sprintf "getting email address for stream %d" stream_id)
+238
lib/zulip/channels.mli
···11+(*---------------------------------------------------------------------------
22+ Copyright (c) 2025 Anil Madhavapeddy <anil@recoil.org>. All rights reserved.
33+ SPDX-License-Identifier: ISC
44+ ---------------------------------------------------------------------------*)
55+66+(** Channel (stream) operations for the Zulip API.
77+88+ All functions raise [Eio.Io] with [Error.E error] on failure. Context is
99+ automatically added indicating the operation being performed. *)
1010+1111+(** {1 Listing Channels} *)
1212+1313+val list : Client.t -> Channel.t list
1414+(** List all channels in the organization.
1515+ @raise Eio.Io on failure *)
1616+1717+val list_all :
1818+ Client.t ->
1919+ ?include_public:bool ->
2020+ ?include_web_public:bool ->
2121+ ?include_subscribed:bool ->
2222+ ?include_all_active:bool ->
2323+ ?include_default:bool ->
2424+ ?include_owner_subscribed:bool ->
2525+ unit ->
2626+ Channel.t list
2727+(** List channels with filtering options.
2828+2929+ @param include_public Include public channels (default: true)
3030+ @param include_web_public Include web-public channels
3131+ @param include_subscribed Include subscribed channels
3232+ @param include_all_active Include all active channels (admin only)
3333+ @param include_default Include default channels
3434+ @param include_owner_subscribed Include channels the owner is subscribed to
3535+ @raise Eio.Io on failure *)
3636+3737+(** {1 Channel Lookup} *)
3838+3939+val get_id : Client.t -> name:string -> int
4040+(** Get the stream ID for a channel by name.
4141+ @raise Eio.Io if the channel doesn't exist or on failure *)
4242+4343+val get_by_id : Client.t -> stream_id:int -> Channel.t
4444+(** Get channel information by ID.
4545+ @raise Eio.Io on failure *)
4646+4747+(** {1 Creating Channels} *)
4848+4949+type create_options = {
5050+ name : string; (** Channel name (required) *)
5151+ description : string option; (** Channel description *)
5252+ invite_only : bool option; (** Whether the channel is private *)
5353+ is_web_public : bool option; (** Whether the channel is web-public *)
5454+ history_public_to_subscribers : bool option;
5555+ (** Whether history is visible to new subscribers *)
5656+ message_retention_days : int option option;
5757+ (** Message retention (None = default, Some None = forever) *)
5858+ can_remove_subscribers_group : int option;
5959+ (** User group that can remove subscribers *)
6060+}
6161+(** Options for creating a channel. *)
6262+6363+val create : Client.t -> create_options -> int
6464+(** Create a new channel.
6565+ @return The stream_id of the created channel
6666+ @raise Eio.Io on failure *)
6767+6868+val create_simple :
6969+ Client.t ->
7070+ name:string ->
7171+ ?description:string ->
7272+ ?invite_only:bool ->
7373+ unit ->
7474+ int
7575+(** Create a new channel with common options.
7676+ @return The stream_id of the created channel
7777+ @raise Eio.Io on failure *)
7878+7979+(** {1 Updating Channels} *)
8080+8181+val update :
8282+ Client.t ->
8383+ stream_id:int ->
8484+ ?description:string ->
8585+ ?new_name:string ->
8686+ ?is_private:bool ->
8787+ ?is_web_public:bool ->
8888+ ?history_public_to_subscribers:bool ->
8989+ ?message_retention_days:int option ->
9090+ ?stream_post_policy:int ->
9191+ unit ->
9292+ unit
9393+(** Update channel properties.
9494+ @raise Eio.Io on failure *)
9595+9696+(** {1 Deleting Channels} *)
9797+9898+val delete : Client.t -> stream_id:int -> unit
9999+(** Delete a channel by ID.
100100+ @raise Eio.Io on failure *)
101101+102102+val archive : Client.t -> stream_id:int -> unit
103103+(** Archive a channel (soft delete).
104104+ @raise Eio.Io on failure *)
105105+106106+(** {1 Default Channels} *)
107107+108108+val add_default : Client.t -> stream_id:int -> unit
109109+(** Add a channel to the list of default channels for new users.
110110+ @raise Eio.Io on failure *)
111111+112112+val remove_default : Client.t -> stream_id:int -> unit
113113+(** Remove a channel from the list of default channels.
114114+ @raise Eio.Io on failure *)
115115+116116+(** {1 Subscriptions} *)
117117+118118+type subscription_request = {
119119+ name : string; (** Channel name *)
120120+ color : string option; (** Color preference (hex string) *)
121121+ description : string option; (** Description (for new channels) *)
122122+}
123123+(** Subscription request for a single channel. *)
124124+125125+val subscribe :
126126+ Client.t ->
127127+ subscriptions:subscription_request list ->
128128+ ?principals:[ `Emails of string list | `User_ids of int list ] ->
129129+ ?authorization_errors_fatal:bool ->
130130+ ?announce:bool ->
131131+ ?invite_only:bool ->
132132+ ?history_public_to_subscribers:bool ->
133133+ unit ->
134134+ Jsont.json
135135+(** Subscribe users to channels.
136136+137137+ @param subscriptions List of channels to subscribe to
138138+ @param principals Users to subscribe (default: current user)
139139+ @param authorization_errors_fatal Whether to abort on permission errors
140140+ @param announce Whether to announce new subscriptions
141141+ @param invite_only For new channels: whether they should be private
142142+ @param history_public_to_subscribers For new channels: history visibility
143143+ @return
144144+ JSON with "subscribed", "already_subscribed", and "unauthorized" fields
145145+ @raise Eio.Io on failure *)
146146+147147+val subscribe_simple : Client.t -> channels:string list -> unit
148148+(** Subscribe the current user to channels by name.
149149+ @raise Eio.Io on failure *)
150150+151151+val unsubscribe :
152152+ Client.t ->
153153+ subscriptions:string list ->
154154+ ?principals:[ `Emails of string list | `User_ids of int list ] ->
155155+ unit ->
156156+ Jsont.json
157157+(** Unsubscribe users from channels.
158158+159159+ @param subscriptions List of channel names to unsubscribe from
160160+ @param principals Users to unsubscribe (default: current user)
161161+ @return JSON with "removed" and "not_removed" fields
162162+ @raise Eio.Io on failure *)
163163+164164+val unsubscribe_simple : Client.t -> channels:string list -> unit
165165+(** Unsubscribe the current user from channels by name.
166166+ @raise Eio.Io on failure *)
167167+168168+val get_subscriptions : Client.t -> Channel.Subscription.t list
169169+(** Get the current user's channel subscriptions.
170170+ @raise Eio.Io on failure *)
171171+172172+val get_subscription_status : Client.t -> user_id:int -> stream_id:int -> bool
173173+(** Check if a user is subscribed to a channel.
174174+ @raise Eio.Io on failure *)
175175+176176+val update_subscription_settings :
177177+ Client.t ->
178178+ stream_id:int ->
179179+ ?color:string ->
180180+ ?is_muted:bool ->
181181+ ?pin_to_top:bool ->
182182+ ?desktop_notifications:bool ->
183183+ ?audible_notifications:bool ->
184184+ ?push_notifications:bool ->
185185+ ?email_notifications:bool ->
186186+ ?wildcard_mentions_notify:bool ->
187187+ unit ->
188188+ unit
189189+(** Update subscription settings for a channel.
190190+ @raise Eio.Io on failure *)
191191+192192+(** {1 Topics} *)
193193+194194+(** A topic within a channel. *)
195195+module Topic : sig
196196+ type t
197197+198198+ val name : t -> string
199199+ (** Topic name. *)
200200+201201+ val max_id : t -> int
202202+ (** ID of the latest message in the topic. *)
203203+204204+ val jsont : t Jsont.t
205205+end
206206+207207+val get_topics : Client.t -> stream_id:int -> Topic.t list
208208+(** Get all topics in a channel.
209209+ @raise Eio.Io on failure *)
210210+211211+val delete_topic : Client.t -> stream_id:int -> topic:string -> unit
212212+(** Delete a topic and all its messages. Requires admin privileges.
213213+ @raise Eio.Io on failure *)
214214+215215+(** {1 Topic Muting} *)
216216+217217+type mute_op = Mute (** Mute the topic *) | Unmute (** Unmute the topic *)
218218+219219+val set_topic_mute :
220220+ Client.t -> stream_id:int -> topic:string -> op:mute_op -> unit
221221+(** Mute or unmute a topic.
222222+ @raise Eio.Io on failure *)
223223+224224+val get_muted_topics : Client.t -> (int * string) list
225225+(** Get list of muted topics as (stream_id, topic_name) pairs.
226226+ @raise Eio.Io on failure *)
227227+228228+(** {1 Subscribers} *)
229229+230230+val get_subscribers : Client.t -> stream_id:int -> int list
231231+(** Get list of user IDs subscribed to a channel.
232232+ @raise Eio.Io on failure *)
233233+234234+(** {1 Email Address} *)
235235+236236+val get_email_address : Client.t -> stream_id:int -> string
237237+(** Get the email address for posting to a channel.
238238+ @raise Eio.Io on failure *)
+160
lib/zulip/client.ml
···11+(*---------------------------------------------------------------------------
22+ Copyright (c) 2025 Anil Madhavapeddy <anil@recoil.org>. All rights reserved.
33+ SPDX-License-Identifier: ISC
44+ ---------------------------------------------------------------------------*)
55+66+(* Logging setup *)
77+let src = Logs.Src.create "zulip.client" ~doc:"Zulip API client"
88+99+module Log = (val Logs.src_log src : Logs.LOG)
1010+1111+type t = { auth : Auth.t; session : Requests.t }
1212+1313+let create ~sw env auth =
1414+ Log.info (fun m -> m "Creating Zulip client for %s" (Auth.server_url auth));
1515+ let session =
1616+ Requests.create ~sw
1717+ ~default_headers:
1818+ (Requests.Headers.of_list
1919+ [
2020+ ("Authorization", Auth.to_basic_auth_header auth);
2121+ ("User-Agent", "OCaml-Zulip/1.0");
2222+ ])
2323+ ~follow_redirects:true ~verify_tls:true env
2424+ in
2525+ { auth; session }
2626+2727+let with_client env auth f =
2828+ Eio.Switch.run @@ fun sw ->
2929+ let client = create ~sw env auth in
3030+ f client
3131+3232+let method_to_string = function
3333+ | `GET -> "GET"
3434+ | `POST -> "POST"
3535+ | `PUT -> "PUT"
3636+ | `DELETE -> "DELETE"
3737+ | `PATCH -> "PATCH"
3838+3939+let request t ~method_ ~path ?params ?body ?content_type () =
4040+ let url = Auth.server_url t.auth ^ path in
4141+ Log.debug (fun m -> m "Request: %s %s" (method_to_string method_) path);
4242+4343+ (* Convert params to URL query string if provided *)
4444+ let url =
4545+ params
4646+ |> Option.map (fun p ->
4747+ Uri.of_string url
4848+ |> Fun.flip
4949+ (List.fold_left (fun u (k, v) -> Uri.add_query_param' u (k, v)))
5050+ p
5151+ |> Uri.to_string)
5252+ |> Option.value ~default:url
5353+ in
5454+5555+ (* Prepare request body if provided *)
5656+ let body_opt =
5757+ body
5858+ |> Option.map (fun body_str ->
5959+ let mime =
6060+ match content_type with
6161+ | Some ct when String.starts_with ~prefix:"multipart/form-data" ct ->
6262+ Requests.Mime.of_string ct
6363+ | Some "application/json" -> Requests.Mime.json
6464+ | Some "application/x-www-form-urlencoded" | None ->
6565+ if
6666+ String.contains body_str '='
6767+ && not (String.contains body_str '{')
6868+ then Requests.Mime.form
6969+ else Requests.Mime.json
7070+ | Some ct -> Requests.Mime.of_string ct
7171+ in
7272+ Requests.Body.of_string mime body_str)
7373+ in
7474+7575+ (* Make the request *)
7676+ let response =
7777+ match method_ with
7878+ | `GET -> Requests.get t.session url
7979+ | `POST -> Requests.post t.session ?body:body_opt url
8080+ | `PUT -> Requests.put t.session ?body:body_opt url
8181+ | `DELETE -> Requests.delete t.session url
8282+ | `PATCH -> Requests.patch t.session ?body:body_opt url
8383+ in
8484+8585+ (* Parse response *)
8686+ let status = Requests.Response.status_code response in
8787+ Log.debug (fun m -> m "Response status: %d" status);
8888+ let body_str =
8989+ let body_flow = Requests.Response.body response in
9090+ let buf = Buffer.create 4096 in
9191+ Eio.Flow.copy body_flow (Eio.Flow.buffer_sink buf);
9292+ Buffer.contents buf
9393+ in
9494+9595+ (* Parse JSON response using Jsont_bytesrw *)
9696+ let json =
9797+ match Jsont_bytesrw.decode_string' Jsont.json body_str with
9898+ | Ok j -> j
9999+ | Error e ->
100100+ let msg = Jsont.Error.to_string e in
101101+ Log.err (fun m -> m "JSON parse error: %s" msg);
102102+ Error.raise_with_context
103103+ (Error.make ~code:(Other "json_parse") ~message:msg ())
104104+ "%s %s" (method_to_string method_) path
105105+ in
106106+107107+ (* Check for Zulip error response *)
108108+ match json with
109109+ | Jsont.Object (fields, _) -> (
110110+ let assoc = List.map (fun ((k, _), v) -> (k, v)) fields in
111111+ match List.assoc_opt "result" assoc with
112112+ | Some (Jsont.String ("error", _)) ->
113113+ let msg =
114114+ match List.assoc_opt "msg" assoc with
115115+ | Some (Jsont.String (s, _)) -> s
116116+ | _ -> "Unknown error"
117117+ in
118118+ let code : Error.code =
119119+ match List.assoc_opt "code" assoc with
120120+ | Some (Jsont.String (s, _)) -> (
121121+ match s with
122122+ | "INVALID_API_KEY" -> Invalid_api_key
123123+ | "REQUEST_VARIABLE_MISSING" -> Request_variable_missing
124124+ | "BAD_REQUEST" -> Bad_request
125125+ | "USER_DEACTIVATED" -> User_deactivated
126126+ | "REALM_DEACTIVATED" -> Realm_deactivated
127127+ | "RATE_LIMIT_HIT" -> Rate_limit_hit
128128+ | s -> Other s)
129129+ | _ -> Other "unknown"
130130+ in
131131+ let extra =
132132+ List.filter
133133+ (fun (k, _) -> k <> "code" && k <> "msg" && k <> "result")
134134+ assoc
135135+ in
136136+ Log.warn (fun m ->
137137+ m "API error: %s (code: %a)" msg Error.pp_code code);
138138+ Error.raise_with_context
139139+ (Error.make ~code ~message:msg ~extra ())
140140+ "%s %s" (method_to_string method_) path
141141+ | _ ->
142142+ if status >= 200 && status < 300 then json
143143+ else (
144144+ Log.warn (fun m -> m "HTTP error: %d" status);
145145+ Error.raise_with_context
146146+ (Error.make
147147+ ~code:(Other (string_of_int status))
148148+ ~message:("HTTP error: " ^ string_of_int status)
149149+ ())
150150+ "%s %s" (method_to_string method_) path))
151151+ | _ ->
152152+ if status >= 200 && status < 300 then json
153153+ else (
154154+ Log.err (fun m -> m "Invalid JSON response");
155155+ Error.raise_with_context
156156+ (Error.make ~code:(Other "json_parse")
157157+ ~message:"Invalid JSON response" ())
158158+ "%s %s" (method_to_string method_) path)
159159+160160+let pp fmt t = Format.fprintf fmt "Client(server=%s)" (Auth.server_url t.auth)
+57
lib/zulip/client.mli
···11+(*---------------------------------------------------------------------------
22+ Copyright (c) 2025 Anil Madhavapeddy <anil@recoil.org>. All rights reserved.
33+ SPDX-License-Identifier: ISC
44+ ---------------------------------------------------------------------------*)
55+66+(** HTTP client for making requests to the Zulip API.
77+88+ This module provides the low-level HTTP client for communicating with the
99+ Zulip API. All API errors are raised as [Eio.Io] exceptions with [Error.E]
1010+ error codes, following the Eio error pattern.
1111+1212+ @raise Eio.Io with [Error.E error] for API errors *)
1313+1414+type t
1515+(** Type representing a Zulip HTTP client *)
1616+1717+val create :
1818+ sw:Eio.Switch.t ->
1919+ < clock : float Eio.Time.clock_ty Eio.Resource.t
2020+ ; net : [ `Generic | `Unix ] Eio.Net.ty Eio.Resource.t
2121+ ; fs : Eio.Fs.dir_ty Eio.Path.t
2222+ ; .. > ->
2323+ Auth.t ->
2424+ t
2525+(** Create a new client with the given switch, environment and authentication.
2626+ The environment must have clock, net, and fs capabilities. *)
2727+2828+val with_client :
2929+ < clock : float Eio.Time.clock_ty Eio.Resource.t
3030+ ; net : [ `Generic | `Unix ] Eio.Net.ty Eio.Resource.t
3131+ ; fs : Eio.Fs.dir_ty Eio.Path.t
3232+ ; .. > ->
3333+ Auth.t ->
3434+ (t -> 'a) ->
3535+ 'a
3636+(** Resource-safe client management using structured concurrency. The
3737+ environment must have clock, net, and fs capabilities. *)
3838+3939+val request :
4040+ t ->
4141+ method_:[ `GET | `POST | `PUT | `DELETE | `PATCH ] ->
4242+ path:string ->
4343+ ?params:(string * string) list ->
4444+ ?body:string ->
4545+ ?content_type:string ->
4646+ unit ->
4747+ Jsont.json
4848+(** Make an HTTP request to the Zulip API.
4949+ @param content_type
5050+ Optional Content-Type header (default: application/x-www-form-urlencoded
5151+ for POST/PUT, none for GET/DELETE)
5252+ @raise Eio.Io
5353+ with [Error.E error] on API errors. The exception includes context about
5454+ the request method and path. *)
5555+5656+val pp : Format.formatter -> t -> unit
5757+(** Pretty printer for client (shows server URL only, not credentials) *)
···11+(*---------------------------------------------------------------------------
22+ Copyright (c) 2025 Anil Madhavapeddy <anil@recoil.org>. All rights reserved.
33+ SPDX-License-Identifier: ISC
44+ ---------------------------------------------------------------------------*)
55+66+(** Encoding utilities for Zulip API requests *)
77+88+(** Convert a jsont-encoded value to JSON string *)
99+let to_json_string : 'a Jsont.t -> 'a -> string =
1010+ fun codec value ->
1111+ match Jsont_bytesrw.encode_string' codec value with
1212+ | Ok s -> s
1313+ | Error e -> failwith ("JSON encoding error: " ^ Jsont.Error.to_string e)
1414+1515+(** Convert a jsont-encoded value to form-urlencoded string *)
1616+let to_form_urlencoded : 'a Jsont.t -> 'a -> string =
1717+ fun codec value ->
1818+ (* First encode to JSON, then extract fields *)
1919+ let json_str = to_json_string codec value in
2020+ match Jsont_bytesrw.decode_string' Jsont.json json_str with
2121+ | Error e -> failwith ("JSON decode error: " ^ Jsont.Error.to_string e)
2222+ | Ok (Jsont.Object (fields, _)) ->
2323+ (* Convert object fields to form-urlencoded *)
2424+ let encode_value = function
2525+ | Jsont.String (s, _) -> Some (Uri.pct_encode ~component:`Query_value s)
2626+ | Jsont.Bool (b, _) -> Some (string_of_bool b)
2727+ | Jsont.Number (n, _) -> Some (string_of_float n)
2828+ | Jsont.Null _ -> None
2929+ | Jsont.Array (items, _) ->
3030+ (* For arrays, encode as JSON array string *)
3131+ let array_str =
3232+ "["
3333+ ^ String.concat ","
3434+ (List.filter_map
3535+ (function
3636+ | Jsont.String (s, _) ->
3737+ Some ("\"" ^ String.escaped s ^ "\"")
3838+ | Jsont.Number (n, _) -> Some (string_of_float n)
3939+ | Jsont.Bool (b, _) -> Some (string_of_bool b)
4040+ | _ -> None)
4141+ items)
4242+ ^ "]"
4343+ in
4444+ Some array_str
4545+ | Jsont.Object _ -> None (* Skip nested objects *)
4646+ in
4747+4848+ let params =
4949+ List.filter_map
5050+ (fun ((key, _), value) ->
5151+ match encode_value value with
5252+ | Some encoded -> Some (key ^ "=" ^ encoded)
5353+ | None -> None)
5454+ fields
5555+ in
5656+5757+ String.concat "&" params
5858+ | Ok _ -> failwith "Expected JSON object for form encoding"
5959+6060+(** Parse JSON string using a jsont codec *)
6161+let from_json_string : 'a Jsont.t -> string -> ('a, string) result =
6262+ fun codec json_str ->
6363+ match Jsont_bytesrw.decode_string' codec json_str with
6464+ | Ok v -> Ok v
6565+ | Error e -> Error (Jsont.Error.to_string e)
6666+6767+(** Parse a Jsont.json value using a codec *)
6868+let from_json : 'a Jsont.t -> Jsont.json -> ('a, string) result =
6969+ fun codec json ->
7070+ let json_str =
7171+ match Jsont_bytesrw.encode_string' Jsont.json json with
7272+ | Ok s -> s
7373+ | Error e ->
7474+ failwith ("Failed to re-encode json: " ^ Jsont.Error.to_string e)
7575+ in
7676+ from_json_string codec json_str
7777+7878+(** Convert a value to Jsont.json using a codec *)
7979+let to_json : 'a Jsont.t -> 'a -> (Jsont.json, string) result =
8080+ fun codec value ->
8181+ let json_str = to_json_string codec value in
8282+ match Jsont_bytesrw.decode_string' Jsont.json json_str with
8383+ | Ok json -> Ok json
8484+ | Error e -> Error (Jsont.Error.to_string e)
+31
lib/zulip/encode.mli
···11+(*---------------------------------------------------------------------------
22+ Copyright (c) 2025 Anil Madhavapeddy <anil@recoil.org>. All rights reserved.
33+ SPDX-License-Identifier: ISC
44+ ---------------------------------------------------------------------------*)
55+66+(** Encoding utilities for Zulip API requests *)
77+88+val to_json_string : 'a Jsont.t -> 'a -> string
99+(** Convert a value to JSON string using its jsont codec *)
1010+1111+val to_form_urlencoded : 'a Jsont.t -> 'a -> string
1212+(** Convert a value to application/x-www-form-urlencoded string using its jsont
1313+ codec
1414+1515+ The codec should represent a JSON object. Fields will be converted to
1616+ key=value pairs:
1717+ - Strings: URL-encoded
1818+ - Booleans: "true"/"false"
1919+ - Numbers: string representation
2020+ - Arrays: JSON array string "[...]"
2121+ - Null: omitted
2222+ - Nested objects: omitted *)
2323+2424+val from_json_string : 'a Jsont.t -> string -> ('a, string) result
2525+(** Parse JSON string using a jsont codec *)
2626+2727+val from_json : 'a Jsont.t -> Jsont.json -> ('a, string) result
2828+(** Parse a Jsont.json value using a codec *)
2929+3030+val to_json : 'a Jsont.t -> 'a -> (Jsont.json, string) result
3131+(** Convert a value to Jsont.json using a codec *)
+104
lib/zulip/error.ml
···11+(*---------------------------------------------------------------------------
22+ Copyright (c) 2025 Anil Madhavapeddy <anil@recoil.org>. All rights reserved.
33+ SPDX-License-Identifier: ISC
44+ ---------------------------------------------------------------------------*)
55+66+type code =
77+ | Invalid_api_key
88+ | Request_variable_missing
99+ | Bad_request
1010+ | User_deactivated
1111+ | Realm_deactivated
1212+ | Rate_limit_hit
1313+ | Other of string
1414+1515+type t = { code : code; message : string; extra : (string * Jsont.json) list }
1616+type Eio.Exn.err += E of t
1717+1818+let pp_code fmt = function
1919+ | Invalid_api_key -> Format.fprintf fmt "Invalid_api_key"
2020+ | Request_variable_missing -> Format.fprintf fmt "Request_variable_missing"
2121+ | Bad_request -> Format.fprintf fmt "Bad_request"
2222+ | User_deactivated -> Format.fprintf fmt "User_deactivated"
2323+ | Realm_deactivated -> Format.fprintf fmt "Realm_deactivated"
2424+ | Rate_limit_hit -> Format.fprintf fmt "Rate_limit_hit"
2525+ | Other s -> Format.fprintf fmt "Other(%s)" s
2626+2727+let pp fmt t = Format.fprintf fmt "%a: %s" pp_code t.code t.message
2828+2929+let () =
3030+ Eio.Exn.register_pp (fun f -> function
3131+ | E e ->
3232+ Format.fprintf f "Zulip %a" pp e;
3333+ true
3434+ | _ -> false)
3535+3636+let code_to_api_string = function
3737+ | Invalid_api_key -> "INVALID_API_KEY"
3838+ | Request_variable_missing -> "REQUEST_VARIABLE_MISSING"
3939+ | Bad_request -> "BAD_REQUEST"
4040+ | User_deactivated -> "USER_DEACTIVATED"
4141+ | Realm_deactivated -> "REALM_DEACTIVATED"
4242+ | Rate_limit_hit -> "RATE_LIMIT_HIT"
4343+ | Other s -> s
4444+4545+let code_of_api_string = function
4646+ | "INVALID_API_KEY" -> Invalid_api_key
4747+ | "REQUEST_VARIABLE_MISSING" -> Request_variable_missing
4848+ | "BAD_REQUEST" -> Bad_request
4949+ | "USER_DEACTIVATED" -> User_deactivated
5050+ | "REALM_DEACTIVATED" -> Realm_deactivated
5151+ | "RATE_LIMIT_HIT" -> Rate_limit_hit
5252+ | s -> Other s
5353+5454+let make ~code ~message ?(extra = []) () = { code; message; extra }
5555+let code t = t.code
5656+let message t = t.message
5757+let extra t = t.extra
5858+let raise e = Stdlib.raise (Eio.Exn.create (E e))
5959+6060+let raise_with_context e fmt =
6161+ Format.kasprintf
6262+ (fun context ->
6363+ Stdlib.raise (Eio.Exn.add_context (Eio.Exn.create (E e)) "%s" context))
6464+ fmt
6565+6666+let code_jsont =
6767+ let of_string s = Ok (code_of_api_string s) in
6868+ Jsont.of_of_string ~kind:"ErrorCode" of_string ~enc:code_to_api_string
6969+7070+let jsont =
7171+ let kind = "ZulipError" in
7272+ let make' code msg =
7373+ { code = code_of_api_string code; message = msg; extra = [] }
7474+ in
7575+ let code' t = code_to_api_string t.code in
7676+ let msg t = t.message in
7777+ Jsont.Object.(
7878+ map ~kind make'
7979+ |> mem "code" Jsont.string ~enc:code'
8080+ |> mem "msg" Jsont.string ~enc:msg
8181+ |> finish)
8282+8383+let of_json json =
8484+ Encode.from_json jsont json
8585+ |> Result.to_option
8686+ |> Option.map (fun err ->
8787+ match json with
8888+ | Jsont.Object (fields, _) ->
8989+ let extra =
9090+ fields
9191+ |> List.map (fun ((k, _), v) -> (k, v))
9292+ |> List.filter (fun (k, _) ->
9393+ k <> "code" && k <> "msg" && k <> "result")
9494+ in
9595+ { err with extra }
9696+ | _ -> err)
9797+9898+let decode_or_raise codec json context =
9999+ match Encode.from_json codec json with
100100+ | Ok v -> v
101101+ | Error msg ->
102102+ raise_with_context
103103+ (make ~code:(Other "json_parse") ~message:msg ())
104104+ "%s" context
+89
lib/zulip/error.mli
···11+(*---------------------------------------------------------------------------
22+ Copyright (c) 2025 Anil Madhavapeddy <anil@recoil.org>. All rights reserved.
33+ SPDX-License-Identifier: ISC
44+ ---------------------------------------------------------------------------*)
55+66+(** Zulip API error handling.
77+88+ This module defines protocol-level errors for the Zulip API, following the
99+ Eio error pattern for context-aware error handling.
1010+1111+ Errors are raised as [Eio.Io] exceptions:
1212+ {[
1313+ try Zulip.Messages.send client msg with
1414+ | Eio.Io (Zulip.Error.E { code = Invalid_api_key; message; _ }, _) ->
1515+ Printf.eprintf "Authentication failed: %s\n" message
1616+ | Eio.Io (Zulip.Error.E err, _) ->
1717+ Printf.eprintf "API error: %a\n" Zulip.Error.pp err
1818+ ]} *)
1919+2020+(** {1 Error Codes}
2121+2222+ These error codes correspond to the error codes returned by the Zulip API in
2323+ the "code" field of error responses. *)
2424+2525+type code =
2626+ | Invalid_api_key (** Authentication failure - invalid API key *)
2727+ | Request_variable_missing (** Required parameter is missing *)
2828+ | Bad_request (** Malformed request *)
2929+ | User_deactivated (** User account has been deactivated *)
3030+ | Realm_deactivated (** Organization (realm) has been deactivated *)
3131+ | Rate_limit_hit (** API rate limit exceeded *)
3232+ | Other of string (** Other/unknown error code *)
3333+3434+(** {1 Error Type} *)
3535+3636+type t = {
3737+ code : code; (** The error code from the API *)
3838+ message : string; (** Human-readable error message *)
3939+ extra : (string * Jsont.json) list;
4040+ (** Additional fields from the error response *)
4141+}
4242+(** The protocol-level error type. *)
4343+4444+(** {1 Eio Integration} *)
4545+4646+type Eio.Exn.err +=
4747+ | E of t (** Extend [Eio.Exn.err] with Zulip protocol errors. *)
4848+4949+val raise : t -> 'a
5050+(** [raise e] raises an [Eio.Io] exception for error [e]. Equivalent to
5151+ [Stdlib.raise (Eio.Exn.create (E e))]. *)
5252+5353+val raise_with_context : t -> ('a, Format.formatter, unit, 'b) format4 -> 'a
5454+(** [raise_with_context e fmt ...] raises an [Eio.Io] exception with context.
5555+ Equivalent to
5656+ [Stdlib.raise (Eio.Exn.add_context (Eio.Exn.create (E e)) fmt ...)]. *)
5757+5858+(** {1 Error Construction} *)
5959+6060+val make :
6161+ code:code -> message:string -> ?extra:(string * Jsont.json) list -> unit -> t
6262+(** [make ~code ~message ?extra ()] creates an error value. *)
6363+6464+(** {1 Accessors} *)
6565+6666+val code : t -> code
6767+val message : t -> string
6868+val extra : t -> (string * Jsont.json) list
6969+7070+(** {1 Pretty Printing} *)
7171+7272+val pp_code : Format.formatter -> code -> unit
7373+val pp : Format.formatter -> t -> unit
7474+7575+(** {1 JSON Parsing} *)
7676+7777+val code_jsont : code Jsont.t
7878+(** Jsont codec for error codes. *)
7979+8080+val jsont : t Jsont.t
8181+(** Jsont codec for errors. *)
8282+8383+val of_json : Jsont.json -> t option
8484+(** [of_json json] attempts to parse a Zulip API error response. Returns [None]
8585+ if the JSON does not represent an error. *)
8686+8787+val decode_or_raise : 'a Jsont.t -> Jsont.json -> string -> 'a
8888+(** [decode_or_raise codec json context] decodes JSON using the codec, or raises
8989+ a Zulip error with the given context if decoding fails. *)
+31
lib/zulip/event.ml
···11+(*---------------------------------------------------------------------------
22+ Copyright (c) 2025 Anil Madhavapeddy <anil@recoil.org>. All rights reserved.
33+ SPDX-License-Identifier: ISC
44+ ---------------------------------------------------------------------------*)
55+66+type t = { id : int; type_ : Event_type.t; data : Jsont.json }
77+88+let id t = t.id
99+let type_ t = t.type_
1010+let data t = t.data
1111+1212+let pp fmt t =
1313+ Format.fprintf fmt "Event{id=%d, type=%a}" t.id Event_type.pp t.type_
1414+1515+(* Jsont codec for event_type *)
1616+let event_type_jsont =
1717+ let of_string s = Ok (Event_type.of_string s) in
1818+ Jsont.of_of_string ~kind:"Event_type.t" of_string ~enc:Event_type.to_string
1919+2020+(* Jsont codec for event.
2121+ We decode id and type, and use keep_unknown to preserve all other fields as data *)
2222+let jsont =
2323+ let kind = "Event" in
2424+ let doc = "A Zulip event from the event queue" in
2525+ let make id type_ (data : Jsont.json) = { id; type_; data } in
2626+ let enc_data t = t.data in
2727+ Jsont.Object.map ~kind ~doc make
2828+ |> Jsont.Object.mem "id" Jsont.int ~enc:id
2929+ |> Jsont.Object.mem "type" event_type_jsont ~enc:type_
3030+ |> Jsont.Object.keep_unknown Jsont.json_mems ~enc:enc_data
3131+ |> Jsont.Object.finish
+20
lib/zulip/event.mli
···11+(*---------------------------------------------------------------------------
22+ Copyright (c) 2025 Anil Madhavapeddy <anil@recoil.org>. All rights reserved.
33+ SPDX-License-Identifier: ISC
44+ ---------------------------------------------------------------------------*)
55+66+(** Zulip events.
77+88+ This module represents events received from the Zulip event queue. Use
99+ {!jsont} with Bytesrw-eio for wire deserialization. *)
1010+1111+type t
1212+1313+val id : t -> int
1414+val type_ : t -> Event_type.t
1515+val data : t -> Jsont.json
1616+1717+val jsont : t Jsont.t
1818+(** Jsont codec for event *)
1919+2020+val pp : Format.formatter -> t -> unit
+181
lib/zulip/event_queue.ml
···11+(*---------------------------------------------------------------------------
22+ Copyright (c) 2025 Anil Madhavapeddy <anil@recoil.org>. All rights reserved.
33+ SPDX-License-Identifier: ISC
44+ ---------------------------------------------------------------------------*)
55+66+(* Logging setup *)
77+let src = Logs.Src.create "zulip.event_queue" ~doc:"Zulip event queue"
88+99+module Log = (val Logs.src_log src : Logs.LOG)
1010+1111+type t = { id : string; mutable last_event_id : int }
1212+1313+module Register_response = struct
1414+ type t = { queue_id : string; last_event_id : int }
1515+1616+ let codec =
1717+ Jsont.Object.(
1818+ map ~kind:"RegisterResponse" (fun queue_id last_event_id ->
1919+ { queue_id; last_event_id })
2020+ |> mem "queue_id" Jsont.string ~enc:(fun r -> r.queue_id)
2121+ |> mem "last_event_id" Jsont.int ~dec_absent:(-1) ~enc:(fun r ->
2222+ r.last_event_id)
2323+ |> finish)
2424+end
2525+2626+let register client ?event_types ?narrow ?all_public_streams
2727+ ?include_subscribers ?client_capabilities ?fetch_event_types
2828+ ?client_gravatar ?slim_presence () =
2929+ let event_types_str =
3030+ Option.map (List.map Event_type.to_string) event_types
3131+ in
3232+ let fetch_event_types_str =
3333+ Option.map (List.map Event_type.to_string) fetch_event_types
3434+ in
3535+ let params =
3636+ List.filter_map Fun.id
3737+ [
3838+ Option.map
3939+ (fun types ->
4040+ ( "event_types",
4141+ Encode.to_json_string (Jsont.list Jsont.string) types ))
4242+ event_types_str;
4343+ Option.map
4444+ (fun n -> ("narrow", Encode.to_json_string Narrow.list_jsont n))
4545+ narrow;
4646+ Option.map
4747+ (fun v -> ("all_public_streams", string_of_bool v))
4848+ all_public_streams;
4949+ Option.map
5050+ (fun v -> ("include_subscribers", string_of_bool v))
5151+ include_subscribers;
5252+ Option.map
5353+ (fun v -> ("client_capabilities", Encode.to_json_string Jsont.json v))
5454+ client_capabilities;
5555+ Option.map
5656+ (fun types ->
5757+ ( "fetch_event_types",
5858+ Encode.to_json_string (Jsont.list Jsont.string) types ))
5959+ fetch_event_types_str;
6060+ Option.map
6161+ (fun v -> ("client_gravatar", string_of_bool v))
6262+ client_gravatar;
6363+ Option.map (fun v -> ("slim_presence", string_of_bool v)) slim_presence;
6464+ ]
6565+ in
6666+6767+ Option.iter
6868+ (fun types ->
6969+ Log.debug (fun m ->
7070+ m "Registering with event_types: %s" (String.concat "," types)))
7171+ event_types_str;
7272+7373+ let json =
7474+ Client.request client ~method_:`POST ~path:"/api/v1/register" ~params ()
7575+ in
7676+ let response =
7777+ Error.decode_or_raise Register_response.codec json
7878+ "parsing register response"
7979+ in
8080+ { id = response.queue_id; last_event_id = response.last_event_id }
8181+8282+let id t = t.id
8383+let last_event_id t = t.last_event_id
8484+8585+(* Events response codec - events field is optional (may not be present) *)
8686+module Events_response = struct
8787+ type t = { events : Event.t list }
8888+8989+ let codec =
9090+ let make raw_json =
9191+ match raw_json with
9292+ | Jsont.Object (fields, _) -> (
9393+ let assoc = List.map (fun ((k, _), v) -> (k, v)) fields in
9494+ match List.assoc_opt "events" assoc with
9595+ | Some (Jsont.Array (items, _)) ->
9696+ let events =
9797+ items
9898+ |> List.filter_map (fun item ->
9999+ Encode.from_json Event.jsont item |> Result.to_option)
100100+ in
101101+ { events }
102102+ | Some _ -> { events = [] }
103103+ | None -> { events = [] })
104104+ | _ -> { events = [] }
105105+ in
106106+ Jsont.Object.map ~kind:"EventsResponse" make
107107+ |> Jsont.Object.keep_unknown Jsont.json_mems ~enc:(fun _ ->
108108+ Jsont.Object ([], Jsont.Meta.none))
109109+ |> Jsont.Object.finish
110110+end
111111+112112+let get_events t client ?last_event_id ?dont_block () =
113113+ let event_id = Option.value last_event_id ~default:t.last_event_id in
114114+ let params =
115115+ [ ("queue_id", t.id); ("last_event_id", string_of_int event_id) ]
116116+ @ if dont_block = Some true then [ ("dont_block", "true") ] else []
117117+ in
118118+ let json =
119119+ Client.request client ~method_:`GET ~path:"/api/v1/events" ~params ()
120120+ in
121121+ let response =
122122+ Error.decode_or_raise Events_response.codec json
123123+ (Printf.sprintf "parsing events from queue %s" t.id)
124124+ in
125125+ Log.debug (fun m -> m "Got %d events from API" (List.length response.events));
126126+ (* Update internal last_event_id *)
127127+ (match response.events with
128128+ | [] -> ()
129129+ | events ->
130130+ let max_id =
131131+ List.fold_left (fun acc e -> max acc (Event.id e)) event_id events
132132+ in
133133+ t.last_event_id <- max_id);
134134+ response.events
135135+136136+let delete t client =
137137+ let params = [ ("queue_id", t.id) ] in
138138+ let _response =
139139+ Client.request client ~method_:`DELETE ~path:"/api/v1/events" ~params ()
140140+ in
141141+ ()
142142+143143+let call_on_each_event client ?event_types ?narrow ~callback () =
144144+ let queue = register client ?event_types ?narrow () in
145145+ let rec loop () =
146146+ let events = get_events queue client () in
147147+ List.iter
148148+ (fun event ->
149149+ (* Filter out heartbeat events *)
150150+ match Event.type_ event with
151151+ | Event_type.Heartbeat -> ()
152152+ | _ -> callback event)
153153+ events;
154154+ loop ()
155155+ in
156156+ loop ()
157157+158158+let call_on_each_message client ?narrow ~callback () =
159159+ call_on_each_event client ~event_types:[ Event_type.Message ] ?narrow
160160+ ~callback:(fun event ->
161161+ match Event.type_ event with
162162+ | Event_type.Message -> callback (Event.data event)
163163+ | _ -> ())
164164+ ()
165165+166166+let events t client =
167167+ let rec next () =
168168+ let events = get_events t client ~dont_block:true () in
169169+ match events with
170170+ | [] ->
171171+ (* No events available, wait a bit and try again *)
172172+ Seq.Cons (List.hd (get_events t client ()), next)
173173+ | e :: rest ->
174174+ (* Return events one by one *)
175175+ let rest_seq = List.to_seq rest in
176176+ Seq.Cons (e, fun () -> Seq.append rest_seq next ())
177177+ in
178178+ next
179179+180180+let pp fmt t =
181181+ Format.fprintf fmt "EventQueue{id=%s, last_event_id=%d}" t.id t.last_event_id
+126
lib/zulip/event_queue.mli
···11+(*---------------------------------------------------------------------------
22+ Copyright (c) 2025 Anil Madhavapeddy <anil@recoil.org>. All rights reserved.
33+ SPDX-License-Identifier: ISC
44+ ---------------------------------------------------------------------------*)
55+66+(** Event queue for receiving Zulip events in real-time.
77+88+ Event queues provide real-time notifications of changes in Zulip. Register a
99+ queue to receive events, then poll for updates.
1010+1111+ All functions raise [Eio.Io] with [Error.E error] on failure. *)
1212+1313+(** {1 Event Queue Type} *)
1414+1515+type t
1616+(** An opaque event queue handle. *)
1717+1818+(** {1 Registration} *)
1919+2020+val register :
2121+ Client.t ->
2222+ ?event_types:Event_type.t list ->
2323+ ?narrow:Narrow.t list ->
2424+ ?all_public_streams:bool ->
2525+ ?include_subscribers:bool ->
2626+ ?client_capabilities:Jsont.json ->
2727+ ?fetch_event_types:Event_type.t list ->
2828+ ?client_gravatar:bool ->
2929+ ?slim_presence:bool ->
3030+ unit ->
3131+ t
3232+(** Register a new event queue with the server.
3333+3434+ @param event_types Event types to subscribe to (default: all)
3535+ @param narrow Narrow filter for message events
3636+ @param all_public_streams Include events from all public streams
3737+ @param include_subscribers Include subscriber lists in stream events
3838+ @param client_capabilities Client capability flags
3939+ @param fetch_event_types Types to return initial data for
4040+ @param client_gravatar Whether client handles gravatar URLs
4141+ @param slim_presence Use compact presence format
4242+ @raise Eio.Io on failure *)
4343+4444+(** {1 Queue Properties} *)
4545+4646+val id : t -> string
4747+(** Get the queue ID. *)
4848+4949+val last_event_id : t -> int
5050+(** Get the last event ID received. *)
5151+5252+(** {1 Polling for Events} *)
5353+5454+val get_events :
5555+ t ->
5656+ Client.t ->
5757+ ?last_event_id:int ->
5858+ ?dont_block:bool ->
5959+ unit ->
6060+ Event.t list
6161+(** Get events from the queue.
6262+6363+ @param last_event_id Event ID to resume from (default: use queue's state)
6464+ @param dont_block Return immediately even if no events (default: long-poll)
6565+ @raise Eio.Io on failure
6666+6767+ Note: This function updates the queue's internal last_event_id. *)
6868+6969+(** {1 Cleanup} *)
7070+7171+val delete : t -> Client.t -> unit
7272+(** Delete the event queue from the server.
7373+ @raise Eio.Io on failure *)
7474+7575+(** {1 High-Level Event Processing}
7676+7777+ These functions provide convenient callback-based patterns for processing
7878+ events. They handle queue management and reconnection automatically. *)
7979+8080+val call_on_each_event :
8181+ Client.t ->
8282+ ?event_types:Event_type.t list ->
8383+ ?narrow:Narrow.t list ->
8484+ callback:(Event.t -> unit) ->
8585+ unit ->
8686+ unit
8787+(** Process events with a callback.
8888+8989+ Registers a queue and continuously polls for events, calling the callback
9090+ for each event. Automatically handles reconnection if the queue expires.
9191+9292+ This function runs indefinitely until cancelled via [Eio.Cancel].
9393+9494+ @param event_types Event types to subscribe to
9595+ @param narrow Narrow filter for message events
9696+ @param callback Function called for each event
9797+9898+ Note: Heartbeat events are filtered out automatically. *)
9999+100100+val call_on_each_message :
101101+ Client.t ->
102102+ ?narrow:Narrow.t list ->
103103+ callback:(Jsont.json -> unit) ->
104104+ unit ->
105105+ unit
106106+(** Process message events with a callback.
107107+108108+ Convenience wrapper around [call_on_each_event] that filters for message
109109+ events and extracts the message data.
110110+111111+ @param narrow Narrow filter for messages
112112+ @param callback Function called with each message's JSON data *)
113113+114114+(** {1 Event Stream}
115115+116116+ For use with Eio's streaming patterns. *)
117117+118118+val events : t -> Client.t -> Event.t Seq.t
119119+(** Create a lazy sequence of events from the queue. The sequence polls the
120120+ server as needed.
121121+122122+ Note: This sequence is infinite - use [Seq.take] or similar to limit. *)
123123+124124+(** {1 Pretty Printing} *)
125125+126126+val pp : Format.formatter -> t -> unit
···11+(*---------------------------------------------------------------------------
22+ Copyright (c) 2025 Anil Madhavapeddy <anil@recoil.org>. All rights reserved.
33+ SPDX-License-Identifier: ISC
44+ ---------------------------------------------------------------------------*)
55+66+(** Zulip event types.
77+88+ This module defines the event types that can be received from the Zulip
99+ event queue. These correspond to the "type" field in event objects returned
1010+ by the /events endpoint. *)
1111+1212+(** {1 Event Types} *)
1313+1414+type t =
1515+ | Message (** New message received *)
1616+ | Heartbeat (** Keep-alive heartbeat (internal protocol) *)
1717+ | Presence (** User presence update *)
1818+ | Typing (** User typing notification *)
1919+ | Reaction (** Emoji reaction added/removed *)
2020+ | Subscription (** Stream subscription change *)
2121+ | Stream (** Stream created/deleted/updated *)
2222+ | Realm (** Realm settings changed *)
2323+ | Realm_user (** User added/removed/updated in realm *)
2424+ | Realm_emoji (** Custom emoji added/removed *)
2525+ | Realm_linkifiers (** Linkifier rules changed *)
2626+ | User_group (** User group modified *)
2727+ | User_status (** User status (away/active) changed *)
2828+ | Update_message (** Message edited *)
2929+ | Delete_message (** Message deleted *)
3030+ | Update_message_flags (** Message flags (read/starred) changed *)
3131+ | Restart (** Server restart notification *)
3232+ | Other of string (** Unknown/custom event type *)
3333+3434+(** {1 Conversion} *)
3535+3636+val to_string : t -> string
3737+(** Convert an event type to its wire format string. *)
3838+3939+val of_string : string -> t
4040+(** Parse an event type from its wire format string. Unknown types are wrapped
4141+ in [Other]. *)
4242+4343+(** {1 Pretty Printing} *)
4444+4545+val pp : Format.formatter -> t -> unit
4646+4747+(** {1 JSON Codec} *)
4848+4949+val jsont : t Jsont.t
+57
lib/zulip/message.ml
···11+(*---------------------------------------------------------------------------
22+ Copyright (c) 2025 Anil Madhavapeddy <anil@recoil.org>. All rights reserved.
33+ SPDX-License-Identifier: ISC
44+ ---------------------------------------------------------------------------*)
55+66+type t = {
77+ type_ : Message_type.t;
88+ to_ : string list;
99+ content : string;
1010+ topic : string option;
1111+ queue_id : string option;
1212+ local_id : string option;
1313+ read_by_sender : bool;
1414+}
1515+1616+let create ~type_ ~to_ ~content ?topic ?queue_id ?local_id
1717+ ?(read_by_sender = true) () =
1818+ { type_; to_; content; topic; queue_id; local_id; read_by_sender }
1919+2020+let type_ t = t.type_
2121+let to_ t = t.to_
2222+let content t = t.content
2323+let topic t = t.topic
2424+let queue_id t = t.queue_id
2525+let local_id t = t.local_id
2626+let read_by_sender t = t.read_by_sender
2727+2828+let pp fmt t =
2929+ Format.fprintf fmt "Message{type=%a, to=%s, content=%s}" Message_type.pp
3030+ t.type_ (String.concat "," t.to_) t.content
3131+3232+(* Jsont codec for Message_type.t *)
3333+let message_type_jsont =
3434+ let of_string s =
3535+ match Message_type.of_string s with
3636+ | Some t -> Ok t
3737+ | None -> Error (Format.sprintf "Invalid message type: %s" s)
3838+ in
3939+ Jsont.of_of_string ~kind:"Message_type.t" of_string
4040+ ~enc:Message_type.to_string
4141+4242+(* Jsont codec for the message *)
4343+let jsont =
4444+ let kind = "Message" in
4545+ let doc = "A Zulip message to be sent" in
4646+ let make type_ to_ content topic queue_id local_id read_by_sender =
4747+ { type_; to_; content; topic; queue_id; local_id; read_by_sender }
4848+ in
4949+ Jsont.Object.map ~kind ~doc make
5050+ |> Jsont.Object.mem "type" message_type_jsont ~enc:type_
5151+ |> Jsont.Object.mem "to" (Jsont.list Jsont.string) ~enc:to_
5252+ |> Jsont.Object.mem "content" Jsont.string ~enc:content
5353+ |> Jsont.Object.opt_mem "topic" Jsont.string ~enc:topic
5454+ |> Jsont.Object.opt_mem "queue_id" Jsont.string ~enc:queue_id
5555+ |> Jsont.Object.opt_mem "local_id" Jsont.string ~enc:local_id
5656+ |> Jsont.Object.mem "read_by_sender" Jsont.bool ~enc:read_by_sender
5757+ |> Jsont.Object.finish
+35
lib/zulip/message.mli
···11+(*---------------------------------------------------------------------------
22+ Copyright (c) 2025 Anil Madhavapeddy <anil@recoil.org>. All rights reserved.
33+ SPDX-License-Identifier: ISC
44+ ---------------------------------------------------------------------------*)
55+66+(** Outgoing Zulip messages.
77+88+ This module represents messages to be sent via the Zulip API. Use {!jsont}
99+ with Bytesrw-eio for wire serialization. *)
1010+1111+type t
1212+1313+val create :
1414+ type_:Message_type.t ->
1515+ to_:string list ->
1616+ content:string ->
1717+ ?topic:string ->
1818+ ?queue_id:string ->
1919+ ?local_id:string ->
2020+ ?read_by_sender:bool ->
2121+ unit ->
2222+ t
2323+2424+val type_ : t -> Message_type.t
2525+val to_ : t -> string list
2626+val content : t -> string
2727+val topic : t -> string option
2828+val queue_id : t -> string option
2929+val local_id : t -> string option
3030+val read_by_sender : t -> bool
3131+3232+val jsont : t Jsont.t
3333+(** Jsont codec for the message type *)
3434+3535+val pp : Format.formatter -> t -> unit
+61
lib/zulip/message_flag.ml
···11+(*---------------------------------------------------------------------------
22+ Copyright (c) 2025 Anil Madhavapeddy <anil@recoil.org>. All rights reserved.
33+ SPDX-License-Identifier: ISC
44+ ---------------------------------------------------------------------------*)
55+66+type modifiable = [ `Read | `Starred | `Collapsed ]
77+88+type t =
99+ [ modifiable
1010+ | `Mentioned
1111+ | `Wildcard_mentioned
1212+ | `Has_alert_word
1313+ | `Historical ]
1414+1515+let to_string = function
1616+ | `Read -> "read"
1717+ | `Starred -> "starred"
1818+ | `Collapsed -> "collapsed"
1919+ | `Mentioned -> "mentioned"
2020+ | `Wildcard_mentioned -> "wildcard_mentioned"
2121+ | `Has_alert_word -> "has_alert_word"
2222+ | `Historical -> "historical"
2323+2424+let of_string = function
2525+ | "read" -> Some `Read
2626+ | "starred" -> Some `Starred
2727+ | "collapsed" -> Some `Collapsed
2828+ | "mentioned" -> Some `Mentioned
2929+ | "wildcard_mentioned" -> Some `Wildcard_mentioned
3030+ | "has_alert_word" -> Some `Has_alert_word
3131+ | "historical" -> Some `Historical
3232+ | _ -> None
3333+3434+let modifiable_of_string = function
3535+ | "read" -> Some `Read
3636+ | "starred" -> Some `Starred
3737+ | "collapsed" -> Some `Collapsed
3838+ | _ -> None
3939+4040+type op = Add | Remove
4141+4242+let op_to_string = function Add -> "add" | Remove -> "remove"
4343+let pp fmt t = Format.fprintf fmt "%s" (to_string t)
4444+4545+let jsont =
4646+ let encode t = to_string t in
4747+ let decode s =
4848+ match of_string s with
4949+ | Some t -> t
5050+ | None -> failwith ("Unknown message flag: " ^ s)
5151+ in
5252+ Jsont.string |> Jsont.map ~dec:decode ~enc:encode
5353+5454+let modifiable_jsont =
5555+ let encode t = to_string t in
5656+ let decode s =
5757+ match modifiable_of_string s with
5858+ | Some t -> t
5959+ | None -> failwith ("Unknown modifiable message flag: " ^ s)
6060+ in
6161+ Jsont.string |> Jsont.map ~dec:decode ~enc:encode
+53
lib/zulip/message_flag.mli
···11+(*---------------------------------------------------------------------------
22+ Copyright (c) 2025 Anil Madhavapeddy <anil@recoil.org>. All rights reserved.
33+ SPDX-License-Identifier: ISC
44+ ---------------------------------------------------------------------------*)
55+66+(** Message flags in Zulip.
77+88+ Message flags indicate read/unread status, starred messages, mentions, and
99+ other message properties. *)
1010+1111+(** {1 Flag Types} *)
1212+1313+type modifiable =
1414+ [ `Read (** Message has been read *)
1515+ | `Starred (** Message is starred/bookmarked *)
1616+ | `Collapsed (** Message content is collapsed *) ]
1717+(** Flags that can be directly modified by the user. *)
1818+1919+type t =
2020+ [ modifiable
2121+ | `Mentioned (** User was @-mentioned in the message *)
2222+ | `Wildcard_mentioned (** User was mentioned via @all/@everyone *)
2323+ | `Has_alert_word (** Message contains one of user's alert words *)
2424+ | `Historical (** Message predates user joining the stream *) ]
2525+(** All possible message flags. *)
2626+2727+(** {1 Conversion} *)
2828+2929+val to_string : [< t ] -> string
3030+(** Convert a flag to its wire format string. *)
3131+3232+val of_string : string -> t option
3333+(** Parse a flag from its wire format string. *)
3434+3535+val modifiable_of_string : string -> modifiable option
3636+(** Parse a modifiable flag from its wire format string. *)
3737+3838+(** {1 Flag Update Operations} *)
3939+4040+type op =
4141+ | Add (** Add the flag to messages *)
4242+ | Remove (** Remove the flag from messages *)
4343+4444+val op_to_string : op -> string
4545+4646+(** {1 Pretty Printing} *)
4747+4848+val pp : Format.formatter -> t -> unit
4949+5050+(** {1 JSON Codec} *)
5151+5252+val jsont : t Jsont.t
5353+val modifiable_jsont : modifiable Jsont.t
+23
lib/zulip/message_response.ml
···11+(*---------------------------------------------------------------------------
22+ Copyright (c) 2025 Anil Madhavapeddy <anil@recoil.org>. All rights reserved.
33+ SPDX-License-Identifier: ISC
44+ ---------------------------------------------------------------------------*)
55+66+type t = { id : int; automatic_new_visibility_policy : string option }
77+88+let id t = t.id
99+let automatic_new_visibility_policy t = t.automatic_new_visibility_policy
1010+let pp fmt t = Format.fprintf fmt "MessageResponse{id=%d}" t.id
1111+1212+(* Jsont codec for message response *)
1313+let jsont =
1414+ let kind = "MessageResponse" in
1515+ let doc = "A Zulip message response" in
1616+ let make id automatic_new_visibility_policy =
1717+ { id; automatic_new_visibility_policy }
1818+ in
1919+ Jsont.Object.map ~kind ~doc make
2020+ |> Jsont.Object.mem "id" Jsont.int ~enc:id
2121+ |> Jsont.Object.opt_mem "automatic_new_visibility_policy" Jsont.string
2222+ ~enc:automatic_new_visibility_policy
2323+ |> Jsont.Object.finish
+19
lib/zulip/message_response.mli
···11+(*---------------------------------------------------------------------------
22+ Copyright (c) 2025 Anil Madhavapeddy <anil@recoil.org>. All rights reserved.
33+ SPDX-License-Identifier: ISC
44+ ---------------------------------------------------------------------------*)
55+66+(** Response from sending a Zulip message.
77+88+ This module represents the response returned when a message is sent. Use
99+ {!jsont} with Bytesrw-eio for wire serialization. *)
1010+1111+type t
1212+1313+val id : t -> int
1414+val automatic_new_visibility_policy : t -> string option
1515+1616+val jsont : t Jsont.t
1717+(** Jsont codec for message response *)
1818+1919+val pp : Format.formatter -> t -> unit
+15
lib/zulip/message_type.ml
···11+(*---------------------------------------------------------------------------
22+ Copyright (c) 2025 Anil Madhavapeddy <anil@recoil.org>. All rights reserved.
33+ SPDX-License-Identifier: ISC
44+ ---------------------------------------------------------------------------*)
55+66+type t = [ `Direct | `Channel ]
77+88+let to_string = function `Direct -> "direct" | `Channel -> "stream"
99+1010+let of_string = function
1111+ | "direct" -> Some `Direct
1212+ | "stream" -> Some `Channel
1313+ | _ -> None
1414+1515+let pp fmt t = Format.fprintf fmt "%s" (to_string t)
+10
lib/zulip/message_type.mli
···11+(*---------------------------------------------------------------------------
22+ Copyright (c) 2025 Anil Madhavapeddy <anil@recoil.org>. All rights reserved.
33+ SPDX-License-Identifier: ISC
44+ ---------------------------------------------------------------------------*)
55+66+type t = [ `Direct | `Channel ]
77+88+val to_string : t -> string
99+val of_string : string -> t option
1010+val pp : Format.formatter -> t -> unit
···11+(*---------------------------------------------------------------------------
22+ Copyright (c) 2025 Anil Madhavapeddy <anil@recoil.org>. All rights reserved.
33+ SPDX-License-Identifier: ISC
44+ ---------------------------------------------------------------------------*)
55+66+(** Message operations for the Zulip API.
77+88+ All functions raise [Eio.Io] with [Error.E error] on failure. Context is
99+ automatically added indicating the operation being performed. *)
1010+1111+(** {1 Sending Messages} *)
1212+1313+val send : Client.t -> Message.t -> Message_response.t
1414+(** Send a message.
1515+ @raise Eio.Io on failure *)
1616+1717+(** {1 Reading Messages} *)
1818+1919+val get : Client.t -> message_id:int -> Jsont.json
2020+(** Get a single message by ID. Returns the full message object.
2121+ @raise Eio.Io on failure *)
2222+2323+val get_raw : Client.t -> message_id:int -> string
2424+(** Get the raw Markdown content of a message.
2525+ @raise Eio.Io on failure *)
2626+2727+(** Anchor points for message pagination. *)
2828+type anchor =
2929+ | Newest (** Start from the newest message *)
3030+ | Oldest (** Start from the oldest message *)
3131+ | First_unread (** Start from first unread message *)
3232+ | Message_id of int (** Start from a specific message ID *)
3333+3434+val get_messages :
3535+ Client.t ->
3636+ anchor:anchor ->
3737+ ?num_before:int ->
3838+ ?num_after:int ->
3939+ ?narrow:Narrow.t list ->
4040+ ?include_anchor:bool ->
4141+ unit ->
4242+ Jsont.json
4343+(** Get multiple messages with optional filtering.
4444+4545+ @param anchor Where to start fetching (required)
4646+ @param num_before Number of messages before anchor (default: 0)
4747+ @param num_after Number of messages after anchor (default: 0)
4848+ @param narrow Filter criteria (see {!Narrow})
4949+ @param include_anchor Include the anchor message in results
5050+ @raise Eio.Io on failure *)
5151+5252+val check_messages_match_narrow :
5353+ Client.t -> message_ids:int list -> narrow:Narrow.t list -> Jsont.json
5454+(** Check if messages match a narrow filter. Returns which of the given messages
5555+ match the narrow.
5656+ @raise Eio.Io on failure *)
5757+5858+(** {1 Message History} *)
5959+6060+val get_history : Client.t -> message_id:int -> Jsont.json
6161+(** Get the edit history of a message.
6262+ @raise Eio.Io on failure *)
6363+6464+(** {1 Editing Messages} *)
6565+6666+(** Propagation mode for topic/stream changes. *)
6767+type propagate_mode =
6868+ | Change_one (** Only change this message *)
6969+ | Change_later (** Change this and subsequent messages *)
7070+ | Change_all (** Change all messages in the topic *)
7171+7272+val edit :
7373+ Client.t ->
7474+ message_id:int ->
7575+ ?content:string ->
7676+ ?topic:string ->
7777+ ?stream_id:int ->
7878+ ?propagate_mode:propagate_mode ->
7979+ ?send_notification_to_old_thread:bool ->
8080+ ?send_notification_to_new_thread:bool ->
8181+ unit ->
8282+ unit
8383+(** Edit a message's content, topic, or stream.
8484+8585+ @param content New message content
8686+ @param topic New topic name
8787+ @param stream_id Move message to this stream
8888+ @param propagate_mode How to handle topic/stream changes for other messages
8989+ @param send_notification_to_old_thread Notify in old location
9090+ @param send_notification_to_new_thread Notify in new location
9191+ @raise Eio.Io on failure *)
9292+9393+(** {1 Deleting Messages} *)
9494+9595+val delete : Client.t -> message_id:int -> unit
9696+(** Delete a message.
9797+ @raise Eio.Io on failure *)
9898+9999+(** {1 Message Flags} *)
100100+101101+val update_flags :
102102+ Client.t ->
103103+ messages:int list ->
104104+ op:Message_flag.op ->
105105+ flag:Message_flag.modifiable ->
106106+ unit
107107+(** Update flags on a list of messages.
108108+109109+ @param messages List of message IDs to update
110110+ @param op Whether to add or remove the flag
111111+ @param flag The flag to update
112112+ @raise Eio.Io on failure
113113+114114+ {b Example:}
115115+ {[
116116+ (* Mark messages as read *)
117117+ Messages.update_flags client ~messages:[ 123; 456; 789 ] ~op:Add
118118+ ~flag:`Read
119119+ ]} *)
120120+121121+val mark_all_as_read : Client.t -> unit
122122+(** Mark all messages as read.
123123+ @raise Eio.Io on failure *)
124124+125125+val mark_stream_as_read : Client.t -> stream_id:int -> unit
126126+(** Mark all messages in a stream as read.
127127+ @raise Eio.Io on failure *)
128128+129129+val mark_topic_as_read : Client.t -> stream_id:int -> topic:string -> unit
130130+(** Mark all messages in a topic as read.
131131+ @raise Eio.Io on failure *)
132132+133133+(** {1 Reactions} *)
134134+135135+(** Type of emoji for reactions. *)
136136+type emoji_type =
137137+ | Unicode_emoji (** Standard Unicode emoji *)
138138+ | Realm_emoji (** Custom organization emoji *)
139139+ | Zulip_extra_emoji (** Zulip-specific emoji *)
140140+141141+val add_reaction :
142142+ Client.t ->
143143+ message_id:int ->
144144+ emoji_name:string ->
145145+ ?emoji_code:string ->
146146+ ?reaction_type:emoji_type ->
147147+ unit ->
148148+ unit
149149+(** Add an emoji reaction to a message.
150150+151151+ @param emoji_name The emoji name (e.g., "thumbs_up", "heart")
152152+ @param emoji_code The emoji code (optional, required for realm emoji)
153153+ @param reaction_type The type of emoji (default: [Unicode_emoji])
154154+ @raise Eio.Io on failure
155155+156156+ {b Example:}
157157+ {[
158158+ Messages.add_reaction client ~message_id:12345 ~emoji_name:"thumbs_up" ()
159159+ ]} *)
160160+161161+val remove_reaction :
162162+ Client.t ->
163163+ message_id:int ->
164164+ emoji_name:string ->
165165+ ?emoji_code:string ->
166166+ ?reaction_type:emoji_type ->
167167+ unit ->
168168+ unit
169169+(** Remove an emoji reaction from a message.
170170+ @raise Eio.Io on failure *)
171171+172172+(** {1 Rendering} *)
173173+174174+val render : Client.t -> content:string -> string
175175+(** Render message content as HTML. Useful for previewing how a message will
176176+ appear.
177177+ @return The rendered HTML
178178+ @raise Eio.Io on failure *)
179179+180180+(** {1 File Uploads} *)
181181+182182+val upload_file : Client.t -> filename:string -> string
183183+(** Upload a file to Zulip.
184184+185185+ @param filename The path to the file to upload
186186+ @return The Zulip URL for the uploaded file
187187+ @raise Eio.Io on failure
188188+189189+ {b Example:}
190190+ {[
191191+ let uri = Messages.upload_file client ~filename:"/path/to/image.png" in
192192+ let msg =
193193+ Message.create ~type_:`Channel ~to_:[ "general" ]
194194+ ~content:("Check out this image: " ^ uri)
195195+ ()
196196+ in
197197+ Messages.send client msg
198198+ ]} *)
199199+200200+(** {1 Scheduled Messages} *)
201201+202202+val get_scheduled : Client.t -> Jsont.json
203203+(** Get all scheduled messages for the current user.
204204+ @raise Eio.Io on failure *)
205205+206206+val delete_scheduled : Client.t -> scheduled_message_id:int -> unit
207207+(** Delete a scheduled message.
208208+ @raise Eio.Io on failure *)
+121
lib/zulip/narrow.ml
···11+(*---------------------------------------------------------------------------
22+ Copyright (c) 2025 Anil Madhavapeddy <anil@recoil.org>. All rights reserved.
33+ SPDX-License-Identifier: ISC
44+ ---------------------------------------------------------------------------*)
55+66+type t = {
77+ operator : string;
88+ operand : [ `String of string | `Int of int | `Strings of string list ];
99+ negated : bool;
1010+}
1111+1212+let make ?(negated = false) operator operand = { operator; operand; negated }
1313+let stream name = make "stream" (`String name)
1414+let stream_id id = make "stream" (`Int id)
1515+let topic name = make "topic" (`String name)
1616+let channel = stream
1717+let sender email = make "sender" (`String email)
1818+let sender_id id = make "sender" (`Int id)
1919+2020+type is_operand =
2121+ [ `Alerted | `Dm | `Mentioned | `Private | `Resolved | `Starred | `Unread ]
2222+2323+let is_operand_to_string = function
2424+ | `Alerted -> "alerted"
2525+ | `Dm -> "dm"
2626+ | `Mentioned -> "mentioned"
2727+ | `Private -> "private"
2828+ | `Resolved -> "resolved"
2929+ | `Starred -> "starred"
3030+ | `Unread -> "unread"
3131+3232+let is operand = make "is" (`String (is_operand_to_string operand))
3333+3434+type has_operand = [ `Attachment | `Image | `Link | `Reaction ]
3535+3636+let has_operand_to_string = function
3737+ | `Attachment -> "attachment"
3838+ | `Image -> "image"
3939+ | `Link -> "link"
4040+ | `Reaction -> "reaction"
4141+4242+let has operand = make "has" (`String (has_operand_to_string operand))
4343+let search query = make "search" (`String query)
4444+let id msg_id = make "id" (`Int msg_id)
4545+let near msg_id = make "near" (`Int msg_id)
4646+let dm emails = make "dm" (`Strings emails)
4747+let dm_including email = make "dm-including" (`String email)
4848+let group_pm_with = dm_including
4949+let not_ filter = { filter with negated = true }
5050+5151+let to_json filters =
5252+ let meta = Jsont.Meta.none in
5353+ let make_string s = Jsont.String (s, meta) in
5454+ let make_member name value = ((name, meta), value) in
5555+ Jsont.Array
5656+ ( List.map
5757+ (fun f ->
5858+ let operand_json =
5959+ match f.operand with
6060+ | `String s -> make_string s
6161+ | `Int i -> Jsont.Number (float_of_int i, meta)
6262+ | `Strings ss -> Jsont.Array (List.map make_string ss, meta)
6363+ in
6464+ let fields =
6565+ [
6666+ make_member "operator" (make_string f.operator);
6767+ make_member "operand" operand_json;
6868+ ]
6969+ in
7070+ let fields =
7171+ if f.negated then
7272+ make_member "negated" (Jsont.Bool (true, meta)) :: fields
7373+ else fields
7474+ in
7575+ Jsont.Object (fields, meta))
7676+ filters,
7777+ meta )
7878+7979+let operand_to_json = function
8080+ | `String s -> Jsont.String (s, Jsont.Meta.none)
8181+ | `Int i -> Jsont.Number (float_of_int i, Jsont.Meta.none)
8282+ | `Strings ss ->
8383+ Jsont.Array
8484+ ( List.map (fun s -> Jsont.String (s, Jsont.Meta.none)) ss,
8585+ Jsont.Meta.none )
8686+8787+let operand_of_json = function
8888+ | Jsont.String (s, _) -> `String s
8989+ | Jsont.Number (f, _) -> `Int (int_of_float f)
9090+ | Jsont.Array (items, _) ->
9191+ `Strings
9292+ (List.filter_map
9393+ (function Jsont.String (s, _) -> Some s | _ -> None)
9494+ items)
9595+ | _ -> `String ""
9696+9797+let operand_jsont =
9898+ Jsont.json |> Jsont.map ~dec:operand_of_json ~enc:operand_to_json
9999+100100+let jsont =
101101+ let kind = "Narrow" in
102102+ let doc = "A narrow filter" in
103103+ let make operator operand negated = { operator; operand; negated } in
104104+ Jsont.Object.map ~kind ~doc make
105105+ |> Jsont.Object.mem "operator" Jsont.string ~enc:(fun t -> t.operator)
106106+ |> Jsont.Object.mem "operand" operand_jsont ~enc:(fun t -> t.operand)
107107+ |> Jsont.Object.mem "negated" Jsont.bool ~dec_absent:false ~enc:(fun t ->
108108+ t.negated)
109109+ |> Jsont.Object.finish
110110+111111+let list_jsont = Jsont.list jsont
112112+113113+let pp fmt t =
114114+ let neg = if t.negated then "-" else "" in
115115+ let operand =
116116+ match t.operand with
117117+ | `String s -> s
118118+ | `Int i -> string_of_int i
119119+ | `Strings ss -> String.concat "," ss
120120+ in
121121+ Format.fprintf fmt "%s%s:%s" neg t.operator operand
+113
lib/zulip/narrow.mli
···11+(*---------------------------------------------------------------------------
22+ Copyright (c) 2025 Anil Madhavapeddy <anil@recoil.org>. All rights reserved.
33+ SPDX-License-Identifier: ISC
44+ ---------------------------------------------------------------------------*)
55+66+(** Type-safe narrow filters for message queries.
77+88+ Narrow filters constrain which messages are returned by the
99+ [Messages.get_messages] endpoint. This module provides a type-safe interface
1010+ for constructing these filters.
1111+1212+ Example:
1313+ {[
1414+ let narrow = Narrow.[ stream "general"; topic "greetings"; is `Unread ] in
1515+ Messages.get_messages client ~narrow ()
1616+ ]} *)
1717+1818+(** {1 Filter Type} *)
1919+2020+type t
2121+(** A single narrow filter clause. *)
2222+2323+(** {1 Stream/Channel Filters} *)
2424+2525+val stream : string -> t
2626+(** [stream name] filters to messages in the given stream/channel. *)
2727+2828+val stream_id : int -> t
2929+(** [stream_id id] filters to messages in the stream with the given ID. *)
3030+3131+val topic : string -> t
3232+(** [topic name] filters to messages with the given topic/subject. *)
3333+3434+val channel : string -> t
3535+(** Alias for [stream]. *)
3636+3737+(** {1 Sender Filters} *)
3838+3939+val sender : string -> t
4040+(** [sender email] filters to messages from the given sender. *)
4141+4242+val sender_id : int -> t
4343+(** [sender_id id] filters to messages from the sender with the given user ID.
4444+*)
4545+4646+(** {1 Message Property Filters} *)
4747+4848+type is_operand =
4949+ [ `Alerted (** Messages containing alert words *)
5050+ | `Dm (** Direct messages (private messages) *)
5151+ | `Mentioned (** Messages where user is mentioned *)
5252+ | `Private (** Alias for [`Dm] *)
5353+ | `Resolved (** Topics marked as resolved *)
5454+ | `Starred (** Starred messages *)
5555+ | `Unread (** Unread messages *) ]
5656+5757+val is : is_operand -> t
5858+(** [is operand] filters by message property. *)
5959+6060+type has_operand =
6161+ [ `Attachment (** Messages with file attachments *)
6262+ | `Image (** Messages containing images *)
6363+ | `Link (** Messages containing links *)
6464+ | `Reaction (** Messages with emoji reactions *) ]
6565+6666+val has : has_operand -> t
6767+(** [has operand] filters to messages that have the given content type. *)
6868+6969+(** {1 Search} *)
7070+7171+val search : string -> t
7272+(** [search query] full-text search within messages. *)
7373+7474+(** {1 Message ID Filters} *)
7575+7676+val id : int -> t
7777+(** [id msg_id] filters to the specific message with the given ID. *)
7878+7979+val near : int -> t
8080+(** [near msg_id] centers results around the given message ID. *)
8181+8282+(** {1 Direct Message Filters} *)
8383+8484+val dm : string list -> t
8585+(** [dm emails] filters to direct messages with exactly these participants. *)
8686+8787+val dm_including : string -> t
8888+(** [dm_including email] filters to direct messages that include this user. *)
8989+9090+val group_pm_with : string -> t
9191+(** [group_pm_with email] filters to group DMs including this user (deprecated,
9292+ use [dm_including]). *)
9393+9494+(** {1 Negation} *)
9595+9696+val not_ : t -> t
9797+(** [not_ filter] negates a filter. Example: [not_ (stream "general")] excludes
9898+ the "general" stream. *)
9999+100100+(** {1 Encoding} *)
101101+102102+val to_json : t list -> Jsont.json
103103+(** Encode a list of filters to JSON for the API request. *)
104104+105105+val jsont : t Jsont.t
106106+(** Jsont codec for a single filter. *)
107107+108108+val list_jsont : t list Jsont.t
109109+(** Jsont codec for a list of filters. *)
110110+111111+(** {1 Pretty Printing} *)
112112+113113+val pp : Format.formatter -> t -> unit
+179
lib/zulip/presence.ml
···11+(*---------------------------------------------------------------------------
22+ Copyright (c) 2025 Anil Madhavapeddy <anil@recoil.org>. All rights reserved.
33+ SPDX-License-Identifier: ISC
44+ ---------------------------------------------------------------------------*)
55+66+type status = Active | Idle | Offline
77+88+type client_presence = {
99+ status : status;
1010+ timestamp : float;
1111+ client : string;
1212+ pushable : bool;
1313+}
1414+1515+type user_presence = {
1616+ aggregated : client_presence option;
1717+ clients : (string * client_presence) list;
1818+}
1919+2020+let status_to_string = function
2121+ | Active -> "active"
2222+ | Idle -> "idle"
2323+ | Offline -> "offline"
2424+2525+let status_of_string = function
2626+ | "active" -> Some Active
2727+ | "idle" -> Some Idle
2828+ | "offline" -> Some Offline
2929+ | _ -> None
3030+3131+let pp_status fmt s = Format.fprintf fmt "%s" (status_to_string s)
3232+3333+let pp_user_presence fmt p =
3434+ let agg =
3535+ Option.fold ~none:"none"
3636+ ~some:(fun c -> Printf.sprintf "%s" (status_to_string c.status))
3737+ p.aggregated
3838+ in
3939+ Format.fprintf fmt "UserPresence{aggregated=%s, clients=%d}" agg
4040+ (List.length p.clients)
4141+4242+let status_jsont =
4343+ let of_string s =
4444+ match status_of_string s with
4545+ | Some s -> Ok s
4646+ | None -> Error (Printf.sprintf "Unknown status: %s" s)
4747+ in
4848+ Jsont.of_of_string ~kind:"Presence.status" of_string ~enc:status_to_string
4949+5050+let client_presence_jsont =
5151+ Jsont.Object.(
5252+ map ~kind:"ClientPresence" (fun status timestamp client pushable ->
5353+ { status; timestamp; client; pushable })
5454+ |> mem "status" status_jsont ~enc:(fun p -> p.status)
5555+ |> mem "timestamp" Jsont.number ~enc:(fun p -> p.timestamp)
5656+ |> mem "client" Jsont.string ~dec_absent:"" ~enc:(fun p -> p.client)
5757+ |> mem "pushable" Jsont.bool ~dec_absent:false ~enc:(fun p -> p.pushable)
5858+ |> finish)
5959+6060+let parse_user_presence_from_json json =
6161+ match json with
6262+ | Jsont.Object (fields, _) ->
6363+ let assoc = List.map (fun ((k, _), v) -> (k, v)) fields in
6464+ let aggregated =
6565+ match List.assoc_opt "aggregated" assoc with
6666+ | Some agg_json ->
6767+ Encode.from_json client_presence_jsont agg_json |> Result.to_option
6868+ | None -> None
6969+ in
7070+ let clients =
7171+ List.filter_map
7272+ (fun (name, json) ->
7373+ if name = "aggregated" then None
7474+ else
7575+ match Encode.from_json client_presence_jsont json with
7676+ | Ok cp -> Some (name, cp)
7777+ | Error _ -> None)
7878+ assoc
7979+ in
8080+ { aggregated; clients }
8181+ | _ -> { aggregated = None; clients = [] }
8282+8383+let user_presence_jsont =
8484+ Jsont.map ~kind:"UserPresence" Jsont.json ~dec:parse_user_presence_from_json
8585+ ~enc:(fun p ->
8686+ let agg_field =
8787+ match p.aggregated with
8888+ | Some agg -> (
8989+ match Encode.to_json client_presence_jsont agg with
9090+ | Ok json -> [ (("aggregated", Jsont.Meta.none), json) ]
9191+ | Error _ -> [])
9292+ | None -> []
9393+ in
9494+ let client_fields =
9595+ List.filter_map
9696+ (fun (name, cp) ->
9797+ match Encode.to_json client_presence_jsont cp with
9898+ | Ok json -> Some ((name, Jsont.Meta.none), json)
9999+ | Error _ -> None)
100100+ p.clients
101101+ in
102102+ Jsont.Object (agg_field @ client_fields, Jsont.Meta.none))
103103+104104+let parse_presence_response json =
105105+ match json with
106106+ | Jsont.Object (fields, _) -> (
107107+ let assoc = List.map (fun ((k, _), v) -> (k, v)) fields in
108108+ match List.assoc_opt "presence" assoc with
109109+ | Some pres -> (
110110+ match Encode.from_json user_presence_jsont pres with
111111+ | Ok p -> p
112112+ | Error msg ->
113113+ Error.raise_with_context
114114+ (Error.make ~code:(Other "json_parse") ~message:msg ())
115115+ "parsing presence")
116116+ | None ->
117117+ Error.raise_with_context
118118+ (Error.make ~code:(Other "missing_field")
119119+ ~message:"Missing presence field" ())
120120+ "parsing presence response")
121121+ | _ ->
122122+ Error.raise_with_context
123123+ (Error.make ~code:(Other "json_parse")
124124+ ~message:"Expected JSON object for presence" ())
125125+ "parsing presence response"
126126+127127+let get_user client ~user_id =
128128+ let json =
129129+ Client.request client ~method_:`GET
130130+ ~path:("/api/v1/users/" ^ string_of_int user_id ^ "/presence")
131131+ ()
132132+ in
133133+ parse_presence_response json
134134+135135+let get_user_by_email client ~email =
136136+ let json =
137137+ Client.request client ~method_:`GET
138138+ ~path:("/api/v1/users/" ^ email ^ "/presence")
139139+ ()
140140+ in
141141+ parse_presence_response json
142142+143143+let get_all client =
144144+ let json =
145145+ Client.request client ~method_:`GET ~path:"/api/v1/realm/presence" ()
146146+ in
147147+ match json with
148148+ | Jsont.Object (fields, _) -> (
149149+ let assoc = List.map (fun ((k, _), v) -> (k, v)) fields in
150150+ match List.assoc_opt "presences" assoc with
151151+ | Some (Jsont.Object (pres_fields, _)) ->
152152+ List.filter_map
153153+ (fun ((user_id_str, _), pres_json) ->
154154+ match int_of_string_opt user_id_str with
155155+ | Some user_id -> (
156156+ match Encode.from_json user_presence_jsont pres_json with
157157+ | Ok p -> Some (user_id, p)
158158+ | Error _ -> None)
159159+ | None -> None)
160160+ pres_fields
161161+ | _ -> [])
162162+ | _ -> []
163163+164164+let update client ~status ?ping_only ?new_user_input () =
165165+ let params =
166166+ [ ("status", status_to_string status) ]
167167+ @ List.filter_map Fun.id
168168+ [
169169+ Option.map (fun v -> ("ping_only", string_of_bool v)) ping_only;
170170+ Option.map
171171+ (fun v -> ("new_user_input", string_of_bool v))
172172+ new_user_input;
173173+ ]
174174+ in
175175+ let _response =
176176+ Client.request client ~method_:`POST ~path:"/api/v1/users/me/presence"
177177+ ~params ()
178178+ in
179179+ ()
+77
lib/zulip/presence.mli
···11+(*---------------------------------------------------------------------------
22+ Copyright (c) 2025 Anil Madhavapeddy <anil@recoil.org>. All rights reserved.
33+ SPDX-License-Identifier: ISC
44+ ---------------------------------------------------------------------------*)
55+66+(** User presence information for the Zulip API.
77+88+ Track online/offline status of users in the organization. *)
99+1010+(** {1 Presence Types} *)
1111+1212+type status =
1313+ | Active (** User is currently active *)
1414+ | Idle (** User is idle *)
1515+ | Offline (** User is offline *)
1616+1717+type client_presence = {
1818+ status : status;
1919+ timestamp : float;
2020+ client : string; (** Client name (e.g., "website", "ZulipMobile") *)
2121+ pushable : bool; (** Whether push notifications can be sent *)
2222+}
2323+(** Presence information from a single client. *)
2424+2525+type user_presence = {
2626+ aggregated : client_presence option;
2727+ (** Aggregated presence across clients *)
2828+ clients : (string * client_presence) list; (** Per-client presence *)
2929+}
3030+(** A user's presence information. *)
3131+3232+(** {1 Querying Presence} *)
3333+3434+val get_user : Client.t -> user_id:int -> user_presence
3535+(** Get presence information for a specific user.
3636+ @raise Eio.Io on failure *)
3737+3838+val get_user_by_email : Client.t -> email:string -> user_presence
3939+(** Get presence information for a user by email.
4040+ @raise Eio.Io on failure *)
4141+4242+val get_all : Client.t -> (int * user_presence) list
4343+(** Get presence information for all users in the organization. Returns a list
4444+ of (user_id, presence) pairs.
4545+ @raise Eio.Io on failure *)
4646+4747+(** {1 Updating Presence} *)
4848+4949+val update :
5050+ Client.t ->
5151+ status:status ->
5252+ ?ping_only:bool ->
5353+ ?new_user_input:bool ->
5454+ unit ->
5555+ unit
5656+(** Update the current user's presence status.
5757+5858+ @param status The presence status to set
5959+ @param ping_only Only send a ping without changing status
6060+ @param new_user_input Whether there was new user input
6161+ @raise Eio.Io on failure *)
6262+6363+(** {1 JSON Codecs} *)
6464+6565+val status_jsont : status Jsont.t
6666+val client_presence_jsont : client_presence Jsont.t
6767+val user_presence_jsont : user_presence Jsont.t
6868+6969+(** {1 Conversion} *)
7070+7171+val status_to_string : status -> string
7272+val status_of_string : string -> status option
7373+7474+(** {1 Pretty Printing} *)
7575+7676+val pp_status : Format.formatter -> status -> unit
7777+val pp_user_presence : Format.formatter -> user_presence -> unit
+448
lib/zulip/server.ml
···11+(*---------------------------------------------------------------------------
22+ Copyright (c) 2025 Anil Madhavapeddy <anil@recoil.org>. All rights reserved.
33+ SPDX-License-Identifier: ISC
44+ ---------------------------------------------------------------------------*)
55+66+type authentication_method = {
77+ password : bool;
88+ dev : bool;
99+ email : bool;
1010+ ldap : bool;
1111+ remoteuser : bool;
1212+ github : bool;
1313+ azuread : bool;
1414+ gitlab : bool;
1515+ apple : bool;
1616+ google : bool;
1717+ saml : bool;
1818+ openid_connect : bool;
1919+}
2020+2121+type emoji = {
2222+ id : string;
2323+ name : string;
2424+ source_url : string;
2525+ deactivated : bool;
2626+ author_id : int option;
2727+}
2828+2929+(* Define types with conflicting fields after types they might conflict with *)
3030+type external_authentication_method = {
3131+ name : string;
3232+ display_name : string;
3333+ display_icon : string option;
3434+ login_url : string;
3535+ signup_url : string;
3636+}
3737+3838+type linkifier = { id : int; pattern : string; url_template : string }
3939+4040+type t = {
4141+ zulip_version : string;
4242+ zulip_feature_level : int;
4343+ zulip_merge_base : string option;
4444+ push_notifications_enabled : bool;
4545+ is_incompatible : bool;
4646+ email_auth_enabled : bool;
4747+ require_email_format_usernames : bool;
4848+ realm_uri : string;
4949+ realm_name : string;
5050+ realm_icon : string;
5151+ realm_description : string;
5252+ realm_web_public_access_enabled : bool;
5353+ authentication_methods : authentication_method;
5454+ external_authentication_methods : external_authentication_method list;
5555+}
5656+5757+(* Codecs for authentication methods *)
5858+let authentication_method_jsont =
5959+ Jsont.Object.(
6060+ map ~kind:"AuthenticationMethod"
6161+ (fun
6262+ password
6363+ dev
6464+ email
6565+ ldap
6666+ remoteuser
6767+ github
6868+ azuread
6969+ gitlab
7070+ apple
7171+ google
7272+ saml
7373+ openid_connect
7474+ ->
7575+ {
7676+ password;
7777+ dev;
7878+ email;
7979+ ldap;
8080+ remoteuser;
8181+ github;
8282+ azuread;
8383+ gitlab;
8484+ apple;
8585+ google;
8686+ saml;
8787+ openid_connect;
8888+ })
8989+ |> mem "Password" Jsont.bool ~dec_absent:false ~enc:(fun a -> a.password)
9090+ |> mem "Dev" Jsont.bool ~dec_absent:false ~enc:(fun a -> a.dev)
9191+ |> mem "Email" Jsont.bool ~dec_absent:false ~enc:(fun a -> a.email)
9292+ |> mem "LDAP" Jsont.bool ~dec_absent:false ~enc:(fun a -> a.ldap)
9393+ |> mem "RemoteUser" Jsont.bool ~dec_absent:false ~enc:(fun a ->
9494+ a.remoteuser)
9595+ |> mem "GitHub" Jsont.bool ~dec_absent:false ~enc:(fun a -> a.github)
9696+ |> mem "AzureAD" Jsont.bool ~dec_absent:false ~enc:(fun a -> a.azuread)
9797+ |> mem "GitLab" Jsont.bool ~dec_absent:false ~enc:(fun a -> a.gitlab)
9898+ |> mem "Apple" Jsont.bool ~dec_absent:false ~enc:(fun a -> a.apple)
9999+ |> mem "Google" Jsont.bool ~dec_absent:false ~enc:(fun a -> a.google)
100100+ |> mem "SAML" Jsont.bool ~dec_absent:false ~enc:(fun a -> a.saml)
101101+ |> mem "OpenID Connect" Jsont.bool ~dec_absent:false ~enc:(fun a ->
102102+ a.openid_connect)
103103+ |> finish)
104104+105105+let external_authentication_method_jsont =
106106+ Jsont.Object.(
107107+ map ~kind:"ExternalAuthenticationMethod"
108108+ (fun name display_name display_icon login_url signup_url ->
109109+ { name; display_name; display_icon; login_url; signup_url })
110110+ |> mem "name" Jsont.string ~enc:(fun e -> e.name)
111111+ |> mem "display_name" Jsont.string ~enc:(fun e -> e.display_name)
112112+ |> opt_mem "display_icon" Jsont.string ~enc:(fun e -> e.display_icon)
113113+ |> mem "login_url" Jsont.string ~enc:(fun e -> e.login_url)
114114+ |> mem "signup_url" Jsont.string ~enc:(fun e -> e.signup_url)
115115+ |> finish)
116116+117117+let jsont =
118118+ Jsont.Object.(
119119+ map ~kind:"ServerSettings"
120120+ (fun
121121+ zulip_version
122122+ zulip_feature_level
123123+ zulip_merge_base
124124+ push_notifications_enabled
125125+ is_incompatible
126126+ email_auth_enabled
127127+ require_email_format_usernames
128128+ realm_uri
129129+ realm_name
130130+ realm_icon
131131+ realm_description
132132+ realm_web_public_access_enabled
133133+ authentication_methods
134134+ external_authentication_methods
135135+ ->
136136+ {
137137+ zulip_version;
138138+ zulip_feature_level;
139139+ zulip_merge_base;
140140+ push_notifications_enabled;
141141+ is_incompatible;
142142+ email_auth_enabled;
143143+ require_email_format_usernames;
144144+ realm_uri;
145145+ realm_name;
146146+ realm_icon;
147147+ realm_description;
148148+ realm_web_public_access_enabled;
149149+ authentication_methods;
150150+ external_authentication_methods;
151151+ })
152152+ |> mem "zulip_version" Jsont.string ~enc:(fun s -> s.zulip_version)
153153+ |> mem "zulip_feature_level" Jsont.int ~enc:(fun s -> s.zulip_feature_level)
154154+ |> opt_mem "zulip_merge_base" Jsont.string ~enc:(fun s ->
155155+ s.zulip_merge_base)
156156+ |> mem "push_notifications_enabled" Jsont.bool ~dec_absent:false
157157+ ~enc:(fun s -> s.push_notifications_enabled)
158158+ |> mem "is_incompatible" Jsont.bool ~dec_absent:false ~enc:(fun s ->
159159+ s.is_incompatible)
160160+ |> mem "email_auth_enabled" Jsont.bool ~dec_absent:true ~enc:(fun s ->
161161+ s.email_auth_enabled)
162162+ |> mem "require_email_format_usernames" Jsont.bool ~dec_absent:true
163163+ ~enc:(fun s -> s.require_email_format_usernames)
164164+ |> mem "realm_uri" Jsont.string ~enc:(fun s -> s.realm_uri)
165165+ |> mem "realm_name" Jsont.string ~dec_absent:"" ~enc:(fun s -> s.realm_name)
166166+ |> mem "realm_icon" Jsont.string ~dec_absent:"" ~enc:(fun s -> s.realm_icon)
167167+ |> mem "realm_description" Jsont.string ~dec_absent:"" ~enc:(fun s ->
168168+ s.realm_description)
169169+ |> mem "realm_web_public_access_enabled" Jsont.bool ~dec_absent:false
170170+ ~enc:(fun s -> s.realm_web_public_access_enabled)
171171+ |> mem "authentication_methods" authentication_method_jsont ~enc:(fun s ->
172172+ s.authentication_methods)
173173+ |> mem "external_authentication_methods"
174174+ (Jsont.list external_authentication_method_jsont) ~dec_absent:[]
175175+ ~enc:(fun s -> s.external_authentication_methods)
176176+ |> finish)
177177+178178+let get_settings_json client =
179179+ Client.request client ~method_:`GET ~path:"/api/v1/server_settings" ()
180180+181181+let get_settings client =
182182+ let json = get_settings_json client in
183183+ Error.decode_or_raise jsont json "parsing server settings"
184184+185185+let feature_level client =
186186+ let settings = get_settings client in
187187+ settings.zulip_feature_level
188188+189189+let supports_feature client ~level = feature_level client >= level
190190+191191+(* Linkifier codec *)
192192+let linkifier_jsont =
193193+ Jsont.Object.(
194194+ map ~kind:"Linkifier" (fun id pattern url_template ->
195195+ { id; pattern; url_template })
196196+ |> mem "id" Jsont.int ~enc:(fun l -> l.id)
197197+ |> mem "pattern" Jsont.string ~enc:(fun l -> l.pattern)
198198+ |> mem "url_template" Jsont.string ~enc:(fun l -> l.url_template)
199199+ |> finish)
200200+201201+let get_linkifiers client =
202202+ let response_codec =
203203+ Jsont.Object.(
204204+ map ~kind:"LinkifiersResponse" Fun.id
205205+ |> mem "linkifiers" (Jsont.list linkifier_jsont) ~enc:Fun.id
206206+ |> finish)
207207+ in
208208+ let json =
209209+ Client.request client ~method_:`GET ~path:"/api/v1/realm/linkifiers" ()
210210+ in
211211+ Error.decode_or_raise response_codec json "getting linkifiers"
212212+213213+let add_linkifier client ~pattern ~url_template =
214214+ let params = [ ("pattern", pattern); ("url_template", url_template) ] in
215215+ let response_codec =
216216+ Jsont.Object.(
217217+ map ~kind:"AddLinkifierResponse" Fun.id
218218+ |> mem "id" Jsont.int ~enc:Fun.id
219219+ |> finish)
220220+ in
221221+ let json =
222222+ Client.request client ~method_:`POST ~path:"/api/v1/realm/filters" ~params
223223+ ()
224224+ in
225225+ Error.decode_or_raise response_codec json "adding linkifier"
226226+227227+let update_linkifier client ~filter_id ~pattern ~url_template =
228228+ let params = [ ("pattern", pattern); ("url_template", url_template) ] in
229229+ let _response =
230230+ Client.request client ~method_:`PATCH
231231+ ~path:("/api/v1/realm/filters/" ^ string_of_int filter_id)
232232+ ~params ()
233233+ in
234234+ ()
235235+236236+let delete_linkifier client ~filter_id =
237237+ let _response =
238238+ Client.request client ~method_:`DELETE
239239+ ~path:("/api/v1/realm/filters/" ^ string_of_int filter_id)
240240+ ()
241241+ in
242242+ ()
243243+244244+(* Emoji codec *)
245245+let emoji_jsont : emoji Jsont.t =
246246+ Jsont.Object.(
247247+ map ~kind:"Emoji" (fun id name source_url deactivated author_id : emoji ->
248248+ { id; name; source_url; deactivated; author_id })
249249+ |> mem "id" Jsont.string ~enc:(fun (e : emoji) -> e.id)
250250+ |> mem "name" Jsont.string ~enc:(fun (e : emoji) -> e.name)
251251+ |> mem "source_url" Jsont.string ~enc:(fun (e : emoji) -> e.source_url)
252252+ |> mem "deactivated" Jsont.bool ~dec_absent:false ~enc:(fun (e : emoji) ->
253253+ e.deactivated)
254254+ |> opt_mem "author_id" Jsont.int ~enc:(fun (e : emoji) -> e.author_id)
255255+ |> finish)
256256+257257+let get_emoji client =
258258+ let json =
259259+ Client.request client ~method_:`GET ~path:"/api/v1/realm/emoji" ()
260260+ in
261261+ match json with
262262+ | Jsont.Object (fields, _) -> (
263263+ let assoc = List.map (fun ((k, _), v) -> (k, v)) fields in
264264+ match List.assoc_opt "emoji" assoc with
265265+ | Some (Jsont.Object (emoji_fields, _)) ->
266266+ List.filter_map
267267+ (fun ((name, _), emoji_json) ->
268268+ (* Add name to the emoji JSON before parsing *)
269269+ let emoji_with_name =
270270+ match emoji_json with
271271+ | Jsont.Object (e_fields, meta) ->
272272+ let name_field =
273273+ ( ("name", Jsont.Meta.none),
274274+ Jsont.String (name, Jsont.Meta.none) )
275275+ in
276276+ Jsont.Object (name_field :: e_fields, meta)
277277+ | _ -> emoji_json
278278+ in
279279+ match Encode.from_json emoji_jsont emoji_with_name with
280280+ | Ok e -> Some e
281281+ | Error _ -> None)
282282+ emoji_fields
283283+ | _ -> [])
284284+ | _ -> []
285285+286286+let upload_emoji _client ~name:_ ~filename:_ =
287287+ (* TODO: Implement file upload using multipart/form-data *)
288288+ Error.raise
289289+ (Error.make ~code:(Other "not_implemented")
290290+ ~message:"Emoji upload not yet implemented" ())
291291+292292+let deactivate_emoji client ~name =
293293+ let _response =
294294+ Client.request client ~method_:`DELETE
295295+ ~path:("/api/v1/realm/emoji/" ^ name)
296296+ ()
297297+ in
298298+ ()
299299+300300+type profile_field_type =
301301+ | Short_text
302302+ | Long_text
303303+ | Choice
304304+ | Date
305305+ | Link
306306+ | User
307307+ | External_account
308308+ | Pronouns
309309+310310+type profile_field = {
311311+ id : int;
312312+ field_type : profile_field_type;
313313+ order : int;
314314+ name : string;
315315+ hint : string;
316316+ field_data : Jsont.json;
317317+ display_in_profile_summary : bool option;
318318+}
319319+320320+(* Profile field type codec *)
321321+let profile_field_type_to_int = function
322322+ | Short_text -> 1
323323+ | Long_text -> 2
324324+ | Choice -> 3
325325+ | Date -> 4
326326+ | Link -> 5
327327+ | User -> 6
328328+ | External_account -> 7
329329+ | Pronouns -> 8
330330+331331+let profile_field_type_of_int = function
332332+ | 1 -> Some Short_text
333333+ | 2 -> Some Long_text
334334+ | 3 -> Some Choice
335335+ | 4 -> Some Date
336336+ | 5 -> Some Link
337337+ | 6 -> Some User
338338+ | 7 -> Some External_account
339339+ | 8 -> Some Pronouns
340340+ | _ -> None
341341+342342+let profile_field_type_jsont =
343343+ Jsont.map ~kind:"ProfileFieldType" Jsont.int
344344+ ~dec:(fun i ->
345345+ match profile_field_type_of_int i with Some t -> t | None -> Short_text)
346346+ ~enc:profile_field_type_to_int
347347+348348+let profile_field_jsont =
349349+ Jsont.Object.(
350350+ map ~kind:"ProfileField"
351351+ (fun
352352+ id field_type order name hint field_data display_in_profile_summary ->
353353+ {
354354+ id;
355355+ field_type;
356356+ order;
357357+ name;
358358+ hint;
359359+ field_data;
360360+ display_in_profile_summary;
361361+ })
362362+ |> mem "id" Jsont.int ~enc:(fun p -> p.id)
363363+ |> mem "type" profile_field_type_jsont ~enc:(fun p -> p.field_type)
364364+ |> mem "order" Jsont.int ~enc:(fun p -> p.order)
365365+ |> mem "name" Jsont.string ~enc:(fun p -> p.name)
366366+ |> mem "hint" Jsont.string ~dec_absent:"" ~enc:(fun p -> p.hint)
367367+ |> mem "field_data" Jsont.json
368368+ ~dec_absent:(Jsont.Null ((), Jsont.Meta.none))
369369+ ~enc:(fun p -> p.field_data)
370370+ |> opt_mem "display_in_profile_summary" Jsont.bool ~enc:(fun p ->
371371+ p.display_in_profile_summary)
372372+ |> finish)
373373+374374+let get_profile_fields client =
375375+ let response_codec =
376376+ Jsont.Object.(
377377+ map ~kind:"ProfileFieldsResponse" Fun.id
378378+ |> mem "custom_profile_fields"
379379+ (Jsont.list profile_field_jsont)
380380+ ~enc:Fun.id
381381+ |> finish)
382382+ in
383383+ let json =
384384+ Client.request client ~method_:`GET ~path:"/api/v1/realm/profile_fields" ()
385385+ in
386386+ Error.decode_or_raise response_codec json "getting profile fields"
387387+388388+let create_profile_field client ~field_type ~name ?hint ?field_data () =
389389+ let params =
390390+ [
391391+ ("field_type", string_of_int (profile_field_type_to_int field_type));
392392+ ("name", name);
393393+ ]
394394+ @ List.filter_map Fun.id
395395+ [
396396+ Option.map (fun h -> ("hint", h)) hint;
397397+ Option.map
398398+ (fun d -> ("field_data", Encode.to_json_string Jsont.json d))
399399+ field_data;
400400+ ]
401401+ in
402402+ let response_codec =
403403+ Jsont.Object.(
404404+ map ~kind:"CreateProfileFieldResponse" Fun.id
405405+ |> mem "id" Jsont.int ~enc:Fun.id
406406+ |> finish)
407407+ in
408408+ let json =
409409+ Client.request client ~method_:`POST ~path:"/api/v1/realm/profile_fields"
410410+ ~params ()
411411+ in
412412+ Error.decode_or_raise response_codec json "creating profile field"
413413+414414+let update_profile_field client ~field_id ?name ?hint ?field_data () =
415415+ let params =
416416+ List.filter_map Fun.id
417417+ [
418418+ Option.map (fun n -> ("name", n)) name;
419419+ Option.map (fun h -> ("hint", h)) hint;
420420+ Option.map
421421+ (fun d -> ("field_data", Encode.to_json_string Jsont.json d))
422422+ field_data;
423423+ ]
424424+ in
425425+ let _response =
426426+ Client.request client ~method_:`PATCH
427427+ ~path:("/api/v1/realm/profile_fields/" ^ string_of_int field_id)
428428+ ~params ()
429429+ in
430430+ ()
431431+432432+let delete_profile_field client ~field_id =
433433+ let _response =
434434+ Client.request client ~method_:`DELETE
435435+ ~path:("/api/v1/realm/profile_fields/" ^ string_of_int field_id)
436436+ ()
437437+ in
438438+ ()
439439+440440+let reorder_profile_fields client ~order =
441441+ let params =
442442+ [ ("order", Encode.to_json_string (Jsont.list Jsont.int) order) ]
443443+ in
444444+ let _response =
445445+ Client.request client ~method_:`PATCH ~path:"/api/v1/realm/profile_fields"
446446+ ~params ()
447447+ in
448448+ ()
+180
lib/zulip/server.mli
···11+(*---------------------------------------------------------------------------
22+ Copyright (c) 2025 Anil Madhavapeddy <anil@recoil.org>. All rights reserved.
33+ SPDX-License-Identifier: ISC
44+ ---------------------------------------------------------------------------*)
55+66+(** Server information and settings for the Zulip API.
77+88+ This module provides access to server-level information including version,
99+ feature level, and authentication methods. *)
1010+1111+(** {1 Server Settings} *)
1212+1313+type authentication_method = {
1414+ password : bool; (** Password authentication enabled *)
1515+ dev : bool; (** Development authentication enabled *)
1616+ email : bool; (** Email authentication enabled *)
1717+ ldap : bool; (** LDAP authentication enabled *)
1818+ remoteuser : bool; (** Remote user authentication enabled *)
1919+ github : bool; (** GitHub OAuth enabled *)
2020+ azuread : bool; (** Azure AD OAuth enabled *)
2121+ gitlab : bool; (** GitLab OAuth enabled *)
2222+ apple : bool; (** Apple OAuth enabled *)
2323+ google : bool; (** Google OAuth enabled *)
2424+ saml : bool; (** SAML SSO enabled *)
2525+ openid_connect : bool; (** OpenID Connect enabled *)
2626+}
2727+(** Enabled authentication methods on the server. *)
2828+2929+type external_authentication_method = {
3030+ name : string; (** Method name *)
3131+ display_name : string; (** Display name for UI *)
3232+ display_icon : string option; (** Icon URL *)
3333+ login_url : string; (** Login URL *)
3434+ signup_url : string; (** Signup URL *)
3535+}
3636+(** External authentication method configuration. *)
3737+3838+type t = {
3939+ zulip_version : string; (** Server version string *)
4040+ zulip_feature_level : int; (** API feature level *)
4141+ zulip_merge_base : string option; (** Git merge base (for dev servers) *)
4242+ push_notifications_enabled : bool; (** Push notifications available *)
4343+ is_incompatible : bool; (** Client incompatible with server *)
4444+ email_auth_enabled : bool; (** Email auth enabled *)
4545+ require_email_format_usernames : bool; (** Usernames must be emails *)
4646+ realm_uri : string; (** Organization URL *)
4747+ realm_name : string; (** Organization name *)
4848+ realm_icon : string; (** Organization icon URL *)
4949+ realm_description : string; (** Organization description *)
5050+ realm_web_public_access_enabled : bool; (** Web public access enabled *)
5151+ authentication_methods : authentication_method;
5252+ external_authentication_methods : external_authentication_method list;
5353+}
5454+(** Server settings response. *)
5555+5656+val get_settings : Client.t -> t
5757+(** Get server settings.
5858+ @raise Eio.Io on failure *)
5959+6060+val get_settings_json : Client.t -> Jsont.json
6161+(** Get server settings as raw JSON.
6262+ @raise Eio.Io on failure *)
6363+6464+(** {1 Feature Level Checks} *)
6565+6666+val feature_level : Client.t -> int
6767+(** Get the server's feature level. Useful for checking API compatibility.
6868+ @raise Eio.Io on failure *)
6969+7070+val supports_feature : Client.t -> level:int -> bool
7171+(** Check if the server supports a given feature level.
7272+ @raise Eio.Io on failure *)
7373+7474+(** {1 Linkifiers} *)
7575+7676+type linkifier = {
7777+ id : int; (** Linkifier ID *)
7878+ pattern : string; (** Regex pattern *)
7979+ url_template : string; (** URL template *)
8080+}
8181+(** A realm linkifier (auto-link rule). *)
8282+8383+val get_linkifiers : Client.t -> linkifier list
8484+(** Get all linkifiers for the organization.
8585+ @raise Eio.Io on failure *)
8686+8787+val add_linkifier : Client.t -> pattern:string -> url_template:string -> int
8888+(** Add a new linkifier.
8989+ @return The ID of the created linkifier
9090+ @raise Eio.Io on failure *)
9191+9292+val update_linkifier :
9393+ Client.t -> filter_id:int -> pattern:string -> url_template:string -> unit
9494+(** Update an existing linkifier.
9595+ @raise Eio.Io on failure *)
9696+9797+val delete_linkifier : Client.t -> filter_id:int -> unit
9898+(** Delete a linkifier.
9999+ @raise Eio.Io on failure *)
100100+101101+(** {1 Custom Emoji} *)
102102+103103+type emoji = {
104104+ id : string; (** Emoji ID *)
105105+ name : string; (** Emoji name *)
106106+ source_url : string; (** Source image URL *)
107107+ deactivated : bool; (** Whether emoji is deactivated *)
108108+ author_id : int option; (** User ID of uploader *)
109109+}
110110+(** A custom realm emoji. *)
111111+112112+val get_emoji : Client.t -> emoji list
113113+(** Get all custom emoji for the organization.
114114+ @raise Eio.Io on failure *)
115115+116116+val upload_emoji : Client.t -> name:string -> filename:string -> unit
117117+(** Upload a new custom emoji.
118118+ @raise Eio.Io on failure *)
119119+120120+val deactivate_emoji : Client.t -> name:string -> unit
121121+(** Deactivate a custom emoji.
122122+ @raise Eio.Io on failure *)
123123+124124+(** {1 Profile Fields} *)
125125+126126+type profile_field_type =
127127+ | Short_text (** Single line text *)
128128+ | Long_text (** Multi-line text *)
129129+ | Choice (** Select from options *)
130130+ | Date (** Date picker *)
131131+ | Link (** URL *)
132132+ | User (** User reference *)
133133+ | External_account (** External account link *)
134134+ | Pronouns (** Pronouns field *)
135135+136136+type profile_field = {
137137+ id : int;
138138+ field_type : profile_field_type;
139139+ order : int;
140140+ name : string;
141141+ hint : string;
142142+ field_data : Jsont.json;
143143+ display_in_profile_summary : bool option;
144144+}
145145+(** A custom profile field definition. *)
146146+147147+val get_profile_fields : Client.t -> profile_field list
148148+(** Get all custom profile fields.
149149+ @raise Eio.Io on failure *)
150150+151151+val create_profile_field :
152152+ Client.t ->
153153+ field_type:profile_field_type ->
154154+ name:string ->
155155+ ?hint:string ->
156156+ ?field_data:Jsont.json ->
157157+ unit ->
158158+ int
159159+(** Create a new custom profile field.
160160+ @return The ID of the created field
161161+ @raise Eio.Io on failure *)
162162+163163+val update_profile_field :
164164+ Client.t ->
165165+ field_id:int ->
166166+ ?name:string ->
167167+ ?hint:string ->
168168+ ?field_data:Jsont.json ->
169169+ unit ->
170170+ unit
171171+(** Update a custom profile field.
172172+ @raise Eio.Io on failure *)
173173+174174+val delete_profile_field : Client.t -> field_id:int -> unit
175175+(** Delete a custom profile field.
176176+ @raise Eio.Io on failure *)
177177+178178+val reorder_profile_fields : Client.t -> order:int list -> unit
179179+(** Reorder custom profile fields.
180180+ @raise Eio.Io on failure *)
···11+(*---------------------------------------------------------------------------
22+ Copyright (c) 2025 Anil Madhavapeddy <anil@recoil.org>. All rights reserved.
33+ SPDX-License-Identifier: ISC
44+ ---------------------------------------------------------------------------*)
55+66+(** Typing notifications for the Zulip API.
77+88+ Send typing start/stop notifications to indicate that the user is composing
99+ a message. *)
1010+1111+(** {1 Typing Status Operations} *)
1212+1313+type op = Start (** User started typing *) | Stop (** User stopped typing *)
1414+1515+(** {1 Direct Messages} *)
1616+1717+val set_dm : Client.t -> op:op -> user_ids:int list -> unit
1818+(** Set typing status for a direct message conversation.
1919+2020+ @param op Whether typing has started or stopped
2121+ @param user_ids List of user IDs in the conversation
2222+ @raise Eio.Io on failure *)
2323+2424+(** {1 Channel Messages} *)
2525+2626+val set_channel : Client.t -> op:op -> stream_id:int -> topic:string -> unit
2727+(** Set typing status in a channel topic.
2828+2929+ @param op Whether typing has started or stopped
3030+ @param stream_id The channel's stream ID
3131+ @param topic The topic name
3232+ @raise Eio.Io on failure *)
3333+3434+(** {1 Legacy API} *)
3535+3636+val set :
3737+ Client.t ->
3838+ op:op ->
3939+ to_:[ `User_ids of int list | `Stream of int * string ] ->
4040+ unit
4141+(** Set typing status (unified interface).
4242+4343+ @param op Whether typing has started or stopped
4444+ @param to_ Either user IDs for DM or (stream_id, topic) for channel
4545+ @raise Eio.Io on failure *)
···11+(*---------------------------------------------------------------------------
22+ Copyright (c) 2025 Anil Madhavapeddy <anil@recoil.org>. All rights reserved.
33+ SPDX-License-Identifier: ISC
44+ ---------------------------------------------------------------------------*)
55+66+(** Zulip user records.
77+88+ This module represents user information from the Zulip API. Use {!jsont}
99+ with Bytesrw-eio for wire serialization. *)
1010+1111+(** {1 User Type} *)
1212+1313+type t
1414+(** A Zulip user. *)
1515+1616+(** {1 Construction} *)
1717+1818+val create :
1919+ email:string ->
2020+ full_name:string ->
2121+ ?user_id:int ->
2222+ ?delivery_email:string ->
2323+ ?is_active:bool ->
2424+ ?is_admin:bool ->
2525+ ?is_owner:bool ->
2626+ ?is_guest:bool ->
2727+ ?is_billing_admin:bool ->
2828+ ?is_bot:bool ->
2929+ ?bot_type:int ->
3030+ ?bot_owner_id:int ->
3131+ ?avatar_url:string ->
3232+ ?avatar_version:int ->
3333+ ?timezone:string ->
3434+ ?date_joined:string ->
3535+ ?role:int ->
3636+ unit ->
3737+ t
3838+3939+(** {1 Accessors} *)
4040+4141+val email : t -> string
4242+(** User's email address (may be delivery_email if visible). *)
4343+4444+val full_name : t -> string
4545+(** User's full display name. *)
4646+4747+val user_id : t -> int option
4848+(** Server-assigned user ID. *)
4949+5050+val delivery_email : t -> string option
5151+(** User's actual email (if visible to current user). *)
5252+5353+val is_active : t -> bool
5454+(** Whether the user account is active. *)
5555+5656+val is_admin : t -> bool
5757+(** Whether the user is an organization admin. *)
5858+5959+val is_owner : t -> bool
6060+(** Whether the user is an organization owner. *)
6161+6262+val is_guest : t -> bool
6363+(** Whether the user is a guest. *)
6464+6565+val is_billing_admin : t -> bool
6666+(** Whether the user is a billing admin. *)
6767+6868+val is_bot : t -> bool
6969+(** Whether the user is a bot. *)
7070+7171+val bot_type : t -> int option
7272+(** Bot type (1=generic, 2=incoming webhook, 3=outgoing webhook, 4=embedded). *)
7373+7474+val bot_owner_id : t -> int option
7575+(** User ID of the bot's owner. *)
7676+7777+val avatar_url : t -> string option
7878+(** URL for the user's avatar. *)
7979+8080+val avatar_version : t -> int option
8181+(** Version number of the avatar (for cache busting). *)
8282+8383+val timezone : t -> string option
8484+(** User's timezone string. *)
8585+8686+val date_joined : t -> string option
8787+(** ISO 8601 datetime when user joined. *)
8888+8989+val role : t -> int option
9090+(** User's role (100=owner, 200=admin, 300=moderator, 400=member, 600=guest). *)
9191+9292+(** {1 Role Constants} *)
9393+9494+val role_owner : int
9595+val role_admin : int
9696+val role_moderator : int
9797+val role_member : int
9898+val role_guest : int
9999+100100+(** {1 JSON Codec} *)
101101+102102+val jsont : t Jsont.t
103103+(** Jsont codec for the user type. *)
104104+105105+(** {1 Pretty Printing} *)
106106+107107+val pp : Format.formatter -> t -> unit
+216
lib/zulip/user_group.ml
···11+(*---------------------------------------------------------------------------
22+ Copyright (c) 2025 Anil Madhavapeddy <anil@recoil.org>. All rights reserved.
33+ SPDX-License-Identifier: ISC
44+ ---------------------------------------------------------------------------*)
55+66+type t = {
77+ id : int;
88+ name : string;
99+ description : string;
1010+ members : int list;
1111+ direct_subgroup_ids : int list;
1212+ is_system_group : bool;
1313+ can_mention_group : int;
1414+}
1515+1616+let pp fmt g =
1717+ Format.fprintf fmt "UserGroup{id=%d, name=%s, members=%d}" g.id g.name
1818+ (List.length g.members)
1919+2020+let jsont =
2121+ Jsont.Object.(
2222+ map ~kind:"UserGroup"
2323+ (fun
2424+ id
2525+ name
2626+ description
2727+ members
2828+ direct_subgroup_ids
2929+ is_system_group
3030+ can_mention_group
3131+ ->
3232+ {
3333+ id;
3434+ name;
3535+ description;
3636+ members;
3737+ direct_subgroup_ids;
3838+ is_system_group;
3939+ can_mention_group;
4040+ })
4141+ |> mem "id" Jsont.int ~enc:(fun g -> g.id)
4242+ |> mem "name" Jsont.string ~enc:(fun g -> g.name)
4343+ |> mem "description" Jsont.string ~enc:(fun g -> g.description)
4444+ |> mem "members" (Jsont.list Jsont.int) ~dec_absent:[] ~enc:(fun g ->
4545+ g.members)
4646+ |> mem "direct_subgroup_ids" (Jsont.list Jsont.int) ~dec_absent:[]
4747+ ~enc:(fun g -> g.direct_subgroup_ids)
4848+ |> mem "is_system_group" Jsont.bool ~dec_absent:false ~enc:(fun g ->
4949+ g.is_system_group)
5050+ |> mem "can_mention_group" Jsont.int ~dec_absent:0 ~enc:(fun g ->
5151+ g.can_mention_group)
5252+ |> finish)
5353+5454+let list client =
5555+ let response_codec =
5656+ Jsont.Object.(
5757+ map ~kind:"UserGroupsResponse" Fun.id
5858+ |> mem "user_groups" (Jsont.list jsont) ~enc:Fun.id
5959+ |> finish)
6060+ in
6161+ let json =
6262+ Client.request client ~method_:`GET ~path:"/api/v1/user_groups" ()
6363+ in
6464+ Error.decode_or_raise response_codec json "getting user groups"
6565+6666+let create client ~name ~description ~members ?can_mention_group () =
6767+ let params =
6868+ [
6969+ ("name", name);
7070+ ("description", description);
7171+ ("members", Encode.to_json_string (Jsont.list Jsont.int) members);
7272+ ]
7373+ @ List.filter_map Fun.id
7474+ [
7575+ Option.map
7676+ (fun g -> ("can_mention_group", string_of_int g))
7777+ can_mention_group;
7878+ ]
7979+ in
8080+ let response_codec =
8181+ Jsont.Object.(
8282+ map ~kind:"CreateUserGroupResponse" Fun.id
8383+ |> mem "id" Jsont.int ~enc:Fun.id
8484+ |> finish)
8585+ in
8686+ let json =
8787+ Client.request client ~method_:`POST ~path:"/api/v1/user_groups/create"
8888+ ~params ()
8989+ in
9090+ Error.decode_or_raise response_codec json "creating user group"
9191+9292+let update client ~group_id ?name ?description ?can_mention_group () =
9393+ let params =
9494+ List.filter_map Fun.id
9595+ [
9696+ Option.map (fun n -> ("name", n)) name;
9797+ Option.map (fun d -> ("description", d)) description;
9898+ Option.map
9999+ (fun g -> ("can_mention_group", string_of_int g))
100100+ can_mention_group;
101101+ ]
102102+ in
103103+ let _response =
104104+ Client.request client ~method_:`PATCH
105105+ ~path:("/api/v1/user_groups/" ^ string_of_int group_id)
106106+ ~params ()
107107+ in
108108+ ()
109109+110110+let update_members client ~group_id ?add ?remove () =
111111+ let params =
112112+ List.filter_map Fun.id
113113+ [
114114+ Option.map
115115+ (fun ids -> ("add", Encode.to_json_string (Jsont.list Jsont.int) ids))
116116+ add;
117117+ Option.map
118118+ (fun ids ->
119119+ ("delete", Encode.to_json_string (Jsont.list Jsont.int) ids))
120120+ remove;
121121+ ]
122122+ in
123123+ let _response =
124124+ Client.request client ~method_:`POST
125125+ ~path:("/api/v1/user_groups/" ^ string_of_int group_id ^ "/members")
126126+ ~params ()
127127+ in
128128+ ()
129129+130130+let update_subgroups client ~group_id ?add ?remove () =
131131+ let params =
132132+ List.filter_map Fun.id
133133+ [
134134+ Option.map
135135+ (fun ids -> ("add", Encode.to_json_string (Jsont.list Jsont.int) ids))
136136+ add;
137137+ Option.map
138138+ (fun ids ->
139139+ ("delete", Encode.to_json_string (Jsont.list Jsont.int) ids))
140140+ remove;
141141+ ]
142142+ in
143143+ let _response =
144144+ Client.request client ~method_:`POST
145145+ ~path:("/api/v1/user_groups/" ^ string_of_int group_id ^ "/subgroups")
146146+ ~params ()
147147+ in
148148+ ()
149149+150150+let delete client ~group_id =
151151+ let _response =
152152+ Client.request client ~method_:`DELETE
153153+ ~path:("/api/v1/user_groups/" ^ string_of_int group_id)
154154+ ()
155155+ in
156156+ ()
157157+158158+let get_members client ~group_id =
159159+ let response_codec =
160160+ Jsont.Object.(
161161+ map ~kind:"MembersResponse" Fun.id
162162+ |> mem "members" (Jsont.list Jsont.int) ~enc:Fun.id
163163+ |> finish)
164164+ in
165165+ let json =
166166+ Client.request client ~method_:`GET
167167+ ~path:("/api/v1/user_groups/" ^ string_of_int group_id ^ "/members")
168168+ ()
169169+ in
170170+ Error.decode_or_raise response_codec json "getting group members"
171171+172172+let is_member client ~group_id ~user_id =
173173+ let response_codec =
174174+ Jsont.Object.(
175175+ map ~kind:"IsMemberResponse" Fun.id
176176+ |> mem "is_user_group_member" Jsont.bool ~enc:Fun.id
177177+ |> finish)
178178+ in
179179+ let json =
180180+ Client.request client ~method_:`GET
181181+ ~path:
182182+ ("/api/v1/user_groups/" ^ string_of_int group_id ^ "/members/"
183183+ ^ string_of_int user_id)
184184+ ()
185185+ in
186186+ Error.decode_or_raise response_codec json "checking group membership"
187187+188188+let get_subgroups client ~group_id =
189189+ let response_codec =
190190+ Jsont.Object.(
191191+ map ~kind:"SubgroupsResponse" Fun.id
192192+ |> mem "subgroups" (Jsont.list Jsont.int) ~enc:Fun.id
193193+ |> finish)
194194+ in
195195+ let json =
196196+ Client.request client ~method_:`GET
197197+ ~path:("/api/v1/user_groups/" ^ string_of_int group_id ^ "/subgroups")
198198+ ()
199199+ in
200200+ Error.decode_or_raise response_codec json "getting subgroups"
201201+202202+let is_subgroup client ~group_id ~subgroup_id =
203203+ let response_codec =
204204+ Jsont.Object.(
205205+ map ~kind:"IsSubgroupResponse" Fun.id
206206+ |> mem "is_subgroup" Jsont.bool ~enc:Fun.id
207207+ |> finish)
208208+ in
209209+ let json =
210210+ Client.request client ~method_:`GET
211211+ ~path:
212212+ ("/api/v1/user_groups/" ^ string_of_int group_id ^ "/subgroups/"
213213+ ^ string_of_int subgroup_id)
214214+ ()
215215+ in
216216+ Error.decode_or_raise response_codec json "checking subgroup membership"
+105
lib/zulip/user_group.mli
···11+(*---------------------------------------------------------------------------
22+ Copyright (c) 2025 Anil Madhavapeddy <anil@recoil.org>. All rights reserved.
33+ SPDX-License-Identifier: ISC
44+ ---------------------------------------------------------------------------*)
55+66+(** User groups for the Zulip API.
77+88+ User groups allow organizing users and setting permissions. *)
99+1010+(** {1 User Group Type} *)
1111+1212+type t = {
1313+ id : int; (** Group ID *)
1414+ name : string; (** Group name *)
1515+ description : string; (** Group description *)
1616+ members : int list; (** User IDs of group members *)
1717+ direct_subgroup_ids : int list; (** IDs of direct subgroups *)
1818+ is_system_group : bool; (** Whether this is a system-managed group *)
1919+ can_mention_group : int; (** Group ID that can mention this group *)
2020+}
2121+(** A user group. *)
2222+2323+(** {1 Listing Groups} *)
2424+2525+val list : Client.t -> t list
2626+(** Get all user groups in the organization.
2727+ @raise Eio.Io on failure *)
2828+2929+(** {1 Creating Groups} *)
3030+3131+val create :
3232+ Client.t ->
3333+ name:string ->
3434+ description:string ->
3535+ members:int list ->
3636+ ?can_mention_group:int ->
3737+ unit ->
3838+ int
3939+(** Create a new user group.
4040+ @return The ID of the created group
4141+ @raise Eio.Io on failure *)
4242+4343+(** {1 Updating Groups} *)
4444+4545+val update :
4646+ Client.t ->
4747+ group_id:int ->
4848+ ?name:string ->
4949+ ?description:string ->
5050+ ?can_mention_group:int ->
5151+ unit ->
5252+ unit
5353+(** Update a user group's properties.
5454+ @raise Eio.Io on failure *)
5555+5656+val update_members :
5757+ Client.t -> group_id:int -> ?add:int list -> ?remove:int list -> unit -> unit
5858+(** Update user group membership.
5959+6060+ @param add User IDs to add to the group
6161+ @param remove User IDs to remove from the group
6262+ @raise Eio.Io on failure *)
6363+6464+val update_subgroups :
6565+ Client.t -> group_id:int -> ?add:int list -> ?remove:int list -> unit -> unit
6666+(** Update user group subgroups.
6767+6868+ @param add Subgroup IDs to add
6969+ @param remove Subgroup IDs to remove
7070+ @raise Eio.Io on failure *)
7171+7272+(** {1 Deleting Groups} *)
7373+7474+val delete : Client.t -> group_id:int -> unit
7575+(** Delete a user group.
7676+ @raise Eio.Io on failure *)
7777+7878+(** {1 Membership Queries} *)
7979+8080+val get_members : Client.t -> group_id:int -> int list
8181+(** Get the members of a user group.
8282+ @raise Eio.Io on failure *)
8383+8484+val is_member : Client.t -> group_id:int -> user_id:int -> bool
8585+(** Check if a user is a member of a group.
8686+ @raise Eio.Io on failure *)
8787+8888+(** {1 Subgroup Queries} *)
8989+9090+val get_subgroups : Client.t -> group_id:int -> int list
9191+(** Get the direct subgroups of a user group.
9292+ @raise Eio.Io on failure *)
9393+9494+val is_subgroup : Client.t -> group_id:int -> subgroup_id:int -> bool
9595+(** Check if a group is a subgroup of another.
9696+ @raise Eio.Io on failure *)
9797+9898+(** {1 JSON Codec} *)
9999+100100+val jsont : t Jsont.t
101101+(** Jsont codec for user groups. *)
102102+103103+(** {1 Pretty Printing} *)
104104+105105+val pp : Format.formatter -> t -> unit
···11+(*---------------------------------------------------------------------------
22+ Copyright (c) 2025 Anil Madhavapeddy <anil@recoil.org>. All rights reserved.
33+ SPDX-License-Identifier: ISC
44+ ---------------------------------------------------------------------------*)
55+66+(** Zulip API client library for OCaml.
77+88+ This module provides a comprehensive interface to the Zulip REST API, with
99+ support for messages, channels, users, and real-time events.
1010+1111+ {1 Quick Start}
1212+1313+ {[
1414+ (* Create a client *)
1515+ let auth = Zulip.Auth.from_zuliprc () in
1616+ Zulip.Client.with_client env auth (fun client ->
1717+ (* Send a message *)
1818+ let msg = Zulip.Message.create
1919+ ~type_:`Channel ~to_:["general"]
2020+ ~content:"Hello from OCaml!" ~topic:"greetings" ()
2121+ in
2222+ let _ = Zulip.Messages.send client msg in
2323+2424+ (* Get recent messages *)
2525+ let narrow = Zulip.Narrow.[stream "general"; topic "greetings"] in
2626+ let messages = Zulip.Messages.get_messages client
2727+ ~anchor:Newest ~num_before:10 ~narrow ()
2828+ in
2929+ (* ... *)
3030+ )
3131+ ]}
3232+3333+ {1 Error Handling}
3434+3535+ Errors are raised as [Eio.Io] exceptions with [Error.E error], following the
3636+ Eio error pattern (like [Eio.Net.E] and [Eio.Fs.E]). This provides
3737+ context-aware error handling with automatic context accumulation as errors
3838+ propagate up the call stack.
3939+4040+ Example:
4141+ {[
4242+ try Client.request client ~method_:`GET ~path:"/api/v1/users" () with
4343+ | Eio.Io (Error.E { code = Invalid_api_key; message; _ }, _) ->
4444+ (* Handle authentication error *)
4545+ Log.err (fun m -> m "Auth failed: %s" message)
4646+ | Eio.Io _ as ex ->
4747+ (* Re-raise with additional context *)
4848+ let bt = Printexc.get_raw_backtrace () in
4949+ Eio.Exn.reraise_with_context ex bt "fetching user list"
5050+ ]} *)
5151+5252+(** {1 Core Types} *)
5353+5454+type json = Jsont.json
5555+(** JSON type used throughout the API. *)
5656+5757+(** {1 Error Handling} *)
5858+5959+module Error = Error
6060+(** API error types and handling. *)
6161+6262+(** {1 Authentication} *)
6363+6464+module Auth = Auth
6565+(** Authentication credentials management. *)
6666+6767+(** {1 Client} *)
6868+6969+module Client = Client
7070+(** HTTP client for making API requests. *)
7171+7272+(** {1 Messages} *)
7373+7474+module Message = Message
7575+(** Outgoing message construction. *)
7676+7777+module Message_type = Message_type
7878+(** Message type (channel vs direct). *)
7979+8080+module Message_response = Message_response
8181+(** Response from sending a message. *)
8282+8383+module Message_flag = Message_flag
8484+(** Message flags (read, starred, etc.). *)
8585+8686+module Messages = Messages
8787+(** Message operations (send, get, edit, delete, reactions). *)
8888+8989+(** {1 Narrow Filters} *)
9090+9191+module Narrow = Narrow
9292+(** Type-safe message query filters. *)
9393+9494+(** {1 Channels (Streams)} *)
9595+9696+module Channel = Channel
9797+(** Channel/stream type and properties. *)
9898+9999+module Channels = Channels
100100+(** Channel operations (create, subscribe, topics). *)
101101+102102+(** {1 Users} *)
103103+104104+module User = User
105105+(** User type and properties. *)
106106+107107+module Users = Users
108108+(** User operations (list, get, create, status). *)
109109+110110+module User_group = User_group
111111+(** User groups and membership. *)
112112+113113+(** {1 Presence} *)
114114+115115+module Presence = Presence
116116+(** User presence/online status. *)
117117+118118+(** {1 Events} *)
119119+120120+module Event = Event
121121+(** Event records from the event queue. *)
122122+123123+module Event_type = Event_type
124124+(** Event type enumeration. *)
125125+126126+module Event_queue = Event_queue
127127+(** Event queue management and polling. *)
128128+129129+(** {1 Typing Notifications} *)
130130+131131+module Typing = Typing
132132+(** Typing start/stop notifications. *)
133133+134134+(** {1 Server Information} *)
135135+136136+module Server = Server
137137+(** Server settings, linkifiers, emoji, profile fields. *)
138138+139139+(** {1 Utilities} *)
140140+141141+module Encode = Encode
142142+(** JSON encoding/decoding utilities. *)
+177
lib/zulip_bot/bot.ml
···11+(*---------------------------------------------------------------------------
22+ Copyright (c) 2025 Anil Madhavapeddy <anil@recoil.org>. All rights reserved.
33+ SPDX-License-Identifier: ISC
44+ ---------------------------------------------------------------------------*)
55+66+let src = Logs.Src.create "zulip_bot.bot" ~doc:"Zulip bot runner"
77+88+module Log = (val Logs.src_log src : Logs.LOG)
99+1010+type identity = { user_id : int; email : string; full_name : string }
1111+type handler = storage:Storage.t -> identity:identity -> Message.t -> Response.t
1212+1313+let create_client ~sw ~env ~config =
1414+ let auth =
1515+ Zulip.Auth.create ~server_url:config.Config.site ~email:config.Config.email
1616+ ~api_key:config.Config.api_key
1717+ in
1818+ Zulip.Client.create ~sw env auth
1919+2020+let fetch_identity client =
2121+ let user = Zulip.Users.me client in
2222+ {
2323+ user_id = Zulip.User.user_id user |> Option.value ~default:0;
2424+ email = Zulip.User.email user;
2525+ full_name = Zulip.User.full_name user;
2626+ }
2727+2828+let send_response client ~in_reply_to response =
2929+ match response with
3030+ | Response.Reply content ->
3131+ let message_to_send =
3232+ if Message.is_private in_reply_to then
3333+ let sender = Message.sender_email in_reply_to in
3434+ Zulip.Message.create ~type_:`Direct ~to_:[ sender ] ~content ()
3535+ else
3636+ let reply_to = Message.get_reply_to in_reply_to in
3737+ let topic =
3838+ match in_reply_to with
3939+ | Message.Stream { subject; _ } -> Some subject
4040+ | _ -> None
4141+ in
4242+ Zulip.Message.create ~type_:`Channel ~to_:[ reply_to ] ~content ?topic
4343+ ()
4444+ in
4545+ let resp = Zulip.Messages.send client message_to_send in
4646+ Log.info (fun m ->
4747+ m "Reply sent (id: %d)" (Zulip.Message_response.id resp))
4848+ | Response.Direct { recipients; content } ->
4949+ let message_to_send =
5050+ Zulip.Message.create ~type_:`Direct ~to_:recipients ~content ()
5151+ in
5252+ let resp = Zulip.Messages.send client message_to_send in
5353+ Log.info (fun m ->
5454+ m "Direct message sent (id: %d)" (Zulip.Message_response.id resp))
5555+ | Response.Stream { stream; topic; content } ->
5656+ let message_to_send =
5757+ Zulip.Message.create ~type_:`Channel ~to_:[ stream ] ~topic ~content ()
5858+ in
5959+ let resp = Zulip.Messages.send client message_to_send in
6060+ Log.info (fun m ->
6161+ m "Stream message sent (id: %d)" (Zulip.Message_response.id resp))
6262+ | Response.Silent -> Log.debug (fun m -> m "Handler returned silent response")
6363+6464+let process_event ~client ~storage ~identity ~handler event =
6565+ Log.debug (fun m ->
6666+ m "Processing event type: %s"
6767+ (Zulip.Event_type.to_string (Zulip.Event.type_ event)));
6868+ match Zulip.Event.type_ event with
6969+ | Zulip.Event_type.Message -> (
7070+ let event_data = Zulip.Event.data event in
7171+ let message_json, flags =
7272+ match event_data with
7373+ | Jsont.Object (fields, _) ->
7474+ let assoc = List.map (fun ((k, _), v) -> (k, v)) fields in
7575+ let msg =
7676+ List.assoc_opt "message" assoc |> Option.value ~default:event_data
7777+ in
7878+ let flgs =
7979+ List.assoc_opt "flags" assoc
8080+ |> Option.fold ~none:[] ~some:(function
8181+ | Jsont.Array (f, _) -> f
8282+ | _ -> [])
8383+ in
8484+ (msg, flgs)
8585+ | _ -> (event_data, [])
8686+ in
8787+ match Message.of_json message_json with
8888+ | Error err ->
8989+ Log.err (fun m -> m "Failed to parse message JSON: %s" err);
9090+ Log.debug (fun m -> m "@[%a@]" Message.pp_json_debug message_json)
9191+ | Ok message ->
9292+ Log.info (fun m ->
9393+ m "@[<h>%a@]" (Message.pp_ansi ~show_json:false) message);
9494+ let is_mentioned =
9595+ List.exists
9696+ (function Jsont.String ("mentioned", _) -> true | _ -> false)
9797+ flags
9898+ || Message.is_mentioned message ~user_email:identity.email
9999+ in
100100+ let is_private = Message.is_private message in
101101+ let is_from_self =
102102+ Message.is_from_email message ~email:identity.email
103103+ in
104104+ Log.debug (fun m ->
105105+ m "Message check: mentioned=%b, private=%b, from_self=%b"
106106+ is_mentioned is_private is_from_self);
107107+ if (is_mentioned || is_private) && not is_from_self then (
108108+ Log.info (fun m -> m "Bot should respond to this message");
109109+ try
110110+ let response = handler ~storage ~identity message in
111111+ send_response client ~in_reply_to:message response
112112+ with Eio.Exn.Io (e, _) ->
113113+ Log.err (fun m -> m "Error handling message: %a" Eio.Exn.pp_err e))
114114+ else
115115+ Log.debug (fun m ->
116116+ m "Not processing (not mentioned and not private)"))
117117+ | _ -> ()
118118+119119+let run ~sw ~env ~config ~handler =
120120+ Log.info (fun m -> m "Starting bot: %s" config.Config.name);
121121+ let client = create_client ~sw ~env ~config in
122122+ let identity = fetch_identity client in
123123+ let storage = Storage.create client in
124124+ Log.info (fun m ->
125125+ m "Bot identity: %s <%s> (id: %d)" identity.full_name identity.email
126126+ identity.user_id);
127127+ let queue =
128128+ Zulip.Event_queue.register client
129129+ ~event_types:[ Zulip.Event_type.Message ]
130130+ ()
131131+ in
132132+ Log.info (fun m ->
133133+ m "Event queue registered: %s" (Zulip.Event_queue.id queue));
134134+ let rec event_loop last_event_id =
135135+ try
136136+ let events =
137137+ Zulip.Event_queue.get_events queue client ~last_event_id ()
138138+ in
139139+ if List.length events > 0 then
140140+ Log.info (fun m -> m "Received %d event(s)" (List.length events));
141141+ List.iter
142142+ (fun event ->
143143+ Log.debug (fun m ->
144144+ m "Event id=%d, type=%s" (Zulip.Event.id event)
145145+ (Zulip.Event_type.to_string (Zulip.Event.type_ event)));
146146+ process_event ~client ~storage ~identity ~handler event)
147147+ events;
148148+ let new_last_id =
149149+ List.fold_left
150150+ (fun max_id event -> max (Zulip.Event.id event) max_id)
151151+ last_event_id events
152152+ in
153153+ event_loop new_last_id
154154+ with Eio.Exn.Io (e, _) ->
155155+ Log.warn (fun m ->
156156+ m "Error getting events: %a (retrying in 2s)" Eio.Exn.pp_err e);
157157+ Eio.Time.sleep env#clock 2.0;
158158+ event_loop last_event_id
159159+ in
160160+ event_loop (-1)
161161+162162+let handle_webhook ~sw ~env ~config ~handler ~payload =
163163+ let client = create_client ~sw ~env ~config in
164164+ let identity = fetch_identity client in
165165+ let storage = Storage.create client in
166166+ match Jsont_bytesrw.decode_string Jsont.json payload with
167167+ | Error _ ->
168168+ Log.err (fun m -> m "Failed to parse webhook payload as JSON");
169169+ None
170170+ | Ok json -> (
171171+ match Message.of_json json with
172172+ | Error err ->
173173+ Log.err (fun m -> m "Failed to parse webhook message: %s" err);
174174+ None
175175+ | Ok message ->
176176+ let response = handler ~storage ~identity message in
177177+ Some response)
+144
lib/zulip_bot/bot.mli
···11+(*---------------------------------------------------------------------------
22+ Copyright (c) 2025 Anil Madhavapeddy <anil@recoil.org>. All rights reserved.
33+ SPDX-License-Identifier: ISC
44+ ---------------------------------------------------------------------------*)
55+66+(** Fiber-based Zulip bot execution.
77+88+ A bot is simply a function that processes messages. The [run] function
99+ executes the bot as an Eio fiber, making it easy to compose multiple bots
1010+ using standard Eio concurrency primitives.
1111+1212+ {b Example: Single bot}
1313+ {[
1414+ let echo_handler ~storage:_ ~identity:_ msg =
1515+ Response.reply ("Echo: " ^ Message.content msg)
1616+1717+ let () =
1818+ Eio_main.run @@ fun env ->
1919+ Eio.Switch.run @@ fun sw ->
2020+ let fs = Eio.Stdenv.fs env in
2121+ let config = Config.load ~fs "echo-bot" in
2222+ Bot.run ~sw ~env ~config ~handler:echo_handler
2323+ ]}
2424+2525+ {b Example: Multiple bots}
2626+ {[
2727+ let () =
2828+ Eio_main.run @@ fun env ->
2929+ Eio.Switch.run @@ fun sw ->
3030+ let fs = Eio.Stdenv.fs env in
3131+3232+ Eio.Fiber.all
3333+ [
3434+ (fun () ->
3535+ Bot.run ~sw ~env
3636+ ~config:(Config.load ~fs "echo-bot")
3737+ ~handler:echo_handler);
3838+ (fun () ->
3939+ Bot.run ~sw ~env
4040+ ~config:(Config.load ~fs "help-bot")
4141+ ~handler:help_handler);
4242+ ]
4343+ ]} *)
4444+4545+(** {1 Types} *)
4646+4747+type identity = {
4848+ user_id : int; (** Bot's user ID on the server *)
4949+ email : string; (** Bot's email address *)
5050+ full_name : string; (** Bot's display name *)
5151+}
5252+(** Bot identity information retrieved from Zulip. *)
5353+5454+type handler = storage:Storage.t -> identity:identity -> Message.t -> Response.t
5555+(** Handler function signature.
5656+5757+ A handler receives:
5858+ - [storage]: Key-value storage via Zulip's bot storage API
5959+ - [identity]: The bot's identity (email, name, user_id)
6060+ - The incoming [Message.t]
6161+6262+ And returns a [Response.t] indicating what action to take. *)
6363+6464+(** {1 Running Bots} *)
6565+6666+val run :
6767+ sw:Eio.Switch.t ->
6868+ env:
6969+ < clock : float Eio.Time.clock_ty Eio.Resource.t
7070+ ; net : [ `Generic | `Unix ] Eio.Net.ty Eio.Resource.t
7171+ ; fs : Eio.Fs.dir_ty Eio.Path.t
7272+ ; .. > ->
7373+ config:Config.t ->
7474+ handler:handler ->
7575+ unit
7676+(** [run ~sw ~env ~config ~handler] runs a bot as a fiber.
7777+7878+ The bot connects to Zulip's real-time events API and processes incoming
7979+ messages. It runs until the switch is cancelled.
8080+8181+ The bot will:
8282+ - Register for message events with Zulip
8383+ - Process private messages and messages that mention the bot
8484+ - Ignore messages from itself
8585+ - Send responses back via the Zulip API
8686+ - Automatically reconnect with exponential backoff on errors
8787+8888+ @param sw Eio switch controlling the bot's lifetime
8989+ @param env Eio environment with clock, net, and fs capabilities
9090+ @param config Bot configuration (credentials and metadata)
9191+ @param handler Function to process incoming messages *)
9292+9393+(** {1 Webhook Mode} *)
9494+9595+val handle_webhook :
9696+ sw:Eio.Switch.t ->
9797+ env:
9898+ < clock : float Eio.Time.clock_ty Eio.Resource.t
9999+ ; net : [ `Generic | `Unix ] Eio.Net.ty Eio.Resource.t
100100+ ; fs : Eio.Fs.dir_ty Eio.Path.t
101101+ ; .. > ->
102102+ config:Config.t ->
103103+ handler:handler ->
104104+ payload:string ->
105105+ Response.t option
106106+(** [handle_webhook ~sw ~env ~config ~handler ~payload] processes a single
107107+ webhook payload.
108108+109109+ For webhook-based deployments, provide your own HTTP server and call this
110110+ function to process incoming webhook payloads from Zulip.
111111+112112+ Returns [Some response] if the message was processed, [None] if the payload
113113+ could not be parsed or should not be handled. *)
114114+115115+val send_response :
116116+ Zulip.Client.t -> in_reply_to:Message.t -> Response.t -> unit
117117+(** [send_response client ~in_reply_to response] sends a response via the Zulip
118118+ API.
119119+120120+ Utility function for webhook mode to send responses after processing. The
121121+ [in_reply_to] message is used to determine the reply context (stream/topic
122122+ or private message recipients). *)
123123+124124+(** {1 Utilities} *)
125125+126126+val create_client :
127127+ sw:Eio.Switch.t ->
128128+ env:
129129+ < clock : float Eio.Time.clock_ty Eio.Resource.t
130130+ ; net : [ `Generic | `Unix ] Eio.Net.ty Eio.Resource.t
131131+ ; fs : Eio.Fs.dir_ty Eio.Path.t
132132+ ; .. > ->
133133+ config:Config.t ->
134134+ Zulip.Client.t
135135+(** [create_client ~sw ~env ~config] creates a Zulip client from bot
136136+ configuration.
137137+138138+ Useful when you need direct access to the Zulip API beyond what the bot
139139+ framework provides. *)
140140+141141+val fetch_identity : Zulip.Client.t -> identity
142142+(** [fetch_identity client] retrieves the bot's identity from the Zulip server.
143143+144144+ @raise Eio.Io on API errors *)
+194
lib/zulip_bot/cmd.ml
···11+(*---------------------------------------------------------------------------
22+ Copyright (c) 2025 Anil Madhavapeddy <anil@recoil.org>. All rights reserved.
33+ SPDX-License-Identifier: ISC
44+ ---------------------------------------------------------------------------*)
55+66+open Cmdliner
77+88+let src = Logs.Src.create "zulip_bot.cmd" ~doc:"Zulip bot cmdliner integration"
99+1010+module Log = (val Logs.src_log src : Logs.LOG)
1111+1212+type source = Default | Env of string | Config | Cmdline
1313+type 'a with_source = { value : 'a; source : source }
1414+1515+let pp_source ppf = function
1616+ | Default -> Format.fprintf ppf "default"
1717+ | Env var -> Format.fprintf ppf "env(%s)" var
1818+ | Config -> Format.fprintf ppf "config"
1919+ | Cmdline -> Format.fprintf ppf "cmdline"
2020+2121+let pp_with_source pp_val ppf ws =
2222+ Format.fprintf ppf "%a [%a]" pp_val ws.value pp_source ws.source
2323+2424+(** Check environment variable and track source *)
2525+let check_env_bool ~env_var ~default =
2626+ match Sys.getenv_opt env_var with
2727+ | Some v
2828+ when String.lowercase_ascii v = "1" || String.lowercase_ascii v = "true" ->
2929+ { value = true; source = Env env_var }
3030+ | Some v
3131+ when String.lowercase_ascii v = "0" || String.lowercase_ascii v = "false" ->
3232+ { value = false; source = Env env_var }
3333+ | Some _ | None -> { value = default; source = Default }
3434+3535+let parse_log_level s =
3636+ match String.lowercase_ascii s with
3737+ | "debug" -> Some Logs.Debug
3838+ | "info" -> Some Logs.Info
3939+ | "warning" | "warn" -> Some Logs.Warning
4040+ | "error" | "err" -> Some Logs.Error
4141+ | "app" -> Some Logs.App
4242+ | _ -> None
4343+4444+(* Individual terms *)
4545+4646+let name_term default_name =
4747+ let doc = "Bot name (used for XDG paths and identification)" in
4848+ Arg.(value & opt string default_name & info [ "n"; "name" ] ~docv:"NAME" ~doc)
4949+5050+let config_file_term =
5151+ let doc = "Path to .zuliprc configuration file" in
5252+ Arg.(
5353+ value & opt (some string) None & info [ "c"; "config" ] ~docv:"FILE" ~doc)
5454+5555+let verbosity_term =
5656+ let doc =
5757+ "Increase verbosity (-v for debug). Can also use ZULIP_LOG_LEVEL env var."
5858+ in
5959+ let cmdline_arg = Arg.(value & flag_all & info [ "v"; "verbose" ] ~doc) in
6060+ Term.(
6161+ const (fun flags ->
6262+ match List.length flags with
6363+ | 0 -> (
6464+ (* Check environment *)
6565+ match Sys.getenv_opt "ZULIP_LOG_LEVEL" with
6666+ | Some v -> (
6767+ match parse_log_level v with
6868+ | Some lvl -> { value = lvl; source = Env "ZULIP_LOG_LEVEL" }
6969+ | None -> { value = Logs.Info; source = Default })
7070+ | None -> { value = Logs.Info; source = Default })
7171+ | _ -> { value = Logs.Debug; source = Cmdline })
7272+ $ cmdline_arg)
7373+7474+let verbose_http_term app_name =
7575+ let doc = "Enable verbose HTTP-level logging (hexdumps, TLS details)" in
7676+ let env_name = String.uppercase_ascii app_name ^ "_VERBOSE_HTTP" in
7777+ let env_info = Cmdliner.Cmd.Env.info env_name in
7878+ let cmdline_arg =
7979+ Arg.(value & flag & info [ "verbose-http" ] ~env:env_info ~doc)
8080+ in
8181+ Term.(
8282+ const (fun cmdline ->
8383+ if cmdline then { value = true; source = Cmdline }
8484+ else check_env_bool ~env_var:env_name ~default:false)
8585+ $ cmdline_arg)
8686+8787+(* Logging setup *)
8888+8989+let setup_logging ?(verbose_http = false) level =
9090+ Logs.set_reporter (Logs_fmt.reporter ());
9191+ Logs.set_level (Some level);
9292+9393+ (* Set bot-level sources *)
9494+ Logs.Src.set_level src (Some level);
9595+9696+ (* Set zulip_bot sources based on level *)
9797+ List.iter
9898+ (fun s ->
9999+ if
100100+ String.starts_with ~prefix:"zulip_bot" (Logs.Src.name s)
101101+ || String.starts_with ~prefix:"zulip" (Logs.Src.name s)
102102+ then Logs.Src.set_level s (Some level))
103103+ (Logs.Src.list ());
104104+105105+ (* HTTP-level verbose logging (if requested) *)
106106+ if verbose_http then (
107107+ (* Enable requests library debug logging *)
108108+ List.iter
109109+ (fun s ->
110110+ if String.starts_with ~prefix:"requests" (Logs.Src.name s) then
111111+ Logs.Src.set_level s (Some Logs.Debug))
112112+ (Logs.Src.list ());
113113+ (* Enable TLS tracing if available *)
114114+ match
115115+ List.find_opt
116116+ (fun s -> Logs.Src.name s = "tls.tracing")
117117+ (Logs.Src.list ())
118118+ with
119119+ | Some tls_src -> Logs.Src.set_level tls_src (Some Logs.Debug)
120120+ | None -> ())
121121+ else
122122+ (* Suppress noisy HTTP logging when not verbose *)
123123+ List.iter
124124+ (fun s ->
125125+ if
126126+ String.starts_with ~prefix:"requests" (Logs.Src.name s)
127127+ || Logs.Src.name s = "tls.tracing"
128128+ then Logs.Src.set_level s (Some Logs.Warning))
129129+ (Logs.Src.list ())
130130+131131+(* Load configuration from various sources *)
132132+let load_config ~fs ~name ~config_file =
133133+ match config_file with
134134+ | Some path ->
135135+ (* Load from .zuliprc style file for backwards compatibility *)
136136+ let auth = Zulip.Auth.from_zuliprc ~path () in
137137+ Config.create ~name
138138+ ~site:(Zulip.Auth.server_url auth)
139139+ ~email:(Zulip.Auth.email auth)
140140+ ~api_key:(Zulip.Auth.api_key auth)
141141+ ()
142142+ | None -> (
143143+ (* Try XDG config first, fall back to ~/.zuliprc *)
144144+ try Config.load ~fs name
145145+ with _ ->
146146+ let auth = Zulip.Auth.from_zuliprc () in
147147+ Config.create ~name
148148+ ~site:(Zulip.Auth.server_url auth)
149149+ ~email:(Zulip.Auth.email auth)
150150+ ~api_key:(Zulip.Auth.api_key auth)
151151+ ())
152152+153153+(* Combined terms *)
154154+155155+let config_term default_name env =
156156+ let fs = env#fs in
157157+ Term.(
158158+ const (fun name config_file verbosity verbose_http ->
159159+ setup_logging ~verbose_http:verbose_http.value verbosity.value;
160160+ load_config ~fs ~name ~config_file)
161161+ $ name_term default_name
162162+ $ config_file_term
163163+ $ verbosity_term
164164+ $ verbose_http_term default_name)
165165+166166+let run_term default_name eio_env _sw f =
167167+ let open Cmdliner in
168168+ Term.(const f $ config_term default_name eio_env)
169169+170170+(* Documentation *)
171171+172172+let env_docs app_name =
173173+ let app_upper = String.uppercase_ascii app_name in
174174+ Printf.sprintf
175175+ "## ENVIRONMENT\n\n\
176176+ The following environment variables affect %s:\n\n\
177177+ ### Credentials\n\n\
178178+ **ZULIP_%s_SITE**\n\
179179+ : Zulip server URL (e.g., https://chat.zulip.org)\n\n\
180180+ **ZULIP_%s_EMAIL**\n\
181181+ : Bot email address\n\n\
182182+ **ZULIP_%s_API_KEY**\n\
183183+ : Bot API key\n\n\
184184+ ### Logging\n\n\
185185+ **ZULIP_LOG_LEVEL**\n\
186186+ : Log level: debug, info, warning, error (default: info)\n\n\
187187+ **%s_VERBOSE_HTTP**\n\
188188+ : Set to '1' to enable verbose HTTP-level logging\n\n\
189189+ ### XDG Directories\n\n\
190190+ Configuration is loaded from XDG config directory:\n\
191191+ - [$XDG_CONFIG_HOME/zulip-bot/%s/config] (typically \
192192+ [~/.config/zulip-bot/%s/config])\n\n\
193193+ Or from a legacy [.zuliprc] file in the home directory.\n"
194194+ app_name app_upper app_upper app_upper app_upper app_name app_name
+122
lib/zulip_bot/cmd.mli
···11+(*---------------------------------------------------------------------------
22+ Copyright (c) 2025 Anil Madhavapeddy <anil@recoil.org>. All rights reserved.
33+ SPDX-License-Identifier: ISC
44+ ---------------------------------------------------------------------------*)
55+66+(** Cmdliner integration for Zulip bots.
77+88+ This module provides command-line argument handling for Zulip bot
99+ configuration, including bot name, credentials, logging levels, and HTTP
1010+ settings.
1111+1212+ {b Configuration Sources (in precedence order):}
1313+ + Command-line arguments (highest priority)
1414+ + Application-specific environment variables (e.g., [ZULIP_MYBOT_SITE])
1515+ + XDG configuration file ([~/.config/zulip-bot/<name>/config])
1616+ + Legacy [.zuliprc] file
1717+ + Default values
1818+1919+ {b Example usage:}
2020+ {[
2121+ let my_handler ~storage:_ ~identity:_ msg =
2222+ Response.reply ("Hello: " ^ Message.content msg)
2323+2424+ let () =
2525+ Eio_main.run @@ fun env ->
2626+ Eio.Switch.run @@ fun sw ->
2727+ let run config = Bot.run ~sw ~env ~config ~handler:my_handler in
2828+ let cmd = Cmd.v info Term.(const run $ Zulip_bot.Cmd.config_term "mybot" env) in
2929+ Cmdliner.Cmd.eval cmd
3030+ ]} *)
3131+3232+(** {1 Source Tracking} *)
3333+3434+type source =
3535+ | Default (** Value from hardcoded default *)
3636+ | Env of string (** Value from environment variable (stores var name) *)
3737+ | Config (** Value from XDG config file or .zuliprc *)
3838+ | Cmdline (** Value from command-line argument *)
3939+4040+type 'a with_source = { value : 'a; source : source }
4141+(** Wrapper for values with source tracking. *)
4242+4343+(** {1 Individual Terms} *)
4444+4545+val name_term : string -> string Cmdliner.Term.t
4646+(** [name_term default_name] creates a term for [--name NAME].
4747+4848+ The bot name is used to locate XDG configuration files and for logging. *)
4949+5050+val config_file_term : string option Cmdliner.Term.t
5151+(** Term for [--config FILE] option.
5252+5353+ Provides a path to a [.zuliprc]-style configuration file. If not provided,
5454+ the bot will use XDG configuration or environment variables. *)
5555+5656+val verbosity_term : Logs.level with_source Cmdliner.Term.t
5757+(** Term for [-v] / [--verbose] flags.
5858+5959+ - No flag: [Logs.Info]
6060+ - One [-v]: [Logs.Debug]
6161+ - Two or more [-v]: [Logs.Debug]
6262+6363+ Env var: [ZULIP_LOG_LEVEL] (values: debug, info, warning, error) *)
6464+6565+val verbose_http_term : string -> bool with_source Cmdliner.Term.t
6666+(** [verbose_http_term app_name] creates a term for [--verbose-http] flag.
6767+6868+ Enables verbose HTTP-level logging including hexdumps, TLS details, and
6969+ low-level protocol information. Default is [false] (off).
7070+7171+ Env var: [{APP_NAME}_VERBOSE_HTTP] *)
7272+7373+(** {1 Combined Terms} *)
7474+7575+val config_term :
7676+ string ->
7777+ < fs : Eio.Fs.dir_ty Eio.Path.t ; .. > ->
7878+ Config.t Cmdliner.Term.t
7979+(** [config_term default_name env] creates a complete configuration term.
8080+8181+ This term combines:
8282+ - Bot name (with default)
8383+ - Optional config file path
8484+ - XDG/environment configuration loading
8585+ - Logging setup
8686+8787+ The returned [Config.t] is ready to use with [Bot.run]. *)
8888+8989+val run_term :
9090+ string ->
9191+ < fs : Eio.Fs.dir_ty Eio.Path.t ; .. > ->
9292+ Eio.Switch.t ->
9393+ (Config.t -> unit) ->
9494+ unit Cmdliner.Term.t
9595+(** [run_term default_name env sw f] creates a term that runs a bot function.
9696+9797+ This is a convenience for the common pattern of loading configuration and
9898+ running a bot. The function [f] is called with the loaded configuration. *)
9999+100100+(** {1 Logging Setup} *)
101101+102102+val setup_logging : ?verbose_http:bool -> Logs.level -> unit
103103+(** [setup_logging ?verbose_http level] configures the Logs reporter and levels.
104104+105105+ @param verbose_http If [true], enables verbose HTTP-level logging including
106106+ hexdumps and TLS details. Default is [false].
107107+ @param level The minimum log level to display. *)
108108+109109+(** {1 Documentation} *)
110110+111111+val pp_source : Format.formatter -> source -> unit
112112+(** Pretty-print a source type. *)
113113+114114+val pp_with_source :
115115+ (Format.formatter -> 'a -> unit) -> Format.formatter -> 'a with_source -> unit
116116+(** Pretty-print a value with its source. *)
117117+118118+val env_docs : string -> string
119119+(** [env_docs app_name] generates documentation for environment variables.
120120+121121+ Returns a formatted string documenting all environment variables that affect
122122+ bot configuration for the given application name. *)
+125
lib/zulip_bot/config.ml
···11+(*---------------------------------------------------------------------------
22+ Copyright (c) 2025 Anil Madhavapeddy <anil@recoil.org>. All rights reserved.
33+ SPDX-License-Identifier: ISC
44+ ---------------------------------------------------------------------------*)
55+66+let src = Logs.Src.create "zulip_bot.config" ~doc:"Zulip bot configuration"
77+88+module Log = (val Logs.src_log src : Logs.LOG)
99+1010+type t = {
1111+ name : string;
1212+ site : string;
1313+ email : string;
1414+ api_key : string;
1515+ description : string option;
1616+ usage : string option;
1717+}
1818+1919+let create ~name ~site ~email ~api_key ?description ?usage () =
2020+ { name; site; email; api_key; description; usage }
2121+2222+(** Convert bot name to environment variable prefix. "my-bot" -> "ZULIP_MY_BOT"
2323+*)
2424+let env_prefix name =
2525+ let upper = String.uppercase_ascii name in
2626+ let replaced = String.map (fun c -> if c = '-' then '_' else c) upper in
2727+ "ZULIP_" ^ replaced ^ "_"
2828+2929+type ini_config = {
3030+ ini_site : string;
3131+ ini_email : string;
3232+ ini_api_key : string;
3333+ ini_description : string option;
3434+ ini_usage : string option;
3535+}
3636+(** INI section record for parsing (without name field) *)
3737+3838+(** Codec for parsing the bot section of the config file *)
3939+let ini_section_codec =
4040+ Init.Section.(
4141+ obj (fun site email api_key description usage ->
4242+ {
4343+ ini_site = site;
4444+ ini_email = email;
4545+ ini_api_key = api_key;
4646+ ini_description = description;
4747+ ini_usage = usage;
4848+ })
4949+ |> mem "site" Init.string ~enc:(fun c -> c.ini_site)
5050+ |> mem "email" Init.string ~enc:(fun c -> c.ini_email)
5151+ |> mem "api_key" Init.string ~enc:(fun c -> c.ini_api_key)
5252+ |> opt_mem "description" Init.string ~enc:(fun c -> c.ini_description)
5353+ |> opt_mem "usage" Init.string ~enc:(fun c -> c.ini_usage)
5454+ |> skip_unknown |> finish)
5555+5656+(** Document codec that accepts a [bot] section or bare options at top level *)
5757+let ini_doc_codec =
5858+ Init.Document.(
5959+ obj (fun bot -> bot)
6060+ |> section "bot" ini_section_codec ~enc:Fun.id
6161+ |> skip_unknown |> finish)
6262+6363+(** Codec for configs without section headers (bare key=value pairs) *)
6464+let bare_section_codec =
6565+ Init.Document.(
6666+ obj (fun defaults -> defaults)
6767+ |> defaults ini_section_codec ~enc:Fun.id
6868+ |> skip_unknown |> finish)
6969+7070+let load ~fs name =
7171+ Log.info (fun m -> m "Loading config for bot: %s" name);
7272+ let xdg = Xdge.create fs ("zulip-bot/" ^ name) in
7373+ let config_file = Eio.Path.(Xdge.config_dir xdg / "config") in
7474+ Log.debug (fun m -> m "Looking for config at: %a" Eio.Path.pp config_file);
7575+ (* Try parsing with [bot] section first, fall back to bare config *)
7676+ let ini_config =
7777+ match Init_eio.decode_path ini_doc_codec config_file with
7878+ | Ok c -> c
7979+ | Error _ -> (
8080+ (* Try bare config format (no section headers) *)
8181+ match Init_eio.decode_path bare_section_codec config_file with
8282+ | Ok c -> c
8383+ | Error e -> raise (Init_eio.err e))
8484+ in
8585+ {
8686+ name;
8787+ site = ini_config.ini_site;
8888+ email = ini_config.ini_email;
8989+ api_key = ini_config.ini_api_key;
9090+ description = ini_config.ini_description;
9191+ usage = ini_config.ini_usage;
9292+ }
9393+9494+let from_env name =
9595+ Log.info (fun m -> m "Loading config for bot %s from environment" name);
9696+ let prefix = env_prefix name in
9797+ let get_env key = Sys.getenv_opt (prefix ^ key) in
9898+ let get_required key =
9999+ match get_env key with
100100+ | Some v -> v
101101+ | None ->
102102+ failwith
103103+ (Printf.sprintf "Missing required environment variable: %s%s" prefix
104104+ key)
105105+ in
106106+ {
107107+ name;
108108+ site = get_required "SITE";
109109+ email = get_required "EMAIL";
110110+ api_key = get_required "API_KEY";
111111+ description = get_env "DESCRIPTION";
112112+ usage = get_env "USAGE";
113113+ }
114114+115115+let load_or_env ~fs name =
116116+ try load ~fs name
117117+ with _ ->
118118+ Log.debug (fun m ->
119119+ m "Config file not found, falling back to environment variables");
120120+ from_env name
121121+122122+let xdg ~fs config = Xdge.create fs ("zulip-bot/" ^ config.name)
123123+let data_dir ~fs config = Xdge.data_dir (xdg ~fs config)
124124+let state_dir ~fs config = Xdge.state_dir (xdg ~fs config)
125125+let cache_dir ~fs config = Xdge.cache_dir (xdg ~fs config)
+112
lib/zulip_bot/config.mli
···11+(*---------------------------------------------------------------------------
22+ Copyright (c) 2025 Anil Madhavapeddy <anil@recoil.org>. All rights reserved.
33+ SPDX-License-Identifier: ISC
44+ ---------------------------------------------------------------------------*)
55+66+(** Bot configuration with XDG Base Directory support.
77+88+ Configuration is loaded from XDG-compliant locations using the bot's name to
99+ locate the appropriate configuration file. The configuration file should be
1010+ in INI format with the following structure:
1111+1212+ {v
1313+ [bot]
1414+ site = https://chat.zulip.org
1515+ email = my-bot@chat.zulip.org
1616+ api_key = your_api_key_here
1717+1818+ # Optional fields
1919+ description = A helpful bot
2020+ usage = @bot help
2121+ v}
2222+2323+ Configuration files are searched in XDG config directories:
2424+ - [$XDG_CONFIG_HOME/zulip-bot/<name>/config] (typically
2525+ [~/.config/zulip-bot/<name>/config])
2626+ - System directories as fallback
2727+2828+ Environment variables can override file configuration:
2929+ - [ZULIP_<NAME>_SITE], [ZULIP_<NAME>_EMAIL], [ZULIP_<NAME>_API_KEY]
3030+3131+ Where [<NAME>] is the uppercase version of the bot name with hyphens
3232+ replaced by underscores. *)
3333+3434+type t = {
3535+ name : string; (** Bot name (used for XDG paths and identification) *)
3636+ site : string; (** Zulip server URL *)
3737+ email : string; (** Bot email address *)
3838+ api_key : string; (** Bot API key *)
3939+ description : string option; (** Optional bot description *)
4040+ usage : string option; (** Optional usage help text *)
4141+}
4242+(** Bot configuration record. *)
4343+4444+val create :
4545+ name:string ->
4646+ site:string ->
4747+ email:string ->
4848+ api_key:string ->
4949+ ?description:string ->
5050+ ?usage:string ->
5151+ unit ->
5252+ t
5353+(** [create ~name ~site ~email ~api_key ?description ?usage ()] creates a
5454+ configuration programmatically. *)
5555+5656+val load : fs:Eio.Fs.dir_ty Eio.Path.t -> string -> t
5757+(** [load ~fs name] loads configuration for a named bot from XDG config
5858+ directory.
5959+6060+ Searches for configuration in:
6161+ - [$XDG_CONFIG_HOME/zulip-bot/<name>/config]
6262+ - System config directories as fallback
6363+6464+ @param fs The Eio filesystem
6565+ @param name The bot name
6666+ @raise Eio.Io if configuration file cannot be read or parsed
6767+ @raise Failure if required fields are missing *)
6868+6969+val from_env : string -> t
7070+(** [from_env name] loads configuration from environment variables.
7171+7272+ Reads the following environment variables (where [NAME] is the uppercase bot
7373+ name with hyphens replaced by underscores):
7474+ - [ZULIP_<NAME>_SITE] (required)
7575+ - [ZULIP_<NAME>_EMAIL] (required)
7676+ - [ZULIP_<NAME>_API_KEY] (required)
7777+ - [ZULIP_<NAME>_DESCRIPTION] (optional)
7878+ - [ZULIP_<NAME>_USAGE] (optional)
7979+8080+ @raise Failure if required environment variables are not set *)
8181+8282+val load_or_env : fs:Eio.Fs.dir_ty Eio.Path.t -> string -> t
8383+(** [load_or_env ~fs name] loads config from XDG location, falling back to
8484+ environment.
8585+8686+ Attempts to load from the XDG config file first. If that fails (file not
8787+ found or unreadable), falls back to environment variables.
8888+8989+ @raise Failure if neither source provides valid configuration *)
9090+9191+val xdg : fs:Eio.Fs.dir_ty Eio.Path.t -> t -> Xdge.t
9292+(** [xdg ~fs config] returns the XDG context for this bot.
9393+9494+ Useful for accessing data and state directories for the bot:
9595+ - [Xdge.data_dir (xdg ~fs config)] for persistent data
9696+ - [Xdge.state_dir (xdg ~fs config)] for runtime state
9797+ - [Xdge.cache_dir (xdg ~fs config)] for cached data *)
9898+9999+val data_dir : fs:Eio.Fs.dir_ty Eio.Path.t -> t -> Eio.Fs.dir_ty Eio.Path.t
100100+(** [data_dir ~fs config] returns the XDG data directory for this bot.
101101+102102+ Returns [$XDG_DATA_HOME/zulip-bot/<name>], creating it if necessary. *)
103103+104104+val state_dir : fs:Eio.Fs.dir_ty Eio.Path.t -> t -> Eio.Fs.dir_ty Eio.Path.t
105105+(** [state_dir ~fs config] returns the XDG state directory for this bot.
106106+107107+ Returns [$XDG_STATE_HOME/zulip-bot/<name>], creating it if necessary. *)
108108+109109+val cache_dir : fs:Eio.Fs.dir_ty Eio.Path.t -> t -> Eio.Fs.dir_ty Eio.Path.t
110110+(** [cache_dir ~fs config] returns the XDG cache directory for this bot.
111111+112112+ Returns [$XDG_CACHE_HOME/zulip-bot/<name>], creating it if necessary. *)
···11+(*---------------------------------------------------------------------------
22+ Copyright (c) 2025 Anil Madhavapeddy <anil@recoil.org>. All rights reserved.
33+ SPDX-License-Identifier: ISC
44+ ---------------------------------------------------------------------------*)
55+66+(* Message parsing using Jsont codecs *)
77+88+let logs_src = Logs.Src.create "zulip_bot.message"
99+1010+module Log = (val Logs.src_log logs_src : Logs.LOG)
1111+1212+(** User representation *)
1313+module User = struct
1414+ type t = {
1515+ user_id : int;
1616+ email : string;
1717+ full_name : string;
1818+ short_name : string option;
1919+ unknown : Jsont.json;
2020+ (** Unknown/extra JSON fields preserved during parsing *)
2121+ }
2222+2323+ let user_id t = t.user_id
2424+ let email t = t.email
2525+ let full_name t = t.full_name
2626+ let short_name t = t.short_name
2727+2828+ (* Jsont codec for User - handles both user_id and id fields *)
2929+ let jsont : t Jsont.t =
3030+ let make email full_name short_name unknown =
3131+ (* user_id will be extracted in a custom way from the object *)
3232+ fun user_id_opt id_opt ->
3333+ let user_id =
3434+ match (user_id_opt, id_opt) with
3535+ | Some uid, _ -> uid
3636+ | None, Some id -> id
3737+ | None, None ->
3838+ Jsont.Error.msgf Jsont.Meta.none "Missing user_id or id field"
3939+ in
4040+ { user_id; email; full_name; short_name; unknown }
4141+ in
4242+ Jsont.Object.map ~kind:"User" make
4343+ |> Jsont.Object.mem "email" Jsont.string ~enc:email
4444+ |> Jsont.Object.mem "full_name" Jsont.string ~enc:full_name
4545+ |> Jsont.Object.opt_mem "short_name" Jsont.string ~enc:short_name
4646+ |> Jsont.Object.keep_unknown Jsont.json_mems ~enc:(fun t -> t.unknown)
4747+ |> Jsont.Object.opt_mem "user_id" Jsont.int ~enc:(fun t -> Some t.user_id)
4848+ |> Jsont.Object.opt_mem "id" Jsont.int ~enc:(fun _ -> None)
4949+ |> Jsont.Object.finish
5050+5151+ let of_json (json : Zulip.json) : (t, string) result =
5252+ Zulip.Encode.from_json jsont json
5353+end
5454+5555+(** Reaction representation *)
5656+module Reaction = struct
5757+ type t = {
5858+ emoji_name : string;
5959+ emoji_code : string;
6060+ reaction_type : string;
6161+ user_id : int;
6262+ unknown : Jsont.json;
6363+ (** Unknown/extra JSON fields preserved during parsing *)
6464+ }
6565+6666+ let emoji_name t = t.emoji_name
6767+ let emoji_code t = t.emoji_code
6868+ let reaction_type t = t.reaction_type
6969+ let user_id t = t.user_id
7070+7171+ (* Jsont codec for Reaction - handles user_id in different locations *)
7272+ let jsont : t Jsont.t =
7373+ (* Helper codec for nested user object - extracts just the user_id *)
7474+ let user_obj_codec =
7575+ Jsont.Object.map ~kind:"ReactionUser" Fun.id
7676+ |> Jsont.Object.mem "user_id" Jsont.int ~enc:Fun.id
7777+ |> Jsont.Object.finish
7878+ in
7979+ let make emoji_name emoji_code reaction_type unknown =
8080+ fun user_id_direct user_obj_nested ->
8181+ let user_id =
8282+ match (user_id_direct, user_obj_nested) with
8383+ | Some uid, _ -> uid
8484+ | None, Some uid -> uid
8585+ | None, None -> Jsont.Error.msgf Jsont.Meta.none "Missing user_id field"
8686+ in
8787+ { emoji_name; emoji_code; reaction_type; user_id; unknown }
8888+ in
8989+ Jsont.Object.map ~kind:"Reaction" make
9090+ |> Jsont.Object.mem "emoji_name" Jsont.string ~enc:emoji_name
9191+ |> Jsont.Object.mem "emoji_code" Jsont.string ~enc:emoji_code
9292+ |> Jsont.Object.mem "reaction_type" Jsont.string ~enc:reaction_type
9393+ |> Jsont.Object.keep_unknown Jsont.json_mems ~enc:(fun t -> t.unknown)
9494+ |> Jsont.Object.opt_mem "user_id" Jsont.int ~enc:(fun t -> Some t.user_id)
9595+ |> Jsont.Object.opt_mem "user" user_obj_codec ~enc:(fun _ -> None)
9696+ |> Jsont.Object.finish
9797+9898+ let of_json (json : Zulip.json) : (t, string) result =
9999+ Zulip.Encode.from_json jsont json
100100+end
101101+102102+let parse_reaction_json json = Reaction.of_json json
103103+let parse_user_json json = User.of_json json
104104+105105+type common = {
106106+ id : int;
107107+ sender_id : int;
108108+ sender_email : string;
109109+ sender_full_name : string;
110110+ sender_short_name : string option;
111111+ timestamp : float;
112112+ content : string;
113113+ content_type : string;
114114+ reactions : Reaction.t list;
115115+ submessages : Zulip.json list;
116116+ flags : string list;
117117+ is_me_message : bool;
118118+ client : string;
119119+ gravatar_hash : string;
120120+ avatar_url : string option;
121121+}
122122+(** Common message fields *)
123123+124124+(** Message types *)
125125+type t =
126126+ | Private of { common : common; display_recipient : User.t list }
127127+ | Stream of {
128128+ common : common;
129129+ display_recipient : string;
130130+ stream_id : int;
131131+ subject : string;
132132+ }
133133+ | Unknown of { common : common; raw_json : Zulip.json }
134134+135135+(** Helper function to parse common fields *)
136136+let parse_common json =
137137+ match json with
138138+ | Jsont.Object (fields, _) -> (
139139+ let assoc = List.map (fun ((k, _), v) -> (k, v)) fields in
140140+ let get_int key =
141141+ List.assoc_opt key assoc
142142+ |> Option.fold ~none:None ~some:(function
143143+ | Jsont.Number (f, _) -> Some (int_of_float f)
144144+ | _ -> None)
145145+ in
146146+ let get_string key =
147147+ List.assoc_opt key assoc
148148+ |> Option.fold ~none:None ~some:(function
149149+ | Jsont.String (s, _) -> Some s
150150+ | _ -> None)
151151+ in
152152+ let get_float key default =
153153+ List.assoc_opt key assoc
154154+ |> Option.fold ~none:default ~some:(function
155155+ | Jsont.Number (f, _) -> f
156156+ | _ -> default)
157157+ in
158158+ let get_bool key default =
159159+ List.assoc_opt key assoc
160160+ |> Option.fold ~none:default ~some:(function
161161+ | Jsont.Bool (b, _) -> b
162162+ | _ -> default)
163163+ in
164164+ let get_array key =
165165+ List.assoc_opt key assoc
166166+ |> Option.fold ~none:None ~some:(function
167167+ | Jsont.Array (arr, _) -> Some arr
168168+ | _ -> None)
169169+ in
170170+171171+ match
172172+ ( get_int "id",
173173+ get_int "sender_id",
174174+ get_string "sender_email",
175175+ get_string "sender_full_name" )
176176+ with
177177+ | Some id, Some sender_id, Some sender_email, Some sender_full_name ->
178178+ let sender_short_name = get_string "sender_short_name" in
179179+ let timestamp = get_float "timestamp" 0.0 in
180180+ let content = get_string "content" |> Option.value ~default:"" in
181181+ let content_type =
182182+ get_string "content_type" |> Option.value ~default:"text/html"
183183+ in
184184+185185+ let reactions =
186186+ get_array "reactions"
187187+ |> Option.fold ~none:[]
188188+ ~some:
189189+ (List.filter_map (fun r ->
190190+ parse_reaction_json r
191191+ |> Result.fold ~ok:Option.some ~error:(fun msg ->
192192+ Log.warn (fun m ->
193193+ m "Failed to parse reaction: %s" msg);
194194+ None)))
195195+ in
196196+197197+ let submessages =
198198+ get_array "submessages" |> Option.value ~default:[]
199199+ in
200200+201201+ let flags =
202202+ get_array "flags"
203203+ |> Option.fold ~none:[]
204204+ ~some:
205205+ (List.filter_map (function
206206+ | Jsont.String (s, _) -> Some s
207207+ | _ -> None))
208208+ in
209209+210210+ let is_me_message = get_bool "is_me_message" false in
211211+ let client = get_string "client" |> Option.value ~default:"" in
212212+ let gravatar_hash =
213213+ get_string "gravatar_hash" |> Option.value ~default:""
214214+ in
215215+ let avatar_url = get_string "avatar_url" in
216216+217217+ Ok
218218+ {
219219+ id;
220220+ sender_id;
221221+ sender_email;
222222+ sender_full_name;
223223+ sender_short_name;
224224+ timestamp;
225225+ content;
226226+ content_type;
227227+ reactions;
228228+ submessages;
229229+ flags;
230230+ is_me_message;
231231+ client;
232232+ gravatar_hash;
233233+ avatar_url;
234234+ }
235235+ | _ -> Error "Missing required message fields")
236236+ | _ -> Error "Expected JSON object for message"
237237+238238+(** JSON parsing *)
239239+let of_json json =
240240+ (* Helper to pretty print JSON without using jsonu *)
241241+ let json_str =
242242+ match Jsont_bytesrw.encode_string' Jsont.json json with
243243+ | Ok s -> s
244244+ | Error _ -> "<error encoding json>"
245245+ in
246246+ Log.debug (fun m -> m "Parsing message JSON: %s" json_str);
247247+248248+ match parse_common json with
249249+ | Error msg -> Error msg
250250+ | Ok common -> (
251251+ match json with
252252+ | Jsont.Object (fields, _) -> (
253253+ let assoc = List.map (fun ((k, _), v) -> (k, v)) fields in
254254+ let msg_type =
255255+ match List.assoc_opt "type" assoc with
256256+ | Some (Jsont.String (s, _)) -> Some s
257257+ | _ -> None
258258+ in
259259+ match msg_type with
260260+ | Some "private" -> (
261261+ match List.assoc_opt "display_recipient" assoc with
262262+ | Some (Jsont.Array (recipient_json, _)) ->
263263+ let users =
264264+ List.filter_map
265265+ (fun u ->
266266+ match parse_user_json u with
267267+ | Ok user -> Some user
268268+ | Error msg ->
269269+ Log.warn (fun m ->
270270+ m
271271+ "Failed to parse user in display_recipient: \
272272+ %s"
273273+ msg);
274274+ None)
275275+ recipient_json
276276+ in
277277+278278+ if List.length users = 0 && List.length recipient_json > 0
279279+ then Error "Failed to parse any users in display_recipient"
280280+ else Ok (Private { common; display_recipient = users })
281281+ | _ ->
282282+ Log.warn (fun m ->
283283+ m "display_recipient is not an array for private message");
284284+ Ok (Unknown { common; raw_json = json }))
285285+ | Some "stream" -> (
286286+ let display_recipient =
287287+ match List.assoc_opt "display_recipient" assoc with
288288+ | Some (Jsont.String (s, _)) -> Some s
289289+ | _ -> None
290290+ in
291291+ let stream_id =
292292+ match List.assoc_opt "stream_id" assoc with
293293+ | Some (Jsont.Number (f, _)) -> Some (int_of_float f)
294294+ | _ -> None
295295+ in
296296+ let subject =
297297+ match List.assoc_opt "subject" assoc with
298298+ | Some (Jsont.String (s, _)) -> Some s
299299+ | _ -> None
300300+ in
301301+ match (display_recipient, stream_id, subject) with
302302+ | Some display_recipient, Some stream_id, Some subject ->
303303+ Ok (Stream { common; display_recipient; stream_id; subject })
304304+ | _ ->
305305+ Log.warn (fun m ->
306306+ m "Missing required fields for stream message");
307307+ Ok (Unknown { common; raw_json = json }))
308308+ | Some unknown_type ->
309309+ Log.warn (fun m -> m "Unknown message type: %s" unknown_type);
310310+ Ok (Unknown { common; raw_json = json })
311311+ | None ->
312312+ Log.warn (fun m -> m "No message type field found");
313313+ Ok (Unknown { common; raw_json = json }))
314314+ | _ -> Error "Expected JSON object for message")
315315+316316+(** Accessor functions *)
317317+let get_common = function
318318+ | Private { common; _ } -> common
319319+ | Stream { common; _ } -> common
320320+ | Unknown { common; _ } -> common
321321+322322+let id msg = (get_common msg).id
323323+let sender_id msg = (get_common msg).sender_id
324324+let sender_email msg = (get_common msg).sender_email
325325+let sender_full_name msg = (get_common msg).sender_full_name
326326+let sender_short_name msg = (get_common msg).sender_short_name
327327+let timestamp msg = (get_common msg).timestamp
328328+let content msg = (get_common msg).content
329329+let content_type msg = (get_common msg).content_type
330330+let reactions msg = (get_common msg).reactions
331331+let submessages msg = (get_common msg).submessages
332332+let flags msg = (get_common msg).flags
333333+let is_me_message msg = (get_common msg).is_me_message
334334+let client msg = (get_common msg).client
335335+let gravatar_hash msg = (get_common msg).gravatar_hash
336336+let avatar_url msg = (get_common msg).avatar_url
337337+338338+(** Helper functions *)
339339+let is_private = function Private _ -> true | _ -> false
340340+341341+let is_stream = function Stream _ -> true | _ -> false
342342+let is_from_self msg ~bot_user_id = sender_id msg = bot_user_id
343343+let is_from_email msg ~email = sender_email msg = email
344344+345345+let get_reply_to = function
346346+ | Private { display_recipient; _ } ->
347347+ display_recipient |> List.map User.email |> String.concat ", "
348348+ | Stream { display_recipient; _ } -> display_recipient
349349+ | Unknown _ -> ""
350350+351351+(** Utility functions *)
352352+let is_mentioned msg ~user_email =
353353+ let content_text = content msg in
354354+ (* Check for both email and username mentions *)
355355+ let email_mention = "@**" ^ user_email ^ "**" in
356356+ (* Also check for username mention (part before @ in email) *)
357357+ let username =
358358+ match String.index_opt user_email '@' with
359359+ | Some idx -> String.sub user_email 0 idx
360360+ | None -> user_email
361361+ in
362362+ let username_mention = "@**" ^ username ^ "**" in
363363+364364+ let contains text pattern =
365365+ if String.length pattern = 0 || String.length pattern > String.length text
366366+ then false
367367+ else
368368+ let rec search_from pos =
369369+ if pos > String.length text - String.length pattern then false
370370+ else if String.sub text pos (String.length pattern) = pattern then true
371371+ else search_from (pos + 1)
372372+ in
373373+ search_from 0
374374+ in
375375+ contains content_text email_mention || contains content_text username_mention
376376+377377+let strip_mention msg ~user_email =
378378+ let content_text = content msg in
379379+ (* Check for both email and username mentions *)
380380+ let email_mention = "@**" ^ user_email ^ "**" in
381381+ let username =
382382+ match String.index_opt user_email '@' with
383383+ | Some idx -> String.sub user_email 0 idx
384384+ | None -> user_email
385385+ in
386386+ let username_mention = "@**" ^ username ^ "**" in
387387+388388+ (* Remove whichever mention pattern is found at the start *)
389389+ let without_mention =
390390+ if String.starts_with ~prefix:email_mention content_text then
391391+ String.sub content_text
392392+ (String.length email_mention)
393393+ (String.length content_text - String.length email_mention)
394394+ else if String.starts_with ~prefix:username_mention content_text then
395395+ String.sub content_text
396396+ (String.length username_mention)
397397+ (String.length content_text - String.length username_mention)
398398+ else content_text
399399+ in
400400+ String.trim without_mention
401401+402402+let extract_command msg =
403403+ let content_text = String.trim (content msg) in
404404+ if String.length content_text > 0 && content_text.[0] = '!' then
405405+ Some (String.sub content_text 1 (String.length content_text - 1))
406406+ else None
407407+408408+let parse_command msg =
409409+ match extract_command msg with
410410+ | None -> None
411411+ | Some cmd_string -> (
412412+ let parts = String.split_on_char ' ' (String.trim cmd_string) in
413413+ match parts with [] -> None | cmd :: args -> Some (cmd, args))
414414+415415+(** Pretty printing *)
416416+let pp_user fmt user =
417417+ Format.fprintf fmt "{ user_id=%d; email=%s; full_name=%s }"
418418+ (User.user_id user) (User.email user) (User.full_name user)
419419+420420+let _pp_reaction fmt reaction =
421421+ Format.fprintf fmt "{ emoji_name=%s; user_id=%d }"
422422+ (Reaction.emoji_name reaction)
423423+ (Reaction.user_id reaction)
424424+425425+let pp fmt = function
426426+ | Private { common; display_recipient } ->
427427+ Format.fprintf fmt
428428+ "Private { id=%d; sender=%s; recipients=[%a]; content=%S }" common.id
429429+ common.sender_email
430430+ (Format.pp_print_list
431431+ ~pp_sep:(fun fmt () -> Format.fprintf fmt "; ")
432432+ pp_user)
433433+ display_recipient common.content
434434+ | Stream { common; display_recipient; subject; _ } ->
435435+ Format.fprintf fmt
436436+ "Stream { id=%d; sender=%s; stream=%s; subject=%s; content=%S }"
437437+ common.id common.sender_email display_recipient subject common.content
438438+ | Unknown { common; _ } ->
439439+ Format.fprintf fmt "Unknown { id=%d; sender=%s; content=%S }" common.id
440440+ common.sender_email common.content
441441+442442+(** ANSI colored pretty printing for debugging *)
443443+let pp_ansi ?(show_json = false) ppf msg =
444444+ let open Fmt in
445445+ let blue = styled `Blue string in
446446+ let green = styled `Green string in
447447+ let yellow = styled `Yellow string in
448448+ let magenta = styled `Magenta string in
449449+ let cyan = styled `Cyan string in
450450+ let dim = styled (`Fg `Black) string in
451451+452452+ match msg with
453453+ | Private { common; display_recipient } ->
454454+ pf ppf "%a %a %a %a %a" (styled `Bold blue) "DM" dim
455455+ (Printf.sprintf "[#%d]" common.id)
456456+ (styled `Cyan string) common.sender_email dim "→" green
457457+ (Printf.sprintf "%S" common.content);
458458+ if show_json then
459459+ pf ppf "@. %a %a" dim "Recipients:"
460460+ (list ~sep:(const string ", ") (fun fmt u -> cyan fmt (User.email u)))
461461+ display_recipient
462462+ | Stream { common; display_recipient; subject; _ } ->
463463+ pf ppf "%a %a %a%a%a %a %a" (styled `Bold yellow) "STREAM" dim
464464+ (Printf.sprintf "[#%d]" common.id)
465465+ magenta display_recipient dim "/" cyan subject (styled `Cyan string)
466466+ common.sender_email green
467467+ (Printf.sprintf "%S" common.content)
468468+ | Unknown { common; _ } ->
469469+ pf ppf "%a %a %a %a"
470470+ (styled `Bold (styled (`Fg `Red) string))
471471+ "UNKNOWN" dim
472472+ (Printf.sprintf "[#%d]" common.id)
473473+ (styled `Cyan string) common.sender_email
474474+ (styled (`Fg `Red) string)
475475+ (Printf.sprintf "%S" common.content)
476476+477477+(** Pretty print JSON for debugging *)
478478+let pp_json_debug ppf json =
479479+ let open Fmt in
480480+ let json_str =
481481+ match Jsont_bytesrw.encode_string' Jsont.json json with
482482+ | Ok s -> s
483483+ | Error _ -> "<error encoding json>"
484484+ in
485485+ pf ppf "@[<v>%a@.%a@]"
486486+ (styled `Bold (styled (`Fg `Blue) string))
487487+ "Raw JSON:"
488488+ (styled (`Fg `Black) string)
489489+ json_str
+121
lib/zulip_bot/message.mli
···11+(*---------------------------------------------------------------------------
22+ Copyright (c) 2025 Anil Madhavapeddy <anil@recoil.org>. All rights reserved.
33+ SPDX-License-Identifier: ISC
44+ ---------------------------------------------------------------------------*)
55+66+(** Zulip message types and utilities for bots *)
77+88+(** User representation *)
99+module User : sig
1010+ type t = {
1111+ user_id : int;
1212+ email : string;
1313+ full_name : string;
1414+ short_name : string option;
1515+ unknown : Jsont.json;
1616+ }
1717+1818+ val user_id : t -> int
1919+ val email : t -> string
2020+ val full_name : t -> string
2121+ val short_name : t -> string option
2222+2323+ val jsont : t Jsont.t
2424+ (** Jsont codec for User *)
2525+end
2626+2727+(** Reaction representation *)
2828+module Reaction : sig
2929+ type t = {
3030+ emoji_name : string;
3131+ emoji_code : string;
3232+ reaction_type : string;
3333+ user_id : int;
3434+ unknown : Jsont.json;
3535+ }
3636+3737+ val emoji_name : t -> string
3838+ val emoji_code : t -> string
3939+ val reaction_type : t -> string
4040+ val user_id : t -> int
4141+4242+ val jsont : t Jsont.t
4343+ (** Jsont codec for Reaction *)
4444+end
4545+4646+type common = {
4747+ id : int;
4848+ sender_id : int;
4949+ sender_email : string;
5050+ sender_full_name : string;
5151+ sender_short_name : string option;
5252+ timestamp : float;
5353+ content : string;
5454+ content_type : string;
5555+ reactions : Reaction.t list;
5656+ submessages : Zulip.json list;
5757+ flags : string list;
5858+ is_me_message : bool;
5959+ client : string;
6060+ gravatar_hash : string;
6161+ avatar_url : string option;
6262+}
6363+(** Common message fields *)
6464+6565+(** Message types *)
6666+type t =
6767+ | Private of { common : common; display_recipient : User.t list }
6868+ | Stream of {
6969+ common : common;
7070+ display_recipient : string;
7171+ stream_id : int;
7272+ subject : string;
7373+ }
7474+ | Unknown of { common : common; raw_json : Zulip.json }
7575+7676+(** Accessor functions *)
7777+7878+val id : t -> int
7979+val sender_id : t -> int
8080+val sender_email : t -> string
8181+val sender_full_name : t -> string
8282+val sender_short_name : t -> string option
8383+val timestamp : t -> float
8484+val content : t -> string
8585+val content_type : t -> string
8686+val reactions : t -> Reaction.t list
8787+val submessages : t -> Zulip.json list
8888+val flags : t -> string list
8989+val is_me_message : t -> bool
9090+val client : t -> string
9191+val gravatar_hash : t -> string
9292+val avatar_url : t -> string option
9393+9494+(** Helper functions *)
9595+9696+val is_private : t -> bool
9797+val is_stream : t -> bool
9898+val is_from_self : t -> bot_user_id:int -> bool
9999+val is_from_email : t -> email:string -> bool
100100+val get_reply_to : t -> string
101101+102102+(** Utility functions *)
103103+104104+val is_mentioned : t -> user_email:string -> bool
105105+val strip_mention : t -> user_email:string -> string
106106+val extract_command : t -> string option
107107+val parse_command : t -> (string * string list) option
108108+109109+(** JSON parsing *)
110110+111111+val of_json : Zulip.json -> (t, string) result
112112+113113+(** Pretty printing *)
114114+115115+val pp : Format.formatter -> t -> unit
116116+117117+val pp_ansi : ?show_json:bool -> Format.formatter -> t -> unit
118118+(** ANSI colored pretty printing for debugging *)
119119+120120+val pp_json_debug : Format.formatter -> Zulip.json -> unit
121121+(** Pretty print JSON for debugging *)
+15
lib/zulip_bot/response.ml
···11+(*---------------------------------------------------------------------------
22+ Copyright (c) 2025 Anil Madhavapeddy <anil@recoil.org>. All rights reserved.
33+ SPDX-License-Identifier: ISC
44+ ---------------------------------------------------------------------------*)
55+66+type t =
77+ | Reply of string
88+ | Direct of { recipients : string list; content : string }
99+ | Stream of { stream : string; topic : string; content : string }
1010+ | Silent
1111+1212+let reply content = Reply content
1313+let direct ~recipients ~content = Direct { recipients; content }
1414+let stream ~stream ~topic ~content = Stream { stream; topic; content }
1515+let silent = Silent
+36
lib/zulip_bot/response.mli
···11+(*---------------------------------------------------------------------------
22+ Copyright (c) 2025 Anil Madhavapeddy <anil@recoil.org>. All rights reserved.
33+ SPDX-License-Identifier: ISC
44+ ---------------------------------------------------------------------------*)
55+66+(** Response types that bot handlers can return.
77+88+ A handler processes a message and returns a response indicating what action
99+ to take. The bot runner then executes the appropriate Zulip API calls to
1010+ send the response. *)
1111+1212+type t =
1313+ | Reply of string
1414+ (** Reply in the same context as the incoming message. For stream
1515+ messages, replies to the same stream and topic. For private messages,
1616+ replies to the sender. *)
1717+ | Direct of { recipients : string list; content : string }
1818+ (** Send a direct (private) message to specific users. Recipients are
1919+ specified by email address. *)
2020+ | Stream of { stream : string; topic : string; content : string }
2121+ (** Send a message to a stream with a specific topic. *)
2222+ | Silent (** No response - the bot acknowledges but does not reply. *)
2323+2424+(** {1 Constructors} *)
2525+2626+val reply : string -> t
2727+(** [reply content] creates a reply response. *)
2828+2929+val direct : recipients:string list -> content:string -> t
3030+(** [direct ~recipients ~content] creates a direct message response. *)
3131+3232+val stream : stream:string -> topic:string -> content:string -> t
3333+(** [stream ~stream ~topic ~content] creates a stream message response. *)
3434+3535+val silent : t
3636+(** [silent] is a response that produces no output. *)
+132
lib/zulip_bot/storage.ml
···11+(*---------------------------------------------------------------------------
22+ Copyright (c) 2025 Anil Madhavapeddy <anil@recoil.org>. All rights reserved.
33+ SPDX-License-Identifier: ISC
44+ ---------------------------------------------------------------------------*)
55+66+let src = Logs.Src.create "zulip_bot.storage" ~doc:"Zulip bot storage"
77+88+module Log = (val Logs.src_log src : Logs.LOG)
99+module String_map = Map.Make (String)
1010+1111+type t = { client : Zulip.Client.t; cache : (string, string) Hashtbl.t }
1212+1313+type storage_response = { storage : string String_map.t; unknown : Jsont.json }
1414+(** Storage response type - {"storage": {...}} *)
1515+1616+let storage_response_jsont : storage_response Jsont.t =
1717+ let make storage unknown = { storage; unknown } in
1818+ let storage_map_jsont =
1919+ Jsont.Object.map ~kind:"StorageMap" Fun.id
2020+ |> Jsont.Object.keep_unknown
2121+ (Jsont.Object.Mems.string_map Jsont.string)
2222+ ~enc:Fun.id
2323+ |> Jsont.Object.finish
2424+ in
2525+ Jsont.Object.map ~kind:"StorageResponse" make
2626+ |> Jsont.Object.mem "storage" storage_map_jsont
2727+ ~enc:(fun r -> r.storage)
2828+ ~dec_absent:String_map.empty
2929+ |> Jsont.Object.keep_unknown Jsont.json_mems ~enc:(fun r -> r.unknown)
3030+ |> Jsont.Object.finish
3131+3232+let create client =
3333+ Log.info (fun m -> m "Creating bot storage");
3434+ let cache = Hashtbl.create 16 in
3535+ (* Fetch all existing storage from server to populate cache *)
3636+ (try
3737+ let json =
3838+ Zulip.Client.request client ~method_:`GET ~path:"/api/v1/bot_storage" ()
3939+ in
4040+ match Zulip.Encode.from_json storage_response_jsont json with
4141+ | Ok response ->
4242+ String_map.iter
4343+ (fun k v ->
4444+ Log.debug (fun m -> m "Loaded key from server: %s" k);
4545+ Hashtbl.add cache k v)
4646+ response.storage
4747+ | Error msg ->
4848+ Log.warn (fun m -> m "Failed to parse storage response: %s" msg)
4949+ with Eio.Exn.Io (e, _) ->
5050+ Log.warn (fun m ->
5151+ m "Failed to load existing storage: %a" Eio.Exn.pp_err e));
5252+ { client; cache }
5353+5454+let encode_storage_update keys_values =
5555+ let storage_obj =
5656+ List.map
5757+ (fun (k, v) -> ((k, Jsont.Meta.none), Jsont.String (v, Jsont.Meta.none)))
5858+ keys_values
5959+ in
6060+ let json_obj = Jsont.Object (storage_obj, Jsont.Meta.none) in
6161+ let json_str =
6262+ Jsont_bytesrw.encode_string' Jsont.json json_obj |> Result.get_ok
6363+ in
6464+ "storage=" ^ Uri.pct_encode json_str
6565+6666+let get t key =
6767+ Log.debug (fun m -> m "Getting value for key: %s" key);
6868+ match Hashtbl.find_opt t.cache key with
6969+ | Some value ->
7070+ Log.debug (fun m -> m "Found key in cache: %s" key);
7171+ Some value
7272+ | None -> (
7373+ let params = [ ("keys", "[\"" ^ key ^ "\"]") ] in
7474+ try
7575+ let json =
7676+ Zulip.Client.request t.client ~method_:`GET
7777+ ~path:"/api/v1/bot_storage" ~params ()
7878+ in
7979+ match Zulip.Encode.from_json storage_response_jsont json with
8080+ | Ok response -> (
8181+ match String_map.find_opt key response.storage with
8282+ | Some value ->
8383+ Log.debug (fun m -> m "Retrieved key from API: %s" key);
8484+ Hashtbl.add t.cache key value;
8585+ Some value
8686+ | None ->
8787+ Log.debug (fun m -> m "Key not found in API: %s" key);
8888+ None)
8989+ | Error msg ->
9090+ Log.warn (fun m -> m "Failed to parse storage response: %s" msg);
9191+ None
9292+ with Eio.Exn.Io (e, _) ->
9393+ Log.warn (fun m -> m "Error fetching key %s: %a" key Eio.Exn.pp_err e);
9494+ None)
9595+9696+let set t key value =
9797+ Log.debug (fun m -> m "Storing key: %s" key);
9898+ Hashtbl.replace t.cache key value;
9999+ let body = encode_storage_update [ (key, value) ] in
100100+ Log.debug (fun m -> m "Sending storage update");
101101+ let _response =
102102+ Zulip.Client.request t.client ~method_:`PUT ~path:"/api/v1/bot_storage"
103103+ ~body ()
104104+ in
105105+ Log.debug (fun m -> m "Successfully stored key: %s" key)
106106+107107+let remove t key =
108108+ Log.debug (fun m -> m "Removing key: %s" key);
109109+ Hashtbl.remove t.cache key;
110110+ (* Zulip API doesn't have a delete endpoint, so we set to empty string *)
111111+ set t key ""
112112+113113+let mem t key =
114114+ if Hashtbl.mem t.cache key then true
115115+ else match get t key with Some _ -> true | None -> false
116116+117117+let keys t =
118118+ let json =
119119+ Zulip.Client.request t.client ~method_:`GET ~path:"/api/v1/bot_storage" ()
120120+ in
121121+ match Zulip.Encode.from_json storage_response_jsont json with
122122+ | Ok response ->
123123+ let api_keys =
124124+ String_map.fold (fun k _ acc -> k :: acc) response.storage []
125125+ in
126126+ let cache_keys = Hashtbl.fold (fun k _ acc -> k :: acc) t.cache [] in
127127+ List.sort_uniq String.compare (api_keys @ cache_keys)
128128+ | Error msg ->
129129+ Log.warn (fun m -> m "Failed to parse storage response: %s" msg);
130130+ []
131131+132132+let client t = t.client
+48
lib/zulip_bot/storage.mli
···11+(*---------------------------------------------------------------------------
22+ Copyright (c) 2025 Anil Madhavapeddy <anil@recoil.org>. All rights reserved.
33+ SPDX-License-Identifier: ISC
44+ ---------------------------------------------------------------------------*)
55+66+(** Bot storage - key-value storage via the Zulip bot storage API.
77+88+ Provides persistent storage for bots using Zulip's built-in bot storage
99+ mechanism. Values are cached locally and synchronized with the server.
1010+1111+ All mutation functions raise [Eio.Io] with [Zulip.E error] on failure. *)
1212+1313+type t
1414+(** An opaque storage handle. *)
1515+1616+val create : Zulip.Client.t -> t
1717+(** [create client] creates a new storage instance.
1818+1919+ The storage is initialized by fetching all existing keys from the server
2020+ into a local cache. *)
2121+2222+val get : t -> string -> string option
2323+(** [get t key] retrieves a value from storage.
2424+2525+ Returns [Some value] if the key exists, [None] otherwise. Checks the local
2626+ cache first, then queries the server if not found. *)
2727+2828+val set : t -> string -> string -> unit
2929+(** [set t key value] stores a value.
3030+3131+ The value is cached locally and immediately written to the server.
3232+ @raise Eio.Io on server communication failure *)
3333+3434+val remove : t -> string -> unit
3535+(** [remove t key] removes a key from storage.
3636+3737+ @raise Eio.Io on server communication failure *)
3838+3939+val mem : t -> string -> bool
4040+(** [mem t key] checks if a key exists in storage. *)
4141+4242+val keys : t -> string list
4343+(** [keys t] returns all keys in storage.
4444+4545+ @raise Eio.Io on server communication failure *)
4646+4747+val client : t -> Zulip.Client.t
4848+(** [client t] returns the underlying Zulip client. *)
+42
zulip.opam
···11+# This file is generated by dune, edit dune-project instead
22+opam-version: "2.0"
33+synopsis: "OCaml bindings for the Zulip REST API with bot framework"
44+description:
55+ "High-quality OCaml bindings to the Zulip REST API using Eio for async operations. Includes a fiber-based bot framework (zulip.bot) with XDG configuration support."
66+maintainer: ["Anil Madhavapeddy <anil@recoil.org>"]
77+authors: ["Anil Madhavapeddy"]
88+license: "ISC"
99+homepage: "https://tangled.org/@anil.recoil.org/ocaml-zulip"
1010+bug-reports: "https://tangled.org/@anil.recoil.org/ocaml-zulip/issues"
1111+depends: [
1212+ "dune" {>= "3.0"}
1313+ "ocaml" {>= "5.1.0"}
1414+ "eio"
1515+ "requests"
1616+ "uri"
1717+ "base64"
1818+ "init"
1919+ "jsont"
2020+ "logs"
2121+ "fmt"
2222+ "xdge"
2323+ "eio_main"
2424+ "cmdliner"
2525+ "odoc" {with-doc}
2626+ "alcotest" {with-test}
2727+ "mirage-crypto-rng" {with-test}
2828+]
2929+build: [
3030+ ["dune" "subst"] {dev}
3131+ [
3232+ "dune"
3333+ "build"
3434+ "-p"
3535+ name
3636+ "-j"
3737+ jobs
3838+ "@install"
3939+ "@runtest" {with-test}
4040+ "@doc" {with-doc}
4141+ ]
4242+]