html terminal
better banning
| -rw-r--r-- | Cargo.lock | 29 | ||||
| -rw-r--r-- | Cargo.toml | 5 | ||||
| -rw-r--r-- | src/bot/maps.rs | 41 | ||||
| -rw-r--r-- | src/bot/mod.rs (renamed from src/bot.rs) | 202 | ||||
| -rw-r--r-- | src/bot/player.rs | 85 | ||||
| -rw-r--r-- | src/main.rs | 1 | ||||
| -rw-r--r-- | src/server.rs | 2 | ||||
| -rw-r--r-- | src/webhook.rs | 1 |
8 files changed, 249 insertions, 117 deletions
@@ -726,12 +726,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" [[package]] -name = "heck" -version = "0.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "95505c38b4572b2d910cecb0281560f54b440a19336cbbcb27bf6ce6adc6f5a8" - -[[package]] name = "hermit-abi" version = "0.2.6" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -1092,7 +1086,6 @@ dependencies = [ "regex", "serenity", "strip-ansi-escapes", - "strum", "tokio", "tokio-stream", ] @@ -1643,28 +1636,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "73473c0e59e6d5812c5dfe2a064a6444949f089e20eec9a2e5506596494e4623" [[package]] -name = "strum" -version = "0.24.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "063e6045c0e62079840579a7e47a355ae92f60eb74daaf156fb1e84ba164e63f" -dependencies = [ - "strum_macros", -] - -[[package]] -name = "strum_macros" -version = "0.24.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e385be0d24f186b4ce2f9982191e7101bb737312ad61c1f2f984f34bcf85d59" -dependencies = [ - "heck", - "proc-macro2", - "quote", - "rustversion", - "syn 1.0.109", -] - -[[package]] name = "syn" version = "1.0.109" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -39,11 +39,6 @@ serenity = { version = "0.11.5", features = [ ], default-features = false } poise = "0.5.5" anyhow = "1.0.71" -strum = { version = "0.24.1", features = [ - "strum_macros", - "derive", - "std", -], default-features = false } regex = { version = "1.8.4", features = ["std"], default-features = false } minify-js = "0.4.3" diff --git a/src/bot/maps.rs b/src/bot/maps.rs new file mode 100644 index 0000000..ba330d7 --- /dev/null +++ b/src/bot/maps.rs @@ -0,0 +1,41 @@ +use super::{get_nextblock, strip_colors, Context}; +use crate::send; +use futures_util::StreamExt; +use tokio::sync::broadcast; +use tokio::sync::OnceCell; + +pub struct Maps; +impl Maps { + pub async fn find(map: &str, stdin: &broadcast::Sender<String>) -> usize { + Self::get_all(stdin) + .await + .iter() + .position(|r| r == map) + .unwrap() + } + + pub async fn get_all(stdin: &broadcast::Sender<String>) -> &Vec<String> { + static MAPS: OnceCell<Vec<String>> = OnceCell::const_new(); + MAPS.get_or_init(|| async move { + send!(stdin, "maps").unwrap(); + let res = get_nextblock().await; + let mut vec = vec![]; + for line in res.lines() { + if let Some((_, name)) = line.split_once(':') { + vec.push(strip_colors(name)); + } + } + vec + }) + .await + } +} + +pub async fn autocomplete<'a>( + ctx: Context<'a>, + partial: &'a str, +) -> impl futures::Stream<Item = String> + 'a { + futures::stream::iter(Maps::get_all(&ctx.data().stdin).await) + .filter(move |name| futures::future::ready(name.starts_with(partial))) + .map(|name| name.to_string()) +} diff --git a/src/bot.rs b/src/bot/mod.rs index e7de0f4..760a290 100644 --- a/src/bot.rs +++ b/src/bot/mod.rs @@ -1,35 +1,62 @@ +mod maps; +mod player; + use crate::webhook::Webhook; use anyhow::Result; -use futures_util::StreamExt; +use maps::Maps; use minify_js::TopLevelMode; +use player::Players; use regex::Regex; use serenity::http::Http; use serenity::prelude::*; use std::fs::read_to_string; use std::sync::{Arc, Mutex, OnceLock}; -use strum::{AsRefStr, EnumString, EnumVariantNames, VariantNames}; use tokio::sync::broadcast; -use tokio::sync::OnceCell as TokLock; + pub struct Data { stdin: broadcast::Sender<String>, } static SKIPPING: OnceLock<(Arc<Mutex<u8>>, broadcast::Sender<String>)> = OnceLock::new(); +#[macro_export] +macro_rules! send { + ($e:expr, $fmt:literal $(, $args:expr)* $(,)?) => { + $e.send(format!($fmt $(, $args)*)) + }; +} + +macro_rules! send_ctx { + ($e:expr,$fmt:literal $(, $args:expr)* $(,)?) => { + $e.data().stdin.send(format!($fmt $(, $args)*)) + }; +} + pub struct Bot; impl Bot { - pub async fn new(stdout: broadcast::Receiver<String>, stdin: broadcast::Sender<String>) { + pub async fn spawn(stdout: broadcast::Receiver<String>, stdin: broadcast::Sender<String>) { let tok = std::env::var("TOKEN").unwrap_or(read_to_string("token").expect("wher token")); let f: poise::FrameworkBuilder<Data, anyhow::Error> = poise::Framework::builder() .options(poise::FrameworkOptions { - commands: vec![raw(), say(), ban(), js(), maps(), start(), end(), help()], + commands: vec![ + raw(), + say(), + ban(), + unban(), + js(), + maps(), + players(), + start(), + end(), + help(), + ], prefix_options: poise::PrefixFrameworkOptions { prefix: Some(">".to_string()), ..Default::default() }, ..Default::default() }) - .token(&tok) + .token(tok) .intents(GatewayIntents::GUILD_MESSAGES | GatewayIntents::MESSAGE_CONTENT) .setup(|ctx, _ready, framework| { Box::pin(async move { @@ -56,7 +83,7 @@ type Context<'a> = poise::Context<'a, Data, anyhow::Error>; #[poise::command( prefix_command, required_permissions = "USE_SLASH_COMMANDS", - category = "ADMINISTRATION" + category = "Control" )] /// send a raw command to the server async fn raw( @@ -65,7 +92,7 @@ async fn raw( #[rest] cmd: String, ) -> Result<()> { - ctx.data().stdin.send(cmd)?; + send_ctx!(ctx, "{cmd}")?; println!("sent"); Ok(()) } @@ -90,49 +117,49 @@ async fn get_nextblock() -> String { .unwrap_or("._?".to_string()) } -#[poise::command(slash_command, category = "ADMINISTRATION")] +#[poise::command(slash_command, category = "Control")] /// say something as the server async fn say(ctx: Context<'_>, #[description = "Message"] message: String) -> Result<()> { - let _ = ctx.defer(); + let _ = ctx.defer().await; ctx.data().stdin.send(format!("say {message}"))?; return_next!(ctx) } -#[derive(EnumString, EnumVariantNames, AsRefStr)] -#[strum(serialize_all = "snake_case")] -enum BanType { - Id, - Ip, - Name, -} - -async fn autocomplete_ban<'a>( - _ctx: Context<'_>, - partial: &'a str, -) -> impl futures::Stream<Item = String> + 'a { - futures::stream::iter(BanType::VARIANTS) - .filter(move |name| futures::future::ready(name.starts_with(partial))) - .map(|name| name.to_string()) +#[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 = "ADMINISTRATION")] -/// ban a player -async fn ban( +#[poise::command(slash_command, category = "Control")] +/// unban a player by uuid or ip +async fn unban( ctx: Context<'_>, - #[autocomplete = "autocomplete_ban"] ban_type: BanType, - #[description = "Player (id/ip/name)"] player: String, + #[description = "Player id/ip"] + #[rename = "ip_or_id"] + player: String, ) -> Result<()> { - let _ = ctx.defer(); - ctx.data() - .stdin - .send(format!("ban {} {player}", ban_type.as_ref()))?; + let _ = ctx.defer().await; + send_ctx!(ctx, "unban {}", player)?; return_next!(ctx) } #[poise::command( prefix_command, required_permissions = "USE_SLASH_COMMANDS", - category = "ADMINISTRATION", + category = "Control", track_edits )] /// run arbitrary javascript @@ -150,12 +177,12 @@ async fn js( .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 let Err(_) = minify_js::minify(TopLevelMode::Global, script.into(), &mut out) { + 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) }; - ctx.data().stdin.send(format!("js {script}"))?; + send_ctx!(ctx, "js {script}")?; let line = get_nextblock().await; ctx.send(|m| m.content(line)).await?; Ok(()) @@ -177,81 +204,92 @@ fn strip_colors(from: &str) -> String { result } -static MAPS: TokLock<Vec<String>> = TokLock::const_new(); -async fn get_maps(stdin: &broadcast::Sender<String>) -> &Vec<String> { - MAPS.get_or_init(|| async move { - stdin.send(format!("listmaps")).unwrap(); - let res = get_nextblock().await; - let mut vec = vec![]; - for line in res.lines() { - if let Some((_, name)) = line.split_once(':') { - vec.push(strip_colors(name)); - } - } - vec - }) - .await -} - -#[poise::command(slash_command, required_permissions = "USE_SLASH_COMMANDS")] +#[poise::command( + slash_command, + required_permissions = "USE_SLASH_COMMANDS", + category = "Control" +)] /// lists the maps. pub async fn maps(ctx: Context<'_>) -> Result<()> { - let _ = ctx.defer(); - let maps = get_maps(&ctx.data().stdin).await; + 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.") + e.description("map list.").color((34, 139, 34)) }) }) .await?; Ok(()) } -async fn autocomplete_map<'a>( - ctx: Context<'a>, - partial: &'a str, -) -> impl futures::Stream<Item = String> + 'a { - futures::stream::iter(get_maps(&ctx.data().stdin).await) - .filter(move |name| futures::future::ready(name.starts_with(partial))) - .map(|name| name.to_string()) -} - -async fn mapi(map: &str, stdin: &broadcast::Sender<String>) -> usize { - get_maps(stdin).await.iter().position(|r| r == map).unwrap() +#[poise::command( + slash_command, + required_permissions = "USE_SLASH_COMMANDS", + category = "Control" +)] +/// 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(); + poise::send_reply(ctx, |m| { + m.embed(|e| { + if players.is_empty() { + return e.title("no players online.").color((255, 69, 0)); + } + e.fields(players.into_iter().map(|p| { + ( + p.name, + format!("{id}, {ip}", id = p.uuid, ip = p.ip) + + if p.admin { " [A]" } else { "" }, + true, + ) + })); + e.description("currently online players.") + .color((255, 165, 0)) + }) + }) + .await?; + Ok(()) } -#[poise::command(slash_command, required_permissions = "USE_SLASH_COMMANDS")] +#[poise::command( + slash_command, + required_permissions = "USE_SLASH_COMMANDS", + category = "Control" +)] /// start the game. pub async fn start( ctx: Context<'_>, #[description = "the map"] - #[autocomplete = "autocomplete_map"] + #[autocomplete = "maps::autocomplete"] map: String, ) -> Result<()> { - let _ = ctx.defer(); - ctx.data() - .stdin - .send(format!("plague {}", mapi(&map, &ctx.data().stdin).await)) - .unwrap(); + let _ = ctx.defer().await; + send_ctx!(ctx, "host {}", Maps::find(&map, &ctx.data().stdin).await)?; return_next!(ctx) } -#[poise::command(slash_command, required_permissions = "USE_SLASH_COMMANDS")] +#[poise::command( + slash_command, + category = "Control", + required_permissions = "USE_SLASH_COMMANDS" +)] /// end the game. pub async fn end( ctx: Context<'_>, #[description = "the map to go to"] - #[autocomplete = "autocomplete_map"] + #[autocomplete = "maps::autocomplete"] map: String, ) -> Result<()> { - let _ = ctx.defer(); - ctx.data() - .stdin - .send(format!("endplague {}", mapi(&map, &ctx.data().stdin).await)) - .unwrap(); + let _ = ctx.defer().await; + send_ctx!( + ctx, + "gameover {}", + Maps::find(&map, &ctx.data().stdin).await + )?; return_next!(ctx) } diff --git a/src/bot/player.rs b/src/bot/player.rs new file mode 100644 index 0000000..590f9e4 --- /dev/null +++ b/src/bot/player.rs @@ -0,0 +1,85 @@ +use super::{get_nextblock, strip_colors, Context}; +use crate::send; +use anyhow::Result; +use futures_util::StreamExt; +use std::net::Ipv4Addr; +use std::str::FromStr; +use std::time::Instant; +use tokio::sync::{broadcast, MappedMutexGuard, Mutex, MutexGuard}; + +#[derive(Clone, Debug)] +pub struct Player { + pub admin: bool, + pub name: String, + pub uuid: String, + pub ip: Ipv4Addr, +} + +static PLAYERS: Mutex<(Vec<Player>, Option<Instant>)> = Mutex::const_new((vec![], None)); + +async fn update( + stdin: &broadcast::Sender<String>, +) -> Result<MutexGuard<(Vec<Player>, Option<Instant>)>> { + let mut lock = PLAYERS.lock().await; + if lock.1.is_none() || lock.1.unwrap().elapsed().as_millis() > 500 { + lock.0 = get_players(stdin).await?; + lock.1 = Some(Instant::now()); + } + Ok(lock) +} +pub struct Players {} +impl Players { + pub async fn get_all( + stdin: &broadcast::Sender<String>, + ) -> Result<MappedMutexGuard<Vec<Player>>> { + { + Ok(MutexGuard::map(update(stdin).await?, |(p, _)| p)) + } + } + + pub async fn find( + stdin: &broadcast::Sender<String>, + name: String, + ) -> Result<Option<MappedMutexGuard<Player>>> { + Ok(MutexGuard::try_map(update(stdin).await?, |(p, _)| { + p.iter_mut().find(|x| x.name == name) + }) + .ok()) + } +} + +async fn get_players(stdin: &broadcast::Sender<String>) -> Result<Vec<Player>> { + let mut players = vec![]; + send!(stdin, "players")?; + let recv = get_nextblock().await; + for line in recv.lines() { + if line.starts_with("No") { + break; + } else if line.is_empty() { + continue; + } + let split = line.split('/').collect::<Vec<&str>>(); + if split.len() != 3 { + continue; + } + if let Some((admin, name)) = split[0].split_once(' ') { + players.push(Player { + admin: admin == "[A]", + name: strip_colors(name), + uuid: split[1].to_owned(), + ip: Ipv4Addr::from_str(split[2]).unwrap(), + }) + } + } + Ok(players) +} + +pub async fn autocomplete<'a>( + ctx: Context<'a>, + partial: &'a str, +) -> impl futures::Stream<Item = String> + 'a { + let x = Players::get_all(&ctx.data().stdin).await.unwrap().clone(); + futures::stream::iter(x) + .filter(move |p| futures::future::ready(p.name.starts_with(partial))) + .map(|p| p.name) +} diff --git a/src/main.rs b/src/main.rs index 48ce3d6..0510033 100644 --- a/src/main.rs +++ b/src/main.rs @@ -4,6 +4,7 @@ use std::str::FromStr; #[macro_use] mod logging; +#[macro_use] mod bot; mod process; mod server; diff --git a/src/server.rs b/src/server.rs index 6e0a274..9f8dbd1 100644 --- a/src/server.rs +++ b/src/server.rs @@ -92,7 +92,7 @@ impl Server { .unwrap() }); let mut process_handle = proc.input(stdin).with_state(&state).link(); - Bot::new(state.stdout_plain.subscribe(), state.stdin.clone()).await; + Bot::spawn(state.stdout_plain.subscribe(), state.stdin.clone()).await; tokio::select! { _ = (&mut server_handle) => process_handle.abort(), _ = (&mut process_handle) => server_handle.abort(), diff --git a/src/webhook.rs b/src/webhook.rs index 6412a8f..7ebc069 100644 --- a/src/webhook.rs +++ b/src/webhook.rs @@ -27,6 +27,7 @@ impl<'a> Webhook<'a> { for<'b> F: FnOnce(&'b mut ExecuteWebhook<'a>) -> &'b mut ExecuteWebhook<'a>, { let mut execute_webhook = ExecuteWebhook::default(); + execute_webhook.allowed_mentions(|m| m.empty_parse()); block(&mut execute_webhook); let map = json::hashmap_to_json_map(execute_webhook.0); |