From 7bc0ee2c3d98c814026455964f240116f07ae191 Mon Sep 17 00:00:00 2001 From: michel Date: Sun, 19 Jul 2026 19:47:58 +0200 Subject: [PATCH] feat(lib): chat-room protocol (list, join, leave, say) with events Implement the Soulseek chat-room server messages against soulfind's wire format: - MessageFactory builders: room-list request (64), join (14), leave (15), say (13). - Handlers parsing the server side: RoomList (64) names+counts, JoinRoom (14) membership, SayChatroom (13), UserJoinedRoom (16), UserLeftRoom (17), LeaveRoom (15). - Surface everything to the client as a drained RoomEvent stream plus a room-list snapshot; new Client API: request_room_list, join_room, leave_room, say_in_room, room_list, take_room_events. Unit tests cover each builder and handler; server-gated e2e tests exercise message delivery and room listing against soulfind. --- soulseek-rs-lib/src/actor/server_actor.rs | 70 ++++++++++ soulseek-rs-lib/src/client.rs | 128 +++++++++++++++++- .../src/message/server/join_room.rs | 52 +++++++ .../src/message/server/leave_room.rs | 38 ++++++ .../src/message/server/message_factory.rs | 73 ++++++++++ soulseek-rs-lib/src/message/server/mod.rs | 10 ++ .../src/message/server/room_list.rs | 100 +++++++++++++- .../src/message/server/say_chatroom.rs | 54 ++++++++ .../src/message/server/user_joined_room.rs | 45 ++++++ .../src/message/server/user_left_room.rs | 43 ++++++ soulseek-rs-lib/src/types.rs | 30 ++++ soulseek-rs-lib/tests/e2e.rs | 79 +++++++++++ 12 files changed, 718 insertions(+), 4 deletions(-) create mode 100644 soulseek-rs-lib/src/message/server/join_room.rs create mode 100644 soulseek-rs-lib/src/message/server/leave_room.rs create mode 100644 soulseek-rs-lib/src/message/server/say_chatroom.rs create mode 100644 soulseek-rs-lib/src/message/server/user_joined_room.rs create mode 100644 soulseek-rs-lib/src/message/server/user_left_room.rs diff --git a/soulseek-rs-lib/src/actor/server_actor.rs b/soulseek-rs-lib/src/actor/server_actor.rs index ff8a6da..908d18f 100644 --- a/soulseek-rs-lib/src/actor/server_actor.rs +++ b/soulseek-rs-lib/src/actor/server_actor.rs @@ -5,6 +5,8 @@ use crate::message::server::ConnectToPeerHandler; use crate::message::server::ExcludedSearchPhrasesHandler; use crate::message::server::FileSearchHandler; use crate::message::server::GetPeerAddressHandler; +use crate::message::server::JoinRoomHandler; +use crate::message::server::LeaveRoomHandler; use crate::message::server::LoginHandler; use crate::message::server::MessageFactory; use crate::message::server::MessageUser; @@ -12,11 +14,15 @@ 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::{Handlers, MessageType}; use crate::message::{Message, MessageReader}; use crate::peer::ConnectionType; use crate::peer::Peer; +use crate::types::{RoomEvent, RoomInfo}; use crate::utils::lock::RwLockExt; use std::io::{self, Error, Write}; @@ -168,6 +174,27 @@ pub enum ServerMessage { obfuscated_port: u16, }, PrivateMessageReceived(UserMessage), + RoomListReceived(Vec), + RoomJoined { + room: String, + users: Vec, + }, + RoomLeft { + room: String, + }, + RoomMessageReceived { + room: String, + username: String, + message: String, + }, + RoomUserJoined { + room: String, + username: String, + }, + RoomUserLeft { + room: String, + username: String, + }, } pub struct ServerActor { @@ -327,6 +354,11 @@ impl ServerActor { handlers.register_handler(LoginHandler); handlers.register_handler(RoomListHandler); + handlers.register_handler(JoinRoomHandler); + handlers.register_handler(LeaveRoomHandler); + handlers.register_handler(SayChatroomHandler); + handlers.register_handler(UserJoinedRoomHandler); + handlers.register_handler(UserLeftRoomHandler); handlers.register_handler(ExcludedSearchPhrasesHandler); handlers.register_handler(PrivilegedUsersHandler); handlers.register_handler(MessageUser); @@ -468,6 +500,35 @@ impl ServerActor { ); } } + ServerMessage::RoomListReceived(rooms) => { + self.forward_room_event(RoomEvent::List(rooms)); + } + ServerMessage::RoomJoined { room, users } => { + self.forward_room_event(RoomEvent::Joined { room, users }); + } + ServerMessage::RoomLeft { room } => { + self.forward_room_event(RoomEvent::Left { room }); + } + ServerMessage::RoomMessageReceived { + room, + username, + message, + } => { + self.forward_room_event(RoomEvent::Message { + room, + username, + message, + }); + } + ServerMessage::RoomUserJoined { room, username } => { + self.forward_room_event(RoomEvent::UserJoined { + room, + username, + }); + } + ServerMessage::RoomUserLeft { room, username } => { + self.forward_room_event(RoomEvent::UserLeft { room, username }); + } ServerMessage::ProcessRead => { self.process_read(); } @@ -602,6 +663,15 @@ impl ServerActor { self.process_dispatcher_messages(); } + /// Forward a chat-room event to the client operations loop. + fn forward_room_event(&self, event: RoomEvent) { + if let Err(e) = + self.client_channel.send(ClientOperation::RoomEvent(event)) + { + error!("[server] Error forwarding room event to client: {}", e); + } + } + fn queue_message(&mut self, message: Message) { if let Some(sender) = &self.dispatcher_sender { match sender.send(ServerMessage::SendMessage(message)) { diff --git a/soulseek-rs-lib/src/client.rs b/soulseek-rs-lib/src/client.rs index a48abb9..270d864 100644 --- a/soulseek-rs-lib/src/client.rs +++ b/soulseek-rs-lib/src/client.rs @@ -3,7 +3,7 @@ use crate::actor::server_actor::{ PeerAddress, ServerActor, ServerMessage, UserMessage, }; use crate::download_store::{DownloadStore, collect_failed_tokens}; -use crate::types::{DownloadMetadata, DownloadStatus}; +use crate::types::{DownloadMetadata, DownloadStatus, RoomEvent, RoomInfo}; use crate::utils::logger; use crate::{ Transfer, @@ -186,6 +186,9 @@ pub enum ClientOperation { /// established — the peer is likely firewalled, so fall back to asking the /// server to broker the connection. Carries the reporting actor's id. PeerConnectFailed(u64, String), + /// Something happened in the chat-room subsystem (list refreshed, a room + /// joined/left, a message said, a member joined/left). + RoomEvent(RoomEvent), } pub struct ClientContext { pub peer_registry: Option, @@ -209,6 +212,10 @@ pub struct ClientContext { pending_serves: HashMap>, /// Shared-file listings received from peers we browsed. browse_results: HashMap>, + /// Latest snapshot of the public chat-room list (from `RoomList`, code 64). + room_list: Vec, + /// Chat-room events awaiting consumption by the client/UI. + room_events: Vec, actor_system: Arc, } impl Default for ClientContext { @@ -499,11 +506,34 @@ impl ClientContext { uploads: HashMap::new(), pending_serves: HashMap::new(), browse_results: HashMap::new(), + room_list: Vec::new(), + room_events: Vec::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. + pub fn apply_room_event(&mut self, event: RoomEvent) { + if let RoomEvent::List(rooms) = &event { + self.room_list.clone_from(rooms); + } + self.room_events.push(event); + } + + /// The latest snapshot of the public chat-room list. + #[must_use] + pub fn room_list(&self) -> Vec { + self.room_list.clone() + } + + /// Remove and return all chat-room events received since the last call. + #[must_use] + pub fn take_room_events(&mut self) -> Vec { + std::mem::take(&mut self.room_events) + } + /// Cache a peer's listen address learned from a GetPeerAddress response. pub fn cache_peer_address( &mut self, @@ -746,6 +776,93 @@ impl Client { Ok(()) } + /// Send a raw server message via the server actor, mapping a dead channel + /// to [`SoulseekRs::NotConnected`]. + fn send_server_message( + &self, + message: crate::message::Message, + ) -> Result<()> { + self.server_handle + .as_ref() + .ok_or(SoulseekRs::NotConnected)? + .send(ServerMessage::SendMessage(message)) + .map_err(|_| SoulseekRs::NotConnected)?; + Ok(()) + } + + /// Ask the server for the list of public chat rooms. The response arrives + /// asynchronously; read it with [`Client::room_list`] or by draining + /// [`Client::take_room_events`] for a [`RoomEvent::List`]. + /// + /// # Errors + /// Returns [`SoulseekRs::NotConnected`] if the client is not connected. + pub fn request_room_list(&self) -> Result<()> { + self.send_server_message( + crate::message::server::MessageFactory::build_room_list_request(), + ) + } + + /// Join a public chat room. The membership list and subsequent messages + /// arrive via [`Client::take_room_events`]. + /// + /// # Errors + /// Returns [`SoulseekRs::NotConnected`] if the client is not connected. + pub fn join_room(&self, room: &str) -> Result<()> { + self.send_server_message( + crate::message::server::MessageFactory::build_join_room( + room, false, + ), + ) + } + + /// Leave a chat room previously joined with [`Client::join_room`]. + /// + /// # Errors + /// Returns [`SoulseekRs::NotConnected`] if the client is not connected. + pub fn leave_room(&self, room: &str) -> Result<()> { + self.send_server_message( + crate::message::server::MessageFactory::build_leave_room(room), + ) + } + + /// Say `message` in chat room `room`. The server echoes it back as a + /// [`RoomEvent::Message`], so the UI should render from that echo rather + /// than optimistically. + /// + /// # Errors + /// Returns [`SoulseekRs::NotConnected`] if the client is not connected. + pub fn say_in_room(&self, room: &str, message: &str) -> Result<()> { + self.send_server_message( + crate::message::server::MessageFactory::build_say_chatroom( + room, message, + ), + ) + } + + /// The latest snapshot of the public chat-room list. + #[must_use] + pub fn room_list(&self) -> Vec { + match self.context.read_safe() { + Ok(ctx) => ctx.room_list(), + Err(e) => { + error!("[client] room_list: {}", e); + Vec::new() + } + } + } + + /// Remove and return all chat-room events received since the last call. + #[must_use] + pub fn take_room_events(&self) -> Vec { + match self.context.write_safe() { + Ok(mut ctx) => ctx.take_room_events(), + Err(e) => { + error!("[client] take_room_events: {}", e); + Vec::new() + } + } + } + /// Request a peer's shared-file listing. When it arrives it can be /// retrieved with [`Client::take_browse_result`]. /// @@ -1654,6 +1771,15 @@ impl Client { e ), }, + ClientOperation::RoomEvent(event) => { + match client_context.write_safe() { + Ok(mut ctx) => ctx.apply_room_event(event), + Err(e) => error!( + "[client] RoomEvent write: {}", + e + ), + } + } ClientOperation::PeerConnected(username) => { // An outbound control connection just handshook. // Flush any downloads that were queued for this diff --git a/soulseek-rs-lib/src/message/server/join_room.rs b/soulseek-rs-lib/src/message/server/join_room.rs new file mode 100644 index 0000000..f31071a --- /dev/null +++ b/soulseek-rs-lib/src/message/server/join_room.rs @@ -0,0 +1,52 @@ +use crate::{ + actor::server_actor::ServerMessage, + message::{Message, MessageHandler}, +}; +use std::sync::mpsc::Sender; + +pub struct JoinRoomHandler; + +impl MessageHandler for JoinRoomHandler { + fn get_code(&self) -> u8 { + 14 + } + + fn handle(&self, message: &mut Message, sender: Sender) { + // JoinRoom (code 14): room name, then a vector of member usernames. + // Per-user stat vectors follow but are not needed here, so we stop + // after reading the names. + let room = message.read_string(); + let user_count = message.read_int32(); + let mut users = Vec::new(); + for _ in 0..user_count { + users.push(message.read_string()); + } + let _ = sender.send(ServerMessage::RoomJoined { room, users }); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn forwards_room_and_member_list() { + let (tx, rx) = std::sync::mpsc::channel(); + let mut message = Message::new(); + message.write_raw_bytes(vec![0u8; 8]); + message.write_string("nicotine"); + message.write_int32(2); + message.write_string("alice"); + message.write_string("bob"); + message.set_pointer(8); + + JoinRoomHandler.handle(&mut message, tx); + match rx.try_recv() { + Ok(ServerMessage::RoomJoined { room, users }) => { + assert_eq!(room, "nicotine"); + assert_eq!(users, vec!["alice", "bob"]); + } + other => panic!("unexpected: {other:?}"), + } + } +} diff --git a/soulseek-rs-lib/src/message/server/leave_room.rs b/soulseek-rs-lib/src/message/server/leave_room.rs new file mode 100644 index 0000000..cc243db --- /dev/null +++ b/soulseek-rs-lib/src/message/server/leave_room.rs @@ -0,0 +1,38 @@ +use crate::{ + actor::server_actor::ServerMessage, + message::{Message, MessageHandler}, +}; +use std::sync::mpsc::Sender; + +pub struct LeaveRoomHandler; + +impl MessageHandler for LeaveRoomHandler { + fn get_code(&self) -> u8 { + 15 + } + + fn handle(&self, message: &mut Message, sender: Sender) { + let room = message.read_string(); + let _ = sender.send(ServerMessage::RoomLeft { room }); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn forwards_left_room() { + let (tx, rx) = std::sync::mpsc::channel(); + let mut message = Message::new(); + message.write_raw_bytes(vec![0u8; 8]); + message.write_string("jazz"); + message.set_pointer(8); + + LeaveRoomHandler.handle(&mut message, tx); + match rx.try_recv() { + Ok(ServerMessage::RoomLeft { room }) => assert_eq!(room, "jazz"), + other => panic!("unexpected: {other:?}"), + } + } +} diff --git a/soulseek-rs-lib/src/message/server/message_factory.rs b/soulseek-rs-lib/src/message/server/message_factory.rs index 6183541..de50258 100644 --- a/soulseek-rs-lib/src/message/server/message_factory.rs +++ b/soulseek-rs-lib/src/message/server/message_factory.rs @@ -122,6 +122,38 @@ impl MessageFactory { .clone() } + /// Ask the server (code 64) for the list of public chat rooms. + #[must_use] + pub fn build_room_list_request() -> Message { + Message::new().write_int32(64).clone() + } + + /// Join a chat room (server code 14). `private` requests a private room. + #[must_use] + pub fn build_join_room(room: &str, private: bool) -> Message { + Message::new() + .write_int32(14) + .write_string(room) + .write_int32(u32::from(private)) + .clone() + } + + /// Leave a chat room (server code 15). + #[must_use] + pub fn build_leave_room(room: &str) -> Message { + Message::new().write_int32(15).write_string(room).clone() + } + + /// Say `message` in chat room `room` (server code 13). + #[must_use] + pub fn build_say_chatroom(room: &str, message: &str) -> Message { + Message::new() + .write_int32(13) + .write_string(room) + .write_string(message) + .clone() + } + /// Ask a peer for their shared-file listing (peer code 4, no body). #[must_use] pub fn build_get_share_file_list() -> Message { @@ -272,6 +304,47 @@ fn test_build_message_acked() { assert_eq!(expect, message.get_data()); } +#[test] +fn test_build_join_room() { + let message = MessageFactory::build_join_room("nicotine", false); + let expect: Vec = [ + 14, 0, 0, 0, // code + 8, 0, 0, 0, 110, 105, 99, 111, 116, 105, 110, 101, // "nicotine" + 0, 0, 0, 0, // private = 0 + ] + .to_vec(); + assert_eq!(expect, message.get_data()); +} + +#[test] +fn test_build_leave_room() { + let message = MessageFactory::build_leave_room("nicotine"); + let expect: Vec = [ + 15, 0, 0, 0, // code + 8, 0, 0, 0, 110, 105, 99, 111, 116, 105, 110, 101, // "nicotine" + ] + .to_vec(); + assert_eq!(expect, message.get_data()); +} + +#[test] +fn test_build_say_chatroom() { + let message = MessageFactory::build_say_chatroom("room", "hi"); + let expect: Vec = [ + 13, 0, 0, 0, // code + 4, 0, 0, 0, 114, 111, 111, 109, // "room" + 2, 0, 0, 0, 104, 105, // "hi" + ] + .to_vec(); + assert_eq!(expect, message.get_data()); +} + +#[test] +fn test_build_room_list_request() { + let message = MessageFactory::build_room_list_request(); + assert_eq!(vec![64, 0, 0, 0], message.get_data()); +} + #[test] fn test_build_file_search_message() { let message = MessageFactory::build_file_search_message(12, "trance wax"); diff --git a/soulseek-rs-lib/src/message/server/mod.rs b/soulseek-rs-lib/src/message/server/mod.rs index 07aaa79..181707f 100644 --- a/soulseek-rs-lib/src/message/server/mod.rs +++ b/soulseek-rs-lib/src/message/server/mod.rs @@ -2,6 +2,8 @@ mod connect_to_peer; mod excluded_search_phrases; mod file_search; mod get_peer_address; +mod join_room; +mod leave_room; mod login; mod message_factory; mod message_user; @@ -9,12 +11,17 @@ mod parent_min_speed; mod parent_speed_ratio; mod privileged_users; mod room_list; +mod say_chatroom; +mod user_joined_room; +mod user_left_room; mod wish_list_interval; pub use connect_to_peer::ConnectToPeerHandler; pub use excluded_search_phrases::ExcludedSearchPhrasesHandler; pub use file_search::FileSearchHandler; pub use get_peer_address::GetPeerAddressHandler; +pub use join_room::JoinRoomHandler; +pub use leave_room::LeaveRoomHandler; pub use login::LoginHandler; pub use message_factory::MessageFactory; pub use message_user::MessageUser; @@ -22,4 +29,7 @@ pub use parent_min_speed::ParentMinSpeedHandler; pub use parent_speed_ratio::ParentSpeedRatioHandler; pub use privileged_users::PrivilegedUsersHandler; pub use room_list::RoomListHandler; +pub use say_chatroom::SayChatroomHandler; +pub use user_joined_room::UserJoinedRoomHandler; +pub use user_left_room::UserLeftRoomHandler; pub use wish_list_interval::WishListIntervalHandler; diff --git a/soulseek-rs-lib/src/message/server/room_list.rs b/soulseek-rs-lib/src/message/server/room_list.rs index 2920088..e102578 100644 --- a/soulseek-rs-lib/src/message/server/room_list.rs +++ b/soulseek-rs-lib/src/message/server/room_list.rs @@ -1,6 +1,7 @@ use crate::{ actor::server_actor::ServerMessage, message::{Message, MessageHandler}, + types::RoomInfo, }; use std::sync::mpsc::Sender; @@ -11,8 +12,101 @@ impl MessageHandler for RoomListHandler { 64 } - fn handle(&self, _message: &mut Message, _sender: Sender) { - // Room listing is not yet exposed to the client; this handler exists - // so the dispatcher can ack code 64 without logging an unknown message. + fn handle(&self, message: &mut Message, sender: Sender) { + let rooms = parse_room_list(message); + let _ = sender.send(ServerMessage::RoomListReceived(rooms)); + } +} + +/// Parse the public rooms out of a `RoomList` (code 64) message: a vector of +/// room names followed by a vector of user counts. The remaining private-room +/// sections are ignored. `message` must be positioned at the payload (the +/// dispatcher sets pointer 8). +#[must_use] +pub fn parse_room_list(message: &mut Message) -> Vec { + let name_count = message.read_int32(); + let mut names = Vec::new(); + for _ in 0..name_count { + names.push(message.read_string()); + } + let count_count = message.read_int32(); + let mut counts = Vec::new(); + for _ in 0..count_count { + counts.push(message.read_int32()); + } + names + .into_iter() + .zip(counts) + .map(|(name, user_count)| RoomInfo { name, user_count }) + .collect() +} + +#[cfg(test)] +mod tests { + use super::*; + + /// Build a received message (8-byte header + payload) and position it the + /// way the dispatcher would. + fn framed(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 parses_names_zipped_with_counts() { + let mut message = framed(|m| { + m.write_int32(2); + m.write_string("nicotine"); + m.write_string("jazz"); + m.write_int32(2); + m.write_int32(42); + m.write_int32(7); + }); + let rooms = parse_room_list(&mut message); + assert_eq!( + rooms, + vec![ + RoomInfo { + name: "nicotine".to_string(), + user_count: 42 + }, + RoomInfo { + name: "jazz".to_string(), + user_count: 7 + }, + ] + ); + } + + #[test] + fn empty_room_list_parses_to_empty() { + let mut message = framed(|m| { + m.write_int32(0); + m.write_int32(0); + }); + assert!(parse_room_list(&mut message).is_empty()); + } + + #[test] + fn handler_forwards_parsed_rooms() { + let (tx, rx) = std::sync::mpsc::channel(); + let mut message = framed(|m| { + m.write_int32(1); + m.write_string("room"); + m.write_int32(1); + m.write_int32(5); + }); + RoomListHandler.handle(&mut message, tx); + match rx.try_recv() { + Ok(ServerMessage::RoomListReceived(rooms)) => { + assert_eq!(rooms.len(), 1); + assert_eq!(rooms[0].name, "room"); + assert_eq!(rooms[0].user_count, 5); + } + other => panic!("unexpected: {other:?}"), + } } } diff --git a/soulseek-rs-lib/src/message/server/say_chatroom.rs b/soulseek-rs-lib/src/message/server/say_chatroom.rs new file mode 100644 index 0000000..966fade --- /dev/null +++ b/soulseek-rs-lib/src/message/server/say_chatroom.rs @@ -0,0 +1,54 @@ +use crate::{ + actor::server_actor::ServerMessage, + message::{Message, MessageHandler}, +}; +use std::sync::mpsc::Sender; + +pub struct SayChatroomHandler; + +impl MessageHandler for SayChatroomHandler { + fn get_code(&self) -> u8 { + 13 + } + + fn handle(&self, message: &mut Message, sender: Sender) { + let room = message.read_string(); + let username = message.read_string(); + let message_text = message.read_string(); + let _ = sender.send(ServerMessage::RoomMessageReceived { + room, + username, + message: message_text, + }); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn forwards_room_message() { + let (tx, rx) = std::sync::mpsc::channel(); + let mut message = Message::new(); + message.write_raw_bytes(vec![0u8; 8]); + message.write_string("jazz"); + message.write_string("alice"); + message.write_string("hello everyone"); + message.set_pointer(8); + + SayChatroomHandler.handle(&mut message, tx); + match rx.try_recv() { + Ok(ServerMessage::RoomMessageReceived { + room, + username, + message, + }) => { + assert_eq!(room, "jazz"); + assert_eq!(username, "alice"); + assert_eq!(message, "hello everyone"); + } + other => panic!("unexpected: {other:?}"), + } + } +} diff --git a/soulseek-rs-lib/src/message/server/user_joined_room.rs b/soulseek-rs-lib/src/message/server/user_joined_room.rs new file mode 100644 index 0000000..07bd383 --- /dev/null +++ b/soulseek-rs-lib/src/message/server/user_joined_room.rs @@ -0,0 +1,45 @@ +use crate::{ + actor::server_actor::ServerMessage, + message::{Message, MessageHandler}, +}; +use std::sync::mpsc::Sender; + +pub struct UserJoinedRoomHandler; + +impl MessageHandler for UserJoinedRoomHandler { + fn get_code(&self) -> u8 { + 16 + } + + fn handle(&self, message: &mut Message, sender: Sender) { + // UserJoinedRoom (code 16): room, username, then that user's stats, + // which we don't need. Reading the first two fields is enough. + let room = message.read_string(); + let username = message.read_string(); + let _ = sender.send(ServerMessage::RoomUserJoined { room, username }); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn forwards_user_joined() { + let (tx, rx) = std::sync::mpsc::channel(); + let mut message = Message::new(); + message.write_raw_bytes(vec![0u8; 8]); + message.write_string("jazz"); + message.write_string("carol"); + message.set_pointer(8); + + UserJoinedRoomHandler.handle(&mut message, tx); + match rx.try_recv() { + Ok(ServerMessage::RoomUserJoined { room, username }) => { + assert_eq!(room, "jazz"); + assert_eq!(username, "carol"); + } + other => panic!("unexpected: {other:?}"), + } + } +} diff --git a/soulseek-rs-lib/src/message/server/user_left_room.rs b/soulseek-rs-lib/src/message/server/user_left_room.rs new file mode 100644 index 0000000..7350bdc --- /dev/null +++ b/soulseek-rs-lib/src/message/server/user_left_room.rs @@ -0,0 +1,43 @@ +use crate::{ + actor::server_actor::ServerMessage, + message::{Message, MessageHandler}, +}; +use std::sync::mpsc::Sender; + +pub struct UserLeftRoomHandler; + +impl MessageHandler for UserLeftRoomHandler { + fn get_code(&self) -> u8 { + 17 + } + + fn handle(&self, message: &mut Message, sender: Sender) { + let room = message.read_string(); + let username = message.read_string(); + let _ = sender.send(ServerMessage::RoomUserLeft { room, username }); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn forwards_user_left() { + let (tx, rx) = std::sync::mpsc::channel(); + let mut message = Message::new(); + message.write_raw_bytes(vec![0u8; 8]); + message.write_string("jazz"); + message.write_string("carol"); + message.set_pointer(8); + + UserLeftRoomHandler.handle(&mut message, tx); + match rx.try_recv() { + Ok(ServerMessage::RoomUserLeft { room, username }) => { + assert_eq!(room, "jazz"); + assert_eq!(username, "carol"); + } + other => panic!("unexpected: {other:?}"), + } + } +} diff --git a/soulseek-rs-lib/src/types.rs b/soulseek-rs-lib/src/types.rs index cc31e65..490e08a 100644 --- a/soulseek-rs-lib/src/types.rs +++ b/soulseek-rs-lib/src/types.rs @@ -163,6 +163,36 @@ pub enum DownloadStatus { Failed(Option), TimedOut, } + +/// A public chat room advertised by the server (`RoomList`, code 64). +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct RoomInfo { + pub name: String, + pub user_count: u32, +} + +/// Something that happened in the chat-room subsystem, surfaced to the client +/// so a UI can react to it. Drained via `Client::take_room_events`. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum RoomEvent { + /// The full list of public rooms (supersedes any previous snapshot). + List(Vec), + /// We successfully joined `room`; carries the current member list. + Joined { room: String, users: Vec }, + /// We left `room`. + Left { room: String }, + /// `username` said `message` in `room`. + Message { + room: String, + username: String, + message: String, + }, + /// `username` joined `room`. + UserJoined { room: String, username: String }, + /// `username` left `room`. + UserLeft { room: String, username: String }, +} + impl Transfer { pub fn new_from_message(message: &mut Message) -> Self { let direction = message.read_int32(); diff --git a/soulseek-rs-lib/tests/e2e.rs b/soulseek-rs-lib/tests/e2e.rs index 35d1b6a..6760530 100644 --- a/soulseek-rs-lib/tests/e2e.rs +++ b/soulseek-rs-lib/tests/e2e.rs @@ -292,6 +292,85 @@ fn a_private_message_is_delivered_between_users() { assert_eq!(message.username(), "e2e_alice_pm"); } +#[test] +fn a_chat_room_message_is_delivered_between_users() { + use soulseek_rs::types::RoomEvent; + let server = server_or_skip!(); + + let room = "e2e_room_chat"; + let mut alice = + Client::with_settings(server.settings("e2e_alice_room", "pw")); + let mut bob = Client::with_settings(server.settings("e2e_bob_room", "pw")); + alice.connect().expect("alice connect"); + bob.connect().expect("bob connect"); + assert!(alice.login().expect("alice login")); + assert!(bob.login().expect("bob login")); + + alice.join_room(room).expect("alice joins room"); + bob.join_room(room).expect("bob joins room"); + + // Give both joins time to register on the server before speaking. + std::thread::sleep(Duration::from_millis(500)); + let _ = alice.take_room_events(); + let _ = bob.take_room_events(); + + let body = "hello room, this is alice"; + alice.say_in_room(room, body).expect("alice says in room"); + + // Delivery is asynchronous; poll Bob's room events until the message lands. + let deadline = Instant::now() + Duration::from_secs(5); + let mut got = None; + while Instant::now() < deadline { + for event in bob.take_room_events() { + if let RoomEvent::Message { + room: r, + username, + message, + } = event + && r == room + && message == body + { + got = Some(username); + } + } + if got.is_some() { + break; + } + std::thread::sleep(Duration::from_millis(100)); + } + + assert_eq!( + got.as_deref(), + Some("e2e_alice_room"), + "bob should receive alice's room message" + ); +} + +#[test] +fn the_room_list_includes_a_joined_room() { + let server = server_or_skip!(); + + let room = "e2e_room_listed"; + let mut alice = + Client::with_settings(server.settings("e2e_alice_list", "pw")); + alice.connect().expect("alice connect"); + assert!(alice.login().expect("alice login")); + alice.join_room(room).expect("alice joins room"); + + // Once a user is in the room the server should advertise it in RoomList. + let deadline = Instant::now() + Duration::from_secs(5); + let mut listed = false; + while Instant::now() < deadline { + alice.request_room_list().expect("request room list"); + std::thread::sleep(Duration::from_millis(250)); + if alice.room_list().iter().any(|r| r.name == room) { + listed = true; + break; + } + } + assert!(listed, "the joined room should appear in the room list"); +} + #[test] fn login_succeeds_with_listener_enabled() { let server = server_or_skip!();