Compare commits

...

3 Commits

19 changed files with 1709 additions and 96 deletions

87
Cargo.lock generated
View File

@ -1,6 +1,6 @@
# This file is automatically @generated by Cargo.
# It is not intended for manual editing.
version = 3
version = 4
[[package]]
name = "addr2line"
@ -103,6 +103,12 @@ dependencies = [
"libc",
]
[[package]]
name = "anyhow"
version = "1.0.100"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a23eb6b1614318a8071c9b2521f36b424b2c83db5eb3a0fead4a6c0809af6e61"
[[package]]
name = "arrayvec"
version = "0.7.4"
@ -117,7 +123,7 @@ checksum = "cc6dde6e4ed435a4c1ee4e73592f5ba9da2151af10076cc04858746af9352d09"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.27",
"syn 2.0.32",
]
[[package]]
@ -375,7 +381,7 @@ dependencies = [
"percent-encoding",
"rand",
"subtle",
"time 0.3.23",
"time 0.3.41",
"version_check",
]
@ -456,6 +462,16 @@ dependencies = [
"serde",
]
[[package]]
name = "deranged"
version = "0.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "28cfac68e08048ae1883171632c2aef3ebc555621ae56fbccce1cbf22dd7f058"
dependencies = [
"powerfmt",
"serde",
]
[[package]]
name = "derivative"
version = "2.2.0"
@ -525,7 +541,7 @@ checksum = "eecf8589574ce9b895052fa12d69af7a233f99e6107f5cb8dd1044f2a17bfdcb"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.27",
"syn 2.0.32",
]
[[package]]
@ -685,7 +701,7 @@ checksum = "89ca545a94061b6365f2c7355b4b32bd20df3ff95f02da9329b34ccc3bd6ee72"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.27",
"syn 2.0.32",
]
[[package]]
@ -1112,6 +1128,7 @@ checksum = "2dffe52ecf27772e601905b7522cb4ef790d2cc203488bbd0e2fe85fcb74566d"
name = "memejoin-rs"
version = "0.2.2-alpha"
dependencies = [
"anyhow",
"async-trait",
"axum",
"axum-extra",
@ -1225,6 +1242,12 @@ dependencies = [
"winapi",
]
[[package]]
name = "num-conv"
version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "51d515d32fb182ee37cda2ccdcb92950d6a3c2893aa280e540671c2cd0f3b1d9"
[[package]]
name = "num-traits"
version = "0.1.43"
@ -1297,7 +1320,7 @@ checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.27",
"syn 2.0.32",
]
[[package]]
@ -1379,7 +1402,7 @@ checksum = "ec2e072ecce94ec471b13398d5402c188e76ac03cf74dd1a975161b23a3f6d9c"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.27",
"syn 2.0.32",
]
[[package]]
@ -1450,6 +1473,12 @@ dependencies = [
"universal-hash 0.5.1",
]
[[package]]
name = "powerfmt"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391"
[[package]]
name = "ppv-lite86"
version = "0.2.17"
@ -1772,9 +1801,9 @@ dependencies = [
[[package]]
name = "serde"
version = "1.0.177"
version = "1.0.193"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "63ba2516aa6bf82e0b19ca8b50019d52df58455d3cf9bdaf6315225fdd0c560a"
checksum = "25dd9975e68d0cb5aa1120c288333fc98731bd1dd12f561e468ea4728c042b89"
dependencies = [
"serde_derive",
]
@ -1791,13 +1820,13 @@ dependencies = [
[[package]]
name = "serde_derive"
version = "1.0.177"
version = "1.0.193"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "401797fe7833d72109fedec6bfcbe67c0eed9b99772f26eb8afd261f0abc6fd3"
checksum = "43576ca501357b9b071ac53cdc7da8ef0cbd9493d8df094cd821777ea6e894d3"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.27",
"syn 2.0.32",
]
[[package]]
@ -1829,7 +1858,7 @@ checksum = "8725e1dfadb3a50f7e5ce0b1a540466f6ed3fe7a0fca2ac2b8b831d31316bd00"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.27",
"syn 2.0.32",
]
[[package]]
@ -1867,7 +1896,7 @@ dependencies = [
"serde",
"serde-value",
"serde_json",
"time 0.3.23",
"time 0.3.41",
"tokio",
"tracing",
"typemap_rev",
@ -2043,9 +2072,9 @@ dependencies = [
[[package]]
name = "syn"
version = "2.0.27"
version = "2.0.32"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b60f673f44a8255b9c8c657daf66a596d435f2da81a555b06dc644d080ba45e0"
checksum = "239814284fd6f1a4ffe4ca893952cdd93c224b6a1571c9a9eadd670295c0c9e2"
dependencies = [
"proc-macro2",
"quote",
@ -2088,7 +2117,7 @@ checksum = "090198534930841fab3a5d1bb637cde49e339654e606195f8d9c76eeb081dc96"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.27",
"syn 2.0.32",
]
[[package]]
@ -2114,11 +2143,14 @@ dependencies = [
[[package]]
name = "time"
version = "0.3.23"
version = "0.3.41"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "59e399c068f43a5d116fedaf73b203fa4f9c519f17e2b34f63221d3792f81446"
checksum = "8a7619e19bc266e0f9c5e6686659d394bc57973859340060a69221e57dbc0c40"
dependencies = [
"deranged",
"itoa",
"num-conv",
"powerfmt",
"serde",
"time-core",
"time-macros",
@ -2126,16 +2158,17 @@ dependencies = [
[[package]]
name = "time-core"
version = "0.1.1"
version = "0.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7300fbefb4dadc1af235a9cef3737cea692a9d97e1b9cbcd4ebdae6f8868e6fb"
checksum = "c9e9a38711f559d9e3ce1cdb06dd7c5b8ea546bc90052da6d06bb76da74bb07c"
[[package]]
name = "time-macros"
version = "0.2.10"
version = "0.2.22"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "96ba15a897f3c86766b757e5ac7221554c6750054d74d5b28844fce5fb36a6c4"
checksum = "3526739392ec93fd8b359c8e98514cb3e8e021beb4e5f597b00a0221f8ed8a49"
dependencies = [
"num-conv",
"time-core",
]
@ -2181,7 +2214,7 @@ checksum = "630bdcf245f78637c13ec01ffae6187cca34625e8c63150d424b59e55af2675e"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.27",
"syn 2.0.32",
]
[[package]]
@ -2296,7 +2329,7 @@ checksum = "5f4f31f56159e98206da9efd823404b79b6ef3143b4a7ab76e67b1751b25a4ab"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.27",
"syn 2.0.32",
]
[[package]]
@ -2539,7 +2572,7 @@ dependencies = [
"once_cell",
"proc-macro2",
"quote",
"syn 2.0.27",
"syn 2.0.32",
"wasm-bindgen-shared",
]
@ -2573,7 +2606,7 @@ checksum = "54681b18a46765f095758388f2d0cf16eb8d4169b639ab575a8f5693af210c7b"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.27",
"syn 2.0.32",
"wasm-bindgen-backend",
"wasm-bindgen-shared",
]

View File

@ -6,6 +6,7 @@ edition = "2021"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[dependencies]
anyhow = "1.0.100"
async-trait = "0.1.72"
axum = { version = "0.6.9", features = ["headers", "multipart"] }
axum-extra = { version = "0.7.5", features = ["cookie-private", "cookie"] }

View File

@ -5,29 +5,11 @@
"systems": "systems"
},
"locked": {
"lastModified": 1710146030,
"narHash": "sha256-SZ5L6eA7HJ/nmkzGG7/ISclqe6oZdOZTNoesiInkXPQ=",
"lastModified": 1731533236,
"narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=",
"owner": "numtide",
"repo": "flake-utils",
"rev": "b1d9ab70662946ef0850d488da1c9019f3a9752a",
"type": "github"
},
"original": {
"owner": "numtide",
"repo": "flake-utils",
"type": "github"
}
},
"flake-utils_2": {
"inputs": {
"systems": "systems_2"
},
"locked": {
"lastModified": 1705309234,
"narHash": "sha256-uNRRNRKmJyCRC/8y1RqBkqWBLM034y4qN7EprSdmgyA=",
"owner": "numtide",
"repo": "flake-utils",
"rev": "1ef2e671c3b0c19053962c07dbda38332dcebf26",
"rev": "11707dc2f618dd54ca8739b309ec4fc024de578b",
"type": "github"
},
"original": {
@ -38,11 +20,11 @@
},
"nixpkgs": {
"locked": {
"lastModified": 1717786204,
"narHash": "sha256-4q0s6m0GUcN7q+Y2DqD27iLvbcd1G50T2lv08kKxkSI=",
"lastModified": 1743583204,
"narHash": "sha256-F7n4+KOIfWrwoQjXrL2wD9RhFYLs2/GGe/MQY1sSdlE=",
"owner": "nixos",
"repo": "nixpkgs",
"rev": "051f920625ab5aabe37c920346e3e69d7d34400e",
"rev": "2c8d3f48d33929642c1c12cd243df4cc7d2ce434",
"type": "github"
},
"original": {
@ -54,11 +36,11 @@
},
"nixpkgs_2": {
"locked": {
"lastModified": 1706487304,
"narHash": "sha256-LE8lVX28MV2jWJsidW13D2qrHU/RUUONendL2Q/WlJg=",
"lastModified": 1744536153,
"narHash": "sha256-awS2zRgF4uTwrOKwwiJcByDzDOdo3Q1rPZbiHQg/N38=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "90f456026d284c22b3e3497be980b2e47d0b28ac",
"rev": "18dd725c29603f582cf1900e0d25f9f1063dbf11",
"type": "github"
},
"original": {
@ -77,15 +59,14 @@
},
"rust-overlay": {
"inputs": {
"flake-utils": "flake-utils_2",
"nixpkgs": "nixpkgs_2"
},
"locked": {
"lastModified": 1717985971,
"narHash": "sha256-24h/qKp0aeI+Ew13WdRF521kY24PYa5HOvw0mlrABjk=",
"lastModified": 1759718104,
"narHash": "sha256-TbkLsgdnXHUXR4gOQBmhxkEE9ne+eHmX1chZHWRogy0=",
"owner": "oxalica",
"repo": "rust-overlay",
"rev": "abfe5b3126b1b7e9e4daafc1c6478d17f0b584e7",
"rev": "edea9f33f9a03f615ad3609a40fbcefe0ec835ca",
"type": "github"
},
"original": {
@ -108,21 +89,6 @@
"repo": "default",
"type": "github"
}
},
"systems_2": {
"locked": {
"lastModified": 1681028828,
"narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=",
"owner": "nix-systems",
"repo": "default",
"rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e",
"type": "github"
},
"original": {
"owner": "nix-systems",
"repo": "default",
"type": "github"
}
}
},
"root": "root",

View File

@ -24,7 +24,7 @@
};
});
local-rust = (pkgs.rust-bin.fromRustupToolchainFile ./rust-toolchain).override {
extensions = [ "rust-analysis" ];
extensions = [ "rust-analyzer" "rust-src" ];
};
in
{

View File

@ -0,0 +1,125 @@
use chrono::{Duration, Utc};
use crate::lib::domain::intro_tool::{
models,
ports::{IntroToolRepository, IntroToolService},
};
#[derive(Clone)]
pub struct DebugService<S>
where
S: IntroToolService,
{
impersonated_username: String,
wrapped_service: S,
}
impl<S> DebugService<S>
where
S: IntroToolService,
{
pub fn new(wrapped_service: S, impersonated_username: String) -> Self {
Self {
wrapped_service,
impersonated_username,
}
}
}
impl<S> IntroToolService for DebugService<S>
where
S: IntroToolService,
{
async fn needs_setup(&self) -> bool {
self.wrapped_service.needs_setup().await
}
async fn get_guild(
&self,
guild_id: impl Into<models::guild::GuildId> + Send,
) -> Result<models::guild::Guild, models::guild::GetGuildError> {
self.wrapped_service.get_guild(guild_id).await
}
async fn get_guild_users(
&self,
guild_id: models::guild::GuildId,
) -> Result<Vec<models::guild::User>, models::guild::GetUserError> {
self.wrapped_service.get_guild_users(guild_id).await
}
async fn get_guild_intros(
&self,
guild_id: models::guild::GuildId,
) -> Result<Vec<models::guild::Intro>, models::guild::GetIntroError> {
self.wrapped_service.get_guild_intros(guild_id).await
}
async fn get_user(
&self,
username: impl AsRef<str> + Send,
) -> Result<models::guild::User, models::guild::GetUserError> {
self.wrapped_service.get_user(username).await
}
async fn get_user_guilds(
&self,
username: impl AsRef<str> + Send,
) -> Result<Vec<models::guild::GuildRef>, models::guild::GetGuildError> {
self.wrapped_service.get_user_guilds(username).await
}
async fn get_user_from_api_key(
&self,
_api_key: &str,
) -> Result<models::guild::User, models::guild::GetUserError> {
let user = self
.wrapped_service
.get_user(&self.impersonated_username)
.await?;
Ok(models::guild::User::new(
self.impersonated_username.clone(),
"testApiKey".into(),
Utc::now().naive_utc() + Duration::days(1),
"testDiscordToken".into(),
Utc::now().naive_utc() + Duration::days(1),
)
.with_channel_intros(user.intros().clone()))
}
async fn create_guild(
&self,
req: models::guild::CreateGuildRequest,
) -> Result<models::guild::Guild, models::guild::CreateGuildError> {
self.wrapped_service.create_guild(req).await
}
async fn create_user(
&self,
req: models::guild::CreateUserRequest,
) -> Result<models::guild::User, models::guild::CreateUserError> {
self.wrapped_service.create_user(req).await
}
async fn create_channel(
&self,
req: models::guild::CreateChannelRequest,
) -> Result<models::guild::Channel, models::guild::CreateChannelError> {
self.wrapped_service.create_channel(req).await
}
async fn add_intro_to_guild(
&self,
req: models::guild::AddIntroToGuildRequest,
) -> Result<(), models::guild::AddIntroToGuildError> {
self.wrapped_service.add_intro_to_guild(req).await
}
async fn add_intro_to_user(
&self,
req: models::guild::AddIntroToUserRequest,
) -> Result<(), models::guild::AddIntroToUserError> {
self.wrapped_service.add_intro_to_user(req).await
}
}

View File

@ -0,0 +1,4 @@
pub mod debug_service;
pub mod models;
pub mod ports;
pub mod service;

View File

@ -0,0 +1,350 @@
use std::collections::HashMap;
use chrono::NaiveDateTime;
use thiserror::Error;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub struct GuildId(u64);
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub struct ExternalGuildId(u64);
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub struct UserName(String);
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub struct ChannelName(String);
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub struct IntroId(i32);
impl From<u64> for GuildId {
fn from(id: u64) -> Self {
Self(id)
}
}
impl From<u64> for ExternalGuildId {
fn from(id: u64) -> Self {
Self(id)
}
}
impl From<i32> for IntroId {
fn from(id: i32) -> Self {
Self(id)
}
}
impl From<String> for UserName {
fn from(name: String) -> Self {
Self(name)
}
}
impl From<String> for ChannelName {
fn from(name: String) -> Self {
Self(name)
}
}
impl AsRef<str> for UserName {
fn as_ref(&self) -> &str {
&self.0
}
}
impl AsRef<str> for ChannelName {
fn as_ref(&self) -> &str {
&self.0
}
}
impl std::fmt::Display for GuildId {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}", self.0)
}
}
impl std::fmt::Display for IntroId {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}", self.0)
}
}
#[derive(Debug)]
pub struct Guild {
guild: GuildRef,
channels: Vec<Channel>,
users: Vec<User>,
}
#[derive(Debug)]
pub struct GuildRef {
id: GuildId,
name: String,
sound_delay: u32,
external_id: ExternalGuildId,
}
impl GuildRef {
pub fn id(&self) -> GuildId {
self.id
}
pub fn name(&self) -> &str {
&self.name
}
}
impl GuildRef {
pub fn new(id: GuildId, name: String, sound_delay: u32, external_id: ExternalGuildId) -> Self {
Self {
id,
name,
sound_delay,
external_id,
}
}
}
impl Guild {
pub fn new(id: GuildId, name: String, sound_delay: u32, external_id: ExternalGuildId) -> Self {
Self {
guild: GuildRef {
id,
name,
sound_delay,
external_id,
},
channels: vec![],
users: vec![],
}
}
pub fn id(&self) -> GuildId {
self.guild.id()
}
pub fn name(&self) -> &str {
self.guild.name()
}
pub fn users(&self) -> &[User] {
&self.users
}
pub fn channels(&self) -> &[Channel] {
&self.channels
}
pub fn with_users(self, users: Vec<User>) -> Self {
Self { users, ..self }
}
pub fn with_channels(self, channels: Vec<Channel>) -> Self {
Self { channels, ..self }
}
}
#[derive(Debug)]
pub struct User {
name: UserName,
api_key: String,
api_key_expires_at: NaiveDateTime,
discord_token: String,
discord_token_expires_at: NaiveDateTime,
channel_intros: HashMap<(GuildId, ChannelName), Vec<Intro>>,
}
impl User {
pub fn new(
name: impl Into<UserName>,
api_key: String,
api_key_expires_at: NaiveDateTime,
discord_token: String,
discord_token_expires_at: NaiveDateTime,
) -> Self {
Self {
name: name.into(),
api_key,
api_key_expires_at,
discord_token,
discord_token_expires_at,
channel_intros: HashMap::new(),
}
}
pub fn name(&self) -> &str {
&self.name.0
}
pub fn intros(&self) -> &HashMap<(GuildId, ChannelName), Vec<Intro>> {
&self.channel_intros
}
pub fn api_key_expires_at(&self) -> NaiveDateTime {
self.api_key_expires_at
}
pub fn discord_token_expires_at(&self) -> NaiveDateTime {
self.discord_token_expires_at
}
pub fn with_channel_intros(
self,
channel_intros: HashMap<(GuildId, ChannelName), Vec<Intro>>,
) -> Self {
Self {
channel_intros,
..self
}
}
}
#[derive(Debug)]
pub struct Channel {
name: ChannelName,
}
impl Channel {
pub fn new(name: ChannelName) -> Self {
Self { name }
}
pub fn name(&self) -> &ChannelName {
&self.name
}
}
#[derive(Debug, Clone)]
pub struct Intro {
id: IntroId,
name: String,
filename: String,
}
impl Intro {
pub fn new(id: IntroId, name: String, filename: String) -> Self {
Self { id, name, filename }
}
pub fn id(&self) -> IntroId {
self.id
}
pub fn name(&self) -> &str {
&self.name
}
}
pub struct CreateGuildRequest {
name: String,
sound_delay: u32,
external_id: ExternalGuildId,
}
pub struct CreateUserRequest {
user: UserName,
}
pub struct CreateChannelRequest {
guild_id: GuildId,
channel_name: ChannelName,
}
pub struct AddIntroToGuildRequest {
guild_id: GuildId,
name: String,
volume: i32,
filename: String,
}
pub struct AddIntroToUserRequest {
user: UserName,
guild_id: GuildId,
channel_name: ChannelName,
intro_id: IntroId,
}
#[derive(Debug, Error)]
pub enum CreateGuildError {
#[error(transparent)]
Unknown(#[from] anyhow::Error),
}
#[derive(Debug, Error)]
pub enum CreateUserError {
#[error(transparent)]
Unknown(#[from] anyhow::Error),
}
#[derive(Debug, Error)]
pub enum CreateChannelError {
#[error(transparent)]
Unknown(#[from] anyhow::Error),
}
#[derive(Debug, Error)]
pub enum AddIntroToGuildError {
#[error(transparent)]
Unknown(#[from] anyhow::Error),
}
#[derive(Debug, Error)]
pub enum AddIntroToUserError {
#[error(transparent)]
Unknown(#[from] anyhow::Error),
}
#[derive(Debug, Error)]
pub enum GetGuildError {
#[error("Guild not found")]
NotFound,
#[error("Could not fetch guild users")]
CouldNotFetchUsers(#[from] GetUserError),
#[error("Could not fetch guild channels")]
CouldNotFetchChannels(#[from] GetChannelError),
#[error(transparent)]
Unknown(#[from] anyhow::Error),
}
#[derive(Debug, Error)]
pub enum GetUserError {
#[error("User not found")]
NotFound,
#[error("Could not fetch user guilds")]
CouldNotFetchGuilds(#[from] Box<GetGuildError>),
#[error("Could not fetch user channel intros")]
CouldNotFetchChannelIntros(#[from] GetIntroError),
#[error(transparent)]
Unknown(#[from] anyhow::Error),
}
#[derive(Debug, Error)]
pub enum GetChannelError {
#[error("Channel not found")]
NotFound,
#[error(transparent)]
Unknown(#[from] anyhow::Error),
}
#[derive(Debug, Error)]
pub enum GetIntroError {
#[error("Intro not found")]
NotFound,
#[error(transparent)]
Unknown(#[from] anyhow::Error),
}

View File

@ -0,0 +1 @@
pub mod guild;

View File

@ -0,0 +1,116 @@
use std::{collections::HashMap, future::Future};
use crate::lib::domain::intro_tool::models::guild::ChannelName;
use super::models::guild::{
AddIntroToGuildError, AddIntroToGuildRequest, AddIntroToUserError, AddIntroToUserRequest,
Channel, CreateChannelError, CreateChannelRequest, CreateGuildError, CreateGuildRequest,
CreateUserError, CreateUserRequest, GetChannelError, GetGuildError, GetIntroError,
GetUserError, Guild, GuildId, GuildRef, Intro, User,
};
pub trait IntroToolService: Send + Sync + Clone + 'static {
fn needs_setup(&self) -> impl Future<Output = bool> + Send;
fn get_guild(
&self,
guild_id: impl Into<GuildId> + Send,
) -> impl Future<Output = Result<Guild, GetGuildError>> + Send;
fn get_guild_users(
&self,
guild_id: GuildId,
) -> impl Future<Output = Result<Vec<User>, GetUserError>> + Send;
fn get_guild_intros(
&self,
guild_id: GuildId,
) -> impl Future<Output = Result<Vec<Intro>, GetIntroError>> + Send;
fn get_user(
&self,
username: impl AsRef<str> + Send,
) -> impl Future<Output = Result<User, GetUserError>> + Send;
fn get_user_guilds(
&self,
username: impl AsRef<str> + Send,
) -> impl Future<Output = Result<Vec<GuildRef>, GetGuildError>> + Send;
fn get_user_from_api_key(
&self,
api_key: &str,
) -> impl Future<Output = Result<User, GetUserError>> + Send;
async fn create_guild(&self, req: CreateGuildRequest) -> Result<Guild, CreateGuildError>;
async fn create_user(&self, req: CreateUserRequest) -> Result<User, CreateUserError>;
async fn create_channel(
&self,
req: CreateChannelRequest,
) -> Result<Channel, CreateChannelError>;
async fn add_intro_to_guild(
&self,
req: AddIntroToGuildRequest,
) -> Result<(), AddIntroToGuildError>;
async fn add_intro_to_user(
&self,
req: AddIntroToUserRequest,
) -> Result<(), AddIntroToUserError>;
}
pub trait IntroToolRepository: Send + Sync + Clone + 'static {
fn get_guild(
&self,
guild_id: GuildId,
) -> impl Future<Output = Result<Guild, GetGuildError>> + Send;
fn get_guild_count(&self) -> impl Future<Output = Result<usize, GetGuildError>> + Send;
fn get_guild_users(
&self,
guild_id: GuildId,
) -> impl Future<Output = Result<Vec<User>, GetUserError>> + Send;
fn get_guild_channels(
&self,
guild_id: GuildId,
) -> impl Future<Output = Result<Vec<Channel>, GetChannelError>> + Send;
fn get_guild_intros(
&self,
guild_id: GuildId,
) -> impl Future<Output = Result<Vec<Intro>, GetIntroError>> + Send;
fn get_user(
&self,
username: impl AsRef<str> + Send,
) -> impl Future<Output = Result<User, GetUserError>> + Send;
fn get_user_channel_intros(
&self,
username: impl AsRef<str> + Send,
guild_id: GuildId,
) -> impl Future<Output = Result<HashMap<(GuildId, ChannelName), Vec<Intro>>, GetIntroError>> + Send;
fn get_user_guilds(
&self,
username: impl AsRef<str> + Send,
) -> impl Future<Output = Result<Vec<GuildRef>, GetGuildError>> + Send;
fn get_user_from_api_key(
&self,
api_key: &str,
) -> impl Future<Output = Result<User, GetUserError>> + Send;
async fn create_guild(&self, req: CreateGuildRequest) -> Result<Guild, CreateGuildError>;
async fn create_user(&self, req: CreateUserRequest) -> Result<User, CreateUserError>;
async fn create_channel(
&self,
req: CreateChannelRequest,
) -> Result<Channel, CreateChannelError>;
async fn add_intro_to_guild(
&self,
req: AddIntroToGuildRequest,
) -> Result<(), AddIntroToGuildError>;
async fn add_intro_to_user(
&self,
req: AddIntroToUserRequest,
) -> Result<(), AddIntroToUserError>;
}

View File

@ -0,0 +1,106 @@
use crate::lib::domain::intro_tool::{
models::guild::{GetUserError, GuildId, User},
ports::{IntroToolRepository, IntroToolService},
};
use super::models;
#[derive(Clone)]
pub struct Service<R>
where
R: IntroToolRepository,
{
repo: R,
}
impl<R> Service<R>
where
R: IntroToolRepository,
{
pub fn new(repo: R) -> Self {
Self { repo }
}
}
impl<R> IntroToolService for Service<R>
where
R: IntroToolRepository,
{
async fn needs_setup(&self) -> bool {
let Ok(guild_count) = self.repo.get_guild_count().await else {
return false;
};
guild_count == 0
}
async fn get_guild(
&self,
guild_id: impl Into<GuildId>,
) -> Result<models::guild::Guild, models::guild::GetGuildError> {
self.repo.get_guild(guild_id.into()).await
}
async fn get_guild_users(&self, guild_id: GuildId) -> Result<Vec<User>, GetUserError> {
self.repo.get_guild_users(guild_id).await
}
async fn get_guild_intros(
&self,
guild_id: GuildId,
) -> Result<Vec<models::guild::Intro>, models::guild::GetIntroError> {
self.repo.get_guild_intros(guild_id).await
}
async fn get_user(
&self,
username: impl AsRef<str> + Send,
) -> Result<models::guild::User, models::guild::GetUserError> {
self.repo.get_user(username).await
}
async fn get_user_guilds(
&self,
username: impl AsRef<str> + Send,
) -> Result<Vec<models::guild::GuildRef>, models::guild::GetGuildError> {
self.repo.get_user_guilds(username).await
}
async fn get_user_from_api_key(&self, api_key: &str) -> Result<User, GetUserError> {
self.repo.get_user_from_api_key(api_key).await
}
async fn create_guild(
&self,
req: models::guild::CreateGuildRequest,
) -> Result<models::guild::Guild, models::guild::CreateGuildError> {
self.repo.create_guild(req).await
}
async fn create_user(
&self,
req: models::guild::CreateUserRequest,
) -> Result<models::guild::User, models::guild::CreateUserError> {
self.repo.create_user(req).await
}
async fn create_channel(
&self,
req: models::guild::CreateChannelRequest,
) -> Result<models::guild::Channel, models::guild::CreateChannelError> {
self.repo.create_channel(req).await
}
async fn add_intro_to_guild(
&self,
req: models::guild::AddIntroToGuildRequest,
) -> Result<(), models::guild::AddIntroToGuildError> {
self.repo.add_intro_to_guild(req).await
}
async fn add_intro_to_user(
&self,
req: models::guild::AddIntroToUserRequest,
) -> Result<(), models::guild::AddIntroToUserError> {
self.repo.add_intro_to_user(req).await
}
}

1
src/lib/domain/mod.rs Normal file
View File

@ -0,0 +1 @@
pub mod intro_tool;

2
src/lib/inbound.rs Normal file
View File

@ -0,0 +1,2 @@
pub mod http;
pub mod response;

146
src/lib/inbound/http.rs Normal file
View File

@ -0,0 +1,146 @@
mod page;
use std::{net::SocketAddr, sync::Arc};
use axum::{
extract::FromRequestParts,
http::request::Parts,
response::Redirect,
routing::{get, post},
};
use axum_extra::extract::CookieJar;
use chrono::Utc;
use reqwest::Method;
use tower_http::cors::CorsLayer;
use tracing::info;
use crate::{
auth,
lib::domain::intro_tool::{models::guild::User, ports::IntroToolService},
};
#[derive(Clone)]
pub(crate) struct ApiState<S>
where
S: IntroToolService,
{
intro_tool_service: Arc<S>,
pub secrets: auth::DiscordSecret,
pub origin: String,
}
#[axum::async_trait]
impl<S: IntroToolService> FromRequestParts<ApiState<S>> for User {
type Rejection = Redirect;
async fn from_request_parts(
Parts { headers, .. }: &mut Parts,
state: &ApiState<S>,
) -> Result<Self, Self::Rejection> {
let jar = CookieJar::from_headers(headers);
if let Some(token) = jar.get("access_token") {
match state
.intro_tool_service
.get_user_from_api_key(token.value())
.await
{
Ok(user) => {
let now = Utc::now().naive_utc();
if user.api_key_expires_at() < now || user.discord_token_expires_at() < now {
Err(Redirect::to(&format!("{}/login", state.origin)))
} else {
Ok(user)
}
}
Err(err) => {
tracing::error!(?err, "failed to authenticate user");
Err(Redirect::to(&format!("{}/login", state.origin)))
}
}
} else {
Err(Redirect::to(&format!("{}/login", state.origin)))
}
}
}
pub struct HttpServer {
make_service: axum::routing::IntoMakeService<axum::Router>,
}
impl HttpServer {
pub fn new(
intro_tool_service: impl IntroToolService,
secrets: auth::DiscordSecret,
origin: String,
) -> anyhow::Result<Self> {
let state = ApiState {
intro_tool_service: Arc::new(intro_tool_service),
secrets,
origin: origin.clone(),
};
let router = routes()
.layer(
CorsLayer::new()
.allow_origin([origin.parse().unwrap()])
.allow_headers(tower_http::cors::Any)
.allow_methods([Method::GET, Method::POST, Method::DELETE]),
)
.with_state(state);
Ok(Self {
make_service: router.into_make_service(),
})
}
pub async fn run(self) {
let addr = SocketAddr::from(([0, 0, 0, 0], 8100));
info!("socket listening on {addr}");
axum::Server::bind(&addr)
.serve(self.make_service)
.await
.expect("couldn't start http server");
}
}
fn routes<S>() -> axum::Router<ApiState<S>>
where
S: IntroToolService,
{
axum::Router::<ApiState<S>>::new()
.route("/", get(page::home))
.route("/login", get(page::login))
.route("/guild/:guild_id", get(page::guild_dashboard))
// .route("/", get(page::home))
// .route("/index.html", get(page::home))
// .route("/login", get(page::login))
// .route("/guild/:guild_id", get(page::guild_dashboard))
// .route("/guild/:guild_id/setup", get(routes::guild_setup))
// .route(
// "/guild/:guild_id/add_channel",
// post(routes::guild_add_channel),
// )
// .route(
// "/guild/:guild_id/permissions/update",
// post(routes::update_guild_permissions),
// )
// .route("/v2/auth", get(routes::v2_auth))
// .route(
// "/v2/intros/add/:guild_id/:channel",
// post(routes::v2_add_intro_to_user),
// )
// .route(
// "/v2/intros/remove/:guild_id/:channel",
// post(routes::v2_remove_intro_from_user),
// )
// .route("/v2/intros/:guild/add", get(routes::v2_add_guild_intro))
// .route(
// "/v2/intros/:guild/upload",
// post(routes::v2_upload_guild_intro),
// )
// .route("/health", get(routes::health))
}

View File

@ -0,0 +1,292 @@
use axum::{
extract::{Path, State},
response::{Html, Redirect},
};
use crate::{
htmx::{Build, HtmxBuilder, Tag},
lib::{
domain::intro_tool::{
models::guild::{ChannelName, GuildRef, Intro, User},
ports::IntroToolService,
},
inbound::{http::ApiState, response::ErrorAsRedirect},
},
};
pub async fn home<S: IntroToolService>(
State(state): State<ApiState<S>>,
user: Option<User>,
) -> Result<impl axum::response::IntoResponse, Redirect> {
if let Some(user) = user {
let needs_setup = state.intro_tool_service.needs_setup().await;
let user_guilds = state
.intro_tool_service
.get_user_guilds(user.name())
.await
.as_redirect(&state.origin, "/login")?;
// TODO: get user app permissions
// TODO: check if user can add guilds
// TODO: fetch guilds from discord
let can_add_guild = false;
let discord_guilds: Vec<GuildRef> = vec![];
let guild_list = if needs_setup {
// TODO:
// HtmxBuilder::new(Tag::Empty).builder(Tag::Div, |b| {
// b.attribute("class", "container")
// .builder_text(Tag::Header2, "Select a Guild to setup")
// .push_builder(setup_guild_list(&state.origin, &discord_guilds))
// })
todo!()
} else {
HtmxBuilder::new(Tag::Empty).builder(Tag::Div, |b| {
b.attribute("class", "container")
.builder_text(Tag::Header2, "Choose a Guild")
.push_builder(guild_list(&state.origin, user_guilds.iter()))
})
};
Ok(Html(
page_header("MemeJoin - Home")
.builder(Tag::Div, |b| {
b.push_builder(guild_list)
// TODO:
// let mut b = b.push_builder(guild_list);
//
// if !needs_setup && can_add_guild && !discord_guilds.is_empty() {
// b = b
// .attribute("class", "container")
// .builder_text(Tag::Header2, "Add a Guild")
// .push_builder(setup_guild_list(&state.origin, &discord_guilds));
// }
//
// b
})
.build(),
))
} else {
Err(Redirect::to(&format!("{}/login", state.origin)))
}
}
pub async fn login<S: IntroToolService>(
State(state): State<ApiState<S>>,
user: Option<User>,
) -> Result<Html<String>, Redirect> {
if user.is_some() {
Err(Redirect::to(&format!("{}/", state.origin)))
} else {
let authorize_uri = format!("https://discord.com/api/oauth2/authorize?client_id={}&redirect_uri={}/v2/auth&response_type=code&scope=guilds.members.read+guilds+identify", state.secrets.client_id, state.origin);
Ok(Html(
HtmxBuilder::new(Tag::Html)
.push_builder(page_header("MemeJoin - Dashboard"))
.builder(Tag::Nav, |b| {
b.builder(Tag::HeaderGroup, |b| {
b.attribute("class", "container")
.builder(Tag::Header1, |b| b.text("MemeJoin - A bot for user intros"))
.builder_text(Tag::Header6, "salad")
})
})
.builder(Tag::Main, |b| {
b.attribute("class", "container").builder(Tag::Anchor, |b| {
b.attribute("role", "button")
.text("Login with Discord")
.attribute("href", &authorize_uri)
})
})
.build(),
))
}
}
pub async fn guild_dashboard<S: IntroToolService>(
State(state): State<ApiState<S>>,
user: User,
Path(guild_id): Path<u64>,
) -> Result<Html<String>, Redirect> {
let guild = state
.intro_tool_service
.get_guild(guild_id)
.await
.as_redirect(&state.origin, "/login")?;
let user_guilds = state
.intro_tool_service
.get_user_guilds(user.name())
.await
.as_redirect(&state.origin, "/login")?;
let guild_intros = state
.intro_tool_service
.get_guild_intros(guild_id.into())
.await
.as_redirect(&state.origin, "/login")?;
// does user have access to this guild
if !user_guilds
.iter()
.any(|guild_ref| guild_ref.id() == guild.id())
{
return Err(Redirect::to(&format!("{}/error", state.origin)));
}
Ok(Html(
HtmxBuilder::new(Tag::Html)
.push_builder(page_header("MemeJoin - Dashboard"))
.builder(Tag::Nav, |b| {
b.builder(Tag::HeaderGroup, |b| {
b.attribute("class", "container")
.builder(Tag::Header1, |b| b.text("MemeJoin - A bot for user intros"))
.builder_text(Tag::Header6, &format!("{} - {}", user.name(), guild.name()))
})
})
.builder(Tag::Empty, |b| {
// TODO:
// let mut b = if is_moderator || can_add_channel {
// b.builder(Tag::Div, |b| {
// b.attribute("class", "container")
// .builder(Tag::Article, |b| {
// b.builder_text(Tag::Header, "Server Settings")
// .push_builder(mod_dashboard)
// })
// })
// } else {
// b
// };
// b = if can_upload {
// b.builder(Tag::Div, |b| {
// b.attribute("class", "container")
// .builder(Tag::Article, |b| {
// b.builder_text(Tag::Header, "Upload New Intro")
// .push_builder(upload_form(&state.origin, guild_id))
// })
// })
// .builder(Tag::Div, |b| {
// b.attribute("class", "container")
// .builder(Tag::Article, |b| {
// b.builder_text(Tag::Header, "Upload New Intro from Url")
// .push_builder(ytdl_form(&state.origin, guild_id))
// })
// })
// } else {
// b
// };
b.builder(Tag::Div, |b| {
b.attribute("class", "container")
.builder(Tag::Article, |b| {
let mut b = b.builder_text(Tag::Header, "Guild Intros");
for guild_channel in guild.channels() {
let intros = user.intros().get(&(guild.id(), guild_channel.name().clone())).map(|intros| intros.iter()).unwrap_or_default();
b = b.builder(Tag::Details, |b| {
let mut b = b;
if guild.channels().len() < 2 {
b = b.attribute("open", "");
}
b.builder_text(Tag::Summary, guild_channel.name().as_ref()).builder(
Tag::Div,
|b| {
b.attribute("id", "channel-intro-selector")
.attribute("style", "display: flex; align-items: flex-end; max-height: 50%; overflow: hidden;")
.push_builder(channel_intro_selector(
&state.origin,
guild_id,
guild_channel.name(),
intros,
guild_intros.iter(),
))
},
)
});
}
b
})
})
})
.build(),
))
}
fn page_header(title: &str) -> HtmxBuilder {
HtmxBuilder::new(Tag::Html).head(|b| {
b.title(title)
.script(
"https://unpkg.com/htmx.org@1.9.3",
Some("sha384-lVb3Rd/Ca0AxaoZg5sACe8FJKF0tnUgR2Kd7ehUOG5GCcROv5uBIZsOqovBAcWua"),
)
// Not currently using
// .script("https://unpkg.com/hyperscript.org@0.9.9", None)
.style_link("https://cdn.jsdelivr.net/npm/@picocss/pico@2/css/pico.min.css")
})
}
fn guild_list<'a>(origin: &str, guilds: impl Iterator<Item = &'a GuildRef>) -> HtmxBuilder {
HtmxBuilder::new(Tag::Empty).ul(|b| {
let mut b = b;
for guild in guilds {
b = b.li(|b| b.link(guild.name(), &format!("{}/guild/{}", origin, guild.id())));
}
b
})
}
pub fn channel_intro_selector<'a>(
origin: &str,
guild_id: u64,
channel_name: &ChannelName,
intros: impl Iterator<Item = &'a Intro>,
guild_intros: impl Iterator<Item = &'a Intro>,
) -> HtmxBuilder {
HtmxBuilder::new(Tag::Empty)
.builder(Tag::Div, |b| {
b.attribute("style", "display: flex; flex-direction: column; justify-content: space-between; align-items: center; width: 100%; height: 100%; padding: 16px;")
.builder_text(Tag::Strong, "Your Current Intros")
.push_builder(intro_list(
intros,
"Remove Intro",
&format!("{}/v2/intros/remove/{}/{}", origin, guild_id, channel_name.as_ref()),
))
})
.builder(Tag::Div, |b| {
b.attribute("style", "display: flex; flex-direction: column; justify-content: space-between; align-items: center; width: 100%; height: 100%; padding: 16px;")
.builder_text(Tag::Strong, "Select Intros")
.push_builder(intro_list(
guild_intros,
"Add Intro",
&format!("{}/v2/intros/add/{}/{}", origin, guild_id, channel_name.as_ref()),
))
})
}
fn intro_list<'a>(intros: impl Iterator<Item = &'a Intro>, label: &str, post: &str) -> HtmxBuilder {
HtmxBuilder::new(Tag::Empty).form(|b| {
b.attribute("class", "container")
.hx_post(post)
.hx_target("closest #channel-intro-selector")
.attribute("hx-encoding", "multipart/form-data")
.builder(Tag::FieldSet, |b| {
let mut b = b
.attribute("class", "container")
.attribute("style", "height: 256px; overflow: auto");
for intro in intros {
b = b.builder(Tag::Label, |b| {
b.builder(Tag::Input, |b| {
b.attribute("type", "checkbox")
.attribute("name", &intro.id().to_string())
})
.builder_text(Tag::Paragraph, intro.name())
});
}
b
})
.button(|b| b.attribute("type", "submit").text(label))
})
}

View File

@ -0,0 +1,65 @@
use std::fmt::Debug;
use axum::response::Redirect;
use crate::lib::domain::intro_tool::models::guild::{
GetChannelError, GetGuildError, GetIntroError,
};
pub(super) trait ErrorAsRedirect<T>: Sized {
fn as_redirect(self, origin: impl AsRef<str>, path: impl AsRef<str>) -> Result<T, Redirect>;
}
impl<T: Debug> ErrorAsRedirect<T> for Result<T, GetGuildError> {
fn as_redirect(self, origin: impl AsRef<str>, path: impl AsRef<str>) -> Result<T, Redirect> {
match self {
Ok(value) => Ok(value),
Err(GetGuildError::NotFound)
| Err(GetGuildError::CouldNotFetchUsers(_))
| Err(GetGuildError::CouldNotFetchChannels(_))
| Err(GetGuildError::Unknown(_)) => {
tracing::error!(err = ?self, "failed to get guild");
Err(Redirect::to(&format!(
"{}/{}",
origin.as_ref(),
path.as_ref()
)))
}
}
}
}
impl<T: Debug> ErrorAsRedirect<T> for Result<T, GetChannelError> {
fn as_redirect(self, origin: impl AsRef<str>, path: impl AsRef<str>) -> Result<T, Redirect> {
match self {
Ok(value) => Ok(value),
Err(GetChannelError::NotFound) | Err(GetChannelError::Unknown(_)) => {
tracing::error!(err = ?self, "failed to get channel");
Err(Redirect::to(&format!(
"{}/{}",
origin.as_ref(),
path.as_ref()
)))
}
}
}
}
impl<T: Debug> ErrorAsRedirect<T> for Result<T, GetIntroError> {
fn as_redirect(self, origin: impl AsRef<str>, path: impl AsRef<str>) -> Result<T, Redirect> {
match self {
Ok(value) => Ok(value),
Err(GetIntroError::NotFound) | Err(GetIntroError::Unknown(_)) => {
tracing::error!(err = ?self, "failed to get intro");
Err(Redirect::to(&format!(
"{}/{}",
origin.as_ref(),
path.as_ref()
)))
}
}
}
}

3
src/lib/mod.rs Normal file
View File

@ -0,0 +1,3 @@
pub mod domain;
pub mod inbound;
pub mod outbound;

1
src/lib/outbound/mod.rs Normal file
View File

@ -0,0 +1 @@
pub mod sqlite;

361
src/lib/outbound/sqlite.rs Normal file
View File

@ -0,0 +1,361 @@
use iter_tools::Itertools;
use std::{collections::HashMap, sync::Arc};
use tokio::sync::Mutex;
use anyhow::Context;
use rusqlite::Connection;
use crate::lib::domain::intro_tool::{
models::guild::{
self, AddIntroToGuildError, AddIntroToGuildRequest, AddIntroToUserRequest, Channel,
ChannelName, CreateChannelError, CreateChannelRequest, CreateGuildError,
CreateGuildRequest, CreateUserError, CreateUserRequest, GetChannelError, GetGuildError,
GetIntroError, GetUserError, Guild, GuildId, GuildRef, Intro, User, UserName,
},
ports::IntroToolRepository,
};
#[derive(Clone)]
pub struct Sqlite {
conn: Arc<Mutex<Connection>>,
}
impl Sqlite {
pub fn new(path: &str) -> rusqlite::Result<Self> {
Ok(Self {
conn: Arc::new(Mutex::new(Connection::open(path)?)),
})
}
}
impl IntroToolRepository for Sqlite {
async fn get_guild(&self, guild_id: GuildId) -> Result<Guild, GetGuildError> {
let guild = {
let conn = self.conn.lock().await;
let mut query = conn
.prepare(
"
select
Guild.id,
Guild.name,
Guild.sound_delay
from Guild
where Guild.id = :guild_id
",
)
.context("failed to prepare query")?;
query
.query_row(&[(":guild_id", &guild_id.to_string())], |row| {
Ok(Guild::new(
row.get::<_, u64>(0)?.into(),
row.get(1)?,
row.get(2)?,
row.get::<_, u64>(0)?.into(),
))
})
.context("failed to query row")?
};
Ok(guild
.with_users(self.get_guild_users(guild_id).await?)
.with_channels(self.get_guild_channels(guild_id).await?))
}
async fn get_guild_count(&self) -> Result<usize, GetGuildError> {
let conn = self.conn.lock().await;
let mut query = conn
.prepare(
"
select
count(*)
from Guild
",
)
.context("failed to prepare query")?;
Ok(query
.query_row([], |row| row.get::<_, usize>(0))
.context("failed to query row")?)
}
async fn get_guild_users(&self, guild_id: GuildId) -> Result<Vec<User>, GetUserError> {
let conn = self.conn.lock().await;
let mut query = conn
.prepare(
"
SELECT
User.username AS name,
User.api_key,
User.api_key_expires_at,
User.discord_token,
User.discord_token_expires_at
FROM UserGuild
LEFT JOIN User ON User.username = UserGuild.username
WHERE UserGuild.guild_id = :guild_id
",
)
.context("failed to prepare query")?;
let users = query
.query_map(&[(":guild_id", &guild_id.to_string())], |row| {
Ok(User::new(
UserName::from(row.get::<_, String>(0)?),
row.get(1)?,
row.get(2)?,
row.get(3)?,
row.get(4)?,
))
})
.context("failed to map prepared query")?
.collect::<Result<_, _>>()
.context("failed to fetch guild user rows")?;
Ok(users)
}
async fn get_user_guilds(
&self,
username: impl AsRef<str>,
) -> Result<Vec<GuildRef>, GetGuildError> {
let conn = self.conn.lock().await;
let mut query = conn
.prepare(
"
SELECT
Guild.id,
Guild.name,
Guild.sound_delay
FROM Guild
LEFT JOIN UserGuild ON Guild.id = UserGuild.guild_id
LEFT JOIN User ON User.username = UserGuild.username
WHERE User.username = :username
",
)
.context("failed to prepare query")?;
let guilds = query
.query_map(&[(":username", username.as_ref())], |row| {
Ok(GuildRef::new(
row.get::<_, u64>(0)?.into(),
row.get(1)?,
row.get(2)?,
row.get::<_, u64>(0)?.into(),
))
})
.context("failed to map prepared query")?
.collect::<Result<_, _>>()
.context("failed to fetch guild user rows")?;
Ok(guilds)
}
async fn get_guild_channels(&self, guild_id: GuildId) -> Result<Vec<Channel>, GetChannelError> {
let conn = self.conn.lock().await;
let mut query = conn
.prepare(
"
SELECT
Channel.name
FROM Channel
WHERE
Channel.guild_id = :guild_id
ORDER BY Channel.name DESC
",
)
.context("failed to prepare query")?;
let channels = query
.query_map(&[(":guild_id", &guild_id.to_string())], |row| {
Ok(Channel::new(row.get::<_, String>(0)?.into()))
})
.context("failed to map prepared query")?
.collect::<Result<_, _>>()
.context("failed to fetch guild channel rows")?;
Ok(channels)
}
async fn get_guild_intros(&self, guild_id: GuildId) -> Result<Vec<Intro>, GetIntroError> {
let conn = self.conn.lock().await;
let mut query = conn
.prepare(
"
SELECT
Intro.id,
Intro.name,
Intro.filename
FROM Intro
WHERE
Intro.guild_id = :guild_id
",
)
.context("failed to prepare query")?;
let intros = query
.query_map(&[(":guild_id", &guild_id.to_string())], |row| {
Ok(Intro::new(
row.get::<_, i32>(0)?.into(),
row.get(1)?,
row.get(2)?,
))
})
.context("failed to map prepared query")?
.collect::<Result<_, _>>()
.context("failed to fetch guild intro rows")?;
Ok(intros)
}
async fn get_user(&self, username: impl AsRef<str>) -> Result<User, GetUserError> {
let user = {
let conn = self.conn.lock().await;
let mut query = conn
.prepare(
"
SELECT
username AS name, api_key, api_key_expires_at, discord_token, discord_token_expires_at
FROM User
WHERE username = :username
",
)
.context("failed to prepare query")?;
query
.query_row(&[(":username", username.as_ref())], |row| {
Ok(User::new(
UserName::from(row.get::<_, String>(0)?),
row.get(1)?,
row.get(2)?,
row.get(3)?,
row.get(4)?,
))
})
.context("failed to query row")?
};
let guilds = self
.get_user_guilds(username.as_ref())
.await
.map_err(Box::new)?;
let mut intros = HashMap::new();
for guild in guilds {
intros.extend(
self.get_user_channel_intros(username.as_ref(), guild.id())
.await?,
);
}
Ok(user.with_channel_intros(intros))
}
async fn get_user_channel_intros(
&self,
username: impl AsRef<str>,
guild_id: GuildId,
) -> Result<HashMap<(GuildId, ChannelName), Vec<Intro>>, GetIntroError> {
let conn = self.conn.lock().await;
struct ChannelIntro {
channel_name: ChannelName,
intro: Intro,
}
let mut query = conn
.prepare(
"
SELECT
Intro.id,
Intro.name,
Intro.filename,
UI.channel_name
FROM Intro
LEFT JOIN UserIntro UI ON UI.intro_id = Intro.id
WHERE
UI.username = ?1
AND UI.guild_id = ?2
",
)
.context("failed to prepare query")?;
let intros = query
.query_map([username.as_ref(), &guild_id.to_string()], |row| {
Ok(ChannelIntro {
channel_name: ChannelName::from(row.get::<_, String>(3)?),
intro: Intro::new(row.get::<_, i32>(0)?.into(), row.get(1)?, row.get(2)?),
})
})
.context("failed to map prepared query")?
.collect::<Result<Vec<ChannelIntro>, _>>()
.context("failed to fetch user channel intro rows")?;
let intros = intros
.into_iter()
.map(|intro| ((guild_id, intro.channel_name), intro.intro))
.into_group_map();
Ok(intros)
}
async fn get_user_from_api_key(&self, api_key: &str) -> Result<User, GetUserError> {
let username = {
let conn = self.conn.lock().await;
let mut query = conn
.prepare(
"
SELECT
username AS name
FROM User
WHERE api_key = :api_key
",
)
.context("failed to prepare query")?;
query
.query_row(&[(":api_key", api_key)], |row| {
Ok(UserName::from(row.get::<_, String>(0)?))
})
.context("failed to query row")?
};
self.get_user(username).await
}
async fn create_guild(&self, req: CreateGuildRequest) -> Result<Guild, CreateGuildError> {
todo!()
}
async fn create_user(&self, req: CreateUserRequest) -> Result<User, CreateUserError> {
todo!()
}
async fn create_channel(
&self,
req: CreateChannelRequest,
) -> Result<Channel, CreateChannelError> {
todo!()
}
async fn add_intro_to_guild(
&self,
req: AddIntroToGuildRequest,
) -> Result<(), AddIntroToGuildError> {
todo!()
}
async fn add_intro_to_user(
&self,
req: AddIntroToUserRequest,
) -> Result<(), guild::AddIntroToUserError> {
todo!()
}
}

View File

@ -2,6 +2,8 @@
// #![feature(proc_macro_hygiene)]
// #![feature(async_closure)]
mod lib;
mod auth;
mod db;
mod htmx;
@ -28,6 +30,8 @@ use serenity::prelude::*;
use songbird::SerenityInit;
use tracing::*;
use crate::lib::domain::intro_tool;
use crate::lib::{inbound, outbound};
use crate::settings::Settings;
enum HandlerMessage {
@ -312,37 +316,73 @@ async fn spawn_bot(db: Arc<tokio::sync::Mutex<db::Database>>) {
#[instrument]
async fn main() -> std::io::Result<()> {
dotenv::dotenv().ok();
tracing_subscriber::fmt::init();
let settings = serde_json::from_str::<Settings>(
&std::fs::read_to_string("config/settings.json").expect("no config/settings.json"),
)
.expect("error parsing settings file");
info!("{settings:?}");
let secrets = auth::DiscordSecret {
client_id: env::var("DISCORD_CLIENT_ID").expect("expected DISCORD_CLIENT_ID env var"),
client_secret: env::var("DISCORD_CLIENT_SECRET")
.expect("expected DISCORD_CLIENT_SECRET env var"),
bot_token: env::var("DISCORD_TOKEN").expect("expected DISCORD_TOKEN env var"),
};
let origin = env::var("APP_ORIGIN").expect("expected APP_ORIGIN");
let (run_api, run_bot) = (settings.run_api, settings.run_bot);
let db = Arc::new(tokio::sync::Mutex::new(
db::Database::new("./config/db.sqlite").expect("couldn't open sqlite db"),
));
let db = outbound::sqlite::Sqlite::new("./config/db.sqlite").expect("couldn't open sqlite db");
{
// attempt to initialize the database with the schema
let db = db.lock().await;
db.init().expect("couldn't init db");
if let Ok(impersonated_username) = env::var("IMPERSONATED_USERNAME") {
let service = intro_tool::service::Service::new(db);
let service = intro_tool::debug_service::DebugService::new(service, impersonated_username);
let http_server = inbound::http::HttpServer::new(service, secrets, origin)
.expect("couldn't start http server");
http_server.run().await;
} else {
let service = intro_tool::service::Service::new(db);
let http_server = inbound::http::HttpServer::new(service, secrets, origin)
.expect("couldn't start http server");
http_server.run().await;
}
if run_api {
spawn_api(db.clone());
}
if run_bot {
spawn_bot(db).await;
}
info!("spawned background tasks");
let _ = tokio::signal::ctrl_c().await;
info!("Received Ctrl-C, shuttdown down.");
Ok(())
// dotenv::dotenv().ok();
//
// tracing_subscriber::fmt::init();
//
// let settings = serde_json::from_str::<Settings>(
// &std::fs::read_to_string("config/settings.json").expect("no config/settings.json"),
// )
// .expect("error parsing settings file");
// info!("{settings:?}");
//
// let (run_api, run_bot) = (settings.run_api, settings.run_bot);
// let db = Arc::new(tokio::sync::Mutex::new(
// db::Database::new("./config/db.sqlite").expect("couldn't open sqlite db"),
// ));
//
// {
// // attempt to initialize the database with the schema
// let db = db.lock().await;
// db.init().expect("couldn't init db");
// }
//
// if run_api {
// spawn_api(db.clone());
// }
// if run_bot {
// spawn_bot(db).await;
// }
//
// info!("spawned background tasks");
//
// let _ = tokio::signal::ctrl_c().await;
// info!("Received Ctrl-C, shuttdown down.");
//
// Ok(())
}