html terminal
persist votes
| -rw-r--r-- | .gitignore | 2 | ||||
| -rw-r--r-- | Cargo.lock | 305 | ||||
| -rw-r--r-- | Cargo.toml | 5 | ||||
| -rw-r--r-- | src/bot/config.rs | 2 | ||||
| -rw-r--r-- | src/bot/js.rs | 2 | ||||
| -rw-r--r-- | src/bot/maps.rs | 2 | ||||
| -rw-r--r-- | src/bot/mod.rs | 58 | ||||
| -rw-r--r-- | src/bot/player.rs | 2 | ||||
| -rw-r--r-- | src/bot/status.rs | 12 | ||||
| -rw-r--r-- | src/bot/voting.rs | 690 | ||||
| -rw-r--r-- | src/main.rs | 1 | ||||
| -rw-r--r-- | src/process.rs | 21 | ||||
| -rw-r--r-- | src/server.rs | 17 | ||||
| -rw-r--r-- | src/webhook.rs | 30 |
14 files changed, 618 insertions, 531 deletions
@@ -1,3 +1,5 @@ /target *token html/ +# vote data +**.vd @@ -65,107 +65,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "23b62fc65de8e4e7f52534fb52b0f3ed04746ae267519eef2a83941e8085068b" [[package]] -name = "async-channel" -version = "1.8.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cf46fee83e5ccffc220104713af3292ff9bc7c64c7de289f66dae8e38d826833" -dependencies = [ - "concurrent-queue", - "event-listener", - "futures-core", -] - -[[package]] -name = "async-executor" -version = "1.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6fa3dc5f2a8564f07759c008b9109dc0d39de92a88d5588b8a5036d286383afb" -dependencies = [ - "async-lock", - "async-task", - "concurrent-queue", - "fastrand", - "futures-lite", - "slab", -] - -[[package]] -name = "async-global-executor" -version = "2.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f1b6f5d7df27bd294849f8eec66ecfc63d11814df7a4f5d74168a2394467b776" -dependencies = [ - "async-channel", - "async-executor", - "async-io", - "async-lock", - "blocking", - "futures-lite", - "once_cell", -] - -[[package]] -name = "async-io" -version = "1.13.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0fc5b45d93ef0529756f812ca52e44c221b35341892d3dcc34132ac02f3dd2af" -dependencies = [ - "async-lock", - "autocfg", - "cfg-if", - "concurrent-queue", - "futures-lite", - "log", - "parking", - "polling", - "rustix", - "slab", - "socket2", - "waker-fn", -] - -[[package]] -name = "async-lock" -version = "2.7.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fa24f727524730b077666307f2734b4a1a1c57acb79193127dcc8914d5242dd7" -dependencies = [ - "event-listener", -] - -[[package]] -name = "async-std" -version = "1.12.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "62565bb4402e926b29953c785397c6dc0391b7b446e45008b0049eb43cec6f5d" -dependencies = [ - "async-channel", - "async-global-executor", - "async-io", - "async-lock", - "crossbeam-utils", - "futures-channel", - "futures-core", - "futures-io", - "futures-lite", - "gloo-timers", - "kv-log-macro", - "log", - "memchr", - "once_cell", - "pin-project-lite", - "pin-utils", - "slab", - "wasm-bindgen-futures", -] - -[[package]] -name = "async-task" -version = "4.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ecc7ab41815b3c653ccd2978ec3255c81349336702dfdf62ee6f7069b12a3aae" - -[[package]] name = "async-trait" version = "0.1.68" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -193,12 +92,6 @@ dependencies = [ ] [[package]] -name = "atomic-waker" -version = "1.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1181e1e0d1fce796a03db1ae795d67167da795f9cf4a39c37589e85ef57f26d3" - -[[package]] name = "autocfg" version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -278,19 +171,10 @@ dependencies = [ ] [[package]] -name = "blocking" -version = "1.3.1" +name = "btparse" +version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "77231a1c8f801696fc0123ec6150ce92cffb8e164a02afb9c8ddee0e9b65ad65" -dependencies = [ - "async-channel", - "async-lock", - "async-task", - "atomic-waker", - "fastrand", - "futures-lite", - "log", -] +checksum = "ca8fb3f04d0ef6cb7d39262d6c2bad0eec0c00071083ceddeb8bbcf693ff28a6" [[package]] name = "bumpalo" @@ -336,15 +220,6 @@ dependencies = [ ] [[package]] -name = "concurrent-queue" -version = "2.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "62ec6771ecfa0762d24683ee5a32ad78487a3d3afdc0fb8cae19d2c5deb50b7c" -dependencies = [ - "crossbeam-utils", -] - -[[package]] name = "convert_case" version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -384,15 +259,6 @@ dependencies = [ ] [[package]] -name = "crossbeam-utils" -version = "0.8.16" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5a22b2d63d4d1dc0b7f1b6b2747dd0088008a9be28b6ddf0b1e7d335e3037294" -dependencies = [ - "cfg-if", -] - -[[package]] name = "crypto-common" version = "0.1.6" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -512,42 +378,6 @@ dependencies = [ ] [[package]] -name = "errno" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4bcfec3a70f97c962c307b2d2c56e358cf1d00b558d74262b5f929ee8cc7e73a" -dependencies = [ - "errno-dragonfly", - "libc", - "windows-sys", -] - -[[package]] -name = "errno-dragonfly" -version = "0.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "aa68f1b12764fab894d2755d2518754e71b4fd80ecfb822714a1206c2aab39bf" -dependencies = [ - "cc", - "libc", -] - -[[package]] -name = "event-listener" -version = "2.5.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0206175f82b8d6bf6652ff7d71a1e27fd2e4efde587fd368662814d6ec1d9ce0" - -[[package]] -name = "fastrand" -version = "1.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e51093e27b0797c359783294ca4f0a911c270184cb10f85783b118614a1501be" -dependencies = [ - "instant", -] - -[[package]] name = "flate2" version = "1.0.26" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -621,21 +451,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4fff74096e71ed47f8e023204cfd0aa1289cd54ae5430a9523be060cdb849964" [[package]] -name = "futures-lite" -version = "1.13.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "49a9d51ce47660b1e808d3c990b4709f2f415d928835a17dfd16991515c46bce" -dependencies = [ - "fastrand", - "futures-core", - "futures-io", - "memchr", - "parking", - "pin-project-lite", - "waker-fn", -] - -[[package]] name = "futures-macro" version = "0.3.28" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -698,18 +513,6 @@ dependencies = [ ] [[package]] -name = "gloo-timers" -version = "0.2.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9b995a66bb87bebce9a0f4a95aed01daca4872c050bfcb21653361c03bc35e5c" -dependencies = [ - "futures-channel", - "futures-core", - "js-sys", - "wasm-bindgen", -] - -[[package]] name = "h2" version = "0.3.19" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -754,12 +557,6 @@ dependencies = [ ] [[package]] -name = "hermit-abi" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fed44880c466736ef9a5c5b5facefb5ed0785676d0c02d612db14e54f0d84286" - -[[package]] name = "http" version = "0.2.9" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -880,26 +677,6 @@ dependencies = [ ] [[package]] -name = "instant" -version = "0.1.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7a5bbe824c507c5da5956355e86a746d82e0e1464f65d862cc5e71da70e94b2c" -dependencies = [ - "cfg-if", -] - -[[package]] -name = "io-lifetimes" -version = "1.0.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eae7b9aee968036d54dce06cebaefd919e4472e753296daccd6d344e3e2df0c2" -dependencies = [ - "hermit-abi 0.3.1", - "libc", - "windows-sys", -] - -[[package]] name = "ipnet" version = "2.7.2" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -930,15 +707,6 @@ dependencies = [ ] [[package]] -name = "kv-log-macro" -version = "1.0.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0de8b303297635ad57c9f5059fd9cee7a47f8e8daa09df0fcd07dd39fb22977f" -dependencies = [ - "log", -] - -[[package]] name = "lazy_static" version = "1.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -951,12 +719,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f92be4933c13fd498862a9e02a3055f8a8d9c039ce33db97306fd5a6caa7f29b" [[package]] -name = "linux-raw-sys" -version = "0.3.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ef53942eb7bf7ff43a617b3e2c1c4a5ecf5944a7c1bc12d7ee39bbb15e5c1519" - -[[package]] name = "lock_api" version = "0.4.10" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -971,9 +733,6 @@ name = "log" version = "0.4.19" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b06a4cde4c0f271a446782e3eff8de789548ce57dbc8eca9292c27f4a42004b4" -dependencies = [ - "value-bag", -] [[package]] name = "matchit" @@ -1156,7 +915,7 @@ version = "1.15.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0fac9e2da13b5eb447a6ce3d392f23a29d8694bff781bf03a16cd9ac8697593b" dependencies = [ - "hermit-abi 0.2.6", + "hermit-abi", "libc", ] @@ -1180,8 +939,8 @@ name = "panel" version = "0.1.0" dependencies = [ "anyhow", - "async-std", "axum", + "btparse", "convert_case 0.6.0", "futures", "futures-util", @@ -1192,6 +951,8 @@ dependencies = [ "paste", "poise", "regex", + "serde", + "serde_json", "serenity", "strip-ansi-escapes", "tokio", @@ -1199,12 +960,6 @@ dependencies = [ ] [[package]] -name = "parking" -version = "2.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "14f2252c834a40ed9bb5422029649578e63aa341ac401f74e719dd1afda8394e" - -[[package]] name = "parking_lot" version = "0.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -1338,22 +1093,6 @@ dependencies = [ ] [[package]] -name = "polling" -version = "2.8.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4b2d323e8ca7996b3e23126511a523f7e62924d93ecd5ae73b333815b0eb3dce" -dependencies = [ - "autocfg", - "bitflags", - "cfg-if", - "concurrent-queue", - "libc", - "log", - "pin-project-lite", - "windows-sys", -] - -[[package]] name = "ppv-lite86" version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -1506,20 +1245,6 @@ dependencies = [ ] [[package]] -name = "rustix" -version = "0.37.20" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b96e891d04aa506a6d1f318d2771bcb1c7dfda84e126660ace067c9b474bb2c0" -dependencies = [ - "bitflags", - "errno", - "io-lifetimes", - "libc", - "linux-raw-sys", - "windows-sys", -] - -[[package]] name = "rustls" version = "0.20.8" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -1945,9 +1670,9 @@ dependencies = [ [[package]] name = "tracing-attributes" -version = "0.1.24" +version = "0.1.25" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0f57e3ca2a01450b1a921183a9c9cbfda207fd822cef4ccb00a65402cbba7a74" +checksum = "8803eee176538f94ae9a14b55b2804eb7e1441f8210b1c31290b3bccdccff73b" dependencies = [ "proc-macro2", "quote", @@ -2069,12 +1794,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "711b9620af191e0cdc7468a8d14e709c3dcdb115b36f838e601583af800a370a" [[package]] -name = "value-bag" -version = "1.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a4d330786735ea358f3bc09eea4caa098569c1c93f342d9aca0514915022fe7e" - -[[package]] name = "version_check" version = "0.9.4" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -2102,12 +1821,6 @@ dependencies = [ ] [[package]] -name = "waker-fn" -version = "1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9d5b2c62b4012a3e1eca5a7e077d13b3bf498c4073e33ccd58626607748ceeca" - -[[package]] name = "want" version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -6,7 +6,6 @@ edition = "2021" # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html [dependencies] -async-std = "1.12.0" axum = { version = "0.6.18", features = [ "tokio", "http1", @@ -19,6 +18,7 @@ tokio = { version = "1.28.2", features = [ "sync", "rt-multi-thread", "parking_lot", + "time", ], default-features = false } tokio-stream = "0.1.14" futures-util = "0.3.28" @@ -37,6 +37,9 @@ minify-js = "0.5.6" itertools = "0.10.5" convert_case = "0.6.0" parse_duration = "2.1.1" +serde = "1.0" +serde_json = "1.0" +btparse = "0.1.1" [profile.release] lto = true diff --git a/src/bot/config.rs b/src/bot/config.rs index a68a905..78a0da3 100644 --- a/src/bot/config.rs +++ b/src/bot/config.rs @@ -3,7 +3,7 @@ use crate::{return_next, send_ctx}; use convert_case::{Case, Casing}; use futures_util::StreamExt; -const ITEMS: &'static [&'static str] = &[ +const ITEMS: &[&str] = &[ "autoUpdate", "showConnectMessages", "enableVotekick", diff --git a/src/bot/js.rs b/src/bot/js.rs index 1486f11..0b67e33 100644 --- a/src/bot/js.rs +++ b/src/bot/js.rs @@ -8,7 +8,7 @@ fn parse_js(from: &str) -> Result<String> { static RE: LazyLock<Regex> = LazyLock::new(|| Regex::new(r#"```(js|javascript)?([^`]+)```"#).unwrap()); let mat = RE - .captures(&from) + .captures(from) .ok_or(anyhow::anyhow!(r#"no code found (use \`\`\`js...\`\`\`."#))?; let script = mat.get(2).unwrap().as_str(); let mut out = vec![]; diff --git a/src/bot/maps.rs b/src/bot/maps.rs index 9c62168..758cae0 100644 --- a/src/bot/maps.rs +++ b/src/bot/maps.rs @@ -37,7 +37,7 @@ pub async fn autocomplete<'a>( ) -> 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()) + .map(ToString::to_string) } #[poise::command( diff --git a/src/bot/mod.rs b/src/bot/mod.rs index 9c3f6d8..a559dee 100644 --- a/src/bot/mod.rs +++ b/src/bot/mod.rs @@ -17,6 +17,7 @@ use std::fs::read_to_string; use std::sync::{Arc, Mutex, OnceLock}; use tokio::sync::broadcast; +#[derive(Debug)] pub struct Data { stdin: broadcast::Sender<String>, vote_data: voting::Votes, @@ -41,7 +42,7 @@ macro_rules! send_ctx { #[cfg(not(debug_assertions))] const PFX: &'static str = ">"; #[cfg(debug_assertions)] -const PFX: &'static str = "-"; +const PFX: &str = "-"; const SUCCESS: (u8, u8, u8) = (34, 139, 34); const FAIL: (u8, u8, u8) = (255, 69, 0); @@ -66,24 +67,13 @@ impl Bot { status::command(), config::set(), voting::create(), + voting::fixall(), + voting::list(), start(), end(), help(), ], - on_error: |e| { - Box::pin(async move { - e.ctx() - .unwrap() - .send(|b| { - b.embed(|e| { - e.color(FAIL) - .description("oy <@696196765564534825> i broke") - }) - }) - .await - .unwrap(); - }) - }, + on_error: |e| Box::pin(on_error(e)), prefix_options: poise::PrefixFrameworkOptions { edit_tracker: Some(poise::EditTracker::for_timespan( std::time::Duration::from_secs(2 * 60), @@ -103,6 +93,7 @@ impl Bot { stdin, vote_data: voting::Votes::new(vec![]), }) + // todo: voting::fixall() auto }) }); @@ -112,12 +103,45 @@ impl Bot { SKIPPING.get_or_init(|| (wh.skip.clone(), wh.skipped.clone())); wh.link(stdout).await; }); - f.run().await.unwrap() + f.run().await.unwrap(); } } type Context<'a> = poise::Context<'a, Data, anyhow::Error>; +async fn on_error(error: poise::FrameworkError<'_, Data, anyhow::Error>) { + use poise::FrameworkError::Command; + match error { + Command { error, ctx } => { + ctx.say(format!("e: `{error}`")).await.unwrap(); + if let Ok(n) = std::env::var("RUST_LIB_BACKTRACE") { + use std::str::FromStr; + if let Ok(n) = u8::from_str(&n) { + if n == 1 { + let mut parsed = btparse::deserialize(dbg!(error.backtrace())).unwrap(); + let mut s = vec![]; + for frame in &mut parsed.frames { + if let Some(line) = frame.line.take() { + if frame.function.contains("panel") + || frame.function.contains("poise") + || frame.function.contains("serenity") + { + s.push(format!("l{}@{}", line, frame.function)); + } + } + } + s.truncate(15); + ctx.say(format!("trace: ```rs\n{}\n```", s.join("\n"))) + .await + .unwrap(); + } + } + } + } + err => poise::builtins::on_error(err).await.unwrap(), + } +} + #[poise::command( prefix_command, required_permissions = "ADMINISTRATOR", @@ -138,7 +162,7 @@ async fn raw( #[macro_export] macro_rules! return_next { ($ctx:expr) => {{ - let line = crate::bot::get_nextblock().await; + let line = $crate::bot::get_nextblock().await; $ctx.send(|m| m.content(line)).await?; return Ok(()); }}; diff --git a/src/bot/player.rs b/src/bot/player.rs index 26b2dd5..9039485 100644 --- a/src/bot/player.rs +++ b/src/bot/player.rs @@ -67,7 +67,7 @@ async fn get_players(stdin: &broadcast::Sender<String>) -> Result<Vec<Player>> { name: strip_colors(name), uuid: uuid.to_owned(), ip: Ipv4Addr::from_str(ip).unwrap(), - }) + }); } } } diff --git a/src/bot/status.rs b/src/bot/status.rs index 0863e57..ce6d7e4 100644 --- a/src/bot/status.rs +++ b/src/bot/status.rs @@ -3,6 +3,7 @@ use crate::send_ctx; use anyhow::Result; use itertools::Itertools; use std::str::FromStr; +use tokio::time::{sleep, Duration}; fn parse(line: &str) -> Option<(u32, u32, u32)> { let mut v = vec![]; @@ -63,12 +64,10 @@ pub async fn command(ctx: Context<'_>) -> Result<()> { } let block = tokio::select! { block = get_nextblock() => block, - _ = async_std::task::sleep(std::time::Duration::from_secs(5)) => fail!(ctx, FAIL), + _ = sleep(Duration::from_secs(5)) => fail!(ctx, FAIL), }; - let (tps, mem, pcount) = if let Some(t) = parse(&block) { - t - } else { - fail!(ctx, FAIL) + let Some((tps,mem,pcount)) = parse(&block) else { + fail!(ctx, FAIL); }; poise::send_reply(ctx, |m| { m.embed(|e| { @@ -77,7 +76,7 @@ pub async fn command(ctx: Context<'_>) -> Result<()> { } e.title("server online") .field("tps", tps, true) - .field("memory use", humanize_bytes(Size::Mb(mem as f64)), true) + .field("memory use", humanize_bytes(Size::Mb(f64::from(mem))), true) .field("players", pcount, true) .color(SUCCESS) }) @@ -97,5 +96,6 @@ fn test_bytes() { 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::Mb(2000.0)) == "2 GB"); assert!(humanize_bytes(Size::Gb(15.3)) == "15.3 GB"); } diff --git a/src/bot/voting.rs b/src/bot/voting.rs index 090ac73..9fa57a6 100644 --- a/src/bot/voting.rs +++ b/src/bot/voting.rs @@ -1,68 +1,399 @@ use super::{Context, DISABLED, SUCCESS}; use ::serenity::builder::CreateActionRow; use ::serenity::builder::CreateEmbed; +use anyhow::anyhow; use anyhow::Result; use itertools::Itertools; -use poise::serenity_prelude::CollectComponentInteraction as Interaction; use poise::serenity_prelude::*; +use serde::{Deserialize, Serialize}; use std::collections::HashMap; +use std::fs::{read_dir, File}; +use std::io::BufReader; +use std::path::Path; use std::str::FromStr; use std::sync::Mutex; use std::time::SystemTime; +use tokio::time::*; pub type Vote = usize; + +#[derive(Default, Clone, Serialize, Deserialize, Debug)] +struct VoteOptions { + options: Vec<String>, + styles: Vec<ButtonStyle>, + title: String, + fields: HashMap<String, String>, + image: Option<String>, +} + +#[derive(Serialize, Deserialize, Debug, Clone, Default)] +pub struct BeforePushVoteData { + votes: HashMap<UserId, Vote>, + // you can parse this from the message but its easier to do this + deadline: Duration, + #[serde(skip)] + deadline_changed: Duration, + options: VoteOptions, + #[serde(skip)] + reply: Option<Box<Message>>, + id: u64, + mid: MessageId, + cid: ChannelId, + endat: u64, +} + +#[derive(Clone, Debug)] +pub struct AfterPushVoteData { + index: usize, + deadline_changed: Duration, + options: VoteOptions, + id: u64, +} + +macro_rules! read { + ($self:expr,$ctx:expr,$v:expr) => {{ + $v = $ctx.data().vote_data.lock().unwrap(); + match $v.get_mut($self.index).unwrap() { + VoteData::Before(x) => x, + VoteData::After(_) => unreachable!(), + } + }}; +} + +#[derive(Debug)] pub enum VoteData { - Running(HashMap<UserId, Vote>), - Finished(Vec<usize>), + Before(BeforePushVoteData), + After(AfterPushVoteData), +} + +pub type Votes = Mutex<Vec<VoteData>>; + +trait EmbedUtil { + fn imageor<S: ToString>(&mut self, img: Option<S>) -> &mut Self; + fn set_fields<M: IntoIterator<Item = (S, S)>, S: ToString>(&mut self, fields: M) -> &mut Self; +} + +impl EmbedUtil for CreateEmbed { + fn imageor<S: ToString>(&mut self, img: Option<S>) -> &mut Self { + if let Some(iuri) = img { + self.image(iuri) + } else { + self + } + } + + fn set_fields<M: IntoIterator<Item = (S, S)>, S: ToString>(&mut self, fields: M) -> &mut Self { + for (k, v) in fields { + self.field(k, v, false); + } + self + } +} + +macro_rules! votes { + ($self:expr, $ctx:expr, $v:expr) => { + match &mut $self { + VoteData::After(a) => &mut read!(a, $ctx, $v).votes, + VoteData::Before(b) => &mut b.votes, + } + }; } impl VoteData { - pub fn get(&mut self) -> &mut HashMap<UserId, Vote> { + pub fn summarize(&mut self, ctx: &Context<'_>, optcount: usize) -> Vec<usize> { + let mut ret = vec![]; + ret.resize(optcount, 0); + let mut v; + for v in votes!(*self, ctx, v).values() { + ret[*v] += 1; + } + ret + } + + fn deadline(&self) -> Duration { match self { - Self::Running(x) => x, - Self::Finished(_) => unreachable!(), + Self::After(a) => a.deadline_changed, + Self::Before(b) => b.deadline_changed, } } - pub fn summarize_running(&self, optcount: usize) -> Vec<usize> { + fn id(&self) -> u64 { match self { - Self::Running(s) => { - let mut ret = vec![]; - ret.resize(optcount, 0); - for (_, v) in s { - ret[*v] += 1 - } - ret + Self::After(a) => a.id, + Self::Before(b) => b.id, + } + } + + fn options(&self) -> &VoteOptions { + match self { + Self::After(a) => &a.options, + Self::Before(b) => &b.options, + } + } + + fn set_reply(&mut self, ctx: &Context<'_>, reply: Message) { + let mut v; + let mid = reply.id; + let cid = reply.channel_id; + let reply = Some(Box::new(reply)); + match self { + Self::After(a) => { + let read = read!(a, ctx, v); + read.reply = reply; + read.mid = mid; + read.cid = cid; + } + Self::Before(b) => { + b.reply = reply; + b.mid = mid; + b.cid = cid; } - Self::Finished(_) => unreachable!(), } } - pub fn get_summarized(&self) -> &Vec<usize> { + fn get_reply(&mut self, ctx: &Context<'_>) -> Box<Message> { + let mut v; match self { - Self::Finished(ret) => ret, - Self::Running(_) => unreachable!(), + Self::After(a) => read!(a, ctx, v).reply.take().unwrap(), + Self::Before(b) => b.reply.take().unwrap(), } } - pub fn finish(self, optcount: usize) -> Self { - Self::Finished(self.summarize_running(optcount)) + fn set_end(&mut self) { + let end = self.dead_secs(); + match self { + Self::Before(x) => x.endat = end, + _ => unreachable!(), + } } -} -pub type Votes = Mutex<Vec<VoteData>>; + fn dead_secs(&self) -> u64 { + (SystemTime::now() + self.deadline()) + .duration_since(SystemTime::UNIX_EPOCH) + .unwrap() + .as_secs() + } -trait Imgor { - fn imageor<S: ToString>(&mut self, img: Option<S>) -> &mut Self; + fn end_stamp(&self) -> String { + format!("<t:{}:R>", self.dead_secs()) + } + + pub async fn begin(mut self, ctx: Context<'_>) -> Result<Self> { + self.set_end(); + let o = self.options(); + let handle = poise::send_reply(ctx, |m| { + m.embed(|e| { + e.imageor(o.image.as_ref()) + .color(SUCCESS) + .title(&o.title) + .description(format!("vote ends {}", self.end_stamp())) + .set_fields(&o.fields) + }) + .components(|c| { + c.create_action_row(|r| { + for (n, option) in o.options.iter().enumerate() { + r.create_button(|b| { + b.custom_id(format!("{}{n}", self.id())) + .label(option) + .style(o.styles[n]) + }); + } + r + }) + }) + }) + .await?; + let msg = handle.into_message().await?; + self.set_reply(&ctx, msg); + self.push(&ctx).save(&ctx) + } + + fn save_ref(&self, ctx: &Context<'_>) -> Result<()> { + let t = self.options().title.clone() + ".vd"; + let mut re; + let thing = match &self { + Self::Before(x) => x, + Self::After(y) => { + re = ctx.data().vote_data.lock().unwrap(); + match re.get_mut(y.index).unwrap() { + VoteData::Before(x) => x, + VoteData::After(_) => unreachable!(), + } + } + }; + std::fs::write(t, serde_json::to_string(thing)?)?; + Ok(()) + } + + pub fn save(self, ctx: &Context<'_>) -> Result<Self> { + self.save_ref(ctx)?; + Ok(self) + } + + pub fn push(self, ctx: &Context<'_>) -> Self { + let mut data = ctx.data().vote_data.lock().unwrap(); + let n = data.len(); + let v = Self::After(AfterPushVoteData { + index: n, + id: self.id(), + deadline_changed: self.deadline(), + options: self.options().clone(), + }); + data.push(self); + v + } + + fn remove(self, ctx: &Context<'_>) -> Self { + match self { + Self::After(x) => ctx.data().vote_data.lock().unwrap().remove(x.index), + Self::Before(_) => unreachable!(), + } + } + + pub async fn input(mut self, ctx: &Context<'_>) -> Result<Self> { + let dead = self.deadline(); + let ctx_id = self.id(); + let ctx_id_len = ctx_id.to_string().len(); + let o = self.options().clone(); + let timestamp = self.end_stamp(); + while let Some(press) = CollectComponentInteraction::new(ctx) + .filter(move |press| press.data.custom_id.starts_with(&ctx_id.to_string())) + .timeout(dead) + .await + { + let s = { + let mut v; + if votes!(self, ctx, v) + .insert( + press.user.id, + Vote::from_str(&press.data.custom_id[ctx_id_len..]).unwrap(), + ) + .is_some() + { + "updated" + } else { + "voted" + } + }; + self.save_ref(ctx)?; + let (_m, _) = tokio::join!( + press.create_followup_message(ctx, |m| { m.ephemeral(true).content(s) }), + press.create_interaction_response(ctx, |c| { + c.kind(InteractionResponseType::UpdateMessage) + .interaction_response_data(|m| { + m.embed(|e| { + for (option, votes) in + self.summarize(ctx, o.options.len()).iter().enumerate() + { + e.field(&o.options[option], votes, true); + } + e.imageor(o.image.as_ref()) + .color(SUCCESS) + .title(&o.title) + .set_fields(&o.fields) + .description(format!("vote ends {timestamp}")) + }) + }) + }) + ); + // let m = m?; + // let http = ctx.serenity_context().http.clone(); + // tokio::spawn(async move { + // sleep(Duration::from_secs(10)).await; + // m.delete(http).await.unwrap(); + // }); + } + Ok(self) + } + + pub async fn finish(mut self, ctx: &Context<'_>) -> Result<()> { + let o = self.options().clone(); + let filename = format!("{}.vd", o.title); + let p = std::path::Path::new(&filename); + if p.exists() { + let _ = std::fs::remove_file(p); + } + self.get_reply(ctx) + .edit(ctx, |m| { + m.embed(|e| { + for (option, votes) in self + .remove(ctx) + .summarize(ctx, o.options.len()) + .iter() + .enumerate() + { + e.field(&o.options[option], votes, true); + } + e.color(DISABLED) + .title(&o.title) + .imageor(o.image.as_ref()) + .set_fields(o.fields) + .description(format!("vote ended!")) + }) + .components(|c| { + c.set_action_row({ + let mut r = CreateActionRow::default(); + for (n, option) in o.options.iter().enumerate() { + r.create_button(|b| { + b.label(option) + .disabled(true) + .style(o.styles[n]) + .custom_id("_") + }); + } + r + }) + }) + }) + .await?; + Ok(()) + } } -impl Imgor for CreateEmbed { - fn imageor<S: ToString>(&mut self, img: Option<S>) -> &mut Self { - if let Some(iuri) = img { - self.image(iuri) - } else { - self +trait Parsing { + fn parse_pscsp(self) -> HashMap<String, String>; + fn parse_psv(self) -> Vec<String>; + fn parse_psv_to_styles(self) -> Vec<ButtonStyle>; +} + +impl<S> Parsing for S +where + S: AsRef<str>, +{ + fn parse_pscsp(self) -> HashMap<String, String> { + let pair = self.as_ref().split('|'); + let pairs = pair.map(|s| { + s.split(':') + .map(|s| s.trim().to_owned()) + .collect_tuple() + .unwrap() + }); + let mut map = HashMap::new(); + for (k, v) in pairs { + map.insert(k, v); } + map + } + fn parse_psv(self) -> Vec<String> { + self.as_ref() + .split('|') + .map(|s| s.trim().to_owned()) + .collect() + } + fn parse_psv_to_styles(self) -> Vec<ButtonStyle> { + self.as_ref() + .split('|') + .map(|s| { + use ButtonStyle::*; + match s.trim().to_lowercase().as_str() { + // "blue" => Primary, + "gray" => Secondary, + "green" => Success, + "red" => Danger, + _ => Primary, + } + }) + .collect() } } @@ -72,148 +403,185 @@ pub async fn create( ctx: Context<'_>, #[description = "picture url"] image: Option<String>, #[description = "pressables (psv)"] options: String, - #[description = "option styles (psv)"] styles: String, + #[description = "option styles (psv) {blue|gray|green|red}"] styles: Option<String>, #[description = "how long the vote will be up"] length: String, + #[description = "fields (pipe separated colon seperated pairs) (a:b|c:d)"] fields: Option< + String, + >, title: String, ) -> Result<()> { - let ctx_id = ctx.id(); - let options = options.split('|').map(|s| s.trim()).collect_vec(); - let styles = styles - .split('|') - .map(|s| s.trim().to_lowercase()) - .map(|s| { - use ButtonStyle::*; - match s.as_str() { - "blue" => Primary, - "gray" => Secondary, - "green" => Success, - "red" => Danger, - _ => Primary, - } - }) - .collect_vec(); - let dur = if let Ok(t) = parse_duration::parse(&length) { - t - } else { + let options = options.parse_psv(); + let styles = styles.map_or( + vec![ButtonStyle::Primary; options.len()], + Parsing::parse_psv_to_styles, + ); + let fields = fields.as_ref().map_or(HashMap::new(), Parsing::parse_pscsp); + let Ok(dur) = parse_duration::parse(&length) else { ctx.say(format!("`{length}` is not time")).await?; return Ok(()); }; - let end = format!( - "<t:{}:R>", - (SystemTime::now() + dur) - .duration_since(SystemTime::UNIX_EPOCH)? - .as_secs() - ); - macro_rules! update_msg { - // give me unhygenic macros - ($m:expr, $ctx:expr, $image:expr, $title:expr, $options:expr, $ctx_id:expr, $n:expr) => { - $m.embed(|e| { - for (option, votes) in $ctx.data().vote_data.lock().unwrap()[$n] - .summarize_running($options.len()) - .iter() - .enumerate() - { - e.field(&$options[option], votes, true); - } - e.imageor($image.as_ref()) - .color(SUCCESS) - .title(&$title) - .description(format!("vote ends {end}")) - }) - }; + + VoteData::Before({ + BeforePushVoteData { + votes: HashMap::new(), + deadline: dur, + deadline_changed: dur, + options: VoteOptions { + options, + styles, + title, + fields, + image, + }, + reply: None, + id: ctx.id(), + ..Default::default() + } + }) + .begin(ctx) + .await? + .input(&ctx) + .await? + .finish(&ctx) + .await +} + +async fn fix(ctx: &Context<'_>, data: BufReader<std::fs::File>) -> Result<()> { + let mut v: BeforePushVoteData = serde_json::from_reader(data)?; + let m = ctx.http().get_message(v.cid.0, v.mid.0).await?; + let end = dbg!(m.timestamp.unix_timestamp()) as u64; + v.reply = Some(Box::new(m)); + let now = SystemTime::now() + .duration_since(SystemTime::UNIX_EPOCH) + .unwrap() + .as_secs(); + let end = end + dbg!(v.deadline.as_secs()); + println!("@{now} | :{end}"); + // cant use abs() because unsigned + v.deadline_changed = if now < end { + Duration::from_secs(end - now) + } else { + Duration::from_secs(now - end) + }; + let v = VoteData::Before(v); + if end < now { + v.push(&ctx).finish(&ctx).await + } else { + v.push(&ctx).input(&ctx).await?.finish(&ctx).await } - let n = { - let mut data = ctx.data().vote_data.lock().unwrap(); - let n = data.len(); - data.push(VoteData::Running(HashMap::new())); - n +} + +#[poise::command( + slash_command, + category = "Discord", + required_permissions = "ADMINISTRATOR" +)] +pub async fn fixall(ctx: Context<'_>) -> Result<()> { + use futures::future; + let mut futs = vec![]; + for e in read_dir(".")? { + let e = e?; + let fname = e.file_name(); + let p = Path::new(&fname); + if let Some(ext) = p.extension() { + if ext == "vd" { + futs.push(fix(&ctx, BufReader::new(File::open(p).unwrap()))); + } + } + } + let msg = format!("fixed {}", futs.len()); + poise::send_reply(ctx, |m| m.content(msg).ephemeral(true)).await?; + future::join_all(futs).await; + Ok(()) +} + +// #[poise::command( +// context_menu_command = "fix vote", +// slash_command, +// category = "Discord", +// rename = "fix_vote", +// required_permissions = "ADMINISTRATOR" +// )] +// /// restart vote (use ctx menu) +// pub async fn reinstate( +// ctx: Context<'_>, +// #[description = "previous vote, id or link"] m: Message, +// ) -> Result<()> { +// static RE: LazyLock<Regex> = LazyLock::new(|| Regex::new(r#"<t:([0-9]+):R>"#).unwrap()); +// let e = m.embeds.get(0).ok_or(anyhow!("no embed?"))?; +// let end = u64::from_str( +// RE.captures( +// m.embeds +// .get(0) +// .ok_or(anyhow!("no embed?"))? +// .description +// .as_ref() +// .ok_or(anyhow!("no desc?"))?, +// ) +// .ok_or(anyhow!("no timestamp?"))? +// .get(1) +// .unwrap() +// .as_str(), +// ) +// .unwrap(); +// let now = SystemTime::now() +// .duration_since(SystemTime::UNIX_EPOCH) +// .unwrap() +// .as_secs(); +// let f = BufReader::new(std::fs::File::open( +// e.title.to_owned().ok_or(anyhow!("no title?"))? + ".vd", +// )?); +// poise::send_reply(ctx, |m| m.content("yes").ephemeral(true)).await?; +// let mut v: BeforePushVoteData = serde_json::from_reader(f)?; +// v.reply = Some(Box::new(m)); +// // cant use abs() because unsigned +// v.deadline = if now < end { +// Duration::from_secs(end - now) +// } else { +// Duration::from_secs(now - end) +// }; +// let v = VoteData::Before(v); +// if end < now { +// v.push(&ctx).finish(&ctx).await +// } else { +// v.push(&ctx).input(&ctx).await?.finish(&ctx).await +// } +// } + +// voters + +#[poise::command(prefix_command, slash_command, category = "Discord", rename = "votes")] +pub async fn list(ctx: Context<'_>, #[description = "the vote title"] vote: String) -> Result<()> { + let vd = { + let buf = ctx.data().vote_data.lock().unwrap(); + match &buf[buf + .iter() + .position(|x| x.options().title == vote) + .ok_or(anyhow!("vote doesnt exist"))?] + { + VoteData::Before(x) => x.clone(), + VoteData::After(_) => unreachable!(), + } }; - let handle = poise::send_reply(ctx, |m| { - update_msg!(m, ctx, image, title, options, ctx_id, n).components(|c| { - c.create_action_row(|r| { - for (n, option) in options.iter().enumerate() { - r.create_button(|b| { - b.custom_id(format!("{}{n}", ctx_id)) - .label(option) - .style(styles[n]) - }); + poise::send_reply(ctx, |m| { + m.allowed_mentions(|x| x.empty_parse()).embed(|e| { + let mut votes: HashMap<usize, Vec<u64>> = HashMap::new(); + for (user, vote) in vd.votes { + votes.entry(vote).or_default().push(user.0); + } + for (vote, voters) in votes { + let mut s = vec![]; + s.reserve(voters.len()); + for person in voters { + s.push(format!("<@{person}>")); } - r - }) + e.field(&vd.options.options[vote], s.join("\n"), false); + } + e.color(SUCCESS) + .title(format!("voter list for {vote}")) + .footer(|f| f.text("privacy is a illusion")) }) }) .await?; - let ctx_id_len = ctx_id.to_string().len(); - while let Some(press) = Interaction::new(ctx) - .filter(move |press| press.data.custom_id.starts_with(&ctx_id.to_string())) - .timeout(dur) - .await - { - let s = { - if ctx.data().vote_data.lock().unwrap()[n] - .get() - .insert( - press.user.id, - Vote::from_str(&press.data.custom_id[ctx_id_len..]).unwrap(), - ) - .is_some() - { - "updated" - } else { - "voted" - } - }; - println!("got vote!"); - tokio::join!( - press.create_followup_message(ctx, |m| { - m.ephemeral(true).embed(|e| e.title(s).color(SUCCESS)) - }), - press.create_interaction_response(ctx, |c| { - c.kind(InteractionResponseType::UpdateMessage) - .interaction_response_data(|m| { - update_msg!(m, ctx, image, title, options, ctx_id, n) - }) - }) - ) - .0?; - } - println!("vote ended!"); - handle - .edit(ctx, |m| { - m.embed(|e| { - for (option, votes) in ctx - .data() - .vote_data - .lock() - .unwrap() - .remove(n) - .finish(options.len()) - .get_summarized() - .into_iter() - .enumerate() - { - e.field(&options[option], votes, true); - } - e.color(DISABLED) - .title(&title) - .imageor(image.as_ref()) - .description(format!("vote ended!")) - }) - .components(|c| { - c.set_action_row({ - let mut r = CreateActionRow::default(); - for (n, option) in options.iter().enumerate() { - r.create_button(|b| { - b.custom_id(format!("{}{n}", ctx_id)) - .label(option) - .disabled(true) - .style(styles[n]) - }); - } - r - }) - }) - }) - .await?; Ok(()) } diff --git a/src/main.rs b/src/main.rs index ab28cfd..1a7a9dd 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,5 +1,4 @@ #![feature(lazy_cell)] - use std::str::FromStr; #[macro_use] mod logging; diff --git a/src/process.rs b/src/process.rs index 1a5c9fb..bfa284e 100644 --- a/src/process.rs +++ b/src/process.rs @@ -1,9 +1,9 @@ use std::time::Duration; use tokio::io::{AsyncReadExt, AsyncWriteExt}; use tokio::net::TcpStream; -use tokio::sync::broadcast; -use tokio::sync::broadcast::error::TryRecvError; +use tokio::sync::broadcast::{self, error::TryRecvError}; use tokio::task::JoinHandle; +use tokio::time::sleep; pub struct Process { inner: TcpStream, @@ -13,7 +13,6 @@ pub struct Process { impl Process { /// spawns the server - #[must_use] pub async fn spawn() -> anyhow::Result<Self> { let stream = TcpStream::connect("localhost:6859").await?; Ok(Self { @@ -41,7 +40,7 @@ impl Process { let mut stdout = [0; 4096]; loop { if output.receiver_count() == 0 { - async_std::task::sleep(Duration::from_millis(500)).await; + sleep(Duration::from_millis(500)).await; continue; } match input.try_recv() { @@ -54,16 +53,6 @@ impl Process { input!("{s}"); s += "\n"; self.inner.write_all(s.as_bytes()).await.unwrap(); - // let mut last = 250; - // while let Err(e) = self.inner.write_all(s.as_bytes()).await { - // last *= last; - // if e.kind() == std::io::ErrorKind::BrokenPipe { - // println!("failed write, waiting {last}ms to retry."); - // async_std::task::sleep(Duration::from_millis(last)).await; - // continue; - // } - // panic!("{e:?}"); - // } self.inner.flush().await.unwrap(); } } @@ -71,7 +60,7 @@ impl Process { let string = { let n = tokio::select! { n = {self.inner.read(&mut stdout)} => n.unwrap(), - _ = async_std::task::sleep(Duration::from_millis(500)) => continue, + _ = sleep(Duration::from_millis(500)) => continue, }; String::from_utf8_lossy(&stdout[..n]).into_owned() }; @@ -82,7 +71,7 @@ impl Process { String::from_utf8_lossy(&strip_ansi_escapes::strip(&string).unwrap()) .into_owned(); output.send(stripped).unwrap(); - async_std::task::sleep(Duration::from_millis(500)).await; + sleep(Duration::from_millis(500)).await; } }) } diff --git a/src/server.rs b/src/server.rs index a5529cf..14f080c 100644 --- a/src/server.rs +++ b/src/server.rs @@ -7,8 +7,8 @@ use axum::{ Router, Server as AxumServer, }; -use std::{net::SocketAddr, sync::Arc, time::Duration}; -use tokio::{sync::broadcast, task::JoinHandle}; +use std::{net::SocketAddr, sync::Arc}; +use tokio::{sync::broadcast, task::JoinHandle, time::sleep, time::Duration}; // its a arced arcs pub struct State { @@ -21,7 +21,7 @@ pub struct State { impl State { fn new(stdin: broadcast::Sender<String>) -> Self { let (stdout, _) = broadcast::channel(2); - Self { stdin, stdout } + Self { stdout, stdin } } } @@ -65,15 +65,15 @@ impl Server { AxumServer::bind(&addr) .serve(router.into_make_service()) .await - .unwrap() + .unwrap(); }); let stdout = state.stdout.clone(); tokio::spawn(async move { macro_rules! backoff { ($backoff:expr) => { - $backoff *= $backoff; + $backoff <<= 1; println!("process died; waiting {}s", $backoff); - async_std::task::sleep(Duration::from_secs($backoff)).await; + sleep(Duration::from_secs($backoff)).await; continue; }; } @@ -84,10 +84,7 @@ impl Server { let _ = h.await; process_handle = None; } - - let spawn = if let Ok(s) = Process::spawn().await { - s - } else { + let Ok(spawn) = Process::spawn().await else { backoff!(backoff); }; process_handle = Some( diff --git a/src/webhook.rs b/src/webhook.rs index c83e64c..f018917 100644 --- a/src/webhook.rs +++ b/src/webhook.rs @@ -4,8 +4,8 @@ use regex::Regex; use serenity::{builder::ExecuteWebhook, http::Http, json}; use std::convert::AsRef; use std::sync::{Arc, LazyLock, Mutex}; -use std::time::{Duration, Instant}; use tokio::sync::broadcast::{self, error::TryRecvError}; +use tokio::time::{sleep, Duration, Instant}; pub struct Webhook<'a> { pub skipped: broadcast::Sender<String>, @@ -73,7 +73,7 @@ impl<'a> Webhook<'a> { flush!(); } } - async_std::task::sleep(Duration::from_millis(20)).await; + sleep(Duration::from_millis(20)).await; continue; } }, @@ -94,7 +94,7 @@ impl<'a> Webhook<'a> { last = Some(now); } } - async_std::task::sleep(Duration::from_millis(20)).await; + sleep(Duration::from_millis(20)).await; } } @@ -104,18 +104,17 @@ impl<'a> Webhook<'a> { let mut unnamed: Option<String> = None; // this code is very game dependent - for line in feed.into_iter() { + for line in feed { let line: String = Style::fix(line); if let Some((name, msg)) = Style::split(&line) { if let Some(n) = current.as_ref() { if n == &name { message.madd_panic(&msg); continue; - } else { - let message = message.take().unwrap(); - self.send_message(n, &message).await; - current.take(); } + let message = message.take().unwrap(); + self.send_message(n, &message).await; + current.take(); } current = Some(name.to_owned()); message = Some(msg.to_owned()); @@ -125,7 +124,7 @@ impl<'a> Webhook<'a> { } continue; } - unnamed.madd(line); + unnamed.madd(unify(&line)); } // finish if let Some(n) = current.as_ref() { @@ -173,7 +172,7 @@ impl OutputStyle for MindustryStyle { return None; } - if let Some((u, c)) = line.split(": ").map(|s| unify(s)).collect_tuple() { + if let Some((u, c)) = line.split(": ").map(unify).collect_tuple() { let u = u.trim_start_matches('<'); let c = c.trim_end_matches('>'); if !(u.is_empty() || c.is_empty()) { @@ -194,15 +193,8 @@ impl OutputStyle for MindustryStyle { } } -fn unify(s: &str) -> String { - s.chars() - .filter_map(|c| { - if c > 'џ' { - return None; - } - Some(c) - }) - .collect() +pub fn unify(s: &str) -> String { + s.chars().filter(|&c| c < 'џ').collect() } trait Madd { |