init
This commit is contained in:
commit
4a821a2ca3
14 changed files with 3472 additions and 0 deletions
2
.gitignore
vendored
Normal file
2
.gitignore
vendored
Normal file
|
@ -0,0 +1,2 @@
|
|||
/target
|
||||
config.toml
|
3130
Cargo.lock
generated
Normal file
3130
Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load diff
13
Cargo.toml
Normal file
13
Cargo.toml
Normal 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
10
config.example.toml
Normal 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
1
rustfmt.toml
Normal file
|
@ -0,0 +1 @@
|
|||
tab_spaces = 2
|
8
src/cli.rs
Normal file
8
src/cli.rs
Normal 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
2
src/commands/mod.rs
Normal file
|
@ -0,0 +1,2 @@
|
|||
pub(crate) mod ping;
|
||||
pub(crate) mod sandwich;
|
8
src/commands/ping.rs
Normal file
8
src/commands/ping.rs
Normal 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
18
src/commands/sandwich.rs
Normal 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
28
src/config.rs
Normal 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
61
src/directus/client.rs
Normal 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
2
src/directus/mod.rs
Normal file
|
@ -0,0 +1,2 @@
|
|||
pub(crate) mod client;
|
||||
pub(crate) mod model;
|
59
src/directus/model.rs
Normal file
59
src/directus/model.rs
Normal 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
130
src/main.rs
Normal 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();
|
||||
}
|
Loading…
Add table
Add a link
Reference in a new issue