Matrix protocol in OCaml, Eio specialised
at main 406 lines 15 kB view raw
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)