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:
@@ -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");
|
||||
|
||||
@@ -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:?}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user