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
@@ -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");