diff --git a/Cargo.lock b/Cargo.lock index 242fda2..4f3473e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1263,6 +1263,16 @@ version = "0.2.164" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "433bfe06b8c75da9b2e3fbea6e5329ff87748f0b144ef75306e674c3f6f7c13f" +[[package]] +name = "libloading" +version = "0.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4979f22fdb869068da03c9f7528f8297c6fd2606bc3a4affe42e6a823fdb8da4" +dependencies = [ + "cfg-if", + "windows-targets 0.52.6", +] + [[package]] name = "libm" version = "0.2.11" @@ -2284,6 +2294,7 @@ dependencies = [ "chrono", "chrono-tz", "dotenv", + "libloading", "poise", "reqwest 0.12.9", "serde", @@ -2422,6 +2433,7 @@ dependencies = [ "smallvec", "sqlformat", "thiserror", + "time", "tokio", "tokio-stream", "tracing", @@ -2506,6 +2518,7 @@ dependencies = [ "sqlx-core", "stringprep", "thiserror", + "time", "tracing", "whoami", ] @@ -2544,6 +2557,7 @@ dependencies = [ "sqlx-core", "stringprep", "thiserror", + "time", "tracing", "whoami", ] @@ -2567,6 +2581,7 @@ dependencies = [ "serde", "serde_urlencoded", "sqlx-core", + "time", "tracing", "url", ] diff --git a/Cargo.toml b/Cargo.toml index 67f2a77..192305b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -7,7 +7,7 @@ edition = "2021" dotenv = "0.15.0" poise = "0.6.1" serenity = "0.12.4" -sqlx = { version = "0.8", features = [ "runtime-tokio", "tls-rustls-ring", "mysql" ] } +sqlx = { version = "0.8", features = [ "runtime-tokio", "tls-rustls-ring", "mysql", "time" ] } tokio = { version = "1", features = ["full"] } tracing = "0.1.40" tracing-subscriber = "0.3.18" @@ -17,3 +17,4 @@ chrono = "0.4.38" chrono-tz = "0.10.0" reqwest = "0.12.9" serde_json = "1.0.133" +libloading = "0.8.5" diff --git a/migrations/20241125173833_add_kv_table.sql b/migrations/20241125173833_add_kv_table.sql new file mode 100644 index 0000000..a3374a7 --- /dev/null +++ b/migrations/20241125173833_add_kv_table.sql @@ -0,0 +1,5 @@ +-- Add migration script here +CREATE TABLE kv_store ( + `key` VARCHAR(255) NOT NULL PRIMARY KEY, + `value` TEXT +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; \ No newline at end of file diff --git a/migrations/20241125180340_add_quotes_table.sql b/migrations/20241125180340_add_quotes_table.sql new file mode 100644 index 0000000..ade89de --- /dev/null +++ b/migrations/20241125180340_add_quotes_table.sql @@ -0,0 +1,11 @@ +-- Add migration script here +CREATE TABLE quotes ( + quote_id INT AUTO_INCREMENT PRIMARY KEY, -- Unique ID for each quote + user_id BIGINT NOT NULL, -- Discord user ID (64-bit) + username VARCHAR(255) NOT NULL, -- Username of the person who said the quote + quote TEXT NOT NULL, -- The quote itself + added_by BIGINT NOT NULL, -- Discord user ID of the person who added the quote + added_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, -- Timestamp when the quote was added + INDEX (user_id), -- Index for efficient lookup by user_id + INDEX (added_by) -- Index for efficient lookup by added_by +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; diff --git a/src/commands/action.rs b/src/commands/action.rs index 8989184..c0fa843 100644 --- a/src/commands/action.rs +++ b/src/commands/action.rs @@ -1,7 +1,6 @@ use crate::{utils, Context, Error}; use poise::CreateReply; use serenity::all::{CreateAllowedMentions, CreateAttachment, Message}; -use serenity::builder::CreateMessage; use serenity::model::prelude::UserId; use serenity::prelude::Mentionable; diff --git a/src/commands/eval.rs b/src/commands/eval.rs new file mode 100644 index 0000000..bec42a8 --- /dev/null +++ b/src/commands/eval.rs @@ -0,0 +1,112 @@ +use std::{ + env, fs, + process::{Command, Output, Stdio}, + time::{SystemTime, UNIX_EPOCH}, +}; + +use crate::{Context, Error}; + +/// Evaluate rust code +#[poise::command(prefix_command)] +pub async fn eval( + ctx: Context<'_>, + #[description = "The code to run"] code: poise::CodeBlock, +) -> Result<(), Error> { + let authed_users = ctx.data().owners.clone(); + + // Check if the user is an owner + if !authed_users.contains(&ctx.author().id.into()) { + ctx.say(":x: You are not authorized to run this command") + .await?; + + return Ok(()); + } + + ctx.say(":gear: Processing...").await?; + + // Create a temporary directory for the file + let temp_dir = env::temp_dir(); + let unique_id = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("Time went backwards") + .as_secs(); + let file_path = temp_dir.join(format!("eval_{}.rs", unique_id)); + let executable_path = temp_dir.join(format!("eval_{}.out", unique_id)); + + // Write the code to a temporary file + if let Err(err) = fs::write(&file_path, code.code) { + ctx.say(format!("Error writing to temporary file: {}", err)) + .await?; + return Ok(()); + } + + // Compile the Rust file and capture stderr + let compile_output: Result = Command::new("rustc") + .arg(&file_path) + .arg("-o") + .arg(&executable_path) + .stderr(Stdio::piped()) + .output(); + + match compile_output { + Ok(output) if output.status.success() => { + ctx.say( + "<:success:1310650176037453834> Compilation successful. Running the executable...", + ) + .await?; + + // Run the compiled executable + let output = Command::new(&executable_path).output(); + + match output { + Ok(output) => { + let stdout = String::from_utf8_lossy(&output.stdout); + let stderr = String::from_utf8_lossy(&output.stderr); + ctx.say(format!( + "**Output:**\n```\n{}\n```\n**Errors:**\n```\n{}\n```", + if stdout.trim().is_empty() { + "" + } else { + stdout.trim() + }, + if stderr.trim().is_empty() { + "" + } else { + stderr.trim() + } + )) + .await?; + } + Err(err) => { + ctx.say(format!( + "<:error:1310650177056538655> Error running the executable: {}", + err + )) + .await?; + } + } + } + Ok(output) => { + // If compilation failed, display the compiler error output + let stderr = String::from_utf8_lossy(&output.stderr); + ctx.say(format!( + "<:error:1310650177056538655> Compilation failed:\n```\n{}\n```", + stderr + )) + .await?; + } + Err(err) => { + ctx.say(format!( + "<:error:1310650177056538655> Error invoking rustc: {}", + err + )) + .await?; + } + } + + // Clean up + let _ = fs::remove_file(&file_path); + let _ = fs::remove_file(&executable_path); + + Ok(()) +} diff --git a/src/commands/mod.rs b/src/commands/mod.rs index c5fafd5..08e527f 100644 --- a/src/commands/mod.rs +++ b/src/commands/mod.rs @@ -3,4 +3,6 @@ pub mod vouch; pub mod cta; pub mod dog; pub mod profile; -pub mod action; \ No newline at end of file +pub mod action; +pub mod eval; +pub mod quote; \ No newline at end of file diff --git a/src/commands/quote.rs b/src/commands/quote.rs new file mode 100644 index 0000000..8a92eab --- /dev/null +++ b/src/commands/quote.rs @@ -0,0 +1,91 @@ +use crate::{Context, Error}; +use poise::CreateReply; +use serenity::all::{CreateAllowedMentions, Message, User}; +use sqlx::types::time::OffsetDateTime; + +#[poise::command(context_menu_command = "Quote User")] +pub async fn quote_action( + ctx: Context<'_>, + #[description = "The target message to quote"] message: Message, +) -> Result<(), Error> { + let quote = crate::structs::quote::Quote { + quote_id: 0, + user_id: message.author.id.into(), + username: message.author.name.clone(), + quote: message.content.clone(), + added_by: ctx.author().id.into(), + added_at: OffsetDateTime::now_utc(), + }; + + if quote.user_id == quote.added_by { + ctx.say(":x: You can't quote yourself").await?; + return Ok(()); + } + + ctx.data().database_controller.quote_create(quote).await?; + ctx.say(":white_check_mark: Okay, I've immortalized that message for you :)") + .await?; + Ok(()) +} + +/// Get a random quote +#[poise::command(slash_command)] +pub async fn random_quote(ctx: Context<'_>) -> Result<(), Error> { + let quote = ctx.data().database_controller.quote_get_random().await?; + + if let Some(quote) = quote { + ctx.send( + CreateReply::default() + .content(format!( + "{}: {}\nQuoted at: by <@{}>", + quote.username, + quote.quote, + quote.added_at.unix_timestamp(), + quote.added_by + )) + .allowed_mentions(CreateAllowedMentions::new().empty_users()), + ) + .await?; + } else { + ctx.say("No quotes found").await?; + } + + Ok(()) +} + +/// Get quotes for a user, showing latest 10 +#[poise::command(slash_command)] +pub async fn user_quotes( + ctx: Context<'_>, + #[description = "The user to get quotes for"] user: User, +) -> Result<(), Error> { + let quotes = ctx + .data() + .database_controller + .quote_get_by_user_id(user.id.into()) + .await?; + + if quotes.is_empty() { + ctx.say("No quotes found").await?; + return Ok(()); + } + + let mut response = String::new(); + for quote in quotes { + response.push_str(&format!( + "{}: {}\nQuoted at: by <@{}>\n", + quote.username, + quote.quote, + quote.added_at.unix_timestamp(), + quote.added_by + )); + } + + ctx.send( + CreateReply::default() + .content(response) + .allowed_mentions(CreateAllowedMentions::new().empty_users()), + ) + .await?; + Ok(()) +} diff --git a/src/handlers/db.rs b/src/handlers/db.rs index 78381f9..2984228 100644 --- a/src/handlers/db.rs +++ b/src/handlers/db.rs @@ -1,3 +1,4 @@ +use crate::structs::quote::Quote; use crate::structs::user::User; use sqlx::MySqlPool; @@ -99,4 +100,80 @@ impl DatabaseController { Ok(()) } + + pub async fn kv_set(&self, key: &str, value: &str) -> Result<(), sqlx::Error> { + sqlx::query!( + "INSERT INTO kv_store (`key`, value) VALUES (?, ?) ON DUPLICATE KEY UPDATE value = ?", + key, + value, + value + ) + .execute(&self.db) + .await?; + + Ok(()) + } + + pub async fn kv_get(&self, key: &str) -> Result, sqlx::Error> { + let kv = sqlx::query!("SELECT * FROM kv_store WHERE `key` = ?", key) + .fetch_optional(&self.db) + .await?; + + match kv { + Some(kv) => Ok(kv.value), + None => Ok(None), + } + } + + pub async fn quote_create(&self, quote: Quote) -> Result<(), sqlx::Error> { + sqlx::query!( + "INSERT INTO quotes (user_id, username, quote, added_by) VALUES (?, ?, ?, ?)", + quote.user_id, + quote.username, + quote.quote, + quote.added_by + ) + .execute(&self.db) + .await?; + + Ok(()) + } + + pub async fn quote_get_random(&self) -> Result, sqlx::Error> { + let quote = sqlx::query!("SELECT * FROM quotes ORDER BY RAND() LIMIT 1") + .fetch_optional(&self.db) + .await?; + + match quote { + Some(quote) => Ok(Some(Quote { + quote_id: quote.quote_id, + user_id: quote.user_id, + username: quote.username, + quote: quote.quote, + added_by: quote.added_by, + added_at: quote.added_at.unwrap(), + })), + None => Ok(None), + } + } + + pub async fn quote_get_by_user_id(&self, user_id: u64) -> Result, sqlx::Error> { + let quote = sqlx::query!("SELECT * FROM quotes WHERE user_id = ?", user_id) + .fetch_all(&self.db) + .await?; + + let mut quotes = Vec::new(); + for q in quote { + quotes.push(Quote { + quote_id: q.quote_id, + user_id: q.user_id, + username: q.username, + quote: q.quote, + added_by: q.added_by, + added_at: q.added_at.unwrap(), + }); + } + + Ok(quotes) + } } diff --git a/src/handlers/mod.rs b/src/handlers/mod.rs index 89da20b..a36e48c 100644 --- a/src/handlers/mod.rs +++ b/src/handlers/mod.rs @@ -1,2 +1,2 @@ +pub mod db; pub mod join; -pub mod db; \ No newline at end of file diff --git a/src/main.rs b/src/main.rs index 1b67686..f4cc8d9 100644 --- a/src/main.rs +++ b/src/main.rs @@ -4,6 +4,8 @@ mod handlers; mod structs; mod utils; +use std::sync::Arc; + use events::event_handler; use handlers::db::DatabaseController; use serde::{Deserialize, Serialize}; @@ -78,6 +80,7 @@ async fn init_sqlx() -> MySqlPool { struct Data { database_controller: DatabaseController, + owners: Vec, uptime: std::time::Instant, config: Config, vouch_store: Mutex>, @@ -99,6 +102,7 @@ struct Channels { main: u64, logs_public: u64, logs_mod: u64, + starboard: u64, } #[derive(Deserialize, Serialize)] @@ -136,6 +140,7 @@ async fn main() { main: 0, logs_public: 0, logs_mod: 0, + starboard: 0, }, roles: Roles { admin: 0, @@ -160,7 +165,9 @@ async fn main() { let pool = init_sqlx().await; info!("initializing bot"); - let intents = GatewayIntents::privileged(); + let intents = GatewayIntents::privileged() + | GatewayIntents::GUILD_MEMBERS + | GatewayIntents::GUILD_MESSAGES; let framework = poise::Framework::builder() .options(poise::FrameworkOptions:: { @@ -173,10 +180,22 @@ async fn main() { commands::action::use_action_hug(), commands::action::use_action_kiss(), commands::action::use_action_pat(), + commands::eval::eval(), + commands::quote::quote_action(), + commands::quote::random_quote(), + commands::quote::user_quotes(), ], event_handler: |ctx, event, framework, data| { Box::pin(event_handler(ctx, event, framework, data)) }, + prefix_options: poise::PrefixFrameworkOptions { + prefix: Some("~".into()), + edit_tracker: Some(Arc::new(poise::EditTracker::for_timespan( + std::time::Duration::from_secs(3600), + ))), + case_insensitive_commands: true, + ..Default::default() + }, ..Default::default() }) .setup(|ctx, _ready, framework| { @@ -188,6 +207,8 @@ async fn main() { uptime: std::time::Instant::now(), config, vouch_store: Mutex::new(Vec::new()), + // Sticks, Emi, Katie + owners: vec![1017196087276220447, 272871217256726531, 1033331958291369984], }) }) }) diff --git a/src/structs/mod.rs b/src/structs/mod.rs index fd43a72..b3edc7e 100644 --- a/src/structs/mod.rs +++ b/src/structs/mod.rs @@ -1,2 +1,3 @@ +pub mod quote; +pub mod user; pub mod vouch; -pub mod user; \ No newline at end of file diff --git a/src/structs/quote.rs b/src/structs/quote.rs new file mode 100644 index 0000000..8e8bce6 --- /dev/null +++ b/src/structs/quote.rs @@ -0,0 +1,11 @@ +use sqlx::types::time::OffsetDateTime; + +#[derive(Debug)] +pub struct Quote { + pub quote_id: i32, + pub user_id: i64, + pub username: String, + pub quote: String, + pub added_by: i64, + pub added_at: OffsetDateTime, +}