diff --git a/Cargo.lock b/Cargo.lock index 45d1187..b10586e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -586,10 +586,26 @@ dependencies = [ "pin-project-lite", "tokio", "tokio-rustls 0.23.4", - "tungstenite", + "tungstenite 0.17.3", "webpki-roots", ] +[[package]] +name = "async-tungstenite" +version = "0.22.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce01ac37fdc85f10a43c43bc582cbd566720357011578a935761075f898baf58" +dependencies = [ + "futures-io", + "futures-util", + "log 0.4.19", + "native-tls", + "pin-project-lite", + "tokio", + "tokio-native-tls", + "tungstenite 0.19.0", +] + [[package]] name = "autocfg" version = "0.1.8" @@ -924,6 +940,7 @@ name = "azalea-worker" version = "0.1.0" dependencies = [ "async-trait", + "async-tungstenite 0.22.2", "azalea-buf", "azalea-client", "azalea-core", @@ -932,6 +949,7 @@ dependencies = [ "bevy", "bevy_app", "bevy_ecs", + "chrono", "common", "futures 0.3.28", "log 0.4.19", @@ -946,7 +964,6 @@ dependencies = [ "toml", "url 2.4.0", "uuid", - "websocket", ] [[package]] @@ -5205,7 +5222,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d007dc45584ecc47e791f2a9a7cf17bf98ac386728106f111159c846d624be3f" dependencies = [ "async-trait", - "async-tungstenite", + "async-tungstenite 0.17.2", "base64 0.13.1", "bitflags 1.3.2", "bytes 1.4.0", @@ -6003,6 +6020,26 @@ dependencies = [ "webpki", ] +[[package]] +name = "tungstenite" +version = "0.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "15fba1a6d6bb030745759a9a2a588bfe8490fc8b4751a277db3a0be1c9ebbf67" +dependencies = [ + "byteorder", + "bytes 1.4.0", + "data-encoding", + "http", + "httparse", + "log 0.4.19", + "native-tls", + "rand 0.8.5", + "sha1", + "thiserror", + "url 2.4.0", + "utf-8", +] + [[package]] name = "twox-hash" version = "1.6.3" diff --git a/azalea-worker/Cargo.toml b/azalea-worker/Cargo.toml index 492e632..e66f35d 100644 --- a/azalea-worker/Cargo.toml +++ b/azalea-worker/Cargo.toml @@ -15,7 +15,6 @@ tokio = { version = "1", features = ["full"] } reqwest = { version = "0.11", features = ["json"] } serde_json = "1" async-trait = "0.1" -websocket = { version = "0.26", features = ["async"] } common = { path = "../common" } tokio-threadpool = "0.1" bevy_ecs = "0.11" @@ -29,4 +28,6 @@ azalea-protocol = { version = "0.7", git = "https://git.e3t.cc/~core/azalea" } azalea-crypto = { version = "0.7", git = "https://git.e3t.cc/~core/azalea" } azalea-core = { version = "0.7", git = "https://git.e3t.cc/~core/azalea" } azalea-buf = { version = "0.7", git = "https://git.e3t.cc/~core/azalea" } -azalea-client = { version = "0.7", git = "https://git.e3t.cc/~core/azalea" } \ No newline at end of file +azalea-client = { version = "0.7", git = "https://git.e3t.cc/~core/azalea" } +async-tungstenite = { version = "0.22", features = ["tokio-runtime", "tokio-native-tls"] } +chrono = "0.4" \ No newline at end of file diff --git a/azalea-worker/config.toml b/azalea-worker/config.toml index c9899b0..d8b35c3 100644 --- a/azalea-worker/config.toml +++ b/azalea-worker/config.toml @@ -1,5 +1,5 @@ -api_status_url = "http://localhost:8171/status" -token = "test-rw-token" +api_status_url = "https://data-api.locationoverflow.coredoes.dev/status" +token = "rw-minecraft-b1d648ab-498a-4499-b5a7-64aedcd7c836" [server] ip = "95.216.24.174" @@ -14,4 +14,6 @@ ignored = [] [permissions] owner = [] -admin = [] \ No newline at end of file +admin = [] + +[locations] \ No newline at end of file diff --git a/azalea-worker/src/bacon.rs b/azalea-worker/src/bacon.rs new file mode 100644 index 0000000..0b3abae --- /dev/null +++ b/azalea-worker/src/bacon.rs @@ -0,0 +1,344 @@ +use std::ops::DerefMut; +use std::str::FromStr; +use std::sync::{Arc}; +use azalea_client::chat::ChatPacket; +use azalea_client::Client; +use log::info; +use crate::config::{Config}; +use uuid::Uuid; + +pub const BACON_FEATURE_FLAGS: &[&str] = &["F-A", "F-Q", "F-E", "F-T", "F-B", "C-B", "C-A", "C-O", "C-H", "C-S", "C-A-S", "C-A-C", "C-A-I", "C-A-U"]; + +pub async fn handle_message(p: ChatPacket, client: &mut Client, config: &Config) { + if p.username().is_none() { return; } + + { + info!("{} {} {:?}", p.username().unwrap(), p.uuid().unwrap(), config.server.ignored); + + if config.server.ignored.contains(&p.username().unwrap()) || config.server.ignored.contains(&p.uuid().unwrap().to_string()) { + return; + } + } + + let message = p.content().to_string(); + + let mut components = vec![]; + + let mut is_parsing_string = false; + let mut is_parsing_escape = false; + let mut component = "".to_string(); + for char in message.chars() { + let mut char = char.to_string(); + if !&is_parsing_escape && !&is_parsing_string { + if char == "\"" { + is_parsing_string = true; + continue; + } + if char == "\\" { + is_parsing_escape = true; + continue; + } + if char == " " { + components.push(component); + component = "".to_string(); + continue; + } + + component += char.as_str(); + } + if is_parsing_escape { + if char == "\"" { + is_parsing_escape = false; + component += "\""; + continue; + } + if char == "n" { + is_parsing_escape = false; + component += "\n"; + continue; + } + if char == "\\" { + is_parsing_escape = false; + component += "\\"; + continue; + } + client.chat(&format!("[bot] Sorry, I don't understand that message. (invalid escape char)")); + return; + } + if is_parsing_string { + if char == "\"" { + is_parsing_string = false; + components.push(component); + component = "".to_string(); + continue; + } + component += char.as_str(); + } + } + + if !component.is_empty() { + components.push(component); + } + + if is_parsing_string { + return; + } + + if p.uuid() == Some(config.server.uuid) && components[0].starts_with('@') { + components.remove(0); + } + + let is_for_me = components[0].starts_with(&format!("!@{}", config.server.username)) || (!components[0].starts_with("!@") && components[0].starts_with("!")); + + if !is_for_me { return; } + + if components[0] == format!("!@{}", config.server.username) && components.len() == 1 { + return; + } + + let command = if components[0] == format!("!@{}", config.server.username) { + components[1].clone() + } else { + components[0].split('!').nth(1).unwrap().to_owned() + }; + + match command.as_str() { + "bacon" => { + client.chat("[bacon] F-A F-Q F-E F-T F-B C-B C-A C-O C-H C-S C-A-I C-A-U"); + client.chat("[bacon end]"); + return; + }, + "bot" => { + client.chat("[iambot]"); + return; + }, + "about" => { + client.chat("[bot about] Hi, I'm CuberCore! I am a bot that provides the chat relay for this server as well as many other useful features."); + return; + }, + "owner" => { + client.chat(&format!("[bot] I am owned by: {} ({})", config.server.owner_username, config.server.owner_uuid_undashed)); + return; + }, + "help" => { + client.chat("[bot help] Here are my commands:"); + client.chat("[bot help] !bacon, !bot, !about, !owner, !help, !sleep, !ignore [player], !unignore [player], !disconnect, !reconnect, !reload, !op [uuid], !deop [uuid], !location, !location [location], !location [location] [newlocation], !location [location] remove"); + return; + }, + "reload" => { + client.chat("[bot] Reloading configuration..."); + + /*{ + let _ = std::mem::replace(config.write().await.deref_mut(), load_config()); + }*/ + + client.chat("[bot] Configuration not reloaded - not yet implemented"); + }, + "ignore" => { + if let Some(u) = p.uuid() { + if !config.permissions.admin.contains(&u) { + return; + } + } else { + return; + } + + if components.len() != 2 { + client.chat("[bot] usage: !ignore [player]"); + return; + } + + client.chat("Not yet implemented."); + return; +/* + let player = components[1].clone(); + + { + let has = { config.write().await.relay.ignored.contains(&player) }; + if !has { + config.write().await.relay.ignored.push(player.clone()); + } + } + + save_config(&*config.read().await); + + client.chat(&format!("[bot] {} has been ignored", player)); + + */ + }, + "unignore" => { + if let Some(u) = p.uuid() { + if !config.permissions.admin.contains(&u) { + return; + } + } else { + return; + } + + if components.len() != 2 { + client.chat("[bot] usage: !unignore [player]"); + return; + } + + client.chat("not yet implemented"); + + return; + /* + + let player = components[1].clone(); + + { + let has = { config.write().await.relay.ignored.contains(&player) }; + if has { + let index = { config.write().await.relay.ignored.iter().position(|u| u == &player).unwrap() }; + config.write().await.relay.ignored.remove(index); + } + } + + save_config(&*config.read().await); + + client.chat(&format!("[bot] {} has been unignored", player)); + + */ + }, + "op" => { + if let Some(u) = p.uuid() { + if !config.permissions.admin.contains(&u) { + return; + } + } else { + return; + } + + if components.len() != 2 { + client.chat("[bot] usage: !op [uuid]"); + return; + } + + let player = components[1].clone(); + let player = match Uuid::from_str(&player) { + Ok(p) => p, + Err(e) => { + client.chat(&format!("[bot] invalid uuid: {}", e)); + return; + } + }; + + client.chat("not yet implemented"); + + return; +/* + { + let has = { config.write().await.permissions.admin.contains(&player) }; + if !has { + config.write().await.permissions.admin.push(player.clone()); + } + } + + save_config(&*config.read().await); + + client.chat(&format!("[bot] {} has been opped", player)) + + */ + }, + "deop" => { + if let Some(u) = p.uuid() { + if !config.permissions.admin.contains(&u) { + return; + } + } else { + return; + } + + if components.len() != 2 { + client.chat("[bot] usage: !deop [player]"); + return; + } + + let player = components[1].clone(); + let player = match Uuid::from_str(&player) { + Ok(p) => p, + Err(e) => { + client.chat(&format!("[bot] invalid uuid: {}", e)); + return; + } + }; + + client.chat("not yet implemented"); + return; + /* + + { + let has = { config.write().await.permissions.admin.contains(&player) }; + if has { + let index = { config.write().await.permissions.admin.iter().position(|u| u == &player).unwrap() }; + config.write().await.permissions.admin.remove(index); + } + } + + save_config(&*config.read().await); + + client.chat(&format!("[bot] {} has been deopped", player)); + + */ + }, + "location" => { + if components.len() == 1 { + client.chat(&format!("Here are the locations I know about: {:?}", config.locations.keys().collect::>())); + return; + } + + if components.len() == 2 { + if let Some(pos) = config.locations.get(&components[1]) { + client.chat(&format!("{} is located at {}!", components[1], pos)); + return; + } else { + client.chat(&format!("Sorry, I don't know where {} is.", components[1])); + return; + } + } + + if components.len() == 3 { + if let Some(u) = p.uuid() { + if !config.permissions.admin.contains(&u) { + return; + } + } else { + return; + } + + // !location abode "1 2 3" + // !location abode remove + + if components[2] == "remove" { + client.chat("not yet implemented"); + /* + { + config.write().await.locations.remove(&components[1]); + } + + save_config(&*config.read().await); + + client.chat(&format!("Okay, I removed the {} location.", components[1])); + + */ + return; + } + + client.chat("not yet implemented"); + /* + { + config.write().await.locations.insert(components[1].clone(), components[2].clone()); + } + + save_config(&*config.read().await); + + client.chat(&format!("Okay, I set the {} location to {}.", components[1], components[2])); + + */ + return; + } + } + _ => () + } +} \ No newline at end of file diff --git a/azalea-worker/src/config.rs b/azalea-worker/src/config.rs index 9b6fe15..7319035 100644 --- a/azalea-worker/src/config.rs +++ b/azalea-worker/src/config.rs @@ -1,3 +1,4 @@ +use std::collections::HashMap; use std::net::Ipv4Addr; use serde::{Deserialize, Serialize}; use url::Url; @@ -8,7 +9,8 @@ pub struct Config { pub api_status_url: Url, pub token: String, pub server: ServerConfig, - pub permissions: PermissionsConfig + pub permissions: PermissionsConfig, + pub locations: HashMap } #[derive(Serialize, Deserialize, Debug, Clone)] diff --git a/azalea-worker/src/main.rs b/azalea-worker/src/main.rs index d075c22..fcd8e5d 100644 --- a/azalea-worker/src/main.rs +++ b/azalea-worker/src/main.rs @@ -1,29 +1,41 @@ use std::error::Error; -use std::fs; +use std::{fs, thread}; use std::net::SocketAddr; -use azalea_client::{Account, Client, DefaultPlugins, start_ecs}; +use std::sync::Arc; +use std::time::SystemTime; +use async_tungstenite::tokio::{connect_async, ConnectStream}; +use async_tungstenite::tungstenite::Message; +use async_tungstenite::WebSocketStream; +use azalea_client::{Account, Client, DefaultPlugins, Event, start_ecs}; use azalea_client::packet_handling::DeathEvent; use azalea_client::respawn::{perform_respawn, PerformRespawnEvent}; +use azalea_protocol::packets::game::ClientboundGamePacket; use azalea_protocol::packets::game::serverbound_client_information_packet::{ChatVisibility, ServerboundClientInformationPacket}; use azalea_protocol::ServerAddress; use bevy_app::{App, Plugin, Update}; use bevy_ecs::event::{EventReader, EventWriter}; use bevy_ecs::prelude::IntoSystemConfigs; -use log::{debug, error, info}; +use chrono::DateTime; +use futures::{SinkExt, Stream, StreamExt}; +use log::{debug, error, info, Level}; +use regex::Regex; +use tokio::select; +use tokio::sync::broadcast::channel; use tokio::sync::mpsc; +use tokio::sync::mpsc::Sender; use tokio_threadpool::ThreadPool; use url::Url; -use websocket::{ClientBuilder, Message, OwnedMessage}; -use websocket::websocket_base::result::WebSocketResult; -use common::message::{GatewayChatMessage, GatewayPacketC2S, GatewayPacketS2C}; +use common::message::{GatewayChatMessage, GatewayChatSource, GatewayPacketC2S, GatewayPacketS2C}; use common::status::{DATA_API_VERSION, Status}; +use crate::bacon::handle_message; use crate::config::Config; +pub mod bacon; pub mod config; #[tokio::main] async fn main() -> Result<(), Box> { - simple_logger::init_with_env().unwrap(); + simple_logger::init_with_level(Level::Info).unwrap(); info!("Loading config"); @@ -43,7 +55,7 @@ async fn main() -> Result<(), Box> { info!("Loading status from the API ({})...", config.api_status_url); - let status: Status = reqwest::get(config.api_status_url).await?.json().await?; + let status: Status = reqwest::get(config.api_status_url.clone()).await?.json().await?; debug!("{:?}", status); @@ -54,18 +66,66 @@ async fn main() -> Result<(), Box> { info!("Connecting to gateway uri {}", status.gateway_url.to_string()); - let mut client = ClientBuilder::new(status.gateway_url.as_str())?.connect(None)?; + let mut gateway_uri = status.gateway_url; + if gateway_uri.scheme() == "http" { + gateway_uri.set_scheme("ws").unwrap(); + } else if gateway_uri.scheme() == "https" { + gateway_uri.set_scheme("wss").unwrap(); + } - // send the authentication packet, and then start listen + let (mut ws_stream, _) = connect_async(&gateway_uri).await?; - let message = Message::text(serde_json::to_string(&GatewayPacketC2S::Authenticate { + debug!("websocket connection opened - sending Authenticate"); + + ws_stream.send(Message::text(serde_json::to_string(&GatewayPacketC2S::Authenticate { token: config.token.clone(), - request_write_perms: true, - })?); + request_write_perms: true, // we *need* write perms for the relay + })?)).await?; - client.send_message(&message)?; + debug!("starting handshake loop (waiting for AuthenticationAccepted)"); - //let (mut tx, mut rx) = client.split().unwrap(); + loop { + let msg = ws_stream.next().await; + + match msg { + Some(msg) => match msg? { + Message::Text(packet) => { + debug!("recv gateway packet {}", packet); + let packet: GatewayPacketS2C = serde_json::from_str(&packet)?; + + match packet { + GatewayPacketS2C::AuthenticationAccepted { write_perms } => { + info!("Gateway accepted our token."); + + if !write_perms { + error!("Gateway server did not grant us write perms to the chat stream socket."); + error!("This is REQUIRED for azalea-worker to function correctly."); + error!("Check to ensure your token has `allow-write = true` set in the API config file."); + error!("AuthenticationAccepted with write_perms:false"); + std::process::exit(1); + } + + break; + } + GatewayPacketS2C::Disconnect { reason } => { + error!("Disconnected by server: {:?}", reason); + std::process::exit(1); + } + GatewayPacketS2C::Relayed { .. } => {} // ignore messages for now + } + }, + msg => { + debug!("unknown message {:?}", msg); + } + }, + None => { + debug!("didn't receive anything"); + continue; + } + } + } + + info!("Connected to gateway, starting the minecraft client"); info!("Connecting to the Minecraft server..."); @@ -99,6 +159,17 @@ async fn main() -> Result<(), Box> { allows_listing: true, }).await?; + loop { + select! { + msg = ws_stream.next() => handle_ws_message(msg, &mut client).await?, + e = rx.recv() => { + if let Some(e) = e { + handle_packet(e, &config, &mut client, &mut ws_stream).await? + } + } + } + } + /* for msg in client.incoming_messages() { match msg { @@ -142,6 +213,7 @@ async fn main() -> Result<(), Box> { */ + Ok(()) } @@ -163,4 +235,146 @@ fn auto_respawn( entity: event.entity, }); } +} + +async fn handle_ws_message(msg: Option>, client: &mut Client) -> Result<(), Box> { + match msg { + Some(msg) => match msg? { + Message::Text(packet) => { + debug!("recv gateway packet {}", packet); + let packet: GatewayPacketS2C = serde_json::from_str(&packet)?; + + match packet { + GatewayPacketS2C::AuthenticationAccepted { .. } => { + Ok(()) + }, + GatewayPacketS2C::Disconnect { reason } => { + error!("Disconnected by server: {:?}", reason); + std::process::exit(1); + } + GatewayPacketS2C::Relayed { msg } => { + if matches!(msg.source, GatewayChatSource::Minecraft) { return Ok(()); } + + client.chat(&format!("{}: {}", msg.username, msg.message)); + + Ok(()) + } + } + }, + msg => { + debug!("unknown message {:?}", msg); + Ok(()) + } + }, + None => { + debug!("didn't receive anything"); + Ok(()) + } + } +} + +async fn handle_packet(e: Event, config: &Config, client: &mut Client, ws_stream: &mut WebSocketStream) -> Result<(), Box> { + match e { + Event::Init => {} + Event::Login => {} + Event::Chat(pkt) => { + if pkt.content().starts_with("/skill") { + return Ok(()); + } + + info!("<{} ({})> {}", pkt.username().unwrap_or("SYSTEM".to_string()), pkt.uuid().map(|u| u.to_string()).unwrap_or("SYSTEM".to_string()), pkt.message()); + + let re = Regex::new(r"@[a-z0-9_-]+:").unwrap(); + if !(re.is_match(&pkt.content()) && pkt.uuid() == Some(config.server.uuid)) { + info!("sending message"); + ws_stream.send(Message::Text(serde_json::to_string(&GatewayPacketC2S::Relay { msg: GatewayChatMessage { + source: GatewayChatSource::Minecraft, + username: pkt.username().unwrap_or("SYSTEM".to_string()), + message: pkt.content(), + timestamp: DateTime::from(SystemTime::now()), + }})?)).await?; + } + + handle_message(pkt.clone(), client, config).await; + + /* + + if pkt.content() == "!disconnect" { + if let Some(u) = pkt.uuid() { + if !config.read().await.permissions.admin.uuid_members.contains(&u) { + client.chat("Nice try. You need to be an 'admin' to do that."); + continue; + } + } else { + client.chat("Nice try. You need to be an 'admin' to do that."); + continue; + } + client.chat("Okay, disconnecting. Cya!"); + client.disconnect(); + return Err("Disconnected.".into()); + } + if pkt.content() == "!reconnect" { + if let Some(u) = pkt.uuid() { + if !config.read().await.permissions.admin.uuid_members.contains(&u) { + client.chat("Nice try. You need to be an 'admin' to do that."); + continue; + } + } else { + client.chat("Nice try. You need to be an 'admin' to do that."); + continue; + } + client.chat("Okay, reconnecting. Cya!"); + client.disconnect(); + break; + } + if pkt.content() == "!help" { + client.chat("Hi, I'm CuberCore! I am owned by CoreCuber/@realcore."); + client.chat("I function as the chat relay for this server and also do other useful things."); + client.chat("My commands:"); + client.chat("!sleep - Sleep in the nearest bed"); + client.chat("!disconnect - Disconnect from the server (admin only)"); + client.chat("!reconnect - Reconnect to the server (admin only)"); + client.chat("!location - List all locations I know about"); + client.chat("!location - Get the coords of a location"); + } + if pkt.content() == "!bot" { + client.chat("[iambot]"); + } + + if pkt.content().starts_with("!location") { + let content = pkt.content(); + let split = content.split(' ').collect::>(); + + if split.len() == 1 { + client.chat(&format!("[bot response] Locations: {:?}", config.read().await.locations.keys().collect::>())); + } else { + let location = split[1]; + + if let Some(coords) = config.read().await.locations.get(location) { + client.chat(&format!("[bot response] Location '{}' is at {}", location, coords)); + } else { + client.chat(&format!("[bot response] I don't know where '{}' is.", location)); + } + } + } + + */ + } + Event::Tick => {} + Event::Packet(p) => { + match p.as_ref() { + ClientboundGamePacket::Disconnect(_) => { + info!("Disconnected, restarting"); + return Err("Disconnected".into()); + } + _ => () + } + } + Event::AddPlayer(_) => {} + Event::RemovePlayer(_) => {} + Event::UpdatePlayer(_) => {} + Event::Death(e) => {} + Event::KeepAlive(_) => {} + }; + Ok(()) } \ No newline at end of file