Compare commits
	
		
			158 Commits
		
	
	
		
			jellywx/ma
			...
			jude/react
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
| 
						 | 
					1a03c2471b | ||
| a476f43f28 | |||
| 17192b0f89 | |||
| 
						 | 
					0419863afa | ||
| 
						 | 
					827a982a40 | ||
| 
						 | 
					6e435bfc2e | ||
| 8ba0f02b98 | |||
| d36438c6ce | |||
| e0c60e2ce3 | |||
| 
						 | 
					e7160215b0 | ||
| 
						 | 
					6eaa6f0f28 | ||
| 
						 | 
					9db0fa2513 | ||
| 
						 | 
					ca13fd4fa7 | ||
| 
						 | 
					55acc8fd16 | ||
| 
						 | 
					145711fa5d | ||
| 
						 | 
					5524215786 | ||
| 
						 | 
					e8bd05893f | ||
| 
						 | 
					e3d3418f99 | ||
| 
						 | 
					2681280a39 | ||
| 
						 | 
					00579428a1 | ||
| 
						 | 
					b8ef999710 | ||
| 
						 | 
					e8f84e281a | ||
| 
						 | 
					8ddff698e5 | ||
| 
						 | 
					541633270c | ||
| 
						 | 
					25286da5e0 | ||
| 
						 | 
					4bad1324b9 | ||
| 
						 | 
					bd1462a00c | ||
| 
						 | 
					56ffc43616 | ||
| 
						 | 
					52cf642455 | ||
| 
						 | 
					0bf578357a | ||
| 
						 | 
					6e9eccb62e | ||
| 
						 | 
					6ea28284ce | ||
| 
						 | 
					a6525f3052 | ||
| 
						 | 
					348639270d | ||
| 
						 | 
					37177c2431 | ||
| 
						 | 
					8587bed703 | ||
| 
						 | 
					6c9af1ae8e | ||
| 
						 | 
					7695b7a476 | ||
| 651da7b28e | |||
| eb086146bf | |||
| 4ebd705e5e | |||
| 5a85f1d83a | |||
| 68ba25886a | |||
| 
						 | 
					e25bf6b828 | ||
| 
						 | 
					5a386daa9d | ||
| 
						 | 
					0d4a02fb1e | ||
| 
						 | 
					e135a74a9b | ||
| 
						 | 
					77f17c8dc2 | ||
| 
						 | 
					6a94f990cf | ||
| 
						 | 
					3aa5bd37aa | ||
| 
						 | 
					fa83fed1af | ||
| 
						 | 
					666cb7fa2f | ||
| 
						 | 
					a5678e15dc | ||
| 
						 | 
					9405cfcee9 | ||
| 
						 | 
					cb25d02cdf | ||
| 
						 | 
					bfe651a125 | ||
| 
						 | 
					dc5e52d9ce | ||
| 
						 | 
					229ada83e1 | ||
| 
						 | 
					13171d6744 | ||
| 
						 | 
					2ad941c94c | ||
| 
						 | 
					924d31e978 | ||
| 
						 | 
					f9a1b23212 | ||
| 
						 | 
					ae5795a7ea | ||
| 
						 | 
					ee36c38eda | ||
| 
						 | 
					eca7df3d9f | ||
| 
						 | 
					902b7e1b4a | ||
| 
						 | 
					db1a53a797 | ||
| 
						 | 
					3605d71b73 | ||
| 
						 | 
					ea2cea573e | ||
| 
						 | 
					d5fa8036e8 | ||
| 
						 | 
					b8707bbc9a | ||
| 
						 | 
					99eea16f62 | ||
| 
						 | 
					88737302f3 | ||
| 
						 | 
					213e3a5100 | ||
| 
						 | 
					8fa1402ecc | ||
| 
						 | 
					e63996bb61 | ||
| 
						 | 
					9ede879630 | ||
| 
						 | 
					88e9826a62 | ||
| 
						 | 
					5d655c7e6d | ||
| 
						 | 
					51c9d8a7ae | ||
| 
						 | 
					90df265114 | ||
| 
						 | 
					e65429aa9c | ||
| 
						 | 
					8d2232f0da | ||
| 
						 | 
					a58b9866ea | ||
| 
						 | 
					b1f25be5d7 | ||
| 
						 | 
					f0f9787326 | ||
| 
						 | 
					302f5835e6 | ||
| 
						 | 
					58c778632e | ||
| 
						 | 
					5671fd462b | ||
| 
						 | 
					5ac9733f15 | ||
| 
						 | 
					01dc0334fd | ||
| 
						 | 
					4a17aac15c | ||
| 
						 | 
					8ce4fc9c6d | ||
| 
						 | 
					b4f07cfc1c | ||
| 
						 | 
					8799089b2d | ||
| 
						 | 
					88c4830209 | ||
| 
						 | 
					4dd3df5cc2 | ||
| 
						 | 
					369a325a46 | ||
| 
						 | 
					1a1a0fdefb | ||
| 
						 | 
					dda8bd3e10 | ||
| 
						 | 
					edbfc92cb9 | ||
| 
						 | 
					6de11f09db | ||
| 
						 | 
					284bfcd9ad | ||
| 
						 | 
					3d627b5bf0 | ||
| 
						 | 
					c3c0dbbbae | ||
| 
						 | 
					64dd81e941 | ||
| 
						 | 
					799298ca34 | ||
| 
						 | 
					fa542bb24f | ||
| 
						 | 
					e025d945cf | ||
| 
						 | 
					bb1c61d0b9 | ||
| 
						 | 
					1519474f93 | ||
| 
						 | 
					9d8622f418 | ||
| 
						 | 
					a66db37b33 | ||
| 
						 | 
					c8c1a171d4 | ||
| 
						 | 
					88cfb829e3 | ||
| 
						 | 
					16be7a328e | ||
| 
						 | 
					04babf7930 | ||
| 
						 | 
					96bc09e8b5 | ||
| 
						 | 
					976fb91ecc | ||
| 
						 | 
					1305b6e64e | ||
| 
						 | 
					cdfe44d958 | ||
| 
						 | 
					c824a36832 | ||
| 
						 | 
					c4bd2c1d18 | ||
| 
						 | 
					561555ab7e | ||
| 
						 | 
					115fbd44cb | ||
| 
						 | 
					aa931328b0 | ||
| 4b42966284 | |||
| 523ab7f03a | |||
| 6e831c8253 | |||
| 
						 | 
					4416e5d175 | ||
| 
						 | 
					734a39a001 | ||
| 
						 | 
					98191d29ee | ||
| 
						 | 
					1c4c4a8b31 | ||
| 
						 | 
					d496c81003 | ||
| 
						 | 
					094d210f64 | ||
| 
						 | 
					314c72e132 | ||
| 
						 | 
					4e0163f2cb | ||
| 
						 | 
					e5b8c418af | ||
| 
						 | 
					3ef8584189 | ||
| 
						 | 
					df2ad09c86 | ||
| 
						 | 
					d70fb24eb1 | ||
| 
						 | 
					3150c7267d | ||
| 
						 | 
					6e65e4ff3d | ||
| 
						 | 
					67a4db2e9a | ||
| 
						 | 
					e9bcb1973f | ||
| 
						 | 
					9b87fd4258 | ||
| 
						 | 
					a49a849917 | ||
| 
						 | 
					aa74a7f9a3 | ||
| 
						 | 
					08e4c6cb57 | ||
| 
						 | 
					6e087bd2dd | ||
| e9792e6322 | |||
| 130504b964 | |||
| 2a8117d0c1 | |||
| 94bfd39085 | |||
| 40cd5f8a36 | |||
| 133b00a2ce | |||
| 57336f5c81 | |||
| b62d24c024 | 
							
								
								
									
										4
									
								
								.gitignore
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										4
									
								
								.gitignore
									
									
									
									
										vendored
									
									
								
							@@ -2,6 +2,6 @@
 | 
				
			|||||||
.env
 | 
					.env
 | 
				
			||||||
/venv
 | 
					/venv
 | 
				
			||||||
.cargo
 | 
					.cargo
 | 
				
			||||||
assets
 | 
					 | 
				
			||||||
out.json
 | 
					 | 
				
			||||||
/.idea
 | 
					/.idea
 | 
				
			||||||
 | 
					web/static/index.html
 | 
				
			||||||
 | 
					web/static/assets
 | 
				
			||||||
 
 | 
				
			|||||||
							
								
								
									
										2462
									
								
								Cargo.lock
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										2462
									
								
								Cargo.lock
									
									
									
										generated
									
									
									
								
							
										
											
												File diff suppressed because it is too large
												Load Diff
											
										
									
								
							
							
								
								
									
										48
									
								
								Cargo.toml
									
									
									
									
									
								
							
							
						
						
									
										48
									
								
								Cargo.toml
									
									
									
									
									
								
							@@ -1,20 +1,22 @@
 | 
				
			|||||||
[package]
 | 
					[package]
 | 
				
			||||||
name = "reminder_rs"
 | 
					name = "reminder-rs"
 | 
				
			||||||
version = "1.6.5"
 | 
					version = "1.6.50"
 | 
				
			||||||
authors = ["jellywx <judesouthworth@pm.me>"]
 | 
					authors = ["Jude Southworth <judesouthworth@pm.me>"]
 | 
				
			||||||
edition = "2018"
 | 
					edition = "2021"
 | 
				
			||||||
 | 
					license = "AGPL-3.0 only"
 | 
				
			||||||
 | 
					description = "Reminder Bot for Discord, now in Rust"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
[dependencies]
 | 
					[dependencies]
 | 
				
			||||||
poise = "0.3"
 | 
					poise = "0.5"
 | 
				
			||||||
dotenv = "0.15"
 | 
					dotenv = "0.15"
 | 
				
			||||||
tokio = { version = "1", features = ["process", "full"] }
 | 
					tokio = { version = "1", features = ["process", "full"] }
 | 
				
			||||||
reqwest = "0.11"
 | 
					reqwest = "0.11"
 | 
				
			||||||
lazy-regex = "2.3.0"
 | 
					lazy-regex = "3.0.2"
 | 
				
			||||||
regex = "1.6"
 | 
					regex = "1.9"
 | 
				
			||||||
log = "0.4"
 | 
					log = "0.4"
 | 
				
			||||||
env_logger = "0.9"
 | 
					env_logger = "0.10"
 | 
				
			||||||
chrono = "0.4"
 | 
					chrono = "0.4"
 | 
				
			||||||
chrono-tz = { version = "0.6", features = ["serde"] }
 | 
					chrono-tz = { version = "0.8", features = ["serde"] }
 | 
				
			||||||
lazy_static = "1.4"
 | 
					lazy_static = "1.4"
 | 
				
			||||||
num-integer = "0.1"
 | 
					num-integer = "0.1"
 | 
				
			||||||
serde = "1.0"
 | 
					serde = "1.0"
 | 
				
			||||||
@@ -23,11 +25,35 @@ serde_repr = "0.1"
 | 
				
			|||||||
rmp-serde = "1.1"
 | 
					rmp-serde = "1.1"
 | 
				
			||||||
rand = "0.8"
 | 
					rand = "0.8"
 | 
				
			||||||
levenshtein = "1.0"
 | 
					levenshtein = "1.0"
 | 
				
			||||||
sqlx = { version = "0.6", features = ["runtime-tokio-rustls", "macros", "mysql", "bigdecimal", "chrono"]}
 | 
					sqlx = { version = "0.7", features = ["runtime-tokio-rustls", "macros", "mysql", "bigdecimal", "chrono", "migrate"]}
 | 
				
			||||||
base64 = "0.13"
 | 
					base64 = "0.21.0"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
[dependencies.postman]
 | 
					[dependencies.postman]
 | 
				
			||||||
path = "postman"
 | 
					path = "postman"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
[dependencies.reminder_web]
 | 
					[dependencies.reminder_web]
 | 
				
			||||||
path = "web"
 | 
					path = "web"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					[package.metadata.deb]
 | 
				
			||||||
 | 
					depends = "$auto, python3-dateparser (>= 1.0.0)"
 | 
				
			||||||
 | 
					suggests = "mysql-server-8.0, nginx"
 | 
				
			||||||
 | 
					maintainer-scripts = "debian"
 | 
				
			||||||
 | 
					assets = [
 | 
				
			||||||
 | 
					    ["target/release/reminder-rs", "usr/bin/reminder-rs", "755"],
 | 
				
			||||||
 | 
					    ["conf/default.env", "etc/reminder-rs/config.env", "600"],
 | 
				
			||||||
 | 
					    ["conf/Rocket.toml", "etc/reminder-rs/Rocket.toml", "600"],
 | 
				
			||||||
 | 
					    ["web/static/**/*", "lib/reminder-rs/static", "644"],
 | 
				
			||||||
 | 
					    ["reminder-dashboard/dist/static/**/*", "lib/reminder-rs/static", "644"],
 | 
				
			||||||
 | 
					    ["web/templates/**/*", "lib/reminder-rs/templates", "644"],
 | 
				
			||||||
 | 
					    ["healthcheck", "lib/reminder-rs/healthcheck", "755"],
 | 
				
			||||||
 | 
					    ["cron.d/reminder_health", "etc/cron.d/reminder_health", "644"],
 | 
				
			||||||
 | 
					#    ["nginx/reminder-rs", "etc/nginx/sites-available/reminder-rs", "755"]
 | 
				
			||||||
 | 
					]
 | 
				
			||||||
 | 
					conf-files = [
 | 
				
			||||||
 | 
					    "/etc/reminder-rs/config.env",
 | 
				
			||||||
 | 
					    "/etc/reminder-rs/Rocket.toml",
 | 
				
			||||||
 | 
					]
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					[package.metadata.deb.systemd-units]
 | 
				
			||||||
 | 
					unit-scripts = "systemd"
 | 
				
			||||||
 | 
					start = false
 | 
				
			||||||
 
 | 
				
			|||||||
							
								
								
									
										9
									
								
								Containerfile
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										9
									
								
								Containerfile
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,9 @@
 | 
				
			|||||||
 | 
					FROM ubuntu:20.04
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					ENV RUSTUP_HOME=/usr/local/rustup \
 | 
				
			||||||
 | 
					    CARGO_HOME=/usr/local/cargo \
 | 
				
			||||||
 | 
					    PATH=/usr/local/cargo/bin:$PATH
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					RUN apt update && DEBIAN_FRONTEND=noninteractive TZ=Etc/UTC apt install -y gcc gcc-multilib cmake pkg-config libssl-dev curl mysql-client-8.0
 | 
				
			||||||
 | 
					RUN curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --no-modify-path --profile minimal --default-toolchain nightly
 | 
				
			||||||
 | 
					RUN cargo install cargo-deb
 | 
				
			||||||
							
								
								
									
										44
									
								
								README.md
									
									
									
									
									
								
							
							
						
						
									
										44
									
								
								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)
 | 
					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
 | 
					### Build APT package
 | 
				
			||||||
Install build requirements: 
 | 
					 | 
				
			||||||
`sudo apt install gcc gcc-multilib cmake libssl-dev build-essential`
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
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 
 | 
					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.
 | 
				
			||||||
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.
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
#### Compilation environment variables
 | 
					1. Install container software: `sudo apt install podman`.
 | 
				
			||||||
These environment variables must be provided when compiling the bot
 | 
					2. Install database server: `sudo apt install mysql-server-8.0`. Create a database called `reminders`
 | 
				
			||||||
 | 
					3. Install SQLx CLI: `cargo install sqlx-cli`
 | 
				
			||||||
 | 
					4. From the source code directory, execute `sqlx migrate run`
 | 
				
			||||||
 | 
					5. Build container image: `podman build -t reminder-rs .`
 | 
				
			||||||
 | 
					6. Build with podman: `podman run --rm --network=host -v "$PWD":/mnt -w /mnt -e "DATABASE_URL=mysql://user@localhost/reminders" reminder-rs cargo deb` 
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					### Compiling for other target
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					1. Install requirements: 
 | 
				
			||||||
 | 
					`sudo apt install gcc gcc-multilib cmake libssl-dev build-essential python3-dateparser`
 | 
				
			||||||
 | 
					2. Install rustup from https://rustup.rs
 | 
				
			||||||
 | 
					3. Install the nightly toolchain: `rustup toolchain default nightly`
 | 
				
			||||||
 | 
					4. Install database server: `sudo apt install mysql-server-8.0`. Create a database called `reminders`.
 | 
				
			||||||
 | 
					5. Install `sqlx-cli`: `cargo install sqlx-cli`.
 | 
				
			||||||
 | 
					6. Run migrations: `sqlx migrate run`.
 | 
				
			||||||
 | 
					7. Set environment variables:
 | 
				
			||||||
   * `DATABASE_URL` - the URL of your MySQL database (`mysql://user[:password]@domain/database`)
 | 
					   * `DATABASE_URL` - the URL of your MySQL database (`mysql://user[:password]@domain/database`)
 | 
				
			||||||
* `WEBHOOK_AVATAR` - accepts the name of an image file located in `$CARGO_MANIFEST_DIR/assets/` to be used as the avatar when creating webhooks. **IMPORTANT: image file must be 128x128 or smaller in size**
 | 
					8. Build: `cargo build --release`
 | 
				
			||||||
 | 
					
 | 
				
			||||||
### Setting up Python
 | 
					 | 
				
			||||||
Reminder Bot by default looks for a venv within it's working directory to run Python out of. To set up a venv, install `python3-venv` and run `python3 -m venv venv`. Then, run `source venv/bin/activate` to activate the venv, and do `pip install dateparser` to install the required library
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
### Environment Variables
 | 
					### Configuring
 | 
				
			||||||
 | 
					
 | 
				
			||||||
Reminder Bot reads a number of environment variables. Some are essential, and others have hardcoded fallbacks. Environment variables can be loaded from a .env file in the working directory.
 | 
					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__
 | 
					__Required Variables__
 | 
				
			||||||
@@ -37,10 +48,5 @@ __Other Variables__
 | 
				
			|||||||
* `LOCAL_TIMEZONE` - default `UTC`, necessary for calculations in the natural language processor
 | 
					* `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
 | 
					* `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
 | 
					* `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 
 | 
					* `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]
 | 
					[default]
 | 
				
			||||||
address = "0.0.0.0"
 | 
					address = "0.0.0.0"
 | 
				
			||||||
port = 5000
 | 
					port = 18920
 | 
				
			||||||
template_dir = "web/templates"
 | 
					template_dir = "web/templates"
 | 
				
			||||||
limits = { json = "10MiB" }
 | 
					limits = { json = "10MiB" }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -11,18 +11,18 @@ secret_key = "tR8krio5FXTnnyIZNiJDXPondz0kI1v6X6BXZcBGIRY="
 | 
				
			|||||||
certs = "web/private/rsa_sha256_cert.pem"
 | 
					certs = "web/private/rsa_sha256_cert.pem"
 | 
				
			||||||
key = "web/private/rsa_sha256_key.pem"
 | 
					key = "web/private/rsa_sha256_key.pem"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
[rsa_sha256.tls]
 | 
					[debug.rsa_sha256.tls]
 | 
				
			||||||
certs = "web/private/rsa_sha256_cert.pem"
 | 
					certs = "web/private/rsa_sha256_cert.pem"
 | 
				
			||||||
key = "web/private/rsa_sha256_key.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"
 | 
					certs = "web/private/ecdsa_nistp256_sha256_cert.pem"
 | 
				
			||||||
key = "web/private/ecdsa_nistp256_sha256_key_pkcs8.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"
 | 
					certs = "web/private/ecdsa_nistp384_sha384_cert.pem"
 | 
				
			||||||
key = "web/private/ecdsa_nistp384_sha384_key_pkcs8.pem"
 | 
					key = "web/private/ecdsa_nistp384_sha384_key_pkcs8.pem"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
[ed25519.tls]
 | 
					[debug.ed25519.tls]
 | 
				
			||||||
certs = "web/private/ed25519_cert.pem"
 | 
					certs = "web/private/ed25519_cert.pem"
 | 
				
			||||||
key = "eb/private/ed25519_key.pem"
 | 
					key = "eb/private/ed25519_key.pem"
 | 
				
			||||||
 
 | 
				
			|||||||
							
								
								
									
										
											BIN
										
									
								
								assets/webhook.jpg
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										
											BIN
										
									
								
								assets/webhook.jpg
									
									
									
									
									
										Normal file
									
								
							
										
											Binary file not shown.
										
									
								
							| 
		 After Width: | Height: | Size: 21 KiB  | 
							
								
								
									
										3
									
								
								build.rs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										3
									
								
								build.rs
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,3 @@
 | 
				
			|||||||
 | 
					fn main() {
 | 
				
			||||||
 | 
					    println!("cargo:rerun-if-changed=migrations");
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
							
								
								
									
										8
									
								
								conf/Rocket.toml
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										8
									
								
								conf/Rocket.toml
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,8 @@
 | 
				
			|||||||
 | 
					[default]
 | 
				
			||||||
 | 
					address = "127.0.0.1"
 | 
				
			||||||
 | 
					port = 18920
 | 
				
			||||||
 | 
					template_dir = "/lib/reminder-rs/templates"
 | 
				
			||||||
 | 
					limits = { json = "10MiB" }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					[release]
 | 
				
			||||||
 | 
					# secret_key = ""
 | 
				
			||||||
							
								
								
									
										19
									
								
								conf/default.env
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										19
									
								
								conf/default.env
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,19 @@
 | 
				
			|||||||
 | 
					DATABASE_URL=
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					DISCORD_TOKEN=
 | 
				
			||||||
 | 
					PATREON_GUILD_ID=
 | 
				
			||||||
 | 
					PATREON_ROLE_ID=
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					LOCAL_TIMEZONE=
 | 
				
			||||||
 | 
					MIN_INTERVAL=
 | 
				
			||||||
 | 
					PYTHON_LOCATION=/usr/bin/python3
 | 
				
			||||||
 | 
					DONTRUN=
 | 
				
			||||||
 | 
					SECRET_KEY=
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					REMIND_INTERVAL=
 | 
				
			||||||
 | 
					OAUTH2_DISCORD_CALLBACK=
 | 
				
			||||||
 | 
					OAUTH2_CLIENT_ID=
 | 
				
			||||||
 | 
					OAUTH2_CLIENT_SECRET=
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					REPORT_EMAIL=
 | 
				
			||||||
 | 
					LOG_TO_DATABASE=1
 | 
				
			||||||
							
								
								
									
										1
									
								
								cron.d/reminder_health
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										1
									
								
								cron.d/reminder_health
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1 @@
 | 
				
			|||||||
 | 
					*/10 * * * * reminder /lib/reminder-rs/healthcheck
 | 
				
			||||||
							
								
								
									
										9
									
								
								debian/postinst
									
									
									
									
										vendored
									
									
										Normal file
									
								
							
							
						
						
									
										9
									
								
								debian/postinst
									
									
									
									
										vendored
									
									
										Normal file
									
								
							@@ -0,0 +1,9 @@
 | 
				
			|||||||
 | 
					#!/bin/bash
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					set -e
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					id -u reminder &>/dev/null || useradd -r -M reminder
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					chown -R reminder /etc/reminder-rs
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					#DEBHELPER#
 | 
				
			||||||
							
								
								
									
										7
									
								
								debian/postrm
									
									
									
									
										vendored
									
									
										Normal file
									
								
							
							
						
						
									
										7
									
								
								debian/postrm
									
									
									
									
										vendored
									
									
										Normal file
									
								
							@@ -0,0 +1,7 @@
 | 
				
			|||||||
 | 
					#!/bin/bash
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					set -e
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					id -u reminder &>/dev/null || userdel reminder
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					#DEBHELPER#
 | 
				
			||||||
							
								
								
									
										13
									
								
								healthcheck
									
									
									
									
									
										Executable file
									
								
							
							
						
						
									
										13
									
								
								healthcheck
									
									
									
									
									
										Executable file
									
								
							@@ -0,0 +1,13 @@
 | 
				
			|||||||
 | 
					#!/bin/bash
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					export $(grep -v '^#' /etc/reminder-rs/config.env | xargs -d '\n')
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					REGEX='mysql://([A-Za-z]+)@(.+)/(.+)'
 | 
				
			||||||
 | 
					[[ $DATABASE_URL =~ $REGEX ]]
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					VAR=$(mysql -u "${BASH_REMATCH[1]}" -h "${BASH_REMATCH[2]}" -N -D "${BASH_REMATCH[3]}" -e "SELECT COUNT(1) FROM reminders WHERE utc_time < NOW() - INTERVAL 10 MINUTE AND enabled = 1 AND status = 'pending'")
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					if [ "$VAR" -gt 0 ]
 | 
				
			||||||
 | 
					then
 | 
				
			||||||
 | 
					  echo "This is to inform that there is a reminder backlog which must be resolved." | mail -s "Backlog: $VAR" "$REPORT_EMAIL"
 | 
				
			||||||
 | 
					fi
 | 
				
			||||||
@@ -1,4 +0,0 @@
 | 
				
			|||||||
USE reminders;
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
ALTER TABLE reminders.reminders RENAME COLUMN `interval` TO `interval_seconds`;
 | 
					 | 
				
			||||||
ALTER TABLE reminders.reminders ADD COLUMN `interval_months` INT UNSIGNED DEFAULT NULL;
 | 
					 | 
				
			||||||
@@ -1,10 +1,6 @@
 | 
				
			|||||||
CREATE DATABASE IF NOT EXISTS reminders;
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
SET FOREIGN_KEY_CHECKS=0;
 | 
					SET FOREIGN_KEY_CHECKS=0;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
USE reminders;
 | 
					CREATE TABLE guilds (
 | 
				
			||||||
 | 
					 | 
				
			||||||
CREATE TABLE reminders.guilds (
 | 
					 | 
				
			||||||
    id INT UNSIGNED UNIQUE NOT NULL AUTO_INCREMENT,
 | 
					    id INT UNSIGNED UNIQUE NOT NULL AUTO_INCREMENT,
 | 
				
			||||||
    guild BIGINT UNSIGNED UNIQUE NOT NULL,
 | 
					    guild BIGINT UNSIGNED UNIQUE NOT NULL,
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -18,10 +14,10 @@ CREATE TABLE reminders.guilds (
 | 
				
			|||||||
    default_avatar VARCHAR(512) DEFAULT 'https://raw.githubusercontent.com/reminder-bot/logos/master/Remind_Me_Bot_Logo_PPic.jpg' NOT NULL,
 | 
					    default_avatar VARCHAR(512) DEFAULT 'https://raw.githubusercontent.com/reminder-bot/logos/master/Remind_Me_Bot_Logo_PPic.jpg' NOT NULL,
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    PRIMARY KEY (id),
 | 
					    PRIMARY KEY (id),
 | 
				
			||||||
    FOREIGN KEY (default_channel_id) REFERENCES reminders.channels(id) ON DELETE SET NULL
 | 
					    FOREIGN KEY (default_channel_id) REFERENCES channels(id) ON DELETE SET NULL
 | 
				
			||||||
);
 | 
					);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
CREATE TABLE reminders.channels (
 | 
					CREATE TABLE channels (
 | 
				
			||||||
    id INT UNSIGNED UNIQUE NOT NULL AUTO_INCREMENT,
 | 
					    id INT UNSIGNED UNIQUE NOT NULL AUTO_INCREMENT,
 | 
				
			||||||
    channel BIGINT UNSIGNED UNIQUE NOT NULL,
 | 
					    channel BIGINT UNSIGNED UNIQUE NOT NULL,
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -39,10 +35,10 @@ CREATE TABLE reminders.channels (
 | 
				
			|||||||
    guild_id INT UNSIGNED,
 | 
					    guild_id INT UNSIGNED,
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    PRIMARY KEY (id),
 | 
					    PRIMARY KEY (id),
 | 
				
			||||||
    FOREIGN KEY (guild_id) REFERENCES reminders.guilds(id) ON DELETE CASCADE
 | 
					    FOREIGN KEY (guild_id) REFERENCES guilds(id) ON DELETE CASCADE
 | 
				
			||||||
);
 | 
					);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
CREATE TABLE reminders.users (
 | 
					CREATE TABLE users (
 | 
				
			||||||
    id INT UNSIGNED AUTO_INCREMENT UNIQUE NOT NULL,
 | 
					    id INT UNSIGNED AUTO_INCREMENT UNIQUE NOT NULL,
 | 
				
			||||||
    user BIGINT UNSIGNED UNIQUE NOT NULL,
 | 
					    user BIGINT UNSIGNED UNIQUE NOT NULL,
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -59,10 +55,10 @@ CREATE TABLE reminders.users (
 | 
				
			|||||||
    patreon BOOLEAN NOT NULL DEFAULT 0,
 | 
					    patreon BOOLEAN NOT NULL DEFAULT 0,
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    PRIMARY KEY (id),
 | 
					    PRIMARY KEY (id),
 | 
				
			||||||
    FOREIGN KEY (dm_channel) REFERENCES reminders.channels(id) ON DELETE RESTRICT
 | 
					    FOREIGN KEY (dm_channel) REFERENCES channels(id) ON DELETE RESTRICT
 | 
				
			||||||
);
 | 
					);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
CREATE TABLE reminders.roles (
 | 
					CREATE TABLE roles (
 | 
				
			||||||
    id INT UNSIGNED UNIQUE NOT NULL AUTO_INCREMENT,
 | 
					    id INT UNSIGNED UNIQUE NOT NULL AUTO_INCREMENT,
 | 
				
			||||||
    role BIGINT UNSIGNED UNIQUE NOT NULL,
 | 
					    role BIGINT UNSIGNED UNIQUE NOT NULL,
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -71,10 +67,10 @@ CREATE TABLE reminders.roles (
 | 
				
			|||||||
    guild_id INT UNSIGNED NOT NULL,
 | 
					    guild_id INT UNSIGNED NOT NULL,
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    PRIMARY KEY (id),
 | 
					    PRIMARY KEY (id),
 | 
				
			||||||
    FOREIGN KEY (guild_id) REFERENCES reminders.guilds(id) ON DELETE CASCADE
 | 
					    FOREIGN KEY (guild_id) REFERENCES guilds(id) ON DELETE CASCADE
 | 
				
			||||||
);
 | 
					);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
CREATE TABLE reminders.embeds (
 | 
					CREATE TABLE embeds (
 | 
				
			||||||
    id INT UNSIGNED AUTO_INCREMENT UNIQUE NOT NULL,
 | 
					    id INT UNSIGNED AUTO_INCREMENT UNIQUE NOT NULL,
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    title VARCHAR(256) NOT NULL DEFAULT '',
 | 
					    title VARCHAR(256) NOT NULL DEFAULT '',
 | 
				
			||||||
@@ -91,7 +87,7 @@ CREATE TABLE reminders.embeds (
 | 
				
			|||||||
    PRIMARY KEY (id)
 | 
					    PRIMARY KEY (id)
 | 
				
			||||||
);
 | 
					);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
CREATE TABLE reminders.embed_fields (
 | 
					CREATE TABLE embed_fields (
 | 
				
			||||||
    id INT UNSIGNED AUTO_INCREMENT UNIQUE NOT NULL,
 | 
					    id INT UNSIGNED AUTO_INCREMENT UNIQUE NOT NULL,
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    title VARCHAR(256) NOT NULL DEFAULT '',
 | 
					    title VARCHAR(256) NOT NULL DEFAULT '',
 | 
				
			||||||
@@ -100,10 +96,10 @@ CREATE TABLE reminders.embed_fields (
 | 
				
			|||||||
    embed_id INT UNSIGNED NOT NULL,
 | 
					    embed_id INT UNSIGNED NOT NULL,
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    PRIMARY KEY (id),
 | 
					    PRIMARY KEY (id),
 | 
				
			||||||
    FOREIGN KEY (embed_id) REFERENCES reminders.embeds(id) ON DELETE CASCADE
 | 
					    FOREIGN KEY (embed_id) REFERENCES embeds(id) ON DELETE CASCADE
 | 
				
			||||||
);
 | 
					);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
CREATE TABLE reminders.messages (
 | 
					CREATE TABLE messages (
 | 
				
			||||||
    id INT UNSIGNED AUTO_INCREMENT UNIQUE NOT NULL,
 | 
					    id INT UNSIGNED AUTO_INCREMENT UNIQUE NOT NULL,
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    content VARCHAR(2048) NOT NULL DEFAULT '',
 | 
					    content VARCHAR(2048) NOT NULL DEFAULT '',
 | 
				
			||||||
@@ -114,10 +110,10 @@ CREATE TABLE reminders.messages (
 | 
				
			|||||||
    attachment_name VARCHAR(260),
 | 
					    attachment_name VARCHAR(260),
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    PRIMARY KEY (id),
 | 
					    PRIMARY KEY (id),
 | 
				
			||||||
    FOREIGN KEY (embed_id) REFERENCES reminders.embeds(id) ON DELETE SET NULL
 | 
					    FOREIGN KEY (embed_id) REFERENCES embeds(id) ON DELETE SET NULL
 | 
				
			||||||
);
 | 
					);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
CREATE TABLE reminders.reminders (
 | 
					CREATE TABLE reminders (
 | 
				
			||||||
    id INT UNSIGNED AUTO_INCREMENT UNIQUE NOT NULL,
 | 
					    id INT UNSIGNED AUTO_INCREMENT UNIQUE NOT NULL,
 | 
				
			||||||
    uid VARCHAR(64) UNIQUE NOT NULL,
 | 
					    uid VARCHAR(64) UNIQUE NOT NULL,
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -140,20 +136,20 @@ CREATE TABLE reminders.reminders (
 | 
				
			|||||||
    set_by INT UNSIGNED,
 | 
					    set_by INT UNSIGNED,
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    PRIMARY KEY (id),
 | 
					    PRIMARY KEY (id),
 | 
				
			||||||
    FOREIGN KEY (message_id) REFERENCES reminders.messages(id) ON DELETE RESTRICT,
 | 
					    FOREIGN KEY (message_id) REFERENCES messages(id) ON DELETE RESTRICT,
 | 
				
			||||||
    FOREIGN KEY (channel_id) REFERENCES reminders.channels(id) ON DELETE CASCADE,
 | 
					    FOREIGN KEY (channel_id) REFERENCES channels(id) ON DELETE CASCADE,
 | 
				
			||||||
    FOREIGN KEY (set_by) REFERENCES reminders.users(id) ON DELETE SET NULL
 | 
					    FOREIGN KEY (set_by) REFERENCES users(id) ON DELETE SET NULL
 | 
				
			||||||
);
 | 
					);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
CREATE TRIGGER message_cleanup AFTER DELETE ON reminders.reminders
 | 
					CREATE TRIGGER message_cleanup AFTER DELETE ON reminders
 | 
				
			||||||
FOR EACH ROW
 | 
					FOR EACH ROW
 | 
				
			||||||
    DELETE FROM reminders.messages WHERE id = OLD.message_id;
 | 
					    DELETE FROM messages WHERE id = OLD.message_id;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
CREATE TRIGGER embed_cleanup AFTER DELETE ON reminders.messages
 | 
					CREATE TRIGGER embed_cleanup AFTER DELETE ON messages
 | 
				
			||||||
FOR EACH ROW
 | 
					FOR EACH ROW
 | 
				
			||||||
    DELETE FROM reminders.embeds WHERE id = OLD.embed_id;
 | 
					    DELETE FROM embeds WHERE id = OLD.embed_id;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
CREATE TABLE reminders.todos (
 | 
					CREATE TABLE todos (
 | 
				
			||||||
    id INT UNSIGNED AUTO_INCREMENT UNIQUE NOT NULL,
 | 
					    id INT UNSIGNED AUTO_INCREMENT UNIQUE NOT NULL,
 | 
				
			||||||
    user_id INT UNSIGNED,
 | 
					    user_id INT UNSIGNED,
 | 
				
			||||||
    guild_id INT UNSIGNED,
 | 
					    guild_id INT UNSIGNED,
 | 
				
			||||||
@@ -161,23 +157,23 @@ CREATE TABLE reminders.todos (
 | 
				
			|||||||
    value VARCHAR(2000) NOT NULL,
 | 
					    value VARCHAR(2000) NOT NULL,
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    PRIMARY KEY (id),
 | 
					    PRIMARY KEY (id),
 | 
				
			||||||
    FOREIGN KEY (user_id) REFERENCES reminders.users(id) ON DELETE SET NULL,
 | 
					    FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE SET NULL,
 | 
				
			||||||
    FOREIGN KEY (guild_id) REFERENCES reminders.guilds(id) ON DELETE CASCADE,
 | 
					    FOREIGN KEY (guild_id) REFERENCES guilds(id) ON DELETE CASCADE,
 | 
				
			||||||
    FOREIGN KEY (channel_id) REFERENCES reminders.channels(id) ON DELETE SET NULL
 | 
					    FOREIGN KEY (channel_id) REFERENCES channels(id) ON DELETE SET NULL
 | 
				
			||||||
);
 | 
					);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
CREATE TABLE reminders.command_restrictions (
 | 
					CREATE TABLE command_restrictions (
 | 
				
			||||||
    id INT UNSIGNED AUTO_INCREMENT UNIQUE NOT NULL,
 | 
					    id INT UNSIGNED AUTO_INCREMENT UNIQUE NOT NULL,
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    role_id INT UNSIGNED NOT NULL,
 | 
					    role_id INT UNSIGNED NOT NULL,
 | 
				
			||||||
    command ENUM('todos', 'natural', 'remind', 'interval', 'timer', 'del', 'look', 'alias', 'countdown') NOT NULL,
 | 
					    command ENUM('todos', 'natural', 'remind', 'interval', 'timer', 'del', 'look', 'alias', 'countdown') NOT NULL,
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    PRIMARY KEY (id),
 | 
					    PRIMARY KEY (id),
 | 
				
			||||||
    FOREIGN KEY (role_id) REFERENCES reminders.roles(id) ON DELETE CASCADE,
 | 
					    FOREIGN KEY (role_id) REFERENCES roles(id) ON DELETE CASCADE,
 | 
				
			||||||
    UNIQUE KEY (`role_id`, `command`)
 | 
					    UNIQUE KEY (`role_id`, `command`)
 | 
				
			||||||
);
 | 
					);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
CREATE TABLE reminders.timers (
 | 
					CREATE TABLE timers (
 | 
				
			||||||
    id INT UNSIGNED AUTO_INCREMENT UNIQUE NOT NULL,
 | 
					    id INT UNSIGNED AUTO_INCREMENT UNIQUE NOT NULL,
 | 
				
			||||||
    start_time TIMESTAMP NOT NULL DEFAULT NOW(),
 | 
					    start_time TIMESTAMP NOT NULL DEFAULT NOW(),
 | 
				
			||||||
    name VARCHAR(32) NOT NULL,
 | 
					    name VARCHAR(32) NOT NULL,
 | 
				
			||||||
@@ -186,7 +182,7 @@ CREATE TABLE reminders.timers (
 | 
				
			|||||||
    PRIMARY KEY (id)
 | 
					    PRIMARY KEY (id)
 | 
				
			||||||
);
 | 
					);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
CREATE TABLE reminders.events (
 | 
					CREATE TABLE events (
 | 
				
			||||||
    id INT UNSIGNED AUTO_INCREMENT UNIQUE NOT NULL,
 | 
					    id INT UNSIGNED AUTO_INCREMENT UNIQUE NOT NULL,
 | 
				
			||||||
    `time` TIMESTAMP NOT NULL DEFAULT NOW(),
 | 
					    `time` TIMESTAMP NOT NULL DEFAULT NOW(),
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -198,12 +194,12 @@ CREATE TABLE reminders.events (
 | 
				
			|||||||
    reminder_id INT UNSIGNED,
 | 
					    reminder_id INT UNSIGNED,
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    PRIMARY KEY (id),
 | 
					    PRIMARY KEY (id),
 | 
				
			||||||
    FOREIGN KEY (guild_id) REFERENCES reminders.guilds(id) ON DELETE CASCADE,
 | 
					    FOREIGN KEY (guild_id) REFERENCES guilds(id) ON DELETE CASCADE,
 | 
				
			||||||
    FOREIGN KEY (user_id) REFERENCES reminders.users(id) ON DELETE SET NULL,
 | 
					    FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE SET NULL,
 | 
				
			||||||
    FOREIGN KEY (reminder_id) REFERENCES reminders.reminders(id) ON DELETE SET NULL
 | 
					    FOREIGN KEY (reminder_id) REFERENCES reminders(id) ON DELETE SET NULL
 | 
				
			||||||
);
 | 
					);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
CREATE TABLE reminders.command_aliases (
 | 
					CREATE TABLE command_aliases (
 | 
				
			||||||
    id INT UNSIGNED AUTO_INCREMENT UNIQUE NOT NULL,
 | 
					    id INT UNSIGNED AUTO_INCREMENT UNIQUE NOT NULL,
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    guild_id INT UNSIGNED NOT NULL,
 | 
					    guild_id INT UNSIGNED NOT NULL,
 | 
				
			||||||
@@ -212,22 +208,22 @@ CREATE TABLE reminders.command_aliases (
 | 
				
			|||||||
    command VARCHAR(2048) NOT NULL,
 | 
					    command VARCHAR(2048) NOT NULL,
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    PRIMARY KEY (id),
 | 
					    PRIMARY KEY (id),
 | 
				
			||||||
    FOREIGN KEY (guild_id) REFERENCES reminders.guilds(id) ON DELETE CASCADE,
 | 
					    FOREIGN KEY (guild_id) REFERENCES guilds(id) ON DELETE CASCADE,
 | 
				
			||||||
    UNIQUE KEY (`guild_id`, `name`)
 | 
					    UNIQUE KEY (`guild_id`, `name`)
 | 
				
			||||||
);
 | 
					);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
CREATE TABLE reminders.guild_users (
 | 
					CREATE TABLE guild_users (
 | 
				
			||||||
    guild INT UNSIGNED NOT NULL,
 | 
					    guild INT UNSIGNED NOT NULL,
 | 
				
			||||||
    user INT UNSIGNED NOT NULL,
 | 
					    user INT UNSIGNED NOT NULL,
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    can_access BOOL NOT NULL DEFAULT 0,
 | 
					    can_access BOOL NOT NULL DEFAULT 0,
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    FOREIGN KEY (guild) REFERENCES reminders.guilds(id) ON DELETE CASCADE,
 | 
					    FOREIGN KEY (guild) REFERENCES guilds(id) ON DELETE CASCADE,
 | 
				
			||||||
    FOREIGN KEY (user) REFERENCES reminders.users(id) ON DELETE CASCADE,
 | 
					    FOREIGN KEY (user) REFERENCES users(id) ON DELETE CASCADE,
 | 
				
			||||||
    UNIQUE KEY (guild, user)
 | 
					    UNIQUE KEY (guild, user)
 | 
				
			||||||
);
 | 
					);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
CREATE EVENT reminders.event_cleanup
 | 
					CREATE EVENT event_cleanup
 | 
				
			||||||
ON SCHEDULE AT CURRENT_TIMESTAMP + INTERVAL 1 DAY
 | 
					ON SCHEDULE AT CURRENT_TIMESTAMP + INTERVAL 1 DAY
 | 
				
			||||||
ON COMPLETION PRESERVE
 | 
					ON COMPLETION PRESERVE
 | 
				
			||||||
DO DELETE FROM reminders.events WHERE `time` < DATE_SUB(NOW(), INTERVAL 5 DAY);
 | 
					DO DELETE FROM events WHERE `time` < DATE_SUB(NOW(), INTERVAL 5 DAY);
 | 
				
			||||||
@@ -1,5 +1,3 @@
 | 
				
			|||||||
USE reminders;
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
SET FOREIGN_KEY_CHECKS = 0;
 | 
					SET FOREIGN_KEY_CHECKS = 0;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
DROP TABLE IF EXISTS reminders_new;
 | 
					DROP TABLE IF EXISTS reminders_new;
 | 
				
			||||||
@@ -1,5 +1,3 @@
 | 
				
			|||||||
USE reminders;
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
CREATE TABLE macro (
 | 
					CREATE TABLE macro (
 | 
				
			||||||
    id INT UNSIGNED AUTO_INCREMENT,
 | 
					    id INT UNSIGNED AUTO_INCREMENT,
 | 
				
			||||||
    guild_id INT UNSIGNED NOT NULL,
 | 
					    guild_id INT UNSIGNED NOT NULL,
 | 
				
			||||||
@@ -0,0 +1,2 @@
 | 
				
			|||||||
 | 
					ALTER TABLE reminders RENAME COLUMN `interval` TO `interval_seconds`;
 | 
				
			||||||
 | 
					ALTER TABLE reminders ADD COLUMN `interval_months` INT UNSIGNED DEFAULT NULL;
 | 
				
			||||||
@@ -1,5 +1,3 @@
 | 
				
			|||||||
USE reminders;
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
CREATE TABLE reminder_template (
 | 
					CREATE TABLE reminder_template (
 | 
				
			||||||
    `id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
 | 
					    `id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
 | 
				
			||||||
 | 
					
 | 
				
			||||||
							
								
								
									
										1
									
								
								migrations/20221210000000_reminder_daily_intervals.sql
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										1
									
								
								migrations/20221210000000_reminder_daily_intervals.sql
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1 @@
 | 
				
			|||||||
 | 
					ALTER TABLE reminders ADD COLUMN `interval_days` INT UNSIGNED DEFAULT NULL;
 | 
				
			||||||
							
								
								
									
										1
									
								
								migrations/20230511125236_reminder_threads.sql
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										1
									
								
								migrations/20230511125236_reminder_threads.sql
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1 @@
 | 
				
			|||||||
 | 
					ALTER TABLE reminders ADD COLUMN `thread_id` BIGINT DEFAULT NULL;
 | 
				
			||||||
							
								
								
									
										1
									
								
								migrations/20230511180231_ephemeral_confirmations.sql
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										1
									
								
								migrations/20230511180231_ephemeral_confirmations.sql
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1 @@
 | 
				
			|||||||
 | 
					ALTER TABLE guilds ADD COLUMN ephemeral_confirmations BOOL NOT NULL DEFAULT 0;
 | 
				
			||||||
							
								
								
									
										2
									
								
								migrations/20230722130906_increase_reminder_name.sql
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										2
									
								
								migrations/20230722130906_increase_reminder_name.sql
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,2 @@
 | 
				
			|||||||
 | 
					ALTER TABLE reminders MODIFY `name` VARCHAR(100) NOT NULL DEFAULT 'Reminder';
 | 
				
			||||||
 | 
					ALTER TABLE reminder_template MODIFY `name` VARCHAR(100) NOT NULL DEFAULT 'Reminder';
 | 
				
			||||||
							
								
								
									
										9
									
								
								migrations/20230730134827_stats.sql
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										9
									
								
								migrations/20230730134827_stats.sql
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,9 @@
 | 
				
			|||||||
 | 
					CREATE TABLE stat (
 | 
				
			||||||
 | 
					    `id` BIGINT NOT NULL AUTO_INCREMENT,
 | 
				
			||||||
 | 
					    `utc_time` DATETIME NOT NULL DEFAULT NOW(),
 | 
				
			||||||
 | 
					    `type` ENUM('reminder_sent', 'reminder_failed'),
 | 
				
			||||||
 | 
					    `reminder_id` INT UNSIGNED,
 | 
				
			||||||
 | 
					    `message` TEXT,
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    PRIMARY KEY (`id`)
 | 
				
			||||||
 | 
					);
 | 
				
			||||||
							
								
								
									
										2
									
								
								migrations/20230731170452_reminder_archive.sql
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										2
									
								
								migrations/20230731170452_reminder_archive.sql
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,2 @@
 | 
				
			|||||||
 | 
					ALTER TABLE reminders ADD COLUMN `status` ENUM ('pending', 'sent', 'failed', 'deleted') NOT NULL DEFAULT 'pending';
 | 
				
			||||||
 | 
					ALTER TABLE reminders ADD COLUMN `status_message` TEXT;
 | 
				
			||||||
@@ -0,0 +1,3 @@
 | 
				
			|||||||
 | 
					ALTER TABLE `reminder_template` ADD COLUMN `interval_seconds` INT UNSIGNED;
 | 
				
			||||||
 | 
					ALTER TABLE `reminder_template` ADD COLUMN `interval_days` INT UNSIGNED;
 | 
				
			||||||
 | 
					ALTER TABLE `reminder_template` ADD COLUMN `interval_months` INT UNSIGNED;
 | 
				
			||||||
							
								
								
									
										41
									
								
								nginx/reminder-rs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										41
									
								
								nginx/reminder-rs
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,41 @@
 | 
				
			|||||||
 | 
					server {
 | 
				
			||||||
 | 
					        server_name www.reminder-bot.com;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        return 301 $scheme://reminder-bot.com$request_uri;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					server {
 | 
				
			||||||
 | 
					        listen 80;
 | 
				
			||||||
 | 
					        server_name reminder-bot.com;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						    return 301 https://reminder-bot.com$request_uri;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					server {
 | 
				
			||||||
 | 
					        listen 443 ssl;
 | 
				
			||||||
 | 
					        server_name reminder-bot.com;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        ssl_certificate /etc/letsencrypt/live/reminder-bot.com/fullchain.pem;
 | 
				
			||||||
 | 
					        ssl_certificate_key /etc/letsencrypt/live/reminder-bot.com/privkey.pem;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        access_log /var/log/nginx/access.log;
 | 
				
			||||||
 | 
					        error_log /var/log/nginx/error.log;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        proxy_buffer_size 128k;
 | 
				
			||||||
 | 
					        proxy_buffers 4 256k;
 | 
				
			||||||
 | 
					        proxy_busy_buffers_size 256k;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        location / {
 | 
				
			||||||
 | 
					                proxy_pass http://localhost:18920;
 | 
				
			||||||
 | 
					                proxy_redirect off;
 | 
				
			||||||
 | 
					                proxy_set_header Host $host;
 | 
				
			||||||
 | 
					                proxy_set_header X-Real-IP $remote_addr;
 | 
				
			||||||
 | 
					                proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
 | 
				
			||||||
 | 
							        proxy_set_header X-Forwarded-Proto $scheme;
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        location /static {
 | 
				
			||||||
 | 
					                alias /var/www/reminder-rs/static;
 | 
				
			||||||
 | 
					                expires 30d;
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
@@ -5,12 +5,12 @@ edition = "2021"
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
[dependencies]
 | 
					[dependencies]
 | 
				
			||||||
tokio = { version = "1", features = ["process", "full"] }
 | 
					tokio = { version = "1", features = ["process", "full"] }
 | 
				
			||||||
regex = "1.4"
 | 
					regex = "1.9"
 | 
				
			||||||
log = "0.4"
 | 
					log = "0.4"
 | 
				
			||||||
chrono = "0.4"
 | 
					chrono = "0.4"
 | 
				
			||||||
chrono-tz = { version = "0.5", features = ["serde"] }
 | 
					chrono-tz = { version = "0.8", features = ["serde"] }
 | 
				
			||||||
lazy_static = "1.4"
 | 
					lazy_static = "1.4"
 | 
				
			||||||
num-integer = "0.1"
 | 
					num-integer = "0.1"
 | 
				
			||||||
serde = "1.0"
 | 
					serde = "1.0"
 | 
				
			||||||
sqlx = { version = "0.6", features = ["runtime-tokio-rustls", "macros", "mysql", "bigdecimal", "chrono", "json"]}
 | 
					sqlx = { version = "0.7", 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"] }
 | 
					serenity = { version = "0.11", 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 chrono_tz::Tz;
 | 
				
			||||||
use lazy_static::lazy_static;
 | 
					use lazy_static::lazy_static;
 | 
				
			||||||
use log::{error, info, warn};
 | 
					use log::{error, info, warn};
 | 
				
			||||||
@@ -7,7 +9,7 @@ use regex::{Captures, Regex};
 | 
				
			|||||||
use serde::Deserialize;
 | 
					use serde::Deserialize;
 | 
				
			||||||
use serenity::{
 | 
					use serenity::{
 | 
				
			||||||
    builder::CreateEmbed,
 | 
					    builder::CreateEmbed,
 | 
				
			||||||
    http::{CacheHttp, Http, HttpError, StatusCode},
 | 
					    http::{CacheHttp, Http, HttpError},
 | 
				
			||||||
    model::{
 | 
					    model::{
 | 
				
			||||||
        channel::{Channel, Embed as SerenityEmbed},
 | 
					        channel::{Channel, Embed as SerenityEmbed},
 | 
				
			||||||
        id::ChannelId,
 | 
					        id::ChannelId,
 | 
				
			||||||
@@ -30,6 +32,7 @@ lazy_static! {
 | 
				
			|||||||
        Regex::new(r#"<<timefrom:(?P<time>\d+):(?P<format>.+)?>>"#).unwrap();
 | 
					        Regex::new(r#"<<timefrom:(?P<time>\d+):(?P<format>.+)?>>"#).unwrap();
 | 
				
			||||||
    pub static ref TIMENOW_REGEX: Regex =
 | 
					    pub static ref TIMENOW_REGEX: Regex =
 | 
				
			||||||
        Regex::new(r#"<<timenow:(?P<timezone>(?:\w|/|_)+):(?P<format>.+)?>>"#).unwrap();
 | 
					        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 {
 | 
					fn fmt_displacement(format: &str, seconds: u64) -> String {
 | 
				
			||||||
@@ -62,7 +65,8 @@ pub fn substitute(string: &str) -> String {
 | 
				
			|||||||
        let format = caps.name("format").map(|m| m.as_str());
 | 
					        let format = caps.name("format").map(|m| m.as_str());
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        if let (Some(final_time), Some(format)) = (final_time, format) {
 | 
					        if let (Some(final_time), Some(format)) = (final_time, format) {
 | 
				
			||||||
            let dt = NaiveDateTime::from_timestamp(final_time, 0);
 | 
					            match NaiveDateTime::from_timestamp_opt(final_time, 0) {
 | 
				
			||||||
 | 
					                Some(dt) => {
 | 
				
			||||||
                    let now = Utc::now().naive_utc();
 | 
					                    let now = Utc::now().naive_utc();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
                    let difference = {
 | 
					                    let difference = {
 | 
				
			||||||
@@ -74,6 +78,10 @@ pub fn substitute(string: &str) -> String {
 | 
				
			|||||||
                    };
 | 
					                    };
 | 
				
			||||||
 | 
					
 | 
				
			||||||
                    fmt_displacement(format, difference.num_seconds() as u64)
 | 
					                    fmt_displacement(format, difference.num_seconds() as u64)
 | 
				
			||||||
 | 
					                }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                None => String::new(),
 | 
				
			||||||
 | 
					            }
 | 
				
			||||||
        } else {
 | 
					        } else {
 | 
				
			||||||
            String::new()
 | 
					            String::new()
 | 
				
			||||||
        }
 | 
					        }
 | 
				
			||||||
@@ -146,7 +154,7 @@ impl Embed {
 | 
				
			|||||||
                embed.description = substitute(&embed.description);
 | 
					                embed.description = substitute(&embed.description);
 | 
				
			||||||
                embed.footer = substitute(&embed.footer);
 | 
					                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.title = substitute(&field.title);
 | 
				
			||||||
                    field.value = substitute(&field.value);
 | 
					                    field.value = substitute(&field.value);
 | 
				
			||||||
                });
 | 
					                });
 | 
				
			||||||
@@ -243,11 +251,12 @@ pub struct Reminder {
 | 
				
			|||||||
    attachment: Option<Vec<u8>>,
 | 
					    attachment: Option<Vec<u8>>,
 | 
				
			||||||
    attachment_name: Option<String>,
 | 
					    attachment_name: Option<String>,
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    utc_time: NaiveDateTime,
 | 
					    utc_time: DateTime<Utc>,
 | 
				
			||||||
    timezone: String,
 | 
					    timezone: String,
 | 
				
			||||||
    restartable: bool,
 | 
					    restartable: bool,
 | 
				
			||||||
    expires: Option<NaiveDateTime>,
 | 
					    expires: Option<DateTime<Utc>>,
 | 
				
			||||||
    interval_seconds: Option<u32>,
 | 
					    interval_seconds: Option<u32>,
 | 
				
			||||||
 | 
					    interval_days: Option<u32>,
 | 
				
			||||||
    interval_months: Option<u32>,
 | 
					    interval_months: Option<u32>,
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    avatar: Option<String>,
 | 
					    avatar: Option<String>,
 | 
				
			||||||
@@ -281,6 +290,7 @@ SELECT
 | 
				
			|||||||
    reminders.`restartable` AS restartable,
 | 
					    reminders.`restartable` AS restartable,
 | 
				
			||||||
    reminders.`expires` AS 'expires',
 | 
					    reminders.`expires` AS 'expires',
 | 
				
			||||||
    reminders.`interval_seconds` AS 'interval_seconds',
 | 
					    reminders.`interval_seconds` AS 'interval_seconds',
 | 
				
			||||||
 | 
					    reminders.`interval_days` AS 'interval_days',
 | 
				
			||||||
    reminders.`interval_months` AS 'interval_months',
 | 
					    reminders.`interval_months` AS 'interval_months',
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    reminders.`avatar` AS avatar,
 | 
					    reminders.`avatar` AS avatar,
 | 
				
			||||||
@@ -292,16 +302,19 @@ INNER JOIN
 | 
				
			|||||||
ON
 | 
					ON
 | 
				
			||||||
    reminders.channel_id = channels.id
 | 
					    reminders.channel_id = channels.id
 | 
				
			||||||
WHERE
 | 
					WHERE
 | 
				
			||||||
 | 
					    reminders.`status` = 'pending' AND
 | 
				
			||||||
    reminders.`id` IN (
 | 
					    reminders.`id` IN (
 | 
				
			||||||
        SELECT
 | 
					        SELECT
 | 
				
			||||||
            MIN(id)
 | 
					            MIN(id)
 | 
				
			||||||
        FROM
 | 
					        FROM
 | 
				
			||||||
            reminders
 | 
					            reminders
 | 
				
			||||||
        WHERE
 | 
					        WHERE
 | 
				
			||||||
            reminders.`utc_time` <= NOW()
 | 
					            reminders.`utc_time` <= NOW() AND
 | 
				
			||||||
            AND (
 | 
					            `status` = 'pending' AND
 | 
				
			||||||
 | 
					            (
 | 
				
			||||||
                reminders.`interval_seconds` IS NOT NULL
 | 
					                reminders.`interval_seconds` IS NOT NULL
 | 
				
			||||||
                OR reminders.`interval_months` IS NOT NULL
 | 
					                OR reminders.`interval_months` IS NOT NULL
 | 
				
			||||||
 | 
					                OR reminders.`interval_days` IS NOT NULL
 | 
				
			||||||
                OR reminders.enabled
 | 
					                OR reminders.enabled
 | 
				
			||||||
            )
 | 
					            )
 | 
				
			||||||
        GROUP BY channel_id
 | 
					        GROUP BY channel_id
 | 
				
			||||||
@@ -330,9 +343,7 @@ WHERE
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
    async fn reset_webhook(&self, pool: impl Executor<'_, Database = Database> + Copy) {
 | 
					    async fn reset_webhook(&self, pool: impl Executor<'_, Database = Database> + Copy) {
 | 
				
			||||||
        let _ = sqlx::query!(
 | 
					        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
 | 
					            self.channel_id
 | 
				
			||||||
        )
 | 
					        )
 | 
				
			||||||
        .execute(pool)
 | 
					        .execute(pool)
 | 
				
			||||||
@@ -340,56 +351,72 @@ UPDATE channels SET webhook_id = NULL, webhook_token = NULL WHERE channel = ?
 | 
				
			|||||||
    }
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    async fn refresh(&self, pool: impl Executor<'_, Database = Database> + Copy) {
 | 
					    async fn refresh(&self, pool: impl Executor<'_, Database = Database> + Copy) {
 | 
				
			||||||
        if self.interval_seconds.is_some() || self.interval_months.is_some() {
 | 
					        if self.interval_seconds.is_some()
 | 
				
			||||||
            let now = Utc::now().naive_local();
 | 
					            || self.interval_months.is_some()
 | 
				
			||||||
            let mut updated_reminder_time = self.utc_time;
 | 
					            || self.interval_days.is_some()
 | 
				
			||||||
 | 
					 | 
				
			||||||
            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 {
 | 
					            // If all intervals are zero then dont care
 | 
				
			||||||
                        Some(datetime) => {
 | 
					            if self.interval_seconds == Some(0)
 | 
				
			||||||
                            updated_reminder_time = datetime;
 | 
					                && self.interval_days == Some(0)
 | 
				
			||||||
 | 
					                && self.interval_months == Some(0)
 | 
				
			||||||
 | 
					            {
 | 
				
			||||||
 | 
					                self.set_sent(pool).await;
 | 
				
			||||||
            }
 | 
					            }
 | 
				
			||||||
                        None => {
 | 
					 | 
				
			||||||
                            warn!("Could not update interval by months: got NULL");
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
                            updated_reminder_time += Duration::days(30);
 | 
					            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
 | 
				
			||||||
 | 
					                            });
 | 
				
			||||||
                    }
 | 
					                    }
 | 
				
			||||||
                    },
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
                    Err(e) => {
 | 
					 | 
				
			||||||
                        warn!("Could not update interval by months: {:?}", e);
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
                        // naively fallback to adding 30 days
 | 
					 | 
				
			||||||
                        updated_reminder_time += Duration::days(30);
 | 
					 | 
				
			||||||
                }
 | 
					                }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                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 {
 | 
					                if let Some(interval) = self.interval_seconds {
 | 
				
			||||||
                while updated_reminder_time < now {
 | 
					 | 
				
			||||||
                    updated_reminder_time += Duration::seconds(interval as i64);
 | 
					                    updated_reminder_time += Duration::seconds(interval as i64);
 | 
				
			||||||
                }
 | 
					                }
 | 
				
			||||||
            }
 | 
					            }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
            if self.expires.map_or(false, |expires| {
 | 
					            if fail_count >= 4 {
 | 
				
			||||||
                NaiveDateTime::from_timestamp(updated_reminder_time.timestamp(), 0) > expires
 | 
					                self.log_error(
 | 
				
			||||||
            }) {
 | 
					                    pool,
 | 
				
			||||||
                self.force_delete(pool).await;
 | 
					                    "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 {
 | 
					            } else {
 | 
				
			||||||
                sqlx::query!(
 | 
					                sqlx::query!(
 | 
				
			||||||
                    "
 | 
					                    "UPDATE reminders SET `utc_time` = ? WHERE `id` = ?",
 | 
				
			||||||
UPDATE reminders SET `utc_time` = ? WHERE `id` = ?
 | 
					                    updated_reminder_time.with_timezone(&Utc),
 | 
				
			||||||
                    ",
 | 
					 | 
				
			||||||
                    updated_reminder_time,
 | 
					 | 
				
			||||||
                    self.id
 | 
					                    self.id
 | 
				
			||||||
                )
 | 
					                )
 | 
				
			||||||
                .execute(pool)
 | 
					                .execute(pool)
 | 
				
			||||||
@@ -397,15 +424,67 @@ UPDATE reminders SET `utc_time` = ? WHERE `id` = ?
 | 
				
			|||||||
                .expect(&format!("Could not update time on Reminder {}", self.id));
 | 
					                .expect(&format!("Could not update time on Reminder {}", self.id));
 | 
				
			||||||
            }
 | 
					            }
 | 
				
			||||||
        } else {
 | 
					        } 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!(
 | 
					            sqlx::query!(
 | 
				
			||||||
            "
 | 
					                "INSERT INTO stat (type, reminder_id, message) VALUES ('reminder_failed', ?, ?)",
 | 
				
			||||||
DELETE FROM reminders WHERE `id` = ?
 | 
					                self.id,
 | 
				
			||||||
            ",
 | 
					                message,
 | 
				
			||||||
 | 
					            )
 | 
				
			||||||
 | 
					            .execute(pool)
 | 
				
			||||||
 | 
					            .await
 | 
				
			||||||
 | 
					            .expect("Could not log error to database");
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    async fn log_success(&self, pool: impl Executor<'_, Database = Database> + Copy) {
 | 
				
			||||||
 | 
					        if *LOG_TO_DATABASE {
 | 
				
			||||||
 | 
					            sqlx::query!(
 | 
				
			||||||
 | 
					                "INSERT INTO stat (type, reminder_id) VALUES ('reminder_sent', ?)",
 | 
				
			||||||
 | 
					                self.id,
 | 
				
			||||||
 | 
					            )
 | 
				
			||||||
 | 
					            .execute(pool)
 | 
				
			||||||
 | 
					            .await
 | 
				
			||||||
 | 
					            .expect("Could not log success to database");
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    async fn set_sent(&self, pool: impl Executor<'_, Database = Database> + Copy) {
 | 
				
			||||||
 | 
					        sqlx::query!("UPDATE reminders SET `status` = 'sent' WHERE `id` = ?", self.id)
 | 
				
			||||||
 | 
					            .execute(pool)
 | 
				
			||||||
 | 
					            .await
 | 
				
			||||||
 | 
					            .expect(&format!("Could not delete Reminder {}", self.id));
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    async fn set_failed(
 | 
				
			||||||
 | 
					        &self,
 | 
				
			||||||
 | 
					        pool: impl Executor<'_, Database = Database> + Copy,
 | 
				
			||||||
 | 
					        message: &'static str,
 | 
				
			||||||
 | 
					    ) {
 | 
				
			||||||
 | 
					        sqlx::query!(
 | 
				
			||||||
 | 
					            "UPDATE reminders SET `status` = 'failed', `status_message` = ? WHERE `id` = ?",
 | 
				
			||||||
 | 
					            message,
 | 
				
			||||||
            self.id
 | 
					            self.id
 | 
				
			||||||
        )
 | 
					        )
 | 
				
			||||||
        .execute(pool)
 | 
					        .execute(pool)
 | 
				
			||||||
@@ -504,8 +583,10 @@ DELETE FROM reminders WHERE `id` = ?
 | 
				
			|||||||
                    w.content(&reminder.content).tts(reminder.tts);
 | 
					                    w.content(&reminder.content).tts(reminder.tts);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
                    if let Some(username) = &reminder.username {
 | 
					                    if let Some(username) = &reminder.username {
 | 
				
			||||||
 | 
					                        if !username.is_empty() {
 | 
				
			||||||
                            w.username(username);
 | 
					                            w.username(username);
 | 
				
			||||||
                        }
 | 
					                        }
 | 
				
			||||||
 | 
					                    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
                    if let Some(avatar) = &reminder.avatar {
 | 
					                    if let Some(avatar) = &reminder.avatar {
 | 
				
			||||||
                        w.avatar_url(avatar);
 | 
					                        w.avatar_url(avatar);
 | 
				
			||||||
@@ -548,9 +629,7 @@ DELETE FROM reminders WHERE `id` = ?
 | 
				
			|||||||
                    .map_or(true, |inner| inner >= Utc::now().naive_local()))
 | 
					                    .map_or(true, |inner| inner >= Utc::now().naive_local()))
 | 
				
			||||||
        {
 | 
					        {
 | 
				
			||||||
            let _ = sqlx::query!(
 | 
					            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
 | 
					                self.channel_id
 | 
				
			||||||
            )
 | 
					            )
 | 
				
			||||||
            .execute(pool)
 | 
					            .execute(pool)
 | 
				
			||||||
@@ -567,7 +646,7 @@ UPDATE `channels` SET paused = 0, paused_until = NULL WHERE `channel` = ?
 | 
				
			|||||||
                if let Ok(webhook) = webhook_res {
 | 
					                if let Ok(webhook) = webhook_res {
 | 
				
			||||||
                    send_to_webhook(cache_http, &self, webhook, embed).await
 | 
					                    send_to_webhook(cache_http, &self, webhook, embed).await
 | 
				
			||||||
                } else {
 | 
					                } else {
 | 
				
			||||||
                    warn!("Webhook vanished: {:?}", webhook_res);
 | 
					                    warn!("Webhook vanished for reminder {}: {:?}", self.id, webhook_res);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
                    self.reset_webhook(pool).await;
 | 
					                    self.reset_webhook(pool).await;
 | 
				
			||||||
                    send_to_channel(cache_http, &self, embed).await
 | 
					                    send_to_channel(cache_http, &self, embed).await
 | 
				
			||||||
@@ -577,24 +656,84 @@ UPDATE `channels` SET paused = 0, paused_until = NULL WHERE `channel` = ?
 | 
				
			|||||||
            };
 | 
					            };
 | 
				
			||||||
 | 
					
 | 
				
			||||||
            if let Err(e) = result {
 | 
					            if let Err(e) = result {
 | 
				
			||||||
                error!("Error sending reminder {}: {:?}", self.id, e);
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
                if let Error::Http(error) = e {
 | 
					                if let Error::Http(error) = e {
 | 
				
			||||||
                    if error.status_code() == Some(StatusCode::NOT_FOUND) {
 | 
					                    if let HttpError::UnsuccessfulRequest(http_error) = *error {
 | 
				
			||||||
                        warn!("Seeing channel is deleted. Removing reminder");
 | 
					                        match http_error.error.code {
 | 
				
			||||||
                        self.force_delete(pool).await;
 | 
					                            10003 => {
 | 
				
			||||||
                    } else if let HttpError::UnsuccessfulRequest(error) = *error {
 | 
					                                self.log_error(
 | 
				
			||||||
                        if error.error.code == 50007 {
 | 
					                                    pool,
 | 
				
			||||||
                            warn!("User cannot receive DMs");
 | 
					                                    "Could not be sent as channel does not exist",
 | 
				
			||||||
                            self.force_delete(pool).await;
 | 
					                                    None::<&'static str>,
 | 
				
			||||||
                        } else {
 | 
					                                )
 | 
				
			||||||
 | 
					                                .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;
 | 
					                                self.refresh(pool).await;
 | 
				
			||||||
                            }
 | 
					                            }
 | 
				
			||||||
                        }
 | 
					                        }
 | 
				
			||||||
                    } else {
 | 
					                    } else {
 | 
				
			||||||
 | 
					                        self.log_error(pool, "(Likely) a parsing error", Some(error)).await;
 | 
				
			||||||
                        self.refresh(pool).await;
 | 
					                        self.refresh(pool).await;
 | 
				
			||||||
                    }
 | 
					                    }
 | 
				
			||||||
                } else {
 | 
					                } 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;
 | 
					                self.refresh(pool).await;
 | 
				
			||||||
            }
 | 
					            }
 | 
				
			||||||
        } else {
 | 
					        } else {
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -1,6 +1,9 @@
 | 
				
			|||||||
use chrono_tz::TZ_VARIANTS;
 | 
					use std::time::{SystemTime, UNIX_EPOCH};
 | 
				
			||||||
 | 
					
 | 
				
			||||||
use crate::Context;
 | 
					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> {
 | 
					pub async fn timezone_autocomplete(ctx: Context<'_>, partial: &str) -> Vec<String> {
 | 
				
			||||||
    if partial.is_empty() {
 | 
					    if partial.is_empty() {
 | 
				
			||||||
@@ -33,3 +36,82 @@ WHERE
 | 
				
			|||||||
    .map(|s| s.name.clone())
 | 
					    .map(|s| s.name.clone())
 | 
				
			||||||
    .collect()
 | 
					    .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(),
 | 
				
			||||||
 | 
					                }]
 | 
				
			||||||
 | 
					            }
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -2,7 +2,7 @@ use poise::CreateReply;
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
use crate::{
 | 
					use crate::{
 | 
				
			||||||
    component_models::pager::{MacroPager, Pager},
 | 
					    component_models::pager::{MacroPager, Pager},
 | 
				
			||||||
    consts::{EMBED_DESCRIPTION_MAX_LENGTH, THEME_COLOR},
 | 
					    consts::THEME_COLOR,
 | 
				
			||||||
    models::{command_macro::CommandMacro, CtxData},
 | 
					    models::{command_macro::CommandMacro, CtxData},
 | 
				
			||||||
    Context, Error,
 | 
					    Context, Error,
 | 
				
			||||||
};
 | 
					};
 | 
				
			||||||
@@ -30,27 +30,7 @@ pub async fn list_macro(ctx: Context<'_>) -> Result<(), Error> {
 | 
				
			|||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
pub fn max_macro_page<U, E>(macros: &[CommandMacro<U, E>]) -> usize {
 | 
					pub fn max_macro_page<U, E>(macros: &[CommandMacro<U, E>]) -> usize {
 | 
				
			||||||
    let mut skipped_char_count = 0;
 | 
					    ((macros.len() as f64) / 25.0).ceil() as usize
 | 
				
			||||||
 | 
					 | 
				
			||||||
    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 {
 | 
					pub fn show_macro_page<U, E>(macros: &[CommandMacro<U, E>], page: usize) -> CreateReply {
 | 
				
			||||||
@@ -75,45 +55,27 @@ pub fn show_macro_page<U, E>(macros: &[CommandMacro<U, E>], page: usize) -> Crea
 | 
				
			|||||||
        page = pages - 1;
 | 
					        page = pages - 1;
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    let mut char_count = 0;
 | 
					    let lower = (page * 25).min(macros.len());
 | 
				
			||||||
    let mut skipped_char_count = 0;
 | 
					    let upper = ((page + 1) * 25).min(macros.len());
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    let mut skipped_pages = 0;
 | 
					    let fields = macros[lower..upper].iter().map(|m| {
 | 
				
			||||||
 | 
					 | 
				
			||||||
    let display_vec: Vec<String> = macros
 | 
					 | 
				
			||||||
        .iter()
 | 
					 | 
				
			||||||
        .map(|m| {
 | 
					 | 
				
			||||||
        if let Some(description) = &m.description {
 | 
					        if let Some(description) = &m.description {
 | 
				
			||||||
                format!("**{}**\n- *{}*\n- Has {} commands", m.name, description, m.commands.len())
 | 
					            (
 | 
				
			||||||
 | 
					                m.name.clone(),
 | 
				
			||||||
 | 
					                format!("*{}*\n- Has {} commands", description, m.commands.len()),
 | 
				
			||||||
 | 
					                true,
 | 
				
			||||||
 | 
					            )
 | 
				
			||||||
        } else {
 | 
					        } else {
 | 
				
			||||||
                format!("**{}**\n- Has {} commands", m.name, m.commands.len())
 | 
					            (m.name.clone(), format!("- Has {} commands", m.commands.len()), true)
 | 
				
			||||||
        }
 | 
					        }
 | 
				
			||||||
        })
 | 
					    });
 | 
				
			||||||
        .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();
 | 
					    let mut reply = CreateReply::default();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    reply
 | 
					    reply
 | 
				
			||||||
        .embed(|e| {
 | 
					        .embed(|e| {
 | 
				
			||||||
            e.title("Macros")
 | 
					            e.title("Macros")
 | 
				
			||||||
                .description(display)
 | 
					                .fields(fields)
 | 
				
			||||||
                .footer(|f| f.text(format!("Page {} of {}", page + 1, pages)))
 | 
					                .footer(|f| f.text(format!("Page {} of {}", page + 1, pages)))
 | 
				
			||||||
                .color(*THEME_COLOR)
 | 
					                .color(*THEME_COLOR)
 | 
				
			||||||
        })
 | 
					        })
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -27,7 +27,7 @@ pub async fn migrate_macro(ctx: Context<'_>) -> Result<(), Error> {
 | 
				
			|||||||
        "SELECT name, command FROM command_aliases WHERE guild_id = (SELECT id FROM guilds WHERE guild = ?)",
 | 
					        "SELECT name, command FROM command_aliases WHERE guild_id = (SELECT id FROM guilds WHERE guild = ?)",
 | 
				
			||||||
        guild_id.0
 | 
					        guild_id.0
 | 
				
			||||||
    )
 | 
					    )
 | 
				
			||||||
    .fetch_all(&mut transaction)
 | 
					    .fetch_all(&mut *transaction)
 | 
				
			||||||
    .await?;
 | 
					    .await?;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    let mut added_aliases = 0;
 | 
					    let mut added_aliases = 0;
 | 
				
			||||||
@@ -42,7 +42,7 @@ pub async fn migrate_macro(ctx: Context<'_>) -> Result<(), Error> {
 | 
				
			|||||||
                    cmd_macro.description,
 | 
					                    cmd_macro.description,
 | 
				
			||||||
                    cmd_macro.commands
 | 
					                    cmd_macro.commands
 | 
				
			||||||
                )
 | 
					                )
 | 
				
			||||||
                .execute(&mut transaction)
 | 
					                .execute(&mut *transaction)
 | 
				
			||||||
                .await?;
 | 
					                .await?;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
                added_aliases += 1;
 | 
					                added_aliases += 1;
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -15,6 +15,18 @@ pub async fn record_macro(
 | 
				
			|||||||
    #[description = "Name for the new macro"] name: String,
 | 
					    #[description = "Name for the new macro"] name: String,
 | 
				
			||||||
    #[description = "Description for the new macro"] description: Option<String>,
 | 
					    #[description = "Description for the new macro"] description: Option<String>,
 | 
				
			||||||
) -> Result<(), Error> {
 | 
					) -> 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 guild_id = ctx.guild_id().unwrap();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    let row = sqlx::query!(
 | 
					    let row = sqlx::query!(
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -1,5 +1,5 @@
 | 
				
			|||||||
use super::super::autocomplete::macro_name_autocomplete;
 | 
					use super::super::autocomplete::macro_name_autocomplete;
 | 
				
			||||||
use crate::{models::command_macro::guild_command_macro, Context, Data, Error};
 | 
					use crate::{models::command_macro::guild_command_macro, Context, Data, Error, THEME_COLOR};
 | 
				
			||||||
 | 
					
 | 
				
			||||||
/// Run a recorded macro
 | 
					/// Run a recorded macro
 | 
				
			||||||
#[poise::command(
 | 
					#[poise::command(
 | 
				
			||||||
@@ -17,7 +17,17 @@ pub async fn run_macro(
 | 
				
			|||||||
) -> Result<(), Error> {
 | 
					) -> Result<(), Error> {
 | 
				
			||||||
    match guild_command_macro(&Context::Application(ctx), &name).await {
 | 
					    match guild_command_macro(&Context::Application(ctx), &name).await {
 | 
				
			||||||
        Some(command_macro) => {
 | 
					        Some(command_macro) => {
 | 
				
			||||||
            ctx.defer_response(false).await?;
 | 
					            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 {
 | 
					            for command in command_macro.commands {
 | 
				
			||||||
                if let Some(action) = command.action {
 | 
					                if let Some(action) = command.action {
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -6,8 +6,8 @@ use crate::{models::CtxData, Context, Error, THEME_COLOR};
 | 
				
			|||||||
fn footer(
 | 
					fn footer(
 | 
				
			||||||
    ctx: Context<'_>,
 | 
					    ctx: Context<'_>,
 | 
				
			||||||
) -> impl FnOnce(&mut serenity::CreateEmbedFooter) -> &mut serenity::CreateEmbedFooter {
 | 
					) -> impl FnOnce(&mut serenity::CreateEmbedFooter) -> &mut serenity::CreateEmbedFooter {
 | 
				
			||||||
    let shard_count = ctx.discord().cache.shard_count();
 | 
					    let shard_count = ctx.serenity_context().cache.shard_count();
 | 
				
			||||||
    let shard = ctx.discord().shard_id;
 | 
					    let shard = ctx.serenity_context().shard_id;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    move |f| {
 | 
					    move |f| {
 | 
				
			||||||
        f.text(format!(
 | 
					        f.text(format!(
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -1,6 +1,7 @@
 | 
				
			|||||||
use chrono::offset::Utc;
 | 
					use chrono::offset::Utc;
 | 
				
			||||||
use chrono_tz::{Tz, TZ_VARIANTS};
 | 
					use chrono_tz::{Tz, TZ_VARIANTS};
 | 
				
			||||||
use levenshtein::levenshtein;
 | 
					use levenshtein::levenshtein;
 | 
				
			||||||
 | 
					use log::warn;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
use super::autocomplete::timezone_autocomplete;
 | 
					use super::autocomplete::timezone_autocomplete;
 | 
				
			||||||
use crate::{consts::THEME_COLOR, models::CtxData, Context, Error};
 | 
					use crate::{consts::THEME_COLOR, models::CtxData, Context, Error};
 | 
				
			||||||
@@ -101,6 +102,78 @@ You may want to use one of the popular timezones below, otherwise click [here](h
 | 
				
			|||||||
    Ok(())
 | 
					    Ok(())
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					/// Configure server settings
 | 
				
			||||||
 | 
					#[poise::command(
 | 
				
			||||||
 | 
					    slash_command,
 | 
				
			||||||
 | 
					    rename = "settings",
 | 
				
			||||||
 | 
					    identifying_name = "settings",
 | 
				
			||||||
 | 
					    guild_only = true
 | 
				
			||||||
 | 
					)]
 | 
				
			||||||
 | 
					pub async fn settings(_ctx: Context<'_>) -> Result<(), Error> {
 | 
				
			||||||
 | 
					    Ok(())
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					/// Configure ephemeral setup
 | 
				
			||||||
 | 
					#[poise::command(
 | 
				
			||||||
 | 
					    slash_command,
 | 
				
			||||||
 | 
					    rename = "ephemeral",
 | 
				
			||||||
 | 
					    identifying_name = "ephemeral_confirmations",
 | 
				
			||||||
 | 
					    guild_only = true
 | 
				
			||||||
 | 
					)]
 | 
				
			||||||
 | 
					pub async fn ephemeral_confirmations(_ctx: Context<'_>) -> Result<(), Error> {
 | 
				
			||||||
 | 
					    Ok(())
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					/// Set reminder confirmations to be sent "ephemerally" (private and cleared automatically)
 | 
				
			||||||
 | 
					#[poise::command(
 | 
				
			||||||
 | 
					    slash_command,
 | 
				
			||||||
 | 
					    rename = "on",
 | 
				
			||||||
 | 
					    identifying_name = "set_ephemeral_confirmations",
 | 
				
			||||||
 | 
					    guild_only = true
 | 
				
			||||||
 | 
					)]
 | 
				
			||||||
 | 
					pub async fn set_ephemeral_confirmations(ctx: Context<'_>) -> Result<(), Error> {
 | 
				
			||||||
 | 
					    let mut guild_data = ctx.guild_data().await.unwrap()?;
 | 
				
			||||||
 | 
					    guild_data.ephemeral_confirmations = true;
 | 
				
			||||||
 | 
					    guild_data.commit_changes(&ctx.data().database).await;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    ctx.send(|r| {
 | 
				
			||||||
 | 
					        r.ephemeral(true).embed(|e| {
 | 
				
			||||||
 | 
					            e.title("Confirmations ephemeral")
 | 
				
			||||||
 | 
					                .description("Reminder confirmations will be sent privately, and removed when your client restarts.")
 | 
				
			||||||
 | 
					                .color(*THEME_COLOR)
 | 
				
			||||||
 | 
					        })
 | 
				
			||||||
 | 
					    })
 | 
				
			||||||
 | 
					    .await?;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    Ok(())
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					/// Set reminder confirmations to persist indefinitely
 | 
				
			||||||
 | 
					#[poise::command(
 | 
				
			||||||
 | 
					    slash_command,
 | 
				
			||||||
 | 
					    rename = "off",
 | 
				
			||||||
 | 
					    identifying_name = "unset_ephemeral_confirmations",
 | 
				
			||||||
 | 
					    guild_only = true
 | 
				
			||||||
 | 
					)]
 | 
				
			||||||
 | 
					pub async fn unset_ephemeral_confirmations(ctx: Context<'_>) -> Result<(), Error> {
 | 
				
			||||||
 | 
					    let mut guild_data = ctx.guild_data().await.unwrap()?;
 | 
				
			||||||
 | 
					    guild_data.ephemeral_confirmations = false;
 | 
				
			||||||
 | 
					    guild_data.commit_changes(&ctx.data().database).await;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    ctx.send(|r| {
 | 
				
			||||||
 | 
					        r.ephemeral(true).embed(|e| {
 | 
				
			||||||
 | 
					            e.title("Confirmations public")
 | 
				
			||||||
 | 
					                .description(
 | 
				
			||||||
 | 
					                    "Reminder confirmations will be sent as regular messages, and won't be removed automatically.",
 | 
				
			||||||
 | 
					                )
 | 
				
			||||||
 | 
					                .color(*THEME_COLOR)
 | 
				
			||||||
 | 
					        })
 | 
				
			||||||
 | 
					    })
 | 
				
			||||||
 | 
					    .await?;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    Ok(())
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
/// Configure whether other users can set reminders to your direct messages
 | 
					/// Configure whether other users can set reminders to your direct messages
 | 
				
			||||||
#[poise::command(slash_command, rename = "dm", identifying_name = "allowed_dm")]
 | 
					#[poise::command(slash_command, rename = "dm", identifying_name = "allowed_dm")]
 | 
				
			||||||
pub async fn allowed_dm(_ctx: Context<'_>) -> Result<(), Error> {
 | 
					pub async fn allowed_dm(_ctx: Context<'_>) -> Result<(), Error> {
 | 
				
			||||||
@@ -108,7 +181,7 @@ pub async fn allowed_dm(_ctx: Context<'_>) -> Result<(), Error> {
 | 
				
			|||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
/// Allow other users to set reminders in your direct messages
 | 
					/// Allow other users to set reminders in your direct messages
 | 
				
			||||||
#[poise::command(slash_command, rename = "allow", identifying_name = "allowed_dm")]
 | 
					#[poise::command(slash_command, rename = "allow", identifying_name = "set_allowed_dm")]
 | 
				
			||||||
pub async fn set_allowed_dm(ctx: Context<'_>) -> Result<(), Error> {
 | 
					pub async fn set_allowed_dm(ctx: Context<'_>) -> Result<(), Error> {
 | 
				
			||||||
    let mut user_data = ctx.author_data().await?;
 | 
					    let mut user_data = ctx.author_data().await?;
 | 
				
			||||||
    user_data.allowed_dm = true;
 | 
					    user_data.allowed_dm = true;
 | 
				
			||||||
@@ -127,7 +200,7 @@ pub async fn set_allowed_dm(ctx: Context<'_>) -> Result<(), Error> {
 | 
				
			|||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
/// Block other users from setting reminders in your direct messages
 | 
					/// Block other users from setting reminders in your direct messages
 | 
				
			||||||
#[poise::command(slash_command, rename = "block", identifying_name = "allowed_dm")]
 | 
					#[poise::command(slash_command, rename = "block", identifying_name = "unset_allowed_dm")]
 | 
				
			||||||
pub async fn unset_allowed_dm(ctx: Context<'_>) -> Result<(), Error> {
 | 
					pub async fn unset_allowed_dm(ctx: Context<'_>) -> Result<(), Error> {
 | 
				
			||||||
    let mut user_data = ctx.author_data().await?;
 | 
					    let mut user_data = ctx.author_data().await?;
 | 
				
			||||||
    user_data.allowed_dm = false;
 | 
					    user_data.allowed_dm = false;
 | 
				
			||||||
@@ -157,8 +230,7 @@ pub async fn webhook(ctx: Context<'_>) -> Result<(), Error> {
 | 
				
			|||||||
    match ctx.channel_data().await {
 | 
					    match ctx.channel_data().await {
 | 
				
			||||||
        Ok(data) => {
 | 
					        Ok(data) => {
 | 
				
			||||||
            if let (Some(id), Some(token)) = (data.webhook_id, data.webhook_token) {
 | 
					            if let (Some(id), Some(token)) = (data.webhook_id, data.webhook_token) {
 | 
				
			||||||
                let _ = ctx
 | 
					                ctx.send(|b| {
 | 
				
			||||||
                    .send(|b| {
 | 
					 | 
				
			||||||
                    b.ephemeral(true).content(format!(
 | 
					                    b.ephemeral(true).content(format!(
 | 
				
			||||||
                        "**Warning!**
 | 
					                        "**Warning!**
 | 
				
			||||||
This link can be used by users to anonymously send messages, with or without permissions.
 | 
					This link can be used by users to anonymously send messages, with or without permissions.
 | 
				
			||||||
@@ -167,13 +239,15 @@ Do not share it!
 | 
				
			|||||||
                        id, token,
 | 
					                        id, token,
 | 
				
			||||||
                    ))
 | 
					                    ))
 | 
				
			||||||
                })
 | 
					                })
 | 
				
			||||||
                    .await;
 | 
					                .await?;
 | 
				
			||||||
            } else {
 | 
					            } else {
 | 
				
			||||||
                let _ = ctx.say("No webhook configured on this channel.").await;
 | 
					                ctx.say("No webhook configured on this channel.").await?;
 | 
				
			||||||
            }
 | 
					            }
 | 
				
			||||||
        }
 | 
					        }
 | 
				
			||||||
        Err(_) => {
 | 
					        Err(e) => {
 | 
				
			||||||
            let _ = ctx.say("No webhook configured on this channel.").await;
 | 
					            warn!("Error fetching channel data: {:?}", e);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            ctx.say("No webhook configured on this channel.").await?;
 | 
				
			||||||
        }
 | 
					        }
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -1,21 +1,18 @@
 | 
				
			|||||||
use std::{
 | 
					use std::{collections::HashSet, string::ToString};
 | 
				
			||||||
    collections::HashSet,
 | 
					 | 
				
			||||||
    string::ToString,
 | 
					 | 
				
			||||||
    time::{SystemTime, UNIX_EPOCH},
 | 
					 | 
				
			||||||
};
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
use chrono::NaiveDateTime;
 | 
					use chrono::{DateTime, NaiveDateTime, Utc};
 | 
				
			||||||
use chrono_tz::Tz;
 | 
					use chrono_tz::Tz;
 | 
				
			||||||
 | 
					use log::warn;
 | 
				
			||||||
use num_integer::Integer;
 | 
					use num_integer::Integer;
 | 
				
			||||||
use poise::{
 | 
					use poise::{
 | 
				
			||||||
    serenity_prelude::{
 | 
					    serenity_prelude::{
 | 
				
			||||||
        builder::CreateEmbed, component::ButtonStyle, model::channel::Channel, ReactionType,
 | 
					        builder::CreateEmbed, component::ButtonStyle, model::channel::Channel, ReactionType,
 | 
				
			||||||
    },
 | 
					    },
 | 
				
			||||||
    AutocompleteChoice, CreateReply, Modal,
 | 
					    CreateReply, Modal,
 | 
				
			||||||
};
 | 
					};
 | 
				
			||||||
 | 
					
 | 
				
			||||||
use super::autocomplete::timezone_autocomplete;
 | 
					 | 
				
			||||||
use crate::{
 | 
					use crate::{
 | 
				
			||||||
 | 
					    commands::autocomplete::{time_hint_autocomplete, timezone_autocomplete},
 | 
				
			||||||
    component_models::{
 | 
					    component_models::{
 | 
				
			||||||
        pager::{DelPager, LookPager, Pager},
 | 
					        pager::{DelPager, LookPager, Pager},
 | 
				
			||||||
        ComponentDataModel, DelSelector, UndoReminder,
 | 
					        ComponentDataModel, DelSelector, UndoReminder,
 | 
				
			||||||
@@ -60,8 +57,8 @@ pub async fn pause(
 | 
				
			|||||||
            let parsed = natural_parser(&until, &timezone.to_string()).await;
 | 
					            let parsed = natural_parser(&until, &timezone.to_string()).await;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
            if let Some(timestamp) = parsed {
 | 
					            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 = true;
 | 
				
			||||||
                        channel.paused_until = Some(dt);
 | 
					                        channel.paused_until = Some(dt);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -72,6 +69,15 @@ pub async fn pause(
 | 
				
			|||||||
                            timestamp
 | 
					                            timestamp
 | 
				
			||||||
                        ))
 | 
					                        ))
 | 
				
			||||||
                        .await?;
 | 
					                        .await?;
 | 
				
			||||||
 | 
					                    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                    None => {
 | 
				
			||||||
 | 
					                        ctx.say(format!(
 | 
				
			||||||
 | 
					                            "Time processed could not be interpreted as `DateTime`. Please write the time as clearly as possible",
 | 
				
			||||||
 | 
					                        ))
 | 
				
			||||||
 | 
					                        .await?;
 | 
				
			||||||
 | 
					                    }
 | 
				
			||||||
 | 
					                }
 | 
				
			||||||
            } else {
 | 
					            } else {
 | 
				
			||||||
                ctx.say(
 | 
					                ctx.say(
 | 
				
			||||||
                    "Time could not be processed. Please write the time as clearly as possible",
 | 
					                    "Time could not be processed. Please write the time as clearly as possible",
 | 
				
			||||||
@@ -108,6 +114,8 @@ pub async fn offset(
 | 
				
			|||||||
    #[description = "Number of minutes to offset by"] minutes: Option<isize>,
 | 
					    #[description = "Number of minutes to offset by"] minutes: Option<isize>,
 | 
				
			||||||
    #[description = "Number of seconds to offset by"] seconds: Option<isize>,
 | 
					    #[description = "Number of seconds to offset by"] seconds: Option<isize>,
 | 
				
			||||||
) -> Result<(), Error> {
 | 
					) -> Result<(), Error> {
 | 
				
			||||||
 | 
					    ctx.defer().await?;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    let combined_time = hours.map_or(0, |h| h * HOUR as isize)
 | 
					    let combined_time = hours.map_or(0, |h| h * HOUR as isize)
 | 
				
			||||||
        + minutes.map_or(0, |m| m * MINUTE as isize)
 | 
					        + minutes.map_or(0, |m| m * MINUTE as isize)
 | 
				
			||||||
        + seconds.map_or(0, |s| s);
 | 
					        + seconds.map_or(0, |s| s);
 | 
				
			||||||
@@ -210,7 +218,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 {
 | 
					    let channel_id = if let Some(Channel::Guild(channel)) = channel_opt {
 | 
				
			||||||
        if Some(channel.guild_id) == ctx.guild_id() {
 | 
					        if Some(channel.guild_id) == ctx.guild_id() {
 | 
				
			||||||
@@ -222,8 +230,7 @@ pub async fn look(
 | 
				
			|||||||
        ctx.channel_id()
 | 
					        ctx.channel_id()
 | 
				
			||||||
    };
 | 
					    };
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    let channel_name =
 | 
					    let channel_name = if let Some(Channel::Guild(channel)) = channel_id.to_channel_cached(&ctx) {
 | 
				
			||||||
        if let Some(Channel::Guild(channel)) = channel_id.to_channel_cached(&ctx.discord()) {
 | 
					 | 
				
			||||||
        Some(channel.name)
 | 
					        Some(channel.name)
 | 
				
			||||||
    } else {
 | 
					    } else {
 | 
				
			||||||
        None
 | 
					        None
 | 
				
			||||||
@@ -245,7 +252,7 @@ pub async fn look(
 | 
				
			|||||||
                char_count < EMBED_DESCRIPTION_MAX_LENGTH
 | 
					                char_count < EMBED_DESCRIPTION_MAX_LENGTH
 | 
				
			||||||
            })
 | 
					            })
 | 
				
			||||||
            .collect::<Vec<String>>()
 | 
					            .collect::<Vec<String>>()
 | 
				
			||||||
            .join("\n");
 | 
					            .join("");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        let pages = reminders
 | 
					        let pages = reminders
 | 
				
			||||||
            .iter()
 | 
					            .iter()
 | 
				
			||||||
@@ -289,8 +296,7 @@ pub async fn delete(ctx: Context<'_>) -> Result<(), Error> {
 | 
				
			|||||||
    let timezone = ctx.timezone().await;
 | 
					    let timezone = ctx.timezone().await;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    let reminders =
 | 
					    let reminders =
 | 
				
			||||||
        Reminder::from_guild(&ctx.discord(), &ctx.data().database, ctx.guild_id(), ctx.author().id)
 | 
					        Reminder::from_guild(&ctx, &ctx.data().database, ctx.guild_id(), ctx.author().id).await;
 | 
				
			||||||
            .await;
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
    let resp = show_delete_page(&reminders, 0, timezone);
 | 
					    let resp = show_delete_page(&reminders, 0, timezone);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -432,11 +438,8 @@ pub fn show_delete_page(reminders: &[Reminder], page: usize, timezone: Tz) -> Cr
 | 
				
			|||||||
    reply
 | 
					    reply
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
fn time_difference(start_time: NaiveDateTime) -> String {
 | 
					fn time_difference(start_time: DateTime<Utc>) -> String {
 | 
				
			||||||
    let unix_time = SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_secs() as i64;
 | 
					    let delta = (Utc::now() - start_time).num_seconds();
 | 
				
			||||||
    let now = NaiveDateTime::from_timestamp(unix_time, 0);
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    let delta = (now - start_time).num_seconds();
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
    let (minutes, seconds) = delta.div_rem(&60);
 | 
					    let (minutes, seconds) = delta.div_rem(&60);
 | 
				
			||||||
    let (hours, minutes) = minutes.div_rem(&60);
 | 
					    let (hours, minutes) = minutes.div_rem(&60);
 | 
				
			||||||
@@ -550,20 +553,6 @@ pub async fn delete_timer(
 | 
				
			|||||||
    Ok(())
 | 
					    Ok(())
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
async fn multiline_autocomplete(
 | 
					 | 
				
			||||||
    _ctx: Context<'_>,
 | 
					 | 
				
			||||||
    partial: &str,
 | 
					 | 
				
			||||||
) -> Vec<AutocompleteChoice<String>> {
 | 
					 | 
				
			||||||
    if partial.is_empty() {
 | 
					 | 
				
			||||||
        vec![AutocompleteChoice { name: "Multiline content...".to_string(), value: "".to_string() }]
 | 
					 | 
				
			||||||
    } else {
 | 
					 | 
				
			||||||
        vec![
 | 
					 | 
				
			||||||
            AutocompleteChoice { name: partial.to_string(), value: partial.to_string() },
 | 
					 | 
				
			||||||
            AutocompleteChoice { name: "Multiline content...".to_string(), value: "".to_string() },
 | 
					 | 
				
			||||||
        ]
 | 
					 | 
				
			||||||
    }
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
#[derive(poise::Modal)]
 | 
					#[derive(poise::Modal)]
 | 
				
			||||||
#[name = "Reminder"]
 | 
					#[name = "Reminder"]
 | 
				
			||||||
struct ContentModal {
 | 
					struct ContentModal {
 | 
				
			||||||
@@ -574,7 +563,57 @@ struct ContentModal {
 | 
				
			|||||||
    content: String,
 | 
					    content: String,
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
/// Create a reminder. Press "+5 more" for other options. A modal will open if "content" is not provided
 | 
					/// 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(
 | 
					#[poise::command(
 | 
				
			||||||
    slash_command,
 | 
					    slash_command,
 | 
				
			||||||
    identifying_name = "remind",
 | 
					    identifying_name = "remind",
 | 
				
			||||||
@@ -582,10 +621,10 @@ struct ContentModal {
 | 
				
			|||||||
)]
 | 
					)]
 | 
				
			||||||
pub async fn remind(
 | 
					pub async fn remind(
 | 
				
			||||||
    ctx: ApplicationContext<'_>,
 | 
					    ctx: ApplicationContext<'_>,
 | 
				
			||||||
    #[description = "A description of the time to set the reminder for"] time: String,
 | 
					    #[description = "The time (and optionally date) to set the reminder for"]
 | 
				
			||||||
    #[description = "The message content to send"]
 | 
					    #[autocomplete = "time_hint_autocomplete"]
 | 
				
			||||||
    #[autocomplete = "multiline_autocomplete"]
 | 
					    time: String,
 | 
				
			||||||
    content: String,
 | 
					    #[description = "The message content to send"] content: String,
 | 
				
			||||||
    #[description = "Channel or user mentions to set the reminder for"] channels: Option<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"]
 | 
					    #[description = "(Patreon only) Time to wait before repeating the reminder. Leave blank for one-shot reminder"]
 | 
				
			||||||
    interval: Option<String>,
 | 
					    interval: Option<String>,
 | 
				
			||||||
@@ -599,33 +638,8 @@ pub async fn remind(
 | 
				
			|||||||
) -> Result<(), Error> {
 | 
					) -> Result<(), Error> {
 | 
				
			||||||
    let tz = timezone.map(|t| t.parse::<Tz>().ok()).flatten();
 | 
					    let tz = timezone.map(|t| t.parse::<Tz>().ok()).flatten();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    if content.is_empty() {
 | 
					    create_reminder(Context::Application(ctx), time, content, channels, interval, expires, tts, tz)
 | 
				
			||||||
        let data = ContentModal::execute(ctx).await?;
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
        create_reminder(
 | 
					 | 
				
			||||||
            Context::Application(ctx),
 | 
					 | 
				
			||||||
            time,
 | 
					 | 
				
			||||||
            data.content,
 | 
					 | 
				
			||||||
            channels,
 | 
					 | 
				
			||||||
            interval,
 | 
					 | 
				
			||||||
            expires,
 | 
					 | 
				
			||||||
            tts,
 | 
					 | 
				
			||||||
            tz,
 | 
					 | 
				
			||||||
        )
 | 
					 | 
				
			||||||
        .await
 | 
					        .await
 | 
				
			||||||
    } else {
 | 
					 | 
				
			||||||
        create_reminder(
 | 
					 | 
				
			||||||
            Context::Application(ctx),
 | 
					 | 
				
			||||||
            time,
 | 
					 | 
				
			||||||
            content,
 | 
					 | 
				
			||||||
            channels,
 | 
					 | 
				
			||||||
            interval,
 | 
					 | 
				
			||||||
            expires,
 | 
					 | 
				
			||||||
            tts,
 | 
					 | 
				
			||||||
            tz,
 | 
					 | 
				
			||||||
        )
 | 
					 | 
				
			||||||
        .await
 | 
					 | 
				
			||||||
    }
 | 
					 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
async fn create_reminder(
 | 
					async fn create_reminder(
 | 
				
			||||||
@@ -644,7 +658,13 @@ async fn create_reminder(
 | 
				
			|||||||
        return Ok(());
 | 
					        return Ok(());
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    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?;
 | 
					        ctx.defer().await?;
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    let user_data = ctx.author_data().await.unwrap();
 | 
					    let user_data = ctx.author_data().await.unwrap();
 | 
				
			||||||
    let timezone = timezone.unwrap_or(ctx.timezone().await);
 | 
					    let timezone = timezone.unwrap_or(ctx.timezone().await);
 | 
				
			||||||
@@ -674,9 +694,9 @@ async fn create_reminder(
 | 
				
			|||||||
            };
 | 
					            };
 | 
				
			||||||
 | 
					
 | 
				
			||||||
            let (processed_interval, processed_expires) = if let Some(repeat) = &interval {
 | 
					            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()
 | 
					                    || (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)
 | 
					                        parse_duration(repeat)
 | 
				
			||||||
@@ -691,9 +711,10 @@ async fn create_reminder(
 | 
				
			|||||||
                        },
 | 
					                        },
 | 
				
			||||||
                    )
 | 
					                    )
 | 
				
			||||||
                } else {
 | 
					                } else {
 | 
				
			||||||
                    ctx.say(
 | 
					                    ctx.send(|b| {
 | 
				
			||||||
                        "`repeat` is only available to Patreon subscribers or self-hosted users",
 | 
					                        b.content(
 | 
				
			||||||
                    )
 | 
					                        "`repeat` is only available to Patreon subscribers or self-hosted users")
 | 
				
			||||||
 | 
					                    })
 | 
				
			||||||
                    .await?;
 | 
					                    .await?;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
                    return Ok(());
 | 
					                    return Ok(());
 | 
				
			||||||
@@ -703,12 +724,17 @@ async fn create_reminder(
 | 
				
			|||||||
            };
 | 
					            };
 | 
				
			||||||
 | 
					
 | 
				
			||||||
            if processed_interval.is_none() && interval.is_some() {
 | 
					            if processed_interval.is_none() && interval.is_some() {
 | 
				
			||||||
                ctx.say(
 | 
					                ctx.send(|b| {
 | 
				
			||||||
                    "Repeat interval could not be processed. Try similar to `1 hour` or `4 days`",
 | 
					                    b.content(
 | 
				
			||||||
                )
 | 
					                    "Repeat interval could not be processed. Try similar to `1 hour` or `4 days`")
 | 
				
			||||||
 | 
					                })
 | 
				
			||||||
                .await?;
 | 
					                .await?;
 | 
				
			||||||
            } else if processed_expires.is_none() && expires.is_some() {
 | 
					            } else if processed_expires.is_none() && expires.is_some() {
 | 
				
			||||||
                ctx.say("Expiry time failed to process. Please make it as clear as possible")
 | 
					                ctx.send(|b| {
 | 
				
			||||||
 | 
					                    b.ephemeral(true).content(
 | 
				
			||||||
 | 
					                        "Expiry time failed to process. Please make it as clear as possible",
 | 
				
			||||||
 | 
					                    )
 | 
				
			||||||
 | 
					                })
 | 
				
			||||||
                .await?;
 | 
					                .await?;
 | 
				
			||||||
            } else {
 | 
					            } else {
 | 
				
			||||||
                let mut builder = MultiReminderBuilder::new(&ctx, ctx.guild_id())
 | 
					                let mut builder = MultiReminderBuilder::new(&ctx, ctx.guild_id())
 | 
				
			||||||
@@ -749,7 +775,7 @@ async fn create_reminder(
 | 
				
			|||||||
                                    b.emoji(ReactionType::Unicode("📝".to_string()))
 | 
					                                    b.emoji(ReactionType::Unicode("📝".to_string()))
 | 
				
			||||||
                                        .label("Edit")
 | 
					                                        .label("Edit")
 | 
				
			||||||
                                        .style(ButtonStyle::Link)
 | 
					                                        .style(ButtonStyle::Link)
 | 
				
			||||||
                                        .url("https://reminder-bot.com/dashboard")
 | 
					                                        .url("https://beta.reminder-bot.com/dashboard")
 | 
				
			||||||
                                })
 | 
					                                })
 | 
				
			||||||
                            })
 | 
					                            })
 | 
				
			||||||
                        })
 | 
					                        })
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -340,7 +340,18 @@ pub fn show_todo_page(
 | 
				
			|||||||
                                opt.create_option(|o| {
 | 
					                                opt.create_option(|o| {
 | 
				
			||||||
                                    o.label(format!("Mark {} complete", count + first_num))
 | 
					                                    o.label(format!("Mark {} complete", count + first_num))
 | 
				
			||||||
                                        .value(id)
 | 
					                                        .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,6 +2,7 @@ pub(crate) mod pager;
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
use std::io::Cursor;
 | 
					use std::io::Cursor;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					use base64::{engine::general_purpose, Engine};
 | 
				
			||||||
use chrono_tz::Tz;
 | 
					use chrono_tz::Tz;
 | 
				
			||||||
use log::warn;
 | 
					use log::warn;
 | 
				
			||||||
use poise::{
 | 
					use poise::{
 | 
				
			||||||
@@ -51,11 +52,12 @@ impl ComponentDataModel {
 | 
				
			|||||||
    pub fn to_custom_id(&self) -> String {
 | 
					    pub fn to_custom_id(&self) -> String {
 | 
				
			||||||
        let mut buf = Vec::new();
 | 
					        let mut buf = Vec::new();
 | 
				
			||||||
        self.serialize(&mut Serializer::new(&mut buf)).unwrap();
 | 
					        self.serialize(&mut Serializer::new(&mut buf)).unwrap();
 | 
				
			||||||
        base64::encode(buf)
 | 
					        general_purpose::STANDARD.encode(buf)
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    pub fn from_custom_id(data: &String) -> Self {
 | 
					    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))
 | 
					            .map_err(|e| format!("Could not decode `custom_id' {}: {:?}", data, e))
 | 
				
			||||||
            .unwrap();
 | 
					            .unwrap();
 | 
				
			||||||
        let cur = Cursor::new(buf);
 | 
					        let cur = Cursor::new(buf);
 | 
				
			||||||
@@ -113,7 +115,7 @@ impl ComponentDataModel {
 | 
				
			|||||||
                        char_count < EMBED_DESCRIPTION_MAX_LENGTH
 | 
					                        char_count < EMBED_DESCRIPTION_MAX_LENGTH
 | 
				
			||||||
                    })
 | 
					                    })
 | 
				
			||||||
                    .collect::<Vec<String>>()
 | 
					                    .collect::<Vec<String>>()
 | 
				
			||||||
                    .join("\n");
 | 
					                    .join("");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
                let mut embed = CreateEmbed::default();
 | 
					                let mut embed = CreateEmbed::default();
 | 
				
			||||||
                embed
 | 
					                embed
 | 
				
			||||||
@@ -166,7 +168,10 @@ impl ComponentDataModel {
 | 
				
			|||||||
            ComponentDataModel::DelSelector(selector) => {
 | 
					            ComponentDataModel::DelSelector(selector) => {
 | 
				
			||||||
                let selected_id = component.data.values.join(",");
 | 
					                let selected_id = component.data.values.join(",");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
                sqlx::query!("DELETE FROM reminders WHERE FIND_IN_SET(id, ?)", selected_id)
 | 
					                sqlx::query!(
 | 
				
			||||||
 | 
					                    "UPDATE reminders SET `status` = 'deleted' WHERE FIND_IN_SET(id, ?)",
 | 
				
			||||||
 | 
					                    selected_id
 | 
				
			||||||
 | 
					                )
 | 
				
			||||||
                .execute(&data.database)
 | 
					                .execute(&data.database)
 | 
				
			||||||
                .await
 | 
					                .await
 | 
				
			||||||
                .unwrap();
 | 
					                .unwrap();
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -2,7 +2,7 @@ pub const DAY: u64 = 86_400;
 | 
				
			|||||||
pub const HOUR: u64 = 3_600;
 | 
					pub const HOUR: u64 = 3_600;
 | 
				
			||||||
pub const MINUTE: u64 = 60;
 | 
					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 SELECT_MAX_ENTRIES: usize = 25;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
pub const CHARACTERS: &str = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_";
 | 
					pub const CHARACTERS: &str = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_";
 | 
				
			||||||
@@ -17,17 +17,13 @@ use regex::Regex;
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
lazy_static! {
 | 
					lazy_static! {
 | 
				
			||||||
    pub static ref DEFAULT_AVATAR: AttachmentType<'static> = (
 | 
					    pub static ref DEFAULT_AVATAR: AttachmentType<'static> = (
 | 
				
			||||||
        include_bytes!(concat!(
 | 
					        include_bytes!(concat!(env!("CARGO_MANIFEST_DIR"), "/assets/webhook.jpg")) as &[u8],
 | 
				
			||||||
            env!("CARGO_MANIFEST_DIR"),
 | 
					        "webhook.jpg",
 | 
				
			||||||
            "/assets/",
 | 
					 | 
				
			||||||
            env!("WEBHOOK_AVATAR", "WEBHOOK_AVATAR not provided for compilation")
 | 
					 | 
				
			||||||
        )) as &[u8],
 | 
					 | 
				
			||||||
        env!("WEBHOOK_AVATAR"),
 | 
					 | 
				
			||||||
    )
 | 
					    )
 | 
				
			||||||
        .into();
 | 
					        .into();
 | 
				
			||||||
    pub static ref REGEX_CHANNEL_USER: Regex = Regex::new(r#"\s*<(#|@)(?:!)?(\d+)>\s*"#).unwrap();
 | 
					    pub static ref REGEX_CHANNEL_USER: Regex = Regex::new(r#"\s*<(#|@)(?:!)?(\d+)>\s*"#).unwrap();
 | 
				
			||||||
    pub static ref SUBSCRIPTION_ROLES: HashSet<u64> = HashSet::from_iter(
 | 
					    pub static ref SUBSCRIPTION_ROLES: HashSet<u64> = HashSet::from_iter(
 | 
				
			||||||
        env::var("SUBSCRIPTION_ROLES")
 | 
					        env::var("PATREON_ROLE_ID")
 | 
				
			||||||
            .map(|var| var
 | 
					            .map(|var| var
 | 
				
			||||||
                .split(',')
 | 
					                .split(',')
 | 
				
			||||||
                .filter_map(|item| { item.parse::<u64>().ok() })
 | 
					                .filter_map(|item| { item.parse::<u64>().ok() })
 | 
				
			||||||
@@ -35,7 +31,7 @@ lazy_static! {
 | 
				
			|||||||
            .unwrap_or_else(|_| Vec::new())
 | 
					            .unwrap_or_else(|_| Vec::new())
 | 
				
			||||||
    );
 | 
					    );
 | 
				
			||||||
    pub static ref CNC_GUILD: Option<u64> =
 | 
					    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 =
 | 
					    pub static ref MIN_INTERVAL: i64 =
 | 
				
			||||||
        env::var("MIN_INTERVAL").ok().and_then(|inner| inner.parse::<i64>().ok()).unwrap_or(600);
 | 
					        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")
 | 
					    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)
 | 
					        .map_or(THEME_COLOR_FALLBACK, |inner| u32::from_str_radix(&inner, 16)
 | 
				
			||||||
            .unwrap_or(THEME_COLOR_FALLBACK));
 | 
					            .unwrap_or(THEME_COLOR_FALLBACK));
 | 
				
			||||||
    pub static ref PYTHON_LOCATION: String =
 | 
					    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());
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 
 | 
				
			|||||||
							
								
								
									
										31
									
								
								src/hooks.rs
									
									
									
									
									
								
							
							
						
						
									
										31
									
								
								src/hooks.rs
									
									
									
									
									
								
							@@ -1,9 +1,14 @@
 | 
				
			|||||||
use poise::serenity_prelude::model::channel::Channel;
 | 
					use poise::{
 | 
				
			||||||
 | 
					    serenity_prelude::model::channel::Channel, ApplicationCommandOrAutocompleteInteraction,
 | 
				
			||||||
 | 
					};
 | 
				
			||||||
 | 
					
 | 
				
			||||||
use crate::{consts::MACRO_MAX_COMMANDS, models::command_macro::RecordedCommand, Context, Error};
 | 
					use crate::{consts::MACRO_MAX_COMMANDS, models::command_macro::RecordedCommand, Context, Error};
 | 
				
			||||||
 | 
					
 | 
				
			||||||
async fn macro_check(ctx: Context<'_>) -> bool {
 | 
					async fn macro_check(ctx: Context<'_>) -> bool {
 | 
				
			||||||
    if let Context::Application(app_ctx) = ctx {
 | 
					    if let Context::Application(app_ctx) = ctx {
 | 
				
			||||||
 | 
					        if let ApplicationCommandOrAutocompleteInteraction::ApplicationCommand(_) =
 | 
				
			||||||
 | 
					            app_ctx.interaction
 | 
				
			||||||
 | 
					        {
 | 
				
			||||||
            if let Some(guild_id) = ctx.guild_id() {
 | 
					            if let Some(guild_id) = ctx.guild_id() {
 | 
				
			||||||
                if ctx.command().identifying_name != "finish_macro" {
 | 
					                if ctx.command().identifying_name != "finish_macro" {
 | 
				
			||||||
                    let mut lock = ctx.data().recording_macros.write().await;
 | 
					                    let mut lock = ctx.data().recording_macros.write().await;
 | 
				
			||||||
@@ -35,31 +40,33 @@ async fn macro_check(ctx: Context<'_>) -> bool {
 | 
				
			|||||||
                }
 | 
					                }
 | 
				
			||||||
            }
 | 
					            }
 | 
				
			||||||
        }
 | 
					        }
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    true
 | 
					    true
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
async fn check_self_permissions(ctx: Context<'_>) -> bool {
 | 
					async fn check_self_permissions(ctx: Context<'_>) -> bool {
 | 
				
			||||||
    if let Some(guild) = ctx.guild() {
 | 
					    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
 | 
					        let (view_channel, send_messages, embed_links) = ctx
 | 
				
			||||||
            .channel_id()
 | 
					            .channel_id()
 | 
				
			||||||
            .to_channel_cached(&ctx.discord())
 | 
					            .to_channel(&ctx)
 | 
				
			||||||
 | 
					            .await
 | 
				
			||||||
 | 
					            .ok()
 | 
				
			||||||
            .and_then(|c| {
 | 
					            .and_then(|c| {
 | 
				
			||||||
                if let Channel::Guild(channel) = 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 {
 | 
					                } else {
 | 
				
			||||||
                    None
 | 
					                    None
 | 
				
			||||||
                }
 | 
					                }
 | 
				
			||||||
            })
 | 
					            })
 | 
				
			||||||
            .map_or((false, false, false), |p| {
 | 
					            .unwrap_or((false, false, false));
 | 
				
			||||||
                (p.view_channel(), p.send_messages(), p.embed_links())
 | 
					 | 
				
			||||||
            });
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
        if manage_webhooks && send_messages && embed_links {
 | 
					        if manage_webhooks && send_messages && embed_links {
 | 
				
			||||||
            true
 | 
					            true
 | 
				
			||||||
@@ -75,8 +82,8 @@ async fn check_self_permissions(ctx: Context<'_>) -> bool {
 | 
				
			|||||||
{}     **Manage Webhooks**",
 | 
					{}     **Manage Webhooks**",
 | 
				
			||||||
                        if view_channel { "✅" } else { "❌" },
 | 
					                        if view_channel { "✅" } else { "❌" },
 | 
				
			||||||
                        if send_messages { "✅" } else { "❌" },
 | 
					                        if send_messages { "✅" } else { "❌" },
 | 
				
			||||||
                        if manage_webhooks { "✅" } else { "❌" },
 | 
					 | 
				
			||||||
                        if embed_links { "✅" } else { "❌" },
 | 
					                        if embed_links { "✅" } else { "❌" },
 | 
				
			||||||
 | 
					                        if manage_webhooks { "✅" } else { "❌" },
 | 
				
			||||||
                    ))
 | 
					                    ))
 | 
				
			||||||
                })
 | 
					                })
 | 
				
			||||||
                .await;
 | 
					                .await;
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -110,13 +110,14 @@ impl OverflowOp for u64 {
 | 
				
			|||||||
#[derive(Copy, Clone)]
 | 
					#[derive(Copy, Clone)]
 | 
				
			||||||
pub struct Interval {
 | 
					pub struct Interval {
 | 
				
			||||||
    pub month: u64,
 | 
					    pub month: u64,
 | 
				
			||||||
 | 
					    pub day: u64,
 | 
				
			||||||
    pub sec: u64,
 | 
					    pub sec: u64,
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
struct Parser<'a> {
 | 
					struct Parser<'a> {
 | 
				
			||||||
    iter: Chars<'a>,
 | 
					    iter: Chars<'a>,
 | 
				
			||||||
    src: &'a str,
 | 
					    src: &'a str,
 | 
				
			||||||
    current: (u64, u64, u64),
 | 
					    current: (u64, u64, u64, u64),
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
impl<'a> Parser<'a> {
 | 
					impl<'a> Parser<'a> {
 | 
				
			||||||
@@ -140,17 +141,17 @@ impl<'a> Parser<'a> {
 | 
				
			|||||||
        Ok(None)
 | 
					        Ok(None)
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
    fn parse_unit(&mut self, n: u64, start: usize, end: usize) -> Result<(), Error> {
 | 
					    fn parse_unit(&mut self, n: u64, start: usize, end: usize) -> Result<(), Error> {
 | 
				
			||||||
        let (mut month, mut sec, nsec) = match &self.src[start..end] {
 | 
					        let (mut month, mut day, mut sec, nsec) = match &self.src[start..end] {
 | 
				
			||||||
            "nanos" | "nsec" | "ns" => (0u64, 0u64, n),
 | 
					            "nanos" | "nsec" | "ns" => (0, 0u64, 0u64, n),
 | 
				
			||||||
            "usec" | "us" => (0, 0u64, n.mul(1000)?),
 | 
					            "usec" | "us" => (0, 0, 0u64, n.mul(1000)?),
 | 
				
			||||||
            "millis" | "msec" | "ms" => (0, 0u64, n.mul(1_000_000)?),
 | 
					            "millis" | "msec" | "ms" => (0, 0, 0u64, n.mul(1_000_000)?),
 | 
				
			||||||
            "seconds" | "second" | "secs" | "sec" | "s" => (0, n, 0),
 | 
					            "seconds" | "second" | "secs" | "sec" | "s" => (0, 0, n, 0),
 | 
				
			||||||
            "minutes" | "minute" | "min" | "mins" | "m" => (0, n.mul(60)?, 0),
 | 
					            "minutes" | "minute" | "min" | "mins" | "m" => (0, 0, n.mul(60)?, 0),
 | 
				
			||||||
            "hours" | "hour" | "hr" | "hrs" | "h" => (0, n.mul(3600)?, 0),
 | 
					            "hours" | "hour" | "hr" | "hrs" | "h" => (0, 0, n.mul(3600)?, 0),
 | 
				
			||||||
            "days" | "day" | "d" => (0, n.mul(86400)?, 0),
 | 
					            "days" | "day" | "d" => (0, n, 0, 0),
 | 
				
			||||||
            "weeks" | "week" | "w" => (0, n.mul(86400 * 7)?, 0),
 | 
					            "weeks" | "week" | "w" => (0, n.mul(7)?, 0, 0),
 | 
				
			||||||
            "months" | "month" | "M" => (n, 0, 0),
 | 
					            "months" | "month" => (n, 0, 0, 0),
 | 
				
			||||||
            "years" | "year" | "y" => (12, 0, 0),
 | 
					            "years" | "year" | "y" => (n.mul(12)?, 0, 0, 0),
 | 
				
			||||||
            _ => {
 | 
					            _ => {
 | 
				
			||||||
                return Err(Error::UnknownUnit {
 | 
					                return Err(Error::UnknownUnit {
 | 
				
			||||||
                    start,
 | 
					                    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 {
 | 
					        if nsec > 1_000_000_000 {
 | 
				
			||||||
            sec += nsec / 1_000_000_000;
 | 
					            sec += nsec / 1_000_000_000;
 | 
				
			||||||
            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;
 | 
					        month += self.current.0;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        self.current = (month, sec, nsec);
 | 
					        self.current = (month, day, sec, nsec);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        Ok(())
 | 
					        Ok(())
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
@@ -215,7 +217,13 @@ impl<'a> Parser<'a> {
 | 
				
			|||||||
            self.parse_unit(n, start, off)?;
 | 
					            self.parse_unit(n, start, off)?;
 | 
				
			||||||
            n = match self.parse_first_char()? {
 | 
					            n = match self.parse_first_char()? {
 | 
				
			||||||
                Some(n) => n,
 | 
					                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,82 @@ impl<'a> Parser<'a> {
 | 
				
			|||||||
/// assert_eq!(parse_duration("32ms"), Ok(Duration::new(0, 32_000_000)));
 | 
					/// assert_eq!(parse_duration("32ms"), Ok(Duration::new(0, 32_000_000)));
 | 
				
			||||||
/// ```
 | 
					/// ```
 | 
				
			||||||
pub fn parse_duration(s: &str) -> Result<Interval, Error> {
 | 
					pub fn parse_duration(s: &str) -> Result<Interval, Error> {
 | 
				
			||||||
    Parser { iter: s.chars(), src: s, current: (0, 0, 0) }.parse()
 | 
					    Parser { iter: s.to_lowercase().chars(), src: &s.to_lowercase(), current: (0, 0, 0, 0) }.parse()
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					#[cfg(test)]
 | 
				
			||||||
 | 
					mod tests {
 | 
				
			||||||
 | 
					    use super::*;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    #[test]
 | 
				
			||||||
 | 
					    fn parse_seconds() {
 | 
				
			||||||
 | 
					        let interval = parse_duration("10 seconds").unwrap();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        assert_eq!(interval.sec, 10);
 | 
				
			||||||
 | 
					        assert_eq!(interval.day, 0);
 | 
				
			||||||
 | 
					        assert_eq!(interval.month, 0);
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    #[test]
 | 
				
			||||||
 | 
					    fn parse_minutes() {
 | 
				
			||||||
 | 
					        let interval = parse_duration("10 minutes").unwrap();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        assert_eq!(interval.sec, 600);
 | 
				
			||||||
 | 
					        assert_eq!(interval.day, 0);
 | 
				
			||||||
 | 
					        assert_eq!(interval.month, 0);
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    #[test]
 | 
				
			||||||
 | 
					    fn parse_hours() {
 | 
				
			||||||
 | 
					        let interval = parse_duration("10 hours").unwrap();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        assert_eq!(interval.sec, 36_000);
 | 
				
			||||||
 | 
					        assert_eq!(interval.day, 0);
 | 
				
			||||||
 | 
					        assert_eq!(interval.month, 0);
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    #[test]
 | 
				
			||||||
 | 
					    fn parse_days() {
 | 
				
			||||||
 | 
					        let interval = parse_duration("10 days").unwrap();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        assert_eq!(interval.sec, 0);
 | 
				
			||||||
 | 
					        assert_eq!(interval.day, 10);
 | 
				
			||||||
 | 
					        assert_eq!(interval.month, 0);
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    #[test]
 | 
				
			||||||
 | 
					    fn parse_weeks() {
 | 
				
			||||||
 | 
					        let interval = parse_duration("10 weeks").unwrap();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        assert_eq!(interval.sec, 0);
 | 
				
			||||||
 | 
					        assert_eq!(interval.day, 70);
 | 
				
			||||||
 | 
					        assert_eq!(interval.month, 0);
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    #[test]
 | 
				
			||||||
 | 
					    fn parse_months() {
 | 
				
			||||||
 | 
					        let interval = parse_duration("10 months").unwrap();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        assert_eq!(interval.sec, 0);
 | 
				
			||||||
 | 
					        assert_eq!(interval.day, 0);
 | 
				
			||||||
 | 
					        assert_eq!(interval.month, 10);
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    #[test]
 | 
				
			||||||
 | 
					    fn parse_years() {
 | 
				
			||||||
 | 
					        let interval = parse_duration("10 years").unwrap();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        assert_eq!(interval.sec, 0);
 | 
				
			||||||
 | 
					        assert_eq!(interval.day, 0);
 | 
				
			||||||
 | 
					        assert_eq!(interval.month, 120);
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    #[test]
 | 
				
			||||||
 | 
					    fn parse_case() {
 | 
				
			||||||
 | 
					        let interval = parse_duration("200 Seconds").unwrap();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        assert_eq!(interval.sec, 200);
 | 
				
			||||||
 | 
					        assert_eq!(interval.day, 0);
 | 
				
			||||||
 | 
					        assert_eq!(interval.month, 0);
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 
 | 
				
			|||||||
							
								
								
									
										48
									
								
								src/main.rs
									
									
									
									
									
								
							
							
						
						
									
										48
									
								
								src/main.rs
									
									
									
									
									
								
							@@ -18,10 +18,10 @@ use std::{
 | 
				
			|||||||
    env,
 | 
					    env,
 | 
				
			||||||
    error::Error as StdError,
 | 
					    error::Error as StdError,
 | 
				
			||||||
    fmt::{Debug, Display, Formatter},
 | 
					    fmt::{Debug, Display, Formatter},
 | 
				
			||||||
 | 
					    path::Path,
 | 
				
			||||||
};
 | 
					};
 | 
				
			||||||
 | 
					
 | 
				
			||||||
use chrono_tz::Tz;
 | 
					use chrono_tz::Tz;
 | 
				
			||||||
use dotenv::dotenv;
 | 
					 | 
				
			||||||
use log::{error, warn};
 | 
					use log::{error, warn};
 | 
				
			||||||
use poise::serenity_prelude::model::{
 | 
					use poise::serenity_prelude::model::{
 | 
				
			||||||
    gateway::GatewayIntents,
 | 
					    gateway::GatewayIntents,
 | 
				
			||||||
@@ -75,7 +75,7 @@ impl Display for Ended {
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
impl StdError for Ended {}
 | 
					impl StdError for Ended {}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
#[tokio::main]
 | 
					#[tokio::main(flavor = "multi_thread")]
 | 
				
			||||||
async fn main() -> Result<(), Box<dyn StdError + Send + Sync>> {
 | 
					async fn main() -> Result<(), Box<dyn StdError + Send + Sync>> {
 | 
				
			||||||
    let (tx, mut rx) = broadcast::channel(16);
 | 
					    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>> {
 | 
					async fn _main(tx: Sender<()>) -> Result<(), Box<dyn StdError + Send + Sync>> {
 | 
				
			||||||
    env_logger::init();
 | 
					    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");
 | 
					    let discord_token = env::var("DISCORD_TOKEN").expect("Missing DISCORD_TOKEN from environment");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -108,6 +112,16 @@ async fn _main(tx: Sender<()>) -> Result<(), Box<dyn StdError + Send + Sync>> {
 | 
				
			|||||||
                ],
 | 
					                ],
 | 
				
			||||||
                ..moderation_cmds::allowed_dm()
 | 
					                ..moderation_cmds::allowed_dm()
 | 
				
			||||||
            },
 | 
					            },
 | 
				
			||||||
 | 
					            poise::Command {
 | 
				
			||||||
 | 
					                subcommands: vec![poise::Command {
 | 
				
			||||||
 | 
					                    subcommands: vec![
 | 
				
			||||||
 | 
					                        moderation_cmds::set_ephemeral_confirmations(),
 | 
				
			||||||
 | 
					                        moderation_cmds::unset_ephemeral_confirmations(),
 | 
				
			||||||
 | 
					                    ],
 | 
				
			||||||
 | 
					                    ..moderation_cmds::ephemeral_confirmations()
 | 
				
			||||||
 | 
					                }],
 | 
				
			||||||
 | 
					                ..moderation_cmds::settings()
 | 
				
			||||||
 | 
					            },
 | 
				
			||||||
            moderation_cmds::webhook(),
 | 
					            moderation_cmds::webhook(),
 | 
				
			||||||
            poise::Command {
 | 
					            poise::Command {
 | 
				
			||||||
                subcommands: vec![
 | 
					                subcommands: vec![
 | 
				
			||||||
@@ -133,6 +147,7 @@ async fn _main(tx: Sender<()>) -> Result<(), Box<dyn StdError + Send + Sync>> {
 | 
				
			|||||||
                ],
 | 
					                ],
 | 
				
			||||||
                ..reminder_cmds::timer_base()
 | 
					                ..reminder_cmds::timer_base()
 | 
				
			||||||
            },
 | 
					            },
 | 
				
			||||||
 | 
					            reminder_cmds::multiline(),
 | 
				
			||||||
            reminder_cmds::remind(),
 | 
					            reminder_cmds::remind(),
 | 
				
			||||||
            poise::Command {
 | 
					            poise::Command {
 | 
				
			||||||
                subcommands: vec![
 | 
					                subcommands: vec![
 | 
				
			||||||
@@ -160,15 +175,36 @@ async fn _main(tx: Sender<()>) -> Result<(), Box<dyn StdError + Send + Sync>> {
 | 
				
			|||||||
        ],
 | 
					        ],
 | 
				
			||||||
        allowed_mentions: None,
 | 
					        allowed_mentions: None,
 | 
				
			||||||
        command_check: Some(|ctx| Box::pin(all_checks(ctx))),
 | 
					        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()
 | 
					        ..Default::default()
 | 
				
			||||||
    };
 | 
					    };
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    let database =
 | 
					    let database =
 | 
				
			||||||
        Pool::connect(&env::var("DATABASE_URL").expect("No database URL provided")).await.unwrap();
 | 
					        Pool::connect(&env::var("DATABASE_URL").expect("No database URL provided")).await.unwrap();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    sqlx::migrate!().run(&database).await?;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    let popular_timezones = sqlx::query!(
 | 
					    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)
 | 
					    .fetch_all(&database)
 | 
				
			||||||
    .await
 | 
					    .await
 | 
				
			||||||
@@ -179,7 +215,7 @@ async fn _main(tx: Sender<()>) -> Result<(), Box<dyn StdError + Send + Sync>> {
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
    poise::Framework::builder()
 | 
					    poise::Framework::builder()
 | 
				
			||||||
        .token(discord_token)
 | 
					        .token(discord_token)
 | 
				
			||||||
        .user_data_setup(move |ctx, _bot, framework| {
 | 
					        .setup(move |ctx, _bot, framework| {
 | 
				
			||||||
            Box::pin(async move {
 | 
					            Box::pin(async move {
 | 
				
			||||||
                register_application_commands(ctx, framework, None).await.unwrap();
 | 
					                register_application_commands(ctx, framework, None).await.unwrap();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -22,9 +22,7 @@ impl ChannelData {
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
        if let Ok(c) = sqlx::query_as_unchecked!(
 | 
					        if let Ok(c) = sqlx::query_as_unchecked!(
 | 
				
			||||||
            Self,
 | 
					            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
 | 
					            channel_id
 | 
				
			||||||
        )
 | 
					        )
 | 
				
			||||||
        .fetch_one(pool)
 | 
					        .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) };
 | 
					            let (guild_id, channel_name) = if let Some((a, b)) = props { (Some(a), Some(b)) } else { (None, None) };
 | 
				
			||||||
 | 
					
 | 
				
			||||||
            sqlx::query!(
 | 
					            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_id,
 | 
				
			||||||
                channel_name,
 | 
					                channel_name,
 | 
				
			||||||
                guild_id
 | 
					                guild_id
 | 
				
			||||||
 
 | 
				
			|||||||
							
								
								
									
										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 channel_data;
 | 
				
			||||||
pub mod command_macro;
 | 
					pub mod command_macro;
 | 
				
			||||||
 | 
					pub mod guild_data;
 | 
				
			||||||
pub mod reminder;
 | 
					pub mod reminder;
 | 
				
			||||||
pub mod timer;
 | 
					pub mod timer;
 | 
				
			||||||
pub mod user_data;
 | 
					pub mod user_data;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
use chrono_tz::Tz;
 | 
					use chrono_tz::Tz;
 | 
				
			||||||
use poise::serenity_prelude::{async_trait, model::id::UserId};
 | 
					use poise::serenity_prelude::{async_trait, model::id::UserId, ChannelType};
 | 
				
			||||||
 | 
					
 | 
				
			||||||
use crate::{
 | 
					use crate::{
 | 
				
			||||||
    models::{channel_data::ChannelData, user_data::UserData},
 | 
					    models::{channel_data::ChannelData, guild_data::GuildData, user_data::UserData},
 | 
				
			||||||
    CommandMacro, Context, Data, Error, GuildId,
 | 
					    CommandMacro, Context, Data, Error, GuildId,
 | 
				
			||||||
};
 | 
					};
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -18,6 +19,8 @@ pub trait CtxData {
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
    async fn author_data(&self) -> Result<UserData, Error>;
 | 
					    async fn author_data(&self) -> Result<UserData, Error>;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    async fn guild_data(&self) -> Option<Result<GuildData, Error>>;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    async fn timezone(&self) -> Tz;
 | 
					    async fn timezone(&self) -> Tz;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    async fn channel_data(&self) -> Result<ChannelData, Error>;
 | 
					    async fn channel_data(&self) -> Result<ChannelData, Error>;
 | 
				
			||||||
@@ -27,15 +30,21 @@ pub trait CtxData {
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
#[async_trait]
 | 
					#[async_trait]
 | 
				
			||||||
impl CtxData for Context<'_> {
 | 
					impl CtxData for Context<'_> {
 | 
				
			||||||
    async fn user_data<U: Into<UserId> + Send>(
 | 
					    async fn user_data<U: Into<UserId> + Send>(&self, user_id: U) -> Result<UserData, Error> {
 | 
				
			||||||
        &self,
 | 
					        UserData::from_user(user_id, &self.serenity_context(), &self.data().database).await
 | 
				
			||||||
        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 author_data(&self) -> Result<UserData, Box<dyn std::error::Error + Sync + Send>> {
 | 
					    async fn author_data(&self) -> Result<UserData, Error> {
 | 
				
			||||||
        UserData::from_user(&self.author().id, &self.discord(), &self.data().database).await
 | 
					        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 {
 | 
					    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>> {
 | 
					    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
 | 
					        ChannelData::from_channel(&channel, &self.data().database).await
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -9,7 +9,7 @@ use poise::serenity_prelude::{
 | 
				
			|||||||
        id::{ChannelId, GuildId, UserId},
 | 
					        id::{ChannelId, GuildId, UserId},
 | 
				
			||||||
        webhook::Webhook,
 | 
					        webhook::Webhook,
 | 
				
			||||||
    },
 | 
					    },
 | 
				
			||||||
    Result as SerenityResult,
 | 
					    ChannelType, Result as SerenityResult,
 | 
				
			||||||
};
 | 
					};
 | 
				
			||||||
use sqlx::MySqlPool;
 | 
					use sqlx::MySqlPool;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -51,9 +51,11 @@ pub struct ReminderBuilder {
 | 
				
			|||||||
    pool: MySqlPool,
 | 
					    pool: MySqlPool,
 | 
				
			||||||
    uid: String,
 | 
					    uid: String,
 | 
				
			||||||
    channel: u32,
 | 
					    channel: u32,
 | 
				
			||||||
 | 
					    thread_id: Option<u64>,
 | 
				
			||||||
    utc_time: NaiveDateTime,
 | 
					    utc_time: NaiveDateTime,
 | 
				
			||||||
    timezone: String,
 | 
					    timezone: String,
 | 
				
			||||||
    interval_secs: Option<i64>,
 | 
					    interval_seconds: Option<i64>,
 | 
				
			||||||
 | 
					    interval_days: Option<i64>,
 | 
				
			||||||
    interval_months: Option<i64>,
 | 
					    interval_months: Option<i64>,
 | 
				
			||||||
    expires: Option<NaiveDateTime>,
 | 
					    expires: Option<NaiveDateTime>,
 | 
				
			||||||
    content: String,
 | 
					    content: String,
 | 
				
			||||||
@@ -87,6 +89,7 @@ INSERT INTO reminders (
 | 
				
			|||||||
    `utc_time`,
 | 
					    `utc_time`,
 | 
				
			||||||
    `timezone`,
 | 
					    `timezone`,
 | 
				
			||||||
    `interval_seconds`,
 | 
					    `interval_seconds`,
 | 
				
			||||||
 | 
					    `interval_days`,
 | 
				
			||||||
    `interval_months`,
 | 
					    `interval_months`,
 | 
				
			||||||
    `expires`,
 | 
					    `expires`,
 | 
				
			||||||
    `content`,
 | 
					    `content`,
 | 
				
			||||||
@@ -106,6 +109,7 @@ INSERT INTO reminders (
 | 
				
			|||||||
    ?,
 | 
					    ?,
 | 
				
			||||||
    ?,
 | 
					    ?,
 | 
				
			||||||
    ?,
 | 
					    ?,
 | 
				
			||||||
 | 
					    ?,
 | 
				
			||||||
    ?
 | 
					    ?
 | 
				
			||||||
)
 | 
					)
 | 
				
			||||||
            ",
 | 
					            ",
 | 
				
			||||||
@@ -113,7 +117,8 @@ INSERT INTO reminders (
 | 
				
			|||||||
                        self.channel,
 | 
					                        self.channel,
 | 
				
			||||||
                        utc_time,
 | 
					                        utc_time,
 | 
				
			||||||
                        self.timezone,
 | 
					                        self.timezone,
 | 
				
			||||||
                        self.interval_secs,
 | 
					                        self.interval_seconds,
 | 
				
			||||||
 | 
					                        self.interval_days,
 | 
				
			||||||
                        self.interval_months,
 | 
					                        self.interval_months,
 | 
				
			||||||
                        self.expires,
 | 
					                        self.expires,
 | 
				
			||||||
                        self.content,
 | 
					                        self.content,
 | 
				
			||||||
@@ -175,17 +180,15 @@ impl<'a> MultiReminderBuilder<'a> {
 | 
				
			|||||||
    }
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    pub fn time<T: Into<i64>>(mut self, time: T) -> Self {
 | 
					    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
 | 
					        self
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    pub fn expires<T: Into<i64>>(mut self, time: Option<T>) -> Self {
 | 
					    pub fn expires<T: Into<i64>>(mut self, time: Option<T>) -> Self {
 | 
				
			||||||
        if let Some(t) = time {
 | 
					        self.expires = time.map(|t| NaiveDateTime::from_timestamp_opt(t.into(), 0)).flatten();
 | 
				
			||||||
            self.expires = Some(NaiveDateTime::from_timestamp(t.into(), 0));
 | 
					 | 
				
			||||||
        } else {
 | 
					 | 
				
			||||||
            self.expires = None;
 | 
					 | 
				
			||||||
        }
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
        self
 | 
					        self
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
@@ -212,26 +215,32 @@ impl<'a> MultiReminderBuilder<'a> {
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
        let mut ok_locs = HashSet::new();
 | 
					        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);
 | 
					            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);
 | 
					            errors.insert(ReminderError::LongInterval);
 | 
				
			||||||
        } else {
 | 
					        } else {
 | 
				
			||||||
            for scope in self.scopes {
 | 
					            for scope in self.scopes {
 | 
				
			||||||
 | 
					                let thread_id = None;
 | 
				
			||||||
                let db_channel_id = match scope {
 | 
					                let db_channel_id = match scope {
 | 
				
			||||||
                    ReminderScope::User(user_id) => {
 | 
					                    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(
 | 
					                            let user_data = UserData::from_user(
 | 
				
			||||||
                                &user,
 | 
					                                &user,
 | 
				
			||||||
                                &self.ctx.discord(),
 | 
					                                &self.ctx.serenity_context(),
 | 
				
			||||||
                                &self.ctx.data().database,
 | 
					                                &self.ctx.data().database,
 | 
				
			||||||
                            )
 | 
					                            )
 | 
				
			||||||
                            .await
 | 
					                            .await
 | 
				
			||||||
                            .unwrap();
 | 
					                            .unwrap();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
                            if let Some(guild_id) = self.guild_id {
 | 
					                            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)
 | 
					                                    Err(ReminderError::InvalidTag)
 | 
				
			||||||
                                } else if self.set_by.map_or(true, |i| i != user_data.id)
 | 
					                                } else if self.set_by.map_or(true, |i| i != user_data.id)
 | 
				
			||||||
                                    && !user_data.allowed_dm
 | 
					                                    && !user_data.allowed_dm
 | 
				
			||||||
@@ -248,27 +257,36 @@ impl<'a> MultiReminderBuilder<'a> {
 | 
				
			|||||||
                        }
 | 
					                        }
 | 
				
			||||||
                    }
 | 
					                    }
 | 
				
			||||||
                    ReminderScope::Channel(channel_id) => {
 | 
					                    ReminderScope::Channel(channel_id) => {
 | 
				
			||||||
                        let channel =
 | 
					                        let channel = ChannelId(channel_id).to_channel(&self.ctx).await.unwrap();
 | 
				
			||||||
                            ChannelId(channel_id).to_channel(&self.ctx.discord()).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 {
 | 
					                            if Some(guild_channel.guild_id) != self.guild_id {
 | 
				
			||||||
                                Err(ReminderError::InvalidTag)
 | 
					                                Err(ReminderError::InvalidTag)
 | 
				
			||||||
                            } else {
 | 
					                            } else {
 | 
				
			||||||
                                let mut channel_data =
 | 
					                                let mut channel_data = if guild_channel.kind
 | 
				
			||||||
                                    ChannelData::from_channel(&channel, &self.ctx.data().database)
 | 
					                                    == ChannelType::PublicThread
 | 
				
			||||||
 | 
					                                {
 | 
				
			||||||
 | 
					                                    // fixme jesus christ
 | 
				
			||||||
 | 
					                                    let parent = guild_channel
 | 
				
			||||||
 | 
					                                        .parent_id
 | 
				
			||||||
 | 
					                                        .unwrap()
 | 
				
			||||||
 | 
					                                        .to_channel(&self.ctx)
 | 
				
			||||||
                                        .await
 | 
					                                        .await
 | 
				
			||||||
                                        .unwrap();
 | 
					                                        .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()
 | 
					                                if channel_data.webhook_id.is_none()
 | 
				
			||||||
                                    || channel_data.webhook_token.is_none()
 | 
					                                    || channel_data.webhook_token.is_none()
 | 
				
			||||||
                                {
 | 
					                                {
 | 
				
			||||||
                                    match create_webhook(
 | 
					                                    match create_webhook(&self.ctx, guild_channel, "Reminder").await
 | 
				
			||||||
                                        &self.ctx.discord(),
 | 
					 | 
				
			||||||
                                        guild_channel,
 | 
					 | 
				
			||||||
                                        "Reminder",
 | 
					 | 
				
			||||||
                                    )
 | 
					 | 
				
			||||||
                                    .await
 | 
					 | 
				
			||||||
                                    {
 | 
					                                    {
 | 
				
			||||||
                                        Ok(webhook) => {
 | 
					                                        Ok(webhook) => {
 | 
				
			||||||
                                            channel_data.webhook_id =
 | 
					                                            channel_data.webhook_id =
 | 
				
			||||||
@@ -300,9 +318,11 @@ impl<'a> MultiReminderBuilder<'a> {
 | 
				
			|||||||
                            pool: self.ctx.data().database.clone(),
 | 
					                            pool: self.ctx.data().database.clone(),
 | 
				
			||||||
                            uid: generate_uid(),
 | 
					                            uid: generate_uid(),
 | 
				
			||||||
                            channel: c,
 | 
					                            channel: c,
 | 
				
			||||||
 | 
					                            thread_id,
 | 
				
			||||||
                            utc_time: self.utc_time,
 | 
					                            utc_time: self.utc_time,
 | 
				
			||||||
                            timezone: self.timezone.to_string(),
 | 
					                            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),
 | 
					                            interval_months: self.interval.map(|i| i.month as i64),
 | 
				
			||||||
                            expires: self.expires,
 | 
					                            expires: self.expires,
 | 
				
			||||||
                            content: self.content.content.clone(),
 | 
					                            content: self.content.content.clone(),
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -6,7 +6,7 @@ pub mod look_flags;
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
use std::hash::{Hash, Hasher};
 | 
					use std::hash::{Hash, Hasher};
 | 
				
			||||||
 | 
					
 | 
				
			||||||
use chrono::{NaiveDateTime, TimeZone};
 | 
					use chrono::{DateTime, NaiveDateTime, Utc};
 | 
				
			||||||
use chrono_tz::Tz;
 | 
					use chrono_tz::Tz;
 | 
				
			||||||
use poise::serenity_prelude::{
 | 
					use poise::serenity_prelude::{
 | 
				
			||||||
    model::id::{ChannelId, GuildId, UserId},
 | 
					    model::id::{ChannelId, GuildId, UserId},
 | 
				
			||||||
@@ -24,8 +24,9 @@ pub struct Reminder {
 | 
				
			|||||||
    pub id: u32,
 | 
					    pub id: u32,
 | 
				
			||||||
    pub uid: String,
 | 
					    pub uid: String,
 | 
				
			||||||
    pub channel: u64,
 | 
					    pub channel: u64,
 | 
				
			||||||
    pub utc_time: NaiveDateTime,
 | 
					    pub utc_time: DateTime<Utc>,
 | 
				
			||||||
    pub interval_seconds: Option<u32>,
 | 
					    pub interval_seconds: Option<u32>,
 | 
				
			||||||
 | 
					    pub interval_days: Option<u32>,
 | 
				
			||||||
    pub interval_months: Option<u32>,
 | 
					    pub interval_months: Option<u32>,
 | 
				
			||||||
    pub expires: Option<NaiveDateTime>,
 | 
					    pub expires: Option<NaiveDateTime>,
 | 
				
			||||||
    pub enabled: bool,
 | 
					    pub enabled: bool,
 | 
				
			||||||
@@ -59,6 +60,7 @@ SELECT
 | 
				
			|||||||
    channels.channel,
 | 
					    channels.channel,
 | 
				
			||||||
    reminders.utc_time,
 | 
					    reminders.utc_time,
 | 
				
			||||||
    reminders.interval_seconds,
 | 
					    reminders.interval_seconds,
 | 
				
			||||||
 | 
					    reminders.interval_days,
 | 
				
			||||||
    reminders.interval_months,
 | 
					    reminders.interval_months,
 | 
				
			||||||
    reminders.expires,
 | 
					    reminders.expires,
 | 
				
			||||||
    reminders.enabled,
 | 
					    reminders.enabled,
 | 
				
			||||||
@@ -95,6 +97,7 @@ SELECT
 | 
				
			|||||||
    channels.channel,
 | 
					    channels.channel,
 | 
				
			||||||
    reminders.utc_time,
 | 
					    reminders.utc_time,
 | 
				
			||||||
    reminders.interval_seconds,
 | 
					    reminders.interval_seconds,
 | 
				
			||||||
 | 
					    reminders.interval_days,
 | 
				
			||||||
    reminders.interval_months,
 | 
					    reminders.interval_months,
 | 
				
			||||||
    reminders.expires,
 | 
					    reminders.expires,
 | 
				
			||||||
    reminders.enabled,
 | 
					    reminders.enabled,
 | 
				
			||||||
@@ -138,6 +141,7 @@ SELECT
 | 
				
			|||||||
    channels.channel,
 | 
					    channels.channel,
 | 
				
			||||||
    reminders.utc_time,
 | 
					    reminders.utc_time,
 | 
				
			||||||
    reminders.interval_seconds,
 | 
					    reminders.interval_seconds,
 | 
				
			||||||
 | 
					    reminders.interval_days,
 | 
				
			||||||
    reminders.interval_months,
 | 
					    reminders.interval_months,
 | 
				
			||||||
    reminders.expires,
 | 
					    reminders.expires,
 | 
				
			||||||
    reminders.enabled,
 | 
					    reminders.enabled,
 | 
				
			||||||
@@ -155,6 +159,7 @@ LEFT JOIN
 | 
				
			|||||||
ON
 | 
					ON
 | 
				
			||||||
    reminders.set_by = users.id
 | 
					    reminders.set_by = users.id
 | 
				
			||||||
WHERE
 | 
					WHERE
 | 
				
			||||||
 | 
					    `status` = 'pending' AND
 | 
				
			||||||
    channels.channel = ? AND
 | 
					    channels.channel = ? AND
 | 
				
			||||||
    FIND_IN_SET(reminders.enabled, ?)
 | 
					    FIND_IN_SET(reminders.enabled, ?)
 | 
				
			||||||
ORDER BY
 | 
					ORDER BY
 | 
				
			||||||
@@ -195,6 +200,7 @@ SELECT
 | 
				
			|||||||
    channels.channel,
 | 
					    channels.channel,
 | 
				
			||||||
    reminders.utc_time,
 | 
					    reminders.utc_time,
 | 
				
			||||||
    reminders.interval_seconds,
 | 
					    reminders.interval_seconds,
 | 
				
			||||||
 | 
					    reminders.interval_days,
 | 
				
			||||||
    reminders.interval_months,
 | 
					    reminders.interval_months,
 | 
				
			||||||
    reminders.expires,
 | 
					    reminders.expires,
 | 
				
			||||||
    reminders.enabled,
 | 
					    reminders.enabled,
 | 
				
			||||||
@@ -212,6 +218,7 @@ LEFT JOIN
 | 
				
			|||||||
ON
 | 
					ON
 | 
				
			||||||
    reminders.set_by = users.id
 | 
					    reminders.set_by = users.id
 | 
				
			||||||
WHERE
 | 
					WHERE
 | 
				
			||||||
 | 
					    `status` = 'pending' AND
 | 
				
			||||||
    FIND_IN_SET(channels.channel, ?)
 | 
					    FIND_IN_SET(channels.channel, ?)
 | 
				
			||||||
                ",
 | 
					                ",
 | 
				
			||||||
                    channels
 | 
					                    channels
 | 
				
			||||||
@@ -228,6 +235,7 @@ SELECT
 | 
				
			|||||||
    channels.channel,
 | 
					    channels.channel,
 | 
				
			||||||
    reminders.utc_time,
 | 
					    reminders.utc_time,
 | 
				
			||||||
    reminders.interval_seconds,
 | 
					    reminders.interval_seconds,
 | 
				
			||||||
 | 
					    reminders.interval_days,
 | 
				
			||||||
    reminders.interval_months,
 | 
					    reminders.interval_months,
 | 
				
			||||||
    reminders.expires,
 | 
					    reminders.expires,
 | 
				
			||||||
    reminders.enabled,
 | 
					    reminders.enabled,
 | 
				
			||||||
@@ -245,6 +253,7 @@ LEFT JOIN
 | 
				
			|||||||
ON
 | 
					ON
 | 
				
			||||||
    reminders.set_by = users.id
 | 
					    reminders.set_by = users.id
 | 
				
			||||||
WHERE
 | 
					WHERE
 | 
				
			||||||
 | 
					    `status` = 'pending' AND
 | 
				
			||||||
    channels.guild_id = (SELECT id FROM guilds WHERE guild = ?)
 | 
					    channels.guild_id = (SELECT id FROM guilds WHERE guild = ?)
 | 
				
			||||||
                ",
 | 
					                ",
 | 
				
			||||||
                    guild_id.as_u64()
 | 
					                    guild_id.as_u64()
 | 
				
			||||||
@@ -262,6 +271,7 @@ SELECT
 | 
				
			|||||||
    channels.channel,
 | 
					    channels.channel,
 | 
				
			||||||
    reminders.utc_time,
 | 
					    reminders.utc_time,
 | 
				
			||||||
    reminders.interval_seconds,
 | 
					    reminders.interval_seconds,
 | 
				
			||||||
 | 
					    reminders.interval_days,
 | 
				
			||||||
    reminders.interval_months,
 | 
					    reminders.interval_months,
 | 
				
			||||||
    reminders.expires,
 | 
					    reminders.expires,
 | 
				
			||||||
    reminders.enabled,
 | 
					    reminders.enabled,
 | 
				
			||||||
@@ -279,6 +289,7 @@ LEFT JOIN
 | 
				
			|||||||
ON
 | 
					ON
 | 
				
			||||||
    reminders.set_by = users.id
 | 
					    reminders.set_by = users.id
 | 
				
			||||||
WHERE
 | 
					WHERE
 | 
				
			||||||
 | 
					    `status` = 'pending' AND
 | 
				
			||||||
    channels.id = (SELECT dm_channel FROM users WHERE user = ?)
 | 
					    channels.id = (SELECT dm_channel FROM users WHERE user = ?)
 | 
				
			||||||
            ",
 | 
					            ",
 | 
				
			||||||
                user.as_u64()
 | 
					                user.as_u64()
 | 
				
			||||||
@@ -293,7 +304,10 @@ WHERE
 | 
				
			|||||||
        &self,
 | 
					        &self,
 | 
				
			||||||
        db: impl Executor<'_, Database = Database>,
 | 
					        db: impl Executor<'_, Database = Database>,
 | 
				
			||||||
    ) -> Result<(), sqlx::Error> {
 | 
					    ) -> 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 {
 | 
					    pub fn display_content(&self) -> &str {
 | 
				
			||||||
@@ -310,30 +324,32 @@ WHERE
 | 
				
			|||||||
            count + 1,
 | 
					            count + 1,
 | 
				
			||||||
            self.display_content(),
 | 
					            self.display_content(),
 | 
				
			||||||
            self.channel,
 | 
					            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 {
 | 
					    pub fn display(&self, flags: &LookFlags, timezone: &Tz) -> String {
 | 
				
			||||||
        let time_display = match flags.time_display {
 | 
					        let time_display = match flags.time_display {
 | 
				
			||||||
            TimeDisplayType::Absolute => timezone
 | 
					            TimeDisplayType::Absolute => {
 | 
				
			||||||
                .timestamp(self.utc_time.timestamp(), 0)
 | 
					                self.utc_time.with_timezone(timezone).format("%Y-%m-%d %H:%M:%S").to_string()
 | 
				
			||||||
                .format("%Y-%m-%d %H:%M:%S")
 | 
					            }
 | 
				
			||||||
                .to_string(),
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
            TimeDisplayType::Relative => format!("<t:{}:R>", self.utc_time.timestamp()),
 | 
					            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!(
 | 
					            format!(
 | 
				
			||||||
                "'{}' *occurs next at* **{}**, repeating (set by {})",
 | 
					                "'{}' *occurs next at* **{}**, repeating (set by {})\n",
 | 
				
			||||||
                self.display_content(),
 | 
					                self.display_content(),
 | 
				
			||||||
                time_display,
 | 
					                time_display,
 | 
				
			||||||
                self.set_by.map(|i| format!("<@{}>", i)).unwrap_or_else(|| "unknown".to_string())
 | 
					                self.set_by.map(|i| format!("<@{}>", i)).unwrap_or_else(|| "unknown".to_string())
 | 
				
			||||||
            )
 | 
					            )
 | 
				
			||||||
        } else {
 | 
					        } else {
 | 
				
			||||||
            format!(
 | 
					            format!(
 | 
				
			||||||
                "'{}' *occurs next at* **{}** (set by {})",
 | 
					                "'{}' *occurs next at* **{}** (set by {})\n",
 | 
				
			||||||
                self.display_content(),
 | 
					                self.display_content(),
 | 
				
			||||||
                time_display,
 | 
					                time_display,
 | 
				
			||||||
                self.set_by.map(|i| format!("<@{}>", i)).unwrap_or_else(|| "unknown".to_string())
 | 
					                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;
 | 
					use sqlx::MySqlPool;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
pub struct Timer {
 | 
					pub struct Timer {
 | 
				
			||||||
    pub name: String,
 | 
					    pub name: String,
 | 
				
			||||||
    pub start_time: NaiveDateTime,
 | 
					    pub start_time: DateTime<Utc>,
 | 
				
			||||||
    pub owner: u64,
 | 
					    pub owner: u64,
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -22,7 +22,7 @@ impl UserData {
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
        match sqlx::query!(
 | 
					        match sqlx::query!(
 | 
				
			||||||
            "
 | 
					            "
 | 
				
			||||||
SELECT timezone FROM users WHERE user = ?
 | 
					SELECT IFNULL(timezone, 'UTC') AS timezone FROM users WHERE user = ?
 | 
				
			||||||
            ",
 | 
					            ",
 | 
				
			||||||
            user_id
 | 
					            user_id
 | 
				
			||||||
        )
 | 
					        )
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -83,7 +83,7 @@ pub fn send_as_initial_response(
 | 
				
			|||||||
        components,
 | 
					        components,
 | 
				
			||||||
        ephemeral,
 | 
					        ephemeral,
 | 
				
			||||||
        allowed_mentions,
 | 
					        allowed_mentions,
 | 
				
			||||||
        reference_message: _, // can't reply to a message in interactions
 | 
					        reply: _,
 | 
				
			||||||
    } = data;
 | 
					    } = data;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    if let Some(content) = content {
 | 
					    if let Some(content) = content {
 | 
				
			||||||
 
 | 
				
			|||||||
							
								
								
									
										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
 | 
				
			||||||
@@ -1,21 +1,22 @@
 | 
				
			|||||||
[package]
 | 
					[package]
 | 
				
			||||||
name = "reminder_web"
 | 
					name = "reminder_web"
 | 
				
			||||||
version = "0.1.0"
 | 
					version = "0.1.4"
 | 
				
			||||||
authors = ["jellywx <judesouthworth@pm.me>"]
 | 
					authors = ["jellywx <judesouthworth@pm.me>"]
 | 
				
			||||||
edition = "2018"
 | 
					edition = "2018"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
[dependencies]
 | 
					[dependencies]
 | 
				
			||||||
rocket = { git = "https://github.com/SergioBenitez/Rocket", branch = "master", features = ["tls", "secrets", "json"] }
 | 
					rocket = { git = "https://github.com/SergioBenitez/Rocket", branch = "master", features = ["tls", "secrets", "json"] }
 | 
				
			||||||
rocket_dyn_templates = { git = "https://github.com/SergioBenitez/Rocket", branch = "master", features = ["tera"] }
 | 
					rocket_dyn_templates = { git = "https://github.com/SergioBenitez/Rocket", branch = "master", features = ["tera"] }
 | 
				
			||||||
serenity = { version = "0.11.1", default-features = false, features = ["builder", "cache", "client", "gateway", "http", "model", "utils", "rustls_backend"] }
 | 
					serenity = { version = "0.11", default-features = false, features = ["builder", "cache", "client", "gateway", "http", "model", "utils", "rustls_backend"] }
 | 
				
			||||||
oauth2 = "4"
 | 
					oauth2 = "4"
 | 
				
			||||||
log = "0.4"
 | 
					log = "0.4"
 | 
				
			||||||
reqwest = "0.11"
 | 
					reqwest = "0.11"
 | 
				
			||||||
serde = { version = "1.0", features = ["derive"] }
 | 
					serde = { version = "1.0", features = ["derive"] }
 | 
				
			||||||
sqlx = { version = "0.6", features = ["runtime-tokio-rustls", "macros", "mysql", "chrono", "json"] }
 | 
					sqlx = { version = "0.7", features = ["runtime-tokio-rustls", "macros", "mysql", "chrono", "json"] }
 | 
				
			||||||
chrono = "0.4"
 | 
					chrono = "0.4"
 | 
				
			||||||
chrono-tz = "0.5"
 | 
					chrono-tz = "0.8"
 | 
				
			||||||
lazy_static = "1.4.0"
 | 
					lazy_static = "1.4.0"
 | 
				
			||||||
rand = "0.7"
 | 
					rand = "0.8"
 | 
				
			||||||
base64 = "0.13"
 | 
					base64 = "0.13"
 | 
				
			||||||
csv = "1.1"
 | 
					csv = "1.2"
 | 
				
			||||||
 | 
					prometheus = "0.13.3"
 | 
				
			||||||
 
 | 
				
			|||||||
							
								
								
									
										40
									
								
								web/src/catchers.rs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										40
									
								
								web/src/catchers.rs
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,40 @@
 | 
				
			|||||||
 | 
					use std::collections::HashMap;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					use rocket::serde::json::json;
 | 
				
			||||||
 | 
					use rocket_dyn_templates::Template;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					use crate::JsonValue;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					#[catch(403)]
 | 
				
			||||||
 | 
					pub(crate) async fn forbidden() -> Template {
 | 
				
			||||||
 | 
					    let map: HashMap<String, String> = HashMap::new();
 | 
				
			||||||
 | 
					    Template::render("errors/403", &map)
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					#[catch(500)]
 | 
				
			||||||
 | 
					pub(crate) async fn internal_server_error() -> Template {
 | 
				
			||||||
 | 
					    let map: HashMap<String, String> = HashMap::new();
 | 
				
			||||||
 | 
					    Template::render("errors/500", &map)
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					#[catch(401)]
 | 
				
			||||||
 | 
					pub(crate) async fn not_authorized() -> Template {
 | 
				
			||||||
 | 
					    let map: HashMap<String, String> = HashMap::new();
 | 
				
			||||||
 | 
					    Template::render("errors/401", &map)
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					#[catch(404)]
 | 
				
			||||||
 | 
					pub(crate) async fn not_found() -> Template {
 | 
				
			||||||
 | 
					    let map: HashMap<String, String> = HashMap::new();
 | 
				
			||||||
 | 
					    Template::render("errors/404", &map)
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					#[catch(413)]
 | 
				
			||||||
 | 
					pub(crate) async fn payload_too_large() -> JsonValue {
 | 
				
			||||||
 | 
					    json!({"error": "Data too large.", "errors": ["Data too large."]})
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					#[catch(422)]
 | 
				
			||||||
 | 
					pub(crate) async fn unprocessable_entity() -> JsonValue {
 | 
				
			||||||
 | 
					    json!({"error": "Invalid request.", "errors": ["Invalid request."]})
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
@@ -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_OAUTH_AUTHORIZE: &'static str = "https://discord.com/api/oauth2/authorize";
 | 
				
			||||||
pub const DISCORD_API: &'static str = "https://discord.com/api";
 | 
					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_CONTENT_LENGTH: usize = 2000;
 | 
				
			||||||
pub const MAX_EMBED_DESCRIPTION_LENGTH: usize = 4096;
 | 
					pub const MAX_EMBED_DESCRIPTION_LENGTH: usize = 4096;
 | 
				
			||||||
pub const MAX_EMBED_TITLE_LENGTH: usize = 256;
 | 
					pub const MAX_EMBED_TITLE_LENGTH: usize = 256;
 | 
				
			||||||
@@ -31,7 +32,7 @@ lazy_static! {
 | 
				
			|||||||
    )
 | 
					    )
 | 
				
			||||||
        .into();
 | 
					        .into();
 | 
				
			||||||
    pub static ref SUBSCRIPTION_ROLES: HashSet<u64> = HashSet::from_iter(
 | 
					    pub static ref SUBSCRIPTION_ROLES: HashSet<u64> = HashSet::from_iter(
 | 
				
			||||||
        env::var("SUBSCRIPTION_ROLES")
 | 
					        env::var("PATREON_ROLE_ID")
 | 
				
			||||||
            .map(|var| var
 | 
					            .map(|var| var
 | 
				
			||||||
                .split(',')
 | 
					                .split(',')
 | 
				
			||||||
                .filter_map(|item| { item.parse::<u64>().ok() })
 | 
					                .filter_map(|item| { item.parse::<u64>().ok() })
 | 
				
			||||||
@@ -39,7 +40,7 @@ lazy_static! {
 | 
				
			|||||||
            .unwrap_or_else(|_| Vec::new())
 | 
					            .unwrap_or_else(|_| Vec::new())
 | 
				
			||||||
    );
 | 
					    );
 | 
				
			||||||
    pub static ref CNC_GUILD: Option<u64> =
 | 
					    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")
 | 
					    pub static ref MIN_INTERVAL: u32 = env::var("MIN_INTERVAL")
 | 
				
			||||||
        .ok()
 | 
					        .ok()
 | 
				
			||||||
        .map(|inner| inner.parse::<u32>().ok())
 | 
					        .map(|inner| inner.parse::<u32>().ok())
 | 
				
			||||||
 
 | 
				
			|||||||
							
								
								
									
										1
									
								
								web/src/guards/mod.rs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										1
									
								
								web/src/guards/mod.rs
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1 @@
 | 
				
			|||||||
 | 
					pub(crate) mod transaction;
 | 
				
			||||||
							
								
								
									
										42
									
								
								web/src/guards/transaction.rs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										42
									
								
								web/src/guards/transaction.rs
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,42 @@
 | 
				
			|||||||
 | 
					use rocket::{
 | 
				
			||||||
 | 
					    http::Status,
 | 
				
			||||||
 | 
					    request::{FromRequest, Outcome},
 | 
				
			||||||
 | 
					    Request, State,
 | 
				
			||||||
 | 
					};
 | 
				
			||||||
 | 
					use sqlx::Pool;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					use crate::Database;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					pub struct Transaction<'a>(sqlx::Transaction<'a, Database>);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					impl Transaction<'_> {
 | 
				
			||||||
 | 
					    pub fn executor(&mut self) -> impl sqlx::Executor<'_, Database = Database> {
 | 
				
			||||||
 | 
					        &mut *(self.0)
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    pub async fn commit(self) -> Result<(), sqlx::Error> {
 | 
				
			||||||
 | 
					        self.0.commit().await
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					#[derive(Debug)]
 | 
				
			||||||
 | 
					pub enum TransactionError {
 | 
				
			||||||
 | 
					    Error(sqlx::Error),
 | 
				
			||||||
 | 
					    Missing,
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					#[rocket::async_trait]
 | 
				
			||||||
 | 
					impl<'r> FromRequest<'r> for Transaction<'r> {
 | 
				
			||||||
 | 
					    type Error = TransactionError;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    async fn from_request(request: &'r Request<'_>) -> Outcome<Self, Self::Error> {
 | 
				
			||||||
 | 
					        match request.guard::<&State<Pool<Database>>>().await {
 | 
				
			||||||
 | 
					            Outcome::Success(pool) => match pool.begin().await {
 | 
				
			||||||
 | 
					                Ok(transaction) => Outcome::Success(Transaction(transaction)),
 | 
				
			||||||
 | 
					                Err(e) => Outcome::Error((Status::InternalServerError, TransactionError::Error(e))),
 | 
				
			||||||
 | 
					            },
 | 
				
			||||||
 | 
					            Outcome::Error(e) => Outcome::Error((e.0, TransactionError::Missing)),
 | 
				
			||||||
 | 
					            Outcome::Forward(f) => Outcome::Forward(f),
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
							
								
								
									
										179
									
								
								web/src/lib.rs
									
									
									
									
									
								
							
							
						
						
									
										179
									
								
								web/src/lib.rs
									
									
									
									
									
								
							@@ -4,13 +4,17 @@ extern crate rocket;
 | 
				
			|||||||
mod consts;
 | 
					mod consts;
 | 
				
			||||||
#[macro_use]
 | 
					#[macro_use]
 | 
				
			||||||
mod macros;
 | 
					mod macros;
 | 
				
			||||||
 | 
					mod catchers;
 | 
				
			||||||
 | 
					mod guards;
 | 
				
			||||||
 | 
					mod metrics;
 | 
				
			||||||
mod routes;
 | 
					mod routes;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
use std::{collections::HashMap, env};
 | 
					use std::{env, path::Path};
 | 
				
			||||||
 | 
					
 | 
				
			||||||
use oauth2::{basic::BasicClient, AuthUrl, ClientId, ClientSecret, RedirectUrl, TokenUrl};
 | 
					use oauth2::{basic::BasicClient, AuthUrl, ClientId, ClientSecret, RedirectUrl, TokenUrl};
 | 
				
			||||||
use rocket::{
 | 
					use rocket::{
 | 
				
			||||||
    fs::FileServer,
 | 
					    fs::FileServer,
 | 
				
			||||||
 | 
					    http::CookieJar,
 | 
				
			||||||
    serde::json::{json, Value as JsonValue},
 | 
					    serde::json::{json, Value as JsonValue},
 | 
				
			||||||
    tokio::sync::broadcast::Sender,
 | 
					    tokio::sync::broadcast::Sender,
 | 
				
			||||||
};
 | 
					};
 | 
				
			||||||
@@ -22,7 +26,10 @@ use serenity::{
 | 
				
			|||||||
};
 | 
					};
 | 
				
			||||||
use sqlx::{MySql, Pool};
 | 
					use sqlx::{MySql, Pool};
 | 
				
			||||||
 | 
					
 | 
				
			||||||
use crate::consts::{CNC_GUILD, DISCORD_OAUTH_AUTHORIZE, DISCORD_OAUTH_TOKEN, SUBSCRIPTION_ROLES};
 | 
					use crate::{
 | 
				
			||||||
 | 
					    consts::{CNC_GUILD, DISCORD_OAUTH_AUTHORIZE, DISCORD_OAUTH_TOKEN, SUBSCRIPTION_ROLES},
 | 
				
			||||||
 | 
					    metrics::{init_metrics, MetricProducer},
 | 
				
			||||||
 | 
					};
 | 
				
			||||||
 | 
					
 | 
				
			||||||
type Database = MySql;
 | 
					type Database = MySql;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -32,50 +39,20 @@ enum Error {
 | 
				
			|||||||
    Serenity(serenity::Error),
 | 
					    Serenity(serenity::Error),
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
#[catch(401)]
 | 
					 | 
				
			||||||
async fn not_authorized() -> Template {
 | 
					 | 
				
			||||||
    let map: HashMap<String, String> = HashMap::new();
 | 
					 | 
				
			||||||
    Template::render("errors/401", &map)
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
#[catch(403)]
 | 
					 | 
				
			||||||
async fn forbidden() -> Template {
 | 
					 | 
				
			||||||
    let map: HashMap<String, String> = HashMap::new();
 | 
					 | 
				
			||||||
    Template::render("errors/403", &map)
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
#[catch(404)]
 | 
					 | 
				
			||||||
async fn not_found() -> Template {
 | 
					 | 
				
			||||||
    let map: HashMap<String, String> = HashMap::new();
 | 
					 | 
				
			||||||
    Template::render("errors/404", &map)
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
#[catch(413)]
 | 
					 | 
				
			||||||
async fn payload_too_large() -> JsonValue {
 | 
					 | 
				
			||||||
    json!({"error": "Data too large.", "errors": ["Data too large."]})
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
#[catch(422)]
 | 
					 | 
				
			||||||
async fn unprocessable_entity() -> JsonValue {
 | 
					 | 
				
			||||||
    json!({"error": "Invalid request.", "errors": ["Invalid request."]})
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
#[catch(500)]
 | 
					 | 
				
			||||||
async fn internal_server_error() -> Template {
 | 
					 | 
				
			||||||
    let map: HashMap<String, String> = HashMap::new();
 | 
					 | 
				
			||||||
    Template::render("errors/500", &map)
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
pub async fn initialize(
 | 
					pub async fn initialize(
 | 
				
			||||||
    kill_channel: Sender<()>,
 | 
					    kill_channel: Sender<()>,
 | 
				
			||||||
    serenity_context: Context,
 | 
					    serenity_context: Context,
 | 
				
			||||||
    db_pool: Pool<Database>,
 | 
					    db_pool: Pool<Database>,
 | 
				
			||||||
) -> Result<(), Box<dyn std::error::Error>> {
 | 
					) -> Result<(), Box<dyn std::error::Error>> {
 | 
				
			||||||
    info!("Checking environment variables...");
 | 
					    info!("Checking environment variables...");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    if env::var("OFFLINE").map_or(true, |v| v != "1") {
 | 
				
			||||||
        env::var("OAUTH2_CLIENT_ID").expect("`OAUTH2_CLIENT_ID' not supplied");
 | 
					        env::var("OAUTH2_CLIENT_ID").expect("`OAUTH2_CLIENT_ID' not supplied");
 | 
				
			||||||
        env::var("OAUTH2_CLIENT_SECRET").expect("`OAUTH2_CLIENT_SECRET' 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("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!");
 | 
					    info!("Done!");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    let oauth2_client = BasicClient::new(
 | 
					    let oauth2_client = BasicClient::new(
 | 
				
			||||||
@@ -88,32 +65,40 @@ pub async fn initialize(
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
    let reqwest_client = reqwest::Client::new();
 | 
					    let reqwest_client = reqwest::Client::new();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    let static_path =
 | 
				
			||||||
 | 
					        if Path::new("web/static").exists() { "web/static" } else { "/lib/reminder-rs/static" };
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    init_metrics();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    rocket::build()
 | 
					    rocket::build()
 | 
				
			||||||
 | 
					        .attach(MetricProducer)
 | 
				
			||||||
        .attach(Template::fairing())
 | 
					        .attach(Template::fairing())
 | 
				
			||||||
        .register(
 | 
					        .register(
 | 
				
			||||||
            "/",
 | 
					            "/",
 | 
				
			||||||
            catchers![
 | 
					            catchers![
 | 
				
			||||||
                not_authorized,
 | 
					                catchers::not_authorized,
 | 
				
			||||||
                forbidden,
 | 
					                catchers::forbidden,
 | 
				
			||||||
                not_found,
 | 
					                catchers::not_found,
 | 
				
			||||||
                internal_server_error,
 | 
					                catchers::internal_server_error,
 | 
				
			||||||
                unprocessable_entity,
 | 
					                catchers::unprocessable_entity,
 | 
				
			||||||
                payload_too_large,
 | 
					                catchers::payload_too_large,
 | 
				
			||||||
            ],
 | 
					            ],
 | 
				
			||||||
        )
 | 
					        )
 | 
				
			||||||
        .manage(oauth2_client)
 | 
					        .manage(oauth2_client)
 | 
				
			||||||
        .manage(reqwest_client)
 | 
					        .manage(reqwest_client)
 | 
				
			||||||
        .manage(serenity_context)
 | 
					        .manage(serenity_context)
 | 
				
			||||||
        .manage(db_pool)
 | 
					        .manage(db_pool)
 | 
				
			||||||
        .mount("/static", FileServer::from(concat!(env!("CARGO_MANIFEST_DIR"), "/static")))
 | 
					        .mount("/static", FileServer::from(static_path))
 | 
				
			||||||
        .mount(
 | 
					        .mount(
 | 
				
			||||||
            "/",
 | 
					            "/",
 | 
				
			||||||
            routes![
 | 
					            routes![
 | 
				
			||||||
                routes::index,
 | 
					 | 
				
			||||||
                routes::cookies,
 | 
					                routes::cookies,
 | 
				
			||||||
 | 
					                routes::index,
 | 
				
			||||||
 | 
					                routes::metrics::metrics,
 | 
				
			||||||
                routes::privacy,
 | 
					                routes::privacy,
 | 
				
			||||||
 | 
					                routes::report::report_error,
 | 
				
			||||||
 | 
					                routes::return_to_same_site,
 | 
				
			||||||
                routes::terms,
 | 
					                routes::terms,
 | 
				
			||||||
                routes::return_to_same_site
 | 
					 | 
				
			||||||
            ],
 | 
					            ],
 | 
				
			||||||
        )
 | 
					        )
 | 
				
			||||||
        .mount(
 | 
					        .mount(
 | 
				
			||||||
@@ -131,25 +116,32 @@ pub async fn initialize(
 | 
				
			|||||||
                routes::help_iemanager,
 | 
					                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(
 | 
					        .mount(
 | 
				
			||||||
            "/dashboard",
 | 
					            "/dashboard",
 | 
				
			||||||
            routes![
 | 
					            routes![
 | 
				
			||||||
                routes::dashboard::dashboard,
 | 
					                routes::dashboard::dashboard,
 | 
				
			||||||
                routes::dashboard::dashboard_home,
 | 
					                routes::dashboard::dashboard_home,
 | 
				
			||||||
                routes::dashboard::user::get_user_info,
 | 
					                routes::dashboard::api::user::get_user_info,
 | 
				
			||||||
                routes::dashboard::user::update_user_info,
 | 
					                routes::dashboard::api::user::update_user_info,
 | 
				
			||||||
                routes::dashboard::user::get_user_guilds,
 | 
					                routes::dashboard::api::user::get_user_guilds,
 | 
				
			||||||
                routes::dashboard::guild::get_guild_patreon,
 | 
					                routes::dashboard::api::guild::get_guild_info,
 | 
				
			||||||
                routes::dashboard::guild::get_guild_channels,
 | 
					                routes::dashboard::api::guild::get_guild_channels,
 | 
				
			||||||
                routes::dashboard::guild::get_guild_roles,
 | 
					                routes::dashboard::api::guild::get_guild_roles,
 | 
				
			||||||
                routes::dashboard::guild::get_reminder_templates,
 | 
					                routes::dashboard::api::guild::get_reminder_templates,
 | 
				
			||||||
                routes::dashboard::guild::create_reminder_template,
 | 
					                routes::dashboard::api::guild::create_reminder_template,
 | 
				
			||||||
                routes::dashboard::guild::delete_reminder_template,
 | 
					                routes::dashboard::api::guild::delete_reminder_template,
 | 
				
			||||||
                routes::dashboard::guild::create_guild_reminder,
 | 
					                routes::dashboard::api::guild::create_guild_reminder,
 | 
				
			||||||
                routes::dashboard::guild::get_reminders,
 | 
					                routes::dashboard::api::guild::get_reminders,
 | 
				
			||||||
                routes::dashboard::guild::edit_reminder,
 | 
					                routes::dashboard::api::guild::edit_reminder,
 | 
				
			||||||
                routes::dashboard::guild::delete_reminder,
 | 
					                routes::dashboard::api::guild::delete_reminder,
 | 
				
			||||||
                routes::dashboard::export::export_reminders,
 | 
					                routes::dashboard::export::export_reminders,
 | 
				
			||||||
                routes::dashboard::export::export_reminder_templates,
 | 
					                routes::dashboard::export::export_reminder_templates,
 | 
				
			||||||
                routes::dashboard::export::export_todos,
 | 
					                routes::dashboard::export::export_todos,
 | 
				
			||||||
@@ -157,6 +149,7 @@ pub async fn initialize(
 | 
				
			|||||||
                routes::dashboard::export::import_todos,
 | 
					                routes::dashboard::export::import_todos,
 | 
				
			||||||
            ],
 | 
					            ],
 | 
				
			||||||
        )
 | 
					        )
 | 
				
			||||||
 | 
					        .mount("/admin", routes![routes::admin::admin_dashboard_home, routes::admin::bot_data])
 | 
				
			||||||
        .launch()
 | 
					        .launch()
 | 
				
			||||||
        .await?;
 | 
					        .await?;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -173,6 +166,8 @@ pub async fn initialize(
 | 
				
			|||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
pub async fn check_subscription(cache_http: impl CacheHttp, user_id: impl Into<UserId>) -> bool {
 | 
					pub async fn check_subscription(cache_http: impl CacheHttp, user_id: impl Into<UserId>) -> bool {
 | 
				
			||||||
 | 
					    offline!(true);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    if let Some(subscription_guild) = *CNC_GUILD {
 | 
					    if let Some(subscription_guild) = *CNC_GUILD {
 | 
				
			||||||
        let guild_member = GuildId(subscription_guild).member(cache_http, user_id).await;
 | 
					        let guild_member = GuildId(subscription_guild).member(cache_http, user_id).await;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -194,6 +189,8 @@ pub async fn check_guild_subscription(
 | 
				
			|||||||
    cache_http: impl CacheHttp,
 | 
					    cache_http: impl CacheHttp,
 | 
				
			||||||
    guild_id: impl Into<GuildId>,
 | 
					    guild_id: impl Into<GuildId>,
 | 
				
			||||||
) -> bool {
 | 
					) -> bool {
 | 
				
			||||||
 | 
					    offline!(true);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    if let Some(guild) = cache_http.cache().unwrap().guild(guild_id) {
 | 
					    if let Some(guild) = cache_http.cache().unwrap().guild(guild_id) {
 | 
				
			||||||
        let owner = guild.owner_id;
 | 
					        let owner = guild.owner_id;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -202,3 +199,65 @@ pub async fn check_guild_subscription(
 | 
				
			|||||||
        false
 | 
					        false
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					pub async fn check_authorization(
 | 
				
			||||||
 | 
					    cookies: &CookieJar<'_>,
 | 
				
			||||||
 | 
					    ctx: &Context,
 | 
				
			||||||
 | 
					    guild: u64,
 | 
				
			||||||
 | 
					) -> Result<(), JsonValue> {
 | 
				
			||||||
 | 
					    let user_id = cookies.get_private("userid").map(|c| c.value().parse::<u64>().ok()).flatten();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    if std::env::var("OFFLINE").map_or(true, |v| v != "1") {
 | 
				
			||||||
 | 
					        match user_id {
 | 
				
			||||||
 | 
					            Some(user_id) => {
 | 
				
			||||||
 | 
					                let admin_id = std::env::var("ADMIN_ID")
 | 
				
			||||||
 | 
					                    .map_or(false, |u| u.parse::<u64>().map_or(false, |u| u == user_id));
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                if admin_id {
 | 
				
			||||||
 | 
					                    return Ok(());
 | 
				
			||||||
 | 
					                }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                match GuildId(guild).to_guild_cached(ctx) {
 | 
				
			||||||
 | 
					                    Some(guild) => {
 | 
				
			||||||
 | 
					                        let member_res = guild.member(ctx, UserId(user_id)).await;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                        match member_res {
 | 
				
			||||||
 | 
					                            Err(_) => {
 | 
				
			||||||
 | 
					                                return Err(json!({"error": "User not in guild"}));
 | 
				
			||||||
 | 
					                            }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                            Ok(member) => {
 | 
				
			||||||
 | 
					                                let permissions_res = member.permissions(ctx);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                                match permissions_res {
 | 
				
			||||||
 | 
					                                    Err(_) => {
 | 
				
			||||||
 | 
					                                        return Err(json!({"error": "Couldn't fetch permissions"}));
 | 
				
			||||||
 | 
					                                    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                                    Ok(permissions) => {
 | 
				
			||||||
 | 
					                                        if !(permissions.manage_messages()
 | 
				
			||||||
 | 
					                                            || permissions.manage_guild()
 | 
				
			||||||
 | 
					                                            || permissions.administrator())
 | 
				
			||||||
 | 
					                                        {
 | 
				
			||||||
 | 
					                                            return Err(json!({"error": "Incorrect permissions"}));
 | 
				
			||||||
 | 
					                                        }
 | 
				
			||||||
 | 
					                                    }
 | 
				
			||||||
 | 
					                                }
 | 
				
			||||||
 | 
					                            }
 | 
				
			||||||
 | 
					                        }
 | 
				
			||||||
 | 
					                    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                    None => {
 | 
				
			||||||
 | 
					                        return Err(json!({"error": "Bot not in guild"}));
 | 
				
			||||||
 | 
					                    }
 | 
				
			||||||
 | 
					                }
 | 
				
			||||||
 | 
					            }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            None => {
 | 
				
			||||||
 | 
					                return Err(json!({"error": "User not authorized"}));
 | 
				
			||||||
 | 
					            }
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    Ok(())
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -1,3 +1,11 @@
 | 
				
			|||||||
 | 
					macro_rules! offline {
 | 
				
			||||||
 | 
					    ($field:expr) => {
 | 
				
			||||||
 | 
					        if std::env::var("OFFLINE").map_or(false, |v| v == "1") {
 | 
				
			||||||
 | 
					            return $field;
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					    };
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
macro_rules! check_length {
 | 
					macro_rules! check_length {
 | 
				
			||||||
    ($max:ident, $field:expr) => {
 | 
					    ($max:ident, $field:expr) => {
 | 
				
			||||||
        if $field.len() > $max {
 | 
					        if $field.len() > $max {
 | 
				
			||||||
@@ -46,40 +54,6 @@ macro_rules! check_url_opt {
 | 
				
			|||||||
    };
 | 
					    };
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
macro_rules! check_authorization {
 | 
					 | 
				
			||||||
    ($cookies:expr, $ctx:expr, $guild:expr) => {
 | 
					 | 
				
			||||||
        use serenity::model::id::UserId;
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
        let user_id = $cookies.get_private("userid").map(|c| c.value().parse::<u64>().ok()).flatten();
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
        match user_id {
 | 
					 | 
				
			||||||
            Some(user_id) => {
 | 
					 | 
				
			||||||
                match GuildId($guild).to_guild_cached($ctx) {
 | 
					 | 
				
			||||||
                    Some(guild) => {
 | 
					 | 
				
			||||||
                        let member = guild.member($ctx, UserId(user_id)).await;
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
                        match member {
 | 
					 | 
				
			||||||
                            Err(_) => {
 | 
					 | 
				
			||||||
                                return Err(json!({"error": "User not in guild"}));
 | 
					 | 
				
			||||||
                            }
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
                            Ok(_) => {}
 | 
					 | 
				
			||||||
                        }
 | 
					 | 
				
			||||||
                    }
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
                    None => {
 | 
					 | 
				
			||||||
                        return Err(json!({"error": "Bot not in guild"}));
 | 
					 | 
				
			||||||
                    }
 | 
					 | 
				
			||||||
                }
 | 
					 | 
				
			||||||
            }
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
            None => {
 | 
					 | 
				
			||||||
                return Err(json!({"error": "User not authorized"}));
 | 
					 | 
				
			||||||
            }
 | 
					 | 
				
			||||||
        }
 | 
					 | 
				
			||||||
    }
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
macro_rules! update_field {
 | 
					macro_rules! update_field {
 | 
				
			||||||
    ($pool:expr, $error:ident, $reminder:ident.[$field:ident]) => {
 | 
					    ($pool:expr, $error:ident, $reminder:ident.[$field:ident]) => {
 | 
				
			||||||
        if let Some(value) = &$reminder.$field {
 | 
					        if let Some(value) = &$reminder.$field {
 | 
				
			||||||
 
 | 
				
			|||||||
							
								
								
									
										43
									
								
								web/src/metrics.rs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										43
									
								
								web/src/metrics.rs
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,43 @@
 | 
				
			|||||||
 | 
					use lazy_static::lazy_static;
 | 
				
			||||||
 | 
					use prometheus::{IntCounterVec, Opts, Registry};
 | 
				
			||||||
 | 
					use rocket::{
 | 
				
			||||||
 | 
					    fairing::{Fairing, Info, Kind},
 | 
				
			||||||
 | 
					    Data, Request, Response,
 | 
				
			||||||
 | 
					};
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					lazy_static! {
 | 
				
			||||||
 | 
					    pub static ref REGISTRY: Registry = Registry::new();
 | 
				
			||||||
 | 
					    static ref REQUEST_COUNTER: IntCounterVec =
 | 
				
			||||||
 | 
					        IntCounterVec::new(Opts::new("requests", "Requests"), &["method", "route"]).unwrap();
 | 
				
			||||||
 | 
					    static ref RESPONSE_COUNTER: IntCounterVec =
 | 
				
			||||||
 | 
					        IntCounterVec::new(Opts::new("responses", "Responses"), &["status", "route"]).unwrap();
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					pub fn init_metrics() {
 | 
				
			||||||
 | 
					    REGISTRY.register(Box::new(REQUEST_COUNTER.clone())).unwrap();
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					pub struct MetricProducer;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					#[rocket::async_trait]
 | 
				
			||||||
 | 
					impl Fairing for MetricProducer {
 | 
				
			||||||
 | 
					    fn info(&self) -> Info {
 | 
				
			||||||
 | 
					        Info { name: "Metrics fairing", kind: Kind::Request }
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    async fn on_request(&self, req: &mut Request<'_>, _data: &mut Data<'_>) {
 | 
				
			||||||
 | 
					        if let Some(route) = req.route() {
 | 
				
			||||||
 | 
					            REQUEST_COUNTER
 | 
				
			||||||
 | 
					                .with_label_values(&[req.method().as_str(), &route.uri.to_string()])
 | 
				
			||||||
 | 
					                .inc();
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    async fn on_response<'r>(&self, req: &'r Request<'_>, resp: &mut Response<'r>) {
 | 
				
			||||||
 | 
					        if let Some(route) = req.route() {
 | 
				
			||||||
 | 
					            RESPONSE_COUNTER
 | 
				
			||||||
 | 
					                .with_label_values(&[&resp.status().code.to_string(), &route.uri.to_string()])
 | 
				
			||||||
 | 
					                .inc();
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
							
								
								
									
										218
									
								
								web/src/routes/admin.rs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										218
									
								
								web/src/routes/admin.rs
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,218 @@
 | 
				
			|||||||
 | 
					use std::{collections::HashMap, env};
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					use chrono::{DateTime, Utc};
 | 
				
			||||||
 | 
					use rocket::{
 | 
				
			||||||
 | 
					    http::{CookieJar, Status},
 | 
				
			||||||
 | 
					    serde::json::json,
 | 
				
			||||||
 | 
					    State,
 | 
				
			||||||
 | 
					};
 | 
				
			||||||
 | 
					use rocket_dyn_templates::Template;
 | 
				
			||||||
 | 
					use serde::Serialize;
 | 
				
			||||||
 | 
					use sqlx::{MySql, Pool};
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					use crate::routes::JsonResult;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					fn is_admin(cookies: &CookieJar<'_>) -> bool {
 | 
				
			||||||
 | 
					    cookies
 | 
				
			||||||
 | 
					        .get_private("userid")
 | 
				
			||||||
 | 
					        .map_or(false, |cookie| Some(cookie.value().to_string()) == env::var("ADMIN_ID").ok())
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					#[get("/")]
 | 
				
			||||||
 | 
					pub async fn admin_dashboard_home(cookies: &CookieJar<'_>) -> Result<Template, Status> {
 | 
				
			||||||
 | 
					    if let Some(cookie) = cookies.get_private("userid") {
 | 
				
			||||||
 | 
					        let map: HashMap<&str, String> = HashMap::new();
 | 
				
			||||||
 | 
					        if Some(cookie.value().to_string()) == env::var("ADMIN_ID").ok() {
 | 
				
			||||||
 | 
					            Ok(Template::render("admin_dashboard", &map))
 | 
				
			||||||
 | 
					        } else {
 | 
				
			||||||
 | 
					            Err(Status::Forbidden)
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					    } else {
 | 
				
			||||||
 | 
					        Err(Status::Unauthorized)
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					#[derive(Serialize)]
 | 
				
			||||||
 | 
					struct TimeFrame {
 | 
				
			||||||
 | 
					    time_key: DateTime<Utc>,
 | 
				
			||||||
 | 
					    count: i64,
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					#[get("/data")]
 | 
				
			||||||
 | 
					pub async fn bot_data(cookies: &CookieJar<'_>, pool: &State<Pool<MySql>>) -> JsonResult {
 | 
				
			||||||
 | 
					    if !is_admin(cookies) {
 | 
				
			||||||
 | 
					        return json_err!("Not authorized");
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    let backlog = sqlx::query!(
 | 
				
			||||||
 | 
					        "SELECT COUNT(1) AS backlog FROM reminders WHERE `utc_time` < NOW() AND enabled = 1 AND `status` = 'pending'"
 | 
				
			||||||
 | 
					    )
 | 
				
			||||||
 | 
					    .fetch_one(pool.inner())
 | 
				
			||||||
 | 
					    .await
 | 
				
			||||||
 | 
					    .unwrap();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    let schedule_once = sqlx::query_as_unchecked!(
 | 
				
			||||||
 | 
					        TimeFrame,
 | 
				
			||||||
 | 
					        "SELECT
 | 
				
			||||||
 | 
					            FROM_UNIXTIME(FLOOR(UNIX_TIMESTAMP(`utc_time`) / 300) * 300) AS `time_key`,
 | 
				
			||||||
 | 
					            COUNT(1) AS `count`
 | 
				
			||||||
 | 
					        FROM reminders
 | 
				
			||||||
 | 
					        WHERE
 | 
				
			||||||
 | 
					            `utc_time` < DATE_ADD(NOW(), INTERVAL 1 DAY) AND
 | 
				
			||||||
 | 
					            `utc_time` >= NOW() AND
 | 
				
			||||||
 | 
					            `enabled` = 1 AND
 | 
				
			||||||
 | 
					            `status` = 'pending' AND
 | 
				
			||||||
 | 
					            `interval_seconds` IS NULL AND
 | 
				
			||||||
 | 
					            `interval_months` IS NULL AND
 | 
				
			||||||
 | 
					            `interval_days` IS NULL
 | 
				
			||||||
 | 
					        GROUP BY `time_key`
 | 
				
			||||||
 | 
					        ORDER BY `time_key`"
 | 
				
			||||||
 | 
					    )
 | 
				
			||||||
 | 
					    .fetch_all(pool.inner())
 | 
				
			||||||
 | 
					    .await
 | 
				
			||||||
 | 
					    .unwrap();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    let schedule_interval = sqlx::query_as_unchecked!(
 | 
				
			||||||
 | 
					        TimeFrame,
 | 
				
			||||||
 | 
					        "SELECT
 | 
				
			||||||
 | 
					            FROM_UNIXTIME(FLOOR(UNIX_TIMESTAMP(`utc_time`) / 300) * 300) AS `time_key`,
 | 
				
			||||||
 | 
					            COUNT(1) AS `count`
 | 
				
			||||||
 | 
					        FROM reminders
 | 
				
			||||||
 | 
					        WHERE
 | 
				
			||||||
 | 
					            `utc_time` < DATE_ADD(NOW(), INTERVAL 1 DAY) AND
 | 
				
			||||||
 | 
					            `utc_time` >= NOW() AND
 | 
				
			||||||
 | 
					            `status` = 'pending' AND
 | 
				
			||||||
 | 
					            `enabled` = 1 AND (
 | 
				
			||||||
 | 
					                `interval_seconds` IS NOT NULL OR
 | 
				
			||||||
 | 
					                `interval_months` IS NOT NULL OR
 | 
				
			||||||
 | 
					                `interval_days` IS NOT NULL
 | 
				
			||||||
 | 
					            )
 | 
				
			||||||
 | 
					        GROUP BY `time_key`
 | 
				
			||||||
 | 
					        ORDER BY `time_key`"
 | 
				
			||||||
 | 
					    )
 | 
				
			||||||
 | 
					    .fetch_all(pool.inner())
 | 
				
			||||||
 | 
					    .await
 | 
				
			||||||
 | 
					    .unwrap();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    let schedule_once_long = sqlx::query_as_unchecked!(
 | 
				
			||||||
 | 
					        TimeFrame,
 | 
				
			||||||
 | 
					        "SELECT
 | 
				
			||||||
 | 
					            FROM_UNIXTIME(FLOOR(UNIX_TIMESTAMP(`utc_time`) / 86400) * 86400) AS `time_key`,
 | 
				
			||||||
 | 
					            COUNT(1) AS `count`
 | 
				
			||||||
 | 
					        FROM reminders
 | 
				
			||||||
 | 
					        WHERE
 | 
				
			||||||
 | 
					            `utc_time` < DATE_ADD(NOW(), INTERVAL 31 DAY) AND
 | 
				
			||||||
 | 
					            `utc_time` >= NOW() AND
 | 
				
			||||||
 | 
					            `enabled` = 1 AND
 | 
				
			||||||
 | 
					            `status` = 'pending' AND
 | 
				
			||||||
 | 
					            `interval_seconds` IS NULL AND
 | 
				
			||||||
 | 
					            `interval_months` IS NULL AND
 | 
				
			||||||
 | 
					            `interval_days` IS NULL
 | 
				
			||||||
 | 
					        GROUP BY `time_key`
 | 
				
			||||||
 | 
					        ORDER BY `time_key`"
 | 
				
			||||||
 | 
					    )
 | 
				
			||||||
 | 
					    .fetch_all(pool.inner())
 | 
				
			||||||
 | 
					    .await
 | 
				
			||||||
 | 
					    .unwrap();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    let schedule_interval_long = sqlx::query_as_unchecked!(
 | 
				
			||||||
 | 
					        TimeFrame,
 | 
				
			||||||
 | 
					        "SELECT
 | 
				
			||||||
 | 
					            FROM_UNIXTIME(FLOOR(UNIX_TIMESTAMP(`utc_time`) / 86400) * 86400) AS `time_key`,
 | 
				
			||||||
 | 
					            COUNT(1) AS `count`
 | 
				
			||||||
 | 
					        FROM reminders
 | 
				
			||||||
 | 
					        WHERE
 | 
				
			||||||
 | 
					            `utc_time` < DATE_ADD(NOW(), INTERVAL 31 DAY) AND
 | 
				
			||||||
 | 
					            `utc_time` >= NOW() AND
 | 
				
			||||||
 | 
					            `status` = 'pending' AND
 | 
				
			||||||
 | 
					            `enabled` = 1 AND (
 | 
				
			||||||
 | 
					                `interval_seconds` IS NOT NULL OR
 | 
				
			||||||
 | 
					                `interval_months` IS NOT NULL OR
 | 
				
			||||||
 | 
					                `interval_days` IS NOT NULL
 | 
				
			||||||
 | 
					            )
 | 
				
			||||||
 | 
					        GROUP BY `time_key`
 | 
				
			||||||
 | 
					        ORDER BY `time_key`"
 | 
				
			||||||
 | 
					    )
 | 
				
			||||||
 | 
					    .fetch_all(pool.inner())
 | 
				
			||||||
 | 
					    .await
 | 
				
			||||||
 | 
					    .unwrap();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    let history = sqlx::query_as_unchecked!(
 | 
				
			||||||
 | 
					        TimeFrame,
 | 
				
			||||||
 | 
					        "SELECT
 | 
				
			||||||
 | 
					            FROM_UNIXTIME(FLOOR(UNIX_TIMESTAMP(`utc_time`) / 86400) * 86400) AS `time_key`,
 | 
				
			||||||
 | 
					            COUNT(1) AS `count`
 | 
				
			||||||
 | 
					        FROM stat
 | 
				
			||||||
 | 
					        WHERE
 | 
				
			||||||
 | 
					            `utc_time` > DATE_SUB(NOW(), INTERVAL 31 DAY) AND
 | 
				
			||||||
 | 
					            `type` = 'reminder_sent'
 | 
				
			||||||
 | 
					        GROUP BY `time_key`
 | 
				
			||||||
 | 
					        ORDER BY `time_key`"
 | 
				
			||||||
 | 
					    )
 | 
				
			||||||
 | 
					    .fetch_all(pool.inner())
 | 
				
			||||||
 | 
					    .await
 | 
				
			||||||
 | 
					    .unwrap();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    let history_failed = sqlx::query_as_unchecked!(
 | 
				
			||||||
 | 
					        TimeFrame,
 | 
				
			||||||
 | 
					        "SELECT
 | 
				
			||||||
 | 
					            FROM_UNIXTIME(FLOOR(UNIX_TIMESTAMP(`utc_time`) / 86400) * 86400) AS `time_key`,
 | 
				
			||||||
 | 
					            COUNT(1) AS `count`
 | 
				
			||||||
 | 
					        FROM stat
 | 
				
			||||||
 | 
					        WHERE
 | 
				
			||||||
 | 
					            `utc_time` > DATE_SUB(NOW(), INTERVAL 31 DAY) AND
 | 
				
			||||||
 | 
					            `type` = 'reminder_failed'
 | 
				
			||||||
 | 
					        GROUP BY `time_key`
 | 
				
			||||||
 | 
					        ORDER BY `time_key`"
 | 
				
			||||||
 | 
					    )
 | 
				
			||||||
 | 
					    .fetch_all(pool.inner())
 | 
				
			||||||
 | 
					    .await
 | 
				
			||||||
 | 
					    .unwrap();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    let interval_count = sqlx::query!(
 | 
				
			||||||
 | 
					        "SELECT COUNT(1) AS count
 | 
				
			||||||
 | 
					        FROM reminders
 | 
				
			||||||
 | 
					        WHERE
 | 
				
			||||||
 | 
					            `status` = 'pending' AND (
 | 
				
			||||||
 | 
					                `interval_seconds` IS NOT NULL OR
 | 
				
			||||||
 | 
					                `interval_months` IS NOT NULL OR
 | 
				
			||||||
 | 
					                `interval_days` IS NOT NULL
 | 
				
			||||||
 | 
					            )"
 | 
				
			||||||
 | 
					    )
 | 
				
			||||||
 | 
					    .fetch_one(pool.inner())
 | 
				
			||||||
 | 
					    .await
 | 
				
			||||||
 | 
					    .unwrap();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    let reminder_count = sqlx::query!(
 | 
				
			||||||
 | 
					        "SELECT COUNT(1) AS count
 | 
				
			||||||
 | 
					        FROM reminders
 | 
				
			||||||
 | 
					        WHERE
 | 
				
			||||||
 | 
					            `status` = 'pending' AND
 | 
				
			||||||
 | 
					            `interval_seconds` IS NULL AND
 | 
				
			||||||
 | 
					            `interval_months` IS NULL AND
 | 
				
			||||||
 | 
					            `interval_days` IS NULL"
 | 
				
			||||||
 | 
					    )
 | 
				
			||||||
 | 
					    .fetch_one(pool.inner())
 | 
				
			||||||
 | 
					    .await
 | 
				
			||||||
 | 
					    .unwrap();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    Ok(json!({
 | 
				
			||||||
 | 
					        "backlog": backlog.backlog,
 | 
				
			||||||
 | 
					        "scheduleShort": {
 | 
				
			||||||
 | 
					            "once": schedule_once,
 | 
				
			||||||
 | 
					            "interval": schedule_interval
 | 
				
			||||||
 | 
					        },
 | 
				
			||||||
 | 
					        "scheduleLong": {
 | 
				
			||||||
 | 
					            "once": schedule_once_long,
 | 
				
			||||||
 | 
					            "interval": schedule_interval_long,
 | 
				
			||||||
 | 
					        },
 | 
				
			||||||
 | 
					        "historyLong": {
 | 
				
			||||||
 | 
					            "sent": history,
 | 
				
			||||||
 | 
					            "failed": history_failed,
 | 
				
			||||||
 | 
					        },
 | 
				
			||||||
 | 
					        "count": {
 | 
				
			||||||
 | 
					            "reminders": reminder_count.count,
 | 
				
			||||||
 | 
					            "intervals": interval_count.count,
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					    }))
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
							
								
								
									
										61
									
								
								web/src/routes/dashboard/api/guild/channels.rs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										61
									
								
								web/src/routes/dashboard/api/guild/channels.rs
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,61 @@
 | 
				
			|||||||
 | 
					use rocket::{http::CookieJar, serde::json::json, State};
 | 
				
			||||||
 | 
					use serde::Serialize;
 | 
				
			||||||
 | 
					use serenity::{
 | 
				
			||||||
 | 
					    client::Context,
 | 
				
			||||||
 | 
					    model::{
 | 
				
			||||||
 | 
					        channel::GuildChannel,
 | 
				
			||||||
 | 
					        id::{ChannelId, GuildId},
 | 
				
			||||||
 | 
					    },
 | 
				
			||||||
 | 
					};
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					use crate::{check_authorization, routes::JsonResult};
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					#[derive(Serialize)]
 | 
				
			||||||
 | 
					struct ChannelInfo {
 | 
				
			||||||
 | 
					    id: String,
 | 
				
			||||||
 | 
					    name: String,
 | 
				
			||||||
 | 
					    webhook_avatar: Option<String>,
 | 
				
			||||||
 | 
					    webhook_name: Option<String>,
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					#[get("/api/guild/<id>/channels")]
 | 
				
			||||||
 | 
					pub async fn get_guild_channels(
 | 
				
			||||||
 | 
					    id: u64,
 | 
				
			||||||
 | 
					    cookies: &CookieJar<'_>,
 | 
				
			||||||
 | 
					    ctx: &State<Context>,
 | 
				
			||||||
 | 
					) -> JsonResult {
 | 
				
			||||||
 | 
					    offline!(Ok(json!(vec![ChannelInfo {
 | 
				
			||||||
 | 
					        name: "general".to_string(),
 | 
				
			||||||
 | 
					        id: "1".to_string(),
 | 
				
			||||||
 | 
					        webhook_avatar: None,
 | 
				
			||||||
 | 
					        webhook_name: None,
 | 
				
			||||||
 | 
					    }])));
 | 
				
			||||||
 | 
					    check_authorization(cookies, ctx.inner(), id).await?;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    match GuildId(id).to_guild_cached(ctx.inner()) {
 | 
				
			||||||
 | 
					        Some(guild) => {
 | 
				
			||||||
 | 
					            let mut channels = guild
 | 
				
			||||||
 | 
					                .channels
 | 
				
			||||||
 | 
					                .iter()
 | 
				
			||||||
 | 
					                .filter_map(|(id, channel)| channel.to_owned().guild().map(|c| (id.to_owned(), c)))
 | 
				
			||||||
 | 
					                .filter(|(_, channel)| channel.is_text_based())
 | 
				
			||||||
 | 
					                .collect::<Vec<(ChannelId, GuildChannel)>>();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            channels.sort_by(|(_, c1), (_, c2)| c1.position.cmp(&c2.position));
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            let channel_info = channels
 | 
				
			||||||
 | 
					                .iter()
 | 
				
			||||||
 | 
					                .map(|(channel_id, channel)| ChannelInfo {
 | 
				
			||||||
 | 
					                    name: channel.name.to_string(),
 | 
				
			||||||
 | 
					                    id: channel_id.to_string(),
 | 
				
			||||||
 | 
					                    webhook_avatar: None,
 | 
				
			||||||
 | 
					                    webhook_name: None,
 | 
				
			||||||
 | 
					                })
 | 
				
			||||||
 | 
					                .collect::<Vec<ChannelInfo>>();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            Ok(json!(channel_info))
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        None => json_err!("Bot not in guild"),
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
							
								
								
									
										42
									
								
								web/src/routes/dashboard/api/guild/mod.rs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										42
									
								
								web/src/routes/dashboard/api/guild/mod.rs
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,42 @@
 | 
				
			|||||||
 | 
					mod channels;
 | 
				
			||||||
 | 
					mod reminders;
 | 
				
			||||||
 | 
					mod roles;
 | 
				
			||||||
 | 
					mod templates;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					use std::env;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					pub use channels::*;
 | 
				
			||||||
 | 
					pub use reminders::*;
 | 
				
			||||||
 | 
					use rocket::{http::CookieJar, serde::json::json, State};
 | 
				
			||||||
 | 
					pub use roles::*;
 | 
				
			||||||
 | 
					use serenity::{
 | 
				
			||||||
 | 
					    client::Context,
 | 
				
			||||||
 | 
					    model::id::{GuildId, RoleId},
 | 
				
			||||||
 | 
					};
 | 
				
			||||||
 | 
					pub use templates::*;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					use crate::{check_authorization, routes::JsonResult};
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					#[get("/api/guild/<id>")]
 | 
				
			||||||
 | 
					pub async fn get_guild_info(id: u64, cookies: &CookieJar<'_>, ctx: &State<Context>) -> JsonResult {
 | 
				
			||||||
 | 
					    offline!(Ok(json!({ "patreon": true, "name": "Guild" })));
 | 
				
			||||||
 | 
					    check_authorization(cookies, ctx.inner(), id).await?;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    match GuildId(id).to_guild_cached(ctx.inner()) {
 | 
				
			||||||
 | 
					        Some(guild) => {
 | 
				
			||||||
 | 
					            let member_res = GuildId(env::var("PATREON_GUILD_ID").unwrap().parse().unwrap())
 | 
				
			||||||
 | 
					                .member(&ctx.inner(), guild.owner_id)
 | 
				
			||||||
 | 
					                .await;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            let patreon = member_res.map_or(false, |member| {
 | 
				
			||||||
 | 
					                member
 | 
				
			||||||
 | 
					                    .roles
 | 
				
			||||||
 | 
					                    .contains(&RoleId(env::var("PATREON_ROLE_ID").unwrap().parse().unwrap()))
 | 
				
			||||||
 | 
					            });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            Ok(json!({ "patreon": patreon, "name": guild.name }))
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        None => json_err!("Bot not in guild"),
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
							
								
								
									
										388
									
								
								web/src/routes/dashboard/api/guild/reminders.rs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										388
									
								
								web/src/routes/dashboard/api/guild/reminders.rs
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,388 @@
 | 
				
			|||||||
 | 
					use rocket::{
 | 
				
			||||||
 | 
					    http::CookieJar,
 | 
				
			||||||
 | 
					    serde::json::{json, Json},
 | 
				
			||||||
 | 
					    State,
 | 
				
			||||||
 | 
					};
 | 
				
			||||||
 | 
					use serenity::{
 | 
				
			||||||
 | 
					    client::Context,
 | 
				
			||||||
 | 
					    model::id::{ChannelId, GuildId, UserId},
 | 
				
			||||||
 | 
					};
 | 
				
			||||||
 | 
					use sqlx::{MySql, Pool};
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					use crate::{
 | 
				
			||||||
 | 
					    check_authorization, check_guild_subscription, check_subscription,
 | 
				
			||||||
 | 
					    consts::MIN_INTERVAL,
 | 
				
			||||||
 | 
					    guards::transaction::Transaction,
 | 
				
			||||||
 | 
					    routes::{
 | 
				
			||||||
 | 
					        dashboard::{
 | 
				
			||||||
 | 
					            create_database_channel, create_reminder, DeleteReminder, PatchReminder, Reminder,
 | 
				
			||||||
 | 
					        },
 | 
				
			||||||
 | 
					        JsonResult,
 | 
				
			||||||
 | 
					    },
 | 
				
			||||||
 | 
					    Database,
 | 
				
			||||||
 | 
					};
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					#[post("/api/guild/<id>/reminders", data = "<reminder>")]
 | 
				
			||||||
 | 
					pub async fn create_guild_reminder(
 | 
				
			||||||
 | 
					    id: u64,
 | 
				
			||||||
 | 
					    reminder: Json<Reminder>,
 | 
				
			||||||
 | 
					    cookies: &CookieJar<'_>,
 | 
				
			||||||
 | 
					    ctx: &State<Context>,
 | 
				
			||||||
 | 
					    mut transaction: Transaction<'_>,
 | 
				
			||||||
 | 
					) -> JsonResult {
 | 
				
			||||||
 | 
					    check_authorization(cookies, ctx.inner(), id).await?;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    let user_id =
 | 
				
			||||||
 | 
					        cookies.get_private("userid").map(|c| c.value().parse::<u64>().ok()).flatten().unwrap();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    match create_reminder(
 | 
				
			||||||
 | 
					        ctx.inner(),
 | 
				
			||||||
 | 
					        &mut transaction,
 | 
				
			||||||
 | 
					        GuildId(id),
 | 
				
			||||||
 | 
					        UserId(user_id),
 | 
				
			||||||
 | 
					        reminder.into_inner(),
 | 
				
			||||||
 | 
					    )
 | 
				
			||||||
 | 
					    .await
 | 
				
			||||||
 | 
					    {
 | 
				
			||||||
 | 
					        Ok(r) => match transaction.commit().await {
 | 
				
			||||||
 | 
					            Ok(_) => Ok(r),
 | 
				
			||||||
 | 
					            Err(e) => {
 | 
				
			||||||
 | 
					                warn!("Couldn't commit transaction: {:?}", e);
 | 
				
			||||||
 | 
					                json_err!("Couldn't commit transaction.")
 | 
				
			||||||
 | 
					            }
 | 
				
			||||||
 | 
					        },
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        Err(e) => Err(e),
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					#[get("/api/guild/<id>/reminders")]
 | 
				
			||||||
 | 
					pub async fn get_reminders(
 | 
				
			||||||
 | 
					    id: u64,
 | 
				
			||||||
 | 
					    cookies: &CookieJar<'_>,
 | 
				
			||||||
 | 
					    ctx: &State<Context>,
 | 
				
			||||||
 | 
					    pool: &State<Pool<MySql>>,
 | 
				
			||||||
 | 
					) -> JsonResult {
 | 
				
			||||||
 | 
					    check_authorization(cookies, ctx.inner(), id).await?;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    let channels_res = GuildId(id).channels(&ctx.inner()).await;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    match channels_res {
 | 
				
			||||||
 | 
					        Ok(channels) => {
 | 
				
			||||||
 | 
					            let channels = channels
 | 
				
			||||||
 | 
					                .keys()
 | 
				
			||||||
 | 
					                .into_iter()
 | 
				
			||||||
 | 
					                .map(|k| k.as_u64().to_string())
 | 
				
			||||||
 | 
					                .collect::<Vec<String>>()
 | 
				
			||||||
 | 
					                .join(",");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            sqlx::query_as_unchecked!(
 | 
				
			||||||
 | 
					                Reminder,
 | 
				
			||||||
 | 
					                "SELECT
 | 
				
			||||||
 | 
					                 reminders.attachment,
 | 
				
			||||||
 | 
					                 reminders.attachment_name,
 | 
				
			||||||
 | 
					                 reminders.avatar,
 | 
				
			||||||
 | 
					                 channels.channel,
 | 
				
			||||||
 | 
					                 reminders.content,
 | 
				
			||||||
 | 
					                 reminders.embed_author,
 | 
				
			||||||
 | 
					                 reminders.embed_author_url,
 | 
				
			||||||
 | 
					                 reminders.embed_color,
 | 
				
			||||||
 | 
					                 reminders.embed_description,
 | 
				
			||||||
 | 
					                 reminders.embed_footer,
 | 
				
			||||||
 | 
					                 reminders.embed_footer_url,
 | 
				
			||||||
 | 
					                 reminders.embed_image_url,
 | 
				
			||||||
 | 
					                 reminders.embed_thumbnail_url,
 | 
				
			||||||
 | 
					                 reminders.embed_title,
 | 
				
			||||||
 | 
					                 IFNULL(reminders.embed_fields, '[]') AS embed_fields,
 | 
				
			||||||
 | 
					                 reminders.enabled,
 | 
				
			||||||
 | 
					                 reminders.expires,
 | 
				
			||||||
 | 
					                 reminders.interval_seconds,
 | 
				
			||||||
 | 
					                 reminders.interval_days,
 | 
				
			||||||
 | 
					                 reminders.interval_months,
 | 
				
			||||||
 | 
					                 reminders.name,
 | 
				
			||||||
 | 
					                 reminders.restartable,
 | 
				
			||||||
 | 
					                 reminders.tts,
 | 
				
			||||||
 | 
					                 reminders.uid,
 | 
				
			||||||
 | 
					                 reminders.username,
 | 
				
			||||||
 | 
					                 reminders.utc_time
 | 
				
			||||||
 | 
					                FROM reminders
 | 
				
			||||||
 | 
					                LEFT JOIN channels ON channels.id = reminders.channel_id
 | 
				
			||||||
 | 
					                WHERE `status` = 'pending' AND FIND_IN_SET(channels.channel, ?)",
 | 
				
			||||||
 | 
					                channels
 | 
				
			||||||
 | 
					            )
 | 
				
			||||||
 | 
					            .fetch_all(pool.inner())
 | 
				
			||||||
 | 
					            .await
 | 
				
			||||||
 | 
					            .map(|r| Ok(json!(r)))
 | 
				
			||||||
 | 
					            .unwrap_or_else(|e| {
 | 
				
			||||||
 | 
					                warn!("Failed to complete SQL query: {:?}", e);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                json_err!("Could not load reminders")
 | 
				
			||||||
 | 
					            })
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					        Err(e) => {
 | 
				
			||||||
 | 
					            warn!("Could not fetch channels from {}: {:?}", id, e);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            Ok(json!([]))
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					#[patch("/api/guild/<id>/reminders", data = "<reminder>")]
 | 
				
			||||||
 | 
					pub async fn edit_reminder(
 | 
				
			||||||
 | 
					    id: u64,
 | 
				
			||||||
 | 
					    reminder: Json<PatchReminder>,
 | 
				
			||||||
 | 
					    ctx: &State<Context>,
 | 
				
			||||||
 | 
					    mut transaction: Transaction<'_>,
 | 
				
			||||||
 | 
					    pool: &State<Pool<Database>>,
 | 
				
			||||||
 | 
					    cookies: &CookieJar<'_>,
 | 
				
			||||||
 | 
					) -> JsonResult {
 | 
				
			||||||
 | 
					    check_authorization(cookies, ctx.inner(), id).await?;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    let mut error = vec![];
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    let user_id =
 | 
				
			||||||
 | 
					        cookies.get_private("userid").map(|c| c.value().parse::<u64>().ok()).flatten().unwrap();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    if reminder.message_ok() {
 | 
				
			||||||
 | 
					        update_field!(transaction.executor(), error, reminder.[
 | 
				
			||||||
 | 
					            content,
 | 
				
			||||||
 | 
					            embed_author,
 | 
				
			||||||
 | 
					            embed_description,
 | 
				
			||||||
 | 
					            embed_footer,
 | 
				
			||||||
 | 
					            embed_title,
 | 
				
			||||||
 | 
					            embed_fields,
 | 
				
			||||||
 | 
					            username
 | 
				
			||||||
 | 
					        ]);
 | 
				
			||||||
 | 
					    } else {
 | 
				
			||||||
 | 
					        error.push("Message exceeds limits.".to_string());
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    update_field!(transaction.executor(), error, reminder.[
 | 
				
			||||||
 | 
					        attachment,
 | 
				
			||||||
 | 
					        attachment_name,
 | 
				
			||||||
 | 
					        avatar,
 | 
				
			||||||
 | 
					        embed_author_url,
 | 
				
			||||||
 | 
					        embed_color,
 | 
				
			||||||
 | 
					        embed_footer_url,
 | 
				
			||||||
 | 
					        embed_image_url,
 | 
				
			||||||
 | 
					        embed_thumbnail_url,
 | 
				
			||||||
 | 
					        enabled,
 | 
				
			||||||
 | 
					        expires,
 | 
				
			||||||
 | 
					        name,
 | 
				
			||||||
 | 
					        restartable,
 | 
				
			||||||
 | 
					        tts,
 | 
				
			||||||
 | 
					        utc_time
 | 
				
			||||||
 | 
					    ]);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    if reminder.interval_days.flatten().is_some()
 | 
				
			||||||
 | 
					        || reminder.interval_months.flatten().is_some()
 | 
				
			||||||
 | 
					        || reminder.interval_seconds.flatten().is_some()
 | 
				
			||||||
 | 
					    {
 | 
				
			||||||
 | 
					        if check_guild_subscription(&ctx.inner(), id).await
 | 
				
			||||||
 | 
					            || check_subscription(&ctx.inner(), user_id).await
 | 
				
			||||||
 | 
					        {
 | 
				
			||||||
 | 
					            let new_interval_length = match reminder.interval_days {
 | 
				
			||||||
 | 
					                Some(interval) => interval.unwrap_or(0),
 | 
				
			||||||
 | 
					                None => sqlx::query!(
 | 
				
			||||||
 | 
					                    "SELECT interval_days AS days FROM reminders WHERE uid = ?",
 | 
				
			||||||
 | 
					                    reminder.uid
 | 
				
			||||||
 | 
					                )
 | 
				
			||||||
 | 
					                .fetch_one(transaction.executor())
 | 
				
			||||||
 | 
					                .await
 | 
				
			||||||
 | 
					                .map_err(|e| {
 | 
				
			||||||
 | 
					                    warn!("Error updating reminder interval: {:?}", e);
 | 
				
			||||||
 | 
					                    json!({ "reminder": Option::<Reminder>::None, "errors": vec!["Unknown error"] })
 | 
				
			||||||
 | 
					                })?
 | 
				
			||||||
 | 
					                .days
 | 
				
			||||||
 | 
					                .unwrap_or(0),
 | 
				
			||||||
 | 
					            } * 86400 + match reminder.interval_months {
 | 
				
			||||||
 | 
					                Some(interval) => interval.unwrap_or(0),
 | 
				
			||||||
 | 
					                None => sqlx::query!(
 | 
				
			||||||
 | 
					                    "SELECT interval_months AS months FROM reminders WHERE uid = ?",
 | 
				
			||||||
 | 
					                    reminder.uid
 | 
				
			||||||
 | 
					                )
 | 
				
			||||||
 | 
					                .fetch_one(transaction.executor())
 | 
				
			||||||
 | 
					                .await
 | 
				
			||||||
 | 
					                .map_err(|e| {
 | 
				
			||||||
 | 
					                    warn!("Error updating reminder interval: {:?}", e);
 | 
				
			||||||
 | 
					                    json!({ "reminder": Option::<Reminder>::None, "errors": vec!["Unknown error"] })
 | 
				
			||||||
 | 
					                })?
 | 
				
			||||||
 | 
					                .months
 | 
				
			||||||
 | 
					                .unwrap_or(0),
 | 
				
			||||||
 | 
					            } * 2592000 + match reminder.interval_seconds {
 | 
				
			||||||
 | 
					                Some(interval) => interval.unwrap_or(0),
 | 
				
			||||||
 | 
					                None => sqlx::query!(
 | 
				
			||||||
 | 
					                    "SELECT interval_seconds AS seconds FROM reminders WHERE uid = ?",
 | 
				
			||||||
 | 
					                    reminder.uid
 | 
				
			||||||
 | 
					                )
 | 
				
			||||||
 | 
					                .fetch_one(transaction.executor())
 | 
				
			||||||
 | 
					                .await
 | 
				
			||||||
 | 
					                .map_err(|e| {
 | 
				
			||||||
 | 
					                    warn!("Error updating reminder interval: {:?}", e);
 | 
				
			||||||
 | 
					                    json!({ "reminder": Option::<Reminder>::None, "errors": vec!["Unknown error"] })
 | 
				
			||||||
 | 
					                })?
 | 
				
			||||||
 | 
					                .seconds
 | 
				
			||||||
 | 
					                .unwrap_or(0),
 | 
				
			||||||
 | 
					            };
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            if new_interval_length < *MIN_INTERVAL {
 | 
				
			||||||
 | 
					                error.push(String::from("New interval is too short."));
 | 
				
			||||||
 | 
					            } else {
 | 
				
			||||||
 | 
					                update_field!(transaction.executor(), error, reminder.[
 | 
				
			||||||
 | 
					                    interval_days,
 | 
				
			||||||
 | 
					                    interval_months,
 | 
				
			||||||
 | 
					                    interval_seconds
 | 
				
			||||||
 | 
					                ]);
 | 
				
			||||||
 | 
					            }
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					    } else {
 | 
				
			||||||
 | 
					        sqlx::query!(
 | 
				
			||||||
 | 
					            "
 | 
				
			||||||
 | 
					            UPDATE reminders
 | 
				
			||||||
 | 
					            SET interval_seconds = NULL, interval_days = NULL, interval_months = NULL
 | 
				
			||||||
 | 
					            WHERE uid = ?
 | 
				
			||||||
 | 
					            ",
 | 
				
			||||||
 | 
					            reminder.uid
 | 
				
			||||||
 | 
					        )
 | 
				
			||||||
 | 
					        .execute(transaction.executor())
 | 
				
			||||||
 | 
					        .await
 | 
				
			||||||
 | 
					        .map_err(|e| {
 | 
				
			||||||
 | 
					            warn!("Error updating reminder interval: {:?}", e);
 | 
				
			||||||
 | 
					            json!({ "reminder": Option::<Reminder>::None, "errors": vec!["Unknown error"] })
 | 
				
			||||||
 | 
					        })?;
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    if reminder.channel > 0 {
 | 
				
			||||||
 | 
					        let channel = ChannelId(reminder.channel).to_channel_cached(&ctx.inner());
 | 
				
			||||||
 | 
					        match channel {
 | 
				
			||||||
 | 
					            Some(channel) => {
 | 
				
			||||||
 | 
					                let channel_matches_guild = channel.guild().map_or(false, |c| c.guild_id.0 == id);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                if !channel_matches_guild {
 | 
				
			||||||
 | 
					                    warn!(
 | 
				
			||||||
 | 
					                        "Error in `edit_reminder`: channel {:?} not found for guild {}",
 | 
				
			||||||
 | 
					                        reminder.channel, id
 | 
				
			||||||
 | 
					                    );
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                    return Err(json!({"error": "Channel not found"}));
 | 
				
			||||||
 | 
					                }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                let channel = create_database_channel(
 | 
				
			||||||
 | 
					                    ctx.inner(),
 | 
				
			||||||
 | 
					                    ChannelId(reminder.channel),
 | 
				
			||||||
 | 
					                    &mut transaction,
 | 
				
			||||||
 | 
					                )
 | 
				
			||||||
 | 
					                .await;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                if let Err(e) = channel {
 | 
				
			||||||
 | 
					                    warn!("`create_database_channel` returned an error code: {:?}", e);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                    return Err(
 | 
				
			||||||
 | 
					                        json!({"error": "Failed to configure channel for reminders. Please check the bot permissions"}),
 | 
				
			||||||
 | 
					                    );
 | 
				
			||||||
 | 
					                }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                let channel = channel.unwrap();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                match sqlx::query!(
 | 
				
			||||||
 | 
					                    "UPDATE reminders SET channel_id = ? WHERE uid = ?",
 | 
				
			||||||
 | 
					                    channel,
 | 
				
			||||||
 | 
					                    reminder.uid
 | 
				
			||||||
 | 
					                )
 | 
				
			||||||
 | 
					                .execute(transaction.executor())
 | 
				
			||||||
 | 
					                .await
 | 
				
			||||||
 | 
					                {
 | 
				
			||||||
 | 
					                    Ok(_) => {}
 | 
				
			||||||
 | 
					                    Err(e) => {
 | 
				
			||||||
 | 
					                        warn!("Error setting channel: {:?}", e);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                        error.push("Couldn't set channel".to_string())
 | 
				
			||||||
 | 
					                    }
 | 
				
			||||||
 | 
					                }
 | 
				
			||||||
 | 
					            }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            None => {
 | 
				
			||||||
 | 
					                warn!(
 | 
				
			||||||
 | 
					                    "Error in `edit_reminder`: channel {:?} not found for guild {}",
 | 
				
			||||||
 | 
					                    reminder.channel, id
 | 
				
			||||||
 | 
					                );
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                return Err(json!({"error": "Channel not found"}));
 | 
				
			||||||
 | 
					            }
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    if let Err(e) = transaction.commit().await {
 | 
				
			||||||
 | 
					        warn!("Couldn't commit transaction: {:?}", e);
 | 
				
			||||||
 | 
					        return json_err!("Couldn't commit transaction");
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    match sqlx::query_as_unchecked!(
 | 
				
			||||||
 | 
					        Reminder,
 | 
				
			||||||
 | 
					        "SELECT reminders.attachment,
 | 
				
			||||||
 | 
					         reminders.attachment_name,
 | 
				
			||||||
 | 
					         reminders.avatar,
 | 
				
			||||||
 | 
					         channels.channel,
 | 
				
			||||||
 | 
					         reminders.content,
 | 
				
			||||||
 | 
					         reminders.embed_author,
 | 
				
			||||||
 | 
					         reminders.embed_author_url,
 | 
				
			||||||
 | 
					         reminders.embed_color,
 | 
				
			||||||
 | 
					         reminders.embed_description,
 | 
				
			||||||
 | 
					         reminders.embed_footer,
 | 
				
			||||||
 | 
					         reminders.embed_footer_url,
 | 
				
			||||||
 | 
					         reminders.embed_image_url,
 | 
				
			||||||
 | 
					         reminders.embed_thumbnail_url,
 | 
				
			||||||
 | 
					         reminders.embed_title,
 | 
				
			||||||
 | 
					         reminders.embed_fields,
 | 
				
			||||||
 | 
					         reminders.enabled,
 | 
				
			||||||
 | 
					         reminders.expires,
 | 
				
			||||||
 | 
					         reminders.interval_seconds,
 | 
				
			||||||
 | 
					         reminders.interval_days,
 | 
				
			||||||
 | 
					         reminders.interval_months,
 | 
				
			||||||
 | 
					         reminders.name,
 | 
				
			||||||
 | 
					         reminders.restartable,
 | 
				
			||||||
 | 
					         reminders.tts,
 | 
				
			||||||
 | 
					         reminders.uid,
 | 
				
			||||||
 | 
					         reminders.username,
 | 
				
			||||||
 | 
					         reminders.utc_time
 | 
				
			||||||
 | 
					        FROM reminders
 | 
				
			||||||
 | 
					        LEFT JOIN channels ON channels.id = reminders.channel_id
 | 
				
			||||||
 | 
					        WHERE uid = ?",
 | 
				
			||||||
 | 
					        reminder.uid
 | 
				
			||||||
 | 
					    )
 | 
				
			||||||
 | 
					    .fetch_one(pool.inner())
 | 
				
			||||||
 | 
					    .await
 | 
				
			||||||
 | 
					    {
 | 
				
			||||||
 | 
					        Ok(reminder) => Ok(json!({"reminder": reminder, "errors": error})),
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        Err(e) => {
 | 
				
			||||||
 | 
					            warn!("Error exiting `edit_reminder': {:?}", e);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            Err(json!({"reminder": Option::<Reminder>::None, "errors": vec!["Unknown error"]}))
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					#[delete("/api/guild/<id>/reminders", data = "<reminder>")]
 | 
				
			||||||
 | 
					pub async fn delete_reminder(
 | 
				
			||||||
 | 
					    cookies: &CookieJar<'_>,
 | 
				
			||||||
 | 
					    id: u64,
 | 
				
			||||||
 | 
					    reminder: Json<DeleteReminder>,
 | 
				
			||||||
 | 
					    ctx: &State<Context>,
 | 
				
			||||||
 | 
					    pool: &State<Pool<MySql>>,
 | 
				
			||||||
 | 
					) -> JsonResult {
 | 
				
			||||||
 | 
					    check_authorization(cookies, ctx.inner(), id).await?;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    match sqlx::query!("UPDATE reminders SET `status` = 'deleted' WHERE uid = ?", reminder.uid)
 | 
				
			||||||
 | 
					        .execute(pool.inner())
 | 
				
			||||||
 | 
					        .await
 | 
				
			||||||
 | 
					    {
 | 
				
			||||||
 | 
					        Ok(_) => Ok(json!({})),
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        Err(e) => {
 | 
				
			||||||
 | 
					            warn!("Error in `delete_reminder`: {:?}", e);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            Err(json!({"error": "Could not delete reminder"}))
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
							
								
								
									
										35
									
								
								web/src/routes/dashboard/api/guild/roles.rs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										35
									
								
								web/src/routes/dashboard/api/guild/roles.rs
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,35 @@
 | 
				
			|||||||
 | 
					use rocket::{http::CookieJar, serde::json::json, State};
 | 
				
			||||||
 | 
					use serde::Serialize;
 | 
				
			||||||
 | 
					use serenity::client::Context;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					use crate::{check_authorization, routes::JsonResult};
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					#[derive(Serialize)]
 | 
				
			||||||
 | 
					struct RoleInfo {
 | 
				
			||||||
 | 
					    id: String,
 | 
				
			||||||
 | 
					    name: String,
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					#[get("/api/guild/<id>/roles")]
 | 
				
			||||||
 | 
					pub async fn get_guild_roles(id: u64, cookies: &CookieJar<'_>, ctx: &State<Context>) -> JsonResult {
 | 
				
			||||||
 | 
					    offline!(Ok(json!(vec![RoleInfo { name: "@everyone".to_string(), id: "1".to_string() }])));
 | 
				
			||||||
 | 
					    check_authorization(cookies, ctx.inner(), id).await?;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    let roles_res = ctx.cache.guild_roles(id);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    match roles_res {
 | 
				
			||||||
 | 
					        Some(roles) => {
 | 
				
			||||||
 | 
					            let roles = roles
 | 
				
			||||||
 | 
					                .iter()
 | 
				
			||||||
 | 
					                .map(|(_, r)| RoleInfo { id: r.id.to_string(), name: r.name.to_string() })
 | 
				
			||||||
 | 
					                .collect::<Vec<RoleInfo>>();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            Ok(json!(roles))
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					        None => {
 | 
				
			||||||
 | 
					            warn!("Could not fetch roles from {}", id);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            json_err!("Could not get roles")
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
							
								
								
									
										181
									
								
								web/src/routes/dashboard/api/guild/templates.rs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										181
									
								
								web/src/routes/dashboard/api/guild/templates.rs
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,181 @@
 | 
				
			|||||||
 | 
					use rocket::{
 | 
				
			||||||
 | 
					    http::CookieJar,
 | 
				
			||||||
 | 
					    serde::json::{json, Json},
 | 
				
			||||||
 | 
					    State,
 | 
				
			||||||
 | 
					};
 | 
				
			||||||
 | 
					use serenity::client::Context;
 | 
				
			||||||
 | 
					use sqlx::{MySql, Pool};
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					use crate::{
 | 
				
			||||||
 | 
					    check_authorization,
 | 
				
			||||||
 | 
					    consts::{
 | 
				
			||||||
 | 
					        MAX_CONTENT_LENGTH, MAX_EMBED_AUTHOR_LENGTH, MAX_EMBED_DESCRIPTION_LENGTH,
 | 
				
			||||||
 | 
					        MAX_EMBED_FIELDS, MAX_EMBED_FIELD_TITLE_LENGTH, MAX_EMBED_FIELD_VALUE_LENGTH,
 | 
				
			||||||
 | 
					        MAX_EMBED_FOOTER_LENGTH, MAX_EMBED_TITLE_LENGTH, MAX_URL_LENGTH, MAX_USERNAME_LENGTH,
 | 
				
			||||||
 | 
					    },
 | 
				
			||||||
 | 
					    routes::{
 | 
				
			||||||
 | 
					        dashboard::{template_name_default, DeleteReminderTemplate, ReminderTemplate},
 | 
				
			||||||
 | 
					        JsonResult,
 | 
				
			||||||
 | 
					    },
 | 
				
			||||||
 | 
					};
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					#[get("/api/guild/<id>/templates")]
 | 
				
			||||||
 | 
					pub async fn get_reminder_templates(
 | 
				
			||||||
 | 
					    id: u64,
 | 
				
			||||||
 | 
					    cookies: &CookieJar<'_>,
 | 
				
			||||||
 | 
					    ctx: &State<Context>,
 | 
				
			||||||
 | 
					    pool: &State<Pool<MySql>>,
 | 
				
			||||||
 | 
					) -> JsonResult {
 | 
				
			||||||
 | 
					    check_authorization(cookies, ctx.inner(), id).await?;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    match sqlx::query_as_unchecked!(
 | 
				
			||||||
 | 
					        ReminderTemplate,
 | 
				
			||||||
 | 
					        "SELECT * FROM reminder_template WHERE guild_id = (SELECT id FROM guilds WHERE guild = ?)",
 | 
				
			||||||
 | 
					        id
 | 
				
			||||||
 | 
					    )
 | 
				
			||||||
 | 
					    .fetch_all(pool.inner())
 | 
				
			||||||
 | 
					    .await
 | 
				
			||||||
 | 
					    {
 | 
				
			||||||
 | 
					        Ok(templates) => Ok(json!(templates)),
 | 
				
			||||||
 | 
					        Err(e) => {
 | 
				
			||||||
 | 
					            warn!("Could not fetch templates from {}: {:?}", id, e);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            json_err!("Could not get templates")
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					#[post("/api/guild/<id>/templates", data = "<reminder_template>")]
 | 
				
			||||||
 | 
					pub async fn create_reminder_template(
 | 
				
			||||||
 | 
					    id: u64,
 | 
				
			||||||
 | 
					    reminder_template: Json<ReminderTemplate>,
 | 
				
			||||||
 | 
					    cookies: &CookieJar<'_>,
 | 
				
			||||||
 | 
					    ctx: &State<Context>,
 | 
				
			||||||
 | 
					    pool: &State<Pool<MySql>>,
 | 
				
			||||||
 | 
					) -> JsonResult {
 | 
				
			||||||
 | 
					    check_authorization(cookies, ctx.inner(), id).await?;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    // validate lengths
 | 
				
			||||||
 | 
					    check_length!(MAX_CONTENT_LENGTH, reminder_template.content);
 | 
				
			||||||
 | 
					    check_length!(MAX_EMBED_DESCRIPTION_LENGTH, reminder_template.embed_description);
 | 
				
			||||||
 | 
					    check_length!(MAX_EMBED_TITLE_LENGTH, reminder_template.embed_title);
 | 
				
			||||||
 | 
					    check_length!(MAX_EMBED_AUTHOR_LENGTH, reminder_template.embed_author);
 | 
				
			||||||
 | 
					    check_length!(MAX_EMBED_FOOTER_LENGTH, reminder_template.embed_footer);
 | 
				
			||||||
 | 
					    check_length_opt!(MAX_EMBED_FIELDS, reminder_template.embed_fields);
 | 
				
			||||||
 | 
					    if let Some(fields) = &reminder_template.embed_fields {
 | 
				
			||||||
 | 
					        for field in &fields.0 {
 | 
				
			||||||
 | 
					            check_length!(MAX_EMBED_FIELD_VALUE_LENGTH, field.value);
 | 
				
			||||||
 | 
					            check_length!(MAX_EMBED_FIELD_TITLE_LENGTH, field.title);
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					    check_length_opt!(MAX_USERNAME_LENGTH, reminder_template.username);
 | 
				
			||||||
 | 
					    check_length_opt!(
 | 
				
			||||||
 | 
					        MAX_URL_LENGTH,
 | 
				
			||||||
 | 
					        reminder_template.embed_footer_url,
 | 
				
			||||||
 | 
					        reminder_template.embed_thumbnail_url,
 | 
				
			||||||
 | 
					        reminder_template.embed_author_url,
 | 
				
			||||||
 | 
					        reminder_template.embed_image_url,
 | 
				
			||||||
 | 
					        reminder_template.avatar
 | 
				
			||||||
 | 
					    );
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    // validate urls
 | 
				
			||||||
 | 
					    check_url_opt!(
 | 
				
			||||||
 | 
					        reminder_template.embed_footer_url,
 | 
				
			||||||
 | 
					        reminder_template.embed_thumbnail_url,
 | 
				
			||||||
 | 
					        reminder_template.embed_author_url,
 | 
				
			||||||
 | 
					        reminder_template.embed_image_url,
 | 
				
			||||||
 | 
					        reminder_template.avatar
 | 
				
			||||||
 | 
					    );
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    let name = if reminder_template.name.is_empty() {
 | 
				
			||||||
 | 
					        template_name_default()
 | 
				
			||||||
 | 
					    } else {
 | 
				
			||||||
 | 
					        reminder_template.name.clone()
 | 
				
			||||||
 | 
					    };
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    match sqlx::query!(
 | 
				
			||||||
 | 
					        "INSERT INTO reminder_template
 | 
				
			||||||
 | 
					        (guild_id,
 | 
				
			||||||
 | 
					         name,
 | 
				
			||||||
 | 
					         attachment,
 | 
				
			||||||
 | 
					         attachment_name,
 | 
				
			||||||
 | 
					         avatar,
 | 
				
			||||||
 | 
					         content,
 | 
				
			||||||
 | 
					         embed_author,
 | 
				
			||||||
 | 
					         embed_author_url,
 | 
				
			||||||
 | 
					         embed_color,
 | 
				
			||||||
 | 
					         embed_description,
 | 
				
			||||||
 | 
					         embed_footer,
 | 
				
			||||||
 | 
					         embed_footer_url,
 | 
				
			||||||
 | 
					         embed_image_url,
 | 
				
			||||||
 | 
					         embed_thumbnail_url,
 | 
				
			||||||
 | 
					         embed_title,
 | 
				
			||||||
 | 
					         embed_fields,
 | 
				
			||||||
 | 
					         interval_seconds,
 | 
				
			||||||
 | 
					         interval_days,
 | 
				
			||||||
 | 
					         interval_months,
 | 
				
			||||||
 | 
					         tts,
 | 
				
			||||||
 | 
					         username
 | 
				
			||||||
 | 
					        ) VALUES ((SELECT id FROM guilds WHERE guild = ?), ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?,
 | 
				
			||||||
 | 
					         ?, ?, ?, ?, ?, ?, ?)",
 | 
				
			||||||
 | 
					        id,
 | 
				
			||||||
 | 
					        name,
 | 
				
			||||||
 | 
					        reminder_template.attachment,
 | 
				
			||||||
 | 
					        reminder_template.attachment_name,
 | 
				
			||||||
 | 
					        reminder_template.avatar,
 | 
				
			||||||
 | 
					        reminder_template.content,
 | 
				
			||||||
 | 
					        reminder_template.embed_author,
 | 
				
			||||||
 | 
					        reminder_template.embed_author_url,
 | 
				
			||||||
 | 
					        reminder_template.embed_color,
 | 
				
			||||||
 | 
					        reminder_template.embed_description,
 | 
				
			||||||
 | 
					        reminder_template.embed_footer,
 | 
				
			||||||
 | 
					        reminder_template.embed_footer_url,
 | 
				
			||||||
 | 
					        reminder_template.embed_image_url,
 | 
				
			||||||
 | 
					        reminder_template.embed_thumbnail_url,
 | 
				
			||||||
 | 
					        reminder_template.embed_title,
 | 
				
			||||||
 | 
					        reminder_template.embed_fields,
 | 
				
			||||||
 | 
					        reminder_template.interval_seconds,
 | 
				
			||||||
 | 
					        reminder_template.interval_days,
 | 
				
			||||||
 | 
					        reminder_template.interval_months,
 | 
				
			||||||
 | 
					        reminder_template.tts,
 | 
				
			||||||
 | 
					        reminder_template.username,
 | 
				
			||||||
 | 
					    )
 | 
				
			||||||
 | 
					    .fetch_all(pool.inner())
 | 
				
			||||||
 | 
					    .await
 | 
				
			||||||
 | 
					    {
 | 
				
			||||||
 | 
					        Ok(_) => Ok(json!({})),
 | 
				
			||||||
 | 
					        Err(e) => {
 | 
				
			||||||
 | 
					            warn!("Could not create template for {}: {:?}", id, e);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            json_err!("Could not create template")
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					#[delete("/api/guild/<id>/templates", data = "<delete_reminder_template>")]
 | 
				
			||||||
 | 
					pub async fn delete_reminder_template(
 | 
				
			||||||
 | 
					    id: u64,
 | 
				
			||||||
 | 
					    delete_reminder_template: Json<DeleteReminderTemplate>,
 | 
				
			||||||
 | 
					    cookies: &CookieJar<'_>,
 | 
				
			||||||
 | 
					    ctx: &State<Context>,
 | 
				
			||||||
 | 
					    pool: &State<Pool<MySql>>,
 | 
				
			||||||
 | 
					) -> JsonResult {
 | 
				
			||||||
 | 
					    check_authorization(cookies, ctx.inner(), id).await?;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    match sqlx::query!(
 | 
				
			||||||
 | 
					        "DELETE FROM reminder_template WHERE guild_id = (SELECT id FROM guilds WHERE guild = ?) AND id = ?",
 | 
				
			||||||
 | 
					        id, delete_reminder_template.id
 | 
				
			||||||
 | 
					    )
 | 
				
			||||||
 | 
					    .fetch_all(pool.inner())
 | 
				
			||||||
 | 
					    .await
 | 
				
			||||||
 | 
					    {
 | 
				
			||||||
 | 
					        Ok(_) => {
 | 
				
			||||||
 | 
					            Ok(json!({}))
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					        Err(e) => {
 | 
				
			||||||
 | 
					            warn!("Could not delete template from {}: {:?}", id, e);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            json_err!("Could not delete template")
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
							
								
								
									
										2
									
								
								web/src/routes/dashboard/api/mod.rs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										2
									
								
								web/src/routes/dashboard/api/mod.rs
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,2 @@
 | 
				
			|||||||
 | 
					pub mod guild;
 | 
				
			||||||
 | 
					pub mod user;
 | 
				
			||||||
@@ -1,36 +1,14 @@
 | 
				
			|||||||
use std::env;
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
use chrono_tz::Tz;
 | 
					 | 
				
			||||||
use reqwest::Client;
 | 
					use reqwest::Client;
 | 
				
			||||||
use rocket::{
 | 
					use rocket::{
 | 
				
			||||||
    http::CookieJar,
 | 
					    http::CookieJar,
 | 
				
			||||||
    serde::json::{json, Json, Value as JsonValue},
 | 
					    serde::json::{json, Value as JsonValue},
 | 
				
			||||||
    State,
 | 
					    State,
 | 
				
			||||||
};
 | 
					};
 | 
				
			||||||
use serde::{Deserialize, Serialize};
 | 
					use serde::{Deserialize, Serialize};
 | 
				
			||||||
use serenity::{
 | 
					use serenity::model::{id::GuildId, permissions::Permissions};
 | 
				
			||||||
    client::Context,
 | 
					 | 
				
			||||||
    model::{
 | 
					 | 
				
			||||||
        id::{GuildId, RoleId},
 | 
					 | 
				
			||||||
        permissions::Permissions,
 | 
					 | 
				
			||||||
    },
 | 
					 | 
				
			||||||
};
 | 
					 | 
				
			||||||
use sqlx::{MySql, Pool};
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
use crate::consts::DISCORD_API;
 | 
					use crate::consts::DISCORD_API;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
#[derive(Serialize)]
 | 
					 | 
				
			||||||
struct UserInfo {
 | 
					 | 
				
			||||||
    name: String,
 | 
					 | 
				
			||||||
    patreon: bool,
 | 
					 | 
				
			||||||
    timezone: Option<String>,
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
#[derive(Deserialize)]
 | 
					 | 
				
			||||||
pub struct UpdateUser {
 | 
					 | 
				
			||||||
    timezone: String,
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
#[derive(Serialize)]
 | 
					#[derive(Serialize)]
 | 
				
			||||||
struct GuildInfo {
 | 
					struct GuildInfo {
 | 
				
			||||||
    id: String,
 | 
					    id: String,
 | 
				
			||||||
@@ -38,9 +16,8 @@ struct GuildInfo {
 | 
				
			|||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
#[derive(Deserialize)]
 | 
					#[derive(Deserialize)]
 | 
				
			||||||
pub struct PartialGuild {
 | 
					struct PartialGuild {
 | 
				
			||||||
    pub id: GuildId,
 | 
					    pub id: GuildId,
 | 
				
			||||||
    pub icon: Option<String>,
 | 
					 | 
				
			||||||
    pub name: String,
 | 
					    pub name: String,
 | 
				
			||||||
    #[serde(default)]
 | 
					    #[serde(default)]
 | 
				
			||||||
    pub owner: bool,
 | 
					    pub owner: bool,
 | 
				
			||||||
@@ -48,71 +25,10 @@ pub struct PartialGuild {
 | 
				
			|||||||
    pub permissions: Option<String>,
 | 
					    pub permissions: Option<String>,
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
#[get("/api/user")]
 | 
					 | 
				
			||||||
pub async fn get_user_info(
 | 
					 | 
				
			||||||
    cookies: &CookieJar<'_>,
 | 
					 | 
				
			||||||
    ctx: &State<Context>,
 | 
					 | 
				
			||||||
    pool: &State<Pool<MySql>>,
 | 
					 | 
				
			||||||
) -> JsonValue {
 | 
					 | 
				
			||||||
    if let Some(user_id) =
 | 
					 | 
				
			||||||
        cookies.get_private("userid").map(|u| u.value().parse::<u64>().ok()).flatten()
 | 
					 | 
				
			||||||
    {
 | 
					 | 
				
			||||||
        let member_res = GuildId(env::var("PATREON_GUILD_ID").unwrap().parse().unwrap())
 | 
					 | 
				
			||||||
            .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 user_info = UserInfo {
 | 
					 | 
				
			||||||
            name: cookies
 | 
					 | 
				
			||||||
                .get_private("username")
 | 
					 | 
				
			||||||
                .map_or("DiscordUser#0000".to_string(), |c| c.value().to_string()),
 | 
					 | 
				
			||||||
            patreon: member_res.map_or(false, |member| {
 | 
					 | 
				
			||||||
                member
 | 
					 | 
				
			||||||
                    .roles
 | 
					 | 
				
			||||||
                    .contains(&RoleId(env::var("PATREON_ROLE_ID").unwrap().parse().unwrap()))
 | 
					 | 
				
			||||||
            }),
 | 
					 | 
				
			||||||
            timezone,
 | 
					 | 
				
			||||||
        };
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
        json!(user_info)
 | 
					 | 
				
			||||||
    } else {
 | 
					 | 
				
			||||||
        json!({"error": "Not authorized"})
 | 
					 | 
				
			||||||
    }
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
#[patch("/api/user", data = "<user>")]
 | 
					 | 
				
			||||||
pub async fn update_user_info(
 | 
					 | 
				
			||||||
    cookies: &CookieJar<'_>,
 | 
					 | 
				
			||||||
    user: Json<UpdateUser>,
 | 
					 | 
				
			||||||
    pool: &State<Pool<MySql>>,
 | 
					 | 
				
			||||||
) -> JsonValue {
 | 
					 | 
				
			||||||
    if let Some(user_id) =
 | 
					 | 
				
			||||||
        cookies.get_private("userid").map(|u| u.value().parse::<u64>().ok()).flatten()
 | 
					 | 
				
			||||||
    {
 | 
					 | 
				
			||||||
        if user.timezone.parse::<Tz>().is_ok() {
 | 
					 | 
				
			||||||
            let _ = sqlx::query!(
 | 
					 | 
				
			||||||
                "UPDATE users SET timezone = ? WHERE user = ?",
 | 
					 | 
				
			||||||
                user.timezone,
 | 
					 | 
				
			||||||
                user_id,
 | 
					 | 
				
			||||||
            )
 | 
					 | 
				
			||||||
            .execute(pool.inner())
 | 
					 | 
				
			||||||
            .await;
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
            json!({})
 | 
					 | 
				
			||||||
        } else {
 | 
					 | 
				
			||||||
            json!({"error": "Timezone not recognized"})
 | 
					 | 
				
			||||||
        }
 | 
					 | 
				
			||||||
    } else {
 | 
					 | 
				
			||||||
        json!({"error": "Not authorized"})
 | 
					 | 
				
			||||||
    }
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
#[get("/api/user/guilds")]
 | 
					#[get("/api/user/guilds")]
 | 
				
			||||||
pub async fn get_user_guilds(cookies: &CookieJar<'_>, reqwest_client: &State<Client>) -> JsonValue {
 | 
					pub async fn get_user_guilds(cookies: &CookieJar<'_>, reqwest_client: &State<Client>) -> JsonValue {
 | 
				
			||||||
 | 
					    offline!(json!(vec![GuildInfo { id: "1".to_string(), name: "Guild".to_string() }]));
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    if let Some(access_token) = cookies.get_private("access_token") {
 | 
					    if let Some(access_token) = cookies.get_private("access_token") {
 | 
				
			||||||
        let request_res = reqwest_client
 | 
					        let request_res = reqwest_client
 | 
				
			||||||
            .get(format!("{}/users/@me/guilds", DISCORD_API))
 | 
					            .get(format!("{}/users/@me/guilds", DISCORD_API))
 | 
				
			||||||
							
								
								
									
										97
									
								
								web/src/routes/dashboard/api/user/mod.rs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										97
									
								
								web/src/routes/dashboard/api/user/mod.rs
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,97 @@
 | 
				
			|||||||
 | 
					mod guilds;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					use std::env;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					use chrono_tz::Tz;
 | 
				
			||||||
 | 
					pub use guilds::*;
 | 
				
			||||||
 | 
					use rocket::{
 | 
				
			||||||
 | 
					    http::CookieJar,
 | 
				
			||||||
 | 
					    serde::json::{json, Json, Value as JsonValue},
 | 
				
			||||||
 | 
					    State,
 | 
				
			||||||
 | 
					};
 | 
				
			||||||
 | 
					use serde::{Deserialize, Serialize};
 | 
				
			||||||
 | 
					use serenity::{
 | 
				
			||||||
 | 
					    client::Context,
 | 
				
			||||||
 | 
					    model::id::{GuildId, RoleId},
 | 
				
			||||||
 | 
					};
 | 
				
			||||||
 | 
					use sqlx::{MySql, Pool};
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					#[derive(Serialize)]
 | 
				
			||||||
 | 
					struct UserInfo {
 | 
				
			||||||
 | 
					    name: String,
 | 
				
			||||||
 | 
					    patreon: bool,
 | 
				
			||||||
 | 
					    timezone: Option<String>,
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					#[derive(Deserialize)]
 | 
				
			||||||
 | 
					pub struct UpdateUser {
 | 
				
			||||||
 | 
					    timezone: String,
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					#[get("/api/user")]
 | 
				
			||||||
 | 
					pub async fn get_user_info(
 | 
				
			||||||
 | 
					    cookies: &CookieJar<'_>,
 | 
				
			||||||
 | 
					    ctx: &State<Context>,
 | 
				
			||||||
 | 
					    pool: &State<Pool<MySql>>,
 | 
				
			||||||
 | 
					) -> JsonValue {
 | 
				
			||||||
 | 
					    offline!(json!(UserInfo { name: "Discord".to_string(), patreon: true, timezone: None }));
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    if let Some(user_id) =
 | 
				
			||||||
 | 
					        cookies.get_private("userid").map(|u| u.value().parse::<u64>().ok()).flatten()
 | 
				
			||||||
 | 
					    {
 | 
				
			||||||
 | 
					        let member_res = GuildId(env::var("PATREON_GUILD_ID").unwrap().parse().unwrap())
 | 
				
			||||||
 | 
					            .member(&ctx.inner(), user_id)
 | 
				
			||||||
 | 
					            .await;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        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
 | 
				
			||||||
 | 
					                .get_private("username")
 | 
				
			||||||
 | 
					                .map_or("DiscordUser#0000".to_string(), |c| c.value().to_string()),
 | 
				
			||||||
 | 
					            patreon: member_res.map_or(false, |member| {
 | 
				
			||||||
 | 
					                member
 | 
				
			||||||
 | 
					                    .roles
 | 
				
			||||||
 | 
					                    .contains(&RoleId(env::var("PATREON_ROLE_ID").unwrap().parse().unwrap()))
 | 
				
			||||||
 | 
					            }),
 | 
				
			||||||
 | 
					            timezone,
 | 
				
			||||||
 | 
					        };
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        json!(user_info)
 | 
				
			||||||
 | 
					    } else {
 | 
				
			||||||
 | 
					        json!({"error": "Not authorized"})
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					#[patch("/api/user", data = "<user>")]
 | 
				
			||||||
 | 
					pub async fn update_user_info(
 | 
				
			||||||
 | 
					    cookies: &CookieJar<'_>,
 | 
				
			||||||
 | 
					    user: Json<UpdateUser>,
 | 
				
			||||||
 | 
					    pool: &State<Pool<MySql>>,
 | 
				
			||||||
 | 
					) -> JsonValue {
 | 
				
			||||||
 | 
					    if let Some(user_id) =
 | 
				
			||||||
 | 
					        cookies.get_private("userid").map(|u| u.value().parse::<u64>().ok()).flatten()
 | 
				
			||||||
 | 
					    {
 | 
				
			||||||
 | 
					        if user.timezone.parse::<Tz>().is_ok() {
 | 
				
			||||||
 | 
					            let _ = sqlx::query!(
 | 
				
			||||||
 | 
					                "UPDATE users SET timezone = ? WHERE user = ?",
 | 
				
			||||||
 | 
					                user.timezone,
 | 
				
			||||||
 | 
					                user_id,
 | 
				
			||||||
 | 
					            )
 | 
				
			||||||
 | 
					            .execute(pool.inner())
 | 
				
			||||||
 | 
					            .await;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            json!({})
 | 
				
			||||||
 | 
					        } else {
 | 
				
			||||||
 | 
					            json!({"error": "Timezone not recognized"})
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					    } else {
 | 
				
			||||||
 | 
					        json!({"error": "Not authorized"})
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
							
								
								
									
										20
									
								
								web/src/routes/dashboard/api/user/models.rs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										20
									
								
								web/src/routes/dashboard/api/user/models.rs
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,20 @@
 | 
				
			|||||||
 | 
					use std::env;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					use chrono_tz::Tz;
 | 
				
			||||||
 | 
					use reqwest::Client;
 | 
				
			||||||
 | 
					use rocket::{
 | 
				
			||||||
 | 
					    http::CookieJar,
 | 
				
			||||||
 | 
					    serde::json::{json, Json, Value as JsonValue},
 | 
				
			||||||
 | 
					    State,
 | 
				
			||||||
 | 
					};
 | 
				
			||||||
 | 
					use serde::{Deserialize, Serialize};
 | 
				
			||||||
 | 
					use serenity::{
 | 
				
			||||||
 | 
					    client::Context,
 | 
				
			||||||
 | 
					    model::{
 | 
				
			||||||
 | 
					        id::{GuildId, RoleId},
 | 
				
			||||||
 | 
					        permissions::Permissions,
 | 
				
			||||||
 | 
					    },
 | 
				
			||||||
 | 
					};
 | 
				
			||||||
 | 
					use sqlx::{MySql, Pool};
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					use crate::{consts::DISCORD_API, routes::JsonResult};
 | 
				
			||||||
							
								
								
									
										29
									
								
								web/src/routes/dashboard/api/user/reminders.rs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										29
									
								
								web/src/routes/dashboard/api/user/reminders.rs
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,29 @@
 | 
				
			|||||||
 | 
					use std::env;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					use chrono_tz::Tz;
 | 
				
			||||||
 | 
					use reqwest::Client;
 | 
				
			||||||
 | 
					use rocket::{
 | 
				
			||||||
 | 
					    http::CookieJar,
 | 
				
			||||||
 | 
					    serde::json::{json, Json, Value as JsonValue},
 | 
				
			||||||
 | 
					    State,
 | 
				
			||||||
 | 
					};
 | 
				
			||||||
 | 
					use serde::{Deserialize, Serialize};
 | 
				
			||||||
 | 
					use serenity::{
 | 
				
			||||||
 | 
					    client::Context,
 | 
				
			||||||
 | 
					    model::{
 | 
				
			||||||
 | 
					        id::{GuildId, RoleId},
 | 
				
			||||||
 | 
					        permissions::Permissions,
 | 
				
			||||||
 | 
					    },
 | 
				
			||||||
 | 
					};
 | 
				
			||||||
 | 
					use sqlx::{MySql, Pool};
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					use crate::{consts::DISCORD_API, routes::JsonResult};
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					#[get("/api/user/reminders")]
 | 
				
			||||||
 | 
					pub async fn get_reminders(
 | 
				
			||||||
 | 
					    cookies: &CookieJar<'_>,
 | 
				
			||||||
 | 
					    ctx: &State<Context>,
 | 
				
			||||||
 | 
					    pool: &State<Pool<MySql>>,
 | 
				
			||||||
 | 
					) -> JsonResult {
 | 
				
			||||||
 | 
					    Ok(json! {})
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
@@ -6,13 +6,20 @@ use rocket::{
 | 
				
			|||||||
};
 | 
					};
 | 
				
			||||||
use serenity::{
 | 
					use serenity::{
 | 
				
			||||||
    client::Context,
 | 
					    client::Context,
 | 
				
			||||||
    model::id::{ChannelId, GuildId},
 | 
					    model::id::{ChannelId, GuildId, UserId},
 | 
				
			||||||
};
 | 
					};
 | 
				
			||||||
use sqlx::{MySql, Pool};
 | 
					use sqlx::{MySql, Pool};
 | 
				
			||||||
 | 
					
 | 
				
			||||||
use crate::routes::dashboard::{
 | 
					use crate::{
 | 
				
			||||||
    create_reminder, generate_uid, ImportBody, JsonResult, Reminder, ReminderCsv,
 | 
					    check_authorization,
 | 
				
			||||||
    ReminderTemplateCsv, TodoCsv,
 | 
					    guards::transaction::Transaction,
 | 
				
			||||||
 | 
					    routes::{
 | 
				
			||||||
 | 
					        dashboard::{
 | 
				
			||||||
 | 
					            create_reminder, generate_uid, ImportBody, Reminder, ReminderCsv, ReminderTemplateCsv,
 | 
				
			||||||
 | 
					            TodoCsv,
 | 
				
			||||||
 | 
					        },
 | 
				
			||||||
 | 
					        JsonResult,
 | 
				
			||||||
 | 
					    },
 | 
				
			||||||
};
 | 
					};
 | 
				
			||||||
 | 
					
 | 
				
			||||||
#[get("/api/guild/<id>/export/reminders")]
 | 
					#[get("/api/guild/<id>/export/reminders")]
 | 
				
			||||||
@@ -22,7 +29,7 @@ pub async fn export_reminders(
 | 
				
			|||||||
    ctx: &State<Context>,
 | 
					    ctx: &State<Context>,
 | 
				
			||||||
    pool: &State<Pool<MySql>>,
 | 
					    pool: &State<Pool<MySql>>,
 | 
				
			||||||
) -> JsonResult {
 | 
					) -> JsonResult {
 | 
				
			||||||
    check_authorization!(cookies, ctx.inner(), id);
 | 
					    check_authorization(cookies, ctx.inner(), id).await?;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    let mut csv_writer = WriterBuilder::new().quote_style(QuoteStyle::Always).from_writer(vec![]);
 | 
					    let mut csv_writer = WriterBuilder::new().quote_style(QuoteStyle::Always).from_writer(vec![]);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -58,6 +65,7 @@ pub async fn export_reminders(
 | 
				
			|||||||
                 reminders.enabled,
 | 
					                 reminders.enabled,
 | 
				
			||||||
                 reminders.expires,
 | 
					                 reminders.expires,
 | 
				
			||||||
                 reminders.interval_seconds,
 | 
					                 reminders.interval_seconds,
 | 
				
			||||||
 | 
					                 reminders.interval_days,
 | 
				
			||||||
                 reminders.interval_months,
 | 
					                 reminders.interval_months,
 | 
				
			||||||
                 reminders.name,
 | 
					                 reminders.name,
 | 
				
			||||||
                 reminders.restartable,
 | 
					                 reminders.restartable,
 | 
				
			||||||
@@ -66,7 +74,7 @@ pub async fn export_reminders(
 | 
				
			|||||||
                 reminders.utc_time
 | 
					                 reminders.utc_time
 | 
				
			||||||
                FROM reminders
 | 
					                FROM reminders
 | 
				
			||||||
                LEFT JOIN channels ON channels.id = reminders.channel_id
 | 
					                LEFT JOIN channels ON channels.id = reminders.channel_id
 | 
				
			||||||
                WHERE FIND_IN_SET(channels.channel, ?)",
 | 
					                WHERE FIND_IN_SET(channels.channel, ?) AND status = 'pending'",
 | 
				
			||||||
                channels
 | 
					                channels
 | 
				
			||||||
            )
 | 
					            )
 | 
				
			||||||
            .fetch_all(pool.inner())
 | 
					            .fetch_all(pool.inner())
 | 
				
			||||||
@@ -114,14 +122,14 @@ pub async fn export_reminders(
 | 
				
			|||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
#[put("/api/guild/<id>/export/reminders", data = "<body>")]
 | 
					#[put("/api/guild/<id>/export/reminders", data = "<body>")]
 | 
				
			||||||
pub async fn import_reminders(
 | 
					pub(crate) async fn import_reminders(
 | 
				
			||||||
    id: u64,
 | 
					    id: u64,
 | 
				
			||||||
    cookies: &CookieJar<'_>,
 | 
					    cookies: &CookieJar<'_>,
 | 
				
			||||||
    body: Json<ImportBody>,
 | 
					    body: Json<ImportBody>,
 | 
				
			||||||
    ctx: &State<Context>,
 | 
					    ctx: &State<Context>,
 | 
				
			||||||
    pool: &State<Pool<MySql>>,
 | 
					    mut transaction: Transaction<'_>,
 | 
				
			||||||
) -> JsonResult {
 | 
					) -> JsonResult {
 | 
				
			||||||
    check_authorization!(cookies, ctx.inner(), id);
 | 
					    check_authorization(cookies, ctx.inner(), id).await?;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    let user_id =
 | 
					    let user_id =
 | 
				
			||||||
        cookies.get_private("userid").map(|c| c.value().parse::<u64>().ok()).flatten().unwrap();
 | 
					        cookies.get_private("userid").map(|c| c.value().parse::<u64>().ok()).flatten().unwrap();
 | 
				
			||||||
@@ -129,6 +137,7 @@ pub async fn import_reminders(
 | 
				
			|||||||
    match base64::decode(&body.body) {
 | 
					    match base64::decode(&body.body) {
 | 
				
			||||||
        Ok(body) => {
 | 
					        Ok(body) => {
 | 
				
			||||||
            let mut reader = csv::Reader::from_reader(body.as_slice());
 | 
					            let mut reader = csv::Reader::from_reader(body.as_slice());
 | 
				
			||||||
 | 
					            let mut count = 0;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
            for result in reader.deserialize::<ReminderCsv>() {
 | 
					            for result in reader.deserialize::<ReminderCsv>() {
 | 
				
			||||||
                match result {
 | 
					                match result {
 | 
				
			||||||
@@ -159,6 +168,7 @@ pub async fn import_reminders(
 | 
				
			|||||||
                                    enabled: record.enabled,
 | 
					                                    enabled: record.enabled,
 | 
				
			||||||
                                    expires: record.expires,
 | 
					                                    expires: record.expires,
 | 
				
			||||||
                                    interval_seconds: record.interval_seconds,
 | 
					                                    interval_seconds: record.interval_seconds,
 | 
				
			||||||
 | 
					                                    interval_days: record.interval_days,
 | 
				
			||||||
                                    interval_months: record.interval_months,
 | 
					                                    interval_months: record.interval_months,
 | 
				
			||||||
                                    name: record.name,
 | 
					                                    name: record.name,
 | 
				
			||||||
                                    restartable: record.restartable,
 | 
					                                    restartable: record.restartable,
 | 
				
			||||||
@@ -170,12 +180,14 @@ pub async fn import_reminders(
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
                                create_reminder(
 | 
					                                create_reminder(
 | 
				
			||||||
                                    ctx.inner(),
 | 
					                                    ctx.inner(),
 | 
				
			||||||
                                    pool.inner(),
 | 
					                                    &mut transaction,
 | 
				
			||||||
                                    GuildId(id),
 | 
					                                    GuildId(id),
 | 
				
			||||||
                                    UserId(user_id),
 | 
					                                    UserId(user_id),
 | 
				
			||||||
                                    reminder,
 | 
					                                    reminder,
 | 
				
			||||||
                                )
 | 
					                                )
 | 
				
			||||||
                                .await?;
 | 
					                                .await?;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                                count += 1;
 | 
				
			||||||
                            }
 | 
					                            }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
                            Err(_) => {
 | 
					                            Err(_) => {
 | 
				
			||||||
@@ -195,7 +207,16 @@ pub async fn import_reminders(
 | 
				
			|||||||
                }
 | 
					                }
 | 
				
			||||||
            }
 | 
					            }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
            Ok(json!({}))
 | 
					            match transaction.commit().await {
 | 
				
			||||||
 | 
					                Ok(_) => Ok(json!({
 | 
				
			||||||
 | 
					                    "message": format!("Imported {} reminders", count)
 | 
				
			||||||
 | 
					                })),
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                Err(e) => {
 | 
				
			||||||
 | 
					                    warn!("Failed to commit transaction: {:?}", e);
 | 
				
			||||||
 | 
					                    json_err!("Couldn't commit transaction")
 | 
				
			||||||
 | 
					                }
 | 
				
			||||||
 | 
					            }
 | 
				
			||||||
        }
 | 
					        }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        Err(_) => {
 | 
					        Err(_) => {
 | 
				
			||||||
@@ -211,7 +232,7 @@ pub async fn export_todos(
 | 
				
			|||||||
    ctx: &State<Context>,
 | 
					    ctx: &State<Context>,
 | 
				
			||||||
    pool: &State<Pool<MySql>>,
 | 
					    pool: &State<Pool<MySql>>,
 | 
				
			||||||
) -> JsonResult {
 | 
					) -> JsonResult {
 | 
				
			||||||
    check_authorization!(cookies, ctx.inner(), id);
 | 
					    check_authorization(cookies, ctx.inner(), id).await?;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    let mut csv_writer = WriterBuilder::new().quote_style(QuoteStyle::Always).from_writer(vec![]);
 | 
					    let mut csv_writer = WriterBuilder::new().quote_style(QuoteStyle::Always).from_writer(vec![]);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -266,7 +287,7 @@ pub async fn import_todos(
 | 
				
			|||||||
    ctx: &State<Context>,
 | 
					    ctx: &State<Context>,
 | 
				
			||||||
    pool: &State<Pool<MySql>>,
 | 
					    pool: &State<Pool<MySql>>,
 | 
				
			||||||
) -> JsonResult {
 | 
					) -> JsonResult {
 | 
				
			||||||
    check_authorization!(cookies, ctx.inner(), id);
 | 
					    check_authorization(cookies, ctx.inner(), id).await?;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    let channels_res = GuildId(id).channels(&ctx.inner()).await;
 | 
					    let channels_res = GuildId(id).channels(&ctx.inner()).await;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -318,13 +339,6 @@ pub async fn import_todos(
 | 
				
			|||||||
                    }
 | 
					                    }
 | 
				
			||||||
                }
 | 
					                }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
                let _ = sqlx::query!(
 | 
					 | 
				
			||||||
                    "DELETE FROM todos WHERE guild_id = (SELECT id FROM guilds WHERE guild = ?)",
 | 
					 | 
				
			||||||
                    id
 | 
					 | 
				
			||||||
                )
 | 
					 | 
				
			||||||
                .execute(pool.inner())
 | 
					 | 
				
			||||||
                .await;
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
                let query_str = format!(
 | 
					                let query_str = format!(
 | 
				
			||||||
                    "INSERT INTO todos (value, channel_id, guild_id) VALUES {}",
 | 
					                    "INSERT INTO todos (value, channel_id, guild_id) VALUES {}",
 | 
				
			||||||
                    vec![query_placeholder].repeat(query_params.len()).join(",")
 | 
					                    vec![query_placeholder].repeat(query_params.len()).join(",")
 | 
				
			||||||
@@ -368,7 +382,7 @@ pub async fn export_reminder_templates(
 | 
				
			|||||||
    ctx: &State<Context>,
 | 
					    ctx: &State<Context>,
 | 
				
			||||||
    pool: &State<Pool<MySql>>,
 | 
					    pool: &State<Pool<MySql>>,
 | 
				
			||||||
) -> JsonResult {
 | 
					) -> JsonResult {
 | 
				
			||||||
    check_authorization!(cookies, ctx.inner(), id);
 | 
					    check_authorization(cookies, ctx.inner(), id).await?;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    let mut csv_writer = WriterBuilder::new().quote_style(QuoteStyle::Always).from_writer(vec![]);
 | 
					    let mut csv_writer = WriterBuilder::new().quote_style(QuoteStyle::Always).from_writer(vec![]);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -390,6 +404,9 @@ pub async fn export_reminder_templates(
 | 
				
			|||||||
         embed_thumbnail_url,
 | 
					         embed_thumbnail_url,
 | 
				
			||||||
         embed_title,
 | 
					         embed_title,
 | 
				
			||||||
         embed_fields,
 | 
					         embed_fields,
 | 
				
			||||||
 | 
					         interval_seconds,
 | 
				
			||||||
 | 
					         interval_days,
 | 
				
			||||||
 | 
					         interval_months,
 | 
				
			||||||
         tts,
 | 
					         tts,
 | 
				
			||||||
         username
 | 
					         username
 | 
				
			||||||
        FROM reminder_template WHERE guild_id = (SELECT id FROM guilds WHERE guild = ?)",
 | 
					        FROM reminder_template WHERE guild_id = (SELECT id FROM guilds WHERE guild = ?)",
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -1,528 +0,0 @@
 | 
				
			|||||||
use std::env;
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
use rocket::{
 | 
					 | 
				
			||||||
    http::CookieJar,
 | 
					 | 
				
			||||||
    serde::json::{json, Json},
 | 
					 | 
				
			||||||
    State,
 | 
					 | 
				
			||||||
};
 | 
					 | 
				
			||||||
use serde::Serialize;
 | 
					 | 
				
			||||||
use serenity::{
 | 
					 | 
				
			||||||
    client::Context,
 | 
					 | 
				
			||||||
    model::{
 | 
					 | 
				
			||||||
        channel::GuildChannel,
 | 
					 | 
				
			||||||
        id::{ChannelId, GuildId, RoleId},
 | 
					 | 
				
			||||||
    },
 | 
					 | 
				
			||||||
};
 | 
					 | 
				
			||||||
use sqlx::{MySql, Pool};
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
use crate::{
 | 
					 | 
				
			||||||
    consts::{
 | 
					 | 
				
			||||||
        MAX_CONTENT_LENGTH, MAX_EMBED_AUTHOR_LENGTH, MAX_EMBED_DESCRIPTION_LENGTH,
 | 
					 | 
				
			||||||
        MAX_EMBED_FIELDS, MAX_EMBED_FIELD_TITLE_LENGTH, MAX_EMBED_FIELD_VALUE_LENGTH,
 | 
					 | 
				
			||||||
        MAX_EMBED_FOOTER_LENGTH, MAX_EMBED_TITLE_LENGTH, MAX_URL_LENGTH, MAX_USERNAME_LENGTH,
 | 
					 | 
				
			||||||
    },
 | 
					 | 
				
			||||||
    routes::dashboard::{
 | 
					 | 
				
			||||||
        create_database_channel, create_reminder, template_name_default, DeleteReminder,
 | 
					 | 
				
			||||||
        DeleteReminderTemplate, JsonResult, PatchReminder, Reminder, ReminderTemplate,
 | 
					 | 
				
			||||||
    },
 | 
					 | 
				
			||||||
};
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
#[derive(Serialize)]
 | 
					 | 
				
			||||||
struct ChannelInfo {
 | 
					 | 
				
			||||||
    id: String,
 | 
					 | 
				
			||||||
    name: String,
 | 
					 | 
				
			||||||
    webhook_avatar: Option<String>,
 | 
					 | 
				
			||||||
    webhook_name: Option<String>,
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
#[get("/api/guild/<id>/patreon")]
 | 
					 | 
				
			||||||
pub async fn get_guild_patreon(
 | 
					 | 
				
			||||||
    id: u64,
 | 
					 | 
				
			||||||
    cookies: &CookieJar<'_>,
 | 
					 | 
				
			||||||
    ctx: &State<Context>,
 | 
					 | 
				
			||||||
) -> JsonResult {
 | 
					 | 
				
			||||||
    check_authorization!(cookies, ctx.inner(), id);
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    match GuildId(id).to_guild_cached(ctx.inner()) {
 | 
					 | 
				
			||||||
        Some(guild) => {
 | 
					 | 
				
			||||||
            let member_res = GuildId(env::var("PATREON_GUILD_ID").unwrap().parse().unwrap())
 | 
					 | 
				
			||||||
                .member(&ctx.inner(), guild.owner_id)
 | 
					 | 
				
			||||||
                .await;
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
            let patreon = member_res.map_or(false, |member| {
 | 
					 | 
				
			||||||
                member
 | 
					 | 
				
			||||||
                    .roles
 | 
					 | 
				
			||||||
                    .contains(&RoleId(env::var("PATREON_ROLE_ID").unwrap().parse().unwrap()))
 | 
					 | 
				
			||||||
            });
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
            Ok(json!({ "patreon": patreon }))
 | 
					 | 
				
			||||||
        }
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
        None => json_err!("Bot not in guild"),
 | 
					 | 
				
			||||||
    }
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
#[get("/api/guild/<id>/channels")]
 | 
					 | 
				
			||||||
pub async fn get_guild_channels(
 | 
					 | 
				
			||||||
    id: u64,
 | 
					 | 
				
			||||||
    cookies: &CookieJar<'_>,
 | 
					 | 
				
			||||||
    ctx: &State<Context>,
 | 
					 | 
				
			||||||
) -> JsonResult {
 | 
					 | 
				
			||||||
    check_authorization!(cookies, ctx.inner(), id);
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    match GuildId(id).to_guild_cached(ctx.inner()) {
 | 
					 | 
				
			||||||
        Some(guild) => {
 | 
					 | 
				
			||||||
            let mut channels = guild
 | 
					 | 
				
			||||||
                .channels
 | 
					 | 
				
			||||||
                .iter()
 | 
					 | 
				
			||||||
                .filter_map(|(id, channel)| channel.to_owned().guild().map(|c| (id.to_owned(), c)))
 | 
					 | 
				
			||||||
                .filter(|(_, channel)| channel.is_text_based())
 | 
					 | 
				
			||||||
                .collect::<Vec<(ChannelId, GuildChannel)>>();
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
            channels.sort_by(|(_, c1), (_, c2)| c1.position.cmp(&c2.position));
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
            let channel_info = channels
 | 
					 | 
				
			||||||
                .iter()
 | 
					 | 
				
			||||||
                .map(|(channel_id, channel)| ChannelInfo {
 | 
					 | 
				
			||||||
                    name: channel.name.to_string(),
 | 
					 | 
				
			||||||
                    id: channel_id.to_string(),
 | 
					 | 
				
			||||||
                    webhook_avatar: None,
 | 
					 | 
				
			||||||
                    webhook_name: None,
 | 
					 | 
				
			||||||
                })
 | 
					 | 
				
			||||||
                .collect::<Vec<ChannelInfo>>();
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
            Ok(json!(channel_info))
 | 
					 | 
				
			||||||
        }
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
        None => json_err!("Bot not in guild"),
 | 
					 | 
				
			||||||
    }
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
#[derive(Serialize)]
 | 
					 | 
				
			||||||
struct RoleInfo {
 | 
					 | 
				
			||||||
    id: String,
 | 
					 | 
				
			||||||
    name: String,
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
#[get("/api/guild/<id>/roles")]
 | 
					 | 
				
			||||||
pub async fn get_guild_roles(id: u64, cookies: &CookieJar<'_>, ctx: &State<Context>) -> JsonResult {
 | 
					 | 
				
			||||||
    check_authorization!(cookies, ctx.inner(), id);
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    let roles_res = ctx.cache.guild_roles(id);
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    match roles_res {
 | 
					 | 
				
			||||||
        Some(roles) => {
 | 
					 | 
				
			||||||
            let roles = roles
 | 
					 | 
				
			||||||
                .iter()
 | 
					 | 
				
			||||||
                .map(|(_, r)| RoleInfo { id: r.id.to_string(), name: r.name.to_string() })
 | 
					 | 
				
			||||||
                .collect::<Vec<RoleInfo>>();
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
            Ok(json!(roles))
 | 
					 | 
				
			||||||
        }
 | 
					 | 
				
			||||||
        None => {
 | 
					 | 
				
			||||||
            warn!("Could not fetch roles from {}", id);
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
            json_err!("Could not get roles")
 | 
					 | 
				
			||||||
        }
 | 
					 | 
				
			||||||
    }
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
#[get("/api/guild/<id>/templates")]
 | 
					 | 
				
			||||||
pub async fn get_reminder_templates(
 | 
					 | 
				
			||||||
    id: u64,
 | 
					 | 
				
			||||||
    cookies: &CookieJar<'_>,
 | 
					 | 
				
			||||||
    ctx: &State<Context>,
 | 
					 | 
				
			||||||
    pool: &State<Pool<MySql>>,
 | 
					 | 
				
			||||||
) -> JsonResult {
 | 
					 | 
				
			||||||
    check_authorization!(cookies, ctx.inner(), id);
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    match sqlx::query_as_unchecked!(
 | 
					 | 
				
			||||||
        ReminderTemplate,
 | 
					 | 
				
			||||||
        "SELECT * FROM reminder_template WHERE guild_id = (SELECT id FROM guilds WHERE guild = ?)",
 | 
					 | 
				
			||||||
        id
 | 
					 | 
				
			||||||
    )
 | 
					 | 
				
			||||||
    .fetch_all(pool.inner())
 | 
					 | 
				
			||||||
    .await
 | 
					 | 
				
			||||||
    {
 | 
					 | 
				
			||||||
        Ok(templates) => Ok(json!(templates)),
 | 
					 | 
				
			||||||
        Err(e) => {
 | 
					 | 
				
			||||||
            warn!("Could not fetch templates from {}: {:?}", id, e);
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
            json_err!("Could not get templates")
 | 
					 | 
				
			||||||
        }
 | 
					 | 
				
			||||||
    }
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
#[post("/api/guild/<id>/templates", data = "<reminder_template>")]
 | 
					 | 
				
			||||||
pub async fn create_reminder_template(
 | 
					 | 
				
			||||||
    id: u64,
 | 
					 | 
				
			||||||
    reminder_template: Json<ReminderTemplate>,
 | 
					 | 
				
			||||||
    cookies: &CookieJar<'_>,
 | 
					 | 
				
			||||||
    ctx: &State<Context>,
 | 
					 | 
				
			||||||
    pool: &State<Pool<MySql>>,
 | 
					 | 
				
			||||||
) -> JsonResult {
 | 
					 | 
				
			||||||
    check_authorization!(cookies, ctx.inner(), id);
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    // validate lengths
 | 
					 | 
				
			||||||
    check_length!(MAX_CONTENT_LENGTH, reminder_template.content);
 | 
					 | 
				
			||||||
    check_length!(MAX_EMBED_DESCRIPTION_LENGTH, reminder_template.embed_description);
 | 
					 | 
				
			||||||
    check_length!(MAX_EMBED_TITLE_LENGTH, reminder_template.embed_title);
 | 
					 | 
				
			||||||
    check_length!(MAX_EMBED_AUTHOR_LENGTH, reminder_template.embed_author);
 | 
					 | 
				
			||||||
    check_length!(MAX_EMBED_FOOTER_LENGTH, reminder_template.embed_footer);
 | 
					 | 
				
			||||||
    check_length_opt!(MAX_EMBED_FIELDS, reminder_template.embed_fields);
 | 
					 | 
				
			||||||
    if let Some(fields) = &reminder_template.embed_fields {
 | 
					 | 
				
			||||||
        for field in &fields.0 {
 | 
					 | 
				
			||||||
            check_length!(MAX_EMBED_FIELD_VALUE_LENGTH, field.value);
 | 
					 | 
				
			||||||
            check_length!(MAX_EMBED_FIELD_TITLE_LENGTH, field.title);
 | 
					 | 
				
			||||||
        }
 | 
					 | 
				
			||||||
    }
 | 
					 | 
				
			||||||
    check_length_opt!(MAX_USERNAME_LENGTH, reminder_template.username);
 | 
					 | 
				
			||||||
    check_length_opt!(
 | 
					 | 
				
			||||||
        MAX_URL_LENGTH,
 | 
					 | 
				
			||||||
        reminder_template.embed_footer_url,
 | 
					 | 
				
			||||||
        reminder_template.embed_thumbnail_url,
 | 
					 | 
				
			||||||
        reminder_template.embed_author_url,
 | 
					 | 
				
			||||||
        reminder_template.embed_image_url,
 | 
					 | 
				
			||||||
        reminder_template.avatar
 | 
					 | 
				
			||||||
    );
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    // validate urls
 | 
					 | 
				
			||||||
    check_url_opt!(
 | 
					 | 
				
			||||||
        reminder_template.embed_footer_url,
 | 
					 | 
				
			||||||
        reminder_template.embed_thumbnail_url,
 | 
					 | 
				
			||||||
        reminder_template.embed_author_url,
 | 
					 | 
				
			||||||
        reminder_template.embed_image_url,
 | 
					 | 
				
			||||||
        reminder_template.avatar
 | 
					 | 
				
			||||||
    );
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    let name = if reminder_template.name.is_empty() {
 | 
					 | 
				
			||||||
        template_name_default()
 | 
					 | 
				
			||||||
    } else {
 | 
					 | 
				
			||||||
        reminder_template.name.clone()
 | 
					 | 
				
			||||||
    };
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    match sqlx::query!(
 | 
					 | 
				
			||||||
        "INSERT INTO reminder_template
 | 
					 | 
				
			||||||
        (guild_id,
 | 
					 | 
				
			||||||
         name,
 | 
					 | 
				
			||||||
         attachment,
 | 
					 | 
				
			||||||
         attachment_name,
 | 
					 | 
				
			||||||
         avatar,
 | 
					 | 
				
			||||||
         content,
 | 
					 | 
				
			||||||
         embed_author,
 | 
					 | 
				
			||||||
         embed_author_url,
 | 
					 | 
				
			||||||
         embed_color,
 | 
					 | 
				
			||||||
         embed_description,
 | 
					 | 
				
			||||||
         embed_footer,
 | 
					 | 
				
			||||||
         embed_footer_url,
 | 
					 | 
				
			||||||
         embed_image_url,
 | 
					 | 
				
			||||||
         embed_thumbnail_url,
 | 
					 | 
				
			||||||
         embed_title,
 | 
					 | 
				
			||||||
         embed_fields,
 | 
					 | 
				
			||||||
         tts,
 | 
					 | 
				
			||||||
         username
 | 
					 | 
				
			||||||
        ) VALUES ((SELECT id FROM guilds WHERE guild = ?), ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
 | 
					 | 
				
			||||||
        id, name,
 | 
					 | 
				
			||||||
        reminder_template.attachment,
 | 
					 | 
				
			||||||
        reminder_template.attachment_name,
 | 
					 | 
				
			||||||
        reminder_template.avatar,
 | 
					 | 
				
			||||||
        reminder_template.content,
 | 
					 | 
				
			||||||
        reminder_template.embed_author,
 | 
					 | 
				
			||||||
        reminder_template.embed_author_url,
 | 
					 | 
				
			||||||
        reminder_template.embed_color,
 | 
					 | 
				
			||||||
        reminder_template.embed_description,
 | 
					 | 
				
			||||||
        reminder_template.embed_footer,
 | 
					 | 
				
			||||||
        reminder_template.embed_footer_url,
 | 
					 | 
				
			||||||
        reminder_template.embed_image_url,
 | 
					 | 
				
			||||||
        reminder_template.embed_thumbnail_url,
 | 
					 | 
				
			||||||
        reminder_template.embed_title,
 | 
					 | 
				
			||||||
        reminder_template.embed_fields,
 | 
					 | 
				
			||||||
        reminder_template.tts,
 | 
					 | 
				
			||||||
        reminder_template.username,
 | 
					 | 
				
			||||||
    )
 | 
					 | 
				
			||||||
    .fetch_all(pool.inner())
 | 
					 | 
				
			||||||
    .await
 | 
					 | 
				
			||||||
    {
 | 
					 | 
				
			||||||
        Ok(_) => {
 | 
					 | 
				
			||||||
            Ok(json!({}))
 | 
					 | 
				
			||||||
        }
 | 
					 | 
				
			||||||
        Err(e) => {
 | 
					 | 
				
			||||||
            warn!("Could not fetch templates from {}: {:?}", id, e);
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
            json_err!("Could not get templates")
 | 
					 | 
				
			||||||
        }
 | 
					 | 
				
			||||||
    }
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
#[delete("/api/guild/<id>/templates", data = "<delete_reminder_template>")]
 | 
					 | 
				
			||||||
pub async fn delete_reminder_template(
 | 
					 | 
				
			||||||
    id: u64,
 | 
					 | 
				
			||||||
    delete_reminder_template: Json<DeleteReminderTemplate>,
 | 
					 | 
				
			||||||
    cookies: &CookieJar<'_>,
 | 
					 | 
				
			||||||
    ctx: &State<Context>,
 | 
					 | 
				
			||||||
    pool: &State<Pool<MySql>>,
 | 
					 | 
				
			||||||
) -> JsonResult {
 | 
					 | 
				
			||||||
    check_authorization!(cookies, ctx.inner(), id);
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    match sqlx::query!(
 | 
					 | 
				
			||||||
        "DELETE FROM reminder_template WHERE guild_id = (SELECT id FROM guilds WHERE guild = ?) AND id = ?",
 | 
					 | 
				
			||||||
        id, delete_reminder_template.id
 | 
					 | 
				
			||||||
    )
 | 
					 | 
				
			||||||
    .fetch_all(pool.inner())
 | 
					 | 
				
			||||||
    .await
 | 
					 | 
				
			||||||
    {
 | 
					 | 
				
			||||||
        Ok(_) => {
 | 
					 | 
				
			||||||
            Ok(json!({}))
 | 
					 | 
				
			||||||
        }
 | 
					 | 
				
			||||||
        Err(e) => {
 | 
					 | 
				
			||||||
            warn!("Could not delete template from {}: {:?}", id, e);
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
            json_err!("Could not delete template")
 | 
					 | 
				
			||||||
        }
 | 
					 | 
				
			||||||
    }
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
#[post("/api/guild/<id>/reminders", data = "<reminder>")]
 | 
					 | 
				
			||||||
pub async fn create_guild_reminder(
 | 
					 | 
				
			||||||
    id: u64,
 | 
					 | 
				
			||||||
    reminder: Json<Reminder>,
 | 
					 | 
				
			||||||
    cookies: &CookieJar<'_>,
 | 
					 | 
				
			||||||
    serenity_context: &State<Context>,
 | 
					 | 
				
			||||||
    pool: &State<Pool<MySql>>,
 | 
					 | 
				
			||||||
) -> JsonResult {
 | 
					 | 
				
			||||||
    check_authorization!(cookies, serenity_context.inner(), id);
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    let user_id =
 | 
					 | 
				
			||||||
        cookies.get_private("userid").map(|c| c.value().parse::<u64>().ok()).flatten().unwrap();
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    create_reminder(
 | 
					 | 
				
			||||||
        serenity_context.inner(),
 | 
					 | 
				
			||||||
        pool.inner(),
 | 
					 | 
				
			||||||
        GuildId(id),
 | 
					 | 
				
			||||||
        UserId(user_id),
 | 
					 | 
				
			||||||
        reminder.into_inner(),
 | 
					 | 
				
			||||||
    )
 | 
					 | 
				
			||||||
    .await
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
#[get("/api/guild/<id>/reminders")]
 | 
					 | 
				
			||||||
pub async fn get_reminders(id: u64, ctx: &State<Context>, pool: &State<Pool<MySql>>) -> JsonResult {
 | 
					 | 
				
			||||||
    let channels_res = GuildId(id).channels(&ctx.inner()).await;
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    match channels_res {
 | 
					 | 
				
			||||||
        Ok(channels) => {
 | 
					 | 
				
			||||||
            let channels = channels
 | 
					 | 
				
			||||||
                .keys()
 | 
					 | 
				
			||||||
                .into_iter()
 | 
					 | 
				
			||||||
                .map(|k| k.as_u64().to_string())
 | 
					 | 
				
			||||||
                .collect::<Vec<String>>()
 | 
					 | 
				
			||||||
                .join(",");
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
            sqlx::query_as_unchecked!(
 | 
					 | 
				
			||||||
                Reminder,
 | 
					 | 
				
			||||||
                "SELECT
 | 
					 | 
				
			||||||
                 reminders.attachment,
 | 
					 | 
				
			||||||
                 reminders.attachment_name,
 | 
					 | 
				
			||||||
                 reminders.avatar,
 | 
					 | 
				
			||||||
                 channels.channel,
 | 
					 | 
				
			||||||
                 reminders.content,
 | 
					 | 
				
			||||||
                 reminders.embed_author,
 | 
					 | 
				
			||||||
                 reminders.embed_author_url,
 | 
					 | 
				
			||||||
                 reminders.embed_color,
 | 
					 | 
				
			||||||
                 reminders.embed_description,
 | 
					 | 
				
			||||||
                 reminders.embed_footer,
 | 
					 | 
				
			||||||
                 reminders.embed_footer_url,
 | 
					 | 
				
			||||||
                 reminders.embed_image_url,
 | 
					 | 
				
			||||||
                 reminders.embed_thumbnail_url,
 | 
					 | 
				
			||||||
                 reminders.embed_title,
 | 
					 | 
				
			||||||
                 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 FIND_IN_SET(channels.channel, ?)",
 | 
					 | 
				
			||||||
                channels
 | 
					 | 
				
			||||||
            )
 | 
					 | 
				
			||||||
            .fetch_all(pool.inner())
 | 
					 | 
				
			||||||
            .await
 | 
					 | 
				
			||||||
            .map(|r| Ok(json!(r)))
 | 
					 | 
				
			||||||
            .unwrap_or_else(|e| {
 | 
					 | 
				
			||||||
                warn!("Failed to complete SQL query: {:?}", e);
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
                json_err!("Could not load reminders")
 | 
					 | 
				
			||||||
            })
 | 
					 | 
				
			||||||
        }
 | 
					 | 
				
			||||||
        Err(e) => {
 | 
					 | 
				
			||||||
            warn!("Could not fetch channels from {}: {:?}", id, e);
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
            Ok(json!([]))
 | 
					 | 
				
			||||||
        }
 | 
					 | 
				
			||||||
    }
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
#[patch("/api/guild/<id>/reminders", data = "<reminder>")]
 | 
					 | 
				
			||||||
pub async fn edit_reminder(
 | 
					 | 
				
			||||||
    id: u64,
 | 
					 | 
				
			||||||
    reminder: Json<PatchReminder>,
 | 
					 | 
				
			||||||
    serenity_context: &State<Context>,
 | 
					 | 
				
			||||||
    pool: &State<Pool<MySql>>,
 | 
					 | 
				
			||||||
) -> JsonResult {
 | 
					 | 
				
			||||||
    let mut error = vec![];
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    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.channel > 0 {
 | 
					 | 
				
			||||||
        let channel = ChannelId(reminder.channel).to_channel_cached(&serenity_context.inner());
 | 
					 | 
				
			||||||
        match channel {
 | 
					 | 
				
			||||||
            Some(channel) => {
 | 
					 | 
				
			||||||
                let channel_matches_guild = channel.guild().map_or(false, |c| c.guild_id.0 == id);
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
                if !channel_matches_guild {
 | 
					 | 
				
			||||||
                    warn!(
 | 
					 | 
				
			||||||
                        "Error in `edit_reminder`: channel {:?} not found for guild {}",
 | 
					 | 
				
			||||||
                        reminder.channel, id
 | 
					 | 
				
			||||||
                    );
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
                    return Err(json!({"error": "Channel not found"}));
 | 
					 | 
				
			||||||
                }
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
                let channel = create_database_channel(
 | 
					 | 
				
			||||||
                    serenity_context.inner(),
 | 
					 | 
				
			||||||
                    ChannelId(reminder.channel),
 | 
					 | 
				
			||||||
                    pool.inner(),
 | 
					 | 
				
			||||||
                )
 | 
					 | 
				
			||||||
                .await;
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
                if let Err(e) = channel {
 | 
					 | 
				
			||||||
                    warn!("`create_database_channel` returned an error code: {:?}", e);
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
                    return Err(
 | 
					 | 
				
			||||||
                        json!({"error": "Failed to configure channel for reminders. Please check the bot permissions"}),
 | 
					 | 
				
			||||||
                    );
 | 
					 | 
				
			||||||
                }
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
                let channel = channel.unwrap();
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
                match sqlx::query!(
 | 
					 | 
				
			||||||
                    "UPDATE reminders SET channel_id = ? WHERE uid = ?",
 | 
					 | 
				
			||||||
                    channel,
 | 
					 | 
				
			||||||
                    reminder.uid
 | 
					 | 
				
			||||||
                )
 | 
					 | 
				
			||||||
                .execute(pool.inner())
 | 
					 | 
				
			||||||
                .await
 | 
					 | 
				
			||||||
                {
 | 
					 | 
				
			||||||
                    Ok(_) => {}
 | 
					 | 
				
			||||||
                    Err(e) => {
 | 
					 | 
				
			||||||
                        warn!("Error setting channel: {:?}", e);
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
                        error.push("Couldn't set channel".to_string())
 | 
					 | 
				
			||||||
                    }
 | 
					 | 
				
			||||||
                }
 | 
					 | 
				
			||||||
            }
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
            None => {
 | 
					 | 
				
			||||||
                warn!(
 | 
					 | 
				
			||||||
                    "Error in `edit_reminder`: channel {:?} not found for guild {}",
 | 
					 | 
				
			||||||
                    reminder.channel, id
 | 
					 | 
				
			||||||
                );
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
                return Err(json!({"error": "Channel not found"}));
 | 
					 | 
				
			||||||
            }
 | 
					 | 
				
			||||||
        }
 | 
					 | 
				
			||||||
    }
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    match sqlx::query_as_unchecked!(
 | 
					 | 
				
			||||||
        Reminder,
 | 
					 | 
				
			||||||
        "SELECT reminders.attachment,
 | 
					 | 
				
			||||||
         reminders.attachment_name,
 | 
					 | 
				
			||||||
         reminders.avatar,
 | 
					 | 
				
			||||||
         channels.channel,
 | 
					 | 
				
			||||||
         reminders.content,
 | 
					 | 
				
			||||||
         reminders.embed_author,
 | 
					 | 
				
			||||||
         reminders.embed_author_url,
 | 
					 | 
				
			||||||
         reminders.embed_color,
 | 
					 | 
				
			||||||
         reminders.embed_description,
 | 
					 | 
				
			||||||
         reminders.embed_footer,
 | 
					 | 
				
			||||||
         reminders.embed_footer_url,
 | 
					 | 
				
			||||||
         reminders.embed_image_url,
 | 
					 | 
				
			||||||
         reminders.embed_thumbnail_url,
 | 
					 | 
				
			||||||
         reminders.embed_title,
 | 
					 | 
				
			||||||
         reminders.embed_fields,
 | 
					 | 
				
			||||||
         reminders.enabled,
 | 
					 | 
				
			||||||
         reminders.expires,
 | 
					 | 
				
			||||||
         reminders.interval_seconds,
 | 
					 | 
				
			||||||
         reminders.interval_months,
 | 
					 | 
				
			||||||
         reminders.name,
 | 
					 | 
				
			||||||
         reminders.restartable,
 | 
					 | 
				
			||||||
         reminders.tts,
 | 
					 | 
				
			||||||
         reminders.uid,
 | 
					 | 
				
			||||||
         reminders.username,
 | 
					 | 
				
			||||||
         reminders.utc_time
 | 
					 | 
				
			||||||
        FROM reminders
 | 
					 | 
				
			||||||
        LEFT JOIN channels ON channels.id = reminders.channel_id
 | 
					 | 
				
			||||||
        WHERE uid = ?",
 | 
					 | 
				
			||||||
        reminder.uid
 | 
					 | 
				
			||||||
    )
 | 
					 | 
				
			||||||
    .fetch_one(pool.inner())
 | 
					 | 
				
			||||||
    .await
 | 
					 | 
				
			||||||
    {
 | 
					 | 
				
			||||||
        Ok(reminder) => Ok(json!({"reminder": reminder, "errors": error})),
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
        Err(e) => {
 | 
					 | 
				
			||||||
            warn!("Error exiting `edit_reminder': {:?}", e);
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
            Err(json!({"reminder": Option::<Reminder>::None, "errors": vec!["Unknown error"]}))
 | 
					 | 
				
			||||||
        }
 | 
					 | 
				
			||||||
    }
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
#[delete("/api/guild/<_>/reminders", data = "<reminder>")]
 | 
					 | 
				
			||||||
pub async fn delete_reminder(
 | 
					 | 
				
			||||||
    reminder: Json<DeleteReminder>,
 | 
					 | 
				
			||||||
    pool: &State<Pool<MySql>>,
 | 
					 | 
				
			||||||
) -> JsonResult {
 | 
					 | 
				
			||||||
    match sqlx::query!("DELETE FROM reminders WHERE uid = ?", reminder.uid)
 | 
					 | 
				
			||||||
        .execute(pool.inner())
 | 
					 | 
				
			||||||
        .await
 | 
					 | 
				
			||||||
    {
 | 
					 | 
				
			||||||
        Ok(_) => Ok(json!({})),
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
        Err(e) => {
 | 
					 | 
				
			||||||
            warn!("Error in `delete_reminder`: {:?}", e);
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
            Err(json!({"error": "Could not delete reminder"}))
 | 
					 | 
				
			||||||
        }
 | 
					 | 
				
			||||||
    }
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
@@ -1,20 +1,20 @@
 | 
				
			|||||||
use std::collections::HashMap;
 | 
					use std::path::Path;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
use chrono::{naive::NaiveDateTime, Utc};
 | 
					use chrono::{naive::NaiveDateTime, Utc};
 | 
				
			||||||
use rand::{rngs::OsRng, seq::IteratorRandom};
 | 
					use rand::{rngs::OsRng, seq::IteratorRandom};
 | 
				
			||||||
use rocket::{
 | 
					use rocket::{
 | 
				
			||||||
 | 
					    fs::{relative, NamedFile},
 | 
				
			||||||
    http::CookieJar,
 | 
					    http::CookieJar,
 | 
				
			||||||
    response::Redirect,
 | 
					    response::Redirect,
 | 
				
			||||||
    serde::json::{json, Value as JsonValue},
 | 
					    serde::json::json,
 | 
				
			||||||
};
 | 
					};
 | 
				
			||||||
use rocket_dyn_templates::Template;
 | 
					use serde::{de, Deserialize, Deserializer, Serialize, Serializer};
 | 
				
			||||||
use serde::{Deserialize, Serialize};
 | 
					 | 
				
			||||||
use serenity::{
 | 
					use serenity::{
 | 
				
			||||||
    client::Context,
 | 
					    client::Context,
 | 
				
			||||||
    http::Http,
 | 
					    http::Http,
 | 
				
			||||||
    model::id::{ChannelId, GuildId, UserId},
 | 
					    model::id::{ChannelId, GuildId, UserId},
 | 
				
			||||||
};
 | 
					};
 | 
				
			||||||
use sqlx::{types::Json, Executor, MySql, Pool};
 | 
					use sqlx::types::Json;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
use crate::{
 | 
					use crate::{
 | 
				
			||||||
    check_guild_subscription, check_subscription,
 | 
					    check_guild_subscription, check_subscription,
 | 
				
			||||||
@@ -22,16 +22,16 @@ use crate::{
 | 
				
			|||||||
        CHARACTERS, DAY, DEFAULT_AVATAR, MAX_CONTENT_LENGTH, MAX_EMBED_AUTHOR_LENGTH,
 | 
					        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_DESCRIPTION_LENGTH, MAX_EMBED_FIELDS, MAX_EMBED_FIELD_TITLE_LENGTH,
 | 
				
			||||||
        MAX_EMBED_FIELD_VALUE_LENGTH, MAX_EMBED_FOOTER_LENGTH, MAX_EMBED_TITLE_LENGTH,
 | 
					        MAX_EMBED_FIELD_VALUE_LENGTH, MAX_EMBED_FOOTER_LENGTH, MAX_EMBED_TITLE_LENGTH,
 | 
				
			||||||
        MAX_URL_LENGTH, MAX_USERNAME_LENGTH, MIN_INTERVAL,
 | 
					        MAX_NAME_LENGTH, MAX_URL_LENGTH, MAX_USERNAME_LENGTH, MIN_INTERVAL,
 | 
				
			||||||
    },
 | 
					    },
 | 
				
			||||||
    Database, Error,
 | 
					    guards::transaction::Transaction,
 | 
				
			||||||
 | 
					    routes::JsonResult,
 | 
				
			||||||
 | 
					    Error,
 | 
				
			||||||
};
 | 
					};
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					pub mod api;
 | 
				
			||||||
pub mod export;
 | 
					pub mod export;
 | 
				
			||||||
pub mod guild;
 | 
					 | 
				
			||||||
pub mod user;
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
pub type JsonResult = Result<JsonValue, JsonValue>;
 | 
					 | 
				
			||||||
type Unset<T> = Option<T>;
 | 
					type Unset<T> = Option<T>;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
fn name_default() -> String {
 | 
					fn name_default() -> String {
 | 
				
			||||||
@@ -50,6 +50,33 @@ fn id_default() -> u32 {
 | 
				
			|||||||
    0
 | 
					    0
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					fn interval_default() -> Unset<Option<u32>> {
 | 
				
			||||||
 | 
					    None
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					#[derive(sqlx::Type)]
 | 
				
			||||||
 | 
					#[sqlx(transparent)]
 | 
				
			||||||
 | 
					struct Attachment(Vec<u8>);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					impl<'de> Deserialize<'de> for Attachment {
 | 
				
			||||||
 | 
					    fn deserialize<D>(deserializer: D) -> Result<Attachment, D::Error>
 | 
				
			||||||
 | 
					    where
 | 
				
			||||||
 | 
					        D: Deserializer<'de>,
 | 
				
			||||||
 | 
					    {
 | 
				
			||||||
 | 
					        let string = String::deserialize(deserializer)?;
 | 
				
			||||||
 | 
					        Ok(Attachment(base64::decode(string).map_err(de::Error::custom)?))
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					impl Serialize for Attachment {
 | 
				
			||||||
 | 
					    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
 | 
				
			||||||
 | 
					    where
 | 
				
			||||||
 | 
					        S: Serializer,
 | 
				
			||||||
 | 
					    {
 | 
				
			||||||
 | 
					        serializer.collect_str(&base64::encode(&self.0))
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
#[derive(Serialize, Deserialize)]
 | 
					#[derive(Serialize, Deserialize)]
 | 
				
			||||||
pub struct ReminderTemplate {
 | 
					pub struct ReminderTemplate {
 | 
				
			||||||
    #[serde(default = "id_default")]
 | 
					    #[serde(default = "id_default")]
 | 
				
			||||||
@@ -58,7 +85,7 @@ pub struct ReminderTemplate {
 | 
				
			|||||||
    guild_id: u32,
 | 
					    guild_id: u32,
 | 
				
			||||||
    #[serde(default = "template_name_default")]
 | 
					    #[serde(default = "template_name_default")]
 | 
				
			||||||
    name: String,
 | 
					    name: String,
 | 
				
			||||||
    attachment: Option<Vec<u8>>,
 | 
					    attachment: Option<Attachment>,
 | 
				
			||||||
    attachment_name: Option<String>,
 | 
					    attachment_name: Option<String>,
 | 
				
			||||||
    avatar: Option<String>,
 | 
					    avatar: Option<String>,
 | 
				
			||||||
    content: String,
 | 
					    content: String,
 | 
				
			||||||
@@ -72,6 +99,9 @@ pub struct ReminderTemplate {
 | 
				
			|||||||
    embed_thumbnail_url: Option<String>,
 | 
					    embed_thumbnail_url: Option<String>,
 | 
				
			||||||
    embed_title: String,
 | 
					    embed_title: String,
 | 
				
			||||||
    embed_fields: Option<Json<Vec<EmbedField>>>,
 | 
					    embed_fields: Option<Json<Vec<EmbedField>>>,
 | 
				
			||||||
 | 
					    interval_seconds: Option<u32>,
 | 
				
			||||||
 | 
					    interval_days: Option<u32>,
 | 
				
			||||||
 | 
					    interval_months: Option<u32>,
 | 
				
			||||||
    tts: bool,
 | 
					    tts: bool,
 | 
				
			||||||
    username: Option<String>,
 | 
					    username: Option<String>,
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
@@ -80,7 +110,7 @@ pub struct ReminderTemplate {
 | 
				
			|||||||
pub struct ReminderTemplateCsv {
 | 
					pub struct ReminderTemplateCsv {
 | 
				
			||||||
    #[serde(default = "template_name_default")]
 | 
					    #[serde(default = "template_name_default")]
 | 
				
			||||||
    name: String,
 | 
					    name: String,
 | 
				
			||||||
    attachment: Option<Vec<u8>>,
 | 
					    attachment: Option<Attachment>,
 | 
				
			||||||
    attachment_name: Option<String>,
 | 
					    attachment_name: Option<String>,
 | 
				
			||||||
    avatar: Option<String>,
 | 
					    avatar: Option<String>,
 | 
				
			||||||
    content: String,
 | 
					    content: String,
 | 
				
			||||||
@@ -94,6 +124,9 @@ pub struct ReminderTemplateCsv {
 | 
				
			|||||||
    embed_thumbnail_url: Option<String>,
 | 
					    embed_thumbnail_url: Option<String>,
 | 
				
			||||||
    embed_title: String,
 | 
					    embed_title: String,
 | 
				
			||||||
    embed_fields: Option<String>,
 | 
					    embed_fields: Option<String>,
 | 
				
			||||||
 | 
					    interval_seconds: Option<u32>,
 | 
				
			||||||
 | 
					    interval_days: Option<u32>,
 | 
				
			||||||
 | 
					    interval_months: Option<u32>,
 | 
				
			||||||
    tts: bool,
 | 
					    tts: bool,
 | 
				
			||||||
    username: Option<String>,
 | 
					    username: Option<String>,
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
@@ -112,8 +145,7 @@ pub struct EmbedField {
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
#[derive(Serialize, Deserialize)]
 | 
					#[derive(Serialize, Deserialize)]
 | 
				
			||||||
pub struct Reminder {
 | 
					pub struct Reminder {
 | 
				
			||||||
    #[serde(with = "base64s")]
 | 
					    attachment: Option<Attachment>,
 | 
				
			||||||
    attachment: Option<Vec<u8>>,
 | 
					 | 
				
			||||||
    attachment_name: Option<String>,
 | 
					    attachment_name: Option<String>,
 | 
				
			||||||
    avatar: Option<String>,
 | 
					    avatar: Option<String>,
 | 
				
			||||||
    #[serde(with = "string")]
 | 
					    #[serde(with = "string")]
 | 
				
			||||||
@@ -132,6 +164,7 @@ pub struct Reminder {
 | 
				
			|||||||
    enabled: bool,
 | 
					    enabled: bool,
 | 
				
			||||||
    expires: Option<NaiveDateTime>,
 | 
					    expires: Option<NaiveDateTime>,
 | 
				
			||||||
    interval_seconds: Option<u32>,
 | 
					    interval_seconds: Option<u32>,
 | 
				
			||||||
 | 
					    interval_days: Option<u32>,
 | 
				
			||||||
    interval_months: Option<u32>,
 | 
					    interval_months: Option<u32>,
 | 
				
			||||||
    #[serde(default = "name_default")]
 | 
					    #[serde(default = "name_default")]
 | 
				
			||||||
    name: String,
 | 
					    name: String,
 | 
				
			||||||
@@ -145,8 +178,7 @@ pub struct Reminder {
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
#[derive(Serialize, Deserialize)]
 | 
					#[derive(Serialize, Deserialize)]
 | 
				
			||||||
pub struct ReminderCsv {
 | 
					pub struct ReminderCsv {
 | 
				
			||||||
    #[serde(with = "base64s")]
 | 
					    attachment: Option<Attachment>,
 | 
				
			||||||
    attachment: Option<Vec<u8>>,
 | 
					 | 
				
			||||||
    attachment_name: Option<String>,
 | 
					    attachment_name: Option<String>,
 | 
				
			||||||
    avatar: Option<String>,
 | 
					    avatar: Option<String>,
 | 
				
			||||||
    channel: String,
 | 
					    channel: String,
 | 
				
			||||||
@@ -164,6 +196,7 @@ pub struct ReminderCsv {
 | 
				
			|||||||
    enabled: bool,
 | 
					    enabled: bool,
 | 
				
			||||||
    expires: Option<NaiveDateTime>,
 | 
					    expires: Option<NaiveDateTime>,
 | 
				
			||||||
    interval_seconds: Option<u32>,
 | 
					    interval_seconds: Option<u32>,
 | 
				
			||||||
 | 
					    interval_days: Option<u32>,
 | 
				
			||||||
    interval_months: Option<u32>,
 | 
					    interval_months: Option<u32>,
 | 
				
			||||||
    #[serde(default = "name_default")]
 | 
					    #[serde(default = "name_default")]
 | 
				
			||||||
    name: String,
 | 
					    name: String,
 | 
				
			||||||
@@ -177,10 +210,13 @@ pub struct ReminderCsv {
 | 
				
			|||||||
pub struct PatchReminder {
 | 
					pub struct PatchReminder {
 | 
				
			||||||
    uid: String,
 | 
					    uid: String,
 | 
				
			||||||
    #[serde(default)]
 | 
					    #[serde(default)]
 | 
				
			||||||
    attachment: Unset<Option<String>>,
 | 
					    #[serde(deserialize_with = "deserialize_optional_field")]
 | 
				
			||||||
 | 
					    attachment: Unset<Option<Attachment>>,
 | 
				
			||||||
    #[serde(default)]
 | 
					    #[serde(default)]
 | 
				
			||||||
 | 
					    #[serde(deserialize_with = "deserialize_optional_field")]
 | 
				
			||||||
    attachment_name: Unset<Option<String>>,
 | 
					    attachment_name: Unset<Option<String>>,
 | 
				
			||||||
    #[serde(default)]
 | 
					    #[serde(default)]
 | 
				
			||||||
 | 
					    #[serde(deserialize_with = "deserialize_optional_field")]
 | 
				
			||||||
    avatar: Unset<Option<String>>,
 | 
					    avatar: Unset<Option<String>>,
 | 
				
			||||||
    #[serde(default = "channel_default")]
 | 
					    #[serde(default = "channel_default")]
 | 
				
			||||||
    #[serde(with = "string")]
 | 
					    #[serde(with = "string")]
 | 
				
			||||||
@@ -190,6 +226,7 @@ pub struct PatchReminder {
 | 
				
			|||||||
    #[serde(default)]
 | 
					    #[serde(default)]
 | 
				
			||||||
    embed_author: Unset<String>,
 | 
					    embed_author: Unset<String>,
 | 
				
			||||||
    #[serde(default)]
 | 
					    #[serde(default)]
 | 
				
			||||||
 | 
					    #[serde(deserialize_with = "deserialize_optional_field")]
 | 
				
			||||||
    embed_author_url: Unset<Option<String>>,
 | 
					    embed_author_url: Unset<Option<String>>,
 | 
				
			||||||
    #[serde(default)]
 | 
					    #[serde(default)]
 | 
				
			||||||
    embed_color: Unset<u32>,
 | 
					    embed_color: Unset<u32>,
 | 
				
			||||||
@@ -198,10 +235,13 @@ pub struct PatchReminder {
 | 
				
			|||||||
    #[serde(default)]
 | 
					    #[serde(default)]
 | 
				
			||||||
    embed_footer: Unset<String>,
 | 
					    embed_footer: Unset<String>,
 | 
				
			||||||
    #[serde(default)]
 | 
					    #[serde(default)]
 | 
				
			||||||
 | 
					    #[serde(deserialize_with = "deserialize_optional_field")]
 | 
				
			||||||
    embed_footer_url: Unset<Option<String>>,
 | 
					    embed_footer_url: Unset<Option<String>>,
 | 
				
			||||||
    #[serde(default)]
 | 
					    #[serde(default)]
 | 
				
			||||||
 | 
					    #[serde(deserialize_with = "deserialize_optional_field")]
 | 
				
			||||||
    embed_image_url: Unset<Option<String>>,
 | 
					    embed_image_url: Unset<Option<String>>,
 | 
				
			||||||
    #[serde(default)]
 | 
					    #[serde(default)]
 | 
				
			||||||
 | 
					    #[serde(deserialize_with = "deserialize_optional_field")]
 | 
				
			||||||
    embed_thumbnail_url: Unset<Option<String>>,
 | 
					    embed_thumbnail_url: Unset<Option<String>>,
 | 
				
			||||||
    #[serde(default)]
 | 
					    #[serde(default)]
 | 
				
			||||||
    embed_title: Unset<String>,
 | 
					    embed_title: Unset<String>,
 | 
				
			||||||
@@ -210,10 +250,16 @@ pub struct PatchReminder {
 | 
				
			|||||||
    #[serde(default)]
 | 
					    #[serde(default)]
 | 
				
			||||||
    enabled: Unset<bool>,
 | 
					    enabled: Unset<bool>,
 | 
				
			||||||
    #[serde(default)]
 | 
					    #[serde(default)]
 | 
				
			||||||
 | 
					    #[serde(deserialize_with = "deserialize_optional_field")]
 | 
				
			||||||
    expires: Unset<Option<NaiveDateTime>>,
 | 
					    expires: Unset<Option<NaiveDateTime>>,
 | 
				
			||||||
    #[serde(default)]
 | 
					    #[serde(default = "interval_default")]
 | 
				
			||||||
 | 
					    #[serde(deserialize_with = "deserialize_optional_field")]
 | 
				
			||||||
    interval_seconds: Unset<Option<u32>>,
 | 
					    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>>,
 | 
					    interval_months: Unset<Option<u32>>,
 | 
				
			||||||
    #[serde(default)]
 | 
					    #[serde(default)]
 | 
				
			||||||
    name: Unset<String>,
 | 
					    name: Unset<String>,
 | 
				
			||||||
@@ -222,11 +268,36 @@ pub struct PatchReminder {
 | 
				
			|||||||
    #[serde(default)]
 | 
					    #[serde(default)]
 | 
				
			||||||
    tts: Unset<bool>,
 | 
					    tts: Unset<bool>,
 | 
				
			||||||
    #[serde(default)]
 | 
					    #[serde(default)]
 | 
				
			||||||
 | 
					    #[serde(deserialize_with = "deserialize_optional_field")]
 | 
				
			||||||
    username: Unset<Option<String>>,
 | 
					    username: Unset<Option<String>>,
 | 
				
			||||||
    #[serde(default)]
 | 
					    #[serde(default)]
 | 
				
			||||||
    utc_time: Unset<NaiveDateTime>,
 | 
					    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 {
 | 
					pub fn generate_uid() -> String {
 | 
				
			||||||
    let mut generator: OsRng = Default::default();
 | 
					    let mut generator: OsRng = Default::default();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -236,6 +307,14 @@ pub fn generate_uid() -> String {
 | 
				
			|||||||
        .join("")
 | 
					        .join("")
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					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)?))
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
// https://github.com/serde-rs/json/issues/329#issuecomment-305608405
 | 
					// https://github.com/serde-rs/json/issues/329#issuecomment-305608405
 | 
				
			||||||
mod string {
 | 
					mod string {
 | 
				
			||||||
    use std::{fmt::Display, str::FromStr};
 | 
					    use std::{fmt::Display, str::FromStr};
 | 
				
			||||||
@@ -260,29 +339,6 @@ mod string {
 | 
				
			|||||||
    }
 | 
					    }
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
mod base64s {
 | 
					 | 
				
			||||||
    use serde::{de, Deserialize, Deserializer, Serializer};
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    pub fn serialize<S>(value: &Option<Vec<u8>>, serializer: S) -> Result<S::Ok, S::Error>
 | 
					 | 
				
			||||||
    where
 | 
					 | 
				
			||||||
        S: Serializer,
 | 
					 | 
				
			||||||
    {
 | 
					 | 
				
			||||||
        if let Some(opt) = value {
 | 
					 | 
				
			||||||
            serializer.collect_str(&base64::encode(opt))
 | 
					 | 
				
			||||||
        } else {
 | 
					 | 
				
			||||||
            serializer.serialize_none()
 | 
					 | 
				
			||||||
        }
 | 
					 | 
				
			||||||
    }
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    pub fn deserialize<'de, D>(deserializer: D) -> Result<Option<Vec<u8>>, D::Error>
 | 
					 | 
				
			||||||
    where
 | 
					 | 
				
			||||||
        D: Deserializer<'de>,
 | 
					 | 
				
			||||||
    {
 | 
					 | 
				
			||||||
        let string = Option::<String>::deserialize(deserializer)?;
 | 
					 | 
				
			||||||
        Some(string.map(|b| base64::decode(b).map_err(de::Error::custom))).flatten().transpose()
 | 
					 | 
				
			||||||
    }
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
#[derive(Deserialize)]
 | 
					#[derive(Deserialize)]
 | 
				
			||||||
pub struct DeleteReminder {
 | 
					pub struct DeleteReminder {
 | 
				
			||||||
    uid: String,
 | 
					    uid: String,
 | 
				
			||||||
@@ -299,13 +355,30 @@ pub struct TodoCsv {
 | 
				
			|||||||
    channel_id: Option<String>,
 | 
					    channel_id: Option<String>,
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
pub async fn create_reminder(
 | 
					pub(crate) async fn create_reminder(
 | 
				
			||||||
    ctx: &Context,
 | 
					    ctx: &Context,
 | 
				
			||||||
    pool: &Pool<MySql>,
 | 
					    transaction: &mut Transaction<'_>,
 | 
				
			||||||
    guild_id: GuildId,
 | 
					    guild_id: GuildId,
 | 
				
			||||||
    user_id: UserId,
 | 
					    user_id: UserId,
 | 
				
			||||||
    reminder: Reminder,
 | 
					    reminder: Reminder,
 | 
				
			||||||
) -> JsonResult {
 | 
					) -> JsonResult {
 | 
				
			||||||
 | 
					    // check guild in db
 | 
				
			||||||
 | 
					    match sqlx::query!("SELECT 1 as A FROM guilds WHERE guild = ?", guild_id.0)
 | 
				
			||||||
 | 
					        .fetch_one(transaction.executor())
 | 
				
			||||||
 | 
					        .await
 | 
				
			||||||
 | 
					    {
 | 
				
			||||||
 | 
					        Err(sqlx::Error::RowNotFound) => {
 | 
				
			||||||
 | 
					            if sqlx::query!("INSERT INTO guilds (guild) VALUES (?)", guild_id.0)
 | 
				
			||||||
 | 
					                .execute(transaction.executor())
 | 
				
			||||||
 | 
					                .await
 | 
				
			||||||
 | 
					                .is_err()
 | 
				
			||||||
 | 
					            {
 | 
				
			||||||
 | 
					                return Err(json!({"error": "Guild could not be created"}));
 | 
				
			||||||
 | 
					            }
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					        _ => {}
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    // validate channel
 | 
					    // validate channel
 | 
				
			||||||
    let channel = ChannelId(reminder.channel).to_channel_cached(&ctx);
 | 
					    let channel = ChannelId(reminder.channel).to_channel_cached(&ctx);
 | 
				
			||||||
    let channel_exists = channel.is_some();
 | 
					    let channel_exists = channel.is_some();
 | 
				
			||||||
@@ -322,7 +395,7 @@ pub async fn create_reminder(
 | 
				
			|||||||
        return Err(json!({"error": "Channel not found"}));
 | 
					        return Err(json!({"error": "Channel not found"}));
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    let channel = create_database_channel(&ctx, ChannelId(reminder.channel), pool).await;
 | 
					    let channel = create_database_channel(&ctx, ChannelId(reminder.channel), transaction).await;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    if let Err(e) = channel {
 | 
					    if let Err(e) = channel {
 | 
				
			||||||
        warn!("`create_database_channel` returned an error code: {:?}", e);
 | 
					        warn!("`create_database_channel` returned an error code: {:?}", e);
 | 
				
			||||||
@@ -335,6 +408,7 @@ pub async fn create_reminder(
 | 
				
			|||||||
    let channel = channel.unwrap();
 | 
					    let channel = channel.unwrap();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    // validate lengths
 | 
					    // validate lengths
 | 
				
			||||||
 | 
					    check_length!(MAX_NAME_LENGTH, reminder.name);
 | 
				
			||||||
    check_length!(MAX_CONTENT_LENGTH, reminder.content);
 | 
					    check_length!(MAX_CONTENT_LENGTH, reminder.content);
 | 
				
			||||||
    check_length!(MAX_EMBED_DESCRIPTION_LENGTH, reminder.embed_description);
 | 
					    check_length!(MAX_EMBED_DESCRIPTION_LENGTH, reminder.embed_description);
 | 
				
			||||||
    check_length!(MAX_EMBED_TITLE_LENGTH, reminder.embed_title);
 | 
					    check_length!(MAX_EMBED_TITLE_LENGTH, reminder.embed_title);
 | 
				
			||||||
@@ -370,8 +444,12 @@ pub async fn create_reminder(
 | 
				
			|||||||
    if reminder.utc_time < Utc::now().naive_utc() {
 | 
					    if reminder.utc_time < Utc::now().naive_utc() {
 | 
				
			||||||
        return Err(json!({"error": "Time must be in the future"}));
 | 
					        return Err(json!({"error": "Time must be in the future"}));
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
    if reminder.interval_seconds.is_some() || reminder.interval_months.is_some() {
 | 
					    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
 | 
					        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)
 | 
					            + reminder.interval_seconds.unwrap_or(0)
 | 
				
			||||||
            < *MIN_INTERVAL
 | 
					            < *MIN_INTERVAL
 | 
				
			||||||
        {
 | 
					        {
 | 
				
			||||||
@@ -380,7 +458,10 @@ pub async fn create_reminder(
 | 
				
			|||||||
    }
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    // check patreon if necessary
 | 
					    // check patreon if necessary
 | 
				
			||||||
    if reminder.interval_seconds.is_some() || reminder.interval_months.is_some() {
 | 
					    if reminder.interval_seconds.is_some()
 | 
				
			||||||
 | 
					        || reminder.interval_days.is_some()
 | 
				
			||||||
 | 
					        || reminder.interval_months.is_some()
 | 
				
			||||||
 | 
					    {
 | 
				
			||||||
        if !check_guild_subscription(&ctx, guild_id).await
 | 
					        if !check_guild_subscription(&ctx, guild_id).await
 | 
				
			||||||
            && !check_subscription(&ctx, user_id).await
 | 
					            && !check_subscription(&ctx, user_id).await
 | 
				
			||||||
        {
 | 
					        {
 | 
				
			||||||
@@ -388,9 +469,12 @@ pub async fn create_reminder(
 | 
				
			|||||||
        }
 | 
					        }
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    // 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 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();
 | 
					    let new_uid = generate_uid();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -416,15 +500,16 @@ pub async fn create_reminder(
 | 
				
			|||||||
         enabled,
 | 
					         enabled,
 | 
				
			||||||
         expires,
 | 
					         expires,
 | 
				
			||||||
         interval_seconds,
 | 
					         interval_seconds,
 | 
				
			||||||
 | 
					         interval_days,
 | 
				
			||||||
         interval_months,
 | 
					         interval_months,
 | 
				
			||||||
         name,
 | 
					         name,
 | 
				
			||||||
         restartable,
 | 
					         restartable,
 | 
				
			||||||
         tts,
 | 
					         tts,
 | 
				
			||||||
         username,
 | 
					         username,
 | 
				
			||||||
         `utc_time`
 | 
					         `utc_time`
 | 
				
			||||||
        ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
 | 
					        ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
 | 
				
			||||||
        new_uid,
 | 
					        new_uid,
 | 
				
			||||||
        attachment_data,
 | 
					        reminder.attachment,
 | 
				
			||||||
        reminder.attachment_name,
 | 
					        reminder.attachment_name,
 | 
				
			||||||
        channel,
 | 
					        channel,
 | 
				
			||||||
        reminder.avatar,
 | 
					        reminder.avatar,
 | 
				
			||||||
@@ -442,14 +527,15 @@ pub async fn create_reminder(
 | 
				
			|||||||
        reminder.enabled,
 | 
					        reminder.enabled,
 | 
				
			||||||
        reminder.expires,
 | 
					        reminder.expires,
 | 
				
			||||||
        reminder.interval_seconds,
 | 
					        reminder.interval_seconds,
 | 
				
			||||||
 | 
					        reminder.interval_days,
 | 
				
			||||||
        reminder.interval_months,
 | 
					        reminder.interval_months,
 | 
				
			||||||
        name,
 | 
					        name,
 | 
				
			||||||
        reminder.restartable,
 | 
					        reminder.restartable,
 | 
				
			||||||
        reminder.tts,
 | 
					        reminder.tts,
 | 
				
			||||||
        reminder.username,
 | 
					        username,
 | 
				
			||||||
        reminder.utc_time,
 | 
					        reminder.utc_time,
 | 
				
			||||||
    )
 | 
					    )
 | 
				
			||||||
    .execute(pool)
 | 
					    .execute(transaction.executor())
 | 
				
			||||||
    .await
 | 
					    .await
 | 
				
			||||||
    {
 | 
					    {
 | 
				
			||||||
        Ok(_) => sqlx::query_as_unchecked!(
 | 
					        Ok(_) => sqlx::query_as_unchecked!(
 | 
				
			||||||
@@ -473,6 +559,7 @@ pub async fn create_reminder(
 | 
				
			|||||||
             reminders.enabled,
 | 
					             reminders.enabled,
 | 
				
			||||||
             reminders.expires,
 | 
					             reminders.expires,
 | 
				
			||||||
             reminders.interval_seconds,
 | 
					             reminders.interval_seconds,
 | 
				
			||||||
 | 
					             reminders.interval_days,
 | 
				
			||||||
             reminders.interval_months,
 | 
					             reminders.interval_months,
 | 
				
			||||||
             reminders.name,
 | 
					             reminders.name,
 | 
				
			||||||
             reminders.restartable,
 | 
					             reminders.restartable,
 | 
				
			||||||
@@ -485,7 +572,7 @@ pub async fn create_reminder(
 | 
				
			|||||||
            WHERE uid = ?",
 | 
					            WHERE uid = ?",
 | 
				
			||||||
            new_uid
 | 
					            new_uid
 | 
				
			||||||
        )
 | 
					        )
 | 
				
			||||||
        .fetch_one(pool)
 | 
					        .fetch_one(transaction.executor())
 | 
				
			||||||
        .await
 | 
					        .await
 | 
				
			||||||
        .map(|r| Ok(json!(r)))
 | 
					        .map(|r| Ok(json!(r)))
 | 
				
			||||||
        .unwrap_or_else(|e| {
 | 
					        .unwrap_or_else(|e| {
 | 
				
			||||||
@@ -505,11 +592,11 @@ pub async fn create_reminder(
 | 
				
			|||||||
async fn create_database_channel(
 | 
					async fn create_database_channel(
 | 
				
			||||||
    ctx: impl AsRef<Http>,
 | 
					    ctx: impl AsRef<Http>,
 | 
				
			||||||
    channel: ChannelId,
 | 
					    channel: ChannelId,
 | 
				
			||||||
    pool: impl Executor<'_, Database = Database> + Copy,
 | 
					    transaction: &mut Transaction<'_>,
 | 
				
			||||||
) -> Result<u32, crate::Error> {
 | 
					) -> Result<u32, crate::Error> {
 | 
				
			||||||
    let row =
 | 
					    let row =
 | 
				
			||||||
        sqlx::query!("SELECT webhook_token, webhook_id FROM channels WHERE channel = ?", channel.0)
 | 
					        sqlx::query!("SELECT webhook_token, webhook_id FROM channels WHERE channel = ?", channel.0)
 | 
				
			||||||
            .fetch_one(pool)
 | 
					            .fetch_one(transaction.executor())
 | 
				
			||||||
            .await;
 | 
					            .await;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    match row {
 | 
					    match row {
 | 
				
			||||||
@@ -526,7 +613,7 @@ async fn create_database_channel(
 | 
				
			|||||||
                    webhook.token,
 | 
					                    webhook.token,
 | 
				
			||||||
                    channel.0
 | 
					                    channel.0
 | 
				
			||||||
                )
 | 
					                )
 | 
				
			||||||
                .execute(pool)
 | 
					                .execute(transaction.executor())
 | 
				
			||||||
                .await
 | 
					                .await
 | 
				
			||||||
                .map_err(|e| Error::SQLx(e))?;
 | 
					                .map_err(|e| Error::SQLx(e))?;
 | 
				
			||||||
            }
 | 
					            }
 | 
				
			||||||
@@ -552,7 +639,7 @@ async fn create_database_channel(
 | 
				
			|||||||
                webhook.token,
 | 
					                webhook.token,
 | 
				
			||||||
                channel.0
 | 
					                channel.0
 | 
				
			||||||
            )
 | 
					            )
 | 
				
			||||||
            .execute(pool)
 | 
					            .execute(transaction.executor())
 | 
				
			||||||
            .await
 | 
					            .await
 | 
				
			||||||
            .map_err(|e| Error::SQLx(e))?;
 | 
					            .map_err(|e| Error::SQLx(e))?;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -563,7 +650,7 @@ async fn create_database_channel(
 | 
				
			|||||||
    }?;
 | 
					    }?;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    let row = sqlx::query!("SELECT id FROM channels WHERE channel = ?", channel.0)
 | 
					    let row = sqlx::query!("SELECT id FROM channels WHERE channel = ?", channel.0)
 | 
				
			||||||
        .fetch_one(pool)
 | 
					        .fetch_one(transaction.executor())
 | 
				
			||||||
        .await
 | 
					        .await
 | 
				
			||||||
        .map_err(|e| Error::SQLx(e))?;
 | 
					        .map_err(|e| Error::SQLx(e))?;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -571,20 +658,26 @@ async fn create_database_channel(
 | 
				
			|||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
#[get("/")]
 | 
					#[get("/")]
 | 
				
			||||||
pub async fn dashboard_home(cookies: &CookieJar<'_>) -> Result<Template, Redirect> {
 | 
					pub async fn dashboard_home(cookies: &CookieJar<'_>) -> Result<NamedFile, Redirect> {
 | 
				
			||||||
    if cookies.get_private("userid").is_some() {
 | 
					    if cookies.get_private("userid").is_some() {
 | 
				
			||||||
        let map: HashMap<&str, String> = HashMap::new();
 | 
					        NamedFile::open(Path::new(relative!("static/index.html"))).await.map_err(|e| {
 | 
				
			||||||
        Ok(Template::render("dashboard", &map))
 | 
					            warn!("Couldn't render dashboard: {:?}", e);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            Redirect::to("/login/discord")
 | 
				
			||||||
 | 
					        })
 | 
				
			||||||
    } else {
 | 
					    } else {
 | 
				
			||||||
        Err(Redirect::to("/login/discord"))
 | 
					        Err(Redirect::to("/login/discord"))
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
#[get("/<_>")]
 | 
					#[get("/<_..>")]
 | 
				
			||||||
pub async fn dashboard(cookies: &CookieJar<'_>) -> Result<Template, Redirect> {
 | 
					pub async fn dashboard(cookies: &CookieJar<'_>) -> Result<NamedFile, Redirect> {
 | 
				
			||||||
    if cookies.get_private("userid").is_some() {
 | 
					    if cookies.get_private("userid").is_some() {
 | 
				
			||||||
        let map: HashMap<&str, String> = HashMap::new();
 | 
					        NamedFile::open(Path::new(relative!("static/index.html"))).await.map_err(|e| {
 | 
				
			||||||
        Ok(Template::render("dashboard", &map))
 | 
					            warn!("Couldn't render dashboard: {:?}", e);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            Redirect::to("/login/discord")
 | 
				
			||||||
 | 
					        })
 | 
				
			||||||
    } else {
 | 
					    } else {
 | 
				
			||||||
        Err(Redirect::to("/login/discord"))
 | 
					        Err(Redirect::to("/login/discord"))
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -11,7 +11,7 @@ use rocket::{
 | 
				
			|||||||
};
 | 
					};
 | 
				
			||||||
use serenity::model::user::User;
 | 
					use serenity::model::user::User;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
use crate::consts::DISCORD_API;
 | 
					use crate::{consts::DISCORD_API, routes};
 | 
				
			||||||
 | 
					
 | 
				
			||||||
#[get("/discord")]
 | 
					#[get("/discord")]
 | 
				
			||||||
pub async fn discord_login(
 | 
					pub async fn discord_login(
 | 
				
			||||||
@@ -31,27 +31,34 @@ pub async fn discord_login(
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
    // store the pkce secret to verify the authorization later
 | 
					    // store the pkce secret to verify the authorization later
 | 
				
			||||||
    cookies.add_private(
 | 
					    cookies.add_private(
 | 
				
			||||||
        Cookie::build("verify", pkce_verifier.secret().to_string())
 | 
					        Cookie::build(("verify", pkce_verifier.secret().to_string()))
 | 
				
			||||||
            .http_only(true)
 | 
					            .http_only(true)
 | 
				
			||||||
            .path("/login")
 | 
					            .path("/login")
 | 
				
			||||||
            .same_site(SameSite::Lax)
 | 
					            .same_site(SameSite::Lax)
 | 
				
			||||||
            .expires(Expiration::Session)
 | 
					            .expires(Expiration::Session),
 | 
				
			||||||
            .finish(),
 | 
					 | 
				
			||||||
    );
 | 
					    );
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    // store the csrf token to verify no interference
 | 
					    // store the csrf token to verify no interference
 | 
				
			||||||
    cookies.add_private(
 | 
					    cookies.add_private(
 | 
				
			||||||
        Cookie::build("csrf", csrf_token.secret().to_string())
 | 
					        Cookie::build(("csrf", csrf_token.secret().to_string()))
 | 
				
			||||||
            .http_only(true)
 | 
					            .http_only(true)
 | 
				
			||||||
            .path("/login")
 | 
					            .path("/login")
 | 
				
			||||||
            .same_site(SameSite::Lax)
 | 
					            .same_site(SameSite::Lax)
 | 
				
			||||||
            .expires(Expiration::Session)
 | 
					            .expires(Expiration::Session),
 | 
				
			||||||
            .finish(),
 | 
					 | 
				
			||||||
    );
 | 
					    );
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    Redirect::to(auth_url.to_string())
 | 
					    Redirect::to(auth_url.to_string())
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					#[get("/discord/logout")]
 | 
				
			||||||
 | 
					pub async fn discord_logout(cookies: &CookieJar<'_>) -> Redirect {
 | 
				
			||||||
 | 
					    cookies.remove_private(Cookie::from("username"));
 | 
				
			||||||
 | 
					    cookies.remove_private(Cookie::from("userid"));
 | 
				
			||||||
 | 
					    cookies.remove_private(Cookie::from("access_token"));
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    Redirect::to(uri!(routes::index))
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
#[get("/discord/authorized?<code>&<state>")]
 | 
					#[get("/discord/authorized?<code>&<state>")]
 | 
				
			||||||
pub async fn discord_callback(
 | 
					pub async fn discord_callback(
 | 
				
			||||||
    code: &str,
 | 
					    code: &str,
 | 
				
			||||||
@@ -71,17 +78,16 @@ pub async fn discord_callback(
 | 
				
			|||||||
                .request_async(async_http_client)
 | 
					                .request_async(async_http_client)
 | 
				
			||||||
                .await;
 | 
					                .await;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
            cookies.remove_private(Cookie::named("verify"));
 | 
					            cookies.remove_private(Cookie::from("verify"));
 | 
				
			||||||
            cookies.remove_private(Cookie::named("csrf"));
 | 
					            cookies.remove_private(Cookie::from("csrf"));
 | 
				
			||||||
 | 
					
 | 
				
			||||||
            match token_result {
 | 
					            match token_result {
 | 
				
			||||||
                Ok(token) => {
 | 
					                Ok(token) => {
 | 
				
			||||||
                    cookies.add_private(
 | 
					                    cookies.add_private(
 | 
				
			||||||
                        Cookie::build("access_token", token.access_token().secret().to_string())
 | 
					                        Cookie::build(("access_token", token.access_token().secret().to_string()))
 | 
				
			||||||
                            .secure(true)
 | 
					                            .secure(true)
 | 
				
			||||||
                            .http_only(true)
 | 
					                            .http_only(true)
 | 
				
			||||||
                            .path("/dashboard")
 | 
					                            .path("/dashboard"),
 | 
				
			||||||
                            .finish(),
 | 
					 | 
				
			||||||
                    );
 | 
					                    );
 | 
				
			||||||
 | 
					
 | 
				
			||||||
                    let request_res = reqwest_client
 | 
					                    let request_res = reqwest_client
 | 
				
			||||||
@@ -135,14 +141,14 @@ pub async fn discord_callback(
 | 
				
			|||||||
                    Err(Flash::new(
 | 
					                    Err(Flash::new(
 | 
				
			||||||
                        Redirect::to(uri!(super::return_to_same_site(""))),
 | 
					                        Redirect::to(uri!(super::return_to_same_site(""))),
 | 
				
			||||||
                        "warning",
 | 
					                        "warning",
 | 
				
			||||||
                        "Your login request was rejected",
 | 
					                        "Your login request was rejected. The server may be misconfigured. Please retry or alert us in Discord.",
 | 
				
			||||||
                    ))
 | 
					                    ))
 | 
				
			||||||
                }
 | 
					                }
 | 
				
			||||||
            }
 | 
					            }
 | 
				
			||||||
        } else {
 | 
					        } 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 {
 | 
					    } 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)"))
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 
 | 
				
			|||||||
							
								
								
									
										18
									
								
								web/src/routes/metrics.rs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										18
									
								
								web/src/routes/metrics.rs
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,18 @@
 | 
				
			|||||||
 | 
					use prometheus;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					use crate::metrics::REGISTRY;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					#[get("/metrics")]
 | 
				
			||||||
 | 
					pub async fn metrics() -> String {
 | 
				
			||||||
 | 
					    let encoder = prometheus::TextEncoder::new();
 | 
				
			||||||
 | 
					    let res_custom = encoder.encode_to_string(®ISTRY.gather());
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    match res_custom {
 | 
				
			||||||
 | 
					        Ok(s) => s,
 | 
				
			||||||
 | 
					        Err(e) => {
 | 
				
			||||||
 | 
					            warn!("Error encoding metrics: {:?}", e);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            String::new()
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
@@ -1,11 +1,16 @@
 | 
				
			|||||||
 | 
					pub mod admin;
 | 
				
			||||||
pub mod dashboard;
 | 
					pub mod dashboard;
 | 
				
			||||||
pub mod login;
 | 
					pub mod login;
 | 
				
			||||||
 | 
					pub mod metrics;
 | 
				
			||||||
 | 
					pub mod report;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
use std::collections::HashMap;
 | 
					use std::collections::HashMap;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
use rocket::request::FlashMessage;
 | 
					use rocket::{request::FlashMessage, serde::json::Value as JsonValue};
 | 
				
			||||||
use rocket_dyn_templates::Template;
 | 
					use rocket_dyn_templates::Template;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					pub type JsonResult = Result<JsonValue, JsonValue>;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
#[get("/")]
 | 
					#[get("/")]
 | 
				
			||||||
pub async fn index(flash: Option<FlashMessage<'_>>) -> Template {
 | 
					pub async fn index(flash: Option<FlashMessage<'_>>) -> Template {
 | 
				
			||||||
    let mut map: HashMap<&str, String> = HashMap::new();
 | 
					    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,10 +11,26 @@ div.reminderContent.is-collapsed .column.discord-frame {
 | 
				
			|||||||
    display: none;
 | 
					    display: none;
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
div.reminderContent.is-collapsed .collapses {
 | 
					div.reminderContent.is-collapsed .column.settings {
 | 
				
			||||||
    display: none;
 | 
					    display: none;
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					div.reminderContent.is-collapsed .reminder-settings {
 | 
				
			||||||
 | 
					    margin-bottom: 0;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					div.reminderContent.is-collapsed .button-row {
 | 
				
			||||||
 | 
					    display: none;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					div.reminderContent.is-collapsed .button-row-edit {
 | 
				
			||||||
 | 
					    display: none;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					div.reminderContent.is-collapsed .reminder-topbar {
 | 
				
			||||||
 | 
					    padding-bottom: 0;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
div.reminderContent.is-collapsed .invert-collapses {
 | 
					div.reminderContent.is-collapsed .invert-collapses {
 | 
				
			||||||
    display: inline-flex;
 | 
					    display: inline-flex;
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
@@ -23,42 +39,42 @@ div.reminderContent .invert-collapses {
 | 
				
			|||||||
    display: none;
 | 
					    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"] {
 | 
					div.reminderContent.is-collapsed input[name="name"] {
 | 
				
			||||||
    display: inline-flex;
 | 
					    display: inline-flex;
 | 
				
			||||||
    flex-grow: 1;
 | 
					    flex-grow: 1;
 | 
				
			||||||
    border: none;
 | 
					    border: none;
 | 
				
			||||||
    font-weight: 700;
 | 
					 | 
				
			||||||
    background: none;
 | 
					    background: none;
 | 
				
			||||||
 | 
					    box-shadow: none;
 | 
				
			||||||
 | 
					    opacity: 1;
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
div.reminderContent.is-collapsed button.hide-box {
 | 
					div.reminderContent.is-collapsed .hide-box {
 | 
				
			||||||
    display: inline-flex;
 | 
					    display: inline-flex;
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
div.reminderContent.is-collapsed button.hide-box i {
 | 
					div.reminderContent.is-collapsed .hide-box i {
 | 
				
			||||||
    transform: rotate(90deg);
 | 
					    transform: rotate(90deg);
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
/* END */
 | 
					/* END */
 | 
				
			||||||
 | 
					
 | 
				
			||||||
/* dashboard styles */
 | 
					/* 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 {
 | 
					button.inline-btn {
 | 
				
			||||||
    height: 100%;
 | 
					    height: 100%;
 | 
				
			||||||
    padding: 5px;
 | 
					    padding: 5px;
 | 
				
			||||||
@@ -85,18 +101,86 @@ div.discord-embed {
 | 
				
			|||||||
    position: relative;
 | 
					    position: relative;
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
div.reminderContent {
 | 
					div.split-controls {
 | 
				
			||||||
    padding: 2px;
 | 
					    display: flex;
 | 
				
			||||||
    background-color: #f5f5f5;
 | 
					    flex-direction: column;
 | 
				
			||||||
    border-radius: 8px;
 | 
					    justify-content: space-between;
 | 
				
			||||||
    margin: 8px;
 | 
					    flex-grow: 2;
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
div.interval-group > button {
 | 
					.reminder-topbar > div {
 | 
				
			||||||
    margin-left: auto;
 | 
					    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;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.reminder-settings > .column {
 | 
				
			||||||
 | 
					    flex-grow: 0;
 | 
				
			||||||
 | 
					    flex-shrink: 0;
 | 
				
			||||||
 | 
					    flex-basis: 50%;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					div.reminderContent {
 | 
				
			||||||
 | 
					    margin-top: 10px;
 | 
				
			||||||
 | 
					    margin-bottom: 10px;
 | 
				
			||||||
 | 
					    padding: 14px;
 | 
				
			||||||
 | 
					    background-color: #f5f5f5;
 | 
				
			||||||
 | 
					    border-radius: 8px;
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
/* Interval inputs */
 | 
					/* 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 {
 | 
					div.interval-group > .interval-group-left input {
 | 
				
			||||||
    -webkit-appearance: none;
 | 
					    -webkit-appearance: none;
 | 
				
			||||||
    border-style: none;
 | 
					    border-style: none;
 | 
				
			||||||
@@ -110,12 +194,13 @@ div.interval-group > .interval-group-left input.w2 {
 | 
				
			|||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
div.interval-group > .interval-group-left input.w3 {
 | 
					div.interval-group > .interval-group-left input.w3 {
 | 
				
			||||||
    width: 6ch;
 | 
					    width: 3ch;
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
div.interval-group {
 | 
					div.interval-group {
 | 
				
			||||||
    display: flex;
 | 
					    display: flex;
 | 
				
			||||||
    flex-direction: row;
 | 
					    flex-direction: row;
 | 
				
			||||||
 | 
					    justify-content: space-between;
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
/* !Interval inputs */
 | 
					/* !Interval inputs */
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -133,17 +218,16 @@ div.inset-content {
 | 
				
			|||||||
    margin-right: 10%;
 | 
					    margin-right: 10%;
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
div.flash-message {
 | 
					div.flash-container {
 | 
				
			||||||
    position: fixed;
 | 
					    position: fixed;
 | 
				
			||||||
 | 
					    width: 100%;
 | 
				
			||||||
 | 
					    bottom: 0;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					div.flash-message {
 | 
				
			||||||
    width: calc(100% - 32px);
 | 
					    width: calc(100% - 32px);
 | 
				
			||||||
    margin: 16px !important;
 | 
					    margin: 16px !important;
 | 
				
			||||||
    z-index: 99;
 | 
					    z-index: 99;
 | 
				
			||||||
    bottom: 0;
 | 
					 | 
				
			||||||
    display: none;
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
div.flash-message.is-active {
 | 
					 | 
				
			||||||
    display: block;
 | 
					 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
body {
 | 
					body {
 | 
				
			||||||
@@ -180,6 +264,23 @@ div#pageNavbar a {
 | 
				
			|||||||
    text-align: center;
 | 
					    text-align: center;
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.navbar-burger {
 | 
				
			||||||
 | 
					    flex-shrink: 0;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.navbar-item.pageTitle {
 | 
				
			||||||
 | 
					    flex-shrink: 1;
 | 
				
			||||||
 | 
					    white-space: nowrap;
 | 
				
			||||||
 | 
					    overflow: hidden;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.dashboard-burger, .dashboard-burger:active, .dashboard-burger.is-active {
 | 
				
			||||||
 | 
					    background-color: #adc99c !important;
 | 
				
			||||||
 | 
					    border-radius: 14px;
 | 
				
			||||||
 | 
					    padding: 6px;
 | 
				
			||||||
 | 
					    background-clip: content-box;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
div#pageNavbar a:hover {
 | 
					div#pageNavbar a:hover {
 | 
				
			||||||
    background-color: #4a4a4a;
 | 
					    background-color: #4a4a4a;
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
@@ -206,17 +307,24 @@ div.dashboard-sidebar {
 | 
				
			|||||||
    padding-right: 0;
 | 
					    padding-right: 0;
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
div.dashboard-sidebar:not(.mobile-sidebar) {
 | 
					ul.guildList {
 | 
				
			||||||
    display: flex;
 | 
					    flex-grow: 1;
 | 
				
			||||||
    flex-direction: column;
 | 
					    flex-shrink: 1;
 | 
				
			||||||
 | 
					    overflow: auto;
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
div.dashboard-sidebar:not(.mobile-sidebar) .aside-footer {
 | 
					div.dashboard-sidebar:not(.mobile-sidebar) .aside-footer {
 | 
				
			||||||
 | 
					    flex-shrink: 0;
 | 
				
			||||||
 | 
					    flex-grow: 0;
 | 
				
			||||||
    position: fixed;
 | 
					    position: fixed;
 | 
				
			||||||
    bottom: 0;
 | 
					    bottom: 0;
 | 
				
			||||||
    width: 226px;
 | 
					    width: 226px;
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					div.dashboard-sidebar svg {
 | 
				
			||||||
 | 
					    flex-shrink: 0;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
div.mobile-sidebar {
 | 
					div.mobile-sidebar {
 | 
				
			||||||
    z-index: 100;
 | 
					    z-index: 100;
 | 
				
			||||||
    min-height: 100vh;
 | 
					    min-height: 100vh;
 | 
				
			||||||
@@ -293,10 +401,7 @@ input.default-width {
 | 
				
			|||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
.message-input:placeholder-shown {
 | 
					.message-input:placeholder-shown {
 | 
				
			||||||
    border-top: none;
 | 
					    font-style: italic;
 | 
				
			||||||
    border-left: none;
 | 
					 | 
				
			||||||
    border-right: none;
 | 
					 | 
				
			||||||
    border-bottom-style: dashed;
 | 
					 | 
				
			||||||
    background-color: #40444b;
 | 
					    background-color: #40444b;
 | 
				
			||||||
    color: #fff;
 | 
					    color: #fff;
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
@@ -367,8 +472,7 @@ input.default-width {
 | 
				
			|||||||
.customizable.is-400x300 img {
 | 
					.customizable.is-400x300 img {
 | 
				
			||||||
    margin-top: 10px;
 | 
					    margin-top: 10px;
 | 
				
			||||||
    width: 100%;
 | 
					    width: 100%;
 | 
				
			||||||
    min-height: 100px;
 | 
					    height: 100px;
 | 
				
			||||||
    max-height: 400px;
 | 
					 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
.customizable.is-32x32 img {
 | 
					.customizable.is-32x32 img {
 | 
				
			||||||
@@ -462,6 +566,7 @@ input.default-width {
 | 
				
			|||||||
    flex-grow: 1;
 | 
					    flex-grow: 1;
 | 
				
			||||||
    flex-shrink: 1;
 | 
					    flex-shrink: 1;
 | 
				
			||||||
    flex-basis: auto;
 | 
					    flex-basis: auto;
 | 
				
			||||||
 | 
					    margin-right: 4px;
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
.embed-body input, .embed-body textarea {
 | 
					.embed-body input, .embed-body textarea {
 | 
				
			||||||
@@ -511,21 +616,88 @@ input.default-width {
 | 
				
			|||||||
    border-bottom: 1px solid #fff;
 | 
					    border-bottom: 1px solid #fff;
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@media only screen and (max-width: 768px) {
 | 
					.channel-selector {
 | 
				
			||||||
 | 
					    width: 100%;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.select {
 | 
				
			||||||
 | 
					    width: 100%;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					li.highlight {
 | 
				
			||||||
 | 
					    margin-bottom: 0 !important;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.button-row {
 | 
				
			||||||
 | 
					    display: flex;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.button-row-edit > button {
 | 
				
			||||||
 | 
					    margin-right: 4px;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.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: 1023px) {
 | 
				
			||||||
 | 
					    p.title.pageTitle {
 | 
				
			||||||
 | 
					        display: none;
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    .dashboard-frame {
 | 
				
			||||||
 | 
					        margin-top: 4rem !important;
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    .customizable.thumbnail img {
 | 
					    .customizable.thumbnail img {
 | 
				
			||||||
        width: 60px;
 | 
					        width: 60px;
 | 
				
			||||||
        height: 60px;
 | 
					        height: 60px;
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    .customizable.is-24x24 img {
 | 
					@media only screen and (max-width: 768px) {
 | 
				
			||||||
        width: 16px;
 | 
					    .button-row {
 | 
				
			||||||
        height: 16px;
 | 
					        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%;
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    .reminder-settings {
 | 
				
			||||||
 | 
					        margin-bottom: 0 !important;
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    .tts-row {
 | 
				
			||||||
 | 
					        padding-bottom: 0;
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
/* loader */
 | 
					/* loader */
 | 
				
			||||||
#loader {
 | 
					#loader {
 | 
				
			||||||
    position: fixed;
 | 
					    position: fixed;
 | 
				
			||||||
 | 
					    top: 0;
 | 
				
			||||||
    background-color: rgba(255, 255, 255, 0.8);
 | 
					    background-color: rgba(255, 255, 255, 0.8);
 | 
				
			||||||
    width: 100vw;
 | 
					    width: 100vw;
 | 
				
			||||||
    z-index: 999;
 | 
					    z-index: 999;
 | 
				
			||||||
@@ -537,6 +709,86 @@ input.default-width {
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
/* END */
 | 
					/* END */
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					div.reminderError {
 | 
				
			||||||
 | 
					    margin: 10px;
 | 
				
			||||||
 | 
					    padding: 14px;
 | 
				
			||||||
 | 
					    background-color: #f5f5f5;
 | 
				
			||||||
 | 
					    border-radius: 8px;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					div.reminderError .errorHead {
 | 
				
			||||||
 | 
					    display: flex;
 | 
				
			||||||
 | 
					    flex-direction: row;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					div.reminderError .errorIcon {
 | 
				
			||||||
 | 
					    padding: 8px;
 | 
				
			||||||
 | 
					    border-radius: 4px;
 | 
				
			||||||
 | 
					    margin-right: 12px;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					div.reminderError .errorIcon .fas {
 | 
				
			||||||
 | 
					    display: none
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					div.reminderError[data-case="deleted"] .errorIcon {
 | 
				
			||||||
 | 
					    background-color: #e7e5e4;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					div.reminderError[data-case="failed"] .errorIcon {
 | 
				
			||||||
 | 
					    background-color: #fecaca;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					div.reminderError[data-case="sent"] .errorIcon {
 | 
				
			||||||
 | 
					    background-color: #d9f99d;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					div.reminderError[data-case="deleted"] .errorIcon .fas.fa-trash {
 | 
				
			||||||
 | 
					    display: block;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					div.reminderError[data-case="failed"] .errorIcon .fas.fa-exclamation-triangle {
 | 
				
			||||||
 | 
					    display: block;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					div.reminderError[data-case="sent"] .errorIcon .fas.fa-check {
 | 
				
			||||||
 | 
					    display: block;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					div.reminderError .errorHead .reminderName {
 | 
				
			||||||
 | 
					    font-size: 1rem;
 | 
				
			||||||
 | 
					    display: flex;
 | 
				
			||||||
 | 
					    flex-direction: column;
 | 
				
			||||||
 | 
					    justify-content: center;
 | 
				
			||||||
 | 
					    color: rgb(54, 54, 54);
 | 
				
			||||||
 | 
					    flex-grow: 1;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					div.reminderError .errorHead .reminderTime {
 | 
				
			||||||
 | 
					    font-size: 1rem;
 | 
				
			||||||
 | 
					    display: flex;
 | 
				
			||||||
 | 
					    flex-direction: column;
 | 
				
			||||||
 | 
					    flex-shrink: 1;
 | 
				
			||||||
 | 
					    justify-content: center;
 | 
				
			||||||
 | 
					    color: rgb(54, 54, 54);
 | 
				
			||||||
 | 
					    background-color: #ffffff;
 | 
				
			||||||
 | 
					    padding: 8px;
 | 
				
			||||||
 | 
					    border-radius: 4px;
 | 
				
			||||||
 | 
					    border-color: #e5e5e5;
 | 
				
			||||||
 | 
					    border-width: 1px;
 | 
				
			||||||
 | 
					    border-style: solid;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					div.reminderError .reminderMessage {
 | 
				
			||||||
 | 
					    font-size: 1rem;
 | 
				
			||||||
 | 
					    display: flex;
 | 
				
			||||||
 | 
					    flex-direction: column;
 | 
				
			||||||
 | 
					    justify-content: center;
 | 
				
			||||||
 | 
					    color: rgb(54, 54, 54);
 | 
				
			||||||
 | 
					    flex-grow: 1;
 | 
				
			||||||
 | 
					    font-style: italic;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
/* other stuff */
 | 
					/* other stuff */
 | 
				
			||||||
 | 
					
 | 
				
			||||||
.half-rem {
 | 
					.half-rem {
 | 
				
			||||||
@@ -568,11 +820,44 @@ input.default-width {
 | 
				
			|||||||
    background-color: white;
 | 
					    background-color: white;
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					a.switch-pane {
 | 
				
			||||||
 | 
					    white-space: nowrap;
 | 
				
			||||||
 | 
					    overflow: hidden;
 | 
				
			||||||
 | 
					    text-overflow: ellipsis;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.guild-submenu {
 | 
				
			||||||
 | 
					    display: none;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.guild-submenu li {
 | 
				
			||||||
 | 
					    font-size: 0.8rem;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					a.switch-pane.is-active ~ .guild-submenu {
 | 
				
			||||||
 | 
					    display: block;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.feedback {
 | 
				
			||||||
 | 
					    background-color: #5865F2;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
.is-locked {
 | 
					.is-locked {
 | 
				
			||||||
    pointer-events: none;
 | 
					    pointer-events: none;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.is-locked > :not(.patreon-invert) {
 | 
				
			||||||
    opacity: 0.4;
 | 
					    opacity: 0.4;
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.is-locked .patreon-invert {
 | 
				
			||||||
 | 
					    display: block;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.patreon-invert {
 | 
				
			||||||
 | 
					    display: none;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
.is-locked .foreground {
 | 
					.is-locked .foreground {
 | 
				
			||||||
    pointer-events: auto;
 | 
					    pointer-events: auto;
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
@@ -580,3 +865,27 @@ input.default-width {
 | 
				
			|||||||
.is-locked .field:last-of-type {
 | 
					.is-locked .field:last-of-type {
 | 
				
			||||||
    display: none;
 | 
					    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: 2rem;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 
 | 
				
			|||||||
							
								
								
									
										
											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  | 
							
								
								
									
										131
									
								
								web/static/js/admin.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										131
									
								
								web/static/js/admin.js
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,131 @@
 | 
				
			|||||||
 | 
					document.addEventListener("DOMContentLoaded", () => {
 | 
				
			||||||
 | 
					    fetch("/admin/data")
 | 
				
			||||||
 | 
					        .then((resp) => resp.json())
 | 
				
			||||||
 | 
					        .then((data) => {
 | 
				
			||||||
 | 
					            document.querySelector("#backlog").textContent = data.backlog;
 | 
				
			||||||
 | 
					            document.querySelector("#reminders").textContent = data.count.reminders;
 | 
				
			||||||
 | 
					            document.querySelector("#intervals").textContent = data.count.intervals;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            let historySent = data.historyLong.sent.reduce(
 | 
				
			||||||
 | 
					                (iv, frame) => iv + frame.count,
 | 
				
			||||||
 | 
					                0
 | 
				
			||||||
 | 
					            );
 | 
				
			||||||
 | 
					            let historyFailed = data.historyLong.failed.reduce(
 | 
				
			||||||
 | 
					                (iv, frame) => iv + frame.count,
 | 
				
			||||||
 | 
					                0
 | 
				
			||||||
 | 
					            );
 | 
				
			||||||
 | 
					            let rate = historyFailed / (historySent + historyFailed);
 | 
				
			||||||
 | 
					            let formatted = Math.round(rate * 10000) / 100;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            document.querySelector("#historySent").textContent = historySent;
 | 
				
			||||||
 | 
					            document.querySelector("#historyFailed").textContent = historyFailed;
 | 
				
			||||||
 | 
					            document.querySelector("#failRate").textContent = `${formatted}%`;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            new Chart(document.getElementById("schedule"), {
 | 
				
			||||||
 | 
					                type: "bar",
 | 
				
			||||||
 | 
					                data: {
 | 
				
			||||||
 | 
					                    labels: [
 | 
				
			||||||
 | 
					                        ...data.scheduleShort.once,
 | 
				
			||||||
 | 
					                        ...data.scheduleShort.interval,
 | 
				
			||||||
 | 
					                    ].map((row) => luxon.DateTime.fromISO(row.time_key)),
 | 
				
			||||||
 | 
					                    datasets: [
 | 
				
			||||||
 | 
					                        {
 | 
				
			||||||
 | 
					                            label: "Reminders",
 | 
				
			||||||
 | 
					                            data: data.scheduleShort.once.map((row) => row.count),
 | 
				
			||||||
 | 
					                        },
 | 
				
			||||||
 | 
					                        {
 | 
				
			||||||
 | 
					                            label: "Intervals",
 | 
				
			||||||
 | 
					                            data: data.scheduleShort.interval.map((row) => row.count),
 | 
				
			||||||
 | 
					                        },
 | 
				
			||||||
 | 
					                    ],
 | 
				
			||||||
 | 
					                },
 | 
				
			||||||
 | 
					                options: {
 | 
				
			||||||
 | 
					                    responsive: true,
 | 
				
			||||||
 | 
					                    maintainAspectRatio: false,
 | 
				
			||||||
 | 
					                    scales: {
 | 
				
			||||||
 | 
					                        x: {
 | 
				
			||||||
 | 
					                            stacked: true,
 | 
				
			||||||
 | 
					                            type: "time",
 | 
				
			||||||
 | 
					                            time: {
 | 
				
			||||||
 | 
					                                unit: "minute",
 | 
				
			||||||
 | 
					                            },
 | 
				
			||||||
 | 
					                        },
 | 
				
			||||||
 | 
					                        y: {
 | 
				
			||||||
 | 
					                            stacked: true,
 | 
				
			||||||
 | 
					                        },
 | 
				
			||||||
 | 
					                    },
 | 
				
			||||||
 | 
					                },
 | 
				
			||||||
 | 
					            });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            new Chart(document.getElementById("scheduleLong"), {
 | 
				
			||||||
 | 
					                type: "bar",
 | 
				
			||||||
 | 
					                data: {
 | 
				
			||||||
 | 
					                    labels: [
 | 
				
			||||||
 | 
					                        ...data.scheduleLong.once,
 | 
				
			||||||
 | 
					                        ...data.scheduleLong.interval,
 | 
				
			||||||
 | 
					                    ].map((row) => luxon.DateTime.fromISO(row.time_key)),
 | 
				
			||||||
 | 
					                    datasets: [
 | 
				
			||||||
 | 
					                        {
 | 
				
			||||||
 | 
					                            label: "Reminders",
 | 
				
			||||||
 | 
					                            data: data.scheduleLong.once.map((row) => row.count),
 | 
				
			||||||
 | 
					                        },
 | 
				
			||||||
 | 
					                        {
 | 
				
			||||||
 | 
					                            label: "Intervals",
 | 
				
			||||||
 | 
					                            data: data.scheduleLong.interval.map((row) => row.count),
 | 
				
			||||||
 | 
					                        },
 | 
				
			||||||
 | 
					                    ],
 | 
				
			||||||
 | 
					                },
 | 
				
			||||||
 | 
					                options: {
 | 
				
			||||||
 | 
					                    responsive: true,
 | 
				
			||||||
 | 
					                    maintainAspectRatio: false,
 | 
				
			||||||
 | 
					                    scales: {
 | 
				
			||||||
 | 
					                        x: {
 | 
				
			||||||
 | 
					                            stacked: true,
 | 
				
			||||||
 | 
					                            type: "time",
 | 
				
			||||||
 | 
					                            time: {
 | 
				
			||||||
 | 
					                                unit: "day",
 | 
				
			||||||
 | 
					                            },
 | 
				
			||||||
 | 
					                        },
 | 
				
			||||||
 | 
					                        y: {
 | 
				
			||||||
 | 
					                            stacked: true,
 | 
				
			||||||
 | 
					                        },
 | 
				
			||||||
 | 
					                    },
 | 
				
			||||||
 | 
					                },
 | 
				
			||||||
 | 
					            });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            new Chart(document.getElementById("historyLong"), {
 | 
				
			||||||
 | 
					                type: "bar",
 | 
				
			||||||
 | 
					                data: {
 | 
				
			||||||
 | 
					                    labels: [...data.historyLong.sent, ...data.historyLong.failed].map(
 | 
				
			||||||
 | 
					                        (row) => luxon.DateTime.fromISO(row.time_key)
 | 
				
			||||||
 | 
					                    ),
 | 
				
			||||||
 | 
					                    datasets: [
 | 
				
			||||||
 | 
					                        {
 | 
				
			||||||
 | 
					                            label: "Success",
 | 
				
			||||||
 | 
					                            data: data.historyLong.sent.map((row) => row.count),
 | 
				
			||||||
 | 
					                        },
 | 
				
			||||||
 | 
					                        {
 | 
				
			||||||
 | 
					                            label: "Fail",
 | 
				
			||||||
 | 
					                            data: data.historyLong.failed.map((row) => row.count),
 | 
				
			||||||
 | 
					                        },
 | 
				
			||||||
 | 
					                    ],
 | 
				
			||||||
 | 
					                },
 | 
				
			||||||
 | 
					                options: {
 | 
				
			||||||
 | 
					                    responsive: true,
 | 
				
			||||||
 | 
					                    maintainAspectRatio: false,
 | 
				
			||||||
 | 
					                    scales: {
 | 
				
			||||||
 | 
					                        x: {
 | 
				
			||||||
 | 
					                            stacked: true,
 | 
				
			||||||
 | 
					                            type: "time",
 | 
				
			||||||
 | 
					                            time: {
 | 
				
			||||||
 | 
					                                unit: "day",
 | 
				
			||||||
 | 
					                            },
 | 
				
			||||||
 | 
					                        },
 | 
				
			||||||
 | 
					                        y: {
 | 
				
			||||||
 | 
					                            stacked: true,
 | 
				
			||||||
 | 
					                        },
 | 
				
			||||||
 | 
					                    },
 | 
				
			||||||
 | 
					                },
 | 
				
			||||||
 | 
					            });
 | 
				
			||||||
 | 
					        });
 | 
				
			||||||
 | 
					});
 | 
				
			||||||
							
								
								
									
										20
									
								
								web/static/js/chart.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										20
									
								
								web/static/js/chart.js
									
									
									
									
									
										Normal file
									
								
							
										
											
												File diff suppressed because one or more lines are too long
											
										
									
								
							
							
								
								
									
										7
									
								
								web/static/js/chartjs-adapter-luxon.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										7
									
								
								web/static/js/chartjs-adapter-luxon.js
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,7 @@
 | 
				
			|||||||
 | 
					/*!
 | 
				
			||||||
 | 
					 * chartjs-adapter-luxon v1.3.1
 | 
				
			||||||
 | 
					 * https://www.chartjs.org
 | 
				
			||||||
 | 
					 * (c) 2023 chartjs-adapter-luxon Contributors
 | 
				
			||||||
 | 
					 * Released under the MIT license
 | 
				
			||||||
 | 
					 */
 | 
				
			||||||
 | 
					!function(e,t){"object"==typeof exports&&"undefined"!=typeof module?t(require("chart.js"),require("luxon")):"function"==typeof define&&define.amd?define(["chart.js","luxon"],t):t((e="undefined"!=typeof globalThis?globalThis:e||self).Chart,e.luxon)}(this,(function(e,t){"use strict";const n={datetime:t.DateTime.DATETIME_MED_WITH_SECONDS,millisecond:"h:mm:ss.SSS a",second:t.DateTime.TIME_WITH_SECONDS,minute:t.DateTime.TIME_SIMPLE,hour:{hour:"numeric"},day:{day:"numeric",month:"short"},week:"DD",month:{month:"short",year:"numeric"},quarter:"'Q'q - yyyy",year:{year:"numeric"}};e._adapters._date.override({_id:"luxon",_create:function(e){return t.DateTime.fromMillis(e,this.options)},init(e){this.options.locale||(this.options.locale=e.locale)},formats:function(){return n},parse:function(e,n){const i=this.options,r=typeof e;return null===e||"undefined"===r?null:("number"===r?e=this._create(e):"string"===r?e="string"==typeof n?t.DateTime.fromFormat(e,n,i):t.DateTime.fromISO(e,i):e instanceof Date?e=t.DateTime.fromJSDate(e,i):"object"!==r||e instanceof t.DateTime||(e=t.DateTime.fromObject(e,i)),e.isValid?e.valueOf():null)},format:function(e,t){const n=this._create(e);return"string"==typeof t?n.toFormat(t):n.toLocaleString(t)},add:function(e,t,n){const i={};return i[n]=t,this._create(e).plus(i).valueOf()},diff:function(e,t,n){return this._create(e).diff(this._create(t)).as(n).valueOf()},startOf:function(e,t,n){if("isoWeek"===t){n=Math.trunc(Math.min(Math.max(0,n),6));const t=this._create(e);return t.minus({days:(t.weekday-n+7)%7}).startOf("day").valueOf()}return t?this._create(e).startOf(t).valueOf():e},endOf:function(e,t){return this._create(e).endOf(t).valueOf()}})}));
 | 
				
			||||||
@@ -7,8 +7,8 @@ function get_interval(element) {
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
    return {
 | 
					    return {
 | 
				
			||||||
        months: parseInt(months) || null,
 | 
					        months: parseInt(months) || null,
 | 
				
			||||||
 | 
					        days: parseInt(days) || null,
 | 
				
			||||||
        seconds:
 | 
					        seconds:
 | 
				
			||||||
            (parseInt(days) || 0) * 86400 +
 | 
					 | 
				
			||||||
            (parseInt(hours) || 0) * 3600 +
 | 
					            (parseInt(hours) || 0) * 3600 +
 | 
				
			||||||
                (parseInt(minutes) || 0) * 60 +
 | 
					                (parseInt(minutes) || 0) * 60 +
 | 
				
			||||||
                (parseInt(seconds) || 0) || null,
 | 
					                (parseInt(seconds) || 0) || null,
 | 
				
			||||||
@@ -22,6 +22,15 @@ function update_interval(element) {
 | 
				
			|||||||
    let minutes = element.querySelector('input[name="interval_minutes"]');
 | 
					    let minutes = element.querySelector('input[name="interval_minutes"]');
 | 
				
			||||||
    let seconds = element.querySelector('input[name="interval_seconds"]');
 | 
					    let seconds = element.querySelector('input[name="interval_seconds"]');
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    let interval = get_interval(element);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    if (interval.months === null && interval.days === null && interval.seconds === null) {
 | 
				
			||||||
 | 
					        months.value = "";
 | 
				
			||||||
 | 
					        days.value = "";
 | 
				
			||||||
 | 
					        hours.value = "";
 | 
				
			||||||
 | 
					        minutes.value = "";
 | 
				
			||||||
 | 
					        seconds.value = "";
 | 
				
			||||||
 | 
					    } else {
 | 
				
			||||||
        months.value = months.value.padStart(1, "0");
 | 
					        months.value = months.value.padStart(1, "0");
 | 
				
			||||||
        days.value = days.value.padStart(1, "0");
 | 
					        days.value = days.value.padStart(1, "0");
 | 
				
			||||||
        hours.value = hours.value.padStart(2, "0");
 | 
					        hours.value = hours.value.padStart(2, "0");
 | 
				
			||||||
@@ -33,7 +42,10 @@ function update_interval(element) {
 | 
				
			|||||||
            let remainder = seconds.value % 60;
 | 
					            let remainder = seconds.value % 60;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
            seconds.value = String(remainder).padStart(2, "0");
 | 
					            seconds.value = String(remainder).padStart(2, "0");
 | 
				
			||||||
        minutes.value = String(Number(minutes.value) + Number(quotient)).padStart(2, "0");
 | 
					            minutes.value = String(Number(minutes.value) + Number(quotient)).padStart(
 | 
				
			||||||
 | 
					                2,
 | 
				
			||||||
 | 
					                "0"
 | 
				
			||||||
 | 
					            );
 | 
				
			||||||
        }
 | 
					        }
 | 
				
			||||||
        if (minutes.value >= 60) {
 | 
					        if (minutes.value >= 60) {
 | 
				
			||||||
            let quotient = Math.floor(minutes.value / 60);
 | 
					            let quotient = Math.floor(minutes.value / 60);
 | 
				
			||||||
@@ -42,12 +54,6 @@ function update_interval(element) {
 | 
				
			|||||||
            minutes.value = String(remainder).padStart(2, "0");
 | 
					            minutes.value = String(remainder).padStart(2, "0");
 | 
				
			||||||
            hours.value = String(Number(hours.value) + Number(quotient)).padStart(2, "0");
 | 
					            hours.value = String(Number(hours.value) + Number(quotient)).padStart(2, "0");
 | 
				
			||||||
        }
 | 
					        }
 | 
				
			||||||
    if (hours.value >= 24) {
 | 
					 | 
				
			||||||
        let quotient = Math.floor(hours.value / 24);
 | 
					 | 
				
			||||||
        let remainder = hours.value % 24;
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
        hours.value = String(remainder).padStart(2, "0");
 | 
					 | 
				
			||||||
        days.value = Number(days.value) + Number(quotient);
 | 
					 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -33,7 +33,16 @@ let globalPatreon = false;
 | 
				
			|||||||
let guildPatreon = false;
 | 
					let guildPatreon = false;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
function guildId() {
 | 
					function guildId() {
 | 
				
			||||||
    return document.querySelector(".guildList a.is-active").dataset["guild"];
 | 
					    return window.location.pathname.match(/dashboard\/(\d+)/)[1];
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					function pane() {
 | 
				
			||||||
 | 
					    const match = window.location.pathname.match(/dashboard\/\d+\/(.+)/);
 | 
				
			||||||
 | 
					    if (match === null) {
 | 
				
			||||||
 | 
					        return null;
 | 
				
			||||||
 | 
					    } else {
 | 
				
			||||||
 | 
					        return match[1];
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
function colorToInt(r, g, b) {
 | 
					function colorToInt(r, g, b) {
 | 
				
			||||||
@@ -56,18 +65,36 @@ function switch_pane(selector) {
 | 
				
			|||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
function update_select(sel) {
 | 
					function update_select(sel) {
 | 
				
			||||||
    if (sel.selectedOptions[0].dataset["webhookAvatar"]) {
 | 
					    let channelDisplay = sel.closest("div.reminderContent").querySelector(".channel-bar");
 | 
				
			||||||
        sel.closest("div.reminderContent").querySelector("img.discord-avatar").src =
 | 
					
 | 
				
			||||||
            sel.selectedOptions[0].dataset["webhookAvatar"];
 | 
					    if (channelDisplay !== null) {
 | 
				
			||||||
    } else {
 | 
					        channelDisplay.textContent = `#${sel.selectedOptions[0].textContent}`;
 | 
				
			||||||
        sel.closest("div.reminderContent").querySelector("img.discord-avatar").src = "";
 | 
					 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
    if (sel.selectedOptions[0].dataset["webhookName"]) {
 | 
					
 | 
				
			||||||
        sel.closest("div.reminderContent").querySelector("input.discord-username").value =
 | 
					    if (sel.selectedOptions[0] === undefined) {
 | 
				
			||||||
            sel.selectedOptions[0].dataset["webhookName"];
 | 
					        return;
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    const avatarInput = sel.closest("div.reminderContent").querySelector("img.avatar");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    if (!avatarInput.dataset["set"]) {
 | 
				
			||||||
 | 
					        if (sel.selectedOptions[0].dataset["webhookAvatar"]) {
 | 
				
			||||||
 | 
					            avatarInput.src = sel.selectedOptions[0].dataset["webhookAvatar"];
 | 
				
			||||||
        } else {
 | 
					        } else {
 | 
				
			||||||
        sel.closest("div.reminderContent").querySelector("input.discord-username").value =
 | 
					            avatarInput.src = "/static/img/icon.png";
 | 
				
			||||||
            "";
 | 
					        }
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    const usernameInput = sel
 | 
				
			||||||
 | 
					        .closest("div.reminderContent")
 | 
				
			||||||
 | 
					        .querySelector("input.discord-username");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    if (usernameInput.value.length === 0) {
 | 
				
			||||||
 | 
					        if (sel.selectedOptions[0].dataset["webhookName"]) {
 | 
				
			||||||
 | 
					            usernameInput.value = sel.selectedOptions[0].dataset["webhookName"];
 | 
				
			||||||
 | 
					        } else {
 | 
				
			||||||
 | 
					            usernameInput.value = "Reminder";
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -78,7 +105,7 @@ function reset_guild_pane() {
 | 
				
			|||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
async function fetch_patreon(guild_id) {
 | 
					async function fetch_patreon(guild_id) {
 | 
				
			||||||
    fetch(`/dashboard/api/guild/${guild_id}/patreon`)
 | 
					    fetch(`/dashboard/api/guild/${guild_id}`)
 | 
				
			||||||
        .then((response) => response.json())
 | 
					        .then((response) => response.json())
 | 
				
			||||||
        .then((data) => {
 | 
					        .then((data) => {
 | 
				
			||||||
            if (data.error) {
 | 
					            if (data.error) {
 | 
				
			||||||
@@ -138,12 +165,18 @@ async function fetch_channels(guild_id) {
 | 
				
			|||||||
    const event = new Event("channelsLoading");
 | 
					    const event = new Event("channelsLoading");
 | 
				
			||||||
    document.dispatchEvent(event);
 | 
					    document.dispatchEvent(event);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    let hasError = false;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    await fetch(`/dashboard/api/guild/${guild_id}/channels`)
 | 
					    await fetch(`/dashboard/api/guild/${guild_id}/channels`)
 | 
				
			||||||
        .then((response) => response.json())
 | 
					        .then((response) => response.json())
 | 
				
			||||||
        .then((data) => {
 | 
					        .then((data) => {
 | 
				
			||||||
            if (data.error) {
 | 
					            if (data.error) {
 | 
				
			||||||
                if (data.error === "Bot not in guild") {
 | 
					                if (data.error === "Bot not in guild") {
 | 
				
			||||||
                    switch_pane("guild-error");
 | 
					                    switch_pane("guild-error");
 | 
				
			||||||
 | 
					                    hasError = true;
 | 
				
			||||||
 | 
					                } else if (data.error === "Incorrect permissions") {
 | 
				
			||||||
 | 
					                    switch_pane("user-error");
 | 
				
			||||||
 | 
					                    hasError = true;
 | 
				
			||||||
                } else {
 | 
					                } else {
 | 
				
			||||||
                    show_error(data.error);
 | 
					                    show_error(data.error);
 | 
				
			||||||
                }
 | 
					                }
 | 
				
			||||||
@@ -155,6 +188,8 @@ async function fetch_channels(guild_id) {
 | 
				
			|||||||
            const event = new Event("channelsLoaded");
 | 
					            const event = new Event("channelsLoaded");
 | 
				
			||||||
            document.dispatchEvent(event);
 | 
					            document.dispatchEvent(event);
 | 
				
			||||||
        });
 | 
					        });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    return hasError;
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
async function fetch_reminders(guild_id) {
 | 
					async function fetch_reminders(guild_id) {
 | 
				
			||||||
@@ -197,22 +232,25 @@ async function fetch_reminders(guild_id) {
 | 
				
			|||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
async function serialize_reminder(node, mode) {
 | 
					async function serialize_reminder(node, mode) {
 | 
				
			||||||
    let interval, utc_time, expiration_time;
 | 
					    let utc_time, expiration_time;
 | 
				
			||||||
 | 
					    let interval = get_interval(node);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    if (mode !== "template") {
 | 
					    if (mode !== "template") {
 | 
				
			||||||
        interval = get_interval(node);
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
        utc_time = luxon.DateTime.fromISO(
 | 
					        utc_time = luxon.DateTime.fromISO(
 | 
				
			||||||
            node.querySelector('input[name="time"]').value
 | 
					            node.querySelector('input[name="time"]').value
 | 
				
			||||||
        ).setZone("UTC");
 | 
					        ).setZone("UTC");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        if (utc_time.invalid) {
 | 
					        if (utc_time.invalid) {
 | 
				
			||||||
            return { error: "Time provided invalid." };
 | 
					            return { error: "Time provided invalid." };
 | 
				
			||||||
        } else {
 | 
					        } else {
 | 
				
			||||||
            utc_time = utc_time.toFormat("yyyy-LL-dd'T'HH:mm:ss");
 | 
					            utc_time = utc_time.toFormat("yyyy-LL-dd'T'HH:mm:ss");
 | 
				
			||||||
        }
 | 
					        }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        let expiration = node.querySelector('input[name="expiration"]').value;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        if (expiration) {
 | 
				
			||||||
            expiration_time = luxon.DateTime.fromISO(
 | 
					            expiration_time = luxon.DateTime.fromISO(
 | 
				
			||||||
            node.querySelector('input[name="time"]').value
 | 
					                node.querySelector('input[name="expiration"]').value
 | 
				
			||||||
            ).setZone("UTC");
 | 
					            ).setZone("UTC");
 | 
				
			||||||
            if (expiration_time.invalid) {
 | 
					            if (expiration_time.invalid) {
 | 
				
			||||||
                return { error: "Expiration provided invalid." };
 | 
					                return { error: "Expiration provided invalid." };
 | 
				
			||||||
@@ -220,6 +258,12 @@ async function serialize_reminder(node, mode) {
 | 
				
			|||||||
                expiration_time = expiration_time.toFormat("yyyy-LL-dd'T'HH:mm:ss");
 | 
					                expiration_time = expiration_time.toFormat("yyyy-LL-dd'T'HH:mm:ss");
 | 
				
			||||||
            }
 | 
					            }
 | 
				
			||||||
        }
 | 
					        }
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    let name = node.querySelector('input[name="name"]').value;
 | 
				
			||||||
 | 
					    if (name.length > 100) {
 | 
				
			||||||
 | 
					        return { error: "Name exceeds maximum length (100)." };
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    let rgb_color = window.getComputedStyle(
 | 
					    let rgb_color = window.getComputedStyle(
 | 
				
			||||||
        node.querySelector("div.discord-embed")
 | 
					        node.querySelector("div.discord-embed")
 | 
				
			||||||
@@ -283,15 +327,17 @@ async function serialize_reminder(node, mode) {
 | 
				
			|||||||
    const embed_title = node.querySelector('textarea[name="embed_title"]').value;
 | 
					    const embed_title = node.querySelector('textarea[name="embed_title"]').value;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    if (
 | 
					    if (
 | 
				
			||||||
        attachment === null &&
 | 
					        content.length === 0 &&
 | 
				
			||||||
        content.length == 0 &&
 | 
					        embed_author.length === 0 &&
 | 
				
			||||||
 | 
					        embed_title.length === 0 &&
 | 
				
			||||||
 | 
					        embed_description.length === 0 &&
 | 
				
			||||||
 | 
					        embed_footer.length === 0 &&
 | 
				
			||||||
        embed_author_url === null &&
 | 
					        embed_author_url === null &&
 | 
				
			||||||
        embed_author.length == 0 &&
 | 
					 | 
				
			||||||
        embed_description.length == 0 &&
 | 
					 | 
				
			||||||
        embed_footer.length == 0 &&
 | 
					 | 
				
			||||||
        embed_footer_url === null &&
 | 
					        embed_footer_url === null &&
 | 
				
			||||||
        embed_image_url === null &&
 | 
					        embed_image_url === null &&
 | 
				
			||||||
        embed_thumbnail_url === null
 | 
					        embed_thumbnail_url === null &&
 | 
				
			||||||
 | 
					        fields.length === 0 &&
 | 
				
			||||||
 | 
					        attachment === null
 | 
				
			||||||
    ) {
 | 
					    ) {
 | 
				
			||||||
        return { error: "Reminder needs content." };
 | 
					        return { error: "Reminder needs content." };
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
@@ -304,7 +350,7 @@ async function serialize_reminder(node, mode) {
 | 
				
			|||||||
        restartable: false,
 | 
					        restartable: false,
 | 
				
			||||||
        attachment: attachment,
 | 
					        attachment: attachment,
 | 
				
			||||||
        attachment_name: attachment_name,
 | 
					        attachment_name: attachment_name,
 | 
				
			||||||
        avatar: has_source(node.querySelector("img.discord-avatar").src),
 | 
					        avatar: has_source(node.querySelector("img.avatar").src),
 | 
				
			||||||
        channel: node.querySelector("select.channel-selector").value,
 | 
					        channel: node.querySelector("select.channel-selector").value,
 | 
				
			||||||
        content: content,
 | 
					        content: content,
 | 
				
			||||||
        embed_author_url: embed_author_url,
 | 
					        embed_author_url: embed_author_url,
 | 
				
			||||||
@@ -318,8 +364,9 @@ async function serialize_reminder(node, mode) {
 | 
				
			|||||||
        embed_title: embed_title,
 | 
					        embed_title: embed_title,
 | 
				
			||||||
        embed_fields: fields,
 | 
					        embed_fields: fields,
 | 
				
			||||||
        expires: expiration_time,
 | 
					        expires: expiration_time,
 | 
				
			||||||
        interval_seconds: mode !== "template" ? interval.seconds : null,
 | 
					        interval_seconds: interval.seconds,
 | 
				
			||||||
        interval_months: mode !== "template" ? interval.months : null,
 | 
					        interval_days: interval.days,
 | 
				
			||||||
 | 
					        interval_months: interval.months,
 | 
				
			||||||
        name: node.querySelector('input[name="name"]').value,
 | 
					        name: node.querySelector('input[name="name"]').value,
 | 
				
			||||||
        tts: node.querySelector('input[name="tts"]').checked,
 | 
					        tts: node.querySelector('input[name="tts"]').checked,
 | 
				
			||||||
        username: node.querySelector('input[name="username"]').value,
 | 
					        username: node.querySelector('input[name="username"]').value,
 | 
				
			||||||
@@ -331,6 +378,9 @@ function deserialize_reminder(reminder, frame, mode) {
 | 
				
			|||||||
    // populate channels
 | 
					    // populate channels
 | 
				
			||||||
    set_channels(frame.querySelector("select.channel-selector"));
 | 
					    set_channels(frame.querySelector("select.channel-selector"));
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    frame.querySelector(`*[name="interval_hours"]`).value = 0;
 | 
				
			||||||
 | 
					    frame.querySelector(`*[name="interval_minutes"]`).value = 0;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    // populate majority of items
 | 
					    // populate majority of items
 | 
				
			||||||
    for (let prop in reminder) {
 | 
					    for (let prop in reminder) {
 | 
				
			||||||
        if (reminder.hasOwnProperty(prop) && reminder[prop] !== null) {
 | 
					        if (reminder.hasOwnProperty(prop) && reminder[prop] !== null) {
 | 
				
			||||||
@@ -345,15 +395,27 @@ function deserialize_reminder(reminder, frame, mode) {
 | 
				
			|||||||
                if ($input !== null) {
 | 
					                if ($input !== null) {
 | 
				
			||||||
                    $input.value = reminder[prop];
 | 
					                    $input.value = reminder[prop];
 | 
				
			||||||
                } else if ($image !== null) {
 | 
					                } else if ($image !== null) {
 | 
				
			||||||
 | 
					                    console.log(`loading img ${prop}`);
 | 
				
			||||||
                    $image.src = reminder[prop];
 | 
					                    $image.src = reminder[prop];
 | 
				
			||||||
 | 
					                    $image.dataset["set"] = "1";
 | 
				
			||||||
                }
 | 
					                }
 | 
				
			||||||
            }
 | 
					            }
 | 
				
			||||||
        }
 | 
					        }
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    const lastChild = frame.querySelector("div.embed-multifield-box .embed-field-box");
 | 
					    update_interval(frame);
 | 
				
			||||||
 | 
					    update_select(frame.querySelector(".channel-selector"));
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    for (let field of reminder["embed_fields"]) {
 | 
					    const lastChild = frame.querySelector(
 | 
				
			||||||
 | 
					        "div.embed-multifield-box .embed-field-box:last-child"
 | 
				
			||||||
 | 
					    );
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    // Drop existing fields
 | 
				
			||||||
 | 
					    frame
 | 
				
			||||||
 | 
					        .querySelectorAll(".embed-field-box:not(:last-child)")
 | 
				
			||||||
 | 
					        .forEach((el) => el.remove());
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    for (let field of reminder["embed_fields"] || []) {
 | 
				
			||||||
        let embed_field = $embedFieldTemplate.content.cloneNode(true);
 | 
					        let embed_field = $embedFieldTemplate.content.cloneNode(true);
 | 
				
			||||||
        embed_field.querySelector("textarea.discord-field-title").value = field["title"];
 | 
					        embed_field.querySelector("textarea.discord-field-title").value = field["title"];
 | 
				
			||||||
        embed_field.querySelector("textarea.discord-field-value").value = field["value"];
 | 
					        embed_field.querySelector("textarea.discord-field-value").value = field["value"];
 | 
				
			||||||
@@ -366,9 +428,9 @@ function deserialize_reminder(reminder, frame, mode) {
 | 
				
			|||||||
            .insertBefore(embed_field, lastChild);
 | 
					            .insertBefore(embed_field, lastChild);
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    if (mode !== "template") {
 | 
					 | 
				
			||||||
    if (reminder["interval_seconds"]) update_interval(frame);
 | 
					    if (reminder["interval_seconds"]) update_interval(frame);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    if (mode !== "template") {
 | 
				
			||||||
        let $enableBtn = frame.querySelector(".disable-enable");
 | 
					        let $enableBtn = frame.querySelector(".disable-enable");
 | 
				
			||||||
        $enableBtn.dataset["action"] = reminder["enabled"] ? "disable" : "enable";
 | 
					        $enableBtn.dataset["action"] = reminder["enabled"] ? "disable" : "enable";
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -379,7 +441,7 @@ function deserialize_reminder(reminder, frame, mode) {
 | 
				
			|||||||
        timeInput.value = localTime.toFormat("yyyy-LL-dd'T'HH:mm:ss");
 | 
					        timeInput.value = localTime.toFormat("yyyy-LL-dd'T'HH:mm:ss");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        if (reminder["expires"]) {
 | 
					        if (reminder["expires"]) {
 | 
				
			||||||
            let expiresInput = frame.querySelector('input[name="time"]');
 | 
					            let expiresInput = frame.querySelector('input[name="expiration"]');
 | 
				
			||||||
            let expiresTime = luxon.DateTime.fromISO(reminder["expires"], {
 | 
					            let expiresTime = luxon.DateTime.fromISO(reminder["expires"], {
 | 
				
			||||||
                zone: "UTC",
 | 
					                zone: "UTC",
 | 
				
			||||||
            }).setZone(timezone);
 | 
					            }).setZone(timezone);
 | 
				
			||||||
@@ -399,9 +461,19 @@ document.addEventListener("guildSwitched", async (e) => {
 | 
				
			|||||||
        `.switch-pane[data-guild="${e.detail.guild_id}"]`
 | 
					        `.switch-pane[data-guild="${e.detail.guild_id}"]`
 | 
				
			||||||
    );
 | 
					    );
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    switch_pane($anchor.dataset["pane"]);
 | 
					    let hasError = false;
 | 
				
			||||||
    reset_guild_pane();
 | 
					
 | 
				
			||||||
 | 
					    if (pane() === null) {
 | 
				
			||||||
 | 
					        window.history.replaceState({}, "", `/dashboard/${guildId()}/reminders`);
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    switch_pane(pane());
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    if ($anchor !== null) {
 | 
				
			||||||
        $anchor.classList.add("is-active");
 | 
					        $anchor.classList.add("is-active");
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    reset_guild_pane();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    if (globalPatreon || (await fetch_patreon(e.detail.guild_id))) {
 | 
					    if (globalPatreon || (await fetch_patreon(e.detail.guild_id))) {
 | 
				
			||||||
        document
 | 
					        document
 | 
				
			||||||
@@ -409,9 +481,10 @@ document.addEventListener("guildSwitched", async (e) => {
 | 
				
			|||||||
            .forEach((el) => el.classList.remove("is-locked"));
 | 
					            .forEach((el) => el.classList.remove("is-locked"));
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    hasError = await fetch_channels(e.detail.guild_id);
 | 
				
			||||||
 | 
					    if (!hasError) {
 | 
				
			||||||
        fetch_roles(e.detail.guild_id);
 | 
					        fetch_roles(e.detail.guild_id);
 | 
				
			||||||
        fetch_templates(e.detail.guild_id);
 | 
					        fetch_templates(e.detail.guild_id);
 | 
				
			||||||
    await fetch_channels(e.detail.guild_id);
 | 
					 | 
				
			||||||
        fetch_reminders(e.detail.guild_id);
 | 
					        fetch_reminders(e.detail.guild_id);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        document.querySelectorAll("p.pageTitle").forEach((el) => {
 | 
					        document.querySelectorAll("p.pageTitle").forEach((el) => {
 | 
				
			||||||
@@ -422,6 +495,7 @@ document.addEventListener("guildSwitched", async (e) => {
 | 
				
			|||||||
                update_select(e.target);
 | 
					                update_select(e.target);
 | 
				
			||||||
            });
 | 
					            });
 | 
				
			||||||
        });
 | 
					        });
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    $loader.classList.add("is-hidden");
 | 
					    $loader.classList.add("is-hidden");
 | 
				
			||||||
});
 | 
					});
 | 
				
			||||||
@@ -433,6 +507,12 @@ document.addEventListener("channelsLoaded", () => {
 | 
				
			|||||||
document.addEventListener("remindersLoaded", (event) => {
 | 
					document.addEventListener("remindersLoaded", (event) => {
 | 
				
			||||||
    const guild = guildId();
 | 
					    const guild = guildId();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    document.querySelectorAll("select.channel-selector").forEach((el) => {
 | 
				
			||||||
 | 
					        el.addEventListener("change", (e) => {
 | 
				
			||||||
 | 
					            update_select(e.target);
 | 
				
			||||||
 | 
					        });
 | 
				
			||||||
 | 
					    });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    for (let reminder of event.detail) {
 | 
					    for (let reminder of event.detail) {
 | 
				
			||||||
        let node = reminder.node;
 | 
					        let node = reminder.node;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -460,9 +540,9 @@ document.addEventListener("remindersLoaded", (event) => {
 | 
				
			|||||||
                    if (data.error) {
 | 
					                    if (data.error) {
 | 
				
			||||||
                        show_error(data.error);
 | 
					                        show_error(data.error);
 | 
				
			||||||
                    } else {
 | 
					                    } else {
 | 
				
			||||||
                        enableBtn.dataset["action"] = data["enabled"]
 | 
					                        enableBtn.dataset["action"] = data.reminder["enabled"]
 | 
				
			||||||
                            ? "enable"
 | 
					                            ? "disable"
 | 
				
			||||||
                            : "disable";
 | 
					                            : "enable";
 | 
				
			||||||
                    }
 | 
					                    }
 | 
				
			||||||
                });
 | 
					                });
 | 
				
			||||||
        });
 | 
					        });
 | 
				
			||||||
@@ -497,6 +577,8 @@ document.addEventListener("remindersLoaded", (event) => {
 | 
				
			|||||||
                .then((response) => response.json())
 | 
					                .then((response) => response.json())
 | 
				
			||||||
                .then((data) => {
 | 
					                .then((data) => {
 | 
				
			||||||
                    for (let error of data.errors) show_error(error);
 | 
					                    for (let error of data.errors) show_error(error);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                    deserialize_reminder(data.reminder, node, "reload");
 | 
				
			||||||
                });
 | 
					                });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
            $saveBtn.querySelector("span.icon > i").classList = ["fas fa-check"];
 | 
					            $saveBtn.querySelector("span.icon > i").classList = ["fas fa-check"];
 | 
				
			||||||
@@ -532,6 +614,16 @@ function show_error(error) {
 | 
				
			|||||||
    }, 5000);
 | 
					    }, 5000);
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					function show_success(error) {
 | 
				
			||||||
 | 
					    document.getElementById("success").querySelector("span.success-message").textContent =
 | 
				
			||||||
 | 
					        error;
 | 
				
			||||||
 | 
					    document.getElementById("success").classList.add("is-active");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    window.setTimeout(() => {
 | 
				
			||||||
 | 
					        document.getElementById("success").classList.remove("is-active");
 | 
				
			||||||
 | 
					    }, 5000);
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
$colorPickerInput.value = colorPicker.color.hexString;
 | 
					$colorPickerInput.value = colorPicker.color.hexString;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
$colorPickerInput.addEventListener("input", () => {
 | 
					$colorPickerInput.addEventListener("input", () => {
 | 
				
			||||||
@@ -557,7 +649,7 @@ document.querySelectorAll(".show-modal").forEach((element) => {
 | 
				
			|||||||
    });
 | 
					    });
 | 
				
			||||||
});
 | 
					});
 | 
				
			||||||
 | 
					
 | 
				
			||||||
document.addEventListener("DOMContentLoaded", () => {
 | 
					document.addEventListener("DOMContentLoaded", async () => {
 | 
				
			||||||
    $loader.classList.remove("is-hidden");
 | 
					    $loader.classList.remove("is-hidden");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    mentions.attach(document.querySelectorAll("textarea"));
 | 
					    mentions.attach(document.querySelectorAll("textarea"));
 | 
				
			||||||
@@ -577,7 +669,7 @@ document.addEventListener("DOMContentLoaded", () => {
 | 
				
			|||||||
        hideBox.closest(".reminderContent").classList.toggle("is-collapsed");
 | 
					        hideBox.closest(".reminderContent").classList.toggle("is-collapsed");
 | 
				
			||||||
    });
 | 
					    });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    fetch("/dashboard/api/user")
 | 
					    await fetch("/dashboard/api/user")
 | 
				
			||||||
        .then((response) => response.json())
 | 
					        .then((response) => response.json())
 | 
				
			||||||
        .then((data) => {
 | 
					        .then((data) => {
 | 
				
			||||||
            if (data.error) {
 | 
					            if (data.error) {
 | 
				
			||||||
@@ -591,7 +683,7 @@ document.addEventListener("DOMContentLoaded", () => {
 | 
				
			|||||||
            }
 | 
					            }
 | 
				
			||||||
        });
 | 
					        });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    fetch("/dashboard/api/user/guilds")
 | 
					    await fetch("/dashboard/api/user/guilds")
 | 
				
			||||||
        .then((response) => response.json())
 | 
					        .then((response) => response.json())
 | 
				
			||||||
        .then((data) => {
 | 
					        .then((data) => {
 | 
				
			||||||
            if (data.error) {
 | 
					            if (data.error) {
 | 
				
			||||||
@@ -614,11 +706,15 @@ document.addEventListener("DOMContentLoaded", () => {
 | 
				
			|||||||
                        );
 | 
					                        );
 | 
				
			||||||
                        $anchor.dataset["guild"] = guild.id;
 | 
					                        $anchor.dataset["guild"] = guild.id;
 | 
				
			||||||
                        $anchor.dataset["name"] = guild.name;
 | 
					                        $anchor.dataset["name"] = guild.name;
 | 
				
			||||||
                        $anchor.href = `/dashboard/${guild.id}?name=${guild.name}`;
 | 
					                        $anchor.href = `/dashboard/${guild.id}/reminders`;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
                        $anchor.addEventListener("click", async (e) => {
 | 
					                        $anchor.addEventListener("click", async (e) => {
 | 
				
			||||||
                            e.preventDefault();
 | 
					                            e.preventDefault();
 | 
				
			||||||
                            window.history.pushState({}, "", `/dashboard/${guild.id}`);
 | 
					                            window.history.pushState(
 | 
				
			||||||
 | 
					                                {},
 | 
				
			||||||
 | 
					                                "",
 | 
				
			||||||
 | 
					                                `/dashboard/${guild.id}/reminders`
 | 
				
			||||||
 | 
					                            );
 | 
				
			||||||
                            const event = new CustomEvent("guildSwitched", {
 | 
					                            const event = new CustomEvent("guildSwitched", {
 | 
				
			||||||
                                detail: {
 | 
					                                detail: {
 | 
				
			||||||
                                    guild_name: guild.name,
 | 
					                                    guild_name: guild.name,
 | 
				
			||||||
@@ -682,11 +778,25 @@ $uploader.addEventListener("change", (ev) => {
 | 
				
			|||||||
        fileReader.onload = (e) => resolve(fileReader.result);
 | 
					        fileReader.onload = (e) => resolve(fileReader.result);
 | 
				
			||||||
        fileReader.readAsDataURL($uploader.files[0]);
 | 
					        fileReader.readAsDataURL($uploader.files[0]);
 | 
				
			||||||
    }).then((dataUrl) => {
 | 
					    }).then((dataUrl) => {
 | 
				
			||||||
 | 
					        $importBtn.setAttribute("disabled", true);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        fetch(`/dashboard/api/guild/${guildId()}/export/${urlTail}`, {
 | 
					        fetch(`/dashboard/api/guild/${guildId()}/export/${urlTail}`, {
 | 
				
			||||||
            method: "PUT",
 | 
					            method: "PUT",
 | 
				
			||||||
            body: JSON.stringify({ body: dataUrl.split(",")[1] }),
 | 
					            body: JSON.stringify({ body: dataUrl.split(",")[1] }),
 | 
				
			||||||
        }).then(() => {
 | 
					        })
 | 
				
			||||||
 | 
					            .then((response) => response.json())
 | 
				
			||||||
 | 
					            .then((data) => {
 | 
				
			||||||
 | 
					                $importBtn.removeAttribute("disabled");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                if (data.error) {
 | 
				
			||||||
 | 
					                    show_error(data.error);
 | 
				
			||||||
 | 
					                } else {
 | 
				
			||||||
 | 
					                    show_success(data.message);
 | 
				
			||||||
 | 
					                }
 | 
				
			||||||
 | 
					            })
 | 
				
			||||||
 | 
					            .then(() => {
 | 
				
			||||||
                delete $uploader.files[0];
 | 
					                delete $uploader.files[0];
 | 
				
			||||||
 | 
					                fetch_reminders(guild);
 | 
				
			||||||
            });
 | 
					            });
 | 
				
			||||||
    });
 | 
					    });
 | 
				
			||||||
});
 | 
					});
 | 
				
			||||||
@@ -715,6 +825,7 @@ $createReminderBtn.addEventListener("click", async () => {
 | 
				
			|||||||
    let reminder = await serialize_reminder($createReminder, "create");
 | 
					    let reminder = await serialize_reminder($createReminder, "create");
 | 
				
			||||||
    if (reminder.error) {
 | 
					    if (reminder.error) {
 | 
				
			||||||
        show_error(reminder.error);
 | 
					        show_error(reminder.error);
 | 
				
			||||||
 | 
					        $createReminderBtn.querySelector("span.icon > i").classList = ["fas fa-sparkles"];
 | 
				
			||||||
        return;
 | 
					        return;
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -772,6 +883,14 @@ $createTemplateBtn.addEventListener("click", async () => {
 | 
				
			|||||||
    ];
 | 
					    ];
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    let reminder = await serialize_reminder($createReminder, "template");
 | 
					    let reminder = await serialize_reminder($createReminder, "template");
 | 
				
			||||||
 | 
					    if (reminder.error) {
 | 
				
			||||||
 | 
					        show_error(reminder.error);
 | 
				
			||||||
 | 
					        $createTemplateBtn.querySelector("span.icon > i").classList = [
 | 
				
			||||||
 | 
					            "fas fa-file-spreadsheet",
 | 
				
			||||||
 | 
					        ];
 | 
				
			||||||
 | 
					        return;
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    let guild = guildId();
 | 
					    let guild = guildId();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    fetch(`/dashboard/api/guild/${guild}/templates`, {
 | 
					    fetch(`/dashboard/api/guild/${guild}/templates`, {
 | 
				
			||||||
@@ -813,6 +932,7 @@ $loadTemplateBtn.addEventListener("click", (ev) => {
 | 
				
			|||||||
});
 | 
					});
 | 
				
			||||||
 | 
					
 | 
				
			||||||
$deleteTemplateBtn.addEventListener("click", (ev) => {
 | 
					$deleteTemplateBtn.addEventListener("click", (ev) => {
 | 
				
			||||||
 | 
					    if (parseInt($templateSelect.value) !== null) {
 | 
				
			||||||
        fetch(`/dashboard/api/guild/${guildId()}/templates`, {
 | 
					        fetch(`/dashboard/api/guild/${guildId()}/templates`, {
 | 
				
			||||||
            method: "DELETE",
 | 
					            method: "DELETE",
 | 
				
			||||||
            headers: {
 | 
					            headers: {
 | 
				
			||||||
@@ -830,13 +950,7 @@ $deleteTemplateBtn.addEventListener("click", (ev) => {
 | 
				
			|||||||
                        .remove();
 | 
					                        .remove();
 | 
				
			||||||
                }
 | 
					                }
 | 
				
			||||||
            });
 | 
					            });
 | 
				
			||||||
});
 | 
					    }
 | 
				
			||||||
 | 
					 | 
				
			||||||
document.querySelectorAll("textarea.autoresize").forEach((element) => {
 | 
					 | 
				
			||||||
    element.addEventListener("input", () => {
 | 
					 | 
				
			||||||
        element.style.height = "";
 | 
					 | 
				
			||||||
        element.style.height = element.scrollHeight + 3 + "px";
 | 
					 | 
				
			||||||
    });
 | 
					 | 
				
			||||||
});
 | 
					});
 | 
				
			||||||
 | 
					
 | 
				
			||||||
let $img;
 | 
					let $img;
 | 
				
			||||||
@@ -894,6 +1008,13 @@ document.addEventListener("remindersLoaded", () => {
 | 
				
			|||||||
                window.getComputedStyle($discordFrame).borderLeftColor;
 | 
					                window.getComputedStyle($discordFrame).borderLeftColor;
 | 
				
			||||||
        });
 | 
					        });
 | 
				
			||||||
    });
 | 
					    });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    document.querySelectorAll("textarea.autoresize").forEach((element) => {
 | 
				
			||||||
 | 
					        element.addEventListener("input", () => {
 | 
				
			||||||
 | 
					            element.style.height = "";
 | 
				
			||||||
 | 
					            element.style.height = element.scrollHeight + 3 + "px";
 | 
				
			||||||
 | 
					        });
 | 
				
			||||||
 | 
					    });
 | 
				
			||||||
});
 | 
					});
 | 
				
			||||||
 | 
					
 | 
				
			||||||
function check_embed_fields() {
 | 
					function check_embed_fields() {
 | 
				
			||||||
@@ -969,6 +1090,13 @@ document.addEventListener("click", (ev) => {
 | 
				
			|||||||
    if (ev.target.closest("button.inline-btn") !== null) {
 | 
					    if (ev.target.closest("button.inline-btn") !== null) {
 | 
				
			||||||
        let inlined = ev.target.closest(".embed-field-box").dataset["inlined"];
 | 
					        let inlined = ev.target.closest(".embed-field-box").dataset["inlined"];
 | 
				
			||||||
        ev.target.closest(".embed-field-box").dataset["inlined"] =
 | 
					        ev.target.closest(".embed-field-box").dataset["inlined"] =
 | 
				
			||||||
            inlined == "1" ? "0" : "1";
 | 
					            inlined === "1" ? "0" : "1";
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
});
 | 
					});
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					document.addEventListener("DOMContentLoaded", () => {
 | 
				
			||||||
 | 
					    let now = luxon.DateTime.now().setZone(timezone);
 | 
				
			||||||
 | 
					    document.querySelectorAll(".prefill-now").forEach((el) => {
 | 
				
			||||||
 | 
					        el.value = now.toFormat("yyyy-LL-dd'T'HH:mm:ss");
 | 
				
			||||||
 | 
					    });
 | 
				
			||||||
 | 
					});
 | 
				
			||||||
 
 | 
				
			|||||||
							
								
								
									
										16
									
								
								web/static/js/reporter.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										16
									
								
								web/static/js/reporter.js
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,16 @@
 | 
				
			|||||||
 | 
					const REPORTER_ID = crypto.randomUUID();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					window.addEventListener("error", async (ev) => {
 | 
				
			||||||
 | 
					    await fetch("/report", {
 | 
				
			||||||
 | 
					        method: "POST",
 | 
				
			||||||
 | 
					        body: JSON.stringify({
 | 
				
			||||||
 | 
					            reporterId: REPORTER_ID,
 | 
				
			||||||
 | 
					            url: window.location.href,
 | 
				
			||||||
 | 
					            relativeTimestamp: ev.timeStamp,
 | 
				
			||||||
 | 
					            errorMessage: ev.message,
 | 
				
			||||||
 | 
					            errorLine: ev.lineno,
 | 
				
			||||||
 | 
					            errorFile: ev.filename,
 | 
				
			||||||
 | 
					            errorType: ev.type,
 | 
				
			||||||
 | 
					        }),
 | 
				
			||||||
 | 
					    });
 | 
				
			||||||
 | 
					});
 | 
				
			||||||
@@ -1,14 +1,15 @@
 | 
				
			|||||||
{
 | 
					{
 | 
				
			||||||
    "name": "",
 | 
					    "name": "Reminder Bot Dashboard",
 | 
				
			||||||
    "short_name": "",
 | 
					    "short_name": "Reminders",
 | 
				
			||||||
 | 
					    "start_url": "/dashboard",
 | 
				
			||||||
    "icons": [
 | 
					    "icons": [
 | 
				
			||||||
        {
 | 
					        {
 | 
				
			||||||
            "src": "/android-chrome-192x192.png",
 | 
					            "src": "/static/favicon/android-chrome-192x192.png",
 | 
				
			||||||
            "sizes": "192x192",
 | 
					            "sizes": "192x192",
 | 
				
			||||||
            "type": "image/png"
 | 
					            "type": "image/png"
 | 
				
			||||||
        },
 | 
					        },
 | 
				
			||||||
        {
 | 
					        {
 | 
				
			||||||
            "src": "/android-chrome-512x512.png",
 | 
					            "src": "/static/favicon/android-chrome-512x512.png",
 | 
				
			||||||
            "sizes": "512x512",
 | 
					            "sizes": "512x512",
 | 
				
			||||||
            "type": "image/png"
 | 
					            "type": "image/png"
 | 
				
			||||||
        }
 | 
					        }
 | 
				
			||||||
							
								
								
									
										89
									
								
								web/templates/admin_dashboard.html.tera
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										89
									
								
								web/templates/admin_dashboard.html.tera
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,89 @@
 | 
				
			|||||||
 | 
					<!DOCTYPE html>
 | 
				
			||||||
 | 
					<html lang="EN">
 | 
				
			||||||
 | 
					<head>
 | 
				
			||||||
 | 
					    <script src="/static/js/reporter.js" type="application/javascript"></script>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    <meta name="description" content="The most powerful Discord Reminders Bot">
 | 
				
			||||||
 | 
					    <meta name="viewport" content="width=device-width, initial-scale=1.0">
 | 
				
			||||||
 | 
					    <meta charset="UTF-8">
 | 
				
			||||||
 | 
					    <meta name="yandex-verification" content="bb77b8681eb64a90"/>
 | 
				
			||||||
 | 
					    <meta name="google-site-verification" content="7h7UVTeEe0AOzHiH3cFtsqMULYGN-zCZdMT_YCkW1Ho"/>
 | 
				
			||||||
 | 
					    <!-- <meta http-equiv="Content-Security-Policy" content="default-src 'self'; img-src *; font-src fonts.gstatic.com 'self'"> -->
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    <!-- favicon -->
 | 
				
			||||||
 | 
					    <link rel="apple-touch-icon" sizes="180x180"
 | 
				
			||||||
 | 
					          href="/static/favicon/apple-touch-icon.png">
 | 
				
			||||||
 | 
					    <link rel="icon" type="image/png" sizes="32x32"
 | 
				
			||||||
 | 
					          href="/static/favicon/favicon-32x32.png">
 | 
				
			||||||
 | 
					    <link rel="icon" type="image/png" sizes="16x16"
 | 
				
			||||||
 | 
					          href="/static/favicon/favicon-16x16.png">
 | 
				
			||||||
 | 
					    <link rel="manifest" href="/static/favicon/site.webmanifest">
 | 
				
			||||||
 | 
					    <meta name="msapplication-TileColor" content="#da532c">
 | 
				
			||||||
 | 
					    <meta name="theme-color" content="#ffffff">
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    <title>Reminder Bot | Admin</title>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    <!-- styles -->
 | 
				
			||||||
 | 
					    <link rel="stylesheet" href="/static/css/bulma.min.css">
 | 
				
			||||||
 | 
					    <link rel="stylesheet" href="/static/css/fa.css">
 | 
				
			||||||
 | 
					    <link rel="stylesheet" href="/static/css/font.css">
 | 
				
			||||||
 | 
					    <link rel="stylesheet" href="/static/css/style.css">
 | 
				
			||||||
 | 
					    <link rel="stylesheet" href="/static/css/dtsel.css">
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    <script src="/static/js/luxon.min.js"></script>
 | 
				
			||||||
 | 
					</head>
 | 
				
			||||||
 | 
					<body style="width: 100%;">
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					<p class="title pageTitle">Admin dashboard</p>
 | 
				
			||||||
 | 
					<section id="main">
 | 
				
			||||||
 | 
					    <div class="stat-row">
 | 
				
			||||||
 | 
					        <div class="stat-box" style="height: 400px;">
 | 
				
			||||||
 | 
					            <canvas id="schedule"></canvas>
 | 
				
			||||||
 | 
					        </div>
 | 
				
			||||||
 | 
					    </div>
 | 
				
			||||||
 | 
					    <div class="stat-row">
 | 
				
			||||||
 | 
					        <div class="stat-box figure">
 | 
				
			||||||
 | 
					            <p>Backlog</p>
 | 
				
			||||||
 | 
					            <p class="figure-num" id="backlog">?</p>
 | 
				
			||||||
 | 
					        </div>
 | 
				
			||||||
 | 
					        <div class="stat-box figure">
 | 
				
			||||||
 | 
					            <p>Reminders</p>
 | 
				
			||||||
 | 
					            <p class="figure-num" id="reminders">?</p>
 | 
				
			||||||
 | 
					        </div>
 | 
				
			||||||
 | 
					        <div class="stat-box figure">
 | 
				
			||||||
 | 
					            <p>Intervals</p>
 | 
				
			||||||
 | 
					            <p class="figure-num" id="intervals">?</p>
 | 
				
			||||||
 | 
					        </div>
 | 
				
			||||||
 | 
					    </div>
 | 
				
			||||||
 | 
					    <div class="stat-row">
 | 
				
			||||||
 | 
					        <div class="stat-box" style="height: 400px;">
 | 
				
			||||||
 | 
					            <canvas id="scheduleLong"></canvas>
 | 
				
			||||||
 | 
					        </div>
 | 
				
			||||||
 | 
					    </div>
 | 
				
			||||||
 | 
					    <div class="stat-row">
 | 
				
			||||||
 | 
					        <div class="stat-box figure">
 | 
				
			||||||
 | 
					            <p>Last 31 days (success)</p>
 | 
				
			||||||
 | 
					            <p class="figure-num" id="historySent">?</p>
 | 
				
			||||||
 | 
					        </div>
 | 
				
			||||||
 | 
					        <div class="stat-box figure">
 | 
				
			||||||
 | 
					            <p>Last 31 days (failed)</p>
 | 
				
			||||||
 | 
					            <p class="figure-num" id="historyFailed">?</p>
 | 
				
			||||||
 | 
					        </div>
 | 
				
			||||||
 | 
					        <div class="stat-box figure">
 | 
				
			||||||
 | 
					            <p>Last 31 days (failure rate)</p>
 | 
				
			||||||
 | 
					            <p class="figure-num" id="failRate">?</p>
 | 
				
			||||||
 | 
					        </div>
 | 
				
			||||||
 | 
					    </div>
 | 
				
			||||||
 | 
					    <div class="stat-row">
 | 
				
			||||||
 | 
					        <div class="stat-box" style="height: 400px;">
 | 
				
			||||||
 | 
					            <canvas id="historyLong"></canvas>
 | 
				
			||||||
 | 
					        </div>
 | 
				
			||||||
 | 
					    </div>
 | 
				
			||||||
 | 
					</section>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					<script src="/static/js/chart.js" defer></script>
 | 
				
			||||||
 | 
					<script src="/static/js/chartjs-adapter-luxon.js" defer></script>
 | 
				
			||||||
 | 
					<script src="/static/js/admin.js" defer></script>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					</body>
 | 
				
			||||||
 | 
					</html>
 | 
				
			||||||
@@ -13,7 +13,7 @@
 | 
				
			|||||||
    <link rel="apple-touch-icon" sizes="180x180" href="/static/favicon/apple-touch-icon.png">
 | 
					    <link rel="apple-touch-icon" sizes="180x180" href="/static/favicon/apple-touch-icon.png">
 | 
				
			||||||
    <link rel="icon" type="image/png" sizes="32x32" href="/static/favicon/favicon-32x32.png">
 | 
					    <link rel="icon" type="image/png" sizes="32x32" href="/static/favicon/favicon-32x32.png">
 | 
				
			||||||
    <link rel="icon" type="image/png" sizes="16x16" href="/static/favicon/favicon-16x16.png">
 | 
					    <link rel="icon" type="image/png" sizes="16x16" href="/static/favicon/favicon-16x16.png">
 | 
				
			||||||
    <link rel="manifest" href="/static/favicon/site.webmanifest">
 | 
					    <link rel="manifest" href="/static/site.webmanifest">
 | 
				
			||||||
    <meta name="msapplication-TileColor" content="#da532c">
 | 
					    <meta name="msapplication-TileColor" content="#da532c">
 | 
				
			||||||
    <meta name="theme-color" content="#ffffff">
 | 
					    <meta name="theme-color" content="#ffffff">
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -51,8 +51,8 @@
 | 
				
			|||||||
                <a class="navbar-item" href="https://invite.reminder-bot.com">
 | 
					                <a class="navbar-item" href="https://invite.reminder-bot.com">
 | 
				
			||||||
                    <i class="fas fa-plus"></i>
 | 
					                    <i class="fas fa-plus"></i>
 | 
				
			||||||
                </a>
 | 
					                </a>
 | 
				
			||||||
                <a class="navbar-item" href="https://github.com/jellywx">
 | 
					                <a class="navbar-item" href="https://gitea.jellypro.xyz/jude">
 | 
				
			||||||
                    <i class="fab fa-github"></i>
 | 
					                    <i class="fab fa-git-square"></i>
 | 
				
			||||||
                </a>
 | 
					                </a>
 | 
				
			||||||
                <a class="navbar-item" href="https://discord.jellywx.com">
 | 
					                <a class="navbar-item" href="https://discord.jellywx.com">
 | 
				
			||||||
                    <i class="fab fa-discord"></i>
 | 
					                    <i class="fab fa-discord"></i>
 | 
				
			||||||
@@ -128,7 +128,7 @@
 | 
				
			|||||||
                </div>
 | 
					                </div>
 | 
				
			||||||
            {% elif show_login %}
 | 
					            {% elif show_login %}
 | 
				
			||||||
                <div class="hero-foot has-text-centered">
 | 
					                <div class="hero-foot has-text-centered">
 | 
				
			||||||
                    <a class="button is-size-4 is-rounded is-light" href="/oauth/login">
 | 
					                    <a class="button is-size-4 is-rounded is-light" href="/login/discord">
 | 
				
			||||||
                        <p class="is-size-4">
 | 
					                        <p class="is-size-4">
 | 
				
			||||||
                            <span>Login with Discord</span> <span class="icon"><i class="fas fa-chevron-right"></i></span>
 | 
					                            <span>Login with Discord</span> <span class="icon"><i class="fas fa-chevron-right"></i></span>
 | 
				
			||||||
                        </p>
 | 
					                        </p>
 | 
				
			||||||
@@ -155,7 +155,7 @@
 | 
				
			|||||||
                <br>
 | 
					                <br>
 | 
				
			||||||
                <a href="/cookies">Cookies</a> | <a href="/privacy">Privacy Policy</a> | <a href="/terms">Terms of Service</a>
 | 
					                <a href="/cookies">Cookies</a> | <a href="/privacy">Privacy Policy</a> | <a href="/terms">Terms of Service</a>
 | 
				
			||||||
                <br>
 | 
					                <br>
 | 
				
			||||||
                <a href="https://patreon.com/jellywx"><strong>Patreon</strong></a> | <a href="https://discord.jellywx.com"><strong>Discord</strong></a> | <a href="https://github.com/JellyWX"><strong>GitHub</strong></a>
 | 
					                <a href="https://patreon.com/jellywx"><strong>Patreon</strong></a> | <a href="https://discord.jellywx.com"><strong>Discord</strong></a> | <a href="https://gitea.jellypro.xyz/jude"><strong>Gitea</strong></a>
 | 
				
			||||||
                <br>
 | 
					                <br>
 | 
				
			||||||
                or, <a href="mailto:jude@jellywx.com">Email me</a>
 | 
					                or, <a href="mailto:jude@jellywx.com">Email me</a>
 | 
				
			||||||
            </p>
 | 
					            </p>
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -1,6 +1,8 @@
 | 
				
			|||||||
<!DOCTYPE html>
 | 
					<!DOCTYPE html>
 | 
				
			||||||
<html lang="EN">
 | 
					<html lang="EN">
 | 
				
			||||||
<head>
 | 
					<head>
 | 
				
			||||||
 | 
					    <script src="/static/js/reporter.js" type="application/javascript"></script>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    <meta name="description" content="The most powerful Discord Reminders Bot">
 | 
					    <meta name="description" content="The most powerful Discord Reminders Bot">
 | 
				
			||||||
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
 | 
					    <meta name="viewport" content="width=device-width, initial-scale=1.0">
 | 
				
			||||||
    <meta charset="UTF-8">
 | 
					    <meta charset="UTF-8">
 | 
				
			||||||
@@ -25,7 +27,7 @@
 | 
				
			|||||||
    <link rel="stylesheet" href="/static/css/bulma.min.css">
 | 
					    <link rel="stylesheet" href="/static/css/bulma.min.css">
 | 
				
			||||||
    <link rel="stylesheet" href="/static/css/fa.css">
 | 
					    <link rel="stylesheet" href="/static/css/fa.css">
 | 
				
			||||||
    <link rel="stylesheet" href="/static/css/font.css">
 | 
					    <link rel="stylesheet" href="/static/css/font.css">
 | 
				
			||||||
    <link rel="stylesheet" href="/static/css/style.css">
 | 
					    <link rel="stylesheet" href="/static/css/style.css?v{{ version }}">
 | 
				
			||||||
    <link rel="stylesheet" href="/static/css/dtsel.css">
 | 
					    <link rel="stylesheet" href="/static/css/dtsel.css">
 | 
				
			||||||
    <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/tributejs/5.1.3/tribute.css" integrity="sha512-GnwBnXd+ZGO9CdP343MUr0jCcJXCr++JVtQRnllexRW2IDq4Zvrh/McTQjooAKnSUbXZ7wamp7AQSweTnfMVoA==" crossorigin="anonymous" referrerpolicy="no-referrer" />
 | 
					    <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/tributejs/5.1.3/tribute.css" integrity="sha512-GnwBnXd+ZGO9CdP343MUr0jCcJXCr++JVtQRnllexRW2IDq4Zvrh/McTQjooAKnSUbXZ7wamp7AQSweTnfMVoA==" crossorigin="anonymous" referrerpolicy="no-referrer" />
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -38,14 +40,14 @@
 | 
				
			|||||||
    <div class="navbar-brand">
 | 
					    <div class="navbar-brand">
 | 
				
			||||||
        <a class="navbar-item" href="/">
 | 
					        <a class="navbar-item" href="/">
 | 
				
			||||||
            <figure class="image">
 | 
					            <figure class="image">
 | 
				
			||||||
                <img src="/static/img/logo_flat.webp" alt="Reminder Bot Logo">
 | 
					                <img width="28px" height="28px" src="/static/img/logo_nobg.webp" alt="Reminder Bot Logo">
 | 
				
			||||||
            </figure>
 | 
					            </figure>
 | 
				
			||||||
        </a>
 | 
					        </a>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        <p class="navbar-item pageTitle">
 | 
					        <p class="navbar-item pageTitle">
 | 
				
			||||||
        </p>
 | 
					        </p>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        <a role="button" class="navbar-burger is-right" aria-label="menu" aria-expanded="false"
 | 
					        <a role="button" class="dashboard-burger navbar-burger is-right" aria-label="menu" aria-expanded="false"
 | 
				
			||||||
           data-target="mobileSidebar">
 | 
					           data-target="mobileSidebar">
 | 
				
			||||||
            <span aria-hidden="true"></span>
 | 
					            <span aria-hidden="true"></span>
 | 
				
			||||||
            <span aria-hidden="true"></span>
 | 
					            <span aria-hidden="true"></span>
 | 
				
			||||||
@@ -74,6 +76,10 @@
 | 
				
			|||||||
    <span class="icon"><i class="far fa-exclamation-circle"></i></span> <span class="error-message"></span>
 | 
					    <span class="icon"><i class="far fa-exclamation-circle"></i></span> <span class="error-message"></span>
 | 
				
			||||||
</div>
 | 
					</div>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					<div class="notification is-success flash-message" id="success">
 | 
				
			||||||
 | 
					    <span class="icon"><i class="far fa-check"></i></span> <span class="success-message"></span>
 | 
				
			||||||
 | 
					</div>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
<div class="modal" id="addImageModal">
 | 
					<div class="modal" id="addImageModal">
 | 
				
			||||||
    <div class="modal-background"></div>
 | 
					    <div class="modal-background"></div>
 | 
				
			||||||
    <div class="modal-card">
 | 
					    <div class="modal-card">
 | 
				
			||||||
@@ -183,27 +189,8 @@
 | 
				
			|||||||
                    </label>
 | 
					                    </label>
 | 
				
			||||||
                </div>
 | 
					                </div>
 | 
				
			||||||
            </div>
 | 
					            </div>
 | 
				
			||||||
            <div class="control">
 | 
					 | 
				
			||||||
                <div class="field">
 | 
					 | 
				
			||||||
                    <label>
 | 
					 | 
				
			||||||
                        <input type="radio" class="default-width" name="exportSelect" value="todos">
 | 
					 | 
				
			||||||
                        Todo Lists
 | 
					 | 
				
			||||||
                    </label>
 | 
					 | 
				
			||||||
                </div>
 | 
					 | 
				
			||||||
            </div>
 | 
					 | 
				
			||||||
            <div class="control">
 | 
					 | 
				
			||||||
                <div class="field">
 | 
					 | 
				
			||||||
                    <label>
 | 
					 | 
				
			||||||
                        <input type="radio" class="default-width" name="exportSelect" value="reminder_templates">
 | 
					 | 
				
			||||||
                        Reminder templates
 | 
					 | 
				
			||||||
                    </label>
 | 
					 | 
				
			||||||
                </div>
 | 
					 | 
				
			||||||
            </div>
 | 
					 | 
				
			||||||
            <br>
 | 
					            <br>
 | 
				
			||||||
            <div class="has-text-centered">
 | 
					            <div class="has-text-centered">
 | 
				
			||||||
                <div style="color: red; font-weight: bold;">
 | 
					 | 
				
			||||||
                    By selecting "Import", you understand that this will overwrite existing data.
 | 
					 | 
				
			||||||
                </div>
 | 
					 | 
				
			||||||
                <div style="color: red">
 | 
					                <div style="color: red">
 | 
				
			||||||
                    Please first read the <a href="/help/iemanager">support page</a>
 | 
					                    Please first read the <a href="/help/iemanager">support page</a>
 | 
				
			||||||
                </div>
 | 
					                </div>
 | 
				
			||||||
@@ -242,7 +229,8 @@
 | 
				
			|||||||
    <div class="column is-2 is-sidebar-menu dashboard-sidebar is-hidden-touch">
 | 
					    <div class="column is-2 is-sidebar-menu dashboard-sidebar is-hidden-touch">
 | 
				
			||||||
        <a href="/">
 | 
					        <a href="/">
 | 
				
			||||||
            <div class="brand">
 | 
					            <div class="brand">
 | 
				
			||||||
                <img src="/static/img/logo_flat.webp" alt="Reminder bot logo"
 | 
					                <img src="/static/img/logo_nobg.webp" alt="Reminder bot logo"
 | 
				
			||||||
 | 
					                     width="52px" height="52px"
 | 
				
			||||||
                     class="dashboard-brand">
 | 
					                     class="dashboard-brand">
 | 
				
			||||||
            </div>
 | 
					            </div>
 | 
				
			||||||
        </a>
 | 
					        </a>
 | 
				
			||||||
@@ -261,7 +249,7 @@
 | 
				
			|||||||
            </ul>
 | 
					            </ul>
 | 
				
			||||||
            <div class="aside-footer">
 | 
					            <div class="aside-footer">
 | 
				
			||||||
                <p class="menu-label">
 | 
					                <p class="menu-label">
 | 
				
			||||||
                    Settings
 | 
					                    Options
 | 
				
			||||||
                </p>
 | 
					                </p>
 | 
				
			||||||
                <ul class="menu-list">
 | 
					                <ul class="menu-list">
 | 
				
			||||||
                    <li>
 | 
					                    <li>
 | 
				
			||||||
@@ -271,6 +259,12 @@
 | 
				
			|||||||
                        <a class="show-modal" data-modal="chooseTimezoneModal">
 | 
					                        <a class="show-modal" data-modal="chooseTimezoneModal">
 | 
				
			||||||
                            <span class="icon"><i class="fas fa-map-marked"></i></span> Timezone
 | 
					                            <span class="icon"><i class="fas fa-map-marked"></i></span> Timezone
 | 
				
			||||||
                        </a>
 | 
					                        </a>
 | 
				
			||||||
 | 
					                        <a href="/login/discord/logout">
 | 
				
			||||||
 | 
					                            <span class="icon"><i class="fas fa-sign-out"></i></span> Log out
 | 
				
			||||||
 | 
					                        </a>
 | 
				
			||||||
 | 
					                        <a href="https://discord.jellywx.com" class="feedback">
 | 
				
			||||||
 | 
					                            <span class="icon"><i class="fab fa-discord"></i></span> Give feedback
 | 
				
			||||||
 | 
					                        </a>
 | 
				
			||||||
                    </li>
 | 
					                    </li>
 | 
				
			||||||
                </ul>
 | 
					                </ul>
 | 
				
			||||||
            </div>
 | 
					            </div>
 | 
				
			||||||
@@ -280,7 +274,7 @@
 | 
				
			|||||||
    <div class="dashboard-sidebar mobile-sidebar is-hidden-desktop" id="mobileSidebar">
 | 
					    <div class="dashboard-sidebar mobile-sidebar is-hidden-desktop" id="mobileSidebar">
 | 
				
			||||||
        <a href="/">
 | 
					        <a href="/">
 | 
				
			||||||
            <div class="brand">
 | 
					            <div class="brand">
 | 
				
			||||||
                <img src="/static/img/logo_flat.webp" alt="Reminder bot logo"
 | 
					                <img src="/static/img/logo_nobg.webp" alt="Reminder bot logo"
 | 
				
			||||||
                     class="dashboard-brand">
 | 
					                     class="dashboard-brand">
 | 
				
			||||||
            </div>
 | 
					            </div>
 | 
				
			||||||
        </a>
 | 
					        </a>
 | 
				
			||||||
@@ -309,6 +303,12 @@
 | 
				
			|||||||
                        <a class="show-modal" data-modal="chooseTimezoneModal">
 | 
					                        <a class="show-modal" data-modal="chooseTimezoneModal">
 | 
				
			||||||
                            <span class="icon"><i class="fas fa-map-marked"></i></span> Timezone
 | 
					                            <span class="icon"><i class="fas fa-map-marked"></i></span> Timezone
 | 
				
			||||||
                        </a>
 | 
					                        </a>
 | 
				
			||||||
 | 
					                        <a href="/login/discord/logout">
 | 
				
			||||||
 | 
					                            <span class="icon"><i class="fas fa-sign-out"></i></span> Log out
 | 
				
			||||||
 | 
					                        </a>
 | 
				
			||||||
 | 
					                        <a href="https://discord.jellywx.com/" class="feedback">
 | 
				
			||||||
 | 
					                            <span class="icon"><i class="fab fa-discord"></i></span> Give feedback
 | 
				
			||||||
 | 
					                        </a>
 | 
				
			||||||
                    </li>
 | 
					                    </li>
 | 
				
			||||||
                </ul>
 | 
					                </ul>
 | 
				
			||||||
            </div>
 | 
					            </div>
 | 
				
			||||||
@@ -325,25 +325,17 @@
 | 
				
			|||||||
                <p class="subtitle is-hidden-desktop">Press the <span class="icon"><i class="fal fa-bars"></i></span> to get started</p>
 | 
					                <p class="subtitle is-hidden-desktop">Press the <span class="icon"><i class="fal fa-bars"></i></span> to get started</p>
 | 
				
			||||||
            </div>
 | 
					            </div>
 | 
				
			||||||
        </section>
 | 
					        </section>
 | 
				
			||||||
        <section id="guild" class="is-hidden">
 | 
					        <section id="reminders" class="is-hidden">
 | 
				
			||||||
            {% include "reminder_dashboard/reminder_dashboard" %}
 | 
					            {% include "reminder_dashboard/reminder_dashboard" %}
 | 
				
			||||||
        </section>
 | 
					        </section>
 | 
				
			||||||
        <section id="guild-error" class="is-hidden hero is-fullheight">
 | 
					        <section id="reminder-errors" class="is-hidden">
 | 
				
			||||||
            <div class="hero-body">
 | 
					            {% include "reminder_dashboard/reminder_errors" %}
 | 
				
			||||||
                <div class="container has-text-centered">
 | 
					        </section>
 | 
				
			||||||
                    <p class="title">
 | 
					        <section id="guild-error" class="is-hidden">
 | 
				
			||||||
                        We couldn't get this server's data
 | 
					            {% include "reminder_dashboard/guild_error" %}
 | 
				
			||||||
                    </p>
 | 
					        </section>
 | 
				
			||||||
                    <p class="subtitle">
 | 
					        <section id="user-error" class="is-hidden">
 | 
				
			||||||
                        Please check Reminder Bot is in the server, and has correct permissions.
 | 
					            {% include "reminder_dashboard/user_error" %}
 | 
				
			||||||
                    </p>
 | 
					 | 
				
			||||||
                    <a class="button is-size-4 is-rounded is-success" href="https://invite.reminder-bot.com">
 | 
					 | 
				
			||||||
                        <p class="is-size-4">
 | 
					 | 
				
			||||||
                            <span>Add to Server</span> <span class="icon"><i class="fas fa-chevron-right"></i></span>
 | 
					 | 
				
			||||||
                        </p>
 | 
					 | 
				
			||||||
                    </a>
 | 
					 | 
				
			||||||
                </div>
 | 
					 | 
				
			||||||
            </div>
 | 
					 | 
				
			||||||
        </section>
 | 
					        </section>
 | 
				
			||||||
    </div>
 | 
					    </div>
 | 
				
			||||||
    <!-- /main content -->
 | 
					    <!-- /main content -->
 | 
				
			||||||
@@ -389,9 +381,9 @@
 | 
				
			|||||||
<script src="/static/js/iro.js"></script>
 | 
					<script src="/static/js/iro.js"></script>
 | 
				
			||||||
<script src="/static/js/dtsel.js"></script>
 | 
					<script src="/static/js/dtsel.js"></script>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
<script src="/static/js/interval.js"></script>
 | 
					<script src="/static/js/interval.js?v{{ version }}"></script>
 | 
				
			||||||
<script src="/static/js/timezone.js" defer></script>
 | 
					<script src="/static/js/timezone.js?v{{ version }}" defer></script>
 | 
				
			||||||
<script src="/static/js/main.js" defer></script>
 | 
					<script src="/static/js/main.js?v{{ version }}" defer></script>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
</body>
 | 
					</body>
 | 
				
			||||||
</html>
 | 
					</html>
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -27,7 +27,7 @@
 | 
				
			|||||||
            </div>
 | 
					            </div>
 | 
				
			||||||
            <div class="tile is-parent">
 | 
					            <div class="tile is-parent">
 | 
				
			||||||
                <article class="tile is-child notification">
 | 
					                <article class="tile is-child notification">
 | 
				
			||||||
                    <p class="title">Creating reminders</p>
 | 
					                    <p class="title">Create reminders</p>
 | 
				
			||||||
                    <p class="subtitle">Learn to create reminders for your server</p>
 | 
					                    <p class="subtitle">Learn to create reminders for your server</p>
 | 
				
			||||||
                    <div class="content has-text-centered">
 | 
					                    <div class="content has-text-centered">
 | 
				
			||||||
                        <a class="button is-size-4 is-rounded is-light" href="/help/create_reminder">
 | 
					                        <a class="button is-size-4 is-rounded is-light" href="/help/create_reminder">
 | 
				
			||||||
@@ -52,47 +52,47 @@
 | 
				
			|||||||
                </article>
 | 
					                </article>
 | 
				
			||||||
            </div>
 | 
					            </div>
 | 
				
			||||||
        </div>
 | 
					        </div>
 | 
				
			||||||
        <div class="tile is-ancestor">
 | 
					<!--        <div class="tile is-ancestor">-->
 | 
				
			||||||
            <div class="tile is-parent">
 | 
					<!--            <div class="tile is-parent">-->
 | 
				
			||||||
                <article class="tile is-child notification">
 | 
					<!--                <article class="tile is-child notification">-->
 | 
				
			||||||
                    <p class="title">Timers</p>
 | 
					<!--                    <p class="title">Timers</p>-->
 | 
				
			||||||
                    <p class="subtitle">Learn to manage timers</p>
 | 
					<!--                    <p class="subtitle">Learn to manage timers</p>-->
 | 
				
			||||||
                    <div class="content has-text-centered">
 | 
					<!--                    <div class="content has-text-centered">-->
 | 
				
			||||||
                        <a class="button is-size-4 is-rounded is-light" href="/help/timers">
 | 
					<!--                        <a class="button is-size-4 is-rounded is-light" href="/help/timers">-->
 | 
				
			||||||
                            <p class="is-size-4">
 | 
					<!--                            <p class="is-size-4">-->
 | 
				
			||||||
                                Read <span class="icon"><i class="fas fa-chevron-right"></i></span>
 | 
					<!--                                Read <span class="icon"><i class="fas fa-chevron-right"></i></span>-->
 | 
				
			||||||
                            </p>
 | 
					<!--                            </p>-->
 | 
				
			||||||
                        </a>
 | 
					<!--                        </a>-->
 | 
				
			||||||
                    </div>
 | 
					<!--                    </div>-->
 | 
				
			||||||
                </article>
 | 
					<!--                </article>-->
 | 
				
			||||||
            </div>
 | 
					<!--            </div>-->
 | 
				
			||||||
            <div class="tile is-parent">
 | 
					<!--            <div class="tile is-parent">-->
 | 
				
			||||||
                <article class="tile is-child notification">
 | 
					<!--                <article class="tile is-child notification">-->
 | 
				
			||||||
                    <p class="title">Todo Lists</p>
 | 
					<!--                    <p class="title">Todo Lists</p>-->
 | 
				
			||||||
                    <p class="subtitle">Learn to manage various todo lists</p>
 | 
					<!--                    <p class="subtitle">Learn to manage various todo lists</p>-->
 | 
				
			||||||
                    <div class="content has-text-centered">
 | 
					<!--                    <div class="content has-text-centered">-->
 | 
				
			||||||
                        <a class="button is-size-4 is-rounded is-light" href="/help/todo_lists">
 | 
					<!--                        <a class="button is-size-4 is-rounded is-light" href="/help/todo_lists">-->
 | 
				
			||||||
                            <p class="is-size-4">
 | 
					<!--                            <p class="is-size-4">-->
 | 
				
			||||||
                                Read <span class="icon"><i class="fas fa-chevron-right"></i></span>
 | 
					<!--                                Read <span class="icon"><i class="fas fa-chevron-right"></i></span>-->
 | 
				
			||||||
                            </p>
 | 
					<!--                            </p>-->
 | 
				
			||||||
                        </a>
 | 
					<!--                        </a>-->
 | 
				
			||||||
                    </div>
 | 
					<!--                    </div>-->
 | 
				
			||||||
                </article>
 | 
					<!--                </article>-->
 | 
				
			||||||
            </div>
 | 
					<!--            </div>-->
 | 
				
			||||||
            <div class="tile is-parent is-vertical">
 | 
					<!--            <div class="tile is-parent is-vertical">-->
 | 
				
			||||||
                <article class="tile is-child notification">
 | 
					<!--                <article class="tile is-child notification">-->
 | 
				
			||||||
                    <p class="title">Macros</p>
 | 
					<!--                    <p class="title">Macros</p>-->
 | 
				
			||||||
                    <p class="subtitle">Learn how to create combination commands called macros, to suit advanced use cases</p>
 | 
					<!--                    <p class="subtitle">Learn how to create combination commands called macros, to suit advanced use cases</p>-->
 | 
				
			||||||
                    <div class="content has-text-centered">
 | 
					<!--                    <div class="content has-text-centered">-->
 | 
				
			||||||
                        <a class="button is-size-4 is-rounded is-light" href="/help/macros">
 | 
					<!--                        <a class="button is-size-4 is-rounded is-light" href="/help/macros">-->
 | 
				
			||||||
                            <p class="is-size-4">
 | 
					<!--                            <p class="is-size-4">-->
 | 
				
			||||||
                                Read <span class="icon"><i class="fas fa-chevron-right"></i></span>
 | 
					<!--                                Read <span class="icon"><i class="fas fa-chevron-right"></i></span>-->
 | 
				
			||||||
                            </p>
 | 
					<!--                            </p>-->
 | 
				
			||||||
                        </a>
 | 
					<!--                        </a>-->
 | 
				
			||||||
                    </div>
 | 
					<!--                    </div>-->
 | 
				
			||||||
                </article>
 | 
					<!--                </article>-->
 | 
				
			||||||
            </div>
 | 
					<!--            </div>-->
 | 
				
			||||||
        </div>
 | 
					<!--        </div>-->
 | 
				
			||||||
        <div class="tile is-ancestor">
 | 
					        <div class="tile is-ancestor">
 | 
				
			||||||
            <div class="tile is-parent">
 | 
					            <div class="tile is-parent">
 | 
				
			||||||
                <article class="tile is-child notification">
 | 
					                <article class="tile is-child notification">
 | 
				
			||||||
@@ -107,7 +107,23 @@
 | 
				
			|||||||
                    </div>
 | 
					                    </div>
 | 
				
			||||||
                </article>
 | 
					                </article>
 | 
				
			||||||
            </div>
 | 
					            </div>
 | 
				
			||||||
 | 
					            <div class="tile is-parent is-vertical">
 | 
				
			||||||
 | 
					                {#
 | 
				
			||||||
 | 
					                <article class="tile is-child notification">
 | 
				
			||||||
 | 
					                    <p class="title">Import/export</p>
 | 
				
			||||||
 | 
					                    <p class="subtitle">Learn how to import and export data from the dashboard</p>
 | 
				
			||||||
 | 
					                    <div class="content has-text-centered">
 | 
				
			||||||
 | 
					                        <a class="button is-size-4 is-rounded is-light" href="/help/iemanager">
 | 
				
			||||||
 | 
					                            <p class="is-size-4">
 | 
				
			||||||
 | 
					                                Read <span class="icon"><i class="fas fa-chevron-right"></i></span>
 | 
				
			||||||
 | 
					                            </p>
 | 
				
			||||||
 | 
					                        </a>
 | 
				
			||||||
 | 
					                    </div>
 | 
				
			||||||
 | 
					                </article>
 | 
				
			||||||
 | 
					                #}
 | 
				
			||||||
 | 
					            </div>
 | 
				
			||||||
            <div class="tile is-parent">
 | 
					            <div class="tile is-parent">
 | 
				
			||||||
 | 
					                {#
 | 
				
			||||||
                <article class="tile is-child notification">
 | 
					                <article class="tile is-child notification">
 | 
				
			||||||
                    <p class="title">Dashboard</p>
 | 
					                    <p class="title">Dashboard</p>
 | 
				
			||||||
                    <p class="subtitle">Learn to use the interactive web dashboard</p>
 | 
					                    <p class="subtitle">Learn to use the interactive web dashboard</p>
 | 
				
			||||||
@@ -119,19 +135,7 @@
 | 
				
			|||||||
                        </a>
 | 
					                        </a>
 | 
				
			||||||
                    </div>
 | 
					                    </div>
 | 
				
			||||||
                </article>
 | 
					                </article>
 | 
				
			||||||
            </div>
 | 
					                #}
 | 
				
			||||||
            <div class="tile is-parent is-vertical">
 | 
					 | 
				
			||||||
                <article class="tile is-child notification">
 | 
					 | 
				
			||||||
                    <p class="title">Import/Export</p>
 | 
					 | 
				
			||||||
                    <p class="subtitle">Learn how to import and export data from the dashboard</p>
 | 
					 | 
				
			||||||
                    <div class="content has-text-centered">
 | 
					 | 
				
			||||||
                        <a class="button is-size-4 is-rounded is-light" href="/help/iemanager">
 | 
					 | 
				
			||||||
                            <p class="is-size-4">
 | 
					 | 
				
			||||||
                                Read <span class="icon"><i class="fas fa-chevron-right"></i></span>
 | 
					 | 
				
			||||||
                            </p>
 | 
					 | 
				
			||||||
                        </a>
 | 
					 | 
				
			||||||
                    </div>
 | 
					 | 
				
			||||||
                </article>
 | 
					 | 
				
			||||||
            </div>
 | 
					            </div>
 | 
				
			||||||
        </div>
 | 
					        </div>
 | 
				
			||||||
    </div>
 | 
					    </div>
 | 
				
			||||||
@@ -141,14 +145,14 @@
 | 
				
			|||||||
            <div class="container has-text-centered">
 | 
					            <div class="container has-text-centered">
 | 
				
			||||||
                <p class="title">Need more help?</p>
 | 
					                <p class="title">Need more help?</p>
 | 
				
			||||||
                <p class="content">
 | 
					                <p class="content">
 | 
				
			||||||
                    Feel free to come and ask us!
 | 
					                    Please come and ask us!
 | 
				
			||||||
                </p>
 | 
					                </p>
 | 
				
			||||||
            </div>
 | 
					            </div>
 | 
				
			||||||
        </div>
 | 
					        </div>
 | 
				
			||||||
        <div class="hero-foot has-text-centered">
 | 
					        <div class="hero-foot has-text-centered">
 | 
				
			||||||
            <a class="button is-size-6 is-rounded is-primary" href="https://discord.jellywx.com">
 | 
					            <a class="button is-size-6 is-rounded is-primary" href="https://discord.jellywx.com">
 | 
				
			||||||
                <p class="is-size-6">
 | 
					                <p class="is-size-6">
 | 
				
			||||||
                    Join Discord <span class="icon"><i class="fas fa-chevron-right"></i></span>
 | 
					                    <span>Join Discord</span> <span class="icon"><i class="fas fa-chevron-right"></i></span>
 | 
				
			||||||
                </p>
 | 
					                </p>
 | 
				
			||||||
            </a>
 | 
					            </a>
 | 
				
			||||||
        </div>
 | 
					        </div>
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -16,7 +16,7 @@
 | 
				
			|||||||
            <div class="tile is-parent">
 | 
					            <div class="tile is-parent">
 | 
				
			||||||
                <article class="tile is-child notification">
 | 
					                <article class="tile is-child notification">
 | 
				
			||||||
                    <p class="title">Slash-command Ready <svg aria-hidden="false" width="28" height="28" viewBox="0 0 24 24"><path fill="#777" fill-rule="evenodd" clip-rule="evenodd" d="M5 3C3.89543 3 3 3.89543 3 5V19C3 20.1046 3.89543 21 5 21H19C20.1046 21 21 20.1046 21 19V5C21 3.89543 20.1046 3 19 3H5ZM16.8995 8.41419L15.4853 6.99998L7 15.4853L8.41421 16.8995L16.8995 8.41419Z"></path></svg></p>
 | 
					                    <p class="title">Slash-command Ready <svg aria-hidden="false" width="28" height="28" viewBox="0 0 24 24"><path fill="#777" fill-rule="evenodd" clip-rule="evenodd" d="M5 3C3.89543 3 3 3.89543 3 5V19C3 20.1046 3.89543 21 5 21H19C20.1046 21 21 20.1046 21 19V5C21 3.89543 20.1046 3 19 3H5ZM16.8995 8.41419L15.4853 6.99998L7 15.4853L8.41421 16.8995L16.8995 8.41419Z"></path></svg></p>
 | 
				
			||||||
                    <p class="subtitle">Set reminders easily and quickly from anywhere</p>
 | 
					                    <p class="subtitle">Set reminders easily and quickly from anywhere.</p>
 | 
				
			||||||
                    <figure class="image">
 | 
					                    <figure class="image">
 | 
				
			||||||
                        <img class="rounded-corners" src="/static/img/slash-commands.png" alt="Discord slash commands demonstration">
 | 
					                        <img class="rounded-corners" src="/static/img/slash-commands.png" alt="Discord slash commands demonstration">
 | 
				
			||||||
                    </figure>
 | 
					                    </figure>
 | 
				
			||||||
@@ -25,7 +25,7 @@
 | 
				
			|||||||
            <div class="tile is-parent">
 | 
					            <div class="tile is-parent">
 | 
				
			||||||
                <article class="tile is-child notification">
 | 
					                <article class="tile is-child notification">
 | 
				
			||||||
                    <p class="title">Advanced Options <span class="icon"><i class="fad fa-palette"></i></span></p>
 | 
					                    <p class="title">Advanced Options <span class="icon"><i class="fad fa-palette"></i></span></p>
 | 
				
			||||||
                    <p class="subtitle">Decorate your announcements with our web dashboard</p>
 | 
					                    <p class="subtitle">Decorate your announcements with our web dashboard.</p>
 | 
				
			||||||
                    <figure class="image">
 | 
					                    <figure class="image">
 | 
				
			||||||
                        <img class="rounded-corners" src="/static/img/tournament-demo.png" alt="Advanced options demonstration">
 | 
					                        <img class="rounded-corners" src="/static/img/tournament-demo.png" alt="Advanced options demonstration">
 | 
				
			||||||
                    </figure>
 | 
					                    </figure>
 | 
				
			||||||
@@ -34,32 +34,62 @@
 | 
				
			|||||||
            <div class="tile is-parent is-vertical">
 | 
					            <div class="tile is-parent is-vertical">
 | 
				
			||||||
                <article class="tile is-child notification">
 | 
					                <article class="tile is-child notification">
 | 
				
			||||||
                    <p class="title">Unlimited Reminders <span class="icon"><i class="far fa-infinity"></i></span></p>
 | 
					                    <p class="title">Unlimited Reminders <span class="icon"><i class="far fa-infinity"></i></span></p>
 | 
				
			||||||
                    <p class="subtitle">Never forget a thing</p>
 | 
					                    <p class="subtitle">Never forget a thing.</p>
 | 
				
			||||||
                </article>
 | 
					                </article>
 | 
				
			||||||
                <article class="tile is-child notification">
 | 
					                <article class="tile is-child notification">
 | 
				
			||||||
                    <p class="title">Repeating Reminders <span class="icon"><i class="fas fa-repeat"></i></span></p>
 | 
					                    <p class="title">Repeating Reminders <span class="icon"><i class="fas fa-repeat"></i></span></p>
 | 
				
			||||||
                    <p class="subtitle">Available to <a href="https://patreon.com/jellywx"><span class="patreon-color">Patreon <span class="icon"><i class="fab fa-patreon"></i></span></span></a> subscribers at <strong>$2/month</strong></p>
 | 
					                    <p class="subtitle">Available to <a href="https://patreon.com/jellywx"><span class="patreon-color">Patreon <span class="icon"><i class="fab fa-patreon"></i></span></span></a> subscribers at <strong>$2/month</strong>.</p>
 | 
				
			||||||
                </article>
 | 
					                </article>
 | 
				
			||||||
            </div>
 | 
					            </div>
 | 
				
			||||||
        </div>
 | 
					        </div>
 | 
				
			||||||
    </div>
 | 
					    </div>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    <section class="hero is-small">
 | 
					    <section class="hero is-medium">
 | 
				
			||||||
        <div class="hero-body">
 | 
					        <div class="hero-body">
 | 
				
			||||||
 | 
					            <div class="columns">
 | 
				
			||||||
 | 
					                <div class="column">
 | 
				
			||||||
 | 
					                    <div class="container has-text-centered">
 | 
				
			||||||
 | 
					                        <p class="title">Technically-minded?</p>
 | 
				
			||||||
 | 
					                        <p class="content">
 | 
				
			||||||
 | 
					                            Install the bot on your own computer
 | 
				
			||||||
 | 
					                        </p>
 | 
				
			||||||
 | 
					                        <a class="button is-size-6 is-rounded is-link" href="https://gitea.jellypro.xyz/jude/reminder-bot">
 | 
				
			||||||
 | 
					                            <p class="is-size-6">
 | 
				
			||||||
 | 
					                                <span>Install</span> <span class="icon"><i class="fas fa-chevron-right"></i></span>
 | 
				
			||||||
 | 
					                            </p>
 | 
				
			||||||
 | 
					                        </a>
 | 
				
			||||||
 | 
					                    </div>
 | 
				
			||||||
 | 
					                </div>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                <div class="column">
 | 
				
			||||||
                    <div class="container has-text-centered">
 | 
					                    <div class="container has-text-centered">
 | 
				
			||||||
                        <p class="title">Ready to go?</p>
 | 
					                        <p class="title">Ready to go?</p>
 | 
				
			||||||
                        <p class="content">
 | 
					                        <p class="content">
 | 
				
			||||||
                    Add the bot to get started!
 | 
					                            Add the bot to get started
 | 
				
			||||||
                        </p>
 | 
					                        </p>
 | 
				
			||||||
            </div>
 | 
					 | 
				
			||||||
        </div>
 | 
					 | 
				
			||||||
        <div class="hero-foot has-text-centered">
 | 
					 | 
				
			||||||
                        <a class="button is-size-6 is-rounded is-success" href="https://invite.reminder-bot.com">
 | 
					                        <a class="button is-size-6 is-rounded is-success" href="https://invite.reminder-bot.com">
 | 
				
			||||||
                            <p class="is-size-6">
 | 
					                            <p class="is-size-6">
 | 
				
			||||||
                    Add Now <span class="icon"><i class="fas fa-chevron-right"></i></span>
 | 
					                                <span>Add Now</span> <span class="icon"><i class="fas fa-chevron-right"></i></span>
 | 
				
			||||||
                            </p>
 | 
					                            </p>
 | 
				
			||||||
                        </a>
 | 
					                        </a>
 | 
				
			||||||
                    </div>
 | 
					                    </div>
 | 
				
			||||||
 | 
					                </div>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                <div class="column">
 | 
				
			||||||
 | 
					                    <div class="container has-text-centered">
 | 
				
			||||||
 | 
					                        <p class="title">Need support?</p>
 | 
				
			||||||
 | 
					                        <p class="content">
 | 
				
			||||||
 | 
					                            Check out our guides, or join our Discord
 | 
				
			||||||
 | 
					                        </p>
 | 
				
			||||||
 | 
					                        <a class="button is-size-6 is-rounded is-primary" href="/help">
 | 
				
			||||||
 | 
					                            <p class="is-size-6">
 | 
				
			||||||
 | 
					                                <span>Guides</span> <span class="icon"><i class="fas fa-chevron-right"></i></span>
 | 
				
			||||||
 | 
					                            </p>
 | 
				
			||||||
 | 
					                        </a>
 | 
				
			||||||
 | 
					                    </div>
 | 
				
			||||||
 | 
					                </div>
 | 
				
			||||||
 | 
					            </div>
 | 
				
			||||||
 | 
					        </div>
 | 
				
			||||||
    </section>
 | 
					    </section>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
{% endblock %}
 | 
					{% endblock %}
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -13,7 +13,7 @@
 | 
				
			|||||||
    <section class="section">
 | 
					    <section class="section">
 | 
				
			||||||
        <div class="container">
 | 
					        <div class="container">
 | 
				
			||||||
            <h2 class="title">Who we are</h2>
 | 
					            <h2 class="title">Who we are</h2>
 | 
				
			||||||
            <p class="is-size-5 pl-6">
 | 
					            <p>
 | 
				
			||||||
                Reminder Bot is operated solely by Jude Southworth. You can contact me by email at
 | 
					                Reminder Bot is operated solely by Jude Southworth. You can contact me by email at
 | 
				
			||||||
                <a href="mailto:jude@jellywx.com">jude@jellywx.com</a>, or via private/public message on Discord at
 | 
					                <a href="mailto:jude@jellywx.com">jude@jellywx.com</a>, or via private/public message on Discord at
 | 
				
			||||||
                <a href="https://discord.jellywx.com">https://discord.jellywx.com</a>.
 | 
					                <a href="https://discord.jellywx.com">https://discord.jellywx.com</a>.
 | 
				
			||||||
@@ -24,12 +24,16 @@
 | 
				
			|||||||
    <section class="section">
 | 
					    <section class="section">
 | 
				
			||||||
        <div class="container">
 | 
					        <div class="container">
 | 
				
			||||||
            <h2 class="title">What data we collect</h2>
 | 
					            <h2 class="title">What data we collect</h2>
 | 
				
			||||||
            <p class="is-size-5 pl-6">
 | 
					            <p>
 | 
				
			||||||
                Reminder Bot stores limited data necessary for the function of the bot. This data
 | 
					                Reminder Bot stores limited data necessary for the function of the bot. This data
 | 
				
			||||||
                is your <strong>unique user ID</strong>, <strong>timezone</strong>, and <strong>direct message channel</strong>.
 | 
					                is your <strong>unique user ID</strong>, <strong>timezone</strong>, and <strong>direct message channel</strong>.
 | 
				
			||||||
                <br>
 | 
					                <br>
 | 
				
			||||||
                <br>
 | 
					                <br>
 | 
				
			||||||
                Timezones are provided by the user or the user's browser.
 | 
					                Timezones are provided by the user or the user's browser.
 | 
				
			||||||
 | 
					                <br><br>
 | 
				
			||||||
 | 
					                Some  additional information is collected by the dashboard for the purpose of debugging.   This is your
 | 
				
			||||||
 | 
					                <strong>time spent on the website</strong>, <strong>current URL</strong>, <strong>unique user ID</strong>,
 | 
				
			||||||
 | 
					                <strong>unique session token</strong>, <strong>contents of any client errors</strong>.
 | 
				
			||||||
            </p>
 | 
					            </p>
 | 
				
			||||||
        </div>
 | 
					        </div>
 | 
				
			||||||
    </section>
 | 
					    </section>
 | 
				
			||||||
@@ -37,10 +41,12 @@
 | 
				
			|||||||
    <section class="section">
 | 
					    <section class="section">
 | 
				
			||||||
        <div class="container">
 | 
					        <div class="container">
 | 
				
			||||||
            <h2 class="title">Why we collect this data</h2>
 | 
					            <h2 class="title">Why we collect this data</h2>
 | 
				
			||||||
            <p class="is-size-5 pl-6">
 | 
					            <p>
 | 
				
			||||||
                Unique user IDs are stored to <strong>keep track of who sets reminders</strong>. User timezones are
 | 
					                Unique user IDs are stored to <strong>keep track of who sets reminders</strong>. User timezones are
 | 
				
			||||||
                stored to allow users to set reminders in their local timezone. Direct message channels are stored to
 | 
					                stored to allow users to set reminders in their local timezone. Direct message channels are stored to
 | 
				
			||||||
                allow the setting of reminders for your direct message channel.
 | 
					                allow the setting of reminders for your direct message channel.
 | 
				
			||||||
 | 
					                <br>
 | 
				
			||||||
 | 
					                Information collected  by the dashboard is for resolving bugs.
 | 
				
			||||||
            </p>
 | 
					            </p>
 | 
				
			||||||
        </div>
 | 
					        </div>
 | 
				
			||||||
    </section>
 | 
					    </section>
 | 
				
			||||||
@@ -48,7 +54,7 @@
 | 
				
			|||||||
    <section class="section">
 | 
					    <section class="section">
 | 
				
			||||||
        <div class="container">
 | 
					        <div class="container">
 | 
				
			||||||
            <h2 class="title">Who your data is shared with</h2>
 | 
					            <h2 class="title">Who your data is shared with</h2>
 | 
				
			||||||
            <p class="is-size-5 pl-6">
 | 
					            <p>
 | 
				
			||||||
                Your data is also guarded by the privacy policies of <strong>MEGA</strong>, our backup provider, and
 | 
					                Your data is also guarded by the privacy policies of <strong>MEGA</strong>, our backup provider, and
 | 
				
			||||||
                <strong>Hetzner</strong>, our hosting provider.
 | 
					                <strong>Hetzner</strong>, our hosting provider.
 | 
				
			||||||
            </p>
 | 
					            </p>
 | 
				
			||||||
@@ -58,17 +64,13 @@
 | 
				
			|||||||
    <section class="section">
 | 
					    <section class="section">
 | 
				
			||||||
        <div class="container">
 | 
					        <div class="container">
 | 
				
			||||||
            <h2 class="title">Accessing or removing your data</h2>
 | 
					            <h2 class="title">Accessing or removing your data</h2>
 | 
				
			||||||
            <p class="is-size-5 pl-6">
 | 
					            <p>
 | 
				
			||||||
                Your timezone can be removed with the command <strong>/timezone UTC</strong>. Other data can be removed
 | 
					                Your timezone can be removed with the command <strong>/timezone UTC</strong>. Other data can be removed
 | 
				
			||||||
                on request. Please contact me.
 | 
					                on request. Please contact me.
 | 
				
			||||||
                <br>
 | 
					                <br>
 | 
				
			||||||
                <br>
 | 
					                <br>
 | 
				
			||||||
                Reminders created in a guild/channel will be removed automatically when the bot is removed from the
 | 
					                Reminders created in a guild/channel will be removed automatically when the bot is removed from the
 | 
				
			||||||
                guild, the guild is deleted, or channel is deleted. Data is otherwise not removed automatically.
 | 
					                guild, the guild is deleted, or channel is deleted. Data is otherwise not removed automatically.
 | 
				
			||||||
                <br>
 | 
					 | 
				
			||||||
                <br>
 | 
					 | 
				
			||||||
                Reminders deleted with <strong>/del</strong> or via the dashboard are removed from the live database
 | 
					 | 
				
			||||||
                instantly, but may persist in backups for up to a year.
 | 
					 | 
				
			||||||
            </p>
 | 
					            </p>
 | 
				
			||||||
        </div>
 | 
					        </div>
 | 
				
			||||||
    </section>
 | 
					    </section>
 | 
				
			||||||
 
 | 
				
			|||||||
							
								
								
									
										17
									
								
								web/templates/reminder_dashboard/guild_error.html.tera
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										17
									
								
								web/templates/reminder_dashboard/guild_error.html.tera
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,17 @@
 | 
				
			|||||||
 | 
					<div class="hero is-fullheight">
 | 
				
			||||||
 | 
					    <div class="hero-body">
 | 
				
			||||||
 | 
					        <div class="container has-text-centered">
 | 
				
			||||||
 | 
					            <p class="title">
 | 
				
			||||||
 | 
					                We couldn't get this server's data
 | 
				
			||||||
 | 
					            </p>
 | 
				
			||||||
 | 
					            <p class="subtitle">
 | 
				
			||||||
 | 
					                Please check Reminder Bot is in the server, and has correct permissions.
 | 
				
			||||||
 | 
					            </p>
 | 
				
			||||||
 | 
					            <a class="button is-size-4 is-rounded is-success" href="https://invite.reminder-bot.com">
 | 
				
			||||||
 | 
					                <p class="is-size-4">
 | 
				
			||||||
 | 
					                    <span>Add to Server</span> <span class="icon"><i class="fas fa-chevron-right"></i></span>
 | 
				
			||||||
 | 
					                </p>
 | 
				
			||||||
 | 
					            </a>
 | 
				
			||||||
 | 
					        </div>
 | 
				
			||||||
 | 
					    </div>
 | 
				
			||||||
 | 
					</div>
 | 
				
			||||||
@@ -1,10 +1,31 @@
 | 
				
			|||||||
<div class="columns reminderContent {% if creating %}creator{% endif %}">
 | 
					<div class="reminderContent {% if creating %}creator{% endif %}">
 | 
				
			||||||
 | 
					    <div class="columns is-mobile column reminder-topbar">
 | 
				
			||||||
 | 
					        {% if not creating %}
 | 
				
			||||||
 | 
					        <div class="invert-collapses channel-bar">
 | 
				
			||||||
 | 
					            #channel
 | 
				
			||||||
 | 
					        </div>
 | 
				
			||||||
 | 
					        {% endif %}
 | 
				
			||||||
 | 
					        <div class="name-bar">
 | 
				
			||||||
 | 
					            <div class="field">
 | 
				
			||||||
 | 
					                <div class="control">
 | 
				
			||||||
 | 
					                    <label class="label sr-only">Reminder Name</label>
 | 
				
			||||||
 | 
					                    <input class="input" type="text" name="name" placeholder="Reminder Name" maxlength="100">
 | 
				
			||||||
 | 
					                </div>
 | 
				
			||||||
 | 
					            </div>
 | 
				
			||||||
 | 
					        </div>
 | 
				
			||||||
 | 
					        <div class="hide-button-bar">
 | 
				
			||||||
 | 
					            <button class="button hide-box">
 | 
				
			||||||
 | 
					                <span class="is-sr-only">Hide reminder</span><i class="fas fa-chevron-down"></i>
 | 
				
			||||||
 | 
					            </button>
 | 
				
			||||||
 | 
					        </div>
 | 
				
			||||||
 | 
					    </div>
 | 
				
			||||||
 | 
					    <div class="columns reminder-settings">
 | 
				
			||||||
        <div class="column discord-frame">
 | 
					        <div class="column discord-frame">
 | 
				
			||||||
            <article class="media">
 | 
					            <article class="media">
 | 
				
			||||||
                <figure class="media-left">
 | 
					                <figure class="media-left">
 | 
				
			||||||
                    <p class="image is-32x32 customizable">
 | 
					                    <p class="image is-32x32 customizable">
 | 
				
			||||||
                        <a>
 | 
					                        <a>
 | 
				
			||||||
                        <img class="is-rounded discord-avatar" src="/static/img/bg.webp" alt="Image for discord avatar">
 | 
					                            <img class="is-rounded avatar" src="/static/img/bg.webp" alt="Image for discord avatar">
 | 
				
			||||||
                        </a>
 | 
					                        </a>
 | 
				
			||||||
                    </p>
 | 
					                    </p>
 | 
				
			||||||
                </figure>
 | 
					                </figure>
 | 
				
			||||||
@@ -112,24 +133,6 @@
 | 
				
			|||||||
            </article>
 | 
					            </article>
 | 
				
			||||||
        </div>
 | 
					        </div>
 | 
				
			||||||
        <div class="column settings">
 | 
					        <div class="column settings">
 | 
				
			||||||
        <div class="columns is-mobile reminder-topbar">
 | 
					 | 
				
			||||||
            <div class="column">
 | 
					 | 
				
			||||||
                <div class="field">
 | 
					 | 
				
			||||||
                    <div class="control">
 | 
					 | 
				
			||||||
                        <label class="label sr-only">Reminder Name</label>
 | 
					 | 
				
			||||||
                        <input class="input" type="text" name="name" placeholder="Reminder Name">
 | 
					 | 
				
			||||||
                    </div>
 | 
					 | 
				
			||||||
                </div>
 | 
					 | 
				
			||||||
            </div>
 | 
					 | 
				
			||||||
            <div class="column is-narrow">
 | 
					 | 
				
			||||||
                <button class="button is-rounded hide-box">
 | 
					 | 
				
			||||||
                    <span class="is-sr-only">Hide reminder</span><i class="fas fa-chevron-down"></i>
 | 
					 | 
				
			||||||
                </button>
 | 
					 | 
				
			||||||
            </div>
 | 
					 | 
				
			||||||
        </div>
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
        <div class="columns">
 | 
					 | 
				
			||||||
            <div class="column">
 | 
					 | 
				
			||||||
            <div class="field channel-field">
 | 
					            <div class="field channel-field">
 | 
				
			||||||
                <div class="collapses">
 | 
					                <div class="collapses">
 | 
				
			||||||
                    <label class="label" for="channelOption">Channel*</label>
 | 
					                    <label class="label" for="channelOption">Channel*</label>
 | 
				
			||||||
@@ -144,26 +147,28 @@
 | 
				
			|||||||
                    </div>
 | 
					                    </div>
 | 
				
			||||||
                </div>
 | 
					                </div>
 | 
				
			||||||
            </div>
 | 
					            </div>
 | 
				
			||||||
            </div>
 | 
					
 | 
				
			||||||
            <div class="column">
 | 
					 | 
				
			||||||
            <div class="field">
 | 
					            <div class="field">
 | 
				
			||||||
                <div class="control">
 | 
					                <div class="control">
 | 
				
			||||||
                    <label class="label collapses">
 | 
					                    <label class="label collapses">
 | 
				
			||||||
                        Time*
 | 
					                        Time*
 | 
				
			||||||
                            <input class="input" type="datetime-local" step="1" name="time">
 | 
					                        <input class="input prefill-now" type="datetime-local" step="1" name="time">
 | 
				
			||||||
                    </label>
 | 
					                    </label>
 | 
				
			||||||
                </div>
 | 
					                </div>
 | 
				
			||||||
            </div>
 | 
					            </div>
 | 
				
			||||||
            </div>
 | 
					 | 
				
			||||||
        </div>
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
        <div class="collapses">
 | 
					            <div class="collapses split-controls">
 | 
				
			||||||
 | 
					                <div>
 | 
				
			||||||
                    <div class="patreon-only">
 | 
					                    <div class="patreon-only">
 | 
				
			||||||
 | 
					                        <div class="patreon-invert foreground">
 | 
				
			||||||
 | 
					                            Intervals available on <a href="https://patreon.com/jellywx">Patreon</a> or <a href="https://gitea.jellypro.xyz/jude/reminder-bot">self-hosting</a>
 | 
				
			||||||
 | 
					                        </div>
 | 
				
			||||||
                        <div class="field">
 | 
					                        <div class="field">
 | 
				
			||||||
                            <label class="label">Interval <a class="foreground" href="/help/intervals"><i class="fas fa-question-circle"></i></a></label>
 | 
					                            <label class="label">Interval <a class="foreground" href="/help/intervals"><i class="fas fa-question-circle"></i></a></label>
 | 
				
			||||||
                    <div class="control intervalSelector" style="min-width: 400px;" >
 | 
					                            <div class="control intervalSelector">
 | 
				
			||||||
                                <div class="input interval-group">
 | 
					                                <div class="input interval-group">
 | 
				
			||||||
                                    <div class="interval-group-left">
 | 
					                                    <div class="interval-group-left">
 | 
				
			||||||
 | 
					                                        <span class="no-break">
 | 
				
			||||||
                                            <label>
 | 
					                                            <label>
 | 
				
			||||||
                                                <span class="is-sr-only">Interval months</span>
 | 
					                                                <span class="is-sr-only">Interval months</span>
 | 
				
			||||||
                                                <input class="w2" type="text" pattern="\d*" name="interval_months" maxlength="2" placeholder=""> <span class="half-rem"></span> months, <span class="half-rem"></span>
 | 
					                                                <input class="w2" type="text" pattern="\d*" name="interval_months" maxlength="2" placeholder=""> <span class="half-rem"></span> months, <span class="half-rem"></span>
 | 
				
			||||||
@@ -172,6 +177,8 @@
 | 
				
			|||||||
                                                <span class="is-sr-only">Interval days</span>
 | 
					                                                <span class="is-sr-only">Interval days</span>
 | 
				
			||||||
                                                <input class="w3" type="text" pattern="\d*" name="interval_days" maxlength="4" placeholder=""> <span class="half-rem"></span> days, <span class="half-rem"></span>
 | 
					                                                <input class="w3" type="text" pattern="\d*" name="interval_days" maxlength="4" placeholder=""> <span class="half-rem"></span> days, <span class="half-rem"></span>
 | 
				
			||||||
                                            </label>
 | 
					                                            </label>
 | 
				
			||||||
 | 
					                                        </span>
 | 
				
			||||||
 | 
					                                        <span class="no-break">
 | 
				
			||||||
                                            <label>
 | 
					                                            <label>
 | 
				
			||||||
                                                <span class="is-sr-only">Interval hours</span>
 | 
					                                                <span class="is-sr-only">Interval hours</span>
 | 
				
			||||||
                                                <input class="w2" type="text" pattern="\d*" name="interval_hours" maxlength="2" placeholder="HH">:
 | 
					                                                <input class="w2" type="text" pattern="\d*" name="interval_hours" maxlength="2" placeholder="HH">:
 | 
				
			||||||
@@ -184,6 +191,7 @@
 | 
				
			|||||||
                                                <span class="is-sr-only">Interval seconds</span>
 | 
					                                                <span class="is-sr-only">Interval seconds</span>
 | 
				
			||||||
                                                <input class="w2" type="text" pattern="\d*" name="interval_seconds" maxlength="2" placeholder="SS">
 | 
					                                                <input class="w2" type="text" pattern="\d*" name="interval_seconds" maxlength="2" placeholder="SS">
 | 
				
			||||||
                                            </label>
 | 
					                                            </label>
 | 
				
			||||||
 | 
					                                        </span>
 | 
				
			||||||
                                    </div>
 | 
					                                    </div>
 | 
				
			||||||
                                    <button class="clear"><span class="is-sr-only">Clear interval</span><span class="icon"><i class="fas fa-trash"></i></span></button>
 | 
					                                    <button class="clear"><span class="is-sr-only">Clear interval</span><span class="icon"><i class="fas fa-trash"></i></span></button>
 | 
				
			||||||
                                </div>
 | 
					                                </div>
 | 
				
			||||||
@@ -200,7 +208,7 @@
 | 
				
			|||||||
                        </div>
 | 
					                        </div>
 | 
				
			||||||
                    </div>
 | 
					                    </div>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
            <div class="columns">
 | 
					                    <div class="columns is-mobile tts-row">
 | 
				
			||||||
                        <div class="column has-text-centered">
 | 
					                        <div class="column has-text-centered">
 | 
				
			||||||
                            <div class="is-boxed">
 | 
					                            <div class="is-boxed">
 | 
				
			||||||
                                <label class="label">Enable TTS <input type="checkbox" name="tts"></label>
 | 
					                                <label class="label">Enable TTS <input type="checkbox" name="tts"></label>
 | 
				
			||||||
@@ -222,20 +230,32 @@
 | 
				
			|||||||
                            </div>
 | 
					                            </div>
 | 
				
			||||||
                        </div>
 | 
					                        </div>
 | 
				
			||||||
                    </div>
 | 
					                    </div>
 | 
				
			||||||
 | 
					                </div>
 | 
				
			||||||
            <div>
 | 
					            </div>
 | 
				
			||||||
                <span class="pad-left"></span>
 | 
					        </div>
 | 
				
			||||||
 | 
					    </div>
 | 
				
			||||||
    {% if creating %}
 | 
					    {% if creating %}
 | 
				
			||||||
 | 
					        <div class="button-row">
 | 
				
			||||||
 | 
					            <div class="button-row-reminder">
 | 
				
			||||||
                <button class="button is-success" id="createReminder">
 | 
					                <button class="button is-success" id="createReminder">
 | 
				
			||||||
                    <span>Create Reminder</span> <span class="icon"><i class="fas fa-sparkles"></i></span>
 | 
					                    <span>Create Reminder</span> <span class="icon"><i class="fas fa-sparkles"></i></span>
 | 
				
			||||||
                </button>
 | 
					                </button>
 | 
				
			||||||
 | 
					            </div>
 | 
				
			||||||
 | 
					            <div class="button-row-template">
 | 
				
			||||||
 | 
					                <div>
 | 
				
			||||||
                    <button class="button is-success is-outlined" id="createTemplate">
 | 
					                    <button class="button is-success is-outlined" id="createTemplate">
 | 
				
			||||||
                        <span>Create Template</span> <span class="icon"><i class="fas fa-file-spreadsheet"></i></span>
 | 
					                        <span>Create Template</span> <span class="icon"><i class="fas fa-file-spreadsheet"></i></span>
 | 
				
			||||||
                    </button>
 | 
					                    </button>
 | 
				
			||||||
 | 
					                </div>
 | 
				
			||||||
 | 
					                <div>
 | 
				
			||||||
                    <button class="button is-outlined show-modal is-pulled-right" data-modal="chooseTemplateModal">
 | 
					                    <button class="button is-outlined show-modal is-pulled-right" data-modal="chooseTemplateModal">
 | 
				
			||||||
                        Load Template
 | 
					                        Load Template
 | 
				
			||||||
                    </button>
 | 
					                    </button>
 | 
				
			||||||
 | 
					                </div>
 | 
				
			||||||
 | 
					            </div>
 | 
				
			||||||
 | 
					        </div>
 | 
				
			||||||
    {% else %}
 | 
					    {% else %}
 | 
				
			||||||
 | 
					        <div class="button-row-edit">
 | 
				
			||||||
            <button class="button is-success save-btn">
 | 
					            <button class="button is-success save-btn">
 | 
				
			||||||
                <span>Save</span> <span class="icon"><i class="fas fa-save"></i></span>
 | 
					                <span>Save</span> <span class="icon"><i class="fas fa-save"></i></span>
 | 
				
			||||||
            </button>
 | 
					            </button>
 | 
				
			||||||
@@ -244,8 +264,6 @@
 | 
				
			|||||||
            <button class="button is-danger delete-reminder">
 | 
					            <button class="button is-danger delete-reminder">
 | 
				
			||||||
                Delete
 | 
					                Delete
 | 
				
			||||||
            </button>
 | 
					            </button>
 | 
				
			||||||
 | 
					        </div>
 | 
				
			||||||
    {% endif %}
 | 
					    {% endif %}
 | 
				
			||||||
</div>
 | 
					</div>
 | 
				
			||||||
        </div>
 | 
					 | 
				
			||||||
    </div>
 | 
					 | 
				
			||||||
</div>
 | 
					 | 
				
			||||||
 
 | 
				
			|||||||
@@ -0,0 +1,5 @@
 | 
				
			|||||||
 | 
					<div>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					</div>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					<!--<script src="/static/js/reminder_errors.js"></script>-->
 | 
				
			||||||
							
								
								
									
										12
									
								
								web/templates/reminder_dashboard/user_error.html.tera
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										12
									
								
								web/templates/reminder_dashboard/user_error.html.tera
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,12 @@
 | 
				
			|||||||
 | 
					<div class="hero is-fullheight">
 | 
				
			||||||
 | 
					    <div class="hero-body">
 | 
				
			||||||
 | 
					        <div class="container has-text-centered">
 | 
				
			||||||
 | 
					            <p class="title">
 | 
				
			||||||
 | 
					                You do not have permissions for this server
 | 
				
			||||||
 | 
					            </p>
 | 
				
			||||||
 | 
					            <p class="subtitle">
 | 
				
			||||||
 | 
					                Ask an admin to grant you the "Manage Messages" permission.
 | 
				
			||||||
 | 
					            </p>
 | 
				
			||||||
 | 
					        </div>
 | 
				
			||||||
 | 
					    </div>
 | 
				
			||||||
 | 
					</div>
 | 
				
			||||||
@@ -28,7 +28,10 @@
 | 
				
			|||||||
            <div class="container">
 | 
					            <div class="container">
 | 
				
			||||||
                <p class="title">Create reminders via the dashboard</p>
 | 
					                <p class="title">Create reminders via the dashboard</p>
 | 
				
			||||||
                <p class="content">
 | 
					                <p class="content">
 | 
				
			||||||
                    Reminders can also be created on the dashboard.
 | 
					                    Reminders can also be created on the dashboard. The dashboard offers more options for configuring
 | 
				
			||||||
 | 
					                    reminders, and offers templates for quick recreation of reminders.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                    <a href="/dashboard">Access the dashboard.</a>
 | 
				
			||||||
                </p>
 | 
					                </p>
 | 
				
			||||||
            </div>
 | 
					            </div>
 | 
				
			||||||
        </div>
 | 
					        </div>
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -12,7 +12,7 @@
 | 
				
			|||||||
    <section class="hero is-small">
 | 
					    <section class="hero is-small">
 | 
				
			||||||
        <div class="hero-body">
 | 
					        <div class="hero-body">
 | 
				
			||||||
            <div class="container">
 | 
					            <div class="container">
 | 
				
			||||||
                <p class="title">Export your data</p>
 | 
					                <p class="title">Export data</p>
 | 
				
			||||||
                <p class="content">
 | 
					                <p class="content">
 | 
				
			||||||
                    You can export data associated with your server from the dashboard. The data will export as a CSV
 | 
					                    You can export data associated with your server from the dashboard. The data will export as a CSV
 | 
				
			||||||
                    file. The CSV file can then be edited and imported to bulk edit server data.
 | 
					                    file. The CSV file can then be edited and imported to bulk edit server data.
 | 
				
			||||||
@@ -26,8 +26,7 @@
 | 
				
			|||||||
            <div class="container">
 | 
					            <div class="container">
 | 
				
			||||||
                <p class="title">Import data</p>
 | 
					                <p class="title">Import data</p>
 | 
				
			||||||
                <p class="content">
 | 
					                <p class="content">
 | 
				
			||||||
                    You can import previous exports or modified exports. When importing a file, <strong>existing data
 | 
					                    You can import previous exports or modified exports. When importing a file, the new data will be added alongside existing data.
 | 
				
			||||||
                    will be overwritten</strong>.
 | 
					 | 
				
			||||||
                </p>
 | 
					                </p>
 | 
				
			||||||
            </div>
 | 
					            </div>
 | 
				
			||||||
        </div>
 | 
					        </div>
 | 
				
			||||||
@@ -55,7 +54,7 @@
 | 
				
			|||||||
                        </figure>
 | 
					                        </figure>
 | 
				
			||||||
                    </li>
 | 
					                    </li>
 | 
				
			||||||
                    <li>
 | 
					                    <li>
 | 
				
			||||||
                        Make edits to the spreadsheet. You can add, edit, and remove rows for reminders. Don't remove the title row.
 | 
					                        Make edits to the spreadsheet. You can add, edit, and remove rows for reminders. Don't remove the top-most (title) row.
 | 
				
			||||||
                        <figure>
 | 
					                        <figure>
 | 
				
			||||||
                            <img src="/static/img/support/iemanager/edit_spreadsheet.png" alt="Editing spreadsheet">
 | 
					                            <img src="/static/img/support/iemanager/edit_spreadsheet.png" alt="Editing spreadsheet">
 | 
				
			||||||
                        </figure>
 | 
					                        </figure>
 | 
				
			||||||
@@ -70,7 +69,7 @@
 | 
				
			|||||||
                Other spreadsheet tools can also be used to edit exports, as long as they are properly configured:
 | 
					                Other spreadsheet tools can also be used to edit exports, as long as they are properly configured:
 | 
				
			||||||
                <ul>
 | 
					                <ul>
 | 
				
			||||||
                    <li>
 | 
					                    <li>
 | 
				
			||||||
                        <strong>Google Sheets</strong>: Create a new blank spreadsheet. Select <strong>File >> Import >> Upload >> export.csv</strong>.
 | 
					                        <strong>Google Sheets</strong>: Create a new blank spreadsheet. Select <strong>File > Import > Upload > export.csv</strong>.
 | 
				
			||||||
                        Use the following import settings:
 | 
					                        Use the following import settings:
 | 
				
			||||||
                        <figure>
 | 
					                        <figure>
 | 
				
			||||||
                            <img src="/static/img/support/iemanager/sheets_settings.png" alt="Google sheets import settings">
 | 
					                            <img src="/static/img/support/iemanager/sheets_settings.png" alt="Google sheets import settings">
 | 
				
			||||||
 
 | 
				
			|||||||
Some files were not shown because too many files have changed in this diff Show More
		Reference in New Issue
	
	Block a user