Compare commits
	
		
			138 Commits
		
	
	
		
			postgres
			...
			53aa5ebc55
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
| 53aa5ebc55 | |||
| 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 | 
							
								
								
									
										4
									
								
								.gitignore
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										4
									
								
								.gitignore
									
									
									
									
										vendored
									
									
								
							| @@ -2,6 +2,6 @@ | ||||
| .env | ||||
| /venv | ||||
| .cargo | ||||
| assets | ||||
| out.json | ||||
| /.idea | ||||
|  | ||||
| node_modules/ | ||||
|   | ||||
							
								
								
									
										2112
									
								
								Cargo.lock
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										2112
									
								
								Cargo.lock
									
									
									
										generated
									
									
									
								
							
										
											
												File diff suppressed because it is too large
												Load Diff
											
										
									
								
							
							
								
								
									
										50
									
								
								Cargo.toml
									
									
									
									
									
								
							
							
						
						
									
										50
									
								
								Cargo.toml
									
									
									
									
									
								
							| @@ -1,32 +1,58 @@ | ||||
| [package] | ||||
| name = "reminder_rs" | ||||
| version = "1.6.0" | ||||
| authors = ["jellywx <judesouthworth@pm.me>"] | ||||
| edition = "2018" | ||||
| workspaces = [".", "postman", "web", "entity", "migration"] | ||||
| name = "reminder-rs" | ||||
| version = "1.6.36" | ||||
| authors = ["Jude Southworth <judesouthworth@pm.me>"] | ||||
| edition = "2021" | ||||
| license = "AGPL-3.0 only" | ||||
| description = "Reminder Bot for Discord, now in Rust" | ||||
|  | ||||
| [dependencies] | ||||
| poise = "0.2" | ||||
| poise = "0.5.5" | ||||
| dotenv = "0.15" | ||||
| tokio = { version = "1", features = ["process", "full"] } | ||||
| reqwest = "0.11" | ||||
| regex = "1.4" | ||||
| lazy-regex = "2.3.0" | ||||
| regex = "1.6" | ||||
| 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" | ||||
| base64 = "0.13.0" | ||||
| sqlx = { version = "0.6", features = ["runtime-tokio-rustls", "macros", "mysql", "bigdecimal", "chrono", "migrate"]} | ||||
| base64 = "0.21.0" | ||||
|  | ||||
| [dependencies.postman] | ||||
| path = "postman" | ||||
|  | ||||
| [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"], | ||||
|     ["$OUT_DIR/web/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 | ||||
							
								
								
									
										46
									
								
								README.md
									
									
									
									
									
								
							
							
						
						
									
										46
									
								
								README.md
									
									
									
									
									
								
							| @@ -7,25 +7,36 @@ reminders are paid on the hosted version of the bot. Keep reading if you want to | ||||
|  | ||||
| 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 | ||||
| Install build requirements:  | ||||
| `sudo apt install gcc gcc-multilib cmake libssl-dev build-essential` | ||||
| ### Build APT package | ||||
|  | ||||
| Install Rust from https://rustup.rs | ||||
| Recommended method. | ||||
|  | ||||
| Reminder Bot can then 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. | ||||
| 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. | ||||
|  | ||||
| #### Compilation environment variables | ||||
| These environment variables must be provided when compiling the bot | ||||
| * `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** | ||||
| 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`  | ||||
|  | ||||
| ### 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 | ||||
| ### 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`) | ||||
| 8. Build: `cargo build --release` | ||||
|  | ||||
|  | ||||
| ### 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__ | ||||
| @@ -37,10 +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 | ||||
| * `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  | ||||
| * `DM_ENABLED` - default `1`, if `1`, Reminder Bot will respond to direct messages | ||||
|  | ||||
| ### Todo List | ||||
|  | ||||
| * Convert aliases to macros | ||||
|   | ||||
							
								
								
									
										10
									
								
								Rocket.toml
									
									
									
									
									
								
							
							
						
						
									
										10
									
								
								Rocket.toml
									
									
									
									
									
								
							| @@ -1,6 +1,6 @@ | ||||
| [default] | ||||
| address = "0.0.0.0" | ||||
| port = 5000 | ||||
| port = 18920 | ||||
| template_dir = "web/templates" | ||||
| limits = { json = "10MiB" } | ||||
|  | ||||
| @@ -11,18 +11,18 @@ secret_key = "tR8krio5FXTnnyIZNiJDXPondz0kI1v6X6BXZcBGIRY=" | ||||
| certs = "web/private/rsa_sha256_cert.pem" | ||||
| key = "web/private/rsa_sha256_key.pem" | ||||
|  | ||||
| [rsa_sha256.tls] | ||||
| [debug.rsa_sha256.tls] | ||||
| certs = "web/private/rsa_sha256_cert.pem" | ||||
| key = "web/private/rsa_sha256_key.pem" | ||||
|  | ||||
| [ecdsa_nistp256_sha256.tls] | ||||
| [debug.ecdsa_nistp256_sha256.tls] | ||||
| certs = "web/private/ecdsa_nistp256_sha256_cert.pem" | ||||
| key = "web/private/ecdsa_nistp256_sha256_key_pkcs8.pem" | ||||
|  | ||||
| [ecdsa_nistp384_sha384.tls] | ||||
| [debug.ecdsa_nistp384_sha384.tls] | ||||
| certs = "web/private/ecdsa_nistp384_sha384_cert.pem" | ||||
| key = "web/private/ecdsa_nistp384_sha384_key_pkcs8.pem" | ||||
|  | ||||
| [ed25519.tls] | ||||
| [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 | 
							
								
								
									
										99
									
								
								build.rs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										99
									
								
								build.rs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,99 @@ | ||||
| #[cfg(not(debug_assertions))] | ||||
| use std::{ | ||||
|     env, fs, | ||||
|     fs::{create_dir_all, DirEntry, File}, | ||||
|     io, | ||||
|     io::Write, | ||||
|     path::Path, | ||||
|     process::Command, | ||||
| }; | ||||
|  | ||||
| #[cfg(not(debug_assertions))] | ||||
| fn visit_dirs(dir: &Path, cb: &dyn Fn(&DirEntry)) -> io::Result<()> { | ||||
|     if dir.is_dir() { | ||||
|         for entry in fs::read_dir(dir)? { | ||||
|             let entry = entry?; | ||||
|             let path = entry.path(); | ||||
|             if path.is_dir() { | ||||
|                 visit_dirs(&path, cb)?; | ||||
|             } else { | ||||
|                 cb(&entry); | ||||
|             } | ||||
|         } | ||||
|     } | ||||
|     Ok(()) | ||||
| } | ||||
|  | ||||
| #[cfg(not(debug_assertions))] | ||||
| fn process_static(file: &DirEntry) { | ||||
|     let out_dir = env::var("OUT_DIR").unwrap(); | ||||
|     let path = file.path(); | ||||
|  | ||||
|     let in_path = path.to_str().unwrap(); | ||||
|     let art_path = format!("{}/{}", out_dir, in_path); | ||||
|     let art_dir = format!("{}/{}", out_dir, path.parent().unwrap().to_str().unwrap()); | ||||
|  | ||||
|     match path.extension().map(|o| o.to_str()).flatten() { | ||||
|         Some("ts") => {} | ||||
|         Some("js") => { | ||||
|             create_dir_all(art_dir).unwrap(); | ||||
|  | ||||
|             if art_path.ends_with(".min.js") { | ||||
|                 Command::new("cp").arg(in_path).arg(art_path).spawn().expect("Could not copy"); | ||||
|             } else { | ||||
|                 let minified = Command::new("npx") | ||||
|                     .arg("minify") | ||||
|                     .arg(in_path) | ||||
|                     .output() | ||||
|                     .expect("Could not minify"); | ||||
|  | ||||
|                 let mut fh = File::create(art_path).expect("Couldn't create file"); | ||||
|                 fh.write(&minified.stdout).unwrap(); | ||||
|             } | ||||
|         } | ||||
|         Some("css") => { | ||||
|             create_dir_all(art_dir).unwrap(); | ||||
|  | ||||
|             if art_path.ends_with(".min.css") { | ||||
|                 Command::new("cp").arg(in_path).arg(art_path).spawn().expect("Could not copy"); | ||||
|             } else { | ||||
|                 let minified = Command::new("npx") | ||||
|                     .arg("minify") | ||||
|                     .arg(in_path) | ||||
|                     .output() | ||||
|                     .expect("Could not minify"); | ||||
|  | ||||
|                 let mut fh = File::create(art_path).expect("Couldn't create file"); | ||||
|                 fh.write(&minified.stdout).unwrap(); | ||||
|             } | ||||
|         } | ||||
|         _ => { | ||||
|             create_dir_all(art_dir).unwrap(); | ||||
|  | ||||
|             Command::new("cp").arg(in_path).arg(art_path).spawn().expect("Could not copy"); | ||||
|         } | ||||
|     } | ||||
| } | ||||
|  | ||||
| // fn compile_tsc(file: &DirEntry) { | ||||
| //     if path.extension() == Some("ts") { | ||||
| //         let out_dir = env::var("OUT_DIR").unwrap(); | ||||
| //         let path = file.path(); | ||||
| // | ||||
| //         Command::new("npx") | ||||
| //             .arg("tsc") | ||||
| //             .arg(in_path) | ||||
| //             .arg(art_path) | ||||
| //             .spawn() | ||||
| //             .expect("Could not compile"); | ||||
| //     } | ||||
| // } | ||||
|  | ||||
| fn main() { | ||||
|     println!("cargo:rerun-if-changed=migrations"); | ||||
|  | ||||
|     #[cfg(not(debug_assertions))] | ||||
|     visit_dirs("web/static".as_ref(), &process_static).unwrap(); | ||||
|  | ||||
|     // visit_dirs("web/static".as_ref(), &compile_tsc).unwrap(); | ||||
| } | ||||
							
								
								
									
										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 | ||||
							
								
								
									
										229
									
								
								migrations/20210603000000_initial.sql
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										229
									
								
								migrations/20210603000000_initial.sql
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,229 @@ | ||||
| SET FOREIGN_KEY_CHECKS=0; | ||||
|  | ||||
| CREATE TABLE guilds ( | ||||
|     id INT UNSIGNED UNIQUE NOT NULL AUTO_INCREMENT, | ||||
|     guild BIGINT UNSIGNED UNIQUE NOT NULL, | ||||
|  | ||||
|     name VARCHAR(100), | ||||
|  | ||||
|     prefix VARCHAR(5) DEFAULT '$' NOT NULL, | ||||
|     timezone VARCHAR(32) DEFAULT 'UTC' NOT NULL, | ||||
|  | ||||
|     default_channel_id INT UNSIGNED, | ||||
|     default_username VARCHAR(32) DEFAULT 'Reminder' NOT NULL, | ||||
|     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 channels(id) ON DELETE SET NULL | ||||
| ); | ||||
|  | ||||
| CREATE TABLE channels ( | ||||
|     id INT UNSIGNED UNIQUE NOT NULL AUTO_INCREMENT, | ||||
|     channel BIGINT UNSIGNED UNIQUE NOT NULL, | ||||
|  | ||||
|     name VARCHAR(100), | ||||
|  | ||||
|     nudge SMALLINT NOT NULL DEFAULT 0, | ||||
|     blacklisted BOOL NOT NULL DEFAULT FALSE, | ||||
|  | ||||
|     webhook_id BIGINT UNSIGNED UNIQUE, | ||||
|     webhook_token TEXT, | ||||
|  | ||||
|     paused BOOL NOT NULL DEFAULT 0, | ||||
|     paused_until TIMESTAMP, | ||||
|  | ||||
|     guild_id INT UNSIGNED, | ||||
|  | ||||
|     PRIMARY KEY (id), | ||||
|     FOREIGN KEY (guild_id) REFERENCES guilds(id) ON DELETE CASCADE | ||||
| ); | ||||
|  | ||||
| CREATE TABLE users ( | ||||
|     id INT UNSIGNED AUTO_INCREMENT UNIQUE NOT NULL, | ||||
|     user BIGINT UNSIGNED UNIQUE NOT NULL, | ||||
|  | ||||
|     name VARCHAR(37) NOT NULL, | ||||
|  | ||||
|     dm_channel INT UNSIGNED UNIQUE NOT NULL, | ||||
|  | ||||
|     language VARCHAR(2) DEFAULT 'EN' NOT NULL, | ||||
|     timezone VARCHAR(32) DEFAULT 'UTC' NOT NULL, | ||||
|     meridian_time BOOLEAN DEFAULT 0 NOT NULL, | ||||
|  | ||||
|     allowed_dm BOOLEAN DEFAULT 1 NOT NULL, | ||||
|  | ||||
|     patreon BOOLEAN NOT NULL DEFAULT 0, | ||||
|  | ||||
|     PRIMARY KEY (id), | ||||
|     FOREIGN KEY (dm_channel) REFERENCES channels(id) ON DELETE RESTRICT | ||||
| ); | ||||
|  | ||||
| CREATE TABLE roles ( | ||||
|     id INT UNSIGNED UNIQUE NOT NULL AUTO_INCREMENT, | ||||
|     role BIGINT UNSIGNED UNIQUE NOT NULL, | ||||
|  | ||||
|     name VARCHAR(100), | ||||
|  | ||||
|     guild_id INT UNSIGNED NOT NULL, | ||||
|  | ||||
|     PRIMARY KEY (id), | ||||
|     FOREIGN KEY (guild_id) REFERENCES guilds(id) ON DELETE CASCADE | ||||
| ); | ||||
|  | ||||
| CREATE TABLE embeds ( | ||||
|     id INT UNSIGNED AUTO_INCREMENT UNIQUE NOT NULL, | ||||
|  | ||||
|     title VARCHAR(256) NOT NULL DEFAULT '', | ||||
|     description VARCHAR(2048) NOT NULL DEFAULT '', | ||||
|  | ||||
|     image_url VARCHAR(512), | ||||
|     thumbnail_url VARCHAR(512), | ||||
|  | ||||
|     footer VARCHAR(2048) NOT NULL DEFAULT '', | ||||
|     footer_icon VARCHAR(512), | ||||
|  | ||||
|     color MEDIUMINT UNSIGNED NOT NULL DEFAULT 0x0, | ||||
|  | ||||
|     PRIMARY KEY (id) | ||||
| ); | ||||
|  | ||||
| CREATE TABLE embed_fields ( | ||||
|     id INT UNSIGNED AUTO_INCREMENT UNIQUE NOT NULL, | ||||
|  | ||||
|     title VARCHAR(256) NOT NULL DEFAULT '', | ||||
|     value VARCHAR(1024) NOT NULL DEFAULT '', | ||||
|     inline BOOL NOT NULL DEFAULT 0, | ||||
|     embed_id INT UNSIGNED NOT NULL, | ||||
|  | ||||
|     PRIMARY KEY (id), | ||||
|     FOREIGN KEY (embed_id) REFERENCES embeds(id) ON DELETE CASCADE | ||||
| ); | ||||
|  | ||||
| CREATE TABLE messages ( | ||||
|     id INT UNSIGNED AUTO_INCREMENT UNIQUE NOT NULL, | ||||
|  | ||||
|     content VARCHAR(2048) NOT NULL DEFAULT '', | ||||
|     tts BOOL NOT NULL DEFAULT 0, | ||||
|     embed_id INT UNSIGNED, | ||||
|  | ||||
|     attachment MEDIUMBLOB, | ||||
|     attachment_name VARCHAR(260), | ||||
|  | ||||
|     PRIMARY KEY (id), | ||||
|     FOREIGN KEY (embed_id) REFERENCES embeds(id) ON DELETE SET NULL | ||||
| ); | ||||
|  | ||||
| CREATE TABLE reminders ( | ||||
|     id INT UNSIGNED AUTO_INCREMENT UNIQUE NOT NULL, | ||||
|     uid VARCHAR(64) UNIQUE NOT NULL, | ||||
|  | ||||
|     name VARCHAR(24) NOT NULL DEFAULT 'Reminder', | ||||
|  | ||||
|     message_id INT UNSIGNED NOT NULL, | ||||
|     channel_id INT UNSIGNED NOT NULL, | ||||
|  | ||||
|     `time` INT UNSIGNED DEFAULT 0 NOT NULL, | ||||
|     `interval` INT UNSIGNED DEFAULT NULL, | ||||
|     expires TIMESTAMP DEFAULT NULL, | ||||
|  | ||||
|     enabled BOOLEAN DEFAULT 1 NOT NULL, | ||||
|  | ||||
|     avatar VARCHAR(512), | ||||
|     username VARCHAR(32), | ||||
|  | ||||
|     method ENUM('remind', 'natural', 'dashboard', 'todo', 'countdown'), | ||||
|     set_at TIMESTAMP DEFAULT NOW(), | ||||
|     set_by INT UNSIGNED, | ||||
|  | ||||
|     PRIMARY KEY (id), | ||||
|     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 | ||||
| FOR EACH ROW | ||||
|     DELETE FROM messages WHERE id = OLD.message_id; | ||||
|  | ||||
| CREATE TRIGGER embed_cleanup AFTER DELETE ON messages | ||||
| FOR EACH ROW | ||||
|     DELETE FROM embeds WHERE id = OLD.embed_id; | ||||
|  | ||||
| CREATE TABLE todos ( | ||||
|     id INT UNSIGNED AUTO_INCREMENT UNIQUE NOT NULL, | ||||
|     user_id INT UNSIGNED, | ||||
|     guild_id INT UNSIGNED, | ||||
|     channel_id INT UNSIGNED, | ||||
|     value VARCHAR(2000) NOT NULL, | ||||
|  | ||||
|     PRIMARY KEY (id), | ||||
|     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 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 roles(id) ON DELETE CASCADE, | ||||
|     UNIQUE KEY (`role_id`, `command`) | ||||
| ); | ||||
|  | ||||
| CREATE TABLE timers ( | ||||
|     id INT UNSIGNED AUTO_INCREMENT UNIQUE NOT NULL, | ||||
|     start_time TIMESTAMP NOT NULL DEFAULT NOW(), | ||||
|     name VARCHAR(32) NOT NULL, | ||||
|     owner BIGINT UNSIGNED NOT NULL, | ||||
|  | ||||
|     PRIMARY KEY (id) | ||||
| ); | ||||
|  | ||||
| CREATE TABLE events ( | ||||
|     id INT UNSIGNED AUTO_INCREMENT UNIQUE NOT NULL, | ||||
|     `time` TIMESTAMP NOT NULL DEFAULT NOW(), | ||||
|  | ||||
|     event_name ENUM('edit', 'enable', 'disable', 'delete') NOT NULL, | ||||
|     bulk_count INT UNSIGNED, | ||||
|  | ||||
|     guild_id INT UNSIGNED NOT NULL, | ||||
|     user_id INT UNSIGNED, | ||||
|     reminder_id INT UNSIGNED, | ||||
|  | ||||
|     PRIMARY KEY (id), | ||||
|     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 command_aliases ( | ||||
|     id INT UNSIGNED AUTO_INCREMENT UNIQUE NOT NULL, | ||||
|  | ||||
|     guild_id INT UNSIGNED NOT NULL, | ||||
|     name VARCHAR(12) NOT NULL, | ||||
|  | ||||
|     command VARCHAR(2048) NOT NULL, | ||||
|  | ||||
|     PRIMARY KEY (id), | ||||
|     FOREIGN KEY (guild_id) REFERENCES guilds(id) ON DELETE CASCADE, | ||||
|     UNIQUE KEY (`guild_id`, `name`) | ||||
| ); | ||||
|  | ||||
| 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 guilds(id) ON DELETE CASCADE, | ||||
|     FOREIGN KEY (user) REFERENCES users(id) ON DELETE CASCADE, | ||||
|     UNIQUE KEY (guild, user) | ||||
| ); | ||||
|  | ||||
| CREATE EVENT event_cleanup | ||||
| ON SCHEDULE AT CURRENT_TIMESTAMP + INTERVAL 1 DAY | ||||
| ON COMPLETION PRESERVE | ||||
| DO DELETE FROM events WHERE `time` < DATE_SUB(NOW(), INTERVAL 5 DAY); | ||||
							
								
								
									
										163
									
								
								migrations/20210623000000_reminder_message_embed.sql
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										163
									
								
								migrations/20210623000000_reminder_message_embed.sql
									
									
									
									
									
										Normal file
									
								
							
										
											
												File diff suppressed because one or more lines are too long
											
										
									
								
							
							
								
								
									
										11
									
								
								migrations/20210922000000_macro.sql
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										11
									
								
								migrations/20210922000000_macro.sql
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,11 @@ | ||||
| CREATE TABLE macro ( | ||||
|     id INT UNSIGNED AUTO_INCREMENT, | ||||
|     guild_id INT UNSIGNED NOT NULL, | ||||
|  | ||||
|     name VARCHAR(100) NOT NULL, | ||||
|     description VARCHAR(100), | ||||
|     commands TEXT NOT NULL, | ||||
|  | ||||
|     FOREIGN KEY (guild_id) REFERENCES guilds(id) ON DELETE CASCADE, | ||||
|     PRIMARY KEY (id) | ||||
| ); | ||||
| @@ -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; | ||||
							
								
								
									
										7
									
								
								models/Cargo.lock
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										7
									
								
								models/Cargo.lock
									
									
									
										generated
									
									
									
								
							| @@ -1,7 +0,0 @@ | ||||
| # This file is automatically @generated by Cargo. | ||||
| # It is not intended for manual editing. | ||||
| version = 3 | ||||
|  | ||||
| [[package]] | ||||
| name = "models" | ||||
| version = "0.1.0" | ||||
| @@ -1,8 +0,0 @@ | ||||
| [package] | ||||
| name = "models" | ||||
| version = "0.1.0" | ||||
| edition = "2021" | ||||
|  | ||||
| # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html | ||||
|  | ||||
| [dependencies] | ||||
| @@ -1,8 +0,0 @@ | ||||
| [package] | ||||
| name = "entity" | ||||
| version = "0.1.0" | ||||
| edition = "2021" | ||||
|  | ||||
| [dependencies] | ||||
| chrono-tz = "^0.6" | ||||
| sea-orm = { version = "^0.8", features = ["sqlx-postgres", "runtime-tokio-native-tls", "macros"] } | ||||
| @@ -1,60 +0,0 @@ | ||||
| //! SeaORM Entity. Generated by sea-orm-codegen 0.8.0 | ||||
|  | ||||
| use sea_orm::entity::prelude::*; | ||||
|  | ||||
| #[derive(Clone, Debug, PartialEq, DeriveEntityModel)] | ||||
| #[sea_orm(table_name = "channel")] | ||||
| pub struct Model { | ||||
|     #[sea_orm(primary_key, auto_increment = false)] | ||||
|     pub id: i64, | ||||
|     pub guild_id: Option<i64>, | ||||
|     pub nudge: i32, | ||||
|     pub webhook_id: Option<i64>, | ||||
|     pub webhook_token: Option<String>, | ||||
|     pub paused: bool, | ||||
|     pub paused_until: Option<DateTimeUtc>, | ||||
| } | ||||
|  | ||||
| #[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] | ||||
| pub enum Relation { | ||||
|     #[sea_orm( | ||||
|         belongs_to = "super::guild::Entity", | ||||
|         from = "Column::GuildId", | ||||
|         to = "super::guild::Column::Id", | ||||
|         on_update = "NoAction", | ||||
|         on_delete = "Cascade" | ||||
|     )] | ||||
|     Guild, | ||||
|     #[sea_orm(has_many = "super::user::Entity")] | ||||
|     User, | ||||
|     #[sea_orm(has_many = "super::reminder::Entity")] | ||||
|     Reminder, | ||||
|     #[sea_orm(has_many = "super::todo::Entity")] | ||||
|     Todo, | ||||
| } | ||||
|  | ||||
| impl Related<super::guild::Entity> for Entity { | ||||
|     fn to() -> RelationDef { | ||||
|         Relation::Guild.def() | ||||
|     } | ||||
| } | ||||
|  | ||||
| impl Related<super::user::Entity> for Entity { | ||||
|     fn to() -> RelationDef { | ||||
|         Relation::User.def() | ||||
|     } | ||||
| } | ||||
|  | ||||
| impl Related<super::reminder::Entity> for Entity { | ||||
|     fn to() -> RelationDef { | ||||
|         Relation::Reminder.def() | ||||
|     } | ||||
| } | ||||
|  | ||||
| impl Related<super::todo::Entity> for Entity { | ||||
|     fn to() -> RelationDef { | ||||
|         Relation::Todo.def() | ||||
|     } | ||||
| } | ||||
|  | ||||
| impl ActiveModelBehavior for ActiveModel {} | ||||
| @@ -1,34 +0,0 @@ | ||||
| //! SeaORM Entity. Generated by sea-orm-codegen 0.8.0 | ||||
|  | ||||
| use sea_orm::entity::prelude::*; | ||||
|  | ||||
| #[derive(Clone, Debug, PartialEq, DeriveEntityModel)] | ||||
| #[sea_orm(table_name = "command_macro")] | ||||
| pub struct Model { | ||||
|     #[sea_orm(primary_key)] | ||||
|     pub id: i32, | ||||
|     pub guild_id: i64, | ||||
|     pub name: String, | ||||
|     pub description: Option<String>, | ||||
|     pub commands: Option<Json>, | ||||
| } | ||||
|  | ||||
| #[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] | ||||
| pub enum Relation { | ||||
|     #[sea_orm( | ||||
|         belongs_to = "super::guild::Entity", | ||||
|         from = "Column::GuildId", | ||||
|         to = "super::guild::Column::Id", | ||||
|         on_update = "NoAction", | ||||
|         on_delete = "Cascade" | ||||
|     )] | ||||
|     Guild, | ||||
| } | ||||
|  | ||||
| impl Related<super::guild::Entity> for Entity { | ||||
|     fn to() -> RelationDef { | ||||
|         Relation::Guild.def() | ||||
|     } | ||||
| } | ||||
|  | ||||
| impl ActiveModelBehavior for ActiveModel {} | ||||
| @@ -1,48 +0,0 @@ | ||||
| //! SeaORM Entity. Generated by sea-orm-codegen 0.8.0 | ||||
|  | ||||
| use sea_orm::entity::prelude::*; | ||||
|  | ||||
| #[derive(Clone, Debug, PartialEq, DeriveEntityModel)] | ||||
| #[sea_orm(table_name = "guild")] | ||||
| pub struct Model { | ||||
|     #[sea_orm(primary_key, auto_increment = false)] | ||||
|     pub id: i64, | ||||
| } | ||||
|  | ||||
| #[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] | ||||
| pub enum Relation { | ||||
|     #[sea_orm(has_many = "super::reminder_template::Entity")] | ||||
|     ReminderTemplate, | ||||
|     #[sea_orm(has_many = "super::channel::Entity")] | ||||
|     Channel, | ||||
|     #[sea_orm(has_many = "super::todo::Entity")] | ||||
|     Todo, | ||||
|     #[sea_orm(has_many = "super::command_macro::Entity")] | ||||
|     CommandMacro, | ||||
| } | ||||
|  | ||||
| impl Related<super::reminder_template::Entity> for Entity { | ||||
|     fn to() -> RelationDef { | ||||
|         Relation::ReminderTemplate.def() | ||||
|     } | ||||
| } | ||||
|  | ||||
| impl Related<super::channel::Entity> for Entity { | ||||
|     fn to() -> RelationDef { | ||||
|         Relation::Channel.def() | ||||
|     } | ||||
| } | ||||
|  | ||||
| impl Related<super::todo::Entity> for Entity { | ||||
|     fn to() -> RelationDef { | ||||
|         Relation::Todo.def() | ||||
|     } | ||||
| } | ||||
|  | ||||
| impl Related<super::command_macro::Entity> for Entity { | ||||
|     fn to() -> RelationDef { | ||||
|         Relation::CommandMacro.def() | ||||
|     } | ||||
| } | ||||
|  | ||||
| impl ActiveModelBehavior for ActiveModel {} | ||||
| @@ -1 +0,0 @@ | ||||
|  | ||||
| @@ -1,14 +0,0 @@ | ||||
| //! SeaORM Entity. Generated by sea-orm-codegen 0.8.0 | ||||
|  | ||||
| pub mod prelude; | ||||
|  | ||||
| pub mod channel; | ||||
| pub mod command_macro; | ||||
| pub mod guild; | ||||
| pub mod reminder; | ||||
| pub mod reminder_template; | ||||
| pub mod sea_orm_active_enums; | ||||
| pub mod seaql_migrations; | ||||
| pub mod timer; | ||||
| pub mod todo; | ||||
| pub mod user; | ||||
| @@ -1,8 +0,0 @@ | ||||
| //! SeaORM Entity. Generated by sea-orm-codegen 0.8.0 | ||||
|  | ||||
| pub use super::{ | ||||
|     channel::Entity as Channel, command_macro::Entity as CommandMacro, guild::Entity as Guild, | ||||
|     reminder::Entity as Reminder, reminder_template::Entity as ReminderTemplate, | ||||
|     seaql_migrations::Entity as SeaqlMigrations, timer::Entity as Timer, todo::Entity as Todo, | ||||
|     user::Entity as User, | ||||
| }; | ||||
| @@ -1,73 +0,0 @@ | ||||
| //! SeaORM Entity. Generated by sea-orm-codegen 0.8.0 | ||||
|  | ||||
| use sea_orm::entity::prelude::*; | ||||
|  | ||||
| use super::sea_orm_active_enums::Timezone; | ||||
|  | ||||
| #[derive(Clone, Debug, PartialEq, DeriveEntityModel)] | ||||
| #[sea_orm(table_name = "reminder")] | ||||
| pub struct Model { | ||||
|     #[sea_orm(primary_key)] | ||||
|     pub id: i32, | ||||
|     pub uid: String, | ||||
|     pub name: String, | ||||
|     pub channel_id: i64, | ||||
|     pub utc_time: DateTimeUtc, | ||||
|     pub timezone: Timezone, | ||||
|     pub interval_seconds: Option<i32>, | ||||
|     pub interval_months: Option<i32>, | ||||
|     pub enabled: bool, | ||||
|     pub expires: Option<DateTimeUtc>, | ||||
|     pub username: Option<String>, | ||||
|     pub avatar: Option<String>, | ||||
|     pub content: Option<String>, | ||||
|     pub tts: bool, | ||||
|     pub attachment: Option<Vec<u8>>, | ||||
|     pub attachment_name: Option<String>, | ||||
|     pub embed_title: Option<String>, | ||||
|     pub embed_description: Option<String>, | ||||
|     pub embed_image_url: Option<String>, | ||||
|     pub embed_thumbnail_url: Option<String>, | ||||
|     pub embed_footer: Option<String>, | ||||
|     pub embed_footer_url: Option<String>, | ||||
|     pub embed_author: Option<String>, | ||||
|     pub embed_author_url: Option<String>, | ||||
|     pub embed_color: Option<i32>, | ||||
|     pub embed_fields: Option<Json>, | ||||
|     pub set_at: DateTimeUtc, | ||||
|     pub set_by: i64, | ||||
| } | ||||
|  | ||||
| #[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] | ||||
| pub enum Relation { | ||||
|     #[sea_orm( | ||||
|         belongs_to = "super::channel::Entity", | ||||
|         from = "Column::ChannelId", | ||||
|         to = "super::channel::Column::Id", | ||||
|         on_update = "NoAction", | ||||
|         on_delete = "Cascade" | ||||
|     )] | ||||
|     Channel, | ||||
|     #[sea_orm( | ||||
|         belongs_to = "super::user::Entity", | ||||
|         from = "Column::SetBy", | ||||
|         to = "super::user::Column::Id", | ||||
|         on_update = "NoAction", | ||||
|         on_delete = "Cascade" | ||||
|     )] | ||||
|     User, | ||||
| } | ||||
|  | ||||
| impl Related<super::channel::Entity> for Entity { | ||||
|     fn to() -> RelationDef { | ||||
|         Relation::Channel.def() | ||||
|     } | ||||
| } | ||||
|  | ||||
| impl Related<super::user::Entity> for Entity { | ||||
|     fn to() -> RelationDef { | ||||
|         Relation::User.def() | ||||
|     } | ||||
| } | ||||
|  | ||||
| impl ActiveModelBehavior for ActiveModel {} | ||||
| @@ -1,48 +0,0 @@ | ||||
| //! SeaORM Entity. Generated by sea-orm-codegen 0.8.0 | ||||
|  | ||||
| use sea_orm::entity::prelude::*; | ||||
|  | ||||
| #[derive(Clone, Debug, PartialEq, DeriveEntityModel)] | ||||
| #[sea_orm(table_name = "reminder_template")] | ||||
| pub struct Model { | ||||
|     #[sea_orm(primary_key)] | ||||
|     pub id: i32, | ||||
|     pub guild_id: i64, | ||||
|     pub name: String, | ||||
|     pub username: Option<String>, | ||||
|     pub avatar: Option<String>, | ||||
|     pub content: Option<String>, | ||||
|     pub tts: bool, | ||||
|     pub attachment: Option<Vec<u8>>, | ||||
|     pub attachment_name: Option<String>, | ||||
|     pub embed_title: Option<String>, | ||||
|     pub embed_description: Option<String>, | ||||
|     pub embed_image_url: Option<String>, | ||||
|     pub embed_thumbnail_url: Option<String>, | ||||
|     pub embed_footer: Option<String>, | ||||
|     pub embed_footer_url: Option<String>, | ||||
|     pub embed_author: Option<String>, | ||||
|     pub embed_author_url: Option<String>, | ||||
|     pub embed_color: Option<i32>, | ||||
|     pub embed_fields: Option<Json>, | ||||
| } | ||||
|  | ||||
| #[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] | ||||
| pub enum Relation { | ||||
|     #[sea_orm( | ||||
|         belongs_to = "super::guild::Entity", | ||||
|         from = "Column::GuildId", | ||||
|         to = "super::guild::Column::Id", | ||||
|         on_update = "NoAction", | ||||
|         on_delete = "Cascade" | ||||
|     )] | ||||
|     Guild, | ||||
| } | ||||
|  | ||||
| impl Related<super::guild::Entity> for Entity { | ||||
|     fn to() -> RelationDef { | ||||
|         Relation::Guild.def() | ||||
|     } | ||||
| } | ||||
|  | ||||
| impl ActiveModelBehavior for ActiveModel {} | ||||
										
											
												File diff suppressed because it is too large
												Load Diff
											
										
									
								
							| @@ -1,22 +0,0 @@ | ||||
| //! SeaORM Entity. Generated by sea-orm-codegen 0.8.0 | ||||
|  | ||||
| use sea_orm::entity::prelude::*; | ||||
|  | ||||
| #[derive(Clone, Debug, PartialEq, DeriveEntityModel)] | ||||
| #[sea_orm(table_name = "seaql_migrations")] | ||||
| pub struct Model { | ||||
|     #[sea_orm(primary_key, auto_increment = false)] | ||||
|     pub version: String, | ||||
|     pub applied_at: i64, | ||||
| } | ||||
|  | ||||
| #[derive(Copy, Clone, Debug, EnumIter)] | ||||
| pub enum Relation {} | ||||
|  | ||||
| impl RelationTrait for Relation { | ||||
|     fn def(&self) -> RelationDef { | ||||
|         panic!("No RelationDef") | ||||
|     } | ||||
| } | ||||
|  | ||||
| impl ActiveModelBehavior for ActiveModel {} | ||||
| @@ -1,36 +0,0 @@ | ||||
| //! SeaORM Entity. Generated by sea-orm-codegen 0.8.0 | ||||
|  | ||||
| use sea_orm::entity::prelude::*; | ||||
|  | ||||
| #[derive(Clone, Debug, PartialEq, DeriveEntityModel)] | ||||
| #[sea_orm(table_name = "timer")] | ||||
| pub struct Model { | ||||
|     #[sea_orm(primary_key)] | ||||
|     pub id: i32, | ||||
|     pub start_time: DateTimeUtc, | ||||
|     pub name: String, | ||||
|     pub user_id: Option<i64>, | ||||
|     pub guild_id: Option<i64>, | ||||
| } | ||||
|  | ||||
| #[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] | ||||
| pub enum Relation { | ||||
|     #[sea_orm( | ||||
|         belongs_to = "super::guild::Entity", | ||||
|         from = "Column::GuildId", | ||||
|         to = "super::guild::Column::Id", | ||||
|         on_update = "NoAction", | ||||
|         on_delete = "Cascade" | ||||
|     )] | ||||
|     Guild2, | ||||
|     #[sea_orm( | ||||
|         belongs_to = "super::guild::Entity", | ||||
|         from = "Column::UserId", | ||||
|         to = "super::guild::Column::Id", | ||||
|         on_update = "NoAction", | ||||
|         on_delete = "Cascade" | ||||
|     )] | ||||
|     Guild1, | ||||
| } | ||||
|  | ||||
| impl ActiveModelBehavior for ActiveModel {} | ||||
| @@ -1,62 +0,0 @@ | ||||
| //! SeaORM Entity. Generated by sea-orm-codegen 0.8.0 | ||||
|  | ||||
| use sea_orm::entity::prelude::*; | ||||
|  | ||||
| #[derive(Clone, Debug, PartialEq, DeriveEntityModel)] | ||||
| #[sea_orm(table_name = "todo")] | ||||
| pub struct Model { | ||||
|     #[sea_orm(primary_key)] | ||||
|     pub id: i32, | ||||
|     pub user_id: Option<i64>, | ||||
|     pub guild_id: Option<i64>, | ||||
|     pub channel_id: Option<i64>, | ||||
|     pub value: String, | ||||
| } | ||||
|  | ||||
| #[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] | ||||
| pub enum Relation { | ||||
|     #[sea_orm( | ||||
|         belongs_to = "super::channel::Entity", | ||||
|         from = "Column::ChannelId", | ||||
|         to = "super::channel::Column::Id", | ||||
|         on_update = "NoAction", | ||||
|         on_delete = "Cascade" | ||||
|     )] | ||||
|     Channel, | ||||
|     #[sea_orm( | ||||
|         belongs_to = "super::guild::Entity", | ||||
|         from = "Column::GuildId", | ||||
|         to = "super::guild::Column::Id", | ||||
|         on_update = "NoAction", | ||||
|         on_delete = "Cascade" | ||||
|     )] | ||||
|     Guild, | ||||
|     #[sea_orm( | ||||
|         belongs_to = "super::user::Entity", | ||||
|         from = "Column::UserId", | ||||
|         to = "super::user::Column::Id", | ||||
|         on_update = "NoAction", | ||||
|         on_delete = "Cascade" | ||||
|     )] | ||||
|     User, | ||||
| } | ||||
|  | ||||
| impl Related<super::channel::Entity> for Entity { | ||||
|     fn to() -> RelationDef { | ||||
|         Relation::Channel.def() | ||||
|     } | ||||
| } | ||||
|  | ||||
| impl Related<super::guild::Entity> for Entity { | ||||
|     fn to() -> RelationDef { | ||||
|         Relation::Guild.def() | ||||
|     } | ||||
| } | ||||
|  | ||||
| impl Related<super::user::Entity> for Entity { | ||||
|     fn to() -> RelationDef { | ||||
|         Relation::User.def() | ||||
|     } | ||||
| } | ||||
|  | ||||
| impl ActiveModelBehavior for ActiveModel {} | ||||
| @@ -1,50 +0,0 @@ | ||||
| //! SeaORM Entity. Generated by sea-orm-codegen 0.8.0 | ||||
|  | ||||
| use sea_orm::entity::prelude::*; | ||||
|  | ||||
| use super::sea_orm_active_enums::Timezone; | ||||
|  | ||||
| #[derive(Clone, Debug, PartialEq, DeriveEntityModel)] | ||||
| #[sea_orm(table_name = "user")] | ||||
| pub struct Model { | ||||
|     #[sea_orm(primary_key, auto_increment = false)] | ||||
|     pub id: i64, | ||||
|     pub dm_channel: i64, | ||||
|     pub timezone: Timezone, | ||||
| } | ||||
|  | ||||
| #[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] | ||||
| pub enum Relation { | ||||
|     #[sea_orm( | ||||
|         belongs_to = "super::channel::Entity", | ||||
|         from = "Column::DmChannel", | ||||
|         to = "super::channel::Column::Id", | ||||
|         on_update = "NoAction", | ||||
|         on_delete = "Cascade" | ||||
|     )] | ||||
|     Channel, | ||||
|     #[sea_orm(has_many = "super::reminder::Entity")] | ||||
|     Reminder, | ||||
|     #[sea_orm(has_many = "super::todo::Entity")] | ||||
|     Todo, | ||||
| } | ||||
|  | ||||
| impl Related<super::channel::Entity> for Entity { | ||||
|     fn to() -> RelationDef { | ||||
|         Relation::Channel.def() | ||||
|     } | ||||
| } | ||||
|  | ||||
| impl Related<super::reminder::Entity> for Entity { | ||||
|     fn to() -> RelationDef { | ||||
|         Relation::Reminder.def() | ||||
|     } | ||||
| } | ||||
|  | ||||
| impl Related<super::todo::Entity> for Entity { | ||||
|     fn to() -> RelationDef { | ||||
|         Relation::Todo.def() | ||||
|     } | ||||
| } | ||||
|  | ||||
| impl ActiveModelBehavior for ActiveModel {} | ||||
							
								
								
									
										2400
									
								
								models/migration/Cargo.lock
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										2400
									
								
								models/migration/Cargo.lock
									
									
									
										generated
									
									
									
								
							
										
											
												File diff suppressed because it is too large
												Load Diff
											
										
									
								
							| @@ -1,16 +0,0 @@ | ||||
| [package] | ||||
| name = "migration" | ||||
| version = "0.1.0" | ||||
| edition = "2021" | ||||
| publish = false | ||||
|  | ||||
| [lib] | ||||
| name = "migration" | ||||
| path = "src/lib.rs" | ||||
|  | ||||
| [dependencies] | ||||
| entity = { path = "../entity" } | ||||
| chrono-tz = "^0.6" | ||||
|  | ||||
| [dependencies.sea-orm-migration] | ||||
| version = "^0.8.0" | ||||
| @@ -1,37 +0,0 @@ | ||||
| # Running Migrator CLI | ||||
|  | ||||
| - Apply all pending migrations | ||||
|     ```sh | ||||
|     cargo run | ||||
|     ``` | ||||
|     ```sh | ||||
|     cargo run -- up | ||||
|     ``` | ||||
| - Apply first 10 pending migrations | ||||
|     ```sh | ||||
|     cargo run -- up -n 10 | ||||
|     ``` | ||||
| - Rollback last applied migrations | ||||
|     ```sh | ||||
|     cargo run -- down | ||||
|     ``` | ||||
| - Rollback last 10 applied migrations | ||||
|     ```sh | ||||
|     cargo run -- down -n 10 | ||||
|     ``` | ||||
| - Drop all tables from the database, then reapply all migrations | ||||
|     ```sh | ||||
|     cargo run -- fresh | ||||
|     ``` | ||||
| - Rollback all applied migrations, then reapply all migrations | ||||
|     ```sh | ||||
|     cargo run -- refresh | ||||
|     ``` | ||||
| - Rollback all applied migrations | ||||
|     ```sh | ||||
|     cargo run -- reset | ||||
|     ``` | ||||
| - Check the status of all migrations | ||||
|     ```sh | ||||
|     cargo run -- status | ||||
|     ``` | ||||
| @@ -1,12 +0,0 @@ | ||||
| pub use sea_orm_migration::prelude::*; | ||||
|  | ||||
| mod m20220101_000001_create_table; | ||||
|  | ||||
| pub struct Migrator; | ||||
|  | ||||
| #[async_trait::async_trait] | ||||
| impl MigratorTrait for Migrator { | ||||
|     fn migrations() -> Vec<Box<dyn MigrationTrait>> { | ||||
|         vec![Box::new(m20220101_000001_create_table::Migration)] | ||||
|     } | ||||
| } | ||||
| @@ -1,553 +0,0 @@ | ||||
| use chrono_tz::{Tz, TZ_VARIANTS}; | ||||
| use sea_orm_migration::prelude::*; | ||||
|  | ||||
| use crate::extension::postgres::Type; | ||||
|  | ||||
| pub struct Migration; | ||||
|  | ||||
| impl MigrationName for Migration { | ||||
|     fn name(&self) -> &str { | ||||
|         "m20220101_000001_create_table" | ||||
|     } | ||||
| } | ||||
|  | ||||
| #[derive(Iden)] | ||||
| pub enum Guild { | ||||
|     Table, | ||||
|     Id, | ||||
| } | ||||
|  | ||||
| #[derive(Iden)] | ||||
| pub enum Channel { | ||||
|     Table, | ||||
|     Id, | ||||
|     GuildId, | ||||
|     Nudge, | ||||
|     WebhookId, | ||||
|     WebhookToken, | ||||
|     Paused, | ||||
|     PausedUntil, | ||||
| } | ||||
|  | ||||
| #[derive(Iden)] | ||||
| pub enum User { | ||||
|     Table, | ||||
|     Id, | ||||
|     DmChannel, | ||||
|     Timezone, | ||||
| } | ||||
|  | ||||
| #[derive(Iden)] | ||||
| pub enum Reminder { | ||||
|     Table, | ||||
|     Id, | ||||
|     Uid, | ||||
|     Name, | ||||
|     ChannelId, | ||||
|     UtcTime, | ||||
|     Timezone, | ||||
|     IntervalSeconds, | ||||
|     IntervalMonths, | ||||
|     Enabled, | ||||
|     Expires, | ||||
|     Username, | ||||
|     Avatar, | ||||
|     Content, | ||||
|     Tts, | ||||
|     Attachment, | ||||
|     AttachmentName, | ||||
|     EmbedTitle, | ||||
|     EmbedDescription, | ||||
|     EmbedImageUrl, | ||||
|     EmbedThumbnailUrl, | ||||
|     EmbedFooter, | ||||
|     EmbedFooterUrl, | ||||
|     EmbedAuthor, | ||||
|     EmbedAuthorUrl, | ||||
|     EmbedColor, | ||||
|     EmbedFields, | ||||
|     SetAt, | ||||
|     SetBy, | ||||
| } | ||||
|  | ||||
| #[derive(Iden)] | ||||
| pub enum ReminderTemplate { | ||||
|     Table, | ||||
|     Id, | ||||
|     GuildId, | ||||
|     Name, | ||||
|     Username, | ||||
|     Avatar, | ||||
|     Content, | ||||
|     Tts, | ||||
|     Attachment, | ||||
|     AttachmentName, | ||||
|     EmbedTitle, | ||||
|     EmbedDescription, | ||||
|     EmbedImageUrl, | ||||
|     EmbedThumbnailUrl, | ||||
|     EmbedFooter, | ||||
|     EmbedFooterUrl, | ||||
|     EmbedAuthor, | ||||
|     EmbedAuthorUrl, | ||||
|     EmbedColor, | ||||
|     EmbedFields, | ||||
| } | ||||
|  | ||||
| #[derive(Iden)] | ||||
| pub enum Timer { | ||||
|     Table, | ||||
|     Id, | ||||
|     StartTime, | ||||
|     Name, | ||||
|     UserId, | ||||
|     GuildId, | ||||
| } | ||||
|  | ||||
| #[derive(Iden)] | ||||
| pub enum Todo { | ||||
|     Table, | ||||
|     Id, | ||||
|     UserId, | ||||
|     GuildId, | ||||
|     ChannelId, | ||||
|     Value, | ||||
| } | ||||
|  | ||||
| #[derive(Iden)] | ||||
| pub enum CommandMacro { | ||||
|     Table, | ||||
|     Id, | ||||
|     GuildId, | ||||
|     Name, | ||||
|     Description, | ||||
|     Commands, | ||||
| } | ||||
|  | ||||
| pub enum Timezone { | ||||
|     Type, | ||||
|     Tz(Tz), | ||||
| } | ||||
|  | ||||
| impl Iden for Timezone { | ||||
|     fn unquoted(&self, s: &mut dyn Write) { | ||||
|         write!( | ||||
|             s, | ||||
|             "{}", | ||||
|             match self { | ||||
|                 Self::Type => "timezone".to_string(), | ||||
|                 Self::Tz(tz) => tz.to_string(), | ||||
|             } | ||||
|         ) | ||||
|         .unwrap(); | ||||
|     } | ||||
| } | ||||
|  | ||||
| #[async_trait::async_trait] | ||||
| impl MigrationTrait for Migration { | ||||
|     async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> { | ||||
|         manager | ||||
|             .create_type( | ||||
|                 Type::create() | ||||
|                     .as_enum(Timezone::Type) | ||||
|                     .values(TZ_VARIANTS.iter().map(|tz| Timezone::Tz(tz.to_owned()))) | ||||
|                     .to_owned(), | ||||
|             ) | ||||
|             .await?; | ||||
|  | ||||
|         manager | ||||
|             .create_table( | ||||
|                 Table::create() | ||||
|                     .table(Guild::Table) | ||||
|                     .if_not_exists() | ||||
|                     .col(ColumnDef::new(Guild::Id).big_integer().not_null().primary_key()) | ||||
|                     .to_owned(), | ||||
|             ) | ||||
|             .await?; | ||||
|  | ||||
|         manager | ||||
|             .create_table( | ||||
|                 Table::create() | ||||
|                     .table(Channel::Table) | ||||
|                     .if_not_exists() | ||||
|                     .col(ColumnDef::new(Channel::Id).big_integer().not_null().primary_key()) | ||||
|                     .col(ColumnDef::new(Channel::GuildId).big_integer()) | ||||
|                     .col(ColumnDef::new(Channel::Nudge).integer().not_null().default(0)) | ||||
|                     .col(ColumnDef::new(Channel::WebhookId).big_integer()) | ||||
|                     .col(ColumnDef::new(Channel::WebhookToken).string()) | ||||
|                     .col(ColumnDef::new(Channel::Paused).boolean().not_null().default(false)) | ||||
|                     .col(ColumnDef::new(Channel::PausedUntil).date_time()) | ||||
|                     .to_owned(), | ||||
|             ) | ||||
|             .await?; | ||||
|  | ||||
|         manager | ||||
|             .create_foreign_key( | ||||
|                 ForeignKey::create() | ||||
|                     .name("fk_channel_guild") | ||||
|                     .from(Channel::Table, Channel::GuildId) | ||||
|                     .to(Guild::Table, Guild::Id) | ||||
|                     .on_delete(ForeignKeyAction::Cascade) | ||||
|                     .to_owned(), | ||||
|             ) | ||||
|             .await?; | ||||
|  | ||||
|         manager | ||||
|             .create_table( | ||||
|                 Table::create() | ||||
|                     .table(User::Table) | ||||
|                     .if_not_exists() | ||||
|                     .col(ColumnDef::new(User::Id).big_integer().not_null().primary_key()) | ||||
|                     .col(ColumnDef::new(User::DmChannel).big_integer().not_null()) | ||||
|                     .col( | ||||
|                         ColumnDef::new(User::Timezone) | ||||
|                             .custom(Timezone::Type) | ||||
|                             .not_null() | ||||
|                             .default("UTC"), | ||||
|                     ) | ||||
|                     .to_owned(), | ||||
|             ) | ||||
|             .await?; | ||||
|  | ||||
|         manager | ||||
|             .create_foreign_key( | ||||
|                 ForeignKey::create() | ||||
|                     .name("fk_user_channel") | ||||
|                     .from(User::Table, User::DmChannel) | ||||
|                     .to(Channel::Table, Channel::Id) | ||||
|                     .on_delete(ForeignKeyAction::Cascade) | ||||
|                     .to_owned(), | ||||
|             ) | ||||
|             .await?; | ||||
|  | ||||
|         manager | ||||
|             .create_table( | ||||
|                 Table::create() | ||||
|                     .table(Reminder::Table) | ||||
|                     .if_not_exists() | ||||
|                     .col( | ||||
|                         ColumnDef::new(Reminder::Id) | ||||
|                             .integer() | ||||
|                             .not_null() | ||||
|                             .auto_increment() | ||||
|                             .primary_key(), | ||||
|                     ) | ||||
|                     .col(ColumnDef::new(Reminder::Uid).string().char_len(64).not_null()) | ||||
|                     .col( | ||||
|                         ColumnDef::new(Reminder::Name) | ||||
|                             .string() | ||||
|                             .char_len(24) | ||||
|                             .default("Reminder") | ||||
|                             .not_null(), | ||||
|                     ) | ||||
|                     .col(ColumnDef::new(Reminder::ChannelId).big_integer().not_null()) | ||||
|                     .col(ColumnDef::new(Reminder::UtcTime).date_time().not_null()) | ||||
|                     .col( | ||||
|                         ColumnDef::new(Reminder::Timezone) | ||||
|                             .custom(Timezone::Type) | ||||
|                             .not_null() | ||||
|                             .default("UTC"), | ||||
|                     ) | ||||
|                     .col(ColumnDef::new(Reminder::IntervalSeconds).integer()) | ||||
|                     .col(ColumnDef::new(Reminder::IntervalMonths).integer()) | ||||
|                     .col(ColumnDef::new(Reminder::Enabled).boolean().not_null().default(false)) | ||||
|                     .col(ColumnDef::new(Reminder::Expires).date_time()) | ||||
|                     .col(ColumnDef::new(Reminder::Username).string_len(32)) | ||||
|                     .col(ColumnDef::new(Reminder::Avatar).string_len(512)) | ||||
|                     .col(ColumnDef::new(Reminder::Content).string_len(2000)) | ||||
|                     .col(ColumnDef::new(Reminder::Tts).boolean().not_null().default(false)) | ||||
|                     .col(ColumnDef::new(Reminder::Attachment).binary_len(8 * 1024 * 1024)) | ||||
|                     .col(ColumnDef::new(Reminder::AttachmentName).string_len(260)) | ||||
|                     .col(ColumnDef::new(Reminder::EmbedTitle).string_len(256)) | ||||
|                     .col(ColumnDef::new(Reminder::EmbedDescription).string_len(4096)) | ||||
|                     .col(ColumnDef::new(Reminder::EmbedImageUrl).string_len(500)) | ||||
|                     .col(ColumnDef::new(Reminder::EmbedThumbnailUrl).string_len(500)) | ||||
|                     .col(ColumnDef::new(Reminder::EmbedFooter).string_len(2048)) | ||||
|                     .col(ColumnDef::new(Reminder::EmbedFooterUrl).string_len(500)) | ||||
|                     .col(ColumnDef::new(Reminder::EmbedAuthor).string_len(256)) | ||||
|                     .col(ColumnDef::new(Reminder::EmbedAuthorUrl).string_len(500)) | ||||
|                     .col(ColumnDef::new(Reminder::EmbedColor).integer()) | ||||
|                     .col(ColumnDef::new(Reminder::EmbedFields).json()) | ||||
|                     .col(ColumnDef::new(Reminder::SetAt).date_time().not_null().default("NOW()")) | ||||
|                     .col(ColumnDef::new(Reminder::SetBy).big_integer().not_null()) | ||||
|                     .to_owned(), | ||||
|             ) | ||||
|             .await?; | ||||
|  | ||||
|         manager | ||||
|             .create_foreign_key( | ||||
|                 ForeignKey::create() | ||||
|                     .name("fk_reminder_channel") | ||||
|                     .from(Reminder::Table, Reminder::ChannelId) | ||||
|                     .to(Channel::Table, Channel::Id) | ||||
|                     .on_delete(ForeignKeyAction::Cascade) | ||||
|                     .to_owned(), | ||||
|             ) | ||||
|             .await?; | ||||
|  | ||||
|         manager | ||||
|             .create_foreign_key( | ||||
|                 ForeignKey::create() | ||||
|                     .name("fk_reminder_user") | ||||
|                     .from(Reminder::Table, Reminder::SetBy) | ||||
|                     .to(User::Table, User::Id) | ||||
|                     .on_delete(ForeignKeyAction::Cascade) | ||||
|                     .to_owned(), | ||||
|             ) | ||||
|             .await?; | ||||
|  | ||||
|         manager | ||||
|             .create_table( | ||||
|                 Table::create() | ||||
|                     .table(ReminderTemplate::Table) | ||||
|                     .if_not_exists() | ||||
|                     .col( | ||||
|                         ColumnDef::new(ReminderTemplate::Id) | ||||
|                             .integer() | ||||
|                             .not_null() | ||||
|                             .auto_increment() | ||||
|                             .primary_key(), | ||||
|                     ) | ||||
|                     .col(ColumnDef::new(ReminderTemplate::GuildId).big_integer().not_null()) | ||||
|                     .col( | ||||
|                         ColumnDef::new(ReminderTemplate::Name) | ||||
|                             .string() | ||||
|                             .char_len(24) | ||||
|                             .default("Reminder") | ||||
|                             .not_null(), | ||||
|                     ) | ||||
|                     .col(ColumnDef::new(ReminderTemplate::Username).string_len(32)) | ||||
|                     .col(ColumnDef::new(ReminderTemplate::Avatar).string_len(512)) | ||||
|                     .col(ColumnDef::new(ReminderTemplate::Content).string_len(2000)) | ||||
|                     .col(ColumnDef::new(ReminderTemplate::Tts).boolean().not_null().default(false)) | ||||
|                     .col(ColumnDef::new(ReminderTemplate::Attachment).binary_len(8 * 1024 * 1024)) | ||||
|                     .col(ColumnDef::new(ReminderTemplate::AttachmentName).string_len(260)) | ||||
|                     .col(ColumnDef::new(ReminderTemplate::EmbedTitle).string_len(256)) | ||||
|                     .col(ColumnDef::new(ReminderTemplate::EmbedDescription).string_len(4096)) | ||||
|                     .col(ColumnDef::new(ReminderTemplate::EmbedImageUrl).string_len(500)) | ||||
|                     .col(ColumnDef::new(ReminderTemplate::EmbedThumbnailUrl).string_len(500)) | ||||
|                     .col(ColumnDef::new(ReminderTemplate::EmbedFooter).string_len(2048)) | ||||
|                     .col(ColumnDef::new(ReminderTemplate::EmbedFooterUrl).string_len(500)) | ||||
|                     .col(ColumnDef::new(ReminderTemplate::EmbedAuthor).string_len(256)) | ||||
|                     .col(ColumnDef::new(ReminderTemplate::EmbedAuthorUrl).string_len(500)) | ||||
|                     .col(ColumnDef::new(ReminderTemplate::EmbedColor).integer()) | ||||
|                     .col(ColumnDef::new(ReminderTemplate::EmbedFields).json()) | ||||
|                     .to_owned(), | ||||
|             ) | ||||
|             .await?; | ||||
|  | ||||
|         manager | ||||
|             .create_foreign_key( | ||||
|                 ForeignKey::create() | ||||
|                     .name("fk_reminder_template_guild") | ||||
|                     .from(ReminderTemplate::Table, ReminderTemplate::GuildId) | ||||
|                     .to(Guild::Table, Guild::Id) | ||||
|                     .on_delete(ForeignKeyAction::Cascade) | ||||
|                     .to_owned(), | ||||
|             ) | ||||
|             .await?; | ||||
|  | ||||
|         manager | ||||
|             .create_table( | ||||
|                 Table::create() | ||||
|                     .table(Timer::Table) | ||||
|                     .if_not_exists() | ||||
|                     .col( | ||||
|                         ColumnDef::new(Timer::Id) | ||||
|                             .integer() | ||||
|                             .not_null() | ||||
|                             .auto_increment() | ||||
|                             .primary_key(), | ||||
|                     ) | ||||
|                     .col(ColumnDef::new(Timer::StartTime).date_time().not_null().default("NOW()")) | ||||
|                     .col(ColumnDef::new(Timer::Name).string_len(32).not_null().default("Timer")) | ||||
|                     .col(ColumnDef::new(Timer::UserId).big_integer()) | ||||
|                     .col(ColumnDef::new(Timer::GuildId).big_integer()) | ||||
|                     .to_owned(), | ||||
|             ) | ||||
|             .await?; | ||||
|  | ||||
|         manager | ||||
|             .create_foreign_key( | ||||
|                 ForeignKey::create() | ||||
|                     .name("fk_timer_user") | ||||
|                     .from(Timer::Table, Timer::UserId) | ||||
|                     .to(Guild::Table, Guild::Id) | ||||
|                     .on_delete(ForeignKeyAction::Cascade) | ||||
|                     .to_owned(), | ||||
|             ) | ||||
|             .await?; | ||||
|  | ||||
|         manager | ||||
|             .create_foreign_key( | ||||
|                 ForeignKey::create() | ||||
|                     .name("fk_timer_guild") | ||||
|                     .from(Timer::Table, Timer::GuildId) | ||||
|                     .to(Guild::Table, Guild::Id) | ||||
|                     .on_delete(ForeignKeyAction::Cascade) | ||||
|                     .to_owned(), | ||||
|             ) | ||||
|             .await?; | ||||
|  | ||||
|         manager | ||||
|             .create_table( | ||||
|                 Table::create() | ||||
|                     .table(Todo::Table) | ||||
|                     .if_not_exists() | ||||
|                     .col( | ||||
|                         ColumnDef::new(Todo::Id) | ||||
|                             .integer() | ||||
|                             .not_null() | ||||
|                             .auto_increment() | ||||
|                             .primary_key(), | ||||
|                     ) | ||||
|                     .col(ColumnDef::new(Todo::UserId).big_integer()) | ||||
|                     .col(ColumnDef::new(Todo::GuildId).big_integer()) | ||||
|                     .col(ColumnDef::new(Todo::ChannelId).big_integer()) | ||||
|                     .col(ColumnDef::new(Todo::Value).string_len(2000).not_null()) | ||||
|                     .to_owned(), | ||||
|             ) | ||||
|             .await?; | ||||
|  | ||||
|         manager | ||||
|             .create_foreign_key( | ||||
|                 ForeignKey::create() | ||||
|                     .name("fk_todo_user") | ||||
|                     .from(Todo::Table, Todo::UserId) | ||||
|                     .to(User::Table, User::Id) | ||||
|                     .on_delete(ForeignKeyAction::Cascade) | ||||
|                     .to_owned(), | ||||
|             ) | ||||
|             .await?; | ||||
|  | ||||
|         manager | ||||
|             .create_foreign_key( | ||||
|                 ForeignKey::create() | ||||
|                     .name("fk_todo_guild") | ||||
|                     .from(Todo::Table, Todo::GuildId) | ||||
|                     .to(Guild::Table, Guild::Id) | ||||
|                     .on_delete(ForeignKeyAction::Cascade) | ||||
|                     .to_owned(), | ||||
|             ) | ||||
|             .await?; | ||||
|  | ||||
|         manager | ||||
|             .create_foreign_key( | ||||
|                 ForeignKey::create() | ||||
|                     .name("fk_todo_channel") | ||||
|                     .from(Todo::Table, Todo::ChannelId) | ||||
|                     .to(Channel::Table, Channel::Id) | ||||
|                     .on_delete(ForeignKeyAction::Cascade) | ||||
|                     .to_owned(), | ||||
|             ) | ||||
|             .await?; | ||||
|  | ||||
|         manager | ||||
|             .create_table( | ||||
|                 Table::create() | ||||
|                     .table(CommandMacro::Table) | ||||
|                     .if_not_exists() | ||||
|                     .col( | ||||
|                         ColumnDef::new(CommandMacro::Id) | ||||
|                             .integer() | ||||
|                             .not_null() | ||||
|                             .auto_increment() | ||||
|                             .primary_key(), | ||||
|                     ) | ||||
|                     .col(ColumnDef::new(CommandMacro::GuildId).big_integer().not_null()) | ||||
|                     .col(ColumnDef::new(CommandMacro::Name).string_len(100).not_null()) | ||||
|                     .col(ColumnDef::new(CommandMacro::Description).string_len(100)) | ||||
|                     .col(ColumnDef::new(CommandMacro::Commands).json()) | ||||
|                     .to_owned(), | ||||
|             ) | ||||
|             .await?; | ||||
|  | ||||
|         manager | ||||
|             .create_foreign_key( | ||||
|                 ForeignKey::create() | ||||
|                     .name("fk_command_macro_guild") | ||||
|                     .from(CommandMacro::Table, CommandMacro::GuildId) | ||||
|                     .to(Guild::Table, Guild::Id) | ||||
|                     .on_delete(ForeignKeyAction::Cascade) | ||||
|                     .to_owned(), | ||||
|             ) | ||||
|             .await?; | ||||
|  | ||||
|         Ok(()) | ||||
|     } | ||||
|  | ||||
|     async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> { | ||||
|         manager | ||||
|             .drop_foreign_key( | ||||
|                 ForeignKey::drop().table(Channel::Table).name("fk_channel_guild").to_owned(), | ||||
|             ) | ||||
|             .await?; | ||||
|         manager | ||||
|             .drop_foreign_key( | ||||
|                 ForeignKey::drop().table(User::Table).name("fk_user_channel").to_owned(), | ||||
|             ) | ||||
|             .await?; | ||||
|         manager | ||||
|             .drop_foreign_key( | ||||
|                 ForeignKey::drop().table(Reminder::Table).name("fk_reminder_channel").to_owned(), | ||||
|             ) | ||||
|             .await?; | ||||
|         manager | ||||
|             .drop_foreign_key( | ||||
|                 ForeignKey::drop().table(Reminder::Table).name("fk_reminder_user").to_owned(), | ||||
|             ) | ||||
|             .await?; | ||||
|         manager | ||||
|             .drop_foreign_key( | ||||
|                 ForeignKey::drop() | ||||
|                     .table(ReminderTemplate::Table) | ||||
|                     .name("fk_reminder_template_guild") | ||||
|                     .to_owned(), | ||||
|             ) | ||||
|             .await?; | ||||
|         manager | ||||
|             .drop_foreign_key( | ||||
|                 ForeignKey::drop().table(Timer::Table).name("fk_timer_user").to_owned(), | ||||
|             ) | ||||
|             .await?; | ||||
|         manager | ||||
|             .drop_foreign_key( | ||||
|                 ForeignKey::drop().table(Timer::Table).name("fk_timer_guild").to_owned(), | ||||
|             ) | ||||
|             .await?; | ||||
|         manager | ||||
|             .drop_foreign_key(ForeignKey::drop().table(Todo::Table).name("fk_todo_user").to_owned()) | ||||
|             .await?; | ||||
|         manager | ||||
|             .drop_foreign_key( | ||||
|                 ForeignKey::drop().table(Todo::Table).name("fk_todo_guild").to_owned(), | ||||
|             ) | ||||
|             .await?; | ||||
|         manager | ||||
|             .drop_foreign_key( | ||||
|                 ForeignKey::drop().table(Todo::Table).name("fk_todo_channel").to_owned(), | ||||
|             ) | ||||
|             .await?; | ||||
|         manager | ||||
|             .drop_foreign_key( | ||||
|                 ForeignKey::drop() | ||||
|                     .table(CommandMacro::Table) | ||||
|                     .name("fk_command_macro_guild") | ||||
|                     .to_owned(), | ||||
|             ) | ||||
|             .await?; | ||||
|  | ||||
|         manager.drop_table(Table::drop().table(Guild::Table).to_owned()).await?; | ||||
|         manager.drop_table(Table::drop().table(Channel::Table).to_owned()).await?; | ||||
|         manager.drop_table(Table::drop().table(User::Table).to_owned()).await?; | ||||
|         manager.drop_table(Table::drop().table(Reminder::Table).to_owned()).await?; | ||||
|         manager.drop_table(Table::drop().table(ReminderTemplate::Table).to_owned()).await?; | ||||
|         manager.drop_table(Table::drop().table(Timer::Table).to_owned()).await?; | ||||
|         manager.drop_table(Table::drop().table(Todo::Table).to_owned()).await?; | ||||
|         manager.drop_table(Table::drop().table(CommandMacro::Table).to_owned()).await?; | ||||
|  | ||||
|         manager.drop_type(Type::drop().name(Timezone::Type).to_owned()).await?; | ||||
|  | ||||
|         Ok(()) | ||||
|     } | ||||
| } | ||||
| @@ -1,6 +0,0 @@ | ||||
| use sea_orm_migration::prelude::*; | ||||
|  | ||||
| #[async_std::main] | ||||
| async fn main() { | ||||
|     cli::run_cli(migration::Migrator).await; | ||||
| } | ||||
| @@ -1 +0,0 @@ | ||||
|  | ||||
							
								
								
									
										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; | ||||
|         } | ||||
| } | ||||
							
								
								
									
										485
									
								
								package-lock.json
									
									
									
										generated
									
									
									
										Normal file
									
								
							
							
						
						
									
										485
									
								
								package-lock.json
									
									
									
										generated
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,485 @@ | ||||
| { | ||||
|   "name": "reminder-rs", | ||||
|   "lockfileVersion": 3, | ||||
|   "requires": true, | ||||
|   "packages": { | ||||
|     "": { | ||||
|       "devDependencies": { | ||||
|         "minify": "^10.3.0", | ||||
|         "prettier": "^3.0.1", | ||||
|         "tsc": "^2.0.4" | ||||
|       } | ||||
|     }, | ||||
|     "node_modules/@jridgewell/gen-mapping": { | ||||
|       "version": "0.3.3", | ||||
|       "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.3.tgz", | ||||
|       "integrity": "sha512-HLhSWOLRi875zjjMG/r+Nv0oCW8umGb0BgEhyX3dDX3egwZtB8PqLnjz3yedt8R5StBrzcg4aBpnh8UA9D1BoQ==", | ||||
|       "dev": true, | ||||
|       "dependencies": { | ||||
|         "@jridgewell/set-array": "^1.0.1", | ||||
|         "@jridgewell/sourcemap-codec": "^1.4.10", | ||||
|         "@jridgewell/trace-mapping": "^0.3.9" | ||||
|       }, | ||||
|       "engines": { | ||||
|         "node": ">=6.0.0" | ||||
|       } | ||||
|     }, | ||||
|     "node_modules/@jridgewell/resolve-uri": { | ||||
|       "version": "3.1.1", | ||||
|       "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.1.tgz", | ||||
|       "integrity": "sha512-dSYZh7HhCDtCKm4QakX0xFpsRDqjjtZf/kjI/v3T3Nwt5r8/qz/M19F9ySyOqU94SXBmeG9ttTul+YnR4LOxFA==", | ||||
|       "dev": true, | ||||
|       "engines": { | ||||
|         "node": ">=6.0.0" | ||||
|       } | ||||
|     }, | ||||
|     "node_modules/@jridgewell/set-array": { | ||||
|       "version": "1.1.2", | ||||
|       "resolved": "https://registry.npmjs.org/@jridgewell/set-array/-/set-array-1.1.2.tgz", | ||||
|       "integrity": "sha512-xnkseuNADM0gt2bs+BvhO0p78Mk762YnZdsuzFV018NoG1Sj1SCQvpSqa7XUaTam5vAGasABV9qXASMKnFMwMw==", | ||||
|       "dev": true, | ||||
|       "engines": { | ||||
|         "node": ">=6.0.0" | ||||
|       } | ||||
|     }, | ||||
|     "node_modules/@jridgewell/source-map": { | ||||
|       "version": "0.3.5", | ||||
|       "resolved": "https://registry.npmjs.org/@jridgewell/source-map/-/source-map-0.3.5.tgz", | ||||
|       "integrity": "sha512-UTYAUj/wviwdsMfzoSJspJxbkH5o1snzwX0//0ENX1u/55kkZZkcTZP6u9bwKGkv+dkk9at4m1Cpt0uY80kcpQ==", | ||||
|       "dev": true, | ||||
|       "dependencies": { | ||||
|         "@jridgewell/gen-mapping": "^0.3.0", | ||||
|         "@jridgewell/trace-mapping": "^0.3.9" | ||||
|       } | ||||
|     }, | ||||
|     "node_modules/@jridgewell/sourcemap-codec": { | ||||
|       "version": "1.4.15", | ||||
|       "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.4.15.tgz", | ||||
|       "integrity": "sha512-eF2rxCRulEKXHTRiDrDy6erMYWqNw4LPdQ8UQA4huuxaQsVeRPFl2oM8oDGxMFhJUWZf9McpLtJasDDZb/Bpeg==", | ||||
|       "dev": true | ||||
|     }, | ||||
|     "node_modules/@jridgewell/trace-mapping": { | ||||
|       "version": "0.3.19", | ||||
|       "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.19.tgz", | ||||
|       "integrity": "sha512-kf37QtfW+Hwx/buWGMPcR60iF9ziHa6r/CZJIHbmcm4+0qrXiVdxegAH0F6yddEVQ7zdkjcGCgCzUu+BcbhQxw==", | ||||
|       "dev": true, | ||||
|       "dependencies": { | ||||
|         "@jridgewell/resolve-uri": "^3.1.0", | ||||
|         "@jridgewell/sourcemap-codec": "^1.4.14" | ||||
|       } | ||||
|     }, | ||||
|     "node_modules/@putout/minify": { | ||||
|       "version": "1.49.0", | ||||
|       "resolved": "https://registry.npmjs.org/@putout/minify/-/minify-1.49.0.tgz", | ||||
|       "integrity": "sha512-T/eS9rJC0tgq/s8uLpB0cpbsUaY7KSML3UbvPri2qjVCcEK/qwi8+lNWdp8VSyOWiC25Ntrt/DewOu6dXRX1ng==", | ||||
|       "dev": true, | ||||
|       "engines": { | ||||
|         "node": ">=16" | ||||
|       } | ||||
|     }, | ||||
|     "node_modules/acorn": { | ||||
|       "version": "8.10.0", | ||||
|       "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.10.0.tgz", | ||||
|       "integrity": "sha512-F0SAmZ8iUtS//m8DmCTA0jlh6TDKkHQyK6xc6V4KDTyZKA9dnvX9/3sRTVQrWm79glUAZbnmmNcdYwUIHWVybw==", | ||||
|       "dev": true, | ||||
|       "bin": { | ||||
|         "acorn": "bin/acorn" | ||||
|       }, | ||||
|       "engines": { | ||||
|         "node": ">=0.4.0" | ||||
|       } | ||||
|     }, | ||||
|     "node_modules/buffer-from": { | ||||
|       "version": "1.1.2", | ||||
|       "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", | ||||
|       "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", | ||||
|       "dev": true | ||||
|     }, | ||||
|     "node_modules/camel-case": { | ||||
|       "version": "4.1.2", | ||||
|       "resolved": "https://registry.npmjs.org/camel-case/-/camel-case-4.1.2.tgz", | ||||
|       "integrity": "sha512-gxGWBrTT1JuMx6R+o5PTXMmUnhnVzLQ9SNutD4YqKtI6ap897t3tKECYla6gCWEkplXnlNybEkZg9GEGxKFCgw==", | ||||
|       "dev": true, | ||||
|       "dependencies": { | ||||
|         "pascal-case": "^3.1.2", | ||||
|         "tslib": "^2.0.3" | ||||
|       } | ||||
|     }, | ||||
|     "node_modules/clean-css": { | ||||
|       "version": "5.3.2", | ||||
|       "resolved": "https://registry.npmjs.org/clean-css/-/clean-css-5.3.2.tgz", | ||||
|       "integrity": "sha512-JVJbM+f3d3Q704rF4bqQ5UUyTtuJ0JRKNbTKVEeujCCBoMdkEi+V+e8oktO9qGQNSvHrFTM6JZRXrUvGR1czww==", | ||||
|       "dev": true, | ||||
|       "dependencies": { | ||||
|         "source-map": "~0.6.0" | ||||
|       }, | ||||
|       "engines": { | ||||
|         "node": ">= 10.0" | ||||
|       } | ||||
|     }, | ||||
|     "node_modules/commander": { | ||||
|       "version": "10.0.1", | ||||
|       "resolved": "https://registry.npmjs.org/commander/-/commander-10.0.1.tgz", | ||||
|       "integrity": "sha512-y4Mg2tXshplEbSGzx7amzPwKKOCGuoSRP/CjEdwwk0FOGlUbq6lKuoyDZTNZkmxHdJtp54hdfY/JUrdL7Xfdug==", | ||||
|       "dev": true, | ||||
|       "engines": { | ||||
|         "node": ">=14" | ||||
|       } | ||||
|     }, | ||||
|     "node_modules/css-b64-images": { | ||||
|       "version": "0.2.5", | ||||
|       "resolved": "https://registry.npmjs.org/css-b64-images/-/css-b64-images-0.2.5.tgz", | ||||
|       "integrity": "sha512-TgQBEdP07adhrDfXvI5o6bHGukKBNMzp2Ngckc/6d09zpjD2gc1Hl3Ca1CKgb8FXjHi88+Phv2Uegs2kTL4zjg==", | ||||
|       "dev": true, | ||||
|       "bin": { | ||||
|         "css-b64-images": "bin/css-b64-images" | ||||
|       }, | ||||
|       "engines": { | ||||
|         "node": "*" | ||||
|       } | ||||
|     }, | ||||
|     "node_modules/debug": { | ||||
|       "version": "4.3.4", | ||||
|       "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz", | ||||
|       "integrity": "sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==", | ||||
|       "dev": true, | ||||
|       "dependencies": { | ||||
|         "ms": "2.1.2" | ||||
|       }, | ||||
|       "engines": { | ||||
|         "node": ">=6.0" | ||||
|       }, | ||||
|       "peerDependenciesMeta": { | ||||
|         "supports-color": { | ||||
|           "optional": true | ||||
|         } | ||||
|       } | ||||
|     }, | ||||
|     "node_modules/dot-case": { | ||||
|       "version": "3.0.4", | ||||
|       "resolved": "https://registry.npmjs.org/dot-case/-/dot-case-3.0.4.tgz", | ||||
|       "integrity": "sha512-Kv5nKlh6yRrdrGvxeJ2e5y2eRUpkUosIW4A2AS38zwSz27zu7ufDwQPi5Jhs3XAlGNetl3bmnGhQsMtkKJnj3w==", | ||||
|       "dev": true, | ||||
|       "dependencies": { | ||||
|         "no-case": "^3.0.4", | ||||
|         "tslib": "^2.0.3" | ||||
|       } | ||||
|     }, | ||||
|     "node_modules/entities": { | ||||
|       "version": "4.5.0", | ||||
|       "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", | ||||
|       "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==", | ||||
|       "dev": true, | ||||
|       "engines": { | ||||
|         "node": ">=0.12" | ||||
|       }, | ||||
|       "funding": { | ||||
|         "url": "https://github.com/fb55/entities?sponsor=1" | ||||
|       } | ||||
|     }, | ||||
|     "node_modules/find-up": { | ||||
|       "version": "6.3.0", | ||||
|       "resolved": "https://registry.npmjs.org/find-up/-/find-up-6.3.0.tgz", | ||||
|       "integrity": "sha512-v2ZsoEuVHYy8ZIlYqwPe/39Cy+cFDzp4dXPaxNvkEuouymu+2Jbz0PxpKarJHYJTmv2HWT3O382qY8l4jMWthw==", | ||||
|       "dev": true, | ||||
|       "dependencies": { | ||||
|         "locate-path": "^7.1.0", | ||||
|         "path-exists": "^5.0.0" | ||||
|       }, | ||||
|       "engines": { | ||||
|         "node": "^12.20.0 || ^14.13.1 || >=16.0.0" | ||||
|       }, | ||||
|       "funding": { | ||||
|         "url": "https://github.com/sponsors/sindresorhus" | ||||
|       } | ||||
|     }, | ||||
|     "node_modules/html-minifier-terser": { | ||||
|       "version": "7.2.0", | ||||
|       "resolved": "https://registry.npmjs.org/html-minifier-terser/-/html-minifier-terser-7.2.0.tgz", | ||||
|       "integrity": "sha512-tXgn3QfqPIpGl9o+K5tpcj3/MN4SfLtsx2GWwBC3SSd0tXQGyF3gsSqad8loJgKZGM3ZxbYDd5yhiBIdWpmvLA==", | ||||
|       "dev": true, | ||||
|       "dependencies": { | ||||
|         "camel-case": "^4.1.2", | ||||
|         "clean-css": "~5.3.2", | ||||
|         "commander": "^10.0.0", | ||||
|         "entities": "^4.4.0", | ||||
|         "param-case": "^3.0.4", | ||||
|         "relateurl": "^0.2.7", | ||||
|         "terser": "^5.15.1" | ||||
|       }, | ||||
|       "bin": { | ||||
|         "html-minifier-terser": "cli.js" | ||||
|       }, | ||||
|       "engines": { | ||||
|         "node": "^14.13.1 || >=16.0.0" | ||||
|       } | ||||
|     }, | ||||
|     "node_modules/jju": { | ||||
|       "version": "1.4.0", | ||||
|       "resolved": "https://registry.npmjs.org/jju/-/jju-1.4.0.tgz", | ||||
|       "integrity": "sha512-8wb9Yw966OSxApiCt0K3yNJL8pnNeIv+OEq2YMidz4FKP6nonSRoOXc80iXY4JaN2FC11B9qsNmDsm+ZOfMROA==", | ||||
|       "dev": true | ||||
|     }, | ||||
|     "node_modules/locate-path": { | ||||
|       "version": "7.2.0", | ||||
|       "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-7.2.0.tgz", | ||||
|       "integrity": "sha512-gvVijfZvn7R+2qyPX8mAuKcFGDf6Nc61GdvGafQsHL0sBIxfKzA+usWn4GFC/bk+QdwPUD4kWFJLhElipq+0VA==", | ||||
|       "dev": true, | ||||
|       "dependencies": { | ||||
|         "p-locate": "^6.0.0" | ||||
|       }, | ||||
|       "engines": { | ||||
|         "node": "^12.20.0 || ^14.13.1 || >=16.0.0" | ||||
|       }, | ||||
|       "funding": { | ||||
|         "url": "https://github.com/sponsors/sindresorhus" | ||||
|       } | ||||
|     }, | ||||
|     "node_modules/lower-case": { | ||||
|       "version": "2.0.2", | ||||
|       "resolved": "https://registry.npmjs.org/lower-case/-/lower-case-2.0.2.tgz", | ||||
|       "integrity": "sha512-7fm3l3NAF9WfN6W3JOmf5drwpVqX78JtoGJ3A6W0a6ZnldM41w2fV5D490psKFTpMds8TJse/eHLFFsNHHjHgg==", | ||||
|       "dev": true, | ||||
|       "dependencies": { | ||||
|         "tslib": "^2.0.3" | ||||
|       } | ||||
|     }, | ||||
|     "node_modules/minify": { | ||||
|       "version": "10.3.0", | ||||
|       "resolved": "https://registry.npmjs.org/minify/-/minify-10.3.0.tgz", | ||||
|       "integrity": "sha512-eRkx2J1ykkGBVi1gI2sksmovWFzts+GYi2u3Jd/S5eNIkzj0pidciICsWRWdTKTLZVFUP7b6IvoAzasvQkMicg==", | ||||
|       "dev": true, | ||||
|       "dependencies": { | ||||
|         "@putout/minify": "^1.0.4", | ||||
|         "clean-css": "^5.0.1", | ||||
|         "css-b64-images": "~0.2.5", | ||||
|         "debug": "^4.1.0", | ||||
|         "find-up": "^6.1.0", | ||||
|         "html-minifier-terser": "^7.1.0", | ||||
|         "readjson": "^2.2.2", | ||||
|         "simport": "^1.2.0", | ||||
|         "try-catch": "^3.0.0", | ||||
|         "try-to-catch": "^3.0.0" | ||||
|       }, | ||||
|       "bin": { | ||||
|         "minify": "bin/minify.js" | ||||
|       }, | ||||
|       "engines": { | ||||
|         "node": ">=16" | ||||
|       } | ||||
|     }, | ||||
|     "node_modules/ms": { | ||||
|       "version": "2.1.2", | ||||
|       "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", | ||||
|       "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==", | ||||
|       "dev": true | ||||
|     }, | ||||
|     "node_modules/no-case": { | ||||
|       "version": "3.0.4", | ||||
|       "resolved": "https://registry.npmjs.org/no-case/-/no-case-3.0.4.tgz", | ||||
|       "integrity": "sha512-fgAN3jGAh+RoxUGZHTSOLJIqUc2wmoBwGR4tbpNAKmmovFoWq0OdRkb0VkldReO2a2iBT/OEulG9XSUc10r3zg==", | ||||
|       "dev": true, | ||||
|       "dependencies": { | ||||
|         "lower-case": "^2.0.2", | ||||
|         "tslib": "^2.0.3" | ||||
|       } | ||||
|     }, | ||||
|     "node_modules/p-limit": { | ||||
|       "version": "4.0.0", | ||||
|       "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-4.0.0.tgz", | ||||
|       "integrity": "sha512-5b0R4txpzjPWVw/cXXUResoD4hb6U/x9BH08L7nw+GN1sezDzPdxeRvpc9c433fZhBan/wusjbCsqwqm4EIBIQ==", | ||||
|       "dev": true, | ||||
|       "dependencies": { | ||||
|         "yocto-queue": "^1.0.0" | ||||
|       }, | ||||
|       "engines": { | ||||
|         "node": "^12.20.0 || ^14.13.1 || >=16.0.0" | ||||
|       }, | ||||
|       "funding": { | ||||
|         "url": "https://github.com/sponsors/sindresorhus" | ||||
|       } | ||||
|     }, | ||||
|     "node_modules/p-locate": { | ||||
|       "version": "6.0.0", | ||||
|       "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-6.0.0.tgz", | ||||
|       "integrity": "sha512-wPrq66Llhl7/4AGC6I+cqxT07LhXvWL08LNXz1fENOw0Ap4sRZZ/gZpTTJ5jpurzzzfS2W/Ge9BY3LgLjCShcw==", | ||||
|       "dev": true, | ||||
|       "dependencies": { | ||||
|         "p-limit": "^4.0.0" | ||||
|       }, | ||||
|       "engines": { | ||||
|         "node": "^12.20.0 || ^14.13.1 || >=16.0.0" | ||||
|       }, | ||||
|       "funding": { | ||||
|         "url": "https://github.com/sponsors/sindresorhus" | ||||
|       } | ||||
|     }, | ||||
|     "node_modules/param-case": { | ||||
|       "version": "3.0.4", | ||||
|       "resolved": "https://registry.npmjs.org/param-case/-/param-case-3.0.4.tgz", | ||||
|       "integrity": "sha512-RXlj7zCYokReqWpOPH9oYivUzLYZ5vAPIfEmCTNViosC78F8F0H9y7T7gG2M39ymgutxF5gcFEsyZQSph9Bp3A==", | ||||
|       "dev": true, | ||||
|       "dependencies": { | ||||
|         "dot-case": "^3.0.4", | ||||
|         "tslib": "^2.0.3" | ||||
|       } | ||||
|     }, | ||||
|     "node_modules/pascal-case": { | ||||
|       "version": "3.1.2", | ||||
|       "resolved": "https://registry.npmjs.org/pascal-case/-/pascal-case-3.1.2.tgz", | ||||
|       "integrity": "sha512-uWlGT3YSnK9x3BQJaOdcZwrnV6hPpd8jFH1/ucpiLRPh/2zCVJKS19E4GvYHvaCcACn3foXZ0cLB9Wrx1KGe5g==", | ||||
|       "dev": true, | ||||
|       "dependencies": { | ||||
|         "no-case": "^3.0.4", | ||||
|         "tslib": "^2.0.3" | ||||
|       } | ||||
|     }, | ||||
|     "node_modules/path-exists": { | ||||
|       "version": "5.0.0", | ||||
|       "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-5.0.0.tgz", | ||||
|       "integrity": "sha512-RjhtfwJOxzcFmNOi6ltcbcu4Iu+FL3zEj83dk4kAS+fVpTxXLO1b38RvJgT/0QwvV/L3aY9TAnyv0EOqW4GoMQ==", | ||||
|       "dev": true, | ||||
|       "engines": { | ||||
|         "node": "^12.20.0 || ^14.13.1 || >=16.0.0" | ||||
|       } | ||||
|     }, | ||||
|     "node_modules/prettier": { | ||||
|       "version": "3.0.1", | ||||
|       "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.0.1.tgz", | ||||
|       "integrity": "sha512-fcOWSnnpCrovBsmFZIGIy9UqK2FaI7Hqax+DIO0A9UxeVoY4iweyaFjS5TavZN97Hfehph0nhsZnjlVKzEQSrQ==", | ||||
|       "dev": true, | ||||
|       "bin": { | ||||
|         "prettier": "bin/prettier.cjs" | ||||
|       }, | ||||
|       "engines": { | ||||
|         "node": ">=14" | ||||
|       }, | ||||
|       "funding": { | ||||
|         "url": "https://github.com/prettier/prettier?sponsor=1" | ||||
|       } | ||||
|     }, | ||||
|     "node_modules/readjson": { | ||||
|       "version": "2.2.2", | ||||
|       "resolved": "https://registry.npmjs.org/readjson/-/readjson-2.2.2.tgz", | ||||
|       "integrity": "sha512-PdeC9tsmLWBiL8vMhJvocq+OezQ3HhsH2HrN7YkhfYcTjQSa/iraB15A7Qvt7Xpr0Yd2rDNt6GbFwVQDg3HcAw==", | ||||
|       "dev": true, | ||||
|       "dependencies": { | ||||
|         "jju": "^1.4.0", | ||||
|         "try-catch": "^3.0.0" | ||||
|       }, | ||||
|       "engines": { | ||||
|         "node": ">=10" | ||||
|       } | ||||
|     }, | ||||
|     "node_modules/relateurl": { | ||||
|       "version": "0.2.7", | ||||
|       "resolved": "https://registry.npmjs.org/relateurl/-/relateurl-0.2.7.tgz", | ||||
|       "integrity": "sha512-G08Dxvm4iDN3MLM0EsP62EDV9IuhXPR6blNz6Utcp7zyV3tr4HVNINt6MpaRWbxoOHT3Q7YN2P+jaHX8vUbgog==", | ||||
|       "dev": true, | ||||
|       "engines": { | ||||
|         "node": ">= 0.10" | ||||
|       } | ||||
|     }, | ||||
|     "node_modules/simport": { | ||||
|       "version": "1.2.0", | ||||
|       "resolved": "https://registry.npmjs.org/simport/-/simport-1.2.0.tgz", | ||||
|       "integrity": "sha512-85Bm7pKsqiiQ8rmYCaPDdlXZjJvuW6/k/FY8MTtLFMgU7f8S00CgTHfRtWB6KwSb6ek4p9YyG2enG1+yJbl+CA==", | ||||
|       "dev": true, | ||||
|       "dependencies": { | ||||
|         "readjson": "^2.2.0", | ||||
|         "try-to-catch": "^3.0.0" | ||||
|       }, | ||||
|       "engines": { | ||||
|         "node": ">=12.2" | ||||
|       } | ||||
|     }, | ||||
|     "node_modules/source-map": { | ||||
|       "version": "0.6.1", | ||||
|       "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", | ||||
|       "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", | ||||
|       "dev": true, | ||||
|       "engines": { | ||||
|         "node": ">=0.10.0" | ||||
|       } | ||||
|     }, | ||||
|     "node_modules/source-map-support": { | ||||
|       "version": "0.5.21", | ||||
|       "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.21.tgz", | ||||
|       "integrity": "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==", | ||||
|       "dev": true, | ||||
|       "dependencies": { | ||||
|         "buffer-from": "^1.0.0", | ||||
|         "source-map": "^0.6.0" | ||||
|       } | ||||
|     }, | ||||
|     "node_modules/terser": { | ||||
|       "version": "5.19.2", | ||||
|       "resolved": "https://registry.npmjs.org/terser/-/terser-5.19.2.tgz", | ||||
|       "integrity": "sha512-qC5+dmecKJA4cpYxRa5aVkKehYsQKc+AHeKl0Oe62aYjBL8ZA33tTljktDHJSaxxMnbI5ZYw+o/S2DxxLu8OfA==", | ||||
|       "dev": true, | ||||
|       "dependencies": { | ||||
|         "@jridgewell/source-map": "^0.3.3", | ||||
|         "acorn": "^8.8.2", | ||||
|         "commander": "^2.20.0", | ||||
|         "source-map-support": "~0.5.20" | ||||
|       }, | ||||
|       "bin": { | ||||
|         "terser": "bin/terser" | ||||
|       }, | ||||
|       "engines": { | ||||
|         "node": ">=10" | ||||
|       } | ||||
|     }, | ||||
|     "node_modules/terser/node_modules/commander": { | ||||
|       "version": "2.20.3", | ||||
|       "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz", | ||||
|       "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==", | ||||
|       "dev": true | ||||
|     }, | ||||
|     "node_modules/try-catch": { | ||||
|       "version": "3.0.1", | ||||
|       "resolved": "https://registry.npmjs.org/try-catch/-/try-catch-3.0.1.tgz", | ||||
|       "integrity": "sha512-91yfXw1rr/P6oLpHSyHDOHm0vloVvUoo9FVdw8YwY05QjJQG9OT0LUxe2VRAzmHG+0CUOmI3nhxDUMLxDN/NEQ==", | ||||
|       "dev": true, | ||||
|       "engines": { | ||||
|         "node": ">=6" | ||||
|       } | ||||
|     }, | ||||
|     "node_modules/try-to-catch": { | ||||
|       "version": "3.0.1", | ||||
|       "resolved": "https://registry.npmjs.org/try-to-catch/-/try-to-catch-3.0.1.tgz", | ||||
|       "integrity": "sha512-hOY83V84Hx/1sCzDSaJA+Xz2IIQOHRvjxzt+F0OjbQGPZ6yLPLArMA0gw/484MlfUkQbCpKYMLX3VDCAjWKfzQ==", | ||||
|       "dev": true, | ||||
|       "engines": { | ||||
|         "node": ">=6" | ||||
|       } | ||||
|     }, | ||||
|     "node_modules/tsc": { | ||||
|       "version": "2.0.4", | ||||
|       "resolved": "https://registry.npmjs.org/tsc/-/tsc-2.0.4.tgz", | ||||
|       "integrity": "sha512-fzoSieZI5KKJVBYGvwbVZs/J5za84f2lSTLPYf6AGiIf43tZ3GNrI1QzTLcjtyDDP4aLxd46RTZq1nQxe7+k5Q==", | ||||
|       "dev": true, | ||||
|       "bin": { | ||||
|         "tsc": "bin/tsc" | ||||
|       } | ||||
|     }, | ||||
|     "node_modules/tslib": { | ||||
|       "version": "2.6.1", | ||||
|       "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.6.1.tgz", | ||||
|       "integrity": "sha512-t0hLfiEKfMUoqhG+U1oid7Pva4bbDPHYfJNiB7BiIjRkj1pyC++4N3huJfqY6aRH6VTB0rvtzQwjM4K6qpfOig==", | ||||
|       "dev": true | ||||
|     }, | ||||
|     "node_modules/yocto-queue": { | ||||
|       "version": "1.0.0", | ||||
|       "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-1.0.0.tgz", | ||||
|       "integrity": "sha512-9bnSc/HEW2uRy67wc+T8UwauLuPJVn28jb+GtJY16iiKWyvmYJRXVT4UamsAEGQfPohgr2q4Tq0sQbQlxTfi1g==", | ||||
|       "dev": true, | ||||
|       "engines": { | ||||
|         "node": ">=12.20" | ||||
|       }, | ||||
|       "funding": { | ||||
|         "url": "https://github.com/sponsors/sindresorhus" | ||||
|       } | ||||
|     } | ||||
|   } | ||||
| } | ||||
							
								
								
									
										7
									
								
								package.json
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										7
									
								
								package.json
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,7 @@ | ||||
| { | ||||
|   "devDependencies": { | ||||
|     "minify": "^10.3.0", | ||||
|     "prettier": "^3.0.1", | ||||
|     "tsc": "^2.0.4" | ||||
|   } | ||||
| } | ||||
| @@ -7,12 +7,10 @@ edition = "2021" | ||||
| tokio = { version = "1", features = ["process", "full"] } | ||||
| regex = "1.4" | ||||
| log = "0.4" | ||||
| env_logger = "0.8" | ||||
| chrono = "0.4" | ||||
| chrono-tz = { version = "0.5", features = ["serde"] } | ||||
| lazy_static = "1.4" | ||||
| num-integer = "0.1" | ||||
| serde = "1.0" | ||||
| serde_json = "1.0" | ||||
| sqlx = { version = "0.5", features = ["runtime-tokio-rustls", "macros", "mysql", "bigdecimal", "chrono", "json"]} | ||||
| sqlx = { version = "0.6", features = ["runtime-tokio-rustls", "macros", "mysql", "bigdecimal", "chrono", "json"]} | ||||
| serenity = { version = "0.11.1", default-features = false, features = ["builder", "cache", "client", "gateway", "http", "model", "utils", "rustls_backend"] } | ||||
|   | ||||
| @@ -1,4 +1,6 @@ | ||||
| use chrono::Duration; | ||||
| use std::env; | ||||
|  | ||||
| use chrono::{DateTime, Days, Duration, Months}; | ||||
| use chrono_tz::Tz; | ||||
| use lazy_static::lazy_static; | ||||
| use log::{error, info, warn}; | ||||
| @@ -7,7 +9,7 @@ use regex::{Captures, Regex}; | ||||
| use serde::Deserialize; | ||||
| use serenity::{ | ||||
|     builder::CreateEmbed, | ||||
|     http::{CacheHttp, Http, HttpError, StatusCode}, | ||||
|     http::{CacheHttp, Http, HttpError}, | ||||
|     model::{ | ||||
|         channel::{Channel, Embed as SerenityEmbed}, | ||||
|         id::ChannelId, | ||||
| @@ -30,6 +32,7 @@ lazy_static! { | ||||
|         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 { | ||||
| @@ -62,18 +65,23 @@ pub fn substitute(string: &str) -> String { | ||||
|         let format = caps.name("format").map(|m| m.as_str()); | ||||
|  | ||||
|         if let (Some(final_time), Some(format)) = (final_time, format) { | ||||
|             let dt = NaiveDateTime::from_timestamp(final_time, 0); | ||||
|             let now = Utc::now().naive_utc(); | ||||
|             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 | ||||
|                     let difference = { | ||||
|                         if now < dt { | ||||
|                             dt - Utc::now().naive_utc() | ||||
|                         } else { | ||||
|                             Utc::now().naive_utc() - dt | ||||
|                         } | ||||
|                     }; | ||||
|  | ||||
|                     fmt_displacement(format, difference.num_seconds() as u64) | ||||
|                 } | ||||
|             }; | ||||
|  | ||||
|             fmt_displacement(format, difference.num_seconds() as u64) | ||||
|                 None => String::new(), | ||||
|             } | ||||
|         } else { | ||||
|             String::new() | ||||
|         } | ||||
| @@ -146,7 +154,7 @@ impl Embed { | ||||
|                 embed.description = substitute(&embed.description); | ||||
|                 embed.footer = substitute(&embed.footer); | ||||
|  | ||||
|                 embed.fields.iter_mut().for_each(|mut field| { | ||||
|                 embed.fields.iter_mut().for_each(|field| { | ||||
|                     field.title = substitute(&field.title); | ||||
|                     field.value = substitute(&field.value); | ||||
|                 }); | ||||
| @@ -226,7 +234,6 @@ impl Into<CreateEmbed> for Embed { | ||||
|     } | ||||
| } | ||||
|  | ||||
| #[derive(Debug)] | ||||
| pub struct Reminder { | ||||
|     id: u32, | ||||
|  | ||||
| @@ -244,11 +251,12 @@ pub struct Reminder { | ||||
|     attachment: Option<Vec<u8>>, | ||||
|     attachment_name: Option<String>, | ||||
|  | ||||
|     utc_time: NaiveDateTime, | ||||
|     utc_time: DateTime<Utc>, | ||||
|     timezone: String, | ||||
|     restartable: bool, | ||||
|     expires: Option<NaiveDateTime>, | ||||
|     expires: Option<DateTime<Utc>>, | ||||
|     interval_seconds: Option<u32>, | ||||
|     interval_days: Option<u32>, | ||||
|     interval_months: Option<u32>, | ||||
|  | ||||
|     avatar: Option<String>, | ||||
| @@ -282,6 +290,7 @@ SELECT | ||||
|     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, | ||||
| @@ -293,9 +302,24 @@ INNER JOIN | ||||
| ON | ||||
|     reminders.channel_id = channels.id | ||||
| WHERE | ||||
|     reminders.`utc_time` < NOW() | ||||
| LIMIT 25 | ||||
|             "#, | ||||
|     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 | ||||
| @@ -319,9 +343,7 @@ LIMIT 25 | ||||
|  | ||||
|     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 = ? | ||||
|             ", | ||||
|             "UPDATE channels SET webhook_id = NULL, webhook_token = NULL WHERE channel = ?", | ||||
|             self.channel_id | ||||
|         ) | ||||
|         .execute(pool) | ||||
| @@ -329,56 +351,72 @@ UPDATE channels SET webhook_id = NULL, webhook_token = NULL WHERE channel = ? | ||||
|     } | ||||
|  | ||||
|     async fn refresh(&self, pool: impl Executor<'_, Database = Database> + Copy) { | ||||
|         if self.interval_seconds.is_some() || self.interval_months.is_some() { | ||||
|             let now = Utc::now().naive_local(); | ||||
|             let mut updated_reminder_time = self.utc_time; | ||||
|  | ||||
|             if let Some(interval) = self.interval_months { | ||||
|                 match sqlx::query!( | ||||
|                     // use the second date_add to force return value to datetime | ||||
|                     "SELECT DATE_ADD(DATE_ADD(?, INTERVAL ? MONTH), INTERVAL 0 SECOND) AS new_time", | ||||
|                     updated_reminder_time, | ||||
|                     interval | ||||
|                 ) | ||||
|                 .fetch_one(pool) | ||||
|                 .await | ||||
|                 { | ||||
|                     Ok(row) => match row.new_time { | ||||
|                         Some(datetime) => { | ||||
|                             updated_reminder_time = datetime; | ||||
|                         } | ||||
|                         None => { | ||||
|                             warn!("Could not update interval by months: got NULL"); | ||||
|  | ||||
|                             updated_reminder_time += Duration::days(30); | ||||
|                         } | ||||
|                     }, | ||||
|  | ||||
|                     Err(e) => { | ||||
|                         warn!("Could not update interval by months: {:?}", e); | ||||
|  | ||||
|                         // naively fallback to adding 30 days | ||||
|                         updated_reminder_time += Duration::days(30); | ||||
|                     } | ||||
|                 } | ||||
|         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; | ||||
|             } | ||||
|  | ||||
|             if let Some(interval) = self.interval_seconds { | ||||
|                 while updated_reminder_time < now { | ||||
|             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 self.expires.map_or(false, |expires| { | ||||
|                 NaiveDateTime::from_timestamp(updated_reminder_time.timestamp(), 0) > expires | ||||
|             }) { | ||||
|                 self.force_delete(pool).await; | ||||
|             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, | ||||
|                     "UPDATE reminders SET `utc_time` = ? WHERE `id` = ?", | ||||
|                     updated_reminder_time.with_timezone(&Utc), | ||||
|                     self.id | ||||
|                 ) | ||||
|                 .execute(pool) | ||||
| @@ -386,15 +424,67 @@ UPDATE reminders SET `utc_time` = ? WHERE `id` = ? | ||||
|                 .expect(&format!("Could not update time on Reminder {}", self.id)); | ||||
|             } | ||||
|         } else { | ||||
|             self.force_delete(pool).await; | ||||
|             self.set_sent(pool).await; | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     async fn force_delete(&self, pool: impl Executor<'_, Database = Database> + Copy) { | ||||
|     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!( | ||||
|             " | ||||
| DELETE FROM reminders WHERE `id` = ? | ||||
|             ", | ||||
|             "UPDATE reminders SET `status` = 'failed', `status_message` = ? WHERE `id` = ?", | ||||
|             message, | ||||
|             self.id | ||||
|         ) | ||||
|         .execute(pool) | ||||
| @@ -493,7 +583,9 @@ DELETE FROM reminders WHERE `id` = ? | ||||
|                     w.content(&reminder.content).tts(reminder.tts); | ||||
|  | ||||
|                     if let Some(username) = &reminder.username { | ||||
|                         w.username(username); | ||||
|                         if !username.is_empty() { | ||||
|                             w.username(username); | ||||
|                         } | ||||
|                     } | ||||
|  | ||||
|                     if let Some(avatar) = &reminder.avatar { | ||||
| @@ -537,9 +629,7 @@ DELETE FROM reminders WHERE `id` = ? | ||||
|                     .map_or(true, |inner| inner >= Utc::now().naive_local())) | ||||
|         { | ||||
|             let _ = sqlx::query!( | ||||
|                 " | ||||
| UPDATE `channels` SET paused = 0, paused_until = NULL WHERE `channel` = ? | ||||
|                 ", | ||||
|                 "UPDATE `channels` SET paused = 0, paused_until = NULL WHERE `channel` = ?", | ||||
|                 self.channel_id | ||||
|             ) | ||||
|             .execute(pool) | ||||
| @@ -556,7 +646,7 @@ UPDATE `channels` SET paused = 0, paused_until = NULL WHERE `channel` = ? | ||||
|                 if let Ok(webhook) = webhook_res { | ||||
|                     send_to_webhook(cache_http, &self, webhook, embed).await | ||||
|                 } else { | ||||
|                     warn!("Webhook vanished: {:?}", webhook_res); | ||||
|                     warn!("Webhook vanished for reminder {}: {:?}", self.id, webhook_res); | ||||
|  | ||||
|                     self.reset_webhook(pool).await; | ||||
|                     send_to_channel(cache_http, &self, embed).await | ||||
| @@ -566,24 +656,84 @@ UPDATE `channels` SET paused = 0, paused_until = NULL WHERE `channel` = ? | ||||
|             }; | ||||
|  | ||||
|             if let Err(e) = result { | ||||
|                 error!("Error sending {:?}: {:?}", self, e); | ||||
|  | ||||
|                 if let Error::Http(error) = e { | ||||
|                     if error.status_code() == Some(StatusCode::NOT_FOUND) { | ||||
|                         warn!("Seeing channel is deleted. Removing reminder"); | ||||
|                         self.force_delete(pool).await; | ||||
|                     } else if let HttpError::UnsuccessfulRequest(error) = *error { | ||||
|                         if error.error.code == 50007 { | ||||
|                             warn!("User cannot receive DMs"); | ||||
|                             self.force_delete(pool).await; | ||||
|                         } else { | ||||
|                             self.refresh(pool).await; | ||||
|                     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 { | ||||
|   | ||||
							
								
								
									
										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(()) | ||||
| } | ||||
| @@ -6,8 +6,8 @@ use crate::{models::CtxData, Context, Error, THEME_COLOR}; | ||||
| fn footer( | ||||
|     ctx: Context<'_>, | ||||
| ) -> impl FnOnce(&mut serenity::CreateEmbedFooter) -> &mut serenity::CreateEmbedFooter { | ||||
|     let shard_count = ctx.discord().cache.shard_count(); | ||||
|     let shard = ctx.discord().shard_id; | ||||
|     let shard_count = ctx.serenity_context().cache.shard_count(); | ||||
|     let shard = ctx.serenity_context().shard_id; | ||||
|  | ||||
|     move |f| { | ||||
|         f.text(format!( | ||||
| @@ -49,6 +49,7 @@ __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 | ||||
|   | ||||
| @@ -1,3 +1,5 @@ | ||||
| mod autocomplete; | ||||
| pub mod command_macro; | ||||
| pub mod info_cmds; | ||||
| pub mod moderation_cmds; | ||||
| pub mod reminder_cmds; | ||||
|   | ||||
| @@ -1,32 +1,10 @@ | ||||
| use std::collections::hash_map::Entry; | ||||
|  | ||||
| use chrono::offset::Utc; | ||||
| use chrono_tz::{Tz, TZ_VARIANTS}; | ||||
| use levenshtein::levenshtein; | ||||
| use poise::CreateReply; | ||||
| use log::warn; | ||||
|  | ||||
| use crate::{ | ||||
|     component_models::pager::{MacroPager, Pager}, | ||||
|     consts::{EMBED_DESCRIPTION_MAX_LENGTH, THEME_COLOR}, | ||||
|     models::{ | ||||
|         command_macro::{guild_command_macro, CommandMacro}, | ||||
|         CtxData, | ||||
|     }, | ||||
|     Context, Data, Error, | ||||
| }; | ||||
|  | ||||
| async fn timezone_autocomplete(ctx: Context<'_>, partial: String) -> 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>>() | ||||
|     } | ||||
| } | ||||
| use super::autocomplete::timezone_autocomplete; | ||||
| use crate::{consts::THEME_COLOR, models::CtxData, Context, Error}; | ||||
|  | ||||
| /// Select your timezone | ||||
| #[poise::command(slash_command, identifying_name = "timezone")] | ||||
| @@ -124,376 +102,154 @@ You may want to use one of the popular timezones below, otherwise click [here](h | ||||
|     Ok(()) | ||||
| } | ||||
|  | ||||
| async fn macro_name_autocomplete(ctx: Context<'_>, partial: String) -> 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() | ||||
| } | ||||
|  | ||||
| /// Record and replay command sequences | ||||
| /// Configure server settings | ||||
| #[poise::command( | ||||
|     slash_command, | ||||
|     rename = "macro", | ||||
|     guild_only = true, | ||||
|     default_member_permissions = "MANAGE_GUILD", | ||||
|     identifying_name = "macro_base" | ||||
|     rename = "settings", | ||||
|     identifying_name = "settings", | ||||
|     guild_only = true | ||||
| )] | ||||
| pub async fn macro_base(_ctx: Context<'_>) -> Result<(), Error> { | ||||
| pub async fn settings(_ctx: Context<'_>) -> Result<(), Error> { | ||||
|     Ok(()) | ||||
| } | ||||
|  | ||||
| /// Start recording up to 5 commands to replay | ||||
| /// Configure ephemeral setup | ||||
| #[poise::command( | ||||
|     slash_command, | ||||
|     rename = "record", | ||||
|     guild_only = true, | ||||
|     default_member_permissions = "MANAGE_GUILD", | ||||
|     identifying_name = "record_macro" | ||||
|     rename = "ephemeral", | ||||
|     identifying_name = "ephemeral_confirmations", | ||||
|     guild_only = true | ||||
| )] | ||||
| 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> { | ||||
|     let guild_id = ctx.guild_id().unwrap(); | ||||
| pub async fn ephemeral_confirmations(_ctx: Context<'_>) -> Result<(), Error> { | ||||
|     Ok(()) | ||||
| } | ||||
|  | ||||
|     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; | ||||
| /// 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; | ||||
|  | ||||
|     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) | ||||
|             }) | ||||
|     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?; | ||||
|     } 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(()) | ||||
| } | ||||
|  | ||||
| /// 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(()) | ||||
| } | ||||
|  | ||||
| /// Run a recorded macro | ||||
| /// Set reminder confirmations to persist indefinitely | ||||
| #[poise::command( | ||||
|     slash_command, | ||||
|     rename = "run", | ||||
|     guild_only = true, | ||||
|     default_member_permissions = "MANAGE_GUILD", | ||||
|     identifying_name = "run_macro" | ||||
|     rename = "off", | ||||
|     identifying_name = "unset_ephemeral_confirmations", | ||||
|     guild_only = true | ||||
| )] | ||||
| 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) => { | ||||
|             ctx.defer_response(false).await?; | ||||
| 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; | ||||
|  | ||||
|             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?; | ||||
|         } | ||||
|     } | ||||
|     ctx.send(|r| { | ||||
|         r.ephemeral(true).embed(|e| { | ||||
|             e.title("Confirmations public") | ||||
|                 .description( | ||||
|                     "Reminder confirmations will be sent as regular messages, and won't be removed automatically.", | ||||
|                 ) | ||||
|                 .color(*THEME_COLOR) | ||||
|         }) | ||||
|     }) | ||||
|     .await?; | ||||
|  | ||||
|     Ok(()) | ||||
| } | ||||
|  | ||||
| /// Delete a recorded macro | ||||
| /// 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 can still set DM reminders for yourself or for users with DMs enabled.", | ||||
|                 ) | ||||
|                 .color(*THEME_COLOR) | ||||
|         }) | ||||
|     }) | ||||
|     .await?; | ||||
|  | ||||
|     Ok(()) | ||||
| } | ||||
|  | ||||
| /// View the webhook being used to send reminders to this channel | ||||
| #[poise::command( | ||||
|     slash_command, | ||||
|     rename = "delete", | ||||
|     guild_only = true, | ||||
|     default_member_permissions = "MANAGE_GUILD", | ||||
|     identifying_name = "delete_macro" | ||||
|     identifying_name = "webhook_url", | ||||
|     required_permissions = "ADMINISTRATOR" | ||||
| )] | ||||
| 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?; | ||||
| 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 { | ||||
|                 ctx.say("No webhook configured on this channel.").await?; | ||||
|             } | ||||
|         } | ||||
|  | ||||
|         Err(sqlx::Error::RowNotFound) => { | ||||
|             ctx.say(format!("Macro \"{}\" not found", name)).await?; | ||||
|         } | ||||
|  | ||||
|         Err(e) => { | ||||
|             panic!("{}", e); | ||||
|             warn!("Error fetching channel data: {:?}", e); | ||||
|  | ||||
|             ctx.say("No webhook configured on this channel.").await?; | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     Ok(()) | ||||
| } | ||||
|  | ||||
| pub fn max_macro_page<U, E>(macros: &[CommandMacro<U, E>]) -> 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<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 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"); | ||||
|  | ||||
|     let mut reply = CreateReply::default(); | ||||
|  | ||||
|     reply | ||||
|         .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 | ||||
|         }); | ||||
|  | ||||
|     reply | ||||
| } | ||||
|   | ||||
| @@ -1,19 +1,18 @@ | ||||
| use std::{ | ||||
|     collections::HashSet, | ||||
|     string::ToString, | ||||
|     time::{SystemTime, UNIX_EPOCH}, | ||||
| }; | ||||
| use std::{collections::HashSet, string::ToString}; | ||||
|  | ||||
| use chrono::NaiveDateTime; | ||||
| use chrono::{DateTime, NaiveDateTime, Utc}; | ||||
| use chrono_tz::Tz; | ||||
| use log::warn; | ||||
| use num_integer::Integer; | ||||
| use poise::{ | ||||
|     serenity::{builder::CreateEmbed, model::channel::Channel}, | ||||
|     serenity_prelude::{ButtonStyle, ReactionType}, | ||||
|     CreateReply, | ||||
|     serenity_prelude::{ | ||||
|         builder::CreateEmbed, component::ButtonStyle, model::channel::Channel, ReactionType, | ||||
|     }, | ||||
|     CreateReply, Modal, | ||||
| }; | ||||
|  | ||||
| use crate::{ | ||||
|     commands::autocomplete::{time_hint_autocomplete, timezone_autocomplete}, | ||||
|     component_models::{ | ||||
|         pager::{DelPager, LookPager, Pager}, | ||||
|         ComponentDataModel, DelSelector, UndoReminder, | ||||
| @@ -36,7 +35,7 @@ use crate::{ | ||||
|     }, | ||||
|     time_parser::natural_parser, | ||||
|     utils::{check_guild_subscription, check_subscription}, | ||||
|     Context, Error, | ||||
|     ApplicationContext, Context, Error, | ||||
| }; | ||||
|  | ||||
| /// Pause all reminders on the current channel until a certain time or indefinitely | ||||
| @@ -58,18 +57,27 @@ pub async fn pause( | ||||
|             let parsed = natural_parser(&until, &timezone.to_string()).await; | ||||
|  | ||||
|             if let Some(timestamp) = parsed { | ||||
|                 let dt = NaiveDateTime::from_timestamp(timestamp, 0); | ||||
|                 match NaiveDateTime::from_timestamp_opt(timestamp, 0) { | ||||
|                     Some(dt) => { | ||||
|                         channel.paused = true; | ||||
|                         channel.paused_until = Some(dt); | ||||
|  | ||||
|                 channel.paused = true; | ||||
|                 channel.paused_until = Some(dt); | ||||
|                         channel.commit_changes(&ctx.data().database).await; | ||||
|  | ||||
|                 channel.commit_changes(&ctx.data().database).await; | ||||
|                         ctx.say(format!( | ||||
|                             "Reminders in this channel have been silenced until **<t:{}:D>**", | ||||
|                             timestamp | ||||
|                         )) | ||||
|                         .await?; | ||||
|                     } | ||||
|  | ||||
|                 ctx.say(format!( | ||||
|                     "Reminders in this channel have been silenced until **<t:{}:D>**", | ||||
|                     timestamp | ||||
|                 )) | ||||
|                 .await?; | ||||
|                     None => { | ||||
|                         ctx.say(format!( | ||||
|                             "Time processed could not be interpreted as `DateTime`. Please write the time as clearly as possible", | ||||
|                         )) | ||||
|                         .await?; | ||||
|                     } | ||||
|                 } | ||||
|             } else { | ||||
|                 ctx.say( | ||||
|                     "Time could not be processed. Please write the time as clearly as possible", | ||||
| @@ -208,7 +216,7 @@ pub async fn look( | ||||
|         }), | ||||
|     }; | ||||
|  | ||||
|     let channel_opt = ctx.channel_id().to_channel_cached(&ctx.discord()); | ||||
|     let channel_opt = ctx.channel_id().to_channel_cached(&ctx); | ||||
|  | ||||
|     let channel_id = if let Some(Channel::Guild(channel)) = channel_opt { | ||||
|         if Some(channel.guild_id) == ctx.guild_id() { | ||||
| @@ -220,12 +228,11 @@ pub async fn look( | ||||
|         ctx.channel_id() | ||||
|     }; | ||||
|  | ||||
|     let channel_name = | ||||
|         if let Some(Channel::Guild(channel)) = channel_id.to_channel_cached(&ctx.discord()) { | ||||
|             Some(channel.name) | ||||
|         } else { | ||||
|             None | ||||
|         }; | ||||
|     let channel_name = if let Some(Channel::Guild(channel)) = channel_id.to_channel_cached(&ctx) { | ||||
|         Some(channel.name) | ||||
|     } else { | ||||
|         None | ||||
|     }; | ||||
|  | ||||
|     let reminders = Reminder::from_channel(&ctx.data().database, channel_id, &flags).await; | ||||
|  | ||||
| @@ -243,7 +250,7 @@ pub async fn look( | ||||
|                 char_count < EMBED_DESCRIPTION_MAX_LENGTH | ||||
|             }) | ||||
|             .collect::<Vec<String>>() | ||||
|             .join("\n"); | ||||
|             .join(""); | ||||
|  | ||||
|         let pages = reminders | ||||
|             .iter() | ||||
| @@ -287,8 +294,7 @@ pub async fn delete(ctx: Context<'_>) -> Result<(), Error> { | ||||
|     let timezone = ctx.timezone().await; | ||||
|  | ||||
|     let reminders = | ||||
|         Reminder::from_guild(&ctx.discord(), &ctx.data().database, ctx.guild_id(), ctx.author().id) | ||||
|             .await; | ||||
|         Reminder::from_guild(&ctx, &ctx.data().database, ctx.guild_id(), ctx.author().id).await; | ||||
|  | ||||
|     let resp = show_delete_page(&reminders, 0, timezone); | ||||
|  | ||||
| @@ -430,11 +436,8 @@ pub fn show_delete_page(reminders: &[Reminder], page: usize, timezone: Tz) -> Cr | ||||
|     reply | ||||
| } | ||||
|  | ||||
| fn time_difference(start_time: NaiveDateTime) -> String { | ||||
|     let unix_time = SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_secs() as i64; | ||||
|     let now = NaiveDateTime::from_timestamp(unix_time, 0); | ||||
|  | ||||
|     let delta = (now - start_time).num_seconds(); | ||||
| fn time_difference(start_time: DateTime<Utc>) -> String { | ||||
|     let delta = (Utc::now() - start_time).num_seconds(); | ||||
|  | ||||
|     let (minutes, seconds) = delta.div_rem(&60); | ||||
|     let (hours, minutes) = minutes.div_rem(&60); | ||||
| @@ -548,23 +551,104 @@ pub async fn delete_timer( | ||||
|     Ok(()) | ||||
| } | ||||
|  | ||||
| /// Create a new reminder | ||||
| #[derive(poise::Modal)] | ||||
| #[name = "Reminder"] | ||||
| struct ContentModal { | ||||
|     #[name = "Content"] | ||||
|     #[placeholder = "Message..."] | ||||
|     #[paragraph] | ||||
|     #[max_length = 2000] | ||||
|     content: String, | ||||
| } | ||||
|  | ||||
| /// Create a reminder with multi-line content. Press "+4 more" for other options. | ||||
| #[poise::command( | ||||
|     slash_command, | ||||
|     identifying_name = "multiline", | ||||
|     default_member_permissions = "MANAGE_GUILD" | ||||
| )] | ||||
| pub async fn multiline( | ||||
|     ctx: ApplicationContext<'_>, | ||||
|     #[description = "A description of the time to set the reminder for"] | ||||
|     #[autocomplete = "time_hint_autocomplete"] | ||||
|     time: String, | ||||
|     #[description = "Channel or user mentions to set the reminder for"] channels: Option<String>, | ||||
|     #[description = "(Patreon only) Time to wait before repeating the reminder. Leave blank for one-shot reminder"] | ||||
|     interval: Option<String>, | ||||
|     #[description = "(Patreon only) For repeating reminders, the time at which the reminder will stop repeating"] | ||||
|     expires: Option<String>, | ||||
|     #[description = "Set the TTS flag on the reminder message, similar to the /tts command"] | ||||
|     tts: Option<bool>, | ||||
|     #[description = "Set a timezone override for this reminder only"] | ||||
|     #[autocomplete = "timezone_autocomplete"] | ||||
|     timezone: Option<String>, | ||||
| ) -> Result<(), Error> { | ||||
|     let tz = timezone.map(|t| t.parse::<Tz>().ok()).flatten(); | ||||
|     let data_opt = ContentModal::execute(ctx).await?; | ||||
|  | ||||
|     match data_opt { | ||||
|         Some(data) => { | ||||
|             create_reminder( | ||||
|                 Context::Application(ctx), | ||||
|                 time, | ||||
|                 data.content, | ||||
|                 channels, | ||||
|                 interval, | ||||
|                 expires, | ||||
|                 tts, | ||||
|                 tz, | ||||
|             ) | ||||
|             .await | ||||
|         } | ||||
|  | ||||
|         None => { | ||||
|             warn!("Unexpected None encountered in /multiline"); | ||||
|             Ok(Context::Application(ctx) | ||||
|                 .send(|m| m.content("Unexpected error.").ephemeral(true)) | ||||
|                 .await | ||||
|                 .map(|_| ())?) | ||||
|         } | ||||
|     } | ||||
| } | ||||
|  | ||||
| /// Create a reminder. Press "+4 more" for other options. Use "/multiline" for multiline content. | ||||
| #[poise::command( | ||||
|     slash_command, | ||||
|     identifying_name = "remind", | ||||
|     default_member_permissions = "MANAGE_GUILD" | ||||
| )] | ||||
| pub async fn remind( | ||||
|     ctx: Context<'_>, | ||||
|     #[description = "A description of the time to set the reminder for"] time: String, | ||||
|     ctx: ApplicationContext<'_>, | ||||
|     #[description = "A description of the time to set the reminder for"] | ||||
|     #[autocomplete = "time_hint_autocomplete"] | ||||
|     time: String, | ||||
|     #[description = "The message content to send"] content: String, | ||||
|     #[description = "Channel or user mentions to set the reminder for"] channels: Option<String>, | ||||
|     #[description = "(Patreon only) Time to wait before repeating the reminder. Leave blank for one-shot reminder"] | ||||
|     interval: Option<String>, | ||||
|     #[description = "(Patreon only) For repeating reminders, the time at which the reminder will stop sending"] | ||||
|     #[description = "(Patreon only) For repeating reminders, the time at which the reminder will stop repeating"] | ||||
|     expires: Option<String>, | ||||
|     #[description = "Set the TTS flag on the reminder message, similar to the /tts command"] | ||||
|     tts: Option<bool>, | ||||
|     #[description = "Set a timezone override for this reminder only"] | ||||
|     #[autocomplete = "timezone_autocomplete"] | ||||
|     timezone: Option<String>, | ||||
| ) -> Result<(), Error> { | ||||
|     let tz = timezone.map(|t| t.parse::<Tz>().ok()).flatten(); | ||||
|  | ||||
|     create_reminder(Context::Application(ctx), time, content, channels, interval, expires, tts, tz) | ||||
|         .await | ||||
| } | ||||
|  | ||||
| async fn create_reminder( | ||||
|     ctx: Context<'_>, | ||||
|     time: String, | ||||
|     content: String, | ||||
|     channels: Option<String>, | ||||
|     interval: Option<String>, | ||||
|     expires: Option<String>, | ||||
|     tts: Option<bool>, | ||||
|     timezone: Option<Tz>, | ||||
| ) -> Result<(), Error> { | ||||
|     if interval.is_none() && expires.is_some() { | ||||
|         ctx.say("`expires` can only be used with `interval`").await?; | ||||
| @@ -572,10 +656,16 @@ pub async fn remind( | ||||
|         return Ok(()); | ||||
|     } | ||||
|  | ||||
|     ctx.defer().await?; | ||||
|     let ephemeral = | ||||
|         ctx.guild_data().await.map_or(false, |gr| gr.map_or(false, |g| g.ephemeral_confirmations)); | ||||
|     if ephemeral { | ||||
|         ctx.defer_ephemeral().await?; | ||||
|     } else { | ||||
|         ctx.defer().await?; | ||||
|     } | ||||
|  | ||||
|     let user_data = ctx.author_data().await.unwrap(); | ||||
|     let timezone = ctx.timezone().await; | ||||
|     let timezone = timezone.unwrap_or(ctx.timezone().await); | ||||
|  | ||||
|     let time = natural_parser(&time, &timezone.to_string()).await; | ||||
|  | ||||
| @@ -602,9 +692,9 @@ pub async fn remind( | ||||
|             }; | ||||
|  | ||||
|             let (processed_interval, processed_expires) = if let Some(repeat) = &interval { | ||||
|                 if check_subscription(&ctx.discord(), ctx.author().id).await | ||||
|                 if check_subscription(&ctx, ctx.author().id).await | ||||
|                     || (ctx.guild_id().is_some() | ||||
|                         && check_guild_subscription(&ctx.discord(), ctx.guild_id().unwrap()).await) | ||||
|                         && check_guild_subscription(&ctx, ctx.guild_id().unwrap()).await) | ||||
|                 { | ||||
|                     ( | ||||
|                         parse_duration(repeat) | ||||
| @@ -619,9 +709,10 @@ pub async fn remind( | ||||
|                         }, | ||||
|                     ) | ||||
|                 } else { | ||||
|                     ctx.say( | ||||
|                         "`repeat` is only available to Patreon subscribers or self-hosted users", | ||||
|                     ) | ||||
|                     ctx.send(|b| { | ||||
|                         b.content( | ||||
|                         "`repeat` is only available to Patreon subscribers or self-hosted users") | ||||
|                     }) | ||||
|                     .await?; | ||||
|  | ||||
|                     return Ok(()); | ||||
| @@ -631,13 +722,18 @@ pub async fn remind( | ||||
|             }; | ||||
|  | ||||
|             if processed_interval.is_none() && interval.is_some() { | ||||
|                 ctx.say( | ||||
|                     "Repeat interval could not be processed. Try similar to `1 hour` or `4 days`", | ||||
|                 ) | ||||
|                 ctx.send(|b| { | ||||
|                     b.content( | ||||
|                     "Repeat interval could not be processed. Try similar to `1 hour` or `4 days`") | ||||
|                 }) | ||||
|                 .await?; | ||||
|             } else if processed_expires.is_none() && expires.is_some() { | ||||
|                 ctx.say("Expiry time failed to process. Please make it as clear as possible") | ||||
|                     .await?; | ||||
|                 ctx.send(|b| { | ||||
|                     b.ephemeral(true).content( | ||||
|                         "Expiry time failed to process. Please make it as clear as possible", | ||||
|                     ) | ||||
|                 }) | ||||
|                 .await?; | ||||
|             } else { | ||||
|                 let mut builder = MultiReminderBuilder::new(&ctx, ctx.guild_id()) | ||||
|                     .author(user_data) | ||||
| @@ -694,6 +790,7 @@ pub async fn remind( | ||||
|                 } | ||||
|             } | ||||
|         } | ||||
|  | ||||
|         None => { | ||||
|             ctx.say("Time could not be processed").await?; | ||||
|         } | ||||
|   | ||||
| @@ -6,6 +6,7 @@ use crate::{ | ||||
|         ComponentDataModel, TodoSelector, | ||||
|     }, | ||||
|     consts::{EMBED_DESCRIPTION_MAX_LENGTH, SELECT_MAX_ENTRIES, THEME_COLOR}, | ||||
|     models::CtxData, | ||||
|     Context, Error, | ||||
| }; | ||||
|  | ||||
| @@ -116,6 +117,9 @@ 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 = ?), ?)", | ||||
| @@ -336,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() | ||||
|                                             } | ||||
|                                         }) | ||||
|                                 }); | ||||
|                             } | ||||
|  | ||||
|   | ||||
| @@ -2,28 +2,29 @@ pub(crate) mod pager; | ||||
|  | ||||
| use std::io::Cursor; | ||||
|  | ||||
| use base64::{engine::general_purpose, Engine}; | ||||
| use chrono_tz::Tz; | ||||
| use log::warn; | ||||
| use poise::{ | ||||
|     serenity::{ | ||||
|         builder::CreateEmbed, | ||||
|         client::Context, | ||||
|         model::{ | ||||
|             channel::Channel, | ||||
|             interactions::{ | ||||
|                 message_component::MessageComponentInteraction, InteractionResponseType, | ||||
|             }, | ||||
|             prelude::InteractionApplicationCommandCallbackDataFlags, | ||||
|         }, | ||||
|     }, | ||||
|     serenity_prelude as serenity, | ||||
|     serenity_prelude::{ | ||||
|         builder::CreateEmbed, | ||||
|         model::{ | ||||
|             application::interaction::{ | ||||
|                 message_component::MessageComponentInteraction, InteractionResponseType, | ||||
|                 MessageFlags, | ||||
|             }, | ||||
|             channel::Channel, | ||||
|         }, | ||||
|         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}, | ||||
|     }, | ||||
| @@ -51,11 +52,12 @@ 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); | ||||
| @@ -113,7 +115,7 @@ impl ComponentDataModel { | ||||
|                         char_count < EMBED_DESCRIPTION_MAX_LENGTH | ||||
|                     }) | ||||
|                     .collect::<Vec<String>>() | ||||
|                     .join("\n"); | ||||
|                     .join(""); | ||||
|  | ||||
|                 let mut embed = CreateEmbed::default(); | ||||
|                 embed | ||||
| @@ -166,10 +168,13 @@ impl ComponentDataModel { | ||||
|             ComponentDataModel::DelSelector(selector) => { | ||||
|                 let selected_id = component.data.values.join(","); | ||||
|  | ||||
|                 sqlx::query!("DELETE FROM reminders WHERE FIND_IN_SET(id, ?)", selected_id) | ||||
|                     .execute(&data.database) | ||||
|                     .await | ||||
|                     .unwrap(); | ||||
|                 sqlx::query!( | ||||
|                     "UPDATE reminders SET `status` = 'pending' WHERE FIND_IN_SET(id, ?)", | ||||
|                     selected_id | ||||
|                 ) | ||||
|                 .execute(&data.database) | ||||
|                 .await | ||||
|                 .unwrap(); | ||||
|  | ||||
|                 let reminders = Reminder::from_guild( | ||||
|                     &ctx, | ||||
| @@ -260,7 +265,7 @@ WHERE guilds.guild = ?", | ||||
|                             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") | ||||
|                                 }) | ||||
| @@ -314,7 +319,7 @@ WHERE guilds.guild = ?", | ||||
|                             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") | ||||
|                                 }) | ||||
|   | ||||
| @@ -1,7 +1,7 @@ | ||||
| // todo split pager out into a single struct | ||||
| use chrono_tz::Tz; | ||||
| use poise::serenity::{ | ||||
|     builder::CreateComponents, model::interactions::message_component::ButtonStyle, | ||||
| use poise::serenity_prelude::{ | ||||
|     builder::CreateComponents, model::application::component::ButtonStyle, | ||||
| }; | ||||
| use serde::{Deserialize, Serialize}; | ||||
| use serde_repr::*; | ||||
|   | ||||
| @@ -2,7 +2,7 @@ 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_"; | ||||
| @@ -12,22 +12,18 @@ pub const MACRO_MAX_COMMANDS: usize = 5; | ||||
|  | ||||
| use std::{collections::HashSet, env, iter::FromIterator}; | ||||
|  | ||||
| use poise::serenity::model::prelude::AttachmentType; | ||||
| use poise::serenity_prelude::model::prelude::AttachmentType; | ||||
| use regex::Regex; | ||||
|  | ||||
| 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() }) | ||||
| @@ -35,7 +31,7 @@ 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(); | ||||
|         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") | ||||
| @@ -48,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()); | ||||
| } | ||||
|   | ||||
| @@ -1,12 +1,12 @@ | ||||
| use std::{collections::HashMap, env, sync::atomic::Ordering}; | ||||
| use std::{collections::HashMap, env}; | ||||
|  | ||||
| use log::{error, info, warn}; | ||||
| use log::error; | ||||
| use poise::{ | ||||
|     serenity::{model::interactions::Interaction, utils::shard_id}, | ||||
|     serenity_prelude as serenity, | ||||
|     serenity_prelude::{model::application::interaction::Interaction, utils::shard_id}, | ||||
| }; | ||||
|  | ||||
| use crate::{component_models::ComponentDataModel, Data, Error}; | ||||
| use crate::{component_models::ComponentDataModel, Data, Error, THEME_COLOR}; | ||||
|  | ||||
| pub async fn listener( | ||||
|     ctx: &serenity::Context, | ||||
| @@ -17,45 +17,6 @@ pub async fn listener( | ||||
|         poise::Event::Ready { .. } => { | ||||
|             ctx.set_activity(serenity::Activity::watching("for /remind")).await; | ||||
|         } | ||||
|         poise::Event::CacheReady { .. } => { | ||||
|             info!("Cache Ready! Preparing extra processes"); | ||||
|  | ||||
|             if !data.is_loop_running.load(Ordering::Relaxed) { | ||||
|                 let kill_tx = data.broadcast.clone(); | ||||
|                 let kill_recv = data.broadcast.subscribe(); | ||||
|  | ||||
|                 let ctx1 = ctx.clone(); | ||||
|                 let ctx2 = ctx.clone(); | ||||
|  | ||||
|                 let pool1 = data.database.clone(); | ||||
|                 let pool2 = data.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); | ||||
|                             } | ||||
|                         }; | ||||
|                     }); | ||||
|                 } else { | ||||
|                     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"); | ||||
|                 } | ||||
|  | ||||
|                 data.is_loop_running.swap(true, Ordering::Relaxed); | ||||
|             } | ||||
|         } | ||||
|         poise::Event::ChannelDelete { channel } => { | ||||
|             sqlx::query!("DELETE FROM channels WHERE channel = ?", channel.id.as_u64()) | ||||
|                 .execute(&data.database) | ||||
| @@ -66,46 +27,36 @@ pub async fn listener( | ||||
|             if *is_new { | ||||
|                 let guild_id = guild.id.as_u64().to_owned(); | ||||
|  | ||||
|                 sqlx::query!("INSERT INTO guilds (guild) VALUES (?)", guild_id) | ||||
|                 sqlx::query!("INSERT IGNORE INTO guilds (guild) VALUES (?)", guild_id) | ||||
|                     .execute(&data.database) | ||||
|                     .await | ||||
|                     .unwrap(); | ||||
|                     .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); | ||||
|                 if let Err(e) = post_guild_count(ctx, &data.http, guild_id).await { | ||||
|                     error!("DiscordBotList: {:?}", e); | ||||
|                 } | ||||
|  | ||||
|                     let guild_count = ctx | ||||
|                         .cache | ||||
|                         .guilds() | ||||
|                         .iter() | ||||
|                         .filter(|g| { | ||||
|                             shard_id(g.as_u64().to_owned(), shard_count) == current_shard_id | ||||
|                 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) | ||||
|                             }) | ||||
|                         }) | ||||
|                         .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 response = data | ||||
|                         .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; | ||||
|  | ||||
|                     if let Err(res) = response { | ||||
|                         println!("DiscordBots Response: {:?}", res); | ||||
|                     } | ||||
|                         .await?; | ||||
|                 } | ||||
|             } | ||||
|         } | ||||
| @@ -126,3 +77,38 @@ pub async fn listener( | ||||
|  | ||||
|     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(()) | ||||
|     } | ||||
| } | ||||
|   | ||||
							
								
								
									
										67
									
								
								src/hooks.rs
									
									
									
									
									
								
							
							
						
						
									
										67
									
								
								src/hooks.rs
									
									
									
									
									
								
							| @@ -1,36 +1,42 @@ | ||||
| use poise::serenity::model::channel::Channel; | ||||
| use poise::{ | ||||
|     serenity_prelude::model::channel::Channel, ApplicationCommandOrAutocompleteInteraction, | ||||
| }; | ||||
|  | ||||
| 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 Some(guild_id) = ctx.guild_id() { | ||||
|             if ctx.command().identifying_name != "finish_macro" { | ||||
|                 let mut lock = ctx.data().recording_macros.write().await; | ||||
|         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| { | ||||
|                     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 { | ||||
|                         let recorded = RecordedCommand { | ||||
|                             action: None, | ||||
|                             command_name: ctx.command().identifying_name.clone(), | ||||
|                             options: Vec::from(app_ctx.args), | ||||
|                         }; | ||||
|                         } else { | ||||
|                             let recorded = RecordedCommand { | ||||
|                                 action: None, | ||||
|                                 command_name: ctx.command().identifying_name.clone(), | ||||
|                                 options: Vec::from(app_ctx.args), | ||||
|                             }; | ||||
|  | ||||
|                         command_macro.commands.push(recorded); | ||||
|                             command_macro.commands.push(recorded); | ||||
|  | ||||
|                         let _ = ctx | ||||
|                             .send(|m| m.ephemeral(true).content("Command recorded to macro")) | ||||
|                             .await; | ||||
|                             let _ = ctx | ||||
|                                 .send(|m| m.ephemeral(true).content("Command recorded to macro")) | ||||
|                                 .await; | ||||
|                         } | ||||
|  | ||||
|                         return false; | ||||
|                     } | ||||
|  | ||||
|                     return false; | ||||
|                 } | ||||
|             } | ||||
|         } | ||||
| @@ -41,25 +47,26 @@ async fn macro_check(ctx: Context<'_>) -> bool { | ||||
|  | ||||
| async fn check_self_permissions(ctx: Context<'_>) -> bool { | ||||
|     if let Some(guild) = ctx.guild() { | ||||
|         let user_id = ctx.discord().cache.current_user_id(); | ||||
|         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 manage_webhooks = guild | ||||
|             .member_permissions(&ctx.discord(), user_id) | ||||
|             .await | ||||
|             .map_or(false, |p| p.manage_webhooks()); | ||||
|         let (view_channel, send_messages, embed_links) = ctx | ||||
|             .channel_id() | ||||
|             .to_channel_cached(&ctx.discord()) | ||||
|             .to_channel(&ctx) | ||||
|             .await | ||||
|             .ok() | ||||
|             .and_then(|c| { | ||||
|                 if let Channel::Guild(channel) = c { | ||||
|                     channel.permissions_for_user(&ctx.discord(), 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 | ||||
|                 } | ||||
|             }) | ||||
|             .map_or((false, false, false), |p| { | ||||
|                 (p.view_channel(), p.send_messages(), p.embed_links()) | ||||
|             }); | ||||
|             .unwrap_or((false, false, false)); | ||||
|  | ||||
|         if manage_webhooks && send_messages && embed_links { | ||||
|             true | ||||
| @@ -75,8 +82,8 @@ async fn check_self_permissions(ctx: Context<'_>) -> bool { | ||||
| {}     **Manage Webhooks**", | ||||
|                         if view_channel { "✅" } else { "❌" }, | ||||
|                         if send_messages { "✅" } else { "❌" }, | ||||
|                         if manage_webhooks { "✅" } else { "❌" }, | ||||
|                         if embed_links { "✅" } else { "❌" }, | ||||
|                         if manage_webhooks { "✅" } else { "❌" }, | ||||
|                     )) | ||||
|                 }) | ||||
|                 .await; | ||||
|   | ||||
| @@ -110,13 +110,14 @@ impl OverflowOp for u64 { | ||||
| #[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), | ||||
|     current: (u64, u64, u64, u64), | ||||
| } | ||||
|  | ||||
| impl<'a> Parser<'a> { | ||||
| @@ -140,17 +141,17 @@ impl<'a> Parser<'a> { | ||||
|         Ok(None) | ||||
|     } | ||||
|     fn parse_unit(&mut self, n: u64, start: usize, end: usize) -> Result<(), Error> { | ||||
|         let (mut month, mut sec, nsec) = match &self.src[start..end] { | ||||
|             "nanos" | "nsec" | "ns" => (0u64, 0u64, n), | ||||
|             "usec" | "us" => (0, 0u64, n.mul(1000)?), | ||||
|             "millis" | "msec" | "ms" => (0, 0u64, n.mul(1_000_000)?), | ||||
|             "seconds" | "second" | "secs" | "sec" | "s" => (0, n, 0), | ||||
|             "minutes" | "minute" | "min" | "mins" | "m" => (0, n.mul(60)?, 0), | ||||
|             "hours" | "hour" | "hr" | "hrs" | "h" => (0, n.mul(3600)?, 0), | ||||
|             "days" | "day" | "d" => (0, n.mul(86400)?, 0), | ||||
|             "weeks" | "week" | "w" => (0, n.mul(86400 * 7)?, 0), | ||||
|             "months" | "month" | "M" => (n, 0, 0), | ||||
|             "years" | "year" | "y" => (12, 0, 0), | ||||
|         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" | "M" => (n, 0, 0, 0), | ||||
|             "years" | "year" | "y" => (n.mul(12)?, 0, 0, 0), | ||||
|             _ => { | ||||
|                 return Err(Error::UnknownUnit { | ||||
|                     start, | ||||
| @@ -160,15 +161,16 @@ impl<'a> Parser<'a> { | ||||
|                 }); | ||||
|             } | ||||
|         }; | ||||
|         let mut nsec = self.current.2 + nsec; | ||||
|         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.1; | ||||
|         sec += self.current.2; | ||||
|         day += self.current.1; | ||||
|         month += self.current.0; | ||||
|  | ||||
|         self.current = (month, sec, nsec); | ||||
|         self.current = (month, day, sec, nsec); | ||||
|  | ||||
|         Ok(()) | ||||
|     } | ||||
| @@ -215,7 +217,13 @@ impl<'a> Parser<'a> { | ||||
|             self.parse_unit(n, start, off)?; | ||||
|             n = match self.parse_first_char()? { | ||||
|                 Some(n) => n, | ||||
|                 None => return Ok(Interval { month: self.current.0, sec: self.current.1 }), | ||||
|                 None => { | ||||
|                     return Ok(Interval { | ||||
|                         month: self.current.0, | ||||
|                         day: self.current.1, | ||||
|                         sec: self.current.2, | ||||
|                     }) | ||||
|                 } | ||||
|             }; | ||||
|         } | ||||
|     } | ||||
| @@ -247,5 +255,73 @@ impl<'a> Parser<'a> { | ||||
| /// assert_eq!(parse_duration("32ms"), Ok(Duration::new(0, 32_000_000))); | ||||
| /// ``` | ||||
| pub fn parse_duration(s: &str) -> Result<Interval, Error> { | ||||
|     Parser { iter: s.chars(), src: s, current: (0, 0, 0) }.parse() | ||||
|     Parser { iter: s.chars(), src: s, 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); | ||||
|     } | ||||
| } | ||||
|   | ||||
							
								
								
									
										126
									
								
								src/main.rs
									
									
									
									
									
								
							
							
						
						
									
										126
									
								
								src/main.rs
									
									
									
									
									
								
							| @@ -18,12 +18,12 @@ use std::{ | ||||
|     env, | ||||
|     error::Error as StdError, | ||||
|     fmt::{Debug, Display, Formatter}, | ||||
|     sync::atomic::AtomicBool, | ||||
|     path::Path, | ||||
| }; | ||||
|  | ||||
| use chrono_tz::Tz; | ||||
| use dotenv::dotenv; | ||||
| use poise::serenity::model::{ | ||||
| use log::{error, warn}; | ||||
| use poise::serenity_prelude::model::{ | ||||
|     gateway::GatewayIntents, | ||||
|     id::{GuildId, UserId}, | ||||
| }; | ||||
| @@ -31,7 +31,7 @@ use sqlx::{MySql, Pool}; | ||||
| use tokio::sync::{broadcast, broadcast::Sender, RwLock}; | ||||
|  | ||||
| use crate::{ | ||||
|     commands::{info_cmds, moderation_cmds, reminder_cmds, todo_cmds}, | ||||
|     commands::{command_macro, info_cmds, moderation_cmds, reminder_cmds, todo_cmds}, | ||||
|     consts::THEME_COLOR, | ||||
|     event_handlers::listener, | ||||
|     hooks::all_checks, | ||||
| @@ -43,14 +43,14 @@ type Database = MySql; | ||||
|  | ||||
| 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>, | ||||
|     is_loop_running: AtomicBool, | ||||
|     broadcast: Sender<()>, | ||||
|     _broadcast: Sender<()>, | ||||
| } | ||||
|  | ||||
| impl Debug for Data { | ||||
| @@ -75,7 +75,7 @@ impl Display for Ended { | ||||
|  | ||||
| impl StdError for Ended {} | ||||
|  | ||||
| #[tokio::main] | ||||
| #[tokio::main(flavor = "multi_thread")] | ||||
| async fn main() -> Result<(), Box<dyn StdError + Send + Sync>> { | ||||
|     let (tx, mut rx) = broadcast::channel(16); | ||||
|  | ||||
| @@ -88,7 +88,11 @@ async fn main() -> Result<(), 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 discord_token = env::var("DISCORD_TOKEN").expect("Missing DISCORD_TOKEN from environment"); | ||||
|  | ||||
| @@ -103,13 +107,32 @@ async fn _main(tx: Sender<()>) -> Result<(), Box<dyn StdError + Send + Sync>> { | ||||
|             moderation_cmds::timezone(), | ||||
|             poise::Command { | ||||
|                 subcommands: vec![ | ||||
|                     moderation_cmds::delete_macro(), | ||||
|                     moderation_cmds::finish_macro(), | ||||
|                     moderation_cmds::list_macro(), | ||||
|                     moderation_cmds::record_macro(), | ||||
|                     moderation_cmds::run_macro(), | ||||
|                     moderation_cmds::set_allowed_dm(), | ||||
|                     moderation_cmds::unset_allowed_dm(), | ||||
|                 ], | ||||
|                 ..moderation_cmds::macro_base() | ||||
|                 ..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(), | ||||
| @@ -124,6 +147,7 @@ async fn _main(tx: Sender<()>) -> Result<(), Box<dyn StdError + Send + Sync>> { | ||||
|                 ], | ||||
|                 ..reminder_cmds::timer_base() | ||||
|             }, | ||||
|             reminder_cmds::multiline(), | ||||
|             reminder_cmds::remind(), | ||||
|             poise::Command { | ||||
|                 subcommands: vec![ | ||||
| @@ -151,15 +175,36 @@ async fn _main(tx: Sender<()>) -> Result<(), Box<dyn StdError + Send + Sync>> { | ||||
|         ], | ||||
|         allowed_mentions: None, | ||||
|         command_check: Some(|ctx| Box::pin(all_checks(ctx))), | ||||
|         listener: |ctx, event, _framework, data| Box::pin(listener(ctx, event, data)), | ||||
|         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 database = | ||||
|         Pool::connect(&env::var("DATABASE_URL").expect("No database URL provided")).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(&database) | ||||
|     .await | ||||
| @@ -168,27 +213,50 @@ async fn _main(tx: Sender<()>) -> Result<(), Box<dyn StdError + Send + Sync>> { | ||||
|     .map(|t| t.timezone.parse::<Tz>().unwrap()) | ||||
|     .collect::<Vec<Tz>>(); | ||||
|  | ||||
|     poise::Framework::build() | ||||
|     poise::Framework::builder() | ||||
|         .token(discord_token) | ||||
|         .user_data_setup(move |ctx, _bot, framework| { | ||||
|         .setup(move |ctx, _bot, framework| { | ||||
|             Box::pin(async move { | ||||
|                 register_application_commands( | ||||
|                     ctx, | ||||
|                     framework, | ||||
|                     env::var("DEBUG_GUILD") | ||||
|                         .map(|inner| GuildId(inner.parse().expect("DEBUG_GUILD not valid"))) | ||||
|                         .ok(), | ||||
|                 ) | ||||
|                 .await | ||||
|                 .unwrap(); | ||||
|                 register_application_commands(ctx, framework, None).await.unwrap(); | ||||
|  | ||||
|                 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); | ||||
|                             } | ||||
|                         }; | ||||
|                     }); | ||||
|                 } else { | ||||
|                     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(), | ||||
|                     is_loop_running: AtomicBool::new(false), | ||||
|                     broadcast: tx, | ||||
|                     _broadcast: tx, | ||||
|                 }) | ||||
|             }) | ||||
|         }) | ||||
|   | ||||
| @@ -1,5 +1,5 @@ | ||||
| use chrono::NaiveDateTime; | ||||
| use poise::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,7 +1,8 @@ | ||||
| use poise::serenity::model::{ | ||||
|     id::GuildId, interactions::application_command::ApplicationCommandInteractionDataOption, | ||||
| use poise::serenity_prelude::model::{ | ||||
|     application::interaction::application_command::CommandDataOption, id::GuildId, | ||||
| }; | ||||
| use serde::{Deserialize, Serialize}; | ||||
| use serde_json::Value; | ||||
|  | ||||
| use crate::{Context, Data, Error}; | ||||
|  | ||||
| @@ -19,7 +20,7 @@ pub struct RecordedCommand<U, E> { | ||||
|     #[serde(default = "default_none::<U, E>")] | ||||
|     pub action: Option<Func<U, E>>, | ||||
|     pub command_name: String, | ||||
|     pub options: Vec<ApplicationCommandInteractionDataOption>, | ||||
|     pub options: Vec<CommandDataOption>, | ||||
| } | ||||
|  | ||||
| pub struct CommandMacro<U, E> { | ||||
| @@ -29,6 +30,13 @@ pub struct CommandMacro<U, E> { | ||||
|     pub commands: Vec<RecordedCommand<U, E>>, | ||||
| } | ||||
|  | ||||
| pub struct RawCommandMacro { | ||||
|     pub guild_id: GuildId, | ||||
|     pub name: String, | ||||
|     pub description: Option<String>, | ||||
|     pub commands: Value, | ||||
| } | ||||
|  | ||||
| pub async fn guild_command_macro( | ||||
|     ctx: &Context<'_>, | ||||
|     name: &str, | ||||
|   | ||||
							
								
								
									
										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,14 +1,15 @@ | ||||
| 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 poise::serenity::{async_trait, model::id::UserId}; | ||||
| use poise::serenity_prelude::{async_trait, model::id::UserId, ChannelType}; | ||||
|  | ||||
| use crate::{ | ||||
|     models::{channel_data::ChannelData, user_data::UserData}, | ||||
|     models::{channel_data::ChannelData, guild_data::GuildData, user_data::UserData}, | ||||
|     CommandMacro, Context, Data, Error, GuildId, | ||||
| }; | ||||
|  | ||||
| @@ -18,6 +19,8 @@ pub trait CtxData { | ||||
|  | ||||
|     async fn author_data(&self) -> Result<UserData, Error>; | ||||
|  | ||||
|     async fn guild_data(&self) -> Option<Result<GuildData, Error>>; | ||||
|  | ||||
|     async fn timezone(&self) -> Tz; | ||||
|  | ||||
|     async fn channel_data(&self) -> Result<ChannelData, Error>; | ||||
| @@ -27,15 +30,21 @@ pub trait CtxData { | ||||
|  | ||||
| #[async_trait] | ||||
| impl CtxData for Context<'_> { | ||||
|     async fn user_data<U: Into<UserId> + Send>( | ||||
|         &self, | ||||
|         user_id: U, | ||||
|     ) -> Result<UserData, Box<dyn std::error::Error + Sync + Send>> { | ||||
|         UserData::from_user(user_id, &self.discord(), &self.data().database).await | ||||
|     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, Box<dyn std::error::Error + Sync + Send>> { | ||||
|         UserData::from_user(&self.author().id, &self.discord(), &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 { | ||||
| @@ -43,7 +52,20 @@ impl CtxData for Context<'_> { | ||||
|     } | ||||
|  | ||||
|     async fn channel_data(&self) -> Result<ChannelData, Box<dyn std::error::Error + Sync + Send>> { | ||||
|         let channel = self.channel_id().to_channel_cached(&self.discord()).unwrap(); | ||||
|         // 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 | ||||
|     } | ||||
|   | ||||
| @@ -2,14 +2,14 @@ use std::{collections::HashSet, fmt::Display}; | ||||
|  | ||||
| use chrono::{Duration, NaiveDateTime, Utc}; | ||||
| use chrono_tz::Tz; | ||||
| use poise::serenity::{ | ||||
| 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; | ||||
|  | ||||
| @@ -51,9 +51,11 @@ pub struct ReminderBuilder { | ||||
|     pool: MySqlPool, | ||||
|     uid: String, | ||||
|     channel: u32, | ||||
|     thread_id: Option<u64>, | ||||
|     utc_time: NaiveDateTime, | ||||
|     timezone: String, | ||||
|     interval_secs: Option<i64>, | ||||
|     interval_seconds: Option<i64>, | ||||
|     interval_days: Option<i64>, | ||||
|     interval_months: Option<i64>, | ||||
|     expires: Option<NaiveDateTime>, | ||||
|     content: String, | ||||
| @@ -87,6 +89,7 @@ INSERT INTO reminders ( | ||||
|     `utc_time`, | ||||
|     `timezone`, | ||||
|     `interval_seconds`, | ||||
|     `interval_days`, | ||||
|     `interval_months`, | ||||
|     `expires`, | ||||
|     `content`, | ||||
| @@ -106,6 +109,7 @@ INSERT INTO reminders ( | ||||
|     ?, | ||||
|     ?, | ||||
|     ?, | ||||
|     ?, | ||||
|     ? | ||||
| ) | ||||
|             ", | ||||
| @@ -113,7 +117,8 @@ INSERT INTO reminders ( | ||||
|                         self.channel, | ||||
|                         utc_time, | ||||
|                         self.timezone, | ||||
|                         self.interval_secs, | ||||
|                         self.interval_seconds, | ||||
|                         self.interval_days, | ||||
|                         self.interval_months, | ||||
|                         self.expires, | ||||
|                         self.content, | ||||
| @@ -175,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 | ||||
|     } | ||||
| @@ -212,27 +215,37 @@ impl<'a> MultiReminderBuilder<'a> { | ||||
|  | ||||
|         let mut ok_locs = HashSet::new(); | ||||
|  | ||||
|         if self.interval.map_or(false, |i| ((i.sec + i.month * 30 * DAY) 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.sec + i.month * 30 * DAY) 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.discord()).await { | ||||
|                         if let Ok(user) = UserId(user_id).to_user(&self.ctx).await { | ||||
|                             let user_data = UserData::from_user( | ||||
|                                 &user, | ||||
|                                 &self.ctx.discord(), | ||||
|                                 &self.ctx.serenity_context(), | ||||
|                                 &self.ctx.data().database, | ||||
|                             ) | ||||
|                             .await | ||||
|                             .unwrap(); | ||||
|  | ||||
|                             if let Some(guild_id) = self.guild_id { | ||||
|                                 if guild_id.member(&self.ctx.discord(), user).await.is_err() { | ||||
|                                 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) | ||||
|                                 } | ||||
| @@ -244,27 +257,36 @@ impl<'a> MultiReminderBuilder<'a> { | ||||
|                         } | ||||
|                     } | ||||
|                     ReminderScope::Channel(channel_id) => { | ||||
|                         let channel = | ||||
|                             ChannelId(channel_id).to_channel(&self.ctx.discord()).await.unwrap(); | ||||
|                         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, &self.ctx.data().database) | ||||
|                                 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() | ||||
|                                 { | ||||
|                                     match create_webhook( | ||||
|                                         &self.ctx.discord(), | ||||
|                                         guild_channel, | ||||
|                                         "Reminder", | ||||
|                                     ) | ||||
|                                     .await | ||||
|                                     match create_webhook(&self.ctx, guild_channel, "Reminder").await | ||||
|                                     { | ||||
|                                         Ok(webhook) => { | ||||
|                                             channel_data.webhook_id = | ||||
| @@ -296,9 +318,11 @@ impl<'a> MultiReminderBuilder<'a> { | ||||
|                             pool: self.ctx.data().database.clone(), | ||||
|                             uid: generate_uid(), | ||||
|                             channel: c, | ||||
|                             thread_id, | ||||
|                             utc_time: self.utc_time, | ||||
|                             timezone: self.timezone.to_string(), | ||||
|                             interval_secs: self.interval.map(|i| i.sec as i64), | ||||
|                             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(), | ||||
|   | ||||
| @@ -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,4 +1,4 @@ | ||||
| use poise::serenity::model::id::ChannelId; | ||||
| use poise::serenity_prelude::model::id::ChannelId; | ||||
| use serde::{Deserialize, Serialize}; | ||||
| use serde_repr::*; | ||||
|  | ||||
|   | ||||
| @@ -6,11 +6,11 @@ pub mod look_flags; | ||||
|  | ||||
| use std::hash::{Hash, Hasher}; | ||||
|  | ||||
| use chrono::{NaiveDateTime, TimeZone}; | ||||
| use chrono::{DateTime, NaiveDateTime, Utc}; | ||||
| use chrono_tz::Tz; | ||||
| use poise::{ | ||||
|     serenity::model::id::{ChannelId, GuildId, UserId}, | ||||
|     serenity_prelude::Cache, | ||||
| use poise::serenity_prelude::{ | ||||
|     model::id::{ChannelId, GuildId, UserId}, | ||||
|     Cache, | ||||
| }; | ||||
| use sqlx::Executor; | ||||
|  | ||||
| @@ -24,8 +24,9 @@ pub struct Reminder { | ||||
|     pub id: u32, | ||||
|     pub uid: String, | ||||
|     pub channel: u64, | ||||
|     pub utc_time: NaiveDateTime, | ||||
|     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, | ||||
| @@ -59,6 +60,7 @@ SELECT | ||||
|     channels.channel, | ||||
|     reminders.utc_time, | ||||
|     reminders.interval_seconds, | ||||
|     reminders.interval_days, | ||||
|     reminders.interval_months, | ||||
|     reminders.expires, | ||||
|     reminders.enabled, | ||||
| @@ -95,6 +97,7 @@ SELECT | ||||
|     channels.channel, | ||||
|     reminders.utc_time, | ||||
|     reminders.interval_seconds, | ||||
|     reminders.interval_days, | ||||
|     reminders.interval_months, | ||||
|     reminders.expires, | ||||
|     reminders.enabled, | ||||
| @@ -138,6 +141,7 @@ SELECT | ||||
|     channels.channel, | ||||
|     reminders.utc_time, | ||||
|     reminders.interval_seconds, | ||||
|     reminders.interval_days, | ||||
|     reminders.interval_months, | ||||
|     reminders.expires, | ||||
|     reminders.enabled, | ||||
| @@ -155,6 +159,7 @@ LEFT JOIN | ||||
| ON | ||||
|     reminders.set_by = users.id | ||||
| WHERE | ||||
|     `status` = 'pending' AND | ||||
|     channels.channel = ? AND | ||||
|     FIND_IN_SET(reminders.enabled, ?) | ||||
| ORDER BY | ||||
| @@ -195,6 +200,7 @@ SELECT | ||||
|     channels.channel, | ||||
|     reminders.utc_time, | ||||
|     reminders.interval_seconds, | ||||
|     reminders.interval_days, | ||||
|     reminders.interval_months, | ||||
|     reminders.expires, | ||||
|     reminders.enabled, | ||||
| @@ -212,6 +218,7 @@ LEFT JOIN | ||||
| ON | ||||
|     reminders.set_by = users.id | ||||
| WHERE | ||||
|     `status` = 'pending' AND | ||||
|     FIND_IN_SET(channels.channel, ?) | ||||
|                 ", | ||||
|                     channels | ||||
| @@ -228,6 +235,7 @@ SELECT | ||||
|     channels.channel, | ||||
|     reminders.utc_time, | ||||
|     reminders.interval_seconds, | ||||
|     reminders.interval_days, | ||||
|     reminders.interval_months, | ||||
|     reminders.expires, | ||||
|     reminders.enabled, | ||||
| @@ -245,6 +253,7 @@ 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() | ||||
| @@ -262,6 +271,7 @@ SELECT | ||||
|     channels.channel, | ||||
|     reminders.utc_time, | ||||
|     reminders.interval_seconds, | ||||
|     reminders.interval_days, | ||||
|     reminders.interval_months, | ||||
|     reminders.expires, | ||||
|     reminders.enabled, | ||||
| @@ -279,6 +289,7 @@ LEFT JOIN | ||||
| ON | ||||
|     reminders.set_by = users.id | ||||
| WHERE | ||||
|     `status` = 'pending' AND | ||||
|     channels.id = (SELECT dm_channel FROM users WHERE user = ?) | ||||
|             ", | ||||
|                 user.as_u64() | ||||
| @@ -293,7 +304,10 @@ WHERE | ||||
|         &self, | ||||
|         db: impl Executor<'_, Database = Database>, | ||||
|     ) -> Result<(), sqlx::Error> { | ||||
|         sqlx::query!("DELETE FROM reminders WHERE uid = ?", self.uid).execute(db).await.map(|_| ()) | ||||
|         sqlx::query!("UPDATE reminders SET `status` = 'deleted' WHERE uid = ?", self.uid) | ||||
|             .execute(db) | ||||
|             .await | ||||
|             .map(|_| ()) | ||||
|     } | ||||
|  | ||||
|     pub fn display_content(&self) -> &str { | ||||
| @@ -310,30 +324,32 @@ WHERE | ||||
|             count + 1, | ||||
|             self.display_content(), | ||||
|             self.channel, | ||||
|             timezone.timestamp(self.utc_time.timestamp(), 0).format("%Y-%m-%d %H:%M:%S") | ||||
|             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 self.interval_seconds.is_some() || self.interval_months.is_some() { | ||||
|         if self.interval_seconds.is_some() | ||||
|             || self.interval_days.is_some() | ||||
|             || self.interval_months.is_some() | ||||
|         { | ||||
|             format!( | ||||
|                 "'{}' *occurs next at* **{}**, repeating (set by {})", | ||||
|                 "'{}' *occurs next at* **{}**, repeating (set by {})\n", | ||||
|                 self.display_content(), | ||||
|                 time_display, | ||||
|                 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,6 +1,6 @@ | ||||
| use chrono_tz::Tz; | ||||
| use log::error; | ||||
| use poise::serenity::{http::CacheHttp, model::id::UserId}; | ||||
| use poise::serenity_prelude::{http::CacheHttp, model::id::UserId}; | ||||
| use sqlx::MySqlPool; | ||||
|  | ||||
| use crate::consts::LOCAL_TIMEZONE; | ||||
| @@ -10,6 +10,7 @@ pub struct UserData { | ||||
|     pub user: u64, | ||||
|     pub dm_channel: u32, | ||||
|     pub timezone: String, | ||||
|     pub allowed_dm: bool, | ||||
| } | ||||
|  | ||||
| impl UserData { | ||||
| @@ -21,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 | ||||
|         ) | ||||
| @@ -46,7 +47,7 @@ SELECT timezone FROM users WHERE user = ? | ||||
|         match sqlx::query_as_unchecked!( | ||||
|             Self, | ||||
|             " | ||||
| SELECT id, user, 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.0 | ||||
| @@ -83,7 +84,7 @@ INSERT INTO users (name, user, dm_channel, timezone) VALUES ('', ?, (SELECT id F | ||||
|                 Ok(sqlx::query_as_unchecked!( | ||||
|                     Self, | ||||
|                     " | ||||
| SELECT id, user, dm_channel, timezone FROM users WHERE user = ? | ||||
| SELECT id, user, dm_channel, timezone, allowed_dm FROM users WHERE user = ? | ||||
|                     ", | ||||
|                     user_id.0 | ||||
|                 ) | ||||
| @@ -102,9 +103,10 @@ SELECT id, user, dm_channel, timezone FROM users WHERE user = ? | ||||
|     pub async fn commit_changes(&self, pool: &MySqlPool) { | ||||
|         sqlx::query!( | ||||
|             " | ||||
| UPDATE users SET timezone = ? WHERE id = ? | ||||
| UPDATE users SET timezone = ?, allowed_dm = ? WHERE id = ? | ||||
|             ", | ||||
|             self.timezone, | ||||
|             self.allowed_dm, | ||||
|             self.id | ||||
|         ) | ||||
|         .execute(pool) | ||||
|   | ||||
							
								
								
									
										15
									
								
								src/utils.rs
									
									
									
									
									
								
							
							
						
						
									
										15
									
								
								src/utils.rs
									
									
									
									
									
								
							| @@ -1,10 +1,11 @@ | ||||
| use poise::{ | ||||
|     serenity::{ | ||||
|     serenity_prelude as serenity, | ||||
|     serenity_prelude::{ | ||||
|         builder::CreateApplicationCommands, | ||||
|         http::CacheHttp, | ||||
|         interaction::MessageFlags, | ||||
|         model::id::{GuildId, UserId}, | ||||
|     }, | ||||
|     serenity_prelude as serenity, | ||||
| }; | ||||
|  | ||||
| use crate::{ | ||||
| @@ -13,10 +14,10 @@ use crate::{ | ||||
| }; | ||||
|  | ||||
| pub async fn register_application_commands( | ||||
|     ctx: &poise::serenity::client::Context, | ||||
|     ctx: &serenity::Context, | ||||
|     framework: &poise::Framework<Data, Error>, | ||||
|     guild_id: Option<GuildId>, | ||||
| ) -> Result<(), poise::serenity::Error> { | ||||
| ) -> Result<(), serenity::Error> { | ||||
|     let mut commands_builder = CreateApplicationCommands::default(); | ||||
|     let commands = &framework.options().commands; | ||||
|     for command in commands { | ||||
| @@ -27,7 +28,7 @@ pub async fn register_application_commands( | ||||
|             commands_builder.add_application_command(context_menu_command); | ||||
|         } | ||||
|     } | ||||
|     let commands_builder = poise::serenity::json::Value::Array(commands_builder.0); | ||||
|     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?; | ||||
| @@ -82,7 +83,7 @@ pub fn send_as_initial_response( | ||||
|         components, | ||||
|         ephemeral, | ||||
|         allowed_mentions, | ||||
|         reference_message: _, // can't reply to a message in interactions | ||||
|         reply: _, | ||||
|     } = data; | ||||
|  | ||||
|     if let Some(content) = content { | ||||
| @@ -102,6 +103,6 @@ pub fn send_as_initial_response( | ||||
|         }); | ||||
|     } | ||||
|     if ephemeral { | ||||
|         f.flags(serenity::InteractionApplicationCommandCallbackDataFlags::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 | ||||
| @@ -12,10 +12,10 @@ oauth2 = "4" | ||||
| log = "0.4" | ||||
| reqwest = "0.11" | ||||
| serde = { version = "1.0", features = ["derive"] } | ||||
| serde_json = "1.0" | ||||
| sqlx = { version = "0.5", features = ["runtime-tokio-rustls", "macros", "mysql", "chrono", "json"] } | ||||
| sqlx = { version = "0.6", features = ["runtime-tokio-rustls", "macros", "mysql", "chrono", "json"] } | ||||
| chrono = "0.4" | ||||
| chrono-tz = "0.5" | ||||
| lazy_static = "1.4.0" | ||||
| rand = "0.7" | ||||
| base64 = "0.13" | ||||
| csv = "1.1" | ||||
|   | ||||
| @@ -2,6 +2,7 @@ pub const DISCORD_OAUTH_TOKEN: &'static str = "https://discord.com/api/oauth2/to | ||||
| 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; | ||||
| @@ -26,16 +27,12 @@ use serenity::model::prelude::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 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() }) | ||||
| @@ -43,7 +40,7 @@ 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(); | ||||
|         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()) | ||||
|   | ||||
| @@ -6,7 +6,7 @@ mod consts; | ||||
| mod macros; | ||||
| mod routes; | ||||
|  | ||||
| use std::{collections::HashMap, env}; | ||||
| use std::{collections::HashMap, env, path::Path}; | ||||
|  | ||||
| use oauth2::{basic::BasicClient, AuthUrl, ClientId, ClientSecret, RedirectUrl, TokenUrl}; | ||||
| use rocket::{ | ||||
| @@ -75,7 +75,7 @@ pub async fn initialize( | ||||
|     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' not supplied"); | ||||
|     env::var("PATREON_GUILD_ID").expect("`PATREON_GUILD_ID' not supplied"); | ||||
|     info!("Done!"); | ||||
|  | ||||
|     let oauth2_client = BasicClient::new( | ||||
| @@ -88,6 +88,9 @@ pub async fn initialize( | ||||
|  | ||||
|     let reqwest_client = reqwest::Client::new(); | ||||
|  | ||||
|     let static_path = | ||||
|         if Path::new("web/static").exists() { "web/static" } else { "/lib/reminder-rs/static" }; | ||||
|  | ||||
|     rocket::build() | ||||
|         .attach(Template::fairing()) | ||||
|         .register( | ||||
| @@ -105,7 +108,7 @@ pub async fn initialize( | ||||
|         .manage(reqwest_client) | ||||
|         .manage(serenity_context) | ||||
|         .manage(db_pool) | ||||
|         .mount("/static", FileServer::from(concat!(env!("CARGO_MANIFEST_DIR"), "/static"))) | ||||
|         .mount("/static", FileServer::from(static_path)) | ||||
|         .mount( | ||||
|             "/", | ||||
|             routes![ | ||||
| @@ -113,7 +116,8 @@ pub async fn initialize( | ||||
|                 routes::cookies, | ||||
|                 routes::privacy, | ||||
|                 routes::terms, | ||||
|                 routes::return_to_same_site | ||||
|                 routes::return_to_same_site, | ||||
|                 routes::report::report_error, | ||||
|             ], | ||||
|         ) | ||||
|         .mount( | ||||
| @@ -131,7 +135,14 @@ pub async fn initialize( | ||||
|                 routes::help_iemanager, | ||||
|             ], | ||||
|         ) | ||||
|         .mount("/login", routes![routes::login::discord_login, routes::login::discord_callback]) | ||||
|         .mount( | ||||
|             "/login", | ||||
|             routes![ | ||||
|                 routes::login::discord_login, | ||||
|                 routes::login::discord_logout, | ||||
|                 routes::login::discord_callback | ||||
|             ], | ||||
|         ) | ||||
|         .mount( | ||||
|             "/dashboard", | ||||
|             routes![ | ||||
| @@ -146,12 +157,18 @@ pub async fn initialize( | ||||
|                 routes::dashboard::guild::get_reminder_templates, | ||||
|                 routes::dashboard::guild::create_reminder_template, | ||||
|                 routes::dashboard::guild::delete_reminder_template, | ||||
|                 routes::dashboard::guild::create_reminder, | ||||
|                 routes::dashboard::guild::create_guild_reminder, | ||||
|                 routes::dashboard::guild::get_reminders, | ||||
|                 routes::dashboard::guild::edit_reminder, | ||||
|                 routes::dashboard::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?; | ||||
|  | ||||
|   | ||||
| @@ -1,7 +1,7 @@ | ||||
| macro_rules! check_length { | ||||
|     ($max:ident, $field:expr) => { | ||||
|         if $field.len() > $max { | ||||
|             return json!({ "error": format!("{} exceeded", stringify!($max)) }); | ||||
|             return Err(json!({ "error": format!("{} exceeded", stringify!($max)) })); | ||||
|         } | ||||
|     }; | ||||
|     ($max:ident, $field:expr, $($fields:expr),+) => { | ||||
| @@ -25,7 +25,7 @@ macro_rules! check_length_opt { | ||||
| macro_rules! check_url { | ||||
|     ($field:expr) => { | ||||
|         if !($field.starts_with("http://") || $field.starts_with("https://")) { | ||||
|             return json!({ "error": "URL invalid" }); | ||||
|             return Err(json!({ "error": "URL invalid" })); | ||||
|         } | ||||
|     }; | ||||
|     ($field:expr, $($fields:expr),+) => { | ||||
| @@ -56,25 +56,39 @@ macro_rules! check_authorization { | ||||
|             Some(user_id) => { | ||||
|                 match GuildId($guild).to_guild_cached($ctx) { | ||||
|                     Some(guild) => { | ||||
|                         let member = guild.member($ctx, UserId(user_id)).await; | ||||
|                         let member_res = guild.member($ctx, UserId(user_id)).await; | ||||
|  | ||||
|                         match member { | ||||
|                         match member_res { | ||||
|                             Err(_) => { | ||||
|                                 return json!({"error": "User not in guild"}) | ||||
|                                 return Err(json!({"error": "User not in guild"})); | ||||
|                             } | ||||
|  | ||||
|                             Ok(_) => {} | ||||
|                             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 json!({"error": "Bot not in guild"}) | ||||
|                         return Err(json!({"error": "Bot not in guild"})); | ||||
|                     } | ||||
|                 } | ||||
|             } | ||||
|  | ||||
|             None => { | ||||
|                 return json!({"error": "User not authorized"}); | ||||
|                 return Err(json!({"error": "User not authorized"})); | ||||
|             } | ||||
|         } | ||||
|     } | ||||
| @@ -117,3 +131,9 @@ macro_rules! update_field { | ||||
|         update_field!($pool, $error, $reminder.[$($fields),+]); | ||||
|     }; | ||||
| } | ||||
|  | ||||
| macro_rules! json_err { | ||||
|     ($message:expr) => { | ||||
|         Err(json!({ "error": $message })) | ||||
|     }; | ||||
| } | ||||
|   | ||||
							
								
								
									
										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, | ||||
|         } | ||||
|     })) | ||||
| } | ||||
							
								
								
									
										428
									
								
								web/src/routes/dashboard/export.rs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										428
									
								
								web/src/routes/dashboard/export.rs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,428 @@ | ||||
| use csv::{QuoteStyle, WriterBuilder}; | ||||
| use rocket::{ | ||||
|     http::CookieJar, | ||||
|     serde::json::{json, serde_json, Json}, | ||||
|     State, | ||||
| }; | ||||
| use serenity::{ | ||||
|     client::Context, | ||||
|     model::id::{ChannelId, GuildId}, | ||||
| }; | ||||
| use sqlx::{MySql, Pool}; | ||||
|  | ||||
| use crate::routes::{ | ||||
|     dashboard::{ | ||||
|         create_reminder, generate_uid, ImportBody, Reminder, ReminderCsv, ReminderTemplateCsv, | ||||
|         TodoCsv, | ||||
|     }, | ||||
|     JsonResult, | ||||
| }; | ||||
|  | ||||
| #[get("/api/guild/<id>/export/reminders")] | ||||
| pub async fn export_reminders( | ||||
|     id: u64, | ||||
|     cookies: &CookieJar<'_>, | ||||
|     ctx: &State<Context>, | ||||
|     pool: &State<Pool<MySql>>, | ||||
| ) -> JsonResult { | ||||
|     check_authorization!(cookies, ctx.inner(), id); | ||||
|  | ||||
|     let mut csv_writer = WriterBuilder::new().quote_style(QuoteStyle::Always).from_writer(vec![]); | ||||
|  | ||||
|     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(","); | ||||
|  | ||||
|             let result = sqlx::query_as_unchecked!( | ||||
|                 ReminderCsv, | ||||
|                 "SELECT | ||||
|                  reminders.attachment, | ||||
|                  reminders.attachment_name, | ||||
|                  reminders.avatar, | ||||
|                  CONCAT('#', channels.channel) AS 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.username, | ||||
|                  reminders.utc_time | ||||
|                 FROM reminders | ||||
|                 LEFT JOIN channels ON channels.id = reminders.channel_id | ||||
|                 WHERE FIND_IN_SET(channels.channel, ?)", | ||||
|                 channels | ||||
|             ) | ||||
|             .fetch_all(pool.inner()) | ||||
|             .await; | ||||
|  | ||||
|             match result { | ||||
|                 Ok(reminders) => { | ||||
|                     reminders.iter().for_each(|reminder| { | ||||
|                         csv_writer.serialize(reminder).unwrap(); | ||||
|                     }); | ||||
|  | ||||
|                     match csv_writer.into_inner() { | ||||
|                         Ok(inner) => match String::from_utf8(inner) { | ||||
|                             Ok(encoded) => Ok(json!({ "body": encoded })), | ||||
|  | ||||
|                             Err(e) => { | ||||
|                                 warn!("Failed to write UTF-8: {:?}", e); | ||||
|  | ||||
|                                 Err(json!({"error": "Failed to write UTF-8"})) | ||||
|                             } | ||||
|                         }, | ||||
|  | ||||
|                         Err(e) => { | ||||
|                             warn!("Failed to extract CSV: {:?}", e); | ||||
|  | ||||
|                             Err(json!({"error": "Failed to extract CSV"})) | ||||
|                         } | ||||
|                     } | ||||
|                 } | ||||
|  | ||||
|                 Err(e) => { | ||||
|                     warn!("Failed to complete SQL query: {:?}", e); | ||||
|  | ||||
|                     Err(json!({"error": "Failed to query reminders"})) | ||||
|                 } | ||||
|             } | ||||
|         } | ||||
|  | ||||
|         Err(e) => { | ||||
|             warn!("Could not fetch channels from {}: {:?}", id, e); | ||||
|  | ||||
|             Err(json!({"error": "Failed to get guild channels"})) | ||||
|         } | ||||
|     } | ||||
| } | ||||
|  | ||||
| #[put("/api/guild/<id>/export/reminders", data = "<body>")] | ||||
| pub async fn import_reminders( | ||||
|     id: u64, | ||||
|     cookies: &CookieJar<'_>, | ||||
|     body: Json<ImportBody>, | ||||
|     ctx: &State<Context>, | ||||
|     pool: &State<Pool<MySql>>, | ||||
| ) -> JsonResult { | ||||
|     check_authorization!(cookies, ctx.inner(), id); | ||||
|  | ||||
|     let user_id = | ||||
|         cookies.get_private("userid").map(|c| c.value().parse::<u64>().ok()).flatten().unwrap(); | ||||
|  | ||||
|     match base64::decode(&body.body) { | ||||
|         Ok(body) => { | ||||
|             let mut reader = csv::Reader::from_reader(body.as_slice()); | ||||
|  | ||||
|             for result in reader.deserialize::<ReminderCsv>() { | ||||
|                 match result { | ||||
|                     Ok(record) => { | ||||
|                         let channel_id = record.channel.split_at(1).1; | ||||
|  | ||||
|                         match channel_id.parse::<u64>() { | ||||
|                             Ok(channel_id) => { | ||||
|                                 let reminder = Reminder { | ||||
|                                     attachment: record.attachment, | ||||
|                                     attachment_name: record.attachment_name, | ||||
|                                     avatar: record.avatar, | ||||
|                                     channel: channel_id, | ||||
|                                     content: record.content, | ||||
|                                     embed_author: record.embed_author, | ||||
|                                     embed_author_url: record.embed_author_url, | ||||
|                                     embed_color: record.embed_color, | ||||
|                                     embed_description: record.embed_description, | ||||
|                                     embed_footer: record.embed_footer, | ||||
|                                     embed_footer_url: record.embed_footer_url, | ||||
|                                     embed_image_url: record.embed_image_url, | ||||
|                                     embed_thumbnail_url: record.embed_thumbnail_url, | ||||
|                                     embed_title: record.embed_title, | ||||
|                                     embed_fields: record | ||||
|                                         .embed_fields | ||||
|                                         .map(|s| serde_json::from_str(&s).ok()) | ||||
|                                         .flatten(), | ||||
|                                     enabled: record.enabled, | ||||
|                                     expires: record.expires, | ||||
|                                     interval_seconds: record.interval_seconds, | ||||
|                                     interval_days: record.interval_days, | ||||
|                                     interval_months: record.interval_months, | ||||
|                                     name: record.name, | ||||
|                                     restartable: record.restartable, | ||||
|                                     tts: record.tts, | ||||
|                                     uid: generate_uid(), | ||||
|                                     username: record.username, | ||||
|                                     utc_time: record.utc_time, | ||||
|                                 }; | ||||
|  | ||||
|                                 create_reminder( | ||||
|                                     ctx.inner(), | ||||
|                                     pool.inner(), | ||||
|                                     GuildId(id), | ||||
|                                     UserId(user_id), | ||||
|                                     reminder, | ||||
|                                 ) | ||||
|                                 .await?; | ||||
|                             } | ||||
|  | ||||
|                             Err(_) => { | ||||
|                                 return json_err!(format!( | ||||
|                                     "Failed to parse channel {}", | ||||
|                                     channel_id | ||||
|                                 )); | ||||
|                             } | ||||
|                         } | ||||
|                     } | ||||
|  | ||||
|                     Err(e) => { | ||||
|                         warn!("Couldn't deserialize CSV row: {:?}", e); | ||||
|  | ||||
|                         return json_err!("Deserialize error. Aborted"); | ||||
|                     } | ||||
|                 } | ||||
|             } | ||||
|  | ||||
|             Ok(json!({})) | ||||
|         } | ||||
|  | ||||
|         Err(_) => { | ||||
|             json_err!("Malformed base64") | ||||
|         } | ||||
|     } | ||||
| } | ||||
|  | ||||
| #[get("/api/guild/<id>/export/todos")] | ||||
| pub async fn export_todos( | ||||
|     id: u64, | ||||
|     cookies: &CookieJar<'_>, | ||||
|     ctx: &State<Context>, | ||||
|     pool: &State<Pool<MySql>>, | ||||
| ) -> JsonResult { | ||||
|     check_authorization!(cookies, ctx.inner(), id); | ||||
|  | ||||
|     let mut csv_writer = WriterBuilder::new().quote_style(QuoteStyle::Always).from_writer(vec![]); | ||||
|  | ||||
|     match sqlx::query_as_unchecked!( | ||||
|         TodoCsv, | ||||
|         "SELECT value, CONCAT('#', channels.channel) AS channel_id FROM todos | ||||
|         LEFT JOIN channels ON todos.channel_id = channels.id | ||||
|         INNER JOIN guilds ON todos.guild_id = guilds.id | ||||
|         WHERE guilds.guild = ?", | ||||
|         id | ||||
|     ) | ||||
|     .fetch_all(pool.inner()) | ||||
|     .await | ||||
|     { | ||||
|         Ok(todos) => { | ||||
|             todos.iter().for_each(|todo| { | ||||
|                 csv_writer.serialize(todo).unwrap(); | ||||
|             }); | ||||
|  | ||||
|             match csv_writer.into_inner() { | ||||
|                 Ok(inner) => match String::from_utf8(inner) { | ||||
|                     Ok(encoded) => Ok(json!({ "body": encoded })), | ||||
|  | ||||
|                     Err(e) => { | ||||
|                         warn!("Failed to write UTF-8: {:?}", e); | ||||
|  | ||||
|                         json_err!("Failed to write UTF-8") | ||||
|                     } | ||||
|                 }, | ||||
|  | ||||
|                 Err(e) => { | ||||
|                     warn!("Failed to extract CSV: {:?}", e); | ||||
|  | ||||
|                     json_err!("Failed to extract CSV") | ||||
|                 } | ||||
|             } | ||||
|         } | ||||
|  | ||||
|         Err(e) => { | ||||
|             warn!("Could not fetch templates from {}: {:?}", id, e); | ||||
|  | ||||
|             json_err!("Failed to query templates") | ||||
|         } | ||||
|     } | ||||
| } | ||||
|  | ||||
| #[put("/api/guild/<id>/export/todos", data = "<body>")] | ||||
| pub async fn import_todos( | ||||
|     id: u64, | ||||
|     cookies: &CookieJar<'_>, | ||||
|     body: Json<ImportBody>, | ||||
|     ctx: &State<Context>, | ||||
|     pool: &State<Pool<MySql>>, | ||||
| ) -> JsonResult { | ||||
|     check_authorization!(cookies, ctx.inner(), id); | ||||
|  | ||||
|     let channels_res = GuildId(id).channels(&ctx.inner()).await; | ||||
|  | ||||
|     match channels_res { | ||||
|         Ok(channels) => match base64::decode(&body.body) { | ||||
|             Ok(body) => { | ||||
|                 let mut reader = csv::Reader::from_reader(body.as_slice()); | ||||
|  | ||||
|                 let query_placeholder = "(?, (SELECT id FROM channels WHERE channel = ?), (SELECT id FROM guilds WHERE guild = ?))"; | ||||
|                 let mut query_params = vec![]; | ||||
|  | ||||
|                 for result in reader.deserialize::<TodoCsv>() { | ||||
|                     match result { | ||||
|                         Ok(record) => match record.channel_id { | ||||
|                             Some(channel_id) => { | ||||
|                                 let channel_id = channel_id.split_at(1).1; | ||||
|  | ||||
|                                 match channel_id.parse::<u64>() { | ||||
|                                     Ok(channel_id) => { | ||||
|                                         if channels.contains_key(&ChannelId(channel_id)) { | ||||
|                                             query_params.push((record.value, Some(channel_id), id)); | ||||
|                                         } else { | ||||
|                                             return json_err!(format!( | ||||
|                                                 "Invalid channel ID {}", | ||||
|                                                 channel_id | ||||
|                                             )); | ||||
|                                         } | ||||
|                                     } | ||||
|  | ||||
|                                     Err(_) => { | ||||
|                                         return json_err!(format!( | ||||
|                                             "Invalid channel ID {}", | ||||
|                                             channel_id | ||||
|                                         )); | ||||
|                                     } | ||||
|                                 } | ||||
|                             } | ||||
|  | ||||
|                             None => { | ||||
|                                 query_params.push((record.value, None, id)); | ||||
|                             } | ||||
|                         }, | ||||
|  | ||||
|                         Err(e) => { | ||||
|                             warn!("Couldn't deserialize CSV row: {:?}", e); | ||||
|  | ||||
|                             return json_err!("Deserialize error. Aborted"); | ||||
|                         } | ||||
|                     } | ||||
|                 } | ||||
|  | ||||
|                 let query_str = format!( | ||||
|                     "INSERT INTO todos (value, channel_id, guild_id) VALUES {}", | ||||
|                     vec![query_placeholder].repeat(query_params.len()).join(",") | ||||
|                 ); | ||||
|                 let mut query = sqlx::query(&query_str); | ||||
|  | ||||
|                 for param in query_params { | ||||
|                     query = query.bind(param.0).bind(param.1).bind(param.2); | ||||
|                 } | ||||
|  | ||||
|                 let res = query.execute(pool.inner()).await; | ||||
|  | ||||
|                 match res { | ||||
|                     Ok(_) => Ok(json!({})), | ||||
|  | ||||
|                     Err(e) => { | ||||
|                         warn!("Couldn't execute todo query: {:?}", e); | ||||
|  | ||||
|                         json_err!("An unexpected error occured.") | ||||
|                     } | ||||
|                 } | ||||
|             } | ||||
|  | ||||
|             Err(_) => { | ||||
|                 json_err!("Malformed base64") | ||||
|             } | ||||
|         }, | ||||
|  | ||||
|         Err(e) => { | ||||
|             warn!("Couldn't fetch channels for guild {}: {:?}", id, e); | ||||
|  | ||||
|             json_err!("Couldn't fetch channels.") | ||||
|         } | ||||
|     } | ||||
| } | ||||
|  | ||||
| #[get("/api/guild/<id>/export/reminder_templates")] | ||||
| pub async fn export_reminder_templates( | ||||
|     id: u64, | ||||
|     cookies: &CookieJar<'_>, | ||||
|     ctx: &State<Context>, | ||||
|     pool: &State<Pool<MySql>>, | ||||
| ) -> JsonResult { | ||||
|     check_authorization!(cookies, ctx.inner(), id); | ||||
|  | ||||
|     let mut csv_writer = WriterBuilder::new().quote_style(QuoteStyle::Always).from_writer(vec![]); | ||||
|  | ||||
|     match sqlx::query_as_unchecked!( | ||||
|         ReminderTemplateCsv, | ||||
|         "SELECT | ||||
|          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, | ||||
|          tts, | ||||
|          username | ||||
|         FROM reminder_template WHERE guild_id = (SELECT id FROM guilds WHERE guild = ?)", | ||||
|         id | ||||
|     ) | ||||
|     .fetch_all(pool.inner()) | ||||
|     .await | ||||
|     { | ||||
|         Ok(templates) => { | ||||
|             templates.iter().for_each(|template| { | ||||
|                 csv_writer.serialize(template).unwrap(); | ||||
|             }); | ||||
|  | ||||
|             match csv_writer.into_inner() { | ||||
|                 Ok(inner) => match String::from_utf8(inner) { | ||||
|                     Ok(encoded) => Ok(json!({ "body": encoded })), | ||||
|  | ||||
|                     Err(e) => { | ||||
|                         warn!("Failed to write UTF-8: {:?}", e); | ||||
|  | ||||
|                         json_err!("Failed to write UTF-8") | ||||
|                     } | ||||
|                 }, | ||||
|  | ||||
|                 Err(e) => { | ||||
|                     warn!("Failed to extract CSV: {:?}", e); | ||||
|  | ||||
|                     json_err!("Failed to extract CSV") | ||||
|                 } | ||||
|             } | ||||
|         } | ||||
|         Err(e) => { | ||||
|             warn!("Could not fetch templates from {}: {:?}", id, e); | ||||
|  | ||||
|             json_err!("Failed to query templates") | ||||
|         } | ||||
|     } | ||||
| } | ||||
| @@ -1,10 +1,8 @@ | ||||
| use std::env; | ||||
|  | ||||
| use base64; | ||||
| use chrono::Utc; | ||||
| use rocket::{ | ||||
|     http::CookieJar, | ||||
|     serde::json::{json, Json, Value as JsonValue}, | ||||
|     serde::json::{json, Json}, | ||||
|     State, | ||||
| }; | ||||
| use serde::Serialize; | ||||
| @@ -20,14 +18,17 @@ use sqlx::{MySql, Pool}; | ||||
| use crate::{ | ||||
|     check_guild_subscription, check_subscription, | ||||
|     consts::{ | ||||
|         DAY, MAX_CONTENT_LENGTH, MAX_EMBED_AUTHOR_LENGTH, MAX_EMBED_DESCRIPTION_LENGTH, | ||||
|         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, | ||||
|         MIN_INTERVAL, | ||||
|     }, | ||||
|     routes::dashboard::{ | ||||
|         create_database_channel, generate_uid, name_default, template_name_default, DeleteReminder, | ||||
|         DeleteReminderTemplate, PatchReminder, Reminder, ReminderTemplate, | ||||
|     routes::{ | ||||
|         dashboard::{ | ||||
|             create_database_channel, create_reminder, template_name_default, DeleteReminder, | ||||
|             DeleteReminderTemplate, PatchReminder, Reminder, ReminderTemplate, | ||||
|         }, | ||||
|         JsonResult, | ||||
|     }, | ||||
| }; | ||||
|  | ||||
| @@ -44,7 +45,7 @@ pub async fn get_guild_patreon( | ||||
|     id: u64, | ||||
|     cookies: &CookieJar<'_>, | ||||
|     ctx: &State<Context>, | ||||
| ) -> JsonValue { | ||||
| ) -> JsonResult { | ||||
|     check_authorization!(cookies, ctx.inner(), id); | ||||
|  | ||||
|     match GuildId(id).to_guild_cached(ctx.inner()) { | ||||
| @@ -59,12 +60,10 @@ pub async fn get_guild_patreon( | ||||
|                     .contains(&RoleId(env::var("PATREON_ROLE_ID").unwrap().parse().unwrap())) | ||||
|             }); | ||||
|  | ||||
|             json!({ "patreon": patreon }) | ||||
|             Ok(json!({ "patreon": patreon })) | ||||
|         } | ||||
|  | ||||
|         None => { | ||||
|             json!({"error": "Bot not in guild"}) | ||||
|         } | ||||
|         None => json_err!("Bot not in guild"), | ||||
|     } | ||||
| } | ||||
|  | ||||
| @@ -73,7 +72,7 @@ pub async fn get_guild_channels( | ||||
|     id: u64, | ||||
|     cookies: &CookieJar<'_>, | ||||
|     ctx: &State<Context>, | ||||
| ) -> JsonValue { | ||||
| ) -> JsonResult { | ||||
|     check_authorization!(cookies, ctx.inner(), id); | ||||
|  | ||||
|     match GuildId(id).to_guild_cached(ctx.inner()) { | ||||
| @@ -97,12 +96,10 @@ pub async fn get_guild_channels( | ||||
|                 }) | ||||
|                 .collect::<Vec<ChannelInfo>>(); | ||||
|  | ||||
|             json!(channel_info) | ||||
|             Ok(json!(channel_info)) | ||||
|         } | ||||
|  | ||||
|         None => { | ||||
|             json!({"error": "Bot not in guild"}) | ||||
|         } | ||||
|         None => json_err!("Bot not in guild"), | ||||
|     } | ||||
| } | ||||
|  | ||||
| @@ -113,7 +110,7 @@ struct RoleInfo { | ||||
| } | ||||
|  | ||||
| #[get("/api/guild/<id>/roles")] | ||||
| pub async fn get_guild_roles(id: u64, cookies: &CookieJar<'_>, ctx: &State<Context>) -> JsonValue { | ||||
| pub async fn get_guild_roles(id: u64, cookies: &CookieJar<'_>, ctx: &State<Context>) -> JsonResult { | ||||
|     check_authorization!(cookies, ctx.inner(), id); | ||||
|  | ||||
|     let roles_res = ctx.cache.guild_roles(id); | ||||
| @@ -125,12 +122,12 @@ pub async fn get_guild_roles(id: u64, cookies: &CookieJar<'_>, ctx: &State<Conte | ||||
|                 .map(|(_, r)| RoleInfo { id: r.id.to_string(), name: r.name.to_string() }) | ||||
|                 .collect::<Vec<RoleInfo>>(); | ||||
|  | ||||
|             json!(roles) | ||||
|             Ok(json!(roles)) | ||||
|         } | ||||
|         None => { | ||||
|             warn!("Could not fetch roles from {}", id); | ||||
|  | ||||
|             json!({"error": "Could not get roles"}) | ||||
|             json_err!("Could not get roles") | ||||
|         } | ||||
|     } | ||||
| } | ||||
| @@ -141,7 +138,7 @@ pub async fn get_reminder_templates( | ||||
|     cookies: &CookieJar<'_>, | ||||
|     ctx: &State<Context>, | ||||
|     pool: &State<Pool<MySql>>, | ||||
| ) -> JsonValue { | ||||
| ) -> JsonResult { | ||||
|     check_authorization!(cookies, ctx.inner(), id); | ||||
|  | ||||
|     match sqlx::query_as_unchecked!( | ||||
| @@ -152,13 +149,11 @@ pub async fn get_reminder_templates( | ||||
|     .fetch_all(pool.inner()) | ||||
|     .await | ||||
|     { | ||||
|         Ok(templates) => { | ||||
|             json!(templates) | ||||
|         } | ||||
|         Ok(templates) => Ok(json!(templates)), | ||||
|         Err(e) => { | ||||
|             warn!("Could not fetch templates from {}: {:?}", id, e); | ||||
|  | ||||
|             json!({"error": "Could not get templates"}) | ||||
|             json_err!("Could not get templates") | ||||
|         } | ||||
|     } | ||||
| } | ||||
| @@ -170,7 +165,7 @@ pub async fn create_reminder_template( | ||||
|     cookies: &CookieJar<'_>, | ||||
|     ctx: &State<Context>, | ||||
|     pool: &State<Pool<MySql>>, | ||||
| ) -> JsonValue { | ||||
| ) -> JsonResult { | ||||
|     check_authorization!(cookies, ctx.inner(), id); | ||||
|  | ||||
|     // validate lengths | ||||
| @@ -254,12 +249,12 @@ pub async fn create_reminder_template( | ||||
|     .await | ||||
|     { | ||||
|         Ok(_) => { | ||||
|             json!({}) | ||||
|             Ok(json!({})) | ||||
|         } | ||||
|         Err(e) => { | ||||
|             warn!("Could not fetch templates from {}: {:?}", id, e); | ||||
|             warn!("Could not create template for {}: {:?}", id, e); | ||||
|  | ||||
|             json!({"error": "Could not get templates"}) | ||||
|             json_err!("Could not create template") | ||||
|         } | ||||
|     } | ||||
| } | ||||
| @@ -271,7 +266,7 @@ pub async fn delete_reminder_template( | ||||
|     cookies: &CookieJar<'_>, | ||||
|     ctx: &State<Context>, | ||||
|     pool: &State<Pool<MySql>>, | ||||
| ) -> JsonValue { | ||||
| ) -> JsonResult { | ||||
|     check_authorization!(cookies, ctx.inner(), id); | ||||
|  | ||||
|     match sqlx::query!( | ||||
| @@ -282,230 +277,49 @@ pub async fn delete_reminder_template( | ||||
|     .await | ||||
|     { | ||||
|         Ok(_) => { | ||||
|             json!({}) | ||||
|             Ok(json!({})) | ||||
|         } | ||||
|         Err(e) => { | ||||
|             warn!("Could not delete template from {}: {:?}", id, e); | ||||
|  | ||||
|             json!({"error": "Could not delete template"}) | ||||
|             json_err!("Could not delete template") | ||||
|         } | ||||
|     } | ||||
| } | ||||
|  | ||||
| #[post("/api/guild/<id>/reminders", data = "<reminder>")] | ||||
| pub async fn create_reminder( | ||||
| pub async fn create_guild_reminder( | ||||
|     id: u64, | ||||
|     reminder: Json<Reminder>, | ||||
|     cookies: &CookieJar<'_>, | ||||
|     serenity_context: &State<Context>, | ||||
|     pool: &State<Pool<MySql>>, | ||||
| ) -> JsonValue { | ||||
| ) -> JsonResult { | ||||
|     check_authorization!(cookies, serenity_context.inner(), id); | ||||
|  | ||||
|     let user_id = | ||||
|         cookies.get_private("userid").map(|c| c.value().parse::<u64>().ok()).flatten().unwrap(); | ||||
|  | ||||
|     // validate channel | ||||
|     let channel = ChannelId(reminder.channel).to_channel_cached(&serenity_context.inner()); | ||||
|     let channel_exists = channel.is_some(); | ||||
|  | ||||
|     let channel_matches_guild = | ||||
|         channel.map_or(false, |c| c.guild().map_or(false, |c| c.guild_id.0 == id)); | ||||
|  | ||||
|     if !channel_matches_guild || !channel_exists { | ||||
|         warn!( | ||||
|             "Error in `create_reminder`: channel {} not found for guild {} (channel exists: {})", | ||||
|             reminder.channel, id, channel_exists | ||||
|         ); | ||||
|  | ||||
|         return json!({"error": "Channel not found"}); | ||||
|     } | ||||
|  | ||||
|     let channel = create_database_channel( | ||||
|     create_reminder( | ||||
|         serenity_context.inner(), | ||||
|         ChannelId(reminder.channel), | ||||
|         pool.inner(), | ||||
|         GuildId(id), | ||||
|         UserId(user_id), | ||||
|         reminder.into_inner(), | ||||
|     ) | ||||
|     .await; | ||||
|  | ||||
|     if let Err(e) = channel { | ||||
|         warn!("`create_database_channel` returned an error code: {:?}", e); | ||||
|  | ||||
|         return json!({"error": "Failed to configure channel for reminders. Please check the bot permissions"}); | ||||
|     } | ||||
|  | ||||
|     let channel = channel.unwrap(); | ||||
|  | ||||
|     // validate lengths | ||||
|     check_length!(MAX_CONTENT_LENGTH, reminder.content); | ||||
|     check_length!(MAX_EMBED_DESCRIPTION_LENGTH, reminder.embed_description); | ||||
|     check_length!(MAX_EMBED_TITLE_LENGTH, reminder.embed_title); | ||||
|     check_length!(MAX_EMBED_AUTHOR_LENGTH, reminder.embed_author); | ||||
|     check_length!(MAX_EMBED_FOOTER_LENGTH, reminder.embed_footer); | ||||
|     check_length_opt!(MAX_EMBED_FIELDS, reminder.embed_fields); | ||||
|     if let Some(fields) = &reminder.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.username); | ||||
|     check_length_opt!( | ||||
|         MAX_URL_LENGTH, | ||||
|         reminder.embed_footer_url, | ||||
|         reminder.embed_thumbnail_url, | ||||
|         reminder.embed_author_url, | ||||
|         reminder.embed_image_url, | ||||
|         reminder.avatar | ||||
|     ); | ||||
|  | ||||
|     // validate urls | ||||
|     check_url_opt!( | ||||
|         reminder.embed_footer_url, | ||||
|         reminder.embed_thumbnail_url, | ||||
|         reminder.embed_author_url, | ||||
|         reminder.embed_image_url, | ||||
|         reminder.avatar | ||||
|     ); | ||||
|  | ||||
|     // validate time and interval | ||||
|     if reminder.utc_time < Utc::now().naive_utc() { | ||||
|         return json!({"error": "Time must be in the future"}); | ||||
|     } | ||||
|     if reminder.interval_seconds.is_some() || reminder.interval_months.is_some() { | ||||
|         if reminder.interval_months.unwrap_or(0) * 30 * DAY as u32 | ||||
|             + reminder.interval_seconds.unwrap_or(0) | ||||
|             < *MIN_INTERVAL | ||||
|         { | ||||
|             return json!({"error": "Interval too short"}); | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     // check patreon if necessary | ||||
|     if reminder.interval_seconds.is_some() || reminder.interval_months.is_some() { | ||||
|         if !check_guild_subscription(serenity_context.inner(), GuildId(id)).await | ||||
|             && !check_subscription(serenity_context.inner(), user_id).await | ||||
|         { | ||||
|             return json!({"error": "Patreon is required to set intervals"}); | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     // base64 decode error dropped here | ||||
|     let attachment_data = reminder.attachment.as_ref().map(|s| base64::decode(s).ok()).flatten(); | ||||
|     let name = if reminder.name.is_empty() { name_default() } else { reminder.name.clone() }; | ||||
|  | ||||
|     let new_uid = generate_uid(); | ||||
|  | ||||
|     // write to db | ||||
|     match sqlx::query!( | ||||
|         "INSERT INTO reminders ( | ||||
|          uid, | ||||
|          attachment, | ||||
|          attachment_name, | ||||
|          channel_id, | ||||
|          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, | ||||
|          enabled, | ||||
|          expires, | ||||
|          interval_seconds, | ||||
|          interval_months, | ||||
|          name, | ||||
|          restartable, | ||||
|          tts, | ||||
|          username, | ||||
|          `utc_time` | ||||
|         ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", | ||||
|         new_uid, | ||||
|         attachment_data, | ||||
|         reminder.attachment_name, | ||||
|         channel, | ||||
|         reminder.avatar, | ||||
|         reminder.content, | ||||
|         reminder.embed_author, | ||||
|         reminder.embed_author_url, | ||||
|         reminder.embed_color, | ||||
|         reminder.embed_description, | ||||
|         reminder.embed_footer, | ||||
|         reminder.embed_footer_url, | ||||
|         reminder.embed_image_url, | ||||
|         reminder.embed_thumbnail_url, | ||||
|         reminder.embed_title, | ||||
|         reminder.embed_fields, | ||||
|         reminder.enabled, | ||||
|         reminder.expires, | ||||
|         reminder.interval_seconds, | ||||
|         reminder.interval_months, | ||||
|         name, | ||||
|         reminder.restartable, | ||||
|         reminder.tts, | ||||
|         reminder.username, | ||||
|         reminder.utc_time, | ||||
|     ) | ||||
|     .execute(pool.inner()) | ||||
|     .await | ||||
|     { | ||||
|         Ok(_) => 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_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 = ?", | ||||
|             new_uid | ||||
|         ) | ||||
|         .fetch_one(pool.inner()) | ||||
|         .await | ||||
|         .map(|r| json!(r)) | ||||
|         .unwrap_or_else(|e| { | ||||
|             warn!("Failed to complete SQL query: {:?}", e); | ||||
|  | ||||
|             json!({"error": "Could not load reminder"}) | ||||
|         }), | ||||
|  | ||||
|         Err(e) => { | ||||
|             warn!("Error in `create_reminder`: Could not execute query: {:?}", e); | ||||
|  | ||||
|             json!({"error": "Unknown error"}) | ||||
|         } | ||||
|     } | ||||
| } | ||||
|  | ||||
| #[get("/api/guild/<id>/reminders")] | ||||
| pub async fn get_reminders(id: u64, ctx: &State<Context>, pool: &State<Pool<MySql>>) -> JsonValue { | ||||
| pub async fn get_reminders( | ||||
|     id: u64, | ||||
|     cookies: &CookieJar<'_>, | ||||
|     ctx: &State<Context>, | ||||
|     serenity_context: &State<Context>, | ||||
|     pool: &State<Pool<MySql>>, | ||||
| ) -> JsonResult { | ||||
|     check_authorization!(cookies, serenity_context.inner(), id); | ||||
|  | ||||
|     let channels_res = GuildId(id).channels(&ctx.inner()).await; | ||||
|  | ||||
|     match channels_res { | ||||
| @@ -534,10 +348,11 @@ pub async fn get_reminders(id: u64, ctx: &State<Context>, pool: &State<Pool<MySq | ||||
|                  reminders.embed_image_url, | ||||
|                  reminders.embed_thumbnail_url, | ||||
|                  reminders.embed_title, | ||||
|                  reminders.embed_fields, | ||||
|                  IFNULL(reminders.embed_fields, '[]') AS embed_fields, | ||||
|                  reminders.enabled, | ||||
|                  reminders.expires, | ||||
|                  reminders.interval_seconds, | ||||
|                  reminders.interval_days, | ||||
|                  reminders.interval_months, | ||||
|                  reminders.name, | ||||
|                  reminders.restartable, | ||||
| @@ -547,22 +362,22 @@ pub async fn get_reminders(id: u64, ctx: &State<Context>, pool: &State<Pool<MySq | ||||
|                  reminders.utc_time | ||||
|                 FROM reminders | ||||
|                 LEFT JOIN channels ON channels.id = reminders.channel_id | ||||
|                 WHERE FIND_IN_SET(channels.channel, ?)", | ||||
|                 WHERE `status` = 'pending' AND FIND_IN_SET(channels.channel, ?)", | ||||
|                 channels | ||||
|             ) | ||||
|             .fetch_all(pool.inner()) | ||||
|             .await | ||||
|             .map(|r| json!(r)) | ||||
|             .map(|r| Ok(json!(r))) | ||||
|             .unwrap_or_else(|e| { | ||||
|                 warn!("Failed to complete SQL query: {:?}", e); | ||||
|  | ||||
|                 json!({"error": "Could not load reminders"}) | ||||
|                 json_err!("Could not load reminders") | ||||
|             }) | ||||
|         } | ||||
|         Err(e) => { | ||||
|             warn!("Could not fetch channels from {}: {:?}", id, e); | ||||
|  | ||||
|             json!([]) | ||||
|             Ok(json!([])) | ||||
|         } | ||||
|     } | ||||
| } | ||||
| @@ -573,35 +388,109 @@ pub async fn edit_reminder( | ||||
|     reminder: Json<PatchReminder>, | ||||
|     serenity_context: &State<Context>, | ||||
|     pool: &State<Pool<MySql>>, | ||||
| ) -> JsonValue { | ||||
|     cookies: &CookieJar<'_>, | ||||
| ) -> JsonResult { | ||||
|     check_authorization!(cookies, serenity_context.inner(), id); | ||||
|  | ||||
|     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!(pool.inner(), error, reminder.[ | ||||
|             content, | ||||
|             embed_author, | ||||
|             embed_description, | ||||
|             embed_footer, | ||||
|             embed_title, | ||||
|             embed_fields, | ||||
|             username | ||||
|         ]); | ||||
|     } else { | ||||
|         error.push("Message exceeds limits.".to_string()); | ||||
|     } | ||||
|  | ||||
|     update_field!(pool.inner(), error, reminder.[ | ||||
|         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, | ||||
|         enabled, | ||||
|         expires, | ||||
|         interval_seconds, | ||||
|         interval_months, | ||||
|         name, | ||||
|         restartable, | ||||
|         tts, | ||||
|         username, | ||||
|         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(&serenity_context.inner(), id).await | ||||
|             || check_subscription(&serenity_context.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(pool.inner()) | ||||
|                 .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(pool.inner()) | ||||
|                 .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(pool.inner()) | ||||
|                 .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!(pool.inner(), error, reminder.[ | ||||
|                     interval_days, | ||||
|                     interval_months, | ||||
|                     interval_seconds | ||||
|                 ]); | ||||
|             } | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     if reminder.channel > 0 { | ||||
|         let channel = ChannelId(reminder.channel).to_channel_cached(&serenity_context.inner()); | ||||
|         match channel { | ||||
| @@ -614,7 +503,7 @@ pub async fn edit_reminder( | ||||
|                         reminder.channel, id | ||||
|                     ); | ||||
|  | ||||
|                     return json!({"error": "Channel not found"}); | ||||
|                     return Err(json!({"error": "Channel not found"})); | ||||
|                 } | ||||
|  | ||||
|                 let channel = create_database_channel( | ||||
| @@ -627,7 +516,9 @@ pub async fn edit_reminder( | ||||
|                 if let Err(e) = channel { | ||||
|                     warn!("`create_database_channel` returned an error code: {:?}", e); | ||||
|  | ||||
|                     return json!({"error": "Failed to configure channel for reminders. Please check the bot permissions"}); | ||||
|                     return Err( | ||||
|                         json!({"error": "Failed to configure channel for reminders. Please check the bot permissions"}), | ||||
|                     ); | ||||
|                 } | ||||
|  | ||||
|                 let channel = channel.unwrap(); | ||||
| @@ -655,7 +546,7 @@ pub async fn edit_reminder( | ||||
|                     reminder.channel, id | ||||
|                 ); | ||||
|  | ||||
|                 return json!({"error": "Channel not found"}); | ||||
|                 return Err(json!({"error": "Channel not found"})); | ||||
|             } | ||||
|         } | ||||
|     } | ||||
| @@ -680,6 +571,7 @@ pub async fn edit_reminder( | ||||
|          reminders.enabled, | ||||
|          reminders.expires, | ||||
|          reminders.interval_seconds, | ||||
|          reminders.interval_days, | ||||
|          reminders.interval_months, | ||||
|          reminders.name, | ||||
|          reminders.restartable, | ||||
| @@ -695,12 +587,12 @@ pub async fn edit_reminder( | ||||
|     .fetch_one(pool.inner()) | ||||
|     .await | ||||
|     { | ||||
|         Ok(reminder) => json!({"reminder": reminder, "errors": error}), | ||||
|         Ok(reminder) => Ok(json!({"reminder": reminder, "errors": error})), | ||||
|  | ||||
|         Err(e) => { | ||||
|             warn!("Error exiting `edit_reminder': {:?}", e); | ||||
|  | ||||
|             json!({"reminder": Option::<Reminder>::None, "errors": vec!["Unknown error"]}) | ||||
|             Err(json!({"reminder": Option::<Reminder>::None, "errors": vec!["Unknown error"]})) | ||||
|         } | ||||
|     } | ||||
| } | ||||
| @@ -709,19 +601,17 @@ pub async fn edit_reminder( | ||||
| pub async fn delete_reminder( | ||||
|     reminder: Json<DeleteReminder>, | ||||
|     pool: &State<Pool<MySql>>, | ||||
| ) -> JsonValue { | ||||
|     match sqlx::query!("DELETE FROM reminders WHERE uid = ?", reminder.uid) | ||||
| ) -> JsonResult { | ||||
|     match sqlx::query!("UPDATE reminders SET `status` = 'deleted' WHERE uid = ?", reminder.uid) | ||||
|         .execute(pool.inner()) | ||||
|         .await | ||||
|     { | ||||
|         Ok(_) => { | ||||
|             json!({}) | ||||
|         } | ||||
|         Ok(_) => Ok(json!({})), | ||||
|  | ||||
|         Err(e) => { | ||||
|             warn!("Error in `delete_reminder`: {:?}", e); | ||||
|  | ||||
|             json!({"error": "Could not delete reminder"}) | ||||
|             Err(json!({"error": "Could not delete reminder"})) | ||||
|         } | ||||
|     } | ||||
| } | ||||
|   | ||||
| @@ -1,18 +1,30 @@ | ||||
| use std::collections::HashMap; | ||||
|  | ||||
| use chrono::naive::NaiveDateTime; | ||||
| use chrono::{naive::NaiveDateTime, Utc}; | ||||
| use rand::{rngs::OsRng, seq::IteratorRandom}; | ||||
| use rocket::{http::CookieJar, response::Redirect}; | ||||
| use rocket::{http::CookieJar, response::Redirect, serde::json::json}; | ||||
| use rocket_dyn_templates::Template; | ||||
| use serde::{Deserialize, Serialize}; | ||||
| use serenity::{http::Http, model::id::ChannelId}; | ||||
| use serde::{Deserialize, Deserializer, Serialize}; | ||||
| use serenity::{ | ||||
|     client::Context, | ||||
|     http::Http, | ||||
|     model::id::{ChannelId, GuildId, UserId}, | ||||
| }; | ||||
| use sqlx::{types::Json, Executor}; | ||||
|  | ||||
| use crate::{ | ||||
|     consts::{CHARACTERS, DEFAULT_AVATAR}, | ||||
|     check_guild_subscription, check_subscription, | ||||
|     consts::{ | ||||
|         CHARACTERS, DAY, DEFAULT_AVATAR, 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_NAME_LENGTH, MAX_URL_LENGTH, MAX_USERNAME_LENGTH, MIN_INTERVAL, | ||||
|     }, | ||||
|     routes::JsonResult, | ||||
|     Database, Error, | ||||
| }; | ||||
|  | ||||
| pub mod export; | ||||
| pub mod guild; | ||||
| pub mod user; | ||||
|  | ||||
| @@ -34,6 +46,18 @@ fn id_default() -> u32 { | ||||
|     0 | ||||
| } | ||||
|  | ||||
| fn interval_default() -> Unset<Option<u32>> { | ||||
|     None | ||||
| } | ||||
|  | ||||
| fn deserialize_optional_field<'de, T, D>(deserializer: D) -> Result<Option<Option<T>>, D::Error> | ||||
| where | ||||
|     D: Deserializer<'de>, | ||||
|     T: Deserialize<'de>, | ||||
| { | ||||
|     Ok(Some(Option::deserialize(deserializer)?)) | ||||
| } | ||||
|  | ||||
| #[derive(Serialize, Deserialize)] | ||||
| pub struct ReminderTemplate { | ||||
|     #[serde(default = "id_default")] | ||||
| @@ -60,6 +84,28 @@ pub struct ReminderTemplate { | ||||
|     username: Option<String>, | ||||
| } | ||||
|  | ||||
| #[derive(Serialize, Deserialize)] | ||||
| pub struct ReminderTemplateCsv { | ||||
|     #[serde(default = "template_name_default")] | ||||
|     name: String, | ||||
|     attachment: Option<Vec<u8>>, | ||||
|     attachment_name: Option<String>, | ||||
|     avatar: Option<String>, | ||||
|     content: String, | ||||
|     embed_author: String, | ||||
|     embed_author_url: Option<String>, | ||||
|     embed_color: u32, | ||||
|     embed_description: String, | ||||
|     embed_footer: String, | ||||
|     embed_footer_url: Option<String>, | ||||
|     embed_image_url: Option<String>, | ||||
|     embed_thumbnail_url: Option<String>, | ||||
|     embed_title: String, | ||||
|     embed_fields: Option<String>, | ||||
|     tts: bool, | ||||
|     username: Option<String>, | ||||
| } | ||||
|  | ||||
| #[derive(Deserialize)] | ||||
| pub struct DeleteReminderTemplate { | ||||
|     id: u32, | ||||
| @@ -94,6 +140,7 @@ pub struct Reminder { | ||||
|     enabled: bool, | ||||
|     expires: Option<NaiveDateTime>, | ||||
|     interval_seconds: Option<u32>, | ||||
|     interval_days: Option<u32>, | ||||
|     interval_months: Option<u32>, | ||||
|     #[serde(default = "name_default")] | ||||
|     name: String, | ||||
| @@ -105,14 +152,48 @@ pub struct Reminder { | ||||
|     utc_time: NaiveDateTime, | ||||
| } | ||||
|  | ||||
| #[derive(Serialize, Deserialize)] | ||||
| pub struct ReminderCsv { | ||||
|     #[serde(with = "base64s")] | ||||
|     attachment: Option<Vec<u8>>, | ||||
|     attachment_name: Option<String>, | ||||
|     avatar: Option<String>, | ||||
|     channel: String, | ||||
|     content: String, | ||||
|     embed_author: String, | ||||
|     embed_author_url: Option<String>, | ||||
|     embed_color: u32, | ||||
|     embed_description: String, | ||||
|     embed_footer: String, | ||||
|     embed_footer_url: Option<String>, | ||||
|     embed_image_url: Option<String>, | ||||
|     embed_thumbnail_url: Option<String>, | ||||
|     embed_title: String, | ||||
|     embed_fields: Option<String>, | ||||
|     enabled: bool, | ||||
|     expires: Option<NaiveDateTime>, | ||||
|     interval_seconds: Option<u32>, | ||||
|     interval_days: Option<u32>, | ||||
|     interval_months: Option<u32>, | ||||
|     #[serde(default = "name_default")] | ||||
|     name: String, | ||||
|     restartable: bool, | ||||
|     tts: bool, | ||||
|     username: Option<String>, | ||||
|     utc_time: NaiveDateTime, | ||||
| } | ||||
|  | ||||
| #[derive(Deserialize)] | ||||
| pub struct PatchReminder { | ||||
|     uid: String, | ||||
|     #[serde(default)] | ||||
|     #[serde(deserialize_with = "deserialize_optional_field")] | ||||
|     attachment: Unset<Option<String>>, | ||||
|     #[serde(default)] | ||||
|     #[serde(deserialize_with = "deserialize_optional_field")] | ||||
|     attachment_name: Unset<Option<String>>, | ||||
|     #[serde(default)] | ||||
|     #[serde(deserialize_with = "deserialize_optional_field")] | ||||
|     avatar: Unset<Option<String>>, | ||||
|     #[serde(default = "channel_default")] | ||||
|     #[serde(with = "string")] | ||||
| @@ -122,6 +203,7 @@ pub struct PatchReminder { | ||||
|     #[serde(default)] | ||||
|     embed_author: Unset<String>, | ||||
|     #[serde(default)] | ||||
|     #[serde(deserialize_with = "deserialize_optional_field")] | ||||
|     embed_author_url: Unset<Option<String>>, | ||||
|     #[serde(default)] | ||||
|     embed_color: Unset<u32>, | ||||
| @@ -130,10 +212,13 @@ pub struct PatchReminder { | ||||
|     #[serde(default)] | ||||
|     embed_footer: Unset<String>, | ||||
|     #[serde(default)] | ||||
|     #[serde(deserialize_with = "deserialize_optional_field")] | ||||
|     embed_footer_url: Unset<Option<String>>, | ||||
|     #[serde(default)] | ||||
|     #[serde(deserialize_with = "deserialize_optional_field")] | ||||
|     embed_image_url: Unset<Option<String>>, | ||||
|     #[serde(default)] | ||||
|     #[serde(deserialize_with = "deserialize_optional_field")] | ||||
|     embed_thumbnail_url: Unset<Option<String>>, | ||||
|     #[serde(default)] | ||||
|     embed_title: Unset<String>, | ||||
| @@ -142,10 +227,16 @@ pub struct PatchReminder { | ||||
|     #[serde(default)] | ||||
|     enabled: Unset<bool>, | ||||
|     #[serde(default)] | ||||
|     #[serde(deserialize_with = "deserialize_optional_field")] | ||||
|     expires: Unset<Option<NaiveDateTime>>, | ||||
|     #[serde(default)] | ||||
|     #[serde(default = "interval_default")] | ||||
|     #[serde(deserialize_with = "deserialize_optional_field")] | ||||
|     interval_seconds: Unset<Option<u32>>, | ||||
|     #[serde(default)] | ||||
|     #[serde(default = "interval_default")] | ||||
|     #[serde(deserialize_with = "deserialize_optional_field")] | ||||
|     interval_days: Unset<Option<u32>>, | ||||
|     #[serde(default = "interval_default")] | ||||
|     #[serde(deserialize_with = "deserialize_optional_field")] | ||||
|     interval_months: Unset<Option<u32>>, | ||||
|     #[serde(default)] | ||||
|     name: Unset<String>, | ||||
| @@ -154,11 +245,36 @@ pub struct PatchReminder { | ||||
|     #[serde(default)] | ||||
|     tts: Unset<bool>, | ||||
|     #[serde(default)] | ||||
|     #[serde(deserialize_with = "deserialize_optional_field")] | ||||
|     username: Unset<Option<String>>, | ||||
|     #[serde(default)] | ||||
|     utc_time: Unset<NaiveDateTime>, | ||||
| } | ||||
|  | ||||
| impl PatchReminder { | ||||
|     fn message_ok(&self) -> bool { | ||||
|         self.content.as_ref().map_or(true, |c| c.len() <= MAX_CONTENT_LENGTH) | ||||
|             && self.embed_author.as_ref().map_or(true, |c| c.len() <= MAX_EMBED_AUTHOR_LENGTH) | ||||
|             && self | ||||
|                 .embed_description | ||||
|                 .as_ref() | ||||
|                 .map_or(true, |c| c.len() <= MAX_EMBED_DESCRIPTION_LENGTH) | ||||
|             && self.embed_footer.as_ref().map_or(true, |c| c.len() <= MAX_EMBED_FOOTER_LENGTH) | ||||
|             && self.embed_title.as_ref().map_or(true, |c| c.len() <= MAX_EMBED_TITLE_LENGTH) | ||||
|             && self.embed_fields.as_ref().map_or(true, |c| { | ||||
|                 c.0.len() <= MAX_EMBED_FIELDS | ||||
|                     && c.0.iter().all(|f| { | ||||
|                         f.title.len() <= MAX_EMBED_FIELD_TITLE_LENGTH | ||||
|                             && f.value.len() <= MAX_EMBED_FIELD_VALUE_LENGTH | ||||
|                     }) | ||||
|             }) | ||||
|             && self | ||||
|                 .username | ||||
|                 .as_ref() | ||||
|                 .map_or(true, |c| c.as_ref().map_or(true, |v| v.len() <= MAX_USERNAME_LENGTH)) | ||||
|     } | ||||
| } | ||||
|  | ||||
| pub fn generate_uid() -> String { | ||||
|     let mut generator: OsRng = Default::default(); | ||||
|  | ||||
| @@ -220,13 +336,258 @@ pub struct DeleteReminder { | ||||
|     uid: String, | ||||
| } | ||||
|  | ||||
| #[derive(Deserialize)] | ||||
| pub struct ImportBody { | ||||
|     body: String, | ||||
| } | ||||
|  | ||||
| #[derive(Serialize, Deserialize)] | ||||
| pub struct TodoCsv { | ||||
|     value: String, | ||||
|     channel_id: Option<String>, | ||||
| } | ||||
|  | ||||
| pub async fn create_reminder( | ||||
|     ctx: &Context, | ||||
|     pool: impl sqlx::Executor<'_, Database = Database> + Copy, | ||||
|     guild_id: GuildId, | ||||
|     user_id: UserId, | ||||
|     reminder: Reminder, | ||||
| ) -> JsonResult { | ||||
|     // check guild in db | ||||
|     match sqlx::query!("SELECT 1 as A FROM guilds WHERE guild = ?", guild_id.0) | ||||
|         .fetch_one(pool) | ||||
|         .await | ||||
|     { | ||||
|         Err(sqlx::Error::RowNotFound) => { | ||||
|             if sqlx::query!("INSERT INTO guilds (guild) VALUES (?)", guild_id.0) | ||||
|                 .execute(pool) | ||||
|                 .await | ||||
|                 .is_err() | ||||
|             { | ||||
|                 return Err(json!({"error": "Guild could not be created"})); | ||||
|             } | ||||
|         } | ||||
|         _ => {} | ||||
|     } | ||||
|  | ||||
|     // validate channel | ||||
|     let channel = ChannelId(reminder.channel).to_channel_cached(&ctx); | ||||
|     let channel_exists = channel.is_some(); | ||||
|  | ||||
|     let channel_matches_guild = | ||||
|         channel.map_or(false, |c| c.guild().map_or(false, |c| c.guild_id == guild_id)); | ||||
|  | ||||
|     if !channel_matches_guild || !channel_exists { | ||||
|         warn!( | ||||
|             "Error in `create_reminder`: channel {} not found for guild {} (channel exists: {})", | ||||
|             reminder.channel, guild_id, channel_exists | ||||
|         ); | ||||
|  | ||||
|         return Err(json!({"error": "Channel not found"})); | ||||
|     } | ||||
|  | ||||
|     let channel = create_database_channel(&ctx, ChannelId(reminder.channel), pool).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(); | ||||
|  | ||||
|     // validate lengths | ||||
|     check_length!(MAX_NAME_LENGTH, reminder.name); | ||||
|     check_length!(MAX_CONTENT_LENGTH, reminder.content); | ||||
|     check_length!(MAX_EMBED_DESCRIPTION_LENGTH, reminder.embed_description); | ||||
|     check_length!(MAX_EMBED_TITLE_LENGTH, reminder.embed_title); | ||||
|     check_length!(MAX_EMBED_AUTHOR_LENGTH, reminder.embed_author); | ||||
|     check_length!(MAX_EMBED_FOOTER_LENGTH, reminder.embed_footer); | ||||
|     check_length_opt!(MAX_EMBED_FIELDS, reminder.embed_fields); | ||||
|     if let Some(fields) = &reminder.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.username); | ||||
|     check_length_opt!( | ||||
|         MAX_URL_LENGTH, | ||||
|         reminder.embed_footer_url, | ||||
|         reminder.embed_thumbnail_url, | ||||
|         reminder.embed_author_url, | ||||
|         reminder.embed_image_url, | ||||
|         reminder.avatar | ||||
|     ); | ||||
|  | ||||
|     // validate urls | ||||
|     check_url_opt!( | ||||
|         reminder.embed_footer_url, | ||||
|         reminder.embed_thumbnail_url, | ||||
|         reminder.embed_author_url, | ||||
|         reminder.embed_image_url, | ||||
|         reminder.avatar | ||||
|     ); | ||||
|  | ||||
|     // validate time and interval | ||||
|     if reminder.utc_time < Utc::now().naive_utc() { | ||||
|         return Err(json!({"error": "Time must be in the future"})); | ||||
|     } | ||||
|     if reminder.interval_seconds.is_some() | ||||
|         || reminder.interval_days.is_some() | ||||
|         || reminder.interval_months.is_some() | ||||
|     { | ||||
|         if reminder.interval_months.unwrap_or(0) * 30 * DAY as u32 | ||||
|             + reminder.interval_days.unwrap_or(0) * DAY as u32 | ||||
|             + reminder.interval_seconds.unwrap_or(0) | ||||
|             < *MIN_INTERVAL | ||||
|         { | ||||
|             return Err(json!({"error": "Interval too short"})); | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     // check patreon if necessary | ||||
|     if reminder.interval_seconds.is_some() | ||||
|         || reminder.interval_days.is_some() | ||||
|         || reminder.interval_months.is_some() | ||||
|     { | ||||
|         if !check_guild_subscription(&ctx, guild_id).await | ||||
|             && !check_subscription(&ctx, user_id).await | ||||
|         { | ||||
|             return Err(json!({"error": "Patreon is required to set intervals"})); | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     // base64 decode error dropped here | ||||
|     let attachment_data = reminder.attachment.as_ref().map(|s| base64::decode(s).ok()).flatten(); | ||||
|     let name = if reminder.name.is_empty() { name_default() } else { reminder.name.clone() }; | ||||
|     let username = if reminder.username.as_ref().map(|s| s.is_empty()).unwrap_or(true) { | ||||
|         None | ||||
|     } else { | ||||
|         reminder.username | ||||
|     }; | ||||
|  | ||||
|     let new_uid = generate_uid(); | ||||
|  | ||||
|     // write to db | ||||
|     match sqlx::query!( | ||||
|         "INSERT INTO reminders ( | ||||
|          uid, | ||||
|          attachment, | ||||
|          attachment_name, | ||||
|          channel_id, | ||||
|          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, | ||||
|          enabled, | ||||
|          expires, | ||||
|          interval_seconds, | ||||
|          interval_days, | ||||
|          interval_months, | ||||
|          name, | ||||
|          restartable, | ||||
|          tts, | ||||
|          username, | ||||
|          `utc_time` | ||||
|         ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", | ||||
|         new_uid, | ||||
|         attachment_data, | ||||
|         reminder.attachment_name, | ||||
|         channel, | ||||
|         reminder.avatar, | ||||
|         reminder.content, | ||||
|         reminder.embed_author, | ||||
|         reminder.embed_author_url, | ||||
|         reminder.embed_color, | ||||
|         reminder.embed_description, | ||||
|         reminder.embed_footer, | ||||
|         reminder.embed_footer_url, | ||||
|         reminder.embed_image_url, | ||||
|         reminder.embed_thumbnail_url, | ||||
|         reminder.embed_title, | ||||
|         reminder.embed_fields, | ||||
|         reminder.enabled, | ||||
|         reminder.expires, | ||||
|         reminder.interval_seconds, | ||||
|         reminder.interval_days, | ||||
|         reminder.interval_months, | ||||
|         name, | ||||
|         reminder.restartable, | ||||
|         reminder.tts, | ||||
|         username, | ||||
|         reminder.utc_time, | ||||
|     ) | ||||
|     .execute(pool) | ||||
|     .await | ||||
|     { | ||||
|         Ok(_) => 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 = ?", | ||||
|             new_uid | ||||
|         ) | ||||
|         .fetch_one(pool) | ||||
|         .await | ||||
|         .map(|r| Ok(json!(r))) | ||||
|         .unwrap_or_else(|e| { | ||||
|             warn!("Failed to complete SQL query: {:?}", e); | ||||
|  | ||||
|             Err(json!({"error": "Could not load reminder"})) | ||||
|         }), | ||||
|  | ||||
|         Err(e) => { | ||||
|             warn!("Error in `create_reminder`: Could not execute query: {:?}", e); | ||||
|  | ||||
|             Err(json!({"error": "Unknown error"})) | ||||
|         } | ||||
|     } | ||||
| } | ||||
|  | ||||
| async fn create_database_channel( | ||||
|     ctx: impl AsRef<Http>, | ||||
|     channel: ChannelId, | ||||
|     pool: impl Executor<'_, Database = Database> + Copy, | ||||
| ) -> Result<u32, crate::Error> { | ||||
|     println!("{:?}", channel); | ||||
|  | ||||
|     let row = | ||||
|         sqlx::query!("SELECT webhook_token, webhook_id FROM channels WHERE channel = ?", channel.0) | ||||
|             .fetch_one(pool) | ||||
|   | ||||
| @@ -61,10 +61,13 @@ pub async fn get_user_info( | ||||
|             .member(&ctx.inner(), user_id) | ||||
|             .await; | ||||
|  | ||||
|         let timezone = sqlx::query!("SELECT timezone FROM users WHERE user = ?", user_id) | ||||
|             .fetch_one(pool.inner()) | ||||
|             .await | ||||
|             .map_or(None, |q| Some(q.timezone)); | ||||
|         let timezone = sqlx::query!( | ||||
|             "SELECT IFNULL(timezone, 'UTC') AS timezone FROM users WHERE user = ?", | ||||
|             user_id | ||||
|         ) | ||||
|         .fetch_one(pool.inner()) | ||||
|         .await | ||||
|         .map_or(None, |q| Some(q.timezone)); | ||||
|  | ||||
|         let user_info = UserInfo { | ||||
|             name: cookies | ||||
|   | ||||
| @@ -11,7 +11,7 @@ use rocket::{ | ||||
| }; | ||||
| use serenity::model::user::User; | ||||
|  | ||||
| use crate::consts::DISCORD_API; | ||||
| use crate::{consts::DISCORD_API, routes}; | ||||
|  | ||||
| #[get("/discord")] | ||||
| pub async fn discord_login( | ||||
| @@ -25,7 +25,6 @@ pub async fn discord_login( | ||||
|         // Set the desired scopes. | ||||
|         .add_scope(Scope::new("identify".to_string())) | ||||
|         .add_scope(Scope::new("guilds".to_string())) | ||||
|         .add_scope(Scope::new("email".to_string())) | ||||
|         // Set the PKCE code challenge. | ||||
|         .set_pkce_challenge(pkce_challenge) | ||||
|         .url(); | ||||
| @@ -53,6 +52,15 @@ pub async fn discord_login( | ||||
|     Redirect::to(auth_url.to_string()) | ||||
| } | ||||
|  | ||||
| #[get("/discord/logout")] | ||||
| pub async fn discord_logout(cookies: &CookieJar<'_>) -> Redirect { | ||||
|     cookies.remove_private(Cookie::named("username")); | ||||
|     cookies.remove_private(Cookie::named("userid")); | ||||
|     cookies.remove_private(Cookie::named("access_token")); | ||||
|  | ||||
|     Redirect::to(uri!(routes::index)) | ||||
| } | ||||
|  | ||||
| #[get("/discord/authorized?<code>&<state>")] | ||||
| pub async fn discord_callback( | ||||
|     code: &str, | ||||
| @@ -136,14 +144,14 @@ pub async fn discord_callback( | ||||
|                     Err(Flash::new( | ||||
|                         Redirect::to(uri!(super::return_to_same_site(""))), | ||||
|                         "warning", | ||||
|                         "Your login request was rejected", | ||||
|                         "Your login request was rejected. The server may be misconfigured. Please retry or alert us in Discord.", | ||||
|                     )) | ||||
|                 } | ||||
|             } | ||||
|         } else { | ||||
|             Err(Flash::new(Redirect::to(uri!(super::return_to_same_site(""))), "danger", "Your request failed to validate, and so has been rejected (error: CSRF Validation Failure)")) | ||||
|             Err(Flash::new(Redirect::to(uri!(super::return_to_same_site(""))), "danger", "Your request failed to validate, and so has been rejected (CSRF Validation Failure)")) | ||||
|         } | ||||
|     } else { | ||||
|         Err(Flash::new(Redirect::to(uri!(super::return_to_same_site(""))), "warning", "Your request was missing information, and so has been rejected (error: CSRF Validation Tokens Missing)")) | ||||
|         Err(Flash::new(Redirect::to(uri!(super::return_to_same_site(""))), "warning", "Your request was missing information, and so has been rejected (CSRF Validation Tokens Missing)")) | ||||
|     } | ||||
| } | ||||
|   | ||||
| @@ -1,11 +1,15 @@ | ||||
| pub mod admin; | ||||
| pub mod dashboard; | ||||
| pub mod login; | ||||
| pub mod report; | ||||
|  | ||||
| use std::collections::HashMap; | ||||
|  | ||||
| use rocket::request::FlashMessage; | ||||
| use rocket::{request::FlashMessage, serde::json::Value as JsonValue}; | ||||
| use rocket_dyn_templates::Template; | ||||
|  | ||||
| pub type JsonResult = Result<JsonValue, JsonValue>; | ||||
|  | ||||
| #[get("/")] | ||||
| pub async fn index(flash: Option<FlashMessage<'_>>) -> Template { | ||||
|     let mut map: HashMap<&str, String> = HashMap::new(); | ||||
|   | ||||
							
								
								
									
										48
									
								
								web/src/routes/report.rs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										48
									
								
								web/src/routes/report.rs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,48 @@ | ||||
| use rocket::{ | ||||
|     http::CookieJar, | ||||
|     serde::{ | ||||
|         json::{json, Json}, | ||||
|         Deserialize, | ||||
|     }, | ||||
| }; | ||||
|  | ||||
| use crate::routes::JsonResult; | ||||
|  | ||||
| #[derive(Deserialize)] | ||||
| pub struct ClientError { | ||||
|     #[serde(rename = "reporterId")] | ||||
|     reporter_id: String, | ||||
|     url: String, | ||||
|     #[serde(rename = "relativeTimestamp")] | ||||
|     relative_timestamp: i64, | ||||
|     #[serde(rename = "errorMessage")] | ||||
|     error_message: String, | ||||
|     #[serde(rename = "errorLine")] | ||||
|     error_line: u64, | ||||
|     #[serde(rename = "errorFile")] | ||||
|     error_file: String, | ||||
|     #[serde(rename = "errorType")] | ||||
|     error_type: String, | ||||
| } | ||||
|  | ||||
| #[post("/report", data = "<client_error>")] | ||||
| pub async fn report_error(cookies: &CookieJar<'_>, client_error: Json<ClientError>) -> JsonResult { | ||||
|     if let Some(user_id) = cookies.get_private("userid") { | ||||
|         error!( | ||||
|             "User {} reports a client-side error. | ||||
| {}, {}:{} at {}ms | ||||
| {}: {} | ||||
| Chain: {}", | ||||
|             user_id, | ||||
|             client_error.url, | ||||
|             client_error.error_file, | ||||
|             client_error.error_line, | ||||
|             client_error.relative_timestamp, | ||||
|             client_error.error_type, | ||||
|             client_error.error_message, | ||||
|             client_error.reporter_id | ||||
|         ); | ||||
|     } | ||||
|  | ||||
|     Ok(json!({})) | ||||
| } | ||||
| @@ -11,7 +11,7 @@ div.reminderContent.is-collapsed .column.discord-frame { | ||||
|     display: none; | ||||
| } | ||||
|  | ||||
| div.reminderContent.is-collapsed .collapses { | ||||
| div.reminderContent.is-collapsed .column.settings { | ||||
|     display: none; | ||||
| } | ||||
|  | ||||
| @@ -23,42 +23,42 @@ div.reminderContent .invert-collapses { | ||||
|     display: none; | ||||
| } | ||||
|  | ||||
| div.reminderContent.is-collapsed .settings { | ||||
|     display: flex; | ||||
|     flex-direction: row; | ||||
|     padding-bottom: 0; | ||||
| } | ||||
|  | ||||
| div.reminderContent.is-collapsed .channel-field { | ||||
|     display: inline-flex; | ||||
|     order: 1; | ||||
| } | ||||
|  | ||||
| div.reminderContent.is-collapsed .reminder-topbar { | ||||
|     display: inline-flex; | ||||
|     margin-bottom: 0px; | ||||
|     flex-grow: 1; | ||||
|     order: 2; | ||||
| } | ||||
|  | ||||
| div.reminderContent.is-collapsed input[name="name"] { | ||||
|     display: inline-flex; | ||||
|     flex-grow: 1; | ||||
|     border: none; | ||||
|     font-weight: 700; | ||||
|     background: none; | ||||
|     box-shadow: none; | ||||
|     opacity: 1; | ||||
| } | ||||
|  | ||||
| div.reminderContent.is-collapsed button.hide-box { | ||||
| div.reminderContent.is-collapsed .hide-box { | ||||
|     display: inline-flex; | ||||
| } | ||||
|  | ||||
| div.reminderContent.is-collapsed button.hide-box i { | ||||
| div.reminderContent.is-collapsed .hide-box i { | ||||
|     transform: rotate(90deg); | ||||
| } | ||||
| /* END */ | ||||
|  | ||||
| /* dashboard styles */ | ||||
| .hide-box { | ||||
|     border: none; | ||||
|     background: none; | ||||
| } | ||||
|  | ||||
| .hide-box:focus { | ||||
|     outline: none; | ||||
|     box-shadow: none !important; | ||||
| } | ||||
|  | ||||
| .channel-bar { | ||||
|     display: flex; | ||||
|     justify-content: center; | ||||
|     flex-direction: column; | ||||
|     font-weight: bold; | ||||
| } | ||||
|  | ||||
| button.inline-btn { | ||||
|     height: 100%; | ||||
|     padding: 5px; | ||||
| @@ -85,18 +85,80 @@ div.discord-embed { | ||||
|     position: relative; | ||||
| } | ||||
|  | ||||
| div.reminderContent { | ||||
|     padding: 2px; | ||||
|     background-color: #f5f5f5; | ||||
|     border-radius: 8px; | ||||
|     margin: 8px; | ||||
| div.split-controls { | ||||
|     display: flex; | ||||
|     flex-direction: column; | ||||
|     justify-content: space-between; | ||||
|     flex-grow: 2; | ||||
| } | ||||
|  | ||||
| div.interval-group > button { | ||||
|     margin-left: auto; | ||||
| .reminder-topbar > div { | ||||
|     padding-left: 6px; | ||||
|     padding-right: 6px; | ||||
| } | ||||
|  | ||||
| .settings { | ||||
|     display: flex; | ||||
|     flex-direction: column; | ||||
| } | ||||
|  | ||||
| .name-bar { | ||||
|     flex-grow: 1; | ||||
|     flex-shrink: 1; | ||||
| } | ||||
|  | ||||
| .hide-button-bar { | ||||
|     flex-grow: 0; | ||||
|     flex-shrink: 0; | ||||
| } | ||||
|  | ||||
| .patreon-only { | ||||
|     padding-bottom: 16px; | ||||
| } | ||||
|  | ||||
| .tts-row { | ||||
|     padding-bottom: 10px; | ||||
| } | ||||
|  | ||||
| .reminder-topbar { | ||||
|     display: flex; | ||||
|     margin-bottom: 0 !important; | ||||
| } | ||||
|  | ||||
| .reminder-settings { | ||||
|     margin-top: 0 !important; | ||||
| } | ||||
|  | ||||
| div.reminderContent { | ||||
|     margin-top: 10px; | ||||
|     margin-bottom: 10px; | ||||
|     padding: 14px; | ||||
|     background-color: #f5f5f5; | ||||
|     border-radius: 8px; | ||||
| } | ||||
|  | ||||
| /* Interval inputs */ | ||||
| div.interval-group { | ||||
|     height: unset !important; | ||||
| } | ||||
|  | ||||
| div.interval-group .clear:focus { | ||||
|     outline: none; | ||||
|     box-shadow: none !important; | ||||
| } | ||||
|  | ||||
| div.interval-group .no-break { | ||||
|     text-wrap: avoid; | ||||
|     white-space: nowrap; | ||||
| } | ||||
|  | ||||
| div.interval-group .clear { | ||||
|     border: none; | ||||
|     background: none; | ||||
|     padding: 1px; | ||||
|     margin-right: -3px; | ||||
| } | ||||
|  | ||||
| div.interval-group > .interval-group-left input { | ||||
|     -webkit-appearance: none; | ||||
|     border-style: none; | ||||
| @@ -110,12 +172,13 @@ div.interval-group > .interval-group-left input.w2 { | ||||
| } | ||||
|  | ||||
| div.interval-group > .interval-group-left input.w3 { | ||||
|     width: 6ch; | ||||
|     width: 3ch; | ||||
| } | ||||
|  | ||||
| div.interval-group { | ||||
|     display: flex; | ||||
|     flex-direction: row; | ||||
|     justify-content: space-between; | ||||
| } | ||||
| /* !Interval inputs */ | ||||
|  | ||||
| @@ -180,6 +243,23 @@ div#pageNavbar a { | ||||
|     text-align: center; | ||||
| } | ||||
|  | ||||
| .navbar-burger { | ||||
|     flex-shrink: 0; | ||||
| } | ||||
|  | ||||
| .navbar-item.pageTitle { | ||||
|     flex-shrink: 1; | ||||
|     text-wrap: nowrap; | ||||
|     overflow: hidden; | ||||
| } | ||||
|  | ||||
| .navbar-burger, .navbar-burger:active, .navbar-burger.is-active { | ||||
|     background-color: #adc99c !important; | ||||
|     border-radius: 14px; | ||||
|     padding: 6px; | ||||
|     background-clip: content-box; | ||||
| } | ||||
|  | ||||
| div#pageNavbar a:hover { | ||||
|     background-color: #4a4a4a; | ||||
| } | ||||
| @@ -293,10 +373,7 @@ input.default-width { | ||||
| } | ||||
|  | ||||
| .message-input:placeholder-shown { | ||||
|     border-top: none; | ||||
|     border-left: none; | ||||
|     border-right: none; | ||||
|     border-bottom-style: dashed; | ||||
|     font-style: italic; | ||||
|     background-color: #40444b; | ||||
|     color: #fff; | ||||
| } | ||||
| @@ -462,6 +539,7 @@ input.default-width { | ||||
|     flex-grow: 1; | ||||
|     flex-shrink: 1; | ||||
|     flex-basis: auto; | ||||
|     margin-right: 4px; | ||||
| } | ||||
|  | ||||
| .embed-body input, .embed-body textarea { | ||||
| @@ -511,6 +589,67 @@ input.default-width { | ||||
|     border-bottom: 1px solid #fff; | ||||
| } | ||||
|  | ||||
| li.highlight { | ||||
|     margin-bottom: 0 !important; | ||||
| } | ||||
|  | ||||
| .button-row { | ||||
|     display: flex; | ||||
| } | ||||
|  | ||||
| .button-row .button-row-reminder { | ||||
|     flex-grow: 0; | ||||
|     padding: 2px; | ||||
| } | ||||
|  | ||||
| .button-row-template { | ||||
|     display: flex; | ||||
|     flex-grow: 1; | ||||
|     justify-content: space-between; | ||||
| } | ||||
|  | ||||
| .button-row .button-row-template > div { | ||||
|     padding: 2px; | ||||
| } | ||||
|  | ||||
| @media only screen and (max-width: 1408px) { | ||||
|     .button-row { | ||||
|         display: flex; | ||||
|         flex-direction: column; | ||||
|     } | ||||
|  | ||||
|     .button-row .button-row-reminder { | ||||
|         width: 100%; | ||||
|     } | ||||
|  | ||||
|     .button-row .button-row-template > div { | ||||
|         flex-basis: 0; | ||||
|         flex-grow: 1; | ||||
|     } | ||||
|  | ||||
|     .button-row button { | ||||
|         width: 100%; | ||||
|     } | ||||
| } | ||||
|  | ||||
| @media only screen and (max-width: 768px) { | ||||
|     .button-row-edit { | ||||
|         display: flex; | ||||
|         flex-direction: column; | ||||
|     } | ||||
|  | ||||
|     .button-row-edit > button { | ||||
|         width: 100%; | ||||
|         margin: 4px; | ||||
|     } | ||||
|  | ||||
|     p.title.pageTitle { | ||||
|         visibility: hidden; | ||||
|         text-wrap: nowrap; | ||||
|         overflow: hidden; | ||||
|     } | ||||
| } | ||||
|  | ||||
| @media only screen and (max-width: 768px) { | ||||
|     .customizable.thumbnail img { | ||||
|         width: 60px; | ||||
| @@ -568,6 +707,16 @@ input.default-width { | ||||
|     background-color: white; | ||||
| } | ||||
|  | ||||
| a.switch-pane { | ||||
|     white-space: nowrap; | ||||
|     overflow: hidden; | ||||
|     text-overflow: ellipsis; | ||||
| } | ||||
|  | ||||
| .feedback { | ||||
|     background-color: #5865F2; | ||||
| } | ||||
|  | ||||
| .is-locked { | ||||
|     pointer-events: none; | ||||
|     opacity: 0.4; | ||||
| @@ -580,3 +729,27 @@ input.default-width { | ||||
| .is-locked .field:last-of-type { | ||||
|     display: none; | ||||
| } | ||||
|  | ||||
| .stat-row { | ||||
|     display: flex; | ||||
|     flex-direction: row; | ||||
| } | ||||
|  | ||||
| .stat-box { | ||||
|     flex-grow: 1; | ||||
|     border-radius: 6px; | ||||
|     background-color: #fcfcfc; | ||||
|     border-color: #efefef; | ||||
|     border-style: solid; | ||||
|     border-width: 1px; | ||||
|     margin: 4px; | ||||
|     padding: 4px; | ||||
| } | ||||
|  | ||||
| .figure { | ||||
|     text-align: center; | ||||
| } | ||||
|  | ||||
| .figure-num { | ||||
|     font-size: 2em; | ||||
| } | ||||
|   | ||||
							
								
								
									
										
											BIN
										
									
								
								web/static/img/logo_nobg.webp
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										
											BIN
										
									
								
								web/static/img/logo_nobg.webp
									
									
									
									
									
										Normal file
									
								
							
										
											Binary file not shown.
										
									
								
							| After Width: | Height: | Size: 81 KiB | 
							
								
								
									
										
											BIN
										
									
								
								web/static/img/support/iemanager/edit_spreadsheet.png
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										
											BIN
										
									
								
								web/static/img/support/iemanager/edit_spreadsheet.png
									
									
									
									
									
										Normal file
									
								
							
										
											Binary file not shown.
										
									
								
							| After Width: | Height: | Size: 44 KiB | 
							
								
								
									
										
											BIN
										
									
								
								web/static/img/support/iemanager/format_text.png
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										
											BIN
										
									
								
								web/static/img/support/iemanager/format_text.png
									
									
									
									
									
										Normal file
									
								
							
										
											Binary file not shown.
										
									
								
							| After Width: | Height: | Size: 40 KiB | 
Some files were not shown because too many files have changed in this diff Show More
		Reference in New Issue
	
	Block a user