This commit is contained in:
Max Bossing 2025-08-06 17:24:33 +02:00
commit 4a821a2ca3
Signed by: max
GPG key ID: E2E95E80A0C1217E
14 changed files with 3472 additions and 0 deletions

2
.gitignore vendored Normal file
View file

@ -0,0 +1,2 @@
/target
config.toml

3130
Cargo.lock generated Normal file

File diff suppressed because it is too large Load diff

13
Cargo.toml Normal file
View file

@ -0,0 +1,13 @@
[package]
name = "sandwichmeister-rs"
version = "0.1.0"
edition = "2024"
[dependencies]
clap = { version = "4.5.42", features = ["derive"] }
poise = "0.6.1"
reqwest = { version = "0.12.22", features = ["json"] }
serde = { version = "1.0.219", features = ["derive"] }
serenity = "0.12.4"
tokio = { version = "1.47.1", features = ["full"] }
toml = "0.9.5"

10
config.example.toml Normal file
View file

@ -0,0 +1,10 @@
[Discord]
token = "your bot token from https://discord.com/developers"
log_channel = 0 # Channel id of the log channel
[Sandwiches]
sandwichmeister_role = 0 # Role id of the sandwich admin role
[Directus]
url = "https://directus.example.com" # DO NOT ADD A TRAILING SLASH
token = "the static access token from the directus bot account"

1
rustfmt.toml Normal file
View file

@ -0,0 +1 @@
tab_spaces = 2

8
src/cli.rs Normal file
View file

@ -0,0 +1,8 @@
use clap::Parser;
use std::path::PathBuf;
#[derive(Debug, Parser)]
pub struct Cli {
#[arg(short, long, default_value = "/app/config.toml")]
pub config: PathBuf,
}

2
src/commands/mod.rs Normal file
View file

@ -0,0 +1,2 @@
pub(crate) mod ping;
pub(crate) mod sandwich;

8
src/commands/ping.rs Normal file
View file

@ -0,0 +1,8 @@
use crate::Context;
use crate::Error;
#[poise::command(prefix_command)]
pub async fn ping(ctx: Context<'_>) -> Result<(), Error> {
ctx.say("Pong!").await?;
Ok(())
}

18
src/commands/sandwich.rs Normal file
View file

@ -0,0 +1,18 @@
use crate::Context;
use crate::Error;
#[poise::command(prefix_command)]
pub async fn sandwich_list(ctx: Context<'_>) -> Result<(), Error> {
ctx
.say(format!(
"{:?}",
ctx
.data()
.sandwiches
.iter()
.map(|s| s.name.as_str())
.collect::<Vec<&str>>()
))
.await?;
Ok(())
}

28
src/config.rs Normal file
View file

@ -0,0 +1,28 @@
use serde::Deserialize;
#[derive(Debug, Deserialize)]
pub struct Config {
#[serde(rename = "Discord")]
pub discord: ConfigDiscord,
#[serde(rename = "Sandwiches")]
pub sandwich: ConfigSandwich,
#[serde(rename = "Directus")]
pub directus: ConfigDirectus,
}
#[derive(Debug, Deserialize)]
pub struct ConfigDiscord {
pub token: String,
pub log_channel: u64,
}
#[derive(Debug, Deserialize)]
pub struct ConfigSandwich {
pub sandwichmeister_role: u64,
}
#[derive(Debug, Deserialize)]
pub struct ConfigDirectus {
pub url: String,
pub token: String,
}

61
src/directus/client.rs Normal file
View file

@ -0,0 +1,61 @@
use std::error::Error;
use reqwest::header::{self, HeaderMap};
use crate::config::ConfigDirectus;
use super::model::{DirectusResponse, Ingredient, RawSandwich, Sandwich};
pub struct DirectusClient {
config: ConfigDirectus,
client: reqwest::Client,
}
impl DirectusClient {
pub fn new(config: ConfigDirectus) -> Self {
let mut headers = HeaderMap::new();
let mut auth_value =
header::HeaderValue::from_str(&format!("Bearer {}", config.token.clone())).unwrap();
auth_value.set_sensitive(true);
headers.insert(header::AUTHORIZATION, auth_value);
let client = reqwest::Client::builder()
.default_headers(headers)
.build()
.expect("Failed to construct Directus Client");
DirectusClient { config, client }
}
pub async fn get_ingredients(&self) -> Result<Vec<Ingredient>, Box<dyn Error>> {
let response = self
.client
.get(format!("{}/items/Ingredients", self.config.url))
.send()
.await?
.json::<DirectusResponse<Vec<Ingredient>>>()
.await?;
Ok(response.data)
}
pub async fn get_sandwiches(&self) -> Result<Vec<Sandwich>, Box<dyn Error>> {
let response = self
.client
.get(format!("{}/items/Sandwiches", self.config.url))
.send()
.await?
.json::<DirectusResponse<Vec<RawSandwich>>>()
.await?;
let ingredients = self.get_ingredients().await?;
Ok(
response
.data
.iter()
.map(|s| s.resolve(&ingredients))
.collect::<Vec<Sandwich>>(),
)
}
}

2
src/directus/mod.rs Normal file
View file

@ -0,0 +1,2 @@
pub(crate) mod client;
pub(crate) mod model;

59
src/directus/model.rs Normal file
View file

