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:
@@ -5,6 +5,8 @@ use crate::message::server::ConnectToPeerHandler;
|
|||||||
use crate::message::server::ExcludedSearchPhrasesHandler;
|
use crate::message::server::ExcludedSearchPhrasesHandler;
|
||||||
use crate::message::server::FileSearchHandler;
|
use crate::message::server::FileSearchHandler;
|
||||||
use crate::message::server::GetPeerAddressHandler;
|
use crate::message::server::GetPeerAddressHandler;
|
||||||
|
use crate::message::server::JoinRoomHandler;
|
||||||
|
use crate::message::server::LeaveRoomHandler;
|
||||||
use crate::message::server::LoginHandler;
|
use crate::message::server::LoginHandler;
|
||||||
use crate::message::server::MessageFactory;
|
use crate::message::server::MessageFactory;
|
||||||
use crate::message::server::MessageUser;
|
use crate::message::server::MessageUser;
|
||||||
@@ -12,11 +14,15 @@ use crate::message::server::ParentMinSpeedHandler;
|
|||||||
use crate::message::server::ParentSpeedRatioHandler;
|
use crate::message::server::ParentSpeedRatioHandler;
|
||||||
use crate::message::server::PrivilegedUsersHandler;
|
use crate::message::server::PrivilegedUsersHandler;
|
||||||
use crate::message::server::RoomListHandler;
|
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::server::WishListIntervalHandler;
|
||||||
use crate::message::{Handlers, MessageType};
|
use crate::message::{Handlers, MessageType};
|
||||||
use crate::message::{Message, MessageReader};
|
use crate::message::{Message, MessageReader};
|
||||||
use crate::peer::ConnectionType;
|
use crate::peer::ConnectionType;
|
||||||
use crate::peer::Peer;
|
use crate::peer::Peer;
|
||||||
|
use crate::types::{RoomEvent, RoomInfo};
|
||||||
use crate::utils::lock::RwLockExt;
|
use crate::utils::lock::RwLockExt;
|
||||||
|
|
||||||
use std::io::{self, Error, Write};
|
use std::io::{self, Error, Write};
|
||||||
@@ -168,6 +174,27 @@ pub enum ServerMessage {
|
|||||||
obfuscated_port: u16,
|
obfuscated_port: u16,
|
||||||
},
|
},
|
||||||
PrivateMessageReceived(UserMessage),
|
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 {
|
pub struct ServerActor {
|
||||||
@@ -327,6 +354,11 @@ impl ServerActor {
|
|||||||
|
|
||||||
handlers.register_handler(LoginHandler);
|
handlers.register_handler(LoginHandler);
|
||||||
handlers.register_handler(RoomListHandler);
|
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(ExcludedSearchPhrasesHandler);
|
||||||
handlers.register_handler(PrivilegedUsersHandler);
|
handlers.register_handler(PrivilegedUsersHandler);
|
||||||
handlers.register_handler(MessageUser);
|
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 => {
|
ServerMessage::ProcessRead => {
|
||||||
self.process_read();
|
self.process_read();
|
||||||
}
|
}
|
||||||
@@ -602,6 +663,15 @@ impl ServerActor {
|
|||||||
self.process_dispatcher_messages();
|
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) {
|
fn queue_message(&mut self, message: Message) {
|
||||||
if let Some(sender) = &self.dispatcher_sender {
|
if let Some(sender) = &self.dispatcher_sender {
|
||||||
match sender.send(ServerMessage::SendMessage(message)) {
|
match sender.send(ServerMessage::SendMessage(message)) {
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ use crate::actor::server_actor::{
|
|||||||
PeerAddress, ServerActor, ServerMessage, UserMessage,
|
PeerAddress, ServerActor, ServerMessage, UserMessage,
|
||||||
};
|
};
|
||||||
use crate::download_store::{DownloadStore, collect_failed_tokens};
|
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::utils::logger;
|
||||||
use crate::{
|
use crate::{
|
||||||
Transfer,
|
Transfer,
|
||||||
@@ -186,6 +186,9 @@ pub enum ClientOperation {
|
|||||||
/// established — the peer is likely firewalled, so fall back to asking the
|
/// established — the peer is likely firewalled, so fall back to asking the
|
||||||
/// server to broker the connection. Carries the reporting actor's id.
|
/// server to broker the connection. Carries the reporting actor's id.
|
||||||
PeerConnectFailed(u64, String),
|
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 struct ClientContext {
|
||||||
pub peer_registry: Option<PeerRegistry>,
|
pub peer_registry: Option<PeerRegistry>,
|
||||||
@@ -209,6 +212,10 @@ pub struct ClientContext {
|
|||||||
pending_serves: HashMap<String, Vec<u32>>,
|
pending_serves: HashMap<String, Vec<u32>>,
|
||||||
/// Shared-file listings received from peers we browsed.
|
/// Shared-file listings received from peers we browsed.
|
||||||
browse_results: HashMap<String, Vec<SharedDirectory>>,
|
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>,
|
actor_system: Arc<ActorSystem>,
|
||||||
}
|
}
|
||||||
impl Default for ClientContext {
|
impl Default for ClientContext {
|
||||||
@@ -499,11 +506,34 @@ impl ClientContext {
|
|||||||
uploads: HashMap::new(),
|
uploads: HashMap::new(),
|
||||||
pending_serves: HashMap::new(),
|
pending_serves: HashMap::new(),
|
||||||
browse_results: HashMap::new(),
|
browse_results: HashMap::new(),
|
||||||
|
room_list: Vec::new(),
|
||||||
|
room_events: Vec::new(),
|
||||||
downloads: DownloadStore::new(),
|
downloads: DownloadStore::new(),
|
||||||
actor_system,
|
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.
|
/// Cache a peer's listen address learned from a GetPeerAddress response.
|
||||||
pub fn cache_peer_address(
|
pub fn cache_peer_address(
|
||||||
&mut self,
|
&mut self,
|
||||||
@@ -746,6 +776,93 @@ impl Client {
|
|||||||
Ok(())
|
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
|
/// Request a peer's shared-file listing. When it arrives it can be
|
||||||
/// retrieved with [`Client::take_browse_result`].
|
/// retrieved with [`Client::take_browse_result`].
|
||||||
///
|
///
|
||||||
@@ -1654,6 +1771,15 @@ impl Client {
|
|||||||
e
|
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) => {
|
ClientOperation::PeerConnected(username) => {
|
||||||
// An outbound control connection just handshook.
|
// An outbound control connection just handshook.
|
||||||
// Flush any downloads that were queued for this
|
// 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()
|
.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).
|
/// Ask a peer for their shared-file listing (peer code 4, no body).
|
||||||
#[must_use]
|
#[must_use]
|
||||||
pub fn build_get_share_file_list() -> Message {
|
pub fn build_get_share_file_list() -> Message {
|
||||||
@@ -272,6 +304,47 @@ fn test_build_message_acked() {
|
|||||||
assert_eq!(expect, message.get_data());
|
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]
|
#[test]
|
||||||
fn test_build_file_search_message() {
|
fn test_build_file_search_message() {
|
||||||
let message = MessageFactory::build_file_search_message(12, "trance wax");
|
let message = MessageFactory::build_file_search_message(12, "trance wax");
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ mod connect_to_peer;
|
|||||||
mod excluded_search_phrases;
|
mod excluded_search_phrases;
|
||||||
mod file_search;
|
mod file_search;
|
||||||
mod get_peer_address;
|
mod get_peer_address;
|
||||||
|
mod join_room;
|
||||||
|
mod leave_room;
|
||||||
mod login;
|
mod login;
|
||||||
mod message_factory;
|
mod message_factory;
|
||||||
mod message_user;
|
mod message_user;
|
||||||
@@ -9,12 +11,17 @@ mod parent_min_speed;
|
|||||||
mod parent_speed_ratio;
|
mod parent_speed_ratio;
|
||||||
mod privileged_users;
|
mod privileged_users;
|
||||||
mod room_list;
|
mod room_list;
|
||||||
|
mod say_chatroom;
|
||||||
|
mod user_joined_room;
|
||||||
|
mod user_left_room;
|
||||||
mod wish_list_interval;
|
mod wish_list_interval;
|
||||||
|
|
||||||
pub use connect_to_peer::ConnectToPeerHandler;
|
pub use connect_to_peer::ConnectToPeerHandler;
|
||||||
pub use excluded_search_phrases::ExcludedSearchPhrasesHandler;
|
pub use excluded_search_phrases::ExcludedSearchPhrasesHandler;
|
||||||
pub use file_search::FileSearchHandler;
|
pub use file_search::FileSearchHandler;
|
||||||
pub use get_peer_address::GetPeerAddressHandler;
|
pub use get_peer_address::GetPeerAddressHandler;
|
||||||
|
pub use join_room::JoinRoomHandler;
|
||||||
|
pub use leave_room::LeaveRoomHandler;
|
||||||
pub use login::LoginHandler;
|
pub use login::LoginHandler;
|
||||||
pub use message_factory::MessageFactory;
|
pub use message_factory::MessageFactory;
|
||||||
pub use message_user::MessageUser;
|
pub use message_user::MessageUser;
|
||||||
@@ -22,4 +29,7 @@ pub use parent_min_speed::ParentMinSpeedHandler;
|
|||||||
pub use parent_speed_ratio::ParentSpeedRatioHandler;
|
pub use parent_speed_ratio::ParentSpeedRatioHandler;
|
||||||
pub use privileged_users::PrivilegedUsersHandler;
|
pub use privileged_users::PrivilegedUsersHandler;
|
||||||
pub use room_list::RoomListHandler;
|
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;
|
pub use wish_list_interval::WishListIntervalHandler;
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
use crate::{
|
use crate::{
|
||||||
actor::server_actor::ServerMessage,
|
actor::server_actor::ServerMessage,
|
||||||
message::{Message, MessageHandler},
|
message::{Message, MessageHandler},
|
||||||
|
types::RoomInfo,
|
||||||
};
|
};
|
||||||
use std::sync::mpsc::Sender;
|
use std::sync::mpsc::Sender;
|
||||||
|
|
||||||
@@ -11,8 +12,101 @@ impl MessageHandler<ServerMessage> for RoomListHandler {
|
|||||||
64
|
64
|
||||||
}
|
}
|
||||||
|
|
||||||
fn handle(&self, _message: &mut Message, _sender: Sender<ServerMessage>) {
|
fn handle(&self, message: &mut Message, sender: Sender<ServerMessage>) {
|
||||||
// Room listing is not yet exposed to the client; this handler exists
|
let rooms = parse_room_list(message);
|
||||||
// so the dispatcher can ack code 64 without logging an unknown 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:?}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -163,6 +163,36 @@ pub enum DownloadStatus {
|
|||||||
Failed(Option<String>),
|
Failed(Option<String>),
|
||||||
TimedOut,
|
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 {
|
impl Transfer {
|
||||||
pub fn new_from_message(message: &mut Message) -> Self {
|
pub fn new_from_message(message: &mut Message) -> Self {
|
||||||
let direction = message.read_int32();
|
let direction = message.read_int32();
|
||||||
|
|||||||
@@ -292,6 +292,85 @@ fn a_private_message_is_delivered_between_users() {
|
|||||||
assert_eq!(message.username(), "e2e_alice_pm");
|
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]
|
#[test]
|
||||||
fn login_succeeds_with_listener_enabled() {
|
fn login_succeeds_with_listener_enabled() {
|
||||||
let server = server_or_skip!();
|
let server = server_or_skip!();
|
||||||
|
|||||||
Reference in New Issue
Block a user