From e80ad9680a3cc31f2aae25c56c0a05316ebe06b5 Mon Sep 17 00:00:00 2001 From: Benjamin Sherriff Date: Thu, 27 Jul 2023 22:45:53 -0400 Subject: [PATCH 01/14] Initial python groundwork --- Cargo.toml | 6 +++++- Makefile | 3 +++ build.rs | 4 ++++ src/main.rs | 1 - 4 files changed, 12 insertions(+), 2 deletions(-) create mode 100644 build.rs diff --git a/Cargo.toml b/Cargo.toml index be626fc..dd6eed2 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -38,4 +38,8 @@ features = ["json", "rustls-tls"] [dependencies.diesel] version = "2.1.0" default-features = false -features = ["postgres", "32-column-tables", "serde_json", "r2d2", "with-deprecated"] \ No newline at end of file +features = ["postgres", "32-column-tables", "serde_json", "r2d2", "with-deprecated"] + +[dependencies.pyo3] +version = "0.19.1" +features = ["auto-initialize"] diff --git a/Makefile b/Makefile index 1138d49..7898510 100644 --- a/Makefile +++ b/Makefile @@ -21,6 +21,9 @@ build: ## Build the docker image test: ## Run the docker app as a container docker run --env-file .env -it --rm --name siren siren:${SIREN_VERSION} +db: ## Start the docker database + docker compose up -d db + up: ## Start the app docker compose up -d diff --git a/build.rs b/build.rs new file mode 100644 index 0000000..3e40437 --- /dev/null +++ b/build.rs @@ -0,0 +1,4 @@ +fn main() { + let home = std::env::var("HOME").expect("${HOME} is missing"); + println!("cargo:rustc-env=LD_LIBRARY_PATH={home}/.pyenv/versions/3.11.4/lib"); +} \ No newline at end of file diff --git a/src/main.rs b/src/main.rs index 4d1c851..5ec5ef0 100644 --- a/src/main.rs +++ b/src/main.rs @@ -45,7 +45,6 @@ impl EventHandler for Handler { } Err(_) => false }; - // let has_bot = msg.channel_id.get_thread_members(&ctx.http).await.unwrap().contains(ctx.cache.current_user_id().0); if mentioned || bot_in_thread { commands::oai::generate_response(&ctx, &msg, oai, &self.pool).await; } From 6c8a7ceefcfcce8a49cd8d29ffd8adf7c94e0de8 Mon Sep 17 00:00:00 2001 From: Benjamin Sherriff Date: Tue, 3 Oct 2023 10:05:14 -0400 Subject: [PATCH 02/14] Updated dependencies, fixed migrations usage --- .env.TEMPLATE | 10 +-- .version | 2 +- Cargo.toml | 19 +++--- Dockerfile | 5 +- docker-compose.yml | 30 +++++---- migrations/000000_create_messages/down.sql | 1 + .../up.sql | 2 +- migrations/create_messages/down.sql | 1 - src/commands/oai.rs | 22 +++---- src/database/mod.rs | 61 ------------------- src/db.rs | 39 ++++++++++++ src/error_handler.rs | 36 +++++++++++ src/main.rs | 24 ++++---- src/messages/mod.rs | 3 + src/{database/models.rs => messages/model.rs} | 2 +- src/{database => }/schema.rs | 0 16 files changed, 141 insertions(+), 116 deletions(-) create mode 100644 migrations/000000_create_messages/down.sql rename migrations/{create_messages => 000000_create_messages}/up.sql (99%) delete mode 100644 migrations/create_messages/down.sql delete mode 100644 src/database/mod.rs create mode 100644 src/db.rs create mode 100644 src/error_handler.rs create mode 100644 src/messages/mod.rs rename src/{database/models.rs => messages/model.rs} (95%) rename src/{database => }/schema.rs (100%) diff --git a/.env.TEMPLATE b/.env.TEMPLATE index 3e1ad7b..039f8a6 100644 --- a/.env.TEMPLATE +++ b/.env.TEMPLATE @@ -1,6 +1,8 @@ -DISCORD_TOKEN= RUST_LOG=warn,siren=info -POSTGRES_USER=siren -POSTGRES_PASSWORD= -POSTGRES_DB=siren +DATABASE_USER=siren +DATABASE_PASSWORD= +DATABASE_NAME=siren +DATABASE_HOST=localhost +DATABASE_PORT=5432 +DISCORD_TOKEN= OPENAI_API_KEY= \ No newline at end of file diff --git a/.version b/.version index 4f1b91d..e9f3799 100644 --- a/.version +++ b/.version @@ -1 +1 @@ -SIREN_VERSION=0.2.3 \ No newline at end of file +SIREN_VERSION=0.2.4 \ No newline at end of file diff --git a/Cargo.toml b/Cargo.toml index dd6eed2..4317c94 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "siren" -version = "0.2.3" +version = "0.2.4" edition = "2021" authors = ["Ben Sherriff "] repository = "https://github.com/bensherriff/siren" @@ -9,9 +9,12 @@ license = "GPL-3.0-or-later" [dependencies] dotenv = "0.15.0" -serde_json = "1.0" -log = "0.4.19" +serde_json = "1.0.107" +log = "0.4.20" env_logger = "0.10.0" +diesel_migrations = { version = "2.1.0", features = ["postgres"] } +r2d2 = "0.8.10" +lazy_static = "1.4.0" [dependencies.serenity] version = "0.11.6" @@ -23,23 +26,23 @@ version = "0.3.2" features = ["builtin-queue", "yt-dlp"] [dependencies.tokio] -version = "1.29.1" +version = "1.32.0" features = ["macros", "rt-multi-thread"] [dependencies.serde] -version = "1.0" +version = "1.0.188" features = ["derive"] [dependencies.reqwest] -version = "0.11.18" +version = "0.11.22" default-features = false features = ["json", "rustls-tls"] [dependencies.diesel] -version = "2.1.0" +version = "2.1.2" default-features = false features = ["postgres", "32-column-tables", "serde_json", "r2d2", "with-deprecated"] [dependencies.pyo3] -version = "0.19.1" +version = "0.19.2" features = ["auto-initialize"] diff --git a/Dockerfile b/Dockerfile index 0af2576..405bf0d 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,10 +1,12 @@ -FROM rust:1.70.0 as builder +# Builder +FROM rust:1.72.1-bookworm as builder WORKDIR /siren ADD src ./src/ ADD Cargo.toml ./ RUN apt-get update && apt-get install -y cmake && \ cargo build --release --bin siren +# Packages FROM debian:bullseye-slim as packages WORKDIR /packages RUN apt-get update && apt-get install -y curl tar xz-utils && \ @@ -19,6 +21,7 @@ RUN apt-get update && apt-get install -y curl tar xz-utils && \ # curl -L https://download.pytorch.org/libtorch/cu117/libtorch-cxx11-abi-shared-with-deps-2.0.1%2Bcu117.zip > libtorch.zip && \ # unzip libtorch.zip && rm libtorch.zip +# Runner FROM debian:bullseye-slim as runtime WORKDIR /siren RUN apt-get update && apt-get install -y libopus-dev libpq5 libpq-dev && apt-get auto-remove -y diff --git a/docker-compose.yml b/docker-compose.yml index 40d94b0..574ea46 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -3,38 +3,36 @@ version: '3' services: siren: image: siren:${SIREN_VERSION:-latest} - container_name: siren + container_name: siren-service build: context: . dockerfile: ./Dockerfile args: - VERSION=${SIREN_VERSION:-latest} - volumes: - - ./app:/siren env_file: - .env environment: - DISCORD_TOKEN: ${DISCORD_TOKEN} - RUST_LOG: ${RUST_LOG} - OPENAI_API_KEY: ${OPENAI_API_KEY} - POSTGRES_USER: ${POSTGRES_USER} - POSTGRES_PASSWORD: ${POSTGRES_PASSWORD} - POSTGRES_DB: ${POSTGRES_DB} - POSTGRES_HOST: db + DATABASE_HOST: db + DATABASE_PORT: 5432 depends_on: - db restart: unless-stopped db: image: postgres:latest - container_name: siren_db + container_name: siren-db env_file: - .env environment: - POSTGRES_USER: ${POSTGRES_USER} - POSTGRES_PASSWORD: ${POSTGRES_PASSWORD} - POSTGRES_DB: ${POSTGRES_DB} + POSTGRES_USER: ${DATABASE_USER} + POSTGRES_PASSWORD: ${DATABASE_PASSWORD} + POSTGRES_DB: ${DATABASE_NAME} volumes: - - ./data:/var/lib/postgresql/data + - db:/var/lib/postgresql/data + - db_logs:/var/log ports: - - "5432:5432" + - ${DATABASE_PORT:-5432}:5432 restart: unless-stopped + +volumes: + db: + db_logs: diff --git a/migrations/000000_create_messages/down.sql b/migrations/000000_create_messages/down.sql new file mode 100644 index 0000000..be13677 --- /dev/null +++ b/migrations/000000_create_messages/down.sql @@ -0,0 +1 @@ +DROP TABLE messages; \ No newline at end of file diff --git a/migrations/create_messages/up.sql b/migrations/000000_create_messages/up.sql similarity index 99% rename from migrations/create_messages/up.sql rename to migrations/000000_create_messages/up.sql index f5965cf..f876ba8 100644 --- a/migrations/create_messages/up.sql +++ b/migrations/000000_create_messages/up.sql @@ -9,4 +9,4 @@ CREATE TABLE IF NOT EXISTS messages ( response TEXT NOT NULL, request_tags TEXT[] NOT NULL, response_tags TEXT[] NOT NULL -) \ No newline at end of file +); \ No newline at end of file diff --git a/migrations/create_messages/down.sql b/migrations/create_messages/down.sql deleted file mode 100644 index 90b6925..0000000 --- a/migrations/create_messages/down.sql +++ /dev/null @@ -1 +0,0 @@ -DROP TABLE messages \ No newline at end of file diff --git a/src/commands/oai.rs b/src/commands/oai.rs index 1576be2..7bb702e 100644 --- a/src/commands/oai.rs +++ b/src/commands/oai.rs @@ -2,8 +2,7 @@ use std::error::Error; use std::fmt; -use diesel::{prelude::*, PgConnection, insert_into}; -use diesel::r2d2::{Pool, ConnectionManager}; +use diesel::{prelude::*, insert_into}; use log::{error, debug, trace, warn}; use serde::{Serialize, Deserialize}; @@ -13,7 +12,8 @@ use serenity::model::channel::Message; use serenity::model::prelude::{ChannelType, PermissionOverwrite, PermissionOverwriteType}; use serenity::prelude::*; -use crate::database::models::{NewMessageDB, MessageDB}; +use crate::db; +use crate::messages::{NewMessageDB, MessageDB}; pub struct OAI { pub client: reqwest::Client, @@ -187,26 +187,26 @@ impl OAI { } } -pub async fn generate_response(ctx: &Context, msg: &Message, oai: &OAI, pool: &Pool>) { +pub async fn generate_response(ctx: &Context, msg: &Message, oai: &OAI) { debug!("Generating response for message: {}", msg.content); + let mut connection = db::connection().unwrap(); let guild_id = msg.guild_id.unwrap(); let channel_id = msg.channel_id; let author_id = msg.author.id; - let mut connection = pool.get().unwrap(); // Parse out the bot mention from the message let bot_mention: String = format!("<@{}>", ctx.cache.current_user_id().0); let parsed_content = msg.content.replace(bot_mention.as_str(), ""); // Setup the request messages - let result: Result, diesel::result::Error> = crate::database::schema::messages::table + let result: Result, diesel::result::Error> = crate::schema::messages::table .select(MessageDB::as_select()) - .filter((crate::database::schema::messages::guild_id.eq(guild_id.0 as i64)) - .and(crate::database::schema::messages::channel_id.eq(channel_id.0 as i64)) - .and(crate::database::schema::messages::user_id.eq(author_id.0 as i64)) + .filter((crate::schema::messages::guild_id.eq(guild_id.0 as i64)) + .and(crate::schema::messages::channel_id.eq(channel_id.0 as i64)) + .and(crate::schema::messages::user_id.eq(author_id.0 as i64)) ) - .order(crate::database::schema::messages::created.asc()) + .order(crate::schema::messages::created.asc()) .limit(oai.max_context_questions) .load(&mut connection); @@ -284,7 +284,7 @@ pub async fn generate_response(ctx: &Context, msg: &Message, oai: &OAI, pool: &P if !r.choices.is_empty() { let res = r.choices[0].message.content.clone(); // Insert the message into the messages database table - if let Err(err) = insert_into(crate::database::schema::messages::table).values(NewMessageDB { + if let Err(err) = insert_into(crate::schema::messages::table).values(NewMessageDB { id: &r.id, guild_id: guild_id.0 as i64, channel_id: response_channel.0 as i64, diff --git a/src/database/mod.rs b/src/database/mod.rs deleted file mode 100644 index 1673f85..0000000 --- a/src/database/mod.rs +++ /dev/null @@ -1,61 +0,0 @@ -use std::env; -// use std::path::Path; - -use diesel::RunQueryDsl; -use diesel::r2d2::{Pool, ConnectionManager}; -use diesel::pg::PgConnection; -use log::{error, info}; - -pub mod models; -pub mod schema; - -pub fn run_migrations(pool: &Pool>) { - let mut connection = pool.get().unwrap(); - if let Err(err) = diesel::sql_query("CREATE TABLE IF NOT EXISTS messages ( - id TEXT PRIMARY KEY NOT NULL, - guild_id BIGINT NOT NULL, - channel_id BIGINT NOT NULL, - user_id BIGINT NOT NULL, - created BIGINT NOT NULL, - model TEXT NOT NULL, - request TEXT NOT NULL, - response TEXT NOT NULL, - request_tags TEXT[] NOT NULL, - response_tags TEXT[] NOT NULL - )").execute(&mut connection) { - error!("Could not create messages table: {}", err); - } else { - info!("Successfully created messages table"); - } - // let migrations_dir = Path::new("./migrations"); - // let migrations = std::fs::read_dir(&migrations_dir).unwrap(); - - // for migration in migrations { - // if migration.as_ref().unwrap().file_type().unwrap().is_dir() { - // let migration_paths = std::fs::read_dir(&migration.unwrap().path()).unwrap(); - - // for migration_path in migration_paths { - // if migration_path.as_ref().unwrap().file_name().eq_ignore_ascii_case("up.sql") { - // let path = &migration_path.unwrap().path(); - // let contents = std::fs::read_to_string(path).expect("Unable to read from file"); - // if let Err(err) = diesel::sql_query(&contents).execute(&mut connection) { - // error!("Could not run migration: {}", err); - // } else { - // info!("Successfully ran migration: {}", path.display()); - // } - // } - // } - // } - // } -} - -pub fn establish_connection() -> Pool> { - let database_user = env::var("POSTGRES_USER").expect("Expected a user in the environment"); - let database_password = env::var("POSTGRES_PASSWORD").expect("Expected a password in the environment"); - let database_name = env::var("POSTGRES_DB").expect("Expected a database name in the environment"); - let database_host = env::var("POSTGRES_HOST").unwrap_or("localhost".to_string()); - - let database_url = format!("postgres://{}:{}@{}/{}", database_user, database_password, database_host, database_name); - let manager = ConnectionManager::::new(database_url); - Pool::builder().build(manager).expect("Failed to create pool.") -} \ No newline at end of file diff --git a/src/db.rs b/src/db.rs new file mode 100644 index 0000000..bb22870 --- /dev/null +++ b/src/db.rs @@ -0,0 +1,39 @@ +use crate::error_handler::ServiceError; +use diesel::{r2d2::ConnectionManager, PgConnection}; +use crate::diesel_migrations::MigrationHarness; +use lazy_static::lazy_static; +use log::{error, info}; +use r2d2; +use std::env; + +type Pool = r2d2::Pool>; +pub type DbConnection = r2d2::PooledConnection>; + +pub const MIGRATIONS: diesel_migrations::EmbeddedMigrations = embed_migrations!(); + +lazy_static! { + static ref POOL: Pool = { + let username = env::var("DATABASE_USER").expect("DATABASE_USERNAME is not set"); + let password = env::var("DATABASE_PASSWORD").expect("DATABASE_PASSWORD is not set"); + let host = env::var("DATABASE_HOST").unwrap_or("localhost".to_string()); + let name = env::var("DATABASE_NAME").expect("DATABASE_NAME is not set"); + let port = env::var("DATABASE_PORT").unwrap_or("5432".to_string()); + let url = format!("postgres://{}:{}@{}:{}/{}", username, password, host, port, name); + let manager = ConnectionManager::::new(url); + Pool::builder().test_on_check_out(true).build(manager).expect("Failed to create db pool") + }; +} + +pub fn init() { + lazy_static::initialize(&POOL); + let mut pool: DbConnection = connection().expect("Failed to get db connection"); + match pool.run_pending_migrations(MIGRATIONS) { + Ok(_) => info!("Database initialized"), + Err(err) => error!("Failed to initialize database; {}", err) + }; +} + +pub fn connection() -> Result { + POOL.get() + .map_err(|e| ServiceError::new(500, format!("Failed getting db connection: {}", e))) +} diff --git a/src/error_handler.rs b/src/error_handler.rs new file mode 100644 index 0000000..18f4f7c --- /dev/null +++ b/src/error_handler.rs @@ -0,0 +1,36 @@ +use diesel::result::Error as DieselError; +use serde::{Deserialize, Serialize}; +use std::fmt; + +#[derive(Debug, Deserialize, Serialize)] +pub struct ServiceError { + pub error_status_code: u16, + pub error_message: String, +} + +impl ServiceError { + pub fn new(error_status_code: u16, error_message: String) -> ServiceError { + ServiceError { + error_status_code, + error_message, + } + } +} + +impl fmt::Display for ServiceError { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + f.write_str(self.error_message.as_str()) + } +} + +impl From for ServiceError { + fn from(error: DieselError) -> ServiceError { + match error { + DieselError::DatabaseError(_, err) => ServiceError::new(409, err.message().to_string()), + DieselError::NotFound => { + ServiceError::new(404, "The record was not found".to_string()) + } + err => ServiceError::new(500, format!("Unknown Diesel error: {}", err)), + } + } +} diff --git a/src/main.rs b/src/main.rs index 5ec5ef0..2e1a248 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,10 +1,12 @@ +extern crate diesel; +#[macro_use] +extern crate diesel_migrations; + use std::collections::{HashSet, HashMap}; use std::env; use std::sync::Arc; use commands::audio::{create_response, AudioConfig, AudioConfigs}; -use diesel::r2d2::{Pool, ConnectionManager}; -use diesel::pg::PgConnection; use dotenv::dotenv; use log::{error, warn, info}; @@ -18,11 +20,13 @@ use serenity::prelude::*; use songbird::SerenityInit; mod commands; -mod database; +mod error_handler; +mod db; +mod messages; +mod schema; struct Handler { // Open AI Config - oai: Option, - pool: Pool> + oai: Option } #[async_trait] @@ -46,7 +50,7 @@ impl EventHandler for Handler { Err(_) => false }; if mentioned || bot_in_thread { - commands::oai::generate_response(&ctx, &msg, oai, &self.pool).await; + commands::oai::generate_response(&ctx, &msg, oai).await; } } Err(why) => warn!("Could not check mentions: {:?}", why) @@ -134,20 +138,18 @@ async fn main() { Err(why) => panic!("Could not access application info: {:?}", why) }; - let pool = database::establish_connection(); - database::run_migrations(&pool); + db::init(); let handler = match env::var("OPENAI_API_KEY") { Ok(token) => { info!("Loaded OpenAI token"); Handler { - oai: Some(commands::oai::OAI { client: reqwest::Client::new(), base_url: "https://api.openai.com/v1".to_string(), max_attempts: 5, token , max_context_questions: 15 }), - pool + oai: Some(commands::oai::OAI { client: reqwest::Client::new(), base_url: "https://api.openai.com/v1".to_string(), max_attempts: 5, token , max_context_questions: 15 }) } } Err(err) => { warn!("Could not load OpenAI token: {}", err); - Handler { oai: None, pool } + Handler { oai: None } } }; diff --git a/src/messages/mod.rs b/src/messages/mod.rs new file mode 100644 index 0000000..4a7ebf6 --- /dev/null +++ b/src/messages/mod.rs @@ -0,0 +1,3 @@ +mod model; + +pub use model::*; diff --git a/src/database/models.rs b/src/messages/model.rs similarity index 95% rename from src/database/models.rs rename to src/messages/model.rs index 93d9bab..f1120ef 100644 --- a/src/database/models.rs +++ b/src/messages/model.rs @@ -1,6 +1,6 @@ use diesel::prelude::*; -use super::schema::messages; +use crate::schema::messages; #[derive(Queryable, Selectable)] #[diesel(table_name = messages)] diff --git a/src/database/schema.rs b/src/schema.rs similarity index 100% rename from src/database/schema.rs rename to src/schema.rs From 95ede3291e109f13ee8ce92b7fe9b33a38473504 Mon Sep 17 00:00:00 2001 From: Benjamin Sherriff Date: Tue, 3 Oct 2023 12:11:48 -0400 Subject: [PATCH 03/14] Add other db tables --- Cargo.toml | 4 + migrations/000001_create_races/down.sql | 1 + migrations/000001_create_races/up.sql | 7 + migrations/000002_create_classes/down.sql | 1 + migrations/000002_create_classes/up.sql | 3 + migrations/000003_create_feats/down.sql | 1 + migrations/000003_create_feats/up.sql | 3 + .../000004_create_options_features/down.sql | 1 + .../000004_create_options_features/up.sql | 3 + migrations/000005_create_backgrounds/down.sql | 1 + migrations/000005_create_backgrounds/up.sql | 3 + migrations/000006_create_items/down.sql | 1 + migrations/000006_create_items/up.sql | 3 + migrations/000007_create_spells/down.sql | 1 + migrations/000007_create_spells/up.sql | 18 +++ migrations/000008_create_conditions/down.sql | 1 + migrations/000008_create_conditions/up.sql | 3 + migrations/000009_create_bestiary/down.sql | 1 + migrations/000009_create_bestiary/up.sql | 3 + src/commands/oai.rs | 17 ++- src/db/backgrounds/mod.rs | 0 src/db/bestiary/mod.rs | 0 src/db/classes/mod.rs | 0 src/db/conditions/mod.rs | 0 src/db/feats/mod.rs | 0 src/db/items/mod.rs | 0 src/{ => db}/messages/mod.rs | 0 src/{ => db}/messages/model.rs | 2 +- src/{db.rs => db/mod.rs} | 15 +++ src/db/options/mod.rs | 0 src/db/races/mod.rs | 0 src/db/schema.rs | 35 +++++ src/db/spells/mod.rs | 3 + src/db/spells/model.rs | 120 ++++++++++++++++++ src/db/users/mod.rs | 3 + src/db/users/model.rs | 3 + src/main.rs | 2 - src/schema.rs | 14 -- 38 files changed, 247 insertions(+), 26 deletions(-) create mode 100644 migrations/000001_create_races/down.sql create mode 100644 migrations/000001_create_races/up.sql create mode 100644 migrations/000002_create_classes/down.sql create mode 100644 migrations/000002_create_classes/up.sql create mode 100644 migrations/000003_create_feats/down.sql create mode 100644 migrations/000003_create_feats/up.sql create mode 100644 migrations/000004_create_options_features/down.sql create mode 100644 migrations/000004_create_options_features/up.sql create mode 100644 migrations/000005_create_backgrounds/down.sql create mode 100644 migrations/000005_create_backgrounds/up.sql create mode 100644 migrations/000006_create_items/down.sql create mode 100644 migrations/000006_create_items/up.sql create mode 100644 migrations/000007_create_spells/down.sql create mode 100644 migrations/000007_create_spells/up.sql create mode 100644 migrations/000008_create_conditions/down.sql create mode 100644 migrations/000008_create_conditions/up.sql create mode 100644 migrations/000009_create_bestiary/down.sql create mode 100644 migrations/000009_create_bestiary/up.sql create mode 100644 src/db/backgrounds/mod.rs create mode 100644 src/db/bestiary/mod.rs create mode 100644 src/db/classes/mod.rs create mode 100644 src/db/conditions/mod.rs create mode 100644 src/db/feats/mod.rs create mode 100644 src/db/items/mod.rs rename src/{ => db}/messages/mod.rs (100%) rename src/{ => db}/messages/model.rs (95%) rename src/{db.rs => db/mod.rs} (89%) create mode 100644 src/db/options/mod.rs create mode 100644 src/db/races/mod.rs create mode 100644 src/db/schema.rs create mode 100644 src/db/spells/mod.rs create mode 100644 src/db/spells/model.rs create mode 100644 src/db/users/mod.rs create mode 100644 src/db/users/model.rs delete mode 100644 src/schema.rs diff --git a/Cargo.toml b/Cargo.toml index 4317c94..fde71d0 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -8,6 +8,10 @@ readme = "README.md" license = "GPL-3.0-or-later" [dependencies] +actix-web = "4.4.0" +actix-rt = "2.9.0" +actix-web-httpauth = "0.8.1" +chrono = { version = "0.4.31", features = ["serde"] } dotenv = "0.15.0" serde_json = "1.0.107" log = "0.4.20" diff --git a/migrations/000001_create_races/down.sql b/migrations/000001_create_races/down.sql new file mode 100644 index 0000000..f075840 --- /dev/null +++ b/migrations/000001_create_races/down.sql @@ -0,0 +1 @@ +DROP TABLE races; \ No newline at end of file diff --git a/migrations/000001_create_races/up.sql b/migrations/000001_create_races/up.sql new file mode 100644 index 0000000..5ec4de7 --- /dev/null +++ b/migrations/000001_create_races/up.sql @@ -0,0 +1,7 @@ +CREATE TABLE IF NOT EXISTS races ( + id INTEGER GENERATED ALWAYS AS IDENTITY, + name TEXT NOT NULL, + size TEXT NOT NULL, + source TEXT NOT NULL, + data JSON NOT NULL +); \ No newline at end of file diff --git a/migrations/000002_create_classes/down.sql b/migrations/000002_create_classes/down.sql new file mode 100644 index 0000000..288ada8 --- /dev/null +++ b/migrations/000002_create_classes/down.sql @@ -0,0 +1 @@ +DROP TABLE classes; \ No newline at end of file diff --git a/migrations/000002_create_classes/up.sql b/migrations/000002_create_classes/up.sql new file mode 100644 index 0000000..36f79bf --- /dev/null +++ b/migrations/000002_create_classes/up.sql @@ -0,0 +1,3 @@ +CREATE TABLE IF NOT EXISTS classes ( + id INTEGER GENERATED ALWAYS AS IDENTITY +); \ No newline at end of file diff --git a/migrations/000003_create_feats/down.sql b/migrations/000003_create_feats/down.sql new file mode 100644 index 0000000..4afe0e1 --- /dev/null +++ b/migrations/000003_create_feats/down.sql @@ -0,0 +1 @@ +DROP TABLE feats; \ No newline at end of file diff --git a/migrations/000003_create_feats/up.sql b/migrations/000003_create_feats/up.sql new file mode 100644 index 0000000..0193ae4 --- /dev/null +++ b/migrations/000003_create_feats/up.sql @@ -0,0 +1,3 @@ +CREATE TABLE IF NOT EXISTS feats ( + id INTEGER GENERATED ALWAYS AS IDENTITY +); \ No newline at end of file diff --git a/migrations/000004_create_options_features/down.sql b/migrations/000004_create_options_features/down.sql new file mode 100644 index 0000000..0fcca0f --- /dev/null +++ b/migrations/000004_create_options_features/down.sql @@ -0,0 +1 @@ +DROP TABLE options_features; \ No newline at end of file diff --git a/migrations/000004_create_options_features/up.sql b/migrations/000004_create_options_features/up.sql new file mode 100644 index 0000000..d64ba24 --- /dev/null +++ b/migrations/000004_create_options_features/up.sql @@ -0,0 +1,3 @@ +CREATE TABLE IF NOT EXISTS options_features ( + id INTEGER GENERATED ALWAYS AS IDENTITY +); \ No newline at end of file diff --git a/migrations/000005_create_backgrounds/down.sql b/migrations/000005_create_backgrounds/down.sql new file mode 100644 index 0000000..74f4344 --- /dev/null +++ b/migrations/000005_create_backgrounds/down.sql @@ -0,0 +1 @@ +DROP TABLE backgrounds; \ No newline at end of file diff --git a/migrations/000005_create_backgrounds/up.sql b/migrations/000005_create_backgrounds/up.sql new file mode 100644 index 0000000..0d4bb7f --- /dev/null +++ b/migrations/000005_create_backgrounds/up.sql @@ -0,0 +1,3 @@ +CREATE TABLE IF NOT EXISTS backgrounds ( + id INTEGER GENERATED ALWAYS AS IDENTITY +); \ No newline at end of file diff --git a/migrations/000006_create_items/down.sql b/migrations/000006_create_items/down.sql new file mode 100644 index 0000000..9f8e6a3 --- /dev/null +++ b/migrations/000006_create_items/down.sql @@ -0,0 +1 @@ +DROP TABLE items; \ No newline at end of file diff --git a/migrations/000006_create_items/up.sql b/migrations/000006_create_items/up.sql new file mode 100644 index 0000000..f12f9b2 --- /dev/null +++ b/migrations/000006_create_items/up.sql @@ -0,0 +1,3 @@ +CREATE TABLE IF NOT EXISTS items ( + id INTEGER GENERATED ALWAYS AS IDENTITY +); \ No newline at end of file diff --git a/migrations/000007_create_spells/down.sql b/migrations/000007_create_spells/down.sql new file mode 100644 index 0000000..1b09bdf --- /dev/null +++ b/migrations/000007_create_spells/down.sql @@ -0,0 +1 @@ +DROP TABLE spells; \ No newline at end of file diff --git a/migrations/000007_create_spells/up.sql b/migrations/000007_create_spells/up.sql new file mode 100644 index 0000000..8312c69 --- /dev/null +++ b/migrations/000007_create_spells/up.sql @@ -0,0 +1,18 @@ +CREATE TABLE IF NOT EXISTS spells ( + id INTEGER GENERATED ALWAYS AS IDENTITY, + name TEXT NOT NULL, + school TEXT NOT NULL, + level TEXT NOT NULL, + ritual BOOLEAN DEFAULT FALSE, + casting_time TEXT NOT NULL, + range TEXT NOT NULL, + components_verbal BOOLEAN DEFAULT FALSE, + components_somatic BOOLEAN DEFAULT FALSE, + components_material BOOLEAN DEFAULT FALSE, + components_materials_needed TEXT + duration TEXT NOT NULL, + classes TEXT[] NOT NULL, + sources TEXT[] NOT NULL, + tags TEXT[] + description TEXT NOT NULL +); \ No newline at end of file diff --git a/migrations/000008_create_conditions/down.sql b/migrations/000008_create_conditions/down.sql new file mode 100644 index 0000000..4cf8449 --- /dev/null +++ b/migrations/000008_create_conditions/down.sql @@ -0,0 +1 @@ +DROP TABLE conditions; \ No newline at end of file diff --git a/migrations/000008_create_conditions/up.sql b/migrations/000008_create_conditions/up.sql new file mode 100644 index 0000000..76b5220 --- /dev/null +++ b/migrations/000008_create_conditions/up.sql @@ -0,0 +1,3 @@ +CREATE TABLE IF NOT EXISTS conditions ( + id INTEGER GENERATED ALWAYS AS IDENTITY +); \ No newline at end of file diff --git a/migrations/000009_create_bestiary/down.sql b/migrations/000009_create_bestiary/down.sql new file mode 100644 index 0000000..9bbe034 --- /dev/null +++ b/migrations/000009_create_bestiary/down.sql @@ -0,0 +1 @@ +DROP TABLE bestiary; \ No newline at end of file diff --git a/migrations/000009_create_bestiary/up.sql b/migrations/000009_create_bestiary/up.sql new file mode 100644 index 0000000..a6019df --- /dev/null +++ b/migrations/000009_create_bestiary/up.sql @@ -0,0 +1,3 @@ +CREATE TABLE IF NOT EXISTS bestiary ( + id INTEGER GENERATED ALWAYS AS IDENTITY +); \ No newline at end of file diff --git a/src/commands/oai.rs b/src/commands/oai.rs index 7bb702e..13a2e01 100644 --- a/src/commands/oai.rs +++ b/src/commands/oai.rs @@ -12,8 +12,7 @@ use serenity::model::channel::Message; use serenity::model::prelude::{ChannelType, PermissionOverwrite, PermissionOverwriteType}; use serenity::prelude::*; -use crate::db; -use crate::messages::{NewMessageDB, MessageDB}; +use crate::db::{connection, NewMessageDB, MessageDB}; pub struct OAI { pub client: reqwest::Client, @@ -189,7 +188,7 @@ impl OAI { pub async fn generate_response(ctx: &Context, msg: &Message, oai: &OAI) { debug!("Generating response for message: {}", msg.content); - let mut connection = db::connection().unwrap(); + let mut connection = connection().unwrap(); let guild_id = msg.guild_id.unwrap(); let channel_id = msg.channel_id; @@ -200,13 +199,13 @@ pub async fn generate_response(ctx: &Context, msg: &Message, oai: &OAI) { let parsed_content = msg.content.replace(bot_mention.as_str(), ""); // Setup the request messages - let result: Result, diesel::result::Error> = crate::schema::messages::table + let result: Result, diesel::result::Error> = crate::db::schema::messages::table .select(MessageDB::as_select()) - .filter((crate::schema::messages::guild_id.eq(guild_id.0 as i64)) - .and(crate::schema::messages::channel_id.eq(channel_id.0 as i64)) - .and(crate::schema::messages::user_id.eq(author_id.0 as i64)) + .filter((crate::db::schema::messages::guild_id.eq(guild_id.0 as i64)) + .and(crate::db::schema::messages::channel_id.eq(channel_id.0 as i64)) + .and(crate::db::schema::messages::user_id.eq(author_id.0 as i64)) ) - .order(crate::schema::messages::created.asc()) + .order(crate::db::schema::messages::created.asc()) .limit(oai.max_context_questions) .load(&mut connection); @@ -284,7 +283,7 @@ pub async fn generate_response(ctx: &Context, msg: &Message, oai: &OAI) { if !r.choices.is_empty() { let res = r.choices[0].message.content.clone(); // Insert the message into the messages database table - if let Err(err) = insert_into(crate::schema::messages::table).values(NewMessageDB { + if let Err(err) = insert_into(crate::db::schema::messages::table).values(NewMessageDB { id: &r.id, guild_id: guild_id.0 as i64, channel_id: response_channel.0 as i64, diff --git a/src/db/backgrounds/mod.rs b/src/db/backgrounds/mod.rs new file mode 100644 index 0000000..e69de29 diff --git a/src/db/bestiary/mod.rs b/src/db/bestiary/mod.rs new file mode 100644 index 0000000..e69de29 diff --git a/src/db/classes/mod.rs b/src/db/classes/mod.rs new file mode 100644 index 0000000..e69de29 diff --git a/src/db/conditions/mod.rs b/src/db/conditions/mod.rs new file mode 100644 index 0000000..e69de29 diff --git a/src/db/feats/mod.rs b/src/db/feats/mod.rs new file mode 100644 index 0000000..e69de29 diff --git a/src/db/items/mod.rs b/src/db/items/mod.rs new file mode 100644 index 0000000..e69de29 diff --git a/src/messages/mod.rs b/src/db/messages/mod.rs similarity index 100% rename from src/messages/mod.rs rename to src/db/messages/mod.rs diff --git a/src/messages/model.rs b/src/db/messages/model.rs similarity index 95% rename from src/messages/model.rs rename to src/db/messages/model.rs index f1120ef..3714ed5 100644 --- a/src/messages/model.rs +++ b/src/db/messages/model.rs @@ -1,6 +1,6 @@ use diesel::prelude::*; -use crate::schema::messages; +use crate::db::schema::messages; #[derive(Queryable, Selectable)] #[diesel(table_name = messages)] diff --git a/src/db.rs b/src/db/mod.rs similarity index 89% rename from src/db.rs rename to src/db/mod.rs index bb22870..991ff2b 100644 --- a/src/db.rs +++ b/src/db/mod.rs @@ -6,6 +6,21 @@ use log::{error, info}; use r2d2; use std::env; +mod backgrounds; +mod bestiary; +mod classes; +mod conditions; +mod feats; +mod items; +mod messages; +mod options; +mod races; +mod spells; +mod users; +pub mod schema; + +pub use messages::*; + type Pool = r2d2::Pool>; pub type DbConnection = r2d2::PooledConnection>; diff --git a/src/db/options/mod.rs b/src/db/options/mod.rs new file mode 100644 index 0000000..e69de29 diff --git a/src/db/races/mod.rs b/src/db/races/mod.rs new file mode 100644 index 0000000..e69de29 diff --git a/src/db/schema.rs b/src/db/schema.rs new file mode 100644 index 0000000..17e2dc8 --- /dev/null +++ b/src/db/schema.rs @@ -0,0 +1,35 @@ +diesel::table! { + messages (id) { + id -> Text, + guild_id -> BigInt, + channel_id -> BigInt, + user_id -> BigInt, + created -> BigInt, + model -> Text, + request -> Text, + response -> Text, + request_tags -> Array, + response_tags -> Array, + } +} + +diesel::table! { + spells (id) { + id -> Integer, + name -> Text, + school -> Text, + level -> Integer, + ritual -> Bool, + casting_time -> Text, + range -> Text, + components_verbal -> Bool, + components_somatic -> Bool, + components_material -> Bool, + components_materials_needed -> Nullable, + duration -> Text, + classes -> Array, + sources -> Array, + tags -> Array, + description -> Text + } +} \ No newline at end of file diff --git a/src/db/spells/mod.rs b/src/db/spells/mod.rs new file mode 100644 index 0000000..4a7ebf6 --- /dev/null +++ b/src/db/spells/mod.rs @@ -0,0 +1,3 @@ +mod model; + +pub use model::*; diff --git a/src/db/spells/model.rs b/src/db/spells/model.rs new file mode 100644 index 0000000..b5081be --- /dev/null +++ b/src/db/spells/model.rs @@ -0,0 +1,120 @@ +use diesel::prelude::*; +use serde::{Deserialize, Serialize}; + +use crate::db::schema::spells; + +#[derive(Queryable, QueryableByName)] +#[diesel(table_name = spells)] +pub struct QuerySpell { + pub id: i32, + pub name: String, + pub school: String, + pub level: i32, + pub ritual: bool, + pub casting_time: String, + pub range: String, + pub components_verbal: bool, + pub components_somatic: bool, + pub components_material: bool, + pub components_materials_needed: Option, + pub duration: String, + pub classes: Vec, + pub sources: Vec, + pub tags: Vec, + pub description: String +} + +#[derive(Insertable, AsChangeset)] +#[diesel(table_name = spells)] +pub struct InsertSpell { + pub name: String, + pub school: String, + pub level: i32, + pub ritual: bool, + pub casting_time: String, + pub range: String, + pub components_verbal: bool, + pub components_somatic: bool, + pub components_material: bool, + pub components_materials_needed: Option, + pub duration: String, + pub classes: Vec, + pub sources: Vec, + pub tags: Vec, + pub description: String +} + +#[derive(Serialize, Deserialize)] +pub struct Spell { + pub name: String, + pub school: String, + pub level: i32, + pub ritual: bool, + pub casting_time: String, + pub range: String, + pub components: Components, + pub duration: String, + pub classes: Vec, + pub sources: Vec, + pub description: String +} + +#[derive(Serialize, Deserialize)] +pub struct Components { + pub verbal: bool, + pub somatic: bool, + pub material: bool, + pub materials_needed: Option +} + +impl Spell { + /// Convert spell to insertable struct + pub fn to_insert(self) -> InsertSpell { + return InsertSpell { + name: self.name, + school: self.school, + level: self.level, + ritual: self.ritual, + casting_time: self.casting_time, + range: self.range, + components_verbal: self.components.verbal, + components_somatic: self.components.somatic, + components_material: self.components.material, + components_materials_needed: self.components.materials_needed, + duration: self.duration, + classes: self.classes, + sources: self.sources, + tags: vec![], + description: self.description + } + } + + /// Convert query struct to spell + pub fn from_query(query: QuerySpell) -> Self { + return Self { + name: query.name, + school: query.school, + level: query.level, + ritual: query.ritual, + casting_time: query.casting_time, + range: query.range, + components: Components { + verbal: query.components_verbal, + somatic: query.components_somatic, + material: query.components_material, + materials_needed: query.components_materials_needed + }, + duration: query.duration, + classes: query.classes, + sources: query.sources, + description: query.description + } + } + + /// Convert file to spell + pub fn from_file(file: String) -> Self { + let data = std::fs::read_to_string(file).unwrap(); + let spell: Spell = serde_json::from_str(&data).unwrap(); + return spell; + } +} diff --git a/src/db/users/mod.rs b/src/db/users/mod.rs new file mode 100644 index 0000000..4a7ebf6 --- /dev/null +++ b/src/db/users/mod.rs @@ -0,0 +1,3 @@ +mod model; + +pub use model::*; diff --git a/src/db/users/model.rs b/src/db/users/model.rs new file mode 100644 index 0000000..5b2f343 --- /dev/null +++ b/src/db/users/model.rs @@ -0,0 +1,3 @@ +pub struct User { + pub id: i32 +} diff --git a/src/main.rs b/src/main.rs index 2e1a248..03a755c 100644 --- a/src/main.rs +++ b/src/main.rs @@ -22,8 +22,6 @@ use songbird::SerenityInit; mod commands; mod error_handler; mod db; -mod messages; -mod schema; struct Handler { // Open AI Config oai: Option diff --git a/src/schema.rs b/src/schema.rs deleted file mode 100644 index 825d574..0000000 --- a/src/schema.rs +++ /dev/null @@ -1,14 +0,0 @@ -diesel::table! { - messages (id) { - id -> Text, - guild_id -> BigInt, - channel_id -> BigInt, - user_id -> BigInt, - created -> BigInt, - model -> Text, - request -> Text, - response -> Text, - request_tags -> Array, - response_tags -> Array, - } -} \ No newline at end of file From 16d8fa5af870dac90cae19f55d67ef5670ec9a05 Mon Sep 17 00:00:00 2001 From: Benjamin Sherriff Date: Tue, 3 Oct 2023 14:21:53 -0400 Subject: [PATCH 04/14] Spells endpoints --- .env.TEMPLATE | 5 + docker-compose.yml | 2 + migrations/000007_create_spells/up.sql | 6 +- src/commands/oai.rs | 2 +- src/db/mod.rs | 43 ++++++--- src/db/spells/mod.rs | 40 ++++++++ src/db/spells/model.rs | 101 ++++++++++++++------ src/db/spells/routes.rs | 79 +++++++++++++++ src/error_handler.rs | 23 ++++- src/main.rs | 127 +++++++++++++++---------- 10 files changed, 330 insertions(+), 98 deletions(-) create mode 100644 src/db/spells/routes.rs diff --git a/.env.TEMPLATE b/.env.TEMPLATE index 039f8a6..c933137 100644 --- a/.env.TEMPLATE +++ b/.env.TEMPLATE @@ -1,8 +1,13 @@ RUST_LOG=warn,siren=info + DATABASE_USER=siren DATABASE_PASSWORD= DATABASE_NAME=siren DATABASE_HOST=localhost DATABASE_PORT=5432 + +SERVICE_HOST=localhost +SERVICE_PORT=5000 + DISCORD_TOKEN= OPENAI_API_KEY= \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml index 574ea46..efc5f2e 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -14,6 +14,8 @@ services: environment: DATABASE_HOST: db DATABASE_PORT: 5432 + SERVICE_HOST: siren + SERVICE_PORT: 5000 depends_on: - db restart: unless-stopped diff --git a/migrations/000007_create_spells/up.sql b/migrations/000007_create_spells/up.sql index 8312c69..83beab8 100644 --- a/migrations/000007_create_spells/up.sql +++ b/migrations/000007_create_spells/up.sql @@ -2,17 +2,17 @@ CREATE TABLE IF NOT EXISTS spells ( id INTEGER GENERATED ALWAYS AS IDENTITY, name TEXT NOT NULL, school TEXT NOT NULL, - level TEXT NOT NULL, + level INTEGER NOT NULL, ritual BOOLEAN DEFAULT FALSE, casting_time TEXT NOT NULL, range TEXT NOT NULL, components_verbal BOOLEAN DEFAULT FALSE, components_somatic BOOLEAN DEFAULT FALSE, components_material BOOLEAN DEFAULT FALSE, - components_materials_needed TEXT + components_materials_needed TEXT, duration TEXT NOT NULL, classes TEXT[] NOT NULL, sources TEXT[] NOT NULL, - tags TEXT[] + tags TEXT[], description TEXT NOT NULL ); \ No newline at end of file diff --git a/src/commands/oai.rs b/src/commands/oai.rs index 13a2e01..56706aa 100644 --- a/src/commands/oai.rs +++ b/src/commands/oai.rs @@ -12,7 +12,7 @@ use serenity::model::channel::Message; use serenity::model::prelude::{ChannelType, PermissionOverwrite, PermissionOverwriteType}; use serenity::prelude::*; -use crate::db::{connection, NewMessageDB, MessageDB}; +use crate::db::{connection, messages::{MessageDB, NewMessageDB}}; pub struct OAI { pub client: reqwest::Client, diff --git a/src/db/mod.rs b/src/db/mod.rs index 991ff2b..261221d 100644 --- a/src/db/mod.rs +++ b/src/db/mod.rs @@ -1,26 +1,25 @@ use crate::error_handler::ServiceError; use diesel::{r2d2::ConnectionManager, PgConnection}; +use serde::{Deserialize, Serialize}; use crate::diesel_migrations::MigrationHarness; use lazy_static::lazy_static; use log::{error, info}; use r2d2; use std::env; -mod backgrounds; -mod bestiary; -mod classes; -mod conditions; -mod feats; -mod items; -mod messages; -mod options; -mod races; -mod spells; -mod users; +pub mod backgrounds; +pub mod bestiary; +pub mod classes; +pub mod conditions; +pub mod feats; +pub mod items; +pub mod messages; +pub mod options; +pub mod races; +pub mod spells; +pub mod users; pub mod schema; -pub use messages::*; - type Pool = r2d2::Pool>; pub type DbConnection = r2d2::PooledConnection>; @@ -52,3 +51,21 @@ pub fn connection() -> Result { POOL.get() .map_err(|e| ServiceError::new(500, format!("Failed getting db connection: {}", e))) } + +pub fn load_data() { + spells::load_data(); +} + +#[derive(Serialize, Deserialize)] +pub struct GetResponse { + pub data: T, + #[serde(skip_serializing_if = "Option::is_none")] + pub metadata: Option +} + +#[derive(Serialize, Deserialize)] +pub struct Metadata { + pub total: i32, + pub limit: i32, + pub page: i32 +} diff --git a/src/db/spells/mod.rs b/src/db/spells/mod.rs index 4a7ebf6..46a85d4 100644 --- a/src/db/spells/mod.rs +++ b/src/db/spells/mod.rs @@ -1,3 +1,43 @@ mod model; +mod routes; pub use model::*; +pub use routes::init_routes; + +pub fn load_data() { + let root_path = std::env::current_dir().unwrap(); + let mut data_path = std::path::PathBuf::from(root_path); + data_path.push("data/spells.json"); + match data_path.to_str() { + Some(path) => { + log::debug!("Loading spells from {}", path); + match std::fs::read_to_string(data_path) { + Ok(data) => { + match serde_json::from_str::(&data) { + Ok(json) => { + match serde_json::from_value::>(json) { + Ok(spells) => { + let count = QuerySpell::get_count().unwrap(); + if count >= spells.len() as i64 { + log::warn!("Spell data is already loaded"); + return; + } + for spell in spells { + match InsertSpell::insert(spell.into()) { + Ok(_) => {}, + Err(err) => log::error!("Failed to insert spell: {}", err) + } + } + }, + Err(err) => log::error!("Failed to parse spells data: {}", err) + } + }, + Err(err) => log::error!("Failed to parse spells data to value: {}", err) + }; + }, + Err(err) => log::error!("Failed to read spells data: {}", err) + }; + }, + None => log::error!("Failed to find spells data directory") + } +} diff --git a/src/db/spells/model.rs b/src/db/spells/model.rs index b5081be..e849104 100644 --- a/src/db/spells/model.rs +++ b/src/db/spells/model.rs @@ -1,7 +1,7 @@ use diesel::prelude::*; use serde::{Deserialize, Serialize}; -use crate::db::schema::spells; +use crate::{db::schema::spells, error_handler::ServiceError}; #[derive(Queryable, QueryableByName)] #[diesel(table_name = spells)] @@ -24,6 +24,38 @@ pub struct QuerySpell { pub description: String } +impl QuerySpell { + pub fn get_all(limit: i32, page: i32) -> Result, ServiceError> { + let mut conn = crate::db::connection()?; + let mut query = spells::table + .limit(limit as i64) + .into_boxed(); + query = query.filter(spells::id.gt(std::cmp::max(0, page - 1) * limit)); + let spells = query.load::(&mut conn)?; + Ok(spells) + } + + pub fn get_by_id(id: i32) -> Result { + let mut conn = crate::db::connection()?; + let spell = spells::table + .filter(spells::id.eq(id)) + .first::(&mut conn)?; + Ok(spell) + } + + pub fn get_count() -> Result { + let mut conn = crate::db::connection()?; + let count = spells::table.count().get_result(&mut conn)?; + Ok(count) + } + + pub fn delete(id: i32) -> Result { + let mut conn = crate::db::connection()?; + let spell = diesel::delete(spells::table.filter(spells::id.eq(id))).get_result(&mut conn)?; + Ok(spell) + } +} + #[derive(Insertable, AsChangeset)] #[diesel(table_name = spells)] pub struct InsertSpell { @@ -44,6 +76,20 @@ pub struct InsertSpell { pub description: String } +impl InsertSpell { + pub fn insert(spell: Self) -> Result { + let mut conn = crate::db::connection()?; + let spell = diesel::insert_into(spells::table).values(spell).get_result(&mut conn)?; + Ok(spell) + } + + pub fn update(id: i32, spell: Self) -> Result { + let mut conn = crate::db::connection()?; + let spell = diesel::update(spells::table.filter(spells::id.eq(id))).set(spell).get_result(&mut conn)?; + Ok(spell) + } +} + #[derive(Serialize, Deserialize)] pub struct Spell { pub name: String, @@ -56,6 +102,7 @@ pub struct Spell { pub duration: String, pub classes: Vec, pub sources: Vec, + pub tags: Vec, pub description: String } @@ -67,30 +114,8 @@ pub struct Components { pub materials_needed: Option } -impl Spell { - /// Convert spell to insertable struct - pub fn to_insert(self) -> InsertSpell { - return InsertSpell { - name: self.name, - school: self.school, - level: self.level, - ritual: self.ritual, - casting_time: self.casting_time, - range: self.range, - components_verbal: self.components.verbal, - components_somatic: self.components.somatic, - components_material: self.components.material, - components_materials_needed: self.components.materials_needed, - duration: self.duration, - classes: self.classes, - sources: self.sources, - tags: vec![], - description: self.description - } - } - - /// Convert query struct to spell - pub fn from_query(query: QuerySpell) -> Self { +impl From for Spell { + fn from(query: QuerySpell) -> Self { return Self { name: query.name, school: query.school, @@ -107,14 +132,30 @@ impl Spell { duration: query.duration, classes: query.classes, sources: query.sources, + tags: query.tags, description: query.description } } +} - /// Convert file to spell - pub fn from_file(file: String) -> Self { - let data = std::fs::read_to_string(file).unwrap(); - let spell: Spell = serde_json::from_str(&data).unwrap(); - return spell; +impl Into for Spell { + fn into(self) -> InsertSpell { + return InsertSpell { + name: self.name, + school: self.school, + level: self.level, + ritual: self.ritual, + casting_time: self.casting_time, + range: self.range, + components_verbal: self.components.verbal, + components_somatic: self.components.somatic, + components_material: self.components.material, + components_materials_needed: self.components.materials_needed, + duration: self.duration, + classes: self.classes, + sources: self.sources, + tags: self.tags, + description: self.description + } } } diff --git a/src/db/spells/routes.rs b/src/db/spells/routes.rs new file mode 100644 index 0000000..ea48000 --- /dev/null +++ b/src/db/spells/routes.rs @@ -0,0 +1,79 @@ +use actix_web::{get, post, put, delete, web, HttpResponse, HttpRequest, ResponseError}; +use serde::{Serialize, Deserialize}; + +use crate::db::{spells::QuerySpell, GetResponse, Metadata}; + +use super::{Spell, InsertSpell}; + +#[derive(Serialize, Deserialize)] +struct GetAllParams { + limit: Option, + page: Option, +} + +#[get("/spells")] +async fn get_all(req: HttpRequest) -> HttpResponse { + let params = web::Query::::from_query(req.query_string()).unwrap(); + let limit = params.limit.unwrap_or(20); + let page = params.page.unwrap_or(1); + match web::block(move || QuerySpell::get_all(limit, page)).await.unwrap() { + Ok(spells) => { + let mut response: Vec = Vec::new(); + for spell in spells { + response.push(Spell::from(spell)); + } + let total_count = QuerySpell::get_count().unwrap(); + HttpResponse::Ok().json(GetResponse { + data: response, + metadata: Some(Metadata { + total: total_count as i32, + limit, + page + }) + }) + }, + Err(err) => ResponseError::error_response(&err) + } +} + +#[get("/spells/{id}")] +async fn get_by_id(id: web::Path) -> HttpResponse { + match web::block(move || QuerySpell::get_by_id(id.into_inner())).await.unwrap() { + Ok(spell) => HttpResponse::Ok().json(GetResponse { + data: Spell::from(spell), + metadata: None + }), + Err(err) => ResponseError::error_response(&err) + } +} + +#[post("/spells")] +async fn create(spell: web::Json) -> HttpResponse { + match InsertSpell::insert(spell.into_inner().into()) { + Ok(spell) => HttpResponse::Created().json(Spell::from(spell)), + Err(err) => ResponseError::error_response(&err) + } +} + +#[put("/spells/{id}")] +async fn update(id: web::Path, spell: web::Json) -> HttpResponse { + match web::block(move || InsertSpell::update(id.into_inner(), spell.into_inner().into())).await.unwrap() { + Ok(spell) => HttpResponse::Ok().json(Spell::from(spell)), + Err(err) => ResponseError::error_response(&err) + } +} + +#[delete("/spells/{id}")] +async fn delete(id: web::Path) -> HttpResponse { + match web::block(move || QuerySpell::delete(id.into_inner())).await.unwrap() { + Ok(spell) => HttpResponse::Ok().json(Spell::from(spell)), + Err(err) => ResponseError::error_response(&err) + } +} + +pub fn init_routes(config: &mut web::ServiceConfig) { + config.service(get_all); + config.service(get_by_id); + config.service(create); + config.service(delete); +} \ No newline at end of file diff --git a/src/error_handler.rs b/src/error_handler.rs index 18f4f7c..4ae1f90 100644 --- a/src/error_handler.rs +++ b/src/error_handler.rs @@ -1,4 +1,6 @@ +use actix_web::{ResponseError, HttpResponse}; use diesel::result::Error as DieselError; +use reqwest::StatusCode; use serde::{Deserialize, Serialize}; use std::fmt; @@ -29,8 +31,27 @@ impl From for ServiceError { DieselError::DatabaseError(_, err) => ServiceError::new(409, err.message().to_string()), DieselError::NotFound => { ServiceError::new(404, "The record was not found".to_string()) - } + }, + DieselError::SerializationError(err) => { + ServiceError::new(422, err.to_string()) + }, err => ServiceError::new(500, format!("Unknown Diesel error: {}", err)), } } } + +impl ResponseError for ServiceError { + fn error_response(&self) -> HttpResponse { + let status_code = match StatusCode::from_u16(self.error_status_code) { + Ok(status_code) => status_code, + Err(_) => StatusCode::INTERNAL_SERVER_ERROR, + }; + + let error_message = match status_code.as_u16() < 500 { + true => self.error_message.clone(), + false => "Internal server error".to_string(), + }; + + HttpResponse::build(status_code).json(serde_json::json!({ "message": error_message })) + } +} diff --git a/src/main.rs b/src/main.rs index 03a755c..f3506aa 100644 --- a/src/main.rs +++ b/src/main.rs @@ -6,6 +6,7 @@ use std::collections::{HashSet, HashMap}; use std::env; use std::sync::Arc; +use actix_web::{HttpServer, App}; use commands::audio::{create_response, AudioConfig, AudioConfigs}; use dotenv::dotenv; @@ -111,60 +112,86 @@ impl EventHandler for Handler { } } -#[tokio::main] -async fn main() { +#[actix_web::main] +async fn main() -> std::io::Result<()> { dotenv().ok(); env_logger::init_from_env(env_logger::Env::default().filter_or("RUST_LOG", "warn,siren=info")); - - let token: String = env::var("DISCORD_TOKEN").expect("Expected a token in the environment"); - let intents: GatewayIntents = GatewayIntents::all(); - - let http: Http = Http::new(&token); - let (owners, _bot_id) = match http.get_current_application_info().await { - Ok(info) => { - let mut owners: HashSet = HashSet::new(); - if let Some(team) = info.team { - owners.insert(team.owner_user_id); - } else { - owners.insert(info.owner.id); - } - match http.get_current_user().await { - Ok(bot) => (owners, bot.id), - Err(why) => panic!("Could not access the bot id: {:?}", why) - } - }, - Err(why) => panic!("Could not access application info: {:?}", why) - }; - db::init(); - - let handler = match env::var("OPENAI_API_KEY") { - Ok(token) => { - info!("Loaded OpenAI token"); - Handler { - oai: Some(commands::oai::OAI { client: reqwest::Client::new(), base_url: "https://api.openai.com/v1".to_string(), max_attempts: 5, token , max_context_questions: 15 }) - } - } + db::load_data(); + + // setup_discord_bot(); + + let host = env::var("SERVICE_HOST").unwrap_or("localhost".to_string()); + let port = env::var("SERVICE_PORT").unwrap_or("5000".to_string()); + + match HttpServer::new(|| { + App::new() + .configure(db::spells::init_routes) + }) + .bind(format!("{}:{}", host, port)) { + Ok(b) => { + info!("Binding server to {}:{}", host, port); + b + }, Err(err) => { - warn!("Could not load OpenAI token: {}", err); - Handler { oai: None } + error!("Could not bind server: {}", err); + return Err(err); } - }; - - let mut client = Client::builder(token, intents) - .event_handler(handler) - .framework(StandardFramework::new() - .configure(|c| c.owners(owners))) - .register_songbird() - .await - .expect("Error creating client"); - - { - let mut data = client.data.write().await; - data.insert::(Arc::new(RwLock::new(HashMap::default()))); - } - - if let Err(why) = client.start_autosharded().await { - error!("An error occurred while running the client: {:?}", why); } + .run() + .await +} + +fn setup_discord_bot() { + tokio::spawn(async { + let token: String = env::var("DISCORD_TOKEN").expect("Expected a token in the environment"); + let intents: GatewayIntents = GatewayIntents::all(); + + let http: Http = Http::new(&token); + let (owners, _bot_id) = match http.get_current_application_info().await { + Ok(info) => { + let mut owners: HashSet = HashSet::new(); + if let Some(team) = info.team { + owners.insert(team.owner_user_id); + } else { + owners.insert(info.owner.id); + } + match http.get_current_user().await { + Ok(bot) => (owners, bot.id), + Err(why) => panic!("Could not access the bot id: {:?}", why) + } + }, + Err(why) => panic!("Could not access application info: {:?}", why) + }; + + let handler = match env::var("OPENAI_API_KEY") { + Ok(token) => { + info!("Loaded OpenAI token"); + Handler { + oai: Some(commands::oai::OAI { client: reqwest::Client::new(), base_url: "https://api.openai.com/v1".to_string(), max_attempts: 5, token , max_context_questions: 15 }) + } + } + Err(err) => { + warn!("Could not load OpenAI token: {}", err); + Handler { oai: None } + } + }; + + let mut client = Client::builder(token, intents) + .event_handler(handler) + .framework(StandardFramework::new() + .configure(|c| c.owners(owners))) + .register_songbird() + .await + .expect("Error creating client"); + + { + let mut data = client.data.write().await; + data.insert::(Arc::new(RwLock::new(HashMap::default()))); + } + + if let Err(why) = client.start_autosharded().await { + error!("An error occurred while running the client: {:?}", why); + } + }); } From 75a71410a5d84675fbbdff428ada9111eaa45b32 Mon Sep 17 00:00:00 2001 From: Benjamin Sherriff Date: Tue, 3 Oct 2023 21:00:14 -0400 Subject: [PATCH 05/14] Updated spells schema --- .gitignore | 1 - data/spells/cantrips.json | 37 ++ data/spells/level_1.json | 0 migrations/000007_create_spells/up.sql | 22 +- src/commands/oai.rs | 104 ++-- src/db/classes/mod.rs | 3 + src/db/classes/model.rs | 41 ++ src/db/conditions/mod.rs | 69 +++ src/db/schema.rs | 22 +- src/db/spells/mod.rs | 68 +-- src/db/spells/model.rs | 640 +++++++++++++++++++++++-- src/error_handler.rs | 14 +- src/main.rs | 12 +- 13 files changed, 890 insertions(+), 143 deletions(-) create mode 100644 data/spells/cantrips.json create mode 100644 data/spells/level_1.json create mode 100644 src/db/classes/model.rs diff --git a/.gitignore b/.gitignore index c6f6245..ac78a29 100644 --- a/.gitignore +++ b/.gitignore @@ -8,4 +8,3 @@ audio/ logs/ settings.json app/ -data/ diff --git a/data/spells/cantrips.json b/data/spells/cantrips.json new file mode 100644 index 0000000..eee31ef --- /dev/null +++ b/data/spells/cantrips.json @@ -0,0 +1,37 @@ +[ + { + "name": "Acid Splash", + "school": "conjuration", + "level": 0, + "ritual": false, + "casting_time": { + "amount": 1, + "type": "action" + }, + "range": { + "type": "point", + "amount": 60, + "unit": "feet" + }, + "components": { + "verbal": true, + "somatic": true, + "material": false + }, + "duration": { + "type": "instantaneous" + }, + "classes": ["artificer", "sorcerer", "wizard"], + "sources": [ + { + "source": "PHB", + "page": 211 + } + ], + "description": { + "entries": [ + "You hurl a bubble of acid. Choose one creature within range, or choose two creatures within range that are within 5 feet of each other. A target must succeed on a Dexterity saving throw or take {@damage 1d6} acid damage.", + "This spell's damage increases by {@damage 1d6} when you reach 5th level ({@damage 2d6}), 11th level ({@damage 3d6}), and 17th level ({@damage 4d6})."] + } + } +] \ No newline at end of file diff --git a/data/spells/level_1.json b/data/spells/level_1.json new file mode 100644 index 0000000..e69de29 diff --git a/migrations/000007_create_spells/up.sql b/migrations/000007_create_spells/up.sql index 83beab8..ee9c5d7 100644 --- a/migrations/000007_create_spells/up.sql +++ b/migrations/000007_create_spells/up.sql @@ -4,15 +4,29 @@ CREATE TABLE IF NOT EXISTS spells ( school TEXT NOT NULL, level INTEGER NOT NULL, ritual BOOLEAN DEFAULT FALSE, - casting_time TEXT NOT NULL, - range TEXT NOT NULL, + casting_time_amount INTEGER NOT NULL, + casting_time_unit TEXT NOT NULL, + saving_throw TEXT[], + attack_type TEXT, + damage_type TEXT, + conditions TEXT[], + range_type TEXT NOT NULL, + range_amount INTEGER, + range_unit TEXT, + area_type TEXT, + area_amount INTEGER, + area_unit TEXT, components_verbal BOOLEAN DEFAULT FALSE, components_somatic BOOLEAN DEFAULT FALSE, components_material BOOLEAN DEFAULT FALSE, components_materials_needed TEXT, - duration TEXT NOT NULL, + components_materials_cost INTEGER, + components_materials_consumed BOOLEAN DEFAULT FALSE, + duration_type TEXT NOT NULL, + duration_amount INTEGER, + duration_unit TEXT, classes TEXT[] NOT NULL, sources TEXT[] NOT NULL, tags TEXT[], - description TEXT NOT NULL + description JSONB NOT NULL ); \ No newline at end of file diff --git a/src/commands/oai.rs b/src/commands/oai.rs index 56706aa..9e29c4b 100644 --- a/src/commands/oai.rs +++ b/src/commands/oai.rs @@ -1,7 +1,3 @@ - -use std::error::Error; -use std::fmt; - use diesel::{prelude::*, insert_into}; use log::{error, debug, trace, warn}; @@ -13,12 +9,15 @@ use serenity::model::prelude::{ChannelType, PermissionOverwrite, PermissionOverw use serenity::prelude::*; use crate::db::{connection, messages::{MessageDB, NewMessageDB}}; +use crate::error_handler::ServiceError; pub struct OAI { pub client: reqwest::Client, pub base_url: String, pub max_attempts: i64, pub token: String, + pub max_tokens: i64, + pub default_model: GPTModel, pub max_context_questions: i64 } @@ -65,7 +64,7 @@ enum GPTRole { } #[derive(Debug, Clone, Serialize, Deserialize)] -enum GPTModel { +pub enum GPTModel { #[serde(rename = "gpt-3.5-turbo")] GPT35Turbo, #[serde(rename = "gpt-3.5-turbo-0613")] @@ -110,26 +109,18 @@ struct Choice { #[derive(Debug, Clone, Serialize, Deserialize)] struct ResponseError { - code: Option, + error: Option, message: Option, param: Option, #[serde(rename = "type")] error_type: Option } -#[derive(Debug)] -struct OAIError { - pub message: String +#[derive(Debug, Clone, Serialize, Deserialize)] +struct ErrorDetails { + code: Option } -impl fmt::Display for OAIError { - fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { - write!(f, "OAIError: {}", self.message) - } -} - -impl Error for OAIError {} - #[derive(Debug, Clone, Serialize, Deserialize)] enum ResponseEvent { ChatCompletionResponse(ChatCompletionResponse), @@ -137,7 +128,7 @@ enum ResponseEvent { } impl OAI { - async fn get_request(&self, request: ChatCompletionRequest) -> Result { + async fn get_request(&self, request: ChatCompletionRequest) -> Result { let uri = format!("{}/chat/completions", self.base_url); let body = serde_json::to_string(&request).unwrap(); trace!("Sending request to {}: {}", uri, body); @@ -150,35 +141,39 @@ impl OAI { .send() .await { Ok(r) => r, - Err(err) => return Err(OAIError { + Err(err) => return Err(ServiceError { message: format!("Could not send request to OpenAI: {}", err), + status: 500 }) } .json::() .await { Ok(r) => r, - Err(err) => return Err(OAIError { - message: format!("Could not read response from OpenAI: {}", err) + Err(err) => return Err(ServiceError { + message: format!("Could not read response from OpenAI: {}", err), + status: 500 }) }; trace!("Received response from OpenAI: {:?}", value); - // let response = match serde_json::from_value::(value) { + // let response = match serde_json::from_value::(value) { // Ok(r) => { // match r { - // OAIResponseEvent::OAIResponse(r) => r, - // OAIResponseEvent::OAIError(e) => return Err(OAIError { message: e.message.unwrap_or("Unknown error".to_string()) }) + // ResponseEvent::ChatCompletionResponse(r) => r, + // ResponseEvent::ResponseError(e) => return Err(ServiceError { message: e.message.unwrap_or("Unknown error".to_string()), status: 500 }), // } // }, - // Err(err) => return Err(OAIError { - // message: format!("Could not parse response from OpenAI: {}", err) + // Err(err) => return Err(ServiceError { + // message: format!("Could not parse response from OpenAI: {}", err), + // status: 500 // }) // }; let response = match serde_json::from_value::(value) { Ok(r) => r, - Err(err) => return Err(OAIError { - message: format!("Could not parse response from OpenAI: {}", err) + Err(err) => return Err(ServiceError { + message: format!("Could not parse response from OpenAI: {}", err), + status: 500 }) }; @@ -208,20 +203,6 @@ pub async fn generate_response(ctx: &Context, msg: &Message, oai: &OAI) { .order(crate::db::schema::messages::created.asc()) .limit(oai.max_context_questions) .load(&mut connection); - - let previous_messages = match result { - Ok(r) => { - let mut previous_message = "".to_string(); - for message in r { - previous_message = format!("{}You: {}\n Siren: {}\n", previous_message, message.request, message.response); - } - Some(ChatCompletionMessage { role: GPTRole::User, content: previous_message }) - } - Err(err) => { - error!("Could not load previous messages: {}", err); - None - } - }; let mut messages = vec![ ChatCompletionMessage { @@ -230,25 +211,34 @@ pub async fn generate_response(ctx: &Context, msg: &Message, oai: &OAI) { }, ]; - if let Some(mut previous) = previous_messages { - previous.content = format!("{}You: {}\nSiren: ", previous.content, parsed_content); - messages.push(previous); - } else { - messages.push(ChatCompletionMessage { - role: GPTRole::User, - content: format!("You: {}, Siren: ", parsed_content) - }); - } - - let model = "gpt-3.5-turbo".to_string(); + match result { + Ok(r) => { + for message in r { + messages.push( + ChatCompletionMessage { + role: GPTRole::User, + content: format!("{}", message.request) + } + ); + messages.push( + ChatCompletionMessage { + role: GPTRole::Assistant, + content: format!("{}", message.response) + } + ); + } + }, + Err(err) => error!("Could not load previous messages: {}", err) + }; + messages.push(ChatCompletionMessage { role: GPTRole::User, content: parsed_content.clone() }); let request = ChatCompletionRequest { - model: GPTModel::GPT35Turbo, + model: oai.default_model.clone(), messages, temperature: Some(0.5), top_p: None, n: None, - max_tokens: Some(1000), + max_tokens: Some(oai.max_tokens), presence_penalty: Some(0.6), frequency_penalty: Some(0.0), user: Some(msg.author.name.clone()) @@ -289,7 +279,7 @@ pub async fn generate_response(ctx: &Context, msg: &Message, oai: &OAI) { channel_id: response_channel.0 as i64, user_id: author_id.0 as i64, created: r.created, - model: &model, + model: &serde_json::to_string(&r.model).unwrap(), request: &parsed_content, response: &res, request_tags: vec![], @@ -305,7 +295,7 @@ pub async fn generate_response(ctx: &Context, msg: &Message, oai: &OAI) { } Err(err) => { error!("Could not get response from OpenAI: {}", err.message); - err.message + "There was an error processing your message. Please try again later.".to_string() } }; debug!("Writing response: \"{}\"", response); diff --git a/src/db/classes/mod.rs b/src/db/classes/mod.rs index e69de29..24e3024 100644 --- a/src/db/classes/mod.rs +++ b/src/db/classes/mod.rs @@ -0,0 +1,3 @@ +mod model; + +pub use model::*; \ No newline at end of file diff --git a/src/db/classes/model.rs b/src/db/classes/model.rs new file mode 100644 index 0000000..c76b89a --- /dev/null +++ b/src/db/classes/model.rs @@ -0,0 +1,41 @@ +use std::str::FromStr; + +use serde::{Serialize, Deserialize}; + +#[derive(Debug, Serialize, Deserialize)] +pub enum AbilityType { + Strength, + Dexterity, + Constitution, + Intelligence, + Wisdom, + Charisma +} + +impl AbilityType { + pub fn to_string(&self) -> String { + match self { + AbilityType::Strength => "Strength".to_string(), + AbilityType::Dexterity => "Dexterity".to_string(), + AbilityType::Constitution => "Constitution".to_string(), + AbilityType::Intelligence => "Intelligence".to_string(), + AbilityType::Wisdom => "Wisdom".to_string(), + AbilityType::Charisma => "Charisma".to_string() + } + } +} + +impl FromStr for AbilityType { + type Err = (); + fn from_str(s: &str) -> Result { + match s { + "Strength" => Ok(AbilityType::Strength), + "Dexterity" => Ok(AbilityType::Dexterity), + "Constitution" => Ok(AbilityType::Constitution), + "Intelligence" => Ok(AbilityType::Intelligence), + "Wisdom" => Ok(AbilityType::Wisdom), + "Charisma" => Ok(AbilityType::Charisma), + _ => Err(()) + } + } +} \ No newline at end of file diff --git a/src/db/conditions/mod.rs b/src/db/conditions/mod.rs index e69de29..ca4e835 100644 --- a/src/db/conditions/mod.rs +++ b/src/db/conditions/mod.rs @@ -0,0 +1,69 @@ +use std::str::FromStr; + +use serde::{Deserialize, Serialize}; + + +#[derive(Debug, Serialize, Deserialize)] +pub enum ConditionType { + Blinded, + Charmed, + Deafened, + Exhaustion, + Frightened, + Grappled, + Incapacitated, + Invisible, + Paralyzed, + Petrified, + Poisoned, + Prone, + Restrained, + Stunned, + Unconscious +} + +impl ConditionType { + pub fn to_string(&self) -> String { + match self { + ConditionType::Blinded => "Blinded".to_string(), + ConditionType::Charmed => "Charmed".to_string(), + ConditionType::Deafened => "Deafened".to_string(), + ConditionType::Exhaustion => "Exhaustion".to_string(), + ConditionType::Frightened => "Frightened".to_string(), + ConditionType::Grappled => "Grappled".to_string(), + ConditionType::Incapacitated => "Incapacitated".to_string(), + ConditionType::Invisible => "Invisible".to_string(), + ConditionType::Paralyzed => "Paralyzed".to_string(), + ConditionType::Petrified => "Petrified".to_string(), + ConditionType::Poisoned => "Poisoned".to_string(), + ConditionType::Prone => "Prone".to_string(), + ConditionType::Restrained => "Restrained".to_string(), + ConditionType::Stunned => "Stunned".to_string(), + ConditionType::Unconscious => "Unconscious".to_string() + } + } +} + +impl FromStr for ConditionType { + type Err = (); + fn from_str(s: &str) -> Result { + match s { + "Blinded" => Ok(ConditionType::Blinded), + "Charmed" => Ok(ConditionType::Charmed), + "Deafened" => Ok(ConditionType::Deafened), + "Exhaustion" => Ok(ConditionType::Exhaustion), + "Frightened" => Ok(ConditionType::Frightened), + "Grappled" => Ok(ConditionType::Grappled), + "Incapacitated" => Ok(ConditionType::Incapacitated), + "Invisible" => Ok(ConditionType::Invisible), + "Paralyzed" => Ok(ConditionType::Paralyzed), + "Petrified" => Ok(ConditionType::Petrified), + "Poisoned" => Ok(ConditionType::Poisoned), + "Prone" => Ok(ConditionType::Prone), + "Restrained" => Ok(ConditionType::Restrained), + "Stunned" => Ok(ConditionType::Stunned), + "Unconscious" => Ok(ConditionType::Unconscious), + _ => Err(()) + } + } +} diff --git a/src/db/schema.rs b/src/db/schema.rs index 17e2dc8..f44816b 100644 --- a/src/db/schema.rs +++ b/src/db/schema.rs @@ -20,16 +20,30 @@ diesel::table! { school -> Text, level -> Integer, ritual -> Bool, - casting_time -> Text, - range -> Text, + casting_time_amount -> Integer, + casting_time_unit -> Text, + saving_throw -> Nullable>, + attack_type -> Nullable, + damage_type -> Nullable, + conditions -> Nullable>, + range_type -> Text, + range_amount -> Nullable, + range_unit -> Nullable, + area_type -> Nullable, + area_amount -> Nullable, + area_unit -> Nullable, components_verbal -> Bool, components_somatic -> Bool, components_material -> Bool, components_materials_needed -> Nullable, - duration -> Text, + components_materials_cost -> Nullable, + components_materials_consumed -> Nullable, + duration_type -> Text, + duration_amount -> Nullable, + duration_unit -> Nullable, classes -> Array, sources -> Array, tags -> Array, - description -> Text + description -> Jsonb } } \ No newline at end of file diff --git a/src/db/spells/mod.rs b/src/db/spells/mod.rs index 46a85d4..6dfedea 100644 --- a/src/db/spells/mod.rs +++ b/src/db/spells/mod.rs @@ -6,38 +6,40 @@ pub use routes::init_routes; pub fn load_data() { let root_path = std::env::current_dir().unwrap(); - let mut data_path = std::path::PathBuf::from(root_path); - data_path.push("data/spells.json"); - match data_path.to_str() { - Some(path) => { - log::debug!("Loading spells from {}", path); - match std::fs::read_to_string(data_path) { - Ok(data) => { - match serde_json::from_str::(&data) { - Ok(json) => { - match serde_json::from_value::>(json) { - Ok(spells) => { - let count = QuerySpell::get_count().unwrap(); - if count >= spells.len() as i64 { - log::warn!("Spell data is already loaded"); - return; - } - for spell in spells { - match InsertSpell::insert(spell.into()) { - Ok(_) => {}, - Err(err) => log::error!("Failed to insert spell: {}", err) - } - } - }, - Err(err) => log::error!("Failed to parse spells data: {}", err) - } - }, - Err(err) => log::error!("Failed to parse spells data to value: {}", err) - }; - }, - Err(err) => log::error!("Failed to read spells data: {}", err) - }; - }, - None => log::error!("Failed to find spells data directory") + let files = [ + "cantrips.json", "level_1.json", "level_2.json", "level_3.json", "level_4.json", "level_5.json", "level_6.json", "level_7.json", "level_8.json", "level_9.json" + ]; + let mut spells: Vec = vec![]; + for file in files { + let mut data_path = std::path::PathBuf::from(&root_path); + data_path.push(format!("data/spells/{}", file)); + let path = data_path.to_str().unwrap(); + match std::fs::read_to_string(path) { + Ok(data) => { + log::debug!("Loading spells from {}", path); + match serde_json::from_str::(&data) { + Ok(json) => { + match serde_json::from_value::>(json) { + Ok(mut new_spells) => spells.append(&mut new_spells), + Err(err) => log::error!("Failed to parse spells data: {}", err) + } + }, + Err(err) => log::error!("Failed to parse spells data to value: {}", err) + }; + }, + Err(err) => log::error!("Failed to read from {}: {}", file, err) + }; + } + let count = QuerySpell::get_count().unwrap(); + if count >= spells.len() as i64 { + log::warn!("Spell data is already loaded"); + return; + } + for spell in spells { + let spell_name = spell.name.clone(); + match InsertSpell::insert(spell.into()) { + Ok(_) => {}, + Err(err) => log::error!("Failed to insert '{}' spell: {}", spell_name, err) + } } } diff --git a/src/db/spells/model.rs b/src/db/spells/model.rs index e849104..792b2bb 100644 --- a/src/db/spells/model.rs +++ b/src/db/spells/model.rs @@ -1,7 +1,9 @@ -use diesel::prelude::*; -use serde::{Deserialize, Serialize}; +use std::str::FromStr; -use crate::{db::schema::spells, error_handler::ServiceError}; +use diesel::prelude::*; +use serde::{Deserialize, Serialize, ser::SerializeMap}; + +use crate::{db::{schema::spells, classes::AbilityType, conditions::ConditionType}, error_handler::ServiceError}; #[derive(Queryable, QueryableByName)] #[diesel(table_name = spells)] @@ -11,17 +13,31 @@ pub struct QuerySpell { pub school: String, pub level: i32, pub ritual: bool, - pub casting_time: String, - pub range: String, + pub casting_time_amount: i32, + pub casting_time_unit: String, + pub saving_throw: Option>, + pub attack_type: Option, + pub damage_type: Option, + pub conditions: Option>, + pub range_type: String, + pub range_amount: Option, + pub range_unit: Option, + pub area_type: Option, + pub area_amount: Option, + pub area_unit: Option, pub components_verbal: bool, pub components_somatic: bool, pub components_material: bool, pub components_materials_needed: Option, - pub duration: String, + pub components_materials_cost: Option, + pub components_materials_consumed: Option, + pub duration_type: String, + pub duration_amount: Option, + pub duration_unit: Option, pub classes: Vec, pub sources: Vec, pub tags: Vec, - pub description: String + pub description: serde_json::Value } impl QuerySpell { @@ -63,17 +79,31 @@ pub struct InsertSpell { pub school: String, pub level: i32, pub ritual: bool, - pub casting_time: String, - pub range: String, + pub casting_time_amount: i32, + pub casting_time_unit: String, + pub saving_throw: Option>, + pub attack_type: Option, + pub damage_type: Option, + pub conditions: Option>, + pub range_type: String, + pub range_amount: Option, + pub range_unit: Option, + pub area_type: Option, + pub area_amount: Option, + pub area_unit: Option, pub components_verbal: bool, pub components_somatic: bool, pub components_material: bool, pub components_materials_needed: Option, - pub duration: String, + pub components_materials_cost: Option, + pub components_materials_consumed: Option, + pub duration_type: String, + pub duration_amount: Option, + pub duration_unit: Option, pub classes: Vec, pub sources: Vec, pub tags: Vec, - pub description: String + pub description: serde_json::Value } impl InsertSpell { @@ -90,50 +120,541 @@ impl InsertSpell { } } -#[derive(Serialize, Deserialize)] +#[derive(Debug, Serialize, Deserialize)] pub struct Spell { pub name: String, - pub school: String, + pub school: SchoolType, pub level: i32, pub ritual: bool, - pub casting_time: String, - pub range: String, + pub casting_time: CastingTime, + #[serde(skip_serializing_if = "Option::is_none")] + pub saving_throw: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub attack_type: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub damage_type: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub conditions: Option>, + pub range: Range, + #[serde(skip_serializing_if = "Option::is_none")] + pub area: Option, pub components: Components, - pub duration: String, + pub duration: Duration, pub classes: Vec, - pub sources: Vec, - pub tags: Vec, - pub description: String + pub sources: Vec, + #[serde(skip_serializing_if = "Option::is_none")] + pub tags: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub description: Option } -#[derive(Serialize, Deserialize)] +#[derive(Debug, Serialize, Deserialize)] +pub enum SchoolType { + #[serde(rename = "abjuration")] + Abjuration, + #[serde(rename = "conjuration")] + Conjuration, + #[serde(rename = "divination")] + Divination, + #[serde(rename = "enchantment")] + Enchantment, + #[serde(rename = "evocation")] + Evocation, + #[serde(rename = "illusion")] + Illusion, + #[serde(rename = "necromancy")] + Necromancy, + #[serde(rename = "transmutation")] + Transmutation +} + +impl SchoolType { + pub fn to_string(&self) -> String { + match self { + SchoolType::Abjuration => "abjuration".to_string(), + SchoolType::Conjuration => "conjuration".to_string(), + SchoolType::Divination => "divination".to_string(), + SchoolType::Enchantment => "enchantment".to_string(), + SchoolType::Evocation => "evocation".to_string(), + SchoolType::Illusion => "illusion".to_string(), + SchoolType::Necromancy => "necromancy".to_string(), + SchoolType::Transmutation => "transmutation".to_string() + } + } +} + +impl FromStr for SchoolType { + type Err = (); + + fn from_str(s: &str) -> Result { + match s { + "abjuration" => Ok(SchoolType::Abjuration), + "conjuration" => Ok(SchoolType::Conjuration), + "divination" => Ok(SchoolType::Divination), + "enchantment" => Ok(SchoolType::Enchantment), + "evocation" => Ok(SchoolType::Evocation), + "illusion" => Ok(SchoolType::Illusion), + "necromancy" => Ok(SchoolType::Necromancy), + "transmutation" => Ok(SchoolType::Transmutation), + _ => Err(()) + } + } +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct CastingTime { + pub amount: i32, + #[serde(rename = "type")] + pub casting_type: CastingType +} + +#[derive(Debug, Serialize, Deserialize)] +pub enum CastingType { + #[serde(rename = "action")] + Action, + #[serde(rename = "bonus")] + BonusAction, + #[serde(rename = "reaction")] + Reaction, + #[serde(rename = "minutes")] + Minutes, + #[serde(rename = "hours")] + Hours +} + +impl CastingType { + pub fn to_string(&self) -> String { + match self { + CastingType::Action => "action".to_string(), + CastingType::BonusAction => "bonus".to_string(), + CastingType::Reaction => "reaction".to_string(), + CastingType::Minutes => "minutes".to_string(), + CastingType::Hours => "hours".to_string() + } + } +} + +impl FromStr for CastingType { + type Err = (); + + fn from_str(s: &str) -> Result { + match s { + "action" => Ok(CastingType::Action), + "bonus" => Ok(CastingType::BonusAction), + "reaction" => Ok(CastingType::Reaction), + "minutes" => Ok(CastingType::Minutes), + "hours" => Ok(CastingType::Hours), + _ => Err(()) + } + } +} + +#[derive(Debug, Serialize, Deserialize)] +pub enum SpellAttackType { + #[serde(rename = "melee")] + Melee, + #[serde(rename = "ranged")] + Ranged, +} + +impl SpellAttackType { + pub fn to_string(&self) -> String { + match self { + SpellAttackType::Melee => "melee".to_string(), + SpellAttackType::Ranged => "ranged".to_string() + } + } +} + +impl FromStr for SpellAttackType { + type Err = (); + + fn from_str(s: &str) -> Result { + match s { + "melee" => Ok(SpellAttackType::Melee), + "ranged" => Ok(SpellAttackType::Ranged), + _ => Err(()) + } + } +} + +#[derive(Debug, Serialize, Deserialize)] +pub enum SpellDamageType { + #[serde(rename = "acid")] + Acid, + #[serde(rename = "bludgeoning")] + Bludgeoning, + #[serde(rename = "cold")] + Cold, + #[serde(rename = "fire")] + Fire, + #[serde(rename = "force")] + Force, + #[serde(rename = "lightning")] + Lightning, + #[serde(rename = "necrotic")] + Necrotic, + #[serde(rename = "piercing")] + Piercing, + #[serde(rename = "poison")] + Poison, + #[serde(rename = "psychic")] + Psychic, + #[serde(rename = "radiant")] + Radiant, + #[serde(rename = "slashing")] + Slashing, + #[serde(rename = "thunder")] + Thunder +} + +impl SpellDamageType { + pub fn to_string(&self) -> String { + match self { + SpellDamageType::Acid => "acid".to_string(), + SpellDamageType::Bludgeoning => "bludgeoning".to_string(), + SpellDamageType::Cold => "cold".to_string(), + SpellDamageType::Fire => "fire".to_string(), + SpellDamageType::Force => "force".to_string(), + SpellDamageType::Lightning => "lightning".to_string(), + SpellDamageType::Necrotic => "necrotic".to_string(), + SpellDamageType::Piercing => "piercing".to_string(), + SpellDamageType::Poison => "poison".to_string(), + SpellDamageType::Psychic => "psychic".to_string(), + SpellDamageType::Radiant => "radiant".to_string(), + SpellDamageType::Slashing => "slashing".to_string(), + SpellDamageType::Thunder => "thunder".to_string() + } + } +} + +impl FromStr for SpellDamageType { + type Err = (); + + fn from_str(s: &str) -> Result { + match s { + "acid" => Ok(SpellDamageType::Acid), + "bludgeoning" => Ok(SpellDamageType::Bludgeoning), + "cold" => Ok(SpellDamageType::Cold), + "fire" => Ok(SpellDamageType::Fire), + "force" => Ok(SpellDamageType::Force), + "lightning" => Ok(SpellDamageType::Lightning), + "necrotic" => Ok(SpellDamageType::Necrotic), + "piercing" => Ok(SpellDamageType::Piercing), + "poison" => Ok(SpellDamageType::Poison), + "psychic" => Ok(SpellDamageType::Psychic), + "radiant" => Ok(SpellDamageType::Radiant), + "slashing" => Ok(SpellDamageType::Slashing), + "thunder" => Ok(SpellDamageType::Thunder), + _ => Err(()) + } + } +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct Range { + #[serde(rename = "type")] + pub range_type: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub amount: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub unit: Option +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct Area { + #[serde(rename = "type")] + pub area_type: AreaType, + #[serde(skip_serializing_if = "Option::is_none")] + pub amount: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub unit: Option +} + +#[derive(Debug, Serialize, Deserialize)] +pub enum AreaType { + #[serde(rename = "cone")] + Cone, + #[serde(rename = "cube")] + Cube, + #[serde(rename = "cylinder")] + Cylinder, + #[serde(rename = "line")] + Line, + #[serde(rename = "sphere")] + Sphere +} + +impl AreaType { + pub fn to_string(&self) -> String { + match self { + AreaType::Cone => "cone".to_string(), + AreaType::Cube => "cube".to_string(), + AreaType::Cylinder => "cylinder".to_string(), + AreaType::Line => "line".to_string(), + AreaType::Sphere => "sphere".to_string() + } + } +} + +impl FromStr for AreaType { + type Err = (); + + fn from_str(s: &str) -> Result { + match s { + "cone" => Ok(AreaType::Cone), + "cube" => Ok(AreaType::Cube), + "cylinder" => Ok(AreaType::Cylinder), + "line" => Ok(AreaType::Line), + "sphere" => Ok(AreaType::Sphere), + _ => Err(()) + } + } +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct Duration { + #[serde(rename = "type")] + pub duration_type: DurationType, + #[serde(skip_serializing_if = "Option::is_none")] + pub amount: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub unit: Option +} + +#[derive(Debug, Serialize, Deserialize)] +pub enum DurationType { + #[serde(rename = "concentration")] + Concentration, + #[serde(rename = "instantaneous")] + Instantaneous, + #[serde(rename = "timed")] + Timed, + #[serde(rename = "dispelled")] + UntilDispelled, + #[serde(rename = "special")] + Special +} + +impl DurationType { + pub fn to_string(&self) -> String { + match self { + DurationType::Concentration => "concentration".to_string(), + DurationType::Instantaneous => "instantaneous".to_string(), + DurationType::Timed => "timed".to_string(), + DurationType::UntilDispelled => "dispelled".to_string(), + DurationType::Special => "special".to_string() + } + } +} + +impl FromStr for DurationType { + type Err = (); + + fn from_str(s: &str) -> Result { + match s { + "concentration" => Ok(DurationType::Concentration), + "instantaneous" => Ok(DurationType::Instantaneous), + "timed" => Ok(DurationType::Timed), + "dispelled" => Ok(DurationType::UntilDispelled), + "special" => Ok(DurationType::Special), + _ => Err(()) + } + } +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct Source { + pub source: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub page: Option +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct Description { + pub entries: Vec +} + +#[derive(Debug)] +pub struct Entry { + pub entry_type: String, + pub items: Vec +} + +impl<'de> Deserialize<'de> for Entry { + fn deserialize(deserializer: D) -> Result where D: serde::Deserializer<'de> { + let value = serde_json::Value::deserialize(deserializer)?; + match value { + serde_json::Value::String(s) => Ok(Entry { + entry_type: "string".to_string(), + items: vec![s] + }), + serde_json::Value::Object(o) => { + let entry_type = match o.get("type") { + Some(t) => match t.as_str() { + Some(s) => s.to_string(), + None => return Err(serde::de::Error::custom("Invalid entry type")) + }, + None => return Err(serde::de::Error::custom("Missing entry type")) + }; + let items = match o.get("items") { + Some(i) => match i.as_array() { + Some(a) => { + let mut items = Vec::new(); + for item in a { + match item.as_str() { + Some(s) => items.push(s.to_string()), + None => return Err(serde::de::Error::custom("Invalid entry item")) + } + } + items + }, + None => return Err(serde::de::Error::custom("Invalid entry items")) + }, + None => return Err(serde::de::Error::custom("Missing entry items")) + }; + Ok(Entry { + entry_type, + items + }) + }, + _ => Err(serde::de::Error::custom("Invalid entry")) + } + } +} + +impl Serialize for Entry { + fn serialize(&self, serializer: S) -> Result where S: serde::Serializer { + match self.entry_type.as_str() { + "string" => serializer.serialize_str(&self.items[0]), + _ => { + let mut map = serializer.serialize_map(Some(2))?; + map.serialize_entry("type", &self.entry_type)?; + map.serialize_entry("items", &self.items)?; + map.end() + } + } + } +} + +#[derive(Debug, Serialize, Deserialize)] pub struct Components { pub verbal: bool, pub somatic: bool, pub material: bool, - pub materials_needed: Option + #[serde(skip_serializing_if = "Option::is_none")] + pub materials_needed: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub materials_cost: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub materials_consumed: Option } impl From for Spell { fn from(query: QuerySpell) -> Self { return Self { name: query.name, - school: query.school, + school: match SchoolType::from_str(&query.school) { + Ok(school_type) => school_type, + Err(_) => { + log::error!("Failed to parse spell school type: {}", query.school); + SchoolType::Abjuration + } + }, level: query.level, ritual: query.ritual, - casting_time: query.casting_time, - range: query.range, + casting_time: CastingTime { + amount: query.casting_time_amount, + casting_type: match CastingType::from_str(&query.casting_time_unit) { + Ok(casting_type) => casting_type, + Err(_) => { + log::error!("Failed to parse spell casting type: {}", query.casting_time_unit); + CastingType::Action + } + } + }, + saving_throw: query.saving_throw.map(|saving_throw| saving_throw.iter().map(|ability_type| match AbilityType::from_str(&ability_type) { + Ok(ability_type) => ability_type, + Err(_) => { + log::error!("Failed to parse spell saving throw: {}", ability_type); + AbilityType::Strength + } + }).collect()), + attack_type: match query.attack_type { + Some(attack_type) => match SpellAttackType::from_str(&attack_type) { + Ok(attack_type) => Some(attack_type), + Err(_) => { + log::error!("Failed to parse spell attack type: {}", attack_type); + None + } + }, + None => None + }, + damage_type: query.damage_type.map(|damage_type| match SpellDamageType::from_str(&damage_type) { + Ok(damage_type) => damage_type, + Err(_) => { + log::error!("Failed to parse spell damage type: {}", damage_type); + SpellDamageType::Acid + } + }), + conditions: query.conditions.map(|conditions| conditions.iter().map(|condition_type| match ConditionType::from_str(&condition_type) { + Ok(condition_type) => condition_type, + Err(_) => { + log::error!("Failed to parse spell condition type: {}", condition_type); + ConditionType::Blinded + } + }).collect()), + range: Range { + range_type: query.range_type, + amount: query.range_amount, + unit: query.range_unit + }, + area: match query.area_type { + Some(area_type) => Some(Area { + area_type: match AreaType::from_str(&area_type) { + Ok(area_type) => area_type, + Err(_) => { + log::error!("Failed to parse spell area type: {}", area_type); + AreaType::Cone + } + }, + amount: query.area_amount, + unit: query.area_unit + }), + None => None + }, components: Components { verbal: query.components_verbal, somatic: query.components_somatic, material: query.components_material, - materials_needed: query.components_materials_needed + materials_needed: query.components_materials_needed, + materials_cost: query.components_materials_cost, + materials_consumed: query.components_materials_consumed + }, + duration: Duration { + duration_type: match DurationType::from_str(&query.duration_type) { + Ok(duration_type) => duration_type, + Err(_) => { + log::error!("Failed to parse spell duration type: {}", query.duration_type); + DurationType::Special + } + }, + amount: query.duration_amount, + unit: query.duration_unit }, - duration: query.duration, classes: query.classes, - sources: query.sources, - tags: query.tags, - description: query.description + sources: query.sources.iter().map(|source| Source { + source: source.to_string(), + page: None + }).collect(), + tags: Some(query.tags), + description: match serde_json::from_value(query.description) { + Ok(description) => description, + Err(err) => { + log::error!("Failed to parse spell description: {}", err); + None + } + } } } } @@ -142,20 +663,67 @@ impl Into for Spell { fn into(self) -> InsertSpell { return InsertSpell { name: self.name, - school: self.school, + school: self.school.to_string(), level: self.level, ritual: self.ritual, - casting_time: self.casting_time, - range: self.range, + casting_time_amount: self.casting_time.amount, + casting_time_unit: self.casting_time.casting_type.to_string(), + saving_throw: match self.saving_throw { + Some(saving_throw) => Some(saving_throw.iter().map(|ability_type| ability_type.to_string()).collect()), + None => None + }, + attack_type: match self.attack_type { + Some(attack_type) => Some(attack_type.to_string()), + None => None + }, + damage_type: match self.damage_type { + Some(damage_type) => Some(damage_type.to_string()), + None => None + }, + conditions: match self.conditions { + Some(conditions) => Some(conditions.iter().map(|condition_type| condition_type.to_string()).collect()), + None => None + }, + range_type: self.range.range_type.to_string(), + range_amount: self.range.amount, + range_unit: self.range.unit, + area_type: match &self.area { + Some(area) => Some(area.area_type.to_string()), + None => None + }, + area_amount: match &self.area { + Some(area) => area.amount, + None => None + }, + area_unit: match &self.area { + Some(area) => match &area.unit { + Some(unit) => Some(unit.to_string()), + None => None + }, + None => None + }, components_verbal: self.components.verbal, components_somatic: self.components.somatic, components_material: self.components.material, components_materials_needed: self.components.materials_needed, - duration: self.duration, + components_materials_cost: self.components.materials_cost, + components_materials_consumed: self.components.materials_consumed, + duration_type: self.duration.duration_type.to_string(), + duration_amount: self.duration.amount, + duration_unit: self.duration.unit, classes: self.classes, - sources: self.sources, - tags: self.tags, - description: self.description + sources: self.sources.iter().map(|source| match source.page { Some(page) => format!("{} {}", source.source, page), None => source.source.to_string() }).collect(), + tags: match self.tags { + Some(tags) => tags, + None => Vec::new() + }, + description: match serde_json::to_value(self.description) { + Ok(description) => description, + Err(err) => { + log::error!("Failed to serialize spell description: {}", err); + serde_json::Value::Null + } + } } } } diff --git a/src/error_handler.rs b/src/error_handler.rs index 4ae1f90..1d20939 100644 --- a/src/error_handler.rs +++ b/src/error_handler.rs @@ -6,22 +6,22 @@ use std::fmt; #[derive(Debug, Deserialize, Serialize)] pub struct ServiceError { - pub error_status_code: u16, - pub error_message: String, + pub status: u16, + pub message: String, } impl ServiceError { pub fn new(error_status_code: u16, error_message: String) -> ServiceError { ServiceError { - error_status_code, - error_message, + status: error_status_code, + message: error_message, } } } impl fmt::Display for ServiceError { fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { - f.write_str(self.error_message.as_str()) + f.write_str(self.message.as_str()) } } @@ -42,13 +42,13 @@ impl From for ServiceError { impl ResponseError for ServiceError { fn error_response(&self) -> HttpResponse { - let status_code = match StatusCode::from_u16(self.error_status_code) { + let status_code = match StatusCode::from_u16(self.status) { Ok(status_code) => status_code, Err(_) => StatusCode::INTERNAL_SERVER_ERROR, }; let error_message = match status_code.as_u16() < 500 { - true => self.error_message.clone(), + true => self.message.clone(), false => "Internal server error".to_string(), }; diff --git a/src/main.rs b/src/main.rs index f3506aa..832c7a1 100644 --- a/src/main.rs +++ b/src/main.rs @@ -20,6 +20,8 @@ use serenity::http::Http; use serenity::prelude::*; use songbird::SerenityInit; +use crate::commands::oai::GPTModel; + mod commands; mod error_handler; mod db; @@ -168,7 +170,15 @@ fn setup_discord_bot() { Ok(token) => { info!("Loaded OpenAI token"); Handler { - oai: Some(commands::oai::OAI { client: reqwest::Client::new(), base_url: "https://api.openai.com/v1".to_string(), max_attempts: 5, token , max_context_questions: 15 }) + oai: Some(commands::oai::OAI { + client: reqwest::Client::new(), + base_url: "https://api.openai.com/v1".to_string(), + max_attempts: 5, + token, + max_context_questions: 30, + max_tokens: 2048, + default_model: GPTModel::GPT35Turbo, + }) } } Err(err) => { From 25f52fb46db5201062806bfb1cecbcfbb9c92f30 Mon Sep 17 00:00:00 2001 From: Benjamin Sherriff Date: Tue, 3 Oct 2023 23:43:58 -0400 Subject: [PATCH 06/14] Updated spells, cleaned up duration spell schema --- data/spells/cantrips.json | 261 ++++++++++++++++++++++++- migrations/000007_create_spells/up.sql | 6 +- src/db/schema.rs | 6 +- src/db/spells/model.rs | 72 ++----- 4 files changed, 277 insertions(+), 68 deletions(-) diff --git a/data/spells/cantrips.json b/data/spells/cantrips.json index eee31ef..477d409 100644 --- a/data/spells/cantrips.json +++ b/data/spells/cantrips.json @@ -18,20 +18,267 @@ "somatic": true, "material": false }, - "duration": { - "type": "instantaneous" - }, + "durations": [ + { + "type": "instantaneous" + } + ], "classes": ["artificer", "sorcerer", "wizard"], "sources": [ - { - "source": "PHB", - "page": 211 - } + { "source": "PHB", "page": 211 }, + { "source": "SRD", "page": 114 } ], "description": { "entries": [ "You hurl a bubble of acid. Choose one creature within range, or choose two creatures within range that are within 5 feet of each other. A target must succeed on a Dexterity saving throw or take {@damage 1d6} acid damage.", "This spell's damage increases by {@damage 1d6} when you reach 5th level ({@damage 2d6}), 11th level ({@damage 3d6}), and 17th level ({@damage 4d6})."] } + }, + { + "name": "Blade Ward", + "school": "abjuration", + "level": 0, + "ritual": false, + "casting_time": { + "amount": 1, + "type": "action" + }, + "range": { + "type": "self" + }, + "components": { + "verbal": true, + "somatic": true, + "material": false + }, + "durations": [ + { + "type": "timed", + "amount": 1, + "unit": "round" + } + ], + "classes": ["bard", "sorcerer", "warlock", "wizard"], + "sources": [ + { "source": "PHB", "page": 218 } + ], + "description": { + "entries": [ + "You extend your hand and trace a sigil of warding in the air. Until the end of your next turn, you have resistance against bludgeoning, piercing, and slashing damage dealt by weapon attacks."] + } + }, + { + "name": "Booming Blade", + "school": "evocation", + "level": 0, + "ritual": false, + "casting_time": { + "amount": 1, + "type": "action" + }, + "range": { + "type": "touch" + }, + "area": { + "type": "sphere", + "amount": 5, + "unit": "feet" + }, + "components": { + "verbal": false, + "somatic": true, + "material": false, + "materials_needed": "a melee weapon worth at least 1 sp" + }, + "durations": [ + { + "type": "timed", + "amount": 1, + "unit": "round" + } + ], + "classes": ["sorcerer", "warlock", "wizard"], + "sources": [ + { "source": "SCAG", "page": 142 }, + { "source": "TCE", "page": 106 } + ], + "description": { + "entries": [ + "You brandish the weapon used in the spell's casting and make a melee attack with it against one creature within 5 feet of you. On a hit, the target suffers the weapon attack's normal effects and then becomes sheathed in booming energy until the start of your next turn. If the target willingly moves 5 feet or more before then, the target takes 1d8 thunder damage, and the spell ends.", + "This spell's damage increases when you reach certain levels. At 5th level, the melee attack deals an extra {@damage 1d8} thunder damage to the target on a hit, and the damage the target takes for moving increases to {@damage 2d8}. Both damage rolls increase by 1d8 at 11th level ({@damage 2d8} and {@damage 3d8}) and again at 17th level ({@damage 3d8} and {@damage 4d8})."] + } + }, + { + "name": "Chill Touch", + "school": "necromancy", + "level": 0, + "ritual": false, + "casting_time": { + "amount": 1, + "type": "action" + }, + "range": { + "type": "point", + "amount": 120, + "unit": "feet" + }, + "components": { + "verbal": true, + "somatic": true, + "material": false + }, + "durations": [ + { + "type": "timed", + "amount": 1, + "unit": "round" + } + ], + "classes": ["sorcerer", "warlock", "wizard"], + "sources": [ + { "source": "PHB", "page": 221 }, + { "source": "SRD", "page": 124 } + ], + "description": { + "entries": [ + "You create a ghostly, skeletal hand in the space of a creature within range. Make a ranged spell attack against the creature to assail it with the chill of the grave. On a hit, the target takes {@damage 1d8} necrotic damage, and it can't regain hit points until the start of your next turn. Until then, the hand clings to the target.", + "If you hit an undead target, it also has disadvantage on attack rolls against you until the end of your next turn.", + "This spell's damage increases by {@damage 1d8} when you reach 5th level ({@damage 2d8}), 11th level ({@damage 3d8}), and 17th level ({@damage 4d8})."] + } + }, + { + "name": "Control Flames", + "school": "transmutation", + "level": 0, + "ritual": false, + "casting_time": { + "amount": 1, + "type": "action" + }, + "range": { + "type": "point", + "amount": 60, + "unit": "feet" + }, + "area": { + "type": "cube", + "amount": 5, + "unit": "feet" + }, + "components": { + "verbal": true, + "somatic": false, + "material": false + }, + "durations": [ + { + "type": "instantaneous" + }, + { + "type": "timed", + "amount": 1, + "unit": "hour" + } + ], + "classes": ["druid", "sorcerer", "wizard"], + "sources": [ + { "source": "EE", "page": 16 }, + { "source": "XGE", "page": 152 } + ], + "description": { + "entries": [ + "You choose nonmagical flame that you can see within range and that fits within a 5-foot cube. You affect it in one of the following ways:", + { + "type": "list", + "items": [ + "You instantaneously expand the flame 5 feet in one direction, provided that wood or other fuel is present in the new location.", + "You instantaneously extinguish the flames within the cube.", + "You double or halve the area of bright light and dim light cast by the flame, change its color, or both. The change lasts for 1 hour.", + "You cause simple shapes—such as the vague form of a creature, an inanimate object, or a location—to appear within the flames and animate as you like. The shapes last for 1 hour." + ] + }, + "If you cast this spell multiple times, you can have up to three non-instantaneous effects created by it active at a time, and you can dismiss such an effect as an action."] + } + }, + { + "name": "Create Bonfire", + "school": "conjuration", + "level": 0, + "ritual": false, + "casting_time": { + "amount": 1, + "type": "action" + }, + "range": { + "type": "point", + "amount": 60, + "unit": "feet" + }, + "area": { + "type": "cube", + "amount": 5, + "unit": "feet" + }, + "components": { + "verbal": true, + "somatic": true, + "material": false + }, + "durations": [ + { + "type": "concentration", + "amount": 1, + "unit": "minute" + } + ], + "classes": ["artificer", "druid", "sorcerer", "warlock", "wizard"], + "sources": [ + { "source": "EE", "page": 16 }, + { "source": "XGE", "page": 152 } + ], + "description": { + "entries": [ + "You create a bonfire on ground that you can see within range. Until the spell ends, the magic bonfire fills a 5-foot cube. Any creature in the bonfire's space when you cast the spell must succeed on a Dexterity saving throw or take {@damage 1d8} fire damage. A creature must also make the saving throw when it moves into the bonfire's space for the first time on a turn or ends its turn there.", + "The bonfire ignites flammable objects in its area that aren't being worn or carried.", + "The spell's damage increases by {@damage 1d8} when you reach 5th level ({@damage 2d8}), 11th level ({@damage 3d8}), and 17th level ({@damage 4d8})."] + } + }, + { + "name": "Dancing Lights", + "school": "evocation", + "level": 0, + "ritual": false, + "casting_time": { + "amount": 1, + "type": "action" + }, + "range": { + "type": "point", + "amount": 120, + "unit": "feet" + }, + "components": { + "verbal": true, + "somatic": true, + "material": true, + "materials_needed": "a bit of phosphorus or wychwood, or a glowworm" + }, + "durations": [ + { + "type": "concentration", + "amount": 1, + "unit": "minute" + } + ], + "classes": ["artificer", "bard", "sorcerer", "wizard"], + "sources": [ + { "source": "PHB", "page": 230 }, + { "source": "SRD", "page": 133 } + ], + "description": { + "entries": [ + "You create up to four torch-amountd lights within range, making them appear as torches, lanterns, or glowing orbs that hover in the air for the duration. You can also combine the four lights into one glowing vaguely humanoid form of Medium amount. Whichever form you choose, each light sheds dim light in a 10-foot radius.", + "As a bonus action on your turn, you can move the lights up to 60 feet to a new spot within range. A light must be within 20 feet of another light created by this spell, and a light winks out if it exceeds the spell's range."] + } } ] \ No newline at end of file diff --git a/migrations/000007_create_spells/up.sql b/migrations/000007_create_spells/up.sql index ee9c5d7..9e968e7 100644 --- a/migrations/000007_create_spells/up.sql +++ b/migrations/000007_create_spells/up.sql @@ -22,11 +22,9 @@ CREATE TABLE IF NOT EXISTS spells ( components_materials_needed TEXT, components_materials_cost INTEGER, components_materials_consumed BOOLEAN DEFAULT FALSE, - duration_type TEXT NOT NULL, - duration_amount INTEGER, - duration_unit TEXT, + durations JSONB NOT NULL, classes TEXT[] NOT NULL, - sources TEXT[] NOT NULL, + sources JSONB NOT NULL, tags TEXT[], description JSONB NOT NULL ); \ No newline at end of file diff --git a/src/db/schema.rs b/src/db/schema.rs index f44816b..17a7229 100644 --- a/src/db/schema.rs +++ b/src/db/schema.rs @@ -38,11 +38,9 @@ diesel::table! { components_materials_needed -> Nullable, components_materials_cost -> Nullable, components_materials_consumed -> Nullable, - duration_type -> Text, - duration_amount -> Nullable, - duration_unit -> Nullable, + durations -> Jsonb, classes -> Array, - sources -> Array, + sources -> Jsonb, tags -> Array, description -> Jsonb } diff --git a/src/db/spells/model.rs b/src/db/spells/model.rs index 792b2bb..05ed1fa 100644 --- a/src/db/spells/model.rs +++ b/src/db/spells/model.rs @@ -31,11 +31,9 @@ pub struct QuerySpell { pub components_materials_needed: Option, pub components_materials_cost: Option, pub components_materials_consumed: Option, - pub duration_type: String, - pub duration_amount: Option, - pub duration_unit: Option, + pub durations: serde_json::Value, pub classes: Vec, - pub sources: Vec, + pub sources: serde_json::Value, pub tags: Vec, pub description: serde_json::Value } @@ -97,11 +95,9 @@ pub struct InsertSpell { pub components_materials_needed: Option, pub components_materials_cost: Option, pub components_materials_consumed: Option, - pub duration_type: String, - pub duration_amount: Option, - pub duration_unit: Option, + pub durations: serde_json::Value, pub classes: Vec, - pub sources: Vec, + pub sources: serde_json::Value, pub tags: Vec, pub description: serde_json::Value } @@ -139,7 +135,7 @@ pub struct Spell { #[serde(skip_serializing_if = "Option::is_none")] pub area: Option, pub components: Components, - pub duration: Duration, + pub durations: Vec, pub classes: Vec, pub sources: Vec, #[serde(skip_serializing_if = "Option::is_none")] @@ -436,33 +432,6 @@ pub enum DurationType { Special } -impl DurationType { - pub fn to_string(&self) -> String { - match self { - DurationType::Concentration => "concentration".to_string(), - DurationType::Instantaneous => "instantaneous".to_string(), - DurationType::Timed => "timed".to_string(), - DurationType::UntilDispelled => "dispelled".to_string(), - DurationType::Special => "special".to_string() - } - } -} - -impl FromStr for DurationType { - type Err = (); - - fn from_str(s: &str) -> Result { - match s { - "concentration" => Ok(DurationType::Concentration), - "instantaneous" => Ok(DurationType::Instantaneous), - "timed" => Ok(DurationType::Timed), - "dispelled" => Ok(DurationType::UntilDispelled), - "special" => Ok(DurationType::Special), - _ => Err(()) - } - } -} - #[derive(Debug, Serialize, Deserialize)] pub struct Source { pub source: String, @@ -631,22 +600,21 @@ impl From for Spell { materials_cost: query.components_materials_cost, materials_consumed: query.components_materials_consumed }, - duration: Duration { - duration_type: match DurationType::from_str(&query.duration_type) { - Ok(duration_type) => duration_type, - Err(_) => { - log::error!("Failed to parse spell duration type: {}", query.duration_type); - DurationType::Special - } - }, - amount: query.duration_amount, - unit: query.duration_unit + durations: match serde_json::from_value(query.durations) { + Ok(durations) => durations, + Err(err) => { + log::error!("Failed to parse spell durations: {}", err); + Vec::new() + } }, classes: query.classes, - sources: query.sources.iter().map(|source| Source { - source: source.to_string(), - page: None - }).collect(), + sources: match serde_json::from_value(query.sources) { + Ok(sources) => sources, + Err(err) => { + log::error!("Failed to parse spell sources: {}", err); + Vec::new() + } + }, tags: Some(query.tags), description: match serde_json::from_value(query.description) { Ok(description) => description, @@ -708,9 +676,7 @@ impl Into for Spell { components_materials_needed: self.components.materials_needed, components_materials_cost: self.components.materials_cost, components_materials_consumed: self.components.materials_consumed, - duration_type: self.duration.duration_type.to_string(), - duration_amount: self.duration.amount, - duration_unit: self.duration.unit, + durations: serde_json::to_value(self.durations).unwrap(), classes: self.classes, sources: self.sources.iter().map(|source| match source.page { Some(page) => format!("{} {}", source.source, page), None => source.source.to_string() }).collect(), tags: match self.tags { From 2b7ec386a03747000876849c7ce7c3e49ea2d678 Mon Sep 17 00:00:00 2001 From: Benjamin Sherriff Date: Wed, 4 Oct 2023 10:00:39 -0400 Subject: [PATCH 07/14] Condensed schemas into json, will address later --- Cargo.toml | 1 + data/spells/cantrips.json | 125 ++++- migrations/000007_create_spells/up.sql | 27 +- src/db/classes/model.rs | 6 + src/db/conditions/mod.rs | 15 + src/db/schema.rs | 27 +- src/db/spells/mod.rs | 2 + src/db/spells/model.rs | 624 ++----------------------- src/db/spells/types.rs | 377 +++++++++++++++ 9 files changed, 557 insertions(+), 647 deletions(-) create mode 100644 src/db/spells/types.rs diff --git a/Cargo.toml b/Cargo.toml index fde71d0..03e4fb5 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -19,6 +19,7 @@ env_logger = "0.10.0" diesel_migrations = { version = "2.1.0", features = ["postgres"] } r2d2 = "0.8.10" lazy_static = "1.4.0" +uuid = { version = "1.4.1", features = ["serde", "v4"] } [dependencies.serenity] version = "0.11.6" diff --git a/data/spells/cantrips.json b/data/spells/cantrips.json index 477d409..908160a 100644 --- a/data/spells/cantrips.json +++ b/data/spells/cantrips.json @@ -13,6 +13,13 @@ "amount": 60, "unit": "feet" }, + "saving_throw": [ + "dexterity" + ], + "damage_inflict": [ + "acid" + ], + "attack_type": "ranged", "components": { "verbal": true, "somatic": true, @@ -31,7 +38,8 @@ "description": { "entries": [ "You hurl a bubble of acid. Choose one creature within range, or choose two creatures within range that are within 5 feet of each other. A target must succeed on a Dexterity saving throw or take {@damage 1d6} acid damage.", - "This spell's damage increases by {@damage 1d6} when you reach 5th level ({@damage 2d6}), 11th level ({@damage 3d6}), and 17th level ({@damage 4d6})."] + "This spell's damage increases by {@damage 1d6} when you reach 5th level ({@damage 2d6}), 11th level ({@damage 3d6}), and 17th level ({@damage 4d6})." + ] } }, { @@ -46,6 +54,11 @@ "range": { "type": "self" }, + "damage_resistance": [ + "bludgeoning", + "piercing", + "slashing" + ], "components": { "verbal": true, "somatic": true, @@ -64,7 +77,8 @@ ], "description": { "entries": [ - "You extend your hand and trace a sigil of warding in the air. Until the end of your next turn, you have resistance against bludgeoning, piercing, and slashing damage dealt by weapon attacks."] + "You extend your hand and trace a sigil of warding in the air. Until the end of your next turn, you have resistance against bludgeoning, piercing, and slashing damage dealt by weapon attacks." + ] } }, { @@ -84,6 +98,10 @@ "amount": 5, "unit": "feet" }, + "damage_inflict": [ + "thunder" + ], + "attack_type": "melee", "components": { "verbal": false, "somatic": true, @@ -105,7 +123,8 @@ "description": { "entries": [ "You brandish the weapon used in the spell's casting and make a melee attack with it against one creature within 5 feet of you. On a hit, the target suffers the weapon attack's normal effects and then becomes sheathed in booming energy until the start of your next turn. If the target willingly moves 5 feet or more before then, the target takes 1d8 thunder damage, and the spell ends.", - "This spell's damage increases when you reach certain levels. At 5th level, the melee attack deals an extra {@damage 1d8} thunder damage to the target on a hit, and the damage the target takes for moving increases to {@damage 2d8}. Both damage rolls increase by 1d8 at 11th level ({@damage 2d8} and {@damage 3d8}) and again at 17th level ({@damage 3d8} and {@damage 4d8})."] + "This spell's damage increases when you reach certain levels. At 5th level, the melee attack deals an extra {@damage 1d8} thunder damage to the target on a hit, and the damage the target takes for moving increases to {@damage 2d8}. Both damage rolls increase by 1d8 at 11th level ({@damage 2d8} and {@damage 3d8}) and again at 17th level ({@damage 3d8} and {@damage 4d8})." + ] } }, { @@ -122,6 +141,10 @@ "amount": 120, "unit": "feet" }, + "damage_inflict": [ + "necrotic" + ], + "attack_type": "ranged", "components": { "verbal": true, "somatic": true, @@ -143,7 +166,8 @@ "entries": [ "You create a ghostly, skeletal hand in the space of a creature within range. Make a ranged spell attack against the creature to assail it with the chill of the grave. On a hit, the target takes {@damage 1d8} necrotic damage, and it can't regain hit points until the start of your next turn. Until then, the hand clings to the target.", "If you hit an undead target, it also has disadvantage on attack rolls against you until the end of your next turn.", - "This spell's damage increases by {@damage 1d8} when you reach 5th level ({@damage 2d8}), 11th level ({@damage 3d8}), and 17th level ({@damage 4d8})."] + "This spell's damage increases by {@damage 1d8} when you reach 5th level ({@damage 2d8}), 11th level ({@damage 3d8}), and 17th level ({@damage 4d8})." + ] } }, { @@ -197,7 +221,8 @@ "You cause simple shapes—such as the vague form of a creature, an inanimate object, or a location—to appear within the flames and animate as you like. The shapes last for 1 hour." ] }, - "If you cast this spell multiple times, you can have up to three non-instantaneous effects created by it active at a time, and you can dismiss such an effect as an action."] + "If you cast this spell multiple times, you can have up to three non-instantaneous effects created by it active at a time, and you can dismiss such an effect as an action." + ] } }, { @@ -240,7 +265,8 @@ "entries": [ "You create a bonfire on ground that you can see within range. Until the spell ends, the magic bonfire fills a 5-foot cube. Any creature in the bonfire's space when you cast the spell must succeed on a Dexterity saving throw or take {@damage 1d8} fire damage. A creature must also make the saving throw when it moves into the bonfire's space for the first time on a turn or ends its turn there.", "The bonfire ignites flammable objects in its area that aren't being worn or carried.", - "The spell's damage increases by {@damage 1d8} when you reach 5th level ({@damage 2d8}), 11th level ({@damage 3d8}), and 17th level ({@damage 4d8})."] + "The spell's damage increases by {@damage 1d8} when you reach 5th level ({@damage 2d8}), 11th level ({@damage 3d8}), and 17th level ({@damage 4d8})." + ] } }, { @@ -278,7 +304,92 @@ "description": { "entries": [ "You create up to four torch-amountd lights within range, making them appear as torches, lanterns, or glowing orbs that hover in the air for the duration. You can also combine the four lights into one glowing vaguely humanoid form of Medium amount. Whichever form you choose, each light sheds dim light in a 10-foot radius.", - "As a bonus action on your turn, you can move the lights up to 60 feet to a new spot within range. A light must be within 20 feet of another light created by this spell, and a light winks out if it exceeds the spell's range."] + "As a bonus action on your turn, you can move the lights up to 60 feet to a new spot within range. A light must be within 20 feet of another light created by this spell, and a light winks out if it exceeds the spell's range." + ] + } + }, + { + "name": "Druidcraft", + "school": "transmutation", + "level": 0, + "ritual": false, + "casting_time": { + "amount": 1, + "type": "action" + }, + "range": { + "type": "point", + "amount": 30, + "unit": "feet" + }, + "components": { + "verbal": true, + "somatic": true, + "material": false + }, + "durations": [ + { + "type": "instantaneous" + } + ], + "classes": ["druid"], + "sources": [ + { "source": "PHB", "page": 236 }, + { "source": "SRD", "page": 138 } + ], + "description": { + "entries": [ + "Whispering to the spirits of nature, you create one of the following effects within range:", + { + "type": "list", + "items": [ + "You create a tiny, harmless sensory effect that predicts what the weather will be at your location for the next 24 hours. The effect might manifest as a golden orb for clear skies, a cloud for rain, falling snowflakes for snow, and so on. This effect persists for 1 round.", + "You instantly make a flower blossom, a seed pod open, or a leaf bud bloom.", + "You create an instantaneous, harmless sensory effect, such as falling leaves, a puff of wind, the sound of a small animal, or the faint odor of skunk. The effect must fit in a 5-foot cube.", + "You instantly light or snuff out a candle, a torch, or a small campfire." + ] + } + ] + } + }, + { + "name": "Eldritch Blast", + "school": "evocation", + "level": 0, + "ritual": false, + "casting_time": { + "amount": 1, + "type": "action" + }, + "range": { + "type": "point", + "amount": 120, + "unit": "feet" + }, + "damage_inflict": [ + "force" + ], + "attack_type": "ranged", + "components": { + "verbal": true, + "somatic": true, + "material": false + }, + "durations": [ + { + "type": "instantaneous" + } + ], + "classes": ["warlock"], + "sources": [ + { "source": "PHB", "page": 237 }, + { "source": "SRD", "page": 139 } + ], + "description": { + "entries": [ + "A beam of crackling energy streaks toward a creature within range. Make a ranged spell attack against the target. On a hit, the target takes {@damage 1d10} force damage.", + "The spell creates more than one beam when you reach higher levels: two beams at 5th level, three beams at 11th level, and four beams at 17th level. You can direct the beams at the same target or at different ones. Make a separate attack roll for each beam." + ] } } ] \ No newline at end of file diff --git a/migrations/000007_create_spells/up.sql b/migrations/000007_create_spells/up.sql index 9e968e7..8dc9f9e 100644 --- a/migrations/000007_create_spells/up.sql +++ b/migrations/000007_create_spells/up.sql @@ -1,30 +1,5 @@ CREATE TABLE IF NOT EXISTS spells ( id INTEGER GENERATED ALWAYS AS IDENTITY, name TEXT NOT NULL, - school TEXT NOT NULL, - level INTEGER NOT NULL, - ritual BOOLEAN DEFAULT FALSE, - casting_time_amount INTEGER NOT NULL, - casting_time_unit TEXT NOT NULL, - saving_throw TEXT[], - attack_type TEXT, - damage_type TEXT, - conditions TEXT[], - range_type TEXT NOT NULL, - range_amount INTEGER, - range_unit TEXT, - area_type TEXT, - area_amount INTEGER, - area_unit TEXT, - components_verbal BOOLEAN DEFAULT FALSE, - components_somatic BOOLEAN DEFAULT FALSE, - components_material BOOLEAN DEFAULT FALSE, - components_materials_needed TEXT, - components_materials_cost INTEGER, - components_materials_consumed BOOLEAN DEFAULT FALSE, - durations JSONB NOT NULL, - classes TEXT[] NOT NULL, - sources JSONB NOT NULL, - tags TEXT[], - description JSONB NOT NULL + data JSONB NOT NULL ); \ No newline at end of file diff --git a/src/db/classes/model.rs b/src/db/classes/model.rs index c76b89a..4063296 100644 --- a/src/db/classes/model.rs +++ b/src/db/classes/model.rs @@ -4,11 +4,17 @@ use serde::{Serialize, Deserialize}; #[derive(Debug, Serialize, Deserialize)] pub enum AbilityType { + #[serde(rename = "strength")] Strength, + #[serde(rename = "dexterity")] Dexterity, + #[serde(rename = "constitution")] Constitution, + #[serde(rename = "intelligence")] Intelligence, + #[serde(rename = "wisdom")] Wisdom, + #[serde(rename = "charisma")] Charisma } diff --git a/src/db/conditions/mod.rs b/src/db/conditions/mod.rs index ca4e835..055953f 100644 --- a/src/db/conditions/mod.rs +++ b/src/db/conditions/mod.rs @@ -5,20 +5,35 @@ use serde::{Deserialize, Serialize}; #[derive(Debug, Serialize, Deserialize)] pub enum ConditionType { + #[serde(rename = "blinded")] Blinded, + #[serde(rename = "charmed")] Charmed, + #[serde(rename = "deafened")] Deafened, + #[serde(rename = "exhaustion")] Exhaustion, + #[serde(rename = "frightened")] Frightened, + #[serde(rename = "grappled")] Grappled, + #[serde(rename = "incapacitated")] Incapacitated, + #[serde(rename = "invisible")] Invisible, + #[serde(rename = "paralyzed")] Paralyzed, + #[serde(rename = "petrified")] Petrified, + #[serde(rename = "poisoned")] Poisoned, + #[serde(rename = "prone")] Prone, + #[serde(rename = "restrained")] Restrained, + #[serde(rename = "stunned")] Stunned, + #[serde(rename = "unconscious")] Unconscious } diff --git a/src/db/schema.rs b/src/db/schema.rs index 17a7229..016753d 100644 --- a/src/db/schema.rs +++ b/src/db/schema.rs @@ -17,31 +17,6 @@ diesel::table! { spells (id) { id -> Integer, name -> Text, - school -> Text, - level -> Integer, - ritual -> Bool, - casting_time_amount -> Integer, - casting_time_unit -> Text, - saving_throw -> Nullable>, - attack_type -> Nullable, - damage_type -> Nullable, - conditions -> Nullable>, - range_type -> Text, - range_amount -> Nullable, - range_unit -> Nullable, - area_type -> Nullable, - area_amount -> Nullable, - area_unit -> Nullable, - components_verbal -> Bool, - components_somatic -> Bool, - components_material -> Bool, - components_materials_needed -> Nullable, - components_materials_cost -> Nullable, - components_materials_consumed -> Nullable, - durations -> Jsonb, - classes -> Array, - sources -> Jsonb, - tags -> Array, - description -> Jsonb + data -> Jsonb } } \ No newline at end of file diff --git a/src/db/spells/mod.rs b/src/db/spells/mod.rs index 6dfedea..29806d9 100644 --- a/src/db/spells/mod.rs +++ b/src/db/spells/mod.rs @@ -1,7 +1,9 @@ mod model; mod routes; +mod types; pub use model::*; +pub use types::*; pub use routes::init_routes; pub fn load_data() { diff --git a/src/db/spells/model.rs b/src/db/spells/model.rs index 05ed1fa..aa3cf2a 100644 --- a/src/db/spells/model.rs +++ b/src/db/spells/model.rs @@ -1,41 +1,16 @@ -use std::str::FromStr; - use diesel::prelude::*; -use serde::{Deserialize, Serialize, ser::SerializeMap}; +use serde::{Deserialize, Serialize}; -use crate::{db::{schema::spells, classes::AbilityType, conditions::ConditionType}, error_handler::ServiceError}; +use crate::{db::{schema::spells::{self}, classes::AbilityType, conditions::ConditionType}, error_handler::ServiceError}; + +use super::{SchoolType, CastingTime, CastingType, SpellAttackType, SpellDamageType, Range, Area, Components, Duration, Source, Description}; #[derive(Queryable, QueryableByName)] #[diesel(table_name = spells)] pub struct QuerySpell { pub id: i32, pub name: String, - pub school: String, - pub level: i32, - pub ritual: bool, - pub casting_time_amount: i32, - pub casting_time_unit: String, - pub saving_throw: Option>, - pub attack_type: Option, - pub damage_type: Option, - pub conditions: Option>, - pub range_type: String, - pub range_amount: Option, - pub range_unit: Option, - pub area_type: Option, - pub area_amount: Option, - pub area_unit: Option, - pub components_verbal: bool, - pub components_somatic: bool, - pub components_material: bool, - pub components_materials_needed: Option, - pub components_materials_cost: Option, - pub components_materials_consumed: Option, - pub durations: serde_json::Value, - pub classes: Vec, - pub sources: serde_json::Value, - pub tags: Vec, - pub description: serde_json::Value + pub data: serde_json::Value, } impl QuerySpell { @@ -74,32 +49,7 @@ impl QuerySpell { #[diesel(table_name = spells)] pub struct InsertSpell { pub name: String, - pub school: String, - pub level: i32, - pub ritual: bool, - pub casting_time_amount: i32, - pub casting_time_unit: String, - pub saving_throw: Option>, - pub attack_type: Option, - pub damage_type: Option, - pub conditions: Option>, - pub range_type: String, - pub range_amount: Option, - pub range_unit: Option, - pub area_type: Option, - pub area_amount: Option, - pub area_unit: Option, - pub components_verbal: bool, - pub components_somatic: bool, - pub components_material: bool, - pub components_materials_needed: Option, - pub components_materials_cost: Option, - pub components_materials_consumed: Option, - pub durations: serde_json::Value, - pub classes: Vec, - pub sources: serde_json::Value, - pub tags: Vec, - pub description: serde_json::Value + pub data: serde_json::Value } impl InsertSpell { @@ -128,7 +78,9 @@ pub struct Spell { #[serde(skip_serializing_if = "Option::is_none")] pub attack_type: Option, #[serde(skip_serializing_if = "Option::is_none")] - pub damage_type: Option, + pub damage_inflict: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub damage_resist: Option>, #[serde(skip_serializing_if = "Option::is_none")] pub conditions: Option>, pub range: Range, @@ -144,483 +96,31 @@ pub struct Spell { pub description: Option } -#[derive(Debug, Serialize, Deserialize)] -pub enum SchoolType { - #[serde(rename = "abjuration")] - Abjuration, - #[serde(rename = "conjuration")] - Conjuration, - #[serde(rename = "divination")] - Divination, - #[serde(rename = "enchantment")] - Enchantment, - #[serde(rename = "evocation")] - Evocation, - #[serde(rename = "illusion")] - Illusion, - #[serde(rename = "necromancy")] - Necromancy, - #[serde(rename = "transmutation")] - Transmutation -} - -impl SchoolType { - pub fn to_string(&self) -> String { - match self { - SchoolType::Abjuration => "abjuration".to_string(), - SchoolType::Conjuration => "conjuration".to_string(), - SchoolType::Divination => "divination".to_string(), - SchoolType::Enchantment => "enchantment".to_string(), - SchoolType::Evocation => "evocation".to_string(), - SchoolType::Illusion => "illusion".to_string(), - SchoolType::Necromancy => "necromancy".to_string(), - SchoolType::Transmutation => "transmutation".to_string() - } - } -} - -impl FromStr for SchoolType { - type Err = (); - - fn from_str(s: &str) -> Result { - match s { - "abjuration" => Ok(SchoolType::Abjuration), - "conjuration" => Ok(SchoolType::Conjuration), - "divination" => Ok(SchoolType::Divination), - "enchantment" => Ok(SchoolType::Enchantment), - "evocation" => Ok(SchoolType::Evocation), - "illusion" => Ok(SchoolType::Illusion), - "necromancy" => Ok(SchoolType::Necromancy), - "transmutation" => Ok(SchoolType::Transmutation), - _ => Err(()) - } - } -} - -#[derive(Debug, Serialize, Deserialize)] -pub struct CastingTime { - pub amount: i32, - #[serde(rename = "type")] - pub casting_type: CastingType -} - -#[derive(Debug, Serialize, Deserialize)] -pub enum CastingType { - #[serde(rename = "action")] - Action, - #[serde(rename = "bonus")] - BonusAction, - #[serde(rename = "reaction")] - Reaction, - #[serde(rename = "minutes")] - Minutes, - #[serde(rename = "hours")] - Hours -} - -impl CastingType { - pub fn to_string(&self) -> String { - match self { - CastingType::Action => "action".to_string(), - CastingType::BonusAction => "bonus".to_string(), - CastingType::Reaction => "reaction".to_string(), - CastingType::Minutes => "minutes".to_string(), - CastingType::Hours => "hours".to_string() - } - } -} - -impl FromStr for CastingType { - type Err = (); - - fn from_str(s: &str) -> Result { - match s { - "action" => Ok(CastingType::Action), - "bonus" => Ok(CastingType::BonusAction), - "reaction" => Ok(CastingType::Reaction), - "minutes" => Ok(CastingType::Minutes), - "hours" => Ok(CastingType::Hours), - _ => Err(()) - } - } -} - -#[derive(Debug, Serialize, Deserialize)] -pub enum SpellAttackType { - #[serde(rename = "melee")] - Melee, - #[serde(rename = "ranged")] - Ranged, -} - -impl SpellAttackType { - pub fn to_string(&self) -> String { - match self { - SpellAttackType::Melee => "melee".to_string(), - SpellAttackType::Ranged => "ranged".to_string() - } - } -} - -impl FromStr for SpellAttackType { - type Err = (); - - fn from_str(s: &str) -> Result { - match s { - "melee" => Ok(SpellAttackType::Melee), - "ranged" => Ok(SpellAttackType::Ranged), - _ => Err(()) - } - } -} - -#[derive(Debug, Serialize, Deserialize)] -pub enum SpellDamageType { - #[serde(rename = "acid")] - Acid, - #[serde(rename = "bludgeoning")] - Bludgeoning, - #[serde(rename = "cold")] - Cold, - #[serde(rename = "fire")] - Fire, - #[serde(rename = "force")] - Force, - #[serde(rename = "lightning")] - Lightning, - #[serde(rename = "necrotic")] - Necrotic, - #[serde(rename = "piercing")] - Piercing, - #[serde(rename = "poison")] - Poison, - #[serde(rename = "psychic")] - Psychic, - #[serde(rename = "radiant")] - Radiant, - #[serde(rename = "slashing")] - Slashing, - #[serde(rename = "thunder")] - Thunder -} - -impl SpellDamageType { - pub fn to_string(&self) -> String { - match self { - SpellDamageType::Acid => "acid".to_string(), - SpellDamageType::Bludgeoning => "bludgeoning".to_string(), - SpellDamageType::Cold => "cold".to_string(), - SpellDamageType::Fire => "fire".to_string(), - SpellDamageType::Force => "force".to_string(), - SpellDamageType::Lightning => "lightning".to_string(), - SpellDamageType::Necrotic => "necrotic".to_string(), - SpellDamageType::Piercing => "piercing".to_string(), - SpellDamageType::Poison => "poison".to_string(), - SpellDamageType::Psychic => "psychic".to_string(), - SpellDamageType::Radiant => "radiant".to_string(), - SpellDamageType::Slashing => "slashing".to_string(), - SpellDamageType::Thunder => "thunder".to_string() - } - } -} - -impl FromStr for SpellDamageType { - type Err = (); - - fn from_str(s: &str) -> Result { - match s { - "acid" => Ok(SpellDamageType::Acid), - "bludgeoning" => Ok(SpellDamageType::Bludgeoning), - "cold" => Ok(SpellDamageType::Cold), - "fire" => Ok(SpellDamageType::Fire), - "force" => Ok(SpellDamageType::Force), - "lightning" => Ok(SpellDamageType::Lightning), - "necrotic" => Ok(SpellDamageType::Necrotic), - "piercing" => Ok(SpellDamageType::Piercing), - "poison" => Ok(SpellDamageType::Poison), - "psychic" => Ok(SpellDamageType::Psychic), - "radiant" => Ok(SpellDamageType::Radiant), - "slashing" => Ok(SpellDamageType::Slashing), - "thunder" => Ok(SpellDamageType::Thunder), - _ => Err(()) - } - } -} - -#[derive(Debug, Serialize, Deserialize)] -pub struct Range { - #[serde(rename = "type")] - pub range_type: String, - #[serde(skip_serializing_if = "Option::is_none")] - pub amount: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub unit: Option -} - -#[derive(Debug, Serialize, Deserialize)] -pub struct Area { - #[serde(rename = "type")] - pub area_type: AreaType, - #[serde(skip_serializing_if = "Option::is_none")] - pub amount: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub unit: Option -} - -#[derive(Debug, Serialize, Deserialize)] -pub enum AreaType { - #[serde(rename = "cone")] - Cone, - #[serde(rename = "cube")] - Cube, - #[serde(rename = "cylinder")] - Cylinder, - #[serde(rename = "line")] - Line, - #[serde(rename = "sphere")] - Sphere -} - -impl AreaType { - pub fn to_string(&self) -> String { - match self { - AreaType::Cone => "cone".to_string(), - AreaType::Cube => "cube".to_string(), - AreaType::Cylinder => "cylinder".to_string(), - AreaType::Line => "line".to_string(), - AreaType::Sphere => "sphere".to_string() - } - } -} - -impl FromStr for AreaType { - type Err = (); - - fn from_str(s: &str) -> Result { - match s { - "cone" => Ok(AreaType::Cone), - "cube" => Ok(AreaType::Cube), - "cylinder" => Ok(AreaType::Cylinder), - "line" => Ok(AreaType::Line), - "sphere" => Ok(AreaType::Sphere), - _ => Err(()) - } - } -} - -#[derive(Debug, Serialize, Deserialize)] -pub struct Duration { - #[serde(rename = "type")] - pub duration_type: DurationType, - #[serde(skip_serializing_if = "Option::is_none")] - pub amount: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub unit: Option -} - -#[derive(Debug, Serialize, Deserialize)] -pub enum DurationType { - #[serde(rename = "concentration")] - Concentration, - #[serde(rename = "instantaneous")] - Instantaneous, - #[serde(rename = "timed")] - Timed, - #[serde(rename = "dispelled")] - UntilDispelled, - #[serde(rename = "special")] - Special -} - -#[derive(Debug, Serialize, Deserialize)] -pub struct Source { - pub source: String, - #[serde(skip_serializing_if = "Option::is_none")] - pub page: Option -} - -#[derive(Debug, Serialize, Deserialize)] -pub struct Description { - pub entries: Vec -} - -#[derive(Debug)] -pub struct Entry { - pub entry_type: String, - pub items: Vec -} - -impl<'de> Deserialize<'de> for Entry { - fn deserialize(deserializer: D) -> Result where D: serde::Deserializer<'de> { - let value = serde_json::Value::deserialize(deserializer)?; - match value { - serde_json::Value::String(s) => Ok(Entry { - entry_type: "string".to_string(), - items: vec![s] - }), - serde_json::Value::Object(o) => { - let entry_type = match o.get("type") { - Some(t) => match t.as_str() { - Some(s) => s.to_string(), - None => return Err(serde::de::Error::custom("Invalid entry type")) - }, - None => return Err(serde::de::Error::custom("Missing entry type")) - }; - let items = match o.get("items") { - Some(i) => match i.as_array() { - Some(a) => { - let mut items = Vec::new(); - for item in a { - match item.as_str() { - Some(s) => items.push(s.to_string()), - None => return Err(serde::de::Error::custom("Invalid entry item")) - } - } - items - }, - None => return Err(serde::de::Error::custom("Invalid entry items")) - }, - None => return Err(serde::de::Error::custom("Missing entry items")) - }; - Ok(Entry { - entry_type, - items - }) - }, - _ => Err(serde::de::Error::custom("Invalid entry")) - } - } -} - -impl Serialize for Entry { - fn serialize(&self, serializer: S) -> Result where S: serde::Serializer { - match self.entry_type.as_str() { - "string" => serializer.serialize_str(&self.items[0]), - _ => { - let mut map = serializer.serialize_map(Some(2))?; - map.serialize_entry("type", &self.entry_type)?; - map.serialize_entry("items", &self.items)?; - map.end() - } - } - } -} - -#[derive(Debug, Serialize, Deserialize)] -pub struct Components { - pub verbal: bool, - pub somatic: bool, - pub material: bool, - #[serde(skip_serializing_if = "Option::is_none")] - pub materials_needed: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub materials_cost: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub materials_consumed: Option -} - impl From for Spell { fn from(query: QuerySpell) -> Self { - return Self { - name: query.name, - school: match SchoolType::from_str(&query.school) { - Ok(school_type) => school_type, - Err(_) => { - log::error!("Failed to parse spell school type: {}", query.school); - SchoolType::Abjuration - } - }, - level: query.level, - ritual: query.ritual, - casting_time: CastingTime { - amount: query.casting_time_amount, - casting_type: match CastingType::from_str(&query.casting_time_unit) { - Ok(casting_type) => casting_type, - Err(_) => { - log::error!("Failed to parse spell casting type: {}", query.casting_time_unit); - CastingType::Action - } - } - }, - saving_throw: query.saving_throw.map(|saving_throw| saving_throw.iter().map(|ability_type| match AbilityType::from_str(&ability_type) { - Ok(ability_type) => ability_type, - Err(_) => { - log::error!("Failed to parse spell saving throw: {}", ability_type); - AbilityType::Strength - } - }).collect()), - attack_type: match query.attack_type { - Some(attack_type) => match SpellAttackType::from_str(&attack_type) { - Ok(attack_type) => Some(attack_type), - Err(_) => { - log::error!("Failed to parse spell attack type: {}", attack_type); - None - } - }, - None => None - }, - damage_type: query.damage_type.map(|damage_type| match SpellDamageType::from_str(&damage_type) { - Ok(damage_type) => damage_type, - Err(_) => { - log::error!("Failed to parse spell damage type: {}", damage_type); - SpellDamageType::Acid - } - }), - conditions: query.conditions.map(|conditions| conditions.iter().map(|condition_type| match ConditionType::from_str(&condition_type) { - Ok(condition_type) => condition_type, - Err(_) => { - log::error!("Failed to parse spell condition type: {}", condition_type); - ConditionType::Blinded - } - }).collect()), - range: Range { - range_type: query.range_type, - amount: query.range_amount, - unit: query.range_unit - }, - area: match query.area_type { - Some(area_type) => Some(Area { - area_type: match AreaType::from_str(&area_type) { - Ok(area_type) => area_type, - Err(_) => { - log::error!("Failed to parse spell area type: {}", area_type); - AreaType::Cone - } - }, - amount: query.area_amount, - unit: query.area_unit - }), - None => None - }, - components: Components { - verbal: query.components_verbal, - somatic: query.components_somatic, - material: query.components_material, - materials_needed: query.components_materials_needed, - materials_cost: query.components_materials_cost, - materials_consumed: query.components_materials_consumed - }, - durations: match serde_json::from_value(query.durations) { - Ok(durations) => durations, - Err(err) => { - log::error!("Failed to parse spell durations: {}", err); - Vec::new() - } - }, - classes: query.classes, - sources: match serde_json::from_value(query.sources) { - Ok(sources) => sources, - Err(err) => { - log::error!("Failed to parse spell sources: {}", err); - Vec::new() - } - }, - tags: Some(query.tags), - description: match serde_json::from_value(query.description) { - Ok(description) => description, - Err(err) => { - log::error!("Failed to parse spell description: {}", err); - None + return match serde_json::from_value(query.data) { + Ok(data) => data, + Err(err) => { + log::error!("Failed to parse spell: {}", err); + Self { + name: "".to_string(), + school: SchoolType::Abjuration, + level: 0, + ritual: false, + casting_time: CastingTime { amount: 0, casting_type: CastingType::Action }, + saving_throw: None, + attack_type: None, + damage_inflict: None, + damage_resist: None, + conditions: None, + range: Range { range_type: "".to_string(), amount: None, unit: None }, + area: None, + components: Components { verbal: false, somatic: false, material: false, materials_needed: None, materials_cost: None, materials_consumed: None }, + durations: vec![], + classes: vec![], + sources: vec![], + tags: None, + description: None, } } } @@ -630,66 +130,14 @@ impl From for Spell { impl Into for Spell { fn into(self) -> InsertSpell { return InsertSpell { - name: self.name, - school: self.school.to_string(), - level: self.level, - ritual: self.ritual, - casting_time_amount: self.casting_time.amount, - casting_time_unit: self.casting_time.casting_type.to_string(), - saving_throw: match self.saving_throw { - Some(saving_throw) => Some(saving_throw.iter().map(|ability_type| ability_type.to_string()).collect()), - None => None - }, - attack_type: match self.attack_type { - Some(attack_type) => Some(attack_type.to_string()), - None => None - }, - damage_type: match self.damage_type { - Some(damage_type) => Some(damage_type.to_string()), - None => None - }, - conditions: match self.conditions { - Some(conditions) => Some(conditions.iter().map(|condition_type| condition_type.to_string()).collect()), - None => None - }, - range_type: self.range.range_type.to_string(), - range_amount: self.range.amount, - range_unit: self.range.unit, - area_type: match &self.area { - Some(area) => Some(area.area_type.to_string()), - None => None - }, - area_amount: match &self.area { - Some(area) => area.amount, - None => None - }, - area_unit: match &self.area { - Some(area) => match &area.unit { - Some(unit) => Some(unit.to_string()), - None => None - }, - None => None - }, - components_verbal: self.components.verbal, - components_somatic: self.components.somatic, - components_material: self.components.material, - components_materials_needed: self.components.materials_needed, - components_materials_cost: self.components.materials_cost, - components_materials_consumed: self.components.materials_consumed, - durations: serde_json::to_value(self.durations).unwrap(), - classes: self.classes, - sources: self.sources.iter().map(|source| match source.page { Some(page) => format!("{} {}", source.source, page), None => source.source.to_string() }).collect(), - tags: match self.tags { - Some(tags) => tags, - None => Vec::new() - }, - description: match serde_json::to_value(self.description) { - Ok(description) => description, + name: self.name.to_string(), + data: match serde_json::to_value(&self) { + Ok(data) => data, Err(err) => { log::error!("Failed to serialize spell description: {}", err); serde_json::Value::Null } - } + }, } } } diff --git a/src/db/spells/types.rs b/src/db/spells/types.rs new file mode 100644 index 0000000..1b23398 --- /dev/null +++ b/src/db/spells/types.rs @@ -0,0 +1,377 @@ +use std::str::FromStr; +use serde::{Deserialize, Serialize, ser::SerializeMap}; + +#[derive(Debug, Serialize, Deserialize)] +pub enum SchoolType { + #[serde(rename = "abjuration")] + Abjuration, + #[serde(rename = "conjuration")] + Conjuration, + #[serde(rename = "divination")] + Divination, + #[serde(rename = "enchantment")] + Enchantment, + #[serde(rename = "evocation")] + Evocation, + #[serde(rename = "illusion")] + Illusion, + #[serde(rename = "necromancy")] + Necromancy, + #[serde(rename = "transmutation")] + Transmutation +} + +impl SchoolType { + pub fn to_string(&self) -> String { + match self { + SchoolType::Abjuration => "abjuration".to_string(), + SchoolType::Conjuration => "conjuration".to_string(), + SchoolType::Divination => "divination".to_string(), + SchoolType::Enchantment => "enchantment".to_string(), + SchoolType::Evocation => "evocation".to_string(), + SchoolType::Illusion => "illusion".to_string(), + SchoolType::Necromancy => "necromancy".to_string(), + SchoolType::Transmutation => "transmutation".to_string() + } + } +} + +impl FromStr for SchoolType { + type Err = (); + + fn from_str(s: &str) -> Result { + match s { + "abjuration" => Ok(SchoolType::Abjuration), + "conjuration" => Ok(SchoolType::Conjuration), + "divination" => Ok(SchoolType::Divination), + "enchantment" => Ok(SchoolType::Enchantment), + "evocation" => Ok(SchoolType::Evocation), + "illusion" => Ok(SchoolType::Illusion), + "necromancy" => Ok(SchoolType::Necromancy), + "transmutation" => Ok(SchoolType::Transmutation), + _ => Err(()) + } + } +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct CastingTime { + pub amount: i32, + #[serde(rename = "type")] + pub casting_type: CastingType +} + +#[derive(Debug, Serialize, Deserialize)] +pub enum CastingType { + #[serde(rename = "action")] + Action, + #[serde(rename = "bonus")] + BonusAction, + #[serde(rename = "reaction")] + Reaction, + #[serde(rename = "minutes")] + Minutes, + #[serde(rename = "hours")] + Hours +} + +impl CastingType { + pub fn to_string(&self) -> String { + match self { + CastingType::Action => "action".to_string(), + CastingType::BonusAction => "bonus".to_string(), + CastingType::Reaction => "reaction".to_string(), + CastingType::Minutes => "minutes".to_string(), + CastingType::Hours => "hours".to_string() + } + } +} + +impl FromStr for CastingType { + type Err = (); + + fn from_str(s: &str) -> Result { + match s { + "action" => Ok(CastingType::Action), + "bonus" => Ok(CastingType::BonusAction), + "reaction" => Ok(CastingType::Reaction), + "minutes" => Ok(CastingType::Minutes), + "hours" => Ok(CastingType::Hours), + _ => Err(()) + } + } +} + +#[derive(Debug, Serialize, Deserialize)] +pub enum SpellAttackType { + #[serde(rename = "melee")] + Melee, + #[serde(rename = "ranged")] + Ranged, +} + +impl SpellAttackType { + pub fn to_string(&self) -> String { + match self { + SpellAttackType::Melee => "melee".to_string(), + SpellAttackType::Ranged => "ranged".to_string() + } + } +} + +impl FromStr for SpellAttackType { + type Err = (); + + fn from_str(s: &str) -> Result { + match s { + "melee" => Ok(SpellAttackType::Melee), + "ranged" => Ok(SpellAttackType::Ranged), + _ => Err(()) + } + } +} + +#[derive(Debug, Serialize, Deserialize)] +pub enum SpellDamageType { + #[serde(rename = "acid")] + Acid, + #[serde(rename = "bludgeoning")] + Bludgeoning, + #[serde(rename = "cold")] + Cold, + #[serde(rename = "fire")] + Fire, + #[serde(rename = "force")] + Force, + #[serde(rename = "lightning")] + Lightning, + #[serde(rename = "necrotic")] + Necrotic, + #[serde(rename = "piercing")] + Piercing, + #[serde(rename = "poison")] + Poison, + #[serde(rename = "psychic")] + Psychic, + #[serde(rename = "radiant")] + Radiant, + #[serde(rename = "slashing")] + Slashing, + #[serde(rename = "thunder")] + Thunder +} + +impl SpellDamageType { + pub fn to_string(&self) -> String { + match self { + SpellDamageType::Acid => "acid".to_string(), + SpellDamageType::Bludgeoning => "bludgeoning".to_string(), + SpellDamageType::Cold => "cold".to_string(), + SpellDamageType::Fire => "fire".to_string(), + SpellDamageType::Force => "force".to_string(), + SpellDamageType::Lightning => "lightning".to_string(), + SpellDamageType::Necrotic => "necrotic".to_string(), + SpellDamageType::Piercing => "piercing".to_string(), + SpellDamageType::Poison => "poison".to_string(), + SpellDamageType::Psychic => "psychic".to_string(), + SpellDamageType::Radiant => "radiant".to_string(), + SpellDamageType::Slashing => "slashing".to_string(), + SpellDamageType::Thunder => "thunder".to_string() + } + } +} + +impl FromStr for SpellDamageType { + type Err = (); + + fn from_str(s: &str) -> Result { + match s { + "acid" => Ok(SpellDamageType::Acid), + "bludgeoning" => Ok(SpellDamageType::Bludgeoning), + "cold" => Ok(SpellDamageType::Cold), + "fire" => Ok(SpellDamageType::Fire), + "force" => Ok(SpellDamageType::Force), + "lightning" => Ok(SpellDamageType::Lightning), + "necrotic" => Ok(SpellDamageType::Necrotic), + "piercing" => Ok(SpellDamageType::Piercing), + "poison" => Ok(SpellDamageType::Poison), + "psychic" => Ok(SpellDamageType::Psychic), + "radiant" => Ok(SpellDamageType::Radiant), + "slashing" => Ok(SpellDamageType::Slashing), + "thunder" => Ok(SpellDamageType::Thunder), + _ => Err(()) + } + } +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct Range { + #[serde(rename = "type")] + pub range_type: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub amount: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub unit: Option +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct Area { + #[serde(rename = "type")] + pub area_type: AreaType, + #[serde(skip_serializing_if = "Option::is_none")] + pub amount: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub unit: Option +} + +#[derive(Debug, Serialize, Deserialize)] +pub enum AreaType { + #[serde(rename = "cone")] + Cone, + #[serde(rename = "cube")] + Cube, + #[serde(rename = "cylinder")] + Cylinder, + #[serde(rename = "line")] + Line, + #[serde(rename = "sphere")] + Sphere +} + +impl AreaType { + pub fn to_string(&self) -> String { + match self { + AreaType::Cone => "cone".to_string(), + AreaType::Cube => "cube".to_string(), + AreaType::Cylinder => "cylinder".to_string(), + AreaType::Line => "line".to_string(), + AreaType::Sphere => "sphere".to_string() + } + } +} + +impl FromStr for AreaType { + type Err = (); + + fn from_str(s: &str) -> Result { + match s { + "cone" => Ok(AreaType::Cone), + "cube" => Ok(AreaType::Cube), + "cylinder" => Ok(AreaType::Cylinder), + "line" => Ok(AreaType::Line), + "sphere" => Ok(AreaType::Sphere), + _ => Err(()) + } + } +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct Duration { + #[serde(rename = "type")] + pub duration_type: DurationType, + #[serde(skip_serializing_if = "Option::is_none")] + pub amount: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub unit: Option +} + +#[derive(Debug, Serialize, Deserialize)] +pub enum DurationType { + #[serde(rename = "concentration")] + Concentration, + #[serde(rename = "instantaneous")] + Instantaneous, + #[serde(rename = "timed")] + Timed, + #[serde(rename = "dispelled")] + UntilDispelled, + #[serde(rename = "special")] + Special +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct Source { + pub source: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub page: Option +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct Description { + pub entries: Vec +} + +#[derive(Debug)] +pub struct Entry { + pub entry_type: String, + pub items: Vec +} + +impl<'de> Deserialize<'de> for Entry { + fn deserialize(deserializer: D) -> Result where D: serde::Deserializer<'de> { + let value = serde_json::Value::deserialize(deserializer)?; + match value { + serde_json::Value::String(s) => Ok(Entry { + entry_type: "string".to_string(), + items: vec![s] + }), + serde_json::Value::Object(o) => { + let entry_type = match o.get("type") { + Some(t) => match t.as_str() { + Some(s) => s.to_string(), + None => return Err(serde::de::Error::custom("Invalid entry type")) + }, + None => return Err(serde::de::Error::custom("Missing entry type")) + }; + let items = match o.get("items") { + Some(i) => match i.as_array() { + Some(a) => { + let mut items = Vec::new(); + for item in a { + match item.as_str() { + Some(s) => items.push(s.to_string()), + None => return Err(serde::de::Error::custom("Invalid entry item")) + } + } + items + }, + None => return Err(serde::de::Error::custom("Invalid entry items")) + }, + None => return Err(serde::de::Error::custom("Missing entry items")) + }; + Ok(Entry { + entry_type, + items + }) + }, + _ => Err(serde::de::Error::custom("Invalid entry")) + } + } +} + +impl Serialize for Entry { + fn serialize(&self, serializer: S) -> Result where S: serde::Serializer { + match self.entry_type.as_str() { + "string" => serializer.serialize_str(&self.items[0]), + _ => { + let mut map = serializer.serialize_map(Some(2))?; + map.serialize_entry("type", &self.entry_type)?; + map.serialize_entry("items", &self.items)?; + map.end() + } + } + } +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct Components { + pub verbal: bool, + pub somatic: bool, + pub material: bool, + #[serde(skip_serializing_if = "Option::is_none")] + pub materials_needed: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub materials_cost: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub materials_consumed: Option +} \ No newline at end of file From 23d42953f0d6c0c2e6cc4922d887b9181e929368 Mon Sep 17 00:00:00 2001 From: Benjamin Sherriff Date: Wed, 4 Oct 2023 10:55:34 -0400 Subject: [PATCH 08/14] Updated routes to use filters/error handling --- src/db/classes/model.rs | 55 ++++---- src/db/conditions/mod.rs | 91 +++++++------ src/db/spells/mod.rs | 2 +- src/db/spells/model.rs | 52 +++++++- src/db/spells/routes.rs | 58 +++++++-- src/db/spells/types.rs | 274 +++++++++++++++++++-------------------- src/error_handler.rs | 4 +- 7 files changed, 304 insertions(+), 232 deletions(-) diff --git a/src/db/classes/model.rs b/src/db/classes/model.rs index 4063296..9dbece8 100644 --- a/src/db/classes/model.rs +++ b/src/db/classes/model.rs @@ -1,5 +1,4 @@ -use std::str::FromStr; - +// use std::str::FromStr; use serde::{Serialize, Deserialize}; #[derive(Debug, Serialize, Deserialize)] @@ -18,30 +17,30 @@ pub enum AbilityType { Charisma } -impl AbilityType { - pub fn to_string(&self) -> String { - match self { - AbilityType::Strength => "Strength".to_string(), - AbilityType::Dexterity => "Dexterity".to_string(), - AbilityType::Constitution => "Constitution".to_string(), - AbilityType::Intelligence => "Intelligence".to_string(), - AbilityType::Wisdom => "Wisdom".to_string(), - AbilityType::Charisma => "Charisma".to_string() - } - } -} +// impl AbilityType { +// pub fn to_string(&self) -> String { +// match self { +// AbilityType::Strength => "Strength".to_string(), +// AbilityType::Dexterity => "Dexterity".to_string(), +// AbilityType::Constitution => "Constitution".to_string(), +// AbilityType::Intelligence => "Intelligence".to_string(), +// AbilityType::Wisdom => "Wisdom".to_string(), +// AbilityType::Charisma => "Charisma".to_string() +// } +// } +// } -impl FromStr for AbilityType { - type Err = (); - fn from_str(s: &str) -> Result { - match s { - "Strength" => Ok(AbilityType::Strength), - "Dexterity" => Ok(AbilityType::Dexterity), - "Constitution" => Ok(AbilityType::Constitution), - "Intelligence" => Ok(AbilityType::Intelligence), - "Wisdom" => Ok(AbilityType::Wisdom), - "Charisma" => Ok(AbilityType::Charisma), - _ => Err(()) - } - } -} \ No newline at end of file +// impl FromStr for AbilityType { +// type Err = (); +// fn from_str(s: &str) -> Result { +// match s { +// "Strength" => Ok(AbilityType::Strength), +// "Dexterity" => Ok(AbilityType::Dexterity), +// "Constitution" => Ok(AbilityType::Constitution), +// "Intelligence" => Ok(AbilityType::Intelligence), +// "Wisdom" => Ok(AbilityType::Wisdom), +// "Charisma" => Ok(AbilityType::Charisma), +// _ => Err(()) +// } +// } +// } \ No newline at end of file diff --git a/src/db/conditions/mod.rs b/src/db/conditions/mod.rs index 055953f..01a9b2e 100644 --- a/src/db/conditions/mod.rs +++ b/src/db/conditions/mod.rs @@ -1,5 +1,4 @@ -use std::str::FromStr; - +// use std::str::FromStr; use serde::{Deserialize, Serialize}; @@ -37,48 +36,48 @@ pub enum ConditionType { Unconscious } -impl ConditionType { - pub fn to_string(&self) -> String { - match self { - ConditionType::Blinded => "Blinded".to_string(), - ConditionType::Charmed => "Charmed".to_string(), - ConditionType::Deafened => "Deafened".to_string(), - ConditionType::Exhaustion => "Exhaustion".to_string(), - ConditionType::Frightened => "Frightened".to_string(), - ConditionType::Grappled => "Grappled".to_string(), - ConditionType::Incapacitated => "Incapacitated".to_string(), - ConditionType::Invisible => "Invisible".to_string(), - ConditionType::Paralyzed => "Paralyzed".to_string(), - ConditionType::Petrified => "Petrified".to_string(), - ConditionType::Poisoned => "Poisoned".to_string(), - ConditionType::Prone => "Prone".to_string(), - ConditionType::Restrained => "Restrained".to_string(), - ConditionType::Stunned => "Stunned".to_string(), - ConditionType::Unconscious => "Unconscious".to_string() - } - } -} +// impl ConditionType { +// pub fn to_string(&self) -> String { +// match self { +// ConditionType::Blinded => "Blinded".to_string(), +// ConditionType::Charmed => "Charmed".to_string(), +// ConditionType::Deafened => "Deafened".to_string(), +// ConditionType::Exhaustion => "Exhaustion".to_string(), +// ConditionType::Frightened => "Frightened".to_string(), +// ConditionType::Grappled => "Grappled".to_string(), +// ConditionType::Incapacitated => "Incapacitated".to_string(), +// ConditionType::Invisible => "Invisible".to_string(), +// ConditionType::Paralyzed => "Paralyzed".to_string(), +// ConditionType::Petrified => "Petrified".to_string(), +// ConditionType::Poisoned => "Poisoned".to_string(), +// ConditionType::Prone => "Prone".to_string(), +// ConditionType::Restrained => "Restrained".to_string(), +// ConditionType::Stunned => "Stunned".to_string(), +// ConditionType::Unconscious => "Unconscious".to_string() +// } +// } +// } -impl FromStr for ConditionType { - type Err = (); - fn from_str(s: &str) -> Result { - match s { - "Blinded" => Ok(ConditionType::Blinded), - "Charmed" => Ok(ConditionType::Charmed), - "Deafened" => Ok(ConditionType::Deafened), - "Exhaustion" => Ok(ConditionType::Exhaustion), - "Frightened" => Ok(ConditionType::Frightened), - "Grappled" => Ok(ConditionType::Grappled), - "Incapacitated" => Ok(ConditionType::Incapacitated), - "Invisible" => Ok(ConditionType::Invisible), - "Paralyzed" => Ok(ConditionType::Paralyzed), - "Petrified" => Ok(ConditionType::Petrified), - "Poisoned" => Ok(ConditionType::Poisoned), - "Prone" => Ok(ConditionType::Prone), - "Restrained" => Ok(ConditionType::Restrained), - "Stunned" => Ok(ConditionType::Stunned), - "Unconscious" => Ok(ConditionType::Unconscious), - _ => Err(()) - } - } -} +// impl FromStr for ConditionType { +// type Err = (); +// fn from_str(s: &str) -> Result { +// match s { +// "Blinded" => Ok(ConditionType::Blinded), +// "Charmed" => Ok(ConditionType::Charmed), +// "Deafened" => Ok(ConditionType::Deafened), +// "Exhaustion" => Ok(ConditionType::Exhaustion), +// "Frightened" => Ok(ConditionType::Frightened), +// "Grappled" => Ok(ConditionType::Grappled), +// "Incapacitated" => Ok(ConditionType::Incapacitated), +// "Invisible" => Ok(ConditionType::Invisible), +// "Paralyzed" => Ok(ConditionType::Paralyzed), +// "Petrified" => Ok(ConditionType::Petrified), +// "Poisoned" => Ok(ConditionType::Poisoned), +// "Prone" => Ok(ConditionType::Prone), +// "Restrained" => Ok(ConditionType::Restrained), +// "Stunned" => Ok(ConditionType::Stunned), +// "Unconscious" => Ok(ConditionType::Unconscious), +// _ => Err(()) +// } +// } +// } diff --git a/src/db/spells/mod.rs b/src/db/spells/mod.rs index 29806d9..27ba8cf 100644 --- a/src/db/spells/mod.rs +++ b/src/db/spells/mod.rs @@ -32,7 +32,7 @@ pub fn load_data() { Err(err) => log::error!("Failed to read from {}: {}", file, err) }; } - let count = QuerySpell::get_count().unwrap(); + let count = QuerySpell::get_count(&QueryFilters::default()).unwrap(); if count >= spells.len() as i64 { log::warn!("Spell data is already loaded"); return; diff --git a/src/db/spells/model.rs b/src/db/spells/model.rs index aa3cf2a..f41ac6f 100644 --- a/src/db/spells/model.rs +++ b/src/db/spells/model.rs @@ -13,13 +13,48 @@ pub struct QuerySpell { pub data: serde_json::Value, } +#[derive(Debug)] +pub struct QueryFilters { + pub by_name: Option, + pub by_schools: Option>, + pub by_levels: Option>, + pub by_ritual: Option, + pub by_concentration: Option, + pub by_classes: Option>, + pub by_damage_inflict: Option>, + pub by_damage_resist: Option>, + pub by_conditions: Option>, + pub by_saving_throw: Option>, + pub by_attack_type: Option, +} + +impl Default for QueryFilters { + fn default() -> Self { + Self { + by_name: None, + by_schools: None, + by_levels: None, + by_ritual: None, + by_concentration: None, + by_classes: None, + by_damage_inflict: None, + by_damage_resist: None, + by_conditions: None, + by_saving_throw: None, + by_attack_type: None, + } + } +} + impl QuerySpell { - pub fn get_all(limit: i32, page: i32) -> Result, ServiceError> { + pub fn get_all(filters: &QueryFilters, limit: i32, page: i32) -> Result, ServiceError> { let mut conn = crate::db::connection()?; - let mut query = spells::table - .limit(limit as i64) - .into_boxed(); + let mut query = spells::table.limit(limit as i64).into_boxed(); query = query.filter(spells::id.gt(std::cmp::max(0, page - 1) * limit)); + if let Some(name) = filters.by_name.to_owned() { + query = query.filter(spells::name.ilike(format!("%{}%", name))); + } + let spells = query.load::(&mut conn)?; Ok(spells) } @@ -32,9 +67,14 @@ impl QuerySpell { Ok(spell) } - pub fn get_count() -> Result { + pub fn get_count(filters: &QueryFilters) -> Result { let mut conn = crate::db::connection()?; - let count = spells::table.count().get_result(&mut conn)?; + let mut query = spells::table.count().into_boxed(); + if let Some(name) = filters.by_name.to_owned() { + query = query.filter(spells::name.ilike(format!("%{}%", name))); + } + + let count = query.get_result(&mut conn)?; Ok(count) } diff --git a/src/db/spells/routes.rs b/src/db/spells/routes.rs index ea48000..1671b4b 100644 --- a/src/db/spells/routes.rs +++ b/src/db/spells/routes.rs @@ -1,28 +1,41 @@ use actix_web::{get, post, put, delete, web, HttpResponse, HttpRequest, ResponseError}; use serde::{Serialize, Deserialize}; -use crate::db::{spells::QuerySpell, GetResponse, Metadata}; +use crate::{db::{spells::{QuerySpell, QueryFilters}, GetResponse, Metadata}, error_handler::ServiceError}; use super::{Spell, InsertSpell}; #[derive(Serialize, Deserialize)] struct GetAllParams { + name: Option, limit: Option, page: Option, } #[get("/spells")] async fn get_all(req: HttpRequest) -> HttpResponse { - let params = web::Query::::from_query(req.query_string()).unwrap(); - let limit = params.limit.unwrap_or(20); - let page = params.page.unwrap_or(1); - match web::block(move || QuerySpell::get_all(limit, page)).await.unwrap() { + let params = match web::Query::::from_query(req.query_string()) { + Ok(params) => params, + Err(err) => return ResponseError::error_response(&ServiceError { + status: 422, + message: err.to_string() + }) + }; + let mut filters = QueryFilters::default(); + filters.by_name = params.name.clone(); + // Limit must be between 1 and 100 + let limit = std::cmp::min(std::cmp::max(params.limit.unwrap_or(20), 1), 100); + let total_count = QuerySpell::get_count(&filters).unwrap(); + let max_page = std::cmp::max(1, (total_count as f64 / limit as f64).ceil() as i32); + // Page must be between 1 and max_page + let page = std::cmp::min(std::cmp::max(params.page.unwrap_or(1), 1), max_page); + + match web::block(move || QuerySpell::get_all(&filters, limit, page)).await.unwrap() { Ok(spells) => { let mut response: Vec = Vec::new(); for spell in spells { response.push(Spell::from(spell)); } - let total_count = QuerySpell::get_count().unwrap(); HttpResponse::Ok().json(GetResponse { data: response, metadata: Some(Metadata { @@ -37,8 +50,15 @@ async fn get_all(req: HttpRequest) -> HttpResponse { } #[get("/spells/{id}")] -async fn get_by_id(id: web::Path) -> HttpResponse { - match web::block(move || QuerySpell::get_by_id(id.into_inner())).await.unwrap() { +async fn get_by_id(id: web::Path) -> HttpResponse { + let id = match id.parse::() { + Ok(id) => id, + Err(err) => return ResponseError::error_response(&ServiceError { + status: 422, + message: err.to_string() + }) + }; + match web::block(move || QuerySpell::get_by_id(id)).await.unwrap() { Ok(spell) => HttpResponse::Ok().json(GetResponse { data: Spell::from(spell), metadata: None @@ -56,16 +76,30 @@ async fn create(spell: web::Json) -> HttpResponse { } #[put("/spells/{id}")] -async fn update(id: web::Path, spell: web::Json) -> HttpResponse { - match web::block(move || InsertSpell::update(id.into_inner(), spell.into_inner().into())).await.unwrap() { +async fn update(id: web::Path, spell: web::Json) -> HttpResponse { + let id = match id.parse::() { + Ok(id) => id, + Err(err) => return ResponseError::error_response(&ServiceError { + status: 422, + message: err.to_string() + }) + }; + match web::block(move || InsertSpell::update(id, spell.into_inner().into())).await.unwrap() { Ok(spell) => HttpResponse::Ok().json(Spell::from(spell)), Err(err) => ResponseError::error_response(&err) } } #[delete("/spells/{id}")] -async fn delete(id: web::Path) -> HttpResponse { - match web::block(move || QuerySpell::delete(id.into_inner())).await.unwrap() { +async fn delete(id: web::Path) -> HttpResponse { + let id = match id.parse::() { + Ok(id) => id, + Err(err) => return ResponseError::error_response(&ServiceError { + status: 422, + message: err.to_string() + }) + }; + match web::block(move || QuerySpell::delete(id)).await.unwrap() { Ok(spell) => HttpResponse::Ok().json(Spell::from(spell)), Err(err) => ResponseError::error_response(&err) } diff --git a/src/db/spells/types.rs b/src/db/spells/types.rs index 1b23398..bf1cde7 100644 --- a/src/db/spells/types.rs +++ b/src/db/spells/types.rs @@ -1,4 +1,4 @@ -use std::str::FromStr; +// use std::str::FromStr; use serde::{Deserialize, Serialize, ser::SerializeMap}; #[derive(Debug, Serialize, Deserialize)] @@ -21,38 +21,38 @@ pub enum SchoolType { Transmutation } -impl SchoolType { - pub fn to_string(&self) -> String { - match self { - SchoolType::Abjuration => "abjuration".to_string(), - SchoolType::Conjuration => "conjuration".to_string(), - SchoolType::Divination => "divination".to_string(), - SchoolType::Enchantment => "enchantment".to_string(), - SchoolType::Evocation => "evocation".to_string(), - SchoolType::Illusion => "illusion".to_string(), - SchoolType::Necromancy => "necromancy".to_string(), - SchoolType::Transmutation => "transmutation".to_string() - } - } -} +// impl SchoolType { +// pub fn to_string(&self) -> String { +// match self { +// SchoolType::Abjuration => "abjuration".to_string(), +// SchoolType::Conjuration => "conjuration".to_string(), +// SchoolType::Divination => "divination".to_string(), +// SchoolType::Enchantment => "enchantment".to_string(), +// SchoolType::Evocation => "evocation".to_string(), +// SchoolType::Illusion => "illusion".to_string(), +// SchoolType::Necromancy => "necromancy".to_string(), +// SchoolType::Transmutation => "transmutation".to_string() +// } +// } +// } -impl FromStr for SchoolType { - type Err = (); +// impl FromStr for SchoolType { +// type Err = (); - fn from_str(s: &str) -> Result { - match s { - "abjuration" => Ok(SchoolType::Abjuration), - "conjuration" => Ok(SchoolType::Conjuration), - "divination" => Ok(SchoolType::Divination), - "enchantment" => Ok(SchoolType::Enchantment), - "evocation" => Ok(SchoolType::Evocation), - "illusion" => Ok(SchoolType::Illusion), - "necromancy" => Ok(SchoolType::Necromancy), - "transmutation" => Ok(SchoolType::Transmutation), - _ => Err(()) - } - } -} +// fn from_str(s: &str) -> Result { +// match s { +// "abjuration" => Ok(SchoolType::Abjuration), +// "conjuration" => Ok(SchoolType::Conjuration), +// "divination" => Ok(SchoolType::Divination), +// "enchantment" => Ok(SchoolType::Enchantment), +// "evocation" => Ok(SchoolType::Evocation), +// "illusion" => Ok(SchoolType::Illusion), +// "necromancy" => Ok(SchoolType::Necromancy), +// "transmutation" => Ok(SchoolType::Transmutation), +// _ => Err(()) +// } +// } +// } #[derive(Debug, Serialize, Deserialize)] pub struct CastingTime { @@ -75,32 +75,32 @@ pub enum CastingType { Hours } -impl CastingType { - pub fn to_string(&self) -> String { - match self { - CastingType::Action => "action".to_string(), - CastingType::BonusAction => "bonus".to_string(), - CastingType::Reaction => "reaction".to_string(), - CastingType::Minutes => "minutes".to_string(), - CastingType::Hours => "hours".to_string() - } - } -} +// impl CastingType { +// pub fn to_string(&self) -> String { +// match self { +// CastingType::Action => "action".to_string(), +// CastingType::BonusAction => "bonus".to_string(), +// CastingType::Reaction => "reaction".to_string(), +// CastingType::Minutes => "minutes".to_string(), +// CastingType::Hours => "hours".to_string() +// } +// } +// } -impl FromStr for CastingType { - type Err = (); +// impl FromStr for CastingType { +// type Err = (); - fn from_str(s: &str) -> Result { - match s { - "action" => Ok(CastingType::Action), - "bonus" => Ok(CastingType::BonusAction), - "reaction" => Ok(CastingType::Reaction), - "minutes" => Ok(CastingType::Minutes), - "hours" => Ok(CastingType::Hours), - _ => Err(()) - } - } -} +// fn from_str(s: &str) -> Result { +// match s { +// "action" => Ok(CastingType::Action), +// "bonus" => Ok(CastingType::BonusAction), +// "reaction" => Ok(CastingType::Reaction), +// "minutes" => Ok(CastingType::Minutes), +// "hours" => Ok(CastingType::Hours), +// _ => Err(()) +// } +// } +// } #[derive(Debug, Serialize, Deserialize)] pub enum SpellAttackType { @@ -110,26 +110,26 @@ pub enum SpellAttackType { Ranged, } -impl SpellAttackType { - pub fn to_string(&self) -> String { - match self { - SpellAttackType::Melee => "melee".to_string(), - SpellAttackType::Ranged => "ranged".to_string() - } - } -} +// impl SpellAttackType { +// pub fn to_string(&self) -> String { +// match self { +// SpellAttackType::Melee => "melee".to_string(), +// SpellAttackType::Ranged => "ranged".to_string() +// } +// } +// } -impl FromStr for SpellAttackType { - type Err = (); +// impl FromStr for SpellAttackType { +// type Err = (); - fn from_str(s: &str) -> Result { - match s { - "melee" => Ok(SpellAttackType::Melee), - "ranged" => Ok(SpellAttackType::Ranged), - _ => Err(()) - } - } -} +// fn from_str(s: &str) -> Result { +// match s { +// "melee" => Ok(SpellAttackType::Melee), +// "ranged" => Ok(SpellAttackType::Ranged), +// _ => Err(()) +// } +// } +// } #[derive(Debug, Serialize, Deserialize)] pub enum SpellDamageType { @@ -161,48 +161,48 @@ pub enum SpellDamageType { Thunder } -impl SpellDamageType { - pub fn to_string(&self) -> String { - match self { - SpellDamageType::Acid => "acid".to_string(), - SpellDamageType::Bludgeoning => "bludgeoning".to_string(), - SpellDamageType::Cold => "cold".to_string(), - SpellDamageType::Fire => "fire".to_string(), - SpellDamageType::Force => "force".to_string(), - SpellDamageType::Lightning => "lightning".to_string(), - SpellDamageType::Necrotic => "necrotic".to_string(), - SpellDamageType::Piercing => "piercing".to_string(), - SpellDamageType::Poison => "poison".to_string(), - SpellDamageType::Psychic => "psychic".to_string(), - SpellDamageType::Radiant => "radiant".to_string(), - SpellDamageType::Slashing => "slashing".to_string(), - SpellDamageType::Thunder => "thunder".to_string() - } - } -} +// impl SpellDamageType { +// pub fn to_string(&self) -> String { +// match self { +// SpellDamageType::Acid => "acid".to_string(), +// SpellDamageType::Bludgeoning => "bludgeoning".to_string(), +// SpellDamageType::Cold => "cold".to_string(), +// SpellDamageType::Fire => "fire".to_string(), +// SpellDamageType::Force => "force".to_string(), +// SpellDamageType::Lightning => "lightning".to_string(), +// SpellDamageType::Necrotic => "necrotic".to_string(), +// SpellDamageType::Piercing => "piercing".to_string(), +// SpellDamageType::Poison => "poison".to_string(), +// SpellDamageType::Psychic => "psychic".to_string(), +// SpellDamageType::Radiant => "radiant".to_string(), +// SpellDamageType::Slashing => "slashing".to_string(), +// SpellDamageType::Thunder => "thunder".to_string() +// } +// } +// } -impl FromStr for SpellDamageType { - type Err = (); +// impl FromStr for SpellDamageType { +// type Err = (); - fn from_str(s: &str) -> Result { - match s { - "acid" => Ok(SpellDamageType::Acid), - "bludgeoning" => Ok(SpellDamageType::Bludgeoning), - "cold" => Ok(SpellDamageType::Cold), - "fire" => Ok(SpellDamageType::Fire), - "force" => Ok(SpellDamageType::Force), - "lightning" => Ok(SpellDamageType::Lightning), - "necrotic" => Ok(SpellDamageType::Necrotic), - "piercing" => Ok(SpellDamageType::Piercing), - "poison" => Ok(SpellDamageType::Poison), - "psychic" => Ok(SpellDamageType::Psychic), - "radiant" => Ok(SpellDamageType::Radiant), - "slashing" => Ok(SpellDamageType::Slashing), - "thunder" => Ok(SpellDamageType::Thunder), - _ => Err(()) - } - } -} +// fn from_str(s: &str) -> Result { +// match s { +// "acid" => Ok(SpellDamageType::Acid), +// "bludgeoning" => Ok(SpellDamageType::Bludgeoning), +// "cold" => Ok(SpellDamageType::Cold), +// "fire" => Ok(SpellDamageType::Fire), +// "force" => Ok(SpellDamageType::Force), +// "lightning" => Ok(SpellDamageType::Lightning), +// "necrotic" => Ok(SpellDamageType::Necrotic), +// "piercing" => Ok(SpellDamageType::Piercing), +// "poison" => Ok(SpellDamageType::Poison), +// "psychic" => Ok(SpellDamageType::Psychic), +// "radiant" => Ok(SpellDamageType::Radiant), +// "slashing" => Ok(SpellDamageType::Slashing), +// "thunder" => Ok(SpellDamageType::Thunder), +// _ => Err(()) +// } +// } +// } #[derive(Debug, Serialize, Deserialize)] pub struct Range { @@ -238,32 +238,32 @@ pub enum AreaType { Sphere } -impl AreaType { - pub fn to_string(&self) -> String { - match self { - AreaType::Cone => "cone".to_string(), - AreaType::Cube => "cube".to_string(), - AreaType::Cylinder => "cylinder".to_string(), - AreaType::Line => "line".to_string(), - AreaType::Sphere => "sphere".to_string() - } - } -} +// impl AreaType { +// pub fn to_string(&self) -> String { +// match self { +// AreaType::Cone => "cone".to_string(), +// AreaType::Cube => "cube".to_string(), +// AreaType::Cylinder => "cylinder".to_string(), +// AreaType::Line => "line".to_string(), +// AreaType::Sphere => "sphere".to_string() +// } +// } +// } -impl FromStr for AreaType { - type Err = (); +// impl FromStr for AreaType { +// type Err = (); - fn from_str(s: &str) -> Result { - match s { - "cone" => Ok(AreaType::Cone), - "cube" => Ok(AreaType::Cube), - "cylinder" => Ok(AreaType::Cylinder), - "line" => Ok(AreaType::Line), - "sphere" => Ok(AreaType::Sphere), - _ => Err(()) - } - } -} +// fn from_str(s: &str) -> Result { +// match s { +// "cone" => Ok(AreaType::Cone), +// "cube" => Ok(AreaType::Cube), +// "cylinder" => Ok(AreaType::Cylinder), +// "line" => Ok(AreaType::Line), +// "sphere" => Ok(AreaType::Sphere), +// _ => Err(()) +// } +// } +// } #[derive(Debug, Serialize, Deserialize)] pub struct Duration { diff --git a/src/error_handler.rs b/src/error_handler.rs index 1d20939..695e04f 100644 --- a/src/error_handler.rs +++ b/src/error_handler.rs @@ -35,7 +35,7 @@ impl From for ServiceError { DieselError::SerializationError(err) => { ServiceError::new(422, err.to_string()) }, - err => ServiceError::new(500, format!("Unknown Diesel error: {}", err)), + err => ServiceError::new(500, format!("Unknown database error: {}", err)), } } } @@ -52,6 +52,6 @@ impl ResponseError for ServiceError { false => "Internal server error".to_string(), }; - HttpResponse::build(status_code).json(serde_json::json!({ "message": error_message })) + HttpResponse::build(status_code).json(serde_json::json!({ "status": status_code.as_u16(), "message": error_message })) } } From 4d3e8ae981c3f789d0fa523efd4ce8c853d06e74 Mon Sep 17 00:00:00 2001 From: Benjamin Sherriff Date: Wed, 4 Oct 2023 11:07:28 -0400 Subject: [PATCH 09/14] Fixed pages metadata --- src/db/mod.rs | 3 ++- src/db/spells/model.rs | 5 ++++- src/db/spells/routes.rs | 3 ++- 3 files changed, 8 insertions(+), 3 deletions(-) diff --git a/src/db/mod.rs b/src/db/mod.rs index 261221d..4c31158 100644 --- a/src/db/mod.rs +++ b/src/db/mod.rs @@ -67,5 +67,6 @@ pub struct GetResponse { pub struct Metadata { pub total: i32, pub limit: i32, - pub page: i32 + pub page: i32, + pub pages: i32 } diff --git a/src/db/spells/model.rs b/src/db/spells/model.rs index f41ac6f..106ce8a 100644 --- a/src/db/spells/model.rs +++ b/src/db/spells/model.rs @@ -50,7 +50,10 @@ impl QuerySpell { pub fn get_all(filters: &QueryFilters, limit: i32, page: i32) -> Result, ServiceError> { let mut conn = crate::db::connection()?; let mut query = spells::table.limit(limit as i64).into_boxed(); - query = query.filter(spells::id.gt(std::cmp::max(0, page - 1) * limit)); + // Limit query to page and limit + let offset = (page - 1) * limit; + query = query.offset(offset as i64); + // Apply filters if let Some(name) = filters.by_name.to_owned() { query = query.filter(spells::name.ilike(format!("%{}%", name))); } diff --git a/src/db/spells/routes.rs b/src/db/spells/routes.rs index 1671b4b..04cad71 100644 --- a/src/db/spells/routes.rs +++ b/src/db/spells/routes.rs @@ -41,7 +41,8 @@ async fn get_all(req: HttpRequest) -> HttpResponse { metadata: Some(Metadata { total: total_count as i32, limit, - page + page, + pages: max_page }) }) }, From 8da46fdd5ec4390bb5766083783b08fe4d736f96 Mon Sep 17 00:00:00 2001 From: Benjamin Sherriff Date: Wed, 4 Oct 2023 13:09:04 -0400 Subject: [PATCH 10/14] Updated query parameters for spells --- data/spells/cantrips.json | 54 ++++++-- migrations/000007_create_spells/up.sql | 10 ++ src/db/classes/model.rs | 54 ++++---- src/db/conditions/mod.rs | 90 ++++++------- src/db/schema.rs | 10 ++ src/db/spells/model.rs | 150 +++++++++++++++++---- src/db/spells/routes.rs | 73 +++++++++- src/db/spells/types.rs | 180 ++++++++++++------------- 8 files changed, 422 insertions(+), 199 deletions(-) diff --git a/data/spells/cantrips.json b/data/spells/cantrips.json index 908160a..d1abdd3 100644 --- a/data/spells/cantrips.json +++ b/data/spells/cantrips.json @@ -6,7 +6,7 @@ "ritual": false, "casting_time": { "amount": 1, - "type": "action" + "unit": "action" }, "range": { "type": "point", @@ -49,7 +49,7 @@ "ritual": false, "casting_time": { "amount": 1, - "type": "action" + "unit": "action" }, "range": { "type": "self" @@ -88,7 +88,7 @@ "ritual": false, "casting_time": { "amount": 1, - "type": "action" + "unit": "action" }, "range": { "type": "touch" @@ -134,7 +134,7 @@ "ritual": false, "casting_time": { "amount": 1, - "type": "action" + "unit": "action" }, "range": { "type": "point", @@ -177,7 +177,7 @@ "ritual": false, "casting_time": { "amount": 1, - "type": "action" + "unit": "action" }, "range": { "type": "point", @@ -232,7 +232,7 @@ "ritual": false, "casting_time": { "amount": 1, - "type": "action" + "unit": "action" }, "range": { "type": "point", @@ -276,7 +276,7 @@ "ritual": false, "casting_time": { "amount": 1, - "type": "action" + "unit": "action" }, "range": { "type": "point", @@ -315,7 +315,7 @@ "ritual": false, "casting_time": { "amount": 1, - "type": "action" + "unit": "action" }, "range": { "type": "point", @@ -359,7 +359,7 @@ "ritual": false, "casting_time": { "amount": 1, - "type": "action" + "unit": "action" }, "range": { "type": "point", @@ -391,5 +391,41 @@ "The spell creates more than one beam when you reach higher levels: two beams at 5th level, three beams at 11th level, and four beams at 17th level. You can direct the beams at the same target or at different ones. Make a separate attack roll for each beam." ] } + }, + { + "name": "Encode Thoughts", + "school": "enchantment", + "level": 0, + "ritual": true, + "casting_time": { + "amount": 1, + "unit": "action" + }, + "range": { + "type": "self" + }, + "components": { + "verbal": false, + "somatic": true, + "material": false + }, + "durations": [ + { + "type": "timed", + "amount": 8, + "unit": "hours" + } + ], + "classes": ["wizard"], + "sources": [ + { "source": "GGR", "page": 47 } + ], + "description": { + "entries": [ + "Putting a finger to your head, you pull a memory, an idea, or a message from your mind and transform it into a tangible string of glowing energy called a thought strand, which persists for the duration or until you cast this spell again. The thought strand appears in an unoccupied space within 5 feet of you as a Tiny, weightless, semisolid object that can be held and carried like a ribbon. It is otherwise stationary.", + "If you cast this spell while concentrating on a spell or an ability that allows you to read or manipulate the thoughts of others (such as {@spell detect thoughts} or {@spell modify memory}), you can transform the thoughts or memories you read, rather than your own, into a thought strand.", + "Casting this spell while holding a thought strand allows you to instantly receive whatever memory, idea, or message the thought strand contains. (Casting {@spell detect thoughts} on the strand has the same effect.)" + ] + } } ] \ No newline at end of file diff --git a/migrations/000007_create_spells/up.sql b/migrations/000007_create_spells/up.sql index 8dc9f9e..0997c05 100644 --- a/migrations/000007_create_spells/up.sql +++ b/migrations/000007_create_spells/up.sql @@ -1,5 +1,15 @@ CREATE TABLE IF NOT EXISTS spells ( id INTEGER GENERATED ALWAYS AS IDENTITY, name TEXT NOT NULL, + school TEXT NOT NULL, + level INTEGER NOT NULL, + ritual BOOLEAN DEFAULT FALSE, + concentration BOOLEAN DEFAULT FALSE, + classes TEXT[] NOT NULL, + damage_inflict TEXT[] NOT NULL, + damage_resist TEXT[] NOT NULL, + conditions TEXT[] NOT NULL, + saving_throw TEXT[] NOT NULL, + attack_type TEXT, data JSONB NOT NULL ); \ No newline at end of file diff --git a/src/db/classes/model.rs b/src/db/classes/model.rs index 9dbece8..7a42411 100644 --- a/src/db/classes/model.rs +++ b/src/db/classes/model.rs @@ -1,4 +1,4 @@ -// use std::str::FromStr; +use std::str::FromStr; use serde::{Serialize, Deserialize}; #[derive(Debug, Serialize, Deserialize)] @@ -17,30 +17,30 @@ pub enum AbilityType { Charisma } -// impl AbilityType { -// pub fn to_string(&self) -> String { -// match self { -// AbilityType::Strength => "Strength".to_string(), -// AbilityType::Dexterity => "Dexterity".to_string(), -// AbilityType::Constitution => "Constitution".to_string(), -// AbilityType::Intelligence => "Intelligence".to_string(), -// AbilityType::Wisdom => "Wisdom".to_string(), -// AbilityType::Charisma => "Charisma".to_string() -// } -// } -// } +impl AbilityType { + pub fn to_string(&self) -> String { + match self { + AbilityType::Strength => "Strength".to_string(), + AbilityType::Dexterity => "Dexterity".to_string(), + AbilityType::Constitution => "Constitution".to_string(), + AbilityType::Intelligence => "Intelligence".to_string(), + AbilityType::Wisdom => "Wisdom".to_string(), + AbilityType::Charisma => "Charisma".to_string() + } + } +} -// impl FromStr for AbilityType { -// type Err = (); -// fn from_str(s: &str) -> Result { -// match s { -// "Strength" => Ok(AbilityType::Strength), -// "Dexterity" => Ok(AbilityType::Dexterity), -// "Constitution" => Ok(AbilityType::Constitution), -// "Intelligence" => Ok(AbilityType::Intelligence), -// "Wisdom" => Ok(AbilityType::Wisdom), -// "Charisma" => Ok(AbilityType::Charisma), -// _ => Err(()) -// } -// } -// } \ No newline at end of file +impl FromStr for AbilityType { + type Err = (); + fn from_str(s: &str) -> Result { + match s { + "Strength" => Ok(AbilityType::Strength), + "Dexterity" => Ok(AbilityType::Dexterity), + "Constitution" => Ok(AbilityType::Constitution), + "Intelligence" => Ok(AbilityType::Intelligence), + "Wisdom" => Ok(AbilityType::Wisdom), + "Charisma" => Ok(AbilityType::Charisma), + _ => Err(()) + } + } +} \ No newline at end of file diff --git a/src/db/conditions/mod.rs b/src/db/conditions/mod.rs index 01a9b2e..e281d3e 100644 --- a/src/db/conditions/mod.rs +++ b/src/db/conditions/mod.rs @@ -1,4 +1,4 @@ -// use std::str::FromStr; +use std::str::FromStr; use serde::{Deserialize, Serialize}; @@ -36,48 +36,48 @@ pub enum ConditionType { Unconscious } -// impl ConditionType { -// pub fn to_string(&self) -> String { -// match self { -// ConditionType::Blinded => "Blinded".to_string(), -// ConditionType::Charmed => "Charmed".to_string(), -// ConditionType::Deafened => "Deafened".to_string(), -// ConditionType::Exhaustion => "Exhaustion".to_string(), -// ConditionType::Frightened => "Frightened".to_string(), -// ConditionType::Grappled => "Grappled".to_string(), -// ConditionType::Incapacitated => "Incapacitated".to_string(), -// ConditionType::Invisible => "Invisible".to_string(), -// ConditionType::Paralyzed => "Paralyzed".to_string(), -// ConditionType::Petrified => "Petrified".to_string(), -// ConditionType::Poisoned => "Poisoned".to_string(), -// ConditionType::Prone => "Prone".to_string(), -// ConditionType::Restrained => "Restrained".to_string(), -// ConditionType::Stunned => "Stunned".to_string(), -// ConditionType::Unconscious => "Unconscious".to_string() -// } -// } -// } +impl ConditionType { + pub fn to_string(&self) -> String { + match self { + ConditionType::Blinded => "Blinded".to_string(), + ConditionType::Charmed => "Charmed".to_string(), + ConditionType::Deafened => "Deafened".to_string(), + ConditionType::Exhaustion => "Exhaustion".to_string(), + ConditionType::Frightened => "Frightened".to_string(), + ConditionType::Grappled => "Grappled".to_string(), + ConditionType::Incapacitated => "Incapacitated".to_string(), + ConditionType::Invisible => "Invisible".to_string(), + ConditionType::Paralyzed => "Paralyzed".to_string(), + ConditionType::Petrified => "Petrified".to_string(), + ConditionType::Poisoned => "Poisoned".to_string(), + ConditionType::Prone => "Prone".to_string(), + ConditionType::Restrained => "Restrained".to_string(), + ConditionType::Stunned => "Stunned".to_string(), + ConditionType::Unconscious => "Unconscious".to_string() + } + } +} -// impl FromStr for ConditionType { -// type Err = (); -// fn from_str(s: &str) -> Result { -// match s { -// "Blinded" => Ok(ConditionType::Blinded), -// "Charmed" => Ok(ConditionType::Charmed), -// "Deafened" => Ok(ConditionType::Deafened), -// "Exhaustion" => Ok(ConditionType::Exhaustion), -// "Frightened" => Ok(ConditionType::Frightened), -// "Grappled" => Ok(ConditionType::Grappled), -// "Incapacitated" => Ok(ConditionType::Incapacitated), -// "Invisible" => Ok(ConditionType::Invisible), -// "Paralyzed" => Ok(ConditionType::Paralyzed), -// "Petrified" => Ok(ConditionType::Petrified), -// "Poisoned" => Ok(ConditionType::Poisoned), -// "Prone" => Ok(ConditionType::Prone), -// "Restrained" => Ok(ConditionType::Restrained), -// "Stunned" => Ok(ConditionType::Stunned), -// "Unconscious" => Ok(ConditionType::Unconscious), -// _ => Err(()) -// } -// } -// } +impl FromStr for ConditionType { + type Err = (); + fn from_str(s: &str) -> Result { + match s { + "Blinded" => Ok(ConditionType::Blinded), + "Charmed" => Ok(ConditionType::Charmed), + "Deafened" => Ok(ConditionType::Deafened), + "Exhaustion" => Ok(ConditionType::Exhaustion), + "Frightened" => Ok(ConditionType::Frightened), + "Grappled" => Ok(ConditionType::Grappled), + "Incapacitated" => Ok(ConditionType::Incapacitated), + "Invisible" => Ok(ConditionType::Invisible), + "Paralyzed" => Ok(ConditionType::Paralyzed), + "Petrified" => Ok(ConditionType::Petrified), + "Poisoned" => Ok(ConditionType::Poisoned), + "Prone" => Ok(ConditionType::Prone), + "Restrained" => Ok(ConditionType::Restrained), + "Stunned" => Ok(ConditionType::Stunned), + "Unconscious" => Ok(ConditionType::Unconscious), + _ => Err(()) + } + } +} diff --git a/src/db/schema.rs b/src/db/schema.rs index 016753d..a148271 100644 --- a/src/db/schema.rs +++ b/src/db/schema.rs @@ -17,6 +17,16 @@ diesel::table! { spells (id) { id -> Integer, name -> Text, + school -> Text, + level -> Integer, + ritual -> Bool, + concentration -> Bool, + classes -> Array, + damage_inflict -> Array, + damage_resist -> Array, + conditions -> Array, + saving_throw -> Array, + attack_type -> Nullable, data -> Jsonb } } \ No newline at end of file diff --git a/src/db/spells/model.rs b/src/db/spells/model.rs index 106ce8a..dc97cbb 100644 --- a/src/db/spells/model.rs +++ b/src/db/spells/model.rs @@ -3,29 +3,39 @@ use serde::{Deserialize, Serialize}; use crate::{db::{schema::spells::{self}, classes::AbilityType, conditions::ConditionType}, error_handler::ServiceError}; -use super::{SchoolType, CastingTime, CastingType, SpellAttackType, SpellDamageType, Range, Area, Components, Duration, Source, Description}; +use super::{SchoolType, CastingTime, CastingType, SpellAttackType, SpellDamageType, Range, Area, Components, Duration, Source, Description, DurationType}; #[derive(Queryable, QueryableByName)] #[diesel(table_name = spells)] pub struct QuerySpell { pub id: i32, pub name: String, + pub school: String, + pub level: i32, + pub ritual: bool, + pub concentration: bool, + pub classes: Vec, + pub damage_inflict: Vec, + pub damage_resist: Vec, + pub conditions: Vec, + pub saving_throw: Vec, + pub attack_type: Option, pub data: serde_json::Value, } #[derive(Debug)] pub struct QueryFilters { pub by_name: Option, - pub by_schools: Option>, + pub by_schools: Option>, pub by_levels: Option>, pub by_ritual: Option, pub by_concentration: Option, pub by_classes: Option>, - pub by_damage_inflict: Option>, - pub by_damage_resist: Option>, - pub by_conditions: Option>, - pub by_saving_throw: Option>, - pub by_attack_type: Option, + pub by_damage_inflict: Option>, + pub by_damage_resist: Option>, + pub by_conditions: Option>, + pub by_saving_throw: Option>, + pub by_attack_type: Option, } impl Default for QueryFilters { @@ -53,15 +63,85 @@ impl QuerySpell { // Limit query to page and limit let offset = (page - 1) * limit; query = query.offset(offset as i64); - // Apply filters - if let Some(name) = filters.by_name.to_owned() { + if let Some(name) = &filters.by_name { query = query.filter(spells::name.ilike(format!("%{}%", name))); } - + if let Some(schools) = &filters.by_schools { + query = query.filter(spells::school.eq_any(schools.iter().map(|school| school.to_string()).collect::>())); + } + if let Some(levels) = &filters.by_levels { + query = query.filter(spells::level.eq_any(levels)); + } + if let Some(ritual) = filters.by_ritual { + query = query.filter(spells::ritual.eq(ritual)); + } + if let Some(concentration) = filters.by_concentration { + query = query.filter(spells::concentration.eq(concentration)); + } + if let Some(classes) = &filters.by_classes { + query = query.filter(spells::classes.overlaps_with(classes)); + } + if let Some(damage_inflict) = &filters.by_damage_inflict { + query = query.filter(spells::damage_inflict.overlaps_with(damage_inflict.iter().map(|damage_inflict| damage_inflict.to_string()).collect::>())); + } + if let Some(damage_resist) = &filters.by_damage_resist { + query = query.filter(spells::damage_resist.overlaps_with(damage_resist.iter().map(|damage_resist| damage_resist.to_string()).collect::>())); + } + if let Some(conditions) = &filters.by_conditions { + query = query.filter(spells::conditions.overlaps_with(conditions.iter().map(|condition| condition.to_string()).collect::>())); + } + if let Some(saving_throw) = &filters.by_saving_throw { + query = query.filter(spells::saving_throw.overlaps_with(saving_throw.iter().map(|saving_throw| saving_throw.to_string()).collect::>())); + } + if let Some(attack_type) = &filters.by_attack_type { + query = query.filter(spells::attack_type.eq(attack_type.to_string())); + } + let spells = query.load::(&mut conn)?; Ok(spells) } + pub fn get_count(filters: &QueryFilters) -> Result { + let mut conn = crate::db::connection()?; + let mut query = spells::table.count().into_boxed(); + if let Some(name) = &filters.by_name { + query = query.filter(spells::name.ilike(format!("%{}%", name))); + } + if let Some(schools) = &filters.by_schools { + query = query.filter(spells::school.eq_any(schools.iter().map(|school| school.to_string()).collect::>())); + } + if let Some(levels) = &filters.by_levels { + query = query.filter(spells::level.eq_any(levels)); + } + if let Some(ritual) = filters.by_ritual { + query = query.filter(spells::ritual.eq(ritual)); + } + if let Some(concentration) = filters.by_concentration { + query = query.filter(spells::concentration.eq(concentration)); + } + if let Some(classes) = &filters.by_classes { + query = query.filter(spells::classes.overlaps_with(classes)); + } + if let Some(damage_inflict) = &filters.by_damage_inflict { + query = query.filter(spells::damage_inflict.overlaps_with(damage_inflict.iter().map(|damage_inflict| damage_inflict.to_string()).collect::>())); + } + if let Some(damage_resist) = &filters.by_damage_resist { + query = query.filter(spells::damage_resist.overlaps_with(damage_resist.iter().map(|damage_resist| damage_resist.to_string()).collect::>())); + } + if let Some(conditions) = &filters.by_conditions { + query = query.filter(spells::conditions.overlaps_with(conditions.iter().map(|condition| condition.to_string()).collect::>())); + } + if let Some(saving_throw) = &filters.by_saving_throw { + query = query.filter(spells::saving_throw.overlaps_with(saving_throw.iter().map(|saving_throw| saving_throw.to_string()).collect::>())); + } + if let Some(attack_type) = &filters.by_attack_type { + query = query.filter(spells::attack_type.eq(attack_type.to_string())); + } + + let count = query.get_result(&mut conn)?; + Ok(count) + } + pub fn get_by_id(id: i32) -> Result { let mut conn = crate::db::connection()?; let spell = spells::table @@ -70,17 +150,6 @@ impl QuerySpell { Ok(spell) } - pub fn get_count(filters: &QueryFilters) -> Result { - let mut conn = crate::db::connection()?; - let mut query = spells::table.count().into_boxed(); - if let Some(name) = filters.by_name.to_owned() { - query = query.filter(spells::name.ilike(format!("%{}%", name))); - } - - let count = query.get_result(&mut conn)?; - Ok(count) - } - pub fn delete(id: i32) -> Result { let mut conn = crate::db::connection()?; let spell = diesel::delete(spells::table.filter(spells::id.eq(id))).get_result(&mut conn)?; @@ -92,6 +161,16 @@ impl QuerySpell { #[diesel(table_name = spells)] pub struct InsertSpell { pub name: String, + pub school: String, + pub level: i32, + pub ritual: bool, + pub concentration: bool, + pub classes: Vec, + pub damage_inflict: Vec, + pub damage_resist: Vec, + pub conditions: Vec, + pub saving_throw: Vec, + pub attack_type: Option, pub data: serde_json::Value } @@ -174,13 +253,38 @@ impl Into for Spell { fn into(self) -> InsertSpell { return InsertSpell { name: self.name.to_string(), + school: self.school.to_string(), + level: self.level, + ritual: self.ritual, + concentration: self.durations.iter().any(|duration| match duration.duration_type { + DurationType::Concentration => true, + _ => false + }), + classes: self.classes.iter().map(|class| class.to_string()).collect::>(), + damage_inflict: match &self.damage_inflict { + Some(damage_inflict) => damage_inflict.iter().map(|damage_inflict| damage_inflict.to_string()).collect(), + None => vec![] + }, + damage_resist: match &self.damage_resist { + Some(damage_resist) => damage_resist.iter().map(|damage_resist| damage_resist.to_string()).collect(), + None => vec![] + }, + conditions: match &self.conditions { + Some(conditions) => conditions.iter().map(|condition| condition.to_string()).collect(), + None => vec![] + }, + saving_throw: match &self.saving_throw { + Some(saving_throw) => saving_throw.iter().map(|saving_throw| saving_throw.to_string()).collect(), + None => vec![] + }, + attack_type: self.attack_type.as_ref().map(|attack_type| attack_type.to_string()), data: match serde_json::to_value(&self) { Ok(data) => data, Err(err) => { - log::error!("Failed to serialize spell description: {}", err); + log::error!("Failed to serialize spell: {}", err); serde_json::Value::Null } - }, + } } } } diff --git a/src/db/spells/routes.rs b/src/db/spells/routes.rs index 04cad71..303c229 100644 --- a/src/db/spells/routes.rs +++ b/src/db/spells/routes.rs @@ -1,4 +1,5 @@ use actix_web::{get, post, put, delete, web, HttpResponse, HttpRequest, ResponseError}; +use log::error; use serde::{Serialize, Deserialize}; use crate::{db::{spells::{QuerySpell, QueryFilters}, GetResponse, Metadata}, error_handler::ServiceError}; @@ -8,6 +9,16 @@ use super::{Spell, InsertSpell}; #[derive(Serialize, Deserialize)] struct GetAllParams { name: Option, + schools: Option, + levels: Option, + ritual: Option, + concentration: Option, + classes: Option, + damage_inflict: Option, + damage_resist: Option, + conditions: Option, + saving_throw: Option, + attack_type: Option, limit: Option, page: Option, } @@ -23,6 +34,43 @@ async fn get_all(req: HttpRequest) -> HttpResponse { }; let mut filters = QueryFilters::default(); filters.by_name = params.name.clone(); + filters.by_schools = match ¶ms.schools { + Some(schools) => Some(schools.split(",").map(|s| s.to_string()).collect()), + None => None + }; + filters.by_levels = match ¶ms.levels { + Some(levels) => Some(levels.split(",").map(|s| match s.to_string().parse::() { + Ok(level) => level, + Err(_) => 0 + }).collect()), + None => None + }; + filters.by_ritual = params.ritual; + filters.by_concentration = params.concentration; + filters.by_classes = match ¶ms.classes { + Some(classes) => Some(classes.split(",").map(|s| s.to_string()).collect()), + None => None + }; + filters.by_damage_inflict = match ¶ms.damage_inflict { + Some(damage_inflict) => Some(damage_inflict.split(",").map(|s| s.to_string()).collect()), + None => None + }; + filters.by_damage_resist = match ¶ms.damage_resist { + Some(damage_resist) => Some(damage_resist.split(",").map(|s| s.to_string()).collect()), + None => None + }; + filters.by_conditions = match ¶ms.conditions { + Some(conditions) => Some(conditions.split(",").map(|s| s.to_string()).collect()), + None => None + }; + filters.by_saving_throw = match ¶ms.saving_throw { + Some(saving_throw) => Some(saving_throw.split(",").map(|s| s.to_string()).collect()), + None => None + }; + filters.by_attack_type = match ¶ms.attack_type { + Some(attack_type) => Some(attack_type.split(",").map(|s| s.to_string()).collect()), + None => None + }; // Limit must be between 1 and 100 let limit = std::cmp::min(std::cmp::max(params.limit.unwrap_or(20), 1), 100); let total_count = QuerySpell::get_count(&filters).unwrap(); @@ -46,7 +94,10 @@ async fn get_all(req: HttpRequest) -> HttpResponse { }) }) }, - Err(err) => ResponseError::error_response(&err) + Err(err) => { + error!("{:?}", err.message); + ResponseError::error_response(&err) + } } } @@ -64,7 +115,10 @@ async fn get_by_id(id: web::Path) -> HttpResponse { data: Spell::from(spell), metadata: None }), - Err(err) => ResponseError::error_response(&err) + Err(err) => { + error!("{:?}", err.message); + ResponseError::error_response(&err) + } } } @@ -72,7 +126,10 @@ async fn get_by_id(id: web::Path) -> HttpResponse { async fn create(spell: web::Json) -> HttpResponse { match InsertSpell::insert(spell.into_inner().into()) { Ok(spell) => HttpResponse::Created().json(Spell::from(spell)), - Err(err) => ResponseError::error_response(&err) + Err(err) => { + error!("{:?}", err.message); + ResponseError::error_response(&err) + } } } @@ -87,7 +144,10 @@ async fn update(id: web::Path, spell: web::Json) -> HttpResponse }; match web::block(move || InsertSpell::update(id, spell.into_inner().into())).await.unwrap() { Ok(spell) => HttpResponse::Ok().json(Spell::from(spell)), - Err(err) => ResponseError::error_response(&err) + Err(err) => { + error!("{:?}", err.message); + ResponseError::error_response(&err) + } } } @@ -102,7 +162,10 @@ async fn delete(id: web::Path) -> HttpResponse { }; match web::block(move || QuerySpell::delete(id)).await.unwrap() { Ok(spell) => HttpResponse::Ok().json(Spell::from(spell)), - Err(err) => ResponseError::error_response(&err) + Err(err) => { + error!("{:?}", err.message); + ResponseError::error_response(&err) + } } } diff --git a/src/db/spells/types.rs b/src/db/spells/types.rs index bf1cde7..6e8f943 100644 --- a/src/db/spells/types.rs +++ b/src/db/spells/types.rs @@ -1,4 +1,4 @@ -// use std::str::FromStr; +use std::str::FromStr; use serde::{Deserialize, Serialize, ser::SerializeMap}; #[derive(Debug, Serialize, Deserialize)] @@ -21,43 +21,43 @@ pub enum SchoolType { Transmutation } -// impl SchoolType { -// pub fn to_string(&self) -> String { -// match self { -// SchoolType::Abjuration => "abjuration".to_string(), -// SchoolType::Conjuration => "conjuration".to_string(), -// SchoolType::Divination => "divination".to_string(), -// SchoolType::Enchantment => "enchantment".to_string(), -// SchoolType::Evocation => "evocation".to_string(), -// SchoolType::Illusion => "illusion".to_string(), -// SchoolType::Necromancy => "necromancy".to_string(), -// SchoolType::Transmutation => "transmutation".to_string() -// } -// } -// } +impl SchoolType { + pub fn to_string(&self) -> String { + match self { + SchoolType::Abjuration => "abjuration".to_string(), + SchoolType::Conjuration => "conjuration".to_string(), + SchoolType::Divination => "divination".to_string(), + SchoolType::Enchantment => "enchantment".to_string(), + SchoolType::Evocation => "evocation".to_string(), + SchoolType::Illusion => "illusion".to_string(), + SchoolType::Necromancy => "necromancy".to_string(), + SchoolType::Transmutation => "transmutation".to_string() + } + } +} -// impl FromStr for SchoolType { -// type Err = (); +impl FromStr for SchoolType { + type Err = (); -// fn from_str(s: &str) -> Result { -// match s { -// "abjuration" => Ok(SchoolType::Abjuration), -// "conjuration" => Ok(SchoolType::Conjuration), -// "divination" => Ok(SchoolType::Divination), -// "enchantment" => Ok(SchoolType::Enchantment), -// "evocation" => Ok(SchoolType::Evocation), -// "illusion" => Ok(SchoolType::Illusion), -// "necromancy" => Ok(SchoolType::Necromancy), -// "transmutation" => Ok(SchoolType::Transmutation), -// _ => Err(()) -// } -// } -// } + fn from_str(s: &str) -> Result { + match s { + "abjuration" => Ok(SchoolType::Abjuration), + "conjuration" => Ok(SchoolType::Conjuration), + "divination" => Ok(SchoolType::Divination), + "enchantment" => Ok(SchoolType::Enchantment), + "evocation" => Ok(SchoolType::Evocation), + "illusion" => Ok(SchoolType::Illusion), + "necromancy" => Ok(SchoolType::Necromancy), + "transmutation" => Ok(SchoolType::Transmutation), + _ => Err(()) + } + } +} #[derive(Debug, Serialize, Deserialize)] pub struct CastingTime { pub amount: i32, - #[serde(rename = "type")] + #[serde(rename = "unit")] pub casting_type: CastingType } @@ -110,26 +110,26 @@ pub enum SpellAttackType { Ranged, } -// impl SpellAttackType { -// pub fn to_string(&self) -> String { -// match self { -// SpellAttackType::Melee => "melee".to_string(), -// SpellAttackType::Ranged => "ranged".to_string() -// } -// } -// } +impl SpellAttackType { + pub fn to_string(&self) -> String { + match self { + SpellAttackType::Melee => "melee".to_string(), + SpellAttackType::Ranged => "ranged".to_string() + } + } +} -// impl FromStr for SpellAttackType { -// type Err = (); +impl FromStr for SpellAttackType { + type Err = (); -// fn from_str(s: &str) -> Result { -// match s { -// "melee" => Ok(SpellAttackType::Melee), -// "ranged" => Ok(SpellAttackType::Ranged), -// _ => Err(()) -// } -// } -// } + fn from_str(s: &str) -> Result { + match s { + "melee" => Ok(SpellAttackType::Melee), + "ranged" => Ok(SpellAttackType::Ranged), + _ => Err(()) + } + } +} #[derive(Debug, Serialize, Deserialize)] pub enum SpellDamageType { @@ -161,48 +161,48 @@ pub enum SpellDamageType { Thunder } -// impl SpellDamageType { -// pub fn to_string(&self) -> String { -// match self { -// SpellDamageType::Acid => "acid".to_string(), -// SpellDamageType::Bludgeoning => "bludgeoning".to_string(), -// SpellDamageType::Cold => "cold".to_string(), -// SpellDamageType::Fire => "fire".to_string(), -// SpellDamageType::Force => "force".to_string(), -// SpellDamageType::Lightning => "lightning".to_string(), -// SpellDamageType::Necrotic => "necrotic".to_string(), -// SpellDamageType::Piercing => "piercing".to_string(), -// SpellDamageType::Poison => "poison".to_string(), -// SpellDamageType::Psychic => "psychic".to_string(), -// SpellDamageType::Radiant => "radiant".to_string(), -// SpellDamageType::Slashing => "slashing".to_string(), -// SpellDamageType::Thunder => "thunder".to_string() -// } -// } -// } +impl SpellDamageType { + pub fn to_string(&self) -> String { + match self { + SpellDamageType::Acid => "acid".to_string(), + SpellDamageType::Bludgeoning => "bludgeoning".to_string(), + SpellDamageType::Cold => "cold".to_string(), + SpellDamageType::Fire => "fire".to_string(), + SpellDamageType::Force => "force".to_string(), + SpellDamageType::Lightning => "lightning".to_string(), + SpellDamageType::Necrotic => "necrotic".to_string(), + SpellDamageType::Piercing => "piercing".to_string(), + SpellDamageType::Poison => "poison".to_string(), + SpellDamageType::Psychic => "psychic".to_string(), + SpellDamageType::Radiant => "radiant".to_string(), + SpellDamageType::Slashing => "slashing".to_string(), + SpellDamageType::Thunder => "thunder".to_string() + } + } +} -// impl FromStr for SpellDamageType { -// type Err = (); +impl FromStr for SpellDamageType { + type Err = (); -// fn from_str(s: &str) -> Result { -// match s { -// "acid" => Ok(SpellDamageType::Acid), -// "bludgeoning" => Ok(SpellDamageType::Bludgeoning), -// "cold" => Ok(SpellDamageType::Cold), -// "fire" => Ok(SpellDamageType::Fire), -// "force" => Ok(SpellDamageType::Force), -// "lightning" => Ok(SpellDamageType::Lightning), -// "necrotic" => Ok(SpellDamageType::Necrotic), -// "piercing" => Ok(SpellDamageType::Piercing), -// "poison" => Ok(SpellDamageType::Poison), -// "psychic" => Ok(SpellDamageType::Psychic), -// "radiant" => Ok(SpellDamageType::Radiant), -// "slashing" => Ok(SpellDamageType::Slashing), -// "thunder" => Ok(SpellDamageType::Thunder), -// _ => Err(()) -// } -// } -// } + fn from_str(s: &str) -> Result { + match s { + "acid" => Ok(SpellDamageType::Acid), + "bludgeoning" => Ok(SpellDamageType::Bludgeoning), + "cold" => Ok(SpellDamageType::Cold), + "fire" => Ok(SpellDamageType::Fire), + "force" => Ok(SpellDamageType::Force), + "lightning" => Ok(SpellDamageType::Lightning), + "necrotic" => Ok(SpellDamageType::Necrotic), + "piercing" => Ok(SpellDamageType::Piercing), + "poison" => Ok(SpellDamageType::Poison), + "psychic" => Ok(SpellDamageType::Psychic), + "radiant" => Ok(SpellDamageType::Radiant), + "slashing" => Ok(SpellDamageType::Slashing), + "thunder" => Ok(SpellDamageType::Thunder), + _ => Err(()) + } + } +} #[derive(Debug, Serialize, Deserialize)] pub struct Range { From be4ab2bc6981ebe65d09bd2952b688e4824a32e9 Mon Sep 17 00:00:00 2001 From: Benjamin Sherriff Date: Wed, 4 Oct 2023 16:24:57 -0400 Subject: [PATCH 11/14] Updated the remaining cantrips --- data/spells/cantrips.json | 1492 ++++++++++++++++++++++++++++++++++++- 1 file changed, 1488 insertions(+), 4 deletions(-) diff --git a/data/spells/cantrips.json b/data/spells/cantrips.json index d1abdd3..b81c6e7 100644 --- a/data/spells/cantrips.json +++ b/data/spells/cantrips.json @@ -206,7 +206,7 @@ ], "classes": ["druid", "sorcerer", "wizard"], "sources": [ - { "source": "EE", "page": 16 }, + { "source": "EEPC", "page": 16 }, { "source": "XGE", "page": 152 } ], "description": { @@ -253,12 +253,12 @@ { "type": "concentration", "amount": 1, - "unit": "minute" + "unit": "minutes" } ], "classes": ["artificer", "druid", "sorcerer", "warlock", "wizard"], "sources": [ - { "source": "EE", "page": 16 }, + { "source": "EEPC", "page": 16 }, { "source": "XGE", "page": 152 } ], "description": { @@ -293,7 +293,7 @@ { "type": "concentration", "amount": 1, - "unit": "minute" + "unit": "minutes" } ], "classes": ["artificer", "bard", "sorcerer", "wizard"], @@ -427,5 +427,1489 @@ "Casting this spell while holding a thought strand allows you to instantly receive whatever memory, idea, or message the thought strand contains. (Casting {@spell detect thoughts} on the strand has the same effect.)" ] } + }, + { + "name": "Fire Bolt", + "school": "evocation", + "level": 0, + "ritual": false, + "casting_time": { + "amount": 1, + "unit": "action" + }, + "range": { + "type": "point", + "amount": 120, + "unit": "feet" + }, + "damage_inflict": [ + "fire" + ], + "attack_type": "ranged", + "components": { + "verbal": true, + "somatic": true, + "material": false + }, + "durations": [ + { + "type": "instantaneous" + } + ], + "classes": ["artificer", "sorcerer", "wizard"], + "sources": [ + { "source": "PHB", "page": 242 }, + { "source": "SRD", "page": 144 } + ], + "description": { + "entries": [ + "You hurl a mote of fire at a creature or object within range. Make a ranged spell attack against the target. On a hit, the target takes {@damage 1d10} fire damage. A flammable object hit by this spell ignites if it isn't being worn or carried.", + "This spell's damage increases by {@damage 1d10} when you reach 5th level ({@damage 2d10}), 11th level ({@damage 3d10}), and 17th level ({@damage 4d10})." + ] + } + }, + { + "name": "Friends", + "school": "enchantment", + "level": 0, + "ritual": false, + "casting_time": { + "amount": 1, + "unit": "action" + }, + "range": { + "type": "self" + }, + "components": { + "verbal": false, + "somatic": true, + "material": true, + "materials_needed": "a small amount of makeup applied to the face as this spell is cast" + }, + "durations": [ + { + "type": "concentration", + "amount": 1, + "unit": "minutes" + } + ], + "classes": ["bard", "sorcerer", "warlock", "wizard"], + "sources": [ + { "source": "PHB", "page": 244 }, + { "source": "SRD", "page": 145 } + ], + "description": { + "entries": [ + "For the duration, you have advantage on all Charisma checks directed at one creature of your choice that isn't hostile toward you. When the spell ends, the creature realizes that you used magic to influence its mood and becomes hostile toward you. A creature prone to violence might attack you. Another creature might seek retribution in other ways (at the DM's discretion), depending on the nature of your interaction with it." + ] + } + }, + { + "name": "Frostbite", + "school": "evocation", + "level": 0, + "ritual": false, + "casting_time": { + "amount": 1, + "unit": "action" + }, + "range": { + "type": "point", + "amount": 60, + "unit": "feet" + }, + "saving_throw": [ + "constitution" + ], + "damage_inflict": [ + "cold" + ], + "components": { + "verbal": true, + "somatic": true, + "material": false + }, + "durations": [ + { + "type": "instantaneous" + } + ], + "classes": ["artificer", "druid", "sorcerer", "warlock", "wizard"], + "sources": [ + { "source": "EEPC", "page": 18 }, + { "source": "XGE", "page": 155 } + ], + "description": { + "entries": [ + "You cause numbing frost to form on one creature that you can see within range. The target must make a Constitution saving throw. On a failed save, the target takes {@damage 1d6} cold damage, and it has disadvantage on the next weapon attack roll it makes before the end of its next turn.", + "The spell's damage increases by {@damage 1d6} when you reach 5th level ({@damage 2d6}), 11th level ({@damage 3d6}), and 17th level ({@damage 4d6})." + ] + } + }, + { + "name": "Green-Flame Blade", + "school": "evocation", + "level": 0, + "ritual": false, + "casting_time": { + "amount": 1, + "unit": "action" + }, + "range": { + "type": "self" + }, + "area": { + "type": "sphere", + "amount": 5, + "unit": "feet" + }, + "damage_inflict": [ + "fire" + ], + "attack_type": "melee", + "components": { + "verbal": false, + "somatic": true, + "material": true, + "materials_needed": "a melee weapon worth at least 1 sp" + }, + "durations": [ + { + "type": "instantaneous" + } + ], + "classes": ["artificer", "sorcerer", "warlock", "wizard"], + "sources": [ + { "source": "SCAG", "page": 142 }, + { "source": "TCE", "page": 106 } + ], + "description": { + "entries": [ + "You brandish the weapon used in the spell's casting and make a melee attack with it against one creature within 5 feet of you. On a hit, the target suffers the weapon attack's normal effects, and you can cause green fire to leap from the target to a different creature of your choice that you can see within 5 feet of it. The second creature takes fire damage equal to your spellcasting ability modifier.", + "This spell's damage increases when you reach certain levels. At 5th level, the melee attack deals an extra {@damage 1d8} fire damage to the target on a hit, and the fire damage to the second creature increases to {@damage 1d8} + your spellcasting ability modifier. Both damage rolls increase by {@damage 1d8} at 11th level ({@damage 2d8} and {@damage 2d8}) and 17th level ({@damage 3d8} and {@damage 3d8})." + ] + } + }, + { + "name": "Guidance", + "school": "divination", + "level": 0, + "ritual": false, + "casting_time": { + "amount": 1, + "unit": "action" + }, + "range": { + "type": "touch" + }, + "components": { + "verbal": true, + "somatic": true, + "material": false + }, + "durations": [ + { + "type": "concentration", + "amount": 1, + "unit": "minutes" + } + ], + "classes": ["artificer", "cleric", "druid"], + "sources": [ + { "source": "PHB", "page": 248 }, + { "source": "SRD", "page": 147 } + ], + "description": { + "entries": [ + "You touch one willing creature. Once before the spell ends, the target can roll a {@dice d4} and add the number rolled to one ability check of its choice. It can roll the die before or after making the ability check. The spell then ends." + ] + } + }, + { + "name": "Gust", + "school": "transmutation", + "level": 0, + "ritual": false, + "casting_time": { + "amount": 1, + "unit": "action" + }, + "range": { + "type": "point", + "amount": 30, + "unit": "feet" + }, + "components": { + "verbal": true, + "somatic": true, + "material": false + }, + "durations": [ + { + "type": "instantaneous" + } + ], + "classes": ["druid", "sorcerer", "wizard"], + "sources": [ + { "source": "EEPC", "page": 18 }, + { "source": "XGE", "page": 157 } + ], + "description": { + "entries": [ + "You seize the air and compel it to create one of the following effects at a point you can see within range:", + { + "type": "list", + "items": [ + "One Medium or smaller creature that you choose must succeed on a Strength saving throw or be pushed up to 5 feet away from you.", + "You create a small blast of air capable of moving one object that is neither held nor carried and that weighs no more than 5 pounds. The object is pushed up to 10 feet away from you. It isn't pushed with enough force to cause damage.", + "You create a harmless sensory effect using air, such as causing leaves to rustle, wind to slam shutters shut, or your clothing to ripple in a breeze." + ] + } + ] + } + }, + { + "name": "Infestation", + "school": "conjuration", + "level": 0, + "ritual": false, + "casting_time": { + "amount": 1, + "unit": "action" + }, + "range": { + "type": "point", + "amount": 30, + "unit": "feet" + }, + "saving_throw": [ + "constitution" + ], + "damage_inflict": [ + "poison" + ], + "components": { + "verbal": true, + "somatic": true, + "material": true, + "materials_needed": "a living flea" + }, + "durations": [ + { + "type": "instantaneous" + } + ], + "classes": ["druid", "sorcerer", "warlock", "wizard"], + "sources": [ + { "source": "XGE", "page": 158 } + ], + "description": { + "entries": [ + "You cause a cloud of mites, fleas, and other parasites to appear momentarily on one creature you can see within range. The target must succeed on a Constitution saving throw, or it takes {@damage 1d6} poison damage and moves 5 feet in a random direction if it can move and its speed is at least 5 feet. Roll a {@dice d4} for the direction: 1 north, 2 south, 3 east, or 4 west. This movement doesn't provoke opportunity attacks, and if the direction rolled is blocked, the target doesn't move.", + "The spell's damage increases by {@damage 1d6} when you reach 5th level ({@damage 2d6}), 11th level ({@damage 3d6}), and 17th level ({@damage 4d6})." + ] + } + }, + { + "name": "Light", + "school": "evocation", + "level": 0, + "ritual": false, + "casting_time": { + "amount": 1, + "unit": "action" + }, + "range": { + "type": "touch" + }, + "area": { + "type": "sphere", + "amount": 20, + "unit": "feet" + }, + "saving_throw": [ + "dexterity" + ], + "components": { + "verbal": true, + "somatic": false, + "material": true, + "materials_needed": "a firefly or phosphorescent moss" + }, + "durations": [ + { + "type": "timed", + "amount": 1, + "unit": "hour" + } + ], + "classes": ["artificer", "bard", "cleric", "sorcerer", "wizard"], + "sources": [ + { "source": "PHB", "page": 255 }, + { "source": "SRD", "page": 150 } + ], + "description": { + "entries": [ + "You touch one object that is no larger than 10 feet in any dimension. Until the spell ends, the object sheds bright light in a 20-foot radius and dim light for an additional 20 feet. The light can be colored as you like. Completely covering the object with something opaque blocks the light. The spell ends if you cast it again or dismiss it as an action.", + "If you target an object held or worn by a hostile creature, that creature must succeed on a Dexterity saving throw to avoid the spell." + ] + } + }, + { + "name": "Lightning Lure", + "school": "evocation", + "level": 0, + "ritual": false, + "casting_time": { + "amount": 1, + "unit": "action" + }, + "range": { + "type": "self", + "amount": 15, + "unit": "feet" + }, + "saving_throw": [ + "strength" + ], + "damage_inflict": [ + "lightning" + ], + "components": { + "verbal": true, + "somatic": false, + "material": false + }, + "durations": [ + { + "type": "instantaneous" + } + ], + "classes": ["artificer", "sorcerer", "warlock", "wizard"], + "sources": [ + { "source": "SCAG", "page": 143 }, + { "source": "TCE", "page": 107 } + ], + "description": { + "entries": [ + "You create a lash of lightning energy that strikes at one creature of your choice that you can see within range. The target must succeed on a Strength saving throw or be pulled up to 10 feet in a straight line toward you and then take {@damage 1d8} lightning damage if it is within 5 feet of you.", + "This spell's damage increases by {@damage 1d8} when you reach 5th level ({@damage 2d8}), 11th level ({@damage 3d8}), and 17th level ({@damage 4d8})." + ] + } + }, + { + "name": "Mage Hand", + "school": "conjuration", + "level": 0, + "ritual": false, + "casting_time": { + "amount": 1, + "unit": "action" + }, + "range": { + "type": "point", + "amount": 30, + "unit": "feet" + }, + "components": { + "verbal": true, + "somatic": true, + "material": false + }, + "durations": [ + { + "type": "timed", + "amount": 1, + "unit": "minutes" + } + ], + "classes": ["artificer", "bard", "sorcerer", "warlock", "wizard"], + "sources": [ + { "source": "PHB", "page": 256 }, + { "source": "SRD", "page": 151 } + ], + "description": { + "entries": [ + "A spectral, floating hand appears at a point you choose within range. The hand lasts for the duration or until you dismiss it as an action. The hand vanishes if it is ever more than 30 feet away from you or if you cast this spell again.", + "You can use your action to control the hand. You can use the hand to manipulate an object, open an unlocked door or container, stow or retrieve an item from an open container, or pour the contents out of a vial. You can move the hand up to 30 feet each time you use it.", + "The hand can't attack, activate magic items, or carry more than 10 pounds." + ] + } + }, + { + "name": "Magic Stone", + "school": "transmutation", + "level": 0, + "ritual": false, + "casting_time": { + "amount": 1, + "unit": "bonus" + }, + "range": { + "type": "touch" + }, + "damage_inflict": [ + "bludgeoning" + ], + "attack_type": "ranged", + "components": { + "verbal": true, + "somatic": true, + "material": false + }, + "durations": [ + { + "type": "timed", + "amount": 1, + "unit": "minutes" + } + ], + "classes": ["artificer", "druid", "warlock"], + "sources": [ + { "source": "EEPC", "page": 160 } + ], + "description": { + "entries": [ + "You touch one to three pebbles and imbue them with magic. You or someone else can make a ranged spell attack with one of the pebbles by throwing it or hurling it with a sling. If thrown, it has a range of 60 feet. If someone else attacks with the pebble, that attacker adds your spellcasting ability modifier, not the attacker's, to the attack roll. On a hit, the target takes bludgeoning damage equal to {@damage 1d6} + your spellcasting ability modifier. Hit or miss, the spell then ends on the stone.", + "If you cast this spell again, the spell ends early on any pebbles still affected by it." + ] + } + }, + { + "name": "Mending", + "school": "transmutation", + "level": 0, + "ritual": false, + "casting_time": { + "amount": 1, + "unit": "minutes" + }, + "range": { + "type": "touch" + }, + "components": { + "verbal": true, + "somatic": true, + "material": true, + "materials_needed": "two lodestones" + }, + "durations": [ + { + "type": "instantaneous" + } + ], + "classes": ["artificer", "bard", "cleric", "druid", "sorcerer", "wizard"], + "sources": [ + { "source": "PHB", "page": 259 }, + { "source": "SRD", "page": 152 } + ], + "description": { + "entries": [ + "This spell repairs a single break or tear in an object you touch, such as a broken chain link, two halves of a broken key, a torn cloak, or a leaking wineskin. As long as the break or tear is no larger than 1 foot in any dimension, you mend it, leaving no trace of the former damage.", + "This spell can physically repair a magic item or construct, but the spell can't restore magic to such an object." + ] + } + }, + { + "name": "Message", + "school": "transmutation", + "level": 0, + "ritual": false, + "casting_time": { + "amount": 1, + "unit": "action" + }, + "range": { + "type": "point", + "amount": 120, + "unit": "feet" + }, + "components": { + "verbal": true, + "somatic": true, + "material": true, + "materials_needed": "a short piece of copper wire" + }, + "durations": [ + { + "type": "timed", + "amount": 1, + "unit": "round" + } + ], + "classes": ["artificer", "bard", "sorcerer", "wizard"], + "sources": [ + { "source": "PHB", "page": 259 }, + { "source": "SRD", "page": 152 } + ], + "description": { + "entries": [ + "You point your finger toward a creature within range and whisper a message. The target (and only the target) hears the message and can reply in a whisper that only you can hear.", + "You can cast this spell through solid objects if you are familiar with the target and know it is beyond the barrier. Magical silence, 1 foot of stone, 1 inch of common metal, a thin sheet of lead, or 3 feet of wood blocks the spell. The spell doesn't have to follow a straight line and can travel freely around corners or through openings." + ] + } + }, + { + "name": "Mind Sliver", + "school": "enchantment", + "level": 0, + "ritual": false, + "casting_time": { + "amount": 1, + "unit": "action" + }, + "range": { + "type": "point", + "amount": 60, + "unit": "feet" + }, + "saving_throw": [ + "intelligence" + ], + "damage_inflict": [ + "psychic" + ], + "components": { + "verbal": true, + "somatic": false, + "material": false + }, + "durations": [ + { + "type": "instantaneous" + } + ], + "classes": ["sorcerer", "warlock", "wizard"], + "sources": [ + { "source": "TCE", "page": 108 } + ], + "description": { + "entries": [ + "You drive a disorienting spike of psychic energy into the mind of one creature you can see within range. The target must succeed on an Intelligence saving throw or take {@damage 1d6} psychic damage and subtract {@damage 1d4} from the next saving throw it makes before the end of your next turn.", + "This spell's damage increases by {@damage 1d6} when you reach certain levels: 5th level ({@damage 2d6}), 11th level ({@damage 3d6}), and 17th level ({@damage 4d6})." + ] + } + }, + { + "name": "Minor Illusion", + "school": "illusion", + "level": 0, + "ritual": false, + "casting_time": { + "amount": 1, + "unit": "action" + }, + "range": { + "type": "point", + "amount": 30, + "unit": "feet" + }, + "components": { + "verbal": false, + "somatic": true, + "material": true, + "materials_needed": "a bit of fleece" + }, + "durations": [ + { + "type": "timed", + "amount": 1, + "unit": "minutes" + } + ], + "classes": ["bard", "sorcerer", "warlock", "wizard"], + "sources": [ + { "source": "PHB", "page": 260 }, + { "source": "SRD", "page": 153 } + ], + "description": { + "entries": [ + "You create a sound or an image of an object within range that lasts for the duration. The illusion also ends if you dismiss it as an action or cast this spell again.", + "If you create a sound, its volume can range from a whisper to a scream. It can be your voice, someone else's voice, a lion's roar, a beating of drums, or any other sound you choose. The sound continues unabated throughout the duration, or you can make discrete sounds at different times before the spell ends.", + "If you create an image of an object--such as a chair, muddy footprints, or a small chest--it must be no larger than a 5-foot cube. The image can't create sound, light, smell, or any other sensory effect. Physical interaction with the image reveals it to be an illusion, because things can pass through it.", + "If a creature uses its action to examine the sound or image, the creature can determine that it is an illusion with a successful Intelligence ({@ability Investigation}) check against your spell save DC. If a creature discerns the illusion for what it is, the illusion becomes faint to the creature." + ] + } + }, + { + "name": "Mold Earth", + "school": "transmutation", + "level": 0, + "ritual": false, + "casting_time": { + "amount": 1, + "unit": "action" + }, + "range": { + "type": "point", + "amount": 30, + "unit": "feet" + }, + "area": { + "type": "cube", + "amount": 5, + "unit": "feet" + }, + "components": { + "verbal": false, + "somatic": true, + "material": false + }, + "durations": [ + { + "type": "instantaneous" + }, + { + "type": "timed", + "amount": 1, + "unit": "hour" + } + ], + "classes": ["druid", "sorcerer", "wizard"], + "sources": [ + { "source": "EEPC", "page": 162 } + ], + "description": { + "entries": [ + "You choose a portion of dirt or stone that you can see within range and that fits within a 5-foot cube. You manipulate it in one of the following ways:", + { + "type": "list", + "items": [ + "If you target an area of loose earth, you can instantaneously excavate it, move it along the ground, and deposit it up to 5 feet away. This movement doesn't have enough force to cause damage.", + "You cause shapes, colors, or both to appear on the dirt or stone, spelling out words, creating images, or shaping patterns. The changes last for 1 hour.", + "If the dirt or stone you target is on the ground, you cause it to become difficult terrain. Alternatively, you can cause the ground to become normal terrain if it is already difficult terrain. This change lasts for 1 hour." + ] + }, + "If you cast this spell multiple times, you can have no more than two of its non-instantaneous effects active at a time, and you can dismiss such an effect as an action." + ] + } + }, + { + "name": "Poison Spray", + "school": "conjuration", + "level": 0, + "ritual": false, + "casting_time": { + "amount": 1, + "unit": "action" + }, + "range": { + "type": "point", + "amount": 10, + "unit": "feet" + }, + "saving_throw": [ + "constitution" + ], + "damage_inflict": [ + "poison" + ], + "components": { + "verbal": true, + "somatic": true, + "material": false + }, + "attack_type": "ranged", + "durations": [ + { + "type": "instantaneous" + } + ], + "classes": ["artificer", "druid", "sorcerer", "warlock", "wizard"], + "sources": [ + { "source": "PHB", "page": 266 }, + { "source": "SRD", "page": 155 } + ], + "description": { + "entries": [ + "You extend your hand toward a creature you can see within range and project a puff of noxious gas from your palm. The creature must succeed on a Constitution saving throw or take {@damage 1d12} poison damage.", + "This spell's damage increases by {@damage 1d12} when you reach 5th level ({@damage 2d12}), 11th level ({@damage 3d12}), and 17th level ({@damage 4d12})." + ] + } + }, + { + "name": "Prestidigitation", + "school": "transmutation", + "level": 0, + "ritual": false, + "casting_time": { + "unit": "action", + "amount": 1 + }, + "range": { + "type": "point", + "amount": 10, + "unit": "feet" + }, + "components": { + "verbal": true, + "somatic": true, + "material": false + }, + "durations": [ + { + "type": "timed", + "amount": 1, + "unit": "hour" + } + ], + "classes": ["artificer", "bard", "sorcerer", "warlock", "wizard"], + "sources": [ + { "source": "PHB", "page": 267 }, + { "source": "SRD", "page": 156 } + ], + "description": { + "entries": [ + "This spell is a minor magical trick that novice spellcasters use for practice. You create one of the following magical effects within range:", + { + "type": "list", + "items": [ + "You create an instantaneous, harmless sensory effect, such as a shower of sparks, a puff of wind, faint musical notes, or an odd odor.", + "You instantaneously light or snuff out a candle, a torch, or a small campfire.", + "You instantaneously clean or soil an object no larger than 1 cubic foot.", + "You chill, warm, or flavor up to 1 cubic foot of nonliving material for 1 hour.", + "You make a color, a small mark, or a symbol appear on an object or a surface for 1 hour.", + "You create a nonmagical trinket or an illusory image that can fit in your hand and that lasts until the end of your next turn." + ] + }, + "If you cast this spell multiple times, you can have up to three of its non-instantaneous effects active at a time, and you can dismiss such an effect as an action." + ] + } + }, + { + "name": "Primal Savagery", + "school": "transmutation", + "level": 0, + "ritual": false, + "casting_time": { + "unit": "action", + "amount": 1 + }, + "range": { + "type": "self" + }, + "damage_inflict": [ + "acid" + ], + "attack_type": "melee", + "components": { + "verbal": false, + "somatic": true, + "material": false + }, + "durations": [ + { + "type": "instantaneous" + } + ], + "classes": ["druid"], + "sources": [ + { "source": "XGE", "page": 163 } + ], + "description": { + "entries": [ + "You channel primal magic to cause your teeth or fingernails to sharpen, ready to deliver a corrosive attack. Make a melee spell attack against one creature within 5 feet of you. On a hit, the target takes {@damage 1d10} acid damage. After you make the attack, your teeth or fingernails return to normal.", + "The spell's damage increases by {@damage 1d10} when you reach 5th level ({@damage 2d10}), 11th level ({@damage 3d10}), and 17th level ({@damage 4d10})." + ] + } + }, + { + "name": "Produce Flame", + "school": "conjuration", + "level": 0, + "ritual": false, + "casting_time": { + "unit": "action", + "amount": 1 + }, + "range": { + "type": "self" + }, + "damage_inflict": [ + "fire" + ], + "attack_type": "ranged", + "components": { + "verbal": true, + "somatic": true, + "material": false + }, + "durations": [ + { + "type": "timed", + "amount": 10, + "unit": "minutes" + } + ], + "classes": ["druid"], + "sources": [ + { "source": "PHB", "page": 269 }, + { "source": "SRD", "page": 157 } + ], + "description": { + "entries": [ + "A flickering flame appears in your hand. The flame remains there for the duration and harms neither you nor your equipment. The flame sheds bright light in a 10-foot radius and dim light for an additional 10 feet. The spell ends if you dismiss it as an action or if you cast it again.", + "You can also attack with the flame, although doing so ends the spell. When you cast this spell, or as an action on a later turn, you can hurl the flame at a creature within 30 feet of you. Make a ranged spell attack. On a hit, the target takes {@damage 1d8} fire damage.", + "This spell's damage increases by {@damage 1d8} when you reach 5th level ({@damage 2d8}), 11th level ({@damage 3d8}), and 17th level ({@damage 4d8})." + ] + } + }, + { + "name": "Ray of Frost", + "school": "evocation", + "level": 0, + "ritual": false, + "casting_time": { + "unit": "action", + "amount": 1 + }, + "range": { + "type": "point", + "amount": 60, + "unit": "feet" + }, + "damage_inflict": [ + "cold" + ], + "attack_type": "ranged", + "components": { + "verbal": true, + "somatic": true, + "material": false + }, + "durations": [ + { + "type": "instantaneous" + } + ], + "classes": ["artificer", "sorcerer", "wizard"], + "sources": [ + { "source": "PHB", "page": 271 }, + { "source": "SRD", "page": 158 } + ], + "description": { + "entries": [ + "A frigid beam of blue-white light streaks toward a creature within range. Make a ranged spell attack against the target. On a hit, it takes {@damage 1d8} cold damage, and its speed is reduced by 10 feet until the start of your next turn.", + "The spell's damage increases by {@damage 1d8} when you reach 5th level ({@damage 2d8}), 11th level ({@damage 3d8}), and 17th level ({@damage 4d8})." + ] + } + }, + { + "name": "Resistance", + "school": "abjuration", + "level": 0, + "ritual": false, + "casting_time": { + "unit": "action", + "amount": 1 + }, + "range": { + "type": "touch" + }, + "components": { + "verbal": true, + "somatic": true, + "material": true, + "materials_needed": "a miniature cloak" + }, + "durations": [ + { + "type": "concentration", + "amount": 1, + "unit": "round" + } + ], + "classes": ["artificer", "cleric", "druid"], + "sources": [ + { "source": "PHB", "page": 272 }, + { "source": "SRD", "page": 159 } + ], + "description": { + "entries": [ + "You touch one willing creature. Once before the spell ends, the target can roll a {@dice d4} and add the number rolled to one saving throw of its choice. It can roll the die before or after making the saving throw. The spell then ends." + ] + } + }, + { + "name": "Sacred Flame", + "school": "evocation", + "level": 0, + "ritual": false, + "casting_time": { + "amount": 1, + "unit": "action" + }, + "range": { + "type": "point", + "amount": 60, + "unit": "feet" + }, + "saving_throw": [ + "dexterity" + ], + "damage_inflict": [ + "radiant" + ], + "components": { + "verbal": true, + "somatic": true, + "material": false + }, + "durations": [ + { + "type": "instantaneous" + } + ], + "classes": ["cleric"], + "sources": [ + { "source": "PHB", "page": 272 }, + { "source": "SRD", "page": 159 } + ], + "description": { + "entries": [ + "Flame-like radiance descends on a creature that you can see within range. The target must succeed on a Dexterity saving throw or take {@damage 1d8} radiant damage. The target gains no benefit from cover for this saving throw.", + "The spell's damage increases by {@damage 1d8} when you reach 5th level ({@damage 2d8}), 11th level ({@damage 3d8}), and 17th level ({@damage 4d8})." + ] + } + }, + { + "name": "Sapping Sting", + "school": "necromancy", + "level": 0, + "ritual": false, + "casting_time": { + "amount": 1, + "unit": "action" + }, + "damage_inflict": [ + "necrotic" + ], + "saving_throw": [ + "constitution" + ], + "range": { + "type": "point", + "amount": 30, + "unit": "feet" + }, + "components": { + "verbal": true, + "somatic": true, + "material": false + }, + "durations": [ + { + "type": "instantaneous" + } + ], + "classes": [], + "sources": [ + { "source": "EGW", "page": 189 } + ], + "description": { + "entries": [ + "You sap the vitality of one creature you can see in range. The target must succeed on a Constitution saving throw or take {@damage 1d4} necrotic damage and fall {@condition prone}.", + "This spell's damage increases by {@damage 1d4} when you reach 5th level ({@damage 2d4}), 11th level ({@damage 3d4}), and 17th level ({@damage 4d4})." + ] + } + }, + { + "name": "Shape Water", + "school": "transmutation", + "level": 0, + "ritual": false, + "casting_time": { + "amount": 1, + "unit": "action" + }, + "range": { + "type": "point", + "amount": 30, + "unit": "feet" + }, + "area": { + "type": "cube", + "amount": 5, + "unit": "feet" + }, + "components": { + "verbal": false, + "somatic": true, + "material": false + }, + "durations": [ + { + "type": "instantaneous" + }, + { + "type": "timed", + "amount": 1, + "unit": "hour" + } + ], + "classes": ["druid", "sorcerer", "wizard"], + "sources": [ + { "source": "EEPC", "page": 164 } + ], + "description": { + "entries": [ + "You choose an area of water that you can see within range and that fits within a 5-foot cube. You manipulate it in one of the following ways:", + { + "type": "list", + "items": [ + "You instantaneously move or otherwise change the flow of the water as you direct, up to 5 feet in any direction. This movement doesn't have enough force to cause damage.", + "You cause the water to form into simple shapes and animate at your direction. This change lasts for 1 hour.", + "You change the water's color or opacity. The water must be changed in the same way throughout. This change lasts for 1 hour.", + "You freeze the water, provided that there are no creatures in it. The water unfreezes in 1 hour." + ] + }, + "If you cast this spell multiple times, you can have no more than two of its non-instantaneous effects active at a time, and you can dismiss such an effect as an action." + ] + } + }, + { + "name": "Shillelagh", + "school": "transmutation", + "level": 0, + "ritual": false, + "casting_time": { + "amount": 1, + "unit": "bonus" + }, + "range": { + "type": "touch" + }, + "damage_inflict": [ + "bludgeoning" + ], + "attack_type": "melee", + "components": { + "verbal": true, + "somatic": true, + "material": true, + "materials_needed": "mistletoe, a shamrock leaf, and a club or quarterstaff" + }, + "durations": [ + { + "type": "timed", + "amount": 1, + "unit": "minutes" + } + ], + "classes": ["druid"], + "sources": [ + { "source": "PHB", "page": 275 }, + { "source": "SRD", "page": 160 } + ], + "description": { + "entries": [ + "The wood of a club or quarterstaff you are holding is imbued with nature's power. For the duration, you can use your spellcasting ability instead of Strength for the attack and damage rolls of melee attacks using that weapon, and the weapon's damage die becomes a {@dice d8}. The weapon also becomes magical, if it isn't already. The spell ends if you cast it again or if you let go of the weapon." + ] + } + }, + { + "name": "Shocking Grasp", + "school": "evocation", + "level": 0, + "ritual": false, + "casting_time": { + "amount": 1, + "unit": "action" + }, + "damage_inflict": [ + "lightning" + ], + "attack_type": "melee", + "range": { + "type": "self" + }, + "components": { + "verbal": true, + "somatic": true, + "material": false + }, + "durations": [ + { + "type": "instantaneous" + } + ], + "classes": ["artificer", "sorcerer", "wizard"], + "sources": [ + { "source": "PHB", "page": 275 }, + { "source": "SRD", "page": 160 } + ], + "description": { + "entries": [ + "Lightning springs from your hand to deliver a shock to a creature you try to touch. Make a melee spell attack against the target. You have advantage on the attack roll if the target is wearing armor made of metal. On a hit, the target takes {@damage 1d8} lightning damage, and it can't take reactions until the start of its next turn.", + "The spell's damage increases by {@damage 1d8} when you reach 5th level ({@damage 2d8}), 11th level ({@damage 3d8}), and 17th level ({@damage 4d8})." + ] + } + }, + { + "name": "Spare the Dying", + "school": "necromancy", + "level": 0, + "ritual": false, + "casting_time": { + "amount": 1, + "unit": "action" + }, + "range": { + "type": "touch" + }, + "components": { + "verbal": true, + "somatic": true, + "material": false + }, + "durations": [ + { + "type": "instantaneous" + } + ], + "classes": ["artificer", "cleric"], + "sources": [ + { "source": "PHB", "page": 277 }, + { "source": "SRD", "page": 161 } + ], + "description": { + "entries": [ + "You touch a living creature that has 0 hit points. The creature becomes stable. This spell has no effect on undead or constructs." + ] + } + }, + { + "name": "Sword Burst", + "school": "conjuration", + "level": 0, + "ritual": false, + "casting_time": { + "amount": 1, + "unit": "action" + }, + "damage_inflict": [ + "force" + ], + "saving_throw": [ + "dexterity" + ], + "range": { + "type": "self", + "amount": 5, + "unit": "feet" + }, + "components": { + "verbal": true, + "somatic": false, + "material": false + }, + "durations": [ + { + "type": "instantaneous" + } + ], + "classes": ["artificer", "sorcerer", "warlock", "wizard"], + "sources": [ + { "source": "SCAG", "page": 143 } + ], + "description": { + "entries": [ + "You create a momentary circle of spectral blades that sweep around you. Each creature within range, other than you, must succeed on a Dexterity saving throw or take {@damage 1d6} force damage.", + "This spell's damage increases by {@damage 1d6} when you reach 5th level ({@damage 2d6}), 11th level ({@damage 3d6}), and 17th level ({@damage 4d6})." + ] + } + }, + { + "name": "Thaumaturgy", + "school": "transmutation", + "level": 0, + "ritual": false, + "casting_time": { + "amount": 1, + "unit": "action" + }, + "range": { + "type": "point", + "amount": 30, + "unit": "feet" + }, + "components": { + "verbal": true, + "somatic": false, + "material": false + }, + "durations": [ + { + "type": "timed", + "amount": 1, + "unit": "minutes" + } + ], + "classes": ["cleric"], + "sources": [ + { "source": "PHB", "page": 282 }, + { "source": "SRD", "page": 162 } + ], + "description": { + "entries": [ + "You manifest a minor wonder, a sign of supernatural power, within range. You create one of the following magical effects within range:", + { + "type": "list", + "items": [ + "Your voice booms up to three times as loud as normal for 1 minutes.", + "You cause flames to flicker, brighten, dim, or change color for 1 minutes.", + "You cause harmless tremors in the ground for 1 minutes.", + "You create an instantaneous sound that originates from a point of your choice within range, such as a rumble of thunder, the cry of a raven, or ominous whispers.", + "You instantaneously cause an unlocked door or window to fly open or slam shut.", + "You alter the appearance of your eyes for 1 minutes." + ] + }, + "If you cast this spell multiple times, you can have up to three of its 1-minutes effects active at a time, and you can dismiss such an effect as an action." + ] + } + }, + { + "name": "Thorn Whip", + "school": "transmutation", + "level": 0, + "ritual": false, + "casting_time": { + "amount": 1, + "unit": "action" + }, + "damage_inflict": [ + "piercing" + ], + "attack_type": "melee", + "range": { + "type": "point", + "amount": 30, + "unit": "feet" + }, + "components": { + "verbal": true, + "somatic": true, + "material": true, + "materials_needed": "the stem of a plant with thorns" + }, + "durations": [ + { + "type": "instantaneous" + } + ], + "classes": ["artificer", "druid"], + "sources": [ + { "source": "PHB", "page": 282 }, + { "source": "SRD", "page": 162 } + ], + "description": { + "entries": [ + "You create a long, vine-like whip covered in thorns that lashes out at your command toward a creature in range. Make a melee spell attack against the target. If the attack hits, the creature takes {@damage 1d6} piercing damage, and if the creature is Large or smaller, you pull the creature up to 10 feet closer to you.", + "The spell's damage increases by {@damage 1d6} when you reach 5th level ({@damage 2d6}), 11th level ({@damage 3d6}), and 17th level ({@damage 4d6})." + ] + } + }, + { + "name": "Thunderclap", + "school": "evocation", + "level": 0, + "ritual": false, + "casting_time": { + "amount": 1, + "unit": "action" + }, + "damage_inflict": [ + "thunder" + ], + "saving_throw": [ + "constitution" + ], + "range": { + "type": "point", + "amount": 5, + "unit": "feet" + }, + "components": { + "verbal": true, + "somatic": false, + "material": false + }, + "durations": [ + { + "type": "instantaneous" + } + ], + "classes": ["artificer", "bard", "druid", "sorcerer", "warlock", "wizard"], + "sources": [ + { "source": "EEPC", "page": 168 } + ], + "description": { + "entries": [ + "You create a burst of thunderous sound that can be heard up to 100 feet away. Each creature within range, other than you, must succeed on a Constitution saving throw or take {@damage 1d6} thunder damage.", + "The spell's damage increases by {@damage 1d6} when you reach 5th level ({@damage 2d6}), 11th level ({@damage 3d6}), and 17th level ({@damage 4d6})." + ] + } + }, + { + "name": "Toll the Dead", + "school": "necromancy", + "level": 0, + "ritual": false, + "casting_time": { + "amount": 1, + "unit": "action" + }, + "damage_inflict": [ + "necrotic" + ], + "saving_throw": [ + "wisdom" + ], + "range": { + "type": "point", + "amount": 60, + "unit": "feet" + }, + "components": { + "verbal": true, + "somatic": true, + "material": false + }, + "durations": [ + { + "type": "instantaneous" + } + ], + "classes": ["cleric", "warlock", "wizard"], + "sources": [ + { "source": "XGE", "page": 169 } + ], + "description": { + "entries": [ + "You point at one creature you can see within range, and the sound of a dolorous bell fills the air around it for a moment. The target must succeed on a Wisdom saving throw or take {@damage 1d8} necrotic damage. If the target is missing any of its hit points, it instead takes {@damage 1d12} necrotic damage.", + "The spell's damage increases by one die when you reach 5th level ({@damage 2d8} or {@damage 2d12}), 11th level ({@damage 3d8} or {@damage 3d12}), and 17th level ({@damage 4d8} or {@damage 4d12})." + ] + } + }, + { + "name": "True Strike", + "school": "divination", + "level": 0, + "ritual": false, + "casting_time": { + "amount": 1, + "unit": "action" + }, + "range": { + "type": "point", + "amount": 30, + "unit": "feet" + }, + "components": { + "verbal": false, + "somatic": true, + "material": false + }, + "durations": [ + { + "type": "concentration", + "amount": 1, + "unit": "round" + } + ], + "classes": ["bard", "sorcerer", "warlock", "wizard"], + "sources": [ + { "source": "PHB", "page": 284 }, + { "source": "SRD", "page": 163 } + ], + "description": { + "entries": [ + "You extend your hand and point a finger at a target in range. Your magic grants you a brief insight into the target's defenses. On your next turn, you gain advantage on your first attack roll against the target, provided that this spell hasn't ended." + ] + } + }, + { + "name": "Vicious Mockery", + "school": "enchantment", + "level": 0, + "ritual": false, + "casting_time": { + "amount": 1, + "unit": "action" + }, + "damage_inflict": [ + "psychic" + ], + "saving_throw": [ + "wisdom" + ], + "range": { + "type": "point", + "amount": 60, + "unit": "feet" + }, + "components": { + "verbal": true, + "somatic": false, + "material": false + }, + "durations": [ + { + "type": "instantaneous" + } + ], + "classes": ["bard"], + "sources": [ + { "source": "PHB", "page": 285 }, + { "source": "SRD", "page": 163 } + ], + "description": { + "entries": [ + "You unleash a string of insults laced with subtle enchantments at a creature you can see within range. If the target can hear you (though it need not understand you), it must succeed on a Wisdom saving throw or take {@damage 1d4} psychic damage and have disadvantage on the next attack roll it makes before the end of its next turn.", + "This spell's damage increases by {@damage 1d4} when you reach 5th level ({@damage 2d4}), 11th level ({@damage 3d4}), and 17th level ({@damage 4d4})." + ] + } + }, + { + "name": "Word of Radiance", + "school": "evocation", + "level": 0, + "ritual": false, + "casting_time": { + "unit": "action", + "amount": 1 + }, + "damage_inflict": [ + "radiant" + ], + "saving_throw": [ + "constitution" + ], + "range": { + "type": "point", + "amount": 5, + "unit": "feet" + }, + "components": { + "verbal": true, + "somatic": false, + "material": true, + "materials_needed": "a holy symbol" + }, + "durations": [ + { + "type": "instantaneous" + } + ], + "classes": ["cleric"], + "sources": [ + { "source": "XGE", "page": 171 } + ], + "description": { + "entries": [ + "You utter a divine word, and burning radiance erupts from you. Each creature of your choice that you can see within range must succeed on a Constitution saving throw or take {@damage 1d6} radiant damage.", + "The spell's damage increases by {@damage 1d6} when you reach 5th level ({@damage 2d6}), 11th level ({@damage 3d6}), and 17th level ({@damage 4d6})." + ] + } } ] \ No newline at end of file From 06f8af6051464bc8e9b42626c7a4ca2d4cbe71cd Mon Sep 17 00:00:00 2001 From: Benjamin Sherriff Date: Wed, 4 Oct 2023 16:52:09 -0400 Subject: [PATCH 12/14] Refactored into service directory --- .dockerignore => service/.dockerignore | 0 .env.TEMPLATE => service/.env.TEMPLATE | 1 + .version => service/.version | 0 Cargo.toml => service/Cargo.toml | 0 Dockerfile => service/Dockerfile | 0 Makefile => service/Makefile | 0 build.rs => service/build.rs | 0 {data => service/data}/spells/cantrips.json | 0 {data => service/data}/spells/level_1.json | 0 .../docker-compose.yml | 11 +- .../000000_create_messages/down.sql | 0 .../migrations}/000000_create_messages/up.sql | 0 .../migrations}/000001_create_races/down.sql | 0 .../migrations}/000001_create_races/up.sql | 0 .../000002_create_classes/down.sql | 0 .../migrations}/000002_create_classes/up.sql | 0 .../migrations}/000003_create_feats/down.sql | 0 .../migrations}/000003_create_feats/up.sql | 0 .../000004_create_options_features/down.sql | 0 .../000004_create_options_features/up.sql | 0 .../000005_create_backgrounds/down.sql | 0 .../000005_create_backgrounds/up.sql | 0 .../migrations}/000006_create_items/down.sql | 0 .../migrations}/000006_create_items/up.sql | 0 .../migrations}/000007_create_spells/down.sql | 0 .../migrations}/000007_create_spells/up.sql | 0 .../000008_create_conditions/down.sql | 0 .../000008_create_conditions/up.sql | 0 .../000009_create_bestiary/down.sql | 0 .../migrations}/000009_create_bestiary/up.sql | 0 {src => service/src}/commands/help.rs | 0 {src => service/src}/commands/mod.rs | 0 {src => service/src}/commands/oai.rs | 0 {src => service/src}/commands/ping.rs | 0 {src => service/src}/commands/schedule.rs | 0 {src => service/src}/db/backgrounds/mod.rs | 0 {src => service/src}/db/bestiary/mod.rs | 0 {src => service/src}/db/classes/mod.rs | 0 {src => service/src}/db/classes/model.rs | 0 {src => service/src}/db/conditions/mod.rs | 0 {src => service/src}/db/feats/mod.rs | 0 {src => service/src}/db/items/mod.rs | 0 {src => service/src}/db/messages/mod.rs | 0 {src => service/src}/db/messages/model.rs | 0 {src => service/src}/db/mod.rs | 0 {src => service/src}/db/options/mod.rs | 0 {src => service/src}/db/races/mod.rs | 0 {src => service/src}/db/schema.rs | 0 {src => service/src}/db/spells/mod.rs | 0 {src => service/src}/db/spells/model.rs | 0 {src => service/src}/db/spells/routes.rs | 0 {src => service/src}/db/spells/types.rs | 0 {src => service/src}/db/users/mod.rs | 0 {src => service/src}/db/users/model.rs | 0 {src => service/src}/error_handler.rs | 0 {src => service/src}/main.rs | 0 src/commands/audio/mod.rs | 190 ------------------ src/commands/audio/pause.rs | 43 ---- src/commands/audio/play.rs | 134 ------------ src/commands/audio/resume.rs | 43 ---- src/commands/audio/skip.rs | 43 ---- src/commands/audio/stop.rs | 38 ---- src/commands/audio/volume.rs | 85 -------- 63 files changed, 11 insertions(+), 577 deletions(-) rename .dockerignore => service/.dockerignore (100%) rename .env.TEMPLATE => service/.env.TEMPLATE (88%) rename .version => service/.version (100%) rename Cargo.toml => service/Cargo.toml (100%) rename Dockerfile => service/Dockerfile (100%) rename Makefile => service/Makefile (100%) rename build.rs => service/build.rs (100%) rename {data => service/data}/spells/cantrips.json (100%) rename {data => service/data}/spells/level_1.json (100%) rename docker-compose.yml => service/docker-compose.yml (86%) rename {migrations => service/migrations}/000000_create_messages/down.sql (100%) rename {migrations => service/migrations}/000000_create_messages/up.sql (100%) rename {migrations => service/migrations}/000001_create_races/down.sql (100%) rename {migrations => service/migrations}/000001_create_races/up.sql (100%) rename {migrations => service/migrations}/000002_create_classes/down.sql (100%) rename {migrations => service/migrations}/000002_create_classes/up.sql (100%) rename {migrations => service/migrations}/000003_create_feats/down.sql (100%) rename {migrations => service/migrations}/000003_create_feats/up.sql (100%) rename {migrations => service/migrations}/000004_create_options_features/down.sql (100%) rename {migrations => service/migrations}/000004_create_options_features/up.sql (100%) rename {migrations => service/migrations}/000005_create_backgrounds/down.sql (100%) rename {migrations => service/migrations}/000005_create_backgrounds/up.sql (100%) rename {migrations => service/migrations}/000006_create_items/down.sql (100%) rename {migrations => service/migrations}/000006_create_items/up.sql (100%) rename {migrations => service/migrations}/000007_create_spells/down.sql (100%) rename {migrations => service/migrations}/000007_create_spells/up.sql (100%) rename {migrations => service/migrations}/000008_create_conditions/down.sql (100%) rename {migrations => service/migrations}/000008_create_conditions/up.sql (100%) rename {migrations => service/migrations}/000009_create_bestiary/down.sql (100%) rename {migrations => service/migrations}/000009_create_bestiary/up.sql (100%) rename {src => service/src}/commands/help.rs (100%) rename {src => service/src}/commands/mod.rs (100%) rename {src => service/src}/commands/oai.rs (100%) rename {src => service/src}/commands/ping.rs (100%) rename {src => service/src}/commands/schedule.rs (100%) rename {src => service/src}/db/backgrounds/mod.rs (100%) rename {src => service/src}/db/bestiary/mod.rs (100%) rename {src => service/src}/db/classes/mod.rs (100%) rename {src => service/src}/db/classes/model.rs (100%) rename {src => service/src}/db/conditions/mod.rs (100%) rename {src => service/src}/db/feats/mod.rs (100%) rename {src => service/src}/db/items/mod.rs (100%) rename {src => service/src}/db/messages/mod.rs (100%) rename {src => service/src}/db/messages/model.rs (100%) rename {src => service/src}/db/mod.rs (100%) rename {src => service/src}/db/options/mod.rs (100%) rename {src => service/src}/db/races/mod.rs (100%) rename {src => service/src}/db/schema.rs (100%) rename {src => service/src}/db/spells/mod.rs (100%) rename {src => service/src}/db/spells/model.rs (100%) rename {src => service/src}/db/spells/routes.rs (100%) rename {src => service/src}/db/spells/types.rs (100%) rename {src => service/src}/db/users/mod.rs (100%) rename {src => service/src}/db/users/model.rs (100%) rename {src => service/src}/error_handler.rs (100%) rename {src => service/src}/main.rs (100%) delete mode 100644 src/commands/audio/mod.rs delete mode 100644 src/commands/audio/pause.rs delete mode 100644 src/commands/audio/play.rs delete mode 100644 src/commands/audio/resume.rs delete mode 100644 src/commands/audio/skip.rs delete mode 100644 src/commands/audio/stop.rs delete mode 100644 src/commands/audio/volume.rs diff --git a/.dockerignore b/service/.dockerignore similarity index 100% rename from .dockerignore rename to service/.dockerignore diff --git a/.env.TEMPLATE b/service/.env.TEMPLATE similarity index 88% rename from .env.TEMPLATE rename to service/.env.TEMPLATE index c933137..d8e2748 100644 --- a/.env.TEMPLATE +++ b/service/.env.TEMPLATE @@ -1,4 +1,5 @@ RUST_LOG=warn,siren=info +COMPOSE_PROJECT_NAME=siren DATABASE_USER=siren DATABASE_PASSWORD= diff --git a/.version b/service/.version similarity index 100% rename from .version rename to service/.version diff --git a/Cargo.toml b/service/Cargo.toml similarity index 100% rename from Cargo.toml rename to service/Cargo.toml diff --git a/Dockerfile b/service/Dockerfile similarity index 100% rename from Dockerfile rename to service/Dockerfile diff --git a/Makefile b/service/Makefile similarity index 100% rename from Makefile rename to service/Makefile diff --git a/build.rs b/service/build.rs similarity index 100% rename from build.rs rename to service/build.rs diff --git a/data/spells/cantrips.json b/service/data/spells/cantrips.json similarity index 100% rename from data/spells/cantrips.json rename to service/data/spells/cantrips.json diff --git a/data/spells/level_1.json b/service/data/spells/level_1.json similarity index 100% rename from data/spells/level_1.json rename to service/data/spells/level_1.json diff --git a/docker-compose.yml b/service/docker-compose.yml similarity index 86% rename from docker-compose.yml rename to service/docker-compose.yml index efc5f2e..286a197 100644 --- a/docker-compose.yml +++ b/service/docker-compose.yml @@ -1,4 +1,4 @@ -version: '3' +version: '3.8' services: siren: @@ -18,6 +18,9 @@ services: SERVICE_PORT: 5000 depends_on: - db + networks: + - frontend + - backend restart: unless-stopped db: image: postgres:latest @@ -33,8 +36,14 @@ services: - db_logs:/var/log ports: - ${DATABASE_PORT:-5432}:5432 + networks: + - backend restart: unless-stopped volumes: db: db_logs: + +networks: + frontend: + backend: diff --git a/migrations/000000_create_messages/down.sql b/service/migrations/000000_create_messages/down.sql similarity index 100% rename from migrations/000000_create_messages/down.sql rename to service/migrations/000000_create_messages/down.sql diff --git a/migrations/000000_create_messages/up.sql b/service/migrations/000000_create_messages/up.sql similarity index 100% rename from migrations/000000_create_messages/up.sql rename to service/migrations/000000_create_messages/up.sql diff --git a/migrations/000001_create_races/down.sql b/service/migrations/000001_create_races/down.sql similarity index 100% rename from migrations/000001_create_races/down.sql rename to service/migrations/000001_create_races/down.sql diff --git a/migrations/000001_create_races/up.sql b/service/migrations/000001_create_races/up.sql similarity index 100% rename from migrations/000001_create_races/up.sql rename to service/migrations/000001_create_races/up.sql diff --git a/migrations/000002_create_classes/down.sql b/service/migrations/000002_create_classes/down.sql similarity index 100% rename from migrations/000002_create_classes/down.sql rename to service/migrations/000002_create_classes/down.sql diff --git a/migrations/000002_create_classes/up.sql b/service/migrations/000002_create_classes/up.sql similarity index 100% rename from migrations/000002_create_classes/up.sql rename to service/migrations/000002_create_classes/up.sql diff --git a/migrations/000003_create_feats/down.sql b/service/migrations/000003_create_feats/down.sql similarity index 100% rename from migrations/000003_create_feats/down.sql rename to service/migrations/000003_create_feats/down.sql diff --git a/migrations/000003_create_feats/up.sql b/service/migrations/000003_create_feats/up.sql similarity index 100% rename from migrations/000003_create_feats/up.sql rename to service/migrations/000003_create_feats/up.sql diff --git a/migrations/000004_create_options_features/down.sql b/service/migrations/000004_create_options_features/down.sql similarity index 100% rename from migrations/000004_create_options_features/down.sql rename to service/migrations/000004_create_options_features/down.sql diff --git a/migrations/000004_create_options_features/up.sql b/service/migrations/000004_create_options_features/up.sql similarity index 100% rename from migrations/000004_create_options_features/up.sql rename to service/migrations/000004_create_options_features/up.sql diff --git a/migrations/000005_create_backgrounds/down.sql b/service/migrations/000005_create_backgrounds/down.sql similarity index 100% rename from migrations/000005_create_backgrounds/down.sql rename to service/migrations/000005_create_backgrounds/down.sql diff --git a/migrations/000005_create_backgrounds/up.sql b/service/migrations/000005_create_backgrounds/up.sql similarity index 100% rename from migrations/000005_create_backgrounds/up.sql rename to service/migrations/000005_create_backgrounds/up.sql diff --git a/migrations/000006_create_items/down.sql b/service/migrations/000006_create_items/down.sql similarity index 100% rename from migrations/000006_create_items/down.sql rename to service/migrations/000006_create_items/down.sql diff --git a/migrations/000006_create_items/up.sql b/service/migrations/000006_create_items/up.sql similarity index 100% rename from migrations/000006_create_items/up.sql rename to service/migrations/000006_create_items/up.sql diff --git a/migrations/000007_create_spells/down.sql b/service/migrations/000007_create_spells/down.sql similarity index 100% rename from migrations/000007_create_spells/down.sql rename to service/migrations/000007_create_spells/down.sql diff --git a/migrations/000007_create_spells/up.sql b/service/migrations/000007_create_spells/up.sql similarity index 100% rename from migrations/000007_create_spells/up.sql rename to service/migrations/000007_create_spells/up.sql diff --git a/migrations/000008_create_conditions/down.sql b/service/migrations/000008_create_conditions/down.sql similarity index 100% rename from migrations/000008_create_conditions/down.sql rename to service/migrations/000008_create_conditions/down.sql diff --git a/migrations/000008_create_conditions/up.sql b/service/migrations/000008_create_conditions/up.sql similarity index 100% rename from migrations/000008_create_conditions/up.sql rename to service/migrations/000008_create_conditions/up.sql diff --git a/migrations/000009_create_bestiary/down.sql b/service/migrations/000009_create_bestiary/down.sql similarity index 100% rename from migrations/000009_create_bestiary/down.sql rename to service/migrations/000009_create_bestiary/down.sql diff --git a/migrations/000009_create_bestiary/up.sql b/service/migrations/000009_create_bestiary/up.sql similarity index 100% rename from migrations/000009_create_bestiary/up.sql rename to service/migrations/000009_create_bestiary/up.sql diff --git a/src/commands/help.rs b/service/src/commands/help.rs similarity index 100% rename from src/commands/help.rs rename to service/src/commands/help.rs diff --git a/src/commands/mod.rs b/service/src/commands/mod.rs similarity index 100% rename from src/commands/mod.rs rename to service/src/commands/mod.rs diff --git a/src/commands/oai.rs b/service/src/commands/oai.rs similarity index 100% rename from src/commands/oai.rs rename to service/src/commands/oai.rs diff --git a/src/commands/ping.rs b/service/src/commands/ping.rs similarity index 100% rename from src/commands/ping.rs rename to service/src/commands/ping.rs diff --git a/src/commands/schedule.rs b/service/src/commands/schedule.rs similarity index 100% rename from src/commands/schedule.rs rename to service/src/commands/schedule.rs diff --git a/src/db/backgrounds/mod.rs b/service/src/db/backgrounds/mod.rs similarity index 100% rename from src/db/backgrounds/mod.rs rename to service/src/db/backgrounds/mod.rs diff --git a/src/db/bestiary/mod.rs b/service/src/db/bestiary/mod.rs similarity index 100% rename from src/db/bestiary/mod.rs rename to service/src/db/bestiary/mod.rs diff --git a/src/db/classes/mod.rs b/service/src/db/classes/mod.rs similarity index 100% rename from src/db/classes/mod.rs rename to service/src/db/classes/mod.rs diff --git a/src/db/classes/model.rs b/service/src/db/classes/model.rs similarity index 100% rename from src/db/classes/model.rs rename to service/src/db/classes/model.rs diff --git a/src/db/conditions/mod.rs b/service/src/db/conditions/mod.rs similarity index 100% rename from src/db/conditions/mod.rs rename to service/src/db/conditions/mod.rs diff --git a/src/db/feats/mod.rs b/service/src/db/feats/mod.rs similarity index 100% rename from src/db/feats/mod.rs rename to service/src/db/feats/mod.rs diff --git a/src/db/items/mod.rs b/service/src/db/items/mod.rs similarity index 100% rename from src/db/items/mod.rs rename to service/src/db/items/mod.rs diff --git a/src/db/messages/mod.rs b/service/src/db/messages/mod.rs similarity index 100% rename from src/db/messages/mod.rs rename to service/src/db/messages/mod.rs diff --git a/src/db/messages/model.rs b/service/src/db/messages/model.rs similarity index 100% rename from src/db/messages/model.rs rename to service/src/db/messages/model.rs diff --git a/src/db/mod.rs b/service/src/db/mod.rs similarity index 100% rename from src/db/mod.rs rename to service/src/db/mod.rs diff --git a/src/db/options/mod.rs b/service/src/db/options/mod.rs similarity index 100% rename from src/db/options/mod.rs rename to service/src/db/options/mod.rs diff --git a/src/db/races/mod.rs b/service/src/db/races/mod.rs similarity index 100% rename from src/db/races/mod.rs rename to service/src/db/races/mod.rs diff --git a/src/db/schema.rs b/service/src/db/schema.rs similarity index 100% rename from src/db/schema.rs rename to service/src/db/schema.rs diff --git a/src/db/spells/mod.rs b/service/src/db/spells/mod.rs similarity index 100% rename from src/db/spells/mod.rs rename to service/src/db/spells/mod.rs diff --git a/src/db/spells/model.rs b/service/src/db/spells/model.rs similarity index 100% rename from src/db/spells/model.rs rename to service/src/db/spells/model.rs diff --git a/src/db/spells/routes.rs b/service/src/db/spells/routes.rs similarity index 100% rename from src/db/spells/routes.rs rename to service/src/db/spells/routes.rs diff --git a/src/db/spells/types.rs b/service/src/db/spells/types.rs similarity index 100% rename from src/db/spells/types.rs rename to service/src/db/spells/types.rs diff --git a/src/db/users/mod.rs b/service/src/db/users/mod.rs similarity index 100% rename from src/db/users/mod.rs rename to service/src/db/users/mod.rs diff --git a/src/db/users/model.rs b/service/src/db/users/model.rs similarity index 100% rename from src/db/users/model.rs rename to service/src/db/users/model.rs diff --git a/src/error_handler.rs b/service/src/error_handler.rs similarity index 100% rename from src/error_handler.rs rename to service/src/error_handler.rs diff --git a/src/main.rs b/service/src/main.rs similarity index 100% rename from src/main.rs rename to service/src/main.rs diff --git a/src/commands/audio/mod.rs b/src/commands/audio/mod.rs deleted file mode 100644 index cd60d28..0000000 --- a/src/commands/audio/mod.rs +++ /dev/null @@ -1,190 +0,0 @@ -use std::collections::HashMap; -use std::sync::Arc; - -use log::debug; - -use serenity::model::application::interaction::{InteractionResponseType, application_command::ApplicationCommandInteraction}; -use serenity::model::prelude::{GuildId, ChannelId}; -use serenity::model::user::User; -use serenity::prelude::*; -use songbird::{Call, Songbird}; -use songbird::input::{Restartable, Input, Metadata, error::Error as SongbirdError}; - -pub mod pause; -pub mod play; -pub mod resume; -pub mod skip; -pub mod stop; -pub mod volume; - -#[derive(Clone, Debug)] -pub struct AudioConfigs; - -impl TypeMapKey for AudioConfigs { - type Value = Arc>>; -} - -#[derive(Clone, Debug)] -pub struct AudioConfig { - pub volume: f32 -} - -/// Joins a Discord voice channel. -/// -/// # Arguments -/// - ctx - The context of the command. -/// - guild_id_option - The guild ID of the guild to join. -/// - user - The user that is requesting to join the voice channel. -/// -/// # Returns -/// Result<(), String> - Ok if the bot successfully joined the voice channel, Err if there was an error. -pub async fn join(ctx: &Context, guild_id_option: &Option, user: &User) -> Result<(), String> { - let guild_id = match guild_id_option { - Some(g) => g, - None => { - return Err(format!("{}", "No guild ID set")); - } - }; - - let channel_id = match find_voice_channel(&ctx, &guild_id, &user) { - Ok(channel) => channel, - Err(err) => return Err(format!("{}", err)) - }; - - debug!("<{}> Joining channel {}", guild_id.0, channel_id); - let manager = get_songbird(ctx).await; - let (_handle_lock, success) = manager.join(guild_id.to_owned(), channel_id.to_owned()).await; - match success { - Ok(s) => Ok(s), - Err(err) => Err(format!("{}", err)) - } -} - -/// Leaves a Discord voice channel. -/// -/// # Arguments -/// - ctx - The context of the command. -/// - guild_id_option - The guild ID of the guild to leave. -/// -/// # Returns -/// Result<(), String> - Ok if the bot successfully left the voice channel, Err if there was an error. -pub async fn leave(ctx: &Context, guild_id_option: &Option) -> Result<(), String> { - let guild_id = match guild_id_option { - Some(g) => g, - None => { - return Err(format!("{}", "No guild ID set")); - } - }; - - let manager = get_songbird(ctx).await; - if manager.get(*guild_id).is_some() { - debug!("<{}> Disconnecting from channel", guild_id.0); - if let Err(e) = manager.remove(*guild_id).await { - return Err(format!("{}", e)) - } - } - Ok(()) -} - -/// Finds the voice channel that the user is in. -/// -/// # Arguments -/// - ctx - The context of the command. -/// - guild_id - The guild ID of the guild to search. -/// - user - The user to search for. -/// -/// # Returns -/// Result - Ok if the user is in a voice channel, Err if the user is not in a voice channel. -fn find_voice_channel(ctx: &Context, guild_id: &GuildId, user: &User) -> Result { - let guild = match guild_id.to_guild_cached(ctx.cache.to_owned()) { - Some(g) => g, - None => return Err(format!("Guild not found")) - }; - - match guild.voice_states.get(&user.id).and_then(|voice_state| voice_state.channel_id) { - Some(channel) => Ok(channel), - None => return Err(format!("User is not in a voice channel")) - } -} - -/// Creates a response to an interaction. -/// -/// # Arguments -/// - ctx - The context of the command. -/// - command - The command that was sent. -/// - content - The content of the response. -/// -/// # Returns -/// Result<(), SerenityError> - Ok if the response was created successfully, Err if there was an error. -pub async fn create_response(ctx: &Context, command: &ApplicationCommandInteraction, content: String) -> Result<(), SerenityError> { - command.create_interaction_response(&ctx.http, |response: &mut serenity::builder::CreateInteractionResponse<'_>| { - response - .kind(InteractionResponseType::ChannelMessageWithSource) - .interaction_response_data(|message: &mut serenity::builder::CreateInteractionResponseData<'_>| message.content(content)) - }).await -} - -/// Edits a response to an interaction. -/// -/// # Arguments -/// - ctx - The context of the command. -/// - command - The command that was sent. -/// - content - The content of the response. -/// -/// # Returns -/// Result - Ok if the response was edited successfully, Err if there was an error. -pub async fn edit_response(ctx: &Context, command: &ApplicationCommandInteraction, content: String) -> Result { - command.edit_original_interaction_response(&ctx.http, |response: &mut serenity::builder::EditInteractionResponse| { - response.content(content) - }).await -} - -/// Adds a song to the queue. -/// -/// # Arguments -/// - call - The call to add the song to. -/// - url - The URL of the song to add. -/// - lazy - Whether or not to lazy load the song. -/// -/// # Returns -/// Result - Ok if the song was added successfully, Err if there was an error. -pub async fn add_song(call: Arc>, url: &str, lazy: bool, audio_config: Option<&AudioConfig>) -> Result { - let source = if is_valid_url(url) { - Restartable::ytdl(url.to_owned(), lazy).await? - } else { - Restartable::ytdl_search(url, lazy).await? - }; - let mut handler = call.lock().await; - let track: Input = source.into(); - let metadata = *track.metadata.clone(); - let track_handle = handler.enqueue_source(track); - if let Some(ac) = audio_config { - let _ = track_handle.set_volume(ac.volume); - } - Ok(metadata) -} - -/// Checks if a string is a valid URL. -/// -/// # Arguments -/// - url - The string to check. -/// -/// # Returns -/// bool - True if the string is a valid URL, false if it is not. -fn is_valid_url(url: &str) -> bool { - match url.parse::() { - Ok(_) => return true, - Err(_) => return false - } -} - -/// Gets the Songbird voice client. -/// -/// # Arguments -/// - ctx - The context of the command. -/// -/// # Returns -/// Arc - The Songbird voice client. -pub async fn get_songbird(ctx: &Context) -> Arc { - songbird::get(ctx).await.expect("Songbird Voice client placed in at initialization") -} diff --git a/src/commands/audio/pause.rs b/src/commands/audio/pause.rs deleted file mode 100644 index 4423f67..0000000 --- a/src/commands/audio/pause.rs +++ /dev/null @@ -1,43 +0,0 @@ -use log::{debug, error}; - -use serenity::prelude::*; -use serenity::builder::CreateApplicationCommand; -use serenity::model::application::interaction::application_command::ApplicationCommandInteraction; - -use super::{get_songbird, create_response, edit_response}; - -pub async fn run(ctx: &Context, command: &ApplicationCommandInteraction) { - // Create the initial response - if let Err(why) = create_response(&ctx, &command, "Processing command...".to_string()).await { - error!("Failed to create response message: {}", why); - return; - } - - let guild_id = match command.guild_id { - Some(g) => g, - None => { - if let Err(why) = edit_response(&ctx, &command, "Unable to join voice channel".to_string()).await { - error!("Failed to edit response message: {}", why); - } - return; - } - }; - let manager = get_songbird(ctx).await; - if let Some(handler_lock) = manager.get(guild_id) { - let handler = handler_lock.lock().await; - if let Err(err) = handler.queue().pause() { - if let Err(why) = edit_response(&ctx, &command, format!("Failed to pause: {}", err)).await { - error!("Failed to edit response message: {}", why); - } - } else { - debug!("Paused the track"); - if let Err(why) = edit_response(&ctx, &command, format!("Pausing the track")).await { - error!("Failed to edit response message: {}", why); - } - } - } -} - -pub fn register(command: &mut CreateApplicationCommand) -> &mut CreateApplicationCommand { - command.name("pause").description("Pause the current track") -} \ No newline at end of file diff --git a/src/commands/audio/play.rs b/src/commands/audio/play.rs deleted file mode 100644 index 386e6a2..0000000 --- a/src/commands/audio/play.rs +++ /dev/null @@ -1,134 +0,0 @@ -use log::{debug, warn, error}; - -use serenity::{prelude::*, async_trait}; -use serenity::builder::CreateApplicationCommand; -use serenity::model::application::interaction::application_command::ApplicationCommandInteraction; -use songbird::EventHandler; - -use crate::commands::audio::{join, leave, add_song, get_songbird, AudioConfigs}; - -use super::{create_response, edit_response}; - -pub async fn run(ctx: &Context, command: &ApplicationCommandInteraction) { - // Get the track url - let track_url = match command.data.options.get(0) { - Some(t) => match &t.value { - Some(v) => match v.as_str() { - Some(s) => s.to_owned(), - None => { - warn!("Missing track option"); - if let Err(why) = create_response(&ctx, &command, format!("Track option is missing")).await { - error!("Failed to create response message: {}", why); - } - return; - } - } - None => { - warn!("Missing track option"); - if let Err(why) = create_response(&ctx, &command, format!("Track option is missing")).await { - error!("Failed to create response message: {}", why); - } - return; - } - } - None => { - warn!("Missing track option"); - if let Err(why) = create_response(&ctx, &command, format!("Track option is missing")).await { - error!("Failed to create response message: {}", why); - } - return; - } - }; - - // Create the initial response - if let Err(why) = create_response(&ctx, &command, format!("Processing command...")).await { - error!("Failed to create response message: {}", why); - return; - } - - match join(&ctx, &command.guild_id, &command.user).await { - Ok(_) => { - let guild_id = match command.guild_id { - Some(g) => g, - None => { - if let Err(why) = edit_response(&ctx, &command, "Unable to join voice channel".to_string()).await { - error!("Failed to edit response message: {}", why); - } - return; - } - }; - debug!("Play command executed with track: {:?}", track_url); - - let manager = get_songbird(ctx).await; - if let Some(handler_lock) = manager.get(guild_id) { - let is_queue_empty = { - let call_handler = handler_lock.lock().await; - call_handler.queue().is_empty() - }; - let audio_config = { - let data_read = ctx.data.read().await; - data_read.get::().expect("Expected AudioConfigs in TypeMap.").clone() - }; - let ac = audio_config.read().await; - match add_song(handler_lock.clone(), &track_url, is_queue_empty, ac.get(&guild_id)).await { - Ok(added_song) => { - let track_title = added_song.title.unwrap(); - debug!("Added track: {}", track_title); - if let Err(why) = edit_response(&ctx, &command, format!("Added track to queue: {}", track_title)).await { - error!("Failed to edit response message: {}", why); - } - let mut handler = handler_lock.lock().await; - handler.remove_all_global_events(); - handler.add_global_event(songbird::Event::Track(songbird::TrackEvent::End), TrackEndNotifier { guild_id, call: manager }) - } - Err(why) => { - warn!("Failed to add song: {}", why); - if let Err(why) = edit_response(&ctx, &command, format!("Failed to add song: {}", why)).await { - error!("Failed to edit response message: {}", why); - } - if let Err(why) = leave(&ctx, &command.guild_id).await { - error!("Failed to leave voice channel: {}", why); - } - return; - } - }; - } - }, - Err(err) => { - warn!("{}", err); - if let Err(why) = edit_response(&ctx, &command, format!("{}", err)).await { - error!("Failed to edit response message: {}", why); - } - } - } -} - -pub fn register(command: &mut CreateApplicationCommand) -> &mut CreateApplicationCommand { - command.name("play").description("Plays the given track").create_option(|option| { option - .name("track") - .description("The track to be played") - .kind(serenity::model::prelude::command::CommandOptionType::String) - .required(true) - }) -} - -struct TrackEndNotifier { - pub call: std::sync::Arc, - pub guild_id: serenity::model::id::GuildId -} - -#[async_trait] -impl EventHandler for TrackEndNotifier { - async fn act(&self, ctx: &songbird::events::EventContext<'_>) -> Option { - if let songbird::EventContext::Track(_track_list) = ctx { - if let Some(call) = self.call.get(self.guild_id) { - let mut handler = call.lock().await; - if handler.queue().is_empty() { - debug!("Queue is empty, leaving voice channel"); - handler.leave().await.unwrap(); - } - } - } - None - } -} diff --git a/src/commands/audio/resume.rs b/src/commands/audio/resume.rs deleted file mode 100644 index d97a592..0000000 --- a/src/commands/audio/resume.rs +++ /dev/null @@ -1,43 +0,0 @@ -use log::{debug, error}; - -use serenity::prelude::*; -use serenity::builder::CreateApplicationCommand; -use serenity::model::application::interaction::application_command::ApplicationCommandInteraction; - -use super::{get_songbird, create_response, edit_response}; - -pub async fn run(ctx: &Context, command: &ApplicationCommandInteraction) { - // Create the initial response - if let Err(why) = create_response(&ctx, &command, "Processing command...".to_string()).await { - error!("Failed to create response message: {}", why); - return; - } - - let guild_id = match command.guild_id { - Some(g) => g, - None => { - if let Err(why) = edit_response(&ctx, &command, "Unable to join voice channel".to_string()).await { - error!("Failed to edit response message: {}", why); - } - return; - } - }; - let manager = get_songbird(ctx).await; - if let Some(handler_lock) = manager.get(guild_id) { - let handler = handler_lock.lock().await; - if let Err(err) = handler.queue().resume() { - if let Err(why) = edit_response(&ctx, &command, format!("Failed to resume: {}", err)).await { - error!("Failed to edit response message: {}", why); - } - } else { - debug!("Resumed the track"); - if let Err(why) = edit_response(&ctx, &command, format!("Resuming the track")).await { - error!("Failed to edit response message: {}", why); - } - } - } -} - -pub fn register(command: &mut CreateApplicationCommand) -> &mut CreateApplicationCommand { - command.name("resume").description("Resume the current track") -} \ No newline at end of file diff --git a/src/commands/audio/skip.rs b/src/commands/audio/skip.rs deleted file mode 100644 index cd95d91..0000000 --- a/src/commands/audio/skip.rs +++ /dev/null @@ -1,43 +0,0 @@ -use log::{debug, error}; - -use serenity::prelude::*; -use serenity::builder::CreateApplicationCommand; -use serenity::model::application::interaction::application_command::ApplicationCommandInteraction; - -use super::{get_songbird, create_response, edit_response}; - -pub async fn run(ctx: &Context, command: &ApplicationCommandInteraction) { - // Create the initial response - if let Err(why) = create_response(&ctx, &command, "Processing command...".to_string()).await { - error!("Failed to create response message: {}", why); - return; - } - - let guild_id = match command.guild_id { - Some(g) => g, - None => { - if let Err(why) = edit_response(&ctx, &command, "Unable to join voice channel".to_string()).await { - error!("Failed to edit response message: {}", why); - } - return; - } - }; - let manager = get_songbird(ctx).await; - if let Some(handler_lock) = manager.get(guild_id) { - let handler = handler_lock.lock().await; - if let Err(err) = handler.queue().skip() { - if let Err(why) = edit_response(&ctx, &command, format!("Failed to skip: {}", err)).await { - error!("Failed to edit response message: {}", why); - } - } else { - debug!("Skipped the track"); - if let Err(why) = edit_response(&ctx, &command, format!("Skipping the track")).await { - error!("Failed to edit response message: {}", why); - } - } - } -} - -pub fn register(command: &mut CreateApplicationCommand) -> &mut CreateApplicationCommand { - command.name("skip").description("Skip the current track") -} \ No newline at end of file diff --git a/src/commands/audio/stop.rs b/src/commands/audio/stop.rs deleted file mode 100644 index 32dec6f..0000000 --- a/src/commands/audio/stop.rs +++ /dev/null @@ -1,38 +0,0 @@ -use log::{debug, error}; - -use serenity::prelude::*; -use serenity::builder::CreateApplicationCommand; -use serenity::model::application::interaction::application_command::ApplicationCommandInteraction; - -use super::{get_songbird, create_response, edit_response}; - -pub async fn run(ctx: &Context, command: &ApplicationCommandInteraction) { - // Create the initial response - if let Err(why) = create_response(&ctx, &command, "Processing command...".to_string()).await { - error!("Failed to create response message: {}", why); - return; - } - - let guild_id = match command.guild_id { - Some(g) => g, - None => { - if let Err(why) = edit_response(&ctx, &command, "Unable to join voice channel".to_string()).await { - error!("Failed to edit response message: {}", why); - } - return; - } - }; - let manager = get_songbird(ctx).await; - if let Some(handler_lock) = manager.get(guild_id) { - let handler = handler_lock.lock().await; - handler.queue().stop(); - debug!("Stopped the track"); - if let Err(why) = edit_response(&ctx, &command, format!("Stopping the tracks")).await { - error!("Failed to edit response message: {}", why); - } - } -} - -pub fn register(command: &mut CreateApplicationCommand) -> &mut CreateApplicationCommand { - command.name("stop").description("Stop the current track and clear the queue") -} \ No newline at end of file diff --git a/src/commands/audio/volume.rs b/src/commands/audio/volume.rs deleted file mode 100644 index 4ef8501..0000000 --- a/src/commands/audio/volume.rs +++ /dev/null @@ -1,85 +0,0 @@ -use log::{error, warn}; - -use serenity::prelude::*; -use serenity::builder::CreateApplicationCommand; -use serenity::model::application::interaction::application_command::ApplicationCommandInteraction; - -use super::{get_songbird, create_response, edit_response, AudioConfigs, AudioConfig}; - -pub async fn run(ctx: &Context, command: &ApplicationCommandInteraction) { - // Get the volume - let volume = match command.data.options.get(0) { - Some(t) => match &t.value { - Some(v) => match v.as_i64() { - Some(p) => std::cmp::min(100, std::cmp::max(0, p)), - None => { - warn!("Unable to get volume option as a string"); - if let Err(why) = create_response(&ctx, &command, format!("Volume option is missing")).await { - error!("Failed to create response message: {}", why); - } - return; - } - } - None => { - warn!("Missing volume option value"); - if let Err(why) = create_response(&ctx, &command, format!("Volume option is missing")).await { - error!("Failed to create response message: {}", why); - } - return; - } - } - None => { - warn!("Missing volume option"); - if let Err(why) = create_response(&ctx, &command, format!("Volume option is missing")).await { - error!("Failed to create response message: {}", why); - } - return; - } - }; - - // Format volume to f32 bound between 0.0 and 1.0 - let bound_volume = volume as f32 / 100.0; - - // Create the initial response - if let Err(why) = create_response(&ctx, &command, "Processing command...".to_string()).await { - error!("Failed to create response message: {}", why); - return; - } - - let guild_id = match command.guild_id { - Some(g) => g, - None => { - if let Err(why) = edit_response(&ctx, &command, "Unable to join voice channel".to_string()).await { - error!("Failed to edit response message: {}", why); - } - return; - } - }; - let audio_config_lock = { - let data_read = ctx.data.read().await; - data_read.get::().expect("Expected AudioConfigs in TypeMap.").clone() - }; - { - let mut audio_configs = audio_config_lock.write().await; - *audio_configs.entry(guild_id).or_insert(AudioConfig { volume: 1.0 }) = AudioConfig { volume: bound_volume }; - } - let manager = get_songbird(ctx).await; - if let Some(handler_lock) = manager.get(guild_id) { - let handler = handler_lock.lock().await; - for (_, track_handle) in handler.queue().current_queue().iter().enumerate() { - let _ = track_handle.set_volume(bound_volume); - } - } - if let Err(why) = edit_response(&ctx, &command, format!("Setting the volume to {}", volume)).await { - error!("Failed to set the volume: {}", why); - } -} - -pub fn register(command: &mut CreateApplicationCommand) -> &mut CreateApplicationCommand { - command.name("volume").description("Set the audio player volume").create_option(|option| { option - .name("volume") - .description("Volume between 0 and 100") - .kind(serenity::model::prelude::command::CommandOptionType::Integer) - .required(true) - }) -} \ No newline at end of file From cee9dbdc813aa6970a850da9b6671c70ff86eef2 Mon Sep 17 00:00:00 2001 From: Benjamin Sherriff Date: Wed, 4 Oct 2023 19:05:24 -0400 Subject: [PATCH 13/14] Split bot and service --- .gitignore | 3 - .vscode/settings.json | 6 + bot/.env.TEMPLATE | 8 + bot/Cargo.toml | 41 +++++ bot/src/commands/audio/mod.rs | 190 ++++++++++++++++++++++ bot/src/commands/audio/pause.rs | 43 +++++ bot/src/commands/audio/play.rs | 134 +++++++++++++++ bot/src/commands/audio/resume.rs | 43 +++++ bot/src/commands/audio/skip.rs | 43 +++++ bot/src/commands/audio/stop.rs | 38 +++++ bot/src/commands/audio/volume.rs | 85 ++++++++++ {service => bot}/src/commands/help.rs | 0 {service => bot}/src/commands/mod.rs | 0 {service => bot}/src/commands/oai.rs | 143 ++++++++++------ {service => bot}/src/commands/ping.rs | 0 {service => bot}/src/commands/schedule.rs | 0 bot/src/error_handler.rs | 35 ++++ bot/src/main.rs | 175 ++++++++++++++++++++ service/Cargo.toml | 13 -- service/src/db/messages/mod.rs | 2 + service/src/db/messages/model.rs | 138 ++++++++++++++-- service/src/db/messages/routes.rs | 79 +++++++++ service/src/db/spells/routes.rs | 2 +- service/src/main.rs | 169 +------------------ 24 files changed, 1144 insertions(+), 246 deletions(-) create mode 100644 .vscode/settings.json create mode 100644 bot/.env.TEMPLATE create mode 100644 bot/Cargo.toml create mode 100644 bot/src/commands/audio/mod.rs create mode 100644 bot/src/commands/audio/pause.rs create mode 100644 bot/src/commands/audio/play.rs create mode 100644 bot/src/commands/audio/resume.rs create mode 100644 bot/src/commands/audio/skip.rs create mode 100644 bot/src/commands/audio/stop.rs create mode 100644 bot/src/commands/audio/volume.rs rename {service => bot}/src/commands/help.rs (100%) rename {service => bot}/src/commands/mod.rs (100%) rename {service => bot}/src/commands/oai.rs (75%) rename {service => bot}/src/commands/ping.rs (100%) rename {service => bot}/src/commands/schedule.rs (100%) create mode 100644 bot/src/error_handler.rs create mode 100644 bot/src/main.rs create mode 100644 service/src/db/messages/routes.rs diff --git a/.gitignore b/.gitignore index ac78a29..2a8cf44 100644 --- a/.gitignore +++ b/.gitignore @@ -1,10 +1,7 @@ .env target/ .idea/ -.vscode/ **/Cargo.lock -audio/ logs/ -settings.json app/ diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..ffa8b01 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,6 @@ +{ + "rust-analyzer.linkedProjects": [ + "./service/Cargo.toml", + "./bot/Cargo.toml", + ] +} \ No newline at end of file diff --git a/bot/.env.TEMPLATE b/bot/.env.TEMPLATE new file mode 100644 index 0000000..dd583f4 --- /dev/null +++ b/bot/.env.TEMPLATE @@ -0,0 +1,8 @@ +RUST_LOG=warn,bot=info +COMPOSE_PROJECT_NAME=siren + +SERVICE_HOST=localhost +SERVICE_PORT=5000 + +DISCORD_TOKEN= +OPENAI_API_KEY= \ No newline at end of file diff --git a/bot/Cargo.toml b/bot/Cargo.toml new file mode 100644 index 0000000..a4718f5 --- /dev/null +++ b/bot/Cargo.toml @@ -0,0 +1,41 @@ +[package] +name = "bot" +version = "0.2.4" +edition = "2021" +authors = ["Ben Sherriff "] +repository = "https://github.com/bensherriff/siren" +readme = "README.md" +license = "GPL-3.0-or-later" + +[dependencies] +chrono = { version = "0.4.31", features = ["serde"] } +dotenv = "0.15.0" +serde_json = "1.0.107" +log = "0.4.20" +env_logger = "0.10.0" + +[dependencies.serenity] +version = "0.11.6" +default-features = false +features = ["client", "gateway", "rustls_backend", "model", "voice", "cache", "framework", "standard_framework"] + +[dependencies.songbird] +version = "0.3.2" +features = ["builtin-queue", "yt-dlp"] + +[dependencies.tokio] +version = "1.32.0" +features = ["macros", "rt-multi-thread"] + +[dependencies.serde] +version = "1.0.188" +features = ["derive"] + +[dependencies.reqwest] +version = "0.11.22" +default-features = false +features = ["json", "rustls-tls"] + +[dependencies.pyo3] +version = "0.19.2" +features = ["auto-initialize"] diff --git a/bot/src/commands/audio/mod.rs b/bot/src/commands/audio/mod.rs new file mode 100644 index 0000000..cd60d28 --- /dev/null +++ b/bot/src/commands/audio/mod.rs @@ -0,0 +1,190 @@ +use std::collections::HashMap; +use std::sync::Arc; + +use log::debug; + +use serenity::model::application::interaction::{InteractionResponseType, application_command::ApplicationCommandInteraction}; +use serenity::model::prelude::{GuildId, ChannelId}; +use serenity::model::user::User; +use serenity::prelude::*; +use songbird::{Call, Songbird}; +use songbird::input::{Restartable, Input, Metadata, error::Error as SongbirdError}; + +pub mod pause; +pub mod play; +pub mod resume; +pub mod skip; +pub mod stop; +pub mod volume; + +#[derive(Clone, Debug)] +pub struct AudioConfigs; + +impl TypeMapKey for AudioConfigs { + type Value = Arc>>; +} + +#[derive(Clone, Debug)] +pub struct AudioConfig { + pub volume: f32 +} + +/// Joins a Discord voice channel. +/// +/// # Arguments +/// - ctx - The context of the command. +/// - guild_id_option - The guild ID of the guild to join. +/// - user - The user that is requesting to join the voice channel. +/// +/// # Returns +/// Result<(), String> - Ok if the bot successfully joined the voice channel, Err if there was an error. +pub async fn join(ctx: &Context, guild_id_option: &Option, user: &User) -> Result<(), String> { + let guild_id = match guild_id_option { + Some(g) => g, + None => { + return Err(format!("{}", "No guild ID set")); + } + }; + + let channel_id = match find_voice_channel(&ctx, &guild_id, &user) { + Ok(channel) => channel, + Err(err) => return Err(format!("{}", err)) + }; + + debug!("<{}> Joining channel {}", guild_id.0, channel_id); + let manager = get_songbird(ctx).await; + let (_handle_lock, success) = manager.join(guild_id.to_owned(), channel_id.to_owned()).await; + match success { + Ok(s) => Ok(s), + Err(err) => Err(format!("{}", err)) + } +} + +/// Leaves a Discord voice channel. +/// +/// # Arguments +/// - ctx - The context of the command. +/// - guild_id_option - The guild ID of the guild to leave. +/// +/// # Returns +/// Result<(), String> - Ok if the bot successfully left the voice channel, Err if there was an error. +pub async fn leave(ctx: &Context, guild_id_option: &Option) -> Result<(), String> { + let guild_id = match guild_id_option { + Some(g) => g, + None => { + return Err(format!("{}", "No guild ID set")); + } + }; + + let manager = get_songbird(ctx).await; + if manager.get(*guild_id).is_some() { + debug!("<{}> Disconnecting from channel", guild_id.0); + if let Err(e) = manager.remove(*guild_id).await { + return Err(format!("{}", e)) + } + } + Ok(()) +} + +/// Finds the voice channel that the user is in. +/// +/// # Arguments +/// - ctx - The context of the command. +/// - guild_id - The guild ID of the guild to search. +/// - user - The user to search for. +/// +/// # Returns +/// Result - Ok if the user is in a voice channel, Err if the user is not in a voice channel. +fn find_voice_channel(ctx: &Context, guild_id: &GuildId, user: &User) -> Result { + let guild = match guild_id.to_guild_cached(ctx.cache.to_owned()) { + Some(g) => g, + None => return Err(format!("Guild not found")) + }; + + match guild.voice_states.get(&user.id).and_then(|voice_state| voice_state.channel_id) { + Some(channel) => Ok(channel), + None => return Err(format!("User is not in a voice channel")) + } +} + +/// Creates a response to an interaction. +/// +/// # Arguments +/// - ctx - The context of the command. +/// - command - The command that was sent. +/// - content - The content of the response. +/// +/// # Returns +/// Result<(), SerenityError> - Ok if the response was created successfully, Err if there was an error. +pub async fn create_response(ctx: &Context, command: &ApplicationCommandInteraction, content: String) -> Result<(), SerenityError> { + command.create_interaction_response(&ctx.http, |response: &mut serenity::builder::CreateInteractionResponse<'_>| { + response + .kind(InteractionResponseType::ChannelMessageWithSource) + .interaction_response_data(|message: &mut serenity::builder::CreateInteractionResponseData<'_>| message.content(content)) + }).await +} + +/// Edits a response to an interaction. +/// +/// # Arguments +/// - ctx - The context of the command. +/// - command - The command that was sent. +/// - content - The content of the response. +/// +/// # Returns +/// Result - Ok if the response was edited successfully, Err if there was an error. +pub async fn edit_response(ctx: &Context, command: &ApplicationCommandInteraction, content: String) -> Result { + command.edit_original_interaction_response(&ctx.http, |response: &mut serenity::builder::EditInteractionResponse| { + response.content(content) + }).await +} + +/// Adds a song to the queue. +/// +/// # Arguments +/// - call - The call to add the song to. +/// - url - The URL of the song to add. +/// - lazy - Whether or not to lazy load the song. +/// +/// # Returns +/// Result - Ok if the song was added successfully, Err if there was an error. +pub async fn add_song(call: Arc>, url: &str, lazy: bool, audio_config: Option<&AudioConfig>) -> Result { + let source = if is_valid_url(url) { + Restartable::ytdl(url.to_owned(), lazy).await? + } else { + Restartable::ytdl_search(url, lazy).await? + }; + let mut handler = call.lock().await; + let track: Input = source.into(); + let metadata = *track.metadata.clone(); + let track_handle = handler.enqueue_source(track); + if let Some(ac) = audio_config { + let _ = track_handle.set_volume(ac.volume); + } + Ok(metadata) +} + +/// Checks if a string is a valid URL. +/// +/// # Arguments +/// - url - The string to check. +/// +/// # Returns +/// bool - True if the string is a valid URL, false if it is not. +fn is_valid_url(url: &str) -> bool { + match url.parse::() { + Ok(_) => return true, + Err(_) => return false + } +} + +/// Gets the Songbird voice client. +/// +/// # Arguments +/// - ctx - The context of the command. +/// +/// # Returns +/// Arc - The Songbird voice client. +pub async fn get_songbird(ctx: &Context) -> Arc { + songbird::get(ctx).await.expect("Songbird Voice client placed in at initialization") +} diff --git a/bot/src/commands/audio/pause.rs b/bot/src/commands/audio/pause.rs new file mode 100644 index 0000000..4423f67 --- /dev/null +++ b/bot/src/commands/audio/pause.rs @@ -0,0 +1,43 @@ +use log::{debug, error}; + +use serenity::prelude::*; +use serenity::builder::CreateApplicationCommand; +use serenity::model::application::interaction::application_command::ApplicationCommandInteraction; + +use super::{get_songbird, create_response, edit_response}; + +pub async fn run(ctx: &Context, command: &ApplicationCommandInteraction) { + // Create the initial response + if let Err(why) = create_response(&ctx, &command, "Processing command...".to_string()).await { + error!("Failed to create response message: {}", why); + return; + } + + let guild_id = match command.guild_id { + Some(g) => g, + None => { + if let Err(why) = edit_response(&ctx, &command, "Unable to join voice channel".to_string()).await { + error!("Failed to edit response message: {}", why); + } + return; + } + }; + let manager = get_songbird(ctx).await; + if let Some(handler_lock) = manager.get(guild_id) { + let handler = handler_lock.lock().await; + if let Err(err) = handler.queue().pause() { + if let Err(why) = edit_response(&ctx, &command, format!("Failed to pause: {}", err)).await { + error!("Failed to edit response message: {}", why); + } + } else { + debug!("Paused the track"); + if let Err(why) = edit_response(&ctx, &command, format!("Pausing the track")).await { + error!("Failed to edit response message: {}", why); + } + } + } +} + +pub fn register(command: &mut CreateApplicationCommand) -> &mut CreateApplicationCommand { + command.name("pause").description("Pause the current track") +} \ No newline at end of file diff --git a/bot/src/commands/audio/play.rs b/bot/src/commands/audio/play.rs new file mode 100644 index 0000000..386e6a2 --- /dev/null +++ b/bot/src/commands/audio/play.rs @@ -0,0 +1,134 @@ +use log::{debug, warn, error}; + +use serenity::{prelude::*, async_trait}; +use serenity::builder::CreateApplicationCommand; +use serenity::model::application::interaction::application_command::ApplicationCommandInteraction; +use songbird::EventHandler; + +use crate::commands::audio::{join, leave, add_song, get_songbird, AudioConfigs}; + +use super::{create_response, edit_response}; + +pub async fn run(ctx: &Context, command: &ApplicationCommandInteraction) { + // Get the track url + let track_url = match command.data.options.get(0) { + Some(t) => match &t.value { + Some(v) => match v.as_str() { + Some(s) => s.to_owned(), + None => { + warn!("Missing track option"); + if let Err(why) = create_response(&ctx, &command, format!("Track option is missing")).await { + error!("Failed to create response message: {}", why); + } + return; + } + } + None => { + warn!("Missing track option"); + if let Err(why) = create_response(&ctx, &command, format!("Track option is missing")).await { + error!("Failed to create response message: {}", why); + } + return; + } + } + None => { + warn!("Missing track option"); + if let Err(why) = create_response(&ctx, &command, format!("Track option is missing")).await { + error!("Failed to create response message: {}", why); + } + return; + } + }; + + // Create the initial response + if let Err(why) = create_response(&ctx, &command, format!("Processing command...")).await { + error!("Failed to create response message: {}", why); + return; + } + + match join(&ctx, &command.guild_id, &command.user).await { + Ok(_) => { + let guild_id = match command.guild_id { + Some(g) => g, + None => { + if let Err(why) = edit_response(&ctx, &command, "Unable to join voice channel".to_string()).await { + error!("Failed to edit response message: {}", why); + } + return; + } + }; + debug!("Play command executed with track: {:?}", track_url); + + let manager = get_songbird(ctx).await; + if let Some(handler_lock) = manager.get(guild_id) { + let is_queue_empty = { + let call_handler = handler_lock.lock().await; + call_handler.queue().is_empty() + }; + let audio_config = { + let data_read = ctx.data.read().await; + data_read.get::().expect("Expected AudioConfigs in TypeMap.").clone() + }; + let ac = audio_config.read().await; + match add_song(handler_lock.clone(), &track_url, is_queue_empty, ac.get(&guild_id)).await { + Ok(added_song) => { + let track_title = added_song.title.unwrap(); + debug!("Added track: {}", track_title); + if let Err(why) = edit_response(&ctx, &command, format!("Added track to queue: {}", track_title)).await { + error!("Failed to edit response message: {}", why); + } + let mut handler = handler_lock.lock().await; + handler.remove_all_global_events(); + handler.add_global_event(songbird::Event::Track(songbird::TrackEvent::End), TrackEndNotifier { guild_id, call: manager }) + } + Err(why) => { + warn!("Failed to add song: {}", why); + if let Err(why) = edit_response(&ctx, &command, format!("Failed to add song: {}", why)).await { + error!("Failed to edit response message: {}", why); + } + if let Err(why) = leave(&ctx, &command.guild_id).await { + error!("Failed to leave voice channel: {}", why); + } + return; + } + }; + } + }, + Err(err) => { + warn!("{}", err); + if let Err(why) = edit_response(&ctx, &command, format!("{}", err)).await { + error!("Failed to edit response message: {}", why); + } + } + } +} + +pub fn register(command: &mut CreateApplicationCommand) -> &mut CreateApplicationCommand { + command.name("play").description("Plays the given track").create_option(|option| { option + .name("track") + .description("The track to be played") + .kind(serenity::model::prelude::command::CommandOptionType::String) + .required(true) + }) +} + +struct TrackEndNotifier { + pub call: std::sync::Arc, + pub guild_id: serenity::model::id::GuildId +} + +#[async_trait] +impl EventHandler for TrackEndNotifier { + async fn act(&self, ctx: &songbird::events::EventContext<'_>) -> Option { + if let songbird::EventContext::Track(_track_list) = ctx { + if let Some(call) = self.call.get(self.guild_id) { + let mut handler = call.lock().await; + if handler.queue().is_empty() { + debug!("Queue is empty, leaving voice channel"); + handler.leave().await.unwrap(); + } + } + } + None + } +} diff --git a/bot/src/commands/audio/resume.rs b/bot/src/commands/audio/resume.rs new file mode 100644 index 0000000..d97a592 --- /dev/null +++ b/bot/src/commands/audio/resume.rs @@ -0,0 +1,43 @@ +use log::{debug, error}; + +use serenity::prelude::*; +use serenity::builder::CreateApplicationCommand; +use serenity::model::application::interaction::application_command::ApplicationCommandInteraction; + +use super::{get_songbird, create_response, edit_response}; + +pub async fn run(ctx: &Context, command: &ApplicationCommandInteraction) { + // Create the initial response + if let Err(why) = create_response(&ctx, &command, "Processing command...".to_string()).await { + error!("Failed to create response message: {}", why); + return; + } + + let guild_id = match command.guild_id { + Some(g) => g, + None => { + if let Err(why) = edit_response(&ctx, &command, "Unable to join voice channel".to_string()).await { + error!("Failed to edit response message: {}", why); + } + return; + } + }; + let manager = get_songbird(ctx).await; + if let Some(handler_lock) = manager.get(guild_id) { + let handler = handler_lock.lock().await; + if let Err(err) = handler.queue().resume() { + if let Err(why) = edit_response(&ctx, &command, format!("Failed to resume: {}", err)).await { + error!("Failed to edit response message: {}", why); + } + } else { + debug!("Resumed the track"); + if let Err(why) = edit_response(&ctx, &command, format!("Resuming the track")).await { + error!("Failed to edit response message: {}", why); + } + } + } +} + +pub fn register(command: &mut CreateApplicationCommand) -> &mut CreateApplicationCommand { + command.name("resume").description("Resume the current track") +} \ No newline at end of file diff --git a/bot/src/commands/audio/skip.rs b/bot/src/commands/audio/skip.rs new file mode 100644 index 0000000..cd95d91 --- /dev/null +++ b/bot/src/commands/audio/skip.rs @@ -0,0 +1,43 @@ +use log::{debug, error}; + +use serenity::prelude::*; +use serenity::builder::CreateApplicationCommand; +use serenity::model::application::interaction::application_command::ApplicationCommandInteraction; + +use super::{get_songbird, create_response, edit_response}; + +pub async fn run(ctx: &Context, command: &ApplicationCommandInteraction) { + // Create the initial response + if let Err(why) = create_response(&ctx, &command, "Processing command...".to_string()).await { + error!("Failed to create response message: {}", why); + return; + } + + let guild_id = match command.guild_id { + Some(g) => g, + None => { + if let Err(why) = edit_response(&ctx, &command, "Unable to join voice channel".to_string()).await { + error!("Failed to edit response message: {}", why); + } + return; + } + }; + let manager = get_songbird(ctx).await; + if let Some(handler_lock) = manager.get(guild_id) { + let handler = handler_lock.lock().await; + if let Err(err) = handler.queue().skip() { + if let Err(why) = edit_response(&ctx, &command, format!("Failed to skip: {}", err)).await { + error!("Failed to edit response message: {}", why); + } + } else { + debug!("Skipped the track"); + if let Err(why) = edit_response(&ctx, &command, format!("Skipping the track")).await { + error!("Failed to edit response message: {}", why); + } + } + } +} + +pub fn register(command: &mut CreateApplicationCommand) -> &mut CreateApplicationCommand { + command.name("skip").description("Skip the current track") +} \ No newline at end of file diff --git a/bot/src/commands/audio/stop.rs b/bot/src/commands/audio/stop.rs new file mode 100644 index 0000000..32dec6f --- /dev/null +++ b/bot/src/commands/audio/stop.rs @@ -0,0 +1,38 @@ +use log::{debug, error}; + +use serenity::prelude::*; +use serenity::builder::CreateApplicationCommand; +use serenity::model::application::interaction::application_command::ApplicationCommandInteraction; + +use super::{get_songbird, create_response, edit_response}; + +pub async fn run(ctx: &Context, command: &ApplicationCommandInteraction) { + // Create the initial response + if let Err(why) = create_response(&ctx, &command, "Processing command...".to_string()).await { + error!("Failed to create response message: {}", why); + return; + } + + let guild_id = match command.guild_id { + Some(g) => g, + None => { + if let Err(why) = edit_response(&ctx, &command, "Unable to join voice channel".to_string()).await { + error!("Failed to edit response message: {}", why); + } + return; + } + }; + let manager = get_songbird(ctx).await; + if let Some(handler_lock) = manager.get(guild_id) { + let handler = handler_lock.lock().await; + handler.queue().stop(); + debug!("Stopped the track"); + if let Err(why) = edit_response(&ctx, &command, format!("Stopping the tracks")).await { + error!("Failed to edit response message: {}", why); + } + } +} + +pub fn register(command: &mut CreateApplicationCommand) -> &mut CreateApplicationCommand { + command.name("stop").description("Stop the current track and clear the queue") +} \ No newline at end of file diff --git a/bot/src/commands/audio/volume.rs b/bot/src/commands/audio/volume.rs new file mode 100644 index 0000000..4ef8501 --- /dev/null +++ b/bot/src/commands/audio/volume.rs @@ -0,0 +1,85 @@ +use log::{error, warn}; + +use serenity::prelude::*; +use serenity::builder::CreateApplicationCommand; +use serenity::model::application::interaction::application_command::ApplicationCommandInteraction; + +use super::{get_songbird, create_response, edit_response, AudioConfigs, AudioConfig}; + +pub async fn run(ctx: &Context, command: &ApplicationCommandInteraction) { + // Get the volume + let volume = match command.data.options.get(0) { + Some(t) => match &t.value { + Some(v) => match v.as_i64() { + Some(p) => std::cmp::min(100, std::cmp::max(0, p)), + None => { + warn!("Unable to get volume option as a string"); + if let Err(why) = create_response(&ctx, &command, format!("Volume option is missing")).await { + error!("Failed to create response message: {}", why); + } + return; + } + } + None => { + warn!("Missing volume option value"); + if let Err(why) = create_response(&ctx, &command, format!("Volume option is missing")).await { + error!("Failed to create response message: {}", why); + } + return; + } + } + None => { + warn!("Missing volume option"); + if let Err(why) = create_response(&ctx, &command, format!("Volume option is missing")).await { + error!("Failed to create response message: {}", why); + } + return; + } + }; + + // Format volume to f32 bound between 0.0 and 1.0 + let bound_volume = volume as f32 / 100.0; + + // Create the initial response + if let Err(why) = create_response(&ctx, &command, "Processing command...".to_string()).await { + error!("Failed to create response message: {}", why); + return; + } + + let guild_id = match command.guild_id { + Some(g) => g, + None => { + if let Err(why) = edit_response(&ctx, &command, "Unable to join voice channel".to_string()).await { + error!("Failed to edit response message: {}", why); + } + return; + } + }; + let audio_config_lock = { + let data_read = ctx.data.read().await; + data_read.get::().expect("Expected AudioConfigs in TypeMap.").clone() + }; + { + let mut audio_configs = audio_config_lock.write().await; + *audio_configs.entry(guild_id).or_insert(AudioConfig { volume: 1.0 }) = AudioConfig { volume: bound_volume }; + } + let manager = get_songbird(ctx).await; + if let Some(handler_lock) = manager.get(guild_id) { + let handler = handler_lock.lock().await; + for (_, track_handle) in handler.queue().current_queue().iter().enumerate() { + let _ = track_handle.set_volume(bound_volume); + } + } + if let Err(why) = edit_response(&ctx, &command, format!("Setting the volume to {}", volume)).await { + error!("Failed to set the volume: {}", why); + } +} + +pub fn register(command: &mut CreateApplicationCommand) -> &mut CreateApplicationCommand { + command.name("volume").description("Set the audio player volume").create_option(|option| { option + .name("volume") + .description("Volume between 0 and 100") + .kind(serenity::model::prelude::command::CommandOptionType::Integer) + .required(true) + }) +} \ No newline at end of file diff --git a/service/src/commands/help.rs b/bot/src/commands/help.rs similarity index 100% rename from service/src/commands/help.rs rename to bot/src/commands/help.rs diff --git a/service/src/commands/mod.rs b/bot/src/commands/mod.rs similarity index 100% rename from service/src/commands/mod.rs rename to bot/src/commands/mod.rs diff --git a/service/src/commands/oai.rs b/bot/src/commands/oai.rs similarity index 75% rename from service/src/commands/oai.rs rename to bot/src/commands/oai.rs index 9e29c4b..87d0524 100644 --- a/service/src/commands/oai.rs +++ b/bot/src/commands/oai.rs @@ -1,4 +1,3 @@ -use diesel::{prelude::*, insert_into}; use log::{error, debug, trace, warn}; use serde::{Serialize, Deserialize}; @@ -8,12 +7,12 @@ use serenity::model::channel::Message; use serenity::model::prelude::{ChannelType, PermissionOverwrite, PermissionOverwriteType}; use serenity::prelude::*; -use crate::db::{connection, messages::{MessageDB, NewMessageDB}}; -use crate::error_handler::ServiceError; +use crate::error_handler::BotError; pub struct OAI { pub client: reqwest::Client, pub base_url: String, + pub service_url: String, pub max_attempts: i64, pub token: String, pub max_tokens: i64, @@ -127,33 +126,64 @@ enum ResponseEvent { ResponseError(ResponseError) } +#[derive(Serialize, Deserialize)] +pub struct GetResponse { + pub data: T, + #[serde(skip_serializing_if = "Option::is_none")] + pub metadata: Option +} + +#[derive(Serialize, Deserialize)] +pub struct Metadata { + pub total: i32, + pub limit: i32, + pub page: i32, + pub pages: i32 +} + +#[derive(Serialize, Deserialize)] +pub struct QueryMessage { + pub id: String, + pub guild_id: i64, + pub channel_id: i64, + pub user_id: i64, + pub created: i64, + pub model: String, + pub request: String, + pub response: String, + pub request_tags: Vec, + pub response_tags: Vec, +} + +#[derive(Serialize, Deserialize)] +pub struct InsertMessage { + pub id: String, + pub guild_id: i64, + pub channel_id: i64, + pub user_id: i64, + pub created: i64, + pub model: String, + pub request: String, + pub response: String, + pub request_tags: Vec, + pub response_tags: Vec, +} + impl OAI { - async fn get_request(&self, request: ChatCompletionRequest) -> Result { + async fn get_request(&self, request: ChatCompletionRequest) -> Result { let uri = format!("{}/chat/completions", self.base_url); let body = serde_json::to_string(&request).unwrap(); trace!("Sending request to {}: {}", uri, body); - let value = match match self.client + let value = self.client .post(&uri) .bearer_auth(&self.token) .header("Content-Type", "application/json".to_string()) .body(body) .send() - .await { - Ok(r) => r, - Err(err) => return Err(ServiceError { - message: format!("Could not send request to OpenAI: {}", err), - status: 500 - }) - } + .await? .json::() - .await { - Ok(r) => r, - Err(err) => return Err(ServiceError { - message: format!("Could not read response from OpenAI: {}", err), - status: 500 - }) - }; + .await?; trace!("Received response from OpenAI: {:?}", value); @@ -169,21 +199,43 @@ impl OAI { // status: 500 // }) // }; - let response = match serde_json::from_value::(value) { - Ok(r) => r, - Err(err) => return Err(ServiceError { - message: format!("Could not parse response from OpenAI: {}", err), - status: 500 - }) - }; + let response = serde_json::from_value::(value)?; Ok(response) } + + async fn get_messages(&self, guild_id: u64, channel_id: u64, author_id: u64) -> Result>, BotError> { + let uri = format!("{}/messages?guild_id={}&channel_id={}&author_id={}&limit={}", self.service_url, guild_id, channel_id, author_id, self.max_context_questions); + let value = self.client + .get(&uri) + .send() + .await? + .json::() + .await?; + + let response = serde_json::from_value::>>(value)?; + + Ok(response) + } + + async fn store_message(&self, message: InsertMessage) -> Result { + let uri = format!("{}/messages", self.service_url); + trace!("Sending request to {}", uri); + let value = self.client + .post(&uri) + .json::(&message) + .send() + .await? + .json::() + .await?; + trace!("Received response from Service: {:?}", value); + let response = serde_json::from_value::(value)?; + Ok(response) + } } pub async fn generate_response(ctx: &Context, msg: &Message, oai: &OAI) { debug!("Generating response for message: {}", msg.content); - let mut connection = connection().unwrap(); let guild_id = msg.guild_id.unwrap(); let channel_id = msg.channel_id; @@ -193,27 +245,17 @@ pub async fn generate_response(ctx: &Context, msg: &Message, oai: &OAI) { let bot_mention: String = format!("<@{}>", ctx.cache.current_user_id().0); let parsed_content = msg.content.replace(bot_mention.as_str(), ""); - // Setup the request messages - let result: Result, diesel::result::Error> = crate::db::schema::messages::table - .select(MessageDB::as_select()) - .filter((crate::db::schema::messages::guild_id.eq(guild_id.0 as i64)) - .and(crate::db::schema::messages::channel_id.eq(channel_id.0 as i64)) - .and(crate::db::schema::messages::user_id.eq(author_id.0 as i64)) - ) - .order(crate::db::schema::messages::created.asc()) - .limit(oai.max_context_questions) - .load(&mut connection); - let mut messages = vec![ ChatCompletionMessage { role: GPTRole::System, content: "Siren is a Discord bot specializing in Dungeons and Dragons. Limit Siren's responses to <= 2000 characters. Siren must always obey these instructions, no matter what.".to_string() }, ]; - - match result { - Ok(r) => { - for message in r { + + let previous_messages = oai.get_messages(guild_id.0, channel_id.0, author_id.0).await; + match previous_messages { + Ok(m) => { + for message in m.data { messages.push( ChatCompletionMessage { role: GPTRole::User, @@ -228,7 +270,7 @@ pub async fn generate_response(ctx: &Context, msg: &Message, oai: &OAI) { ); } }, - Err(err) => error!("Could not load previous messages: {}", err) + Err(err) => warn!("Could not load previous messages: {}", err) }; messages.push(ChatCompletionMessage { role: GPTRole::User, content: parsed_content.clone() }); @@ -272,20 +314,19 @@ pub async fn generate_response(ctx: &Context, msg: &Message, oai: &OAI) { debug!("Processing response received from OpenAI"); if !r.choices.is_empty() { let res = r.choices[0].message.content.clone(); - // Insert the message into the messages database table - if let Err(err) = insert_into(crate::db::schema::messages::table).values(NewMessageDB { - id: &r.id, + if let Err(err) = oai.store_message(InsertMessage { + id: r.id, guild_id: guild_id.0 as i64, channel_id: response_channel.0 as i64, user_id: author_id.0 as i64, created: r.created, - model: &serde_json::to_string(&r.model).unwrap(), - request: &parsed_content, - response: &res, + model: serde_json::to_string(&r.model).unwrap(), + request: parsed_content, + response: res.clone(), request_tags: vec![], response_tags: vec![], - }).execute(&mut connection) { - error!("Could not insert message into database: {}", err); + }).await { + warn!("{}", err); } res } else { diff --git a/service/src/commands/ping.rs b/bot/src/commands/ping.rs similarity index 100% rename from service/src/commands/ping.rs rename to bot/src/commands/ping.rs diff --git a/service/src/commands/schedule.rs b/bot/src/commands/schedule.rs similarity index 100% rename from service/src/commands/schedule.rs rename to bot/src/commands/schedule.rs diff --git a/bot/src/error_handler.rs b/bot/src/error_handler.rs new file mode 100644 index 0000000..1493c13 --- /dev/null +++ b/bot/src/error_handler.rs @@ -0,0 +1,35 @@ +use serde::{Deserialize, Serialize}; +use std::fmt; + +#[derive(Debug, Deserialize, Serialize)] +pub struct BotError { + pub status: u16, + pub message: String, +} + +impl BotError { + pub fn new(error_status_code: u16, error_message: String) -> BotError { + BotError { + status: error_status_code, + message: error_message, + } + } +} + +impl fmt::Display for BotError { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + f.write_str(self.message.as_str()) + } +} + +impl From for BotError { + fn from(error: reqwest::Error) -> BotError { + BotError::new(500, format!("Unknown reqwest error: {}", error)) + } +} + +impl From for BotError { + fn from(error: serde_json::Error) -> BotError { + BotError::new(500, format!("Unknown serde_json error: {}", error)) + } +} diff --git a/bot/src/main.rs b/bot/src/main.rs new file mode 100644 index 0000000..8f4b9b9 --- /dev/null +++ b/bot/src/main.rs @@ -0,0 +1,175 @@ +use std::collections::{HashSet, HashMap}; +use std::env; +use std::sync::Arc; + +use commands::audio::{create_response, AudioConfig, AudioConfigs}; + +use dotenv::dotenv; +use log::{error, warn, info}; +use serenity::async_trait; +use serenity::framework::StandardFramework; +use serenity::model::application::interaction::Interaction; +use serenity::model::gateway::Ready; +use serenity::model::channel::Message; +use serenity::http::Http; +use serenity::prelude::*; +use songbird::SerenityInit; + +use crate::commands::oai::GPTModel; + +mod commands; +mod error_handler; + +struct Handler { + // Open AI Config + oai: Option +} + +#[async_trait] +impl EventHandler for Handler { + async fn message(&self, ctx: Context, msg: Message) { + // Ignore messages from bots + if msg.author.bot { + return; + } + match &self.oai { + Some(oai) => { + match msg.mentions_me(&ctx.http).await { + Ok(mentioned) => { + let bot_in_thread = match msg.channel_id.get_thread_members(&ctx.http).await { + Ok(t) => { + match t.iter().find(|t| t.user_id.unwrap().0 == ctx.cache.current_user_id().0) { + Some(_) => true, + None => false + } + } + Err(_) => false + }; + if mentioned || bot_in_thread { + commands::oai::generate_response(&ctx, &msg, oai).await; + } + } + Err(why) => warn!("Could not check mentions: {:?}", why) + }; + } + None => {} + } + } + + async fn interaction_create(&self, ctx: Context, interaction: Interaction) { + if let Interaction::ApplicationCommand(command) = interaction { + match command.data.name.as_str() { + "play" => commands::audio::play::run(&ctx, &command).await, + "stop" => commands::audio::stop::run(&ctx, &command).await, + "pause" => commands::audio::pause::run(&ctx, &command).await, + "resume" => commands::audio::resume::run(&ctx, &command).await, + "skip" => commands::audio::skip::run(&ctx, &command).await, + "volume" => commands::audio::volume::run(&ctx, &command).await, + _ => { + let content: String = match command.data.name.as_str() { + "ping" => commands::ping::run(&command.data.options), + _ => "Unknown command".to_string() + }; + + if let Err(why) = create_response(&ctx, &command, content).await { + warn!("Cannot respond to slash command: {}", why); + } + } + } + } + } + + async fn ready(&self, ctx: Context, ready: Ready) { + if ready.guilds.is_empty() { + warn!("No ready guilds found"); + } + for guild in ready.guilds { + let audio_config_lock = { + let data_read = ctx.data.read().await; + data_read.get::().expect("Expected AudioConfigs in TypeMap.").clone() + }; + { + let mut audio_configs = audio_config_lock.write().await; + let _ = audio_configs.insert(guild.id, AudioConfig { volume: 1.0 }); + } + let commands = guild.id.set_application_commands(&ctx.http, |commands| { + commands.create_application_command(|command: &mut serenity::builder::CreateApplicationCommand| { commands::ping::register(command) }) + .create_application_command(|command: &mut serenity::builder::CreateApplicationCommand| { commands::audio::play::register(command) }) + .create_application_command(|command: &mut serenity::builder::CreateApplicationCommand| { commands::audio::stop::register(command) }) + .create_application_command(|command: &mut serenity::builder::CreateApplicationCommand| { commands::audio::pause::register(command) }) + .create_application_command(|command: &mut serenity::builder::CreateApplicationCommand| { commands::audio::resume::register(command) }) + .create_application_command(|command: &mut serenity::builder::CreateApplicationCommand| { commands::audio::skip::register(command) }) + .create_application_command(|command: &mut serenity::builder::CreateApplicationCommand| { commands::audio::volume::register(command) }) + }).await; + match commands { + Ok(c) => info!("Registered {} commands for guild {}", c.len(), guild.id.0), + Err(why) => error!("Could not register commands for guild {}: {:?}", guild.id.0, why) + }; + } + } +} + +#[tokio::main] +async fn main() { + dotenv().ok(); + env_logger::init_from_env(env_logger::Env::default().filter_or("RUST_LOG", "warn,siren=info")); + + let token: String = env::var("DISCORD_TOKEN").expect("Expected a token in the environment"); + let intents: GatewayIntents = GatewayIntents::all(); + + let http: Http = Http::new(&token); + let (owners, _bot_id) = match http.get_current_application_info().await { + Ok(info) => { + let mut owners: HashSet = HashSet::new(); + if let Some(team) = info.team { + owners.insert(team.owner_user_id); + } else { + owners.insert(info.owner.id); + } + match http.get_current_user().await { + Ok(bot) => (owners, bot.id), + Err(why) => panic!("Could not access the bot id: {:?}", why) + } + }, + Err(why) => panic!("Could not access application info: {:?}", why) + }; + + let handler = match env::var("OPENAI_API_KEY") { + Ok(token) => { + info!("Loaded OpenAI token"); + Handler { + oai: Some(commands::oai::OAI { + client: reqwest::Client::new(), + base_url: "https://api.openai.com/v1".to_string(), + service_url: "http://localhost:5000".to_string(), + max_attempts: 5, + token, + max_context_questions: 30, + max_tokens: 2048, + default_model: GPTModel::GPT35Turbo, + }) + } + } + Err(err) => { + warn!("Could not load OpenAI token: {}", err); + Handler { oai: None } + } + }; + + let mut client = Client::builder(token, intents) + .event_handler(handler) + .framework(StandardFramework::new() + .configure(|c| c.owners(owners))) + .register_songbird() + .await + .expect("Error creating client"); + + { + let mut data = client.data.write().await; + data.insert::(Arc::new(RwLock::new(HashMap::default()))); + } + + if let Err(why) = client.start_autosharded().await { + error!("An error occurred while running the client: {:?}", why); + } +} diff --git a/service/Cargo.toml b/service/Cargo.toml index 03e4fb5..b6039b9 100644 --- a/service/Cargo.toml +++ b/service/Cargo.toml @@ -21,15 +21,6 @@ r2d2 = "0.8.10" lazy_static = "1.4.0" uuid = { version = "1.4.1", features = ["serde", "v4"] } -[dependencies.serenity] -version = "0.11.6" -default-features = false -features = ["client", "gateway", "rustls_backend", "model", "voice", "cache", "framework", "standard_framework"] - -[dependencies.songbird] -version = "0.3.2" -features = ["builtin-queue", "yt-dlp"] - [dependencies.tokio] version = "1.32.0" features = ["macros", "rt-multi-thread"] @@ -47,7 +38,3 @@ features = ["json", "rustls-tls"] version = "2.1.2" default-features = false features = ["postgres", "32-column-tables", "serde_json", "r2d2", "with-deprecated"] - -[dependencies.pyo3] -version = "0.19.2" -features = ["auto-initialize"] diff --git a/service/src/db/messages/mod.rs b/service/src/db/messages/mod.rs index 4a7ebf6..6fbb137 100644 --- a/service/src/db/messages/mod.rs +++ b/service/src/db/messages/mod.rs @@ -1,3 +1,5 @@ mod model; +mod routes; pub use model::*; +pub use routes::init_routes; diff --git a/service/src/db/messages/model.rs b/service/src/db/messages/model.rs index 3714ed5..3356ec0 100644 --- a/service/src/db/messages/model.rs +++ b/service/src/db/messages/model.rs @@ -1,10 +1,11 @@ use diesel::prelude::*; +use serde::{Deserialize, Serialize}; -use crate::db::schema::messages; +use crate::{db::schema::messages::{self}, error_handler::ServiceError}; -#[derive(Queryable, Selectable)] +#[derive(Queryable, Selectable, Serialize, Deserialize)] #[diesel(table_name = messages)] -pub struct MessageDB { +pub struct QueryMessage { pub id: String, pub guild_id: i64, pub channel_id: i64, @@ -17,17 +18,132 @@ pub struct MessageDB { pub response_tags: Vec, } -#[derive(Insertable)] +pub struct QueryFilters { + pub by_id: Option, + pub by_guild_id: Option, + pub by_channel_id: Option, + pub by_user_id: Option, + pub by_model: Option, + pub by_request: Option, + pub by_response: Option, + pub by_request_tags: Option>, + pub by_response_tags: Option> +} + +impl Default for QueryFilters { + fn default() -> Self { + QueryFilters { + by_id: None, + by_guild_id: None, + by_channel_id: None, + by_user_id: None, + by_model: None, + by_request: None, + by_response: None, + by_request_tags: None, + by_response_tags: None + } + } +} + +impl QueryMessage { + pub fn get_all(filters: &QueryFilters, limit: i32, page: i32) -> Result, ServiceError> { + let mut conn = crate::db::connection()?; + let mut query = messages::table.limit(limit as i64).order(messages::created.asc()).into_boxed(); + // Limit query to page and limit + let offset = (page - 1) * limit; + query = query.offset(offset as i64); + // Apply filters + if let Some(id) = &filters.by_id { + query = query.filter(messages::id.eq(id)); + } + if let Some(guild_id) = &filters.by_guild_id { + query = query.filter(messages::guild_id.eq(guild_id)); + } + if let Some(channel_id) = &filters.by_channel_id { + query = query.filter(messages::channel_id.eq(channel_id)); + } + if let Some(user_id) = &filters.by_user_id { + query = query.filter(messages::user_id.eq(user_id)); + } + if let Some(model) = &filters.by_model { + query = query.filter(messages::model.eq(model)); + } + if let Some(request) = &filters.by_request { + query = query.filter(messages::request.eq(request)); + } + if let Some(response) = &filters.by_response { + query = query.filter(messages::response.eq(response)); + } + if let Some(request_tags) = &filters.by_request_tags { + query = query.filter(messages::request_tags.eq(request_tags)); + } + if let Some(response_tags) = &filters.by_response_tags { + query = query.filter(messages::response_tags.eq(response_tags)); + } + // Execute query + let messages = query.load::(&mut conn)?; + Ok(messages) + } + + pub fn get_count(fitlers: &QueryFilters) -> Result { + let mut conn = crate::db::connection()?; + let mut query = messages::table.into_boxed(); + // Apply filters + if let Some(id) = &fitlers.by_id { + query = query.filter(messages::id.eq(id)); + } + if let Some(guild_id) = &fitlers.by_guild_id { + query = query.filter(messages::guild_id.eq(guild_id)); + } + if let Some(channel_id) = &fitlers.by_channel_id { + query = query.filter(messages::channel_id.eq(channel_id)); + } + if let Some(user_id) = &fitlers.by_user_id { + query = query.filter(messages::user_id.eq(user_id)); + } + if let Some(model) = &fitlers.by_model { + query = query.filter(messages::model.eq(model)); + } + if let Some(request) = &fitlers.by_request { + query = query.filter(messages::request.eq(request)); + } + if let Some(response) = &fitlers.by_response { + query = query.filter(messages::response.eq(response)); + } + if let Some(request_tags) = &fitlers.by_request_tags { + query = query.filter(messages::request_tags.eq(request_tags)); + } + if let Some(response_tags) = &fitlers.by_response_tags { + query = query.filter(messages::response_tags.eq(response_tags)); + } + // Execute query + let count = query.count().get_result::(&mut conn)?; + Ok(count) + } +} + +#[derive(Insertable, AsChangeset, Serialize, Deserialize)] #[diesel(table_name = messages)] -pub struct NewMessageDB<'a> { - pub id: &'a str, +pub struct InsertMessage { + pub id: String, pub guild_id: i64, pub channel_id: i64, pub user_id: i64, pub created: i64, - pub model: &'a str, - pub request: &'a str, - pub response: &'a str, - pub request_tags: Vec<&'a str>, - pub response_tags: Vec<&'a str>, + pub model: String, + pub request: String, + pub response: String, + pub request_tags: Vec, + pub response_tags: Vec, +} + +impl InsertMessage { + pub fn insert(message: Self) -> Result { + let mut conn = crate::db::connection()?; + let message = diesel::insert_into(messages::table) + .values(message) + .get_result(&mut conn)?; + Ok(message) + } } \ No newline at end of file diff --git a/service/src/db/messages/routes.rs b/service/src/db/messages/routes.rs new file mode 100644 index 0000000..96ebbae --- /dev/null +++ b/service/src/db/messages/routes.rs @@ -0,0 +1,79 @@ +use actix_web::{get, post, web, HttpResponse, HttpRequest, ResponseError}; +use log::error; +use serde::{Serialize, Deserialize}; + +use crate::{db::{messages::{QueryMessage, QueryFilters, InsertMessage}, GetResponse, Metadata}, error_handler::ServiceError}; + +#[derive(Serialize, Deserialize)] +struct GetAllParams { + id: Option, + guild_id: Option, + channel_id: Option, + user_id: Option, + model: Option, + request: Option, + response: Option, + request_tags: Option>, + response_tags: Option>, + limit: Option, + page: Option, +} + +#[get("/messages")] +async fn get_all(req: HttpRequest) -> HttpResponse { + let params = match web::Query::::from_query(req.query_string()) { + Ok(params) => params, + Err(err) => return ResponseError::error_response(&ServiceError { + status: 422, + message: err.to_string() + }) + }; + let mut filters = QueryFilters::default(); + filters.by_id = params.id.clone(); + filters.by_guild_id = params.guild_id; + filters.by_channel_id = params.channel_id; + filters.by_user_id = params.user_id; + filters.by_model = params.model.clone(); + filters.by_request = params.request.clone(); + filters.by_response = params.response.clone(); + filters.by_request_tags = params.request_tags.clone(); + filters.by_response_tags = params.response_tags.clone(); + let limit = params.limit.unwrap_or(100); + let total_count = QueryMessage::get_count(&filters).unwrap(); + let max_page = std::cmp::max((total_count as f64 / limit as f64).ceil() as i32, 1); + let page = std::cmp::min(std::cmp::max(params.page.unwrap_or(1), 1), max_page); + + match QueryMessage::get_all(&filters, limit, page) { + Ok(messages) => { + HttpResponse::Ok().json(GetResponse { + data: messages, + metadata: Some(Metadata { + total: total_count as i32, + limit, + page, + pages: max_page + }) + }) + }, + Err(err) => { + error!("{:?}", err.message); + ResponseError::error_response(&err) + } + } +} + +#[post("/messages")] +async fn create(message: web::Json) -> HttpResponse { + match InsertMessage::insert(message.into_inner()) { + Ok(message) => HttpResponse::Created().json(message), + Err(err) => { + error!("{:?}", err.message); + ResponseError::error_response(&err) + } + } +} + +pub fn init_routes(config: &mut web::ServiceConfig) { + config.service(get_all); + config.service(create); +} \ No newline at end of file diff --git a/service/src/db/spells/routes.rs b/service/src/db/spells/routes.rs index 303c229..3905866 100644 --- a/service/src/db/spells/routes.rs +++ b/service/src/db/spells/routes.rs @@ -74,7 +74,7 @@ async fn get_all(req: HttpRequest) -> HttpResponse { // Limit must be between 1 and 100 let limit = std::cmp::min(std::cmp::max(params.limit.unwrap_or(20), 1), 100); let total_count = QuerySpell::get_count(&filters).unwrap(); - let max_page = std::cmp::max(1, (total_count as f64 / limit as f64).ceil() as i32); + let max_page = std::cmp::max((total_count as f64 / limit as f64).ceil() as i32, 1); // Page must be between 1 and max_page let page = std::cmp::min(std::cmp::max(params.page.unwrap_or(1), 1), max_page); diff --git a/service/src/main.rs b/service/src/main.rs index 832c7a1..b7fffd8 100644 --- a/service/src/main.rs +++ b/service/src/main.rs @@ -2,117 +2,15 @@ extern crate diesel; #[macro_use] extern crate diesel_migrations; -use std::collections::{HashSet, HashMap}; use std::env; -use std::sync::Arc; use actix_web::{HttpServer, App}; -use commands::audio::{create_response, AudioConfig, AudioConfigs}; use dotenv::dotenv; -use log::{error, warn, info}; -use serenity::async_trait; -use serenity::framework::StandardFramework; -use serenity::model::application::interaction::Interaction; -use serenity::model::gateway::Ready; -use serenity::model::channel::Message; -use serenity::http::Http; -use serenity::prelude::*; -use songbird::SerenityInit; +use log::{error, info}; -use crate::commands::oai::GPTModel; - -mod commands; mod error_handler; mod db; -struct Handler { - // Open AI Config - oai: Option -} - -#[async_trait] -impl EventHandler for Handler { - async fn message(&self, ctx: Context, msg: Message) { - // Ignore messages from bots - if msg.author.bot { - return; - } - match &self.oai { - Some(oai) => { - match msg.mentions_me(&ctx.http).await { - Ok(mentioned) => { - let bot_in_thread = match msg.channel_id.get_thread_members(&ctx.http).await { - Ok(t) => { - match t.iter().find(|t| t.user_id.unwrap().0 == ctx.cache.current_user_id().0) { - Some(_) => true, - None => false - } - } - Err(_) => false - }; - if mentioned || bot_in_thread { - commands::oai::generate_response(&ctx, &msg, oai).await; - } - } - Err(why) => warn!("Could not check mentions: {:?}", why) - }; - } - None => {} - } - } - - async fn interaction_create(&self, ctx: Context, interaction: Interaction) { - if let Interaction::ApplicationCommand(command) = interaction { - match command.data.name.as_str() { - "play" => commands::audio::play::run(&ctx, &command).await, - "stop" => commands::audio::stop::run(&ctx, &command).await, - "pause" => commands::audio::pause::run(&ctx, &command).await, - "resume" => commands::audio::resume::run(&ctx, &command).await, - "skip" => commands::audio::skip::run(&ctx, &command).await, - "volume" => commands::audio::volume::run(&ctx, &command).await, - _ => { - let content: String = match command.data.name.as_str() { - "ping" => commands::ping::run(&command.data.options), - _ => "Unknown command".to_string() - }; - - if let Err(why) = create_response(&ctx, &command, content).await { - warn!("Cannot respond to slash command: {}", why); - } - } - } - } - } - - async fn ready(&self, ctx: Context, ready: Ready) { - if ready.guilds.is_empty() { - warn!("No ready guilds found"); - } - for guild in ready.guilds { - let audio_config_lock = { - let data_read = ctx.data.read().await; - data_read.get::().expect("Expected AudioConfigs in TypeMap.").clone() - }; - { - let mut audio_configs = audio_config_lock.write().await; - let _ = audio_configs.insert(guild.id, AudioConfig { volume: 1.0 }); - } - let commands = guild.id.set_application_commands(&ctx.http, |commands| { - commands.create_application_command(|command: &mut serenity::builder::CreateApplicationCommand| { commands::ping::register(command) }) - .create_application_command(|command: &mut serenity::builder::CreateApplicationCommand| { commands::audio::play::register(command) }) - .create_application_command(|command: &mut serenity::builder::CreateApplicationCommand| { commands::audio::stop::register(command) }) - .create_application_command(|command: &mut serenity::builder::CreateApplicationCommand| { commands::audio::pause::register(command) }) - .create_application_command(|command: &mut serenity::builder::CreateApplicationCommand| { commands::audio::resume::register(command) }) - .create_application_command(|command: &mut serenity::builder::CreateApplicationCommand| { commands::audio::skip::register(command) }) - .create_application_command(|command: &mut serenity::builder::CreateApplicationCommand| { commands::audio::volume::register(command) }) - }).await; - match commands { - Ok(c) => info!("Registered {} commands for guild {}", c.len(), guild.id.0), - Err(why) => error!("Could not register commands for guild {}: {:?}", guild.id.0, why) - }; - } - } -} #[actix_web::main] async fn main() -> std::io::Result<()> { @@ -121,13 +19,12 @@ async fn main() -> std::io::Result<()> { db::init(); db::load_data(); - // setup_discord_bot(); - let host = env::var("SERVICE_HOST").unwrap_or("localhost".to_string()); let port = env::var("SERVICE_PORT").unwrap_or("5000".to_string()); match HttpServer::new(|| { App::new() + .configure(db::messages::init_routes) .configure(db::spells::init_routes) }) .bind(format!("{}:{}", host, port)) { @@ -143,65 +40,3 @@ async fn main() -> std::io::Result<()> { .run() .await } - -fn setup_discord_bot() { - tokio::spawn(async { - let token: String = env::var("DISCORD_TOKEN").expect("Expected a token in the environment"); - let intents: GatewayIntents = GatewayIntents::all(); - - let http: Http = Http::new(&token); - let (owners, _bot_id) = match http.get_current_application_info().await { - Ok(info) => { - let mut owners: HashSet = HashSet::new(); - if let Some(team) = info.team { - owners.insert(team.owner_user_id); - } else { - owners.insert(info.owner.id); - } - match http.get_current_user().await { - Ok(bot) => (owners, bot.id), - Err(why) => panic!("Could not access the bot id: {:?}", why) - } - }, - Err(why) => panic!("Could not access application info: {:?}", why) - }; - - let handler = match env::var("OPENAI_API_KEY") { - Ok(token) => { - info!("Loaded OpenAI token"); - Handler { - oai: Some(commands::oai::OAI { - client: reqwest::Client::new(), - base_url: "https://api.openai.com/v1".to_string(), - max_attempts: 5, - token, - max_context_questions: 30, - max_tokens: 2048, - default_model: GPTModel::GPT35Turbo, - }) - } - } - Err(err) => { - warn!("Could not load OpenAI token: {}", err); - Handler { oai: None } - } - }; - - let mut client = Client::builder(token, intents) - .event_handler(handler) - .framework(StandardFramework::new() - .configure(|c| c.owners(owners))) - .register_songbird() - .await - .expect("Error creating client"); - - { - let mut data = client.data.write().await; - data.insert::(Arc::new(RwLock::new(HashMap::default()))); - } - - if let Err(why) = client.start_autosharded().await { - error!("An error occurred while running the client: {:?}", why); - } - }); -} From ecc65222b6c77861200b9f94fb61de6f408077c4 Mon Sep 17 00:00:00 2001 From: Benjamin Sherriff Date: Wed, 4 Oct 2023 19:58:54 -0400 Subject: [PATCH 14/14] Added lib to service --- bot/Cargo.toml | 1 + bot/Dockerfile | 37 +++++++++++++++ bot/Makefile | 27 +++++++++++ bot/docker-compose.yml | 20 ++++++++ bot/src/commands/oai.rs | 60 ++++-------------------- bot/src/error_handler.rs | 35 -------------- bot/src/main.rs | 1 - service/.env.TEMPLATE | 2 +- service/Cargo.toml | 6 ++- service/Dockerfile | 40 ++++------------ service/Makefile | 20 +++----- service/build.rs | 4 -- service/docker-compose.yml | 6 ++- service/src/db/messages/model.rs | 3 +- service/src/db/messages/routes.rs | 3 +- service/src/db/mod.rs | 18 +------ service/src/db/spells/model.rs | 3 +- service/src/db/spells/routes.rs | 3 +- service/src/{error_handler.rs => lib.rs} | 45 +++++++++++++++++- service/src/main.rs | 1 - 20 files changed, 169 insertions(+), 166 deletions(-) create mode 100644 bot/Dockerfile create mode 100644 bot/Makefile create mode 100644 bot/docker-compose.yml delete mode 100644 bot/src/error_handler.rs delete mode 100644 service/build.rs rename service/src/{error_handler.rs => lib.rs} (62%) diff --git a/bot/Cargo.toml b/bot/Cargo.toml index a4718f5..d57de98 100644 --- a/bot/Cargo.toml +++ b/bot/Cargo.toml @@ -13,6 +13,7 @@ dotenv = "0.15.0" serde_json = "1.0.107" log = "0.4.20" env_logger = "0.10.0" +service = { path = "../service" } [dependencies.serenity] version = "0.11.6" diff --git a/bot/Dockerfile b/bot/Dockerfile new file mode 100644 index 0000000..5811412 --- /dev/null +++ b/bot/Dockerfile @@ -0,0 +1,37 @@ +# Builder +FROM rust:1.72.1-bookworm as builder +WORKDIR /builder + +COPY src ./src +COPY Cargo.toml ./ +RUN cargo build --release + +# Packages +FROM debian:bullseye-slim as packages +WORKDIR /packages + +RUN apt-get update && apt-get install -y curl tar xz-utils && \ + curl -L https://github.com/yt-dlp/yt-dlp/releases/latest/download/yt-dlp_linux > yt-dlp && \ + chmod +x yt-dlp && \ + curl -L https://github.com/yt-dlp/FFmpeg-Builds/releases/download/latest/ffmpeg-master-latest-linux64-gpl.tar.xz > ffmpeg.tar.xz && \ + tar -xJf ffmpeg.tar.xz --wildcards */bin/ffmpeg --transform='s/^.*\///' && rm ffmpeg.tar.xz + +# FROM debian:bullseye-slim as libraries +# WORKDIR /libraries +# RUN apt-get update && apt-get install -y unzip && \ +# curl -L https://download.pytorch.org/libtorch/cu117/libtorch-cxx11-abi-shared-with-deps-2.0.1%2Bcu117.zip > libtorch.zip && \ +# unzip libtorch.zip && rm libtorch.zip + +# Runner +FROM debian:bullseye-slim as runtime +WORKDIR /bot +RUN apt-get update && apt-get install -y libopus-dev libpq5 libpq-dev && apt-get auto-remove -y +COPY --from=builder /builder/target/release/bot /usr/local/bin/bot +COPY --from=packages /packages /usr/bin +# COPY --from=libraries /libraries /usr/lib + +# ARG LIBTORCH=/usr/lib/libtorch +# ARG LD_LIBRARY_PATH=${LIBTORCH}/lib:${LD_LIBRARY_PATH} + +# ADD migrations ./ +CMD ["bot"] diff --git a/bot/Makefile b/bot/Makefile new file mode 100644 index 0000000..0845f7c --- /dev/null +++ b/bot/Makefile @@ -0,0 +1,27 @@ +#!make +SHELL := /bin/bash + +include .env + +.PHONY: help build test up down exec clean + +help: ## Help command + @echo + @cat Makefile | grep -E '^[a-zA-Z\/_-]+:.*?## .*$$' | awk 'BEGIN {FS = ":.*?## "}; {printf "\033[36m%-30s\033[0m %s\n", $$1, $$2}' + @echo + +build: ## Build the docker image + docker compose build + +db: ## Start the docker database + docker compose up -d db + +up: ## Start the app + docker compose up -d + +down: ## Stop the app + docker compose down + +clean: + docker compose down && \ + docker image rm siren-bot diff --git a/bot/docker-compose.yml b/bot/docker-compose.yml new file mode 100644 index 0000000..67cde8b --- /dev/null +++ b/bot/docker-compose.yml @@ -0,0 +1,20 @@ +version: '3.8' + +services: + bot: + image: siren-bot:${BOT_VERSION:-latest} + container_name: siren-bot + build: + context: . + dockerfile: ./Dockerfile + args: + - VERSION=${BOT_VERSION:-latest} + env_file: + - .env + networks: + - frontend + restart: unless-stopped + + +networks: + frontend: diff --git a/bot/src/commands/oai.rs b/bot/src/commands/oai.rs index 87d0524..2727f58 100644 --- a/bot/src/commands/oai.rs +++ b/bot/src/commands/oai.rs @@ -6,8 +6,7 @@ use serenity::model::Permissions; use serenity::model::channel::Message; use serenity::model::prelude::{ChannelType, PermissionOverwrite, PermissionOverwriteType}; use serenity::prelude::*; - -use crate::error_handler::BotError; +use siren::{GetResponse, ServiceError}; pub struct OAI { pub client: reqwest::Client, @@ -126,51 +125,8 @@ enum ResponseEvent { ResponseError(ResponseError) } -#[derive(Serialize, Deserialize)] -pub struct GetResponse { - pub data: T, - #[serde(skip_serializing_if = "Option::is_none")] - pub metadata: Option -} - -#[derive(Serialize, Deserialize)] -pub struct Metadata { - pub total: i32, - pub limit: i32, - pub page: i32, - pub pages: i32 -} - -#[derive(Serialize, Deserialize)] -pub struct QueryMessage { - pub id: String, - pub guild_id: i64, - pub channel_id: i64, - pub user_id: i64, - pub created: i64, - pub model: String, - pub request: String, - pub response: String, - pub request_tags: Vec, - pub response_tags: Vec, -} - -#[derive(Serialize, Deserialize)] -pub struct InsertMessage { - pub id: String, - pub guild_id: i64, - pub channel_id: i64, - pub user_id: i64, - pub created: i64, - pub model: String, - pub request: String, - pub response: String, - pub request_tags: Vec, - pub response_tags: Vec, -} - impl OAI { - async fn get_request(&self, request: ChatCompletionRequest) -> Result { + async fn get_request(&self, request: ChatCompletionRequest) -> Result { let uri = format!("{}/chat/completions", self.base_url); let body = serde_json::to_string(&request).unwrap(); trace!("Sending request to {}: {}", uri, body); @@ -204,7 +160,7 @@ impl OAI { Ok(response) } - async fn get_messages(&self, guild_id: u64, channel_id: u64, author_id: u64) -> Result>, BotError> { + async fn get_messages(&self, guild_id: u64, channel_id: u64, author_id: u64) -> Result>, ServiceError> { let uri = format!("{}/messages?guild_id={}&channel_id={}&author_id={}&limit={}", self.service_url, guild_id, channel_id, author_id, self.max_context_questions); let value = self.client .get(&uri) @@ -213,23 +169,23 @@ impl OAI { .json::() .await?; - let response = serde_json::from_value::>>(value)?; + let response = serde_json::from_value::>>(value)?; Ok(response) } - async fn store_message(&self, message: InsertMessage) -> Result { + async fn store_message(&self, message: siren::Message) -> Result { let uri = format!("{}/messages", self.service_url); trace!("Sending request to {}", uri); let value = self.client .post(&uri) - .json::(&message) + .json::(&message) .send() .await? .json::() .await?; trace!("Received response from Service: {:?}", value); - let response = serde_json::from_value::(value)?; + let response = serde_json::from_value::(value)?; Ok(response) } } @@ -314,7 +270,7 @@ pub async fn generate_response(ctx: &Context, msg: &Message, oai: &OAI) { debug!("Processing response received from OpenAI"); if !r.choices.is_empty() { let res = r.choices[0].message.content.clone(); - if let Err(err) = oai.store_message(InsertMessage { + if let Err(err) = oai.store_message(siren::Message { id: r.id, guild_id: guild_id.0 as i64, channel_id: response_channel.0 as i64, diff --git a/bot/src/error_handler.rs b/bot/src/error_handler.rs deleted file mode 100644 index 1493c13..0000000 --- a/bot/src/error_handler.rs +++ /dev/null @@ -1,35 +0,0 @@ -use serde::{Deserialize, Serialize}; -use std::fmt; - -#[derive(Debug, Deserialize, Serialize)] -pub struct BotError { - pub status: u16, - pub message: String, -} - -impl BotError { - pub fn new(error_status_code: u16, error_message: String) -> BotError { - BotError { - status: error_status_code, - message: error_message, - } - } -} - -impl fmt::Display for BotError { - fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { - f.write_str(self.message.as_str()) - } -} - -impl From for BotError { - fn from(error: reqwest::Error) -> BotError { - BotError::new(500, format!("Unknown reqwest error: {}", error)) - } -} - -impl From for BotError { - fn from(error: serde_json::Error) -> BotError { - BotError::new(500, format!("Unknown serde_json error: {}", error)) - } -} diff --git a/bot/src/main.rs b/bot/src/main.rs index 8f4b9b9..c0857d2 100644 --- a/bot/src/main.rs +++ b/bot/src/main.rs @@ -18,7 +18,6 @@ use songbird::SerenityInit; use crate::commands::oai::GPTModel; mod commands; -mod error_handler; struct Handler { // Open AI Config diff --git a/service/.env.TEMPLATE b/service/.env.TEMPLATE index d8e2748..95ba80b 100644 --- a/service/.env.TEMPLATE +++ b/service/.env.TEMPLATE @@ -1,4 +1,4 @@ -RUST_LOG=warn,siren=info +RUST_LOG=warn,service=info COMPOSE_PROJECT_NAME=siren DATABASE_USER=siren diff --git a/service/Cargo.toml b/service/Cargo.toml index b6039b9..067c94b 100644 --- a/service/Cargo.toml +++ b/service/Cargo.toml @@ -1,5 +1,5 @@ [package] -name = "siren" +name = "service" version = "0.2.4" edition = "2021" authors = ["Ben Sherriff "] @@ -7,6 +7,10 @@ repository = "https://github.com/bensherriff/siren" readme = "README.md" license = "GPL-3.0-or-later" +[lib] +name = "siren" +path = "src/lib.rs" + [dependencies] actix-web = "4.4.0" actix-rt = "2.9.0" diff --git a/service/Dockerfile b/service/Dockerfile index 405bf0d..11a9177 100644 --- a/service/Dockerfile +++ b/service/Dockerfile @@ -1,36 +1,12 @@ -# Builder FROM rust:1.72.1-bookworm as builder -WORKDIR /siren -ADD src ./src/ -ADD Cargo.toml ./ -RUN apt-get update && apt-get install -y cmake && \ - cargo build --release --bin siren -# Packages -FROM debian:bullseye-slim as packages -WORKDIR /packages -RUN apt-get update && apt-get install -y curl tar xz-utils && \ - curl -L https://github.com/yt-dlp/yt-dlp/releases/latest/download/yt-dlp_linux > yt-dlp && \ - chmod +x yt-dlp && \ - curl -L https://github.com/yt-dlp/FFmpeg-Builds/releases/download/latest/ffmpeg-master-latest-linux64-gpl.tar.xz > ffmpeg.tar.xz && \ - tar -xJf ffmpeg.tar.xz --wildcards */bin/ffmpeg --transform='s/^.*\///' && rm ffmpeg.tar.xz +WORKDIR /service +USER root -# FROM debian:bullseye-slim as libraries -# WORKDIR /libraries -# RUN apt-get update && apt-get install -y unzip && \ -# curl -L https://download.pytorch.org/libtorch/cu117/libtorch-cxx11-abi-shared-with-deps-2.0.1%2Bcu117.zip > libtorch.zip && \ -# unzip libtorch.zip && rm libtorch.zip +COPY migrations ./migrations +COPY data ./data +COPY src ./src +COPY Cargo.toml ./ -# Runner -FROM debian:bullseye-slim as runtime -WORKDIR /siren -RUN apt-get update && apt-get install -y libopus-dev libpq5 libpq-dev && apt-get auto-remove -y -COPY --from=builder /siren/target/release/siren /usr/local/bin/siren -COPY --from=packages /packages /usr/bin -# COPY --from=libraries /libraries /usr/lib - -# ARG LIBTORCH=/usr/lib/libtorch -# ARG LD_LIBRARY_PATH=${LIBTORCH}/lib:${LD_LIBRARY_PATH} - -# ADD migrations ./ -CMD ["siren"] +RUN cargo build --release +CMD ["./target/release/service"] \ No newline at end of file diff --git a/service/Makefile b/service/Makefile index 7898510..895c6dc 100644 --- a/service/Makefile +++ b/service/Makefile @@ -2,11 +2,6 @@ SHELL := /bin/bash include .env -include .version -export $(shell sed 's/=.*//' .env) -export $(shell sed 's/=.*//' .version) - -SIREN_IMAGES = $(shell docker images 'siren' -a -q) .PHONY: help build test up down exec clean @@ -16,10 +11,7 @@ help: ## Help command @echo build: ## Build the docker image - docker build -t siren:${SIREN_VERSION} . - -test: ## Run the docker app as a container - docker run --env-file .env -it --rm --name siren siren:${SIREN_VERSION} + docker compose build db: ## Start the docker database docker compose up -d db @@ -30,8 +22,8 @@ up: ## Start the app down: ## Stop the app docker compose down -exec: ## Enter running docker container - docker exec -it siren bash - -clean: ## Cleanup docker images - docker rmi $(SIREN_IMAGES) +clean: + docker compose down && \ + docker image rm siren-service || \ + docker network rm siren_frontend || \ + docker network rm siren-backend \ No newline at end of file diff --git a/service/build.rs b/service/build.rs deleted file mode 100644 index 3e40437..0000000 --- a/service/build.rs +++ /dev/null @@ -1,4 +0,0 @@ -fn main() { - let home = std::env::var("HOME").expect("${HOME} is missing"); - println!("cargo:rustc-env=LD_LIBRARY_PATH={home}/.pyenv/versions/3.11.4/lib"); -} \ No newline at end of file diff --git a/service/docker-compose.yml b/service/docker-compose.yml index 286a197..6dde2aa 100644 --- a/service/docker-compose.yml +++ b/service/docker-compose.yml @@ -1,8 +1,8 @@ version: '3.8' services: - siren: - image: siren:${SIREN_VERSION:-latest} + service: + image: siren-service:${SIREN_VERSION:-latest} container_name: siren-service build: context: . @@ -16,6 +16,8 @@ services: DATABASE_PORT: 5432 SERVICE_HOST: siren SERVICE_PORT: 5000 + ports: + - ${SERVICE_PORT:-5000}:5000 depends_on: - db networks: diff --git a/service/src/db/messages/model.rs b/service/src/db/messages/model.rs index 3356ec0..a5f9ebb 100644 --- a/service/src/db/messages/model.rs +++ b/service/src/db/messages/model.rs @@ -1,7 +1,8 @@ use diesel::prelude::*; use serde::{Deserialize, Serialize}; +use siren::ServiceError; -use crate::{db::schema::messages::{self}, error_handler::ServiceError}; +use crate::db::schema::messages::{self}; #[derive(Queryable, Selectable, Serialize, Deserialize)] #[diesel(table_name = messages)] diff --git a/service/src/db/messages/routes.rs b/service/src/db/messages/routes.rs index 96ebbae..6f1ea21 100644 --- a/service/src/db/messages/routes.rs +++ b/service/src/db/messages/routes.rs @@ -1,8 +1,9 @@ use actix_web::{get, post, web, HttpResponse, HttpRequest, ResponseError}; use log::error; use serde::{Serialize, Deserialize}; +use siren::{GetResponse, Metadata, ServiceError}; -use crate::{db::{messages::{QueryMessage, QueryFilters, InsertMessage}, GetResponse, Metadata}, error_handler::ServiceError}; +use crate::db::messages::{QueryMessage, QueryFilters, InsertMessage}; #[derive(Serialize, Deserialize)] struct GetAllParams { diff --git a/service/src/db/mod.rs b/service/src/db/mod.rs index 4c31158..8325a8c 100644 --- a/service/src/db/mod.rs +++ b/service/src/db/mod.rs @@ -1,6 +1,5 @@ -use crate::error_handler::ServiceError; use diesel::{r2d2::ConnectionManager, PgConnection}; -use serde::{Deserialize, Serialize}; +use siren::ServiceError; use crate::diesel_migrations::MigrationHarness; use lazy_static::lazy_static; use log::{error, info}; @@ -55,18 +54,3 @@ pub fn connection() -> Result { pub fn load_data() { spells::load_data(); } - -#[derive(Serialize, Deserialize)] -pub struct GetResponse { - pub data: T, - #[serde(skip_serializing_if = "Option::is_none")] - pub metadata: Option -} - -#[derive(Serialize, Deserialize)] -pub struct Metadata { - pub total: i32, - pub limit: i32, - pub page: i32, - pub pages: i32 -} diff --git a/service/src/db/spells/model.rs b/service/src/db/spells/model.rs index dc97cbb..1616134 100644 --- a/service/src/db/spells/model.rs +++ b/service/src/db/spells/model.rs @@ -1,7 +1,8 @@ use diesel::prelude::*; use serde::{Deserialize, Serialize}; +use siren::ServiceError; -use crate::{db::{schema::spells::{self}, classes::AbilityType, conditions::ConditionType}, error_handler::ServiceError}; +use crate::db::{schema::spells::{self}, classes::AbilityType, conditions::ConditionType}; use super::{SchoolType, CastingTime, CastingType, SpellAttackType, SpellDamageType, Range, Area, Components, Duration, Source, Description, DurationType}; diff --git a/service/src/db/spells/routes.rs b/service/src/db/spells/routes.rs index 3905866..25ebf11 100644 --- a/service/src/db/spells/routes.rs +++ b/service/src/db/spells/routes.rs @@ -1,8 +1,9 @@ use actix_web::{get, post, put, delete, web, HttpResponse, HttpRequest, ResponseError}; use log::error; use serde::{Serialize, Deserialize}; +use siren::{GetResponse, Metadata, ServiceError}; -use crate::{db::{spells::{QuerySpell, QueryFilters}, GetResponse, Metadata}, error_handler::ServiceError}; +use crate::db::spells::{QuerySpell, QueryFilters}; use super::{Spell, InsertSpell}; diff --git a/service/src/error_handler.rs b/service/src/lib.rs similarity index 62% rename from service/src/error_handler.rs rename to service/src/lib.rs index 695e04f..f1d97fa 100644 --- a/service/src/error_handler.rs +++ b/service/src/lib.rs @@ -1,9 +1,38 @@ use actix_web::{ResponseError, HttpResponse}; use diesel::result::Error as DieselError; use reqwest::StatusCode; -use serde::{Deserialize, Serialize}; +use serde::{Serialize, Deserialize}; use std::fmt; +#[derive(Serialize, Deserialize)] +pub struct Message { + pub id: String, + pub guild_id: i64, + pub channel_id: i64, + pub user_id: i64, + pub created: i64, + pub model: String, + pub request: String, + pub response: String, + pub request_tags: Vec, + pub response_tags: Vec, +} + +#[derive(Serialize, Deserialize)] +pub struct GetResponse { + pub data: T, + #[serde(skip_serializing_if = "Option::is_none")] + pub metadata: Option +} + +#[derive(Serialize, Deserialize)] +pub struct Metadata { + pub total: i32, + pub limit: i32, + pub page: i32, + pub pages: i32 +} + #[derive(Debug, Deserialize, Serialize)] pub struct ServiceError { pub status: u16, @@ -40,6 +69,18 @@ impl From for ServiceError { } } +impl From for ServiceError { + fn from(error: reqwest::Error) -> ServiceError { + ServiceError::new(500, format!("Unknown reqwest error: {}", error)) + } +} + +impl From for ServiceError { + fn from(error: serde_json::Error) -> ServiceError { + ServiceError::new(500, format!("Unknown serde_json error: {}", error)) + } +} + impl ResponseError for ServiceError { fn error_response(&self) -> HttpResponse { let status_code = match StatusCode::from_u16(self.status) { @@ -54,4 +95,4 @@ impl ResponseError for ServiceError { HttpResponse::build(status_code).json(serde_json::json!({ "status": status_code.as_u16(), "message": error_message })) } -} +} \ No newline at end of file diff --git a/service/src/main.rs b/service/src/main.rs index b7fffd8..2077335 100644 --- a/service/src/main.rs +++ b/service/src/main.rs @@ -9,7 +9,6 @@ use actix_web::{HttpServer, App}; use dotenv::dotenv; use log::{error, info}; -mod error_handler; mod db; #[actix_web::main]