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
+70
View File
@@ -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<RoomInfo>),
RoomJoined {
room: String,
users: Vec<String>,
},
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)) {
+127 -1
View File
@@ -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<PeerRegistry>,
@@ -209,6 +212,10 @@ pub struct ClientContext {
pending_serves: HashMap<String, Vec<u32>>,
/// Shared-file listings received from peers we browsed.
browse_results: HashMap<String, Vec<SharedDirectory>>,
/// Latest snapshot of the public chat-room list (from `RoomList`, code 64).
room_list: Vec<RoomInfo>,
/// Chat-room events awaiting consumption by the client/UI.
room_events: Vec<RoomEvent>,
actor_system: Arc<ActorSystem>,
}
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<RoomInfo> {
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<RoomEvent> {
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<RoomInfo> {
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<RoomEvent> {
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
@@ -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:?}"),
}
}
}
+30
View File
@@ -163,6 +163,36 @@ pub enum DownloadStatus {
Failed(Option<String>),
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<RoomInfo>),
/// We successfully joined `room`; carries the current member list.
Joined { room: String, users: Vec<String> },
/// 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();