···2121(package
2222 (name zulip_bot)
2323 (synopsis "OCaml bot framework for Zulip")
2424- (description "Interactive bot framework built on the OCaml Zulip library")
2424+ (description "Fiber-based bot framework for Zulip with XDG configuration support")
2525 (depends
2626 ocaml
2727 dune
2828 zulip
2929 eio
3030+ xdge
3131+ jsont
3232+ logs
3333+ fmt
3034 (alcotest :with-test)))
3131-3232-(package
3333- (name zulip_botserver)
3434- (synopsis "OCaml bot server for running multiple Zulip bots")
3535- (description "HTTP server for running multiple Zulip bots with webhook support")
3636- (depends
3737- ocaml
3838- dune
3939- zulip
4040- zulip_bot
4141- eio
4242- requests
4343- (alcotest :with-test)))
+179-222
examples/atom_feed_bot.ml
···3344(* Logging setup *)
55let src = Logs.Src.create "atom_feed_bot" ~doc:"Atom feed bot for Zulip"
66+67module Log = (val Logs.src_log src : Logs.LOG)
7889module Feed_parser = struct
···1415 author : string option;
1516 }
16171717- type feed = {
1818- title : string;
1919- entries : entry list;
2020- }
1818+ type feed = { title : string; entries : entry list }
21192220 (* Simple XML parser for Atom/RSS feeds *)
2321 let parse_xml_element xml element_name =
2422 let open_tag = "<" ^ element_name ^ ">" in
2523 let close_tag = "</" ^ element_name ^ ">" in
2624 try
2727- (* Find the opening tag *)
2825 match String.index_opt xml '<' with
2926 | None -> None
3030- | Some _ ->
3131- (* Search for the actual open tag in the XML *)
2727+ | Some _ -> (
3228 let pattern = open_tag in
3329 let pattern_start =
3434- try Some (String.index (String.lowercase_ascii xml)
3535- (String.lowercase_ascii pattern).[0])
3030+ try
3131+ Some
3232+ (String.index
3333+ (String.lowercase_ascii xml)
3434+ (String.lowercase_ascii pattern).[0])
3635 with Not_found -> None
3736 in
3837 match pattern_start with
3938 | None -> None
4039 | Some _ ->
4141- (* Try to find the content between tags *)
4240 let rec find_substring str sub start =
4343- if start + String.length sub > String.length str then
4444- None
4141+ if start + String.length sub > String.length str then None
4542 else if String.sub str start (String.length sub) = sub then
4643 Some start
4747- else
4848- find_substring str sub (start + 1)
4444+ else find_substring str sub (start + 1)
4945 in
5050- match find_substring xml open_tag 0 with
4646+ (match find_substring xml open_tag 0 with
5147 | None -> None
5252- | Some start_pos ->
4848+ | Some start_pos -> (
5349 let content_start = start_pos + String.length open_tag in
5450 match find_substring xml close_tag content_start with
5551 | None -> None
5652 | Some end_pos ->
5757- let content = String.sub xml content_start (end_pos - content_start) in
5858- Some (String.trim content)
5353+ let content =
5454+ String.sub xml content_start (end_pos - content_start)
5555+ in
5656+ Some (String.trim content))))
5957 with _ -> None
60586159 let parse_entry entry_xml =
···6462 let summary = parse_xml_element entry_xml "summary" in
6563 let published = parse_xml_element entry_xml "published" in
6664 let author = parse_xml_element entry_xml "author" in
6767- match title, link with
6565+ match (title, link) with
6866 | Some t, Some l -> Some { title = t; link = l; summary; published; author }
6967 | _ -> None
70687169 let _parse_feed xml =
7272- (* Very basic XML parsing - in production, use a proper XML library *)
7373- let feed_title = parse_xml_element xml "title" |> Option.value ~default:"Unknown Feed" in
7474-7575- (* Extract entries between <entry> tags (Atom) or <item> tags (RSS) *)
7070+ let feed_title =
7171+ parse_xml_element xml "title" |> Option.value ~default:"Unknown Feed"
7272+ in
7673 let entries = ref [] in
7774 let rec extract_entries str pos =
7875 try
7976 let entry_start =
8080- try String.index_from str pos '<'
8181- with Not_found -> String.length str
7777+ try String.index_from str pos '<' with Not_found -> String.length str
8278 in
8379 if entry_start >= String.length str then ()
8480 else
8581 let tag_end = String.index_from str entry_start '>' in
8686- let tag = String.sub str (entry_start + 1) (tag_end - entry_start - 1) in
8787- if tag = "entry" || tag = "item" then
8282+ let tag =
8383+ String.sub str (entry_start + 1) (tag_end - entry_start - 1)
8484+ in
8585+ if tag = "entry" || tag = "item" then (
8886 let entry_end =
8987 try String.index_from str tag_end '<'
9088 with Not_found -> String.length str
9189 in
9292- let entry_xml = String.sub str entry_start (entry_end - entry_start) in
9090+ let entry_xml =
9191+ String.sub str entry_start (entry_end - entry_start)
9292+ in
9393 (match parse_entry entry_xml with
9494- | Some e -> entries := e :: !entries
9595- | None -> ());
9696- extract_entries str entry_end
9797- else
9898- extract_entries str (tag_end + 1)
9494+ | Some e -> entries := e :: !entries
9595+ | None -> ());
9696+ extract_entries str entry_end)
9797+ else extract_entries str (tag_end + 1)
9998 with _ -> ()
10099 in
101100 extract_entries xml 0;
···104103105104module Feed_bot = struct
106105 type config = {
107107- feeds : (string * string * string) list; (* URL, channel, topic *)
108108- refresh_interval : float; (* seconds *)
106106+ feeds : (string * string * string) list;
107107+ refresh_interval : float;
109108 state_file : string;
110109 }
111110112112- type state = {
113113- last_seen : (string, string) Hashtbl.t; (* feed_url -> last_entry_id *)
114114- }
111111+ type state = { last_seen : (string, string) Hashtbl.t }
115112116113 let load_state path =
117114 try
118115 let ic = open_in path in
119116 let state = { last_seen = Hashtbl.create 10 } in
120117 (try
121121- while true do
122122- let line = input_line ic in
123123- match String.split_on_char '|' line with
124124- | [url; id] -> Hashtbl.add state.last_seen url id
125125- | _ -> ()
126126- done
127127- with End_of_file -> ());
118118+ while true do
119119+ let line = input_line ic in
120120+ match String.split_on_char '|' line with
121121+ | [ url; id ] -> Hashtbl.add state.last_seen url id
122122+ | _ -> ()
123123+ done
124124+ with End_of_file -> ());
128125 close_in ic;
129126 state
130127 with _ -> { last_seen = Hashtbl.create 10 }
131128132129 let save_state path state =
133130 let oc = open_out path in
134134- Hashtbl.iter (fun url id ->
135135- output_string oc (url ^ "|" ^ id ^ "\n")
136136- ) state.last_seen;
131131+ Hashtbl.iter
132132+ (fun url id -> output_string oc (url ^ "|" ^ id ^ "\n"))
133133+ state.last_seen;
137134 close_out oc
138135139136 let fetch_feed _url =
140140- (* In a real implementation, use an HTTP client to fetch the feed *)
141141- (* For now, return a mock feed *)
142142- Feed_parser.{
143143- title = "Mock Feed";
144144- entries = [
145145- { title = "Test Entry";
146146- link = "https://example.com/1";
147147- summary = Some "This is a test entry";
148148- published = Some "2024-01-01T00:00:00Z";
149149- author = Some "Test Author" }
150150- ]
151151- }
137137+ Feed_parser.
138138+ {
139139+ title = "Mock Feed";
140140+ entries =
141141+ [
142142+ {
143143+ title = "Test Entry";
144144+ link = "https://example.com/1";
145145+ summary = Some "This is a test entry";
146146+ published = Some "2024-01-01T00:00:00Z";
147147+ author = Some "Test Author";
148148+ };
149149+ ];
150150+ }
152151153152 let format_entry (entry : Feed_parser.entry) =
154154- let lines = [
155155- Printf.sprintf "**[%s](%s)**" entry.title entry.link;
156156- ] in
157157- let lines = match entry.author with
158158- | Some a -> lines @ [Printf.sprintf "*By %s*" a]
153153+ let lines = [ Printf.sprintf "**[%s](%s)**" entry.title entry.link ] in
154154+ let lines =
155155+ match entry.author with
156156+ | Some a -> lines @ [ Printf.sprintf "*By %s*" a ]
159157 | None -> lines
160158 in
161161- let lines = match entry.published with
162162- | Some p -> lines @ [Printf.sprintf "*Published: %s*" p]
159159+ let lines =
160160+ match entry.published with
161161+ | Some p -> lines @ [ Printf.sprintf "*Published: %s*" p ]
163162 | None -> lines
164163 in
165165- let lines = match entry.summary with
166166- | Some s -> lines @ [""; s]
164164+ let lines =
165165+ match entry.summary with
166166+ | Some s -> lines @ [ ""; s ]
167167 | None -> lines
168168 in
169169 String.concat "\n" lines
170170171171 let post_entry client channel topic entry =
172172 let open Feed_parser in
173173- let message = Zulip.Message.create
174174- ~type_:`Channel
175175- ~to_:[channel]
176176- ~topic
177177- ~content:(format_entry entry)
178178- ()
173173+ let message =
174174+ Zulip.Message.create ~type_:`Channel ~to_:[ channel ] ~topic
175175+ ~content:(format_entry entry) ()
179176 in
180177 try
181178 let _ = Zulip.Messages.send client message in
···186183 let process_feed client state (url, channel, topic) =
187184 Printf.printf "Processing feed: %s -> #%s/%s\n" url channel topic;
188185 let feed = fetch_feed url in
189189-190186 let last_id = Hashtbl.find_opt state.last_seen url in
191191- let new_entries = match last_id with
187187+ let new_entries =
188188+ match last_id with
192189 | Some id ->
193193- (* Filter entries newer than last_id *)
194194- List.filter (fun e ->
195195- Feed_parser.(e.link <> id)
196196- ) feed.entries
190190+ List.filter (fun e -> Feed_parser.(e.link <> id)) feed.entries
197191 | None -> feed.entries
198192 in
199199-200200- (* Post new entries *)
201193 List.iter (post_entry client channel topic) new_entries;
202202-203203- (* Update last seen *)
204194 match feed.entries with
205195 | h :: _ -> Hashtbl.replace state.last_seen url Feed_parser.(h.link)
206196 | [] -> ()
207197208198 let run_bot env config =
209209- (* Load authentication *)
210199 let auth =
211200 try Zulip.Auth.from_zuliprc ()
212201 with Eio.Exn.Io _ as e ->
213202 Printf.eprintf "Failed to load auth: %s\n" (Printexc.to_string e);
214203 exit 1
215204 in
216216-217217- (* Create client *)
218205 Eio.Switch.run @@ fun sw ->
219206 let client = Zulip.Client.create ~sw env auth in
220220-221221- (* Load state *)
222207 let state = load_state config.state_file in
223223-224224- (* Main loop *)
225208 let rec loop () =
226209 Printf.printf "Checking feeds...\n";
227210 List.iter (process_feed client state) config.feeds;
228211 save_state config.state_file state;
229229-230212 Printf.printf "Sleeping for %.0f seconds...\n" config.refresh_interval;
231213 Eio.Time.sleep (Eio.Stdenv.clock env) config.refresh_interval;
232214 loop ()
···239221 open Zulip_bot
240222241223 type t = {
242242- feeds : (string, string * string) Hashtbl.t; (* name -> (url, topic) *)
224224+ feeds : (string, string * string) Hashtbl.t;
243225 mutable default_channel : string;
244226 }
245227246246- let create () = {
247247- feeds = Hashtbl.create 10;
248248- default_channel = "general";
249249- }
228228+ let create () = { feeds = Hashtbl.create 10; default_channel = "general" }
250229251230 let handle_command bot_state command args =
252231 match command with
253253- | "add" ->
254254- (match args with
255255- | name :: url :: topic ->
256256- let topic_str = String.concat " " topic in
257257- Hashtbl.replace bot_state.feeds name (url, topic_str);
258258- Printf.sprintf "Added feed '%s' -> %s (topic: %s)" name url topic_str
259259- | _ -> "Usage: !feed add <name> <url> <topic>")
260260-261261- | "remove" ->
262262- (match args with
263263- | name :: _ ->
264264- if Hashtbl.mem bot_state.feeds name then (
265265- Hashtbl.remove bot_state.feeds name;
266266- Printf.sprintf "Removed feed '%s'" name
267267- ) else
268268- Printf.sprintf "Feed '%s' not found" name
269269- | _ -> "Usage: !feed remove <name>")
270270-232232+ | "add" -> (
233233+ match args with
234234+ | name :: url :: topic ->
235235+ let topic_str = String.concat " " topic in
236236+ Hashtbl.replace bot_state.feeds name (url, topic_str);
237237+ Printf.sprintf "Added feed '%s' -> %s (topic: %s)" name url topic_str
238238+ | _ -> "Usage: !feed add <name> <url> <topic>")
239239+ | "remove" -> (
240240+ match args with
241241+ | name :: _ ->
242242+ if Hashtbl.mem bot_state.feeds name then (
243243+ Hashtbl.remove bot_state.feeds name;
244244+ Printf.sprintf "Removed feed '%s'" name)
245245+ else Printf.sprintf "Feed '%s' not found" name
246246+ | _ -> "Usage: !feed remove <name>")
271247 | "list" ->
272272- if Hashtbl.length bot_state.feeds = 0 then
273273- "No feeds configured"
248248+ if Hashtbl.length bot_state.feeds = 0 then "No feeds configured"
274249 else
275275- let lines = Hashtbl.fold (fun name (url, topic) acc ->
276276- (Printf.sprintf "• **%s**: %s → topic: %s" name url topic) :: acc
277277- ) bot_state.feeds [] in
250250+ let lines =
251251+ Hashtbl.fold
252252+ (fun name (url, topic) acc ->
253253+ Printf.sprintf "* **%s**: %s -> topic: %s" name url topic :: acc)
254254+ bot_state.feeds []
255255+ in
278256 String.concat "\n" lines
279279-280280- | "fetch" ->
281281- (match args with
282282- | name :: _ ->
283283- (match Hashtbl.find_opt bot_state.feeds name with
284284- | Some (url, _topic) ->
285285- Printf.sprintf "Fetching feed '%s' from %s..." name url
286286- | None ->
287287- Printf.sprintf "Feed '%s' not found" name)
288288- | _ -> "Usage: !feed fetch <name>")
289289-290290- | "channel" ->
291291- (match args with
292292- | channel :: _ ->
293293- bot_state.default_channel <- channel;
294294- Printf.sprintf "Default channel set to: %s" channel
295295- | _ -> Printf.sprintf "Current default channel: %s" bot_state.default_channel)
296296-257257+ | "fetch" -> (
258258+ match args with
259259+ | name :: _ -> (
260260+ match Hashtbl.find_opt bot_state.feeds name with
261261+ | Some (url, _topic) ->
262262+ Printf.sprintf "Fetching feed '%s' from %s..." name url
263263+ | None -> Printf.sprintf "Feed '%s' not found" name)
264264+ | _ -> "Usage: !feed fetch <name>")
265265+ | "channel" -> (
266266+ match args with
267267+ | channel :: _ ->
268268+ bot_state.default_channel <- channel;
269269+ Printf.sprintf "Default channel set to: %s" channel
270270+ | _ ->
271271+ Printf.sprintf "Current default channel: %s" bot_state.default_channel)
297272 | "help" | _ ->
298298- String.concat "\n" [
299299- "**Atom Feed Bot Commands:**";
300300- "• `!feed add <name> <url> <topic>` - Add a new feed";
301301- "• `!feed remove <name>` - Remove a feed";
302302- "• `!feed list` - List all configured feeds";
303303- "• `!feed fetch <name>` - Manually fetch a feed";
304304- "• `!feed channel <name>` - Set default channel";
305305- "• `!feed help` - Show this help message";
306306- ]
307307-308308- let create_handler bot_state =
309309- let module Handler : Bot_handler.S = struct
310310- let initialize _ = ()
311311- let usage () = "Atom feed bot - use !feed help for commands"
312312- let description () = "Bot for managing and posting Atom/RSS feeds to Zulip"
313313-314314- let handle_message ~config:_ ~storage:_ ~identity ~message ~env:_ =
315315- (* Get message content using Message accessor *)
316316- let content = Message.content message in
273273+ String.concat "\n"
274274+ [
275275+ "**Atom Feed Bot Commands:**";
276276+ "* `!feed add <name> <url> <topic>` - Add a new feed";
277277+ "* `!feed remove <name>` - Remove a feed";
278278+ "* `!feed list` - List all configured feeds";
279279+ "* `!feed fetch <name>` - Manually fetch a feed";
280280+ "* `!feed channel <name>` - Set default channel";
281281+ "* `!feed help` - Show this help message";
282282+ ]
317283318318- (* Check if this is our own message to avoid loops *)
319319- let bot_email = Bot_handler.Identity.email identity in
320320- if Message.is_from_email message ~email:bot_email then
321321- Bot_handler.Response.None
322322- else
323323- (* Check if message starts with !feed *)
324324- if String.starts_with ~prefix:"!feed" content then
325325- let parts = String.split_on_char ' ' (String.trim content) in
326326- match parts with
327327- | _ :: command :: args ->
328328- let response = handle_command bot_state command args in
329329- Bot_handler.Response.Reply response
330330- | _ ->
331331- let response = handle_command bot_state "help" [] in
332332- Bot_handler.Response.Reply response
333333- else
334334- Bot_handler.Response.None
335335- end in
336336- (module Handler : Bot_handler.S)
284284+ (* Create a handler function for the bot *)
285285+ let create_handler bot_state ~storage:_ ~identity message =
286286+ let content = Message.content message in
287287+ let bot_email = identity.Bot.email in
288288+ if Message.is_from_email message ~email:bot_email then Response.silent
289289+ else if String.starts_with ~prefix:"!feed" content then
290290+ let parts = String.split_on_char ' ' (String.trim content) in
291291+ match parts with
292292+ | _ :: command :: args ->
293293+ let response = handle_command bot_state command args in
294294+ Response.reply response
295295+ | _ ->
296296+ let response = handle_command bot_state "help" [] in
297297+ Response.reply response
298298+ else Response.silent
337299end
338300339301(* Run interactive bot mode *)
340302let run_interactive verbosity env =
341341- (* Setup logging *)
342303 Logs.set_reporter (Logs_fmt.reporter ());
343343- Logs.set_level (Some (match verbosity with
344344- | 0 -> Logs.Info
345345- | 1 -> Logs.Debug
346346- | _ -> Logs.Debug));
304304+ Logs.set_level
305305+ (Some (match verbosity with 0 -> Logs.Info | 1 -> Logs.Debug | _ -> Logs.Debug));
347306348307 Log.info (fun m -> m "Starting interactive Atom feed bot...");
349308350309 let bot_state = Interactive_feed_bot.create () in
351351- let handler = Interactive_feed_bot.create_handler bot_state in
352310353353- (* Load auth and create bot runner *)
354311 let auth =
355312 try Zulip.Auth.from_zuliprc ()
356313 with Eio.Exn.Io _ as e ->
···359316 in
360317361318 Eio.Switch.run @@ fun sw ->
362362- let client = Zulip.Client.create ~sw env auth in
319319+ let config =
320320+ Zulip_bot.Config.create ~name:"atom-feed-bot"
321321+ ~site:(Zulip.Auth.server_url auth) ~email:(Zulip.Auth.email auth)
322322+ ~api_key:(Zulip.Auth.api_key auth)
323323+ ~description:"Bot for managing and posting Atom/RSS feeds to Zulip" ()
324324+ in
363325364364- (* Create and run bot *)
365365- let config = Zulip_bot.Bot_config.create [] in
366366- let bot_email = Zulip.Auth.email auth in
367367- let storage = Zulip_bot.Bot_storage.create client ~bot_email in
368368- let identity = Zulip_bot.Bot_handler.Identity.create
369369- ~full_name:"Atom Feed Bot"
370370- ~email:bot_email
371371- ~mention_name:"feedbot"
372372- in
326326+ Log.info (fun m -> m "Feed bot is running! Use !feed help for commands.");
373327374374- let bot = Zulip_bot.Bot_handler.create handler ~config ~storage ~identity in
375375- let runner = Zulip_bot.Bot_runner.create ~env ~client ~handler:bot in
376376- Zulip_bot.Bot_runner.run_realtime runner
328328+ Zulip_bot.Bot.run ~sw ~env ~config
329329+ ~handler:(Interactive_feed_bot.create_handler bot_state)
377330378331(* Run scheduled fetcher mode *)
379332let run_scheduled verbosity env =
380380- (* Setup logging *)
381333 Logs.set_reporter (Logs_fmt.reporter ());
382382- Logs.set_level (Some (match verbosity with
383383- | 0 -> Logs.Info
384384- | 1 -> Logs.Debug
385385- | _ -> Logs.Debug));
334334+ Logs.set_level
335335+ (Some (match verbosity with 0 -> Logs.Info | 1 -> Logs.Debug | _ -> Logs.Debug));
386336387337 Log.info (fun m -> m "Starting scheduled Atom feed fetcher...");
388338389389- let config = Feed_bot.{
390390- feeds = [
391391- ("https://example.com/feed.xml", "general", "News");
392392- ("https://blog.example.com/atom.xml", "general", "Blog Posts");
393393- ];
394394- refresh_interval = 300.0; (* 5 minutes *)
395395- state_file = "feed_bot_state.txt";
396396- } in
339339+ let config =
340340+ Feed_bot.
341341+ {
342342+ feeds =
343343+ [
344344+ ("https://example.com/feed.xml", "general", "News");
345345+ ("https://blog.example.com/atom.xml", "general", "Blog Posts");
346346+ ];
347347+ refresh_interval = 300.0;
348348+ state_file = "feed_bot_state.txt";
349349+ }
350350+ in
397351398352 Feed_bot.run_bot env config
399353···402356403357let verbosity =
404358 let doc = "Increase verbosity (can be used multiple times)" in
405405- let verbosity_flags = Arg.(value & flag_all & info ["v"; "verbose"] ~doc) in
359359+ let verbosity_flags = Arg.(value & flag_all & info [ "v"; "verbose" ] ~doc) in
406360 Term.(const List.length $ verbosity_flags)
407361408362let mode =
409363 let doc = "Bot mode (interactive or scheduled)" in
410410- let modes = ["interactive", `Interactive; "scheduled", `Scheduled] in
411411- Arg.(value & opt (enum modes) `Interactive & info ["m"; "mode"] ~docv:"MODE" ~doc)
364364+ let modes = [ ("interactive", `Interactive); ("scheduled", `Scheduled) ] in
365365+ Arg.(
366366+ value & opt (enum modes) `Interactive & info [ "m"; "mode" ] ~docv:"MODE" ~doc)
412367413368let main_cmd =
414369 let doc = "Atom feed bot for Zulip" in
415415- let man = [
416416- `S Manpage.s_description;
417417- `P "This bot can run in two modes:";
418418- `P "- Interactive mode: Responds to !feed commands in Zulip";
419419- `P "- Scheduled mode: Periodically fetches configured feeds";
420420- `P "The bot requires a configured ~/.zuliprc file with API credentials.";
421421- ] in
422422- let info = Cmd.info "atom_feed_bot" ~version:"1.0.0" ~doc ~man in
370370+ let man =
371371+ [
372372+ `S Manpage.s_description;
373373+ `P "This bot can run in two modes:";
374374+ `P "- Interactive mode: Responds to !feed commands in Zulip";
375375+ `P "- Scheduled mode: Periodically fetches configured feeds";
376376+ `P "The bot requires a configured ~/.zuliprc file with API credentials.";
377377+ ]
378378+ in
379379+ let info = Cmd.info "atom_feed_bot" ~version:"2.0.0" ~doc ~man in
423380 let run verbosity mode =
424381 Eio_main.run @@ fun env ->
425382 match mode with
···429386 let term = Term.(const run $ verbosity $ mode) in
430387 Cmd.v info term
431388432432-let () = exit (Cmd.eval main_cmd)389389+let () = exit (Cmd.eval main_cmd)
+154-309
examples/echo_bot.ml
···11(* Enhanced Echo Bot for Zulip with Logging and CLI
22 Responds to direct messages and mentions by echoing back the message
33- Features verbose logging and command-line configuration *)
33+ Uses the new functional Zulip_bot API *)
4455open Zulip_bot
6677(* Set up logging *)
88let src = Logs.Src.create "echo_bot" ~doc:"Zulip Echo Bot"
99-module Log = (val Logs.src_log src : Logs.LOG)
1010-1111-module Echo_bot_handler : Bot_handler.S = struct
1212- let initialize _config =
1313- Log.info (fun m -> m "Initializing echo bot handler");
1414- Log.debug (fun m -> m "Bot handler initialized")
1515-1616- let usage () =
1717- "Echo Bot - I repeat everything you say to me!"
1818-1919- let description () =
2020- "A simple echo bot that repeats messages sent to it. \
2121- Send me a direct message or mention me in a channel."
2292323- let handle_message ~config:_ ~storage ~identity ~message ~env:_ =
2424- (* Log the message with colorful formatting *)
2525- Log.debug (fun m -> m "@[<h>Received: %a@]" (Message.pp_ansi ~show_json:false) message);
2626-2727- (* Use the new Message type for cleaner handling *)
2828- match message with
2929- | Message.Private { common; display_recipient = _ } ->
3030-3131- (* Check if this is our own message to avoid loops *)
3232- let bot_email = Bot_handler.Identity.email identity in
3333- if common.sender_email = bot_email then (
3434- Log.debug (fun m -> m "Ignoring own message");
3535- Bot_handler.Response.None
3636- ) else
3737- (* Process the message content *)
3838- let sender_name = common.sender_full_name in
3939-4040- (* Remove bot mention using Message utility *)
4141- let bot_email = Bot_handler.Identity.email identity in
4242- let cleaned_msg = Message.strip_mention message ~user_email:bot_email in
4343- Log.debug (fun m -> m "Cleaned message: %s" cleaned_msg);
1010+module Log = (val Logs.src_log src : Logs.LOG)
44114545- (* Create echo response *)
4646- let response_content =
4747- let lower_msg = String.lowercase_ascii cleaned_msg in
4848- if cleaned_msg = "" then
4949- Printf.sprintf "Hello %s! Send me a message and I'll echo it back!" sender_name
5050- else if lower_msg = "help" then
5151- Printf.sprintf "Hi %s! I'm an echo bot with storage. Commands:\n\
5252- • `help` - Show this help\n\
5353- • `ping` - Test if I'm alive\n\
5454- • `store <key> <value>` - Store a value\n\
5555- • `get <key>` - Retrieve a value\n\
5656- • `delete <key>` - Delete a stored value\n\
5757- • `list` - List all stored keys\n\
5858- • Any other message - I'll echo it back!" sender_name
5959- else if lower_msg = "ping" then (
6060- Log.info (fun m -> m "Responding to ping from %s" sender_name);
6161- Printf.sprintf "Pong! 🏓 (from %s)" sender_name
6262- )
6363- else if String.starts_with ~prefix:"store " lower_msg then (
6464- (* Parse store command: store <key> <value> *)
6565- let parts = String.sub cleaned_msg 6 (String.length cleaned_msg - 6) |> String.trim in
6666- match String.index_opt parts ' ' with
6767- | Some idx ->
6868- let key = String.sub parts 0 idx |> String.trim in
6969- let value = String.sub parts (idx + 1) (String.length parts - idx - 1) |> String.trim in
7070- (try
7171- Bot_storage.put storage ~key ~value;
7272- Log.info (fun m -> m "Stored key=%s value=%s for user %s" key value sender_name);
7373- Printf.sprintf "✅ Stored: `%s` = `%s`" key value
7474- with Eio.Exn.Io _ as e ->
7575- Log.err (fun m -> m "Failed to store key=%s: %s" key (Printexc.to_string e));
7676- Printf.sprintf "❌ Failed to store: %s" (Printexc.to_string e))
7777- | None ->
7878- "Usage: `store <key> <value>` - Example: `store name John`"
7979- )
8080- else if String.starts_with ~prefix:"get " lower_msg then (
8181- (* Parse get command: get <key> *)
8282- let key = String.sub cleaned_msg 4 (String.length cleaned_msg - 4) |> String.trim in
8383- match Bot_storage.get storage ~key with
8484- | Some value ->
8585- Log.info (fun m -> m "Retrieved key=%s value=%s for user %s" key value sender_name);
8686- Printf.sprintf "📦 `%s` = `%s`" key value
8787- | None ->
8888- Log.info (fun m -> m "Key not found: %s" key);
8989- Printf.sprintf "❓ Key not found: `%s`" key
9090- )
9191- else if String.starts_with ~prefix:"delete " lower_msg then (
9292- (* Parse delete command: delete <key> *)
9393- let key = String.sub cleaned_msg 7 (String.length cleaned_msg - 7) |> String.trim in
9494- try
9595- Bot_storage.remove storage ~key;
9696- Log.info (fun m -> m "Deleted key=%s for user %s" key sender_name);
9797- Printf.sprintf "🗑️ Deleted key: `%s`" key
9898- with Eio.Exn.Io _ as e ->
9999- Log.err (fun m -> m "Failed to delete key=%s: %s" key (Printexc.to_string e));
100100- Printf.sprintf "❌ Failed to delete: %s" (Printexc.to_string e)
101101- )
102102- else if lower_msg = "list" then (
103103- (* List all stored keys *)
104104- try
105105- let keys = Bot_storage.keys storage in
106106- if keys = [] then
107107- "📭 No keys stored yet. Use `store <key> <value>` to add data!"
108108- else
109109- let key_list = String.concat "\n" (List.map (fun k -> "• `" ^ k ^ "`") keys) in
110110- Printf.sprintf "📋 Stored keys:\n%s\n\nUse `get <key>` to retrieve values." key_list
111111- with Eio.Exn.Io _ as e ->
112112- Printf.sprintf "❌ Failed to list keys: %s" (Printexc.to_string e)
113113- )
114114- else
115115- Printf.sprintf "Echo from %s: %s" sender_name cleaned_msg
116116- in
1212+(* The handler is now just a function *)
1313+let echo_handler ~storage ~identity msg =
1414+ Log.debug (fun m -> m "@[<h>Received: %a@]" (Message.pp_ansi ~show_json:false) msg);
11715118118- Log.debug (fun m -> m "Generated response: %s" response_content);
119119- Log.info (fun m -> m "Sending private reply");
120120- Bot_handler.Response.Reply response_content
1616+ let bot_email = identity.Bot.email in
1717+ let sender_email = Message.sender_email msg in
1818+ let sender_name = Message.sender_full_name msg in
12119122122- | Message.Stream { common; display_recipient = _; subject = _; _ } ->
123123- (* Check if this is our own message to avoid loops *)
124124- let bot_email = Bot_handler.Identity.email identity in
125125- if common.sender_email = bot_email then (
126126- Log.debug (fun m -> m "Ignoring own message");
127127- Bot_handler.Response.None
128128- ) else
129129- (* Process the message content *)
130130- let sender_name = common.sender_full_name in
2020+ (* Ignore our own messages *)
2121+ if sender_email = bot_email then Response.silent
2222+ else
2323+ (* Remove bot mention *)
2424+ let cleaned_msg = Message.strip_mention msg ~user_email:bot_email in
2525+ Log.debug (fun m -> m "Cleaned message: %s" cleaned_msg);
13126132132- (* Remove bot mention using Message utility *)
133133- let cleaned_msg = Message.strip_mention message ~user_email:bot_email in
2727+ (* Process command or echo *)
2828+ let lower_msg = String.lowercase_ascii cleaned_msg in
2929+ let response_content =
3030+ if cleaned_msg = "" then
3131+ Printf.sprintf "Hello %s! Send me a message and I'll echo it back!"
3232+ sender_name
3333+ else if lower_msg = "help" then
3434+ Printf.sprintf
3535+ "Hi %s! I'm an echo bot with storage. Commands:\n\
3636+ • `help` - Show this help\n\
3737+ • `ping` - Test if I'm alive\n\
3838+ • `store <key> <value>` - Store a value\n\
3939+ • `get <key>` - Retrieve a value\n\
4040+ • `delete <key>` - Delete a stored value\n\
4141+ • `list` - List all stored keys\n\
4242+ • Any other message - I'll echo it back!"
4343+ sender_name
4444+ else if lower_msg = "ping" then (
4545+ Log.info (fun m -> m "Responding to ping from %s" sender_name);
4646+ Printf.sprintf "Pong! (from %s)" sender_name)
4747+ else if String.starts_with ~prefix:"store " lower_msg then (
4848+ let parts =
4949+ String.sub cleaned_msg 6 (String.length cleaned_msg - 6)
5050+ |> String.trim
5151+ in
5252+ match String.index_opt parts ' ' with
5353+ | Some idx ->
5454+ let key = String.sub parts 0 idx |> String.trim in
5555+ let value =
5656+ String.sub parts (idx + 1) (String.length parts - idx - 1)
5757+ |> String.trim
5858+ 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`")
6969+ else if String.starts_with ~prefix:"get " lower_msg then (
7070+ let key =
7171+ String.sub cleaned_msg 4 (String.length cleaned_msg - 4) |> String.trim
7272+ in
7373+ match Storage.get storage key with
7474+ | Some value ->
7575+ Log.info (fun m ->
7676+ m "Retrieved key=%s value=%s for user %s" key value sender_name);
7777+ Printf.sprintf "`%s` = `%s`" key value
7878+ | None ->
7979+ Log.info (fun m -> m "Key not found: %s" key);
8080+ Printf.sprintf "Key not found: `%s`" key)
8181+ else if String.starts_with ~prefix:"delete " lower_msg then (
8282+ let key =
8383+ String.sub cleaned_msg 7 (String.length cleaned_msg - 7) |> String.trim
8484+ in
8585+ try
8686+ Storage.remove storage key;
8787+ Log.info (fun m -> m "Deleted key=%s for user %s" key sender_name);
8888+ Printf.sprintf "Deleted key: `%s`" key
8989+ with Eio.Exn.Io _ as e ->
9090+ Log.err (fun m ->
9191+ m "Failed to delete key=%s: %s" key (Printexc.to_string e));
9292+ Printf.sprintf "Failed to delete: %s" (Printexc.to_string e))
9393+ else if lower_msg = "list" then (
9494+ try
9595+ let keys = Storage.keys storage in
9696+ if keys = [] then
9797+ "No keys stored yet. Use `store <key> <value>` to add data!"
9898+ else
9999+ let key_list =
100100+ String.concat "\n" (List.map (fun k -> "* `" ^ k ^ "`") keys)
101101+ in
102102+ Printf.sprintf "Stored keys:\n%s\n\nUse `get <key>` to retrieve values."
103103+ key_list
104104+ with Eio.Exn.Io _ as e ->
105105+ Printf.sprintf "Failed to list keys: %s" (Printexc.to_string e))
106106+ else Printf.sprintf "Echo from %s: %s" sender_name cleaned_msg
107107+ in
108108+ Log.debug (fun m -> m "Generated response: %s" response_content);
109109+ Response.reply response_content
134110135135- (* Create echo response *)
136136- let response_content =
137137- let lower_msg = String.lowercase_ascii cleaned_msg in
138138- if cleaned_msg = "" then
139139- Printf.sprintf "Hello %s! Send me a message and I'll echo it back!" sender_name
140140- else if lower_msg = "help" then
141141- Printf.sprintf "Hi %s! I'm an echo bot with storage. Commands:\n\
142142- • `help` - Show this help\n\
143143- • `ping` - Test if I'm alive\n\
144144- • `store <key> <value>` - Store a value\n\
145145- • `get <key>` - Retrieve a value\n\
146146- • `delete <key>` - Delete a stored value\n\
147147- • `list` - List all stored keys\n\
148148- • Any other message - I'll echo it back!" sender_name
149149- else if lower_msg = "ping" then (
150150- Log.info (fun m -> m "Responding to ping from %s" sender_name);
151151- Printf.sprintf "Pong! 🏓 (from %s)" sender_name
152152- )
153153- else if String.starts_with ~prefix:"store " lower_msg then (
154154- (* Parse store command: store <key> <value> *)
155155- let parts = String.sub cleaned_msg 6 (String.length cleaned_msg - 6) |> String.trim in
156156- match String.index_opt parts ' ' with
157157- | Some idx ->
158158- let key = String.sub parts 0 idx |> String.trim in
159159- let value = String.sub parts (idx + 1) (String.length parts - idx - 1) |> String.trim in
160160- (try
161161- Bot_storage.put storage ~key ~value;
162162- Log.info (fun m -> m "Stored key=%s value=%s for user %s" key value sender_name);
163163- Printf.sprintf "✅ Stored: `%s` = `%s`" key value
164164- with Eio.Exn.Io _ as e ->
165165- Log.err (fun m -> m "Failed to store key=%s: %s" key (Printexc.to_string e));
166166- Printf.sprintf "❌ Failed to store: %s" (Printexc.to_string e))
167167- | None ->
168168- "Usage: `store <key> <value>` - Example: `store name John`"
169169- )
170170- else if String.starts_with ~prefix:"get " lower_msg then (
171171- (* Parse get command: get <key> *)
172172- let key = String.sub cleaned_msg 4 (String.length cleaned_msg - 4) |> String.trim in
173173- match Bot_storage.get storage ~key with
174174- | Some value ->
175175- Log.info (fun m -> m "Retrieved key=%s value=%s for user %s" key value sender_name);
176176- Printf.sprintf "📦 `%s` = `%s`" key value
177177- | None ->
178178- Log.info (fun m -> m "Key not found: %s" key);
179179- Printf.sprintf "❓ Key not found: `%s`" key
180180- )
181181- else if String.starts_with ~prefix:"delete " lower_msg then (
182182- (* Parse delete command: delete <key> *)
183183- let key = String.sub cleaned_msg 7 (String.length cleaned_msg - 7) |> String.trim in
184184- try
185185- Bot_storage.remove storage ~key;
186186- Log.info (fun m -> m "Deleted key=%s for user %s" key sender_name);
187187- Printf.sprintf "🗑️ Deleted key: `%s`" key
188188- with Eio.Exn.Io _ as e ->
189189- Log.err (fun m -> m "Failed to delete key=%s: %s" key (Printexc.to_string e));
190190- Printf.sprintf "❌ Failed to delete: %s" (Printexc.to_string e)
191191- )
192192- else if lower_msg = "list" then (
193193- (* List all stored keys *)
194194- try
195195- let keys = Bot_storage.keys storage in
196196- if keys = [] then
197197- "📭 No keys stored yet. Use `store <key> <value>` to add data!"
198198- else
199199- let key_list = String.concat "\n" (List.map (fun k -> "• `" ^ k ^ "`") keys) in
200200- Printf.sprintf "📋 Stored keys:\n%s\n\nUse `get <key>` to retrieve values." key_list
201201- with Eio.Exn.Io _ as e ->
202202- Printf.sprintf "❌ Failed to list keys: %s" (Printexc.to_string e)
203203- )
204204- else
205205- Printf.sprintf "Echo from %s: %s" sender_name cleaned_msg
206206- in
207207-208208- Log.debug (fun m -> m "Generated response: %s" response_content);
209209- Log.info (fun m -> m "Sending stream reply");
210210- Bot_handler.Response.Reply response_content
211211-212212- | Message.Unknown _ ->
213213- Log.err (fun m -> m "Received unknown message format");
214214- Bot_handler.Response.None
215215-end
216216-217217-let run_echo_bot config_file verbosity env =
111111+let run_echo_bot config_path verbosity env =
218112 (* Set up logging based on verbosity *)
219113 Logs.set_reporter (Logs_fmt.reporter ());
220220- let log_level = match verbosity with
221221- | 0 -> Logs.Info
222222- | 1 -> Logs.Debug
223223- | _ -> Logs.Debug (* Cap at debug level *)
114114+ let log_level =
115115+ match verbosity with 0 -> Logs.Info | 1 -> Logs.Debug | _ -> Logs.Debug
224116 in
225117 Logs.set_level (Some log_level);
226226-227227- (* Also set levels for related modules if they exist *)
228118 Logs.Src.set_level src (Some log_level);
229119230120 Log.app (fun m -> m "Starting Zulip Echo Bot");
231121 Log.app (fun m -> m "Log level: %s" (Logs.level_to_string (Some log_level)));
232122 Log.app (fun m -> m "=============================\n");
233123234234- (* Load authentication from .zuliprc file *)
235235- let auth =
236236- try
237237- let a = Zulip.Auth.from_zuliprc ?path:config_file () in
238238- Log.info (fun m -> m "Loaded authentication for: %s" (Zulip.Auth.email a));
239239- Log.info (fun m -> m "Server: %s" (Zulip.Auth.server_url a));
240240- a
241241- with Eio.Exn.Io _ as e ->
242242- Log.err (fun m -> m "Failed to load .zuliprc: %s" (Printexc.to_string e));
243243- Log.app (fun m -> m "\nPlease create a ~/.zuliprc file with:");
244244- Log.app (fun m -> m "[api]");
245245- Log.app (fun m -> m "email=bot@example.com");
246246- Log.app (fun m -> m "key=your-api-key");
247247- Log.app (fun m -> m "site=https://your-domain.zulipchat.com");
248248- exit 1
249249- in
250250-251124 Eio.Switch.run @@ fun sw ->
252252- Log.debug (fun m -> m "Creating Zulip client");
253253- let client = Zulip.Client.create ~sw env auth in
125125+ let fs = Eio.Stdenv.fs env in
254126255255- (* Create bot configuration *)
256256- let config = Bot_config.create [] in
257257- let bot_email = Zulip.Auth.email auth in
258258-259259- Log.debug (fun m -> m "Creating bot storage for %s" bot_email);
260260- let storage = Bot_storage.create client ~bot_email in
261261-262262- let identity = Bot_handler.Identity.create
263263- ~full_name:"Echo Bot"
264264- ~email:bot_email
265265- ~mention_name:"echobot"
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" ())
266144 in
267267- Log.info (fun m -> m "Bot identity created: %s (%s)"
268268- (Bot_handler.Identity.full_name identity)
269269- (Bot_handler.Identity.email identity));
270145271271- (* Create and run the bot *)
272272- Log.debug (fun m -> m "Creating bot handler");
273273- let handler = Bot_handler.create
274274- (module Echo_bot_handler)
275275- ~config ~storage ~identity
276276- in
277277-278278- Log.debug (fun m -> m "Creating bot runner");
279279- let runner = Bot_runner.create ~env ~client ~handler in
146146+ Log.info (fun m -> m "Loaded configuration for: %s" config.email);
147147+ Log.info (fun m -> m "Server: %s" config.site);
280148281149 Log.app (fun m -> m "Echo bot is running!");
282282- Log.app (fun m -> m "Send a direct message or mention @echobot in a channel.");
150150+ Log.app (fun m -> m "Send a direct message or mention the bot in a channel.");
283151 Log.app (fun m -> m "Commands: 'help', 'ping', or any message to echo");
284152 Log.app (fun m -> m "Press Ctrl+C to stop.\n");
285153286286- (* Run in real-time mode *)
287287- Log.info (fun m -> m "Starting real-time event loop");
288288- try
289289- Bot_runner.run_realtime runner;
290290- Log.info (fun m -> m "Bot runner exited normally")
154154+ (* Run the bot - this is now just a simple function call *)
155155+ try Bot.run ~sw ~env ~config ~handler:echo_handler
291156 with
292292- | Sys.Break ->
293293- Log.info (fun m -> m "Received interrupt signal, shutting down");
294294- Bot_runner.shutdown runner
157157+ | Sys.Break -> Log.info (fun m -> m "Received interrupt signal, shutting down")
295158 | exn ->
296159 Log.err (fun m -> m "Bot crashed with exception: %s" (Printexc.to_string exn));
297160 Log.debug (fun m -> m "Backtrace: %s" (Printexc.get_backtrace ()));
···302165303166let config_file =
304167 let doc = "Path to .zuliprc configuration file" in
305305- Arg.(value & opt (some string) None & info ["c"; "config"] ~docv:"FILE" ~doc)
168168+ Arg.(value & opt (some string) None & info [ "c"; "config" ] ~docv:"FILE" ~doc)
306169307170let verbosity =
308171 let doc = "Increase verbosity. Use multiple times for more verbose output." in
309309- Arg.(value & flag_all & info ["v"; "verbose"] ~doc)
172172+ Arg.(value & flag_all & info [ "v"; "verbose" ] ~doc)
310173311311-let verbosity_term =
312312- Term.(const List.length $ verbosity)
174174+let verbosity_term = Term.(const List.length $ verbosity)
313175314176let bot_cmd eio_env =
315177 let doc = "Zulip Echo Bot with verbose logging" in
316316- let man = [
317317- `S Manpage.s_description;
318318- `P "A simple echo bot for Zulip that responds to messages by echoing them back. \
319319- Features verbose logging for debugging and development.";
320320- `S "CONFIGURATION";
321321- `P "The bot reads configuration from a .zuliprc file (default: ~/.zuliprc).";
322322- `P "The file should contain:";
323323- `Pre "[api]\n\
324324- email=bot@example.com\n\
325325- key=your-api-key\n\
326326- site=https://your-domain.zulipchat.com";
327327- `S "LOGGING";
328328- `P "Use -v for info level logging, -vv for debug level logging.";
329329- `P "Log messages include:";
330330- `P "- Message metadata (sender, type, ID)";
331331- `P "- Message processing steps";
332332- `P "- Bot responses";
333333- `P "- Error conditions";
334334- `S "COMMANDS";
335335- `P "The bot responds to:";
336336- `P "- 'help' - Show usage information";
337337- `P "- 'ping' - Respond with 'Pong!'";
338338- `P "- Any other message - Echo it back";
339339- `S Manpage.s_examples;
340340- `P "Run with default configuration:";
341341- `Pre " echo_bot";
342342- `P "Run with verbose logging:";
343343- `Pre " echo_bot -v";
344344- `P "Run with debug logging:";
345345- `Pre " echo_bot -vv";
346346- `P "Run with custom config file:";
347347- `Pre " echo_bot -c /path/to/.zuliprc";
348348- `P "Run with maximum verbosity and custom config:";
349349- `Pre " echo_bot -vv -c ~/my-bot.zuliprc";
350350- `S Manpage.s_bugs;
351351- `P "Report bugs at https://github.com/your-org/zulip-ocaml/issues";
352352- `S Manpage.s_see_also;
353353- `P "zulip(1), zulip-bot(1)";
354354- ] in
355355- let info = Cmd.info "echo_bot" ~version:"1.0.0" ~doc ~man in
356356- Cmd.v info Term.(const (run_echo_bot) $ config_file $ verbosity_term $ const eio_env)
178178+ let man =
179179+ [
180180+ `S Manpage.s_description;
181181+ `P
182182+ "A simple echo bot for Zulip that responds to messages by echoing them \
183183+ back. Features verbose logging for debugging and development.";
184184+ `S "CONFIGURATION";
185185+ `P
186186+ "The bot reads configuration from XDG config directory \
187187+ (~/.config/zulip-bot/echo-bot/config) or from a .zuliprc file.";
188188+ `S "LOGGING";
189189+ `P "Use -v for info level logging, -vv for debug level logging.";
190190+ `S "COMMANDS";
191191+ `P "The bot responds to:";
192192+ `P "- 'help' - Show usage information";
193193+ `P "- 'ping' - Respond with 'Pong!'";
194194+ `P "- 'store <key> <value>' - Store a value";
195195+ `P "- 'get <key>' - Retrieve a value";
196196+ `P "- 'delete <key>' - Delete a stored value";
197197+ `P "- 'list' - List all stored keys";
198198+ `P "- Any other message - Echo it back";
199199+ ]
200200+ in
201201+ 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)
357204358205let () =
359359- (* Initialize the cryptographic RNG for the application *)
360206 Mirage_crypto_rng_unix.use_default ();
361361- Eio_main.run @@ fun env ->
362362- exit (Cmd.eval (bot_cmd env))
207207+ Eio_main.run @@ fun env -> exit (Cmd.eval (bot_cmd env))
+40-66
examples/test_realtime_bot.ml
···4455(* Logging setup *)
66let src = Logs.Src.create "test_realtime_bot" ~doc:"Test real-time bot"
77+78module Log = (val Logs.src_log src : Logs.LOG)
8999-(* Simple test bot that logs everything *)
1010-module Test_bot_handler : Bot_handler.S = struct
1111- let initialize _config =
1212- Log.info (fun m -> m "Bot initialized")
1010+(* Simple test bot handler that logs everything *)
1111+let test_handler ~storage ~identity:_ message =
1212+ Log.info (fun m -> m "Received message");
13131414- let usage () = "Test Bot - Verifies real-time event processing"
1414+ let content = Message.content message in
1515+ let sender = Message.sender_email message in
1616+ let is_direct = Message.is_private message in
15171616- let description () = "A test bot that logs all messages received"
1818+ Log.info (fun m -> m "Content: %s" content);
1919+ Log.info (fun m -> m "Sender: %s" sender);
2020+ Log.info (fun m -> m "Direct: %b" is_direct);
17211818- let handle_message ~config:_ ~storage ~identity:_ ~message ~env:_ =
1919- Log.info (fun m -> m "Received message");
2222+ (* Test storage *)
2323+ (try
2424+ Storage.set storage "last_message" content;
2525+ Log.info (fun m -> m "Stored message in bot storage")
2626+ with Eio.Exn.Io _ as e ->
2727+ Log.err (fun m -> m "Storage error: %s" (Printexc.to_string e)));
20282121- (* Extract and log message details *)
2222- let content = Message.content message in
2323- let sender = Message.sender_email message in
2424- let is_direct = Message.is_private message in
2525-2626- Log.info (fun m -> m "Content: %s" content);
2727- Log.info (fun m -> m "Sender: %s" sender);
2828- Log.info (fun m -> m "Direct: %b" is_direct);
2929-3030- (* Test storage *)
3131- let test_key = "last_message" in
3232- let test_value = content in
3333-3434- (try
3535- Bot_storage.put storage ~key:test_key ~value:test_value;
3636- Log.info (fun m -> m "Stored message in bot storage")
3737- with Eio.Exn.Io _ as e ->
3838- Log.err (fun m -> m "Storage error: %s" (Printexc.to_string e)));
3939-4040- (* Always reply with confirmation *)
4141- let reply = Printf.sprintf "Test bot received: %s" content in
4242- Bot_handler.Response.Reply reply
4343-end
2929+ (* Always reply with confirmation *)
3030+ Response.reply (Printf.sprintf "Test bot received: %s" content)
44314532let run_test verbosity env =
4633 (* Setup logging *)
4734 Logs.set_reporter (Logs_fmt.reporter ());
4848- Logs.set_level (Some (match verbosity with
4949- | 0 -> Logs.Info
5050- | 1 -> Logs.Debug
5151- | _ -> Logs.Debug));
3535+ Logs.set_level
3636+ (Some (match verbosity with 0 -> Logs.Info | 1 -> Logs.Debug | _ -> Logs.Debug));
52375338 Log.info (fun m -> m "Real-time Bot Test");
5439 Log.info (fun m -> m "==================");
55405656- (* Load auth *)
4141+ (* Load auth from .zuliprc *)
5742 let auth =
5843 try
5944 let a = Zulip.Auth.from_zuliprc () in
···6651 in
67526853 Eio.Switch.run @@ fun sw ->
6969- let client = Zulip.Client.create ~sw env auth in
7070-7171- (* Create bot components *)
7272- let config = Bot_config.create [] in
7373- let bot_email = Zulip.Auth.email auth in
7474- let storage = Bot_storage.create client ~bot_email in
7575- let identity = Bot_handler.Identity.create
7676- ~full_name:"Test Bot"
7777- ~email:bot_email
7878- ~mention_name:"testbot"
5454+ (* Create configuration from auth *)
5555+ let config =
5656+ Config.create ~name:"test-bot" ~site:(Zulip.Auth.server_url auth)
5757+ ~email:(Zulip.Auth.email auth) ~api_key:(Zulip.Auth.api_key auth)
5858+ ~description:"A test bot that logs all messages received" ()
7959 in
80608181- (* Create handler and runner *)
8282- let handler = Bot_handler.create
8383- (module Test_bot_handler)
8484- ~config ~storage ~identity
8585- in
8686- let runner = Bot_runner.create ~env ~client ~handler in
8787-8861 Log.info (fun m -> m "Starting bot in real-time mode...");
8962 Log.info (fun m -> m "The bot will:");
9063 Log.info (fun m -> m "- Register for message events");
···9366 Log.info (fun m -> m "- Store messages in Zulip bot storage");
9467 Log.info (fun m -> m "Press Ctrl+C to stop.");
95689696- (* Run the bot *)
9797- Bot_runner.run_realtime runner
6969+ (* Run the bot - now just a simple function call *)
7070+ Bot.run ~sw ~env ~config ~handler:test_handler
98719972(* Command-line interface *)
10073open Cmdliner
1017410275let verbosity =
10376 let doc = "Increase verbosity (can be used multiple times)" in
104104- let verbosity_flags = Arg.(value & flag_all & info ["v"; "verbose"] ~doc) in
7777+ let verbosity_flags = Arg.(value & flag_all & info [ "v"; "verbose" ] ~doc) in
10578 Term.(const List.length $ verbosity_flags)
1067910780let main_cmd =
10881 let doc = "Test real-time bot for Zulip" in
109109- let man = [
110110- `S Manpage.s_description;
111111- `P "This bot tests real-time event processing with the Zulip API. \
112112- It will echo received messages and store them in bot storage.";
113113- `P "The bot requires a configured ~/.zuliprc file with API credentials.";
114114- ] in
115115- let info = Cmd.info "test_realtime_bot" ~version:"1.0.0" ~doc ~man in
116116- let run verbosity =
117117- Eio_main.run (run_test verbosity)
8282+ let man =
8383+ [
8484+ `S Manpage.s_description;
8585+ `P
8686+ "This bot tests real-time event processing with the Zulip API. It will \
8787+ echo received messages and store them in bot storage.";
8888+ `P "The bot requires a configured ~/.zuliprc file with API credentials.";
8989+ ]
11890 in
9191+ let info = Cmd.info "test_realtime_bot" ~version:"2.0.0" ~doc ~man in
9292+ let run verbosity = Eio_main.run (run_test verbosity) in
11993 let term = Term.(const run $ verbosity) in
12094 Cmd.v info term
12195122122-let () = exit (Cmd.eval main_cmd)9696+let () = exit (Cmd.eval main_cmd)
+1
lib/zulip/auth.ml
···98989999let server_url t = t.server_url
100100let email t = t.email
101101+let api_key t = t.api_key
101102102103let to_basic_auth_header t =
103104 match Base64.encode (t.email ^ ":" ^ t.api_key) with
+1
lib/zulip/auth.mli
···15151616val server_url : t -> string
1717val email : t -> string
1818+val api_key : t -> string
1819val to_basic_auth_header : t -> string
1920val pp : Format.formatter -> t -> unit
+182
lib/zulip_bot/bot.ml
···11+let src = Logs.Src.create "zulip_bot.bot" ~doc:"Zulip bot runner"
22+33+module Log = (val Logs.src_log src : Logs.LOG)
44+55+type identity = { user_id : int; email : string; full_name : string }
66+type handler = storage:Storage.t -> identity:identity -> Message.t -> Response.t
77+88+let create_client ~sw ~env ~config =
99+ let auth =
1010+ Zulip.Auth.create ~server_url:config.Config.site ~email:config.Config.email
1111+ ~api_key:config.Config.api_key
1212+ in
1313+ Zulip.Client.create ~sw env auth
1414+1515+let 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+ match List.assoc_opt key assoc with
2222+ | Some (Jsont.Number (f, _)) -> int_of_float f
2323+ | _ -> 0
2424+ in
2525+ let get_string key =
2626+ match List.assoc_opt key assoc with
2727+ | Some (Jsont.String (s, _)) -> s
2828+ | _ -> ""
2929+ in
3030+ { user_id = get_int "user_id"; email = get_string "email"; full_name = get_string "full_name" }
3131+ | _ ->
3232+ Log.warn (fun m -> m "Unexpected response format from /users/me");
3333+ { user_id = 0; email = ""; full_name = "" }
3434+3535+let send_response client ~in_reply_to response =
3636+ match response with
3737+ | Response.Reply content ->
3838+ let message_to_send =
3939+ if Message.is_private in_reply_to then
4040+ let sender = Message.sender_email in_reply_to in
4141+ Zulip.Message.create ~type_:`Direct ~to_:[ sender ] ~content ()
4242+ else
4343+ let reply_to = Message.get_reply_to in_reply_to in
4444+ let topic =
4545+ match in_reply_to with
4646+ | Message.Stream { subject; _ } -> Some subject
4747+ | _ -> None
4848+ in
4949+ Zulip.Message.create ~type_:`Channel ~to_:[ reply_to ] ~content ?topic
5050+ ()
5151+ in
5252+ let resp = Zulip.Messages.send client message_to_send in
5353+ Log.info (fun m ->
5454+ m "Reply sent (id: %d)" (Zulip.Message_response.id resp))
5555+ | Response.Direct { recipients; content } ->
5656+ let message_to_send =
5757+ Zulip.Message.create ~type_:`Direct ~to_:recipients ~content ()
5858+ in
5959+ let resp = Zulip.Messages.send client message_to_send in
6060+ Log.info (fun m ->
6161+ m "Direct message sent (id: %d)" (Zulip.Message_response.id resp))
6262+ | Response.Stream { stream; topic; content } ->
6363+ let message_to_send =
6464+ Zulip.Message.create ~type_:`Channel ~to_:[ stream ] ~topic ~content ()
6565+ in
6666+ let resp = Zulip.Messages.send client message_to_send in
6767+ Log.info (fun m ->
6868+ m "Stream message sent (id: %d)" (Zulip.Message_response.id resp))
6969+ | Response.Silent -> Log.debug (fun m -> m "Handler returned silent response")
7070+7171+let process_event ~client ~storage ~identity ~handler event =
7272+ Log.debug (fun m ->
7373+ m "Processing event type: %s"
7474+ (Zulip.Event_type.to_string (Zulip.Event.type_ event)));
7575+ match Zulip.Event.type_ event with
7676+ | Zulip.Event_type.Message -> (
7777+ let event_data = Zulip.Event.data event in
7878+ let message_json, flags =
7979+ match event_data with
8080+ | Jsont.Object (fields, _) ->
8181+ let assoc = List.map (fun ((k, _), v) -> (k, v)) fields in
8282+ let msg =
8383+ match List.assoc_opt "message" assoc with
8484+ | Some m -> m
8585+ | None -> event_data
8686+ in
8787+ let flgs =
8888+ match List.assoc_opt "flags" assoc with
8989+ | Some (Jsont.Array (f, _)) -> f
9090+ | _ -> []
9191+ in
9292+ (msg, flgs)
9393+ | _ -> (event_data, [])
9494+ in
9595+ match Message.of_json message_json with
9696+ | Error err ->
9797+ Log.err (fun m -> m "Failed to parse message JSON: %s" err);
9898+ Log.debug (fun m -> m "@[%a@]" Message.pp_json_debug message_json)
9999+ | Ok message -> (
100100+ Log.info (fun m ->
101101+ m "@[<h>%a@]" (Message.pp_ansi ~show_json:false) message);
102102+ let is_mentioned =
103103+ List.exists
104104+ (function Jsont.String ("mentioned", _) -> true | _ -> false)
105105+ flags
106106+ || Message.is_mentioned message ~user_email:identity.email
107107+ in
108108+ let is_private = Message.is_private message in
109109+ let is_from_self = Message.is_from_email message ~email:identity.email in
110110+ Log.debug (fun m ->
111111+ m "Message check: mentioned=%b, private=%b, from_self=%b"
112112+ is_mentioned is_private is_from_self);
113113+ if (is_mentioned || is_private) && not is_from_self then (
114114+ Log.info (fun m -> m "Bot should respond to this message");
115115+ try
116116+ let response = handler ~storage ~identity message in
117117+ send_response client ~in_reply_to:message response
118118+ with Eio.Exn.Io (e, _) ->
119119+ Log.err (fun m -> m "Error handling message: %a" Eio.Exn.pp_err e))
120120+ else
121121+ Log.debug (fun m ->
122122+ m "Not processing (not mentioned and not private)")))
123123+ | _ -> ()
124124+125125+let run ~sw ~env ~config ~handler =
126126+ Log.info (fun m -> m "Starting bot: %s" config.Config.name);
127127+ let client = create_client ~sw ~env ~config in
128128+ let identity = fetch_identity client in
129129+ let storage = Storage.create client in
130130+ Log.info (fun m ->
131131+ m "Bot identity: %s <%s> (id: %d)" identity.full_name identity.email
132132+ identity.user_id);
133133+ let queue =
134134+ Zulip.Event_queue.register client
135135+ ~event_types:[ Zulip.Event_type.Message ]
136136+ ()
137137+ in
138138+ Log.info (fun m -> m "Event queue registered: %s" (Zulip.Event_queue.id queue));
139139+ let rec event_loop last_event_id =
140140+ try
141141+ let events =
142142+ Zulip.Event_queue.get_events queue client ~last_event_id ()
143143+ in
144144+ if List.length events > 0 then
145145+ Log.info (fun m -> m "Received %d event(s)" (List.length events));
146146+ List.iter
147147+ (fun event ->
148148+ Log.debug (fun m ->
149149+ m "Event id=%d, type=%s" (Zulip.Event.id event)
150150+ (Zulip.Event_type.to_string (Zulip.Event.type_ event)));
151151+ process_event ~client ~storage ~identity ~handler event)
152152+ events;
153153+ let new_last_id =
154154+ List.fold_left
155155+ (fun max_id event -> max (Zulip.Event.id event) max_id)
156156+ last_event_id events
157157+ in
158158+ event_loop new_last_id
159159+ with Eio.Exn.Io (e, _) ->
160160+ Log.warn (fun m ->
161161+ m "Error getting events: %a (retrying in 2s)" Eio.Exn.pp_err e);
162162+ Eio.Time.sleep env#clock 2.0;
163163+ event_loop last_event_id
164164+ in
165165+ event_loop (-1)
166166+167167+let handle_webhook ~sw ~env ~config ~handler ~payload =
168168+ let client = create_client ~sw ~env ~config in
169169+ let identity = fetch_identity client in
170170+ let storage = Storage.create client in
171171+ match Jsont_bytesrw.decode_string Jsont.json payload with
172172+ | Error _ ->
173173+ Log.err (fun m -> m "Failed to parse webhook payload as JSON");
174174+ None
175175+ | Ok json -> (
176176+ match Message.of_json json with
177177+ | Error err ->
178178+ Log.err (fun m -> m "Failed to parse webhook message: %s" err);
179179+ None
180180+ | Ok message ->
181181+ let response = handler ~storage ~identity message in
182182+ Some response)
+131
lib/zulip_bot/bot.mli
···11+(** Fiber-based Zulip bot execution.
22+33+ A bot is simply a function that processes messages. The [run] function
44+ executes the bot as an Eio fiber, making it easy to compose multiple bots
55+ using standard Eio concurrency primitives.
66+77+ {b Example: Single bot}
88+ {[
99+ let echo_handler ~storage:_ ~identity:_ msg =
1010+ Response.reply ("Echo: " ^ Message.content msg)
1111+1212+ let () =
1313+ Eio_main.run @@ fun env ->
1414+ Eio.Switch.run @@ fun sw ->
1515+ let fs = Eio.Stdenv.fs env in
1616+ let config = Config.load ~fs "echo-bot" in
1717+ Bot.run ~sw ~env ~config ~handler:echo_handler
1818+ ]}
1919+2020+ {b Example: Multiple bots}
2121+ {[
2222+ let () =
2323+ Eio_main.run @@ fun env ->
2424+ Eio.Switch.run @@ fun sw ->
2525+ let fs = Eio.Stdenv.fs env in
2626+2727+ 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+*)
3737+3838+(** {1 Types} *)
3939+4040+type identity = {
4141+ user_id : int; (** Bot's user ID on the server *)
4242+ email : string; (** Bot's email address *)
4343+ full_name : string; (** Bot's display name *)
4444+}
4545+(** Bot identity information retrieved from Zulip. *)
4646+4747+type handler = storage:Storage.t -> identity:identity -> Message.t -> Response.t
4848+(** Handler function signature.
4949+5050+ A handler receives:
5151+ - [storage]: Key-value storage via Zulip's bot storage API
5252+ - [identity]: The bot's identity (email, name, user_id)
5353+ - The incoming [Message.t]
5454+5555+ And returns a [Response.t] indicating what action to take. *)
5656+5757+(** {1 Running Bots} *)
5858+5959+val run :
6060+ 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+ ; .. > ->
6565+ config:Config.t ->
6666+ handler:handler ->
6767+ unit
6868+(** [run ~sw ~env ~config ~handler] runs a bot as a fiber.
6969+7070+ The bot connects to Zulip's real-time events API and processes incoming
7171+ messages. It runs until the switch is cancelled.
7272+7373+ The bot will:
7474+ - Register for message events with Zulip
7575+ - Process private messages and messages that mention the bot
7676+ - Ignore messages from itself
7777+ - Send responses back via the Zulip API
7878+ - Automatically reconnect with exponential backoff on errors
7979+8080+ @param sw Eio switch controlling the bot's lifetime
8181+ @param env Eio environment with clock, net, and fs capabilities
8282+ @param config Bot configuration (credentials and metadata)
8383+ @param handler Function to process incoming messages *)
8484+8585+(** {1 Webhook Mode} *)
8686+8787+val handle_webhook :
8888+ 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+ ; .. > ->
9393+ config:Config.t ->
9494+ handler:handler ->
9595+ payload:string ->
9696+ Response.t option
9797+(** [handle_webhook ~sw ~env ~config ~handler ~payload] processes a single webhook payload.
9898+9999+ For webhook-based deployments, provide your own HTTP server and call this
100100+ function to process incoming webhook payloads from Zulip.
101101+102102+ Returns [Some response] if the message was processed, [None] if the payload
103103+ could not be parsed or should not be handled. *)
104104+105105+val send_response :
106106+ 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.
108108+109109+ 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
111111+ or private message recipients). *)
112112+113113+(** {1 Utilities} *)
114114+115115+val create_client :
116116+ 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+ ; .. > ->
121121+ config:Config.t ->
122122+ Zulip.Client.t
123123+(** [create_client ~sw ~env ~config] creates a Zulip client from bot configuration.
124124+125125+ Useful when you need direct access to the Zulip API beyond what the bot
126126+ framework provides. *)
127127+128128+val fetch_identity : Zulip.Client.t -> identity
129129+(** [fetch_identity client] retrieves the bot's identity from the Zulip server.
130130+131131+ @raise Eio.Io on API errors *)
-127
lib/zulip_bot/bot_config.ml
···11-type t = (string, string) Hashtbl.t
22-33-let create pairs =
44- let config = Hashtbl.create (List.length pairs) in
55- List.iter (fun (k, v) -> Hashtbl.replace config k v) pairs;
66- config
77-88-let from_file path =
99- try
1010- let content =
1111- let ic = open_in path in
1212- let content = really_input_string ic (in_channel_length ic) in
1313- close_in ic;
1414- content
1515- in
1616-1717- (* Simple INI-style parser for config files *)
1818- let lines = String.split_on_char '\n' content in
1919- let config = Hashtbl.create 16 in
2020- let current_section = ref "" in
2121-2222- List.iter
2323- (fun line ->
2424- let line = String.trim line in
2525- if String.length line > 0 && line.[0] <> '#' && line.[0] <> ';' then
2626- if
2727- String.length line > 2
2828- && line.[0] = '['
2929- && line.[String.length line - 1] = ']'
3030- then
3131- (* Section header *)
3232- current_section := String.sub line 1 (String.length line - 2)
3333- else
3434- (* Key-value pair *)
3535- match String.index_opt line '=' with
3636- | Some idx ->
3737- let key = String.trim (String.sub line 0 idx) in
3838- let value =
3939- String.trim
4040- (String.sub line (idx + 1) (String.length line - idx - 1))
4141- in
4242- (* Remove quotes if present *)
4343- let value =
4444- if
4545- String.length value >= 2
4646- && ((value.[0] = '"' && value.[String.length value - 1] = '"')
4747- || (value.[0] = '\''
4848- && value.[String.length value - 1] = '\''))
4949- then String.sub value 1 (String.length value - 2)
5050- else value
5151- in
5252- let full_key =
5353- if !current_section = "" then key
5454- else if
5555- !current_section = "bot" || !current_section = "features"
5656- then (* For bot and features sections, use flat keys *)
5757- key
5858- else !current_section ^ "." ^ key
5959- in
6060- Hashtbl.replace config full_key value
6161- | None -> ())
6262- lines;
6363-6464- config
6565- with
6666- | Eio.Exn.Io _ as ex -> raise ex
6767- | Sys_error msg ->
6868- let err =
6969- Zulip.create_error ~code:(Other "file_error")
7070- ~msg:("Cannot read config file: " ^ msg)
7171- ()
7272- in
7373- raise (Eio.Exn.add_context (Zulip.err err) "reading config from %s" path)
7474- | exn ->
7575- let err =
7676- Zulip.create_error ~code:(Other "parse_error")
7777- ~msg:("Error parsing config: " ^ Printexc.to_string exn)
7878- ()
7979- in
8080- raise (Eio.Exn.add_context (Zulip.err err) "parsing config from %s" path)
8181-8282-let from_env ~prefix =
8383- try
8484- let config = Hashtbl.create 16 in
8585- let env_vars = Array.to_list (Unix.environment ()) in
8686-8787- List.iter
8888- (fun env_var ->
8989- match String.split_on_char '=' env_var with
9090- | key :: value_parts
9191- when String.length key > String.length prefix
9292- && String.sub key 0 (String.length prefix) = prefix ->
9393- let config_key =
9494- String.sub key (String.length prefix)
9595- (String.length key - String.length prefix)
9696- in
9797- let value = String.concat "=" value_parts in
9898- Hashtbl.replace config config_key value
9999- | _ -> ())
100100- env_vars;
101101-102102- config
103103- with
104104- | Eio.Exn.Io _ as ex -> raise ex
105105- | exn ->
106106- let err =
107107- Zulip.create_error ~code:(Other "env_error")
108108- ~msg:("Error reading environment: " ^ Printexc.to_string exn)
109109- ()
110110- in
111111- raise (Eio.Exn.add_context (Zulip.err err) "reading env with prefix %s" prefix)
112112-113113-let get t ~key = Hashtbl.find_opt t key
114114-115115-let get_required t ~key =
116116- match Hashtbl.find_opt t key with
117117- | Some value -> value
118118- | None ->
119119- let err =
120120- Zulip.create_error ~code:(Other "config_missing")
121121- ~msg:("Required config key missing: " ^ key)
122122- ()
123123- in
124124- raise (Zulip.err err)
125125-126126-let has_key t ~key = Hashtbl.mem t key
127127-let keys t = Hashtbl.fold (fun k _ acc -> k :: acc) t []
-29
lib/zulip_bot/bot_config.mli
···11-(** Configuration management for bots.
22-33- All functions that can fail raise [Eio.Io] with [Zulip.E error]. *)
44-55-type t
66-77-val create : (string * string) list -> t
88-(** Create configuration from key-value pairs *)
99-1010-val from_file : string -> t
1111-(** Load configuration from file.
1212- @raise Eio.Io on file read or parse errors *)
1313-1414-val from_env : prefix:string -> t
1515-(** Load configuration from environment variables with prefix.
1616- @raise Eio.Io if no matching variables found *)
1717-1818-val get : t -> key:string -> string option
1919-(** Get a configuration value *)
2020-2121-val get_required : t -> key:string -> string
2222-(** Get a required configuration value.
2323- @raise Eio.Io if key not present *)
2424-2525-val has_key : t -> key:string -> bool
2626-(** Check if a key exists in configuration *)
2727-2828-val keys : t -> string list
2929-(** Get all configuration keys *)
-70
lib/zulip_bot/bot_handler.ml
···11-module Response = struct
22- type t =
33- | Reply of string
44- | DirectMessage of { to_ : string; content : string }
55- | ChannelMessage of { channel : string; topic : string; content : string }
66- | None
77-88- let none = None
99- let reply content = Reply content
1010- let direct_message ~to_ ~content = DirectMessage { to_; content }
1111-1212- let channel_message ~channel ~topic ~content =
1313- ChannelMessage { channel; topic; content }
1414-end
1515-1616-module Identity = struct
1717- type t = {
1818- full_name : string;
1919- email : string;
2020- mention_name : string;
2121- }
2222-2323- let create ~full_name ~email ~mention_name = { full_name; email; mention_name }
2424- let full_name t = t.full_name
2525- let email t = t.email
2626- let mention_name t = t.mention_name
2727-end
2828-2929-(** Module signature for bot implementations *)
3030-module type Bot_handler = sig
3131- val initialize : Bot_config.t -> unit
3232- val usage : unit -> string
3333- val description : unit -> string
3434-3535- val handle_message :
3636- config:Bot_config.t ->
3737- storage:Bot_storage.t ->
3838- identity:Identity.t ->
3939- message:Message.t ->
4040- env:_ ->
4141- Response.t
4242-end
4343-4444-module type S = Bot_handler
4545-4646-type t = {
4747- module_impl : (module Bot_handler);
4848- config : Bot_config.t;
4949- storage : Bot_storage.t;
5050- identity : Identity.t;
5151-}
5252-5353-let create module_impl ~config ~storage ~identity =
5454- { module_impl; config; storage; identity }
5555-5656-(* Main message handling function - requires environment for proper EIO operations *)
5757-let handle_message_with_env t env message =
5858- let module Handler = (val t.module_impl) in
5959- Handler.handle_message ~config:t.config ~storage:t.storage
6060- ~identity:t.identity ~message ~env
6161-6262-let identity t = t.identity
6363-6464-let usage t =
6565- let module Handler = (val t.module_impl) in
6666- Handler.usage ()
6767-6868-let description t =
6969- let module Handler = (val t.module_impl) in
7070- Handler.description ()
-77
lib/zulip_bot/bot_handler.mli
···11-(** Bot handler framework for Zulip bots.
22-33- Functions that can fail raise [Eio.Io] with [Zulip.E error]. *)
44-55-(** Response types that bots can return *)
66-module Response : sig
77- type t =
88- | Reply of string
99- | DirectMessage of { to_ : string; content : string }
1010- | ChannelMessage of { channel : string; topic : string; content : string }
1111- | None
1212-1313- val none : t
1414- val reply : string -> t
1515- val direct_message : to_:string -> content:string -> t
1616- val channel_message : channel:string -> topic:string -> content:string -> t
1717-end
1818-1919-(** Bot identity information *)
2020-module Identity : sig
2121- type t
2222-2323- val create : full_name:string -> email:string -> mention_name:string -> t
2424- val full_name : t -> string
2525- val email : t -> string
2626- val mention_name : t -> string
2727-end
2828-2929-(** Module signature for bot implementations *)
3030-module type Bot_handler = sig
3131- val initialize : Bot_config.t -> unit
3232- (** Initialize the bot (called once on startup).
3333- @raise Eio.Io on failure *)
3434-3535- val usage : unit -> string
3636- (** Provide usage/help text *)
3737-3838- val description : unit -> string
3939- (** Provide bot description *)
4040-4141- val handle_message :
4242- config:Bot_config.t ->
4343- storage:Bot_storage.t ->
4444- identity:Identity.t ->
4545- message:Message.t ->
4646- env:_ ->
4747- Response.t
4848- (** Handle an incoming message with EIO environment.
4949- @raise Eio.Io on failure *)
5050-end
5151-5252-(** Shorter alias for Bot_handler *)
5353-module type S = Bot_handler
5454-5555-(** Abstract bot handler *)
5656-type t
5757-5858-val create :
5959- (module Bot_handler) ->
6060- config:Bot_config.t ->
6161- storage:Bot_storage.t ->
6262- identity:Identity.t ->
6363- t
6464-(** Create a bot handler from a module *)
6565-6666-val handle_message_with_env : t -> _ -> Message.t -> Response.t
6767-(** Process an incoming message with EIO environment.
6868- @raise Eio.Io on failure *)
6969-7070-val identity : t -> Identity.t
7171-(** Get bot identity *)
7272-7373-val usage : t -> string
7474-(** Get bot usage text *)
7575-7676-val description : t -> string
7777-(** Get bot description *)
-280
lib/zulip_bot/bot_runner.ml
···11-(* Logging setup *)
22-let src = Logs.Src.create "zulip_bot.runner" ~doc:"Zulip bot runner"
33-44-module Log = (val Logs.src_log src : Logs.LOG)
55-66-(* Initialize crypto RNG - now done at module load time via Mirage_crypto_rng_unix *)
77-let () =
88- try
99- let _ =
1010- Mirage_crypto_rng.generate ~g:(Mirage_crypto_rng.default_generator ()) 0
1111- in
1212- ()
1313- with _ ->
1414- (* Generator not initialized - this will be done by applications using the library *)
1515- ()
1616-1717-type 'env t = {
1818- client : Zulip.Client.t;
1919- handler : Bot_handler.t;
2020- mutable running : bool;
2121- storage : Bot_storage.t;
2222- env : 'env;
2323-}
2424-2525-let create ~env ~client ~handler =
2626- let bot_email =
2727- (* Get bot email from handler identity *)
2828- Bot_handler.Identity.email (Bot_handler.identity handler)
2929- in
3030- Log.info (fun m -> m "Creating bot runner for %s" bot_email);
3131- let storage = Bot_storage.create client ~bot_email in
3232- { client; handler; running = false; storage; env }
3333-3434-(* Helper to extract clock from environment *)
3535-(* The environment should have a #clock method *)
3636-let get_clock (env : < clock : float Eio.Time.clock_ty Eio.Resource.t ; .. >) =
3737- env#clock
3838-3939-let process_event t event =
4040- (* Check if this is a message event *)
4141- Log.debug (fun m ->
4242- m "Processing event type: %s"
4343- (Zulip.Event_type.to_string (Zulip.Event.type_ event)));
4444- match Zulip.Event.type_ event with
4545- | Zulip.Event_type.Message -> (
4646- (* Get the message data from the event *)
4747- let event_data = Zulip.Event.data event in
4848-4949- (* Extract the actual message from the event *)
5050- let message_json, flags =
5151- match event_data with
5252- | Jsont.Object (fields, _) ->
5353- let assoc = List.map (fun ((k, _), v) -> (k, v)) fields in
5454- let msg =
5555- match List.assoc_opt "message" assoc with
5656- | Some m -> m
5757- | None -> event_data (* Fallback if structure is different *)
5858- in
5959- let flgs =
6060- match List.assoc_opt "flags" assoc with
6161- | Some (Jsont.Array (f, _)) -> f
6262- | _ -> []
6363- in
6464- (msg, flgs)
6565- | _ -> (event_data, [])
6666- in
6767-6868- (* Parse the message JSON into Message.t *)
6969- match Message.of_json message_json with
7070- | Error err ->
7171- Log.err (fun m -> m "Failed to parse message JSON: %s" err);
7272- (* Show raw JSON for debugging *)
7373- Log.debug (fun m -> m "@[%a@]" Message.pp_json_debug message_json)
7474- | Ok message -> (
7575- (* Log the parsed message with colors *)
7676- Log.info (fun m ->
7777- m "@[<h>%a@]" (Message.pp_ansi ~show_json:false) message);
7878-7979- (* Get bot identity for checking mentions *)
8080- let bot_email =
8181- Bot_handler.Identity.email (Bot_handler.identity t.handler)
8282- in
8383-8484- (* Check if mentioned *)
8585- let is_mentioned =
8686- List.exists
8787- (function Jsont.String ("mentioned", _) -> true | _ -> false)
8888- flags
8989- || Message.is_mentioned message ~user_email:bot_email
9090- in
9191-9292- (* Check if it's a private message *)
9393- let is_private = Message.is_private message in
9494-9595- (* Don't respond to our own messages *)
9696- let is_from_self = Message.is_from_email message ~email:bot_email in
9797-9898- (* Log what we found *)
9999- Log.debug (fun m ->
100100- m "Message check: mentioned=%b, private=%b, from_self=%b"
101101- is_mentioned is_private is_from_self);
102102-103103- (* Only process if bot was mentioned or it's a private message, and not from self *)
104104- if (is_mentioned || is_private) && not is_from_self then (
105105- Log.info (fun m -> m "Bot should respond to this message");
106106-107107- (* Handle the message using exception-based handling *)
108108- try
109109- let response =
110110- Bot_handler.handle_message_with_env t.handler t.env message
111111- in
112112- match response with
113113- | Bot_handler.Response.Reply content ->
114114- Log.debug (fun m -> m "Bot is sending reply: %s" content);
115115- (* Send reply back using Message utilities *)
116116- let message_to_send =
117117- if Message.is_private message then (
118118- (* Reply to private message *)
119119- let sender = Message.sender_email message in
120120- Log.debug (fun m -> m "Replying to sender: %s" sender);
121121- Zulip.Message.create ~type_:`Direct ~to_:[ sender ]
122122- ~content ())
123123- else
124124- (* Reply to stream message *)
125125- let reply_to = Message.get_reply_to message in
126126- let topic =
127127- match message with
128128- | Message.Stream { subject; _ } -> Some subject
129129- | _ -> None
130130- in
131131- Zulip.Message.create ~type_:`Channel ~to_:[ reply_to ]
132132- ~content ?topic ()
133133- in
134134- (try
135135- let resp = Zulip.Messages.send t.client message_to_send in
136136- Log.info (fun m ->
137137- m "Reply sent successfully (id: %d)"
138138- (Zulip.Message_response.id resp))
139139- with Eio.Exn.Io (e, _) ->
140140- Log.err (fun m ->
141141- m "Error sending reply: %a" Eio.Exn.pp_err e))
142142- | Bot_handler.Response.DirectMessage { to_; content } ->
143143- Log.debug (fun m ->
144144- m "Bot is sending direct message to: %s" to_);
145145- let message_to_send =
146146- Zulip.Message.create ~type_:`Direct ~to_:[ to_ ] ~content ()
147147- in
148148- (try
149149- let resp = Zulip.Messages.send t.client message_to_send in
150150- Log.info (fun m ->
151151- m "Direct message sent successfully (id: %d)"
152152- (Zulip.Message_response.id resp))
153153- with Eio.Exn.Io (e, _) ->
154154- Log.err (fun m ->
155155- m "Error sending direct message: %a" Eio.Exn.pp_err e))
156156- | Bot_handler.Response.ChannelMessage { channel; topic; content }
157157- ->
158158- Log.debug (fun m ->
159159- m "Bot is sending channel message to #%s - %s" channel
160160- topic);
161161- let message_to_send =
162162- Zulip.Message.create ~type_:`Channel ~to_:[ channel ] ~topic
163163- ~content ()
164164- in
165165- (try
166166- let resp = Zulip.Messages.send t.client message_to_send in
167167- Log.info (fun m ->
168168- m "Channel message sent successfully (id: %d)"
169169- (Zulip.Message_response.id resp))
170170- with Eio.Exn.Io (e, _) ->
171171- Log.err (fun m ->
172172- m "Error sending channel message: %a" Eio.Exn.pp_err e))
173173- | Bot_handler.Response.None ->
174174- Log.info (fun m -> m "Bot handler returned no response")
175175- with Eio.Exn.Io (e, _) ->
176176- Log.err (fun m -> m "Error handling message: %a" Eio.Exn.pp_err e))
177177- else Log.info (fun m ->
178178- m "Not processing message (not mentioned and not private)")))
179179- | _ -> () (* Ignore non-message events for now *)
180180-181181-let run_realtime t =
182182- t.running <- true;
183183- Log.info (fun m -> m "Starting bot in real-time mode");
184184-185185- (* Get clock from environment *)
186186- let clock = get_clock t.env in
187187-188188- (* Register for message events *)
189189- try
190190- let queue =
191191- Zulip.Event_queue.register t.client
192192- ~event_types:[ Zulip.Event_type.Message ]
193193- ()
194194- in
195195- Log.info (fun m ->
196196- m "Event queue registered: %s" (Zulip.Event_queue.id queue));
197197-198198- (* Main event loop *)
199199- let rec event_loop last_event_id =
200200- if not t.running then (
201201- Log.info (fun m -> m "Bot stopping");
202202- (* Clean up event queue *)
203203- try
204204- Zulip.Event_queue.delete queue t.client;
205205- Log.info (fun m -> m "Event queue deleted")
206206- with Eio.Exn.Io (e, _) ->
207207- Log.err (fun m -> m "Error deleting queue: %a" Eio.Exn.pp_err e))
208208- else
209209- (* Get events from Zulip *)
210210- try
211211- let events =
212212- Zulip.Event_queue.get_events queue t.client ~last_event_id ()
213213- in
214214- if List.length events > 0 then begin
215215- Log.info (fun m -> m "Received %d event(s)" (List.length events));
216216- List.iter
217217- (fun event ->
218218- Log.info (fun m ->
219219- m "Event id=%d, type=%s" (Zulip.Event.id event)
220220- (Zulip.Event_type.to_string (Zulip.Event.type_ event))))
221221- events
222222- end;
223223-224224- (* Process each event *)
225225- List.iter (process_event t) events;
226226-227227- (* Get the highest event ID for next poll *)
228228- let new_last_id =
229229- List.fold_left
230230- (fun max_id event -> max (Zulip.Event.id event) max_id)
231231- last_event_id events
232232- in
233233-234234- (* Continue polling *)
235235- event_loop new_last_id
236236- with Eio.Exn.Io (e, _) ->
237237- (* Handle errors with exponential backoff *)
238238- Log.warn (fun m ->
239239- m "Error getting events: %a (retrying in 2s)" Eio.Exn.pp_err e);
240240-241241- (* Sleep using EIO clock *)
242242- Eio.Time.sleep clock 2.0;
243243-244244- (* For now, treat all errors as recoverable *)
245245- event_loop last_event_id
246246- in
247247-248248- (* Start with last_event_id = -1 to get all events *)
249249- event_loop (-1)
250250- with Eio.Exn.Io (e, _) ->
251251- Log.err (fun m -> m "Failed to register event queue: %a" Eio.Exn.pp_err e);
252252- t.running <- false
253253-254254-let run_webhook t =
255255- t.running <- true;
256256- Log.info (fun m -> m "Bot started in webhook mode");
257257- (* Webhook mode would wait for HTTP callbacks *)
258258- (* Not implemented yet - would need HTTP server *)
259259- ()
260260-261261-let handle_webhook t ~webhook_data =
262262- (* Process webhook data and route to handler *)
263263- (* Parse the webhook data into Message.t first *)
264264- match Message.of_json webhook_data with
265265- | Error err ->
266266- let e =
267267- Zulip.create_error ~code:(Zulip.Other "parse_error")
268268- ~msg:("Failed to parse webhook message: " ^ err)
269269- ()
270270- in
271271- raise (Zulip.err e)
272272- | Ok message ->
273273- let response =
274274- Bot_handler.handle_message_with_env t.handler t.env message
275275- in
276276- Some response
277277-278278-let shutdown t =
279279- t.running <- false;
280280- Log.info (fun m -> m "Bot shutting down")
-24
lib/zulip_bot/bot_runner.mli
···11-(** Bot execution and lifecycle management.
22-33- Functions that can fail raise [Eio.Io] with [Zulip.E error]. *)
44-55-type 'env t
66-77-val create : env:'env -> client:Zulip.Client.t -> handler:Bot_handler.t -> 'env t
88-(** Create a bot runner *)
99-1010-val run_realtime :
1111- < clock : float Eio.Time.clock_ty Eio.Resource.t ; .. > t -> unit
1212-(** Run the bot in real-time mode (using Zulip events API).
1313- @raise Eio.Io on failure *)
1414-1515-val run_webhook : 'env t -> unit
1616-(** Run the bot in webhook mode (for use with bot server) *)
1717-1818-val handle_webhook :
1919- 'env t -> webhook_data:Zulip.json -> Bot_handler.Response.t option
2020-(** Process a single webhook event.
2121- @raise Eio.Io on failure *)
2222-2323-val shutdown : 'env t -> unit
2424-(** Gracefully shutdown the bot *)
-205
lib/zulip_bot/bot_storage.ml
···11-(* Logging setup *)
22-let src = Logs.Src.create "zulip_bot.storage" ~doc:"Zulip bot storage"
33-44-module Log = (val Logs.src_log src : Logs.LOG)
55-66-type t = {
77- client : Zulip.Client.t;
88- bot_email : string;
99- cache : (string, string) Hashtbl.t;
1010- mutable dirty_keys : string list;
1111-}
1212-1313-(** {1 JSON Codecs for Bot Storage} *)
1414-1515-(* String map for storage values *)
1616-module String_map = Map.Make (String)
1717-1818-(* Storage response type - {"storage": {...}} *)
1919-type storage_response = {
2020- storage : string String_map.t;
2121- unknown : Jsont.json;
2222-}
2323-2424-(* Codec for storage response using Jsont.Object with keep_unknown *)
2525-let storage_response_jsont : storage_response Jsont.t =
2626- let make storage unknown = { storage; unknown } in
2727- let storage_map_jsont =
2828- Jsont.Object.map ~kind:"StorageMap" Fun.id
2929- |> Jsont.Object.keep_unknown
3030- (Jsont.Object.Mems.string_map Jsont.string)
3131- ~enc:Fun.id
3232- |> Jsont.Object.finish
3333- in
3434- Jsont.Object.map ~kind:"StorageResponse" make
3535- |> Jsont.Object.mem "storage" storage_map_jsont ~enc:(fun r -> r.storage)
3636- ~dec_absent:String_map.empty
3737- |> Jsont.Object.keep_unknown Jsont.json_mems ~enc:(fun r -> r.unknown)
3838- |> Jsont.Object.finish
3939-4040-let create client ~bot_email =
4141- Log.info (fun m -> m "Creating bot storage for %s" bot_email);
4242- let cache = Hashtbl.create 16 in
4343-4444- (* Fetch all existing storage from server to populate cache *)
4545- (try
4646- let json =
4747- Zulip.Client.request client ~method_:`GET ~path:"/api/v1/bot_storage" ()
4848- in
4949- match Zulip.Encode.from_json storage_response_jsont json with
5050- | Ok response ->
5151- String_map.iter
5252- (fun k v ->
5353- Log.debug (fun m -> m "Loaded key from server: %s" k);
5454- Hashtbl.add cache k v)
5555- response.storage
5656- | Error msg ->
5757- Log.warn (fun m -> m "Failed to parse storage response: %s" msg)
5858- with Eio.Exn.Io (e, _) ->
5959- Log.warn (fun m ->
6060- m "Failed to load existing storage: %a" Eio.Exn.pp_err e));
6161-6262- { client; bot_email; cache; dirty_keys = [] }
6363-6464-(* Helper to encode storage data as form-encoded body for the API *)
6565-let encode_storage_update keys_values =
6666- (* Build the storage object as JSON - the API expects storage={"key": "value"} *)
6767- let storage_obj =
6868- List.map
6969- (fun (k, v) ->
7070- ((k, Jsont.Meta.none), Jsont.String (v, Jsont.Meta.none)))
7171- keys_values
7272- in
7373- let json_obj = Jsont.Object (storage_obj, Jsont.Meta.none) in
7474-7575- (* Convert to JSON string using Jsont_bytesrw *)
7676- let json_str =
7777- Jsont_bytesrw.encode_string' Jsont.json json_obj |> Result.get_ok
7878- in
7979-8080- (* Return as form-encoded body: storage=<url-encoded-json> *)
8181- "storage=" ^ Uri.pct_encode json_str
8282-8383-let get t ~key =
8484- Log.debug (fun m -> m "Getting value for key: %s" key);
8585- (* First check cache *)
8686- match Hashtbl.find_opt t.cache key with
8787- | Some value ->
8888- Log.debug (fun m -> m "Found key in cache: %s" key);
8989- Some value
9090- | None -> (
9191- (* Fetch from Zulip API - keys parameter should be a JSON array *)
9292- let params = [ ("keys", "[\"" ^ key ^ "\"]") ] in
9393- try
9494- let json =
9595- Zulip.Client.request t.client ~method_:`GET ~path:"/api/v1/bot_storage"
9696- ~params ()
9797- in
9898- match Zulip.Encode.from_json storage_response_jsont json with
9999- | Ok response -> (
100100- match String_map.find_opt key response.storage with
101101- | Some value ->
102102- (* Cache the value *)
103103- Log.debug (fun m -> m "Retrieved key from API: %s" key);
104104- Hashtbl.add t.cache key value;
105105- Some value
106106- | None ->
107107- Log.debug (fun m -> m "Key not found in API: %s" key);
108108- None)
109109- | Error msg ->
110110- Log.warn (fun m -> m "Failed to parse storage response: %s" msg);
111111- None
112112- with Eio.Exn.Io (e, _) ->
113113- Log.warn (fun m ->
114114- m "Error fetching key %s: %a" key Eio.Exn.pp_err e);
115115- None)
116116-117117-let put t ~key ~value =
118118- Log.debug (fun m -> m "Storing key: %s with value: %s" key value);
119119- (* Update cache *)
120120- Hashtbl.replace t.cache key value;
121121-122122- (* Mark as dirty if not already *)
123123- if not (List.mem key t.dirty_keys) then t.dirty_keys <- key :: t.dirty_keys;
124124-125125- (* Use the helper to properly encode as form data *)
126126- let body = encode_storage_update [ (key, value) ] in
127127-128128- Log.debug (fun m -> m "Sending storage update with body: %s" body);
129129-130130- let _response =
131131- Zulip.Client.request t.client ~method_:`PUT ~path:"/api/v1/bot_storage"
132132- ~body ()
133133- in
134134- (* Remove from dirty list on success *)
135135- Log.debug (fun m -> m "Successfully stored key: %s" key);
136136- t.dirty_keys <- List.filter (( <> ) key) t.dirty_keys
137137-138138-let contains t ~key =
139139- (* Check cache first *)
140140- if Hashtbl.mem t.cache key then true
141141- else
142142- (* Check API *)
143143- match get t ~key with Some _ -> true | None -> false
144144-145145-let remove t ~key =
146146- Log.debug (fun m -> m "Removing key: %s" key);
147147- (* Remove from cache *)
148148- Hashtbl.remove t.cache key;
149149-150150- (* Remove from dirty list *)
151151- t.dirty_keys <- List.filter (( <> ) key) t.dirty_keys;
152152-153153- (* Delete from Zulip API by setting to empty *)
154154- (* Note: Zulip API doesn't have a delete endpoint, so we set to empty string *)
155155- put t ~key ~value:""
156156-157157-let keys t =
158158- (* Fetch all storage from API to get complete key list *)
159159- let json =
160160- Zulip.Client.request t.client ~method_:`GET ~path:"/api/v1/bot_storage" ()
161161- in
162162- match Zulip.Encode.from_json storage_response_jsont json with
163163- | Ok response ->
164164- let api_keys =
165165- String_map.fold (fun k _ acc -> k :: acc) response.storage []
166166- in
167167- (* Merge with cache keys *)
168168- let cache_keys =
169169- Hashtbl.fold (fun k _ acc -> k :: acc) t.cache []
170170- in
171171- List.sort_uniq String.compare (api_keys @ cache_keys)
172172- | Error msg ->
173173- Log.warn (fun m -> m "Failed to parse storage response: %s" msg);
174174- []
175175-176176-(* Flush all dirty keys to API *)
177177-let flush t =
178178- if t.dirty_keys = [] then ()
179179- else begin
180180- Log.info (fun m ->
181181- m "Flushing %d dirty keys to API" (List.length t.dirty_keys));
182182- let updates =
183183- List.fold_left
184184- (fun acc key ->
185185- match Hashtbl.find_opt t.cache key with
186186- | Some value -> (key, value) :: acc
187187- | None -> acc)
188188- [] t.dirty_keys
189189- in
190190-191191- if updates = [] then ()
192192- else
193193- (* Use the helper to properly encode all updates as form data *)
194194- let body = encode_storage_update updates in
195195-196196- let _response =
197197- Zulip.Client.request t.client ~method_:`PUT ~path:"/api/v1/bot_storage"
198198- ~body ()
199199- in
200200- Log.info (fun m -> m "Successfully flushed storage to API");
201201- t.dirty_keys <- []
202202- end
203203-204204-(* Get the underlying client *)
205205-let client t = t.client
-33
lib/zulip_bot/bot_storage.mli
···11-(** Persistent storage interface for bots.
22-33- All mutation functions raise [Eio.Io] with [Zulip.E error] on failure. *)
44-55-type t
66-77-val create : Zulip.Client.t -> bot_email:string -> t
88-(** Create a new storage instance for a bot *)
99-1010-val get : t -> key:string -> string option
1111-(** Get a value from storage *)
1212-1313-val put : t -> key:string -> value:string -> unit
1414-(** Store a value in storage.
1515- @raise Eio.Io on failure *)
1616-1717-val contains : t -> key:string -> bool
1818-(** Check if a key exists in storage *)
1919-2020-val remove : t -> key:string -> unit
2121-(** Remove a key from storage.
2222- @raise Eio.Io on failure *)
2323-2424-val keys : t -> string list
2525-(** List all keys in storage.
2626- @raise Eio.Io on failure *)
2727-2828-val flush : t -> unit
2929-(** Flush all dirty keys to the API.
3030- @raise Eio.Io on failure *)
3131-3232-val client : t -> Zulip.Client.t
3333-(** Get the underlying Zulip client *)
+108
lib/zulip_bot/config.ml
···11+let src = Logs.Src.create "zulip_bot.config" ~doc:"Zulip bot configuration"
22+33+module Log = (val Logs.src_log src : Logs.LOG)
44+55+type t = {
66+ name : string;
77+ site : string;
88+ email : string;
99+ api_key : string;
1010+ description : string option;
1111+ usage : string option;
1212+}
1313+1414+let create ~name ~site ~email ~api_key ?description ?usage () =
1515+ { name; site; email; api_key; description; usage }
1616+1717+(** Convert bot name to environment variable prefix.
1818+ "my-bot" -> "ZULIP_MY_BOT" *)
1919+let env_prefix name =
2020+ let upper = String.uppercase_ascii name in
2121+ let replaced = String.map (fun c -> if c = '-' then '_' else c) upper in
2222+ "ZULIP_" ^ replaced ^ "_"
2323+2424+(** Parse INI-style config file content into key-value pairs *)
2525+let parse_ini content =
2626+ let lines = String.split_on_char '\n' content in
2727+ let config = Hashtbl.create 16 in
2828+ List.iter
2929+ (fun line ->
3030+ let line = String.trim line in
3131+ if String.length line > 0 && line.[0] <> '#' && line.[0] <> ';' then
3232+ if not (line.[0] = '[') then
3333+ match String.index_opt line '=' with
3434+ | Some idx ->
3535+ let key = String.trim (String.sub line 0 idx) in
3636+ let value =
3737+ String.trim
3838+ (String.sub line (idx + 1) (String.length line - idx - 1))
3939+ in
4040+ (* Remove quotes if present *)
4141+ let value =
4242+ if
4343+ String.length value >= 2
4444+ && ((value.[0] = '"' && value.[String.length value - 1] = '"')
4545+ || (value.[0] = '\''
4646+ && value.[String.length value - 1] = '\''))
4747+ then String.sub value 1 (String.length value - 2)
4848+ else value
4949+ in
5050+ Hashtbl.replace config key value
5151+ | None -> ())
5252+ lines;
5353+ config
5454+5555+let load ~fs name =
5656+ Log.info (fun m -> m "Loading config for bot: %s" name);
5757+ let xdg = Xdge.create fs ("zulip-bot/" ^ name) in
5858+ let config_file = Eio.Path.(Xdge.config_dir xdg / "config") in
5959+ Log.debug (fun m -> m "Looking for config at: %a" Eio.Path.pp config_file);
6060+ let content = Eio.Path.load config_file in
6161+ let kv = parse_ini content in
6262+ let get key = Hashtbl.find_opt kv key in
6363+ let get_required key =
6464+ match get key with
6565+ | Some v -> v
6666+ | None -> failwith (Printf.sprintf "Missing required config key: %s" key)
6767+ in
6868+ {
6969+ name;
7070+ site = get_required "site";
7171+ email = get_required "email";
7272+ api_key = get_required "api_key";
7373+ description = get "description";
7474+ usage = get "usage";
7575+ }
7676+7777+let from_env name =
7878+ Log.info (fun m -> m "Loading config for bot %s from environment" name);
7979+ let prefix = env_prefix name in
8080+ let get_env key = Sys.getenv_opt (prefix ^ key) in
8181+ let get_required key =
8282+ match get_env key with
8383+ | Some v -> v
8484+ | None ->
8585+ failwith
8686+ (Printf.sprintf "Missing required environment variable: %s%s" prefix
8787+ key)
8888+ in
8989+ {
9090+ name;
9191+ site = get_required "SITE";
9292+ email = get_required "EMAIL";
9393+ api_key = get_required "API_KEY";
9494+ description = get_env "DESCRIPTION";
9595+ usage = get_env "USAGE";
9696+ }
9797+9898+let load_or_env ~fs name =
9999+ try load ~fs name
100100+ with _ ->
101101+ Log.debug (fun m ->
102102+ m "Config file not found, falling back to environment variables");
103103+ from_env name
104104+105105+let xdg ~fs config = Xdge.create fs ("zulip-bot/" ^ config.name)
106106+let data_dir ~fs config = Xdge.data_dir (xdg ~fs config)
107107+let state_dir ~fs config = Xdge.state_dir (xdg ~fs config)
108108+let cache_dir ~fs config = Xdge.cache_dir (xdg ~fs config)
+103
lib/zulip_bot/config.mli
···11+(** Bot configuration with XDG Base Directory support.
22+33+ 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:
66+77+ {v
88+ [bot]
99+ site = https://chat.zulip.org
1010+ email = my-bot@chat.zulip.org
1111+ api_key = your_api_key_here
1212+1313+ # Optional fields
1414+ description = A helpful bot
1515+ usage = @bot help
1616+ v}
1717+1818+ Configuration files are searched in XDG config directories:
1919+ - [$XDG_CONFIG_HOME/zulip-bot/<name>/config] (typically [~/.config/zulip-bot/<name>/config])
2020+ - System directories as fallback
2121+2222+ Environment variables can override file configuration:
2323+ - [ZULIP_<NAME>_SITE], [ZULIP_<NAME>_EMAIL], [ZULIP_<NAME>_API_KEY]
2424+2525+ Where [<NAME>] is the uppercase version of the bot name with hyphens replaced by underscores. *)
2626+2727+type t = {
2828+ name : string; (** Bot name (used for XDG paths and identification) *)
2929+ site : string; (** Zulip server URL *)
3030+ email : string; (** Bot email address *)
3131+ api_key : string; (** Bot API key *)
3232+ description : string option; (** Optional bot description *)
3333+ usage : string option; (** Optional usage help text *)
3434+}
3535+(** Bot configuration record. *)
3636+3737+val create :
3838+ name:string ->
3939+ site:string ->
4040+ email:string ->
4141+ api_key:string ->
4242+ ?description:string ->
4343+ ?usage:string ->
4444+ unit ->
4545+ t
4646+(** [create ~name ~site ~email ~api_key ?description ?usage ()] creates a
4747+ configuration programmatically. *)
4848+4949+val 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.
5151+5252+ Searches for configuration in:
5353+ - [$XDG_CONFIG_HOME/zulip-bot/<name>/config]
5454+ - System config directories as fallback
5555+5656+ @param fs The Eio filesystem
5757+ @param name The bot name
5858+ @raise Eio.Io if configuration file cannot be read or parsed
5959+ @raise Failure if required fields are missing *)
6060+6161+val from_env : string -> t
6262+(** [from_env name] loads configuration from environment variables.
6363+6464+ Reads the following environment variables (where [NAME] is the uppercase
6565+ bot name with hyphens replaced by underscores):
6666+ - [ZULIP_<NAME>_SITE] (required)
6767+ - [ZULIP_<NAME>_EMAIL] (required)
6868+ - [ZULIP_<NAME>_API_KEY] (required)
6969+ - [ZULIP_<NAME>_DESCRIPTION] (optional)
7070+ - [ZULIP_<NAME>_USAGE] (optional)
7171+7272+ @raise Failure if required environment variables are not set *)
7373+7474+val 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.
7676+7777+ Attempts to load from the XDG config file first. If that fails (file not
7878+ found or unreadable), falls back to environment variables.
7979+8080+ @raise Failure if neither source provides valid configuration *)
8181+8282+val xdg : fs:Eio.Fs.dir_ty Eio.Path.t -> t -> Xdge.t
8383+(** [xdg ~fs config] returns the XDG context for this bot.
8484+8585+ Useful for accessing data and state directories for the bot:
8686+ - [Xdge.data_dir (xdg ~fs config)] for persistent data
8787+ - [Xdge.state_dir (xdg ~fs config)] for runtime state
8888+ - [Xdge.cache_dir (xdg ~fs config)] for cached data *)
8989+9090+val data_dir : fs:Eio.Fs.dir_ty Eio.Path.t -> t -> Eio.Fs.dir_ty Eio.Path.t
9191+(** [data_dir ~fs config] returns the XDG data directory for this bot.
9292+9393+ Returns [$XDG_DATA_HOME/zulip-bot/<name>], creating it if necessary. *)
9494+9595+val state_dir : fs:Eio.Fs.dir_ty Eio.Path.t -> t -> Eio.Fs.dir_ty Eio.Path.t
9696+(** [state_dir ~fs config] returns the XDG state directory for this bot.
9797+9898+ Returns [$XDG_STATE_HOME/zulip-bot/<name>], creating it if necessary. *)
9999+100100+val cache_dir : fs:Eio.Fs.dir_ty Eio.Path.t -> t -> Eio.Fs.dir_ty Eio.Path.t
101101+(** [cache_dir ~fs config] returns the XDG cache directory for this bot.
102102+103103+ Returns [$XDG_CACHE_HOME/zulip-bot/<name>], creating it if necessary. *)
···11+(** Response types that bot handlers can return.
22+33+ 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. *)
66+77+type t =
88+ | 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. *)
1212+ | Direct of { recipients : string list; content : string }
1313+ (** Send a direct (private) message to specific users.
1414+ Recipients are specified by email address. *)
1515+ | Stream of { stream : string; topic : string; content : string }
1616+ (** Send a message to a stream with a specific topic. *)
1717+ | Silent
1818+ (** No response - the bot acknowledges but does not reply. *)
1919+2020+(** {1 Constructors} *)
2121+2222+val reply : string -> t
2323+(** [reply content] creates a reply response. *)
2424+2525+val direct : recipients:string list -> content:string -> t
2626+(** [direct ~recipients ~content] creates a direct message response. *)
2727+2828+val stream : stream:string -> topic:string -> content:string -> t
2929+(** [stream ~stream ~topic ~content] creates a stream message response. *)
3030+3131+val silent : t
3232+(** [silent] is a response that produces no output. *)
+129
lib/zulip_bot/storage.ml
···11+let src = Logs.Src.create "zulip_bot.storage" ~doc:"Zulip bot storage"
22+33+module Log = (val Logs.src_log src : Logs.LOG)
44+module String_map = Map.Make (String)
55+66+type t = {
77+ client : Zulip.Client.t;
88+ cache : (string, string) Hashtbl.t;
99+}
1010+1111+(** Storage response type - {"storage": {...}} *)
1212+type storage_response = { storage : string String_map.t; unknown : Jsont.json }
1313+1414+let storage_response_jsont : storage_response Jsont.t =
1515+ let make storage unknown = { storage; unknown } in
1616+ let storage_map_jsont =
1717+ Jsont.Object.map ~kind:"StorageMap" Fun.id
1818+ |> Jsont.Object.keep_unknown
1919+ (Jsont.Object.Mems.string_map Jsont.string)
2020+ ~enc:Fun.id
2121+ |> Jsont.Object.finish
2222+ in
2323+ Jsont.Object.map ~kind:"StorageResponse" make
2424+ |> Jsont.Object.mem "storage" storage_map_jsont ~enc:(fun r -> r.storage)
2525+ ~dec_absent:String_map.empty
2626+ |> Jsont.Object.keep_unknown Jsont.json_mems ~enc:(fun r -> r.unknown)
2727+ |> Jsont.Object.finish
2828+2929+let create client =
3030+ Log.info (fun m -> m "Creating bot storage");
3131+ let cache = Hashtbl.create 16 in
3232+ (* Fetch all existing storage from server to populate cache *)
3333+ (try
3434+ let json =
3535+ Zulip.Client.request client ~method_:`GET ~path:"/api/v1/bot_storage" ()
3636+ in
3737+ match Zulip.Encode.from_json storage_response_jsont json with
3838+ | Ok response ->
3939+ String_map.iter
4040+ (fun k v ->
4141+ Log.debug (fun m -> m "Loaded key from server: %s" k);
4242+ Hashtbl.add cache k v)
4343+ response.storage
4444+ | Error msg ->
4545+ Log.warn (fun m -> m "Failed to parse storage response: %s" msg)
4646+ with Eio.Exn.Io (e, _) ->
4747+ Log.warn (fun m ->
4848+ m "Failed to load existing storage: %a" Eio.Exn.pp_err e));
4949+ { client; cache }
5050+5151+let encode_storage_update keys_values =
5252+ let storage_obj =
5353+ List.map
5454+ (fun (k, v) -> ((k, Jsont.Meta.none), Jsont.String (v, Jsont.Meta.none)))
5555+ keys_values
5656+ in
5757+ let json_obj = Jsont.Object (storage_obj, Jsont.Meta.none) in
5858+ let json_str =
5959+ Jsont_bytesrw.encode_string' Jsont.json json_obj |> Result.get_ok
6060+ in
6161+ "storage=" ^ Uri.pct_encode json_str
6262+6363+let get t key =
6464+ Log.debug (fun m -> m "Getting value for key: %s" key);
6565+ match Hashtbl.find_opt t.cache key with
6666+ | Some value ->
6767+ Log.debug (fun m -> m "Found key in cache: %s" key);
6868+ Some value
6969+ | None -> (
7070+ let params = [ ("keys", "[\"" ^ key ^ "\"]") ] in
7171+ try
7272+ let json =
7373+ Zulip.Client.request t.client ~method_:`GET ~path:"/api/v1/bot_storage"
7474+ ~params ()
7575+ in
7676+ match Zulip.Encode.from_json storage_response_jsont json with
7777+ | Ok response -> (
7878+ match String_map.find_opt key response.storage with
7979+ | Some value ->
8080+ Log.debug (fun m -> m "Retrieved key from API: %s" key);
8181+ Hashtbl.add t.cache key value;
8282+ Some value
8383+ | None ->
8484+ Log.debug (fun m -> m "Key not found in API: %s" key);
8585+ None)
8686+ | Error msg ->
8787+ Log.warn (fun m -> m "Failed to parse storage response: %s" msg);
8888+ None
8989+ with Eio.Exn.Io (e, _) ->
9090+ Log.warn (fun m -> m "Error fetching key %s: %a" key Eio.Exn.pp_err e);
9191+ None)
9292+9393+let set t key value =
9494+ Log.debug (fun m -> m "Storing key: %s" key);
9595+ Hashtbl.replace t.cache key value;
9696+ let body = encode_storage_update [ (key, value) ] in
9797+ Log.debug (fun m -> m "Sending storage update");
9898+ let _response =
9999+ Zulip.Client.request t.client ~method_:`PUT ~path:"/api/v1/bot_storage"
100100+ ~body ()
101101+ in
102102+ Log.debug (fun m -> m "Successfully stored key: %s" key)
103103+104104+let remove t key =
105105+ Log.debug (fun m -> m "Removing key: %s" key);
106106+ Hashtbl.remove t.cache key;
107107+ (* Zulip API doesn't have a delete endpoint, so we set to empty string *)
108108+ set t key ""
109109+110110+let mem t key =
111111+ if Hashtbl.mem t.cache key then true
112112+ else match get t key with Some _ -> true | None -> false
113113+114114+let keys t =
115115+ let json =
116116+ Zulip.Client.request t.client ~method_:`GET ~path:"/api/v1/bot_storage" ()
117117+ in
118118+ match Zulip.Encode.from_json storage_response_jsont json with
119119+ | Ok response ->
120120+ let api_keys =
121121+ String_map.fold (fun k _ acc -> k :: acc) response.storage []
122122+ in
123123+ let cache_keys = Hashtbl.fold (fun k _ acc -> k :: acc) t.cache [] in
124124+ List.sort_uniq String.compare (api_keys @ cache_keys)
125125+ | Error msg ->
126126+ Log.warn (fun m -> m "Failed to parse storage response: %s" msg);
127127+ []
128128+129129+let client t = t.client
+43
lib/zulip_bot/storage.mli
···11+(** Bot storage - key-value storage via the Zulip bot storage API.
22+33+ Provides persistent storage for bots using Zulip's built-in bot storage
44+ mechanism. Values are cached locally and synchronized with the server.
55+66+ All mutation functions raise [Eio.Io] with [Zulip.E error] on failure. *)
77+88+type t
99+(** An opaque storage handle. *)
1010+1111+val create : Zulip.Client.t -> t
1212+(** [create client] creates a new storage instance.
1313+1414+ The storage is initialized by fetching all existing keys from the server
1515+ into a local cache. *)
1616+1717+val get : t -> string -> string option
1818+(** [get t key] retrieves a value from storage.
1919+2020+ Returns [Some value] if the key exists, [None] otherwise.
2121+ Checks the local cache first, then queries the server if not found. *)
2222+2323+val set : t -> string -> string -> unit
2424+(** [set t key value] stores a value.
2525+2626+ The value is cached locally and immediately written to the server.
2727+ @raise Eio.Io on server communication failure *)
2828+2929+val remove : t -> string -> unit
3030+(** [remove t key] removes a key from storage.
3131+3232+ @raise Eio.Io on server communication failure *)
3333+3434+val mem : t -> string -> bool
3535+(** [mem t key] checks if a key exists in storage. *)
3636+3737+val keys : t -> string list
3838+(** [keys t] returns all keys in storage.
3939+4040+ @raise Eio.Io on server communication failure *)
4141+4242+val client : t -> Zulip.Client.t
4343+(** [client t] returns the underlying Zulip client. *)
-41
lib/zulip_botserver/bot_registry.mli
···11-(** Registry for managing multiple bots *)
22-33-(** Bot module definition *)
44-module Bot_module : sig
55- type t
66-77- val create :
88- name:string ->
99- handler:(module Zulip_bot.Bot_handler.Bot_handler) ->
1010- create_config:(Server_config.Bot_config.t -> Zulip_bot.Bot_config.t) ->
1111- t
1212- (** Create a bot module. The [create_config] function raises [Eio.Io] on failure. *)
1313-1414- val name : t -> string
1515-1616- val create_handler : t -> Server_config.Bot_config.t -> Zulip.Client.t -> Zulip_bot.Bot_handler.t
1717- (** Create handler from bot module.
1818- @raise Eio.Io on failure *)
1919-end
2020-2121-type t
2222-2323-(** Create a new bot registry *)
2424-val create : unit -> t
2525-2626-(** Register a bot module *)
2727-val register : t -> Bot_module.t -> unit
2828-2929-(** Get a bot handler by email *)
3030-val get_bot : t -> email:string -> Zulip_bot.Bot_handler.t option
3131-3232-(** Load a bot module from file.
3333- @raise Eio.Io on failure *)
3434-val load_from_file : string -> Bot_module.t
3535-3636-(** Load bot modules from directory.
3737- @raise Eio.Io on failure *)
3838-val load_from_directory : string -> Bot_module.t list
3939-4040-(** List all registered bot emails *)
4141-val list_bots : t -> string list
-24
lib/zulip_botserver/bot_server.mli
···11-(** Main bot server implementation *)
22-33-type t
44-55-(** Create a bot server.
66- @raise Eio.Io on failure *)
77-val create :
88- config:Server_config.t ->
99- registry:Bot_registry.t ->
1010- t
1111-1212-(** Start the bot server *)
1313-val run : t -> unit
1414-1515-(** Stop the bot server gracefully *)
1616-val shutdown : t -> unit
1717-1818-(** Resource-safe server management.
1919- @raise Eio.Io on failure *)
2020-val with_server :
2121- config:Server_config.t ->
2222- registry:Bot_registry.t ->
2323- (t -> 'a) ->
2424- 'a
···11-(** Bot server configuration *)
22-33-(** Configuration for a single bot *)
44-module Bot_config : sig
55- type t
66-77- val create :
88- email:string ->
99- api_key:string ->
1010- server_url:string ->
1111- token:string ->
1212- config_path:string option ->
1313- t
1414-1515- val email : t -> string
1616- val api_key : t -> string
1717- val server_url : t -> string
1818- val token : t -> string
1919- val config_path : t -> string option
2020- val pp : Format.formatter -> t -> unit
2121-end
2222-2323-(** Server configuration *)
2424-type t
2525-2626-val create :
2727- ?host:string ->
2828- ?port:int ->
2929- bots:Bot_config.t list ->
3030- unit ->
3131- t
3232-3333-val from_file : string -> t
3434-(** Load configuration from file.
3535- @raise Eio.Io on failure *)
3636-3737-val from_env : unit -> t
3838-(** Load configuration from environment variables.
3939- @raise Eio.Io on failure *)
4040-4141-val host : t -> string
4242-val port : t -> int
4343-val bots : t -> Bot_config.t list
4444-4545-val pp : Format.formatter -> t -> unit
-35
lib/zulip_botserver/webhook_handler.mli
···11-(** Webhook processing for bot server *)
22-33-(** Webhook event data *)
44-module Webhook_event : sig
55- type trigger = [`Direct_message | `Mention]
66-77- type t
88-99- val create :
1010- bot_email:string ->
1111- token:string ->
1212- message:Zulip.json ->
1313- trigger:trigger ->
1414- t
1515-1616- val bot_email : t -> string
1717- val token : t -> string
1818- val message : t -> Zulip.json
1919- val trigger : t -> trigger
2020- val pp : Format.formatter -> t -> unit
2121-end
2222-2323-(** Parse webhook data from HTTP request.
2424- @raise Eio.Io on failure *)
2525-val parse_webhook : string -> Webhook_event.t
2626-2727-(** Process webhook with bot registry.
2828- @raise Eio.Io on failure *)
2929-val handle_webhook :
3030- Bot_registry.t ->
3131- Webhook_event.t ->
3232- Zulip_bot.Bot_handler.Response.t option
3333-3434-(** Validate webhook token *)
3535-val validate_token : Server_config.Bot_config.t -> string -> bool
+6-1
zulip_bot.opam
···11# This file is generated by dune, edit dune-project instead
22opam-version: "2.0"
33synopsis: "OCaml bot framework for Zulip"
44-description: "Interactive bot framework built on the OCaml Zulip library"
44+description:
55+ "Fiber-based bot framework for Zulip with XDG configuration support"
56depends: [
67 "ocaml"
78 "dune" {>= "3.0"}
89 "zulip"
910 "eio"
1111+ "xdge"
1212+ "jsont"
1313+ "logs"
1414+ "fmt"
1015 "alcotest" {with-test}
1116 "odoc" {with-doc}
1217]
-29
zulip_botserver.opam
···11-# This file is generated by dune, edit dune-project instead
22-opam-version: "2.0"
33-synopsis: "OCaml bot server for running multiple Zulip bots"
44-description:
55- "HTTP server for running multiple Zulip bots with webhook support"
66-depends: [
77- "ocaml"
88- "dune" {>= "3.0"}
99- "zulip"
1010- "zulip_bot"
1111- "eio"
1212- "requests"
1313- "alcotest" {with-test}
1414- "odoc" {with-doc}
1515-]
1616-build: [
1717- ["dune" "subst"] {dev}
1818- [
1919- "dune"
2020- "build"
2121- "-p"
2222- name
2323- "-j"
2424- jobs
2525- "@install"
2626- "@runtest" {with-test}
2727- "@doc" {with-doc}
2828- ]
2929-]