feat(cli): expose sharing, peer lookup and settings as one-shot commands

An agent driving the binary could search, fetch and chat, but it could not be
a peer: nothing kept a session online to serve files, nothing reported an
upload, shares could only be set at startup, and there was no way to ask
whether a peer was worth queueing to or to check credentials without doing
something else.

New commands:

  serve [--follow]                stay online sharing; one record per upload
  whoami                          credentials, connection and what we offer
  user <NAME>                     a peer's presence and share counts
  room users <ROOM>               the member roster
  shares list|add|remove|status|reindex
  config path|list|get|set

`config` and `shares list|add|remove` need no account, so they run before the
credential check — asking a script to log in for a `config get` would be a
poor trade. `serve` is the only place uploads are visible: they live in the
process serving them, so a one-shot `uploads list` could print nothing but an
empty list.

Library additions, all request/poll pairs matching the existing conventions:

- Client::room_members, kept current from the join and leave events
- Client::request_user_info / user_info, backed by new handlers for
  GetUserStatus (7) and GetUserStats (36) — codes the dispatcher previously
  logged as unhandled. The wire format is taken from soulfind's own source
  and pinned by an e2e test that asserts a peer's real share count.
- UserInfo carries presence and stats as Options, so a reply that never
  arrived cannot be read as "offline" or "shares nothing"
- request_user_info clears the previous answer, so the documented poll loop
  observes this request rather than the last one
- ServerMessage and ClientOperation are now #[non_exhaustive]: every protocol
  message the client learns adds a variant, and that should not break callers
- the crate root and prelude re-export every type a Client method returns

