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.
This commit is contained in:
michel
2026-07-19 19:47:58 +02:00
parent ae28f4236a
commit 7bc0ee2c3d
12 changed files with 718 additions and 4 deletions
@@ -0,0 +1,52 @@
use crate::{
actor::server_actor::ServerMessage,
message::{Message, MessageHandler},
};
use std::sync::mpsc::Sender;
pub struct JoinRoomHandler;
impl MessageHandler<ServerMessage> for JoinRoomHandler {
fn get_code(&self) -> u8 {
14
}
fn handle(&self, message: &mut Message, sender: Sender<ServerMessage>) {
// 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:?}"),
}
}
}
@@ -0,0 +1,38 @@
use crate::{
actor::server_actor::ServerMessage,
message::{Message, MessageHandler},
};
use std::sync::mpsc::Sender;
pub struct LeaveRoomHandler;
impl MessageHandler<ServerMessage> for LeaveRoomHandler {
fn get_code(&self) -> u8 {
15
}
fn handle(&self, message: &mut Message, sender: Sender<ServerMessage>) {
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:?}"),
}
}
}
@@ -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<u8> = [
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<u8> = [
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<u8> = [
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");
+10
View File
@@ -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;
@@ -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<ServerMessage> for RoomListHandler {
64
}
fn handle(&self, _message: &mut Message, _sender: Sender<ServerMessage>) {
// 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<ServerMessage>) {
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<RoomInfo> {
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:?}"),
}
}
}
@@ -0,0 +1,54 @@
use crate::{
actor::server_actor::ServerMessage,
message::{Message, MessageHandler},
};
use std::sync::mpsc::Sender;
pub struct SayChatroomHandler;
impl MessageHandler<ServerMessage> for SayChatroomHandler {
fn get_code(&self) -> u8 {
13
}
fn handle(&self, message: &mut Message, sender: Sender<ServerMessage>) {
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:?}"),
}
}
}
@@ -0,0 +1,45 @@
use crate::{
actor::server_actor::ServerMessage,
message::{Message, MessageHandler},
};
use std::sync::mpsc::Sender;
pub struct UserJoinedRoomHandler;
impl MessageHandler<ServerMessage> for UserJoinedRoomHandler {
fn get_code(&self) -> u8 {
16
}
fn handle(&self, message: &mut Message, sender: Sender<ServerMessage>) {
// 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:?}"),
}
}
}
@@ -0,0 +1,43 @@
use crate::{
actor::server_actor::ServerMessage,
message::{Message, MessageHandler},
};
use std::sync::mpsc::Sender;
pub struct UserLeftRoomHandler;
impl MessageHandler<ServerMessage> for UserLeftRoomHandler {
fn get_code(&self) -> u8 {
17
}
fn handle(&self, message: &mut Message, sender: Sender<ServerMessage>) {
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:?}"),
}
}
}