diff --git a/Cargo.lock b/Cargo.lock index 2ed79e5..af2cb04 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -279,7 +279,10 @@ dependencies = [ "mwbot", "reqwest", "serde", + "thiserror 2.0.12", "tokio", + "tracing", + "tracing-subscriber", ] [[package]] @@ -580,23 +583,6 @@ version = "0.3.31" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "05f29059c0c2090612e8d742178b0580d2dc940c837851ad723096f87af6663e" -[[package]] -name = "futures-io" -version = "0.3.31" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9e5c1b78ca4aae1ac06c48a526a655760685149f0d465d21f37abfe57ce075c6" - -[[package]] -name = "futures-macro" -version = "0.3.31" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "162ee34ebcb7c64a8abebc059ce0fee27c2262618d7b60ed8faf72fef13c3650" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.104", -] - [[package]] name = "futures-sink" version = "0.3.31" @@ -616,14 +602,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9fa08315bb612088cc391249efdc3bc77536f16c91f6cf495e6fbe85b20a4a81" dependencies = [ "futures-core", - "futures-io", - "futures-macro", - "futures-sink", "futures-task", - "memchr", "pin-project-lite", "pin-utils", - "slab", ] [[package]] @@ -1206,16 +1187,6 @@ version = "0.3.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" -[[package]] -name = "mime_guess" -version = "2.0.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f7c44f8e672c00fe5308fa235f821cb4198414e1c77935c1ab6948d3fd78550e" -dependencies = [ - "mime", - "unicase", -] - [[package]] name = "miniz_oxide" version = "0.8.9" @@ -1994,7 +1965,6 @@ dependencies = [ "js-sys", "log", "mime", - "mime_guess", "native-tls", "percent-encoding", "pin-project-lite", @@ -2015,7 +1985,6 @@ dependencies = [ "url", "wasm-bindgen", "wasm-bindgen-futures", - "wasm-streams", "web-sys", "webpki-roots", ] @@ -2587,21 +2556,9 @@ dependencies = [ "pin-project-lite", "slab", "socket2", - "tokio-macros", "windows-sys 0.52.0", ] -[[package]] -name = "tokio-macros" -version = "2.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6e06d43f1345a3bcd39f6a56dbb7dcab2ba47e68e8ac134855e7e2bdbaf8cab8" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.104", -] - [[package]] name = "tokio-native-tls" version = "0.3.1" @@ -2805,12 +2762,6 @@ version = "1.18.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1dccffe3ce07af9386bfd29e80c0ab1a8205a2fc34e4bcd40364df902cfa8f3f" -[[package]] -name = "unicase" -version = "2.8.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "75b844d17643ee918803943289730bec8aac480150456169e647ed0b576ba539" - [[package]] name = "unicode-ident" version = "1.0.18" @@ -2987,19 +2938,6 @@ dependencies = [ "unicode-ident", ] -[[package]] -name = "wasm-streams" -version = "0.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "15053d8d85c7eccdbefef60f06769760a563c7f0a9d6902a13d35c7800b0ad65" -dependencies = [ - "futures-util", - "js-sys", - "wasm-bindgen", - "wasm-bindgen-futures", - "web-sys", -] - [[package]] name = "web-sys" version = "0.3.77" diff --git a/Cargo.toml b/Cargo.toml index 89ea4a4..9da06e8 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -2,9 +2,19 @@ name = "dancing-bot-teachers" version = "0.1.0" edition = "2024" +authors = ["Lukas Wölfer "] +description = "A MediaWiki bot that updates score information of teachers" +license = "MIT" +repository = "https://gitea.thasky.one/lukas/mediawiki-bot" +readme = "README.md" +keywords = ["mediawiki", "bot", "teacher", "score", "automation"] +categories = ["web-programming", "api-bindings", "automation"] [dependencies] -mwbot = { git = "https://gitlab.wikimedia.org/repos/mwbot-rs/mwbot.git", rev = "05cbb12188f18e2da710de158d89a9a4f1b42689" } +mwbot = { git = "https://gitlab.wikimedia.org/repos/mwbot-rs/mwbot.git", rev = "05cbb12188f18e2da710de158d89a9a4f1b42689", default-features = false, features = ["generators", "mwbot_derive"] } reqwest = "0.12.22" serde = { version = "1.0.219", features = ["derive"] } -tokio = { version = "1.46.1", features = ["rt", "rt-multi-thread", "macros"] } +thiserror = "2.0.12" +tokio = { version = "1.46.1", features = ["rt"] } +tracing = { version = "0.1.41", default-features = false, features = ["std"] } +tracing-subscriber = "0.3.19" diff --git a/src/dance_info.rs b/src/dance_info.rs new file mode 100644 index 0000000..13050b9 --- /dev/null +++ b/src/dance_info.rs @@ -0,0 +1,156 @@ +use std::collections::HashMap; + +#[derive(serde::Deserialize, Debug, PartialEq, Eq)] +pub enum DanceRole { + Leader, + Follower, +} + +impl DanceRole { + pub const fn as_str(&self) -> &str { + match self { + Self::Leader => "Leader", + Self::Follower => "Follower", + } + } + + #[allow(dead_code)] + pub const fn other(&self) -> Self { + match self { + Self::Leader => Self::Follower, + Self::Follower => Self::Leader, + } + } +} + +#[derive(serde::Deserialize, Debug, PartialEq, Eq)] +pub enum DanceRank { + Newcomer, + Novice, + Intermediate, + #[serde(rename = "Advance")] + Advanced, + #[serde(rename = "All Star")] + AllStars, + Champions, +} + +impl DanceRank { + pub const fn as_str(&self) -> &str { + match self { + Self::Newcomer => "Newcomer", + Self::Novice => "Novice", + Self::Intermediate => "Intermediate", + Self::Advanced => "Advanced", + Self::AllStars => "All-Stars", + Self::Champions => "Champions", + } + } +} + +#[derive(serde::Deserialize, Debug)] +enum OptionalDanceRank { + #[serde(rename = "N/A")] + NotAvailable, + #[serde(untagged)] + Rank(DanceRank), +} + +#[derive(serde::Deserialize, Debug)] +enum OptionalDancePoints { + #[serde(rename = "N/A")] + NotAvailable, + #[serde(untagged)] + Points(u16), +} + +#[derive(serde::Deserialize, Debug)] +struct DanceInfoParser { + pub dancer_first: String, + pub dancer_last: String, + pub short_dominate_role: DanceRole, + #[allow(dead_code)] + pub short_non_dominate_role: DanceRole, + pub dominate_role_highest_level_points: u16, + pub dominate_role_highest_level: DanceRank, + pub non_dominate_role_highest_level_points: OptionalDancePoints, + pub non_dominate_role_highest_level: OptionalDanceRank, +} + +#[derive(Debug)] +pub struct CompState { + pub rank: DanceRank, + pub points: u16, +} +pub struct DanceInfo { + pub firstname: String, + pub lastname: String, + pub dominant_role: DanceRole, + pub dominant_role_comp: CompState, + pub non_dominant_role_comp: Option, +} + +impl DanceInfo { + pub fn name(&self) -> String { + format!("{} {}", self.firstname, self.lastname) + } + + #[allow(dead_code)] + pub const fn non_dominant_role(&self) -> DanceRole { + self.dominant_role.other() + } +} + +impl From for DanceInfo { + fn from(value: DanceInfoParser) -> Self { + let non_dominant_role_comp = if let OptionalDanceRank::Rank(r) = + value.non_dominate_role_highest_level + && let OptionalDancePoints::Points(l) = value.non_dominate_role_highest_level_points + { + Some(CompState { rank: r, points: l }) + } else { + None + }; + Self { + firstname: value.dancer_first, + lastname: value.dancer_last, + dominant_role: value.short_dominate_role, + dominant_role_comp: CompState { + rank: value.dominate_role_highest_level, + points: value.dominate_role_highest_level_points, + }, + non_dominant_role_comp, + } + } +} + +#[derive(thiserror::Error, Debug)] +pub enum DanceInfoError { + #[error("Failed to build client: {0}")] + ClientBuild(reqwest::Error), + #[error("Request error: {0}")] + Request(reqwest::Error), + #[error("Failed to parse response: {0}")] + JsonParse(reqwest::Error), +} + +pub async fn fetch_wsdc_info(id: u32) -> Result { + let client = reqwest::ClientBuilder::new() + .build() + .map_err(DanceInfoError::ClientBuild)?; + + let mut params = HashMap::new(); + params.insert("q", id.to_string()); + let response = client + .request( + reqwest::Method::POST, + "https://points.worldsdc.com/lookup2020/find", + ) + .form(¶ms) + .send() + .await + .map_err(DanceInfoError::Request)?; + + let x: DanceInfoParser = response.json().await.map_err(DanceInfoError::JsonParse)?; + Ok(x.into()) +} diff --git a/src/main.rs b/src/main.rs index 9e0c68e..1db8a2c 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,16 +1,32 @@ +#![warn(clippy::all, clippy::cargo, clippy::nursery, clippy::pedantic)] +#![warn(clippy::unwrap_used, clippy::expect_used)] +#![warn(clippy::print_stderr, clippy::print_stdout)] +#![allow( + clippy::multiple_crate_versions, + reason = "Don't know how to fix this, should be fine" +)] +#![allow( + clippy::cast_possible_truncation, + clippy::cast_precision_loss, + clippy::cast_sign_loss, + clippy::cast_possible_wrap, + reason = "Disable this for most of the time, enable this for cleanup later" +)] + use mwbot::{ Bot, - generators::{ - Generator, SortDirection, categories::CategoryMemberSort, querypage::QueryPage, - search::Search, - }, + generators::{Generator, SortDirection, categories::CategoryMemberSort}, }; -use reqwest::RequestBuilder; -use serde::{Deserialize, Deserializer}; -use std::{collections::HashMap, error::Error, path::Path}; +use std::{error::Error, path::Path}; -mod old_style; +use crate::watchdog::watch_wanted; +mod dance_info; +mod watchdog; +mod wikiinfo; + +#[allow(dead_code)] +#[allow(clippy::print_stdout, reason = "We want to print here")] fn list_teacher_pages(bot: &Bot) -> tokio::sync::mpsc::Receiver> { let category_title = "Category:Teachers"; let pages = mwbot::generators::categories::CategoryMembers::new(category_title) @@ -22,145 +38,31 @@ fn list_teacher_pages(bot: &Bot) -> tokio::sync::mpsc::Receiver Result<(), Box> { + tracing_subscriber::fmt() + .with_level(true) + .with_max_level(tracing::Level::INFO) + .init(); -#[derive(serde::Deserialize, Debug, PartialEq, Eq)] -enum DanceRank { - Novice, - Intermediate, - Advanced, - #[serde(rename = "All-Stars")] - AllStars, - Champions, -} - -#[derive(serde::Deserialize, Debug)] -enum OptionalDanceRank { - #[serde(rename = "N/A")] - NotAvailable, - #[serde(untagged)] - Rank(DanceRank), -} - -#[derive(serde::Deserialize, Debug)] -enum OptionalDanceLevel { - #[serde(rename = "N/A")] - NotAvailable, - #[serde(untagged)] - Level(u16), -} - -#[derive(serde::Deserialize, Debug)] -struct DanceInfo { - dancer_first: String, - dancer_last: String, - short_dominate_role: DanceRole, - short_non_dominate_role: DanceRole, - dominate_role_highest_level_points: u16, - dominate_role_highest_level: DanceRank, - non_dominate_role_highest_level_points: OptionalDanceLevel, - non_dominate_role_highest_level: OptionalDanceRank, -} - -async fn fetch_wsdc_info(id: u32) { - let client = reqwest::ClientBuilder::new().build().unwrap(); - let mut params = HashMap::new(); - params.insert("q", id.to_string()); - let response = client - .request( - reqwest::Method::POST, - "https://points.worldsdc.com/lookup2020/find", - ) - .form(¶ms) - .send() - .await - .unwrap(); - let x: DanceInfo = response.json().await.unwrap(); - dbg!(x); -} - -async fn wanted_ids(bot: &Bot) -> Vec { - let mut gene = QueryPage::new("Wantedpages").generate(bot); - let mut result = vec![]; - while let Some(x) = gene.recv().await { - let p = match x { - Ok(p) => p, - Err(e) => { - eprintln!("Could not get search result: {e}"); - continue; - } - }; - if let Ok(n) = parse_wsdc_page_name(p.title()) { - result.push(n); - } - } - result -} - -fn parse_wsdc_page_name(name: &str) -> Result { - if !name.starts_with("WSDC/") { - eprintln!("{name} is a wrong match"); - return Err(()); - } - match name.trim_start_matches("WSDC/").parse::() { - Ok(n) => Ok(n), + let rt = match tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + { + Ok(o) => o, Err(e) => { - eprintln!("Page {name} does not fit: {e}"); - Err(()) - } - } -} - -async fn index_wsdc_ids(bot: &Bot) -> Vec { - let mut gene = Search::new("WSDC/").generate(bot); - let mut result = vec![]; - while let Some(x) = gene.recv().await { - let p = match x { - Ok(p) => p, - Err(e) => { - eprintln!("Could not get search result: {e}"); - continue; - } - }; - if let Ok(n) = parse_wsdc_page_name(&p.title()) { - result.push(n); - } - } - result -} - -#[tokio::main] -async fn main() -> Result<(), Box> { - let bot = match Bot::from_path(Path::new("./mwbot.toml")).await { - Ok(x) => x, - Err(e) => { - dbg!(e); + tracing::error!("Could not start runtime: {e}"); return Ok(()); } }; - // dbg!(index_wsdc_ids(&bot).await); - // dbg!(wanted_ids(&bot).await); - // fetch_wsdc_info(1234).await; - // fetch_wsdc_info(1010).await; - fetch_wsdc_info(18080).await; - Ok(()) - - // // Monitor changes on these pages - // for page_title in pages { - // let mut stream = bot.watch_page(&page_title).await?; - // tokio::spawn(async move { - // while let Some(change) = stream.next().await { - // println!("Change detected on {}: {:?}", page_title, change); - // } - // }); - // } - - // // Keep the main thread alive to continue monitoring - // loop { - // tokio::time::sleep(tokio::time::Duration::from_secs(60)).await; - // } + rt.block_on(async { + let bot = match Bot::from_path(Path::new("./mwbot.toml")).await { + Ok(x) => x, + Err(e) => { + dbg!(e); + return Ok(()); + } + }; + watch_wanted(bot).await; + Ok(()) + }) } diff --git a/src/watchdog.rs b/src/watchdog.rs new file mode 100644 index 0000000..fd8ae54 --- /dev/null +++ b/src/watchdog.rs @@ -0,0 +1,94 @@ +use std::time::Duration; + +use crate::{ + dance_info::{DanceInfo, fetch_wsdc_info}, + wikiinfo::wanted_ids, +}; +use mwbot::{ + Bot, + parsoid::{self, Template, Wikicode, map::IndexMap}, +}; +use mwbot::{SaveOptions, parsoid::WikinodeIterator}; +use tracing::Level; + +#[derive(thiserror::Error, Debug)] +enum InfoCompileError { + #[error("Could not compile wikipage: {0}")] + CompileError(#[from] parsoid::Error), +} + +fn page_from_info(info: DanceInfo) -> Result { + let mut params = IndexMap::new(); + params.insert("name".to_string(), info.name()); + params.insert( + "dominant_role".to_string(), + info.dominant_role.as_str().to_string(), + ); + params.insert( + "allowed_rank".to_string(), + info.dominant_role_comp.rank.as_str().to_string(), + ); + params.insert( + "dominant_rank".to_string(), + info.dominant_role_comp.rank.as_str().to_string(), + ); + params.insert( + "dominant_points".to_string(), + info.dominant_role_comp.points.to_string(), + ); + if let Some(u) = info.non_dominant_role_comp { + params.insert("non_dominant_rank".to_string(), u.rank.as_str().to_string()); + params.insert("non_dominant_points".to_string(), u.points.to_string()); + } + let t = Template::new("Template:WSDCBox", ¶ms)?; + let result = Wikicode::new(""); + result.append(&t); + Ok(result) +} + +pub async fn watch_wanted(bot: Bot) { + let span = tracing::span!(Level::INFO, "wanted_watchdog"); + let _enter = span.enter(); + loop { + tracing::info!("Watchdog check..."); + let wanted = wanted_ids(bot.clone()).await; + + for (id, page) in wanted { + generate_page(id, page).await; + } + tokio::time::sleep(Duration::from_secs(30)).await; + } +} + +async fn generate_page(id: u32, page: mwbot::Page) { + tracing::info!("Taking care of {id}"); + let info = match fetch_wsdc_info(id).await { + Ok(o) => o, + Err(e) => { + tracing::error!("Error fetching wsdc info for {id}: {e}"); + return; + } + }; + let code = match page_from_info(info) { + Ok(o) => o, + Err(e) => { + tracing::error!("Creating wikicode for {id}: {e}"); + return; + } + }; + + match page + .save( + code, + &SaveOptions::summary("Created WSDC info from worldsdc.com") + .mark_as_bot(true) + .mark_as_minor(false), + ) + .await + { + Ok(_) => (), + Err(e) => { + tracing::error!("Could not save page for {id}: {e}"); + } + } +} diff --git a/src/wikiinfo.rs b/src/wikiinfo.rs new file mode 100644 index 0000000..cc92fd1 --- /dev/null +++ b/src/wikiinfo.rs @@ -0,0 +1,62 @@ +use mwbot::{ + Bot, Page, + generators::{Generator, querypage::QueryPage, search::Search}, +}; + +pub async fn wanted_ids(bot: Bot) -> Vec<(u32, Page)> { + let mut gene = QueryPage::new("Wantedpages").generate(&bot); + let mut result = vec![]; + while let Some(x) = gene.recv().await { + let p = match x { + Ok(p) => p, + Err(e) => { + tracing::error!("Could not get search result: {e}"); + continue; + } + }; + if let Ok(n) = parse_wsdc_page_name(p.title()) { + result.push((n, p)); + } + } + result +} + +#[derive(thiserror::Error, Debug)] +enum TitleParseError { + #[error("Does not contain the correct prefix")] + NoPrefix, + #[error("Possibly incorrect page")] + Sketchy, +} + +fn parse_wsdc_page_name(name: &str) -> Result { + if !name.starts_with("WSDC/") { + return Err(TitleParseError::NoPrefix); + } + match name.trim_start_matches("WSDC/").parse::() { + Ok(n) => Ok(n), + Err(e) => { + tracing::error!("Page {name} does not fit: {e}"); + Err(TitleParseError::Sketchy) + } + } +} + +#[allow(dead_code)] +pub async fn index_wsdc_ids(bot: &Bot) -> Vec { + let mut gene = Search::new("WSDC/").generate(bot); + let mut result = vec![]; + while let Some(x) = gene.recv().await { + let p = match x { + Ok(p) => p, + Err(e) => { + tracing::error!("Could not get search result: {e}"); + continue; + } + }; + if let Ok(n) = parse_wsdc_page_name(p.title()) { + result.push(n); + } + } + result +}