From ed778ab2060c6131caf98231a97873d7ea490d5a Mon Sep 17 00:00:00 2001 From: Nathan Ringo Date: Tue, 16 Jan 2024 00:57:41 -0600 Subject: The start of database functionality. --- src/bin/add-student-x500s.rs | 144 +++++++++++++++++++++++++++++++++++++++++++ src/bin/lambo.rs | 85 +++++++++++++++++++++++++ src/config.rs | 17 +++++ src/handlers/x500_mapper.rs | 21 +++++-- src/lib.rs | 2 + src/main.rs | 87 -------------------------- 6 files changed, 263 insertions(+), 93 deletions(-) create mode 100644 src/bin/add-student-x500s.rs create mode 100644 src/bin/lambo.rs create mode 100644 src/config.rs create mode 100644 src/lib.rs delete mode 100644 src/main.rs (limited to 'src') diff --git a/src/bin/add-student-x500s.rs b/src/bin/add-student-x500s.rs new file mode 100644 index 0000000..14023b1 --- /dev/null +++ b/src/bin/add-student-x500s.rs @@ -0,0 +1,144 @@ +use anyhow::{bail, Context, Result}; +use clap::{value_parser, ArgAction, Parser}; +use futures::{stream, FutureExt, StreamExt}; +use lambo::config::Config; +use sqlx::sqlite::SqlitePoolOptions; +use std::{fs, path::PathBuf}; +use stderrlog::StdErrLog; + +#[derive(Debug, Parser)] +struct Args { + /// The path to the lambo configuration file. + config_path: PathBuf, + + /// The path to the CSV file, as exported from Canvas's gradebook. + csv_path: PathBuf, + + /// Decreases the log level. + #[clap( + short, + long, + conflicts_with("verbose"), + action = ArgAction::Count, + value_parser = value_parser!(u8).range(..=2) + )] + quiet: u8, + + /// Increases the log level. + #[clap( + short, + long, + conflicts_with("quiet"), + action = ArgAction::Count, + value_parser = value_parser!(u8).range(..=3) + )] + verbose: u8, +} + +#[tokio::main] +async fn main() -> Result<()> { + // Parse the arguments. + let args = Args::parse(); + + // Set up logging. + { + let mut logger = StdErrLog::new(); + match args.quiet { + 0 => logger.verbosity(1 + args.verbose as usize), + 1 => logger.verbosity(0), + 2 => logger.quiet(true), + // UNREACHABLE: A maximum of two occurrences of quiet are allowed. + _ => unreachable!(), + }; + // UNWRAP: No other logger should be set up. + logger.show_module_names(true).init().unwrap() + } + + // Parse the config file. + let config = Config::read_from_file(&args.config_path)?; + + // Connect to the database. + let db = SqlitePoolOptions::new() + .connect(&config.database_url) + .await + .with_context(|| format!("failed to connect to database at {:?}", config.database_url))?; + + // Run any necessary migrations. + sqlx::migrate!().run(&db).await.with_context(|| { + format!( + "failed to run migrations on database at {:?}", + config.database_url + ) + })?; + + // Read the CSV file. + let csv_string = fs::read_to_string(&args.csv_path) + .with_context(|| format!("failed to read {:?}", args.csv_path))?; + + // Skip the first two lines; Canvas puts two lines of headers in... + let (i, _) = csv_string + .match_indices('\n') + .nth(1) + .context("invalid CSV file (not enough lines)")?; + let csv_string = &csv_string[i..]; + + // Parse the CSV file. + let mut csv = csv::ReaderBuilder::new() + .has_headers(false) + .from_reader(csv_string.as_bytes()) + .records() + .collect::, _>>() + .with_context(|| format!("failed to parse {:?}", args.csv_path))?; + + // Remove the test student. + csv.retain(|record| &record[0] != "Student, Test"); + + // Collect all the X.500s, checking that they were of the right form. + let x500s = csv + .into_iter() + .map(|record| { + let email = &record[3]; + if let Some(x500) = email.strip_suffix("@umn.edu") { + Ok(x500.to_string()) + } else { + bail!("not a valid UMN email: {:?}", email) + } + }) + .collect::>>()?; + + // Insert them all in the database. + // + // Looks like sqlx doesn't actually have bulk insert? WTF? + // + // https://github.com/launchbadge/sqlx/issues/294 + let db = &db; + let errors = stream::iter(x500s) + .map(|x500| async move { + sqlx::query!( + "INSERT OR IGNORE INTO student_x500s (x500) VALUES (?)", + x500 + ) + .execute(db) + .await + .context("failed to insert X.500s") + }) + .filter_map(|future| future.map(|r| r.err())) + .collect::>() + .await; + if !errors.is_empty() { + log::error!("encountered {} errors:", errors.len()); + for error in errors { + log::error!("{:?}", error); + } + bail!("failed to insert X.500s") + } + + // Count the number of X.500s. + let x500_count = sqlx::query!("SELECT COUNT(x500) as count from student_x500s") + .fetch_one(db) + .await + .context("failed to get a count of X.500s")?; + log::info!("We now have {} student X.500s", x500_count.count); + + Ok(()) +} diff --git a/src/bin/lambo.rs b/src/bin/lambo.rs new file mode 100644 index 0000000..e3e81a6 --- /dev/null +++ b/src/bin/lambo.rs @@ -0,0 +1,85 @@ +use anyhow::{Context as _, Result}; +use clap::{value_parser, ArgAction, Parser}; +use lambo::{config::Config, handlers::*}; +use serenity::{all::GatewayIntents, Client}; +use sqlx::sqlite::SqlitePoolOptions; +use std::path::PathBuf; +use stderrlog::StdErrLog; + +#[derive(Debug, Parser)] +struct Args { + /// The path to the lambo configuration file. + config_path: PathBuf, + + /// Decreases the log level. + #[clap( + short, + long, + conflicts_with("verbose"), + action = ArgAction::Count, + value_parser = value_parser!(u8).range(..=2) + )] + quiet: u8, + + /// Increases the log level. + #[clap( + short, + long, + conflicts_with("quiet"), + action = ArgAction::Count, + value_parser = value_parser!(u8).range(..=3) + )] + verbose: u8, +} + +#[tokio::main] +async fn main() -> Result<()> { + // Parse the arguments. + let args = Args::parse(); + + // Set up logging. + { + let mut logger = StdErrLog::new(); + match args.quiet { + 0 => logger.verbosity(1 + args.verbose as usize), + 1 => logger.verbosity(0), + 2 => logger.quiet(true), + // UNREACHABLE: A maximum of two occurrences of quiet are allowed. + _ => unreachable!(), + }; + // UNWRAP: No other logger should be set up. + logger.show_module_names(true).init().unwrap() + } + + // Parse the config file. + let config = Config::read_from_file(&args.config_path)?; + + // Connect to the database. + let db = SqlitePoolOptions::new() + .connect(&config.database_url) + .await + .with_context(|| format!("failed to connect to database at {:?}", config.database_url))?; + + // Run any necessary migrations. + sqlx::migrate!().run(&db).await.with_context(|| { + format!( + "failed to run migrations on database at {:?}", + config.database_url + ) + })?; + + // Create the handlers. + let handler = MultiHandler(vec![Box::new(PresenceSetter), Box::new(X500Mapper(db))]); + + // Start up the client. + let intents = GatewayIntents::default() | GatewayIntents::GUILD_MEMBERS; + Client::builder(&config.discord_token, intents) + .event_handler(handler) + .await + .context("failed to create Discord client")? + .start() + .await + .context("failed to start Discord client")?; + + Ok(()) +} diff --git a/src/config.rs b/src/config.rs new file mode 100644 index 0000000..14a4c19 --- /dev/null +++ b/src/config.rs @@ -0,0 +1,17 @@ +use anyhow::{Context, Result}; +use serde::Deserialize; +use std::{fs, path::Path}; + +#[derive(Debug, Deserialize)] +pub struct Config { + pub database_url: String, + pub discord_token: String, +} + +impl Config { + pub fn read_from_file(path: &Path) -> Result { + let config_str = fs::read_to_string(path) + .with_context(|| format!("failed to read {}", path.display()))?; + toml::from_str(&config_str).with_context(|| format!("failed to parse {}", path.display())) + } +} diff --git a/src/handlers/x500_mapper.rs b/src/handlers/x500_mapper.rs index f477e97..1dd8955 100644 --- a/src/handlers/x500_mapper.rs +++ b/src/handlers/x500_mapper.rs @@ -1,22 +1,31 @@ use serenity::{ - all::{GuildMemberUpdateEvent, Member}, + all::{GuildMemberUpdateEvent, Member, UserId}, async_trait, client::{Context, EventHandler}, }; +use sqlx::{Database, Pool}; /// A handler that notices people with an X.500 in their nicknames that matches a student's, and /// records it in the database. -pub struct X500Mapper; +pub struct X500Mapper(pub Pool); + +impl X500Mapper { + async fn notice_member(&self, nick: &str, uid: UserId) { + dbg!((nick, uid)); + } +} #[async_trait] -impl EventHandler for X500Mapper { +impl EventHandler for X500Mapper { async fn guild_member_update( &self, _ctx: Context, - old_if_available: Option, - new: Option, + _old_if_available: Option, + _new: Option, event: GuildMemberUpdateEvent, ) { - dbg!((old_if_available, new, event)); + if let Some(nick) = event.nick { + self.notice_member(&nick, event.user.id).await + } } } diff --git a/src/lib.rs b/src/lib.rs new file mode 100644 index 0000000..61d095d --- /dev/null +++ b/src/lib.rs @@ -0,0 +1,2 @@ +pub mod config; +pub mod handlers; diff --git a/src/main.rs b/src/main.rs deleted file mode 100644 index c9dbd7a..0000000 --- a/src/main.rs +++ /dev/null @@ -1,87 +0,0 @@ -mod handlers; - -use crate::handlers::*; -use anyhow::{Context as _, Result}; -use clap::{value_parser, ArgAction, Parser}; -use serde::Deserialize; -use serenity::{ - all::{ActivityData, GatewayIntents, GuildMemberUpdateEvent, Member, Ready}, - async_trait, - client::{Context, EventHandler}, - Client, -}; -use std::{fs, path::PathBuf}; -use stderrlog::StdErrLog; - -#[derive(Debug, Deserialize)] -struct Config { - discord_token: String, -} - -#[derive(Debug, Parser)] -struct Args { - /// The path to the configuration file. - config_path: PathBuf, - - /// Decreases the log level. - #[clap( - short, - long, - conflicts_with("verbose"), - action = ArgAction::Count, - value_parser = value_parser!(u8).range(..=2) - )] - quiet: u8, - - /// Increases the log level. - #[clap( - short, - long, - conflicts_with("quiet"), - action = ArgAction::Count, - value_parser = value_parser!(u8).range(..=3) - )] - verbose: u8, -} - -#[tokio::main] -async fn main() -> Result<()> { - let args = Args::parse(); - - // Set up logging. - { - let mut logger = StdErrLog::new(); - match args.quiet { - 0 => logger.verbosity(1 + args.verbose as usize), - 1 => logger.verbosity(0), - 2 => logger.quiet(true), - // UNREACHABLE: A maximum of two occurrences of quiet are allowed. - _ => unreachable!(), - }; - // UNWRAP: No other logger should be set up. - logger.show_module_names(true).init().unwrap() - } - - let config_str = fs::read_to_string(&args.config_path) - .with_context(|| format!("failed to read {}", args.config_path.display()))?; - let config: Config = toml::from_str(&config_str) - .with_context(|| format!("failed to parse {}", args.config_path.display()))?; - drop(config_str); - - let handler = MultiHandler(vec![Box::new(PresenceSetter), Box::new(X500Mapper)]); - - let mut client = Client::builder( - &config.discord_token, - GatewayIntents::default() | GatewayIntents::GUILD_MEMBERS, - ) - .event_handler(handler) - .await - .context("failed to create Discord client")?; - - client - .start() - .await - .context("failed to start Discord client")?; - - Ok(()) -} -- cgit v1.2.3