feat(rooms): private room membership, operators and refusals
Private rooms were unreachable: the client could ask to join one but had no way to invite, promote, resign or be told what happened, and a room name already owned by someone else came back as silence rather than a refusal (code 1003). Adds the rosters the server pushes after login (133/148), the changes it narrates to them (134/135, 143/144), our own standing (139/140, 145/146), and the messages that drive them, with the rosters kept current in client state. The daemon protocol and the TUI room transcript carry the new events rather than dropping them.
This commit is contained in:
@@ -26,6 +26,10 @@ use crate::message::server::UserJoinedRoomHandler;
|
||||
use crate::message::server::UserLeftRoomHandler;
|
||||
use crate::message::server::WatchUserHandler;
|
||||
use crate::message::server::WishListIntervalHandler;
|
||||
use crate::message::server::{
|
||||
CantCreateRoomHandler, OwnRoomStandingHandler, RoomMembersHandler,
|
||||
RoomOperatorsHandler, RoomRosterChangeHandler,
|
||||
};
|
||||
use crate::message::server::{
|
||||
GetUserStatsHandler, GetUserStatusHandler, RoomListHandler,
|
||||
};
|
||||
@@ -229,6 +233,36 @@ pub enum ServerMessage {
|
||||
},
|
||||
/// Phrases the server refuses to search for (code 160).
|
||||
ExcludedSearchPhrases(Vec<String>),
|
||||
/// Who may enter a private room (code 133).
|
||||
PrivateRoomMembers {
|
||||
room: String,
|
||||
users: Vec<String>,
|
||||
},
|
||||
/// Who runs a private room (code 148).
|
||||
PrivateRoomOperators {
|
||||
room: String,
|
||||
users: Vec<String>,
|
||||
},
|
||||
/// One user joined or left a private room's member or operator roster
|
||||
/// (codes 134/135 and 143/144).
|
||||
PrivateRoomRosterChanged {
|
||||
room: String,
|
||||
username: String,
|
||||
/// True for the member roster, false for the operator roster.
|
||||
members: bool,
|
||||
added: bool,
|
||||
},
|
||||
/// Our own membership (139/140) or operatorship (145/146) of a private
|
||||
/// room was granted or revoked.
|
||||
OwnRoomStandingChanged {
|
||||
room: String,
|
||||
members: bool,
|
||||
granted: bool,
|
||||
},
|
||||
/// The room we asked to join could not be created (code 1003).
|
||||
CantCreateRoom {
|
||||
room: String,
|
||||
},
|
||||
}
|
||||
|
||||
pub struct ServerActor {
|
||||
@@ -386,6 +420,19 @@ impl ServerActor {
|
||||
handlers.register_handler(GetPeerAddressHandler);
|
||||
handlers.register_handler(ConnectToPeerHandler);
|
||||
handlers.register_handler(CantConnectToPeerHandler);
|
||||
handlers.register_handler(RoomMembersHandler);
|
||||
handlers.register_handler(RoomOperatorsHandler);
|
||||
handlers.register_handler(RoomRosterChangeHandler::member_added());
|
||||
handlers.register_handler(RoomRosterChangeHandler::member_removed());
|
||||
handlers.register_handler(RoomRosterChangeHandler::operator_added());
|
||||
handlers.register_handler(RoomRosterChangeHandler::operator_removed());
|
||||
handlers.register_handler(OwnRoomStandingHandler::membership_granted());
|
||||
handlers.register_handler(OwnRoomStandingHandler::membership_revoked());
|
||||
handlers
|
||||
.register_handler(OwnRoomStandingHandler::operatorship_granted());
|
||||
handlers
|
||||
.register_handler(OwnRoomStandingHandler::operatorship_revoked());
|
||||
handlers.register_handler(CantCreateRoomHandler);
|
||||
handlers.register_handler(RoomTickersHandler);
|
||||
handlers.register_handler(RoomTickerAddedHandler);
|
||||
handlers.register_handler(RoomTickerRemovedHandler);
|
||||
@@ -694,6 +741,45 @@ impl ServerActor {
|
||||
token,
|
||||
});
|
||||
}
|
||||
ServerMessage::PrivateRoomMembers { room, users } => {
|
||||
self.forward_room_event(RoomEvent::PrivateMembers {
|
||||
room,
|
||||
users,
|
||||
});
|
||||
}
|
||||
ServerMessage::PrivateRoomOperators { room, users } => {
|
||||
self.forward_room_event(RoomEvent::PrivateOperators {
|
||||
room,
|
||||
users,
|
||||
});
|
||||
}
|
||||
ServerMessage::PrivateRoomRosterChanged {
|
||||
room,
|
||||
username,
|
||||
members,
|
||||
added,
|
||||
} => {
|
||||
self.forward_room_event(RoomEvent::PrivateRosterChanged {
|
||||
room,
|
||||
username,
|
||||
members,
|
||||
added,
|
||||
});
|
||||
}
|
||||
ServerMessage::OwnRoomStandingChanged {
|
||||
room,
|
||||
members,
|
||||
granted,
|
||||
} => {
|
||||
self.forward_room_event(RoomEvent::OwnStandingChanged {
|
||||
room,
|
||||
members,
|
||||
granted,
|
||||
});
|
||||
}
|
||||
ServerMessage::CantCreateRoom { room } => {
|
||||
self.forward_room_event(RoomEvent::CantCreate { room });
|
||||
}
|
||||
ServerMessage::ExcludedSearchPhrases(phrases) => {
|
||||
self.forward_to_client(ClientOperation::ExcludedSearchPhrases(
|
||||
phrases,
|
||||
|
||||
@@ -440,6 +440,11 @@ pub struct ClientContext {
|
||||
/// Per-member statistics for each joined room, from the stat vectors the
|
||||
/// server sends alongside the membership list.
|
||||
room_member_stats: HashMap<String, Vec<RoomUserStats>>,
|
||||
/// Who may enter each private room we belong to (code 133), kept current
|
||||
/// from the later roster changes.
|
||||
private_room_members: HashMap<String, Vec<String>>,
|
||||
/// Who runs each of those rooms (code 148).
|
||||
private_room_operators: HashMap<String, Vec<String>>,
|
||||
/// The ticker board of each joined room, kept current from the board sent
|
||||
/// on join (code 113) and the later add/remove events (114/115).
|
||||
room_tickers: HashMap<String, Vec<RoomTicker>>,
|
||||
@@ -490,6 +495,15 @@ pub struct ClientContext {
|
||||
upload_events: Vec<crate::types::UploadInfo>,
|
||||
actor_system: Arc<ActorSystem>,
|
||||
}
|
||||
/// A roster sorted and de-duplicated, so membership lookups can binary-search
|
||||
/// it and a repeated name cannot appear twice.
|
||||
fn sorted_unique(users: &[String]) -> Vec<String> {
|
||||
let mut users = users.to_vec();
|
||||
users.sort();
|
||||
users.dedup();
|
||||
users
|
||||
}
|
||||
|
||||
impl Default for ClientContext {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
@@ -569,6 +583,8 @@ impl ClientContext {
|
||||
room_members: HashMap::new(),
|
||||
room_member_stats: HashMap::new(),
|
||||
room_tickers: HashMap::new(),
|
||||
private_room_members: HashMap::new(),
|
||||
private_room_operators: HashMap::new(),
|
||||
recommendations: None,
|
||||
global_recommendations: None,
|
||||
item_recommendations: HashMap::new(),
|
||||
@@ -641,11 +657,84 @@ impl ClientContext {
|
||||
board.retain(|t| &t.username != username);
|
||||
}
|
||||
}
|
||||
RoomEvent::Message { .. } | RoomEvent::GlobalMessage { .. } => {}
|
||||
RoomEvent::PrivateMembers { room, users } => {
|
||||
self.private_room_members
|
||||
.insert(room.clone(), sorted_unique(users));
|
||||
}
|
||||
RoomEvent::PrivateOperators { room, users } => {
|
||||
self.private_room_operators
|
||||
.insert(room.clone(), sorted_unique(users));
|
||||
}
|
||||
RoomEvent::PrivateRosterChanged {
|
||||
room,
|
||||
username,
|
||||
members,
|
||||
added,
|
||||
} => {
|
||||
let roster = if *members {
|
||||
self.private_room_members.entry(room.clone()).or_default()
|
||||
} else {
|
||||
self.private_room_operators.entry(room.clone()).or_default()
|
||||
};
|
||||
match (added, roster.binary_search(username)) {
|
||||
(true, Err(at)) => roster.insert(at, username.clone()),
|
||||
(false, Ok(at)) => {
|
||||
roster.remove(at);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
RoomEvent::OwnStandingChanged {
|
||||
room,
|
||||
members,
|
||||
granted,
|
||||
} => {
|
||||
// Our own standing is rendered from the same rosters, so a
|
||||
// revocation must drop the room rather than leave a roster
|
||||
// we are no longer part of looking current.
|
||||
if !granted {
|
||||
// Losing membership takes the operator roster with it:
|
||||
// a room we cannot enter has no roster worth showing.
|
||||
if *members {
|
||||
self.private_room_members.remove(room);
|
||||
}
|
||||
self.private_room_operators.remove(room);
|
||||
}
|
||||
}
|
||||
RoomEvent::Message { .. }
|
||||
| RoomEvent::GlobalMessage { .. }
|
||||
| RoomEvent::CantCreate { .. } => {}
|
||||
}
|
||||
self.room_events.push(event);
|
||||
}
|
||||
|
||||
/// Who may enter the private room `room`, as last reported.
|
||||
#[must_use]
|
||||
pub fn private_room_members(&self, room: &str) -> Vec<String> {
|
||||
self.private_room_members
|
||||
.get(room)
|
||||
.cloned()
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Who runs the private room `room`, as last reported.
|
||||
#[must_use]
|
||||
pub fn private_room_operators(&self, room: &str) -> Vec<String> {
|
||||
self.private_room_operators
|
||||
.get(room)
|
||||
.cloned()
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
/// The private rooms we belong to, in a stable order.
|
||||
#[must_use]
|
||||
pub fn private_rooms(&self) -> Vec<String> {
|
||||
let mut rooms: Vec<String> =
|
||||
self.private_room_members.keys().cloned().collect();
|
||||
rooms.sort();
|
||||
rooms
|
||||
}
|
||||
|
||||
/// The ticker board of `room` as last reported by the server.
|
||||
#[must_use]
|
||||
pub fn room_tickers(&self, room: &str) -> Vec<RoomTicker> {
|
||||
|
||||
@@ -21,6 +21,137 @@ impl Client {
|
||||
self.send_server_message(MessageFactory::build_server_ping())
|
||||
}
|
||||
|
||||
/// Join a private room, creating it (and owning it) if the name is free.
|
||||
///
|
||||
/// A name already taken by someone else's private room comes back as a
|
||||
/// [`crate::RoomEvent::CantCreate`] rather than a join.
|
||||
///
|
||||
/// # Errors
|
||||
/// [`crate::SoulseekRs::NotConnected`] when there is no server connection.
|
||||
pub fn join_private_room(&self, room: &str) -> Result<()> {
|
||||
self.send_server_message(MessageFactory::build_join_room(room, true))
|
||||
}
|
||||
|
||||
/// Invite `username` into the private room we own (code 134).
|
||||
///
|
||||
/// # Errors
|
||||
/// [`crate::SoulseekRs::NotConnected`] when there is no server connection.
|
||||
pub fn add_room_member(&self, room: &str, username: &str) -> Result<()> {
|
||||
self.send_server_message(MessageFactory::build_add_room_member(
|
||||
room, username,
|
||||
))
|
||||
}
|
||||
|
||||
/// Remove `username` from the private room we own (code 135).
|
||||
///
|
||||
/// # Errors
|
||||
/// [`crate::SoulseekRs::NotConnected`] when there is no server connection.
|
||||
pub fn remove_room_member(&self, room: &str, username: &str) -> Result<()> {
|
||||
self.send_server_message(MessageFactory::build_remove_room_member(
|
||||
room, username,
|
||||
))
|
||||
}
|
||||
|
||||
/// Give up our own membership of a private room (code 136).
|
||||
///
|
||||
/// # Errors
|
||||
/// [`crate::SoulseekRs::NotConnected`] when there is no server connection.
|
||||
pub fn leave_private_room(&self, room: &str) -> Result<()> {
|
||||
self.send_server_message(MessageFactory::build_cancel_room_membership(
|
||||
room,
|
||||
))
|
||||
}
|
||||
|
||||
/// Give up ownership of a private room, which deletes it (code 137).
|
||||
///
|
||||
/// # Errors
|
||||
/// [`crate::SoulseekRs::NotConnected`] when there is no server connection.
|
||||
pub fn disband_private_room(&self, room: &str) -> Result<()> {
|
||||
self.send_server_message(MessageFactory::build_cancel_room_ownership(
|
||||
room,
|
||||
))
|
||||
}
|
||||
|
||||
/// Say whether we accept private-room invitations (code 141).
|
||||
///
|
||||
/// # Errors
|
||||
/// [`crate::SoulseekRs::NotConnected`] when there is no server connection.
|
||||
pub fn set_room_invitations_enabled(&self, enabled: bool) -> Result<()> {
|
||||
self.send_server_message(MessageFactory::build_enable_room_invitations(
|
||||
enabled,
|
||||
))
|
||||
}
|
||||
|
||||
/// Make `username` an operator of our private room (code 143).
|
||||
///
|
||||
/// # Errors
|
||||
/// [`crate::SoulseekRs::NotConnected`] when there is no server connection.
|
||||
pub fn add_room_operator(&self, room: &str, username: &str) -> Result<()> {
|
||||
self.send_server_message(MessageFactory::build_add_room_operator(
|
||||
room, username,
|
||||
))
|
||||
}
|
||||
|
||||
/// Take operator status away from `username` (code 144).
|
||||
///
|
||||
/// # Errors
|
||||
/// [`crate::SoulseekRs::NotConnected`] when there is no server connection.
|
||||
pub fn remove_room_operator(
|
||||
&self,
|
||||
room: &str,
|
||||
username: &str,
|
||||
) -> Result<()> {
|
||||
self.send_server_message(MessageFactory::build_remove_room_operator(
|
||||
room, username,
|
||||
))
|
||||
}
|
||||
|
||||
/// Give up our own operator status in `room` (code 147).
|
||||
///
|
||||
/// # Errors
|
||||
/// [`crate::SoulseekRs::NotConnected`] when there is no server connection.
|
||||
pub fn resign_room_operatorship(&self, room: &str) -> Result<()> {
|
||||
self.send_server_message(
|
||||
MessageFactory::build_cancel_room_operatorship(room),
|
||||
)
|
||||
}
|
||||
|
||||
/// Who may enter the private room `room`, as the server last reported.
|
||||
#[must_use]
|
||||
pub fn private_room_members(&self, room: &str) -> Vec<String> {
|
||||
match self.context.read_safe() {
|
||||
Ok(ctx) => ctx.private_room_members(room),
|
||||
Err(e) => {
|
||||
error!("[client] private_room_members: {}", e);
|
||||
Vec::new()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Who runs the private room `room`.
|
||||
#[must_use]
|
||||
pub fn private_room_operators(&self, room: &str) -> Vec<String> {
|
||||
match self.context.read_safe() {
|
||||
Ok(ctx) => ctx.private_room_operators(room),
|
||||
Err(e) => {
|
||||
error!("[client] private_room_operators: {}", e);
|
||||
Vec::new()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The private rooms we belong to.
|
||||
#[must_use]
|
||||
pub fn private_rooms(&self) -> Vec<String> {
|
||||
match self.context.read_safe() {
|
||||
Ok(ctx) => ctx.private_rooms(),
|
||||
Err(e) => {
|
||||
error!("[client] private_rooms: {}", e);
|
||||
Vec::new()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Set our online status (server code 28): away, or back to online.
|
||||
///
|
||||
/// Other clients see it in their `GetUserStatus` and watch replies, and
|
||||
|
||||
@@ -951,3 +951,86 @@ fn asking_again_about_a_users_interests_drops_the_previous_answer() {
|
||||
"a stale answer must not be mistaken for the next one"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_private_room_roster_tracks_who_is_added_and_removed() {
|
||||
let mut ctx = ClientContext::new();
|
||||
ctx.apply_room_event(RoomEvent::PrivateMembers {
|
||||
room: "club".into(),
|
||||
users: vec!["bob".into(), "alice".into(), "bob".into()],
|
||||
});
|
||||
assert_eq!(
|
||||
ctx.private_room_members("club"),
|
||||
vec!["alice".to_string(), "bob".to_string()],
|
||||
"a roster is sorted and free of repeats"
|
||||
);
|
||||
|
||||
ctx.apply_room_event(RoomEvent::PrivateRosterChanged {
|
||||
room: "club".into(),
|
||||
username: "carol".into(),
|
||||
members: true,
|
||||
added: true,
|
||||
});
|
||||
ctx.apply_room_event(RoomEvent::PrivateRosterChanged {
|
||||
room: "club".into(),
|
||||
username: "alice".into(),
|
||||
members: true,
|
||||
added: false,
|
||||
});
|
||||
assert_eq!(
|
||||
ctx.private_room_members("club"),
|
||||
vec!["bob".to_string(), "carol".to_string()]
|
||||
);
|
||||
|
||||
// Operators are a separate roster in the same room.
|
||||
ctx.apply_room_event(RoomEvent::PrivateRosterChanged {
|
||||
room: "club".into(),
|
||||
username: "bob".into(),
|
||||
members: false,
|
||||
added: true,
|
||||
});
|
||||
assert_eq!(ctx.private_room_operators("club"), vec!["bob".to_string()]);
|
||||
assert_eq!(ctx.private_rooms(), vec!["club".to_string()]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn revoked_membership_drops_the_room_we_can_no_longer_see() {
|
||||
let mut ctx = ClientContext::new();
|
||||
ctx.apply_room_event(RoomEvent::PrivateMembers {
|
||||
room: "club".into(),
|
||||
users: vec!["alice".into()],
|
||||
});
|
||||
ctx.apply_room_event(RoomEvent::PrivateOperators {
|
||||
room: "club".into(),
|
||||
users: vec!["alice".into()],
|
||||
});
|
||||
|
||||
ctx.apply_room_event(RoomEvent::OwnStandingChanged {
|
||||
room: "club".into(),
|
||||
members: true,
|
||||
granted: false,
|
||||
});
|
||||
assert!(ctx.private_rooms().is_empty());
|
||||
assert!(ctx.private_room_operators("club").is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn losing_operatorship_keeps_the_membership_it_does_not_touch() {
|
||||
let mut ctx = ClientContext::new();
|
||||
ctx.apply_room_event(RoomEvent::PrivateMembers {
|
||||
room: "club".into(),
|
||||
users: vec!["alice".into()],
|
||||
});
|
||||
ctx.apply_room_event(RoomEvent::PrivateOperators {
|
||||
room: "club".into(),
|
||||
users: vec!["alice".into()],
|
||||
});
|
||||
|
||||
ctx.apply_room_event(RoomEvent::OwnStandingChanged {
|
||||
room: "club".into(),
|
||||
members: false,
|
||||
granted: false,
|
||||
});
|
||||
assert_eq!(ctx.private_room_members("club"), vec!["alice".to_string()]);
|
||||
assert!(ctx.private_room_operators("club").is_empty());
|
||||
}
|
||||
|
||||
@@ -310,6 +310,73 @@ impl MessageFactory {
|
||||
.clone()
|
||||
}
|
||||
|
||||
/// Invite `username` into the private room we own (server code 134).
|
||||
#[must_use]
|
||||
pub fn build_add_room_member(room: &str, username: &str) -> Message {
|
||||
Message::new()
|
||||
.write_int32(134)
|
||||
.write_string(room)
|
||||
.write_string(username)
|
||||
.clone()
|
||||
}
|
||||
|
||||
/// Remove `username` from the private room we own (server code 135).
|
||||
#[must_use]
|
||||
pub fn build_remove_room_member(room: &str, username: &str) -> Message {
|
||||
Message::new()
|
||||
.write_int32(135)
|
||||
.write_string(room)
|
||||
.write_string(username)
|
||||
.clone()
|
||||
}
|
||||
|
||||
/// Give up our own membership of a private room (server code 136).
|
||||
#[must_use]
|
||||
pub fn build_cancel_room_membership(room: &str) -> Message {
|
||||
Message::new().write_int32(136).write_string(room).clone()
|
||||
}
|
||||
|
||||
/// Give up ownership of a private room, which deletes it (code 137).
|
||||
#[must_use]
|
||||
pub fn build_cancel_room_ownership(room: &str) -> Message {
|
||||
Message::new().write_int32(137).write_string(room).clone()
|
||||
}
|
||||
|
||||
/// Say whether we accept private-room invitations (server code 141).
|
||||
#[must_use]
|
||||
pub fn build_enable_room_invitations(enabled: bool) -> Message {
|
||||
Message::new()
|
||||
.write_int32(141)
|
||||
.write_int8(u8::from(enabled))
|
||||
.clone()
|
||||
}
|
||||
|
||||
/// Make `username` an operator of our private room (server code 143).
|
||||
#[must_use]
|
||||
pub fn build_add_room_operator(room: &str, username: &str) -> Message {
|
||||
Message::new()
|
||||
.write_int32(143)
|
||||
.write_string(room)
|
||||
.write_string(username)
|
||||
.clone()
|
||||
}
|
||||
|
||||
/// Take operator status away from `username` (server code 144).
|
||||
#[must_use]
|
||||
pub fn build_remove_room_operator(room: &str, username: &str) -> Message {
|
||||
Message::new()
|
||||
.write_int32(144)
|
||||
.write_string(room)
|
||||
.write_string(username)
|
||||
.clone()
|
||||
}
|
||||
|
||||
/// Give up our own operator status in a room (server code 147).
|
||||
#[must_use]
|
||||
pub fn build_cancel_room_operatorship(room: &str) -> Message {
|
||||
Message::new().write_int32(147).write_string(room).clone()
|
||||
}
|
||||
|
||||
/// A keepalive ping (server code 32, no body). The server sends no reply;
|
||||
/// its only job is to keep a quiet connection from being reaped by a NAT
|
||||
/// or the server's own idle timeout, the way other clients ping.
|
||||
@@ -830,3 +897,54 @@ fn a_multi_user_message_counts_its_recipients_first() {
|
||||
assert_eq!(decoded.read_string(), "bob");
|
||||
assert_eq!(decoded.read_string(), "hi all");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_private_room_roster_messages_carry_room_then_user() {
|
||||
for (message, code) in [
|
||||
(MessageFactory::build_add_room_member("club", "bob"), 134),
|
||||
(MessageFactory::build_remove_room_member("club", "bob"), 135),
|
||||
(MessageFactory::build_add_room_operator("club", "bob"), 143),
|
||||
(
|
||||
MessageFactory::build_remove_room_operator("club", "bob"),
|
||||
144,
|
||||
),
|
||||
] {
|
||||
let mut decoded = Message::new_with_data(message.get_buffer());
|
||||
assert_eq!(decoded.get_message_code(), code);
|
||||
decoded.set_pointer(8);
|
||||
assert_eq!(decoded.read_string(), "club");
|
||||
assert_eq!(decoded.read_string(), "bob");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn giving_up_a_room_standing_names_only_the_room() {
|
||||
for (message, code) in [
|
||||
(MessageFactory::build_cancel_room_membership("club"), 136),
|
||||
(MessageFactory::build_cancel_room_ownership("club"), 137),
|
||||
(MessageFactory::build_cancel_room_operatorship("club"), 147),
|
||||
] {
|
||||
let mut decoded = Message::new_with_data(message.get_buffer());
|
||||
assert_eq!(decoded.get_message_code(), code);
|
||||
decoded.set_pointer(8);
|
||||
assert_eq!(decoded.read_string(), "club");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn room_invitations_are_toggled_by_a_single_byte() {
|
||||
let message = MessageFactory::build_enable_room_invitations(true);
|
||||
assert_eq!(message.get_data(), [141, 0, 0, 0, 1]);
|
||||
let message = MessageFactory::build_enable_room_invitations(false);
|
||||
assert_eq!(message.get_data(), [141, 0, 0, 0, 0]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_private_join_sets_the_private_flag() {
|
||||
let message = MessageFactory::build_join_room("club", true);
|
||||
let mut decoded = Message::new_with_data(message.get_buffer());
|
||||
assert_eq!(decoded.get_message_code(), 14);
|
||||
decoded.set_pointer(8);
|
||||
assert_eq!(decoded.read_string(), "club");
|
||||
assert_eq!(decoded.read_int32(), 1);
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ mod message_factory;
|
||||
mod message_user;
|
||||
mod parent_min_speed;
|
||||
mod parent_speed_ratio;
|
||||
mod private_rooms;
|
||||
mod privileged_users;
|
||||
mod relogged;
|
||||
mod room_list;
|
||||
@@ -47,6 +48,10 @@ pub use message_factory::MessageFactory;
|
||||
pub use message_user::MessageUser;
|
||||
pub use parent_min_speed::ParentMinSpeedHandler;
|
||||
pub use parent_speed_ratio::ParentSpeedRatioHandler;
|
||||
pub use private_rooms::{
|
||||
CantCreateRoomHandler, OwnRoomStandingHandler, RoomMembersHandler,
|
||||
RoomOperatorsHandler, RoomRosterChangeHandler,
|
||||
};
|
||||
pub use privileged_users::{CheckPrivilegesHandler, PrivilegedUsersHandler};
|
||||
pub use relogged::ReloggedHandler;
|
||||
pub use room_list::RoomListHandler;
|
||||
|
||||
@@ -0,0 +1,317 @@
|
||||
//! Private rooms: who may enter one and who runs it.
|
||||
//!
|
||||
//! The server pushes the membership and operator rosters of every private
|
||||
//! room we belong to right after login (codes 133 and 148), then narrates
|
||||
//! changes to them: a member added or removed (134/135), our own membership
|
||||
//! or operatorship granted or revoked (139/140, 145/146), and a room we asked
|
||||
//! for that could not be created (1003).
|
||||
|
||||
use std::sync::mpsc::Sender;
|
||||
|
||||
use crate::{
|
||||
actor::server_actor::ServerMessage,
|
||||
message::{Message, MessageHandler},
|
||||
};
|
||||
|
||||
/// Read a `[room][count][username]...` roster, stopping at the payload's end.
|
||||
fn read_roster(message: &mut Message) -> (String, Vec<String>) {
|
||||
let room = message.read_string();
|
||||
let count = message.read_int32();
|
||||
let mut users = Vec::new();
|
||||
for _ in 0..count {
|
||||
let username = message.read_string();
|
||||
if username.is_empty() {
|
||||
break;
|
||||
}
|
||||
users.push(username);
|
||||
}
|
||||
(room, users)
|
||||
}
|
||||
|
||||
/// `RoomMembers` (code 133): who may enter a private room.
|
||||
pub struct RoomMembersHandler;
|
||||
|
||||
impl MessageHandler<ServerMessage> for RoomMembersHandler {
|
||||
fn get_code(&self) -> u32 {
|
||||
133
|
||||
}
|
||||
|
||||
fn handle(&self, message: &mut Message, sender: Sender<ServerMessage>) {
|
||||
let (room, users) = read_roster(message);
|
||||
let _ = sender.send(ServerMessage::PrivateRoomMembers { room, users });
|
||||
}
|
||||
}
|
||||
|
||||
/// `RoomOperators` (code 148): who runs a private room.
|
||||
pub struct RoomOperatorsHandler;
|
||||
|
||||
impl MessageHandler<ServerMessage> for RoomOperatorsHandler {
|
||||
fn get_code(&self) -> u32 {
|
||||
148
|
||||
}
|
||||
|
||||
fn handle(&self, message: &mut Message, sender: Sender<ServerMessage>) {
|
||||
let (room, users) = read_roster(message);
|
||||
let _ =
|
||||
sender.send(ServerMessage::PrivateRoomOperators { room, users });
|
||||
}
|
||||
}
|
||||
|
||||
/// One user added to (`added`) or removed from a private room's roster:
|
||||
/// members (134/135) or operators (143/144).
|
||||
pub struct RoomRosterChangeHandler {
|
||||
code: u32,
|
||||
members: bool,
|
||||
added: bool,
|
||||
}
|
||||
|
||||
impl RoomRosterChangeHandler {
|
||||
#[must_use]
|
||||
pub const fn member_added() -> Self {
|
||||
Self {
|
||||
code: 134,
|
||||
members: true,
|
||||
added: true,
|
||||
}
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub const fn member_removed() -> Self {
|
||||
Self {
|
||||
code: 135,
|
||||
members: true,
|
||||
added: false,
|
||||
}
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub const fn operator_added() -> Self {
|
||||
Self {
|
||||
code: 143,
|
||||
members: false,
|
||||
added: true,
|
||||
}
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub const fn operator_removed() -> Self {
|
||||
Self {
|
||||
code: 144,
|
||||
members: false,
|
||||
added: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl MessageHandler<ServerMessage> for RoomRosterChangeHandler {
|
||||
fn get_code(&self) -> u32 {
|
||||
self.code
|
||||
}
|
||||
|
||||
fn handle(&self, message: &mut Message, sender: Sender<ServerMessage>) {
|
||||
let room = message.read_string();
|
||||
let username = message.read_string();
|
||||
let _ = sender.send(ServerMessage::PrivateRoomRosterChanged {
|
||||
room,
|
||||
username,
|
||||
members: self.members,
|
||||
added: self.added,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// Our own standing in a private room changed: membership granted (139) or
|
||||
/// revoked (140), operatorship granted (145) or revoked (146).
|
||||
pub struct OwnRoomStandingHandler {
|
||||
code: u32,
|
||||
members: bool,
|
||||
granted: bool,
|
||||
}
|
||||
|
||||
impl OwnRoomStandingHandler {
|
||||
#[must_use]
|
||||
pub const fn membership_granted() -> Self {
|
||||
Self {
|
||||
code: 139,
|
||||
members: true,
|
||||
granted: true,
|
||||
}
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub const fn membership_revoked() -> Self {
|
||||
Self {
|
||||
code: 140,
|
||||
members: true,
|
||||
granted: false,
|
||||
}
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub const fn operatorship_granted() -> Self {
|
||||
Self {
|
||||
code: 145,
|
||||
members: false,
|
||||
granted: true,
|
||||
}
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub const fn operatorship_revoked() -> Self {
|
||||
Self {
|
||||
code: 146,
|
||||
members: false,
|
||||
granted: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl MessageHandler<ServerMessage> for OwnRoomStandingHandler {
|
||||
fn get_code(&self) -> u32 {
|
||||
self.code
|
||||
}
|
||||
|
||||
fn handle(&self, message: &mut Message, sender: Sender<ServerMessage>) {
|
||||
let room = message.read_string();
|
||||
let _ = sender.send(ServerMessage::OwnRoomStandingChanged {
|
||||
room,
|
||||
members: self.members,
|
||||
granted: self.granted,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// `CantCreateRoom` (code 1003): the room we asked to join could not be
|
||||
/// created — the name is taken by a private room we are not a member of, or
|
||||
/// it is otherwise refused.
|
||||
pub struct CantCreateRoomHandler;
|
||||
|
||||
impl MessageHandler<ServerMessage> for CantCreateRoomHandler {
|
||||
fn get_code(&self) -> u32 {
|
||||
1003
|
||||
}
|
||||
|
||||
fn handle(&self, message: &mut Message, sender: Sender<ServerMessage>) {
|
||||
let room = message.read_string();
|
||||
let _ = sender.send(ServerMessage::CantCreateRoom { room });
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::message::framed;
|
||||
|
||||
#[test]
|
||||
fn a_member_roster_lists_the_rooms_members() {
|
||||
let (tx, rx) = std::sync::mpsc::channel();
|
||||
let mut message = framed(|m| {
|
||||
m.write_string("club");
|
||||
m.write_int32(2);
|
||||
m.write_string("alice");
|
||||
m.write_string("bob");
|
||||
});
|
||||
|
||||
RoomMembersHandler.handle(&mut message, tx);
|
||||
match rx.try_recv() {
|
||||
Ok(ServerMessage::PrivateRoomMembers { room, users }) => {
|
||||
assert_eq!(room, "club");
|
||||
assert_eq!(users, ["alice", "bob"]);
|
||||
}
|
||||
other => panic!("unexpected: {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_roster_count_beyond_the_payload_stops_at_the_data() {
|
||||
let (tx, rx) = std::sync::mpsc::channel();
|
||||
let mut message = framed(|m| {
|
||||
m.write_string("club");
|
||||
m.write_int32(u32::MAX);
|
||||
m.write_string("alice");
|
||||
});
|
||||
|
||||
RoomOperatorsHandler.handle(&mut message, tx);
|
||||
match rx.try_recv() {
|
||||
Ok(ServerMessage::PrivateRoomOperators { users, .. }) => {
|
||||
assert_eq!(users, ["alice"]);
|
||||
}
|
||||
other => panic!("unexpected: {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn each_roster_change_reports_which_roster_and_which_way() {
|
||||
for (handler, members, added) in [
|
||||
(RoomRosterChangeHandler::member_added(), true, true),
|
||||
(RoomRosterChangeHandler::member_removed(), true, false),
|
||||
(RoomRosterChangeHandler::operator_added(), false, true),
|
||||
(RoomRosterChangeHandler::operator_removed(), false, false),
|
||||
] {
|
||||
let (tx, rx) = std::sync::mpsc::channel();
|
||||
let mut message = framed(|m| {
|
||||
m.write_string("club");
|
||||
m.write_string("alice");
|
||||
});
|
||||
|
||||
handler.handle(&mut message, tx);
|
||||
match rx.try_recv() {
|
||||
Ok(ServerMessage::PrivateRoomRosterChanged {
|
||||
room,
|
||||
username,
|
||||
members: got_members,
|
||||
added: got_added,
|
||||
}) => {
|
||||
assert_eq!(room, "club");
|
||||
assert_eq!(username, "alice");
|
||||
assert_eq!((got_members, got_added), (members, added));
|
||||
}
|
||||
other => panic!("unexpected: {other:?}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn our_own_standing_reports_the_room_and_the_change() {
|
||||
for (handler, members, granted) in [
|
||||
(OwnRoomStandingHandler::membership_granted(), true, true),
|
||||
(OwnRoomStandingHandler::membership_revoked(), true, false),
|
||||
(OwnRoomStandingHandler::operatorship_granted(), false, true),
|
||||
(OwnRoomStandingHandler::operatorship_revoked(), false, false),
|
||||
] {
|
||||
let (tx, rx) = std::sync::mpsc::channel();
|
||||
let mut message = framed(|m| {
|
||||
m.write_string("club");
|
||||
});
|
||||
|
||||
handler.handle(&mut message, tx);
|
||||
match rx.try_recv() {
|
||||
Ok(ServerMessage::OwnRoomStandingChanged {
|
||||
room,
|
||||
members: got_members,
|
||||
granted: got_granted,
|
||||
}) => {
|
||||
assert_eq!(room, "club");
|
||||
assert_eq!((got_members, got_granted), (members, granted));
|
||||
}
|
||||
other => panic!("unexpected: {other:?}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_refused_room_names_itself() {
|
||||
let (tx, rx) = std::sync::mpsc::channel();
|
||||
let mut message = framed(|m| {
|
||||
m.write_string("taken");
|
||||
});
|
||||
|
||||
CantCreateRoomHandler.handle(&mut message, tx);
|
||||
match rx.try_recv() {
|
||||
Ok(ServerMessage::CantCreateRoom { room }) => {
|
||||
assert_eq!(room, "taken");
|
||||
}
|
||||
other => panic!("unexpected: {other:?}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -414,6 +414,28 @@ pub enum RoomEvent {
|
||||
username: String,
|
||||
message: String,
|
||||
},
|
||||
/// Who may enter a private room (code 133).
|
||||
PrivateMembers { room: String, users: Vec<String> },
|
||||
/// Who runs a private room (code 148).
|
||||
PrivateOperators { room: String, users: Vec<String> },
|
||||
/// One user joined or left a private room's member roster (`members`
|
||||
/// true, codes 134/135) or its operator roster (143/144).
|
||||
PrivateRosterChanged {
|
||||
room: String,
|
||||
username: String,
|
||||
members: bool,
|
||||
added: bool,
|
||||
},
|
||||
/// Our own membership (`members` true, codes 139/140) or operatorship
|
||||
/// (145/146) of a private room was granted or revoked.
|
||||
OwnStandingChanged {
|
||||
room: String,
|
||||
members: bool,
|
||||
granted: bool,
|
||||
},
|
||||
/// The room we asked to join could not be created (code 1003) — the name
|
||||
/// belongs to a private room we are not a member of.
|
||||
CantCreate { room: String },
|
||||
}
|
||||
|
||||
/// One user's ticker (the scrolling one-line message) in a room.
|
||||
|
||||
@@ -4583,3 +4583,128 @@ fn a_queued_download_can_ask_where_it_sits() {
|
||||
let _ = peer.join();
|
||||
let _ = std::fs::remove_dir_all(download_dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_private_room_is_owned_granted_and_revoked() {
|
||||
use soulseek_rs::types::RoomEvent;
|
||||
let server = server_or_skip!();
|
||||
|
||||
let room = "e2e_private_club";
|
||||
let mut owner =
|
||||
Client::with_settings(server.settings("e2e_priv_owner", "pw"));
|
||||
let mut guest =
|
||||
Client::with_settings(server.settings("e2e_priv_guest", "pw"));
|
||||
owner.connect().expect("owner connect");
|
||||
guest.connect().expect("guest connect");
|
||||
assert!(owner.login().expect("owner login"));
|
||||
assert!(guest.login().expect("guest login"));
|
||||
|
||||
guest
|
||||
.set_room_invitations_enabled(true)
|
||||
.expect("guest accepts invitations");
|
||||
owner
|
||||
.join_private_room(room)
|
||||
.expect("owner creates the room");
|
||||
std::thread::sleep(Duration::from_millis(750));
|
||||
|
||||
// The guest is invited, and hears about it (code 139).
|
||||
owner
|
||||
.add_room_member(room, "e2e_priv_guest")
|
||||
.expect("owner invites the guest");
|
||||
let granted = await_room_event(&guest, Duration::from_secs(5), |event| {
|
||||
matches!(event, RoomEvent::OwnStandingChanged {
|
||||
room: r, members: true, granted: true
|
||||
} if r == room)
|
||||
.then_some(())
|
||||
});
|
||||
assert!(
|
||||
granted.is_some(),
|
||||
"the guest should be told they are a member"
|
||||
);
|
||||
|
||||
// The owner's own roster now carries the guest (code 134).
|
||||
let deadline = Instant::now() + Duration::from_secs(5);
|
||||
while Instant::now() < deadline
|
||||
&& !owner
|
||||
.private_room_members(room)
|
||||
.iter()
|
||||
.any(|u| u == "e2e_priv_guest")
|
||||
{
|
||||
std::thread::sleep(Duration::from_millis(100));
|
||||
}
|
||||
assert!(
|
||||
owner
|
||||
.private_room_members(room)
|
||||
.iter()
|
||||
.any(|u| u == "e2e_priv_guest"),
|
||||
"the member roster should carry the guest, got {:?}",
|
||||
owner.private_room_members(room)
|
||||
);
|
||||
|
||||
// A member can be made an operator (code 145 for them, 143 for the room).
|
||||
owner
|
||||
.add_room_operator(room, "e2e_priv_guest")
|
||||
.expect("owner promotes the guest");
|
||||
let promoted = await_room_event(&guest, Duration::from_secs(5), |event| {
|
||||
matches!(event, RoomEvent::OwnStandingChanged {
|
||||
room: r, members: false, granted: true
|
||||
} if r == room)
|
||||
.then_some(())
|
||||
});
|
||||
assert!(
|
||||
promoted.is_some(),
|
||||
"the guest should be told they run the room"
|
||||
);
|
||||
|
||||
// And membership can be taken away again (code 140).
|
||||
owner
|
||||
.remove_room_member(room, "e2e_priv_guest")
|
||||
.expect("owner removes the guest");
|
||||
let revoked = await_room_event(&guest, Duration::from_secs(5), |event| {
|
||||
matches!(event, RoomEvent::OwnStandingChanged {
|
||||
room: r, members: true, granted: false
|
||||
} if r == room)
|
||||
.then_some(())
|
||||
});
|
||||
assert!(revoked.is_some(), "the guest should be told they are out");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_private_room_someone_else_owns_cannot_be_taken() {
|
||||
use soulseek_rs::types::RoomEvent;
|
||||
let server = server_or_skip!();
|
||||
|
||||
let room = "e2e_private_taken";
|
||||
let mut owner =
|
||||
Client::with_settings(server.settings("e2e_taken_owner", "pw"));
|
||||
owner.connect().expect("owner connect");
|
||||
assert!(owner.login().expect("owner login"));
|
||||
owner
|
||||
.join_private_room(room)
|
||||
.expect("owner creates the room");
|
||||
std::thread::sleep(Duration::from_millis(750));
|
||||
|
||||
// An outsider asking for the same name must be refused (code 1003), not
|
||||
// handed the room.
|
||||
let mut outsider =
|
||||
Client::with_settings(server.settings("e2e_taken_outsider", "pw"));
|
||||
outsider.connect().expect("outsider connect");
|
||||
assert!(outsider.login().expect("outsider login"));
|
||||
outsider
|
||||
.join_private_room(room)
|
||||
.expect("outsider asks for the same room");
|
||||
|
||||
let refused =
|
||||
await_room_event(&outsider, Duration::from_secs(5), |event| {
|
||||
matches!(event, RoomEvent::CantCreate { room: r } if r == room)
|
||||
.then_some(())
|
||||
});
|
||||
assert!(
|
||||
refused.is_some(),
|
||||
"a private room owned by someone else must be refused"
|
||||
);
|
||||
assert!(
|
||||
outsider.private_rooms().is_empty(),
|
||||
"a refused room must not appear as one we belong to"
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user