diff --git a/.gitignore b/.gitignore index 0b745e2..aafb6ba 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,3 @@ /target -.env \ No newline at end of file +.env +config.toml \ No newline at end of file diff --git a/Cargo.lock b/Cargo.lock index 2e9fa91..242fda2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -88,6 +88,12 @@ dependencies = [ "num-traits", ] +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + [[package]] name = "autocfg" version = "1.4.0" @@ -229,11 +235,34 @@ checksum = "a21f936df1771bf62b77f047b726c4625ff2e8aa607c01ec06e5a05bd8463401" dependencies = [ "android-tzdata", "iana-time-zone", + "js-sys", "num-traits", "serde", + "wasm-bindgen", "windows-targets 0.52.6", ] +[[package]] +name = "chrono-tz" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd6dd8046d00723a59a2f8c5f295c515b9bb9a331ee4f8f3d4dd49e428acd3b6" +dependencies = [ + "chrono", + "chrono-tz-build", + "phf", +] + +[[package]] +name = "chrono-tz-build" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e94fea34d77a245229e7746bd2beb786cd2a896f306ff491fb8cecb3074b10a7" +dependencies = [ + "parse-zoneinfo", + "phf_codegen", +] + [[package]] name = "command_attr" version = "0.5.3" @@ -563,6 +592,21 @@ version = "1.0.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" +[[package]] +name = "foreign-types" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1" +dependencies = [ + "foreign-types-shared", +] + +[[package]] +name = "foreign-types-shared" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b" + [[package]] name = "form_urlencoded" version = "1.2.1" @@ -732,6 +776,25 @@ dependencies = [ "tracing", ] +[[package]] +name = "h2" +version = "0.4.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccae279728d634d083c00f6099cb58f01cc99c145b84b8be2f6c74618d79922e" +dependencies = [ + "atomic-waker", + "bytes", + "fnv", + "futures-core", + "futures-sink", + "http 1.1.0", + "indexmap", + "slab", + "tokio", + "tokio-util", + "tracing", +] + [[package]] name = "hashbrown" version = "0.14.5" @@ -835,6 +898,29 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "http-body" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" +dependencies = [ + "bytes", + "http 1.1.0", +] + +[[package]] +name = "http-body-util" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "793429d76616a256bcb62c2a2ec2bed781c8307e797e2598c50010f2bee2544f" +dependencies = [ + "bytes", + "futures-util", + "http 1.1.0", + "http-body 1.0.1", + "pin-project-lite", +] + [[package]] name = "httparse" version = "1.9.5" @@ -857,9 +943,9 @@ dependencies = [ "futures-channel", "futures-core", "futures-util", - "h2", + "h2 0.3.26", "http 0.2.12", - "http-body", + "http-body 0.4.6", "httparse", "httpdate", "itoa", @@ -871,6 +957,26 @@ dependencies = [ "want", ] +[[package]] +name = "hyper" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97818827ef4f364230e16705d4706e2897df2bb60617d6ca15d598025a3c481f" +dependencies = [ + "bytes", + "futures-channel", + "futures-util", + "h2 0.4.7", + "http 1.1.0", + "http-body 1.0.1", + "httparse", + "itoa", + "pin-project-lite", + "smallvec", + "tokio", + "want", +] + [[package]] name = "hyper-rustls" version = "0.24.2" @@ -879,12 +985,64 @@ checksum = "ec3efd23720e2049821a693cbc7e65ea87c72f1c58ff2f9522ff332b1491e590" dependencies = [ "futures-util", "http 0.2.12", - "hyper", + "hyper 0.14.31", "rustls 0.21.12", "tokio", "tokio-rustls 0.24.1", ] +[[package]] +name = "hyper-rustls" +version = "0.27.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08afdbb5c31130e3034af566421053ab03787c640246a446327f550d11bcb333" +dependencies = [ + "futures-util", + "http 1.1.0", + "hyper 1.5.1", + "hyper-util", + "rustls 0.23.18", + "rustls-pki-types", + "tokio", + "tokio-rustls 0.26.0", + "tower-service", +] + +[[package]] +name = "hyper-tls" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70206fc6890eaca9fde8a0bf71caa2ddfc9fe045ac9e5c70df101a7dbde866e0" +dependencies = [ + "bytes", + "http-body-util", + "hyper 1.5.1", + "hyper-util", + "native-tls", + "tokio", + "tokio-native-tls", + "tower-service", +] + +[[package]] +name = "hyper-util" +version = "0.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df2dcfbe0677734ab2f3ffa7fa7bfd4706bfdc1ef393f2ee30184aed67e631b4" +dependencies = [ + "bytes", + "futures-channel", + "futures-util", + "http 1.1.0", + "http-body 1.0.1", + "hyper 1.5.1", + "pin-project-lite", + "socket2", + "tokio", + "tower-service", + "tracing", +] + [[package]] name = "iana-time-zone" version = "0.1.61" @@ -1224,6 +1382,23 @@ dependencies = [ "windows-sys 0.52.0", ] +[[package]] +name = "native-tls" +version = "0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8614eb2c83d59d1c8cc974dd3f920198647674a0a035e1af1fa58707e317466" +dependencies = [ + "libc", + "log", + "openssl", + "openssl-probe", + "openssl-sys", + "schannel", + "security-framework", + "security-framework-sys", + "tempfile", +] + [[package]] name = "nom" version = "7.1.3" @@ -1312,6 +1487,50 @@ version = "1.20.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1261fe7e33c73b354eab43b1273a57c8f967d0391e80353e51f764ac02cf6775" +[[package]] +name = "openssl" +version = "0.10.68" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6174bc48f102d208783c2c84bf931bb75927a617866870de8a4ea85597f871f5" +dependencies = [ + "bitflags 2.6.0", + "cfg-if", + "foreign-types", + "libc", + "once_cell", + "openssl-macros", + "openssl-sys", +] + +[[package]] +name = "openssl-macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.89", +] + +[[package]] +name = "openssl-probe" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff011a302c396a5197692431fc1948019154afc178baf7d8e37367442a4601cf" + +[[package]] +name = "openssl-sys" +version = "0.9.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "45abf306cbf99debc8195b66b7346498d7b10c210de50418b5ccd7ceba08c741" +dependencies = [ + "cc", + "libc", + "pkg-config", + "vcpkg", +] + [[package]] name = "overload" version = "0.1.1" @@ -1347,6 +1566,15 @@ dependencies = [ "windows-targets 0.52.6", ] +[[package]] +name = "parse-zoneinfo" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f2a05b18d44e2957b88f96ba460715e295bc1d7510468a2f3d3b44535d26c24" +dependencies = [ + "regex", +] + [[package]] name = "paste" version = "1.0.15" @@ -1368,6 +1596,44 @@ version = "2.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e3148f5046208a5d56bcfc03053e3ca6334e51da8dfb19b6cdc8b306fae3283e" +[[package]] +name = "phf" +version = "0.11.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ade2d8b8f33c7333b51bcf0428d37e217e9f32192ae4772156f65063b8ce03dc" +dependencies = [ + "phf_shared", +] + +[[package]] +name = "phf_codegen" +version = "0.11.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8d39688d359e6b34654d328e262234662d16cc0f60ec8dcbe5e718709342a5a" +dependencies = [ + "phf_generator", + "phf_shared", +] + +[[package]] +name = "phf_generator" +version = "0.11.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48e4cc64c2ad9ebe670cb8fd69dd50ae301650392e81c05f9bfcb2d5bdbc24b0" +dependencies = [ + "phf_shared", + "rand", +] + +[[package]] +name = "phf_shared" +version = "0.11.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90fcb95eef784c2ac79119d1dd819e162b5da872ce6f3c3abe1e8ca1c082f72b" +dependencies = [ + "siphasher", +] + [[package]] name = "pin-project-lite" version = "0.2.15" @@ -1559,11 +1825,11 @@ dependencies = [ "encoding_rs", "futures-core", "futures-util", - "h2", + "h2 0.3.26", "http 0.2.12", - "http-body", - "hyper", - "hyper-rustls", + "http-body 0.4.6", + "hyper 0.14.31", + "hyper-rustls 0.24.2", "ipnet", "js-sys", "log", @@ -1577,8 +1843,8 @@ dependencies = [ "serde", "serde_json", "serde_urlencoded", - "sync_wrapper", - "system-configuration", + "sync_wrapper 0.1.2", + "system-configuration 0.5.1", "tokio", "tokio-rustls 0.24.1", "tokio-util", @@ -1592,6 +1858,49 @@ dependencies = [ "winreg", ] +[[package]] +name = "reqwest" +version = "0.12.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a77c62af46e79de0a562e1a9849205ffcb7fc1238876e9bd743357570e04046f" +dependencies = [ + "base64 0.22.1", + "bytes", + "encoding_rs", + "futures-core", + "futures-util", + "h2 0.4.7", + "http 1.1.0", + "http-body 1.0.1", + "http-body-util", + "hyper 1.5.1", + "hyper-rustls 0.27.3", + "hyper-tls", + "hyper-util", + "ipnet", + "js-sys", + "log", + "mime", + "native-tls", + "once_cell", + "percent-encoding", + "pin-project-lite", + "rustls-pemfile 2.2.0", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper 1.0.2", + "system-configuration 0.6.1", + "tokio", + "tokio-native-tls", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "windows-registry", +] + [[package]] name = "ring" version = "0.17.8" @@ -1746,6 +2055,15 @@ dependencies = [ "winapi-util", ] +[[package]] +name = "schannel" +version = "0.1.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f29ebaa345f945cec9fbbc532eb307f0fdad8161f281b6369539c8d84876b3d" +dependencies = [ + "windows-sys 0.59.0", +] + [[package]] name = "scopeguard" version = "1.2.0" @@ -1772,6 +2090,29 @@ dependencies = [ "zeroize", ] +[[package]] +name = "security-framework" +version = "2.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "897b2245f0b511c87893af39b033e5ca9cce68824c4d7e7630b5a1d339658d02" +dependencies = [ + "bitflags 2.6.0", + "core-foundation", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework-sys" +version = "2.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa39c7303dc58b5543c94d22c1766b0d31f2ee58306363ea622b10bbc075eaa2" +dependencies = [ + "core-foundation-sys", + "libc", +] + [[package]] name = "semver" version = "1.0.23" @@ -1822,6 +2163,15 @@ dependencies = [ "serde", ] +[[package]] +name = "serde_spanned" +version = "0.6.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "87607cb1398ed59d48732e575a4c28a7a8ebf2454b964fe3f224f2afc07909e1" +dependencies = [ + "serde", +] + [[package]] name = "serde_urlencoded" version = "0.7.1" @@ -1855,7 +2205,7 @@ dependencies = [ "mime_guess", "parking_lot", "percent-encoding", - "reqwest", + "reqwest 0.11.27", "secrecy", "serde", "serde_cow", @@ -1931,16 +2281,27 @@ dependencies = [ name = "sillycord-bot" version = "0.1.0" dependencies = [ + "chrono", + "chrono-tz", "dotenv", "poise", + "reqwest 0.12.9", "serde", + "serde_json", "serenity", "sqlx", "tokio", + "toml", "tracing", "tracing-subscriber", ] +[[package]] +name = "siphasher" +version = "0.3.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38b58827f4464d87d377d175e90bf58eb00fd8716ff0a62f80356b5e61555d0d" + [[package]] name = "skeptic" version = "0.13.7" @@ -2273,6 +2634,15 @@ version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2047c6ded9c721764247e62cd3b03c09ffc529b2ba5b10ec482ae507a4a70160" +[[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" +dependencies = [ + "futures-core", +] + [[package]] name = "synstructure" version = "0.13.1" @@ -2292,7 +2662,18 @@ checksum = "ba3a3adc5c275d719af8cb4272ea1c4a6d668a777f37e115f6d11ddbc1c8e0e7" dependencies = [ "bitflags 1.3.2", "core-foundation", - "system-configuration-sys", + "system-configuration-sys 0.5.0", +] + +[[package]] +name = "system-configuration" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c879d448e9d986b661742763247d3693ed13609438cf3d006f51f5368a5ba6b" +dependencies = [ + "bitflags 2.6.0", + "core-foundation", + "system-configuration-sys 0.6.0", ] [[package]] @@ -2305,6 +2686,16 @@ dependencies = [ "libc", ] +[[package]] +name = "system-configuration-sys" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e1d1b10ced5ca923a1fcb8d03e96b8d3268065d724548c0211415ff6ac6bac4" +dependencies = [ + "core-foundation-sys", + "libc", +] + [[package]] name = "tagptr" version = "0.2.0" @@ -2439,6 +2830,16 @@ dependencies = [ "syn 2.0.89", ] +[[package]] +name = "tokio-native-tls" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbae76ab933c85776efabc971569dd6119c580d8f5d448769dec1764bf796ef2" +dependencies = [ + "native-tls", + "tokio", +] + [[package]] name = "tokio-rustls" version = "0.24.1" @@ -2460,6 +2861,17 @@ dependencies = [ "tokio", ] +[[package]] +name = "tokio-rustls" +version = "0.26.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c7bc40d0e5a97695bb96e27995cd3a08538541b0a846f65bba7a359f36700d4" +dependencies = [ + "rustls 0.23.18", + "rustls-pki-types", + "tokio", +] + [[package]] name = "tokio-stream" version = "0.1.16" @@ -2500,6 +2912,40 @@ dependencies = [ "tokio", ] +[[package]] +name = "toml" +version = "0.8.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1ed1f98e3fdc28d6d910e6737ae6ab1a93bf1985935a1193e68f93eeb68d24e" +dependencies = [ + "serde", + "serde_spanned", + "toml_datetime", + "toml_edit", +] + +[[package]] +name = "toml_datetime" +version = "0.6.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0dd7358ecb8fc2f8d014bf86f6f638ce72ba252a2c3a2572f2a795f1d23efb41" +dependencies = [ + "serde", +] + +[[package]] +name = "toml_edit" +version = "0.22.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ae48d6208a266e853d946088ed816055e556cc6028c5e8e2b84d9fa5dd7c7f5" +dependencies = [ + "indexmap", + "serde", + "serde_spanned", + "toml_datetime", + "winnow", +] + [[package]] name = "tower-service" version = "0.3.3" @@ -2923,6 +3369,36 @@ dependencies = [ "windows-targets 0.52.6", ] +[[package]] +name = "windows-registry" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e400001bb720a623c1c69032f8e3e4cf09984deec740f007dd2b03ec864804b0" +dependencies = [ + "windows-result", + "windows-strings", + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-result" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d1043d8214f791817bab27572aaa8af63732e11bf84aa21a45a78d6c317ae0e" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-strings" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cd9b125c486025df0eabcb585e62173c6c9eddcec5d117d3b6e8c30e2ee4d10" +dependencies = [ + "windows-result", + "windows-targets 0.52.6", +] + [[package]] name = "windows-sys" version = "0.48.0" @@ -3071,6 +3547,15 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" +[[package]] +name = "winnow" +version = "0.6.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "36c1fec1a2bb5866f07c25f68c26e565c4c200aebb96d7e55710c19d3e8ac49b" +dependencies = [ + "memchr", +] + [[package]] name = "winreg" version = "0.50.0" diff --git a/Cargo.toml b/Cargo.toml index 9f150e9..67f2a77 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -11,4 +11,9 @@ sqlx = { version = "0.8", features = [ "runtime-tokio", "tls-rustls-ring", "mysq tokio = { version = "1", features = ["full"] } tracing = "0.1.40" tracing-subscriber = "0.3.18" -serde = { version = "1.0.215", features = ["derive"] } \ No newline at end of file +serde = { version = "1.0.215", features = ["derive"] } +toml = "0.8.19" +chrono = "0.4.38" +chrono-tz = "0.10.0" +reqwest = "0.12.9" +serde_json = "1.0.133" diff --git a/migrations/20241125045124_init.sql b/migrations/20241125045124_init.sql new file mode 100644 index 0000000..dda48a0 --- /dev/null +++ b/migrations/20241125045124_init.sql @@ -0,0 +1,9 @@ +-- Create basic users table for fun stuff around the bot +CREATE TABLE users ( + id SERIAL PRIMARY KEY, + discord_id VARCHAR(255) NOT NULL UNIQUE, + actions_allowed TINYINT DEFAULT 1, + about TEXT, + pronouns VARCHAR(255) +); + diff --git a/src/commands/action.rs b/src/commands/action.rs new file mode 100644 index 0000000..8989184 --- /dev/null +++ b/src/commands/action.rs @@ -0,0 +1,159 @@ +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; + +async fn check_if_allowed(ctx: Context<'_>, message: Message) -> Result { + let user = ctx + .data() + .database_controller + .get_user_by_discord_id(message.author.id.into()) + .await?; + + if user.is_none() { + ctx.data() + .database_controller + .create_user(message.author.id.into()) + .await?; + } + + let user = ctx + .data() + .database_controller + .get_user_by_discord_id(message.author.id.into()) + .await?; + + Ok(user.unwrap().actions_allowed) +} + +#[poise::command(context_menu_command = "Hug User")] +pub async fn use_action_hug( + ctx: Context<'_>, + #[description = "The target message to use the action with"] message: Message, +) -> Result<(), Error> { + ctx.defer().await?; + + let allowed = check_if_allowed(ctx, message.clone()).await?; + + if !allowed { + ctx.send(CreateReply::default().content( + ":x: Sorry, either that user has disabled actions or has no profile to check against", + ).ephemeral(true)) + .await?; + return Ok(()); + } + + let action_img = utils::get_random_action_image("hug".to_string()).await; + + // Match the result of the action image + match action_img { + Ok(img) => { + let user = UserId::new(ctx.author().id.into()); + let target = UserId::new(message.clone().author.id.into()); + let builder = CreateReply::default() + .content(format!("{} hugs {}", user.mention(), target.mention())) + .attachment(CreateAttachment::url(ctx.http(), &img).await?) + .allowed_mentions(CreateAllowedMentions::default().users(vec![user, target])); + + ctx.send(builder).await?; + } + Err(_) => { + ctx.send( + CreateReply::default() + .content(":x: Something went wrong while fetching the action image") + .ephemeral(true), + ) + .await?; + } + } + + Ok(()) +} + +#[poise::command(context_menu_command = "Kiss User")] +pub async fn use_action_kiss( + ctx: Context<'_>, + #[description = "The target message to use the action with"] message: Message, +) -> Result<(), Error> { + ctx.defer().await?; + let allowed = check_if_allowed(ctx, message.clone()).await?; + + if !allowed { + ctx.send(CreateReply::default().content( + ":x: Sorry, either that user has disabled actions or has no profile to check against", + ).ephemeral(true)) + .await?; + return Ok(()); + } + + let action_img = utils::get_random_action_image("kiss".to_string()).await; + + // Match the result of the action image + match action_img { + Ok(img) => { + let user = UserId::new(ctx.author().id.into()); + let target = UserId::new(message.author.id.into()); + let builder = CreateReply::default() + .content(format!("{} kisses {}", user.mention(), target.mention())) + .attachment(CreateAttachment::url(ctx.http(), &img).await?) + .allowed_mentions(CreateAllowedMentions::default().users(vec![user, target])); + + ctx.send(builder).await?; + } + Err(_) => { + ctx.send( + CreateReply::default() + .content(":x: Something went wrong while fetching the action image") + .ephemeral(true), + ) + .await?; + } + } + + Ok(()) +} + +#[poise::command(context_menu_command = "Pat User")] +pub async fn use_action_pat( + ctx: Context<'_>, + #[description = "The target message to use the action with"] message: Message, +) -> Result<(), Error> { + ctx.defer().await?; + let allowed = check_if_allowed(ctx, message.clone()).await?; + + if !allowed { + ctx.send(CreateReply::default().content( + ":x: Sorry, either that user has disabled actions or has no profile to check against", + ).ephemeral(true)) + .await?; + return Ok(()); + } + + let action_img = utils::get_random_action_image("pat".to_string()).await; + + // Match the result of the action image + match action_img { + Ok(img) => { + let user = UserId::new(ctx.author().id.into()); + let target = UserId::new(message.author.id.into()); + let builder = CreateReply::default() + .content(format!("{} pats {}", user.mention(), target.mention())) + .attachment(CreateAttachment::url(ctx.http(), &img).await?) + .allowed_mentions(CreateAllowedMentions::default().users(vec![user, target])); + + ctx.send(builder).await?; + } + Err(_) => { + ctx.send( + CreateReply::default() + .content(":x: Something went wrong while fetching the action image") + .ephemeral(true), + ) + .await?; + } + } + + Ok(()) +} diff --git a/src/commands/cta.rs b/src/commands/cta.rs new file mode 100644 index 0000000..a9f0101 --- /dev/null +++ b/src/commands/cta.rs @@ -0,0 +1,34 @@ +use poise::CreateReply; +use serenity::{all::CreateEmbed, model::colour}; +use reqwest::Client; +use serde_json::Value; +use crate::{Context, Error}; + +/// Returns a random cat image, along with a random cat fact. +#[poise::command(slash_command)] +pub async fn cta(ctx: Context<'_>) -> Result<(), Error> { + let cat_fact_res = Client::new() + .get("https://catfact.ninja/fact") + .send() + .await?; + + let cat_fact: Value = serde_json::from_str(&cat_fact_res.text().await?)?; + let cat_fact = cat_fact["fact"].as_str().unwrap(); + + let cat_image_res = Client::new() + .get("https://api.thecatapi.com/v1/images/search") + .send() + .await?; + + let cat_image: Value = serde_json::from_str(&cat_image_res.text().await?)?; + let cat_image = cat_image[0]["url"].as_str().unwrap(); + + let embed = CreateEmbed::default() + .title("Random Cat") + .description(cat_fact) + .image(cat_image) + .color(colour::Colour::from_rgb(0, 255, 255)); + + ctx.send(CreateReply::default().embed(embed)).await?; + Ok(()) +} diff --git a/src/commands/dog.rs b/src/commands/dog.rs new file mode 100644 index 0000000..1295e8c --- /dev/null +++ b/src/commands/dog.rs @@ -0,0 +1,25 @@ +use crate::{Context, Error}; +use poise::CreateReply; +use reqwest::Client; +use serde_json::Value; +use serenity::{all::CreateEmbed, model::colour}; + +/// Returns a random dog image. +#[poise::command(slash_command)] +pub async fn dog(ctx: Context<'_>) -> Result<(), Error> { + let dog_res = Client::new() + .get("https://dog.ceo/api/breeds/image/random") + .send() + .await?; + + let dog: Value = serde_json::from_str(&dog_res.text().await?)?; + let dog = dog["message"].as_str().unwrap(); + + let embed = CreateEmbed::default() + .title("Random Dog") + .image(dog) + .color(colour::Colour::from_rgb(0, 255, 255)); + + ctx.send(CreateReply::default().embed(embed)).await?; + Ok(()) +} diff --git a/src/commands/mod.rs b/src/commands/mod.rs index a766209..c5fafd5 100644 --- a/src/commands/mod.rs +++ b/src/commands/mod.rs @@ -1 +1,6 @@ pub mod ping; +pub mod vouch; +pub mod cta; +pub mod dog; +pub mod profile; +pub mod action; \ No newline at end of file diff --git a/src/commands/ping.rs b/src/commands/ping.rs index 0774b67..7448b90 100644 --- a/src/commands/ping.rs +++ b/src/commands/ping.rs @@ -1,16 +1,32 @@ +use poise::CreateReply; +use serenity::{all::CreateEmbed, model::colour}; + use crate::{Context, Error}; +/// Pong? Returns the ping of the bot and process uptime #[poise::command(slash_command)] pub async fn ping(ctx: Context<'_>) -> Result<(), Error> { let start = std::time::Instant::now(); - let msg = ctx.say("Pong?").await?; - let latency = start.elapsed(); - msg.edit( - ctx, - poise::CreateReply::default().content(format!("Pong! Latency: {:?}", latency)), - ) - .await?; + // Create pining embed + let mut embed = CreateEmbed::default() + .title("Ping?") + .description("Pinging...") + .color(colour::Colour::PURPLE); + let msg = ctx.send(CreateReply::default().embed(embed)).await?; + + let ping = start.elapsed().as_millis(); + embed = CreateEmbed::default() + .title("Pong!") + .description(format!( + "Pong! Took {}ms!\nProcess uptime: {:?}", + ping, + // Round to 2 decimal places + (ctx.data().uptime.elapsed().as_secs_f64() * 100.0).floor() / 100.0 + )) + .color(colour::Colour::DARK_GREEN); + + msg.edit(ctx, CreateReply::default().embed(embed)).await?; // Edit the message with the ping Ok(()) } diff --git a/src/commands/profile.rs b/src/commands/profile.rs new file mode 100644 index 0000000..5bd3c50 --- /dev/null +++ b/src/commands/profile.rs @@ -0,0 +1,142 @@ +use crate::structs::user::User as UserStruct; +use crate::{Context, Error}; +use poise::CreateReply; +use serenity::all::{Colour, CreateEmbed, User}; + +/// Commands related to profiles in the bot +#[poise::command( + slash_command, + subcommands("view", "edit"), + check = "ensure_profile_is_setup" +)] +pub async fn profiles(_ctx: Context<'_>) -> Result<(), Error> { + Ok(()) +} + +/// Ensure the user has a profile setup +/// If the user doesn't have a profile, create one +async fn ensure_profile_is_setup(ctx: Context<'_>) -> Result { + let profile = ctx + .data() + .database_controller + .get_user_by_discord_id(ctx.author().id.into()) + .await?; + + if profile.is_none() { + ctx.data() + .database_controller + .create_user(ctx.author().id.into()) + .await?; + } + + Ok(true) +} + +/// View a user's profile +#[poise::command(slash_command)] +pub async fn view( + _ctx: Context<'_>, + #[description = "The user to view the profile of, defaults to yourself"] user: Option, +) -> Result<(), Error> { + // Get the profile of the provided user, or the user who ran the command + let target_user = user.unwrap_or_else(|| _ctx.author().clone()); + + // Get the profile of the target user + let profile = _ctx + .data() + .database_controller + .get_user_by_discord_id(target_user.id.into()) + .await?; + + match profile { + Some(profile) => { + let profile_embed = CreateEmbed::default() + .title(format!("Profile of {}", target_user.tag())) + .description(format!( + "About: {}\nPronouns: {}\nActions Allowed: {}", + profile.about.unwrap_or("No about section".to_string()), + profile.pronouns.unwrap_or("No pronouns set".to_string()), + if profile.actions_allowed { "Yes" } else { "No" } + )) + .color(Colour::FABLED_PINK); + + _ctx.send(CreateReply::default().embed(profile_embed)) + .await?; + } + None => { + // Send a emphul messagrespond(formate if the user doesn't have a profile + _ctx.send( + CreateReply::default() + .content(":x: User has no profile") + .ephemeral(true), + ) + .await?; + } + } + + Ok(()) +} + +/// Edit your profile +#[poise::command(slash_command)] +pub async fn edit( + _ctx: Context<'_>, + #[description = "Your about section"] about: Option, + #[description = "Your pronouns"] pronouns: Option, + #[description = "Whether you want to allow actions to be performed on you"] + actions_allowed: Option, +) -> Result<(), Error> { + // If no options are provided, send a help message + if about.is_none() && pronouns.is_none() && actions_allowed.is_none() { + _ctx.send( + CreateReply::default() + .content("Please provide at least one option to edit") + .ephemeral(true), + ) + .await?; + return Ok(()); + } + + // Get the profile of the user who ran the command + let profile = _ctx + .data() + .database_controller + .get_user_by_discord_id(_ctx.author().id.into()) + .await?; + + // If the user doesn't have a profile, create one + let profile = match profile { + Some(profile) => profile, + None => { + _ctx.data() + .database_controller + .create_user(_ctx.author().id.into()) + .await? + } + }; + + // Generate a user struct with the updated values + let updated_profile = UserStruct { + id: profile.id, + discord_id: profile.discord_id, + about, + pronouns, + actions_allowed: actions_allowed.unwrap_or(profile.actions_allowed), + }; + + // Update the user's profile + _ctx.data() + .database_controller + .update_user(updated_profile) + .await?; + + // Send a success message + _ctx.send( + CreateReply::default() + .content(":white_check_mark: Profile updated successfully!") + .ephemeral(true), + ) + .await?; + + Ok(()) +} diff --git a/src/commands/vouch.rs b/src/commands/vouch.rs new file mode 100644 index 0000000..b6e015c --- /dev/null +++ b/src/commands/vouch.rs @@ -0,0 +1,303 @@ +use crate::{structs::vouch::Vouch, Context, Error}; +use serenity::all::{ + Colour, CreateAllowedMentions, CreateEmbed, CreateEmbedFooter, CreateMessage, Mentionable, User, +}; + +/// Commands related to vouching for new users +#[poise::command(slash_command, subcommands("submit", "approve", "deny"))] +pub async fn vouch(_ctx: Context<'_>) -> Result<(), Error> { + Ok(()) +} + +/// Vouch for a new user +#[poise::command(slash_command, guild_only)] +pub async fn submit( + ctx: Context<'_>, + #[description = "The user to vouch for"] user: User, +) -> Result<(), Error> { + // Defer emphemeral response + ctx.defer_ephemeral().await?; + + // Do we have a vouch for this user already? + if ctx + .data() + .vouch_store + .lock() + .await + .iter() + .any(|vouch| vouch.user.id == user.id) + { + ctx.say(":x: This user already has a vouch pending!") + .await?; + return Ok(()); + } + + let guild_id = ctx.guild_id().ok_or("Must be run in a guild")?; + let member = guild_id.member(ctx.serenity_context(), user.id).await?; + + // Does the user have a silly role? + if member + .roles + .iter() + .any(|role_id| *role_id == ctx.data().config.roles.silly_role) + { + ctx.say(":x: This user already is a vouched member!") + .await?; + return Ok(()); + } + + // Create a new vouch + let vouch = Vouch::new(user.clone(), ctx.author().clone()); + + // Add the vouch to the store, we need to create a mutable reference to .data().vouch_store, + // because we want to modify the store - and poise context is immutable by default + ctx.data().vouch_store.lock().await.push(vouch); + + // Send a messasge to the mod-logs channel with a ping that a new vouch has been submitted + let log_msg = format!( + "{} {}\n:notepad_spiral: A new vouch has been submitted for {} by {}, please either approve or deny this vouch.", + serenity::model::id::RoleId::new(ctx.data().config.roles.mod_role).mention(), + serenity::model::id::RoleId::new(ctx.data().config.roles.admin).mention(), + user.clone().mention(), + ctx.author().mention() + ); + + let log_channel_id = ctx.data().config.channels.logs_mod; + let channel_hash = guild_id.channels(ctx.serenity_context()).await?; + let channel = channel_hash.get(&log_channel_id.into()).unwrap(); + + channel + .send_message( + ctx.serenity_context(), + CreateMessage::new().content(log_msg).allowed_mentions( + CreateAllowedMentions::new().roles(vec![ + ctx.data().config.roles.mod_role, + ctx.data().config.roles.admin, + ]), + ), + ) + .await?; + + ctx.say(":white_check_mark: Vouch submitted! An admin will review the vouch when able.") + .await?; + + Ok(()) +} + +/// Approve a vouch for a user +#[poise::command(slash_command, guild_only)] +pub async fn approve( + ctx: Context<'_>, + #[description = "The user to approve the vouch for"] user: User, +) -> Result<(), Error> { + // Defer emphemeral response + ctx.defer_ephemeral().await?; + + // Grab user + let author_user = ctx.author_member().await.clone(); + match author_user { + Some(author_user) => { + // Check if the author is an admin + if !author_user.roles.iter().any(|role_id| { + *role_id == ctx.data().config.roles.admin + || *role_id == ctx.data().config.roles.mod_role + }) { + ctx.say(":x: You must be an admin to approve vouches!") + .await?; + return Ok(()); + } + } + None => { + ctx.say(":x: You must be an admin to approve vouches!") + .await?; + return Ok(()); + } + } + + // Do we have a vouch for this user? + let mut vouch_store = ctx.data().vouch_store.lock().await; + + // Find the vouch for the user and its index + if let Some((vouch_index, vouch)) = vouch_store + .iter() + .enumerate() + .find(|(_, v)| v.user.id == user.id) + .map(|(index, vouch)| (index, vouch.clone())) + { + // Add the silly role to the user + let guild_id = ctx.guild_id().ok_or("Must be run in a guild")?; + let member = guild_id.member(ctx.serenity_context(), user.id).await?; + + member + .add_role(ctx.serenity_context(), ctx.data().config.roles.silly_role) + .await?; + + // Remove the vouch from the store + vouch_store.remove(vouch_index); + + // Send a message to the logs channel (mod) + let log_msg = format!( + ":white_check_mark: Vouch approved for {} by {} at {} CDT, vouched by {}", + user.mention(), + ctx.author().mention(), + vouch.get_vouch_time(), + vouch.vouched_by.mention() + ); + + let public_msg = CreateEmbed::default() + .title(format!("Welcome to sillycord, {}!", user.tag())) + .description("Enjoy your stay in our silly little community!") + .footer(CreateEmbedFooter::new(format!( + "Vouched by {} - Approved by {}", + vouch.vouched_by.tag(), + ctx.author().tag() + ))) + .color(Colour::DARK_PURPLE); + + let log_channel_id = ctx.data().config.channels.logs_mod; + let public_channel_id = ctx.data().config.channels.main; + + let channels = guild_id.channels(ctx.serenity_context()).await?; + let channel = channels.get(&log_channel_id.into()).unwrap(); + let public_channel = channels.get(&public_channel_id.into()).unwrap(); + + channel + .send_message( + ctx.serenity_context(), + CreateMessage::new().content(log_msg), + ) + .await?; + + public_channel + .send_message( + ctx.serenity_context(), + CreateMessage::new() + .embed(public_msg) + .content(user.mention().to_string()) + .allowed_mentions(CreateAllowedMentions::new().users(vec![user.id])), + ) + .await?; + + ctx.data() + .database_controller + .create_user(user.id.into()) + .await?; + ctx.say(":white_check_mark: Vouch approved!").await?; + } else { + ctx.say(":x: No vouch found for this user!").await?; + } + + drop(vouch_store); + Ok(()) +} + +/// Deny a vouch for a user +#[poise::command(slash_command, guild_only)] +pub async fn deny( + ctx: Context<'_>, + #[description = "The user to deny the vouch for"] user: User, + #[flag] kick: bool, + #[description = "The reason for denying the vouch"] _reason: Option, +) -> Result<(), Error> { + // Defer emphemeral response + ctx.defer_ephemeral().await?; + + // Grab user + let author_user = ctx.author_member().await.clone(); + match author_user { + Some(author_user) => { + // Check if the author is an admin + if !author_user.roles.iter().any(|role_id| { + *role_id == ctx.data().config.roles.admin + || *role_id == ctx.data().config.roles.mod_role + }) { + ctx.say(":x: You must be an admin to deny vouches!").await?; + return Ok(()); + } + } + None => { + ctx.say(":x: You must be an admin to deny vouches!").await?; + return Ok(()); + } + } + + // Do we have a vouch for this user? + // Do we have a vouch for this user? + let mut vouch_store = ctx.data().vouch_store.lock().await; + + // Find the vouch for the user and its index + if let Some((vouch_index, vouch)) = vouch_store + .iter() + .enumerate() + .find(|(_, v)| v.user.id == user.id) + .map(|(index, vouch)| (index, vouch.clone())) + { + // Remove the vouch from the store + vouch_store.remove(vouch_index); + + // Send a message to the logs channel (mod) + let log_msg = format!( + ":x: Vouch denied for {} by {} at {} CDT, vouched by {} with a reason of '{}'", + user.mention(), + ctx.author().mention(), + vouch.get_vouch_time(), + vouch.vouched_by.mention(), + _reason.clone().unwrap_or("No reason provided".to_string()) + ); + + let log_channel_id = ctx.data().config.channels.logs_mod; + let guild = ctx.guild_id().unwrap().clone(); + let channels = guild.channels(ctx.serenity_context()).await?; + let channel = channels.get(&log_channel_id.into()).unwrap(); + + channel + .send_message( + ctx.serenity_context(), + CreateMessage::new().content(log_msg), + ) + .await?; + + // Then kick the user + let member = guild.member(ctx.serenity_context(), user.id).await?; + + // Attempt to notify then kick the user + let dm_msg = format!( + ":warning: Your vouch has been denied by {} with a reason of '{}'. If you have any questions, feel free to ask a moderator or admin.", + ctx.author().mention(), + _reason.unwrap_or("No reason provided".to_string()) + ); + + let notify_result = vouch + .user + .direct_message(ctx.serenity_context(), CreateMessage::new().content(dm_msg)) + .await; + + match notify_result { + Ok(_) => { + if kick { + member.kick(ctx.serenity_context()).await?; + ctx.say(":white_check_mark: Vouch denied and user kicked! User notified.") + .await?; + } else { + ctx.say(":white_check_mark: Vouch denied! User notified.") + .await?; + } + } + Err(_) => { + if kick { + member.kick(ctx.serenity_context()).await?; + ctx.say(":white_check_mark: Vouch denied and user kicked! User not notified.") + .await?; + } else { + ctx.say(":white_check_mark: Vouch denied! User not notified.") + .await?; + } + } + } + } else { + ctx.say(":x: No vouch found for this user!").await?; + } + + drop(vouch_store); + Ok(()) +} diff --git a/src/events.rs b/src/events.rs index 2beb3b8..dc5b004 100644 --- a/src/events.rs +++ b/src/events.rs @@ -1,13 +1,16 @@ -use crate::{Data, Error}; -use poise::serenity_prelude::{self as serenity, ActivityData, Interaction, OnlineStatus}; -use tracing::{info, warn}; +use crate::{handlers, utils::get_rustc_version, Data, Error}; +use ::serenity::all::{ + ChannelId, Colour, CreateEmbed, CreateEmbedFooter, CreateMessage, Mentionable, +}; +use poise::serenity_prelude::{self as serenity, ActivityData, OnlineStatus}; +use tracing::{error, info}; // Create a span for every event -#[tracing::instrument(skip(ctx, event, framework, data))] +#[tracing::instrument(skip(ctx, event, _framework, data))] pub async fn event_handler( ctx: &serenity::Context, event: &serenity::FullEvent, - framework: poise::FrameworkContext<'_, Data, Error>, + _framework: poise::FrameworkContext<'_, Data, Error>, data: &Data, ) -> Result<(), Error> { match event { @@ -17,6 +20,82 @@ pub async fn event_handler( Some(ActivityData::watching("sillycord")), OnlineStatus::Online, ); + + let embed = CreateEmbed::default() + .title("Bot Ready!") + .description(format!( + "Bot is ready! Running on rust version {}, sillycord-bot version {}", + get_rustc_version().await, + env!("CARGO_PKG_VERSION") + )) + .footer(CreateEmbedFooter::new( + "Bot created for sillycord. Made with ❤️ by sticks and others", + )) + .color(Colour::DARK_GREEN); + + let msg = CreateMessage::default().embed(embed); + let channel_id = ChannelId::new(data.config.channels.logs_public); + + // We have to use http here to be future safe and not block the event loop + ctx.http.send_message(channel_id, vec![], &msg).await?; + } + + serenity::FullEvent::GuildMemberAddition { new_member, .. } => { + // Is the new user in the main guild? + if new_member.guild_id != data.config.main_guild_id { + return Ok(()); + } + + info!("Handling new user {}", new_member.user.tag()); + + ctx.http + .send_message( + ChannelId::new(data.config.channels.logs_mod), + vec![], + &CreateMessage::default().content(format!( + "<:join:1310407968503894158> New user joined {} - created at: ", + new_member.mention(), + new_member.user.created_at().timestamp_millis() / 1000 + )), + ) + .await?; + + let handler_result = + handlers::join::join_handler(ctx.clone(), data, new_member.clone()).await; + + if let Err(e) = handler_result { + error!( + "Error invoking join handler for new user {}: {:?}", + new_member.user.tag(), + e + ); + } else { + info!( + "Join handler completed successfully for new user {}", + new_member.user.tag() + ); + } + } + + serenity::FullEvent::GuildMemberRemoval { user, .. } => { + // We can safely ignore the user's guild_id here, as they are leaving the guild - + // because the bot is only in one guild, we know the user is leaving the main guild + info!("Handling user leave {}", user.tag()); + + data.database_controller + .delete_user_by_discord_id(user.id.into()) + .await?; + + ctx.http + .send_message( + ChannelId::new(data.config.channels.logs_mod), + vec![], + &CreateMessage::default().content(format!( + "<:leave:1310407968503894158> User left {}", + user.mention() + )), + ) + .await?; } serenity::FullEvent::ShardsReady { total_shards, .. } => { diff --git a/src/handlers/db.rs b/src/handlers/db.rs new file mode 100644 index 0000000..78381f9 --- /dev/null +++ b/src/handlers/db.rs @@ -0,0 +1,102 @@ +use crate::structs::user::User; +use sqlx::MySqlPool; + +pub struct DatabaseController { + db: MySqlPool, +} + +impl DatabaseController { + pub fn new(db: MySqlPool) -> Self { + Self { db } + } + + pub async fn get_user_by_discord_id( + &self, + discord_id: u64, + ) -> Result, sqlx::Error> { + let user = sqlx::query!("SELECT * FROM users WHERE discord_id = ?", discord_id) + .fetch_optional(&self.db) + .await?; + + match user { + Some(user) => Ok(Some(User { + id: user.id, + discord_id: user + .discord_id + .parse::() + .map_err(|_| sqlx::Error::Decode("Failed to parse discord_id".into()))?, + actions_allowed: user.actions_allowed == Some(1), + about: user.about, + pronouns: user.pronouns, + })), + None => Ok(None), + } + } + + async fn get_user_by_id(&self, id: u64) -> Result, sqlx::Error> { + let user = sqlx::query!("SELECT * FROM users WHERE id = ?", id) + .fetch_optional(&self.db) + .await?; + + match user { + Some(user) => Ok(Some(User { + id: user.id, + discord_id: user + .discord_id + .parse::() + .map_err(|_| sqlx::Error::Decode("Failed to parse discord_id".into()))?, + actions_allowed: user.actions_allowed == Some(1), + about: user.about, + pronouns: user.pronouns, + })), + None => Ok(None), + } + } + + pub async fn create_user(&self, discord_id: u64) -> Result { + let user = sqlx::query!( + "INSERT INTO users (discord_id) VALUES (?)", + discord_id.to_string() + ) + .execute(&self.db) + .await?; + + Ok(User { + id: user.last_insert_id(), + discord_id, + actions_allowed: true, + about: None, + pronouns: None, + }) + } + + pub async fn update_user(&self, user: User) -> Result<(), sqlx::Error> { + sqlx::query!( + "UPDATE users SET actions_allowed = ?, about = ?, pronouns = ? WHERE id = ?", + user.actions_allowed as i8, + user.about, + user.pronouns, + user.id + ) + .execute(&self.db) + .await?; + + Ok(()) + } + + pub async fn delete_user(&self, user: User) -> Result<(), sqlx::Error> { + sqlx::query!("DELETE FROM users WHERE id = ?", user.id) + .execute(&self.db) + .await?; + + Ok(()) + } + + pub async fn delete_user_by_discord_id(&self, discord_id: u64) -> Result<(), sqlx::Error> { + sqlx::query!("DELETE FROM users WHERE discord_id = ?", discord_id) + .execute(&self.db) + .await?; + + Ok(()) + } +} diff --git a/src/handlers/join.rs b/src/handlers/join.rs new file mode 100644 index 0000000..6511241 --- /dev/null +++ b/src/handlers/join.rs @@ -0,0 +1,73 @@ +use serenity::all::{ + ChannelId, Colour, Context, CreateAllowedMentions, CreateEmbed, CreateEmbedFooter, + CreateMessage, Member, Mentionable, +}; +use std::error::Error; +use tracing::{error, warn}; + +#[tracing::instrument(skip(ctx, new_member, data))] +pub async fn join_handler( + ctx: Context, + data: &crate::Data, + new_member: Member, +) -> Result<(), Box> { + let welcome_msg = CreateEmbed::default() + .title("Welcome to sillycord!") + .description(format!( + "Welcome! To keep sillycord a safe and fun place, we require all newly invited members to be vouched by a current member. Please wait for a member of the community to vouch for you. If you do not receive a vouch within 24 hours, you will be removed from the server. If you have any questions, feel free to ask a moderator or admin.", + )) + .color(Colour::PURPLE) + .footer(CreateEmbedFooter::new( + "I am a bot, and this action was performed automatically. If you have any questions or concerns, please contact a moderator or admin.", + )); + + // Try to send the welcome message to the new member + let dm_result = new_member + .user + .direct_message(&ctx, CreateMessage::default().embed(welcome_msg.clone())) + .await; + + if let Err(why) = dm_result { + warn!( + "Failed to send welcome message to {}: {:?} - defaulting to public channel", + new_member.user.tag(), + why + ); + + let new_welcome = welcome_msg.clone().footer(CreateEmbedFooter::new( + "We were unable to send you a direct message. We've posted the welcome message here instead - please make sure to read it!", + )); + + let send_result = ctx + .http + .send_message( + ChannelId::new(data.config.channels.welcome), + vec![], + &CreateMessage::default() + .embed(new_welcome) + .content(format!( + "{} Please make sure to read the welcome message below!", + new_member.mention() + )) + .allowed_mentions(CreateAllowedMentions::new().users(vec![new_member.user.id])), + ) + .await; + + if let Err(why) = send_result { + error!( + "Failed to send welcome message to {} in public channel: {:?}", + new_member.user.tag(), + why + ); + + error!( + "All options to send welcome message to {} failed - aborting", + new_member.user.tag() + ); + + return Err(why.into()); + } + } + + Ok(()) +} diff --git a/src/handlers/mod.rs b/src/handlers/mod.rs index e69de29..89da20b 100644 --- a/src/handlers/mod.rs +++ b/src/handlers/mod.rs @@ -0,0 +1,2 @@ +pub mod join; +pub mod db; \ No newline at end of file diff --git a/src/main.rs b/src/main.rs index b4f4c2e..1b67686 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,10 +1,19 @@ mod commands; mod events; +mod handlers; +mod structs; +mod utils; use events::event_handler; -use serenity::all::{ClientBuilder, GatewayIntents}; +use handlers::db::DatabaseController; +use serde::{Deserialize, Serialize}; +use serenity::{ + all::{ClientBuilder, GatewayIntents}, + futures::lock::Mutex, +}; use sqlx::mysql::MySqlPoolOptions; use sqlx::MySqlPool; +use structs::vouch::Vouch; use tracing::{event, info, info_span, Level}; fn check_required_env_vars() { @@ -68,11 +77,37 @@ async fn init_sqlx() -> MySqlPool { } struct Data { - sqlx_pool: MySqlPool, + database_controller: DatabaseController, + uptime: std::time::Instant, + config: Config, + vouch_store: Mutex>, } // User data, which is stored and accessible in all command invocations -type Error = Box; + +pub type Error = Box; type Context<'a> = poise::Context<'a, Data, Error>; +#[derive(Deserialize, Serialize)] +struct Config { + main_guild_id: u64, + channels: Channels, + roles: Roles, +} + +#[derive(Deserialize, Serialize)] +struct Channels { + welcome: u64, + main: u64, + logs_public: u64, + logs_mod: u64, +} + +#[derive(Deserialize, Serialize)] +struct Roles { + admin: u64, + mod_role: u64, + silly_role: u64, +} + #[tokio::main] async fn main() { let start_time = std::time::Instant::now(); @@ -88,15 +123,57 @@ async fn main() { info!("checking required environment variables"); check_required_env_vars(); + info!("loading config"); + // Do we have a config.toml file? If we do, load it + // If we don't, create it with the default values, then exit the program and tell the user to fill it out + let config: Config = match std::fs::read_to_string("config.toml") { + Ok(config) => toml::from_str(&config).expect("failed to parse config.toml"), + Err(_) => { + let default_config = Config { + main_guild_id: 0, + channels: Channels { + welcome: 0, + main: 0, + logs_public: 0, + logs_mod: 0, + }, + roles: Roles { + admin: 0, + mod_role: 0, + silly_role: 0, + }, + }; + let default_config_toml = toml::to_string_pretty(&default_config).unwrap(); + std::fs::write("config.toml", default_config_toml) + .expect("failed to write config.toml"); + event!(Level::WARN, "config.toml not found, created a default one"); + event!( + Level::ERROR, + "please fill out config.toml and restart the bot" + ); + + panic!("config.toml not found, created a default one, please fill it out and restart the bot"); + } + }; + info!("initializing SQLx"); let pool = init_sqlx().await; info!("initializing bot"); - let intents = GatewayIntents::non_privileged(); + let intents = GatewayIntents::privileged(); let framework = poise::Framework::builder() .options(poise::FrameworkOptions:: { - commands: vec![commands::ping::ping()], + commands: vec![ + commands::ping::ping(), + commands::vouch::vouch(), + commands::profile::profiles(), + commands::dog::dog(), + commands::cta::cta(), + commands::action::use_action_hug(), + commands::action::use_action_kiss(), + commands::action::use_action_pat(), + ], event_handler: |ctx, event, framework, data| { Box::pin(event_handler(ctx, event, framework, data)) }, @@ -107,7 +184,10 @@ async fn main() { poise::builtins::register_globally(ctx, &framework.options().commands).await?; Ok(Data { // Initialize user data here - sqlx_pool: pool, + database_controller: DatabaseController::new(pool.clone()), + uptime: std::time::Instant::now(), + config, + vouch_store: Mutex::new(Vec::new()), }) }) }) diff --git a/src/structs/mod.rs b/src/structs/mod.rs new file mode 100644 index 0000000..fd43a72 --- /dev/null +++ b/src/structs/mod.rs @@ -0,0 +1,2 @@ +pub mod vouch; +pub mod user; \ No newline at end of file diff --git a/src/structs/user.rs b/src/structs/user.rs new file mode 100644 index 0000000..e74a420 --- /dev/null +++ b/src/structs/user.rs @@ -0,0 +1,7 @@ +pub struct User { + pub id: u64, + pub discord_id: u64, + pub actions_allowed: bool, + pub about: Option, + pub pronouns: Option, +} diff --git a/src/structs/vouch.rs b/src/structs/vouch.rs new file mode 100644 index 0000000..cec89e9 --- /dev/null +++ b/src/structs/vouch.rs @@ -0,0 +1,27 @@ +use chrono::{DateTime, Utc}; +use chrono_tz::US::Central; +use serenity::all::User; + +#[derive(Debug, Clone)] +pub struct Vouch { + pub user: User, + pub vouched_by: User, + pub vouch_time: DateTime, +} + +impl Vouch { + pub fn new(user: User, vouched_by: User) -> Self { + Self { + user, + vouched_by, + vouch_time: Utc::now(), + } + } + + pub fn get_vouch_time(&self) -> String { + self.vouch_time + .with_timezone(&Central) + .format("%Y-%m-%d %H:%M:%S") + .to_string() + } +} diff --git a/src/utils.rs b/src/utils.rs new file mode 100644 index 0000000..f55629f --- /dev/null +++ b/src/utils.rs @@ -0,0 +1,30 @@ +use std::process::Command; + +pub async fn get_rustc_version() -> String { + let rustc_version = Command::new("rustc") + .arg("--version") + .output() + .expect("failed to get rustc version"); + + String::from_utf8(rustc_version.stdout).expect("failed to convert rustc version to string") +} + +pub async fn get_random_action_image(action: String) -> Result { + let action = action.to_lowercase(); + let action = action.replace(" ", "_"); + + let url = format!("https://api.otakugifs.xyz/gif?reaction={}", action); + let response = reqwest::get(&url) + .await + .expect("failed to get random action image"); + + let response_json: serde_json::Value = + serde_json::from_str(&response.text().await.expect("failed to get response text")) + .expect("failed to parse response json"); + + let image_url = response_json["url"] + .as_str() + .expect("failed to get image url from response json"); + + Ok(image_url.to_string()) +}