First merge #1

Merged
Jef Roosens merged 26 commits from dev into main 2023-05-17 23:33:39 +02:00
30 changed files with 1159 additions and 190 deletions

2
.dockerignore 100644
View File

@ -0,0 +1,2 @@
.woodpecker/
target/

1
.env 100644
View File

@ -0,0 +1 @@
DATABASE_URL=sqlite://affy.db

1
.gitignore vendored
View File

@ -24,3 +24,4 @@ rust-project.json
# End of https://www.toptal.com/developers/gitignore/api/rust,rust-analyzer
*.db*

View File

@ -0,0 +1,11 @@
platform: 'linux/amd64'
branches:
exclude: [main]
pipeline:
build:
image: 'rust:1.69'
commands:
- cargo build --verbose
- cargo test --verbose

View File

@ -0,0 +1,11 @@
platform: 'linux/amd64'
branches:
exclude: [main]
pipeline:
clippy:
image: 'rust:1.69'
commands:
- rustup component add clippy
- cargo clippy -- --no-deps -Dwarnings

View File

@ -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"

View File

@ -0,0 +1,11 @@
platform: 'linux/amd64'
branches:
exclude: [main]
pipeline:
lint:
image: 'rust:1.69'
commands:
- rustup component add rustfmt
- cargo fmt -- --check

339
Cargo.lock generated
View File

