1
.gitignore
vendored
1
.gitignore
vendored
@@ -2,6 +2,7 @@
|
|||||||
target/
|
target/
|
||||||
.idea/
|
.idea/
|
||||||
**/Cargo.lock
|
**/Cargo.lock
|
||||||
|
.DS_Store
|
||||||
|
|
||||||
.next/
|
.next/
|
||||||
node_modules/
|
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.
|
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>
|
<details>
|
||||||
<summary>Unix Installation</summary>
|
<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 libopus-dev
|
||||||
sudo apt install ffmpeg
|
sudo apt install ffmpeg
|
||||||
sudo apt apt install youtube-dl
|
sudo apt apt install youtube-dl # See notes above
|
||||||
# PostgreSQL Headers
|
# PostgreSQL Headers
|
||||||
sudo apt install libpq5
|
sudo apt install libpq5
|
||||||
sudo apt install libpq-dev
|
sudo apt install libpq-dev
|
||||||
@@ -45,11 +48,15 @@ The following packages must be installed for [serenity-rs/songbird](https://gith
|
|||||||
</details>
|
</details>
|
||||||
<details>
|
<details>
|
||||||
<summary>Mac Installation</summary>
|
<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 opus
|
||||||
brew install ffmpeg
|
brew install ffmpeg
|
||||||
brew install youtube-dl
|
brew install yt-dlp # See notes above
|
||||||
brew install postgresql
|
brew install postgresql
|
||||||
```
|
```
|
||||||
</details>
|
</details>
|
||||||
|
|||||||
@@ -29,6 +29,9 @@ jsonwebtoken = "9.0.0"
|
|||||||
redis = { version = "0.23.3", features = ["tokio-comp", "connection-manager", "r2d2"] }
|
redis = { version = "0.23.3", features = ["tokio-comp", "connection-manager", "r2d2"] }
|
||||||
base64 = "0.21.4"
|
base64 = "0.21.4"
|
||||||
rust-s3 = "0.33.0"
|
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]
|
[dependencies.tokio]
|
||||||
version = "1.32.0"
|
version = "1.32.0"
|
||||||
@@ -46,7 +49,7 @@ features = ["json", "rustls-tls"]
|
|||||||
[dependencies.diesel]
|
[dependencies.diesel]
|
||||||
version = "2.1.2"
|
version = "2.1.2"
|
||||||
default-features = false
|
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]
|
[dependencies.serenity]
|
||||||
version = "0.11.6"
|
version = "0.11.6"
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
version: '3.8'
|
version: '3.8'
|
||||||
|
|
||||||
x-env_file_personifi: &env
|
x-env_file: &env
|
||||||
- .env
|
- .env
|
||||||
|
|
||||||
name: siren
|
name: siren
|
||||||
@@ -30,6 +30,8 @@ services:
|
|||||||
- ${SERVICE_PORT:-5000}:5000
|
- ${SERVICE_PORT:-5000}:5000
|
||||||
depends_on:
|
depends_on:
|
||||||
- db
|
- db
|
||||||
|
- redis
|
||||||
|
- minio
|
||||||
networks:
|
networks:
|
||||||
- frontend
|
- frontend
|
||||||
- backend
|
- backend
|
||||||
@@ -53,6 +55,8 @@ services:
|
|||||||
redis:
|
redis:
|
||||||
image: redis:latest
|
image: redis:latest
|
||||||
container_name: siren-redis
|
container_name: siren-redis
|
||||||
|
volumes:
|
||||||
|
- redis:/data
|
||||||
ports:
|
ports:
|
||||||
- ${REDIS_PORT:-6379}:6379
|
- ${REDIS_PORT:-6379}:6379
|
||||||
networks:
|
networks:
|
||||||
@@ -77,6 +81,7 @@ services:
|
|||||||
volumes:
|
volumes:
|
||||||
db:
|
db:
|
||||||
db_logs:
|
db_logs:
|
||||||
|
redis:
|
||||||
minio:
|
minio:
|
||||||
|
|
||||||
networks:
|
networks:
|
||||||
|
|||||||
@@ -4,5 +4,8 @@ CREATE TABLE IF NOT EXISTS users (
|
|||||||
role TEXT NOT NULL,
|
role TEXT NOT NULL,
|
||||||
first_name TEXT NOT NULL,
|
first_name TEXT NOT NULL,
|
||||||
last_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
|
verified BOOLEAN NOT NULL DEFAULT FALSE
|
||||||
);
|
);
|
||||||
@@ -15,7 +15,8 @@ use siren::ServiceError;
|
|||||||
#[derive(Debug, Serialize, Deserialize)]
|
#[derive(Debug, Serialize, Deserialize)]
|
||||||
struct TokenClaims {
|
struct TokenClaims {
|
||||||
sub: String, // Subject
|
sub: String, // Subject
|
||||||
token_uuid: String, // Issuer
|
token_uuid: String, // Token UUID
|
||||||
|
iss: String, // Issuer
|
||||||
exp: i64, // Expiration time
|
exp: i64, // Expiration time
|
||||||
iat: i64, // Issued At
|
iat: i64, // Issued At
|
||||||
nbf: i64 // Not Before
|
nbf: i64 // Not Before
|
||||||
@@ -73,6 +74,7 @@ pub fn generate_token(email: &str, ttl: i64, private_key: &str) -> Result<TokenD
|
|||||||
};
|
};
|
||||||
let claims = TokenClaims {
|
let claims = TokenClaims {
|
||||||
sub: token_details.email.clone(),
|
sub: token_details.email.clone(),
|
||||||
|
iss: "siren".to_string(),
|
||||||
token_uuid: token_details.token_uuid.to_string(),
|
token_uuid: token_details.token_uuid.to_string(),
|
||||||
exp: token_details.expires_in.unwrap(),
|
exp: token_details.expires_in.unwrap(),
|
||||||
iat: now.timestamp(),
|
iat: now.timestamp(),
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ use redis::Commands;
|
|||||||
use serde::{Serialize, Deserialize};
|
use serde::{Serialize, Deserialize};
|
||||||
use siren::ServiceError;
|
use siren::ServiceError;
|
||||||
|
|
||||||
use crate::db::schema::users;
|
use crate::storage::{schema::users, connection};
|
||||||
|
|
||||||
use super::{hash_password, verify_token};
|
use super::{hash_password, verify_token};
|
||||||
|
|
||||||
@@ -27,6 +27,9 @@ impl RegisterUser {
|
|||||||
role: "user".to_string(),
|
role: "user".to_string(),
|
||||||
first_name: self.first_name,
|
first_name: self.first_name,
|
||||||
last_name: self.last_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,
|
verified: false,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -46,12 +49,15 @@ pub struct QueryUser {
|
|||||||
pub role: String,
|
pub role: String,
|
||||||
pub first_name: String,
|
pub first_name: String,
|
||||||
pub last_name: String,
|
pub last_name: String,
|
||||||
|
pub updated_at: chrono::NaiveDateTime,
|
||||||
|
pub created_at: chrono::NaiveDateTime,
|
||||||
|
pub profile_picture: Option<String>,
|
||||||
pub verified: bool,
|
pub verified: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl QueryUser {
|
impl QueryUser {
|
||||||
pub fn get_by_email(email: &str) -> Result<QueryUser, ServiceError> {
|
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
|
// Check if the user exists by email, case insensitive
|
||||||
|
|
||||||
let user = users::table
|
let user = users::table
|
||||||
@@ -69,17 +75,29 @@ pub struct InsertUser {
|
|||||||
pub role: String,
|
pub role: String,
|
||||||
pub first_name: String,
|
pub first_name: String,
|
||||||
pub last_name: String,
|
pub last_name: String,
|
||||||
|
pub updated_at: chrono::NaiveDateTime,
|
||||||
|
pub created_at: chrono::NaiveDateTime,
|
||||||
|
pub profile_picture: Option<String>,
|
||||||
pub verified: bool,
|
pub verified: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl InsertUser {
|
impl InsertUser {
|
||||||
pub fn insert(user: Self) -> Result<QueryUser, ServiceError> {
|
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)
|
let user = diesel::insert_into(users::table)
|
||||||
.values(user)
|
.values(user)
|
||||||
.get_result(&mut conn)?;
|
.get_result(&mut conn)?;
|
||||||
Ok(user)
|
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)]
|
#[derive(Debug, Serialize, Deserialize)]
|
||||||
@@ -88,6 +106,7 @@ pub struct ResponseUser {
|
|||||||
pub role: String,
|
pub role: String,
|
||||||
pub first_name: String,
|
pub first_name: String,
|
||||||
pub last_name: String,
|
pub last_name: String,
|
||||||
|
pub profile_picture: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl From<QueryUser> for ResponseUser {
|
impl From<QueryUser> for ResponseUser {
|
||||||
@@ -97,6 +116,7 @@ impl From<QueryUser> for ResponseUser {
|
|||||||
role: user.role,
|
role: user.role,
|
||||||
first_name: user.first_name,
|
first_name: user.first_name,
|
||||||
last_name: user.last_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 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,
|
Ok(conn) => conn,
|
||||||
Err(err) => {
|
Err(err) => {
|
||||||
error!("Failed to get redis connection: {}", err);
|
error!("Failed to get redis connection: {}", err);
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ use redis::AsyncCommands;
|
|||||||
use serde::{Serialize, Deserialize};
|
use serde::{Serialize, Deserialize};
|
||||||
use siren::ServiceError;
|
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")]
|
#[post("/register")]
|
||||||
async fn register(user: web::Json<RegisterUser>) -> HttpResponse {
|
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,
|
Ok(conn) => conn,
|
||||||
Err(err) => {
|
Err(err) => {
|
||||||
error!("Failed to get redis connection: {}", 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,
|
Ok(conn) => conn,
|
||||||
Err(err) => {
|
Err(err) => {
|
||||||
error!("Failed to get redis connection: {}", 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)
|
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,
|
Ok(conn) => conn,
|
||||||
Err(err) => {
|
Err(err) => {
|
||||||
error!("Failed to get redis connection: {}", err);
|
error!("Failed to get redis connection: {}", err);
|
||||||
@@ -340,6 +340,40 @@ async fn me(auth: JwtAuth) -> HttpResponse {
|
|||||||
HttpResponse::Ok().json(auth)
|
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")]
|
#[get("/roles")]
|
||||||
async fn roles() -> HttpResponse {
|
async fn roles() -> HttpResponse {
|
||||||
HttpResponse::Ok().json(vec!["admin", "user"])
|
HttpResponse::Ok().json(vec!["admin", "user"])
|
||||||
@@ -363,5 +397,6 @@ pub fn init_routes(config: &mut web::ServiceConfig) {
|
|||||||
.service(logout)
|
.service(logout)
|
||||||
.service(me)
|
.service(me)
|
||||||
.service(roles)
|
.service(roles)
|
||||||
|
.service(check_session)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -2,6 +2,7 @@ use std::sync::Arc;
|
|||||||
|
|
||||||
use log::{debug, warn};
|
use log::{debug, warn};
|
||||||
|
|
||||||
|
use reqwest::Url;
|
||||||
use serenity::client::Cache;
|
use serenity::client::Cache;
|
||||||
use serenity::model::application::interaction::{InteractionResponseType, application_command::ApplicationCommandInteraction};
|
use serenity::model::application::interaction::{InteractionResponseType, application_command::ApplicationCommandInteraction};
|
||||||
use serenity::model::prelude::{GuildId, ChannelId};
|
use serenity::model::prelude::{GuildId, ChannelId};
|
||||||
@@ -11,6 +12,8 @@ use siren::ServiceError;
|
|||||||
use songbird::{Call, Songbird};
|
use songbird::{Call, Songbird};
|
||||||
use songbird::input::{Restartable, Input, Metadata, error::Error as SongbirdError};
|
use songbird::input::{Restartable, Input, Metadata, error::Error as SongbirdError};
|
||||||
|
|
||||||
|
use crate::bot::ytdlp::{PlaylistItem, YtDlp};
|
||||||
|
|
||||||
pub mod pause;
|
pub mod pause;
|
||||||
pub mod play;
|
pub mod play;
|
||||||
pub mod resume;
|
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> {
|
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) {
|
let source = Restartable::ytdl(url.to_owned(), lazy).await?;
|
||||||
Restartable::ytdl(url.to_owned(), lazy).await?
|
|
||||||
} else {
|
|
||||||
Restartable::ytdl_search(url, lazy).await?
|
|
||||||
};
|
|
||||||
let mut handler = call.lock().await;
|
let mut handler = call.lock().await;
|
||||||
let track: Input = source.into();
|
let track: Input = source.into();
|
||||||
let metadata = *track.metadata.clone();
|
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)
|
Ok(metadata)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn is_valid_url(url: &str) -> bool {
|
pub fn get_playlist_urls(url: &str) -> Result<Vec<PlaylistItem>, ServiceError> {
|
||||||
match url.parse::<reqwest::Url>() {
|
let output = YtDlp::new()
|
||||||
Ok(_) => return true,
|
.arg("--flat-playlist")
|
||||||
Err(_) => return false
|
.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> {
|
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 siren::ServiceError;
|
||||||
use songbird::{EventHandler, Songbird};
|
use songbird::{EventHandler, Songbird};
|
||||||
|
|
||||||
use crate::bot::commands::audio::{leave, add_song, get_songbird};
|
use crate::bot::ytdlp::PlaylistItem;
|
||||||
use crate::db::guilds::QueryGuild;
|
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) {
|
pub async fn run(ctx: &Context, command: &ApplicationCommandInteraction) {
|
||||||
// Get the track url
|
// Get the track url
|
||||||
@@ -66,8 +66,14 @@ pub async fn run(ctx: &Context, command: &ApplicationCommandInteraction) {
|
|||||||
debug!("Play command executed with track: {:?}", track_url);
|
debug!("Play command executed with track: {:?}", track_url);
|
||||||
let manager = get_songbird(ctx).await;
|
let manager = get_songbird(ctx).await;
|
||||||
match play_track(manager, guild_id, track_url).await {
|
match play_track(manager, guild_id, track_url).await {
|
||||||
Ok(_) => {
|
Ok(count) => {
|
||||||
if let Err(why) = edit_response(&ctx, &command, "Playing track".to_string()).await {
|
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);
|
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) {
|
if let Some(handler_lock) = manager.get(guild_id) {
|
||||||
let is_queue_empty = {
|
let is_queue_empty = {
|
||||||
let call_handler = handler_lock.lock().await;
|
let call_handler = handler_lock.lock().await;
|
||||||
call_handler.queue().is_empty()
|
call_handler.queue().is_empty()
|
||||||
};
|
};
|
||||||
let guild = QueryGuild::get(guild_id.0 as i64)?;
|
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 {
|
let valid = is_valid_url(&track_url);
|
||||||
Ok(added_song) => {
|
if !valid.0 {
|
||||||
let track_title = added_song.title.unwrap();
|
warn!("Invalid track url: {}", track_url);
|
||||||
debug!("Added track: {}", track_title);
|
return Err(ServiceError { status: 422, message: format!("Invalid track url: {}", track_url) })
|
||||||
let mut handler = handler_lock.lock().await;
|
}
|
||||||
handler.remove_all_global_events();
|
let mut playlist_items: Vec<PlaylistItem> = Vec::new();
|
||||||
handler.add_global_event(songbird::Event::Track(songbird::TrackEvent::End), TrackEndNotifier { guild_id, call: manager })
|
if valid.1 {
|
||||||
},
|
playlist_items = match get_playlist_urls(&track_url) {
|
||||||
Err(err) => {
|
Ok(items) => items,
|
||||||
warn!("Failed to add song: {}", err);
|
Err(err) => {
|
||||||
if let Err(why) = leave(manager, &Some(guild_id)).await {
|
warn!("Failed to get playlist urls: {}", err);
|
||||||
error!("Failed to leave voice channel: {}", why);
|
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 {
|
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 serenity::model::application::interaction::application_command::ApplicationCommandInteraction;
|
||||||
use songbird::Songbird;
|
use songbird::Songbird;
|
||||||
|
|
||||||
use crate::db::guilds::InsertGuild;
|
use crate::bot::guilds::InsertGuild;
|
||||||
|
|
||||||
use super::{get_songbird, create_response, edit_response};
|
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 audio;
|
||||||
pub mod help;
|
pub mod help;
|
||||||
pub mod message;
|
pub mod chat;
|
||||||
pub mod oai;
|
|
||||||
pub mod ping;
|
pub mod ping;
|
||||||
pub mod schedule;
|
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 serde::{Serialize, Deserialize};
|
||||||
use siren::ServiceError;
|
use siren::ServiceError;
|
||||||
|
|
||||||
use crate::db::{schema::guilds, connection};
|
use crate::storage::{schema::guilds, connection};
|
||||||
|
|
||||||
#[derive(Queryable, QueryableByName, Serialize, Deserialize)]
|
#[derive(Queryable, QueryableByName, Serialize, Deserialize)]
|
||||||
#[diesel(table_name = guilds)]
|
#[diesel(table_name = guilds)]
|
||||||
@@ -1,18 +1,16 @@
|
|||||||
use std::{sync::Arc, pin::Pin};
|
use std::{sync::Arc, pin::Pin};
|
||||||
|
|
||||||
use actix_web::{get, post, web, HttpResponse, ResponseError};
|
use actix_web::{get, post, web, HttpResponse, ResponseError};
|
||||||
use log::warn;
|
|
||||||
use serde::{Serialize, Deserialize};
|
use serde::{Serialize, Deserialize};
|
||||||
use serenity::model::prelude::{GuildChannel, ChannelType};
|
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")]
|
#[get("/guilds")]
|
||||||
async fn get_guilds(data: web::Data<Arc<AppState>>, auth: JwtAuth) -> HttpResponse {
|
async fn get_guilds(data: web::Data<Arc<AppState>>, auth: JwtAuth) -> HttpResponse {
|
||||||
let _ = match verify_role(&auth, "admin") {
|
if let Err(err) = verify_role(&auth, "admin") {
|
||||||
Ok(_) => {},
|
return ResponseError::error_response(&err)
|
||||||
Err(err) => return ResponseError::error_response(&err)
|
|
||||||
};
|
};
|
||||||
let guild_results = &data.http.get_guilds(None, None).await;
|
let guild_results = &data.http.get_guilds(None, None).await;
|
||||||
let guilds = match guild_results {
|
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()
|
message: err.to_string()
|
||||||
})
|
})
|
||||||
};
|
};
|
||||||
HttpResponse::Ok().json(guilds)
|
HttpResponse::Ok().json(Response {
|
||||||
|
data: guilds,
|
||||||
|
metadata: None
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
#[get("/{id}/text")]
|
#[get("/{id}/text")]
|
||||||
async fn get_text_channels(id: web::Path<String>, data: web::Data<Arc<AppState>>, auth: JwtAuth) -> HttpResponse {
|
async fn get_text_channels(id: web::Path<String>, data: web::Data<Arc<AppState>>, auth: JwtAuth) -> HttpResponse {
|
||||||
let _ = match verify_role(&auth, "admin") {
|
if let Err(err) = verify_role(&auth, "admin") {
|
||||||
Ok(_) => {},
|
return ResponseError::error_response(&err)
|
||||||
Err(err) => return ResponseError::error_response(&err)
|
|
||||||
};
|
};
|
||||||
let channel_results = &data.http.get_channels(id.parse::<u64>().unwrap()).await;
|
let channel_results = &data.http.get_channels(id.parse::<u64>().unwrap()).await;
|
||||||
let channels = match channel_results {
|
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()
|
message: err.to_string()
|
||||||
})
|
})
|
||||||
};
|
};
|
||||||
HttpResponse::Ok().json(channels)
|
HttpResponse::Ok().json(Response {
|
||||||
|
data: channels,
|
||||||
|
metadata: None
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
#[get("/{id}/voice")]
|
#[get("/{id}/voice")]
|
||||||
async fn get_voice_channels(id: web::Path<String>, data: web::Data<Arc<AppState>>, auth: JwtAuth) -> HttpResponse {
|
async fn get_voice_channels(id: web::Path<String>, data: web::Data<Arc<AppState>>, auth: JwtAuth) -> HttpResponse {
|
||||||
let _ = match verify_role(&auth, "admin") {
|
if let Err(err) = verify_role(&auth, "admin") {
|
||||||
Ok(_) => {},
|
return ResponseError::error_response(&err)
|
||||||
Err(err) => return ResponseError::error_response(&err)
|
|
||||||
};
|
};
|
||||||
let channel_results = &data.http.get_channels(id.parse::<u64>().unwrap()).await;
|
let channel_results = &data.http.get_channels(id.parse::<u64>().unwrap()).await;
|
||||||
let channels = match channel_results {
|
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()
|
message: err.to_string()
|
||||||
})
|
})
|
||||||
};
|
};
|
||||||
HttpResponse::Ok().json(channels)
|
HttpResponse::Ok().json(Response {
|
||||||
|
data: channels,
|
||||||
|
metadata: None
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Serialize, Deserialize)]
|
#[derive(Serialize, Deserialize)]
|
||||||
@@ -66,15 +71,13 @@ struct ChannelMessage {
|
|||||||
|
|
||||||
#[post("/{guild_id}/text/{channel_id}/message")]
|
#[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 {
|
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") {
|
if let Err(err) = verify_role(&auth, "admin") {
|
||||||
Ok(_) => {},
|
return ResponseError::error_response(&err)
|
||||||
Err(err) => return ResponseError::error_response(&err)
|
|
||||||
};
|
};
|
||||||
let (guild_id, channel_id) = path.into_inner();
|
let (guild_id, channel_id) = path.into_inner();
|
||||||
let guild_id = match guild_id.parse::<u64>() {
|
let guild_id = match guild_id.parse::<u64>() {
|
||||||
Ok(id) => id,
|
Ok(id) => id,
|
||||||
Err(err) => {
|
Err(err) => {
|
||||||
warn!("Could not parse guild id: {:?}", err);
|
|
||||||
return ResponseError::error_response(&ServiceError {
|
return ResponseError::error_response(&ServiceError {
|
||||||
status: 422,
|
status: 422,
|
||||||
message: err.to_string()
|
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>() {
|
let channel_id = match channel_id.parse::<u64>() {
|
||||||
Ok(id) => id,
|
Ok(id) => id,
|
||||||
Err(err) => {
|
Err(err) => {
|
||||||
warn!("Could not parse channel id: {:?}", err);
|
|
||||||
return ResponseError::error_response(&ServiceError {
|
return ResponseError::error_response(&ServiceError {
|
||||||
status: 422,
|
status: 422,
|
||||||
message: err.to_string()
|
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 {
|
let channels = match channel_results {
|
||||||
Ok(channels) => channels,
|
Ok(channels) => channels,
|
||||||
Err(err) => {
|
Err(err) => {
|
||||||
warn!("Could not get channels: {:?}", err);
|
|
||||||
return ResponseError::error_response(&ServiceError {
|
return ResponseError::error_response(&ServiceError {
|
||||||
status: 422,
|
status: 422,
|
||||||
message: err.to_string()
|
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) {
|
let channel = match channels.iter().find(|c| c.id.0 == channel_id) {
|
||||||
Some(channel) => channel,
|
Some(channel) => channel,
|
||||||
None => {
|
None => {
|
||||||
warn!("Could not find channel with id {}", channel_id);
|
|
||||||
return ResponseError::error_response(&ServiceError {
|
return ResponseError::error_response(&ServiceError {
|
||||||
status: 422,
|
status: 422,
|
||||||
message: format!("Could not find channel with id {}", channel_id)
|
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 {
|
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 {
|
return ResponseError::error_response(&ServiceError {
|
||||||
status: 422,
|
status: 422,
|
||||||
message: err.to_string()
|
message: err.to_string()
|
||||||
@@ -132,22 +131,19 @@ struct PlayRequest {
|
|||||||
|
|
||||||
#[post("/{guild_id}/voice/{channel_id}/play")]
|
#[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 {
|
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") {
|
if let Err(err) = verify_role(&auth, "admin") {
|
||||||
Ok(_) => {},
|
return ResponseError::error_response(&err)
|
||||||
Err(err) => return ResponseError::error_response(&err)
|
|
||||||
};
|
};
|
||||||
let (guild_id, channel_id) = path.into_inner();
|
let (guild_id, channel_id) = path.into_inner();
|
||||||
let guild_id = match guild_id.parse::<u64>() {
|
let guild_id = match guild_id.parse::<u64>() {
|
||||||
Ok(id) => id,
|
Ok(id) => id,
|
||||||
Err(err) => {
|
Err(err) => {
|
||||||
warn!("Could not parse guild id: {:?}", err);
|
|
||||||
return ResponseError::error_response(&ServiceError { status: 422, message: err.to_string() })
|
return ResponseError::error_response(&ServiceError { status: 422, message: err.to_string() })
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
let channel_id = match channel_id.parse::<u64>() {
|
let channel_id = match channel_id.parse::<u64>() {
|
||||||
Ok(id) => id,
|
Ok(id) => id,
|
||||||
Err(err) => {
|
Err(err) => {
|
||||||
warn!("Could not parse channel id: {:?}", err);
|
|
||||||
return ResponseError::error_response(&ServiceError { status: 422, message: err.to_string() })
|
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 {
|
let guild = match http.get_guild(guild_id).await {
|
||||||
Ok(guild) => guild,
|
Ok(guild) => guild,
|
||||||
Err(err) => {
|
Err(err) => {
|
||||||
warn!("Could not get guild: {:?}", err);
|
|
||||||
return ResponseError::error_response(&ServiceError { status: 422, message: err.to_string() })
|
return ResponseError::error_response(&ServiceError { status: 422, message: err.to_string() })
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
let channel = match http.get_channel(channel_id).await {
|
let channel = match http.get_channel(channel_id).await {
|
||||||
Ok(channel) => channel,
|
Ok(channel) => channel,
|
||||||
Err(err) => {
|
Err(err) => {
|
||||||
warn!("Could not get channel: {:?}", err);
|
|
||||||
return ResponseError::error_response(&ServiceError { status: 422, message: err.to_string() })
|
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 {
|
match play_track(Arc::clone(&data.songbird), guild.id, play_request.track_url.to_string()).await {
|
||||||
Ok(_) => HttpResponse::Ok().finish(),
|
Ok(_) => HttpResponse::Ok().finish(),
|
||||||
Err(err) => {
|
Err(err) => {
|
||||||
warn!("Could not play track: {:?}", err);
|
|
||||||
return ResponseError::error_response(&err)
|
return ResponseError::error_response(&err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
Err(err) => {
|
Err(err) => {
|
||||||
warn!("Could not join channel: {:?}", err);
|
|
||||||
return ResponseError::error_response(&ServiceError { status: 500, message: err.to_string() })
|
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")]
|
#[post("/{guild_id}/voice/stop")]
|
||||||
async fn stop(path: web::Path<String>, data: web::Data<Arc<AppState>>, auth: JwtAuth) -> HttpResponse {
|
async fn stop(path: web::Path<String>, data: web::Data<Arc<AppState>>, auth: JwtAuth) -> HttpResponse {
|
||||||
let _ = match verify_role(&auth, "admin") {
|
if let Err(err) = verify_role(&auth, "admin") {
|
||||||
Ok(_) => {},
|
return ResponseError::error_response(&err)
|
||||||
Err(err) => return ResponseError::error_response(&err)
|
|
||||||
};
|
};
|
||||||
let guild_id = path.into_inner();
|
let guild_id = path.into_inner();
|
||||||
let guild_id = match guild_id.parse::<u64>() {
|
let guild_id = match guild_id.parse::<u64>() {
|
||||||
Ok(id) => id,
|
Ok(id) => id,
|
||||||
Err(err) => {
|
Err(err) => {
|
||||||
warn!("Could not parse guild id: {:?}", err);
|
|
||||||
return ResponseError::error_response(&ServiceError {
|
return ResponseError::error_response(&ServiceError {
|
||||||
status: 422,
|
status: 422,
|
||||||
message: err.to_string()
|
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")]
|
#[post("/{guild_id}/voice/resume")]
|
||||||
async fn resume(path: web::Path<String>, data: web::Data<Arc<AppState>>, auth: JwtAuth) -> HttpResponse {
|
async fn resume(path: web::Path<String>, data: web::Data<Arc<AppState>>, auth: JwtAuth) -> HttpResponse {
|
||||||
let _ = match verify_role(&auth, "admin") {
|
if let Err(err) = verify_role(&auth, "admin") {
|
||||||
Ok(_) => {},
|
return ResponseError::error_response(&err)
|
||||||
Err(err) => return ResponseError::error_response(&err)
|
|
||||||
};
|
};
|
||||||
let guild_id = path.into_inner();
|
let guild_id = path.into_inner();
|
||||||
let guild_id = match guild_id.parse::<u64>() {
|
let guild_id = match guild_id.parse::<u64>() {
|
||||||
Ok(id) => id,
|
Ok(id) => id,
|
||||||
Err(err) => {
|
Err(err) => {
|
||||||
warn!("Could not parse guild id: {:?}", err);
|
|
||||||
return ResponseError::error_response(&ServiceError {
|
return ResponseError::error_response(&ServiceError {
|
||||||
status: 422,
|
status: 422,
|
||||||
message: err.to_string()
|
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) {
|
if let Some(handler_lock) = data.songbird.get(guild_id) {
|
||||||
let handler = handler_lock.lock().await;
|
let handler = handler_lock.lock().await;
|
||||||
if let Err(err) = handler.queue().resume() {
|
if let Err(err) = handler.queue().resume() {
|
||||||
warn!("Could not resume track: {:?}", err);
|
|
||||||
return ResponseError::error_response(&ServiceError {
|
return ResponseError::error_response(&ServiceError {
|
||||||
status: 422,
|
status: 422,
|
||||||
message: err.to_string()
|
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")]
|
#[post("/{guild_id}/voice/pause")]
|
||||||
async fn pause(path: web::Path<String>, data: web::Data<Arc<AppState>>, auth: JwtAuth) -> HttpResponse {
|
async fn pause(path: web::Path<String>, data: web::Data<Arc<AppState>>, auth: JwtAuth) -> HttpResponse {
|
||||||
let _ = match verify_role(&auth, "admin") {
|
if let Err(err) = verify_role(&auth, "admin") {
|
||||||
Ok(_) => {},
|
return ResponseError::error_response(&err)
|
||||||
Err(err) => return ResponseError::error_response(&err)
|
|
||||||
};
|
};
|
||||||
let guild_id = path.into_inner();
|
let guild_id = path.into_inner();
|
||||||
let guild_id = match guild_id.parse::<u64>() {
|
let guild_id = match guild_id.parse::<u64>() {
|
||||||
Ok(id) => id,
|
Ok(id) => id,
|
||||||
Err(err) => {
|
Err(err) => {
|
||||||
warn!("Could not parse guild id: {:?}", err);
|
|
||||||
return ResponseError::error_response(&ServiceError {
|
return ResponseError::error_response(&ServiceError {
|
||||||
status: 422,
|
status: 422,
|
||||||
message: err.to_string()
|
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) {
|
if let Some(handler_lock) = data.songbird.get(guild_id) {
|
||||||
let handler = handler_lock.lock().await;
|
let handler = handler_lock.lock().await;
|
||||||
if let Err(err) = handler.queue().pause() {
|
if let Err(err) = handler.queue().pause() {
|
||||||
warn!("Could not pause track: {:?}", err);
|
|
||||||
return ResponseError::error_response(&ServiceError {
|
return ResponseError::error_response(&ServiceError {
|
||||||
status: 422,
|
status: 422,
|
||||||
message: err.to_string()
|
message: err.to_string()
|
||||||
@@ -283,15 +267,13 @@ struct SetVolume {
|
|||||||
|
|
||||||
#[get("/{guild_id}/voice/volume")]
|
#[get("/{guild_id}/voice/volume")]
|
||||||
async fn get_volume(path: web::Path<String>, auth: JwtAuth) -> HttpResponse {
|
async fn get_volume(path: web::Path<String>, auth: JwtAuth) -> HttpResponse {
|
||||||
let _ = match verify_role(&auth, "admin") {
|
if let Err(err) = verify_role(&auth, "admin") {
|
||||||
Ok(_) => {},
|
return ResponseError::error_response(&err)
|
||||||
Err(err) => return ResponseError::error_response(&err)
|
|
||||||
};
|
};
|
||||||
let guild_id = path.into_inner();
|
let guild_id = path.into_inner();
|
||||||
let guild_id = match guild_id.parse::<u64>() {
|
let guild_id = match guild_id.parse::<u64>() {
|
||||||
Ok(id) => id,
|
Ok(id) => id,
|
||||||
Err(err) => {
|
Err(err) => {
|
||||||
warn!("Could not parse guild id: {:?}", err);
|
|
||||||
return ResponseError::error_response(&ServiceError {
|
return ResponseError::error_response(&ServiceError {
|
||||||
status: 422,
|
status: 422,
|
||||||
message: err.to_string()
|
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) {
|
let volume = match QueryGuild::get(guild_id as i64) {
|
||||||
Ok(guild) => guild.volume,
|
Ok(guild) => guild.volume,
|
||||||
Err(err) => {
|
Err(err) => {
|
||||||
warn!("Could not get volume: {:?}", err);
|
|
||||||
return ResponseError::error_response(&ServiceError {
|
return ResponseError::error_response(&ServiceError {
|
||||||
status: 422,
|
status: 422,
|
||||||
message: err.to_string()
|
message: err.to_string()
|
||||||
@@ -315,15 +296,13 @@ async fn get_volume(path: web::Path<String>, auth: JwtAuth) -> HttpResponse {
|
|||||||
|
|
||||||
#[post("/{guild_id}/voice/volume")]
|
#[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 {
|
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") {
|
if let Err(err) = verify_role(&auth, "admin") {
|
||||||
Ok(_) => {},
|
return ResponseError::error_response(&err)
|
||||||
Err(err) => return ResponseError::error_response(&err)
|
|
||||||
};
|
};
|
||||||
let guild_id = path.into_inner();
|
let guild_id = path.into_inner();
|
||||||
let guild_id = match guild_id.parse::<u64>() {
|
let guild_id = match guild_id.parse::<u64>() {
|
||||||
Ok(id) => id,
|
Ok(id) => id,
|
||||||
Err(err) => {
|
Err(err) => {
|
||||||
warn!("Could not parse guild id: {:?}", err);
|
|
||||||
return ResponseError::error_response(&ServiceError {
|
return ResponseError::error_response(&ServiceError {
|
||||||
status: 422,
|
status: 422,
|
||||||
message: err.to_string()
|
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 {
|
let guild = match http.get_guild(guild_id).await {
|
||||||
Ok(guild) => guild,
|
Ok(guild) => guild,
|
||||||
Err(err) => {
|
Err(err) => {
|
||||||
warn!("Could not get guild: {:?}", err);
|
|
||||||
return ResponseError::error_response(&ServiceError { status: 422, message: err.to_string() })
|
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")]
|
#[post("/{guild_id}/voice/skip")]
|
||||||
async fn skip(path: web::Path<String>, data: web::Data<Arc<AppState>>, auth: JwtAuth) -> HttpResponse {
|
async fn skip(path: web::Path<String>, data: web::Data<Arc<AppState>>, auth: JwtAuth) -> HttpResponse {
|
||||||
let _ = match verify_role(&auth, "admin") {
|
if let Err(err) = verify_role(&auth, "admin") {
|
||||||
Ok(_) => {},
|
return ResponseError::error_response(&err)
|
||||||
Err(err) => return ResponseError::error_response(&err)
|
|
||||||
};
|
};
|
||||||
let guild_id = path.into_inner();
|
let guild_id = path.into_inner();
|
||||||
let guild_id = match guild_id.parse::<u64>() {
|
let guild_id = match guild_id.parse::<u64>() {
|
||||||
Ok(id) => id,
|
Ok(id) => id,
|
||||||
Err(err) => {
|
Err(err) => {
|
||||||
warn!("Could not parse guild id: {:?}", err);
|
|
||||||
return ResponseError::error_response(&ServiceError {
|
return ResponseError::error_response(&ServiceError {
|
||||||
status: 422,
|
status: 422,
|
||||||
message: err.to_string()
|
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) {
|
if let Some(handler_lock) = data.songbird.get(guild_id) {
|
||||||
let handler = handler_lock.lock().await;
|
let handler = handler_lock.lock().await;
|
||||||
if let Err(err) = handler.queue().skip() {
|
if let Err(err) = handler.queue().skip() {
|
||||||
warn!("Could not skip track: {:?}", err);
|
|
||||||
return ResponseError::error_response(&ServiceError {
|
return ResponseError::error_response(&ServiceError {
|
||||||
status: 422,
|
status: 422,
|
||||||
message: err.to_string()
|
message: err.to_string()
|
||||||
@@ -5,14 +5,14 @@ use serenity::model::gateway::Ready;
|
|||||||
use serenity::model::channel::Message;
|
use serenity::model::channel::Message;
|
||||||
use serenity::prelude::*;
|
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;
|
use super::commands::audio::create_response;
|
||||||
|
|
||||||
pub struct Handler {
|
pub struct Handler {
|
||||||
// Open AI Config
|
// Open AI Config
|
||||||
pub oai: Option<commands::oai::OAI>
|
pub oai: Option<oai::OAI>
|
||||||
}
|
}
|
||||||
|
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
@@ -36,7 +36,7 @@ impl EventHandler for Handler {
|
|||||||
Err(_) => false
|
Err(_) => false
|
||||||
};
|
};
|
||||||
if mentioned || bot_in_thread {
|
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)
|
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) {
|
async fn interaction_create(&self, ctx: Context, interaction: Interaction) {
|
||||||
if let Interaction::ApplicationCommand(command) = interaction {
|
if let Interaction::ApplicationCommand(command) = interaction {
|
||||||
match command.data.name.as_str() {
|
match command.data.name.as_str() {
|
||||||
|
"roll" => commands::roll::run(&ctx, &command).await,
|
||||||
"play" => commands::audio::play::run(&ctx, &command).await,
|
"play" => commands::audio::play::run(&ctx, &command).await,
|
||||||
"stop" => commands::audio::stop::run(&ctx, &command).await,
|
"stop" => commands::audio::stop::run(&ctx, &command).await,
|
||||||
"pause" => commands::audio::pause::run(&ctx, &command).await,
|
"pause" => commands::audio::pause::run(&ctx, &command).await,
|
||||||
@@ -80,7 +81,9 @@ impl EventHandler for Handler {
|
|||||||
volume: 100
|
volume: 100
|
||||||
});
|
});
|
||||||
let commands = guild.id.set_application_commands(&ctx.http, |commands| {
|
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::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::stop::register(command) })
|
||||||
.create_application_command(|command: &mut serenity::builder::CreateApplicationCommand| { commands::audio::pause::register(command) })
|
.create_application_command(|command: &mut serenity::builder::CreateApplicationCommand| { commands::audio::pause::register(command) })
|
||||||
|
|||||||
@@ -2,9 +2,9 @@ use diesel::prelude::*;
|
|||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use siren::ServiceError;
|
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)]
|
#[diesel(table_name = messages)]
|
||||||
pub struct QueryMessage {
|
pub struct QueryMessage {
|
||||||
pub id: String,
|
pub id: String,
|
||||||
@@ -49,7 +49,7 @@ impl Default for QueryFilters {
|
|||||||
|
|
||||||
impl QueryMessage {
|
impl QueryMessage {
|
||||||
pub fn get_all(filters: &QueryFilters, limit: i32, page: i32) -> Result<Vec<Self>, ServiceError> {
|
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();
|
let mut query = messages::table.limit(limit as i64).order(messages::created.asc()).into_boxed();
|
||||||
// Limit query to page and limit
|
// Limit query to page and limit
|
||||||
let offset = (page - 1) * limit;
|
let offset = (page - 1) * limit;
|
||||||
@@ -88,7 +88,7 @@ impl QueryMessage {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn get_count(fitlers: &QueryFilters) -> Result<i64, ServiceError> {
|
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();
|
let mut query = messages::table.into_boxed();
|
||||||
// Apply filters
|
// Apply filters
|
||||||
if let Some(id) = &fitlers.by_id {
|
if let Some(id) = &fitlers.by_id {
|
||||||
@@ -122,26 +122,9 @@ impl QueryMessage {
|
|||||||
let count = query.count().get_result::<i64>(&mut conn)?;
|
let count = query.count().get_result::<i64>(&mut conn)?;
|
||||||
Ok(count)
|
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> {
|
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)
|
let message = diesel::insert_into(messages::table)
|
||||||
.values(message)
|
.values(message)
|
||||||
.get_result(&mut conn)?;
|
.get_result(&mut conn)?;
|
||||||
@@ -1,9 +1,9 @@
|
|||||||
use actix_web::{get, post, web, HttpResponse, HttpRequest, ResponseError};
|
use actix_web::{get, post, web, HttpResponse, HttpRequest, ResponseError};
|
||||||
use log::error;
|
use log::error;
|
||||||
use serde::{Serialize, Deserialize};
|
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)]
|
#[derive(Serialize, Deserialize)]
|
||||||
struct GetAllParams {
|
struct GetAllParams {
|
||||||
@@ -50,7 +50,7 @@ async fn get_all(req: HttpRequest, auth: JwtAuth) -> HttpResponse {
|
|||||||
|
|
||||||
match QueryMessage::get_all(&filters, limit, page) {
|
match QueryMessage::get_all(&filters, limit, page) {
|
||||||
Ok(messages) => {
|
Ok(messages) => {
|
||||||
HttpResponse::Ok().json(GetResponse {
|
HttpResponse::Ok().json(Response {
|
||||||
data: messages,
|
data: messages,
|
||||||
metadata: Some(Metadata {
|
metadata: Some(Metadata {
|
||||||
total: total_count as i32,
|
total: total_count as i32,
|
||||||
@@ -68,12 +68,12 @@ async fn get_all(req: HttpRequest, auth: JwtAuth) -> HttpResponse {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[post("/messages")]
|
#[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") {
|
let _ = match verify_role(&auth, "admin") {
|
||||||
Ok(_) => {},
|
Ok(_) => {},
|
||||||
Err(err) => return ResponseError::error_response(&err)
|
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),
|
Ok(message) => HttpResponse::Created().json(message),
|
||||||
Err(err) => {
|
Err(err) => {
|
||||||
error!("{:?}", err.message);
|
error!("{:?}", err.message);
|
||||||
@@ -1,3 +1,6 @@
|
|||||||
pub mod api;
|
|
||||||
pub mod commands;
|
pub mod commands;
|
||||||
|
pub mod guilds;
|
||||||
pub mod handler;
|
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 serde::{Deserialize, Serialize};
|
||||||
use siren::ServiceError;
|
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};
|
use super::{SchoolType, CastingTime, SpellAttackType, SpellDamageType, Range, Area, Components, Duration, Source, Description, DurationType, Effect};
|
||||||
|
|
||||||
@@ -61,7 +63,7 @@ impl Default for QueryFilters {
|
|||||||
|
|
||||||
impl QuerySpell {
|
impl QuerySpell {
|
||||||
pub fn get_all(filters: &QueryFilters, limit: i32, page: i32) -> Result<Vec<Self>, ServiceError> {
|
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();
|
let mut query = spells::table.limit(limit as i64).into_boxed();
|
||||||
// Limit query to page and limit
|
// Limit query to page and limit
|
||||||
let offset = (page - 1) * limit;
|
let offset = (page - 1) * limit;
|
||||||
@@ -108,7 +110,7 @@ impl QuerySpell {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn get_count(filters: &QueryFilters) -> Result<i64, ServiceError> {
|
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();
|
let mut query = spells::table.count().into_boxed();
|
||||||
if let Some(name) = &filters.by_name {
|
if let Some(name) = &filters.by_name {
|
||||||
query = query.filter(spells::name.ilike(format!("%{}%", 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> {
|
pub fn get_by_id(id: i32) -> Result<Self, ServiceError> {
|
||||||
let mut conn = crate::db::connection()?;
|
let mut conn = connection()?;
|
||||||
let spell = spells::table
|
let spell = spells::table
|
||||||
.filter(spells::id.eq(id))
|
.filter(spells::id.eq(id))
|
||||||
.first::<QuerySpell>(&mut conn)?;
|
.first::<QuerySpell>(&mut conn)?;
|
||||||
@@ -157,7 +159,7 @@ impl QuerySpell {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn delete(id: i32) -> Result<Self, ServiceError> {
|
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)?;
|
let spell = diesel::delete(spells::table.filter(spells::id.eq(id))).get_result(&mut conn)?;
|
||||||
Ok(spell)
|
Ok(spell)
|
||||||
}
|
}
|
||||||
@@ -182,13 +184,13 @@ pub struct InsertSpell {
|
|||||||
|
|
||||||
impl InsertSpell {
|
impl InsertSpell {
|
||||||
pub fn insert(spell: Self) -> Result<QuerySpell, ServiceError> {
|
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)?;
|
let spell = diesel::insert_into(spells::table).values(spell).get_result(&mut conn)?;
|
||||||
Ok(spell)
|
Ok(spell)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn update(id: i32, spell: Self) -> Result<QuerySpell, ServiceError> {
|
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)?;
|
let spell = diesel::update(spells::table.filter(spells::id.eq(id))).set(spell).get_result(&mut conn)?;
|
||||||
Ok(spell)
|
Ok(spell)
|
||||||
}
|
}
|
||||||
@@ -1,9 +1,9 @@
|
|||||||
use actix_web::{get, post, put, delete, web, HttpResponse, HttpRequest, ResponseError};
|
use actix_web::{get, post, put, delete, web, HttpResponse, HttpRequest, ResponseError};
|
||||||
use log::error;
|
use log::error;
|
||||||
use serde::{Serialize, Deserialize};
|
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};
|
use super::{Spell, InsertSpell};
|
||||||
|
|
||||||
@@ -90,7 +90,7 @@ async fn get_all(req: HttpRequest) -> HttpResponse {
|
|||||||
spell.id = Some(id);
|
spell.id = Some(id);
|
||||||
response.push(spell);
|
response.push(spell);
|
||||||
}
|
}
|
||||||
HttpResponse::Ok().json(GetResponse {
|
HttpResponse::Ok().json(Response {
|
||||||
data: response,
|
data: response,
|
||||||
metadata: Some(Metadata {
|
metadata: Some(Metadata {
|
||||||
total: total_count as i32,
|
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 id = query_spell.id;
|
||||||
let mut spell = Spell::from(query_spell);
|
let mut spell = Spell::from(query_spell);
|
||||||
spell.id = Some(id);
|
spell.id = Some(id);
|
||||||
HttpResponse::Ok().json(GetResponse {
|
HttpResponse::Ok().json(Response {
|
||||||
data: spell,
|
data: spell,
|
||||||
metadata: None
|
metadata: None
|
||||||
})
|
})
|
||||||
@@ -19,7 +19,7 @@ pub struct Message {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Serialize, Deserialize)]
|
#[derive(Serialize, Deserialize)]
|
||||||
pub struct GetResponse<T> {
|
pub struct Response<T> {
|
||||||
pub data: T,
|
pub data: T,
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
pub metadata: Option<Metadata>
|
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 {
|
impl From<DieselError> for ServiceError {
|
||||||
fn from(error: DieselError) -> ServiceError {
|
fn from(error: DieselError) -> ServiceError {
|
||||||
match error {
|
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 {
|
impl ResponseError for ServiceError {
|
||||||
fn error_response(&self) -> HttpResponse {
|
fn error_response(&self) -> HttpResponse {
|
||||||
let status_code = match StatusCode::from_u16(self.status) {
|
let status_code = match StatusCode::from_u16(self.status) {
|
||||||
|
|||||||
@@ -14,22 +14,23 @@ use songbird::{SerenityInit, Songbird};
|
|||||||
|
|
||||||
use actix_cors::Cors;
|
use actix_cors::Cors;
|
||||||
use actix_web::{HttpServer, App, web};
|
use actix_web::{HttpServer, App, web};
|
||||||
use crate::bot::{commands::oai::GPTModel, handler::Handler};
|
use crate::bot::handler::Handler;
|
||||||
|
|
||||||
use dotenv::dotenv;
|
use dotenv::dotenv;
|
||||||
|
|
||||||
mod auth;
|
mod auth;
|
||||||
mod dnd;
|
mod dnd;
|
||||||
mod bot;
|
mod bot;
|
||||||
mod db;
|
mod storage;
|
||||||
|
mod users;
|
||||||
|
|
||||||
#[actix_web::main]
|
#[actix_web::main]
|
||||||
async fn main() -> std::io::Result<()> {
|
async fn main() -> std::io::Result<()> {
|
||||||
dotenv().ok();
|
dotenv().ok();
|
||||||
env_logger::init_from_env(env_logger::Env::default().filter_or("RUST_LOG", "warn,siren=info"));
|
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") {
|
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)
|
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") {
|
let handler = match env::var("OPENAI_API_KEY") {
|
||||||
Ok(token) => {
|
Ok(token) => {
|
||||||
info!("Loaded OpenAI token");
|
info!("Loaded OpenAI token");
|
||||||
|
let default_model = env::var("OPENAI_API_MODEL").unwrap_or("gpt-3.5-turbo".to_string());
|
||||||
Handler {
|
Handler {
|
||||||
oai: Some(bot::commands::oai::OAI {
|
oai: Some(bot::oai::OAI {
|
||||||
client: reqwest::Client::new(),
|
client: reqwest::Client::new(),
|
||||||
base_url: "https://api.openai.com/v1".to_string(),
|
base_url: "https://api.openai.com/v1".to_string(),
|
||||||
service_url: "http://localhost:5000".to_string(),
|
service_url: "http://localhost:5000".to_string(),
|
||||||
@@ -65,7 +67,7 @@ async fn main() -> std::io::Result<()> {
|
|||||||
token,
|
token,
|
||||||
max_context_questions: 30,
|
max_context_questions: 30,
|
||||||
max_tokens: 2048,
|
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);
|
let shard_manager = Arc::clone(&client.shard_manager);
|
||||||
|
|
||||||
// tokio::spawn(async move {
|
tokio::spawn(async move {
|
||||||
// tokio::signal::ctrl_c().await.expect("Could not register ctrl+c handler");
|
tokio::signal::ctrl_c().await.expect("Could not register ctrl+c handler");
|
||||||
// shard_manager.lock().await.shutdown_all().await;
|
shard_manager.lock().await.shutdown_all().await;
|
||||||
// });
|
});
|
||||||
|
|
||||||
// tokio::spawn(async move {
|
tokio::spawn(async move {
|
||||||
// if let Err(why) = client.start_autosharded().await {
|
if let Err(why) = client.start_autosharded().await {
|
||||||
// error!("An error occurred while running the client: {:?}", why);
|
error!("An error occurred while running the client: {:?}", why);
|
||||||
// }
|
}
|
||||||
// });
|
});
|
||||||
|
|
||||||
let host = env::var("SERVICE_HOST").unwrap_or("localhost".to_string());
|
let host = env::var("SERVICE_HOST").unwrap_or("localhost".to_string());
|
||||||
let port = env::var("SERVICE_PORT").unwrap_or("5000".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()
|
App::new()
|
||||||
.wrap(cors)
|
.wrap(cors)
|
||||||
.app_data(web::Data::new(Arc::clone(&app_data)))
|
.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::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)) {
|
.bind(format!("{}:{}", host, port)) {
|
||||||
Ok(b) => {
|
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,
|
role -> Text,
|
||||||
first_name -> Text,
|
first_name -> Text,
|
||||||
last_name -> Text,
|
last_name -> Text,
|
||||||
|
updated_at -> Timestamp,
|
||||||
|
created_at -> Timestamp,
|
||||||
|
profile_picture -> Nullable<Text>,
|
||||||
verified -> Bool,
|
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"
|
"lint": "next lint"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@mantine/core": "^7.1.2",
|
"@mantine/core": "^7.2.2",
|
||||||
"@mantine/form": "^7.1.2",
|
"@mantine/form": "^7.2.2",
|
||||||
"@mantine/hooks": "^7.1.2",
|
"@mantine/hooks": "^7.2.2",
|
||||||
"@mantine/modals": "^7.1.2",
|
"@mantine/modals": "^7.2.2",
|
||||||
"@mantine/notifications": "^7.1.2",
|
"@mantine/notifications": "^7.2.2",
|
||||||
"axios": "^1.5.1",
|
"@pixi/react": "^7.1.1",
|
||||||
"js-cookie": "^3.0.5",
|
"js-cookie": "^3.0.5",
|
||||||
"next": "^13.5.4",
|
"next": "^14.0.3",
|
||||||
|
"pixi.js": "^7.3.2",
|
||||||
"react": "^18.2.0",
|
"react": "^18.2.0",
|
||||||
"react-dom": "^18.2.0",
|
"react-dom": "^18.2.0",
|
||||||
"react-icons": "^4.11.0",
|
"react-icons": "^4.12.0",
|
||||||
"react-leaflet": "^4.2.1",
|
|
||||||
"recharts": "^2.8.0",
|
|
||||||
"recoil": "^0.7.7"
|
"recoil": "^0.7.7"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/js-cookie": "^3.0.4",
|
"@types/js-cookie": "^3.0.5",
|
||||||
"@types/node": "20.8.2",
|
"@types/node": "20.8.7",
|
||||||
"@types/react": "18.2.24",
|
"@types/react": "18.2.31",
|
||||||
"@types/react-dom": "18.2.8",
|
"@types/react-dom": "18.2.14",
|
||||||
"@typescript-eslint/eslint-plugin": "^6.7.4",
|
"@typescript-eslint/eslint-plugin": "^6.8.0",
|
||||||
"@typescript-eslint/parser": "^6.7.4",
|
"@typescript-eslint/parser": "^6.8.0",
|
||||||
"autoprefixer": "^10.4.16",
|
"autoprefixer": "^10.4.16",
|
||||||
"eslint": "8.50.0",
|
"eslint": "8.52.0",
|
||||||
"eslint-config-next": "13.5.4",
|
"eslint-config-next": "13.5.6",
|
||||||
"eslint-config-prettier": "^9.0.0",
|
"eslint-config-prettier": "^9.0.0",
|
||||||
"eslint-plugin-prettier": "^5.0.0",
|
"eslint-plugin-prettier": "^5.0.0",
|
||||||
"postcss": "^8.4.31",
|
"postcss": "^8.4.31",
|
||||||
"postcss-import": "^15.1.0",
|
"postcss-import": "^15.1.0",
|
||||||
"postcss-preset-mantine": "^1.8.0",
|
"postcss-preset-mantine": "^1.9.0",
|
||||||
"prettier": "^3.0.3",
|
"prettier": "^3.0.3",
|
||||||
"typescript": "5.2.2"
|
"typescript": "5.2.2"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
|
import Cookies from 'js-cookie';
|
||||||
import { getRequest, postRequest } from '.';
|
import { getRequest, postRequest } from '.';
|
||||||
import { RegisterUser, ResponseAuth } from './auth.types';
|
import { RegisterUser, ResponseAuth } from './auth.types';
|
||||||
|
|
||||||
export async function login(email: string, password: string): Promise<ResponseAuth | undefined> {
|
export async function login(email: string, password: string): Promise<ResponseAuth | undefined> {
|
||||||
const response = await postRequest('auth/login', { email, password });
|
const response = await postRequest('auth/login', { email, password });
|
||||||
if (response?.status === 200) {
|
if (response?.status === 200) {
|
||||||
return response.data as ResponseAuth;
|
return response.json();
|
||||||
} else {
|
} else {
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
@@ -24,9 +25,9 @@ export async function logout() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function refresh(refresh_token_rotation?: boolean): Promise<ResponseAuth | undefined> {
|
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) {
|
if (response?.status === 200) {
|
||||||
return response.data as ResponseAuth;
|
return response.json();
|
||||||
} else {
|
} else {
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
@@ -35,8 +36,37 @@ export async function refresh(refresh_token_rotation?: boolean): Promise<Respons
|
|||||||
export async function me(): Promise<ResponseAuth | undefined> {
|
export async function me(): Promise<ResponseAuth | undefined> {
|
||||||
const response = await getRequest('auth/me');
|
const response = await getRequest('auth/me');
|
||||||
if (response?.status === 200) {
|
if (response?.status === 200) {
|
||||||
return response.data;
|
return response.json();
|
||||||
} else {
|
} else {
|
||||||
return undefined;
|
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;
|
role: string;
|
||||||
first_name: string;
|
first_name: string;
|
||||||
last_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';
|
import { GuildChannel, GuildInfo } from './guilds.types';
|
||||||
|
|
||||||
export async function getGuilds(): Promise<GuildInfo[]> {
|
export async function getGuilds(): Promise<GuildInfo[]> {
|
||||||
const response = await getRequest('guilds');
|
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[]> {
|
export async function getTextChannels(guildId: number): Promise<GuildChannel[]> {
|
||||||
const response = await getRequest(`guilds/${guildId}/text`);
|
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> {
|
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[]> {
|
export async function getVoiceChannels(guildId: number): Promise<GuildChannel[]> {
|
||||||
const response = await getRequest(`guilds/${guildId}/voice`);
|
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> {
|
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> {
|
export async function getVolume(guildId: number): Promise<number> {
|
||||||
const response = await getRequest(`guilds/${guildId}/voice/volume`);
|
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 serviceHost = process.env.SERVICE_HOST || 'http://localhost';
|
||||||
const servicePort = process.env.SERVICE_PORT || 5000;
|
const servicePort = process.env.SERVICE_PORT || 5000;
|
||||||
|
const baseURL = `${serviceHost}:${servicePort}`;
|
||||||
|
|
||||||
function createAxiosClient(): AxiosInstance {
|
export async function getRequest(endpoint: string, params: Record<string, any> = {}): Promise<Response> {
|
||||||
const axiosClient = axios.create({
|
// Remove undefined params
|
||||||
baseURL: `${serviceHost}:${servicePort}`
|
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'
|
||||||
});
|
});
|
||||||
|
return response;
|
||||||
axiosClient.interceptors.request.use(
|
|
||||||
(request) => {
|
|
||||||
request.withCredentials = true;
|
|
||||||
return request;
|
|
||||||
},
|
|
||||||
(error) => {
|
|
||||||
console.error(error);
|
|
||||||
return Promise.reject(error);
|
|
||||||
}
|
|
||||||
);
|
|
||||||
return axiosClient;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const axiosClient = createAxiosClient();
|
interface PostOptions {
|
||||||
|
headers?: Record<string, any>;
|
||||||
export async function getRequest(
|
type?: 'json' | 'form';
|
||||||
url: string,
|
|
||||||
config?: AxiosRequestConfig<any>
|
|
||||||
): Promise<AxiosResponse<any, any> | undefined> {
|
|
||||||
const response = await axiosClient.get(`/${url}`, config);
|
|
||||||
return response || undefined;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function postRequest(
|
export async function postRequest(endpoint: string, body?: any, options?: PostOptions): Promise<Response> {
|
||||||
url: string,
|
const url = `${baseURL}/${endpoint}`;
|
||||||
data?: any,
|
let response;
|
||||||
config?: AxiosRequestConfig<any>
|
if (body && (!options?.type || options.type === 'json')) {
|
||||||
): Promise<AxiosResponse<any, any> | undefined> {
|
response = await fetch(url, {
|
||||||
const response = await axiosClient.post(`/${url}`, data, config);
|
method: 'POST',
|
||||||
return response || undefined;
|
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 {
|
export interface Metadata {
|
||||||
|
|||||||
@@ -20,22 +20,20 @@ interface GetSpellsParams {
|
|||||||
|
|
||||||
export async function getSpells(params?: GetSpellsParams): Promise<GetSpellsResponse> {
|
export async function getSpells(params?: GetSpellsParams): Promise<GetSpellsResponse> {
|
||||||
const response = await getRequest('dnd/spells', {
|
const response = await getRequest('dnd/spells', {
|
||||||
params: {
|
name: params?.name,
|
||||||
name: params?.name,
|
like_name: params?.like_name,
|
||||||
like_name: params?.like_name,
|
schools: params?.schools?.join(','),
|
||||||
schools: params?.schools?.join(','),
|
levels: params?.levels?.join(','),
|
||||||
levels: params?.levels?.join(','),
|
ritual: params?.ritual,
|
||||||
ritual: params?.ritual,
|
concentration: params?.concentration,
|
||||||
concentration: params?.concentration,
|
classes: params?.classes?.join(','),
|
||||||
classes: params?.classes?.join(','),
|
damage_inflict: params?.damage_inflict?.join(','),
|
||||||
damage_inflict: params?.damage_inflict?.join(','),
|
damage_resist: params?.damage_resist?.join(','),
|
||||||
damage_resist: params?.damage_resist?.join(','),
|
conditions: params?.conditions?.join(','),
|
||||||
conditions: params?.conditions?.join(','),
|
saving_throw: params?.saving_throw?.join(','),
|
||||||
saving_throw: params?.saving_throw?.join(','),
|
attack_type: params?.attack_type?.join(','),
|
||||||
attack_type: params?.attack_type?.join(','),
|
limit: params?.limit,
|
||||||
limit: params?.limit,
|
page: params?.page
|
||||||
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 React from 'react';
|
||||||
import RecoilRootWrapper from '@app/recoil-root-wrapper';
|
import RecoilRootWrapper from '@app/recoil-root-wrapper';
|
||||||
import Topbar from '@/components/Topbar';
|
import Header from '@/components/Header';
|
||||||
import { Inter } from 'next/font/google';
|
import { Inter } from 'next/font/google';
|
||||||
import { Box, MantineProvider } from '@mantine/core';
|
import { Box, MantineProvider } from '@mantine/core';
|
||||||
import { ModalsProvider } from '@mantine/modals';
|
import { ModalsProvider } from '@mantine/modals';
|
||||||
@@ -8,6 +8,7 @@ import { Notifications } from '@mantine/notifications';
|
|||||||
import 'styles/globals.css';
|
import 'styles/globals.css';
|
||||||
import '@mantine/core/styles.css';
|
import '@mantine/core/styles.css';
|
||||||
import '@mantine/notifications/styles.css';
|
import '@mantine/notifications/styles.css';
|
||||||
|
import Loading from '@/components/Loading';
|
||||||
|
|
||||||
export const metadata = {
|
export const metadata = {
|
||||||
title: 'Siren',
|
title: 'Siren',
|
||||||
@@ -22,15 +23,15 @@ export default function RootLayout({ children }: { children: React.ReactNode })
|
|||||||
<head>
|
<head>
|
||||||
<title>Siren</title>
|
<title>Siren</title>
|
||||||
</head>
|
</head>
|
||||||
<body className={`${inter.className} wrapper h-full`}>
|
<body className={`${inter.className} wrapper`}>
|
||||||
<RecoilRootWrapper>
|
<RecoilRootWrapper>
|
||||||
<MantineProvider>
|
<MantineProvider>
|
||||||
<Notifications />
|
<Notifications />
|
||||||
<ModalsProvider>
|
<ModalsProvider>
|
||||||
<Topbar />
|
<Loading>
|
||||||
<Box p='xl' pt='sm' className='h-full'>
|
<Header />
|
||||||
{children}
|
<Box>{children}</Box>
|
||||||
</Box>
|
</Loading>
|
||||||
</ModalsProvider>
|
</ModalsProvider>
|
||||||
</MantineProvider>
|
</MantineProvider>
|
||||||
</RecoilRootWrapper>
|
</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';
|
import React from 'react';
|
||||||
|
|
||||||
// Home page for siren
|
// Home page for siren
|
||||||
export default function Page() {
|
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 />
|
<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)} />}
|
{activeSpell && <SpellModal spell={activeSpell} isOpen={isOpen} onClose={() => setIsOpen(false)} />}
|
||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
@@ -90,9 +161,9 @@ function SpellSection({ title, spells, onClick }: { title: string; spells: Spell
|
|||||||
<Box>
|
<Box>
|
||||||
<h2>{title}</h2>
|
<h2>{title}</h2>
|
||||||
<ul>
|
<ul>
|
||||||
{spells.map((spell) => (
|
{spells.map((spell, index) => (
|
||||||
<li
|
<li
|
||||||
key={spell.id}
|
key={`spell-${index}`}
|
||||||
className='link spell-item'
|
className='link spell-item'
|
||||||
style={{ width: 'fit-content' }}
|
style={{ width: 'fit-content' }}
|
||||||
onClick={() => onClick(spell)}
|
onClick={() => onClick(spell)}
|
||||||
|
|||||||
@@ -1,210 +1,31 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import Link from 'next/link';
|
import { login, register } from '@/api/auth';
|
||||||
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 { User } from '@/api/auth.types';
|
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';
|
import { notifications } from '@mantine/notifications';
|
||||||
|
|
||||||
interface HeaderItem {
|
interface HeaderModalProps {
|
||||||
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 {
|
|
||||||
type?: string;
|
type?: string;
|
||||||
toggle: any;
|
toggle: any;
|
||||||
setUser: (user: User) => void;
|
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) {
|
function passwordValidator(value: string) {
|
||||||
if (value.trim().length < 10) {
|
if (value.trim().length < 10) {
|
||||||
return 'Password must be at least 10 characters';
|
return 'Password must be at least 10 characters';
|
||||||
@@ -3,6 +3,8 @@
|
|||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
color: black;
|
color: black;
|
||||||
border-bottom: 1px solid #e6e6e6;
|
border-bottom: 1px solid #e6e6e6;
|
||||||
|
max-height: 70px;
|
||||||
|
user-select: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.navbar .left {
|
.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>
|
||||||
<Grid.Col span={6}>
|
<Grid.Col span={6}>
|
||||||
<span style={{ fontWeight: 'bold', paddingRight: '1em' }}>Sources:</span>
|
<span style={{ fontWeight: 'bold', paddingRight: '1em' }}>Sources:</span>
|
||||||
{spell.sources.map((s) => (
|
{spell.sources.map((s, index) => (
|
||||||
<span style={{ paddingRight: '0.6em' }}>
|
<span style={{ paddingRight: '0.6em' }} key={`spell-source-${index}`}>
|
||||||
{s.source}
|
{s.source}
|
||||||
{s.page ? `.${s.page}` : ''}
|
{s.page ? `.${s.page}` : ''}
|
||||||
</span>
|
</span>
|
||||||
@@ -48,8 +48,12 @@ export default function SpellModal({ spell, isOpen, onClose }: SpellModalProps)
|
|||||||
<Grid.Col span={6}>
|
<Grid.Col span={6}>
|
||||||
<span style={{ fontWeight: 'bold', marginRight: '1em' }}>Classes:</span>
|
<span style={{ fontWeight: 'bold', marginRight: '1em' }}>Classes:</span>
|
||||||
<span style={{ overflowWrap: 'break-word' }}>
|
<span style={{ overflowWrap: 'break-word' }}>
|
||||||
{spell.classes.map((c) => (
|
{spell.classes.map((c, index) => (
|
||||||
<span style={{ paddingRight: '0.6em', display: 'inline-block' }} className='link'>
|
<span
|
||||||
|
style={{ paddingRight: '0.6em', display: 'inline-block' }}
|
||||||
|
className='link'
|
||||||
|
key={`spell-class-${index}`}
|
||||||
|
>
|
||||||
{parseText(c, true)}
|
{parseText(c, true)}
|
||||||
</span>
|
</span>
|
||||||
))}
|
))}
|
||||||
@@ -71,8 +75,8 @@ export default function SpellModal({ spell, isOpen, onClose }: SpellModalProps)
|
|||||||
<Grid.Col span={6}>
|
<Grid.Col span={6}>
|
||||||
<span style={{ fontWeight: 'bold', paddingRight: '1em' }}>Duration:</span>
|
<span style={{ fontWeight: 'bold', paddingRight: '1em' }}>Duration:</span>
|
||||||
<span style={{ paddingRight: '0.6em' }}>
|
<span style={{ paddingRight: '0.6em' }}>
|
||||||
{spell.durations.map((d) => (
|
{spell.durations.map((d, index) => (
|
||||||
<span>
|
<span key={`duration-${index}`}>
|
||||||
{capitalize(d.type)} {d.value} {capitalize(d.unit)}
|
{capitalize(d.type)} {d.value} {capitalize(d.unit)}
|
||||||
</span>
|
</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 regex = /{@(.*?) (.*?)}/g;
|
||||||
const matches = text.matchAll(regex);
|
const matches = text.matchAll(regex);
|
||||||
const result = [];
|
const result = [];
|
||||||
let lastIndex = 0;
|
let lastIndex = 0;
|
||||||
|
let noMatches = true;
|
||||||
|
|
||||||
for (const match of matches) {
|
for (const match of matches) {
|
||||||
|
noMatches = false;
|
||||||
|
const key = crypto.randomUUID();
|
||||||
const [full, type, name] = match;
|
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 (match.index !== undefined) {
|
||||||
if (type == 'dice') {
|
if (type == 'dice') {
|
||||||
result.push(
|
result.push(
|
||||||
<span onClick={() => handleLink(type, name)} className='link'>
|
<span onClick={() => handleLink(type, name)} className='link' key={key}>
|
||||||
{name}
|
{name}
|
||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
} else if (type == 'scaledice') {
|
} 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('|');
|
const [dice, levels] = name.split('|');
|
||||||
result.push(
|
result.push(
|
||||||
<span onClick={() => handleLink('dice', dice)} className='link'>
|
<span onClick={() => handleLink('dice', dice)} className='link' key={key}>
|
||||||
{dice}
|
{dice}
|
||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
} else if (type == 'bold') {
|
} 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') {
|
} else if (type == 'subclass') {
|
||||||
const [className, subclassName] = name.split('|');
|
const [className, subclassName] = name.split('|');
|
||||||
result.push(
|
result.push(
|
||||||
<span>
|
<span key={key}>
|
||||||
{capitalize(className)} ({capitalize(subclassName)})
|
{capitalize(className)} ({capitalize(subclassName)})
|
||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
result.push(<span>{capitalizeFirst ? capitalize(name) : name}</span>);
|
result.push(<span key={key}>{capitalizeFirst ? capitalize(name) : name}</span>);
|
||||||
}
|
}
|
||||||
lastIndex = match.index + full.length;
|
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;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -148,18 +162,17 @@ function handleLink(type: string, name: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function SpellDescription({ spell }: { spell: Spell }) {
|
function SpellDescription({ spell }: { spell: Spell }) {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{spell.description && (
|
{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.text && <p>{parseText(e.text)}</p>}
|
||||||
{e.list && (
|
{e.list && (
|
||||||
<ul>
|
<ul>
|
||||||
{e.list.map((text) => (
|
{e.list.map((text, index) => (
|
||||||
<li>{parseText(text)}</li>
|
<li key={`spell-text-${index}`}>{parseText(text)}</li>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
)}
|
)}
|
||||||
@@ -167,23 +180,23 @@ function SpellDescription({ spell }: { spell: Spell }) {
|
|||||||
<table>
|
<table>
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
{e.table.headers.map((label) => (
|
{e.table.headers.map((label, index) => (
|
||||||
<th>{label}</th>
|
<th key={`spell-header-${index}`}>{label}</th>
|
||||||
))}
|
))}
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{e.table.rows.map((row) => (
|
{e.table.rows.map((row, index) => (
|
||||||
<tr>
|
<tr key={`spell-row-${index}`}>
|
||||||
{row.map((cell) => (
|
{row.map((cell, index) => (
|
||||||
<td>{parseText(cell)}</td>
|
<td key={`spell-cell-${index}`}>{parseText(cell)}</td>
|
||||||
))}
|
))}
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
))}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</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": {
|
"compilerOptions": {
|
||||||
"target": "ESNext",
|
"target": "ES2022",
|
||||||
"downlevelIteration": true,
|
"downlevelIteration": true,
|
||||||
"lib": [
|
"lib": [
|
||||||
"dom",
|
"dom",
|
||||||
"dom.iterable",
|
"dom.iterable",
|
||||||
"esnext"
|
"ES2022"
|
||||||
],
|
],
|
||||||
"allowJs": true,
|
"allowJs": true,
|
||||||
"skipLibCheck": true,
|
"skipLibCheck": true,
|
||||||
@@ -14,7 +14,7 @@
|
|||||||
"noEmit": true,
|
"noEmit": true,
|
||||||
"esModuleInterop": true,
|
"esModuleInterop": true,
|
||||||
"module": "esnext",
|
"module": "esnext",
|
||||||
"moduleResolution": "node",
|
"moduleResolution": "Node",
|
||||||
"resolveJsonModule": true,
|
"resolveJsonModule": true,
|
||||||
"isolatedModules": true,
|
"isolatedModules": true,
|
||||||
"jsx": "preserve",
|
"jsx": "preserve",
|
||||||
@@ -26,11 +26,24 @@
|
|||||||
],
|
],
|
||||||
"baseUrl": ".",
|
"baseUrl": ".",
|
||||||
"paths": {
|
"paths": {
|
||||||
"@/*": ["./src/*"],
|
"@/*": [
|
||||||
"@api/*": ["src/api"],
|
"./src/*"
|
||||||
"@app/*": ["./src/app/*"],
|
],
|
||||||
"@components/*": ["src/components/*"],
|
"@api/*": [
|
||||||
"@lib/*": ["src/components/*"]
|
"src/api"
|
||||||
|
],
|
||||||
|
"@app/*": [
|
||||||
|
"./src/app/*"
|
||||||
|
],
|
||||||
|
"@components/*": [
|
||||||
|
"src/components/*"
|
||||||
|
],
|
||||||
|
"@js/*": [
|
||||||
|
"src/js/*"
|
||||||
|
],
|
||||||
|
"@state/*": [
|
||||||
|
"src/state/*"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"include": [
|
"include": [
|
||||||
|
|||||||
Reference in New Issue
Block a user