diff --git a/Cargo.lock b/Cargo.lock index 41fa101..857bd78 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1029,6 +1029,7 @@ dependencies = [ "poise", "serenity", "sqlx", + "thiserror 2.0.18", "tokio", "tracing-subscriber", ] diff --git a/Cargo.toml b/Cargo.toml index b78bb80..99ef7c3 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -8,5 +8,6 @@ dotenvy = "0.15.7" poise = "0.6.2" serenity = { version = "0.12.5", features = ["cache"] } sqlx = { version = "0.8.6", features = ["any", "mysql", "runtime-tokio", "sqlite"] } +thiserror = "2.0.18" tokio = { version = "1.52.3", features = ["macros", "rt-multi-thread"] } tracing-subscriber = "0.3.23" diff --git a/migrations/20260511132221_creation.sql b/migrations/20260511132221_creation.sql index 0f8b79a..825221f 100644 --- a/migrations/20260511132221_creation.sql +++ b/migrations/20260511132221_creation.sql @@ -1,6 +1,7 @@ -- Add migration script here create TABLE `Guild` ( - `id` INT UNSIGNED NOT NULL PRIMARY KEY + `id` INT UNSIGNED NOT NULL PRIMARY KEY, + `shadow_ban_role` INT UNSIGNED ); create TABLE `User` ( @@ -30,4 +31,18 @@ create TABLE `Auto_Channel` ( `channel_id` INT UNSIGNED NOT NULL PRIMARY KEY, `category_id` INT UNSIGNED, FOREIGN KEY(channel_id) REFERENCES User(id) -) +); + +create TABLE `Shadow_Ban` ( + `id` INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, + `guild_id` INT UNSIGNED NOT NULL, + `user_id` INT UNSIGNED NOT NULL, + FOREIGN KEY(guild_id) REFERENCES Guild(id), + FOREIGN KEY(user_id) REFERENCES User(id) +); + +create TABLE `Shadow_Ban_Role` ( + `shadow_ban_id` INTEGER NOT NULL, + `role_id` INT UNSIGNED NOT NULL, + FOREIGN KEY(shadow_ban_id) REFERENCES Shadow_Ban(id) +); diff --git a/src/commands.rs b/src/commands.rs index c0f22e2..6aca42b 100644 --- a/src/commands.rs +++ b/src/commands.rs @@ -1,4 +1,31 @@ -use crate::{Context, Error}; +use std::convert::Infallible; + +use poise::CreateReply; +use serenity::{ + builder::{CreateEmbed, EditRole}, + cache::CacheRef, + futures::future::join_all, + http::Http, + model::{ + self, Permissions, + channel::{Embed, PermissionOverwrite, PermissionOverwriteType}, + guild::Guild, + id::{GuildId, RoleId}, + user::User, + }, +}; +use thiserror::Error; +use tokio::join; + +use crate::{Context, Error, database::DataBase}; + +#[derive(Error, Debug)] +pub enum BotError { + #[error("Error related to the database")] + DataBaseError(#[from] sqlx::Error), + #[error("Error related to serenity")] + SerenityError(#[from] serenity::Error), +} /// ℹ️ Display all commands and their uses /// @@ -20,3 +47,141 @@ pub async fn help( .await?; Ok(()) } + +#[poise::command( + slash_command, + subcommands("shadowban_ban", "shadowban_unban", "shadowban_setup") +)] +pub async fn shadowban(_ctx: Context<'_>) -> Result<(), Error> { + Ok(()) +} + +#[poise::command( + slash_command, + required_permissions = "BAN_MEMBERS", + rename = "ban", + guild_only +)] + +/// Remove the access of all channels to an user +pub async fn shadowban_ban( + ctx: Context<'_>, + #[description = "The user to shadow ban"] user: User, +) -> Result<(), Error> { + if let Some(guild) = ctx.guild_id() { + let role_id = ctx.data().database.get_shadow_ban_role(guild).await; + let role = if let Some(role_id) = role_id { + role_id + } else { + shadowban_setup_internal(&ctx.data().database, ctx.http(), guild, None).await? + }; + guild + .member(ctx.http(), user.id) + .await? + .add_role(ctx.http(), role) + .await?; + } else { + let embed = CreateEmbed::new() + .title("Error") + .description("This command must execute in a guild."); + ctx.send(CreateReply { + embeds: vec![embed], + ..Default::default() + }) + .await?; + } + Ok(()) +} + +/// Remove a previous shadow ban +#[poise::command( + slash_command, + required_permissions = "BAN_MEMBERS", + rename = "unban", + guild_only +)] +pub async fn shadowban_unban( + ctx: Context<'_>, + #[description = "The user to remove the shadow ban role"] user: User, +) -> Result<(), Error> { + if let Some(guild) = ctx.guild_id() { + let role_id = ctx.data().database.get_shadow_ban_role(guild).await; + let role = if let Some(role_id) = role_id { + role_id + } else { + shadowban_setup_internal(&ctx.data().database, ctx.http(), guild, None).await? + }; + guild + .member(ctx.http(), user.id) + .await? + .remove_role(ctx.http(), role) + .await?; + } else { + let embed = CreateEmbed::new() + .title("Error") + .description("This command must execute in a guild."); + ctx.send(CreateReply { + embeds: vec![embed], + ..Default::default() + }) + .await?; + } + Ok(()) +} + +#[poise::command( + slash_command, + required_permissions = "BAN_MEMBERS", + rename = "setup", + guild_only +)] +pub async fn shadowban_setup(ctx: Context<'_>, role: Option) -> Result<(), Error> { + ctx.defer_ephemeral().await?; + if let Some(guild) = ctx.guild_id() { + shadowban_setup_internal(&ctx.data().database, ctx.http(), guild, role).await?; + } else { + let embed = CreateEmbed::new() + .title("Error") + .description("This command must execute in a guild."); + ctx.send(CreateReply { + embeds: vec![embed], + ..Default::default() + }) + .await?; + } + Ok(()) +} + +async fn shadowban_setup_internal( + database: &DataBase, + http: &Http, + guild: GuildId, + role: Option, +) -> Result { + let role_id = if let Some(role_id) = role { + role_id + } else { + let role = EditRole::new() + .colour(0x000000) + .name("Shadow Ban") + .hoist(true) + .mentionable(false) + .permissions(Permissions::empty()); + let role = guild.create_role(http, role).await?; + role.id + }; + database.set_shadow_ban_role(guild, role_id).await?; + let channels = guild.channels(http).await?; + + let perms = PermissionOverwrite { + allow: Permissions::empty(), + deny: Permissions::VIEW_CHANNEL.union(Permissions::SEND_MESSAGES), + kind: PermissionOverwriteType::Role(role_id), + }; + + for (_, channel) in channels { + channel.create_permission(http, perms.clone()).await.ok(); + } + + Ok(role_id) +} diff --git a/src/database.rs b/src/database.rs index bb5e3d6..8b335ff 100644 --- a/src/database.rs +++ b/src/database.rs @@ -1,9 +1,10 @@ use std::env::var; +use serenity::model::id::{GuildId, RoleId}; use sqlx::any::install_default_drivers; use sqlx::migrate::Migrator; use sqlx::sqlite::SqlitePoolOptions; -use sqlx::{Error, SqlitePool}; +use sqlx::{Error, SqlitePool, query}; static MIGRATOR: Migrator = sqlx::migrate!(); @@ -24,4 +25,31 @@ impl DataBase { Ok(Self { pool: pool }) } + pub async fn set_shadow_ban_role(&self, guild: GuildId, role: RoleId) -> Result<(), Error> { + let guild = guild.get() as i64; + let role = role.get() as i64; + query( + "INSERT INTO Guild (id, shadow_ban_role) + VALUES (?1,?2) + ON CONFLICT(id) DO UPDATE SET + shadow_ban_role = excluded.shadow_ban_role;", + ) + .bind(guild) + .bind(role) + .execute(&self.pool) + .await?; + + Ok(()) + } + pub async fn get_shadow_ban_role(&self, guild: GuildId) -> Option { + let guild = guild.get() as i64; + + let role = query!("SELECT (shadow_ban_role) FROM Guild where id = ?;", guild) + .fetch_one(&self.pool) + .await + .map(|x| x.shadow_ban_role) + .unwrap_or(None); + + role.map(|x| RoleId::new(x as u64)) + } } diff --git a/src/main.rs b/src/main.rs index b6ba244..18136ed 100644 --- a/src/main.rs +++ b/src/main.rs @@ -91,11 +91,12 @@ async fn main() { let intents = serenity::GatewayIntents::non_privileged() | serenity::GatewayIntents::MESSAGE_CONTENT; - let client = serenity::ClientBuilder::new(token, intents) + let mut client = serenity::ClientBuilder::new(token, intents) .framework(framework) .status(serenity::OnlineStatus::Idle) .activity(ActivityData::playing("next station ⭐ /help")) - .await; + .await + .unwrap(); - client.unwrap().start().await.unwrap() + client.start().await.unwrap() }