@ -0,0 +1,59 @@
use serde::Deserialize;
#[derive(Debug, Deserialize)]
pub(in crate::directus) struct DirectusResponse<T> {
pub data: T,
}
#[derive(Debug, Deserialize)]
pub(in crate::directus) struct RawSandwich {
id: u32,
name: String,
#[serde(deserialize_with = "string_to_f32")]
price_small: f32,
#[serde(deserialize_with = "string_to_f32")]
price_large: f32,
ingredients: Vec<u32>,
}
#[derive(Deserialize, Debug, Clone)]
pub struct Ingredient {
pub id: u32,
pub name: String,
pub veggetarian: bool,
}
#[derive(Debug)]
pub struct Sandwich {
pub id: u32,
pub name: String,
pub price_small: f32,
pub price_large: f32,
pub inredients: Vec<Ingredient>,
}
impl RawSandwich {
pub fn resolve(&self, ingredients: &[Ingredient]) -> Sandwich {
let ingredients = ingredients
.iter()
.filter(|ingredient| self.ingredients.contains(&ingredient.id))
.cloned()
.collect::<Vec<Ingredient>>();
Sandwich {
id: self.id,
name: self.name.clone(),
price_small: self.price_small,
price_large: self.price_large,
inredients: ingredients,
}
}
}
fn string_to_f32<'de, D>(deserializer: D) -> Result<f32, D::Error>
where
D: serde::Deserializer<'de>,
{
let s: &str = Deserialize::deserialize(deserializer)?;
s.parse::<f32>().map_err(serde::de::Error::custom)
}

130
src/main.rs Normal file
View file

@ -0,0 +1,130 @@
use std::{sync::Arc, time::Duration};
use clap::Parser;
use cli::Cli;
use config::Config;
use directus::{client::DirectusClient, model::Sandwich};
use poise::serenity_prelude as serenity;
mod cli;
mod commands;
mod config;
mod directus;
type Error = Box<dyn std::error::Error + Send + Sync>;
type Context<'a> = poise::Context<'a, Data, Error>;
pub struct Data {
pub sandwiches: Vec<Sandwich>,
pub directus: DirectusClient,
}
async fn on_error(error: poise::FrameworkError<'_, Data, Error>) {
match error {
poise::FrameworkError::Setup { error, .. } => panic!("Failed to start bot: {error:?}"),
poise::FrameworkError::Command { error, ctx, .. } => {
println!("Error in command `{}`: {:?}", ctx.command().name, error,);
}
error => {
if let Err(e) = poise::builtins::on_error(error).await {
println!("Error while handling error: {e}")
}
}
}
}
#[tokio::main]
async fn main() {
let cli = Cli::parse();
let config_file_contents =
std::fs::read_to_string(cli.config).expect("failed to read config file!");
let config: Config =
toml::de::from_str(&config_file_contents).expect("failed to deserialize config file");
println!("{config:?}");
let options = poise::FrameworkOptions {
commands: vec![commands::ping::ping(), commands::sandwich::sandwich_list()],
prefix_options: poise::PrefixFrameworkOptions {
prefix: Some("!".into()),
edit_tracker: Some(Arc::new(poise::EditTracker::for_timespan(
Duration::from_secs(3600),
))),
additional_prefixes: vec![
poise::Prefix::Literal("sandwichmeister,"),
poise::Prefix::Literal("sandwichmeister"),
poise::Prefix::Literal("SANDWICHMEISTER,"),
poise::Prefix::Literal("SANDWICHMEISTER"),
],
..Default::default()
},
// The global error handler for all error cases that may occur
on_error: |error| Box::pin(on_error(error)),
// This code is run before every command
pre_command: |ctx| {
Box::pin(async move {
println!("Executing command {}...", ctx.command().qualified_name);
})
},
// This code is run after a command if it was successful (returned Ok)
post_command: |ctx| {
Box::pin(async move {
println!("Executed command {}!", ctx.command().qualified_name);
})
},
// Every command invocation must pass this check to continue execution
// command_check: Some(|ctx| {
// Box::pin(async move {
// if ctx.author().id == 123456789 {
// return Ok(false);
// }
// Ok(true)
// })
// }),
// Enforce command checks even for owners (enforced by default)
// Set to true to bypass checks, which is useful for testing
skip_checks_for_owners: false,
event_handler: |_ctx, event, _framework, _data| {
Box::pin(async move {
println!(
"Got an event in event handler: {:?}",
event.snake_case_name()
);
Ok(())
})
},
..Default::default()
};
let framework = poise::Framework::builder()
.setup(move |ctx, _ready, framework| {
Box::pin(async move {
println!("Logged in as {}", _ready.user.name);
poise::builtins::register_globally(ctx, &framework.options().commands).await?;
let directus = DirectusClient::new(config.directus);
let sandwiches = directus
.get_sandwiches()
.await
.expect("failed to load sandwiches from directus!");
Ok(Data {
directus,
sandwiches,
})
})
})
.options(options)
.build();
let intents =
serenity::GatewayIntents::non_privileged() | serenity::GatewayIntents::MESSAGE_CONTENT;
let client = serenity::ClientBuilder::new(config.discord.token, intents)
.framework(framework)
.await;
client.unwrap().start().await.unwrap();
}