html terminal
admin add commands
| -rw-r--r-- | .gitignore | 1 | ||||
| -rw-r--r-- | Cargo.lock | 29 | ||||
| -rw-r--r-- | Cargo.toml | 11 | ||||
| -rw-r--r-- | build.rs | 57 | ||||
| -rw-r--r-- | html-src/index.html (renamed from html/index.html) | 0 | ||||
| -rw-r--r-- | html-src/panel.html (renamed from html/panel.html) | 0 | ||||
| -rw-r--r-- | src/bot/mod.rs | 130 | ||||
| -rw-r--r-- | src/bot/player.rs | 21 | ||||
| -rw-r--r-- | src/main.rs | 2 | ||||
| -rw-r--r-- | src/server.rs | 47 | ||||
| -rw-r--r-- | src/webhook.rs | 68 | ||||
| -rw-r--r-- | src/websocket.rs | 108 |
12 files changed, 306 insertions, 168 deletions
@@ -1,2 +1,3 @@ /target *token +html/ @@ -227,9 +227,6 @@ dependencies = [ "pin-project-lite", "rustversion", "serde", - "serde_json", - "serde_path_to_error", - "serde_urlencoded", "sha1", "sync_wrapper", "tokio", @@ -494,6 +491,12 @@ dependencies = [ ] [[package]] +name = "either" +version = "1.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7fcaabb2fef8c910e7f4c7ce9f67a1283a1715879a7c230ca9d6d1ae31f16d91" + +[[package]] name = "encoding_rs" version = "0.8.32" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -887,6 +890,15 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "12b6ee2129af8d4fb011108c73d99a1b83a85977f23b82460c0ae2e25bb4b57f" [[package]] +name = "itertools" +version = "0.10.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b0fd2260e829bddf4cb6ea802289de2f86d6a7a690192fbe91b3f46e0f2c8473" +dependencies = [ + "either", +] + +[[package]] name = "itoa" version = "1.0.6" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -1079,6 +1091,7 @@ dependencies = [ "axum", "futures", "futures-util", + "itertools", "minify-html", "minify-js", "paste", @@ -1506,15 +1519,6 @@ dependencies = [ ] [[package]] -name = "serde_path_to_error" -version = "0.1.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f7f05c1d5476066defcdfacce1f52fc3cae3af1d3089727100c02ae92e5abbe0" -dependencies = [ - "serde", -] - -[[package]] name = "serde_urlencoded" version = "0.7.1" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -1826,7 +1830,6 @@ dependencies = [ "tokio", "tower-layer", "tower-service", - "tracing", ] [[package]] @@ -13,9 +13,8 @@ axum = { version = "0.6.18", features = [ "tokio", "http1", "matched-path", -] } +], default-features = false } futures = "0.3.28" -minify-html = "0.11.1" paste = "1.0.12" tokio = { version = "1.28.2", features = [ "macros", @@ -24,24 +23,26 @@ tokio = { version = "1.28.2", features = [ "rt-multi-thread", "process", "parking_lot", -] } +], default-features = false } tokio-stream = "0.1.14" futures-util = "0.3.28" strip-ansi-escapes = "0.1.1" serenity = { version = "0.11.5", features = [ "builder", "client", - # "framework", "utils", "rustls_backend", "gateway", - # "standard_framework", ], default-features = false } poise = "0.5.5" anyhow = "1.0.71" regex = { version = "1.8.4", features = ["std"], default-features = false } minify-js = "0.4.3" +itertools = "0.10.5" [profile.release] lto = true strip = true + +[build-dependencies] +minify-html = "0.11.1" diff --git a/build.rs b/build.rs new file mode 100644 index 0000000..77d8311 --- /dev/null +++ b/build.rs @@ -0,0 +1,57 @@ +#![feature(utf8_chunks)] +use std::fs; +use std::io::prelude::*; +use std::path::Path; + +use minify_html::{minify, Cfg}; + +/// like [String::from_utf8_lossy] but instead of being lossy it panics +pub fn from_utf8(v: &[u8]) -> &str { + let mut iter = std::str::Utf8Chunks::new(v); + if let Some(chunk) = iter.next() { + let valid = chunk.valid(); + if chunk.invalid().is_empty() { + debug_assert_eq!(valid.len(), v.len()); + return valid; + } + } else { + return ""; + }; + unreachable!("invalid utf8") +} + +pub fn process(input: impl AsRef<Path>) -> std::io::Result<()> { + let mut f = fs::File::create(dbg!(Path::new("html").join(input.as_ref()))).unwrap(); + let mut buf = vec![]; + fs::File::open(Path::new("html-src").join(input.as_ref()))?.read_to_end(&mut buf)?; + let minified = minify( + &buf, + &Cfg { + minify_js: true, + minify_css: true, + ..Default::default() + }, + ); + let minified = from_utf8(&minified); + let minified = minified.replace( + "ws://localhost:4001/connect/", + &format!( + "{}", + std::env::var("URL").unwrap_or("ws://localhost:4001/connect/".to_string()) + ), + ); + f.write_all(minified.as_bytes()) +} + +fn main() -> std::io::Result<()> { + if !Path::new("html").exists() { + std::fs::create_dir("html")?; + } + + for path in fs::read_dir("html-src")? { + process(path.unwrap().path().file_name().unwrap())?; + } + println!("cargo:rerun-if-changed=html-src/"); + println!("cargo:rerun-if-changed=build.rs"); + Ok(()) +} diff --git a/html/index.html b/html-src/index.html index bbf9f63..bbf9f63 100644 --- a/html/index.html +++ b/html-src/index.html diff --git a/html/panel.html b/html-src/panel.html index 982b465..982b465 100644 --- a/html/panel.html +++ b/html-src/panel.html diff --git a/src/bot/mod.rs b/src/bot/mod.rs index 760a290..7386f2b 100644 --- a/src/bot/mod.rs +++ b/src/bot/mod.rs @@ -2,15 +2,17 @@ mod maps; mod player; use crate::webhook::Webhook; -use anyhow::Result; +use anyhow::{anyhow, Result}; +use itertools::Itertools; use maps::Maps; use minify_js::TopLevelMode; use player::Players; use regex::Regex; -use serenity::http::Http; +use serenity::http::{CacheHttp, Http}; use serenity::prelude::*; use std::fs::read_to_string; -use std::sync::{Arc, Mutex, OnceLock}; +use std::str::FromStr; +use std::sync::{Arc, LazyLock, Mutex, OnceLock}; use tokio::sync::broadcast; pub struct Data { @@ -32,6 +34,14 @@ macro_rules! send_ctx { }; } +#[cfg(not(debug_assertions))] +const PFX: &'static str = ">"; +#[cfg(debug_assertions)] +const PFX: &'static str = "-"; + +const SUCCESS: (u8, u8, u8) = (34, 139, 34); +const FAIL: (u8, u8, u8) = (255, 69, 0); + pub struct Bot; impl Bot { pub async fn spawn(stdout: broadcast::Receiver<String>, stdin: broadcast::Sender<String>) { @@ -43,15 +53,21 @@ impl Bot { say(), ban(), unban(), + add_admin(), + remove_admin(), js(), maps(), players(), + status(), start(), end(), help(), ], prefix_options: poise::PrefixFrameworkOptions { - prefix: Some(">".to_string()), + edit_tracker: Some(poise::EditTracker::for_timespan( + std::time::Duration::from_secs(2 * 60), + )), + prefix: Some(PFX.to_string()), ..Default::default() }, ..Default::default() @@ -169,10 +185,10 @@ async fn js( #[rest] script: String, ) -> Result<()> { - static RE: OnceLock<Regex> = OnceLock::new(); + static RE: LazyLock<Regex> = + LazyLock::new(|| Regex::new(r#"```(js|javascript)?([^`]+)```"#).unwrap()); let _ = ctx.channel_id().start_typing(&ctx.serenity_context().http); - let re = RE.get_or_init(|| Regex::new(r#"```(js|javascript)?([^`]+)```"#).unwrap()); - let mat = re + let mat = RE .captures(&script) .ok_or(anyhow::anyhow!(r#"no code found (use \`\`\`js...\`\`\`."#))?; let script = mat.get(2).unwrap().as_str(); @@ -207,7 +223,7 @@ fn strip_colors(from: &str) -> String { #[poise::command( slash_command, required_permissions = "USE_SLASH_COMMANDS", - category = "Control" + category = "Info" )] /// lists the maps. pub async fn maps(ctx: Context<'_>) -> Result<()> { @@ -218,37 +234,78 @@ pub async fn maps(ctx: Context<'_>) -> Result<()> { for (k, v) in maps.iter().enumerate() { e.field((k + 1).to_string(), v, true); } - e.description("map list.").color((34, 139, 34)) + e.description("map list.").color(SUCCESS) }) }) .await?; Ok(()) } -#[poise::command( - slash_command, - required_permissions = "USE_SLASH_COMMANDS", - category = "Control" -)] +#[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((255, 69, 0)); + return e.title("no players online.").color(FAIL); } e.fields(players.into_iter().map(|p| { + let admins = if p.admin { " [A]" } else { "" }; ( p.name, - format!("{id}, {ip}", id = p.uuid, ip = p.ip) - + if p.admin { " [A]" } else { "" }, + 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((255, 165, 0)) + e.description("currently online players.").color(SUCCESS) }) }) .await?; @@ -293,11 +350,44 @@ 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 + track_edits, + category = "Info" )] /// show help and stuff pub async fn help( diff --git a/src/bot/player.rs b/src/bot/player.rs index 590f9e4..29b534d 100644 --- a/src/bot/player.rs +++ b/src/bot/player.rs @@ -2,6 +2,7 @@ use super::{get_nextblock, strip_colors, Context}; use crate::send; use anyhow::Result; use futures_util::StreamExt; +use itertools::Itertools; use std::net::Ipv4Addr; use std::str::FromStr; use std::time::Instant; @@ -58,17 +59,15 @@ async fn get_players(stdin: &broadcast::Sender<String>) -> Result<Vec<Player>> { } 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(), - }) + if let Some((first, uuid, ip)) = line.split('/').collect_tuple() { + if let Some((admin, name)) = first.split_once(' ') { + players.push(Player { + admin: admin == "[A]", + name: strip_colors(name), + uuid: uuid.to_owned(), + ip: Ipv4Addr::from_str(ip).unwrap(), + }) + } } } Ok(players) diff --git a/src/main.rs b/src/main.rs index 0510033..ee1fc78 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,4 +1,4 @@ -#![feature(utf8_chunks)] +#![feature(lazy_cell)] use std::str::FromStr; diff --git a/src/server.rs b/src/server.rs index 9f8dbd1..3426530 100644 --- a/src/server.rs +++ b/src/server.rs @@ -12,12 +12,7 @@ use axum::{ Router, Server as AxumServer, }; use futures::sink::SinkExt; -use minify_html::{minify, Cfg}; -use paste::paste; -use std::{ - net::SocketAddr, - sync::{Arc, OnceLock}, -}; +use std::{net::SocketAddr, sync::Arc}; use tokio::sync::broadcast; pub struct State { @@ -42,21 +37,14 @@ impl State { macro_rules! html { ($file:expr) => { - get(paste!( - || async { - static [<$file:upper>]: OnceLock<Vec<u8>> = OnceLock::new(); - Html(from_utf8([<$file:upper>].get_or_init(|| { - minify( - include_bytes!(concat!("../html/", stringify!($file), ".html")), - &Cfg { - minify_js: true, - minify_css: true, - ..Default::default() - }, - ) - })).replace("ws://localhost:4001/connect/", &format!("{}", std::env::var("URL").unwrap_or("ws://localhost:4001/connect/".to_string())))) - } - )) + get(|| async { + let ret: Html<&'static [u8]> = Html(include_bytes!(concat!( + "../html/", + stringify!($file), + ".html" + ))); + ret + }) }; } @@ -101,21 +89,6 @@ impl Server { } } -/// like [String::from_utf8_lossy] but instead of being lossy it panics -pub fn from_utf8(v: &[u8]) -> &str { - let mut iter = std::str::Utf8Chunks::new(v); - if let Some(chunk) = iter.next() { - let valid = chunk.valid(); - if chunk.invalid().is_empty() { - debug_assert_eq!(valid.len(), v.len()); - return valid; - } - } else { - return ""; - }; - unreachable!("invalid utf8") -} - fn matches(id: &str) -> bool { std::env::var("ID").as_deref().unwrap_or("4") == id } @@ -131,6 +104,6 @@ async fn connect_ws( let _ = s.send(Message::Text("correct id".to_string())).await; return; } - WebSocket::new(socket, state).await.wait().await; + tokio::spawn(WebSocket::spawn(socket, state)); }) } diff --git a/src/webhook.rs b/src/webhook.rs index 7ebc069..c83e64c 100644 --- a/src/webhook.rs +++ b/src/webhook.rs @@ -1,7 +1,9 @@ +use itertools::Itertools; use poise::serenity_prelude::Webhook as RealHook; +use regex::Regex; use serenity::{builder::ExecuteWebhook, http::Http, json}; use std::convert::AsRef; -use std::sync::{Arc, Mutex}; +use std::sync::{Arc, LazyLock, Mutex}; use std::time::{Duration, Instant}; use tokio::sync::broadcast::{self, error::TryRecvError}; @@ -154,40 +156,52 @@ macro_rules! s { $line.starts_with($e) }; } + +macro_rules! tern { + ($predicate:expr, $true: expr, $false: expr) => {{ + if $predicate { + $true + } else { + $false + } + }}; +} pub struct MindustryStyle; impl OutputStyle for MindustryStyle { fn split(line: &str) -> Option<(String, String)> { - if s!(line, [' ', '\t']) || s!(line, "at") { + if s!(line, [' ', '\t']) || s!(line, "at") || s!(line, "Lost command socket connection") { return None; } - if line.chars().filter(|x| x == &':').count() == 1 { - if let Some((u, c)) = line.split_once(':') { - let u = unify(u).trim_start_matches('<').trim().to_owned(); - let c = unify(c).trim_end_matches('>').trim().to_owned(); - if !(u.is_empty() || c.is_empty()) { - return Some((u, c)); - } + + if let Some((u, c)) = line.split(": ").map(|s| unify(s)).collect_tuple() { + let u = u.trim_start_matches('<'); + let c = c.trim_end_matches('>'); + if !(u.is_empty() || c.is_empty()) { + return Some((u.to_owned(), c.to_owned())); } } - if let Some(index) = line.find("has") { - if line.contains("connected") { - let player = &line[..index]; - let prefix = if line.contains("disconnected") { - "left" - } else { - "joined" - }; - return Some((unify(player).trim().to_owned(), prefix.to_owned())); - } + + static REGEX: LazyLock<Regex> = LazyLock::new(|| { + Regex::new(r#"(.+) has (dis)?connected. \[([a-zA-Z0-9+/]+==)\]"#).unwrap() + }); + if let Some(captures) = REGEX.captures(line) { + let player = unify(captures.get(1).unwrap().as_str()); + let prefix = tern!(captures.get(2).is_some(), "left", "joined"); + let uuid = captures.get(3).unwrap().as_str(); + return Some((player, format!("{prefix} ({uuid})"))); } None } } -/// latin > extended a > kill fn unify(s: &str) -> String { s.chars() - .map(|c| if (c as u32) < 384 { c } else { ' ' }) + .filter_map(|c| { + if c > 'џ' { + return None; + } + Some(c) + }) .collect() } @@ -237,6 +251,14 @@ fn style() { //named test_line!("abc: hi", "abc", "hi"); test_line!("<a: /help>", "a", "/help"); - test_line!("a has connected. [abc==]", "a", "joined"); - test_line!("a has disconnected. [abc==] (closed)", "a", "left"); + test_line!("a has connected. [abc==]", "a", "joined (abc==)"); + test_line!("a has disconnected. [abc==] (closed)", "a", "left (abc==)"); + test_line!("a: :o", "a", ":o"); + test_line!("a:b: :o", "a:b", ":o"); +} + +#[test] +fn test_unify() { + assert!(unify("grassྱྊၔ") == "grass"); + assert!(unify("иди к черту") == "иди к черту") } diff --git a/src/websocket.rs b/src/websocket.rs index 75591d4..df614f4 100644 --- a/src/websocket.rs +++ b/src/websocket.rs @@ -7,75 +7,67 @@ use std::{ }; use tokio::{sync::broadcast::error::TryRecvError, task::JoinHandle}; use tokio_stream::StreamExt; - pub struct WebSocket(JoinHandle<()>); impl WebSocket { - pub async fn new(stream: RealWebSocket, state: Arc<State>) -> Self { + pub async fn spawn(stream: RealWebSocket, state: Arc<State>) { let (mut sender, mut reciever) = futures::stream::StreamExt::split(stream); let mut stdout = state.stdout_html.subscribe(); - let ws_task = tokio::spawn(async move { - dummy_print!("websocket"); - let mut last: Option<Instant> = None; - let mut waiting: usize = 0; - loop { - let out = stdout.try_recv(); - let now = Instant::now(); - match out { - Err(e) => match e { - TryRecvError::Closed => fail!("closed"), - TryRecvError::Lagged(_) => continue, // no delay - _ => { - if let Some(earlier) = last { - let since = now.duration_since(earlier).as_millis(); - if since > 200 || waiting > 15 { - last.take(); - sender.flush().await.unwrap(); - waiting = 0; - flush!(); - } + dummy_print!("websocket"); + let mut last: Option<Instant> = None; + let mut waiting: usize = 0; + loop { + let out = stdout.try_recv(); + let now = Instant::now(); + match out { + Err(e) => match e { + TryRecvError::Closed => fail!("closed"), + TryRecvError::Lagged(_) => continue, // no delay + _ => { + if let Some(earlier) = last { + let since = now.duration_since(earlier).as_millis(); + if since > 200 || waiting > 15 { + last.take(); + sender.flush().await.unwrap(); + waiting = 0; + flush!(); } } - }, - Ok(m) => { - #[allow(unused_variables)] - for line in m.lines() { - input!("{line}"); - if let Err(e) = sender.feed(Message::Text(line.to_owned())).await { - fail!("{e}"); - }; - waiting += 1; - } - last = Some(now); } - } - match tokio::select! { - next = reciever.next() => next, - _ = async_std::task::sleep(Duration::from_millis(20)) =>continue, - } { - Some(r) => match r { - Ok(m) => { - if let Message::Text(m) = m { - output!("{m}"); - state.stdin.send(m).unwrap(); - } - } - #[allow(unused_variables)] - Err(e) => { + }, + Ok(m) => { + #[allow(unused_variables)] + for line in m.lines() { + input!("{line}"); + if let Err(e) = sender.feed(Message::Text(line.to_owned())).await { fail!("{e}"); + }; + waiting += 1; + } + last = Some(now); + } + } + match tokio::select! { + next = reciever.next() => next, + _ = async_std::task::sleep(Duration::from_millis(20)) => continue, + } { + Some(r) => match r { + Ok(m) => { + if let Message::Text(m) = m { + output!("{m}"); + state.stdin.send(m).unwrap(); } - }, - None => { - nooutput!(); } + #[allow(unused_variables)] + Err(e) => { + fail!("{e}"); + } + }, + None => { + nooutput!(); } - async_std::task::sleep(Duration::from_millis(20)).await; - continue; } - }); - Self(ws_task) - } - - pub async fn wait(self) { - self.0.await.unwrap() + async_std::task::sleep(Duration::from_millis(20)).await; + continue; + } } } |