···11-(* Enhanced Echo Bot for Zulip with Logging and CLI
22- Responds to direct messages and mentions by echoing back the message
33- Uses the new functional Zulip_bot API *)
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. *)
410511open Zulip_bot
61277-(* Set up logging *)
813let src = Logs.Src.create "echo_bot" ~doc:"Zulip Echo Bot"
9141015module Log = (val Logs.src_log src : Logs.LOG)
11161217(* The handler is now just a function *)
1318let echo_handler ~storage ~identity msg =
1414- Log.debug (fun m -> m "@[<h>Received: %a@]" (Message.pp_ansi ~show_json:false) msg);
1919+ Log.debug (fun m ->
2020+ m "@[<h>Received: %a@]" (Message.pp_ansi ~show_json:false) msg);
15211622 let bot_email = identity.Bot.email in
1723 let sender_email = Message.sender_email msg in
···4450 else if lower_msg = "ping" then (
4551 Log.info (fun m -> m "Responding to ping from %s" sender_name);
4652 Printf.sprintf "Pong! (from %s)" sender_name)
4747- else if String.starts_with ~prefix:"store " lower_msg then (
5353+ else if String.starts_with ~prefix:"store " lower_msg then
4854 let parts =
4955 String.sub cleaned_msg 6 (String.length cleaned_msg - 6)
5056 |> String.trim
5157 in
5258 match String.index_opt parts ' ' with
5353- | Some idx ->
5959+ | Some idx -> (
5460 let key = String.sub parts 0 idx |> String.trim in
5561 let value =
5662 String.sub parts (idx + 1) (String.length parts - idx - 1)
5763 |> String.trim
5864 in
5959- (try
6060- Storage.set storage key value;
6161- Log.info (fun m ->
6262- m "Stored key=%s value=%s for user %s" key value sender_name);
6363- Printf.sprintf "Stored: `%s` = `%s`" key value
6464- with Eio.Exn.Io _ as e ->
6565- Log.err (fun m ->
6666- m "Failed to store key=%s: %s" key (Printexc.to_string e));
6767- Printf.sprintf "Failed to store: %s" (Printexc.to_string e))
6868- | None -> "Usage: `store <key> <value>` - Example: `store name John`")
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`"
6975 else if String.starts_with ~prefix:"get " lower_msg then (
7076 let key =
7171- String.sub cleaned_msg 4 (String.length cleaned_msg - 4) |> String.trim
7777+ String.sub cleaned_msg 4 (String.length cleaned_msg - 4)
7878+ |> String.trim
7279 in
7380 match Storage.get storage key with
7481 | Some value ->
···8087 Printf.sprintf "Key not found: `%s`" key)
8188 else if String.starts_with ~prefix:"delete " lower_msg then (
8289 let key =
8383- String.sub cleaned_msg 7 (String.length cleaned_msg - 7) |> String.trim
9090+ String.sub cleaned_msg 7 (String.length cleaned_msg - 7)
9191+ |> String.trim
8492 in
8593 try
8694 Storage.remove storage key;
···9098 Log.err (fun m ->
9199 m "Failed to delete key=%s: %s" key (Printexc.to_string e));
92100 Printf.sprintf "Failed to delete: %s" (Printexc.to_string e))
9393- else if lower_msg = "list" then (
101101+ else if lower_msg = "list" then
94102 try
95103 let keys = Storage.keys storage in
96104 if keys = [] then
···99107 let key_list =
100108 String.concat "\n" (List.map (fun k -> "* `" ^ k ^ "`") keys)
101109 in
102102- Printf.sprintf "Stored keys:\n%s\n\nUse `get <key>` to retrieve values."
103103- key_list
110110+ Printf.sprintf
111111+ "Stored keys:\n%s\n\nUse `get <key>` to retrieve values." key_list
104112 with Eio.Exn.Io _ as e ->
105105- Printf.sprintf "Failed to list keys: %s" (Printexc.to_string e))
113113+ Printf.sprintf "Failed to list keys: %s" (Printexc.to_string e)
106114 else Printf.sprintf "Echo from %s: %s" sender_name cleaned_msg
107115 in
108116 Log.debug (fun m -> m "Generated response: %s" response_content);
109117 Response.reply response_content
110118111111-let run_echo_bot config_path verbosity env =
112112- (* Set up logging based on verbosity *)
113113- Logs.set_reporter (Logs_fmt.reporter ());
114114- let log_level =
115115- match verbosity with 0 -> Logs.Info | 1 -> Logs.Debug | _ -> Logs.Debug
116116- in
117117- Logs.set_level (Some log_level);
118118- Logs.Src.set_level src (Some log_level);
119119-119119+let run_echo_bot config env =
120120 Log.app (fun m -> m "Starting Zulip Echo Bot");
121121- Log.app (fun m -> m "Log level: %s" (Logs.level_to_string (Some log_level)));
122121 Log.app (fun m -> m "=============================\n");
123122124123 Eio.Switch.run @@ fun sw ->
125125- let fs = Eio.Stdenv.fs env in
126126-127127- (* Load configuration - either from XDG or from provided path *)
128128- let config =
129129- match config_path with
130130- | Some path ->
131131- (* Load from .zuliprc style file for backwards compatibility *)
132132- let auth = Zulip.Auth.from_zuliprc ~path () in
133133- Config.create ~name:"echo-bot" ~site:(Zulip.Auth.server_url auth)
134134- ~email:(Zulip.Auth.email auth) ~api_key:(Zulip.Auth.api_key auth)
135135- ~description:"A simple echo bot that repeats messages" ()
136136- | None -> (
137137- (* Try XDG config first, fall back to ~/.zuliprc *)
138138- try Config.load ~fs "echo-bot"
139139- with _ ->
140140- let auth = Zulip.Auth.from_zuliprc () in
141141- Config.create ~name:"echo-bot" ~site:(Zulip.Auth.server_url auth)
142142- ~email:(Zulip.Auth.email auth) ~api_key:(Zulip.Auth.api_key auth)
143143- ~description:"A simple echo bot that repeats messages" ())
144144- in
145145-146146- Log.info (fun m -> m "Loaded configuration for: %s" config.email);
147147- Log.info (fun m -> m "Server: %s" config.site);
124124+ Log.info (fun m -> m "Loaded configuration for: %s" config.Config.email);
125125+ Log.info (fun m -> m "Server: %s" config.Config.site);
148126149127 Log.app (fun m -> m "Echo bot is running!");
150128 Log.app (fun m -> m "Send a direct message or mention the bot in a channel.");
151129 Log.app (fun m -> m "Commands: 'help', 'ping', or any message to echo");
152130 Log.app (fun m -> m "Press Ctrl+C to stop.\n");
153131154154- (* Run the bot - this is now just a simple function call *)
155155- try Bot.run ~sw ~env ~config ~handler:echo_handler
156156- with
157157- | Sys.Break -> Log.info (fun m -> m "Received interrupt signal, shutting down")
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")
158135 | exn ->
159159- Log.err (fun m -> m "Bot crashed with exception: %s" (Printexc.to_string exn));
136136+ Log.err (fun m ->
137137+ m "Bot crashed with exception: %s" (Printexc.to_string exn));
160138 Log.debug (fun m -> m "Backtrace: %s" (Printexc.get_backtrace ()));
161139 raise exn
162140163163-(* Command-line interface *)
164141open Cmdliner
165142166166-let config_file =
167167- let doc = "Path to .zuliprc configuration file" in
168168- Arg.(value & opt (some string) None & info [ "c"; "config" ] ~docv:"FILE" ~doc)
169169-170170-let verbosity =
171171- let doc = "Increase verbosity. Use multiple times for more verbose output." in
172172- Arg.(value & flag_all & info [ "v"; "verbose" ] ~doc)
173173-174174-let verbosity_term = Term.(const List.length $ verbosity)
175175-176143let bot_cmd eio_env =
177144 let doc = "Zulip Echo Bot with verbose logging" in
178145 let man =
···186153 "The bot reads configuration from XDG config directory \
187154 (~/.config/zulip-bot/echo-bot/config) or from a .zuliprc file.";
188155 `S "LOGGING";
189189- `P "Use -v for info level logging, -vv for debug level logging.";
156156+ `P "Use -v for debug level logging, --verbose-http for HTTP-level details.";
190157 `S "COMMANDS";
191158 `P "The bot responds to:";
192159 `P "- 'help' - Show usage information";
···199166 ]
200167 in
201168 let info = Cmd.info "echo_bot" ~version:"2.0.0" ~doc ~man in
202202- Cmd.v info
203203- Term.(const run_echo_bot $ config_file $ verbosity_term $ const eio_env)
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)
204171205172let () =
206173 Mirage_crypto_rng_unix.use_default ();
+1-1
examples/echo_bot.mli
···11-(** Echo bot example *)11+(** Echo bot example *)
+11-13
examples/example.ml
···11open Zulip
2233-let () = Eio_main.run @@ fun env ->
33+let () =
44+ Eio_main.run @@ fun env ->
45 Eio.Switch.run @@ fun sw ->
55-66 Printf.printf "OCaml Zulip Library Example\n";
77 Printf.printf "===========================\n\n";
8899 (* Create authentication *)
1010- let auth = Auth.create
1111- ~server_url:"https://example.zulipchat.com"
1212- ~email:"bot@example.com"
1313- ~api_key:"your-api-key" in
1010+ let auth =
1111+ Auth.create ~server_url:"https://example.zulipchat.com"
1212+ ~email:"bot@example.com" ~api_key:"your-api-key"
1313+ in
14141515 Printf.printf "Created auth for: %s\n" (Auth.email auth);
1616 Printf.printf "Server URL: %s\n" (Auth.server_url auth);
17171818 (* Create a message *)
1919- let message = Message.create
2020- ~type_:`Channel
2121- ~to_:["general"]
2222- ~content:"Hello from OCaml Zulip library!"
2323- ~topic:"Test"
2424- () in
1919+ let message =
2020+ Message.create ~type_:`Channel ~to_:[ "general" ]
2121+ ~content:"Hello from OCaml Zulip library!" ~topic:"Test" ()
2222+ in
25232624 Printf.printf "\nCreated message:\n";
2725 Printf.printf "- Type: %s\n" (Message_type.to_string (Message.type_ message));
···4139 with Eio.Exn.Io _ as e ->
4240 Printf.printf "Mock request failed: %s\n" (Printexc.to_string e));
43414444- Printf.printf "\nLibrary is working correctly!\n"4242+ Printf.printf "\nLibrary is working correctly!\n"
+1-1
examples/example.mli
···11-(** Basic Zulip library usage example *)11+(** Basic Zulip library usage example *)
+379
examples/regression_test.ml
···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))
+18-21
examples/test_client.ml
···1111 with Eio.Exn.Io _ as e ->
1212 Printf.eprintf "Failed to load auth: %s\n" (Printexc.to_string e);
1313 (* Create a test auth *)
1414- Zulip.Auth.create
1515- ~server_url:"https://example.zulipchat.com"
1616- ~email:"bot@example.com"
1717- ~api_key:"test_api_key"
1414+ Zulip.Auth.create ~server_url:"https://example.zulipchat.com"
1515+ ~email:"bot@example.com" ~api_key:"test_api_key"
18161917let test_message_send env auth =
2018 Printf.printf "\nTesting message send...\n";
···2321 let client = Zulip.Client.create ~sw env auth in
24222523 (* Create a test message *)
2626- let message = Zulip.Message.create
2727- ~type_:`Channel
2828- ~to_:["general"]
2929- ~topic:"Test Topic"
3030- ~content:"Hello from OCaml Zulip client using requests library!"
3131- ()
2424+ let message =
2525+ Zulip.Message.create ~type_:`Channel ~to_:[ "general" ] ~topic:"Test Topic"
2626+ ~content:"Hello from OCaml Zulip client using requests library!" ()
3227 in
33283429 try
···4540 let client = Zulip.Client.create ~sw env auth in
46414742 try
4848- let json = Zulip.Messages.get_messages client ~num_before:5 ~num_after:0 () in
4343+ let json =
4444+ Zulip.Messages.get_messages client ~anchor:Newest ~num_before:5
4545+ ~num_after:0 ()
4646+ in
4947 Printf.printf "Fetched messages successfully!\n";
5050- (match json with
5151- | Jsont.Object (fields, _) ->
5252- let assoc = List.map (fun ((k, _), v) -> (k, v)) fields in
5353- (match List.assoc_opt "messages" assoc with
5454- | Some (Jsont.Array (messages, _)) ->
5555- Printf.printf "Got %d messages\n" (List.length messages)
5656- | _ -> Printf.printf "No messages field found\n")
5757- | _ -> Printf.printf "Unexpected JSON format\n")
4848+ match json with
4949+ | Jsont.Object (fields, _) -> (
5050+ let assoc = List.map (fun ((k, _), v) -> (k, v)) fields in
5151+ match List.assoc_opt "messages" assoc with
5252+ | Some (Jsont.Array (messages, _)) ->
5353+ Printf.printf "Got %d messages\n" (List.length messages)
5454+ | _ -> Printf.printf "No messages field found\n")
5555+ | _ -> Printf.printf "Unexpected JSON format\n"
5856 with Eio.Exn.Io _ as e ->
5957 Printf.eprintf "Failed to fetch messages: %s\n" (Printexc.to_string e)
6058···6361 Printf.printf "========================\n\n";
64626563 Eio_main.run @@ fun env ->
6666-6764 (* Test authentication *)
6865 let auth = test_auth () in
6966···7370 (* Test fetching messages *)
7471 test_fetch_messages env auth;
75727676- Printf.printf "\nAll tests completed!\n"7373+ Printf.printf "\nAll tests completed!\n"
+1-1
examples/test_client.mli
···11-(** Test client example *)11+(** Test client example *)
+7-2
examples/test_realtime_bot.ml
···3333 (* Setup logging *)
3434 Logs.set_reporter (Logs_fmt.reporter ());
3535 Logs.set_level
3636- (Some (match verbosity with 0 -> Logs.Info | 1 -> Logs.Debug | _ -> Logs.Debug));
3636+ (Some
3737+ (match verbosity with
3838+ | 0 -> Logs.Info
3939+ | 1 -> Logs.Debug
4040+ | _ -> Logs.Debug));
37413842 Log.info (fun m -> m "Real-time Bot Test");
3943 Log.info (fun m -> m "==================");
···5357 Eio.Switch.run @@ fun sw ->
5458 (* Create configuration from auth *)
5559 let config =
5656- Config.create ~name:"test-bot" ~site:(Zulip.Auth.server_url auth)
6060+ Config.create ~name:"test-bot"
6161+ ~site:(Zulip.Auth.server_url auth)
5762 ~email:(Zulip.Auth.email auth) ~api_key:(Zulip.Auth.api_key auth)
5863 ~description:"A test bot that logs all messages received" ()
5964 in
+42-32
examples/toml_example.ml
···33let () =
44 Printf.printf "OCaml Zulip TOML Support Demo\n";
55 Printf.printf "=============================\n\n";
66-66+77 (* Example 1: Create a sample zuliprc TOML file *)
88- let zuliprc_content = {|
88+ let zuliprc_content =
99+ {|
910# Zulip API Configuration
1011[api]
1112email = "demo@example.com"
···1516# Optional settings
1617insecure = false
1718cert_bundle = "/etc/ssl/certs/ca-certificates.crt"
1818-|} in
1919-1919+|}
2020+ in
2121+2022 let zuliprc_file = "demo_zuliprc.toml" in
2123 let oc = open_out zuliprc_file in
2224 output_string oc zuliprc_content;
2325 close_out oc;
2424-2626+2527 Printf.printf "Created sample zuliprc.toml file:\n%s\n" zuliprc_content;
2626-2828+2729 (* Test loading auth from TOML *)
2830 (try
2931 let auth = Auth.from_zuliprc ~path:zuliprc_file () in
···3941 Printf.printf "✅ Created client successfully\n\n";
40424143 (* Test basic functionality *)
4242- (try
4343- let _ = Client.request client ~method_:`GET ~path:"/users/me" () in
4444- Printf.printf "✅ Mock API request succeeded\n"
4545- with Eio.Exn.Io _ as e ->
4646- Printf.printf "❌ API request failed: %s\n" (Printexc.to_string e))
4444+ try
4545+ let _ = Client.request client ~method_:`GET ~path:"/users/me" () in
4646+ Printf.printf "✅ Mock API request succeeded\n"
4747+ with Eio.Exn.Io _ as e ->
4848+ Printf.printf "❌ API request failed: %s\n" (Printexc.to_string e)
4749 with Eio.Exn.Io _ as e ->
4848- Printf.printf "❌ Failed to load auth from TOML: %s\n" (Printexc.to_string e));
4949-5050+ Printf.printf "❌ Failed to load auth from TOML: %s\n"
5151+ (Printexc.to_string e));
5252+5053 (* Example 2: Root-level TOML configuration *)
5151- let root_toml_content = {|
5454+ let root_toml_content =
5555+ {|
5256email = "root-user@example.com"
5357key = "root-api-key-67890"
5458site = "https://root.zulipchat.com"
5555-|} in
5656-5959+|}
6060+ in
6161+5762 let root_file = "demo_root.toml" in
5863 let oc = open_out root_file in
5964 output_string oc root_toml_content;
6065 close_out oc;
6161-6666+6267 Printf.printf "\nTesting root-level TOML configuration:\n";
6368 (try
6469 let auth = Auth.from_zuliprc ~path:root_file () in
···6671 Printf.printf " Email: %s\n" (Auth.email auth);
6772 Printf.printf " Server: %s\n" (Auth.server_url auth)
6873 with Eio.Exn.Io _ as e ->
6969- Printf.printf "❌ Failed to parse root-level TOML: %s\n" (Printexc.to_string e));
7070-7474+ Printf.printf "❌ Failed to parse root-level TOML: %s\n"
7575+ (Printexc.to_string e));
7676+7177 (* Example 3: Test error handling with invalid TOML *)
7272- let invalid_toml = {|
7878+ let invalid_toml =
7979+ {|
7380[api
7481email = "invalid@example.com" # Missing closing bracket
7575-|} in
7676-8282+|}
8383+ in
8484+7785 let invalid_file = "demo_invalid.toml" in
7886 let oc = open_out invalid_file in
7987 output_string oc invalid_toml;
8088 close_out oc;
8181-8989+8290 Printf.printf "\nTesting error handling with invalid TOML:\n";
8391 (try
8492 let _ = Auth.from_zuliprc ~path:invalid_file () in
8593 Printf.printf "❌ Should have failed with invalid TOML\n"
8694 with Eio.Exn.Io _ as e ->
8787- Printf.printf "✅ Correctly handled invalid TOML: %s\n" (Printexc.to_string e));
8888-9595+ Printf.printf "✅ Correctly handled invalid TOML: %s\n"
9696+ (Printexc.to_string e));
9797+8998 (* Example 4: Test missing file handling *)
9099 Printf.printf "\nTesting missing file handling:\n";
91100 (try
92101 let _ = Auth.from_zuliprc ~path:"nonexistent.toml" () in
93102 Printf.printf "❌ Should have failed with missing file\n"
94103 with Eio.Exn.Io _ as e ->
9595- Printf.printf "✅ Correctly handled missing file: %s\n" (Printexc.to_string e));
9696-104104+ Printf.printf "✅ Correctly handled missing file: %s\n"
105105+ (Printexc.to_string e));
106106+97107 (* Clean up *)
9898- List.iter (fun file ->
9999- if Sys.file_exists file then Sys.remove file
100100- ) [zuliprc_file; root_file; invalid_file];
101101-108108+ List.iter
109109+ (fun file -> if Sys.file_exists file then Sys.remove file)
110110+ [ zuliprc_file; root_file; invalid_file ];
111111+102112 Printf.printf "\n🎉 TOML support demonstration complete!\n";
103113 Printf.printf "\nFeatures demonstrated:\n";
104114 Printf.printf "• Parse TOML files with [api] section\n";
105115 Printf.printf "• Parse TOML files with root-level configuration\n";
106116 Printf.printf "• Proper error handling for invalid TOML syntax\n";
107117 Printf.printf "• Proper error handling for missing files\n";
108108- Printf.printf "• Integration with existing Zulip client\n"118118+ Printf.printf "• Integration with existing Zulip client\n"
+1-1
examples/toml_example.mli
···11-(** TOML support demonstration for Zulip configuration files *)11+(** TOML support demonstration for Zulip configuration files *)
···11-type t = {
22- server_url : string;
33- email : string;
44- api_key : string;
55-}
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 }
6778let create ~server_url ~email ~api_key = { server_url; email; api_key }
8999-(** INI section record for parsing the [api] section of zuliprc *)
1010type 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 *)
15161616-(** Codec for parsing the [api] section of zuliprc.
1717- Note: zuliprc uses "key" not "api_key" *)
1717+(** Codec for parsing the [api] section of zuliprc. Note: zuliprc uses "key" not
1818+ "api_key" *)
1819let api_section_codec =
1920 Init.Section.(
2021 obj (fun email key site ->
···2223 |> mem "email" Init.string ~enc:(fun c -> c.zuliprc_email)
2324 |> mem "key" Init.string ~enc:(fun c -> c.zuliprc_key)
2425 |> mem "site" Init.string ~enc:(fun c -> c.zuliprc_site)
2525- |> skip_unknown
2626- |> finish)
2626+ |> skip_unknown |> finish)
27272828(** Document codec for zuliprc with [api] section *)
2929let zuliprc_codec =
3030 Init.Document.(
3131 obj (fun api -> api)
3232 |> section "api" api_section_codec ~enc:Fun.id
3333- |> skip_unknown
3434- |> finish)
3333+ |> skip_unknown |> finish)
35343635(** Codec for zuliprc without section headers (bare key=value pairs) *)
3736let zuliprc_bare_codec =
3837 Init.Document.(
3938 obj (fun defaults -> defaults)
4039 |> defaults api_section_codec ~enc:Fun.id
4141- |> skip_unknown
4242- |> finish)
4040+ |> skip_unknown |> finish)
43414442let from_zuliprc ?(path = "~/.zuliprc") () =
4543 try
···6361 let api =
6462 match Init_bytesrw.decode_string zuliprc_codec content with
6563 | Ok c -> c
6666- | Error _ ->
6464+ | Error _ -> (
6765 (* Try bare config format (no section headers) *)
6866 match Init_bytesrw.decode_string zuliprc_bare_codec content with
6967 | Ok c -> c
7068 | Error msg ->
7169 Error.raise_with_context
7270 (Error.make ~code:(Other "parse_error")
7373- ~message:("Error parsing zuliprc: " ^ msg) ())
7474- "reading %s" path
7171+ ~message:("Error parsing zuliprc: " ^ msg)
7272+ ())
7373+ "reading %s" path)
7574 in
76757776 (* Ensure server_url has proper protocol *)
···8887 | Sys_error msg ->
8988 Error.raise_with_context
9089 (Error.make ~code:(Other "file_error")
9191- ~message:("Cannot read zuliprc file: " ^ msg) ())
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) ())
9696+ ~message:("Error parsing zuliprc: " ^ Printexc.to_string exn)
9797+ ())
9798 "reading %s" path
989999100let server_url t = t.server_url
+7-1
lib/zulip/auth.mli
···11+(*---------------------------------------------------------------------------
22+ Copyright (c) 2025 Anil Madhavapeddy <anil@recoil.org>. All rights reserved.
33+ SPDX-License-Identifier: ISC
44+ ---------------------------------------------------------------------------*)
55+16(** Authentication for the Zulip API.
2733- This module handles authentication credentials for connecting to a Zulip server.
88+ This module handles authentication credentials for connecting to a Zulip
99+ server.
410 @raise Eio.Io with [Error.E error] on authentication/config errors *)
511612type t
···11+(*---------------------------------------------------------------------------
22+ Copyright (c) 2025 Anil Madhavapeddy <anil@recoil.org>. All rights reserved.
33+ SPDX-License-Identifier: ISC
44+ ---------------------------------------------------------------------------*)
55+16(** Zulip channels (streams).
2733- This module represents channel/stream information from the Zulip API.
44- Use {!jsont} with Bytesrw-eio for wire serialization.
88+ This module represents channel/stream information from the Zulip API. Use
99+ {!jsont} with Bytesrw-eio for wire serialization.
51066- Note: Zulip uses "stream" in the API but "channel" in the UI.
77- This library uses "channel" to match the current Zulip terminology. *)
1111+ Note: Zulip uses "stream" in the API but "channel" in the UI. This library
1212+ uses "channel" to match the current Zulip terminology. *)
813914(** {1 Channel Type} *)
1015···3439 @param description Channel description
3540 @param invite_only Whether the channel is private
3641 @param is_web_public Whether the channel is web-public
3737- @param history_public_to_subscribers Whether history is visible to new subscribers
4242+ @param history_public_to_subscribers
4343+ Whether history is visible to new subscribers
3844 @param is_default Whether this is a default channel for new users
3945 @param message_retention_days Message retention policy (None = forever)
4046 @param first_message_id ID of the first message in the channel
4147 @param date_created Unix timestamp of creation
4242- @param stream_post_policy Who can post (1=any, 2=admins, 3=full members, 4=moderators) *)
4848+ @param stream_post_policy
4949+ Who can post (1=any, 2=admins, 3=full members, 4=moderators) *)
43504451(** {1 Accessors} *)
4552···7582(** Unix timestamp when the channel was created. *)
76837784val stream_post_policy : t -> int
7878-(** Who can post to the channel.
7979- 1 = any member, 2 = admins only, 3 = full members, 4 = moderators only. *)
8585+(** Who can post to the channel. 1 = any member, 2 = admins only, 3 = full
8686+ members, 4 = moderators only. *)
80878188(** {1 Subscription Info}
8289
···11+(*---------------------------------------------------------------------------
22+ Copyright (c) 2025 Anil Madhavapeddy <anil@recoil.org>. All rights reserved.
33+ SPDX-License-Identifier: ISC
44+ ---------------------------------------------------------------------------*)
55+16(** Channel (stream) operations for the Zulip API.
2733- All functions raise [Eio.Io] with [Error.E error] on failure.
44- Context is automatically added indicating the operation being performed. *)
88+ All functions raise [Eio.Io] with [Error.E error] on failure. Context is
99+ automatically added indicating the operation being performed. *)
510611(** {1 Listing Channels} *)
712···41464247(** {1 Creating Channels} *)
43484444-(** Options for creating a channel. *)
4549type create_options = {
4650 name : string; (** Channel name (required) *)
4751 description : string option; (** Channel description *)
···5458 can_remove_subscribers_group : int option;
5559 (** User group that can remove subscribers *)
5660}
6161+(** Options for creating a channel. *)
57625863val create : Client.t -> create_options -> int
5964(** Create a new channel.
···6166 @raise Eio.Io on failure *)
62676368val create_simple :
6464- Client.t -> name:string -> ?description:string -> ?invite_only:bool -> unit -> int
6969+ Client.t ->
7070+ name:string ->
7171+ ?description:string ->
7272+ ?invite_only:bool ->
7373+ unit ->
7474+ int
6575(** Create a new channel with common options.
6676 @return The stream_id of the created channel
6777 @raise Eio.Io on failure *)
···105115106116(** {1 Subscriptions} *)
107117108108-(** Subscription request for a single channel. *)
109118type subscription_request = {
110119 name : string; (** Channel name *)
111120 color : string option; (** Color preference (hex string) *)
112121 description : string option; (** Description (for new channels) *)
113122}
123123+(** Subscription request for a single channel. *)
114124115125val subscribe :
116126 Client.t ->
···130140 @param announce Whether to announce new subscriptions
131141 @param invite_only For new channels: whether they should be private
132142 @param history_public_to_subscribers For new channels: history visibility
133133- @return JSON with "subscribed", "already_subscribed", and "unauthorized" fields
143143+ @return
144144+ JSON with "subscribed", "already_subscribed", and "unauthorized" fields
134145 @raise Eio.Io on failure *)
135146136147val subscribe_simple : Client.t -> channels:string list -> unit
···198209 @raise Eio.Io on failure *)
199210200211val delete_topic : Client.t -> stream_id:int -> topic:string -> unit
201201-(** Delete a topic and all its messages.
202202- Requires admin privileges.
212212+(** Delete a topic and all its messages. Requires admin privileges.
203213 @raise Eio.Io on failure *)
204214205215(** {1 Topic Muting} *)
206216207207-type mute_op =
208208- | Mute (** Mute the topic *)
209209- | Unmute (** Unmute the topic *)
217217+type mute_op = Mute (** Mute the topic *) | Unmute (** Unmute the topic *)
210218211219val set_topic_mute :
212220 Client.t -> stream_id:int -> topic:string -> op:mute_op -> unit
+36-26
lib/zulip/client.ml
···11+(*---------------------------------------------------------------------------
22+ Copyright (c) 2025 Anil Madhavapeddy <anil@recoil.org>. All rights reserved.
33+ SPDX-License-Identifier: ISC
44+ ---------------------------------------------------------------------------*)
55+16(* Logging setup *)
27let src = Logs.Src.create "zulip.client" ~doc:"Zulip API client"
3849module Log = (val Logs.src_log src : Logs.LOG)
51066-type t = {
77- auth : Auth.t;
88- session : Requests.t;
99-}
1111+type t = { auth : Auth.t; session : Requests.t }
10121113let create ~sw env auth =
1214 Log.info (fun m -> m "Creating Zulip client for %s" (Auth.server_url auth));
···4345 params
4446 |> Option.map (fun p ->
4547 Uri.of_string url
4646- |> Fun.flip (List.fold_left (fun u (k, v) -> Uri.add_query_param' u (k, v))) p
4848+ |> Fun.flip
4949+ (List.fold_left (fun u (k, v) -> Uri.add_query_param' u (k, v)))
5050+ p
4751 |> Uri.to_string)
4852 |> Option.value ~default:url
4953 in
50545155 (* Prepare request body if provided *)
5256 let body_opt =
5353- body |> Option.map (fun body_str ->
5454- let mime =
5555- match content_type with
5656- | Some ct when String.starts_with ~prefix:"multipart/form-data" ct ->
5757- Requests.Mime.of_string ct
5858- | Some "application/json" -> Requests.Mime.json
5959- | Some "application/x-www-form-urlencoded" | None ->
6060- if String.contains body_str '=' && not (String.contains body_str '{')
6161- then Requests.Mime.form
6262- else Requests.Mime.json
6363- | Some ct -> Requests.Mime.of_string ct
6464- in
6565- Requests.Body.of_string mime body_str)
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)
6673 in
67746875 (* Make the request *)
···99106100107 (* Check for Zulip error response *)
101108 match json with
102102- | Jsont.Object (fields, _) ->
109109+ | Jsont.Object (fields, _) -> (
103110 let assoc = List.map (fun ((k, _), v) -> (k, v)) fields in
104104- (match List.assoc_opt "result" assoc with
111111+ match List.assoc_opt "result" assoc with
105112 | Some (Jsont.String ("error", _)) ->
106113 let msg =
107114 match List.assoc_opt "msg" assoc with
···126133 (fun (k, _) -> k <> "code" && k <> "msg" && k <> "result")
127134 assoc
128135 in
129129- Log.warn (fun m -> m "API error: %s (code: %a)" msg Error.pp_code code);
136136+ Log.warn (fun m ->
137137+ m "API error: %s (code: %a)" msg Error.pp_code code);
130138 Error.raise_with_context
131139 (Error.make ~code ~message:msg ~extra ())
132140 "%s %s" (method_to_string method_) path
···135143 else (
136144 Log.warn (fun m -> m "HTTP error: %d" status);
137145 Error.raise_with_context
138138- (Error.make ~code:(Other (string_of_int status))
139139- ~message:("HTTP error: " ^ string_of_int status) ())
146146+ (Error.make
147147+ ~code:(Other (string_of_int status))
148148+ ~message:("HTTP error: " ^ string_of_int status)
149149+ ())
140150 "%s %s" (method_to_string method_) path))
141151 | _ ->
142152 if status >= 200 && status < 300 then json
143153 else (
144154 Log.err (fun m -> m "Invalid JSON response");
145155 Error.raise_with_context
146146- (Error.make ~code:(Other "json_parse") ~message:"Invalid JSON response" ())
156156+ (Error.make ~code:(Other "json_parse")
157157+ ~message:"Invalid JSON response" ())
147158 "%s %s" (method_to_string method_) path)
148159149149-let pp fmt t =
150150- Format.fprintf fmt "Client(server=%s)" (Auth.server_url t.auth)
160160+let pp fmt t = Format.fprintf fmt "Client(server=%s)" (Auth.server_url t.auth)
+16-9
lib/zulip/client.mli
···11+(*---------------------------------------------------------------------------
22+ Copyright (c) 2025 Anil Madhavapeddy <anil@recoil.org>. All rights reserved.
33+ SPDX-License-Identifier: ISC
44+ ---------------------------------------------------------------------------*)
55+16(** HTTP client for making requests to the Zulip API.
2733- This module provides the low-level HTTP client for communicating with
44- the Zulip API. All API errors are raised as [Eio.Io] exceptions with
55- [Error.E] error codes, following the Eio error pattern.
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.
611712 @raise Eio.Io with [Error.E error] for API errors *)
813···2833 Auth.t ->
2934 (t -> 'a) ->
3035 'a
3131-(** Resource-safe client management using structured concurrency.
3232- The environment must have clock, net, and fs capabilities. *)
3636+(** Resource-safe client management using structured concurrency. The
3737+ environment must have clock, net, and fs capabilities. *)
33383439val request :
3540 t ->
···4146 unit ->
4247 Jsont.json
4348(** Make an HTTP request to the Zulip API.
4444- @param content_type Optional Content-Type header
4545- (default: application/x-www-form-urlencoded for POST/PUT, none for GET/DELETE)
4646- @raise Eio.Io with [Error.E error] on API errors. The exception
4747- includes context about the request method and path. *)
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. *)
48554956val pp : Format.formatter -> t -> unit
5057(** 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+16(** Encoding utilities for Zulip API requests *)
2738(** Convert a jsont-encoded value to JSON string *)
44-let to_json_string : 'a Jsont.t -> 'a -> string = fun codec value ->
99+let to_json_string : 'a Jsont.t -> 'a -> string =
1010+ fun codec value ->
511 match Jsont_bytesrw.encode_string' codec value with
612 | Ok s -> s
713 | Error e -> failwith ("JSON encoding error: " ^ Jsont.Error.to_string e)
814915(** Convert a jsont-encoded value to form-urlencoded string *)
1010-let to_form_urlencoded : 'a Jsont.t -> 'a -> string = fun codec value ->
1616+let to_form_urlencoded : 'a Jsont.t -> 'a -> string =
1717+ fun codec value ->
1118 (* First encode to JSON, then extract fields *)
1219 let json_str = to_json_string codec value in
1320 match Jsont_bytesrw.decode_string' Jsont.json json_str with
···2128 | Jsont.Null _ -> None
2229 | Jsont.Array (items, _) ->
2330 (* For arrays, encode as JSON array string *)
2424- let array_str = "[" ^ String.concat "," (List.filter_map (function
2525- | Jsont.String (s, _) -> Some ("\"" ^ String.escaped s ^ "\"")
2626- | Jsont.Number (n, _) -> Some (string_of_float n)
2727- | Jsont.Bool (b, _) -> Some (string_of_bool b)
2828- | _ -> None
2929- ) items) ^ "]" in
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
3044 Some array_str
3145 | Jsont.Object _ -> None (* Skip nested objects *)
3246 in
33473434- let params = List.filter_map (fun ((key, _), value) ->
3535- match encode_value value with
3636- | Some encoded -> Some (key ^ "=" ^ encoded)
3737- | None -> None
3838- ) fields in
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
39564057 String.concat "&" params
4141- | Ok _ ->
4242- failwith "Expected JSON object for form encoding"
5858+ | Ok _ -> failwith "Expected JSON object for form encoding"
43594460(** Parse JSON string using a jsont codec *)
4545-let from_json_string : 'a Jsont.t -> string -> ('a, string) result = fun codec json_str ->
6161+let from_json_string : 'a Jsont.t -> string -> ('a, string) result =
6262+ fun codec json_str ->
4663 match Jsont_bytesrw.decode_string' codec json_str with
4764 | Ok v -> Ok v
4865 | Error e -> Error (Jsont.Error.to_string e)
49665067(** Parse a Jsont.json value using a codec *)
5151-let from_json : 'a Jsont.t -> Jsont.json -> ('a, string) result = fun codec json ->
5252- let json_str = match Jsont_bytesrw.encode_string' Jsont.json json with
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
5372 | Ok s -> s
5454- | Error e -> failwith ("Failed to re-encode json: " ^ Jsont.Error.to_string e)
7373+ | Error e ->
7474+ failwith ("Failed to re-encode json: " ^ Jsont.Error.to_string e)
5575 in
5676 from_json_string codec json_str
57775878(** Convert a value to Jsont.json using a codec *)
5959-let to_json : 'a Jsont.t -> 'a -> (Jsont.json, string) result = fun codec value ->
7979+let to_json : 'a Jsont.t -> 'a -> (Jsont.json, string) result =
8080+ fun codec value ->
6081 let json_str = to_json_string codec value in
6182 match Jsont_bytesrw.decode_string' Jsont.json json_str with
6283 | Ok json -> Ok json
+14-7
lib/zulip/encode.mli
···11+(*---------------------------------------------------------------------------
22+ Copyright (c) 2025 Anil Madhavapeddy <anil@recoil.org>. All rights reserved.
33+ SPDX-License-Identifier: ISC
44+ ---------------------------------------------------------------------------*)
55+16(** Encoding utilities for Zulip API requests *)
2788+val to_json_string : 'a Jsont.t -> 'a -> string
39(** Convert a value to JSON string using its jsont codec *)
44-val to_json_string : 'a Jsont.t -> 'a -> string
51066-(** Convert a value to application/x-www-form-urlencoded string using its jsont codec
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
71488- The codec should represent a JSON object. Fields will be converted to key=value pairs:
1515+ The codec should represent a JSON object. Fields will be converted to
1616+ key=value pairs:
917 - Strings: URL-encoded
1018 - Booleans: "true"/"false"
1119 - Numbers: string representation
1220 - Arrays: JSON array string "[...]"
1321 - Null: omitted
1422 - Nested objects: omitted *)
1515-val to_form_urlencoded : 'a Jsont.t -> 'a -> string
16231717-(** Parse JSON string using a jsont codec *)
1824val from_json_string : 'a Jsont.t -> string -> ('a, string) result
2525+(** Parse JSON string using a jsont codec *)
19262727+val from_json : 'a Jsont.t -> Jsont.json -> ('a, string) result
2028(** Parse a Jsont.json value using a codec *)
2121-val from_json : 'a Jsont.t -> Jsont.json -> ('a, string) result
22292323-(** Convert a value to Jsont.json using a codec *)
2430val to_json : 'a Jsont.t -> 'a -> (Jsont.json, string) result
3131+(** Convert a value to Jsont.json using a codec *)
+13-9
lib/zulip/error.ml
···11+(*---------------------------------------------------------------------------
22+ Copyright (c) 2025 Anil Madhavapeddy <anil@recoil.org>. All rights reserved.
33+ SPDX-License-Identifier: ISC
44+ ---------------------------------------------------------------------------*)
55+16type code =
27 | Invalid_api_key
38 | Request_variable_missing
···712 | Rate_limit_hit
813 | Other of string
9141010-type t = {
1111- code : code;
1212- message : string;
1313- extra : (string * Jsont.json) list;
1414-}
1515-1515+type t = { code : code; message : string; extra : (string * Jsont.json) list }
1616type Eio.Exn.err += E of t
17171818let pp_code fmt = function
···5858let raise e = Stdlib.raise (Eio.Exn.create (E e))
59596060let raise_with_context e fmt =
6161- Format.kasprintf (fun context ->
6161+ Format.kasprintf
6262+ (fun context ->
6263 Stdlib.raise (Eio.Exn.add_context (Eio.Exn.create (E e)) "%s" context))
6364 fmt
6465···68696970let jsont =
7071 let kind = "ZulipError" in
7171- let make' code msg = { code = code_of_api_string code; message = msg; extra = [] } in
7272+ let make' code msg =
7373+ { code = code_of_api_string code; message = msg; extra = [] }
7474+ in
7275 let code' t = code_to_api_string t.code in
7376 let msg t = t.message in
7477 Jsont.Object.(
···8689 let extra =
8790 fields
8891 |> List.map (fun ((k, _), v) -> (k, v))
8989- |> List.filter (fun (k, _) -> k <> "code" && k <> "msg" && k <> "result")
9292+ |> List.filter (fun (k, _) ->
9393+ k <> "code" && k <> "msg" && k <> "result")
9094 in
9195 { err with extra }
9296 | _ -> err)
+24-18
lib/zulip/error.mli
···11+(*---------------------------------------------------------------------------
22+ Copyright (c) 2025 Anil Madhavapeddy <anil@recoil.org>. All rights reserved.
33+ SPDX-License-Identifier: ISC
44+ ---------------------------------------------------------------------------*)
55+16(** Zulip API error handling.
2733- This module defines protocol-level errors for the Zulip API,
44- following the Eio error pattern for context-aware error handling.
88+ This module defines protocol-level errors for the Zulip API, following the
99+ Eio error pattern for context-aware error handling.
510611 Errors are raised as [Eio.Io] exceptions:
712 {[
88- try
99- Zulip.Messages.send client msg
1010- with
1313+ try Zulip.Messages.send client msg with
1114 | Eio.Io (Zulip.Error.E { code = Invalid_api_key; message; _ }, _) ->
1215 Printf.eprintf "Authentication failed: %s\n" message
1316 | Eio.Io (Zulip.Error.E err, _) ->
···16191720(** {1 Error Codes}
18211919- These error codes correspond to the error codes returned by the Zulip API
2020- in the "code" field of error responses. *)
2222+ These error codes correspond to the error codes returned by the Zulip API in
2323+ the "code" field of error responses. *)
21242225type code =
2326 | Invalid_api_key (** Authentication failure - invalid API key *)
···3336type t = {
3437 code : code; (** The error code from the API *)
3538 message : string; (** Human-readable error message *)
3636- extra : (string * Jsont.json) list; (** Additional fields from the error response *)
3939+ extra : (string * Jsont.json) list;
4040+ (** Additional fields from the error response *)
3741}
3842(** The protocol-level error type. *)
39434044(** {1 Eio Integration} *)
41454242-type Eio.Exn.err += E of t
4343-(** Extend [Eio.Exn.err] with Zulip protocol errors. *)
4646+type Eio.Exn.err +=
4747+ | E of t (** Extend [Eio.Exn.err] with Zulip protocol errors. *)
44484549val raise : t -> 'a
4646-(** [raise e] raises an [Eio.Io] exception for error [e].
4747- Equivalent to [Stdlib.raise (Eio.Exn.create (E e))]. *)
5050+(** [raise e] raises an [Eio.Io] exception for error [e]. Equivalent to
5151+ [Stdlib.raise (Eio.Exn.create (E e))]. *)
48524953val raise_with_context : t -> ('a, Format.formatter, unit, 'b) format4 -> 'a
5054(** [raise_with_context e fmt ...] raises an [Eio.Io] exception with context.
5151- Equivalent to [Stdlib.raise (Eio.Exn.add_context (Eio.Exn.create (E e)) fmt ...)]. *)
5555+ Equivalent to
5656+ [Stdlib.raise (Eio.Exn.add_context (Eio.Exn.create (E e)) fmt ...)]. *)
52575358(** {1 Error Construction} *)
54595555-val make : code:code -> message:string -> ?extra:(string * Jsont.json) list -> unit -> t
6060+val make :
6161+ code:code -> message:string -> ?extra:(string * Jsont.json) list -> unit -> t
5662(** [make ~code ~message ?extra ()] creates an error value. *)
57635864(** {1 Accessors} *)
···7581(** Jsont codec for errors. *)
76827783val of_json : Jsont.json -> t option
7878-(** [of_json json] attempts to parse a Zulip API error response.
7979- Returns [None] if the JSON does not represent an error. *)
8484+(** [of_json json] attempts to parse a Zulip API error response. Returns [None]
8585+ if the JSON does not represent an error. *)
80868187val decode_or_raise : 'a Jsont.t -> Jsont.json -> string -> 'a
8282-(** [decode_or_raise codec json context] decodes JSON using the codec,
8383- or raises a Zulip error with the given context if decoding fails. *)
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. *)
+7-8
lib/zulip/event.ml
···11-type t = {
22- id : int;
33- type_ : Event_type.t;
44- data : Jsont.json;
55-}
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 }
6778let id t = t.id
89let type_ t = t.type_
···2122let jsont =
2223 let kind = "Event" in
2324 let doc = "A Zulip event from the event queue" in
2424- let make id type_ (data : Jsont.json) =
2525- { id; type_; data }
2626- in
2525+ let make id type_ (data : Jsont.json) = { id; type_; data } in
2726 let enc_data t = t.data in
2827 Jsont.Object.map ~kind ~doc make
2928 |> Jsont.Object.mem "id" Jsont.int ~enc:id
+8-3
lib/zulip/event.mli
···11+(*---------------------------------------------------------------------------
22+ Copyright (c) 2025 Anil Madhavapeddy <anil@recoil.org>. All rights reserved.
33+ SPDX-License-Identifier: ISC
44+ ---------------------------------------------------------------------------*)
55+16(** Zulip events.
2733- This module represents events received from the Zulip event queue.
44- Use {!jsont} with Bytesrw-eio for wire deserialization. *)
88+ This module represents events received from the Zulip event queue. Use
99+ {!jsont} with Bytesrw-eio for wire deserialization. *)
510611type t
712···914val type_ : t -> Event_type.t
1015val data : t -> Jsont.json
11161212-(** Jsont codec for event *)
1317val jsont : t Jsont.t
1818+(** Jsont codec for event *)
14191520val pp : Format.formatter -> t -> unit
+41-32
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+16(* Logging setup *)
27let src = Logs.Src.create "zulip.event_queue" ~doc:"Zulip event queue"
38···510611type t = { id : string; mutable last_event_id : int }
71288-(* Request/response codecs *)
99-module Register_request = struct
1010- type t = { event_types : string list option }
1111-1212- let _codec =
1313- Jsont.Object.(
1414- map ~kind:"RegisterRequest" (fun event_types -> { event_types })
1515- |> opt_mem "event_types" (Jsont.list Jsont.string)
1616- ~enc:(fun r -> r.event_types)
1717- |> finish)
1818-end
1919-2013module Register_response = struct
2114 type t = { queue_id : string; last_event_id : int }
2215···2619 { queue_id; last_event_id })
2720 |> mem "queue_id" Jsont.string ~enc:(fun r -> r.queue_id)
2821 |> mem "last_event_id" Jsont.int ~dec_absent:(-1) ~enc:(fun r ->
2929- r.last_event_id)
2222+ r.last_event_id)
3023 |> finish)
3124end
32253333-let register client ?event_types ?narrow ?all_public_streams ?include_subscribers
3434- ?client_capabilities ?fetch_event_types ?client_gravatar ?slim_presence () =
2626+let register client ?event_types ?narrow ?all_public_streams
2727+ ?include_subscribers ?client_capabilities ?fetch_event_types
2828+ ?client_gravatar ?slim_presence () =
3529 let event_types_str =
3630 Option.map (List.map Event_type.to_string) event_types
3731 in
···4337 [
4438 Option.map
4539 (fun types ->
4646- ("event_types", Encode.to_json_string (Jsont.list Jsont.string) types))
4040+ ( "event_types",
4141+ Encode.to_json_string (Jsont.list Jsont.string) types ))
4742 event_types_str;
4843 Option.map
4944 (fun n -> ("narrow", Encode.to_json_string Narrow.list_jsont n))
···6964 ]
7065 in
71667272- Option.iter (fun types ->
7373- Log.debug (fun m -> m "Registering with event_types: %s" (String.concat "," types)))
6767+ Option.iter
6868+ (fun types ->
6969+ Log.debug (fun m ->
7070+ m "Registering with event_types: %s" (String.concat "," types)))
7471 event_types_str;
75727673 let json =
7774 Client.request client ~method_:`POST ~path:"/api/v1/register" ~params ()
7875 in
7979- let response = Error.decode_or_raise Register_response.codec json "parsing register response" 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 }
81818282let id t = t.id
···8989 let codec =
9090 let make raw_json =
9191 match raw_json with
9292- | Jsont.Object (fields, _) ->
9292+ | Jsont.Object (fields, _) -> (
9393 let assoc = List.map (fun ((k, _), v) -> (k, v)) fields in
9494- (match List.assoc_opt "events" assoc with
9494+ match List.assoc_opt "events" assoc with
9595 | Some (Jsont.Array (items, _)) ->
9696 let events =
9797- items |> List.filter_map (fun item ->
9898- Encode.from_json Event.jsont item |> Result.to_option)
9797+ items
9898+ |> List.filter_map (fun item ->
9999+ Encode.from_json Event.jsont item |> Result.to_option)
99100 in
100101 { events }
101102 | Some _ -> { events = [] }
···103104 | _ -> { events = [] }
104105 in
105106 Jsont.Object.map ~kind:"EventsResponse" make
106106- |> Jsont.Object.keep_unknown Jsont.json_mems
107107- ~enc:(fun _ -> Jsont.Object ([], Jsont.Meta.none))
107107+ |> Jsont.Object.keep_unknown Jsont.json_mems ~enc:(fun _ ->
108108+ Jsont.Object ([], Jsont.Meta.none))
108109 |> Jsont.Object.finish
109110end
110111···112113 let event_id = Option.value last_event_id ~default:t.last_event_id in
113114 let params =
114115 [ ("queue_id", t.id); ("last_event_id", string_of_int event_id) ]
115115- @ (if dont_block = Some true then [ ("dont_block", "true") ] else [])
116116+ @ if dont_block = Some true then [ ("dont_block", "true") ] else []
116117 in
117118 let json =
118119 Client.request client ~method_:`GET ~path:"/api/v1/events" ~params ()
119120 in
120120- let response = Error.decode_or_raise Events_response.codec json (Printf.sprintf "parsing events from queue %s" t.id) 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
121125 Log.debug (fun m -> m "Got %d events from API" (List.length response.events));
122126 (* Update internal last_event_id *)
123127 (match response.events with
124124- | [] -> ()
125125- | events ->
126126- let max_id = List.fold_left (fun acc e -> max acc (Event.id e)) event_id events in
128128+ | [] -> ()
129129+ | events ->
130130+ let max_id =
131131+ List.fold_left (fun acc e -> max acc (Event.id e)) event_id events
132132+ in
127133 t.last_event_id <- max_id);
128134 response.events
129135···141147 List.iter
142148 (fun event ->
143149 (* Filter out heartbeat events *)
144144- match Event.type_ event with Event_type.Heartbeat -> () | _ -> callback event)
150150+ match Event.type_ event with
151151+ | Event_type.Heartbeat -> ()
152152+ | _ -> callback event)
145153 events;
146154 loop ()
147155 in
···169177 in
170178 next
171179172172-let pp fmt t = Format.fprintf fmt "EventQueue{id=%s, last_event_id=%d}" t.id t.last_event_id
180180+let pp fmt t =
181181+ Format.fprintf fmt "EventQueue{id=%s, last_event_id=%d}" t.id t.last_event_id
+21-13
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+16(** Event queue for receiving Zulip events in real-time.
2733- Event queues provide real-time notifications of changes in Zulip.
44- Register a queue to receive events, then poll for updates.
88+ Event queues provide real-time notifications of changes in Zulip. Register a
99+ queue to receive events, then poll for updates.
510611 All functions raise [Eio.Io] with [Error.E error] on failure. *)
712···4752(** {1 Polling for Events} *)
48534954val get_events :
5050- t -> Client.t -> ?last_event_id:int -> ?dont_block:bool -> unit -> Event.t list
5555+ t ->
5656+ Client.t ->
5757+ ?last_event_id:int ->
5858+ ?dont_block:bool ->
5959+ unit ->
6060+ Event.t list
5161(** Get events from the queue.
52625363 @param last_event_id Event ID to resume from (default: use queue's state)
···64746575(** {1 High-Level Event Processing}
66766767- These functions provide convenient callback-based patterns for
6868- processing events. They handle queue management and reconnection
6969- automatically. *)
7777+ These functions provide convenient callback-based patterns for processing
7878+ events. They handle queue management and reconnection automatically. *)
70797180val call_on_each_event :
7281 Client.t ->
···7786 unit
7887(** Process events with a callback.
79888080- Registers a queue and continuously polls for events, calling the
8181- callback for each event. Automatically handles reconnection if
8282- the queue expires.
8989+ Registers a queue and continuously polls for events, calling the callback
9090+ for each event. Automatically handles reconnection if the queue expires.
83918492 This function runs indefinitely until cancelled via [Eio.Cancel].
8593···97105 unit
98106(** Process message events with a callback.
99107100100- Convenience wrapper around [call_on_each_event] that filters
101101- for message events and extracts the message data.
108108+ Convenience wrapper around [call_on_each_event] that filters for message
109109+ events and extracts the message data.
102110103111 @param narrow Narrow filter for messages
104112 @param callback Function called with each message's JSON data *)
···108116 For use with Eio's streaming patterns. *)
109117110118val events : t -> Client.t -> Event.t Seq.t
111111-(** Create a lazy sequence of events from the queue.
112112- The sequence polls the server as needed.
119119+(** Create a lazy sequence of events from the queue. The sequence polls the
120120+ server as needed.
113121114122 Note: This sequence is infinite - use [Seq.take] or similar to limit. *)
115123
+5
lib/zulip/event_type.ml
···11+(*---------------------------------------------------------------------------
22+ Copyright (c) 2025 Anil Madhavapeddy <anil@recoil.org>. All rights reserved.
33+ SPDX-License-Identifier: ISC
44+ ---------------------------------------------------------------------------*)
55+16type t =
27 | Message
38 | Heartbeat
+10-5
lib/zulip/event_type.mli
···11+(*---------------------------------------------------------------------------
22+ Copyright (c) 2025 Anil Madhavapeddy <anil@recoil.org>. All rights reserved.
33+ SPDX-License-Identifier: ISC
44+ ---------------------------------------------------------------------------*)
55+16(** Zulip event types.
2733- This module defines the event types that can be received from the
44- Zulip event queue. These correspond to the "type" field in event
55- objects returned by the /events endpoint. *)
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. *)
611712(** {1 Event Types} *)
813···3237(** Convert an event type to its wire format string. *)
33383439val of_string : string -> t
3535-(** Parse an event type from its wire format string.
3636- Unknown types are wrapped in [Other]. *)
4040+(** Parse an event type from its wire format string. Unknown types are wrapped
4141+ in [Other]. *)
37423843(** {1 Pretty Printing} *)
3944
···11+(*---------------------------------------------------------------------------
22+ Copyright (c) 2025 Anil Madhavapeddy <anil@recoil.org>. All rights reserved.
33+ SPDX-License-Identifier: ISC
44+ ---------------------------------------------------------------------------*)
55+16(** Outgoing Zulip messages.
2733- This module represents messages to be sent via the Zulip API.
44- Use {!jsont} with Bytesrw-eio for wire serialization. *)
88+ This module represents messages to be sent via the Zulip API. Use {!jsont}
99+ with Bytesrw-eio for wire serialization. *)
510611type t
712···2429val local_id : t -> string option
2530val read_by_sender : t -> bool
26312727-(** Jsont codec for the message type *)
2832val jsont : t Jsont.t
3333+(** Jsont codec for the message type *)
29343035val 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+16(** Message flags in Zulip.
2733- Message flags indicate read/unread status, starred messages,
44- mentions, and other message properties. *)
88+ Message flags indicate read/unread status, starred messages, mentions, and
99+ other message properties. *)
510611(** {1 Flag Types} *)
712813type modifiable =
914 [ `Read (** Message has been read *)
1015 | `Starred (** Message is starred/bookmarked *)
1111- | `Collapsed (** Message content is collapsed *)
1212- ]
1616+ | `Collapsed (** Message content is collapsed *) ]
1317(** Flags that can be directly modified by the user. *)
14181519type t =
···1721 | `Mentioned (** User was @-mentioned in the message *)
1822 | `Wildcard_mentioned (** User was mentioned via @all/@everyone *)
1923 | `Has_alert_word (** Message contains one of user's alert words *)
2020- | `Historical (** Message predates user joining the stream *)
2121- ]
2424+ | `Historical (** Message predates user joining the stream *) ]
2225(** All possible message flags. *)
23262427(** {1 Conversion} *)
···4750(** {1 JSON Codec} *)
48514952val jsont : t Jsont.t
5050-5153val modifiable_jsont : modifiable Jsont.t
+6-5
lib/zulip/message_response.ml
···11-type t = {
22- id : int;
33- automatic_new_visibility_policy : string option;
44-}
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 }
5768let id t = t.id
79let automatic_new_visibility_policy t = t.automatic_new_visibility_policy
88-910let pp fmt t = Format.fprintf fmt "MessageResponse{id=%d}" t.id
10111112(* Jsont codec for message response *)
+8-3
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+16(** Response from sending a Zulip message.
2733- This module represents the response returned when a message is sent.
44- Use {!jsont} with Bytesrw-eio for wire serialization. *)
88+ This module represents the response returned when a message is sent. Use
99+ {!jsont} with Bytesrw-eio for wire serialization. *)
510611type t
712813val id : t -> int
914val automatic_new_visibility_policy : t -> string option
10151111-(** Jsont codec for message response *)
1216val jsont : t Jsont.t
1717+(** Jsont codec for message response *)
13181419val pp : Format.formatter -> t -> unit
+7-4
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+16type t = [ `Direct | `Channel ]
2733-let to_string = function
44- | `Direct -> "direct"
55- | `Channel -> "stream"
88+let to_string = function `Direct -> "direct" | `Channel -> "stream"
69710let of_string = function
811 | "direct" -> Some `Direct
912 | "stream" -> Some `Channel
1013 | _ -> None
11141212-let pp fmt t = Format.fprintf fmt "%s" (to_string t)1515+let pp fmt t = Format.fprintf fmt "%s" (to_string t)
+6-1
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+16type t = [ `Direct | `Channel ]
2738val to_string : t -> string
49val of_string : string -> t option
55-val pp : Format.formatter -> t -> unit1010+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+16(** Message operations for the Zulip API.
2733- All functions raise [Eio.Io] with [Error.E error] on failure.
44- Context is automatically added indicating the operation being performed. *)
88+ All functions raise [Eio.Io] with [Error.E error] on failure. Context is
99+ automatically added indicating the operation being performed. *)
510611(** {1 Sending Messages} *)
712···1217(** {1 Reading Messages} *)
13181419val get : Client.t -> message_id:int -> Jsont.json
1515-(** Get a single message by ID.
1616- Returns the full message object.
2020+(** Get a single message by ID. Returns the full message object.
1721 @raise Eio.Io on failure *)
18221923val get_raw : Client.t -> message_id:int -> string
···29333034val get_messages :
3135 Client.t ->
3232- ?anchor:anchor ->
3636+ anchor:anchor ->
3337 ?num_before:int ->
3438 ?num_after:int ->
3539 ?narrow:Narrow.t list ->
···3842 Jsont.json
3943(** Get multiple messages with optional filtering.
40444141- @param anchor Where to start fetching (default: [Newest])
4545+ @param anchor Where to start fetching (required)
4246 @param num_before Number of messages before anchor (default: 0)
4347 @param num_after Number of messages after anchor (default: 0)
4448 @param narrow Filter criteria (see {!Narrow})
···47514852val check_messages_match_narrow :
4953 Client.t -> message_ids:int list -> narrow:Narrow.t list -> Jsont.json
5050-(** Check if messages match a narrow filter.
5151- Returns which of the given messages match the narrow.
5454+(** Check if messages match a narrow filter. Returns which of the given messages
5555+ match the narrow.
5256 @raise Eio.Io on failure *)
53575458(** {1 Message History} *)
···110114 {b Example:}
111115 {[
112116 (* Mark messages as read *)
113113- Messages.update_flags client
114114- ~messages:[123; 456; 789]
115115- ~op:Add
117117+ Messages.update_flags client ~messages:[ 123; 456; 789 ] ~op:Add
116118 ~flag:`Read
117119 ]} *)
118120···170172(** {1 Rendering} *)
171173172174val render : Client.t -> content:string -> string
173173-(** Render message content as HTML.
174174- Useful for previewing how a message will appear.
175175+(** Render message content as HTML. Useful for previewing how a message will
176176+ appear.
175177 @return The rendered HTML
176178 @raise Eio.Io on failure *)
177179···187189 {b Example:}
188190 {[
189191 let uri = Messages.upload_file client ~filename:"/path/to/image.png" in
190190- let msg = Message.create ~type_:`Channel ~to_:["general"]
191191- ~content:("Check out this image: " ^ uri) () in
192192+ let msg =
193193+ Message.create ~type_:`Channel ~to_:[ "general" ]
194194+ ~content:("Check out this image: " ^ uri)
195195+ ()
196196+ in
192197 Messages.send client msg
193198 ]} *)
194199
+30-25
lib/zulip/narrow.ml
···11+(*---------------------------------------------------------------------------
22+ Copyright (c) 2025 Anil Madhavapeddy <anil@recoil.org>. All rights reserved.
33+ SPDX-License-Identifier: ISC
44+ ---------------------------------------------------------------------------*)
55+16type t = {
27 operator : string;
38 operand : [ `String of string | `Int of int | `Strings of string list ];
···510}
611712let make ?(negated = false) operator operand = { operator; operand; negated }
88-913let stream name = make "stream" (`String name)
1014let stream_id id = make "stream" (`Int id)
1115let topic name = make "topic" (`String name)
1216let channel = stream
1313-1417let sender email = make "sender" (`String email)
1518let sender_id id = make "sender" (`Int id)
1619···3740 | `Reaction -> "reaction"
38413942let has operand = make "has" (`String (has_operand_to_string operand))
4040-4143let search query = make "search" (`String query)
4242-4344let id msg_id = make "id" (`Int msg_id)
4445let near msg_id = make "near" (`Int msg_id)
4545-4646let dm emails = make "dm" (`Strings emails)
4747let dm_including email = make "dm-including" (`String email)
4848let group_pm_with = dm_including
4949-5049let not_ filter = { filter with negated = true }
51505251let to_json filters =
···5453 let make_string s = Jsont.String (s, meta) in
5554 let make_member name value = ((name, meta), value) in
5655 Jsont.Array
5757- (List.map
5858- (fun f ->
5959- let operand_json =
6060- match f.operand with
6161- | `String s -> make_string s
6262- | `Int i -> Jsont.Number (float_of_int i, meta)
6363- | `Strings ss -> Jsont.Array (List.map make_string ss, meta)
6464- in
6565- let fields =
6666- [ make_member "operator" (make_string f.operator);
6767- make_member "operand" operand_json ]
6868- in
6969- let fields =
7070- if f.negated then make_member "negated" (Jsont.Bool (true, meta)) :: fields else fields
7171- in
7272- Jsont.Object (fields, meta))
7373- filters, meta)
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 )
74787579let operand_to_json = function
7680 | `String s -> Jsont.String (s, Jsont.Meta.none)
7781 | `Int i -> Jsont.Number (float_of_int i, Jsont.Meta.none)
7882 | `Strings ss ->
7983 Jsont.Array
8080- (List.map (fun s -> Jsont.String (s, Jsont.Meta.none)) ss, Jsont.Meta.none)
8484+ ( List.map (fun s -> Jsont.String (s, Jsont.Meta.none)) ss,
8585+ Jsont.Meta.none )
81868287let operand_of_json = function
8388 | Jsont.String (s, _) -> `String s
···100105 |> Jsont.Object.mem "operator" Jsont.string ~enc:(fun t -> t.operator)
101106 |> Jsont.Object.mem "operand" operand_jsont ~enc:(fun t -> t.operand)
102107 |> Jsont.Object.mem "negated" Jsont.bool ~dec_absent:false ~enc:(fun t ->
103103- t.negated)
108108+ t.negated)
104109 |> Jsont.Object.finish
105110106111let list_jsont = Jsont.list jsont
+16-15
lib/zulip/narrow.mli
···11+(*---------------------------------------------------------------------------
22+ Copyright (c) 2025 Anil Madhavapeddy <anil@recoil.org>. All rights reserved.
33+ SPDX-License-Identifier: ISC
44+ ---------------------------------------------------------------------------*)
55+16(** Type-safe narrow filters for message queries.
2738 Narrow filters constrain which messages are returned by the
44- [Messages.get_messages] endpoint. This module provides a type-safe
55- interface for constructing these filters.
99+ [Messages.get_messages] endpoint. This module provides a type-safe interface
1010+ for constructing these filters.
611712 Example:
813 {[
99- let narrow = Narrow.[
1010- stream "general";
1111- topic "greetings";
1212- is `Unread;
1313- ] in
1414+ let narrow = Narrow.[ stream "general"; topic "greetings"; is `Unread ] in
1415 Messages.get_messages client ~narrow ()
1516 ]} *)
1617···3940(** [sender email] filters to messages from the given sender. *)
40414142val sender_id : int -> t
4242-(** [sender_id id] filters to messages from the sender with the given user ID. *)
4343+(** [sender_id id] filters to messages from the sender with the given user ID.
4444+*)
43454446(** {1 Message Property Filters} *)
4547···5052 | `Private (** Alias for [`Dm] *)
5153 | `Resolved (** Topics marked as resolved *)
5254 | `Starred (** Starred messages *)
5353- | `Unread (** Unread messages *)
5454- ]
5555+ | `Unread (** Unread messages *) ]
55565657val is : is_operand -> t
5758(** [is operand] filters by message property. *)
···6061 [ `Attachment (** Messages with file attachments *)
6162 | `Image (** Messages containing images *)
6263 | `Link (** Messages containing links *)
6363- | `Reaction (** Messages with emoji reactions *)
6464- ]
6464+ | `Reaction (** Messages with emoji reactions *) ]
65656666val has : has_operand -> t
6767(** [has operand] filters to messages that have the given content type. *)
···8888(** [dm_including email] filters to direct messages that include this user. *)
89899090val group_pm_with : string -> t
9191-(** [group_pm_with email] filters to group DMs including this user (deprecated, use [dm_including]). *)
9191+(** [group_pm_with email] filters to group DMs including this user (deprecated,
9292+ use [dm_including]). *)
92939394(** {1 Negation} *)
94959596val not_ : t -> t
9696-(** [not_ filter] negates a filter.
9797- Example: [not_ (stream "general")] excludes the "general" stream. *)
9797+(** [not_ filter] negates a filter. Example: [not_ (stream "general")] excludes
9898+ the "general" stream. *)
989999100(** {1 Encoding} *)
100101
+9-5
lib/zulip/presence.ml
···11+(*---------------------------------------------------------------------------
22+ Copyright (c) 2025 Anil Madhavapeddy <anil@recoil.org>. All rights reserved.
33+ SPDX-License-Identifier: ISC
44+ ---------------------------------------------------------------------------*)
55+16type status = Active | Idle | Offline
2738type client_presence = {
···44494550let client_presence_jsont =
4651 Jsont.Object.(
4747- map ~kind:"ClientPresence"
4848- (fun status timestamp client pushable ->
5252+ map ~kind:"ClientPresence" (fun status timestamp client pushable ->
4953 { status; timestamp; client; pushable })
5054 |> mem "status" status_jsont ~enc:(fun p -> p.status)
5155 |> mem "timestamp" Jsont.number ~enc:(fun p -> p.timestamp)
···5963 let assoc = List.map (fun ((k, _), v) -> (k, v)) fields in
6064 let aggregated =
6165 match List.assoc_opt "aggregated" assoc with
6262- | Some agg_json -> Encode.from_json client_presence_jsont agg_json |> Result.to_option
6666+ | Some agg_json ->
6767+ Encode.from_json client_presence_jsont agg_json |> Result.to_option
6368 | None -> None
6469 in
6570 let clients =
···7681 | _ -> { aggregated = None; clients = [] }
77827883let user_presence_jsont =
7979- Jsont.map ~kind:"UserPresence" Jsont.json
8080- ~dec:parse_user_presence_from_json
8484+ Jsont.map ~kind:"UserPresence" Jsont.json ~dec:parse_user_presence_from_json
8185 ~enc:(fun p ->
8286 let agg_field =
8387 match p.aggregated with
+9-7
lib/zulip/presence.mli
···11+(*---------------------------------------------------------------------------
22+ Copyright (c) 2025 Anil Madhavapeddy <anil@recoil.org>. All rights reserved.
33+ SPDX-License-Identifier: ISC
44+ ---------------------------------------------------------------------------*)
55+16(** User presence information for the Zulip API.
2738 Track online/offline status of users in the organization. *)
···1823(** Presence information from a single client. *)
19242025type user_presence = {
2121- aggregated : client_presence option; (** Aggregated presence across clients *)
2626+ aggregated : client_presence option;
2727+ (** Aggregated presence across clients *)
2228 clients : (string * client_presence) list; (** Per-client presence *)
2329}
2430(** A user's presence information. *)
···3440 @raise Eio.Io on failure *)
35413642val get_all : Client.t -> (int * user_presence) list
3737-(** Get presence information for all users in the organization.
3838- Returns a list of (user_id, presence) pairs.
4343+(** Get presence information for all users in the organization. Returns a list
4444+ of (user_id, presence) pairs.
3945 @raise Eio.Io on failure *)
40464147(** {1 Updating Presence} *)
···5763(** {1 JSON Codecs} *)
58645965val status_jsont : status Jsont.t
6060-6166val client_presence_jsont : client_presence Jsont.t
6262-6367val user_presence_jsont : user_presence Jsont.t
64686569(** {1 Conversion} *)
66706771val status_to_string : status -> string
6868-6972val status_of_string : string -> status option
70737174(** {1 Pretty Printing} *)
72757376val pp_status : Format.formatter -> status -> unit
7474-7577val pp_user_presence : Format.formatter -> user_presence -> unit
+69-27
lib/zulip/server.ml
···11+(*---------------------------------------------------------------------------
22+ Copyright (c) 2025 Anil Madhavapeddy <anil@recoil.org>. All rights reserved.
33+ SPDX-License-Identifier: ISC
44+ ---------------------------------------------------------------------------*)
55+16type authentication_method = {
27 password : bool;
38 dev : bool;
···5358let authentication_method_jsont =
5459 Jsont.Object.(
5560 map ~kind:"AuthenticationMethod"
5656- (fun password dev email ldap remoteuser github azuread gitlab apple
5757- google saml openid_connect ->
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+ ->
5875 {
5976 password;
6077 dev;
···7390 |> mem "Dev" Jsont.bool ~dec_absent:false ~enc:(fun a -> a.dev)
7491 |> mem "Email" Jsont.bool ~dec_absent:false ~enc:(fun a -> a.email)
7592 |> mem "LDAP" Jsont.bool ~dec_absent:false ~enc:(fun a -> a.ldap)
7676- |> mem "RemoteUser" Jsont.bool ~dec_absent:false ~enc:(fun a -> a.remoteuser)
9393+ |> mem "RemoteUser" Jsont.bool ~dec_absent:false ~enc:(fun a ->
9494+ a.remoteuser)
7795 |> mem "GitHub" Jsont.bool ~dec_absent:false ~enc:(fun a -> a.github)
7896 |> mem "AzureAD" Jsont.bool ~dec_absent:false ~enc:(fun a -> a.azuread)
7997 |> mem "GitLab" Jsont.bool ~dec_absent:false ~enc:(fun a -> a.gitlab)
···8199 |> mem "Google" Jsont.bool ~dec_absent:false ~enc:(fun a -> a.google)
82100 |> mem "SAML" Jsont.bool ~dec_absent:false ~enc:(fun a -> a.saml)
83101 |> mem "OpenID Connect" Jsont.bool ~dec_absent:false ~enc:(fun a ->
8484- a.openid_connect)
102102+ a.openid_connect)
85103 |> finish)
8610487105let external_authentication_method_jsont =
···99117let jsont =
100118 Jsont.Object.(
101119 map ~kind:"ServerSettings"
102102- (fun zulip_version zulip_feature_level zulip_merge_base
103103- push_notifications_enabled is_incompatible email_auth_enabled
104104- require_email_format_usernames realm_uri realm_name realm_icon
105105- realm_description realm_web_public_access_enabled
106106- authentication_methods external_authentication_methods ->
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+ ->
107136 {
108137 zulip_version;
109138 zulip_feature_level;
···122151 })
123152 |> mem "zulip_version" Jsont.string ~enc:(fun s -> s.zulip_version)
124153 |> mem "zulip_feature_level" Jsont.int ~enc:(fun s -> s.zulip_feature_level)
125125- |> opt_mem "zulip_merge_base" Jsont.string ~enc:(fun s -> s.zulip_merge_base)
154154+ |> opt_mem "zulip_merge_base" Jsont.string ~enc:(fun s ->
155155+ s.zulip_merge_base)
126156 |> mem "push_notifications_enabled" Jsont.bool ~dec_absent:false
127157 ~enc:(fun s -> s.push_notifications_enabled)
128158 |> mem "is_incompatible" Jsont.bool ~dec_absent:false ~enc:(fun s ->
129129- s.is_incompatible)
159159+ s.is_incompatible)
130160 |> mem "email_auth_enabled" Jsont.bool ~dec_absent:true ~enc:(fun s ->
131131- s.email_auth_enabled)
161161+ s.email_auth_enabled)
132162 |> mem "require_email_format_usernames" Jsont.bool ~dec_absent:true
133163 ~enc:(fun s -> s.require_email_format_usernames)
134164 |> mem "realm_uri" Jsont.string ~enc:(fun s -> s.realm_uri)
135165 |> mem "realm_name" Jsont.string ~dec_absent:"" ~enc:(fun s -> s.realm_name)
136166 |> mem "realm_icon" Jsont.string ~dec_absent:"" ~enc:(fun s -> s.realm_icon)
137167 |> mem "realm_description" Jsont.string ~dec_absent:"" ~enc:(fun s ->
138138- s.realm_description)
168168+ s.realm_description)
139169 |> mem "realm_web_public_access_enabled" Jsont.bool ~dec_absent:false
140170 ~enc:(fun s -> s.realm_web_public_access_enabled)
141171 |> mem "authentication_methods" authentication_method_jsont ~enc:(fun s ->
142142- s.authentication_methods)
172172+ s.authentication_methods)
143173 |> mem "external_authentication_methods"
144144- (Jsont.list external_authentication_method_jsont)
145145- ~dec_absent:[]
174174+ (Jsont.list external_authentication_method_jsont) ~dec_absent:[]
146175 ~enc:(fun s -> s.external_authentication_methods)
147176 |> finish)
148177···221250 |> mem "name" Jsont.string ~enc:(fun (e : emoji) -> e.name)
222251 |> mem "source_url" Jsont.string ~enc:(fun (e : emoji) -> e.source_url)
223252 |> mem "deactivated" Jsont.bool ~dec_absent:false ~enc:(fun (e : emoji) ->
224224- e.deactivated)
253253+ e.deactivated)
225254 |> opt_mem "author_id" Jsont.int ~enc:(fun (e : emoji) -> e.author_id)
226255 |> finish)
227256···240269 let emoji_with_name =
241270 match emoji_json with
242271 | Jsont.Object (e_fields, meta) ->
243243- let name_field = (("name", Jsont.Meta.none), Jsont.String (name, Jsont.Meta.none)) in
272272+ let name_field =
273273+ ( ("name", Jsont.Meta.none),
274274+ Jsont.String (name, Jsont.Meta.none) )
275275+ in
244276 Jsont.Object (name_field :: e_fields, meta)
245277 | _ -> emoji_json
246278 in
···310342let profile_field_type_jsont =
311343 Jsont.map ~kind:"ProfileFieldType" Jsont.int
312344 ~dec:(fun i ->
313313- match profile_field_type_of_int i with
314314- | Some t -> t
315315- | None -> Short_text)
345345+ match profile_field_type_of_int i with Some t -> t | None -> Short_text)
316346 ~enc:profile_field_type_to_int
317347318348let profile_field_jsont =
319349 Jsont.Object.(
320350 map ~kind:"ProfileField"
321321- (fun id field_type order name hint field_data display_in_profile_summary ->
322322- { id; field_type; order; name; hint; field_data; display_in_profile_summary })
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+ })
323362 |> mem "id" Jsont.int ~enc:(fun p -> p.id)
324363 |> mem "type" profile_field_type_jsont ~enc:(fun p -> p.field_type)
325364 |> mem "order" Jsont.int ~enc:(fun p -> p.order)
326365 |> mem "name" Jsont.string ~enc:(fun p -> p.name)
327366 |> mem "hint" Jsont.string ~dec_absent:"" ~enc:(fun p -> p.hint)
328328- |> mem "field_data" Jsont.json ~dec_absent:(Jsont.Null ((), Jsont.Meta.none)) ~enc:(fun p ->
329329- p.field_data)
367367+ |> mem "field_data" Jsont.json
368368+ ~dec_absent:(Jsont.Null ((), Jsont.Meta.none))
369369+ ~enc:(fun p -> p.field_data)
330370 |> opt_mem "display_in_profile_summary" Jsont.bool ~enc:(fun p ->
331331- p.display_in_profile_summary)
371371+ p.display_in_profile_summary)
332372 |> finish)
333373334374let get_profile_fields client =
335375 let response_codec =
336376 Jsont.Object.(
337377 map ~kind:"ProfileFieldsResponse" Fun.id
338338- |> mem "custom_profile_fields" (Jsont.list profile_field_jsont) ~enc:Fun.id
378378+ |> mem "custom_profile_fields"
379379+ (Jsont.list profile_field_jsont)
380380+ ~enc:Fun.id
339381 |> finish)
340382 in
341383 let json =
+8-4
lib/zulip/server.mli
···11+(*---------------------------------------------------------------------------
22+ Copyright (c) 2025 Anil Madhavapeddy <anil@recoil.org>. All rights reserved.
33+ SPDX-License-Identifier: ISC
44+ ---------------------------------------------------------------------------*)
55+16(** Server information and settings for the Zulip API.
2733- This module provides access to server-level information including
44- version, feature level, and authentication methods. *)
88+ This module provides access to server-level information including version,
99+ feature level, and authentication methods. *)
510611(** {1 Server Settings} *)
712···5964(** {1 Feature Level Checks} *)
60656166val feature_level : Client.t -> int
6262-(** Get the server's feature level.
6363- Useful for checking API compatibility.
6767+(** Get the server's feature level. Useful for checking API compatibility.
6468 @raise Eio.Io on failure *)
65696670val supports_feature : Client.t -> level:int -> bool
+5
lib/zulip/typing.ml
···11+(*---------------------------------------------------------------------------
22+ Copyright (c) 2025 Anil Madhavapeddy <anil@recoil.org>. All rights reserved.
33+ SPDX-License-Identifier: ISC
44+ ---------------------------------------------------------------------------*)
55+16type op = Start | Stop
2738let op_to_string = function Start -> "start" | Stop -> "stop"
+10-9
lib/zulip/typing.mli
···11+(*---------------------------------------------------------------------------
22+ Copyright (c) 2025 Anil Madhavapeddy <anil@recoil.org>. All rights reserved.
33+ SPDX-License-Identifier: ISC
44+ ---------------------------------------------------------------------------*)
55+16(** Typing notifications for the Zulip API.
2733- Send typing start/stop notifications to indicate that the user
44- is composing a message. *)
88+ Send typing start/stop notifications to indicate that the user is composing
99+ a message. *)
510611(** {1 Typing Status Operations} *)
71288-type op =
99- | Start (** User started typing *)
1010- | Stop (** User stopped typing *)
1313+type op = Start (** User started typing *) | Stop (** User stopped typing *)
11141215(** {1 Direct Messages} *)
13161414-val set_dm :
1515- Client.t -> op:op -> user_ids:int list -> unit
1717+val set_dm : Client.t -> op:op -> user_ids:int list -> unit
1618(** Set typing status for a direct message conversation.
17191820 @param op Whether typing has started or stopped
···21232224(** {1 Channel Messages} *)
23252424-val set_channel :
2525- Client.t -> op:op -> stream_id:int -> topic:string -> unit
2626+val set_channel : Client.t -> op:op -> stream_id:int -> topic:string -> unit
2627(** Set typing status in a channel topic.
27282829 @param op Whether typing has started or stopped
···11+(*---------------------------------------------------------------------------
22+ Copyright (c) 2025 Anil Madhavapeddy <anil@recoil.org>. All rights reserved.
33+ SPDX-License-Identifier: ISC
44+ ---------------------------------------------------------------------------*)
55+16(** Zulip user records.
2733- This module represents user information from the Zulip API.
44- Use {!jsont} with Bytesrw-eio for wire serialization. *)
88+ This module represents user information from the Zulip API. Use {!jsont}
99+ with Bytesrw-eio for wire serialization. *)
510611(** {1 User Type} *)
712
+17-5
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+16type t = {
27 id : int;
38 name : string;
···1520let jsont =
1621 Jsont.Object.(
1722 map ~kind:"UserGroup"
1818- (fun id name description members direct_subgroup_ids is_system_group
1919- can_mention_group ->
2323+ (fun
2424+ id
2525+ name
2626+ description
2727+ members
2828+ direct_subgroup_ids
2929+ is_system_group
3030+ can_mention_group
3131+ ->
2032 {
2133 id;
2234 name;
···3042 |> mem "name" Jsont.string ~enc:(fun g -> g.name)
3143 |> mem "description" Jsont.string ~enc:(fun g -> g.description)
3244 |> mem "members" (Jsont.list Jsont.int) ~dec_absent:[] ~enc:(fun g ->
3333- g.members)
4545+ g.members)
3446 |> mem "direct_subgroup_ids" (Jsont.list Jsont.int) ~dec_absent:[]
3547 ~enc:(fun g -> g.direct_subgroup_ids)
3648 |> mem "is_system_group" Jsont.bool ~dec_absent:false ~enc:(fun g ->
3737- g.is_system_group)
4949+ g.is_system_group)
3850 |> mem "can_mention_group" Jsont.int ~dec_absent:0 ~enc:(fun g ->
3939- g.can_mention_group)
5151+ g.can_mention_group)
4052 |> finish)
41534254let list client =
+5
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+16(** User groups for the Zulip API.
2738 User groups allow organizing users and setting permissions. *)
+30-21
lib/zulip/users.ml
···11+(*---------------------------------------------------------------------------
22+ Copyright (c) 2025 Anil Madhavapeddy <anil@recoil.org>. All rights reserved.
33+ SPDX-License-Identifier: ISC
44+ ---------------------------------------------------------------------------*)
55+16let list client =
27 let response_codec =
38 Jsont.Object.(
···1217 let params =
1318 List.filter_map Fun.id
1419 [
1515- Option.map (fun v -> ("client_gravatar", string_of_bool v)) client_gravatar;
1616- Option.map (fun v -> ("include_custom_profile_fields", string_of_bool v)) include_custom_profile_fields;
2020+ Option.map
2121+ (fun v -> ("client_gravatar", string_of_bool v))
2222+ client_gravatar;
2323+ Option.map
2424+ (fun v -> ("include_custom_profile_fields", string_of_bool v))
2525+ include_custom_profile_fields;
1726 ]
1827 in
1928 let response_codec =
···3847 Client.request client ~method_:`GET ~path:("/api/v1/users/" ^ email) ()
3948 in
4049 Encode.from_json user_response_codec json
4141- |> Result.fold
4242- ~ok:Fun.id
4343- ~error:(fun _ ->
4444- Error.decode_or_raise User.jsont json (Printf.sprintf "parsing user %s" email))
5050+ |> Result.fold ~ok:Fun.id ~error:(fun _ ->
5151+ Error.decode_or_raise User.jsont json
5252+ (Printf.sprintf "parsing user %s" email))
45534654let get_by_id client ~user_id ?include_custom_profile_fields () =
4755 let params =
4856 List.filter_map Fun.id
4957 [
5050- Option.map (fun v -> ("include_custom_profile_fields", string_of_bool v)) include_custom_profile_fields;
5858+ Option.map
5959+ (fun v -> ("include_custom_profile_fields", string_of_bool v))
6060+ include_custom_profile_fields;
5161 ]
5262 in
5363 let json =
···5666 ~params ()
5767 in
5868 Encode.from_json user_response_codec json
5959- |> Result.fold
6060- ~ok:Fun.id
6161- ~error:(fun _ ->
6262- Error.decode_or_raise User.jsont json (Printf.sprintf "parsing user id %d" user_id))
6969+ |> Result.fold ~ok:Fun.id ~error:(fun _ ->
7070+ Error.decode_or_raise User.jsont json
7171+ (Printf.sprintf "parsing user id %d" user_id))
63726473let me client =
6574 let json = Client.request client ~method_:`GET ~path:"/api/v1/users/me" () in
···202211 ?enable_offline_email_notifications ?enable_offline_push_notifications
203212 ?enable_online_push_notifications ?enable_digest_emails
204213 ?enable_marketing_emails ?enable_login_emails
205205- ?message_content_in_email_notifications
206206- ?pm_content_in_desktop_notifications ?wildcard_mentions_notify
207207- ?desktop_icon_count_display ?realm_name_in_notifications ?presence_enabled
208208- ?enter_sends () =
214214+ ?message_content_in_email_notifications ?pm_content_in_desktop_notifications
215215+ ?wildcard_mentions_notify ?desktop_icon_count_display
216216+ ?realm_name_in_notifications ?presence_enabled ?enter_sends () =
209217 let params =
210218 List.filter_map Fun.id
211219 [
···283291 (fun v -> ("enable_login_emails", string_of_bool v))
284292 enable_login_emails;
285293 Option.map
286286- (fun v -> ("message_content_in_email_notifications", string_of_bool v))
294294+ (fun v ->
295295+ ("message_content_in_email_notifications", string_of_bool v))
287296 message_content_in_email_notifications;
288297 Option.map
289298 (fun v -> ("pm_content_in_desktop_notifications", string_of_bool v))
···322331 map ~kind:"MutedUsersResponse" Fun.id
323332 |> mem "muted_users"
324333 (Jsont.list
325325- (Jsont.Object.(
326326- map ~kind:"MutedUser" (fun id _ts -> id)
327327- |> mem "id" Jsont.int ~enc:Fun.id
328328- |> mem "timestamp" Jsont.int ~dec_absent:0 ~enc:(Fun.const 0)
329329- |> finish)))
334334+ Jsont.Object.(
335335+ map ~kind:"MutedUser" (fun id _ts -> id)
336336+ |> mem "id" Jsont.int ~enc:Fun.id
337337+ |> mem "timestamp" Jsont.int ~dec_absent:0 ~enc:(Fun.const 0)
338338+ |> finish))
330339 ~enc:Fun.id
331340 |> finish)
332341 in
+15-15
lib/zulip/users.mli
···11+(*---------------------------------------------------------------------------
22+ Copyright (c) 2025 Anil Madhavapeddy <anil@recoil.org>. All rights reserved.
33+ SPDX-License-Identifier: ISC
44+ ---------------------------------------------------------------------------*)
55+16(** User operations for the Zulip API.
2733- All functions raise [Eio.Io] with [Error.E error] on failure.
44- Context is automatically added indicating the operation being performed. *)
88+ All functions raise [Eio.Io] with [Error.E error] on failure. Context is
99+ automatically added indicating the operation being performed. *)
510611(** {1 Listing Users} *)
712···2833 @raise Eio.Io on failure *)
29343035val get_by_id :
3131- Client.t -> user_id:int -> ?include_custom_profile_fields:bool -> unit -> User.t
3636+ Client.t ->
3737+ user_id:int ->
3838+ ?include_custom_profile_fields:bool ->
3939+ unit ->
4040+ User.t
3241(** Get a user by their numeric ID.
3342 @raise Eio.Io on failure *)
3443···4958(** {1 Creating Users} *)
50595160val create :
5252- Client.t ->
5353- email:string ->
5454- full_name:string ->
5555- password:string ->
5656- unit
6161+ Client.t -> email:string -> full_name:string -> password:string -> unit
5762(** Create a new user.
5863 @raise Eio.Io on failure *)
59646065(** {1 Updating Users} *)
61666267val update :
6363- Client.t ->
6464- user_id:int ->
6565- ?full_name:string ->
6666- ?role:int ->
6767- unit ->
6868- unit
6868+ Client.t -> user_id:int -> ?full_name:string -> ?role:int -> unit -> unit
6969(** Update a user's profile.
7070 @raise Eio.Io on failure *)
7171···97979898(** {1 User Status} *)
9999100100-(** User status types. *)
101100type status_emoji = {
102101 emoji_name : string;
103102 emoji_code : string option;
104103 reaction_type : string option;
105104}
105105+(** User status types. *)
106106107107val get_status : Client.t -> user_id:int -> Jsont.json
108108(** Get a user's status.
+7-1
lib/zulip/zulip.ml
···11+(*---------------------------------------------------------------------------
22+ Copyright (c) 2025 Anil Madhavapeddy <anil@recoil.org>. All rights reserved.
33+ SPDX-License-Identifier: ISC
44+ ---------------------------------------------------------------------------*)
55+16(** Main module for Zulip OCaml API bindings *)
2738type json = Jsont.json
491010+module Error = Error
511(** Re-export all submodules *)
66-module Error = Error
1212+713module Auth = Auth
814module Client = Client
915module Message = Message
+13-11
lib/zulip/zulip.mli
···11+(*---------------------------------------------------------------------------
22+ Copyright (c) 2025 Anil Madhavapeddy <anil@recoil.org>. All rights reserved.
33+ SPDX-License-Identifier: ISC
44+ ---------------------------------------------------------------------------*)
55+16(** Zulip API client library for OCaml.
2733- This module provides a comprehensive interface to the Zulip REST API,
44- with support for messages, channels, users, and real-time events.
88+ This module provides a comprehensive interface to the Zulip REST API, with
99+ support for messages, channels, users, and real-time events.
510611 {1 Quick Start}
712···27322833 {1 Error Handling}
29343030- Errors are raised as [Eio.Io] exceptions with [Error.E error],
3131- following the Eio error pattern (like [Eio.Net.E] and [Eio.Fs.E]).
3232- This provides context-aware error handling with automatic context
3333- accumulation as errors propagate up the call stack.
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.
34393540 Example:
3641 {[
3737- try
3838- Client.request client ~method_:`GET ~path:"/api/v1/users" ()
3939- with
4242+ try Client.request client ~method_:`GET ~path:"/api/v1/users" () with
4043 | Eio.Io (Error.E { code = Invalid_api_key; message; _ }, _) ->
4144 (* Handle authentication error *)
4245 Log.err (fun m -> m "Auth failed: %s" message)
···4447 (* Re-raise with additional context *)
4548 let bt = Printexc.get_raw_backtrace () in
4649 Eio.Exn.reraise_with_context ex bt "fetching user list"
4747- ]}
4848-*)
5050+ ]} *)
49515052(** {1 Core Types} *)
5153
+24-22
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+16let src = Logs.Src.create "zulip_bot.bot" ~doc:"Zulip bot runner"
2738module Log = (val Logs.src_log src : Logs.LOG)
···1318 Zulip.Client.create ~sw env auth
14191520let fetch_identity client =
1616- let json = Zulip.Client.request client ~method_:`GET ~path:"/api/v1/users/me" () in
1717- match json with
1818- | Jsont.Object (fields, _) ->
1919- let assoc = List.map (fun ((k, _), v) -> (k, v)) fields in
2020- let get_int key =
2121- List.assoc_opt key assoc
2222- |> Option.fold ~none:0 ~some:(function Jsont.Number (f, _) -> int_of_float f | _ -> 0)
2323- in
2424- let get_string key =
2525- List.assoc_opt key assoc
2626- |> Option.fold ~none:"" ~some:(function Jsont.String (s, _) -> s | _ -> "")
2727- in
2828- { user_id = get_int "user_id"; email = get_string "email"; full_name = get_string "full_name" }
2929- | _ ->
3030- Log.warn (fun m -> m "Unexpected response format from /users/me");
3131- { user_id = 0; email = ""; full_name = "" }
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+ }
32273328let send_response client ~in_reply_to response =
3429 match response with
···7772 match event_data with
7873 | Jsont.Object (fields, _) ->
7974 let assoc = List.map (fun ((k, _), v) -> (k, v)) fields in
8080- let msg = List.assoc_opt "message" assoc |> Option.value ~default:event_data in
7575+ let msg =
7676+ List.assoc_opt "message" assoc |> Option.value ~default:event_data
7777+ in
8178 let flgs =
8279 List.assoc_opt "flags" assoc
8383- |> Option.fold ~none:[] ~some:(function Jsont.Array (f, _) -> f | _ -> [])
8080+ |> Option.fold ~none:[] ~some:(function
8181+ | Jsont.Array (f, _) -> f
8282+ | _ -> [])
8483 in
8584 (msg, flgs)
8685 | _ -> (event_data, [])
···8988 | Error err ->
9089 Log.err (fun m -> m "Failed to parse message JSON: %s" err);
9190 Log.debug (fun m -> m "@[%a@]" Message.pp_json_debug message_json)
9292- | Ok message -> (
9191+ | Ok message ->
9392 Log.info (fun m ->
9493 m "@[<h>%a@]" (Message.pp_ansi ~show_json:false) message);
9594 let is_mentioned =
···9998 || Message.is_mentioned message ~user_email:identity.email
10099 in
101100 let is_private = Message.is_private message in
102102- let is_from_self = Message.is_from_email message ~email:identity.email in
101101+ let is_from_self =
102102+ Message.is_from_email message ~email:identity.email
103103+ in
103104 Log.debug (fun m ->
104105 m "Message check: mentioned=%b, private=%b, from_self=%b"
105106 is_mentioned is_private is_from_self);
···112113 Log.err (fun m -> m "Error handling message: %a" Eio.Exn.pp_err e))
113114 else
114115 Log.debug (fun m ->
115115- m "Not processing (not mentioned and not private)")))
116116+ m "Not processing (not mentioned and not private)"))
116117 | _ -> ()
117118118119let run ~sw ~env ~config ~handler =
···128129 ~event_types:[ Zulip.Event_type.Message ]
129130 ()
130131 in
131131- Log.info (fun m -> m "Event queue registered: %s" (Zulip.Event_queue.id queue));
132132+ Log.info (fun m ->
133133+ m "Event queue registered: %s" (Zulip.Event_queue.id queue));
132134 let rec event_loop last_event_id =
133135 try
134136 let events =
+40-27
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+16(** Fiber-based Zulip bot execution.
2738 A bot is simply a function that processes messages. The [run] function
···2429 Eio.Switch.run @@ fun sw ->
2530 let fs = Eio.Stdenv.fs env in
26312727- Eio.Fiber.all [
2828- (fun () -> Bot.run ~sw ~env
2929- ~config:(Config.load ~fs "echo-bot")
3030- ~handler:echo_handler);
3131- (fun () -> Bot.run ~sw ~env
3232- ~config:(Config.load ~fs "help-bot")
3333- ~handler:help_handler);
3434- ]
3535- ]}
3636-*)
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+ ]} *)
37443845(** {1 Types} *)
3946···58655966val run :
6067 sw:Eio.Switch.t ->
6161- env:< clock : float Eio.Time.clock_ty Eio.Resource.t
6262- ; net : [ `Generic | `Unix ] Eio.Net.ty Eio.Resource.t
6363- ; fs : Eio.Fs.dir_ty Eio.Path.t
6464- ; .. > ->
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+ ; .. > ->
6573 config:Config.t ->
6674 handler:handler ->
6775 unit
···86948795val handle_webhook :
8896 sw:Eio.Switch.t ->
8989- env:< clock : float Eio.Time.clock_ty Eio.Resource.t
9090- ; net : [ `Generic | `Unix ] Eio.Net.ty Eio.Resource.t
9191- ; fs : Eio.Fs.dir_ty Eio.Path.t
9292- ; .. > ->
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+ ; .. > ->
93102 config:Config.t ->
94103 handler:handler ->
95104 payload:string ->
96105 Response.t option
9797-(** [handle_webhook ~sw ~env ~config ~handler ~payload] processes a single webhook payload.
106106+(** [handle_webhook ~sw ~env ~config ~handler ~payload] processes a single
107107+ webhook payload.
9810899109 For webhook-based deployments, provide your own HTTP server and call this
100110 function to process incoming webhook payloads from Zulip.
···104114105115val send_response :
106116 Zulip.Client.t -> in_reply_to:Message.t -> Response.t -> unit
107107-(** [send_response client ~in_reply_to response] sends a response via the Zulip API.
117117+(** [send_response client ~in_reply_to response] sends a response via the Zulip
118118+ API.
108119109109- Utility function for webhook mode to send responses after processing.
110110- The [in_reply_to] message is used to determine the reply context (stream/topic
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
111122 or private message recipients). *)
112123113124(** {1 Utilities} *)
114125115126val create_client :
116127 sw:Eio.Switch.t ->
117117- env:< clock : float Eio.Time.clock_ty Eio.Resource.t
118118- ; net : [ `Generic | `Unix ] Eio.Net.ty Eio.Resource.t
119119- ; fs : Eio.Fs.dir_ty Eio.Path.t
120120- ; .. > ->
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+ ; .. > ->
121133 config:Config.t ->
122134 Zulip.Client.t
123123-(** [create_client ~sw ~env ~config] creates a Zulip client from bot configuration.
135135+(** [create_client ~sw ~env ~config] creates a Zulip client from bot
136136+ configuration.
124137125138 Useful when you need direct access to the Zulip API beyond what the bot
126139 framework provides. *)
+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. *)
+20-14
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+16let src = Logs.Src.create "zulip_bot.config" ~doc:"Zulip bot configuration"
2738module Log = (val Logs.src_log src : Logs.LOG)
···1419let create ~name ~site ~email ~api_key ?description ?usage () =
1520 { name; site; email; api_key; description; usage }
16211717-(** Convert bot name to environment variable prefix.
1818- "my-bot" -> "ZULIP_MY_BOT" *)
2222+(** Convert bot name to environment variable prefix. "my-bot" -> "ZULIP_MY_BOT"
2323+*)
1924let env_prefix name =
2025 let upper = String.uppercase_ascii name in
2126 let replaced = String.map (fun c -> if c = '-' then '_' else c) upper in
2227 "ZULIP_" ^ replaced ^ "_"
23282424-(** INI section record for parsing (without name field) *)
2529type ini_config = {
2630 ini_site : string;
2731 ini_email : string;
···2933 ini_description : string option;
3034 ini_usage : string option;
3135}
3636+(** INI section record for parsing (without name field) *)
32373338(** Codec for parsing the bot section of the config file *)
3439let ini_section_codec =
3540 Init.Section.(
3641 obj (fun site email api_key description usage ->
3737- { ini_site = site; ini_email = email; ini_api_key = api_key;
3838- ini_description = description; ini_usage = 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+ })
3949 |> mem "site" Init.string ~enc:(fun c -> c.ini_site)
4050 |> mem "email" Init.string ~enc:(fun c -> c.ini_email)
4151 |> mem "api_key" Init.string ~enc:(fun c -> c.ini_api_key)
4252 |> opt_mem "description" Init.string ~enc:(fun c -> c.ini_description)
4353 |> opt_mem "usage" Init.string ~enc:(fun c -> c.ini_usage)
4444- |> skip_unknown
4545- |> finish)
5454+ |> skip_unknown |> finish)
46554756(** Document codec that accepts a [bot] section or bare options at top level *)
4857let ini_doc_codec =
4958 Init.Document.(
5059 obj (fun bot -> bot)
5160 |> section "bot" ini_section_codec ~enc:Fun.id
5252- |> skip_unknown
5353- |> finish)
6161+ |> skip_unknown |> finish)
54625563(** Codec for configs without section headers (bare key=value pairs) *)
5664let bare_section_codec =
5765 Init.Document.(
5866 obj (fun defaults -> defaults)
5967 |> defaults ini_section_codec ~enc:Fun.id
6060- |> skip_unknown
6161- |> finish)
6868+ |> skip_unknown |> finish)
62696370let load ~fs name =
6471 Log.info (fun m -> m "Loading config for bot: %s" name);
···6976 let ini_config =
7077 match Init_eio.decode_path ini_doc_codec config_file with
7178 | Ok c -> c
7272- | Error _ ->
7979+ | Error _ -> (
7380 (* Try bare config format (no section headers) *)
7481 match Init_eio.decode_path bare_section_codec config_file with
7582 | Ok c -> c
7676- | Error e ->
7777- raise (Init_eio.err e)
8383+ | Error e -> raise (Init_eio.err e))
7884 in
7985 {
8086 name;
+18-9
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+16(** Bot configuration with XDG Base Directory support.
2733- Configuration is loaded from XDG-compliant locations using the bot's name
44- to locate the appropriate configuration file. The configuration file should
55- be in INI format with the following structure:
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:
611712 {v
813 [bot]
···1621 v}
17221823 Configuration files are searched in XDG config directories:
1919- - [$XDG_CONFIG_HOME/zulip-bot/<name>/config] (typically [~/.config/zulip-bot/<name>/config])
2424+ - [$XDG_CONFIG_HOME/zulip-bot/<name>/config] (typically
2525+ [~/.config/zulip-bot/<name>/config])
2026 - System directories as fallback
21272228 Environment variables can override file configuration:
2329 - [ZULIP_<NAME>_SITE], [ZULIP_<NAME>_EMAIL], [ZULIP_<NAME>_API_KEY]
24302525- Where [<NAME>] is the uppercase version of the bot name with hyphens replaced by underscores. *)
3131+ Where [<NAME>] is the uppercase version of the bot name with hyphens
3232+ replaced by underscores. *)
26332734type t = {
2835 name : string; (** Bot name (used for XDG paths and identification) *)
···4754 configuration programmatically. *)
48554956val load : fs:Eio.Fs.dir_ty Eio.Path.t -> string -> t
5050-(** [load ~fs name] loads configuration for a named bot from XDG config directory.
5757+(** [load ~fs name] loads configuration for a named bot from XDG config
5858+ directory.
51595260 Searches for configuration in:
5361 - [$XDG_CONFIG_HOME/zulip-bot/<name>/config]
···6169val from_env : string -> t
6270(** [from_env name] loads configuration from environment variables.
63716464- Reads the following environment variables (where [NAME] is the uppercase
6565- bot name with hyphens replaced by underscores):
7272+ Reads the following environment variables (where [NAME] is the uppercase bot
7373+ name with hyphens replaced by underscores):
6674 - [ZULIP_<NAME>_SITE] (required)
6775 - [ZULIP_<NAME>_EMAIL] (required)
6876 - [ZULIP_<NAME>_API_KEY] (required)
···7280 @raise Failure if required environment variables are not set *)
73817482val load_or_env : fs:Eio.Fs.dir_ty Eio.Path.t -> string -> t
7575-(** [load_or_env ~fs name] loads config from XDG location, falling back to environment.
8383+(** [load_or_env ~fs name] loads config from XDG location, falling back to
8484+ environment.
76857786 Attempts to load from the XDG config file first. If that fails (file not
7887 found or unreadable), falls back to environment variables.
···11+(*---------------------------------------------------------------------------
22+ Copyright (c) 2025 Anil Madhavapeddy <anil@recoil.org>. All rights reserved.
33+ SPDX-License-Identifier: ISC
44+ ---------------------------------------------------------------------------*)
55+16(* Message parsing using Jsont codecs *)
2738let logs_src = Logs.Src.create "zulip_bot.message"
99+410module Log = (val Logs.src_log logs_src : Logs.LOG)
511612(** User representation *)
713module User = struct
814 type t = {
99- user_id: int;
1010- email: string;
1111- full_name: string;
1212- short_name: string option;
1313- unknown: Jsont.json; (** Unknown/extra JSON fields preserved during parsing *)
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 *)
1421 }
15221623 let user_id t = t.user_id
···2128 (* Jsont codec for User - handles both user_id and id fields *)
2229 let jsont : t Jsont.t =
2330 let make email full_name short_name unknown =
2424- (* user_id will be extracted in a custom way from the object *)
2525- fun user_id_opt id_opt ->
2626- let user_id = match user_id_opt, id_opt with
2727- | Some uid, _ -> uid
2828- | None, Some id -> id
2929- | None, None -> Jsont.Error.msgf Jsont.Meta.none "Missing user_id or id field"
3030- in
3131- { user_id; 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 }
3241 in
3342 Jsont.Object.map ~kind:"User" make
3443 |> Jsont.Object.mem "email" Jsont.string ~enc:email
···4655(** Reaction representation *)
4756module Reaction = struct
4857 type t = {
4949- emoji_name: string;
5050- emoji_code: string;
5151- reaction_type: string;
5252- user_id: int;
5353- unknown: Jsont.json; (** Unknown/extra JSON fields preserved during parsing *)
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 *)
5464 }
55655666 let emoji_name t = t.emoji_name
···6777 |> Jsont.Object.finish
6878 in
6979 let make emoji_name emoji_code reaction_type unknown =
7070- fun user_id_direct user_obj_nested ->
7171- let user_id = match user_id_direct, user_obj_nested with
7272- | Some uid, _ -> uid
7373- | None, Some uid -> uid
7474- | None, None -> Jsont.Error.msgf Jsont.Meta.none "Missing user_id field"
7575- in
7676- { emoji_name; emoji_code; reaction_type; user_id; 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 }
7788 in
7889 Jsont.Object.map ~kind:"Reaction" make
7990 |> Jsont.Object.mem "emoji_name" Jsont.string ~enc:emoji_name
···91102let parse_reaction_json json = Reaction.of_json json
92103let parse_user_json json = User.of_json json
931049494-(** Common message fields *)
95105type common = {
9696- id: int;
9797- sender_id: int;
9898- sender_email: string;
9999- sender_full_name: string;
100100- sender_short_name: string option;
101101- timestamp: float;
102102- content: string;
103103- content_type: string;
104104- reactions: Reaction.t list;
105105- submessages: Zulip.json list;
106106- flags: string list;
107107- is_me_message: bool;
108108- client: string;
109109- gravatar_hash: string;
110110- avatar_url: string option;
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;
111121}
122122+(** Common message fields *)
112123113124(** Message types *)
114125type t =
115115- | Private of {
116116- common: common;
117117- display_recipient: User.t list;
118118- }
126126+ | Private of { common : common; display_recipient : User.t list }
119127 | Stream of {
120120- common: common;
121121- display_recipient: string;
122122- stream_id: int;
123123- subject: string;
124124- }
125125- | Unknown of {
126126- common: common;
127127- raw_json: Zulip.json;
128128+ common : common;
129129+ display_recipient : string;
130130+ stream_id : int;
131131+ subject : string;
128132 }
133133+ | Unknown of { common : common; raw_json : Zulip.json }
129134130135(** Helper function to parse common fields *)
131136let parse_common json =
132137 match json with
133133- | Jsont.Object (fields, _) ->
138138+ | Jsont.Object (fields, _) -> (
134139 let assoc = List.map (fun ((k, _), v) -> (k, v)) fields in
135140 let get_int key =
136141 List.assoc_opt key assoc
137137- |> Option.fold ~none:None ~some:(function Jsont.Number (f, _) -> Some (int_of_float f) | _ -> None)
142142+ |> Option.fold ~none:None ~some:(function
143143+ | Jsont.Number (f, _) -> Some (int_of_float f)
144144+ | _ -> None)
138145 in
139146 let get_string key =
140147 List.assoc_opt key assoc
141141- |> Option.fold ~none:None ~some:(function Jsont.String (s, _) -> Some s | _ -> None)
148148+ |> Option.fold ~none:None ~some:(function
149149+ | Jsont.String (s, _) -> Some s
150150+ | _ -> None)
142151 in
143152 let get_float key default =
144153 List.assoc_opt key assoc
145145- |> Option.fold ~none:default ~some:(function Jsont.Number (f, _) -> f | _ -> default)
154154+ |> Option.fold ~none:default ~some:(function
155155+ | Jsont.Number (f, _) -> f
156156+ | _ -> default)
146157 in
147158 let get_bool key default =
148159 List.assoc_opt key assoc
149149- |> Option.fold ~none:default ~some:(function Jsont.Bool (b, _) -> b | _ -> default)
160160+ |> Option.fold ~none:default ~some:(function
161161+ | Jsont.Bool (b, _) -> b
162162+ | _ -> default)
150163 in
151164 let get_array key =
152165 List.assoc_opt key assoc
153153- |> Option.fold ~none:None ~some:(function Jsont.Array (arr, _) -> Some arr | _ -> None)
166166+ |> Option.fold ~none:None ~some:(function
167167+ | Jsont.Array (arr, _) -> Some arr
168168+ | _ -> None)
154169 in
155170156156- (match (get_int "id", get_int "sender_id", get_string "sender_email", get_string "sender_full_name") with
157157- | (Some id, Some sender_id, Some sender_email, Some sender_full_name) ->
158158- let sender_short_name = get_string "sender_short_name" in
159159- let timestamp = get_float "timestamp" 0.0 in
160160- let content = get_string "content" |> Option.value ~default:"" in
161161- let content_type = get_string "content_type" |> Option.value ~default:"text/html" in
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
162184163163- let reactions =
164164- get_array "reactions"
165165- |> Option.fold ~none:[] ~some:(List.filter_map (fun r ->
166166- parse_reaction_json r
167167- |> Result.fold ~ok:Option.some ~error:(fun msg ->
168168- Log.warn (fun m -> m "Failed to parse reaction: %s" msg);
169169- None)))
170170- in
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
171196172172- let submessages = get_array "submessages" |> Option.value ~default:[] in
197197+ let submessages =
198198+ get_array "submessages" |> Option.value ~default:[]
199199+ in
173200174174- let flags =
175175- get_array "flags"
176176- |> Option.fold ~none:[] ~some:(List.filter_map (function
177177- | Jsont.String (s, _) -> Some s
178178- | _ -> None))
179179- in
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
180209181181- let is_me_message = get_bool "is_me_message" false in
182182- let client = get_string "client" |> Option.value ~default:"" in
183183- let gravatar_hash = get_string "gravatar_hash" |> Option.value ~default:"" in
184184- let avatar_url = get_string "avatar_url" in
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
185216186186- Ok {
187187- id; sender_id; sender_email; sender_full_name; sender_short_name;
188188- timestamp; content; content_type; reactions; submessages;
189189- flags; is_me_message; client; gravatar_hash; avatar_url
190190- }
191191- | _ -> Error "Missing required message fields")
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")
192236 | _ -> Error "Expected JSON object for message"
193237194238(** JSON parsing *)
···203247204248 match parse_common json with
205249 | Error msg -> Error msg
206206- | Ok common ->
250250+ | Ok common -> (
207251 match json with
208208- | Jsont.Object (fields, _) ->
252252+ | Jsont.Object (fields, _) -> (
209253 let assoc = List.map (fun ((k, _), v) -> (k, v)) fields in
210254 let msg_type =
211255 match List.assoc_opt "type" assoc with
212256 | Some (Jsont.String (s, _)) -> Some s
213257 | _ -> None
214258 in
215215- (match msg_type with
216216- | Some "private" ->
217217- (match List.assoc_opt "display_recipient" assoc with
218218- | Some (Jsont.Array (recipient_json, _)) ->
219219- let users = List.filter_map (fun u ->
220220- match parse_user_json u with
221221- | Ok user -> Some user
222222- | Error msg ->
223223- Log.warn (fun m -> m "Failed to parse user in display_recipient: %s" msg);
224224- None
225225- ) recipient_json 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
226277227227- if List.length users = 0 && List.length recipient_json > 0 then
228228- Error "Failed to parse any users in display_recipient"
229229- else
230230- Ok (Private { common; display_recipient = users })
231231- | _ ->
232232- Log.warn (fun m -> m "display_recipient is not an array for private message");
233233- Ok (Unknown { common; raw_json = json }))
234234-235235- | Some "stream" ->
236236- let display_recipient =
237237- match List.assoc_opt "display_recipient" assoc with
238238- | Some (Jsont.String (s, _)) -> Some s
239239- | _ -> None
240240- in
241241- let stream_id =
242242- match List.assoc_opt "stream_id" assoc with
243243- | Some (Jsont.Number (f, _)) -> Some (int_of_float f)
244244- | _ -> None
245245- in
246246- let subject =
247247- match List.assoc_opt "subject" assoc with
248248- | Some (Jsont.String (s, _)) -> Some s
249249- | _ -> None
250250- in
251251- (match (display_recipient, stream_id, subject) with
252252- | (Some display_recipient, Some stream_id, Some subject) ->
253253- Ok (Stream { common; display_recipient; stream_id; subject })
254254- | _ ->
255255- Log.warn (fun m -> m "Missing required fields for stream message");
256256- Ok (Unknown { common; raw_json = json }))
257257-258258- | Some unknown_type ->
259259- Log.warn (fun m -> m "Unknown message type: %s" unknown_type);
260260- Ok (Unknown { common; raw_json = json })
261261-262262- | None ->
263263- Log.warn (fun m -> m "No message type field found");
264264- Ok (Unknown { common; raw_json = json }))
265265- | _ -> Error "Expected JSON object for message"
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")
266315267316(** Accessor functions *)
268317let get_common = function
···287336let avatar_url msg = (get_common msg).avatar_url
288337289338(** Helper functions *)
290290-let is_private = function
291291- | Private _ -> true
292292- | _ -> false
293293-294294-let is_stream = function
295295- | Stream _ -> true
296296- | _ -> false
339339+let is_private = function Private _ -> true | _ -> false
297340298298-let is_from_self msg ~bot_user_id =
299299- sender_id msg = bot_user_id
300300-301301-let is_from_email msg ~email =
302302- sender_email msg = email
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
303344304345let get_reply_to = function
305346 | Private { display_recipient; _ } ->
306306- display_recipient
307307- |> List.map User.email
308308- |> String.concat ", "
347347+ display_recipient |> List.map User.email |> String.concat ", "
309348 | Stream { display_recipient; _ } -> display_recipient
310349 | Unknown _ -> ""
311350···323362 let username_mention = "@**" ^ username ^ "**" in
324363325364 let contains text pattern =
326326- if String.length pattern = 0 || String.length pattern > String.length text then
327327- false
365365+ if String.length pattern = 0 || String.length pattern > String.length text
366366+ then false
328367 else
329368 let rec search_from pos =
330330- if pos > String.length text - String.length pattern then
331331- false
332332- else if String.sub text pos (String.length pattern) = pattern then
333333- true
334334- else
335335- search_from (pos + 1)
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)
336372 in
337373 search_from 0
338374 in
···352388 (* Remove whichever mention pattern is found at the start *)
353389 let without_mention =
354390 if String.starts_with ~prefix:email_mention content_text then
355355- String.sub content_text (String.length email_mention)
391391+ String.sub content_text
392392+ (String.length email_mention)
356393 (String.length content_text - String.length email_mention)
357394 else if String.starts_with ~prefix:username_mention content_text then
358358- String.sub content_text (String.length username_mention)
395395+ String.sub content_text
396396+ (String.length username_mention)
359397 (String.length content_text - String.length username_mention)
360360- else
361361- content_text
398398+ else content_text
362399 in
363400 String.trim without_mention
364401···366403 let content_text = String.trim (content msg) in
367404 if String.length content_text > 0 && content_text.[0] = '!' then
368405 Some (String.sub content_text 1 (String.length content_text - 1))
369369- else
370370- None
406406+ else None
371407372408let parse_command msg =
373409 match extract_command msg with
374410 | None -> None
375375- | Some cmd_string ->
376376- let parts = String.split_on_char ' ' (String.trim cmd_string) in
377377- match parts with
378378- | [] -> None
379379- | cmd :: args -> Some (cmd, args)
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))
380414381415(** Pretty printing *)
382416let pp_user fmt user =
···385419386420let _pp_reaction fmt reaction =
387421 Format.fprintf fmt "{ emoji_name=%s; user_id=%d }"
388388- (Reaction.emoji_name reaction) (Reaction.user_id reaction)
422422+ (Reaction.emoji_name reaction)
423423+ (Reaction.user_id reaction)
389424390425let pp fmt = function
391426 | Private { common; display_recipient } ->
392392- Format.fprintf fmt "Private { id=%d; sender=%s; recipients=[%a]; content=%S }"
393393- common.id common.sender_email
394394- (Format.pp_print_list ~pp_sep:(fun fmt () -> Format.fprintf fmt "; ") pp_user)
395395- display_recipient
396396- common.content
397397-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
398434 | Stream { common; display_recipient; subject; _ } ->
399399- Format.fprintf fmt "Stream { id=%d; sender=%s; stream=%s; subject=%s; content=%S }"
400400- common.id common.sender_email display_recipient subject common.content
401401-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
402438 | Unknown { common; _ } ->
403403- Format.fprintf fmt "Unknown { id=%d; sender=%s; content=%S }"
404404- common.id common.sender_email common.content
439439+ Format.fprintf fmt "Unknown { id=%d; sender=%s; content=%S }" common.id
440440+ common.sender_email common.content
405441406442(** ANSI colored pretty printing for debugging *)
407407-let pp_ansi ?(show_json=false) ppf msg =
443443+let pp_ansi ?(show_json = false) ppf msg =
408444 let open Fmt in
409445 let blue = styled `Blue string in
410446 let green = styled `Green string in
···415451416452 match msg with
417453 | Private { common; display_recipient } ->
418418- pf ppf "%a %a %a %a %a"
419419- (styled `Bold blue) "DM"
420420- dim (Printf.sprintf "[#%d]" common.id)
421421- (styled `Cyan string) common.sender_email
422422- dim "→"
423423- green (Printf.sprintf "%S" common.content);
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);
424458 if show_json then
425459 pf ppf "@. %a %a" dim "Recipients:"
426460 (list ~sep:(const string ", ") (fun fmt u -> cyan fmt (User.email u)))
427461 display_recipient
428428-429462 | Stream { common; display_recipient; subject; _ } ->
430430- pf ppf "%a %a %a%a%a %a %a"
431431- (styled `Bold yellow) "STREAM"
432432- dim (Printf.sprintf "[#%d]" common.id)
433433- magenta display_recipient
434434- dim "/"
435435- cyan subject
436436- (styled `Cyan string) common.sender_email
437437- green (Printf.sprintf "%S" common.content)
438438-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)
439468 | Unknown { common; _ } ->
440469 pf ppf "%a %a %a %a"
441441- (styled `Bold (styled (`Fg `Red) string)) "UNKNOWN"
442442- dim (Printf.sprintf "[#%d]" common.id)
470470+ (styled `Bold (styled (`Fg `Red) string))
471471+ "UNKNOWN" dim
472472+ (Printf.sprintf "[#%d]" common.id)
443473 (styled `Cyan string) common.sender_email
444444- (styled (`Fg `Red) string) (Printf.sprintf "%S" common.content)
474474+ (styled (`Fg `Red) string)
475475+ (Printf.sprintf "%S" common.content)
445476446477(** Pretty print JSON for debugging *)
447478let pp_json_debug ppf json =
···452483 | Error _ -> "<error encoding json>"
453484 in
454485 pf ppf "@[<v>%a@.%a@]"
455455- (styled `Bold (styled (`Fg `Blue) string)) "Raw JSON:"
456456- (styled (`Fg `Black) string) json_str
486486+ (styled `Bold (styled (`Fg `Blue) string))
487487+ "Raw JSON:"
488488+ (styled (`Fg `Black) string)
489489+ json_str
+10-5
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+16(** Zulip message types and utilities for bots *)
2738(** User representation *)
···1520 val full_name : t -> string
1621 val short_name : t -> string option
17221818- (** Jsont codec for User *)
1923 val jsont : t Jsont.t
2424+ (** Jsont codec for User *)
2025end
21262227(** Reaction representation *)
···3439 val reaction_type : t -> string
3540 val user_id : t -> int
36414242+ val jsont : t Jsont.t
3743 (** Jsont codec for Reaction *)
3838- val jsont : t Jsont.t
3944end
40454141-(** Common message fields *)
4246type common = {
4347 id : int;
4448 sender_id : int;
···5660 gravatar_hash : string;
5761 avatar_url : string option;
5862}
6363+(** Common message fields *)
59646065(** Message types *)
6166type t =
···109114110115val pp : Format.formatter -> t -> unit
111116112112-(** ANSI colored pretty printing for debugging *)
113117val pp_ansi : ?show_json:bool -> Format.formatter -> t -> unit
118118+(** ANSI colored pretty printing for debugging *)
114119115115-(** Pretty print JSON for debugging *)
116120val pp_json_debug : Format.formatter -> Zulip.json -> unit
121121+(** Pretty print JSON for debugging *)
+5
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+16type t =
27 | Reply of string
38 | Direct of { recipients : string list; content : string }
+14-10
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+16(** Response types that bot handlers can return.
2733- A handler processes a message and returns a response indicating what
44- action to take. The bot runner then executes the appropriate Zulip
55- API calls to send the response. *)
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. *)
611712type t =
813 | Reply of string
99- (** Reply in the same context as the incoming message.
1010- For stream messages, replies to the same stream and topic.
1111- For private messages, replies to the sender. *)
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. *)
1217 | Direct of { recipients : string list; content : string }
1313- (** Send a direct (private) message to specific users.
1414- Recipients are specified by email address. *)
1818+ (** Send a direct (private) message to specific users. Recipients are
1919+ specified by email address. *)
1520 | Stream of { stream : string; topic : string; content : string }
1621 (** Send a message to a stream with a specific topic. *)
1717- | Silent
1818- (** No response - the bot acknowledges but does not reply. *)
2222+ | Silent (** No response - the bot acknowledges but does not reply. *)
19232024(** {1 Constructors} *)
2125
+11-8
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+16let src = Logs.Src.create "zulip_bot.storage" ~doc:"Zulip bot storage"
2738module Log = (val Logs.src_log src : Logs.LOG)
49module String_map = Map.Make (String)
51066-type t = {
77- client : Zulip.Client.t;
88- cache : (string, string) Hashtbl.t;
99-}
1111+type t = { client : Zulip.Client.t; cache : (string, string) Hashtbl.t }
10121111-(** Storage response type - {"storage": {...}} *)
1213type storage_response = { storage : string String_map.t; unknown : Jsont.json }
1414+(** Storage response type - {"storage": {...}} *)
13151416let storage_response_jsont : storage_response Jsont.t =
1517 let make storage unknown = { storage; unknown } in
···2123 |> Jsont.Object.finish
2224 in
2325 Jsont.Object.map ~kind:"StorageResponse" make
2424- |> Jsont.Object.mem "storage" storage_map_jsont ~enc:(fun r -> r.storage)
2626+ |> Jsont.Object.mem "storage" storage_map_jsont
2727+ ~enc:(fun r -> r.storage)
2528 ~dec_absent:String_map.empty
2629 |> Jsont.Object.keep_unknown Jsont.json_mems ~enc:(fun r -> r.unknown)
2730 |> Jsont.Object.finish
···7073 let params = [ ("keys", "[\"" ^ key ^ "\"]") ] in
7174 try
7275 let json =
7373- Zulip.Client.request t.client ~method_:`GET ~path:"/api/v1/bot_storage"
7474- ~params ()
7676+ Zulip.Client.request t.client ~method_:`GET
7777+ ~path:"/api/v1/bot_storage" ~params ()
7578 in
7679 match Zulip.Encode.from_json storage_response_jsont json with
7780 | Ok response -> (
+7-2
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+16(** Bot storage - key-value storage via the Zulip bot storage API.
2738 Provides persistent storage for bots using Zulip's built-in bot storage
···1722val get : t -> string -> string option
1823(** [get t key] retrieves a value from storage.
19242020- Returns [Some value] if the key exists, [None] otherwise.
2121- Checks the local cache first, then queries the server if not found. *)
2525+ Returns [Some value] if the key exists, [None] otherwise. Checks the local
2626+ cache first, then queries the server if not found. *)
22272328val set : t -> string -> string -> unit
2429(** [set t key value] stores a value.
+16-5
zulip.opam
···11# This file is generated by dune, edit dune-project instead
22opam-version: "2.0"
33-synopsis: "OCaml bindings for the Zulip REST API"
33+synopsis: "OCaml bindings for the Zulip REST API with bot framework"
44description:
55- "High-quality OCaml bindings to the Zulip REST API using EIO for async operations"
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"
611depends: [
77- "ocaml"
88- "dune" {>= "3.0"}
1212+ "ocaml" {>= "5.1.0"}
1313+ "dune" {>= "3.0" & >= "3.0"}
914 "eio"
1015 "requests"
1116 "uri"
1217 "base64"
1318 "init"
1919+ "jsont"
2020+ "logs"
2121+ "fmt"
2222+ "xdge"
2323+ "odoc" {with-doc}
1424 "alcotest" {with-test}
1525 "eio_main" {with-test}
1616- "odoc" {with-doc}
2626+ "cmdliner" {with-test}
2727+ "mirage-crypto-rng" {with-test}
1728]
1829build: [
1930 ["dune" "subst"] {dev}