Also pins the output contract with tests: under --json, stdout carries only
newline-delimited JSON across fifteen command shapes while progress still
goes to stderr, and a failing command writes nothing to stdout at all.
This commit is contained in:
Michel de Graaf
2026-07-25 01:30:48 +02:00
parent be6581ee6c
commit 03e8924a6b
21 changed files with 2187 additions and 19 deletions
+53 -1
View File
@@ -13,11 +13,13 @@ use crate::message::server::MessageUser;
use crate::message::server::ParentMinSpeedHandler;
use crate::message::server::ParentSpeedRatioHandler;
use crate::message::server::PrivilegedUsersHandler;
use crate::message::server::RoomListHandler;
use crate::message::server::SayChatroomHandler;
use crate::message::server::UserJoinedRoomHandler;
use crate::message::server::UserLeftRoomHandler;
use crate::message::server::WishListIntervalHandler;
use crate::message::server::{
GetUserStatsHandler, GetUserStatusHandler, RoomListHandler,
};
use crate::message::{Handlers, MessageType};
use crate::message::{Message, MessageReader};
use crate::peer::ConnectionType;
@@ -141,7 +143,10 @@ impl UserMessage {
}
}
/// The server actor's mailbox. Marked non-exhaustive: each protocol message
/// the client learns adds a variant, and that must not break callers.
#[derive(Debug, Clone)]
#[non_exhaustive]
pub enum ServerMessage {
ProcessRead,
LoginStatus(bool),
@@ -175,6 +180,19 @@ pub enum ServerMessage {
},
PrivateMessageReceived(UserMessage),
RoomListReceived(Vec<RoomInfo>),
/// A `GetUserStatus` (code 7) reply.
UserStatusReceived {
username: String,
status: u32,
privileged: bool,
},
/// A `GetUserStats` (code 36) reply.
UserStatsReceived {
username: String,
average_speed: u32,
shared_files: u32,
shared_folders: u32,
},
RoomJoined {
room: String,
users: Vec<String>,
@@ -354,6 +372,8 @@ impl ServerActor {
handlers.register_handler(LoginHandler);
handlers.register_handler(RoomListHandler);
handlers.register_handler(GetUserStatusHandler);
handlers.register_handler(GetUserStatsHandler);
handlers.register_handler(JoinRoomHandler);
handlers.register_handler(LeaveRoomHandler);
handlers.register_handler(SayChatroomHandler);
@@ -452,6 +472,30 @@ impl ServerActor {
ServerMessage::RoomListReceived(rooms) => {
self.forward_room_event(RoomEvent::List(rooms));
}
ServerMessage::UserStatusReceived {
username,
status,
privileged,
} => {
self.forward_to_client(ClientOperation::UserStatusReceived {
username,
status,
privileged,
});
}
ServerMessage::UserStatsReceived {
username,
average_speed,
shared_files,
shared_folders,
} => {
self.forward_to_client(ClientOperation::UserStatsReceived {
username,
average_speed,
shared_files,
shared_folders,
});
}
ServerMessage::RoomJoined { room, users } => {
self.forward_room_event(RoomEvent::Joined { room, users });
}
@@ -566,6 +610,14 @@ impl ServerActor {
}
}
/// Hand an operation to the client loop, logging a dead channel rather
/// than unwinding the actor.
fn forward_to_client(&self, operation: ClientOperation) {
if let Err(e) = self.client_channel.send(operation) {
error!("[server] Error forwarding to client: {}", e);
}
}
fn handle_private_message_received(&self, user_message: UserMessage) {
debug!("[server] Private message from {}", user_message.username());
if let Err(e) = self
+202 -8
View File
@@ -3,7 +3,10 @@ use crate::actor::server_actor::{
PeerAddress, ServerActor, ServerMessage, UserMessage,
};
use crate::download_store::{DownloadStore, collect_failed_tokens};
use crate::types::{DownloadMetadata, DownloadStatus, RoomEvent, RoomInfo};
use crate::types::{
DownloadMetadata, DownloadStatus, RoomEvent, RoomInfo, UserInfo,
UserPresence, UserStats, UserStatus,
};
use crate::utils::logger;
use crate::{
Transfer,
@@ -167,8 +170,10 @@ impl Default for ClientSettings {
}
}
/// The client loop's mailbox. Non-exhaustive for the same reason as
/// [`ServerMessage`]: new protocol coverage adds variants.
#[derive(Debug)]
#[non_exhaustive]
pub enum ClientOperation {
NewPeer(NewPeer),
ConnectToPeer(Peer),
@@ -192,6 +197,19 @@ pub enum ClientOperation {
},
SetServerSender(Sender<ServerMessage>),
PrivateMessageReceived(UserMessage),
/// The server answered `GetUserStatus` for a user we asked about.
UserStatusReceived {
username: String,
status: u32,
privileged: bool,
},
/// The server answered `GetUserStats` for a user we asked about.
UserStatsReceived {
username: String,
average_speed: u32,
shared_files: u32,
shared_folders: u32,
},
PeerConnected(String),
/// A search distributed to us by the server; reply if our shares match.
IncomingSearch {
@@ -255,6 +273,12 @@ pub struct ClientContext {
room_list: Vec<RoomInfo>,
/// Chat-room events awaiting consumption by the client/UI.
room_events: Vec<RoomEvent>,
/// Who is in each room we have joined, kept current from the membership
/// the server sends on join plus the later join/leave events.
room_members: HashMap<String, Vec<String>>,
/// What the server has told us about other users, merged across the
/// separate status and statistics replies.
user_info: HashMap<String, UserInfo>,
actor_system: Arc<ActorSystem>,
}
impl Default for ClientContext {
@@ -549,20 +573,100 @@ impl ClientContext {
browse_results: HashMap::new(),
room_list: Vec::new(),
room_events: Vec::new(),
room_members: HashMap::new(),
user_info: HashMap::new(),
downloads: DownloadStore::new(),
actor_system,
}
}
/// Apply a chat-room event: keep the room-list snapshot current and queue
/// the event for the client/UI to drain.
/// Apply a chat-room event: keep the room-list snapshot and the per-room
/// rosters current, then queue the event for the client/UI to drain.
pub fn apply_room_event(&mut self, event: RoomEvent) {
if let RoomEvent::List(rooms) = &event {
self.room_list.clone_from(rooms);
match &event {
RoomEvent::List(rooms) => self.room_list.clone_from(rooms),
RoomEvent::Joined { room, users } => {
let mut members = users.clone();
members.sort();
members.dedup();
self.room_members.insert(room.clone(), members);
}
RoomEvent::Left { room } => {
self.room_members.remove(room);
}
RoomEvent::UserJoined { room, username } => {
let members =
self.room_members.entry(room.clone()).or_default();
if let Err(at) = members.binary_search(username) {
members.insert(at, username.clone());
}
}
RoomEvent::UserLeft { room, username } => {
if let Some(members) = self.room_members.get_mut(room)
&& let Ok(at) = members.binary_search(username)
{
members.remove(at);
}
}
RoomEvent::Message { .. } => {}
}
self.room_events.push(event);
}
/// Record a `GetUserStatus` reply, merging it with any statistics already
/// received for that user.
pub fn apply_user_status(
&mut self,
username: String,
status: u32,
privileged: bool,
) {
self.user_info
.entry(username.clone())
.or_insert_with(|| UserInfo::pending(username))
.presence = Some(UserPresence {
status: UserStatus::from_code(status),
privileged,
});
}
/// Forget what we know about `username`, so the next poll reports the
/// answer to the request being made now rather than the previous one.
pub fn invalidate_user_info(&mut self, username: &str) {
self.user_info.remove(username);
}
/// Record a `GetUserStats` reply, merging it with any status already
/// received for that user.
pub fn apply_user_stats(
&mut self,
username: String,
average_speed: u32,
shared_files: u32,
shared_folders: u32,
) {
self.user_info
.entry(username.clone())
.or_insert_with(|| UserInfo::pending(username))
.stats = Some(UserStats {
average_speed,
shared_files,
shared_folders,
});
}
/// What the server has said about `username` so far.
#[must_use]
pub fn user_info(&self, username: &str) -> Option<UserInfo> {
self.user_info.get(username).cloned()
}
/// Who is currently in `room`, sorted, or empty when we are not in it.
#[must_use]
pub fn room_members(&self, room: &str) -> Vec<String> {
self.room_members.get(room).cloned().unwrap_or_default()
}
/// The latest snapshot of the public chat-room list.
#[must_use]
pub fn room_list(&self) -> Vec<RoomInfo> {
@@ -812,10 +916,100 @@ mod uploads;
#[cfg(test)]
mod tests {
use super::upload_speed;
use crate::types::UploadStatus;
use super::{ClientContext, upload_speed};
use crate::types::{RoomEvent, UploadStatus};
use std::time::{Duration, Instant};
#[test]
fn the_room_roster_follows_the_joins_and_leaves_the_server_reports() {
let mut context = ClientContext::new();
// Joining hands us the current membership in one event.
context.apply_room_event(RoomEvent::Joined {
room: "lobby".to_string(),
users: vec!["bob".to_string(), "alice".to_string()],
});
assert_eq!(context.room_members("lobby"), ["alice", "bob"]);
context.apply_room_event(RoomEvent::UserJoined {
room: "lobby".to_string(),
username: "carol".to_string(),
});
context.apply_room_event(RoomEvent::UserLeft {
room: "lobby".to_string(),
username: "bob".to_string(),
});
assert_eq!(context.room_members("lobby"), ["alice", "carol"]);
// Events for other rooms do not leak into this one.
context.apply_room_event(RoomEvent::UserJoined {
room: "elsewhere".to_string(),
username: "dave".to_string(),
});
assert_eq!(context.room_members("lobby"), ["alice", "carol"]);
// A room we never joined has no roster, and leaving forgets it.
assert!(context.room_members("unknown").is_empty());
context.apply_room_event(RoomEvent::Left {
room: "lobby".to_string(),
});
assert!(context.room_members("lobby").is_empty());
}
#[test]
fn a_fresh_request_discards_the_previous_answer() {
// Without this, a poll after a second request returns the old
// snapshot immediately and the caller cannot tell stale from fresh.
let mut context = ClientContext::new();
context.apply_user_status("alice".to_string(), 2, false);
context.apply_user_stats("alice".to_string(), 10, 20, 30);
assert!(context.user_info("alice").is_some_and(|i| i.is_complete()));
context.invalidate_user_info("alice");
assert!(
context.user_info("alice").is_none(),
"a new request must not be answerable from the old reply"
);
}
#[test]
fn each_reply_fills_only_its_own_half() {
let mut context = ClientContext::new();
context.apply_user_status("bob".to_string(), 1, true);
let info = context.user_info("bob").expect("a snapshot");
assert!(!info.is_complete(), "stats have not arrived");
assert_eq!(
info.presence.map(|p| p.status),
Some(crate::types::UserStatus::Away)
);
assert!(info.stats.is_none(), "must not invent statistics");
context.apply_user_stats("bob".to_string(), 5, 6, 7);
let info = context.user_info("bob").expect("a snapshot");
assert!(info.is_complete());
assert_eq!(info.stats.map(|s| s.shared_files), Some(6));
assert_eq!(
info.presence.map(|p| p.privileged),
Some(true),
"the earlier half must survive the merge"
);
}
#[test]
fn a_user_joining_twice_is_listed_once() {
let mut context = ClientContext::new();
context.apply_room_event(RoomEvent::Joined {
room: "lobby".to_string(),
users: vec!["alice".to_string()],
});
context.apply_room_event(RoomEvent::UserJoined {
room: "lobby".to_string(),
username: "alice".to_string(),
});
assert_eq!(context.room_members("lobby"), ["alice"]);
}
#[test]
fn upload_speed_is_reported_only_while_running() {
let two_seconds_ago = Instant::now()
+30
View File
@@ -492,6 +492,36 @@ impl Client {
e
),
},
ClientOperation::UserStatusReceived {
username,
status,
privileged,
} => match client_context.write_safe() {
Ok(mut ctx) => ctx.apply_user_status(
username, status, privileged,
),
Err(e) => error!(
"[client] UserStatusReceived write: {}",
e
),
},
ClientOperation::UserStatsReceived {
username,
average_speed,
shared_files,
shared_folders,
} => match client_context.write_safe() {
Ok(mut ctx) => ctx.apply_user_stats(
username,
average_speed,
shared_files,
shared_folders,
),
Err(e) => error!(
"[client] UserStatsReceived write: {}",
e
),
},
ClientOperation::RoomEvent(event) => {
match client_context.write_safe() {
Ok(mut ctx) => ctx.apply_room_event(event),
+55
View File
@@ -2,6 +2,7 @@ use super::{
Client, PeerMessage, Result, RoomEvent, RoomInfo, RwLockExt, ServerMessage,
SharedDirectory, SoulseekRs, UserMessage, error,
};
use crate::types::UserInfo;
impl Client {
/// Send a private message to another user via the server.
@@ -101,6 +102,60 @@ impl Client {
}
}
/// Ask the server what it knows about `username`: online status and share
/// statistics.
///
/// Any previous answer for `username` is discarded first, so
/// [`Client::user_info`] returns `None` until the replies to *this*
/// request arrive — polling it cannot mistake a stale snapshot for a
/// fresh one.
///
/// # Errors
/// Returns [`SoulseekRs::NotConnected`] if the client is not connected.
pub fn request_user_info(&self, username: &str) -> Result<()> {
use crate::message::server::MessageFactory;
self.context.write_safe()?.invalidate_user_info(username);
self.send_server_message(MessageFactory::build_get_user_status(
username,
))?;
self.send_server_message(MessageFactory::build_get_user_stats(username))
}
/// What the server has told us about `username` since the last
/// [`Client::request_user_info`], or `None` before any reply arrives.
///
/// Presence and statistics are separate replies, so a snapshot can carry
/// one and not the other; [`UserInfo::is_complete`] reports when both
/// have landed.
#[must_use]
pub fn user_info(&self, username: &str) -> Option<UserInfo> {
match self.context.read_safe() {
Ok(ctx) => ctx.user_info(username),
Err(e) => {
error!("[client] user_info: {}", e);
None
}
}
}
/// Who is currently in `room`, sorted by name.
///
/// The roster is built from the membership the server sends when
/// [`Client::join_room`] succeeds and kept current by the join and leave
/// events after it, so it is populated shortly after joining rather than
/// immediately. Returns an empty list for a room this client has not
/// joined.
#[must_use]
pub fn room_members(&self, room: &str) -> Vec<String> {
match self.context.read_safe() {
Ok(ctx) => ctx.room_members(room),
Err(e) => {
error!("[client] room_members: {}", e);
Vec::new()
}
}
}
/// Remove and return all chat-room events received since the last call.
#[must_use]
pub fn take_room_events(&self) -> Vec<RoomEvent> {
+7 -2
View File
@@ -15,7 +15,9 @@ pub mod utils;
pub mod prelude {
pub use crate::actor::server_actor::PeerAddress;
pub use crate::types::{
DownloadStatus, File, Search, SearchResult, Transfer,
DownloadStatus, File, RoomEvent, RoomInfo, Search, SearchResult,
Transfer, UploadInfo, UploadStatus, UserInfo, UserPresence, UserStats,
UserStatus,
};
pub use crate::{debug, error, info, trace, warn};
}
@@ -25,4 +27,7 @@ pub use actor::server_actor::{PeerAddress, UserMessage};
pub use client::{Client, ClientSettings};
pub use error::{Result, SoulseekRs};
pub use message::peer::SharedDirectory;
pub use types::{DownloadStatus, File, Search, SearchResult, Transfer};
pub use types::{
DownloadStatus, File, RoomEvent, RoomInfo, Search, SearchResult, Transfer,
UploadInfo, UploadStatus, UserInfo, UserPresence, UserStats, UserStatus,
};
@@ -122,6 +122,21 @@ impl MessageFactory {
.clone()
}
/// Ask the server (code 7) for a user's online status.
#[must_use]
pub fn build_get_user_status(username: &str) -> Message {
Message::new().write_int32(7).write_string(username).clone()
}
/// Ask the server (code 36) for a user's share statistics.
#[must_use]
pub fn build_get_user_stats(username: &str) -> Message {
Message::new()
.write_int32(36)
.write_string(username)
.clone()
}
/// Ask the server (code 64) for the list of public chat rooms.
#[must_use]
pub fn build_room_list_request() -> Message {
@@ -228,6 +243,20 @@ impl MessageFactory {
}
}
#[test]
fn test_build_get_user_status() {
let message = MessageFactory::build_get_user_status("bob");
let expect: Vec<u8> = [7, 0, 0, 0, 3, 0, 0, 0, b'b', b'o', b'b'].to_vec();
assert_eq!(expect, message.get_data());
}
#[test]
fn test_build_get_user_stats() {
let message = MessageFactory::build_get_user_stats("bob");
let expect: Vec<u8> = [36, 0, 0, 0, 3, 0, 0, 0, b'b', b'o', b'b'].to_vec();
assert_eq!(expect, message.get_data());
}
#[test]
fn test_build_watch_user() {
let token: u32 = 223;
@@ -12,6 +12,7 @@ mod parent_speed_ratio;
mod privileged_users;
mod room_list;
mod say_chatroom;
mod user_info;
mod user_joined_room;
mod user_left_room;
mod wish_list_interval;
@@ -30,6 +31,7 @@ pub use parent_speed_ratio::ParentSpeedRatioHandler;
pub use privileged_users::PrivilegedUsersHandler;
pub use room_list::RoomListHandler;
pub use say_chatroom::SayChatroomHandler;
pub use user_info::{GetUserStatsHandler, GetUserStatusHandler};
pub use user_joined_room::UserJoinedRoomHandler;
pub use user_left_room::UserLeftRoomHandler;
pub use wish_list_interval::WishListIntervalHandler;
@@ -0,0 +1,121 @@
//! `GetUserStatus` (code 7) and `GetUserStats` (code 36): what the server
//! knows about another user.
//!
//! The two answers arrive as separate messages, so a caller that wants a full
//! picture asks for both and merges them — see `Client::request_user_info`.
use crate::actor::server_actor::ServerMessage;
use crate::message::{Message, MessageHandler};
use std::sync::mpsc::Sender;
/// Receives `GetUserStatus` (code 7): `username`, `status`, `privileged`.
pub struct GetUserStatusHandler;
impl MessageHandler<ServerMessage> for GetUserStatusHandler {
fn get_code(&self) -> u8 {
7
}
fn handle(&self, message: &mut Message, sender: Sender<ServerMessage>) {
let username = message.read_string();
let status = message.read_int32();
let privileged = message.read_bool();
let _ = sender.send(ServerMessage::UserStatusReceived {
username,
status,
privileged,
});
}
}
/// Receives `GetUserStats` (code 36): `username`, `average upload speed`, two
/// obsolete fields, then the shared file and folder counts.
pub struct GetUserStatsHandler;
impl MessageHandler<ServerMessage> for GetUserStatsHandler {
fn get_code(&self) -> u8 {
36
}
fn handle(&self, message: &mut Message, sender: Sender<ServerMessage>) {
let username = message.read_string();
let average_speed = message.read_int32();
let _upload_number = message.read_int32(); // obsolete, always 0
let _unknown = message.read_int32(); // obsolete, always 0
let shared_files = message.read_int32();
let shared_folders = message.read_int32();
let _ = sender.send(ServerMessage::UserStatsReceived {
username,
average_speed,
shared_files,
shared_folders,
});
}
}
#[cfg(test)]
mod tests {
use super::*;
/// Build a payload the dispatcher would hand a handler: 8 bytes of frame
/// header, then the fields, with the pointer parked at the payload.
fn payload(build: impl FnOnce(&mut Message)) -> Message {
let mut message = Message::new();
message.write_raw_bytes(vec![0u8; 8]);
build(&mut message);
message.set_pointer(8);
message
}
#[test]
fn user_status_is_parsed_with_its_privileged_flag() {
let (tx, rx) = std::sync::mpsc::channel();
let mut message = payload(|m| {
m.write_string("alice").write_int32(2).write_int8(1);
});
GetUserStatusHandler.handle(&mut message, tx);
match rx.try_recv() {
Ok(ServerMessage::UserStatusReceived {
username,
status,
privileged,
}) => {
assert_eq!(username, "alice");
assert_eq!(status, 2);
assert!(privileged);
}
other => panic!("unexpected: {other:?}"),
}
}
#[test]
fn user_stats_skip_the_two_obsolete_fields() {
let (tx, rx) = std::sync::mpsc::channel();
// username, speed, obsolete, obsolete, files, folders
let mut message = payload(|m| {
m.write_string("bob")
.write_int32(1024)
.write_int32(0)
.write_int32(0)
.write_int32(4321)
.write_int32(77);
});
GetUserStatsHandler.handle(&mut message, tx);
match rx.try_recv() {
Ok(ServerMessage::UserStatsReceived {
username,
average_speed,
shared_files,
shared_folders,
}) => {
assert_eq!(username, "bob");
assert_eq!(average_speed, 1024);
assert_eq!(shared_files, 4321, "must skip the obsolete pair");
assert_eq!(shared_folders, 77);
}
other => panic!("unexpected: {other:?}"),
}
}
}
+89
View File
@@ -174,6 +174,95 @@ pub enum DownloadStatus {
TimedOut,
}
/// Whether a user is reachable, as the server reports it.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum UserStatus {
#[default]
Offline,
Away,
Online,
}
impl UserStatus {
/// Map the wire value (`GetUserStatus`, code 7). Anything unrecognised is
/// treated as offline, which is the safe reading for "cannot be reached".
#[must_use]
pub const fn from_code(code: u32) -> Self {
match code {
1 => Self::Away,
2 => Self::Online,
_ => Self::Offline,
}
}
/// True when the user can be reached — online or away, but not offline.
#[must_use]
pub const fn is_reachable(self) -> bool {
matches!(self, Self::Away | Self::Online)
}
}
impl std::fmt::Display for UserStatus {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(match self {
Self::Offline => "offline",
Self::Away => "away",
Self::Online => "online",
})
}
}
/// A user's presence, from `GetUserStatus`.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct UserPresence {
pub status: UserStatus,
/// Whether the server grants this user queue priority.
pub privileged: bool,
}
/// A user's sharing statistics, from `GetUserStats`.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub struct UserStats {
/// Average upload speed in bytes per second, as the server records it.
pub average_speed: u32,
pub shared_files: u32,
pub shared_folders: u32,
}
/// What the server knows about another user.
///
/// The server answers presence and statistics as two separate messages, so
/// each part is `None` until its reply lands. A snapshot therefore cannot
/// report a status the server never sent — ask for
/// [`UserInfo::presence`] and handle `None` rather than reading a default.
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub struct UserInfo {
pub username: String,
/// `None` until the status reply arrives.
pub presence: Option<UserPresence>,
/// `None` until the statistics reply arrives.
pub stats: Option<UserStats>,
}
impl UserInfo {
/// An empty snapshot for `username`, before any reply has arrived.
#[must_use]
pub const fn pending(username: String) -> Self {
Self {
username,
presence: None,
stats: None,
}
}
/// True once both replies have landed.
#[must_use]
pub const fn is_complete(&self) -> bool {
self.presence.is_some() && self.stats.is_some()
}
}
/// A public chat room advertised by the server (`RoomList`, code 64).
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct RoomInfo {