(** omatrix - Command-line Matrix client. A CLI tool for interacting with Matrix homeservers, supporting session persistence and common operations like login, messaging, and room management. {b Quick Start} {[ # Login and store session omatrix login -s https://matrix.org -u @you:matrix.org # Send a direct message (uses stored session) omatrix msg -t @them:matrix.org "Hello!" # Send to a room omatrix msg -r '!roomid:matrix.org' "Hello room!" # Check current session omatrix whoami # Logout and clear session omatrix logout ]} Environment variables: - [MATRIX_HOMESERVER]: Default homeserver URL - [MATRIX_USERNAME]: Default username - [MATRIX_PASSWORD]: Password (preferred over command-line) *) open Cmdliner module Cmd = Matrix_client.Cmd module Session = Matrix_client.Session (* ================================================================ *) (* Shared State *) (* ================================================================ *) (** Application name for session metadata *) let app_name = "omatrix" (** Get XDG directories for session storage *) let with_xdg ~env f = let fs = Eio.Stdenv.fs env in let xdg = Xdge.create fs "matrix" in f xdg (** Load a stored session, returning the store and session data *) let load_session ~env ~profile = with_xdg ~env @@ fun xdg -> let store = Session.Store.create ~xdg ~profile in Session.Store.load_session store |> Option.map (fun session -> (store, session)) (** Log error for missing session *) let log_no_session ~profile = Logs.err (fun m -> m "No session found for profile '%s'" profile); Logs.err (fun m -> m "Use 'omatrix login' to authenticate first") (** Create a client from stored session credentials *) let client_from_session ~sw ~env (session : Session.Session_file.t) = let homeserver = session.server.homeserver in let client = Matrix_eio.Client.create ~sw ~env ~homeserver () in (* Restore session state *) let matrix_session : Matrix_client.Client.session = { user_id = session.server.user_id; device_id = session.auth.device_id; access_token = session.auth.access_token; refresh_token = session.auth.refresh_token; } in Matrix_eio.Client.with_session client matrix_session (* ================================================================ *) (* Login Command *) (* ================================================================ *) let login_run ~homeserver ~username ~password ~profile () = Eio_main.run @@ fun env -> Eio.Switch.run @@ fun sw -> Logs.info (fun m -> m "Connecting to %s" homeserver); let client = try Matrix_eio.login_password ~sw ~env ~homeserver:(Uri.of_string homeserver) ~user:username ~password () with Eio.Io (Matrix_eio.Error.E err, _) -> Logs.err (fun m -> m "Login failed: %a" Matrix_eio.Error.pp_err err); exit Cmd.exit_auth in let user_id = Matrix_eio.Client.user_id client in let device_id = Matrix_eio.Client.device_id client in let access_token = Matrix_eio.Client.access_token client in Logs.info (fun m -> m "Logged in as %s" (Matrix_proto.Id.User_id.to_string user_id)); (* Save session *) with_xdg ~env @@ fun xdg -> let store = Session.Store.create ~xdg ~profile in let now = Ptime_clock.now () in let session : Session.Session_file.t = { server = { homeserver = Uri.of_string homeserver; user_id; }; auth = { access_token; device_id; refresh_token = None; }; sync = { next_batch = None; filter_id = None; }; metadata = { created_at = now; last_used_at = now; client_name = app_name; }; } in Session.Store.save_session store session; Logs.app (fun m -> m "Session saved to profile '%s'" profile); Logs.app (fun m -> m "User ID: %s" (Matrix_proto.Id.User_id.to_string user_id)); Logs.app (fun m -> m "Device ID: %s" (Matrix_proto.Id.Device_id.to_string device_id)); `Ok () let login_term = let run () homeserver username password profile = login_run ~homeserver ~username ~password ~profile () in Term.(ret (const run $ Cmd.verbosity_term $ Cmd.homeserver_term $ Cmd.username_term $ Cmd.password_term $ Cmd.profile_term)) let login_cmd = let doc = "Authenticate with a Matrix homeserver" in let man = [ `S Manpage.s_description; `P "Logs in to a Matrix homeserver using password authentication and \ stores the session credentials for later use. The session is saved \ to the profile directory under $(b,\\$XDG_DATA_HOME/matrix/profiles/)."; `S Manpage.s_examples; `Pre " omatrix login -s https://matrix.org -u @you:matrix.org"; `P "Using environment variables:"; `Pre " export MATRIX_PASSWORD=secret"; `Pre " omatrix login -s https://matrix.org -u @you:matrix.org"; `P "Using a named profile:"; `Pre " omatrix login --profile work -s https://work.matrix.org -u @you:work.matrix.org"; ] in let info = Cmdliner.Cmd.info "login" ~doc ~man in Cmdliner.Cmd.v info login_term (* ================================================================ *) (* Logout Command *) (* ================================================================ *) let logout_run ~profile ~keep_local () = Eio_main.run @@ fun env -> Eio.Switch.run @@ fun sw -> match load_session ~env ~profile with | None -> Logs.warn (fun m -> m "No session found for profile '%s'" profile); `Ok () | Some (store, session) -> if not keep_local then begin (* Logout from server *) Logs.info (fun m -> m "Logging out from server..."); let client = client_from_session ~sw ~env session in (try Matrix_eio.Auth.logout client; Logs.info (fun m -> m "Server logout successful") with Eio.Io _ -> Logs.warn (fun m -> m "Server logout failed (session may still be active)")) end; (* Clear local session *) Session.Store.clear store; Logs.app (fun m -> m "Session cleared for profile '%s'" profile); `Ok () let keep_local_term = let doc = "Only clear local session without notifying the server." in Arg.(value & flag & info ["local"] ~doc) let logout_term = let run () profile keep_local = logout_run ~profile ~keep_local () in Term.(ret (const run $ Cmd.verbosity_term $ Cmd.profile_term $ keep_local_term)) let logout_cmd = let doc = "Log out and clear stored session" in let man = [ `S Manpage.s_description; `P "Logs out from the Matrix homeserver and clears the stored session. \ By default, this invalidates the access token on the server."; `S Manpage.s_examples; `Pre " omatrix logout"; `P "Clear only the local session (keep server session active):"; `Pre " omatrix logout --local"; `P "Logout from a specific profile:"; `Pre " omatrix logout --profile work"; ] in let info = Cmdliner.Cmd.info "logout" ~doc ~man in Cmdliner.Cmd.v info logout_term (* ================================================================ *) (* Whoami Command *) (* ================================================================ *) let whoami_run ~profile () = Eio_main.run @@ fun env -> match load_session ~env ~profile with | None -> log_no_session ~profile; `Error (false, "Not logged in") | Some (_store, session) -> let user_id = Matrix_proto.Id.User_id.to_string session.server.user_id in let device_id = Matrix_proto.Id.Device_id.to_string session.auth.device_id in let homeserver = Uri.to_string session.server.homeserver in Format.printf "Profile: %s@." profile; Format.printf "User ID: %s@." user_id; Format.printf "Device ID: %s@." device_id; Format.printf "Homeserver: %s@." homeserver; Format.printf "Last used: %a@." (Ptime.pp_rfc3339 ()) session.metadata.last_used_at; `Ok () let whoami_term = let run () profile = whoami_run ~profile () in Term.(ret (const run $ Cmd.verbosity_term $ Cmd.profile_term)) let whoami_cmd = let doc = "Show current session information" in let man = [ `S Manpage.s_description; `P "Displays information about the currently stored session, including \ the user ID, device ID, and homeserver."; `S Manpage.s_examples; `Pre " omatrix whoami"; `Pre " omatrix whoami --profile work"; ] in let info = Cmdliner.Cmd.info "whoami" ~doc ~man in Cmdliner.Cmd.v info whoami_term (* ================================================================ *) (* Msg Command *) (* ================================================================ *) let msg_run ~profile ~room ~recipient ~message ~encrypted () = Eio_main.run @@ fun env -> Eio.Switch.run @@ fun sw -> (* Load session *) let store, session = match load_session ~env ~profile with | None -> log_no_session ~profile; exit Cmd.exit_auth | Some s -> s in let client = client_from_session ~sw ~env session in let user_id = Matrix_eio.Client.user_id client in Logs.info (fun m -> m "Using session for %s" (Matrix_proto.Id.User_id.to_string user_id)); (* Determine target room *) let room_id = match room, recipient with | Some room_str, None -> (* Direct room ID *) (match Matrix_proto.Id.Room_id.of_string room_str with | Ok id -> id | Error (`Invalid_room_id msg) -> Logs.err (fun m -> m "Invalid room ID '%s': %s" room_str msg); exit Cmd.exit_usage) | None, Some recipient_str -> (* DM - find or create room *) let recipient_id = match Matrix_proto.Id.User_id.of_string recipient_str with | Ok id -> id | Error (`Invalid_user_id msg) -> Logs.err (fun m -> m "Invalid user ID '%s': %s" recipient_str msg); exit Cmd.exit_usage in Logs.info (fun m -> m "Finding or creating DM room with %s" recipient_str); let encrypted_opt = if encrypted then Some true else None in (try Matrix_eio.Rooms.get_or_create_dm client ~user_id:recipient_id ?encrypted:encrypted_opt () with Eio.Io (Matrix_eio.Error.E err, _) -> Logs.err (fun m -> m "Failed to get/create DM room: %a" Matrix_eio.Error.pp_err err); exit Cmd.exit_network) | Some _, Some _ -> Logs.err (fun m -> m "Cannot specify both --room and --to"); exit Cmd.exit_usage | None, None -> Logs.err (fun m -> m "Must specify --room or --to"); exit Cmd.exit_usage in Logs.info (fun m -> m "Sending to room %s" (Matrix_proto.Id.Room_id.to_string room_id)); (* Send message *) let event_id = try Matrix_eio.Messages.send_text client ~room_id ~body:message () with Eio.Io (Matrix_eio.Error.E err, _) -> Logs.err (fun m -> m "Failed to send message: %a" Matrix_eio.Error.pp_err err); exit Cmd.exit_network in Logs.app (fun m -> m "Message sent (event ID: %s)" (Matrix_proto.Id.Event_id.to_string event_id)); (* Update last_used_at *) let updated_session = { session with metadata = { session.metadata with last_used_at = Ptime_clock.now () } } in Session.Store.save_session store updated_session; `Ok () let msg_term = let run () profile room recipient message encrypted = msg_run ~profile ~room ~recipient ~message ~encrypted () in Term.(ret (const run $ Cmd.verbosity_term $ Cmd.profile_term $ Cmd.room_opt_term $ Cmd.recipient_opt_term $ Cmd.message_term $ Cmd.encrypted_term)) let msg_cmd = let doc = "Send a message to a room or user" in let man = [ `S Manpage.s_description; `P "Sends a text message to a Matrix room or directly to another user. \ Requires a stored session from $(b,omatrix login)."; `P "For direct messages (using $(b,--to)), an existing DM room is reused \ if one exists, otherwise a new room is created."; `S Manpage.s_examples; `P "Send a direct message:"; `Pre " omatrix msg -t @alice:matrix.org \"Hello Alice!\""; `P "Send to a room:"; `Pre " omatrix msg -r '!roomid:matrix.org' \"Hello room!\""; `P "Create encrypted DM (for new rooms):"; `Pre " omatrix msg -t @alice:matrix.org -e \"Secret message\""; `P "Using a different profile:"; `Pre " omatrix msg --profile work -t @colleague:work.org \"Meeting at 3pm\""; ] in let info = Cmdliner.Cmd.info "msg" ~doc ~man in Cmdliner.Cmd.v info msg_term (* ================================================================ *) (* Main Command Group *) (* ================================================================ *) let main_cmd = let doc = "Command-line Matrix client" in let man = [ `S Manpage.s_description; `P "$(b,omatrix) is a command-line client for the Matrix communication \ protocol. It supports session persistence, allowing you to log in \ once and perform subsequent operations without re-authenticating."; `S Manpage.s_commands; `P "$(b,login) Authenticate with a homeserver and store session"; `P "$(b,logout) Clear stored session and invalidate token"; `P "$(b,whoami) Show current session information"; `P "$(b,msg) Send a message to a room or user"; `S "PROFILES"; `P "$(b,omatrix) supports multiple profiles for managing different Matrix \ accounts. Each profile stores its own session data independently."; `P "Session data is stored in $(b,\\$XDG_DATA_HOME/matrix/profiles/NAME/)."; `P "The default profile is named 'default'. Use $(b,--profile NAME) to \ use a different profile."; `S Manpage.s_environment; `I ("$(b,MATRIX_HOMESERVER)", "Default homeserver URL"); `I ("$(b,MATRIX_USERNAME)", "Default username"); `I ("$(b,MATRIX_PASSWORD)", "Password (preferred over command-line)"); `S Manpage.s_bugs; `P "Report bugs at ."; ] in let default = Term.(ret (const (`Help (`Auto, None)))) in let info = Cmdliner.Cmd.info "omatrix" ~version:"0.1.0" ~doc ~man in Cmdliner.Cmd.group info ~default [ login_cmd; logout_cmd; whoami_cmd; msg_cmd; ] let () = exit (Cmdliner.Cmd.eval main_cmd)