tangled
alpha
login
or
join now
anil.recoil.org
/
ocaml-zulip
0
fork
atom
Zulip bots with Eio
0
fork
atom
overview
issues
pulls
pipelines
use init
anil.recoil.org
3 months ago
5677b62d
a4ca9c90
+117
-96
8 changed files
expand all
collapse all
unified
split
.ocamlformat
dune-project
lib
zulip
auth.ml
dune
zulip_bot
config.ml
dune
zulip.opam
zulip_bot.opam
+1
.ocamlformat
···
1
1
+
version=0.28.1
+2
dune-project
···
15
15
requests
16
16
uri
17
17
base64
18
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
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
9
+
(** INI section record for parsing the [api] section of zuliprc *)
10
10
+
type zuliprc_api = {
11
11
+
zuliprc_email : string;
12
12
+
zuliprc_key : string;
13
13
+
zuliprc_site : string;
14
14
+
}
15
15
+
16
16
+
(** Codec for parsing the [api] section of zuliprc.
17
17
+
Note: zuliprc uses "key" not "api_key" *)
18
18
+
let api_section_codec =
19
19
+
Init.Section.(
20
20
+
obj (fun email key site ->
21
21
+
{ zuliprc_email = email; zuliprc_key = key; zuliprc_site = site })
22
22
+
|> mem "email" Init.string ~enc:(fun c -> c.zuliprc_email)
23
23
+
|> mem "key" Init.string ~enc:(fun c -> c.zuliprc_key)
24
24
+
|> mem "site" Init.string ~enc:(fun c -> c.zuliprc_site)
25
25
+
|> skip_unknown
26
26
+
|> finish)
27
27
+
28
28
+
(** Document codec for zuliprc with [api] section *)
29
29
+
let zuliprc_codec =
30
30
+
Init.Document.(
31
31
+
obj (fun api -> api)
32
32
+
|> section "api" api_section_codec ~enc:Fun.id
33
33
+
|> skip_unknown
34
34
+
|> finish)
35
35
+
36
36
+
(** Codec for zuliprc without section headers (bare key=value pairs) *)
37
37
+
let zuliprc_bare_codec =
38
38
+
Init.Document.(
39
39
+
obj (fun defaults -> defaults)
40
40
+
|> defaults api_section_codec ~enc:Fun.id
41
41
+
|> skip_unknown
42
42
+
|> finish)
43
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
27
-
(* Simple INI-style parser for zuliprc *)
28
28
-
let lines = String.split_on_char '\n' content in
29
29
-
let config = Hashtbl.create 10 in
30
30
-
let current_section = ref "" in
62
62
+
(* Parse using Init library *)
63
63
+
let api =
64
64
+
match Init_bytesrw.decode_string zuliprc_codec content with
65
65
+
| Ok c -> c
66
66
+
| Error _ ->
67
67
+
(* Try bare config format (no section headers) *)
68
68
+
match Init_bytesrw.decode_string zuliprc_bare_codec content with
69
69
+
| Ok c -> c
70
70
+
| Error msg ->
71
71
+
Error.raise_with_context
72
72
+
(Error.make ~code:(Other "parse_error")
73
73
+
~message:("Error parsing zuliprc: " ^ msg) ())
74
74
+
"reading %s" path
75
75
+
in
31
76
32
32
-
List.iter
33
33
-
(fun line ->
34
34
-
let line = String.trim line in
35
35
-
if String.length line > 0 && line.[0] <> '#' then
36
36
-
if
37
37
-
String.length line > 2
38
38
-
&& line.[0] = '['
39
39
-
&& line.[String.length line - 1] = ']'
40
40
-
then
41
41
-
(* Section header *)
42
42
-
current_section := String.sub line 1 (String.length line - 2)
43
43
-
else
44
44
-
(* Key-value pair *)
45
45
-
match String.index_opt line '=' with
46
46
-
| Some idx ->
47
47
-
let key = String.trim (String.sub line 0 idx) in
48
48
-
let value =
49
49
-
String.trim
50
50
-
(String.sub line (idx + 1) (String.length line - idx - 1))
51
51
-
in
52
52
-
let full_key =
53
53
-
if !current_section = "" || !current_section = "api" then key
54
54
-
else !current_section ^ "." ^ key
55
55
-
in
56
56
-
Hashtbl.add config full_key value
57
57
-
| None -> ())
58
58
-
lines;
59
59
-
60
60
-
(* Extract required fields *)
61
61
-
let get_value key =
62
62
-
try Some (Hashtbl.find config key) with Not_found -> None
77
77
+
(* Ensure server_url has proper protocol *)
78
78
+
let server_url =
79
79
+
if
80
80
+
String.starts_with ~prefix:"http://" api.zuliprc_site
81
81
+
|| String.starts_with ~prefix:"https://" api.zuliprc_site
82
82
+
then api.zuliprc_site
83
83
+
else "https://" ^ api.zuliprc_site
63
84
in
64
64
-
65
65
-
match (get_value "email", get_value "key", get_value "site") with
66
66
-
| Some email, Some api_key, Some server_url ->
67
67
-
(* Ensure server_url has proper protocol *)
68
68
-
let server_url =
69
69
-
if
70
70
-
String.starts_with ~prefix:"http://" server_url
71
71
-
|| String.starts_with ~prefix:"https://" server_url
72
72
-
then server_url
73
73
-
else "https://" ^ server_url
74
74
-
in
75
75
-
{ server_url; email; api_key }
76
76
-
| _ ->
77
77
-
Error.raise_with_context
78
78
-
(Error.make ~code:(Other "config_missing")
79
79
-
~message:"Missing required fields: email, key, site in zuliprc" ())
80
80
-
"reading %s" path
85
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
4
-
(libraries eio requests jsont jsont.bytesrw uri base64 logs))
4
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
24
-
(** Parse INI-style config file content into key-value pairs *)
25
25
-
let parse_ini content =
26
26
-
let lines = String.split_on_char '\n' content in
27
27
-
let config = Hashtbl.create 16 in
28
28
-
List.iter
29
29
-
(fun line ->
30
30
-
let line = String.trim line in
31
31
-
if String.length line > 0 && line.[0] <> '#' && line.[0] <> ';' then
32
32
-
if not (line.[0] = '[') then
33
33
-
match String.index_opt line '=' with
34
34
-
| Some idx ->
35
35
-
let key = String.trim (String.sub line 0 idx) in
36
36
-
let value =
37
37
-
String.trim
38
38
-
(String.sub line (idx + 1) (String.length line - idx - 1))
39
39
-
in
40
40
-
(* Remove quotes if present *)
41
41
-
let value =
42
42
-
if
43
43
-
String.length value >= 2
44
44
-
&& ((value.[0] = '"' && value.[String.length value - 1] = '"')
45
45
-
|| (value.[0] = '\''
46
46
-
&& value.[String.length value - 1] = '\''))
47
47
-
then String.sub value 1 (String.length value - 2)
48
48
-
else value
49
49
-
in
50
50
-
Hashtbl.replace config key value
51
51
-
| None -> ())
52
52
-
lines;
53
53
-
config
24
24
+
(** INI section record for parsing (without name field) *)
25
25
+
type ini_config = {
26
26
+
ini_site : string;
27
27
+
ini_email : string;
28
28
+
ini_api_key : string;
29
29
+
ini_description : string option;
30
30
+
ini_usage : string option;
31
31
+
}
32
32
+
33
33
+
(** Codec for parsing the bot section of the config file *)
34
34
+
let ini_section_codec =
35
35
+
Init.Section.(
36
36
+
obj (fun site email api_key description usage ->
37
37
+
{ ini_site = site; ini_email = email; ini_api_key = api_key;
38
38
+
ini_description = description; ini_usage = usage })
39
39
+
|> mem "site" Init.string ~enc:(fun c -> c.ini_site)
40
40
+
|> mem "email" Init.string ~enc:(fun c -> c.ini_email)
41
41
+
|> mem "api_key" Init.string ~enc:(fun c -> c.ini_api_key)
42
42
+
|> opt_mem "description" Init.string ~enc:(fun c -> c.ini_description)
43
43
+
|> opt_mem "usage" Init.string ~enc:(fun c -> c.ini_usage)
44
44
+
|> skip_unknown
45
45
+
|> finish)
46
46
+
47
47
+
(** Document codec that accepts a [bot] section or bare options at top level *)
48
48
+
let ini_doc_codec =
49
49
+
Init.Document.(
50
50
+
obj (fun bot -> bot)
51
51
+
|> section "bot" ini_section_codec ~enc:Fun.id
52
52
+
|> skip_unknown
53
53
+
|> finish)
54
54
+
55
55
+
(** Codec for configs without section headers (bare key=value pairs) *)
56
56
+
let bare_section_codec =
57
57
+
Init.Document.(
58
58
+
obj (fun defaults -> defaults)
59
59
+
|> defaults ini_section_codec ~enc:Fun.id
60
60
+
|> skip_unknown
61
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
60
-
let content = Eio.Path.load config_file in
61
61
-
let kv = parse_ini content in
62
62
-
let get key = Hashtbl.find_opt kv key in
63
63
-
let get_required key =
64
64
-
match get key with
65
65
-
| Some v -> v
66
66
-
| None -> failwith (Printf.sprintf "Missing required config key: %s" key)
68
68
+
(* Try parsing with [bot] section first, fall back to bare config *)
69
69
+
let ini_config =
70
70
+
match Init_eio.decode_path ini_doc_codec config_file with
71
71
+
| Ok c -> c
72
72
+
| Error _ ->
73
73
+
(* Try bare config format (no section headers) *)
74
74
+
match Init_eio.decode_path bare_section_codec config_file with
75
75
+
| Ok c -> c
76
76
+
| Error e ->
77
77
+
raise (Init_eio.err e)
67
78
in
68
79
{
69
80
name;
70
70
-
site = get_required "site";
71
71
-
email = get_required "email";
72
72
-
api_key = get_required "api_key";
73
73
-
description = get "description";
74
74
-
usage = get "usage";
81
81
+
site = ini_config.ini_site;
82
82
+
email = ini_config.ini_email;
83
83
+
api_key = ini_config.ini_api_key;
84
84
+
description = ini_config.ini_description;
85
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
5
-
(libraries zulip eio jsont jsont.bytesrw logs fmt xdge)
5
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
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
15
+
"init"
15
16
"alcotest" {with-test}
16
17
"odoc" {with-doc}
17
18
]