Merge pull request #13 from bensherriff/develop

Develop
This commit is contained in:
Ben Sherriff
2024-01-29 13:52:10 -05:00
committed by GitHub
82 changed files with 3032 additions and 1667 deletions

1
.gitignore vendored
View File

@@ -2,6 +2,7 @@
target/
.idea/
**/Cargo.lock
.DS_Store
.next/
node_modules/

View File

@@ -30,11 +30,14 @@ The CLIENT_ID can be found in the General Information tab on the Discord Develop
The following packages must be installed for [serenity-rs/songbird](https://github.com/serenity-rs/songbird). View the repository for additional installation and setup information on other operating systems.
<details>
<summary>Unix Installation</summary>
Notes:
- [yt-dlp](https://github.com/yt-dlp/yt-dlp/releases) is preferred over youtube-dl.
```
sudo apt install libopus-dev
sudo apt install ffmpeg
sudo apt apt install youtube-dl
sudo apt apt install youtube-dl # See notes above
# PostgreSQL Headers
sudo apt install libpq5
sudo apt install libpq-dev
@@ -45,11 +48,15 @@ The following packages must be installed for [serenity-rs/songbird](https://gith
</details>
<details>
<summary>Mac Installation</summary>
Notes:
- [Homebrew](https://brew.sh/) must be installed to run the following commands.
- [youtube-dl](https://formulae.brew.sh/formula/youtube-dl#default) is deprecated, [yt-dlp](https://formulae.brew.sh/formula/yt-dlp) is preferred
```
brew install opus
brew install ffmpeg
brew install youtube-dl
brew install yt-dlp # See notes above
brew install postgresql
```
</details>

View File

@@ -29,6 +29,9 @@ jsonwebtoken = "9.0.0"
redis = { version = "0.23.3", features = ["tokio-comp", "connection-manager", "r2d2"] }
base64 = "0.21.4"
rust-s3 = "0.33.0"
actix-multipart = "0.6.1"
openssl = "0.10.60" # Resolve `openssl` `X509StoreRef::objects` is unsound #10
rand = "0.8.5"
[dependencies.tokio]
version = "1.32.0"
@@ -46,7 +49,7 @@ features = ["json", "rustls-tls"]
[dependencies.diesel]
version = "2.1.2"
default-features = false
features = ["postgres", "32-column-tables", "serde_json", "r2d2", "with-deprecated"]
features = ["postgres", "chrono", "32-column-tables", "serde_json", "r2d2", "with-deprecated"]
[dependencies.serenity]
version = "0.11.6"

View File

@@ -1,6 +1,6 @@
version: '3.8'
x-env_file_personifi: &env
x-env_file: &env
- .env
name: siren
@@ -30,6 +30,8 @@ services:
- ${SERVICE_PORT:-5000}:5000
depends_on:
- db
- redis
- minio
networks:
- frontend
- backend
@@ -53,6 +55,8 @@ services:
redis:
image: redis:latest
container_name: siren-redis
volumes:
- redis:/data
ports:
- ${REDIS_PORT:-6379}:6379
networks:
@@ -77,6 +81,7 @@ services:
volumes:
db:
db_logs:
redis:
minio:
networks:

View File

@@ -4,5 +4,8 @@ CREATE TABLE IF NOT EXISTS users (
role TEXT NOT NULL,
first_name TEXT NOT NULL,
last_name TEXT NOT NULL,
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
updated_at TIMESTAMP NOT NULL DEFAULT NOW(),
profile_picture TEXT,
verified BOOLEAN NOT NULL DEFAULT FALSE
);

View File

@@ -15,7 +15,8 @@ use siren::ServiceError;
#[derive(Debug, Serialize, Deserialize)]
struct TokenClaims {
sub: String, // Subject
token_uuid: String, // Issuer
token_uuid: String, // Token UUID
iss: String, // Issuer
exp: i64, // Expiration time
iat: i64, // Issued At
nbf: i64 // Not Before
@@ -73,6 +74,7 @@ pub fn generate_token(email: &str, ttl: i64, private_key: &str) -> Result<TokenD
};
let claims = TokenClaims {
sub: token_details.email.clone(),
iss: "siren".to_string(),
token_uuid: token_details.token_uuid.to_string(),
exp: token_details.expires_in.unwrap(),
iat: now.timestamp(),

View File

@@ -6,7 +6,7 @@ use redis::Commands;
use serde::{Serialize, Deserialize};
use siren::ServiceError;
use crate::db::schema::users;
use crate::storage::{schema::users, connection};
use super::{hash_password, verify_token};
@@ -27,6 +27,9 @@ impl RegisterUser {
role: "user".to_string(),
first_name: self.first_name,
last_name: self.last_name,
updated_at: chrono::Utc::now().naive_utc(),
created_at: chrono::Utc::now().naive_utc(),
profile_picture: None,
verified: false,
})
}
@@ -46,12 +49,15 @@ pub struct QueryUser {
pub role: String,
pub first_name: String,
pub last_name: String,
pub updated_at: chrono::NaiveDateTime,
pub created_at: chrono::NaiveDateTime,
pub profile_picture: Option<String>,
pub verified: bool,
}
impl QueryUser {
pub fn get_by_email(email: &str) -> Result<QueryUser, ServiceError> {
let mut conn = crate::db::connection()?;
let mut conn = connection()?;
// Check if the user exists by email, case insensitive
let user = users::table
@@ -69,17 +75,29 @@ pub struct InsertUser {
pub role: String,
pub first_name: String,
pub last_name: String,
pub updated_at: chrono::NaiveDateTime,
pub created_at: chrono::NaiveDateTime,
pub profile_picture: Option<String>,
pub verified: bool,
}
impl InsertUser {
pub fn insert(user: Self) -> Result<QueryUser, ServiceError> {
let mut conn = crate::db::connection()?;
let mut conn = connection()?;
let user = diesel::insert_into(users::table)
.values(user)
.get_result(&mut conn)?;
Ok(user)
}
pub fn update_profile(email: &str, profile_picture: Option<&str>) -> Result<QueryUser, ServiceError> {
let mut conn = connection()?;
let user = diesel::update(users::table)
.filter(users::email.eq(&email))
.set(users::profile_picture.eq(profile_picture))
.get_result(&mut conn)?;
Ok(user)
}
}
#[derive(Debug, Serialize, Deserialize)]
@@ -88,6 +106,7 @@ pub struct ResponseUser {
pub role: String,
pub first_name: String,
pub last_name: String,
pub profile_picture: Option<String>,
}
impl From<QueryUser> for ResponseUser {
@@ -97,6 +116,7 @@ impl From<QueryUser> for ResponseUser {
role: user.role,
first_name: user.first_name,
last_name: user.last_name,
profile_picture: user.profile_picture,
}
}
}
@@ -141,7 +161,7 @@ impl FromRequest for JwtAuth {
let access_token_uuid = uuid::Uuid::parse_str(&access_token_details.token_uuid.to_string()).unwrap();
let mut conn = match crate::db::redis_connection() {
let mut conn = match crate::storage::redis_connection() {
Ok(conn) => conn,
Err(err) => {
error!("Failed to get redis connection: {}", err);

View File

@@ -6,7 +6,7 @@ use redis::AsyncCommands;
use serde::{Serialize, Deserialize};
use siren::ServiceError;
use crate::{auth::{LoginRequest, RegisterUser, InsertUser, QueryUser, verify_password, JwtAuth, verify_token, generate_access_token, generate_refresh_token}, db};
use crate::{auth::{LoginRequest, RegisterUser, InsertUser, QueryUser, verify_password, JwtAuth, verify_token, generate_access_token, generate_refresh_token}, storage};
#[post("/register")]
async fn register(user: web::Json<RegisterUser>) -> HttpResponse {
@@ -58,7 +58,7 @@ async fn login(request: web::Json<LoginRequest>) -> HttpResponse {
}
};
let mut conn = match db::redis_async_connection().await {
let mut conn = match storage::redis_async_connection().await {
Ok(conn) => conn,
Err(err) => {
error!("Failed to get redis connection: {}", err);
@@ -169,7 +169,7 @@ async fn refresh(req: HttpRequest) -> HttpResponse {
}
};
let mut conn = match db::redis_async_connection().await {
let mut conn = match storage::redis_async_connection().await {
Ok(conn) => conn,
Err(err) => {
error!("Failed to get redis connection: {}", err);
@@ -292,7 +292,7 @@ async fn logout(req: HttpRequest, auth: JwtAuth) -> HttpResponse {
Err(err) => return ResponseError::error_response(&err)
};
let mut conn = match db::redis_async_connection().await {
let mut conn = match storage::redis_async_connection().await {
Ok(conn) => conn,
Err(err) => {
error!("Failed to get redis connection: {}", err);
@@ -340,6 +340,40 @@ async fn me(auth: JwtAuth) -> HttpResponse {
HttpResponse::Ok().json(auth)
}
#[get("/check-session")]
async fn check_session(req: HttpRequest) -> HttpResponse {
// If there is a access_token cookie, check if it is valid
let has_session = match req.cookie("access_token") {
Some(cookie) => {
let access_token = cookie.value().to_string();
let public_key = env::var("ACCESS_TOKEN_PUBLIC_KEY")
.expect("ACCESS_TOKEN_PUBLIC_KEY must be set");
match verify_token(&access_token, &public_key) {
Ok(_) => true,
Err(_) => false
}
},
None => false
};
if !has_session {
// If there is a refresh_token cookie, check if it is valid
match req.cookie("refresh_token") {
Some(cookie) => {
let refresh_token = cookie.value().to_string();
let public_key = env::var("REFRESH_TOKEN_PUBLIC_KEY")
.expect("REFRESH_TOKEN_PUBLIC_KEY must be set");
match verify_token(&refresh_token, &public_key) {
Ok(_) => return HttpResponse::Ok().json(true),
Err(_) => return HttpResponse::Ok().json(false)
};
},
None => return HttpResponse::Ok().json(false)
};
} else {
return HttpResponse::Ok().json(true)
}
}
#[get("/roles")]
async fn roles() -> HttpResponse {
HttpResponse::Ok().json(vec!["admin", "user"])
@@ -363,5 +397,6 @@ pub fn init_routes(config: &mut web::ServiceConfig) {
.service(logout)
.service(me)
.service(roles)
.service(check_session)
);
}

View File

@@ -2,6 +2,7 @@ use std::sync::Arc;
use log::{debug, warn};
use reqwest::Url;
use serenity::client::Cache;
use serenity::model::application::interaction::{InteractionResponseType, application_command::ApplicationCommandInteraction};
use serenity::model::prelude::{GuildId, ChannelId};
@@ -11,6 +12,8 @@ use siren::ServiceError;
use songbird::{Call, Songbird};
use songbird::input::{Restartable, Input, Metadata, error::Error as SongbirdError};
use crate::bot::ytdlp::{PlaylistItem, YtDlp};
pub mod pause;
pub mod play;
pub mod resume;
@@ -88,11 +91,7 @@ pub async fn edit_response(ctx: &Context, command: &ApplicationCommandInteractio
}
pub async fn add_song(call: Arc<Mutex<Call>>, url: &str, lazy: bool, volume: Option<f32>) -> Result<Metadata, SongbirdError> {
let source = if is_valid_url(url) {
Restartable::ytdl(url.to_owned(), lazy).await?
} else {
Restartable::ytdl_search(url, lazy).await?
};
let source = Restartable::ytdl(url.to_owned(), lazy).await?;
let mut handler = call.lock().await;
let track: Input = source.into();
let metadata = *track.metadata.clone();
@@ -103,11 +102,41 @@ pub async fn add_song(call: Arc<Mutex<Call>>, url: &str, lazy: bool, volume: Opt
Ok(metadata)
}
fn is_valid_url(url: &str) -> bool {
match url.parse::<reqwest::Url>() {
Ok(_) => return true,
Err(_) => return false
pub fn get_playlist_urls(url: &str) -> Result<Vec<PlaylistItem>, ServiceError> {
let output = YtDlp::new()
.arg("--flat-playlist")
.arg("--dump-json")
.arg(url)
.execute()?;
let items: Vec<PlaylistItem> = String::from_utf8(output.stdout)?
.split('\n')
.filter_map(|line| {
if line.is_empty() {
None
} else {
Some(
serde_json::from_slice::<PlaylistItem>(line.as_bytes()).map_err(
|err| ServiceError { status: 500, message: err.to_string() }
)
)
}
})
.filter_map(|parsed| match parsed {
Ok(item) => Some(item),
Err(err) => {
warn!("Failed to parse playlist item: {}", err);
None
}
})
.collect();
Ok(items)
}
fn is_valid_url(url: &str) -> (bool, bool) {
Url::parse(url).ok().map_or((false, false), |valid_url| {
let is_playlist: bool = valid_url.query_pairs().find(|(key, _)| key == "list").map_or(false, |_| true);
(true, is_playlist)
})
}
pub async fn get_songbird(ctx: &Context) -> Arc<Songbird> {

View File

@@ -9,10 +9,10 @@ use serenity::model::application::interaction::application_command::ApplicationC
use siren::ServiceError;
use songbird::{EventHandler, Songbird};
use crate::bot::commands::audio::{leave, add_song, get_songbird};
use crate::db::guilds::QueryGuild;
use crate::bot::ytdlp::PlaylistItem;
use crate::bot::{guilds::QueryGuild, commands::audio::{leave, get_playlist_urls, add_song, get_songbird}};
use super::{create_response, edit_response, join_by_user};
use super::{create_response, edit_response, is_valid_url, join_by_user};
pub async fn run(ctx: &Context, command: &ApplicationCommandInteraction) {
// Get the track url
@@ -66,8 +66,14 @@ pub async fn run(ctx: &Context, command: &ApplicationCommandInteraction) {
debug!("Play command executed with track: {:?}", track_url);
let manager = get_songbird(ctx).await;
match play_track(manager, guild_id, track_url).await {
Ok(_) => {
if let Err(why) = edit_response(&ctx, &command, "Playing track".to_string()).await {
Ok(count) => {
let mut message = format!("Playing {} tracks", count);
if count == 0 {
message = "No tracks were played".to_string();
} else if count == 1 {
message = "Playing 1 track".to_string();
}
if let Err(why) = edit_response(&ctx, &command, message).await {
error!("Failed to edit response message: {}", why);
}
},
@@ -88,20 +94,47 @@ pub async fn run(ctx: &Context, command: &ApplicationCommandInteraction) {
}
}
pub async fn play_track(manager: Arc<Songbird>, guild_id: GuildId, track_url: String) -> Result<(), ServiceError> {
pub async fn play_track(manager: Arc<Songbird>, guild_id: GuildId, track_url: String) -> Result<i32, ServiceError> {
let mut track_count = 0;
if let Some(handler_lock) = manager.get(guild_id) {
let is_queue_empty = {
let call_handler = handler_lock.lock().await;
call_handler.queue().is_empty()
};
let guild = QueryGuild::get(guild_id.0 as i64)?;
match add_song(handler_lock.clone(), &track_url, is_queue_empty, Some(guild.volume as f32)).await {
let valid = is_valid_url(&track_url);
if !valid.0 {
warn!("Invalid track url: {}", track_url);
return Err(ServiceError { status: 422, message: format!("Invalid track url: {}", track_url) })
}
let mut playlist_items: Vec<PlaylistItem> = Vec::new();
if valid.1 {
playlist_items = match get_playlist_urls(&track_url) {
Ok(items) => items,
Err(err) => {
warn!("Failed to get playlist urls: {}", err);
return Err(ServiceError { status: 422, message: err.to_string() })
}
};
} else {
let playlist_item = PlaylistItem {
id: "".to_string(),
url: track_url,
title: "".to_string(),
duration: 0,
playlist_index: 0
};
playlist_items.push(playlist_item);
}
for item in playlist_items {
match add_song(handler_lock.clone(), &item.url, is_queue_empty, Some(guild.volume as f32 / 100.0)).await {
Ok(added_song) => {
let track_title = added_song.title.unwrap();
debug!("Added track: {}", track_title);
let mut handler = handler_lock.lock().await;
handler.remove_all_global_events();
handler.add_global_event(songbird::Event::Track(songbird::TrackEvent::End), TrackEndNotifier { guild_id, call: manager })
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);
@@ -112,7 +145,8 @@ pub async fn play_track(manager: Arc<Songbird>, guild_id: GuildId, track_url: St
}
}
}
Ok(())
}
Ok(track_count)
}
pub fn register(command: &mut CreateApplicationCommand) -> &mut CreateApplicationCommand {

View File

@@ -7,7 +7,7 @@ use serenity::builder::CreateApplicationCommand;
use serenity::model::application::interaction::application_command::ApplicationCommandInteraction;
use songbird::Songbird;
use crate::db::guilds::InsertGuild;
use crate::bot::guilds::InsertGuild;
use super::{get_songbird, create_response, edit_response};

View 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;
}

View File

@@ -1,6 +1,6 @@
pub mod audio;
pub mod help;
pub mod message;
pub mod oai;
pub mod chat;
pub mod ping;
pub mod schedule;
pub mod roll;

View File

@@ -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],
}
}

View 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)
})
}

View File

@@ -2,7 +2,7 @@ use diesel::prelude::*;
use serde::{Serialize, Deserialize};
use siren::ServiceError;
use crate::db::{schema::guilds, connection};
use crate::storage::{schema::guilds, connection};
#[derive(Queryable, QueryableByName, Serialize, Deserialize)]
#[diesel(table_name = guilds)]

View File

@@ -1,18 +1,16 @@
use std::{sync::Arc, pin::Pin};
use actix_web::{get, post, web, HttpResponse, ResponseError};
use log::warn;
use serde::{Serialize, Deserialize};
use serenity::model::prelude::{GuildChannel, ChannelType};
use siren::ServiceError;
use siren::{ServiceError, Response};
use crate::{AppState, bot::commands::audio::{play::play_track, join}, db::guilds::QueryGuild, auth::{JwtAuth, verify_role}};
use crate::{AppState, bot::commands::audio::{play::play_track, join}, bot::guilds::QueryGuild, auth::{JwtAuth, verify_role}};
#[get("/guilds")]
async fn get_guilds(data: web::Data<Arc<AppState>>, auth: JwtAuth) -> HttpResponse {
let _ = match verify_role(&auth, "admin") {
Ok(_) => {},
Err(err) => return ResponseError::error_response(&err)
if let Err(err) = verify_role(&auth, "admin") {
return ResponseError::error_response(&err)
};
let guild_results = &data.http.get_guilds(None, None).await;
let guilds = match guild_results {
@@ -22,14 +20,16 @@ async fn get_guilds(data: web::Data<Arc<AppState>>, auth: JwtAuth) -> HttpRespon
message: err.to_string()
})
};
HttpResponse::Ok().json(guilds)
HttpResponse::Ok().json(Response {
data: guilds,
metadata: None
})
}
#[get("/{id}/text")]
async fn get_text_channels(id: web::Path<String>, data: web::Data<Arc<AppState>>, auth: JwtAuth) -> HttpResponse {
let _ = match verify_role(&auth, "admin") {
Ok(_) => {},
Err(err) => return ResponseError::error_response(&err)
if let Err(err) = verify_role(&auth, "admin") {
return ResponseError::error_response(&err)
};
let channel_results = &data.http.get_channels(id.parse::<u64>().unwrap()).await;
let channels = match channel_results {
@@ -39,14 +39,16 @@ async fn get_text_channels(id: web::Path<String>, data: web::Data<Arc<AppState>>
message: err.to_string()
})
};
HttpResponse::Ok().json(channels)
HttpResponse::Ok().json(Response {
data: channels,
metadata: None
})
}
#[get("/{id}/voice")]
async fn get_voice_channels(id: web::Path<String>, data: web::Data<Arc<AppState>>, auth: JwtAuth) -> HttpResponse {
let _ = match verify_role(&auth, "admin") {
Ok(_) => {},
Err(err) => return ResponseError::error_response(&err)
if let Err(err) = verify_role(&auth, "admin") {
return ResponseError::error_response(&err)
};
let channel_results = &data.http.get_channels(id.parse::<u64>().unwrap()).await;
let channels = match channel_results {
@@ -56,7 +58,10 @@ async fn get_voice_channels(id: web::Path<String>, data: web::Data<Arc<AppState>
message: err.to_string()
})
};
HttpResponse::Ok().json(channels)
HttpResponse::Ok().json(Response {
data: channels,
metadata: None
})
}
#[derive(Serialize, Deserialize)]
@@ -66,15 +71,13 @@ struct ChannelMessage {
#[post("/{guild_id}/text/{channel_id}/message")]
async fn send_message(path: web::Path<(String, String)>, text: web::Json<ChannelMessage>, data: web::Data<Arc<AppState>>, auth: JwtAuth) -> HttpResponse {
let _ = match verify_role(&auth, "admin") {
Ok(_) => {},
Err(err) => return ResponseError::error_response(&err)
if let Err(err) = verify_role(&auth, "admin") {
return ResponseError::error_response(&err)
};
let (guild_id, channel_id) = path.into_inner();
let guild_id = match guild_id.parse::<u64>() {
Ok(id) => id,
Err(err) => {
warn!("Could not parse guild id: {:?}", err);
return ResponseError::error_response(&ServiceError {
status: 422,
message: err.to_string()
@@ -84,7 +87,6 @@ async fn send_message(path: web::Path<(String, String)>, text: web::Json<Channel
let channel_id = match channel_id.parse::<u64>() {
Ok(id) => id,
Err(err) => {
warn!("Could not parse channel id: {:?}", err);
return ResponseError::error_response(&ServiceError {
status: 422,
message: err.to_string()
@@ -95,7 +97,6 @@ async fn send_message(path: web::Path<(String, String)>, text: web::Json<Channel
let channels = match channel_results {
Ok(channels) => channels,
Err(err) => {
warn!("Could not get channels: {:?}", err);
return ResponseError::error_response(&ServiceError {
status: 422,
message: err.to_string()
@@ -106,7 +107,6 @@ async fn send_message(path: web::Path<(String, String)>, text: web::Json<Channel
let channel = match channels.iter().find(|c| c.id.0 == channel_id) {
Some(channel) => channel,
None => {
warn!("Could not find channel with id {}", channel_id);
return ResponseError::error_response(&ServiceError {
status: 422,
message: format!("Could not find channel with id {}", channel_id)
@@ -115,7 +115,6 @@ async fn send_message(path: web::Path<(String, String)>, text: web::Json<Channel
};
if let Err(err) = channel.say(&Pin::new(&data.http).get_ref(), &text.message).await {
warn!("Could not send message: {:?}", err);
return ResponseError::error_response(&ServiceError {
status: 422,
message: err.to_string()
@@ -132,22 +131,19 @@ struct PlayRequest {
#[post("/{guild_id}/voice/{channel_id}/play")]
async fn play(path: web::Path<(String, String)>, play_request: web::Json<PlayRequest>, data: web::Data<Arc<AppState>>, auth: JwtAuth) -> HttpResponse {
let _ = match verify_role(&auth, "admin") {
Ok(_) => {},
Err(err) => return ResponseError::error_response(&err)
if let Err(err) = verify_role(&auth, "admin") {
return ResponseError::error_response(&err)
};
let (guild_id, channel_id) = path.into_inner();
let guild_id = match guild_id.parse::<u64>() {
Ok(id) => id,
Err(err) => {
warn!("Could not parse guild id: {:?}", err);
return ResponseError::error_response(&ServiceError { status: 422, message: err.to_string() })
}
};
let channel_id = match channel_id.parse::<u64>() {
Ok(id) => id,
Err(err) => {
warn!("Could not parse channel id: {:?}", err);
return ResponseError::error_response(&ServiceError { status: 422, message: err.to_string() })
}
};
@@ -155,14 +151,12 @@ async fn play(path: web::Path<(String, String)>, play_request: web::Json<PlayReq
let guild = match http.get_guild(guild_id).await {
Ok(guild) => guild,
Err(err) => {
warn!("Could not get guild: {:?}", err);
return ResponseError::error_response(&ServiceError { status: 422, message: err.to_string() })
}
};
let channel = match http.get_channel(channel_id).await {
Ok(channel) => channel,
Err(err) => {
warn!("Could not get channel: {:?}", err);
return ResponseError::error_response(&ServiceError { status: 422, message: err.to_string() })
}
};
@@ -174,13 +168,11 @@ async fn play(path: web::Path<(String, String)>, play_request: web::Json<PlayReq
match play_track(Arc::clone(&data.songbird), guild.id, play_request.track_url.to_string()).await {
Ok(_) => HttpResponse::Ok().finish(),
Err(err) => {
warn!("Could not play track: {:?}", err);
return ResponseError::error_response(&err)
}
}
},
Err(err) => {
warn!("Could not join channel: {:?}", err);
return ResponseError::error_response(&ServiceError { status: 500, message: err.to_string() })
}
}
@@ -188,15 +180,13 @@ async fn play(path: web::Path<(String, String)>, play_request: web::Json<PlayReq
#[post("/{guild_id}/voice/stop")]
async fn stop(path: web::Path<String>, data: web::Data<Arc<AppState>>, auth: JwtAuth) -> HttpResponse {
let _ = match verify_role(&auth, "admin") {
Ok(_) => {},
Err(err) => return ResponseError::error_response(&err)
if let Err(err) = verify_role(&auth, "admin") {
return ResponseError::error_response(&err)
};
let guild_id = path.into_inner();
let guild_id = match guild_id.parse::<u64>() {
Ok(id) => id,
Err(err) => {
warn!("Could not parse guild id: {:?}", err);
return ResponseError::error_response(&ServiceError {
status: 422,
message: err.to_string()
@@ -214,15 +204,13 @@ async fn stop(path: web::Path<String>, data: web::Data<Arc<AppState>>, auth: Jwt
#[post("/{guild_id}/voice/resume")]
async fn resume(path: web::Path<String>, data: web::Data<Arc<AppState>>, auth: JwtAuth) -> HttpResponse {
let _ = match verify_role(&auth, "admin") {
Ok(_) => {},
Err(err) => return ResponseError::error_response(&err)
if let Err(err) = verify_role(&auth, "admin") {
return ResponseError::error_response(&err)
};
let guild_id = path.into_inner();
let guild_id = match guild_id.parse::<u64>() {
Ok(id) => id,
Err(err) => {
warn!("Could not parse guild id: {:?}", err);
return ResponseError::error_response(&ServiceError {
status: 422,
message: err.to_string()
@@ -233,7 +221,6 @@ async fn resume(path: web::Path<String>, data: web::Data<Arc<AppState>>, auth: J
if let Some(handler_lock) = data.songbird.get(guild_id) {
let handler = handler_lock.lock().await;
if let Err(err) = handler.queue().resume() {
warn!("Could not resume track: {:?}", err);
return ResponseError::error_response(&ServiceError {
status: 422,
message: err.to_string()
@@ -246,15 +233,13 @@ async fn resume(path: web::Path<String>, data: web::Data<Arc<AppState>>, auth: J
#[post("/{guild_id}/voice/pause")]
async fn pause(path: web::Path<String>, data: web::Data<Arc<AppState>>, auth: JwtAuth) -> HttpResponse {
let _ = match verify_role(&auth, "admin") {
Ok(_) => {},
Err(err) => return ResponseError::error_response(&err)
if let Err(err) = verify_role(&auth, "admin") {
return ResponseError::error_response(&err)
};
let guild_id = path.into_inner();
let guild_id = match guild_id.parse::<u64>() {
Ok(id) => id,
Err(err) => {
warn!("Could not parse guild id: {:?}", err);
return ResponseError::error_response(&ServiceError {
status: 422,
message: err.to_string()
@@ -265,7 +250,6 @@ async fn pause(path: web::Path<String>, data: web::Data<Arc<AppState>>, auth: Jw
if let Some(handler_lock) = data.songbird.get(guild_id) {
let handler = handler_lock.lock().await;
if let Err(err) = handler.queue().pause() {
warn!("Could not pause track: {:?}", err);
return ResponseError::error_response(&ServiceError {
status: 422,
message: err.to_string()
@@ -283,15 +267,13 @@ struct SetVolume {
#[get("/{guild_id}/voice/volume")]
async fn get_volume(path: web::Path<String>, auth: JwtAuth) -> HttpResponse {
let _ = match verify_role(&auth, "admin") {
Ok(_) => {},
Err(err) => return ResponseError::error_response(&err)
if let Err(err) = verify_role(&auth, "admin") {
return ResponseError::error_response(&err)
};
let guild_id = path.into_inner();
let guild_id = match guild_id.parse::<u64>() {
Ok(id) => id,
Err(err) => {
warn!("Could not parse guild id: {:?}", err);
return ResponseError::error_response(&ServiceError {
status: 422,
message: err.to_string()
@@ -302,7 +284,6 @@ async fn get_volume(path: web::Path<String>, auth: JwtAuth) -> HttpResponse {
let volume = match QueryGuild::get(guild_id as i64) {
Ok(guild) => guild.volume,
Err(err) => {
warn!("Could not get volume: {:?}", err);
return ResponseError::error_response(&ServiceError {
status: 422,
message: err.to_string()
@@ -315,15 +296,13 @@ async fn get_volume(path: web::Path<String>, auth: JwtAuth) -> HttpResponse {
#[post("/{guild_id}/voice/volume")]
async fn set_volume(path: web::Path<String>, volume: web::Json::<SetVolume>, data: web::Data<Arc<AppState>>, auth: JwtAuth) -> HttpResponse {
let _ = match verify_role(&auth, "admin") {
Ok(_) => {},
Err(err) => return ResponseError::error_response(&err)
if let Err(err) = verify_role(&auth, "admin") {
return ResponseError::error_response(&err)
};
let guild_id = path.into_inner();
let guild_id = match guild_id.parse::<u64>() {
Ok(id) => id,
Err(err) => {
warn!("Could not parse guild id: {:?}", err);
return ResponseError::error_response(&ServiceError {
status: 422,
message: err.to_string()
@@ -337,7 +316,6 @@ async fn set_volume(path: web::Path<String>, volume: web::Json::<SetVolume>, dat
let guild = match http.get_guild(guild_id).await {
Ok(guild) => guild,
Err(err) => {
warn!("Could not get guild: {:?}", err);
return ResponseError::error_response(&ServiceError { status: 422, message: err.to_string() })
}
};
@@ -348,15 +326,13 @@ async fn set_volume(path: web::Path<String>, volume: web::Json::<SetVolume>, dat
#[post("/{guild_id}/voice/skip")]
async fn skip(path: web::Path<String>, data: web::Data<Arc<AppState>>, auth: JwtAuth) -> HttpResponse {
let _ = match verify_role(&auth, "admin") {
Ok(_) => {},
Err(err) => return ResponseError::error_response(&err)
if let Err(err) = verify_role(&auth, "admin") {
return ResponseError::error_response(&err)
};
let guild_id = path.into_inner();
let guild_id = match guild_id.parse::<u64>() {
Ok(id) => id,
Err(err) => {
warn!("Could not parse guild id: {:?}", err);
return ResponseError::error_response(&ServiceError {
status: 422,
message: err.to_string()
@@ -367,7 +343,6 @@ async fn skip(path: web::Path<String>, data: web::Data<Arc<AppState>>, auth: Jwt
if let Some(handler_lock) = data.songbird.get(guild_id) {
let handler = handler_lock.lock().await;
if let Err(err) = handler.queue().skip() {
warn!("Could not skip track: {:?}", err);
return ResponseError::error_response(&ServiceError {
status: 422,
message: err.to_string()

View File

@@ -5,14 +5,14 @@ use serenity::model::gateway::Ready;
use serenity::model::channel::Message;
use serenity::prelude::*;
use crate::db::guilds::InsertGuild;
use crate::bot::guilds::InsertGuild;
use super::commands;
use super::{commands, oai};
use super::commands::audio::create_response;
pub struct Handler {
// Open AI Config
pub oai: Option<commands::oai::OAI>
pub oai: Option<oai::OAI>
}
#[async_trait]
@@ -36,7 +36,7 @@ impl EventHandler for Handler {
Err(_) => false
};
if mentioned || bot_in_thread {
commands::oai::generate_response(&ctx, &msg, oai).await;
commands::chat::generate_response(&ctx, &msg, oai).await;
}
}
Err(why) => warn!("Could not check mentions: {:?}", why)
@@ -49,6 +49,7 @@ impl EventHandler for Handler {
async fn interaction_create(&self, ctx: Context, interaction: Interaction) {
if let Interaction::ApplicationCommand(command) = interaction {
match command.data.name.as_str() {
"roll" => commands::roll::run(&ctx, &command).await,
"play" => commands::audio::play::run(&ctx, &command).await,
"stop" => commands::audio::stop::run(&ctx, &command).await,
"pause" => commands::audio::pause::run(&ctx, &command).await,
@@ -80,7 +81,9 @@ impl EventHandler for Handler {
volume: 100
});
let commands = guild.id.set_application_commands(&ctx.http, |commands| {
commands.create_application_command(|command: &mut serenity::builder::CreateApplicationCommand| { commands::ping::register(command) })
commands
.create_application_command(|command: &mut serenity::builder::CreateApplicationCommand| { commands::ping::register(command) })
.create_application_command(|command: &mut serenity::builder::CreateApplicationCommand| { commands::roll::register(command) })
.create_application_command(|command: &mut serenity::builder::CreateApplicationCommand| { commands::audio::play::register(command) })
.create_application_command(|command: &mut serenity::builder::CreateApplicationCommand| { commands::audio::stop::register(command) })
.create_application_command(|command: &mut serenity::builder::CreateApplicationCommand| { commands::audio::pause::register(command) })

View File

@@ -2,9 +2,9 @@ use diesel::prelude::*;
use serde::{Deserialize, Serialize};
use siren::ServiceError;
use crate::db::schema::messages::{self};
use crate::storage::{schema::messages::{self}, connection};
#[derive(Queryable, Selectable, Serialize, Deserialize)]
#[derive(Queryable, Selectable, Insertable, AsChangeset, Serialize, Deserialize)]
#[diesel(table_name = messages)]
pub struct QueryMessage {
pub id: String,
@@ -49,7 +49,7 @@ impl Default for QueryFilters {
impl QueryMessage {
pub fn get_all(filters: &QueryFilters, limit: i32, page: i32) -> Result<Vec<Self>, ServiceError> {
let mut conn = crate::db::connection()?;
let mut conn = connection()?;
let mut query = messages::table.limit(limit as i64).order(messages::created.asc()).into_boxed();
// Limit query to page and limit
let offset = (page - 1) * limit;
@@ -88,7 +88,7 @@ impl QueryMessage {
}
pub fn get_count(fitlers: &QueryFilters) -> Result<i64, ServiceError> {
let mut conn = crate::db::connection()?;
let mut conn = connection()?;
let mut query = messages::table.into_boxed();
// Apply filters
if let Some(id) = &fitlers.by_id {
@@ -122,26 +122,9 @@ impl QueryMessage {
let count = query.count().get_result::<i64>(&mut conn)?;
Ok(count)
}
}
#[derive(Insertable, AsChangeset, Serialize, Deserialize)]
#[diesel(table_name = messages)]
pub struct InsertMessage {
pub id: String,
pub guild_id: i64,
pub channel_id: i64,
pub user_id: i64,
pub created: i64,
pub model: String,
pub request: String,
pub response: String,
pub request_tags: Vec<String>,
pub response_tags: Vec<String>,
}
impl InsertMessage {
pub fn insert(message: Self) -> Result<QueryMessage, ServiceError> {
let mut conn = crate::db::connection()?;
let mut conn = connection()?;
let message = diesel::insert_into(messages::table)
.values(message)
.get_result(&mut conn)?;

View File

@@ -1,9 +1,9 @@
use actix_web::{get, post, web, HttpResponse, HttpRequest, ResponseError};
use log::error;
use serde::{Serialize, Deserialize};
use siren::{GetResponse, Metadata, ServiceError};
use siren::{Response, Metadata, ServiceError};
use crate::{db::messages::{QueryMessage, QueryFilters, InsertMessage}, auth::{JwtAuth, verify_role}};
use crate::{bot::messages::{QueryMessage, QueryFilters}, auth::{JwtAuth, verify_role}};
#[derive(Serialize, Deserialize)]
struct GetAllParams {
@@ -50,7 +50,7 @@ async fn get_all(req: HttpRequest, auth: JwtAuth) -> HttpResponse {
match QueryMessage::get_all(&filters, limit, page) {
Ok(messages) => {
HttpResponse::Ok().json(GetResponse {
HttpResponse::Ok().json(Response {
data: messages,
metadata: Some(Metadata {
total: total_count as i32,
@@ -68,12 +68,12 @@ async fn get_all(req: HttpRequest, auth: JwtAuth) -> HttpResponse {
}
#[post("/messages")]
async fn create(message: web::Json<InsertMessage>, auth: JwtAuth) -> HttpResponse {
async fn create(message: web::Json<QueryMessage>, auth: JwtAuth) -> HttpResponse {
let _ = match verify_role(&auth, "admin") {
Ok(_) => {},
Err(err) => return ResponseError::error_response(&err)
};
match InsertMessage::insert(message.into_inner()) {
match QueryMessage::insert(message.into_inner()) {
Ok(message) => HttpResponse::Created().json(message),
Err(err) => {
error!("{:?}", err.message);

View File

@@ -1,3 +1,6 @@
pub mod api;
pub mod commands;
pub mod guilds;
pub mod handler;
pub mod messages;
pub mod oai;
pub mod ytdlp;

View File

@@ -0,0 +1,3 @@
mod model;
pub use model::*;

View 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) })
}
}
}

View 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)
}
}

View 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,
}

View File

@@ -1,3 +0,0 @@
mod model;
pub use model::*;

View File

@@ -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);
}

View File

@@ -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);
}

View File

@@ -2,7 +2,9 @@ use diesel::prelude::*;
use serde::{Deserialize, Serialize};
use siren::ServiceError;
use crate::db::{schema::spells::{self}, classes::AbilityType, conditions::ConditionType};
use crate::storage::connection;
use crate::storage::schema::spells::{self};
use crate::dnd::{classes::AbilityType, conditions::ConditionType};
use super::{SchoolType, CastingTime, SpellAttackType, SpellDamageType, Range, Area, Components, Duration, Source, Description, DurationType, Effect};
@@ -61,7 +63,7 @@ impl Default for QueryFilters {
impl QuerySpell {
pub fn get_all(filters: &QueryFilters, limit: i32, page: i32) -> Result<Vec<Self>, ServiceError> {
let mut conn = crate::db::connection()?;
let mut conn = connection()?;
let mut query = spells::table.limit(limit as i64).into_boxed();
// Limit query to page and limit
let offset = (page - 1) * limit;
@@ -108,7 +110,7 @@ impl QuerySpell {
}
pub fn get_count(filters: &QueryFilters) -> Result<i64, ServiceError> {
let mut conn = crate::db::connection()?;
let mut conn = connection()?;
let mut query = spells::table.count().into_boxed();
if let Some(name) = &filters.by_name {
query = query.filter(spells::name.ilike(format!("%{}%", name)));
@@ -149,7 +151,7 @@ impl QuerySpell {
}
pub fn get_by_id(id: i32) -> Result<Self, ServiceError> {
let mut conn = crate::db::connection()?;
let mut conn = connection()?;
let spell = spells::table
.filter(spells::id.eq(id))
.first::<QuerySpell>(&mut conn)?;
@@ -157,7 +159,7 @@ impl QuerySpell {
}
pub fn delete(id: i32) -> Result<Self, ServiceError> {
let mut conn = crate::db::connection()?;
let mut conn = connection()?;
let spell = diesel::delete(spells::table.filter(spells::id.eq(id))).get_result(&mut conn)?;
Ok(spell)
}
@@ -182,13 +184,13 @@ pub struct InsertSpell {
impl InsertSpell {
pub fn insert(spell: Self) -> Result<QuerySpell, ServiceError> {
let mut conn = crate::db::connection()?;
let mut conn = connection()?;
let spell = diesel::insert_into(spells::table).values(spell).get_result(&mut conn)?;
Ok(spell)
}
pub fn update(id: i32, spell: Self) -> Result<QuerySpell, ServiceError> {
let mut conn = crate::db::connection()?;
let mut conn = connection()?;
let spell = diesel::update(spells::table.filter(spells::id.eq(id))).set(spell).get_result(&mut conn)?;
Ok(spell)
}

View File

@@ -1,9 +1,9 @@
use actix_web::{get, post, put, delete, web, HttpResponse, HttpRequest, ResponseError};
use log::error;
use serde::{Serialize, Deserialize};
use siren::{GetResponse, Metadata, ServiceError};
use siren::{Response, Metadata, ServiceError};
use crate::{db::spells::{QuerySpell, QueryFilters}, auth::{JwtAuth, verify_role}};
use crate::{dnd::spells::{QuerySpell, QueryFilters}, auth::{JwtAuth, verify_role}};
use super::{Spell, InsertSpell};
@@ -90,7 +90,7 @@ async fn get_all(req: HttpRequest) -> HttpResponse {
spell.id = Some(id);
response.push(spell);
}
HttpResponse::Ok().json(GetResponse {
HttpResponse::Ok().json(Response {
data: response,
metadata: Some(Metadata {
total: total_count as i32,
@@ -121,7 +121,7 @@ async fn get_by_id(id: web::Path<String>) -> HttpResponse {
let id = query_spell.id;
let mut spell = Spell::from(query_spell);
spell.id = Some(id);
HttpResponse::Ok().json(GetResponse {
HttpResponse::Ok().json(Response {
data: spell,
metadata: None
})

View File

@@ -19,7 +19,7 @@ pub struct Message {
}
#[derive(Serialize, Deserialize)]
pub struct GetResponse<T> {
pub struct Response<T> {
pub data: T,
#[serde(skip_serializing_if = "Option::is_none")]
pub metadata: Option<Metadata>
@@ -54,6 +54,18 @@ impl fmt::Display for ServiceError {
}
}
impl From<std::io::Error> for ServiceError {
fn from(error: std::io::Error) -> ServiceError {
ServiceError::new(500, format!("Unknown io error: {}", error))
}
}
impl From<std::string::FromUtf8Error> for ServiceError {
fn from(error: std::string::FromUtf8Error) -> ServiceError {
ServiceError::new(500, format!("Unknown from utf8 error: {}", error))
}
}
impl From<DieselError> for ServiceError {
fn from(error: DieselError) -> ServiceError {
match error {
@@ -112,6 +124,23 @@ impl From<redis::RedisError> for ServiceError {
}
}
impl From<s3::error::S3Error> for ServiceError {
fn from(error: s3::error::S3Error) -> ServiceError {
match error {
s3::error::S3Error::Http(code, message) => {
ServiceError::new(code, message)
},
_ => ServiceError::new(500, format!("Unknown s3 error: {}", error))
}
}
}
impl From<s3::creds::error::CredentialsError> for ServiceError {
fn from(error: s3::creds::error::CredentialsError) -> ServiceError {
ServiceError::new(500, format!("Unknown credentials error: {}", error))
}
}
impl ResponseError for ServiceError {
fn error_response(&self) -> HttpResponse {
let status_code = match StatusCode::from_u16(self.status) {

View File

@@ -14,22 +14,23 @@ use songbird::{SerenityInit, Songbird};
use actix_cors::Cors;
use actix_web::{HttpServer, App, web};
use crate::bot::{commands::oai::GPTModel, handler::Handler};
use crate::bot::handler::Handler;
use dotenv::dotenv;
mod auth;
mod dnd;
mod bot;
mod db;
mod storage;
mod users;
#[actix_web::main]
async fn main() -> std::io::Result<()> {
dotenv().ok();
env_logger::init_from_env(env_logger::Env::default().filter_or("RUST_LOG", "warn,siren=info"));
db::init();
storage::init().await;
match env::var("DATA_DIR_PATH") {
Ok(data_dir_path) => db::load_data(&data_dir_path),
Ok(data_dir_path) => dnd::load_data(&data_dir_path),
Err(err) => warn!("Unable to load initial database data: {}", err)
};
@@ -56,8 +57,9 @@ async fn main() -> std::io::Result<()> {
let handler = match env::var("OPENAI_API_KEY") {
Ok(token) => {
info!("Loaded OpenAI token");
let default_model = env::var("OPENAI_API_MODEL").unwrap_or("gpt-3.5-turbo".to_string());
Handler {
oai: Some(bot::commands::oai::OAI {
oai: Some(bot::oai::OAI {
client: reqwest::Client::new(),
base_url: "https://api.openai.com/v1".to_string(),
service_url: "http://localhost:5000".to_string(),
@@ -65,7 +67,7 @@ async fn main() -> std::io::Result<()> {
token,
max_context_questions: 30,
max_tokens: 2048,
default_model: GPTModel::GPT35Turbo,
default_model,
})
}
}
@@ -97,16 +99,16 @@ async fn main() -> std::io::Result<()> {
let shard_manager = Arc::clone(&client.shard_manager);
// tokio::spawn(async move {
// tokio::signal::ctrl_c().await.expect("Could not register ctrl+c handler");
// shard_manager.lock().await.shutdown_all().await;
// });
tokio::spawn(async move {
tokio::signal::ctrl_c().await.expect("Could not register ctrl+c handler");
shard_manager.lock().await.shutdown_all().await;
});
// tokio::spawn(async move {
// if let Err(why) = client.start_autosharded().await {
// error!("An error occurred while running the client: {:?}", why);
// }
// });
tokio::spawn(async move {
if let Err(why) = client.start_autosharded().await {
error!("An error occurred while running the client: {:?}", why);
}
});
let host = env::var("SERVICE_HOST").unwrap_or("localhost".to_string());
let port = env::var("SERVICE_PORT").unwrap_or("5000".to_string());
@@ -121,10 +123,11 @@ async fn main() -> std::io::Result<()> {
App::new()
.wrap(cors)
.app_data(web::Data::new(Arc::clone(&app_data)))
.configure(crate::db::messages::init_routes)
.configure(crate::db::spells::init_routes)
.configure(crate::auth::init_routes)
.configure(crate::bot::api::init_routes)
.configure(crate::users::init_routes)
.configure(crate::dnd::spells::init_routes)
.configure(crate::bot::guilds::init_routes)
.configure(crate::bot::messages::init_routes)
})
.bind(format!("{}:{}", host, port)) {
Ok(b) => {

122
service/src/storage/mod.rs Normal file
View 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)
}

View File

@@ -46,6 +46,9 @@ diesel::table! {
role -> Text,
first_name -> Text,
last_name -> Text,
updated_at -> Timestamp,
created_at -> Timestamp,
profile_picture -> Nullable<Text>,
verified -> Bool,
}
}

3
service/src/users/mod.rs Normal file
View File

@@ -0,0 +1,3 @@
mod routes;
pub use routes::init_routes;

136
service/src/users/routes.rs Normal file
View 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

File diff suppressed because it is too large Load Diff

View File

@@ -9,36 +9,35 @@
"lint": "next lint"
},
"dependencies": {
"@mantine/core": "^7.1.2",
"@mantine/form": "^7.1.2",
"@mantine/hooks": "^7.1.2",
"@mantine/modals": "^7.1.2",
"@mantine/notifications": "^7.1.2",
"axios": "^1.5.1",
"@mantine/core": "^7.2.2",
"@mantine/form": "^7.2.2",
"@mantine/hooks": "^7.2.2",
"@mantine/modals": "^7.2.2",
"@mantine/notifications": "^7.2.2",
"@pixi/react": "^7.1.1",
"js-cookie": "^3.0.5",
"next": "^13.5.4",
"next": "^14.0.3",
"pixi.js": "^7.3.2",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-icons": "^4.11.0",
"react-leaflet": "^4.2.1",
"recharts": "^2.8.0",
"react-icons": "^4.12.0",
"recoil": "^0.7.7"
},
"devDependencies": {
"@types/js-cookie": "^3.0.4",
"@types/node": "20.8.2",
"@types/react": "18.2.24",
"@types/react-dom": "18.2.8",
"@typescript-eslint/eslint-plugin": "^6.7.4",
"@typescript-eslint/parser": "^6.7.4",
"@types/js-cookie": "^3.0.5",
"@types/node": "20.8.7",
"@types/react": "18.2.31",
"@types/react-dom": "18.2.14",
"@typescript-eslint/eslint-plugin": "^6.8.0",
"@typescript-eslint/parser": "^6.8.0",
"autoprefixer": "^10.4.16",
"eslint": "8.50.0",
"eslint-config-next": "13.5.4",
"eslint": "8.52.0",
"eslint-config-next": "13.5.6",
"eslint-config-prettier": "^9.0.0",
"eslint-plugin-prettier": "^5.0.0",
"postcss": "^8.4.31",
"postcss-import": "^15.1.0",
"postcss-preset-mantine": "^1.8.0",
"postcss-preset-mantine": "^1.9.0",
"prettier": "^3.0.3",
"typescript": "5.2.2"
}

View File

@@ -1,10 +1,11 @@
import Cookies from 'js-cookie';
import { getRequest, postRequest } from '.';
import { RegisterUser, ResponseAuth } from './auth.types';
export async function login(email: string, password: string): Promise<ResponseAuth | undefined> {
const response = await postRequest('auth/login', { email, password });
if (response?.status === 200) {
return response.data as ResponseAuth;
return response.json();
} else {
return undefined;
}
@@ -24,9 +25,9 @@ export async function logout() {
}
export async function refresh(refresh_token_rotation?: boolean): Promise<ResponseAuth | undefined> {
const response = await getRequest('auth/refresh', { params: { refresh_token_rotation } });
const response = await getRequest('auth/refresh', { refresh_token_rotation });
if (response?.status === 200) {
return response.data as ResponseAuth;
return response.json();
} else {
return undefined;
}
@@ -35,8 +36,37 @@ export async function refresh(refresh_token_rotation?: boolean): Promise<Respons
export async function me(): Promise<ResponseAuth | undefined> {
const response = await getRequest('auth/me');
if (response?.status === 200) {
return response.data;
return response.json();
} else {
return undefined;
}
}
export async function hasSession(): Promise<boolean> {
const response = await getRequest('auth/check-session');
if (response?.status === 200) {
return response?.json();
} else {
return false;
}
}
/**
* Refreshes the logged_in cookie every interval. By default, the interval is 14 minutes.
* @param interval
* @returns interval id
*/
export function refreshLoggedIn(interval = 840000) {
let loggedIn = Cookies.get('logged_in');
const id = setInterval(async () => {
const cookie = Cookies.get('logged_in');
if (cookie != loggedIn) {
loggedIn = cookie;
const response = await refresh(true);
if (!response) {
Cookies.remove('logged_in');
}
}
}, interval);
return id;
}

View File

@@ -15,4 +15,5 @@ export interface User {
role: string;
first_name: string;
last_name: string;
profile_picture?: string;
}

View File

@@ -1,14 +1,16 @@
import { getRequest, postRequest } from '.';
import { APIResponse, getRequest, postRequest } from '.';
import { GuildChannel, GuildInfo } from './guilds.types';
export async function getGuilds(): Promise<GuildInfo[]> {
const response = await getRequest('guilds');
return response?.data || { data: [] };
const guilds: APIResponse<GuildInfo[]> = await response?.json();
return guilds?.data || [];
}
export async function getTextChannels(guildId: number): Promise<GuildChannel[]> {
const response = await getRequest(`guilds/${guildId}/text`);
return response?.data || { data: [] };
const channels: APIResponse<GuildChannel[]> = await response?.json();
return channels.data || [];
}
export async function sendMessage(guildId: number, channelId: number, message: string): Promise<void> {
@@ -17,7 +19,8 @@ export async function sendMessage(guildId: number, channelId: number, message: s
export async function getVoiceChannels(guildId: number): Promise<GuildChannel[]> {
const response = await getRequest(`guilds/${guildId}/voice`);
return response?.data || { data: [] };
const channels: APIResponse<GuildChannel[]> = await response?.json();
return channels.data || [];
}
export async function playTrack(guildId: number, channelId: number, track: string): Promise<void> {
@@ -46,5 +49,6 @@ export async function skipTrack(guildId: number): Promise<void> {
export async function getVolume(guildId: number): Promise<number> {
const response = await getRequest(`guilds/${guildId}/voice/volume`);
return response?.data?.volume || 0;
const volume: number = await response?.json();
return volume || 0;
}

View File

@@ -1,43 +1,49 @@
import axios, { AxiosInstance, AxiosRequestConfig, AxiosResponse } from 'axios';
const serviceHost = process.env.SERVICE_HOST || 'http://localhost';
const servicePort = process.env.SERVICE_PORT || 5000;
const baseURL = `${serviceHost}:${servicePort}`;
function createAxiosClient(): AxiosInstance {
const axiosClient = axios.create({
baseURL: `${serviceHost}:${servicePort}`
export async function getRequest(endpoint: string, params: Record<string, any> = {}): Promise<Response> {
// Remove undefined params
Object.keys(params).forEach((key) => params[key] === undefined && delete params[key]);
const urlParams = new URLSearchParams(params);
const url = urlParams && urlParams.size > 0 ? `${baseURL}/${endpoint}?${urlParams}` : `${baseURL}/${endpoint}`;
const response = await fetch(url, {
method: 'GET',
credentials: 'include'
});
return response;
}
axiosClient.interceptors.request.use(
(request) => {
request.withCredentials = true;
return request;
interface PostOptions {
headers?: Record<string, any>;
type?: 'json' | 'form';
}
export async function postRequest(endpoint: string, body?: any, options?: PostOptions): Promise<Response> {
const url = `${baseURL}/${endpoint}`;
let response;
if (body && (!options?.type || options.type === 'json')) {
response = await fetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
(error) => {
console.error(error);
return Promise.reject(error);
credentials: 'include',
body: JSON.stringify(body)
});
} else {
response = await fetch(url, {
method: 'POST',
credentials: 'include',
body
});
}
);
return axiosClient;
return response;
}
const axiosClient = createAxiosClient();
export async function getRequest(
url: string,
config?: AxiosRequestConfig<any>
): Promise<AxiosResponse<any, any> | undefined> {
const response = await axiosClient.get(`/${url}`, config);
return response || undefined;
}
export async function postRequest(
url: string,
data?: any,
config?: AxiosRequestConfig<any>
): Promise<AxiosResponse<any, any> | undefined> {
const response = await axiosClient.post(`/${url}`, data, config);
return response || undefined;
export interface APIResponse<T> {
data: T;
metadata: Metadata;
}
export interface Metadata {

View File

@@ -20,7 +20,6 @@ interface GetSpellsParams {
export async function getSpells(params?: GetSpellsParams): Promise<GetSpellsResponse> {
const response = await getRequest('dnd/spells', {
params: {
name: params?.name,
like_name: params?.like_name,
schools: params?.schools?.join(','),
@@ -35,7 +34,6 @@ export async function getSpells(params?: GetSpellsParams): Promise<GetSpellsResp
attack_type: params?.attack_type?.join(','),
limit: params?.limit,
page: params?.page
}
});
return response?.data || { data: [] };
return response?.json() || { data: [] };
}

24
ui/src/api/users.ts Normal file
View 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
View 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>
);
}

View 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>
);
}

View File

@@ -0,0 +1,5 @@
import React from 'react';
export default function Page({ params }: { params: { id: string } }) {
return <>{params.id}</>;
}

View File

@@ -0,0 +1,5 @@
import React from 'react';
export default function Page() {
return <h1>Create new Character</h1>;
}

View File

@@ -0,0 +1,5 @@
import React from 'react';
export default function Page() {
return <></>;
}

View File

@@ -1,6 +1,6 @@
import React from 'react';
import RecoilRootWrapper from '@app/recoil-root-wrapper';
import Topbar from '@/components/Topbar';
import Header from '@/components/Header';
import { Inter } from 'next/font/google';
import { Box, MantineProvider } from '@mantine/core';
import { ModalsProvider } from '@mantine/modals';
@@ -8,6 +8,7 @@ import { Notifications } from '@mantine/notifications';
import 'styles/globals.css';
import '@mantine/core/styles.css';
import '@mantine/notifications/styles.css';
import Loading from '@/components/Loading';
export const metadata = {
title: 'Siren',
@@ -22,15 +23,15 @@ export default function RootLayout({ children }: { children: React.ReactNode })
<head>
<title>Siren</title>
</head>
<body className={`${inter.className} wrapper h-full`}>
<body className={`${inter.className} wrapper`}>
<RecoilRootWrapper>
<MantineProvider>
<Notifications />
<ModalsProvider>
<Topbar />
<Box p='xl' pt='sm' className='h-full'>
{children}
</Box>
<Loading>
<Header />
<Box>{children}</Box>
</Loading>
</ModalsProvider>
</MantineProvider>
</RecoilRootWrapper>

View File

@@ -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>
);
}

View File

@@ -1,6 +1,21 @@
import TileGrid from '@/components/TileGrid';
import React from 'react';
// Home page for siren
export default function Page() {
return <div></div>;
return (
// <div>
// <p>Siren is a Dungeon Master's best friend.</p>
// <h2>Features:</h2>
// <ul>
// <li>Manage your campaign and players</li>
// <li>Create battlemaps on the fly and track initiative</li>
// <li>Connect the Discord Bot to play online with friends</li>
// <li>Reference Races, Classes, Items, Spells, and more</li>
// </ul>
// </div>
<div style={{ overflow: 'hidden' }}>
<TileGrid />
</div>
);
}

View 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 <></>;
}
}

View File

@@ -73,6 +73,77 @@ export default function Page() {
}}
/>
<hr />
<SpellSection
title='Level 2'
spells={level2.filter((s) => s.name.toLowerCase().includes(searchName.toLowerCase()))}
onClick={(spell) => {
setActiveSpell(spell);
setIsOpen(true);
}}
/>
<hr />
<SpellSection
title='Level 3'
spells={level3.filter((s) => s.name.toLowerCase().includes(searchName.toLowerCase()))}
onClick={(spell) => {
setActiveSpell(spell);
setIsOpen(true);
}}
/>
<hr />
<SpellSection
title='Level 4'
spells={level4.filter((s) => s.name.toLowerCase().includes(searchName.toLowerCase()))}
onClick={(spell) => {
setActiveSpell(spell);
setIsOpen(true);
}}
/>
<hr />
<SpellSection
title='Level 5'
spells={level5.filter((s) => s.name.toLowerCase().includes(searchName.toLowerCase()))}
onClick={(spell) => {
setActiveSpell(spell);
setIsOpen(true);
}}
/>
<hr />
<SpellSection
title='Level 6'
spells={level6.filter((s) => s.name.toLowerCase().includes(searchName.toLowerCase()))}
onClick={(spell) => {
setActiveSpell(spell);
setIsOpen(true);
}}
/>
<hr />
<SpellSection
title='Level 7'
spells={level7.filter((s) => s.name.toLowerCase().includes(searchName.toLowerCase()))}
onClick={(spell) => {
setActiveSpell(spell);
setIsOpen(true);
}}
/>
<hr />
<SpellSection
title='Level 8'
spells={level8.filter((s) => s.name.toLowerCase().includes(searchName.toLowerCase()))}
onClick={(spell) => {
setActiveSpell(spell);
setIsOpen(true);
}}
/>
<hr />
<SpellSection
title='Level 9'
spells={level9.filter((s) => s.name.toLowerCase().includes(searchName.toLowerCase()))}
onClick={(spell) => {
setActiveSpell(spell);
setIsOpen(true);
}}
/>
{activeSpell && <SpellModal spell={activeSpell} isOpen={isOpen} onClose={() => setIsOpen(false)} />}
</Box>
);
@@ -90,9 +161,9 @@ function SpellSection({ title, spells, onClick }: { title: string; spells: Spell
<Box>
<h2>{title}</h2>
<ul>
{spells.map((spell) => (
{spells.map((spell, index) => (
<li
key={spell.id}
key={`spell-${index}`}
className='link spell-item'
style={{ width: 'fit-content' }}
onClick={() => onClick(spell)}

View File

@@ -1,210 +1,31 @@
'use client';
import Link from 'next/link';
import { usePathname } from 'next/navigation';
import './topbar.css';
import {
Anchor,
Avatar,
Button,
Card,
Checkbox,
Container,
Grid,
Group,
Menu,
Modal,
Paper,
PasswordInput,
Text,
TextInput,
Title,
UnstyledButton
} from '@mantine/core';
import Cookies from 'js-cookie';
import { useEffect, useState } from 'react';
import { useForm } from '@mantine/form';
import { login, register, logout, me, refresh } from '@/api/auth';
import { login, register } from '@/api/auth';
import { User } from '@/api/auth.types';
import { useToggle } from '@mantine/hooks';
import {
Modal,
Container,
Title,
Anchor,
Paper,
TextInput,
Button,
PasswordInput,
Group,
Checkbox,
Text
} from '@mantine/core';
import { useForm } from '@mantine/form';
import { notifications } from '@mantine/notifications';
interface HeaderItem {
name: string;
link: string;
role?: string;
}
const headerItems: HeaderItem[] = [
{
name: 'Races',
link: '/races'
},
{
name: 'Classes',
link: '/classes'
},
{
name: 'Feats',
link: '/feats'
},
{
name: 'Options & Features',
link: '/options'
},
{
name: 'Backgrounds',
link: '/backgrounds'
},
{
name: 'Items',
link: '/items'
},
{
name: 'Spells',
link: '/spells'
},
{
name: 'Management',
link: '/management',
role: 'admin'
}
];
export default function Topbar() {
const pathName = usePathname();
const [modalType, toggle] = useToggle([undefined, 'login', 'register', 'reset']);
const [headers, setHeaders] = useState<HeaderItem[]>([]);
const [user, setUser] = useState<User | undefined>(undefined);
useEffect(() => {
if (Cookies.get('logged_in')) {
me().then((response) => {
if (response) {
setUser(response.user);
}
});
} else {
refresh(true).then((response) => {
if (response) {
setUser(response.user);
} else {
setUser(undefined);
}
});
}
}, [pathName]);
useEffect(() => {
const h: HeaderItem[] = [];
headerItems.forEach((item) => {
if (item.role == undefined || user?.role == item.role) {
h.push(item);
}
setHeaders(h);
});
}, [user]);
return (
<>
<nav className='navbar'>
<div className='left'>
<Link href={'/'} className='title'>
Siren
</Link>
<div className='header-items'>
{headers.map((item) => (
<Link className={`header-item ${pathName == item.link && 'active'}`} href={item.link} key={item.name}>
{item.name}
</Link>
))}
</div>
</div>
<div className='user-section'>
{user ? (
<Menu shadow='md' width={200} openDelay={100} closeDelay={400}>
<Menu.Target>
<UnstyledButton className='user user-button'>
<Group>
<Avatar />
<div style={{ flex: 1 }}>
<Text size='sm' fw={500}>
{user.first_name} {user.last_name}
</Text>
<Text c='dimmed' size='xs'>
{user.role}
</Text>
</div>
</Group>
</UnstyledButton>
</Menu.Target>
<Menu.Dropdown>
<Card>
<Card.Section h={140} style={{}} />
<Avatar size={80} radius={80} mx={'auto'} mt={-30} />
<Text ta='center' fz='lg' fw={500} mt='sm'>
{user.first_name} {user.last_name}
</Text>
<Text ta='center' fz='sm' c='dimmed'>
{user.role}
</Text>
<Grid mt='xl'>
<Grid.Col span={6}>
<Button
fullWidth
radius='md'
size='xs'
variant='default'
onClick={() => {
toggle(undefined);
}}
>
Profile
</Button>
</Grid.Col>
<Grid.Col span={6}>
<Button
fullWidth
radius='md'
size='xs'
variant='default'
onClick={async () => {
const response = await logout();
if (response?.status == 200) {
Cookies.remove('logged_in');
setUser(undefined);
}
}}
>
Logout
</Button>
</Grid.Col>
</Grid>
</Card>
</Menu.Dropdown>
</Menu>
) : (
<Group className='user'>
<Button onClick={() => toggle('login')}>Login</Button>
<Button variant='outline' onClick={() => toggle('register')}>
Sign up
</Button>
</Group>
)}
</div>
</nav>
<LoginModal type={modalType} toggle={toggle} setUser={setUser} />
</>
);
}
interface LoginModalProps {
interface HeaderModalProps {
type?: string;
toggle: any;
setUser: (user: User) => void;
setRefreshId: (id: NodeJS.Timeout) => void;
}
function LoginModal({ type, toggle, setUser }: LoginModalProps) {
export function HeaderModal({ type, toggle, setUser, setRefreshId }: HeaderModalProps) {
function passwordValidator(value: string) {
if (value.trim().length < 10) {
return 'Password must be at least 10 characters';

View File

@@ -3,6 +3,8 @@
justify-content: space-between;
color: black;
border-bottom: 1px solid #e6e6e6;
max-height: 70px;
user-select: none;
}
.navbar .left {

View 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'
}
]
}
];

View 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}
/>
</>
);
}

View 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}</>;
}
}

View File

@@ -38,8 +38,8 @@ export default function SpellModal({ spell, isOpen, onClose }: SpellModalProps)
</Grid.Col>
<Grid.Col span={6}>
<span style={{ fontWeight: 'bold', paddingRight: '1em' }}>Sources:</span>
{spell.sources.map((s) => (
<span style={{ paddingRight: '0.6em' }}>
{spell.sources.map((s, index) => (
<span style={{ paddingRight: '0.6em' }} key={`spell-source-${index}`}>
{s.source}
{s.page ? `.${s.page}` : ''}
</span>
@@ -48,8 +48,12 @@ export default function SpellModal({ spell, isOpen, onClose }: SpellModalProps)
<Grid.Col span={6}>
<span style={{ fontWeight: 'bold', marginRight: '1em' }}>Classes:</span>
<span style={{ overflowWrap: 'break-word' }}>
{spell.classes.map((c) => (
<span style={{ paddingRight: '0.6em', display: 'inline-block' }} className='link'>
{spell.classes.map((c, index) => (
<span
style={{ paddingRight: '0.6em', display: 'inline-block' }}
className='link'
key={`spell-class-${index}`}
>
{parseText(c, true)}
</span>
))}
@@ -71,8 +75,8 @@ export default function SpellModal({ spell, isOpen, onClose }: SpellModalProps)
<Grid.Col span={6}>
<span style={{ fontWeight: 'bold', paddingRight: '1em' }}>Duration:</span>
<span style={{ paddingRight: '0.6em' }}>
{spell.durations.map((d) => (
<span>
{spell.durations.map((d, index) => (
<span key={`duration-${index}`}>
{capitalize(d.type)} {d.value} {capitalize(d.unit)}
</span>
))}
@@ -86,45 +90,55 @@ export default function SpellModal({ spell, isOpen, onClose }: SpellModalProps)
);
}
function parseText(text: string, capitalizeFirst?: boolean) {
function parseText(text: string, capitalizeFirst?: boolean): (string | JSX.Element)[] {
const regex = /{@(.*?) (.*?)}/g;
const matches = text.matchAll(regex);
const result = [];
let lastIndex = 0;
let noMatches = true;
for (const match of matches) {
noMatches = false;
const key = crypto.randomUUID();
const [full, type, name] = match;
result.push(text.slice(lastIndex, match.index));
result.push(<span key={crypto.randomUUID()}>{text.slice(lastIndex, match.index)}</span>);
if (match.index !== undefined) {
if (type == 'dice') {
result.push(
<span onClick={() => handleLink(type, name)} className='link'>
<span onClick={() => handleLink(type, name)} className='link' key={key}>
{name}
</span>
);
} else if (type == 'scaledice') {
// scaledice format is {@scaledice 1d6|1-9|1d6|}. Parse this out into dice, levels, and dice again.
// scaledice format is {@scaledice 1d6|1-9}. Parse this out into dice, levels, and dice again.
const [dice, levels] = name.split('|');
result.push(
<span onClick={() => handleLink('dice', dice)} className='link'>
<span onClick={() => handleLink('dice', dice)} className='link' key={key}>
{dice}
</span>
);
} else if (type == 'bold') {
result.push(<span style={{ fontWeight: 'bold' }}>{name}</span>);
result.push(
<span style={{ fontWeight: 'bold' }} key={key}>
{name}
</span>
);
} else if (type == 'subclass') {
const [className, subclassName] = name.split('|');
result.push(
<span>
<span key={key}>
{capitalize(className)} ({capitalize(subclassName)})
</span>
);
} else {
result.push(<span>{capitalizeFirst ? capitalize(name) : name}</span>);
result.push(<span key={key}>{capitalizeFirst ? capitalize(name) : name}</span>);
}
lastIndex = match.index + full.length;
}
}
result.push(text.slice(lastIndex));
const lastString = text.slice(lastIndex);
result.push(<span key={crypto.randomUUID()}>{noMatches ? capitalize(lastString) : lastString}</span>);
return result;
}
@@ -148,18 +162,17 @@ function handleLink(type: string, name: string) {
}
function SpellDescription({ spell }: { spell: Spell }) {
return (
<>
{spell.description && (
<>
{spell.description.entries.map((e) => (
<>
{spell.description.entries.map((e, index) => (
<div key={`spell-description-${index}`}>
{e.text && <p>{parseText(e.text)}</p>}
{e.list && (
<ul>
{e.list.map((text) => (
<li>{parseText(text)}</li>
{e.list.map((text, index) => (
<li key={`spell-text-${index}`}>{parseText(text)}</li>
))}
</ul>
)}
@@ -167,23 +180,23 @@ function SpellDescription({ spell }: { spell: Spell }) {
<table>
<thead>
<tr>
{e.table.headers.map((label) => (
<th>{label}</th>
{e.table.headers.map((label, index) => (
<th key={`spell-header-${index}`}>{label}</th>
))}
</tr>
</thead>
<tbody>
{e.table.rows.map((row) => (
<tr>
{row.map((cell) => (
<td>{parseText(cell)}</td>
{e.table.rows.map((row, index) => (
<tr key={`spell-row-${index}`}>
{row.map((cell, index) => (
<td key={`spell-cell-${index}`}>{parseText(cell)}</td>
))}
</tr>
))}
</tbody>
</table>
)}
</>
</div>
))}
</>
)}

View 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>
);
}

View File

View 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>
);
}

View 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
View 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
});

View File

@@ -1,11 +1,11 @@
{
"compilerOptions": {
"target": "ESNext",
"target": "ES2022",
"downlevelIteration": true,
"lib": [
"dom",
"dom.iterable",
"esnext"
"ES2022"
],
"allowJs": true,
"skipLibCheck": true,
@@ -14,7 +14,7 @@
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "node",
"moduleResolution": "Node",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve",
@@ -26,11 +26,24 @@
],
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"],
"@api/*": ["src/api"],
"@app/*": ["./src/app/*"],
"@components/*": ["src/components/*"],
"@lib/*": ["src/components/*"]
"@/*": [
"./src/*"
],
"@api/*": [
"src/api"
],
"@app/*": [
"./src/app/*"
],
"@components/*": [
"src/components/*"
],
"@js/*": [
"src/js/*"
],
"@state/*": [
"src/state/*"
]
}
},
"include": [