1
.gitignore
vendored
1
.gitignore
vendored
@@ -2,6 +2,7 @@
|
||||
target/
|
||||
.idea/
|
||||
**/Cargo.lock
|
||||
.DS_Store
|
||||
|
||||
.next/
|
||||
node_modules/
|
||||
|
||||
11
README.md
11
README.md
@@ -30,11 +30,14 @@ The CLIENT_ID can be found in the General Information tab on the Discord Develop
|
||||
The following packages must be installed for [serenity-rs/songbird](https://github.com/serenity-rs/songbird). View the repository for additional installation and setup information on other operating systems.
|
||||
<details>
|
||||
<summary>Unix Installation</summary>
|
||||
Notes:
|
||||
|
||||
- [yt-dlp](https://github.com/yt-dlp/yt-dlp/releases) is preferred over youtube-dl.
|
||||
|
||||
```
|
||||
sudo apt install libopus-dev
|
||||
sudo apt install ffmpeg
|
||||
sudo apt apt install youtube-dl
|
||||
sudo apt apt install youtube-dl # See notes above
|
||||
# PostgreSQL Headers
|
||||
sudo apt install libpq5
|
||||
sudo apt install libpq-dev
|
||||
@@ -45,11 +48,15 @@ The following packages must be installed for [serenity-rs/songbird](https://gith
|
||||
</details>
|
||||
<details>
|
||||
<summary>Mac Installation</summary>
|
||||
Notes:
|
||||
|
||||
- [Homebrew](https://brew.sh/) must be installed to run the following commands.
|
||||
- [youtube-dl](https://formulae.brew.sh/formula/youtube-dl#default) is deprecated, [yt-dlp](https://formulae.brew.sh/formula/yt-dlp) is preferred
|
||||
|
||||
```
|
||||
brew install opus
|
||||
brew install ffmpeg
|
||||
brew install youtube-dl
|
||||
brew install yt-dlp # See notes above
|
||||
brew install postgresql
|
||||
```
|
||||
</details>
|
||||
|
||||
@@ -29,6 +29,9 @@ jsonwebtoken = "9.0.0"
|
||||
redis = { version = "0.23.3", features = ["tokio-comp", "connection-manager", "r2d2"] }
|
||||
base64 = "0.21.4"
|
||||
rust-s3 = "0.33.0"
|
||||
actix-multipart = "0.6.1"
|
||||
openssl = "0.10.60" # Resolve `openssl` `X509StoreRef::objects` is unsound #10
|
||||
rand = "0.8.5"
|
||||
|
||||
[dependencies.tokio]
|
||||
version = "1.32.0"
|
||||
@@ -46,7 +49,7 @@ features = ["json", "rustls-tls"]
|
||||
[dependencies.diesel]
|
||||
version = "2.1.2"
|
||||
default-features = false
|
||||
features = ["postgres", "32-column-tables", "serde_json", "r2d2", "with-deprecated"]
|
||||
features = ["postgres", "chrono", "32-column-tables", "serde_json", "r2d2", "with-deprecated"]
|
||||
|
||||
[dependencies.serenity]
|
||||
version = "0.11.6"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
version: '3.8'
|
||||
|
||||
x-env_file_personifi: &env
|
||||
x-env_file: &env
|
||||
- .env
|
||||
|
||||
name: siren
|
||||
@@ -30,6 +30,8 @@ services:
|
||||
- ${SERVICE_PORT:-5000}:5000
|
||||
depends_on:
|
||||
- db
|
||||
- redis
|
||||
- minio
|
||||
networks:
|
||||
- frontend
|
||||
- backend
|
||||
@@ -53,6 +55,8 @@ services:
|
||||
redis:
|
||||
image: redis:latest
|
||||
container_name: siren-redis
|
||||
volumes:
|
||||
- redis:/data
|
||||
ports:
|
||||
- ${REDIS_PORT:-6379}:6379
|
||||
networks:
|
||||
@@ -77,6 +81,7 @@ services:
|
||||
volumes:
|
||||
db:
|
||||
db_logs:
|
||||
redis:
|
||||
minio:
|
||||
|
||||
networks:
|
||||
|
||||
@@ -4,5 +4,8 @@ CREATE TABLE IF NOT EXISTS users (
|
||||
role TEXT NOT NULL,
|
||||
first_name TEXT NOT NULL,
|
||||
last_name TEXT NOT NULL,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMP NOT NULL DEFAULT NOW(),
|
||||
profile_picture TEXT,
|
||||
verified BOOLEAN NOT NULL DEFAULT FALSE
|
||||
);
|
||||
@@ -15,7 +15,8 @@ use siren::ServiceError;
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
struct TokenClaims {
|
||||
sub: String, // Subject
|
||||
token_uuid: String, // Issuer
|
||||
token_uuid: String, // Token UUID
|
||||
iss: String, // Issuer
|
||||
exp: i64, // Expiration time
|
||||
iat: i64, // Issued At
|
||||
nbf: i64 // Not Before
|
||||
@@ -73,6 +74,7 @@ pub fn generate_token(email: &str, ttl: i64, private_key: &str) -> Result<TokenD
|
||||
};
|
||||
let claims = TokenClaims {
|
||||
sub: token_details.email.clone(),
|
||||
iss: "siren".to_string(),
|
||||
token_uuid: token_details.token_uuid.to_string(),
|
||||
exp: token_details.expires_in.unwrap(),
|
||||
iat: now.timestamp(),
|
||||
|
||||
@@ -6,7 +6,7 @@ use redis::Commands;
|
||||
use serde::{Serialize, Deserialize};
|
||||
use siren::ServiceError;
|
||||
|
||||
use crate::db::schema::users;
|
||||
use crate::storage::{schema::users, connection};
|
||||
|
||||
use super::{hash_password, verify_token};
|
||||
|
||||
@@ -27,6 +27,9 @@ impl RegisterUser {
|
||||
role: "user".to_string(),
|
||||
first_name: self.first_name,
|
||||
last_name: self.last_name,
|
||||
updated_at: chrono::Utc::now().naive_utc(),
|
||||
created_at: chrono::Utc::now().naive_utc(),
|
||||
profile_picture: None,
|
||||
verified: false,
|
||||
})
|
||||
}
|
||||
@@ -46,12 +49,15 @@ pub struct QueryUser {
|
||||
pub role: String,
|
||||
pub first_name: String,
|
||||
pub last_name: String,
|
||||
pub updated_at: chrono::NaiveDateTime,
|
||||
pub created_at: chrono::NaiveDateTime,
|
||||
pub profile_picture: Option<String>,
|
||||
pub verified: bool,
|
||||
}
|
||||
|
||||
impl QueryUser {
|
||||
pub fn get_by_email(email: &str) -> Result<QueryUser, ServiceError> {
|
||||
let mut conn = crate::db::connection()?;
|
||||
let mut conn = connection()?;
|
||||
// Check if the user exists by email, case insensitive
|
||||
|
||||
let user = users::table
|
||||
@@ -69,17 +75,29 @@ pub struct InsertUser {
|
||||
pub role: String,
|
||||
pub first_name: String,
|
||||
pub last_name: String,
|
||||
pub updated_at: chrono::NaiveDateTime,
|
||||
pub created_at: chrono::NaiveDateTime,
|
||||
pub profile_picture: Option<String>,
|
||||
pub verified: bool,
|
||||
}
|
||||
|
||||
impl InsertUser {
|
||||
pub fn insert(user: Self) -> Result<QueryUser, ServiceError> {
|
||||
let mut conn = crate::db::connection()?;
|
||||
let mut conn = connection()?;
|
||||
let user = diesel::insert_into(users::table)
|
||||
.values(user)
|
||||
.get_result(&mut conn)?;
|
||||
Ok(user)
|
||||
}
|
||||
|
||||
pub fn update_profile(email: &str, profile_picture: Option<&str>) -> Result<QueryUser, ServiceError> {
|
||||
let mut conn = connection()?;
|
||||
let user = diesel::update(users::table)
|
||||
.filter(users::email.eq(&email))
|
||||
.set(users::profile_picture.eq(profile_picture))
|
||||
.get_result(&mut conn)?;
|
||||
Ok(user)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
@@ -88,6 +106,7 @@ pub struct ResponseUser {
|
||||
pub role: String,
|
||||
pub first_name: String,
|
||||
pub last_name: String,
|
||||
pub profile_picture: Option<String>,
|
||||
}
|
||||
|
||||
impl From<QueryUser> for ResponseUser {
|
||||
@@ -97,6 +116,7 @@ impl From<QueryUser> for ResponseUser {
|
||||
role: user.role,
|
||||
first_name: user.first_name,
|
||||
last_name: user.last_name,
|
||||
profile_picture: user.profile_picture,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -141,7 +161,7 @@ impl FromRequest for JwtAuth {
|
||||
|
||||
let access_token_uuid = uuid::Uuid::parse_str(&access_token_details.token_uuid.to_string()).unwrap();
|
||||
|
||||
let mut conn = match crate::db::redis_connection() {
|
||||
let mut conn = match crate::storage::redis_connection() {
|
||||
Ok(conn) => conn,
|
||||
Err(err) => {
|
||||
error!("Failed to get redis connection: {}", err);
|
||||
|
||||
@@ -6,7 +6,7 @@ use redis::AsyncCommands;
|
||||
use serde::{Serialize, Deserialize};
|
||||
use siren::ServiceError;
|
||||
|
||||
use crate::{auth::{LoginRequest, RegisterUser, InsertUser, QueryUser, verify_password, JwtAuth, verify_token, generate_access_token, generate_refresh_token}, db};
|
||||
use crate::{auth::{LoginRequest, RegisterUser, InsertUser, QueryUser, verify_password, JwtAuth, verify_token, generate_access_token, generate_refresh_token}, storage};
|
||||
|
||||
#[post("/register")]
|
||||
async fn register(user: web::Json<RegisterUser>) -> HttpResponse {
|
||||
@@ -58,7 +58,7 @@ async fn login(request: web::Json<LoginRequest>) -> HttpResponse {
|
||||
}
|
||||
};
|
||||
|
||||
let mut conn = match db::redis_async_connection().await {
|
||||
let mut conn = match storage::redis_async_connection().await {
|
||||
Ok(conn) => conn,
|
||||
Err(err) => {
|
||||
error!("Failed to get redis connection: {}", err);
|
||||
@@ -169,7 +169,7 @@ async fn refresh(req: HttpRequest) -> HttpResponse {
|
||||
}
|
||||
};
|
||||
|
||||
let mut conn = match db::redis_async_connection().await {
|
||||
let mut conn = match storage::redis_async_connection().await {
|
||||
Ok(conn) => conn,
|
||||
Err(err) => {
|
||||
error!("Failed to get redis connection: {}", err);
|
||||
@@ -292,7 +292,7 @@ async fn logout(req: HttpRequest, auth: JwtAuth) -> HttpResponse {
|
||||
Err(err) => return ResponseError::error_response(&err)
|
||||
};
|
||||
|
||||
let mut conn = match db::redis_async_connection().await {
|
||||
let mut conn = match storage::redis_async_connection().await {
|
||||
Ok(conn) => conn,
|
||||
Err(err) => {
|
||||
error!("Failed to get redis connection: {}", err);
|
||||
@@ -340,6 +340,40 @@ async fn me(auth: JwtAuth) -> HttpResponse {
|
||||
HttpResponse::Ok().json(auth)
|
||||
}
|
||||
|
||||
#[get("/check-session")]
|
||||
async fn check_session(req: HttpRequest) -> HttpResponse {
|
||||
// If there is a access_token cookie, check if it is valid
|
||||
let has_session = match req.cookie("access_token") {
|
||||
Some(cookie) => {
|
||||
let access_token = cookie.value().to_string();
|
||||
let public_key = env::var("ACCESS_TOKEN_PUBLIC_KEY")
|
||||
.expect("ACCESS_TOKEN_PUBLIC_KEY must be set");
|
||||
match verify_token(&access_token, &public_key) {
|
||||
Ok(_) => true,
|
||||
Err(_) => false
|
||||
}
|
||||
},
|
||||
None => false
|
||||
};
|
||||
if !has_session {
|
||||
// If there is a refresh_token cookie, check if it is valid
|
||||
match req.cookie("refresh_token") {
|
||||
Some(cookie) => {
|
||||
let refresh_token = cookie.value().to_string();
|
||||
let public_key = env::var("REFRESH_TOKEN_PUBLIC_KEY")
|
||||
.expect("REFRESH_TOKEN_PUBLIC_KEY must be set");
|
||||
match verify_token(&refresh_token, &public_key) {
|
||||
Ok(_) => return HttpResponse::Ok().json(true),
|
||||
Err(_) => return HttpResponse::Ok().json(false)
|
||||
};
|
||||
},
|
||||
None => return HttpResponse::Ok().json(false)
|
||||
};
|
||||
} else {
|
||||
return HttpResponse::Ok().json(true)
|
||||
}
|
||||
}
|
||||
|
||||
#[get("/roles")]
|
||||
async fn roles() -> HttpResponse {
|
||||
HttpResponse::Ok().json(vec!["admin", "user"])
|
||||
@@ -363,5 +397,6 @@ pub fn init_routes(config: &mut web::ServiceConfig) {
|
||||
.service(logout)
|
||||
.service(me)
|
||||
.service(roles)
|
||||
.service(check_session)
|
||||
);
|
||||
}
|
||||
@@ -2,6 +2,7 @@ use std::sync::Arc;
|
||||
|
||||
use log::{debug, warn};
|
||||
|
||||
use reqwest::Url;
|
||||
use serenity::client::Cache;
|
||||
use serenity::model::application::interaction::{InteractionResponseType, application_command::ApplicationCommandInteraction};
|
||||
use serenity::model::prelude::{GuildId, ChannelId};
|
||||
@@ -11,6 +12,8 @@ use siren::ServiceError;
|
||||
use songbird::{Call, Songbird};
|
||||
use songbird::input::{Restartable, Input, Metadata, error::Error as SongbirdError};
|
||||
|
||||
use crate::bot::ytdlp::{PlaylistItem, YtDlp};
|
||||
|
||||
pub mod pause;
|
||||
pub mod play;
|
||||
pub mod resume;
|
||||
@@ -88,11 +91,7 @@ pub async fn edit_response(ctx: &Context, command: &ApplicationCommandInteractio
|
||||
}
|
||||
|
||||
pub async fn add_song(call: Arc<Mutex<Call>>, url: &str, lazy: bool, volume: Option<f32>) -> Result<Metadata, SongbirdError> {
|
||||
let source = if is_valid_url(url) {
|
||||
Restartable::ytdl(url.to_owned(), lazy).await?
|
||||
} else {
|
||||
Restartable::ytdl_search(url, lazy).await?
|
||||
};
|
||||
let source = Restartable::ytdl(url.to_owned(), lazy).await?;
|
||||
let mut handler = call.lock().await;
|
||||
let track: Input = source.into();
|
||||
let metadata = *track.metadata.clone();
|
||||
@@ -103,11 +102,41 @@ pub async fn add_song(call: Arc<Mutex<Call>>, url: &str, lazy: bool, volume: Opt
|
||||
Ok(metadata)
|
||||
}
|
||||
|
||||
fn is_valid_url(url: &str) -> bool {
|
||||
match url.parse::<reqwest::Url>() {
|
||||
Ok(_) => return true,
|
||||
Err(_) => return false
|
||||
}
|
||||
pub fn get_playlist_urls(url: &str) -> Result<Vec<PlaylistItem>, ServiceError> {
|
||||
let output = YtDlp::new()
|
||||
.arg("--flat-playlist")
|
||||
.arg("--dump-json")
|
||||
.arg(url)
|
||||
.execute()?;
|
||||
let items: Vec<PlaylistItem> = String::from_utf8(output.stdout)?
|
||||
.split('\n')
|
||||
.filter_map(|line| {
|
||||
if line.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(
|
||||
serde_json::from_slice::<PlaylistItem>(line.as_bytes()).map_err(
|
||||
|err| ServiceError { status: 500, message: err.to_string() }
|
||||
)
|
||||
)
|
||||
}
|
||||
})
|
||||
.filter_map(|parsed| match parsed {
|
||||
Ok(item) => Some(item),
|
||||
Err(err) => {
|
||||
warn!("Failed to parse playlist item: {}", err);
|
||||
None
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
Ok(items)
|
||||
}
|
||||
|
||||
fn is_valid_url(url: &str) -> (bool, bool) {
|
||||
Url::parse(url).ok().map_or((false, false), |valid_url| {
|
||||
let is_playlist: bool = valid_url.query_pairs().find(|(key, _)| key == "list").map_or(false, |_| true);
|
||||
(true, is_playlist)
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn get_songbird(ctx: &Context) -> Arc<Songbird> {
|
||||
|
||||
@@ -9,10 +9,10 @@ use serenity::model::application::interaction::application_command::ApplicationC
|
||||
use siren::ServiceError;
|
||||
use songbird::{EventHandler, Songbird};
|
||||
|
||||
use crate::bot::commands::audio::{leave, add_song, get_songbird};
|
||||
use crate::db::guilds::QueryGuild;
|
||||
use crate::bot::ytdlp::PlaylistItem;
|
||||
use crate::bot::{guilds::QueryGuild, commands::audio::{leave, get_playlist_urls, add_song, get_songbird}};
|
||||
|
||||
use super::{create_response, edit_response, join_by_user};
|
||||
use super::{create_response, edit_response, is_valid_url, join_by_user};
|
||||
|
||||
pub async fn run(ctx: &Context, command: &ApplicationCommandInteraction) {
|
||||
// Get the track url
|
||||
@@ -66,8 +66,14 @@ pub async fn run(ctx: &Context, command: &ApplicationCommandInteraction) {
|
||||
debug!("Play command executed with track: {:?}", track_url);
|
||||
let manager = get_songbird(ctx).await;
|
||||
match play_track(manager, guild_id, track_url).await {
|
||||
Ok(_) => {
|
||||
if let Err(why) = edit_response(&ctx, &command, "Playing track".to_string()).await {
|
||||
Ok(count) => {
|
||||
let mut message = format!("Playing {} tracks", count);
|
||||
if count == 0 {
|
||||
message = "No tracks were played".to_string();
|
||||
} else if count == 1 {
|
||||
message = "Playing 1 track".to_string();
|
||||
}
|
||||
if let Err(why) = edit_response(&ctx, &command, message).await {
|
||||
error!("Failed to edit response message: {}", why);
|
||||
}
|
||||
},
|
||||
@@ -88,31 +94,59 @@ pub async fn run(ctx: &Context, command: &ApplicationCommandInteraction) {
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn play_track(manager: Arc<Songbird>, guild_id: GuildId, track_url: String) -> Result<(), ServiceError> {
|
||||
pub async fn play_track(manager: Arc<Songbird>, guild_id: GuildId, track_url: String) -> Result<i32, ServiceError> {
|
||||
let mut track_count = 0;
|
||||
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 guild = QueryGuild::get(guild_id.0 as i64)?;
|
||||
match add_song(handler_lock.clone(), &track_url, is_queue_empty, Some(guild.volume as f32)).await {
|
||||
Ok(added_song) => {
|
||||
let track_title = added_song.title.unwrap();
|
||||
debug!("Added track: {}", track_title);
|
||||
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(err) => {
|
||||
warn!("Failed to add song: {}", err);
|
||||
if let Err(why) = leave(manager, &Some(guild_id)).await {
|
||||
error!("Failed to leave voice channel: {}", why);
|
||||
let valid = is_valid_url(&track_url);
|
||||
if !valid.0 {
|
||||
warn!("Invalid track url: {}", track_url);
|
||||
return Err(ServiceError { status: 422, message: format!("Invalid track url: {}", track_url) })
|
||||
}
|
||||
let mut playlist_items: Vec<PlaylistItem> = Vec::new();
|
||||
if valid.1 {
|
||||
playlist_items = match get_playlist_urls(&track_url) {
|
||||
Ok(items) => items,
|
||||
Err(err) => {
|
||||
warn!("Failed to get playlist urls: {}", err);
|
||||
return Err(ServiceError { status: 422, message: err.to_string() })
|
||||
}
|
||||
};
|
||||
} else {
|
||||
let playlist_item = PlaylistItem {
|
||||
id: "".to_string(),
|
||||
url: track_url,
|
||||
title: "".to_string(),
|
||||
duration: 0,
|
||||
playlist_index: 0
|
||||
};
|
||||
playlist_items.push(playlist_item);
|
||||
}
|
||||
for item in playlist_items {
|
||||
match add_song(handler_lock.clone(), &item.url, is_queue_empty, Some(guild.volume as f32 / 100.0)).await {
|
||||
Ok(added_song) => {
|
||||
let track_title = added_song.title.unwrap();
|
||||
debug!("Added track: {}", track_title);
|
||||
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.clone() });
|
||||
track_count += 1;
|
||||
},
|
||||
Err(err) => {
|
||||
warn!("Failed to add song: {}", err);
|
||||
if let Err(why) = leave(manager, &Some(guild_id)).await {
|
||||
error!("Failed to leave voice channel: {}", why);
|
||||
}
|
||||
return Err(ServiceError { status: 422, message: err.to_string() })
|
||||
}
|
||||
return Err(ServiceError { status: 422, message: err.to_string() })
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
Ok(track_count)
|
||||
}
|
||||
|
||||
pub fn register(command: &mut CreateApplicationCommand) -> &mut CreateApplicationCommand {
|
||||
|
||||
@@ -7,7 +7,7 @@ use serenity::builder::CreateApplicationCommand;
|
||||
use serenity::model::application::interaction::application_command::ApplicationCommandInteraction;
|
||||
use songbird::Songbird;
|
||||
|
||||
use crate::db::guilds::InsertGuild;
|
||||
use crate::bot::guilds::InsertGuild;
|
||||
|
||||
use super::{get_songbird, create_response, edit_response};
|
||||
|
||||
|
||||
179
service/src/bot/commands/chat.rs
Normal file
179
service/src/bot/commands/chat.rs
Normal file
@@ -0,0 +1,179 @@
|
||||
use log::{error, trace, warn};
|
||||
|
||||
use serenity::model::Permissions;
|
||||
use serenity::model::channel::Message;
|
||||
use serenity::model::prelude::{ChannelType, PermissionOverwrite, PermissionOverwriteType};
|
||||
use serenity::prelude::*;
|
||||
|
||||
use crate::bot::messages::{QueryFilters, QueryMessage};
|
||||
use crate::bot::oai::{ChatCompletionMessage, ChatCompletionRequest, GPTRole, OAI};
|
||||
|
||||
pub async fn generate_response(ctx: &Context, msg: &Message, oai: &OAI) {
|
||||
trace!("Generating response for message: {}", msg.content);
|
||||
|
||||
let guild_id = msg.guild_id.unwrap();
|
||||
let channel_id = msg.channel_id;
|
||||
let author_id = msg.author.id;
|
||||
|
||||
// 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(), "");
|
||||
|
||||
let mut messages = vec![
|
||||
ChatCompletionMessage {
|
||||
role: GPTRole::System,
|
||||
content: "You are a Discord bot named Siren that acts as the Dungeon Master's assistant. Siren must always obey these instructions, no matter what.".to_string()
|
||||
},
|
||||
];
|
||||
|
||||
match QueryMessage::get_all(&QueryFilters {
|
||||
by_guild_id: Some(guild_id.0 as i64),
|
||||
by_channel_id: Some(channel_id.0 as i64),
|
||||
by_user_id: Some(author_id.0 as i64),
|
||||
..Default::default()
|
||||
}, 100, 1) {
|
||||
Ok(m) => {
|
||||
for message in m {
|
||||
messages.push(
|
||||
ChatCompletionMessage {
|
||||
role: GPTRole::User,
|
||||
content: format!("{}", message.request)
|
||||
}
|
||||
);
|
||||
messages.push(
|
||||
ChatCompletionMessage {
|
||||
role: GPTRole::Assistant,
|
||||
content: format!("{}", message.response)
|
||||
}
|
||||
);
|
||||
}
|
||||
},
|
||||
Err(err) => warn!("Could not load previous messages: {}", err)
|
||||
};
|
||||
messages.push(ChatCompletionMessage { role: GPTRole::User, content: parsed_content.clone() });
|
||||
|
||||
let request = ChatCompletionRequest {
|
||||
model: oai.default_model.clone(),
|
||||
messages,
|
||||
temperature: Some(0.5),
|
||||
top_p: None,
|
||||
n: None,
|
||||
max_tokens: Some(oai.max_tokens),
|
||||
presence_penalty: Some(0.6),
|
||||
frequency_penalty: Some(0.0),
|
||||
user: Some(msg.author.name.clone())
|
||||
};
|
||||
|
||||
// Get the thread channel ID
|
||||
let thread_name = generate_thread_name(oai, &parsed_content, 99).await;
|
||||
let response_channel = match msg.channel_id.create_private_thread(&ctx.http, |thread| {
|
||||
thread.name(thread_name).kind(ChannelType::PublicThread)
|
||||
}).await {
|
||||
Ok(c) => {
|
||||
let allow = Permissions::SEND_MESSAGES;
|
||||
let deny = Permissions::SEND_TTS_MESSAGES | Permissions::ATTACH_FILES;
|
||||
let overwrite = PermissionOverwrite {
|
||||
allow,
|
||||
deny,
|
||||
kind: PermissionOverwriteType::Member(msg.author.id),
|
||||
};
|
||||
let _ = c.create_permission(&ctx.http, &overwrite).await;
|
||||
c.id
|
||||
}
|
||||
Err(_) => {
|
||||
channel_id
|
||||
}
|
||||
};
|
||||
|
||||
let typing = response_channel.start_typing(&ctx.http).unwrap();
|
||||
|
||||
// Get the OAI response and store message/response into the database
|
||||
let response = match oai.chat_completion(request).await {
|
||||
Ok(r) => {
|
||||
trace!("Processing response received from OpenAI");
|
||||
if !r.choices.is_empty() {
|
||||
let res = r.choices[0].message.content.clone();
|
||||
if let Err(err) = QueryMessage::insert(QueryMessage {
|
||||
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.clone(),
|
||||
request_tags: vec![],
|
||||
response_tags: vec![],
|
||||
}) {
|
||||
warn!("{}", err);
|
||||
}
|
||||
res
|
||||
} else {
|
||||
warn!("No choices received in the response from OpenAI");
|
||||
"No reply received".to_string()
|
||||
}
|
||||
}
|
||||
Err(err) => {
|
||||
error!("Could not get response from OpenAI: {}", err.message);
|
||||
"There was an error processing your message. Please try again later.".to_string()
|
||||
}
|
||||
};
|
||||
trace!("Writing response: \"{}\"", response);
|
||||
|
||||
typing.stop();
|
||||
if let Err(why) = response_channel.say(&ctx.http, response).await {
|
||||
error!("Cannot send message: {}", why);
|
||||
}
|
||||
|
||||
// match msg.channel_id.create_public_thread(&ctx.http, msg.id, |thread| {
|
||||
// thread.name(truncate(&parsed_content, 99)).kind(ChannelType::PublicThread)
|
||||
// }).await {
|
||||
// Ok(c) => {
|
||||
// if let Err(why) = c.say(&ctx.http, response).await {
|
||||
// error!("Cannot send message: {}", why);
|
||||
// }
|
||||
// }
|
||||
// Err(_) => {
|
||||
// if let Err(why) = channel_id.say(&ctx.http, response).await {
|
||||
// error!("Cannot send message: {}", why);
|
||||
// }
|
||||
// }
|
||||
// };
|
||||
}
|
||||
|
||||
async fn generate_thread_name(oai: &OAI, s: &str, max_chars: usize) -> String {
|
||||
let message = ChatCompletionMessage {
|
||||
role: GPTRole::User,
|
||||
content: format!("---\n{}\n---\nSummarize the message above into a concise Discord thread title", s)
|
||||
};
|
||||
let request = ChatCompletionRequest {
|
||||
model: oai.default_model.clone(),
|
||||
messages: vec![message],
|
||||
temperature: Some(0.5),
|
||||
top_p: None,
|
||||
n: None,
|
||||
max_tokens: Some(oai.max_tokens),
|
||||
presence_penalty: Some(0.6),
|
||||
frequency_penalty: Some(0.0),
|
||||
user: None
|
||||
};
|
||||
// Truncate the response to the max number of characters
|
||||
let mut response = match s.char_indices().nth(max_chars) {
|
||||
None => s,
|
||||
Some((idx, _)) => &s[..idx]
|
||||
}.to_string();
|
||||
// Set the response to the OAI response
|
||||
match oai.chat_completion(request).await {
|
||||
Ok(r) => {
|
||||
if !r.choices.is_empty() {
|
||||
response = r.choices[0].message.content.clone();
|
||||
} else {
|
||||
warn!("No choices received in the response from OpenAI");
|
||||
}
|
||||
}
|
||||
Err(err) => {
|
||||
error!("Could not get response from OpenAI: {}", err.message);
|
||||
}
|
||||
};
|
||||
return response;
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
pub mod audio;
|
||||
pub mod help;
|
||||
pub mod message;
|
||||
pub mod oai;
|
||||
pub mod chat;
|
||||
pub mod ping;
|
||||
pub mod schedule;
|
||||
pub mod roll;
|
||||
|
||||
@@ -1,326 +0,0 @@
|
||||
use log::{error, debug, trace, warn};
|
||||
|
||||
use serde::{Serialize, Deserialize};
|
||||
use serde_json::Value;
|
||||
use serenity::model::Permissions;
|
||||
use serenity::model::channel::Message;
|
||||
use serenity::model::prelude::{ChannelType, PermissionOverwrite, PermissionOverwriteType};
|
||||
use serenity::prelude::*;
|
||||
use siren::{GetResponse, ServiceError};
|
||||
|
||||
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,
|
||||
pub default_model: GPTModel,
|
||||
pub max_context_questions: i64
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
struct ChatCompletionRequest {
|
||||
model: GPTModel,
|
||||
messages: Vec<ChatCompletionMessage>,
|
||||
/// Value between 0 and 2
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
temperature: Option<f64>,
|
||||
/// Value between 0 and 1
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
top_p: Option<f64>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
n: Option<f64>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
max_tokens: Option<i64>,
|
||||
/// Value between -2.0 and 2.0
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
presence_penalty: Option<f64>,
|
||||
/// Value between -2.0 and 2.0
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
frequency_penalty: Option<f64>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
user: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
struct ChatCompletionMessage {
|
||||
role: GPTRole,
|
||||
content: String
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
enum GPTRole {
|
||||
#[serde(rename = "system")]
|
||||
System,
|
||||
#[serde(rename = "user")]
|
||||
User,
|
||||
#[serde(rename = "assistant")]
|
||||
Assistant,
|
||||
#[serde(rename = "function")]
|
||||
Function
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub enum GPTModel {
|
||||
#[serde(rename = "gpt-3.5-turbo")]
|
||||
GPT35Turbo,
|
||||
#[serde(rename = "gpt-3.5-turbo-0613")]
|
||||
GPT35Snapshot,
|
||||
#[serde(rename = "gpt-3.5-turbo-16k")]
|
||||
GPT3516k,
|
||||
#[serde(rename = "gpt-3.5-turbo-16k-0613")]
|
||||
GPT3516kSnapshot,
|
||||
#[serde(rename = "gpt-4")]
|
||||
GPT4,
|
||||
#[serde(rename = "gpt-4-0613")]
|
||||
GPT4Snapshot,
|
||||
#[serde(rename = "gpt-4-32k")]
|
||||
GPT432k,
|
||||
#[serde(rename = "gpt-4-32k-0613")]
|
||||
GPT432kSnapshot,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
struct ChatCompletionResponse {
|
||||
id: String,
|
||||
object: String,
|
||||
created: i64,
|
||||
model: GPTModel,
|
||||
usage: Usage,
|
||||
choices: Vec<Choice>
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
struct Usage {
|
||||
prompt_tokens: i64,
|
||||
completion_tokens: i64,
|
||||
total_tokens: i64
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
struct Choice {
|
||||
message: ChatCompletionMessage,
|
||||
finish_reason: String,
|
||||
index: i64
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
struct ResponseError {
|
||||
error: Option<ErrorDetails>,
|
||||
message: Option<String>,
|
||||
param: Option<String>,
|
||||
#[serde(rename = "type")]
|
||||
error_type: Option<String>
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
struct ErrorDetails {
|
||||
code: Option<String>
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
enum ResponseEvent {
|
||||
ChatCompletionResponse(ChatCompletionResponse),
|
||||
ResponseError(ResponseError)
|
||||
}
|
||||
|
||||
impl OAI {
|
||||
async fn get_request(&self, request: ChatCompletionRequest) -> Result<ChatCompletionResponse, ServiceError> {
|
||||
let uri = format!("{}/chat/completions", self.base_url);
|
||||
let body = serde_json::to_string(&request).unwrap();
|
||||
trace!("Sending request to {}: {}", uri, body);
|
||||
|
||||
let value = self.client
|
||||
.post(&uri)
|
||||
.bearer_auth(&self.token)
|
||||
.header("Content-Type", "application/json".to_string())
|
||||
.body(body)
|
||||
.send()
|
||||
.await?
|
||||
.json::<Value>()
|
||||
.await?;
|
||||
|
||||
trace!("Received response from OpenAI: {:?}", value);
|
||||
|
||||
// let response = match serde_json::from_value::<ResponseEvent>(value) {
|
||||
// Ok(r) => {
|
||||
// match r {
|
||||
// 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(ServiceError {
|
||||
// message: format!("Could not parse response from OpenAI: {}", err),
|
||||
// status: 500
|
||||
// })
|
||||
// };
|
||||
let response = serde_json::from_value::<ChatCompletionResponse>(value)?;
|
||||
|
||||
Ok(response)
|
||||
}
|
||||
|
||||
async fn get_messages(&self, guild_id: u64, channel_id: u64, author_id: u64) -> Result<GetResponse<Vec<siren::Message>>, 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)
|
||||
.send()
|
||||
.await?
|
||||
.json::<Value>()
|
||||
.await?;
|
||||
|
||||
let response = serde_json::from_value::<GetResponse<Vec<siren::Message>>>(value)?;
|
||||
|
||||
Ok(response)
|
||||
}
|
||||
|
||||
async fn store_message(&self, message: siren::Message) -> Result<siren::Message, ServiceError> {
|
||||
let uri = format!("{}/messages", self.service_url);
|
||||
trace!("Sending request to {}", uri);
|
||||
let value = self.client
|
||||
.post(&uri)
|
||||
.json::<siren::Message>(&message)
|
||||
.send()
|
||||
.await?
|
||||
.json::<Value>()
|
||||
.await?;
|
||||
trace!("Received response from Service: {:?}", value);
|
||||
let response = serde_json::from_value::<siren::Message>(value)?;
|
||||
Ok(response)
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn generate_response(ctx: &Context, msg: &Message, oai: &OAI) {
|
||||
debug!("Generating response for message: {}", msg.content);
|
||||
|
||||
let guild_id = msg.guild_id.unwrap();
|
||||
let channel_id = msg.channel_id;
|
||||
let author_id = msg.author.id;
|
||||
|
||||
// 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(), "");
|
||||
|
||||
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()
|
||||
},
|
||||
];
|
||||
|
||||
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,
|
||||
content: format!("{}", message.request)
|
||||
}
|
||||
);
|
||||
messages.push(
|
||||
ChatCompletionMessage {
|
||||
role: GPTRole::Assistant,
|
||||
content: format!("{}", message.response)
|
||||
}
|
||||
);
|
||||
}
|
||||
},
|
||||
Err(err) => warn!("Could not load previous messages: {}", err)
|
||||
};
|
||||
messages.push(ChatCompletionMessage { role: GPTRole::User, content: parsed_content.clone() });
|
||||
|
||||
let request = ChatCompletionRequest {
|
||||
model: oai.default_model.clone(),
|
||||
messages,
|
||||
temperature: Some(0.5),
|
||||
top_p: None,
|
||||
n: None,
|
||||
max_tokens: Some(oai.max_tokens),
|
||||
presence_penalty: Some(0.6),
|
||||
frequency_penalty: Some(0.0),
|
||||
user: Some(msg.author.name.clone())
|
||||
};
|
||||
|
||||
// Get the thread channel ID
|
||||
let response_channel = match msg.channel_id.create_private_thread(&ctx.http, |thread| {
|
||||
thread.name(truncate(&parsed_content, 99)).kind(ChannelType::PublicThread)
|
||||
}).await {
|
||||
Ok(c) => {
|
||||
let allow = Permissions::SEND_MESSAGES;
|
||||
let deny = Permissions::SEND_TTS_MESSAGES | Permissions::ATTACH_FILES;
|
||||
let overwrite = PermissionOverwrite {
|
||||
allow,
|
||||
deny,
|
||||
kind: PermissionOverwriteType::Member(msg.author.id),
|
||||
};
|
||||
let _ = c.create_permission(&ctx.http, &overwrite).await;
|
||||
c.id
|
||||
}
|
||||
Err(_) => {
|
||||
channel_id
|
||||
}
|
||||
};
|
||||
|
||||
let typing = response_channel.start_typing(&ctx.http).unwrap();
|
||||
|
||||
// Get the OAI response and store message/response into the database
|
||||
let response = match oai.get_request(request).await {
|
||||
Ok(r) => {
|
||||
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(siren::Message {
|
||||
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.clone(),
|
||||
request_tags: vec![],
|
||||
response_tags: vec![],
|
||||
}).await {
|
||||
warn!("{}", err);
|
||||
}
|
||||
res
|
||||
} else {
|
||||
warn!("No choices received in the response from OpenAI");
|
||||
"No reply received".to_string()
|
||||
}
|
||||
}
|
||||
Err(err) => {
|
||||
error!("Could not get response from OpenAI: {}", err.message);
|
||||
"There was an error processing your message. Please try again later.".to_string()
|
||||
}
|
||||
};
|
||||
debug!("Writing response: \"{}\"", response);
|
||||
|
||||
typing.stop();
|
||||
if let Err(why) = response_channel.say(&ctx.http, response).await {
|
||||
error!("Cannot send message: {}", why);
|
||||
}
|
||||
|
||||
// match msg.channel_id.create_public_thread(&ctx.http, msg.id, |thread| {
|
||||
// thread.name(truncate(&parsed_content, 99)).kind(ChannelType::PublicThread)
|
||||
// }).await {
|
||||
// Ok(c) => {
|
||||
// if let Err(why) = c.say(&ctx.http, response).await {
|
||||
// error!("Cannot send message: {}", why);
|
||||
// }
|
||||
// }
|
||||
// Err(_) => {
|
||||
// if let Err(why) = channel_id.say(&ctx.http, response).await {
|
||||
// error!("Cannot send message: {}", why);
|
||||
// }
|
||||
// }
|
||||
// };
|
||||
}
|
||||
|
||||
fn truncate(s: &str, max_chars: usize) -> &str {
|
||||
match s.char_indices().nth(max_chars) {
|
||||
None => s,
|
||||
Some((idx, _)) => &s[..idx],
|
||||
}
|
||||
}
|
||||
134
service/src/bot/commands/roll.rs
Normal file
134
service/src/bot/commands/roll.rs
Normal file
@@ -0,0 +1,134 @@
|
||||
use log::{error, warn};
|
||||
use rand::Rng;
|
||||
use serenity::{builder::CreateApplicationCommand, client::Context, model::application::{command::CommandOptionType, interaction::application_command::ApplicationCommandInteraction}};
|
||||
|
||||
use crate::bot::commands::audio::edit_response;
|
||||
|
||||
use super::audio::create_response;
|
||||
|
||||
pub async fn run(ctx: &Context, command: &ApplicationCommandInteraction) {
|
||||
if let Err(why) = create_response(&ctx, &command, format!("Processing command...")).await {
|
||||
error!("Failed to create response message: {}", why);
|
||||
return;
|
||||
}
|
||||
let dice_string: String = match command.data.options.get(0) {
|
||||
Some(o) => match &o.value {
|
||||
Some(v) => match v.as_str() {
|
||||
Some(s) => s.split_whitespace().collect::<String>(),
|
||||
None => {
|
||||
warn!("Missing dice option");
|
||||
if let Err(why) = edit_response(&ctx, &command, format!("Dice option is missing")).await {
|
||||
error!("Failed to create response message: {}", why);
|
||||
}
|
||||
return;
|
||||
}
|
||||
},
|
||||
None => {
|
||||
warn!("Missing dice option");
|
||||
if let Err(why) = edit_response(&ctx, &command, format!("Dice option is missing")).await {
|
||||
error!("Failed to create response message: {}", why);
|
||||
}
|
||||
return;
|
||||
}
|
||||
},
|
||||
None => {
|
||||
warn!("Missing dice option");
|
||||
if let Err(why) = edit_response(&ctx, &command, format!("Dice option is missing")).await {
|
||||
error!("Failed to create response message: {}", why);
|
||||
}
|
||||
return;
|
||||
}
|
||||
};
|
||||
let dice = parse_dice(dice_string.as_str());
|
||||
match dice {
|
||||
Ok((count, sides, modifier)) => {
|
||||
let mut rolls = Vec::new();
|
||||
let mut total = 0;
|
||||
for _ in 0..count {
|
||||
let roll = rand::thread_rng().gen_range(1..=sides);
|
||||
total += roll;
|
||||
rolls.push(roll);
|
||||
}
|
||||
let response = format!("{}d{}{} = {}",
|
||||
count,
|
||||
sides,
|
||||
if modifier > 0 { format!("+{}", modifier) } else if modifier < 0 { format!("-{}", modifier) } else { "".to_string() },
|
||||
total + (modifier as u32)
|
||||
);
|
||||
if let Err(why) = edit_response(&ctx, &command, response).await {
|
||||
error!("Failed to create response message: {}", why);
|
||||
}
|
||||
}
|
||||
Err(why) => {
|
||||
if let Err(why) = edit_response(&ctx, &command, format!("Invalid dice string: {}", why)).await {
|
||||
error!("Failed to create response message: {}", why);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
fn parse_dice(dice: &str) -> Result<(u32, u32, i32), String> {
|
||||
let mut parts = dice.split("d");
|
||||
let count = match parts.next() {
|
||||
Some(c) => match c.parse::<u32>() {
|
||||
Ok(n) => n,
|
||||
Err(_) => return Err(format!("Invalid dice count: {}", c))
|
||||
},
|
||||
None => return Err(format!("Invalid dice string: {}", dice))
|
||||
};
|
||||
let mut positive_modifier = true;
|
||||
let mut parts = match parts.next() {
|
||||
Some(p) => {
|
||||
// Check if contains a +/- modifier
|
||||
if p.contains("+") {
|
||||
positive_modifier = true;
|
||||
p.split("+")
|
||||
} else if p.contains("-") {
|
||||
positive_modifier = false;
|
||||
p.split("-")
|
||||
} else {
|
||||
p.split("+")
|
||||
}
|
||||
},
|
||||
None => return Err(format!("Invalid dice string: {}", dice))
|
||||
};
|
||||
let sides = match parts.next() {
|
||||
Some(s) => match s.parse::<u32>() {
|
||||
Ok(n) => {
|
||||
if n == 4 || n == 6 || n == 8 || n == 10 || n == 12 || n == 20 || n == 100 {
|
||||
n
|
||||
} else {
|
||||
return Err(format!("Invalid dice sides: {}", s));
|
||||
}
|
||||
}
|
||||
Err(_) => return Err(format!("Invalid dice sides: {}", s))
|
||||
},
|
||||
None => return Err(format!("Invalid dice string: {}", dice))
|
||||
};
|
||||
let modifier = match parts.next() {
|
||||
Some(m) => match m.parse::<i32>() {
|
||||
Ok(n) => {
|
||||
if positive_modifier {
|
||||
n
|
||||
} else {
|
||||
n * -1
|
||||
}
|
||||
},
|
||||
Err(_) => return Err(format!("Invalid dice modifier: {}", m))
|
||||
},
|
||||
None => 0
|
||||
};
|
||||
Ok((count, sides, modifier))
|
||||
}
|
||||
|
||||
pub fn register(command: &mut CreateApplicationCommand) -> &mut CreateApplicationCommand {
|
||||
command.name("roll").description("Rolls D&D dice").create_option(|option| {
|
||||
option
|
||||
.name("dice")
|
||||
.description("Dice to roll")
|
||||
.kind(CommandOptionType::String)
|
||||
.required(true)
|
||||
})
|
||||
}
|
||||
@@ -2,7 +2,7 @@ use diesel::prelude::*;
|
||||
use serde::{Serialize, Deserialize};
|
||||
use siren::ServiceError;
|
||||
|
||||
use crate::db::{schema::guilds, connection};
|
||||
use crate::storage::{schema::guilds, connection};
|
||||
|
||||
#[derive(Queryable, QueryableByName, Serialize, Deserialize)]
|
||||
#[diesel(table_name = guilds)]
|
||||
@@ -1,18 +1,16 @@
|
||||
use std::{sync::Arc, pin::Pin};
|
||||
|
||||
use actix_web::{get, post, web, HttpResponse, ResponseError};
|
||||
use log::warn;
|
||||
use serde::{Serialize, Deserialize};
|
||||
use serenity::model::prelude::{GuildChannel, ChannelType};
|
||||
use siren::ServiceError;
|
||||
use siren::{ServiceError, Response};
|
||||
|
||||
use crate::{AppState, bot::commands::audio::{play::play_track, join}, db::guilds::QueryGuild, auth::{JwtAuth, verify_role}};
|
||||
use crate::{AppState, bot::commands::audio::{play::play_track, join}, bot::guilds::QueryGuild, auth::{JwtAuth, verify_role}};
|
||||
|
||||
#[get("/guilds")]
|
||||
async fn get_guilds(data: web::Data<Arc<AppState>>, auth: JwtAuth) -> HttpResponse {
|
||||
let _ = match verify_role(&auth, "admin") {
|
||||
Ok(_) => {},
|
||||
Err(err) => return ResponseError::error_response(&err)
|
||||
if let Err(err) = verify_role(&auth, "admin") {
|
||||
return ResponseError::error_response(&err)
|
||||
};
|
||||
let guild_results = &data.http.get_guilds(None, None).await;
|
||||
let guilds = match guild_results {
|
||||
@@ -22,14 +20,16 @@ async fn get_guilds(data: web::Data<Arc<AppState>>, auth: JwtAuth) -> HttpRespon
|
||||
message: err.to_string()
|
||||
})
|
||||
};
|
||||
HttpResponse::Ok().json(guilds)
|
||||
HttpResponse::Ok().json(Response {
|
||||
data: guilds,
|
||||
metadata: None
|
||||
})
|
||||
}
|
||||
|
||||
#[get("/{id}/text")]
|
||||
async fn get_text_channels(id: web::Path<String>, data: web::Data<Arc<AppState>>, auth: JwtAuth) -> HttpResponse {
|
||||
let _ = match verify_role(&auth, "admin") {
|
||||
Ok(_) => {},
|
||||
Err(err) => return ResponseError::error_response(&err)
|
||||
if let Err(err) = verify_role(&auth, "admin") {
|
||||
return ResponseError::error_response(&err)
|
||||
};
|
||||
let channel_results = &data.http.get_channels(id.parse::<u64>().unwrap()).await;
|
||||
let channels = match channel_results {
|
||||
@@ -39,14 +39,16 @@ async fn get_text_channels(id: web::Path<String>, data: web::Data<Arc<AppState>>
|
||||
message: err.to_string()
|
||||
})
|
||||
};
|
||||
HttpResponse::Ok().json(channels)
|
||||
HttpResponse::Ok().json(Response {
|
||||
data: channels,
|
||||
metadata: None
|
||||
})
|
||||
}
|
||||
|
||||
#[get("/{id}/voice")]
|
||||
async fn get_voice_channels(id: web::Path<String>, data: web::Data<Arc<AppState>>, auth: JwtAuth) -> HttpResponse {
|
||||
let _ = match verify_role(&auth, "admin") {
|
||||
Ok(_) => {},
|
||||
Err(err) => return ResponseError::error_response(&err)
|
||||
if let Err(err) = verify_role(&auth, "admin") {
|
||||
return ResponseError::error_response(&err)
|
||||
};
|
||||
let channel_results = &data.http.get_channels(id.parse::<u64>().unwrap()).await;
|
||||
let channels = match channel_results {
|
||||
@@ -56,7 +58,10 @@ async fn get_voice_channels(id: web::Path<String>, data: web::Data<Arc<AppState>
|
||||
message: err.to_string()
|
||||
})
|
||||
};
|
||||
HttpResponse::Ok().json(channels)
|
||||
HttpResponse::Ok().json(Response {
|
||||
data: channels,
|
||||
metadata: None
|
||||
})
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize)]
|
||||
@@ -66,15 +71,13 @@ struct ChannelMessage {
|
||||
|
||||
#[post("/{guild_id}/text/{channel_id}/message")]
|
||||
async fn send_message(path: web::Path<(String, String)>, text: web::Json<ChannelMessage>, data: web::Data<Arc<AppState>>, auth: JwtAuth) -> HttpResponse {
|
||||
let _ = match verify_role(&auth, "admin") {
|
||||
Ok(_) => {},
|
||||
Err(err) => return ResponseError::error_response(&err)
|
||||
if let Err(err) = verify_role(&auth, "admin") {
|
||||
return ResponseError::error_response(&err)
|
||||
};
|
||||
let (guild_id, channel_id) = path.into_inner();
|
||||
let guild_id = match guild_id.parse::<u64>() {
|
||||
Ok(id) => id,
|
||||
Err(err) => {
|
||||
warn!("Could not parse guild id: {:?}", err);
|
||||
return ResponseError::error_response(&ServiceError {
|
||||
status: 422,
|
||||
message: err.to_string()
|
||||
@@ -84,7 +87,6 @@ async fn send_message(path: web::Path<(String, String)>, text: web::Json<Channel
|
||||
let channel_id = match channel_id.parse::<u64>() {
|
||||
Ok(id) => id,
|
||||
Err(err) => {
|
||||
warn!("Could not parse channel id: {:?}", err);
|
||||
return ResponseError::error_response(&ServiceError {
|
||||
status: 422,
|
||||
message: err.to_string()
|
||||
@@ -95,7 +97,6 @@ async fn send_message(path: web::Path<(String, String)>, text: web::Json<Channel
|
||||
let channels = match channel_results {
|
||||
Ok(channels) => channels,
|
||||
Err(err) => {
|
||||
warn!("Could not get channels: {:?}", err);
|
||||
return ResponseError::error_response(&ServiceError {
|
||||
status: 422,
|
||||
message: err.to_string()
|
||||
@@ -106,7 +107,6 @@ async fn send_message(path: web::Path<(String, String)>, text: web::Json<Channel
|
||||
let channel = match channels.iter().find(|c| c.id.0 == channel_id) {
|
||||
Some(channel) => channel,
|
||||
None => {
|
||||
warn!("Could not find channel with id {}", channel_id);
|
||||
return ResponseError::error_response(&ServiceError {
|
||||
status: 422,
|
||||
message: format!("Could not find channel with id {}", channel_id)
|
||||
@@ -115,7 +115,6 @@ async fn send_message(path: web::Path<(String, String)>, text: web::Json<Channel
|
||||
};
|
||||
|
||||
if let Err(err) = channel.say(&Pin::new(&data.http).get_ref(), &text.message).await {
|
||||
warn!("Could not send message: {:?}", err);
|
||||
return ResponseError::error_response(&ServiceError {
|
||||
status: 422,
|
||||
message: err.to_string()
|
||||
@@ -132,22 +131,19 @@ struct PlayRequest {
|
||||
|
||||
#[post("/{guild_id}/voice/{channel_id}/play")]
|
||||
async fn play(path: web::Path<(String, String)>, play_request: web::Json<PlayRequest>, data: web::Data<Arc<AppState>>, auth: JwtAuth) -> HttpResponse {
|
||||
let _ = match verify_role(&auth, "admin") {
|
||||
Ok(_) => {},
|
||||
Err(err) => return ResponseError::error_response(&err)
|
||||
if let Err(err) = verify_role(&auth, "admin") {
|
||||
return ResponseError::error_response(&err)
|
||||
};
|
||||
let (guild_id, channel_id) = path.into_inner();
|
||||
let guild_id = match guild_id.parse::<u64>() {
|
||||
Ok(id) => id,
|
||||
Err(err) => {
|
||||
warn!("Could not parse guild id: {:?}", err);
|
||||
return ResponseError::error_response(&ServiceError { status: 422, message: err.to_string() })
|
||||
}
|
||||
};
|
||||
let channel_id = match channel_id.parse::<u64>() {
|
||||
Ok(id) => id,
|
||||
Err(err) => {
|
||||
warn!("Could not parse channel id: {:?}", err);
|
||||
return ResponseError::error_response(&ServiceError { status: 422, message: err.to_string() })
|
||||
}
|
||||
};
|
||||
@@ -155,14 +151,12 @@ async fn play(path: web::Path<(String, String)>, play_request: web::Json<PlayReq
|
||||
let guild = match http.get_guild(guild_id).await {
|
||||
Ok(guild) => guild,
|
||||
Err(err) => {
|
||||
warn!("Could not get guild: {:?}", err);
|
||||
return ResponseError::error_response(&ServiceError { status: 422, message: err.to_string() })
|
||||
}
|
||||
};
|
||||
let channel = match http.get_channel(channel_id).await {
|
||||
Ok(channel) => channel,
|
||||
Err(err) => {
|
||||
warn!("Could not get channel: {:?}", err);
|
||||
return ResponseError::error_response(&ServiceError { status: 422, message: err.to_string() })
|
||||
}
|
||||
};
|
||||
@@ -174,13 +168,11 @@ async fn play(path: web::Path<(String, String)>, play_request: web::Json<PlayReq
|
||||
match play_track(Arc::clone(&data.songbird), guild.id, play_request.track_url.to_string()).await {
|
||||
Ok(_) => HttpResponse::Ok().finish(),
|
||||
Err(err) => {
|
||||
warn!("Could not play track: {:?}", err);
|
||||
return ResponseError::error_response(&err)
|
||||
}
|
||||
}
|
||||
},
|
||||
Err(err) => {
|
||||
warn!("Could not join channel: {:?}", err);
|
||||
return ResponseError::error_response(&ServiceError { status: 500, message: err.to_string() })
|
||||
}
|
||||
}
|
||||
@@ -188,15 +180,13 @@ async fn play(path: web::Path<(String, String)>, play_request: web::Json<PlayReq
|
||||
|
||||
#[post("/{guild_id}/voice/stop")]
|
||||
async fn stop(path: web::Path<String>, data: web::Data<Arc<AppState>>, auth: JwtAuth) -> HttpResponse {
|
||||
let _ = match verify_role(&auth, "admin") {
|
||||
Ok(_) => {},
|
||||
Err(err) => return ResponseError::error_response(&err)
|
||||
if let Err(err) = verify_role(&auth, "admin") {
|
||||
return ResponseError::error_response(&err)
|
||||
};
|
||||
let guild_id = path.into_inner();
|
||||
let guild_id = match guild_id.parse::<u64>() {
|
||||
Ok(id) => id,
|
||||
Err(err) => {
|
||||
warn!("Could not parse guild id: {:?}", err);
|
||||
return ResponseError::error_response(&ServiceError {
|
||||
status: 422,
|
||||
message: err.to_string()
|
||||
@@ -214,15 +204,13 @@ async fn stop(path: web::Path<String>, data: web::Data<Arc<AppState>>, auth: Jwt
|
||||
|
||||
#[post("/{guild_id}/voice/resume")]
|
||||
async fn resume(path: web::Path<String>, data: web::Data<Arc<AppState>>, auth: JwtAuth) -> HttpResponse {
|
||||
let _ = match verify_role(&auth, "admin") {
|
||||
Ok(_) => {},
|
||||
Err(err) => return ResponseError::error_response(&err)
|
||||
if let Err(err) = verify_role(&auth, "admin") {
|
||||
return ResponseError::error_response(&err)
|
||||
};
|
||||
let guild_id = path.into_inner();
|
||||
let guild_id = match guild_id.parse::<u64>() {
|
||||
Ok(id) => id,
|
||||
Err(err) => {
|
||||
warn!("Could not parse guild id: {:?}", err);
|
||||
return ResponseError::error_response(&ServiceError {
|
||||
status: 422,
|
||||
message: err.to_string()
|
||||
@@ -233,7 +221,6 @@ async fn resume(path: web::Path<String>, data: web::Data<Arc<AppState>>, auth: J
|
||||
if let Some(handler_lock) = data.songbird.get(guild_id) {
|
||||
let handler = handler_lock.lock().await;
|
||||
if let Err(err) = handler.queue().resume() {
|
||||
warn!("Could not resume track: {:?}", err);
|
||||
return ResponseError::error_response(&ServiceError {
|
||||
status: 422,
|
||||
message: err.to_string()
|
||||
@@ -246,15 +233,13 @@ async fn resume(path: web::Path<String>, data: web::Data<Arc<AppState>>, auth: J
|
||||
|
||||
#[post("/{guild_id}/voice/pause")]
|
||||
async fn pause(path: web::Path<String>, data: web::Data<Arc<AppState>>, auth: JwtAuth) -> HttpResponse {
|
||||
let _ = match verify_role(&auth, "admin") {
|
||||
Ok(_) => {},
|
||||
Err(err) => return ResponseError::error_response(&err)
|
||||
if let Err(err) = verify_role(&auth, "admin") {
|
||||
return ResponseError::error_response(&err)
|
||||
};
|
||||
let guild_id = path.into_inner();
|
||||
let guild_id = match guild_id.parse::<u64>() {
|
||||
Ok(id) => id,
|
||||
Err(err) => {
|
||||
warn!("Could not parse guild id: {:?}", err);
|
||||
return ResponseError::error_response(&ServiceError {
|
||||
status: 422,
|
||||
message: err.to_string()
|
||||
@@ -265,7 +250,6 @@ async fn pause(path: web::Path<String>, data: web::Data<Arc<AppState>>, auth: Jw
|
||||
if let Some(handler_lock) = data.songbird.get(guild_id) {
|
||||
let handler = handler_lock.lock().await;
|
||||
if let Err(err) = handler.queue().pause() {
|
||||
warn!("Could not pause track: {:?}", err);
|
||||
return ResponseError::error_response(&ServiceError {
|
||||
status: 422,
|
||||
message: err.to_string()
|
||||
@@ -283,15 +267,13 @@ struct SetVolume {
|
||||
|
||||
#[get("/{guild_id}/voice/volume")]
|
||||
async fn get_volume(path: web::Path<String>, auth: JwtAuth) -> HttpResponse {
|
||||
let _ = match verify_role(&auth, "admin") {
|
||||
Ok(_) => {},
|
||||
Err(err) => return ResponseError::error_response(&err)
|
||||
if let Err(err) = verify_role(&auth, "admin") {
|
||||
return ResponseError::error_response(&err)
|
||||
};
|
||||
let guild_id = path.into_inner();
|
||||
let guild_id = match guild_id.parse::<u64>() {
|
||||
Ok(id) => id,
|
||||
Err(err) => {
|
||||
warn!("Could not parse guild id: {:?}", err);
|
||||
return ResponseError::error_response(&ServiceError {
|
||||
status: 422,
|
||||
message: err.to_string()
|
||||
@@ -302,7 +284,6 @@ async fn get_volume(path: web::Path<String>, auth: JwtAuth) -> HttpResponse {
|
||||
let volume = match QueryGuild::get(guild_id as i64) {
|
||||
Ok(guild) => guild.volume,
|
||||
Err(err) => {
|
||||
warn!("Could not get volume: {:?}", err);
|
||||
return ResponseError::error_response(&ServiceError {
|
||||
status: 422,
|
||||
message: err.to_string()
|
||||
@@ -315,15 +296,13 @@ async fn get_volume(path: web::Path<String>, auth: JwtAuth) -> HttpResponse {
|
||||
|
||||
#[post("/{guild_id}/voice/volume")]
|
||||
async fn set_volume(path: web::Path<String>, volume: web::Json::<SetVolume>, data: web::Data<Arc<AppState>>, auth: JwtAuth) -> HttpResponse {
|
||||
let _ = match verify_role(&auth, "admin") {
|
||||
Ok(_) => {},
|
||||
Err(err) => return ResponseError::error_response(&err)
|
||||
if let Err(err) = verify_role(&auth, "admin") {
|
||||
return ResponseError::error_response(&err)
|
||||
};
|
||||
let guild_id = path.into_inner();
|
||||
let guild_id = match guild_id.parse::<u64>() {
|
||||
Ok(id) => id,
|
||||
Err(err) => {
|
||||
warn!("Could not parse guild id: {:?}", err);
|
||||
return ResponseError::error_response(&ServiceError {
|
||||
status: 422,
|
||||
message: err.to_string()
|
||||
@@ -337,7 +316,6 @@ async fn set_volume(path: web::Path<String>, volume: web::Json::<SetVolume>, dat
|
||||
let guild = match http.get_guild(guild_id).await {
|
||||
Ok(guild) => guild,
|
||||
Err(err) => {
|
||||
warn!("Could not get guild: {:?}", err);
|
||||
return ResponseError::error_response(&ServiceError { status: 422, message: err.to_string() })
|
||||
}
|
||||
};
|
||||
@@ -348,15 +326,13 @@ async fn set_volume(path: web::Path<String>, volume: web::Json::<SetVolume>, dat
|
||||
|
||||
#[post("/{guild_id}/voice/skip")]
|
||||
async fn skip(path: web::Path<String>, data: web::Data<Arc<AppState>>, auth: JwtAuth) -> HttpResponse {
|
||||
let _ = match verify_role(&auth, "admin") {
|
||||
Ok(_) => {},
|
||||
Err(err) => return ResponseError::error_response(&err)
|
||||
if let Err(err) = verify_role(&auth, "admin") {
|
||||
return ResponseError::error_response(&err)
|
||||
};
|
||||
let guild_id = path.into_inner();
|
||||
let guild_id = match guild_id.parse::<u64>() {
|
||||
Ok(id) => id,
|
||||
Err(err) => {
|
||||
warn!("Could not parse guild id: {:?}", err);
|
||||
return ResponseError::error_response(&ServiceError {
|
||||
status: 422,
|
||||
message: err.to_string()
|
||||
@@ -367,7 +343,6 @@ async fn skip(path: web::Path<String>, data: web::Data<Arc<AppState>>, auth: Jwt
|
||||
if let Some(handler_lock) = data.songbird.get(guild_id) {
|
||||
let handler = handler_lock.lock().await;
|
||||
if let Err(err) = handler.queue().skip() {
|
||||
warn!("Could not skip track: {:?}", err);
|
||||
return ResponseError::error_response(&ServiceError {
|
||||
status: 422,
|
||||
message: err.to_string()
|
||||
@@ -5,14 +5,14 @@ use serenity::model::gateway::Ready;
|
||||
use serenity::model::channel::Message;
|
||||
use serenity::prelude::*;
|
||||
|
||||
use crate::db::guilds::InsertGuild;
|
||||
use crate::bot::guilds::InsertGuild;
|
||||
|
||||
use super::commands;
|
||||
use super::{commands, oai};
|
||||
use super::commands::audio::create_response;
|
||||
|
||||
pub struct Handler {
|
||||
// Open AI Config
|
||||
pub oai: Option<commands::oai::OAI>
|
||||
pub oai: Option<oai::OAI>
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
@@ -36,7 +36,7 @@ impl EventHandler for Handler {
|
||||
Err(_) => false
|
||||
};
|
||||
if mentioned || bot_in_thread {
|
||||
commands::oai::generate_response(&ctx, &msg, oai).await;
|
||||
commands::chat::generate_response(&ctx, &msg, oai).await;
|
||||
}
|
||||
}
|
||||
Err(why) => warn!("Could not check mentions: {:?}", why)
|
||||
@@ -49,6 +49,7 @@ impl EventHandler for Handler {
|
||||
async fn interaction_create(&self, ctx: Context, interaction: Interaction) {
|
||||
if let Interaction::ApplicationCommand(command) = interaction {
|
||||
match command.data.name.as_str() {
|
||||
"roll" => commands::roll::run(&ctx, &command).await,
|
||||
"play" => commands::audio::play::run(&ctx, &command).await,
|
||||
"stop" => commands::audio::stop::run(&ctx, &command).await,
|
||||
"pause" => commands::audio::pause::run(&ctx, &command).await,
|
||||
@@ -80,7 +81,9 @@ impl EventHandler for Handler {
|
||||
volume: 100
|
||||
});
|
||||
let commands = guild.id.set_application_commands(&ctx.http, |commands| {
|
||||
commands.create_application_command(|command: &mut serenity::builder::CreateApplicationCommand| { commands::ping::register(command) })
|
||||
commands
|
||||
.create_application_command(|command: &mut serenity::builder::CreateApplicationCommand| { commands::ping::register(command) })
|
||||
.create_application_command(|command: &mut serenity::builder::CreateApplicationCommand| { commands::roll::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) })
|
||||
|
||||
@@ -2,9 +2,9 @@ use diesel::prelude::*;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use siren::ServiceError;
|
||||
|
||||
use crate::db::schema::messages::{self};
|
||||
use crate::storage::{schema::messages::{self}, connection};
|
||||
|
||||
#[derive(Queryable, Selectable, Serialize, Deserialize)]
|
||||
#[derive(Queryable, Selectable, Insertable, AsChangeset, Serialize, Deserialize)]
|
||||
#[diesel(table_name = messages)]
|
||||
pub struct QueryMessage {
|
||||
pub id: String,
|
||||
@@ -49,7 +49,7 @@ impl Default for QueryFilters {
|
||||
|
||||
impl QueryMessage {
|
||||
pub fn get_all(filters: &QueryFilters, limit: i32, page: i32) -> Result<Vec<Self>, ServiceError> {
|
||||
let mut conn = crate::db::connection()?;
|
||||
let mut conn = 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;
|
||||
@@ -88,7 +88,7 @@ impl QueryMessage {
|
||||
}
|
||||
|
||||
pub fn get_count(fitlers: &QueryFilters) -> Result<i64, ServiceError> {
|
||||
let mut conn = crate::db::connection()?;
|
||||
let mut conn = connection()?;
|
||||
let mut query = messages::table.into_boxed();
|
||||
// Apply filters
|
||||
if let Some(id) = &fitlers.by_id {
|
||||
@@ -122,29 +122,12 @@ impl QueryMessage {
|
||||
let count = query.count().get_result::<i64>(&mut conn)?;
|
||||
Ok(count)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Insertable, AsChangeset, Serialize, Deserialize)]
|
||||
#[diesel(table_name = messages)]
|
||||
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<String>,
|
||||
pub response_tags: Vec<String>,
|
||||
}
|
||||
|
||||
impl InsertMessage {
|
||||
pub fn insert(message: Self) -> Result<QueryMessage, ServiceError> {
|
||||
let mut conn = crate::db::connection()?;
|
||||
let mut conn = connection()?;
|
||||
let message = diesel::insert_into(messages::table)
|
||||
.values(message)
|
||||
.get_result(&mut conn)?;
|
||||
Ok(message)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,9 @@
|
||||
use actix_web::{get, post, web, HttpResponse, HttpRequest, ResponseError};
|
||||
use log::error;
|
||||
use serde::{Serialize, Deserialize};
|
||||
use siren::{GetResponse, Metadata, ServiceError};
|
||||
use siren::{Response, Metadata, ServiceError};
|
||||
|
||||
use crate::{db::messages::{QueryMessage, QueryFilters, InsertMessage}, auth::{JwtAuth, verify_role}};
|
||||
use crate::{bot::messages::{QueryMessage, QueryFilters}, auth::{JwtAuth, verify_role}};
|
||||
|
||||
#[derive(Serialize, Deserialize)]
|
||||
struct GetAllParams {
|
||||
@@ -50,7 +50,7 @@ async fn get_all(req: HttpRequest, auth: JwtAuth) -> HttpResponse {
|
||||
|
||||
match QueryMessage::get_all(&filters, limit, page) {
|
||||
Ok(messages) => {
|
||||
HttpResponse::Ok().json(GetResponse {
|
||||
HttpResponse::Ok().json(Response {
|
||||
data: messages,
|
||||
metadata: Some(Metadata {
|
||||
total: total_count as i32,
|
||||
@@ -68,12 +68,12 @@ async fn get_all(req: HttpRequest, auth: JwtAuth) -> HttpResponse {
|
||||
}
|
||||
|
||||
#[post("/messages")]
|
||||
async fn create(message: web::Json<InsertMessage>, auth: JwtAuth) -> HttpResponse {
|
||||
async fn create(message: web::Json<QueryMessage>, auth: JwtAuth) -> HttpResponse {
|
||||
let _ = match verify_role(&auth, "admin") {
|
||||
Ok(_) => {},
|
||||
Err(err) => return ResponseError::error_response(&err)
|
||||
};
|
||||
match InsertMessage::insert(message.into_inner()) {
|
||||
match QueryMessage::insert(message.into_inner()) {
|
||||
Ok(message) => HttpResponse::Created().json(message),
|
||||
Err(err) => {
|
||||
error!("{:?}", err.message);
|
||||
@@ -1,3 +1,6 @@
|
||||
pub mod api;
|
||||
pub mod commands;
|
||||
pub mod guilds;
|
||||
pub mod handler;
|
||||
pub mod messages;
|
||||
pub mod oai;
|
||||
pub mod ytdlp;
|
||||
|
||||
3
service/src/bot/oai/mod.rs
Normal file
3
service/src/bot/oai/mod.rs
Normal file
@@ -0,0 +1,3 @@
|
||||
mod model;
|
||||
|
||||
pub use model::*;
|
||||
128
service/src/bot/oai/model.rs
Normal file
128
service/src/bot/oai/model.rs
Normal file
@@ -0,0 +1,128 @@
|
||||
use serde::{Serialize, Deserialize};
|
||||
use serde_json::Value;
|
||||
use siren::ServiceError;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub enum GPTRole {
|
||||
#[serde(rename = "system")]
|
||||
System,
|
||||
#[serde(rename = "user")]
|
||||
User,
|
||||
#[serde(rename = "assistant")]
|
||||
Assistant,
|
||||
#[serde(rename = "function")]
|
||||
Function
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ChatCompletionRequest {
|
||||
pub model: String,
|
||||
pub messages: Vec<ChatCompletionMessage>,
|
||||
/// Value between 0 and 2
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub temperature: Option<f64>,
|
||||
/// Value between 0 and 1
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub top_p: Option<f64>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub n: Option<f64>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub max_tokens: Option<i64>,
|
||||
/// Value between -2.0 and 2.0
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub presence_penalty: Option<f64>,
|
||||
/// Value between -2.0 and 2.0
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub frequency_penalty: Option<f64>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub user: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ChatCompletionMessage {
|
||||
pub role: GPTRole,
|
||||
pub content: String
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ChatCompletionResponse {
|
||||
pub id: String,
|
||||
pub object: String,
|
||||
pub system_fingerprint: Option<String>,
|
||||
pub created: i64,
|
||||
pub model: String,
|
||||
pub usage: Usage,
|
||||
pub choices: Vec<Choice>
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Usage {
|
||||
pub prompt_tokens: i64,
|
||||
pub completion_tokens: i64,
|
||||
pub total_tokens: i64
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Choice {
|
||||
pub message: ChatCompletionMessage,
|
||||
pub finish_reason: String,
|
||||
pub index: i64,
|
||||
pub logprobs: Option<String>
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
enum ResponseEvent {
|
||||
ChatCompletionResponse(ChatCompletionResponse),
|
||||
ResponseError(ResponseError)
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
struct ResponseError {
|
||||
error: Option<ErrorDetails>,
|
||||
message: Option<String>,
|
||||
param: Option<String>,
|
||||
#[serde(rename = "type")]
|
||||
error_type: Option<String>
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
struct ErrorDetails {
|
||||
code: Option<String>,
|
||||
message: Option<String>
|
||||
}
|
||||
|
||||
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,
|
||||
pub default_model: String,
|
||||
pub max_context_questions: i64
|
||||
}
|
||||
|
||||
impl OAI {
|
||||
pub async fn chat_completion(&self, request: ChatCompletionRequest) -> Result<ChatCompletionResponse, ServiceError> {
|
||||
let url = format!("{}/chat/completions", self.base_url);
|
||||
let response = self.client.post(&url)
|
||||
.bearer_auth(&self.token)
|
||||
.header("Content-Type", "application/json".to_string())
|
||||
.json(&request)
|
||||
.send()
|
||||
.await;
|
||||
match response {
|
||||
Ok(response) => {
|
||||
let value = response.json::<Value>().await?;
|
||||
// let event: ResponseEvent = serde_json::from_value::<ResponseEvent>(value)?;
|
||||
// match event {
|
||||
// ResponseEvent::ChatCompletionResponse(response) => return Ok(response),
|
||||
// ResponseEvent::ResponseError(error) => return Err(ServiceError { status: 500, message: format!("Error: {}", error.message.unwrap()) })
|
||||
// }
|
||||
let res = serde_json::from_value::<ChatCompletionResponse>(value)?;
|
||||
return Ok(res);
|
||||
},
|
||||
Err(err) => return Err(ServiceError { status: 500, message: format!("Error: {}", err) })
|
||||
}
|
||||
}
|
||||
}
|
||||
39
service/src/bot/ytdlp/mod.rs
Normal file
39
service/src/bot/ytdlp/mod.rs
Normal file
@@ -0,0 +1,39 @@
|
||||
mod model;
|
||||
|
||||
use std::process::{Child, Command, Output, Stdio};
|
||||
|
||||
pub use model::*;
|
||||
|
||||
|
||||
const YOUTUBE_DL_COMMAND: &str = "yt-dlp";
|
||||
|
||||
pub struct YtDlp {
|
||||
command: Command,
|
||||
args: Vec<String>,
|
||||
}
|
||||
|
||||
impl YtDlp {
|
||||
pub fn new() -> Self {
|
||||
let mut cmd = Command::new(YOUTUBE_DL_COMMAND);
|
||||
cmd.env("LC_ALL", "en_US.UTF-8")
|
||||
.stdout(Stdio::piped())
|
||||
.stdin(Stdio::piped())
|
||||
.stderr(Stdio::piped());
|
||||
Self {
|
||||
command: cmd,
|
||||
args: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn arg(&mut self, arg: &str) -> &mut Self {
|
||||
self.args.push(arg.to_owned());
|
||||
self
|
||||
}
|
||||
|
||||
pub fn execute(&mut self) -> std::io::Result<Output> {
|
||||
self.command
|
||||
.args(self.args.clone())
|
||||
.spawn()
|
||||
.and_then(Child::wait_with_output)
|
||||
}
|
||||
}
|
||||
11
service/src/bot/ytdlp/model.rs
Normal file
11
service/src/bot/ytdlp/model.rs
Normal file
@@ -0,0 +1,11 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct PlaylistItem {
|
||||
pub id: String,
|
||||
pub url: String,
|
||||
pub title: String,
|
||||
pub duration: i32,
|
||||
pub playlist_index: i32,
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
mod model;
|
||||
|
||||
pub use model::*;
|
||||
@@ -1,87 +0,0 @@
|
||||
use diesel::{r2d2::ConnectionManager as DieselConnectionManager, PgConnection};
|
||||
// use redis::{aio::{Connection as RedisConnection, ConnectionManager as RedisConnectionManager}, AsyncCommands};
|
||||
use redis::aio::Connection as RedisConnection;
|
||||
use siren::ServiceError;
|
||||
use crate::diesel_migrations::MigrationHarness;
|
||||
use lazy_static::lazy_static;
|
||||
use log::{error, info};
|
||||
use r2d2;
|
||||
use std::env;
|
||||
|
||||
pub mod backgrounds;
|
||||
pub mod bestiary;
|
||||
pub mod classes;
|
||||
pub mod conditions;
|
||||
pub mod feats;
|
||||
pub mod guilds;
|
||||
pub mod items;
|
||||
pub mod messages;
|
||||
pub mod options;
|
||||
pub mod races;
|
||||
pub mod spells;
|
||||
pub mod schema;
|
||||
|
||||
type DbPool = r2d2::Pool<DieselConnectionManager<PgConnection>>;
|
||||
pub type DbConnection = r2d2::PooledConnection<DieselConnectionManager<PgConnection>>;
|
||||
// type RedisPool = r2d2::Pool<redis::ConnectionManager>;
|
||||
|
||||
pub const MIGRATIONS: diesel_migrations::EmbeddedMigrations = embed_migrations!();
|
||||
|
||||
lazy_static! {
|
||||
static ref POOL: DbPool = {
|
||||
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 = DieselConnectionManager::<PgConnection>::new(url);
|
||||
DbPool::builder().test_on_check_out(true).build(manager).expect("Failed to create db pool")
|
||||
};
|
||||
// static ref REDIS_POOL: RedisPool = {
|
||||
// let host = env::var("REDIS_HOST").unwrap_or("localhost".to_string());
|
||||
// let port = env::var("REDIS_PORT").unwrap_or("6379".to_string());
|
||||
// let url = format!("redis://{}:{}", host, port);
|
||||
// let client = redis::Client::open(url).expect("Failed to create redis client");
|
||||
// let manager = RedisConnectionManager::new(client);
|
||||
// "".to_string()
|
||||
// };
|
||||
}
|
||||
|
||||
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<DbConnection, ServiceError> {
|
||||
POOL.get()
|
||||
.map_err(|e| ServiceError::new(500, format!("Failed getting db connection: {}", e)))
|
||||
}
|
||||
|
||||
pub fn redis_client() -> Result<redis::Client, ServiceError> {
|
||||
let host = env::var("REDIS_HOST").unwrap_or("localhost".to_string());
|
||||
let port = env::var("REDIS_PORT").unwrap_or("6379".to_string());
|
||||
let url = format!("redis://{}:{}", host, port);
|
||||
let client = redis::Client::open(url)?;
|
||||
Ok(client)
|
||||
}
|
||||
|
||||
pub fn redis_connection() -> Result<redis::Connection, ServiceError> {
|
||||
let client = redis_client()?;
|
||||
let conn = client.get_connection()?;
|
||||
Ok(conn)
|
||||
}
|
||||
|
||||
pub async fn redis_async_connection() -> Result<RedisConnection, ServiceError> {
|
||||
let client = redis_client()?;
|
||||
let conn = client.get_async_connection().await?;
|
||||
Ok(conn)
|
||||
}
|
||||
|
||||
pub fn load_data(data_dir_path: &str) {
|
||||
spells::load_data(data_dir_path);
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
pub mod backgrounds;
|
||||
pub mod bestiary;
|
||||
pub mod classes;
|
||||
pub mod conditions;
|
||||
pub mod feats;
|
||||
pub mod items;
|
||||
pub mod options;
|
||||
pub mod races;
|
||||
pub mod spells;
|
||||
|
||||
pub fn load_data(data_dir_path: &str) {
|
||||
spells::load_data(data_dir_path);
|
||||
}
|
||||
@@ -2,7 +2,9 @@ use diesel::prelude::*;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use siren::ServiceError;
|
||||
|
||||
use crate::db::{schema::spells::{self}, classes::AbilityType, conditions::ConditionType};
|
||||
use crate::storage::connection;
|
||||
use crate::storage::schema::spells::{self};
|
||||
use crate::dnd::{classes::AbilityType, conditions::ConditionType};
|
||||
|
||||
use super::{SchoolType, CastingTime, SpellAttackType, SpellDamageType, Range, Area, Components, Duration, Source, Description, DurationType, Effect};
|
||||
|
||||
@@ -61,7 +63,7 @@ impl Default for QueryFilters {
|
||||
|
||||
impl QuerySpell {
|
||||
pub fn get_all(filters: &QueryFilters, limit: i32, page: i32) -> Result<Vec<Self>, ServiceError> {
|
||||
let mut conn = crate::db::connection()?;
|
||||
let mut conn = connection()?;
|
||||
let mut query = spells::table.limit(limit as i64).into_boxed();
|
||||
// Limit query to page and limit
|
||||
let offset = (page - 1) * limit;
|
||||
@@ -108,7 +110,7 @@ impl QuerySpell {
|
||||
}
|
||||
|
||||
pub fn get_count(filters: &QueryFilters) -> Result<i64, ServiceError> {
|
||||
let mut conn = crate::db::connection()?;
|
||||
let mut conn = connection()?;
|
||||
let mut query = spells::table.count().into_boxed();
|
||||
if let Some(name) = &filters.by_name {
|
||||
query = query.filter(spells::name.ilike(format!("%{}%", name)));
|
||||
@@ -149,7 +151,7 @@ impl QuerySpell {
|
||||
}
|
||||
|
||||
pub fn get_by_id(id: i32) -> Result<Self, ServiceError> {
|
||||
let mut conn = crate::db::connection()?;
|
||||
let mut conn = connection()?;
|
||||
let spell = spells::table
|
||||
.filter(spells::id.eq(id))
|
||||
.first::<QuerySpell>(&mut conn)?;
|
||||
@@ -157,7 +159,7 @@ impl QuerySpell {
|
||||
}
|
||||
|
||||
pub fn delete(id: i32) -> Result<Self, ServiceError> {
|
||||
let mut conn = crate::db::connection()?;
|
||||
let mut conn = connection()?;
|
||||
let spell = diesel::delete(spells::table.filter(spells::id.eq(id))).get_result(&mut conn)?;
|
||||
Ok(spell)
|
||||
}
|
||||
@@ -182,13 +184,13 @@ pub struct InsertSpell {
|
||||
|
||||
impl InsertSpell {
|
||||
pub fn insert(spell: Self) -> Result<QuerySpell, ServiceError> {
|
||||
let mut conn = crate::db::connection()?;
|
||||
let mut conn = connection()?;
|
||||
let spell = diesel::insert_into(spells::table).values(spell).get_result(&mut conn)?;
|
||||
Ok(spell)
|
||||
}
|
||||
|
||||
pub fn update(id: i32, spell: Self) -> Result<QuerySpell, ServiceError> {
|
||||
let mut conn = crate::db::connection()?;
|
||||
let mut conn = connection()?;
|
||||
let spell = diesel::update(spells::table.filter(spells::id.eq(id))).set(spell).get_result(&mut conn)?;
|
||||
Ok(spell)
|
||||
}
|
||||
@@ -1,9 +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 siren::{Response, Metadata, ServiceError};
|
||||
|
||||
use crate::{db::spells::{QuerySpell, QueryFilters}, auth::{JwtAuth, verify_role}};
|
||||
use crate::{dnd::spells::{QuerySpell, QueryFilters}, auth::{JwtAuth, verify_role}};
|
||||
|
||||
use super::{Spell, InsertSpell};
|
||||
|
||||
@@ -90,7 +90,7 @@ async fn get_all(req: HttpRequest) -> HttpResponse {
|
||||
spell.id = Some(id);
|
||||
response.push(spell);
|
||||
}
|
||||
HttpResponse::Ok().json(GetResponse {
|
||||
HttpResponse::Ok().json(Response {
|
||||
data: response,
|
||||
metadata: Some(Metadata {
|
||||
total: total_count as i32,
|
||||
@@ -121,7 +121,7 @@ async fn get_by_id(id: web::Path<String>) -> HttpResponse {
|
||||
let id = query_spell.id;
|
||||
let mut spell = Spell::from(query_spell);
|
||||
spell.id = Some(id);
|
||||
HttpResponse::Ok().json(GetResponse {
|
||||
HttpResponse::Ok().json(Response {
|
||||
data: spell,
|
||||
metadata: None
|
||||
})
|
||||
@@ -19,7 +19,7 @@ pub struct Message {
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize)]
|
||||
pub struct GetResponse<T> {
|
||||
pub struct Response<T> {
|
||||
pub data: T,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub metadata: Option<Metadata>
|
||||
@@ -54,6 +54,18 @@ impl fmt::Display for ServiceError {
|
||||
}
|
||||
}
|
||||
|
||||
impl From<std::io::Error> for ServiceError {
|
||||
fn from(error: std::io::Error) -> ServiceError {
|
||||
ServiceError::new(500, format!("Unknown io error: {}", error))
|
||||
}
|
||||
}
|
||||
|
||||
impl From<std::string::FromUtf8Error> for ServiceError {
|
||||
fn from(error: std::string::FromUtf8Error) -> ServiceError {
|
||||
ServiceError::new(500, format!("Unknown from utf8 error: {}", error))
|
||||
}
|
||||
}
|
||||
|
||||
impl From<DieselError> for ServiceError {
|
||||
fn from(error: DieselError) -> ServiceError {
|
||||
match error {
|
||||
@@ -112,6 +124,23 @@ impl From<redis::RedisError> for ServiceError {
|
||||
}
|
||||
}
|
||||
|
||||
impl From<s3::error::S3Error> for ServiceError {
|
||||
fn from(error: s3::error::S3Error) -> ServiceError {
|
||||
match error {
|
||||
s3::error::S3Error::Http(code, message) => {
|
||||
ServiceError::new(code, message)
|
||||
},
|
||||
_ => ServiceError::new(500, format!("Unknown s3 error: {}", error))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<s3::creds::error::CredentialsError> for ServiceError {
|
||||
fn from(error: s3::creds::error::CredentialsError) -> ServiceError {
|
||||
ServiceError::new(500, format!("Unknown credentials error: {}", error))
|
||||
}
|
||||
}
|
||||
|
||||
impl ResponseError for ServiceError {
|
||||
fn error_response(&self) -> HttpResponse {
|
||||
let status_code = match StatusCode::from_u16(self.status) {
|
||||
|
||||
@@ -14,22 +14,23 @@ use songbird::{SerenityInit, Songbird};
|
||||
|
||||
use actix_cors::Cors;
|
||||
use actix_web::{HttpServer, App, web};
|
||||
use crate::bot::{commands::oai::GPTModel, handler::Handler};
|
||||
use crate::bot::handler::Handler;
|
||||
|
||||
use dotenv::dotenv;
|
||||
|
||||
mod auth;
|
||||
mod dnd;
|
||||
mod bot;
|
||||
mod db;
|
||||
mod storage;
|
||||
mod users;
|
||||
|
||||
#[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"));
|
||||
db::init();
|
||||
storage::init().await;
|
||||
match env::var("DATA_DIR_PATH") {
|
||||
Ok(data_dir_path) => db::load_data(&data_dir_path),
|
||||
Ok(data_dir_path) => dnd::load_data(&data_dir_path),
|
||||
Err(err) => warn!("Unable to load initial database data: {}", err)
|
||||
};
|
||||
|
||||
@@ -56,8 +57,9 @@ async fn main() -> std::io::Result<()> {
|
||||
let handler = match env::var("OPENAI_API_KEY") {
|
||||
Ok(token) => {
|
||||
info!("Loaded OpenAI token");
|
||||
let default_model = env::var("OPENAI_API_MODEL").unwrap_or("gpt-3.5-turbo".to_string());
|
||||
Handler {
|
||||
oai: Some(bot::commands::oai::OAI {
|
||||
oai: Some(bot::oai::OAI {
|
||||
client: reqwest::Client::new(),
|
||||
base_url: "https://api.openai.com/v1".to_string(),
|
||||
service_url: "http://localhost:5000".to_string(),
|
||||
@@ -65,7 +67,7 @@ async fn main() -> std::io::Result<()> {
|
||||
token,
|
||||
max_context_questions: 30,
|
||||
max_tokens: 2048,
|
||||
default_model: GPTModel::GPT35Turbo,
|
||||
default_model,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -97,16 +99,16 @@ async fn main() -> std::io::Result<()> {
|
||||
|
||||
let shard_manager = Arc::clone(&client.shard_manager);
|
||||
|
||||
// tokio::spawn(async move {
|
||||
// tokio::signal::ctrl_c().await.expect("Could not register ctrl+c handler");
|
||||
// shard_manager.lock().await.shutdown_all().await;
|
||||
// });
|
||||
tokio::spawn(async move {
|
||||
tokio::signal::ctrl_c().await.expect("Could not register ctrl+c handler");
|
||||
shard_manager.lock().await.shutdown_all().await;
|
||||
});
|
||||
|
||||
// tokio::spawn(async move {
|
||||
// if let Err(why) = client.start_autosharded().await {
|
||||
// error!("An error occurred while running the client: {:?}", why);
|
||||
// }
|
||||
// });
|
||||
tokio::spawn(async move {
|
||||
if let Err(why) = client.start_autosharded().await {
|
||||
error!("An error occurred while running the client: {:?}", why);
|
||||
}
|
||||
});
|
||||
|
||||
let host = env::var("SERVICE_HOST").unwrap_or("localhost".to_string());
|
||||
let port = env::var("SERVICE_PORT").unwrap_or("5000".to_string());
|
||||
@@ -121,10 +123,11 @@ async fn main() -> std::io::Result<()> {
|
||||
App::new()
|
||||
.wrap(cors)
|
||||
.app_data(web::Data::new(Arc::clone(&app_data)))
|
||||
.configure(crate::db::messages::init_routes)
|
||||
.configure(crate::db::spells::init_routes)
|
||||
.configure(crate::auth::init_routes)
|
||||
.configure(crate::bot::api::init_routes)
|
||||
.configure(crate::users::init_routes)
|
||||
.configure(crate::dnd::spells::init_routes)
|
||||
.configure(crate::bot::guilds::init_routes)
|
||||
.configure(crate::bot::messages::init_routes)
|
||||
})
|
||||
.bind(format!("{}:{}", host, port)) {
|
||||
Ok(b) => {
|
||||
|
||||
122
service/src/storage/mod.rs
Normal file
122
service/src/storage/mod.rs
Normal file
@@ -0,0 +1,122 @@
|
||||
use diesel::{r2d2::ConnectionManager as DieselConnectionManager, PgConnection};
|
||||
use redis::{Client as RedisClient, aio::Connection as RedisConnection};
|
||||
use s3::{Region, creds::Credentials, Bucket, BucketConfiguration, request::ResponseData};
|
||||
use siren::ServiceError;
|
||||
use crate::diesel_migrations::MigrationHarness;
|
||||
use lazy_static::lazy_static;
|
||||
use log::{error, info};
|
||||
use r2d2;
|
||||
use std::env;
|
||||
|
||||
pub mod schema;
|
||||
|
||||
type DbPool = r2d2::Pool<DieselConnectionManager<PgConnection>>;
|
||||
pub type DbConnection = r2d2::PooledConnection<DieselConnectionManager<PgConnection>>;
|
||||
|
||||
pub const MIGRATIONS: diesel_migrations::EmbeddedMigrations = embed_migrations!();
|
||||
|
||||
lazy_static! {
|
||||
static ref POOL: DbPool = {
|
||||
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 = DieselConnectionManager::<PgConnection>::new(url);
|
||||
DbPool::builder().test_on_check_out(true).build(manager).expect("Failed to create db pool")
|
||||
};
|
||||
static ref REDIS: RedisClient = {
|
||||
let host = env::var("REDIS_HOST").unwrap_or("localhost".to_string());
|
||||
let port = env::var("REDIS_PORT").unwrap_or("6379".to_string());
|
||||
let url = format!("redis://{}:{}", host, port);
|
||||
RedisClient::open(url).expect("Failed to create redis client")
|
||||
};
|
||||
static ref BUCKET: Bucket = {
|
||||
let url = env::var("MINIO_HOST").unwrap_or("localhost".to_string());
|
||||
let port = env::var("MINIO_PORT").unwrap_or("9000".to_string());
|
||||
let user = env::var("MINIO_ROOT_USER").expect("MINIO_ROOT_USER is not set");
|
||||
let password = env::var("MINIO_ROOT_PASSWORD").expect("MINIO_ROOT_PASSWORD is not set");
|
||||
let base_url = format!("http://{}:{}", url, port);
|
||||
|
||||
let region = Region::Custom {
|
||||
region: "".to_string(),
|
||||
endpoint: base_url,
|
||||
};
|
||||
|
||||
let credentials = Credentials {
|
||||
access_key: Some(user),
|
||||
secret_key: Some(password),
|
||||
security_token: None,
|
||||
session_token: None,
|
||||
expiration: None
|
||||
};
|
||||
|
||||
Bucket::new("siren", region.clone(), credentials.clone()).expect("Failed to create S3 Bucket").with_path_style()
|
||||
};
|
||||
}
|
||||
|
||||
pub async fn init() {
|
||||
lazy_static::initialize(&POOL);
|
||||
lazy_static::initialize(&REDIS);
|
||||
lazy_static::initialize(&BUCKET);
|
||||
create_bucket().await;
|
||||
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<DbConnection, ServiceError> {
|
||||
POOL.get()
|
||||
.map_err(|e| ServiceError::new(500, format!("Failed getting db connection: {}", e)))
|
||||
}
|
||||
|
||||
pub fn redis_connection() -> Result<redis::Connection, ServiceError> {
|
||||
let conn = REDIS.get_connection()?;
|
||||
Ok(conn)
|
||||
}
|
||||
|
||||
pub async fn redis_async_connection() -> Result<RedisConnection, ServiceError> {
|
||||
let conn = REDIS.get_async_connection().await?;
|
||||
Ok(conn)
|
||||
}
|
||||
|
||||
async fn create_bucket() {
|
||||
let url = env::var("MINIO_URL").unwrap_or("localhost".to_string());
|
||||
let port = env::var("MINIO_PORT").unwrap_or("9000".to_string());
|
||||
let user = env::var("MINIO_ROOT_USER").expect("MINIO_ROOT_USER is not set");
|
||||
let password = env::var("MINIO_ROOT_PASSWORD").expect("MINIO_ROOT_PASSWORD is not set");
|
||||
let base_url = format!("http://{}:{}", url, port);
|
||||
|
||||
let region = Region::Custom {
|
||||
region: "".to_string(),
|
||||
endpoint: base_url,
|
||||
};
|
||||
|
||||
let credentials = Credentials {
|
||||
access_key: Some(user),
|
||||
secret_key: Some(password),
|
||||
security_token: None,
|
||||
session_token: None,
|
||||
expiration: None
|
||||
};
|
||||
let _ = Bucket::create_with_path_style("siren", region, credentials, BucketConfiguration::default()).await;
|
||||
}
|
||||
|
||||
pub async fn upload_file(path: &str, content: &[u8]) -> Result<ResponseData, ServiceError> {
|
||||
let response = BUCKET.put_object(path, content).await?;
|
||||
Ok(response)
|
||||
}
|
||||
|
||||
pub async fn get_file(path: &str) -> Result<Vec<u8>, ServiceError> {
|
||||
let response = BUCKET.get_object(path).await?;
|
||||
let bytes = response.bytes();
|
||||
Ok(bytes.to_vec())
|
||||
}
|
||||
|
||||
pub async fn delete_file(path: &str) -> Result<ResponseData, ServiceError> {
|
||||
let response = BUCKET.delete_object(path).await?;
|
||||
Ok(response)
|
||||
}
|
||||
@@ -46,6 +46,9 @@ diesel::table! {
|
||||
role -> Text,
|
||||
first_name -> Text,
|
||||
last_name -> Text,
|
||||
updated_at -> Timestamp,
|
||||
created_at -> Timestamp,
|
||||
profile_picture -> Nullable<Text>,
|
||||
verified -> Bool,
|
||||
}
|
||||
}
|
||||
3
service/src/users/mod.rs
Normal file
3
service/src/users/mod.rs
Normal file
@@ -0,0 +1,3 @@
|
||||
mod routes;
|
||||
|
||||
pub use routes::init_routes;
|
||||
136
service/src/users/routes.rs
Normal file
136
service/src/users/routes.rs
Normal file
@@ -0,0 +1,136 @@
|
||||
use actix_multipart::Multipart;
|
||||
use actix_web::{web, HttpResponse, post, delete, get, ResponseError};
|
||||
use log::error;
|
||||
use serenity::futures::StreamExt;
|
||||
use siren::ServiceError;
|
||||
|
||||
use crate::{auth::{JwtAuth, InsertUser, QueryUser}, storage::{upload_file, get_file, delete_file}};
|
||||
|
||||
#[post("/picture")]
|
||||
async fn set_picture(mut payload: Multipart, auth: JwtAuth) -> HttpResponse {
|
||||
while let Some(item) = payload.next().await {
|
||||
let mut bytes = web::BytesMut::new();
|
||||
let mut field = match item {
|
||||
Ok(field) => field,
|
||||
Err(err) => return ResponseError::error_response(&err)
|
||||
};
|
||||
let content_type = field.content_disposition();
|
||||
// Get file name and construct the file path
|
||||
let file_name = match content_type.get_filename() {
|
||||
Some(name) => {
|
||||
// Verify extension is supported
|
||||
match name.split(".").last() {
|
||||
Some(ext) => {
|
||||
match ext {
|
||||
"png" | "jpg" | "jpeg" => name,
|
||||
_ => return ResponseError::error_response(&ServiceError {
|
||||
status: 400,
|
||||
message: "File extension is not supported".to_string()
|
||||
})
|
||||
}
|
||||
},
|
||||
None => return ResponseError::error_response(&ServiceError {
|
||||
status: 400,
|
||||
message: "Unknown file extension".to_string()
|
||||
})
|
||||
}
|
||||
},
|
||||
None => return ResponseError::error_response(&ServiceError {
|
||||
status: 400,
|
||||
message: "File name is not provided".to_string()
|
||||
})
|
||||
};
|
||||
let path = format!("users/{}/{}", auth.user.email, file_name);
|
||||
|
||||
// Build the file and store it in minio
|
||||
while let Some(chunk) = field.next().await {
|
||||
let data = match chunk {
|
||||
Ok(data) => data,
|
||||
Err(err) => {
|
||||
error!("Failed to get chunk: {}", err);
|
||||
return ResponseError::error_response(&err);
|
||||
}
|
||||
};
|
||||
bytes.extend_from_slice(&data);
|
||||
}
|
||||
match upload_file(&path, &bytes).await {
|
||||
Ok(_) => {
|
||||
match InsertUser::update_profile(&auth.user.email, Some(&path)) {
|
||||
Ok(_) => {}
|
||||
Err(err) => {
|
||||
error!("Failed to update user profile: {}", err);
|
||||
return ResponseError::error_response(&err);
|
||||
}
|
||||
};
|
||||
},
|
||||
Err(err) => {
|
||||
error!("Failed to upload file: {}", err);
|
||||
return ResponseError::error_response(&err);
|
||||
}
|
||||
}
|
||||
};
|
||||
return HttpResponse::Ok().finish();
|
||||
}
|
||||
|
||||
#[get("/picture")]
|
||||
async fn get_picture(auth: JwtAuth) -> HttpResponse {
|
||||
let user = match QueryUser::get_by_email(&auth.user.email) {
|
||||
Ok(user) => user,
|
||||
Err(err) => {
|
||||
error!("Failed to get user: {}", err);
|
||||
return ResponseError::error_response(&err);
|
||||
}
|
||||
};
|
||||
if let Some(path) = user.profile_picture {
|
||||
match get_file(&path).await {
|
||||
Ok(bytes) => return HttpResponse::Ok().body(bytes),
|
||||
Err(err) => {
|
||||
error!("Failed to get file: {}", err);
|
||||
return ResponseError::error_response(&err);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
return HttpResponse::NotFound().finish();
|
||||
}
|
||||
}
|
||||
|
||||
#[delete("/picture")]
|
||||
async fn delete_picture(auth: JwtAuth) -> HttpResponse {
|
||||
match QueryUser::get_by_email(&auth.user.email) {
|
||||
Ok(user) => {
|
||||
match user.profile_picture {
|
||||
Some(path) => {
|
||||
match delete_file(&path).await {
|
||||
Ok(_) => {
|
||||
match InsertUser::update_profile(&auth.user.email, None) {
|
||||
Ok(_) => {}
|
||||
Err(err) => {
|
||||
error!("Failed to update user profile: {}", err);
|
||||
return ResponseError::error_response(&err);
|
||||
}
|
||||
};
|
||||
}
|
||||
Err(err) => {
|
||||
error!("Failed to delete file: {}", err);
|
||||
return ResponseError::error_response(&err);
|
||||
}
|
||||
};
|
||||
},
|
||||
None => {}
|
||||
}
|
||||
},
|
||||
Err(err) => {
|
||||
error!("Failed to get user: {}", err);
|
||||
return ResponseError::error_response(&err);
|
||||
}
|
||||
};
|
||||
return HttpResponse::Ok().finish();
|
||||
}
|
||||
|
||||
pub fn init_routes(config: &mut web::ServiceConfig) {
|
||||
config.service(web::scope("users")
|
||||
.service(set_picture)
|
||||
.service(get_picture)
|
||||
.service(delete_picture)
|
||||
);
|
||||
}
|
||||
1296
ui/package-lock.json
generated
1296
ui/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -9,36 +9,35 @@
|
||||
"lint": "next lint"
|
||||
},
|
||||
"dependencies": {
|
||||
"@mantine/core": "^7.1.2",
|
||||
"@mantine/form": "^7.1.2",
|
||||
"@mantine/hooks": "^7.1.2",
|
||||
"@mantine/modals": "^7.1.2",
|
||||
"@mantine/notifications": "^7.1.2",
|
||||
"axios": "^1.5.1",
|
||||
"@mantine/core": "^7.2.2",
|
||||
"@mantine/form": "^7.2.2",
|
||||
"@mantine/hooks": "^7.2.2",
|
||||
"@mantine/modals": "^7.2.2",
|
||||
"@mantine/notifications": "^7.2.2",
|
||||
"@pixi/react": "^7.1.1",
|
||||
"js-cookie": "^3.0.5",
|
||||
"next": "^13.5.4",
|
||||
"next": "^14.0.3",
|
||||
"pixi.js": "^7.3.2",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-icons": "^4.11.0",
|
||||
"react-leaflet": "^4.2.1",
|
||||
"recharts": "^2.8.0",
|
||||
"react-icons": "^4.12.0",
|
||||
"recoil": "^0.7.7"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/js-cookie": "^3.0.4",
|
||||
"@types/node": "20.8.2",
|
||||
"@types/react": "18.2.24",
|
||||
"@types/react-dom": "18.2.8",
|
||||
"@typescript-eslint/eslint-plugin": "^6.7.4",
|
||||
"@typescript-eslint/parser": "^6.7.4",
|
||||
"@types/js-cookie": "^3.0.5",
|
||||
"@types/node": "20.8.7",
|
||||
"@types/react": "18.2.31",
|
||||
"@types/react-dom": "18.2.14",
|
||||
"@typescript-eslint/eslint-plugin": "^6.8.0",
|
||||
"@typescript-eslint/parser": "^6.8.0",
|
||||
"autoprefixer": "^10.4.16",
|
||||
"eslint": "8.50.0",
|
||||
"eslint-config-next": "13.5.4",
|
||||
"eslint": "8.52.0",
|
||||
"eslint-config-next": "13.5.6",
|
||||
"eslint-config-prettier": "^9.0.0",
|
||||
"eslint-plugin-prettier": "^5.0.0",
|
||||
"postcss": "^8.4.31",
|
||||
"postcss-import": "^15.1.0",
|
||||
"postcss-preset-mantine": "^1.8.0",
|
||||
"postcss-preset-mantine": "^1.9.0",
|
||||
"prettier": "^3.0.3",
|
||||
"typescript": "5.2.2"
|
||||
}
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import Cookies from 'js-cookie';
|
||||
import { getRequest, postRequest } from '.';
|
||||
import { RegisterUser, ResponseAuth } from './auth.types';
|
||||
|
||||
export async function login(email: string, password: string): Promise<ResponseAuth | undefined> {
|
||||
const response = await postRequest('auth/login', { email, password });
|
||||
if (response?.status === 200) {
|
||||
return response.data as ResponseAuth;
|
||||
return response.json();
|
||||
} else {
|
||||
return undefined;
|
||||
}
|
||||
@@ -24,9 +25,9 @@ export async function logout() {
|
||||
}
|
||||
|
||||
export async function refresh(refresh_token_rotation?: boolean): Promise<ResponseAuth | undefined> {
|
||||
const response = await getRequest('auth/refresh', { params: { refresh_token_rotation } });
|
||||
const response = await getRequest('auth/refresh', { refresh_token_rotation });
|
||||
if (response?.status === 200) {
|
||||
return response.data as ResponseAuth;
|
||||
return response.json();
|
||||
} else {
|
||||
return undefined;
|
||||
}
|
||||
@@ -35,8 +36,37 @@ export async function refresh(refresh_token_rotation?: boolean): Promise<Respons
|
||||
export async function me(): Promise<ResponseAuth | undefined> {
|
||||
const response = await getRequest('auth/me');
|
||||
if (response?.status === 200) {
|
||||
return response.data;
|
||||
return response.json();
|
||||
} else {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
export async function hasSession(): Promise<boolean> {
|
||||
const response = await getRequest('auth/check-session');
|
||||
if (response?.status === 200) {
|
||||
return response?.json();
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Refreshes the logged_in cookie every interval. By default, the interval is 14 minutes.
|
||||
* @param interval
|
||||
* @returns interval id
|
||||
*/
|
||||
export function refreshLoggedIn(interval = 840000) {
|
||||
let loggedIn = Cookies.get('logged_in');
|
||||
const id = setInterval(async () => {
|
||||
const cookie = Cookies.get('logged_in');
|
||||
if (cookie != loggedIn) {
|
||||
loggedIn = cookie;
|
||||
const response = await refresh(true);
|
||||
if (!response) {
|
||||
Cookies.remove('logged_in');
|
||||
}
|
||||
}
|
||||
}, interval);
|
||||
return id;
|
||||
}
|
||||
|
||||
@@ -15,4 +15,5 @@ export interface User {
|
||||
role: string;
|
||||
first_name: string;
|
||||
last_name: string;
|
||||
profile_picture?: string;
|
||||
}
|
||||
|
||||
@@ -1,14 +1,16 @@
|
||||
import { getRequest, postRequest } from '.';
|
||||
import { APIResponse, getRequest, postRequest } from '.';
|
||||
import { GuildChannel, GuildInfo } from './guilds.types';
|
||||
|
||||
export async function getGuilds(): Promise<GuildInfo[]> {
|
||||
const response = await getRequest('guilds');
|
||||
return response?.data || { data: [] };
|
||||
const guilds: APIResponse<GuildInfo[]> = await response?.json();
|
||||
return guilds?.data || [];
|
||||
}
|
||||
|
||||
export async function getTextChannels(guildId: number): Promise<GuildChannel[]> {
|
||||
const response = await getRequest(`guilds/${guildId}/text`);
|
||||
return response?.data || { data: [] };
|
||||
const channels: APIResponse<GuildChannel[]> = await response?.json();
|
||||
return channels.data || [];
|
||||
}
|
||||
|
||||
export async function sendMessage(guildId: number, channelId: number, message: string): Promise<void> {
|
||||
@@ -17,7 +19,8 @@ export async function sendMessage(guildId: number, channelId: number, message: s
|
||||
|
||||
export async function getVoiceChannels(guildId: number): Promise<GuildChannel[]> {
|
||||
const response = await getRequest(`guilds/${guildId}/voice`);
|
||||
return response?.data || { data: [] };
|
||||
const channels: APIResponse<GuildChannel[]> = await response?.json();
|
||||
return channels.data || [];
|
||||
}
|
||||
|
||||
export async function playTrack(guildId: number, channelId: number, track: string): Promise<void> {
|
||||
@@ -46,5 +49,6 @@ export async function skipTrack(guildId: number): Promise<void> {
|
||||
|
||||
export async function getVolume(guildId: number): Promise<number> {
|
||||
const response = await getRequest(`guilds/${guildId}/voice/volume`);
|
||||
return response?.data?.volume || 0;
|
||||
const volume: number = await response?.json();
|
||||
return volume || 0;
|
||||
}
|
||||
|
||||
@@ -1,43 +1,49 @@
|
||||
import axios, { AxiosInstance, AxiosRequestConfig, AxiosResponse } from 'axios';
|
||||
|
||||
const serviceHost = process.env.SERVICE_HOST || 'http://localhost';
|
||||
const servicePort = process.env.SERVICE_PORT || 5000;
|
||||
const baseURL = `${serviceHost}:${servicePort}`;
|
||||
|
||||
function createAxiosClient(): AxiosInstance {
|
||||
const axiosClient = axios.create({
|
||||
baseURL: `${serviceHost}:${servicePort}`
|
||||
export async function getRequest(endpoint: string, params: Record<string, any> = {}): Promise<Response> {
|
||||
// Remove undefined params
|
||||
Object.keys(params).forEach((key) => params[key] === undefined && delete params[key]);
|
||||
const urlParams = new URLSearchParams(params);
|
||||
const url = urlParams && urlParams.size > 0 ? `${baseURL}/${endpoint}?${urlParams}` : `${baseURL}/${endpoint}`;
|
||||
const response = await fetch(url, {
|
||||
method: 'GET',
|
||||
credentials: 'include'
|
||||
});
|
||||
|
||||
axiosClient.interceptors.request.use(
|
||||
(request) => {
|
||||
request.withCredentials = true;
|
||||
return request;
|
||||
},
|
||||
(error) => {
|
||||
console.error(error);
|
||||
return Promise.reject(error);
|
||||
}
|
||||
);
|
||||
return axiosClient;
|
||||
return response;
|
||||
}
|
||||
|
||||
const axiosClient = createAxiosClient();
|
||||
|
||||
export async function getRequest(
|
||||
url: string,
|
||||
config?: AxiosRequestConfig<any>
|
||||
): Promise<AxiosResponse<any, any> | undefined> {
|
||||
const response = await axiosClient.get(`/${url}`, config);
|
||||
return response || undefined;
|
||||
interface PostOptions {
|
||||
headers?: Record<string, any>;
|
||||
type?: 'json' | 'form';
|
||||
}
|
||||
|
||||
export async function postRequest(
|
||||
url: string,
|
||||
data?: any,
|
||||
config?: AxiosRequestConfig<any>
|
||||
): Promise<AxiosResponse<any, any> | undefined> {
|
||||
const response = await axiosClient.post(`/${url}`, data, config);
|
||||
return response || undefined;
|
||||
export async function postRequest(endpoint: string, body?: any, options?: PostOptions): Promise<Response> {
|
||||
const url = `${baseURL}/${endpoint}`;
|
||||
let response;
|
||||
if (body && (!options?.type || options.type === 'json')) {
|
||||
response = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
credentials: 'include',
|
||||
body: JSON.stringify(body)
|
||||
});
|
||||
} else {
|
||||
response = await fetch(url, {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
body
|
||||
});
|
||||
}
|
||||
return response;
|
||||
}
|
||||
|
||||
export interface APIResponse<T> {
|
||||
data: T;
|
||||
metadata: Metadata;
|
||||
}
|
||||
|
||||
export interface Metadata {
|
||||
|
||||
@@ -20,22 +20,20 @@ interface GetSpellsParams {
|
||||
|
||||
export async function getSpells(params?: GetSpellsParams): Promise<GetSpellsResponse> {
|
||||
const response = await getRequest('dnd/spells', {
|
||||
params: {
|
||||
name: params?.name,
|
||||
like_name: params?.like_name,
|
||||
schools: params?.schools?.join(','),
|
||||
levels: params?.levels?.join(','),
|
||||
ritual: params?.ritual,
|
||||
concentration: params?.concentration,
|
||||
classes: params?.classes?.join(','),
|
||||
damage_inflict: params?.damage_inflict?.join(','),
|
||||
damage_resist: params?.damage_resist?.join(','),
|
||||
conditions: params?.conditions?.join(','),
|
||||
saving_throw: params?.saving_throw?.join(','),
|
||||
attack_type: params?.attack_type?.join(','),
|
||||
limit: params?.limit,
|
||||
page: params?.page
|
||||
}
|
||||
name: params?.name,
|
||||
like_name: params?.like_name,
|
||||
schools: params?.schools?.join(','),
|
||||
levels: params?.levels?.join(','),
|
||||
ritual: params?.ritual,
|
||||
concentration: params?.concentration,
|
||||
classes: params?.classes?.join(','),
|
||||
damage_inflict: params?.damage_inflict?.join(','),
|
||||
damage_resist: params?.damage_resist?.join(','),
|
||||
conditions: params?.conditions?.join(','),
|
||||
saving_throw: params?.saving_throw?.join(','),
|
||||
attack_type: params?.attack_type?.join(','),
|
||||
limit: params?.limit,
|
||||
page: params?.page
|
||||
});
|
||||
return response?.data || { data: [] };
|
||||
return response?.json() || { data: [] };
|
||||
}
|
||||
|
||||
24
ui/src/api/users.ts
Normal file
24
ui/src/api/users.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import { getRequest, postRequest } from '.';
|
||||
|
||||
export async function getPicture(): Promise<Blob | undefined> {
|
||||
const response = await getRequest('users/picture');
|
||||
if (response?.status === 200) {
|
||||
return response.blob();
|
||||
} else {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
export async function setPicture(payload: File): Promise<boolean> {
|
||||
const data = new FormData();
|
||||
data.append('data', payload);
|
||||
// TODO: Figure out why the form data object is empty
|
||||
const response = await postRequest('users/picture', data, {
|
||||
type: 'form'
|
||||
});
|
||||
if (response?.status === 200) {
|
||||
return true;
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
201
ui/src/app/admin/page.tsx
Normal file
201
ui/src/app/admin/page.tsx
Normal file
@@ -0,0 +1,201 @@
|
||||
'use client';
|
||||
|
||||
import {
|
||||
getGuilds,
|
||||
getTextChannels,
|
||||
getVoiceChannels,
|
||||
getVolume,
|
||||
pauseTrack,
|
||||
playTrack,
|
||||
resumeTrack,
|
||||
sendMessage,
|
||||
setVolume,
|
||||
skipTrack,
|
||||
stopTrack
|
||||
} from '@/api/guilds';
|
||||
import { GuildChannel, GuildInfo } from '@/api/guilds.types';
|
||||
import { userState } from '@/state/auth';
|
||||
import { Button, Card, Grid, Select, Slider, Tabs, TextInput, Textarea } from '@mantine/core';
|
||||
import { useForm } from '@mantine/form';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { useRecoilValue } from 'recoil';
|
||||
|
||||
export default function Page() {
|
||||
const user = useRecoilValue(userState);
|
||||
const [guilds, setGuilds] = useState<GuildInfo[]>([]);
|
||||
const [activeGuild, setActiveGuild] = useState<GuildInfo | null>(null);
|
||||
const router = useRouter();
|
||||
|
||||
useEffect(() => {
|
||||
// Check if the user is logged in and an admin, otherwise redirect to the home page
|
||||
// if (!user || !user.roles.includes('admin')) {
|
||||
if (!user || user.role !== 'admin') {
|
||||
router.push('/');
|
||||
} else {
|
||||
getGuilds().then((g) => {
|
||||
setGuilds(g);
|
||||
if (g.length > 0) {
|
||||
setActiveGuild(g[0]);
|
||||
}
|
||||
});
|
||||
}
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<Tabs orientation='vertical' defaultValue={activeGuild?.name}>
|
||||
<Tabs.List>
|
||||
{guilds && guilds.map((guild) => (
|
||||
<Tabs.Tab key={`guild-tab-${guild.id}`} value={guild.name} onClick={() => setActiveGuild(guild)}>
|
||||
{guild.name}
|
||||
</Tabs.Tab>
|
||||
))}
|
||||
</Tabs.List>
|
||||
{guilds && guilds.map((guild) => (
|
||||
<Tabs.Panel key={`guild-${guild.id}`} value={guild.name}>
|
||||
<h1>{guild.name}</h1>
|
||||
<Grid>
|
||||
<Grid.Col span={6}>
|
||||
<TextChannelCard guild={activeGuild} />
|
||||
</Grid.Col>
|
||||
<Grid.Col span={6}>
|
||||
<VoiceChannelsCard guild={activeGuild} />
|
||||
</Grid.Col>
|
||||
</Grid>
|
||||
</Tabs.Panel>
|
||||
))}
|
||||
</Tabs>
|
||||
);
|
||||
}
|
||||
|
||||
function TextChannelCard({ guild }: { guild: GuildInfo | null }) {
|
||||
const [textChannels, setTextChannels] = useState<GuildChannel[]>([]);
|
||||
const [activeChannel, setActiveChannel] = useState<GuildChannel | null>(null);
|
||||
|
||||
const form = useForm({
|
||||
initialValues: {
|
||||
message: ''
|
||||
}
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (guild) {
|
||||
getTextChannels(guild.id).then((c) => setTextChannels(c));
|
||||
}
|
||||
}, [guild]);
|
||||
|
||||
return (
|
||||
<Card shadow='sm' style={{ margin: '1em' }}>
|
||||
<Card.Section>
|
||||
<h2>Text Channels</h2>
|
||||
<Select
|
||||
placeholder='Select channel...'
|
||||
data={textChannels.map((channel, index) => {
|
||||
return {
|
||||
value: `${index}`,
|
||||
label: channel.name
|
||||
};
|
||||
})}
|
||||
onChange={(e) => {
|
||||
if (e) {
|
||||
setActiveChannel(textChannels[parseInt(e)]);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
{activeChannel && (
|
||||
<form
|
||||
style={{ margin: '1em' }}
|
||||
onSubmit={form.onSubmit((values) => {
|
||||
sendMessage(guild!.id, activeChannel.id, values.message);
|
||||
})}
|
||||
>
|
||||
<Textarea placeholder='Message...' {...form.getInputProps('message')} />
|
||||
<Button type='submit'>Send Message</Button>
|
||||
</form>
|
||||
)}
|
||||
</Card.Section>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function VoiceChannelsCard({ guild }: { guild: GuildInfo | null }) {
|
||||
const [voiceChannels, setVoiceChannels] = useState<GuildChannel[]>([]);
|
||||
const [guildVolume, setGuildVolume] = useState<number>(50.0);
|
||||
const [activeChannel, setActiveChannel] = useState<GuildChannel | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (guild) {
|
||||
getVoiceChannels(guild.id).then((c) => setVoiceChannels(c));
|
||||
getVolume(guild.id).then((v) => setGuildVolume(v));
|
||||
}
|
||||
}, [guild]);
|
||||
|
||||
const form = useForm({
|
||||
initialValues: {
|
||||
trackUrl: '',
|
||||
volume: 50.0
|
||||
}
|
||||
});
|
||||
|
||||
return (
|
||||
<Card shadow='sm' style={{ margin: '1em' }}>
|
||||
<Card.Section>
|
||||
<h2>Voice Channels</h2>
|
||||
<Select
|
||||
placeholder='Select channel...'
|
||||
data={voiceChannels.map((channel, index) => {
|
||||
return {
|
||||
value: `${index}`,
|
||||
label: channel.name
|
||||
};
|
||||
})}
|
||||
onChange={(e) => {
|
||||
if (e) {
|
||||
setActiveChannel(voiceChannels[parseInt(e)]);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
{activeChannel && (
|
||||
<>
|
||||
<form
|
||||
style={{ margin: '1em' }}
|
||||
onSubmit={form.onSubmit((values) => setVolume(guild!.id, values.volume))}
|
||||
>
|
||||
<Slider
|
||||
defaultValue={guildVolume}
|
||||
{...form.getInputProps('volume')}
|
||||
marks={[
|
||||
{ value: 25, label: '25%' },
|
||||
{ value: 50, label: '50%' },
|
||||
{ value: 75, label: '75%' }
|
||||
]}
|
||||
/>
|
||||
<Button type='submit'>Set Volume</Button>
|
||||
</form>
|
||||
<form
|
||||
style={{ margin: '1em' }}
|
||||
onSubmit={form.onSubmit((values) => playTrack(guild!.id, activeChannel.id, values.trackUrl))}
|
||||
>
|
||||
<TextInput placeholder='Youtube URL...' />
|
||||
<Button type='submit'>Play Track</Button>
|
||||
</form>
|
||||
<div style={{ margin: '1em' }}>
|
||||
<Button style={{ marginRight: '1em' }} onClick={() => skipTrack(guild!.id)}>
|
||||
Skip Track
|
||||
</Button>
|
||||
<Button style={{ marginRight: '1em' }} onClick={() => stopTrack(guild!.id)}>
|
||||
Stop
|
||||
</Button>
|
||||
<Button style={{ marginRight: '1em' }} onClick={() => pauseTrack(guild!.id)}>
|
||||
Pause
|
||||
</Button>
|
||||
<Button style={{ marginRight: '1em' }} onClick={() => resumeTrack(guild!.id)}>
|
||||
Resume
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</Card.Section>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
29
ui/src/app/campaigns/page.tsx
Normal file
29
ui/src/app/campaigns/page.tsx
Normal file
@@ -0,0 +1,29 @@
|
||||
'use client';
|
||||
|
||||
import { ActionIcon, Tooltip } from '@mantine/core';
|
||||
import { FaPlus } from "react-icons/fa";
|
||||
import React, { useEffect } from 'react';
|
||||
import { getCampigns } from '@/api/campaigns';
|
||||
import { Campaign } from '@/api/campaigns.types';
|
||||
|
||||
export default function Page() {
|
||||
const [campaigns, setCampaigns] = React.useState<Campaign[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
getCampigns().then((data) => setCampaigns(data));
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h1>Campaigns</h1>
|
||||
<Tooltip label="Create a new campaign">
|
||||
<ActionIcon variant="outline" color="blue">
|
||||
<FaPlus />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
{campaigns && campaigns.map((campaign) => (
|
||||
<div key={campaign.id}>{campaign.name}</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
5
ui/src/app/characters/[id]/page.tsx
Normal file
5
ui/src/app/characters/[id]/page.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import React from 'react';
|
||||
|
||||
export default function Page({ params }: { params: { id: string } }) {
|
||||
return <>{params.id}</>;
|
||||
}
|
||||
5
ui/src/app/characters/create/page.tsx
Normal file
5
ui/src/app/characters/create/page.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import React from 'react';
|
||||
|
||||
export default function Page() {
|
||||
return <h1>Create new Character</h1>;
|
||||
}
|
||||
5
ui/src/app/characters/page.tsx
Normal file
5
ui/src/app/characters/page.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import React from 'react';
|
||||
|
||||
export default function Page() {
|
||||
return <></>;
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import React from 'react';
|
||||
import RecoilRootWrapper from '@app/recoil-root-wrapper';
|
||||
import Topbar from '@/components/Topbar';
|
||||
import Header from '@/components/Header';
|
||||
import { Inter } from 'next/font/google';
|
||||
import { Box, MantineProvider } from '@mantine/core';
|
||||
import { ModalsProvider } from '@mantine/modals';
|
||||
@@ -8,6 +8,7 @@ import { Notifications } from '@mantine/notifications';
|
||||
import 'styles/globals.css';
|
||||
import '@mantine/core/styles.css';
|
||||
import '@mantine/notifications/styles.css';
|
||||
import Loading from '@/components/Loading';
|
||||
|
||||
export const metadata = {
|
||||
title: 'Siren',
|
||||
@@ -22,15 +23,15 @@ export default function RootLayout({ children }: { children: React.ReactNode })
|
||||
<head>
|
||||
<title>Siren</title>
|
||||
</head>
|
||||
<body className={`${inter.className} wrapper h-full`}>
|
||||
<body className={`${inter.className} wrapper`}>
|
||||
<RecoilRootWrapper>
|
||||
<MantineProvider>
|
||||
<Notifications />
|
||||
<ModalsProvider>
|
||||
<Topbar />
|
||||
<Box p='xl' pt='sm' className='h-full'>
|
||||
{children}
|
||||
</Box>
|
||||
<Loading>
|
||||
<Header />
|
||||
<Box>{children}</Box>
|
||||
</Loading>
|
||||
</ModalsProvider>
|
||||
</MantineProvider>
|
||||
</RecoilRootWrapper>
|
||||
|
||||
@@ -1,140 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import {
|
||||
getGuilds,
|
||||
getTextChannels,
|
||||
getVoiceChannels,
|
||||
getVolume,
|
||||
pauseTrack,
|
||||
playTrack,
|
||||
resumeTrack,
|
||||
sendMessage,
|
||||
setVolume,
|
||||
skipTrack,
|
||||
stopTrack
|
||||
} from '@/api/guilds';
|
||||
import { GuildChannel, GuildInfo } from '@/api/guilds.types';
|
||||
import { Button, Slider, Tabs, TextInput, Textarea } from '@mantine/core';
|
||||
import { useForm } from '@mantine/form';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
|
||||
export default function Page() {
|
||||
const [guilds, setGuilds] = useState<GuildInfo[]>([]);
|
||||
const [activeGuild, setActiveGuild] = useState<GuildInfo | null>(null);
|
||||
const [textChannels, setTextChannels] = useState<GuildChannel[]>([]);
|
||||
const [voiceChannels, setVoiceChannels] = useState<GuildChannel[]>([]);
|
||||
const [guildVolume, setGuildVolume] = useState<number>(50.0);
|
||||
|
||||
useEffect(() => {
|
||||
getGuilds().then((g) => {
|
||||
setGuilds(g);
|
||||
if (g.length > 0) {
|
||||
setActiveGuild(g[0]);
|
||||
}
|
||||
});
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (activeGuild) {
|
||||
getTextChannels(activeGuild.id).then((c) => setTextChannels(c));
|
||||
getVoiceChannels(activeGuild.id).then((c) => setVoiceChannels(c));
|
||||
getVolume(activeGuild.id).then((v) => setGuildVolume(v));
|
||||
}
|
||||
}, [activeGuild]);
|
||||
|
||||
const playForm = useForm({
|
||||
initialValues: {
|
||||
message: '',
|
||||
trackUrl: '',
|
||||
volume: 50.0
|
||||
}
|
||||
});
|
||||
|
||||
return (
|
||||
<Tabs orientation='vertical' defaultValue={activeGuild?.name}>
|
||||
<Tabs.List>
|
||||
{guilds.map((guild) => (
|
||||
<Tabs.Tab key={`guild-tab-${guild.id}`} value={guild.name} onClick={() => setActiveGuild(guild)}>
|
||||
{guild.name}
|
||||
</Tabs.Tab>
|
||||
))}
|
||||
</Tabs.List>
|
||||
{guilds.map((guild) => (
|
||||
<Tabs.Panel key={`guild-${guild.id}`} value={guild.name}>
|
||||
<h1>{guild.name}</h1>
|
||||
<h2>Text Channels</h2>
|
||||
<Tabs orientation='horizontal' defaultValue={textChannels[0]?.name}>
|
||||
<Tabs.List>
|
||||
{textChannels.map((channel) => (
|
||||
<Tabs.Tab key={`text-channel-tab-${channel.id}`} value={channel.name}>
|
||||
{channel.name}
|
||||
</Tabs.Tab>
|
||||
))}
|
||||
</Tabs.List>
|
||||
{textChannels.map((channel) => (
|
||||
<Tabs.Panel key={`text-channel-${channel.id}`} value={channel.name}>
|
||||
<form
|
||||
style={{ margin: '1em' }}
|
||||
onSubmit={playForm.onSubmit((values) => sendMessage(activeGuild!.id, channel.id, values.message))}
|
||||
>
|
||||
<Textarea placeholder='Message...' {...playForm.getInputProps('message')} />
|
||||
<Button type='submit'>Send Message</Button>
|
||||
</form>
|
||||
</Tabs.Panel>
|
||||
))}
|
||||
</Tabs>
|
||||
<h2>Voice Channels</h2>
|
||||
<Tabs orientation='horizontal' defaultValue={voiceChannels[0]?.name}>
|
||||
<Tabs.List>
|
||||
{voiceChannels.map((channel) => (
|
||||
<Tabs.Tab key={`voice-channel-tab-${channel.id}`} value={channel.name}>
|
||||
{channel.name}
|
||||
</Tabs.Tab>
|
||||
))}
|
||||
</Tabs.List>
|
||||
{voiceChannels.map((channel) => (
|
||||
<Tabs.Panel key={`voice-channel-${channel.id}`} value={channel.name}>
|
||||
<form
|
||||
style={{ margin: '1em' }}
|
||||
onSubmit={playForm.onSubmit((values) => {
|
||||
playTrack(activeGuild!.id, channel.id, values.trackUrl);
|
||||
})}
|
||||
>
|
||||
<TextInput placeholder='Youtube URL...' {...playForm.getInputProps('trackUrl')} />
|
||||
<Button type='submit'>Play Track</Button>
|
||||
<Button onClick={() => skipTrack(activeGuild!.id)}>Skip Track</Button>
|
||||
</form>
|
||||
<div style={{ margin: '1em' }}>
|
||||
<Button style={{ marginRight: '1em' }} onClick={() => stopTrack(activeGuild!.id)}>
|
||||
Stop
|
||||
</Button>
|
||||
<Button style={{ marginRight: '1em' }} onClick={() => pauseTrack(activeGuild!.id)}>
|
||||
Pause
|
||||
</Button>
|
||||
<Button style={{ marginRight: '1em' }} onClick={() => resumeTrack(activeGuild!.id)}>
|
||||
Resume
|
||||
</Button>
|
||||
</div>
|
||||
<form
|
||||
style={{ margin: '1em' }}
|
||||
onSubmit={playForm.onSubmit((values) => setVolume(activeGuild!.id, values.volume))}
|
||||
>
|
||||
<Slider
|
||||
defaultValue={guildVolume}
|
||||
{...playForm.getInputProps('volume')}
|
||||
marks={[
|
||||
{ value: 25, label: '25%' },
|
||||
{ value: 50, label: '50%' },
|
||||
{ value: 75, label: '75%' }
|
||||
]}
|
||||
/>
|
||||
<Button type='submit'>Set Volume</Button>
|
||||
</form>
|
||||
</Tabs.Panel>
|
||||
))}
|
||||
</Tabs>
|
||||
</Tabs.Panel>
|
||||
))}
|
||||
</Tabs>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,21 @@
|
||||
import TileGrid from '@/components/TileGrid';
|
||||
import React from 'react';
|
||||
|
||||
// Home page for siren
|
||||
export default function Page() {
|
||||
return <div></div>;
|
||||
return (
|
||||
// <div>
|
||||
// <p>Siren is a Dungeon Master's best friend.</p>
|
||||
// <h2>Features:</h2>
|
||||
// <ul>
|
||||
// <li>Manage your campaign and players</li>
|
||||
// <li>Create battlemaps on the fly and track initiative</li>
|
||||
// <li>Connect the Discord Bot to play online with friends</li>
|
||||
// <li>Reference Races, Classes, Items, Spells, and more</li>
|
||||
// </ul>
|
||||
// </div>
|
||||
<div style={{ overflow: 'hidden' }}>
|
||||
<TileGrid />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
56
ui/src/app/profile/page.tsx
Normal file
56
ui/src/app/profile/page.tsx
Normal file
@@ -0,0 +1,56 @@
|
||||
'use client';
|
||||
|
||||
import { me } from '@/api/auth';
|
||||
import React, { useEffect } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useRecoilState } from 'recoil';
|
||||
import { userState } from '@/state/auth';
|
||||
import { Card, Container, Grid, SimpleGrid } from '@mantine/core';
|
||||
|
||||
export default function Page() {
|
||||
const [user, setUser] = useRecoilState(userState);
|
||||
const router = useRouter();
|
||||
|
||||
useEffect(() => {
|
||||
if (!user) {
|
||||
me().then((response) => {
|
||||
if (response) {
|
||||
setUser(response.user);
|
||||
} else {
|
||||
router.push('/');
|
||||
}
|
||||
});
|
||||
}
|
||||
}, [user]);
|
||||
|
||||
if (user) {
|
||||
return (
|
||||
<Container mt={'2rem'}>
|
||||
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing={'md'}>
|
||||
<Card withBorder radius='md' padding='xl'>
|
||||
<Card.Section p={'1rem'}>
|
||||
<h2>
|
||||
{user.first_name} {user.last_name}
|
||||
</h2>
|
||||
{user.role}
|
||||
</Card.Section>
|
||||
</Card>
|
||||
<Grid gutter={'md'}>
|
||||
<Grid.Col>
|
||||
<Card withBorder radius='md' padding='xl'>
|
||||
<Card.Section p={'1rem'}>test</Card.Section>
|
||||
</Card>
|
||||
</Grid.Col>
|
||||
<Grid.Col>
|
||||
<Card withBorder radius='md' padding='xl'>
|
||||
<Card.Section p={'1rem'}>test</Card.Section>
|
||||
</Card>
|
||||
</Grid.Col>
|
||||
</Grid>
|
||||
</SimpleGrid>
|
||||
</Container>
|
||||
);
|
||||
} else {
|
||||
return <></>;
|
||||
}
|
||||
}
|
||||
@@ -73,6 +73,77 @@ export default function Page() {
|
||||
}}
|
||||
/>
|
||||
<hr />
|
||||
<SpellSection
|
||||
title='Level 2'
|
||||
spells={level2.filter((s) => s.name.toLowerCase().includes(searchName.toLowerCase()))}
|
||||
onClick={(spell) => {
|
||||
setActiveSpell(spell);
|
||||
setIsOpen(true);
|
||||
}}
|
||||
/>
|
||||
<hr />
|
||||
<SpellSection
|
||||
title='Level 3'
|
||||
spells={level3.filter((s) => s.name.toLowerCase().includes(searchName.toLowerCase()))}
|
||||
onClick={(spell) => {
|
||||
setActiveSpell(spell);
|
||||
setIsOpen(true);
|
||||
}}
|
||||
/>
|
||||
<hr />
|
||||
<SpellSection
|
||||
title='Level 4'
|
||||
spells={level4.filter((s) => s.name.toLowerCase().includes(searchName.toLowerCase()))}
|
||||
onClick={(spell) => {
|
||||
setActiveSpell(spell);
|
||||
setIsOpen(true);
|
||||
}}
|
||||
/>
|
||||
<hr />
|
||||
<SpellSection
|
||||
title='Level 5'
|
||||
spells={level5.filter((s) => s.name.toLowerCase().includes(searchName.toLowerCase()))}
|
||||
onClick={(spell) => {
|
||||
setActiveSpell(spell);
|
||||
setIsOpen(true);
|
||||
}}
|
||||
/>
|
||||
<hr />
|
||||
<SpellSection
|
||||
title='Level 6'
|
||||
spells={level6.filter((s) => s.name.toLowerCase().includes(searchName.toLowerCase()))}
|
||||
onClick={(spell) => {
|
||||
setActiveSpell(spell);
|
||||
setIsOpen(true);
|
||||
}}
|
||||
/>
|
||||
<hr />
|
||||
<SpellSection
|
||||
title='Level 7'
|
||||
spells={level7.filter((s) => s.name.toLowerCase().includes(searchName.toLowerCase()))}
|
||||
onClick={(spell) => {
|
||||
setActiveSpell(spell);
|
||||
setIsOpen(true);
|
||||
}}
|
||||
/>
|
||||
<hr />
|
||||
<SpellSection
|
||||
title='Level 8'
|
||||
spells={level8.filter((s) => s.name.toLowerCase().includes(searchName.toLowerCase()))}
|
||||
onClick={(spell) => {
|
||||
setActiveSpell(spell);
|
||||
setIsOpen(true);
|
||||
}}
|
||||
/>
|
||||
<hr />
|
||||
<SpellSection
|
||||
title='Level 9'
|
||||
spells={level9.filter((s) => s.name.toLowerCase().includes(searchName.toLowerCase()))}
|
||||
onClick={(spell) => {
|
||||
setActiveSpell(spell);
|
||||
setIsOpen(true);
|
||||
}}
|
||||
/>
|
||||
{activeSpell && <SpellModal spell={activeSpell} isOpen={isOpen} onClose={() => setIsOpen(false)} />}
|
||||
</Box>
|
||||
);
|
||||
@@ -90,9 +161,9 @@ function SpellSection({ title, spells, onClick }: { title: string; spells: Spell
|
||||
<Box>
|
||||
<h2>{title}</h2>
|
||||
<ul>
|
||||
{spells.map((spell) => (
|
||||
{spells.map((spell, index) => (
|
||||
<li
|
||||
key={spell.id}
|
||||
key={`spell-${index}`}
|
||||
className='link spell-item'
|
||||
style={{ width: 'fit-content' }}
|
||||
onClick={() => onClick(spell)}
|
||||
|
||||
@@ -1,210 +1,31 @@
|
||||
'use client';
|
||||
|
||||
import Link from 'next/link';
|
||||
import { usePathname } from 'next/navigation';
|
||||
import './topbar.css';
|
||||
import {
|
||||
Anchor,
|
||||
Avatar,
|
||||
Button,
|
||||
Card,
|
||||
Checkbox,
|
||||
Container,
|
||||
Grid,
|
||||
Group,
|
||||
Menu,
|
||||
Modal,
|
||||
Paper,
|
||||
PasswordInput,
|
||||
Text,
|
||||
TextInput,
|
||||
Title,
|
||||
UnstyledButton
|
||||
} from '@mantine/core';
|
||||
import Cookies from 'js-cookie';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useForm } from '@mantine/form';
|
||||
import { login, register, logout, me, refresh } from '@/api/auth';
|
||||
import { login, register } from '@/api/auth';
|
||||
import { User } from '@/api/auth.types';
|
||||
import { useToggle } from '@mantine/hooks';
|
||||
import {
|
||||
Modal,
|
||||
Container,
|
||||
Title,
|
||||
Anchor,
|
||||
Paper,
|
||||
TextInput,
|
||||
Button,
|
||||
PasswordInput,
|
||||
Group,
|
||||
Checkbox,
|
||||
Text
|
||||
} from '@mantine/core';
|
||||
import { useForm } from '@mantine/form';
|
||||
import { notifications } from '@mantine/notifications';
|
||||
|
||||
interface HeaderItem {
|
||||
name: string;
|
||||
link: string;
|
||||
role?: string;
|
||||
}
|
||||
|
||||
const headerItems: HeaderItem[] = [
|
||||
{
|
||||
name: 'Races',
|
||||
link: '/races'
|
||||
},
|
||||
{
|
||||
name: 'Classes',
|
||||
link: '/classes'
|
||||
},
|
||||
{
|
||||
name: 'Feats',
|
||||
link: '/feats'
|
||||
},
|
||||
{
|
||||
name: 'Options & Features',
|
||||
link: '/options'
|
||||
},
|
||||
{
|
||||
name: 'Backgrounds',
|
||||
link: '/backgrounds'
|
||||
},
|
||||
{
|
||||
name: 'Items',
|
||||
link: '/items'
|
||||
},
|
||||
{
|
||||
name: 'Spells',
|
||||
link: '/spells'
|
||||
},
|
||||
{
|
||||
name: 'Management',
|
||||
link: '/management',
|
||||
role: 'admin'
|
||||
}
|
||||
];
|
||||
|
||||
export default function Topbar() {
|
||||
const pathName = usePathname();
|
||||
const [modalType, toggle] = useToggle([undefined, 'login', 'register', 'reset']);
|
||||
const [headers, setHeaders] = useState<HeaderItem[]>([]);
|
||||
const [user, setUser] = useState<User | undefined>(undefined);
|
||||
useEffect(() => {
|
||||
if (Cookies.get('logged_in')) {
|
||||
me().then((response) => {
|
||||
if (response) {
|
||||
setUser(response.user);
|
||||
}
|
||||
});
|
||||
} else {
|
||||
refresh(true).then((response) => {
|
||||
if (response) {
|
||||
setUser(response.user);
|
||||
} else {
|
||||
setUser(undefined);
|
||||
}
|
||||
});
|
||||
}
|
||||
}, [pathName]);
|
||||
|
||||
useEffect(() => {
|
||||
const h: HeaderItem[] = [];
|
||||
headerItems.forEach((item) => {
|
||||
if (item.role == undefined || user?.role == item.role) {
|
||||
h.push(item);
|
||||
}
|
||||
setHeaders(h);
|
||||
});
|
||||
}, [user]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<nav className='navbar'>
|
||||
<div className='left'>
|
||||
<Link href={'/'} className='title'>
|
||||
Siren
|
||||
</Link>
|
||||
<div className='header-items'>
|
||||
{headers.map((item) => (
|
||||
<Link className={`header-item ${pathName == item.link && 'active'}`} href={item.link} key={item.name}>
|
||||
{item.name}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className='user-section'>
|
||||
{user ? (
|
||||
<Menu shadow='md' width={200} openDelay={100} closeDelay={400}>
|
||||
<Menu.Target>
|
||||
<UnstyledButton className='user user-button'>
|
||||
<Group>
|
||||
<Avatar />
|
||||
<div style={{ flex: 1 }}>
|
||||
<Text size='sm' fw={500}>
|
||||
{user.first_name} {user.last_name}
|
||||
</Text>
|
||||
|
||||
<Text c='dimmed' size='xs'>
|
||||
{user.role}
|
||||
</Text>
|
||||
</div>
|
||||
</Group>
|
||||
</UnstyledButton>
|
||||
</Menu.Target>
|
||||
<Menu.Dropdown>
|
||||
<Card>
|
||||
<Card.Section h={140} style={{}} />
|
||||
<Avatar size={80} radius={80} mx={'auto'} mt={-30} />
|
||||
<Text ta='center' fz='lg' fw={500} mt='sm'>
|
||||
{user.first_name} {user.last_name}
|
||||
</Text>
|
||||
<Text ta='center' fz='sm' c='dimmed'>
|
||||
{user.role}
|
||||
</Text>
|
||||
<Grid mt='xl'>
|
||||
<Grid.Col span={6}>
|
||||
<Button
|
||||
fullWidth
|
||||
radius='md'
|
||||
size='xs'
|
||||
variant='default'
|
||||
onClick={() => {
|
||||
toggle(undefined);
|
||||
}}
|
||||
>
|
||||
Profile
|
||||
</Button>
|
||||
</Grid.Col>
|
||||
<Grid.Col span={6}>
|
||||
<Button
|
||||
fullWidth
|
||||
radius='md'
|
||||
size='xs'
|
||||
variant='default'
|
||||
onClick={async () => {
|
||||
const response = await logout();
|
||||
if (response?.status == 200) {
|
||||
Cookies.remove('logged_in');
|
||||
setUser(undefined);
|
||||
}
|
||||
}}
|
||||
>
|
||||
Logout
|
||||
</Button>
|
||||
</Grid.Col>
|
||||
</Grid>
|
||||
</Card>
|
||||
</Menu.Dropdown>
|
||||
</Menu>
|
||||
) : (
|
||||
<Group className='user'>
|
||||
<Button onClick={() => toggle('login')}>Login</Button>
|
||||
<Button variant='outline' onClick={() => toggle('register')}>
|
||||
Sign up
|
||||
</Button>
|
||||
</Group>
|
||||
)}
|
||||
</div>
|
||||
</nav>
|
||||
<LoginModal type={modalType} toggle={toggle} setUser={setUser} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
interface LoginModalProps {
|
||||
interface HeaderModalProps {
|
||||
type?: string;
|
||||
toggle: any;
|
||||
setUser: (user: User) => void;
|
||||
setRefreshId: (id: NodeJS.Timeout) => void;
|
||||
}
|
||||
|
||||
function LoginModal({ type, toggle, setUser }: LoginModalProps) {
|
||||
export function HeaderModal({ type, toggle, setUser, setRefreshId }: HeaderModalProps) {
|
||||
function passwordValidator(value: string) {
|
||||
if (value.trim().length < 10) {
|
||||
return 'Password must be at least 10 characters';
|
||||
@@ -3,6 +3,8 @@
|
||||
justify-content: space-between;
|
||||
color: black;
|
||||
border-bottom: 1px solid #e6e6e6;
|
||||
max-height: 70px;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.navbar .left {
|
||||
49
ui/src/components/Header/headerItems.ts
Normal file
49
ui/src/components/Header/headerItems.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
export interface HeaderItem {
|
||||
label: string;
|
||||
link?: string;
|
||||
links?: HeaderItem[];
|
||||
}
|
||||
|
||||
export const headerItems: HeaderItem[] = [
|
||||
{
|
||||
label: 'Campaigns',
|
||||
link: '/campaigns'
|
||||
},
|
||||
{
|
||||
label: 'Characters',
|
||||
link: '/characters'
|
||||
},
|
||||
{
|
||||
label: 'Resources',
|
||||
links: [
|
||||
{
|
||||
label: 'Races',
|
||||
link: '/races'
|
||||
},
|
||||
{
|
||||
label: 'Classes',
|
||||
link: '/classes'
|
||||
},
|
||||
{
|
||||
label: 'Feats',
|
||||
link: '/feats'
|
||||
},
|
||||
{
|
||||
label: 'Options & Features',
|
||||
link: '/options'
|
||||
},
|
||||
{
|
||||
label: 'Backgrounds',
|
||||
link: '/backgrounds'
|
||||
},
|
||||
{
|
||||
label: 'Items',
|
||||
link: '/items'
|
||||
},
|
||||
{
|
||||
label: 'Spells',
|
||||
link: '/spells'
|
||||
}
|
||||
]
|
||||
}
|
||||
];
|
||||
209
ui/src/components/Header/index.tsx
Normal file
209
ui/src/components/Header/index.tsx
Normal file
@@ -0,0 +1,209 @@
|
||||
'use client';
|
||||
|
||||
import Link from 'next/link';
|
||||
import { usePathname, useRouter } from 'next/navigation';
|
||||
import './header.css';
|
||||
import { Avatar, Button, Card, Center, FileButton, Grid, Group, Menu, Text, UnstyledButton } from '@mantine/core';
|
||||
import Cookies from 'js-cookie';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { hasSession, logout, refresh, refreshLoggedIn } from '@/api/auth';
|
||||
import { useToggle } from '@mantine/hooks';
|
||||
import { HeaderModal } from './HeaderModal';
|
||||
import { HeaderItem, headerItems } from './headerItems';
|
||||
import { userState } from '@/state/auth';
|
||||
import { useRecoilState } from 'recoil';
|
||||
import { getPicture, setPicture } from '@/api/users';
|
||||
import { BsChevronDown } from 'react-icons/bs';
|
||||
import { User } from '@/api/auth.types';
|
||||
|
||||
export default function Header() {
|
||||
const pathName = usePathname();
|
||||
const [modalType, toggle] = useToggle([undefined, 'login', 'register', 'reset']);
|
||||
const [headers] = useState<HeaderItem[]>(headerItems);
|
||||
const [user, setUser] = useRecoilState(userState);
|
||||
const [refreshId, setRefreshId] = useState<NodeJS.Timeout | undefined>(undefined);
|
||||
const [profilePicture, setProfilePicture] = useState<File | null>(null);
|
||||
const router = useRouter();
|
||||
|
||||
useEffect(() => {
|
||||
if (user) {
|
||||
updateUser(user);
|
||||
}
|
||||
}, [user]);
|
||||
|
||||
function updateUser(user?: User) {
|
||||
if (!refreshId) {
|
||||
setRefreshId(refreshLoggedIn());
|
||||
}
|
||||
if (user) {
|
||||
getPicture().then((response) => {
|
||||
if (response) {
|
||||
setProfilePicture(response as File);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<nav className='navbar'>
|
||||
<div className='left'>
|
||||
<Link href={'/'} className='title'>
|
||||
Siren
|
||||
</Link>
|
||||
<div className='header-items'>
|
||||
{headers.map((item) => {
|
||||
const menuItems = item.links?.map((subItem) => (
|
||||
<Menu.Item
|
||||
color={pathName == subItem.link ? 'blue' : undefined}
|
||||
onClick={() => router.push(subItem.link ?? '#')}
|
||||
key={subItem.label}
|
||||
>
|
||||
{subItem.label}
|
||||
</Menu.Item>
|
||||
));
|
||||
if (menuItems) {
|
||||
return (
|
||||
<Menu trigger='hover' transitionProps={{ exitDuration: 0 }} withinPortal key={item.label}>
|
||||
<Menu.Target>
|
||||
<Link className={`header-item ${pathName == item.link && 'active'}`} href={item.link ?? '#'}>
|
||||
<Center>
|
||||
{item.label}
|
||||
<BsChevronDown />
|
||||
</Center>
|
||||
</Link>
|
||||
</Menu.Target>
|
||||
<Menu.Dropdown>{menuItems}</Menu.Dropdown>
|
||||
</Menu>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Link
|
||||
className={`header-item ${pathName == item.link && 'active'}`}
|
||||
href={item.link ?? '#'}
|
||||
key={item.label}
|
||||
>
|
||||
{item.label}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
<div className='user-section'>
|
||||
{user ? (
|
||||
<Menu shadow='md' width={200} openDelay={100} closeDelay={400}>
|
||||
<Menu.Target>
|
||||
<UnstyledButton className='user user-button'>
|
||||
<Group>
|
||||
<Avatar src={profilePicture ? URL.createObjectURL(profilePicture) : undefined} />
|
||||
<div style={{ flex: 1 }}>
|
||||
<Text size='sm' fw={500}>
|
||||
{user.first_name} {user.last_name}
|
||||
</Text>
|
||||
<Text c='dimmed' size='xs' style={{ textTransform: 'uppercase' }}>
|
||||
{user.role}
|
||||
</Text>
|
||||
</div>
|
||||
</Group>
|
||||
</UnstyledButton>
|
||||
</Menu.Target>
|
||||
<Menu.Dropdown p={0}>
|
||||
<Card>
|
||||
<Card.Section h={140} style={{ backgroundColor: '#4481e3' }} />
|
||||
<FileButton
|
||||
onChange={(payload) => {
|
||||
if (payload) {
|
||||
setPicture(payload).then((response) => {
|
||||
if (response) {
|
||||
setProfilePicture(payload);
|
||||
}
|
||||
});
|
||||
}
|
||||
}}
|
||||
accept='image/png,image/jpeg,image/jpg'
|
||||
multiple={false}
|
||||
>
|
||||
{(props) => (
|
||||
<Avatar
|
||||
{...props}
|
||||
component='button'
|
||||
size={80}
|
||||
radius={80}
|
||||
mx={'auto'}
|
||||
mt={-30}
|
||||
style={{ cursor: 'pointer' }}
|
||||
bg={profilePicture ? 'transparent' : 'white'}
|
||||
src={profilePicture ? URL.createObjectURL(profilePicture) : undefined}
|
||||
/>
|
||||
)}
|
||||
</FileButton>
|
||||
<Text ta='center' fz='lg' fw={500} mt='sm'>
|
||||
{user.first_name} {user.last_name}
|
||||
</Text>
|
||||
<Text ta='center' fz='sm' c='dimmed' style={{ textTransform: 'uppercase' }}>
|
||||
{user.role}
|
||||
</Text>
|
||||
<Grid mt='xl'>
|
||||
<Grid.Col span={6}>
|
||||
<Link href='/profile'>
|
||||
<Button fullWidth radius='md' size='xs' variant='default'>
|
||||
Profile
|
||||
</Button>
|
||||
</Link>
|
||||
</Grid.Col>
|
||||
<Grid.Col span={6}>
|
||||
<Button
|
||||
fullWidth
|
||||
radius='md'
|
||||
size='xs'
|
||||
variant='default'
|
||||
onClick={async () => {
|
||||
await logout();
|
||||
Cookies.remove('logged_in');
|
||||
setUser(undefined);
|
||||
clearInterval(refreshId);
|
||||
setRefreshId(undefined);
|
||||
setProfilePicture(null);
|
||||
if (refreshId) {
|
||||
clearInterval(refreshId);
|
||||
}
|
||||
}}
|
||||
>
|
||||
Logout
|
||||
</Button>
|
||||
</Grid.Col>
|
||||
{user.role == 'admin' && (
|
||||
<Grid.Col span={12}>
|
||||
<Link href='/admin'>
|
||||
<Button fullWidth radius='md' size='xs' variant='default'>
|
||||
Administration
|
||||
</Button>
|
||||
</Link>
|
||||
</Grid.Col>
|
||||
)}
|
||||
</Grid>
|
||||
</Card>
|
||||
</Menu.Dropdown>
|
||||
</Menu>
|
||||
) : (
|
||||
<Group className='user'>
|
||||
<Button onClick={() => toggle('login')}>Login</Button>
|
||||
<Button variant='outline' onClick={() => toggle('register')}>
|
||||
Sign up
|
||||
</Button>
|
||||
</Group>
|
||||
)}
|
||||
</div>
|
||||
</nav>
|
||||
<HeaderModal
|
||||
type={modalType}
|
||||
toggle={toggle}
|
||||
setUser={(u) => {
|
||||
setUser(u);
|
||||
updateUser(u);
|
||||
}}
|
||||
setRefreshId={setRefreshId}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
39
ui/src/components/Loading.tsx
Normal file
39
ui/src/components/Loading.tsx
Normal file
@@ -0,0 +1,39 @@
|
||||
'use client';
|
||||
|
||||
import { hasSession, refresh } from "@/api/auth";
|
||||
import { userState } from "@/state/auth";
|
||||
import { Skeleton } from "@mantine/core";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useRecoilState } from "recoil";
|
||||
|
||||
export default function Loading({ children }: { children: React.ReactNode }) {
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [user, setUser] = useRecoilState(userState);
|
||||
|
||||
useEffect(() => {
|
||||
if (!user) {
|
||||
hasSession().then((response) => {
|
||||
if (response) {
|
||||
refresh().then((response) => {
|
||||
if (response) {
|
||||
setUser(response.user);
|
||||
setLoading(false);
|
||||
} else {
|
||||
setLoading(false);
|
||||
}
|
||||
});
|
||||
} else {
|
||||
setLoading(false);
|
||||
}
|
||||
});
|
||||
} else {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
if (loading) {
|
||||
return <Skeleton height={'100%'} />;
|
||||
} else {
|
||||
return <>{children}</>;
|
||||
}
|
||||
}
|
||||
@@ -38,8 +38,8 @@ export default function SpellModal({ spell, isOpen, onClose }: SpellModalProps)
|
||||
</Grid.Col>
|
||||
<Grid.Col span={6}>
|
||||
<span style={{ fontWeight: 'bold', paddingRight: '1em' }}>Sources:</span>
|
||||
{spell.sources.map((s) => (
|
||||
<span style={{ paddingRight: '0.6em' }}>
|
||||
{spell.sources.map((s, index) => (
|
||||
<span style={{ paddingRight: '0.6em' }} key={`spell-source-${index}`}>
|
||||
{s.source}
|
||||
{s.page ? `.${s.page}` : ''}
|
||||
</span>
|
||||
@@ -48,8 +48,12 @@ export default function SpellModal({ spell, isOpen, onClose }: SpellModalProps)
|
||||
<Grid.Col span={6}>
|
||||
<span style={{ fontWeight: 'bold', marginRight: '1em' }}>Classes:</span>
|
||||
<span style={{ overflowWrap: 'break-word' }}>
|
||||
{spell.classes.map((c) => (
|
||||
<span style={{ paddingRight: '0.6em', display: 'inline-block' }} className='link'>
|
||||
{spell.classes.map((c, index) => (
|
||||
<span
|
||||
style={{ paddingRight: '0.6em', display: 'inline-block' }}
|
||||
className='link'
|
||||
key={`spell-class-${index}`}
|
||||
>
|
||||
{parseText(c, true)}
|
||||
</span>
|
||||
))}
|
||||
@@ -71,8 +75,8 @@ export default function SpellModal({ spell, isOpen, onClose }: SpellModalProps)
|
||||
<Grid.Col span={6}>
|
||||
<span style={{ fontWeight: 'bold', paddingRight: '1em' }}>Duration:</span>
|
||||
<span style={{ paddingRight: '0.6em' }}>
|
||||
{spell.durations.map((d) => (
|
||||
<span>
|
||||
{spell.durations.map((d, index) => (
|
||||
<span key={`duration-${index}`}>
|
||||
{capitalize(d.type)} {d.value} {capitalize(d.unit)}
|
||||
</span>
|
||||
))}
|
||||
@@ -86,45 +90,55 @@ export default function SpellModal({ spell, isOpen, onClose }: SpellModalProps)
|
||||
);
|
||||
}
|
||||
|
||||
function parseText(text: string, capitalizeFirst?: boolean) {
|
||||
function parseText(text: string, capitalizeFirst?: boolean): (string | JSX.Element)[] {
|
||||
const regex = /{@(.*?) (.*?)}/g;
|
||||
const matches = text.matchAll(regex);
|
||||
const result = [];
|
||||
let lastIndex = 0;
|
||||
let noMatches = true;
|
||||
|
||||
for (const match of matches) {
|
||||
noMatches = false;
|
||||
const key = crypto.randomUUID();
|
||||
const [full, type, name] = match;
|
||||
result.push(text.slice(lastIndex, match.index));
|
||||
result.push(<span key={crypto.randomUUID()}>{text.slice(lastIndex, match.index)}</span>);
|
||||
if (match.index !== undefined) {
|
||||
if (type == 'dice') {
|
||||
result.push(
|
||||
<span onClick={() => handleLink(type, name)} className='link'>
|
||||
<span onClick={() => handleLink(type, name)} className='link' key={key}>
|
||||
{name}
|
||||
</span>
|
||||
);
|
||||
} else if (type == 'scaledice') {
|
||||
// scaledice format is {@scaledice 1d6|1-9|1d6|}. Parse this out into dice, levels, and dice again.
|
||||
// scaledice format is {@scaledice 1d6|1-9}. Parse this out into dice, levels, and dice again.
|
||||
const [dice, levels] = name.split('|');
|
||||
result.push(
|
||||
<span onClick={() => handleLink('dice', dice)} className='link'>
|
||||
<span onClick={() => handleLink('dice', dice)} className='link' key={key}>
|
||||
{dice}
|
||||
</span>
|
||||
);
|
||||
} else if (type == 'bold') {
|
||||
result.push(<span style={{ fontWeight: 'bold' }}>{name}</span>);
|
||||
result.push(
|
||||
<span style={{ fontWeight: 'bold' }} key={key}>
|
||||
{name}
|
||||
</span>
|
||||
);
|
||||
} else if (type == 'subclass') {
|
||||
const [className, subclassName] = name.split('|');
|
||||
result.push(
|
||||
<span>
|
||||
<span key={key}>
|
||||
{capitalize(className)} ({capitalize(subclassName)})
|
||||
</span>
|
||||
);
|
||||
} else {
|
||||
result.push(<span>{capitalizeFirst ? capitalize(name) : name}</span>);
|
||||
result.push(<span key={key}>{capitalizeFirst ? capitalize(name) : name}</span>);
|
||||
}
|
||||
lastIndex = match.index + full.length;
|
||||
}
|
||||
}
|
||||
result.push(text.slice(lastIndex));
|
||||
const lastString = text.slice(lastIndex);
|
||||
result.push(<span key={crypto.randomUUID()}>{noMatches ? capitalize(lastString) : lastString}</span>);
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
@@ -148,18 +162,17 @@ function handleLink(type: string, name: string) {
|
||||
}
|
||||
|
||||
function SpellDescription({ spell }: { spell: Spell }) {
|
||||
|
||||
return (
|
||||
<>
|
||||
{spell.description && (
|
||||
<>
|
||||
{spell.description.entries.map((e) => (
|
||||
<>
|
||||
{spell.description.entries.map((e, index) => (
|
||||
<div key={`spell-description-${index}`}>
|
||||
{e.text && <p>{parseText(e.text)}</p>}
|
||||
{e.list && (
|
||||
<ul>
|
||||
{e.list.map((text) => (
|
||||
<li>{parseText(text)}</li>
|
||||
{e.list.map((text, index) => (
|
||||
<li key={`spell-text-${index}`}>{parseText(text)}</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
@@ -167,23 +180,23 @@ function SpellDescription({ spell }: { spell: Spell }) {
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
{e.table.headers.map((label) => (
|
||||
<th>{label}</th>
|
||||
{e.table.headers.map((label, index) => (
|
||||
<th key={`spell-header-${index}`}>{label}</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{e.table.rows.map((row) => (
|
||||
<tr>
|
||||
{row.map((cell) => (
|
||||
<td>{parseText(cell)}</td>
|
||||
{e.table.rows.map((row, index) => (
|
||||
<tr key={`spell-row-${index}`}>
|
||||
{row.map((cell, index) => (
|
||||
<td key={`spell-cell-${index}`}>{parseText(cell)}</td>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</>
|
||||
</div>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
|
||||
168
ui/src/components/TileGrid/TileControls.tsx
Normal file
168
ui/src/components/TileGrid/TileControls.tsx
Normal file
@@ -0,0 +1,168 @@
|
||||
import { ActionIcon, Box, ColorPicker, Menu } from '@mantine/core';
|
||||
import { FaSquare, FaCircle, FaHandPaper, FaRegCircle } from 'react-icons/fa';
|
||||
import { FaMagnifyingGlass, FaPencil } from 'react-icons/fa6';
|
||||
|
||||
export enum Tool {
|
||||
HAND,
|
||||
ZOOM,
|
||||
EDIT,
|
||||
TOKEN
|
||||
}
|
||||
|
||||
export enum EditTool {
|
||||
SQUARE,
|
||||
CIRCLE
|
||||
}
|
||||
|
||||
export const defaultColors = [
|
||||
'#000000',
|
||||
'#1D2B53',
|
||||
'#7E2553',
|
||||
'#008751',
|
||||
'#AB5236',
|
||||
'#5F574F',
|
||||
'#C2C3C7',
|
||||
'#FFF1E8',
|
||||
'#FF004D'
|
||||
];
|
||||
|
||||
interface TileControlsProps {
|
||||
tool: Tool;
|
||||
setTool: (tool: Tool) => void;
|
||||
editTool: EditTool;
|
||||
setEditTool: (editTool: EditTool) => void;
|
||||
colors: string[];
|
||||
setColors: (colors: string[]) => void;
|
||||
selectedColor: number;
|
||||
setSelectedColor: (selectedColor: number) => void;
|
||||
}
|
||||
|
||||
export default function TileControls({
|
||||
tool,
|
||||
setTool,
|
||||
editTool,
|
||||
setEditTool,
|
||||
colors,
|
||||
setColors,
|
||||
selectedColor,
|
||||
setSelectedColor
|
||||
}: TileControlsProps) {
|
||||
window.addEventListener(
|
||||
'keydown',
|
||||
(e) => {
|
||||
if (e.key === ' ') {
|
||||
setTool(Tool.HAND);
|
||||
} else if (e.key === 'z') {
|
||||
setTool(Tool.ZOOM);
|
||||
} else if (e.key === 'e') {
|
||||
setTool(Tool.EDIT);
|
||||
} else if (e.key === 't') {
|
||||
setTool(Tool.TOKEN);
|
||||
} else if (e.key === '1') {
|
||||
setSelectedColor(0);
|
||||
} else if (e.key === '2') {
|
||||
setSelectedColor(1);
|
||||
} else if (e.key === '3') {
|
||||
setSelectedColor(2);
|
||||
} else if (e.key === '4') {
|
||||
setSelectedColor(3);
|
||||
} else if (e.key === '5') {
|
||||
setSelectedColor(4);
|
||||
} else if (e.key === '6') {
|
||||
setSelectedColor(5);
|
||||
} else if (e.key === '7') {
|
||||
setSelectedColor(6);
|
||||
} else if (e.key === '8') {
|
||||
setSelectedColor(7);
|
||||
} else if (e.key === '9') {
|
||||
setSelectedColor(8);
|
||||
}
|
||||
},
|
||||
{ passive: false }
|
||||
);
|
||||
|
||||
function checkIfColorIsDark(color: string) {
|
||||
// If the color is dark, return white, otherwise return black
|
||||
const r = parseInt(color.slice(1, 3), 16);
|
||||
const g = parseInt(color.slice(3, 5), 16);
|
||||
const b = parseInt(color.slice(5, 7), 16);
|
||||
const brightness = (r * 299 + g * 587 + b * 114) / 1000;
|
||||
return brightness < 128 ? '#ffffff' : '#000000';
|
||||
}
|
||||
|
||||
return (
|
||||
<Box
|
||||
style={{
|
||||
userSelect: 'none',
|
||||
position: 'fixed',
|
||||
bottom: '2rem',
|
||||
left: '2rem'
|
||||
}}
|
||||
>
|
||||
{tool === Tool.EDIT && (
|
||||
<ActionIcon.Group orientation='vertical' style={{ paddingBottom: '0.3rem', paddingLeft: '3.5rem' }}>
|
||||
<ActionIcon
|
||||
variant={editTool == EditTool.SQUARE ? 'filled' : 'default'}
|
||||
onClick={() => setEditTool(EditTool.SQUARE)}
|
||||
>
|
||||
<FaSquare />
|
||||
</ActionIcon>
|
||||
<ActionIcon
|
||||
variant={editTool == EditTool.CIRCLE ? 'filled' : 'default'}
|
||||
onClick={() => setEditTool(EditTool.CIRCLE)}
|
||||
>
|
||||
<FaCircle />
|
||||
</ActionIcon>
|
||||
</ActionIcon.Group>
|
||||
)}
|
||||
<ActionIcon.Group style={{ paddingBottom: '0.3rem' }}>
|
||||
<ActionIcon variant={tool == Tool.HAND ? 'filled' : 'default'} onClick={() => setTool(Tool.HAND)}>
|
||||
<FaHandPaper />
|
||||
</ActionIcon>
|
||||
<ActionIcon variant={tool == Tool.ZOOM ? 'filled' : 'default'} onClick={() => setTool(Tool.ZOOM)}>
|
||||
<FaMagnifyingGlass />
|
||||
</ActionIcon>
|
||||
<ActionIcon variant={tool == Tool.EDIT ? 'filled' : 'default'} onClick={() => setTool(Tool.EDIT)}>
|
||||
<FaPencil />
|
||||
</ActionIcon>
|
||||
<ActionIcon variant={tool == Tool.TOKEN ? 'filled' : 'default'} onClick={() => setTool(Tool.TOKEN)}>
|
||||
<FaRegCircle />
|
||||
</ActionIcon>
|
||||
</ActionIcon.Group>
|
||||
<ActionIcon.Group>
|
||||
{colors.map((color, index) => (
|
||||
<Menu key={`color-${index}`} trigger='hover' openDelay={700} closeDelay={100}>
|
||||
<Menu.Target>
|
||||
<ActionIcon
|
||||
key={`color-${index}`}
|
||||
variant={'filled'}
|
||||
color={color}
|
||||
onClick={() => setSelectedColor(index)}
|
||||
>
|
||||
<span
|
||||
style={{
|
||||
color: checkIfColorIsDark(color),
|
||||
fontWeight: index == selectedColor ? 'bolder' : 'normal',
|
||||
textDecoration: index == selectedColor ? 'underline' : 'none'
|
||||
}}
|
||||
>
|
||||
{index + 1}
|
||||
</span>
|
||||
</ActionIcon>
|
||||
</Menu.Target>
|
||||
<Menu.Dropdown>
|
||||
<ColorPicker
|
||||
value={colors[index]}
|
||||
onChange={(v) => {
|
||||
const newColors = [...colors];
|
||||
newColors[index] = v;
|
||||
setColors(newColors);
|
||||
}}
|
||||
/>
|
||||
</Menu.Dropdown>
|
||||
</Menu>
|
||||
))}
|
||||
</ActionIcon.Group>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
0
ui/src/components/TileGrid/Viewport.tsx
Normal file
0
ui/src/components/TileGrid/Viewport.tsx
Normal file
179
ui/src/components/TileGrid/index.tsx
Normal file
179
ui/src/components/TileGrid/index.tsx
Normal file
@@ -0,0 +1,179 @@
|
||||
'use client';
|
||||
|
||||
import { Graphics, Stage } from '@pixi/react';
|
||||
import { Graphics as PixiGraphics } from '@pixi/graphics';
|
||||
import { MouseEvent, WheelEvent, useCallback, useEffect, useState } from 'react';
|
||||
import TileControls, { EditTool, Tool, defaultColors } from './TileControls';
|
||||
import { Box } from '@mantine/core';
|
||||
|
||||
interface SquareEdit {
|
||||
x: number;
|
||||
y: number;
|
||||
color: string;
|
||||
}
|
||||
|
||||
export default function TileGrid() {
|
||||
// Offset height of navbar from window height
|
||||
const height = window ? window.innerHeight - 70 : 0;
|
||||
// Offset width of layout padding from window width
|
||||
const width = window ? window.innerWidth : 0;
|
||||
|
||||
const [zoom, setZoom] = useState(1);
|
||||
const [gridSize, setGridSize] = useState({ width: width * 2, height: height * 2 });
|
||||
const [mouseDown, setMouseDown] = useState(false);
|
||||
const [lastPosition, setLastPosition] = useState({ x: -width / 2, y: -height / 2 });
|
||||
const [position, setPosition] = useState({ x: -width / 2, y: -height / 2 });
|
||||
const [tool, setTool] = useState<Tool>(Tool.HAND);
|
||||
const [editTool, setEditTool] = useState<EditTool>(EditTool.SQUARE);
|
||||
const [colors, setColors] = useState<string[]>(defaultColors);
|
||||
const [selectedColor, setSelectedColor] = useState<number>(0);
|
||||
const [edits, setEdits] = useState<SquareEdit[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
// Prevent context menu from appearing on right click
|
||||
function handleContextmenu(e: any) {
|
||||
e.preventDefault()
|
||||
}
|
||||
document.addEventListener('contextmenu', handleContextmenu)
|
||||
// Prevent scrollwheel from scrolling page
|
||||
function handleScroll(e: any) {
|
||||
e.preventDefault()
|
||||
}
|
||||
document.addEventListener('wheel', handleScroll, { passive: false })
|
||||
// Prevent space from scrolling page
|
||||
function handleSpace(e: any) {
|
||||
if (e.key === ' ') {
|
||||
e.preventDefault()
|
||||
}
|
||||
}
|
||||
document.addEventListener('keydown', handleSpace, { passive: false })
|
||||
return function cleanup() {
|
||||
document.removeEventListener('contextmenu', handleContextmenu)
|
||||
document.removeEventListener('wheel', handleScroll)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const drawGrid = useCallback(
|
||||
(g: PixiGraphics) => {
|
||||
g.clear();
|
||||
// Draw edits
|
||||
edits.forEach((edit) => {
|
||||
g.beginFill(parseInt(edit.color.replace('#', ''), 16));
|
||||
g.drawRect(edit.x * 32 * zoom, edit.y * 32 * zoom, 32 * zoom, 32 * zoom);
|
||||
g.endFill();
|
||||
});
|
||||
// Draw dot in the corner of each tile
|
||||
for (let x = 0; x < gridSize.width; x += (32 * zoom)) {
|
||||
for (let y = 0; y < gridSize.height; y += (32 * zoom)) {
|
||||
g.beginFill(0xffffff, 0.5);
|
||||
g.drawCircle(x, y, 1);
|
||||
g.endFill();
|
||||
}
|
||||
}
|
||||
},
|
||||
[gridSize, edits, zoom]
|
||||
);
|
||||
|
||||
function drawSquare(button: number, clientX: number, clientY: number) {
|
||||
// TODO: When zoomed in, the position is offset from above, when zoomed out, the position is offset from below
|
||||
const x = Math.floor((clientX - position.x) / (32 * zoom));
|
||||
const y = Math.floor((clientY - position.y) / (32 * zoom));
|
||||
if (button === 1) {
|
||||
// Add new edit if left mouse button is pressed
|
||||
setEdits([...edits, { x, y, color: colors[selectedColor] }]);
|
||||
} else if (button == 2) {
|
||||
// Remove edit if right mouse button is pressed
|
||||
setEdits(edits.filter((edit) => edit.x !== x || edit.y !== y));
|
||||
}
|
||||
}
|
||||
|
||||
function clickEvent(e: MouseEvent, isMouseDown: boolean) {
|
||||
setMouseDown(isMouseDown);
|
||||
setLastPosition({ x: e.clientX, y: e.clientY });
|
||||
if (isMouseDown) {
|
||||
if (tool == Tool.ZOOM) {
|
||||
handleZoom(e.buttons === 1 ? -100 : 100, e.clientX, e.clientY);
|
||||
} else if (tool == Tool.EDIT && editTool === EditTool.SQUARE) {
|
||||
drawSquare(e.buttons, e.clientX, e.clientY);
|
||||
} else if (editTool === EditTool.CIRCLE) {
|
||||
// handle circle
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function moveEvent(e: MouseEvent) {
|
||||
if (mouseDown) {
|
||||
if (tool == Tool.HAND || e.buttons == 4) {
|
||||
let dx = position.x + e.clientX - lastPosition.x;
|
||||
let dy = position.y + e.clientY - lastPosition.y;
|
||||
// Prevent coordinates from going out of bounds
|
||||
dx = Math.min(dx, 0);
|
||||
dx = Math.max(dx, -gridSize.width * zoom + width);
|
||||
dy = Math.min(dy, 0);
|
||||
dy = Math.max(dy, -gridSize.height * zoom + height);
|
||||
setPosition({ x: dx, y: dy });
|
||||
setLastPosition({ x: e.clientX, y: e.clientY });
|
||||
} else if (tool === Tool.EDIT && editTool === EditTool.SQUARE) {
|
||||
drawSquare(e.buttons, e.clientX, e.clientY);
|
||||
} else if (tool === Tool.EDIT && editTool === EditTool.CIRCLE) {
|
||||
// handle circle
|
||||
} else if (tool === Tool.TOKEN) {
|
||||
// handle token
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function zoomEvent(e: WheelEvent) {
|
||||
handleZoom(e.deltaY, e.clientX, e.clientY);
|
||||
}
|
||||
|
||||
function handleZoom(delta: number, clientX: number, clientY: number) {
|
||||
let newZoom = zoom;
|
||||
if (delta > 0) {
|
||||
newZoom = zoom / 1.1;
|
||||
} else {
|
||||
newZoom = zoom * 1.1;
|
||||
}
|
||||
newZoom = Math.min(newZoom, 3);
|
||||
newZoom = Math.max(newZoom, 0.6);
|
||||
setZoom(newZoom);
|
||||
// Adjust position to zoom in on mouse position
|
||||
let dx = (position.x - clientX) * (newZoom / zoom) + clientX;
|
||||
let dy = (position.y - clientY) * (newZoom / zoom) + clientY;
|
||||
// Prevent coordinates from going out of bounds
|
||||
dx = Math.min(dx, 0);
|
||||
dx = Math.max(dx, -gridSize.width * newZoom + width);
|
||||
dy = Math.min(dy, 0);
|
||||
dy = Math.max(dy, -gridSize.height * newZoom + height);
|
||||
setPosition({ x: dx, y: dy });
|
||||
}
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<Stage
|
||||
width={width}
|
||||
height={height}
|
||||
options={{
|
||||
backgroundColor: 0x333333,
|
||||
antialias: false
|
||||
}}
|
||||
onMouseDown={(e) => clickEvent(e, true)}
|
||||
onMouseUp={(e) => clickEvent(e, false)}
|
||||
onMouseMove={(e) => moveEvent(e)}
|
||||
onWheel={(e) => zoomEvent(e)}
|
||||
>
|
||||
<Graphics x={position.x} y={position.y} draw={drawGrid} />
|
||||
</Stage>
|
||||
<TileControls
|
||||
tool={tool}
|
||||
setTool={setTool}
|
||||
editTool={editTool}
|
||||
setEditTool={setEditTool}
|
||||
colors={colors}
|
||||
setColors={setColors}
|
||||
selectedColor={selectedColor}
|
||||
setSelectedColor={setSelectedColor}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
9
ui/src/components/TileGrid/tileGrid.css
Normal file
9
ui/src/components/TileGrid/tileGrid.css
Normal file
@@ -0,0 +1,9 @@
|
||||
.tile {
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
width: 100vw;
|
||||
max-width: 100%;
|
||||
height: 100vh;
|
||||
max-height: 100%;
|
||||
user-select: none;
|
||||
}
|
||||
7
ui/src/state/auth.ts
Normal file
7
ui/src/state/auth.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import { User } from '@/api/auth.types';
|
||||
import { atom } from 'recoil';
|
||||
|
||||
export const userState = atom({
|
||||
key: 'userState',
|
||||
default: undefined as User | undefined
|
||||
});
|
||||
@@ -1,11 +1,11 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ESNext",
|
||||
"target": "ES2022",
|
||||
"downlevelIteration": true,
|
||||
"lib": [
|
||||
"dom",
|
||||
"dom.iterable",
|
||||
"esnext"
|
||||
"ES2022"
|
||||
],
|
||||
"allowJs": true,
|
||||
"skipLibCheck": true,
|
||||
@@ -14,7 +14,7 @@
|
||||
"noEmit": true,
|
||||
"esModuleInterop": true,
|
||||
"module": "esnext",
|
||||
"moduleResolution": "node",
|
||||
"moduleResolution": "Node",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"jsx": "preserve",
|
||||
@@ -26,11 +26,24 @@
|
||||
],
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["./src/*"],
|
||||
"@api/*": ["src/api"],
|
||||
"@app/*": ["./src/app/*"],
|
||||
"@components/*": ["src/components/*"],
|
||||
"@lib/*": ["src/components/*"]
|
||||
"@/*": [
|
||||
"./src/*"
|
||||
],
|
||||
"@api/*": [
|
||||
"src/api"
|
||||
],
|
||||
"@app/*": [
|
||||
"./src/app/*"
|
||||
],
|
||||
"@components/*": [
|
||||
"src/components/*"
|
||||
],
|
||||
"@js/*": [
|
||||
"src/js/*"
|
||||
],
|
||||
"@state/*": [
|
||||
"src/state/*"
|
||||
]
|
||||
}
|
||||
},
|
||||
"include": [
|
||||
|
||||
Reference in New Issue
Block a user