@ -18,12 +18,26 @@ dependencies = [
"uuid",
]
[[package]]
name = "affluences-cli"
version = "0.1.0"
dependencies = [
"affluences-api",
"clap",
"serde_json",
"tokio",
]
[[package]]
name = "affy"
version = "0.1.0"
dependencies = [
"affluences-api",
"async-minecraft-ping",
"chrono",
"diesel",
"diesel_migrations",
"libsqlite3-sys",
"poise",
"tokio",
"uuid",
@ -38,6 +52,68 @@ dependencies = [
"libc",
]
[[package]]
name = "anstream"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0ca84f3628370c59db74ee214b3263d58f9aadd9b4fe7e711fd87dc452b7f163"
dependencies = [
"anstyle",
"anstyle-parse",
"anstyle-query",
"anstyle-wincon",
"colorchoice",
"is-terminal",
"utf8parse",
]
[[package]]
name = "anstyle"
version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "41ed9a86bf92ae6580e0a31281f65a1b1d867c0cc68d5346e2ae128dddfa6a7d"
[[package]]
name = "anstyle-parse"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e765fd216e48e067936442276d1d57399e37bce53c264d6fefbe298080cb57ee"
dependencies = [
"utf8parse",
]
[[package]]
name = "anstyle-query"
version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5ca11d4be1bab0c8bc8734a9aa7bf4ee8316d462a08c6ac5052f888fef5b494b"
dependencies = [
"windows-sys 0.48.0",
]
[[package]]
name = "anstyle-wincon"
version = "1.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "180abfa45703aebe0093f79badacc01b8fd4ea2e35118747e5811127f926e188"
dependencies = [
"anstyle",
"windows-sys 0.48.0",
]
[[package]]
name = "async-minecraft-ping"
version = "0.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "668b459c14dd8d9ef21e296af3f2a3651ff7dc3536e092fb0b09e528daaa6d89"
dependencies = [
"async-trait",
"serde",
"serde_json",
"thiserror",
"tokio",
]
[[package]]
name = "async-trait"
version = "0.1.68"
@ -144,6 +220,48 @@ dependencies = [
"winapi",
]
[[package]]
name = "clap"
version = "4.2.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "34d21f9bf1b425d2968943631ec91202fe5e837264063503708b83013f8fc938"
dependencies = [
"clap_builder",
"clap_derive",
"once_cell",
]
[[package]]
name = "clap_builder"
version = "4.2.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "914c8c79fb560f238ef6429439a30023c862f7a28e688c58f7203f12b29970bd"
dependencies = [
"anstream",
"anstyle",
"bitflags",
"clap_lex",
"strsim",
]
[[package]]
name = "clap_derive"
version = "4.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3f9644cd56d6b87dbe899ef8b053e331c0637664e9e21a33dfcdc36093f5c5c4"
dependencies = [
"heck",
"proc-macro2",
"quote",
"syn 2.0.15",
]
[[package]]
name = "clap_lex"
version = "0.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8a2dd5a6fe8c6e3502f568a6353e5273bbb15193ad9a89e457b9970798efbea1"
[[package]]
name = "codespan-reporting"
version = "0.11.1"
@ -154,6 +272,12 @@ dependencies = [
"unicode-width",
]
[[package]]
name = "colorchoice"
version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "acbf1af155f9b9ef647e42cdc158db4b64a1b61f743629225fde6f3e0be2a7c7"
[[package]]
name = "core-foundation-sys"
version = "0.8.4"
@ -292,6 +416,40 @@ dependencies = [
"syn 1.0.109",
]
[[package]]
name = "diesel"
version = "2.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "72eb77396836a4505da85bae0712fa324b74acfe1876d7c2f7e694ef3d0ee373"
dependencies = [
"diesel_derives",
"libsqlite3-sys",
"r2d2",
]
[[package]]
name = "diesel_derives"
version = "2.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0ad74fdcf086be3d4fdd142f67937678fe60ed431c3b2f08599e7687269410c4"
dependencies = [
"proc-macro-error",
"proc-macro2",
"quote",
"syn 1.0.109",
]
[[package]]
name = "diesel_migrations"
version = "2.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e9ae22beef5e9d6fab9225ddb073c1c6c1a7a6ded5019d5da11d1e5c5adc34e2"
dependencies = [
"diesel",
"migrations_internals",
"migrations_macros",
]
[[package]]
name = "digest"
version = "0.10.6"
@ -311,6 +469,27 @@ dependencies = [
"cfg-if",
]
[[package]]
name = "errno"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4bcfec3a70f97c962c307b2d2c56e358cf1d00b558d74262b5f929ee8cc7e73a"
dependencies = [
"errno-dragonfly",
"libc",
"windows-sys 0.48.0",
]
[[package]]
name = "errno-dragonfly"
version = "0.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "aa68f1b12764fab894d2755d2518754e71b4fd80ecfb822714a1206c2aab39bf"
dependencies = [
"cc",
"libc",
]
[[package]]
name = "flate2"
version = "1.0.26"
@ -459,6 +638,12 @@ version = "0.12.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888"
[[package]]
name = "heck"
version = "0.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "95505c38b4572b2d910cecb0281560f54b440a19336cbbcb27bf6ce6adc6f5a8"
[[package]]
name = "hermit-abi"
version = "0.2.6"
@ -468,6 +653,12 @@ dependencies = [
"libc",
]
[[package]]
name = "hermit-abi"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fed44880c466736ef9a5c5b5facefb5ed0785676d0c02d612db14e54f0d84286"
[[package]]
name = "http"
version = "0.2.9"
@ -589,12 +780,35 @@ dependencies = [
"hashbrown",
]
[[package]]
name = "io-lifetimes"
version = "1.0.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9c66c74d2ae7e79a5a8f7ac924adbe38ee42a859c6539ad869eb51f0b52dc220"
dependencies = [
"hermit-abi 0.3.1",
"libc",
"windows-sys 0.48.0",
]
[[package]]
name = "ipnet"
version = "2.7.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "12b6ee2129af8d4fb011108c73d99a1b83a85977f23b82460c0ae2e25bb4b57f"
[[package]]
name = "is-terminal"
version = "0.4.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "adcf93614601c8129ddf72e2d5633df827ba6551541c6d8c59520a371475be1f"
dependencies = [
"hermit-abi 0.3.1",
"io-lifetimes",
"rustix",
"windows-sys 0.48.0",
]
[[package]]
name = "itoa"
version = "1.0.6"
@ -616,6 +830,17 @@ version = "0.2.144"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2b00cc1c228a6782d0f076e7b232802e0c5689d41bb5df366f2a6b6621cfdfe1"
[[package]]
name = "libsqlite3-sys"
version = "0.26.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "afc22eff61b133b115c6e8c74e818c628d6d5e7a502afea6f64dee076dd94326"
dependencies = [
"cc",
"pkg-config",
"vcpkg",
]
[[package]]
name = "link-cplusplus"
version = "1.0.8"
@ -625,6 +850,12 @@ dependencies = [
"cc",
]
[[package]]
name = "linux-raw-sys"
version = "0.3.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ece97ea872ece730aed82664c424eb4c8291e1ff2480247ccf7409044bc6479f"
[[package]]
name = "lock_api"
version = "0.4.9"
@ -650,6 +881,27 @@ version = "2.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2dffe52ecf27772e601905b7522cb4ef790d2cc203488bbd0e2fe85fcb74566d"
[[package]]
name = "migrations_internals"
version = "2.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c493c09323068c01e54c685f7da41a9ccf9219735c3766fbfd6099806ea08fbc"
dependencies = [
"serde",
"toml",
]
[[package]]
name = "migrations_macros"
version = "2.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8a8ff27a350511de30cdabb77147501c36ef02e0451d957abea2f30caffb2b58"
dependencies = [
"migrations_internals",
"proc-macro2",
"quote",
]
[[package]]
name = "mime"
version = "0.3.17"
@ -712,7 +964,7 @@ version = "1.15.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0fac9e2da13b5eb447a6ce3d392f23a29d8694bff781bf03a16cd9ac8697593b"
dependencies = [
"hermit-abi",
"hermit-abi 0.2.6",
"libc",
]
@ -772,6 +1024,12 @@ version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184"
[[package]]
name = "pkg-config"
version = "0.3.27"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "26072860ba924cbfa98ea39c8c19b4dd6a4a25423dbdf219c1eca91aa0cf6964"
[[package]]
name = "poise"
version = "0.5.5"
@ -809,6 +1067,30 @@ 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"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "da25490ff9892aab3fcf7c36f08cfb902dd3e71ca0f9f9517bea02a73a5ce38c"
dependencies = [
"proc-macro-error-attr",
"proc-macro2",
"quote",
"syn 1.0.109",
"version_check",
]
[[package]]
name = "proc-macro-error-attr"
version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a1be40180e52ecc98ad80b184934baf3d0d29f979574e439af5a55274b35f869"
dependencies = [
"proc-macro2",
"quote",
"version_check",
]
[[package]]
name = "proc-macro2"
version = "1.0.56"
@ -827,6 +1109,17 @@ dependencies = [
"proc-macro2",
]
[[package]]
name = "r2d2"
version = "0.8.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "51de85fb3fb6524929c8a2eb85e6b6d363de4e8c48f9e2c2eac4944abc181c93"
dependencies = [
"log",
"parking_lot",
"scheduled-thread-pool",
]
[[package]]
name = "rand"
version = "0.8.5"
@ -938,6 +1231,20 @@ dependencies = [
"winapi",
]
[[package]]
name = "rustix"
version = "0.37.19"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "acf8729d8542766f1b2cf77eb034d52f40d375bb8b615d0b147089946e16613d"
dependencies = [
"bitflags",
"errno",
"io-lifetimes",
"libc",
"linux-raw-sys",
"windows-sys 0.48.0",
]
[[package]]
name = "rustls"
version = "0.20.8"
@ -971,6 +1278,15 @@ version = "1.0.13"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f91339c0467de62360649f8d3e185ca8de4224ff281f66000de5eb2a77a79041"
[[package]]
name = "scheduled-thread-pool"
version = "0.2.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3cbc66816425a074528352f5789333ecff06ca41b36b0b0efdfbb29edc391a19"
dependencies = [
"parking_lot",
]
[[package]]
name = "scopeguard"
version = "1.1.0"
@ -1294,6 +1610,15 @@ dependencies = [
"tracing",
]
[[package]]
name = "toml"
version = "0.5.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f4f7f0dd8d50a853a531c426359045b1998f04219d88799810762cd4ad314234"
dependencies = [
"serde",
]
[[package]]
name = "tower-service"
version = "0.3.2"
@ -1432,6 +1757,12 @@ version = "0.7.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9"
[[package]]
name = "utf8parse"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "711b9620af191e0cdc7468a8d14e709c3dcdb115b36f838e601583af800a370a"
[[package]]
name = "uuid"
version = "1.3.2"
@ -1441,6 +1772,12 @@ dependencies = [
"serde",
]
[[package]]
name = "vcpkg"
version = "0.2.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426"
[[package]]
name = "version_check"
version = "0.9.4"

View File

@ -2,5 +2,27 @@
members = [
"affluences-api",
"bot",
"affluences-cli",
]
# Don't build the CLI tool by default
default-members = [
".",
"affluences-api",
]
[package]
name = "affy"
version = "0.1.0"
edition = "2021"
[dependencies]
affluences-api = { path = "./affluences-api" }
tokio = { version = "1.28.1", features = ["full"] }
chrono = "*"
uuid = "*"
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"] }

35
Dockerfile 100644
View File

@ -0,0 +1,35 @@
FROM rust:1.69-alpine AS builder
ARG DI_VER=1.2.5
RUN apk update && \
apk add --no-cache build-base curl
WORKDIR /build
# Build dumb-init
RUN curl -Lo - "https://github.com/Yelp/dumb-init/archive/refs/tags/v${DI_VER}.tar.gz" | tar -xzf - && \
cd "dumb-init-${DI_VER}" && \
make SHELL=/bin/sh && \
mv dumb-init ..
WORKDIR /build/affy
COPY ./ ./
RUN cargo build --release && \
[ "$(readelf -d target/release/affy | grep NEEDED | wc -l)" = 0 ]
FROM busybox:1.36.0
COPY --from=builder /build/dumb-init /build/affy/target/release/affy /bin/
WORKDIR /data
ENV TZ=Europe/Brussels
USER www-data:www-data
ENTRYPOINT ["/bin/dumb-init", "--"]
CMD ["/bin/affy"]

View File

@ -1,3 +1,45 @@
# Affluences API Reference
## Terminology
* `site`
* General name for a location/institution
* Defined by a UUID and a slug, e.g. `ghent-university`
* Can contain one more or more sites, e.g.
`ghent-university-library-book-tower` is a child of `ghent-university`
## Notes
The API checks for browser user agents, so your requests should include a valid
user agent of a modern browser.
## API Routes
### Search for sites
`GET https://api.affluences.com/app/v3/sites`
**Body format**
```json
{
"selected_categories": [
1
],
"page": 0,
"search_query": "university of ghent"
}
```
**Response format**
`Data<Vec<SiteData>>`
### Retrieve time table for a given site
`GET https://reservation.affluences.com/api/sites/4737e57a-ee05-4f7b-901a-7bb541eeb297`
curl -L 'https://api.affluences.com/app/v3/sites/ghent-university' -H 'User-Agent: Mozilla/5.0 (X11; Linux x86_64; rv:109.0) Gecko/20100101 Firefox/112.0'
returnt lijst van alle bibliotheken op ugent, inclusief de uuids die dan nodig zijn om de specifieke requests te sturen naar die bib zijn stuff

View File

@ -20,6 +20,23 @@ impl AffluencesClient {
}
}
pub async fn search(&self, query: String) -> reqwest::Result<SiteSearchResponse> {
let url = "https://api.affluences.com/app/v3/sites";
let body = SiteSearch {
search_query: query,
};
Ok(self
.client
.post(url)
.json(&body)
.send()
.await?
.json::<Data<SiteSearchResponse>>()
.await?
.data)
}
pub async fn available(
&self,
site_id: uuid::Uuid,
@ -72,3 +89,9 @@ impl AffluencesClient {
.await
}
}
impl Default for AffluencesClient {
fn default() -> Self {
AffluencesClient::new()
}
}

