Matrix protocol in OCaml, Eio specialised
1(** omatrix - Command-line Matrix client.
2
3 A CLI tool for interacting with Matrix homeservers, supporting
4 session persistence and common operations like login, messaging,
5 and room management.
6
7 {b Quick Start}
8
9 {[
10 # Login and store session
11 omatrix login -s https://matrix.org -u @you:matrix.org
12
13 # Send a direct message (uses stored session)
14 omatrix msg -t @them:matrix.org "Hello!"
15
16 # Send to a room
17 omatrix msg -r '!roomid:matrix.org' "Hello room!"
18
19 # Check current session
20 omatrix whoami
21
22 # Logout and clear session
23 omatrix logout
24 ]}
25
26 Environment variables:
27 - [MATRIX_HOMESERVER]: Default homeserver URL
28 - [MATRIX_USERNAME]: Default username
29 - [MATRIX_PASSWORD]: Password (preferred over command-line) *)
30
31open Cmdliner
32module Cmd = Matrix_client.Cmd
33module Session = Matrix_client.Session
34
35(* ================================================================ *)
36(* Shared State *)
37(* ================================================================ *)
38
39(** Application name for session metadata *)
40let app_name = "omatrix"
41
42(** Get XDG directories for session storage *)
43let with_xdg ~env f =
44 let fs = Eio.Stdenv.fs env in
45 let xdg = Xdge.create fs "matrix" in
46 f xdg
47
48(** Load a stored session, returning the store and session data *)
49let load_session ~env ~profile =
50 with_xdg ~env @@ fun xdg ->
51 let store = Session.Store.create ~xdg ~profile in
52 Session.Store.load_session store |> Option.map (fun session -> (store, session))
53
54(** Log error for missing session *)
55let log_no_session ~profile =
56 Logs.err (fun m -> m "No session found for profile '%s'" profile);
57 Logs.err (fun m -> m "Use 'omatrix login' to authenticate first")
58
59(** Create a client from stored session credentials *)
60let client_from_session ~sw ~env (session : Session.Session_file.t) =
61 let homeserver = session.server.homeserver in
62 let client = Matrix_eio.Client.create ~sw ~env ~homeserver () in
63 (* Restore session state *)
64 let matrix_session : Matrix_client.Client.session = {
65 user_id = session.server.user_id;
66 device_id = session.auth.device_id;
67 access_token = session.auth.access_token;
68 refresh_token = session.auth.refresh_token;
69 } in
70 Matrix_eio.Client.with_session client matrix_session
71
72(* ================================================================ *)
73(* Login Command *)
74(* ================================================================ *)
75
76let login_run ~homeserver ~username ~password ~profile () =
77 Eio_main.run @@ fun env ->
78 Eio.Switch.run @@ fun sw ->
79
80 Logs.info (fun m -> m "Connecting to %s" homeserver);
81
82 let client =
83 try
84 Matrix_eio.login_password ~sw ~env
85 ~homeserver:(Uri.of_string homeserver)
86 ~user:username ~password ()
87 with Eio.Io (Matrix_eio.Error.E err, _) ->
88 Logs.err (fun m -> m "Login failed: %a" Matrix_eio.Error.pp_err err);
89 exit Cmd.exit_auth
90 in
91
92 let user_id = Matrix_eio.Client.user_id client in
93 let device_id = Matrix_eio.Client.device_id client in
94 let access_token = Matrix_eio.Client.access_token client in
95
96 Logs.info (fun m -> m "Logged in as %s"
97 (Matrix_proto.Id.User_id.to_string user_id));
98
99 (* Save session *)
100 with_xdg ~env @@ fun xdg ->
101 let store = Session.Store.create ~xdg ~profile in
102 let now = Ptime_clock.now () in
103 let session : Session.Session_file.t = {
104 server = {
105 homeserver = Uri.of_string homeserver;
106 user_id;
107 };
108 auth = {
109 access_token;
110 device_id;
111 refresh_token = None;
112 };
113 sync = {
114 next_batch = None;
115 filter_id = None;
116 };
117 metadata = {
118 created_at = now;
119 last_used_at = now;
120 client_name = app_name;
121 };
122 } in
123 Session.Store.save_session store session;
124
125 Logs.app (fun m -> m "Session saved to profile '%s'" profile);
126 Logs.app (fun m -> m "User ID: %s" (Matrix_proto.Id.User_id.to_string user_id));
127 Logs.app (fun m -> m "Device ID: %s" (Matrix_proto.Id.Device_id.to_string device_id));
128 `Ok ()
129
130let login_term =
131 let run () homeserver username password profile =
132 login_run ~homeserver ~username ~password ~profile ()
133 in
134 Term.(ret (const run
135 $ Cmd.verbosity_term
136 $ Cmd.homeserver_term
137 $ Cmd.username_term
138 $ Cmd.password_term
139 $ Cmd.profile_term))
140
141let login_cmd =
142 let doc = "Authenticate with a Matrix homeserver" in
143 let man = [
144 `S Manpage.s_description;
145 `P "Logs in to a Matrix homeserver using password authentication and \
146 stores the session credentials for later use. The session is saved \
147 to the profile directory under $(b,\\$XDG_DATA_HOME/matrix/profiles/).";
148 `S Manpage.s_examples;
149 `Pre " omatrix login -s https://matrix.org -u @you:matrix.org";
150 `P "Using environment variables:";
151 `Pre " export MATRIX_PASSWORD=secret";
152 `Pre " omatrix login -s https://matrix.org -u @you:matrix.org";
153 `P "Using a named profile:";
154 `Pre " omatrix login --profile work -s https://work.matrix.org -u @you:work.matrix.org";
155 ] in
156 let info = Cmdliner.Cmd.info "login" ~doc ~man in
157 Cmdliner.Cmd.v info login_term
158
159(* ================================================================ *)
160(* Logout Command *)
161(* ================================================================ *)
162
163let logout_run ~profile ~keep_local () =
164 Eio_main.run @@ fun env ->
165 Eio.Switch.run @@ fun sw ->
166
167 match load_session ~env ~profile with
168 | None ->
169 Logs.warn (fun m -> m "No session found for profile '%s'" profile);
170 `Ok ()
171 | Some (store, session) ->
172 if not keep_local then begin
173 (* Logout from server *)
174 Logs.info (fun m -> m "Logging out from server...");
175 let client = client_from_session ~sw ~env session in
176 (try
177 Matrix_eio.Auth.logout client;
178 Logs.info (fun m -> m "Server logout successful")
179 with Eio.Io _ ->
180 Logs.warn (fun m -> m "Server logout failed (session may still be active)"))
181 end;
182
183 (* Clear local session *)
184 Session.Store.clear store;
185 Logs.app (fun m -> m "Session cleared for profile '%s'" profile);
186 `Ok ()
187
188let keep_local_term =
189 let doc = "Only clear local session without notifying the server." in
190 Arg.(value & flag & info ["local"] ~doc)
191
192let logout_term =
193 let run () profile keep_local =
194 logout_run ~profile ~keep_local ()
195 in
196 Term.(ret (const run $ Cmd.verbosity_term $ Cmd.profile_term $ keep_local_term))
197
198let logout_cmd =
199 let doc = "Log out and clear stored session" in
200 let man = [
201 `S Manpage.s_description;
202 `P "Logs out from the Matrix homeserver and clears the stored session. \
203 By default, this invalidates the access token on the server.";
204 `S Manpage.s_examples;
205 `Pre " omatrix logout";
206 `P "Clear only the local session (keep server session active):";
207 `Pre " omatrix logout --local";
208 `P "Logout from a specific profile:";
209 `Pre " omatrix logout --profile work";
210 ] in
211 let info = Cmdliner.Cmd.info "logout" ~doc ~man in
212 Cmdliner.Cmd.v info logout_term
213
214(* ================================================================ *)
215(* Whoami Command *)
216(* ================================================================ *)
217
218let whoami_run ~profile () =
219 Eio_main.run @@ fun env ->
220
221 match load_session ~env ~profile with
222 | None ->
223 log_no_session ~profile;
224 `Error (false, "Not logged in")
225 | Some (_store, session) ->
226 let user_id = Matrix_proto.Id.User_id.to_string session.server.user_id in
227 let device_id = Matrix_proto.Id.Device_id.to_string session.auth.device_id in
228 let homeserver = Uri.to_string session.server.homeserver in
229
230 Format.printf "Profile: %s@." profile;
231 Format.printf "User ID: %s@." user_id;
232 Format.printf "Device ID: %s@." device_id;
233 Format.printf "Homeserver: %s@." homeserver;
234 Format.printf "Last used: %a@." (Ptime.pp_rfc3339 ()) session.metadata.last_used_at;
235 `Ok ()
236
237let whoami_term =
238 let run () profile =
239 whoami_run ~profile ()
240 in
241 Term.(ret (const run $ Cmd.verbosity_term $ Cmd.profile_term))
242
243let whoami_cmd =
244 let doc = "Show current session information" in
245 let man = [
246 `S Manpage.s_description;
247 `P "Displays information about the currently stored session, including \
248 the user ID, device ID, and homeserver.";
249 `S Manpage.s_examples;
250 `Pre " omatrix whoami";
251 `Pre " omatrix whoami --profile work";
252 ] in
253 let info = Cmdliner.Cmd.info "whoami" ~doc ~man in
254 Cmdliner.Cmd.v info whoami_term
255
256(* ================================================================ *)
257(* Msg Command *)
258(* ================================================================ *)
259
260let msg_run ~profile ~room ~recipient ~message ~encrypted () =
261 Eio_main.run @@ fun env ->
262 Eio.Switch.run @@ fun sw ->
263
264 (* Load session *)
265 let store, session = match load_session ~env ~profile with
266 | None -> log_no_session ~profile; exit Cmd.exit_auth
267 | Some s -> s
268 in
269
270 let client = client_from_session ~sw ~env session in
271 let user_id = Matrix_eio.Client.user_id client in
272 Logs.info (fun m -> m "Using session for %s"
273 (Matrix_proto.Id.User_id.to_string user_id));
274
275 (* Determine target room *)
276 let room_id = match room, recipient with
277 | Some room_str, None ->
278 (* Direct room ID *)
279 (match Matrix_proto.Id.Room_id.of_string room_str with
280 | Ok id -> id
281 | Error (`Invalid_room_id msg) ->
282 Logs.err (fun m -> m "Invalid room ID '%s': %s" room_str msg);
283 exit Cmd.exit_usage)
284 | None, Some recipient_str ->
285 (* DM - find or create room *)
286 let recipient_id = match Matrix_proto.Id.User_id.of_string recipient_str with
287 | Ok id -> id
288 | Error (`Invalid_user_id msg) ->
289 Logs.err (fun m -> m "Invalid user ID '%s': %s" recipient_str msg);
290 exit Cmd.exit_usage
291 in
292 Logs.info (fun m -> m "Finding or creating DM room with %s" recipient_str);
293 let encrypted_opt = if encrypted then Some true else None in
294 (try
295 Matrix_eio.Rooms.get_or_create_dm client ~user_id:recipient_id
296 ?encrypted:encrypted_opt ()
297 with Eio.Io (Matrix_eio.Error.E err, _) ->
298 Logs.err (fun m -> m "Failed to get/create DM room: %a"
299 Matrix_eio.Error.pp_err err);
300 exit Cmd.exit_network)
301 | Some _, Some _ ->
302 Logs.err (fun m -> m "Cannot specify both --room and --to");
303 exit Cmd.exit_usage
304 | None, None ->
305 Logs.err (fun m -> m "Must specify --room or --to");
306 exit Cmd.exit_usage
307 in
308
309 Logs.info (fun m -> m "Sending to room %s"
310 (Matrix_proto.Id.Room_id.to_string room_id));
311
312 (* Send message *)
313 let event_id =
314 try
315 Matrix_eio.Messages.send_text client ~room_id ~body:message ()
316 with Eio.Io (Matrix_eio.Error.E err, _) ->
317 Logs.err (fun m -> m "Failed to send message: %a"
318 Matrix_eio.Error.pp_err err);
319 exit Cmd.exit_network
320 in
321
322 Logs.app (fun m -> m "Message sent (event ID: %s)"
323 (Matrix_proto.Id.Event_id.to_string event_id));
324
325 (* Update last_used_at *)
326 let updated_session = {
327 session with metadata = {
328 session.metadata with last_used_at = Ptime_clock.now ()
329 }
330 } in
331 Session.Store.save_session store updated_session;
332
333 `Ok ()
334
335let msg_term =
336 let run () profile room recipient message encrypted =
337 msg_run ~profile ~room ~recipient ~message ~encrypted ()
338 in
339 Term.(ret (const run
340 $ Cmd.verbosity_term
341 $ Cmd.profile_term
342 $ Cmd.room_opt_term
343 $ Cmd.recipient_opt_term
344 $ Cmd.message_term
345 $ Cmd.encrypted_term))
346
347let msg_cmd =
348 let doc = "Send a message to a room or user" in
349 let man = [
350 `S Manpage.s_description;
351 `P "Sends a text message to a Matrix room or directly to another user. \
352 Requires a stored session from $(b,omatrix login).";
353 `P "For direct messages (using $(b,--to)), an existing DM room is reused \
354 if one exists, otherwise a new room is created.";
355 `S Manpage.s_examples;
356 `P "Send a direct message:";
357 `Pre " omatrix msg -t @alice:matrix.org \"Hello Alice!\"";
358 `P "Send to a room:";
359 `Pre " omatrix msg -r '!roomid:matrix.org' \"Hello room!\"";
360 `P "Create encrypted DM (for new rooms):";
361 `Pre " omatrix msg -t @alice:matrix.org -e \"Secret message\"";
362 `P "Using a different profile:";
363 `Pre " omatrix msg --profile work -t @colleague:work.org \"Meeting at 3pm\"";
364 ] in
365 let info = Cmdliner.Cmd.info "msg" ~doc ~man in
366 Cmdliner.Cmd.v info msg_term
367
368(* ================================================================ *)
369(* Main Command Group *)
370(* ================================================================ *)
371
372let main_cmd =
373 let doc = "Command-line Matrix client" in
374 let man = [
375 `S Manpage.s_description;
376 `P "$(b,omatrix) is a command-line client for the Matrix communication \
377 protocol. It supports session persistence, allowing you to log in \
378 once and perform subsequent operations without re-authenticating.";
379 `S Manpage.s_commands;
380 `P "$(b,login) Authenticate with a homeserver and store session";
381 `P "$(b,logout) Clear stored session and invalidate token";
382 `P "$(b,whoami) Show current session information";
383 `P "$(b,msg) Send a message to a room or user";
384 `S "PROFILES";
385 `P "$(b,omatrix) supports multiple profiles for managing different Matrix \
386 accounts. Each profile stores its own session data independently.";
387 `P "Session data is stored in $(b,\\$XDG_DATA_HOME/matrix/profiles/NAME/).";
388 `P "The default profile is named 'default'. Use $(b,--profile NAME) to \
389 use a different profile.";
390 `S Manpage.s_environment;
391 `I ("$(b,MATRIX_HOMESERVER)", "Default homeserver URL");
392 `I ("$(b,MATRIX_USERNAME)", "Default username");
393 `I ("$(b,MATRIX_PASSWORD)", "Password (preferred over command-line)");
394 `S Manpage.s_bugs;
395 `P "Report bugs at <https://github.com/ocaml-matrix/ocaml-matrix/issues>.";
396 ] in
397 let default = Term.(ret (const (`Help (`Auto, None)))) in
398 let info = Cmdliner.Cmd.info "omatrix" ~version:"0.1.0" ~doc ~man in
399 Cmdliner.Cmd.group info ~default [
400 login_cmd;
401 logout_cmd;
402 whoami_cmd;
403 msg_cmd;
404 ]
405
406let () = exit (Cmdliner.Cmd.eval main_cmd)