From e666250fa96f5f6a11d76c282cf05246d85c539c Mon Sep 17 00:00:00 2001 From: eternal-flame-AD Date: Tue, 20 Jun 2023 16:39:53 -0500 Subject: [PATCH] discord bot --- Cargo.lock | 226 ++++++++++++++++++++++++++++- Cargo.toml | 4 +- config.yaml | 2 + src/apps/auth/mod.rs | 21 +-- src/apps/canvas_lms/mod.rs | 71 ++++----- src/apps/med/directive.rs | 8 +- src/apps/med/log.rs | 58 +++++++- src/apps/med/mod.rs | 89 +++++++++--- src/apps/mod.rs | 21 ++- src/apps/server_info/mod.rs | 50 ++++++- src/apps/webcheck/driver/chrome.rs | 2 +- src/apps/webcheck/mod.rs | 59 ++++---- src/comm/discord.rs | 128 ++++++++++++++++ src/comm/email.rs | 10 +- src/comm/gotify.rs | 16 +- src/comm/mod.rs | 15 +- src/config/comm.rs | 11 +- src/lib.rs | 29 +++- 18 files changed, 677 insertions(+), 143 deletions(-) create mode 100644 src/comm/discord.rs diff --git a/Cargo.lock b/Cargo.lock index ec7f2de..8a9136b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -185,6 +185,22 @@ dependencies = [ "syn 2.0.18", ] +[[package]] +name = "async-tungstenite" +version = "0.17.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1b71b31561643aa8e7df3effe284fa83ab1a840e52294c5f4bd7bfd8b2becbb" +dependencies = [ + "futures-io", + "futures-util", + "log", + "pin-project-lite", + "tokio", + "tokio-rustls 0.23.4", + "tungstenite", + "webpki-roots", +] + [[package]] name = "atty" version = "0.2.14" @@ -328,6 +344,12 @@ version = "3.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a3e2c3daef883ecc1b5d58c15adae93470a91d425f3532ba1695849656af3fc1" +[[package]] +name = "byteorder" +version = "1.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "14c189c53d098945499cdfa7ecc63567cf3886b3332b312a5b4585d8d3a6a610" + [[package]] name = "bytes" version = "1.4.0" @@ -362,6 +384,15 @@ dependencies = [ "winapi", ] +[[package]] +name = "chrono-humanize" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32dce1ea1988dbdf9f9815ff11425828523bd2a134ec0805d2ac8af26ee6096e" +dependencies = [ + "chrono", +] + [[package]] name = "cipher" version = "0.4.4" @@ -496,6 +527,20 @@ dependencies = [ "cipher", ] +[[package]] +name = "dashmap" +version = "5.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "907076dfda823b0b36d2a1bb5f90c96660a5bbcd7729e10727f07858f22c4edc" +dependencies = [ + "cfg-if", + "hashbrown", + "lock_api", + "once_cell", + "parking_lot_core", + "serde", +] + [[package]] name = "diesel" version = "2.1.0" @@ -609,7 +654,7 @@ dependencies = [ "futures-util", "http", "hyper", - "hyper-rustls", + "hyper-rustls 0.23.2", "mime", "serde", "serde_json", @@ -942,6 +987,19 @@ dependencies = [ "tokio-rustls 0.23.4", ] +[[package]] +name = "hyper-rustls" +version = "0.24.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0646026eb1b3eea4cd9ba47912ea5ce9cc07713d105b1a14698f4e6433d348b7" +dependencies = [ + "http", + "hyper", + "rustls 0.21.1", + "tokio", + "tokio-rustls 0.24.0", +] + [[package]] name = "hyper-tls" version = "0.5.0" @@ -1336,6 +1394,15 @@ dependencies = [ "vcpkg", ] +[[package]] +name = "ordered-float" +version = "2.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7940cf2ca942593318d07fcf2596cdca60a85c9e7fab408a5e21a4f9dcd40d87" +dependencies = [ + "num-traits", +] + [[package]] name = "parking_lot" version = "0.12.1" @@ -1426,6 +1493,12 @@ dependencies = [ "universal-hash", ] +[[package]] +name = "ppv-lite86" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b40af805b3121feab8a3c29f04d8ad262fa8e0561883e7653e024ae4479e6de" + [[package]] name = "proc-macro-error" version = "1.0.4" @@ -1474,6 +1547,27 @@ version = "0.4.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5a3866219251662ec3b26fc217e3e05bf9c4f84325234dfb96bf0bf840889e49" +[[package]] +name = "rand" +version = "0.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" +dependencies = [ + "libc", + "rand_chacha", + "rand_core", +] + +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core", +] + [[package]] name = "rand_core" version = "0.6.4" @@ -1524,25 +1618,33 @@ dependencies = [ "http", "http-body", "hyper", + "hyper-rustls 0.24.0", "hyper-tls", "ipnet", "js-sys", "log", "mime", + "mime_guess", "native-tls", "once_cell", "percent-encoding", "pin-project-lite", + "rustls 0.21.1", + "rustls-pemfile", "serde", "serde_json", "serde_urlencoded", "tokio", "tokio-native-tls", + "tokio-rustls 0.24.0", + "tokio-util", "tower-service", "url", "wasm-bindgen", "wasm-bindgen-futures", + "wasm-streams", "web-sys", + "webpki-roots", "winreg", ] @@ -1742,6 +1844,16 @@ dependencies = [ "serde_derive", ] +[[package]] +name = "serde-value" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3a1a3341211875ef120e117ea7fd5228530ae7e7036a779fdc9117be6b3282c" +dependencies = [ + "ordered-float", + "serde", +] + [[package]] name = "serde_derive" version = "1.0.163" @@ -1819,6 +1931,48 @@ dependencies = [ "unsafe-libyaml", ] +[[package]] +name = "serenity" +version = "0.11.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82fd5e7b5858ad96e99d440138f34f5b98e1b959ebcd3a1036203b30e78eb788" +dependencies = [ + "async-trait", + "async-tungstenite", + "base64 0.13.1", + "bitflags", + "bytes", + "cfg-if", + "chrono", + "dashmap", + "flate2", + "futures", + "mime", + "mime_guess", + "parking_lot", + "percent-encoding", + "reqwest", + "serde", + "serde-value", + "serde_json", + "time 0.3.21", + "tokio", + "tracing", + "typemap_rev", + "url", +] + +[[package]] +name = "sha-1" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f5058ada175748e33390e40e872bd0fe59a19f265d0158daa551c5a88a76009c" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + [[package]] name = "sha2" version = "0.10.6" @@ -2214,9 +2368,21 @@ dependencies = [ "cfg-if", "log", "pin-project-lite", + "tracing-attributes", "tracing-core", ] +[[package]] +name = "tracing-attributes" +version = "0.1.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8803eee176538f94ae9a14b55b2804eb7e1441f8210b1c31290b3bccdccff73b" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.18", +] + [[package]] name = "tracing-core" version = "0.1.31" @@ -2232,6 +2398,33 @@ version = "0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3528ecfd12c466c6f163363caf2d02a71161dd5e1cc6ae7b34207ea2d42d81ed" +[[package]] +name = "tungstenite" +version = "0.17.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e27992fd6a8c29ee7eef28fc78349aa244134e10ad447ce3b9f0ac0ed0fa4ce0" +dependencies = [ + "base64 0.13.1", + "byteorder", + "bytes", + "http", + "httparse", + "log", + "rand", + "rustls 0.20.8", + "sha-1", + "thiserror", + "url", + "utf-8", + "webpki", +] + +[[package]] +name = "typemap_rev" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5b74f0a24b5454580a79abb6994393b09adf0ab8070f15827cb666255de155" + [[package]] name = "typenum" version = "1.16.0" @@ -2305,6 +2498,7 @@ dependencies = [ "form_urlencoded", "idna 0.4.0", "percent-encoding", + "serde", ] [[package]] @@ -2313,6 +2507,12 @@ version = "0.7.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "110352d4e9076c67839003c7788d8604e24dcded13e0b375af3efaa8cf468517" +[[package]] +name = "utf-8" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9" + [[package]] name = "utf8parse" version = "0.2.1" @@ -2438,6 +2638,19 @@ version = "0.2.86" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed9d5b4305409d1fc9482fee2d7f9bcbf24b3972bf59817ef757e23982242a93" +[[package]] +name = "wasm-streams" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6bbae3363c08332cadccd13b67db371814cd214c2524020932f0804b8cf7c078" +dependencies = [ + "futures-util", + "js-sys", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + [[package]] name = "web-sys" version = "0.3.63" @@ -2477,6 +2690,15 @@ dependencies = [ "untrusted", ] +[[package]] +name = "webpki-roots" +version = "0.22.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c71e40d7d2c34a5106301fb632274ca37242cd0c9d3e64dbece371a40a2d87" +dependencies = [ + "webpki", +] + [[package]] name = "winapi" version = "0.3.9" @@ -2671,6 +2893,7 @@ dependencies = [ "axum-server", "base64 0.21.2", "chrono", + "chrono-humanize", "clap", "diesel", "diesel_migrations", @@ -2687,6 +2910,7 @@ dependencies = [ "serde", "serde_json", "serde_yaml", + "serenity", "simple_logger", "tempfile", "thirtyfour", diff --git a/Cargo.toml b/Cargo.toml index 48b1dbf..edccbb2 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -18,6 +18,7 @@ axum = { version = "0.6.18", features = ["http2"] } axum-server = { version = "0.5.1", features = ["rustls", "rustls-pemfile", "tls-rustls"] } base64 = "0.21.2" chrono = { version = "0.4.26", features = ["serde"] } +chrono-humanize = "0.2.2" clap = { version = "4.3.2", features = ["derive"] } diesel = { version = "2.1.0", features = ["sqlite", "chrono"] } diesel_migrations = { version = "2.1.0", features = ["sqlite"] } @@ -28,12 +29,13 @@ lettre = "0.10.4" log = "0.4.18" rand_core = { version = "0.6.4", features = ["getrandom"] } regex = "1.8.4" -reqwest = { version = "0.11.18", features = ["json", "blocking"] } +reqwest = { version = "0.11.18", features = ["json"] } rust-embed = "6.7.0" rustls-pemfile = "1.0.2" serde = { version = "1.0.163", features = ["derive"] } serde_json = "1.0.96" serde_yaml = "0.9.21" +serenity = { version = "0.11.5", default-features = false, features = ["builder", "cache", "client", "chrono","gateway", "model", "utils", "rustls_backend"] } simple_logger = "4.1.0" tempfile = "3.6.0" thirtyfour = "0.31.0" diff --git a/config.yaml b/config.yaml index bc87205..35c2062 100644 --- a/config.yaml +++ b/config.yaml @@ -11,6 +11,8 @@ canvas_lms: refresh_interval: 300 comm: + discord: + token: "xxx" gotify: url: https://gotify.yumechi.jp token: Axxxxxx diff --git a/src/apps/auth/mod.rs b/src/apps/auth/mod.rs index 9724b84..0424c7d 100644 --- a/src/apps/auth/mod.rs +++ b/src/apps/auth/mod.rs @@ -1,9 +1,6 @@ -use std::{ - future::Future, - pin::Pin, - sync::{Arc, Mutex}, -}; +use std::sync::Arc; +use async_trait::async_trait; use axum::{ body::HttpBody, error_handling::HandleErrorLayer, @@ -14,6 +11,7 @@ use axum::{ }; use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; +use tokio::sync::Mutex; use tower::ServiceBuilder; use crate::{ @@ -66,7 +64,7 @@ pub async fn route_login( ) -> Result<(Extension, ApiResponse<()>), ApiResponse<()>> { let failed_response = ApiResponse::<()>::error("Invalid credentials".to_string(), 401, None); - let state = app.state.lock().unwrap(); + let state = app.state.lock().await; let state = state.as_ref().unwrap(); let Some(user) = state.config.auth.users.get(&form.username) else { @@ -125,19 +123,14 @@ impl AuthApp { } } +#[async_trait] impl App for AuthApp { - fn initialize( - self: Arc, - config: &'static Config, - app_state: Arc>, - ) -> Pin>> { - let mut state = self.state.lock().unwrap(); + async fn initialize(self: Arc, config: &'static Config, app_state: Arc>) { + let mut state = self.state.lock().await; *state = Some(AuthAppState { config, _app_state: app_state, }); - - Box::pin(async {}) } fn api_routes(self: Arc) -> Router { diff --git a/src/apps/canvas_lms/mod.rs b/src/apps/canvas_lms/mod.rs index 52337c2..a2fe7df 100644 --- a/src/apps/canvas_lms/mod.rs +++ b/src/apps/canvas_lms/mod.rs @@ -1,14 +1,11 @@ -use std::{ - future::Future, - pin::Pin, - sync::{Arc, Mutex}, -}; +use std::sync::Arc; use askama::Template; +use async_trait::async_trait; use axum::{body::HttpBody, extract::Query, http::Request, routing::get, Extension, Router}; use log::{debug, info}; use serde::{Deserialize, Serialize}; -use tokio::sync::Mutex as AsyncMutex; +use tokio::sync::Mutex; use crate::{ apps::{ @@ -62,7 +59,7 @@ pub async fn query_grades( } pub struct CanvasLMSApp { - state: AsyncMutex, + state: Mutex, } struct CanvasLMSAppState { @@ -114,7 +111,7 @@ where impl CanvasLMSApp { pub fn new() -> Self { Self { - state: AsyncMutex::new(CanvasLMSAppState { + state: Mutex::new(CanvasLMSAppState { config: None, grade_cache: GradeCache { last_updated: chrono::Local::now(), @@ -149,13 +146,16 @@ impl CanvasLMSApp { }; let template_rendered = template_ctx.render().unwrap(); let global_app_state = - state.global_app_state.as_ref().unwrap().lock().unwrap(); - let email_result = global_app_state.comm.send_message(&Message { - subject: "New grades available".to_string(), - body: template_rendered, - mime: "text/html", - ..Default::default() - }); + state.global_app_state.as_ref().unwrap().lock().await; + let email_result = global_app_state + .comm + .send_message(&Message { + subject: "New grades available".to_string(), + body: template_rendered, + mime: "text/html", + ..Default::default() + }) + .await; match email_result { Ok(_) => { info!("Sent email notification for new grades"); @@ -176,12 +176,9 @@ impl CanvasLMSApp { } } +#[async_trait] impl App for CanvasLMSApp { - fn initialize( - self: Arc, - config: &'static Config, - app_state: Arc>, - ) -> Pin>> { + async fn initialize(self: Arc, config: &'static Config, app_state: Arc>) { let self_clone = self.clone(); let refresh_interval = config.canvas_lms.refresh_interval; @@ -189,27 +186,23 @@ impl App for CanvasLMSApp { panic!("Canvas LMS refresh interval cannot be 0"); } - let init_async = Box::pin(async move { - let mut state = self.state.lock().await; - state.global_app_state = Some(app_state); - state.config = Some(config); - state.grade_cache = GradeCache { - last_updated: chrono::Local::now(), - response: None, - }; + let mut state = self.state.lock().await; + state.global_app_state = Some(app_state); + state.config = Some(config); + state.grade_cache = GradeCache { + last_updated: chrono::Local::now(), + response: None, + }; - tokio::spawn(async move { - let mut ticker = - tokio::time::interval(std::time::Duration::from_secs(refresh_interval)); - ticker.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay); - loop { - self_clone.refresh_grades().await; - ticker.tick().await; - } - }); + tokio::spawn(async move { + let mut ticker = + tokio::time::interval(std::time::Duration::from_secs(refresh_interval)); + ticker.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay); + loop { + self_clone.refresh_grades().await; + ticker.tick().await; + } }); - - init_async } fn api_routes(self: Arc) -> Router { diff --git a/src/apps/med/directive.rs b/src/apps/med/directive.rs index c2f2612..d393a37 100644 --- a/src/apps/med/directive.rs +++ b/src/apps/med/directive.rs @@ -48,7 +48,7 @@ pub async fn route_get_directive( let state = app.state.lock().await; let state = state.as_ref().unwrap(); - let mut global_app_state = state.global_app_state.lock().unwrap(); + let mut global_app_state = state.global_app_state.lock().await; let meds = { use crate::schema::medications::dsl::*; @@ -77,7 +77,7 @@ pub async fn route_post_directive( let state = app.state.lock().await; let state = state.as_ref().unwrap(); - let mut global_app_state = state.global_app_state.lock().unwrap(); + let mut global_app_state = state.global_app_state.lock().await; form.uuid = uuid::Uuid::new_v4().to_string(); form.created = chrono::Utc::now().naive_local(); @@ -111,7 +111,7 @@ pub async fn route_patch_directive( let state = app.state.lock().await; let state = state.as_ref().unwrap(); - let mut global_app_state = state.global_app_state.lock().unwrap(); + let mut global_app_state = state.global_app_state.lock().await; let res = { use crate::schema::medications; @@ -155,7 +155,7 @@ pub async fn route_delete_directive( let state = app.state.lock().await; let state = state.as_ref().unwrap(); - let mut global_app_state = state.global_app_state.lock().unwrap(); + let mut global_app_state = state.global_app_state.lock().await; { use crate::schema::medications::dsl::medications; diff --git a/src/apps/med/log.rs b/src/apps/med/log.rs index ca3762c..84e6605 100644 --- a/src/apps/med/log.rs +++ b/src/apps/med/log.rs @@ -6,6 +6,7 @@ use crate::{ models::med::{Medication, MedicationLog}, }; +use anyhow::Result; use axum::{ extract::{Path, Query}, Extension, @@ -60,6 +61,55 @@ pub fn project_next_dose(med: &Medication, med_logs: Vec) -> Medi } } +pub async fn project_next_doses( + app: &MedManagementApp, +) -> Result> { + let state = app.state.lock().await; + let state = state.as_ref().unwrap(); + let mut global_app_state = state.global_app_state.lock().await; + + let meds = { + use crate::schema::medications::dsl::*; + use diesel::prelude::*; + + medications + .load::(&mut global_app_state.db) + .map_err(|e| { + error!("Failed to load meds: {:?}", e); + e + })? + }; + + let mut med_logs = Vec::with_capacity(meds.len()); + for med in &meds { + let logs = { + use crate::schema::medication_logs::dsl::*; + use diesel::prelude::*; + + medication_logs + .order(time_actual.desc()) + .filter(med_uuid.eq(&med.uuid)) + .limit(10) + .load::(&mut global_app_state.db) + .map_err(|e| { + error!("Failed to load logs: {:?}", e); + e + })? + }; + + med_logs.push(logs); + } + + let mut res = Vec::with_capacity(meds.len()); + + for (med, logs) in meds.into_iter().zip(med_logs.into_iter()) { + let next_dose = project_next_dose(&med, logs); + res.push((med, next_dose)); + } + + Ok(res) +} + pub async fn route_project_next_dose( auth: AuthInfo, app: Extension>, @@ -69,7 +119,7 @@ pub async fn route_project_next_dose( let state = app.state.lock().await; let state = state.as_ref().unwrap(); - let mut global_app_state = state.global_app_state.lock().unwrap(); + let mut global_app_state = state.global_app_state.lock().await; let med = { use crate::schema::medications::dsl::*; @@ -122,7 +172,7 @@ pub async fn route_get_log( let state = app.state.lock().await; let state = state.as_ref().unwrap(); - let mut global_app_state = state.global_app_state.lock().unwrap(); + let mut global_app_state = state.global_app_state.lock().await; let med_logs = { use crate::schema::medication_logs::dsl::*; @@ -169,7 +219,7 @@ pub async fn route_post_log( let state = app.state.lock().await; let state = state.as_ref().unwrap(); - let mut global_app_state = state.global_app_state.lock().unwrap(); + let mut global_app_state = state.global_app_state.lock().await; let med = { use crate::schema::medications::dsl::*; @@ -249,7 +299,7 @@ pub async fn route_delete_log( let state = app.state.lock().await; let state = state.as_ref().unwrap(); - let mut global_app_state = state.global_app_state.lock().unwrap(); + let mut global_app_state = state.global_app_state.lock().await; { use crate::schema::medication_logs::dsl::*; diff --git a/src/apps/med/mod.rs b/src/apps/med/mod.rs index 9ad1079..5b479f8 100644 --- a/src/apps/med/mod.rs +++ b/src/apps/med/mod.rs @@ -1,24 +1,29 @@ -use std::{ - future::Future, - pin::Pin, - sync::{Arc, Mutex}, -}; +use std::sync::Arc; -use crate::{config::Config, AppState}; +use crate::{ + comm::{Message, MessageDigestor}, + config::Config, + AppState, +}; use super::App; +use anyhow::Result; +use async_trait::async_trait; use axum::{ routing::{delete, get, post}, Extension, Router, }; -use tokio::sync::Mutex as AsyncMutex; +use chrono::DateTime; +use lazy_static::lazy_static; +use serenity::utils::MessageBuilder; +use tokio::sync::Mutex; mod directive; mod log; pub struct MedManagementApp { - state: AsyncMutex>, + state: Mutex>, } struct MedManagementAppState { @@ -28,23 +33,70 @@ struct MedManagementAppState { impl MedManagementApp { pub fn new() -> Self { Self { - state: AsyncMutex::new(None), + state: Mutex::new(None), } } } +pub fn format_relative_time( + time: chrono::DateTime, + relative_to: chrono::DateTime, +) -> String { + let duration = time.signed_duration_since(relative_to); + let duration = chrono_humanize::HumanTime::from(duration); + duration.to_string() +} + +#[async_trait] +impl MessageDigestor for Arc { + async fn digest(&self, message: &Message) -> Result> { + lazy_static! { + static ref REGEX_GET_DIRECTIVE: regex::Regex = + regex::Regex::new(r"get med info$").unwrap(); + } + if REGEX_GET_DIRECTIVE.is_match(&message.body) { + let next_doses = log::project_next_doses(self).await?; + + let mut msg = MessageBuilder::new(); + msg.push_line(""); + + for next_dose in next_doses.iter() { + msg.push_bold(next_dose.0.name.to_string()); + msg.push_line(":"); + msg.push_line(format!("Offset: {:.2}", next_dose.1.dose_offset,)); + msg.push_line(format!( + "Next dose: {}", + format_relative_time( + DateTime::::from_utc(next_dose.1.time_expected, chrono::Utc), + chrono::Utc::now() + ) + )); + msg.push_line(""); + } + + return Ok(Some(Message { + subject: "".to_string(), + priority: 0, + body: msg.build(), + mime: message.mime.clone(), + })); + } + + Ok(None) + } +} + +#[async_trait] impl App for MedManagementApp { - fn initialize( + async fn initialize( self: Arc, _config: &'static Config, app_state: Arc>, - ) -> Pin>> { - Box::pin(async move { - let mut state = self.state.lock().await; - *state = Some(MedManagementAppState { - global_app_state: app_state, - }); - }) + ) { + let mut state = self.state.lock().await; + *state = Some(MedManagementAppState { + global_app_state: app_state, + }); } fn api_routes(self: Arc) -> Router { @@ -81,4 +133,7 @@ impl App for MedManagementApp { ) .layer(Extension(self.clone())) } + fn message_digestors(self: Arc) -> Vec> { + vec![Box::new(self)] + } } diff --git a/src/apps/mod.rs b/src/apps/mod.rs index f47c788..ba42597 100644 --- a/src/apps/mod.rs +++ b/src/apps/mod.rs @@ -1,23 +1,22 @@ -use crate::{config::Config, AppState}; +use crate::{comm::MessageDigestor, config::Config, AppState}; +use async_trait::async_trait; use axum::Router; +use std::sync::Arc; +use tokio::sync::Mutex; -use std::{ - future::Future, - pin::Pin, - sync::{Arc, Mutex}, -}; - +#[async_trait] pub trait App { - fn initialize( + async fn initialize( self: Arc, _config: &'static Config, _app_state: Arc>, - ) -> Pin>> { - Box::pin(async {}) - } + ); fn api_routes(self: Arc) -> Router { Router::new() } + fn message_digestors(self: Arc) -> Vec> { + vec![] + } } pub mod auth; diff --git a/src/apps/server_info/mod.rs b/src/apps/server_info/mod.rs index 060a936..b5f1a3b 100644 --- a/src/apps/server_info/mod.rs +++ b/src/apps/server_info/mod.rs @@ -1,6 +1,14 @@ use std::sync::Arc; -use crate::{apps::App, http::ApiResponse}; +use crate::{ + apps::App, + comm::{discord::MIME_DISCORD, Message, MessageDigestor}, + config::Config, + http::ApiResponse, + AppState, +}; +use anyhow::Result; +use async_trait::async_trait; use axum::{ body::HttpBody, http::Request, @@ -8,7 +16,9 @@ use axum::{ routing::get, Router, }; +use lazy_static::lazy_static; use serde::{Deserialize, Serialize}; +use tokio::sync::Mutex; pub struct ServerInfoApp {} @@ -44,8 +54,46 @@ impl ServerInfoApp { } } +#[async_trait] +impl MessageDigestor for Arc { + async fn digest(&self, message: &Message) -> Result> { + lazy_static! { + static ref REGEXP_ASK_SERVER_INFO: regex::Regex = + regex::Regex::new(r"server info$").unwrap(); + } + if REGEXP_ASK_SERVER_INFO.is_match(&message.body) { + let server_info = ServerInfo { + version: env!("CARGO_PKG_VERSION").to_string(), + profile: PROFILE.to_string(), + }; + let ret = format!( + "Server info:\nVersion: {}\nProfile: {}", + server_info.version, server_info.profile + ); + return Ok(Some(Message { + body: ret, + subject: "".to_string(), + priority: 0, + mime: MIME_DISCORD, + })); + } + + Ok(None) + } +} + +#[async_trait] impl App for ServerInfoApp { + async fn initialize( + self: Arc, + _config: &'static Config, + _app_state: Arc>, + ) { + } fn api_routes(self: Arc) -> Router { Router::new().route("/server_info", get(get_server_info)) } + fn message_digestors(self: Arc) -> Vec> { + vec![Box::new(self)] + } } diff --git a/src/apps/webcheck/driver/chrome.rs b/src/apps/webcheck/driver/chrome.rs index e6e40bf..48d2e2e 100644 --- a/src/apps/webcheck/driver/chrome.rs +++ b/src/apps/webcheck/driver/chrome.rs @@ -9,7 +9,7 @@ pub struct ChromeDriver { impl ChromeDriver { pub fn new() -> Self { - let port = rand_core::OsRng.next_u32() as u16 + 10000; + let port = (rand_core::OsRng.next_u32() % 1024) as u16 + 10000; Self::new_port(port) } pub fn new_port(port: u16) -> Self { diff --git a/src/apps/webcheck/mod.rs b/src/apps/webcheck/mod.rs index ae4a9d5..161127b 100644 --- a/src/apps/webcheck/mod.rs +++ b/src/apps/webcheck/mod.rs @@ -1,9 +1,4 @@ -use std::{ - collections::HashMap, - future::Future, - pin::Pin, - sync::{Arc, Mutex}, -}; +use std::{collections::HashMap, sync::Arc}; use async_trait::async_trait; use axum::{routing::get, Extension, Router}; @@ -11,7 +6,7 @@ use chrono::DateTime; use log::info; use serde::{Deserialize, Serialize}; use thirtyfour::{DesiredCapabilities, WebDriver}; -use tokio::sync::Mutex as AsyncMutex; +use tokio::sync::Mutex; use crate::{ comm::{Communicator, Message}, @@ -29,7 +24,7 @@ mod driver; mod utd_app; pub struct WebcheckApp { - state: AsyncMutex, + state: Mutex, } struct WebcheckAppState { @@ -69,7 +64,7 @@ pub trait WebDriverChecker { impl WebcheckApp { pub fn new() -> Self { Self { - state: AsyncMutex::new(WebcheckAppState { + state: Mutex::new(WebcheckAppState { config: None, global_app_state: None, last_response: HashMap::new(), @@ -116,14 +111,15 @@ impl WebcheckApp { .as_ref() .unwrap() .lock() - .unwrap() + .await .comm .send_message(&Message { subject: format!("webcheck {} changed", key), body: format!("{} changed to {}", key, response), mime: "text/plain", priority: 0, - })?; + }) + .await?; } } None => {} @@ -167,35 +163,30 @@ impl WebcheckApp { } } +#[async_trait] impl App for WebcheckApp { - fn initialize( - self: Arc, - config: &'static Config, - app_state: Arc>, - ) -> Pin>> { - Box::pin(async move { - let mut state = self.state.lock().await; - state.config = Some(config); - state.global_app_state = Some(app_state); + async fn initialize(self: Arc, config: &'static Config, app_state: Arc>) { + let mut state = self.state.lock().await; + state.config = Some(config); + state.global_app_state = Some(app_state); - let Some(ref config) = config.webcheck else { + let Some(ref config) = config.webcheck else { return; }; - config.keys().for_each(|key| match key.as_str() { - "utd_app" => { - let mut checker = utd_app::UTDAppChecker::new(); - checker - .init(config.get(key).unwrap()) - .expect("Failed to initialize UTDAppChecker"); - state.checkers.insert(key.clone(), Box::new(checker)); - } - _ => panic!("Invalid key in webcheck config: {}", key), - }); + config.keys().for_each(|key| match key.as_str() { + "utd_app" => { + let mut checker = utd_app::UTDAppChecker::new(); + checker + .init(config.get(key).unwrap()) + .expect("Failed to initialize UTDAppChecker"); + state.checkers.insert(key.clone(), Box::new(checker)); + } + _ => panic!("Invalid key in webcheck config: {}", key), + }); - let self_clone = self.clone(); - tokio::spawn(self_clone.run_check_loops()); - }) + let self_clone = self.clone(); + tokio::spawn(self_clone.run_check_loops()); } fn api_routes(self: Arc) -> Router { diff --git a/src/comm/discord.rs b/src/comm/discord.rs new file mode 100644 index 0000000..00a34a5 --- /dev/null +++ b/src/comm/discord.rs @@ -0,0 +1,128 @@ +use std::sync::Arc; + +use anyhow::Result; +use async_trait::async_trait; + +use log::info; +use serenity::model::channel::Message; +use serenity::model::gateway::Ready; +use serenity::prelude::*; + +use serenity::model::prelude::{ChannelId, UserId}; + +use crate::config::comm::DiscordConfig; + +use super::{Communicator, MessageDigestor}; + +struct Handler { + state: Mutex, + channel_id: ChannelId, + digestor: Arc>>, +} + +impl Handler { + fn new( + channel_id: ChannelId, + digestor: Arc>>, + ) -> Self { + Self { + state: Mutex::new(HandlerState { bot_id: UserId(0) }), + channel_id, + digestor, + } + } +} + +struct HandlerState { + bot_id: UserId, +} + +#[async_trait] +impl EventHandler for Handler { + async fn message(&self, ctx: Context, msg: Message) { + if msg.channel_id == self.channel_id { + info!("Received message: {:?}", msg.content); + let msg_generic = super::Message { + subject: "".to_string(), + body: msg.content.to_string(), + mime: MIME_DISCORD, + priority: 0, + }; + for digestor in self.digestor.iter() { + match digestor.digest(&msg_generic).await { + Err(e) => { + info!("Error digesting message: {:?}", e); + } + Ok(Some(reply)) => { + if let Err(why) = msg.channel_id.say(&ctx.http, reply.body).await { + info!("Error sending message: {:?}", why); + } + } + Ok(None) => {} + } + } + } + } + + async fn ready(&self, _: Context, ready: Ready) { + let mut state = self.state.lock().await; + state.bot_id = ready.user.id; + } +} + +pub struct DiscordCommunicator { + client: Client, + config: &'static DiscordConfig, +} + +impl DiscordCommunicator { + pub async fn new( + config: &'static DiscordConfig, + digestor: Arc>>, + ) -> Self { + let channel_id = ChannelId(config.channel_id); + + let intents = GatewayIntents::non_privileged() + | GatewayIntents::GUILD_MESSAGES + | GatewayIntents::DIRECT_MESSAGES + | GatewayIntents::MESSAGE_CONTENT; + + let handler = Handler::new(channel_id, digestor); + + let mut client = Client::builder(config.token.to_string(), intents) + .event_handler(handler) + .await + .expect("Error creating client"); + + tokio::spawn(async move { + if let Err(why) = client.start().await { + info!("Client error: {:?}", why); + } + }); + + let client = Client::builder(config.token.to_string(), intents) + .await + .expect("Error creating client"); + + Self { client, config } + } +} + +pub const MIME_DISCORD: &'static str = "application/vnd.discord"; + +#[async_trait] +impl Communicator for DiscordCommunicator { + fn name(&self) -> &'static str { + "Discord" + } + fn supported_mimes(&self) -> Vec<&'static str> { + vec![MIME_DISCORD] + } + async fn send_message(&self, message: &super::Message) -> Result<()> { + let channel = ChannelId(self.config.channel_id); + channel + .say(&self.client.cache_and_http.http, message.body.to_string()) + .await?; + Ok(()) + } +} diff --git a/src/comm/email.rs b/src/comm/email.rs index 22b6af6..f85af22 100644 --- a/src/comm/email.rs +++ b/src/comm/email.rs @@ -1,3 +1,4 @@ +use async_trait::async_trait; use lettre::{ message::header::ContentType, transport::smtp::authentication::Credentials, Transport, }; @@ -12,11 +13,16 @@ pub struct EmailCommunicator { impl EmailCommunicator { pub fn new(config: &'static Config) -> Self { Self { - config: &config.comm.email, + config: &config + .comm + .email + .as_ref() + .expect("Email communicator not configured"), } } } +#[async_trait] impl Communicator for EmailCommunicator { fn name(&self) -> &'static str { "email" @@ -24,7 +30,7 @@ impl Communicator for EmailCommunicator { fn supported_mimes(&self) -> Vec<&'static str> { vec!["text/plain", "text/html"] } - fn send_message(&self, message: &super::Message) -> anyhow::Result<()> { + async fn send_message(&self, message: &super::Message) -> anyhow::Result<()> { let mailer = lettre::SmtpTransport::relay(&self.config.host)? .credentials(Credentials::new( self.config.username.clone(), diff --git a/src/comm/gotify.rs b/src/comm/gotify.rs index 68d096b..c410240 100644 --- a/src/comm/gotify.rs +++ b/src/comm/gotify.rs @@ -1,7 +1,7 @@ use super::Communicator; use crate::config::{comm::GotifyConfig, Config}; +use async_trait::async_trait; use serde::Serialize; - pub struct GotifyCommunicator { config: &'static GotifyConfig, } @@ -9,7 +9,11 @@ pub struct GotifyCommunicator { impl GotifyCommunicator { pub fn new(config: &'static Config) -> Self { Self { - config: &config.comm.gotify, + config: &config + .comm + .gotify + .as_ref() + .expect("Gotify communicator not configured"), } } } @@ -49,6 +53,7 @@ struct GotifyMessageExtrasClientDisplay { content_type: String, } +#[async_trait] impl Communicator for GotifyCommunicator { fn name(&self) -> &'static str { "gotify" @@ -56,13 +61,14 @@ impl Communicator for GotifyCommunicator { fn supported_mimes(&self) -> Vec<&'static str> { vec!["text/plain", "text/markdown"] } - fn send_message(&self, message: &super::Message) -> anyhow::Result<()> { - let client = reqwest::blocking::Client::new(); + async fn send_message(&self, message: &super::Message) -> anyhow::Result<()> { + let client = reqwest::Client::new(); let response = client .post(&format!("{}/message", self.config.url)) .header("X-Gotify-Key", &self.config.token) .json::(&message.into()) - .send()?; + .send() + .await?; if !response.status().is_success() { anyhow::bail!("Gotify returned an error: {:?}", response); diff --git a/src/comm/mod.rs b/src/comm/mod.rs index f12b8b1..74d83f0 100644 --- a/src/comm/mod.rs +++ b/src/comm/mod.rs @@ -1,8 +1,10 @@ use std::{collections::HashMap, sync::Arc}; use anyhow::Result; +use async_trait::async_trait; use log::{error, warn}; +pub mod discord; pub mod email; pub mod gotify; @@ -28,10 +30,16 @@ impl Default for Message { pub const MIME_PLAIN: &'static str = "text/plain"; pub const MIME_HTML: &'static str = "text/html"; +#[async_trait] pub trait Communicator { fn name(&self) -> &'static str; fn supported_mimes(&self) -> Vec<&'static str>; - fn send_message(&self, message: &Message) -> Result<()>; + async fn send_message(&self, message: &Message) -> Result<()>; +} + +#[async_trait] +pub trait MessageDigestor { + async fn digest(&self, message: &Message) -> Result>; } pub struct GlobalCommunicator { @@ -70,6 +78,7 @@ impl GlobalCommunicator { } } +#[async_trait] impl Communicator for GlobalCommunicator { fn name(&self) -> &'static str { "global" @@ -79,11 +88,11 @@ impl Communicator for GlobalCommunicator { self.communicators.keys().map(|k| *k).collect() } - fn send_message(&self, message: &Message) -> Result<()> { + async fn send_message(&self, message: &Message) -> Result<()> { let mime = message.mime; if let Some(communicators) = self.communicators.get(mime) { for communicator in communicators { - if let Err(e) = communicator.send_message(message) { + if let Err(e) = communicator.send_message(message).await { warn!("Failed to send message with {}: {}", communicator.name(), e); continue; } diff --git a/src/config/comm.rs b/src/config/comm.rs index c16e08a..a3e6d82 100644 --- a/src/config/comm.rs +++ b/src/config/comm.rs @@ -2,8 +2,9 @@ use serde::Deserialize; #[derive(Debug, Deserialize)] pub struct Config { - pub email: EmailConfig, - pub gotify: GotifyConfig, + pub email: Option, + pub gotify: Option, + pub discord: Option, } #[derive(Debug, Deserialize)] @@ -22,3 +23,9 @@ pub struct GotifyConfig { pub url: String, pub token: String, } + +#[derive(Debug, Deserialize)] +pub struct DiscordConfig { + pub token: String, + pub channel_id: u64, +} diff --git a/src/lib.rs b/src/lib.rs index f52bb76..ed3a4eb 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,4 +1,4 @@ -use std::sync::{Arc, Mutex}; +use std::sync::Arc; use apps::{med, webcheck}; use axum::{http::Request, middleware::Next, response::Response, routing::get, Extension, Router}; @@ -7,6 +7,7 @@ use diesel::{sqlite, Connection}; use diesel_migrations::{embed_migrations, EmbeddedMigrations, MigrationHarness}; use hyper::Method; use log::info; +use tokio::sync::Mutex; use tower_http::cors::{self, CorsLayer}; use crate::{ @@ -85,7 +86,7 @@ pub async fn server_listen(router: Router) { pub async fn main_server(dev: bool) { let config = config::get_config(); - let apps: &mut [Arc] = &mut [ + let apps: &mut [Arc] = &mut [ Arc::new(server_info::ServerInfoApp::new()), Arc::new(auth::AuthApp::new()), Arc::new(canvas_lms::CanvasLMSApp::new()), @@ -93,9 +94,29 @@ pub async fn main_server(dev: bool) { Arc::new(webcheck::WebcheckApp::new()), ]; + let mut message_digestor = Vec::new(); + for app in &mut *apps { + message_digestor.extend(app.clone().message_digestors()); + } + let message_digestor = Arc::new(message_digestor); + let mut comm = GlobalCommunicator::new(); - comm.add_communicator(Arc::new(comm::gotify::GotifyCommunicator::new(config))); - comm.add_communicator(Arc::new(comm::email::EmailCommunicator::new(config))); + if config.comm.discord.is_some() { + let discord_comm = Arc::new( + comm::discord::DiscordCommunicator::new( + config.comm.discord.as_ref().unwrap(), + message_digestor, + ) + .await, + ); + comm.add_communicator(discord_comm); + } + if config.comm.gotify.is_some() { + comm.add_communicator(Arc::new(comm::gotify::GotifyCommunicator::new(config))); + } + if config.comm.email.is_some() { + comm.add_communicator(Arc::new(comm::email::EmailCommunicator::new(config))); + } let app_state = Arc::new(Mutex::new(AppState { db: establish_db_connection(), comm: comm,