diff --git a/soulseek-rs-lib/src/actor/server_actor.rs b/soulseek-rs-lib/src/actor/server_actor.rs index 16bd473..bd43d56 100644 --- a/soulseek-rs-lib/src/actor/server_actor.rs +++ b/soulseek-rs-lib/src/actor/server_actor.rs @@ -26,6 +26,10 @@ use crate::message::server::UserJoinedRoomHandler; use crate::message::server::UserLeftRoomHandler; use crate::message::server::WatchUserHandler; use crate::message::server::WishListIntervalHandler; +use crate::message::server::{ + CantCreateRoomHandler, OwnRoomStandingHandler, RoomMembersHandler, + RoomOperatorsHandler, RoomRosterChangeHandler, +}; use crate::message::server::{ GetUserStatsHandler, GetUserStatusHandler, RoomListHandler, }; @@ -229,6 +233,36 @@ pub enum ServerMessage { }, /// Phrases the server refuses to search for (code 160). ExcludedSearchPhrases(Vec), + /// Who may enter a private room (code 133). + PrivateRoomMembers { + room: String, + users: Vec, + }, + /// Who runs a private room (code 148). + PrivateRoomOperators { + room: String, + users: Vec, + }, + /// One user joined or left a private room's member or operator roster + /// (codes 134/135 and 143/144). + PrivateRoomRosterChanged { + room: String, + username: String, + /// True for the member roster, false for the operator roster. + members: bool, + added: bool, + }, + /// Our own membership (139/140) or operatorship (145/146) of a private + /// room was granted or revoked. + OwnRoomStandingChanged { + room: String, + members: bool, + granted: bool, + }, + /// The room we asked to join could not be created (code 1003). + CantCreateRoom { + room: String, + }, } pub struct ServerActor { @@ -386,6 +420,19 @@ impl ServerActor { handlers.register_handler(GetPeerAddressHandler); handlers.register_handler(ConnectToPeerHandler); handlers.register_handler(CantConnectToPeerHandler); + handlers.register_handler(RoomMembersHandler); + handlers.register_handler(RoomOperatorsHandler); + handlers.register_handler(RoomRosterChangeHandler::member_added()); + handlers.register_handler(RoomRosterChangeHandler::member_removed()); + handlers.register_handler(RoomRosterChangeHandler::operator_added()); + handlers.register_handler(RoomRosterChangeHandler::operator_removed()); + handlers.register_handler(OwnRoomStandingHandler::membership_granted()); + handlers.register_handler(OwnRoomStandingHandler::membership_revoked()); + handlers + .register_handler(OwnRoomStandingHandler::operatorship_granted()); + handlers + .register_handler(OwnRoomStandingHandler::operatorship_revoked()); + handlers.register_handler(CantCreateRoomHandler); handlers.register_handler(RoomTickersHandler); handlers.register_handler(RoomTickerAddedHandler); handlers.register_handler(RoomTickerRemovedHandler); @@ -694,6 +741,45 @@ impl ServerActor { token, }); } + ServerMessage::PrivateRoomMembers { room, users } => { + self.forward_room_event(RoomEvent::PrivateMembers { + room, + users, + }); + } + ServerMessage::PrivateRoomOperators { room, users } => { + self.forward_room_event(RoomEvent::PrivateOperators { + room, + users, + }); + } + ServerMessage::PrivateRoomRosterChanged { + room, + username, + members, + added, + } => { + self.forward_room_event(RoomEvent::PrivateRosterChanged { + room, + username, + members, + added, + }); + } + ServerMessage::OwnRoomStandingChanged { + room, + members, + granted, + } => { + self.forward_room_event(RoomEvent::OwnStandingChanged { + room, + members, + granted, + }); + } + ServerMessage::CantCreateRoom { room } => { + self.forward_room_event(RoomEvent::CantCreate { room }); + } ServerMessage::ExcludedSearchPhrases(phrases) => { self.forward_to_client(ClientOperation::ExcludedSearchPhrases( phrases, diff --git a/soulseek-rs-lib/src/client/mod.rs b/soulseek-rs-lib/src/client/mod.rs index 61ad87a..a6e54c0 100644 --- a/soulseek-rs-lib/src/client/mod.rs +++ b/soulseek-rs-lib/src/client/mod.rs @@ -440,6 +440,11 @@ pub struct ClientContext { /// Per-member statistics for each joined room, from the stat vectors the /// server sends alongside the membership list. room_member_stats: HashMap>, + /// Who may enter each private room we belong to (code 133), kept current + /// from the later roster changes. + private_room_members: HashMap>, + /// Who runs each of those rooms (code 148). + private_room_operators: HashMap>, /// The ticker board of each joined room, kept current from the board sent /// on join (code 113) and the later add/remove events (114/115). room_tickers: HashMap>, @@ -490,6 +495,15 @@ pub struct ClientContext { upload_events: Vec, actor_system: Arc, } +/// A roster sorted and de-duplicated, so membership lookups can binary-search +/// it and a repeated name cannot appear twice. +fn sorted_unique(users: &[String]) -> Vec { + let mut users = users.to_vec(); + users.sort(); + users.dedup(); + users +} + impl Default for ClientContext { fn default() -> Self { Self::new() @@ -569,6 +583,8 @@ impl ClientContext { room_members: HashMap::new(), room_member_stats: HashMap::new(), room_tickers: HashMap::new(), + private_room_members: HashMap::new(), + private_room_operators: HashMap::new(), recommendations: None, global_recommendations: None, item_recommendations: HashMap::new(), @@ -641,11 +657,84 @@ impl ClientContext { board.retain(|t| &t.username != username); } } - RoomEvent::Message { .. } | RoomEvent::GlobalMessage { .. } => {} + RoomEvent::PrivateMembers { room, users } => { + self.private_room_members + .insert(room.clone(), sorted_unique(users)); + } + RoomEvent::PrivateOperators { room, users } => { + self.private_room_operators + .insert(room.clone(), sorted_unique(users)); + } + RoomEvent::PrivateRosterChanged { + room, + username, + members, + added, + } => { + let roster = if *members { + self.private_room_members.entry(room.clone()).or_default() + } else { + self.private_room_operators.entry(room.clone()).or_default() + }; + match (added, roster.binary_search(username)) { + (true, Err(at)) => roster.insert(at, username.clone()), + (false, Ok(at)) => { + roster.remove(at); + } + _ => {} + } + } + RoomEvent::OwnStandingChanged { + room, + members, + granted, + } => { + // Our own standing is rendered from the same rosters, so a + // revocation must drop the room rather than leave a roster + // we are no longer part of looking current. + if !granted { + // Losing membership takes the operator roster with it: + // a room we cannot enter has no roster worth showing. + if *members { + self.private_room_members.remove(room); + } + self.private_room_operators.remove(room); + } + } + RoomEvent::Message { .. } + | RoomEvent::GlobalMessage { .. } + | RoomEvent::CantCreate { .. } => {} } self.room_events.push(event); } + /// Who may enter the private room `room`, as last reported. + #[must_use] + pub fn private_room_members(&self, room: &str) -> Vec { + self.private_room_members + .get(room) + .cloned() + .unwrap_or_default() + } + + /// Who runs the private room `room`, as last reported. + #[must_use] + pub fn private_room_operators(&self, room: &str) -> Vec { + self.private_room_operators + .get(room) + .cloned() + .unwrap_or_default() + } + + /// The private rooms we belong to, in a stable order. + #[must_use] + pub fn private_rooms(&self) -> Vec { + let mut rooms: Vec = + self.private_room_members.keys().cloned().collect(); + rooms.sort(); + rooms + } + /// The ticker board of `room` as last reported by the server. #[must_use] pub fn room_tickers(&self, room: &str) -> Vec { diff --git a/soulseek-rs-lib/src/client/social.rs b/soulseek-rs-lib/src/client/social.rs index 488deb8..ef0dda6 100644 --- a/soulseek-rs-lib/src/client/social.rs +++ b/soulseek-rs-lib/src/client/social.rs @@ -21,6 +21,137 @@ impl Client { self.send_server_message(MessageFactory::build_server_ping()) } + /// Join a private room, creating it (and owning it) if the name is free. + /// + /// A name already taken by someone else's private room comes back as a + /// [`crate::RoomEvent::CantCreate`] rather than a join. + /// + /// # Errors + /// [`crate::SoulseekRs::NotConnected`] when there is no server connection. + pub fn join_private_room(&self, room: &str) -> Result<()> { + self.send_server_message(MessageFactory::build_join_room(room, true)) + } + + /// Invite `username` into the private room we own (code 134). + /// + /// # Errors + /// [`crate::SoulseekRs::NotConnected`] when there is no server connection. + pub fn add_room_member(&self, room: &str, username: &str) -> Result<()> { + self.send_server_message(MessageFactory::build_add_room_member( + room, username, + )) + } + + /// Remove `username` from the private room we own (code 135). + /// + /// # Errors + /// [`crate::SoulseekRs::NotConnected`] when there is no server connection. + pub fn remove_room_member(&self, room: &str, username: &str) -> Result<()> { + self.send_server_message(MessageFactory::build_remove_room_member( + room, username, + )) + } + + /// Give up our own membership of a private room (code 136). + /// + /// # Errors + /// [`crate::SoulseekRs::NotConnected`] when there is no server connection. + pub fn leave_private_room(&self, room: &str) -> Result<()> { + self.send_server_message(MessageFactory::build_cancel_room_membership( + room, + )) + } + + /// Give up ownership of a private room, which deletes it (code 137). + /// + /// # Errors + /// [`crate::SoulseekRs::NotConnected`] when there is no server connection. + pub fn disband_private_room(&self, room: &str) -> Result<()> { + self.send_server_message(MessageFactory::build_cancel_room_ownership( + room, + )) + } + + /// Say whether we accept private-room invitations (code 141). + /// + /// # Errors + /// [`crate::SoulseekRs::NotConnected`] when there is no server connection. + pub fn set_room_invitations_enabled(&self, enabled: bool) -> Result<()> { + self.send_server_message(MessageFactory::build_enable_room_invitations( + enabled, + )) + } + + /// Make `username` an operator of our private room (code 143). + /// + /// # Errors + /// [`crate::SoulseekRs::NotConnected`] when there is no server connection. + pub fn add_room_operator(&self, room: &str, username: &str) -> Result<()> { + self.send_server_message(MessageFactory::build_add_room_operator( + room, username, + )) + } + + /// Take operator status away from `username` (code 144). + /// + /// # Errors + /// [`crate::SoulseekRs::NotConnected`] when there is no server connection. + pub fn remove_room_operator( + &self, + room: &str, + username: &str, + ) -> Result<()> { + self.send_server_message(MessageFactory::build_remove_room_operator( + room, username, + )) + } + + /// Give up our own operator status in `room` (code 147). + /// + /// # Errors + /// [`crate::SoulseekRs::NotConnected`] when there is no server connection. + pub fn resign_room_operatorship(&self, room: &str) -> Result<()> { + self.send_server_message( + MessageFactory::build_cancel_room_operatorship(room), + ) + } + + /// Who may enter the private room `room`, as the server last reported. + #[must_use] + pub fn private_room_members(&self, room: &str) -> Vec { + match self.context.read_safe() { + Ok(ctx) => ctx.private_room_members(room), + Err(e) => { + error!("[client] private_room_members: {}", e); + Vec::new() + } + } + } + + /// Who runs the private room `room`. + #[must_use] + pub fn private_room_operators(&self, room: &str) -> Vec { + match self.context.read_safe() { + Ok(ctx) => ctx.private_room_operators(room), + Err(e) => { + error!("[client] private_room_operators: {}", e); + Vec::new() + } + } + } + + /// The private rooms we belong to. + #[must_use] + pub fn private_rooms(&self) -> Vec { + match self.context.read_safe() { + Ok(ctx) => ctx.private_rooms(), + Err(e) => { + error!("[client] private_rooms: {}", e); + Vec::new() + } + } + } + /// Set our online status (server code 28): away, or back to online. /// /// Other clients see it in their `GetUserStatus` and watch replies, and diff --git a/soulseek-rs-lib/src/client/tests.rs b/soulseek-rs-lib/src/client/tests.rs index b1b8285..eefd4ff 100644 --- a/soulseek-rs-lib/src/client/tests.rs +++ b/soulseek-rs-lib/src/client/tests.rs @@ -951,3 +951,86 @@ fn asking_again_about_a_users_interests_drops_the_previous_answer() { "a stale answer must not be mistaken for the next one" ); } + +#[test] +fn a_private_room_roster_tracks_who_is_added_and_removed() { + let mut ctx = ClientContext::new(); + ctx.apply_room_event(RoomEvent::PrivateMembers { + room: "club".into(), + users: vec!["bob".into(), "alice".into(), "bob".into()], + }); + assert_eq!( + ctx.private_room_members("club"), + vec!["alice".to_string(), "bob".to_string()], + "a roster is sorted and free of repeats" + ); + + ctx.apply_room_event(RoomEvent::PrivateRosterChanged { + room: "club".into(), + username: "carol".into(), + members: true, + added: true, + }); + ctx.apply_room_event(RoomEvent::PrivateRosterChanged { + room: "club".into(), + username: "alice".into(), + members: true, + added: false, + }); + assert_eq!( + ctx.private_room_members("club"), + vec!["bob".to_string(), "carol".to_string()] + ); + + // Operators are a separate roster in the same room. + ctx.apply_room_event(RoomEvent::PrivateRosterChanged { + room: "club".into(), + username: "bob".into(), + members: false, + added: true, + }); + assert_eq!(ctx.private_room_operators("club"), vec!["bob".to_string()]); + assert_eq!(ctx.private_rooms(), vec!["club".to_string()]); +} + +#[test] +fn revoked_membership_drops_the_room_we_can_no_longer_see() { + let mut ctx = ClientContext::new(); + ctx.apply_room_event(RoomEvent::PrivateMembers { + room: "club".into(), + users: vec!["alice".into()], + }); + ctx.apply_room_event(RoomEvent::PrivateOperators { + room: "club".into(), + users: vec!["alice".into()], + }); + + ctx.apply_room_event(RoomEvent::OwnStandingChanged { + room: "club".into(), + members: true, + granted: false, + }); + assert!(ctx.private_rooms().is_empty()); + assert!(ctx.private_room_operators("club").is_empty()); +} + +#[test] +fn losing_operatorship_keeps_the_membership_it_does_not_touch() { + let mut ctx = ClientContext::new(); + ctx.apply_room_event(RoomEvent::PrivateMembers { + room: "club".into(), + users: vec!["alice".into()], + }); + ctx.apply_room_event(RoomEvent::PrivateOperators { + room: "club".into(), + users: vec!["alice".into()], + }); + + ctx.apply_room_event(RoomEvent::OwnStandingChanged { + room: "club".into(), + members: false, + granted: false, + }); + assert_eq!(ctx.private_room_members("club"), vec!["alice".to_string()]); + assert!(ctx.private_room_operators("club").is_empty()); +} diff --git a/soulseek-rs-lib/src/message/server/message_factory.rs b/soulseek-rs-lib/src/message/server/message_factory.rs index cc3feb7..4ea1ebe 100644 --- a/soulseek-rs-lib/src/message/server/message_factory.rs +++ b/soulseek-rs-lib/src/message/server/message_factory.rs @@ -310,6 +310,73 @@ impl MessageFactory { .clone() } + /// Invite `username` into the private room we own (server code 134). + #[must_use] + pub fn build_add_room_member(room: &str, username: &str) -> Message { + Message::new() + .write_int32(134) + .write_string(room) + .write_string(username) + .clone() + } + + /// Remove `username` from the private room we own (server code 135). + #[must_use] + pub fn build_remove_room_member(room: &str, username: &str) -> Message { + Message::new() + .write_int32(135) + .write_string(room) + .write_string(username) + .clone() + } + + /// Give up our own membership of a private room (server code 136). + #[must_use] + pub fn build_cancel_room_membership(room: &str) -> Message { + Message::new().write_int32(136).write_string(room).clone() + } + + /// Give up ownership of a private room, which deletes it (code 137). + #[must_use] + pub fn build_cancel_room_ownership(room: &str) -> Message { + Message::new().write_int32(137).write_string(room).clone() + } + + /// Say whether we accept private-room invitations (server code 141). + #[must_use] + pub fn build_enable_room_invitations(enabled: bool) -> Message { + Message::new() + .write_int32(141) + .write_int8(u8::from(enabled)) + .clone() + } + + /// Make `username` an operator of our private room (server code 143). + #[must_use] + pub fn build_add_room_operator(room: &str, username: &str) -> Message { + Message::new() + .write_int32(143) + .write_string(room) + .write_string(username) + .clone() + } + + /// Take operator status away from `username` (server code 144). + #[must_use] + pub fn build_remove_room_operator(room: &str, username: &str) -> Message { + Message::new() + .write_int32(144) + .write_string(room) + .write_string(username) + .clone() + } + + /// Give up our own operator status in a room (server code 147). + #[must_use] + pub fn build_cancel_room_operatorship(room: &str) -> Message { + Message::new().write_int32(147).write_string(room).clone() + } + /// A keepalive ping (server code 32, no body). The server sends no reply; /// its only job is to keep a quiet connection from being reaped by a NAT /// or the server's own idle timeout, the way other clients ping. @@ -830,3 +897,54 @@ fn a_multi_user_message_counts_its_recipients_first() { assert_eq!(decoded.read_string(), "bob"); assert_eq!(decoded.read_string(), "hi all"); } + +#[test] +fn the_private_room_roster_messages_carry_room_then_user() { + for (message, code) in [ + (MessageFactory::build_add_room_member("club", "bob"), 134), + (MessageFactory::build_remove_room_member("club", "bob"), 135), + (MessageFactory::build_add_room_operator("club", "bob"), 143), + ( + MessageFactory::build_remove_room_operator("club", "bob"), + 144, + ), + ] { + let mut decoded = Message::new_with_data(message.get_buffer()); + assert_eq!(decoded.get_message_code(), code); + decoded.set_pointer(8); + assert_eq!(decoded.read_string(), "club"); + assert_eq!(decoded.read_string(), "bob"); + } +} + +#[test] +fn giving_up_a_room_standing_names_only_the_room() { + for (message, code) in [ + (MessageFactory::build_cancel_room_membership("club"), 136), + (MessageFactory::build_cancel_room_ownership("club"), 137), + (MessageFactory::build_cancel_room_operatorship("club"), 147), + ] { + let mut decoded = Message::new_with_data(message.get_buffer()); + assert_eq!(decoded.get_message_code(), code); + decoded.set_pointer(8); + assert_eq!(decoded.read_string(), "club"); + } +} + +#[test] +fn room_invitations_are_toggled_by_a_single_byte() { + let message = MessageFactory::build_enable_room_invitations(true); + assert_eq!(message.get_data(), [141, 0, 0, 0, 1]); + let message = MessageFactory::build_enable_room_invitations(false); + assert_eq!(message.get_data(), [141, 0, 0, 0, 0]); +} + +#[test] +fn a_private_join_sets_the_private_flag() { + let message = MessageFactory::build_join_room("club", true); + let mut decoded = Message::new_with_data(message.get_buffer()); + assert_eq!(decoded.get_message_code(), 14); + decoded.set_pointer(8); + assert_eq!(decoded.read_string(), "club"); + assert_eq!(decoded.read_int32(), 1); +} diff --git a/soulseek-rs-lib/src/message/server/mod.rs b/soulseek-rs-lib/src/message/server/mod.rs index 76eb9e4..41a4f77 100644 --- a/soulseek-rs-lib/src/message/server/mod.rs +++ b/soulseek-rs-lib/src/message/server/mod.rs @@ -14,6 +14,7 @@ mod message_factory; mod message_user; mod parent_min_speed; mod parent_speed_ratio; +mod private_rooms; mod privileged_users; mod relogged; mod room_list; @@ -47,6 +48,10 @@ pub use message_factory::MessageFactory; pub use message_user::MessageUser; pub use parent_min_speed::ParentMinSpeedHandler; pub use parent_speed_ratio::ParentSpeedRatioHandler; +pub use private_rooms::{ + CantCreateRoomHandler, OwnRoomStandingHandler, RoomMembersHandler, + RoomOperatorsHandler, RoomRosterChangeHandler, +}; pub use privileged_users::{CheckPrivilegesHandler, PrivilegedUsersHandler}; pub use relogged::ReloggedHandler; pub use room_list::RoomListHandler; diff --git a/soulseek-rs-lib/src/message/server/private_rooms.rs b/soulseek-rs-lib/src/message/server/private_rooms.rs new file mode 100644 index 0000000..18d8aa6 --- /dev/null +++ b/soulseek-rs-lib/src/message/server/private_rooms.rs @@ -0,0 +1,317 @@ +//! Private rooms: who may enter one and who runs it. +//! +//! The server pushes the membership and operator rosters of every private +//! room we belong to right after login (codes 133 and 148), then narrates +//! changes to them: a member added or removed (134/135), our own membership +//! or operatorship granted or revoked (139/140, 145/146), and a room we asked +//! for that could not be created (1003). + +use std::sync::mpsc::Sender; + +use crate::{ + actor::server_actor::ServerMessage, + message::{Message, MessageHandler}, +}; + +/// Read a `[room][count][username]...` roster, stopping at the payload's end. +fn read_roster(message: &mut Message) -> (String, Vec) { + let room = message.read_string(); + let count = message.read_int32(); + let mut users = Vec::new(); + for _ in 0..count { + let username = message.read_string(); + if username.is_empty() { + break; + } + users.push(username); + } + (room, users) +} + +/// `RoomMembers` (code 133): who may enter a private room. +pub struct RoomMembersHandler; + +impl MessageHandler for RoomMembersHandler { + fn get_code(&self) -> u32 { + 133 + } + + fn handle(&self, message: &mut Message, sender: Sender) { + let (room, users) = read_roster(message); + let _ = sender.send(ServerMessage::PrivateRoomMembers { room, users }); + } +} + +/// `RoomOperators` (code 148): who runs a private room. +pub struct RoomOperatorsHandler; + +impl MessageHandler for RoomOperatorsHandler { + fn get_code(&self) -> u32 { + 148 + } + + fn handle(&self, message: &mut Message, sender: Sender) { + let (room, users) = read_roster(message); + let _ = + sender.send(ServerMessage::PrivateRoomOperators { room, users }); + } +} + +/// One user added to (`added`) or removed from a private room's roster: +/// members (134/135) or operators (143/144). +pub struct RoomRosterChangeHandler { + code: u32, + members: bool, + added: bool, +} + +impl RoomRosterChangeHandler { + #[must_use] + pub const fn member_added() -> Self { + Self { + code: 134, + members: true, + added: true, + } + } + + #[must_use] + pub const fn member_removed() -> Self { + Self { + code: 135, + members: true, + added: false, + } + } + + #[must_use] + pub const fn operator_added() -> Self { + Self { + code: 143, + members: false, + added: true, + } + } + + #[must_use] + pub const fn operator_removed() -> Self { + Self { + code: 144, + members: false, + added: false, + } + } +} + +impl MessageHandler for RoomRosterChangeHandler { + fn get_code(&self) -> u32 { + self.code + } + + fn handle(&self, message: &mut Message, sender: Sender) { + let room = message.read_string(); + let username = message.read_string(); + let _ = sender.send(ServerMessage::PrivateRoomRosterChanged { + room, + username, + members: self.members, + added: self.added, + }); + } +} + +/// Our own standing in a private room changed: membership granted (139) or +/// revoked (140), operatorship granted (145) or revoked (146). +pub struct OwnRoomStandingHandler { + code: u32, + members: bool, + granted: bool, +} + +impl OwnRoomStandingHandler { + #[must_use] + pub const fn membership_granted() -> Self { + Self { + code: 139, + members: true, + granted: true, + } + } + + #[must_use] + pub const fn membership_revoked() -> Self { + Self { + code: 140, + members: true, + granted: false, + } + } + + #[must_use] + pub const fn operatorship_granted() -> Self { + Self { + code: 145, + members: false, + granted: true, + } + } + + #[must_use] + pub const fn operatorship_revoked() -> Self { + Self { + code: 146, + members: false, + granted: false, + } + } +} + +impl MessageHandler for OwnRoomStandingHandler { + fn get_code(&self) -> u32 { + self.code + } + + fn handle(&self, message: &mut Message, sender: Sender) { + let room = message.read_string(); + let _ = sender.send(ServerMessage::OwnRoomStandingChanged { + room, + members: self.members, + granted: self.granted, + }); + } +} + +/// `CantCreateRoom` (code 1003): the room we asked to join could not be +/// created — the name is taken by a private room we are not a member of, or +/// it is otherwise refused. +pub struct CantCreateRoomHandler; + +impl MessageHandler for CantCreateRoomHandler { + fn get_code(&self) -> u32 { + 1003 + } + + fn handle(&self, message: &mut Message, sender: Sender) { + let room = message.read_string(); + let _ = sender.send(ServerMessage::CantCreateRoom { room }); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::message::framed; + + #[test] + fn a_member_roster_lists_the_rooms_members() { + let (tx, rx) = std::sync::mpsc::channel(); + let mut message = framed(|m| { + m.write_string("club"); + m.write_int32(2); + m.write_string("alice"); + m.write_string("bob"); + }); + + RoomMembersHandler.handle(&mut message, tx); + match rx.try_recv() { + Ok(ServerMessage::PrivateRoomMembers { room, users }) => { + assert_eq!(room, "club"); + assert_eq!(users, ["alice", "bob"]); + } + other => panic!("unexpected: {other:?}"), + } + } + + #[test] + fn a_roster_count_beyond_the_payload_stops_at_the_data() { + let (tx, rx) = std::sync::mpsc::channel(); + let mut message = framed(|m| { + m.write_string("club"); + m.write_int32(u32::MAX); + m.write_string("alice"); + }); + + RoomOperatorsHandler.handle(&mut message, tx); + match rx.try_recv() { + Ok(ServerMessage::PrivateRoomOperators { users, .. }) => { + assert_eq!(users, ["alice"]); + } + other => panic!("unexpected: {other:?}"), + } + } + + #[test] + fn each_roster_change_reports_which_roster_and_which_way() { + for (handler, members, added) in [ + (RoomRosterChangeHandler::member_added(), true, true), + (RoomRosterChangeHandler::member_removed(), true, false), + (RoomRosterChangeHandler::operator_added(), false, true), + (RoomRosterChangeHandler::operator_removed(), false, false), + ] { + let (tx, rx) = std::sync::mpsc::channel(); + let mut message = framed(|m| { + m.write_string("club"); + m.write_string("alice"); + }); + + handler.handle(&mut message, tx); + match rx.try_recv() { + Ok(ServerMessage::PrivateRoomRosterChanged { + room, + username, + members: got_members, + added: got_added, + }) => { + assert_eq!(room, "club"); + assert_eq!(username, "alice"); + assert_eq!((got_members, got_added), (members, added)); + } + other => panic!("unexpected: {other:?}"), + } + } + } + + #[test] + fn our_own_standing_reports_the_room_and_the_change() { + for (handler, members, granted) in [ + (OwnRoomStandingHandler::membership_granted(), true, true), + (OwnRoomStandingHandler::membership_revoked(), true, false), + (OwnRoomStandingHandler::operatorship_granted(), false, true), + (OwnRoomStandingHandler::operatorship_revoked(), false, false), + ] { + let (tx, rx) = std::sync::mpsc::channel(); + let mut message = framed(|m| { + m.write_string("club"); + }); + + handler.handle(&mut message, tx); + match rx.try_recv() { + Ok(ServerMessage::OwnRoomStandingChanged { + room, + members: got_members, + granted: got_granted, + }) => { + assert_eq!(room, "club"); + assert_eq!((got_members, got_granted), (members, granted)); + } + other => panic!("unexpected: {other:?}"), + } + } + } + + #[test] + fn a_refused_room_names_itself() { + let (tx, rx) = std::sync::mpsc::channel(); + let mut message = framed(|m| { + m.write_string("taken"); + }); + + CantCreateRoomHandler.handle(&mut message, tx); + match rx.try_recv() { + Ok(ServerMessage::CantCreateRoom { room }) => { + assert_eq!(room, "taken"); + } + other => panic!("unexpected: {other:?}"), + } + } +} diff --git a/soulseek-rs-lib/src/types.rs b/soulseek-rs-lib/src/types.rs index c057acf..8fa98e1 100644 --- a/soulseek-rs-lib/src/types.rs +++ b/soulseek-rs-lib/src/types.rs @@ -414,6 +414,28 @@ pub enum RoomEvent { username: String, message: String, }, + /// Who may enter a private room (code 133). + PrivateMembers { room: String, users: Vec }, + /// Who runs a private room (code 148). + PrivateOperators { room: String, users: Vec }, + /// One user joined or left a private room's member roster (`members` + /// true, codes 134/135) or its operator roster (143/144). + PrivateRosterChanged { + room: String, + username: String, + members: bool, + added: bool, + }, + /// Our own membership (`members` true, codes 139/140) or operatorship + /// (145/146) of a private room was granted or revoked. + OwnStandingChanged { + room: String, + members: bool, + granted: bool, + }, + /// The room we asked to join could not be created (code 1003) — the name + /// belongs to a private room we are not a member of. + CantCreate { room: String }, } /// One user's ticker (the scrolling one-line message) in a room. diff --git a/soulseek-rs-lib/tests/e2e.rs b/soulseek-rs-lib/tests/e2e.rs index 120291e..062be52 100644 --- a/soulseek-rs-lib/tests/e2e.rs +++ b/soulseek-rs-lib/tests/e2e.rs @@ -4583,3 +4583,128 @@ fn a_queued_download_can_ask_where_it_sits() { let _ = peer.join(); let _ = std::fs::remove_dir_all(download_dir); } + +#[test] +fn a_private_room_is_owned_granted_and_revoked() { + use soulseek_rs::types::RoomEvent; + let server = server_or_skip!(); + + let room = "e2e_private_club"; + let mut owner = + Client::with_settings(server.settings("e2e_priv_owner", "pw")); + let mut guest = + Client::with_settings(server.settings("e2e_priv_guest", "pw")); + owner.connect().expect("owner connect"); + guest.connect().expect("guest connect"); + assert!(owner.login().expect("owner login")); + assert!(guest.login().expect("guest login")); + + guest + .set_room_invitations_enabled(true) + .expect("guest accepts invitations"); + owner + .join_private_room(room) + .expect("owner creates the room"); + std::thread::sleep(Duration::from_millis(750)); + + // The guest is invited, and hears about it (code 139). + owner + .add_room_member(room, "e2e_priv_guest") + .expect("owner invites the guest"); + let granted = await_room_event(&guest, Duration::from_secs(5), |event| { + matches!(event, RoomEvent::OwnStandingChanged { + room: r, members: true, granted: true + } if r == room) + .then_some(()) + }); + assert!( + granted.is_some(), + "the guest should be told they are a member" + ); + + // The owner's own roster now carries the guest (code 134). + let deadline = Instant::now() + Duration::from_secs(5); + while Instant::now() < deadline + && !owner + .private_room_members(room) + .iter() + .any(|u| u == "e2e_priv_guest") + { + std::thread::sleep(Duration::from_millis(100)); + } + assert!( + owner + .private_room_members(room) + .iter() + .any(|u| u == "e2e_priv_guest"), + "the member roster should carry the guest, got {:?}", + owner.private_room_members(room) + ); + + // A member can be made an operator (code 145 for them, 143 for the room). + owner + .add_room_operator(room, "e2e_priv_guest") + .expect("owner promotes the guest"); + let promoted = await_room_event(&guest, Duration::from_secs(5), |event| { + matches!(event, RoomEvent::OwnStandingChanged { + room: r, members: false, granted: true + } if r == room) + .then_some(()) + }); + assert!( + promoted.is_some(), + "the guest should be told they run the room" + ); + + // And membership can be taken away again (code 140). + owner + .remove_room_member(room, "e2e_priv_guest") + .expect("owner removes the guest"); + let revoked = await_room_event(&guest, Duration::from_secs(5), |event| { + matches!(event, RoomEvent::OwnStandingChanged { + room: r, members: true, granted: false + } if r == room) + .then_some(()) + }); + assert!(revoked.is_some(), "the guest should be told they are out"); +} + +#[test] +fn a_private_room_someone_else_owns_cannot_be_taken() { + use soulseek_rs::types::RoomEvent; + let server = server_or_skip!(); + + let room = "e2e_private_taken"; + let mut owner = + Client::with_settings(server.settings("e2e_taken_owner", "pw")); + owner.connect().expect("owner connect"); + assert!(owner.login().expect("owner login")); + owner + .join_private_room(room) + .expect("owner creates the room"); + std::thread::sleep(Duration::from_millis(750)); + + // An outsider asking for the same name must be refused (code 1003), not + // handed the room. + let mut outsider = + Client::with_settings(server.settings("e2e_taken_outsider", "pw")); + outsider.connect().expect("outsider connect"); + assert!(outsider.login().expect("outsider login")); + outsider + .join_private_room(room) + .expect("outsider asks for the same room"); + + let refused = + await_room_event(&outsider, Duration::from_secs(5), |event| { + matches!(event, RoomEvent::CantCreate { room: r } if r == room) + .then_some(()) + }); + assert!( + refused.is_some(), + "a private room owned by someone else must be refused" + ); + assert!( + outsider.private_rooms().is_empty(), + "a refused room must not appear as one we belong to" + ); +} diff --git a/soulseek-rs/docs/openrpc.json b/soulseek-rs/docs/openrpc.json index d9ab8e0..0ccad0c 100644 --- a/soulseek-rs/docs/openrpc.json +++ b/soulseek-rs/docs/openrpc.json @@ -729,6 +729,218 @@ "username" ], "type": "object" + }, + { + "description": "The ticker board of a room, sent on join.", + "properties": { + "event": { + "const": "tickers", + "type": "string" + }, + "room": { + "type": "string" + }, + "tickers": { + "items": { + "$ref": "#/components/schemas/RoomTickerDto" + }, + "type": "array" + } + }, + "required": [ + "event", + "room", + "tickers" + ], + "type": "object" + }, + { + "properties": { + "event": { + "const": "ticker_added", + "type": "string" + }, + "room": { + "type": "string" + }, + "ticker": { + "type": "string" + }, + "username": { + "type": "string" + } + }, + "required": [ + "event", + "room", + "username", + "ticker" + ], + "type": "object" + }, + { + "properties": { + "event": { + "const": "ticker_removed", + "type": "string" + }, + "room": { + "type": "string" + }, + "username": { + "type": "string" + } + }, + "required": [ + "event", + "room", + "username" + ], + "type": "object" + }, + { + "description": "A message from the global room feed; `room` names where it was said.", + "properties": { + "event": { + "const": "global_message", + "type": "string" + }, + "message": { + "type": "string" + }, + "room": { + "type": "string" + }, + "username": { + "type": "string" + } + }, + "required": [ + "event", + "room", + "username", + "message" + ], + "type": "object" + }, + { + "description": "Who may enter a private room.", + "properties": { + "event": { + "const": "private_members", + "type": "string" + }, + "room": { + "type": "string" + }, + "users": { + "items": { + "type": "string" + }, + "type": "array" + } + }, + "required": [ + "event", + "room", + "users" + ], + "type": "object" + }, + { + "description": "Who runs a private room.", + "properties": { + "event": { + "const": "private_operators", + "type": "string" + }, + "room": { + "type": "string" + }, + "users": { + "items": { + "type": "string" + }, + "type": "array" + } + }, + "required": [ + "event", + "room", + "users" + ], + "type": "object" + }, + { + "description": "A private room's member (`members` true) or operator roster changed.", + "properties": { + "added": { + "type": "boolean" + }, + "event": { + "const": "private_roster_changed", + "type": "string" + }, + "members": { + "type": "boolean" + }, + "room": { + "type": "string" + }, + "username": { + "type": "string" + } + }, + "required": [ + "event", + "room", + "username", + "members", + "added" + ], + "type": "object" + }, + { + "description": "Our own membership or operatorship of a private room changed.", + "properties": { + "event": { + "const": "own_standing_changed", + "type": "string" + }, + "granted": { + "type": "boolean" + }, + "members": { + "type": "boolean" + }, + "room": { + "type": "string" + } + }, + "required": [ + "event", + "room", + "members", + "granted" + ], + "type": "object" + }, + { + "description": "The room we asked to join could not be created.", + "properties": { + "event": { + "const": "cant_create", + "type": "string" + }, + "room": { + "type": "string" + } + }, + "required": [ + "event", + "room" + ], + "type": "object" } ] }, @@ -760,6 +972,22 @@ ], "type": "object" }, + "RoomTickerDto": { + "description": "One user's ticker in a room.", + "properties": { + "ticker": { + "type": "string" + }, + "username": { + "type": "string" + } + }, + "required": [ + "username", + "ticker" + ], + "type": "object" + }, "RoomUserStatsDto": { "description": "One member of a room, as the JoinRoom reply described them.", "properties": { diff --git a/soulseek-rs/src/daemon/proto/dto.rs b/soulseek-rs/src/daemon/proto/dto.rs index 82ea25a..32fd958 100644 --- a/soulseek-rs/src/daemon/proto/dto.rs +++ b/soulseek-rs/src/daemon/proto/dto.rs @@ -373,6 +373,79 @@ pub enum RoomEventDto { room: String, username: String, }, + /// The ticker board of a room, sent on join. + Tickers { + room: String, + tickers: Vec, + }, + TickerAdded { + room: String, + username: String, + ticker: String, + }, + TickerRemoved { + room: String, + username: String, + }, + /// A message from the global room feed; `room` names where it was said. + GlobalMessage { + room: String, + username: String, + message: String, + }, + /// Who may enter a private room. + PrivateMembers { + room: String, + users: Vec, + }, + /// Who runs a private room. + PrivateOperators { + room: String, + users: Vec, + }, + /// A private room's member (`members` true) or operator roster changed. + PrivateRosterChanged { + room: String, + username: String, + members: bool, + added: bool, + }, + /// Our own membership or operatorship of a private room changed. + OwnStandingChanged { + room: String, + members: bool, + granted: bool, + }, + /// The room we asked to join could not be created. + CantCreate { + room: String, + }, +} + +/// One user's ticker in a room. +#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)] +#[cfg_attr(test, derive(schemars::JsonSchema))] +pub struct RoomTickerDto { + pub username: String, + pub ticker: String, +} + +impl From<&soulseek_rs::RoomTicker> for RoomTickerDto { + fn from(ticker: &soulseek_rs::RoomTicker) -> Self { + Self { + username: ticker.username.clone(), + ticker: ticker.ticker.clone(), + } + } +} + +impl From for soulseek_rs::RoomTicker { + fn from(dto: RoomTickerDto) -> Self { + Self { + username: dto.username, + ticker: dto.ticker, + } + } } impl From<&RoomEvent> for RoomEventDto { @@ -403,6 +476,67 @@ impl From<&RoomEvent> for RoomEventDto { room: room.clone(), username: username.clone(), }, + RoomEvent::Tickers { room, tickers } => Self::Tickers { + room: room.clone(), + tickers: tickers.iter().map(RoomTickerDto::from).collect(), + }, + RoomEvent::TickerAdded { + room, + username, + ticker, + } => Self::TickerAdded { + room: room.clone(), + username: username.clone(), + ticker: ticker.clone(), + }, + RoomEvent::TickerRemoved { room, username } => { + Self::TickerRemoved { + room: room.clone(), + username: username.clone(), + } + } + RoomEvent::GlobalMessage { + room, + username, + message, + } => Self::GlobalMessage { + room: room.clone(), + username: username.clone(), + message: message.clone(), + }, + RoomEvent::PrivateMembers { room, users } => Self::PrivateMembers { + room: room.clone(), + users: users.clone(), + }, + RoomEvent::PrivateOperators { room, users } => { + Self::PrivateOperators { + room: room.clone(), + users: users.clone(), + } + } + RoomEvent::PrivateRosterChanged { + room, + username, + members, + added, + } => Self::PrivateRosterChanged { + room: room.clone(), + username: username.clone(), + members: *members, + added: *added, + }, + RoomEvent::OwnStandingChanged { + room, + members, + granted, + } => Self::OwnStandingChanged { + room: room.clone(), + members: *members, + granted: *granted, + }, + RoomEvent::CantCreate { room } => { + Self::CantCreate { room: room.clone() } + } } } } @@ -432,6 +566,58 @@ impl From for RoomEvent { RoomEventDto::UserLeft { room, username } => { Self::UserLeft { room, username } } + RoomEventDto::Tickers { room, tickers } => Self::Tickers { + room, + tickers: tickers.into_iter().map(Into::into).collect(), + }, + RoomEventDto::TickerAdded { + room, + username, + ticker, + } => Self::TickerAdded { + room, + username, + ticker, + }, + RoomEventDto::TickerRemoved { room, username } => { + Self::TickerRemoved { room, username } + } + RoomEventDto::GlobalMessage { + room, + username, + message, + } => Self::GlobalMessage { + room, + username, + message, + }, + RoomEventDto::PrivateMembers { room, users } => { + Self::PrivateMembers { room, users } + } + RoomEventDto::PrivateOperators { room, users } => { + Self::PrivateOperators { room, users } + } + RoomEventDto::PrivateRosterChanged { + room, + username, + members, + added, + } => Self::PrivateRosterChanged { + room, + username, + members, + added, + }, + RoomEventDto::OwnStandingChanged { + room, + members, + granted, + } => Self::OwnStandingChanged { + room, + members, + granted, + }, + RoomEventDto::CantCreate { room } => Self::CantCreate { room }, } } } diff --git a/soulseek-rs/src/models/rooms.rs b/soulseek-rs/src/models/rooms.rs index 5250cdd..8897f39 100644 --- a/soulseek-rs/src/models/rooms.rs +++ b/soulseek-rs/src/models/rooms.rs @@ -336,6 +336,81 @@ impl RoomsState { .push(RoomLine::system(format!("← {username}"))); } } + // Tickers and private-room standing are narrated into the room's + // own transcript: they belong to a room the user has open, and a + // system line is where the rest of that room's news already goes. + RoomEvent::Tickers { room, tickers } => { + if let Some(idx) = self.open_index(&room) { + for ticker in tickers { + self.open[idx].lines.push(RoomLine::system(format!( + "≡ {}: {}", + ticker.username, ticker.ticker + ))); + } + } + } + RoomEvent::TickerAdded { + room, + username, + ticker, + } => { + if let Some(idx) = self.open_index(&room) { + self.open[idx].lines.push(RoomLine::system(format!( + "≡ {username}: {ticker}" + ))); + } + } + RoomEvent::TickerRemoved { room, username } => { + if let Some(idx) = self.open_index(&room) { + self.open[idx].lines.push(RoomLine::system(format!( + "≡ {username} cleared their ticker" + ))); + } + } + RoomEvent::PrivateRosterChanged { + room, + username, + members, + added, + } => { + if let Some(idx) = self.open_index(&room) { + let roster = if members { "member" } else { "operator" }; + let verb = if added { "is now" } else { "is no longer" }; + self.open[idx].lines.push(RoomLine::system(format!( + "{username} {verb} a {roster}" + ))); + } + } + RoomEvent::OwnStandingChanged { + room, + members, + granted, + } => { + if let Some(idx) = self.open_index(&room) { + let what = if members { + "membership" + } else { + "operator status" + }; + let verb = if granted { "granted" } else { "revoked" }; + self.open[idx].lines.push(RoomLine::system(format!( + "— your {what} was {verb} —" + ))); + } + } + RoomEvent::CantCreate { room } => { + if let Some(idx) = self.open_index(&room) { + self.open[idx].lines.push(RoomLine::system( + "— this room cannot be created —".to_string(), + )); + } + } + // The global feed carries rooms the user has not opened, and the + // private rosters are read from the client rather than rendered + // into a transcript. + RoomEvent::GlobalMessage { .. } + | RoomEvent::PrivateMembers { .. } + | RoomEvent::PrivateOperators { .. } => {} } // The active room's member list may have grown/shrunk (join/leave or a // wholesale replace on Joined); keep the selection highlight in range so