Zulip bots with Eio

use init

+117 -96
+1
.ocamlformat
··· 1 + version=0.28.1
+2
dune-project
··· 15 15 requests 16 16 uri 17 17 base64 18 + init 18 19 (alcotest :with-test) 19 20 (eio_main :with-test))) 20 21 ··· 31 32 jsont 32 33 logs 33 34 fmt 35 + init 34 36 (alcotest :with-test)))
+57 -52
lib/zulip/auth.ml
··· 6 6 7 7 let create ~server_url ~email ~api_key = { server_url; email; api_key } 8 8 9 + (** INI section record for parsing the [api] section of zuliprc *) 10 + type zuliprc_api = { 11 + zuliprc_email : string; 12 + zuliprc_key : string; 13 + zuliprc_site : string; 14 + } 15 + 16 + (** Codec for parsing the [api] section of zuliprc. 17 + Note: zuliprc uses "key" not "api_key" *) 18 + let api_section_codec = 19 + Init.Section.( 20 + obj (fun email key site -> 21 + { zuliprc_email = email; zuliprc_key = key; zuliprc_site = site }) 22 + |> mem "email" Init.string ~enc:(fun c -> c.zuliprc_email) 23 + |> mem "key" Init.string ~enc:(fun c -> c.zuliprc_key) 24 + |> mem "site" Init.string ~enc:(fun c -> c.zuliprc_site) 25 + |> skip_unknown 26 + |> finish) 27 + 28 + (** Document codec for zuliprc with [api] section *) 29 + let zuliprc_codec = 30 + Init.Document.( 31 + obj (fun api -> api) 32 + |> section "api" api_section_codec ~enc:Fun.id 33 + |> skip_unknown 34 + |> finish) 35 + 36 + (** Codec for zuliprc without section headers (bare key=value pairs) *) 37 + let zuliprc_bare_codec = 38 + Init.Document.( 39 + obj (fun defaults -> defaults) 40 + |> defaults api_section_codec ~enc:Fun.id 41 + |> skip_unknown 42 + |> finish) 43 + 9 44 let from_zuliprc ?(path = "~/.zuliprc") () = 10 45 try 11 46 (* Expand ~ to home directory *) ··· 24 59 content 25 60 in 26 61 27 - (* Simple INI-style parser for zuliprc *) 28 - let lines = String.split_on_char '\n' content in 29 - let config = Hashtbl.create 10 in 30 - let current_section = ref "" in 62 + (* Parse using Init library *) 63 + let api = 64 + match Init_bytesrw.decode_string zuliprc_codec content with 65 + | Ok c -> c 66 + | Error _ -> 67 + (* Try bare config format (no section headers) *) 68 + match Init_bytesrw.decode_string zuliprc_bare_codec content with 69 + | Ok c -> c 70 + | Error msg -> 71 + Error.raise_with_context 72 + (Error.make ~code:(Other "parse_error") 73 + ~message:("Error parsing zuliprc: " ^ msg) ()) 74 + "reading %s" path 75 + in 31 76 32 - List.iter 33 - (fun line -> 34 - let line = String.trim line in 35 - if String.length line > 0 && line.[0] <> '#' then 36 - if 37 - String.length line > 2 38 - && line.[0] = '[' 39 - && line.[String.length line - 1] = ']' 40 - then 41 - (* Section header *) 42 - current_section := String.sub line 1 (String.length line - 2) 43 - else 44 - (* Key-value pair *) 45 - match String.index_opt line '=' with 46 - | Some idx -> 47 - let key = String.trim (String.sub line 0 idx) in 48 - let value = 49 - String.trim 50 - (String.sub line (idx + 1) (String.length line - idx - 1)) 51 - in 52 - let full_key = 53 - if !current_section = "" || !current_section = "api" then key 54 - else !current_section ^ "." ^ key 55 - in 56 - Hashtbl.add config full_key value 57 - | None -> ()) 58 - lines; 59 - 60 - (* Extract required fields *) 61 - let get_value key = 62 - try Some (Hashtbl.find config key) with Not_found -> None 77 + (* Ensure server_url has proper protocol *) 78 + let server_url = 79 + if 80 + String.starts_with ~prefix:"http://" api.zuliprc_site 81 + || String.starts_with ~prefix:"https://" api.zuliprc_site 82 + then api.zuliprc_site 83 + else "https://" ^ api.zuliprc_site 63 84 in 64 - 65 - match (get_value "email", get_value "key", get_value "site") with 66 - | Some email, Some api_key, Some server_url -> 67 - (* Ensure server_url has proper protocol *) 68 - let server_url = 69 - if 70 - String.starts_with ~prefix:"http://" server_url 71 - || String.starts_with ~prefix:"https://" server_url 72 - then server_url 73 - else "https://" ^ server_url 74 - in 75 - { server_url; email; api_key } 76 - | _ -> 77 - Error.raise_with_context 78 - (Error.make ~code:(Other "config_missing") 79 - ~message:"Missing required fields: email, key, site in zuliprc" ()) 80 - "reading %s" path 85 + { server_url; email = api.zuliprc_email; api_key = api.zuliprc_key } 81 86 with 82 87 | Eio.Exn.Io _ as ex -> raise ex 83 88 | Sys_error msg ->
+1 -1
lib/zulip/dune
··· 1 1 (library 2 2 (public_name zulip) 3 3 (name zulip) 4 - (libraries eio requests jsont jsont.bytesrw uri base64 logs)) 4 + (libraries eio requests jsont jsont.bytesrw uri base64 logs init init.bytesrw))
+53 -42
lib/zulip_bot/config.ml
··· 21 21 let replaced = String.map (fun c -> if c = '-' then '_' else c) upper in 22 22 "ZULIP_" ^ replaced ^ "_" 23 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 24 + (** INI section record for parsing (without name field) *) 25 + type ini_config = { 26 + ini_site : string; 27 + ini_email : string; 28 + ini_api_key : string; 29 + ini_description : string option; 30 + ini_usage : string option; 31 + } 32 + 33 + (** Codec for parsing the bot section of the config file *) 34 + let ini_section_codec = 35 + Init.Section.( 36 + obj (fun site email api_key description usage -> 37 + { ini_site = site; ini_email = email; ini_api_key = api_key; 38 + ini_description = description; ini_usage = usage }) 39 + |> mem "site" Init.string ~enc:(fun c -> c.ini_site) 40 + |> mem "email" Init.string ~enc:(fun c -> c.ini_email) 41 + |> mem "api_key" Init.string ~enc:(fun c -> c.ini_api_key) 42 + |> opt_mem "description" Init.string ~enc:(fun c -> c.ini_description) 43 + |> opt_mem "usage" Init.string ~enc:(fun c -> c.ini_usage) 44 + |> skip_unknown 45 + |> finish) 46 + 47 + (** Document codec that accepts a [bot] section or bare options at top level *) 48 + let ini_doc_codec = 49 + Init.Document.( 50 + obj (fun bot -> bot) 51 + |> section "bot" ini_section_codec ~enc:Fun.id 52 + |> skip_unknown 53 + |> finish) 54 + 55 + (** Codec for configs without section headers (bare key=value pairs) *) 56 + let bare_section_codec = 57 + Init.Document.( 58 + obj (fun defaults -> defaults) 59 + |> defaults ini_section_codec ~enc:Fun.id 60 + |> skip_unknown 61 + |> finish) 54 62 55 63 let load ~fs name = 56 64 Log.info (fun m -> m "Loading config for bot: %s" name); 57 65 let xdg = Xdge.create fs ("zulip-bot/" ^ name) in 58 66 let config_file = Eio.Path.(Xdge.config_dir xdg / "config") in 59 67 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) 68 + (* Try parsing with [bot] section first, fall back to bare config *) 69 + let ini_config = 70 + match Init_eio.decode_path ini_doc_codec config_file with 71 + | Ok c -> c 72 + | Error _ -> 73 + (* Try bare config format (no section headers) *) 74 + match Init_eio.decode_path bare_section_codec config_file with 75 + | Ok c -> c 76 + | Error e -> 77 + raise (Init_eio.err e) 67 78 in 68 79 { 69 80 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"; 81 + site = ini_config.ini_site; 82 + email = ini_config.ini_email; 83 + api_key = ini_config.ini_api_key; 84 + description = ini_config.ini_description; 85 + usage = ini_config.ini_usage; 75 86 } 76 87 77 88 let from_env name =
+1 -1
lib/zulip_bot/dune
··· 2 2 (public_name zulip_bot) 3 3 (name zulip_bot) 4 4 (wrapped true) 5 - (libraries zulip eio jsont jsont.bytesrw logs fmt xdge) 5 + (libraries zulip eio jsont jsont.bytesrw logs fmt xdge init init.eio) 6 6 (flags 7 7 (:standard -warn-error -3)))
+1
zulip.opam
··· 10 10 "requests" 11 11 "uri" 12 12 "base64" 13 + "init" 13 14 "alcotest" {with-test} 14 15 "eio_main" {with-test} 15 16 "odoc" {with-doc}
+1
zulip_bot.opam
··· 12 12 "jsont" 13 13 "logs" 14 14 "fmt" 15 + "init" 15 16 "alcotest" {with-test} 16 17 "odoc" {with-doc} 17 18 ]