commit b2fefc92ee01f5d1320d5a686d16e7c9250dbd70 Author: Mark Date: Thu Nov 23 20:48:52 2023 +0100 initial commit diff --git a/.gitignore b/.gitignore new file mode 100755 index 0000000..35709a7 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +*/Cargo.lock +*/target/ diff --git a/README.md b/README.md new file mode 100755 index 0000000..4144092 --- /dev/null +++ b/README.md @@ -0,0 +1,30 @@ +# mcdcbot + +- shut down / start your minecraft server from discord +- switch between multiple different worlds (/servers) from discord +- forward messages from a certain discord channel to the minecraft chat, and minecraft chat messages to that discord channel + +## usage + +See the repository for examples, notable `mcdcbot/servers/*` and `mcdcbot/settings.txt`. + +For advanced config options, check `minecraft_manager/src/lib.rs`, especially the `fn from_lines()`. + +Documentation may be added in the future... + +### In Discord: + +`/list` lists Servers: + +- (m) My Server +- (t) Test World + +`/starts` starts a server. You can choose which one to start. +Only one server can be running at any given time. + +`/start m` or `/start My Server` + +`/start t` or `/start Test World` + +`/stop` runs the `stop` command in the current server. +Once the server shuts down, a message will be sent. diff --git a/mcdcbot/Cargo.toml b/mcdcbot/Cargo.toml new file mode 100755 index 0000000..8ab685d --- /dev/null +++ b/mcdcbot/Cargo.toml @@ -0,0 +1,12 @@ +[package] +name = "mcdcbot" +version = "0.1.0" +edition = "2021" + +# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html + +[dependencies] +minecraft_manager = { path = "../minecraft_manager" } +poise = "0.5.7" +reqwest = "0.11.22" +tokio = { version = "1.34.0", default-features = false } diff --git a/mcdcbot/my_token.txt b/mcdcbot/my_token.txt new file mode 100755 index 0000000..ec7ade2 --- /dev/null +++ b/mcdcbot/my_token.txt @@ -0,0 +1 @@ +MTE3NzE5NzAwNDgyOTUxMTcyMA.GShE0Z.EmJ9dyHeaH3-NUsjHwiu5icvIojDn6k6f3hU30 diff --git a/mcdcbot/servers/MyTest b/mcdcbot/servers/MyTest new file mode 100755 index 0000000..ec67041 --- /dev/null +++ b/mcdcbot/servers/MyTest @@ -0,0 +1,4 @@ +type=vanilla-mojang +dir=/markone/temp/mc/MyTest +exec=server.jar +ram=2048 diff --git a/mcdcbot/settings.txt b/mcdcbot/settings.txt new file mode 100755 index 0000000..7f59079 --- /dev/null +++ b/mcdcbot/settings.txt @@ -0,0 +1,6 @@ +channel_id_info=1177200748648464464 +channel_id_chat=1177200760480612392 +send_join_and_leave_messages=true +send_start_stop_messages_in_chat=true +get_my_ip_url1=https://ipinfo.io/ip +get_my_ip_url2=https://ipecho.net/plain diff --git a/mcdcbot/src/data.rs b/mcdcbot/src/data.rs new file mode 100755 index 0000000..aacc659 --- /dev/null +++ b/mcdcbot/src/data.rs @@ -0,0 +1,25 @@ +use std::sync::Arc; + +use minecraft_manager::{thread::MinecraftServerThread, MinecraftServerSettings}; +use poise::futures_util::lock::Mutex; + +use crate::settings::Settings; + +pub struct Data { + pub settings: Mutex, + pub servers: Mutex>>>, + pub current: Arc< + Mutex< + Option<( + Arc>, + Arc>>, + )>, + >, + >, +} + +pub struct MinecraftServer { + pub name: String, + pub short: Option, + pub settings: MinecraftServerSettings, +} diff --git a/mcdcbot/src/embed.rs b/mcdcbot/src/embed.rs new file mode 100755 index 0000000..11964d9 --- /dev/null +++ b/mcdcbot/src/embed.rs @@ -0,0 +1,55 @@ +use minecraft_manager::{ + chat::ChatMessage, events::JoinLeaveEvent, threaded::MinecraftServerStopReason, +}; +use poise::serenity_prelude::{json::json, json::Value}; + +pub fn chat_message(e: &ChatMessage) -> Value { + json!({ + "embeds": [{ + "title": e.author, + "description": e.message + }] + }) +} +pub fn join_leave(e: &JoinLeaveEvent) -> Value { + json!({ + "embeds": [{ + "description": if e.joined { + format!("{} joined", e.username) + } else { + format!("{} left", e.username) + }, + }] + }) +} + +pub fn server_started(name: &str, ip: Option) -> Value { + json!({ + "embeds": [{ + "color": 26880, + "title": name, + "description": if let Some(ip) = ip { + format!("Server was started, IP: {ip}") + } else { + format!("Server was started") + }, + }] + }) +} +pub fn server_stopped(reason: Option) -> Value { + if let Some(reason) = reason { + json!({ + "embeds": [{ + "color": 6881280, + "title": reason.to_string(), + }] + }) + } else { + json!({ + "embeds": [{ + "color": 6881280, + "title": "Stopped.", + }] + }) + } +} diff --git a/mcdcbot/src/getmyip.rs b/mcdcbot/src/getmyip.rs new file mode 100755 index 0000000..bb1083b --- /dev/null +++ b/mcdcbot/src/getmyip.rs @@ -0,0 +1,31 @@ +pub async fn get_my_ip(url1: &str, url2: &str) -> String { + let ip1 = get(url1).await; + let ip2 = get(url2).await; + match (ip1, ip2) { + (Some(ip1), Some(ip2)) => { + if ip1 == ip2 { + ip1.to_string() + } else { + format!("{ip1} / {ip2}") + } + } + (Some(ip), None) | (None, Some(ip)) => ip.to_string(), + (None, None) => format!("unknown"), + } +} + +async fn get(url: &str) -> Option { + if url.is_empty() { + return None; + } + Some( + reqwest::get(url) + .await + .ok()? + .text() + .await + .ok()? + .trim() + .to_string(), + ) +} diff --git a/mcdcbot/src/main.rs b/mcdcbot/src/main.rs new file mode 100755 index 0000000..6eade4a --- /dev/null +++ b/mcdcbot/src/main.rs @@ -0,0 +1,324 @@ +mod data; +mod embed; +mod getmyip; +mod settings; + +use std::{collections::HashSet, env, sync::Arc, time::Duration}; + +use crate::{data::Data, settings::Settings}; +use minecraft_manager::{ + events::{MinecraftServerEventType, MinecraftServerWarning}, + tasks::MinecraftServerTask, + thread::MinecraftServerThread, +}; +use poise::{futures_util::lock::Mutex, serenity_prelude as serenity}; + +type Error = Box; +type Context<'a> = poise::Context<'a, Data, Error>; + +#[poise::command(slash_command)] +async fn list(ctx: Context<'_>) -> Result<(), Error> { + ctx.say({ + let mut acc = format!("Available servers:"); + for server in ctx.data().servers.lock().await.iter() { + let server = server.lock().await; + acc.push_str("\n- "); + if let Some(short) = &server.short { + acc.push('('); + acc.push_str(short); + acc.push_str(") "); + } + acc.push_str(server.name.as_str()); + } + acc + }) + .await?; + Ok(()) +} + +#[poise::command(slash_command)] +async fn start( + ctx: Context<'_>, + #[description = "Server's name (see /list)"] srv: String, +) -> Result<(), Error> { + // find server by name + let servers_lock = ctx.data().servers.lock().await; + let mut matching_server = None; + for server in servers_lock.iter() { + let server_lock = server.lock().await; + if server_lock + .short + .as_ref() + .is_some_and(|short| short == &srv) + { + matching_server = Some(Arc::clone(server)); + break; + } + } + if matching_server.is_none() { + for server in servers_lock.iter() { + let server_lock = server.lock().await; + if server_lock.name == srv { + matching_server = Some(Arc::clone(server)); + break; + } + } + } + if let Some(server) = matching_server { + let mut current_lock = ctx.data().current.lock().await; + if let Some((current, _)) = current_lock.as_ref() { + let current = current.lock().await; + ctx.say(format!( + "Already running '{}'! (stop the server before starting it)", + current.name, + )) + .await?; + } else { + let server_lock = server.lock().await; + let settings = ctx.data().settings.lock().await; + _ = ctx + .http() + .send_message( + settings.channel_id_info, + &embed::server_started( + &server_lock.name, + Some( + getmyip::get_my_ip(&settings.get_my_ip_url1, &settings.get_my_ip_url2) + .await, + ), + ), + ) + .await; + if settings.send_start_stop_messages_in_chat { + _ = ctx + .http() + .send_message( + settings.channel_id_chat, + &embed::server_started(&server_lock.name, None), + ) + .await; + } + let thread = server_lock.settings.clone().spawn(); + drop(server_lock); + *current_lock = Some((server, Arc::new(Mutex::new(Some(thread))))); + ctx.say(format!("Starting...")).await?; + } + } else { + ctx.say(format!("Can't find a server with that name!")) + .await?; + } + Ok(()) +} +#[poise::command(slash_command)] +async fn stop(ctx: Context<'_>) -> Result<(), Error> { + let current_lock = ctx.data().current.lock().await; + if let Some((_, thread)) = current_lock.as_ref() { + _ = thread + .lock() + .await + .as_ref() + .unwrap() + .clone_task_sender() + .send_task(MinecraftServerTask::Stop); + ctx.say(format!("Stopping...")).await?; + } else { + ctx.say(format!("Use /start to start a server first")) + .await?; + } + Ok(()) +} + +async fn event_handler( + ctx: &serenity::Context, + event: &poise::Event<'_>, + _framework: poise::FrameworkContext<'_, Data, Error>, + data: &Data, +) -> Result<(), Error> { + match event { + poise::Event::Message { new_message } => { + if !new_message.author.bot + && new_message.channel_id.0 == data.settings.lock().await.channel_id_chat + { + let current_lock = data.current.lock().await; + if let Some((_current, thread)) = current_lock.as_ref() { + let msg = new_message.content_safe(&ctx.cache); + let msg = msg + .replace("\\", "\\\\") + .replace("\n", "\\n") + .replace("\r", "\\r"); + let author = new_message + .author_nick(&ctx) + .await + .unwrap_or_else(|| new_message.author.name.clone()); + _ = thread + .lock() + .await + .as_ref() + .unwrap() + .clone_task_sender() + .send_task(MinecraftServerTask::RunCommand(format!( + "tellraw @a \"<{author}> {msg}\"", + ))); + } else { + } + } + } + _ => {} + } + Ok(()) +} + +#[tokio::main(flavor = "current_thread")] +async fn main() { + // read settings file + let settings = + Settings::from_file(env::var("McDcBotSettingsFile").unwrap_or(format!("settings.txt"))) + .unwrap(); + // read mc servers + let mut servers = vec![]; + for file in std::fs::read_dir(env::var("McDcBotServersDir").unwrap_or(format!("servers"))) + .expect("Couldn't read servers dir, maybe specify the directory with the McDcBotServersDir env variable?") { + let file = file.unwrap(); + let content = std::fs::read_to_string(file.path()).unwrap(); + let mut lines = content.lines().filter(|line| !line.trim().is_empty()); + let settings = minecraft_manager::MinecraftServerSettings::from_lines(&mut lines).unwrap(); + servers.push(data::MinecraftServer { + name: file.file_name().to_string_lossy().into_owned(), + short: None, + settings + }); + } + let mut shorts = HashSet::new(); + for server in &mut servers { + if let Some(ch) = server.name.trim().chars().next() { + let ch = ch.to_lowercase(); + if shorts.insert(format!("{ch}")) { + server.short = Some(format!("{ch}")); + continue; + } + } + let initials = server + .name + .trim() + .split_whitespace() + .filter_map(|v| v.chars().next().map(|c| c.to_uppercase())) + .flatten() + .collect::(); + if !initials.is_empty() { + if shorts.insert(initials.clone()) { + server.short = Some(initials); + continue; + } + } + } + // current server + let current = Arc::new(Mutex::new(None)); + let current_thread = Arc::clone(¤t); + // start + let framework = poise::Framework::builder() + .options(poise::FrameworkOptions { + commands: vec![list(), start(), stop()], + event_handler: |ctx, event, framework, data| { + Box::pin(event_handler(ctx, event, framework, data)) + }, + ..Default::default() + }) + .token(env::var("DISCORD_TOKEN").expect("missing DISCORD_TOKEN env var")) + .intents( + serenity::GatewayIntents::non_privileged() | serenity::GatewayIntents::MESSAGE_CONTENT, + ) + .setup(|ctx, ready, framework: &poise::Framework| { + Box::pin(async move { + ctx.idle().await; + poise::builtins::register_globally(ctx, &framework.options().commands).await?; + eprintln!("Connected as '{}'.", ready.user.name); + { + let ctx = ctx.clone(); + let settings = settings.clone(); + tokio::task::spawn(async move { + let sleep_time = Duration::from_millis(100); + let mut running = false; + loop { + tokio::time::sleep(sleep_time).await; + let mut current_lock: poise::futures_util::lock::MutexGuard< + '_, + Option<( + Arc>, + Arc>>, + )>, + > = current_thread.lock().await; + if let Some((_current_server, current_thread_mutex)) = + current_lock.as_ref() + { + let mut current_thread_opt = current_thread_mutex.lock().await; + let current_thread = current_thread_opt.as_mut().unwrap(); + current_thread.update(); + for event in current_thread.handle_new_events() { + match &event.event { + MinecraftServerEventType::Warning(w) => match w { + MinecraftServerWarning::CouldNotGetServerProcessStdio + | MinecraftServerWarning::CantWriteToStdin(_) => { + ctx.dnd().await; + } + }, + MinecraftServerEventType::JoinLeave(e) => { + if settings.send_join_and_leave_messages { + _ = ctx + .http + .send_message( + settings.channel_id_chat, + &embed::join_leave( + e + ), + ) + .await; + } + } + MinecraftServerEventType::ChatMessage(e) => { + _ = ctx + .http + .send_message( + settings.channel_id_chat, + &embed::chat_message(e), + ) + .await; + } + } + } + if current_thread.is_finished() { + let cto = current_thread_opt.take().unwrap(); + let msg = embed::server_stopped(cto.get_stop_reason().ok()); + _ = ctx.http.send_message(settings.channel_id_info, &msg).await; + if settings.send_start_stop_messages_in_chat { + _ = ctx + .http + .send_message(settings.channel_id_chat, &msg) + .await; + } + running = false; + drop(current_thread_opt); + *current_lock = None; + ctx.idle().await; + } else if !running { + running = true; + ctx.online().await; + } + } + } + }); + } + Ok(Data { + settings: Mutex::new(settings), + current, + servers: Mutex::new( + servers + .into_iter() + .map(|s| Arc::new(Mutex::new(s))) + .collect(), + ), + }) + }) + }); + + framework.run().await.unwrap(); +} diff --git a/mcdcbot/src/settings.rs b/mcdcbot/src/settings.rs new file mode 100755 index 0000000..4a2a61c --- /dev/null +++ b/mcdcbot/src/settings.rs @@ -0,0 +1,51 @@ +use std::path::Path; + +#[derive(Clone)] +pub struct Settings { + pub channel_id_info: u64, + pub channel_id_chat: u64, + pub send_join_and_leave_messages: bool, + pub send_start_stop_messages_in_chat: bool, + pub get_my_ip_url1: String, + pub get_my_ip_url2: String, +} + +impl Settings { + pub fn from_file(path: impl AsRef) -> std::io::Result { + let file = std::fs::read_to_string(path)?; + let mut cii = None; + let mut cic = None; + let mut send_join_and_leave_messages = false; + let mut send_start_stop_messages_in_chat = false; + let mut get_my_ip_url1 = String::new(); + let mut get_my_ip_url2 = String::new(); + for (name, value) in file + .lines() + .map(|line| line.split_once("=").unwrap_or((line, ""))) + { + match name { + "channel_id_info" => { + cii = value.trim().parse().ok(); + } + "channel_id_chat" => { + cic = value.trim().parse().ok(); + } + "get_my_ip_url1" => get_my_ip_url1 = value.trim().to_owned(), + "get_my_ip_url2" => get_my_ip_url2 = value.trim().to_owned(), + "send_join_and_leave_messages" => send_join_and_leave_messages = value != "false", + "send_start_stop_messages_in_chat" => { + send_start_stop_messages_in_chat = value != "false" + } + _ => {} + } + } + Ok(Self { + channel_id_info: cii.expect("[settings] Missing `channel_id_info`"), + channel_id_chat: cic.expect("[settings] Missing `channel_id_chat`"), + send_join_and_leave_messages, + send_start_stop_messages_in_chat, + get_my_ip_url1, + get_my_ip_url2, + }) + } +} diff --git a/minecraft_manager/Cargo.toml b/minecraft_manager/Cargo.toml new file mode 100755 index 0000000..1dcdb4e --- /dev/null +++ b/minecraft_manager/Cargo.toml @@ -0,0 +1,8 @@ +[package] +name = "minecraft_manager" +version = "0.1.0" +edition = "2021" + +# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html + +[dependencies] diff --git a/minecraft_manager/src/chat.rs b/minecraft_manager/src/chat.rs new file mode 100755 index 0000000..a8429ee --- /dev/null +++ b/minecraft_manager/src/chat.rs @@ -0,0 +1,5 @@ +#[derive(Debug)] +pub struct ChatMessage { + pub author: String, + pub message: String, +} diff --git a/minecraft_manager/src/events.rs b/minecraft_manager/src/events.rs new file mode 100755 index 0000000..46ef35c --- /dev/null +++ b/minecraft_manager/src/events.rs @@ -0,0 +1,27 @@ +use crate::chat::ChatMessage; + +#[derive(Debug)] +pub struct MinecraftServerEvent { + pub time: (), + pub event: MinecraftServerEventType, +} + +#[derive(Debug)] +pub enum MinecraftServerEventType { + Warning(MinecraftServerWarning), + JoinLeave(JoinLeaveEvent), + ChatMessage(ChatMessage), +} + +#[derive(Debug)] +pub enum MinecraftServerWarning { + /// The server process was spawned, but std{in,out,err} was not captured. + CouldNotGetServerProcessStdio, + CantWriteToStdin(std::io::Error), +} + +#[derive(Debug)] +pub struct JoinLeaveEvent { + pub username: String, + pub joined: bool, +} diff --git a/minecraft_manager/src/lib.rs b/minecraft_manager/src/lib.rs new file mode 100755 index 0000000..6d3c687 --- /dev/null +++ b/minecraft_manager/src/lib.rs @@ -0,0 +1,295 @@ +pub mod chat; +pub mod events; +pub mod parse_line; +pub mod tasks; +pub mod thread; +pub mod threaded; + +use std::{ + fmt::Display, + io::BufReader, + process::{Child, ChildStdin, ChildStdout, Command}, + sync::{Arc, Mutex}, +}; + +use thread::MinecraftServerThread; + +#[derive(Clone)] +pub struct MinecraftServerSettings { + pub server_type: MinecraftServerType, + pub directory: String, + pub executable: String, + /// the amount of dedicated wam for the JVM in [TODO!] (-Xm{s,x}...M) + pub dedicated_wam: u32, + pub java_cmd: Option, +} +impl Display for MinecraftServerSettings { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!( + f, + "{} @ {} :: {} @ {}MB", + self.server_type, self.directory, self.executable, self.dedicated_wam + ) + } +} +impl MinecraftServerSettings { + /// takes lines from the provided iterator until an empty line is reached (line.trim().is_empty()) or the iterator ends. + /// Note: The iterator items should NOT contain newline characters! + pub fn from_lines<'a, L: Iterator>( + lines: &mut L, + ) -> Result { + let mut server_type = Err(MinecraftServerSettingsFromLinesError::MissingServerType); + let mut directory = Err(MinecraftServerSettingsFromLinesError::MissingDirectory); + let mut executable = Err(MinecraftServerSettingsFromLinesError::MissingExecutable); + let mut ram = None; + let mut java_cmd = None; + let mut extra_line = None; + loop { + if let Some(line) = if let Some(l) = extra_line.take() { + Some(l) + } else { + lines.next() + } { + if let Some((key, value)) = line.split_once('=') { + match key { + "type" => { + server_type = Ok(match value.trim() { + "vanilla-mojang" => MinecraftServerType::VanillaMojang, + "vanilla-papermc" => MinecraftServerType::VanillaPaperMC, + "custom" => { + let mut name = Err(MinecraftServerSettingsFromLinesError::CustomServerTypeMissingName); + let mut line_parser = Err(MinecraftServerSettingsFromLinesError::CustomServerTypeMissingLineParser); + let mut command_override = None; + loop { + if let Some(line) = lines.next() { + if let Some(c) = line.chars().next() { + if c.is_whitespace() { + if let Some((key, val)) = + line.trim_start().split_once('=') + { + match key { + "name" => name = Ok(val.to_owned()), + "parser" => line_parser = Ok(val.to_owned()), + "command-override" => command_override = Some(val.to_owned()), + _ => + return Err(MinecraftServerSettingsFromLinesError::CustomTypeUnknownKey( + key.to_owned() + )), + } + } else { + return Err(MinecraftServerSettingsFromLinesError::CustomTypeUnknownKey( + line.trim_start().to_owned() + )); + } + } else { + extra_line = Some(line); + break; + } + } + } else { + break; + } + } + MinecraftServerType::Custom { + name: name?, + line_parser: line_parser?, + line_parser_proc: Arc::new(Mutex::new(None)), + command_override, + } + } + other => { + return Err( + MinecraftServerSettingsFromLinesError::UnknownServerType( + other.to_owned(), + ), + ) + } + }); + } + "dir" => directory = Ok(value.to_owned()), + "exec" => executable = Ok(value.to_owned()), + "ram" => { + if let Ok(v) = value.trim().parse() { + ram = Some(v); + } else { + return Err(MinecraftServerSettingsFromLinesError::RamNotAnInt( + value.to_owned(), + )); + } + } + "java_cmd" => java_cmd = Some(value.to_owned()), + k => { + return Err(MinecraftServerSettingsFromLinesError::UnknownKey( + k.to_owned(), + )) + } + } + } else if line.trim().is_empty() { + break; + } else { + return Err(MinecraftServerSettingsFromLinesError::UnknownKey( + line.to_owned(), + )); + } + } else { + break; + } + } + let mut o = Self::new(server_type?, directory?, executable?); + if let Some(ram) = ram { + o = o.with_ram(ram); + } + if let Some(java_cmd) = java_cmd { + o = o.with_java_cmd(Some(java_cmd)); + } + Ok(o) + } +} +#[derive(Debug)] +pub enum MinecraftServerSettingsFromLinesError { + UnknownKey(String), + MissingServerType, + UnknownServerType(String), + MissingDirectory, + MissingExecutable, + RamNotAnInt(String), + CustomTypeUnknownKey(String), + CustomServerTypeMissingName, + CustomServerTypeMissingLineParser, +} + +impl MinecraftServerSettings { + pub fn spawn(self) -> MinecraftServerThread { + MinecraftServerThread::start(self) + } + + pub fn new(server_type: MinecraftServerType, directory: String, executable: String) -> Self { + Self { + server_type, + directory, + executable, + dedicated_wam: 1024, + java_cmd: None, + } + } + pub fn with_ram(mut self, ram_mb: u32) -> Self { + self.dedicated_wam = ram_mb; + self + } + pub fn with_java_cmd(mut self, java_cmd: Option) -> Self { + self.java_cmd = java_cmd; + self + } + + pub fn get_command(&self) -> Command { + let mut cmd = Command::new(if let Some(c) = &self.java_cmd { + c.as_str() + } else { + match &self.server_type { + MinecraftServerType::VanillaMojang => "java", // "/usr/lib/jvm/openjdk17/bin/java", + MinecraftServerType::VanillaPaperMC => "java", // "/usr/lib/jvm/openjdk17/bin/java", + MinecraftServerType::Custom { + command_override, .. + } => { + if let Some(cmd) = command_override { + cmd + } else { + "java" + } + } + } + }); + cmd.current_dir(&self.directory); + // match &self.server_type { + // MinecraftServerType::VanillaMojang | MinecraftServerType::VanillaPaperMC => + cmd.args([ + format!("-Xms{}M", self.dedicated_wam), + format!("-Xmx{}M", self.dedicated_wam), + "-Dsun.stdout.encoding=UTF-8".to_owned(), + "-Dsun.stderr.encoding=UTF-8".to_owned(), + "-DFile.Encoding=UTF-8".to_owned(), + "-jar".to_string(), + self.executable.to_string(), + "nogui".to_string(), + ]); + cmd + } +} + +#[derive(Clone)] +pub enum MinecraftServerType { + VanillaMojang, + VanillaPaperMC, + Custom { + /// your custom server type's name + name: String, + /// each time a line is received from the mc server's stdout, it is sent to this programs stdin. + /// if the program has terminated, it is started again. + /// for best performance, the program should read stdin lines in a loop and never exit + line_parser: String, + line_parser_proc: Arc)>>>, + /// instead of running java -jar [...], use this to run a shell script which then starts the server. + /// things like ram etc will be ignored if this is used. + command_override: Option, + }, +} +impl Display for MinecraftServerType { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::VanillaMojang => write!(f, "vanilla-mojang"), + Self::VanillaPaperMC => write!(f, "vanilla-papermc"), + Self::Custom { + name: identifier, .. + } => write!(f, "custom ({identifier})"), + } + } +} + +pub fn test() { + // create minecraft server config + let minecraft_server_settings = MinecraftServerSettings { + server_type: MinecraftServerType::VanillaPaperMC, + directory: "/home/mark/Dokumente/minecraft_server/1".to_string(), + executable: "paper-1.19-81.jar".to_string(), + dedicated_wam: 1024, + java_cmd: None, + }; + // start server + let mut thread = minecraft_server_settings.spawn(); + // handle stdin + if false { + let sender = thread.clone_task_sender(); + std::thread::spawn(move || { + let stdin = std::io::stdin(); + loop { + let mut line = String::new(); + if let Ok(_) = stdin.read_line(&mut line) { + if line.trim().is_empty() { + std::thread::sleep(std::time::Duration::from_secs(300)); + continue; + } + if let Err(_) = sender.send_task(tasks::MinecraftServerTask::RunCommand(line)) { + break; + } + } else { + break; + } + } + }); + } + // handle stdout + loop { + if !thread.is_finished() { + thread.update(); + for event in thread.handle_new_events() { + eprintln!("Event: {event:?}"); + } + std::thread::sleep(std::time::Duration::from_millis(100)); + } else { + if let Ok(stop_reason) = thread.get_stop_reason() { + eprintln!("Thread stopped: {stop_reason}"); + } + break; + } + } +} diff --git a/minecraft_manager/src/parse_line.rs b/minecraft_manager/src/parse_line.rs new file mode 100755 index 0000000..a913bd1 --- /dev/null +++ b/minecraft_manager/src/parse_line.rs @@ -0,0 +1,214 @@ +use std::{ + io::{BufRead, BufReader, Write}, + process::{self, Stdio}, +}; + +use crate::{ + chat::ChatMessage, + events::{self, MinecraftServerEventType}, + MinecraftServerSettings, MinecraftServerType, +}; + +pub enum ParseOutput { + Nothing, + Error(ParseError), + Event(MinecraftServerEventType), +} + +pub enum ParseError { + /// any other errors (for custom line parser implementations) + Custom(String), +} + +pub fn parse_line(line: &str, settings: &MinecraftServerSettings) -> ParseOutput { + if line.trim().is_empty() { + return ParseOutput::Nothing; + } + match &settings.server_type { + MinecraftServerType::Custom { + line_parser, + line_parser_proc, + .. + } => { + let mut proc = line_parser_proc.lock().unwrap(); + let proc = &mut *proc; + let make_new_proc = if let Some((proc, _, _)) = proc { + if let Ok(Some(_)) = proc.try_wait() { + // has exited + true + } else { + false + } + } else { + true + }; + if make_new_proc { + if let Ok(mut new_proc) = process::Command::new(line_parser) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .spawn() + { + if let (Some(stdin), Some(stdout)) = + (new_proc.stdin.take(), new_proc.stdout.take()) + { + *proc = Some((new_proc, stdin, BufReader::new(stdout))); + } else { + eprintln!("[WARN/CUSTOM-LINE-PARSER] No stdin/stdout handles!"); + _ = new_proc.kill(); + } + } else { + eprintln!("[WARN/CUSTOM-LINE-PARSER] Can't spawn command '{line_parser}'!"); + } + } + if let Some((_proc, stdin, stdout)) = proc { + if let Err(e) = writeln!(stdin, "{line}") { + eprintln!("[WARN/CUSTOM-LINE-PARSER] Can't write to stdin: {e:?}"); + return ParseOutput::Nothing; + }; + let mut buf = String::new(); + if let Err(e) = stdout.read_line(&mut buf) { + eprintln!("[WARN/CUSTOM-LINE-PARSER] Can't read_line: {e:?}"); + return ParseOutput::Nothing; + }; + if buf.ends_with('\n') || buf.ends_with('\r') { + buf.pop(); + } + if buf.len() > 0 { + match buf.as_bytes()[0] { + b'c' => { + ParseOutput::Event(MinecraftServerEventType::ChatMessage(ChatMessage { + author: buf[1..].to_owned(), + message: { + let mut o = String::new(); + if let Err(e) = stdout.read_line(&mut o) { + eprintln!( + "[WARN/CUSTOM-LINE-PARSER] Can't read_line: {e:?}" + ); + return ParseOutput::Nothing; + } + o + }, + })) + } + b'j' => ParseOutput::Event(MinecraftServerEventType::JoinLeave( + events::JoinLeaveEvent { + username: buf[1..].to_owned(), + joined: true, + }, + )), + b'l' => ParseOutput::Event(MinecraftServerEventType::JoinLeave( + events::JoinLeaveEvent { + username: buf[1..].to_owned(), + joined: false, + }, + )), + b'e' => ParseOutput::Error({ + if buf.len() > 1 { + match buf.as_bytes()[1] { + b'c' => ParseError::Custom(buf[2..].to_string()), + _ => ParseError::Custom(String::new()), + } + } else { + ParseError::Custom(String::new()) + } + }), + _ => ParseOutput::Nothing, + } + } else { + ParseOutput::Nothing + } + } else { + eprintln!("[WARN/CUSTOM-LINE-PARSER] No process!"); + ParseOutput::Nothing + } + } + MinecraftServerType::VanillaMojang => { + if let Some((_time, rest)) = line[1..].split_once("] [Server thread/INFO]: ") { + let rest = rest.trim(); + if rest.starts_with("<") { + if let Some((user, msg)) = rest[1..].split_once("> ") { + return ParseOutput::Event(MinecraftServerEventType::ChatMessage( + ChatMessage { + author: user.to_owned(), + message: msg.to_owned(), + }, + )); + } + } else if rest.ends_with(" joined the game") { + return ParseOutput::Event(MinecraftServerEventType::JoinLeave( + events::JoinLeaveEvent { + username: rest[0..rest.len() - " joined the game".len()].to_owned(), + joined: true, + }, + )); + } else if rest.ends_with(" left the game") { + return ParseOutput::Event(MinecraftServerEventType::JoinLeave( + events::JoinLeaveEvent { + username: rest[0..rest.len() - " left the game".len()].to_owned(), + joined: false, + }, + )); + } + } + ParseOutput::Nothing + // Vanilla servers not yet supported... + } + MinecraftServerType::VanillaPaperMC => { + match line.chars().next() { + Some('[') => { + if let Some((_time, rest)) = line[1..].split_once(' ') { + if let Some((severity, rest)) = rest.split_once(']') { + if rest.starts_with(": ") { + let rest = &rest[2..]; + // eprintln!("Time: '{time}', Severity: '{severity}', Rest: '{rest}'."); + match severity { + "INFO" => { + if let Some('<') = rest.chars().next() { + if let Some((username, message)) = + rest[1..].split_once('>') + { + return ParseOutput::Event( + MinecraftServerEventType::ChatMessage( + ChatMessage { + author: username.to_string(), + message: message[1..].to_string(), + }, + ), + ); + } + } // join/leave + if rest.trim_end().ends_with(" joined the game") { + let username = &rest[..rest.len() - 16]; + return ParseOutput::Event( + MinecraftServerEventType::JoinLeave( + events::JoinLeaveEvent { + username: username.to_string(), + joined: true, + }, + ), + ); + } + if rest.trim_end().ends_with(" left the game") { + let username = &rest[..rest.len() - 14]; + return ParseOutput::Event( + MinecraftServerEventType::JoinLeave( + events::JoinLeaveEvent { + username: username.to_string(), + joined: false, + }, + ), + ); + } + } + _ => (), + } + } + } + } + } + _ => (), + } + ParseOutput::Nothing + } + } +} diff --git a/minecraft_manager/src/tasks.rs b/minecraft_manager/src/tasks.rs new file mode 100755 index 0000000..df5bf3d --- /dev/null +++ b/minecraft_manager/src/tasks.rs @@ -0,0 +1,37 @@ +use std::sync::mpsc; + +#[derive(Clone, Debug)] +pub enum MinecraftServerTask { + Stop, + Kill, + RunCommand(String), +} + +impl MinecraftServerTask { + pub fn generate_callback( + self, + ) -> ( + (Self, mpsc::Sender>), + MinecraftServerTaskCallback, + ) { + let (sender, update_receiver) = mpsc::channel(); + ( + (self, sender), + MinecraftServerTaskCallback::new(update_receiver), + ) + } +} + +pub struct MinecraftServerTaskCallback { + /// Ok(n) if n < 100 = progress in % + /// Ok(100) = finished + /// Ok(n) if n > 100 = task ended with non-standard exit status (advise checking log) + /// Err(_) = custom message (for log) + pub recv: mpsc::Receiver>, // TODO: NOT PUBLIC +} + +impl MinecraftServerTaskCallback { + pub fn new(recv: mpsc::Receiver>) -> Self { + Self { recv } + } +} diff --git a/minecraft_manager/src/thread.rs b/minecraft_manager/src/thread.rs new file mode 100755 index 0000000..6ce8c0f --- /dev/null +++ b/minecraft_manager/src/thread.rs @@ -0,0 +1,109 @@ +use std::thread::JoinHandle; + +use crate::tasks::MinecraftServerTaskCallback; + +use { + crate::{ + events::MinecraftServerEvent, + tasks::MinecraftServerTask, + threaded::{self, MinecraftServerStopReason}, + MinecraftServerSettings, + }, + std::{collections::VecDeque, sync::mpsc}, +}; + +pub struct MinecraftServerThread { + events: ThreadData, + task_sender: MinecraftServerTaskSender, + join_handle: JoinHandle, +} + +/// A clonable type allowing multiple threads to send tasks to the server. +#[derive(Clone)] +pub struct MinecraftServerTaskSender( + mpsc::Sender<(MinecraftServerTask, mpsc::Sender>)>, +); + +impl MinecraftServerTaskSender { + pub fn send_task(&self, task: MinecraftServerTask) -> Result { + let (sendable, callback) = task.generate_callback(); + if let Ok(_) = self.0.send(sendable) { + Ok(callback) + } else { + Err(()) + } + } +} + +impl MinecraftServerThread { + pub fn start(settings: MinecraftServerSettings) -> Self { + let (task_sender, event_receiver, join_handle) = threaded::run(settings); + Self { + events: ThreadData::new(event_receiver, 100), + task_sender: MinecraftServerTaskSender(task_sender), + join_handle, + } + } + pub fn is_finished(&self) -> bool { + self.join_handle.is_finished() + } + pub fn get_stop_reason(self) -> Result { + if self.is_finished() { + if let Ok(v) = self.join_handle.join() { + Ok(v) + } else { + Err(()) + } + } else { + Err(()) + } + } + pub fn update(&mut self) { + self.events.update(); + } + pub fn handle_new_events( + &mut self, + ) -> std::iter::Skip> { + self.events.handle_all() + } + pub fn clone_task_sender(&self) -> MinecraftServerTaskSender { + self.task_sender.clone() + } +} + +struct ThreadData { + mpsc: mpsc::Receiver, + buffer: VecDeque, + unhandeled: usize, + capacity: usize, +} + +impl ThreadData { + pub fn new(mpsc_receiver: mpsc::Receiver, capacity: usize) -> Self { + Self { + mpsc: mpsc_receiver, + buffer: VecDeque::with_capacity(capacity), + unhandeled: 0, + capacity, + } + } + pub fn update(&mut self) -> usize { + let mut unhandeled = 0; + while let Ok(new_content) = self.mpsc.try_recv() { + if self.buffer.len() == self.capacity { + self.buffer.pop_front(); + } + self.buffer.push_back(new_content); + unhandeled += 1; + } + self.unhandeled += unhandeled; + unhandeled + } + pub fn handle_all(&mut self) -> std::iter::Skip> { + let unhandeled = self.unhandeled; + self.unhandeled = 0; + self.buffer + .iter() + .skip(self.buffer.len().saturating_sub(unhandeled)) + } +} diff --git a/minecraft_manager/src/threaded.rs b/minecraft_manager/src/threaded.rs new file mode 100755 index 0000000..9551e36 --- /dev/null +++ b/minecraft_manager/src/threaded.rs @@ -0,0 +1,235 @@ +use std::{ + fmt::Display, + io::{BufRead, BufReader, Write}, + process::{ExitStatus, Stdio}, +}; + +use crate::{ + parse_line::{parse_line, ParseOutput}, + MinecraftServerType, +}; + +use { + crate::tasks::MinecraftServerTask, + crate::{ + events::{self as MinecraftServerEvents, MinecraftServerEvent, MinecraftServerEventType}, + MinecraftServerSettings, + }, + std::sync::mpsc, +}; + +pub fn run( + settings: MinecraftServerSettings, +) -> ( + mpsc::Sender<(MinecraftServerTask, mpsc::Sender>)>, + mpsc::Receiver, + std::thread::JoinHandle, +) { + let (return_task_sender, tasks) = + mpsc::channel::<(MinecraftServerTask, mpsc::Sender>)>(); + let (events, return_events_receiver) = mpsc::channel(); + + // thread + let join_handle = std::thread::spawn(move || { + let mut command = settings.get_command(); + command + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()); + eprintln!("Spawning {command:?}"); + match command.spawn() { + Ok(mut process) => { + if let (Some(mut stdin), Some(stdout), Some(mut _stderr)) = ( + process.stdin.take(), + process.stdout.take(), + process.stderr.take(), + ) { + let stdout_lines = { + // the stdout reading thread + let (lines, stdout_lines) = mpsc::channel(); + std::thread::spawn(move || { + let mut stdout = BufReader::new(stdout); + let mut line = String::new(); + loop { + line.clear(); + match stdout.read_line(&mut line) { + Ok(_) if !line.trim().is_empty() => { + eprintln!("> {}", line.trim()); + match lines.send(line.trim().to_owned()) { + Ok(_) => (), + Err(_) => return, + } + } + Ok(0) => { + eprintln!( + " [ Stdout read thread ] Reached EOF, stopping." + ); + return; + } + Ok(_) => {} // empty line, but read newline char - ignore + Err(e) => { + eprintln!( + " [ Stdout read thread ] Read error, stopping. ({e:?})" + ); + return; + } + } + } + }); + stdout_lines + }; + loop { + while let Ok(task) = tasks.try_recv() { + eprintln!("[GOT TASK] {task:?}"); + // iterate over all new tasks + match task.0 { + MinecraftServerTask::Stop => match writeln!(stdin, "stop") { + Ok(_) => { + task.1.send(Ok(0)); + while let Ok(None) = process.try_wait() { + std::thread::sleep(std::time::Duration::from_millis( + 250, + )); + } + task.1.send(Ok(100)); + } + Err(e) => { + events.send(MinecraftServerEvent { + time: (), + event: MinecraftServerEventType::Warning( + MinecraftServerEvents::MinecraftServerWarning::CantWriteToStdin(e), + ), + }); + } + }, + MinecraftServerTask::Kill => { + process.kill(); + task.1.send(Ok(100)); + return MinecraftServerStopReason { + time: (), + reason: MinecraftServerStopReasons::KilledDueToTask, + }; + } + MinecraftServerTask::RunCommand(command) => { + match writeln!( + stdin, + "{}", + command.replace("\n", "\\n").replace("\r", "\\r") + ) { + Ok(_) => task.1.send(Ok(100)), + Err(_) => task.1.send(Ok(101)), + }; + } + } + } + while let Ok(line) = stdout_lines.try_recv() { + // iterate over all new lines from stdout + // eprintln!(" [ server manager thread ] Found line '{}'", line); + match parse_line(&line, &settings) { + ParseOutput::Event(event) => { + events.send(MinecraftServerEvent { time: (), event }); + } + ParseOutput::Error(_) => (), + ParseOutput::Nothing => (), + } + } + // stop the loop once the process exits + match process.try_wait() { + Ok(None) => (), + Ok(Some(exit_status)) => { + if let MinecraftServerType::Custom { + line_parser_proc, .. + } = &settings.server_type + { + if let Some(proc) = &mut *line_parser_proc.lock().unwrap() { + _ = proc.0.kill(); + } + } + return MinecraftServerStopReason { + time: (), + reason: MinecraftServerStopReasons::ProcessEnded(exit_status), + }; + } + Err(e) => { + return MinecraftServerStopReason { + time: (), + reason: MinecraftServerStopReasons::ProcessCouldNotBeAwaited(e), + } + } + } + std::thread::sleep(std::time::Duration::from_millis(200)); + } + } else { + eprintln!("No stdin/out!"); + events.send(MinecraftServerEvent { + time: (), + event: MinecraftServerEventType::Warning( + MinecraftServerEvents::MinecraftServerWarning::CouldNotGetServerProcessStdio, + ), + }); + match process.wait() { + Ok(status) => MinecraftServerStopReason { + time: (), + reason: MinecraftServerStopReasons::ProcessEnded(status), + }, + Err(e) => MinecraftServerStopReason { + time: (), + reason: MinecraftServerStopReasons::ProcessCouldNotBeAwaited(e), + }, + } + } + } + Err(e) => { + eprintln!("Couldn't spawn server process: {e:?}"); + MinecraftServerStopReason { + time: (), + reason: MinecraftServerStopReasons::ProcessCouldNotBeSpawned(e), + } + } + } + }); + // return the mpsc channel parts + (return_task_sender, return_events_receiver, join_handle) +} + +pub struct MinecraftServerStopReason { + time: (), + reason: MinecraftServerStopReasons, +} +impl Display for MinecraftServerStopReason { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.reason) + } +} + +pub enum MinecraftServerStopReasons { + KilledDueToTask, + ProcessEnded(ExitStatus), + ProcessCouldNotBeSpawned(std::io::Error), + ProcessCouldNotBeAwaited(std::io::Error), +} +impl Display for MinecraftServerStopReasons { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::KilledDueToTask => write!(f, "killed (due to task)"), + Self::ProcessEnded(exit_status) => { + if let Some(s) = exit_status.code() { + if s == 0 { + write!(f, "Stopped") + } else { + write!(f, "Stopped (Exited with status {s})!") + } + } else { + write!(f, "Stopped!") + } + } + Self::ProcessCouldNotBeSpawned(_e) => { + write!(f, "Couldn't spawn process (check your paths!)") + } + Self::ProcessCouldNotBeAwaited(_e) => write!( + f, + "Couldn't wait for process to end (check console/log for errors)" + ), + } + } +}