diff --git a/.gitignore b/.gitignore index a3a9707..deb0e09 100644 --- a/.gitignore +++ b/.gitignore @@ -40,4 +40,8 @@ Cow.dbmdl Cow.sqlproj.user obj/ Cow.jfm -gpt/ \ No newline at end of file +gpt/ +ucm_maps/ + +# vscode +.vscode \ No newline at end of file diff --git a/Cargo.toml b/Cargo.toml index a324091..03580c8 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -50,6 +50,8 @@ sysinfo = "0.29.8" proto-mc = { git = "https://github.com/DoggySazHi/proto-mc" } # RNG rand = "0.8.5" +pdfium-render = "0.8.16" +image = "0.24.7" # Discord API [dependencies.serenity] diff --git a/README.md b/README.md index cb6bd84..1bebdea 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,10 @@ # cow Discord bot for the UC-Mooced Discord. Written by William and Andrew in Rust. + +### Building Map Download Instructions. +There's no pure-rust library for rendering pdfs to images. + +You need to get pdfium from Chromium. +Either build from source or download the prebuilt .so [file](https://github.com/bblanchon/pdfium-binaries?tab=readme-ov-file) + +And make sure LD_LIBRARY_PATH is set to where the .so file is (or place it in /usr/lib) \ No newline at end of file diff --git a/src/commands/ucm/map.rs b/src/commands/ucm/map.rs new file mode 100644 index 0000000..cddd070 --- /dev/null +++ b/src/commands/ucm/map.rs @@ -0,0 +1,130 @@ +use std::{borrow::Cow, io::Read}; + +use image::EncodableLayout; +use poise::serenity_prelude::AttachmentType; +use serenity::{model::application::component::ButtonStyle, client::Context}; +use serenity::model::application::interaction::message_component::MessageComponentInteraction; +use crate::{CowContext, Error}; + +#[poise::command( + prefix_command, + slash_command, + description_localized("en-US", "Returns building floor plans"), +)] +pub async fn map( + ctx: CowContext<'_>, + #[description = "Building name"] building: String) +-> Result<(), Error> { + + let building_short_name = get_short_building_name(building.to_string()); + let path = format!("ucm_maps/{}", building_short_name); + + let mut entries = tokio::fs::read_dir(path).await?; + let mut max_floors = 0; + while let Some(_) = entries.next_entry().await? { + max_floors += 1; + } + + let building_short_name = get_short_building_name(building.to_string()); + let path = format!("ucm_maps/{}/floor_{}.jpg", building_short_name, 0); + + let img = tokio::fs::read(path).await? + .bytes() + .collect::, std::io::Error>>()?; + + ctx.send(|m| { + m.embed(|e| { + e + .title(format!("Map Floor of {}", building)) + .attachment("floor.jpg") + }) + .attachment(AttachmentType::Bytes { data: Cow::from(img.as_bytes().to_owned()), filename: "floor.jpg".to_string() }); + + if max_floors > 1 { + m.components(|c| { + c.create_action_row(|r| { + + r.create_button(|b| { + let data = format!("map_next_floor:{}:{}:{}", building, 1, max_floors); + + b.style(ButtonStyle::Primary) + .label("Next Floor") + .custom_id(data) + }) + }) + }); + } + + m + }).await?; + + Ok(()) +} + +fn get_short_building_name(building: String) -> String { + match &building[..] { + "campus" => "campus_map_recent", + "cob1" => "cob", + "glacier" => "glcr", + "granite" => "gran", + "library" => "kl", + "se1" => "s_e1", + "ssb" => "ssb_firstfloor", + _ => &building[..] + }.to_owned() +} + +pub async fn map_next_floor(ctx: &Context, interaction: &mut MessageComponentInteraction) -> Result<(), Error> { + let data = interaction.data.custom_id.split(':').collect::>(); + + let building = data[1]; + let idx = str::parse::(data[2])?; + let floors = str::parse::(data[3])?; + + let building_short_name = get_short_building_name(building.to_string()); + let path = format!("ucm_maps/{}/floor_{}.jpg", building_short_name, idx); + + let img = tokio::fs::read(path).await? + .bytes() + .collect::, std::io::Error>>()?; + + interaction.message.edit(ctx, |m| { + m.embed(|e| { + e + .title(format!("Map Floor of {}", building)) + .attachment("floor.jpg") + }) + .attachment(AttachmentType::Bytes { data: Cow::from(img.as_bytes().to_owned()), filename: "floor.jpg".to_string() }); + + m.components(|c| { + c.create_action_row(|r| { + + if idx > 0 { + r.create_button(|b| { + let data = format!("map_next_floor:{}:{}:{}", building, idx-1, floors); + + b.style(ButtonStyle::Primary) + .label("Previous Floor") + .custom_id(data) + + }); + } + + if idx < floors-1 { + r.create_button(|b| { + let data = format!("map_next_floor:{}:{}:{}", building, idx+1, floors); + + b.style(ButtonStyle::Primary) + .label("Next Floor") + .custom_id(data) + + }); + } + + r + }) + }) + }).await?; + + Ok(()) +} diff --git a/src/commands/ucm/mod.rs b/src/commands/ucm/mod.rs index cb56e35..2a49f58 100644 --- a/src/commands/ucm/mod.rs +++ b/src/commands/ucm/mod.rs @@ -13,6 +13,7 @@ mod foodtrucks; mod calendar; mod gym; mod store; +pub(crate) mod map; use library::*; use courses::*; @@ -24,10 +25,11 @@ use calendar::*; use gym::*; use store::*; use reminders::*; +use map::*; use crate::{CowContext, Error}; #[poise::command(prefix_command, slash_command, - subcommands("library", "courses", "courses_old", "pavilion", "professors", "foodtrucks", "calendar", "gym", "store", "reminders"), + subcommands("library", "courses", "courses_old", "pavilion", "professors", "foodtrucks", "calendar", "gym", "store", "reminders", "map"), discard_spare_arguments, description_localized("en-US", "Get information about UC Merced's services and facilities."), aliases("ucmerced"), diff --git a/src/main.rs b/src/main.rs index a8138ae..d49d72d 100644 --- a/src/main.rs +++ b/src/main.rs @@ -65,7 +65,7 @@ impl EventHandler for Handler { } async fn interaction_create(&self, ctx: Context, interaction: Interaction) { - if let Some(component) = interaction.message_component() { + if let Some(mut component) = interaction.message_component() { if component.data.custom_id.starts_with("full_menu") { if let Err(ex) = component.defer(&ctx).await { error!("Failed to defer component: {}", ex); @@ -75,6 +75,15 @@ impl EventHandler for Handler { if let Err(ex) = commands::ucm::pavilion::print_full_menu(&ctx, &component).await { error!("Failed to print full menu: {}", ex); } + } else if component.data.custom_id.starts_with("map_next_floor") { + if let Err(ex) = component.defer(&ctx).await { + error!("Failed to defer component: {}", ex); + return; + } + + if let Err(ex) = commands::ucm::map::map_next_floor(&ctx, &mut component).await { + error!("Failed to get next map floor: {}", ex); + } } } } @@ -206,6 +215,9 @@ async fn main() -> Result<(), Box> { } } + // download ucm maps and save them in this directory + let _ = tokio::task::spawn(services::map_download::dl_and_convert_all_maps()); + if let Err(ex) = poise.start().await { error!("Discord bot client error: {:?}", ex); } diff --git a/src/services/map_download.rs b/src/services/map_download.rs new file mode 100644 index 0000000..7d6bd7a --- /dev/null +++ b/src/services/map_download.rs @@ -0,0 +1,140 @@ +use std::{error::Error, path::Path}; +use scraper::{Html, Selector}; +use tokio::task::JoinSet; + +use pdfium_render::prelude::*; + +/* Taken straight from https://docs.rs/pdfium-render/latest/pdfium_render/ */ +// sadly nothing is async. pdfium dynlib bindings struct can't be shared through Arc> +// it's already guarded behind a mutex, threading cannot improve performance: https://github.com/ajrcarey/pdfium-render/blob/master/examples/thread_safe.rs +fn export_pdf_to_jpegs(path: &impl AsRef, out_path: &str, password: Option<&str>) -> Result<(), PdfiumError> { + let pdfium = Pdfium::new( + Pdfium::bind_to_library(Pdfium::pdfium_platform_library_name_at_path("./")) + .or_else(|_| Pdfium::bind_to_system_library())?, + ); + + let document = pdfium.load_pdf_from_file(path, password)?; + + let render_config = PdfRenderConfig::new() + .set_target_width(2000) + .set_maximum_height(2000); + + for (index, page) in document.pages().iter().enumerate() { + page.render_with_config(&render_config)? + .as_image() // Renders this page to an image::DynamicImage... + .as_rgba8() // ... then converts it to an image::Image... + .ok_or(PdfiumError::ImageError)? + .save_with_format( + format!("{}/floor_{}.jpg", out_path, index), + image::ImageFormat::Jpeg + ) // ... and saves it to a file. + .map_err(|_| PdfiumError::ImageError)?; + } + + Ok(()) +} + +async fn get_map_urls() -> Result, Box> { + let base_url = "https://registrar.ucmerced.edu/resources/maps"; + let registrar_map_txt = reqwest::get(base_url).await?.text().await?; + + let page = Html::parse_document(®istrar_map_txt); + let a_selector = Selector::parse("a").ok().ok_or("No found")?; + let a_elements = page.select(&a_selector); + + let urls_result: Result, Box> = a_elements + .map(|a_elem| { + a_elem + .value() + .attr("href") + .ok_or("no href".into()) + .map(String::from) + }) + .collect(); + + let urls = urls_result?; + let map_urls = urls + .iter() + .filter(|url| url.contains("https://registrar.ucmerced.edu/sites/registrar.ucmerced.edu/files/page/documents")) + .cloned() + .collect::>(); + + Ok(map_urls) +} + +async fn dl_all_maps_pdf() -> Result, Box> { + let map_urls = get_map_urls().await?; + + tokio::fs::create_dir_all("ucm_maps").await?; + + let mut set = JoinSet::new(); + + for url in map_urls { + set.spawn(async move { + dl_map(&url).await + }); + } + + let mut map_names = vec![]; + while let Some(res) = set.join_next().await { + map_names.push(res??); + } + + Ok(map_names) +} + +async fn dl_map(url: &str) -> Result> { + let bytes = reqwest::get(url) + .await? + .bytes() + .await?; + + let name = url.split("/").last().ok_or("invalid file name")?; + + tokio::fs::write(format!("ucm_maps/{}", name), bytes).await?; + + Ok(name.to_owned()) +} + + +pub async fn dl_and_convert_all_maps() -> Result<(), Box> { + if tokio::fs::metadata("ucm_maps").await.is_ok() { // https://users.rust-lang.org/t/tokio-async-how-to-check-if-a-file-exists/80962/7 + return Ok(()); + } + + let names = dl_all_maps_pdf().await?; + + let mut set = JoinSet::new(); + + for name in names { + set.spawn(async move { + let dirname = name.split(".").next(); + if let None = dirname { + return Err("bad pdf name".to_string()); + } + + let dirname = dirname.unwrap(); + + if let Err(err) = tokio::fs::create_dir_all(format!("ucm_maps/{}", dirname)).await { + return Err(err.to_string()); + } + + if let Err(err) = export_pdf_to_jpegs(&Path::new(&format!("ucm_maps/{}", name)), &format!("ucm_maps/{}", dirname), None) { + return Err(err.to_string()); + } + + Ok(()) + }); + } + + while let Some(res) = set.join_next().await { + let _ = res?; + } + + Ok(()) +} + +#[tokio::test] +async fn test_dl_all_maps() { + println!("{:?}", dl_and_convert_all_maps().await); +} \ No newline at end of file diff --git a/src/services/mod.rs b/src/services/mod.rs index 7a1b986..a546f2b 100644 --- a/src/services/mod.rs +++ b/src/services/mod.rs @@ -1,5 +1,6 @@ pub mod message_handler; pub mod bot_init; pub mod database; +pub mod map_download; mod minecraft_db; mod gpt_db; \ No newline at end of file