diff --git a/Cargo.toml b/Cargo.toml index 38f500784..bf63c38a9 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -25,6 +25,8 @@ default = [ "command", "weather", "testing", + "chat", + "secure_chat", ] advancement = ["dep:valence_advancement"] anvil = ["dep:valence_anvil"] @@ -37,6 +39,8 @@ scoreboard = ["dep:valence_scoreboard"] world_border = ["dep:valence_world_border"] command = ["dep:valence_command", "dep:valence_command_macros"] weather = ["dep:valence_weather"] +chat = ["dep:valence_chat"] +secure_chat = ["chat", "valence_chat?/secure"] testing = [] [dependencies] @@ -66,6 +70,7 @@ valence_server.workspace = true valence_text.workspace = true valence_weather = { workspace = true, optional = true } valence_world_border = { workspace = true, optional = true } +valence_chat = { workspace = true, optional = true } [dev-dependencies] anyhow.workspace = true @@ -183,6 +188,7 @@ valence_advancement = { path = "crates/valence_advancement", version = "0.2.0-al valence_anvil = { path = "crates/valence_anvil", version = "0.1.0" } valence_boss_bar = { path = "crates/valence_boss_bar", version = "0.2.0-alpha.1" } valence_build_utils = { path = "crates/valence_build_utils", version = "0.2.0-alpha.1" } +valence_chat = { path = "crates/valence_chat", version = "0.2.0-alpha.1" } valence_command = { path = "crates/valence_command", version = "0.2.0-alpha.1" } valence_command_macros = { path = "crates/valence_command_macros", version = "0.2.0-alpha.1" } valence_entity = { path = "crates/valence_entity", version = "0.2.0-alpha.1" } diff --git a/assets/depgraph.svg b/assets/depgraph.svg index c5380973c..4cb26c577 100644 --- a/assets/depgraph.svg +++ b/assets/depgraph.svg @@ -1,403 +1,420 @@ - - - - -%3 - + + + + 0 - -java_string + +java_string 1 - -valence_advancement + +valence_advancement 2 - -valence_server + +valence_server 1->2 - - + + 3 - -valence_entity + +valence_entity 2->3 - - + + 12 - -valence_registry + +valence_registry 2->12 - - + + 11 - -valence_server_common + +valence_server_common 3->11 - - + + 12->11 - - + + 7 - -valence_protocol + +valence_protocol 11->7 - - + + 4 - -valence_math + +valence_math 5 - -valence_nbt + +valence_nbt 6 - -valence_ident + +valence_ident 8 - -valence_generated + +valence_generated 7->8 - - + + 10 - -valence_text + +valence_text 7->10 - - + + 8->4 - - + + 8->6 - - + + 10->5 - - + + 10->6 - - + + 9 - -valence_build_utils + +valence_build_utils 13 - -valence_anvil + +valence_anvil 13->2 - - + + 14 - -valence_boss_bar + +valence_boss_bar 14->2 - - + + 15 - -valence_command - - - -15->2 - - + +valence_chat 16 - -valence_inventory + +valence_lang - - -16->2 - - + + +15->16 + + 17 - -valence_lang + +valence_player_list + + + +15->17 + + + + + +17->2 + + 18 - -valence_network + +valence_command - -18->2 - - - - -18->17 - - +18->2 + + 19 - -valence_player_list + +valence_inventory 19->2 - - + + 20 - -valence_scoreboard + +valence_network 20->2 - - + + + + + +20->16 + + 21 - -valence_spatial + +valence_scoreboard + + + +21->2 + + 22 - -valence_weather - - - -22->2 - - + +valence_spatial 23 - -valence_world_border + +valence_weather - + 23->2 - - + + 24 - -dump_schedule + +valence_world_border + + + +24->2 + + 25 - -valence - - - -24->25 - - + +dump_schedule - - -25->1 - - + + +26 + +valence - + -25->13 - - +25->26 + + - + -25->14 - - +26->1 + + - + -25->15 - - +26->13 + + - + -25->16 - - +26->14 + + - + -25->18 - - +26->15 + + - + -25->19 - - +26->18 + + - + -25->20 - - +26->19 + + - + -25->22 - - +26->20 + + - + -25->23 - - +26->21 + + - - -26 - -packet_inspector - - + -26->7 - - +26->23 + + + + + +26->24 + + 27 - -playground + +packet_inspector - - -27->25 - - + + +27->7 + + 28 - -stresser - - - -28->7 - - + +playground + + + +28->26 + + + + + +29 + +stresser + + + +29->7 + + diff --git a/crates/valence_chat/Cargo.toml b/crates/valence_chat/Cargo.toml new file mode 100644 index 000000000..cfeaa3903 --- /dev/null +++ b/crates/valence_chat/Cargo.toml @@ -0,0 +1,26 @@ +[package] +name = "valence_chat" +version.workspace = true +edition.workspace = true + +[features] +secure = ["dep:rsa", "dep:rustc-hash", "dep:sha1", "dep:sha2"] + +[dependencies] +anyhow.workspace = true +bevy_app.workspace = true +bevy_ecs.workspace = true +rsa = { workspace = true, optional = true } +rustc-hash = { workspace = true, optional = true } +sha1 = { workspace = true, features = ["oid"], optional = true } +sha2 = { workspace = true, features = ["oid"], optional = true } +tracing.workspace = true +uuid.workspace = true +valence_lang.workspace = true +valence_nbt.workspace = true +valence_player_list.workspace = true +valence_protocol.workspace = true +valence_registry.workspace = true +valence_server.workspace = true +valence_server_common.workspace = true +valence_text.workspace = true diff --git a/crates/valence_chat/README.md b/crates/valence_chat/README.md new file mode 100644 index 000000000..c814f8553 --- /dev/null +++ b/crates/valence_chat/README.md @@ -0,0 +1,8 @@ +# valence_chat + +Provides support for cryptographically verified chat messaging on the server. + +This crate contains the secure chat plugin as well as chat types and the chat type registry. Minecraft's default chat types are added to the registry by default. Chat types contain information about how chat is styled, such as the chat color. + + +This crate also contains the `yggdrasil_session_pubkey.der` file which is an encoded format of Mojang's public key. This is necessary to verify the integrity of our clients' public session key, which is used for validating chat messages. In reality Mojang's key should never change in order to maintain backwards compatibility with older versions, but if it does it can be extracted from any minecraft server jar. \ No newline at end of file diff --git a/crates/valence_chat/src/command.rs b/crates/valence_chat/src/command.rs new file mode 100644 index 000000000..60625f52a --- /dev/null +++ b/crates/valence_chat/src/command.rs @@ -0,0 +1,26 @@ +// TODO: Eventually this should be moved to a `valence_commands` crate + +use bevy_app::prelude::*; +use bevy_ecs::prelude::*; + +pub(super) fn build(app: &mut App) { + app.add_event::(); +} + +#[derive(Event, Clone, Debug)] +pub struct CommandExecutionEvent { + pub client: Entity, + pub command: Box, + pub timestamp: u64, + #[cfg(feature = "secure")] + pub salt: u64, + #[cfg(feature = "secure")] + pub argument_signatures: Vec, +} + +#[cfg(feature = "secure")] +#[derive(Clone, Debug)] +pub struct ArgumentSignature { + pub name: String, + pub signature: Box<[u8; 256]>, +} diff --git a/crates/valence_chat/src/lib.rs b/crates/valence_chat/src/lib.rs new file mode 100644 index 000000000..3327a9405 --- /dev/null +++ b/crates/valence_chat/src/lib.rs @@ -0,0 +1,838 @@ +#![doc = include_str!("../README.md")] +#![deny( + rustdoc::broken_intra_doc_links, + rustdoc::private_intra_doc_links, + rustdoc::missing_crate_level_docs, + rustdoc::invalid_codeblock_attributes, + rustdoc::invalid_rust_codeblocks, + rustdoc::bare_urls, + rustdoc::invalid_html_tags +)] +#![warn( + trivial_casts, + trivial_numeric_casts, + unused_lifetimes, + unused_import_braces, + unreachable_pub, + clippy::dbg_macro +)] + +pub mod command; +pub mod message; + +#[cfg(feature = "secure")] +use std::collections::VecDeque; +use std::time::SystemTime; + +use bevy_app::prelude::*; +use bevy_ecs::prelude::*; +use tracing::warn; +use valence_lang::keys::{CHAT_DISABLED_OPTIONS, DISCONNECT_GENERIC_REASON}; +use valence_protocol::packets::play::client_settings_c2s::ChatMode; +use valence_protocol::packets::play::{ChatMessageC2s, CommandExecutionC2s}; +use valence_registry::chat_type::ChatTypePlugin; +use valence_server::client::{Client, SpawnClientsSet}; +use valence_server::client_settings::ClientSettings; +use valence_server::event_loop::{EventLoopPreUpdate, PacketEvent}; +use valence_server::protocol::packets::play::chat_message_s2c::{ + MessageFilterType, MessageSignature, +}; +use valence_server::protocol::packets::play::{ChatMessageS2c, ProfilelessChatMessageS2c}; +use valence_server::protocol::WritePacket; +use valence_text::{Color, Text}; +#[cfg(feature = "secure")] +use { + crate::command::ArgumentSignature, + crate::message::ChatMessageType, + anyhow::bail, + rsa::pkcs1v15::Pkcs1v15Sign, + rsa::pkcs8::DecodePublicKey, + rsa::RsaPublicKey, + rustc_hash::{FxHashMap, FxHashSet}, + sha1::{Digest, Sha1}, + sha2::Sha256, + uuid::Uuid, + valence_lang::keys::{ + CHAT_DISABLED_CHAIN_BROKEN, CHAT_DISABLED_EXPIRED_PROFILE_KEY, + CHAT_DISABLED_MISSING_PROFILE_KEY, MULTIPLAYER_DISCONNECT_CHAT_VALIDATION_FAILED, + MULTIPLAYER_DISCONNECT_EXPIRED_PUBLIC_KEY, + MULTIPLAYER_DISCONNECT_INVALID_PUBLIC_KEY_SIGNATURE, + MULTIPLAYER_DISCONNECT_OUT_OF_ORDER_CHAT, MULTIPLAYER_DISCONNECT_TOO_MANY_PENDING_CHATS, + MULTIPLAYER_DISCONNECT_UNSIGNED_CHAT, + }, + valence_player_list::{ChatSession, PlayerListEntry}, + valence_server::client::{DisconnectClient, Username}, + valence_server::protocol::packets::play::{MessageAcknowledgmentC2s, PlayerSessionC2s}, + valence_server_common::UniqueId, + valence_text::IntoText, +}; + +use crate::command::CommandExecutionEvent; +use crate::message::{ChatMessageEvent, SendMessage}; + +#[cfg(feature = "secure")] +const MOJANG_KEY_DATA: &[u8] = include_bytes!("../yggdrasil_session_pubkey.der"); + +pub struct ChatPlugin; + +impl Plugin for ChatPlugin { + fn build(&self, app: &mut bevy_app::App) { + app.add_plugins(ChatTypePlugin) + .add_systems(PreUpdate, init_chat_states.after(SpawnClientsSet)) + .add_systems( + EventLoopPreUpdate, + ( + #[cfg(feature = "secure")] + handle_acknowledgement_packets, + #[cfg(not(feature = "secure"))] + handle_message_packets, + handle_command_packets, + ), + ); + + #[cfg(feature = "secure")] + { + let mojang_pub_key = RsaPublicKey::from_public_key_der(MOJANG_KEY_DATA) + .expect("Error creating Mojang public key"); + + app.insert_resource(MojangServicesState::new(mojang_pub_key)) + .add_systems( + EventLoopPreUpdate, + (handle_session_packets, handle_message_packets).chain(), + ); + } + + command::build(app); + message::build(app); + } +} + +#[cfg(feature = "secure")] +#[derive(Resource)] +struct MojangServicesState { + public_key: RsaPublicKey, +} + +#[cfg(feature = "secure")] +impl MojangServicesState { + fn new(public_key: RsaPublicKey) -> Self { + Self { public_key } + } +} + +#[cfg(feature = "secure")] +#[derive(Debug, Component)] +pub struct ChatState { + pub last_message_timestamp: u64, + validator: AcknowledgementValidator, + chain: MessageChain, + signature_storage: MessageSignatureStorage, +} + +#[cfg(feature = "secure")] +impl Default for ChatState { + fn default() -> Self { + Self { + last_message_timestamp: SystemTime::now() + .duration_since(SystemTime::UNIX_EPOCH) + .expect("Unable to get Unix time") + .as_millis() as u64, + validator: AcknowledgementValidator::new(), + chain: MessageChain::new(), + signature_storage: MessageSignatureStorage::new(), + } + } +} + +#[cfg(feature = "secure")] +impl ChatState { + pub fn send_chat_message( + &mut self, + client: &mut Client, + username: &Username, + message: &ChatMessageEvent, + ) -> anyhow::Result<()> { + match &message.message_type { + ChatMessageType::Signed { + salt, + signature, + message_index, + last_seen, + sender, + } => { + // Create a list of messages that have been seen by the client. + let previous = last_seen + .iter() + .map(|sig| match self.signature_storage.index_of(sig) { + Some(index) => MessageSignature::ByIndex(index), + None => MessageSignature::BySignature(sig), + }) + .collect::>(); + + client.write_packet(&ChatMessageS2c { + sender: *sender, + index: (*message_index).into(), + message_signature: Some((*signature).as_ref()), + message: message.message.as_ref().into(), + timestamp: message.timestamp, + salt: *salt, + previous_messages: previous, + unsigned_content: None, + filter_type: MessageFilterType::PassThrough, + chat_type: 0.into(), // TODO: Make chat type for player messages selectable + network_name: Text::from(username.0.clone()).into(), + network_target_name: None, + }); + // Add pending acknowledgement. + self.add_pending(last_seen, signature); + if self.validator.message_count() > 4096 { + warn!("User has too many pending chats `{}`", username.0); + bail!(MULTIPLAYER_DISCONNECT_TOO_MANY_PENDING_CHATS); + } + } + ChatMessageType::Unsigned => client.write_packet(&ProfilelessChatMessageS2c { + message: Text::from(message.message.to_string()).into(), + chat_type: 0.into(), + chat_type_name: Text::from(username.0.clone()).into(), + target_name: None, + }), + } + Ok(()) + } + + /// Updates the chat state's previously seen signatures with a new one + /// `signature`. + fn add_pending(&mut self, last_seen: &[[u8; 256]], signature: &[u8; 256]) { + self.signature_storage.add(last_seen, signature); + self.validator.add_pending(signature); + } +} + +#[cfg(feature = "secure")] +#[derive(Clone, Debug)] +struct AcknowledgementValidator { + messages: Vec>, + last_signature: Option<[u8; 256]>, +} + +#[cfg(feature = "secure")] +impl AcknowledgementValidator { + fn new() -> Self { + Self { + messages: vec![None; 20], + last_signature: None, + } + } + + /// Add a message pending acknowledgement via its `signature`. + fn add_pending(&mut self, signature: &[u8; 256]) { + // Attempting to add the last signature again. + if matches!(&self.last_signature, Some(last_sig) if signature == last_sig.as_ref()) { + return; + } + self.messages.push(Some(AcknowledgedMessage { + signature: *signature, + pending: true, + })); + self.last_signature = Some(*signature); + } + + /// Removes message signatures from the validator before an `index`. + /// + /// Message signatures will only be removed if the result leaves the + /// validator with at least 20 messages. Returns `true` if messages are + /// removed and `false` if they are not. + fn remove_until(&mut self, index: i32) -> bool { + // Ensure that there will still be 20 messages in the array. + if index >= 0 && index <= (self.messages.len() - 20) as i32 { + self.messages.drain(0..index as usize); + debug_assert!( + self.messages.len() >= 20, + "Message validator 'messages' shrunk!" + ); + return true; + } + false + } + + /// Validate a set of `acknowledgements` offset by `message_index`. + /// + /// Returns a [`VecDeque`] of acknowledged message signatures if the + /// `acknowledgements` are valid and `None` if they are invalid. + fn validate( + &mut self, + acknowledgements: &[u8; 3], + message_index: i32, + ) -> anyhow::Result> { + if !self.remove_until(message_index) { + bail!("Invalid message index"); + } + + let acknowledged_count = { + let mut sum = 0u32; + for byte in acknowledgements { + sum += byte.count_ones(); + } + sum as usize + }; + + if acknowledged_count > 20 { + bail!("Too many message acknowledgements, protocol error?"); + } + + let mut list = Vec::with_capacity(acknowledged_count); + for i in 0..20 { + let acknowledgement = acknowledgements[i >> 3] & (0b1 << (i % 8)) != 0; + let acknowledged_message = &mut self.messages[i]; + // Client has acknowledged the i-th message + if acknowledgement { + // The validator has the i-th message + if let Some(m) = acknowledged_message { + m.pending = false; + list.push(m.signature); + } else { + // Client has acknowledged a non-existing message + bail!("Client has acknowledged a non-existing message"); + } + } else { + // Client has not acknowledged the i-th message + if matches!(acknowledged_message, Some(m) if !m.pending) { + // The validator has an i-th message that has been validated but the client + // claims that it hasn't been validated yet + bail!( + "The validator has an i-th message that has been validated but the client \ + claims that it hasn't been validated yet" + ); + } + // Honestly not entirely sure why this is done + *acknowledged_message = None; + } + } + Ok(list) + } + + /// The number of pending messages in the validator. + fn message_count(&self) -> usize { + self.messages.len() + } +} + +#[cfg(feature = "secure")] +#[derive(Clone, Debug)] +struct AcknowledgedMessage { + signature: [u8; 256], + pending: bool, +} + +#[cfg(feature = "secure")] +#[derive(Clone, Default, Debug)] +struct MessageChain { + link: Option, +} + +#[cfg(feature = "secure")] +impl MessageChain { + fn new() -> Self { + Self::default() + } + + fn next_link(&mut self) -> Option { + match &mut self.link { + None => self.link, + Some(current) => { + let temp = *current; + current.index = current.index.wrapping_add(1); + Some(temp) + } + } + } +} + +#[cfg(feature = "secure")] +#[derive(Copy, Clone, Debug)] +struct MessageLink { + index: i32, + sender: Uuid, + session_id: Uuid, +} + +#[cfg(feature = "secure")] +impl MessageLink { + fn update_hash(&self, hasher: &mut impl Digest) { + hasher.update(self.sender.into_bytes()); + hasher.update(self.session_id.into_bytes()); + hasher.update(self.index.to_be_bytes()); + } +} + +#[cfg(feature = "secure")] +#[derive(Clone, Debug)] +struct MessageSignatureStorage { + signatures: [Option<[u8; 256]>; 128], + indices: FxHashMap<[u8; 256], i32>, +} + +#[cfg(feature = "secure")] +impl Default for MessageSignatureStorage { + fn default() -> Self { + Self { + signatures: [None; 128], + indices: FxHashMap::default(), + } + } +} + +#[cfg(feature = "secure")] +impl MessageSignatureStorage { + fn new() -> Self { + Self::default() + } + + /// Get the index of the `signature` in the storage if it exists. + fn index_of(&self, signature: &[u8; 256]) -> Option { + self.indices.get(signature).copied() + } + + /// Update the signature storage according to `last_seen` while adding + /// `signature` to the storage. + /// + /// Warning: this consumes `last_seen`. + fn add(&mut self, last_seen: &[[u8; 256]], signature: &[u8; 256]) { + let mut sig_set = FxHashSet::default(); + + last_seen + .iter() + .chain(std::iter::once(signature)) + .for_each(|sig| { + sig_set.insert(*sig); + }); + + let mut retained_sigs = VecDeque::new(); + let mut index = 0usize; + let mut seen_iter = last_seen.iter().chain(std::iter::once(signature)).rev(); + + while let Some(seen_sig) = seen_iter.next().or(retained_sigs.pop_front().as_ref()) { + if index > 127 { + return; + } + // Remove the old signature + let previous_sig = self.signatures[index]; + // Add the new signature + self.signatures[index] = Some(*seen_sig); + self.indices.insert(*seen_sig, index as i32); + // Reinsert old signature if it is not already in `last_seen` + if let Some(data) = previous_sig { + // Remove the index for the old sig + self.indices.remove(&data); + // If the old sig is still unique, reinsert + if sig_set.insert(data) { + retained_sigs.push_back(data); + } + } + index += 1; + } + } +} + +#[cfg(feature = "secure")] +fn init_chat_states(clients: Query>, mut commands: Commands) { + for entity in clients.iter() { + commands.entity(entity).insert(ChatState::default()); + } +} + +#[cfg(feature = "secure")] +fn handle_session_packets( + services_state: Res, + mut clients: Query<(&UniqueId, &Username, &mut ChatState), With>, + mut packets: EventReader, + mut commands: Commands, +) { + for packet in packets.iter() { + let Some(session) = packet.decode::() else { + continue; + }; + + let Ok((uuid, username, mut state)) = clients.get_mut(packet.client) else { + warn!("Unable to find client in player list for session"); + continue; + }; + + // Verify that the session key has not expired. + if SystemTime::now() + .duration_since(SystemTime::UNIX_EPOCH) + .expect("Unable to get Unix time") + .as_millis() + >= session.0.expires_at as u128 + { + warn!("Failed to validate profile key: expired public key"); + commands.add(DisconnectClient { + client: packet.client, + reason: Text::translate(MULTIPLAYER_DISCONNECT_EXPIRED_PUBLIC_KEY, []), + }); + continue; + } + + // Hash the session data using the SHA-1 algorithm. + let mut hasher = Sha1::new(); + hasher.update(uuid.0.into_bytes()); + hasher.update(session.0.expires_at.to_be_bytes()); + hasher.update(&session.0.public_key_data); + let hash = hasher.finalize(); + + // Verify the session data using Mojang's public key and the hashed session data + // against the message signature. + if services_state + .public_key + .verify( + Pkcs1v15Sign::new::(), // PaddingScheme::new_pkcs1v15_sign::(), + &hash, + session.0.key_signature.as_ref(), + ) + .is_err() + { + warn!("Failed to validate profile key: invalid public key signature"); + commands.add(DisconnectClient { + client: packet.client, + reason: Text::translate(MULTIPLAYER_DISCONNECT_INVALID_PUBLIC_KEY_SIGNATURE, []), + }); + } + + // Decode the player's session public key from the data. + if let Ok(public_key) = + RsaPublicKey::from_public_key_der(session.0.public_key_data.as_ref()) + { + // Update the player's chat state data with the new player session data. + state.chain.link = Some(MessageLink { + index: 0, + sender: uuid.0, + session_id: session.0.session_id, + }); + + // Add the chat session data to player. + // The player list will then send this new session data to the other clients. + commands.entity(packet.client).insert(ChatSession { + public_key, + session_data: session.0.into_owned(), + }); + } else { + // This shouldn't happen considering that it is highly unlikely that Mojang + // would provide the client with a malformed key. By this point the + // key signature has been verified. + warn!("Received malformed profile key data from '{}'", username.0); + commands.add(DisconnectClient { + client: packet.client, + reason: Text::translate( + DISCONNECT_GENERIC_REASON, + ["Malformed profile key data".color(Color::RED)], + ), + }); + } + } +} + +#[cfg(feature = "secure")] +fn handle_acknowledgement_packets( + mut clients: Query<(&Username, &mut ChatState)>, + mut packets: EventReader, + mut commands: Commands, +) { + for packet in packets.iter() { + let Some(acknowledgement) = packet.decode::() else { + continue; + }; + + let Ok((username, mut state)) = clients.get_mut(packet.client) else { + warn!("Unable to find client for acknowledgement"); + continue; + }; + + if !state + .validator + .remove_until(acknowledgement.message_index.0) + { + warn!( + "Failed to validate message acknowledgement from '{:?}'", + username.0 + ); + commands.add(DisconnectClient { + client: packet.client, + reason: Text::translate(MULTIPLAYER_DISCONNECT_CHAT_VALIDATION_FAILED, []), + }); + continue; + } + } +} + +#[cfg(feature = "secure")] +fn handle_message_packets( + mut clients: Query< + (&mut ChatState, &mut Client, &Username, &ClientSettings), + With, + >, + sessions: Query<&ChatSession, With>, + mut packets: EventReader, + mut message_events: EventWriter, + mut commands: Commands, +) { + for packet in packets.iter() { + let Some(message) = packet.decode::() else { + continue; + }; + + let Ok((mut state, mut client, username, settings)) = clients.get_mut(packet.client) else { + warn!("Unable to find client for message '{:?}'", message); + continue; + }; + + // Ensure that the client isn't sending messages while their chat is hidden. + if settings.chat_mode == ChatMode::Hidden { + client.send_game_message(Text::translate(CHAT_DISABLED_OPTIONS, []).color(Color::RED)); + continue; + } + + // Ensure we are receiving chat messages in order. + if message.timestamp < state.last_message_timestamp { + warn!( + "{:?} sent out-of-order chat: '{:?}'", + username.0, message.message + ); + commands.add(DisconnectClient { + client: packet.client, + reason: Text::translate(MULTIPLAYER_DISCONNECT_OUT_OF_ORDER_CHAT, []), + }); + continue; + } + + state.last_message_timestamp = message.timestamp; + + // Check if the message is signed + let Some(message_signature) = message.signature else { + // TODO: Cleanup + warn!("Received unsigned chat message from `{}`", username.0); + /*commands.add(DisconnectClient { + client: packet.client, + reason: Text::translate(MULTIPLAYER_DISCONNECT_UNSIGNED_CHAT, []) + });*/ + message_events.send(ChatMessageEvent { + client: packet.client, + message: message.message.0.into(), + timestamp: message.timestamp, + message_type: message::ChatMessageType::Unsigned, + }); + continue; + }; + + // Validate the message acknowledgements. + let last_seen = match state + .validator + .validate(&message.acknowledgement.0, message.message_index.0) + { + Err(error) => { + warn!( + "Failed to validate acknowledgements from `{}`: {}", + username.0, error + ); + commands.add(DisconnectClient { + client: packet.client, + reason: Text::translate(MULTIPLAYER_DISCONNECT_CHAT_VALIDATION_FAILED, []), + }); + continue; + } + Ok(last_seen) => last_seen, + }; + + let Some(link) = &state.chain.next_link() else { + client.send_game_message( + Text::translate(CHAT_DISABLED_CHAIN_BROKEN, []).color(Color::RED), + ); + continue; + }; + + let Ok(chat_session) = sessions.get(packet.client) else { + warn!("Player `{}` doesn't have a chat session", username.0); + commands.add(DisconnectClient { + client: packet.client, + reason: Text::translate(CHAT_DISABLED_MISSING_PROFILE_KEY, []), + }); + continue; + }; + + // Verify that the player's session has not expired. + if SystemTime::now() + .duration_since(SystemTime::UNIX_EPOCH) + .expect("Unable to get Unix time") + .as_millis() + >= chat_session.session_data.expires_at as u128 + { + warn!("Player `{}` has an expired chat session", username.0); + commands.add(DisconnectClient { + client: packet.client, + reason: Text::translate(CHAT_DISABLED_EXPIRED_PROFILE_KEY, []), + }); + continue; + } + + // Create the hash digest used to verify the chat message. + let mut hasher = Sha256::new_with_prefix([0u8, 0, 0, 1]); + + // Update the hash with the player's message chain state. + link.update_hash(&mut hasher); + + // Update the hash with the message contents. + hasher.update(message.salt.to_be_bytes()); + hasher.update((message.timestamp / 1000).to_be_bytes()); + let bytes = message.message.as_bytes(); + hasher.update((bytes.len() as u32).to_be_bytes()); + hasher.update(bytes); + hasher.update((last_seen.len() as u32).to_be_bytes()); + for sig in last_seen.iter() { + hasher.update(sig); + } + let hashed = hasher.finalize(); + + // Verify the chat message using the player's session public key and hashed data + // against the message signature. + if chat_session + .public_key + .verify( + Pkcs1v15Sign::new::(), // PaddingScheme::new_pkcs1v15_sign::(), + &hashed, + message_signature.as_ref(), + ) + .is_err() + { + warn!("Failed to verify chat message from `{}`", username.0); + commands.add(DisconnectClient { + client: packet.client, + reason: Text::translate(MULTIPLAYER_DISCONNECT_UNSIGNED_CHAT, []), + }); + continue; + } + + message_events.send(ChatMessageEvent { + client: packet.client, + message: message.message.0.into(), + timestamp: message.timestamp, + message_type: message::ChatMessageType::Signed { + salt: message.salt, + signature: (*message_signature).into(), + message_index: link.index, + sender: link.sender, + last_seen, + }, + }); + } +} + +#[cfg(not(feature = "secure"))] +fn handle_message_packets( + mut clients: Query<(&mut Client, &ClientSettings)>, + mut packets: EventReader, + mut message_events: EventWriter, +) { + for packet in packets.iter() { + let Some(message) = packet.decode::() else { + continue; + }; + + let Ok((mut client, settings)) = clients.get_mut(packet.client) else { + warn!("Unable to find client for message '{:?}'", message); + continue; + }; + + // Ensure that the client isn't sending messages while their chat is hidden. + if settings.chat_mode == ChatMode::Hidden { + client.send_game_message(Text::translate(CHAT_DISABLED_OPTIONS, []).color(Color::RED)); + continue; + } + + message_events.send(ChatMessageEvent { + client: packet.client, + message: message.message.into(), + timestamp: message.timestamp, + }) + } +} + +fn handle_command_packets( + mut clients: Query< + (&mut ChatState, &mut Client, &Username, &ClientSettings), + With, + >, + _sessions: Query<&ChatSession, With>, + mut packets: EventReader, + mut command_events: EventWriter, + mut commands: Commands, +) { + for packet in packets.iter() { + let Some(command) = packet.decode::() else { + continue; + }; + + let Ok((mut state, mut client, username, settings)) = clients.get_mut(packet.client) else { + warn!("Unable to find client for message '{:?}'", command); + continue; + }; + + // Ensure that the client isn't sending messages while their chat is hidden. + if settings.chat_mode == ChatMode::Hidden { + client.send_game_message(Text::translate(CHAT_DISABLED_OPTIONS, []).color(Color::RED)); + continue; + } + + // Ensure we are receiving chat messages in order. + if command.timestamp < state.last_message_timestamp { + warn!( + "{:?} sent out-of-order chat: '{:?}'", + username.0, command.command + ); + commands.add(DisconnectClient { + client: packet.client, + reason: Text::translate(MULTIPLAYER_DISCONNECT_OUT_OF_ORDER_CHAT, []), + }); + continue; + } + + state.last_message_timestamp = command.timestamp; + + // Validate the message acknowledgements. + let _last_seen = match state + .validator + .validate(&command.acknowledgement.0, command.message_index.0) + { + Err(error) => { + warn!( + "Failed to validate acknowledgements from `{}`: {}", + username.0, error + ); + commands.add(DisconnectClient { + client: packet.client, + reason: Text::translate(MULTIPLAYER_DISCONNECT_CHAT_VALIDATION_FAILED, []), + }); + continue; + } + Ok(last_seen) => last_seen, + }; + + // TODO: Implement proper argument verification + // This process will involve both `_sessions` and `_last_seen` + + warn!("{:?}", command); + command_events.send(CommandExecutionEvent { + client: packet.client, + command: command.command.0.into(), + timestamp: command.timestamp, + salt: command.salt, + argument_signatures: command + .argument_signatures + .0 + .iter() + .map(|sig| ArgumentSignature { + name: sig.argument_name.0.into(), + signature: (*sig.signature).into(), + }) + .collect(), + }) + } +} diff --git a/crates/valence_chat/src/message.rs b/crates/valence_chat/src/message.rs new file mode 100644 index 000000000..5004b5294 --- /dev/null +++ b/crates/valence_chat/src/message.rs @@ -0,0 +1,56 @@ +use bevy_app::prelude::*; +use bevy_ecs::prelude::*; +#[cfg(feature = "secure")] +use uuid::Uuid; +use valence_protocol::encode::WritePacket; +use valence_protocol::packets::play::GameMessageS2c; +use valence_protocol::text::IntoText; + +pub(super) fn build(app: &mut App) { + app.add_event::(); +} + +pub trait SendMessage { + /// Sends a system message visible in the chat. + fn send_game_message<'a>(&mut self, msg: impl IntoText<'a>); + /// Displays a message in the player's action bar (text above the hotbar). + fn send_action_bar_message<'a>(&mut self, msg: impl IntoText<'a>); +} + +impl SendMessage for T { + fn send_game_message<'a>(&mut self, msg: impl IntoText<'a>) { + self.write_packet(&GameMessageS2c { + chat: msg.into_cow_text(), + overlay: false, + }); + } + + fn send_action_bar_message<'a>(&mut self, msg: impl IntoText<'a>) { + self.write_packet(&GameMessageS2c { + chat: msg.into_cow_text(), + overlay: true, + }); + } +} + +#[derive(Event, Clone, Debug)] +pub struct ChatMessageEvent { + pub client: Entity, + pub message: Box, + pub timestamp: u64, + #[cfg(feature = "secure")] + pub message_type: ChatMessageType, +} + +#[cfg(feature = "secure")] +#[derive(Clone, Debug)] +pub enum ChatMessageType { + Signed { + salt: u64, + signature: Box<[u8; 256]>, + message_index: i32, + sender: Uuid, + last_seen: Vec<[u8; 256]>, + }, + Unsigned, +} diff --git a/crates/valence_chat/yggdrasil_session_pubkey.der b/crates/valence_chat/yggdrasil_session_pubkey.der new file mode 100644 index 000000000..9c79a3aa4 Binary files /dev/null and b/crates/valence_chat/yggdrasil_session_pubkey.der differ diff --git a/crates/valence_command/src/manager.rs b/crates/valence_command/src/manager.rs index b352abbbe..53cba0d0a 100644 --- a/crates/valence_command/src/manager.rs +++ b/crates/valence_command/src/manager.rs @@ -350,8 +350,9 @@ fn parse_command_args( let pre_input = input.clone().into_inner(); let valid = parser(&mut input); if valid { + // If input.len() > pre_input.len() the parser replaced the input let Some(arg) = pre_input - .get(..input.len() - pre_input.len()) + .get(..pre_input.len().wrapping_sub(input.len())) .map(|s| s.to_string()) else { panic!( diff --git a/crates/valence_command/src/parsers.rs b/crates/valence_command/src/parsers.rs index 956b70ee0..2407f296f 100644 --- a/crates/valence_command/src/parsers.rs +++ b/crates/valence_command/src/parsers.rs @@ -8,6 +8,7 @@ pub mod entity_anchor; pub mod entity_selector; pub mod gamemode; pub mod inventory_slot; +pub mod message; pub mod numbers; pub mod rotation; pub mod score_holder; @@ -24,6 +25,7 @@ pub use column_pos::ColumnPos; pub use entity_anchor::EntityAnchor; pub use entity_selector::EntitySelector; pub use inventory_slot::InventorySlot; +pub use message::{Message, MessageSelector}; pub use rotation::Rotation; pub use score_holder::ScoreHolder; pub use strings::{GreedyString, QuotableString}; diff --git a/crates/valence_command/src/parsers/message.rs b/crates/valence_command/src/parsers/message.rs new file mode 100644 index 000000000..426473fce --- /dev/null +++ b/crates/valence_command/src/parsers/message.rs @@ -0,0 +1,105 @@ +use super::Parser; +use crate::parsers::{CommandArg, CommandArgParseError, EntitySelector, ParseInput}; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Message { + pub message: String, + pub selectors: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct MessageSelector { + pub start: u32, + pub end: u32, + pub selector: EntitySelector, +} + +impl CommandArg for Message { + fn parse_arg(input: &mut ParseInput) -> Result { + input.skip_whitespace(); + + let message = input.clone().into_inner().to_string(); + let mut selectors: Vec = Vec::new(); + + let mut i = 0u32; + while let Some(c) = input.peek() { + if c == '@' { + let start = i; + let length_before = input.len(); + let selector = EntitySelector::parse_arg(input)?; + i += length_before as u32 - input.len() as u32; + selectors.push(MessageSelector { + start, + end: i, + selector, + }); + } else { + i += 1; + input.advance(); + } + } + + Ok(Message { message, selectors }) + } + + fn display() -> Parser { + Parser::Message + } +} + +#[test] +fn test_message() { + use crate::parsers::entity_selector::EntitySelectors; + + let mut input = ParseInput::new("Hello @e"); + assert_eq!( + Message::parse_arg(&mut input).unwrap(), + Message { + message: "Hello @e".to_string(), + selectors: vec![MessageSelector { + start: 6, + end: 8, + selector: EntitySelector::SimpleSelector(EntitySelectors::AllEntities) + }] + } + ); + assert!(input.is_done()); + + let mut input = ParseInput::new("@p say hi to @a"); + assert_eq!( + Message::parse_arg(&mut input).unwrap(), + Message { + message: "@p say hi to @a".to_string(), + selectors: vec![ + MessageSelector { + start: 0, + end: 2, + selector: EntitySelector::SimpleSelector(EntitySelectors::NearestPlayer) + }, + MessageSelector { + start: 13, + end: 15, + selector: EntitySelector::SimpleSelector(EntitySelectors::AllPlayers) + }, + ] + } + ); + assert!(input.is_done()); + + let mut input = ParseInput::new("say hi to nearby players @p[distance=..5]"); + assert_eq!( + Message::parse_arg(&mut input).unwrap(), + Message { + message: "say hi to nearby players @p[distance=..5]".to_string(), + selectors: vec![MessageSelector { + start: 25, + end: 41, + selector: EntitySelector::ComplexSelector( + EntitySelectors::NearestPlayer, + "distance=..5".to_string() + ) + },] + } + ); + assert!(input.is_done()); +} diff --git a/crates/valence_player_list/Cargo.toml b/crates/valence_player_list/Cargo.toml index 176c94389..2fe9d3784 100644 --- a/crates/valence_player_list/Cargo.toml +++ b/crates/valence_player_list/Cargo.toml @@ -13,4 +13,5 @@ bevy_app.workspace = true bevy_ecs.workspace = true bitfield-struct.workspace = true derive_more.workspace = true +rsa.workspace = true valence_server.workspace = true diff --git a/crates/valence_player_list/src/lib.rs b/crates/valence_player_list/src/lib.rs index db42f68b5..f30abc179 100644 --- a/crates/valence_player_list/src/lib.rs +++ b/crates/valence_player_list/src/lib.rs @@ -23,10 +23,12 @@ use std::borrow::Cow; use bevy_app::prelude::*; use bevy_ecs::prelude::*; use derive_more::{Deref, DerefMut}; +use rsa::RsaPublicKey; use valence_server::client::{Client, Properties, Username}; use valence_server::keepalive::Ping; use valence_server::layer::UpdateLayersPreClientSet; use valence_server::protocol::encode::PacketWriter; +use valence_server::protocol::packets::play::player_session_c2s::PlayerSessionData; use valence_server::protocol::packets::play::{ player_list_s2c as packet, PlayerListHeaderS2c, PlayerListS2c, PlayerRemoveS2c, }; @@ -154,6 +156,14 @@ impl Default for Listed { } } +/// Contains information for the player's chat message verification. +/// Not required. +#[derive(Component, Clone, Debug)] +pub struct ChatSession { + pub public_key: RsaPublicKey, + pub session_data: PlayerSessionData, +} + fn update_header_footer(player_list: ResMut, server: Res) { if player_list.changed_header_or_footer { let player_list = player_list.into_inner(); @@ -200,6 +210,7 @@ fn init_player_list_for_clients( &Ping, &DisplayName, &Listed, + Option<&ChatSession>, ), With, >, @@ -211,17 +222,27 @@ fn init_player_list_for_clients( .with_update_game_mode(true) .with_update_listed(true) .with_update_latency(true) - .with_update_display_name(true); + .with_update_display_name(true) + .with_initialize_chat(true); let entries: Vec<_> = entries .iter() .map( - |(uuid, username, props, game_mode, ping, display_name, listed)| { + |( + uuid, + username, + props, + game_mode, + ping, + display_name, + listed, + chat_session, + )| { packet::PlayerListEntry { player_uuid: uuid.0, username: &username.0, properties: Cow::Borrowed(&props.0), - chat_data: None, + chat_data: chat_session.map(|s| s.session_data.clone().into()), listed: listed.0, ping: ping.0, game_mode: *game_mode, @@ -286,6 +307,7 @@ fn update_entries( Ref, Ref, Ref, + Option>, ), ( With, @@ -297,6 +319,7 @@ fn update_entries( Changed, Changed, Changed, + Changed, )>, ), >, @@ -310,7 +333,7 @@ fn update_entries( server.compression_threshold(), ); - for (uuid, username, props, game_mode, ping, display_name, listed) in &entries { + for (uuid, username, props, game_mode, ping, display_name, listed, chat_session) in &entries { let mut actions = packet::PlayerListActions::new(); // Did a change occur that would force us to overwrite the entry? This also adds @@ -333,6 +356,10 @@ fn update_entries( if listed.0 { actions.set_update_listed(true); } + + if chat_session.is_some() { + actions.set_initialize_chat(true); + } } else { if game_mode.is_changed() { actions.set_update_game_mode(true); @@ -350,6 +377,10 @@ fn update_entries( actions.set_update_listed(true); } + if matches!(&chat_session, Some(session) if session.is_changed()) { + actions.set_initialize_chat(true); + } + debug_assert_ne!(u8::from(actions), 0); } @@ -357,7 +388,7 @@ fn update_entries( player_uuid: uuid.0, username: &username.0, properties: Cow::Borrowed(&props.0), - chat_data: None, + chat_data: chat_session.map(|s| s.session_data.clone().into()), listed: listed.0, ping: ping.0, game_mode: *game_mode, diff --git a/crates/valence_protocol/src/packets/play/chat_message_c2s.rs b/crates/valence_protocol/src/packets/play/chat_message_c2s.rs index d56d05139..e69273c9e 100644 --- a/crates/valence_protocol/src/packets/play/chat_message_c2s.rs +++ b/crates/valence_protocol/src/packets/play/chat_message_c2s.rs @@ -6,7 +6,7 @@ pub struct ChatMessageC2s<'a> { pub timestamp: u64, pub salt: u64, pub signature: Option<&'a [u8; 256]>, - pub message_count: VarInt, + pub message_index: VarInt, // This is a bitset of 20; each bit represents one // of the last 20 messages received and whether or not // the message was acknowledged by the client diff --git a/crates/valence_protocol/src/packets/play/chat_message_s2c.rs b/crates/valence_protocol/src/packets/play/chat_message_s2c.rs index 99ccf2578..92b3d4858 100644 --- a/crates/valence_protocol/src/packets/play/chat_message_s2c.rs +++ b/crates/valence_protocol/src/packets/play/chat_message_s2c.rs @@ -6,7 +6,7 @@ use valence_text::Text; use crate::{Bounded, Decode, Encode, Packet, VarInt}; -#[derive(Clone, PartialEq, Debug, Packet)] +#[derive(Clone, PartialEq, Debug, Encode, Decode, Packet)] pub struct ChatMessageS2c<'a> { pub sender: Uuid, pub index: VarInt, @@ -17,99 +17,32 @@ pub struct ChatMessageS2c<'a> { pub previous_messages: Vec>, pub unsigned_content: Option>, pub filter_type: MessageFilterType, - pub filter_type_bits: Option, pub chat_type: VarInt, pub network_name: Cow<'a, Text>, pub network_target_name: Option>, } -#[derive(Copy, Clone, PartialEq, Eq, Debug, Encode, Decode)] +#[derive(Clone, PartialEq, Eq, Debug, Encode, Decode)] pub enum MessageFilterType { PassThrough, FullyFiltered, - PartiallyFiltered, -} - -impl<'a> Encode for ChatMessageS2c<'a> { - fn encode(&self, mut w: impl Write) -> anyhow::Result<()> { - self.sender.encode(&mut w)?; - self.index.encode(&mut w)?; - self.message_signature.encode(&mut w)?; - self.message.encode(&mut w)?; - self.timestamp.encode(&mut w)?; - self.salt.encode(&mut w)?; - self.previous_messages.encode(&mut w)?; - self.unsigned_content.encode(&mut w)?; - self.filter_type.encode(&mut w)?; - - if self.filter_type == MessageFilterType::PartiallyFiltered { - match self.filter_type_bits { - // Filler data - None => 0u8.encode(&mut w)?, - Some(bits) => bits.encode(&mut w)?, - } - } - - self.chat_type.encode(&mut w)?; - self.network_name.encode(&mut w)?; - self.network_target_name.encode(&mut w)?; - - Ok(()) - } -} - -impl<'a> Decode<'a> for ChatMessageS2c<'a> { - fn decode(r: &mut &'a [u8]) -> anyhow::Result { - let sender = Uuid::decode(r)?; - let index = VarInt::decode(r)?; - let message_signature = Option::<&'a [u8; 256]>::decode(r)?; - let message = Decode::decode(r)?; - let time_stamp = u64::decode(r)?; - let salt = u64::decode(r)?; - let previous_messages = Vec::::decode(r)?; - let unsigned_content = Option::>::decode(r)?; - let filter_type = MessageFilterType::decode(r)?; - - let filter_type_bits = match filter_type { - MessageFilterType::PartiallyFiltered => Some(u8::decode(r)?), - _ => None, - }; - - let chat_type = VarInt::decode(r)?; - let network_name = >::decode(r)?; - let network_target_name = Option::>::decode(r)?; - - Ok(Self { - sender, - index, - message_signature, - message, - timestamp: time_stamp, - salt, - previous_messages, - unsigned_content, - filter_type, - filter_type_bits, - chat_type, - network_name, - network_target_name, - }) - } + PartiallyFiltered { mask: Vec }, } #[derive(Copy, Clone, PartialEq, Debug)] -pub struct MessageSignature<'a> { - pub message_id: i32, - pub signature: Option<&'a [u8; 256]>, +pub enum MessageSignature<'a> { + ByIndex(i32), + BySignature(&'a [u8; 256]), } impl<'a> Encode for MessageSignature<'a> { fn encode(&self, mut w: impl Write) -> anyhow::Result<()> { - VarInt(self.message_id + 1).encode(&mut w)?; - - match self.signature { - None => {} - Some(signature) => signature.encode(&mut w)?, + match self { + MessageSignature::ByIndex(index) => VarInt(index + 1).encode(&mut w)?, + MessageSignature::BySignature(signature) => { + VarInt(0).encode(&mut w)?; + signature.encode(&mut w)?; + } } Ok(()) @@ -118,17 +51,12 @@ impl<'a> Encode for MessageSignature<'a> { impl<'a> Decode<'a> for MessageSignature<'a> { fn decode(r: &mut &'a [u8]) -> anyhow::Result { - let message_id = VarInt::decode(r)?.0 - 1; // TODO: this can underflow. + let index = VarInt::decode(r)?.0.saturating_sub(1); - let signature = if message_id == -1 { - Some(<&[u8; 256]>::decode(r)?) + if index == -1 { + Ok(MessageSignature::BySignature(<&[u8; 256]>::decode(r)?)) } else { - None - }; - - Ok(Self { - message_id, - signature, - }) + Ok(MessageSignature::ByIndex(index)) + } } } diff --git a/crates/valence_protocol/src/packets/play/command_execution_c2s.rs b/crates/valence_protocol/src/packets/play/command_execution_c2s.rs index b09d4fe0f..332211215 100644 --- a/crates/valence_protocol/src/packets/play/command_execution_c2s.rs +++ b/crates/valence_protocol/src/packets/play/command_execution_c2s.rs @@ -5,8 +5,8 @@ pub struct CommandExecutionC2s<'a> { pub command: Bounded<&'a str, 256>, pub timestamp: u64, pub salt: u64, - pub argument_signatures: Vec>, - pub message_count: VarInt, + pub argument_signatures: Bounded>, 8>, + pub message_index: VarInt, //// This is a bitset of 20; each bit represents one //// of the last 20 messages received and whether or not //// the message was acknowledged by the client diff --git a/crates/valence_protocol/src/packets/play/message_acknowledgment_c2s.rs b/crates/valence_protocol/src/packets/play/message_acknowledgment_c2s.rs index f490751be..15a0d17d0 100644 --- a/crates/valence_protocol/src/packets/play/message_acknowledgment_c2s.rs +++ b/crates/valence_protocol/src/packets/play/message_acknowledgment_c2s.rs @@ -2,5 +2,5 @@ use crate::{Decode, Encode, Packet, VarInt}; #[derive(Copy, Clone, Debug, Encode, Decode, Packet)] pub struct MessageAcknowledgmentC2s { - pub message_count: VarInt, + pub message_index: VarInt, } diff --git a/crates/valence_protocol/src/packets/play/player_list_s2c.rs b/crates/valence_protocol/src/packets/play/player_list_s2c.rs index f07f930ee..85825903e 100644 --- a/crates/valence_protocol/src/packets/play/player_list_s2c.rs +++ b/crates/valence_protocol/src/packets/play/player_list_s2c.rs @@ -5,6 +5,7 @@ use bitfield_struct::bitfield; use uuid::Uuid; use valence_text::Text; +use crate::packets::play::player_session_c2s::PlayerSessionData; use crate::profile::Property; use crate::{Decode, Encode, GameMode, Packet, VarInt}; @@ -118,18 +119,9 @@ pub struct PlayerListEntry<'a> { pub player_uuid: Uuid, pub username: &'a str, pub properties: Cow<'a, [Property]>, - pub chat_data: Option>, + pub chat_data: Option>, pub listed: bool, pub ping: i32, pub game_mode: GameMode, pub display_name: Option>, } - -#[derive(Clone, PartialEq, Debug, Encode, Decode)] -pub struct ChatData<'a> { - pub session_id: Uuid, - /// Unix timestamp in milliseconds. - pub key_expiry_time: i64, - pub public_key: &'a [u8], - pub public_key_signature: &'a [u8], -} diff --git a/crates/valence_protocol/src/packets/play/player_session_c2s.rs b/crates/valence_protocol/src/packets/play/player_session_c2s.rs index 4b34135a0..3214c3219 100644 --- a/crates/valence_protocol/src/packets/play/player_session_c2s.rs +++ b/crates/valence_protocol/src/packets/play/player_session_c2s.rs @@ -1,12 +1,29 @@ +use std::borrow::Cow; + use uuid::Uuid; -use crate::{Bounded, Decode, Encode, Packet}; +use crate::{Decode, Encode, Packet}; + +#[derive(Clone, Debug, Encode, Decode, Packet)] +pub struct PlayerSessionC2s<'a>(pub Cow<'a, PlayerSessionData>); -#[derive(Copy, Clone, Debug, Encode, Decode, Packet)] -pub struct PlayerSessionC2s<'a> { +#[derive(Clone, PartialEq, Debug, Encode, Decode)] +pub struct PlayerSessionData { pub session_id: Uuid, // Public key pub expires_at: i64, - pub public_key_data: Bounded<&'a [u8], 512>, - pub key_signature: Bounded<&'a [u8], 4096>, + pub public_key_data: Box<[u8]>, + pub key_signature: Box<[u8]>, +} + +impl<'a> From for Cow<'a, PlayerSessionData> { + fn from(value: PlayerSessionData) -> Self { + Cow::Owned(value) + } +} + +impl<'a> From<&'a PlayerSessionData> for Cow<'a, PlayerSessionData> { + fn from(value: &'a PlayerSessionData) -> Self { + Cow::Borrowed(value) + } } diff --git a/crates/valence_registry/Cargo.toml b/crates/valence_registry/Cargo.toml index 8d75d1d25..c44dc264f 100644 --- a/crates/valence_registry/Cargo.toml +++ b/crates/valence_registry/Cargo.toml @@ -20,3 +20,4 @@ anyhow.workspace = true valence_ident.workspace = true valence_nbt = { workspace = true, features = ["serde"] } valence_server_common.workspace = true +valence_text.workspace = true diff --git a/crates/valence_registry/src/chat_type.rs b/crates/valence_registry/src/chat_type.rs new file mode 100644 index 000000000..4e1a745cd --- /dev/null +++ b/crates/valence_registry/src/chat_type.rs @@ -0,0 +1,240 @@ +//! ChatType configuration and identification. +//! +//! **NOTE:** +//! - Modifying the chat type registry after the server has started can +//! break invariants within instances and clients! Make sure there are no +//! instances or clients spawned before mutating. + +use std::fmt; +use std::ops::{Deref, DerefMut}; + +use bevy_app::prelude::*; +use bevy_ecs::prelude::*; +use serde::{de, Deserialize, Deserializer, Serialize, Serializer}; +use tracing::error; +use valence_ident::{ident, Ident}; +use valence_nbt::serde::CompoundSerializer; +use valence_text::Color; + +use crate::codec::{RegistryCodec, RegistryValue}; +use crate::{Registry, RegistryIdx, RegistrySet}; + +pub struct ChatTypePlugin; + +impl Plugin for ChatTypePlugin { + fn build(&self, app: &mut bevy_app::App) { + app.init_resource::() + .add_systems(PreStartup, load_default_chat_types) + .add_systems(PostUpdate, update_chat_type_registry.before(RegistrySet)); + } +} + +fn load_default_chat_types(mut reg: ResMut, codec: Res) { + let mut helper = move || -> anyhow::Result<()> { + for value in codec.registry(ChatTypeRegistry::KEY) { + let chat_type = ChatType::deserialize(value.element.clone())?; + + reg.insert(value.name.clone(), chat_type); + } + + reg.swap_to_front(ident!("chat")); + + Ok(()) + }; + + if let Err(e) = helper() { + error!("failed to load default chat types from registry codec: {e:#}"); + } +} + +/// Add new chat types to or update existing chat types in the registry. +fn update_chat_type_registry(reg: Res, mut codec: ResMut) { + if reg.is_changed() { + let chat_types = codec.registry_mut(ChatTypeRegistry::KEY); + + chat_types.clear(); + + chat_types.extend(reg.iter().map(|(_, name, chat_type)| { + RegistryValue { + name: name.into(), + element: chat_type + .serialize(CompoundSerializer) + .expect("failed to serialize chat type"), + } + })); + } +} + +#[derive(Resource, Default, Debug)] +pub struct ChatTypeRegistry { + reg: Registry, +} + +impl ChatTypeRegistry { + pub const KEY: Ident<&'static str> = ident!("chat_type"); +} + +impl Deref for ChatTypeRegistry { + type Target = Registry; + + fn deref(&self) -> &Self::Target { + &self.reg + } +} + +impl DerefMut for ChatTypeRegistry { + fn deref_mut(&mut self) -> &mut Self::Target { + &mut self.reg + } +} + +/// An index into the chat type registry +#[derive(Copy, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Default, Debug)] +pub struct ChatTypeId(pub u16); + +impl ChatTypeId { + pub const DEFAULT: Self = ChatTypeId(0); +} + +impl RegistryIdx for ChatTypeId { + const MAX: usize = u32::MAX as _; + + #[inline] + fn to_index(self) -> usize { + self.0 as _ + } + + #[inline] + fn from_index(idx: usize) -> Self { + Self(idx as _) + } +} + +/// Contains information about how chat is styled, such as the chat color. The +/// notchian server has different chat types for team chat and direct messages. +/// +/// Note that [`ChatTypeDecoration::style`] for [`ChatType::narration`] +/// is unused by the notchian client and is ignored. +#[derive(Serialize, Deserialize, Clone, Debug)] +pub struct ChatType { + pub chat: ChatTypeDecoration, + pub narration: ChatTypeDecoration, +} + +#[derive(Serialize, Deserialize, Clone, Default, Debug)] +pub struct ChatTypeDecoration { + pub translation_key: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub style: Option, + pub parameters: ChatTypeParameters, +} + +#[derive(Serialize, Deserialize, Clone, Default, Debug)] +pub struct ChatTypeStyle { + #[serde(skip_serializing_if = "Option::is_none")] + pub color: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub bold: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub italic: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub underlined: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub strikethrough: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub obfuscated: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub insertion: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub font: Option>, + // TODO + // * click_event: Option, + // * hover_event: Option, +} + +#[derive(Clone, Copy, Default, Debug)] +pub struct ChatTypeParameters { + sender: bool, + target: bool, + content: bool, +} + +impl Default for ChatType { + fn default() -> Self { + Self { + chat: ChatTypeDecoration { + translation_key: "chat.type.text".into(), + style: None, + parameters: ChatTypeParameters { + sender: true, + content: true, + ..Default::default() + }, + }, + narration: ChatTypeDecoration { + translation_key: "chat.type.text.narrate".into(), + style: None, + parameters: ChatTypeParameters { + sender: true, + content: true, + ..Default::default() + }, + }, + } + } +} + +impl Serialize for ChatTypeParameters { + fn serialize(&self, serializer: S) -> Result + where + S: Serializer, + { + let mut args = vec![]; + if self.sender { + args.push("sender"); + } + if self.target { + args.push("target"); + } + if self.content { + args.push("content"); + } + args.serialize(serializer) + } +} + +impl<'de> Deserialize<'de> for ChatTypeParameters { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + struct ParameterVisitor; + + impl<'de> de::Visitor<'de> for ParameterVisitor { + type Value = ChatTypeParameters; + + fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result { + formatter.write_str("struct ChatTypeParameters") + } + + fn visit_seq(self, mut seq: V) -> Result + where + V: de::SeqAccess<'de>, + { + let mut value = Self::Value::default(); + while let Some(element) = seq.next_element::()? { + match element.as_str() { + "sender" => value.sender = true, + "target" => value.target = true, + "content" => value.content = true, + _ => return Err(de::Error::unknown_field(&element, FIELDS)), + } + } + Ok(value) + } + } + + const FIELDS: &[&str] = &["sender", "target", "content"]; + deserializer.deserialize_struct("ChatTypeParameters", FIELDS, ParameterVisitor) + } +} diff --git a/crates/valence_registry/src/lib.rs b/crates/valence_registry/src/lib.rs index cb94fe840..7ec19e77d 100644 --- a/crates/valence_registry/src/lib.rs +++ b/crates/valence_registry/src/lib.rs @@ -18,6 +18,7 @@ )] pub mod biome; +pub mod chat_type; pub mod codec; pub mod dimension_type; pub mod tags; diff --git a/crates/valence_server/src/lib.rs b/crates/valence_server/src/lib.rs index b84a8df7b..8509b6e89 100644 --- a/crates/valence_server/src/lib.rs +++ b/crates/valence_server/src/lib.rs @@ -33,7 +33,6 @@ pub mod interact_entity; pub mod interact_item; pub mod keepalive; pub mod layer; -pub mod message; pub mod movement; pub mod op_level; pub mod resource_pack; diff --git a/crates/valence_server/src/message.rs b/crates/valence_server/src/message.rs deleted file mode 100644 index 54e4b5c03..000000000 --- a/crates/valence_server/src/message.rs +++ /dev/null @@ -1,63 +0,0 @@ -// TODO: delete this module in favor of valence_chat. - -use bevy_app::prelude::*; -use bevy_ecs::prelude::*; -use valence_protocol::encode::WritePacket; -use valence_protocol::packets::play::{ChatMessageC2s, GameMessageS2c}; -use valence_protocol::text::IntoText; - -use crate::event_loop::{EventLoopPreUpdate, PacketEvent}; - -pub struct MessagePlugin; - -impl Plugin for MessagePlugin { - fn build(&self, app: &mut App) { - app.add_event::() - .add_systems(EventLoopPreUpdate, handle_chat_message); - } -} - -pub trait SendMessage { - /// Sends a system message visible in the chat. - fn send_chat_message<'a>(&mut self, msg: impl IntoText<'a>); - /// Displays a message in the player's action bar (text above the hotbar). - fn send_action_bar_message<'a>(&mut self, msg: impl IntoText<'a>); -} - -impl SendMessage for T { - fn send_chat_message<'a>(&mut self, msg: impl IntoText<'a>) { - self.write_packet(&GameMessageS2c { - chat: msg.into_cow_text(), - overlay: false, - }); - } - - fn send_action_bar_message<'a>(&mut self, msg: impl IntoText<'a>) { - self.write_packet(&GameMessageS2c { - chat: msg.into_cow_text(), - overlay: true, - }); - } -} - -#[derive(Event, Clone, Debug)] -pub struct ChatMessageEvent { - pub client: Entity, - pub message: Box, - pub timestamp: u64, -} - -pub fn handle_chat_message( - mut packets: EventReader, - mut events: EventWriter, -) { - for packet in packets.iter() { - if let Some(pkt) = packet.decode::() { - events.send(ChatMessageEvent { - client: packet.client, - message: pkt.message.0.into(), - timestamp: pkt.timestamp, - }); - } - } -} diff --git a/examples/anvil_loading.rs b/examples/anvil_loading.rs index 664ba21dd..34a0c9cfa 100644 --- a/examples/anvil_loading.rs +++ b/examples/anvil_loading.rs @@ -4,7 +4,7 @@ use std::path::PathBuf; use clap::Parser; use valence::abilities::{FlyingSpeed, FovModifier, PlayerAbilitiesFlags}; -use valence::message::SendMessage; +use valence::chat::message::SendMessage; use valence::prelude::*; use valence_anvil::{AnvilLevel, ChunkLoadEvent, ChunkLoadStatus}; @@ -137,7 +137,7 @@ fn handle_chunk_loads( ); eprintln!("{errmsg}"); - layer.send_chat_message(errmsg.color(Color::RED)); + layer.send_game_message(errmsg.color(Color::RED)); layer.insert_chunk(event.pos, UnloadedChunk::new()); } diff --git a/examples/block_entities.rs b/examples/block_entities.rs index 937db43ca..b8f8c8ca7 100644 --- a/examples/block_entities.rs +++ b/examples/block_entities.rs @@ -1,7 +1,7 @@ #![allow(clippy::type_complexity)] +use valence::chat::message::ChatMessageEvent; use valence::interact_block::InteractBlockEvent; -use valence::message::ChatMessageEvent; use valence::nbt::{compound, List}; use valence::prelude::*; diff --git a/examples/boss_bar.rs b/examples/boss_bar.rs index a8aba6968..bb62cb3ca 100644 --- a/examples/boss_bar.rs +++ b/examples/boss_bar.rs @@ -1,13 +1,13 @@ #![allow(clippy::type_complexity)] use rand::seq::SliceRandom; +use valence::chat::message::{ChatMessageEvent, SendMessage}; use valence::prelude::*; use valence_boss_bar::{ BossBarBundle, BossBarColor, BossBarDivision, BossBarFlags, BossBarHealth, BossBarStyle, BossBarTitle, }; use valence_server::entity::cow::CowEntityBundle; -use valence_server::message::ChatMessageEvent; use valence_text::color::NamedColor; const SPAWN_Y: i32 = 64; @@ -107,30 +107,30 @@ fn init_clients( pos.set([0.5, SPAWN_Y as f64 + 1.0, 0.5]); *game_mode = GameMode::Creative; - client.send_chat_message( + client.send_game_message( "Type 'view' to toggle bar display" .on_click_suggest_command("view") .on_hover_show_text("Type 'view'"), ); - client.send_chat_message( + client.send_game_message( "Type 'color' to set a random color" .on_click_suggest_command("color") .on_hover_show_text("Type 'color'"), ); - client.send_chat_message( + client.send_game_message( "Type 'division' to set a random division" .on_click_suggest_command("division") .on_hover_show_text("Type 'division'"), ); - client.send_chat_message( + client.send_game_message( "Type 'flags' to set random flags" .on_click_suggest_command("flags") .on_hover_show_text("Type 'flags'"), ); - client.send_chat_message( + client.send_game_message( "Type any string to set the title".on_click_suggest_command("title"), ); - client.send_chat_message( + client.send_game_message( "Type any number between 0 and 1 to set the health".on_click_suggest_command("health"), ); } diff --git a/examples/building.rs b/examples/building.rs index cbf6e774d..72e57d9ba 100644 --- a/examples/building.rs +++ b/examples/building.rs @@ -79,7 +79,7 @@ fn init_clients( pos.set([0.0, SPAWN_Y as f64 + 1.0, 0.0]); *game_mode = GameMode::Creative; - client.send_chat_message("Welcome to Valence! Build something cool.".italic()); + client.send_game_message("Welcome to Valence! Build something cool.".italic()); } } diff --git a/examples/chat.rs b/examples/chat.rs new file mode 100644 index 000000000..fafa38ed4 --- /dev/null +++ b/examples/chat.rs @@ -0,0 +1,122 @@ +#![allow(clippy::type_complexity)] + +use tracing::warn; +use valence::chat::command::CommandExecutionEvent; +use valence::chat::message::{ChatMessageEvent, SendMessage}; +use valence::chat::ChatState; +use valence::prelude::*; + +const SPAWN_Y: i32 = 64; + +pub fn main() { + App::new() + .add_plugins(DefaultPlugins) + .add_systems(Startup, setup) + .add_systems( + Update, + ( + init_clients, + despawn_disconnected_clients, + handle_command_events, + handle_message_events, + ), + ) + .run(); +} + +fn setup( + mut commands: Commands, + server: Res, + dimensions: Res, + biomes: Res, +) { + let mut layer = LayerBundle::new(ident!("overworld"), &dimensions, &biomes, &server); + + for z in -5..5 { + for x in -5..5 { + layer.chunk.insert_chunk([x, z], UnloadedChunk::new()); + } + } + + for z in -25..25 { + for x in -25..25 { + layer + .chunk + .set_block([x, SPAWN_Y, z], BlockState::GRASS_BLOCK); + } + } + + commands.spawn(layer); +} + +fn init_clients( + mut clients: Query< + ( + &mut Client, + &mut Position, + &mut EntityLayerId, + &mut VisibleChunkLayer, + &mut VisibleEntityLayers, + &mut GameMode, + ), + Added, + >, + layers: Query, With)>, +) { + for ( + mut client, + mut pos, + mut layer_id, + mut visible_chunk_layer, + mut visible_entity_layers, + mut game_mode, + ) in &mut clients + { + let layer = layers.single(); + + pos.0 = [0.0, SPAWN_Y as f64 + 1.0, 0.0].into(); + layer_id.0 = layer; + visible_chunk_layer.0 = layer; + visible_entity_layers.0.insert(layer); + *game_mode = GameMode::Creative; + + client.send_game_message("Welcome to Valence! Say something.".italic()); + } +} + +fn handle_message_events( + mut clients: Query<(&mut Client, &mut ChatState)>, + names: Query<&Username>, + mut messages: EventReader, +) { + for message in messages.iter() { + let sender_name = names.get(message.client).expect("Error getting username"); + // Need to find better way. Username is sender, while client and chat state are + // receivers. Maybe try to add a chat feature to Client. + for (mut client, mut state) in clients.iter_mut() { + state + .as_mut() + .send_chat_message(client.as_mut(), sender_name, message) + .expect("Error sending message"); + } + } +} + +fn handle_command_events( + mut clients: Query<&mut Client>, + mut commands: EventReader, +) { + for command in commands.iter() { + let Ok(mut client) = clients.get_component_mut::(command.client) else { + warn!("Unable to find client for message: {:?}", command); + continue; + }; + + let message = command.command.to_string(); + + let formatted = + "You sent the command ".into_text() + ("/".into_text() + (message).into_text()).bold(); + + client.send_game_message(formatted); + } +} diff --git a/examples/command.rs b/examples/command.rs index 703ec4fc5..e3e6fc999 100644 --- a/examples/command.rs +++ b/examples/command.rs @@ -331,7 +331,7 @@ fn find_targets( match target { None => { let client = &mut clients.get_mut(event.executor).unwrap().1; - client.send_chat_message(format!("Could not find target: {}", name)); + client.send_game_message(format!("Could not find target: {}", name)); vec![] } Some(target_entity) => { @@ -376,7 +376,7 @@ fn find_targets( match target { None => { let mut client = clients.get_mut(event.executor).unwrap().1; - client.send_chat_message("Could not find target".to_string()); + client.send_game_message("Could not find target".to_string()); vec![] } Some(target_entity) => { @@ -396,7 +396,7 @@ fn find_targets( match target { None => { let mut client = clients.get_mut(event.executor).unwrap().1; - client.send_chat_message("Could not find target".to_string()); + client.send_game_message("Could not find target".to_string()); vec![] } Some(target_entity) => { @@ -407,7 +407,7 @@ fn find_targets( }, EntitySelector::ComplexSelector(_, _) => { let mut client = clients.get_mut(event.executor).unwrap().1; - client.send_chat_message("complex selector not implemented".to_string()); + client.send_game_message("complex selector not implemented".to_string()); vec![] } } @@ -419,7 +419,7 @@ fn handle_test_command( ) { for event in events.iter() { let client = &mut clients.get_mut(event.executor).unwrap(); - client.send_chat_message(format!( + client.send_game_message(format!( "Test command executed with data:\n {:#?}", &event.result )); @@ -432,7 +432,7 @@ fn handle_complex_command( ) { for event in events.iter() { let client = &mut clients.get_mut(event.executor).unwrap(); - client.send_chat_message(format!( + client.send_game_message(format!( "complex command executed with data:\n {:#?}\n and with the modifiers:\n {:#?}", &event.result, &event.modifiers )); @@ -445,7 +445,7 @@ fn handle_struct_command( ) { for event in events.iter() { let client = &mut clients.get_mut(event.executor).unwrap(); - client.send_chat_message(format!( + client.send_game_message(format!( "Struct command executed with data:\n {:#?}", &event.result )); @@ -476,7 +476,7 @@ fn handle_gamemode_command( None => { let (mut client, mut game_mode, ..) = clients.get_mut(event.executor).unwrap(); *game_mode = game_mode_to_set; - client.send_chat_message(format!( + client.send_game_message(format!( "Gamemode command executor -> self executed with data:\n {:#?}", &event.result )); @@ -486,7 +486,7 @@ fn handle_gamemode_command( EntitySelectors::AllEntities => { for (mut client, mut game_mode, ..) in &mut clients.iter_mut() { *game_mode = game_mode_to_set; - client.send_chat_message(format!( + client.send_game_message(format!( "Gamemode command executor -> all entities executed with data:\n \ {:#?}", &event.result @@ -503,14 +503,14 @@ fn handle_gamemode_command( None => { let client = &mut clients.get_mut(event.executor).unwrap().0; client - .send_chat_message(format!("Could not find target: {}", name)); + .send_game_message(format!("Could not find target: {}", name)); } Some(target) => { let mut game_mode = clients.get_mut(target).unwrap().1; *game_mode = game_mode_to_set; let client = &mut clients.get_mut(event.executor).unwrap().0; - client.send_chat_message(format!( + client.send_game_message(format!( "Gamemode command executor -> single player executed with \ data:\n {:#?}", &event.result @@ -521,7 +521,7 @@ fn handle_gamemode_command( EntitySelectors::AllPlayers => { for (mut client, mut game_mode, ..) in &mut clients.iter_mut() { *game_mode = game_mode_to_set; - client.send_chat_message(format!( + client.send_game_message(format!( "Gamemode command executor -> all entities executed with data:\n \ {:#?}", &event.result @@ -532,7 +532,7 @@ fn handle_gamemode_command( let (mut client, mut game_mode, ..) = clients.get_mut(event.executor).unwrap(); *game_mode = game_mode_to_set; - client.send_chat_message(format!( + client.send_game_message(format!( "Gamemode command executor -> self executed with data:\n {:#?}", &event.result )); @@ -554,14 +554,14 @@ fn handle_gamemode_command( match target { None => { let client = &mut clients.get_mut(event.executor).unwrap().0; - client.send_chat_message("Could not find target".to_string()); + client.send_game_message("Could not find target".to_string()); } Some(target) => { let mut game_mode = clients.get_mut(target).unwrap().1; *game_mode = game_mode_to_set; let client = &mut clients.get_mut(event.executor).unwrap().0; - client.send_chat_message(format!( + client.send_game_message(format!( "Gamemode command executor -> single player executed with \ data:\n {:#?}", &event.result @@ -578,14 +578,14 @@ fn handle_gamemode_command( match target { None => { let client = &mut clients.get_mut(event.executor).unwrap().0; - client.send_chat_message("Could not find target".to_string()); + client.send_game_message("Could not find target".to_string()); } Some(target) => { let mut game_mode = clients.get_mut(target).unwrap().1; *game_mode = game_mode_to_set; let client = &mut clients.get_mut(event.executor).unwrap().0; - client.send_chat_message(format!( + client.send_game_message(format!( "Gamemode command executor -> single player executed with \ data:\n {:#?}", &event.result @@ -597,7 +597,7 @@ fn handle_gamemode_command( EntitySelector::ComplexSelector(_, _) => { let client = &mut clients.get_mut(event.executor).unwrap().0; client - .send_chat_message("Complex selectors are not implemented yet".to_string()); + .send_game_message("Complex selectors are not implemented yet".to_string()); } }, } diff --git a/examples/cow_sphere.rs b/examples/cow_sphere.rs index e9f9ba124..d8b9e5056 100644 --- a/examples/cow_sphere.rs +++ b/examples/cow_sphere.rs @@ -3,8 +3,8 @@ use std::f64::consts::TAU; use valence::abilities::{PlayerStartFlyingEvent, PlayerStopFlyingEvent}; +use valence::chat::message::SendMessage; use valence::math::{DQuat, EulerRot}; -use valence::message::SendMessage; use valence::prelude::*; use valence_text::color::NamedColor; diff --git a/examples/ctf.rs b/examples/ctf.rs index b6ad1491f..1f4f05ec7 100644 --- a/examples/ctf.rs +++ b/examples/ctf.rs @@ -396,7 +396,7 @@ fn init_clients( *game_mode = GameMode::Adventure; health.0 = PLAYER_MAX_HEALTH; - client.send_chat_message( + client.send_game_message( "Welcome to Valence! Select a team by jumping in the team's portal.".italic(), ); } @@ -460,7 +460,7 @@ fn digging( (Team::Blue, BlockState::RED_WOOL) => { if event.position == globals.red_flag { commands.entity(event.client).insert(HasFlag(Team::Red)); - client.send_chat_message("You have the flag!".italic()); + client.send_game_message("You have the flag!".italic()); flag_manager.red = Some(ent); return; } @@ -468,7 +468,7 @@ fn digging( (Team::Red, BlockState::BLUE_WOOL) => { if event.position == globals.blue_flag { commands.entity(event.client).insert(HasFlag(Team::Blue)); - client.send_chat_message("You have the flag!".italic()); + client.send_game_message("You have the flag!".italic()); flag_manager.blue = Some(ent); return; } @@ -621,7 +621,7 @@ fn do_team_selector_portals( look.pitch = 0.0; head_yaw.0 = yaw; let chat_text: Text = "You are on team ".into_text() + team.team_text() + "!"; - client.send_chat_message(chat_text); + client.send_game_message(chat_text); let main_layer = main_layers.single(); ent_layers.as_mut().0.remove(&main_layer); @@ -776,13 +776,13 @@ fn do_flag_capturing( }; if capture_trigger.contains_pos(position.0) { - client.send_chat_message("You captured the flag!".italic()); + client.send_game_message("You captured the flag!".italic()); score .scores .entry(*team) .and_modify(|score| *score += 1) .or_insert(1); - client.send_chat_message(score.render_scores()); + client.send_game_message(score.render_scores()); commands.entity(ent).remove::(); match has_flag.0 { Team::Red => flag_manager.red = None, diff --git a/examples/death.rs b/examples/death.rs index 645d76d45..019b7d11e 100644 --- a/examples/death.rs +++ b/examples/death.rs @@ -81,7 +81,7 @@ fn init_clients( pos.set([0.0, SPAWN_Y as f64 + 1.0, 0.0]); *game_mode = GameMode::Creative; - client.send_chat_message( + client.send_game_message( "Welcome to Valence! Sneak to die in the game (but not in real life).".italic(), ); } diff --git a/examples/entity_hitbox.rs b/examples/entity_hitbox.rs index 59eff1a31..3e2060a36 100644 --- a/examples/entity_hitbox.rs +++ b/examples/entity_hitbox.rs @@ -77,7 +77,7 @@ fn init_clients( pos.set([0.0, 65.0, 0.0]); *game_mode = GameMode::Creative; - client.send_chat_message("To spawn an entity, press shift. F3 + B to activate hitboxes"); + client.send_game_message("To spawn an entity, press shift. F3 + B to activate hitboxes"); } } diff --git a/examples/game_of_life.rs b/examples/game_of_life.rs index d7624fd43..a6c61d0c4 100644 --- a/examples/game_of_life.rs +++ b/examples/game_of_life.rs @@ -101,8 +101,8 @@ fn init_clients( pos.set([0.0, 65.0, 0.0]); *game_mode = GameMode::Survival; - client.send_chat_message("Welcome to Conway's game of life in Minecraft!".italic()); - client.send_chat_message( + client.send_game_message("Welcome to Conway's game of life in Minecraft!".italic()); + client.send_game_message( "Sneak to toggle running the simulation and the left mouse button to bring blocks to \ life." .italic(), diff --git a/examples/parkour.rs b/examples/parkour.rs index 806d1cdd7..ac9e003fd 100644 --- a/examples/parkour.rs +++ b/examples/parkour.rs @@ -70,7 +70,7 @@ fn init_clients( is_flat.0 = true; *game_mode = GameMode::Adventure; - client.send_chat_message("Welcome to epic infinite parkour game!".italic()); + client.send_game_message("Welcome to epic infinite parkour game!".italic()); let state = GameState { blocks: VecDeque::new(), @@ -100,7 +100,7 @@ fn reset_clients( if out_of_bounds || state.is_added() { if out_of_bounds && !state.is_added() { - client.send_chat_message( + client.send_game_message( "Your score was ".italic() + state .score diff --git a/examples/player_list.rs b/examples/player_list.rs index 8b3635286..c2ccf3c20 100644 --- a/examples/player_list.rs +++ b/examples/player_list.rs @@ -87,7 +87,7 @@ fn init_clients( pos.set([0.0, SPAWN_Y as f64 + 1.0, 0.0]); *game_mode = GameMode::Creative; - client.send_chat_message( + client.send_game_message( "Please open your player list (tab key)." .italic() .color(Color::WHITE), diff --git a/examples/resource_pack.rs b/examples/resource_pack.rs index b337aadad..959c3650c 100644 --- a/examples/resource_pack.rs +++ b/examples/resource_pack.rs @@ -1,7 +1,7 @@ #![allow(clippy::type_complexity)] +use valence::chat::message::SendMessage; use valence::entity::sheep::SheepEntityBundle; -use valence::message::SendMessage; use valence::prelude::*; use valence::protocol::packets::play::ResourcePackStatusC2s; use valence::resource_pack::ResourcePackStatusEvent; @@ -86,7 +86,7 @@ fn init_clients( pos.set([0.0, SPAWN_Y as f64 + 1.0, 0.0]); *game_mode = GameMode::Creative; - client.send_chat_message("Hit the sheep to prompt for the resource pack.".italic()); + client.send_game_message("Hit the sheep to prompt for the resource pack.".italic()); } } @@ -113,16 +113,16 @@ fn on_resource_pack_status( if let Ok(mut client) = clients.get_mut(event.client) { match event.status { ResourcePackStatusC2s::Accepted => { - client.send_chat_message("Resource pack accepted.".color(Color::GREEN)); + client.send_game_message("Resource pack accepted.".color(Color::GREEN)); } ResourcePackStatusC2s::Declined => { - client.send_chat_message("Resource pack declined.".color(Color::RED)); + client.send_game_message("Resource pack declined.".color(Color::RED)); } ResourcePackStatusC2s::FailedDownload => { - client.send_chat_message("Resource pack failed to download.".color(Color::RED)); + client.send_game_message("Resource pack failed to download.".color(Color::RED)); } ResourcePackStatusC2s::SuccessfullyLoaded => { - client.send_chat_message( + client.send_game_message( "Resource pack successfully downloaded.".color(Color::BLUE), ); } diff --git a/examples/text.rs b/examples/text.rs index 8a7ed9eb7..60d3c7001 100644 --- a/examples/text.rs +++ b/examples/text.rs @@ -69,17 +69,17 @@ fn init_clients( visible_entity_layers.0.insert(layer); *game_mode = GameMode::Creative; - client.send_chat_message("Welcome to the text example.".bold()); - client.send_chat_message( + client.send_game_message("Welcome to the text example.".bold()); + client.send_game_message( "The following examples show ways to use the different text components.", ); // Text examples - client.send_chat_message("\nText"); - client.send_chat_message(" - ".into_text() + Text::text("Plain text")); - client.send_chat_message(" - ".into_text() + Text::text("Styled text").italic()); - client.send_chat_message(" - ".into_text() + Text::text("Colored text").color(Color::GOLD)); - client.send_chat_message( + client.send_game_message("\nText"); + client.send_game_message(" - ".into_text() + Text::text("Plain text")); + client.send_game_message(" - ".into_text() + Text::text("Styled text").italic()); + client.send_game_message(" - ".into_text() + Text::text("Colored text").color(Color::GOLD)); + client.send_game_message( " - ".into_text() + Text::text("Colored and styled text") .color(Color::GOLD) @@ -88,61 +88,61 @@ fn init_clients( ); // Translated text examples - client.send_chat_message("\nTranslated Text"); - client.send_chat_message( + client.send_game_message("\nTranslated Text"); + client.send_game_message( " - 'chat.type.advancement.task': ".into_text() + Text::translate(keys::CHAT_TYPE_ADVANCEMENT_TASK, []), ); - client.send_chat_message( + client.send_game_message( " - 'chat.type.advancement.task' with slots: ".into_text() + Text::translate( keys::CHAT_TYPE_ADVANCEMENT_TASK, ["arg1".into(), "arg2".into()], ), ); - client.send_chat_message( + client.send_game_message( " - 'custom.translation_key': ".into_text() + Text::translate("custom.translation_key", []), ); // Scoreboard value example - client.send_chat_message("\nScoreboard Values"); - client.send_chat_message(" - Score: ".into_text() + Text::score("*", "objective", None)); - client.send_chat_message( + client.send_game_message("\nScoreboard Values"); + client.send_game_message(" - Score: ".into_text() + Text::score("*", "objective", None)); + client.send_game_message( " - Score with custom value: ".into_text() + Text::score("*", "objective", Some("value".into())), ); // Entity names example - client.send_chat_message("\nEntity Names (Selector)"); - client.send_chat_message(" - Nearest player: ".into_text() + Text::selector("@p", None)); - client.send_chat_message(" - Random player: ".into_text() + Text::selector("@r", None)); - client.send_chat_message(" - All players: ".into_text() + Text::selector("@a", None)); - client.send_chat_message(" - All entities: ".into_text() + Text::selector("@e", None)); - client.send_chat_message( + client.send_game_message("\nEntity Names (Selector)"); + client.send_game_message(" - Nearest player: ".into_text() + Text::selector("@p", None)); + client.send_game_message(" - Random player: ".into_text() + Text::selector("@r", None)); + client.send_game_message(" - All players: ".into_text() + Text::selector("@a", None)); + client.send_game_message(" - All entities: ".into_text() + Text::selector("@e", None)); + client.send_game_message( " - All entities with custom separator: ".into_text() + Text::selector("@e", Some(", ".into_text().color(Color::GOLD))), ); // Keybind example - client.send_chat_message("\nKeybind"); + client.send_game_message("\nKeybind"); client - .send_chat_message(" - 'key.inventory': ".into_text() + Text::keybind("key.inventory")); + .send_game_message(" - 'key.inventory': ".into_text() + Text::keybind("key.inventory")); // NBT examples - client.send_chat_message("\nNBT"); - client.send_chat_message( + client.send_game_message("\nNBT"); + client.send_game_message( " - Block NBT: ".into_text() + Text::block_nbt("{}", "0 1 0", None, None), ); - client.send_chat_message( + client.send_game_message( " - Entity NBT: ".into_text() + Text::entity_nbt("{}", "@a", None, None), ); - client.send_chat_message( + client.send_game_message( " - Storage NBT: ".into_text() + Text::storage_nbt(ident!("storage.key"), "@a", None, None), ); - client.send_chat_message( + client.send_game_message( "\n\n↑ ".into_text().bold().color(Color::GOLD) + "Scroll up to see the full example!".into_text().not_bold(), ); diff --git a/examples/world_border.rs b/examples/world_border.rs index bb7111c30..1af1887f9 100644 --- a/examples/world_border.rs +++ b/examples/world_border.rs @@ -1,9 +1,9 @@ #![allow(clippy::type_complexity)] use bevy_app::App; +use valence::chat::message::{ChatMessageEvent, SendMessage}; use valence::client::despawn_disconnected_clients; use valence::inventory::HeldItem; -use valence::message::{ChatMessageEvent, SendMessage}; use valence::prelude::*; use valence::world_border::*; @@ -93,14 +93,14 @@ fn init_clients( let pickaxe = ItemStack::new(ItemKind::WoodenPickaxe, 1, None); inv.set_slot(main_slot.slot(), pickaxe); client - .send_chat_message("Use `add` and `center` chat messages to change the world border."); + .send_game_message("Use `add` and `center` chat messages to change the world border."); } } fn display_diameter(mut layers: Query<(&mut ChunkLayer, &WorldBorderLerp)>) { for (mut layer, lerp) in &mut layers { if lerp.remaining_ticks > 0 { - layer.send_chat_message(format!("diameter = {}", lerp.current_diameter)); + layer.send_game_message(format!("diameter = {}", lerp.current_diameter)); } } } diff --git a/src/lib.rs b/src/lib.rs index d9a7b58e3..c334a873e 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -43,6 +43,8 @@ pub use valence_advancement as advancement; pub use valence_anvil as anvil; #[cfg(feature = "boss_bar")] pub use valence_boss_bar as boss_bar; +#[cfg(feature = "chat")] +pub use valence_chat as chat; #[cfg(feature = "command")] pub use valence_command as command; #[cfg(feature = "command")] @@ -72,7 +74,6 @@ use valence_server::interact_entity::InteractEntityPlugin; use valence_server::interact_item::InteractItemPlugin; use valence_server::keepalive::KeepalivePlugin; use valence_server::layer::LayerPlugin; -use valence_server::message::MessagePlugin; use valence_server::movement::MovementPlugin; use valence_server::op_level::OpLevelPlugin; pub use valence_server::protocol::status_effects; @@ -106,6 +107,8 @@ pub mod prelude { event::AdvancementTabChangeEvent, Advancement, AdvancementBundle, AdvancementClientUpdate, AdvancementCriteria, AdvancementDisplay, AdvancementFrameType, AdvancementRequirements, }; + #[cfg(feature = "chat")] + pub use valence_chat::message::SendMessage as _; #[cfg(feature = "inventory")] pub use valence_inventory::{ CursorItem, Inventory, InventoryKind, InventoryWindow, InventoryWindowMut, OpenInventory, @@ -144,7 +147,6 @@ pub mod prelude { }; pub use valence_server::layer::{EntityLayer, LayerBundle}; pub use valence_server::math::{DVec2, DVec3, Vec2, Vec3}; - pub use valence_server::message::SendMessage as _; pub use valence_server::nbt::Compound; pub use valence_server::protocol::packets::play::particle_s2c::Particle; pub use valence_server::protocol::text::{Color, IntoText, Text}; @@ -186,7 +188,6 @@ impl PluginGroup for DefaultPlugins { .add(ClientSettingsPlugin) .add(ActionPlugin) .add(TeleportPlugin) - .add(MessagePlugin) .add(CustomPayloadPlugin) .add(HandSwingPlugin) .add(InteractBlockPlugin) @@ -251,6 +252,11 @@ impl PluginGroup for DefaultPlugins { group = group.add(valence_scoreboard::ScoreboardPlugin); } + #[cfg(feature = "chat")] + { + group = group.add(valence_chat::ChatPlugin); + } + group } }