diff --git a/.woodpecker/deploy.yml b/.woodpecker/deploy.yml new file mode 100644 index 0000000..1fd44e2 --- /dev/null +++ b/.woodpecker/deploy.yml @@ -0,0 +1,22 @@ +platform: 'linux/amd64' +branches: 'main' + +pipeline: + release: + image: 'plugins/docker' + settings: + registry: 'git.rustybever.be' + repo: 'git.rustybever.be/chewing_bever/affy' + tag: + - 'latest' + mtu: 1300 + secrets: + - 'docker_username' + - 'docker_password' + + deploy: + image: 'curlimages/curl' + secrets: + - 'webhook' + commands: + - curl -XPOST --fail -s "$WEBHOOK" diff --git a/Cargo.lock b/Cargo.lock index b8388a2..1237a05 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -37,6 +37,7 @@ dependencies = [ "chrono", "diesel", "diesel_migrations", + "libsqlite3-sys", "poise", "tokio", "uuid", @@ -835,6 +836,7 @@ version = "0.26.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "afc22eff61b133b115c6e8c74e818c628d6d5e7a502afea6f64dee076dd94326" dependencies = [ + "cc", "pkg-config", "vcpkg", ] diff --git a/Cargo.toml b/Cargo.toml index cde15dc..518d125 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -24,3 +24,5 @@ poise = "0.5.5" async-minecraft-ping = "0.8.0" diesel = { version = "2.0.4", features = ["sqlite", "returning_clauses_for_sqlite_3_35", "r2d2"] } diesel_migrations = { version = "2.0.0", features = [ "sqlite" ] } +# Force sqlite3 to be bundled, allowing for a fully static binary +libsqlite3-sys = { version = "*", features = ["bundled"] } diff --git a/affluences-api/src/models/available.rs b/affluences-api/src/models/available.rs index 29df5c5..e10052c 100644 --- a/affluences-api/src/models/available.rs +++ b/affluences-api/src/models/available.rs @@ -57,7 +57,7 @@ impl Resource { duration = duration + Duration::minutes(hour.granularity.into()); } else { out.push((start_hour, duration)); - start_hour = &hour; + start_hour = hour; duration = Duration::minutes(hour.granularity.into()); } } @@ -73,4 +73,13 @@ impl Resource { .filter(|(hour, _)| hour.state == 1) .collect() } + + /// Returns whether a slot with the given state and time bounds is present in the list of + /// hours. + pub fn has_slot(&self, start_time: NaiveTime, end_time: NaiveTime, state: u32) -> bool { + self.condensed_hours() + .into_iter() + .filter(|(block, _)| block.state == state) + .any(|(block, duration)| start_time >= block.hour && end_time <= block.hour + duration) + } } diff --git a/src/build.rs b/build.rs similarity index 100% rename from src/build.rs rename to build.rs diff --git a/src/commands/affluence.rs b/src/commands/affluence.rs deleted file mode 100644 index 47d5f9b..0000000 --- a/src/commands/affluence.rs +++ /dev/null @@ -1,70 +0,0 @@ -use crate::commands::EmbedField; -use crate::{Context, Error}; - -use affluences_api::Resource; -use chrono::NaiveDate; -use uuid::{uuid, Uuid}; - -const STERRE_BIB_ID: Uuid = uuid!("4737e57a-ee05-4f7b-901a-7bb541eeb297"); -const TIME_FORMAT: &str = "%H:%M"; - -fn resource_to_embed_field(resource: Resource) -> EmbedField { - let available_hours = resource.condensed_available_hours(); - - if available_hours.is_empty() { - ( - resource.resource_name.clone(), - "Nothing available.".to_string(), - false, - ) - } else { - ( - resource.resource_name.clone(), - available_hours - .into_iter() - .map(|(start_block, duration)| { - format!( - "{} - {} ({:02}:{:02})", - start_block.hour.format(TIME_FORMAT), - (start_block.hour + duration).format(TIME_FORMAT), - duration.num_hours(), - duration.num_minutes() % 60 - ) - }) - .collect::>() - .join("\n"), - false, - ) - } -} - -/// List available timeslots for day -#[poise::command(prefix_command, slash_command)] -pub async fn available(ctx: Context<'_>, date: NaiveDate) -> Result<(), Error> { - let client = &ctx.data().client; - let resources = client.available(STERRE_BIB_ID, date, 1).await?; - - ctx.send(|f| { - f.embed(|e| { - e.description(format!("Available booking dates for {}.", date)) - .fields( - resources - .into_iter() - .map(resource_to_embed_field) - .collect::>(), - ) - }) - }) - .await?; - - Ok(()) -} - -// Create a reservation -// #[poise::command(prefix_command, slash_command)] -// pub async fn reserve( -// ctx: Context<'_>, -// date: NaiveDate, -// ) -> Result<(), Error> { - -// } diff --git a/src/commands/bib.rs b/src/commands/bib.rs new file mode 100644 index 0000000..32fbbe2 --- /dev/null +++ b/src/commands/bib.rs @@ -0,0 +1,165 @@ +use crate::commands::{EmbedField, HumanNaiveDate}; +use crate::db::users::User; +use crate::{Context, Error}; + +use affluences_api::{Reservation, Resource}; +use chrono::{NaiveDate, NaiveTime}; +use uuid::{uuid, Uuid}; + +const STERRE_BIB_ID: Uuid = uuid!("4737e57a-ee05-4f7b-901a-7bb541eeb297"); +const TIME_FORMAT: &str = "%H:%M"; + +/// Parent command for all bib-related commands +/// +/// Commands for reservating study rooms in the bib. +#[poise::command(prefix_command, slash_command, subcommands("available", "book"))] +pub async fn bib(_ctx: Context<'_>) -> Result<(), Error> { + Ok(()) +} + +fn resource_to_embed_field(resource: Resource) -> EmbedField { + let available_hours = resource.condensed_available_hours(); + let title = format!("{} ({}p)", resource.resource_name, resource.capacity); + + if available_hours.is_empty() { + (title, "Nothing available.".to_string(), false) + } else { + ( + title, + available_hours + .into_iter() + .map(|(start_block, duration)| { + format!( + "{} - {} ({:02}:{:02})", + start_block.hour.format(TIME_FORMAT), + (start_block.hour + duration).format(TIME_FORMAT), + duration.num_hours(), + duration.num_minutes() % 60 + ) + }) + .collect::>() + .join("\n"), + false, + ) + } +} + +/// List available timeslots for day +#[poise::command(prefix_command, slash_command)] +pub async fn available(ctx: Context<'_>, date: HumanNaiveDate) -> Result<(), Error> { + let client = &ctx.data().client; + let mut resources = client + .available(STERRE_BIB_ID, date.clone().into(), 1) + .await?; + // Cloning here isn't super efficient, but this list only consists of a handful of elements so + // it's fine + resources.sort_by_key(|k| k.resource_name.clone()); + + ctx.send(|f| { + f.embed(|e| { + e.description(format!( + "Available booking dates for {}.", + Into::::into(date) + )) + .fields( + resources + .into_iter() + .map(resource_to_embed_field) + .collect::>(), + ) + }) + }) + .await?; + + Ok(()) +} + +#[poise::command(prefix_command, slash_command)] +pub async fn book( + ctx: Context<'_>, + date: HumanNaiveDate, + start_time: NaiveTime, + end_time: NaiveTime, + #[description = "Minimum seats the room should have."] capacity: Option, +) -> Result<(), Error> { + if ctx.guild_id().is_none() { + ctx.say("You have to send this message from a guild.") + .await?; + + return Ok(()); + } + + let guild_id = ctx.guild_id().unwrap(); + let discord_id = ctx.author().id.0 as i64; + + let user = { + let mut conn = ctx.data().pool.get()?; + User::get(&mut conn, guild_id.into(), discord_id)? + }; + + if user.is_none() { + ctx.say("You have to register before being able to book reservations.") + .await?; + + return Ok(()); + } + + let user = user.unwrap(); + + let client = &ctx.data().client; + let resources = client + .available(STERRE_BIB_ID, date.clone().into(), 1) + .await?; + let chosen_resource = resources + .iter() + .filter(|r| capacity.is_none() || capacity.unwrap() <= r.capacity) + .find(|r| r.has_slot(start_time, end_time, 1)); + + if let Some(chosen_resource) = chosen_resource { + let reservation = Reservation { + auth_type: None, + email: user.email.clone(), + date: date.clone().into(), + start_time, + end_time, + note: "coworking space".to_string(), + user_firstname: user.first_name, + user_lastname: user.last_name, + user_phone: None, + person_count: capacity.unwrap_or(1), + }; + + client + .make_reservation(chosen_resource.resource_id, &reservation) + .await?; + + ctx.send(|f| { + f.embed(|e| { + e.description("A new reservation has been made.") + .field("when", format!("{} {} - {}", Into::::into(date), start_time.format(TIME_FORMAT), end_time.format(TIME_FORMAT)), false) + .field("where", &chosen_resource.resource_name, false) + .footer(|ft| ft.text( + format!("A confirmation mail has been sent to {}. Please check your email and confirm your reservation within two hours.", user.email))) + }) + }) + .await?; + } else { + ctx.say("No slot is available within your requested bounds.") + .await?; + } + + // let resources = if let Some(capacity) = capacity { + // resources.filter(|r| r.capacity >= capacity) + // }; + + Ok(()) +} + +// Create a reservation +// #[poise::command(prefix_command, slash_command)] +// pub async fn reserve( +// ctx: Context<'_>, +// date: NaiveDate, +// ) -> Result<(), Error> { + +// } diff --git a/src/commands/mod.rs b/src/commands/mod.rs index 40068c5..32551d4 100644 --- a/src/commands/mod.rs +++ b/src/commands/mod.rs @@ -1,15 +1,61 @@ -mod affluence; +mod bib; mod minecraft; mod users; +use chrono::Datelike; +use core::str; + use crate::{Context, Data, Error}; type EmbedField = (String, String, bool); +const DAY_TERMS: [&str; 3] = ["today", "tomorrow", "overmorrow"]; + +#[derive(Clone)] +pub struct HumanNaiveDate(chrono::NaiveDate); + +impl str::FromStr for HumanNaiveDate { + type Err = chrono::format::ParseError; + + fn from_str(s: &str) -> chrono::format::ParseResult { + if let Some(days_to_add) = DAY_TERMS.iter().position(|term| s.to_lowercase() == *term) { + let now = chrono::Local::now().naive_local().date(); + + // days_to_add will never be greater than 2 + Ok(HumanNaiveDate( + now + chrono::Duration::days(days_to_add.try_into().unwrap()), + )) + } else if let Ok(weekday) = s.parse::() { + let now = chrono::Local::now().naive_local().date(); + let cur_day = now.weekday(); + let cur_day_index = cur_day.num_days_from_monday(); + let parsed_day_index = weekday.num_days_from_monday(); + + let days_to_add = if cur_day_index <= parsed_day_index { + parsed_day_index - cur_day_index + } else { + 7 - (cur_day_index - parsed_day_index) + }; + + Ok(HumanNaiveDate( + now + chrono::Duration::days(days_to_add.into()), + )) + } else { + chrono::NaiveDate::from_str(s).map(HumanNaiveDate) + } + } +} + +impl From for chrono::NaiveDate { + fn from(val: HumanNaiveDate) -> chrono::NaiveDate { + val.0 + } +} + pub fn commands() -> Vec> { vec![ help(), - affluence::available(), + bib::bib(), minecraft::mc(), users::register(), users::registered(), diff --git a/src/models.rs b/src/models.rs deleted file mode 100644 index e69de29..0000000