Compare commits
	
		
			221 Commits
		
	
	
		
			poise
			...
			06e1474396
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
| 06e1474396 | |||
|  | adb9c728f4 | ||
|  | fc02eaea4a | ||
|  | 91f87302fb | ||
|  | 97f186dc33 | ||
|  | 6eaa6f0f28 | ||
|  | 9db0fa2513 | ||
|  | ca13fd4fa7 | ||
|  | 55acc8fd16 | ||
|  | 145711fa5d | ||
|  | 5524215786 | ||
|  | e8bd05893f | ||
|  | e3d3418f99 | ||
|  | 2681280a39 | ||
|  | 00579428a1 | ||
|  | b8ef999710 | ||
|  | e8f84e281a | ||
|  | 8ddff698e5 | ||
|  | 541633270c | ||
|  | 25286da5e0 | ||
|  | 4bad1324b9 | ||
|  | bd1462a00c | ||
|  | 56ffc43616 | ||
|  | 52cf642455 | ||
|  | 0bf578357a | ||
|  | 6e9eccb62e | ||
|  | 6ea28284ce | ||
|  | a6525f3052 | ||
|  | 348639270d | ||
|  | 37177c2431 | ||
|  | 8587bed703 | ||
|  | 6c9af1ae8e | ||
|  | 7695b7a476 | ||
| 651da7b28e | |||
| eb086146bf | |||
| 4ebd705e5e | |||
| 5a85f1d83a | |||
| 68ba25886a | |||
|  | e25bf6b828 | ||
|  | 5a386daa9d | ||
|  | 0d4a02fb1e | ||
|  | e135a74a9b | ||
|  | 77f17c8dc2 | ||
|  | 6a94f990cf | ||
|  | 3aa5bd37aa | ||
|  | fa83fed1af | ||
|  | 666cb7fa2f | ||
|  | a5678e15dc | ||
|  | 9405cfcee9 | ||
|  | cb25d02cdf | ||
|  | bfe651a125 | ||
|  | dc5e52d9ce | ||
|  | 229ada83e1 | ||
|  | 13171d6744 | ||
|  | 2ad941c94c | ||
|  | 924d31e978 | ||
|  | f9a1b23212 | ||
|  | ae5795a7ea | ||
|  | ee36c38eda | ||
|  | eca7df3d9f | ||
|  | 902b7e1b4a | ||
|  | db1a53a797 | ||
|  | 3605d71b73 | ||
|  | ea2cea573e | ||
|  | d5fa8036e8 | ||
|  | b8707bbc9a | ||
|  | 99eea16f62 | ||
|  | 88737302f3 | ||
|  | 213e3a5100 | ||
|  | 8fa1402ecc | ||
|  | e63996bb61 | ||
|  | 9ede879630 | ||
|  | 88e9826a62 | ||
|  | 5d655c7e6d | ||
|  | 51c9d8a7ae | ||
|  | 90df265114 | ||
|  | e65429aa9c | ||
|  | 8d2232f0da | ||
|  | a58b9866ea | ||
|  | b1f25be5d7 | ||
|  | f0f9787326 | ||
|  | 302f5835e6 | ||
|  | 58c778632e | ||
|  | 5671fd462b | ||
|  | 5ac9733f15 | ||
|  | 01dc0334fd | ||
|  | 4a17aac15c | ||
|  | 8ce4fc9c6d | ||
|  | b4f07cfc1c | ||
|  | 8799089b2d | ||
|  | 88c4830209 | ||
|  | 4dd3df5cc2 | ||
|  | 369a325a46 | ||
|  | 1a1a0fdefb | ||
|  | dda8bd3e10 | ||
|  | edbfc92cb9 | ||
|  | 6de11f09db | ||
|  | 284bfcd9ad | ||
|  | 3d627b5bf0 | ||
|  | c3c0dbbbae | ||
|  | 64dd81e941 | ||
|  | 799298ca34 | ||
|  | fa542bb24f | ||
|  | e025d945cf | ||
|  | bb1c61d0b9 | ||
|  | 1519474f93 | ||
|  | 9d8622f418 | ||
|  | a66db37b33 | ||
|  | c8c1a171d4 | ||
|  | 88cfb829e3 | ||
|  | 16be7a328e | ||
|  | 04babf7930 | ||
|  | 96bc09e8b5 | ||
|  | 976fb91ecc | ||
|  | 1305b6e64e | ||
|  | cdfe44d958 | ||
|  | c824a36832 | ||
|  | c4bd2c1d18 | ||
|  | 561555ab7e | ||
|  | 115fbd44cb | ||
|  | aa931328b0 | ||
| 4b42966284 | |||
| 523ab7f03a | |||
| 6e831c8253 | |||
|  | 4416e5d175 | ||
|  | 734a39a001 | ||
|  | 98191d29ee | ||
|  | 1c4c4a8b31 | ||
|  | d496c81003 | ||
|  | 094d210f64 | ||
|  | 314c72e132 | ||
|  | 4e0163f2cb | ||
|  | e5b8c418af | ||
|  | 3ef8584189 | ||
|  | df2ad09c86 | ||
|  | d70fb24eb1 | ||
|  | 3150c7267d | ||
|  | 6e65e4ff3d | ||
|  | 67a4db2e9a | ||
|  | e9bcb1973f | ||
|  | 9b87fd4258 | ||
|  | a49a849917 | ||
|  | aa74a7f9a3 | ||
|  | 08e4c6cb57 | ||
|  | 6e087bd2dd | ||
| e9792e6322 | |||
| 130504b964 | |||
| 2a8117d0c1 | |||
| 94bfd39085 | |||
| 40cd5f8a36 | |||
| 133b00a2ce | |||
| 57336f5c81 | |||
| b62d24c024 | |||
| 8f8235a86e | |||
| c8f646a8fa | |||
| ecaa382a1e | |||
| 8991198fd3 | |||
|  | f20b95a482 | ||
|  | 8dd7dc6409 | ||
|  | c799d10727 | ||
|  | ceb6fb7b12 | ||
|  | 6708abdb0f | ||
|  | a38f6024c1 | ||
|  | 7d8748e3ef | ||
|  | bb3386c4e8 | ||
|  | 25b84880a5 | ||
|  | 7b6e967a5d | ||
|  | 2781f2923e | ||
|  | 03f08f0a18 | ||
|  | 79c86d43f2 | ||
|  | e19af54caf | ||
|  | f4213c6a83 | ||
|  | f56db14720 | ||
|  | 6f7d0f67b3 | ||
|  | bfc2d71ca0 | ||
|  | 8eb46f1f23 | ||
|  | c4087bf569 | ||
|  | f25cfed8d7 | ||
|  | d2a8bd1982 | ||
|  | 437ee6b446 | ||
|  | 7d43aa5918 | ||
|  | 8bad95510d | ||
|  | d7a0b727fb | ||
|  | 1c1f5662d3 | ||
| ded750aa2d | |||
| 4c4f0927f1 | |||
|  | 0f05018cab | ||
|  | 85d27c5bba | ||
|  | d946ef1dca | ||
|  | f21d522435 | ||
|  | 3add718cdf | ||
|  | f4ef7afea0 | ||
|  | f8547bba70 | ||
|  | 08fd88ce54 | ||
|  | abfe492192 | ||
|  | afb2fbe4ff | ||
|  | 878ea11502 | ||
|  | 93da746bdc | ||
|  | 9e6a387f82 | ||
|  | af9d8bea62 | ||
|  | 318be1fa5e | ||
|  | 3b6e02e16e | ||
|  | a56f84f659 | ||
|  | 3e4dd0fa48 | ||
|  | d0d2d50966 | ||
|  | e2e5b022a0 | ||
|  | 6ae2353c92 | ||
|  | 06c4deeaa9 | ||
|  | afc376c44f | ||
|  | 84ee7e77c5 | ||
|  | 620f054703 | ||
|  | cb471c52f3 | ||
|  | 37420b2b1f | ||
|  | 49974b7153 | ||
|  | a3844dde9e | ||
| d62c8c95c2 | |||
| 05606dfec1 | |||
| 68ee42f244 | |||
| fad28faabb | |||
| e5ab99f67b | |||
| e47715917e | 
							
								
								
									
										4
									
								
								.gitignore
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										4
									
								
								.gitignore
									
									
									
									
										vendored
									
									
								
							| @@ -2,6 +2,6 @@ | ||||
| .env | ||||
| /venv | ||||
| .cargo | ||||
| assets | ||||
| out.json | ||||
| /.idea | ||||
| web/static/index.html | ||||
| web/static/assets | ||||
|   | ||||
							
								
								
									
										3
									
								
								.gitmodules
									
									
									
									
										vendored
									
									
										Normal file
									
								
							
							
						
						
									
										3
									
								
								.gitmodules
									
									
									
									
										vendored
									
									
										Normal file
									
								
							| @@ -0,0 +1,3 @@ | ||||
| [submodule "reminder-dashboard"] | ||||
| 	path = reminder-dashboard | ||||
| 	url = gitea@gitea.jellypro.xyz:jude/reminder-dashboard | ||||
							
								
								
									
										2
									
								
								.prettierrc.toml
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										2
									
								
								.prettierrc.toml
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,2 @@ | ||||
| printWidth = 90 | ||||
| tabWidth = 4 | ||||
							
								
								
									
										3166
									
								
								Cargo.lock
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										3166
									
								
								Cargo.lock
									
									
									
										generated
									
									
									
								
							
										
											
												File diff suppressed because it is too large
												Load Diff
											
										
									
								
							
							
								
								
									
										71
									
								
								Cargo.toml
									
									
									
									
									
								
							
							
						
						
									
										71
									
								
								Cargo.toml
									
									
									
									
									
								
							| @@ -1,46 +1,59 @@ | ||||
| [package] | ||||
| name = "reminder_rs" | ||||
| version = "1.6.0-beta2" | ||||
| authors = ["jellywx <judesouthworth@pm.me>"] | ||||
| edition = "2018" | ||||
| name = "reminder-rs" | ||||
| version = "1.6.48" | ||||
| authors = ["Jude Southworth <judesouthworth@pm.me>"] | ||||
| edition = "2021" | ||||
| license = "AGPL-3.0 only" | ||||
| description = "Reminder Bot for Discord, now in Rust" | ||||
|  | ||||
| [dependencies] | ||||
| poise = "0.5" | ||||
| dotenv = "0.15" | ||||
| humantime = "2.1" | ||||
| tokio = { version = "1", features = ["process", "full"] } | ||||
| reqwest = "0.11" | ||||
| regex = "1.4" | ||||
| lazy-regex = "3.0.2" | ||||
| regex = "1.9" | ||||
| log = "0.4" | ||||
| env_logger = "0.8" | ||||
| env_logger = "0.10" | ||||
| chrono = "0.4" | ||||
| chrono-tz = { version = "0.5", features = ["serde"] } | ||||
| chrono-tz = { version = "0.8", features = ["serde"] } | ||||
| lazy_static = "1.4" | ||||
| num-integer = "0.1" | ||||
| serde = "1.0" | ||||
| serde_json = "1.0" | ||||
| serde_repr = "0.1" | ||||
| rmp-serde = "0.15" | ||||
| rand = "0.7" | ||||
| rmp-serde = "1.1" | ||||
| rand = "0.8" | ||||
| levenshtein = "1.0" | ||||
| sqlx = { version = "0.5", features = ["runtime-tokio-rustls", "macros", "mysql", "bigdecimal", "chrono"]} | ||||
| base64 = "0.13.0" | ||||
| sqlx = { version = "0.7", features = ["runtime-tokio-rustls", "macros", "mysql", "bigdecimal", "chrono", "migrate"]} | ||||
| base64 = "0.21.0" | ||||
|  | ||||
| [dependencies.regex_command_attr] | ||||
| path = "command_attributes" | ||||
| [dependencies.postman] | ||||
| path = "postman" | ||||
|  | ||||
| [dependencies.serenity] | ||||
| git = "https://github.com/serenity-rs/serenity" | ||||
| branch = "next" | ||||
| default-features = false | ||||
| features = [ | ||||
|     "builder", | ||||
|     "client", | ||||
|     "cache", | ||||
|     "gateway", | ||||
|     "http", | ||||
|     "model", | ||||
|     "utils", | ||||
|     "rustls_backend", | ||||
|     "collector", | ||||
|     "unstable_discord_api" | ||||
| [dependencies.reminder_web] | ||||
| path = "web" | ||||
|  | ||||
| [package.metadata.deb] | ||||
| depends = "$auto, python3-dateparser (>= 1.0.0)" | ||||
| suggests = "mysql-server-8.0, nginx" | ||||
| maintainer-scripts = "debian" | ||||
| assets = [ | ||||
|     ["target/release/reminder-rs", "usr/bin/reminder-rs", "755"], | ||||
|     ["conf/default.env", "etc/reminder-rs/config.env", "600"], | ||||
|     ["conf/Rocket.toml", "etc/reminder-rs/Rocket.toml", "600"], | ||||
|     ["web/static/**/*", "lib/reminder-rs/static", "644"], | ||||
|     ["reminder-dashboard/dist/static/**/*", "lib/reminder-rs/static", "644"], | ||||
|     ["web/templates/**/*", "lib/reminder-rs/templates", "644"], | ||||
|     ["healthcheck", "lib/reminder-rs/healthcheck", "755"], | ||||
|     ["cron.d/reminder_health", "etc/cron.d/reminder_health", "644"], | ||||
| #    ["nginx/reminder-rs", "etc/nginx/sites-available/reminder-rs", "755"] | ||||
| ] | ||||
| conf-files = [ | ||||
|     "/etc/reminder-rs/config.env", | ||||
|     "/etc/reminder-rs/Rocket.toml", | ||||
| ] | ||||
|  | ||||
| [package.metadata.deb.systemd-units] | ||||
| unit-scripts = "systemd" | ||||
| start = false | ||||
|   | ||||
							
								
								
									
										9
									
								
								Containerfile
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										9
									
								
								Containerfile
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,9 @@ | ||||
| FROM ubuntu:20.04 | ||||
|  | ||||
| ENV RUSTUP_HOME=/usr/local/rustup \ | ||||
|     CARGO_HOME=/usr/local/cargo \ | ||||
|     PATH=/usr/local/cargo/bin:$PATH | ||||
|  | ||||
| RUN apt update && DEBIAN_FRONTEND=noninteractive TZ=Etc/UTC apt install -y gcc gcc-multilib cmake pkg-config libssl-dev curl mysql-client-8.0 | ||||
| RUN curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --no-modify-path --profile minimal --default-toolchain nightly | ||||
| RUN cargo install cargo-deb | ||||
							
								
								
									
										48
									
								
								README.md
									
									
									
									
									
								
							
							
						
						
									
										48
									
								
								README.md
									
									
									
									
									
								
							| @@ -2,23 +2,41 @@ | ||||
| Reminder Bot for Discord. | ||||
|  | ||||
| ## How do I use it? | ||||
| We offer a hosted version of the bot. You can invite it with: **https://invite.reminder-bot.com**. The catch is that repeating  | ||||
| I offer a hosted version of the bot. You can invite it with: **https://invite.reminder-bot.com**. The catch is that repeating  | ||||
| reminders are paid on the hosted version of the bot. Keep reading if you want to host it yourself. | ||||
|  | ||||
| You'll need rustc and cargo for compilation. To run, you'll need Python 3 still (due to no suitable replacement for dateparser in Rust) | ||||
|  | ||||
| ### Compiling | ||||
| Reminder Bot can be built by running `cargo build --release` in the top level directory. It is necessary to create a folder called 'assets' containing an image file with its name specified in the environment as `WEBHOOK_AVATAR`, of dimensions 128x128px to be used as the webhook avatar. | ||||
| ### Build APT package | ||||
|  | ||||
| #### Compilation environment variables | ||||
| These environment variables must be provided when compiling the bot | ||||
| Recommended method. | ||||
|  | ||||
| By default, this builds targeting Ubuntu 20.04. Modify the Containerfile if you wish to target a different platform. These instructions are written using `podman`, but `docker` should work too. | ||||
|  | ||||
| 1. Install container software: `sudo apt install podman`. | ||||
| 2. Install database server: `sudo apt install mysql-server-8.0`. Create a database called `reminders` | ||||
| 3. Install SQLx CLI: `cargo install sqlx-cli` | ||||
| 4. From the source code directory, execute `sqlx migrate run` | ||||
| 5. Build container image: `podman build -t reminder-rs .` | ||||
| 6. Build with podman: `podman run --rm --network=host -v "$PWD":/mnt -w /mnt -e "DATABASE_URL=mysql://user@localhost/reminders" reminder-rs cargo deb`  | ||||
|  | ||||
|  | ||||
| ### Compiling for other target | ||||
|  | ||||
| 1. Install requirements:  | ||||
| `sudo apt install gcc gcc-multilib cmake libssl-dev build-essential python3-dateparser` | ||||
| 2. Install rustup from https://rustup.rs | ||||
| 3. Install the nightly toolchain: `rustup toolchain default nightly` | ||||
| 4. Install database server: `sudo apt install mysql-server-8.0`. Create a database called `reminders`. | ||||
| 5. Install `sqlx-cli`: `cargo install sqlx-cli`. | ||||
| 6. Run migrations: `sqlx migrate run`. | ||||
| 7. Set environment variables: | ||||
|    * `DATABASE_URL` - the URL of your MySQL database (`mysql://user[:password]@domain/database`) | ||||
| * `WEBHOOK_AVATAR` - accepts the name of an image file located in `$CARGO_MANIFEST_DIR/assets/` to be used as the avatar when creating webhooks. **IMPORTANT: image file must be 128x128 or smaller in size** | ||||
| 8. Build: `cargo build --release` | ||||
|  | ||||
| ### Setting up Python | ||||
| Reminder Bot by default looks for a venv within it's working directory to run Python out of. To set up a venv, install `python3-venv` and run `python3 -m venv venv`. Then, run `source venv/bin/activate` to activate the venv, and do `pip install dateparser` to install the required library | ||||
|  | ||||
| ### Environment Variables | ||||
| ### Configuring | ||||
|  | ||||
| Reminder Bot reads a number of environment variables. Some are essential, and others have hardcoded fallbacks. Environment variables can be loaded from a .env file in the working directory. | ||||
|  | ||||
| __Required Variables__ | ||||
| @@ -30,15 +48,5 @@ __Other Variables__ | ||||
| * `LOCAL_TIMEZONE` - default `UTC`, necessary for calculations in the natural language processor | ||||
| * `SUBSCRIPTION_ROLES` - default `None`, accepts a list of Discord role IDs that are given to subscribed users | ||||
| * `CNC_GUILD` - default `None`, accepts a single Discord guild ID for the server that the subscription roles belong to | ||||
| * `IGNORE_BOTS` - default `1`, if `1`, Reminder Bot will ignore all other bots | ||||
| * `PYTHON_LOCATION` - default `venv/bin/python3`. Can be changed if your Python executable is located somewhere else | ||||
| * `PYTHON_LOCATION` - default `/usr/bin/python3`. Can be changed if your Python executable is located somewhere else | ||||
| * `THEME_COLOR` - default `8fb677`. Specifies the hex value of the color to use on info message embeds  | ||||
| * `SHARD_COUNT` - default `None`, accepts the number of shards that are being ran | ||||
| * `SHARD_RANGE` - default `None`, if `SHARD_COUNT` is specified, specifies what range of shards to start on this process  | ||||
| * `DM_ENABLED` - default `1`, if `1`, Reminder Bot will respond to direct messages | ||||
|  | ||||
| ### Todo List | ||||
|  | ||||
| * Convert aliases to macros | ||||
| * Help command | ||||
| * Test everything | ||||
|   | ||||
							
								
								
									
										28
									
								
								Rocket.toml
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										28
									
								
								Rocket.toml
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,28 @@ | ||||
| [default] | ||||
| address = "0.0.0.0" | ||||
| port = 18920 | ||||
| template_dir = "web/templates" | ||||
| limits = { json = "10MiB" } | ||||
|  | ||||
| [debug] | ||||
| secret_key = "tR8krio5FXTnnyIZNiJDXPondz0kI1v6X6BXZcBGIRY=" | ||||
|  | ||||
| [debug.tls] | ||||
| certs = "web/private/rsa_sha256_cert.pem" | ||||
| key = "web/private/rsa_sha256_key.pem" | ||||
|  | ||||
| [debug.rsa_sha256.tls] | ||||
| certs = "web/private/rsa_sha256_cert.pem" | ||||
| key = "web/private/rsa_sha256_key.pem" | ||||
|  | ||||
| [debug.ecdsa_nistp256_sha256.tls] | ||||
| certs = "web/private/ecdsa_nistp256_sha256_cert.pem" | ||||
| key = "web/private/ecdsa_nistp256_sha256_key_pkcs8.pem" | ||||
|  | ||||
| [debug.ecdsa_nistp384_sha384.tls] | ||||
| certs = "web/private/ecdsa_nistp384_sha384_cert.pem" | ||||
| key = "web/private/ecdsa_nistp384_sha384_key_pkcs8.pem" | ||||
|  | ||||
| [debug.ed25519.tls] | ||||
| certs = "web/private/ed25519_cert.pem" | ||||
| key = "eb/private/ed25519_key.pem" | ||||
							
								
								
									
										
											BIN
										
									
								
								assets/webhook.jpg
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										
											BIN
										
									
								
								assets/webhook.jpg
									
									
									
									
									
										Normal file
									
								
							
										
											Binary file not shown.
										
									
								
							| After Width: | Height: | Size: 21 KiB | 
							
								
								
									
										3
									
								
								build.rs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										3
									
								
								build.rs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,3 @@ | ||||
| fn main() { | ||||
|     println!("cargo:rerun-if-changed=migrations"); | ||||
| } | ||||
| @@ -1,16 +0,0 @@ | ||||
| [package] | ||||
| name = "regex_command_attr" | ||||
| version = "0.3.6" | ||||
| authors = ["acdenisSK <acdenissk69@gmail.com>", "jellywx <judesouthworth@pm.me>"] | ||||
| edition = "2018" | ||||
| description = "Procedural macros for command creation for the Serenity library." | ||||
| license = "ISC" | ||||
|  | ||||
| [lib] | ||||
| proc-macro = true | ||||
|  | ||||
| [dependencies] | ||||
| quote = "^1.0" | ||||
| syn = { version = "^1.0", features = ["full", "derive", "extra-traits"] } | ||||
| proc-macro2 = "1.0" | ||||
| uuid = { version = "0.8", features = ["v4"] } | ||||
| @@ -1,351 +0,0 @@ | ||||
| use std::fmt::{self, Write}; | ||||
|  | ||||
| use proc_macro2::Span; | ||||
| use syn::{ | ||||
|     parse::{Error, Result}, | ||||
|     spanned::Spanned, | ||||
|     Attribute, Ident, Lit, LitStr, Meta, NestedMeta, Path, | ||||
| }; | ||||
|  | ||||
| use crate::{ | ||||
|     structures::{ApplicationCommandOptionType, Arg}, | ||||
|     util::{AsOption, LitExt}, | ||||
| }; | ||||
|  | ||||
| #[derive(Debug, Clone, Copy, PartialEq)] | ||||
| pub enum ValueKind { | ||||
|     // #[<name>] | ||||
|     Name, | ||||
|  | ||||
|     // #[<name> = <value>] | ||||
|     Equals, | ||||
|  | ||||
|     // #[<name>([<value>, <value>, <value>, ...])] | ||||
|     List, | ||||
|  | ||||
|     // #[<name>([<prop> = <value>, <prop> = <value>, ...])] | ||||
|     EqualsList, | ||||
|  | ||||
|     // #[<name>(<value>)] | ||||
|     SingleList, | ||||
| } | ||||
|  | ||||
| impl fmt::Display for ValueKind { | ||||
|     fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { | ||||
|         match self { | ||||
|             ValueKind::Name => f.pad("`#[<name>]`"), | ||||
|             ValueKind::Equals => f.pad("`#[<name> = <value>]`"), | ||||
|             ValueKind::List => f.pad("`#[<name>([<value>, <value>, <value>, ...])]`"), | ||||
|             ValueKind::EqualsList => { | ||||
|                 f.pad("`#[<name>([<prop> = <value>, <prop> = <value>, ...])]`") | ||||
|             } | ||||
|             ValueKind::SingleList => f.pad("`#[<name>(<value>)]`"), | ||||
|         } | ||||
|     } | ||||
| } | ||||
|  | ||||
| fn to_ident(p: Path) -> Result<Ident> { | ||||
|     if p.segments.is_empty() { | ||||
|         return Err(Error::new(p.span(), "cannot convert an empty path to an identifier")); | ||||
|     } | ||||
|  | ||||
|     if p.segments.len() > 1 { | ||||
|         return Err(Error::new(p.span(), "the path must not have more than one segment")); | ||||
|     } | ||||
|  | ||||
|     if !p.segments[0].arguments.is_empty() { | ||||
|         return Err(Error::new(p.span(), "the singular path segment must not have any arguments")); | ||||
|     } | ||||
|  | ||||
|     Ok(p.segments[0].ident.clone()) | ||||
| } | ||||
|  | ||||
| #[derive(Debug)] | ||||
| pub struct Values { | ||||
|     pub name: Ident, | ||||
|     pub literals: Vec<(Option<String>, Lit)>, | ||||
|     pub kind: ValueKind, | ||||
|     pub span: Span, | ||||
| } | ||||
|  | ||||
| impl Values { | ||||
|     #[inline] | ||||
|     pub fn new( | ||||
|         name: Ident, | ||||
|         kind: ValueKind, | ||||
|         literals: Vec<(Option<String>, Lit)>, | ||||
|         span: Span, | ||||
|     ) -> Self { | ||||
|         Values { name, literals, kind, span } | ||||
|     } | ||||
| } | ||||
|  | ||||
| pub fn parse_values(attr: &Attribute) -> Result<Values> { | ||||
|     fn is_list_or_named_list(meta: &NestedMeta) -> ValueKind { | ||||
|         match meta { | ||||
|             // catch if the nested value is a literal value | ||||
|             NestedMeta::Lit(_) => ValueKind::List, | ||||
|             // catch if the nested value is a meta value | ||||
|             NestedMeta::Meta(m) => match m { | ||||
|                 // path => some quoted value | ||||
|                 Meta::Path(_) => ValueKind::List, | ||||
|                 Meta::List(_) | Meta::NameValue(_) => ValueKind::EqualsList, | ||||
|             }, | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     let meta = attr.parse_meta()?; | ||||
|  | ||||
|     match meta { | ||||
|         Meta::Path(path) => { | ||||
|             let name = to_ident(path)?; | ||||
|  | ||||
|             Ok(Values::new(name, ValueKind::Name, Vec::new(), attr.span())) | ||||
|         } | ||||
|         Meta::List(meta) => { | ||||
|             let name = to_ident(meta.path)?; | ||||
|             let nested = meta.nested; | ||||
|  | ||||
|             if nested.is_empty() { | ||||
|                 return Err(Error::new(attr.span(), "list cannot be empty")); | ||||
|             } | ||||
|  | ||||
|             if is_list_or_named_list(nested.first().unwrap()) == ValueKind::List { | ||||
|                 let mut lits = Vec::with_capacity(nested.len()); | ||||
|  | ||||
|                 for meta in nested { | ||||
|                     match meta { | ||||
|                         // catch if the nested value is a literal value | ||||
|                         NestedMeta::Lit(l) => lits.push((None, l)), | ||||
|                         // catch if the nested value is a meta value | ||||
|                         NestedMeta::Meta(m) => match m { | ||||
|                             // path => some quoted value | ||||
|                             Meta::Path(path) => { | ||||
|                                 let i = to_ident(path)?; | ||||
|                                 lits.push((None, Lit::Str(LitStr::new(&i.to_string(), i.span())))) | ||||
|                             } | ||||
|                             Meta::List(_) | Meta::NameValue(_) => { | ||||
|                                 return Err(Error::new(attr.span(), "cannot nest a list; only accept literals and identifiers at this level")) | ||||
|                             } | ||||
|                         }, | ||||
|                     } | ||||
|                 } | ||||
|  | ||||
|                 let kind = if lits.len() == 1 { ValueKind::SingleList } else { ValueKind::List }; | ||||
|  | ||||
|                 Ok(Values::new(name, kind, lits, attr.span())) | ||||
|             } else { | ||||
|                 let mut lits = Vec::with_capacity(nested.len()); | ||||
|  | ||||
|                 for meta in nested { | ||||
|                     match meta { | ||||
|                         // catch if the nested value is a literal value | ||||
|                         NestedMeta::Lit(_) => { | ||||
|                             return Err(Error::new(attr.span(), "key-value pairs expected")) | ||||
|                         } | ||||
|                         // catch if the nested value is a meta value | ||||
|                         NestedMeta::Meta(m) => match m { | ||||
|                             Meta::NameValue(n) => { | ||||
|                                 let name = to_ident(n.path)?.to_string(); | ||||
|                                 let value = n.lit; | ||||
|  | ||||
|                                 lits.push((Some(name), value)); | ||||
|                             } | ||||
|                             Meta::List(_) | Meta::Path(_) => { | ||||
|                                 return Err(Error::new(attr.span(), "key-value pairs expected")) | ||||
|                             } | ||||
|                         }, | ||||
|                     } | ||||
|                 } | ||||
|  | ||||
|                 Ok(Values::new(name, ValueKind::EqualsList, lits, attr.span())) | ||||
|             } | ||||
|         } | ||||
|         Meta::NameValue(meta) => { | ||||
|             let name = to_ident(meta.path)?; | ||||
|             let lit = meta.lit; | ||||
|  | ||||
|             Ok(Values::new(name, ValueKind::Equals, vec![(None, lit)], attr.span())) | ||||
|         } | ||||
|     } | ||||
| } | ||||
|  | ||||
| #[derive(Debug, Clone)] | ||||
| struct DisplaySlice<'a, T>(&'a [T]); | ||||
|  | ||||
| impl<'a, T: fmt::Display> fmt::Display for DisplaySlice<'a, T> { | ||||
|     fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { | ||||
|         let mut iter = self.0.iter().enumerate(); | ||||
|  | ||||
|         match iter.next() { | ||||
|             None => f.write_str("nothing")?, | ||||
|             Some((idx, elem)) => { | ||||
|                 write!(f, "{}: {}", idx, elem)?; | ||||
|  | ||||
|                 for (idx, elem) in iter { | ||||
|                     f.write_char('\n')?; | ||||
|                     write!(f, "{}: {}", idx, elem)?; | ||||
|                 } | ||||
|             } | ||||
|         } | ||||
|  | ||||
|         Ok(()) | ||||
|     } | ||||
| } | ||||
|  | ||||
| #[inline] | ||||
| fn is_form_acceptable(expect: &[ValueKind], kind: ValueKind) -> bool { | ||||
|     if expect.contains(&ValueKind::List) && kind == ValueKind::SingleList { | ||||
|         true | ||||
|     } else { | ||||
|         expect.contains(&kind) | ||||
|     } | ||||
| } | ||||
|  | ||||
| #[inline] | ||||
| fn validate(values: &Values, forms: &[ValueKind]) -> Result<()> { | ||||
|     if !is_form_acceptable(forms, values.kind) { | ||||
|         return Err(Error::new( | ||||
|             values.span, | ||||
|             // Using the `_args` version here to avoid an allocation. | ||||
|             format_args!("the attribute must be in of these forms:\n{}", DisplaySlice(forms)), | ||||
|         )); | ||||
|     } | ||||
|  | ||||
|     Ok(()) | ||||
| } | ||||
|  | ||||
| #[inline] | ||||
| pub fn parse<T: AttributeOption>(values: Values) -> Result<T> { | ||||
|     T::parse(values) | ||||
| } | ||||
|  | ||||
| pub trait AttributeOption: Sized { | ||||
|     fn parse(values: Values) -> Result<Self>; | ||||
| } | ||||
|  | ||||
| impl AttributeOption for Vec<String> { | ||||
|     fn parse(values: Values) -> Result<Self> { | ||||
|         validate(&values, &[ValueKind::List])?; | ||||
|  | ||||
|         Ok(values.literals.into_iter().map(|(_, l)| l.to_str()).collect()) | ||||
|     } | ||||
| } | ||||
|  | ||||
| impl AttributeOption for String { | ||||
|     #[inline] | ||||
|     fn parse(values: Values) -> Result<Self> { | ||||
|         validate(&values, &[ValueKind::Equals, ValueKind::SingleList])?; | ||||
|  | ||||
|         Ok(values.literals[0].1.to_str()) | ||||
|     } | ||||
| } | ||||
|  | ||||
| impl AttributeOption for bool { | ||||
|     #[inline] | ||||
|     fn parse(values: Values) -> Result<Self> { | ||||
|         validate(&values, &[ValueKind::Name, ValueKind::SingleList])?; | ||||
|  | ||||
|         Ok(values.literals.get(0).map_or(true, |(_, l)| l.to_bool())) | ||||
|     } | ||||
| } | ||||
|  | ||||
| impl AttributeOption for Ident { | ||||
|     #[inline] | ||||
|     fn parse(values: Values) -> Result<Self> { | ||||
|         validate(&values, &[ValueKind::SingleList])?; | ||||
|  | ||||
|         Ok(values.literals[0].1.to_ident()) | ||||
|     } | ||||
| } | ||||
|  | ||||
| impl AttributeOption for Vec<Ident> { | ||||
|     #[inline] | ||||
|     fn parse(values: Values) -> Result<Self> { | ||||
|         validate(&values, &[ValueKind::List])?; | ||||
|  | ||||
|         Ok(values.literals.into_iter().map(|(_, l)| l.to_ident()).collect()) | ||||
|     } | ||||
| } | ||||
|  | ||||
| impl AttributeOption for Option<String> { | ||||
|     fn parse(values: Values) -> Result<Self> { | ||||
|         validate(&values, &[ValueKind::Name, ValueKind::Equals, ValueKind::SingleList])?; | ||||
|  | ||||
|         Ok(values.literals.get(0).map(|(_, l)| l.to_str())) | ||||
|     } | ||||
| } | ||||
|  | ||||
| impl AttributeOption for Arg { | ||||
|     fn parse(values: Values) -> Result<Self> { | ||||
|         validate(&values, &[ValueKind::EqualsList])?; | ||||
|  | ||||
|         let mut arg: Arg = Default::default(); | ||||
|  | ||||
|         for (key, value) in &values.literals { | ||||
|             match key { | ||||
|                 Some(s) => match s.as_str() { | ||||
|                     "name" => { | ||||
|                         arg.name = value.to_str(); | ||||
|                     } | ||||
|                     "description" => { | ||||
|                         arg.description = value.to_str(); | ||||
|                     } | ||||
|                     "required" => { | ||||
|                         arg.required = value.to_bool(); | ||||
|                     } | ||||
|                     "kind" => arg.kind = ApplicationCommandOptionType::from_str(value.to_str()), | ||||
|                     _ => { | ||||
|                         return Err(Error::new(key.span(), "unexpected attribute")); | ||||
|                     } | ||||
|                 }, | ||||
|                 _ => { | ||||
|                     return Err(Error::new(key.span(), "unnamed attribute")); | ||||
|                 } | ||||
|             } | ||||
|         } | ||||
|  | ||||
|         Ok(arg) | ||||
|     } | ||||
| } | ||||
|  | ||||
| impl<T: AttributeOption> AttributeOption for AsOption<T> { | ||||
|     #[inline] | ||||
|     fn parse(values: Values) -> Result<Self> { | ||||
|         Ok(AsOption(Some(T::parse(values)?))) | ||||
|     } | ||||
| } | ||||
|  | ||||
| macro_rules! attr_option_num { | ||||
|     ($($n:ty),*) => { | ||||
|         $( | ||||
|             impl AttributeOption for $n { | ||||
|                 fn parse(values: Values) -> Result<Self> { | ||||
|                     validate(&values, &[ValueKind::SingleList])?; | ||||
|  | ||||
|                     Ok(match &values.literals[0].1 { | ||||
|                         Lit::Int(l) => l.base10_parse::<$n>()?, | ||||
|                         l => { | ||||
|                             let s = l.to_str(); | ||||
|                             // Use `as_str` to guide the compiler to use `&str`'s parse method. | ||||
|                             // We don't want to use our `parse` method here (`impl AttributeOption for String`). | ||||
|                             match s.as_str().parse::<$n>() { | ||||
|                                 Ok(n) => n, | ||||
|                                 Err(_) => return Err(Error::new(l.span(), "invalid integer")), | ||||
|                             } | ||||
|                         } | ||||
|                     }) | ||||
|                 } | ||||
|             } | ||||
|  | ||||
|             impl AttributeOption for Option<$n> { | ||||
|                 #[inline] | ||||
|                 fn parse(values: Values) -> Result<Self> { | ||||
|                     <$n as AttributeOption>::parse(values).map(Some) | ||||
|                 } | ||||
|             } | ||||
|         )* | ||||
|     } | ||||
| } | ||||
|  | ||||
| attr_option_num!(u16, u32, usize); | ||||
| @@ -1,10 +0,0 @@ | ||||
| pub mod suffixes { | ||||
|     pub const COMMAND: &str = "COMMAND"; | ||||
|     pub const ARG: &str = "ARG"; | ||||
|     pub const SUBCOMMAND: &str = "SUBCOMMAND"; | ||||
|     pub const SUBCOMMAND_GROUP: &str = "GROUP"; | ||||
|     pub const CHECK: &str = "CHECK"; | ||||
|     pub const HOOK: &str = "HOOK"; | ||||
| } | ||||
|  | ||||
| pub use self::suffixes::*; | ||||
| @@ -1,321 +0,0 @@ | ||||
| #![deny(rust_2018_idioms)] | ||||
| #![deny(broken_intra_doc_links)] | ||||
|  | ||||
| use proc_macro::TokenStream; | ||||
| use proc_macro2::Ident; | ||||
| use quote::quote; | ||||
| use syn::{parse::Error, parse_macro_input, parse_quote, spanned::Spanned, Lit, Type}; | ||||
| use uuid::Uuid; | ||||
|  | ||||
| pub(crate) mod attributes; | ||||
| pub(crate) mod consts; | ||||
| pub(crate) mod structures; | ||||
|  | ||||
| #[macro_use] | ||||
| pub(crate) mod util; | ||||
|  | ||||
| use attributes::*; | ||||
| use consts::*; | ||||
| use structures::*; | ||||
| use util::*; | ||||
|  | ||||
| macro_rules! match_options { | ||||
|     ($v:expr, $values:ident, $options:ident, $span:expr => [$($name:ident);*]) => { | ||||
|         match $v { | ||||
|             $( | ||||
|                 stringify!($name) => $options.$name = propagate_err!($crate::attributes::parse($values)), | ||||
|             )* | ||||
|             _ => { | ||||
|                 return Error::new($span, format_args!("invalid attribute: {:?}", $v)) | ||||
|                     .to_compile_error() | ||||
|                     .into(); | ||||
|             }, | ||||
|         } | ||||
|     }; | ||||
| } | ||||
|  | ||||
| #[proc_macro_attribute] | ||||
| pub fn command(attr: TokenStream, input: TokenStream) -> TokenStream { | ||||
|     enum LastItem { | ||||
|         Fun, | ||||
|         SubFun, | ||||
|         SubGroup, | ||||
|         SubGroupFun, | ||||
|     } | ||||
|  | ||||
|     let mut fun = parse_macro_input!(input as CommandFun); | ||||
|  | ||||
|     let _name = if !attr.is_empty() { | ||||
|         parse_macro_input!(attr as Lit).to_str() | ||||
|     } else { | ||||
|         fun.name.to_string() | ||||
|     }; | ||||
|  | ||||
|     let mut hooks: Vec<Ident> = Vec::new(); | ||||
|     let mut options = Options::new(); | ||||
|     let mut last_desc = LastItem::Fun; | ||||
|  | ||||
|     for attribute in &fun.attributes { | ||||
|         let span = attribute.span(); | ||||
|         let values = propagate_err!(parse_values(attribute)); | ||||
|  | ||||
|         let name = values.name.to_string(); | ||||
|         let name = &name[..]; | ||||
|  | ||||
|         match name { | ||||
|             "subcommand" => { | ||||
|                 let new_subcommand = Subcommand::new(propagate_err!(attributes::parse(values))); | ||||
|  | ||||
|                 if let Some(subcommand_group) = options.subcommand_groups.last_mut() { | ||||
|                     last_desc = LastItem::SubGroupFun; | ||||
|                     subcommand_group.subcommands.push(new_subcommand); | ||||
|                 } else { | ||||
|                     last_desc = LastItem::SubFun; | ||||
|                     options.subcommands.push(new_subcommand); | ||||
|                 } | ||||
|             } | ||||
|             "subcommandgroup" => { | ||||
|                 let new_group = SubcommandGroup::new(propagate_err!(attributes::parse(values))); | ||||
|                 last_desc = LastItem::SubGroup; | ||||
|  | ||||
|                 options.subcommand_groups.push(new_group); | ||||
|             } | ||||
|             "arg" => { | ||||
|                 let arg = propagate_err!(attributes::parse(values)); | ||||
|  | ||||
|                 match last_desc { | ||||
|                     LastItem::Fun => { | ||||
|                         options.cmd_args.push(arg); | ||||
|                     } | ||||
|                     LastItem::SubFun => { | ||||
|                         options.subcommands.last_mut().unwrap().cmd_args.push(arg); | ||||
|                     } | ||||
|                     LastItem::SubGroup => { | ||||
|                         panic!("Argument not expected under subcommand group"); | ||||
|                     } | ||||
|                     LastItem::SubGroupFun => { | ||||
|                         options | ||||
|                             .subcommand_groups | ||||
|                             .last_mut() | ||||
|                             .unwrap() | ||||
|                             .subcommands | ||||
|                             .last_mut() | ||||
|                             .unwrap() | ||||
|                             .cmd_args | ||||
|                             .push(arg); | ||||
|                     } | ||||
|                 } | ||||
|             } | ||||
|             "example" => { | ||||
|                 options.examples.push(propagate_err!(attributes::parse(values))); | ||||
|             } | ||||
|             "description" => { | ||||
|                 let line: String = propagate_err!(attributes::parse(values)); | ||||
|  | ||||
|                 match last_desc { | ||||
|                     LastItem::Fun => { | ||||
|                         util::append_line(&mut options.description, line); | ||||
|                     } | ||||
|                     LastItem::SubFun => { | ||||
|                         util::append_line( | ||||
|                             &mut options.subcommands.last_mut().unwrap().description, | ||||
|                             line, | ||||
|                         ); | ||||
|                     } | ||||
|                     LastItem::SubGroup => { | ||||
|                         util::append_line( | ||||
|                             &mut options.subcommand_groups.last_mut().unwrap().description, | ||||
|                             line, | ||||
|                         ); | ||||
|                     } | ||||
|                     LastItem::SubGroupFun => { | ||||
|                         util::append_line( | ||||
|                             &mut options | ||||
|                                 .subcommand_groups | ||||
|                                 .last_mut() | ||||
|                                 .unwrap() | ||||
|                                 .subcommands | ||||
|                                 .last_mut() | ||||
|                                 .unwrap() | ||||
|                                 .description, | ||||
|                             line, | ||||
|                         ); | ||||
|                     } | ||||
|                 } | ||||
|             } | ||||
|             "hook" => { | ||||
|                 hooks.push(propagate_err!(attributes::parse(values))); | ||||
|             } | ||||
|             _ => { | ||||
|                 match_options!(name, values, options, span => [ | ||||
|                     aliases; | ||||
|                     group; | ||||
|                     can_blacklist; | ||||
|                     supports_dm | ||||
|                 ]); | ||||
|             } | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     let Options { | ||||
|         aliases, | ||||
|         description, | ||||
|         group, | ||||
|         examples, | ||||
|         can_blacklist, | ||||
|         supports_dm, | ||||
|         mut cmd_args, | ||||
|         mut subcommands, | ||||
|         mut subcommand_groups, | ||||
|     } = options; | ||||
|  | ||||
|     let visibility = fun.visibility; | ||||
|     let name = fun.name.clone(); | ||||
|     let body = fun.body; | ||||
|  | ||||
|     let root_ident = name.with_suffix(COMMAND); | ||||
|  | ||||
|     let command_path = quote!(crate::framework::Command); | ||||
|  | ||||
|     populate_fut_lifetimes_on_refs(&mut fun.args); | ||||
|  | ||||
|     let mut subcommand_group_idents = subcommand_groups | ||||
|         .iter() | ||||
|         .map(|subcommand| { | ||||
|             root_ident | ||||
|                 .with_suffix(subcommand.name.replace("-", "_").as_str()) | ||||
|                 .with_suffix(SUBCOMMAND_GROUP) | ||||
|         }) | ||||
|         .collect::<Vec<Ident>>(); | ||||
|  | ||||
|     let mut subcommand_idents = subcommands | ||||
|         .iter() | ||||
|         .map(|subcommand| { | ||||
|             root_ident | ||||
|                 .with_suffix(subcommand.name.replace("-", "_").as_str()) | ||||
|                 .with_suffix(SUBCOMMAND) | ||||
|         }) | ||||
|         .collect::<Vec<Ident>>(); | ||||
|  | ||||
|     let mut arg_idents = cmd_args | ||||
|         .iter() | ||||
|         .map(|arg| root_ident.with_suffix(arg.name.replace("-", "_").as_str()).with_suffix(ARG)) | ||||
|         .collect::<Vec<Ident>>(); | ||||
|  | ||||
|     let mut tokens = quote! {}; | ||||
|  | ||||
|     tokens.extend( | ||||
|         subcommand_groups | ||||
|             .iter_mut() | ||||
|             .zip(subcommand_group_idents.iter()) | ||||
|             .map(|(group, group_ident)| group.as_tokens(group_ident)) | ||||
|             .fold(quote! {}, |mut a, b| { | ||||
|                 a.extend(b); | ||||
|                 a | ||||
|             }), | ||||
|     ); | ||||
|  | ||||
|     tokens.extend( | ||||
|         subcommands | ||||
|             .iter_mut() | ||||
|             .zip(subcommand_idents.iter()) | ||||
|             .map(|(subcommand, sc_ident)| subcommand.as_tokens(sc_ident)) | ||||
|             .fold(quote! {}, |mut a, b| { | ||||
|                 a.extend(b); | ||||
|                 a | ||||
|             }), | ||||
|     ); | ||||
|  | ||||
|     tokens.extend( | ||||
|         cmd_args.iter_mut().zip(arg_idents.iter()).map(|(arg, ident)| arg.as_tokens(ident)).fold( | ||||
|             quote! {}, | ||||
|             |mut a, b| { | ||||
|                 a.extend(b); | ||||
|                 a | ||||
|             }, | ||||
|         ), | ||||
|     ); | ||||
|  | ||||
|     arg_idents.append(&mut subcommand_group_idents); | ||||
|     arg_idents.append(&mut subcommand_idents); | ||||
|  | ||||
|     let args = fun.args; | ||||
|  | ||||
|     let variant = if args.len() == 2 { | ||||
|         quote!(crate::framework::CommandFnType::Multi) | ||||
|     } else { | ||||
|         let string: Type = parse_quote!(String); | ||||
|  | ||||
|         let final_arg = args.get(2).unwrap(); | ||||
|  | ||||
|         if final_arg.kind == string { | ||||
|             quote!(crate::framework::CommandFnType::Text) | ||||
|         } else { | ||||
|             quote!(crate::framework::CommandFnType::Slash) | ||||
|         } | ||||
|     }; | ||||
|  | ||||
|     tokens.extend(quote! { | ||||
|         #[allow(missing_docs)] | ||||
|         pub static #root_ident: #command_path = #command_path { | ||||
|             fun: #variant(#name), | ||||
|             names: &[#_name, #(#aliases),*], | ||||
|             desc: #description, | ||||
|             group: #group, | ||||
|             examples: &[#(#examples),*], | ||||
|             can_blacklist: #can_blacklist, | ||||
|             supports_dm: #supports_dm, | ||||
|             args: &[#(&#arg_idents),*], | ||||
|             hooks: &[#(&#hooks),*], | ||||
|         }; | ||||
|  | ||||
|         #[allow(missing_docs)] | ||||
|         #visibility fn #name<'fut> (#(#args),*) -> ::serenity::futures::future::BoxFuture<'fut, ()> { | ||||
|             use ::serenity::futures::future::FutureExt; | ||||
|  | ||||
|             async move { | ||||
|                 #(#body)*; | ||||
|             }.boxed() | ||||
|         } | ||||
|     }); | ||||
|  | ||||
|     tokens.into() | ||||
| } | ||||
|  | ||||
| #[proc_macro_attribute] | ||||
| pub fn check(_attr: TokenStream, input: TokenStream) -> TokenStream { | ||||
|     let mut fun = parse_macro_input!(input as CommandFun); | ||||
|  | ||||
|     let n = fun.name.clone(); | ||||
|     let name = n.with_suffix(HOOK); | ||||
|     let fn_name = n.with_suffix(CHECK); | ||||
|     let visibility = fun.visibility; | ||||
|  | ||||
|     let body = fun.body; | ||||
|     let ret = fun.ret; | ||||
|     populate_fut_lifetimes_on_refs(&mut fun.args); | ||||
|     let args = fun.args; | ||||
|  | ||||
|     let hook_path = quote!(crate::framework::Hook); | ||||
|     let uuid = Uuid::new_v4().as_u128(); | ||||
|  | ||||
|     (quote! { | ||||
|         #[allow(missing_docs)] | ||||
|         #visibility fn #fn_name<'fut>(#(#args),*) -> ::serenity::futures::future::BoxFuture<'fut, #ret> { | ||||
|             use ::serenity::futures::future::FutureExt; | ||||
|  | ||||
|             async move { | ||||
|                 let _output: #ret = { #(#body)* }; | ||||
|                 #[allow(unreachable_code)] | ||||
|                 _output | ||||
|             }.boxed() | ||||
|         } | ||||
|  | ||||
|         #[allow(missing_docs)] | ||||
|         pub static #name: #hook_path = #hook_path { | ||||
|             fun: #fn_name, | ||||
|             uuid: #uuid, | ||||
|         }; | ||||
|     }) | ||||
|     .into() | ||||
| } | ||||
| @@ -1,331 +0,0 @@ | ||||
| use proc_macro2::TokenStream as TokenStream2; | ||||
| use quote::{quote, ToTokens}; | ||||
| use syn::{ | ||||
|     braced, | ||||
|     parse::{Error, Parse, ParseStream, Result}, | ||||
|     spanned::Spanned, | ||||
|     Attribute, Block, FnArg, Ident, Pat, ReturnType, Stmt, Token, Type, Visibility, | ||||
| }; | ||||
|  | ||||
| use crate::{ | ||||
|     consts::{ARG, SUBCOMMAND}, | ||||
|     util::{Argument, IdentExt2, Parenthesised}, | ||||
| }; | ||||
|  | ||||
| fn parse_argument(arg: FnArg) -> Result<Argument> { | ||||
|     match arg { | ||||
|         FnArg::Typed(typed) => { | ||||
|             let pat = typed.pat; | ||||
|             let kind = typed.ty; | ||||
|  | ||||
|             match *pat { | ||||
|                 Pat::Ident(id) => { | ||||
|                     let name = id.ident; | ||||
|                     let mutable = id.mutability; | ||||
|  | ||||
|                     Ok(Argument { mutable, name, kind: *kind }) | ||||
|                 } | ||||
|                 Pat::Wild(wild) => { | ||||
|                     let token = wild.underscore_token; | ||||
|  | ||||
|                     let name = Ident::new("_", token.spans[0]); | ||||
|  | ||||
|                     Ok(Argument { mutable: None, name, kind: *kind }) | ||||
|                 } | ||||
|                 _ => Err(Error::new(pat.span(), format_args!("unsupported pattern: {:?}", pat))), | ||||
|             } | ||||
|         } | ||||
|         FnArg::Receiver(_) => { | ||||
|             Err(Error::new(arg.span(), format_args!("`self` arguments are prohibited: {:?}", arg))) | ||||
|         } | ||||
|     } | ||||
| } | ||||
|  | ||||
| #[derive(Debug)] | ||||
| pub struct CommandFun { | ||||
|     /// `#[...]`-style attributes. | ||||
|     pub attributes: Vec<Attribute>, | ||||
|     /// Populated cooked attributes. These are attributes outside of the realm of this crate's procedural macros | ||||
|     /// and will appear in generated output. | ||||
|     pub visibility: Visibility, | ||||
|     pub name: Ident, | ||||
|     pub args: Vec<Argument>, | ||||
|     pub ret: Type, | ||||
|     pub body: Vec<Stmt>, | ||||
| } | ||||
|  | ||||
| impl Parse for CommandFun { | ||||
|     fn parse(input: ParseStream<'_>) -> Result<Self> { | ||||
|         let attributes = input.call(Attribute::parse_outer)?; | ||||
|  | ||||
|         let visibility = input.parse::<Visibility>()?; | ||||
|  | ||||
|         input.parse::<Token![async]>()?; | ||||
|  | ||||
|         input.parse::<Token![fn]>()?; | ||||
|         let name = input.parse()?; | ||||
|  | ||||
|         // (...) | ||||
|         let Parenthesised(args) = input.parse::<Parenthesised<FnArg>>()?; | ||||
|  | ||||
|         let ret = match input.parse::<ReturnType>()? { | ||||
|             ReturnType::Type(_, t) => (*t).clone(), | ||||
|             ReturnType::Default => Type::Verbatim(quote!(())), | ||||
|         }; | ||||
|  | ||||
|         // { ... } | ||||
|         let bcont; | ||||
|         braced!(bcont in input); | ||||
|         let body = bcont.call(Block::parse_within)?; | ||||
|  | ||||
|         let args = args.into_iter().map(parse_argument).collect::<Result<Vec<_>>>()?; | ||||
|  | ||||
|         Ok(Self { attributes, visibility, name, args, ret, body }) | ||||
|     } | ||||
| } | ||||
|  | ||||
| impl ToTokens for CommandFun { | ||||
|     fn to_tokens(&self, stream: &mut TokenStream2) { | ||||
|         let Self { attributes: _, visibility, name, args, ret, body } = self; | ||||
|  | ||||
|         stream.extend(quote! { | ||||
|             #visibility async fn #name (#(#args),*) -> #ret { | ||||
|                 #(#body)* | ||||
|             } | ||||
|         }); | ||||
|     } | ||||
| } | ||||
|  | ||||
| #[derive(Debug)] | ||||
| pub(crate) enum ApplicationCommandOptionType { | ||||
|     SubCommand, | ||||
|     SubCommandGroup, | ||||
|     String, | ||||
|     Integer, | ||||
|     Boolean, | ||||
|     User, | ||||
|     Channel, | ||||
|     Role, | ||||
|     Mentionable, | ||||
|     Number, | ||||
|     Unknown, | ||||
| } | ||||
|  | ||||
| impl ApplicationCommandOptionType { | ||||
|     pub fn from_str(s: String) -> Self { | ||||
|         match s.as_str() { | ||||
|             "SubCommand" => Self::SubCommand, | ||||
|             "SubCommandGroup" => Self::SubCommandGroup, | ||||
|             "String" => Self::String, | ||||
|             "Integer" => Self::Integer, | ||||
|             "Boolean" => Self::Boolean, | ||||
|             "User" => Self::User, | ||||
|             "Channel" => Self::Channel, | ||||
|             "Role" => Self::Role, | ||||
|             "Mentionable" => Self::Mentionable, | ||||
|             "Number" => Self::Number, | ||||
|             _ => Self::Unknown, | ||||
|         } | ||||
|     } | ||||
| } | ||||
|  | ||||
| impl ToTokens for ApplicationCommandOptionType { | ||||
|     fn to_tokens(&self, stream: &mut TokenStream2) { | ||||
|         let path = quote!( | ||||
|             serenity::model::interactions::application_command::ApplicationCommandOptionType | ||||
|         ); | ||||
|         let variant = match self { | ||||
|             ApplicationCommandOptionType::SubCommand => quote!(SubCommand), | ||||
|             ApplicationCommandOptionType::SubCommandGroup => quote!(SubCommandGroup), | ||||
|             ApplicationCommandOptionType::String => quote!(String), | ||||
|             ApplicationCommandOptionType::Integer => quote!(Integer), | ||||
|             ApplicationCommandOptionType::Boolean => quote!(Boolean), | ||||
|             ApplicationCommandOptionType::User => quote!(User), | ||||
|             ApplicationCommandOptionType::Channel => quote!(Channel), | ||||
|             ApplicationCommandOptionType::Role => quote!(Role), | ||||
|             ApplicationCommandOptionType::Mentionable => quote!(Mentionable), | ||||
|             ApplicationCommandOptionType::Number => quote!(Number), | ||||
|             ApplicationCommandOptionType::Unknown => quote!(Unknown), | ||||
|         }; | ||||
|  | ||||
|         stream.extend(quote! { | ||||
|             #path::#variant | ||||
|         }); | ||||
|     } | ||||
| } | ||||
|  | ||||
| #[derive(Debug)] | ||||
| pub(crate) struct Arg { | ||||
|     pub name: String, | ||||
|     pub description: String, | ||||
|     pub kind: ApplicationCommandOptionType, | ||||
|     pub required: bool, | ||||
| } | ||||
|  | ||||
| impl Arg { | ||||
|     pub fn as_tokens(&self, ident: &Ident) -> TokenStream2 { | ||||
|         let arg_path = quote!(crate::framework::Arg); | ||||
|         let Arg { name, description, kind, required } = self; | ||||
|  | ||||
|         quote! { | ||||
|             #[allow(missing_docs)] | ||||
|             pub static #ident: #arg_path = #arg_path { | ||||
|                 name: #name, | ||||
|                 description: #description, | ||||
|                 kind: #kind, | ||||
|                 required: #required, | ||||
|                 options: &[] | ||||
|             }; | ||||
|         } | ||||
|     } | ||||
| } | ||||
|  | ||||
| impl Default for Arg { | ||||
|     fn default() -> Self { | ||||
|         Self { | ||||
|             name: String::new(), | ||||
|             description: String::new(), | ||||
|             kind: ApplicationCommandOptionType::String, | ||||
|             required: false, | ||||
|         } | ||||
|     } | ||||
| } | ||||
|  | ||||
| #[derive(Debug)] | ||||
| pub(crate) struct Subcommand { | ||||
|     pub name: String, | ||||
|     pub description: String, | ||||
|     pub cmd_args: Vec<Arg>, | ||||
| } | ||||
|  | ||||
| impl Subcommand { | ||||
|     pub fn as_tokens(&mut self, ident: &Ident) -> TokenStream2 { | ||||
|         let arg_path = quote!(crate::framework::Arg); | ||||
|         let subcommand_path = ApplicationCommandOptionType::SubCommand; | ||||
|  | ||||
|         let arg_idents = self | ||||
|             .cmd_args | ||||
|             .iter() | ||||
|             .map(|arg| ident.with_suffix(arg.name.as_str()).with_suffix(ARG)) | ||||
|             .collect::<Vec<Ident>>(); | ||||
|  | ||||
|         let mut tokens = self | ||||
|             .cmd_args | ||||
|             .iter_mut() | ||||
|             .zip(arg_idents.iter()) | ||||
|             .map(|(arg, ident)| arg.as_tokens(ident)) | ||||
|             .fold(quote! {}, |mut a, b| { | ||||
|                 a.extend(b); | ||||
|                 a | ||||
|             }); | ||||
|  | ||||
|         let Subcommand { name, description, .. } = self; | ||||
|  | ||||
|         tokens.extend(quote! { | ||||
|             #[allow(missing_docs)] | ||||
|             pub static #ident: #arg_path = #arg_path { | ||||
|                 name: #name, | ||||
|                 description: #description, | ||||
|                 kind: #subcommand_path, | ||||
|                 required: false, | ||||
|                 options: &[#(&#arg_idents),*], | ||||
|             }; | ||||
|         }); | ||||
|  | ||||
|         tokens | ||||
|     } | ||||
| } | ||||
|  | ||||
| impl Default for Subcommand { | ||||
|     fn default() -> Self { | ||||
|         Self { name: String::new(), description: String::new(), cmd_args: vec![] } | ||||
|     } | ||||
| } | ||||
|  | ||||
| impl Subcommand { | ||||
|     pub(crate) fn new(name: String) -> Self { | ||||
|         Self { name, ..Default::default() } | ||||
|     } | ||||
| } | ||||
|  | ||||
| #[derive(Debug)] | ||||
| pub(crate) struct SubcommandGroup { | ||||
|     pub name: String, | ||||
|     pub description: String, | ||||
|     pub subcommands: Vec<Subcommand>, | ||||
| } | ||||
|  | ||||
| impl SubcommandGroup { | ||||
|     pub fn as_tokens(&mut self, ident: &Ident) -> TokenStream2 { | ||||
|         let arg_path = quote!(crate::framework::Arg); | ||||
|         let subcommand_group_path = ApplicationCommandOptionType::SubCommandGroup; | ||||
|  | ||||
|         let arg_idents = self | ||||
|             .subcommands | ||||
|             .iter() | ||||
|             .map(|arg| { | ||||
|                 ident | ||||
|                     .with_suffix(self.name.as_str()) | ||||
|                     .with_suffix(arg.name.as_str()) | ||||
|                     .with_suffix(SUBCOMMAND) | ||||
|             }) | ||||
|             .collect::<Vec<Ident>>(); | ||||
|  | ||||
|         let mut tokens = self | ||||
|             .subcommands | ||||
|             .iter_mut() | ||||
|             .zip(arg_idents.iter()) | ||||
|             .map(|(subcommand, ident)| subcommand.as_tokens(ident)) | ||||
|             .fold(quote! {}, |mut a, b| { | ||||
|                 a.extend(b); | ||||
|                 a | ||||
|             }); | ||||
|  | ||||
|         let SubcommandGroup { name, description, .. } = self; | ||||
|  | ||||
|         tokens.extend(quote! { | ||||
|             #[allow(missing_docs)] | ||||
|             pub static #ident: #arg_path = #arg_path { | ||||
|                 name: #name, | ||||
|                 description: #description, | ||||
|                 kind: #subcommand_group_path, | ||||
|                 required: false, | ||||
|                 options: &[#(&#arg_idents),*], | ||||
|             }; | ||||
|         }); | ||||
|  | ||||
|         tokens | ||||
|     } | ||||
| } | ||||
|  | ||||
| impl Default for SubcommandGroup { | ||||
|     fn default() -> Self { | ||||
|         Self { name: String::new(), description: String::new(), subcommands: vec![] } | ||||
|     } | ||||
| } | ||||
|  | ||||
| impl SubcommandGroup { | ||||
|     pub(crate) fn new(name: String) -> Self { | ||||
|         Self { name, ..Default::default() } | ||||
|     } | ||||
| } | ||||
|  | ||||
| #[derive(Debug, Default)] | ||||
| pub(crate) struct Options { | ||||
|     pub aliases: Vec<String>, | ||||
|     pub description: String, | ||||
|     pub group: String, | ||||
|     pub examples: Vec<String>, | ||||
|     pub can_blacklist: bool, | ||||
|     pub supports_dm: bool, | ||||
|     pub cmd_args: Vec<Arg>, | ||||
|     pub subcommands: Vec<Subcommand>, | ||||
|     pub subcommand_groups: Vec<SubcommandGroup>, | ||||
| } | ||||
|  | ||||
| impl Options { | ||||
|     #[inline] | ||||
|     pub fn new() -> Self { | ||||
|         Self { group: "None".to_string(), ..Default::default() } | ||||
|     } | ||||
| } | ||||
| @@ -1,176 +0,0 @@ | ||||
| use proc_macro::TokenStream; | ||||
| use proc_macro2::{Span, TokenStream as TokenStream2}; | ||||
| use quote::{format_ident, quote, ToTokens}; | ||||
| use syn::{ | ||||
|     braced, bracketed, parenthesized, | ||||
|     parse::{Error, Parse, ParseStream, Result as SynResult}, | ||||
|     punctuated::Punctuated, | ||||
|     token::{Comma, Mut}, | ||||
|     Ident, Lifetime, Lit, Type, | ||||
| }; | ||||
|  | ||||
| pub trait LitExt { | ||||
|     fn to_str(&self) -> String; | ||||
|     fn to_bool(&self) -> bool; | ||||
|     fn to_ident(&self) -> Ident; | ||||
| } | ||||
|  | ||||
| impl LitExt for Lit { | ||||
|     fn to_str(&self) -> String { | ||||
|         match self { | ||||
|             Lit::Str(s) => s.value(), | ||||
|             Lit::ByteStr(s) => unsafe { String::from_utf8_unchecked(s.value()) }, | ||||
|             Lit::Char(c) => c.value().to_string(), | ||||
|             Lit::Byte(b) => (b.value() as char).to_string(), | ||||
|             _ => panic!("values must be a (byte)string or a char"), | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     fn to_bool(&self) -> bool { | ||||
|         if let Lit::Bool(b) = self { | ||||
|             b.value | ||||
|         } else { | ||||
|             self.to_str() | ||||
|                 .parse() | ||||
|                 .unwrap_or_else(|_| panic!("expected bool from {:?}", self)) | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     #[inline] | ||||
|     fn to_ident(&self) -> Ident { | ||||
|         Ident::new(&self.to_str(), self.span()) | ||||
|     } | ||||
| } | ||||
|  | ||||
| pub trait IdentExt2: Sized { | ||||
|     fn to_uppercase(&self) -> Self; | ||||
|     fn with_suffix(&self, suf: &str) -> Ident; | ||||
| } | ||||
|  | ||||
| impl IdentExt2 for Ident { | ||||
|     #[inline] | ||||
|     fn to_uppercase(&self) -> Self { | ||||
|         format_ident!("{}", self.to_string().to_uppercase()) | ||||
|     } | ||||
|  | ||||
|     #[inline] | ||||
|     fn with_suffix(&self, suffix: &str) -> Ident { | ||||
|         format_ident!("{}_{}", self.to_string().to_uppercase(), suffix) | ||||
|     } | ||||
| } | ||||
|  | ||||
| #[inline] | ||||
| pub fn into_stream(e: Error) -> TokenStream { | ||||
|     e.to_compile_error().into() | ||||
| } | ||||
|  | ||||
| macro_rules! propagate_err { | ||||
|     ($res:expr) => {{ | ||||
|         match $res { | ||||
|             Ok(v) => v, | ||||
|             Err(e) => return $crate::util::into_stream(e), | ||||
|         } | ||||
|     }}; | ||||
| } | ||||
|  | ||||
| #[derive(Debug)] | ||||
| pub struct Bracketed<T>(pub Punctuated<T, Comma>); | ||||
|  | ||||
| impl<T: Parse> Parse for Bracketed<T> { | ||||
|     fn parse(input: ParseStream<'_>) -> SynResult<Self> { | ||||
|         let content; | ||||
|         bracketed!(content in input); | ||||
|  | ||||
|         Ok(Bracketed(content.parse_terminated(T::parse)?)) | ||||
|     } | ||||
| } | ||||
|  | ||||
| #[derive(Debug)] | ||||
| pub struct Braced<T>(pub Punctuated<T, Comma>); | ||||
|  | ||||
| impl<T: Parse> Parse for Braced<T> { | ||||
|     fn parse(input: ParseStream<'_>) -> SynResult<Self> { | ||||
|         let content; | ||||
|         braced!(content in input); | ||||
|  | ||||
|         Ok(Braced(content.parse_terminated(T::parse)?)) | ||||
|     } | ||||
| } | ||||
|  | ||||
| #[derive(Debug)] | ||||
| pub struct Parenthesised<T>(pub Punctuated<T, Comma>); | ||||
|  | ||||
| impl<T: Parse> Parse for Parenthesised<T> { | ||||
|     fn parse(input: ParseStream<'_>) -> SynResult<Self> { | ||||
|         let content; | ||||
|         parenthesized!(content in input); | ||||
|  | ||||
|         Ok(Parenthesised(content.parse_terminated(T::parse)?)) | ||||
|     } | ||||
| } | ||||
|  | ||||
| #[derive(Debug)] | ||||
| pub struct AsOption<T>(pub Option<T>); | ||||
|  | ||||
| impl<T: ToTokens> ToTokens for AsOption<T> { | ||||
|     fn to_tokens(&self, stream: &mut TokenStream2) { | ||||
|         match &self.0 { | ||||
|             Some(o) => stream.extend(quote!(Some(#o))), | ||||
|             None => stream.extend(quote!(None)), | ||||
|         } | ||||
|     } | ||||
| } | ||||
|  | ||||
| impl<T> Default for AsOption<T> { | ||||
|     #[inline] | ||||
|     fn default() -> Self { | ||||
|         AsOption(None) | ||||
|     } | ||||
| } | ||||
|  | ||||
| #[derive(Debug)] | ||||
| pub struct Argument { | ||||
|     pub mutable: Option<Mut>, | ||||
|     pub name: Ident, | ||||
|     pub kind: Type, | ||||
| } | ||||
|  | ||||
| impl ToTokens for Argument { | ||||
|     fn to_tokens(&self, stream: &mut TokenStream2) { | ||||
|         let Argument { | ||||
|             mutable, | ||||
|             name, | ||||
|             kind, | ||||
|         } = self; | ||||
|  | ||||
|         stream.extend(quote! { | ||||
|             #mutable #name: #kind | ||||
|         }); | ||||
|     } | ||||
| } | ||||
|  | ||||
| #[inline] | ||||
| pub fn populate_fut_lifetimes_on_refs(args: &mut Vec<Argument>) { | ||||
|     for arg in args { | ||||
|         if let Type::Reference(reference) = &mut arg.kind { | ||||
|             reference.lifetime = Some(Lifetime::new("'fut", Span::call_site())); | ||||
|         } | ||||
|     } | ||||
| } | ||||
|  | ||||
| pub fn append_line(desc: &mut String, mut line: String) { | ||||
|     if line.starts_with(' ') { | ||||
|         line.remove(0); | ||||
|     } | ||||
|  | ||||
|     match line.rfind("\\$") { | ||||
|         Some(i) => { | ||||
|             desc.push_str(line[..i].trim_end()); | ||||
|             desc.push(' '); | ||||
|         } | ||||
|         None => { | ||||
|             desc.push_str(&line); | ||||
|             desc.push('\n'); | ||||
|         } | ||||
|     } | ||||
| } | ||||
							
								
								
									
										8
									
								
								conf/Rocket.toml
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										8
									
								
								conf/Rocket.toml
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,8 @@ | ||||
| [default] | ||||
| address = "127.0.0.1" | ||||
| port = 18920 | ||||
| template_dir = "/lib/reminder-rs/templates" | ||||
| limits = { json = "10MiB" } | ||||
|  | ||||
| [release] | ||||
| # secret_key = "" | ||||
							
								
								
									
										19
									
								
								conf/default.env
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										19
									
								
								conf/default.env
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,19 @@ | ||||
| DATABASE_URL= | ||||
|  | ||||
| DISCORD_TOKEN= | ||||
| PATREON_GUILD_ID= | ||||
| PATREON_ROLE_ID= | ||||
|  | ||||
| LOCAL_TIMEZONE= | ||||
| MIN_INTERVAL= | ||||
| PYTHON_LOCATION=/usr/bin/python3 | ||||
| DONTRUN= | ||||
| SECRET_KEY= | ||||
|  | ||||
| REMIND_INTERVAL= | ||||
| OAUTH2_DISCORD_CALLBACK= | ||||
| OAUTH2_CLIENT_ID= | ||||
| OAUTH2_CLIENT_SECRET= | ||||
|  | ||||
| REPORT_EMAIL= | ||||
| LOG_TO_DATABASE=1 | ||||
							
								
								
									
										1
									
								
								cron.d/reminder_health
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										1
									
								
								cron.d/reminder_health
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1 @@ | ||||
| */10 * * * * reminder /lib/reminder-rs/healthcheck | ||||
							
								
								
									
										9
									
								
								debian/postinst
									
									
									
									
										vendored
									
									
										Normal file
									
								
							
							
						
						
									
										9
									
								
								debian/postinst
									
									
									
									
										vendored
									
									
										Normal file
									
								
							| @@ -0,0 +1,9 @@ | ||||
| #!/bin/bash | ||||
|  | ||||
| set -e | ||||
|  | ||||
| id -u reminder &>/dev/null || useradd -r -M reminder | ||||
|  | ||||
| chown -R reminder /etc/reminder-rs | ||||
|  | ||||
| #DEBHELPER# | ||||
							
								
								
									
										7
									
								
								debian/postrm
									
									
									
									
										vendored
									
									
										Normal file
									
								
							
							
						
						
									
										7
									
								
								debian/postrm
									
									
									
									
										vendored
									
									
										Normal file
									
								
							| @@ -0,0 +1,7 @@ | ||||
| #!/bin/bash | ||||
|  | ||||
| set -e | ||||
|  | ||||
| id -u reminder &>/dev/null || userdel reminder | ||||
|  | ||||
| #DEBHELPER# | ||||
							
								
								
									
										13
									
								
								healthcheck
									
									
									
									
									
										Executable file
									
								
							
							
						
						
									
										13
									
								
								healthcheck
									
									
									
									
									
										Executable file
									
								
							| @@ -0,0 +1,13 @@ | ||||
| #!/bin/bash | ||||
|  | ||||
| export $(grep -v '^#' /etc/reminder-rs/config.env | xargs -d '\n') | ||||
|  | ||||
| REGEX='mysql://([A-Za-z]+)@(.+)/(.+)' | ||||
| [[ $DATABASE_URL =~ $REGEX ]] | ||||
|  | ||||
| VAR=$(mysql -u "${BASH_REMATCH[1]}" -h "${BASH_REMATCH[2]}" -N -D "${BASH_REMATCH[3]}" -e "SELECT COUNT(1) FROM reminders WHERE utc_time < NOW() - INTERVAL 10 MINUTE AND enabled = 1 AND status = 'pending'") | ||||
|  | ||||
| if [ "$VAR" -gt 0 ] | ||||
| then | ||||
|   echo "This is to inform that there is a reminder backlog which must be resolved." | mail -s "Backlog: $VAR" "$REPORT_EMAIL" | ||||
| fi | ||||
| @@ -1,10 +1,6 @@ | ||||
| CREATE DATABASE IF NOT EXISTS reminders; | ||||
| 
 | ||||
| SET FOREIGN_KEY_CHECKS=0; | ||||
| 
 | ||||
| USE reminders; | ||||
| 
 | ||||
| CREATE TABLE reminders.guilds ( | ||||
| CREATE TABLE guilds ( | ||||
|     id INT UNSIGNED UNIQUE NOT NULL AUTO_INCREMENT, | ||||
|     guild BIGINT UNSIGNED UNIQUE NOT NULL, | ||||
| 
 | ||||
| @@ -18,10 +14,10 @@ CREATE TABLE reminders.guilds ( | ||||
|     default_avatar VARCHAR(512) DEFAULT 'https://raw.githubusercontent.com/reminder-bot/logos/master/Remind_Me_Bot_Logo_PPic.jpg' NOT NULL, | ||||
| 
 | ||||
|     PRIMARY KEY (id), | ||||
|     FOREIGN KEY (default_channel_id) REFERENCES reminders.channels(id) ON DELETE SET NULL | ||||
|     FOREIGN KEY (default_channel_id) REFERENCES channels(id) ON DELETE SET NULL | ||||
| ); | ||||
| 
 | ||||
| CREATE TABLE reminders.channels ( | ||||
| CREATE TABLE channels ( | ||||
|     id INT UNSIGNED UNIQUE NOT NULL AUTO_INCREMENT, | ||||
|     channel BIGINT UNSIGNED UNIQUE NOT NULL, | ||||
| 
 | ||||
| @@ -39,10 +35,10 @@ CREATE TABLE reminders.channels ( | ||||
|     guild_id INT UNSIGNED, | ||||
| 
 | ||||
|     PRIMARY KEY (id), | ||||
|     FOREIGN KEY (guild_id) REFERENCES reminders.guilds(id) ON DELETE CASCADE | ||||
|     FOREIGN KEY (guild_id) REFERENCES guilds(id) ON DELETE CASCADE | ||||
| ); | ||||
| 
 | ||||
| CREATE TABLE reminders.users ( | ||||
| CREATE TABLE users ( | ||||
|     id INT UNSIGNED AUTO_INCREMENT UNIQUE NOT NULL, | ||||
|     user BIGINT UNSIGNED UNIQUE NOT NULL, | ||||
| 
 | ||||
| @@ -59,10 +55,10 @@ CREATE TABLE reminders.users ( | ||||
|     patreon BOOLEAN NOT NULL DEFAULT 0, | ||||
| 
 | ||||
|     PRIMARY KEY (id), | ||||
|     FOREIGN KEY (dm_channel) REFERENCES reminders.channels(id) ON DELETE RESTRICT | ||||
|     FOREIGN KEY (dm_channel) REFERENCES channels(id) ON DELETE RESTRICT | ||||
| ); | ||||
| 
 | ||||
| CREATE TABLE reminders.roles ( | ||||
| CREATE TABLE roles ( | ||||
|     id INT UNSIGNED UNIQUE NOT NULL AUTO_INCREMENT, | ||||
|     role BIGINT UNSIGNED UNIQUE NOT NULL, | ||||
| 
 | ||||
| @@ -71,10 +67,10 @@ CREATE TABLE reminders.roles ( | ||||
|     guild_id INT UNSIGNED NOT NULL, | ||||
| 
 | ||||
|     PRIMARY KEY (id), | ||||
|     FOREIGN KEY (guild_id) REFERENCES reminders.guilds(id) ON DELETE CASCADE | ||||
|     FOREIGN KEY (guild_id) REFERENCES guilds(id) ON DELETE CASCADE | ||||
| ); | ||||
| 
 | ||||
| CREATE TABLE reminders.embeds ( | ||||
| CREATE TABLE embeds ( | ||||
|     id INT UNSIGNED AUTO_INCREMENT UNIQUE NOT NULL, | ||||
| 
 | ||||
|     title VARCHAR(256) NOT NULL DEFAULT '', | ||||
| @@ -91,7 +87,7 @@ CREATE TABLE reminders.embeds ( | ||||
|     PRIMARY KEY (id) | ||||
| ); | ||||
| 
 | ||||
| CREATE TABLE reminders.embed_fields ( | ||||
| CREATE TABLE embed_fields ( | ||||
|     id INT UNSIGNED AUTO_INCREMENT UNIQUE NOT NULL, | ||||
| 
 | ||||
|     title VARCHAR(256) NOT NULL DEFAULT '', | ||||
| @@ -100,10 +96,10 @@ CREATE TABLE reminders.embed_fields ( | ||||
|     embed_id INT UNSIGNED NOT NULL, | ||||
| 
 | ||||
|     PRIMARY KEY (id), | ||||
|     FOREIGN KEY (embed_id) REFERENCES reminders.embeds(id) ON DELETE CASCADE | ||||
|     FOREIGN KEY (embed_id) REFERENCES embeds(id) ON DELETE CASCADE | ||||
| ); | ||||
| 
 | ||||
| CREATE TABLE reminders.messages ( | ||||
| CREATE TABLE messages ( | ||||
|     id INT UNSIGNED AUTO_INCREMENT UNIQUE NOT NULL, | ||||
| 
 | ||||
|     content VARCHAR(2048) NOT NULL DEFAULT '', | ||||
| @@ -114,10 +110,10 @@ CREATE TABLE reminders.messages ( | ||||
|     attachment_name VARCHAR(260), | ||||
| 
 | ||||
|     PRIMARY KEY (id), | ||||
|     FOREIGN KEY (embed_id) REFERENCES reminders.embeds(id) ON DELETE SET NULL | ||||
|     FOREIGN KEY (embed_id) REFERENCES embeds(id) ON DELETE SET NULL | ||||
| ); | ||||
| 
 | ||||
| CREATE TABLE reminders.reminders ( | ||||
| CREATE TABLE reminders ( | ||||
|     id INT UNSIGNED AUTO_INCREMENT UNIQUE NOT NULL, | ||||
|     uid VARCHAR(64) UNIQUE NOT NULL, | ||||
| 
 | ||||
| @@ -140,20 +136,20 @@ CREATE TABLE reminders.reminders ( | ||||
|     set_by INT UNSIGNED, | ||||
| 
 | ||||
|     PRIMARY KEY (id), | ||||
|     FOREIGN KEY (message_id) REFERENCES reminders.messages(id) ON DELETE RESTRICT, | ||||
|     FOREIGN KEY (channel_id) REFERENCES reminders.channels(id) ON DELETE CASCADE, | ||||
|     FOREIGN KEY (set_by) REFERENCES reminders.users(id) ON DELETE SET NULL | ||||
|     FOREIGN KEY (message_id) REFERENCES messages(id) ON DELETE RESTRICT, | ||||
|     FOREIGN KEY (channel_id) REFERENCES channels(id) ON DELETE CASCADE, | ||||
|     FOREIGN KEY (set_by) REFERENCES users(id) ON DELETE SET NULL | ||||
| ); | ||||
| 
 | ||||
| CREATE TRIGGER message_cleanup AFTER DELETE ON reminders.reminders | ||||
| CREATE TRIGGER message_cleanup AFTER DELETE ON reminders | ||||
| FOR EACH ROW | ||||
|     DELETE FROM reminders.messages WHERE id = OLD.message_id; | ||||
|     DELETE FROM messages WHERE id = OLD.message_id; | ||||
| 
 | ||||
| CREATE TRIGGER embed_cleanup AFTER DELETE ON reminders.messages | ||||
| CREATE TRIGGER embed_cleanup AFTER DELETE ON messages | ||||
| FOR EACH ROW | ||||
|     DELETE FROM reminders.embeds WHERE id = OLD.embed_id; | ||||
|     DELETE FROM embeds WHERE id = OLD.embed_id; | ||||
| 
 | ||||
| CREATE TABLE reminders.todos ( | ||||
| CREATE TABLE todos ( | ||||
|     id INT UNSIGNED AUTO_INCREMENT UNIQUE NOT NULL, | ||||
|     user_id INT UNSIGNED, | ||||
|     guild_id INT UNSIGNED, | ||||
| @@ -161,23 +157,23 @@ CREATE TABLE reminders.todos ( | ||||
|     value VARCHAR(2000) NOT NULL, | ||||
| 
 | ||||
|     PRIMARY KEY (id), | ||||
|     FOREIGN KEY (user_id) REFERENCES reminders.users(id) ON DELETE SET NULL, | ||||
|     FOREIGN KEY (guild_id) REFERENCES reminders.guilds(id) ON DELETE CASCADE, | ||||
|     FOREIGN KEY (channel_id) REFERENCES reminders.channels(id) ON DELETE SET NULL | ||||
|     FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE SET NULL, | ||||
|     FOREIGN KEY (guild_id) REFERENCES guilds(id) ON DELETE CASCADE, | ||||
|     FOREIGN KEY (channel_id) REFERENCES channels(id) ON DELETE SET NULL | ||||
| ); | ||||
| 
 | ||||
| CREATE TABLE reminders.command_restrictions ( | ||||
| CREATE TABLE command_restrictions ( | ||||
|     id INT UNSIGNED AUTO_INCREMENT UNIQUE NOT NULL, | ||||
| 
 | ||||
|     role_id INT UNSIGNED NOT NULL, | ||||
|     command ENUM('todos', 'natural', 'remind', 'interval', 'timer', 'del', 'look', 'alias', 'countdown') NOT NULL, | ||||
| 
 | ||||
|     PRIMARY KEY (id), | ||||
|     FOREIGN KEY (role_id) REFERENCES reminders.roles(id) ON DELETE CASCADE, | ||||
|     FOREIGN KEY (role_id) REFERENCES roles(id) ON DELETE CASCADE, | ||||
|     UNIQUE KEY (`role_id`, `command`) | ||||
| ); | ||||
| 
 | ||||
| CREATE TABLE reminders.timers ( | ||||
| CREATE TABLE timers ( | ||||
|     id INT UNSIGNED AUTO_INCREMENT UNIQUE NOT NULL, | ||||
|     start_time TIMESTAMP NOT NULL DEFAULT NOW(), | ||||
|     name VARCHAR(32) NOT NULL, | ||||
| @@ -186,7 +182,7 @@ CREATE TABLE reminders.timers ( | ||||
|     PRIMARY KEY (id) | ||||
| ); | ||||
| 
 | ||||
| CREATE TABLE reminders.events ( | ||||
| CREATE TABLE events ( | ||||
|     id INT UNSIGNED AUTO_INCREMENT UNIQUE NOT NULL, | ||||
|     `time` TIMESTAMP NOT NULL DEFAULT NOW(), | ||||
| 
 | ||||
| @@ -198,12 +194,12 @@ CREATE TABLE reminders.events ( | ||||
|     reminder_id INT UNSIGNED, | ||||
| 
 | ||||
|     PRIMARY KEY (id), | ||||
|     FOREIGN KEY (guild_id) REFERENCES reminders.guilds(id) ON DELETE CASCADE, | ||||
|     FOREIGN KEY (user_id) REFERENCES reminders.users(id) ON DELETE SET NULL, | ||||
|     FOREIGN KEY (reminder_id) REFERENCES reminders.reminders(id) ON DELETE SET NULL | ||||
|     FOREIGN KEY (guild_id) REFERENCES guilds(id) ON DELETE CASCADE, | ||||
|     FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE SET NULL, | ||||
|     FOREIGN KEY (reminder_id) REFERENCES reminders(id) ON DELETE SET NULL | ||||
| ); | ||||
| 
 | ||||
| CREATE TABLE reminders.command_aliases ( | ||||
| CREATE TABLE command_aliases ( | ||||
|     id INT UNSIGNED AUTO_INCREMENT UNIQUE NOT NULL, | ||||
| 
 | ||||
|     guild_id INT UNSIGNED NOT NULL, | ||||
| @@ -212,22 +208,22 @@ CREATE TABLE reminders.command_aliases ( | ||||
|     command VARCHAR(2048) NOT NULL, | ||||
| 
 | ||||
|     PRIMARY KEY (id), | ||||
|     FOREIGN KEY (guild_id) REFERENCES reminders.guilds(id) ON DELETE CASCADE, | ||||
|     FOREIGN KEY (guild_id) REFERENCES guilds(id) ON DELETE CASCADE, | ||||
|     UNIQUE KEY (`guild_id`, `name`) | ||||
| ); | ||||
| 
 | ||||
| CREATE TABLE reminders.guild_users ( | ||||
| CREATE TABLE guild_users ( | ||||
|     guild INT UNSIGNED NOT NULL, | ||||
|     user INT UNSIGNED NOT NULL, | ||||
| 
 | ||||
|     can_access BOOL NOT NULL DEFAULT 0, | ||||
| 
 | ||||
|     FOREIGN KEY (guild) REFERENCES reminders.guilds(id) ON DELETE CASCADE, | ||||
|     FOREIGN KEY (user) REFERENCES reminders.users(id) ON DELETE CASCADE, | ||||
|     FOREIGN KEY (guild) REFERENCES guilds(id) ON DELETE CASCADE, | ||||
|     FOREIGN KEY (user) REFERENCES users(id) ON DELETE CASCADE, | ||||
|     UNIQUE KEY (guild, user) | ||||
| ); | ||||
| 
 | ||||
| CREATE EVENT reminders.event_cleanup | ||||
| CREATE EVENT event_cleanup | ||||
| ON SCHEDULE AT CURRENT_TIMESTAMP + INTERVAL 1 DAY | ||||
| ON COMPLETION PRESERVE | ||||
| DO DELETE FROM reminders.events WHERE `time` < DATE_SUB(NOW(), INTERVAL 5 DAY); | ||||
| DO DELETE FROM events WHERE `time` < DATE_SUB(NOW(), INTERVAL 5 DAY); | ||||
| @@ -1,5 +1,3 @@ | ||||
| USE reminders; | ||||
| 
 | ||||
| SET FOREIGN_KEY_CHECKS = 0; | ||||
| 
 | ||||
| DROP TABLE IF EXISTS reminders_new; | ||||
| @@ -157,4 +155,9 @@ CREATE TABLE events ( | ||||
|     FOREIGN KEY (reminder_id) REFERENCES reminders_new(id) ON DELETE SET NULL | ||||
| ); | ||||
| 
 | ||||
| DROP TABLE reminders; | ||||
| DROP TABLE embed_fields; | ||||
| RENAME TABLE reminders_new TO reminders; | ||||
| RENAME TABLE embed_fields_new TO embed_fields; | ||||
| 
 | ||||
| SET FOREIGN_KEY_CHECKS = 1; | ||||
| @@ -1,5 +1,3 @@ | ||||
| USE reminders; | ||||
| 
 | ||||
| CREATE TABLE macro ( | ||||
|     id INT UNSIGNED AUTO_INCREMENT, | ||||
|     guild_id INT UNSIGNED NOT NULL, | ||||
| @@ -0,0 +1,2 @@ | ||||
| ALTER TABLE reminders RENAME COLUMN `interval` TO `interval_seconds`; | ||||
| ALTER TABLE reminders ADD COLUMN `interval_months` INT UNSIGNED DEFAULT NULL; | ||||
							
								
								
									
										49
									
								
								migrations/20220211000000_reminder_templates.sql
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										49
									
								
								migrations/20220211000000_reminder_templates.sql
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,49 @@ | ||||
| CREATE TABLE reminder_template ( | ||||
|     `id` INT UNSIGNED NOT NULL AUTO_INCREMENT, | ||||
|  | ||||
|     `name` VARCHAR(24) NOT NULL DEFAULT 'Reminder', | ||||
|  | ||||
|     `guild_id` INT UNSIGNED NOT NULL, | ||||
|  | ||||
|     `username` VARCHAR(32) DEFAULT NULL, | ||||
|     `avatar` VARCHAR(512) DEFAULT NULL, | ||||
|  | ||||
|     `content` VARCHAR(2048) NOT NULL DEFAULT '', | ||||
|     `tts` BOOL NOT NULL DEFAULT 0, | ||||
|     `attachment` MEDIUMBLOB, | ||||
|     `attachment_name` VARCHAR(260), | ||||
|  | ||||
|     `embed_title` VARCHAR(256) NOT NULL DEFAULT '', | ||||
|     `embed_description` VARCHAR(2048) NOT NULL DEFAULT '', | ||||
|     `embed_image_url` VARCHAR(512), | ||||
|     `embed_thumbnail_url` VARCHAR(512), | ||||
|     `embed_footer` VARCHAR(2048) NOT NULL DEFAULT '', | ||||
|     `embed_footer_url` VARCHAR(512), | ||||
|     `embed_author` VARCHAR(256) NOT NULL DEFAULT '', | ||||
|     `embed_author_url` VARCHAR(512), | ||||
|     `embed_color` INT UNSIGNED NOT NULL DEFAULT 0x0, | ||||
|     `embed_fields` JSON, | ||||
|  | ||||
|     PRIMARY KEY (id), | ||||
|  | ||||
|     FOREIGN KEY (`guild_id`) REFERENCES guilds (`id`) ON DELETE CASCADE | ||||
| ); | ||||
|  | ||||
| ALTER TABLE reminders ADD COLUMN embed_fields JSON; | ||||
|  | ||||
| update reminders | ||||
|     inner join embed_fields as E | ||||
|     on E.reminder_id = reminders.id | ||||
| set embed_fields = ( | ||||
|     select JSON_ARRAYAGG( | ||||
|         JSON_OBJECT( | ||||
|             'title', E.title, | ||||
|             'value', E.value, | ||||
|             'inline', | ||||
|             if(inline = 1, cast(TRUE as json), cast(FALSE as json)) | ||||
|             ) | ||||
|         ) | ||||
|     from embed_fields | ||||
|     group by reminder_id | ||||
|     having reminder_id = reminders.id | ||||
|     ); | ||||
							
								
								
									
										1
									
								
								migrations/20221210000000_reminder_daily_intervals.sql
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										1
									
								
								migrations/20221210000000_reminder_daily_intervals.sql
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1 @@ | ||||
| ALTER TABLE reminders ADD COLUMN `interval_days` INT UNSIGNED DEFAULT NULL; | ||||
							
								
								
									
										1
									
								
								migrations/20230511125236_reminder_threads.sql
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										1
									
								
								migrations/20230511125236_reminder_threads.sql
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1 @@ | ||||
| ALTER TABLE reminders ADD COLUMN `thread_id` BIGINT DEFAULT NULL; | ||||
							
								
								
									
										1
									
								
								migrations/20230511180231_ephemeral_confirmations.sql
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										1
									
								
								migrations/20230511180231_ephemeral_confirmations.sql
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1 @@ | ||||
| ALTER TABLE guilds ADD COLUMN ephemeral_confirmations BOOL NOT NULL DEFAULT 0; | ||||
							
								
								
									
										2
									
								
								migrations/20230722130906_increase_reminder_name.sql
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										2
									
								
								migrations/20230722130906_increase_reminder_name.sql
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,2 @@ | ||||
| ALTER TABLE reminders MODIFY `name` VARCHAR(100) NOT NULL DEFAULT 'Reminder'; | ||||
| ALTER TABLE reminder_template MODIFY `name` VARCHAR(100) NOT NULL DEFAULT 'Reminder'; | ||||
							
								
								
									
										9
									
								
								migrations/20230730134827_stats.sql
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										9
									
								
								migrations/20230730134827_stats.sql
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,9 @@ | ||||
| CREATE TABLE stat ( | ||||
|     `id` BIGINT NOT NULL AUTO_INCREMENT, | ||||
|     `utc_time` DATETIME NOT NULL DEFAULT NOW(), | ||||
|     `type` ENUM('reminder_sent', 'reminder_failed'), | ||||
|     `reminder_id` INT UNSIGNED, | ||||
|     `message` TEXT, | ||||
|  | ||||
|     PRIMARY KEY (`id`) | ||||
| ); | ||||
							
								
								
									
										2
									
								
								migrations/20230731170452_reminder_archive.sql
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										2
									
								
								migrations/20230731170452_reminder_archive.sql
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,2 @@ | ||||
| ALTER TABLE reminders ADD COLUMN `status` ENUM ('pending', 'sent', 'failed', 'deleted') NOT NULL DEFAULT 'pending'; | ||||
| ALTER TABLE reminders ADD COLUMN `status_message` TEXT; | ||||
| @@ -0,0 +1,3 @@ | ||||
| ALTER TABLE `reminder_template` ADD COLUMN `interval_seconds` INT UNSIGNED; | ||||
| ALTER TABLE `reminder_template` ADD COLUMN `interval_days` INT UNSIGNED; | ||||
| ALTER TABLE `reminder_template` ADD COLUMN `interval_months` INT UNSIGNED; | ||||
							
								
								
									
										41
									
								
								nginx/reminder-rs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										41
									
								
								nginx/reminder-rs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,41 @@ | ||||
| server { | ||||
|         server_name www.reminder-bot.com; | ||||
|  | ||||
|         return 301 $scheme://reminder-bot.com$request_uri; | ||||
| } | ||||
|  | ||||
| server { | ||||
|         listen 80; | ||||
|         server_name reminder-bot.com; | ||||
|  | ||||
| 	    return 301 https://reminder-bot.com$request_uri; | ||||
| } | ||||
|  | ||||
| server { | ||||
|         listen 443 ssl; | ||||
|         server_name reminder-bot.com; | ||||
|  | ||||
|         ssl_certificate /etc/letsencrypt/live/reminder-bot.com/fullchain.pem; | ||||
|         ssl_certificate_key /etc/letsencrypt/live/reminder-bot.com/privkey.pem; | ||||
|  | ||||
|         access_log /var/log/nginx/access.log; | ||||
|         error_log /var/log/nginx/error.log; | ||||
|  | ||||
|         proxy_buffer_size 128k; | ||||
|         proxy_buffers 4 256k; | ||||
|         proxy_busy_buffers_size 256k; | ||||
|  | ||||
|         location / { | ||||
|                 proxy_pass http://localhost:18920; | ||||
|                 proxy_redirect off; | ||||
|                 proxy_set_header Host $host; | ||||
|                 proxy_set_header X-Real-IP $remote_addr; | ||||
|                 proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; | ||||
| 		        proxy_set_header X-Forwarded-Proto $scheme; | ||||
|         } | ||||
|  | ||||
|         location /static { | ||||
|                 alias /var/www/reminder-rs/static; | ||||
|                 expires 30d; | ||||
|         } | ||||
| } | ||||
							
								
								
									
										16
									
								
								postman/Cargo.toml
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										16
									
								
								postman/Cargo.toml
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,16 @@ | ||||
| [package] | ||||
| name = "postman" | ||||
| version = "0.1.0" | ||||
| edition = "2021" | ||||
|  | ||||
| [dependencies] | ||||
| tokio = { version = "1", features = ["process", "full"] } | ||||
| regex = "1.9" | ||||
| log = "0.4" | ||||
| chrono = "0.4" | ||||
| chrono-tz = { version = "0.8", features = ["serde"] } | ||||
| lazy_static = "1.4" | ||||
| num-integer = "0.1" | ||||
| serde = "1.0" | ||||
| sqlx = { version = "0.7", features = ["runtime-tokio-rustls", "macros", "mysql", "bigdecimal", "chrono", "json"]} | ||||
| serenity = { version = "0.11", default-features = false, features = ["builder", "cache", "client", "gateway", "http", "model", "utils", "rustls_backend"] } | ||||
							
								
								
									
										50
									
								
								postman/src/lib.rs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										50
									
								
								postman/src/lib.rs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,50 @@ | ||||
| mod sender; | ||||
|  | ||||
| use std::env; | ||||
|  | ||||
| use log::{info, warn}; | ||||
| use serenity::client::Context; | ||||
| use sqlx::{Executor, MySql}; | ||||
| use tokio::{ | ||||
|     sync::broadcast::Receiver, | ||||
|     time::{sleep_until, Duration, Instant}, | ||||
| }; | ||||
|  | ||||
| type Database = MySql; | ||||
|  | ||||
| pub async fn initialize( | ||||
|     mut kill: Receiver<()>, | ||||
|     ctx: Context, | ||||
|     pool: impl Executor<'_, Database = Database> + Copy, | ||||
| ) -> Result<(), &'static str> { | ||||
|     tokio::select! { | ||||
|         output = _initialize(ctx, pool) => Ok(output), | ||||
|         _ = kill.recv() => { | ||||
|             warn!("Received terminate signal. Goodbye"); | ||||
|             Err("Received terminate signal. Goodbye") | ||||
|         } | ||||
|     } | ||||
| } | ||||
|  | ||||
| async fn _initialize(ctx: Context, pool: impl Executor<'_, Database = Database> + Copy) { | ||||
|     let remind_interval = env::var("REMIND_INTERVAL") | ||||
|         .map(|inner| inner.parse::<u64>().ok()) | ||||
|         .ok() | ||||
|         .flatten() | ||||
|         .unwrap_or(10); | ||||
|  | ||||
|     loop { | ||||
|         let sleep_to = Instant::now() + Duration::from_secs(remind_interval); | ||||
|         let reminders = sender::Reminder::fetch_reminders(pool).await; | ||||
|  | ||||
|         if reminders.len() > 0 { | ||||
|             info!("Preparing to send {} reminders.", reminders.len()); | ||||
|  | ||||
|             for reminder in reminders { | ||||
|                 reminder.send(pool, ctx.clone()).await; | ||||
|             } | ||||
|         } | ||||
|  | ||||
|         sleep_until(sleep_to).await; | ||||
|     } | ||||
| } | ||||
							
								
								
									
										745
									
								
								postman/src/sender.rs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										745
									
								
								postman/src/sender.rs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,745 @@ | ||||
| use std::env; | ||||
|  | ||||
| use chrono::{DateTime, Days, Duration, Months}; | ||||
| use chrono_tz::Tz; | ||||
| use lazy_static::lazy_static; | ||||
| use log::{error, info, warn}; | ||||
| use num_integer::Integer; | ||||
| use regex::{Captures, Regex}; | ||||
| use serde::Deserialize; | ||||
| use serenity::{ | ||||
|     builder::CreateEmbed, | ||||
|     http::{CacheHttp, Http, HttpError}, | ||||
|     model::{ | ||||
|         channel::{Channel, Embed as SerenityEmbed}, | ||||
|         id::ChannelId, | ||||
|         webhook::Webhook, | ||||
|     }, | ||||
|     Error, Result, | ||||
| }; | ||||
| use sqlx::{ | ||||
|     types::{ | ||||
|         chrono::{NaiveDateTime, Utc}, | ||||
|         Json, | ||||
|     }, | ||||
|     Executor, | ||||
| }; | ||||
|  | ||||
| use crate::Database; | ||||
|  | ||||
| lazy_static! { | ||||
|     pub static ref TIMEFROM_REGEX: Regex = | ||||
|         Regex::new(r#"<<timefrom:(?P<time>\d+):(?P<format>.+)?>>"#).unwrap(); | ||||
|     pub static ref TIMENOW_REGEX: Regex = | ||||
|         Regex::new(r#"<<timenow:(?P<timezone>(?:\w|/|_)+):(?P<format>.+)?>>"#).unwrap(); | ||||
|     pub static ref LOG_TO_DATABASE: bool = env::var("LOG_TO_DATABASE").map_or(true, |v| v == "1"); | ||||
| } | ||||
|  | ||||
| fn fmt_displacement(format: &str, seconds: u64) -> String { | ||||
|     let mut seconds = seconds; | ||||
|     let mut days: u64 = 0; | ||||
|     let mut hours: u64 = 0; | ||||
|     let mut minutes: u64 = 0; | ||||
|  | ||||
|     for (rep, time_type, div) in | ||||
|         [("%d", &mut days, 86400), ("%h", &mut hours, 3600), ("%m", &mut minutes, 60)].iter_mut() | ||||
|     { | ||||
|         if format.contains(*rep) { | ||||
|             let (divided, new_seconds) = seconds.div_rem(&div); | ||||
|  | ||||
|             **time_type = divided; | ||||
|             seconds = new_seconds; | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     format | ||||
|         .replace("%s", &seconds.to_string()) | ||||
|         .replace("%m", &minutes.to_string()) | ||||
|         .replace("%h", &hours.to_string()) | ||||
|         .replace("%d", &days.to_string()) | ||||
| } | ||||
|  | ||||
| pub fn substitute(string: &str) -> String { | ||||
|     let new = TIMEFROM_REGEX.replace(string, |caps: &Captures| { | ||||
|         let final_time = caps.name("time").map(|m| m.as_str().parse::<i64>().ok()).flatten(); | ||||
|         let format = caps.name("format").map(|m| m.as_str()); | ||||
|  | ||||
|         if let (Some(final_time), Some(format)) = (final_time, format) { | ||||
|             match NaiveDateTime::from_timestamp_opt(final_time, 0) { | ||||
|                 Some(dt) => { | ||||
|                     let now = Utc::now().naive_utc(); | ||||
|  | ||||
|                     let difference = { | ||||
|                         if now < dt { | ||||
|                             dt - Utc::now().naive_utc() | ||||
|                         } else { | ||||
|                             Utc::now().naive_utc() - dt | ||||
|                         } | ||||
|                     }; | ||||
|  | ||||
|                     fmt_displacement(format, difference.num_seconds() as u64) | ||||
|                 } | ||||
|  | ||||
|                 None => String::new(), | ||||
|             } | ||||
|         } else { | ||||
|             String::new() | ||||
|         } | ||||
|     }); | ||||
|  | ||||
|     TIMENOW_REGEX | ||||
|         .replace(&new, |caps: &Captures| { | ||||
|             let timezone = caps.name("timezone").map(|m| m.as_str().parse::<Tz>().ok()).flatten(); | ||||
|             let format = caps.name("format").map(|m| m.as_str()); | ||||
|  | ||||
|             if let (Some(timezone), Some(format)) = (timezone, format) { | ||||
|                 let now = Utc::now().with_timezone(&timezone); | ||||
|  | ||||
|                 now.format(format).to_string() | ||||
|             } else { | ||||
|                 String::new() | ||||
|             } | ||||
|         }) | ||||
|         .to_string() | ||||
| } | ||||
|  | ||||
| struct Embed { | ||||
|     title: String, | ||||
|     description: String, | ||||
|     image_url: Option<String>, | ||||
|     thumbnail_url: Option<String>, | ||||
|     footer: String, | ||||
|     footer_url: Option<String>, | ||||
|     author: String, | ||||
|     author_url: Option<String>, | ||||
|     color: u32, | ||||
|     fields: Json<Vec<EmbedField>>, | ||||
| } | ||||
|  | ||||
| #[derive(Deserialize)] | ||||
| struct EmbedField { | ||||
|     title: String, | ||||
|     value: String, | ||||
|     inline: bool, | ||||
| } | ||||
|  | ||||
| impl Embed { | ||||
|     pub async fn from_id( | ||||
|         pool: impl Executor<'_, Database = Database> + Copy, | ||||
|         id: u32, | ||||
|     ) -> Option<Self> { | ||||
|         match sqlx::query_as!( | ||||
|             Self, | ||||
|             r#" | ||||
|             SELECT | ||||
|              `embed_title` AS title, | ||||
|              `embed_description` AS description, | ||||
|              `embed_image_url` AS image_url, | ||||
|              `embed_thumbnail_url` AS thumbnail_url, | ||||
|              `embed_footer` AS footer, | ||||
|              `embed_footer_url` AS footer_url, | ||||
|              `embed_author` AS author, | ||||
|              `embed_author_url` AS author_url, | ||||
|              `embed_color` AS color, | ||||
|              IFNULL(`embed_fields`, '[]') AS "fields:_" | ||||
|             FROM reminders | ||||
|             WHERE `id` = ?"#, | ||||
|             id | ||||
|         ) | ||||
|         .fetch_one(pool) | ||||
|         .await | ||||
|         { | ||||
|             Ok(mut embed) => { | ||||
|                 embed.title = substitute(&embed.title); | ||||
|                 embed.description = substitute(&embed.description); | ||||
|                 embed.footer = substitute(&embed.footer); | ||||
|  | ||||
|                 embed.fields.iter_mut().for_each(|field| { | ||||
|                     field.title = substitute(&field.title); | ||||
|                     field.value = substitute(&field.value); | ||||
|                 }); | ||||
|  | ||||
|                 if embed.has_content() { | ||||
|                     Some(embed) | ||||
|                 } else { | ||||
|                     None | ||||
|                 } | ||||
|             } | ||||
|  | ||||
|             Err(e) => { | ||||
|                 warn!("Error loading embed from reminder: {:?}", e); | ||||
|  | ||||
|                 None | ||||
|             } | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     pub fn has_content(&self) -> bool { | ||||
|         if self.title.is_empty() | ||||
|             && self.description.is_empty() | ||||
|             && self.image_url.is_none() | ||||
|             && self.thumbnail_url.is_none() | ||||
|             && self.footer.is_empty() | ||||
|             && self.footer_url.is_none() | ||||
|             && self.author.is_empty() | ||||
|             && self.author_url.is_none() | ||||
|             && self.fields.0.is_empty() | ||||
|         { | ||||
|             false | ||||
|         } else { | ||||
|             true | ||||
|         } | ||||
|     } | ||||
| } | ||||
|  | ||||
| impl Into<CreateEmbed> for Embed { | ||||
|     fn into(self) -> CreateEmbed { | ||||
|         let mut c = CreateEmbed::default(); | ||||
|  | ||||
|         c.title(&self.title) | ||||
|             .description(&self.description) | ||||
|             .color(self.color) | ||||
|             .author(|a| { | ||||
|                 a.name(&self.author); | ||||
|  | ||||
|                 if let Some(author_icon) = &self.author_url { | ||||
|                     a.icon_url(author_icon); | ||||
|                 } | ||||
|  | ||||
|                 a | ||||
|             }) | ||||
|             .footer(|f| { | ||||
|                 f.text(&self.footer); | ||||
|  | ||||
|                 if let Some(footer_icon) = &self.footer_url { | ||||
|                     f.icon_url(footer_icon); | ||||
|                 } | ||||
|  | ||||
|                 f | ||||
|             }); | ||||
|  | ||||
|         for field in &self.fields.0 { | ||||
|             c.field(&field.title, &field.value, field.inline); | ||||
|         } | ||||
|  | ||||
|         if let Some(image_url) = &self.image_url { | ||||
|             c.image(image_url); | ||||
|         } | ||||
|  | ||||
|         if let Some(thumbnail_url) = &self.thumbnail_url { | ||||
|             c.thumbnail(thumbnail_url); | ||||
|         } | ||||
|  | ||||
|         c | ||||
|     } | ||||
| } | ||||
|  | ||||
| pub struct Reminder { | ||||
|     id: u32, | ||||
|  | ||||
|     channel_id: u64, | ||||
|     webhook_id: Option<u64>, | ||||
|     webhook_token: Option<String>, | ||||
|  | ||||
|     channel_paused: bool, | ||||
|     channel_paused_until: Option<NaiveDateTime>, | ||||
|     enabled: bool, | ||||
|  | ||||
|     tts: bool, | ||||
|     pin: bool, | ||||
|     content: String, | ||||
|     attachment: Option<Vec<u8>>, | ||||
|     attachment_name: Option<String>, | ||||
|  | ||||
|     utc_time: DateTime<Utc>, | ||||
|     timezone: String, | ||||
|     restartable: bool, | ||||
|     expires: Option<DateTime<Utc>>, | ||||
|     interval_seconds: Option<u32>, | ||||
|     interval_days: Option<u32>, | ||||
|     interval_months: Option<u32>, | ||||
|  | ||||
|     avatar: Option<String>, | ||||
|     username: Option<String>, | ||||
| } | ||||
|  | ||||
| impl Reminder { | ||||
|     pub async fn fetch_reminders(pool: impl Executor<'_, Database = Database> + Copy) -> Vec<Self> { | ||||
|         match sqlx::query_as_unchecked!( | ||||
|             Reminder, | ||||
|             r#" | ||||
| SELECT | ||||
|     reminders.`id` AS id, | ||||
|  | ||||
|     channels.`channel` AS channel_id, | ||||
|     channels.`webhook_id` AS webhook_id, | ||||
|     channels.`webhook_token` AS webhook_token, | ||||
|  | ||||
|     channels.`paused` AS 'channel_paused', | ||||
|     channels.`paused_until` AS 'channel_paused_until', | ||||
|     reminders.`enabled` AS 'enabled', | ||||
|  | ||||
|     reminders.`tts` AS tts, | ||||
|     reminders.`pin` AS pin, | ||||
|     reminders.`content` AS content, | ||||
|     reminders.`attachment` AS attachment, | ||||
|     reminders.`attachment_name` AS attachment_name, | ||||
|  | ||||
|     reminders.`utc_time` AS 'utc_time', | ||||
|     reminders.`timezone` AS timezone, | ||||
|     reminders.`restartable` AS restartable, | ||||
|     reminders.`expires` AS 'expires', | ||||
|     reminders.`interval_seconds` AS 'interval_seconds', | ||||
|     reminders.`interval_days` AS 'interval_days', | ||||
|     reminders.`interval_months` AS 'interval_months', | ||||
|  | ||||
|     reminders.`avatar` AS avatar, | ||||
|     reminders.`username` AS username | ||||
| FROM | ||||
|     reminders | ||||
| INNER JOIN | ||||
|     channels | ||||
| ON | ||||
|     reminders.channel_id = channels.id | ||||
| WHERE | ||||
|     reminders.`status` = 'pending' AND | ||||
|     reminders.`id` IN ( | ||||
|         SELECT | ||||
|             MIN(id) | ||||
|         FROM | ||||
|             reminders | ||||
|         WHERE | ||||
|             reminders.`utc_time` <= NOW() AND | ||||
|             `status` = 'pending' AND | ||||
|             ( | ||||
|                 reminders.`interval_seconds` IS NOT NULL | ||||
|                 OR reminders.`interval_months` IS NOT NULL | ||||
|                 OR reminders.`interval_days` IS NOT NULL | ||||
|                 OR reminders.enabled | ||||
|             ) | ||||
|         GROUP BY channel_id | ||||
|     ) | ||||
|     "#, | ||||
|         ) | ||||
|         .fetch_all(pool) | ||||
|         .await | ||||
|         { | ||||
|             Ok(reminders) => reminders | ||||
|                 .into_iter() | ||||
|                 .map(|mut rem| { | ||||
|                     rem.content = substitute(&rem.content); | ||||
|  | ||||
|                     rem | ||||
|                 }) | ||||
|                 .collect::<Vec<Self>>(), | ||||
|  | ||||
|             Err(e) => { | ||||
|                 warn!("Could not fetch reminders: {:?}", e); | ||||
|  | ||||
|                 vec![] | ||||
|             } | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     async fn reset_webhook(&self, pool: impl Executor<'_, Database = Database> + Copy) { | ||||
|         let _ = sqlx::query!( | ||||
|             "UPDATE channels SET webhook_id = NULL, webhook_token = NULL WHERE channel = ?", | ||||
|             self.channel_id | ||||
|         ) | ||||
|         .execute(pool) | ||||
|         .await; | ||||
|     } | ||||
|  | ||||
|     async fn refresh(&self, pool: impl Executor<'_, Database = Database> + Copy) { | ||||
|         if self.interval_seconds.is_some() | ||||
|             || self.interval_months.is_some() | ||||
|             || self.interval_days.is_some() | ||||
|         { | ||||
|             // If all intervals are zero then dont care | ||||
|             if self.interval_seconds == Some(0) | ||||
|                 && self.interval_days == Some(0) | ||||
|                 && self.interval_months == Some(0) | ||||
|             { | ||||
|                 self.set_sent(pool).await; | ||||
|             } | ||||
|  | ||||
|             let now = Utc::now(); | ||||
|             let mut updated_reminder_time = | ||||
|                 self.utc_time.with_timezone(&self.timezone.parse().unwrap_or(Tz::UTC)); | ||||
|             let mut fail_count = 0; | ||||
|  | ||||
|             while updated_reminder_time < now && fail_count < 4 { | ||||
|                 if let Some(interval) = self.interval_months { | ||||
|                     if interval != 0 { | ||||
|                         updated_reminder_time = updated_reminder_time | ||||
|                             .checked_add_months(Months::new(interval)) | ||||
|                             .unwrap_or_else(|| { | ||||
|                                 warn!( | ||||
|                                     "{}: Could not add {} months to a reminder", | ||||
|                                     interval, self.id | ||||
|                                 ); | ||||
|                                 fail_count += 1; | ||||
|  | ||||
|                                 updated_reminder_time | ||||
|                             }); | ||||
|                     } | ||||
|                 } | ||||
|  | ||||
|                 if let Some(interval) = self.interval_days { | ||||
|                     if interval != 0 { | ||||
|                         updated_reminder_time = updated_reminder_time | ||||
|                             .checked_add_days(Days::new(interval as u64)) | ||||
|                             .unwrap_or_else(|| { | ||||
|                                 warn!("{}: Could not add {} days to a reminder", self.id, interval); | ||||
|                                 fail_count += 1; | ||||
|  | ||||
|                                 updated_reminder_time | ||||
|                             }) | ||||
|                     } | ||||
|                 } | ||||
|  | ||||
|                 if let Some(interval) = self.interval_seconds { | ||||
|                     updated_reminder_time += Duration::seconds(interval as i64); | ||||
|                 } | ||||
|             } | ||||
|  | ||||
|             if fail_count >= 4 { | ||||
|                 self.log_error( | ||||
|                     pool, | ||||
|                     "Failed to update 4 times and so is being deleted", | ||||
|                     None::<&'static str>, | ||||
|                 ) | ||||
|                 .await; | ||||
|                 self.set_failed(pool, "Failed to update 4 times and so is being deleted").await; | ||||
|             } else if self.expires.map_or(false, |expires| updated_reminder_time > expires) { | ||||
|                 self.set_sent(pool).await; | ||||
|             } else { | ||||
|                 sqlx::query!( | ||||
|                     "UPDATE reminders SET `utc_time` = ? WHERE `id` = ?", | ||||
|                     updated_reminder_time.with_timezone(&Utc), | ||||
|                     self.id | ||||
|                 ) | ||||
|                 .execute(pool) | ||||
|                 .await | ||||
|                 .expect(&format!("Could not update time on Reminder {}", self.id)); | ||||
|             } | ||||
|         } else { | ||||
|             self.set_sent(pool).await; | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     async fn log_error( | ||||
|         &self, | ||||
|         pool: impl Executor<'_, Database = Database> + Copy, | ||||
|         error: &'static str, | ||||
|         debug_info: Option<impl std::fmt::Debug>, | ||||
|     ) { | ||||
|         let message = match debug_info { | ||||
|             Some(info) => format!( | ||||
|                 "{} | ||||
| {:?}", | ||||
|                 error, info | ||||
|             ), | ||||
|  | ||||
|             None => error.to_string(), | ||||
|         }; | ||||
|  | ||||
|         error!("[Reminder {}] {}", self.id, message); | ||||
|  | ||||
|         if *LOG_TO_DATABASE { | ||||
|             sqlx::query!( | ||||
|                 "INSERT INTO stat (type, reminder_id, message) VALUES ('reminder_failed', ?, ?)", | ||||
|                 self.id, | ||||
|                 message, | ||||
|             ) | ||||
|             .execute(pool) | ||||
|             .await | ||||
|             .expect("Could not log error to database"); | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     async fn log_success(&self, pool: impl Executor<'_, Database = Database> + Copy) { | ||||
|         if *LOG_TO_DATABASE { | ||||
|             sqlx::query!( | ||||
|                 "INSERT INTO stat (type, reminder_id) VALUES ('reminder_sent', ?)", | ||||
|                 self.id, | ||||
|             ) | ||||
|             .execute(pool) | ||||
|             .await | ||||
|             .expect("Could not log success to database"); | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     async fn set_sent(&self, pool: impl Executor<'_, Database = Database> + Copy) { | ||||
|         sqlx::query!("UPDATE reminders SET `status` = 'sent' WHERE `id` = ?", self.id) | ||||
|             .execute(pool) | ||||
|             .await | ||||
|             .expect(&format!("Could not delete Reminder {}", self.id)); | ||||
|     } | ||||
|  | ||||
|     async fn set_failed( | ||||
|         &self, | ||||
|         pool: impl Executor<'_, Database = Database> + Copy, | ||||
|         message: &'static str, | ||||
|     ) { | ||||
|         sqlx::query!( | ||||
|             "UPDATE reminders SET `status` = 'failed', `status_message` = ? WHERE `id` = ?", | ||||
|             message, | ||||
|             self.id | ||||
|         ) | ||||
|         .execute(pool) | ||||
|         .await | ||||
|         .expect(&format!("Could not delete Reminder {}", self.id)); | ||||
|     } | ||||
|  | ||||
|     async fn pin_message<M: Into<u64>>(&self, message_id: M, http: impl AsRef<Http>) { | ||||
|         let _ = http.as_ref().pin_message(self.channel_id, message_id.into(), None).await; | ||||
|     } | ||||
|  | ||||
|     pub async fn send( | ||||
|         &self, | ||||
|         pool: impl Executor<'_, Database = Database> + Copy, | ||||
|         cache_http: impl CacheHttp, | ||||
|     ) { | ||||
|         async fn send_to_channel( | ||||
|             cache_http: impl CacheHttp, | ||||
|             reminder: &Reminder, | ||||
|             embed: Option<CreateEmbed>, | ||||
|         ) -> Result<()> { | ||||
|             let channel = ChannelId(reminder.channel_id).to_channel(&cache_http).await; | ||||
|  | ||||
|             match channel { | ||||
|                 Ok(Channel::Guild(channel)) => { | ||||
|                     match channel | ||||
|                         .send_message(&cache_http, |m| { | ||||
|                             m.content(&reminder.content).tts(reminder.tts); | ||||
|  | ||||
|                             if let (Some(attachment), Some(name)) = | ||||
|                                 (&reminder.attachment, &reminder.attachment_name) | ||||
|                             { | ||||
|                                 m.add_file((attachment as &[u8], name.as_str())); | ||||
|                             } | ||||
|  | ||||
|                             if let Some(embed) = embed { | ||||
|                                 m.set_embed(embed); | ||||
|                             } | ||||
|  | ||||
|                             m | ||||
|                         }) | ||||
|                         .await | ||||
|                     { | ||||
|                         Ok(m) => { | ||||
|                             if reminder.pin { | ||||
|                                 reminder.pin_message(m.id, cache_http.http()).await; | ||||
|                             } | ||||
|  | ||||
|                             Ok(()) | ||||
|                         } | ||||
|                         Err(e) => Err(e), | ||||
|                     } | ||||
|                 } | ||||
|                 Ok(Channel::Private(channel)) => { | ||||
|                     match channel | ||||
|                         .send_message(&cache_http.http(), |m| { | ||||
|                             m.content(&reminder.content).tts(reminder.tts); | ||||
|  | ||||
|                             if let (Some(attachment), Some(name)) = | ||||
|                                 (&reminder.attachment, &reminder.attachment_name) | ||||
|                             { | ||||
|                                 m.add_file((attachment as &[u8], name.as_str())); | ||||
|                             } | ||||
|  | ||||
|                             if let Some(embed) = embed { | ||||
|                                 m.set_embed(embed); | ||||
|                             } | ||||
|  | ||||
|                             m | ||||
|                         }) | ||||
|                         .await | ||||
|                     { | ||||
|                         Ok(m) => { | ||||
|                             if reminder.pin { | ||||
|                                 reminder.pin_message(m.id, cache_http.http()).await; | ||||
|                             } | ||||
|  | ||||
|                             Ok(()) | ||||
|                         } | ||||
|                         Err(e) => Err(e), | ||||
|                     } | ||||
|                 } | ||||
|                 Err(e) => Err(e), | ||||
|                 _ => Err(Error::Other("Channel not of valid type")), | ||||
|             } | ||||
|         } | ||||
|  | ||||
|         async fn send_to_webhook( | ||||
|             cache_http: impl CacheHttp, | ||||
|             reminder: &Reminder, | ||||
|             webhook: Webhook, | ||||
|             embed: Option<CreateEmbed>, | ||||
|         ) -> Result<()> { | ||||
|             match webhook | ||||
|                 .execute(&cache_http.http(), reminder.pin || reminder.restartable, |w| { | ||||
|                     w.content(&reminder.content).tts(reminder.tts); | ||||
|  | ||||
|                     if let Some(username) = &reminder.username { | ||||
|                         if !username.is_empty() { | ||||
|                             w.username(username); | ||||
|                         } | ||||
|                     } | ||||
|  | ||||
|                     if let Some(avatar) = &reminder.avatar { | ||||
|                         w.avatar_url(avatar); | ||||
|                     } | ||||
|  | ||||
|                     if let (Some(attachment), Some(name)) = | ||||
|                         (&reminder.attachment, &reminder.attachment_name) | ||||
|                     { | ||||
|                         w.add_file((attachment as &[u8], name.as_str())); | ||||
|                     } | ||||
|  | ||||
|                     if let Some(embed) = embed { | ||||
|                         w.embeds(vec![SerenityEmbed::fake(|c| { | ||||
|                             *c = embed; | ||||
|                             c | ||||
|                         })]); | ||||
|                     } | ||||
|  | ||||
|                     w | ||||
|                 }) | ||||
|                 .await | ||||
|             { | ||||
|                 Ok(m) => { | ||||
|                     if reminder.pin { | ||||
|                         if let Some(message) = m { | ||||
|                             reminder.pin_message(message.id, cache_http.http()).await; | ||||
|                         } | ||||
|                     } | ||||
|  | ||||
|                     Ok(()) | ||||
|                 } | ||||
|                 Err(e) => Err(e), | ||||
|             } | ||||
|         } | ||||
|  | ||||
|         if self.enabled | ||||
|             && !(self.channel_paused | ||||
|                 && self | ||||
|                     .channel_paused_until | ||||
|                     .map_or(true, |inner| inner >= Utc::now().naive_local())) | ||||
|         { | ||||
|             let _ = sqlx::query!( | ||||
|                 "UPDATE `channels` SET paused = 0, paused_until = NULL WHERE `channel` = ?", | ||||
|                 self.channel_id | ||||
|             ) | ||||
|             .execute(pool) | ||||
|             .await; | ||||
|  | ||||
|             let embed = Embed::from_id(pool, self.id).await.map(|e| e.into()); | ||||
|  | ||||
|             let result = if let (Some(webhook_id), Some(webhook_token)) = | ||||
|                 (self.webhook_id, &self.webhook_token) | ||||
|             { | ||||
|                 let webhook_res = | ||||
|                     cache_http.http().get_webhook_with_token(webhook_id, webhook_token).await; | ||||
|  | ||||
|                 if let Ok(webhook) = webhook_res { | ||||
|                     send_to_webhook(cache_http, &self, webhook, embed).await | ||||
|                 } else { | ||||
|                     warn!("Webhook vanished for reminder {}: {:?}", self.id, webhook_res); | ||||
|  | ||||
|                     self.reset_webhook(pool).await; | ||||
|                     send_to_channel(cache_http, &self, embed).await | ||||
|                 } | ||||
|             } else { | ||||
|                 send_to_channel(cache_http, &self, embed).await | ||||
|             }; | ||||
|  | ||||
|             if let Err(e) = result { | ||||
|                 if let Error::Http(error) = e { | ||||
|                     if let HttpError::UnsuccessfulRequest(http_error) = *error { | ||||
|                         match http_error.error.code { | ||||
|                             10003 => { | ||||
|                                 self.log_error( | ||||
|                                     pool, | ||||
|                                     "Could not be sent as channel does not exist", | ||||
|                                     None::<&'static str>, | ||||
|                                 ) | ||||
|                                 .await; | ||||
|                                 self.set_failed( | ||||
|                                     pool, | ||||
|                                     "Could not be sent as channel does not exist", | ||||
|                                 ) | ||||
|                                 .await; | ||||
|                             } | ||||
|                             10004 => { | ||||
|                                 self.log_error( | ||||
|                                     pool, | ||||
|                                     "Could not be sent as guild does not exist", | ||||
|                                     None::<&'static str>, | ||||
|                                 ) | ||||
|                                 .await; | ||||
|                                 self.set_failed(pool, "Could not be sent as guild does not exist") | ||||
|                                     .await; | ||||
|                             } | ||||
|                             50001 => { | ||||
|                                 self.log_error( | ||||
|                                     pool, | ||||
|                                     "Could not be sent as missing access", | ||||
|                                     None::<&'static str>, | ||||
|                                 ) | ||||
|                                 .await; | ||||
|                                 self.set_failed(pool, "Could not be sent as missing access").await; | ||||
|                             } | ||||
|                             50007 => { | ||||
|                                 self.log_error( | ||||
|                                     pool, | ||||
|                                     "Could not be sent as user has DMs disabled", | ||||
|                                     None::<&'static str>, | ||||
|                                 ) | ||||
|                                 .await; | ||||
|                                 self.set_failed(pool, "Could not be sent as user has DMs disabled") | ||||
|                                     .await; | ||||
|                             } | ||||
|                             50013 => { | ||||
|                                 self.log_error( | ||||
|                                     pool, | ||||
|                                     "Could not be sent as permissions are invalid", | ||||
|                                     None::<&'static str>, | ||||
|                                 ) | ||||
|                                 .await; | ||||
|                                 self.set_failed( | ||||
|                                     pool, | ||||
|                                     "Could not be sent as permissions are invalid", | ||||
|                                 ) | ||||
|                                 .await; | ||||
|                             } | ||||
|                             _ => { | ||||
|                                 self.log_error( | ||||
|                                     pool, | ||||
|                                     "HTTP error sending reminder", | ||||
|                                     Some(http_error), | ||||
|                                 ) | ||||
|                                 .await; | ||||
|                                 self.refresh(pool).await; | ||||
|                             } | ||||
|                         } | ||||
|                     } else { | ||||
|                         self.log_error(pool, "(Likely) a parsing error", Some(error)).await; | ||||
|                         self.refresh(pool).await; | ||||
|                     } | ||||
|                 } else { | ||||
|                     self.log_error(pool, "Non-HTTP error", Some(e)).await; | ||||
|                     self.refresh(pool).await; | ||||
|                 } | ||||
|             } else { | ||||
|                 self.log_success(pool).await; | ||||
|                 self.refresh(pool).await; | ||||
|             } | ||||
|         } else { | ||||
|             info!("Reminder {} is paused", self.id); | ||||
|  | ||||
|             self.refresh(pool).await; | ||||
|         } | ||||
|     } | ||||
| } | ||||
							
								
								
									
										1
									
								
								reminder-dashboard
									
									
									
									
									
										Submodule
									
								
							
							
								
								
								
								
								
							
						
						
									
										1
									
								
								reminder-dashboard
									
									
									
									
									
										Submodule
									
								
							 Submodule reminder-dashboard added at 8ba7a39ce5
									
								
							
							
								
								
									
										117
									
								
								src/commands/autocomplete.rs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										117
									
								
								src/commands/autocomplete.rs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,117 @@ | ||||
| use std::time::{SystemTime, UNIX_EPOCH}; | ||||
|  | ||||
| use chrono_tz::TZ_VARIANTS; | ||||
| use poise::AutocompleteChoice; | ||||
|  | ||||
| use crate::{models::CtxData, time_parser::natural_parser, Context}; | ||||
|  | ||||
| pub async fn timezone_autocomplete(ctx: Context<'_>, partial: &str) -> Vec<String> { | ||||
|     if partial.is_empty() { | ||||
|         ctx.data().popular_timezones.iter().map(|t| t.to_string()).collect::<Vec<String>>() | ||||
|     } else { | ||||
|         TZ_VARIANTS | ||||
|             .iter() | ||||
|             .filter(|tz| tz.to_string().contains(&partial)) | ||||
|             .take(25) | ||||
|             .map(|t| t.to_string()) | ||||
|             .collect::<Vec<String>>() | ||||
|     } | ||||
| } | ||||
|  | ||||
| pub async fn macro_name_autocomplete(ctx: Context<'_>, partial: &str) -> Vec<String> { | ||||
|     sqlx::query!( | ||||
|         " | ||||
| SELECT name | ||||
| FROM macro | ||||
| WHERE | ||||
|     guild_id = (SELECT id FROM guilds WHERE guild = ?) | ||||
|     AND name LIKE CONCAT(?, '%')", | ||||
|         ctx.guild_id().unwrap().0, | ||||
|         partial, | ||||
|     ) | ||||
|     .fetch_all(&ctx.data().database) | ||||
|     .await | ||||
|     .unwrap_or_default() | ||||
|     .iter() | ||||
|     .map(|s| s.name.clone()) | ||||
|     .collect() | ||||
| } | ||||
|  | ||||
| pub async fn time_hint_autocomplete( | ||||
|     ctx: Context<'_>, | ||||
|     partial: &str, | ||||
| ) -> Vec<AutocompleteChoice<String>> { | ||||
|     if partial.is_empty() { | ||||
|         vec![AutocompleteChoice { | ||||
|             name: "Start typing a time...".to_string(), | ||||
|             value: "now".to_string(), | ||||
|         }] | ||||
|     } else { | ||||
|         match natural_parser(partial, &ctx.timezone().await.to_string()).await { | ||||
|             Some(timestamp) => match SystemTime::now().duration_since(UNIX_EPOCH) { | ||||
|                 Ok(now) => { | ||||
|                     let diff = timestamp - now.as_secs() as i64; | ||||
|  | ||||
|                     if diff < 0 { | ||||
|                         vec![AutocompleteChoice { | ||||
|                             name: "Time is in the past".to_string(), | ||||
|                             value: "1 year ago".to_string(), | ||||
|                         }] | ||||
|                     } else { | ||||
|                         if diff > 86400 { | ||||
|                             vec![ | ||||
|                                 AutocompleteChoice { | ||||
|                                     name: partial.to_string(), | ||||
|                                     value: partial.to_string(), | ||||
|                                 }, | ||||
|                                 AutocompleteChoice { | ||||
|                                     name: format!( | ||||
|                                         "In approximately {} days, {} hours", | ||||
|                                         diff / 86400, | ||||
|                                         (diff % 86400) / 3600 | ||||
|                                     ), | ||||
|                                     value: partial.to_string(), | ||||
|                                 }, | ||||
|                             ] | ||||
|                         } else if diff > 3600 { | ||||
|                             vec![ | ||||
|                                 AutocompleteChoice { | ||||
|                                     name: partial.to_string(), | ||||
|                                     value: partial.to_string(), | ||||
|                                 }, | ||||
|                                 AutocompleteChoice { | ||||
|                                     name: format!("In approximately {} hours", diff / 3600), | ||||
|                                     value: partial.to_string(), | ||||
|                                 }, | ||||
|                             ] | ||||
|                         } else { | ||||
|                             vec![ | ||||
|                                 AutocompleteChoice { | ||||
|                                     name: partial.to_string(), | ||||
|                                     value: partial.to_string(), | ||||
|                                 }, | ||||
|                                 AutocompleteChoice { | ||||
|                                     name: format!("In approximately {} minutes", diff / 60), | ||||
|                                     value: partial.to_string(), | ||||
|                                 }, | ||||
|                             ] | ||||
|                         } | ||||
|                     } | ||||
|                 } | ||||
|                 Err(_) => { | ||||
|                     vec![AutocompleteChoice { | ||||
|                         name: partial.to_string(), | ||||
|                         value: partial.to_string(), | ||||
|                     }] | ||||
|                 } | ||||
|             }, | ||||
|  | ||||
|             None => { | ||||
|                 vec![AutocompleteChoice { | ||||
|                     name: "Time not recognised".to_string(), | ||||
|                     value: "now".to_string(), | ||||
|                 }] | ||||
|             } | ||||
|         } | ||||
|     } | ||||
| } | ||||
							
								
								
									
										46
									
								
								src/commands/command_macro/delete.rs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										46
									
								
								src/commands/command_macro/delete.rs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,46 @@ | ||||
| use super::super::autocomplete::macro_name_autocomplete; | ||||
| use crate::{Context, Error}; | ||||
|  | ||||
| /// Delete a recorded macro | ||||
| #[poise::command( | ||||
|     slash_command, | ||||
|     rename = "delete", | ||||
|     guild_only = true, | ||||
|     default_member_permissions = "MANAGE_GUILD", | ||||
|     identifying_name = "delete_macro" | ||||
| )] | ||||
| pub async fn delete_macro( | ||||
|     ctx: Context<'_>, | ||||
|     #[description = "Name of macro to delete"] | ||||
|     #[autocomplete = "macro_name_autocomplete"] | ||||
|     name: String, | ||||
| ) -> Result<(), Error> { | ||||
|     match sqlx::query!( | ||||
|         " | ||||
| SELECT id FROM macro WHERE guild_id = (SELECT id FROM guilds WHERE guild = ?) AND name = ?", | ||||
|         ctx.guild_id().unwrap().0, | ||||
|         name | ||||
|     ) | ||||
|     .fetch_one(&ctx.data().database) | ||||
|     .await | ||||
|     { | ||||
|         Ok(row) => { | ||||
|             sqlx::query!("DELETE FROM macro WHERE id = ?", row.id) | ||||
|                 .execute(&ctx.data().database) | ||||
|                 .await | ||||
|                 .unwrap(); | ||||
|  | ||||
|             ctx.say(format!("Macro \"{}\" deleted", name)).await?; | ||||
|         } | ||||
|  | ||||
|         Err(sqlx::Error::RowNotFound) => { | ||||
|             ctx.say(format!("Macro \"{}\" not found", name)).await?; | ||||
|         } | ||||
|  | ||||
|         Err(e) => { | ||||
|             panic!("{}", e); | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     Ok(()) | ||||
| } | ||||
							
								
								
									
										89
									
								
								src/commands/command_macro/list.rs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										89
									
								
								src/commands/command_macro/list.rs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,89 @@ | ||||
| use poise::CreateReply; | ||||
|  | ||||
| use crate::{ | ||||
|     component_models::pager::{MacroPager, Pager}, | ||||
|     consts::THEME_COLOR, | ||||
|     models::{command_macro::CommandMacro, CtxData}, | ||||
|     Context, Error, | ||||
| }; | ||||
|  | ||||
| /// List recorded macros | ||||
| #[poise::command( | ||||
|     slash_command, | ||||
|     rename = "list", | ||||
|     guild_only = true, | ||||
|     default_member_permissions = "MANAGE_GUILD", | ||||
|     identifying_name = "list_macro" | ||||
| )] | ||||
| pub async fn list_macro(ctx: Context<'_>) -> Result<(), Error> { | ||||
|     let macros = ctx.command_macros().await?; | ||||
|  | ||||
|     let resp = show_macro_page(¯os, 0); | ||||
|  | ||||
|     ctx.send(|m| { | ||||
|         *m = resp; | ||||
|         m | ||||
|     }) | ||||
|     .await?; | ||||
|  | ||||
|     Ok(()) | ||||
| } | ||||
|  | ||||
| pub fn max_macro_page<U, E>(macros: &[CommandMacro<U, E>]) -> usize { | ||||
|     ((macros.len() as f64) / 25.0).ceil() as usize | ||||
| } | ||||
|  | ||||
| pub fn show_macro_page<U, E>(macros: &[CommandMacro<U, E>], page: usize) -> CreateReply { | ||||
|     let pager = MacroPager::new(page); | ||||
|  | ||||
|     if macros.is_empty() { | ||||
|         let mut reply = CreateReply::default(); | ||||
|  | ||||
|         reply.embed(|e| { | ||||
|             e.title("Macros") | ||||
|                 .description("No Macros Set Up. Use `/macro record` to get started.") | ||||
|                 .color(*THEME_COLOR) | ||||
|         }); | ||||
|  | ||||
|         return reply; | ||||
|     } | ||||
|  | ||||
|     let pages = max_macro_page(macros); | ||||
|  | ||||
|     let mut page = page; | ||||
|     if page >= pages { | ||||
|         page = pages - 1; | ||||
|     } | ||||
|  | ||||
|     let lower = (page * 25).min(macros.len()); | ||||
|     let upper = ((page + 1) * 25).min(macros.len()); | ||||
|  | ||||
|     let fields = macros[lower..upper].iter().map(|m| { | ||||
|         if let Some(description) = &m.description { | ||||
|             ( | ||||
|                 m.name.clone(), | ||||
|                 format!("*{}*\n- Has {} commands", description, m.commands.len()), | ||||
|                 true, | ||||
|             ) | ||||
|         } else { | ||||
|             (m.name.clone(), format!("- Has {} commands", m.commands.len()), true) | ||||
|         } | ||||
|     }); | ||||
|  | ||||
|     let mut reply = CreateReply::default(); | ||||
|  | ||||
|     reply | ||||
|         .embed(|e| { | ||||
|             e.title("Macros") | ||||
|                 .fields(fields) | ||||
|                 .footer(|f| f.text(format!("Page {} of {}", page + 1, pages))) | ||||
|                 .color(*THEME_COLOR) | ||||
|         }) | ||||
|         .components(|comp| { | ||||
|             pager.create_button_row(pages, comp); | ||||
|  | ||||
|             comp | ||||
|         }); | ||||
|  | ||||
|     reply | ||||
| } | ||||
							
								
								
									
										229
									
								
								src/commands/command_macro/migrate.rs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										229
									
								
								src/commands/command_macro/migrate.rs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,229 @@ | ||||
| use lazy_regex::regex; | ||||
| use poise::serenity_prelude::command::CommandOptionType; | ||||
| use regex::Captures; | ||||
| use serde_json::{json, Value}; | ||||
|  | ||||
| use crate::{models::command_macro::RawCommandMacro, Context, Error, GuildId}; | ||||
|  | ||||
| struct Alias { | ||||
|     name: String, | ||||
|     command: String, | ||||
| } | ||||
|  | ||||
| /// Migrate old $alias reminder commands to macros. Only macro names that are not taken will be used. | ||||
| #[poise::command( | ||||
|     slash_command, | ||||
|     rename = "migrate", | ||||
|     guild_only = true, | ||||
|     default_member_permissions = "MANAGE_GUILD", | ||||
|     identifying_name = "migrate_macro" | ||||
| )] | ||||
| pub async fn migrate_macro(ctx: Context<'_>) -> Result<(), Error> { | ||||
|     let guild_id = ctx.guild_id().unwrap(); | ||||
|     let mut transaction = ctx.data().database.begin().await?; | ||||
|  | ||||
|     let aliases = sqlx::query_as!( | ||||
|         Alias, | ||||
|         "SELECT name, command FROM command_aliases WHERE guild_id = (SELECT id FROM guilds WHERE guild = ?)", | ||||
|         guild_id.0 | ||||
|     ) | ||||
|     .fetch_all(&mut *transaction) | ||||
|     .await?; | ||||
|  | ||||
|     let mut added_aliases = 0; | ||||
|  | ||||
|     for alias in aliases { | ||||
|         match parse_text_command(guild_id, alias.name, &alias.command) { | ||||
|             Some(cmd_macro) => { | ||||
|                 sqlx::query!( | ||||
|                     "INSERT INTO macro (guild_id, name, description, commands) VALUES ((SELECT id FROM guilds WHERE guild = ?), ?, ?, ?)", | ||||
|                     cmd_macro.guild_id.0, | ||||
|                     cmd_macro.name, | ||||
|                     cmd_macro.description, | ||||
|                     cmd_macro.commands | ||||
|                 ) | ||||
|                 .execute(&mut *transaction) | ||||
|                 .await?; | ||||
|  | ||||
|                 added_aliases += 1; | ||||
|             } | ||||
|  | ||||
|             None => {} | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     transaction.commit().await?; | ||||
|  | ||||
|     ctx.send(|b| b.content(format!("Added {} macros.", added_aliases))).await?; | ||||
|  | ||||
|     Ok(()) | ||||
| } | ||||
|  | ||||
| fn parse_text_command( | ||||
|     guild_id: GuildId, | ||||
|     alias_name: String, | ||||
|     command: &str, | ||||
| ) -> Option<RawCommandMacro> { | ||||
|     match command.split_once(" ") { | ||||
|         Some((command_word, args)) => { | ||||
|             let command_word = command_word.to_lowercase(); | ||||
|  | ||||
|             if command_word == "r" | ||||
|                 || command_word == "i" | ||||
|                 || command_word == "remind" | ||||
|                 || command_word == "interval" | ||||
|             { | ||||
|                 let matcher = regex!( | ||||
|                     r#"(?P<mentions>(?:<@\d+>\s+|<@!\d+>\s+|<#\d+>\s+)*)(?P<time>(?:(?:\d+)(?:s|m|h|d|:|/|-|))+)(?:\s+(?P<interval>(?:(?:\d+)(?:s|m|h|d|))+))?(?:\s+(?P<expires>(?:(?:\d+)(?:s|m|h|d|:|/|-|))+))?\s+(?P<content>.*)"#s | ||||
|                 ); | ||||
|  | ||||
|                 match matcher.captures(&args) { | ||||
|                     Some(captures) => { | ||||
|                         let mut args: Vec<Value> = vec![]; | ||||
|  | ||||
|                         if let Some(group) = captures.name("time") { | ||||
|                             let content = group.as_str(); | ||||
|                             args.push(json!({ | ||||
|                                 "name": "time", | ||||
|                                 "value": content, | ||||
|                                 "type": CommandOptionType::String, | ||||
|                             })); | ||||
|                         } | ||||
|  | ||||
|                         if let Some(group) = captures.name("content") { | ||||
|                             let content = group.as_str(); | ||||
|                             args.push(json!({ | ||||
|                                 "name": "content", | ||||
|                                 "value": content, | ||||
|                                 "type": CommandOptionType::String, | ||||
|                             })); | ||||
|                         } | ||||
|  | ||||
|                         if let Some(group) = captures.name("interval") { | ||||
|                             let content = group.as_str(); | ||||
|                             args.push(json!({ | ||||
|                                 "name": "interval", | ||||
|                                 "value": content, | ||||
|                                 "type": CommandOptionType::String, | ||||
|                             })); | ||||
|                         } | ||||
|  | ||||
|                         if let Some(group) = captures.name("expires") { | ||||
|                             let content = group.as_str(); | ||||
|                             args.push(json!({ | ||||
|                                 "name": "expires", | ||||
|                                 "value": content, | ||||
|                                 "type": CommandOptionType::String, | ||||
|                             })); | ||||
|                         } | ||||
|  | ||||
|                         if let Some(group) = captures.name("mentions") { | ||||
|                             let content = group.as_str(); | ||||
|                             args.push(json!({ | ||||
|                                 "name": "channels", | ||||
|                                 "value": content, | ||||
|                                 "type": CommandOptionType::String, | ||||
|                             })); | ||||
|                         } | ||||
|  | ||||
|                         Some(RawCommandMacro { | ||||
|                             guild_id, | ||||
|                             name: alias_name, | ||||
|                             description: None, | ||||
|                             commands: json!([ | ||||
|                                 { | ||||
|                                     "command_name": "remind", | ||||
|                                     "options": args, | ||||
|                                 } | ||||
|                             ]), | ||||
|                         }) | ||||
|                     } | ||||
|  | ||||
|                     None => None, | ||||
|                 } | ||||
|             } else if command_word == "n" || command_word == "natural" { | ||||
|                 let matcher_primary = regex!( | ||||
|                     r#"(?P<time>.*?)(?:\s+)(?:send|say)(?:\s+)(?P<content>.*?)(?:(?:\s+)to(?:\s+)(?P<mentions>((?:<@\d+>)|(?:<@!\d+>)|(?:<#\d+>)|(?:\s+))+))?$"#s | ||||
|                 ); | ||||
|                 let matcher_secondary = regex!( | ||||
|                     r#"(?P<msg>.*)(?:\s+)every(?:\s+)(?P<interval>.*?)(?:(?:\s+)(?:until|for)(?:\s+)(?P<expires>.*?))?$"#s | ||||
|                 ); | ||||
|  | ||||
|                 match matcher_primary.captures(&args) { | ||||
|                     Some(captures) => { | ||||
|                         let captures_secondary = matcher_secondary.captures(&args); | ||||
|  | ||||
|                         let mut args: Vec<Value> = vec![]; | ||||
|  | ||||
|                         if let Some(group) = captures.name("time") { | ||||
|                             let content = group.as_str(); | ||||
|                             args.push(json!({ | ||||
|                                 "name": "time", | ||||
|                                 "value": content, | ||||
|                                 "type": CommandOptionType::String, | ||||
|                             })); | ||||
|                         } | ||||
|  | ||||
|                         if let Some(group) = captures.name("content") { | ||||
|                             let content = group.as_str(); | ||||
|                             args.push(json!({ | ||||
|                                 "name": "content", | ||||
|                                 "value": content, | ||||
|                                 "type": CommandOptionType::String, | ||||
|                             })); | ||||
|                         } | ||||
|  | ||||
|                         if let Some(group) = | ||||
|                             captures_secondary.as_ref().and_then(|c: &Captures| c.name("interval")) | ||||
|                         { | ||||
|                             let content = group.as_str(); | ||||
|                             args.push(json!({ | ||||
|                                 "name": "interval", | ||||
|                                 "value": content, | ||||
|                                 "type": CommandOptionType::String, | ||||
|                             })); | ||||
|                         } | ||||
|  | ||||
|                         if let Some(group) = | ||||
|                             captures_secondary.and_then(|c: Captures| c.name("expires")) | ||||
|                         { | ||||
|                             let content = group.as_str(); | ||||
|                             args.push(json!({ | ||||
|                                 "name": "expires", | ||||
|                                 "value": content, | ||||
|                                 "type": CommandOptionType::String, | ||||
|                             })); | ||||
|                         } | ||||
|  | ||||
|                         if let Some(group) = captures.name("mentions") { | ||||
|                             let content = group.as_str(); | ||||
|                             args.push(json!({ | ||||
|                                 "name": "channels", | ||||
|                                 "value": content, | ||||
|                                 "type": CommandOptionType::String, | ||||
|                             })); | ||||
|                         } | ||||
|  | ||||
|                         Some(RawCommandMacro { | ||||
|                             guild_id, | ||||
|                             name: alias_name, | ||||
|                             description: None, | ||||
|                             commands: json!([ | ||||
|                                 { | ||||
|                                     "command_name": "remind", | ||||
|                                     "options": args, | ||||
|                                 } | ||||
|                             ]), | ||||
|                         }) | ||||
|                     } | ||||
|  | ||||
|                     None => None, | ||||
|                 } | ||||
|             } else { | ||||
|                 None | ||||
|             } | ||||
|         } | ||||
|  | ||||
|         None => None, | ||||
|     } | ||||
| } | ||||
							
								
								
									
										19
									
								
								src/commands/command_macro/mod.rs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										19
									
								
								src/commands/command_macro/mod.rs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,19 @@ | ||||
| use crate::{Context, Error}; | ||||
|  | ||||
| pub mod delete; | ||||
| pub mod list; | ||||
| pub mod migrate; | ||||
| pub mod record; | ||||
| pub mod run; | ||||
|  | ||||
| /// Record and replay command sequences | ||||
| #[poise::command( | ||||
|     slash_command, | ||||
|     rename = "macro", | ||||
|     guild_only = true, | ||||
|     default_member_permissions = "MANAGE_GUILD", | ||||
|     identifying_name = "macro_base" | ||||
| )] | ||||
| pub async fn macro_base(_ctx: Context<'_>) -> Result<(), Error> { | ||||
|     Ok(()) | ||||
| } | ||||
							
								
								
									
										151
									
								
								src/commands/command_macro/record.rs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										151
									
								
								src/commands/command_macro/record.rs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,151 @@ | ||||
| use std::collections::hash_map::Entry; | ||||
|  | ||||
| use crate::{consts::THEME_COLOR, models::command_macro::CommandMacro, Context, Error}; | ||||
|  | ||||
| /// Start recording up to 5 commands to replay | ||||
| #[poise::command( | ||||
|     slash_command, | ||||
|     rename = "record", | ||||
|     guild_only = true, | ||||
|     default_member_permissions = "MANAGE_GUILD", | ||||
|     identifying_name = "record_macro" | ||||
| )] | ||||
| pub async fn record_macro( | ||||
|     ctx: Context<'_>, | ||||
|     #[description = "Name for the new macro"] name: String, | ||||
|     #[description = "Description for the new macro"] description: Option<String>, | ||||
| ) -> Result<(), Error> { | ||||
|     if name.len() > 100 { | ||||
|         ctx.say("Name must be less than 100 characters").await?; | ||||
|  | ||||
|         return Ok(()); | ||||
|     } | ||||
|  | ||||
|     if description.as_ref().map_or(0, |d| d.len()) > 100 { | ||||
|         ctx.say("Description must be less than 100 characters").await?; | ||||
|  | ||||
|         return Ok(()); | ||||
|     } | ||||
|  | ||||
|     let guild_id = ctx.guild_id().unwrap(); | ||||
|  | ||||
|     let row = sqlx::query!( | ||||
|         " | ||||
| SELECT 1 as _e FROM macro WHERE guild_id = (SELECT id FROM guilds WHERE guild = ?) AND name = ?", | ||||
|         guild_id.0, | ||||
|         name | ||||
|     ) | ||||
|     .fetch_one(&ctx.data().database) | ||||
|     .await; | ||||
|  | ||||
|     if row.is_ok() { | ||||
|         ctx.send(|m| { | ||||
|             m.ephemeral(true).embed(|e| { | ||||
|                 e.title("Unique Name Required") | ||||
|                     .description( | ||||
|                         "A macro already exists under this name. | ||||
| Please select a unique name for your macro.", | ||||
|                     ) | ||||
|                     .color(*THEME_COLOR) | ||||
|             }) | ||||
|         }) | ||||
|         .await?; | ||||
|     } else { | ||||
|         let okay = { | ||||
|             let mut lock = ctx.data().recording_macros.write().await; | ||||
|  | ||||
|             if let Entry::Vacant(e) = lock.entry((guild_id, ctx.author().id)) { | ||||
|                 e.insert(CommandMacro { guild_id, name, description, commands: vec![] }); | ||||
|                 true | ||||
|             } else { | ||||
|                 false | ||||
|             } | ||||
|         }; | ||||
|  | ||||
|         if okay { | ||||
|             ctx.send(|m| { | ||||
|                 m.ephemeral(true).embed(|e| { | ||||
|                     e.title("Macro Recording Started") | ||||
|                         .description( | ||||
|                             "Run up to 5 commands, or type `/macro finish` to stop at any point. | ||||
| Any commands ran as part of recording will be inconsequential", | ||||
|                         ) | ||||
|                         .color(*THEME_COLOR) | ||||
|                 }) | ||||
|             }) | ||||
|             .await?; | ||||
|         } else { | ||||
|             ctx.send(|m| { | ||||
|                 m.ephemeral(true).embed(|e| { | ||||
|                     e.title("Macro Already Recording") | ||||
|                         .description( | ||||
|                             "You are already recording a macro in this server. | ||||
| Please use `/macro finish` to end this recording before starting another.", | ||||
|                         ) | ||||
|                         .color(*THEME_COLOR) | ||||
|                 }) | ||||
|             }) | ||||
|             .await?; | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     Ok(()) | ||||
| } | ||||
|  | ||||
| /// Finish current macro recording | ||||
| #[poise::command( | ||||
|     slash_command, | ||||
|     rename = "finish", | ||||
|     guild_only = true, | ||||
|     default_member_permissions = "MANAGE_GUILD", | ||||
|     identifying_name = "finish_macro" | ||||
| )] | ||||
| pub async fn finish_macro(ctx: Context<'_>) -> Result<(), Error> { | ||||
|     let key = (ctx.guild_id().unwrap(), ctx.author().id); | ||||
|  | ||||
|     { | ||||
|         let lock = ctx.data().recording_macros.read().await; | ||||
|         let contained = lock.get(&key); | ||||
|  | ||||
|         if contained.map_or(true, |cmacro| cmacro.commands.is_empty()) { | ||||
|             ctx.send(|m| { | ||||
|                 m.embed(|e| { | ||||
|                     e.title("No Macro Recorded") | ||||
|                         .description("Use `/macro record` to start recording a macro") | ||||
|                         .color(*THEME_COLOR) | ||||
|                 }) | ||||
|             }) | ||||
|             .await?; | ||||
|         } else { | ||||
|             let command_macro = contained.unwrap(); | ||||
|             let json = serde_json::to_string(&command_macro.commands).unwrap(); | ||||
|  | ||||
|             sqlx::query!( | ||||
|                 "INSERT INTO macro (guild_id, name, description, commands) VALUES ((SELECT id FROM guilds WHERE guild = ?), ?, ?, ?)", | ||||
|                 command_macro.guild_id.0, | ||||
|                 command_macro.name, | ||||
|                 command_macro.description, | ||||
|                 json | ||||
|             ) | ||||
|                 .execute(&ctx.data().database) | ||||
|                 .await | ||||
|                 .unwrap(); | ||||
|  | ||||
|             ctx.send(|m| { | ||||
|                 m.embed(|e| { | ||||
|                     e.title("Macro Recorded") | ||||
|                         .description("Use `/macro run` to execute the macro") | ||||
|                         .color(*THEME_COLOR) | ||||
|                 }) | ||||
|             }) | ||||
|             .await?; | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     { | ||||
|         let mut lock = ctx.data().recording_macros.write().await; | ||||
|         lock.remove(&key); | ||||
|     } | ||||
|  | ||||
|     Ok(()) | ||||
| } | ||||
							
								
								
									
										56
									
								
								src/commands/command_macro/run.rs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										56
									
								
								src/commands/command_macro/run.rs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,56 @@ | ||||
| use super::super::autocomplete::macro_name_autocomplete; | ||||
| use crate::{models::command_macro::guild_command_macro, Context, Data, Error, THEME_COLOR}; | ||||
|  | ||||
| /// Run a recorded macro | ||||
| #[poise::command( | ||||
|     slash_command, | ||||
|     rename = "run", | ||||
|     guild_only = true, | ||||
|     default_member_permissions = "MANAGE_GUILD", | ||||
|     identifying_name = "run_macro" | ||||
| )] | ||||
| pub async fn run_macro( | ||||
|     ctx: poise::ApplicationContext<'_, Data, Error>, | ||||
|     #[description = "Name of macro to run"] | ||||
|     #[autocomplete = "macro_name_autocomplete"] | ||||
|     name: String, | ||||
| ) -> Result<(), Error> { | ||||
|     match guild_command_macro(&Context::Application(ctx), &name).await { | ||||
|         Some(command_macro) => { | ||||
|             Context::Application(ctx) | ||||
|                 .send(|b| { | ||||
|                     b.embed(|e| { | ||||
|                         e.title("Running Macro").color(*THEME_COLOR).description(format!( | ||||
|                             "Running macro {} ({} commands)", | ||||
|                             command_macro.name, | ||||
|                             command_macro.commands.len() | ||||
|                         )) | ||||
|                     }) | ||||
|                 }) | ||||
|                 .await?; | ||||
|  | ||||
|             for command in command_macro.commands { | ||||
|                 if let Some(action) = command.action { | ||||
|                     match (action)(poise::ApplicationContext { args: &command.options, ..ctx }) | ||||
|                         .await | ||||
|                     { | ||||
|                         Ok(()) => {} | ||||
|                         Err(e) => { | ||||
|                             println!("{:?}", e); | ||||
|                         } | ||||
|                     } | ||||
|                 } else { | ||||
|                     Context::Application(ctx) | ||||
|                         .say(format!("Command \"{}\" not found", command.command_name)) | ||||
|                         .await?; | ||||
|                 } | ||||
|             } | ||||
|         } | ||||
|  | ||||
|         None => { | ||||
|             Context::Application(ctx).say(format!("Macro \"{}\" not found", name)).await?; | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     Ok(()) | ||||
| } | ||||
| @@ -1,16 +1,13 @@ | ||||
| use chrono::offset::Utc; | ||||
| use regex_command_attr::command; | ||||
| use serenity::{builder::CreateEmbedFooter, client::Context}; | ||||
| use poise::{serenity_prelude as serenity, serenity_prelude::Mentionable}; | ||||
|  | ||||
| use crate::{ | ||||
|     framework::{CommandInvoke, CreateGenericResponse}, | ||||
|     models::CtxData, | ||||
|     THEME_COLOR, | ||||
| }; | ||||
| use crate::{models::CtxData, Context, Error, THEME_COLOR}; | ||||
|  | ||||
| fn footer(ctx: &Context) -> impl FnOnce(&mut CreateEmbedFooter) -> &mut CreateEmbedFooter { | ||||
|     let shard_count = ctx.cache.shard_count(); | ||||
|     let shard = ctx.shard_id; | ||||
| fn footer( | ||||
|     ctx: Context<'_>, | ||||
| ) -> impl FnOnce(&mut serenity::CreateEmbedFooter) -> &mut serenity::CreateEmbedFooter { | ||||
|     let shard_count = ctx.serenity_context().cache.shard_count(); | ||||
|     let shard = ctx.serenity_context().shard_id; | ||||
|  | ||||
|     move |f| { | ||||
|         f.text(format!( | ||||
| @@ -22,15 +19,13 @@ fn footer(ctx: &Context) -> impl FnOnce(&mut CreateEmbedFooter) -> &mut CreateEm | ||||
|     } | ||||
| } | ||||
|  | ||||
| #[command] | ||||
| #[description("Get an overview of the bot commands")] | ||||
| async fn help(ctx: &Context, invoke: &mut CommandInvoke) { | ||||
| /// Get an overview of bot commands | ||||
| #[poise::command(slash_command)] | ||||
| pub async fn help(ctx: Context<'_>) -> Result<(), Error> { | ||||
|     let footer = footer(ctx); | ||||
|  | ||||
|     let _ = invoke | ||||
|         .respond( | ||||
|             &ctx, | ||||
|             CreateGenericResponse::new().embed(|e| { | ||||
|     ctx.send(|m| { | ||||
|         m.ephemeral(true).embed(|e| { | ||||
|             e.title("Help") | ||||
|                 .color(*THEME_COLOR) | ||||
|                 .description( | ||||
| @@ -54,29 +49,30 @@ __Todo Commands__ | ||||
|  | ||||
| __Setup Commands__ | ||||
| `/timezone` - Set your timezone (necessary for `/remind` to work properly) | ||||
| `/dm allow/block` - Change your DM settings for reminders. | ||||
|  | ||||
| __Advanced Commands__ | ||||
| `/macro` - Record and replay command sequences | ||||
|                     ", | ||||
|                 ) | ||||
|                 .footer(footer) | ||||
|             }), | ||||
|         ) | ||||
|         .await; | ||||
|         }) | ||||
|     }) | ||||
|     .await?; | ||||
|  | ||||
|     Ok(()) | ||||
| } | ||||
|  | ||||
| #[command] | ||||
| #[aliases("invite")] | ||||
| #[description("Get information about the bot")] | ||||
| async fn info(ctx: &Context, invoke: &mut CommandInvoke) { | ||||
| /// Get information about the bot | ||||
| #[poise::command(slash_command)] | ||||
| pub async fn info(ctx: Context<'_>) -> Result<(), Error> { | ||||
|     let footer = footer(ctx); | ||||
|  | ||||
|     let _ = invoke | ||||
|         .respond( | ||||
|             ctx.http.clone(), | ||||
|             CreateGenericResponse::new().embed(|e| { | ||||
|     let _ = ctx | ||||
|         .send(|m| { | ||||
|             m.ephemeral(true).embed(|e| { | ||||
|                 e.title("Info") | ||||
|                     .description(format!( | ||||
|                     .description( | ||||
|                         "Help: `/help` | ||||
|  | ||||
| **Welcome to Reminder Bot!** | ||||
| @@ -86,26 +82,25 @@ Find me on https://discord.jellywx.com and on https://github.com/JellyWX :) | ||||
|  | ||||
| Invite the bot: https://invite.reminder-bot.com/ | ||||
| Use our dashboard: https://reminder-bot.com/", | ||||
|                     )) | ||||
|                     ) | ||||
|                     .footer(footer) | ||||
|                     .color(*THEME_COLOR) | ||||
|             }), | ||||
|         ) | ||||
|             }) | ||||
|         }) | ||||
|         .await; | ||||
|  | ||||
|     Ok(()) | ||||
| } | ||||
|  | ||||
| #[command] | ||||
| #[description("Details on supporting the bot and Patreon benefits")] | ||||
| #[group("Info")] | ||||
| async fn donate(ctx: &Context, invoke: &mut CommandInvoke) { | ||||
| /// Details on supporting the bot and Patreon benefits | ||||
| #[poise::command(slash_command)] | ||||
| pub async fn donate(ctx: Context<'_>) -> Result<(), Error> { | ||||
|     let footer = footer(ctx); | ||||
|  | ||||
|     let _ = invoke | ||||
|         .respond( | ||||
|             ctx.http.clone(), | ||||
|             CreateGenericResponse::new().embed(|e| { | ||||
|     ctx.send(|m| m.embed(|e| { | ||||
|         e.title("Donate") | ||||
|                     .description("Thinking of adding a monthly contribution? Click below for my Patreon and official bot server :) | ||||
|             .description("Thinking of adding a monthly contribution? | ||||
| Click below for my Patreon and official bot server :) | ||||
|  | ||||
| **https://www.patreon.com/jellywx/** | ||||
| **https://discord.jellywx.com/** | ||||
| @@ -124,39 +119,63 @@ Just $2 USD/month! | ||||
|                 .color(*THEME_COLOR) | ||||
|         }), | ||||
|     ) | ||||
|         .await; | ||||
|     .await?; | ||||
|  | ||||
|     Ok(()) | ||||
| } | ||||
|  | ||||
| #[command] | ||||
| #[description("Get the link to the online dashboard")] | ||||
| #[group("Info")] | ||||
| async fn dashboard(ctx: &Context, invoke: &mut CommandInvoke) { | ||||
| /// Get the link to the online dashboard | ||||
| #[poise::command(slash_command)] | ||||
| pub async fn dashboard(ctx: Context<'_>) -> Result<(), Error> { | ||||
|     let footer = footer(ctx); | ||||
|  | ||||
|     let _ = invoke | ||||
|         .respond( | ||||
|             ctx.http.clone(), | ||||
|             CreateGenericResponse::new().embed(|e| { | ||||
|     ctx.send(|m| { | ||||
|         m.ephemeral(true).embed(|e| { | ||||
|             e.title("Dashboard") | ||||
|                 .description("**https://reminder-bot.com/dashboard**") | ||||
|                 .footer(footer) | ||||
|                 .color(*THEME_COLOR) | ||||
|             }), | ||||
|         ) | ||||
|         .await; | ||||
|         }) | ||||
|     }) | ||||
|     .await?; | ||||
|  | ||||
|     Ok(()) | ||||
| } | ||||
|  | ||||
| #[command] | ||||
| #[description("View the current time in your selected timezone")] | ||||
| #[group("Info")] | ||||
| async fn clock(ctx: &Context, invoke: &mut CommandInvoke) { | ||||
|     let ud = ctx.user_data(&invoke.author_id()).await.unwrap(); | ||||
|     let now = Utc::now().with_timezone(&ud.timezone()); | ||||
| /// View the current time in your selected timezone | ||||
| #[poise::command(slash_command)] | ||||
| pub async fn clock(ctx: Context<'_>) -> Result<(), Error> { | ||||
|     ctx.defer_ephemeral().await?; | ||||
|  | ||||
|     let _ = invoke | ||||
|         .respond( | ||||
|             ctx.http.clone(), | ||||
|             CreateGenericResponse::new().content(format!("Current time: {}", now.format("%H:%M"))), | ||||
|         ) | ||||
|         .await; | ||||
|     let tz = ctx.timezone().await; | ||||
|     let now = Utc::now().with_timezone(&tz); | ||||
|  | ||||
|     ctx.send(|m| { | ||||
|         m.ephemeral(true).content(format!("Time in **{}**: `{}`", tz, now.format("%H:%M"))) | ||||
|     }) | ||||
|     .await?; | ||||
|  | ||||
|     Ok(()) | ||||
| } | ||||
|  | ||||
| /// View the current time in a user's selected timezone | ||||
| #[poise::command(context_menu_command = "View Local Time")] | ||||
| pub async fn clock_context_menu(ctx: Context<'_>, user: serenity::User) -> Result<(), Error> { | ||||
|     ctx.defer_ephemeral().await?; | ||||
|  | ||||
|     let user_data = ctx.user_data(user.id).await?; | ||||
|     let tz = user_data.timezone(); | ||||
|  | ||||
|     let now = Utc::now().with_timezone(&tz); | ||||
|  | ||||
|     ctx.send(|m| { | ||||
|         m.ephemeral(true).content(format!( | ||||
|             "Time in {}'s timezone: `{}`", | ||||
|             user.mention(), | ||||
|             now.format("%H:%M") | ||||
|         )) | ||||
|     }) | ||||
|     .await?; | ||||
|  | ||||
|     Ok(()) | ||||
| } | ||||
|   | ||||
| @@ -1,3 +1,5 @@ | ||||
| mod autocomplete; | ||||
| pub mod command_macro; | ||||
| pub mod info_cmds; | ||||
| pub mod moderation_cmds; | ||||
| pub mod reminder_cmds; | ||||
|   | ||||
| @@ -1,54 +1,43 @@ | ||||
| use chrono::offset::Utc; | ||||
| use chrono_tz::{Tz, TZ_VARIANTS}; | ||||
| use levenshtein::levenshtein; | ||||
| use regex_command_attr::command; | ||||
| use serenity::client::Context; | ||||
| use log::warn; | ||||
|  | ||||
| use crate::{ | ||||
|     component_models::pager::{MacroPager, Pager}, | ||||
|     consts::{EMBED_DESCRIPTION_MAX_LENGTH, THEME_COLOR}, | ||||
|     framework::{CommandInvoke, CommandOptions, CreateGenericResponse, OptionValue}, | ||||
|     hooks::{CHECK_GUILD_PERMISSIONS_HOOK, GUILD_ONLY_HOOK}, | ||||
|     models::{command_macro::CommandMacro, CtxData}, | ||||
|     PopularTimezones, RecordingMacros, RegexFramework, SQLPool, | ||||
| }; | ||||
| use super::autocomplete::timezone_autocomplete; | ||||
| use crate::{consts::THEME_COLOR, models::CtxData, Context, Error}; | ||||
|  | ||||
| #[command("timezone")] | ||||
| #[description("Select your timezone")] | ||||
| #[arg( | ||||
|     name = "timezone", | ||||
|     description = "Timezone to use from this list: https://gist.github.com/JellyWX/913dfc8b63d45192ad6cb54c829324ee", | ||||
|     kind = "String", | ||||
|     required = false | ||||
| )] | ||||
| async fn timezone(ctx: &Context, invoke: &mut CommandInvoke, args: CommandOptions) { | ||||
|     let pool = ctx.data.read().await.get::<SQLPool>().cloned().unwrap(); | ||||
|     let mut user_data = ctx.user_data(invoke.author_id()).await.unwrap(); | ||||
| /// Select your timezone | ||||
| #[poise::command(slash_command, identifying_name = "timezone")] | ||||
| pub async fn timezone( | ||||
|     ctx: Context<'_>, | ||||
|     #[description = "Timezone to use from this list: https://gist.github.com/JellyWX/913dfc8b63d45192ad6cb54c829324ee"] | ||||
|     #[autocomplete = "timezone_autocomplete"] | ||||
|     timezone: Option<String>, | ||||
| ) -> Result<(), Error> { | ||||
|     let mut user_data = ctx.author_data().await.unwrap(); | ||||
|  | ||||
|     let footer_text = format!("Current timezone: {}", user_data.timezone); | ||||
|  | ||||
|     if let Some(OptionValue::String(timezone)) = args.get("timezone") { | ||||
|     if let Some(timezone) = timezone { | ||||
|         match timezone.parse::<Tz>() { | ||||
|             Ok(tz) => { | ||||
|                 user_data.timezone = timezone.clone(); | ||||
|                 user_data.commit_changes(&pool).await; | ||||
|                 user_data.commit_changes(&ctx.data().database).await; | ||||
|  | ||||
|                 let now = Utc::now().with_timezone(&tz); | ||||
|  | ||||
|                 let _ = invoke | ||||
|                     .respond( | ||||
|                         ctx.http.clone(), | ||||
|                         CreateGenericResponse::new().embed(|e| { | ||||
|                 ctx.send(|m| { | ||||
|                     m.embed(|e| { | ||||
|                         e.title("Timezone Set") | ||||
|                             .description(format!( | ||||
|                                 "Timezone has been set to **{}**. Your current time should be `{}`", | ||||
|                                 timezone, | ||||
|                                     now.format("%H:%M").to_string() | ||||
|                                 now.format("%H:%M") | ||||
|                             )) | ||||
|                             .color(*THEME_COLOR) | ||||
|                         }), | ||||
|                     ) | ||||
|                     .await; | ||||
|                     }) | ||||
|                 }) | ||||
|                 .await?; | ||||
|             } | ||||
|  | ||||
|             Err(_) => { | ||||
| @@ -56,8 +45,8 @@ async fn timezone(ctx: &Context, invoke: &mut CommandInvoke, args: CommandOption | ||||
|                     .iter() | ||||
|                     .filter(|tz| { | ||||
|                         timezone.contains(&tz.to_string()) | ||||
|                             || tz.to_string().contains(timezone) | ||||
|                             || levenshtein(&tz.to_string(), timezone) < 4 | ||||
|                             || tz.to_string().contains(&timezone) | ||||
|                             || levenshtein(&tz.to_string(), &timezone) < 4 | ||||
|                     }) | ||||
|                     .take(25) | ||||
|                     .map(|t| t.to_owned()) | ||||
| @@ -66,44 +55,31 @@ async fn timezone(ctx: &Context, invoke: &mut CommandInvoke, args: CommandOption | ||||
|                 let fields = filtered_tz.iter().map(|tz| { | ||||
|                     ( | ||||
|                         tz.to_string(), | ||||
|                         format!( | ||||
|                             "🕗 `{}`", | ||||
|                             Utc::now().with_timezone(tz).format("%H:%M").to_string() | ||||
|                         ), | ||||
|                         format!("🕗 `{}`", Utc::now().with_timezone(tz).format("%H:%M")), | ||||
|                         true, | ||||
|                     ) | ||||
|                 }); | ||||
|  | ||||
|                 let _ = invoke | ||||
|                     .respond( | ||||
|                         ctx.http.clone(), | ||||
|                         CreateGenericResponse::new().embed(|e| { | ||||
|                 ctx.send(|m| { | ||||
|                     m.embed(|e| { | ||||
|                         e.title("Timezone Not Recognized") | ||||
|                             .description("Possibly you meant one of the following timezones, otherwise click [here](https://gist.github.com/JellyWX/913dfc8b63d45192ad6cb54c829324ee):") | ||||
|                             .color(*THEME_COLOR) | ||||
|                             .fields(fields) | ||||
|                             .footer(|f| f.text(footer_text)) | ||||
|                             .url("https://gist.github.com/JellyWX/913dfc8b63d45192ad6cb54c829324ee") | ||||
|                         }), | ||||
|                     ) | ||||
|                     .await; | ||||
|                     }) | ||||
|                 }) | ||||
|                 .await?; | ||||
|             } | ||||
|         } | ||||
|     } else { | ||||
|         let popular_timezones = ctx.data.read().await.get::<PopularTimezones>().cloned().unwrap(); | ||||
|  | ||||
|         let popular_timezones_iter = popular_timezones.iter().map(|t| { | ||||
|             ( | ||||
|                 t.to_string(), | ||||
|                 format!("🕗 `{}`", Utc::now().with_timezone(t).format("%H:%M").to_string()), | ||||
|                 true, | ||||
|             ) | ||||
|         let popular_timezones_iter = ctx.data().popular_timezones.iter().map(|t| { | ||||
|             (t.to_string(), format!("🕗 `{}`", Utc::now().with_timezone(t).format("%H:%M")), true) | ||||
|         }); | ||||
|  | ||||
|         let _ = invoke | ||||
|             .respond( | ||||
|                 ctx.http.clone(), | ||||
|                 CreateGenericResponse::new().embed(|e| { | ||||
|         ctx.send(|m| { | ||||
|             m.embed(|e| { | ||||
|                 e.title("Timezone Usage") | ||||
|                     .description( | ||||
|                         "**Usage:** | ||||
| @@ -118,345 +94,162 @@ You may want to use one of the popular timezones below, otherwise click [here](h | ||||
|                     .fields(popular_timezones_iter) | ||||
|                     .footer(|f| f.text(footer_text)) | ||||
|                     .url("https://gist.github.com/JellyWX/913dfc8b63d45192ad6cb54c829324ee") | ||||
|                 }), | ||||
|             ) | ||||
|             .await; | ||||
|     } | ||||
|             }) | ||||
|         }) | ||||
|         .await?; | ||||
|     } | ||||
|  | ||||
| #[command("macro")] | ||||
| #[description("Record and replay command sequences")] | ||||
| #[subcommand("record")] | ||||
| #[description("Start recording up to 5 commands to replay")] | ||||
| #[arg(name = "name", description = "Name for the new macro", kind = "String", required = true)] | ||||
| #[arg( | ||||
|     name = "description", | ||||
|     description = "Description for the new macro", | ||||
|     kind = "String", | ||||
|     required = false | ||||
|     Ok(()) | ||||
| } | ||||
|  | ||||
| /// Configure server settings | ||||
| #[poise::command( | ||||
|     slash_command, | ||||
|     rename = "settings", | ||||
|     identifying_name = "settings", | ||||
|     guild_only = true | ||||
| )] | ||||
| #[subcommand("finish")] | ||||
| #[description("Finish current recording")] | ||||
| #[subcommand("list")] | ||||
| #[description("List recorded macros")] | ||||
| #[subcommand("run")] | ||||
| #[description("Run a recorded macro")] | ||||
| #[arg(name = "name", description = "Name of the macro to run", kind = "String", required = true)] | ||||
| #[subcommand("delete")] | ||||
| #[description("Delete a recorded macro")] | ||||
| #[arg(name = "name", description = "Name of the macro to delete", kind = "String", required = true)] | ||||
| #[supports_dm(false)] | ||||
| #[hook(GUILD_ONLY_HOOK)] | ||||
| #[hook(CHECK_GUILD_PERMISSIONS_HOOK)] | ||||
| async fn macro_cmd(ctx: &Context, invoke: &mut CommandInvoke, args: CommandOptions) { | ||||
|     let pool = ctx.data.read().await.get::<SQLPool>().cloned().unwrap(); | ||||
|  | ||||
|     match args.subcommand.clone().unwrap().as_str() { | ||||
|         "record" => { | ||||
|             let guild_id = invoke.guild_id().unwrap(); | ||||
|  | ||||
|             let name = args.get("name").unwrap().to_string(); | ||||
|  | ||||
|             let row = sqlx::query!( | ||||
|                 "SELECT 1 as _e FROM macro WHERE guild_id = (SELECT id FROM guilds WHERE guild = ?) AND name = ?", | ||||
|                 guild_id.0, | ||||
|                 name | ||||
|             ) | ||||
|             .fetch_one(&pool) | ||||
|             .await; | ||||
|  | ||||
|             if row.is_ok() { | ||||
|                 let _ = invoke | ||||
|                     .respond( | ||||
|                         &ctx, | ||||
|                         CreateGenericResponse::new().ephemeral().embed(|e| { | ||||
|                             e | ||||
|                             .title("Unique Name Required") | ||||
|                             .description("A macro already exists under this name. Please select a unique name for your macro.") | ||||
|                             .color(*THEME_COLOR) | ||||
|                         }), | ||||
|                     ) | ||||
|                     .await; | ||||
|             } else { | ||||
|                 let macro_buffer = ctx.data.read().await.get::<RecordingMacros>().cloned().unwrap(); | ||||
|  | ||||
|                 let okay = { | ||||
|                     let mut lock = macro_buffer.write().await; | ||||
|  | ||||
|                     if lock.contains_key(&(guild_id, invoke.author_id())) { | ||||
|                         false | ||||
|                     } else { | ||||
|                         lock.insert( | ||||
|                             (guild_id, invoke.author_id()), | ||||
|                             CommandMacro { | ||||
|                                 guild_id, | ||||
|                                 name, | ||||
|                                 description: args.get("description").map(|d| d.to_string()), | ||||
|                                 commands: vec![], | ||||
|                             }, | ||||
|                         ); | ||||
|                         true | ||||
| pub async fn settings(_ctx: Context<'_>) -> Result<(), Error> { | ||||
|     Ok(()) | ||||
| } | ||||
|                 }; | ||||
|  | ||||
|                 if okay { | ||||
|                     let _ = invoke | ||||
|                         .respond( | ||||
|                             &ctx, | ||||
|                             CreateGenericResponse::new().ephemeral().embed(|e| { | ||||
|                                 e | ||||
|                                 .title("Macro Recording Started") | ||||
| /// Configure ephemeral setup | ||||
| #[poise::command( | ||||
|     slash_command, | ||||
|     rename = "ephemeral", | ||||
|     identifying_name = "ephemeral_confirmations", | ||||
|     guild_only = true | ||||
| )] | ||||
| pub async fn ephemeral_confirmations(_ctx: Context<'_>) -> Result<(), Error> { | ||||
|     Ok(()) | ||||
| } | ||||
|  | ||||
| /// Set reminder confirmations to be sent "ephemerally" (private and cleared automatically) | ||||
| #[poise::command( | ||||
|     slash_command, | ||||
|     rename = "on", | ||||
|     identifying_name = "set_ephemeral_confirmations", | ||||
|     guild_only = true | ||||
| )] | ||||
| pub async fn set_ephemeral_confirmations(ctx: Context<'_>) -> Result<(), Error> { | ||||
|     let mut guild_data = ctx.guild_data().await.unwrap()?; | ||||
|     guild_data.ephemeral_confirmations = true; | ||||
|     guild_data.commit_changes(&ctx.data().database).await; | ||||
|  | ||||
|     ctx.send(|r| { | ||||
|         r.ephemeral(true).embed(|e| { | ||||
|             e.title("Confirmations ephemeral") | ||||
|                 .description("Reminder confirmations will be sent privately, and removed when your client restarts.") | ||||
|                 .color(*THEME_COLOR) | ||||
|         }) | ||||
|     }) | ||||
|     .await?; | ||||
|  | ||||
|     Ok(()) | ||||
| } | ||||
|  | ||||
| /// Set reminder confirmations to persist indefinitely | ||||
| #[poise::command( | ||||
|     slash_command, | ||||
|     rename = "off", | ||||
|     identifying_name = "unset_ephemeral_confirmations", | ||||
|     guild_only = true | ||||
| )] | ||||
| pub async fn unset_ephemeral_confirmations(ctx: Context<'_>) -> Result<(), Error> { | ||||
|     let mut guild_data = ctx.guild_data().await.unwrap()?; | ||||
|     guild_data.ephemeral_confirmations = false; | ||||
|     guild_data.commit_changes(&ctx.data().database).await; | ||||
|  | ||||
|     ctx.send(|r| { | ||||
|         r.ephemeral(true).embed(|e| { | ||||
|             e.title("Confirmations public") | ||||
|                 .description( | ||||
| "Run up to 5 commands, or type `/macro finish` to stop at any point. | ||||
| Any commands ran as part of recording will be inconsequential") | ||||
|                                 .color(*THEME_COLOR) | ||||
|                             }), | ||||
|                     "Reminder confirmations will be sent as regular messages, and won't be removed automatically.", | ||||
|                 ) | ||||
|                         .await; | ||||
|                 } else { | ||||
|                     let _ = invoke | ||||
|                         .respond( | ||||
|                             &ctx, | ||||
|                             CreateGenericResponse::new().ephemeral().embed(|e| { | ||||
|                                 e.title("Macro Already Recording") | ||||
|                 .color(*THEME_COLOR) | ||||
|         }) | ||||
|     }) | ||||
|     .await?; | ||||
|  | ||||
|     Ok(()) | ||||
| } | ||||
|  | ||||
| /// Configure whether other users can set reminders to your direct messages | ||||
| #[poise::command(slash_command, rename = "dm", identifying_name = "allowed_dm")] | ||||
| pub async fn allowed_dm(_ctx: Context<'_>) -> Result<(), Error> { | ||||
|     Ok(()) | ||||
| } | ||||
|  | ||||
| /// Allow other users to set reminders in your direct messages | ||||
| #[poise::command(slash_command, rename = "allow", identifying_name = "set_allowed_dm")] | ||||
| pub async fn set_allowed_dm(ctx: Context<'_>) -> Result<(), Error> { | ||||
|     let mut user_data = ctx.author_data().await?; | ||||
|     user_data.allowed_dm = true; | ||||
|     user_data.commit_changes(&ctx.data().database).await; | ||||
|  | ||||
|     ctx.send(|r| { | ||||
|         r.ephemeral(true).embed(|e| { | ||||
|             e.title("DMs permitted") | ||||
|                 .description("You will receive a message if a user sets a DM reminder for you.") | ||||
|                 .color(*THEME_COLOR) | ||||
|         }) | ||||
|     }) | ||||
|     .await?; | ||||
|  | ||||
|     Ok(()) | ||||
| } | ||||
|  | ||||
| /// Block other users from setting reminders in your direct messages | ||||
| #[poise::command(slash_command, rename = "block", identifying_name = "unset_allowed_dm")] | ||||
| pub async fn unset_allowed_dm(ctx: Context<'_>) -> Result<(), Error> { | ||||
|     let mut user_data = ctx.author_data().await?; | ||||
|     user_data.allowed_dm = false; | ||||
|     user_data.commit_changes(&ctx.data().database).await; | ||||
|  | ||||
|     ctx.send(|r| { | ||||
|         r.ephemeral(true).embed(|e| { | ||||
|             e.title("DMs blocked") | ||||
|                 .description( | ||||
|                                         "You are already recording a macro in this server. | ||||
| Please use `/macro finish` to end this recording before starting another.", | ||||
|                     "You can still set DM reminders for yourself or for users with DMs enabled.", | ||||
|                 ) | ||||
|                 .color(*THEME_COLOR) | ||||
|                             }), | ||||
|                         ) | ||||
|                         .await; | ||||
|                 } | ||||
|             } | ||||
|         } | ||||
|         "finish" => { | ||||
|             let key = (invoke.guild_id().unwrap(), invoke.author_id()); | ||||
|             let macro_buffer = ctx.data.read().await.get::<RecordingMacros>().cloned().unwrap(); | ||||
|         }) | ||||
|     }) | ||||
|     .await?; | ||||
|  | ||||
|             { | ||||
|                 let lock = macro_buffer.read().await; | ||||
|                 let contained = lock.get(&key); | ||||
|     Ok(()) | ||||
| } | ||||
|  | ||||
|                 if contained.map_or(true, |cmacro| cmacro.commands.is_empty()) { | ||||
|                     let _ = invoke | ||||
|                         .respond( | ||||
|                             &ctx, | ||||
|                             CreateGenericResponse::new().embed(|e| { | ||||
|                                 e.title("No Macro Recorded") | ||||
|                                     .description("Use `/macro record` to start recording a macro") | ||||
|                                     .color(*THEME_COLOR) | ||||
|                             }), | ||||
|                         ) | ||||
|                         .await; | ||||
| /// View the webhook being used to send reminders to this channel | ||||
| #[poise::command( | ||||
|     slash_command, | ||||
|     identifying_name = "webhook_url", | ||||
|     required_permissions = "ADMINISTRATOR" | ||||
| )] | ||||
| pub async fn webhook(ctx: Context<'_>) -> Result<(), Error> { | ||||
|     match ctx.channel_data().await { | ||||
|         Ok(data) => { | ||||
|             if let (Some(id), Some(token)) = (data.webhook_id, data.webhook_token) { | ||||
|                 ctx.send(|b| { | ||||
|                     b.ephemeral(true).content(format!( | ||||
|                         "**Warning!** | ||||
| This link can be used by users to anonymously send messages, with or without permissions. | ||||
| Do not share it! | ||||
| || https://discord.com/api/webhooks/{}/{} ||", | ||||
|                         id, token, | ||||
|                     )) | ||||
|                 }) | ||||
|                 .await?; | ||||
|             } else { | ||||
|                     let command_macro = contained.unwrap(); | ||||
|                     let json = serde_json::to_string(&command_macro.commands).unwrap(); | ||||
|  | ||||
|                     sqlx::query!( | ||||
|                         "INSERT INTO macro (guild_id, name, description, commands) VALUES ((SELECT id FROM guilds WHERE guild = ?), ?, ?, ?)", | ||||
|                         command_macro.guild_id.0, | ||||
|                         command_macro.name, | ||||
|                         command_macro.description, | ||||
|                         json | ||||
|                     ) | ||||
|                         .execute(&pool) | ||||
|                         .await | ||||
|                         .unwrap(); | ||||
|  | ||||
|                     let _ = invoke | ||||
|                         .respond( | ||||
|                             &ctx, | ||||
|                             CreateGenericResponse::new().embed(|e| { | ||||
|                                 e.title("Macro Recorded") | ||||
|                                     .description("Use `/macro run` to execute the macro") | ||||
|                                     .color(*THEME_COLOR) | ||||
|                             }), | ||||
|                         ) | ||||
|                         .await; | ||||
|                 ctx.say("No webhook configured on this channel.").await?; | ||||
|             } | ||||
|         } | ||||
|  | ||||
|             { | ||||
|                 let mut lock = macro_buffer.write().await; | ||||
|                 lock.remove(&key); | ||||
|             } | ||||
|         } | ||||
|         "list" => { | ||||
|             let macros = CommandMacro::from_guild(ctx, invoke.guild_id().unwrap()).await; | ||||
|  | ||||
|             let resp = show_macro_page(¯os, 0); | ||||
|  | ||||
|             invoke.respond(&ctx, resp).await.unwrap(); | ||||
|         } | ||||
|         "run" => { | ||||
|             let macro_name = args.get("name").unwrap().to_string(); | ||||
|  | ||||
|             match sqlx::query!( | ||||
|                 "SELECT commands FROM macro WHERE guild_id = (SELECT id FROM guilds WHERE guild = ?) AND name = ?", | ||||
|                 invoke.guild_id().unwrap().0, | ||||
|                 macro_name | ||||
|             ) | ||||
|             .fetch_one(&pool) | ||||
|             .await | ||||
|             { | ||||
|                 Ok(row) => { | ||||
|                     invoke.defer(&ctx).await; | ||||
|  | ||||
|                     let commands: Vec<CommandOptions> = | ||||
|                         serde_json::from_str(&row.commands).unwrap(); | ||||
|                     let framework = ctx.data.read().await.get::<RegexFramework>().cloned().unwrap(); | ||||
|  | ||||
|                     for command in commands { | ||||
|                         framework.run_command_from_options(ctx, invoke, command).await; | ||||
|                     } | ||||
|                 } | ||||
|  | ||||
|                 Err(sqlx::Error::RowNotFound) => { | ||||
|                     let _ = invoke | ||||
|                         .respond( | ||||
|                             &ctx, | ||||
|                             CreateGenericResponse::new() | ||||
|                                 .content(format!("Macro \"{}\" not found", macro_name)), | ||||
|                         ) | ||||
|                         .await; | ||||
|                 } | ||||
|  | ||||
|         Err(e) => { | ||||
|                     panic!("{}", e); | ||||
|                 } | ||||
|             } | ||||
|         } | ||||
|         "delete" => { | ||||
|             let macro_name = args.get("name").unwrap().to_string(); | ||||
|             warn!("Error fetching channel data: {:?}", e); | ||||
|  | ||||
|             match sqlx::query!( | ||||
|                 "SELECT id FROM macro WHERE guild_id = (SELECT id FROM guilds WHERE guild = ?) AND name = ?", | ||||
|                 invoke.guild_id().unwrap().0, | ||||
|                 macro_name | ||||
|             ) | ||||
|             .fetch_one(&pool) | ||||
|             .await | ||||
|             { | ||||
|                 Ok(row) => { | ||||
|                     sqlx::query!("DELETE FROM macro WHERE id = ?", row.id) | ||||
|                         .execute(&pool) | ||||
|                         .await | ||||
|                         .unwrap(); | ||||
|  | ||||
|                     let _ = invoke | ||||
|                         .respond( | ||||
|                             &ctx, | ||||
|                             CreateGenericResponse::new() | ||||
|                                 .content(format!("Macro \"{}\" deleted", macro_name)), | ||||
|                         ) | ||||
|                         .await; | ||||
|                 } | ||||
|  | ||||
|                 Err(sqlx::Error::RowNotFound) => { | ||||
|                     let _ = invoke | ||||
|                         .respond( | ||||
|                             &ctx, | ||||
|                             CreateGenericResponse::new() | ||||
|                                 .content(format!("Macro \"{}\" not found", macro_name)), | ||||
|                         ) | ||||
|                         .await; | ||||
|                 } | ||||
|  | ||||
|                 Err(e) => { | ||||
|                     panic!("{}", e); | ||||
|                 } | ||||
|             } | ||||
|         } | ||||
|         _ => {} | ||||
|             ctx.say("No webhook configured on this channel.").await?; | ||||
|         } | ||||
|     } | ||||
|  | ||||
| pub fn max_macro_page(macros: &[CommandMacro]) -> usize { | ||||
|     let mut skipped_char_count = 0; | ||||
|  | ||||
|     macros | ||||
|         .iter() | ||||
|         .map(|m| { | ||||
|             if let Some(description) = &m.description { | ||||
|                 format!("**{}**\n- *{}*\n- Has {} commands", m.name, description, m.commands.len()) | ||||
|             } else { | ||||
|                 format!("**{}**\n- Has {} commands", m.name, m.commands.len()) | ||||
|             } | ||||
|         }) | ||||
|         .fold(1, |mut pages, p| { | ||||
|             skipped_char_count += p.len(); | ||||
|  | ||||
|             if skipped_char_count > EMBED_DESCRIPTION_MAX_LENGTH { | ||||
|                 skipped_char_count = p.len(); | ||||
|                 pages += 1; | ||||
|             } | ||||
|  | ||||
|             pages | ||||
|         }) | ||||
| } | ||||
|  | ||||
| pub fn show_macro_page(macros: &[CommandMacro], page: usize) -> CreateGenericResponse { | ||||
|     let pager = MacroPager::new(page); | ||||
|  | ||||
|     if macros.is_empty() { | ||||
|         return CreateGenericResponse::new().embed(|e| { | ||||
|             e.title("Macros") | ||||
|                 .description("No Macros Set Up. Use `/macro record` to get started.") | ||||
|                 .color(*THEME_COLOR) | ||||
|         }); | ||||
|     } | ||||
|  | ||||
|     let pages = max_macro_page(macros); | ||||
|  | ||||
|     let mut page = page; | ||||
|     if page >= pages { | ||||
|         page = pages - 1; | ||||
|     } | ||||
|  | ||||
|     let mut char_count = 0; | ||||
|     let mut skipped_char_count = 0; | ||||
|  | ||||
|     let mut skipped_pages = 0; | ||||
|  | ||||
|     let display_vec: Vec<String> = macros | ||||
|         .iter() | ||||
|         .map(|m| { | ||||
|             if let Some(description) = &m.description { | ||||
|                 format!("**{}**\n- *{}*\n- Has {} commands", m.name, description, m.commands.len()) | ||||
|             } else { | ||||
|                 format!("**{}**\n- Has {} commands", m.name, m.commands.len()) | ||||
|             } | ||||
|         }) | ||||
|         .skip_while(|p| { | ||||
|             skipped_char_count += p.len(); | ||||
|  | ||||
|             if skipped_char_count > EMBED_DESCRIPTION_MAX_LENGTH { | ||||
|                 skipped_char_count = p.len(); | ||||
|                 skipped_pages += 1; | ||||
|             } | ||||
|  | ||||
|             skipped_pages < page | ||||
|         }) | ||||
|         .take_while(|p| { | ||||
|             char_count += p.len(); | ||||
|  | ||||
|             char_count < EMBED_DESCRIPTION_MAX_LENGTH | ||||
|         }) | ||||
|         .collect::<Vec<String>>(); | ||||
|  | ||||
|     let display = display_vec.join("\n"); | ||||
|  | ||||
|     CreateGenericResponse::new() | ||||
|         .embed(|e| { | ||||
|             e.title("Macros") | ||||
|                 .description(display) | ||||
|                 .footer(|f| f.text(format!("Page {} of {}", page + 1, pages))) | ||||
|                 .color(*THEME_COLOR) | ||||
|         }) | ||||
|         .components(|comp| { | ||||
|             pager.create_button_row(pages, comp); | ||||
|  | ||||
|             comp | ||||
|         }) | ||||
|     Ok(()) | ||||
| } | ||||
|   | ||||
										
											
												File diff suppressed because it is too large
												Load Diff
											
										
									
								
							| @@ -1,5 +1,4 @@ | ||||
| use regex_command_attr::command; | ||||
| use serenity::client::Context; | ||||
| use poise::CreateReply; | ||||
|  | ||||
| use crate::{ | ||||
|     component_models::{ | ||||
| @@ -7,134 +6,222 @@ use crate::{ | ||||
|         ComponentDataModel, TodoSelector, | ||||
|     }, | ||||
|     consts::{EMBED_DESCRIPTION_MAX_LENGTH, SELECT_MAX_ENTRIES, THEME_COLOR}, | ||||
|     framework::{CommandInvoke, CommandOptions, CreateGenericResponse}, | ||||
|     hooks::CHECK_GUILD_PERMISSIONS_HOOK, | ||||
|     SQLPool, | ||||
|     models::CtxData, | ||||
|     Context, Error, | ||||
| }; | ||||
|  | ||||
| #[command] | ||||
| #[description("Manage todo lists")] | ||||
| #[subcommandgroup("server")] | ||||
| #[description("Manage the server todo list")] | ||||
| #[subcommand("add")] | ||||
| #[description("Add an item to the server todo list")] | ||||
| #[arg( | ||||
|     name = "task", | ||||
|     description = "The task to add to the todo list", | ||||
|     kind = "String", | ||||
|     required = true | ||||
| /// Manage todo lists | ||||
| #[poise::command( | ||||
|     slash_command, | ||||
|     rename = "todo", | ||||
|     identifying_name = "todo_base", | ||||
|     default_member_permissions = "MANAGE_GUILD" | ||||
| )] | ||||
| #[subcommand("view")] | ||||
| #[description("View and remove from the server todo list")] | ||||
| #[subcommandgroup("channel")] | ||||
| #[description("Manage the channel todo list")] | ||||
| #[subcommand("add")] | ||||
| #[description("Add to the channel todo list")] | ||||
| #[arg( | ||||
|     name = "task", | ||||
|     description = "The task to add to the todo list", | ||||
|     kind = "String", | ||||
|     required = true | ||||
| pub async fn todo_base(_ctx: Context<'_>) -> Result<(), Error> { | ||||
|     Ok(()) | ||||
| } | ||||
|  | ||||
| /// Manage the server todo list | ||||
| #[poise::command( | ||||
|     slash_command, | ||||
|     rename = "server", | ||||
|     guild_only = true, | ||||
|     identifying_name = "todo_guild_base", | ||||
|     default_member_permissions = "MANAGE_GUILD" | ||||
| )] | ||||
| #[subcommand("view")] | ||||
| #[description("View and remove from the channel todo list")] | ||||
| #[subcommandgroup("user")] | ||||
| #[description("Manage your personal todo list")] | ||||
| #[subcommand("add")] | ||||
| #[description("Add to your personal todo list")] | ||||
| #[arg( | ||||
|     name = "task", | ||||
|     description = "The task to add to the todo list", | ||||
|     kind = "String", | ||||
|     required = true | ||||
| pub async fn todo_guild_base(_ctx: Context<'_>) -> Result<(), Error> { | ||||
|     Ok(()) | ||||
| } | ||||
|  | ||||
| /// Add an item to the server todo list | ||||
| #[poise::command( | ||||
|     slash_command, | ||||
|     rename = "add", | ||||
|     guild_only = true, | ||||
|     identifying_name = "todo_guild_add", | ||||
|     default_member_permissions = "MANAGE_GUILD" | ||||
| )] | ||||
| #[subcommand("view")] | ||||
| #[description("View and remove from your personal todo list")] | ||||
| #[hook(CHECK_GUILD_PERMISSIONS_HOOK)] | ||||
| async fn todo(ctx: &Context, invoke: &mut CommandInvoke, args: CommandOptions) { | ||||
|     if invoke.guild_id().is_none() && args.subcommand_group != Some("user".to_string()) { | ||||
|         let _ = invoke | ||||
|             .respond( | ||||
|                 &ctx, | ||||
|                 CreateGenericResponse::new().content("Please use `/todo user` in direct messages"), | ||||
|             ) | ||||
|             .await; | ||||
|     } else { | ||||
|         let pool = ctx.data.read().await.get::<SQLPool>().cloned().unwrap(); | ||||
|  | ||||
|         let keys = match args.subcommand_group.as_ref().unwrap().as_str() { | ||||
|             "server" => (None, None, invoke.guild_id().map(|g| g.0)), | ||||
|             "channel" => (None, Some(invoke.channel_id().0), invoke.guild_id().map(|g| g.0)), | ||||
|             _ => (Some(invoke.author_id().0), None, None), | ||||
|         }; | ||||
|  | ||||
|         match args.get("task") { | ||||
|             Some(task) => { | ||||
|                 let task = task.to_string(); | ||||
|  | ||||
| pub async fn todo_guild_add( | ||||
|     ctx: Context<'_>, | ||||
|     #[description = "The task to add to the todo list"] task: String, | ||||
| ) -> Result<(), Error> { | ||||
|     sqlx::query!( | ||||
|                     "INSERT INTO todos (user_id, channel_id, guild_id, value) VALUES ((SELECT id FROM users WHERE user = ?), (SELECT id FROM channels WHERE channel = ?), (SELECT id FROM guilds WHERE guild = ?), ?)", | ||||
|                     keys.0, | ||||
|                     keys.1, | ||||
|                     keys.2, | ||||
|         "INSERT INTO todos (guild_id, value) | ||||
| VALUES ((SELECT id FROM guilds WHERE guild = ?), ?)", | ||||
|         ctx.guild_id().unwrap().0, | ||||
|         task | ||||
|     ) | ||||
|                 .execute(&pool) | ||||
|     .execute(&ctx.data().database) | ||||
|     .await | ||||
|     .unwrap(); | ||||
|  | ||||
|                 let _ = invoke | ||||
|                     .respond(&ctx, CreateGenericResponse::new().content("Item added to todo list")) | ||||
|                     .await; | ||||
|     ctx.say("Item added to todo list").await?; | ||||
|  | ||||
|     Ok(()) | ||||
| } | ||||
|             None => { | ||||
|                 let values = if let Some(uid) = keys.0 { | ||||
|                     sqlx::query!( | ||||
|                         "SELECT todos.id, value FROM todos | ||||
| INNER JOIN users ON todos.user_id = users.id | ||||
| WHERE users.user = ?", | ||||
|                         uid, | ||||
|                     ) | ||||
|                     .fetch_all(&pool) | ||||
|                     .await | ||||
|                     .unwrap() | ||||
|                     .iter() | ||||
|                     .map(|row| (row.id as usize, row.value.clone())) | ||||
|                     .collect::<Vec<(usize, String)>>() | ||||
|                 } else if let Some(cid) = keys.1 { | ||||
|                     sqlx::query!( | ||||
|                         "SELECT todos.id, value FROM todos | ||||
| INNER JOIN channels ON todos.channel_id = channels.id | ||||
| WHERE channels.channel = ?", | ||||
|                         cid, | ||||
|                     ) | ||||
|                     .fetch_all(&pool) | ||||
|                     .await | ||||
|                     .unwrap() | ||||
|                     .iter() | ||||
|                     .map(|row| (row.id as usize, row.value.clone())) | ||||
|                     .collect::<Vec<(usize, String)>>() | ||||
|                 } else { | ||||
|                     sqlx::query!( | ||||
|  | ||||
| /// View and remove from the server todo list | ||||
| #[poise::command( | ||||
|     slash_command, | ||||
|     rename = "view", | ||||
|     guild_only = true, | ||||
|     identifying_name = "todo_guild_view", | ||||
|     default_member_permissions = "MANAGE_GUILD" | ||||
| )] | ||||
| pub async fn todo_guild_view(ctx: Context<'_>) -> Result<(), Error> { | ||||
|     let values = sqlx::query!( | ||||
|         "SELECT todos.id, value FROM todos | ||||
| INNER JOIN guilds ON todos.guild_id = guilds.id | ||||
| WHERE guilds.guild = ?", | ||||
|                         keys.2, | ||||
|         ctx.guild_id().unwrap().0, | ||||
|     ) | ||||
|                     .fetch_all(&pool) | ||||
|     .fetch_all(&ctx.data().database) | ||||
|     .await | ||||
|     .unwrap() | ||||
|     .iter() | ||||
|     .map(|row| (row.id as usize, row.value.clone())) | ||||
|                     .collect::<Vec<(usize, String)>>() | ||||
|                 }; | ||||
|     .collect::<Vec<(usize, String)>>(); | ||||
|  | ||||
|                 let resp = show_todo_page(&values, 0, keys.0, keys.1, keys.2); | ||||
|     let resp = show_todo_page(&values, 0, None, None, ctx.guild_id().map(|g| g.0)); | ||||
|  | ||||
|                 invoke.respond(&ctx, resp).await.unwrap(); | ||||
|     ctx.send(|r| { | ||||
|         *r = resp; | ||||
|         r | ||||
|     }) | ||||
|     .await?; | ||||
|  | ||||
|     Ok(()) | ||||
| } | ||||
|  | ||||
| /// Manage the channel todo list | ||||
| #[poise::command( | ||||
|     slash_command, | ||||
|     rename = "channel", | ||||
|     guild_only = true, | ||||
|     identifying_name = "todo_channel_base", | ||||
|     default_member_permissions = "MANAGE_GUILD" | ||||
| )] | ||||
| pub async fn todo_channel_base(_ctx: Context<'_>) -> Result<(), Error> { | ||||
|     Ok(()) | ||||
| } | ||||
|  | ||||
| /// Add an item to the channel todo list | ||||
| #[poise::command( | ||||
|     slash_command, | ||||
|     rename = "add", | ||||
|     guild_only = true, | ||||
|     identifying_name = "todo_channel_add", | ||||
|     default_member_permissions = "MANAGE_GUILD" | ||||
| )] | ||||
| pub async fn todo_channel_add( | ||||
|     ctx: Context<'_>, | ||||
|     #[description = "The task to add to the todo list"] task: String, | ||||
| ) -> Result<(), Error> { | ||||
|     // ensure channel is cached | ||||
|     let _ = ctx.channel_data().await; | ||||
|  | ||||
|     sqlx::query!( | ||||
|         "INSERT INTO todos (guild_id, channel_id, value) | ||||
| VALUES ((SELECT id FROM guilds WHERE guild = ?), (SELECT id FROM channels WHERE channel = ?), ?)", | ||||
|         ctx.guild_id().unwrap().0, | ||||
|         ctx.channel_id().0, | ||||
|         task | ||||
|     ) | ||||
|     .execute(&ctx.data().database) | ||||
|     .await | ||||
|     .unwrap(); | ||||
|  | ||||
|     ctx.say("Item added to todo list").await?; | ||||
|  | ||||
|     Ok(()) | ||||
| } | ||||
|  | ||||
| /// View and remove from the channel todo list | ||||
| #[poise::command( | ||||
|     slash_command, | ||||
|     rename = "view", | ||||
|     guild_only = true, | ||||
|     identifying_name = "todo_channel_view", | ||||
|     default_member_permissions = "MANAGE_GUILD" | ||||
| )] | ||||
| pub async fn todo_channel_view(ctx: Context<'_>) -> Result<(), Error> { | ||||
|     let values = sqlx::query!( | ||||
|         "SELECT todos.id, value FROM todos | ||||
| INNER JOIN channels ON todos.channel_id = channels.id | ||||
| WHERE channels.channel = ?", | ||||
|         ctx.channel_id().0, | ||||
|     ) | ||||
|     .fetch_all(&ctx.data().database) | ||||
|     .await | ||||
|     .unwrap() | ||||
|     .iter() | ||||
|     .map(|row| (row.id as usize, row.value.clone())) | ||||
|     .collect::<Vec<(usize, String)>>(); | ||||
|  | ||||
|     let resp = | ||||
|         show_todo_page(&values, 0, None, Some(ctx.channel_id().0), ctx.guild_id().map(|g| g.0)); | ||||
|  | ||||
|     ctx.send(|r| { | ||||
|         *r = resp; | ||||
|         r | ||||
|     }) | ||||
|     .await?; | ||||
|  | ||||
|     Ok(()) | ||||
| } | ||||
|  | ||||
| /// Manage your personal todo list | ||||
| #[poise::command(slash_command, rename = "user", identifying_name = "todo_user_base")] | ||||
| pub async fn todo_user_base(_ctx: Context<'_>) -> Result<(), Error> { | ||||
|     Ok(()) | ||||
| } | ||||
|  | ||||
| /// Add an item to your personal todo list | ||||
| #[poise::command(slash_command, rename = "add", identifying_name = "todo_user_add")] | ||||
| pub async fn todo_user_add( | ||||
|     ctx: Context<'_>, | ||||
|     #[description = "The task to add to the todo list"] task: String, | ||||
| ) -> Result<(), Error> { | ||||
|     sqlx::query!( | ||||
|         "INSERT INTO todos (user_id, value) | ||||
| VALUES ((SELECT id FROM users WHERE user = ?), ?)", | ||||
|         ctx.author().id.0, | ||||
|         task | ||||
|     ) | ||||
|     .execute(&ctx.data().database) | ||||
|     .await | ||||
|     .unwrap(); | ||||
|  | ||||
|     ctx.say("Item added to todo list").await?; | ||||
|  | ||||
|     Ok(()) | ||||
| } | ||||
|  | ||||
| /// View and remove from your personal todo list | ||||
| #[poise::command(slash_command, rename = "view", identifying_name = "todo_user_view")] | ||||
| pub async fn todo_user_view(ctx: Context<'_>) -> Result<(), Error> { | ||||
|     let values = sqlx::query!( | ||||
|         "SELECT todos.id, value FROM todos | ||||
| INNER JOIN users ON todos.user_id = users.id | ||||
| WHERE users.user = ?", | ||||
|         ctx.author().id.0, | ||||
|     ) | ||||
|     .fetch_all(&ctx.data().database) | ||||
|     .await | ||||
|     .unwrap() | ||||
|     .iter() | ||||
|     .map(|row| (row.id as usize, row.value.clone())) | ||||
|     .collect::<Vec<(usize, String)>>(); | ||||
|  | ||||
|     let resp = show_todo_page(&values, 0, Some(ctx.author().id.0), None, None); | ||||
|  | ||||
|     ctx.send(|r| { | ||||
|         *r = resp; | ||||
|         r | ||||
|     }) | ||||
|     .await?; | ||||
|  | ||||
|     Ok(()) | ||||
| } | ||||
|  | ||||
| pub fn max_todo_page(todo_values: &[(usize, String)]) -> usize { | ||||
| @@ -164,7 +251,7 @@ pub fn show_todo_page( | ||||
|     user_id: Option<u64>, | ||||
|     channel_id: Option<u64>, | ||||
|     guild_id: Option<u64>, | ||||
| ) -> CreateGenericResponse { | ||||
| ) -> CreateReply { | ||||
|     let pager = TodoPager::new(page, user_id, channel_id, guild_id); | ||||
|  | ||||
|     let pages = max_todo_page(todo_values); | ||||
| @@ -219,17 +306,23 @@ pub fn show_todo_page( | ||||
|     }; | ||||
|  | ||||
|     if todo_ids.is_empty() { | ||||
|         CreateGenericResponse::new().embed(|e| { | ||||
|         let mut reply = CreateReply::default(); | ||||
|  | ||||
|         reply.embed(|e| { | ||||
|             e.title(format!("{} Todo List", title)) | ||||
|                 .description("Todo List Empty!") | ||||
|                 .footer(|f| f.text(format!("Page {} of {}", page + 1, pages))) | ||||
|                 .color(*THEME_COLOR) | ||||
|         }) | ||||
|         }); | ||||
|  | ||||
|         reply | ||||
|     } else { | ||||
|         let todo_selector = | ||||
|             ComponentDataModel::TodoSelector(TodoSelector { page, user_id, channel_id, guild_id }); | ||||
|  | ||||
|         CreateGenericResponse::new() | ||||
|         let mut reply = CreateReply::default(); | ||||
|  | ||||
|         reply | ||||
|             .embed(|e| { | ||||
|                 e.title(format!("{} Todo List", title)) | ||||
|                     .description(display) | ||||
| @@ -247,7 +340,18 @@ pub fn show_todo_page( | ||||
|                                 opt.create_option(|o| { | ||||
|                                     o.label(format!("Mark {} complete", count + first_num)) | ||||
|                                         .value(id) | ||||
|                                         .description(disp.split_once(" ").unwrap_or(("", "")).1) | ||||
|                                         .description({ | ||||
|                                             let c = disp.split_once(' ').unwrap_or(("", "")).1; | ||||
|  | ||||
|                                             if c.len() > 100 { | ||||
|                                                 format!( | ||||
|                                                     "{}...", | ||||
|                                                     c.chars().take(97).collect::<String>() | ||||
|                                                 ) | ||||
|                                             } else { | ||||
|                                                 c.to_string() | ||||
|                                             } | ||||
|                                         }) | ||||
|                                 }); | ||||
|                             } | ||||
|  | ||||
| @@ -255,6 +359,8 @@ pub fn show_todo_page( | ||||
|                         }) | ||||
|                     }) | ||||
|                 }) | ||||
|             }) | ||||
|             }); | ||||
|  | ||||
|         reply | ||||
|     } | ||||
| } | ||||
|   | ||||
| @@ -2,30 +2,37 @@ pub(crate) mod pager; | ||||
|  | ||||
| use std::io::Cursor; | ||||
|  | ||||
| use base64::{engine::general_purpose, Engine}; | ||||
| use chrono_tz::Tz; | ||||
| use rmp_serde::Serializer; | ||||
| use serde::{Deserialize, Serialize}; | ||||
| use serenity::{ | ||||
| use log::warn; | ||||
| use poise::{ | ||||
|     serenity_prelude as serenity, | ||||
|     serenity_prelude::{ | ||||
|         builder::CreateEmbed, | ||||
|     client::Context, | ||||
|         model::{ | ||||
|             application::interaction::{ | ||||
|                 message_component::MessageComponentInteraction, InteractionResponseType, | ||||
|                 MessageFlags, | ||||
|             }, | ||||
|             channel::Channel, | ||||
|         interactions::{message_component::MessageComponentInteraction, InteractionResponseType}, | ||||
|         prelude::InteractionApplicationCommandCallbackDataFlags, | ||||
|         }, | ||||
|         Context, | ||||
|     }, | ||||
| }; | ||||
| use rmp_serde::Serializer; | ||||
| use serde::{Deserialize, Serialize}; | ||||
|  | ||||
| use crate::{ | ||||
|     commands::{ | ||||
|         moderation_cmds::{max_macro_page, show_macro_page}, | ||||
|         command_macro::list::{max_macro_page, show_macro_page}, | ||||
|         reminder_cmds::{max_delete_page, show_delete_page}, | ||||
|         todo_cmds::{max_todo_page, show_todo_page}, | ||||
|     }, | ||||
|     component_models::pager::{DelPager, LookPager, MacroPager, Pager, TodoPager}, | ||||
|     consts::{EMBED_DESCRIPTION_MAX_LENGTH, THEME_COLOR}, | ||||
|     framework::CommandInvoke, | ||||
|     models::{command_macro::CommandMacro, reminder::Reminder}, | ||||
|     SQLPool, | ||||
|     models::reminder::Reminder, | ||||
|     utils::send_as_initial_response, | ||||
|     Data, | ||||
| }; | ||||
|  | ||||
| #[derive(Deserialize, Serialize)] | ||||
| @@ -38,24 +45,26 @@ pub enum ComponentDataModel { | ||||
|     DelSelector(DelSelector), | ||||
|     TodoSelector(TodoSelector), | ||||
|     MacroPager(MacroPager), | ||||
|     UndoReminder(UndoReminder), | ||||
| } | ||||
|  | ||||
| impl ComponentDataModel { | ||||
|     pub fn to_custom_id(&self) -> String { | ||||
|         let mut buf = Vec::new(); | ||||
|         self.serialize(&mut Serializer::new(&mut buf)).unwrap(); | ||||
|         base64::encode(buf) | ||||
|         general_purpose::STANDARD.encode(buf) | ||||
|     } | ||||
|  | ||||
|     pub fn from_custom_id(data: &String) -> Self { | ||||
|         let buf = base64::decode(data) | ||||
|         let buf = general_purpose::STANDARD | ||||
|             .decode(data) | ||||
|             .map_err(|e| format!("Could not decode `custom_id' {}: {:?}", data, e)) | ||||
|             .unwrap(); | ||||
|         let cur = Cursor::new(buf); | ||||
|         rmp_serde::from_read(cur).unwrap() | ||||
|     } | ||||
|  | ||||
|     pub async fn act(&self, ctx: &Context, component: MessageComponentInteraction) { | ||||
|     pub async fn act(&self, ctx: &Context, data: &Data, component: &MessageComponentInteraction) { | ||||
|         match self { | ||||
|             ComponentDataModel::LookPager(pager) => { | ||||
|                 let flags = pager.flags; | ||||
| @@ -72,7 +81,7 @@ impl ComponentDataModel { | ||||
|                     component.channel_id | ||||
|                 }; | ||||
|  | ||||
|                 let reminders = Reminder::from_channel(ctx, channel_id, &flags).await; | ||||
|                 let reminders = Reminder::from_channel(&data.database, channel_id, &flags).await; | ||||
|  | ||||
|                 let pages = reminders | ||||
|                     .iter() | ||||
| @@ -106,7 +115,7 @@ impl ComponentDataModel { | ||||
|                         char_count < EMBED_DESCRIPTION_MAX_LENGTH | ||||
|                     }) | ||||
|                     .collect::<Vec<String>>() | ||||
|                     .join("\n"); | ||||
|                     .join(""); | ||||
|  | ||||
|                 let mut embed = CreateEmbed::default(); | ||||
|                 embed | ||||
| @@ -122,7 +131,7 @@ impl ComponentDataModel { | ||||
|                     .create_interaction_response(&ctx, |r| { | ||||
|                         r.kind(InteractionResponseType::UpdateMessage).interaction_response_data( | ||||
|                             |response| { | ||||
|                                 response.embeds(vec![embed]).components(|comp| { | ||||
|                                 response.set_embeds(vec![embed]).components(|comp| { | ||||
|                                     pager.create_button_row(pages, comp); | ||||
|  | ||||
|                                     comp | ||||
| @@ -133,37 +142,63 @@ impl ComponentDataModel { | ||||
|                     .await; | ||||
|             } | ||||
|             ComponentDataModel::DelPager(pager) => { | ||||
|                 let reminders = | ||||
|                     Reminder::from_guild(ctx, component.guild_id, component.user.id).await; | ||||
|                 let reminders = Reminder::from_guild( | ||||
|                     &ctx, | ||||
|                     &data.database, | ||||
|                     component.guild_id, | ||||
|                     component.user.id, | ||||
|                 ) | ||||
|                 .await; | ||||
|  | ||||
|                 let max_pages = max_delete_page(&reminders, &pager.timezone); | ||||
|  | ||||
|                 let resp = show_delete_page(&reminders, pager.next_page(max_pages), pager.timezone); | ||||
|  | ||||
|                 let mut invoke = CommandInvoke::component(component); | ||||
|                 let _ = invoke.respond(&ctx, resp).await; | ||||
|                 let _ = component | ||||
|                     .create_interaction_response(&ctx, |f| { | ||||
|                         f.kind(InteractionResponseType::UpdateMessage).interaction_response_data( | ||||
|                             |d| { | ||||
|                                 send_as_initial_response(resp, d); | ||||
|                                 d | ||||
|                             }, | ||||
|                         ) | ||||
|                     }) | ||||
|                     .await; | ||||
|             } | ||||
|             ComponentDataModel::DelSelector(selector) => { | ||||
|                 let pool = ctx.data.read().await.get::<SQLPool>().cloned().unwrap(); | ||||
|                 let selected_id = component.data.values.join(","); | ||||
|  | ||||
|                 sqlx::query!("DELETE FROM reminders WHERE FIND_IN_SET(id, ?)", selected_id) | ||||
|                     .execute(&pool) | ||||
|                 sqlx::query!( | ||||
|                     "UPDATE reminders SET `status` = 'deleted' WHERE FIND_IN_SET(id, ?)", | ||||
|                     selected_id | ||||
|                 ) | ||||
|                 .execute(&data.database) | ||||
|                 .await | ||||
|                 .unwrap(); | ||||
|  | ||||
|                 let reminders = | ||||
|                     Reminder::from_guild(ctx, component.guild_id, component.user.id).await; | ||||
|                 let reminders = Reminder::from_guild( | ||||
|                     &ctx, | ||||
|                     &data.database, | ||||
|                     component.guild_id, | ||||
|                     component.user.id, | ||||
|                 ) | ||||
|                 .await; | ||||
|  | ||||
|                 let resp = show_delete_page(&reminders, selector.page, selector.timezone); | ||||
|  | ||||
|                 let mut invoke = CommandInvoke::component(component); | ||||
|                 let _ = invoke.respond(&ctx, resp).await; | ||||
|                 let _ = component | ||||
|                     .create_interaction_response(&ctx, |f| { | ||||
|                         f.kind(InteractionResponseType::UpdateMessage).interaction_response_data( | ||||
|                             |d| { | ||||
|                                 send_as_initial_response(resp, d); | ||||
|                                 d | ||||
|                             }, | ||||
|                         ) | ||||
|                     }) | ||||
|                     .await; | ||||
|             } | ||||
|             ComponentDataModel::TodoPager(pager) => { | ||||
|                 if Some(component.user.id.0) == pager.user_id || pager.user_id.is_none() { | ||||
|                     let pool = ctx.data.read().await.get::<SQLPool>().cloned().unwrap(); | ||||
|  | ||||
|                     let values = if let Some(uid) = pager.user_id { | ||||
|                         sqlx::query!( | ||||
|                             "SELECT todos.id, value FROM todos | ||||
| @@ -171,7 +206,7 @@ impl ComponentDataModel { | ||||
| WHERE users.user = ?", | ||||
|                             uid, | ||||
|                         ) | ||||
|                         .fetch_all(&pool) | ||||
|                         .fetch_all(&data.database) | ||||
|                         .await | ||||
|                         .unwrap() | ||||
|                         .iter() | ||||
| @@ -184,7 +219,7 @@ impl ComponentDataModel { | ||||
| WHERE channels.channel = ?", | ||||
|                             cid, | ||||
|                         ) | ||||
|                         .fetch_all(&pool) | ||||
|                         .fetch_all(&data.database) | ||||
|                         .await | ||||
|                         .unwrap() | ||||
|                         .iter() | ||||
| @@ -197,7 +232,7 @@ impl ComponentDataModel { | ||||
| WHERE guilds.guild = ?", | ||||
|                             pager.guild_id, | ||||
|                         ) | ||||
|                         .fetch_all(&pool) | ||||
|                         .fetch_all(&data.database) | ||||
|                         .await | ||||
|                         .unwrap() | ||||
|                         .iter() | ||||
| @@ -215,15 +250,22 @@ impl ComponentDataModel { | ||||
|                         pager.guild_id, | ||||
|                     ); | ||||
|  | ||||
|                     let mut invoke = CommandInvoke::component(component); | ||||
|                     let _ = invoke.respond(&ctx, resp).await; | ||||
|                     let _ = component | ||||
|                         .create_interaction_response(&ctx, |f| { | ||||
|                             f.kind(InteractionResponseType::UpdateMessage) | ||||
|                                 .interaction_response_data(|d| { | ||||
|                                     send_as_initial_response(resp, d); | ||||
|                                     d | ||||
|                                 }) | ||||
|                         }) | ||||
|                         .await; | ||||
|                 } else { | ||||
|                     let _ = component | ||||
|                         .create_interaction_response(&ctx, |r| { | ||||
|                             r.kind(InteractionResponseType::ChannelMessageWithSource) | ||||
|                                 .interaction_response_data(|d| { | ||||
|                                     d.flags( | ||||
|                                         InteractionApplicationCommandCallbackDataFlags::EPHEMERAL, | ||||
|                                         MessageFlags::EPHEMERAL, | ||||
|                                     ) | ||||
|                                     .content("Only the user who performed the command can use these components") | ||||
|                                 }) | ||||
| @@ -233,11 +275,10 @@ impl ComponentDataModel { | ||||
|             } | ||||
|             ComponentDataModel::TodoSelector(selector) => { | ||||
|                 if Some(component.user.id.0) == selector.user_id || selector.user_id.is_none() { | ||||
|                     let pool = ctx.data.read().await.get::<SQLPool>().cloned().unwrap(); | ||||
|                     let selected_id = component.data.values.join(","); | ||||
|  | ||||
|                     sqlx::query!("DELETE FROM todos WHERE FIND_IN_SET(id, ?)", selected_id) | ||||
|                         .execute(&pool) | ||||
|                         .execute(&data.database) | ||||
|                         .await | ||||
|                         .unwrap(); | ||||
|  | ||||
| @@ -248,7 +289,7 @@ impl ComponentDataModel { | ||||
|                     selector.channel_id, | ||||
|                     selector.guild_id, | ||||
|                 ) | ||||
|                 .fetch_all(&pool) | ||||
|                 .fetch_all(&data.database) | ||||
|                 .await | ||||
|                 .unwrap() | ||||
|                 .iter() | ||||
| @@ -263,15 +304,22 @@ impl ComponentDataModel { | ||||
|                         selector.guild_id, | ||||
|                     ); | ||||
|  | ||||
|                     let mut invoke = CommandInvoke::component(component); | ||||
|                     let _ = invoke.respond(&ctx, resp).await; | ||||
|                     let _ = component | ||||
|                         .create_interaction_response(&ctx, |f| { | ||||
|                             f.kind(InteractionResponseType::UpdateMessage) | ||||
|                                 .interaction_response_data(|d| { | ||||
|                                     send_as_initial_response(resp, d); | ||||
|                                     d | ||||
|                                 }) | ||||
|                         }) | ||||
|                         .await; | ||||
|                 } else { | ||||
|                     let _ = component | ||||
|                         .create_interaction_response(&ctx, |r| { | ||||
|                             r.kind(InteractionResponseType::ChannelMessageWithSource) | ||||
|                                 .interaction_response_data(|d| { | ||||
|                                     d.flags( | ||||
|                                         InteractionApplicationCommandCallbackDataFlags::EPHEMERAL, | ||||
|                                         MessageFlags::EPHEMERAL, | ||||
|                                     ) | ||||
|                                     .content("Only the user who performed the command can use these components") | ||||
|                                 }) | ||||
| @@ -280,15 +328,87 @@ impl ComponentDataModel { | ||||
|                 } | ||||
|             } | ||||
|             ComponentDataModel::MacroPager(pager) => { | ||||
|                 let mut invoke = CommandInvoke::component(component); | ||||
|  | ||||
|                 let macros = CommandMacro::from_guild(ctx, invoke.guild_id().unwrap()).await; | ||||
|                 let macros = data.command_macros(component.guild_id.unwrap()).await.unwrap(); | ||||
|  | ||||
|                 let max_page = max_macro_page(¯os); | ||||
|                 let page = pager.next_page(max_page); | ||||
|  | ||||
|                 let resp = show_macro_page(¯os, page); | ||||
|                 let _ = invoke.respond(&ctx, resp).await; | ||||
|  | ||||
|                 let _ = component | ||||
|                     .create_interaction_response(&ctx, |f| { | ||||
|                         f.kind(InteractionResponseType::UpdateMessage).interaction_response_data( | ||||
|                             |d| { | ||||
|                                 send_as_initial_response(resp, d); | ||||
|                                 d | ||||
|                             }, | ||||
|                         ) | ||||
|                     }) | ||||
|                     .await; | ||||
|             } | ||||
|             ComponentDataModel::UndoReminder(undo_reminder) => { | ||||
|                 if component.user.id == undo_reminder.user_id { | ||||
|                     let reminder = | ||||
|                         Reminder::from_id(&data.database, undo_reminder.reminder_id).await; | ||||
|  | ||||
|                     if let Some(reminder) = reminder { | ||||
|                         match reminder.delete(&data.database).await { | ||||
|                             Ok(()) => { | ||||
|                                 let _ = component | ||||
|                                     .create_interaction_response(&ctx, |f| { | ||||
|                                         f.kind(InteractionResponseType::UpdateMessage) | ||||
|                                             .interaction_response_data(|d| { | ||||
|                                                 d.embed(|e| { | ||||
|                                                     e.title("Reminder Canceled") | ||||
|                                                         .description( | ||||
|                                                             "This reminder has been canceled.", | ||||
|                                                         ) | ||||
|                                                         .color(*THEME_COLOR) | ||||
|                                                 }) | ||||
|                                                 .components(|c| c) | ||||
|                                             }) | ||||
|                                     }) | ||||
|                                     .await; | ||||
|                             } | ||||
|                             Err(e) => { | ||||
|                                 warn!("Error canceling reminder: {:?}", e); | ||||
|  | ||||
|                                 let _ = component | ||||
|                                     .create_interaction_response(&ctx, |f| { | ||||
|                                         f.kind(InteractionResponseType::ChannelMessageWithSource) | ||||
|                                             .interaction_response_data(|d| { | ||||
|                                                 d.content( | ||||
|                                                     "The reminder could not be canceled: it may have already been deleted. Check `/del`!") | ||||
|                                                     .ephemeral(true) | ||||
|                                             }) | ||||
|                                     }) | ||||
|                                     .await; | ||||
|                             } | ||||
|                         } | ||||
|                     } else { | ||||
|                         let _ = component | ||||
|                             .create_interaction_response(&ctx, |f| { | ||||
|                                 f.kind(InteractionResponseType::ChannelMessageWithSource) | ||||
|                                     .interaction_response_data(|d| { | ||||
|                                         d.content( | ||||
|                                             "The reminder could not be canceled: it may have already been deleted. Check `/del`!") | ||||
|                                             .ephemeral(true) | ||||
|                                     }) | ||||
|                             }) | ||||
|                             .await; | ||||
|                     } | ||||
|                 } else { | ||||
|                     let _ = component | ||||
|                         .create_interaction_response(&ctx, |f| { | ||||
|                             f.kind(InteractionResponseType::ChannelMessageWithSource) | ||||
|                                 .interaction_response_data(|d| { | ||||
|                                     d.content( | ||||
|                                         "Only the user who performed the command can use this button.") | ||||
|                                         .ephemeral(true) | ||||
|                                 }) | ||||
|                         }) | ||||
|                         .await; | ||||
|                 } | ||||
|             } | ||||
|         } | ||||
|     } | ||||
| @@ -307,3 +427,9 @@ pub struct TodoSelector { | ||||
|     pub channel_id: Option<u64>, | ||||
|     pub guild_id: Option<u64>, | ||||
| } | ||||
|  | ||||
| #[derive(Serialize, Deserialize)] | ||||
| pub struct UndoReminder { | ||||
|     pub user_id: serenity::UserId, | ||||
|     pub reminder_id: u32, | ||||
| } | ||||
|   | ||||
| @@ -1,8 +1,10 @@ | ||||
| // todo split pager out into a single struct | ||||
| use chrono_tz::Tz; | ||||
| use poise::serenity_prelude::{ | ||||
|     builder::CreateComponents, model::application::component::ButtonStyle, | ||||
| }; | ||||
| use serde::{Deserialize, Serialize}; | ||||
| use serde_repr::*; | ||||
| use serenity::{builder::CreateComponents, model::interactions::message_component::ButtonStyle}; | ||||
|  | ||||
| use crate::{component_models::ComponentDataModel, models::reminder::look_flags::LookFlags}; | ||||
|  | ||||
|   | ||||
| @@ -1,31 +1,29 @@ | ||||
| pub const DAY: u64 = 86_400; | ||||
| pub const HOUR: u64 = 3_600; | ||||
| pub const MINUTE: u64 = 60; | ||||
| pub const EMBED_DESCRIPTION_MAX_LENGTH: usize = 4000; | ||||
|  | ||||
| pub const EMBED_DESCRIPTION_MAX_LENGTH: usize = 4096; | ||||
| pub const SELECT_MAX_ENTRIES: usize = 25; | ||||
|  | ||||
| pub const CHARACTERS: &str = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_"; | ||||
|  | ||||
| const THEME_COLOR_FALLBACK: u32 = 0x8fb677; | ||||
| pub const MACRO_MAX_COMMANDS: usize = 5; | ||||
|  | ||||
| use std::{collections::HashSet, env, iter::FromIterator}; | ||||
|  | ||||
| use poise::serenity_prelude::model::prelude::AttachmentType; | ||||
| use regex::Regex; | ||||
| use serenity::http::AttachmentType; | ||||
|  | ||||
| lazy_static! { | ||||
|     pub static ref DEFAULT_AVATAR: AttachmentType<'static> = ( | ||||
|         include_bytes!(concat!( | ||||
|             env!("CARGO_MANIFEST_DIR"), | ||||
|             "/assets/", | ||||
|             env!("WEBHOOK_AVATAR", "WEBHOOK_AVATAR not provided for compilation") | ||||
|         )) as &[u8], | ||||
|         env!("WEBHOOK_AVATAR"), | ||||
|         include_bytes!(concat!(env!("CARGO_MANIFEST_DIR"), "/assets/webhook.jpg")) as &[u8], | ||||
|         "webhook.jpg", | ||||
|     ) | ||||
|         .into(); | ||||
|     pub static ref REGEX_CHANNEL_USER: Regex = Regex::new(r#"\s*<(#|@)(?:!)?(\d+)>\s*"#).unwrap(); | ||||
|     pub static ref SUBSCRIPTION_ROLES: HashSet<u64> = HashSet::from_iter( | ||||
|         env::var("SUBSCRIPTION_ROLES") | ||||
|         env::var("PATREON_ROLE_ID") | ||||
|             .map(|var| var | ||||
|                 .split(',') | ||||
|                 .filter_map(|item| { item.parse::<u64>().ok() }) | ||||
| @@ -33,16 +31,12 @@ lazy_static! { | ||||
|             .unwrap_or_else(|_| Vec::new()) | ||||
|     ); | ||||
|     pub static ref CNC_GUILD: Option<u64> = | ||||
|         env::var("CNC_GUILD").map(|var| var.parse::<u64>().ok()).ok().flatten(); | ||||
|     pub static ref MIN_INTERVAL: i64 = env::var("MIN_INTERVAL") | ||||
|         .ok() | ||||
|         .map(|inner| inner.parse::<i64>().ok()) | ||||
|         .flatten() | ||||
|         .unwrap_or(600); | ||||
|         env::var("PATREON_GUILD_ID").map(|var| var.parse::<u64>().ok()).ok().flatten(); | ||||
|     pub static ref MIN_INTERVAL: i64 = | ||||
|         env::var("MIN_INTERVAL").ok().and_then(|inner| inner.parse::<i64>().ok()).unwrap_or(600); | ||||
|     pub static ref MAX_TIME: i64 = env::var("MAX_TIME") | ||||
|         .ok() | ||||
|         .map(|inner| inner.parse::<i64>().ok()) | ||||
|         .flatten() | ||||
|         .and_then(|inner| inner.parse::<i64>().ok()) | ||||
|         .unwrap_or(60 * 60 * 24 * 365 * 50); | ||||
|     pub static ref LOCAL_TIMEZONE: String = | ||||
|         env::var("LOCAL_TIMEZONE").unwrap_or_else(|_| "UTC".to_string()); | ||||
| @@ -50,5 +44,5 @@ lazy_static! { | ||||
|         .map_or(THEME_COLOR_FALLBACK, |inner| u32::from_str_radix(&inner, 16) | ||||
|             .unwrap_or(THEME_COLOR_FALLBACK)); | ||||
|     pub static ref PYTHON_LOCATION: String = | ||||
|         env::var("PYTHON_LOCATION").unwrap_or_else(|_| "venv/bin/python3".to_string()); | ||||
|         env::var("PYTHON_LOCATION").unwrap_or_else(|_| "/usr/bin/python3".to_string()); | ||||
| } | ||||
|   | ||||
							
								
								
									
										114
									
								
								src/event_handlers.rs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										114
									
								
								src/event_handlers.rs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,114 @@ | ||||
| use std::{collections::HashMap, env}; | ||||
|  | ||||
| use log::error; | ||||
| use poise::{ | ||||
|     serenity_prelude as serenity, | ||||
|     serenity_prelude::{model::application::interaction::Interaction, utils::shard_id}, | ||||
| }; | ||||
|  | ||||
| use crate::{component_models::ComponentDataModel, Data, Error, THEME_COLOR}; | ||||
|  | ||||
| pub async fn listener( | ||||
|     ctx: &serenity::Context, | ||||
|     event: &poise::Event<'_>, | ||||
|     data: &Data, | ||||
| ) -> Result<(), Error> { | ||||
|     match event { | ||||
|         poise::Event::Ready { .. } => { | ||||
|             ctx.set_activity(serenity::Activity::watching("for /remind")).await; | ||||
|         } | ||||
|         poise::Event::ChannelDelete { channel } => { | ||||
|             sqlx::query!("DELETE FROM channels WHERE channel = ?", channel.id.as_u64()) | ||||
|                 .execute(&data.database) | ||||
|                 .await | ||||
|                 .unwrap(); | ||||
|         } | ||||
|         poise::Event::GuildCreate { guild, is_new } => { | ||||
|             if *is_new { | ||||
|                 let guild_id = guild.id.as_u64().to_owned(); | ||||
|  | ||||
|                 sqlx::query!("INSERT IGNORE INTO guilds (guild) VALUES (?)", guild_id) | ||||
|                     .execute(&data.database) | ||||
|                     .await?; | ||||
|  | ||||
|                 if let Err(e) = post_guild_count(ctx, &data.http, guild_id).await { | ||||
|                     error!("DiscordBotList: {:?}", e); | ||||
|                 } | ||||
|  | ||||
|                 let default_channel = guild.default_channel_guaranteed(); | ||||
|  | ||||
|                 if let Some(default_channel) = default_channel { | ||||
|                     default_channel | ||||
|                         .send_message(&ctx, |m| { | ||||
|                             m.embed(|e| { | ||||
|                                 e.title("Thank you for adding Reminder Bot!").description( | ||||
|                                     "To get started: | ||||
| • Set your timezone with `/timezone` | ||||
| • Set up permissions in Server Settings 🠚 Integrations 🠚 Reminder Bot (desktop only) | ||||
| • Create your first reminder with `/remind` | ||||
|  | ||||
| __Support__ | ||||
| If you need any support, please come and ask us! Join our [Discord](https://discord.jellywx.com). | ||||
|  | ||||
| __Updates__ | ||||
| To stay up to date on the latest features and fixes, join our [Discord](https://discord.jellywx.com). | ||||
| ", | ||||
|                                 ).color(*THEME_COLOR) | ||||
|                             }) | ||||
|                         }) | ||||
|                         .await?; | ||||
|                 } | ||||
|             } | ||||
|         } | ||||
|         poise::Event::GuildDelete { incomplete, .. } => { | ||||
|             let _ = sqlx::query!("DELETE FROM guilds WHERE guild = ?", incomplete.id.0) | ||||
|                 .execute(&data.database) | ||||
|                 .await; | ||||
|         } | ||||
|         poise::Event::InteractionCreate { interaction } => { | ||||
|             if let Interaction::MessageComponent(component) = interaction { | ||||
|                 let component_model = ComponentDataModel::from_custom_id(&component.data.custom_id); | ||||
|  | ||||
|                 component_model.act(ctx, data, component).await; | ||||
|             } | ||||
|         } | ||||
|         _ => {} | ||||
|     } | ||||
|  | ||||
|     Ok(()) | ||||
| } | ||||
|  | ||||
| async fn post_guild_count( | ||||
|     ctx: &serenity::Context, | ||||
|     http: &reqwest::Client, | ||||
|     guild_id: u64, | ||||
| ) -> Result<(), reqwest::Error> { | ||||
|     if let Ok(token) = env::var("DISCORDBOTS_TOKEN") { | ||||
|         let shard_count = ctx.cache.shard_count(); | ||||
|         let current_shard_id = shard_id(guild_id, shard_count); | ||||
|  | ||||
|         let guild_count = ctx | ||||
|             .cache | ||||
|             .guilds() | ||||
|             .iter() | ||||
|             .filter(|g| shard_id(g.as_u64().to_owned(), shard_count) == current_shard_id) | ||||
|             .count() as u64; | ||||
|  | ||||
|         let mut hm = HashMap::new(); | ||||
|         hm.insert("server_count", guild_count); | ||||
|         hm.insert("shard_id", current_shard_id); | ||||
|         hm.insert("shard_count", shard_count); | ||||
|  | ||||
|         http.post( | ||||
|             format!("https://top.gg/api/bots/{}/stats", ctx.cache.current_user_id().as_u64()) | ||||
|                 .as_str(), | ||||
|         ) | ||||
|         .header("Authorization", token) | ||||
|         .json(&hm) | ||||
|         .send() | ||||
|         .await | ||||
|         .map(|_| ()) | ||||
|     } else { | ||||
|         Ok(()) | ||||
|     } | ||||
| } | ||||
							
								
								
									
										692
									
								
								src/framework.rs
									
									
									
									
									
								
							
							
						
						
									
										692
									
								
								src/framework.rs
									
									
									
									
									
								
							| @@ -1,692 +0,0 @@ | ||||
| // todo move framework to its own module, split out permission checks | ||||
|  | ||||
| use std::{ | ||||
|     collections::{HashMap, HashSet}, | ||||
|     hash::{Hash, Hasher}, | ||||
|     sync::Arc, | ||||
| }; | ||||
|  | ||||
| use log::info; | ||||
| use serde::{Deserialize, Serialize}; | ||||
| use serenity::{ | ||||
|     builder::{CreateApplicationCommands, CreateComponents, CreateEmbed}, | ||||
|     cache::Cache, | ||||
|     client::Context, | ||||
|     futures::prelude::future::BoxFuture, | ||||
|     http::Http, | ||||
|     model::{ | ||||
|         guild::Guild, | ||||
|         id::{ChannelId, GuildId, RoleId, UserId}, | ||||
|         interactions::{ | ||||
|             application_command::{ | ||||
|                 ApplicationCommand, ApplicationCommandInteraction, ApplicationCommandOptionType, | ||||
|             }, | ||||
|             message_component::MessageComponentInteraction, | ||||
|             InteractionApplicationCommandCallbackDataFlags, InteractionResponseType, | ||||
|         }, | ||||
|         prelude::application_command::ApplicationCommandInteractionDataOption, | ||||
|     }, | ||||
|     prelude::TypeMapKey, | ||||
|     Result as SerenityResult, | ||||
| }; | ||||
|  | ||||
| use crate::SQLPool; | ||||
|  | ||||
| pub struct CreateGenericResponse { | ||||
|     content: String, | ||||
|     embed: Option<CreateEmbed>, | ||||
|     components: Option<CreateComponents>, | ||||
|     flags: InteractionApplicationCommandCallbackDataFlags, | ||||
| } | ||||
|  | ||||
| impl CreateGenericResponse { | ||||
|     pub fn new() -> Self { | ||||
|         Self { | ||||
|             content: "".to_string(), | ||||
|             embed: None, | ||||
|             components: None, | ||||
|             flags: InteractionApplicationCommandCallbackDataFlags::empty(), | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     pub fn ephemeral(mut self) -> Self { | ||||
|         self.flags.insert(InteractionApplicationCommandCallbackDataFlags::EPHEMERAL); | ||||
|  | ||||
|         self | ||||
|     } | ||||
|  | ||||
|     pub fn content<D: ToString>(mut self, content: D) -> Self { | ||||
|         self.content = content.to_string(); | ||||
|  | ||||
|         self | ||||
|     } | ||||
|  | ||||
|     pub fn embed<F: FnOnce(&mut CreateEmbed) -> &mut CreateEmbed>(mut self, f: F) -> Self { | ||||
|         let mut embed = CreateEmbed::default(); | ||||
|         f(&mut embed); | ||||
|  | ||||
|         self.embed = Some(embed); | ||||
|         self | ||||
|     } | ||||
|  | ||||
|     pub fn components<F: FnOnce(&mut CreateComponents) -> &mut CreateComponents>( | ||||
|         mut self, | ||||
|         f: F, | ||||
|     ) -> Self { | ||||
|         let mut components = CreateComponents::default(); | ||||
|         f(&mut components); | ||||
|  | ||||
|         self.components = Some(components); | ||||
|         self | ||||
|     } | ||||
| } | ||||
|  | ||||
| #[derive(Clone)] | ||||
| enum InvokeModel { | ||||
|     Slash(ApplicationCommandInteraction), | ||||
|     Component(MessageComponentInteraction), | ||||
| } | ||||
|  | ||||
| #[derive(Clone)] | ||||
| pub struct CommandInvoke { | ||||
|     model: InvokeModel, | ||||
|     already_responded: bool, | ||||
|     deferred: bool, | ||||
| } | ||||
|  | ||||
| impl CommandInvoke { | ||||
|     pub fn component(component: MessageComponentInteraction) -> Self { | ||||
|         Self { model: InvokeModel::Component(component), already_responded: false, deferred: false } | ||||
|     } | ||||
|  | ||||
|     fn slash(interaction: ApplicationCommandInteraction) -> Self { | ||||
|         Self { model: InvokeModel::Slash(interaction), already_responded: false, deferred: false } | ||||
|     } | ||||
|  | ||||
|     pub async fn defer(&mut self, http: impl AsRef<Http>) { | ||||
|         if !self.deferred { | ||||
|             match &self.model { | ||||
|                 InvokeModel::Slash(i) => { | ||||
|                     i.create_interaction_response(http, |r| { | ||||
|                         r.kind(InteractionResponseType::DeferredChannelMessageWithSource) | ||||
|                     }) | ||||
|                     .await | ||||
|                     .unwrap(); | ||||
|  | ||||
|                     self.deferred = true; | ||||
|                 } | ||||
|                 InvokeModel::Component(i) => { | ||||
|                     i.create_interaction_response(http, |r| { | ||||
|                         r.kind(InteractionResponseType::DeferredChannelMessageWithSource) | ||||
|                     }) | ||||
|                     .await | ||||
|                     .unwrap(); | ||||
|  | ||||
|                     self.deferred = true; | ||||
|                 } | ||||
|             } | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     pub fn channel_id(&self) -> ChannelId { | ||||
|         match &self.model { | ||||
|             InvokeModel::Slash(i) => i.channel_id, | ||||
|             InvokeModel::Component(i) => i.channel_id, | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     pub fn guild_id(&self) -> Option<GuildId> { | ||||
|         match &self.model { | ||||
|             InvokeModel::Slash(i) => i.guild_id, | ||||
|             InvokeModel::Component(i) => i.guild_id, | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     pub fn guild(&self, cache: impl AsRef<Cache>) -> Option<Guild> { | ||||
|         self.guild_id().map(|id| id.to_guild_cached(cache)).flatten() | ||||
|     } | ||||
|  | ||||
|     pub fn author_id(&self) -> UserId { | ||||
|         match &self.model { | ||||
|             InvokeModel::Slash(i) => i.user.id, | ||||
|             InvokeModel::Component(i) => i.user.id, | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     pub async fn respond( | ||||
|         &mut self, | ||||
|         http: impl AsRef<Http>, | ||||
|         generic_response: CreateGenericResponse, | ||||
|     ) -> SerenityResult<()> { | ||||
|         match &self.model { | ||||
|             InvokeModel::Slash(i) => { | ||||
|                 if self.already_responded { | ||||
|                     i.create_followup_message(http, |d| { | ||||
|                         d.allowed_mentions(|m| m.empty_parse()); | ||||
|                         d.content(generic_response.content); | ||||
|  | ||||
|                         if let Some(embed) = generic_response.embed { | ||||
|                             d.add_embed(embed); | ||||
|                         } | ||||
|  | ||||
|                         if let Some(components) = generic_response.components { | ||||
|                             d.components(|c| { | ||||
|                                 *c = components; | ||||
|                                 c | ||||
|                             }); | ||||
|                         } | ||||
|  | ||||
|                         d | ||||
|                     }) | ||||
|                     .await | ||||
|                     .map(|_| ()) | ||||
|                 } else if self.deferred { | ||||
|                     i.edit_original_interaction_response(http, |d| { | ||||
|                         d.allowed_mentions(|m| m.empty_parse()); | ||||
|                         d.content(generic_response.content); | ||||
|  | ||||
|                         if let Some(embed) = generic_response.embed { | ||||
|                             d.add_embed(embed); | ||||
|                         } | ||||
|  | ||||
|                         if let Some(components) = generic_response.components { | ||||
|                             d.components(|c| { | ||||
|                                 *c = components; | ||||
|                                 c | ||||
|                             }); | ||||
|                         } | ||||
|  | ||||
|                         d | ||||
|                     }) | ||||
|                     .await | ||||
|                     .map(|_| ()) | ||||
|                 } else { | ||||
|                     i.create_interaction_response(http, |r| { | ||||
|                         r.kind(InteractionResponseType::ChannelMessageWithSource) | ||||
|                             .interaction_response_data(|d| { | ||||
|                                 d.allowed_mentions(|m| m.empty_parse()); | ||||
|                                 d.content(generic_response.content); | ||||
|  | ||||
|                                 if let Some(embed) = generic_response.embed { | ||||
|                                     d.add_embed(embed); | ||||
|                                 } | ||||
|  | ||||
|                                 if let Some(components) = generic_response.components { | ||||
|                                     d.components(|c| { | ||||
|                                         *c = components; | ||||
|                                         c | ||||
|                                     }); | ||||
|                                 } | ||||
|  | ||||
|                                 d | ||||
|                             }) | ||||
|                     }) | ||||
|                     .await | ||||
|                     .map(|_| ()) | ||||
|                 } | ||||
|             } | ||||
|             InvokeModel::Component(i) => i | ||||
|                 .create_interaction_response(http, |r| { | ||||
|                     r.kind(InteractionResponseType::UpdateMessage).interaction_response_data(|d| { | ||||
|                         d.allowed_mentions(|m| m.empty_parse()); | ||||
|                         d.content(generic_response.content); | ||||
|  | ||||
|                         if let Some(embed) = generic_response.embed { | ||||
|                             d.add_embed(embed); | ||||
|                         } | ||||
|  | ||||
|                         if let Some(components) = generic_response.components { | ||||
|                             d.components(|c| { | ||||
|                                 *c = components; | ||||
|                                 c | ||||
|                             }); | ||||
|                         } | ||||
|  | ||||
|                         d | ||||
|                     }) | ||||
|                 }) | ||||
|                 .await | ||||
|                 .map(|_| ()), | ||||
|         }?; | ||||
|  | ||||
|         self.already_responded = true; | ||||
|  | ||||
|         Ok(()) | ||||
|     } | ||||
| } | ||||
|  | ||||
| #[derive(Debug)] | ||||
| pub struct Arg { | ||||
|     pub name: &'static str, | ||||
|     pub description: &'static str, | ||||
|     pub kind: ApplicationCommandOptionType, | ||||
|     pub required: bool, | ||||
|     pub options: &'static [&'static Self], | ||||
| } | ||||
|  | ||||
| #[derive(Serialize, Deserialize, Clone)] | ||||
| pub enum OptionValue { | ||||
|     String(String), | ||||
|     Integer(i64), | ||||
|     Boolean(bool), | ||||
|     User(UserId), | ||||
|     Channel(ChannelId), | ||||
|     Role(RoleId), | ||||
|     Mentionable(u64), | ||||
|     Number(f64), | ||||
| } | ||||
|  | ||||
| impl OptionValue { | ||||
|     pub fn as_i64(&self) -> Option<i64> { | ||||
|         match self { | ||||
|             OptionValue::Integer(i) => Some(*i), | ||||
|             _ => None, | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     pub fn as_bool(&self) -> Option<bool> { | ||||
|         match self { | ||||
|             OptionValue::Boolean(b) => Some(*b), | ||||
|             _ => None, | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     pub fn as_channel_id(&self) -> Option<ChannelId> { | ||||
|         match self { | ||||
|             OptionValue::Channel(c) => Some(*c), | ||||
|             _ => None, | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     pub fn to_string(&self) -> String { | ||||
|         match self { | ||||
|             OptionValue::String(s) => s.to_string(), | ||||
|             OptionValue::Integer(i) => i.to_string(), | ||||
|             OptionValue::Boolean(b) => b.to_string(), | ||||
|             OptionValue::User(u) => u.to_string(), | ||||
|             OptionValue::Channel(c) => c.to_string(), | ||||
|             OptionValue::Role(r) => r.to_string(), | ||||
|             OptionValue::Mentionable(m) => m.to_string(), | ||||
|             OptionValue::Number(n) => n.to_string(), | ||||
|         } | ||||
|     } | ||||
| } | ||||
|  | ||||
| #[derive(Serialize, Deserialize, Clone)] | ||||
| pub struct CommandOptions { | ||||
|     pub command: String, | ||||
|     pub subcommand: Option<String>, | ||||
|     pub subcommand_group: Option<String>, | ||||
|     pub options: HashMap<String, OptionValue>, | ||||
| } | ||||
|  | ||||
| impl CommandOptions { | ||||
|     pub fn get(&self, key: &str) -> Option<&OptionValue> { | ||||
|         self.options.get(key) | ||||
|     } | ||||
| } | ||||
|  | ||||
| impl CommandOptions { | ||||
|     fn new(command: &'static Command) -> Self { | ||||
|         Self { | ||||
|             command: command.names[0].to_string(), | ||||
|             subcommand: None, | ||||
|             subcommand_group: None, | ||||
|             options: Default::default(), | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     fn populate(mut self, interaction: &ApplicationCommandInteraction) -> Self { | ||||
|         fn match_option( | ||||
|             option: ApplicationCommandInteractionDataOption, | ||||
|             cmd_opts: &mut CommandOptions, | ||||
|         ) { | ||||
|             match option.kind { | ||||
|                 ApplicationCommandOptionType::SubCommand => { | ||||
|                     cmd_opts.subcommand = Some(option.name); | ||||
|  | ||||
|                     for opt in option.options { | ||||
|                         match_option(opt, cmd_opts); | ||||
|                     } | ||||
|                 } | ||||
|                 ApplicationCommandOptionType::SubCommandGroup => { | ||||
|                     cmd_opts.subcommand_group = Some(option.name); | ||||
|  | ||||
|                     for opt in option.options { | ||||
|                         match_option(opt, cmd_opts); | ||||
|                     } | ||||
|                 } | ||||
|                 ApplicationCommandOptionType::String => { | ||||
|                     cmd_opts.options.insert( | ||||
|                         option.name, | ||||
|                         OptionValue::String(option.value.unwrap().as_str().unwrap().to_string()), | ||||
|                     ); | ||||
|                 } | ||||
|                 ApplicationCommandOptionType::Integer => { | ||||
|                     cmd_opts.options.insert( | ||||
|                         option.name, | ||||
|                         OptionValue::Integer(option.value.map(|m| m.as_i64()).flatten().unwrap()), | ||||
|                     ); | ||||
|                 } | ||||
|                 ApplicationCommandOptionType::Boolean => { | ||||
|                     cmd_opts.options.insert( | ||||
|                         option.name, | ||||
|                         OptionValue::Boolean(option.value.map(|m| m.as_bool()).flatten().unwrap()), | ||||
|                     ); | ||||
|                 } | ||||
|                 ApplicationCommandOptionType::User => { | ||||
|                     cmd_opts.options.insert( | ||||
|                         option.name, | ||||
|                         OptionValue::User(UserId( | ||||
|                             option | ||||
|                                 .value | ||||
|                                 .map(|m| m.as_str().map(|s| s.parse::<u64>().ok())) | ||||
|                                 .flatten() | ||||
|                                 .flatten() | ||||
|                                 .unwrap(), | ||||
|                         )), | ||||
|                     ); | ||||
|                 } | ||||
|                 ApplicationCommandOptionType::Channel => { | ||||
|                     cmd_opts.options.insert( | ||||
|                         option.name, | ||||
|                         OptionValue::Channel(ChannelId( | ||||
|                             option | ||||
|                                 .value | ||||
|                                 .map(|m| m.as_str().map(|s| s.parse::<u64>().ok())) | ||||
|                                 .flatten() | ||||
|                                 .flatten() | ||||
|                                 .unwrap(), | ||||
|                         )), | ||||
|                     ); | ||||
|                 } | ||||
|                 ApplicationCommandOptionType::Role => { | ||||
|                     cmd_opts.options.insert( | ||||
|                         option.name, | ||||
|                         OptionValue::Role(RoleId( | ||||
|                             option | ||||
|                                 .value | ||||
|                                 .map(|m| m.as_str().map(|s| s.parse::<u64>().ok())) | ||||
|                                 .flatten() | ||||
|                                 .flatten() | ||||
|                                 .unwrap(), | ||||
|                         )), | ||||
|                     ); | ||||
|                 } | ||||
|                 ApplicationCommandOptionType::Mentionable => { | ||||
|                     cmd_opts.options.insert( | ||||
|                         option.name, | ||||
|                         OptionValue::Mentionable( | ||||
|                             option.value.map(|m| m.as_u64()).flatten().unwrap(), | ||||
|                         ), | ||||
|                     ); | ||||
|                 } | ||||
|                 ApplicationCommandOptionType::Number => { | ||||
|                     cmd_opts.options.insert( | ||||
|                         option.name, | ||||
|                         OptionValue::Number(option.value.map(|m| m.as_f64()).flatten().unwrap()), | ||||
|                     ); | ||||
|                 } | ||||
|                 _ => {} | ||||
|             } | ||||
|         } | ||||
|  | ||||
|         for option in &interaction.data.options { | ||||
|             match_option(option.clone(), &mut self) | ||||
|         } | ||||
|  | ||||
|         self | ||||
|     } | ||||
| } | ||||
|  | ||||
| pub enum HookResult { | ||||
|     Continue, | ||||
|     Halt, | ||||
| } | ||||
|  | ||||
| type SlashCommandFn = | ||||
|     for<'fut> fn(&'fut Context, &'fut mut CommandInvoke, CommandOptions) -> BoxFuture<'fut, ()>; | ||||
|  | ||||
| type MultiCommandFn = for<'fut> fn(&'fut Context, &'fut mut CommandInvoke) -> BoxFuture<'fut, ()>; | ||||
|  | ||||
| pub type HookFn = for<'fut> fn( | ||||
|     &'fut Context, | ||||
|     &'fut mut CommandInvoke, | ||||
|     &'fut CommandOptions, | ||||
| ) -> BoxFuture<'fut, HookResult>; | ||||
|  | ||||
| pub enum CommandFnType { | ||||
|     Slash(SlashCommandFn), | ||||
|     Multi(MultiCommandFn), | ||||
| } | ||||
|  | ||||
| pub struct Hook { | ||||
|     pub fun: HookFn, | ||||
|     pub uuid: u128, | ||||
| } | ||||
|  | ||||
| impl PartialEq for Hook { | ||||
|     fn eq(&self, other: &Self) -> bool { | ||||
|         self.uuid == other.uuid | ||||
|     } | ||||
| } | ||||
|  | ||||
| pub struct Command { | ||||
|     pub fun: CommandFnType, | ||||
|  | ||||
|     pub names: &'static [&'static str], | ||||
|  | ||||
|     pub desc: &'static str, | ||||
|     pub examples: &'static [&'static str], | ||||
|     pub group: &'static str, | ||||
|  | ||||
|     pub args: &'static [&'static Arg], | ||||
|  | ||||
|     pub can_blacklist: bool, | ||||
|     pub supports_dm: bool, | ||||
|  | ||||
|     pub hooks: &'static [&'static Hook], | ||||
| } | ||||
|  | ||||
| impl Hash for Command { | ||||
|     fn hash<H: Hasher>(&self, state: &mut H) { | ||||
|         self.names[0].hash(state) | ||||
|     } | ||||
| } | ||||
|  | ||||
| impl PartialEq for Command { | ||||
|     fn eq(&self, other: &Self) -> bool { | ||||
|         self.names[0] == other.names[0] | ||||
|     } | ||||
| } | ||||
|  | ||||
| impl Eq for Command {} | ||||
|  | ||||
| pub struct RegexFramework { | ||||
|     pub commands_map: HashMap<String, &'static Command>, | ||||
|     pub commands: HashSet<&'static Command>, | ||||
|     ignore_bots: bool, | ||||
|     dm_enabled: bool, | ||||
|     debug_guild: Option<GuildId>, | ||||
|     hooks: Vec<&'static Hook>, | ||||
| } | ||||
|  | ||||
| impl TypeMapKey for RegexFramework { | ||||
|     type Value = Arc<RegexFramework>; | ||||
| } | ||||
|  | ||||
| impl RegexFramework { | ||||
|     pub fn new() -> Self { | ||||
|         Self { | ||||
|             commands_map: HashMap::new(), | ||||
|             commands: HashSet::new(), | ||||
|             ignore_bots: true, | ||||
|             dm_enabled: true, | ||||
|             debug_guild: None, | ||||
|             hooks: vec![], | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     pub fn ignore_bots(mut self, ignore_bots: bool) -> Self { | ||||
|         self.ignore_bots = ignore_bots; | ||||
|  | ||||
|         self | ||||
|     } | ||||
|  | ||||
|     pub fn dm_enabled(mut self, dm_enabled: bool) -> Self { | ||||
|         self.dm_enabled = dm_enabled; | ||||
|  | ||||
|         self | ||||
|     } | ||||
|  | ||||
|     pub fn add_hook(mut self, fun: &'static Hook) -> Self { | ||||
|         self.hooks.push(fun); | ||||
|  | ||||
|         self | ||||
|     } | ||||
|  | ||||
|     pub fn add_command(mut self, command: &'static Command) -> Self { | ||||
|         self.commands.insert(command); | ||||
|  | ||||
|         for name in command.names { | ||||
|             self.commands_map.insert(name.to_string(), command); | ||||
|         } | ||||
|  | ||||
|         self | ||||
|     } | ||||
|  | ||||
|     pub fn debug_guild(mut self, guild_id: Option<GuildId>) -> Self { | ||||
|         self.debug_guild = guild_id; | ||||
|  | ||||
|         self | ||||
|     } | ||||
|  | ||||
|     fn _populate_commands<'a>( | ||||
|         &self, | ||||
|         commands: &'a mut CreateApplicationCommands, | ||||
|     ) -> &'a mut CreateApplicationCommands { | ||||
|         for command in &self.commands { | ||||
|             commands.create_application_command(|c| { | ||||
|                 c.name(command.names[0]).description(command.desc); | ||||
|  | ||||
|                 for arg in command.args { | ||||
|                     c.create_option(|o| { | ||||
|                         o.name(arg.name) | ||||
|                             .description(arg.description) | ||||
|                             .kind(arg.kind) | ||||
|                             .required(arg.required); | ||||
|  | ||||
|                         for option in arg.options { | ||||
|                             o.create_sub_option(|s| { | ||||
|                                 s.name(option.name) | ||||
|                                     .description(option.description) | ||||
|                                     .kind(option.kind) | ||||
|                                     .required(option.required); | ||||
|  | ||||
|                                 for sub_option in option.options { | ||||
|                                     s.create_sub_option(|ss| { | ||||
|                                         ss.name(sub_option.name) | ||||
|                                             .description(sub_option.description) | ||||
|                                             .kind(sub_option.kind) | ||||
|                                             .required(sub_option.required) | ||||
|                                     }); | ||||
|                                 } | ||||
|  | ||||
|                                 s | ||||
|                             }); | ||||
|                         } | ||||
|  | ||||
|                         o | ||||
|                     }); | ||||
|                 } | ||||
|  | ||||
|                 c | ||||
|             }); | ||||
|         } | ||||
|  | ||||
|         commands | ||||
|     } | ||||
|  | ||||
|     pub async fn build_slash(&self, http: impl AsRef<Http>) { | ||||
|         info!("Building slash commands..."); | ||||
|  | ||||
|         match self.debug_guild { | ||||
|             None => { | ||||
|                 ApplicationCommand::set_global_application_commands(&http, |c| { | ||||
|                     self._populate_commands(c) | ||||
|                 }) | ||||
|                 .await | ||||
|                 .unwrap(); | ||||
|             } | ||||
|             Some(debug_guild) => { | ||||
|                 debug_guild | ||||
|                     .set_application_commands(&http, |c| self._populate_commands(c)) | ||||
|                     .await | ||||
|                     .unwrap(); | ||||
|             } | ||||
|         } | ||||
|  | ||||
|         info!("Slash commands built!"); | ||||
|     } | ||||
|  | ||||
|     pub async fn execute(&self, ctx: Context, interaction: ApplicationCommandInteraction) { | ||||
|         { | ||||
|             if let Some(guild_id) = interaction.guild_id { | ||||
|                 let pool = ctx.data.read().await.get::<SQLPool>().cloned().unwrap(); | ||||
|                 let _ = sqlx::query!("INSERT IGNORE INTO guilds (guild) VALUES (?)", guild_id.0) | ||||
|                     .execute(&pool) | ||||
|                     .await; | ||||
|             } | ||||
|         } | ||||
|  | ||||
|         let command = { | ||||
|             self.commands_map | ||||
|                 .get(&interaction.data.name) | ||||
|                 .expect(&format!("Received invalid command: {}", interaction.data.name)) | ||||
|         }; | ||||
|  | ||||
|         let args = CommandOptions::new(command).populate(&interaction); | ||||
|         let mut command_invoke = CommandInvoke::slash(interaction); | ||||
|  | ||||
|         for hook in command.hooks { | ||||
|             match (hook.fun)(&ctx, &mut command_invoke, &args).await { | ||||
|                 HookResult::Continue => {} | ||||
|                 HookResult::Halt => { | ||||
|                     return; | ||||
|                 } | ||||
|             } | ||||
|         } | ||||
|  | ||||
|         for hook in &self.hooks { | ||||
|             match (hook.fun)(&ctx, &mut command_invoke, &args).await { | ||||
|                 HookResult::Continue => {} | ||||
|                 HookResult::Halt => { | ||||
|                     return; | ||||
|                 } | ||||
|             } | ||||
|         } | ||||
|  | ||||
|         match command.fun { | ||||
|             CommandFnType::Slash(t) => t(&ctx, &mut command_invoke, args).await, | ||||
|             CommandFnType::Multi(m) => m(&ctx, &mut command_invoke).await, | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     pub async fn run_command_from_options( | ||||
|         &self, | ||||
|         ctx: &Context, | ||||
|         command_invoke: &mut CommandInvoke, | ||||
|         command_options: CommandOptions, | ||||
|     ) { | ||||
|         let command = { | ||||
|             self.commands_map | ||||
|                 .get(&command_options.command) | ||||
|                 .expect(&format!("Received invalid command: {}", command_options.command)) | ||||
|         }; | ||||
|  | ||||
|         match command.fun { | ||||
|             CommandFnType::Slash(t) => t(&ctx, command_invoke, command_options).await, | ||||
|             CommandFnType::Multi(m) => m(&ctx, command_invoke).await, | ||||
|         } | ||||
|     } | ||||
| } | ||||
							
								
								
									
										166
									
								
								src/hooks.rs
									
									
									
									
									
								
							
							
						
						
									
										166
									
								
								src/hooks.rs
									
									
									
									
									
								
							| @@ -1,107 +1,79 @@ | ||||
| use regex_command_attr::check; | ||||
| use serenity::{client::Context, model::channel::Channel}; | ||||
|  | ||||
| use crate::{ | ||||
|     framework::{CommandInvoke, CommandOptions, CreateGenericResponse, HookResult}, | ||||
|     moderation_cmds, RecordingMacros, | ||||
| use poise::{ | ||||
|     serenity_prelude::model::channel::Channel, ApplicationCommandOrAutocompleteInteraction, | ||||
| }; | ||||
|  | ||||
| #[check] | ||||
| pub async fn guild_only( | ||||
|     ctx: &Context, | ||||
|     invoke: &mut CommandInvoke, | ||||
|     _args: &CommandOptions, | ||||
| ) -> HookResult { | ||||
|     if invoke.guild_id().is_some() { | ||||
|         HookResult::Continue | ||||
|     } else { | ||||
|         let _ = invoke | ||||
|             .respond( | ||||
|                 &ctx, | ||||
|                 CreateGenericResponse::new().content("This command can only be used in servers"), | ||||
|             ) | ||||
|             .await; | ||||
|  | ||||
|         HookResult::Halt | ||||
|     } | ||||
| } | ||||
|  | ||||
| #[check] | ||||
| pub async fn macro_check( | ||||
|     ctx: &Context, | ||||
|     invoke: &mut CommandInvoke, | ||||
|     args: &CommandOptions, | ||||
| ) -> HookResult { | ||||
|     if let Some(guild_id) = invoke.guild_id() { | ||||
|         if args.command != moderation_cmds::MACRO_CMD_COMMAND.names[0] { | ||||
|             let active_recordings = | ||||
|                 ctx.data.read().await.get::<RecordingMacros>().cloned().unwrap(); | ||||
|             let mut lock = active_recordings.write().await; | ||||
|  | ||||
|             if let Some(command_macro) = lock.get_mut(&(guild_id, invoke.author_id())) { | ||||
|                 if command_macro.commands.len() >= 5 { | ||||
|                     let _ = invoke | ||||
|                         .respond( | ||||
|                             &ctx, | ||||
|                             CreateGenericResponse::new().content("5 commands already recorded. Please use `/macro finish` to end recording."), | ||||
| use crate::{consts::MACRO_MAX_COMMANDS, models::command_macro::RecordedCommand, Context, Error}; | ||||
|  | ||||
| async fn macro_check(ctx: Context<'_>) -> bool { | ||||
|     if let Context::Application(app_ctx) = ctx { | ||||
|         if let ApplicationCommandOrAutocompleteInteraction::ApplicationCommand(_) = | ||||
|             app_ctx.interaction | ||||
|         { | ||||
|             if let Some(guild_id) = ctx.guild_id() { | ||||
|                 if ctx.command().identifying_name != "finish_macro" { | ||||
|                     let mut lock = ctx.data().recording_macros.write().await; | ||||
|  | ||||
|                     if let Some(command_macro) = lock.get_mut(&(guild_id, ctx.author().id)) { | ||||
|                         if command_macro.commands.len() >= MACRO_MAX_COMMANDS { | ||||
|                             let _ = ctx.send(|m| { | ||||
|                             m.ephemeral(true).content( | ||||
|                                 format!("{} commands already recorded. Please use `/macro finish` to end recording.", MACRO_MAX_COMMANDS), | ||||
|                             ) | ||||
|                         }) | ||||
|                             .await; | ||||
|                         } else { | ||||
|                     command_macro.commands.push(args.clone()); | ||||
|                             let recorded = RecordedCommand { | ||||
|                                 action: None, | ||||
|                                 command_name: ctx.command().identifying_name.clone(), | ||||
|                                 options: Vec::from(app_ctx.args), | ||||
|                             }; | ||||
|  | ||||
|                     let _ = invoke | ||||
|                         .respond( | ||||
|                             &ctx, | ||||
|                             CreateGenericResponse::new().content("Command recorded to macro"), | ||||
|                         ) | ||||
|                             command_macro.commands.push(recorded); | ||||
|  | ||||
|                             let _ = ctx | ||||
|                                 .send(|m| m.ephemeral(true).content("Command recorded to macro")) | ||||
|                                 .await; | ||||
|                         } | ||||
|  | ||||
|                 HookResult::Halt | ||||
|             } else { | ||||
|                 HookResult::Continue | ||||
|                         return false; | ||||
|                     } | ||||
|                 } | ||||
|         } else { | ||||
|             HookResult::Continue | ||||
|             } | ||||
|     } else { | ||||
|         HookResult::Continue | ||||
|         } | ||||
|     } | ||||
|  | ||||
| #[check] | ||||
| pub async fn check_self_permissions( | ||||
|     ctx: &Context, | ||||
|     invoke: &mut CommandInvoke, | ||||
|     _args: &CommandOptions, | ||||
| ) -> HookResult { | ||||
|     if let Some(guild) = invoke.guild(&ctx) { | ||||
|         let user_id = ctx.cache.current_user_id(); | ||||
|     true | ||||
| } | ||||
|  | ||||
| async fn check_self_permissions(ctx: Context<'_>) -> bool { | ||||
|     if let Some(guild) = ctx.guild() { | ||||
|         let user_id = ctx.serenity_context().cache.current_user_id(); | ||||
|  | ||||
|         let manage_webhooks = | ||||
|             guild.member_permissions(&ctx, user_id).await.map_or(false, |p| p.manage_webhooks()); | ||||
|         let (view_channel, send_messages, embed_links) = invoke | ||||
|  | ||||
|         let (view_channel, send_messages, embed_links) = ctx | ||||
|             .channel_id() | ||||
|             .to_channel_cached(&ctx) | ||||
|             .map(|c| { | ||||
|             .to_channel(&ctx) | ||||
|             .await | ||||
|             .ok() | ||||
|             .and_then(|c| { | ||||
|                 if let Channel::Guild(channel) = c { | ||||
|                     channel.permissions_for_user(ctx, user_id).ok() | ||||
|                     let perms = channel.permissions_for_user(&ctx, user_id).ok()?; | ||||
|  | ||||
|                     Some((perms.view_channel(), perms.send_messages(), perms.embed_links())) | ||||
|                 } else { | ||||
|                     None | ||||
|                 } | ||||
|             }) | ||||
|             .flatten() | ||||
|             .map_or((false, false, false), |p| { | ||||
|                 (p.read_messages(), p.send_messages(), p.embed_links()) | ||||
|             }); | ||||
|             .unwrap_or((false, false, false)); | ||||
|  | ||||
|         if manage_webhooks && send_messages && embed_links { | ||||
|             HookResult::Continue | ||||
|             true | ||||
|         } else { | ||||
|             let _ = invoke | ||||
|                 .respond( | ||||
|                     &ctx, | ||||
|                     CreateGenericResponse::new().content(format!( | ||||
|             let _ = ctx | ||||
|                 .send(|m| { | ||||
|                     m.content(format!( | ||||
|                         "Please ensure the bot has the correct permissions: | ||||
|  | ||||
| {}     **View Channel** | ||||
| @@ -110,43 +82,19 @@ pub async fn check_self_permissions( | ||||
| {}     **Manage Webhooks**", | ||||
|                         if view_channel { "✅" } else { "❌" }, | ||||
|                         if send_messages { "✅" } else { "❌" }, | ||||
|                         if manage_webhooks { "✅" } else { "❌" }, | ||||
|                         if embed_links { "✅" } else { "❌" }, | ||||
|                     )), | ||||
|                 ) | ||||
|                         if manage_webhooks { "✅" } else { "❌" }, | ||||
|                     )) | ||||
|                 }) | ||||
|                 .await; | ||||
|  | ||||
|             HookResult::Halt | ||||
|             false | ||||
|         } | ||||
|     } else { | ||||
|         HookResult::Continue | ||||
|         true | ||||
|     } | ||||
| } | ||||
|  | ||||
| #[check] | ||||
| pub async fn check_guild_permissions( | ||||
|     ctx: &Context, | ||||
|     invoke: &mut CommandInvoke, | ||||
|     _args: &CommandOptions, | ||||
| ) -> HookResult { | ||||
|     if let Some(guild) = invoke.guild(&ctx) { | ||||
|         let permissions = guild.member_permissions(&ctx, invoke.author_id()).await.unwrap(); | ||||
|  | ||||
|         if !permissions.manage_guild() { | ||||
|             let _ = invoke | ||||
|                 .respond( | ||||
|                     &ctx, | ||||
|                     CreateGenericResponse::new().content( | ||||
|                         "You must have the \"Manage Server\" permission to use this command", | ||||
|                     ), | ||||
|                 ) | ||||
|                 .await; | ||||
|  | ||||
|             HookResult::Halt | ||||
|         } else { | ||||
|             HookResult::Continue | ||||
|         } | ||||
|     } else { | ||||
|         HookResult::Continue | ||||
|     } | ||||
| pub async fn all_checks(ctx: Context<'_>) -> Result<bool, Error> { | ||||
|     Ok(macro_check(ctx).await && check_self_permissions(ctx).await) | ||||
| } | ||||
|   | ||||
							
								
								
									
										336
									
								
								src/interval_parser.rs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										336
									
								
								src/interval_parser.rs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,336 @@ | ||||
| /* | ||||
| With modifications, 2022 Jude Southworth | ||||
|  | ||||
| Original copyright notice: | ||||
|  | ||||
| Copyright 2021 Paul Colomiets | ||||
|  | ||||
| Permission is hereby granted, free of charge, to any person obtaining a copy of this software | ||||
| and associated documentation files (the "Software"), to deal in the Software without restriction, | ||||
| including without limitation the rights to use, copy, modify, merge, publish, distribute, | ||||
| sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is | ||||
| furnished to do so, subject to the following conditions: | ||||
|  | ||||
| The above copyright notice and this permission notice shall be included in all copies or | ||||
| substantial portions of the Software. | ||||
|  | ||||
| THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING | ||||
| BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND | ||||
| NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, | ||||
| DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, | ||||
| OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. | ||||
| */ | ||||
|  | ||||
| use std::{error::Error as StdError, fmt, str::Chars}; | ||||
|  | ||||
| /// Error parsing human-friendly duration | ||||
| #[derive(Debug, PartialEq, Clone)] | ||||
| pub enum Error { | ||||
|     /// Invalid character during parsing | ||||
|     /// | ||||
|     /// More specifically anything that is not alphanumeric is prohibited | ||||
|     /// | ||||
|     /// The field is an byte offset of the character in the string. | ||||
|     InvalidCharacter(usize), | ||||
|     /// Non-numeric value where number is expected | ||||
|     /// | ||||
|     /// This usually means that either time unit is broken into words, | ||||
|     /// e.g. `m sec` instead of `msec`, or just number is omitted, | ||||
|     /// for example `2 hours min` instead of `2 hours 1 min` | ||||
|     /// | ||||
|     /// The field is an byte offset of the errorneous character | ||||
|     /// in the string. | ||||
|     NumberExpected(usize), | ||||
|     /// Unit in the number is not one of allowed units | ||||
|     /// | ||||
|     /// See documentation of `parse_duration` for the list of supported | ||||
|     /// time units. | ||||
|     /// | ||||
|     /// The two fields are start and end (exclusive) of the slice from | ||||
|     /// the original string, containing errorneous value | ||||
|     UnknownUnit { | ||||
|         /// Start of the invalid unit inside the original string | ||||
|         start: usize, | ||||
|         /// End of the invalid unit inside the original string | ||||
|         end: usize, | ||||
|         /// The unit verbatim | ||||
|         unit: String, | ||||
|         /// A number associated with the unit | ||||
|         value: u64, | ||||
|     }, | ||||
|     /// The numeric value is too large | ||||
|     /// | ||||
|     /// Usually this means value is too large to be useful. If user writes | ||||
|     /// data in subsecond units, then the maximum is about 3k years. When | ||||
|     /// using seconds, or larger units, the limit is even larger. | ||||
|     NumberOverflow, | ||||
|     /// The value was an empty string (or consists only whitespace) | ||||
|     Empty, | ||||
| } | ||||
|  | ||||
| impl StdError for Error {} | ||||
|  | ||||
| impl fmt::Display for Error { | ||||
|     fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { | ||||
|         match self { | ||||
|             Error::InvalidCharacter(offset) => write!(f, "invalid character at {}", offset), | ||||
|             Error::NumberExpected(offset) => write!(f, "expected number at {}", offset), | ||||
|             Error::UnknownUnit { unit, value, .. } if unit.is_empty() => { | ||||
|                 write!(f, "time unit needed, for example {0}sec or {0}ms", value,) | ||||
|             } | ||||
|             Error::UnknownUnit { unit, .. } => { | ||||
|                 write!( | ||||
|                     f, | ||||
|                     "unknown time unit {:?}, \ | ||||
|                     supported units: ns, us, ms, sec, min, hours, days, \ | ||||
|                     weeks, months, years (and few variations)", | ||||
|                     unit | ||||
|                 ) | ||||
|             } | ||||
|             Error::NumberOverflow => write!(f, "number is too large"), | ||||
|             Error::Empty => write!(f, "value was empty"), | ||||
|         } | ||||
|     } | ||||
| } | ||||
|  | ||||
| trait OverflowOp: Sized { | ||||
|     fn mul(self, other: Self) -> Result<Self, Error>; | ||||
|     fn add(self, other: Self) -> Result<Self, Error>; | ||||
| } | ||||
|  | ||||
| impl OverflowOp for u64 { | ||||
|     fn mul(self, other: Self) -> Result<Self, Error> { | ||||
|         self.checked_mul(other).ok_or(Error::NumberOverflow) | ||||
|     } | ||||
|     fn add(self, other: Self) -> Result<Self, Error> { | ||||
|         self.checked_add(other).ok_or(Error::NumberOverflow) | ||||
|     } | ||||
| } | ||||
|  | ||||
| #[derive(Copy, Clone)] | ||||
| pub struct Interval { | ||||
|     pub month: u64, | ||||
|     pub day: u64, | ||||
|     pub sec: u64, | ||||
| } | ||||
|  | ||||
| struct Parser<'a> { | ||||
|     iter: Chars<'a>, | ||||
|     src: &'a str, | ||||
|     current: (u64, u64, u64, u64), | ||||
| } | ||||
|  | ||||
| impl<'a> Parser<'a> { | ||||
|     fn off(&self) -> usize { | ||||
|         self.src.len() - self.iter.as_str().len() | ||||
|     } | ||||
|  | ||||
|     fn parse_first_char(&mut self) -> Result<Option<u64>, Error> { | ||||
|         let off = self.off(); | ||||
|         for c in self.iter.by_ref() { | ||||
|             match c { | ||||
|                 '0'..='9' => { | ||||
|                     return Ok(Some(c as u64 - '0' as u64)); | ||||
|                 } | ||||
|                 c if c.is_whitespace() => continue, | ||||
|                 _ => { | ||||
|                     return Err(Error::NumberExpected(off)); | ||||
|                 } | ||||
|             } | ||||
|         } | ||||
|         Ok(None) | ||||
|     } | ||||
|     fn parse_unit(&mut self, n: u64, start: usize, end: usize) -> Result<(), Error> { | ||||
|         let (mut month, mut day, mut sec, nsec) = match &self.src[start..end] { | ||||
|             "nanos" | "nsec" | "ns" => (0, 0u64, 0u64, n), | ||||
|             "usec" | "us" => (0, 0, 0u64, n.mul(1000)?), | ||||
|             "millis" | "msec" | "ms" => (0, 0, 0u64, n.mul(1_000_000)?), | ||||
|             "seconds" | "second" | "secs" | "sec" | "s" => (0, 0, n, 0), | ||||
|             "minutes" | "minute" | "min" | "mins" | "m" => (0, 0, n.mul(60)?, 0), | ||||
|             "hours" | "hour" | "hr" | "hrs" | "h" => (0, 0, n.mul(3600)?, 0), | ||||
|             "days" | "day" | "d" => (0, n, 0, 0), | ||||
|             "weeks" | "week" | "w" => (0, n.mul(7)?, 0, 0), | ||||
|             "months" | "month" => (n, 0, 0, 0), | ||||
|             "years" | "year" | "y" => (n.mul(12)?, 0, 0, 0), | ||||
|             _ => { | ||||
|                 return Err(Error::UnknownUnit { | ||||
|                     start, | ||||
|                     end, | ||||
|                     unit: self.src[start..end].to_string(), | ||||
|                     value: n, | ||||
|                 }); | ||||
|             } | ||||
|         }; | ||||
|         let mut nsec = self.current.3 + nsec; | ||||
|         if nsec > 1_000_000_000 { | ||||
|             sec += nsec / 1_000_000_000; | ||||
|             nsec %= 1_000_000_000; | ||||
|         } | ||||
|         sec += self.current.2; | ||||
|         day += self.current.1; | ||||
|         month += self.current.0; | ||||
|  | ||||
|         self.current = (month, day, sec, nsec); | ||||
|  | ||||
|         Ok(()) | ||||
|     } | ||||
|  | ||||
|     fn parse(mut self) -> Result<Interval, Error> { | ||||
|         let mut n = self.parse_first_char()?.ok_or(Error::Empty)?; | ||||
|         'outer: loop { | ||||
|             let mut off = self.off(); | ||||
|             while let Some(c) = self.iter.next() { | ||||
|                 match c { | ||||
|                     '0'..='9' => { | ||||
|                         n = n | ||||
|                             .checked_mul(10) | ||||
|                             .and_then(|x| x.checked_add(c as u64 - '0' as u64)) | ||||
|                             .ok_or(Error::NumberOverflow)?; | ||||
|                     } | ||||
|                     c if c.is_whitespace() => {} | ||||
|                     'a'..='z' | 'A'..='Z' => { | ||||
|                         break; | ||||
|                     } | ||||
|                     _ => { | ||||
|                         return Err(Error::InvalidCharacter(off)); | ||||
|                     } | ||||
|                 } | ||||
|                 off = self.off(); | ||||
|             } | ||||
|             let start = off; | ||||
|             let mut off = self.off(); | ||||
|             while let Some(c) = self.iter.next() { | ||||
|                 match c { | ||||
|                     '0'..='9' => { | ||||
|                         self.parse_unit(n, start, off)?; | ||||
|                         n = c as u64 - '0' as u64; | ||||
|                         continue 'outer; | ||||
|                     } | ||||
|                     c if c.is_whitespace() => break, | ||||
|                     'a'..='z' | 'A'..='Z' => {} | ||||
|                     _ => { | ||||
|                         return Err(Error::InvalidCharacter(off)); | ||||
|                     } | ||||
|                 } | ||||
|                 off = self.off(); | ||||
|             } | ||||
|             self.parse_unit(n, start, off)?; | ||||
|             n = match self.parse_first_char()? { | ||||
|                 Some(n) => n, | ||||
|                 None => { | ||||
|                     return Ok(Interval { | ||||
|                         month: self.current.0, | ||||
|                         day: self.current.1, | ||||
|                         sec: self.current.2, | ||||
|                     }) | ||||
|                 } | ||||
|             }; | ||||
|         } | ||||
|     } | ||||
| } | ||||
|  | ||||
| /// Parse duration object `1hour 12min 5s` | ||||
| /// | ||||
| /// The duration object is a concatenation of time spans. Where each time | ||||
| /// span is an integer number and a suffix. Supported suffixes: | ||||
| /// | ||||
| /// * `nsec`, `ns` -- nanoseconds | ||||
| /// * `usec`, `us` -- microseconds | ||||
| /// * `msec`, `ms` -- milliseconds | ||||
| /// * `seconds`, `second`, `sec`, `s` | ||||
| /// * `minutes`, `minute`, `min`, `m` | ||||
| /// * `hours`, `hour`, `hr`, `h` | ||||
| /// * `days`, `day`, `d` | ||||
| /// * `weeks`, `week`, `w` | ||||
| /// * `months`, `month`, `M` -- defined as 30.44 days | ||||
| /// * `years`, `year`, `y` -- defined as 365.25 days | ||||
| /// | ||||
| /// # Examples | ||||
| /// | ||||
| /// ``` | ||||
| /// use std::time::Duration; | ||||
| /// use humantime::parse_duration; | ||||
| /// | ||||
| /// assert_eq!(parse_duration("2h 37min"), Ok(Duration::new(9420, 0))); | ||||
| /// assert_eq!(parse_duration("32ms"), Ok(Duration::new(0, 32_000_000))); | ||||
| /// ``` | ||||
| pub fn parse_duration(s: &str) -> Result<Interval, Error> { | ||||
|     Parser { iter: s.to_lowercase().chars(), src: &s.to_lowercase(), current: (0, 0, 0, 0) }.parse() | ||||
| } | ||||
|  | ||||
| #[cfg(test)] | ||||
| mod tests { | ||||
|     use super::*; | ||||
|  | ||||
|     #[test] | ||||
|     fn parse_seconds() { | ||||
|         let interval = parse_duration("10 seconds").unwrap(); | ||||
|  | ||||
|         assert_eq!(interval.sec, 10); | ||||
|         assert_eq!(interval.day, 0); | ||||
|         assert_eq!(interval.month, 0); | ||||
|     } | ||||
|  | ||||
|     #[test] | ||||
|     fn parse_minutes() { | ||||
|         let interval = parse_duration("10 minutes").unwrap(); | ||||
|  | ||||
|         assert_eq!(interval.sec, 600); | ||||
|         assert_eq!(interval.day, 0); | ||||
|         assert_eq!(interval.month, 0); | ||||
|     } | ||||
|  | ||||
|     #[test] | ||||
|     fn parse_hours() { | ||||
|         let interval = parse_duration("10 hours").unwrap(); | ||||
|  | ||||
|         assert_eq!(interval.sec, 36_000); | ||||
|         assert_eq!(interval.day, 0); | ||||
|         assert_eq!(interval.month, 0); | ||||
|     } | ||||
|  | ||||
|     #[test] | ||||
|     fn parse_days() { | ||||
|         let interval = parse_duration("10 days").unwrap(); | ||||
|  | ||||
|         assert_eq!(interval.sec, 0); | ||||
|         assert_eq!(interval.day, 10); | ||||
|         assert_eq!(interval.month, 0); | ||||
|     } | ||||
|  | ||||
|     #[test] | ||||
|     fn parse_weeks() { | ||||
|         let interval = parse_duration("10 weeks").unwrap(); | ||||
|  | ||||
|         assert_eq!(interval.sec, 0); | ||||
|         assert_eq!(interval.day, 70); | ||||
|         assert_eq!(interval.month, 0); | ||||
|     } | ||||
|  | ||||
|     #[test] | ||||
|     fn parse_months() { | ||||
|         let interval = parse_duration("10 months").unwrap(); | ||||
|  | ||||
|         assert_eq!(interval.sec, 0); | ||||
|         assert_eq!(interval.day, 0); | ||||
|         assert_eq!(interval.month, 10); | ||||
|     } | ||||
|  | ||||
|     #[test] | ||||
|     fn parse_years() { | ||||
|         let interval = parse_duration("10 years").unwrap(); | ||||
|  | ||||
|         assert_eq!(interval.sec, 0); | ||||
|         assert_eq!(interval.day, 0); | ||||
|         assert_eq!(interval.month, 120); | ||||
|     } | ||||
|  | ||||
|     #[test] | ||||
|     fn parse_case() { | ||||
|         let interval = parse_duration("200 Seconds").unwrap(); | ||||
|  | ||||
|         assert_eq!(interval.sec, 200); | ||||
|         assert_eq!(interval.day, 0); | ||||
|         assert_eq!(interval.month, 0); | ||||
|     } | ||||
| } | ||||
							
								
								
									
										486
									
								
								src/main.rs
									
									
									
									
									
								
							
							
						
						
									
										486
									
								
								src/main.rs
									
									
									
									
									
								
							| @@ -1,321 +1,269 @@ | ||||
| #![feature(int_roundings)] | ||||
|  | ||||
| #[macro_use] | ||||
| extern crate lazy_static; | ||||
|  | ||||
| mod commands; | ||||
| mod component_models; | ||||
| mod consts; | ||||
| mod framework; | ||||
| mod event_handlers; | ||||
| mod hooks; | ||||
| mod interval_parser; | ||||
| mod models; | ||||
| mod time_parser; | ||||
| mod utils; | ||||
|  | ||||
| use std::{collections::HashMap, env, sync::Arc}; | ||||
| use std::{ | ||||
|     collections::HashMap, | ||||
|     env, | ||||
|     error::Error as StdError, | ||||
|     fmt::{Debug, Display, Formatter}, | ||||
|     path::Path, | ||||
| }; | ||||
|  | ||||
| use chrono_tz::Tz; | ||||
| use dotenv::dotenv; | ||||
| use log::info; | ||||
| use serenity::{ | ||||
|     async_trait, | ||||
|     client::{bridge::gateway::GatewayIntents, Client}, | ||||
|     http::{client::Http, CacheHttp}, | ||||
|     model::{ | ||||
|         channel::GuildChannel, | ||||
|         gateway::{Activity, Ready}, | ||||
|         guild::{Guild, GuildUnavailable}, | ||||
| use log::{error, warn}; | ||||
| use poise::serenity_prelude::model::{ | ||||
|     gateway::GatewayIntents, | ||||
|     id::{GuildId, UserId}, | ||||
|         interactions::Interaction, | ||||
|     }, | ||||
|     prelude::{Context, EventHandler, TypeMapKey}, | ||||
|     utils::shard_id, | ||||
| }; | ||||
| use sqlx::mysql::MySqlPool; | ||||
| use tokio::sync::RwLock; | ||||
| use sqlx::{MySql, Pool}; | ||||
| use tokio::sync::{broadcast, broadcast::Sender, RwLock}; | ||||
|  | ||||
| use crate::{ | ||||
|     commands::{info_cmds, moderation_cmds, reminder_cmds, todo_cmds}, | ||||
|     component_models::ComponentDataModel, | ||||
|     consts::{CNC_GUILD, SUBSCRIPTION_ROLES, THEME_COLOR}, | ||||
|     framework::RegexFramework, | ||||
|     commands::{command_macro, info_cmds, moderation_cmds, reminder_cmds, todo_cmds}, | ||||
|     consts::THEME_COLOR, | ||||
|     event_handlers::listener, | ||||
|     hooks::all_checks, | ||||
|     models::command_macro::CommandMacro, | ||||
|     utils::register_application_commands, | ||||
| }; | ||||
|  | ||||
| struct SQLPool; | ||||
| type Database = MySql; | ||||
|  | ||||
| impl TypeMapKey for SQLPool { | ||||
|     type Value = MySqlPool; | ||||
| type Error = Box<dyn std::error::Error + Send + Sync>; | ||||
| type Context<'a> = poise::Context<'a, Data, Error>; | ||||
| type ApplicationContext<'a> = poise::ApplicationContext<'a, Data, Error>; | ||||
|  | ||||
| pub struct Data { | ||||
|     database: Pool<Database>, | ||||
|     http: reqwest::Client, | ||||
|     recording_macros: RwLock<HashMap<(GuildId, UserId), CommandMacro<Data, Error>>>, | ||||
|     popular_timezones: Vec<Tz>, | ||||
|     _broadcast: Sender<()>, | ||||
| } | ||||
|  | ||||
| struct ReqwestClient; | ||||
|  | ||||
| impl TypeMapKey for ReqwestClient { | ||||
|     type Value = Arc<reqwest::Client>; | ||||
| } | ||||
|  | ||||
| struct PopularTimezones; | ||||
|  | ||||
| impl TypeMapKey for PopularTimezones { | ||||
|     type Value = Arc<Vec<Tz>>; | ||||
| } | ||||
|  | ||||
| struct RecordingMacros; | ||||
|  | ||||
| impl TypeMapKey for RecordingMacros { | ||||
|     type Value = Arc<RwLock<HashMap<(GuildId, UserId), CommandMacro>>>; | ||||
| } | ||||
|  | ||||
| struct Handler; | ||||
|  | ||||
| #[async_trait] | ||||
| impl EventHandler for Handler { | ||||
|     async fn channel_delete(&self, ctx: Context, channel: &GuildChannel) { | ||||
|         let pool = ctx | ||||
|             .data | ||||
|             .read() | ||||
|             .await | ||||
|             .get::<SQLPool>() | ||||
|             .cloned() | ||||
|             .expect("Could not get SQLPool from data"); | ||||
|  | ||||
|         sqlx::query!( | ||||
|             " | ||||
| DELETE FROM channels WHERE channel = ? | ||||
|             ", | ||||
|             channel.id.as_u64() | ||||
|         ) | ||||
|         .execute(&pool) | ||||
|         .await | ||||
|         .unwrap(); | ||||
|     } | ||||
|  | ||||
|     async fn guild_create(&self, ctx: Context, guild: Guild, is_new: bool) { | ||||
|         if is_new { | ||||
|             let guild_id = guild.id.as_u64().to_owned(); | ||||
|  | ||||
|             { | ||||
|                 let pool = ctx.data.read().await.get::<SQLPool>().cloned().unwrap(); | ||||
|  | ||||
|                 let _ = sqlx::query!("INSERT INTO guilds (guild) VALUES (?)", guild_id) | ||||
|                     .execute(&pool) | ||||
|                     .await; | ||||
|             } | ||||
|  | ||||
|             if let Ok(token) = env::var("DISCORDBOTS_TOKEN") { | ||||
|                 let shard_count = ctx.cache.shard_count(); | ||||
|                 let current_shard_id = shard_id(guild_id, shard_count); | ||||
|  | ||||
|                 let guild_count = ctx | ||||
|                     .cache | ||||
|                     .guilds() | ||||
|                     .iter() | ||||
|                     .filter(|g| shard_id(g.as_u64().to_owned(), shard_count) == current_shard_id) | ||||
|                     .count() as u64; | ||||
|  | ||||
|                 let mut hm = HashMap::new(); | ||||
|                 hm.insert("server_count", guild_count); | ||||
|                 hm.insert("shard_id", current_shard_id); | ||||
|                 hm.insert("shard_count", shard_count); | ||||
|  | ||||
|                 let client = ctx | ||||
|                     .data | ||||
|                     .read() | ||||
|                     .await | ||||
|                     .get::<ReqwestClient>() | ||||
|                     .cloned() | ||||
|                     .expect("Could not get ReqwestClient from data"); | ||||
|  | ||||
|                 let response = client | ||||
|                     .post( | ||||
|                         format!( | ||||
|                             "https://top.gg/api/bots/{}/stats", | ||||
|                             ctx.cache.current_user_id().as_u64() | ||||
|                         ) | ||||
|                         .as_str(), | ||||
|                     ) | ||||
|                     .header("Authorization", token) | ||||
|                     .json(&hm) | ||||
|                     .send() | ||||
|                     .await; | ||||
|  | ||||
|                 if let Err(res) = response { | ||||
|                     println!("DiscordBots Response: {:?}", res); | ||||
|                 } | ||||
|             } | ||||
| impl Debug for Data { | ||||
|     fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { | ||||
|         write!(f, "Data {{ .. }}") | ||||
|     } | ||||
| } | ||||
|  | ||||
|     async fn guild_delete(&self, ctx: Context, incomplete: GuildUnavailable, _full: Option<Guild>) { | ||||
|         let pool = ctx.data.read().await.get::<SQLPool>().cloned().unwrap(); | ||||
|         let _ = sqlx::query!("DELETE FROM guilds WHERE guild = ?", incomplete.id.0) | ||||
|             .execute(&pool) | ||||
|             .await; | ||||
|     } | ||||
| struct Ended; | ||||
|  | ||||
|     async fn ready(&self, ctx: Context, _: Ready) { | ||||
|         ctx.set_activity(Activity::watching("for /remind")).await; | ||||
|     } | ||||
|  | ||||
|     async fn interaction_create(&self, ctx: Context, interaction: Interaction) { | ||||
|         match interaction { | ||||
|             Interaction::ApplicationCommand(application_command) => { | ||||
|                 let framework = ctx | ||||
|                     .data | ||||
|                     .read() | ||||
|                     .await | ||||
|                     .get::<RegexFramework>() | ||||
|                     .cloned() | ||||
|                     .expect("RegexFramework not found in context"); | ||||
|  | ||||
|                 framework.execute(ctx, application_command).await; | ||||
|             } | ||||
|             Interaction::MessageComponent(component) => { | ||||
|                 let component_model = ComponentDataModel::from_custom_id(&component.data.custom_id); | ||||
|                 component_model.act(&ctx, component).await; | ||||
|             } | ||||
|             _ => {} | ||||
|         } | ||||
| impl Debug for Ended { | ||||
|     fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { | ||||
|         f.write_str("Process ended.") | ||||
|     } | ||||
| } | ||||
|  | ||||
| #[tokio::main] | ||||
| async fn main() -> Result<(), Box<dyn std::error::Error + Send + Sync>> { | ||||
| impl Display for Ended { | ||||
|     fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { | ||||
|         f.write_str("Process ended.") | ||||
|     } | ||||
| } | ||||
|  | ||||
| impl StdError for Ended {} | ||||
|  | ||||
| #[tokio::main(flavor = "multi_thread")] | ||||
| async fn main() -> Result<(), Box<dyn StdError + Send + Sync>> { | ||||
|     let (tx, mut rx) = broadcast::channel(16); | ||||
|  | ||||
|     tokio::select! { | ||||
|         output = _main(tx) => output, | ||||
|         _ = rx.recv() => Err(Box::new(Ended) as Box<dyn StdError + Send + Sync>) | ||||
|     } | ||||
| } | ||||
|  | ||||
| async fn _main(tx: Sender<()>) -> Result<(), Box<dyn StdError + Send + Sync>> { | ||||
|     env_logger::init(); | ||||
|  | ||||
|     dotenv()?; | ||||
|     if Path::new("/etc/reminder-rs/config.env").exists() { | ||||
|         dotenv::from_path("/etc/reminder-rs/config.env")?; | ||||
|     } else { | ||||
|         let _ = dotenv::dotenv(); | ||||
|     } | ||||
|  | ||||
|     let token = env::var("DISCORD_TOKEN").expect("Missing DISCORD_TOKEN from environment"); | ||||
|     let discord_token = env::var("DISCORD_TOKEN").expect("Missing DISCORD_TOKEN from environment"); | ||||
|  | ||||
|     let http = Http::new_with_token(&token); | ||||
|     let options = poise::FrameworkOptions { | ||||
|         commands: vec![ | ||||
|             info_cmds::help(), | ||||
|             info_cmds::info(), | ||||
|             info_cmds::donate(), | ||||
|             info_cmds::clock(), | ||||
|             info_cmds::clock_context_menu(), | ||||
|             info_cmds::dashboard(), | ||||
|             moderation_cmds::timezone(), | ||||
|             poise::Command { | ||||
|                 subcommands: vec![ | ||||
|                     moderation_cmds::set_allowed_dm(), | ||||
|                     moderation_cmds::unset_allowed_dm(), | ||||
|                 ], | ||||
|                 ..moderation_cmds::allowed_dm() | ||||
|             }, | ||||
|             poise::Command { | ||||
|                 subcommands: vec![poise::Command { | ||||
|                     subcommands: vec![ | ||||
|                         moderation_cmds::set_ephemeral_confirmations(), | ||||
|                         moderation_cmds::unset_ephemeral_confirmations(), | ||||
|                     ], | ||||
|                     ..moderation_cmds::ephemeral_confirmations() | ||||
|                 }], | ||||
|                 ..moderation_cmds::settings() | ||||
|             }, | ||||
|             moderation_cmds::webhook(), | ||||
|             poise::Command { | ||||
|                 subcommands: vec![ | ||||
|                     command_macro::delete::delete_macro(), | ||||
|                     command_macro::record::finish_macro(), | ||||
|                     command_macro::list::list_macro(), | ||||
|                     command_macro::record::record_macro(), | ||||
|                     command_macro::run::run_macro(), | ||||
|                     command_macro::migrate::migrate_macro(), | ||||
|                 ], | ||||
|                 ..command_macro::macro_base() | ||||
|             }, | ||||
|             reminder_cmds::pause(), | ||||
|             reminder_cmds::offset(), | ||||
|             reminder_cmds::nudge(), | ||||
|             reminder_cmds::look(), | ||||
|             reminder_cmds::delete(), | ||||
|             poise::Command { | ||||
|                 subcommands: vec![ | ||||
|                     reminder_cmds::list_timer(), | ||||
|                     reminder_cmds::start_timer(), | ||||
|                     reminder_cmds::delete_timer(), | ||||
|                 ], | ||||
|                 ..reminder_cmds::timer_base() | ||||
|             }, | ||||
|             reminder_cmds::multiline(), | ||||
|             reminder_cmds::remind(), | ||||
|             poise::Command { | ||||
|                 subcommands: vec![ | ||||
|                     poise::Command { | ||||
|                         subcommands: vec![ | ||||
|                             todo_cmds::todo_guild_add(), | ||||
|                             todo_cmds::todo_guild_view(), | ||||
|                         ], | ||||
|                         ..todo_cmds::todo_guild_base() | ||||
|                     }, | ||||
|                     poise::Command { | ||||
|                         subcommands: vec![ | ||||
|                             todo_cmds::todo_channel_add(), | ||||
|                             todo_cmds::todo_channel_view(), | ||||
|                         ], | ||||
|                         ..todo_cmds::todo_channel_base() | ||||
|                     }, | ||||
|                     poise::Command { | ||||
|                         subcommands: vec![todo_cmds::todo_user_add(), todo_cmds::todo_user_view()], | ||||
|                         ..todo_cmds::todo_user_base() | ||||
|                     }, | ||||
|                 ], | ||||
|                 ..todo_cmds::todo_base() | ||||
|             }, | ||||
|         ], | ||||
|         allowed_mentions: None, | ||||
|         command_check: Some(|ctx| Box::pin(all_checks(ctx))), | ||||
|         event_handler: |ctx, event, _framework, data| Box::pin(listener(ctx, event, data)), | ||||
|         on_error: |error| { | ||||
|             Box::pin(async move { | ||||
|                 match error { | ||||
|                     poise::FrameworkError::CommandCheckFailed { .. } => { | ||||
|                         // suppress error | ||||
|                     } | ||||
|                     error => { | ||||
|                         if let Err(e) = poise::builtins::on_error(error).await { | ||||
|                             log::error!("Error while handling error: {}", e); | ||||
|                         } | ||||
|                     } | ||||
|                 } | ||||
|             }) | ||||
|         }, | ||||
|         ..Default::default() | ||||
|     }; | ||||
|  | ||||
|     let application_id = http.get_current_application_info().await?.id; | ||||
|     let database = | ||||
|         Pool::connect(&env::var("DATABASE_URL").expect("No database URL provided")).await.unwrap(); | ||||
|  | ||||
|     let dm_enabled = env::var("DM_ENABLED").map_or(true, |var| var == "1"); | ||||
|  | ||||
|     let framework = RegexFramework::new() | ||||
|         .ignore_bots(env::var("IGNORE_BOTS").map_or(true, |var| var == "1")) | ||||
|         .debug_guild(env::var("DEBUG_GUILD").map_or(None, |g| { | ||||
|             Some(GuildId(g.parse::<u64>().expect("DEBUG_GUILD must be a guild ID"))) | ||||
|         })) | ||||
|         .dm_enabled(dm_enabled) | ||||
|         // info commands | ||||
|         .add_command(&info_cmds::HELP_COMMAND) | ||||
|         .add_command(&info_cmds::INFO_COMMAND) | ||||
|         .add_command(&info_cmds::DONATE_COMMAND) | ||||
|         .add_command(&info_cmds::DASHBOARD_COMMAND) | ||||
|         .add_command(&info_cmds::CLOCK_COMMAND) | ||||
|         // reminder commands | ||||
|         .add_command(&reminder_cmds::TIMER_COMMAND) | ||||
|         .add_command(&reminder_cmds::REMIND_COMMAND) | ||||
|         // management commands | ||||
|         .add_command(&reminder_cmds::DELETE_COMMAND) | ||||
|         .add_command(&reminder_cmds::LOOK_COMMAND) | ||||
|         .add_command(&reminder_cmds::PAUSE_COMMAND) | ||||
|         .add_command(&reminder_cmds::OFFSET_COMMAND) | ||||
|         .add_command(&reminder_cmds::NUDGE_COMMAND) | ||||
|         // to-do commands | ||||
|         .add_command(&todo_cmds::TODO_COMMAND) | ||||
|         // moderation commands | ||||
|         .add_command(&moderation_cmds::TIMEZONE_COMMAND) | ||||
|         .add_command(&moderation_cmds::MACRO_CMD_COMMAND) | ||||
|         .add_hook(&hooks::CHECK_SELF_PERMISSIONS_HOOK) | ||||
|         .add_hook(&hooks::MACRO_CHECK_HOOK); | ||||
|  | ||||
|     let framework_arc = Arc::new(framework); | ||||
|  | ||||
|     let mut client = Client::builder(&token) | ||||
|         .intents(GatewayIntents::GUILDS) | ||||
|         .application_id(application_id.0) | ||||
|         .event_handler(Handler) | ||||
|         .await | ||||
|         .expect("Error occurred creating client"); | ||||
|  | ||||
|     { | ||||
|         let pool = MySqlPool::connect( | ||||
|             &env::var("DATABASE_URL").expect("Missing DATABASE_URL from environment"), | ||||
|         ) | ||||
|         .await | ||||
|         .unwrap(); | ||||
|     sqlx::migrate!().run(&database).await?; | ||||
|  | ||||
|     let popular_timezones = sqlx::query!( | ||||
|             "SELECT timezone FROM users GROUP BY timezone ORDER BY COUNT(timezone) DESC LIMIT 21" | ||||
|         "SELECT IFNULL(timezone, 'UTC') AS timezone | ||||
|         FROM users | ||||
|         WHERE timezone IS NOT NULL | ||||
|         GROUP BY timezone | ||||
|         ORDER BY COUNT(timezone) DESC | ||||
|         LIMIT 21" | ||||
|     ) | ||||
|         .fetch_all(&pool) | ||||
|     .fetch_all(&database) | ||||
|     .await | ||||
|     .unwrap() | ||||
|     .iter() | ||||
|     .map(|t| t.timezone.parse::<Tz>().unwrap()) | ||||
|     .collect::<Vec<Tz>>(); | ||||
|  | ||||
|         let mut data = client.data.write().await; | ||||
|     poise::Framework::builder() | ||||
|         .token(discord_token) | ||||
|         .setup(move |ctx, _bot, framework| { | ||||
|             Box::pin(async move { | ||||
|                 register_application_commands(ctx, framework, None).await.unwrap(); | ||||
|  | ||||
|         data.insert::<SQLPool>(pool); | ||||
|         data.insert::<PopularTimezones>(Arc::new(popular_timezones)); | ||||
|         data.insert::<ReqwestClient>(Arc::new(reqwest::Client::new())); | ||||
|         data.insert::<RegexFramework>(framework_arc.clone()); | ||||
|         data.insert::<RecordingMacros>(Arc::new(RwLock::new(HashMap::new()))); | ||||
|                 let kill_tx = tx.clone(); | ||||
|                 let kill_recv = tx.subscribe(); | ||||
|  | ||||
|                 let ctx1 = ctx.clone(); | ||||
|                 let ctx2 = ctx.clone(); | ||||
|  | ||||
|                 let pool1 = database.clone(); | ||||
|                 let pool2 = database.clone(); | ||||
|  | ||||
|                 let run_settings = env::var("DONTRUN").unwrap_or_else(|_| "".to_string()); | ||||
|  | ||||
|                 if !run_settings.contains("postman") { | ||||
|                     tokio::spawn(async move { | ||||
|                         match postman::initialize(kill_recv, ctx1, &pool1).await { | ||||
|                             Ok(_) => {} | ||||
|                             Err(e) => { | ||||
|                                 error!("postman exiting: {}", e); | ||||
|                             } | ||||
|  | ||||
|     framework_arc.build_slash(&client.cache_and_http.http).await; | ||||
|  | ||||
|     if let Ok((Some(lower), Some(upper))) = env::var("SHARD_RANGE").map(|sr| { | ||||
|         let mut split = | ||||
|             sr.split(',').map(|val| val.parse::<u64>().expect("SHARD_RANGE not an integer")); | ||||
|  | ||||
|         (split.next(), split.next()) | ||||
|     }) { | ||||
|         let total_shards = env::var("SHARD_COUNT") | ||||
|             .map(|shard_count| shard_count.parse::<u64>().ok()) | ||||
|             .ok() | ||||
|             .flatten() | ||||
|             .expect("No SHARD_COUNT provided, but SHARD_RANGE was provided"); | ||||
|  | ||||
|         assert!(lower < upper, "SHARD_RANGE lower limit is not less than the upper limit"); | ||||
|  | ||||
|         info!("Starting client fragment with shards {}-{}/{}", lower, upper, total_shards); | ||||
|  | ||||
|         client.start_shard_range([lower, upper], total_shards).await?; | ||||
|     } else if let Ok(total_shards) = env::var("SHARD_COUNT") | ||||
|         .map(|shard_count| shard_count.parse::<u64>().expect("SHARD_COUNT not an integer")) | ||||
|     { | ||||
|         info!("Starting client with {} shards", total_shards); | ||||
|  | ||||
|         client.start_shards(total_shards).await?; | ||||
|                         }; | ||||
|                     }); | ||||
|                 } else { | ||||
|         info!("Starting client as autosharded"); | ||||
|  | ||||
|         client.start_autosharded().await?; | ||||
|                     warn!("Not running postman"); | ||||
|                 } | ||||
|  | ||||
|                 if !run_settings.contains("web") { | ||||
|                     tokio::spawn(async move { | ||||
|                         reminder_web::initialize(kill_tx, ctx2, pool2).await.unwrap(); | ||||
|                     }); | ||||
|                 } else { | ||||
|                     warn!("Not running web"); | ||||
|                 } | ||||
|  | ||||
|                 Ok(Data { | ||||
|                     http: reqwest::Client::new(), | ||||
|                     database, | ||||
|                     popular_timezones, | ||||
|                     recording_macros: Default::default(), | ||||
|                     _broadcast: tx, | ||||
|                 }) | ||||
|             }) | ||||
|         }) | ||||
|         .options(options) | ||||
|         .intents(GatewayIntents::GUILDS) | ||||
|         .run_autosharded() | ||||
|         .await?; | ||||
|  | ||||
|     Ok(()) | ||||
| } | ||||
|  | ||||
| pub async fn check_subscription(cache_http: impl CacheHttp, user_id: impl Into<UserId>) -> bool { | ||||
|     if let Some(subscription_guild) = *CNC_GUILD { | ||||
|         let guild_member = GuildId(subscription_guild).member(cache_http, user_id).await; | ||||
|  | ||||
|         if let Ok(member) = guild_member { | ||||
|             for role in member.roles { | ||||
|                 if SUBSCRIPTION_ROLES.contains(role.as_u64()) { | ||||
|                     return true; | ||||
|                 } | ||||
|             } | ||||
|         } | ||||
|  | ||||
|         false | ||||
|     } else { | ||||
|         true | ||||
|     } | ||||
| } | ||||
|  | ||||
| pub async fn check_guild_subscription( | ||||
|     cache_http: impl CacheHttp, | ||||
|     guild_id: impl Into<GuildId>, | ||||
| ) -> bool { | ||||
|     if let Some(guild) = cache_http.cache().unwrap().guild(guild_id) { | ||||
|         let owner = guild.owner_id; | ||||
|  | ||||
|         check_subscription(&cache_http, owner).await | ||||
|     } else { | ||||
|         false | ||||
|     } | ||||
| } | ||||
|   | ||||
| @@ -1,5 +1,5 @@ | ||||
| use chrono::NaiveDateTime; | ||||
| use serenity::model::channel::Channel; | ||||
| use poise::serenity_prelude::model::channel::Channel; | ||||
| use sqlx::MySqlPool; | ||||
|  | ||||
| pub struct ChannelData { | ||||
| @@ -22,9 +22,7 @@ impl ChannelData { | ||||
|  | ||||
|         if let Ok(c) = sqlx::query_as_unchecked!( | ||||
|             Self, | ||||
|             " | ||||
| SELECT id, name, nudge, blacklisted, webhook_id, webhook_token, paused, paused_until FROM channels WHERE channel = ? | ||||
|             ", | ||||
|             "SELECT id, name, nudge, blacklisted, webhook_id, webhook_token, paused, paused_until FROM channels WHERE channel = ?", | ||||
|             channel_id | ||||
|         ) | ||||
|         .fetch_one(pool) | ||||
| @@ -37,9 +35,7 @@ SELECT id, name, nudge, blacklisted, webhook_id, webhook_token, paused, paused_u | ||||
|             let (guild_id, channel_name) = if let Some((a, b)) = props { (Some(a), Some(b)) } else { (None, None) }; | ||||
|  | ||||
|             sqlx::query!( | ||||
|                 " | ||||
| INSERT IGNORE INTO channels (channel, name, guild_id) VALUES (?, ?, (SELECT id FROM guilds WHERE guild = ?)) | ||||
|                 ", | ||||
|                 "INSERT IGNORE INTO channels (channel, name, guild_id) VALUES (?, ?, (SELECT id FROM guilds WHERE guild = ?))", | ||||
|                 channel_id, | ||||
|                 channel_name, | ||||
|                 guild_id | ||||
|   | ||||
| @@ -1,33 +1,77 @@ | ||||
| use serenity::{client::Context, model::id::GuildId}; | ||||
| use poise::serenity_prelude::model::{ | ||||
|     application::interaction::application_command::CommandDataOption, id::GuildId, | ||||
| }; | ||||
| use serde::{Deserialize, Serialize}; | ||||
| use serde_json::Value; | ||||
|  | ||||
| use crate::{framework::CommandOptions, SQLPool}; | ||||
| use crate::{Context, Data, Error}; | ||||
|  | ||||
| pub struct CommandMacro { | ||||
| type Func<U, E> = for<'a> fn( | ||||
|     poise::ApplicationContext<'a, U, E>, | ||||
| ) -> poise::BoxFuture<'a, Result<(), poise::FrameworkError<'a, U, E>>>; | ||||
|  | ||||
| fn default_none<U, E>() -> Option<Func<U, E>> { | ||||
|     None | ||||
| } | ||||
|  | ||||
| #[derive(Serialize, Deserialize)] | ||||
| pub struct RecordedCommand<U, E> { | ||||
|     #[serde(skip)] | ||||
|     #[serde(default = "default_none::<U, E>")] | ||||
|     pub action: Option<Func<U, E>>, | ||||
|     pub command_name: String, | ||||
|     pub options: Vec<CommandDataOption>, | ||||
| } | ||||
|  | ||||
| pub struct CommandMacro<U, E> { | ||||
|     pub guild_id: GuildId, | ||||
|     pub name: String, | ||||
|     pub description: Option<String>, | ||||
|     pub commands: Vec<CommandOptions>, | ||||
|     pub commands: Vec<RecordedCommand<U, E>>, | ||||
| } | ||||
|  | ||||
| impl CommandMacro { | ||||
|     pub async fn from_guild(ctx: &Context, guild_id: impl Into<GuildId>) -> Vec<Self> { | ||||
|         let pool = ctx.data.read().await.get::<SQLPool>().cloned().unwrap(); | ||||
|         let guild_id = guild_id.into(); | ||||
| pub struct RawCommandMacro { | ||||
|     pub guild_id: GuildId, | ||||
|     pub name: String, | ||||
|     pub description: Option<String>, | ||||
|     pub commands: Value, | ||||
| } | ||||
|  | ||||
|         sqlx::query!( | ||||
|             "SELECT * FROM macro WHERE guild_id = (SELECT id FROM guilds WHERE guild = ?)", | ||||
|             guild_id.0 | ||||
| pub async fn guild_command_macro( | ||||
|     ctx: &Context<'_>, | ||||
|     name: &str, | ||||
| ) -> Option<CommandMacro<Data, Error>> { | ||||
|     let row = sqlx::query!( | ||||
|         " | ||||
| SELECT * FROM macro WHERE guild_id = (SELECT id FROM guilds WHERE guild = ?) AND name = ? | ||||
|         ", | ||||
|         ctx.guild_id().unwrap().0, | ||||
|         name | ||||
|     ) | ||||
|         .fetch_all(&pool) | ||||
|     .fetch_one(&ctx.data().database) | ||||
|     .await | ||||
|         .unwrap() | ||||
|     .ok()?; | ||||
|  | ||||
|     let mut commands: Vec<RecordedCommand<Data, Error>> = | ||||
|         serde_json::from_str(&row.commands).unwrap(); | ||||
|  | ||||
|     for recorded_command in &mut commands { | ||||
|         let command = &ctx | ||||
|             .framework() | ||||
|             .options() | ||||
|             .commands | ||||
|             .iter() | ||||
|         .map(|row| Self { | ||||
|             guild_id, | ||||
|             name: row.name.clone(), | ||||
|             description: row.description.clone(), | ||||
|             commands: serde_json::from_str(&row.commands).unwrap(), | ||||
|         }) | ||||
|         .collect::<Vec<Self>>() | ||||
|             .find(|c| c.identifying_name == recorded_command.command_name); | ||||
|  | ||||
|         recorded_command.action = command.map(|c| c.slash_action).flatten(); | ||||
|     } | ||||
|  | ||||
|     let command_macro = CommandMacro { | ||||
|         guild_id: ctx.guild_id().unwrap(), | ||||
|         name: row.name, | ||||
|         description: row.description, | ||||
|         commands, | ||||
|     }; | ||||
|  | ||||
|     Some(command_macro) | ||||
| } | ||||
|   | ||||
							
								
								
									
										48
									
								
								src/models/guild_data.rs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										48
									
								
								src/models/guild_data.rs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,48 @@ | ||||
| use poise::serenity_prelude::GuildId; | ||||
| use sqlx::MySqlPool; | ||||
|  | ||||
| pub struct GuildData { | ||||
|     pub ephemeral_confirmations: bool, | ||||
|     pub id: u32, | ||||
| } | ||||
|  | ||||
| impl GuildData { | ||||
|     pub async fn from_guild( | ||||
|         guild_id: GuildId, | ||||
|         pool: &MySqlPool, | ||||
|     ) -> Result<Self, Box<dyn std::error::Error + Sync + Send>> { | ||||
|         if let Ok(c) = sqlx::query_as_unchecked!( | ||||
|             Self, | ||||
|             "SELECT id, ephemeral_confirmations FROM guilds WHERE guild = ?", | ||||
|             guild_id.0 | ||||
|         ) | ||||
|         .fetch_one(pool) | ||||
|         .await | ||||
|         { | ||||
|             Ok(c) | ||||
|         } else { | ||||
|             sqlx::query!("INSERT IGNORE INTO guilds (guild) VALUES (?)", guild_id.0) | ||||
|                 .execute(&pool.clone()) | ||||
|                 .await?; | ||||
|  | ||||
|             Ok(sqlx::query_as_unchecked!( | ||||
|                 Self, | ||||
|                 "SELECT id, ephemeral_confirmations FROM guilds WHERE guild = ?", | ||||
|                 guild_id.0 | ||||
|             ) | ||||
|             .fetch_one(pool) | ||||
|             .await?) | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     pub async fn commit_changes(&self, pool: &MySqlPool) { | ||||
|         sqlx::query!( | ||||
|             "UPDATE guilds SET ephemeral_confirmations = ? WHERE id = ?", | ||||
|             self.ephemeral_confirmations, | ||||
|             self.id | ||||
|         ) | ||||
|         .execute(pool) | ||||
|         .await | ||||
|         .unwrap(); | ||||
|     } | ||||
| } | ||||
| @@ -1,66 +1,97 @@ | ||||
| pub mod channel_data; | ||||
| pub mod command_macro; | ||||
| pub mod guild_data; | ||||
| pub mod reminder; | ||||
| pub mod timer; | ||||
| pub mod user_data; | ||||
|  | ||||
| use chrono_tz::Tz; | ||||
| use serenity::{ | ||||
|     async_trait, | ||||
|     model::id::{ChannelId, UserId}, | ||||
|     prelude::Context, | ||||
| }; | ||||
| use poise::serenity_prelude::{async_trait, model::id::UserId, ChannelType}; | ||||
|  | ||||
| use crate::{ | ||||
|     models::{channel_data::ChannelData, user_data::UserData}, | ||||
|     SQLPool, | ||||
|     models::{channel_data::ChannelData, guild_data::GuildData, user_data::UserData}, | ||||
|     CommandMacro, Context, Data, Error, GuildId, | ||||
| }; | ||||
|  | ||||
| #[async_trait] | ||||
| pub trait CtxData { | ||||
|     async fn user_data<U: Into<UserId> + Send + Sync>( | ||||
|         &self, | ||||
|         user_id: U, | ||||
|     ) -> Result<UserData, Box<dyn std::error::Error + Sync + Send>>; | ||||
|     async fn user_data<U: Into<UserId> + Send>(&self, user_id: U) -> Result<UserData, Error>; | ||||
|  | ||||
|     async fn timezone<U: Into<UserId> + Send + Sync>(&self, user_id: U) -> Tz; | ||||
|     async fn author_data(&self) -> Result<UserData, Error>; | ||||
|  | ||||
|     async fn channel_data<C: Into<ChannelId> + Send + Sync>( | ||||
|         &self, | ||||
|         channel_id: C, | ||||
|     ) -> Result<ChannelData, Box<dyn std::error::Error + Sync + Send>>; | ||||
|     async fn guild_data(&self) -> Option<Result<GuildData, Error>>; | ||||
|  | ||||
|     async fn timezone(&self) -> Tz; | ||||
|  | ||||
|     async fn channel_data(&self) -> Result<ChannelData, Error>; | ||||
|  | ||||
|     async fn command_macros(&self) -> Result<Vec<CommandMacro<Data, Error>>, Error>; | ||||
| } | ||||
|  | ||||
| #[async_trait] | ||||
| impl CtxData for Context { | ||||
|     async fn user_data<U: Into<UserId> + Send + Sync>( | ||||
| impl CtxData for Context<'_> { | ||||
|     async fn user_data<U: Into<UserId> + Send>(&self, user_id: U) -> Result<UserData, Error> { | ||||
|         UserData::from_user(user_id, &self.serenity_context(), &self.data().database).await | ||||
|     } | ||||
|  | ||||
|     async fn author_data(&self) -> Result<UserData, Error> { | ||||
|         UserData::from_user(&self.author().id, &self.serenity_context(), &self.data().database) | ||||
|             .await | ||||
|     } | ||||
|  | ||||
|     async fn guild_data(&self) -> Option<Result<GuildData, Error>> { | ||||
|         if let Some(guild_id) = self.guild_id() { | ||||
|             Some(GuildData::from_guild(guild_id, &self.data().database).await) | ||||
|         } else { | ||||
|             None | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     async fn timezone(&self) -> Tz { | ||||
|         UserData::timezone_of(self.author().id, &self.data().database).await | ||||
|     } | ||||
|  | ||||
|     async fn channel_data(&self) -> Result<ChannelData, Box<dyn std::error::Error + Sync + Send>> { | ||||
|         // If we're in a thread, get the parent channel. | ||||
|         let recv_channel = self.channel_id().to_channel(&self).await?; | ||||
|  | ||||
|         let channel = match recv_channel.guild() { | ||||
|             Some(guild_channel) => { | ||||
|                 if guild_channel.kind == ChannelType::PublicThread { | ||||
|                     guild_channel.parent_id.unwrap().to_channel_cached(&self).unwrap() | ||||
|                 } else { | ||||
|                     self.channel_id().to_channel_cached(&self).unwrap() | ||||
|                 } | ||||
|             } | ||||
|  | ||||
|             None => self.channel_id().to_channel_cached(&self).unwrap(), | ||||
|         }; | ||||
|  | ||||
|         ChannelData::from_channel(&channel, &self.data().database).await | ||||
|     } | ||||
|  | ||||
|     async fn command_macros(&self) -> Result<Vec<CommandMacro<Data, Error>>, Error> { | ||||
|         self.data().command_macros(self.guild_id().unwrap()).await | ||||
|     } | ||||
| } | ||||
|  | ||||
| impl Data { | ||||
|     pub(crate) async fn command_macros( | ||||
|         &self, | ||||
|         user_id: U, | ||||
|     ) -> Result<UserData, Box<dyn std::error::Error + Sync + Send>> { | ||||
|         let user_id = user_id.into(); | ||||
|         let pool = self.data.read().await.get::<SQLPool>().cloned().unwrap(); | ||||
|         guild_id: GuildId, | ||||
|     ) -> Result<Vec<CommandMacro<Data, Error>>, Error> { | ||||
|         let rows = sqlx::query!( | ||||
|             "SELECT name, description, commands FROM macro WHERE guild_id = (SELECT id FROM guilds WHERE guild = ?)", | ||||
|             guild_id.0 | ||||
|         ) | ||||
|         .fetch_all(&self.database) | ||||
|         .await?.iter().map(|row| CommandMacro { | ||||
|             guild_id, | ||||
|             name: row.name.clone(), | ||||
|             description: row.description.clone(), | ||||
|             commands: serde_json::from_str(&row.commands).unwrap(), | ||||
|         }).collect(); | ||||
|  | ||||
|         let user = user_id.to_user(self).await.unwrap(); | ||||
|  | ||||
|         UserData::from_user(&user, &self, &pool).await | ||||
|     } | ||||
|  | ||||
|     async fn timezone<U: Into<UserId> + Send + Sync>(&self, user_id: U) -> Tz { | ||||
|         let user_id = user_id.into(); | ||||
|         let pool = self.data.read().await.get::<SQLPool>().cloned().unwrap(); | ||||
|  | ||||
|         UserData::timezone_of(user_id, &pool).await | ||||
|     } | ||||
|  | ||||
|     async fn channel_data<C: Into<ChannelId> + Send + Sync>( | ||||
|         &self, | ||||
|         channel_id: C, | ||||
|     ) -> Result<ChannelData, Box<dyn std::error::Error + Sync + Send>> { | ||||
|         let channel_id = channel_id.into(); | ||||
|         let pool = self.data.read().await.get::<SQLPool>().cloned().unwrap(); | ||||
|  | ||||
|         let channel = channel_id.to_channel_cached(&self).unwrap(); | ||||
|  | ||||
|         ChannelData::from_channel(&channel, &pool).await | ||||
|         Ok(rows) | ||||
|     } | ||||
| } | ||||
|   | ||||
| @@ -2,27 +2,26 @@ use std::{collections::HashSet, fmt::Display}; | ||||
|  | ||||
| use chrono::{Duration, NaiveDateTime, Utc}; | ||||
| use chrono_tz::Tz; | ||||
| use serenity::{ | ||||
|     client::Context, | ||||
| use poise::serenity_prelude::{ | ||||
|     http::CacheHttp, | ||||
|     model::{ | ||||
|         channel::GuildChannel, | ||||
|         id::{ChannelId, GuildId, UserId}, | ||||
|         webhook::Webhook, | ||||
|     }, | ||||
|     Result as SerenityResult, | ||||
|     ChannelType, Result as SerenityResult, | ||||
| }; | ||||
| use sqlx::MySqlPool; | ||||
|  | ||||
| use crate::{ | ||||
|     consts, | ||||
|     consts::{MAX_TIME, MIN_INTERVAL}, | ||||
|     consts::{DAY, DEFAULT_AVATAR, MAX_TIME, MIN_INTERVAL}, | ||||
|     interval_parser::Interval, | ||||
|     models::{ | ||||
|         channel_data::ChannelData, | ||||
|         reminder::{content::Content, errors::ReminderError, helper::generate_uid, Reminder}, | ||||
|         user_data::UserData, | ||||
|     }, | ||||
|     SQLPool, | ||||
|     Context, | ||||
| }; | ||||
|  | ||||
| async fn create_webhook( | ||||
| @@ -30,7 +29,7 @@ async fn create_webhook( | ||||
|     channel: GuildChannel, | ||||
|     name: impl Display, | ||||
| ) -> SerenityResult<Webhook> { | ||||
|     channel.create_webhook_with_avatar(ctx.http(), name, consts::DEFAULT_AVATAR.clone()).await | ||||
|     channel.create_webhook_with_avatar(ctx.http(), name, DEFAULT_AVATAR.clone()).await | ||||
| } | ||||
|  | ||||
| #[derive(Hash, PartialEq, Eq)] | ||||
| @@ -52,9 +51,12 @@ pub struct ReminderBuilder { | ||||
|     pool: MySqlPool, | ||||
|     uid: String, | ||||
|     channel: u32, | ||||
|     thread_id: Option<u64>, | ||||
|     utc_time: NaiveDateTime, | ||||
|     timezone: String, | ||||
|     interval: Option<i64>, | ||||
|     interval_seconds: Option<i64>, | ||||
|     interval_days: Option<i64>, | ||||
|     interval_months: Option<i64>, | ||||
|     expires: Option<NaiveDateTime>, | ||||
|     content: String, | ||||
|     tts: bool, | ||||
| @@ -86,7 +88,9 @@ INSERT INTO reminders ( | ||||
|     `channel_id`, | ||||
|     `utc_time`, | ||||
|     `timezone`, | ||||
|     `interval`, | ||||
|     `interval_seconds`, | ||||
|     `interval_days`, | ||||
|     `interval_months`, | ||||
|     `expires`, | ||||
|     `content`, | ||||
|     `tts`, | ||||
| @@ -104,6 +108,8 @@ INSERT INTO reminders ( | ||||
|     ?, | ||||
|     ?, | ||||
|     ?, | ||||
|     ?, | ||||
|     ?, | ||||
|     ? | ||||
| ) | ||||
|             ", | ||||
| @@ -111,7 +117,9 @@ INSERT INTO reminders ( | ||||
|                         self.channel, | ||||
|                         utc_time, | ||||
|                         self.timezone, | ||||
|                         self.interval, | ||||
|                         self.interval_seconds, | ||||
|                         self.interval_days, | ||||
|                         self.interval_months, | ||||
|                         self.expires, | ||||
|                         self.content, | ||||
|                         self.tts, | ||||
| @@ -123,7 +131,7 @@ INSERT INTO reminders ( | ||||
|                     .await | ||||
|                     .unwrap(); | ||||
|  | ||||
|                     Ok(Reminder::from_uid(&self.pool, self.uid).await.unwrap()) | ||||
|                     Ok(Reminder::from_uid(&self.pool, &self.uid).await.unwrap()) | ||||
|                 } | ||||
|             } | ||||
|  | ||||
| @@ -136,11 +144,11 @@ pub struct MultiReminderBuilder<'a> { | ||||
|     scopes: Vec<ReminderScope>, | ||||
|     utc_time: NaiveDateTime, | ||||
|     timezone: Tz, | ||||
|     interval: Option<i64>, | ||||
|     interval: Option<Interval>, | ||||
|     expires: Option<NaiveDateTime>, | ||||
|     content: Content, | ||||
|     set_by: Option<u32>, | ||||
|     ctx: &'a Context, | ||||
|     ctx: &'a Context<'a>, | ||||
|     guild_id: Option<GuildId>, | ||||
| } | ||||
|  | ||||
| @@ -159,6 +167,12 @@ impl<'a> MultiReminderBuilder<'a> { | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     pub fn timezone(mut self, timezone: Tz) -> Self { | ||||
|         self.timezone = timezone; | ||||
|  | ||||
|         self | ||||
|     } | ||||
|  | ||||
|     pub fn content(mut self, content: Content) -> Self { | ||||
|         self.content = content; | ||||
|  | ||||
| @@ -166,17 +180,15 @@ impl<'a> MultiReminderBuilder<'a> { | ||||
|     } | ||||
|  | ||||
|     pub fn time<T: Into<i64>>(mut self, time: T) -> Self { | ||||
|         self.utc_time = NaiveDateTime::from_timestamp(time.into(), 0); | ||||
|         if let Some(utc_time) = NaiveDateTime::from_timestamp_opt(time.into(), 0) { | ||||
|             self.utc_time = utc_time; | ||||
|         } | ||||
|  | ||||
|         self | ||||
|     } | ||||
|  | ||||
|     pub fn expires<T: Into<i64>>(mut self, time: Option<T>) -> Self { | ||||
|         if let Some(t) = time { | ||||
|             self.expires = Some(NaiveDateTime::from_timestamp(t.into(), 0)); | ||||
|         } else { | ||||
|             self.expires = None; | ||||
|         } | ||||
|         self.expires = time.map(|t| NaiveDateTime::from_timestamp_opt(t.into(), 0)).flatten(); | ||||
|  | ||||
|         self | ||||
|     } | ||||
| @@ -188,7 +200,7 @@ impl<'a> MultiReminderBuilder<'a> { | ||||
|         self | ||||
|     } | ||||
|  | ||||
|     pub fn interval(mut self, interval: Option<i64>) -> Self { | ||||
|     pub fn interval(mut self, interval: Option<Interval>) -> Self { | ||||
|         self.interval = interval; | ||||
|  | ||||
|         self | ||||
| @@ -198,28 +210,42 @@ impl<'a> MultiReminderBuilder<'a> { | ||||
|         self.scopes = scopes; | ||||
|     } | ||||
|  | ||||
|     pub async fn build(self) -> (HashSet<ReminderError>, HashSet<ReminderScope>) { | ||||
|         let pool = self.ctx.data.read().await.get::<SQLPool>().cloned().unwrap(); | ||||
|  | ||||
|     pub async fn build(self) -> (HashSet<ReminderError>, HashSet<(Reminder, ReminderScope)>) { | ||||
|         let mut errors = HashSet::new(); | ||||
|  | ||||
|         let mut ok_locs = HashSet::new(); | ||||
|  | ||||
|         if self.interval.map_or(false, |i| (i as i64) < *MIN_INTERVAL) { | ||||
|         if self | ||||
|             .interval | ||||
|             .map_or(false, |i| ((i.sec + i.day * DAY + i.month * 30 * DAY) as i64) < *MIN_INTERVAL) | ||||
|         { | ||||
|             errors.insert(ReminderError::ShortInterval); | ||||
|         } else if self.interval.map_or(false, |i| (i as i64) > *MAX_TIME) { | ||||
|         } else if self | ||||
|             .interval | ||||
|             .map_or(false, |i| ((i.sec + i.day * DAY + i.month * 30 * DAY) as i64) > *MAX_TIME) | ||||
|         { | ||||
|             errors.insert(ReminderError::LongInterval); | ||||
|         } else { | ||||
|             for scope in self.scopes { | ||||
|                 let thread_id = None; | ||||
|                 let db_channel_id = match scope { | ||||
|                     ReminderScope::User(user_id) => { | ||||
|                         if let Ok(user) = UserId(user_id).to_user(&self.ctx).await { | ||||
|                             let user_data = | ||||
|                                 UserData::from_user(&user, &self.ctx, &pool).await.unwrap(); | ||||
|                             let user_data = UserData::from_user( | ||||
|                                 &user, | ||||
|                                 &self.ctx.serenity_context(), | ||||
|                                 &self.ctx.data().database, | ||||
|                             ) | ||||
|                             .await | ||||
|                             .unwrap(); | ||||
|  | ||||
|                             if let Some(guild_id) = self.guild_id { | ||||
|                                 if guild_id.member(&self.ctx, user).await.is_err() { | ||||
|                                     Err(ReminderError::InvalidTag) | ||||
|                                 } else if self.set_by.map_or(true, |i| i != user_data.id) | ||||
|                                     && !user_data.allowed_dm | ||||
|                                 { | ||||
|                                     Err(ReminderError::UserBlockedDm) | ||||
|                                 } else { | ||||
|                                     Ok(user_data.dm_channel) | ||||
|                                 } | ||||
| @@ -233,12 +259,29 @@ impl<'a> MultiReminderBuilder<'a> { | ||||
|                     ReminderScope::Channel(channel_id) => { | ||||
|                         let channel = ChannelId(channel_id).to_channel(&self.ctx).await.unwrap(); | ||||
|  | ||||
|                         if let Some(guild_channel) = channel.clone().guild() { | ||||
|                         if let Some(mut guild_channel) = channel.clone().guild() { | ||||
|                             if Some(guild_channel.guild_id) != self.guild_id { | ||||
|                                 Err(ReminderError::InvalidTag) | ||||
|                             } else { | ||||
|                                 let mut channel_data = | ||||
|                                     ChannelData::from_channel(&channel, &pool).await.unwrap(); | ||||
|                                 let mut channel_data = if guild_channel.kind | ||||
|                                     == ChannelType::PublicThread | ||||
|                                 { | ||||
|                                     // fixme jesus christ | ||||
|                                     let parent = guild_channel | ||||
|                                         .parent_id | ||||
|                                         .unwrap() | ||||
|                                         .to_channel(&self.ctx) | ||||
|                                         .await | ||||
|                                         .unwrap(); | ||||
|                                     guild_channel = parent.clone().guild().unwrap(); | ||||
|                                     ChannelData::from_channel(&parent, &self.ctx.data().database) | ||||
|                                         .await | ||||
|                                         .unwrap() | ||||
|                                 } else { | ||||
|                                     ChannelData::from_channel(&channel, &self.ctx.data().database) | ||||
|                                         .await | ||||
|                                         .unwrap() | ||||
|                                 }; | ||||
|  | ||||
|                                 if channel_data.webhook_id.is_none() | ||||
|                                     || channel_data.webhook_token.is_none() | ||||
| @@ -250,7 +293,9 @@ impl<'a> MultiReminderBuilder<'a> { | ||||
|                                                 Some(webhook.id.as_u64().to_owned()); | ||||
|                                             channel_data.webhook_token = webhook.token; | ||||
|  | ||||
|                                             channel_data.commit_changes(&pool).await; | ||||
|                                             channel_data | ||||
|                                                 .commit_changes(&self.ctx.data().database) | ||||
|                                                 .await; | ||||
|  | ||||
|                                             Ok(channel_data.id) | ||||
|                                         } | ||||
| @@ -270,12 +315,15 @@ impl<'a> MultiReminderBuilder<'a> { | ||||
|                 match db_channel_id { | ||||
|                     Ok(c) => { | ||||
|                         let builder = ReminderBuilder { | ||||
|                             pool: pool.clone(), | ||||
|                             pool: self.ctx.data().database.clone(), | ||||
|                             uid: generate_uid(), | ||||
|                             channel: c, | ||||
|                             thread_id, | ||||
|                             utc_time: self.utc_time, | ||||
|                             timezone: self.timezone.to_string(), | ||||
|                             interval: self.interval, | ||||
|                             interval_seconds: self.interval.map(|i| i.sec as i64), | ||||
|                             interval_days: self.interval.map(|i| i.day as i64), | ||||
|                             interval_months: self.interval.map(|i| i.month as i64), | ||||
|                             expires: self.expires, | ||||
|                             content: self.content.content.clone(), | ||||
|                             tts: self.content.tts, | ||||
| @@ -285,8 +333,8 @@ impl<'a> MultiReminderBuilder<'a> { | ||||
|                         }; | ||||
|  | ||||
|                         match builder.build().await { | ||||
|                             Ok(_) => { | ||||
|                                 ok_locs.insert(scope); | ||||
|                             Ok(r) => { | ||||
|                                 ok_locs.insert((r, scope)); | ||||
|                             } | ||||
|                             Err(e) => { | ||||
|                                 errors.insert(e); | ||||
|   | ||||
| @@ -7,6 +7,7 @@ pub enum ReminderError { | ||||
|     PastTime, | ||||
|     ShortInterval, | ||||
|     InvalidTag, | ||||
|     UserBlockedDm, | ||||
|     DiscordError(String), | ||||
| } | ||||
|  | ||||
| @@ -30,6 +31,9 @@ impl ToString for ReminderError { | ||||
|             ReminderError::InvalidTag => { | ||||
|                 "Couldn't find a location by your tag. Your tag must be either a channel or a user (not a role)".to_string() | ||||
|             } | ||||
|             ReminderError::UserBlockedDm => { | ||||
|                 "User has DM reminders disabled".to_string() | ||||
|             } | ||||
|             ReminderError::DiscordError(s) => format!("A Discord error occurred: **{}**", s), | ||||
|         } | ||||
|     } | ||||
|   | ||||
| @@ -1,25 +1,6 @@ | ||||
| use num_integer::Integer; | ||||
| use rand::{rngs::OsRng, seq::IteratorRandom}; | ||||
|  | ||||
| use crate::consts::{CHARACTERS, DAY, HOUR, MINUTE}; | ||||
|  | ||||
| pub fn longhand_displacement(seconds: u64) -> String { | ||||
|     let (days, seconds) = seconds.div_rem(&DAY); | ||||
|     let (hours, seconds) = seconds.div_rem(&HOUR); | ||||
|     let (minutes, seconds) = seconds.div_rem(&MINUTE); | ||||
|  | ||||
|     let mut sections = vec![]; | ||||
|  | ||||
|     for (var, name) in | ||||
|         [days, hours, minutes, seconds].iter().zip(["days", "hours", "minutes", "seconds"].iter()) | ||||
|     { | ||||
|         if *var > 0 { | ||||
|             sections.push(format!("{} {}", var, name)); | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     sections.join(", ") | ||||
| } | ||||
| use crate::consts::CHARACTERS; | ||||
|  | ||||
| pub fn generate_uid() -> String { | ||||
|     let mut generator: OsRng = Default::default(); | ||||
|   | ||||
| @@ -1,6 +1,6 @@ | ||||
| use poise::serenity_prelude::model::id::ChannelId; | ||||
| use serde::{Deserialize, Serialize}; | ||||
| use serde_repr::*; | ||||
| use serenity::model::id::ChannelId; | ||||
|  | ||||
| #[derive(Serialize_repr, Deserialize_repr, Copy, Clone, Debug)] | ||||
| #[repr(u8)] | ||||
|   | ||||
| @@ -4,20 +4,19 @@ pub mod errors; | ||||
| mod helper; | ||||
| pub mod look_flags; | ||||
|  | ||||
| use chrono::{NaiveDateTime, TimeZone}; | ||||
| use std::hash::{Hash, Hasher}; | ||||
|  | ||||
| use chrono::{DateTime, NaiveDateTime, Utc}; | ||||
| use chrono_tz::Tz; | ||||
| use serenity::{ | ||||
|     client::Context, | ||||
| use poise::serenity_prelude::{ | ||||
|     model::id::{ChannelId, GuildId, UserId}, | ||||
|     Cache, | ||||
| }; | ||||
| use sqlx::MySqlPool; | ||||
| use sqlx::Executor; | ||||
|  | ||||
| use crate::{ | ||||
|     models::reminder::{ | ||||
|         helper::longhand_displacement, | ||||
|         look_flags::{LookFlags, TimeDisplayType}, | ||||
|     }, | ||||
|     SQLPool, | ||||
|     models::reminder::look_flags::{LookFlags, TimeDisplayType}, | ||||
|     Database, | ||||
| }; | ||||
|  | ||||
| #[derive(Debug, Clone)] | ||||
| @@ -25,8 +24,10 @@ pub struct Reminder { | ||||
|     pub id: u32, | ||||
|     pub uid: String, | ||||
|     pub channel: u64, | ||||
|     pub utc_time: NaiveDateTime, | ||||
|     pub interval: Option<u32>, | ||||
|     pub utc_time: DateTime<Utc>, | ||||
|     pub interval_seconds: Option<u32>, | ||||
|     pub interval_days: Option<u32>, | ||||
|     pub interval_months: Option<u32>, | ||||
|     pub expires: Option<NaiveDateTime>, | ||||
|     pub enabled: bool, | ||||
|     pub content: String, | ||||
| @@ -34,8 +35,22 @@ pub struct Reminder { | ||||
|     pub set_by: Option<u64>, | ||||
| } | ||||
|  | ||||
| impl Hash for Reminder { | ||||
|     fn hash<H: Hasher>(&self, state: &mut H) { | ||||
|         self.uid.hash(state); | ||||
|     } | ||||
| } | ||||
|  | ||||
| impl PartialEq<Self> for Reminder { | ||||
|     fn eq(&self, other: &Self) -> bool { | ||||
|         self.uid == other.uid | ||||
|     } | ||||
| } | ||||
|  | ||||
| impl Eq for Reminder {} | ||||
|  | ||||
| impl Reminder { | ||||
|     pub async fn from_uid(pool: &MySqlPool, uid: String) -> Option<Self> { | ||||
|     pub async fn from_uid(pool: impl Executor<'_, Database = Database>, uid: &str) -> Option<Self> { | ||||
|         sqlx::query_as_unchecked!( | ||||
|             Self, | ||||
|             " | ||||
| @@ -44,7 +59,9 @@ SELECT | ||||
|     reminders.uid, | ||||
|     channels.channel, | ||||
|     reminders.utc_time, | ||||
|     reminders.interval, | ||||
|     reminders.interval_seconds, | ||||
|     reminders.interval_days, | ||||
|     reminders.interval_months, | ||||
|     reminders.expires, | ||||
|     reminders.enabled, | ||||
|     reminders.content, | ||||
| @@ -70,16 +87,7 @@ WHERE | ||||
|         .ok() | ||||
|     } | ||||
|  | ||||
|     pub async fn from_channel<C: Into<ChannelId>>( | ||||
|         ctx: &Context, | ||||
|         channel_id: C, | ||||
|         flags: &LookFlags, | ||||
|     ) -> Vec<Self> { | ||||
|         let pool = ctx.data.read().await.get::<SQLPool>().cloned().unwrap(); | ||||
|  | ||||
|         let enabled = if flags.show_disabled { "0,1" } else { "1" }; | ||||
|         let channel_id = channel_id.into(); | ||||
|  | ||||
|     pub async fn from_id(pool: impl Executor<'_, Database = Database>, id: u32) -> Option<Self> { | ||||
|         sqlx::query_as_unchecked!( | ||||
|             Self, | ||||
|             " | ||||
| @@ -88,7 +96,9 @@ SELECT | ||||
|     reminders.uid, | ||||
|     channels.channel, | ||||
|     reminders.utc_time, | ||||
|     reminders.interval, | ||||
|     reminders.interval_seconds, | ||||
|     reminders.interval_days, | ||||
|     reminders.interval_months, | ||||
|     reminders.expires, | ||||
|     reminders.enabled, | ||||
|     reminders.content, | ||||
| @@ -105,6 +115,51 @@ LEFT JOIN | ||||
| ON | ||||
|     reminders.set_by = users.id | ||||
| WHERE | ||||
|     reminders.id = ? | ||||
|             ", | ||||
|             id | ||||
|         ) | ||||
|         .fetch_one(pool) | ||||
|         .await | ||||
|         .ok() | ||||
|     } | ||||
|  | ||||
|     pub async fn from_channel<C: Into<ChannelId>>( | ||||
|         pool: impl Executor<'_, Database = Database>, | ||||
|         channel_id: C, | ||||
|         flags: &LookFlags, | ||||
|     ) -> Vec<Self> { | ||||
|         let enabled = if flags.show_disabled { "0,1" } else { "1" }; | ||||
|         let channel_id = channel_id.into(); | ||||
|  | ||||
|         sqlx::query_as_unchecked!( | ||||
|             Self, | ||||
|             " | ||||
| SELECT | ||||
|     reminders.id, | ||||
|     reminders.uid, | ||||
|     channels.channel, | ||||
|     reminders.utc_time, | ||||
|     reminders.interval_seconds, | ||||
|     reminders.interval_days, | ||||
|     reminders.interval_months, | ||||
|     reminders.expires, | ||||
|     reminders.enabled, | ||||
|     reminders.content, | ||||
|     reminders.embed_description, | ||||
|     users.user AS set_by | ||||
| FROM | ||||
|     reminders | ||||
| INNER JOIN | ||||
|     channels | ||||
| ON | ||||
|     reminders.channel_id = channels.id | ||||
| LEFT JOIN | ||||
|     users | ||||
| ON | ||||
|     reminders.set_by = users.id | ||||
| WHERE | ||||
|     `status` = 'pending' AND | ||||
|     channels.channel = ? AND | ||||
|     FIND_IN_SET(reminders.enabled, ?) | ||||
| ORDER BY | ||||
| @@ -113,16 +168,19 @@ ORDER BY | ||||
|             channel_id.as_u64(), | ||||
|             enabled, | ||||
|         ) | ||||
|         .fetch_all(&pool) | ||||
|         .fetch_all(pool) | ||||
|         .await | ||||
|         .unwrap() | ||||
|     } | ||||
|  | ||||
|     pub async fn from_guild(ctx: &Context, guild_id: Option<GuildId>, user: UserId) -> Vec<Self> { | ||||
|         let pool = ctx.data.read().await.get::<SQLPool>().cloned().unwrap(); | ||||
|  | ||||
|     pub async fn from_guild( | ||||
|         cache: impl AsRef<Cache>, | ||||
|         pool: impl Executor<'_, Database = Database>, | ||||
|         guild_id: Option<GuildId>, | ||||
|         user: UserId, | ||||
|     ) -> Vec<Self> { | ||||
|         if let Some(guild_id) = guild_id { | ||||
|             let guild_opt = guild_id.to_guild_cached(&ctx); | ||||
|             let guild_opt = guild_id.to_guild_cached(cache); | ||||
|  | ||||
|             if let Some(guild) = guild_opt { | ||||
|                 let channels = guild | ||||
| @@ -141,7 +199,9 @@ SELECT | ||||
|     reminders.uid, | ||||
|     channels.channel, | ||||
|     reminders.utc_time, | ||||
|     reminders.interval, | ||||
|     reminders.interval_seconds, | ||||
|     reminders.interval_days, | ||||
|     reminders.interval_months, | ||||
|     reminders.expires, | ||||
|     reminders.enabled, | ||||
|     reminders.content, | ||||
| @@ -158,11 +218,12 @@ LEFT JOIN | ||||
| ON | ||||
|     reminders.set_by = users.id | ||||
| WHERE | ||||
|     `status` = 'pending' AND | ||||
|     FIND_IN_SET(channels.channel, ?) | ||||
|                 ", | ||||
|                     channels | ||||
|                 ) | ||||
|                 .fetch_all(&pool) | ||||
|                 .fetch_all(pool) | ||||
|                 .await | ||||
|             } else { | ||||
|                 sqlx::query_as_unchecked!( | ||||
| @@ -173,7 +234,9 @@ SELECT | ||||
|     reminders.uid, | ||||
|     channels.channel, | ||||
|     reminders.utc_time, | ||||
|     reminders.interval, | ||||
|     reminders.interval_seconds, | ||||
|     reminders.interval_days, | ||||
|     reminders.interval_months, | ||||
|     reminders.expires, | ||||
|     reminders.enabled, | ||||
|     reminders.content, | ||||
| @@ -190,11 +253,12 @@ LEFT JOIN | ||||
| ON | ||||
|     reminders.set_by = users.id | ||||
| WHERE | ||||
|     `status` = 'pending' AND | ||||
|     channels.guild_id = (SELECT id FROM guilds WHERE guild = ?) | ||||
|                 ", | ||||
|                     guild_id.as_u64() | ||||
|                 ) | ||||
|                 .fetch_all(&pool) | ||||
|                 .fetch_all(pool) | ||||
|                 .await | ||||
|             } | ||||
|         } else { | ||||
| @@ -206,7 +270,9 @@ SELECT | ||||
|     reminders.uid, | ||||
|     channels.channel, | ||||
|     reminders.utc_time, | ||||
|     reminders.interval, | ||||
|     reminders.interval_seconds, | ||||
|     reminders.interval_days, | ||||
|     reminders.interval_months, | ||||
|     reminders.expires, | ||||
|     reminders.enabled, | ||||
|     reminders.content, | ||||
| @@ -223,16 +289,27 @@ LEFT JOIN | ||||
| ON | ||||
|     reminders.set_by = users.id | ||||
| WHERE | ||||
|     `status` = 'pending' AND | ||||
|     channels.id = (SELECT dm_channel FROM users WHERE user = ?) | ||||
|             ", | ||||
|                 user.as_u64() | ||||
|             ) | ||||
|             .fetch_all(&pool) | ||||
|             .fetch_all(pool) | ||||
|             .await | ||||
|         } | ||||
|         .unwrap() | ||||
|     } | ||||
|  | ||||
|     pub async fn delete( | ||||
|         &self, | ||||
|         db: impl Executor<'_, Database = Database>, | ||||
|     ) -> Result<(), sqlx::Error> { | ||||
|         sqlx::query!("UPDATE reminders SET `status` = 'deleted' WHERE uid = ?", self.uid) | ||||
|             .execute(db) | ||||
|             .await | ||||
|             .map(|_| ()) | ||||
|     } | ||||
|  | ||||
|     pub fn display_content(&self) -> &str { | ||||
|         if self.content.is_empty() { | ||||
|             &self.embed_description | ||||
| @@ -247,34 +324,32 @@ WHERE | ||||
|             count + 1, | ||||
|             self.display_content(), | ||||
|             self.channel, | ||||
|             timezone | ||||
|                 .timestamp(self.utc_time.timestamp(), 0) | ||||
|                 .format("%Y-%m-%d %H:%M:%S") | ||||
|                 .to_string() | ||||
|             self.utc_time.with_timezone(timezone).format("%Y-%m-%d %H:%M:%S") | ||||
|         ) | ||||
|     } | ||||
|  | ||||
|     pub fn display(&self, flags: &LookFlags, timezone: &Tz) -> String { | ||||
|         let time_display = match flags.time_display { | ||||
|             TimeDisplayType::Absolute => timezone | ||||
|                 .timestamp(self.utc_time.timestamp(), 0) | ||||
|                 .format("%Y-%m-%d %H:%M:%S") | ||||
|                 .to_string(), | ||||
|             TimeDisplayType::Absolute => { | ||||
|                 self.utc_time.with_timezone(timezone).format("%Y-%m-%d %H:%M:%S").to_string() | ||||
|             } | ||||
|  | ||||
|             TimeDisplayType::Relative => format!("<t:{}:R>", self.utc_time.timestamp()), | ||||
|         }; | ||||
|  | ||||
|         if let Some(interval) = self.interval { | ||||
|         if self.interval_seconds.is_some() | ||||
|             || self.interval_days.is_some() | ||||
|             || self.interval_months.is_some() | ||||
|         { | ||||
|             format!( | ||||
|                 "'{}' *occurs next at* **{}**, repeating every **{}** (set by {})", | ||||
|                 "'{}' *occurs next at* **{}**, repeating (set by {})\n", | ||||
|                 self.display_content(), | ||||
|                 time_display, | ||||
|                 longhand_displacement(interval as u64), | ||||
|                 self.set_by.map(|i| format!("<@{}>", i)).unwrap_or_else(|| "unknown".to_string()) | ||||
|             ) | ||||
|         } else { | ||||
|             format!( | ||||
|                 "'{}' *occurs next at* **{}** (set by {})", | ||||
|                 "'{}' *occurs next at* **{}** (set by {})\n", | ||||
|                 self.display_content(), | ||||
|                 time_display, | ||||
|                 self.set_by.map(|i| format!("<@{}>", i)).unwrap_or_else(|| "unknown".to_string()) | ||||
|   | ||||
| @@ -1,9 +1,9 @@ | ||||
| use chrono::NaiveDateTime; | ||||
| use chrono::{DateTime, Utc}; | ||||
| use sqlx::MySqlPool; | ||||
|  | ||||
| pub struct Timer { | ||||
|     pub name: String, | ||||
|     pub start_time: NaiveDateTime, | ||||
|     pub start_time: DateTime<Utc>, | ||||
|     pub owner: u64, | ||||
| } | ||||
|  | ||||
|   | ||||
| @@ -1,9 +1,6 @@ | ||||
| use chrono_tz::Tz; | ||||
| use log::error; | ||||
| use serenity::{ | ||||
|     http::CacheHttp, | ||||
|     model::{id::UserId, user::User}, | ||||
| }; | ||||
| use poise::serenity_prelude::{http::CacheHttp, model::id::UserId}; | ||||
| use sqlx::MySqlPool; | ||||
|  | ||||
| use crate::consts::LOCAL_TIMEZONE; | ||||
| @@ -11,9 +8,9 @@ use crate::consts::LOCAL_TIMEZONE; | ||||
| pub struct UserData { | ||||
|     pub id: u32, | ||||
|     pub user: u64, | ||||
|     pub name: String, | ||||
|     pub dm_channel: u32, | ||||
|     pub timezone: String, | ||||
|     pub allowed_dm: bool, | ||||
| } | ||||
|  | ||||
| impl UserData { | ||||
| @@ -25,7 +22,7 @@ impl UserData { | ||||
|  | ||||
|         match sqlx::query!( | ||||
|             " | ||||
| SELECT timezone FROM users WHERE user = ? | ||||
| SELECT IFNULL(timezone, 'UTC') AS timezone FROM users WHERE user = ? | ||||
|             ", | ||||
|             user_id | ||||
|         ) | ||||
| @@ -40,20 +37,20 @@ SELECT timezone FROM users WHERE user = ? | ||||
|         .unwrap() | ||||
|     } | ||||
|  | ||||
|     pub async fn from_user( | ||||
|         user: &User, | ||||
|     pub async fn from_user<U: Into<UserId>>( | ||||
|         user: U, | ||||
|         ctx: impl CacheHttp, | ||||
|         pool: &MySqlPool, | ||||
|     ) -> Result<Self, Box<dyn std::error::Error + Sync + Send>> { | ||||
|         let user_id = user.id.as_u64().to_owned(); | ||||
|         let user_id = user.into(); | ||||
|  | ||||
|         match sqlx::query_as_unchecked!( | ||||
|             Self, | ||||
|             " | ||||
| SELECT id, user, name, dm_channel, IF(timezone IS NULL, ?, timezone) AS timezone FROM users WHERE user = ? | ||||
| SELECT id, user, dm_channel, IF(timezone IS NULL, ?, timezone) AS timezone, allowed_dm FROM users WHERE user = ? | ||||
|             ", | ||||
|             *LOCAL_TIMEZONE, | ||||
|             user_id | ||||
|             user_id.0 | ||||
|         ) | ||||
|         .fetch_one(pool) | ||||
|         .await | ||||
| @@ -61,27 +58,24 @@ SELECT id, user, name, dm_channel, IF(timezone IS NULL, ?, timezone) AS timezone | ||||
|             Ok(c) => Ok(c), | ||||
|  | ||||
|             Err(sqlx::Error::RowNotFound) => { | ||||
|                 let dm_channel = user.create_dm_channel(ctx).await?; | ||||
|                 let dm_id = dm_channel.id.as_u64().to_owned(); | ||||
|  | ||||
|                 let dm_channel = user_id.create_dm_channel(ctx).await?; | ||||
|                 let pool_c = pool.clone(); | ||||
|  | ||||
|                 sqlx::query!( | ||||
|                     " | ||||
| INSERT IGNORE INTO channels (channel) VALUES (?) | ||||
|                     ", | ||||
|                     dm_id | ||||
|                     dm_channel.id.0 | ||||
|                 ) | ||||
|                 .execute(&pool_c) | ||||
|                 .await?; | ||||
|  | ||||
|                 sqlx::query!( | ||||
|                     " | ||||
| INSERT INTO users (user, name, dm_channel, timezone) VALUES (?, ?, (SELECT id FROM channels WHERE channel = ?), ?) | ||||
| INSERT INTO users (name, user, dm_channel, timezone) VALUES ('', ?, (SELECT id FROM channels WHERE channel = ?), ?) | ||||
|                     ", | ||||
|                     user_id, | ||||
|                     user.name, | ||||
|                     dm_id, | ||||
|                     user_id.0, | ||||
|                     dm_channel.id.0, | ||||
|                     *LOCAL_TIMEZONE | ||||
|                 ) | ||||
|                 .execute(&pool_c) | ||||
| @@ -90,9 +84,9 @@ INSERT INTO users (user, name, dm_channel, timezone) VALUES (?, ?, (SELECT id FR | ||||
|                 Ok(sqlx::query_as_unchecked!( | ||||
|                     Self, | ||||
|                     " | ||||
| SELECT id, user, name, dm_channel, timezone FROM users WHERE user = ? | ||||
| SELECT id, user, dm_channel, timezone, allowed_dm FROM users WHERE user = ? | ||||
|                     ", | ||||
|                     user_id | ||||
|                     user_id.0 | ||||
|                 ) | ||||
|                 .fetch_one(pool) | ||||
|                 .await?) | ||||
| @@ -109,10 +103,10 @@ SELECT id, user, name, dm_channel, timezone FROM users WHERE user = ? | ||||
|     pub async fn commit_changes(&self, pool: &MySqlPool) { | ||||
|         sqlx::query!( | ||||
|             " | ||||
| UPDATE users SET name = ?, timezone = ? WHERE id = ? | ||||
| UPDATE users SET timezone = ?, allowed_dm = ? WHERE id = ? | ||||
|             ", | ||||
|             self.name, | ||||
|             self.timezone, | ||||
|             self.allowed_dm, | ||||
|             self.id | ||||
|         ) | ||||
|         .execute(pool) | ||||
|   | ||||
| @@ -211,14 +211,12 @@ pub async fn natural_parser(time: &str, timezone: &str) -> Option<i64> { | ||||
|         .output() | ||||
|         .await | ||||
|         .ok() | ||||
|         .map(|inner| { | ||||
|         .and_then(|inner| { | ||||
|             if inner.status.success() { | ||||
|                 Some(from_utf8(&*inner.stdout).unwrap().parse::<i64>().unwrap()) | ||||
|             } else { | ||||
|                 None | ||||
|             } | ||||
|         }) | ||||
|         .flatten() | ||||
|         .map(|inner| if inner < 0 { None } else { Some(inner) }) | ||||
|         .flatten() | ||||
|         .and_then(|inner| if inner < 0 { None } else { Some(inner) }) | ||||
| } | ||||
|   | ||||
							
								
								
									
										108
									
								
								src/utils.rs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										108
									
								
								src/utils.rs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,108 @@ | ||||
| use poise::{ | ||||
|     serenity_prelude as serenity, | ||||
|     serenity_prelude::{ | ||||
|         builder::CreateApplicationCommands, | ||||
|         http::CacheHttp, | ||||
|         interaction::MessageFlags, | ||||
|         model::id::{GuildId, UserId}, | ||||
|     }, | ||||
| }; | ||||
|  | ||||
| use crate::{ | ||||
|     consts::{CNC_GUILD, SUBSCRIPTION_ROLES}, | ||||
|     Data, Error, | ||||
| }; | ||||
|  | ||||
| pub async fn register_application_commands( | ||||
|     ctx: &serenity::Context, | ||||
|     framework: &poise::Framework<Data, Error>, | ||||
|     guild_id: Option<GuildId>, | ||||
| ) -> Result<(), serenity::Error> { | ||||
|     let mut commands_builder = CreateApplicationCommands::default(); | ||||
|     let commands = &framework.options().commands; | ||||
|     for command in commands { | ||||
|         if let Some(slash_command) = command.create_as_slash_command() { | ||||
|             commands_builder.add_application_command(slash_command); | ||||
|         } | ||||
|         if let Some(context_menu_command) = command.create_as_context_menu_command() { | ||||
|             commands_builder.add_application_command(context_menu_command); | ||||
|         } | ||||
|     } | ||||
|     let commands_builder = poise::serenity_prelude::json::Value::Array(commands_builder.0); | ||||
|  | ||||
|     if let Some(guild_id) = guild_id { | ||||
|         ctx.http.create_guild_application_commands(guild_id.0, &commands_builder).await?; | ||||
|     } else { | ||||
|         ctx.http.create_global_application_commands(&commands_builder).await?; | ||||
|     } | ||||
|  | ||||
|     Ok(()) | ||||
| } | ||||
|  | ||||
| pub async fn check_subscription(cache_http: impl CacheHttp, user_id: impl Into<UserId>) -> bool { | ||||
|     if let Some(subscription_guild) = *CNC_GUILD { | ||||
|         let guild_member = GuildId(subscription_guild).member(cache_http, user_id).await; | ||||
|  | ||||
|         if let Ok(member) = guild_member { | ||||
|             for role in member.roles { | ||||
|                 if SUBSCRIPTION_ROLES.contains(role.as_u64()) { | ||||
|                     return true; | ||||
|                 } | ||||
|             } | ||||
|         } | ||||
|  | ||||
|         false | ||||
|     } else { | ||||
|         true | ||||
|     } | ||||
| } | ||||
|  | ||||
| pub async fn check_guild_subscription( | ||||
|     cache_http: impl CacheHttp, | ||||
|     guild_id: impl Into<GuildId>, | ||||
| ) -> bool { | ||||
|     if let Some(guild) = cache_http.cache().unwrap().guild(guild_id) { | ||||
|         let owner = guild.owner_id; | ||||
|  | ||||
|         check_subscription(&cache_http, owner).await | ||||
|     } else { | ||||
|         false | ||||
|     } | ||||
| } | ||||
|  | ||||
| /// Sends the message, specified via [`crate::CreateReply`], to the interaction initial response | ||||
| /// endpoint | ||||
| pub fn send_as_initial_response( | ||||
|     data: poise::CreateReply<'_>, | ||||
|     f: &mut serenity::CreateInteractionResponseData, | ||||
| ) { | ||||
|     let poise::CreateReply { | ||||
|         content, | ||||
|         embeds, | ||||
|         attachments: _, // serenity doesn't support attachments in initial response yet | ||||
|         components, | ||||
|         ephemeral, | ||||
|         allowed_mentions, | ||||
|         reply: _, | ||||
|     } = data; | ||||
|  | ||||
|     if let Some(content) = content { | ||||
|         f.content(content); | ||||
|     } | ||||
|     f.set_embeds(embeds); | ||||
|     if let Some(allowed_mentions) = allowed_mentions { | ||||
|         f.allowed_mentions(|f| { | ||||
|             *f = allowed_mentions.clone(); | ||||
|             f | ||||
|         }); | ||||
|     } | ||||
|     if let Some(components) = components { | ||||
|         f.components(|f| { | ||||
|             f.0 = components.0; | ||||
|             f | ||||
|         }); | ||||
|     } | ||||
|     if ephemeral { | ||||
|         f.flags(MessageFlags::EPHEMERAL); | ||||
|     } | ||||
| } | ||||
							
								
								
									
										14
									
								
								systemd/reminder-rs.service
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										14
									
								
								systemd/reminder-rs.service
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,14 @@ | ||||
| [Unit] | ||||
| Description=Reminder Bot | ||||
|  | ||||
| [Service] | ||||
| User=reminder | ||||
| Type=simple | ||||
| ExecStart=/usr/bin/reminder-rs | ||||
| WorkingDirectory=/etc/reminder-rs | ||||
| Restart=always | ||||
| RestartSec=4 | ||||
| Environment="reminder_rs=warn,postman=warn" | ||||
|  | ||||
| [Install] | ||||
| WantedBy=multi-user.target | ||||
							
								
								
									
										22
									
								
								web/Cargo.toml
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										22
									
								
								web/Cargo.toml
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,22 @@ | ||||
| [package] | ||||
| name = "reminder_web" | ||||
| version = "0.1.3" | ||||
| authors = ["jellywx <judesouthworth@pm.me>"] | ||||
| edition = "2018" | ||||
|  | ||||
| [dependencies] | ||||
| rocket = { git = "https://github.com/SergioBenitez/Rocket", branch = "master", features = ["tls", "secrets", "json"] } | ||||
| rocket_dyn_templates = { git = "https://github.com/SergioBenitez/Rocket", branch = "master", features = ["tera"] } | ||||
| serenity = { version = "0.11", default-features = false, features = ["builder", "cache", "client", "gateway", "http", "model", "utils", "rustls_backend"] } | ||||
| oauth2 = "4" | ||||
| log = "0.4" | ||||
| reqwest = "0.11" | ||||
| serde = { version = "1.0", features = ["derive"] } | ||||
| sqlx = { version = "0.7", features = ["runtime-tokio-rustls", "macros", "mysql", "chrono", "json"] } | ||||
| chrono = "0.4" | ||||
| chrono-tz = "0.8" | ||||
| lazy_static = "1.4.0" | ||||
| rand = "0.8" | ||||
| base64 = "0.13" | ||||
| csv = "1.2" | ||||
| prometheus = "0.13.3" | ||||
							
								
								
									
										32
									
								
								web/private/ca_cert.pem
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										32
									
								
								web/private/ca_cert.pem
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,32 @@ | ||||
| -----BEGIN CERTIFICATE----- | ||||
| MIIFbzCCA1egAwIBAgIUUY2fYP5h41dtqcuNLVUS99N7+jAwDQYJKoZIhvcNAQEL | ||||
| BQAwRzELMAkGA1UEBhMCVVMxCzAJBgNVBAgMAkNBMRIwEAYDVQQKDAlSb2NrZXQg | ||||
| Q0ExFzAVBgNVBAMMDlJvY2tldCBSb290IENBMB4XDTIxMDUxNzE5MDIyNFoXDTMx | ||||
| MDUxNTE5MDIyNFowRzELMAkGA1UEBhMCVVMxCzAJBgNVBAgMAkNBMRIwEAYDVQQK | ||||
| DAlSb2NrZXQgQ0ExFzAVBgNVBAMMDlJvY2tldCBSb290IENBMIICIjANBgkqhkiG | ||||
| 9w0BAQEFAAOCAg8AMIICCgKCAgEA3MmVOUxbZ0rtBBbXjWkkM2n2IoPuqfiENdrM | ||||
| NH9IcIbEgFb7AQ/c2vGBLWWw2RFtXxe2ubbgcHyAySQ+ENGEf+W2yox5JpcOOBP+ | ||||
| /KrUQ4ROqxLBWOD6+fA/q5m9/CXXJosWXly3DFE9nUEUlNXcaSxJm/bxIqnxwEMQ | ||||
| NESWdG1qlsGVhHLi5sflgwAcYH+nZzdtINLyvQ5AGIs8s6LpmvUVbC3I+5oNNSwW | ||||
| rRsIfCFaYLuUx7jZMaOk8Dfz8MbFL68+7OCGwmhduSHoGW8jVzDXwXv/crAmFtau | ||||
| zmu43/vPDwKa4w/kQJLNr1QmiGz9FBt8DXVJ0s5bokOIlodEgAvjsL3xwnrKak0F | ||||
| 8ak74IBetfDdVvuC9WWmJ9/dj3z91KvCWLdZGm8ijlYZ2OuaiiJ/oC6iebaPFHEY | ||||
| IQmF5wzT/nZLIbQsti9wFnOpptgkhDAcKIAk3+pncuMwZ9WRvWO2IFt+ZCqKCCDU | ||||
| JliDZmm3ow/zob8DD6Txex5OkJGUF6iu8o7fjI9BxsjSc76bLrxIhvR43UXjzoHl | ||||
| t3lvDH8SzSN/kxzQMXVl48h3xydJ3+ZyDOLle1bqMga8Kcin9GcEUqmL1JcyFafe | ||||
| CUE/Nk1pHyrlnhpMCYIDVI3pmNLB0/m/IEWLckSr9+hXyUPJQyWiyrixwC86SPxZ | ||||
| AonU2aUCAwEAAaNTMFEwHQYDVR0OBBYEFJ5uQa9kD5fioNeS/Ff/mxDcMAzhMB8G | ||||
| A1UdIwQYMBaAFJ5uQa9kD5fioNeS/Ff/mxDcMAzhMA8GA1UdEwEB/wQFMAMBAf8w | ||||
| DQYJKoZIhvcNAQELBQADggIBABf7adF1J40/8EjfSJ5XdG7OBUrZTas3+nuULh8B | ||||
| 6h1igAq0BgX9v3awmPCaxqDLqwJCsFZCk0s9Vgd62McKVKasyW1TQchWbdvlqeAB | ||||
| QQfZpJtAZK12dcMl6iznC/JBTPvYl9q1FKS8kYtg19in/xlhxiiEJaL5z+slpTgT | ||||
| cN12650W2FqjT9sD9jshZI/a8o53d2yUBXBBecCZ49djceBGLbxbteEi/sb9qM4f | ||||
| IUxeSrvK6owxKMZ5okUqZWaFseFCkdMKpMg9eecyfSTweac8yLlZmeqX5D/H85Hr | ||||
| hnWHjI5NeVZAoDkdmNeaDbAIv4f3prqC8uFP3ub8D0rG/WiPdOAd79KNgqbUUFyp | ||||
| NbjSiAesx4Rm1WIgjHljFQKXJdtnxt+v1VkKV9entXJX2tye7+Z1+zurNYyUyM1J | ||||
| COdXeV4jpq2mP8cLpAqX7ip1tNx9YMy5ctbAE1WsUw7lPyO91+VN2Q6MN5OyemY3 | ||||
| 4nBzRJU8X1nsDgeNQWjzb/ZC7eoS916Gywk8Hu6GoIwvYMm3zea25n6mAAOX17vE | ||||
| 1YdvSzUlnVbwnbgd4BgFRGUfBVjO7qvFC7ZWLngWhBzIIYIGUJfO2rippgkxAoHH | ||||
| dgWYk1MNnk2yM8WSo0VKoe4yuF9NwPlfPqeCE683JHdwGEJKZWMocszzHrGZ2KX2 | ||||
| I4/u | ||||
| -----END CERTIFICATE----- | ||||
							
								
								
									
										51
									
								
								web/private/ca_key.pem
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										51
									
								
								web/private/ca_key.pem
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,51 @@ | ||||
| -----BEGIN RSA PRIVATE KEY----- | ||||
| MIIJKAIBAAKCAgEA3MmVOUxbZ0rtBBbXjWkkM2n2IoPuqfiENdrMNH9IcIbEgFb7 | ||||
| AQ/c2vGBLWWw2RFtXxe2ubbgcHyAySQ+ENGEf+W2yox5JpcOOBP+/KrUQ4ROqxLB | ||||
| WOD6+fA/q5m9/CXXJosWXly3DFE9nUEUlNXcaSxJm/bxIqnxwEMQNESWdG1qlsGV | ||||
| hHLi5sflgwAcYH+nZzdtINLyvQ5AGIs8s6LpmvUVbC3I+5oNNSwWrRsIfCFaYLuU | ||||
| x7jZMaOk8Dfz8MbFL68+7OCGwmhduSHoGW8jVzDXwXv/crAmFtauzmu43/vPDwKa | ||||
| 4w/kQJLNr1QmiGz9FBt8DXVJ0s5bokOIlodEgAvjsL3xwnrKak0F8ak74IBetfDd | ||||
| VvuC9WWmJ9/dj3z91KvCWLdZGm8ijlYZ2OuaiiJ/oC6iebaPFHEYIQmF5wzT/nZL | ||||
| IbQsti9wFnOpptgkhDAcKIAk3+pncuMwZ9WRvWO2IFt+ZCqKCCDUJliDZmm3ow/z | ||||
| ob8DD6Txex5OkJGUF6iu8o7fjI9BxsjSc76bLrxIhvR43UXjzoHlt3lvDH8SzSN/ | ||||
| kxzQMXVl48h3xydJ3+ZyDOLle1bqMga8Kcin9GcEUqmL1JcyFafeCUE/Nk1pHyrl | ||||
| nhpMCYIDVI3pmNLB0/m/IEWLckSr9+hXyUPJQyWiyrixwC86SPxZAonU2aUCAwEA | ||||
| AQKCAgBVSXlfXOOiDwtnnPs/IPJe+fuecaBsABfyRcbEMLbm4OhfOzpSurHx0YC4 | ||||
| 7KNX9qdtKFfpfX9NdIq7KEjhbk3kqfPmYkUaZxeTCgZhzAua2S0aYHBXyPCqQ+gU | ||||
| fZsqH+Pwe6H0aZQ8KdXHPTCaHdK6veThXo7feQ5t2noT9rq31txpx/Xd6BNGWsmJ | ||||
| xS0xCZ68/GgnWdVyumKAGKkmKzRaK3pPA5CzwFqBw7ouvFaWvLuQymU6kWk1B6Xb | ||||
| NYIB7IaXWPbRwhnMV0x9C2ABEzFvqOpvT1rqDqloAR4dlvcfbsIZZkQ2mhjt6MeT | ||||
| hsorwQ4yCjvtZvVRfW1gTP4iR7ZpmHgHIYQDJy7raZqRVNe9WgMxKTS/IYsHvEvH | ||||
| MUBOfQEclAQ8FTUOgTg9+Hf9VXJOtjZRxY08gb+OgKxoAQKxRZmLDUZli9SNWzGe | ||||
| R3UECh0gImm/CzWnV3fercLX+qHLTcyJFcb2gclAfG8v8cOT2qu8RtsJAQM2ZSn7 | ||||
| L8FaWXewjAwkZwCl8Zl5ky1RbBXUkcuLIkAeLAl0+ffM9xiwLhiIj8gRJoq0/jSr | ||||
| K1pA8CQ/rZC+9RsI8hP4x2Fn1CU8bOyEBPeNFEIDJHBiCrs/Rl6EAzDMJHlhL/HT | ||||
| f7bqssuRjnSbRCKaAdtfGTxQUEjefvqJ11xE3BfHGnKPqoasMQKCAQEA8nY+3MAB | ||||
| eBPlE/H4JeVpj7/9/q+JWLFFH9E3Re25LbObzRzIQOd5bTCJkOPQXYRbRIZ1pMt9 | ||||
| +nZzeLKvWn5nuUFgG2S4n+BEJkBDV78LOkUuGIAPdztF2mwVumygEGJFFfZHbYrh | ||||
| XtaGUKdNxn1R3Z4B8W5jWpNNkU+dvoq4Zt0LrL28HB4Sa2yrtHeXbhqSb0BA15+N | ||||
| vO9mlfX2I6Yr8F+L/OBfxB0gaNLISJJsj5NSm302je/QrfoMAwbePoNPjEX1/qd2 | ||||
| rgj9JfM+NE2FsVnFhrV+q4DywRUdX4VBFP4+x7fPm8LxvtVUmLVA3/NtGwPdnC6U | ||||
| mQh/+kKVhU2AQwKCAQEA6R2FrxZIUdmk45b/tSjlq7r1ycBYeSztjzXw6hx+w/K3 | ||||
| Lf+5OGyoGoKr5bZHMhHFxqoWzP6kiZ2Uyvu+pLAGLfenJPec89ZncN4FvW9yU8yL | ||||
| nE2Sc8Vf2GnOw2KGJcJR11Ew4dDspHfnM3rof2G4gPC/EMZeGojXoc5GHmT4iasD | ||||
| Xwje4qqx5WKvRu1Rw2y+XM5h4gDn3QLLojDOvBpt22yaqSTAupY7OliGFO9h2WPL | ||||
| r9TL6va87nXNepCdtzuSlxqTVtgMu2wVu3CnQyw9RiD2M31YnUtBDLNy/UNFj/5Z | ||||
| 6uXYuakh8vSFFvjgCUyQwR1xCTJur/6LdpAnt9Bz9wKCAQAUqoN9KVh2tatm4c72 | ||||
| 2/D9ca3ikW+xgZqUta5yZWrNPGvhNbzT22b8KZDwKprN/cQRuSw52aZpPMNm3EQa | ||||
| AIAyyCG69ADQj7r/T6btybjZRKBDMlcfIIw5q9DGTQ/vlZCx6IX6DkZbYQmdwkTc | ||||
| 0D20GA2uWGxbggawhgq5/PTuv5SJKrrn4qBLS73u6eqcVeN5XA6q0kywd+9UhNxv | ||||
| +W/xUxOJgE5pVto2VREBLonWSwZVfnyx6GjvC0sOzv0Ocv7KxAPNqtRwzQ9Wtr7s | ||||
| klb84Nv3OW0MjTcjwfr481Cyy2DqgP5PFnSogWJuibR34jXAgbnX4BiGWrUdzaMU | ||||
| 86AlAoIBABnw9Bh41VFuc9/zxL7nLy++HW33HqFVc5Y1PXr/8sdhcisHQxhZVxek | ||||
| JPbqIuAahDTIZsMnLy41QAKaoyt2fymMXqhJecjUuiwgOOlMxp82qu6Y30xM0Y6m | ||||
| r6CkjSMUjcD1QwhOFJd01GCxM8BBIqQOpmR6fqxbQAu8hacKO3IuerCPryXwMt3A | ||||
| 7ppo/GlP55sySEg7K5I3pmuFHOxn0IPTgR6DfYMGBs9GXJ1lyjDD3z3Q42RhUsMC | ||||
| jvwtra9fTL/N8EmAv2H39C8oqSRbfvIX5u3x6/ONFU8RhSFT5CDTADSYoVZ/0MxV | ||||
| k53r0hqWz6D94r9QQmsJW4G1JwZYhx8CggEBANUybXsJRgDC5ZOlwbaq0FeTOpZ4 | ||||
| pSKx1RkVV+G93hK5XdXIVu365pSt3T68MgF+4wWlbC4MuKuzJltFnJBapzz5ygcU | ||||
| jw+Q+l/jq+mJj8uvUfo5TAy9thuggj1a7SCs/dm5DBwYA7bfmamLbbBpA0qywMsF | ||||
| /vL1bpePIFhbGMhBK7uiEzOAOZVuceZWqcUByjUYy925K/an0ANsQAch5PVeAsEv | ||||
| wXC3soaCzfzUyv+eAYBy7LOcz+hpdRj1l4c9Zx6hZeBxMc5LSRoTs+HfE6GgTuI2 | ||||
| cCfCZNFw7DhDy1TBd3XjQ0AFykeaesImZVR6gp0NYH1kionsMtR5rl4C2tw= | ||||
| -----END RSA PRIVATE KEY----- | ||||
							
								
								
									
										21
									
								
								web/private/ecdsa_nistp256_sha256_cert.pem
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										21
									
								
								web/private/ecdsa_nistp256_sha256_cert.pem
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,21 @@ | ||||
| -----BEGIN CERTIFICATE----- | ||||
| MIIDYTCCAUmgAwIBAgIUA/EaCcXKgOxBiuaMNTackeF9DgcwDQYJKoZIhvcNAQEL | ||||
| BQAwRzELMAkGA1UEBhMCVVMxCzAJBgNVBAgMAkNBMRIwEAYDVQQKDAlSb2NrZXQg | ||||
| Q0ExFzAVBgNVBAMMDlJvY2tldCBSb290IENBMB4XDTIxMDUxNzE5MDIyNFoXDTMx | ||||
| MDUxNTE5MDIyNFowPzELMAkGA1UEBhMCVVMxCzAJBgNVBAgMAkNBMQ8wDQYDVQQK | ||||
| DAZSb2NrZXQxEjAQBgNVBAMMCWxvY2FsaG9zdDBZMBMGByqGSM49AgEGCCqGSM49 | ||||
| AwEHA0IABOEVdmVd218y3Yy+ocjtt5siaFsNR7tknvS21pzKzxsFc05UrF74YqRx | ||||
| Ic6/AQq56C48x6gDhUCdf+nMzD0NWTijGDAWMBQGA1UdEQQNMAuCCWxvY2FsaG9z | ||||
| dDANBgkqhkiG9w0BAQsFAAOCAgEAW32kadHWEIsN5WXacjtgE9jbFii/rWJjrAO/ | ||||
| GWuARwLvjWeEFM5xSTny1cTDEz/7Bmfd9GRRpfgxKCFBGaU7zTmOV/AokrjUay+s | ||||
| KPj0EV9ZE/84KVfr+MOuhwXhSSv+NvxduFw1sdhTUHs+Wopwjte+bnOUBXmnQH97 | ||||
| ITSQuUvEamPUS4tJD/kQ0qSGJKNv3CShhbe3XkjUd0UKK7lZzn6hY2fy3CrkkJDT | ||||
| GyzFCRf3ZDfjW5/fGXN/TNoEK65pPQVr5taK/bovDesEO7rR4Y4YgtnGlJ7YToWh | ||||
| E6I77CbsJCJdmgpjPNwRlwQ8upoWAfxOHqwg32s6uWq20v4TXZTOnEKOPEJG74bh | ||||
| JHtwqsy2fIdGz4kZksKGerzJffyQPhX4f3qxxrijT9Hj2EoFIQ4u/jTRpK31IW3R | ||||
| gEeNB8O4iyg3crx0oHRwc6zX63O6wBJCZ+0uxLoyjwtjKCxVYbJpccjoQh6zO3UO | ||||
| pqAO+NKxQ469QDBV3uRyyQ7DRPu6p67/8gn1E/o8kyU1P7eLFIhBp4T4wCaMQBG6 | ||||
| IpL5W7eCyuOcqfdm471N07Z4KAqiV8eBJcKaAE+WzNIvrFvCZ/zq7Gj8p07nkjK8 | ||||
| +ZGDUieiY0mQEpiXLQZt1xNtT/MmlAzFYrK7t6gSygykQKjO1o4GG4g+eXu3h1YK | ||||
| avsOwtc= | ||||
| -----END CERTIFICATE----- | ||||
							
								
								
									
										5
									
								
								web/private/ecdsa_nistp256_sha256_key_pkcs8.pem
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										5
									
								
								web/private/ecdsa_nistp256_sha256_key_pkcs8.pem
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,5 @@ | ||||
| -----BEGIN PRIVATE KEY----- | ||||
| MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQgeo/7wBIYVax9bj4m | ||||
| 1UEe2H+H4YLsbApDCZMslZbubRuhRANCAAThFXZlXdtfMt2MvqHI7bebImhbDUe7 | ||||
| ZJ70ttacys8bBXNOVKxe+GKkcSHOvwEKueguPMeoA4VAnX/pzMw9DVk4 | ||||
| -----END PRIVATE KEY----- | ||||
							
								
								
									
										21
									
								
								web/private/ecdsa_nistp384_sha384_cert.pem
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										21
									
								
								web/private/ecdsa_nistp384_sha384_cert.pem
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,21 @@ | ||||
| -----BEGIN CERTIFICATE----- | ||||
| MIIDfjCCAWagAwIBAgIUfmkM99JpQUsQsh8TVILPQDBGOhowDQYJKoZIhvcNAQEM | ||||
| BQAwRzELMAkGA1UEBhMCVVMxCzAJBgNVBAgMAkNBMRIwEAYDVQQKDAlSb2NrZXQg | ||||
| Q0ExFzAVBgNVBAMMDlJvY2tldCBSb290IENBMB4XDTIxMDUxNzE5MDIyNFoXDTMx | ||||
| MDUxNTE5MDIyNFowPzELMAkGA1UEBhMCVVMxCzAJBgNVBAgMAkNBMQ8wDQYDVQQK | ||||
| DAZSb2NrZXQxEjAQBgNVBAMMCWxvY2FsaG9zdDB2MBAGByqGSM49AgEGBSuBBAAi | ||||
| A2IABM5rQQNZEGWeDyrg2dVQS15c+JsX/ginN9/ArHpTgGO6xaLfkbekIj7gewUR | ||||
| VQcQrYulwu02HTFDzGVvf1DdCZzIJLJUpl+jagdI05yMPFg2s5lThzGB6HENyw1I | ||||
| hU1dMaMYMBYwFAYDVR0RBA0wC4IJbG9jYWxob3N0MA0GCSqGSIb3DQEBDAUAA4IC | ||||
| AQAzpXFEeCMvTBG+kzmHN/+esjCK8MO/Grrw3Qoa1stX0yjNWzfhlGHfWk9Mgwnp | ||||
| DnIEFT9lB+nT9uTkKL81Opu2bkWXuL0MyjyYmcCfEgJeDblUbD4HYzPO/s2P7QZu | ||||
| Oa1pNKBiSWe1xq+F/gkn0+nDfICq3QQOOQMzaAmlFmszN3v7pryz+x21MqTFsfuW | ||||
| ymycRcwZ3VyYeceZuBxjOhR2l8I5yZbR+KPj8VS7A3vgqvkS8ixTK0LrxcLwrTIz | ||||
| W9Z1skzVpux4K7iwn3qlWIJ7EbERi9Ydz0MzpjD+CHxGlgHTzT552DGZhPO8D7BE | ||||
| +4IoS0YeXEGLeC2a9Hmiy3FXbM4nt3aIWMiWyhjslXIbvWOJL1Vi5GNpdQCG+rB7 | ||||
| lvA0IISp/tAi9duufdONjgRceRDsqf7o6TOBQdB3QxHRt9gCB2QquRh5llMUY8YH | ||||
| PxFJAEzF4X5b0q0k4b50HRVNfDY0SC/XIR7S2xnLDGuoUqrOpUligBzfXV4JHrPv | ||||
| YKHsWSOiVxU9eY1SYKuKqnOsGvXSSQlYqSK1ol94eOKDjNy8wekaVYAQNsdNL7o5 | ||||
| QSWZUcbZLkaNMFdD9twWGduAs0eTichVgyvRgPdevjTGDPBHKNSUURZRTYeW4aIJ | ||||
| QzSQUQXwOXsQOmfCkaaTISsDwZAJ0wFqqST1AOhlCWD1OQ== | ||||
| -----END CERTIFICATE----- | ||||
							
								
								
									
										6
									
								
								web/private/ecdsa_nistp384_sha384_key_pkcs8.pem
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										6
									
								
								web/private/ecdsa_nistp384_sha384_key_pkcs8.pem
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,6 @@ | ||||
| -----BEGIN PRIVATE KEY----- | ||||
| MIG2AgEAMBAGByqGSM49AgEGBSuBBAAiBIGeMIGbAgEBBDCRBt7MeJXyJRq7grGQ | ||||
| jEdBHE31hG5LuKQ5iL8yTVGk75Kc8ISll2LewbvQ3NhR6E+hZANiAATOa0EDWRBl | ||||
| ng8q4NnVUEteXPibF/4IpzffwKx6U4BjusWi35G3pCI+4HsFEVUHEK2LpcLtNh0x | ||||
| Q8xlb39Q3QmcyCSyVKZfo2oHSNOcjDxYNrOZU4cxgehxDcsNSIVNXTE= | ||||
| -----END PRIVATE KEY----- | ||||
							
								
								
									
										20
									
								
								web/private/ed25519_cert.pem
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										20
									
								
								web/private/ed25519_cert.pem
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,20 @@ | ||||
| -----BEGIN CERTIFICATE----- | ||||
| MIIDMjCCARqgAwIBAgIUSu1CGfaWlNPjjLG7SaEEgxI+AsAwDQYJKoZIhvcNAQEL | ||||
| BQAwRzELMAkGA1UEBhMCVVMxCzAJBgNVBAgMAkNBMRIwEAYDVQQKDAlSb2NrZXQg | ||||
| Q0ExFzAVBgNVBAMMDlJvY2tldCBSb290IENBMB4XDTIxMDUxNzE5MDIyNFoXDTMx | ||||
| MDUxNTE5MDIyNFowPzELMAkGA1UEBhMCVVMxCzAJBgNVBAgMAkNBMQ8wDQYDVQQK | ||||
| DAZSb2NrZXQxEjAQBgNVBAMMCWxvY2FsaG9zdDAqMAUGAytlcAMhAKNv1fkv5rxY | ||||
| xGT84C98g2xPpain+jsliHvYrqNkS1zhoxgwFjAUBgNVHREEDTALgglsb2NhbGhv | ||||
| c3QwDQYJKoZIhvcNAQELBQADggIBAMgWZhr3whYbFPNYm5RZxjgEonMY7cH/Rza1 | ||||
| UrBkyoMRL9v00YKJTEvjl5Xl4ku7jqxY2qjValacDroD8hYTLhhkkbHxH6u+kJSC | ||||
| cKRQ8QVBZMmoor8qD2Y2BuXZYGWEtgeoXh+DLHWOim7gXDD6GyFPnYFGHnRhNmkE | ||||
| 6fPcfw1FZmBrMM9rk31EeK9nLVRabL+vuLDEddX1LH4LwK4OuV51wQMZGYiC3M2b | ||||
| JpmuPAUAUyiyN53Utuw/ubeih3cEglOwCyDPFt6XISYHO0UaQdw25uhpjxs8KTZB | ||||
| qOPJAI4cvGaN3GARXvz2P/QHUiTzsf2XOXXtrO0g+F9P7t6x2xL35c0A8yxKkfsa | ||||
| RKdCveRuVlsLXVLVBikqLE/OgPC6SIhIFvTYzXTaZyNfge6dRy2Wg6qWPcGwseeA | ||||
| QpFKgWiY3cuy7nJm6uybR6zOEMj5GgNWIbICGguQ5161MLnv0bEmKh2d9/jQ/XN5 | ||||
| M+nixtGla/G4hL3FQIy9rhHVZzPWwSxl+/eE4qgnInEhmlQ2KrcpgneCt38t0vjJ | ||||
| dwHZSzVv8yX7fE0OSq/DWQXJraWUcT7Ds0g7T7jWkWfS0qStVd9VJ+rYQ8dBbE9Y | ||||
| gcmkm1DMUd+y9xDRDjxby7gMDWFiN2Au9FQgaIpIctTNCj0+agFBPnfXPVSIH6gX | ||||
| 10kA2ZVX | ||||
| -----END CERTIFICATE----- | ||||
							
								
								
									
										3
									
								
								web/private/ed25519_key.pem
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										3
									
								
								web/private/ed25519_key.pem
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,3 @@ | ||||
| -----BEGIN PRIVATE KEY----- | ||||
| MC4CAQAwBQYDK2VwBCIEIGtLkaYE4D+P5HLkoc3a/tNhPP+gnhPLF3jEtdYcAtpd | ||||
| -----END PRIVATE KEY----- | ||||
							
								
								
									
										114
									
								
								web/private/gen_certs.sh
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										114
									
								
								web/private/gen_certs.sh
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,114 @@ | ||||
| #! /bin/bash | ||||
|  | ||||
| # Usage: | ||||
| #   ./gen_certs.sh [cert-kind] | ||||
| # | ||||
| # [cert-kind]: | ||||
| #   ed25519 | ||||
| #   rsa_sha256 | ||||
| #   ecdsa_nistp256_sha256 | ||||
| #   ecdsa_nistp384_sha384 | ||||
| # | ||||
| # Generate a certificate of the [cert-kind] key type, or if no cert-kind is | ||||
| # specified, all of the certificates. | ||||
| # | ||||
| # Examples: | ||||
| #   ./gen_certs.sh ed25519 | ||||
| #   ./gen_certs.sh rsa_sha256 | ||||
|  | ||||
| # TODO: `rustls` (really, `webpki`) doesn't currently use the CN in the subject | ||||
| # to check if a certificate is valid for a server name sent via SNI. It's not | ||||
| # clear if this is intended, since certificates _should_ have a `subjectAltName` | ||||
| # with a DNS name, or if it simply hasn't been implemented yet. See | ||||
| # https://bugzilla.mozilla.org/show_bug.cgi?id=552346 for a bit more info. | ||||
|  | ||||
| CA_SUBJECT="/C=US/ST=CA/O=Rocket CA/CN=Rocket Root CA" | ||||
| SUBJECT="/C=US/ST=CA/O=Rocket/CN=localhost" | ||||
| ALT="DNS:localhost" | ||||
|  | ||||
| function gen_ca() { | ||||
|   openssl genrsa -out ca_key.pem 4096 | ||||
|   openssl req -new -x509 -days 3650 -key ca_key.pem \ | ||||
|     -subj "${CA_SUBJECT}" -out ca_cert.pem | ||||
| } | ||||
|  | ||||
| function gen_ca_if_non_existent() { | ||||
|   if ! [ -f ./ca_cert.pem ]; then gen_ca; fi | ||||
| } | ||||
|  | ||||
| function gen_rsa_sha256() { | ||||
|   gen_ca_if_non_existent | ||||
|  | ||||
|   openssl req -newkey rsa:4096 -nodes -sha256 -keyout rsa_sha256_key.pem \ | ||||
|     -subj "${SUBJECT}" -out server.csr | ||||
|  | ||||
|   openssl x509 -req -sha256 -extfile <(printf "subjectAltName=${ALT}") -days 3650 \ | ||||
|     -CA ca_cert.pem -CAkey ca_key.pem -CAcreateserial \ | ||||
|     -in server.csr -out rsa_sha256_cert.pem | ||||
|  | ||||
|   rm ca_cert.srl server.csr | ||||
| } | ||||
|  | ||||
| function gen_ed25519() { | ||||
|   gen_ca_if_non_existent | ||||
|  | ||||
|   openssl genpkey -algorithm ED25519 > ed25519_key.pem | ||||
|  | ||||
|   openssl req -new -key ed25519_key.pem -subj "${SUBJECT}" -out server.csr | ||||
|   openssl x509 -req -extfile <(printf "subjectAltName=${ALT}") -days 3650 \ | ||||
|     -CA ca_cert.pem -CAkey ca_key.pem -CAcreateserial \ | ||||
|     -in server.csr -out ed25519_cert.pem | ||||
|  | ||||
|   rm ca_cert.srl server.csr | ||||
| } | ||||
|  | ||||
| function gen_ecdsa_nistp256_sha256() { | ||||
|   gen_ca_if_non_existent | ||||
|  | ||||
|   openssl ecparam -out ecdsa_nistp256_sha256_key.pem -name prime256v1 -genkey | ||||
|  | ||||
|   # Convert to pkcs8 format supported by rustls | ||||
|   openssl pkcs8 -topk8 -nocrypt -in ecdsa_nistp256_sha256_key.pem \ | ||||
|     -out ecdsa_nistp256_sha256_key_pkcs8.pem | ||||
|  | ||||
|   openssl req -new -nodes -sha256 -key ecdsa_nistp256_sha256_key_pkcs8.pem \ | ||||
|     -subj "${SUBJECT}" -out server.csr | ||||
|  | ||||
|   openssl x509 -req -sha256 -extfile <(printf "subjectAltName=${ALT}") -days 3650 \ | ||||
|     -CA ca_cert.pem -CAkey ca_key.pem -CAcreateserial \ | ||||
|     -in server.csr -out ecdsa_nistp256_sha256_cert.pem | ||||
|  | ||||
|   rm ca_cert.srl server.csr ecdsa_nistp256_sha256_key.pem | ||||
| } | ||||
|  | ||||
| function gen_ecdsa_nistp384_sha384() { | ||||
|   gen_ca_if_non_existent | ||||
|  | ||||
|   openssl ecparam -out ecdsa_nistp384_sha384_key.pem -name secp384r1 -genkey | ||||
|  | ||||
|   # Convert to pkcs8 format supported by rustls | ||||
|   openssl pkcs8 -topk8 -nocrypt -in ecdsa_nistp384_sha384_key.pem \ | ||||
|     -out ecdsa_nistp384_sha384_key_pkcs8.pem | ||||
|  | ||||
|   openssl req -new -nodes -sha384 -key ecdsa_nistp384_sha384_key_pkcs8.pem \ | ||||
|     -subj "${SUBJECT}" -out server.csr | ||||
|  | ||||
|   openssl x509 -req -sha384 -extfile <(printf "subjectAltName=${ALT}") -days 3650 \ | ||||
|     -CA ca_cert.pem -CAkey ca_key.pem -CAcreateserial \ | ||||
|     -in server.csr -out ecdsa_nistp384_sha384_cert.pem | ||||
|  | ||||
|   rm ca_cert.srl server.csr ecdsa_nistp384_sha384_key.pem | ||||
| } | ||||
|  | ||||
| case $1 in | ||||
|   ed25519) gen_ed25519 ;; | ||||
|   rsa_sha256) gen_rsa_sha256 ;; | ||||
|   ecdsa_nistp256_sha256) gen_ecdsa_nistp256_sha256 ;; | ||||
|   ecdsa_nistp384_sha384) gen_ecdsa_nistp384_sha384 ;; | ||||
|   *) | ||||
|     gen_ed25519 | ||||
|     gen_rsa_sha256 | ||||
|     gen_ecdsa_nistp256_sha256 | ||||
|     gen_ecdsa_nistp384_sha384 | ||||
|     ;; | ||||
| esac | ||||
							
								
								
									
										30
									
								
								web/private/rsa_sha256_cert.pem
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										30
									
								
								web/private/rsa_sha256_cert.pem
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,30 @@ | ||||
| -----BEGIN CERTIFICATE----- | ||||
| MIIFLDCCAxSgAwIBAgIUZ461KXDgTT25LP1S6PK6i9ZKtOYwDQYJKoZIhvcNAQEL | ||||
| BQAwRzELMAkGA1UEBhMCVVMxCzAJBgNVBAgMAkNBMRIwEAYDVQQKDAlSb2NrZXQg | ||||
| Q0ExFzAVBgNVBAMMDlJvY2tldCBSb290IENBMB4XDTIxMDUxNzE5MDIyNFoXDTMx | ||||
| MDUxNTE5MDIyNFowPzELMAkGA1UEBhMCVVMxCzAJBgNVBAgMAkNBMQ8wDQYDVQQK | ||||
| DAZSb2NrZXQxEjAQBgNVBAMMCWxvY2FsaG9zdDCCAiIwDQYJKoZIhvcNAQEBBQAD | ||||
| ggIPADCCAgoCggIBAKl8Re1dneFB2obYPOuZf3d6BR2xGcMhr2vmHpnVgkeg/xGI | ||||
| cwHhhB04mEg4TqbZ0Q1wdKN4ruNigdrQAXDqnm4y2IB1q/uTdoXVWNsvAZmDq4N4 | ||||
| rPUDWagpkilV4HOHDQOI27Lo/+30wJX6H8i3J6Nag1y9spuySkL1F1SgQng9uzvP | ||||
| 3SwbsO9R/jS7qTZNEtirnJv3qJlxmT4BCvBuWNALShFlSZYnZk/2csYXEaVkWMUE | ||||
| rnWGmTmzMZEzDOdQdPC3sJDCPQbbgD4SnQANqhOVjPSdJ6Y6joN6XYtB2EP+6LJ8 | ||||
| UBTICETnUROWeKqMm215Gsen32cyWkaCwEWtTFn7rJHbPvUY4vPYQZAB2/h07lYq | ||||
| v67W3r5lTq1KuoStD8M/7nCIYIuNhmJLMzzWrSfJrQpYexoMII6kjOxv2kiUgb1y | ||||
| bYKnFlVf4up3pTpi2/0We4SHRgc7xTcEPNvU5z5hc5JwHZIFjmi5dx50ZoAULrXl | ||||
| OUhfdUPut7H38UQg3riJiNAzedUiTubek26po8qH1iS/EMgAi5RboaBovjWhgjwq | ||||
| P/RP0vzpln6OdQIRaRlY9vZiik9HbFybafuA2zEkyc+zc4HqJk3vqX/0hgd9qWeL | ||||
| zgsHr6A86tNUXsUaoInQbzznjpbFE85klxd6HeqasOyVDCeDUs4lWoDNp0DbAgMB | ||||
| AAGjGDAWMBQGA1UdEQQNMAuCCWxvY2FsaG9zdDANBgkqhkiG9w0BAQsFAAOCAgEA | ||||
| sS2TUKKYtNYC68TEQb5KAa8/zi6Lhz9lyn9rpBXAcAVeBdxCqrBU0nRN8EjXEff1 | ||||
| oBeau9Wi9PwdK6J+4xFuer9YrZZWuWLKUBXJL9ZXEfI+USo5WVz7v/puoKZSUSu2 | ||||
| +Ee4+v1eoAsCtaA8IR0Sh1KTc9kg1QZW1nIdBV0zgAERWTzm1iy2Ff+euowM/lUR | ||||
| FczMAJsNq83O2kaH541fRx3gC2iMN/QLwRalBR2y8hTI5wQa0Yr8moC4IsTfRrKQ | ||||
| /SZDjFT1XoA9TnrByIvGQNlxK1Rm2hvK1OQn4OL6sdYK6F+MxfUn/atQNyBQ6CF+ | ||||
| oKuvOnE2jces8GGZIU1jYJ2271SQkzp0CW3N1ZKjClSQFAYED+5ERTGyeEL4o3Vr | ||||
| V/BUd0KC7HhbWBlFc2EDmPOoOTp/ID901Kf499M68pe2Fr/63/nCAON6WFk1NPYA | ||||
| +sWMfS25hikU5rOHGQgXxXAz90pwpvpoLQvaq0/azsbHvq9B4ubdcLE0xcoK+DGq | ||||
| +/aOeWlYkalWp93akPYpWN3UJXzDXzzagRID/1LEGS+Ssbh1WVhAhLKVoxzBvkgm | ||||
| ozVAhR3zHXI2QpQ2FEbOmkcRtpxv2CiaTsgHg2MK8cdmPx3G+ufCZjRbQx/VXVdN | ||||
| vaFRdmzr/5EQ7DT5Uy8w32h1to4Fm2sAtbAFYaFLXaM= | ||||
| -----END CERTIFICATE----- | ||||
							
								
								
									
										52
									
								
								web/private/rsa_sha256_key.pem
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										52
									
								
								web/private/rsa_sha256_key.pem
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,52 @@ | ||||
| -----BEGIN PRIVATE KEY----- | ||||
| MIIJQgIBADANBgkqhkiG9w0BAQEFAASCCSwwggkoAgEAAoICAQCpfEXtXZ3hQdqG | ||||
| 2DzrmX93egUdsRnDIa9r5h6Z1YJHoP8RiHMB4YQdOJhIOE6m2dENcHSjeK7jYoHa | ||||
| 0AFw6p5uMtiAdav7k3aF1VjbLwGZg6uDeKz1A1moKZIpVeBzhw0DiNuy6P/t9MCV | ||||
| +h/ItyejWoNcvbKbskpC9RdUoEJ4Pbs7z90sG7DvUf40u6k2TRLYq5yb96iZcZk+ | ||||
| AQrwbljQC0oRZUmWJ2ZP9nLGFxGlZFjFBK51hpk5szGRMwznUHTwt7CQwj0G24A+ | ||||
| Ep0ADaoTlYz0nSemOo6Del2LQdhD/uiyfFAUyAhE51ETlniqjJtteRrHp99nMlpG | ||||
| gsBFrUxZ+6yR2z71GOLz2EGQAdv4dO5WKr+u1t6+ZU6tSrqErQ/DP+5wiGCLjYZi | ||||
| SzM81q0nya0KWHsaDCCOpIzsb9pIlIG9cm2CpxZVX+Lqd6U6Ytv9FnuEh0YHO8U3 | ||||
| BDzb1Oc+YXOScB2SBY5ouXcedGaAFC615TlIX3VD7rex9/FEIN64iYjQM3nVIk7m | ||||
| 3pNuqaPKh9YkvxDIAIuUW6GgaL41oYI8Kj/0T9L86ZZ+jnUCEWkZWPb2YopPR2xc | ||||
| m2n7gNsxJMnPs3OB6iZN76l/9IYHfalni84LB6+gPOrTVF7FGqCJ0G88546WxRPO | ||||
| ZJcXeh3qmrDslQwng1LOJVqAzadA2wIDAQABAoICABT4gHp/Q+K0UEKxDNCl/ISe | ||||
| /3UODb78MwVpws2MAoO0YvsbZAeOjNdEwmrlNK4mc1xzVqtHanROIv0dEaCUFyhR | ||||
| eEJkzPPi6h5jKIxuQ4doKFerHdNvJ6/L/P7KVmxVAII4c96uP8SErTOhcD9Ykjn/ | ||||
| IBPgkPH83H1ucAWTksXn9XvQG3CyuHDUN1z0/1ntrXBLw6P0v9LEoI5weJcJQEn1 | ||||
| q6N9Yd6HX3xzZP4nqpJJWUZ/bsqx7dGa3340z9rrNJz4TYuLzRtFG5gSm4R/LFUi | ||||
| Av/dViOWST3xbROnAQhgyRAUm6AGpCdKa9i9nI6VuUGRY4PivJy7OTpSQVIdwD2K | ||||
| VFbFauCmsTY7B+Z+DXe9JJV+Tlazvlwop1DApxCgLMNr2pVB/1yPzMrNYDB4Sg1c | ||||
| T3stu4A8PtljTykla2C2LPFrdIijvYv9n6PSPWV4vf1ix9uYs99FhZPQJMgm+CDr | ||||
| n3cFfuofIWIRJpCuu3hn4woGgW2bXor4pyMNg1yCp1T2c8g6eJUYAAIRpse0HDbT | ||||
| ZUifxlNBx819bf9aqv+lhwMU4UFlmWMv3NETcCBqgUn+z4scNJ3kZwO9ZodLRblK | ||||
| SpalZ6SNejTnVukg3zbwJG8w5vIrJvfjBkikAL1O5X6Kn3YqfrXAl38bIvA50ZBe | ||||
| eOFcgDPClLPGlNKxQXiZAoIBAQDh0aArTjWi8Kxt2Ga3Hu4jQ7IXklL9osGAlFCB | ||||
| wZs831/ktLY0c7vY+mbwrY4AtqK21A00MrNSTsp/McHwAUi410DdcTqzkGnm9BBZ | ||||
| FKVO1H9KGg2t344tOXcPsFTl6SR5a0aPqagyvgdH+YWC4ccfYZWMcLELn3sOGoEp | ||||
| a7VBxjLZZ+/b+/oIzhwxEaBi8N0U3IZ3SsC9Lmojnb9+LMwGs14TFfahD3uM1hnU | ||||
| vww1elwPwXafWc+7Ej1bXijWBXbyzkCITzHafmDsAatEZnemHL8zKKtTn2aSTUSj | ||||
| Fl/y94WR7/V4nVEQ0R3fjGC0rKh08BtKzxPjYBqjT5aI7+yfAoIBAQDAIzROr00o | ||||
| 65auD5TB2k/pSVKQpB/U6ESGafDxg4a+R9Ng2ESmDN5hUUZDefm0Uxf9AZqS6eno | ||||
| GSAqxNDixWPy2WFzbZ0mUCoyQn+Eh09WBvt0IqDZ2+ngBEXiKA/8dsvXinBHLuHV | ||||
| u+rJdLMzPfhWVo1/iXq7SOjBvDuthKROku5BEt6Q3Cs0nk6uneRIqKtaqa7KIInF | ||||
| BPtUifZtCP09h6iFFGNv2g2OYRYDg8TXvjt3scKy0DUC3kTeTCPvNvHaOObGbMRU | ||||
| Q85HkXburxWfsMto5m/lRsbY3COxddB47WIQ6QNewESfCab/R2lOdlXQeaH8fuxT | ||||
| wWC5DMz4F0ZFAoIBAFm+EjZDnaNEnHIHB0MNIryXAabGev7beKUdzCTVCVmWuChO | ||||
| /P45ZFTlppVNk9qKun2IJjsxTvyN3YHRB27XQ8xZlyiqABcudDfZlMmiH9QFNRUA | ||||
| 56DK8Fjetodgn0zDa8BpNqCPXw3TYVdkPX/3NEgvYtxuSJ4C4keHlv8cE+uw1bJ6 | ||||
| 0OMO754iMyf5BlFrwaCxxyqPZauJT5sZ7Ok66lZbYC6bkukNGx+sUpWu2y5Bk2ab | ||||
| jwXjDmAc7o9qCzaK82upNhI1zu0zPldsjmDfi/tS/1VYe0X/WicYWAesM7N+VPHb | ||||
| eCVX98iEIqgdxKzo1QWsClyfkRrSraNrVLrVBqcCggEAaLIGK6YMNnMBPUGSPnt2 | ||||
| NdlVWymDiuExjcimmQOhZYf/33KZHZ4/gunljpklfqQUmzHHh6xcX7NpOsTaSedj | ||||
| Sg43ss0U566g/5gKoi2VBnxxglvoKC5T51SMu+o2o8wb0QxHmBIszulBy5qClzZ6 | ||||
| Xpl1Kvy/2tOkuQSXxDpVydb4ao8cpfTCuj5VA4NXxFvcW1/AtbU7PRc02GEA3XMb | ||||
| gu6r3jA46tb3shCnDS09Eo4/Gz7Kp+MaL8Dr5/G3Vv8qlE2TOqZD6OK1wXu7Qd43 | ||||
| uzd772I5sMZ7TenOrUFUYsB/QlWmF3hPLBX3YH0KHc4PfrT4lnyWzCDAUrVt7vXH | ||||
| vQKCAQEAz1Q+jW+NG7CAqkOJ19n+KmGn+zddvy8x4txKx8kV0+3XIVNkseS6IT65 | ||||
| uemD85Gn7MYwHxcKUHghHSKyJsvCb1vSmB3JtCnrsodmQNMb6Lsq89VlM8Ylc/s3 | ||||
| F4AraiOlylqcEwLkanD3XSfPdQZhExZHEtpAW/Rr6zYT9J94VO1nK3+RkFI4a8kl | ||||
| pEbY+tqJj3LGTuaQkshB9rDtcBT5/JsaxlMk783qkKziCPZF47BWqrJb+t7tm3qg | ||||
| 5gf+QUInEjdW3k3uZBL4316RP/TJlLvo29PkEwoC8X4R2EgDeHQhhRC2Fi2Wpy4O | ||||
| ce4G+zZOOYXwvWGJLwNhgsve8C3oqg== | ||||
| -----END PRIVATE KEY----- | ||||
							
								
								
									
										40
									
								
								web/src/catchers.rs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										40
									
								
								web/src/catchers.rs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,40 @@ | ||||
| use std::collections::HashMap; | ||||
|  | ||||
| use rocket::serde::json::json; | ||||
| use rocket_dyn_templates::Template; | ||||
|  | ||||
| use crate::JsonValue; | ||||
|  | ||||
| #[catch(403)] | ||||
| pub(crate) async fn forbidden() -> Template { | ||||
|     let map: HashMap<String, String> = HashMap::new(); | ||||
|     Template::render("errors/403", &map) | ||||
| } | ||||
|  | ||||
| #[catch(500)] | ||||
| pub(crate) async fn internal_server_error() -> Template { | ||||
|     let map: HashMap<String, String> = HashMap::new(); | ||||
|     Template::render("errors/500", &map) | ||||
| } | ||||
|  | ||||
| #[catch(401)] | ||||
| pub(crate) async fn not_authorized() -> Template { | ||||
|     let map: HashMap<String, String> = HashMap::new(); | ||||
|     Template::render("errors/401", &map) | ||||
| } | ||||
|  | ||||
| #[catch(404)] | ||||
| pub(crate) async fn not_found() -> Template { | ||||
|     let map: HashMap<String, String> = HashMap::new(); | ||||
|     Template::render("errors/404", &map) | ||||
| } | ||||
|  | ||||
| #[catch(413)] | ||||
| pub(crate) async fn payload_too_large() -> JsonValue { | ||||
|     json!({"error": "Data too large.", "errors": ["Data too large."]}) | ||||
| } | ||||
|  | ||||
| #[catch(422)] | ||||
| pub(crate) async fn unprocessable_entity() -> JsonValue { | ||||
|     json!({"error": "Invalid request.", "errors": ["Invalid request."]}) | ||||
| } | ||||
							
								
								
									
										49
									
								
								web/src/consts.rs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										49
									
								
								web/src/consts.rs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,49 @@ | ||||
| pub const DISCORD_OAUTH_TOKEN: &'static str = "https://discord.com/api/oauth2/token"; | ||||
| pub const DISCORD_OAUTH_AUTHORIZE: &'static str = "https://discord.com/api/oauth2/authorize"; | ||||
| pub const DISCORD_API: &'static str = "https://discord.com/api"; | ||||
|  | ||||
| pub const MAX_NAME_LENGTH: usize = 100; | ||||
| pub const MAX_CONTENT_LENGTH: usize = 2000; | ||||
| pub const MAX_EMBED_DESCRIPTION_LENGTH: usize = 4096; | ||||
| pub const MAX_EMBED_TITLE_LENGTH: usize = 256; | ||||
| pub const MAX_EMBED_AUTHOR_LENGTH: usize = 256; | ||||
| pub const MAX_EMBED_FOOTER_LENGTH: usize = 2048; | ||||
| pub const MAX_URL_LENGTH: usize = 512; | ||||
| pub const MAX_USERNAME_LENGTH: usize = 100; | ||||
| pub const MAX_EMBED_FIELDS: usize = 25; | ||||
| pub const MAX_EMBED_FIELD_TITLE_LENGTH: usize = 256; | ||||
| pub const MAX_EMBED_FIELD_VALUE_LENGTH: usize = 1024; | ||||
|  | ||||
| pub const MINUTE: usize = 60; | ||||
| pub const HOUR: usize = 60 * MINUTE; | ||||
| pub const DAY: usize = 24 * HOUR; | ||||
|  | ||||
| pub const CHARACTERS: &str = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_"; | ||||
|  | ||||
| use std::{collections::HashSet, env, iter::FromIterator}; | ||||
|  | ||||
| use lazy_static::lazy_static; | ||||
| use serenity::model::prelude::AttachmentType; | ||||
|  | ||||
| lazy_static! { | ||||
|     pub static ref DEFAULT_AVATAR: AttachmentType<'static> = ( | ||||
|         include_bytes!(concat!(env!("CARGO_MANIFEST_DIR"), "/../assets/webhook.jpg")) as &[u8], | ||||
|         "webhook.jpg", | ||||
|     ) | ||||
|         .into(); | ||||
|     pub static ref SUBSCRIPTION_ROLES: HashSet<u64> = HashSet::from_iter( | ||||
|         env::var("PATREON_ROLE_ID") | ||||
|             .map(|var| var | ||||
|                 .split(',') | ||||
|                 .filter_map(|item| { item.parse::<u64>().ok() }) | ||||
|                 .collect::<Vec<u64>>()) | ||||
|             .unwrap_or_else(|_| Vec::new()) | ||||
|     ); | ||||
|     pub static ref CNC_GUILD: Option<u64> = | ||||
|         env::var("PATREON_GUILD_ID").map(|var| var.parse::<u64>().ok()).ok().flatten(); | ||||
|     pub static ref MIN_INTERVAL: u32 = env::var("MIN_INTERVAL") | ||||
|         .ok() | ||||
|         .map(|inner| inner.parse::<u32>().ok()) | ||||
|         .flatten() | ||||
|         .unwrap_or(600); | ||||
| } | ||||
							
								
								
									
										1
									
								
								web/src/guards/mod.rs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										1
									
								
								web/src/guards/mod.rs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1 @@ | ||||
| pub(crate) mod transaction; | ||||
							
								
								
									
										44
									
								
								web/src/guards/transaction.rs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										44
									
								
								web/src/guards/transaction.rs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,44 @@ | ||||
| use rocket::{ | ||||
|     http::Status, | ||||
|     request::{FromRequest, Outcome}, | ||||
|     Request, State, | ||||
| }; | ||||
| use sqlx::Pool; | ||||
|  | ||||
| use crate::Database; | ||||
|  | ||||
| pub struct Transaction<'a>(sqlx::Transaction<'a, Database>); | ||||
|  | ||||
| impl Transaction<'_> { | ||||
|     pub fn executor(&mut self) -> impl sqlx::Executor<'_, Database = Database> { | ||||
|         &mut *(self.0) | ||||
|     } | ||||
|  | ||||
|     pub async fn commit(self) -> Result<(), sqlx::Error> { | ||||
|         self.0.commit().await | ||||
|     } | ||||
| } | ||||
|  | ||||
| #[derive(Debug)] | ||||
| pub enum TransactionError { | ||||
|     Error(sqlx::Error), | ||||
|     Missing, | ||||
| } | ||||
|  | ||||
| #[rocket::async_trait] | ||||
| impl<'r> FromRequest<'r> for Transaction<'r> { | ||||
|     type Error = TransactionError; | ||||
|  | ||||
|     async fn from_request(request: &'r Request<'_>) -> Outcome<Self, Self::Error> { | ||||
|         match request.guard::<&State<Pool<Database>>>().await { | ||||
|             Outcome::Success(pool) => match pool.begin().await { | ||||
|                 Ok(transaction) => Outcome::Success(Transaction(transaction)), | ||||
|                 Err(e) => { | ||||
|                     Outcome::Failure((Status::InternalServerError, TransactionError::Error(e))) | ||||
|                 } | ||||
|             }, | ||||
|             Outcome::Failure(e) => Outcome::Failure((e.0, TransactionError::Missing)), | ||||
|             Outcome::Forward(f) => Outcome::Forward(f), | ||||
|         } | ||||
|     } | ||||
| } | ||||
							
								
								
									
										263
									
								
								web/src/lib.rs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										263
									
								
								web/src/lib.rs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,263 @@ | ||||
| #[macro_use] | ||||
| extern crate rocket; | ||||
|  | ||||
| mod consts; | ||||
| #[macro_use] | ||||
| mod macros; | ||||
| mod catchers; | ||||
| mod guards; | ||||
| mod metrics; | ||||
| mod routes; | ||||
|  | ||||
| use std::{env, path::Path}; | ||||
|  | ||||
| use oauth2::{basic::BasicClient, AuthUrl, ClientId, ClientSecret, RedirectUrl, TokenUrl}; | ||||
| use rocket::{ | ||||
|     fs::FileServer, | ||||
|     http::CookieJar, | ||||
|     serde::json::{json, Value as JsonValue}, | ||||
|     tokio::sync::broadcast::Sender, | ||||
| }; | ||||
| use rocket_dyn_templates::Template; | ||||
| use serenity::{ | ||||
|     client::Context, | ||||
|     http::CacheHttp, | ||||
|     model::id::{GuildId, UserId}, | ||||
| }; | ||||
| use sqlx::{MySql, Pool}; | ||||
|  | ||||
| use crate::{ | ||||
|     consts::{CNC_GUILD, DISCORD_OAUTH_AUTHORIZE, DISCORD_OAUTH_TOKEN, SUBSCRIPTION_ROLES}, | ||||
|     metrics::{init_metrics, MetricProducer}, | ||||
| }; | ||||
|  | ||||
| type Database = MySql; | ||||
|  | ||||
| #[derive(Debug)] | ||||
| enum Error { | ||||
|     SQLx(sqlx::Error), | ||||
|     Serenity(serenity::Error), | ||||
| } | ||||
|  | ||||
| pub async fn initialize( | ||||
|     kill_channel: Sender<()>, | ||||
|     serenity_context: Context, | ||||
|     db_pool: Pool<Database>, | ||||
| ) -> Result<(), Box<dyn std::error::Error>> { | ||||
|     info!("Checking environment variables..."); | ||||
|  | ||||
|     if env::var("OFFLINE").map_or(true, |v| v != "1") { | ||||
|         env::var("OAUTH2_CLIENT_ID").expect("`OAUTH2_CLIENT_ID' not supplied"); | ||||
|         env::var("OAUTH2_CLIENT_SECRET").expect("`OAUTH2_CLIENT_SECRET' not supplied"); | ||||
|         env::var("OAUTH2_DISCORD_CALLBACK").expect("`OAUTH2_DISCORD_CALLBACK' not supplied"); | ||||
|         env::var("PATREON_GUILD_ID").expect("`PATREON_GUILD_ID' not supplied"); | ||||
|     } | ||||
|  | ||||
|     info!("Done!"); | ||||
|  | ||||
|     let oauth2_client = BasicClient::new( | ||||
|         ClientId::new(env::var("OAUTH2_CLIENT_ID")?), | ||||
|         Some(ClientSecret::new(env::var("OAUTH2_CLIENT_SECRET")?)), | ||||
|         AuthUrl::new(DISCORD_OAUTH_AUTHORIZE.to_string())?, | ||||
|         Some(TokenUrl::new(DISCORD_OAUTH_TOKEN.to_string())?), | ||||
|     ) | ||||
|     .set_redirect_uri(RedirectUrl::new(env::var("OAUTH2_DISCORD_CALLBACK")?)?); | ||||
|  | ||||
|     let reqwest_client = reqwest::Client::new(); | ||||
|  | ||||
|     let static_path = | ||||
|         if Path::new("web/static").exists() { "web/static" } else { "/lib/reminder-rs/static" }; | ||||
|  | ||||
|     init_metrics(); | ||||
|  | ||||
|     rocket::build() | ||||
|         .attach(MetricProducer) | ||||
|         .attach(Template::fairing()) | ||||
|         .register( | ||||
|             "/", | ||||
|             catchers![ | ||||
|                 catchers::not_authorized, | ||||
|                 catchers::forbidden, | ||||
|                 catchers::not_found, | ||||
|                 catchers::internal_server_error, | ||||
|                 catchers::unprocessable_entity, | ||||
|                 catchers::payload_too_large, | ||||
|             ], | ||||
|         ) | ||||
|         .manage(oauth2_client) | ||||
|         .manage(reqwest_client) | ||||
|         .manage(serenity_context) | ||||
|         .manage(db_pool) | ||||
|         .mount("/static", FileServer::from(static_path)) | ||||
|         .mount( | ||||
|             "/", | ||||
|             routes![ | ||||
|                 routes::cookies, | ||||
|                 routes::index, | ||||
|                 routes::metrics::metrics, | ||||
|                 routes::privacy, | ||||
|                 routes::report::report_error, | ||||
|                 routes::return_to_same_site, | ||||
|                 routes::terms, | ||||
|             ], | ||||
|         ) | ||||
|         .mount( | ||||
|             "/help", | ||||
|             routes![ | ||||
|                 routes::help, | ||||
|                 routes::help_timezone, | ||||
|                 routes::help_create_reminder, | ||||
|                 routes::help_delete_reminder, | ||||
|                 routes::help_timers, | ||||
|                 routes::help_todo_lists, | ||||
|                 routes::help_macros, | ||||
|                 routes::help_intervals, | ||||
|                 routes::help_dashboard, | ||||
|                 routes::help_iemanager, | ||||
|             ], | ||||
|         ) | ||||
|         .mount( | ||||
|             "/login", | ||||
|             routes![ | ||||
|                 routes::login::discord_login, | ||||
|                 routes::login::discord_logout, | ||||
|                 routes::login::discord_callback | ||||
|             ], | ||||
|         ) | ||||
|         .mount( | ||||
|             "/dashboard", | ||||
|             routes![ | ||||
|                 routes::dashboard::dashboard, | ||||
|                 routes::dashboard::dashboard_home, | ||||
|                 routes::dashboard::api::user::get_user_info, | ||||
|                 routes::dashboard::api::user::update_user_info, | ||||
|                 routes::dashboard::api::user::get_user_guilds, | ||||
|                 routes::dashboard::api::guild::get_guild_info, | ||||
|                 routes::dashboard::api::guild::get_guild_channels, | ||||
|                 routes::dashboard::api::guild::get_guild_roles, | ||||
|                 routes::dashboard::api::guild::get_reminder_templates, | ||||
|                 routes::dashboard::api::guild::create_reminder_template, | ||||
|                 routes::dashboard::api::guild::delete_reminder_template, | ||||
|                 routes::dashboard::api::guild::create_guild_reminder, | ||||
|                 routes::dashboard::api::guild::get_reminders, | ||||
|                 routes::dashboard::api::guild::edit_reminder, | ||||
|                 routes::dashboard::api::guild::delete_reminder, | ||||
|                 routes::dashboard::export::export_reminders, | ||||
|                 routes::dashboard::export::export_reminder_templates, | ||||
|                 routes::dashboard::export::export_todos, | ||||
|                 routes::dashboard::export::import_reminders, | ||||
|                 routes::dashboard::export::import_todos, | ||||
|             ], | ||||
|         ) | ||||
|         .mount("/admin", routes![routes::admin::admin_dashboard_home, routes::admin::bot_data]) | ||||
|         .launch() | ||||
|         .await?; | ||||
|  | ||||
|     warn!("Exiting rocket runtime"); | ||||
|     // distribute kill signal | ||||
|     match kill_channel.send(()) { | ||||
|         Ok(_) => {} | ||||
|         Err(e) => { | ||||
|             error!("Failed to issue kill signal: {:?}", e); | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     Ok(()) | ||||
| } | ||||
|  | ||||
| pub async fn check_subscription(cache_http: impl CacheHttp, user_id: impl Into<UserId>) -> bool { | ||||
|     offline!(true); | ||||
|  | ||||
|     if let Some(subscription_guild) = *CNC_GUILD { | ||||
|         let guild_member = GuildId(subscription_guild).member(cache_http, user_id).await; | ||||
|  | ||||
|         if let Ok(member) = guild_member { | ||||
|             for role in member.roles { | ||||
|                 if SUBSCRIPTION_ROLES.contains(role.as_u64()) { | ||||
|                     return true; | ||||
|                 } | ||||
|             } | ||||
|         } | ||||
|  | ||||
|         false | ||||
|     } else { | ||||
|         true | ||||
|     } | ||||
| } | ||||
|  | ||||
| pub async fn check_guild_subscription( | ||||
|     cache_http: impl CacheHttp, | ||||
|     guild_id: impl Into<GuildId>, | ||||
| ) -> bool { | ||||
|     offline!(true); | ||||
|  | ||||
|     if let Some(guild) = cache_http.cache().unwrap().guild(guild_id) { | ||||
|         let owner = guild.owner_id; | ||||
|  | ||||
|         check_subscription(&cache_http, owner).await | ||||
|     } else { | ||||
|         false | ||||
|     } | ||||
| } | ||||
|  | ||||
| pub async fn check_authorization( | ||||
|     cookies: &CookieJar<'_>, | ||||
|     ctx: &Context, | ||||
|     guild: u64, | ||||
| ) -> Result<(), JsonValue> { | ||||
|     let user_id = cookies.get_private("userid").map(|c| c.value().parse::<u64>().ok()).flatten(); | ||||
|  | ||||
|     if std::env::var("OFFLINE").map_or(true, |v| v != "1") { | ||||
|         match user_id { | ||||
|             Some(user_id) => { | ||||
|                 let admin_id = std::env::var("ADMIN_ID") | ||||
|                     .map_or(false, |u| u.parse::<u64>().map_or(false, |u| u == user_id)); | ||||
|  | ||||
|                 if admin_id { | ||||
|                     return Ok(()); | ||||
|                 } | ||||
|  | ||||
|                 match GuildId(guild).to_guild_cached(ctx) { | ||||
|                     Some(guild) => { | ||||
|                         let member_res = guild.member(ctx, UserId(user_id)).await; | ||||
|  | ||||
|                         match member_res { | ||||
|                             Err(_) => { | ||||
|                                 return Err(json!({"error": "User not in guild"})); | ||||
|                             } | ||||
|  | ||||
|                             Ok(member) => { | ||||
|                                 let permissions_res = member.permissions(ctx); | ||||
|  | ||||
|                                 match permissions_res { | ||||
|                                     Err(_) => { | ||||
|                                         return Err(json!({"error": "Couldn't fetch permissions"})); | ||||
|                                     } | ||||
|  | ||||
|                                     Ok(permissions) => { | ||||
|                                         if !(permissions.manage_messages() | ||||
|                                             || permissions.manage_guild() | ||||
|                                             || permissions.administrator()) | ||||
|                                         { | ||||
|                                             return Err(json!({"error": "Incorrect permissions"})); | ||||
|                                         } | ||||
|                                     } | ||||
|                                 } | ||||
|                             } | ||||
|                         } | ||||
|                     } | ||||
|  | ||||
|                     None => { | ||||
|                         return Err(json!({"error": "Bot not in guild"})); | ||||
|                     } | ||||
|                 } | ||||
|             } | ||||
|  | ||||
|             None => { | ||||
|                 return Err(json!({"error": "User not authorized"})); | ||||
|             } | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     Ok(()) | ||||
| } | ||||
							
								
								
									
										99
									
								
								web/src/macros.rs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										99
									
								
								web/src/macros.rs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,99 @@ | ||||
| macro_rules! offline { | ||||
|     ($field:expr) => { | ||||
|         if std::env::var("OFFLINE").map_or(false, |v| v == "1") { | ||||
|             return $field; | ||||
|         } | ||||
|     }; | ||||
| } | ||||
|  | ||||
| macro_rules! check_length { | ||||
|     ($max:ident, $field:expr) => { | ||||
|         if $field.len() > $max { | ||||
|             return Err(json!({ "error": format!("{} exceeded", stringify!($max)) })); | ||||
|         } | ||||
|     }; | ||||
|     ($max:ident, $field:expr, $($fields:expr),+) => { | ||||
|         check_length!($max, $field); | ||||
|         check_length!($max, $($fields),+); | ||||
|     }; | ||||
| } | ||||
|  | ||||
| macro_rules! check_length_opt { | ||||
|     ($max:ident, $field:expr) => { | ||||
|         if let Some(field) = &$field { | ||||
|             check_length!($max, field); | ||||
|         } | ||||
|     }; | ||||
|     ($max:ident, $field:expr, $($fields:expr),+) => { | ||||
|         check_length_opt!($max, $field); | ||||
|         check_length_opt!($max, $($fields),+); | ||||
|     }; | ||||
| } | ||||
|  | ||||
| macro_rules! check_url { | ||||
|     ($field:expr) => { | ||||
|         if !($field.starts_with("http://") || $field.starts_with("https://")) { | ||||
|             return Err(json!({ "error": "URL invalid" })); | ||||
|         } | ||||
|     }; | ||||
|     ($field:expr, $($fields:expr),+) => { | ||||
|         check_url!($max, $field); | ||||
|         check_url!($max, $($fields),+); | ||||
|     }; | ||||
| } | ||||
|  | ||||
| macro_rules! check_url_opt { | ||||
|     ($field:expr) => { | ||||
|         if let Some(field) = &$field { | ||||
|             check_url!(field); | ||||
|         } | ||||
|     }; | ||||
|     ($field:expr, $($fields:expr),+) => { | ||||
|         check_url_opt!($field); | ||||
|         check_url_opt!($($fields),+); | ||||
|     }; | ||||
| } | ||||
|  | ||||
| macro_rules! update_field { | ||||
|     ($pool:expr, $error:ident, $reminder:ident.[$field:ident]) => { | ||||
|         if let Some(value) = &$reminder.$field { | ||||
|             match sqlx::query(concat!( | ||||
|                 "UPDATE reminders SET `", | ||||
|                 stringify!($field), | ||||
|                 "` = ? WHERE uid = ?" | ||||
|             )) | ||||
|             .bind(value) | ||||
|             .bind(&$reminder.uid) | ||||
|             .execute($pool) | ||||
|             .await | ||||
|             { | ||||
|                 Ok(_) => {} | ||||
|                 Err(e) => { | ||||
|                     warn!( | ||||
|                         concat!( | ||||
|                             "Error in `update_field!(", | ||||
|                             stringify!($pool), | ||||
|                             stringify!($reminder), | ||||
|                             stringify!($field), | ||||
|                             ")': {:?}" | ||||
|                         ), | ||||
|                         e | ||||
|                     ); | ||||
|  | ||||
|                     $error.push(format!("Error setting field {}", stringify!($field))); | ||||
|                 } | ||||
|             } | ||||
|         } | ||||
|     }; | ||||
|  | ||||
|     ($pool:expr, $error:ident, $reminder:ident.[$field:ident, $($fields:ident),+]) => { | ||||
|         update_field!($pool, $error, $reminder.[$field]); | ||||
|         update_field!($pool, $error, $reminder.[$($fields),+]); | ||||
|     }; | ||||
| } | ||||
|  | ||||
| macro_rules! json_err { | ||||
|     ($message:expr) => { | ||||
|         Err(json!({ "error": $message })) | ||||
|     }; | ||||
| } | ||||
							
								
								
									
										43
									
								
								web/src/metrics.rs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										43
									
								
								web/src/metrics.rs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,43 @@ | ||||
| use lazy_static::lazy_static; | ||||
| use prometheus::{IntCounterVec, Opts, Registry}; | ||||
| use rocket::{ | ||||
|     fairing::{Fairing, Info, Kind}, | ||||
|     Data, Request, Response, | ||||
| }; | ||||
|  | ||||
| lazy_static! { | ||||
|     pub static ref REGISTRY: Registry = Registry::new(); | ||||
|     static ref REQUEST_COUNTER: IntCounterVec = | ||||
|         IntCounterVec::new(Opts::new("requests", "Requests"), &["method", "route"]).unwrap(); | ||||
|     static ref RESPONSE_COUNTER: IntCounterVec = | ||||
|         IntCounterVec::new(Opts::new("responses", "Responses"), &["status", "route"]).unwrap(); | ||||
| } | ||||
|  | ||||
| pub fn init_metrics() { | ||||
|     REGISTRY.register(Box::new(REQUEST_COUNTER.clone())).unwrap(); | ||||
| } | ||||
|  | ||||
| pub struct MetricProducer; | ||||
|  | ||||
| #[rocket::async_trait] | ||||
| impl Fairing for MetricProducer { | ||||
|     fn info(&self) -> Info { | ||||
|         Info { name: "Metrics fairing", kind: Kind::Request } | ||||
|     } | ||||
|  | ||||
|     async fn on_request(&self, req: &mut Request<'_>, _data: &mut Data<'_>) { | ||||
|         if let Some(route) = req.route() { | ||||
|             REQUEST_COUNTER | ||||
|                 .with_label_values(&[req.method().as_str(), &route.uri.to_string()]) | ||||
|                 .inc(); | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     async fn on_response<'r>(&self, req: &'r Request<'_>, resp: &mut Response<'r>) { | ||||
|         if let Some(route) = req.route() { | ||||
|             RESPONSE_COUNTER | ||||
|                 .with_label_values(&[&resp.status().code.to_string(), &route.uri.to_string()]) | ||||
|                 .inc(); | ||||
|         } | ||||
|     } | ||||
| } | ||||
							
								
								
									
										218
									
								
								web/src/routes/admin.rs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										218
									
								
								web/src/routes/admin.rs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,218 @@ | ||||
| use std::{collections::HashMap, env}; | ||||
|  | ||||
| use chrono::{DateTime, Utc}; | ||||
| use rocket::{ | ||||
|     http::{CookieJar, Status}, | ||||
|     serde::json::json, | ||||
|     State, | ||||
| }; | ||||
| use rocket_dyn_templates::Template; | ||||
| use serde::Serialize; | ||||
| use sqlx::{MySql, Pool}; | ||||
|  | ||||
| use crate::routes::JsonResult; | ||||
|  | ||||
| fn is_admin(cookies: &CookieJar<'_>) -> bool { | ||||
|     cookies | ||||
|         .get_private("userid") | ||||
|         .map_or(false, |cookie| Some(cookie.value().to_string()) == env::var("ADMIN_ID").ok()) | ||||
| } | ||||
|  | ||||
| #[get("/")] | ||||
| pub async fn admin_dashboard_home(cookies: &CookieJar<'_>) -> Result<Template, Status> { | ||||
|     if let Some(cookie) = cookies.get_private("userid") { | ||||
|         let map: HashMap<&str, String> = HashMap::new(); | ||||
|         if Some(cookie.value().to_string()) == env::var("ADMIN_ID").ok() { | ||||
|             Ok(Template::render("admin_dashboard", &map)) | ||||
|         } else { | ||||
|             Err(Status::Forbidden) | ||||
|         } | ||||
|     } else { | ||||
|         Err(Status::Unauthorized) | ||||
|     } | ||||
| } | ||||
|  | ||||
| #[derive(Serialize)] | ||||
| struct TimeFrame { | ||||
|     time_key: DateTime<Utc>, | ||||
|     count: i64, | ||||
| } | ||||
|  | ||||
| #[get("/data")] | ||||
| pub async fn bot_data(cookies: &CookieJar<'_>, pool: &State<Pool<MySql>>) -> JsonResult { | ||||
|     if !is_admin(cookies) { | ||||
|         return json_err!("Not authorized"); | ||||
|     } | ||||
|  | ||||
|     let backlog = sqlx::query!( | ||||
|         "SELECT COUNT(1) AS backlog FROM reminders WHERE `utc_time` < NOW() AND enabled = 1 AND `status` = 'pending'" | ||||
|     ) | ||||
|     .fetch_one(pool.inner()) | ||||
|     .await | ||||
|     .unwrap(); | ||||
|  | ||||
|     let schedule_once = sqlx::query_as_unchecked!( | ||||
|         TimeFrame, | ||||
|         "SELECT | ||||
|             FROM_UNIXTIME(FLOOR(UNIX_TIMESTAMP(`utc_time`) / 300) * 300) AS `time_key`, | ||||
|             COUNT(1) AS `count` | ||||
|         FROM reminders | ||||
|         WHERE | ||||
|             `utc_time` < DATE_ADD(NOW(), INTERVAL 1 DAY) AND | ||||
|             `utc_time` >= NOW() AND | ||||
|             `enabled` = 1 AND | ||||
|             `status` = 'pending' AND | ||||
|             `interval_seconds` IS NULL AND | ||||
|             `interval_months` IS NULL AND | ||||
|             `interval_days` IS NULL | ||||
|         GROUP BY `time_key` | ||||
|         ORDER BY `time_key`" | ||||
|     ) | ||||
|     .fetch_all(pool.inner()) | ||||
|     .await | ||||
|     .unwrap(); | ||||
|  | ||||
|     let schedule_interval = sqlx::query_as_unchecked!( | ||||
|         TimeFrame, | ||||
|         "SELECT | ||||
|             FROM_UNIXTIME(FLOOR(UNIX_TIMESTAMP(`utc_time`) / 300) * 300) AS `time_key`, | ||||
|             COUNT(1) AS `count` | ||||
|         FROM reminders | ||||
|         WHERE | ||||
|             `utc_time` < DATE_ADD(NOW(), INTERVAL 1 DAY) AND | ||||
|             `utc_time` >= NOW() AND | ||||
|             `status` = 'pending' AND | ||||
|             `enabled` = 1 AND ( | ||||
|                 `interval_seconds` IS NOT NULL OR | ||||
|                 `interval_months` IS NOT NULL OR | ||||
|                 `interval_days` IS NOT NULL | ||||
|             ) | ||||
|         GROUP BY `time_key` | ||||
|         ORDER BY `time_key`" | ||||
|     ) | ||||
|     .fetch_all(pool.inner()) | ||||
|     .await | ||||
|     .unwrap(); | ||||
|  | ||||
|     let schedule_once_long = sqlx::query_as_unchecked!( | ||||
|         TimeFrame, | ||||
|         "SELECT | ||||
|             FROM_UNIXTIME(FLOOR(UNIX_TIMESTAMP(`utc_time`) / 86400) * 86400) AS `time_key`, | ||||
|             COUNT(1) AS `count` | ||||
|         FROM reminders | ||||
|         WHERE | ||||
|             `utc_time` < DATE_ADD(NOW(), INTERVAL 31 DAY) AND | ||||
|             `utc_time` >= NOW() AND | ||||
|             `enabled` = 1 AND | ||||
|             `status` = 'pending' AND | ||||
|             `interval_seconds` IS NULL AND | ||||
|             `interval_months` IS NULL AND | ||||
|             `interval_days` IS NULL | ||||
|         GROUP BY `time_key` | ||||
|         ORDER BY `time_key`" | ||||
|     ) | ||||
|     .fetch_all(pool.inner()) | ||||
|     .await | ||||
|     .unwrap(); | ||||
|  | ||||
|     let schedule_interval_long = sqlx::query_as_unchecked!( | ||||
|         TimeFrame, | ||||
|         "SELECT | ||||
|             FROM_UNIXTIME(FLOOR(UNIX_TIMESTAMP(`utc_time`) / 86400) * 86400) AS `time_key`, | ||||
|             COUNT(1) AS `count` | ||||
|         FROM reminders | ||||
|         WHERE | ||||
|             `utc_time` < DATE_ADD(NOW(), INTERVAL 31 DAY) AND | ||||
|             `utc_time` >= NOW() AND | ||||
|             `status` = 'pending' AND | ||||
|             `enabled` = 1 AND ( | ||||
|                 `interval_seconds` IS NOT NULL OR | ||||
|                 `interval_months` IS NOT NULL OR | ||||
|                 `interval_days` IS NOT NULL | ||||
|             ) | ||||
|         GROUP BY `time_key` | ||||
|         ORDER BY `time_key`" | ||||
|     ) | ||||
|     .fetch_all(pool.inner()) | ||||
|     .await | ||||
|     .unwrap(); | ||||
|  | ||||
|     let history = sqlx::query_as_unchecked!( | ||||
|         TimeFrame, | ||||
|         "SELECT | ||||
|             FROM_UNIXTIME(FLOOR(UNIX_TIMESTAMP(`utc_time`) / 86400) * 86400) AS `time_key`, | ||||
|             COUNT(1) AS `count` | ||||
|         FROM stat | ||||
|         WHERE | ||||
|             `utc_time` > DATE_SUB(NOW(), INTERVAL 31 DAY) AND | ||||
|             `type` = 'reminder_sent' | ||||
|         GROUP BY `time_key` | ||||
|         ORDER BY `time_key`" | ||||
|     ) | ||||
|     .fetch_all(pool.inner()) | ||||
|     .await | ||||
|     .unwrap(); | ||||
|  | ||||
|     let history_failed = sqlx::query_as_unchecked!( | ||||
|         TimeFrame, | ||||
|         "SELECT | ||||
|             FROM_UNIXTIME(FLOOR(UNIX_TIMESTAMP(`utc_time`) / 86400) * 86400) AS `time_key`, | ||||
|             COUNT(1) AS `count` | ||||
|         FROM stat | ||||
|         WHERE | ||||
|             `utc_time` > DATE_SUB(NOW(), INTERVAL 31 DAY) AND | ||||
|             `type` = 'reminder_failed' | ||||
|         GROUP BY `time_key` | ||||
|         ORDER BY `time_key`" | ||||
|     ) | ||||
|     .fetch_all(pool.inner()) | ||||
|     .await | ||||
|     .unwrap(); | ||||
|  | ||||
|     let interval_count = sqlx::query!( | ||||
|         "SELECT COUNT(1) AS count | ||||
|         FROM reminders | ||||
|         WHERE | ||||
|             `status` = 'pending' AND ( | ||||
|                 `interval_seconds` IS NOT NULL OR | ||||
|                 `interval_months` IS NOT NULL OR | ||||
|                 `interval_days` IS NOT NULL | ||||
|             )" | ||||
|     ) | ||||
|     .fetch_one(pool.inner()) | ||||
|     .await | ||||
|     .unwrap(); | ||||
|  | ||||
|     let reminder_count = sqlx::query!( | ||||
|         "SELECT COUNT(1) AS count | ||||
|         FROM reminders | ||||
|         WHERE | ||||
|             `status` = 'pending' AND | ||||
|             `interval_seconds` IS NULL AND | ||||
|             `interval_months` IS NULL AND | ||||
|             `interval_days` IS NULL" | ||||
|     ) | ||||
|     .fetch_one(pool.inner()) | ||||
|     .await | ||||
|     .unwrap(); | ||||
|  | ||||
|     Ok(json!({ | ||||
|         "backlog": backlog.backlog, | ||||
|         "scheduleShort": { | ||||
|             "once": schedule_once, | ||||
|             "interval": schedule_interval | ||||
|         }, | ||||
|         "scheduleLong": { | ||||
|             "once": schedule_once_long, | ||||
|             "interval": schedule_interval_long, | ||||
|         }, | ||||
|         "historyLong": { | ||||
|             "sent": history, | ||||
|             "failed": history_failed, | ||||
|         }, | ||||
|         "count": { | ||||
|             "reminders": reminder_count.count, | ||||
|             "intervals": interval_count.count, | ||||
|         } | ||||
|     })) | ||||
| } | ||||
							
								
								
									
										61
									
								
								web/src/routes/dashboard/api/guild/channels.rs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										61
									
								
								web/src/routes/dashboard/api/guild/channels.rs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,61 @@ | ||||
| use rocket::{http::CookieJar, serde::json::json, State}; | ||||
| use serde::Serialize; | ||||
| use serenity::{ | ||||
|     client::Context, | ||||
|     model::{ | ||||
|         channel::GuildChannel, | ||||
|         id::{ChannelId, GuildId}, | ||||
|     }, | ||||
| }; | ||||
|  | ||||
| use crate::{check_authorization, routes::JsonResult}; | ||||
|  | ||||
| #[derive(Serialize)] | ||||
| struct ChannelInfo { | ||||
|     id: String, | ||||
|     name: String, | ||||
|     webhook_avatar: Option<String>, | ||||
|     webhook_name: Option<String>, | ||||
| } | ||||
|  | ||||
| #[get("/api/guild/<id>/channels")] | ||||
| pub async fn get_guild_channels( | ||||
|     id: u64, | ||||
|     cookies: &CookieJar<'_>, | ||||
|     ctx: &State<Context>, | ||||
| ) -> JsonResult { | ||||
|     offline!(Ok(json!(vec![ChannelInfo { | ||||
|         name: "general".to_string(), | ||||
|         id: "1".to_string(), | ||||
|         webhook_avatar: None, | ||||
|         webhook_name: None, | ||||
|     }]))); | ||||
|     check_authorization(cookies, ctx.inner(), id).await?; | ||||
|  | ||||
|     match GuildId(id).to_guild_cached(ctx.inner()) { | ||||
|         Some(guild) => { | ||||
|             let mut channels = guild | ||||
|                 .channels | ||||
|                 .iter() | ||||
|                 .filter_map(|(id, channel)| channel.to_owned().guild().map(|c| (id.to_owned(), c))) | ||||
|                 .filter(|(_, channel)| channel.is_text_based()) | ||||
|                 .collect::<Vec<(ChannelId, GuildChannel)>>(); | ||||
|  | ||||
|             channels.sort_by(|(_, c1), (_, c2)| c1.position.cmp(&c2.position)); | ||||
|  | ||||
|             let channel_info = channels | ||||
|                 .iter() | ||||
|                 .map(|(channel_id, channel)| ChannelInfo { | ||||
|                     name: channel.name.to_string(), | ||||
|                     id: channel_id.to_string(), | ||||
|                     webhook_avatar: None, | ||||
|                     webhook_name: None, | ||||
|                 }) | ||||
|                 .collect::<Vec<ChannelInfo>>(); | ||||
|  | ||||
|             Ok(json!(channel_info)) | ||||
|         } | ||||
|  | ||||
|         None => json_err!("Bot not in guild"), | ||||
|     } | ||||
| } | ||||
							
								
								
									
										42
									
								
								web/src/routes/dashboard/api/guild/mod.rs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										42
									
								
								web/src/routes/dashboard/api/guild/mod.rs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,42 @@ | ||||
| mod channels; | ||||
| mod reminders; | ||||
| mod roles; | ||||
| mod templates; | ||||
|  | ||||
| use std::env; | ||||
|  | ||||
| pub use channels::*; | ||||
| pub use reminders::*; | ||||
| use rocket::{http::CookieJar, serde::json::json, State}; | ||||
| pub use roles::*; | ||||
| use serenity::{ | ||||
|     client::Context, | ||||
|     model::id::{GuildId, RoleId}, | ||||
| }; | ||||
| pub use templates::*; | ||||
|  | ||||
| use crate::{check_authorization, routes::JsonResult}; | ||||
|  | ||||
| #[get("/api/guild/<id>")] | ||||
| pub async fn get_guild_info(id: u64, cookies: &CookieJar<'_>, ctx: &State<Context>) -> JsonResult { | ||||
|     offline!(Ok(json!({ "patreon": true, "name": "Guild" }))); | ||||
|     check_authorization(cookies, ctx.inner(), id).await?; | ||||
|  | ||||
|     match GuildId(id).to_guild_cached(ctx.inner()) { | ||||
|         Some(guild) => { | ||||
|             let member_res = GuildId(env::var("PATREON_GUILD_ID").unwrap().parse().unwrap()) | ||||
|                 .member(&ctx.inner(), guild.owner_id) | ||||
|                 .await; | ||||
|  | ||||
|             let patreon = member_res.map_or(false, |member| { | ||||
|                 member | ||||
|                     .roles | ||||
|                     .contains(&RoleId(env::var("PATREON_ROLE_ID").unwrap().parse().unwrap())) | ||||
|             }); | ||||
|  | ||||
|             Ok(json!({ "patreon": patreon, "name": guild.name })) | ||||
|         } | ||||
|  | ||||
|         None => json_err!("Bot not in guild"), | ||||
|     } | ||||
| } | ||||
							
								
								
									
										373
									
								
								web/src/routes/dashboard/api/guild/reminders.rs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										373
									
								
								web/src/routes/dashboard/api/guild/reminders.rs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,373 @@ | ||||
| use rocket::{ | ||||
|     http::CookieJar, | ||||
|     serde::json::{json, Json}, | ||||
|     State, | ||||
| }; | ||||
| use serenity::{ | ||||
|     client::Context, | ||||
|     model::id::{ChannelId, GuildId, UserId}, | ||||
| }; | ||||
| use sqlx::{MySql, Pool}; | ||||
|  | ||||
| use crate::{ | ||||
|     check_authorization, check_guild_subscription, check_subscription, | ||||
|     consts::MIN_INTERVAL, | ||||
|     guards::transaction::Transaction, | ||||
|     routes::{ | ||||
|         dashboard::{ | ||||
|             create_database_channel, create_reminder, DeleteReminder, PatchReminder, Reminder, | ||||
|         }, | ||||
|         JsonResult, | ||||
|     }, | ||||
|     Database, | ||||
| }; | ||||
|  | ||||
| #[post("/api/guild/<id>/reminders", data = "<reminder>")] | ||||
| pub async fn create_guild_reminder( | ||||
|     id: u64, | ||||
|     reminder: Json<Reminder>, | ||||
|     cookies: &CookieJar<'_>, | ||||
|     ctx: &State<Context>, | ||||
|     mut transaction: Transaction<'_>, | ||||
| ) -> JsonResult { | ||||
|     check_authorization(cookies, ctx.inner(), id).await?; | ||||
|  | ||||
|     let user_id = | ||||
|         cookies.get_private("userid").map(|c| c.value().parse::<u64>().ok()).flatten().unwrap(); | ||||
|  | ||||
|     match create_reminder( | ||||
|         ctx.inner(), | ||||
|         &mut transaction, | ||||
|         GuildId(id), | ||||
|         UserId(user_id), | ||||
|         reminder.into_inner(), | ||||
|     ) | ||||
|     .await | ||||
|     { | ||||
|         Ok(r) => match transaction.commit().await { | ||||
|             Ok(_) => Ok(r), | ||||
|             Err(e) => { | ||||
|                 warn!("Couldn't commit transaction: {:?}", e); | ||||
|                 json_err!("Couldn't commit transaction.") | ||||
|             } | ||||
|         }, | ||||
|  | ||||
|         Err(e) => Err(e), | ||||
|     } | ||||
| } | ||||
|  | ||||
| #[get("/api/guild/<id>/reminders")] | ||||
| pub async fn get_reminders( | ||||
|     id: u64, | ||||
|     cookies: &CookieJar<'_>, | ||||
|     ctx: &State<Context>, | ||||
|     pool: &State<Pool<MySql>>, | ||||
| ) -> JsonResult { | ||||
|     check_authorization(cookies, ctx.inner(), id).await?; | ||||
|  | ||||
|     let channels_res = GuildId(id).channels(&ctx.inner()).await; | ||||
|  | ||||
|     match channels_res { | ||||
|         Ok(channels) => { | ||||
|             let channels = channels | ||||
|                 .keys() | ||||
|                 .into_iter() | ||||
|                 .map(|k| k.as_u64().to_string()) | ||||
|                 .collect::<Vec<String>>() | ||||
|                 .join(","); | ||||
|  | ||||
|             sqlx::query_as_unchecked!( | ||||
|                 Reminder, | ||||
|                 "SELECT | ||||
|                  reminders.attachment, | ||||
|                  reminders.attachment_name, | ||||
|                  reminders.avatar, | ||||
|                  channels.channel, | ||||
|                  reminders.content, | ||||
|                  reminders.embed_author, | ||||
|                  reminders.embed_author_url, | ||||
|                  reminders.embed_color, | ||||
|                  reminders.embed_description, | ||||
|                  reminders.embed_footer, | ||||
|                  reminders.embed_footer_url, | ||||
|                  reminders.embed_image_url, | ||||
|                  reminders.embed_thumbnail_url, | ||||
|                  reminders.embed_title, | ||||
|                  IFNULL(reminders.embed_fields, '[]') AS embed_fields, | ||||
|                  reminders.enabled, | ||||
|                  reminders.expires, | ||||
|                  reminders.interval_seconds, | ||||
|                  reminders.interval_days, | ||||
|                  reminders.interval_months, | ||||
|                  reminders.name, | ||||
|                  reminders.restartable, | ||||
|                  reminders.tts, | ||||
|                  reminders.uid, | ||||
|                  reminders.username, | ||||
|                  reminders.utc_time | ||||
|                 FROM reminders | ||||
|                 LEFT JOIN channels ON channels.id = reminders.channel_id | ||||
|                 WHERE `status` = 'pending' AND FIND_IN_SET(channels.channel, ?)", | ||||
|                 channels | ||||
|             ) | ||||
|             .fetch_all(pool.inner()) | ||||
|             .await | ||||
|             .map(|r| Ok(json!(r))) | ||||
|             .unwrap_or_else(|e| { | ||||
|                 warn!("Failed to complete SQL query: {:?}", e); | ||||
|  | ||||
|                 json_err!("Could not load reminders") | ||||
|             }) | ||||
|         } | ||||
|         Err(e) => { | ||||
|             warn!("Could not fetch channels from {}: {:?}", id, e); | ||||
|  | ||||
|             Ok(json!([])) | ||||
|         } | ||||
|     } | ||||
| } | ||||
|  | ||||
| #[patch("/api/guild/<id>/reminders", data = "<reminder>")] | ||||
| pub async fn edit_reminder( | ||||
|     id: u64, | ||||
|     reminder: Json<PatchReminder>, | ||||
|     ctx: &State<Context>, | ||||
|     mut transaction: Transaction<'_>, | ||||
|     pool: &State<Pool<Database>>, | ||||
|     cookies: &CookieJar<'_>, | ||||
| ) -> JsonResult { | ||||
|     check_authorization(cookies, ctx.inner(), id).await?; | ||||
|  | ||||
|     let mut error = vec![]; | ||||
|  | ||||
|     let user_id = | ||||
|         cookies.get_private("userid").map(|c| c.value().parse::<u64>().ok()).flatten().unwrap(); | ||||
|  | ||||
|     if reminder.message_ok() { | ||||
|         update_field!(transaction.executor(), error, reminder.[ | ||||
|             content, | ||||
|             embed_author, | ||||
|             embed_description, | ||||
|             embed_footer, | ||||
|             embed_title, | ||||
|             embed_fields, | ||||
|             username | ||||
|         ]); | ||||
|     } else { | ||||
|         error.push("Message exceeds limits.".to_string()); | ||||
|     } | ||||
|  | ||||
|     update_field!(transaction.executor(), error, reminder.[ | ||||
|         attachment, | ||||
|         attachment_name, | ||||
|         avatar, | ||||
|         embed_author_url, | ||||
|         embed_color, | ||||
|         embed_footer_url, | ||||
|         embed_image_url, | ||||
|         embed_thumbnail_url, | ||||
|         enabled, | ||||
|         expires, | ||||
|         name, | ||||
|         restartable, | ||||
|         tts, | ||||
|         utc_time | ||||
|     ]); | ||||
|  | ||||
|     if reminder.interval_days.flatten().is_some() | ||||
|         || reminder.interval_months.flatten().is_some() | ||||
|         || reminder.interval_seconds.flatten().is_some() | ||||
|     { | ||||
|         if check_guild_subscription(&ctx.inner(), id).await | ||||
|             || check_subscription(&ctx.inner(), user_id).await | ||||
|         { | ||||
|             let new_interval_length = match reminder.interval_days { | ||||
|                 Some(interval) => interval.unwrap_or(0), | ||||
|                 None => sqlx::query!( | ||||
|                     "SELECT interval_days AS days FROM reminders WHERE uid = ?", | ||||
|                     reminder.uid | ||||
|                 ) | ||||
|                 .fetch_one(transaction.executor()) | ||||
|                 .await | ||||
|                 .map_err(|e| { | ||||
|                     warn!("Error updating reminder interval: {:?}", e); | ||||
|                     json!({ "reminder": Option::<Reminder>::None, "errors": vec!["Unknown error"] }) | ||||
|                 })? | ||||
|                 .days | ||||
|                 .unwrap_or(0), | ||||
|             } * 86400 + match reminder.interval_months { | ||||
|                 Some(interval) => interval.unwrap_or(0), | ||||
|                 None => sqlx::query!( | ||||
|                     "SELECT interval_months AS months FROM reminders WHERE uid = ?", | ||||
|                     reminder.uid | ||||
|                 ) | ||||
|                 .fetch_one(transaction.executor()) | ||||
|                 .await | ||||
|                 .map_err(|e| { | ||||
|                     warn!("Error updating reminder interval: {:?}", e); | ||||
|                     json!({ "reminder": Option::<Reminder>::None, "errors": vec!["Unknown error"] }) | ||||
|                 })? | ||||
|                 .months | ||||
|                 .unwrap_or(0), | ||||
|             } * 2592000 + match reminder.interval_seconds { | ||||
|                 Some(interval) => interval.unwrap_or(0), | ||||
|                 None => sqlx::query!( | ||||
|                     "SELECT interval_seconds AS seconds FROM reminders WHERE uid = ?", | ||||
|                     reminder.uid | ||||
|                 ) | ||||
|                 .fetch_one(transaction.executor()) | ||||
|                 .await | ||||
|                 .map_err(|e| { | ||||
|                     warn!("Error updating reminder interval: {:?}", e); | ||||
|                     json!({ "reminder": Option::<Reminder>::None, "errors": vec!["Unknown error"] }) | ||||
|                 })? | ||||
|                 .seconds | ||||
|                 .unwrap_or(0), | ||||
|             }; | ||||
|  | ||||
|             if new_interval_length < *MIN_INTERVAL { | ||||
|                 error.push(String::from("New interval is too short.")); | ||||
|             } else { | ||||
|                 update_field!(transaction.executor(), error, reminder.[ | ||||
|                     interval_days, | ||||
|                     interval_months, | ||||
|                     interval_seconds | ||||
|                 ]); | ||||
|             } | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     if reminder.channel > 0 { | ||||
|         let channel = ChannelId(reminder.channel).to_channel_cached(&ctx.inner()); | ||||
|         match channel { | ||||
|             Some(channel) => { | ||||
|                 let channel_matches_guild = channel.guild().map_or(false, |c| c.guild_id.0 == id); | ||||
|  | ||||
|                 if !channel_matches_guild { | ||||
|                     warn!( | ||||
|                         "Error in `edit_reminder`: channel {:?} not found for guild {}", | ||||
|                         reminder.channel, id | ||||
|                     ); | ||||
|  | ||||
|                     return Err(json!({"error": "Channel not found"})); | ||||
|                 } | ||||
|  | ||||
|                 let channel = create_database_channel( | ||||
|                     ctx.inner(), | ||||
|                     ChannelId(reminder.channel), | ||||
|                     &mut transaction, | ||||
|                 ) | ||||
|                 .await; | ||||
|  | ||||
|                 if let Err(e) = channel { | ||||
|                     warn!("`create_database_channel` returned an error code: {:?}", e); | ||||
|  | ||||
|                     return Err( | ||||
|                         json!({"error": "Failed to configure channel for reminders. Please check the bot permissions"}), | ||||
|                     ); | ||||
|                 } | ||||
|  | ||||
|                 let channel = channel.unwrap(); | ||||
|  | ||||
|                 match sqlx::query!( | ||||
|                     "UPDATE reminders SET channel_id = ? WHERE uid = ?", | ||||
|                     channel, | ||||
|                     reminder.uid | ||||
|                 ) | ||||
|                 .execute(transaction.executor()) | ||||
|                 .await | ||||
|                 { | ||||
|                     Ok(_) => {} | ||||
|                     Err(e) => { | ||||
|                         warn!("Error setting channel: {:?}", e); | ||||
|  | ||||
|                         error.push("Couldn't set channel".to_string()) | ||||
|                     } | ||||
|                 } | ||||
|             } | ||||
|  | ||||
|             None => { | ||||
|                 warn!( | ||||
|                     "Error in `edit_reminder`: channel {:?} not found for guild {}", | ||||
|                     reminder.channel, id | ||||
|                 ); | ||||
|  | ||||
|                 return Err(json!({"error": "Channel not found"})); | ||||
|             } | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     if let Err(e) = transaction.commit().await { | ||||
|         warn!("Couldn't commit transaction: {:?}", e); | ||||
|         return json_err!("Couldn't commit transaction"); | ||||
|     } | ||||
|  | ||||
|     match sqlx::query_as_unchecked!( | ||||
|         Reminder, | ||||
|         "SELECT reminders.attachment, | ||||
|          reminders.attachment_name, | ||||
|          reminders.avatar, | ||||
|          channels.channel, | ||||
|          reminders.content, | ||||
|          reminders.embed_author, | ||||
|          reminders.embed_author_url, | ||||
|          reminders.embed_color, | ||||
|          reminders.embed_description, | ||||
|          reminders.embed_footer, | ||||
|          reminders.embed_footer_url, | ||||
|          reminders.embed_image_url, | ||||
|          reminders.embed_thumbnail_url, | ||||
|          reminders.embed_title, | ||||
|          reminders.embed_fields, | ||||
|          reminders.enabled, | ||||
|          reminders.expires, | ||||
|          reminders.interval_seconds, | ||||
|          reminders.interval_days, | ||||
|          reminders.interval_months, | ||||
|          reminders.name, | ||||
|          reminders.restartable, | ||||
|          reminders.tts, | ||||
|          reminders.uid, | ||||
|          reminders.username, | ||||
|          reminders.utc_time | ||||
|         FROM reminders | ||||
|         LEFT JOIN channels ON channels.id = reminders.channel_id | ||||
|         WHERE uid = ?", | ||||
|         reminder.uid | ||||
|     ) | ||||
|     .fetch_one(pool.inner()) | ||||
|     .await | ||||
|     { | ||||
|         Ok(reminder) => Ok(json!({"reminder": reminder, "errors": error})), | ||||
|  | ||||
|         Err(e) => { | ||||
|             warn!("Error exiting `edit_reminder': {:?}", e); | ||||
|  | ||||
|             Err(json!({"reminder": Option::<Reminder>::None, "errors": vec!["Unknown error"]})) | ||||
|         } | ||||
|     } | ||||
| } | ||||
|  | ||||
| #[delete("/api/guild/<id>/reminders", data = "<reminder>")] | ||||
| pub async fn delete_reminder( | ||||
|     cookies: &CookieJar<'_>, | ||||
|     id: u64, | ||||
|     reminder: Json<DeleteReminder>, | ||||
|     ctx: &State<Context>, | ||||
|     pool: &State<Pool<MySql>>, | ||||
| ) -> JsonResult { | ||||
|     check_authorization(cookies, ctx.inner(), id).await?; | ||||
|  | ||||
|     match sqlx::query!("UPDATE reminders SET `status` = 'deleted' WHERE uid = ?", reminder.uid) | ||||
|         .execute(pool.inner()) | ||||
|         .await | ||||
|     { | ||||
|         Ok(_) => Ok(json!({})), | ||||
|  | ||||
|         Err(e) => { | ||||
|             warn!("Error in `delete_reminder`: {:?}", e); | ||||
|  | ||||
|             Err(json!({"error": "Could not delete reminder"})) | ||||
|         } | ||||
|     } | ||||
| } | ||||
							
								
								
									
										35
									
								
								web/src/routes/dashboard/api/guild/roles.rs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										35
									
								
								web/src/routes/dashboard/api/guild/roles.rs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,35 @@ | ||||
| use rocket::{http::CookieJar, serde::json::json, State}; | ||||
| use serde::Serialize; | ||||
| use serenity::client::Context; | ||||
|  | ||||
| use crate::{check_authorization, routes::JsonResult}; | ||||
|  | ||||
| #[derive(Serialize)] | ||||
| struct RoleInfo { | ||||
|     id: String, | ||||
|     name: String, | ||||
| } | ||||
|  | ||||
| #[get("/api/guild/<id>/roles")] | ||||
| pub async fn get_guild_roles(id: u64, cookies: &CookieJar<'_>, ctx: &State<Context>) -> JsonResult { | ||||
|     offline!(Ok(json!(vec![RoleInfo { name: "@everyone".to_string(), id: "1".to_string() }]))); | ||||
|     check_authorization(cookies, ctx.inner(), id).await?; | ||||
|  | ||||
|     let roles_res = ctx.cache.guild_roles(id); | ||||
|  | ||||
|     match roles_res { | ||||
|         Some(roles) => { | ||||
|             let roles = roles | ||||
|                 .iter() | ||||
|                 .map(|(_, r)| RoleInfo { id: r.id.to_string(), name: r.name.to_string() }) | ||||
|                 .collect::<Vec<RoleInfo>>(); | ||||
|  | ||||
|             Ok(json!(roles)) | ||||
|         } | ||||
|         None => { | ||||
|             warn!("Could not fetch roles from {}", id); | ||||
|  | ||||
|             json_err!("Could not get roles") | ||||
|         } | ||||
|     } | ||||
| } | ||||
							
								
								
									
										181
									
								
								web/src/routes/dashboard/api/guild/templates.rs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										181
									
								
								web/src/routes/dashboard/api/guild/templates.rs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,181 @@ | ||||
| use rocket::{ | ||||
|     http::CookieJar, | ||||
|     serde::json::{json, Json}, | ||||
|     State, | ||||
| }; | ||||
| use serenity::client::Context; | ||||
| use sqlx::{MySql, Pool}; | ||||
|  | ||||
| use crate::{ | ||||
|     check_authorization, | ||||
|     consts::{ | ||||
|         MAX_CONTENT_LENGTH, MAX_EMBED_AUTHOR_LENGTH, MAX_EMBED_DESCRIPTION_LENGTH, | ||||
|         MAX_EMBED_FIELDS, MAX_EMBED_FIELD_TITLE_LENGTH, MAX_EMBED_FIELD_VALUE_LENGTH, | ||||
|         MAX_EMBED_FOOTER_LENGTH, MAX_EMBED_TITLE_LENGTH, MAX_URL_LENGTH, MAX_USERNAME_LENGTH, | ||||
|     }, | ||||
|     routes::{ | ||||
|         dashboard::{template_name_default, DeleteReminderTemplate, ReminderTemplate}, | ||||
|         JsonResult, | ||||
|     }, | ||||
| }; | ||||
|  | ||||
| #[get("/api/guild/<id>/templates")] | ||||
| pub async fn get_reminder_templates( | ||||
|     id: u64, | ||||
|     cookies: &CookieJar<'_>, | ||||
|     ctx: &State<Context>, | ||||
|     pool: &State<Pool<MySql>>, | ||||
| ) -> JsonResult { | ||||
|     check_authorization(cookies, ctx.inner(), id).await?; | ||||
|  | ||||
|     match sqlx::query_as_unchecked!( | ||||
|         ReminderTemplate, | ||||
|         "SELECT * FROM reminder_template WHERE guild_id = (SELECT id FROM guilds WHERE guild = ?)", | ||||
|         id | ||||
|     ) | ||||
|     .fetch_all(pool.inner()) | ||||
|     .await | ||||
|     { | ||||
|         Ok(templates) => Ok(json!(templates)), | ||||
|         Err(e) => { | ||||
|             warn!("Could not fetch templates from {}: {:?}", id, e); | ||||
|  | ||||
|             json_err!("Could not get templates") | ||||
|         } | ||||
|     } | ||||
| } | ||||
|  | ||||
| #[post("/api/guild/<id>/templates", data = "<reminder_template>")] | ||||
| pub async fn create_reminder_template( | ||||
|     id: u64, | ||||
|     reminder_template: Json<ReminderTemplate>, | ||||
|     cookies: &CookieJar<'_>, | ||||
|     ctx: &State<Context>, | ||||
|     pool: &State<Pool<MySql>>, | ||||
| ) -> JsonResult { | ||||
|     check_authorization(cookies, ctx.inner(), id).await?; | ||||
|  | ||||
|     // validate lengths | ||||
|     check_length!(MAX_CONTENT_LENGTH, reminder_template.content); | ||||
|     check_length!(MAX_EMBED_DESCRIPTION_LENGTH, reminder_template.embed_description); | ||||
|     check_length!(MAX_EMBED_TITLE_LENGTH, reminder_template.embed_title); | ||||
|     check_length!(MAX_EMBED_AUTHOR_LENGTH, reminder_template.embed_author); | ||||
|     check_length!(MAX_EMBED_FOOTER_LENGTH, reminder_template.embed_footer); | ||||
|     check_length_opt!(MAX_EMBED_FIELDS, reminder_template.embed_fields); | ||||
|     if let Some(fields) = &reminder_template.embed_fields { | ||||
|         for field in &fields.0 { | ||||
|             check_length!(MAX_EMBED_FIELD_VALUE_LENGTH, field.value); | ||||
|             check_length!(MAX_EMBED_FIELD_TITLE_LENGTH, field.title); | ||||
|         } | ||||
|     } | ||||
|     check_length_opt!(MAX_USERNAME_LENGTH, reminder_template.username); | ||||
|     check_length_opt!( | ||||
|         MAX_URL_LENGTH, | ||||
|         reminder_template.embed_footer_url, | ||||
|         reminder_template.embed_thumbnail_url, | ||||
|         reminder_template.embed_author_url, | ||||
|         reminder_template.embed_image_url, | ||||
|         reminder_template.avatar | ||||
|     ); | ||||
|  | ||||
|     // validate urls | ||||
|     check_url_opt!( | ||||
|         reminder_template.embed_footer_url, | ||||
|         reminder_template.embed_thumbnail_url, | ||||
|         reminder_template.embed_author_url, | ||||
|         reminder_template.embed_image_url, | ||||
|         reminder_template.avatar | ||||
|     ); | ||||
|  | ||||
|     let name = if reminder_template.name.is_empty() { | ||||
|         template_name_default() | ||||
|     } else { | ||||
|         reminder_template.name.clone() | ||||
|     }; | ||||
|  | ||||
|     match sqlx::query!( | ||||
|         "INSERT INTO reminder_template | ||||
|         (guild_id, | ||||
|          name, | ||||
|          attachment, | ||||
|          attachment_name, | ||||
|          avatar, | ||||
|          content, | ||||
|          embed_author, | ||||
|          embed_author_url, | ||||
|          embed_color, | ||||
|          embed_description, | ||||
|          embed_footer, | ||||
|          embed_footer_url, | ||||
|          embed_image_url, | ||||
|          embed_thumbnail_url, | ||||
|          embed_title, | ||||
|          embed_fields, | ||||
|          interval_seconds, | ||||
|          interval_days, | ||||
|          interval_months, | ||||
|          tts, | ||||
|          username | ||||
|         ) VALUES ((SELECT id FROM guilds WHERE guild = ?), ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, | ||||
|          ?, ?, ?, ?, ?, ?, ?)", | ||||
|         id, | ||||
|         name, | ||||
|         reminder_template.attachment, | ||||
|         reminder_template.attachment_name, | ||||
|         reminder_template.avatar, | ||||
|         reminder_template.content, | ||||
|         reminder_template.embed_author, | ||||
|         reminder_template.embed_author_url, | ||||
|         reminder_template.embed_color, | ||||
|         reminder_template.embed_description, | ||||
|         reminder_template.embed_footer, | ||||
|         reminder_template.embed_footer_url, | ||||
|         reminder_template.embed_image_url, | ||||
|         reminder_template.embed_thumbnail_url, | ||||
|         reminder_template.embed_title, | ||||
|         reminder_template.embed_fields, | ||||
|         reminder_template.interval_seconds, | ||||
|         reminder_template.interval_days, | ||||
|         reminder_template.interval_months, | ||||
|         reminder_template.tts, | ||||
|         reminder_template.username, | ||||
|     ) | ||||
|     .fetch_all(pool.inner()) | ||||
|     .await | ||||
|     { | ||||
|         Ok(_) => Ok(json!({})), | ||||
|         Err(e) => { | ||||
|             warn!("Could not create template for {}: {:?}", id, e); | ||||
|  | ||||
|             json_err!("Could not create template") | ||||
|         } | ||||
|     } | ||||
| } | ||||
|  | ||||
| #[delete("/api/guild/<id>/templates", data = "<delete_reminder_template>")] | ||||
| pub async fn delete_reminder_template( | ||||
|     id: u64, | ||||
|     delete_reminder_template: Json<DeleteReminderTemplate>, | ||||
|     cookies: &CookieJar<'_>, | ||||
|     ctx: &State<Context>, | ||||
|     pool: &State<Pool<MySql>>, | ||||
| ) -> JsonResult { | ||||
|     check_authorization(cookies, ctx.inner(), id).await?; | ||||
|  | ||||
|     match sqlx::query!( | ||||
|         "DELETE FROM reminder_template WHERE guild_id = (SELECT id FROM guilds WHERE guild = ?) AND id = ?", | ||||
|         id, delete_reminder_template.id | ||||
|     ) | ||||
|     .fetch_all(pool.inner()) | ||||
|     .await | ||||
|     { | ||||
|         Ok(_) => { | ||||
|             Ok(json!({})) | ||||
|         } | ||||
|         Err(e) => { | ||||
|             warn!("Could not delete template from {}: {:?}", id, e); | ||||
|  | ||||
|             json_err!("Could not delete template") | ||||
|         } | ||||
|     } | ||||
| } | ||||
							
								
								
									
										2
									
								
								web/src/routes/dashboard/api/mod.rs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										2
									
								
								web/src/routes/dashboard/api/mod.rs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,2 @@ | ||||
| pub mod guild; | ||||
| pub mod user; | ||||
							
								
								
									
										81
									
								
								web/src/routes/dashboard/api/user/guilds.rs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										81
									
								
								web/src/routes/dashboard/api/user/guilds.rs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,81 @@ | ||||
| use reqwest::Client; | ||||
| use rocket::{ | ||||
|     http::CookieJar, | ||||
|     serde::json::{json, Value as JsonValue}, | ||||
|     State, | ||||
| }; | ||||
| use serde::{Deserialize, Serialize}; | ||||
| use serenity::model::{id::GuildId, permissions::Permissions}; | ||||
|  | ||||
| use crate::consts::DISCORD_API; | ||||
|  | ||||
| #[derive(Serialize)] | ||||
| struct GuildInfo { | ||||
|     id: String, | ||||
|     name: String, | ||||
| } | ||||
|  | ||||
| #[derive(Deserialize)] | ||||
| struct PartialGuild { | ||||
|     pub id: GuildId, | ||||
|     pub name: String, | ||||
|     #[serde(default)] | ||||
|     pub owner: bool, | ||||
|     #[serde(rename = "permissions_new")] | ||||
|     pub permissions: Option<String>, | ||||
| } | ||||
|  | ||||
| #[get("/api/user/guilds")] | ||||
| pub async fn get_user_guilds(cookies: &CookieJar<'_>, reqwest_client: &State<Client>) -> JsonValue { | ||||
|     offline!(json!(vec![GuildInfo { id: "1".to_string(), name: "Guild".to_string() }])); | ||||
|  | ||||
|     if let Some(access_token) = cookies.get_private("access_token") { | ||||
|         let request_res = reqwest_client | ||||
|             .get(format!("{}/users/@me/guilds", DISCORD_API)) | ||||
|             .bearer_auth(access_token.value()) | ||||
|             .send() | ||||
|             .await; | ||||
|  | ||||
|         match request_res { | ||||
|             Ok(response) => { | ||||
|                 let guilds_res = response.json::<Vec<PartialGuild>>().await; | ||||
|  | ||||
|                 match guilds_res { | ||||
|                     Ok(guilds) => { | ||||
|                         let reduced_guilds = guilds | ||||
|                             .iter() | ||||
|                             .filter(|g| { | ||||
|                                 g.owner | ||||
|                                     || g.permissions.as_ref().map_or(false, |p| { | ||||
|                                         let permissions = | ||||
|                                             Permissions::from_bits_truncate(p.parse().unwrap()); | ||||
|  | ||||
|                                         permissions.manage_messages() | ||||
|                                             || permissions.manage_guild() | ||||
|                                             || permissions.administrator() | ||||
|                                     }) | ||||
|                             }) | ||||
|                             .map(|g| GuildInfo { id: g.id.to_string(), name: g.name.to_string() }) | ||||
|                             .collect::<Vec<GuildInfo>>(); | ||||
|  | ||||
|                         json!(reduced_guilds) | ||||
|                     } | ||||
|  | ||||
|                     Err(e) => { | ||||
|                         warn!("Error constructing user from request: {:?}", e); | ||||
|  | ||||
|                         json!({"error": "Could not get user details"}) | ||||
|                     } | ||||
|                 } | ||||
|             } | ||||
|  | ||||
|             Err(e) => { | ||||
|                 warn!("Error getting user guilds: {:?}", e); | ||||
|  | ||||
|                 json!({"error": "Could not reach Discord"}) | ||||
|             } | ||||
|         } | ||||
|     } else { | ||||
|         json!({"error": "Not authorized"}) | ||||
|     } | ||||
| } | ||||
Some files were not shown because too many files have changed in this diff Show More
		Reference in New Issue
	
	Block a user