html terminal
| -rw-r--r-- | Cargo.lock | 18 | ||||
| -rw-r--r-- | Cargo.toml | 1 | ||||
| -rw-r--r-- | src/bot/admin.rs | 35 | ||||
| -rw-r--r-- | src/bot/bans.rs | 36 | ||||
| -rw-r--r-- | src/bot/config.rs | 55 | ||||
| -rw-r--r-- | src/bot/js.rs | 49 | ||||
| -rw-r--r-- | src/bot/maps.rs | 25 | ||||
| -rw-r--r-- | src/bot/mod.rs | 241 | ||||
| -rw-r--r-- | src/bot/player.rs | 38 | ||||
| -rw-r--r-- | src/bot/status.rs | 91 |
10 files changed, 371 insertions, 218 deletions
@@ -354,6 +354,15 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6245d59a3e82a7fc217c5828a6692dbc6dfb63a0c8c90495621f7b9d79704a0e" [[package]] +name = "convert_case" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec182b0ca2f35d8fc196cf3404988fd8b8c739a4d270ff118a398feb0cbec1ca" +dependencies = [ + "unicode-segmentation", +] + +[[package]] name = "core-foundation-sys" version = "0.8.4" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -473,7 +482,7 @@ version = "0.99.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4fb810d30a7c1953f91334de7244731fc3f3c10d7fe163338a35b9f640960321" dependencies = [ - "convert_case", + "convert_case 0.4.0", "proc-macro2", "quote", "rustc_version", @@ -1089,6 +1098,7 @@ dependencies = [ "anyhow", "async-std", "axum", + "convert_case 0.6.0", "futures", "futures-util", "itertools", @@ -1966,6 +1976,12 @@ dependencies = [ ] [[package]] +name = "unicode-segmentation" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1dd624098567895118886609431a7c3b8f516e41d30e0643f03d94592a147e36" + +[[package]] name = "untrusted" version = "0.7.1" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -39,6 +39,7 @@ anyhow = "1.0.71" regex = { version = "1.8.4", features = ["std"], default-features = false } minify-js = "0.4.3" itertools = "0.10.5" +convert_case = "0.6.0" [profile.release] lto = true diff --git a/src/bot/admin.rs b/src/bot/admin.rs new file mode 100644 index 0000000..b8b919b --- /dev/null +++ b/src/bot/admin.rs @@ -0,0 +1,35 @@ +use super::{Context, Result}; +use crate::bot::player::{self, Players}; +use crate::send_ctx; + +#[poise::command(slash_command, category = "Configuration", rename = "add_admin")] +/// make somebody a admin +pub async fn add( + ctx: Context<'_>, + #[description = "The player to make admin"] + #[autocomplete = "player::autocomplete"] + player: String, +) -> Result<()> { + let player = Players::find(&ctx.data().stdin, player) + .await + .unwrap() + .unwrap(); + send_ctx!(ctx, "admin add {}", player.uuid)?; + Ok(()) +} + +#[poise::command(slash_command, category = "Configuration", rename = "remove_admin")] +/// remove the admin status +pub async fn remove( + ctx: Context<'_>, + #[description = "The player to remove admin status from"] + #[autocomplete = "player::autocomplete"] + player: String, +) -> Result<()> { + let player = Players::find(&ctx.data().stdin, player) + .await + .unwrap() + .unwrap(); + send_ctx!(ctx, "admin remove {}", player.uuid)?; + Ok(()) +} diff --git a/src/bot/bans.rs b/src/bot/bans.rs new file mode 100644 index 0000000..96d101d --- /dev/null +++ b/src/bot/bans.rs @@ -0,0 +1,36 @@ +use super::{Context, Result}; +use crate::bot::player::{self, Players}; +use crate::{return_next, send_ctx}; + +#[poise::command(slash_command, category = "Control", rename = "ban")] +/// ban a player by uuid and ip +pub async fn add( + ctx: Context<'_>, + #[description = "player to ban"] + #[autocomplete = "player::autocomplete"] + player: String, +) -> Result<()> { + let _ = ctx.defer().await; + let player = Players::find(&ctx.data().stdin, player) + .await + .unwrap() + .unwrap(); + send_ctx!(ctx, "ban ip {}", player.ip)?; + send_ctx!(ctx, "ban id {}", player.uuid)?; + return_next!(ctx) +} + +#[poise::command(slash_command, category = "Control", rename = "unban")] +/// unban a player by uuid or ip +pub async fn remove( + ctx: Context<'_>, + #[description = "Player id/ip"] + #[rename = "ip_or_id"] + player: String, +) -> Result<()> { + let _ = ctx.defer().await; + send_ctx!(ctx, "unban {}", player)?; + return_next!(ctx) +} + +// TODO: listbans diff --git a/src/bot/config.rs b/src/bot/config.rs new file mode 100644 index 0000000..a68a905 --- /dev/null +++ b/src/bot/config.rs @@ -0,0 +1,55 @@ +use super::{Context, Result}; +use crate::{return_next, send_ctx}; +use convert_case::{Case, Casing}; +use futures_util::StreamExt; + +const ITEMS: &'static [&'static str] = &[ + "autoUpdate", + "showConnectMessages", + "enableVotekick", + "startCommands", + "logging", + "strict", + "antiSpam", + "interactRateWindow", + "interactRateKick", + "messageRateLimit", + "messageSpamKick", + "packetSpamLimit", + "chatSpamLimit", + "socketInput", + "socketInputPort", + "socketInputAddress", + "allowCustomClients", + "whitelist", + "motd", + "autosave", + "autosaveAmount", + "debug", + "snapshotInterval", + "autoPause", +]; + +async fn complete<'a>( + _ctx: Context<'_>, + partial: &'a str, +) -> impl futures::Stream<Item = String> + 'a { + futures::stream::iter(ITEMS) + .filter(move |name| futures::future::ready(name.starts_with(partial))) + .map(|name| name.from_case(Case::Camel).to_case(Case::Lower)) +} + +#[poise::command(slash_command, category = "Configuration", rename = "config")] +/// change a setting +pub async fn set( + ctx: Context<'_>, + #[autocomplete = "complete"] + #[description = "setting to change"] + setting: String, + #[description = "the value"] config: String, +) -> Result<()> { + let setting = setting.from_case(Case::Lower).to_case(Case::Camel); + send_ctx!(ctx, "config {setting} {config}")?; + return_next!(ctx) +} +// TODO: config::list diff --git a/src/bot/js.rs b/src/bot/js.rs new file mode 100644 index 0000000..38f839d --- /dev/null +++ b/src/bot/js.rs @@ -0,0 +1,49 @@ +use super::{Context, Result}; +use crate::{return_next, send_ctx}; +use minify_js::TopLevelMode; +use regex::Regex; +use std::sync::LazyLock; + +fn parse_js(from: &str) -> Result<String> { + static RE: LazyLock<Regex> = + LazyLock::new(|| Regex::new(r#"```(js|javascript)?([^`]+)```"#).unwrap()); + let mat = RE + .captures(&from) + .ok_or(anyhow::anyhow!(r#"no code found (use \`\`\`js...\`\`\`."#))?; + let script = mat.get(2).unwrap().as_str(); + let mut out = vec![]; + Ok( + if minify_js::minify(TopLevelMode::Global, script.into(), &mut out).is_ok() { + String::from_utf8_lossy(&out).to_string() + } else { + script.replace('\n', ";") + }, + ) +} + +#[poise::command( + prefix_command, + required_permissions = "USE_SLASH_COMMANDS", + category = "Control", + track_edits, + rename = "js" +)] +/// run arbitrary javascript +pub async fn run( + ctx: Context<'_>, + #[description = "Script"] + #[rest] + script: String, +) -> Result<()> { + let _ = ctx.channel_id().start_typing(&ctx.serenity_context().http); + let script = parse_js(&script)?; + send_ctx!(ctx, "js {script}")?; + return_next!(ctx) +} + +#[test] +fn test_parse_js() { + assert!( + parse_js("```js\nLog.info(4)\nLog.info(4+2)\n```").unwrap() == "Log.info(4);Log.info(4+ 2)" // hmm + ) +} diff --git a/src/bot/maps.rs b/src/bot/maps.rs index ba330d7..807f8df 100644 --- a/src/bot/maps.rs +++ b/src/bot/maps.rs @@ -1,4 +1,4 @@ -use super::{get_nextblock, strip_colors, Context}; +use super::{get_nextblock, strip_colors, Context, Result, SUCCESS}; use crate::send; use futures_util::StreamExt; use tokio::sync::broadcast; @@ -39,3 +39,26 @@ pub async fn autocomplete<'a>( .filter(move |name| futures::future::ready(name.starts_with(partial))) .map(|name| name.to_string()) } + +#[poise::command( + slash_command, + prefix_command, + required_permissions = "USE_SLASH_COMMANDS", + category = "Info", + rename = "maps" +)] +/// lists the maps. +pub async fn list(ctx: Context<'_>) -> Result<()> { + let _ = ctx.defer_or_broadcast().await; + let maps = Maps::get_all(&ctx.data().stdin).await; + poise::send_reply(ctx, |m| { + m.embed(|e| { + for (k, v) in maps.iter().enumerate() { + e.field((k + 1).to_string(), v, true); + } + e.description("map list.").color(SUCCESS) + }) + }) + .await?; + Ok(()) +} diff --git a/src/bot/mod.rs b/src/bot/mod.rs index 7386f2b..a6d56e7 100644 --- a/src/bot/mod.rs +++ b/src/bot/mod.rs @@ -1,18 +1,19 @@ +mod admin; +mod bans; +mod config; +mod js; mod maps; mod player; +mod status; use crate::webhook::Webhook; -use anyhow::{anyhow, Result}; -use itertools::Itertools; +use anyhow::Result; use maps::Maps; -use minify_js::TopLevelMode; -use player::Players; -use regex::Regex; -use serenity::http::{CacheHttp, Http}; + +use serenity::http::Http; use serenity::prelude::*; use std::fs::read_to_string; -use std::str::FromStr; -use std::sync::{Arc, LazyLock, Mutex, OnceLock}; +use std::sync::{Arc, Mutex, OnceLock}; use tokio::sync::broadcast; pub struct Data { @@ -28,6 +29,7 @@ macro_rules! send { }; } +#[macro_export] macro_rules! send_ctx { ($e:expr,$fmt:literal $(, $args:expr)* $(,)?) => { $e.data().stdin.send(format!($fmt $(, $args)*)) @@ -51,14 +53,15 @@ impl Bot { commands: vec![ raw(), say(), - ban(), - unban(), - add_admin(), - remove_admin(), - js(), - maps(), - players(), - status(), + bans::add(), + bans::remove(), + admin::add(), + admin::remove(), + js::run(), + maps::list(), + player::list(), + status::command(), + config::set(), start(), end(), help(), @@ -99,7 +102,8 @@ type Context<'a> = poise::Context<'a, Data, anyhow::Error>; #[poise::command( prefix_command, required_permissions = "USE_SLASH_COMMANDS", - category = "Control" + category = "Control", + track_edits )] /// send a raw command to the server async fn raw( @@ -109,15 +113,15 @@ async fn raw( cmd: String, ) -> Result<()> { send_ctx!(ctx, "{cmd}")?; - println!("sent"); Ok(()) } +#[macro_export] macro_rules! return_next { ($ctx:expr) => {{ - let line = get_nextblock().await; + let line = crate::bot::get_nextblock().await; $ctx.send(|m| m.content(line)).await?; - Ok(()) + return Ok(()); }}; } @@ -141,69 +145,6 @@ async fn say(ctx: Context<'_>, #[description = "Message"] message: String) -> Re return_next!(ctx) } -#[poise::command(slash_command, category = "Control")] -/// ban a player by uuid and ip -async fn ban( - ctx: Context<'_>, - #[description = "player to ban"] - #[autocomplete = "player::autocomplete"] - player: String, -) -> Result<()> { - let _ = ctx.defer().await; - let player = Players::find(&ctx.data().stdin, player) - .await - .unwrap() - .unwrap(); - send_ctx!(ctx, "ban ip {}", player.ip)?; - send_ctx!(ctx, "ban id {}", player.uuid)?; - return_next!(ctx) -} - -#[poise::command(slash_command, category = "Control")] -/// unban a player by uuid or ip -async fn unban( - ctx: Context<'_>, - #[description = "Player id/ip"] - #[rename = "ip_or_id"] - player: String, -) -> Result<()> { - let _ = ctx.defer().await; - send_ctx!(ctx, "unban {}", player)?; - return_next!(ctx) -} - -#[poise::command( - prefix_command, - required_permissions = "USE_SLASH_COMMANDS", - category = "Control", - track_edits -)] -/// run arbitrary javascript -async fn js( - ctx: Context<'_>, - #[description = "Script"] - #[rest] - script: String, -) -> Result<()> { - static RE: LazyLock<Regex> = - LazyLock::new(|| Regex::new(r#"```(js|javascript)?([^`]+)```"#).unwrap()); - let _ = ctx.channel_id().start_typing(&ctx.serenity_context().http); - let mat = RE - .captures(&script) - .ok_or(anyhow::anyhow!(r#"no code found (use \`\`\`js...\`\`\`."#))?; - let script = mat.get(2).unwrap().as_str(); - let mut out = vec![]; - let script = if minify_js::minify(TopLevelMode::Global, script.into(), &mut out).is_err() { - std::borrow::Cow::from(script.replace('\n', ";")) // xd - } else { - String::from_utf8_lossy(&out) - }; - send_ctx!(ctx, "js {script}")?; - let line = get_nextblock().await; - ctx.send(|m| m.content(line)).await?; - Ok(()) -} - fn strip_colors(from: &str) -> String { let mut result = String::new(); result.reserve(from.len()); @@ -223,98 +164,6 @@ fn strip_colors(from: &str) -> String { #[poise::command( slash_command, required_permissions = "USE_SLASH_COMMANDS", - category = "Info" -)] -/// lists the maps. -pub async fn maps(ctx: Context<'_>) -> Result<()> { - let _ = ctx.defer().await; - let maps = Maps::get_all(&ctx.data().stdin).await; - poise::send_reply(ctx, |m| { - m.embed(|e| { - for (k, v) in maps.iter().enumerate() { - e.field((k + 1).to_string(), v, true); - } - e.description("map list.").color(SUCCESS) - }) - }) - .await?; - Ok(()) -} - -#[poise::command(prefix_command, slash_command, category = "Info")] -/// server status. -pub async fn status(ctx: Context<'_>) -> Result<()> { - let _ = ctx.defer_or_broadcast().await; - send_ctx!(ctx, "status")?; - let block = tokio::select! { - block = get_nextblock() => block, - _ = async_std::task::sleep(std::time::Duration::from_secs(5)) => - { poise::send_reply(ctx, |m| m.embed(|e| e.title("server down").color(FAIL))).await?; return Ok(()) }, - }; - let (tps, mem, pcount) = block - .split('/') - .map(|s| u32::from_str(s.trim().split_once(' ').unwrap().0).unwrap()) - .collect_tuple() - .ok_or(anyhow!("couldnt split block"))?; - poise::send_reply(ctx, |m| { - m.embed(|e| { - if pcount > 0 { - e.footer(|f| f.text("see /players for player list")); - } - e.title("server online") - .field("tps", tps, true) - .field("memory use", mem, true) - .field("players", pcount, true) - .color(SUCCESS) - }) - }) - .await?; - Ok(()) -} - -#[poise::command(slash_command, prefix_command, category = "Info")] -/// lists the currently online players. -pub async fn players(ctx: Context<'_>) -> Result<()> { - let _ = ctx.defer().await; - let players = Players::get_all(&ctx.data().stdin).await.unwrap().clone(); - let perms = ctx - .partial_guild() - .await - .unwrap() - .member_permissions(ctx.http(), ctx.author().id) - .await?; - // let perms = ctx - // .author_member() - // .await - // .ok_or(anyhow!("couldnt get perms"))? - // .permissions(ctx.cache().unwrap())?; - poise::send_reply(ctx, |m| { - m.embed(|e| { - if players.is_empty() { - return e.title("no players online.").color(FAIL); - } - e.fields(players.into_iter().map(|p| { - let admins = if p.admin { " [A]" } else { "" }; - ( - p.name, - if perms.use_slash_commands() { - format!("{id}, {ip}", id = p.uuid, ip = p.ip) + admins - } else { - admins.to_string() - }, - true, - ) - })); - e.description("currently online players.").color(SUCCESS) - }) - }) - .await?; - Ok(()) -} - -#[poise::command( - slash_command, - required_permissions = "USE_SLASH_COMMANDS", category = "Control" )] /// start the game. @@ -350,49 +199,11 @@ pub async fn end( return_next!(ctx) } -#[poise::command(slash_command, category = "Configuration")] -/// make somebody a admin -pub async fn add_admin( - ctx: Context<'_>, - #[description = "The player to make admin"] - #[autocomplete = "player::autocomplete"] - player: String, -) -> Result<()> { - let player = Players::find(&ctx.data().stdin, player) - .await - .unwrap() - .unwrap(); - send_ctx!(ctx, "admin add {}", player.uuid)?; - Ok(()) -} - -#[poise::command(slash_command, category = "Configuration")] -/// remove the admin status -pub async fn remove_admin( - ctx: Context<'_>, - #[description = "The player to remove admin status from"] - #[autocomplete = "player::autocomplete"] - player: String, -) -> Result<()> { - let player = Players::find(&ctx.data().stdin, player) - .await - .unwrap() - .unwrap(); - send_ctx!(ctx, "admin remove {}", player.uuid)?; - Ok(()) -} - -#[poise::command( - prefix_command, - slash_command, - required_permissions = "USE_SLASH_COMMANDS", - track_edits, - category = "Info" -)] +#[poise::command(prefix_command, slash_command, track_edits, category = "Info")] /// show help and stuff pub async fn help( ctx: Context<'_>, - #[description = "Specific command to show help about"] + #[description = "command to show help about"] #[autocomplete = "poise::builtins::autocomplete_command"] command: Option<String>, ) -> Result<()> { diff --git a/src/bot/player.rs b/src/bot/player.rs index 29b534d..f0d6a0f 100644 --- a/src/bot/player.rs +++ b/src/bot/player.rs @@ -1,8 +1,9 @@ -use super::{get_nextblock, strip_colors, Context}; +use super::{get_nextblock, strip_colors, Context, FAIL, SUCCESS}; use crate::send; use anyhow::Result; use futures_util::StreamExt; use itertools::Itertools; +use serenity::http::CacheHttp; use std::net::Ipv4Addr; use std::str::FromStr; use std::time::Instant; @@ -82,3 +83,38 @@ pub async fn autocomplete<'a>( .filter(move |p| futures::future::ready(p.name.starts_with(partial))) .map(|p| p.name) } + +#[poise::command(slash_command, prefix_command, category = "Info", rename = "players")] +/// lists the currently online players. +pub async fn list(ctx: Context<'_>) -> Result<()> { + let _ = ctx.defer().await; + let players = Players::get_all(&ctx.data().stdin).await.unwrap().clone(); + let perms = ctx + .partial_guild() + .await + .unwrap() + .member_permissions(ctx.http(), ctx.author().id) + .await?; + poise::send_reply(ctx, |m| { + m.embed(|e| { + if players.is_empty() { + return e.title("no players online.").color(FAIL); + } + e.fields(players.into_iter().map(|p| { + let admins = if p.admin { " [A]" } else { "" }; + ( + p.name, + if perms.use_slash_commands() { + format!("{id}, {ip}", id = p.uuid, ip = p.ip) + admins + } else { + admins.to_string() + }, + true, + ) + })); + e.description("currently online players.").color(SUCCESS) + }) + }) + .await?; + Ok(()) +} diff --git a/src/bot/status.rs b/src/bot/status.rs new file mode 100644 index 0000000..6926ab0 --- /dev/null +++ b/src/bot/status.rs @@ -0,0 +1,91 @@ +use super::{get_nextblock, Context, FAIL, SUCCESS}; +use crate::send_ctx; +use anyhow::Result; +use itertools::Itertools; +use std::str::FromStr; + +fn parse(line: &str) -> Option<(u32, u32, u32)> { + line.split('/') + .map(|s| u32::from_str(s.trim().split_once(' ').unwrap().0).unwrap()) + .collect_tuple() +} + +#[allow(dead_code)] +pub enum Size { + Gb(f64), + Mb(f64), + Kb(f64), + B(f64), +} +const UNIT: f64 = 1024.0; + +impl Size { + pub fn bytes(self) -> f64 { + match self { + Self::B(x) => x, + Self::Kb(x) => x * UNIT, + Self::Mb(x) => x * UNIT * UNIT, + Self::Gb(x) => x * UNIT * UNIT * UNIT, + } + } +} +// https://git.sr.ht/~f9/human_bytes +pub fn humanize_bytes<T: Into<Size>>(bytes: T) -> String { + const SUFFIX: [&str; 4] = ["B", "KB", "MB", "GB"]; + let size = dbg!(bytes.into().bytes()); + + if size <= 0.0 { + return "0 B".to_string(); + } + + let base = size.log10() / UNIT.log10(); + + let result = format!("{:.1}", UNIT.powf(base - base.floor()),) + .trim_end_matches(".0") + .to_owned(); + + // Add suffix + [&result, SUFFIX[base.floor() as usize]].join(" ") +} + +#[poise::command(prefix_command, slash_command, category = "Info", rename = "status")] +/// server status. +pub async fn command(ctx: Context<'_>) -> Result<()> { + let _ = ctx.defer_or_broadcast().await; + send_ctx!(ctx, "status")?; + let block = tokio::select! { + block = get_nextblock() => block, + _ = async_std::task::sleep(std::time::Duration::from_secs(5)) => + { poise::send_reply(ctx, |m| m.embed(|e| e.title("server down").color(FAIL))).await?; return Ok(()) }, + }; + let (tps, mem, pcount) = + parse(&block).ok_or(anyhow::anyhow!("couldnt split block {block}."))?; + poise::send_reply(ctx, |m| { + m.embed(|e| { + if pcount > 0 { + e.footer(|f| f.text("see /players for player list")); + } + e.title("server online") + .field("tps", tps, true) + .field("memory use", humanize_bytes(Size::Mb(mem as f64)), true) + .field("players", pcount, true) + .color(SUCCESS) + }) + }) + .await?; + Ok(()) +} + +#[test] +fn test_parse() { + assert!(parse("57 TPS / 274 MB / 7 PLAYERS") == Some((57, 274, 7))); +} + +#[test] +fn test_bytes() { + assert!(humanize_bytes(Size::B(0.0)) == "0 B"); + assert!(humanize_bytes(Size::B(550.0)) == "550 B"); + assert!(humanize_bytes(Size::Kb(550.0)) == "550 KB"); + assert!(humanize_bytes(Size::Mb(650.0)) == "650 MB"); + assert!(humanize_bytes(Size::Gb(15.3)) == "15.3 GB"); +} |