initial commit

This commit is contained in:
Mark 2023-11-23 20:48:52 +01:00
commit b2fefc92ee
19 changed files with 1471 additions and 0 deletions

2
.gitignore vendored Executable file
View File

@ -0,0 +1,2 @@
*/Cargo.lock
*/target/

30
README.md Executable file
View File

@ -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.

12
mcdcbot/Cargo.toml Executable file
View File

@ -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 }

1
mcdcbot/my_token.txt Executable file
View File

@ -0,0 +1 @@
MTE3NzE5NzAwNDgyOTUxMTcyMA.GShE0Z.EmJ9dyHeaH3-NUsjHwiu5icvIojDn6k6f3hU30

4
mcdcbot/servers/MyTest Executable file
View File

@ -0,0 +1,4 @@
type=vanilla-mojang
dir=/markone/temp/mc/MyTest
exec=server.jar
ram=2048

6
mcdcbot/settings.txt Executable file
View File

@ -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

25
mcdcbot/src/data.rs Executable file
View File

@ -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<Settings>,
pub servers: Mutex<Vec<Arc<Mutex<MinecraftServer>>>>,
pub current: Arc<
Mutex<
Option<(
Arc<Mutex<MinecraftServer>>,
Arc<Mutex<Option<MinecraftServerThread>>>,
)>,
>,
>,
}
pub struct MinecraftServer {
pub name: String,
pub short: Option<String>,
pub settings: MinecraftServerSettings,
}

55
mcdcbot/src/embed.rs Executable file
View File

@ -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<String>) -> 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<MinecraftServerStopReason>) -> Value {
if let Some(reason) = reason {
json!({
"embeds": [{
"color": 6881280,
"title": reason.to_string(),
}]
})
} else {
json!({
"embeds": [{
"color": 6881280,
"title": "Stopped.",
}]
})
}
}

31
mcdcbot/src/getmyip.rs Executable file
View File

@ -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<String> {
if url.is_empty() {
return None;
}
Some(
reqwest::get(url)
.await
.ok()?
.text()
.await
.ok()?
.trim()
.to_string(),
)
}

324
mcdcbot/src/main.rs Executable file
View File

@ -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<dyn std::error::Error + Send + Sync>;
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::<String>();
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(&current);
// 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<Data, Error>| {
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<Mutex<data::MinecraftServer>>,
Arc<Mutex<Option<MinecraftServerThread>>>,
)>,
> = 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();
}

51
mcdcbot/src/settings.rs Executable file
View File

@ -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<Path>) -> std::io::Result<Self> {
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,
})
}
}

8
minecraft_manager/Cargo.toml Executable file
View File

@ -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]

5
minecraft_manager/src/chat.rs Executable file
View File

@ -0,0 +1,5 @@
#[derive(Debug)]
pub struct ChatMessage {
pub author: String,
pub message: String,
}

27
minecraft_manager/src/events.rs Executable file
View File

@ -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,
}

295
minecraft_manager/src/lib.rs Executable file
View File

@ -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<String>,
}
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<Item = &'a str>>(
lines: &mut L,
) -> Result<Self, MinecraftServerSettingsFromLinesError> {
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<String>) -> 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<Mutex<Option<(Child, ChildStdin, BufReader<ChildStdout>)>>>,
/// 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<String>,
},
}
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;
}
}
}

View File

@ -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
}
}
}

37
minecraft_manager/src/tasks.rs Executable file
View File

@ -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<Result<u8, String>>),
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<Result<u8, String>>, // TODO: NOT PUBLIC
}
impl MinecraftServerTaskCallback {
pub fn new(recv: mpsc::Receiver<Result<u8, String>>) -> Self {
Self { recv }
}
}

109
minecraft_manager/src/thread.rs Executable file
View File

@ -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<MinecraftServerEvent>,
task_sender: MinecraftServerTaskSender,
join_handle: JoinHandle<MinecraftServerStopReason>,
}
/// A clonable type allowing multiple threads to send tasks to the server.
#[derive(Clone)]
pub struct MinecraftServerTaskSender(
mpsc::Sender<(MinecraftServerTask, mpsc::Sender<Result<u8, String>>)>,
);
impl MinecraftServerTaskSender {
pub fn send_task(&self, task: MinecraftServerTask) -> Result<MinecraftServerTaskCallback, ()> {
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<MinecraftServerStopReason, ()> {
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<std::collections::vec_deque::Iter<MinecraftServerEvent>> {
self.events.handle_all()
}
pub fn clone_task_sender(&self) -> MinecraftServerTaskSender {
self.task_sender.clone()
}
}
struct ThreadData<T> {
mpsc: mpsc::Receiver<T>,
buffer: VecDeque<T>,
unhandeled: usize,
capacity: usize,
}
impl<T> ThreadData<T> {
pub fn new(mpsc_receiver: mpsc::Receiver<T>, 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<std::collections::vec_deque::Iter<T>> {
let unhandeled = self.unhandeled;
self.unhandeled = 0;
self.buffer
.iter()
.skip(self.buffer.len().saturating_sub(unhandeled))
}
}

235
minecraft_manager/src/threaded.rs Executable file
View File

@ -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<Result<u8, String>>)>,
mpsc::Receiver<MinecraftServerEvent>,
std::thread::JoinHandle<MinecraftServerStopReason>,
) {
let (return_task_sender, tasks) =
mpsc::channel::<(MinecraftServerTask, mpsc::Sender<Result<u8, String>>)>();
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)"
),
}
}
}