View File

@ -1,5 +1,5 @@
use super::hh_mm_time_format;
use chrono::NaiveTime;
use chrono::{Duration, NaiveTime};
use serde::Deserialize;
#[derive(Deserialize, Debug, Clone, Copy)]
@ -41,3 +41,45 @@ pub struct Resource {
pub slots_state: u32,
pub hours: Vec<HourBlock>,
}
impl Resource {
pub fn condensed_hours(&self) -> Vec<(&HourBlock, Duration)> {
if self.hours.is_empty() {
return Default::default();
}
let mut start_hour = self.hours.first().unwrap();
let mut duration = Duration::minutes(start_hour.granularity.into());
let mut out: Vec<(&HourBlock, Duration)> = Default::default();
for hour in self.hours.iter().skip(1) {
if hour.state == start_hour.state {
duration = duration + Duration::minutes(hour.granularity.into());
} else {
out.push((start_hour, duration));
start_hour = hour;
duration = Duration::minutes(hour.granularity.into());
}
}
out.push((start_hour, duration));
out
}
pub fn condensed_available_hours(&self) -> Vec<(&HourBlock, Duration)> {
self.condensed_hours()
.into_iter()
.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)
}
}

View File

@ -1,7 +1,7 @@
use chrono::NaiveTime;
use serde::{self, Deserialize, Deserializer, Serializer};
const FORMAT: &'static str = "%H:%M";
const FORMAT: &str = "%H:%M";
pub fn serialize<S>(time: &NaiveTime, serializer: S) -> Result<S::Ok, S::Error>
where

