Zulip bots with Eio

combine bot

+1126 -1638
+5 -14
dune-project
··· 21 21 (package 22 22 (name zulip_bot) 23 23 (synopsis "OCaml bot framework for Zulip") 24 - (description "Interactive bot framework built on the OCaml Zulip library") 24 + (description "Fiber-based bot framework for Zulip with XDG configuration support") 25 25 (depends 26 26 ocaml 27 27 dune 28 28 zulip 29 29 eio 30 + xdge 31 + jsont 32 + logs 33 + fmt 30 34 (alcotest :with-test))) 31 - 32 - (package 33 - (name zulip_botserver) 34 - (synopsis "OCaml bot server for running multiple Zulip bots") 35 - (description "HTTP server for running multiple Zulip bots with webhook support") 36 - (depends 37 - ocaml 38 - dune 39 - zulip 40 - zulip_bot 41 - eio 42 - requests 43 - (alcotest :with-test)))
+179 -222
examples/atom_feed_bot.ml
··· 3 3 4 4 (* Logging setup *) 5 5 let src = Logs.Src.create "atom_feed_bot" ~doc:"Atom feed bot for Zulip" 6 + 6 7 module Log = (val Logs.src_log src : Logs.LOG) 7 8 8 9 module Feed_parser = struct ··· 14 15 author : string option; 15 16 } 16 17 17 - type feed = { 18 - title : string; 19 - entries : entry list; 20 - } 18 + type feed = { title : string; entries : entry list } 21 19 22 20 (* Simple XML parser for Atom/RSS feeds *) 23 21 let parse_xml_element xml element_name = 24 22 let open_tag = "<" ^ element_name ^ ">" in 25 23 let close_tag = "</" ^ element_name ^ ">" in 26 24 try 27 - (* Find the opening tag *) 28 25 match String.index_opt xml '<' with 29 26 | None -> None 30 - | Some _ -> 31 - (* Search for the actual open tag in the XML *) 27 + | Some _ -> ( 32 28 let pattern = open_tag in 33 29 let pattern_start = 34 - try Some (String.index (String.lowercase_ascii xml) 35 - (String.lowercase_ascii pattern).[0]) 30 + try 31 + Some 32 + (String.index 33 + (String.lowercase_ascii xml) 34 + (String.lowercase_ascii pattern).[0]) 36 35 with Not_found -> None 37 36 in 38 37 match pattern_start with 39 38 | None -> None 40 39 | Some _ -> 41 - (* Try to find the content between tags *) 42 40 let rec find_substring str sub start = 43 - if start + String.length sub > String.length str then 44 - None 41 + if start + String.length sub > String.length str then None 45 42 else if String.sub str start (String.length sub) = sub then 46 43 Some start 47 - else 48 - find_substring str sub (start + 1) 44 + else find_substring str sub (start + 1) 49 45 in 50 - match find_substring xml open_tag 0 with 46 + (match find_substring xml open_tag 0 with 51 47 | None -> None 52 - | Some start_pos -> 48 + | Some start_pos -> ( 53 49 let content_start = start_pos + String.length open_tag in 54 50 match find_substring xml close_tag content_start with 55 51 | None -> None 56 52 | Some end_pos -> 57 - let content = String.sub xml content_start (end_pos - content_start) in 58 - Some (String.trim content) 53 + let content = 54 + String.sub xml content_start (end_pos - content_start) 55 + in 56 + Some (String.trim content)))) 59 57 with _ -> None 60 58 61 59 let parse_entry entry_xml = ··· 64 62 let summary = parse_xml_element entry_xml "summary" in 65 63 let published = parse_xml_element entry_xml "published" in 66 64 let author = parse_xml_element entry_xml "author" in 67 - match title, link with 65 + match (title, link) with 68 66 | Some t, Some l -> Some { title = t; link = l; summary; published; author } 69 67 | _ -> None 70 68 71 69 let _parse_feed xml = 72 - (* Very basic XML parsing - in production, use a proper XML library *) 73 - let feed_title = parse_xml_element xml "title" |> Option.value ~default:"Unknown Feed" in 74 - 75 - (* Extract entries between <entry> tags (Atom) or <item> tags (RSS) *) 70 + let feed_title = 71 + parse_xml_element xml "title" |> Option.value ~default:"Unknown Feed" 72 + in 76 73 let entries = ref [] in 77 74 let rec extract_entries str pos = 78 75 try 79 76 let entry_start = 80 - try String.index_from str pos '<' 81 - with Not_found -> String.length str 77 + try String.index_from str pos '<' with Not_found -> String.length str 82 78 in 83 79 if entry_start >= String.length str then () 84 80 else 85 81 let tag_end = String.index_from str entry_start '>' in 86 - let tag = String.sub str (entry_start + 1) (tag_end - entry_start - 1) in 87 - if tag = "entry" || tag = "item" then 82 + let tag = 83 + String.sub str (entry_start + 1) (tag_end - entry_start - 1) 84 + in 85 + if tag = "entry" || tag = "item" then ( 88 86 let entry_end = 89 87 try String.index_from str tag_end '<' 90 88 with Not_found -> String.length str 91 89 in 92 - let entry_xml = String.sub str entry_start (entry_end - entry_start) in 90 + let entry_xml = 91 + String.sub str entry_start (entry_end - entry_start) 92 + in 93 93 (match parse_entry entry_xml with 94 - | Some e -> entries := e :: !entries 95 - | None -> ()); 96 - extract_entries str entry_end 97 - else 98 - extract_entries str (tag_end + 1) 94 + | Some e -> entries := e :: !entries 95 + | None -> ()); 96 + extract_entries str entry_end) 97 + else extract_entries str (tag_end + 1) 99 98 with _ -> () 100 99 in 101 100 extract_entries xml 0; ··· 104 103 105 104 module Feed_bot = struct 106 105 type config = { 107 - feeds : (string * string * string) list; (* URL, channel, topic *) 108 - refresh_interval : float; (* seconds *) 106 + feeds : (string * string * string) list; 107 + refresh_interval : float; 109 108 state_file : string; 110 109 } 111 110 112 - type state = { 113 - last_seen : (string, string) Hashtbl.t; (* feed_url -> last_entry_id *) 114 - } 111 + type state = { last_seen : (string, string) Hashtbl.t } 115 112 116 113 let load_state path = 117 114 try 118 115 let ic = open_in path in 119 116 let state = { last_seen = Hashtbl.create 10 } in 120 117 (try 121 - while true do 122 - let line = input_line ic in 123 - match String.split_on_char '|' line with 124 - | [url; id] -> Hashtbl.add state.last_seen url id 125 - | _ -> () 126 - done 127 - with End_of_file -> ()); 118 + while true do 119 + let line = input_line ic in 120 + match String.split_on_char '|' line with 121 + | [ url; id ] -> Hashtbl.add state.last_seen url id 122 + | _ -> () 123 + done 124 + with End_of_file -> ()); 128 125 close_in ic; 129 126 state 130 127 with _ -> { last_seen = Hashtbl.create 10 } 131 128 132 129 let save_state path state = 133 130 let oc = open_out path in 134 - Hashtbl.iter (fun url id -> 135 - output_string oc (url ^ "|" ^ id ^ "\n") 136 - ) state.last_seen; 131 + Hashtbl.iter 132 + (fun url id -> output_string oc (url ^ "|" ^ id ^ "\n")) 133 + state.last_seen; 137 134 close_out oc 138 135 139 136 let fetch_feed _url = 140 - (* In a real implementation, use an HTTP client to fetch the feed *) 141 - (* For now, return a mock feed *) 142 - Feed_parser.{ 143 - title = "Mock Feed"; 144 - entries = [ 145 - { title = "Test Entry"; 146 - link = "https://example.com/1"; 147 - summary = Some "This is a test entry"; 148 - published = Some "2024-01-01T00:00:00Z"; 149 - author = Some "Test Author" } 150 - ] 151 - } 137 + Feed_parser. 138 + { 139 + title = "Mock Feed"; 140 + entries = 141 + [ 142 + { 143 + title = "Test Entry"; 144 + link = "https://example.com/1"; 145 + summary = Some "This is a test entry"; 146 + published = Some "2024-01-01T00:00:00Z"; 147 + author = Some "Test Author"; 148 + }; 149 + ]; 150 + } 152 151 153 152 let format_entry (entry : Feed_parser.entry) = 154 - let lines = [ 155 - Printf.sprintf "**[%s](%s)**" entry.title entry.link; 156 - ] in 157 - let lines = match entry.author with 158 - | Some a -> lines @ [Printf.sprintf "*By %s*" a] 153 + let lines = [ Printf.sprintf "**[%s](%s)**" entry.title entry.link ] in 154 + let lines = 155 + match entry.author with 156 + | Some a -> lines @ [ Printf.sprintf "*By %s*" a ] 159 157 | None -> lines 160 158 in 161 - let lines = match entry.published with 162 - | Some p -> lines @ [Printf.sprintf "*Published: %s*" p] 159 + let lines = 160 + match entry.published with 161 + | Some p -> lines @ [ Printf.sprintf "*Published: %s*" p ] 163 162 | None -> lines 164 163 in 165 - let lines = match entry.summary with 166 - | Some s -> lines @ [""; s] 164 + let lines = 165 + match entry.summary with 166 + | Some s -> lines @ [ ""; s ] 167 167 | None -> lines 168 168 in 169 169 String.concat "\n" lines 170 170 171 171 let post_entry client channel topic entry = 172 172 let open Feed_parser in 173 - let message = Zulip.Message.create 174 - ~type_:`Channel 175 - ~to_:[channel] 176 - ~topic 177 - ~content:(format_entry entry) 178 - () 173 + let message = 174 + Zulip.Message.create ~type_:`Channel ~to_:[ channel ] ~topic 175 + ~content:(format_entry entry) () 179 176 in 180 177 try 181 178 let _ = Zulip.Messages.send client message in ··· 186 183 let process_feed client state (url, channel, topic) = 187 184 Printf.printf "Processing feed: %s -> #%s/%s\n" url channel topic; 188 185 let feed = fetch_feed url in 189 - 190 186 let last_id = Hashtbl.find_opt state.last_seen url in 191 - let new_entries = match last_id with 187 + let new_entries = 188 + match last_id with 192 189 | Some id -> 193 - (* Filter entries newer than last_id *) 194 - List.filter (fun e -> 195 - Feed_parser.(e.link <> id) 196 - ) feed.entries 190 + List.filter (fun e -> Feed_parser.(e.link <> id)) feed.entries 197 191 | None -> feed.entries 198 192 in 199 - 200 - (* Post new entries *) 201 193 List.iter (post_entry client channel topic) new_entries; 202 - 203 - (* Update last seen *) 204 194 match feed.entries with 205 195 | h :: _ -> Hashtbl.replace state.last_seen url Feed_parser.(h.link) 206 196 | [] -> () 207 197 208 198 let run_bot env config = 209 - (* Load authentication *) 210 199 let auth = 211 200 try Zulip.Auth.from_zuliprc () 212 201 with Eio.Exn.Io _ as e -> 213 202 Printf.eprintf "Failed to load auth: %s\n" (Printexc.to_string e); 214 203 exit 1 215 204 in 216 - 217 - (* Create client *) 218 205 Eio.Switch.run @@ fun sw -> 219 206 let client = Zulip.Client.create ~sw env auth in 220 - 221 - (* Load state *) 222 207 let state = load_state config.state_file in 223 - 224 - (* Main loop *) 225 208 let rec loop () = 226 209 Printf.printf "Checking feeds...\n"; 227 210 List.iter (process_feed client state) config.feeds; 228 211 save_state config.state_file state; 229 - 230 212 Printf.printf "Sleeping for %.0f seconds...\n" config.refresh_interval; 231 213 Eio.Time.sleep (Eio.Stdenv.clock env) config.refresh_interval; 232 214 loop () ··· 239 221 open Zulip_bot 240 222 241 223 type t = { 242 - feeds : (string, string * string) Hashtbl.t; (* name -> (url, topic) *) 224 + feeds : (string, string * string) Hashtbl.t; 243 225 mutable default_channel : string; 244 226 } 245 227 246 - let create () = { 247 - feeds = Hashtbl.create 10; 248 - default_channel = "general"; 249 - } 228 + let create () = { feeds = Hashtbl.create 10; default_channel = "general" } 250 229 251 230 let handle_command bot_state command args = 252 231 match command with 253 - | "add" -> 254 - (match args with 255 - | name :: url :: topic -> 256 - let topic_str = String.concat " " topic in 257 - Hashtbl.replace bot_state.feeds name (url, topic_str); 258 - Printf.sprintf "Added feed '%s' -> %s (topic: %s)" name url topic_str 259 - | _ -> "Usage: !feed add <name> <url> <topic>") 260 - 261 - | "remove" -> 262 - (match args with 263 - | name :: _ -> 264 - if Hashtbl.mem bot_state.feeds name then ( 265 - Hashtbl.remove bot_state.feeds name; 266 - Printf.sprintf "Removed feed '%s'" name 267 - ) else 268 - Printf.sprintf "Feed '%s' not found" name 269 - | _ -> "Usage: !feed remove <name>") 270 - 232 + | "add" -> ( 233 + match args with 234 + | name :: url :: topic -> 235 + let topic_str = String.concat " " topic in 236 + Hashtbl.replace bot_state.feeds name (url, topic_str); 237 + Printf.sprintf "Added feed '%s' -> %s (topic: %s)" name url topic_str 238 + | _ -> "Usage: !feed add <name> <url> <topic>") 239 + | "remove" -> ( 240 + match args with 241 + | name :: _ -> 242 + if Hashtbl.mem bot_state.feeds name then ( 243 + Hashtbl.remove bot_state.feeds name; 244 + Printf.sprintf "Removed feed '%s'" name) 245 + else Printf.sprintf "Feed '%s' not found" name 246 + | _ -> "Usage: !feed remove <name>") 271 247 | "list" -> 272 - if Hashtbl.length bot_state.feeds = 0 then 273 - "No feeds configured" 248 + if Hashtbl.length bot_state.feeds = 0 then "No feeds configured" 274 249 else 275 - let lines = Hashtbl.fold (fun name (url, topic) acc -> 276 - (Printf.sprintf "• **%s**: %s → topic: %s" name url topic) :: acc 277 - ) bot_state.feeds [] in 250 + let lines = 251 + Hashtbl.fold 252 + (fun name (url, topic) acc -> 253 + Printf.sprintf "* **%s**: %s -> topic: %s" name url topic :: acc) 254 + bot_state.feeds [] 255 + in 278 256 String.concat "\n" lines 279 - 280 - | "fetch" -> 281 - (match args with 282 - | name :: _ -> 283 - (match Hashtbl.find_opt bot_state.feeds name with 284 - | Some (url, _topic) -> 285 - Printf.sprintf "Fetching feed '%s' from %s..." name url 286 - | None -> 287 - Printf.sprintf "Feed '%s' not found" name) 288 - | _ -> "Usage: !feed fetch <name>") 289 - 290 - | "channel" -> 291 - (match args with 292 - | channel :: _ -> 293 - bot_state.default_channel <- channel; 294 - Printf.sprintf "Default channel set to: %s" channel 295 - | _ -> Printf.sprintf "Current default channel: %s" bot_state.default_channel) 296 - 257 + | "fetch" -> ( 258 + match args with 259 + | name :: _ -> ( 260 + match Hashtbl.find_opt bot_state.feeds name with 261 + | Some (url, _topic) -> 262 + Printf.sprintf "Fetching feed '%s' from %s..." name url 263 + | None -> Printf.sprintf "Feed '%s' not found" name) 264 + | _ -> "Usage: !feed fetch <name>") 265 + | "channel" -> ( 266 + match args with 267 + | channel :: _ -> 268 + bot_state.default_channel <- channel; 269 + Printf.sprintf "Default channel set to: %s" channel 270 + | _ -> 271 + Printf.sprintf "Current default channel: %s" bot_state.default_channel) 297 272 | "help" | _ -> 298 - String.concat "\n" [ 299 - "**Atom Feed Bot Commands:**"; 300 - "• `!feed add <name> <url> <topic>` - Add a new feed"; 301 - "• `!feed remove <name>` - Remove a feed"; 302 - "• `!feed list` - List all configured feeds"; 303 - "• `!feed fetch <name>` - Manually fetch a feed"; 304 - "• `!feed channel <name>` - Set default channel"; 305 - "• `!feed help` - Show this help message"; 306 - ] 307 - 308 - let create_handler bot_state = 309 - let module Handler : Bot_handler.S = struct 310 - let initialize _ = () 311 - let usage () = "Atom feed bot - use !feed help for commands" 312 - let description () = "Bot for managing and posting Atom/RSS feeds to Zulip" 313 - 314 - let handle_message ~config:_ ~storage:_ ~identity ~message ~env:_ = 315 - (* Get message content using Message accessor *) 316 - let content = Message.content message in 273 + String.concat "\n" 274 + [ 275 + "**Atom Feed Bot Commands:**"; 276 + "* `!feed add <name> <url> <topic>` - Add a new feed"; 277 + "* `!feed remove <name>` - Remove a feed"; 278 + "* `!feed list` - List all configured feeds"; 279 + "* `!feed fetch <name>` - Manually fetch a feed"; 280 + "* `!feed channel <name>` - Set default channel"; 281 + "* `!feed help` - Show this help message"; 282 + ] 317 283 318 - (* Check if this is our own message to avoid loops *) 319 - let bot_email = Bot_handler.Identity.email identity in 320 - if Message.is_from_email message ~email:bot_email then 321 - Bot_handler.Response.None 322 - else 323 - (* Check if message starts with !feed *) 324 - if String.starts_with ~prefix:"!feed" content then 325 - let parts = String.split_on_char ' ' (String.trim content) in 326 - match parts with 327 - | _ :: command :: args -> 328 - let response = handle_command bot_state command args in 329 - Bot_handler.Response.Reply response 330 - | _ -> 331 - let response = handle_command bot_state "help" [] in 332 - Bot_handler.Response.Reply response 333 - else 334 - Bot_handler.Response.None 335 - end in 336 - (module Handler : Bot_handler.S) 284 + (* Create a handler function for the bot *) 285 + let create_handler bot_state ~storage:_ ~identity message = 286 + let content = Message.content message in 287 + let bot_email = identity.Bot.email in 288 + if Message.is_from_email message ~email:bot_email then Response.silent 289 + else if String.starts_with ~prefix:"!feed" content then 290 + let parts = String.split_on_char ' ' (String.trim content) in 291 + match parts with 292 + | _ :: command :: args -> 293 + let response = handle_command bot_state command args in 294 + Response.reply response 295 + | _ -> 296 + let response = handle_command bot_state "help" [] in 297 + Response.reply response 298 + else Response.silent 337 299 end 338 300 339 301 (* Run interactive bot mode *) 340 302 let run_interactive verbosity env = 341 - (* Setup logging *) 342 303 Logs.set_reporter (Logs_fmt.reporter ()); 343 - Logs.set_level (Some (match verbosity with 344 - | 0 -> Logs.Info 345 - | 1 -> Logs.Debug 346 - | _ -> Logs.Debug)); 304 + Logs.set_level 305 + (Some (match verbosity with 0 -> Logs.Info | 1 -> Logs.Debug | _ -> Logs.Debug)); 347 306 348 307 Log.info (fun m -> m "Starting interactive Atom feed bot..."); 349 308 350 309 let bot_state = Interactive_feed_bot.create () in 351 - let handler = Interactive_feed_bot.create_handler bot_state in 352 310 353 - (* Load auth and create bot runner *) 354 311 let auth = 355 312 try Zulip.Auth.from_zuliprc () 356 313 with Eio.Exn.Io _ as e -> ··· 359 316 in 360 317 361 318 Eio.Switch.run @@ fun sw -> 362 - let client = Zulip.Client.create ~sw env auth in 319 + let config = 320 + Zulip_bot.Config.create ~name:"atom-feed-bot" 321 + ~site:(Zulip.Auth.server_url auth) ~email:(Zulip.Auth.email auth) 322 + ~api_key:(Zulip.Auth.api_key auth) 323 + ~description:"Bot for managing and posting Atom/RSS feeds to Zulip" () 324 + in 363 325 364 - (* Create and run bot *) 365 - let config = Zulip_bot.Bot_config.create [] in 366 - let bot_email = Zulip.Auth.email auth in 367 - let storage = Zulip_bot.Bot_storage.create client ~bot_email in 368 - let identity = Zulip_bot.Bot_handler.Identity.create 369 - ~full_name:"Atom Feed Bot" 370 - ~email:bot_email 371 - ~mention_name:"feedbot" 372 - in 326 + Log.info (fun m -> m "Feed bot is running! Use !feed help for commands."); 373 327 374 - let bot = Zulip_bot.Bot_handler.create handler ~config ~storage ~identity in 375 - let runner = Zulip_bot.Bot_runner.create ~env ~client ~handler:bot in 376 - Zulip_bot.Bot_runner.run_realtime runner 328 + Zulip_bot.Bot.run ~sw ~env ~config 329 + ~handler:(Interactive_feed_bot.create_handler bot_state) 377 330 378 331 (* Run scheduled fetcher mode *) 379 332 let run_scheduled verbosity env = 380 - (* Setup logging *) 381 333 Logs.set_reporter (Logs_fmt.reporter ()); 382 - Logs.set_level (Some (match verbosity with 383 - | 0 -> Logs.Info 384 - | 1 -> Logs.Debug 385 - | _ -> Logs.Debug)); 334 + Logs.set_level 335 + (Some (match verbosity with 0 -> Logs.Info | 1 -> Logs.Debug | _ -> Logs.Debug)); 386 336 387 337 Log.info (fun m -> m "Starting scheduled Atom feed fetcher..."); 388 338 389 - let config = Feed_bot.{ 390 - feeds = [ 391 - ("https://example.com/feed.xml", "general", "News"); 392 - ("https://blog.example.com/atom.xml", "general", "Blog Posts"); 393 - ]; 394 - refresh_interval = 300.0; (* 5 minutes *) 395 - state_file = "feed_bot_state.txt"; 396 - } in 339 + let config = 340 + Feed_bot. 341 + { 342 + feeds = 343 + [ 344 + ("https://example.com/feed.xml", "general", "News"); 345 + ("https://blog.example.com/atom.xml", "general", "Blog Posts"); 346 + ]; 347 + refresh_interval = 300.0; 348 + state_file = "feed_bot_state.txt"; 349 + } 350 + in 397 351 398 352 Feed_bot.run_bot env config 399 353 ··· 402 356 403 357 let verbosity = 404 358 let doc = "Increase verbosity (can be used multiple times)" in 405 - let verbosity_flags = Arg.(value & flag_all & info ["v"; "verbose"] ~doc) in 359 + let verbosity_flags = Arg.(value & flag_all & info [ "v"; "verbose" ] ~doc) in 406 360 Term.(const List.length $ verbosity_flags) 407 361 408 362 let mode = 409 363 let doc = "Bot mode (interactive or scheduled)" in 410 - let modes = ["interactive", `Interactive; "scheduled", `Scheduled] in 411 - Arg.(value & opt (enum modes) `Interactive & info ["m"; "mode"] ~docv:"MODE" ~doc) 364 + let modes = [ ("interactive", `Interactive); ("scheduled", `Scheduled) ] in 365 + Arg.( 366 + value & opt (enum modes) `Interactive & info [ "m"; "mode" ] ~docv:"MODE" ~doc) 412 367 413 368 let main_cmd = 414 369 let doc = "Atom feed bot for Zulip" in 415 - let man = [ 416 - `S Manpage.s_description; 417 - `P "This bot can run in two modes:"; 418 - `P "- Interactive mode: Responds to !feed commands in Zulip"; 419 - `P "- Scheduled mode: Periodically fetches configured feeds"; 420 - `P "The bot requires a configured ~/.zuliprc file with API credentials."; 421 - ] in 422 - let info = Cmd.info "atom_feed_bot" ~version:"1.0.0" ~doc ~man in 370 + let man = 371 + [ 372 + `S Manpage.s_description; 373 + `P "This bot can run in two modes:"; 374 + `P "- Interactive mode: Responds to !feed commands in Zulip"; 375 + `P "- Scheduled mode: Periodically fetches configured feeds"; 376 + `P "The bot requires a configured ~/.zuliprc file with API credentials."; 377 + ] 378 + in 379 + let info = Cmd.info "atom_feed_bot" ~version:"2.0.0" ~doc ~man in 423 380 let run verbosity mode = 424 381 Eio_main.run @@ fun env -> 425 382 match mode with ··· 429 386 let term = Term.(const run $ verbosity $ mode) in 430 387 Cmd.v info term 431 388 432 - let () = exit (Cmd.eval main_cmd) 389 + let () = exit (Cmd.eval main_cmd)
+154 -309
examples/echo_bot.ml
··· 1 1 (* Enhanced Echo Bot for Zulip with Logging and CLI 2 2 Responds to direct messages and mentions by echoing back the message 3 - Features verbose logging and command-line configuration *) 3 + Uses the new functional Zulip_bot API *) 4 4 5 5 open Zulip_bot 6 6 7 7 (* Set up logging *) 8 8 let src = Logs.Src.create "echo_bot" ~doc:"Zulip Echo Bot" 9 - module Log = (val Logs.src_log src : Logs.LOG) 10 - 11 - module Echo_bot_handler : Bot_handler.S = struct 12 - let initialize _config = 13 - Log.info (fun m -> m "Initializing echo bot handler"); 14 - Log.debug (fun m -> m "Bot handler initialized") 15 - 16 - let usage () = 17 - "Echo Bot - I repeat everything you say to me!" 18 - 19 - let description () = 20 - "A simple echo bot that repeats messages sent to it. \ 21 - Send me a direct message or mention me in a channel." 22 9 23 - let handle_message ~config:_ ~storage ~identity ~message ~env:_ = 24 - (* Log the message with colorful formatting *) 25 - Log.debug (fun m -> m "@[<h>Received: %a@]" (Message.pp_ansi ~show_json:false) message); 26 - 27 - (* Use the new Message type for cleaner handling *) 28 - match message with 29 - | Message.Private { common; display_recipient = _ } -> 30 - 31 - (* Check if this is our own message to avoid loops *) 32 - let bot_email = Bot_handler.Identity.email identity in 33 - if common.sender_email = bot_email then ( 34 - Log.debug (fun m -> m "Ignoring own message"); 35 - Bot_handler.Response.None 36 - ) else 37 - (* Process the message content *) 38 - let sender_name = common.sender_full_name in 39 - 40 - (* Remove bot mention using Message utility *) 41 - let bot_email = Bot_handler.Identity.email identity in 42 - let cleaned_msg = Message.strip_mention message ~user_email:bot_email in 43 - Log.debug (fun m -> m "Cleaned message: %s" cleaned_msg); 10 + module Log = (val Logs.src_log src : Logs.LOG) 44 11 45 - (* Create echo response *) 46 - let response_content = 47 - let lower_msg = String.lowercase_ascii cleaned_msg in 48 - if cleaned_msg = "" then 49 - Printf.sprintf "Hello %s! Send me a message and I'll echo it back!" sender_name 50 - else if lower_msg = "help" then 51 - Printf.sprintf "Hi %s! I'm an echo bot with storage. Commands:\n\ 52 - • `help` - Show this help\n\ 53 - • `ping` - Test if I'm alive\n\ 54 - • `store <key> <value>` - Store a value\n\ 55 - • `get <key>` - Retrieve a value\n\ 56 - • `delete <key>` - Delete a stored value\n\ 57 - • `list` - List all stored keys\n\ 58 - • Any other message - I'll echo it back!" sender_name 59 - else if lower_msg = "ping" then ( 60 - Log.info (fun m -> m "Responding to ping from %s" sender_name); 61 - Printf.sprintf "Pong! 🏓 (from %s)" sender_name 62 - ) 63 - else if String.starts_with ~prefix:"store " lower_msg then ( 64 - (* Parse store command: store <key> <value> *) 65 - let parts = String.sub cleaned_msg 6 (String.length cleaned_msg - 6) |> String.trim in 66 - match String.index_opt parts ' ' with 67 - | Some idx -> 68 - let key = String.sub parts 0 idx |> String.trim in 69 - let value = String.sub parts (idx + 1) (String.length parts - idx - 1) |> String.trim in 70 - (try 71 - Bot_storage.put storage ~key ~value; 72 - Log.info (fun m -> m "Stored key=%s value=%s for user %s" key value sender_name); 73 - Printf.sprintf "✅ Stored: `%s` = `%s`" key value 74 - with Eio.Exn.Io _ as e -> 75 - Log.err (fun m -> m "Failed to store key=%s: %s" key (Printexc.to_string e)); 76 - Printf.sprintf "❌ Failed to store: %s" (Printexc.to_string e)) 77 - | None -> 78 - "Usage: `store <key> <value>` - Example: `store name John`" 79 - ) 80 - else if String.starts_with ~prefix:"get " lower_msg then ( 81 - (* Parse get command: get <key> *) 82 - let key = String.sub cleaned_msg 4 (String.length cleaned_msg - 4) |> String.trim in 83 - match Bot_storage.get storage ~key with 84 - | Some value -> 85 - Log.info (fun m -> m "Retrieved key=%s value=%s for user %s" key value sender_name); 86 - Printf.sprintf "📦 `%s` = `%s`" key value 87 - | None -> 88 - Log.info (fun m -> m "Key not found: %s" key); 89 - Printf.sprintf "❓ Key not found: `%s`" key 90 - ) 91 - else if String.starts_with ~prefix:"delete " lower_msg then ( 92 - (* Parse delete command: delete <key> *) 93 - let key = String.sub cleaned_msg 7 (String.length cleaned_msg - 7) |> String.trim in 94 - try 95 - Bot_storage.remove storage ~key; 96 - Log.info (fun m -> m "Deleted key=%s for user %s" key sender_name); 97 - Printf.sprintf "🗑️ Deleted key: `%s`" key 98 - with Eio.Exn.Io _ as e -> 99 - Log.err (fun m -> m "Failed to delete key=%s: %s" key (Printexc.to_string e)); 100 - Printf.sprintf "❌ Failed to delete: %s" (Printexc.to_string e) 101 - ) 102 - else if lower_msg = "list" then ( 103 - (* List all stored keys *) 104 - try 105 - let keys = Bot_storage.keys storage in 106 - if keys = [] then 107 - "📭 No keys stored yet. Use `store <key> <value>` to add data!" 108 - else 109 - let key_list = String.concat "\n" (List.map (fun k -> "• `" ^ k ^ "`") keys) in 110 - Printf.sprintf "📋 Stored keys:\n%s\n\nUse `get <key>` to retrieve values." key_list 111 - with Eio.Exn.Io _ as e -> 112 - Printf.sprintf "❌ Failed to list keys: %s" (Printexc.to_string e) 113 - ) 114 - else 115 - Printf.sprintf "Echo from %s: %s" sender_name cleaned_msg 116 - in 12 + (* The handler is now just a function *) 13 + let echo_handler ~storage ~identity msg = 14 + Log.debug (fun m -> m "@[<h>Received: %a@]" (Message.pp_ansi ~show_json:false) msg); 117 15 118 - Log.debug (fun m -> m "Generated response: %s" response_content); 119 - Log.info (fun m -> m "Sending private reply"); 120 - Bot_handler.Response.Reply response_content 16 + let bot_email = identity.Bot.email in 17 + let sender_email = Message.sender_email msg in 18 + let sender_name = Message.sender_full_name msg in 121 19 122 - | Message.Stream { common; display_recipient = _; subject = _; _ } -> 123 - (* Check if this is our own message to avoid loops *) 124 - let bot_email = Bot_handler.Identity.email identity in 125 - if common.sender_email = bot_email then ( 126 - Log.debug (fun m -> m "Ignoring own message"); 127 - Bot_handler.Response.None 128 - ) else 129 - (* Process the message content *) 130 - let sender_name = common.sender_full_name in 20 + (* Ignore our own messages *) 21 + if sender_email = bot_email then Response.silent 22 + else 23 + (* Remove bot mention *) 24 + let cleaned_msg = Message.strip_mention msg ~user_email:bot_email in 25 + Log.debug (fun m -> m "Cleaned message: %s" cleaned_msg); 131 26 132 - (* Remove bot mention using Message utility *) 133 - let cleaned_msg = Message.strip_mention message ~user_email:bot_email in 27 + (* Process command or echo *) 28 + let lower_msg = String.lowercase_ascii cleaned_msg in 29 + let response_content = 30 + if cleaned_msg = "" then 31 + Printf.sprintf "Hello %s! Send me a message and I'll echo it back!" 32 + sender_name 33 + else if lower_msg = "help" then 34 + Printf.sprintf 35 + "Hi %s! I'm an echo bot with storage. Commands:\n\ 36 + • `help` - Show this help\n\ 37 + • `ping` - Test if I'm alive\n\ 38 + • `store <key> <value>` - Store a value\n\ 39 + • `get <key>` - Retrieve a value\n\ 40 + • `delete <key>` - Delete a stored value\n\ 41 + • `list` - List all stored keys\n\ 42 + • Any other message - I'll echo it back!" 43 + sender_name 44 + else if lower_msg = "ping" then ( 45 + Log.info (fun m -> m "Responding to ping from %s" sender_name); 46 + Printf.sprintf "Pong! (from %s)" sender_name) 47 + else if String.starts_with ~prefix:"store " lower_msg then ( 48 + let parts = 49 + String.sub cleaned_msg 6 (String.length cleaned_msg - 6) 50 + |> String.trim 51 + in 52 + match String.index_opt parts ' ' with 53 + | Some idx -> 54 + let key = String.sub parts 0 idx |> String.trim in 55 + let value = 56 + String.sub parts (idx + 1) (String.length parts - idx - 1) 57 + |> String.trim 58 + in 59 + (try 60 + Storage.set storage key value; 61 + Log.info (fun m -> 62 + m "Stored key=%s value=%s for user %s" key value sender_name); 63 + Printf.sprintf "Stored: `%s` = `%s`" key value 64 + with Eio.Exn.Io _ as e -> 65 + Log.err (fun m -> 66 + m "Failed to store key=%s: %s" key (Printexc.to_string e)); 67 + Printf.sprintf "Failed to store: %s" (Printexc.to_string e)) 68 + | None -> "Usage: `store <key> <value>` - Example: `store name John`") 69 + else if String.starts_with ~prefix:"get " lower_msg then ( 70 + let key = 71 + String.sub cleaned_msg 4 (String.length cleaned_msg - 4) |> String.trim 72 + in 73 + match Storage.get storage key with 74 + | Some value -> 75 + Log.info (fun m -> 76 + m "Retrieved key=%s value=%s for user %s" key value sender_name); 77 + Printf.sprintf "`%s` = `%s`" key value 78 + | None -> 79 + Log.info (fun m -> m "Key not found: %s" key); 80 + Printf.sprintf "Key not found: `%s`" key) 81 + else if String.starts_with ~prefix:"delete " lower_msg then ( 82 + let key = 83 + String.sub cleaned_msg 7 (String.length cleaned_msg - 7) |> String.trim 84 + in 85 + try 86 + Storage.remove storage key; 87 + Log.info (fun m -> m "Deleted key=%s for user %s" key sender_name); 88 + Printf.sprintf "Deleted key: `%s`" key 89 + with Eio.Exn.Io _ as e -> 90 + Log.err (fun m -> 91 + m "Failed to delete key=%s: %s" key (Printexc.to_string e)); 92 + Printf.sprintf "Failed to delete: %s" (Printexc.to_string e)) 93 + else if lower_msg = "list" then ( 94 + try 95 + let keys = Storage.keys storage in 96 + if keys = [] then 97 + "No keys stored yet. Use `store <key> <value>` to add data!" 98 + else 99 + let key_list = 100 + String.concat "\n" (List.map (fun k -> "* `" ^ k ^ "`") keys) 101 + in 102 + Printf.sprintf "Stored keys:\n%s\n\nUse `get <key>` to retrieve values." 103 + key_list 104 + with Eio.Exn.Io _ as e -> 105 + Printf.sprintf "Failed to list keys: %s" (Printexc.to_string e)) 106 + else Printf.sprintf "Echo from %s: %s" sender_name cleaned_msg 107 + in 108 + Log.debug (fun m -> m "Generated response: %s" response_content); 109 + Response.reply response_content 134 110 135 - (* Create echo response *) 136 - let response_content = 137 - let lower_msg = String.lowercase_ascii cleaned_msg in 138 - if cleaned_msg = "" then 139 - Printf.sprintf "Hello %s! Send me a message and I'll echo it back!" sender_name 140 - else if lower_msg = "help" then 141 - Printf.sprintf "Hi %s! I'm an echo bot with storage. Commands:\n\ 142 - • `help` - Show this help\n\ 143 - • `ping` - Test if I'm alive\n\ 144 - • `store <key> <value>` - Store a value\n\ 145 - • `get <key>` - Retrieve a value\n\ 146 - • `delete <key>` - Delete a stored value\n\ 147 - • `list` - List all stored keys\n\ 148 - • Any other message - I'll echo it back!" sender_name 149 - else if lower_msg = "ping" then ( 150 - Log.info (fun m -> m "Responding to ping from %s" sender_name); 151 - Printf.sprintf "Pong! 🏓 (from %s)" sender_name 152 - ) 153 - else if String.starts_with ~prefix:"store " lower_msg then ( 154 - (* Parse store command: store <key> <value> *) 155 - let parts = String.sub cleaned_msg 6 (String.length cleaned_msg - 6) |> String.trim in 156 - match String.index_opt parts ' ' with 157 - | Some idx -> 158 - let key = String.sub parts 0 idx |> String.trim in 159 - let value = String.sub parts (idx + 1) (String.length parts - idx - 1) |> String.trim in 160 - (try 161 - Bot_storage.put storage ~key ~value; 162 - Log.info (fun m -> m "Stored key=%s value=%s for user %s" key value sender_name); 163 - Printf.sprintf "✅ Stored: `%s` = `%s`" key value 164 - with Eio.Exn.Io _ as e -> 165 - Log.err (fun m -> m "Failed to store key=%s: %s" key (Printexc.to_string e)); 166 - Printf.sprintf "❌ Failed to store: %s" (Printexc.to_string e)) 167 - | None -> 168 - "Usage: `store <key> <value>` - Example: `store name John`" 169 - ) 170 - else if String.starts_with ~prefix:"get " lower_msg then ( 171 - (* Parse get command: get <key> *) 172 - let key = String.sub cleaned_msg 4 (String.length cleaned_msg - 4) |> String.trim in 173 - match Bot_storage.get storage ~key with 174 - | Some value -> 175 - Log.info (fun m -> m "Retrieved key=%s value=%s for user %s" key value sender_name); 176 - Printf.sprintf "📦 `%s` = `%s`" key value 177 - | None -> 178 - Log.info (fun m -> m "Key not found: %s" key); 179 - Printf.sprintf "❓ Key not found: `%s`" key 180 - ) 181 - else if String.starts_with ~prefix:"delete " lower_msg then ( 182 - (* Parse delete command: delete <key> *) 183 - let key = String.sub cleaned_msg 7 (String.length cleaned_msg - 7) |> String.trim in 184 - try 185 - Bot_storage.remove storage ~key; 186 - Log.info (fun m -> m "Deleted key=%s for user %s" key sender_name); 187 - Printf.sprintf "🗑️ Deleted key: `%s`" key 188 - with Eio.Exn.Io _ as e -> 189 - Log.err (fun m -> m "Failed to delete key=%s: %s" key (Printexc.to_string e)); 190 - Printf.sprintf "❌ Failed to delete: %s" (Printexc.to_string e) 191 - ) 192 - else if lower_msg = "list" then ( 193 - (* List all stored keys *) 194 - try 195 - let keys = Bot_storage.keys storage in 196 - if keys = [] then 197 - "📭 No keys stored yet. Use `store <key> <value>` to add data!" 198 - else 199 - let key_list = String.concat "\n" (List.map (fun k -> "• `" ^ k ^ "`") keys) in 200 - Printf.sprintf "📋 Stored keys:\n%s\n\nUse `get <key>` to retrieve values." key_list 201 - with Eio.Exn.Io _ as e -> 202 - Printf.sprintf "❌ Failed to list keys: %s" (Printexc.to_string e) 203 - ) 204 - else 205 - Printf.sprintf "Echo from %s: %s" sender_name cleaned_msg 206 - in 207 - 208 - Log.debug (fun m -> m "Generated response: %s" response_content); 209 - Log.info (fun m -> m "Sending stream reply"); 210 - Bot_handler.Response.Reply response_content 211 - 212 - | Message.Unknown _ -> 213 - Log.err (fun m -> m "Received unknown message format"); 214 - Bot_handler.Response.None 215 - end 216 - 217 - let run_echo_bot config_file verbosity env = 111 + let run_echo_bot config_path verbosity env = 218 112 (* Set up logging based on verbosity *) 219 113 Logs.set_reporter (Logs_fmt.reporter ()); 220 - let log_level = match verbosity with 221 - | 0 -> Logs.Info 222 - | 1 -> Logs.Debug 223 - | _ -> Logs.Debug (* Cap at debug level *) 114 + let log_level = 115 + match verbosity with 0 -> Logs.Info | 1 -> Logs.Debug | _ -> Logs.Debug 224 116 in 225 117 Logs.set_level (Some log_level); 226 - 227 - (* Also set levels for related modules if they exist *) 228 118 Logs.Src.set_level src (Some log_level); 229 119 230 120 Log.app (fun m -> m "Starting Zulip Echo Bot"); 231 121 Log.app (fun m -> m "Log level: %s" (Logs.level_to_string (Some log_level))); 232 122 Log.app (fun m -> m "=============================\n"); 233 123 234 - (* Load authentication from .zuliprc file *) 235 - let auth = 236 - try 237 - let a = Zulip.Auth.from_zuliprc ?path:config_file () in 238 - Log.info (fun m -> m "Loaded authentication for: %s" (Zulip.Auth.email a)); 239 - Log.info (fun m -> m "Server: %s" (Zulip.Auth.server_url a)); 240 - a 241 - with Eio.Exn.Io _ as e -> 242 - Log.err (fun m -> m "Failed to load .zuliprc: %s" (Printexc.to_string e)); 243 - Log.app (fun m -> m "\nPlease create a ~/.zuliprc file with:"); 244 - Log.app (fun m -> m "[api]"); 245 - Log.app (fun m -> m "email=bot@example.com"); 246 - Log.app (fun m -> m "key=your-api-key"); 247 - Log.app (fun m -> m "site=https://your-domain.zulipchat.com"); 248 - exit 1 249 - in 250 - 251 124 Eio.Switch.run @@ fun sw -> 252 - Log.debug (fun m -> m "Creating Zulip client"); 253 - let client = Zulip.Client.create ~sw env auth in 125 + let fs = Eio.Stdenv.fs env in 254 126 255 - (* Create bot configuration *) 256 - let config = Bot_config.create [] in 257 - let bot_email = Zulip.Auth.email auth in 258 - 259 - Log.debug (fun m -> m "Creating bot storage for %s" bot_email); 260 - let storage = Bot_storage.create client ~bot_email in 261 - 262 - let identity = Bot_handler.Identity.create 263 - ~full_name:"Echo Bot" 264 - ~email:bot_email 265 - ~mention_name:"echobot" 127 + (* Load configuration - either from XDG or from provided path *) 128 + let config = 129 + match config_path with 130 + | Some path -> 131 + (* Load from .zuliprc style file for backwards compatibility *) 132 + let auth = Zulip.Auth.from_zuliprc ~path () in 133 + Config.create ~name:"echo-bot" ~site:(Zulip.Auth.server_url auth) 134 + ~email:(Zulip.Auth.email auth) ~api_key:(Zulip.Auth.api_key auth) 135 + ~description:"A simple echo bot that repeats messages" () 136 + | None -> ( 137 + (* Try XDG config first, fall back to ~/.zuliprc *) 138 + try Config.load ~fs "echo-bot" 139 + with _ -> 140 + let auth = Zulip.Auth.from_zuliprc () in 141 + Config.create ~name:"echo-bot" ~site:(Zulip.Auth.server_url auth) 142 + ~email:(Zulip.Auth.email auth) ~api_key:(Zulip.Auth.api_key auth) 143 + ~description:"A simple echo bot that repeats messages" ()) 266 144 in 267 - Log.info (fun m -> m "Bot identity created: %s (%s)" 268 - (Bot_handler.Identity.full_name identity) 269 - (Bot_handler.Identity.email identity)); 270 145 271 - (* Create and run the bot *) 272 - Log.debug (fun m -> m "Creating bot handler"); 273 - let handler = Bot_handler.create 274 - (module Echo_bot_handler) 275 - ~config ~storage ~identity 276 - in 277 - 278 - Log.debug (fun m -> m "Creating bot runner"); 279 - let runner = Bot_runner.create ~env ~client ~handler in 146 + Log.info (fun m -> m "Loaded configuration for: %s" config.email); 147 + Log.info (fun m -> m "Server: %s" config.site); 280 148 281 149 Log.app (fun m -> m "Echo bot is running!"); 282 - Log.app (fun m -> m "Send a direct message or mention @echobot in a channel."); 150 + Log.app (fun m -> m "Send a direct message or mention the bot in a channel."); 283 151 Log.app (fun m -> m "Commands: 'help', 'ping', or any message to echo"); 284 152 Log.app (fun m -> m "Press Ctrl+C to stop.\n"); 285 153 286 - (* Run in real-time mode *) 287 - Log.info (fun m -> m "Starting real-time event loop"); 288 - try 289 - Bot_runner.run_realtime runner; 290 - Log.info (fun m -> m "Bot runner exited normally") 154 + (* Run the bot - this is now just a simple function call *) 155 + try Bot.run ~sw ~env ~config ~handler:echo_handler 291 156 with 292 - | Sys.Break -> 293 - Log.info (fun m -> m "Received interrupt signal, shutting down"); 294 - Bot_runner.shutdown runner 157 + | Sys.Break -> Log.info (fun m -> m "Received interrupt signal, shutting down") 295 158 | exn -> 296 159 Log.err (fun m -> m "Bot crashed with exception: %s" (Printexc.to_string exn)); 297 160 Log.debug (fun m -> m "Backtrace: %s" (Printexc.get_backtrace ())); ··· 302 165 303 166 let config_file = 304 167 let doc = "Path to .zuliprc configuration file" in 305 - Arg.(value & opt (some string) None & info ["c"; "config"] ~docv:"FILE" ~doc) 168 + Arg.(value & opt (some string) None & info [ "c"; "config" ] ~docv:"FILE" ~doc) 306 169 307 170 let verbosity = 308 171 let doc = "Increase verbosity. Use multiple times for more verbose output." in 309 - Arg.(value & flag_all & info ["v"; "verbose"] ~doc) 172 + Arg.(value & flag_all & info [ "v"; "verbose" ] ~doc) 310 173 311 - let verbosity_term = 312 - Term.(const List.length $ verbosity) 174 + let verbosity_term = Term.(const List.length $ verbosity) 313 175 314 176 let bot_cmd eio_env = 315 177 let doc = "Zulip Echo Bot with verbose logging" in 316 - let man = [ 317 - `S Manpage.s_description; 318 - `P "A simple echo bot for Zulip that responds to messages by echoing them back. \ 319 - Features verbose logging for debugging and development."; 320 - `S "CONFIGURATION"; 321 - `P "The bot reads configuration from a .zuliprc file (default: ~/.zuliprc)."; 322 - `P "The file should contain:"; 323 - `Pre "[api]\n\ 324 - email=bot@example.com\n\ 325 - key=your-api-key\n\ 326 - site=https://your-domain.zulipchat.com"; 327 - `S "LOGGING"; 328 - `P "Use -v for info level logging, -vv for debug level logging."; 329 - `P "Log messages include:"; 330 - `P "- Message metadata (sender, type, ID)"; 331 - `P "- Message processing steps"; 332 - `P "- Bot responses"; 333 - `P "- Error conditions"; 334 - `S "COMMANDS"; 335 - `P "The bot responds to:"; 336 - `P "- 'help' - Show usage information"; 337 - `P "- 'ping' - Respond with 'Pong!'"; 338 - `P "- Any other message - Echo it back"; 339 - `S Manpage.s_examples; 340 - `P "Run with default configuration:"; 341 - `Pre " echo_bot"; 342 - `P "Run with verbose logging:"; 343 - `Pre " echo_bot -v"; 344 - `P "Run with debug logging:"; 345 - `Pre " echo_bot -vv"; 346 - `P "Run with custom config file:"; 347 - `Pre " echo_bot -c /path/to/.zuliprc"; 348 - `P "Run with maximum verbosity and custom config:"; 349 - `Pre " echo_bot -vv -c ~/my-bot.zuliprc"; 350 - `S Manpage.s_bugs; 351 - `P "Report bugs at https://github.com/your-org/zulip-ocaml/issues"; 352 - `S Manpage.s_see_also; 353 - `P "zulip(1), zulip-bot(1)"; 354 - ] in 355 - let info = Cmd.info "echo_bot" ~version:"1.0.0" ~doc ~man in 356 - Cmd.v info Term.(const (run_echo_bot) $ config_file $ verbosity_term $ const eio_env) 178 + let man = 179 + [ 180 + `S Manpage.s_description; 181 + `P 182 + "A simple echo bot for Zulip that responds to messages by echoing them \ 183 + back. Features verbose logging for debugging and development."; 184 + `S "CONFIGURATION"; 185 + `P 186 + "The bot reads configuration from XDG config directory \ 187 + (~/.config/zulip-bot/echo-bot/config) or from a .zuliprc file."; 188 + `S "LOGGING"; 189 + `P "Use -v for info level logging, -vv for debug level logging."; 190 + `S "COMMANDS"; 191 + `P "The bot responds to:"; 192 + `P "- 'help' - Show usage information"; 193 + `P "- 'ping' - Respond with 'Pong!'"; 194 + `P "- 'store <key> <value>' - Store a value"; 195 + `P "- 'get <key>' - Retrieve a value"; 196 + `P "- 'delete <key>' - Delete a stored value"; 197 + `P "- 'list' - List all stored keys"; 198 + `P "- Any other message - Echo it back"; 199 + ] 200 + in 201 + let info = Cmd.info "echo_bot" ~version:"2.0.0" ~doc ~man in 202 + Cmd.v info 203 + Term.(const run_echo_bot $ config_file $ verbosity_term $ const eio_env) 357 204 358 205 let () = 359 - (* Initialize the cryptographic RNG for the application *) 360 206 Mirage_crypto_rng_unix.use_default (); 361 - Eio_main.run @@ fun env -> 362 - exit (Cmd.eval (bot_cmd env)) 207 + Eio_main.run @@ fun env -> exit (Cmd.eval (bot_cmd env))
+40 -66
examples/test_realtime_bot.ml
··· 4 4 5 5 (* Logging setup *) 6 6 let src = Logs.Src.create "test_realtime_bot" ~doc:"Test real-time bot" 7 + 7 8 module Log = (val Logs.src_log src : Logs.LOG) 8 9 9 - (* Simple test bot that logs everything *) 10 - module Test_bot_handler : Bot_handler.S = struct 11 - let initialize _config = 12 - Log.info (fun m -> m "Bot initialized") 10 + (* Simple test bot handler that logs everything *) 11 + let test_handler ~storage ~identity:_ message = 12 + Log.info (fun m -> m "Received message"); 13 13 14 - let usage () = "Test Bot - Verifies real-time event processing" 14 + let content = Message.content message in 15 + let sender = Message.sender_email message in 16 + let is_direct = Message.is_private message in 15 17 16 - let description () = "A test bot that logs all messages received" 18 + Log.info (fun m -> m "Content: %s" content); 19 + Log.info (fun m -> m "Sender: %s" sender); 20 + Log.info (fun m -> m "Direct: %b" is_direct); 17 21 18 - let handle_message ~config:_ ~storage ~identity:_ ~message ~env:_ = 19 - Log.info (fun m -> m "Received message"); 22 + (* Test storage *) 23 + (try 24 + Storage.set storage "last_message" content; 25 + Log.info (fun m -> m "Stored message in bot storage") 26 + with Eio.Exn.Io _ as e -> 27 + Log.err (fun m -> m "Storage error: %s" (Printexc.to_string e))); 20 28 21 - (* Extract and log message details *) 22 - let content = Message.content message in 23 - let sender = Message.sender_email message in 24 - let is_direct = Message.is_private message in 25 - 26 - Log.info (fun m -> m "Content: %s" content); 27 - Log.info (fun m -> m "Sender: %s" sender); 28 - Log.info (fun m -> m "Direct: %b" is_direct); 29 - 30 - (* Test storage *) 31 - let test_key = "last_message" in 32 - let test_value = content in 33 - 34 - (try 35 - Bot_storage.put storage ~key:test_key ~value:test_value; 36 - Log.info (fun m -> m "Stored message in bot storage") 37 - with Eio.Exn.Io _ as e -> 38 - Log.err (fun m -> m "Storage error: %s" (Printexc.to_string e))); 39 - 40 - (* Always reply with confirmation *) 41 - let reply = Printf.sprintf "Test bot received: %s" content in 42 - Bot_handler.Response.Reply reply 43 - end 29 + (* Always reply with confirmation *) 30 + Response.reply (Printf.sprintf "Test bot received: %s" content) 44 31 45 32 let run_test verbosity env = 46 33 (* Setup logging *) 47 34 Logs.set_reporter (Logs_fmt.reporter ()); 48 - Logs.set_level (Some (match verbosity with 49 - | 0 -> Logs.Info 50 - | 1 -> Logs.Debug 51 - | _ -> Logs.Debug)); 35 + Logs.set_level 36 + (Some (match verbosity with 0 -> Logs.Info | 1 -> Logs.Debug | _ -> Logs.Debug)); 52 37 53 38 Log.info (fun m -> m "Real-time Bot Test"); 54 39 Log.info (fun m -> m "=================="); 55 40 56 - (* Load auth *) 41 + (* Load auth from .zuliprc *) 57 42 let auth = 58 43 try 59 44 let a = Zulip.Auth.from_zuliprc () in ··· 66 51 in 67 52 68 53 Eio.Switch.run @@ fun sw -> 69 - let client = Zulip.Client.create ~sw env auth in 70 - 71 - (* Create bot components *) 72 - let config = Bot_config.create [] in 73 - let bot_email = Zulip.Auth.email auth in 74 - let storage = Bot_storage.create client ~bot_email in 75 - let identity = Bot_handler.Identity.create 76 - ~full_name:"Test Bot" 77 - ~email:bot_email 78 - ~mention_name:"testbot" 54 + (* Create configuration from auth *) 55 + let config = 56 + Config.create ~name:"test-bot" ~site:(Zulip.Auth.server_url auth) 57 + ~email:(Zulip.Auth.email auth) ~api_key:(Zulip.Auth.api_key auth) 58 + ~description:"A test bot that logs all messages received" () 79 59 in 80 60 81 - (* Create handler and runner *) 82 - let handler = Bot_handler.create 83 - (module Test_bot_handler) 84 - ~config ~storage ~identity 85 - in 86 - let runner = Bot_runner.create ~env ~client ~handler in 87 - 88 61 Log.info (fun m -> m "Starting bot in real-time mode..."); 89 62 Log.info (fun m -> m "The bot will:"); 90 63 Log.info (fun m -> m "- Register for message events"); ··· 93 66 Log.info (fun m -> m "- Store messages in Zulip bot storage"); 94 67 Log.info (fun m -> m "Press Ctrl+C to stop."); 95 68 96 - (* Run the bot *) 97 - Bot_runner.run_realtime runner 69 + (* Run the bot - now just a simple function call *) 70 + Bot.run ~sw ~env ~config ~handler:test_handler 98 71 99 72 (* Command-line interface *) 100 73 open Cmdliner 101 74 102 75 let verbosity = 103 76 let doc = "Increase verbosity (can be used multiple times)" in 104 - let verbosity_flags = Arg.(value & flag_all & info ["v"; "verbose"] ~doc) in 77 + let verbosity_flags = Arg.(value & flag_all & info [ "v"; "verbose" ] ~doc) in 105 78 Term.(const List.length $ verbosity_flags) 106 79 107 80 let main_cmd = 108 81 let doc = "Test real-time bot for Zulip" in 109 - let man = [ 110 - `S Manpage.s_description; 111 - `P "This bot tests real-time event processing with the Zulip API. \ 112 - It will echo received messages and store them in bot storage."; 113 - `P "The bot requires a configured ~/.zuliprc file with API credentials."; 114 - ] in 115 - let info = Cmd.info "test_realtime_bot" ~version:"1.0.0" ~doc ~man in 116 - let run verbosity = 117 - Eio_main.run (run_test verbosity) 82 + let man = 83 + [ 84 + `S Manpage.s_description; 85 + `P 86 + "This bot tests real-time event processing with the Zulip API. It will \ 87 + echo received messages and store them in bot storage."; 88 + `P "The bot requires a configured ~/.zuliprc file with API credentials."; 89 + ] 118 90 in 91 + let info = Cmd.info "test_realtime_bot" ~version:"2.0.0" ~doc ~man in 92 + let run verbosity = Eio_main.run (run_test verbosity) in 119 93 let term = Term.(const run $ verbosity) in 120 94 Cmd.v info term 121 95 122 - let () = exit (Cmd.eval main_cmd) 96 + let () = exit (Cmd.eval main_cmd)
+1
lib/zulip/auth.ml
··· 98 98 99 99 let server_url t = t.server_url 100 100 let email t = t.email 101 + let api_key t = t.api_key 101 102 102 103 let to_basic_auth_header t = 103 104 match Base64.encode (t.email ^ ":" ^ t.api_key) with
+1
lib/zulip/auth.mli
··· 15 15 16 16 val server_url : t -> string 17 17 val email : t -> string 18 + val api_key : t -> string 18 19 val to_basic_auth_header : t -> string 19 20 val pp : Format.formatter -> t -> unit
+182
lib/zulip_bot/bot.ml
··· 1 + let src = Logs.Src.create "zulip_bot.bot" ~doc:"Zulip bot runner" 2 + 3 + module Log = (val Logs.src_log src : Logs.LOG) 4 + 5 + type identity = { user_id : int; email : string; full_name : string } 6 + type handler = storage:Storage.t -> identity:identity -> Message.t -> Response.t 7 + 8 + let create_client ~sw ~env ~config = 9 + let auth = 10 + Zulip.Auth.create ~server_url:config.Config.site ~email:config.Config.email 11 + ~api_key:config.Config.api_key 12 + in 13 + Zulip.Client.create ~sw env auth 14 + 15 + let fetch_identity client = 16 + let json = Zulip.Client.request client ~method_:`GET ~path:"/api/v1/users/me" () in 17 + match json with 18 + | Jsont.Object (fields, _) -> 19 + let assoc = List.map (fun ((k, _), v) -> (k, v)) fields in 20 + let get_int key = 21 + match List.assoc_opt key assoc with 22 + | Some (Jsont.Number (f, _)) -> int_of_float f 23 + | _ -> 0 24 + in 25 + let get_string key = 26 + match List.assoc_opt key assoc with 27 + | Some (Jsont.String (s, _)) -> s 28 + | _ -> "" 29 + in 30 + { user_id = get_int "user_id"; email = get_string "email"; full_name = get_string "full_name" } 31 + | _ -> 32 + Log.warn (fun m -> m "Unexpected response format from /users/me"); 33 + { user_id = 0; email = ""; full_name = "" } 34 + 35 + let send_response client ~in_reply_to response = 36 + match response with 37 + | Response.Reply content -> 38 + let message_to_send = 39 + if Message.is_private in_reply_to then 40 + let sender = Message.sender_email in_reply_to in 41 + Zulip.Message.create ~type_:`Direct ~to_:[ sender ] ~content () 42 + else 43 + let reply_to = Message.get_reply_to in_reply_to in 44 + let topic = 45 + match in_reply_to with 46 + | Message.Stream { subject; _ } -> Some subject 47 + | _ -> None 48 + in 49 + Zulip.Message.create ~type_:`Channel ~to_:[ reply_to ] ~content ?topic 50 + () 51 + in 52 + let resp = Zulip.Messages.send client message_to_send in 53 + Log.info (fun m -> 54 + m "Reply sent (id: %d)" (Zulip.Message_response.id resp)) 55 + | Response.Direct { recipients; content } -> 56 + let message_to_send = 57 + Zulip.Message.create ~type_:`Direct ~to_:recipients ~content () 58 + in 59 + let resp = Zulip.Messages.send client message_to_send in 60 + Log.info (fun m -> 61 + m "Direct message sent (id: %d)" (Zulip.Message_response.id resp)) 62 + | Response.Stream { stream; topic; content } -> 63 + let message_to_send = 64 + Zulip.Message.create ~type_:`Channel ~to_:[ stream ] ~topic ~content () 65 + in 66 + let resp = Zulip.Messages.send client message_to_send in 67 + Log.info (fun m -> 68 + m "Stream message sent (id: %d)" (Zulip.Message_response.id resp)) 69 + | Response.Silent -> Log.debug (fun m -> m "Handler returned silent response") 70 + 71 + let process_event ~client ~storage ~identity ~handler event = 72 + Log.debug (fun m -> 73 + m "Processing event type: %s" 74 + (Zulip.Event_type.to_string (Zulip.Event.type_ event))); 75 + match Zulip.Event.type_ event with 76 + | Zulip.Event_type.Message -> ( 77 + let event_data = Zulip.Event.data event in 78 + let message_json, flags = 79 + match event_data with 80 + | Jsont.Object (fields, _) -> 81 + let assoc = List.map (fun ((k, _), v) -> (k, v)) fields in 82 + let msg = 83 + match List.assoc_opt "message" assoc with 84 + | Some m -> m 85 + | None -> event_data 86 + in 87 + let flgs = 88 + match List.assoc_opt "flags" assoc with 89 + | Some (Jsont.Array (f, _)) -> f 90 + | _ -> [] 91 + in 92 + (msg, flgs) 93 + | _ -> (event_data, []) 94 + in 95 + match Message.of_json message_json with 96 + | Error err -> 97 + Log.err (fun m -> m "Failed to parse message JSON: %s" err); 98 + Log.debug (fun m -> m "@[%a@]" Message.pp_json_debug message_json) 99 + | Ok message -> ( 100 + Log.info (fun m -> 101 + m "@[<h>%a@]" (Message.pp_ansi ~show_json:false) message); 102 + let is_mentioned = 103 + List.exists 104 + (function Jsont.String ("mentioned", _) -> true | _ -> false) 105 + flags 106 + || Message.is_mentioned message ~user_email:identity.email 107 + in 108 + let is_private = Message.is_private message in 109 + let is_from_self = Message.is_from_email message ~email:identity.email in 110 + Log.debug (fun m -> 111 + m "Message check: mentioned=%b, private=%b, from_self=%b" 112 + is_mentioned is_private is_from_self); 113 + if (is_mentioned || is_private) && not is_from_self then ( 114 + Log.info (fun m -> m "Bot should respond to this message"); 115 + try 116 + let response = handler ~storage ~identity message in 117 + send_response client ~in_reply_to:message response 118 + with Eio.Exn.Io (e, _) -> 119 + Log.err (fun m -> m "Error handling message: %a" Eio.Exn.pp_err e)) 120 + else 121 + Log.debug (fun m -> 122 + m "Not processing (not mentioned and not private)"))) 123 + | _ -> () 124 + 125 + let run ~sw ~env ~config ~handler = 126 + Log.info (fun m -> m "Starting bot: %s" config.Config.name); 127 + let client = create_client ~sw ~env ~config in 128 + let identity = fetch_identity client in 129 + let storage = Storage.create client in 130 + Log.info (fun m -> 131 + m "Bot identity: %s <%s> (id: %d)" identity.full_name identity.email 132 + identity.user_id); 133 + let queue = 134 + Zulip.Event_queue.register client 135 + ~event_types:[ Zulip.Event_type.Message ] 136 + () 137 + in 138 + Log.info (fun m -> m "Event queue registered: %s" (Zulip.Event_queue.id queue)); 139 + let rec event_loop last_event_id = 140 + try 141 + let events = 142 + Zulip.Event_queue.get_events queue client ~last_event_id () 143 + in 144 + if List.length events > 0 then 145 + Log.info (fun m -> m "Received %d event(s)" (List.length events)); 146 + List.iter 147 + (fun event -> 148 + Log.debug (fun m -> 149 + m "Event id=%d, type=%s" (Zulip.Event.id event) 150 + (Zulip.Event_type.to_string (Zulip.Event.type_ event))); 151 + process_event ~client ~storage ~identity ~handler event) 152 + events; 153 + let new_last_id = 154 + List.fold_left 155 + (fun max_id event -> max (Zulip.Event.id event) max_id) 156 + last_event_id events 157 + in 158 + event_loop new_last_id 159 + with Eio.Exn.Io (e, _) -> 160 + Log.warn (fun m -> 161 + m "Error getting events: %a (retrying in 2s)" Eio.Exn.pp_err e); 162 + Eio.Time.sleep env#clock 2.0; 163 + event_loop last_event_id 164 + in 165 + event_loop (-1) 166 + 167 + let handle_webhook ~sw ~env ~config ~handler ~payload = 168 + let client = create_client ~sw ~env ~config in 169 + let identity = fetch_identity client in 170 + let storage = Storage.create client in 171 + match Jsont_bytesrw.decode_string Jsont.json payload with 172 + | Error _ -> 173 + Log.err (fun m -> m "Failed to parse webhook payload as JSON"); 174 + None 175 + | Ok json -> ( 176 + match Message.of_json json with 177 + | Error err -> 178 + Log.err (fun m -> m "Failed to parse webhook message: %s" err); 179 + None 180 + | Ok message -> 181 + let response = handler ~storage ~identity message in 182 + Some response)
+131
lib/zulip_bot/bot.mli
··· 1 + (** Fiber-based Zulip bot execution. 2 + 3 + A bot is simply a function that processes messages. The [run] function 4 + executes the bot as an Eio fiber, making it easy to compose multiple bots 5 + using standard Eio concurrency primitives. 6 + 7 + {b Example: Single bot} 8 + {[ 9 + let echo_handler ~storage:_ ~identity:_ msg = 10 + Response.reply ("Echo: " ^ Message.content msg) 11 + 12 + let () = 13 + Eio_main.run @@ fun env -> 14 + Eio.Switch.run @@ fun sw -> 15 + let fs = Eio.Stdenv.fs env in 16 + let config = Config.load ~fs "echo-bot" in 17 + Bot.run ~sw ~env ~config ~handler:echo_handler 18 + ]} 19 + 20 + {b Example: Multiple bots} 21 + {[ 22 + let () = 23 + Eio_main.run @@ fun env -> 24 + Eio.Switch.run @@ fun sw -> 25 + let fs = Eio.Stdenv.fs env in 26 + 27 + Eio.Fiber.all [ 28 + (fun () -> Bot.run ~sw ~env 29 + ~config:(Config.load ~fs "echo-bot") 30 + ~handler:echo_handler); 31 + (fun () -> Bot.run ~sw ~env 32 + ~config:(Config.load ~fs "help-bot") 33 + ~handler:help_handler); 34 + ] 35 + ]} 36 + *) 37 + 38 + (** {1 Types} *) 39 + 40 + type identity = { 41 + user_id : int; (** Bot's user ID on the server *) 42 + email : string; (** Bot's email address *) 43 + full_name : string; (** Bot's display name *) 44 + } 45 + (** Bot identity information retrieved from Zulip. *) 46 + 47 + type handler = storage:Storage.t -> identity:identity -> Message.t -> Response.t 48 + (** Handler function signature. 49 + 50 + A handler receives: 51 + - [storage]: Key-value storage via Zulip's bot storage API 52 + - [identity]: The bot's identity (email, name, user_id) 53 + - The incoming [Message.t] 54 + 55 + And returns a [Response.t] indicating what action to take. *) 56 + 57 + (** {1 Running Bots} *) 58 + 59 + val run : 60 + sw:Eio.Switch.t -> 61 + env:< clock : float Eio.Time.clock_ty Eio.Resource.t 62 + ; net : [ `Generic | `Unix ] Eio.Net.ty Eio.Resource.t 63 + ; fs : Eio.Fs.dir_ty Eio.Path.t 64 + ; .. > -> 65 + config:Config.t -> 66 + handler:handler -> 67 + unit 68 + (** [run ~sw ~env ~config ~handler] runs a bot as a fiber. 69 + 70 + The bot connects to Zulip's real-time events API and processes incoming 71 + messages. It runs until the switch is cancelled. 72 + 73 + The bot will: 74 + - Register for message events with Zulip 75 + - Process private messages and messages that mention the bot 76 + - Ignore messages from itself 77 + - Send responses back via the Zulip API 78 + - Automatically reconnect with exponential backoff on errors 79 + 80 + @param sw Eio switch controlling the bot's lifetime 81 + @param env Eio environment with clock, net, and fs capabilities 82 + @param config Bot configuration (credentials and metadata) 83 + @param handler Function to process incoming messages *) 84 + 85 + (** {1 Webhook Mode} *) 86 + 87 + val handle_webhook : 88 + sw:Eio.Switch.t -> 89 + env:< clock : float Eio.Time.clock_ty Eio.Resource.t 90 + ; net : [ `Generic | `Unix ] Eio.Net.ty Eio.Resource.t 91 + ; fs : Eio.Fs.dir_ty Eio.Path.t 92 + ; .. > -> 93 + config:Config.t -> 94 + handler:handler -> 95 + payload:string -> 96 + Response.t option 97 + (** [handle_webhook ~sw ~env ~config ~handler ~payload] processes a single webhook payload. 98 + 99 + For webhook-based deployments, provide your own HTTP server and call this 100 + function to process incoming webhook payloads from Zulip. 101 + 102 + Returns [Some response] if the message was processed, [None] if the payload 103 + could not be parsed or should not be handled. *) 104 + 105 + val send_response : 106 + Zulip.Client.t -> in_reply_to:Message.t -> Response.t -> unit 107 + (** [send_response client ~in_reply_to response] sends a response via the Zulip API. 108 + 109 + Utility function for webhook mode to send responses after processing. 110 + The [in_reply_to] message is used to determine the reply context (stream/topic 111 + or private message recipients). *) 112 + 113 + (** {1 Utilities} *) 114 + 115 + val create_client : 116 + sw:Eio.Switch.t -> 117 + env:< clock : float Eio.Time.clock_ty Eio.Resource.t 118 + ; net : [ `Generic | `Unix ] Eio.Net.ty Eio.Resource.t 119 + ; fs : Eio.Fs.dir_ty Eio.Path.t 120 + ; .. > -> 121 + config:Config.t -> 122 + Zulip.Client.t 123 + (** [create_client ~sw ~env ~config] creates a Zulip client from bot configuration. 124 + 125 + Useful when you need direct access to the Zulip API beyond what the bot 126 + framework provides. *) 127 + 128 + val fetch_identity : Zulip.Client.t -> identity 129 + (** [fetch_identity client] retrieves the bot's identity from the Zulip server. 130 + 131 + @raise Eio.Io on API errors *)
-127
lib/zulip_bot/bot_config.ml
··· 1 - type t = (string, string) Hashtbl.t 2 - 3 - let create pairs = 4 - let config = Hashtbl.create (List.length pairs) in 5 - List.iter (fun (k, v) -> Hashtbl.replace config k v) pairs; 6 - config 7 - 8 - let from_file path = 9 - try 10 - let content = 11 - let ic = open_in path in 12 - let content = really_input_string ic (in_channel_length ic) in 13 - close_in ic; 14 - content 15 - in 16 - 17 - (* Simple INI-style parser for config files *) 18 - let lines = String.split_on_char '\n' content in 19 - let config = Hashtbl.create 16 in 20 - let current_section = ref "" in 21 - 22 - List.iter 23 - (fun line -> 24 - let line = String.trim line in 25 - if String.length line > 0 && line.[0] <> '#' && line.[0] <> ';' then 26 - if 27 - String.length line > 2 28 - && line.[0] = '[' 29 - && line.[String.length line - 1] = ']' 30 - then 31 - (* Section header *) 32 - current_section := String.sub line 1 (String.length line - 2) 33 - else 34 - (* Key-value pair *) 35 - match String.index_opt line '=' with 36 - | Some idx -> 37 - let key = String.trim (String.sub line 0 idx) in 38 - let value = 39 - String.trim 40 - (String.sub line (idx + 1) (String.length line - idx - 1)) 41 - in 42 - (* Remove quotes if present *) 43 - let value = 44 - if 45 - String.length value >= 2 46 - && ((value.[0] = '"' && value.[String.length value - 1] = '"') 47 - || (value.[0] = '\'' 48 - && value.[String.length value - 1] = '\'')) 49 - then String.sub value 1 (String.length value - 2) 50 - else value 51 - in 52 - let full_key = 53 - if !current_section = "" then key 54 - else if 55 - !current_section = "bot" || !current_section = "features" 56 - then (* For bot and features sections, use flat keys *) 57 - key 58 - else !current_section ^ "." ^ key 59 - in 60 - Hashtbl.replace config full_key value 61 - | None -> ()) 62 - lines; 63 - 64 - config 65 - with 66 - | Eio.Exn.Io _ as ex -> raise ex 67 - | Sys_error msg -> 68 - let err = 69 - Zulip.create_error ~code:(Other "file_error") 70 - ~msg:("Cannot read config file: " ^ msg) 71 - () 72 - in 73 - raise (Eio.Exn.add_context (Zulip.err err) "reading config from %s" path) 74 - | exn -> 75 - let err = 76 - Zulip.create_error ~code:(Other "parse_error") 77 - ~msg:("Error parsing config: " ^ Printexc.to_string exn) 78 - () 79 - in 80 - raise (Eio.Exn.add_context (Zulip.err err) "parsing config from %s" path) 81 - 82 - let from_env ~prefix = 83 - try 84 - let config = Hashtbl.create 16 in 85 - let env_vars = Array.to_list (Unix.environment ()) in 86 - 87 - List.iter 88 - (fun env_var -> 89 - match String.split_on_char '=' env_var with 90 - | key :: value_parts 91 - when String.length key > String.length prefix 92 - && String.sub key 0 (String.length prefix) = prefix -> 93 - let config_key = 94 - String.sub key (String.length prefix) 95 - (String.length key - String.length prefix) 96 - in 97 - let value = String.concat "=" value_parts in 98 - Hashtbl.replace config config_key value 99 - | _ -> ()) 100 - env_vars; 101 - 102 - config 103 - with 104 - | Eio.Exn.Io _ as ex -> raise ex 105 - | exn -> 106 - let err = 107 - Zulip.create_error ~code:(Other "env_error") 108 - ~msg:("Error reading environment: " ^ Printexc.to_string exn) 109 - () 110 - in 111 - raise (Eio.Exn.add_context (Zulip.err err) "reading env with prefix %s" prefix) 112 - 113 - let get t ~key = Hashtbl.find_opt t key 114 - 115 - let get_required t ~key = 116 - match Hashtbl.find_opt t key with 117 - | Some value -> value 118 - | None -> 119 - let err = 120 - Zulip.create_error ~code:(Other "config_missing") 121 - ~msg:("Required config key missing: " ^ key) 122 - () 123 - in 124 - raise (Zulip.err err) 125 - 126 - let has_key t ~key = Hashtbl.mem t key 127 - let keys t = Hashtbl.fold (fun k _ acc -> k :: acc) t []
-29
lib/zulip_bot/bot_config.mli
··· 1 - (** Configuration management for bots. 2 - 3 - All functions that can fail raise [Eio.Io] with [Zulip.E error]. *) 4 - 5 - type t 6 - 7 - val create : (string * string) list -> t 8 - (** Create configuration from key-value pairs *) 9 - 10 - val from_file : string -> t 11 - (** Load configuration from file. 12 - @raise Eio.Io on file read or parse errors *) 13 - 14 - val from_env : prefix:string -> t 15 - (** Load configuration from environment variables with prefix. 16 - @raise Eio.Io if no matching variables found *) 17 - 18 - val get : t -> key:string -> string option 19 - (** Get a configuration value *) 20 - 21 - val get_required : t -> key:string -> string 22 - (** Get a required configuration value. 23 - @raise Eio.Io if key not present *) 24 - 25 - val has_key : t -> key:string -> bool 26 - (** Check if a key exists in configuration *) 27 - 28 - val keys : t -> string list 29 - (** Get all configuration keys *)
-70
lib/zulip_bot/bot_handler.ml
··· 1 - module Response = struct 2 - type t = 3 - | Reply of string 4 - | DirectMessage of { to_ : string; content : string } 5 - | ChannelMessage of { channel : string; topic : string; content : string } 6 - | None 7 - 8 - let none = None 9 - let reply content = Reply content 10 - let direct_message ~to_ ~content = DirectMessage { to_; content } 11 - 12 - let channel_message ~channel ~topic ~content = 13 - ChannelMessage { channel; topic; content } 14 - end 15 - 16 - module Identity = struct 17 - type t = { 18 - full_name : string; 19 - email : string; 20 - mention_name : string; 21 - } 22 - 23 - let create ~full_name ~email ~mention_name = { full_name; email; mention_name } 24 - let full_name t = t.full_name 25 - let email t = t.email 26 - let mention_name t = t.mention_name 27 - end 28 - 29 - (** Module signature for bot implementations *) 30 - module type Bot_handler = sig 31 - val initialize : Bot_config.t -> unit 32 - val usage : unit -> string 33 - val description : unit -> string 34 - 35 - val handle_message : 36 - config:Bot_config.t -> 37 - storage:Bot_storage.t -> 38 - identity:Identity.t -> 39 - message:Message.t -> 40 - env:_ -> 41 - Response.t 42 - end 43 - 44 - module type S = Bot_handler 45 - 46 - type t = { 47 - module_impl : (module Bot_handler); 48 - config : Bot_config.t; 49 - storage : Bot_storage.t; 50 - identity : Identity.t; 51 - } 52 - 53 - let create module_impl ~config ~storage ~identity = 54 - { module_impl; config; storage; identity } 55 - 56 - (* Main message handling function - requires environment for proper EIO operations *) 57 - let handle_message_with_env t env message = 58 - let module Handler = (val t.module_impl) in 59 - Handler.handle_message ~config:t.config ~storage:t.storage 60 - ~identity:t.identity ~message ~env 61 - 62 - let identity t = t.identity 63 - 64 - let usage t = 65 - let module Handler = (val t.module_impl) in 66 - Handler.usage () 67 - 68 - let description t = 69 - let module Handler = (val t.module_impl) in 70 - Handler.description ()
-77
lib/zulip_bot/bot_handler.mli
··· 1 - (** Bot handler framework for Zulip bots. 2 - 3 - Functions that can fail raise [Eio.Io] with [Zulip.E error]. *) 4 - 5 - (** Response types that bots can return *) 6 - module Response : sig 7 - type t = 8 - | Reply of string 9 - | DirectMessage of { to_ : string; content : string } 10 - | ChannelMessage of { channel : string; topic : string; content : string } 11 - | None 12 - 13 - val none : t 14 - val reply : string -> t 15 - val direct_message : to_:string -> content:string -> t 16 - val channel_message : channel:string -> topic:string -> content:string -> t 17 - end 18 - 19 - (** Bot identity information *) 20 - module Identity : sig 21 - type t 22 - 23 - val create : full_name:string -> email:string -> mention_name:string -> t 24 - val full_name : t -> string 25 - val email : t -> string 26 - val mention_name : t -> string 27 - end 28 - 29 - (** Module signature for bot implementations *) 30 - module type Bot_handler = sig 31 - val initialize : Bot_config.t -> unit 32 - (** Initialize the bot (called once on startup). 33 - @raise Eio.Io on failure *) 34 - 35 - val usage : unit -> string 36 - (** Provide usage/help text *) 37 - 38 - val description : unit -> string 39 - (** Provide bot description *) 40 - 41 - val handle_message : 42 - config:Bot_config.t -> 43 - storage:Bot_storage.t -> 44 - identity:Identity.t -> 45 - message:Message.t -> 46 - env:_ -> 47 - Response.t 48 - (** Handle an incoming message with EIO environment. 49 - @raise Eio.Io on failure *) 50 - end 51 - 52 - (** Shorter alias for Bot_handler *) 53 - module type S = Bot_handler 54 - 55 - (** Abstract bot handler *) 56 - type t 57 - 58 - val create : 59 - (module Bot_handler) -> 60 - config:Bot_config.t -> 61 - storage:Bot_storage.t -> 62 - identity:Identity.t -> 63 - t 64 - (** Create a bot handler from a module *) 65 - 66 - val handle_message_with_env : t -> _ -> Message.t -> Response.t 67 - (** Process an incoming message with EIO environment. 68 - @raise Eio.Io on failure *) 69 - 70 - val identity : t -> Identity.t 71 - (** Get bot identity *) 72 - 73 - val usage : t -> string 74 - (** Get bot usage text *) 75 - 76 - val description : t -> string 77 - (** Get bot description *)
-280
lib/zulip_bot/bot_runner.ml
··· 1 - (* Logging setup *) 2 - let src = Logs.Src.create "zulip_bot.runner" ~doc:"Zulip bot runner" 3 - 4 - module Log = (val Logs.src_log src : Logs.LOG) 5 - 6 - (* Initialize crypto RNG - now done at module load time via Mirage_crypto_rng_unix *) 7 - let () = 8 - try 9 - let _ = 10 - Mirage_crypto_rng.generate ~g:(Mirage_crypto_rng.default_generator ()) 0 11 - in 12 - () 13 - with _ -> 14 - (* Generator not initialized - this will be done by applications using the library *) 15 - () 16 - 17 - type 'env t = { 18 - client : Zulip.Client.t; 19 - handler : Bot_handler.t; 20 - mutable running : bool; 21 - storage : Bot_storage.t; 22 - env : 'env; 23 - } 24 - 25 - let create ~env ~client ~handler = 26 - let bot_email = 27 - (* Get bot email from handler identity *) 28 - Bot_handler.Identity.email (Bot_handler.identity handler) 29 - in 30 - Log.info (fun m -> m "Creating bot runner for %s" bot_email); 31 - let storage = Bot_storage.create client ~bot_email in 32 - { client; handler; running = false; storage; env } 33 - 34 - (* Helper to extract clock from environment *) 35 - (* The environment should have a #clock method *) 36 - let get_clock (env : < clock : float Eio.Time.clock_ty Eio.Resource.t ; .. >) = 37 - env#clock 38 - 39 - let process_event t event = 40 - (* Check if this is a message event *) 41 - Log.debug (fun m -> 42 - m "Processing event type: %s" 43 - (Zulip.Event_type.to_string (Zulip.Event.type_ event))); 44 - match Zulip.Event.type_ event with 45 - | Zulip.Event_type.Message -> ( 46 - (* Get the message data from the event *) 47 - let event_data = Zulip.Event.data event in 48 - 49 - (* Extract the actual message from the event *) 50 - let message_json, flags = 51 - match event_data with 52 - | Jsont.Object (fields, _) -> 53 - let assoc = List.map (fun ((k, _), v) -> (k, v)) fields in 54 - let msg = 55 - match List.assoc_opt "message" assoc with 56 - | Some m -> m 57 - | None -> event_data (* Fallback if structure is different *) 58 - in 59 - let flgs = 60 - match List.assoc_opt "flags" assoc with 61 - | Some (Jsont.Array (f, _)) -> f 62 - | _ -> [] 63 - in 64 - (msg, flgs) 65 - | _ -> (event_data, []) 66 - in 67 - 68 - (* Parse the message JSON into Message.t *) 69 - match Message.of_json message_json with 70 - | Error err -> 71 - Log.err (fun m -> m "Failed to parse message JSON: %s" err); 72 - (* Show raw JSON for debugging *) 73 - Log.debug (fun m -> m "@[%a@]" Message.pp_json_debug message_json) 74 - | Ok message -> ( 75 - (* Log the parsed message with colors *) 76 - Log.info (fun m -> 77 - m "@[<h>%a@]" (Message.pp_ansi ~show_json:false) message); 78 - 79 - (* Get bot identity for checking mentions *) 80 - let bot_email = 81 - Bot_handler.Identity.email (Bot_handler.identity t.handler) 82 - in 83 - 84 - (* Check if mentioned *) 85 - let is_mentioned = 86 - List.exists 87 - (function Jsont.String ("mentioned", _) -> true | _ -> false) 88 - flags 89 - || Message.is_mentioned message ~user_email:bot_email 90 - in 91 - 92 - (* Check if it's a private message *) 93 - let is_private = Message.is_private message in 94 - 95 - (* Don't respond to our own messages *) 96 - let is_from_self = Message.is_from_email message ~email:bot_email in 97 - 98 - (* Log what we found *) 99 - Log.debug (fun m -> 100 - m "Message check: mentioned=%b, private=%b, from_self=%b" 101 - is_mentioned is_private is_from_self); 102 - 103 - (* Only process if bot was mentioned or it's a private message, and not from self *) 104 - if (is_mentioned || is_private) && not is_from_self then ( 105 - Log.info (fun m -> m "Bot should respond to this message"); 106 - 107 - (* Handle the message using exception-based handling *) 108 - try 109 - let response = 110 - Bot_handler.handle_message_with_env t.handler t.env message 111 - in 112 - match response with 113 - | Bot_handler.Response.Reply content -> 114 - Log.debug (fun m -> m "Bot is sending reply: %s" content); 115 - (* Send reply back using Message utilities *) 116 - let message_to_send = 117 - if Message.is_private message then ( 118 - (* Reply to private message *) 119 - let sender = Message.sender_email message in 120 - Log.debug (fun m -> m "Replying to sender: %s" sender); 121 - Zulip.Message.create ~type_:`Direct ~to_:[ sender ] 122 - ~content ()) 123 - else 124 - (* Reply to stream message *) 125 - let reply_to = Message.get_reply_to message in 126 - let topic = 127 - match message with 128 - | Message.Stream { subject; _ } -> Some subject 129 - | _ -> None 130 - in 131 - Zulip.Message.create ~type_:`Channel ~to_:[ reply_to ] 132 - ~content ?topic () 133 - in 134 - (try 135 - let resp = Zulip.Messages.send t.client message_to_send in 136 - Log.info (fun m -> 137 - m "Reply sent successfully (id: %d)" 138 - (Zulip.Message_response.id resp)) 139 - with Eio.Exn.Io (e, _) -> 140 - Log.err (fun m -> 141 - m "Error sending reply: %a" Eio.Exn.pp_err e)) 142 - | Bot_handler.Response.DirectMessage { to_; content } -> 143 - Log.debug (fun m -> 144 - m "Bot is sending direct message to: %s" to_); 145 - let message_to_send = 146 - Zulip.Message.create ~type_:`Direct ~to_:[ to_ ] ~content () 147 - in 148 - (try 149 - let resp = Zulip.Messages.send t.client message_to_send in 150 - Log.info (fun m -> 151 - m "Direct message sent successfully (id: %d)" 152 - (Zulip.Message_response.id resp)) 153 - with Eio.Exn.Io (e, _) -> 154 - Log.err (fun m -> 155 - m "Error sending direct message: %a" Eio.Exn.pp_err e)) 156 - | Bot_handler.Response.ChannelMessage { channel; topic; content } 157 - -> 158 - Log.debug (fun m -> 159 - m "Bot is sending channel message to #%s - %s" channel 160 - topic); 161 - let message_to_send = 162 - Zulip.Message.create ~type_:`Channel ~to_:[ channel ] ~topic 163 - ~content () 164 - in 165 - (try 166 - let resp = Zulip.Messages.send t.client message_to_send in 167 - Log.info (fun m -> 168 - m "Channel message sent successfully (id: %d)" 169 - (Zulip.Message_response.id resp)) 170 - with Eio.Exn.Io (e, _) -> 171 - Log.err (fun m -> 172 - m "Error sending channel message: %a" Eio.Exn.pp_err e)) 173 - | Bot_handler.Response.None -> 174 - Log.info (fun m -> m "Bot handler returned no response") 175 - with Eio.Exn.Io (e, _) -> 176 - Log.err (fun m -> m "Error handling message: %a" Eio.Exn.pp_err e)) 177 - else Log.info (fun m -> 178 - m "Not processing message (not mentioned and not private)"))) 179 - | _ -> () (* Ignore non-message events for now *) 180 - 181 - let run_realtime t = 182 - t.running <- true; 183 - Log.info (fun m -> m "Starting bot in real-time mode"); 184 - 185 - (* Get clock from environment *) 186 - let clock = get_clock t.env in 187 - 188 - (* Register for message events *) 189 - try 190 - let queue = 191 - Zulip.Event_queue.register t.client 192 - ~event_types:[ Zulip.Event_type.Message ] 193 - () 194 - in 195 - Log.info (fun m -> 196 - m "Event queue registered: %s" (Zulip.Event_queue.id queue)); 197 - 198 - (* Main event loop *) 199 - let rec event_loop last_event_id = 200 - if not t.running then ( 201 - Log.info (fun m -> m "Bot stopping"); 202 - (* Clean up event queue *) 203 - try 204 - Zulip.Event_queue.delete queue t.client; 205 - Log.info (fun m -> m "Event queue deleted") 206 - with Eio.Exn.Io (e, _) -> 207 - Log.err (fun m -> m "Error deleting queue: %a" Eio.Exn.pp_err e)) 208 - else 209 - (* Get events from Zulip *) 210 - try 211 - let events = 212 - Zulip.Event_queue.get_events queue t.client ~last_event_id () 213 - in 214 - if List.length events > 0 then begin 215 - Log.info (fun m -> m "Received %d event(s)" (List.length events)); 216 - List.iter 217 - (fun event -> 218 - Log.info (fun m -> 219 - m "Event id=%d, type=%s" (Zulip.Event.id event) 220 - (Zulip.Event_type.to_string (Zulip.Event.type_ event)))) 221 - events 222 - end; 223 - 224 - (* Process each event *) 225 - List.iter (process_event t) events; 226 - 227 - (* Get the highest event ID for next poll *) 228 - let new_last_id = 229 - List.fold_left 230 - (fun max_id event -> max (Zulip.Event.id event) max_id) 231 - last_event_id events 232 - in 233 - 234 - (* Continue polling *) 235 - event_loop new_last_id 236 - with Eio.Exn.Io (e, _) -> 237 - (* Handle errors with exponential backoff *) 238 - Log.warn (fun m -> 239 - m "Error getting events: %a (retrying in 2s)" Eio.Exn.pp_err e); 240 - 241 - (* Sleep using EIO clock *) 242 - Eio.Time.sleep clock 2.0; 243 - 244 - (* For now, treat all errors as recoverable *) 245 - event_loop last_event_id 246 - in 247 - 248 - (* Start with last_event_id = -1 to get all events *) 249 - event_loop (-1) 250 - with Eio.Exn.Io (e, _) -> 251 - Log.err (fun m -> m "Failed to register event queue: %a" Eio.Exn.pp_err e); 252 - t.running <- false 253 - 254 - let run_webhook t = 255 - t.running <- true; 256 - Log.info (fun m -> m "Bot started in webhook mode"); 257 - (* Webhook mode would wait for HTTP callbacks *) 258 - (* Not implemented yet - would need HTTP server *) 259 - () 260 - 261 - let handle_webhook t ~webhook_data = 262 - (* Process webhook data and route to handler *) 263 - (* Parse the webhook data into Message.t first *) 264 - match Message.of_json webhook_data with 265 - | Error err -> 266 - let e = 267 - Zulip.create_error ~code:(Zulip.Other "parse_error") 268 - ~msg:("Failed to parse webhook message: " ^ err) 269 - () 270 - in 271 - raise (Zulip.err e) 272 - | Ok message -> 273 - let response = 274 - Bot_handler.handle_message_with_env t.handler t.env message 275 - in 276 - Some response 277 - 278 - let shutdown t = 279 - t.running <- false; 280 - Log.info (fun m -> m "Bot shutting down")
-24
lib/zulip_bot/bot_runner.mli
··· 1 - (** Bot execution and lifecycle management. 2 - 3 - Functions that can fail raise [Eio.Io] with [Zulip.E error]. *) 4 - 5 - type 'env t 6 - 7 - val create : env:'env -> client:Zulip.Client.t -> handler:Bot_handler.t -> 'env t 8 - (** Create a bot runner *) 9 - 10 - val run_realtime : 11 - < clock : float Eio.Time.clock_ty Eio.Resource.t ; .. > t -> unit 12 - (** Run the bot in real-time mode (using Zulip events API). 13 - @raise Eio.Io on failure *) 14 - 15 - val run_webhook : 'env t -> unit 16 - (** Run the bot in webhook mode (for use with bot server) *) 17 - 18 - val handle_webhook : 19 - 'env t -> webhook_data:Zulip.json -> Bot_handler.Response.t option 20 - (** Process a single webhook event. 21 - @raise Eio.Io on failure *) 22 - 23 - val shutdown : 'env t -> unit 24 - (** Gracefully shutdown the bot *)
-205
lib/zulip_bot/bot_storage.ml
··· 1 - (* Logging setup *) 2 - let src = Logs.Src.create "zulip_bot.storage" ~doc:"Zulip bot storage" 3 - 4 - module Log = (val Logs.src_log src : Logs.LOG) 5 - 6 - type t = { 7 - client : Zulip.Client.t; 8 - bot_email : string; 9 - cache : (string, string) Hashtbl.t; 10 - mutable dirty_keys : string list; 11 - } 12 - 13 - (** {1 JSON Codecs for Bot Storage} *) 14 - 15 - (* String map for storage values *) 16 - module String_map = Map.Make (String) 17 - 18 - (* Storage response type - {"storage": {...}} *) 19 - type storage_response = { 20 - storage : string String_map.t; 21 - unknown : Jsont.json; 22 - } 23 - 24 - (* Codec for storage response using Jsont.Object with keep_unknown *) 25 - let storage_response_jsont : storage_response Jsont.t = 26 - let make storage unknown = { storage; unknown } in 27 - let storage_map_jsont = 28 - Jsont.Object.map ~kind:"StorageMap" Fun.id 29 - |> Jsont.Object.keep_unknown 30 - (Jsont.Object.Mems.string_map Jsont.string) 31 - ~enc:Fun.id 32 - |> Jsont.Object.finish 33 - in 34 - Jsont.Object.map ~kind:"StorageResponse" make 35 - |> Jsont.Object.mem "storage" storage_map_jsont ~enc:(fun r -> r.storage) 36 - ~dec_absent:String_map.empty 37 - |> Jsont.Object.keep_unknown Jsont.json_mems ~enc:(fun r -> r.unknown) 38 - |> Jsont.Object.finish 39 - 40 - let create client ~bot_email = 41 - Log.info (fun m -> m "Creating bot storage for %s" bot_email); 42 - let cache = Hashtbl.create 16 in 43 - 44 - (* Fetch all existing storage from server to populate cache *) 45 - (try 46 - let json = 47 - Zulip.Client.request client ~method_:`GET ~path:"/api/v1/bot_storage" () 48 - in 49 - match Zulip.Encode.from_json storage_response_jsont json with 50 - | Ok response -> 51 - String_map.iter 52 - (fun k v -> 53 - Log.debug (fun m -> m "Loaded key from server: %s" k); 54 - Hashtbl.add cache k v) 55 - response.storage 56 - | Error msg -> 57 - Log.warn (fun m -> m "Failed to parse storage response: %s" msg) 58 - with Eio.Exn.Io (e, _) -> 59 - Log.warn (fun m -> 60 - m "Failed to load existing storage: %a" Eio.Exn.pp_err e)); 61 - 62 - { client; bot_email; cache; dirty_keys = [] } 63 - 64 - (* Helper to encode storage data as form-encoded body for the API *) 65 - let encode_storage_update keys_values = 66 - (* Build the storage object as JSON - the API expects storage={"key": "value"} *) 67 - let storage_obj = 68 - List.map 69 - (fun (k, v) -> 70 - ((k, Jsont.Meta.none), Jsont.String (v, Jsont.Meta.none))) 71 - keys_values 72 - in 73 - let json_obj = Jsont.Object (storage_obj, Jsont.Meta.none) in 74 - 75 - (* Convert to JSON string using Jsont_bytesrw *) 76 - let json_str = 77 - Jsont_bytesrw.encode_string' Jsont.json json_obj |> Result.get_ok 78 - in 79 - 80 - (* Return as form-encoded body: storage=<url-encoded-json> *) 81 - "storage=" ^ Uri.pct_encode json_str 82 - 83 - let get t ~key = 84 - Log.debug (fun m -> m "Getting value for key: %s" key); 85 - (* First check cache *) 86 - match Hashtbl.find_opt t.cache key with 87 - | Some value -> 88 - Log.debug (fun m -> m "Found key in cache: %s" key); 89 - Some value 90 - | None -> ( 91 - (* Fetch from Zulip API - keys parameter should be a JSON array *) 92 - let params = [ ("keys", "[\"" ^ key ^ "\"]") ] in 93 - try 94 - let json = 95 - Zulip.Client.request t.client ~method_:`GET ~path:"/api/v1/bot_storage" 96 - ~params () 97 - in 98 - match Zulip.Encode.from_json storage_response_jsont json with 99 - | Ok response -> ( 100 - match String_map.find_opt key response.storage with 101 - | Some value -> 102 - (* Cache the value *) 103 - Log.debug (fun m -> m "Retrieved key from API: %s" key); 104 - Hashtbl.add t.cache key value; 105 - Some value 106 - | None -> 107 - Log.debug (fun m -> m "Key not found in API: %s" key); 108 - None) 109 - | Error msg -> 110 - Log.warn (fun m -> m "Failed to parse storage response: %s" msg); 111 - None 112 - with Eio.Exn.Io (e, _) -> 113 - Log.warn (fun m -> 114 - m "Error fetching key %s: %a" key Eio.Exn.pp_err e); 115 - None) 116 - 117 - let put t ~key ~value = 118 - Log.debug (fun m -> m "Storing key: %s with value: %s" key value); 119 - (* Update cache *) 120 - Hashtbl.replace t.cache key value; 121 - 122 - (* Mark as dirty if not already *) 123 - if not (List.mem key t.dirty_keys) then t.dirty_keys <- key :: t.dirty_keys; 124 - 125 - (* Use the helper to properly encode as form data *) 126 - let body = encode_storage_update [ (key, value) ] in 127 - 128 - Log.debug (fun m -> m "Sending storage update with body: %s" body); 129 - 130 - let _response = 131 - Zulip.Client.request t.client ~method_:`PUT ~path:"/api/v1/bot_storage" 132 - ~body () 133 - in 134 - (* Remove from dirty list on success *) 135 - Log.debug (fun m -> m "Successfully stored key: %s" key); 136 - t.dirty_keys <- List.filter (( <> ) key) t.dirty_keys 137 - 138 - let contains t ~key = 139 - (* Check cache first *) 140 - if Hashtbl.mem t.cache key then true 141 - else 142 - (* Check API *) 143 - match get t ~key with Some _ -> true | None -> false 144 - 145 - let remove t ~key = 146 - Log.debug (fun m -> m "Removing key: %s" key); 147 - (* Remove from cache *) 148 - Hashtbl.remove t.cache key; 149 - 150 - (* Remove from dirty list *) 151 - t.dirty_keys <- List.filter (( <> ) key) t.dirty_keys; 152 - 153 - (* Delete from Zulip API by setting to empty *) 154 - (* Note: Zulip API doesn't have a delete endpoint, so we set to empty string *) 155 - put t ~key ~value:"" 156 - 157 - let keys t = 158 - (* Fetch all storage from API to get complete key list *) 159 - let json = 160 - Zulip.Client.request t.client ~method_:`GET ~path:"/api/v1/bot_storage" () 161 - in 162 - match Zulip.Encode.from_json storage_response_jsont json with 163 - | Ok response -> 164 - let api_keys = 165 - String_map.fold (fun k _ acc -> k :: acc) response.storage [] 166 - in 167 - (* Merge with cache keys *) 168 - let cache_keys = 169 - Hashtbl.fold (fun k _ acc -> k :: acc) t.cache [] 170 - in 171 - List.sort_uniq String.compare (api_keys @ cache_keys) 172 - | Error msg -> 173 - Log.warn (fun m -> m "Failed to parse storage response: %s" msg); 174 - [] 175 - 176 - (* Flush all dirty keys to API *) 177 - let flush t = 178 - if t.dirty_keys = [] then () 179 - else begin 180 - Log.info (fun m -> 181 - m "Flushing %d dirty keys to API" (List.length t.dirty_keys)); 182 - let updates = 183 - List.fold_left 184 - (fun acc key -> 185 - match Hashtbl.find_opt t.cache key with 186 - | Some value -> (key, value) :: acc 187 - | None -> acc) 188 - [] t.dirty_keys 189 - in 190 - 191 - if updates = [] then () 192 - else 193 - (* Use the helper to properly encode all updates as form data *) 194 - let body = encode_storage_update updates in 195 - 196 - let _response = 197 - Zulip.Client.request t.client ~method_:`PUT ~path:"/api/v1/bot_storage" 198 - ~body () 199 - in 200 - Log.info (fun m -> m "Successfully flushed storage to API"); 201 - t.dirty_keys <- [] 202 - end 203 - 204 - (* Get the underlying client *) 205 - let client t = t.client
-33
lib/zulip_bot/bot_storage.mli
··· 1 - (** Persistent storage interface for bots. 2 - 3 - All mutation functions raise [Eio.Io] with [Zulip.E error] on failure. *) 4 - 5 - type t 6 - 7 - val create : Zulip.Client.t -> bot_email:string -> t 8 - (** Create a new storage instance for a bot *) 9 - 10 - val get : t -> key:string -> string option 11 - (** Get a value from storage *) 12 - 13 - val put : t -> key:string -> value:string -> unit 14 - (** Store a value in storage. 15 - @raise Eio.Io on failure *) 16 - 17 - val contains : t -> key:string -> bool 18 - (** Check if a key exists in storage *) 19 - 20 - val remove : t -> key:string -> unit 21 - (** Remove a key from storage. 22 - @raise Eio.Io on failure *) 23 - 24 - val keys : t -> string list 25 - (** List all keys in storage. 26 - @raise Eio.Io on failure *) 27 - 28 - val flush : t -> unit 29 - (** Flush all dirty keys to the API. 30 - @raise Eio.Io on failure *) 31 - 32 - val client : t -> Zulip.Client.t 33 - (** Get the underlying Zulip client *)
+108
lib/zulip_bot/config.ml
··· 1 + let src = Logs.Src.create "zulip_bot.config" ~doc:"Zulip bot configuration" 2 + 3 + module Log = (val Logs.src_log src : Logs.LOG) 4 + 5 + type t = { 6 + name : string; 7 + site : string; 8 + email : string; 9 + api_key : string; 10 + description : string option; 11 + usage : string option; 12 + } 13 + 14 + let create ~name ~site ~email ~api_key ?description ?usage () = 15 + { name; site; email; api_key; description; usage } 16 + 17 + (** Convert bot name to environment variable prefix. 18 + "my-bot" -> "ZULIP_MY_BOT" *) 19 + let env_prefix name = 20 + let upper = String.uppercase_ascii name in 21 + let replaced = String.map (fun c -> if c = '-' then '_' else c) upper in 22 + "ZULIP_" ^ replaced ^ "_" 23 + 24 + (** Parse INI-style config file content into key-value pairs *) 25 + let parse_ini content = 26 + let lines = String.split_on_char '\n' content in 27 + let config = Hashtbl.create 16 in 28 + List.iter 29 + (fun line -> 30 + let line = String.trim line in 31 + if String.length line > 0 && line.[0] <> '#' && line.[0] <> ';' then 32 + if not (line.[0] = '[') then 33 + match String.index_opt line '=' with 34 + | Some idx -> 35 + let key = String.trim (String.sub line 0 idx) in 36 + let value = 37 + String.trim 38 + (String.sub line (idx + 1) (String.length line - idx - 1)) 39 + in 40 + (* Remove quotes if present *) 41 + let value = 42 + if 43 + String.length value >= 2 44 + && ((value.[0] = '"' && value.[String.length value - 1] = '"') 45 + || (value.[0] = '\'' 46 + && value.[String.length value - 1] = '\'')) 47 + then String.sub value 1 (String.length value - 2) 48 + else value 49 + in 50 + Hashtbl.replace config key value 51 + | None -> ()) 52 + lines; 53 + config 54 + 55 + let load ~fs name = 56 + Log.info (fun m -> m "Loading config for bot: %s" name); 57 + let xdg = Xdge.create fs ("zulip-bot/" ^ name) in 58 + let config_file = Eio.Path.(Xdge.config_dir xdg / "config") in 59 + Log.debug (fun m -> m "Looking for config at: %a" Eio.Path.pp config_file); 60 + let content = Eio.Path.load config_file in 61 + let kv = parse_ini content in 62 + let get key = Hashtbl.find_opt kv key in 63 + let get_required key = 64 + match get key with 65 + | Some v -> v 66 + | None -> failwith (Printf.sprintf "Missing required config key: %s" key) 67 + in 68 + { 69 + name; 70 + site = get_required "site"; 71 + email = get_required "email"; 72 + api_key = get_required "api_key"; 73 + description = get "description"; 74 + usage = get "usage"; 75 + } 76 + 77 + let from_env name = 78 + Log.info (fun m -> m "Loading config for bot %s from environment" name); 79 + let prefix = env_prefix name in 80 + let get_env key = Sys.getenv_opt (prefix ^ key) in 81 + let get_required key = 82 + match get_env key with 83 + | Some v -> v 84 + | None -> 85 + failwith 86 + (Printf.sprintf "Missing required environment variable: %s%s" prefix 87 + key) 88 + in 89 + { 90 + name; 91 + site = get_required "SITE"; 92 + email = get_required "EMAIL"; 93 + api_key = get_required "API_KEY"; 94 + description = get_env "DESCRIPTION"; 95 + usage = get_env "USAGE"; 96 + } 97 + 98 + let load_or_env ~fs name = 99 + try load ~fs name 100 + with _ -> 101 + Log.debug (fun m -> 102 + m "Config file not found, falling back to environment variables"); 103 + from_env name 104 + 105 + let xdg ~fs config = Xdge.create fs ("zulip-bot/" ^ config.name) 106 + let data_dir ~fs config = Xdge.data_dir (xdg ~fs config) 107 + let state_dir ~fs config = Xdge.state_dir (xdg ~fs config) 108 + let cache_dir ~fs config = Xdge.cache_dir (xdg ~fs config)
+103
lib/zulip_bot/config.mli
··· 1 + (** Bot configuration with XDG Base Directory support. 2 + 3 + Configuration is loaded from XDG-compliant locations using the bot's name 4 + to locate the appropriate configuration file. The configuration file should 5 + be in INI format with the following structure: 6 + 7 + {v 8 + [bot] 9 + site = https://chat.zulip.org 10 + email = my-bot@chat.zulip.org 11 + api_key = your_api_key_here 12 + 13 + # Optional fields 14 + description = A helpful bot 15 + usage = @bot help 16 + v} 17 + 18 + Configuration files are searched in XDG config directories: 19 + - [$XDG_CONFIG_HOME/zulip-bot/<name>/config] (typically [~/.config/zulip-bot/<name>/config]) 20 + - System directories as fallback 21 + 22 + Environment variables can override file configuration: 23 + - [ZULIP_<NAME>_SITE], [ZULIP_<NAME>_EMAIL], [ZULIP_<NAME>_API_KEY] 24 + 25 + Where [<NAME>] is the uppercase version of the bot name with hyphens replaced by underscores. *) 26 + 27 + type t = { 28 + name : string; (** Bot name (used for XDG paths and identification) *) 29 + site : string; (** Zulip server URL *) 30 + email : string; (** Bot email address *) 31 + api_key : string; (** Bot API key *) 32 + description : string option; (** Optional bot description *) 33 + usage : string option; (** Optional usage help text *) 34 + } 35 + (** Bot configuration record. *) 36 + 37 + val create : 38 + name:string -> 39 + site:string -> 40 + email:string -> 41 + api_key:string -> 42 + ?description:string -> 43 + ?usage:string -> 44 + unit -> 45 + t 46 + (** [create ~name ~site ~email ~api_key ?description ?usage ()] creates a 47 + configuration programmatically. *) 48 + 49 + val load : fs:Eio.Fs.dir_ty Eio.Path.t -> string -> t 50 + (** [load ~fs name] loads configuration for a named bot from XDG config directory. 51 + 52 + Searches for configuration in: 53 + - [$XDG_CONFIG_HOME/zulip-bot/<name>/config] 54 + - System config directories as fallback 55 + 56 + @param fs The Eio filesystem 57 + @param name The bot name 58 + @raise Eio.Io if configuration file cannot be read or parsed 59 + @raise Failure if required fields are missing *) 60 + 61 + val from_env : string -> t 62 + (** [from_env name] loads configuration from environment variables. 63 + 64 + Reads the following environment variables (where [NAME] is the uppercase 65 + bot name with hyphens replaced by underscores): 66 + - [ZULIP_<NAME>_SITE] (required) 67 + - [ZULIP_<NAME>_EMAIL] (required) 68 + - [ZULIP_<NAME>_API_KEY] (required) 69 + - [ZULIP_<NAME>_DESCRIPTION] (optional) 70 + - [ZULIP_<NAME>_USAGE] (optional) 71 + 72 + @raise Failure if required environment variables are not set *) 73 + 74 + val load_or_env : fs:Eio.Fs.dir_ty Eio.Path.t -> string -> t 75 + (** [load_or_env ~fs name] loads config from XDG location, falling back to environment. 76 + 77 + Attempts to load from the XDG config file first. If that fails (file not 78 + found or unreadable), falls back to environment variables. 79 + 80 + @raise Failure if neither source provides valid configuration *) 81 + 82 + val xdg : fs:Eio.Fs.dir_ty Eio.Path.t -> t -> Xdge.t 83 + (** [xdg ~fs config] returns the XDG context for this bot. 84 + 85 + Useful for accessing data and state directories for the bot: 86 + - [Xdge.data_dir (xdg ~fs config)] for persistent data 87 + - [Xdge.state_dir (xdg ~fs config)] for runtime state 88 + - [Xdge.cache_dir (xdg ~fs config)] for cached data *) 89 + 90 + val data_dir : fs:Eio.Fs.dir_ty Eio.Path.t -> t -> Eio.Fs.dir_ty Eio.Path.t 91 + (** [data_dir ~fs config] returns the XDG data directory for this bot. 92 + 93 + Returns [$XDG_DATA_HOME/zulip-bot/<name>], creating it if necessary. *) 94 + 95 + val state_dir : fs:Eio.Fs.dir_ty Eio.Path.t -> t -> Eio.Fs.dir_ty Eio.Path.t 96 + (** [state_dir ~fs config] returns the XDG state directory for this bot. 97 + 98 + Returns [$XDG_STATE_HOME/zulip-bot/<name>], creating it if necessary. *) 99 + 100 + val cache_dir : fs:Eio.Fs.dir_ty Eio.Path.t -> t -> Eio.Fs.dir_ty Eio.Path.t 101 + (** [cache_dir ~fs config] returns the XDG cache directory for this bot. 102 + 103 + Returns [$XDG_CACHE_HOME/zulip-bot/<name>], creating it if necessary. *)
+2 -2
lib/zulip_bot/dune
··· 2 2 (public_name zulip_bot) 3 3 (name zulip_bot) 4 4 (wrapped true) 5 - (libraries zulip unix eio jsont jsont.bytesrw logs mirage-crypto-rng fmt) 6 - (flags (:standard -warn-error -3))) 5 + (libraries zulip eio jsont jsont.bytesrw logs fmt xdge) 6 + (flags (:standard -warn-error -3)))
+10
lib/zulip_bot/response.ml
··· 1 + type t = 2 + | Reply of string 3 + | Direct of { recipients : string list; content : string } 4 + | Stream of { stream : string; topic : string; content : string } 5 + | Silent 6 + 7 + let reply content = Reply content 8 + let direct ~recipients ~content = Direct { recipients; content } 9 + let stream ~stream ~topic ~content = Stream { stream; topic; content } 10 + let silent = Silent
+32
lib/zulip_bot/response.mli
··· 1 + (** Response types that bot handlers can return. 2 + 3 + A handler processes a message and returns a response indicating what 4 + action to take. The bot runner then executes the appropriate Zulip 5 + API calls to send the response. *) 6 + 7 + type t = 8 + | Reply of string 9 + (** Reply in the same context as the incoming message. 10 + For stream messages, replies to the same stream and topic. 11 + For private messages, replies to the sender. *) 12 + | Direct of { recipients : string list; content : string } 13 + (** Send a direct (private) message to specific users. 14 + Recipients are specified by email address. *) 15 + | Stream of { stream : string; topic : string; content : string } 16 + (** Send a message to a stream with a specific topic. *) 17 + | Silent 18 + (** No response - the bot acknowledges but does not reply. *) 19 + 20 + (** {1 Constructors} *) 21 + 22 + val reply : string -> t 23 + (** [reply content] creates a reply response. *) 24 + 25 + val direct : recipients:string list -> content:string -> t 26 + (** [direct ~recipients ~content] creates a direct message response. *) 27 + 28 + val stream : stream:string -> topic:string -> content:string -> t 29 + (** [stream ~stream ~topic ~content] creates a stream message response. *) 30 + 31 + val silent : t 32 + (** [silent] is a response that produces no output. *)
+129
lib/zulip_bot/storage.ml
··· 1 + let src = Logs.Src.create "zulip_bot.storage" ~doc:"Zulip bot storage" 2 + 3 + module Log = (val Logs.src_log src : Logs.LOG) 4 + module String_map = Map.Make (String) 5 + 6 + type t = { 7 + client : Zulip.Client.t; 8 + cache : (string, string) Hashtbl.t; 9 + } 10 + 11 + (** Storage response type - {"storage": {...}} *) 12 + type storage_response = { storage : string String_map.t; unknown : Jsont.json } 13 + 14 + let storage_response_jsont : storage_response Jsont.t = 15 + let make storage unknown = { storage; unknown } in 16 + let storage_map_jsont = 17 + Jsont.Object.map ~kind:"StorageMap" Fun.id 18 + |> Jsont.Object.keep_unknown 19 + (Jsont.Object.Mems.string_map Jsont.string) 20 + ~enc:Fun.id 21 + |> Jsont.Object.finish 22 + in 23 + Jsont.Object.map ~kind:"StorageResponse" make 24 + |> Jsont.Object.mem "storage" storage_map_jsont ~enc:(fun r -> r.storage) 25 + ~dec_absent:String_map.empty 26 + |> Jsont.Object.keep_unknown Jsont.json_mems ~enc:(fun r -> r.unknown) 27 + |> Jsont.Object.finish 28 + 29 + let create client = 30 + Log.info (fun m -> m "Creating bot storage"); 31 + let cache = Hashtbl.create 16 in 32 + (* Fetch all existing storage from server to populate cache *) 33 + (try 34 + let json = 35 + Zulip.Client.request client ~method_:`GET ~path:"/api/v1/bot_storage" () 36 + in 37 + match Zulip.Encode.from_json storage_response_jsont json with 38 + | Ok response -> 39 + String_map.iter 40 + (fun k v -> 41 + Log.debug (fun m -> m "Loaded key from server: %s" k); 42 + Hashtbl.add cache k v) 43 + response.storage 44 + | Error msg -> 45 + Log.warn (fun m -> m "Failed to parse storage response: %s" msg) 46 + with Eio.Exn.Io (e, _) -> 47 + Log.warn (fun m -> 48 + m "Failed to load existing storage: %a" Eio.Exn.pp_err e)); 49 + { client; cache } 50 + 51 + let encode_storage_update keys_values = 52 + let storage_obj = 53 + List.map 54 + (fun (k, v) -> ((k, Jsont.Meta.none), Jsont.String (v, Jsont.Meta.none))) 55 + keys_values 56 + in 57 + let json_obj = Jsont.Object (storage_obj, Jsont.Meta.none) in 58 + let json_str = 59 + Jsont_bytesrw.encode_string' Jsont.json json_obj |> Result.get_ok 60 + in 61 + "storage=" ^ Uri.pct_encode json_str 62 + 63 + let get t key = 64 + Log.debug (fun m -> m "Getting value for key: %s" key); 65 + match Hashtbl.find_opt t.cache key with 66 + | Some value -> 67 + Log.debug (fun m -> m "Found key in cache: %s" key); 68 + Some value 69 + | None -> ( 70 + let params = [ ("keys", "[\"" ^ key ^ "\"]") ] in 71 + try 72 + let json = 73 + Zulip.Client.request t.client ~method_:`GET ~path:"/api/v1/bot_storage" 74 + ~params () 75 + in 76 + match Zulip.Encode.from_json storage_response_jsont json with 77 + | Ok response -> ( 78 + match String_map.find_opt key response.storage with 79 + | Some value -> 80 + Log.debug (fun m -> m "Retrieved key from API: %s" key); 81 + Hashtbl.add t.cache key value; 82 + Some value 83 + | None -> 84 + Log.debug (fun m -> m "Key not found in API: %s" key); 85 + None) 86 + | Error msg -> 87 + Log.warn (fun m -> m "Failed to parse storage response: %s" msg); 88 + None 89 + with Eio.Exn.Io (e, _) -> 90 + Log.warn (fun m -> m "Error fetching key %s: %a" key Eio.Exn.pp_err e); 91 + None) 92 + 93 + let set t key value = 94 + Log.debug (fun m -> m "Storing key: %s" key); 95 + Hashtbl.replace t.cache key value; 96 + let body = encode_storage_update [ (key, value) ] in 97 + Log.debug (fun m -> m "Sending storage update"); 98 + let _response = 99 + Zulip.Client.request t.client ~method_:`PUT ~path:"/api/v1/bot_storage" 100 + ~body () 101 + in 102 + Log.debug (fun m -> m "Successfully stored key: %s" key) 103 + 104 + let remove t key = 105 + Log.debug (fun m -> m "Removing key: %s" key); 106 + Hashtbl.remove t.cache key; 107 + (* Zulip API doesn't have a delete endpoint, so we set to empty string *) 108 + set t key "" 109 + 110 + let mem t key = 111 + if Hashtbl.mem t.cache key then true 112 + else match get t key with Some _ -> true | None -> false 113 + 114 + let keys t = 115 + let json = 116 + Zulip.Client.request t.client ~method_:`GET ~path:"/api/v1/bot_storage" () 117 + in 118 + match Zulip.Encode.from_json storage_response_jsont json with 119 + | Ok response -> 120 + let api_keys = 121 + String_map.fold (fun k _ acc -> k :: acc) response.storage [] 122 + in 123 + let cache_keys = Hashtbl.fold (fun k _ acc -> k :: acc) t.cache [] in 124 + List.sort_uniq String.compare (api_keys @ cache_keys) 125 + | Error msg -> 126 + Log.warn (fun m -> m "Failed to parse storage response: %s" msg); 127 + [] 128 + 129 + let client t = t.client
+43
lib/zulip_bot/storage.mli
··· 1 + (** Bot storage - key-value storage via the Zulip bot storage API. 2 + 3 + Provides persistent storage for bots using Zulip's built-in bot storage 4 + mechanism. Values are cached locally and synchronized with the server. 5 + 6 + All mutation functions raise [Eio.Io] with [Zulip.E error] on failure. *) 7 + 8 + type t 9 + (** An opaque storage handle. *) 10 + 11 + val create : Zulip.Client.t -> t 12 + (** [create client] creates a new storage instance. 13 + 14 + The storage is initialized by fetching all existing keys from the server 15 + into a local cache. *) 16 + 17 + val get : t -> string -> string option 18 + (** [get t key] retrieves a value from storage. 19 + 20 + Returns [Some value] if the key exists, [None] otherwise. 21 + Checks the local cache first, then queries the server if not found. *) 22 + 23 + val set : t -> string -> string -> unit 24 + (** [set t key value] stores a value. 25 + 26 + The value is cached locally and immediately written to the server. 27 + @raise Eio.Io on server communication failure *) 28 + 29 + val remove : t -> string -> unit 30 + (** [remove t key] removes a key from storage. 31 + 32 + @raise Eio.Io on server communication failure *) 33 + 34 + val mem : t -> string -> bool 35 + (** [mem t key] checks if a key exists in storage. *) 36 + 37 + val keys : t -> string list 38 + (** [keys t] returns all keys in storage. 39 + 40 + @raise Eio.Io on server communication failure *) 41 + 42 + val client : t -> Zulip.Client.t 43 + (** [client t] returns the underlying Zulip client. *)
-41
lib/zulip_botserver/bot_registry.mli
··· 1 - (** Registry for managing multiple bots *) 2 - 3 - (** Bot module definition *) 4 - module Bot_module : sig 5 - type t 6 - 7 - val create : 8 - name:string -> 9 - handler:(module Zulip_bot.Bot_handler.Bot_handler) -> 10 - create_config:(Server_config.Bot_config.t -> Zulip_bot.Bot_config.t) -> 11 - t 12 - (** Create a bot module. The [create_config] function raises [Eio.Io] on failure. *) 13 - 14 - val name : t -> string 15 - 16 - val create_handler : t -> Server_config.Bot_config.t -> Zulip.Client.t -> Zulip_bot.Bot_handler.t 17 - (** Create handler from bot module. 18 - @raise Eio.Io on failure *) 19 - end 20 - 21 - type t 22 - 23 - (** Create a new bot registry *) 24 - val create : unit -> t 25 - 26 - (** Register a bot module *) 27 - val register : t -> Bot_module.t -> unit 28 - 29 - (** Get a bot handler by email *) 30 - val get_bot : t -> email:string -> Zulip_bot.Bot_handler.t option 31 - 32 - (** Load a bot module from file. 33 - @raise Eio.Io on failure *) 34 - val load_from_file : string -> Bot_module.t 35 - 36 - (** Load bot modules from directory. 37 - @raise Eio.Io on failure *) 38 - val load_from_directory : string -> Bot_module.t list 39 - 40 - (** List all registered bot emails *) 41 - val list_bots : t -> string list
-24
lib/zulip_botserver/bot_server.mli
··· 1 - (** Main bot server implementation *) 2 - 3 - type t 4 - 5 - (** Create a bot server. 6 - @raise Eio.Io on failure *) 7 - val create : 8 - config:Server_config.t -> 9 - registry:Bot_registry.t -> 10 - t 11 - 12 - (** Start the bot server *) 13 - val run : t -> unit 14 - 15 - (** Stop the bot server gracefully *) 16 - val shutdown : t -> unit 17 - 18 - (** Resource-safe server management. 19 - @raise Eio.Io on failure *) 20 - val with_server : 21 - config:Server_config.t -> 22 - registry:Bot_registry.t -> 23 - (t -> 'a) -> 24 - 'a
-5
lib/zulip_botserver/dune
··· 1 - (library 2 - (public_name zulip_botserver) 3 - (name zulip_botserver) 4 - (libraries zulip zulip_bot) 5 - (modules_without_implementation bot_registry bot_server server_config webhook_handler))
-45
lib/zulip_botserver/server_config.mli
··· 1 - (** Bot server configuration *) 2 - 3 - (** Configuration for a single bot *) 4 - module Bot_config : sig 5 - type t 6 - 7 - val create : 8 - email:string -> 9 - api_key:string -> 10 - server_url:string -> 11 - token:string -> 12 - config_path:string option -> 13 - t 14 - 15 - val email : t -> string 16 - val api_key : t -> string 17 - val server_url : t -> string 18 - val token : t -> string 19 - val config_path : t -> string option 20 - val pp : Format.formatter -> t -> unit 21 - end 22 - 23 - (** Server configuration *) 24 - type t 25 - 26 - val create : 27 - ?host:string -> 28 - ?port:int -> 29 - bots:Bot_config.t list -> 30 - unit -> 31 - t 32 - 33 - val from_file : string -> t 34 - (** Load configuration from file. 35 - @raise Eio.Io on failure *) 36 - 37 - val from_env : unit -> t 38 - (** Load configuration from environment variables. 39 - @raise Eio.Io on failure *) 40 - 41 - val host : t -> string 42 - val port : t -> int 43 - val bots : t -> Bot_config.t list 44 - 45 - val pp : Format.formatter -> t -> unit
-35
lib/zulip_botserver/webhook_handler.mli
··· 1 - (** Webhook processing for bot server *) 2 - 3 - (** Webhook event data *) 4 - module Webhook_event : sig 5 - type trigger = [`Direct_message | `Mention] 6 - 7 - type t 8 - 9 - val create : 10 - bot_email:string -> 11 - token:string -> 12 - message:Zulip.json -> 13 - trigger:trigger -> 14 - t 15 - 16 - val bot_email : t -> string 17 - val token : t -> string 18 - val message : t -> Zulip.json 19 - val trigger : t -> trigger 20 - val pp : Format.formatter -> t -> unit 21 - end 22 - 23 - (** Parse webhook data from HTTP request. 24 - @raise Eio.Io on failure *) 25 - val parse_webhook : string -> Webhook_event.t 26 - 27 - (** Process webhook with bot registry. 28 - @raise Eio.Io on failure *) 29 - val handle_webhook : 30 - Bot_registry.t -> 31 - Webhook_event.t -> 32 - Zulip_bot.Bot_handler.Response.t option 33 - 34 - (** Validate webhook token *) 35 - val validate_token : Server_config.Bot_config.t -> string -> bool
+6 -1
zulip_bot.opam
··· 1 1 # This file is generated by dune, edit dune-project instead 2 2 opam-version: "2.0" 3 3 synopsis: "OCaml bot framework for Zulip" 4 - description: "Interactive bot framework built on the OCaml Zulip library" 4 + description: 5 + "Fiber-based bot framework for Zulip with XDG configuration support" 5 6 depends: [ 6 7 "ocaml" 7 8 "dune" {>= "3.0"} 8 9 "zulip" 9 10 "eio" 11 + "xdge" 12 + "jsont" 13 + "logs" 14 + "fmt" 10 15 "alcotest" {with-test} 11 16 "odoc" {with-doc} 12 17 ]
-29
zulip_botserver.opam
··· 1 - # This file is generated by dune, edit dune-project instead 2 - opam-version: "2.0" 3 - synopsis: "OCaml bot server for running multiple Zulip bots" 4 - description: 5 - "HTTP server for running multiple Zulip bots with webhook support" 6 - depends: [ 7 - "ocaml" 8 - "dune" {>= "3.0"} 9 - "zulip" 10 - "zulip_bot" 11 - "eio" 12 - "requests" 13 - "alcotest" {with-test} 14 - "odoc" {with-doc} 15 - ] 16 - build: [ 17 - ["dune" "subst"] {dev} 18 - [ 19 - "dune" 20 - "build" 21 - "-p" 22 - name 23 - "-j" 24 - jobs 25 - "@install" 26 - "@runtest" {with-test} 27 - "@doc" {with-doc} 28 - ] 29 - ]