feat(lib): watch and unwatch users (WatchUser 5 / UnwatchUser 6)

Adds the server-side watch subscription a buddy list needs: WatchUser (5)
asks the server to keep pushing a user's status, UnwatchUser (6) cancels it.
The reply to a watch carries the user's current status and share statistics,
which merge into the existing per-user snapshot read via Client::user_info.

Issue #47 asked for AddUserList (141) / WatchedUserAdded (142) /
WatchedUserRemoved (143). Those codes are EnableRoomInvitations,
ChangePassword and AddRoomOperator in the Soulseek protocol -- there are no
such messages on the wire. Watching is codes 5 and 6, so that is what this
implements.

- message/server/watch_user.rs: WatchUserHandler (code 5), tolerant of an
  absent user and of a truncated frame
- MessageFactory::build_watch_user / build_unwatch_user
- Client::watch_user / unwatch_user / watched_users
- The watch reply has no privileged flag, so it preserves whatever an
  earlier GetUserStatus recorded rather than asserting false
This commit is contained in:
Michel de Graaf
2026-08-28 12:44:43 +00:00
parent 0f91ffdcf3
commit b505718db9
8 changed files with 417 additions and 0 deletions
@@ -0,0 +1,140 @@
//! `WatchUser` (code 5): subscribe to a user's status and share statistics.
//!
//! Watching is how a client keeps a buddy list current. After a `WatchUser`
//! request the server answers on the same code with the user's current
//! presence and stats, and from then on pushes every later change as an
//! ordinary `GetUserStatus` (code 7). `UnwatchUser` (code 6) cancels the
//! subscription and has no reply.
//!
//! Note on codes: the protocol has no `AddUserList`/`WatchedUserAdded`
//! /`WatchedUserRemoved` messages — 141/142/143 are `EnableRoomInvitations`,
//! `ChangePassword` and `AddRoomOperator`. Watching is 5/6, per the reference
//! protocol documentation.
use crate::actor::server_actor::ServerMessage;
use crate::message::{Message, MessageHandler};
use std::sync::mpsc::Sender;
/// Receives the `WatchUser` reply (code 5): `username`, `exists`, and — when
/// the user exists — status plus the same share statistics `GetUserStats`
/// carries.
pub struct WatchUserHandler;
impl MessageHandler<ServerMessage> for WatchUserHandler {
fn get_code(&self) -> u8 {
5
}
fn handle(&self, message: &mut Message, sender: Sender<ServerMessage>) {
let username = message.read_string();
let exists = message.read_bool();
let watched = if exists {
let status = message.read_int32();
let average_speed = message.read_int32();
let _upload_number = message.read_int64(); // obsolete
let shared_files = message.read_int32();
let shared_folders = message.read_int32();
ServerMessage::WatchedUserReceived {
username,
exists,
status: Some(status),
average_speed: Some(average_speed),
shared_files: Some(shared_files),
shared_folders: Some(shared_folders),
}
} else {
// An unknown username: the server stops after the flag, so there
// is nothing further to read and nothing to record.
ServerMessage::WatchedUserReceived {
username,
exists,
status: None,
average_speed: None,
shared_files: None,
shared_folders: None,
}
};
let _ = sender.send(watched);
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::message::framed;
#[test]
fn existing_user_carries_status_and_stats() {
let (tx, rx) = std::sync::mpsc::channel();
let mut message = framed(|m| {
m.write_string("alice")
.write_int8(1) // exists
.write_int32(2) // status: online
.write_int32(1024) // average speed
.write_int64(0) // obsolete upload number
.write_int32(4321) // shared files
.write_int32(77); // shared folders
});
WatchUserHandler.handle(&mut message, tx);
match rx.try_recv() {
Ok(ServerMessage::WatchedUserReceived {
username,
exists,
status,
average_speed,
shared_files,
shared_folders,
}) => {
assert_eq!(username, "alice");
assert!(exists);
assert_eq!(status, Some(2));
assert_eq!(average_speed, Some(1024));
assert_eq!(shared_files, Some(4321));
assert_eq!(shared_folders, Some(77));
}
other => panic!("unexpected: {other:?}"),
}
}
#[test]
fn unknown_user_reports_absence_without_stats() {
let (tx, rx) = std::sync::mpsc::channel();
let mut message = framed(|m| {
m.write_string("ghost").write_int8(0);
});
WatchUserHandler.handle(&mut message, tx);
match rx.try_recv() {
Ok(ServerMessage::WatchedUserReceived {
username,
exists,
status,
shared_files,
..
}) => {
assert_eq!(username, "ghost");
assert!(!exists);
assert_eq!(status, None);
assert_eq!(shared_files, None);
}
other => panic!("unexpected: {other:?}"),
}
}
#[test]
fn truncated_payload_does_not_panic() {
// exists = 1 but the stats were cut off: the bounds-checked readers
// must yield zeros rather than panicking on a hostile frame.
let (tx, rx) = std::sync::mpsc::channel();
let mut message = framed(|m| {
m.write_string("alice").write_int8(1).write_int32(2);
});
WatchUserHandler.handle(&mut message, tx);
assert!(matches!(
rx.try_recv(),
Ok(ServerMessage::WatchedUserReceived { .. })
));
}
}