View File

@ -1,24 +1,24 @@
use serde::Deserialize;
use serde::{Deserialize, Serialize};
#[derive(Deserialize, Debug)]
#[derive(Serialize, Deserialize, Debug)]
pub struct Data<T> {
pub data: T,
}
#[derive(Deserialize, Debug)]
#[derive(Serialize, Deserialize, Debug)]
pub struct SiteDataCategory {
pub id: u32,
pub name: String,
pub name_plural: String,
}
#[derive(Deserialize, Debug)]
#[derive(Serialize, Deserialize, Debug)]
pub struct SiteDataLocationCoordinates {
pub latitude: f64,
pub longitude: f64,
}
#[derive(Deserialize, Debug)]
#[derive(Serialize, Deserialize, Debug)]
pub struct SiteDataLocationAddress {
pub route: String,
pub city: String,
@ -27,47 +27,47 @@ pub struct SiteDataLocationAddress {
pub country_code: String,
}
#[derive(Deserialize, Debug)]
#[derive(Serialize, Deserialize, Debug)]
pub struct SiteDataLocation {
pub coordinates: SiteDataLocationCoordinates,
pub address: SiteDataLocationAddress,
}
#[derive(Deserialize, Debug)]
#[derive(Serialize, Deserialize, Debug)]
pub struct SiteDataForecast {
pub opened: bool,
pub occupancy: u32,
pub occupancy: Option<u32>,
// waiting_time
pub waiting_time_overflow: bool,
}
#[derive(Deserialize, Debug)]
#[derive(Serialize, Deserialize, Debug)]
pub struct SiteDataNotice {
pub message: String,
pub url: Option<String>,
}
#[derive(Deserialize, Debug)]
#[derive(Serialize, Deserialize, Debug)]
pub struct SiteDataService {
pub id: u32,
pub name: String,
}
#[derive(Deserialize, Debug)]
#[derive(Serialize, Deserialize, Debug)]
pub struct SiteDataInfo {
pub title: String,
pub description: String,
pub url: Option<String>,
}
#[derive(Deserialize, Debug)]
#[derive(Serialize, Deserialize, Debug)]
pub struct SiteDataStatus {
pub state: String,
pub text: String,
pub color: String,
}
#[derive(Deserialize, Debug)]
#[derive(Serialize, Deserialize, Debug)]
pub struct SiteData {
pub id: uuid::Uuid,
pub slug: String,
@ -104,3 +104,15 @@ pub struct SiteData {
#[serde(rename = "publicationStatus")]
pub publication_status: String,
}
#[derive(Serialize, Debug)]
pub struct SiteSearch {
pub search_query: String,
}
#[derive(Serialize, Deserialize, Debug)]
pub struct SiteSearchResponse {
pub page: u32,
pub max_size: u32,
pub results: Vec<SiteData>,
}

View File

@ -1,5 +1,5 @@
[package]
name = "affy"
name = "affluences-cli"
version = "0.1.0"
edition = "2021"
@ -7,7 +7,6 @@ edition = "2021"
[dependencies]
affluences-api = { path = "../affluences-api" }
clap = { version = "4.2.7", features = ["derive"] }
serde_json = "1.0.96"
tokio = { version = "1.28.1", features = ["full"] }
chrono = "*"
uuid = "*"
poise = "0.5.5"

View File

@ -0,0 +1,30 @@
use affluences_api::AffluencesClient;
use clap::{Parser, Subcommand};
#[derive(Parser)]
#[command(author, version, about, long_about = None)]
struct Cli {
#[command(subcommand)]
command: Option<Commands>,
}
#[derive(Subcommand)]
enum Commands {
/// does testing things
SearchSite { query: String },
}
#[tokio::main]
async fn main() {
let cli = Cli::parse();
let client = AffluencesClient::new();
match &cli.command {
Some(Commands::SearchSite { query }) => {
let res = client.search(query.to_string()).await.unwrap();
let s = serde_json::to_string_pretty(&res).unwrap();
println!("{}", s);
}
None => {}
}
}

View File

@ -1,160 +0,0 @@
use crate::{Context, Error};
use affluences_api::HourBlock;
use chrono::{Duration, NaiveDate};
use uuid::{uuid, Uuid};
const STERRE_BIB_ID: Uuid = uuid!("4737e57a-ee05-4f7b-901a-7bb541eeb297");
const TIME_FORMAT: &'static str = "%H:%M";
/// Show this help menu
#[poise::command(prefix_command, track_edits, slash_command)]
pub async fn help(
ctx: Context<'_>,
#[description = "Specific command to show help about"]
#[autocomplete = "poise::builtins::autocomplete_command"]
command: Option<String>,
) -> Result<(), Error> {
poise::builtins::help(
ctx,
command.as_deref(),
poise::builtins::HelpConfiguration {
extra_text_at_bottom: "This is an example bot made to showcase features of my custom Discord bot framework",
..Default::default()
},
)
.await?;
Ok(())
}
/// Vote for something
///
/// Enter `~vote pumpkin` to vote for pumpkins
#[poise::command(prefix_command, slash_command)]
pub async fn vote(
ctx: Context<'_>,
#[description = "What to vote for"] choice: String,
) -> Result<(), Error> {
// Lock the Mutex in a block {} so the Mutex isn't locked across an await point
let num_votes = {
let mut hash_map = ctx.data().votes.lock().unwrap();
let num_votes = hash_map.entry(choice.clone()).or_default();
*num_votes += 1;
*num_votes
};
let response = format!("Successfully voted for {choice}. {choice} now has {num_votes} votes!");
ctx.say(response).await?;
Ok(())
}
/// Retrieve number of votes
///
/// Retrieve the number of votes either in general, or for a specific choice:
/// ```
/// ~getvotes
/// ~getvotes pumpkin
/// ```
#[poise::command(prefix_command, track_edits, aliases("votes"), slash_command)]
pub async fn getvotes(
ctx: Context<'_>,
#[description = "Choice to retrieve votes for"] choice: Option<String>,
) -> Result<(), Error> {
if let Some(choice) = choice {
let num_votes = *ctx.data().votes.lock().unwrap().get(&choice).unwrap_or(&0);
let response = match num_votes {
0 => format!("Nobody has voted for {} yet", choice),
_ => format!("{} people have voted for {}", num_votes, choice),
};
ctx.say(response).await?;
} else {
let mut response = String::new();
for (choice, num_votes) in ctx.data().votes.lock().unwrap().iter() {
response += &format!("{}: {} votes", choice, num_votes);
}
if response.is_empty() {
response += "Nobody has voted for anything yet :(";
}
ctx.say(response).await?;
};
Ok(())
}
/// 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?;
let mut fields: Vec<(String, String, bool)> = Default::default();
for resource in &resources {
if resource.hours.len() == 0 {
fields.push((
resource.resource_name.clone(),
"Nothing available.".to_string(),
false,
));
continue;
}
let mut lines: Vec<String> = Default::default();
let mut start_hour_opt: Option<&HourBlock> = None;
let mut duration = Duration::seconds(0);
for hour in &resource.hours {
if let Some(start_hour) = start_hour_opt {
if hour.state == 1 {
duration = duration + Duration::minutes(hour.granularity.into());
} else {
let end_hour = start_hour.hour + duration;
lines.push(format!(
"{} - {} ({:02}:{:02})",
start_hour.hour.format(TIME_FORMAT),
end_hour.format(TIME_FORMAT),
duration.num_hours(),
duration.num_minutes() % 60
));
start_hour_opt = None;
}
} else if hour.state == 1 {
start_hour_opt = Some(hour);
duration = Duration::minutes(hour.granularity.into());
}
}
// Print final entry if present
if let Some(start_hour) = start_hour_opt {
let end_hour = start_hour.hour + duration;
lines.push(format!(
"{} - {} ({:02}:{:02})",
start_hour.hour.format(TIME_FORMAT),
end_hour.format(TIME_FORMAT),
duration.num_hours(),
duration.num_minutes() % 60
));
}
fields.push((resource.resource_name.clone(), lines.join("\n"), false));
}
ctx.send(|f| {
f.embed(|e| {
e.description(format!("Available booking dates for {}.", date))
.fields(fields)
})
})
.await?;
Ok(())
}
// Create a reservation
// #[poise::command(prefix_command, slash_command)]
// pub async fn reserve(
// ctx: Context<'_>,
// date: NaiveDate,
// ) -> Result<(), Error> {

3
build.rs 100644
View File

@ -0,0 +1,3 @@
fn main() {
println!("cargo:rerun-if-changed=migrations");
}

8
diesel.toml 100644
View File

@ -0,0 +1,8 @@
# For documentation on how to configure this file,
# see https://diesel.rs/guides/configuring-diesel-cli
[print_schema]
file = "src/db/schema.rs"
[migrations_directory]
dir = "migrations"

View File

@ -0,0 +1,2 @@
-- This file should undo anything in `up.sql`
DROP TABLE users;

View File

@ -0,0 +1,11 @@
-- Your SQL goes here
CREATE TABLE users (
id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
discord_id UNSIGNED BIG INT NOT NULL,
guild_id UNSIGNED BIG INT NOT NULL,
email TEXT UNIQUE NOT NULL,
first_name TEXT NOT NULL,
last_name TEXT NOT NULL,
UNIQUE(discord_id, guild_id)
);

165
src/commands/bib.rs 100644
View File

@ -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::<Vec<String>>()
.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::<NaiveDate>::into(date)
))
.fields(
resources
.into_iter()
.map(resource_to_embed_field)
.collect::<Vec<EmbedField>>(),
)
})
})
.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<u32>,
) -> 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::<NaiveDate>::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> {
// }

View File

@ -0,0 +1,55 @@
use crate::{Context, Error};
use async_minecraft_ping::ServerDescription;
const DEFAULT_SERVER: &str = "rustybever.be";
/// Parent command for Minecraft-related actions.
///
/// Minecraft-related commands
#[poise::command(prefix_command, slash_command, subcommands("ping"))]
pub async fn mc(_ctx: Context<'_>) -> Result<(), Error> {
Ok(())
}
/// Ping a minecraft server; defaults to our server.
#[poise::command(prefix_command, slash_command)]
pub async fn ping(
ctx: Context<'_>,
#[description = "Address of the server"] address: Option<String>,
#[description = "Port the server runs on"] port: Option<u16>,
) -> Result<(), Error> {
let mut full_name = address.unwrap_or(DEFAULT_SERVER.to_string());
let mut builder = async_minecraft_ping::ConnectionConfig::build(&full_name);
if let Some(port) = port {
builder = builder.with_port(port);
full_name += &format!(":{}", port);
}
let conn = builder.connect().await?;
let status = conn.status().await?.status;
let description = match status.description {
ServerDescription::Plain(s) => s,
ServerDescription::Object { text } => text,
};
ctx.send(|f| {
f.embed(|e| {
e.description(format!("Server information for {}", full_name))
.field("version", status.version.name, false)
.field("description", description, false)
.field(
"players",
format!(
"{} of {} player(s) online",
status.players.online, status.players.max
),
false,
)
})
})
.await?;
Ok(())
}

View File

@ -0,0 +1,83 @@
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<Self> {
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::<chrono::Weekday>() {
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<HumanNaiveDate> for chrono::NaiveDate {
fn from(val: HumanNaiveDate) -> chrono::NaiveDate {
val.0
}
}
pub fn commands() -> Vec<poise::structs::Command<Data, Error>> {
vec![
help(),
bib::bib(),
minecraft::mc(),
users::register(),
users::registered(),
]
}
/// Show this help menu
#[poise::command(prefix_command, track_edits, slash_command)]
pub async fn help(
ctx: Context<'_>,
#[description = "Specific command to show help about"]
#[autocomplete = "poise::builtins::autocomplete_command"]
command: Option<String>,
) -> Result<(), Error> {
poise::builtins::help(
ctx,
command.as_deref(),
poise::builtins::HelpConfiguration {
extra_text_at_bottom: "Brought to you by Doofenshmirtz Evil Incorporated.",
..Default::default()
},
)
.await?;
Ok(())
}

View File

@ -0,0 +1,67 @@
use crate::db::users::{NewUser, User};
use crate::{Context, Error};
use diesel::RunQueryDsl;
#[poise::command(prefix_command, slash_command)]
pub async fn register(
ctx: Context<'_>,
first_name: String,
last_name: String,
email: String,
) -> Result<(), Error> {
if let Some(guild_id) = ctx.guild_id() {
let discord_id = ctx.author().id.0 as i64;
{
let mut conn = ctx.data().pool.get()?;
if User::get(&mut conn, guild_id.into(), discord_id)?.is_some() {
ctx.say("You've already been registered.").await?;
return Ok(());
}
let new_user = NewUser {
discord_id,
guild_id: guild_id.into(),
first_name,
last_name,
email,
};
new_user.insert(&mut conn)?;
}
ctx.say("You have been registered.").await?;
} else {
ctx.say("You have to send this message from a guild.")
.await?;
}
Ok(())
}
#[poise::command(prefix_command, slash_command)]
pub async fn registered(ctx: Context<'_>) -> Result<(), Error> {
if let Some(guild_id) = ctx.guild_id() {
let users = {
let mut conn = ctx.data().pool.get()?;
User::by_guild_id(guild_id.into()).load(&mut conn)?
};
ctx.send(|f| {
f.embed(|e| {
e.description("Registered users").fields(
users
.into_iter()
.map(|u| (format!("{} {}", u.first_name, u.last_name), u.email, false)),
)
})
})
.await?;
} else {
ctx.say("You are not in a guild.").await?;
}
Ok(())
}

43
src/db/mod.rs 100644
View File

@ -0,0 +1,43 @@
mod schema;
pub mod users;
use diesel::connection::SimpleConnection;
use diesel::r2d2::{ConnectionManager, Pool};
use diesel::sqlite::{Sqlite, SqliteConnection};
use std::error::Error;
use diesel_migrations::{embed_migrations, EmbeddedMigrations, MigrationHarness};
pub const MIGRATIONS: EmbeddedMigrations = embed_migrations!("migrations");
type DbError = Box<dyn Error + Send + Sync + 'static>;
fn run_migrations(connection: &mut impl MigrationHarness<Sqlite>) -> Result<(), DbError> {
// This will run the necessary migrations.
//
// See the documentation for `MigrationHarness` for
// all available methods.
connection.run_pending_migrations(MIGRATIONS)?;
Ok(())
}
fn initialize_db(conn: &mut SqliteConnection) -> Result<(), DbError> {
// Enable WAL mode and enforce foreign keys
conn.batch_execute(
"PRAGMA journal_mode = WAL; PRAGMA synchronous = NORMAL; PRAGMA foreign_keys = ON;",
)?;
run_migrations(conn)?;
Ok(())
}
pub fn initialize_pool(url: &str) -> Result<Pool<ConnectionManager<SqliteConnection>>, DbError> {
let manager = ConnectionManager::new(url);
let pool = Pool::builder().test_on_check_out(true).build(manager)?;
let mut conn = pool.get()?;
initialize_db(&mut conn)?;
Ok(pool)
}

12
src/db/schema.rs 100644
View File

@ -0,0 +1,12 @@
// @generated automatically by Diesel CLI.
diesel::table! {
users (id) {
id -> Integer,
discord_id -> BigInt,
guild_id -> BigInt,
email -> Text,
first_name -> Text,
last_name -> Text,
}
}

79
src/db/users.rs 100644
View File

@ -0,0 +1,79 @@
use super::schema::users::{self, dsl::*};
use diesel::dsl::Eq;
use diesel::dsl::{AsSelect, Select};
use diesel::helper_types::Filter;
use diesel::prelude::*;
use diesel::sqlite::Sqlite;
use diesel::sqlite::SqliteConnection;
#[derive(Queryable, Selectable, AsChangeset)]
#[diesel(table_name = users)]
pub struct User {
pub id: i32,
pub discord_id: i64,
pub guild_id: i64,
pub email: String,
pub first_name: String,
pub last_name: String,
}
#[derive(Insertable)]
#[diesel(table_name = users)]
pub struct NewUser {
pub discord_id: i64,
pub guild_id: i64,
pub email: String,
pub first_name: String,
pub last_name: String,
}
type All = Select<users::table, AsSelect<User, Sqlite>>;
type WithGuild<T> = Eq<guild_id, T>;
type ByGuild<T> = Filter<All, WithGuild<T>>;
impl User {
pub fn all() -> All {
users::table.select(User::as_select())
}
// pub fn by_guild<T>(guild_id_: T) -> ByGuild<T>
// where T: AsExpression<BigInt>
// {
// Self::all().filter(guild_id.eq(guild_id_))
// }
pub fn by_guild_id(guild_id_: i64) -> ByGuild<i64> {
Self::all().filter(guild_id.eq(guild_id_))
}
pub fn get(
conn: &mut SqliteConnection,
guild_id_: i64,
discord_id_: i64,
) -> Result<Option<User>, diesel::result::Error> {
Self::all()
.filter(guild_id.eq(guild_id_))
.filter(discord_id.eq(discord_id_))
.first(conn)
.optional()
}
pub fn get_by_id(
conn: &mut SqliteConnection,
id_: i32,
) -> Result<Option<User>, diesel::result::Error> {
Self::all().find(id_).first(conn).optional()
}
pub fn update(&self, conn: &mut SqliteConnection) -> Result<usize, diesel::result::Error> {
diesel::update(users::table).set(self).execute(conn)
}
}
impl NewUser {
pub fn insert(&self, conn: &mut SqliteConnection) -> Result<User, diesel::result::Error> {
diesel::insert_into(users::table)
.values(self)
.get_result(conn)
}
}

View File

@ -1,8 +1,11 @@
mod commands;
mod db;
use affluences_api::AffluencesClient;
use diesel::r2d2::{ConnectionManager, Pool};
use diesel::sqlite::SqliteConnection;
use poise::serenity_prelude as serenity;
use std::{collections::HashMap, env::var, sync::Mutex, time::Duration};
use std::{env::var, time::Duration};
// Types used by all command functions
type Error = Box<dyn std::error::Error + Send + Sync>;
@ -10,8 +13,8 @@ type Context<'a> = poise::Context<'a, Data, Error>;
// Custom user data passed to all command functions
pub struct Data {
votes: Mutex<HashMap<String, u32>>,
client: AffluencesClient,
pool: Pool<ConnectionManager<SqliteConnection>>,
}
async fn on_error(error: poise::FrameworkError<'_, Data, Error>) {
@ -36,12 +39,7 @@ async fn main() {
// FrameworkOptions contains all of poise's configuration option in one struct
// Every option can be omitted to use its default value
let options = poise::FrameworkOptions {
commands: vec![
commands::help(),
commands::vote(),
commands::getvotes(),
commands::available(),
],
commands: commands::commands(),
prefix_options: poise::PrefixFrameworkOptions {
prefix: Some("~".into()),
edit_tracker: Some(poise::EditTracker::for_timespan(Duration::from_secs(3600))),
@ -86,6 +84,8 @@ async fn main() {
..Default::default()
};
let pool = db::initialize_pool("affy.db").expect("Failed to initialize database.");
poise::Framework::builder()
.token(
var("DISCORD_TOKEN")
@ -96,8 +96,8 @@ async fn main() {
println!("Logged in as {}", _ready.user.name);
poise::builtins::register_globally(ctx, &framework.options().commands).await?;
Ok(Data {
votes: Mutex::new(HashMap::new()),
client: AffluencesClient::new(),
pool,
})
})
})