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:
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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:?}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -1711,3 +1711,85 @@ fn a_third_party_client_browses_a_large_share() {
|
||||
|
||||
let _ = std::fs::remove_dir_all(share_dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_server_reports_another_users_status_and_share_counts() {
|
||||
let server = server_or_skip!();
|
||||
|
||||
// A sharer with a known number of files, so the statistics are checkable.
|
||||
let share_dir = unique_download_dir();
|
||||
std::fs::create_dir_all(share_dir.join("album")).unwrap();
|
||||
for name in ["one.flac", "two.flac"] {
|
||||
std::fs::write(share_dir.join("album").join(name), b"xxxx").unwrap();
|
||||
}
|
||||
let mut subject = Client::with_settings(ClientSettings {
|
||||
shared_directories: vec![share_dir.display().to_string()],
|
||||
..server.settings("e2e_info_subject", "pw")
|
||||
});
|
||||
subject.connect().expect("subject connect");
|
||||
assert!(subject.login().expect("subject login"));
|
||||
|
||||
let mut asker =
|
||||
Client::with_settings(server.settings("e2e_info_asker", "pw"));
|
||||
asker.connect().expect("asker connect");
|
||||
assert!(asker.login().expect("asker login"));
|
||||
|
||||
asker
|
||||
.request_user_info("e2e_info_subject")
|
||||
.expect("ask about the subject");
|
||||
|
||||
// Status and statistics arrive as two separate replies.
|
||||
let deadline = Instant::now() + Duration::from_secs(15);
|
||||
let mut info = None;
|
||||
while Instant::now() < deadline {
|
||||
match asker.user_info("e2e_info_subject") {
|
||||
Some(found) if found.is_complete() => {
|
||||
info = Some(found);
|
||||
break;
|
||||
}
|
||||
_ => std::thread::sleep(Duration::from_millis(100)),
|
||||
}
|
||||
}
|
||||
let info = info.expect("the server should answer with status and stats");
|
||||
|
||||
assert_eq!(info.username, "e2e_info_subject");
|
||||
let presence = info.presence.expect("presence should have arrived");
|
||||
assert!(
|
||||
presence.status.is_reachable(),
|
||||
"a logged-in user should not read as offline, got {}",
|
||||
presence.status
|
||||
);
|
||||
let stats = info.stats.expect("stats should have arrived");
|
||||
assert_eq!(stats.shared_files, 2, "the subject shares two files");
|
||||
assert!(stats.shared_folders >= 1, "and at least one folder");
|
||||
|
||||
let _ = std::fs::remove_dir_all(share_dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_user_who_never_logged_in_reads_as_offline() {
|
||||
let server = server_or_skip!();
|
||||
let mut asker =
|
||||
Client::with_settings(server.settings("e2e_info_asker_b", "pw"));
|
||||
asker.connect().expect("asker connect");
|
||||
assert!(asker.login().expect("asker login"));
|
||||
|
||||
asker.request_user_info("nobody_by_this_name").expect("ask");
|
||||
|
||||
let deadline = Instant::now() + Duration::from_secs(10);
|
||||
let mut info = None;
|
||||
while Instant::now() < deadline && info.is_none() {
|
||||
info = asker
|
||||
.user_info("nobody_by_this_name")
|
||||
.filter(|found| found.presence.is_some());
|
||||
if info.is_none() {
|
||||
std::thread::sleep(Duration::from_millis(100));
|
||||
}
|
||||
}
|
||||
|
||||
let presence = info
|
||||
.expect("the server should answer for an unknown user")
|
||||
.presence
|
||||
.expect("with a presence");
|
||||
assert!(!presence.status.is_reachable(), "got {}", presence.status);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user