···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 *)
···11+(* Message parsing using Jsont codecs *)
22+33+let logs_src = Logs.Src.create "zulip_bot.message"
44+module Log = (val Logs.src_log logs_src : Logs.LOG)
55+66+(** User representation *)
77+module User = struct
88+ type t = {
99+ user_id: int;
1010+ email: string;
1111+ full_name: string;
1212+ short_name: string option;
1313+ unknown: Jsont.json; (** Unknown/extra JSON fields preserved during parsing *)
1414+ }
1515+1616+ let user_id t = t.user_id
1717+ let email t = t.email
1818+ let full_name t = t.full_name
1919+ let short_name t = t.short_name
2020+2121+ (* Jsont codec for User - handles both user_id and id fields *)
2222+ let jsont : t Jsont.t =
2323+ let make email full_name short_name unknown =
2424+ (* user_id will be extracted in a custom way from the object *)
2525+ fun user_id_opt id_opt ->
2626+ let user_id = match user_id_opt, id_opt with
2727+ | Some uid, _ -> uid
2828+ | None, Some id -> id
2929+ | None, None -> Jsont.Error.msgf Jsont.Meta.none "Missing user_id or id field"
3030+ in
3131+ { user_id; email; full_name; short_name; unknown }
3232+ in
3333+ Jsont.Object.map ~kind:"User" make
3434+ |> Jsont.Object.mem "email" Jsont.string ~enc:email
3535+ |> Jsont.Object.mem "full_name" Jsont.string ~enc:full_name
3636+ |> Jsont.Object.opt_mem "short_name" Jsont.string ~enc:short_name
3737+ |> Jsont.Object.keep_unknown Jsont.json_mems ~enc:(fun t -> t.unknown)
3838+ |> Jsont.Object.opt_mem "user_id" Jsont.int ~enc:(fun t -> Some t.user_id)
3939+ |> Jsont.Object.opt_mem "id" Jsont.int ~enc:(fun _ -> None)
4040+ |> Jsont.Object.finish
4141+4242+ let of_json (json : Zulip.json) : (t, string) result =
4343+ Zulip.Encode.from_json jsont json
4444+end
4545+4646+(** Reaction representation *)
4747+module Reaction = struct
4848+ type t = {
4949+ emoji_name: string;
5050+ emoji_code: string;
5151+ reaction_type: string;
5252+ user_id: int;
5353+ unknown: Jsont.json; (** Unknown/extra JSON fields preserved during parsing *)
5454+ }
5555+5656+ let emoji_name t = t.emoji_name
5757+ let emoji_code t = t.emoji_code
5858+ let reaction_type t = t.reaction_type
5959+ let user_id t = t.user_id
6060+6161+ (* Jsont codec for Reaction - handles user_id in different locations *)
6262+ let jsont : t Jsont.t =
6363+ (* Helper codec for nested user object - extracts just the user_id *)
6464+ let user_obj_codec =
6565+ Jsont.Object.map ~kind:"ReactionUser" Fun.id
6666+ |> Jsont.Object.mem "user_id" Jsont.int ~enc:Fun.id
6767+ |> Jsont.Object.finish
6868+ in
6969+ let make emoji_name emoji_code reaction_type unknown =
7070+ fun user_id_direct user_obj_nested ->
7171+ let user_id = match user_id_direct, user_obj_nested with
7272+ | Some uid, _ -> uid
7373+ | None, Some uid -> uid
7474+ | None, None -> Jsont.Error.msgf Jsont.Meta.none "Missing user_id field"
7575+ in
7676+ { emoji_name; emoji_code; reaction_type; user_id; unknown }
7777+ in
7878+ Jsont.Object.map ~kind:"Reaction" make
7979+ |> Jsont.Object.mem "emoji_name" Jsont.string ~enc:emoji_name
8080+ |> Jsont.Object.mem "emoji_code" Jsont.string ~enc:emoji_code
8181+ |> Jsont.Object.mem "reaction_type" Jsont.string ~enc:reaction_type
8282+ |> Jsont.Object.keep_unknown Jsont.json_mems ~enc:(fun t -> t.unknown)
8383+ |> Jsont.Object.opt_mem "user_id" Jsont.int ~enc:(fun t -> Some t.user_id)
8484+ |> Jsont.Object.opt_mem "user" user_obj_codec ~enc:(fun _ -> None)
8585+ |> Jsont.Object.finish
8686+8787+ let of_json (json : Zulip.json) : (t, string) result =
8888+ Zulip.Encode.from_json jsont json
8989+end
9090+9191+let parse_reaction_json json = Reaction.of_json json
9292+let parse_user_json json = User.of_json json
9393+9494+(** Common message fields *)
9595+type common = {
9696+ id: int;
9797+ sender_id: int;
9898+ sender_email: string;
9999+ sender_full_name: string;
100100+ sender_short_name: string option;
101101+ timestamp: float;
102102+ content: string;
103103+ content_type: string;
104104+ reactions: Reaction.t list;
105105+ submessages: Zulip.json list;
106106+ flags: string list;
107107+ is_me_message: bool;
108108+ client: string;
109109+ gravatar_hash: string;
110110+ avatar_url: string option;
111111+}
112112+113113+(** Message types *)
114114+type t =
115115+ | Private of {
116116+ common: common;
117117+ display_recipient: User.t list;
118118+ }
119119+ | Stream of {
120120+ common: common;
121121+ display_recipient: string;
122122+ stream_id: int;
123123+ subject: string;
124124+ }
125125+ | Unknown of {
126126+ common: common;
127127+ raw_json: Zulip.json;
128128+ }
129129+130130+(** Helper function to parse common fields *)
131131+let parse_common json =
132132+ match json with
133133+ | Jsont.Object (fields, _) ->
134134+ let assoc = List.map (fun ((k, _), v) -> (k, v)) fields in
135135+ let get_int key =
136136+ match List.assoc_opt key assoc with
137137+ | Some (Jsont.Number (f, _)) -> Some (int_of_float f)
138138+ | _ -> None
139139+ in
140140+ let get_string key =
141141+ match List.assoc_opt key assoc with
142142+ | Some (Jsont.String (s, _)) -> Some s
143143+ | _ -> None
144144+ in
145145+ let get_float key default =
146146+ match List.assoc_opt key assoc with
147147+ | Some (Jsont.Number (f, _)) -> f
148148+ | _ -> default
149149+ in
150150+ let get_bool key default =
151151+ match List.assoc_opt key assoc with
152152+ | Some (Jsont.Bool (b, _)) -> b
153153+ | _ -> default
154154+ in
155155+ let get_array key =
156156+ match List.assoc_opt key assoc with
157157+ | Some (Jsont.Array (arr, _)) -> Some arr
158158+ | _ -> None
159159+ in
160160+161161+ (match (get_int "id", get_int "sender_id", get_string "sender_email", get_string "sender_full_name") with
162162+ | (Some id, Some sender_id, Some sender_email, Some sender_full_name) ->
163163+ let sender_short_name = get_string "sender_short_name" in
164164+ let timestamp = get_float "timestamp" 0.0 in
165165+ let content = get_string "content" |> Option.value ~default:"" in
166166+ let content_type = get_string "content_type" |> Option.value ~default:"text/html" in
167167+168168+ let reactions =
169169+ match get_array "reactions" with
170170+ | Some reactions_json ->
171171+ List.filter_map (fun r ->
172172+ match parse_reaction_json r with
173173+ | Ok reaction -> Some reaction
174174+ | Error msg ->
175175+ Log.warn (fun m -> m "Failed to parse reaction: %s" msg);
176176+ None
177177+ ) reactions_json
178178+ | None -> []
179179+ in
180180+181181+ let submessages = get_array "submessages" |> Option.value ~default:[] in
182182+183183+ let flags =
184184+ match get_array "flags" with
185185+ | Some flags_json ->
186186+ List.filter_map (fun f ->
187187+ match f with
188188+ | Jsont.String (s, _) -> Some s
189189+ | _ -> None
190190+ ) flags_json
191191+ | None -> []
192192+ in
193193+194194+ let is_me_message = get_bool "is_me_message" false in
195195+ let client = get_string "client" |> Option.value ~default:"" in
196196+ let gravatar_hash = get_string "gravatar_hash" |> Option.value ~default:"" in
197197+ let avatar_url = get_string "avatar_url" in
198198+199199+ Ok {
200200+ id; sender_id; sender_email; sender_full_name; sender_short_name;
201201+ timestamp; content; content_type; reactions; submessages;
202202+ flags; is_me_message; client; gravatar_hash; avatar_url
203203+ }
204204+ | _ -> Error "Missing required message fields")
205205+ | _ -> Error "Expected JSON object for message"
206206+207207+(** JSON parsing *)
208208+let of_json json =
209209+ (* Helper to pretty print JSON without using jsonu *)
210210+ let json_str =
211211+ match Jsont_bytesrw.encode_string' Jsont.json json with
212212+ | Ok s -> s
213213+ | Error _ -> "<error encoding json>"
214214+ in
215215+ Log.debug (fun m -> m "Parsing message JSON: %s" json_str);
216216+217217+ match parse_common json with
218218+ | Error msg -> Error msg
219219+ | Ok common ->
220220+ match json with
221221+ | Jsont.Object (fields, _) ->
222222+ let assoc = List.map (fun ((k, _), v) -> (k, v)) fields in
223223+ let msg_type =
224224+ match List.assoc_opt "type" assoc with
225225+ | Some (Jsont.String (s, _)) -> Some s
226226+ | _ -> None
227227+ in
228228+ (match msg_type with
229229+ | Some "private" ->
230230+ (match List.assoc_opt "display_recipient" assoc with
231231+ | Some (Jsont.Array (recipient_json, _)) ->
232232+ let users = List.filter_map (fun u ->
233233+ match parse_user_json u with
234234+ | Ok user -> Some user
235235+ | Error msg ->
236236+ Log.warn (fun m -> m "Failed to parse user in display_recipient: %s" msg);
237237+ None
238238+ ) recipient_json in
239239+240240+ if List.length users = 0 && List.length recipient_json > 0 then
241241+ Error "Failed to parse any users in display_recipient"
242242+ else
243243+ Ok (Private { common; display_recipient = users })
244244+ | _ ->
245245+ Log.warn (fun m -> m "display_recipient is not an array for private message");
246246+ Ok (Unknown { common; raw_json = json }))
247247+248248+ | Some "stream" ->
249249+ let display_recipient =
250250+ match List.assoc_opt "display_recipient" assoc with
251251+ | Some (Jsont.String (s, _)) -> Some s
252252+ | _ -> None
253253+ in
254254+ let stream_id =
255255+ match List.assoc_opt "stream_id" assoc with
256256+ | Some (Jsont.Number (f, _)) -> Some (int_of_float f)
257257+ | _ -> None
258258+ in
259259+ let subject =
260260+ match List.assoc_opt "subject" assoc with
261261+ | Some (Jsont.String (s, _)) -> Some s
262262+ | _ -> None
263263+ in
264264+ (match (display_recipient, stream_id, subject) with
265265+ | (Some display_recipient, Some stream_id, Some subject) ->
266266+ Ok (Stream { common; display_recipient; stream_id; subject })
267267+ | _ ->
268268+ Log.warn (fun m -> m "Missing required fields for stream message");
269269+ Ok (Unknown { common; raw_json = json }))
270270+271271+ | Some unknown_type ->
272272+ Log.warn (fun m -> m "Unknown message type: %s" unknown_type);
273273+ Ok (Unknown { common; raw_json = json })
274274+275275+ | None ->
276276+ Log.warn (fun m -> m "No message type field found");
277277+ Ok (Unknown { common; raw_json = json }))
278278+ | _ -> Error "Expected JSON object for message"
279279+280280+(** Accessor functions *)
281281+let get_common = function
282282+ | Private { common; _ } -> common
283283+ | Stream { common; _ } -> common
284284+ | Unknown { common; _ } -> common
285285+286286+let id msg = (get_common msg).id
287287+let sender_id msg = (get_common msg).sender_id
288288+let sender_email msg = (get_common msg).sender_email
289289+let sender_full_name msg = (get_common msg).sender_full_name
290290+let sender_short_name msg = (get_common msg).sender_short_name
291291+let timestamp msg = (get_common msg).timestamp
292292+let content msg = (get_common msg).content
293293+let content_type msg = (get_common msg).content_type
294294+let reactions msg = (get_common msg).reactions
295295+let submessages msg = (get_common msg).submessages
296296+let flags msg = (get_common msg).flags
297297+let is_me_message msg = (get_common msg).is_me_message
298298+let client msg = (get_common msg).client
299299+let gravatar_hash msg = (get_common msg).gravatar_hash
300300+let avatar_url msg = (get_common msg).avatar_url
301301+302302+(** Helper functions *)
303303+let is_private = function
304304+ | Private _ -> true
305305+ | _ -> false
306306+307307+let is_stream = function
308308+ | Stream _ -> true
309309+ | _ -> false
310310+311311+let is_from_self msg ~bot_user_id =
312312+ sender_id msg = bot_user_id
313313+314314+let is_from_email msg ~email =
315315+ sender_email msg = email
316316+317317+let get_reply_to = function
318318+ | Private { display_recipient; _ } ->
319319+ display_recipient
320320+ |> List.map User.email
321321+ |> String.concat ", "
322322+ | Stream { display_recipient; _ } -> display_recipient
323323+ | Unknown _ -> ""
324324+325325+(** Utility functions *)
326326+let is_mentioned msg ~user_email =
327327+ let content_text = content msg in
328328+ (* Check for both email and username mentions *)
329329+ let email_mention = "@**" ^ user_email ^ "**" in
330330+ (* Also check for username mention (part before @ in email) *)
331331+ let username =
332332+ match String.index_opt user_email '@' with
333333+ | Some idx -> String.sub user_email 0 idx
334334+ | None -> user_email
335335+ in
336336+ let username_mention = "@**" ^ username ^ "**" in
337337+338338+ let contains text pattern =
339339+ if String.length pattern = 0 || String.length pattern > String.length text then
340340+ false
341341+ else
342342+ let rec search_from pos =
343343+ if pos > String.length text - String.length pattern then
344344+ false
345345+ else if String.sub text pos (String.length pattern) = pattern then
346346+ true
347347+ else
348348+ search_from (pos + 1)
349349+ in
350350+ search_from 0
351351+ in
352352+ contains content_text email_mention || contains content_text username_mention
353353+354354+let strip_mention msg ~user_email =
355355+ let content_text = content msg in
356356+ (* Check for both email and username mentions *)
357357+ let email_mention = "@**" ^ user_email ^ "**" in
358358+ let username =
359359+ match String.index_opt user_email '@' with
360360+ | Some idx -> String.sub user_email 0 idx
361361+ | None -> user_email
362362+ in
363363+ let username_mention = "@**" ^ username ^ "**" in
364364+365365+ (* Remove whichever mention pattern is found at the start *)
366366+ let without_mention =
367367+ if String.starts_with ~prefix:email_mention content_text then
368368+ String.sub content_text (String.length email_mention)
369369+ (String.length content_text - String.length email_mention)
370370+ else if String.starts_with ~prefix:username_mention content_text then
371371+ String.sub content_text (String.length username_mention)
372372+ (String.length content_text - String.length username_mention)
373373+ else
374374+ content_text
375375+ in
376376+ String.trim without_mention
377377+378378+let extract_command msg =
379379+ let content_text = String.trim (content msg) in
380380+ if String.length content_text > 0 && content_text.[0] = '!' then
381381+ Some (String.sub content_text 1 (String.length content_text - 1))
382382+ else
383383+ None
384384+385385+let parse_command msg =
386386+ match extract_command msg with
387387+ | None -> None
388388+ | Some cmd_string ->
389389+ let parts = String.split_on_char ' ' (String.trim cmd_string) in
390390+ match parts with
391391+ | [] -> None
392392+ | cmd :: args -> Some (cmd, args)
393393+394394+(** Pretty printing *)
395395+let pp_user fmt user =
396396+ Format.fprintf fmt "{ user_id=%d; email=%s; full_name=%s }"
397397+ (User.user_id user) (User.email user) (User.full_name user)
398398+399399+let _pp_reaction fmt reaction =
400400+ Format.fprintf fmt "{ emoji_name=%s; user_id=%d }"
401401+ (Reaction.emoji_name reaction) (Reaction.user_id reaction)
402402+403403+let pp fmt = function
404404+ | Private { common; display_recipient } ->
405405+ Format.fprintf fmt "Private { id=%d; sender=%s; recipients=[%a]; content=%S }"
406406+ common.id common.sender_email
407407+ (Format.pp_print_list ~pp_sep:(fun fmt () -> Format.fprintf fmt "; ") pp_user)
408408+ display_recipient
409409+ common.content
410410+411411+ | Stream { common; display_recipient; subject; _ } ->
412412+ Format.fprintf fmt "Stream { id=%d; sender=%s; stream=%s; subject=%s; content=%S }"
413413+ common.id common.sender_email display_recipient subject common.content
414414+415415+ | Unknown { common; _ } ->
416416+ Format.fprintf fmt "Unknown { id=%d; sender=%s; content=%S }"
417417+ common.id common.sender_email common.content
418418+419419+(** ANSI colored pretty printing for debugging *)
420420+let pp_ansi ?(show_json=false) ppf msg =
421421+ let open Fmt in
422422+ let blue = styled `Blue string in
423423+ let green = styled `Green string in
424424+ let yellow = styled `Yellow string in
425425+ let magenta = styled `Magenta string in
426426+ let cyan = styled `Cyan string in
427427+ let dim = styled (`Fg `Black) string in
428428+429429+ match msg with
430430+ | Private { common; display_recipient } ->
431431+ pf ppf "%a %a %a %a %a"
432432+ (styled `Bold blue) "DM"
433433+ dim (Printf.sprintf "[#%d]" common.id)
434434+ (styled `Cyan string) common.sender_email
435435+ dim "→"
436436+ green (Printf.sprintf "%S" common.content);
437437+ if show_json then
438438+ pf ppf "@. %a %a" dim "Recipients:"
439439+ (list ~sep:(const string ", ") (fun fmt u -> cyan fmt (User.email u)))
440440+ display_recipient
441441+442442+ | Stream { common; display_recipient; subject; _ } ->
443443+ pf ppf "%a %a %a%a%a %a %a"
444444+ (styled `Bold yellow) "STREAM"
445445+ dim (Printf.sprintf "[#%d]" common.id)
446446+ magenta display_recipient
447447+ dim "/"
448448+ cyan subject
449449+ (styled `Cyan string) common.sender_email
450450+ green (Printf.sprintf "%S" common.content)
451451+452452+ | Unknown { common; _ } ->
453453+ pf ppf "%a %a %a %a"
454454+ (styled `Bold (styled (`Fg `Red) string)) "UNKNOWN"
455455+ dim (Printf.sprintf "[#%d]" common.id)
456456+ (styled `Cyan string) common.sender_email
457457+ (styled (`Fg `Red) string) (Printf.sprintf "%S" common.content)
458458+459459+(** Pretty print JSON for debugging *)
460460+let pp_json_debug ppf json =
461461+ let open Fmt in
462462+ let json_str =
463463+ match Jsont_bytesrw.encode_string' Jsont.json json with
464464+ | Ok s -> s
465465+ | Error _ -> "<error encoding json>"
466466+ in
467467+ pf ppf "@[<v>%a@.%a@]"
468468+ (styled `Bold (styled (`Fg `Blue) string)) "Raw JSON:"
469469+ (styled (`Fg `Black) string) json_str
+116
lib/zulip_bot/message.mli
···11+(** Zulip message types and utilities for bots *)
22+33+(** User representation *)
44+module User : sig
55+ type t = {
66+ user_id : int;
77+ email : string;
88+ full_name : string;
99+ short_name : string option;
1010+ unknown : Jsont.json;
1111+ }
1212+1313+ val user_id : t -> int
1414+ val email : t -> string
1515+ val full_name : t -> string
1616+ val short_name : t -> string option
1717+1818+ (** Jsont codec for User *)
1919+ val jsont : t Jsont.t
2020+end
2121+2222+(** Reaction representation *)
2323+module Reaction : sig
2424+ type t = {
2525+ emoji_name : string;
2626+ emoji_code : string;
2727+ reaction_type : string;
2828+ user_id : int;
2929+ unknown : Jsont.json;
3030+ }
3131+3232+ val emoji_name : t -> string
3333+ val emoji_code : t -> string
3434+ val reaction_type : t -> string
3535+ val user_id : t -> int
3636+3737+ (** Jsont codec for Reaction *)
3838+ val jsont : t Jsont.t
3939+end
4040+4141+(** Common message fields *)
4242+type common = {
4343+ id : int;
4444+ sender_id : int;
4545+ sender_email : string;
4646+ sender_full_name : string;
4747+ sender_short_name : string option;
4848+ timestamp : float;
4949+ content : string;
5050+ content_type : string;
5151+ reactions : Reaction.t list;
5252+ submessages : Zulip.json list;
5353+ flags : string list;
5454+ is_me_message : bool;
5555+ client : string;
5656+ gravatar_hash : string;
5757+ avatar_url : string option;
5858+}
5959+6060+(** Message types *)
6161+type t =
6262+ | Private of { common : common; display_recipient : User.t list }
6363+ | Stream of {
6464+ common : common;
6565+ display_recipient : string;
6666+ stream_id : int;
6767+ subject : string;
6868+ }
6969+ | Unknown of { common : common; raw_json : Zulip.json }
7070+7171+(** Accessor functions *)
7272+7373+val id : t -> int
7474+val sender_id : t -> int
7575+val sender_email : t -> string
7676+val sender_full_name : t -> string
7777+val sender_short_name : t -> string option
7878+val timestamp : t -> float
7979+val content : t -> string
8080+val content_type : t -> string
8181+val reactions : t -> Reaction.t list
8282+val submessages : t -> Zulip.json list
8383+val flags : t -> string list
8484+val is_me_message : t -> bool
8585+val client : t -> string
8686+val gravatar_hash : t -> string
8787+val avatar_url : t -> string option
8888+8989+(** Helper functions *)
9090+9191+val is_private : t -> bool
9292+val is_stream : t -> bool
9393+val is_from_self : t -> bot_user_id:int -> bool
9494+val is_from_email : t -> email:string -> bool
9595+val get_reply_to : t -> string
9696+9797+(** Utility functions *)
9898+9999+val is_mentioned : t -> user_email:string -> bool
100100+val strip_mention : t -> user_email:string -> string
101101+val extract_command : t -> string option
102102+val parse_command : t -> (string * string list) option
103103+104104+(** JSON parsing *)
105105+106106+val of_json : Zulip.json -> (t, string) result
107107+108108+(** Pretty printing *)
109109+110110+val pp : Format.formatter -> t -> unit
111111+112112+(** ANSI colored pretty printing for debugging *)
113113+val pp_ansi : ?show_json:bool -> Format.formatter -> t -> unit
114114+115115+(** Pretty print JSON for debugging *)
116116+val pp_json_debug : Format.formatter -> Zulip.json -> unit
+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