diff --git a/Cargo.toml b/Cargo.toml index 58b3b12..cb6d3cd 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -21,7 +21,7 @@ surf = "2.3.2" dotenvy = "0.15.7" # For sqlite -sqlx = { version = "0.7.1", features = [ "runtime-tokio", "sqlite" ] } +sqlx = { version = "0.7.1", features = [ "runtime-tokio", "sqlite", "migrate" ] } # create random strings rand = "0.8.5" diff --git a/db/migrations/1_setup.sql b/db/migrations/1_setup.sql new file mode 100644 index 0000000..aa4fb93 --- /dev/null +++ b/db/migrations/1_setup.sql @@ -0,0 +1,38 @@ +-- setup initial tables + +-- this handles the users "floating" account +CREATE TABLE IF NOT EXISTS wolves ( + id_wolves integer PRIMARY KEY, + email text not null, + discord integer, + minecraft text +); +CREATE INDEX IF NOT EXISTS index_discord ON wolves (discord); + +-- used to verify the users email address +CREATE TABLE IF NOT EXISTS wolves_verify ( + discord integer PRIMARY KEY, + email text not null, + auth_code text not null, + date_expiry text not null +); +CREATE INDEX IF NOT EXISTS index_date_expiry ON wolves_verify (date_expiry); + +-- information on teh server the bot is registered on +CREATE TABLE IF NOT EXISTS servers ( + server integer PRIMARY KEY, + wolves_api text not null, + role_past integer, + role_current integer, + member_past integer DEFAULT 0, + member_current integer DEFAULT 0 +); + +-- keep track of the members on the server +CREATE TABLE IF NOT EXISTS server_members ( + server integer not null, + id_wolves integer not null, + expiry text not null, + PRIMARY KEY(server,id_wolves), + FOREIGN KEY (id_wolves) REFERENCES wolves (id_wolves) +); \ No newline at end of file diff --git a/db/migrations/2_minecraft-server.sql b/db/migrations/2_minecraft-server.sql new file mode 100644 index 0000000..3aedbc3 --- /dev/null +++ b/db/migrations/2_minecraft-server.sql @@ -0,0 +1,3 @@ +-- add teh option to associate each discord server with a minecraft one managed by skynet +ALTER TABLE servers +ADD server_minecraft text; \ No newline at end of file diff --git a/flake.nix b/flake.nix index e6d6f02..cbec3d1 100644 --- a/flake.nix +++ b/flake.nix @@ -107,7 +107,7 @@ }; discord = mkOption rec { type = types.str; - description = "ENV file with DISCORD_TOKEN"; + description = "ENV file with DISCORD_TOKEN, DISCORD_MINECRAFT"; }; mail = mkOption rec { type = types.str; diff --git a/src/bin/update_users.rs b/src/bin/update_users.rs index a5ca7e0..974fc1e 100644 --- a/src/bin/update_users.rs +++ b/src/bin/update_users.rs @@ -4,7 +4,7 @@ use serenity::{ model::gateway::{GatewayIntents, Ready}, Client, }; -use skynet_discord_bot::{db_init, get_config, get_server_config_bulk, set_roles::update_server, Config, DataBase}; +use skynet_discord_bot::{db_init, get_config, get_server_config_bulk, set_roles, update_server, Config, DataBase}; use std::{process, sync::Arc}; use tokio::sync::RwLock; @@ -58,7 +58,14 @@ async fn bulk_check(ctx: Arc) { let db = db_lock.read().await; + let config_lock = { + let data_read = ctx.data.read().await; + data_read.get::().expect("Expected Config in TypeMap.").clone() + }; + let config = config_lock.read().await; + for server_config in get_server_config_bulk(&db).await { - update_server(&ctx, &server_config, &[], &vec![]).await; + set_roles::update_server(&ctx, &server_config, &[], &vec![]).await; + update_server(server_config.server_minecraft, &db, &server_config.server, &config).await; } } diff --git a/src/commands/add_minecraft.rs b/src/commands/add_minecraft.rs new file mode 100644 index 0000000..3457190 --- /dev/null +++ b/src/commands/add_minecraft.rs @@ -0,0 +1,209 @@ +use serenity::{ + builder::CreateApplicationCommand, + client::Context, + model::{ + application::interaction::application_command::ApplicationCommandInteraction, + prelude::{command::CommandOptionType, interaction::application_command::CommandDataOptionValue}, + }, +}; + +use skynet_discord_bot::{get_server_config, DataBase, Servers}; +use sqlx::{Pool, Sqlite}; + +pub(crate) mod user { + use super::*; + use crate::commands::link_email::link::get_server_member_discord; + use serenity::model::id::UserId; + use skynet_discord_bot::{update_whitelist, Config, Wolves}; + use sqlx::Error; + + pub fn register(command: &mut CreateApplicationCommand) -> &mut CreateApplicationCommand { + command.name("link_minecraft").description("Link your minecraft account").create_option(|option| { + option + .name("minecraft_id") + .description("Your Minecraft username") + .kind(CommandOptionType::String) + .required(true) + }) + } + + pub async fn run(command: &ApplicationCommandInteraction, ctx: &Context) -> String { + let db_lock = { + let data_read = ctx.data.read().await; + data_read.get::().expect("Expected Databse in TypeMap.").clone() + }; + let db = db_lock.read().await; + + let config_lock = { + let data_read = ctx.data.read().await; + data_read.get::().expect("Expected Config in TypeMap.").clone() + }; + let config = config_lock.read().await; + + // user has to have previously linked with wolves + if get_server_member_discord(&db, &command.user.id).await.is_none() { + return "Not linked with wolves, please use ``/link_wolves`` with your wolves email.".to_string(); + } + + let username = if let CommandDataOptionValue::String(username) = command + .data + .options + .get(0) + .expect("Expected username option") + .resolved + .as_ref() + .expect("Expected username object") + { + username.trim() + } else { + return "Please provide a valid username".to_string(); + }; + + // insert the username into the database + match add_minecraft(&db, &command.user.id, username).await { + Ok(_) => {} + Err(e) => { + dbg!("{:?}", e); + return format!("Failure to minecraft username {:?}", username); + } + } + + // get a list of servers that the user is a member of + if let Ok(servers) = get_servers(&db, &command.user.id).await { + for server in servers { + if let Some(server_minecraft) = server.server_minecraft { + // activate the user on all linked servers + update_whitelist(&vec![username.to_string()], &server_minecraft, &config.discord_minecraft, false).await; + } + } + } + + "Added/Updated minecraft_user info".to_string() + } + + async fn add_minecraft(db: &Pool, user: &UserId, minecraft: &str) -> Result, Error> { + sqlx::query_as::<_, Wolves>( + " + UPDATE wolves + SET minecraft = ?2 + WHERE discord = ?1; + ", + ) + .bind(*user.as_u64() as i64) + .bind(minecraft) + .fetch_optional(db) + .await + } + + async fn get_servers(db: &Pool, discord: &UserId) -> Result, Error> { + sqlx::query_as::<_, Servers>( + " + SELECT servers.* + FROM servers + JOIN ( + SELECT server + FROM server_members + JOIN wolves USING (id_wolves) + WHERE discord = ?1 + ) USING (server) + WHERE server_minecraft IS NOT NULL + ", + ) + .bind(*discord.as_u64() as i64) + .fetch_all(db) + .await + } +} + +pub(crate) mod server { + use sqlx::Error; + // this is to managfe the server side of commands related to minecraft + use super::*; + use skynet_discord_bot::{is_admin, update_server, Config}; + + pub fn register(command: &mut CreateApplicationCommand) -> &mut CreateApplicationCommand { + command + .name("add_minecraft") + .description("Enable the bot for this discord") + .create_option(|option| { + option + .name("server_id") + .description("ID of the Minecraft server hosted by the Computer Society") + .kind(CommandOptionType::String) + .required(true) + }) + } + + pub async fn run(command: &ApplicationCommandInteraction, ctx: &Context) -> String { + // check if user has high enough permisssions + if let Some(msg) = is_admin(command, ctx).await { + return msg; + } + let g_id = match command.guild_id { + None => return "Not in a server".to_string(), + Some(x) => x, + }; + + let server_minecraft = if let CommandDataOptionValue::String(id) = command + .data + .options + .get(0) + .expect("Expected server_id option") + .resolved + .as_ref() + .expect("Expected server_id object") + { + Some(id.to_owned()) + } else { + None + }; + + let db_lock = { + let data_read = ctx.data.read().await; + data_read.get::().expect("Expected Databse in TypeMap.").clone() + }; + let db = db_lock.read().await; + + let server_data = match get_server_config(&db, &g_id).await { + None => { + return "No existing server config, have you used ``/add``?".to_string(); + } + Some(mut x) => { + x.server_minecraft = server_minecraft.clone(); + x + } + }; + + match add_server(&db, &server_data).await { + Ok(_) => {} + Err(e) => { + println!("{:?}", e); + return format!("Failure to insert into Servers {:?}", server_data); + } + } + + let config_lock = { + let data_read = ctx.data.read().await; + data_read.get::().expect("Expected Config in TypeMap.").clone() + }; + let config = config_lock.read().await; + + update_server(server_minecraft, &db, &g_id, &config).await; + + "Added/Updated minecraft_server info".to_string() + } + + async fn add_server(db: &Pool, server: &Servers) -> Result, Error> { + sqlx::query_as::<_, Servers>( + " + UPDATE servers + SET server_minecraft = ?2 + WHERE server = ?1; + ", + ) + .bind(*server.server.as_u64() as i64) + .bind(&server.server_minecraft) + .fetch_optional(db) + .await + } +} diff --git a/src/commands/add_server.rs b/src/commands/add_server.rs index c576b16..49fcae3 100644 --- a/src/commands/add_server.rs +++ b/src/commands/add_server.rs @@ -7,40 +7,13 @@ use serenity::{ }, }; use skynet_discord_bot::get_data::get_wolves; -use skynet_discord_bot::{get_server_config, set_roles::update_server, DataBase, Servers}; +use skynet_discord_bot::{get_server_config, is_admin, set_roles::update_server, DataBase, Servers}; use sqlx::{Error, Pool, Sqlite}; pub async fn run(command: &ApplicationCommandInteraction, ctx: &Context) -> String { // check if user has high enough permisssions - let mut admin = false; - - let g_id = match command.guild_id { - None => return "Not in a server".to_string(), - Some(x) => x, - }; - - let roles_server = g_id.roles(&ctx.http).await.unwrap_or_default(); - - if let Ok(member) = g_id.member(&ctx.http, command.user.id).await { - if let Some(permissions) = member.permissions { - if permissions.administrator() { - admin = true; - } - } - - for role_id in member.roles { - if admin { - break; - } - if let Some(role) = roles_server.get(&role_id) { - if role.permissions.administrator() { - admin = true; - } - } - } - } - if !admin { - return "Administrator permission required".to_string(); + if let Some(msg) = is_admin(command, ctx).await { + return msg; } let api_key = if let CommandDataOptionValue::String(key) = command @@ -91,6 +64,8 @@ pub async fn run(command: &ApplicationCommandInteraction, ctx: &Context) -> Stri role_current, member_past: 0, member_current: 0, + // this gets added later + server_minecraft: None, }; match add_server(&db, ctx, &server_data).await { @@ -136,16 +111,22 @@ async fn add_server(db: &Pool, ctx: &Context, server: &Servers) -> Resul let role_past = server.role_past.map(|x| *x.as_u64() as i64); let role_current = server.role_current.map(|x| *x.as_u64() as i64); + let server_minecraft = match get_server_config(db, &server.server).await { + None => None, + Some(x) => x.server_minecraft, + }; + let insert = sqlx::query_as::<_, Servers>( " - INSERT OR REPLACE INTO servers (server, wolves_api, role_past, role_current) - VALUES (?1, ?2, ?3, ?4) + INSERT OR REPLACE INTO servers (server, wolves_api, role_past, role_current, server_minecraft) + VALUES (?1, ?2, ?3, ?4, ?5) ", ) .bind(*server.server.as_u64() as i64) .bind(&server.wolves_api) .bind(role_past) .bind(role_current) + .bind(server_minecraft) .fetch_optional(db) .await; diff --git a/src/commands/link_email.rs b/src/commands/link_email.rs index e109525..f4441df 100644 --- a/src/commands/link_email.rs +++ b/src/commands/link_email.rs @@ -15,7 +15,7 @@ use serenity::{ }; use skynet_discord_bot::{get_now_iso, random_string, Config, DataBase, Wolves, WolvesVerify}; use sqlx::{Pool, Sqlite}; -pub(crate) mod link { +pub mod link { use super::*; pub async fn run(command: &ApplicationCommandInteraction, ctx: &Context) -> String { @@ -87,12 +87,12 @@ pub(crate) mod link { pub fn register(command: &mut CreateApplicationCommand) -> &mut CreateApplicationCommand { command - .name("link") + .name("link_wolves") .description("Set Wolves Email") .create_option(|option| option.name("email").description("UL Wolves Email").kind(CommandOptionType::String).required(true)) } - async fn get_server_member_discord(db: &Pool, user: &UserId) -> Option { + pub async fn get_server_member_discord(db: &Pool, user: &UserId) -> Option { sqlx::query_as::<_, Wolves>( r#" SELECT * @@ -234,7 +234,7 @@ pub(crate) mod link { } } -pub(crate) mod verify { +pub mod verify { use super::*; use crate::commands::link_email::link::{db_pending_clear_expired, get_verify_from_db}; use serenity::model::user::User; diff --git a/src/commands/mod.rs b/src/commands/mod.rs index 4be8b3d..95e0c51 100644 --- a/src/commands/mod.rs +++ b/src/commands/mod.rs @@ -1,2 +1,3 @@ +pub mod add_minecraft; pub mod add_server; pub mod link_email; diff --git a/src/lib.rs b/src/lib.rs index c8ca3f9..6f0e5ce 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -8,10 +8,12 @@ use serenity::{ prelude::TypeMapKey, }; +use crate::set_roles::get_server_member_bulk; use chrono::{Datelike, SecondsFormat, Utc}; use rand::{distributions::Alphanumeric, thread_rng, Rng}; use serenity::client::Context; use serenity::model::id::UserId; +use serenity::model::prelude::application_command::ApplicationCommandInteraction; use sqlx::{ sqlite::{SqliteConnectOptions, SqlitePoolOptions, SqliteRow}, Error, FromRow, Pool, Row, Sqlite, @@ -27,6 +29,7 @@ pub struct Config { pub auth: String, pub discord_token: String, + pub discord_minecraft: String, pub mail_smtp: String, pub mail_user: String, @@ -52,6 +55,7 @@ pub fn get_config() -> Config { ldap_api: "https://api.account.skynet.ie".to_string(), auth: "".to_string(), discord_token: "".to_string(), + discord_minecraft: "".to_string(), home: ".".to_string(), database: "database.db".to_string(), @@ -82,6 +86,9 @@ pub fn get_config() -> Config { if let Ok(x) = env::var("DISCORD_TOKEN") { config.discord_token = x.trim().to_string(); } + if let Ok(x) = env::var("DISCORD_MINECRAFT") { + config.discord_minecraft = x.trim().to_string(); + } if let Ok(x) = env::var("EMAIL_SMTP") { config.mail_smtp = x.trim().to_string(); @@ -218,6 +225,7 @@ pub struct Servers { pub role_current: Option, pub member_past: i64, pub member_current: i64, + pub server_minecraft: Option, } impl<'r> FromRow<'r, SqliteRow> for Servers { fn from_row(row: &'r SqliteRow) -> Result { @@ -246,6 +254,11 @@ impl<'r> FromRow<'r, SqliteRow> for Servers { _ => None, }; + let server_minecraft = match row.try_get("server_minecraft") { + Ok(x) => Some(x), + _ => None, + }; + Ok(Self { server, wolves_api: row.try_get("wolves_api")?, @@ -253,6 +266,7 @@ impl<'r> FromRow<'r, SqliteRow> for Servers { role_current, member_past: row.try_get("member_past")?, member_current: row.try_get("member_current")?, + server_minecraft, }) } } @@ -269,58 +283,8 @@ pub async fn db_init(config: &Config) -> Result, Error> { ) .await?; - sqlx::query( - "CREATE TABLE IF NOT EXISTS wolves ( - id_wolves integer PRIMARY KEY, - email text not null, - discord integer, - minecraft text - )", - ) - .execute(&pool) - .await?; - - sqlx::query("CREATE INDEX IF NOT EXISTS index_discord ON wolves (discord)").execute(&pool).await?; - - sqlx::query( - "CREATE TABLE IF NOT EXISTS wolves_verify ( - discord integer PRIMARY KEY, - email text not null, - auth_code text not null, - date_expiry text not null - )", - ) - .execute(&pool) - .await?; - - sqlx::query("CREATE INDEX IF NOT EXISTS index_date_expiry ON wolves_verify (date_expiry)") - .execute(&pool) - .await?; - - sqlx::query( - "CREATE TABLE IF NOT EXISTS server_members ( - server integer not null, - id_wolves integer not null, - expiry text not null, - PRIMARY KEY(server,id_wolves), - FOREIGN KEY (id_wolves) REFERENCES wolves (id_wolves) - )", - ) - .execute(&pool) - .await?; - - sqlx::query( - "CREATE TABLE IF NOT EXISTS servers ( - server integer PRIMARY KEY, - wolves_api text not null, - role_past integer, - role_current integer, - member_past integer DEFAULT 0, - member_current integer DEFAULT 0 - )", - ) - .execute(&pool) - .await?; + // migrations are amazing! + sqlx::migrate!("./db/migrations").run(&pool).await.unwrap(); Ok(pool) } @@ -466,7 +430,7 @@ pub mod set_roles { println!("{:?} Changes: New: +{}, Current: +{}/-{}", server.as_u64(), roles_set[0], roles_set[1], roles_set[2]); } - async fn get_server_member_bulk(db: &Pool, server: &GuildId) -> Vec { + pub async fn get_server_member_bulk(db: &Pool, server: &GuildId) -> Vec { sqlx::query_as::<_, ServerMembersWolves>( r#" SELECT * @@ -678,3 +642,114 @@ pub mod get_data { } } } + +/** + For any time ye need to check if a user who calls a command has admin privlages +*/ +pub async fn is_admin(command: &ApplicationCommandInteraction, ctx: &Context) -> Option { + let mut admin = false; + + let g_id = match command.guild_id { + None => return Some("Not in a server".to_string()), + Some(x) => x, + }; + + let roles_server = g_id.roles(&ctx.http).await.unwrap_or_default(); + + if let Ok(member) = g_id.member(&ctx.http, command.user.id).await { + if let Some(permissions) = member.permissions { + if permissions.administrator() { + admin = true; + } + } + + for role_id in member.roles { + if admin { + break; + } + if let Some(role) = roles_server.get(&role_id) { + if role.permissions.administrator() { + admin = true; + } + } + } + } + if !admin { + Some("Administrator permission required".to_string()) + } else { + None + } +} + +/** +loop through all members of server +get a list of folks with mc accounts that are members +and a list that arent members + */ +pub async fn update_server(server_minecraft: Option, db: &Pool, g_id: &GuildId, config: &Config) { + if let Some(server_id) = server_minecraft { + let mut usernames = vec![]; + for member in get_server_member_bulk(db, g_id).await { + if let Some(x) = member.minecraft { + usernames.push(x); + } + } + if !usernames.is_empty() { + update_whitelist(&usernames, &server_id, &config.discord_minecraft, true).await; + } + } +} + +pub async fn update_whitelist(add: &Vec, server: &str, token: &str, wipe_reset: bool) { + let url_base = format!("http://panel.games.skynet.ie/api/client/servers/{server}"); + let bearer = format!("Bearer {token}"); + + async fn post(url: &str, bearer: &str, data: &T) { + match surf::post(url) + .header("Authorization", bearer) + .header("Content-Type", "application/json") + .header("Accept", "Application/vnd.pterodactyl.v1+json") + .body_json(&data) + { + Ok(req) => { + req.await.ok(); + } + Err(e) => { + dbg!(e); + } + } + } + + #[derive(Deserialize, Serialize, Debug)] + struct BodyCommand { + command: String, + } + + #[derive(Deserialize, Serialize, Debug)] + struct BodyDelete { + root: String, + files: Vec, + } + + if wipe_reset { + // delete whitelist + let deletion = BodyDelete { + root: "/".to_string(), + files: vec!["whitelist.json".to_string()], + }; + post(&format!("{url_base}/files/delete"), &bearer, &deletion).await; + + // reload the whitelist + let data = BodyCommand { + command: "whitelist reload".to_string(), + }; + post(&format!("{url_base}/command"), &bearer, &data).await; + } + + for name in add { + let data = BodyCommand { + command: format!("whitelist add {name}"), + }; + post(&format!("{url_base}/command"), &bearer, &data).await; + } +} diff --git a/src/main.rs b/src/main.rs index 0aac5b9..208f352 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,4 +1,4 @@ -mod commands; +pub mod commands; use serenity::{ async_trait, @@ -60,6 +60,8 @@ impl EventHandler for Handler { .create_application_command(|command| commands::add_server::register(command)) .create_application_command(|command| commands::link_email::link::register(command)) .create_application_command(|command| commands::link_email::verify::register(command)) + .create_application_command(|command| commands::add_minecraft::server::register(command)) + .create_application_command(|command| commands::add_minecraft::user::register(command)) }) .await { @@ -79,6 +81,8 @@ impl EventHandler for Handler { "add" => commands::add_server::run(&command, &ctx).await, "link_wolves" => commands::link_email::link::run(&command, &ctx).await, "verify" => commands::link_email::verify::run(&command, &ctx).await, + "add_minecraft" => commands::add_minecraft::server::run(&command, &ctx).await, + "link_minecraft" => commands::add_minecraft::user::run(&command, &ctx).await, _ => "not implemented :(".to_string(), };