Compare commits
	
		
			57 Commits
		
	
	
		
			poise-2
			...
			98191d29ee
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
|  | 98191d29ee | ||
|  | 1c4c4a8b31 | ||
|  | d496c81003 | ||
|  | 094d210f64 | ||
|  | 314c72e132 | ||
|  | 4e0163f2cb | ||
|  | e5b8c418af | ||
|  | 3ef8584189 | ||
|  | df2ad09c86 | ||
|  | d70fb24eb1 | ||
|  | 3150c7267d | ||
|  | 6e65e4ff3d | ||
|  | 67a4db2e9a | ||
|  | e9bcb1973f | ||
|  | 9b87fd4258 | ||
|  | a49a849917 | ||
|  | aa74a7f9a3 | ||
|  | 08e4c6cb57 | ||
|  | 6e087bd2dd | ||
| e9792e6322 | |||
| 130504b964 | |||
| 2a8117d0c1 | |||
| 94bfd39085 | |||
| 40cd5f8a36 | |||
| 133b00a2ce | |||
| 57336f5c81 | |||
| b62d24c024 | |||
| 8f8235a86e | |||
| c8f646a8fa | |||
| ecaa382a1e | |||
| 8991198fd3 | |||
|  | f20b95a482 | ||
|  | 8dd7dc6409 | ||
|  | c799d10727 | ||
|  | ceb6fb7b12 | ||
|  | 6708abdb0f | ||
|  | a38f6024c1 | ||
|  | 7d8748e3ef | ||
|  | bb3386c4e8 | ||
|  | 25b84880a5 | ||
|  | 7b6e967a5d | ||
|  | 2781f2923e | ||
|  | 03f08f0a18 | ||
|  | 79c86d43f2 | ||
|  | e19af54caf | ||
|  | f4213c6a83 | ||
|  | f56db14720 | ||
|  | 6f7d0f67b3 | ||
|  | bfc2d71ca0 | ||
|  | 8eb46f1f23 | ||
|  | c4087bf569 | ||
|  | f25cfed8d7 | ||
|  | d2a8bd1982 | ||
|  | 437ee6b446 | ||
|  | 7d43aa5918 | ||
|  | 8bad95510d | ||
|  | d7a0b727fb | 
							
								
								
									
										1849
									
								
								Cargo.lock
									
									
									
										generated
									
									
									
								
							
							
						
						
							
								
								
									
										42
									
								
								Cargo.toml
									
									
									
									
									
								
							
							
						
						| @@ -1,32 +1,50 @@ | |||||||
| [package] | [package] | ||||||
| name = "reminder_rs" | name = "reminder-rs" | ||||||
| version = "1.6.0-beta3" | version = "1.6.10" | ||||||
| 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.2" | poise = "0.4" | ||||||
| dotenv = "0.15" | dotenv = "0.15" | ||||||
| tokio = { version = "1", features = ["process", "full"] } | tokio = { version = "1", features = ["process", "full"] } | ||||||
| reqwest = "0.11" | reqwest = "0.11" | ||||||
| regex = "1.4" | lazy-regex = "2.3.0" | ||||||
|  | regex = "1.6" | ||||||
| log = "0.4" | log = "0.4" | ||||||
| env_logger = "0.8" | env_logger = "0.10" | ||||||
| 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" | ||||||
| serde_json = "1.0" | serde_json = "1.0" | ||||||
| serde_repr = "0.1" | serde_repr = "0.1" | ||||||
| rmp-serde = "0.15" | rmp-serde = "1.1" | ||||||
| rand = "0.7" | rand = "0.8" | ||||||
| levenshtein = "1.0" | levenshtein = "1.0" | ||||||
| sqlx = { version = "0.5", features = ["runtime-tokio-rustls", "macros", "mysql", "bigdecimal", "chrono"]} | sqlx = { version = "0.6", features = ["runtime-tokio-rustls", "macros", "mysql", "bigdecimal", "chrono", "migrate"]} | ||||||
| base64 = "0.13.0" | base64 = "0.13" | ||||||
|  |  | ||||||
| [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" | ||||||
|  | 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/default.env", "600"], | ||||||
|  | #    ["web/static/", "var/www/reminder-rs/static", "755"], | ||||||
|  | #    ["nginx/reminder-rs", "etc/nginx/sites-available/reminder-rs", "755"] | ||||||
|  | ] | ||||||
|  |  | ||||||
|  | [package.metadata.deb.systemd-units] | ||||||
|  | unit-scripts = "systemd" | ||||||
|  | start = false | ||||||
|   | |||||||
							
								
								
									
										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 | ||||||
|  | RUN cargo install cargo-deb | ||||||
							
								
								
									
										23
									
								
								README.md
									
									
									
									
									
								
							
							
						
						| @@ -2,21 +2,33 @@ | |||||||
| Reminder Bot for Discord. | Reminder Bot for Discord. | ||||||
|  |  | ||||||
| ## How do I use it? | ## How do I use it? | ||||||
| We offer a hosted version of the bot. You can invite it with: **https://invite.reminder-bot.com**. The catch is that repeating  | I offer a hosted version of the bot. You can invite it with: **https://invite.reminder-bot.com**. The catch is that repeating  | ||||||
| reminders are paid on the hosted version of the bot. Keep reading if you want to host it yourself. | reminders are paid on the hosted version of the bot. Keep reading if you want to host it yourself. | ||||||
|  |  | ||||||
| You'll need rustc and cargo for compilation. To run, you'll need Python 3 still (due to no suitable replacement for dateparser in Rust) | 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 | ### Compiling | ||||||
| Reminder Bot can be built by running `cargo build --release` in the top level directory. It is necessary to create a folder called 'assets' containing an image file with its name specified in the environment as `WEBHOOK_AVATAR`, of dimensions 128x128px to be used as the webhook avatar. | Install build requirements:  | ||||||
|  | `sudo apt install gcc gcc-multilib cmake libssl-dev build-essential python3-dateparser` | ||||||
|  |  | ||||||
|  | Install Rust from https://rustup.rs | ||||||
|  |  | ||||||
|  | Reminder Bot can then be built by running `cargo build --release` in the top level directory. It is necessary to create a  | ||||||
|  | folder called 'assets' containing an image file with its name specified in the environment as `WEBHOOK_AVATAR`, of  | ||||||
|  | dimensions 128x128px to be used as the webhook avatar. | ||||||
|  |  | ||||||
| #### Compilation environment variables | #### Compilation environment variables | ||||||
| These environment variables must be provided when compiling the bot | These environment variables must be provided when compiling the bot | ||||||
| * `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** | * `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** | ||||||
|  |  | ||||||
|  | ### Setting up database | ||||||
|  | Use MySQL 8. MariaDB is confirmed not working at the moment. | ||||||
|  |  | ||||||
|  | Load the SQL files in order from "migrations" to generate the database schema. | ||||||
|  |  | ||||||
| ### Setting up Python | ### 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 | Reminder Bot uses `python3-dateparser` to handle dates. This depends on Python 3. | ||||||
|  |  | ||||||
| ### Environment Variables | ### Environment Variables | ||||||
| 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. | ||||||
| @@ -32,8 +44,3 @@ __Other Variables__ | |||||||
| * `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 `venv/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
									
									
									
									
									
								
							
							
						
						| @@ -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" | ||||||
|   | |||||||
							
								
								
									
										3
									
								
								build.rs
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,3 @@ | |||||||
|  | fn main() { | ||||||
|  |     println!("cargo:rerun-if-changed=migrations"); | ||||||
|  | } | ||||||
							
								
								
									
										16
									
								
								conf/default.env
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,16 @@ | |||||||
|  | DATABASE_URL= | ||||||
|  |  | ||||||
|  | DISCORD_TOKEN= | ||||||
|  | PATREON_GUILD_ID= | ||||||
|  | PATREON_ROLE_ID= | ||||||
|  |  | ||||||
|  | LOCAL_TIMEZONE= | ||||||
|  | MIN_INTERVAL= | ||||||
|  | PYTHON_LOCATION=/usr/bin/python3 | ||||||
|  | DONTRUN=web | ||||||
|  | SECRET_KEY= | ||||||
|  |  | ||||||
|  | REMIND_INTERVAL= | ||||||
|  | OAUTH2_DISCORD_CALLBACK= | ||||||
|  | OAUTH2_CLIENT_ID= | ||||||
|  | OAUTH2_CLIENT_SECRET= | ||||||
							
								
								
									
										13
									
								
								debian/postinst
									
									
									
									
										vendored
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,13 @@ | |||||||
|  | #!/bin/bash | ||||||
|  |  | ||||||
|  | set -e | ||||||
|  |  | ||||||
|  | id -u reminder &>/dev/null || useradd -r -M reminder | ||||||
|  |  | ||||||
|  | if [ ! -f /etc/reminder-rs/config.env ]; then | ||||||
|  |   cp /etc/reminder-rs/default.env /etc/reminder-rs/config.env | ||||||
|  | fi | ||||||
|  |  | ||||||
|  | chown reminder /etc/reminder-rs/config.env | ||||||
|  |  | ||||||
|  | #DEBHELPER# | ||||||
							
								
								
									
										11
									
								
								debian/postrm
									
									
									
									
										vendored
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,11 @@ | |||||||
|  | #!/bin/bash | ||||||
|  |  | ||||||
|  | set -e | ||||||
|  |  | ||||||
|  | id -u reminder &>/dev/null || userdel reminder | ||||||
|  |  | ||||||
|  | if [ -f /etc/reminder-rs/config.env ]; then | ||||||
|  |   rm /etc/reminder-rs/config.env | ||||||
|  | fi | ||||||
|  |  | ||||||
|  | #DEBHELPER# | ||||||
| @@ -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; | ||||||
| @@ -157,4 +155,9 @@ CREATE TABLE events ( | |||||||
|     FOREIGN KEY (reminder_id) REFERENCES reminders_new(id) ON DELETE SET NULL |     FOREIGN KEY (reminder_id) REFERENCES reminders_new(id) ON DELETE SET NULL | ||||||
| ); | ); | ||||||
| 
 | 
 | ||||||
|  | DROP TABLE reminders; | ||||||
|  | DROP TABLE embed_fields; | ||||||
|  | RENAME TABLE reminders_new TO reminders; | ||||||
|  | RENAME TABLE embed_fields_new TO embed_fields; | ||||||
|  | 
 | ||||||
| SET FOREIGN_KEY_CHECKS = 1; | SET FOREIGN_KEY_CHECKS = 1; | ||||||
| @@ -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, | ||||||
| 
 | 
 | ||||||
| @@ -32,3 +30,20 @@ CREATE TABLE reminder_template ( | |||||||
| ); | ); | ||||||
| 
 | 
 | ||||||
| ALTER TABLE reminders ADD COLUMN embed_fields JSON; | ALTER TABLE reminders ADD COLUMN embed_fields JSON; | ||||||
|  | 
 | ||||||
|  | update reminders | ||||||
|  |     inner join embed_fields as E | ||||||
|  |     on E.reminder_id = reminders.id | ||||||
|  | set embed_fields = ( | ||||||
|  |     select JSON_ARRAYAGG( | ||||||
|  |         JSON_OBJECT( | ||||||
|  |             'title', E.title, | ||||||
|  |             'value', E.value, | ||||||
|  |             'inline', | ||||||
|  |             if(inline = 1, cast(TRUE as json), cast(FALSE as json)) | ||||||
|  |             ) | ||||||
|  |         ) | ||||||
|  |     from embed_fields | ||||||
|  |     group by reminder_id | ||||||
|  |     having reminder_id = reminders.id | ||||||
|  |     ); | ||||||
							
								
								
									
										1
									
								
								migrations/20221210000000_reminder_daily_intervals.sql
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1 @@ | |||||||
|  | ALTER TABLE reminders ADD COLUMN `interval_days` INT UNSIGNED DEFAULT NULL; | ||||||
							
								
								
									
										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; | ||||||
|  |         } | ||||||
|  | } | ||||||
| @@ -7,12 +7,10 @@ edition = "2021" | |||||||
| tokio = { version = "1", features = ["process", "full"] } | tokio = { version = "1", features = ["process", "full"] } | ||||||
| regex = "1.4" | regex = "1.4" | ||||||
| log = "0.4" | log = "0.4" | ||||||
| env_logger = "0.8" |  | ||||||
| chrono = "0.4" | chrono = "0.4" | ||||||
| chrono-tz = { version = "0.5", features = ["serde"] } | chrono-tz = { version = "0.5", features = ["serde"] } | ||||||
| lazy_static = "1.4" | lazy_static = "1.4" | ||||||
| num-integer = "0.1" | num-integer = "0.1" | ||||||
| serde = "1.0" | serde = "1.0" | ||||||
| serde_json = "1.0" | sqlx = { version = "0.6", features = ["runtime-tokio-rustls", "macros", "mysql", "bigdecimal", "chrono", "json"]} | ||||||
| sqlx = { version = "0.5", 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.1", default-features = false, features = ["builder", "cache", "client", "gateway", "http", "model", "utils", "rustls_backend"] } | ||||||
|   | |||||||
| @@ -1,4 +1,4 @@ | |||||||
| use chrono::Duration; | 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 +7,7 @@ use regex::{Captures, Regex}; | |||||||
| use serde::Deserialize; | use serde::Deserialize; | ||||||
| use serenity::{ | use serenity::{ | ||||||
|     builder::CreateEmbed, |     builder::CreateEmbed, | ||||||
|     http::{CacheHttp, Http, StatusCode}, |     http::{CacheHttp, Http, HttpError, StatusCode}, | ||||||
|     model::{ |     model::{ | ||||||
|         channel::{Channel, Embed as SerenityEmbed}, |         channel::{Channel, Embed as SerenityEmbed}, | ||||||
|         id::ChannelId, |         id::ChannelId, | ||||||
| @@ -58,11 +58,12 @@ fn fmt_displacement(format: &str, seconds: u64) -> String { | |||||||
|  |  | ||||||
| pub fn substitute(string: &str) -> String { | pub fn substitute(string: &str) -> String { | ||||||
|     let new = TIMEFROM_REGEX.replace(string, |caps: &Captures| { |     let new = TIMEFROM_REGEX.replace(string, |caps: &Captures| { | ||||||
|         let final_time = caps.name("time").unwrap().as_str(); |         let final_time = caps.name("time").map(|m| m.as_str().parse::<i64>().ok()).flatten(); | ||||||
|         let format = caps.name("format").unwrap().as_str(); |         let format = caps.name("format").map(|m| m.as_str()); | ||||||
|  |  | ||||||
|         if let Ok(final_time) = final_time.parse::<i64>() { |         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 +75,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() | ||||||
|         } |         } | ||||||
| @@ -81,13 +86,11 @@ pub fn substitute(string: &str) -> String { | |||||||
|  |  | ||||||
|     TIMENOW_REGEX |     TIMENOW_REGEX | ||||||
|         .replace(&new, |caps: &Captures| { |         .replace(&new, |caps: &Captures| { | ||||||
|             let timezone = caps.name("timezone").unwrap().as_str(); |             let timezone = caps.name("timezone").map(|m| m.as_str().parse::<Tz>().ok()).flatten(); | ||||||
|  |             let format = caps.name("format").map(|m| m.as_str()); | ||||||
|  |  | ||||||
|             println!("{}", timezone); |             if let (Some(timezone), Some(format)) = (timezone, format) { | ||||||
|  |                 let now = Utc::now().with_timezone(&timezone); | ||||||
|             if let Ok(tz) = timezone.parse::<Tz>() { |  | ||||||
|                 let format = caps.name("format").unwrap().as_str(); |  | ||||||
|                 let now = Utc::now().with_timezone(&tz); |  | ||||||
|  |  | ||||||
|                 now.format(format).to_string() |                 now.format(format).to_string() | ||||||
|             } else { |             } else { | ||||||
| @@ -122,7 +125,7 @@ impl Embed { | |||||||
|         pool: impl Executor<'_, Database = Database> + Copy, |         pool: impl Executor<'_, Database = Database> + Copy, | ||||||
|         id: u32, |         id: u32, | ||||||
|     ) -> Option<Self> { |     ) -> Option<Self> { | ||||||
|         let mut embed = sqlx::query_as!( |         match sqlx::query_as!( | ||||||
|             Self, |             Self, | ||||||
|             r#" |             r#" | ||||||
|             SELECT |             SELECT | ||||||
| @@ -142,8 +145,8 @@ impl Embed { | |||||||
|         ) |         ) | ||||||
|         .fetch_one(pool) |         .fetch_one(pool) | ||||||
|         .await |         .await | ||||||
|         .unwrap(); |         { | ||||||
|  |             Ok(mut embed) => { | ||||||
|                 embed.title = substitute(&embed.title); |                 embed.title = substitute(&embed.title); | ||||||
|                 embed.description = substitute(&embed.description); |                 embed.description = substitute(&embed.description); | ||||||
|                 embed.footer = substitute(&embed.footer); |                 embed.footer = substitute(&embed.footer); | ||||||
| @@ -160,6 +163,14 @@ impl Embed { | |||||||
|                 } |                 } | ||||||
|             } |             } | ||||||
|  |  | ||||||
|  |             Err(e) => { | ||||||
|  |                 warn!("Error loading embed from reminder: {:?}", e); | ||||||
|  |  | ||||||
|  |                 None | ||||||
|  |             } | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  |  | ||||||
|     pub fn has_content(&self) -> bool { |     pub fn has_content(&self) -> bool { | ||||||
|         if self.title.is_empty() |         if self.title.is_empty() | ||||||
|             && self.description.is_empty() |             && self.description.is_empty() | ||||||
| @@ -220,7 +231,6 @@ impl Into<CreateEmbed> for Embed { | |||||||
|     } |     } | ||||||
| } | } | ||||||
|  |  | ||||||
| #[derive(Debug)] |  | ||||||
| pub struct Reminder { | pub struct Reminder { | ||||||
|     id: u32, |     id: u32, | ||||||
|  |  | ||||||
| @@ -238,11 +248,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>, | ||||||
| @@ -251,9 +262,9 @@ pub struct Reminder { | |||||||
|  |  | ||||||
| impl Reminder { | impl Reminder { | ||||||
|     pub async fn fetch_reminders(pool: impl Executor<'_, Database = Database> + Copy) -> Vec<Self> { |     pub async fn fetch_reminders(pool: impl Executor<'_, Database = Database> + Copy) -> Vec<Self> { | ||||||
|         sqlx::query_as_unchecked!( |         match sqlx::query_as_unchecked!( | ||||||
|             Reminder, |             Reminder, | ||||||
|             " |             r#" | ||||||
| SELECT | SELECT | ||||||
|     reminders.`id` AS id, |     reminders.`id` AS id, | ||||||
|  |  | ||||||
| @@ -261,9 +272,9 @@ SELECT | |||||||
|     channels.`webhook_id` AS webhook_id, |     channels.`webhook_id` AS webhook_id, | ||||||
|     channels.`webhook_token` AS webhook_token, |     channels.`webhook_token` AS webhook_token, | ||||||
|  |  | ||||||
|     channels.`paused` AS channel_paused, |     channels.`paused` AS 'channel_paused', | ||||||
|     channels.`paused_until` AS channel_paused_until, |     channels.`paused_until` AS 'channel_paused_until', | ||||||
|     reminders.`enabled` AS enabled, |     reminders.`enabled` AS 'enabled', | ||||||
|  |  | ||||||
|     reminders.`tts` AS tts, |     reminders.`tts` AS tts, | ||||||
|     reminders.`pin` AS pin, |     reminders.`pin` AS pin, | ||||||
| @@ -274,8 +285,9 @@ SELECT | |||||||
|     reminders.`utc_time` AS 'utc_time', |     reminders.`utc_time` AS 'utc_time', | ||||||
|     reminders.`timezone` AS timezone, |     reminders.`timezone` AS timezone, | ||||||
|     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, | ||||||
| @@ -287,26 +299,45 @@ INNER JOIN | |||||||
| ON | ON | ||||||
|     reminders.channel_id = channels.id |     reminders.channel_id = channels.id | ||||||
| WHERE | WHERE | ||||||
|     reminders.`utc_time` < NOW() |     reminders.`id` IN ( | ||||||
|             ", |         SELECT | ||||||
|  |             MIN(id) | ||||||
|  |         FROM | ||||||
|  |             reminders | ||||||
|  |         WHERE | ||||||
|  |             reminders.`utc_time` <= NOW() | ||||||
|  |             AND ( | ||||||
|  |                 reminders.`interval_seconds` IS NOT NULL | ||||||
|  |                 OR reminders.`interval_months` IS NOT NULL | ||||||
|  |                 OR reminders.enabled | ||||||
|  |             ) | ||||||
|  |         GROUP BY channel_id | ||||||
|  |     ) | ||||||
|  |     "#, | ||||||
|         ) |         ) | ||||||
|         .fetch_all(pool) |         .fetch_all(pool) | ||||||
|         .await |         .await | ||||||
|         .unwrap() |         { | ||||||
|  |             Ok(reminders) => reminders | ||||||
|                 .into_iter() |                 .into_iter() | ||||||
|                 .map(|mut rem| { |                 .map(|mut rem| { | ||||||
|                     rem.content = substitute(&rem.content); |                     rem.content = substitute(&rem.content); | ||||||
|  |  | ||||||
|                     rem |                     rem | ||||||
|                 }) |                 }) | ||||||
|         .collect::<Vec<Self>>() |                 .collect::<Vec<Self>>(), | ||||||
|  |  | ||||||
|  |             Err(e) => { | ||||||
|  |                 warn!("Could not fetch reminders: {:?}", e); | ||||||
|  |  | ||||||
|  |                 vec![] | ||||||
|  |             } | ||||||
|  |         } | ||||||
|     } |     } | ||||||
|  |  | ||||||
|     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) | ||||||
| @@ -315,39 +346,43 @@ 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() || self.interval_months.is_some() { | ||||||
|             let now = Utc::now().naive_local(); |             let now = Utc::now(); | ||||||
|             let mut updated_reminder_time = self.utc_time; |             let mut updated_reminder_time = | ||||||
|  |                 self.utc_time.with_timezone(&self.timezone.parse().unwrap_or(Tz::UTC)); | ||||||
|  |  | ||||||
|  |             while updated_reminder_time < now { | ||||||
|                 if let Some(interval) = self.interval_months { |                 if let Some(interval) = self.interval_months { | ||||||
|                 let row = sqlx::query!( |                     updated_reminder_time = updated_reminder_time | ||||||
|                     // use the second date_add to force return value to datetime |                         .checked_add_months(Months::new(interval)) | ||||||
|                     "SELECT DATE_ADD(DATE_ADD(?, INTERVAL ? MONTH), INTERVAL 0 SECOND) AS new_time", |                         .unwrap_or_else(|| { | ||||||
|                     updated_reminder_time, |                             warn!("Could not add months to a reminder"); | ||||||
|                     interval |  | ||||||
|                 ) |  | ||||||
|                 .fetch_one(pool) |  | ||||||
|                 .await |  | ||||||
|                 .unwrap(); |  | ||||||
|  |  | ||||||
|                 updated_reminder_time = row.new_time.unwrap(); |                             updated_reminder_time | ||||||
|  |                         }); | ||||||
|  |                 } | ||||||
|  |  | ||||||
|  |                 if let Some(interval) = self.interval_days { | ||||||
|  |                     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"); | ||||||
|  |  | ||||||
|  |                             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 = | ||||||
|                     updated_reminder_time += Duration::seconds(interval as i64); |                         updated_reminder_time + Duration::seconds(interval as i64); | ||||||
|                 } |                 } | ||||||
|             } |             } | ||||||
|  |  | ||||||
|             if self.expires.map_or(false, |expires| { |             if self.expires.map_or(false, |expires| updated_reminder_time > expires) { | ||||||
|                 NaiveDateTime::from_timestamp(updated_reminder_time.timestamp(), 0) > expires |  | ||||||
|             }) { |  | ||||||
|                 self.force_delete(pool).await; |                 self.force_delete(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) | ||||||
| @@ -360,12 +395,7 @@ UPDATE reminders SET `utc_time` = ? WHERE `id` = ? | |||||||
|     } |     } | ||||||
|  |  | ||||||
|     async fn force_delete(&self, pool: impl Executor<'_, Database = Database> + Copy) { |     async fn force_delete(&self, pool: impl Executor<'_, Database = Database> + Copy) { | ||||||
|         sqlx::query!( |         sqlx::query!("DELETE FROM reminders WHERE `id` = ?", self.id) | ||||||
|             " |  | ||||||
| DELETE FROM reminders WHERE `id` = ? |  | ||||||
|             ", |  | ||||||
|             self.id |  | ||||||
|         ) |  | ||||||
|             .execute(pool) |             .execute(pool) | ||||||
|             .await |             .await | ||||||
|             .expect(&format!("Could not delete Reminder {}", self.id)); |             .expect(&format!("Could not delete Reminder {}", self.id)); | ||||||
| @@ -462,8 +492,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); | ||||||
| @@ -506,9 +538,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) | ||||||
| @@ -535,15 +565,20 @@ UPDATE `channels` SET paused = 0, paused_until = NULL WHERE `channel` = ? | |||||||
|             }; |             }; | ||||||
|  |  | ||||||
|             if let Err(e) = result { |             if let Err(e) = result { | ||||||
|                 error!("Error sending {:?}: {:?}", self, e); |                 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::from_u16(404).unwrap()) { |                     if error.status_code() == Some(StatusCode::NOT_FOUND) { | ||||||
|                         error!("Seeing channel is deleted. Removing reminder"); |                         warn!("Seeing channel is deleted. Removing reminder"); | ||||||
|  |                         self.force_delete(pool).await; | ||||||
|  |                     } else if let HttpError::UnsuccessfulRequest(error) = *error { | ||||||
|  |                         if error.error.code == 50007 { | ||||||
|  |                             warn!("User cannot receive DMs"); | ||||||
|                             self.force_delete(pool).await; |                             self.force_delete(pool).await; | ||||||
|                         } else { |                         } else { | ||||||
|                             self.refresh(pool).await; |                             self.refresh(pool).await; | ||||||
|                         } |                         } | ||||||
|  |                     } | ||||||
|                 } else { |                 } else { | ||||||
|                     self.refresh(pool).await; |                     self.refresh(pool).await; | ||||||
|                 } |                 } | ||||||
|   | |||||||
							
								
								
									
										117
									
								
								src/commands/autocomplete.rs
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,117 @@ | |||||||
|  | use std::time::{SystemTime, UNIX_EPOCH}; | ||||||
|  |  | ||||||
|  | use chrono_tz::TZ_VARIANTS; | ||||||
|  | use poise::AutocompleteChoice; | ||||||
|  |  | ||||||
|  | use crate::{models::CtxData, time_parser::natural_parser, Context}; | ||||||
|  |  | ||||||
|  | pub async fn timezone_autocomplete(ctx: Context<'_>, partial: &str) -> Vec<String> { | ||||||
|  |     if partial.is_empty() { | ||||||
|  |         ctx.data().popular_timezones.iter().map(|t| t.to_string()).collect::<Vec<String>>() | ||||||
|  |     } else { | ||||||
|  |         TZ_VARIANTS | ||||||
|  |             .iter() | ||||||
|  |             .filter(|tz| tz.to_string().contains(&partial)) | ||||||
|  |             .take(25) | ||||||
|  |             .map(|t| t.to_string()) | ||||||
|  |             .collect::<Vec<String>>() | ||||||
|  |     } | ||||||
|  | } | ||||||
|  |  | ||||||
|  | pub async fn macro_name_autocomplete(ctx: Context<'_>, partial: &str) -> Vec<String> { | ||||||
|  |     sqlx::query!( | ||||||
|  |         " | ||||||
|  | SELECT name | ||||||
|  | FROM macro | ||||||
|  | WHERE | ||||||
|  |     guild_id = (SELECT id FROM guilds WHERE guild = ?) | ||||||
|  |     AND name LIKE CONCAT(?, '%')", | ||||||
|  |         ctx.guild_id().unwrap().0, | ||||||
|  |         partial, | ||||||
|  |     ) | ||||||
|  |     .fetch_all(&ctx.data().database) | ||||||
|  |     .await | ||||||
|  |     .unwrap_or_default() | ||||||
|  |     .iter() | ||||||
|  |     .map(|s| s.name.clone()) | ||||||
|  |     .collect() | ||||||
|  | } | ||||||
|  |  | ||||||
|  | pub async fn time_hint_autocomplete( | ||||||
|  |     ctx: Context<'_>, | ||||||
|  |     partial: &str, | ||||||
|  | ) -> Vec<AutocompleteChoice<String>> { | ||||||
|  |     if partial.is_empty() { | ||||||
|  |         vec![AutocompleteChoice { | ||||||
|  |             name: "Start typing a time...".to_string(), | ||||||
|  |             value: "now".to_string(), | ||||||
|  |         }] | ||||||
|  |     } else { | ||||||
|  |         match natural_parser(partial, &ctx.timezone().await.to_string()).await { | ||||||
|  |             Some(timestamp) => match SystemTime::now().duration_since(UNIX_EPOCH) { | ||||||
|  |                 Ok(now) => { | ||||||
|  |                     let diff = timestamp - now.as_secs() as i64; | ||||||
|  |  | ||||||
|  |                     if diff < 0 { | ||||||
|  |                         vec![AutocompleteChoice { | ||||||
|  |                             name: "Time is in the past".to_string(), | ||||||
|  |                             value: "now".to_string(), | ||||||
|  |                         }] | ||||||
|  |                     } else { | ||||||
|  |                         if diff > 86400 { | ||||||
|  |                             vec![ | ||||||
|  |                                 AutocompleteChoice { | ||||||
|  |                                     name: partial.to_string(), | ||||||
|  |                                     value: partial.to_string(), | ||||||
|  |                                 }, | ||||||
|  |                                 AutocompleteChoice { | ||||||
|  |                                     name: format!( | ||||||
|  |                                         "In approximately {} days, {} hours", | ||||||
|  |                                         diff / 86400, | ||||||
|  |                                         (diff % 86400) / 3600 | ||||||
|  |                                     ), | ||||||
|  |                                     value: partial.to_string(), | ||||||
|  |                                 }, | ||||||
|  |                             ] | ||||||
|  |                         } else if diff > 3600 { | ||||||
|  |                             vec![ | ||||||
|  |                                 AutocompleteChoice { | ||||||
|  |                                     name: partial.to_string(), | ||||||
|  |                                     value: partial.to_string(), | ||||||
|  |                                 }, | ||||||
|  |                                 AutocompleteChoice { | ||||||
|  |                                     name: format!("In approximately {} hours", diff / 3600), | ||||||
|  |                                     value: partial.to_string(), | ||||||
|  |                                 }, | ||||||
|  |                             ] | ||||||
|  |                         } else { | ||||||
|  |                             vec![ | ||||||
|  |                                 AutocompleteChoice { | ||||||
|  |                                     name: partial.to_string(), | ||||||
|  |                                     value: partial.to_string(), | ||||||
|  |                                 }, | ||||||
|  |                                 AutocompleteChoice { | ||||||
|  |                                     name: format!("In approximately {} minutes", diff / 60), | ||||||
|  |                                     value: partial.to_string(), | ||||||
|  |                                 }, | ||||||
|  |                             ] | ||||||
|  |                         } | ||||||
|  |                     } | ||||||
|  |                 } | ||||||
|  |                 Err(_) => { | ||||||
|  |                     vec![AutocompleteChoice { | ||||||
|  |                         name: partial.to_string(), | ||||||
|  |                         value: partial.to_string(), | ||||||
|  |                     }] | ||||||
|  |                 } | ||||||
|  |             }, | ||||||
|  |  | ||||||
|  |             None => { | ||||||
|  |                 vec![AutocompleteChoice { | ||||||
|  |                     name: "Time not recognised".to_string(), | ||||||
|  |                     value: "now".to_string(), | ||||||
|  |                 }] | ||||||
|  |             } | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  | } | ||||||
							
								
								
									
										46
									
								
								src/commands/command_macro/delete.rs
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,46 @@ | |||||||
|  | use super::super::autocomplete::macro_name_autocomplete; | ||||||
|  | use crate::{Context, Error}; | ||||||
|  |  | ||||||
|  | /// Delete a recorded macro | ||||||
|  | #[poise::command( | ||||||
|  |     slash_command, | ||||||
|  |     rename = "delete", | ||||||
|  |     guild_only = true, | ||||||
|  |     default_member_permissions = "MANAGE_GUILD", | ||||||
|  |     identifying_name = "delete_macro" | ||||||
|  | )] | ||||||
|  | pub async fn delete_macro( | ||||||
|  |     ctx: Context<'_>, | ||||||
|  |     #[description = "Name of macro to delete"] | ||||||
|  |     #[autocomplete = "macro_name_autocomplete"] | ||||||
|  |     name: String, | ||||||
|  | ) -> Result<(), Error> { | ||||||
|  |     match sqlx::query!( | ||||||
|  |         " | ||||||
|  | SELECT id FROM macro WHERE guild_id = (SELECT id FROM guilds WHERE guild = ?) AND name = ?", | ||||||
|  |         ctx.guild_id().unwrap().0, | ||||||
|  |         name | ||||||
|  |     ) | ||||||
|  |     .fetch_one(&ctx.data().database) | ||||||
|  |     .await | ||||||
|  |     { | ||||||
|  |         Ok(row) => { | ||||||
|  |             sqlx::query!("DELETE FROM macro WHERE id = ?", row.id) | ||||||
|  |                 .execute(&ctx.data().database) | ||||||
|  |                 .await | ||||||
|  |                 .unwrap(); | ||||||
|  |  | ||||||
|  |             ctx.say(format!("Macro \"{}\" deleted", name)).await?; | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         Err(sqlx::Error::RowNotFound) => { | ||||||
|  |             ctx.say(format!("Macro \"{}\" not found", name)).await?; | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         Err(e) => { | ||||||
|  |             panic!("{}", e); | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     Ok(()) | ||||||
|  | } | ||||||
							
								
								
									
										89
									
								
								src/commands/command_macro/list.rs
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,89 @@ | |||||||
|  | use poise::CreateReply; | ||||||
|  |  | ||||||
|  | use crate::{ | ||||||
|  |     component_models::pager::{MacroPager, Pager}, | ||||||
|  |     consts::THEME_COLOR, | ||||||
|  |     models::{command_macro::CommandMacro, CtxData}, | ||||||
|  |     Context, Error, | ||||||
|  | }; | ||||||
|  |  | ||||||
|  | /// List recorded macros | ||||||
|  | #[poise::command( | ||||||
|  |     slash_command, | ||||||
|  |     rename = "list", | ||||||
|  |     guild_only = true, | ||||||
|  |     default_member_permissions = "MANAGE_GUILD", | ||||||
|  |     identifying_name = "list_macro" | ||||||
|  | )] | ||||||
|  | pub async fn list_macro(ctx: Context<'_>) -> Result<(), Error> { | ||||||
|  |     let macros = ctx.command_macros().await?; | ||||||
|  |  | ||||||
|  |     let resp = show_macro_page(¯os, 0); | ||||||
|  |  | ||||||
|  |     ctx.send(|m| { | ||||||
|  |         *m = resp; | ||||||
|  |         m | ||||||
|  |     }) | ||||||
|  |     .await?; | ||||||
|  |  | ||||||
|  |     Ok(()) | ||||||
|  | } | ||||||
|  |  | ||||||
|  | pub fn max_macro_page<U, E>(macros: &[CommandMacro<U, E>]) -> usize { | ||||||
|  |     ((macros.len() as f64) / 25.0).ceil() as usize | ||||||
|  | } | ||||||
|  |  | ||||||
|  | pub fn show_macro_page<U, E>(macros: &[CommandMacro<U, E>], page: usize) -> CreateReply { | ||||||
|  |     let pager = MacroPager::new(page); | ||||||
|  |  | ||||||
|  |     if macros.is_empty() { | ||||||
|  |         let mut reply = CreateReply::default(); | ||||||
|  |  | ||||||
|  |         reply.embed(|e| { | ||||||
|  |             e.title("Macros") | ||||||
|  |                 .description("No Macros Set Up. Use `/macro record` to get started.") | ||||||
|  |                 .color(*THEME_COLOR) | ||||||
|  |         }); | ||||||
|  |  | ||||||
|  |         return reply; | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     let pages = max_macro_page(macros); | ||||||
|  |  | ||||||
|  |     let mut page = page; | ||||||
|  |     if page >= pages { | ||||||
|  |         page = pages - 1; | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     let lower = (page * 25).min(macros.len()); | ||||||
|  |     let upper = ((page + 1) * 25).min(macros.len()); | ||||||
|  |  | ||||||
|  |     let fields = macros[lower..upper].iter().map(|m| { | ||||||
|  |         if let Some(description) = &m.description { | ||||||
|  |             ( | ||||||
|  |                 m.name.clone(), | ||||||
|  |                 format!("*{}*\n- Has {} commands", description, m.commands.len()), | ||||||
|  |                 true, | ||||||
|  |             ) | ||||||
|  |         } else { | ||||||
|  |             (m.name.clone(), format!("- Has {} commands", m.commands.len()), true) | ||||||
|  |         } | ||||||
|  |     }); | ||||||
|  |  | ||||||
|  |     let mut reply = CreateReply::default(); | ||||||
|  |  | ||||||
|  |     reply | ||||||
|  |         .embed(|e| { | ||||||
|  |             e.title("Macros") | ||||||
|  |                 .fields(fields) | ||||||
|  |                 .footer(|f| f.text(format!("Page {} of {}", page + 1, pages))) | ||||||
|  |                 .color(*THEME_COLOR) | ||||||
|  |         }) | ||||||
|  |         .components(|comp| { | ||||||
|  |             pager.create_button_row(pages, comp); | ||||||
|  |  | ||||||
|  |             comp | ||||||
|  |         }); | ||||||
|  |  | ||||||
|  |     reply | ||||||
|  | } | ||||||
							
								
								
									
										229
									
								
								src/commands/command_macro/migrate.rs
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,229 @@ | |||||||
|  | use lazy_regex::regex; | ||||||
|  | use poise::serenity_prelude::command::CommandOptionType; | ||||||
|  | use regex::Captures; | ||||||
|  | use serde_json::{json, Value}; | ||||||
|  |  | ||||||
|  | use crate::{models::command_macro::RawCommandMacro, Context, Error, GuildId}; | ||||||
|  |  | ||||||
|  | struct Alias { | ||||||
|  |     name: String, | ||||||
|  |     command: String, | ||||||
|  | } | ||||||
|  |  | ||||||
|  | /// Migrate old $alias reminder commands to macros. Only macro names that are not taken will be used. | ||||||
|  | #[poise::command( | ||||||
|  |     slash_command, | ||||||
|  |     rename = "migrate", | ||||||
|  |     guild_only = true, | ||||||
|  |     default_member_permissions = "MANAGE_GUILD", | ||||||
|  |     identifying_name = "migrate_macro" | ||||||
|  | )] | ||||||
|  | pub async fn migrate_macro(ctx: Context<'_>) -> Result<(), Error> { | ||||||
|  |     let guild_id = ctx.guild_id().unwrap(); | ||||||
|  |     let mut transaction = ctx.data().database.begin().await?; | ||||||
|  |  | ||||||
|  |     let aliases = sqlx::query_as!( | ||||||
|  |         Alias, | ||||||
|  |         "SELECT name, command FROM command_aliases WHERE guild_id = (SELECT id FROM guilds WHERE guild = ?)", | ||||||
|  |         guild_id.0 | ||||||
|  |     ) | ||||||
|  |     .fetch_all(&mut transaction) | ||||||
|  |     .await?; | ||||||
|  |  | ||||||
|  |     let mut added_aliases = 0; | ||||||
|  |  | ||||||
|  |     for alias in aliases { | ||||||
|  |         match parse_text_command(guild_id, alias.name, &alias.command) { | ||||||
|  |             Some(cmd_macro) => { | ||||||
|  |                 sqlx::query!( | ||||||
|  |                     "INSERT INTO macro (guild_id, name, description, commands) VALUES ((SELECT id FROM guilds WHERE guild = ?), ?, ?, ?)", | ||||||
|  |                     cmd_macro.guild_id.0, | ||||||
|  |                     cmd_macro.name, | ||||||
|  |                     cmd_macro.description, | ||||||
|  |                     cmd_macro.commands | ||||||
|  |                 ) | ||||||
|  |                 .execute(&mut transaction) | ||||||
|  |                 .await?; | ||||||
|  |  | ||||||
|  |                 added_aliases += 1; | ||||||
|  |             } | ||||||
|  |  | ||||||
|  |             None => {} | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     transaction.commit().await?; | ||||||
|  |  | ||||||
|  |     ctx.send(|b| b.content(format!("Added {} macros.", added_aliases))).await?; | ||||||
|  |  | ||||||
|  |     Ok(()) | ||||||
|  | } | ||||||
|  |  | ||||||
|  | fn parse_text_command( | ||||||
|  |     guild_id: GuildId, | ||||||
|  |     alias_name: String, | ||||||
|  |     command: &str, | ||||||
|  | ) -> Option<RawCommandMacro> { | ||||||
|  |     match command.split_once(" ") { | ||||||
|  |         Some((command_word, args)) => { | ||||||
|  |             let command_word = command_word.to_lowercase(); | ||||||
|  |  | ||||||
|  |             if command_word == "r" | ||||||
|  |                 || command_word == "i" | ||||||
|  |                 || command_word == "remind" | ||||||
|  |                 || command_word == "interval" | ||||||
|  |             { | ||||||
|  |                 let matcher = regex!( | ||||||
|  |                     r#"(?P<mentions>(?:<@\d+>\s+|<@!\d+>\s+|<#\d+>\s+)*)(?P<time>(?:(?:\d+)(?:s|m|h|d|:|/|-|))+)(?:\s+(?P<interval>(?:(?:\d+)(?:s|m|h|d|))+))?(?:\s+(?P<expires>(?:(?:\d+)(?:s|m|h|d|:|/|-|))+))?\s+(?P<content>.*)"#s | ||||||
|  |                 ); | ||||||
|  |  | ||||||
|  |                 match matcher.captures(&args) { | ||||||
|  |                     Some(captures) => { | ||||||
|  |                         let mut args: Vec<Value> = vec![]; | ||||||
|  |  | ||||||
|  |                         if let Some(group) = captures.name("time") { | ||||||
|  |                             let content = group.as_str(); | ||||||
|  |                             args.push(json!({ | ||||||
|  |                                 "name": "time", | ||||||
|  |                                 "value": content, | ||||||
|  |                                 "type": CommandOptionType::String, | ||||||
|  |                             })); | ||||||
|  |                         } | ||||||
|  |  | ||||||
|  |                         if let Some(group) = captures.name("content") { | ||||||
|  |                             let content = group.as_str(); | ||||||
|  |                             args.push(json!({ | ||||||
|  |                                 "name": "content", | ||||||
|  |                                 "value": content, | ||||||
|  |                                 "type": CommandOptionType::String, | ||||||
|  |                             })); | ||||||
|  |                         } | ||||||
|  |  | ||||||
|  |                         if let Some(group) = captures.name("interval") { | ||||||
|  |                             let content = group.as_str(); | ||||||
|  |                             args.push(json!({ | ||||||
|  |                                 "name": "interval", | ||||||
|  |                                 "value": content, | ||||||
|  |                                 "type": CommandOptionType::String, | ||||||
|  |                             })); | ||||||
|  |                         } | ||||||
|  |  | ||||||
|  |                         if let Some(group) = captures.name("expires") { | ||||||
|  |                             let content = group.as_str(); | ||||||
|  |                             args.push(json!({ | ||||||
|  |                                 "name": "expires", | ||||||
|  |                                 "value": content, | ||||||
|  |                                 "type": CommandOptionType::String, | ||||||
|  |                             })); | ||||||
|  |                         } | ||||||
|  |  | ||||||
|  |                         if let Some(group) = captures.name("mentions") { | ||||||
|  |                             let content = group.as_str(); | ||||||
|  |                             args.push(json!({ | ||||||
|  |                                 "name": "channels", | ||||||
|  |                                 "value": content, | ||||||
|  |                                 "type": CommandOptionType::String, | ||||||
|  |                             })); | ||||||
|  |                         } | ||||||
|  |  | ||||||
|  |                         Some(RawCommandMacro { | ||||||
|  |                             guild_id, | ||||||
|  |                             name: alias_name, | ||||||
|  |                             description: None, | ||||||
|  |                             commands: json!([ | ||||||
|  |                                 { | ||||||
|  |                                     "command_name": "remind", | ||||||
|  |                                     "options": args, | ||||||
|  |                                 } | ||||||
|  |                             ]), | ||||||
|  |                         }) | ||||||
|  |                     } | ||||||
|  |  | ||||||
|  |                     None => None, | ||||||
|  |                 } | ||||||
|  |             } else if command_word == "n" || command_word == "natural" { | ||||||
|  |                 let matcher_primary = regex!( | ||||||
|  |                     r#"(?P<time>.*?)(?:\s+)(?:send|say)(?:\s+)(?P<content>.*?)(?:(?:\s+)to(?:\s+)(?P<mentions>((?:<@\d+>)|(?:<@!\d+>)|(?:<#\d+>)|(?:\s+))+))?$"#s | ||||||
|  |                 ); | ||||||
|  |                 let matcher_secondary = regex!( | ||||||
|  |                     r#"(?P<msg>.*)(?:\s+)every(?:\s+)(?P<interval>.*?)(?:(?:\s+)(?:until|for)(?:\s+)(?P<expires>.*?))?$"#s | ||||||
|  |                 ); | ||||||
|  |  | ||||||
|  |                 match matcher_primary.captures(&args) { | ||||||
|  |                     Some(captures) => { | ||||||
|  |                         let captures_secondary = matcher_secondary.captures(&args); | ||||||
|  |  | ||||||
|  |                         let mut args: Vec<Value> = vec![]; | ||||||
|  |  | ||||||
|  |                         if let Some(group) = captures.name("time") { | ||||||
|  |                             let content = group.as_str(); | ||||||
|  |                             args.push(json!({ | ||||||
|  |                                 "name": "time", | ||||||
|  |                                 "value": content, | ||||||
|  |                                 "type": CommandOptionType::String, | ||||||
|  |                             })); | ||||||
|  |                         } | ||||||
|  |  | ||||||
|  |                         if let Some(group) = captures.name("content") { | ||||||
|  |                             let content = group.as_str(); | ||||||
|  |                             args.push(json!({ | ||||||
|  |                                 "name": "content", | ||||||
|  |                                 "value": content, | ||||||
|  |                                 "type": CommandOptionType::String, | ||||||
|  |                             })); | ||||||
|  |                         } | ||||||
|  |  | ||||||
|  |                         if let Some(group) = | ||||||
|  |                             captures_secondary.as_ref().and_then(|c: &Captures| c.name("interval")) | ||||||
|  |                         { | ||||||
|  |                             let content = group.as_str(); | ||||||
|  |                             args.push(json!({ | ||||||
|  |                                 "name": "interval", | ||||||
|  |                                 "value": content, | ||||||
|  |                                 "type": CommandOptionType::String, | ||||||
|  |                             })); | ||||||
|  |                         } | ||||||
|  |  | ||||||
|  |                         if let Some(group) = | ||||||
|  |                             captures_secondary.and_then(|c: Captures| c.name("expires")) | ||||||
|  |                         { | ||||||
|  |                             let content = group.as_str(); | ||||||
|  |                             args.push(json!({ | ||||||
|  |                                 "name": "expires", | ||||||
|  |                                 "value": content, | ||||||
|  |                                 "type": CommandOptionType::String, | ||||||
|  |                             })); | ||||||
|  |                         } | ||||||
|  |  | ||||||
|  |                         if let Some(group) = captures.name("mentions") { | ||||||
|  |                             let content = group.as_str(); | ||||||
|  |                             args.push(json!({ | ||||||
|  |                                 "name": "channels", | ||||||
|  |                                 "value": content, | ||||||
|  |                                 "type": CommandOptionType::String, | ||||||
|  |                             })); | ||||||
|  |                         } | ||||||
|  |  | ||||||
|  |                         Some(RawCommandMacro { | ||||||
|  |                             guild_id, | ||||||
|  |                             name: alias_name, | ||||||
|  |                             description: None, | ||||||
|  |                             commands: json!([ | ||||||
|  |                                 { | ||||||
|  |                                     "command_name": "remind", | ||||||
|  |                                     "options": args, | ||||||
|  |                                 } | ||||||
|  |                             ]), | ||||||
|  |                         }) | ||||||
|  |                     } | ||||||
|  |  | ||||||
|  |                     None => None, | ||||||
|  |                 } | ||||||
|  |             } else { | ||||||
|  |                 None | ||||||
|  |             } | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         None => None, | ||||||
|  |     } | ||||||
|  | } | ||||||
							
								
								
									
										19
									
								
								src/commands/command_macro/mod.rs
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,19 @@ | |||||||
|  | use crate::{Context, Error}; | ||||||
|  |  | ||||||
|  | pub mod delete; | ||||||
|  | pub mod list; | ||||||
|  | pub mod migrate; | ||||||
|  | pub mod record; | ||||||
|  | pub mod run; | ||||||
|  |  | ||||||
|  | /// Record and replay command sequences | ||||||
|  | #[poise::command( | ||||||
|  |     slash_command, | ||||||
|  |     rename = "macro", | ||||||
|  |     guild_only = true, | ||||||
|  |     default_member_permissions = "MANAGE_GUILD", | ||||||
|  |     identifying_name = "macro_base" | ||||||
|  | )] | ||||||
|  | pub async fn macro_base(_ctx: Context<'_>) -> Result<(), Error> { | ||||||
|  |     Ok(()) | ||||||
|  | } | ||||||
							
								
								
									
										151
									
								
								src/commands/command_macro/record.rs
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,151 @@ | |||||||
|  | use std::collections::hash_map::Entry; | ||||||
|  |  | ||||||
|  | use crate::{consts::THEME_COLOR, models::command_macro::CommandMacro, Context, Error}; | ||||||
|  |  | ||||||
|  | /// Start recording up to 5 commands to replay | ||||||
|  | #[poise::command( | ||||||
|  |     slash_command, | ||||||
|  |     rename = "record", | ||||||
|  |     guild_only = true, | ||||||
|  |     default_member_permissions = "MANAGE_GUILD", | ||||||
|  |     identifying_name = "record_macro" | ||||||
|  | )] | ||||||
|  | pub async fn record_macro( | ||||||
|  |     ctx: Context<'_>, | ||||||
|  |     #[description = "Name for the new macro"] name: String, | ||||||
|  |     #[description = "Description for the new macro"] description: Option<String>, | ||||||
|  | ) -> Result<(), Error> { | ||||||
|  |     if name.len() > 100 { | ||||||
|  |         ctx.say("Name must be less than 100 characters").await?; | ||||||
|  |  | ||||||
|  |         return Ok(()); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     if description.as_ref().map_or(0, |d| d.len()) > 100 { | ||||||
|  |         ctx.say("Description must be less than 100 characters").await?; | ||||||
|  |  | ||||||
|  |         return Ok(()); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     let guild_id = ctx.guild_id().unwrap(); | ||||||
|  |  | ||||||
|  |     let row = sqlx::query!( | ||||||
|  |         " | ||||||
|  | SELECT 1 as _e FROM macro WHERE guild_id = (SELECT id FROM guilds WHERE guild = ?) AND name = ?", | ||||||
|  |         guild_id.0, | ||||||
|  |         name | ||||||
|  |     ) | ||||||
|  |     .fetch_one(&ctx.data().database) | ||||||
|  |     .await; | ||||||
|  |  | ||||||
|  |     if row.is_ok() { | ||||||
|  |         ctx.send(|m| { | ||||||
|  |             m.ephemeral(true).embed(|e| { | ||||||
|  |                 e.title("Unique Name Required") | ||||||
|  |                     .description( | ||||||
|  |                         "A macro already exists under this name. | ||||||
|  | Please select a unique name for your macro.", | ||||||
|  |                     ) | ||||||
|  |                     .color(*THEME_COLOR) | ||||||
|  |             }) | ||||||
|  |         }) | ||||||
|  |         .await?; | ||||||
|  |     } else { | ||||||
|  |         let okay = { | ||||||
|  |             let mut lock = ctx.data().recording_macros.write().await; | ||||||
|  |  | ||||||
|  |             if let Entry::Vacant(e) = lock.entry((guild_id, ctx.author().id)) { | ||||||
|  |                 e.insert(CommandMacro { guild_id, name, description, commands: vec![] }); | ||||||
|  |                 true | ||||||
|  |             } else { | ||||||
|  |                 false | ||||||
|  |             } | ||||||
|  |         }; | ||||||
|  |  | ||||||
|  |         if okay { | ||||||
|  |             ctx.send(|m| { | ||||||
|  |                 m.ephemeral(true).embed(|e| { | ||||||
|  |                     e.title("Macro Recording Started") | ||||||
|  |                         .description( | ||||||
|  |                             "Run up to 5 commands, or type `/macro finish` to stop at any point. | ||||||
|  | Any commands ran as part of recording will be inconsequential", | ||||||
|  |                         ) | ||||||
|  |                         .color(*THEME_COLOR) | ||||||
|  |                 }) | ||||||
|  |             }) | ||||||
|  |             .await?; | ||||||
|  |         } else { | ||||||
|  |             ctx.send(|m| { | ||||||
|  |                 m.ephemeral(true).embed(|e| { | ||||||
|  |                     e.title("Macro Already Recording") | ||||||
|  |                         .description( | ||||||
|  |                             "You are already recording a macro in this server. | ||||||
|  | Please use `/macro finish` to end this recording before starting another.", | ||||||
|  |                         ) | ||||||
|  |                         .color(*THEME_COLOR) | ||||||
|  |                 }) | ||||||
|  |             }) | ||||||
|  |             .await?; | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     Ok(()) | ||||||
|  | } | ||||||
|  |  | ||||||
|  | /// Finish current macro recording | ||||||
|  | #[poise::command( | ||||||
|  |     slash_command, | ||||||
|  |     rename = "finish", | ||||||
|  |     guild_only = true, | ||||||
|  |     default_member_permissions = "MANAGE_GUILD", | ||||||
|  |     identifying_name = "finish_macro" | ||||||
|  | )] | ||||||
|  | pub async fn finish_macro(ctx: Context<'_>) -> Result<(), Error> { | ||||||
|  |     let key = (ctx.guild_id().unwrap(), ctx.author().id); | ||||||
|  |  | ||||||
|  |     { | ||||||
|  |         let lock = ctx.data().recording_macros.read().await; | ||||||
|  |         let contained = lock.get(&key); | ||||||
|  |  | ||||||
|  |         if contained.map_or(true, |cmacro| cmacro.commands.is_empty()) { | ||||||
|  |             ctx.send(|m| { | ||||||
|  |                 m.embed(|e| { | ||||||
|  |                     e.title("No Macro Recorded") | ||||||
|  |                         .description("Use `/macro record` to start recording a macro") | ||||||
|  |                         .color(*THEME_COLOR) | ||||||
|  |                 }) | ||||||
|  |             }) | ||||||
|  |             .await?; | ||||||
|  |         } else { | ||||||
|  |             let command_macro = contained.unwrap(); | ||||||
|  |             let json = serde_json::to_string(&command_macro.commands).unwrap(); | ||||||
|  |  | ||||||
|  |             sqlx::query!( | ||||||
|  |                 "INSERT INTO macro (guild_id, name, description, commands) VALUES ((SELECT id FROM guilds WHERE guild = ?), ?, ?, ?)", | ||||||
|  |                 command_macro.guild_id.0, | ||||||
|  |                 command_macro.name, | ||||||
|  |                 command_macro.description, | ||||||
|  |                 json | ||||||
|  |             ) | ||||||
|  |                 .execute(&ctx.data().database) | ||||||
|  |                 .await | ||||||
|  |                 .unwrap(); | ||||||
|  |  | ||||||
|  |             ctx.send(|m| { | ||||||
|  |                 m.embed(|e| { | ||||||
|  |                     e.title("Macro Recorded") | ||||||
|  |                         .description("Use `/macro run` to execute the macro") | ||||||
|  |                         .color(*THEME_COLOR) | ||||||
|  |                 }) | ||||||
|  |             }) | ||||||
|  |             .await?; | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     { | ||||||
|  |         let mut lock = ctx.data().recording_macros.write().await; | ||||||
|  |         lock.remove(&key); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     Ok(()) | ||||||
|  | } | ||||||
							
								
								
									
										56
									
								
								src/commands/command_macro/run.rs
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,56 @@ | |||||||
|  | use super::super::autocomplete::macro_name_autocomplete; | ||||||
|  | use crate::{models::command_macro::guild_command_macro, Context, Data, Error, THEME_COLOR}; | ||||||
|  |  | ||||||
|  | /// Run a recorded macro | ||||||
|  | #[poise::command( | ||||||
|  |     slash_command, | ||||||
|  |     rename = "run", | ||||||
|  |     guild_only = true, | ||||||
|  |     default_member_permissions = "MANAGE_GUILD", | ||||||
|  |     identifying_name = "run_macro" | ||||||
|  | )] | ||||||
|  | pub async fn run_macro( | ||||||
|  |     ctx: poise::ApplicationContext<'_, Data, Error>, | ||||||
|  |     #[description = "Name of macro to run"] | ||||||
|  |     #[autocomplete = "macro_name_autocomplete"] | ||||||
|  |     name: String, | ||||||
|  | ) -> Result<(), Error> { | ||||||
|  |     match guild_command_macro(&Context::Application(ctx), &name).await { | ||||||
|  |         Some(command_macro) => { | ||||||
|  |             Context::Application(ctx) | ||||||
|  |                 .send(|b| { | ||||||
|  |                     b.embed(|e| { | ||||||
|  |                         e.title("Running Macro").color(*THEME_COLOR).description(format!( | ||||||
|  |                             "Running macro {} ({} commands)", | ||||||
|  |                             command_macro.name, | ||||||
|  |                             command_macro.commands.len() | ||||||
|  |                         )) | ||||||
|  |                     }) | ||||||
|  |                 }) | ||||||
|  |                 .await?; | ||||||
|  |  | ||||||
|  |             for command in command_macro.commands { | ||||||
|  |                 if let Some(action) = command.action { | ||||||
|  |                     match (action)(poise::ApplicationContext { args: &command.options, ..ctx }) | ||||||
|  |                         .await | ||||||
|  |                     { | ||||||
|  |                         Ok(()) => {} | ||||||
|  |                         Err(e) => { | ||||||
|  |                             println!("{:?}", e); | ||||||
|  |                         } | ||||||
|  |                     } | ||||||
|  |                 } else { | ||||||
|  |                     Context::Application(ctx) | ||||||
|  |                         .say(format!("Command \"{}\" not found", command.command_name)) | ||||||
|  |                         .await?; | ||||||
|  |                 } | ||||||
|  |             } | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         None => { | ||||||
|  |             Context::Application(ctx).say(format!("Macro \"{}\" not found", name)).await?; | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     Ok(()) | ||||||
|  | } | ||||||
| @@ -49,6 +49,7 @@ __Todo Commands__ | |||||||
|  |  | ||||||
| __Setup Commands__ | __Setup Commands__ | ||||||
| `/timezone` - Set your timezone (necessary for `/remind` to work properly) | `/timezone` - Set your timezone (necessary for `/remind` to work properly) | ||||||
|  | `/dm allow/block` - Change your DM settings for reminders. | ||||||
|  |  | ||||||
| __Advanced Commands__ | __Advanced Commands__ | ||||||
| `/macro` - Record and replay command sequences | `/macro` - Record and replay command sequences | ||||||
| @@ -71,7 +72,7 @@ pub async fn info(ctx: Context<'_>) -> Result<(), Error> { | |||||||
|         .send(|m| { |         .send(|m| { | ||||||
|             m.ephemeral(true).embed(|e| { |             m.ephemeral(true).embed(|e| { | ||||||
|                 e.title("Info") |                 e.title("Info") | ||||||
|                     .description(format!( |                     .description( | ||||||
|                         "Help: `/help` |                         "Help: `/help` | ||||||
|  |  | ||||||
| **Welcome to Reminder Bot!** | **Welcome to Reminder Bot!** | ||||||
| @@ -81,7 +82,7 @@ Find me on https://discord.jellywx.com and on https://github.com/JellyWX :) | |||||||
|  |  | ||||||
| Invite the bot: https://invite.reminder-bot.com/ | Invite the bot: https://invite.reminder-bot.com/ | ||||||
| Use our dashboard: https://reminder-bot.com/", | Use our dashboard: https://reminder-bot.com/", | ||||||
|                     )) |                     ) | ||||||
|                     .footer(footer) |                     .footer(footer) | ||||||
|                     .color(*THEME_COLOR) |                     .color(*THEME_COLOR) | ||||||
|             }) |             }) | ||||||
|   | |||||||
| @@ -1,3 +1,5 @@ | |||||||
|  | mod autocomplete; | ||||||
|  | pub mod command_macro; | ||||||
| pub mod info_cmds; | pub mod info_cmds; | ||||||
| pub mod moderation_cmds; | pub mod moderation_cmds; | ||||||
| pub mod reminder_cmds; | pub mod reminder_cmds; | ||||||
|   | |||||||
| @@ -1,30 +1,10 @@ | |||||||
| 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 poise::CreateReply; | use log::warn; | ||||||
|  |  | ||||||
| use crate::{ | use super::autocomplete::timezone_autocomplete; | ||||||
|     component_models::pager::{MacroPager, Pager}, | use crate::{consts::THEME_COLOR, models::CtxData, Context, Error}; | ||||||
|     consts::{EMBED_DESCRIPTION_MAX_LENGTH, THEME_COLOR}, |  | ||||||
|     models::{ |  | ||||||
|         command_macro::{guild_command_macro, CommandMacro}, |  | ||||||
|         CtxData, |  | ||||||
|     }, |  | ||||||
|     Context, Data, Error, |  | ||||||
| }; |  | ||||||
|  |  | ||||||
| async fn timezone_autocomplete(ctx: Context<'_>, partial: String) -> Vec<String> { |  | ||||||
|     if partial.is_empty() { |  | ||||||
|         ctx.data().popular_timezones.iter().map(|t| t.to_string()).collect::<Vec<String>>() |  | ||||||
|     } else { |  | ||||||
|         TZ_VARIANTS |  | ||||||
|             .iter() |  | ||||||
|             .filter(|tz| tz.to_string().contains(&partial)) |  | ||||||
|             .take(25) |  | ||||||
|             .map(|t| t.to_string()) |  | ||||||
|             .collect::<Vec<String>>() |  | ||||||
|     } |  | ||||||
| } |  | ||||||
|  |  | ||||||
| /// Select your timezone | /// Select your timezone | ||||||
| #[poise::command(slash_command, identifying_name = "timezone")] | #[poise::command(slash_command, identifying_name = "timezone")] | ||||||
| @@ -52,7 +32,7 @@ pub async fn timezone( | |||||||
|                             .description(format!( |                             .description(format!( | ||||||
|                                 "Timezone has been set to **{}**. Your current time should be `{}`", |                                 "Timezone has been set to **{}**. Your current time should be `{}`", | ||||||
|                                 timezone, |                                 timezone, | ||||||
|                                 now.format("%H:%M").to_string() |                                 now.format("%H:%M") | ||||||
|                             )) |                             )) | ||||||
|                             .color(*THEME_COLOR) |                             .color(*THEME_COLOR) | ||||||
|                     }) |                     }) | ||||||
| @@ -75,10 +55,7 @@ pub async fn timezone( | |||||||
|                 let fields = filtered_tz.iter().map(|tz| { |                 let fields = filtered_tz.iter().map(|tz| { | ||||||
|                     ( |                     ( | ||||||
|                         tz.to_string(), |                         tz.to_string(), | ||||||
|                         format!( |                         format!("🕗 `{}`", Utc::now().with_timezone(tz).format("%H:%M")), | ||||||
|                             "🕗 `{}`", |  | ||||||
|                             Utc::now().with_timezone(tz).format("%H:%M").to_string() |  | ||||||
|                         ), |  | ||||||
|                         true, |                         true, | ||||||
|                     ) |                     ) | ||||||
|                 }); |                 }); | ||||||
| @@ -98,11 +75,7 @@ pub async fn timezone( | |||||||
|         } |         } | ||||||
|     } else { |     } else { | ||||||
|         let popular_timezones_iter = ctx.data().popular_timezones.iter().map(|t| { |         let popular_timezones_iter = ctx.data().popular_timezones.iter().map(|t| { | ||||||
|             ( |             (t.to_string(), format!("🕗 `{}`", Utc::now().with_timezone(t).format("%H:%M")), true) | ||||||
|                 t.to_string(), |  | ||||||
|                 format!("🕗 `{}`", Utc::now().with_timezone(t).format("%H:%M").to_string()), |  | ||||||
|                 true, |  | ||||||
|             ) |  | ||||||
|         }); |         }); | ||||||
|  |  | ||||||
|         ctx.send(|m| { |         ctx.send(|m| { | ||||||
| @@ -129,379 +102,82 @@ You may want to use one of the popular timezones below, otherwise click [here](h | |||||||
|     Ok(()) |     Ok(()) | ||||||
| } | } | ||||||
|  |  | ||||||
| async fn macro_name_autocomplete(ctx: Context<'_>, partial: String) -> Vec<String> { | /// Configure whether other users can set reminders to your direct messages | ||||||
|     sqlx::query!( | #[poise::command(slash_command, rename = "dm", identifying_name = "allowed_dm")] | ||||||
|         " | pub async fn allowed_dm(_ctx: Context<'_>) -> Result<(), Error> { | ||||||
| SELECT name |  | ||||||
| FROM macro |  | ||||||
| WHERE |  | ||||||
|     guild_id = (SELECT id FROM guilds WHERE guild = ?) |  | ||||||
|     AND name LIKE CONCAT(?, '%')", |  | ||||||
|         ctx.guild_id().unwrap().0, |  | ||||||
|         partial, |  | ||||||
|     ) |  | ||||||
|     .fetch_all(&ctx.data().database) |  | ||||||
|     .await |  | ||||||
|     .unwrap_or(vec![]) |  | ||||||
|     .iter() |  | ||||||
|     .map(|s| s.name.clone()) |  | ||||||
|     .collect() |  | ||||||
| } |  | ||||||
|  |  | ||||||
| /// Record and replay command sequences |  | ||||||
| #[poise::command( |  | ||||||
|     slash_command, |  | ||||||
|     rename = "macro", |  | ||||||
|     guild_only = true, |  | ||||||
|     default_member_permissions = "MANAGE_GUILD", |  | ||||||
|     identifying_name = "macro_base" |  | ||||||
| )] |  | ||||||
| pub async fn macro_base(_ctx: Context<'_>) -> Result<(), Error> { |  | ||||||
|     Ok(()) |     Ok(()) | ||||||
| } | } | ||||||
|  |  | ||||||
| /// Start recording up to 5 commands to replay | /// Allow other users to set reminders in your direct messages | ||||||
| #[poise::command( | #[poise::command(slash_command, rename = "allow", identifying_name = "allowed_dm")] | ||||||
|     slash_command, | pub async fn set_allowed_dm(ctx: Context<'_>) -> Result<(), Error> { | ||||||
|     rename = "record", |     let mut user_data = ctx.author_data().await?; | ||||||
|     guild_only = true, |     user_data.allowed_dm = true; | ||||||
|     default_member_permissions = "MANAGE_GUILD", |     user_data.commit_changes(&ctx.data().database).await; | ||||||
|     identifying_name = "record_macro" |  | ||||||
| )] |  | ||||||
| pub async fn record_macro( |  | ||||||
|     ctx: Context<'_>, |  | ||||||
|     #[description = "Name for the new macro"] name: String, |  | ||||||
|     #[description = "Description for the new macro"] description: Option<String>, |  | ||||||
| ) -> Result<(), Error> { |  | ||||||
|     let guild_id = ctx.guild_id().unwrap(); |  | ||||||
|  |  | ||||||
|     let row = sqlx::query!( |     ctx.send(|r| { | ||||||
|         " |         r.ephemeral(true).embed(|e| { | ||||||
| SELECT 1 as _e FROM macro WHERE guild_id = (SELECT id FROM guilds WHERE guild = ?) AND name = ?", |             e.title("DMs permitted") | ||||||
|         guild_id.0, |                 .description("You will receive a message if a user sets a DM reminder for you.") | ||||||
|         name |                 .color(*THEME_COLOR) | ||||||
|     ) |         }) | ||||||
|     .fetch_one(&ctx.data().database) |     }) | ||||||
|     .await; |     .await?; | ||||||
|  |  | ||||||
|     if row.is_ok() { |     Ok(()) | ||||||
|         ctx.send(|m| { | } | ||||||
|             m.ephemeral(true).embed(|e| { |  | ||||||
|                 e.title("Unique Name Required") | /// Block other users from setting reminders in your direct messages | ||||||
|  | #[poise::command(slash_command, rename = "block", identifying_name = "allowed_dm")] | ||||||
|  | pub async fn unset_allowed_dm(ctx: Context<'_>) -> Result<(), Error> { | ||||||
|  |     let mut user_data = ctx.author_data().await?; | ||||||
|  |     user_data.allowed_dm = false; | ||||||
|  |     user_data.commit_changes(&ctx.data().database).await; | ||||||
|  |  | ||||||
|  |     ctx.send(|r| { | ||||||
|  |         r.ephemeral(true).embed(|e| { | ||||||
|  |             e.title("DMs blocked") | ||||||
|                 .description( |                 .description( | ||||||
|                         "A macro already exists under this name. |                     "You can still set DM reminders for yourself or for users with DMs enabled.", | ||||||
| Please select a unique name for your macro.", |  | ||||||
|                 ) |                 ) | ||||||
|                 .color(*THEME_COLOR) |                 .color(*THEME_COLOR) | ||||||
|         }) |         }) | ||||||
|     }) |     }) | ||||||
|     .await?; |     .await?; | ||||||
|     } else { |  | ||||||
|         let okay = { |  | ||||||
|             let mut lock = ctx.data().recording_macros.write().await; |  | ||||||
|  |  | ||||||
|             if lock.contains_key(&(guild_id, ctx.author().id)) { |  | ||||||
|                 false |  | ||||||
|             } else { |  | ||||||
|                 lock.insert( |  | ||||||
|                     (guild_id, ctx.author().id), |  | ||||||
|                     CommandMacro { guild_id, name, description, commands: vec![] }, |  | ||||||
|                 ); |  | ||||||
|                 true |  | ||||||
|             } |  | ||||||
|         }; |  | ||||||
|  |  | ||||||
|         if okay { |  | ||||||
|             ctx.send(|m| { |  | ||||||
|                 m.ephemeral(true).embed(|e| { |  | ||||||
|                     e.title("Macro Recording Started") |  | ||||||
|                         .description( |  | ||||||
|                             "Run up to 5 commands, or type `/macro finish` to stop at any point. |  | ||||||
| Any commands ran as part of recording will be inconsequential", |  | ||||||
|                         ) |  | ||||||
|                         .color(*THEME_COLOR) |  | ||||||
|                 }) |  | ||||||
|             }) |  | ||||||
|             .await?; |  | ||||||
|         } else { |  | ||||||
|             ctx.send(|m| { |  | ||||||
|                 m.ephemeral(true).embed(|e| { |  | ||||||
|                     e.title("Macro Already Recording") |  | ||||||
|                         .description( |  | ||||||
|                             "You are already recording a macro in this server. |  | ||||||
| Please use `/macro finish` to end this recording before starting another.", |  | ||||||
|                         ) |  | ||||||
|                         .color(*THEME_COLOR) |  | ||||||
|                 }) |  | ||||||
|             }) |  | ||||||
|             .await?; |  | ||||||
|         } |  | ||||||
|     } |  | ||||||
|  |  | ||||||
|     Ok(()) |  | ||||||
| } |  | ||||||
|  |  | ||||||
| /// Finish current macro recording |  | ||||||
| #[poise::command( |  | ||||||
|     slash_command, |  | ||||||
|     rename = "finish", |  | ||||||
|     guild_only = true, |  | ||||||
|     default_member_permissions = "MANAGE_GUILD", |  | ||||||
|     identifying_name = "finish_macro" |  | ||||||
| )] |  | ||||||
| pub async fn finish_macro(ctx: Context<'_>) -> Result<(), Error> { |  | ||||||
|     let key = (ctx.guild_id().unwrap(), ctx.author().id); |  | ||||||
|  |  | ||||||
|     { |  | ||||||
|         let lock = ctx.data().recording_macros.read().await; |  | ||||||
|         let contained = lock.get(&key); |  | ||||||
|  |  | ||||||
|         if contained.map_or(true, |cmacro| cmacro.commands.is_empty()) { |  | ||||||
|             ctx.send(|m| { |  | ||||||
|                 m.embed(|e| { |  | ||||||
|                     e.title("No Macro Recorded") |  | ||||||
|                         .description("Use `/macro record` to start recording a macro") |  | ||||||
|                         .color(*THEME_COLOR) |  | ||||||
|                 }) |  | ||||||
|             }) |  | ||||||
|             .await?; |  | ||||||
|         } else { |  | ||||||
|             let command_macro = contained.unwrap(); |  | ||||||
|             let json = serde_json::to_string(&command_macro.commands).unwrap(); |  | ||||||
|  |  | ||||||
|             sqlx::query!( |  | ||||||
|                 "INSERT INTO macro (guild_id, name, description, commands) VALUES ((SELECT id FROM guilds WHERE guild = ?), ?, ?, ?)", |  | ||||||
|                 command_macro.guild_id.0, |  | ||||||
|                 command_macro.name, |  | ||||||
|                 command_macro.description, |  | ||||||
|                 json |  | ||||||
|             ) |  | ||||||
|                 .execute(&ctx.data().database) |  | ||||||
|                 .await |  | ||||||
|                 .unwrap(); |  | ||||||
|  |  | ||||||
|             ctx.send(|m| { |  | ||||||
|                 m.embed(|e| { |  | ||||||
|                     e.title("Macro Recorded") |  | ||||||
|                         .description("Use `/macro run` to execute the macro") |  | ||||||
|                         .color(*THEME_COLOR) |  | ||||||
|                 }) |  | ||||||
|             }) |  | ||||||
|             .await?; |  | ||||||
|         } |  | ||||||
|     } |  | ||||||
|  |  | ||||||
|     { |  | ||||||
|         let mut lock = ctx.data().recording_macros.write().await; |  | ||||||
|         lock.remove(&key); |  | ||||||
|     } |  | ||||||
|  |  | ||||||
|     Ok(()) |  | ||||||
| } |  | ||||||
|  |  | ||||||
| /// List recorded macros |  | ||||||
| #[poise::command( |  | ||||||
|     slash_command, |  | ||||||
|     rename = "list", |  | ||||||
|     guild_only = true, |  | ||||||
|     default_member_permissions = "MANAGE_GUILD", |  | ||||||
|     identifying_name = "list_macro" |  | ||||||
| )] |  | ||||||
| pub async fn list_macro(ctx: Context<'_>) -> Result<(), Error> { |  | ||||||
|     let macros = ctx.command_macros().await?; |  | ||||||
|  |  | ||||||
|     let resp = show_macro_page(¯os, 0); |  | ||||||
|  |  | ||||||
|     ctx.send(|m| { |  | ||||||
|         *m = resp; |  | ||||||
|         m |  | ||||||
|     }) |  | ||||||
|     .await?; |  | ||||||
|  |  | ||||||
|     Ok(()) |     Ok(()) | ||||||
| } | } | ||||||
|  |  | ||||||
| /// Run a recorded macro | /// View the webhook being used to send reminders to this channel | ||||||
| #[poise::command( | #[poise::command( | ||||||
|     slash_command, |     slash_command, | ||||||
|     rename = "run", |     identifying_name = "webhook_url", | ||||||
|     guild_only = true, |     required_permissions = "ADMINISTRATOR" | ||||||
|     default_member_permissions = "MANAGE_GUILD", |  | ||||||
|     identifying_name = "run_macro" |  | ||||||
| )] | )] | ||||||
| pub async fn run_macro( | pub async fn webhook(ctx: Context<'_>) -> Result<(), Error> { | ||||||
|     ctx: poise::ApplicationContext<'_, Data, Error>, |     match ctx.channel_data().await { | ||||||
|     #[description = "Name of macro to run"] |         Ok(data) => { | ||||||
|     #[autocomplete = "macro_name_autocomplete"] |             if let (Some(id), Some(token)) = (data.webhook_id, data.webhook_token) { | ||||||
|     name: String, |                 ctx.send(|b| { | ||||||
| ) -> Result<(), Error> { |                     b.ephemeral(true).content(format!( | ||||||
|     match guild_command_macro(&Context::Application(ctx), &name).await { |                         "**Warning!** | ||||||
|         Some(command_macro) => { | This link can be used by users to anonymously send messages, with or without permissions. | ||||||
|             ctx.defer_response(false).await?; | Do not share it! | ||||||
|  | || https://discord.com/api/webhooks/{}/{} ||", | ||||||
|             for command in command_macro.commands { |                         id, token, | ||||||
|                 if let Some(action) = command.action { |                     )) | ||||||
|                     match (action)(poise::ApplicationContext { args: &command.options, ..ctx }) |                 }) | ||||||
|                         .await |                 .await?; | ||||||
|                     { |             } else { | ||||||
|                         Ok(()) => {} |                 ctx.say("No webhook configured on this channel.").await?; | ||||||
|  |             } | ||||||
|  |         } | ||||||
|         Err(e) => { |         Err(e) => { | ||||||
|                             println!("{:?}", e); |             warn!("Error fetching channel data: {:?}", e); | ||||||
|                         } |  | ||||||
|                     } |  | ||||||
|                 } else { |  | ||||||
|                     Context::Application(ctx) |  | ||||||
|                         .say(format!("Command \"{}\" not found", command.command_name)) |  | ||||||
|                         .await?; |  | ||||||
|                 } |  | ||||||
|             } |  | ||||||
|         } |  | ||||||
|  |  | ||||||
|         None => { |             ctx.say("No webhook configured on this channel.").await?; | ||||||
|             Context::Application(ctx).say(format!("Macro \"{}\" not found", name)).await?; |  | ||||||
|         } |         } | ||||||
|     } |     } | ||||||
|  |  | ||||||
|     Ok(()) |     Ok(()) | ||||||
| } | } | ||||||
|  |  | ||||||
| /// Delete a recorded macro |  | ||||||
| #[poise::command( |  | ||||||
|     slash_command, |  | ||||||
|     rename = "delete", |  | ||||||
|     guild_only = true, |  | ||||||
|     default_member_permissions = "MANAGE_GUILD", |  | ||||||
|     identifying_name = "delete_macro" |  | ||||||
| )] |  | ||||||
| pub async fn delete_macro( |  | ||||||
|     ctx: Context<'_>, |  | ||||||
|     #[description = "Name of macro to delete"] |  | ||||||
|     #[autocomplete = "macro_name_autocomplete"] |  | ||||||
|     name: String, |  | ||||||
| ) -> Result<(), Error> { |  | ||||||
|     match sqlx::query!( |  | ||||||
|         " |  | ||||||
| SELECT id FROM macro WHERE guild_id = (SELECT id FROM guilds WHERE guild = ?) AND name = ?", |  | ||||||
|         ctx.guild_id().unwrap().0, |  | ||||||
|         name |  | ||||||
|     ) |  | ||||||
|     .fetch_one(&ctx.data().database) |  | ||||||
|     .await |  | ||||||
|     { |  | ||||||
|         Ok(row) => { |  | ||||||
|             sqlx::query!("DELETE FROM macro WHERE id = ?", row.id) |  | ||||||
|                 .execute(&ctx.data().database) |  | ||||||
|                 .await |  | ||||||
|                 .unwrap(); |  | ||||||
|  |  | ||||||
|             ctx.say(format!("Macro \"{}\" deleted", name)).await?; |  | ||||||
|         } |  | ||||||
|  |  | ||||||
|         Err(sqlx::Error::RowNotFound) => { |  | ||||||
|             ctx.say(format!("Macro \"{}\" not found", name)).await?; |  | ||||||
|         } |  | ||||||
|  |  | ||||||
|         Err(e) => { |  | ||||||
|             panic!("{}", e); |  | ||||||
|         } |  | ||||||
|     } |  | ||||||
|  |  | ||||||
|     Ok(()) |  | ||||||
| } |  | ||||||
|  |  | ||||||
| pub fn max_macro_page<U, E>(macros: &[CommandMacro<U, E>]) -> usize { |  | ||||||
|     let mut skipped_char_count = 0; |  | ||||||
|  |  | ||||||
|     macros |  | ||||||
|         .iter() |  | ||||||
|         .map(|m| { |  | ||||||
|             if let Some(description) = &m.description { |  | ||||||
|                 format!("**{}**\n- *{}*\n- Has {} commands", m.name, description, m.commands.len()) |  | ||||||
|             } else { |  | ||||||
|                 format!("**{}**\n- Has {} commands", m.name, m.commands.len()) |  | ||||||
|             } |  | ||||||
|         }) |  | ||||||
|         .fold(1, |mut pages, p| { |  | ||||||
|             skipped_char_count += p.len(); |  | ||||||
|  |  | ||||||
|             if skipped_char_count > EMBED_DESCRIPTION_MAX_LENGTH { |  | ||||||
|                 skipped_char_count = p.len(); |  | ||||||
|                 pages += 1; |  | ||||||
|             } |  | ||||||
|  |  | ||||||
|             pages |  | ||||||
|         }) |  | ||||||
| } |  | ||||||
|  |  | ||||||
| pub fn show_macro_page<U, E>(macros: &[CommandMacro<U, E>], page: usize) -> CreateReply { |  | ||||||
|     let pager = MacroPager::new(page); |  | ||||||
|  |  | ||||||
|     if macros.is_empty() { |  | ||||||
|         let mut reply = CreateReply::default(); |  | ||||||
|  |  | ||||||
|         reply.embed(|e| { |  | ||||||
|             e.title("Macros") |  | ||||||
|                 .description("No Macros Set Up. Use `/macro record` to get started.") |  | ||||||
|                 .color(*THEME_COLOR) |  | ||||||
|         }); |  | ||||||
|  |  | ||||||
|         return reply; |  | ||||||
|     } |  | ||||||
|  |  | ||||||
|     let pages = max_macro_page(macros); |  | ||||||
|  |  | ||||||
|     let mut page = page; |  | ||||||
|     if page >= pages { |  | ||||||
|         page = pages - 1; |  | ||||||
|     } |  | ||||||
|  |  | ||||||
|     let mut char_count = 0; |  | ||||||
|     let mut skipped_char_count = 0; |  | ||||||
|  |  | ||||||
|     let mut skipped_pages = 0; |  | ||||||
|  |  | ||||||
|     let display_vec: Vec<String> = macros |  | ||||||
|         .iter() |  | ||||||
|         .map(|m| { |  | ||||||
|             if let Some(description) = &m.description { |  | ||||||
|                 format!("**{}**\n- *{}*\n- Has {} commands", m.name, description, m.commands.len()) |  | ||||||
|             } else { |  | ||||||
|                 format!("**{}**\n- Has {} commands", m.name, m.commands.len()) |  | ||||||
|             } |  | ||||||
|         }) |  | ||||||
|         .skip_while(|p| { |  | ||||||
|             skipped_char_count += p.len(); |  | ||||||
|  |  | ||||||
|             if skipped_char_count > EMBED_DESCRIPTION_MAX_LENGTH { |  | ||||||
|                 skipped_char_count = p.len(); |  | ||||||
|                 skipped_pages += 1; |  | ||||||
|             } |  | ||||||
|  |  | ||||||
|             skipped_pages < page |  | ||||||
|         }) |  | ||||||
|         .take_while(|p| { |  | ||||||
|             char_count += p.len(); |  | ||||||
|  |  | ||||||
|             char_count < EMBED_DESCRIPTION_MAX_LENGTH |  | ||||||
|         }) |  | ||||||
|         .collect::<Vec<String>>(); |  | ||||||
|  |  | ||||||
|     let display = display_vec.join("\n"); |  | ||||||
|  |  | ||||||
|     let mut reply = CreateReply::default(); |  | ||||||
|  |  | ||||||
|     reply |  | ||||||
|         .embed(|e| { |  | ||||||
|             e.title("Macros") |  | ||||||
|                 .description(display) |  | ||||||
|                 .footer(|f| f.text(format!("Page {} of {}", page + 1, pages))) |  | ||||||
|                 .color(*THEME_COLOR) |  | ||||||
|         }) |  | ||||||
|         .components(|comp| { |  | ||||||
|             pager.create_button_row(pages, comp); |  | ||||||
|  |  | ||||||
|             comp |  | ||||||
|         }); |  | ||||||
|  |  | ||||||
|     reply |  | ||||||
| } |  | ||||||
|   | |||||||
| @@ -1,21 +1,20 @@ | |||||||
| 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 num_integer::Integer; | use num_integer::Integer; | ||||||
| use poise::{ | use poise::{ | ||||||
|     serenity::{builder::CreateEmbed, model::channel::Channel}, |     serenity_prelude::{ | ||||||
|     CreateReply, |         builder::CreateEmbed, component::ButtonStyle, model::channel::Channel, ReactionType, | ||||||
|  |     }, | ||||||
|  |     CreateReply, Modal, | ||||||
| }; | }; | ||||||
|  |  | ||||||
| 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, |         ComponentDataModel, DelSelector, UndoReminder, | ||||||
|     }, |     }, | ||||||
|     consts::{ |     consts::{ | ||||||
|         EMBED_DESCRIPTION_MAX_LENGTH, HOUR, MINUTE, REGEX_CHANNEL_USER, SELECT_MAX_ENTRIES, |         EMBED_DESCRIPTION_MAX_LENGTH, HOUR, MINUTE, REGEX_CHANNEL_USER, SELECT_MAX_ENTRIES, | ||||||
| @@ -35,7 +34,7 @@ use crate::{ | |||||||
|     }, |     }, | ||||||
|     time_parser::natural_parser, |     time_parser::natural_parser, | ||||||
|     utils::{check_guild_subscription, check_subscription}, |     utils::{check_guild_subscription, check_subscription}, | ||||||
|     Context, Error, |     ApplicationContext, Context, Error, | ||||||
| }; | }; | ||||||
|  |  | ||||||
| /// Pause all reminders on the current channel until a certain time or indefinitely | /// Pause all reminders on the current channel until a certain time or indefinitely | ||||||
| @@ -57,8 +56,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); | ||||||
|  |  | ||||||
| @@ -69,6 +68,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", | ||||||
| @@ -242,7 +250,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() | ||||||
| @@ -429,11 +437,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); | ||||||
| @@ -500,8 +505,7 @@ pub async fn start_timer( | |||||||
|     if count >= 25 { |     if count >= 25 { | ||||||
|         ctx.say("You already have 25 timers. Please delete some timers before creating a new one") |         ctx.say("You already have 25 timers. Please delete some timers before creating a new one") | ||||||
|             .await?; |             .await?; | ||||||
|     } else { |     } else if name.len() <= 32 { | ||||||
|         if name.len() <= 32 { |  | ||||||
|         Timer::create(&name, owner, &ctx.data().database).await; |         Timer::create(&name, owner, &ctx.data().database).await; | ||||||
|  |  | ||||||
|         ctx.say("Created a new timer").await?; |         ctx.say("Created a new timer").await?; | ||||||
| @@ -512,7 +516,6 @@ pub async fn start_timer( | |||||||
|         )) |         )) | ||||||
|         .await?; |         .await?; | ||||||
|     } |     } | ||||||
|     } |  | ||||||
|  |  | ||||||
|     Ok(()) |     Ok(()) | ||||||
| } | } | ||||||
| @@ -549,23 +552,92 @@ pub async fn delete_timer( | |||||||
|     Ok(()) |     Ok(()) | ||||||
| } | } | ||||||
|  |  | ||||||
| /// Create a new reminder | #[derive(poise::Modal)] | ||||||
|  | #[name = "Reminder"] | ||||||
|  | struct ContentModal { | ||||||
|  |     #[name = "Content"] | ||||||
|  |     #[placeholder = "Message..."] | ||||||
|  |     #[paragraph] | ||||||
|  |     #[max_length = 2000] | ||||||
|  |     content: String, | ||||||
|  | } | ||||||
|  |  | ||||||
|  | /// Create a reminder with multi-line content. Press "+4 more" for other options. | ||||||
|  | #[poise::command( | ||||||
|  |     slash_command, | ||||||
|  |     identifying_name = "multiline", | ||||||
|  |     default_member_permissions = "MANAGE_GUILD" | ||||||
|  | )] | ||||||
|  | pub async fn multiline( | ||||||
|  |     ctx: ApplicationContext<'_>, | ||||||
|  |     #[description = "A description of the time to set the reminder for"] | ||||||
|  |     #[autocomplete = "time_hint_autocomplete"] | ||||||
|  |     time: String, | ||||||
|  |     #[description = "Channel or user mentions to set the reminder for"] channels: Option<String>, | ||||||
|  |     #[description = "(Patreon only) Time to wait before repeating the reminder. Leave blank for one-shot reminder"] | ||||||
|  |     interval: Option<String>, | ||||||
|  |     #[description = "(Patreon only) For repeating reminders, the time at which the reminder will stop repeating"] | ||||||
|  |     expires: Option<String>, | ||||||
|  |     #[description = "Set the TTS flag on the reminder message, similar to the /tts command"] | ||||||
|  |     tts: Option<bool>, | ||||||
|  |     #[description = "Set a timezone override for this reminder only"] | ||||||
|  |     #[autocomplete = "timezone_autocomplete"] | ||||||
|  |     timezone: Option<String>, | ||||||
|  | ) -> Result<(), Error> { | ||||||
|  |     let tz = timezone.map(|t| t.parse::<Tz>().ok()).flatten(); | ||||||
|  |     let data = ContentModal::execute(ctx).await?; | ||||||
|  |  | ||||||
|  |     create_reminder( | ||||||
|  |         Context::Application(ctx), | ||||||
|  |         time, | ||||||
|  |         data.content, | ||||||
|  |         channels, | ||||||
|  |         interval, | ||||||
|  |         expires, | ||||||
|  |         tts, | ||||||
|  |         tz, | ||||||
|  |     ) | ||||||
|  |     .await | ||||||
|  | } | ||||||
|  |  | ||||||
|  | /// 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", | ||||||
|     default_member_permissions = "MANAGE_GUILD" |     default_member_permissions = "MANAGE_GUILD" | ||||||
| )] | )] | ||||||
| pub async fn remind( | pub async fn remind( | ||||||
|     ctx: Context<'_>, |     ctx: ApplicationContext<'_>, | ||||||
|     #[description = "A description of the time to set the reminder for"] time: String, |     #[description = "A description of the time to set the reminder for"] | ||||||
|  |     #[autocomplete = "time_hint_autocomplete"] | ||||||
|  |     time: String, | ||||||
|     #[description = "The message content to send"] content: String, |     #[description = "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>, | ||||||
|     #[description = "(Patreon only) For repeating reminders, the time at which the reminder will stop sending"] |     #[description = "(Patreon only) For repeating reminders, the time at which the reminder will stop repeating"] | ||||||
|     expires: Option<String>, |     expires: Option<String>, | ||||||
|     #[description = "Set the TTS flag on the reminder message, similar to the /tts command"] |     #[description = "Set the TTS flag on the reminder message, similar to the /tts command"] | ||||||
|     tts: Option<bool>, |     tts: Option<bool>, | ||||||
|  |     #[description = "Set a timezone override for this reminder only"] | ||||||
|  |     #[autocomplete = "timezone_autocomplete"] | ||||||
|  |     timezone: Option<String>, | ||||||
|  | ) -> Result<(), Error> { | ||||||
|  |     let tz = timezone.map(|t| t.parse::<Tz>().ok()).flatten(); | ||||||
|  |  | ||||||
|  |     create_reminder(Context::Application(ctx), time, content, channels, interval, expires, tts, tz) | ||||||
|  |         .await | ||||||
|  | } | ||||||
|  |  | ||||||
|  | async fn create_reminder( | ||||||
|  |     ctx: Context<'_>, | ||||||
|  |     time: String, | ||||||
|  |     content: String, | ||||||
|  |     channels: Option<String>, | ||||||
|  |     interval: Option<String>, | ||||||
|  |     expires: Option<String>, | ||||||
|  |     tts: Option<bool>, | ||||||
|  |     timezone: Option<Tz>, | ||||||
| ) -> Result<(), Error> { | ) -> Result<(), Error> { | ||||||
|     if interval.is_none() && expires.is_some() { |     if interval.is_none() && expires.is_some() { | ||||||
|         ctx.say("`expires` can only be used with `interval`").await?; |         ctx.say("`expires` can only be used with `interval`").await?; | ||||||
| @@ -576,7 +648,7 @@ pub async fn remind( | |||||||
|     ctx.defer().await?; |     ctx.defer().await?; | ||||||
|  |  | ||||||
|     let user_data = ctx.author_data().await.unwrap(); |     let user_data = ctx.author_data().await.unwrap(); | ||||||
|     let timezone = ctx.timezone().await; |     let timezone = timezone.unwrap_or(ctx.timezone().await); | ||||||
|  |  | ||||||
|     let time = natural_parser(&time, &timezone.to_string()).await; |     let time = natural_parser(&time, &timezone.to_string()).await; | ||||||
|  |  | ||||||
| @@ -589,8 +661,7 @@ pub async fn remind( | |||||||
|             }; |             }; | ||||||
|  |  | ||||||
|             let scopes = { |             let scopes = { | ||||||
|                 let list = |                 let list = channels.map(|arg| parse_mention_list(&arg)).unwrap_or_default(); | ||||||
|                     channels.map(|arg| parse_mention_list(&arg.to_string())).unwrap_or_default(); |  | ||||||
|  |  | ||||||
|                 if list.is_empty() { |                 if list.is_empty() { | ||||||
|                     if ctx.guild_id().is_some() { |                     if ctx.guild_id().is_some() { | ||||||
| @@ -610,7 +681,7 @@ pub async fn remind( | |||||||
|                 { |                 { | ||||||
|                     ( |                     ( | ||||||
|                         parse_duration(repeat) |                         parse_duration(repeat) | ||||||
|                             .or_else(|_| parse_duration(&format!("1 {}", repeat.to_string()))) |                             .or_else(|_| parse_duration(&format!("1 {}", repeat))) | ||||||
|                             .ok(), |                             .ok(), | ||||||
|                         { |                         { | ||||||
|                             if let Some(arg) = &expires { |                             if let Some(arg) = &expires { | ||||||
| @@ -653,8 +724,39 @@ pub async fn remind( | |||||||
|  |  | ||||||
|                 let (errors, successes) = builder.build().await; |                 let (errors, successes) = builder.build().await; | ||||||
|  |  | ||||||
|                 let embed = create_response(successes, errors, time); |                 let embed = create_response(&successes, &errors, time); | ||||||
|  |  | ||||||
|  |                 if successes.len() == 1 { | ||||||
|  |                     let reminder = successes.iter().next().map(|(r, _)| r.id).unwrap(); | ||||||
|  |                     let undo_button = ComponentDataModel::UndoReminder(UndoReminder { | ||||||
|  |                         user_id: ctx.author().id, | ||||||
|  |                         reminder_id: reminder, | ||||||
|  |                     }); | ||||||
|  |  | ||||||
|  |                     ctx.send(|m| { | ||||||
|  |                         m.embed(|c| { | ||||||
|  |                             *c = embed; | ||||||
|  |                             c | ||||||
|  |                         }) | ||||||
|  |                         .components(|c| { | ||||||
|  |                             c.create_action_row(|r| { | ||||||
|  |                                 r.create_button(|b| { | ||||||
|  |                                     b.emoji(ReactionType::Unicode("🔕".to_string())) | ||||||
|  |                                         .label("Cancel") | ||||||
|  |                                         .style(ButtonStyle::Danger) | ||||||
|  |                                         .custom_id(undo_button.to_custom_id()) | ||||||
|  |                                 }) | ||||||
|  |                                 .create_button(|b| { | ||||||
|  |                                     b.emoji(ReactionType::Unicode("📝".to_string())) | ||||||
|  |                                         .label("Edit") | ||||||
|  |                                         .style(ButtonStyle::Link) | ||||||
|  |                                         .url("https://reminder-bot.com/dashboard") | ||||||
|  |                                 }) | ||||||
|  |                             }) | ||||||
|  |                         }) | ||||||
|  |                     }) | ||||||
|  |                     .await?; | ||||||
|  |                 } else { | ||||||
|                     ctx.send(|m| { |                     ctx.send(|m| { | ||||||
|                         m.embed(|c| { |                         m.embed(|c| { | ||||||
|                             *c = embed; |                             *c = embed; | ||||||
| @@ -664,6 +766,8 @@ pub async fn remind( | |||||||
|                     .await?; |                     .await?; | ||||||
|                 } |                 } | ||||||
|             } |             } | ||||||
|  |         } | ||||||
|  |  | ||||||
|         None => { |         None => { | ||||||
|             ctx.say("Time could not be processed").await?; |             ctx.say("Time could not be processed").await?; | ||||||
|         } |         } | ||||||
| @@ -673,8 +777,8 @@ pub async fn remind( | |||||||
| } | } | ||||||
|  |  | ||||||
| fn create_response( | fn create_response( | ||||||
|     successes: HashSet<ReminderScope>, |     successes: &HashSet<(Reminder, ReminderScope)>, | ||||||
|     errors: HashSet<ReminderError>, |     errors: &HashSet<ReminderError>, | ||||||
|     time: i64, |     time: i64, | ||||||
| ) -> CreateEmbed { | ) -> CreateEmbed { | ||||||
|     let success_part = match successes.len() { |     let success_part = match successes.len() { | ||||||
| @@ -682,7 +786,8 @@ fn create_response( | |||||||
|         n => format!( |         n => format!( | ||||||
|             "Reminder{s} for {locations} set for <t:{offset}:R>", |             "Reminder{s} for {locations} set for <t:{offset}:R>", | ||||||
|             s = if n > 1 { "s" } else { "" }, |             s = if n > 1 { "s" } else { "" }, | ||||||
|             locations = successes.iter().map(|l| l.mention()).collect::<Vec<String>>().join(", "), |             locations = | ||||||
|  |                 successes.iter().map(|(_, l)| l.mention()).collect::<Vec<String>>().join(", "), | ||||||
|             offset = time |             offset = time | ||||||
|         ), |         ), | ||||||
|     }; |     }; | ||||||
|   | |||||||
| @@ -6,6 +6,7 @@ use crate::{ | |||||||
|         ComponentDataModel, TodoSelector, |         ComponentDataModel, TodoSelector, | ||||||
|     }, |     }, | ||||||
|     consts::{EMBED_DESCRIPTION_MAX_LENGTH, SELECT_MAX_ENTRIES, THEME_COLOR}, |     consts::{EMBED_DESCRIPTION_MAX_LENGTH, SELECT_MAX_ENTRIES, THEME_COLOR}, | ||||||
|  |     models::CtxData, | ||||||
|     Context, Error, |     Context, Error, | ||||||
| }; | }; | ||||||
|  |  | ||||||
| @@ -116,6 +117,9 @@ pub async fn todo_channel_add( | |||||||
|     ctx: Context<'_>, |     ctx: Context<'_>, | ||||||
|     #[description = "The task to add to the todo list"] task: String, |     #[description = "The task to add to the todo list"] task: String, | ||||||
| ) -> Result<(), Error> { | ) -> Result<(), Error> { | ||||||
|  |     // ensure channel is cached | ||||||
|  |     let _ = ctx.channel_data().await; | ||||||
|  |  | ||||||
|     sqlx::query!( |     sqlx::query!( | ||||||
|         "INSERT INTO todos (guild_id, channel_id, value) |         "INSERT INTO todos (guild_id, channel_id, value) | ||||||
| VALUES ((SELECT id FROM guilds WHERE guild = ?), (SELECT id FROM channels WHERE channel = ?), ?)", | VALUES ((SELECT id FROM guilds WHERE guild = ?), (SELECT id FROM channels WHERE channel = ?), ?)", | ||||||
| @@ -336,7 +340,18 @@ pub fn show_todo_page( | |||||||
|                                 opt.create_option(|o| { |                                 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() | ||||||
|  |                                             } | ||||||
|  |                                         }) | ||||||
|                                 }); |                                 }); | ||||||
|                             } |                             } | ||||||
|  |  | ||||||
|   | |||||||
| @@ -3,13 +3,19 @@ pub(crate) mod pager; | |||||||
| use std::io::Cursor; | use std::io::Cursor; | ||||||
|  |  | ||||||
| use chrono_tz::Tz; | use chrono_tz::Tz; | ||||||
| use poise::serenity::{ | use log::warn; | ||||||
|  | use poise::{ | ||||||
|  |     serenity_prelude as serenity, | ||||||
|  |     serenity_prelude::{ | ||||||
|         builder::CreateEmbed, |         builder::CreateEmbed, | ||||||
|     client::Context, |  | ||||||
|         model::{ |         model::{ | ||||||
|  |             application::interaction::{ | ||||||
|  |                 message_component::MessageComponentInteraction, InteractionResponseType, | ||||||
|  |                 MessageFlags, | ||||||
|  |             }, | ||||||
|             channel::Channel, |             channel::Channel, | ||||||
|         interactions::{message_component::MessageComponentInteraction, InteractionResponseType}, |         }, | ||||||
|         prelude::InteractionApplicationCommandCallbackDataFlags, |         Context, | ||||||
|     }, |     }, | ||||||
| }; | }; | ||||||
| use rmp_serde::Serializer; | use rmp_serde::Serializer; | ||||||
| @@ -17,7 +23,7 @@ use serde::{Deserialize, Serialize}; | |||||||
|  |  | ||||||
| use crate::{ | use crate::{ | ||||||
|     commands::{ |     commands::{ | ||||||
|         moderation_cmds::{max_macro_page, show_macro_page}, |         command_macro::list::{max_macro_page, show_macro_page}, | ||||||
|         reminder_cmds::{max_delete_page, show_delete_page}, |         reminder_cmds::{max_delete_page, show_delete_page}, | ||||||
|         todo_cmds::{max_todo_page, show_todo_page}, |         todo_cmds::{max_todo_page, show_todo_page}, | ||||||
|     }, |     }, | ||||||
| @@ -38,6 +44,7 @@ pub enum ComponentDataModel { | |||||||
|     DelSelector(DelSelector), |     DelSelector(DelSelector), | ||||||
|     TodoSelector(TodoSelector), |     TodoSelector(TodoSelector), | ||||||
|     MacroPager(MacroPager), |     MacroPager(MacroPager), | ||||||
|  |     UndoReminder(UndoReminder), | ||||||
| } | } | ||||||
|  |  | ||||||
| impl ComponentDataModel { | impl ComponentDataModel { | ||||||
| @@ -106,7 +113,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 | ||||||
| @@ -253,7 +260,7 @@ WHERE guilds.guild = ?", | |||||||
|                             r.kind(InteractionResponseType::ChannelMessageWithSource) |                             r.kind(InteractionResponseType::ChannelMessageWithSource) | ||||||
|                                 .interaction_response_data(|d| { |                                 .interaction_response_data(|d| { | ||||||
|                                     d.flags( |                                     d.flags( | ||||||
|                                         InteractionApplicationCommandCallbackDataFlags::EPHEMERAL, |                                         MessageFlags::EPHEMERAL, | ||||||
|                                     ) |                                     ) | ||||||
|                                     .content("Only the user who performed the command can use these components") |                                     .content("Only the user who performed the command can use these components") | ||||||
|                                 }) |                                 }) | ||||||
| @@ -307,7 +314,7 @@ WHERE guilds.guild = ?", | |||||||
|                             r.kind(InteractionResponseType::ChannelMessageWithSource) |                             r.kind(InteractionResponseType::ChannelMessageWithSource) | ||||||
|                                 .interaction_response_data(|d| { |                                 .interaction_response_data(|d| { | ||||||
|                                     d.flags( |                                     d.flags( | ||||||
|                                         InteractionApplicationCommandCallbackDataFlags::EPHEMERAL, |                                         MessageFlags::EPHEMERAL, | ||||||
|                                     ) |                                     ) | ||||||
|                                     .content("Only the user who performed the command can use these components") |                                     .content("Only the user who performed the command can use these components") | ||||||
|                                 }) |                                 }) | ||||||
| @@ -334,6 +341,70 @@ WHERE guilds.guild = ?", | |||||||
|                     }) |                     }) | ||||||
|                     .await; |                     .await; | ||||||
|             } |             } | ||||||
|  |             ComponentDataModel::UndoReminder(undo_reminder) => { | ||||||
|  |                 if component.user.id == undo_reminder.user_id { | ||||||
|  |                     let reminder = | ||||||
|  |                         Reminder::from_id(&data.database, undo_reminder.reminder_id).await; | ||||||
|  |  | ||||||
|  |                     if let Some(reminder) = reminder { | ||||||
|  |                         match reminder.delete(&data.database).await { | ||||||
|  |                             Ok(()) => { | ||||||
|  |                                 let _ = component | ||||||
|  |                                     .create_interaction_response(&ctx, |f| { | ||||||
|  |                                         f.kind(InteractionResponseType::UpdateMessage) | ||||||
|  |                                             .interaction_response_data(|d| { | ||||||
|  |                                                 d.embed(|e| { | ||||||
|  |                                                     e.title("Reminder Canceled") | ||||||
|  |                                                         .description( | ||||||
|  |                                                             "This reminder has been canceled.", | ||||||
|  |                                                         ) | ||||||
|  |                                                         .color(*THEME_COLOR) | ||||||
|  |                                                 }) | ||||||
|  |                                                 .components(|c| c) | ||||||
|  |                                             }) | ||||||
|  |                                     }) | ||||||
|  |                                     .await; | ||||||
|  |                             } | ||||||
|  |                             Err(e) => { | ||||||
|  |                                 warn!("Error canceling reminder: {:?}", e); | ||||||
|  |  | ||||||
|  |                                 let _ = component | ||||||
|  |                                     .create_interaction_response(&ctx, |f| { | ||||||
|  |                                         f.kind(InteractionResponseType::ChannelMessageWithSource) | ||||||
|  |                                             .interaction_response_data(|d| { | ||||||
|  |                                                 d.content( | ||||||
|  |                                                     "The reminder could not be canceled: it may have already been deleted. Check `/del`!") | ||||||
|  |                                                     .ephemeral(true) | ||||||
|  |                                             }) | ||||||
|  |                                     }) | ||||||
|  |                                     .await; | ||||||
|  |                             } | ||||||
|  |                         } | ||||||
|  |                     } else { | ||||||
|  |                         let _ = component | ||||||
|  |                             .create_interaction_response(&ctx, |f| { | ||||||
|  |                                 f.kind(InteractionResponseType::ChannelMessageWithSource) | ||||||
|  |                                     .interaction_response_data(|d| { | ||||||
|  |                                         d.content( | ||||||
|  |                                             "The reminder could not be canceled: it may have already been deleted. Check `/del`!") | ||||||
|  |                                             .ephemeral(true) | ||||||
|  |                                     }) | ||||||
|  |                             }) | ||||||
|  |                             .await; | ||||||
|  |                     } | ||||||
|  |                 } else { | ||||||
|  |                     let _ = component | ||||||
|  |                         .create_interaction_response(&ctx, |f| { | ||||||
|  |                             f.kind(InteractionResponseType::ChannelMessageWithSource) | ||||||
|  |                                 .interaction_response_data(|d| { | ||||||
|  |                                     d.content( | ||||||
|  |                                         "Only the user who performed the command can use this button.") | ||||||
|  |                                         .ephemeral(true) | ||||||
|  |                                 }) | ||||||
|  |                         }) | ||||||
|  |                         .await; | ||||||
|  |                 } | ||||||
|  |             } | ||||||
|         } |         } | ||||||
|     } |     } | ||||||
| } | } | ||||||
| @@ -351,3 +422,9 @@ pub struct TodoSelector { | |||||||
|     pub channel_id: Option<u64>, |     pub channel_id: Option<u64>, | ||||||
|     pub guild_id: Option<u64>, |     pub guild_id: Option<u64>, | ||||||
| } | } | ||||||
|  |  | ||||||
|  | #[derive(Serialize, Deserialize)] | ||||||
|  | pub struct UndoReminder { | ||||||
|  |     pub user_id: serenity::UserId, | ||||||
|  |     pub reminder_id: u32, | ||||||
|  | } | ||||||
|   | |||||||
| @@ -1,7 +1,7 @@ | |||||||
| // todo split pager out into a single struct | // todo split pager out into a single struct | ||||||
| use chrono_tz::Tz; | use chrono_tz::Tz; | ||||||
| use poise::serenity::{ | use poise::serenity_prelude::{ | ||||||
|     builder::CreateComponents, model::interactions::message_component::ButtonStyle, |     builder::CreateComponents, model::application::component::ButtonStyle, | ||||||
| }; | }; | ||||||
| use serde::{Deserialize, Serialize}; | use serde::{Deserialize, Serialize}; | ||||||
| use serde_repr::*; | use serde_repr::*; | ||||||
|   | |||||||
| @@ -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_"; | ||||||
| @@ -12,7 +12,7 @@ pub const MACRO_MAX_COMMANDS: usize = 5; | |||||||
|  |  | ||||||
| use std::{collections::HashSet, env, iter::FromIterator}; | use std::{collections::HashSet, env, iter::FromIterator}; | ||||||
|  |  | ||||||
| use poise::serenity::model::prelude::AttachmentType; | use poise::serenity_prelude::model::prelude::AttachmentType; | ||||||
| use regex::Regex; | use regex::Regex; | ||||||
|  |  | ||||||
| lazy_static! { | lazy_static! { | ||||||
| @@ -27,7 +27,7 @@ lazy_static! { | |||||||
|         .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,16 +35,12 @@ 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 = env::var("MIN_INTERVAL") |     pub static ref MIN_INTERVAL: i64 = | ||||||
|         .ok() |         env::var("MIN_INTERVAL").ok().and_then(|inner| inner.parse::<i64>().ok()).unwrap_or(600); | ||||||
|         .map(|inner| inner.parse::<i64>().ok()) |  | ||||||
|         .flatten() |  | ||||||
|         .unwrap_or(600); |  | ||||||
|     pub static ref MAX_TIME: i64 = env::var("MAX_TIME") |     pub static ref MAX_TIME: i64 = env::var("MAX_TIME") | ||||||
|         .ok() |         .ok() | ||||||
|         .map(|inner| inner.parse::<i64>().ok()) |         .and_then(|inner| inner.parse::<i64>().ok()) | ||||||
|         .flatten() |  | ||||||
|         .unwrap_or(60 * 60 * 24 * 365 * 50); |         .unwrap_or(60 * 60 * 24 * 365 * 50); | ||||||
|     pub static ref LOCAL_TIMEZONE: String = |     pub static ref LOCAL_TIMEZONE: String = | ||||||
|         env::var("LOCAL_TIMEZONE").unwrap_or_else(|_| "UTC".to_string()); |         env::var("LOCAL_TIMEZONE").unwrap_or_else(|_| "UTC".to_string()); | ||||||
|   | |||||||
| @@ -1,12 +1,12 @@ | |||||||
| use std::{collections::HashMap, env, sync::atomic::Ordering}; | use std::{collections::HashMap, env}; | ||||||
|  |  | ||||||
| use log::{error, info, warn}; | use log::error; | ||||||
| use poise::{ | use poise::{ | ||||||
|     serenity::{model::interactions::Interaction, utils::shard_id}, |  | ||||||
|     serenity_prelude as serenity, |     serenity_prelude as serenity, | ||||||
|  |     serenity_prelude::{model::application::interaction::Interaction, utils::shard_id}, | ||||||
| }; | }; | ||||||
|  |  | ||||||
| use crate::{component_models::ComponentDataModel, Data, Error}; | use crate::{component_models::ComponentDataModel, Data, Error, THEME_COLOR}; | ||||||
|  |  | ||||||
| pub async fn listener( | pub async fn listener( | ||||||
|     ctx: &serenity::Context, |     ctx: &serenity::Context, | ||||||
| @@ -14,44 +14,8 @@ pub async fn listener( | |||||||
|     data: &Data, |     data: &Data, | ||||||
| ) -> Result<(), Error> { | ) -> Result<(), Error> { | ||||||
|     match event { |     match event { | ||||||
|         poise::Event::CacheReady { .. } => { |         poise::Event::Ready { .. } => { | ||||||
|             info!("Cache Ready! Preparing extra processes"); |             ctx.set_activity(serenity::Activity::watching("for /remind")).await; | ||||||
|  |  | ||||||
|             if !data.is_loop_running.load(Ordering::Relaxed) { |  | ||||||
|                 let kill_tx = data.broadcast.clone(); |  | ||||||
|                 let kill_recv = data.broadcast.subscribe(); |  | ||||||
|  |  | ||||||
|                 let ctx1 = ctx.clone(); |  | ||||||
|                 let ctx2 = ctx.clone(); |  | ||||||
|  |  | ||||||
|                 let pool1 = data.database.clone(); |  | ||||||
|                 let pool2 = data.database.clone(); |  | ||||||
|  |  | ||||||
|                 let run_settings = env::var("DONTRUN").unwrap_or_else(|_| "".to_string()); |  | ||||||
|  |  | ||||||
|                 if !run_settings.contains("postman") { |  | ||||||
|                     tokio::spawn(async move { |  | ||||||
|                         match postman::initialize(kill_recv, ctx1, &pool1).await { |  | ||||||
|                             Ok(_) => {} |  | ||||||
|                             Err(e) => { |  | ||||||
|                                 error!("postman exiting: {}", e); |  | ||||||
|                             } |  | ||||||
|                         }; |  | ||||||
|                     }); |  | ||||||
|                 } else { |  | ||||||
|                     warn!("Not running postman") |  | ||||||
|                 } |  | ||||||
|  |  | ||||||
|                 if !run_settings.contains("web") { |  | ||||||
|                     tokio::spawn(async move { |  | ||||||
|                         reminder_web::initialize(kill_tx, ctx2, pool2).await.unwrap(); |  | ||||||
|                     }); |  | ||||||
|                 } else { |  | ||||||
|                     warn!("Not running web") |  | ||||||
|                 } |  | ||||||
|  |  | ||||||
|                 data.is_loop_running.swap(true, Ordering::Relaxed); |  | ||||||
|             } |  | ||||||
|         } |         } | ||||||
|         poise::Event::ChannelDelete { channel } => { |         poise::Event::ChannelDelete { channel } => { | ||||||
|             sqlx::query!("DELETE FROM channels WHERE channel = ?", channel.id.as_u64()) |             sqlx::query!("DELETE FROM channels WHERE channel = ?", channel.id.as_u64()) | ||||||
| @@ -63,46 +27,36 @@ pub async fn listener( | |||||||
|             if *is_new { |             if *is_new { | ||||||
|                 let guild_id = guild.id.as_u64().to_owned(); |                 let guild_id = guild.id.as_u64().to_owned(); | ||||||
|  |  | ||||||
|                 sqlx::query!("INSERT INTO guilds (guild) VALUES (?)", guild_id) |                 sqlx::query!("INSERT IGNORE INTO guilds (guild) VALUES (?)", guild_id) | ||||||
|                     .execute(&data.database) |                     .execute(&data.database) | ||||||
|                     .await |                     .await?; | ||||||
|                     .unwrap(); |  | ||||||
|  |  | ||||||
|                 if let Ok(token) = env::var("DISCORDBOTS_TOKEN") { |                 if let Err(e) = post_guild_count(ctx, &data.http, guild_id).await { | ||||||
|                     let shard_count = ctx.cache.shard_count(); |                     error!("DiscordBotList: {:?}", e); | ||||||
|                     let current_shard_id = shard_id(guild_id, shard_count); |  | ||||||
|  |  | ||||||
|                     let guild_count = ctx |  | ||||||
|                         .cache |  | ||||||
|                         .guilds() |  | ||||||
|                         .iter() |  | ||||||
|                         .filter(|g| { |  | ||||||
|                             shard_id(g.as_u64().to_owned(), shard_count) == current_shard_id |  | ||||||
|                         }) |  | ||||||
|                         .count() as u64; |  | ||||||
|  |  | ||||||
|                     let mut hm = HashMap::new(); |  | ||||||
|                     hm.insert("server_count", guild_count); |  | ||||||
|                     hm.insert("shard_id", current_shard_id); |  | ||||||
|                     hm.insert("shard_count", shard_count); |  | ||||||
|  |  | ||||||
|                     let response = data |  | ||||||
|                         .http |  | ||||||
|                         .post( |  | ||||||
|                             format!( |  | ||||||
|                                 "https://top.gg/api/bots/{}/stats", |  | ||||||
|                                 ctx.cache.current_user_id().as_u64() |  | ||||||
|                             ) |  | ||||||
|                             .as_str(), |  | ||||||
|                         ) |  | ||||||
|                         .header("Authorization", token) |  | ||||||
|                         .json(&hm) |  | ||||||
|                         .send() |  | ||||||
|                         .await; |  | ||||||
|  |  | ||||||
|                     if let Err(res) = response { |  | ||||||
|                         println!("DiscordBots Response: {:?}", res); |  | ||||||
|                 } |                 } | ||||||
|  |  | ||||||
|  |                 let default_channel = guild.default_channel_guaranteed(); | ||||||
|  |  | ||||||
|  |                 if let Some(default_channel) = default_channel { | ||||||
|  |                     default_channel | ||||||
|  |                         .send_message(&ctx, |m| { | ||||||
|  |                             m.embed(|e| { | ||||||
|  |                                 e.title("Thank you for adding Reminder Bot!").description( | ||||||
|  |                                     "To get started: | ||||||
|  | • Set your timezone with `/timezone` | ||||||
|  | • Set up permissions in Server Settings 🠚 Integrations 🠚 Reminder Bot (desktop only) | ||||||
|  | • Create your first reminder with `/remind` | ||||||
|  |  | ||||||
|  | __Support__ | ||||||
|  | If you need any support, please come and ask us! Join our [Discord](https://discord.jellywx.com). | ||||||
|  |  | ||||||
|  | __Updates__ | ||||||
|  | To stay up to date on the latest features and fixes, join our [Discord](https://discord.jellywx.com). | ||||||
|  | ", | ||||||
|  |                                 ).color(*THEME_COLOR) | ||||||
|  |                             }) | ||||||
|  |                         }) | ||||||
|  |                         .await?; | ||||||
|                 } |                 } | ||||||
|             } |             } | ||||||
|         } |         } | ||||||
| @@ -111,16 +65,50 @@ pub async fn listener( | |||||||
|                 .execute(&data.database) |                 .execute(&data.database) | ||||||
|                 .await; |                 .await; | ||||||
|         } |         } | ||||||
|         poise::Event::InteractionCreate { interaction } => match interaction { |         poise::Event::InteractionCreate { interaction } => { | ||||||
|             Interaction::MessageComponent(component) => { |             if let Interaction::MessageComponent(component) = interaction { | ||||||
|                 let component_model = ComponentDataModel::from_custom_id(&component.data.custom_id); |                 let component_model = ComponentDataModel::from_custom_id(&component.data.custom_id); | ||||||
|  |  | ||||||
|                 component_model.act(ctx, data, component).await; |                 component_model.act(ctx, data, component).await; | ||||||
|             } |             } | ||||||
|             _ => {} |         } | ||||||
|         }, |  | ||||||
|         _ => {} |         _ => {} | ||||||
|     } |     } | ||||||
|  |  | ||||||
|     Ok(()) |     Ok(()) | ||||||
| } | } | ||||||
|  |  | ||||||
|  | async fn post_guild_count( | ||||||
|  |     ctx: &serenity::Context, | ||||||
|  |     http: &reqwest::Client, | ||||||
|  |     guild_id: u64, | ||||||
|  | ) -> Result<(), reqwest::Error> { | ||||||
|  |     if let Ok(token) = env::var("DISCORDBOTS_TOKEN") { | ||||||
|  |         let shard_count = ctx.cache.shard_count(); | ||||||
|  |         let current_shard_id = shard_id(guild_id, shard_count); | ||||||
|  |  | ||||||
|  |         let guild_count = ctx | ||||||
|  |             .cache | ||||||
|  |             .guilds() | ||||||
|  |             .iter() | ||||||
|  |             .filter(|g| shard_id(g.as_u64().to_owned(), shard_count) == current_shard_id) | ||||||
|  |             .count() as u64; | ||||||
|  |  | ||||||
|  |         let mut hm = HashMap::new(); | ||||||
|  |         hm.insert("server_count", guild_count); | ||||||
|  |         hm.insert("shard_id", current_shard_id); | ||||||
|  |         hm.insert("shard_count", shard_count); | ||||||
|  |  | ||||||
|  |         http.post( | ||||||
|  |             format!("https://top.gg/api/bots/{}/stats", ctx.cache.current_user_id().as_u64()) | ||||||
|  |                 .as_str(), | ||||||
|  |         ) | ||||||
|  |         .header("Authorization", token) | ||||||
|  |         .json(&hm) | ||||||
|  |         .send() | ||||||
|  |         .await | ||||||
|  |         .map(|_| ()) | ||||||
|  |     } else { | ||||||
|  |         Ok(()) | ||||||
|  |     } | ||||||
|  | } | ||||||
|   | |||||||
							
								
								
									
										23
									
								
								src/hooks.rs
									
									
									
									
									
								
							
							
						
						| @@ -1,9 +1,14 @@ | |||||||
| use poise::serenity::model::channel::Channel; | use poise::{ | ||||||
|  |     serenity_prelude::model::channel::Channel, ApplicationCommandOrAutocompleteInteraction, | ||||||
|  | }; | ||||||
|  |  | ||||||
| use crate::{consts::MACRO_MAX_COMMANDS, models::command_macro::RecordedCommand, Context, Error}; | 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; | ||||||
| @@ -30,19 +35,14 @@ async fn macro_check(ctx: Context<'_>) -> bool { | |||||||
|                                 .await; |                                 .await; | ||||||
|                         } |                         } | ||||||
|  |  | ||||||
|                     false |                         return false; | ||||||
|                 } else { |  | ||||||
|                     true |  | ||||||
|                     } |                     } | ||||||
|             } else { |  | ||||||
|                 true |  | ||||||
|                 } |                 } | ||||||
|         } else { |  | ||||||
|             true |  | ||||||
|             } |             } | ||||||
|     } else { |  | ||||||
|         true |  | ||||||
|         } |         } | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     true | ||||||
| } | } | ||||||
|  |  | ||||||
| async fn check_self_permissions(ctx: Context<'_>) -> bool { | async fn check_self_permissions(ctx: Context<'_>) -> bool { | ||||||
| @@ -56,14 +56,13 @@ async fn check_self_permissions(ctx: Context<'_>) -> bool { | |||||||
|         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_cached(&ctx.discord()) | ||||||
|             .map(|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() |                     channel.permissions_for_user(&ctx.discord(), user_id).ok() | ||||||
|                 } else { |                 } else { | ||||||
|                     None |                     None | ||||||
|                 } |                 } | ||||||
|             }) |             }) | ||||||
|             .flatten() |  | ||||||
|             .map_or((false, false, false), |p| { |             .map_or((false, false, false), |p| { | ||||||
|                 (p.view_channel(), p.send_messages(), p.embed_links()) |                 (p.view_channel(), p.send_messages(), p.embed_links()) | ||||||
|             }); |             }); | ||||||
|   | |||||||
| @@ -75,7 +75,7 @@ impl fmt::Display for Error { | |||||||
|         match self { |         match self { | ||||||
|             Error::InvalidCharacter(offset) => write!(f, "invalid character at {}", offset), |             Error::InvalidCharacter(offset) => write!(f, "invalid character at {}", offset), | ||||||
|             Error::NumberExpected(offset) => write!(f, "expected number at {}", offset), |             Error::NumberExpected(offset) => write!(f, "expected number at {}", offset), | ||||||
|             Error::UnknownUnit { unit, value, .. } if &unit == &"" => { |             Error::UnknownUnit { unit, value, .. } if unit.is_empty() => { | ||||||
|                 write!(f, "time unit needed, for example {0}sec or {0}ms", value,) |                 write!(f, "time unit needed, for example {0}sec or {0}ms", value,) | ||||||
|             } |             } | ||||||
|             Error::UnknownUnit { unit, .. } => { |             Error::UnknownUnit { unit, .. } => { | ||||||
| @@ -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" | "M" => (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 = 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; |         sec += self.current.2; | ||||||
|         month = self.current.0 + month; |         day += self.current.1; | ||||||
|  |         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,73 @@ 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.chars(), src: s, current: (0, 0, 0, 0) }.parse() | ||||||
|  | } | ||||||
|  |  | ||||||
|  | #[cfg(test)] | ||||||
|  | mod tests { | ||||||
|  |     use super::*; | ||||||
|  |  | ||||||
|  |     #[test] | ||||||
|  |     fn parse_seconds() { | ||||||
|  |         let interval = parse_duration("10 seconds").unwrap(); | ||||||
|  |  | ||||||
|  |         assert_eq!(interval.sec, 10); | ||||||
|  |         assert_eq!(interval.day, 0); | ||||||
|  |         assert_eq!(interval.month, 0); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     #[test] | ||||||
|  |     fn parse_minutes() { | ||||||
|  |         let interval = parse_duration("10 minutes").unwrap(); | ||||||
|  |  | ||||||
|  |         assert_eq!(interval.sec, 600); | ||||||
|  |         assert_eq!(interval.day, 0); | ||||||
|  |         assert_eq!(interval.month, 0); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     #[test] | ||||||
|  |     fn parse_hours() { | ||||||
|  |         let interval = parse_duration("10 hours").unwrap(); | ||||||
|  |  | ||||||
|  |         assert_eq!(interval.sec, 36_000); | ||||||
|  |         assert_eq!(interval.day, 0); | ||||||
|  |         assert_eq!(interval.month, 0); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     #[test] | ||||||
|  |     fn parse_days() { | ||||||
|  |         let interval = parse_duration("10 days").unwrap(); | ||||||
|  |  | ||||||
|  |         assert_eq!(interval.sec, 0); | ||||||
|  |         assert_eq!(interval.day, 10); | ||||||
|  |         assert_eq!(interval.month, 0); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     #[test] | ||||||
|  |     fn parse_weeks() { | ||||||
|  |         let interval = parse_duration("10 weeks").unwrap(); | ||||||
|  |  | ||||||
|  |         assert_eq!(interval.sec, 0); | ||||||
|  |         assert_eq!(interval.day, 70); | ||||||
|  |         assert_eq!(interval.month, 0); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     #[test] | ||||||
|  |     fn parse_months() { | ||||||
|  |         let interval = parse_duration("10 months").unwrap(); | ||||||
|  |  | ||||||
|  |         assert_eq!(interval.sec, 0); | ||||||
|  |         assert_eq!(interval.day, 0); | ||||||
|  |         assert_eq!(interval.month, 10); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     #[test] | ||||||
|  |     fn parse_years() { | ||||||
|  |         let interval = parse_duration("10 years").unwrap(); | ||||||
|  |  | ||||||
|  |         assert_eq!(interval.sec, 0); | ||||||
|  |         assert_eq!(interval.day, 0); | ||||||
|  |         assert_eq!(interval.month, 120); | ||||||
|  |     } | ||||||
| } | } | ||||||
|   | |||||||
							
								
								
									
										101
									
								
								src/main.rs
									
									
									
									
									
								
							
							
						
						| @@ -1,4 +1,5 @@ | |||||||
| #![feature(int_roundings)] | #![feature(int_roundings)] | ||||||
|  |  | ||||||
| #[macro_use] | #[macro_use] | ||||||
| extern crate lazy_static; | extern crate lazy_static; | ||||||
|  |  | ||||||
| @@ -17,20 +18,20 @@ use std::{ | |||||||
|     env, |     env, | ||||||
|     error::Error as StdError, |     error::Error as StdError, | ||||||
|     fmt::{Debug, Display, Formatter}, |     fmt::{Debug, Display, Formatter}, | ||||||
|     sync::atomic::AtomicBool, |     path::Path, | ||||||
| }; | }; | ||||||
|  |  | ||||||
| use chrono_tz::Tz; | use chrono_tz::Tz; | ||||||
| use dotenv::dotenv; | use log::{error, warn}; | ||||||
| use poise::serenity::model::{ | use poise::serenity_prelude::model::{ | ||||||
|     gateway::{Activity, GatewayIntents}, |     gateway::GatewayIntents, | ||||||
|     id::{GuildId, UserId}, |     id::{GuildId, UserId}, | ||||||
| }; | }; | ||||||
| use sqlx::{MySql, Pool}; | use sqlx::{MySql, Pool}; | ||||||
| use tokio::sync::{broadcast, broadcast::Sender, RwLock}; | use tokio::sync::{broadcast, broadcast::Sender, RwLock}; | ||||||
|  |  | ||||||
| use crate::{ | use crate::{ | ||||||
|     commands::{info_cmds, moderation_cmds, reminder_cmds, todo_cmds}, |     commands::{command_macro, info_cmds, moderation_cmds, reminder_cmds, todo_cmds}, | ||||||
|     consts::THEME_COLOR, |     consts::THEME_COLOR, | ||||||
|     event_handlers::listener, |     event_handlers::listener, | ||||||
|     hooks::all_checks, |     hooks::all_checks, | ||||||
| @@ -42,17 +43,17 @@ type Database = MySql; | |||||||
|  |  | ||||||
| type Error = Box<dyn std::error::Error + Send + Sync>; | type Error = Box<dyn std::error::Error + Send + Sync>; | ||||||
| type Context<'a> = poise::Context<'a, Data, Error>; | type Context<'a> = poise::Context<'a, Data, Error>; | ||||||
|  | type ApplicationContext<'a> = poise::ApplicationContext<'a, Data, Error>; | ||||||
|  |  | ||||||
| pub struct Data { | pub struct Data { | ||||||
|     database: Pool<Database>, |     database: Pool<Database>, | ||||||
|     http: reqwest::Client, |     http: reqwest::Client, | ||||||
|     recording_macros: RwLock<HashMap<(GuildId, UserId), CommandMacro<Data, Error>>>, |     recording_macros: RwLock<HashMap<(GuildId, UserId), CommandMacro<Data, Error>>>, | ||||||
|     popular_timezones: Vec<Tz>, |     popular_timezones: Vec<Tz>, | ||||||
|     is_loop_running: AtomicBool, |     _broadcast: Sender<()>, | ||||||
|     broadcast: Sender<()>, |  | ||||||
| } | } | ||||||
|  |  | ||||||
| impl std::fmt::Debug for Data { | impl Debug for Data { | ||||||
|     fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { |     fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { | ||||||
|         write!(f, "Data {{ .. }}") |         write!(f, "Data {{ .. }}") | ||||||
|     } |     } | ||||||
| @@ -74,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); | ||||||
|  |  | ||||||
| @@ -87,7 +88,9 @@ 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")?; | ||||||
|  |     } | ||||||
|  |  | ||||||
|     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"); | ||||||
|  |  | ||||||
| @@ -102,13 +105,22 @@ async fn _main(tx: Sender<()>) -> Result<(), Box<dyn StdError + Send + Sync>> { | |||||||
|             moderation_cmds::timezone(), |             moderation_cmds::timezone(), | ||||||
|             poise::Command { |             poise::Command { | ||||||
|                 subcommands: vec![ |                 subcommands: vec![ | ||||||
|                     moderation_cmds::delete_macro(), |                     moderation_cmds::set_allowed_dm(), | ||||||
|                     moderation_cmds::finish_macro(), |                     moderation_cmds::unset_allowed_dm(), | ||||||
|                     moderation_cmds::list_macro(), |  | ||||||
|                     moderation_cmds::record_macro(), |  | ||||||
|                     moderation_cmds::run_macro(), |  | ||||||
|                 ], |                 ], | ||||||
|                 ..moderation_cmds::macro_base() |                 ..moderation_cmds::allowed_dm() | ||||||
|  |             }, | ||||||
|  |             moderation_cmds::webhook(), | ||||||
|  |             poise::Command { | ||||||
|  |                 subcommands: vec![ | ||||||
|  |                     command_macro::delete::delete_macro(), | ||||||
|  |                     command_macro::record::finish_macro(), | ||||||
|  |                     command_macro::list::list_macro(), | ||||||
|  |                     command_macro::record::record_macro(), | ||||||
|  |                     command_macro::run::run_macro(), | ||||||
|  |                     command_macro::migrate::migrate_macro(), | ||||||
|  |                 ], | ||||||
|  |                 ..command_macro::macro_base() | ||||||
|             }, |             }, | ||||||
|             reminder_cmds::pause(), |             reminder_cmds::pause(), | ||||||
|             reminder_cmds::offset(), |             reminder_cmds::offset(), | ||||||
| @@ -123,6 +135,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![ | ||||||
| @@ -157,8 +170,15 @@ async fn _main(tx: Sender<()>) -> Result<(), Box<dyn StdError + Send + Sync>> { | |||||||
|     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 | ||||||
| @@ -167,29 +187,50 @@ async fn _main(tx: Sender<()>) -> Result<(), Box<dyn StdError + Send + Sync>> { | |||||||
|     .map(|t| t.timezone.parse::<Tz>().unwrap()) |     .map(|t| t.timezone.parse::<Tz>().unwrap()) | ||||||
|     .collect::<Vec<Tz>>(); |     .collect::<Vec<Tz>>(); | ||||||
|  |  | ||||||
|     poise::Framework::build() |     poise::Framework::builder() | ||||||
|         .token(discord_token) |         .token(discord_token) | ||||||
|         .user_data_setup(move |ctx, _bot, framework| { |         .user_data_setup(move |ctx, _bot, framework| { | ||||||
|             Box::pin(async move { |             Box::pin(async move { | ||||||
|                 ctx.set_activity(Activity::watching("for /remind")).await; |                 register_application_commands(ctx, framework, None).await.unwrap(); | ||||||
|  |  | ||||||
|                 register_application_commands( |                 let kill_tx = tx.clone(); | ||||||
|                     ctx, |                 let kill_recv = tx.subscribe(); | ||||||
|                     framework, |  | ||||||
|                     env::var("DEBUG_GUILD") |                 let ctx1 = ctx.clone(); | ||||||
|                         .map(|inner| GuildId(inner.parse().expect("DEBUG_GUILD not valid"))) |                 let ctx2 = ctx.clone(); | ||||||
|                         .ok(), |  | ||||||
|                 ) |                 let pool1 = database.clone(); | ||||||
|                 .await |                 let pool2 = database.clone(); | ||||||
|                 .unwrap(); |  | ||||||
|  |                 let run_settings = env::var("DONTRUN").unwrap_or_else(|_| "".to_string()); | ||||||
|  |  | ||||||
|  |                 if !run_settings.contains("postman") { | ||||||
|  |                     tokio::spawn(async move { | ||||||
|  |                         match postman::initialize(kill_recv, ctx1, &pool1).await { | ||||||
|  |                             Ok(_) => {} | ||||||
|  |                             Err(e) => { | ||||||
|  |                                 error!("postman exiting: {}", e); | ||||||
|  |                             } | ||||||
|  |                         }; | ||||||
|  |                     }); | ||||||
|  |                 } else { | ||||||
|  |                     warn!("Not running postman"); | ||||||
|  |                 } | ||||||
|  |  | ||||||
|  |                 if !run_settings.contains("web") { | ||||||
|  |                     tokio::spawn(async move { | ||||||
|  |                         reminder_web::initialize(kill_tx, ctx2, pool2).await.unwrap(); | ||||||
|  |                     }); | ||||||
|  |                 } else { | ||||||
|  |                     warn!("Not running web"); | ||||||
|  |                 } | ||||||
|  |  | ||||||
|                 Ok(Data { |                 Ok(Data { | ||||||
|                     http: reqwest::Client::new(), |                     http: reqwest::Client::new(), | ||||||
|                     database, |                     database, | ||||||
|                     popular_timezones, |                     popular_timezones, | ||||||
|                     recording_macros: Default::default(), |                     recording_macros: Default::default(), | ||||||
|                     is_loop_running: AtomicBool::new(false), |                     _broadcast: tx, | ||||||
|                     broadcast: tx, |  | ||||||
|                 }) |                 }) | ||||||
|             }) |             }) | ||||||
|         }) |         }) | ||||||
|   | |||||||
| @@ -1,5 +1,5 @@ | |||||||
| use chrono::NaiveDateTime; | use chrono::NaiveDateTime; | ||||||
| use poise::serenity::model::channel::Channel; | use poise::serenity_prelude::model::channel::Channel; | ||||||
| use sqlx::MySqlPool; | use sqlx::MySqlPool; | ||||||
|  |  | ||||||
| pub struct ChannelData { | pub struct ChannelData { | ||||||
|   | |||||||
| @@ -1,15 +1,16 @@ | |||||||
| use poise::serenity::model::{ | use poise::serenity_prelude::model::{ | ||||||
|     id::GuildId, interactions::application_command::ApplicationCommandInteractionDataOption, |     application::interaction::application_command::CommandDataOption, id::GuildId, | ||||||
| }; | }; | ||||||
| use serde::{Deserialize, Serialize}; | use serde::{Deserialize, Serialize}; | ||||||
|  | use serde_json::Value; | ||||||
|  |  | ||||||
| use crate::{Context, Data, Error}; | use crate::{Context, Data, Error}; | ||||||
|  |  | ||||||
| fn default_none<U, E>() -> Option< | type Func<U, E> = for<'a> fn( | ||||||
|     for<'a> fn( |  | ||||||
|     poise::ApplicationContext<'a, U, E>, |     poise::ApplicationContext<'a, U, E>, | ||||||
|     ) -> poise::BoxFuture<'a, Result<(), poise::FrameworkError<'a, U, E>>>, | ) -> poise::BoxFuture<'a, Result<(), poise::FrameworkError<'a, U, E>>>; | ||||||
| > { |  | ||||||
|  | fn default_none<U, E>() -> Option<Func<U, E>> { | ||||||
|     None |     None | ||||||
| } | } | ||||||
|  |  | ||||||
| @@ -17,13 +18,9 @@ fn default_none<U, E>() -> Option< | |||||||
| pub struct RecordedCommand<U, E> { | pub struct RecordedCommand<U, E> { | ||||||
|     #[serde(skip)] |     #[serde(skip)] | ||||||
|     #[serde(default = "default_none::<U, E>")] |     #[serde(default = "default_none::<U, E>")] | ||||||
|     pub action: Option< |     pub action: Option<Func<U, E>>, | ||||||
|         for<'a> fn( |  | ||||||
|             poise::ApplicationContext<'a, U, E>, |  | ||||||
|         ) -> poise::BoxFuture<'a, Result<(), poise::FrameworkError<'a, U, E>>>, |  | ||||||
|     >, |  | ||||||
|     pub command_name: String, |     pub command_name: String, | ||||||
|     pub options: Vec<ApplicationCommandInteractionDataOption>, |     pub options: Vec<CommandDataOption>, | ||||||
| } | } | ||||||
|  |  | ||||||
| pub struct CommandMacro<U, E> { | pub struct CommandMacro<U, E> { | ||||||
| @@ -33,6 +30,13 @@ pub struct CommandMacro<U, E> { | |||||||
|     pub commands: Vec<RecordedCommand<U, E>>, |     pub commands: Vec<RecordedCommand<U, E>>, | ||||||
| } | } | ||||||
|  |  | ||||||
|  | pub struct RawCommandMacro { | ||||||
|  |     pub guild_id: GuildId, | ||||||
|  |     pub name: String, | ||||||
|  |     pub description: Option<String>, | ||||||
|  |     pub commands: Value, | ||||||
|  | } | ||||||
|  |  | ||||||
| pub async fn guild_command_macro( | pub async fn guild_command_macro( | ||||||
|     ctx: &Context<'_>, |     ctx: &Context<'_>, | ||||||
|     name: &str, |     name: &str, | ||||||
| @@ -59,7 +63,7 @@ SELECT * FROM macro WHERE guild_id = (SELECT id FROM guilds WHERE guild = ?) AND | |||||||
|             .iter() |             .iter() | ||||||
|             .find(|c| c.identifying_name == recorded_command.command_name); |             .find(|c| c.identifying_name == recorded_command.command_name); | ||||||
|  |  | ||||||
|         recorded_command.action = command.map(|c| c.slash_action).flatten().clone(); |         recorded_command.action = command.map(|c| c.slash_action).flatten(); | ||||||
|     } |     } | ||||||
|  |  | ||||||
|     let command_macro = CommandMacro { |     let command_macro = CommandMacro { | ||||||
|   | |||||||
| @@ -5,7 +5,7 @@ pub mod timer; | |||||||
| pub mod user_data; | pub mod user_data; | ||||||
|  |  | ||||||
| use chrono_tz::Tz; | use chrono_tz::Tz; | ||||||
| use poise::serenity::{async_trait, model::id::UserId}; | use poise::serenity_prelude::{async_trait, model::id::UserId}; | ||||||
|  |  | ||||||
| use crate::{ | use crate::{ | ||||||
|     models::{channel_data::ChannelData, user_data::UserData}, |     models::{channel_data::ChannelData, user_data::UserData}, | ||||||
|   | |||||||
| @@ -2,7 +2,7 @@ use std::{collections::HashSet, fmt::Display}; | |||||||
|  |  | ||||||
| use chrono::{Duration, NaiveDateTime, Utc}; | use chrono::{Duration, NaiveDateTime, Utc}; | ||||||
| use chrono_tz::Tz; | use chrono_tz::Tz; | ||||||
| use poise::serenity::{ | use poise::serenity_prelude::{ | ||||||
|     http::CacheHttp, |     http::CacheHttp, | ||||||
|     model::{ |     model::{ | ||||||
|         channel::GuildChannel, |         channel::GuildChannel, | ||||||
| @@ -53,7 +53,8 @@ pub struct ReminderBuilder { | |||||||
|     channel: u32, |     channel: u32, | ||||||
|     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 +88,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 +108,7 @@ INSERT INTO reminders ( | |||||||
|     ?, |     ?, | ||||||
|     ?, |     ?, | ||||||
|     ?, |     ?, | ||||||
|  |     ?, | ||||||
|     ? |     ? | ||||||
| ) | ) | ||||||
|             ", |             ", | ||||||
| @@ -113,7 +116,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, | ||||||
| @@ -126,7 +130,7 @@ INSERT INTO reminders ( | |||||||
|                     .await |                     .await | ||||||
|                     .unwrap(); |                     .unwrap(); | ||||||
|  |  | ||||||
|                     Ok(Reminder::from_uid(&self.pool, self.uid).await.unwrap()) |                     Ok(Reminder::from_uid(&self.pool, &self.uid).await.unwrap()) | ||||||
|                 } |                 } | ||||||
|             } |             } | ||||||
|  |  | ||||||
| @@ -175,17 +179,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 | ||||||
|     } |     } | ||||||
| @@ -207,14 +209,19 @@ impl<'a> MultiReminderBuilder<'a> { | |||||||
|         self.scopes = scopes; |         self.scopes = scopes; | ||||||
|     } |     } | ||||||
|  |  | ||||||
|     pub async fn build(self) -> (HashSet<ReminderError>, HashSet<ReminderScope>) { |     pub async fn build(self) -> (HashSet<ReminderError>, HashSet<(Reminder, ReminderScope)>) { | ||||||
|         let mut errors = HashSet::new(); |         let mut errors = HashSet::new(); | ||||||
|  |  | ||||||
|         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 { | ||||||
| @@ -233,6 +240,10 @@ impl<'a> MultiReminderBuilder<'a> { | |||||||
|                             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.discord(), user).await.is_err() { | ||||||
|                                     Err(ReminderError::InvalidTag) |                                     Err(ReminderError::InvalidTag) | ||||||
|  |                                 } else if self.set_by.map_or(true, |i| i != user_data.id) | ||||||
|  |                                     && !user_data.allowed_dm | ||||||
|  |                                 { | ||||||
|  |                                     Err(ReminderError::UserBlockedDm) | ||||||
|                                 } else { |                                 } else { | ||||||
|                                     Ok(user_data.dm_channel) |                                     Ok(user_data.dm_channel) | ||||||
|                                 } |                                 } | ||||||
| @@ -298,7 +309,8 @@ impl<'a> MultiReminderBuilder<'a> { | |||||||
|                             channel: c, |                             channel: c, | ||||||
|                             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(), | ||||||
| @@ -309,8 +321,8 @@ impl<'a> MultiReminderBuilder<'a> { | |||||||
|                         }; |                         }; | ||||||
|  |  | ||||||
|                         match builder.build().await { |                         match builder.build().await { | ||||||
|                             Ok(_) => { |                             Ok(r) => { | ||||||
|                                 ok_locs.insert(scope); |                                 ok_locs.insert((r, scope)); | ||||||
|                             } |                             } | ||||||
|                             Err(e) => { |                             Err(e) => { | ||||||
|                                 errors.insert(e); |                                 errors.insert(e); | ||||||
|   | |||||||
| @@ -7,6 +7,7 @@ pub enum ReminderError { | |||||||
|     PastTime, |     PastTime, | ||||||
|     ShortInterval, |     ShortInterval, | ||||||
|     InvalidTag, |     InvalidTag, | ||||||
|  |     UserBlockedDm, | ||||||
|     DiscordError(String), |     DiscordError(String), | ||||||
| } | } | ||||||
|  |  | ||||||
| @@ -30,6 +31,9 @@ impl ToString for ReminderError { | |||||||
|             ReminderError::InvalidTag => { |             ReminderError::InvalidTag => { | ||||||
|                 "Couldn't find a location by your tag. Your tag must be either a channel or a user (not a role)".to_string() |                 "Couldn't find a location by your tag. Your tag must be either a channel or a user (not a role)".to_string() | ||||||
|             } |             } | ||||||
|  |             ReminderError::UserBlockedDm => { | ||||||
|  |                 "User has DM reminders disabled".to_string() | ||||||
|  |             } | ||||||
|             ReminderError::DiscordError(s) => format!("A Discord error occurred: **{}**", s), |             ReminderError::DiscordError(s) => format!("A Discord error occurred: **{}**", s), | ||||||
|         } |         } | ||||||
|     } |     } | ||||||
|   | |||||||
| @@ -1,4 +1,4 @@ | |||||||
| use poise::serenity::model::id::ChannelId; | use poise::serenity_prelude::model::id::ChannelId; | ||||||
| use serde::{Deserialize, Serialize}; | use serde::{Deserialize, Serialize}; | ||||||
| use serde_repr::*; | use serde_repr::*; | ||||||
|  |  | ||||||
|   | |||||||
| @@ -4,11 +4,13 @@ pub mod errors; | |||||||
| mod helper; | mod helper; | ||||||
| pub mod look_flags; | pub mod look_flags; | ||||||
|  |  | ||||||
| use chrono::{NaiveDateTime, TimeZone}; | use std::hash::{Hash, Hasher}; | ||||||
|  |  | ||||||
|  | use chrono::{DateTime, NaiveDateTime, Utc}; | ||||||
| use chrono_tz::Tz; | use chrono_tz::Tz; | ||||||
| use poise::{ | use poise::serenity_prelude::{ | ||||||
|     serenity::model::id::{ChannelId, GuildId, UserId}, |     model::id::{ChannelId, GuildId, UserId}, | ||||||
|     serenity_prelude::Cache, |     Cache, | ||||||
| }; | }; | ||||||
| use sqlx::Executor; | use sqlx::Executor; | ||||||
|  |  | ||||||
| @@ -22,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, | ||||||
| @@ -32,11 +35,22 @@ pub struct Reminder { | |||||||
|     pub set_by: Option<u64>, |     pub set_by: Option<u64>, | ||||||
| } | } | ||||||
|  |  | ||||||
|  | impl Hash for Reminder { | ||||||
|  |     fn hash<H: Hasher>(&self, state: &mut H) { | ||||||
|  |         self.uid.hash(state); | ||||||
|  |     } | ||||||
|  | } | ||||||
|  |  | ||||||
|  | impl PartialEq<Self> for Reminder { | ||||||
|  |     fn eq(&self, other: &Self) -> bool { | ||||||
|  |         self.uid == other.uid | ||||||
|  |     } | ||||||
|  | } | ||||||
|  |  | ||||||
|  | impl Eq for Reminder {} | ||||||
|  |  | ||||||
| impl Reminder { | impl Reminder { | ||||||
|     pub async fn from_uid( |     pub async fn from_uid(pool: impl Executor<'_, Database = Database>, uid: &str) -> Option<Self> { | ||||||
|         pool: impl Executor<'_, Database = Database>, |  | ||||||
|         uid: String, |  | ||||||
|     ) -> Option<Self> { |  | ||||||
|         sqlx::query_as_unchecked!( |         sqlx::query_as_unchecked!( | ||||||
|             Self, |             Self, | ||||||
|             " |             " | ||||||
| @@ -46,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, | ||||||
| @@ -72,6 +87,43 @@ WHERE | |||||||
|         .ok() |         .ok() | ||||||
|     } |     } | ||||||
|  |  | ||||||
|  |     pub async fn from_id(pool: impl Executor<'_, Database = Database>, id: u32) -> Option<Self> { | ||||||
|  |         sqlx::query_as_unchecked!( | ||||||
|  |             Self, | ||||||
|  |             " | ||||||
|  | SELECT | ||||||
|  |     reminders.id, | ||||||
|  |     reminders.uid, | ||||||
|  |     channels.channel, | ||||||
|  |     reminders.utc_time, | ||||||
|  |     reminders.interval_seconds, | ||||||
|  |     reminders.interval_days, | ||||||
|  |     reminders.interval_months, | ||||||
|  |     reminders.expires, | ||||||
|  |     reminders.enabled, | ||||||
|  |     reminders.content, | ||||||
|  |     reminders.embed_description, | ||||||
|  |     users.user AS set_by | ||||||
|  | FROM | ||||||
|  |     reminders | ||||||
|  | INNER JOIN | ||||||
|  |     channels | ||||||
|  | ON | ||||||
|  |     reminders.channel_id = channels.id | ||||||
|  | LEFT JOIN | ||||||
|  |     users | ||||||
|  | ON | ||||||
|  |     reminders.set_by = users.id | ||||||
|  | WHERE | ||||||
|  |     reminders.id = ? | ||||||
|  |             ", | ||||||
|  |             id | ||||||
|  |         ) | ||||||
|  |         .fetch_one(pool) | ||||||
|  |         .await | ||||||
|  |         .ok() | ||||||
|  |     } | ||||||
|  |  | ||||||
|     pub async fn from_channel<C: Into<ChannelId>>( |     pub async fn from_channel<C: Into<ChannelId>>( | ||||||
|         pool: impl Executor<'_, Database = Database>, |         pool: impl Executor<'_, Database = Database>, | ||||||
|         channel_id: C, |         channel_id: C, | ||||||
| @@ -89,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, | ||||||
| @@ -146,6 +199,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, | ||||||
| @@ -179,6 +233,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, | ||||||
| @@ -213,6 +268,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, | ||||||
| @@ -240,6 +296,13 @@ WHERE | |||||||
|         .unwrap() |         .unwrap() | ||||||
|     } |     } | ||||||
|  |  | ||||||
|  |     pub async fn delete( | ||||||
|  |         &self, | ||||||
|  |         db: impl Executor<'_, Database = Database>, | ||||||
|  |     ) -> Result<(), sqlx::Error> { | ||||||
|  |         sqlx::query!("DELETE FROM reminders WHERE uid = ?", self.uid).execute(db).await.map(|_| ()) | ||||||
|  |     } | ||||||
|  |  | ||||||
|     pub fn display_content(&self) -> &str { |     pub fn display_content(&self) -> &str { | ||||||
|         if self.content.is_empty() { |         if self.content.is_empty() { | ||||||
|             &self.embed_description |             &self.embed_description | ||||||
| @@ -254,33 +317,32 @@ WHERE | |||||||
|             count + 1, |             count + 1, | ||||||
|             self.display_content(), |             self.display_content(), | ||||||
|             self.channel, |             self.channel, | ||||||
|             timezone |             self.utc_time.with_timezone(timezone).format("%Y-%m-%d %H:%M:%S") | ||||||
|                 .timestamp(self.utc_time.timestamp(), 0) |  | ||||||
|                 .format("%Y-%m-%d %H:%M:%S") |  | ||||||
|                 .to_string() |  | ||||||
|         ) |         ) | ||||||
|     } |     } | ||||||
|  |  | ||||||
|     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, | ||||||
| } | } | ||||||
|  |  | ||||||
|   | |||||||
| @@ -1,6 +1,6 @@ | |||||||
| use chrono_tz::Tz; | use chrono_tz::Tz; | ||||||
| use log::error; | use log::error; | ||||||
| use poise::serenity::{http::CacheHttp, model::id::UserId}; | use poise::serenity_prelude::{http::CacheHttp, model::id::UserId}; | ||||||
| use sqlx::MySqlPool; | use sqlx::MySqlPool; | ||||||
|  |  | ||||||
| use crate::consts::LOCAL_TIMEZONE; | use crate::consts::LOCAL_TIMEZONE; | ||||||
| @@ -10,6 +10,7 @@ pub struct UserData { | |||||||
|     pub user: u64, |     pub user: u64, | ||||||
|     pub dm_channel: u32, |     pub dm_channel: u32, | ||||||
|     pub timezone: String, |     pub timezone: String, | ||||||
|  |     pub allowed_dm: bool, | ||||||
| } | } | ||||||
|  |  | ||||||
| impl UserData { | impl UserData { | ||||||
| @@ -21,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 | ||||||
|         ) |         ) | ||||||
| @@ -46,7 +47,7 @@ SELECT timezone FROM users WHERE user = ? | |||||||
|         match sqlx::query_as_unchecked!( |         match sqlx::query_as_unchecked!( | ||||||
|             Self, |             Self, | ||||||
|             " |             " | ||||||
| SELECT id, user, dm_channel, IF(timezone IS NULL, ?, timezone) AS timezone FROM users WHERE user = ? | SELECT id, user, dm_channel, IF(timezone IS NULL, ?, timezone) AS timezone, allowed_dm FROM users WHERE user = ? | ||||||
|             ", |             ", | ||||||
|             *LOCAL_TIMEZONE, |             *LOCAL_TIMEZONE, | ||||||
|             user_id.0 |             user_id.0 | ||||||
| @@ -71,7 +72,7 @@ INSERT IGNORE INTO channels (channel) VALUES (?) | |||||||
|  |  | ||||||
|                 sqlx::query!( |                 sqlx::query!( | ||||||
|                     " |                     " | ||||||
| INSERT INTO users (user, dm_channel, timezone) VALUES (?, (SELECT id FROM channels WHERE channel = ?), ?) | INSERT INTO users (name, user, dm_channel, timezone) VALUES ('', ?, (SELECT id FROM channels WHERE channel = ?), ?) | ||||||
|                     ", |                     ", | ||||||
|                     user_id.0, |                     user_id.0, | ||||||
|                     dm_channel.id.0, |                     dm_channel.id.0, | ||||||
| @@ -83,7 +84,7 @@ INSERT INTO users (user, dm_channel, timezone) VALUES (?, (SELECT id FROM channe | |||||||
|                 Ok(sqlx::query_as_unchecked!( |                 Ok(sqlx::query_as_unchecked!( | ||||||
|                     Self, |                     Self, | ||||||
|                     " |                     " | ||||||
| SELECT id, user, dm_channel, timezone FROM users WHERE user = ? | SELECT id, user, dm_channel, timezone, allowed_dm FROM users WHERE user = ? | ||||||
|                     ", |                     ", | ||||||
|                     user_id.0 |                     user_id.0 | ||||||
|                 ) |                 ) | ||||||
| @@ -102,9 +103,10 @@ SELECT id, user, dm_channel, timezone FROM users WHERE user = ? | |||||||
|     pub async fn commit_changes(&self, pool: &MySqlPool) { |     pub async fn commit_changes(&self, pool: &MySqlPool) { | ||||||
|         sqlx::query!( |         sqlx::query!( | ||||||
|             " |             " | ||||||
| UPDATE users SET timezone = ? WHERE id = ? | UPDATE users SET timezone = ?, allowed_dm = ? WHERE id = ? | ||||||
|             ", |             ", | ||||||
|             self.timezone, |             self.timezone, | ||||||
|  |             self.allowed_dm, | ||||||
|             self.id |             self.id | ||||||
|         ) |         ) | ||||||
|         .execute(pool) |         .execute(pool) | ||||||
|   | |||||||
| @@ -211,14 +211,12 @@ pub async fn natural_parser(time: &str, timezone: &str) -> Option<i64> { | |||||||
|         .output() |         .output() | ||||||
|         .await |         .await | ||||||
|         .ok() |         .ok() | ||||||
|         .map(|inner| { |         .and_then(|inner| { | ||||||
|             if inner.status.success() { |             if inner.status.success() { | ||||||
|                 Some(from_utf8(&*inner.stdout).unwrap().parse::<i64>().unwrap()) |                 Some(from_utf8(&*inner.stdout).unwrap().parse::<i64>().unwrap()) | ||||||
|             } else { |             } else { | ||||||
|                 None |                 None | ||||||
|             } |             } | ||||||
|         }) |         }) | ||||||
|         .flatten() |         .and_then(|inner| if inner < 0 { None } else { Some(inner) }) | ||||||
|         .map(|inner| if inner < 0 { None } else { Some(inner) }) |  | ||||||
|         .flatten() |  | ||||||
| } | } | ||||||
|   | |||||||
							
								
								
									
										13
									
								
								src/utils.rs
									
									
									
									
									
								
							
							
						
						| @@ -1,10 +1,11 @@ | |||||||
| use poise::{ | use poise::{ | ||||||
|     serenity::{ |     serenity_prelude as serenity, | ||||||
|  |     serenity_prelude::{ | ||||||
|         builder::CreateApplicationCommands, |         builder::CreateApplicationCommands, | ||||||
|         http::CacheHttp, |         http::CacheHttp, | ||||||
|  |         interaction::MessageFlags, | ||||||
|         model::id::{GuildId, UserId}, |         model::id::{GuildId, UserId}, | ||||||
|     }, |     }, | ||||||
|     serenity_prelude as serenity, |  | ||||||
| }; | }; | ||||||
|  |  | ||||||
| use crate::{ | use crate::{ | ||||||
| @@ -13,10 +14,10 @@ use crate::{ | |||||||
| }; | }; | ||||||
|  |  | ||||||
| pub async fn register_application_commands( | pub async fn register_application_commands( | ||||||
|     ctx: &poise::serenity::client::Context, |     ctx: &serenity::Context, | ||||||
|     framework: &poise::Framework<Data, Error>, |     framework: &poise::Framework<Data, Error>, | ||||||
|     guild_id: Option<GuildId>, |     guild_id: Option<GuildId>, | ||||||
| ) -> Result<(), poise::serenity::Error> { | ) -> Result<(), serenity::Error> { | ||||||
|     let mut commands_builder = CreateApplicationCommands::default(); |     let mut commands_builder = CreateApplicationCommands::default(); | ||||||
|     let commands = &framework.options().commands; |     let commands = &framework.options().commands; | ||||||
|     for command in commands { |     for command in commands { | ||||||
| @@ -27,7 +28,7 @@ pub async fn register_application_commands( | |||||||
|             commands_builder.add_application_command(context_menu_command); |             commands_builder.add_application_command(context_menu_command); | ||||||
|         } |         } | ||||||
|     } |     } | ||||||
|     let commands_builder = poise::serenity::json::Value::Array(commands_builder.0); |     let commands_builder = poise::serenity_prelude::json::Value::Array(commands_builder.0); | ||||||
|  |  | ||||||
|     if let Some(guild_id) = guild_id { |     if let Some(guild_id) = guild_id { | ||||||
|         ctx.http.create_guild_application_commands(guild_id.0, &commands_builder).await?; |         ctx.http.create_guild_application_commands(guild_id.0, &commands_builder).await?; | ||||||
| @@ -102,6 +103,6 @@ pub fn send_as_initial_response( | |||||||
|         }); |         }); | ||||||
|     } |     } | ||||||
|     if ephemeral { |     if ephemeral { | ||||||
|         f.flags(serenity::InteractionApplicationCommandCallbackDataFlags::EPHEMERAL); |         f.flags(MessageFlags::EPHEMERAL); | ||||||
|     } |     } | ||||||
| } | } | ||||||
|   | |||||||
							
								
								
									
										13
									
								
								systemd/reminder-rs.service
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,13 @@ | |||||||
|  | [Unit] | ||||||
|  | Description=Reminder Bot | ||||||
|  |  | ||||||
|  | [Service] | ||||||
|  | User=reminder | ||||||
|  | Type=simple | ||||||
|  | ExecStart=/usr/bin/reminder-rs | ||||||
|  | Restart=always | ||||||
|  | RestartSec=4 | ||||||
|  | # Environment="RUST_LOG=warn,reminder_rs=info,postman=info" | ||||||
|  |  | ||||||
|  | [Install] | ||||||
|  | WantedBy=multi-user.target | ||||||
| @@ -12,10 +12,10 @@ 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"] } | ||||||
| serde_json = "1.0" | sqlx = { version = "0.6", features = ["runtime-tokio-rustls", "macros", "mysql", "chrono", "json"] } | ||||||
| sqlx = { version = "0.5", features = ["runtime-tokio-rustls", "macros", "mysql", "chrono", "json"] } |  | ||||||
| chrono = "0.4" | chrono = "0.4" | ||||||
| chrono-tz = "0.5" | chrono-tz = "0.5" | ||||||
| lazy_static = "1.4.0" | lazy_static = "1.4.0" | ||||||
| rand = "0.7" | rand = "0.7" | ||||||
| base64 = "0.13" | base64 = "0.13" | ||||||
|  | csv = "1.1" | ||||||
|   | |||||||
| @@ -26,16 +26,12 @@ use serenity::model::prelude::AttachmentType; | |||||||
|  |  | ||||||
| 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 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() }) | ||||||
| @@ -43,7 +39,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()) | ||||||
|   | |||||||
| @@ -75,7 +75,7 @@ pub async fn initialize( | |||||||
|     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( | ||||||
| @@ -126,6 +126,9 @@ pub async fn initialize( | |||||||
|                 routes::help_timers, |                 routes::help_timers, | ||||||
|                 routes::help_todo_lists, |                 routes::help_todo_lists, | ||||||
|                 routes::help_macros, |                 routes::help_macros, | ||||||
|  |                 routes::help_intervals, | ||||||
|  |                 routes::help_dashboard, | ||||||
|  |                 routes::help_iemanager, | ||||||
|             ], |             ], | ||||||
|         ) |         ) | ||||||
|         .mount("/login", routes![routes::login::discord_login, routes::login::discord_callback]) |         .mount("/login", routes![routes::login::discord_login, routes::login::discord_callback]) | ||||||
| @@ -143,10 +146,15 @@ pub async fn initialize( | |||||||
|                 routes::dashboard::guild::get_reminder_templates, |                 routes::dashboard::guild::get_reminder_templates, | ||||||
|                 routes::dashboard::guild::create_reminder_template, |                 routes::dashboard::guild::create_reminder_template, | ||||||
|                 routes::dashboard::guild::delete_reminder_template, |                 routes::dashboard::guild::delete_reminder_template, | ||||||
|                 routes::dashboard::guild::create_reminder, |                 routes::dashboard::guild::create_guild_reminder, | ||||||
|                 routes::dashboard::guild::get_reminders, |                 routes::dashboard::guild::get_reminders, | ||||||
|                 routes::dashboard::guild::edit_reminder, |                 routes::dashboard::guild::edit_reminder, | ||||||
|                 routes::dashboard::guild::delete_reminder, |                 routes::dashboard::guild::delete_reminder, | ||||||
|  |                 routes::dashboard::export::export_reminders, | ||||||
|  |                 routes::dashboard::export::export_reminder_templates, | ||||||
|  |                 routes::dashboard::export::export_todos, | ||||||
|  |                 routes::dashboard::export::import_reminders, | ||||||
|  |                 routes::dashboard::export::import_todos, | ||||||
|             ], |             ], | ||||||
|         ) |         ) | ||||||
|         .launch() |         .launch() | ||||||
|   | |||||||
| @@ -1,7 +1,7 @@ | |||||||
| macro_rules! check_length { | macro_rules! check_length { | ||||||
|     ($max:ident, $field:expr) => { |     ($max:ident, $field:expr) => { | ||||||
|         if $field.len() > $max { |         if $field.len() > $max { | ||||||
|             return json!({ "error": format!("{} exceeded", stringify!($max)) }); |             return Err(json!({ "error": format!("{} exceeded", stringify!($max)) })); | ||||||
|         } |         } | ||||||
|     }; |     }; | ||||||
|     ($max:ident, $field:expr, $($fields:expr),+) => { |     ($max:ident, $field:expr, $($fields:expr),+) => { | ||||||
| @@ -25,7 +25,7 @@ macro_rules! check_length_opt { | |||||||
| macro_rules! check_url { | macro_rules! check_url { | ||||||
|     ($field:expr) => { |     ($field:expr) => { | ||||||
|         if !($field.starts_with("http://") || $field.starts_with("https://")) { |         if !($field.starts_with("http://") || $field.starts_with("https://")) { | ||||||
|             return json!({ "error": "URL invalid" }); |             return Err(json!({ "error": "URL invalid" })); | ||||||
|         } |         } | ||||||
|     }; |     }; | ||||||
|     ($field:expr, $($fields:expr),+) => { |     ($field:expr, $($fields:expr),+) => { | ||||||
| @@ -60,7 +60,7 @@ macro_rules! check_authorization { | |||||||
|  |  | ||||||
|                         match member { |                         match member { | ||||||
|                             Err(_) => { |                             Err(_) => { | ||||||
|                                 return json!({"error": "User not in guild"}) |                                 return Err(json!({"error": "User not in guild"})); | ||||||
|                             } |                             } | ||||||
|  |  | ||||||
|                             Ok(_) => {} |                             Ok(_) => {} | ||||||
| @@ -68,13 +68,13 @@ macro_rules! check_authorization { | |||||||
|                     } |                     } | ||||||
|  |  | ||||||
|                     None => { |                     None => { | ||||||
|                         return json!({"error": "Bot not in guild"}) |                         return Err(json!({"error": "Bot not in guild"})); | ||||||
|                     } |                     } | ||||||
|                 } |                 } | ||||||
|             } |             } | ||||||
|  |  | ||||||
|             None => { |             None => { | ||||||
|                 return json!({"error": "User not authorized"}); |                 return Err(json!({"error": "User not authorized"})); | ||||||
|             } |             } | ||||||
|         } |         } | ||||||
|     } |     } | ||||||
| @@ -117,3 +117,9 @@ macro_rules! update_field { | |||||||
|         update_field!($pool, $error, $reminder.[$($fields),+]); |         update_field!($pool, $error, $reminder.[$($fields),+]); | ||||||
|     }; |     }; | ||||||
| } | } | ||||||
|  |  | ||||||
|  | macro_rules! json_err { | ||||||
|  |     ($message:expr) => { | ||||||
|  |         Err(json!({ "error": $message })) | ||||||
|  |     }; | ||||||
|  | } | ||||||
|   | |||||||
							
								
								
									
										425
									
								
								web/src/routes/dashboard/export.rs
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,425 @@ | |||||||
|  | use csv::{QuoteStyle, WriterBuilder}; | ||||||
|  | use rocket::{ | ||||||
|  |     http::CookieJar, | ||||||
|  |     serde::json::{json, serde_json, Json}, | ||||||
|  |     State, | ||||||
|  | }; | ||||||
|  | use serenity::{ | ||||||
|  |     client::Context, | ||||||
|  |     model::id::{ChannelId, GuildId}, | ||||||
|  | }; | ||||||
|  | use sqlx::{MySql, Pool}; | ||||||
|  |  | ||||||
|  | use crate::routes::dashboard::{ | ||||||
|  |     create_reminder, generate_uid, ImportBody, JsonResult, Reminder, ReminderCsv, | ||||||
|  |     ReminderTemplateCsv, TodoCsv, | ||||||
|  | }; | ||||||
|  |  | ||||||
|  | #[get("/api/guild/<id>/export/reminders")] | ||||||
|  | pub async fn export_reminders( | ||||||
|  |     id: u64, | ||||||
|  |     cookies: &CookieJar<'_>, | ||||||
|  |     ctx: &State<Context>, | ||||||
|  |     pool: &State<Pool<MySql>>, | ||||||
|  | ) -> JsonResult { | ||||||
|  |     check_authorization!(cookies, ctx.inner(), id); | ||||||
|  |  | ||||||
|  |     let mut csv_writer = WriterBuilder::new().quote_style(QuoteStyle::Always).from_writer(vec![]); | ||||||
|  |  | ||||||
|  |     let channels_res = GuildId(id).channels(&ctx.inner()).await; | ||||||
|  |  | ||||||
|  |     match channels_res { | ||||||
|  |         Ok(channels) => { | ||||||
|  |             let channels = channels | ||||||
|  |                 .keys() | ||||||
|  |                 .into_iter() | ||||||
|  |                 .map(|k| k.as_u64().to_string()) | ||||||
|  |                 .collect::<Vec<String>>() | ||||||
|  |                 .join(","); | ||||||
|  |  | ||||||
|  |             let result = sqlx::query_as_unchecked!( | ||||||
|  |                 ReminderCsv, | ||||||
|  |                 "SELECT | ||||||
|  |                  reminders.attachment, | ||||||
|  |                  reminders.attachment_name, | ||||||
|  |                  reminders.avatar, | ||||||
|  |                  CONCAT('#', channels.channel) AS channel, | ||||||
|  |                  reminders.content, | ||||||
|  |                  reminders.embed_author, | ||||||
|  |                  reminders.embed_author_url, | ||||||
|  |                  reminders.embed_color, | ||||||
|  |                  reminders.embed_description, | ||||||
|  |                  reminders.embed_footer, | ||||||
|  |                  reminders.embed_footer_url, | ||||||
|  |                  reminders.embed_image_url, | ||||||
|  |                  reminders.embed_thumbnail_url, | ||||||
|  |                  reminders.embed_title, | ||||||
|  |                  reminders.embed_fields, | ||||||
|  |                  reminders.enabled, | ||||||
|  |                  reminders.expires, | ||||||
|  |                  reminders.interval_seconds, | ||||||
|  |                  reminders.interval_days, | ||||||
|  |                  reminders.interval_months, | ||||||
|  |                  reminders.name, | ||||||
|  |                  reminders.restartable, | ||||||
|  |                  reminders.tts, | ||||||
|  |                  reminders.username, | ||||||
|  |                  reminders.utc_time | ||||||
|  |                 FROM reminders | ||||||
|  |                 LEFT JOIN channels ON channels.id = reminders.channel_id | ||||||
|  |                 WHERE FIND_IN_SET(channels.channel, ?)", | ||||||
|  |                 channels | ||||||
|  |             ) | ||||||
|  |             .fetch_all(pool.inner()) | ||||||
|  |             .await; | ||||||
|  |  | ||||||
|  |             match result { | ||||||
|  |                 Ok(reminders) => { | ||||||
|  |                     reminders.iter().for_each(|reminder| { | ||||||
|  |                         csv_writer.serialize(reminder).unwrap(); | ||||||
|  |                     }); | ||||||
|  |  | ||||||
|  |                     match csv_writer.into_inner() { | ||||||
|  |                         Ok(inner) => match String::from_utf8(inner) { | ||||||
|  |                             Ok(encoded) => Ok(json!({ "body": encoded })), | ||||||
|  |  | ||||||
|  |                             Err(e) => { | ||||||
|  |                                 warn!("Failed to write UTF-8: {:?}", e); | ||||||
|  |  | ||||||
|  |                                 Err(json!({"error": "Failed to write UTF-8"})) | ||||||
|  |                             } | ||||||
|  |                         }, | ||||||
|  |  | ||||||
|  |                         Err(e) => { | ||||||
|  |                             warn!("Failed to extract CSV: {:?}", e); | ||||||
|  |  | ||||||
|  |                             Err(json!({"error": "Failed to extract CSV"})) | ||||||
|  |                         } | ||||||
|  |                     } | ||||||
|  |                 } | ||||||
|  |  | ||||||
|  |                 Err(e) => { | ||||||
|  |                     warn!("Failed to complete SQL query: {:?}", e); | ||||||
|  |  | ||||||
|  |                     Err(json!({"error": "Failed to query reminders"})) | ||||||
|  |                 } | ||||||
|  |             } | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         Err(e) => { | ||||||
|  |             warn!("Could not fetch channels from {}: {:?}", id, e); | ||||||
|  |  | ||||||
|  |             Err(json!({"error": "Failed to get guild channels"})) | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  | } | ||||||
|  |  | ||||||
|  | #[put("/api/guild/<id>/export/reminders", data = "<body>")] | ||||||
|  | pub async fn import_reminders( | ||||||
|  |     id: u64, | ||||||
|  |     cookies: &CookieJar<'_>, | ||||||
|  |     body: Json<ImportBody>, | ||||||
|  |     ctx: &State<Context>, | ||||||
|  |     pool: &State<Pool<MySql>>, | ||||||
|  | ) -> JsonResult { | ||||||
|  |     check_authorization!(cookies, ctx.inner(), id); | ||||||
|  |  | ||||||
|  |     let user_id = | ||||||
|  |         cookies.get_private("userid").map(|c| c.value().parse::<u64>().ok()).flatten().unwrap(); | ||||||
|  |  | ||||||
|  |     match base64::decode(&body.body) { | ||||||
|  |         Ok(body) => { | ||||||
|  |             let mut reader = csv::Reader::from_reader(body.as_slice()); | ||||||
|  |  | ||||||
|  |             for result in reader.deserialize::<ReminderCsv>() { | ||||||
|  |                 match result { | ||||||
|  |                     Ok(record) => { | ||||||
|  |                         let channel_id = record.channel.split_at(1).1; | ||||||
|  |  | ||||||
|  |                         match channel_id.parse::<u64>() { | ||||||
|  |                             Ok(channel_id) => { | ||||||
|  |                                 let reminder = Reminder { | ||||||
|  |                                     attachment: record.attachment, | ||||||
|  |                                     attachment_name: record.attachment_name, | ||||||
|  |                                     avatar: record.avatar, | ||||||
|  |                                     channel: channel_id, | ||||||
|  |                                     content: record.content, | ||||||
|  |                                     embed_author: record.embed_author, | ||||||
|  |                                     embed_author_url: record.embed_author_url, | ||||||
|  |                                     embed_color: record.embed_color, | ||||||
|  |                                     embed_description: record.embed_description, | ||||||
|  |                                     embed_footer: record.embed_footer, | ||||||
|  |                                     embed_footer_url: record.embed_footer_url, | ||||||
|  |                                     embed_image_url: record.embed_image_url, | ||||||
|  |                                     embed_thumbnail_url: record.embed_thumbnail_url, | ||||||
|  |                                     embed_title: record.embed_title, | ||||||
|  |                                     embed_fields: record | ||||||
|  |                                         .embed_fields | ||||||
|  |                                         .map(|s| serde_json::from_str(&s).ok()) | ||||||
|  |                                         .flatten(), | ||||||
|  |                                     enabled: record.enabled, | ||||||
|  |                                     expires: record.expires, | ||||||
|  |                                     interval_seconds: record.interval_seconds, | ||||||
|  |                                     interval_days: record.interval_days, | ||||||
|  |                                     interval_months: record.interval_months, | ||||||
|  |                                     name: record.name, | ||||||
|  |                                     restartable: record.restartable, | ||||||
|  |                                     tts: record.tts, | ||||||
|  |                                     uid: generate_uid(), | ||||||
|  |                                     username: record.username, | ||||||
|  |                                     utc_time: record.utc_time, | ||||||
|  |                                 }; | ||||||
|  |  | ||||||
|  |                                 create_reminder( | ||||||
|  |                                     ctx.inner(), | ||||||
|  |                                     pool.inner(), | ||||||
|  |                                     GuildId(id), | ||||||
|  |                                     UserId(user_id), | ||||||
|  |                                     reminder, | ||||||
|  |                                 ) | ||||||
|  |                                 .await?; | ||||||
|  |                             } | ||||||
|  |  | ||||||
|  |                             Err(_) => { | ||||||
|  |                                 return json_err!(format!( | ||||||
|  |                                     "Failed to parse channel {}", | ||||||
|  |                                     channel_id | ||||||
|  |                                 )); | ||||||
|  |                             } | ||||||
|  |                         } | ||||||
|  |                     } | ||||||
|  |  | ||||||
|  |                     Err(e) => { | ||||||
|  |                         warn!("Couldn't deserialize CSV row: {:?}", e); | ||||||
|  |  | ||||||
|  |                         return json_err!("Deserialize error. Aborted"); | ||||||
|  |                     } | ||||||
|  |                 } | ||||||
|  |             } | ||||||
|  |  | ||||||
|  |             Ok(json!({})) | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         Err(_) => { | ||||||
|  |             json_err!("Malformed base64") | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  | } | ||||||
|  |  | ||||||
|  | #[get("/api/guild/<id>/export/todos")] | ||||||
|  | pub async fn export_todos( | ||||||
|  |     id: u64, | ||||||
|  |     cookies: &CookieJar<'_>, | ||||||
|  |     ctx: &State<Context>, | ||||||
|  |     pool: &State<Pool<MySql>>, | ||||||
|  | ) -> JsonResult { | ||||||
|  |     check_authorization!(cookies, ctx.inner(), id); | ||||||
|  |  | ||||||
|  |     let mut csv_writer = WriterBuilder::new().quote_style(QuoteStyle::Always).from_writer(vec![]); | ||||||
|  |  | ||||||
|  |     match sqlx::query_as_unchecked!( | ||||||
|  |         TodoCsv, | ||||||
|  |         "SELECT value, CONCAT('#', channels.channel) AS channel_id FROM todos | ||||||
|  |         LEFT JOIN channels ON todos.channel_id = channels.id | ||||||
|  |         INNER JOIN guilds ON todos.guild_id = guilds.id | ||||||
|  |         WHERE guilds.guild = ?", | ||||||
|  |         id | ||||||
|  |     ) | ||||||
|  |     .fetch_all(pool.inner()) | ||||||
|  |     .await | ||||||
|  |     { | ||||||
|  |         Ok(todos) => { | ||||||
|  |             todos.iter().for_each(|todo| { | ||||||
|  |                 csv_writer.serialize(todo).unwrap(); | ||||||
|  |             }); | ||||||
|  |  | ||||||
|  |             match csv_writer.into_inner() { | ||||||
|  |                 Ok(inner) => match String::from_utf8(inner) { | ||||||
|  |                     Ok(encoded) => Ok(json!({ "body": encoded })), | ||||||
|  |  | ||||||
|  |                     Err(e) => { | ||||||
|  |                         warn!("Failed to write UTF-8: {:?}", e); | ||||||
|  |  | ||||||
|  |                         json_err!("Failed to write UTF-8") | ||||||
|  |                     } | ||||||
|  |                 }, | ||||||
|  |  | ||||||
|  |                 Err(e) => { | ||||||
|  |                     warn!("Failed to extract CSV: {:?}", e); | ||||||
|  |  | ||||||
|  |                     json_err!("Failed to extract CSV") | ||||||
|  |                 } | ||||||
|  |             } | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         Err(e) => { | ||||||
|  |             warn!("Could not fetch templates from {}: {:?}", id, e); | ||||||
|  |  | ||||||
|  |             json_err!("Failed to query templates") | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  | } | ||||||
|  |  | ||||||
|  | #[put("/api/guild/<id>/export/todos", data = "<body>")] | ||||||
|  | pub async fn import_todos( | ||||||
|  |     id: u64, | ||||||
|  |     cookies: &CookieJar<'_>, | ||||||
|  |     body: Json<ImportBody>, | ||||||
|  |     ctx: &State<Context>, | ||||||
|  |     pool: &State<Pool<MySql>>, | ||||||
|  | ) -> JsonResult { | ||||||
|  |     check_authorization!(cookies, ctx.inner(), id); | ||||||
|  |  | ||||||
|  |     let channels_res = GuildId(id).channels(&ctx.inner()).await; | ||||||
|  |  | ||||||
|  |     match channels_res { | ||||||
|  |         Ok(channels) => match base64::decode(&body.body) { | ||||||
|  |             Ok(body) => { | ||||||
|  |                 let mut reader = csv::Reader::from_reader(body.as_slice()); | ||||||
|  |  | ||||||
|  |                 let query_placeholder = "(?, (SELECT id FROM channels WHERE channel = ?), (SELECT id FROM guilds WHERE guild = ?))"; | ||||||
|  |                 let mut query_params = vec![]; | ||||||
|  |  | ||||||
|  |                 for result in reader.deserialize::<TodoCsv>() { | ||||||
|  |                     match result { | ||||||
|  |                         Ok(record) => match record.channel_id { | ||||||
|  |                             Some(channel_id) => { | ||||||
|  |                                 let channel_id = channel_id.split_at(1).1; | ||||||
|  |  | ||||||
|  |                                 match channel_id.parse::<u64>() { | ||||||
|  |                                     Ok(channel_id) => { | ||||||
|  |                                         if channels.contains_key(&ChannelId(channel_id)) { | ||||||
|  |                                             query_params.push((record.value, Some(channel_id), id)); | ||||||
|  |                                         } else { | ||||||
|  |                                             return json_err!(format!( | ||||||
|  |                                                 "Invalid channel ID {}", | ||||||
|  |                                                 channel_id | ||||||
|  |                                             )); | ||||||
|  |                                         } | ||||||
|  |                                     } | ||||||
|  |  | ||||||
|  |                                     Err(_) => { | ||||||
|  |                                         return json_err!(format!( | ||||||
|  |                                             "Invalid channel ID {}", | ||||||
|  |                                             channel_id | ||||||
|  |                                         )); | ||||||
|  |                                     } | ||||||
|  |                                 } | ||||||
|  |                             } | ||||||
|  |  | ||||||
|  |                             None => { | ||||||
|  |                                 query_params.push((record.value, None, id)); | ||||||
|  |                             } | ||||||
|  |                         }, | ||||||
|  |  | ||||||
|  |                         Err(e) => { | ||||||
|  |                             warn!("Couldn't deserialize CSV row: {:?}", e); | ||||||
|  |  | ||||||
|  |                             return json_err!("Deserialize error. Aborted"); | ||||||
|  |                         } | ||||||
|  |                     } | ||||||
|  |                 } | ||||||
|  |  | ||||||
|  |                 let query_str = format!( | ||||||
|  |                     "INSERT INTO todos (value, channel_id, guild_id) VALUES {}", | ||||||
|  |                     vec![query_placeholder].repeat(query_params.len()).join(",") | ||||||
|  |                 ); | ||||||
|  |                 let mut query = sqlx::query(&query_str); | ||||||
|  |  | ||||||
|  |                 for param in query_params { | ||||||
|  |                     query = query.bind(param.0).bind(param.1).bind(param.2); | ||||||
|  |                 } | ||||||
|  |  | ||||||
|  |                 let res = query.execute(pool.inner()).await; | ||||||
|  |  | ||||||
|  |                 match res { | ||||||
|  |                     Ok(_) => Ok(json!({})), | ||||||
|  |  | ||||||
|  |                     Err(e) => { | ||||||
|  |                         warn!("Couldn't execute todo query: {:?}", e); | ||||||
|  |  | ||||||
|  |                         json_err!("An unexpected error occured.") | ||||||
|  |                     } | ||||||
|  |                 } | ||||||
|  |             } | ||||||
|  |  | ||||||
|  |             Err(_) => { | ||||||
|  |                 json_err!("Malformed base64") | ||||||
|  |             } | ||||||
|  |         }, | ||||||
|  |  | ||||||
|  |         Err(e) => { | ||||||
|  |             warn!("Couldn't fetch channels for guild {}: {:?}", id, e); | ||||||
|  |  | ||||||
|  |             json_err!("Couldn't fetch channels.") | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  | } | ||||||
|  |  | ||||||
|  | #[get("/api/guild/<id>/export/reminder_templates")] | ||||||
|  | pub async fn export_reminder_templates( | ||||||
|  |     id: u64, | ||||||
|  |     cookies: &CookieJar<'_>, | ||||||
|  |     ctx: &State<Context>, | ||||||
|  |     pool: &State<Pool<MySql>>, | ||||||
|  | ) -> JsonResult { | ||||||
|  |     check_authorization!(cookies, ctx.inner(), id); | ||||||
|  |  | ||||||
|  |     let mut csv_writer = WriterBuilder::new().quote_style(QuoteStyle::Always).from_writer(vec![]); | ||||||
|  |  | ||||||
|  |     match sqlx::query_as_unchecked!( | ||||||
|  |         ReminderTemplateCsv, | ||||||
|  |         "SELECT | ||||||
|  |          name, | ||||||
|  |          attachment, | ||||||
|  |          attachment_name, | ||||||
|  |          avatar, | ||||||
|  |          content, | ||||||
|  |          embed_author, | ||||||
|  |          embed_author_url, | ||||||
|  |          embed_color, | ||||||
|  |          embed_description, | ||||||
|  |          embed_footer, | ||||||
|  |          embed_footer_url, | ||||||
|  |          embed_image_url, | ||||||
|  |          embed_thumbnail_url, | ||||||
|  |          embed_title, | ||||||
|  |          embed_fields, | ||||||
|  |          tts, | ||||||
|  |          username | ||||||
|  |         FROM reminder_template WHERE guild_id = (SELECT id FROM guilds WHERE guild = ?)", | ||||||
|  |         id | ||||||
|  |     ) | ||||||
|  |     .fetch_all(pool.inner()) | ||||||
|  |     .await | ||||||
|  |     { | ||||||
|  |         Ok(templates) => { | ||||||
|  |             templates.iter().for_each(|template| { | ||||||
|  |                 csv_writer.serialize(template).unwrap(); | ||||||
|  |             }); | ||||||
|  |  | ||||||
|  |             match csv_writer.into_inner() { | ||||||
|  |                 Ok(inner) => match String::from_utf8(inner) { | ||||||
|  |                     Ok(encoded) => Ok(json!({ "body": encoded })), | ||||||
|  |  | ||||||
|  |                     Err(e) => { | ||||||
|  |                         warn!("Failed to write UTF-8: {:?}", e); | ||||||
|  |  | ||||||
|  |                         json_err!("Failed to write UTF-8") | ||||||
|  |                     } | ||||||
|  |                 }, | ||||||
|  |  | ||||||
|  |                 Err(e) => { | ||||||
|  |                     warn!("Failed to extract CSV: {:?}", e); | ||||||
|  |  | ||||||
|  |                     json_err!("Failed to extract CSV") | ||||||
|  |                 } | ||||||
|  |             } | ||||||
|  |         } | ||||||
|  |         Err(e) => { | ||||||
|  |             warn!("Could not fetch templates from {}: {:?}", id, e); | ||||||
|  |  | ||||||
|  |             json_err!("Failed to query templates") | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  | } | ||||||
| @@ -1,10 +1,8 @@ | |||||||
| use std::env; | use std::env; | ||||||
|  |  | ||||||
| use base64; |  | ||||||
| use chrono::Utc; |  | ||||||
| use rocket::{ | use rocket::{ | ||||||
|     http::CookieJar, |     http::CookieJar, | ||||||
|     serde::json::{json, Json, Value as JsonValue}, |     serde::json::{json, Json}, | ||||||
|     State, |     State, | ||||||
| }; | }; | ||||||
| use serde::Serialize; | use serde::Serialize; | ||||||
| @@ -20,14 +18,14 @@ use sqlx::{MySql, Pool}; | |||||||
| use crate::{ | use crate::{ | ||||||
|     check_guild_subscription, check_subscription, |     check_guild_subscription, check_subscription, | ||||||
|     consts::{ |     consts::{ | ||||||
|         DAY, MAX_CONTENT_LENGTH, MAX_EMBED_AUTHOR_LENGTH, MAX_EMBED_DESCRIPTION_LENGTH, |         MAX_CONTENT_LENGTH, MAX_EMBED_AUTHOR_LENGTH, MAX_EMBED_DESCRIPTION_LENGTH, | ||||||
|         MAX_EMBED_FIELDS, MAX_EMBED_FIELD_TITLE_LENGTH, MAX_EMBED_FIELD_VALUE_LENGTH, |         MAX_EMBED_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, |         MAX_EMBED_FOOTER_LENGTH, MAX_EMBED_TITLE_LENGTH, MAX_URL_LENGTH, MAX_USERNAME_LENGTH, | ||||||
|         MIN_INTERVAL, |         MIN_INTERVAL, | ||||||
|     }, |     }, | ||||||
|     routes::dashboard::{ |     routes::dashboard::{ | ||||||
|         create_database_channel, generate_uid, name_default, template_name_default, DeleteReminder, |         create_database_channel, create_reminder, template_name_default, DeleteReminder, | ||||||
|         DeleteReminderTemplate, PatchReminder, Reminder, ReminderTemplate, |         DeleteReminderTemplate, JsonResult, PatchReminder, Reminder, ReminderTemplate, | ||||||
|     }, |     }, | ||||||
| }; | }; | ||||||
|  |  | ||||||
| @@ -44,7 +42,7 @@ pub async fn get_guild_patreon( | |||||||
|     id: u64, |     id: u64, | ||||||
|     cookies: &CookieJar<'_>, |     cookies: &CookieJar<'_>, | ||||||
|     ctx: &State<Context>, |     ctx: &State<Context>, | ||||||
| ) -> JsonValue { | ) -> JsonResult { | ||||||
|     check_authorization!(cookies, ctx.inner(), id); |     check_authorization!(cookies, ctx.inner(), id); | ||||||
|  |  | ||||||
|     match GuildId(id).to_guild_cached(ctx.inner()) { |     match GuildId(id).to_guild_cached(ctx.inner()) { | ||||||
| @@ -59,12 +57,10 @@ pub async fn get_guild_patreon( | |||||||
|                     .contains(&RoleId(env::var("PATREON_ROLE_ID").unwrap().parse().unwrap())) |                     .contains(&RoleId(env::var("PATREON_ROLE_ID").unwrap().parse().unwrap())) | ||||||
|             }); |             }); | ||||||
|  |  | ||||||
|             json!({ "patreon": patreon }) |             Ok(json!({ "patreon": patreon })) | ||||||
|         } |         } | ||||||
|  |  | ||||||
|         None => { |         None => json_err!("Bot not in guild"), | ||||||
|             json!({"error": "Bot not in guild"}) |  | ||||||
|         } |  | ||||||
|     } |     } | ||||||
| } | } | ||||||
|  |  | ||||||
| @@ -73,7 +69,7 @@ pub async fn get_guild_channels( | |||||||
|     id: u64, |     id: u64, | ||||||
|     cookies: &CookieJar<'_>, |     cookies: &CookieJar<'_>, | ||||||
|     ctx: &State<Context>, |     ctx: &State<Context>, | ||||||
| ) -> JsonValue { | ) -> JsonResult { | ||||||
|     check_authorization!(cookies, ctx.inner(), id); |     check_authorization!(cookies, ctx.inner(), id); | ||||||
|  |  | ||||||
|     match GuildId(id).to_guild_cached(ctx.inner()) { |     match GuildId(id).to_guild_cached(ctx.inner()) { | ||||||
| @@ -97,12 +93,10 @@ pub async fn get_guild_channels( | |||||||
|                 }) |                 }) | ||||||
|                 .collect::<Vec<ChannelInfo>>(); |                 .collect::<Vec<ChannelInfo>>(); | ||||||
|  |  | ||||||
|             json!(channel_info) |             Ok(json!(channel_info)) | ||||||
|         } |         } | ||||||
|  |  | ||||||
|         None => { |         None => json_err!("Bot not in guild"), | ||||||
|             json!({"error": "Bot not in guild"}) |  | ||||||
|         } |  | ||||||
|     } |     } | ||||||
| } | } | ||||||
|  |  | ||||||
| @@ -113,7 +107,7 @@ struct RoleInfo { | |||||||
| } | } | ||||||
|  |  | ||||||
| #[get("/api/guild/<id>/roles")] | #[get("/api/guild/<id>/roles")] | ||||||
| pub async fn get_guild_roles(id: u64, cookies: &CookieJar<'_>, ctx: &State<Context>) -> JsonValue { | pub async fn get_guild_roles(id: u64, cookies: &CookieJar<'_>, ctx: &State<Context>) -> JsonResult { | ||||||
|     check_authorization!(cookies, ctx.inner(), id); |     check_authorization!(cookies, ctx.inner(), id); | ||||||
|  |  | ||||||
|     let roles_res = ctx.cache.guild_roles(id); |     let roles_res = ctx.cache.guild_roles(id); | ||||||
| @@ -125,12 +119,12 @@ pub async fn get_guild_roles(id: u64, cookies: &CookieJar<'_>, ctx: &State<Conte | |||||||
|                 .map(|(_, r)| RoleInfo { id: r.id.to_string(), name: r.name.to_string() }) |                 .map(|(_, r)| RoleInfo { id: r.id.to_string(), name: r.name.to_string() }) | ||||||
|                 .collect::<Vec<RoleInfo>>(); |                 .collect::<Vec<RoleInfo>>(); | ||||||
|  |  | ||||||
|             json!(roles) |             Ok(json!(roles)) | ||||||
|         } |         } | ||||||
|         None => { |         None => { | ||||||
|             warn!("Could not fetch roles from {}", id); |             warn!("Could not fetch roles from {}", id); | ||||||
|  |  | ||||||
|             json!({"error": "Could not get roles"}) |             json_err!("Could not get roles") | ||||||
|         } |         } | ||||||
|     } |     } | ||||||
| } | } | ||||||
| @@ -141,7 +135,7 @@ pub async fn get_reminder_templates( | |||||||
|     cookies: &CookieJar<'_>, |     cookies: &CookieJar<'_>, | ||||||
|     ctx: &State<Context>, |     ctx: &State<Context>, | ||||||
|     pool: &State<Pool<MySql>>, |     pool: &State<Pool<MySql>>, | ||||||
| ) -> JsonValue { | ) -> JsonResult { | ||||||
|     check_authorization!(cookies, ctx.inner(), id); |     check_authorization!(cookies, ctx.inner(), id); | ||||||
|  |  | ||||||
|     match sqlx::query_as_unchecked!( |     match sqlx::query_as_unchecked!( | ||||||
| @@ -152,13 +146,11 @@ pub async fn get_reminder_templates( | |||||||
|     .fetch_all(pool.inner()) |     .fetch_all(pool.inner()) | ||||||
|     .await |     .await | ||||||
|     { |     { | ||||||
|         Ok(templates) => { |         Ok(templates) => Ok(json!(templates)), | ||||||
|             json!(templates) |  | ||||||
|         } |  | ||||||
|         Err(e) => { |         Err(e) => { | ||||||
|             warn!("Could not fetch templates from {}: {:?}", id, e); |             warn!("Could not fetch templates from {}: {:?}", id, e); | ||||||
|  |  | ||||||
|             json!({"error": "Could not get templates"}) |             json_err!("Could not get templates") | ||||||
|         } |         } | ||||||
|     } |     } | ||||||
| } | } | ||||||
| @@ -170,7 +162,7 @@ pub async fn create_reminder_template( | |||||||
|     cookies: &CookieJar<'_>, |     cookies: &CookieJar<'_>, | ||||||
|     ctx: &State<Context>, |     ctx: &State<Context>, | ||||||
|     pool: &State<Pool<MySql>>, |     pool: &State<Pool<MySql>>, | ||||||
| ) -> JsonValue { | ) -> JsonResult { | ||||||
|     check_authorization!(cookies, ctx.inner(), id); |     check_authorization!(cookies, ctx.inner(), id); | ||||||
|  |  | ||||||
|     // validate lengths |     // validate lengths | ||||||
| @@ -254,12 +246,12 @@ pub async fn create_reminder_template( | |||||||
|     .await |     .await | ||||||
|     { |     { | ||||||
|         Ok(_) => { |         Ok(_) => { | ||||||
|             json!({}) |             Ok(json!({})) | ||||||
|         } |         } | ||||||
|         Err(e) => { |         Err(e) => { | ||||||
|             warn!("Could not fetch templates from {}: {:?}", id, e); |             warn!("Could not create template for {}: {:?}", id, e); | ||||||
|  |  | ||||||
|             json!({"error": "Could not get templates"}) |             json_err!("Could not create template") | ||||||
|         } |         } | ||||||
|     } |     } | ||||||
| } | } | ||||||
| @@ -271,7 +263,7 @@ pub async fn delete_reminder_template( | |||||||
|     cookies: &CookieJar<'_>, |     cookies: &CookieJar<'_>, | ||||||
|     ctx: &State<Context>, |     ctx: &State<Context>, | ||||||
|     pool: &State<Pool<MySql>>, |     pool: &State<Pool<MySql>>, | ||||||
| ) -> JsonValue { | ) -> JsonResult { | ||||||
|     check_authorization!(cookies, ctx.inner(), id); |     check_authorization!(cookies, ctx.inner(), id); | ||||||
|  |  | ||||||
|     match sqlx::query!( |     match sqlx::query!( | ||||||
| @@ -282,233 +274,41 @@ pub async fn delete_reminder_template( | |||||||
|     .await |     .await | ||||||
|     { |     { | ||||||
|         Ok(_) => { |         Ok(_) => { | ||||||
|             json!({}) |             Ok(json!({})) | ||||||
|         } |         } | ||||||
|         Err(e) => { |         Err(e) => { | ||||||
|             warn!("Could not delete template from {}: {:?}", id, e); |             warn!("Could not delete template from {}: {:?}", id, e); | ||||||
|  |  | ||||||
|             json!({"error": "Could not delete template"}) |             json_err!("Could not delete template") | ||||||
|         } |         } | ||||||
|     } |     } | ||||||
| } | } | ||||||
|  |  | ||||||
| #[post("/api/guild/<id>/reminders", data = "<reminder>")] | #[post("/api/guild/<id>/reminders", data = "<reminder>")] | ||||||
| pub async fn create_reminder( | pub async fn create_guild_reminder( | ||||||
|     id: u64, |     id: u64, | ||||||
|     reminder: Json<Reminder>, |     reminder: Json<Reminder>, | ||||||
|     cookies: &CookieJar<'_>, |     cookies: &CookieJar<'_>, | ||||||
|     serenity_context: &State<Context>, |     serenity_context: &State<Context>, | ||||||
|     pool: &State<Pool<MySql>>, |     pool: &State<Pool<MySql>>, | ||||||
| ) -> JsonValue { | ) -> JsonResult { | ||||||
|     check_authorization!(cookies, serenity_context.inner(), id); |     check_authorization!(cookies, serenity_context.inner(), id); | ||||||
|  |  | ||||||
|     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(); | ||||||
|  |  | ||||||
|     // validate channel |     create_reminder( | ||||||
|     let channel = ChannelId(reminder.channel).to_channel_cached(&serenity_context.inner()); |  | ||||||
|     let channel_exists = channel.is_some(); |  | ||||||
|  |  | ||||||
|     let channel_matches_guild = |  | ||||||
|         channel.map_or(false, |c| c.guild().map_or(false, |c| c.guild_id.0 == id)); |  | ||||||
|  |  | ||||||
|     if !channel_matches_guild || !channel_exists { |  | ||||||
|         warn!( |  | ||||||
|             "Error in `create_reminder`: channel {} not found for guild {} (channel exists: {})", |  | ||||||
|             reminder.channel, id, channel_exists |  | ||||||
|         ); |  | ||||||
|  |  | ||||||
|         return json!({"error": "Channel not found"}); |  | ||||||
|     } |  | ||||||
|  |  | ||||||
|     let channel = create_database_channel( |  | ||||||
|         serenity_context.inner(), |         serenity_context.inner(), | ||||||
|         ChannelId(reminder.channel), |  | ||||||
|         pool.inner(), |         pool.inner(), | ||||||
|  |         GuildId(id), | ||||||
|  |         UserId(user_id), | ||||||
|  |         reminder.into_inner(), | ||||||
|     ) |     ) | ||||||
|     .await; |  | ||||||
|  |  | ||||||
|     if let Err(e) = channel { |  | ||||||
|         warn!("`create_database_channel` returned an error code: {:?}", e); |  | ||||||
|  |  | ||||||
|         return json!({"error": "Failed to configure channel for reminders. Please check the bot permissions"}); |  | ||||||
|     } |  | ||||||
|  |  | ||||||
|     let channel = channel.unwrap(); |  | ||||||
|  |  | ||||||
|     // validate lengths |  | ||||||
|     check_length!(MAX_CONTENT_LENGTH, reminder.content); |  | ||||||
|     check_length!(MAX_EMBED_DESCRIPTION_LENGTH, reminder.embed_description); |  | ||||||
|     check_length!(MAX_EMBED_TITLE_LENGTH, reminder.embed_title); |  | ||||||
|     check_length!(MAX_EMBED_AUTHOR_LENGTH, reminder.embed_author); |  | ||||||
|     check_length!(MAX_EMBED_FOOTER_LENGTH, reminder.embed_footer); |  | ||||||
|     check_length_opt!(MAX_EMBED_FIELDS, reminder.embed_fields); |  | ||||||
|     if let Some(fields) = &reminder.embed_fields { |  | ||||||
|         for field in &fields.0 { |  | ||||||
|             check_length!(MAX_EMBED_FIELD_VALUE_LENGTH, field.value); |  | ||||||
|             check_length!(MAX_EMBED_FIELD_TITLE_LENGTH, field.title); |  | ||||||
|         } |  | ||||||
|     } |  | ||||||
|     check_length_opt!(MAX_USERNAME_LENGTH, reminder.username); |  | ||||||
|     check_length_opt!( |  | ||||||
|         MAX_URL_LENGTH, |  | ||||||
|         reminder.embed_footer_url, |  | ||||||
|         reminder.embed_thumbnail_url, |  | ||||||
|         reminder.embed_author_url, |  | ||||||
|         reminder.embed_image_url, |  | ||||||
|         reminder.avatar |  | ||||||
|     ); |  | ||||||
|  |  | ||||||
|     // validate urls |  | ||||||
|     check_url_opt!( |  | ||||||
|         reminder.embed_footer_url, |  | ||||||
|         reminder.embed_thumbnail_url, |  | ||||||
|         reminder.embed_author_url, |  | ||||||
|         reminder.embed_image_url, |  | ||||||
|         reminder.avatar |  | ||||||
|     ); |  | ||||||
|  |  | ||||||
|     // validate time and interval |  | ||||||
|     if reminder.utc_time < Utc::now().naive_utc() { |  | ||||||
|         return json!({"error": "Time must be in the future"}); |  | ||||||
|     } |  | ||||||
|     if reminder.interval_seconds.is_some() || reminder.interval_months.is_some() { |  | ||||||
|         if reminder.interval_months.unwrap_or(0) * 30 * DAY as u32 |  | ||||||
|             + reminder.interval_seconds.unwrap_or(0) |  | ||||||
|             < *MIN_INTERVAL |  | ||||||
|         { |  | ||||||
|             return json!({"error": "Interval too short"}); |  | ||||||
|         } |  | ||||||
|     } |  | ||||||
|  |  | ||||||
|     // check patreon if necessary |  | ||||||
|     if reminder.interval_seconds.is_some() || reminder.interval_months.is_some() { |  | ||||||
|         if !check_guild_subscription(serenity_context.inner(), GuildId(id)).await |  | ||||||
|             && !check_subscription(serenity_context.inner(), user_id).await |  | ||||||
|         { |  | ||||||
|             return json!({"error": "Patreon is required to set intervals"}); |  | ||||||
|         } |  | ||||||
|     } |  | ||||||
|  |  | ||||||
|     // base64 decode error dropped here |  | ||||||
|     let attachment_data = reminder.attachment.as_ref().map(|s| base64::decode(s).ok()).flatten(); |  | ||||||
|     let name = if reminder.name.is_empty() { name_default() } else { reminder.name.clone() }; |  | ||||||
|  |  | ||||||
|     let new_uid = generate_uid(); |  | ||||||
|  |  | ||||||
|     // write to db |  | ||||||
|     match sqlx::query!( |  | ||||||
|         "INSERT INTO reminders ( |  | ||||||
|          uid, |  | ||||||
|          attachment, |  | ||||||
|          attachment_name, |  | ||||||
|          channel_id, |  | ||||||
|          avatar, |  | ||||||
|          content, |  | ||||||
|          embed_author, |  | ||||||
|          embed_author_url, |  | ||||||
|          embed_color, |  | ||||||
|          embed_description, |  | ||||||
|          embed_footer, |  | ||||||
|          embed_footer_url, |  | ||||||
|          embed_image_url, |  | ||||||
|          embed_thumbnail_url, |  | ||||||
|          embed_title, |  | ||||||
|          embed_fields, |  | ||||||
|          enabled, |  | ||||||
|          expires, |  | ||||||
|          interval_seconds, |  | ||||||
|          interval_months, |  | ||||||
|          name, |  | ||||||
|          pin, |  | ||||||
|          restartable, |  | ||||||
|          tts, |  | ||||||
|          username, |  | ||||||
|          `utc_time` |  | ||||||
|         ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", |  | ||||||
|         new_uid, |  | ||||||
|         attachment_data, |  | ||||||
|         reminder.attachment_name, |  | ||||||
|         channel, |  | ||||||
|         reminder.avatar, |  | ||||||
|         reminder.content, |  | ||||||
|         reminder.embed_author, |  | ||||||
|         reminder.embed_author_url, |  | ||||||
|         reminder.embed_color, |  | ||||||
|         reminder.embed_description, |  | ||||||
|         reminder.embed_footer, |  | ||||||
|         reminder.embed_footer_url, |  | ||||||
|         reminder.embed_image_url, |  | ||||||
|         reminder.embed_thumbnail_url, |  | ||||||
|         reminder.embed_title, |  | ||||||
|         reminder.embed_fields, |  | ||||||
|         reminder.enabled, |  | ||||||
|         reminder.expires, |  | ||||||
|         reminder.interval_seconds, |  | ||||||
|         reminder.interval_months, |  | ||||||
|         name, |  | ||||||
|         reminder.pin, |  | ||||||
|         reminder.restartable, |  | ||||||
|         reminder.tts, |  | ||||||
|         reminder.username, |  | ||||||
|         reminder.utc_time, |  | ||||||
|     ) |  | ||||||
|     .execute(pool.inner()) |  | ||||||
|     .await |     .await | ||||||
|     { |  | ||||||
|         Ok(_) => sqlx::query_as_unchecked!( |  | ||||||
|             Reminder, |  | ||||||
|             "SELECT |  | ||||||
|              reminders.attachment, |  | ||||||
|              reminders.attachment_name, |  | ||||||
|              reminders.avatar, |  | ||||||
|              channels.channel, |  | ||||||
|              reminders.content, |  | ||||||
|              reminders.embed_author, |  | ||||||
|              reminders.embed_author_url, |  | ||||||
|              reminders.embed_color, |  | ||||||
|              reminders.embed_description, |  | ||||||
|              reminders.embed_footer, |  | ||||||
|              reminders.embed_footer_url, |  | ||||||
|              reminders.embed_image_url, |  | ||||||
|              reminders.embed_thumbnail_url, |  | ||||||
|              reminders.embed_title, |  | ||||||
|              reminders.embed_fields, |  | ||||||
|              reminders.enabled, |  | ||||||
|              reminders.expires, |  | ||||||
|              reminders.interval_seconds, |  | ||||||
|              reminders.interval_months, |  | ||||||
|              reminders.name, |  | ||||||
|              reminders.pin, |  | ||||||
|              reminders.restartable, |  | ||||||
|              reminders.tts, |  | ||||||
|              reminders.uid, |  | ||||||
|              reminders.username, |  | ||||||
|              reminders.utc_time |  | ||||||
|             FROM reminders |  | ||||||
|             LEFT JOIN channels ON channels.id = reminders.channel_id |  | ||||||
|             WHERE uid = ?", |  | ||||||
|             new_uid |  | ||||||
|         ) |  | ||||||
|         .fetch_one(pool.inner()) |  | ||||||
|         .await |  | ||||||
|         .map(|r| json!(r)) |  | ||||||
|         .unwrap_or_else(|e| { |  | ||||||
|             warn!("Failed to complete SQL query: {:?}", e); |  | ||||||
|  |  | ||||||
|             json!({"error": "Could not load reminder"}) |  | ||||||
|         }), |  | ||||||
|  |  | ||||||
|         Err(e) => { |  | ||||||
|             warn!("Error in `create_reminder`: Could not execute query: {:?}", e); |  | ||||||
|  |  | ||||||
|             json!({"error": "Unknown error"}) |  | ||||||
|         } |  | ||||||
|     } |  | ||||||
| } | } | ||||||
|  |  | ||||||
| #[get("/api/guild/<id>/reminders")] | #[get("/api/guild/<id>/reminders")] | ||||||
| pub async fn get_reminders(id: u64, ctx: &State<Context>, pool: &State<Pool<MySql>>) -> JsonValue { | pub async fn get_reminders(id: u64, ctx: &State<Context>, pool: &State<Pool<MySql>>) -> JsonResult { | ||||||
|     let channels_res = GuildId(id).channels(&ctx.inner()).await; |     let channels_res = GuildId(id).channels(&ctx.inner()).await; | ||||||
|  |  | ||||||
|     match channels_res { |     match channels_res { | ||||||
| @@ -541,9 +341,9 @@ pub async fn get_reminders(id: u64, ctx: &State<Context>, pool: &State<Pool<MySq | |||||||
|                  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.pin, |  | ||||||
|                  reminders.restartable, |                  reminders.restartable, | ||||||
|                  reminders.tts, |                  reminders.tts, | ||||||
|                  reminders.uid, |                  reminders.uid, | ||||||
| @@ -556,17 +356,17 @@ pub async fn get_reminders(id: u64, ctx: &State<Context>, pool: &State<Pool<MySq | |||||||
|             ) |             ) | ||||||
|             .fetch_all(pool.inner()) |             .fetch_all(pool.inner()) | ||||||
|             .await |             .await | ||||||
|             .map(|r| json!(r)) |             .map(|r| Ok(json!(r))) | ||||||
|             .unwrap_or_else(|e| { |             .unwrap_or_else(|e| { | ||||||
|                 warn!("Failed to complete SQL query: {:?}", e); |                 warn!("Failed to complete SQL query: {:?}", e); | ||||||
|  |  | ||||||
|                 json!({"error": "Could not load reminders"}) |                 json_err!("Could not load reminders") | ||||||
|             }) |             }) | ||||||
|         } |         } | ||||||
|         Err(e) => { |         Err(e) => { | ||||||
|             warn!("Could not fetch channels from {}: {:?}", id, e); |             warn!("Could not fetch channels from {}: {:?}", id, e); | ||||||
|  |  | ||||||
|             json!([]) |             Ok(json!([])) | ||||||
|         } |         } | ||||||
|     } |     } | ||||||
| } | } | ||||||
| @@ -577,36 +377,109 @@ pub async fn edit_reminder( | |||||||
|     reminder: Json<PatchReminder>, |     reminder: Json<PatchReminder>, | ||||||
|     serenity_context: &State<Context>, |     serenity_context: &State<Context>, | ||||||
|     pool: &State<Pool<MySql>>, |     pool: &State<Pool<MySql>>, | ||||||
| ) -> JsonValue { |     cookies: &CookieJar<'_>, | ||||||
|  | ) -> JsonResult { | ||||||
|  |     check_authorization!(cookies, serenity_context.inner(), id); | ||||||
|  |  | ||||||
|     let mut error = vec![]; |     let mut error = vec![]; | ||||||
|  |  | ||||||
|  |     let user_id = | ||||||
|  |         cookies.get_private("userid").map(|c| c.value().parse::<u64>().ok()).flatten().unwrap(); | ||||||
|  |  | ||||||
|  |     if reminder.message_ok() { | ||||||
|  |         update_field!(pool.inner(), error, reminder.[ | ||||||
|  |             content, | ||||||
|  |             embed_author, | ||||||
|  |             embed_description, | ||||||
|  |             embed_footer, | ||||||
|  |             embed_title, | ||||||
|  |             embed_fields, | ||||||
|  |             username | ||||||
|  |         ]); | ||||||
|  |     } else { | ||||||
|  |         error.push("Message exceeds limits.".to_string()); | ||||||
|  |     } | ||||||
|  |  | ||||||
|     update_field!(pool.inner(), error, reminder.[ |     update_field!(pool.inner(), error, reminder.[ | ||||||
|         attachment, |         attachment, | ||||||
|         attachment_name, |         attachment_name, | ||||||
|         avatar, |         avatar, | ||||||
|         content, |  | ||||||
|         embed_author, |  | ||||||
|         embed_author_url, |         embed_author_url, | ||||||
|         embed_color, |         embed_color, | ||||||
|         embed_description, |  | ||||||
|         embed_footer, |  | ||||||
|         embed_footer_url, |         embed_footer_url, | ||||||
|         embed_image_url, |         embed_image_url, | ||||||
|         embed_thumbnail_url, |         embed_thumbnail_url, | ||||||
|         embed_title, |  | ||||||
|         embed_fields, |  | ||||||
|         enabled, |         enabled, | ||||||
|         expires, |         expires, | ||||||
|         interval_seconds, |  | ||||||
|         interval_months, |  | ||||||
|         name, |         name, | ||||||
|         pin, |  | ||||||
|         restartable, |         restartable, | ||||||
|         tts, |         tts, | ||||||
|         username, |  | ||||||
|         utc_time |         utc_time | ||||||
|     ]); |     ]); | ||||||
|  |  | ||||||
|  |     if reminder.interval_days.flatten().is_some() | ||||||
|  |         || reminder.interval_months.flatten().is_some() | ||||||
|  |         || reminder.interval_seconds.flatten().is_some() | ||||||
|  |     { | ||||||
|  |         if check_guild_subscription(&serenity_context.inner(), id).await | ||||||
|  |             || check_subscription(&serenity_context.inner(), user_id).await | ||||||
|  |         { | ||||||
|  |             let new_interval_length = match reminder.interval_days { | ||||||
|  |                 Some(interval) => interval.unwrap_or(0), | ||||||
|  |                 None => sqlx::query!( | ||||||
|  |                     "SELECT interval_days AS days FROM reminders WHERE uid = ?", | ||||||
|  |                     reminder.uid | ||||||
|  |                 ) | ||||||
|  |                 .fetch_one(pool.inner()) | ||||||
|  |                 .await | ||||||
|  |                 .map_err(|e| { | ||||||
|  |                     warn!("Error updating reminder interval: {:?}", e); | ||||||
|  |                     json!({ "reminder": Option::<Reminder>::None, "errors": vec!["Unknown error"] }) | ||||||
|  |                 })? | ||||||
|  |                 .days | ||||||
|  |                 .unwrap_or(0), | ||||||
|  |             } + match reminder.interval_months { | ||||||
|  |                 Some(interval) => interval.unwrap_or(0), | ||||||
|  |                 None => sqlx::query!( | ||||||
|  |                     "SELECT interval_months AS months FROM reminders WHERE uid = ?", | ||||||
|  |                     reminder.uid | ||||||
|  |                 ) | ||||||
|  |                 .fetch_one(pool.inner()) | ||||||
|  |                 .await | ||||||
|  |                 .map_err(|e| { | ||||||
|  |                     warn!("Error updating reminder interval: {:?}", e); | ||||||
|  |                     json!({ "reminder": Option::<Reminder>::None, "errors": vec!["Unknown error"] }) | ||||||
|  |                 })? | ||||||
|  |                 .months | ||||||
|  |                 .unwrap_or(0), | ||||||
|  |             } + match reminder.interval_seconds { | ||||||
|  |                 Some(interval) => interval.unwrap_or(0), | ||||||
|  |                 None => sqlx::query!( | ||||||
|  |                     "SELECT interval_seconds AS seconds FROM reminders WHERE uid = ?", | ||||||
|  |                     reminder.uid | ||||||
|  |                 ) | ||||||
|  |                 .fetch_one(pool.inner()) | ||||||
|  |                 .await | ||||||
|  |                 .map_err(|e| { | ||||||
|  |                     warn!("Error updating reminder interval: {:?}", e); | ||||||
|  |                     json!({ "reminder": Option::<Reminder>::None, "errors": vec!["Unknown error"] }) | ||||||
|  |                 })? | ||||||
|  |                 .seconds | ||||||
|  |                 .unwrap_or(0), | ||||||
|  |             }; | ||||||
|  |  | ||||||
|  |             if new_interval_length < *MIN_INTERVAL { | ||||||
|  |                 error.push(String::from("New interval is too short.")); | ||||||
|  |             } else { | ||||||
|  |                 update_field!(pool.inner(), error, reminder.[ | ||||||
|  |                     interval_days, | ||||||
|  |                     interval_months, | ||||||
|  |                     interval_seconds | ||||||
|  |                 ]); | ||||||
|  |             } | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  |  | ||||||
|     if reminder.channel > 0 { |     if reminder.channel > 0 { | ||||||
|         let channel = ChannelId(reminder.channel).to_channel_cached(&serenity_context.inner()); |         let channel = ChannelId(reminder.channel).to_channel_cached(&serenity_context.inner()); | ||||||
|         match channel { |         match channel { | ||||||
| @@ -619,7 +492,7 @@ pub async fn edit_reminder( | |||||||
|                         reminder.channel, id |                         reminder.channel, id | ||||||
|                     ); |                     ); | ||||||
|  |  | ||||||
|                     return json!({"error": "Channel not found"}); |                     return Err(json!({"error": "Channel not found"})); | ||||||
|                 } |                 } | ||||||
|  |  | ||||||
|                 let channel = create_database_channel( |                 let channel = create_database_channel( | ||||||
| @@ -632,7 +505,9 @@ pub async fn edit_reminder( | |||||||
|                 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); | ||||||
|  |  | ||||||
|                     return json!({"error": "Failed to configure channel for reminders. Please check the bot permissions"}); |                     return Err( | ||||||
|  |                         json!({"error": "Failed to configure channel for reminders. Please check the bot permissions"}), | ||||||
|  |                     ); | ||||||
|                 } |                 } | ||||||
|  |  | ||||||
|                 let channel = channel.unwrap(); |                 let channel = channel.unwrap(); | ||||||
| @@ -660,7 +535,7 @@ pub async fn edit_reminder( | |||||||
|                     reminder.channel, id |                     reminder.channel, id | ||||||
|                 ); |                 ); | ||||||
|  |  | ||||||
|                 return json!({"error": "Channel not found"}); |                 return Err(json!({"error": "Channel not found"})); | ||||||
|             } |             } | ||||||
|         } |         } | ||||||
|     } |     } | ||||||
| @@ -685,9 +560,9 @@ pub async fn edit_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.pin, |  | ||||||
|          reminders.restartable, |          reminders.restartable, | ||||||
|          reminders.tts, |          reminders.tts, | ||||||
|          reminders.uid, |          reminders.uid, | ||||||
| @@ -701,12 +576,12 @@ pub async fn edit_reminder( | |||||||
|     .fetch_one(pool.inner()) |     .fetch_one(pool.inner()) | ||||||
|     .await |     .await | ||||||
|     { |     { | ||||||
|         Ok(reminder) => json!({"reminder": reminder, "errors": error}), |         Ok(reminder) => Ok(json!({"reminder": reminder, "errors": error})), | ||||||
|  |  | ||||||
|         Err(e) => { |         Err(e) => { | ||||||
|             warn!("Error exiting `edit_reminder': {:?}", e); |             warn!("Error exiting `edit_reminder': {:?}", e); | ||||||
|  |  | ||||||
|             json!({"reminder": Option::<Reminder>::None, "errors": vec!["Unknown error"]}) |             Err(json!({"reminder": Option::<Reminder>::None, "errors": vec!["Unknown error"]})) | ||||||
|         } |         } | ||||||
|     } |     } | ||||||
| } | } | ||||||
| @@ -715,19 +590,17 @@ pub async fn edit_reminder( | |||||||
| pub async fn delete_reminder( | pub async fn delete_reminder( | ||||||
|     reminder: Json<DeleteReminder>, |     reminder: Json<DeleteReminder>, | ||||||
|     pool: &State<Pool<MySql>>, |     pool: &State<Pool<MySql>>, | ||||||
| ) -> JsonValue { | ) -> JsonResult { | ||||||
|     match sqlx::query!("DELETE FROM reminders WHERE uid = ?", reminder.uid) |     match sqlx::query!("DELETE FROM reminders WHERE uid = ?", reminder.uid) | ||||||
|         .execute(pool.inner()) |         .execute(pool.inner()) | ||||||
|         .await |         .await | ||||||
|     { |     { | ||||||
|         Ok(_) => { |         Ok(_) => Ok(json!({})), | ||||||
|             json!({}) |  | ||||||
|         } |  | ||||||
|  |  | ||||||
|         Err(e) => { |         Err(e) => { | ||||||
|             warn!("Error in `delete_reminder`: {:?}", e); |             warn!("Error in `delete_reminder`: {:?}", e); | ||||||
|  |  | ||||||
|             json!({"error": "Could not delete reminder"}) |             Err(json!({"error": "Could not delete reminder"})) | ||||||
|         } |         } | ||||||
|     } |     } | ||||||
| } | } | ||||||
|   | |||||||
| @@ -1,21 +1,37 @@ | |||||||
| use std::collections::HashMap; | use std::collections::HashMap; | ||||||
|  |  | ||||||
| use chrono::naive::NaiveDateTime; | use chrono::{naive::NaiveDateTime, Utc}; | ||||||
| use rand::{rngs::OsRng, seq::IteratorRandom}; | use rand::{rngs::OsRng, seq::IteratorRandom}; | ||||||
| use rocket::{http::CookieJar, response::Redirect}; | use rocket::{ | ||||||
|  |     http::CookieJar, | ||||||
|  |     response::Redirect, | ||||||
|  |     serde::json::{json, Value as JsonValue}, | ||||||
|  | }; | ||||||
| use rocket_dyn_templates::Template; | use rocket_dyn_templates::Template; | ||||||
| use serde::{Deserialize, Serialize}; | use serde::{Deserialize, Deserializer, Serialize}; | ||||||
| use serenity::{http::Http, model::id::ChannelId}; | use serenity::{ | ||||||
|  |     client::Context, | ||||||
|  |     http::Http, | ||||||
|  |     model::id::{ChannelId, GuildId, UserId}, | ||||||
|  | }; | ||||||
| use sqlx::{types::Json, Executor}; | use sqlx::{types::Json, Executor}; | ||||||
|  |  | ||||||
| use crate::{ | use crate::{ | ||||||
|     consts::{CHARACTERS, DEFAULT_AVATAR}, |     check_guild_subscription, check_subscription, | ||||||
|  |     consts::{ | ||||||
|  |         CHARACTERS, DAY, DEFAULT_AVATAR, MAX_CONTENT_LENGTH, MAX_EMBED_AUTHOR_LENGTH, | ||||||
|  |         MAX_EMBED_DESCRIPTION_LENGTH, MAX_EMBED_FIELDS, MAX_EMBED_FIELD_TITLE_LENGTH, | ||||||
|  |         MAX_EMBED_FIELD_VALUE_LENGTH, MAX_EMBED_FOOTER_LENGTH, MAX_EMBED_TITLE_LENGTH, | ||||||
|  |         MAX_URL_LENGTH, MAX_USERNAME_LENGTH, MIN_INTERVAL, | ||||||
|  |     }, | ||||||
|     Database, Error, |     Database, Error, | ||||||
| }; | }; | ||||||
|  |  | ||||||
|  | pub mod export; | ||||||
| pub mod guild; | pub mod guild; | ||||||
| pub mod user; | 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 { | ||||||
| @@ -34,6 +50,18 @@ fn id_default() -> u32 { | |||||||
|     0 |     0 | ||||||
| } | } | ||||||
|  |  | ||||||
|  | fn interval_default() -> Unset<Option<u32>> { | ||||||
|  |     None | ||||||
|  | } | ||||||
|  |  | ||||||
|  | fn deserialize_optional_field<'de, T, D>(deserializer: D) -> Result<Option<Option<T>>, D::Error> | ||||||
|  | where | ||||||
|  |     D: Deserializer<'de>, | ||||||
|  |     T: Deserialize<'de>, | ||||||
|  | { | ||||||
|  |     Ok(Some(Option::deserialize(deserializer)?)) | ||||||
|  | } | ||||||
|  |  | ||||||
| #[derive(Serialize, Deserialize)] | #[derive(Serialize, Deserialize)] | ||||||
| pub struct ReminderTemplate { | pub struct ReminderTemplate { | ||||||
|     #[serde(default = "id_default")] |     #[serde(default = "id_default")] | ||||||
| @@ -60,6 +88,28 @@ pub struct ReminderTemplate { | |||||||
|     username: Option<String>, |     username: Option<String>, | ||||||
| } | } | ||||||
|  |  | ||||||
|  | #[derive(Serialize, Deserialize)] | ||||||
|  | pub struct ReminderTemplateCsv { | ||||||
|  |     #[serde(default = "template_name_default")] | ||||||
|  |     name: String, | ||||||
|  |     attachment: Option<Vec<u8>>, | ||||||
|  |     attachment_name: Option<String>, | ||||||
|  |     avatar: Option<String>, | ||||||
|  |     content: String, | ||||||
|  |     embed_author: String, | ||||||
|  |     embed_author_url: Option<String>, | ||||||
|  |     embed_color: u32, | ||||||
|  |     embed_description: String, | ||||||
|  |     embed_footer: String, | ||||||
|  |     embed_footer_url: Option<String>, | ||||||
|  |     embed_image_url: Option<String>, | ||||||
|  |     embed_thumbnail_url: Option<String>, | ||||||
|  |     embed_title: String, | ||||||
|  |     embed_fields: Option<String>, | ||||||
|  |     tts: bool, | ||||||
|  |     username: Option<String>, | ||||||
|  | } | ||||||
|  |  | ||||||
| #[derive(Deserialize)] | #[derive(Deserialize)] | ||||||
| pub struct DeleteReminderTemplate { | pub struct DeleteReminderTemplate { | ||||||
|     id: u32, |     id: u32, | ||||||
| @@ -94,10 +144,10 @@ 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, | ||||||
|     pin: bool, |  | ||||||
|     restartable: bool, |     restartable: bool, | ||||||
|     tts: bool, |     tts: bool, | ||||||
|     #[serde(default)] |     #[serde(default)] | ||||||
| @@ -106,14 +156,48 @@ pub struct Reminder { | |||||||
|     utc_time: NaiveDateTime, |     utc_time: NaiveDateTime, | ||||||
| } | } | ||||||
|  |  | ||||||
|  | #[derive(Serialize, Deserialize)] | ||||||
|  | pub struct ReminderCsv { | ||||||
|  |     #[serde(with = "base64s")] | ||||||
|  |     attachment: Option<Vec<u8>>, | ||||||
|  |     attachment_name: Option<String>, | ||||||
|  |     avatar: Option<String>, | ||||||
|  |     channel: String, | ||||||
|  |     content: String, | ||||||
|  |     embed_author: String, | ||||||
|  |     embed_author_url: Option<String>, | ||||||
|  |     embed_color: u32, | ||||||
|  |     embed_description: String, | ||||||
|  |     embed_footer: String, | ||||||
|  |     embed_footer_url: Option<String>, | ||||||
|  |     embed_image_url: Option<String>, | ||||||
|  |     embed_thumbnail_url: Option<String>, | ||||||
|  |     embed_title: String, | ||||||
|  |     embed_fields: Option<String>, | ||||||
|  |     enabled: bool, | ||||||
|  |     expires: Option<NaiveDateTime>, | ||||||
|  |     interval_seconds: Option<u32>, | ||||||
|  |     interval_days: Option<u32>, | ||||||
|  |     interval_months: Option<u32>, | ||||||
|  |     #[serde(default = "name_default")] | ||||||
|  |     name: String, | ||||||
|  |     restartable: bool, | ||||||
|  |     tts: bool, | ||||||
|  |     username: Option<String>, | ||||||
|  |     utc_time: NaiveDateTime, | ||||||
|  | } | ||||||
|  |  | ||||||
| #[derive(Deserialize)] | #[derive(Deserialize)] | ||||||
| pub struct PatchReminder { | pub struct PatchReminder { | ||||||
|     uid: String, |     uid: String, | ||||||
|     #[serde(default)] |     #[serde(default)] | ||||||
|  |     #[serde(deserialize_with = "deserialize_optional_field")] | ||||||
|     attachment: Unset<Option<String>>, |     attachment: Unset<Option<String>>, | ||||||
|     #[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")] | ||||||
| @@ -123,6 +207,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>, | ||||||
| @@ -131,10 +216,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>, | ||||||
| @@ -143,25 +231,54 @@ 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>, | ||||||
|     #[serde(default)] |     #[serde(default)] | ||||||
|     pin: Unset<bool>, |  | ||||||
|     #[serde(default)] |  | ||||||
|     restartable: Unset<bool>, |     restartable: Unset<bool>, | ||||||
|     #[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(); | ||||||
|  |  | ||||||
| @@ -213,8 +330,8 @@ mod base64s { | |||||||
|     where |     where | ||||||
|         D: Deserializer<'de>, |         D: Deserializer<'de>, | ||||||
|     { |     { | ||||||
|         let string = String::deserialize(deserializer)?; |         let string = Option::<String>::deserialize(deserializer)?; | ||||||
|         Some(base64::decode(string).map_err(de::Error::custom)).transpose() |         Some(string.map(|b| base64::decode(b).map_err(de::Error::custom))).flatten().transpose() | ||||||
|     } |     } | ||||||
| } | } | ||||||
|  |  | ||||||
| @@ -223,13 +340,257 @@ pub struct DeleteReminder { | |||||||
|     uid: String, |     uid: String, | ||||||
| } | } | ||||||
|  |  | ||||||
|  | #[derive(Deserialize)] | ||||||
|  | pub struct ImportBody { | ||||||
|  |     body: String, | ||||||
|  | } | ||||||
|  |  | ||||||
|  | #[derive(Serialize, Deserialize)] | ||||||
|  | pub struct TodoCsv { | ||||||
|  |     value: String, | ||||||
|  |     channel_id: Option<String>, | ||||||
|  | } | ||||||
|  |  | ||||||
|  | pub async fn create_reminder( | ||||||
|  |     ctx: &Context, | ||||||
|  |     pool: impl sqlx::Executor<'_, Database = Database> + Copy, | ||||||
|  |     guild_id: GuildId, | ||||||
|  |     user_id: UserId, | ||||||
|  |     reminder: Reminder, | ||||||
|  | ) -> JsonResult { | ||||||
|  |     // check guild in db | ||||||
|  |     match sqlx::query!("SELECT 1 as A FROM guilds WHERE guild = ?", guild_id.0) | ||||||
|  |         .fetch_one(pool) | ||||||
|  |         .await | ||||||
|  |     { | ||||||
|  |         Err(sqlx::Error::RowNotFound) => { | ||||||
|  |             if sqlx::query!("INSERT INTO guilds (guild) VALUES (?)", guild_id.0) | ||||||
|  |                 .execute(pool) | ||||||
|  |                 .await | ||||||
|  |                 .is_err() | ||||||
|  |             { | ||||||
|  |                 return Err(json!({"error": "Guild could not be created"})); | ||||||
|  |             } | ||||||
|  |         } | ||||||
|  |         _ => {} | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     // validate channel | ||||||
|  |     let channel = ChannelId(reminder.channel).to_channel_cached(&ctx); | ||||||
|  |     let channel_exists = channel.is_some(); | ||||||
|  |  | ||||||
|  |     let channel_matches_guild = | ||||||
|  |         channel.map_or(false, |c| c.guild().map_or(false, |c| c.guild_id == guild_id)); | ||||||
|  |  | ||||||
|  |     if !channel_matches_guild || !channel_exists { | ||||||
|  |         warn!( | ||||||
|  |             "Error in `create_reminder`: channel {} not found for guild {} (channel exists: {})", | ||||||
|  |             reminder.channel, guild_id, channel_exists | ||||||
|  |         ); | ||||||
|  |  | ||||||
|  |         return Err(json!({"error": "Channel not found"})); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     let channel = create_database_channel(&ctx, ChannelId(reminder.channel), pool).await; | ||||||
|  |  | ||||||
|  |     if let Err(e) = channel { | ||||||
|  |         warn!("`create_database_channel` returned an error code: {:?}", e); | ||||||
|  |  | ||||||
|  |         return Err( | ||||||
|  |             json!({"error": "Failed to configure channel for reminders. Please check the bot permissions"}), | ||||||
|  |         ); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     let channel = channel.unwrap(); | ||||||
|  |  | ||||||
|  |     // validate lengths | ||||||
|  |     check_length!(MAX_CONTENT_LENGTH, reminder.content); | ||||||
|  |     check_length!(MAX_EMBED_DESCRIPTION_LENGTH, reminder.embed_description); | ||||||
|  |     check_length!(MAX_EMBED_TITLE_LENGTH, reminder.embed_title); | ||||||
|  |     check_length!(MAX_EMBED_AUTHOR_LENGTH, reminder.embed_author); | ||||||
|  |     check_length!(MAX_EMBED_FOOTER_LENGTH, reminder.embed_footer); | ||||||
|  |     check_length_opt!(MAX_EMBED_FIELDS, reminder.embed_fields); | ||||||
|  |     if let Some(fields) = &reminder.embed_fields { | ||||||
|  |         for field in &fields.0 { | ||||||
|  |             check_length!(MAX_EMBED_FIELD_VALUE_LENGTH, field.value); | ||||||
|  |             check_length!(MAX_EMBED_FIELD_TITLE_LENGTH, field.title); | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  |     check_length_opt!(MAX_USERNAME_LENGTH, reminder.username); | ||||||
|  |     check_length_opt!( | ||||||
|  |         MAX_URL_LENGTH, | ||||||
|  |         reminder.embed_footer_url, | ||||||
|  |         reminder.embed_thumbnail_url, | ||||||
|  |         reminder.embed_author_url, | ||||||
|  |         reminder.embed_image_url, | ||||||
|  |         reminder.avatar | ||||||
|  |     ); | ||||||
|  |  | ||||||
|  |     // validate urls | ||||||
|  |     check_url_opt!( | ||||||
|  |         reminder.embed_footer_url, | ||||||
|  |         reminder.embed_thumbnail_url, | ||||||
|  |         reminder.embed_author_url, | ||||||
|  |         reminder.embed_image_url, | ||||||
|  |         reminder.avatar | ||||||
|  |     ); | ||||||
|  |  | ||||||
|  |     // validate time and interval | ||||||
|  |     if reminder.utc_time < Utc::now().naive_utc() { | ||||||
|  |         return Err(json!({"error": "Time must be in the future"})); | ||||||
|  |     } | ||||||
|  |     if reminder.interval_seconds.is_some() | ||||||
|  |         || reminder.interval_days.is_some() | ||||||
|  |         || reminder.interval_months.is_some() | ||||||
|  |     { | ||||||
|  |         if reminder.interval_months.unwrap_or(0) * 30 * DAY as u32 | ||||||
|  |             + reminder.interval_days.unwrap_or(0) * DAY as u32 | ||||||
|  |             + reminder.interval_seconds.unwrap_or(0) | ||||||
|  |             < *MIN_INTERVAL | ||||||
|  |         { | ||||||
|  |             return Err(json!({"error": "Interval too short"})); | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     // check patreon if necessary | ||||||
|  |     if reminder.interval_seconds.is_some() | ||||||
|  |         || reminder.interval_days.is_some() | ||||||
|  |         || reminder.interval_months.is_some() | ||||||
|  |     { | ||||||
|  |         if !check_guild_subscription(&ctx, guild_id).await | ||||||
|  |             && !check_subscription(&ctx, user_id).await | ||||||
|  |         { | ||||||
|  |             return Err(json!({"error": "Patreon is required to set intervals"})); | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     // base64 decode error dropped here | ||||||
|  |     let attachment_data = reminder.attachment.as_ref().map(|s| base64::decode(s).ok()).flatten(); | ||||||
|  |     let name = if reminder.name.is_empty() { name_default() } else { reminder.name.clone() }; | ||||||
|  |     let username = if reminder.username.as_ref().map(|s| s.is_empty()).unwrap_or(true) { | ||||||
|  |         None | ||||||
|  |     } else { | ||||||
|  |         reminder.username | ||||||
|  |     }; | ||||||
|  |  | ||||||
|  |     let new_uid = generate_uid(); | ||||||
|  |  | ||||||
|  |     // write to db | ||||||
|  |     match sqlx::query!( | ||||||
|  |         "INSERT INTO reminders ( | ||||||
|  |          uid, | ||||||
|  |          attachment, | ||||||
|  |          attachment_name, | ||||||
|  |          channel_id, | ||||||
|  |          avatar, | ||||||
|  |          content, | ||||||
|  |          embed_author, | ||||||
|  |          embed_author_url, | ||||||
|  |          embed_color, | ||||||
|  |          embed_description, | ||||||
|  |          embed_footer, | ||||||
|  |          embed_footer_url, | ||||||
|  |          embed_image_url, | ||||||
|  |          embed_thumbnail_url, | ||||||
|  |          embed_title, | ||||||
|  |          embed_fields, | ||||||
|  |          enabled, | ||||||
|  |          expires, | ||||||
|  |          interval_seconds, | ||||||
|  |          interval_days, | ||||||
|  |          interval_months, | ||||||
|  |          name, | ||||||
|  |          restartable, | ||||||
|  |          tts, | ||||||
|  |          username, | ||||||
|  |          `utc_time` | ||||||
|  |         ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", | ||||||
|  |         new_uid, | ||||||
|  |         attachment_data, | ||||||
|  |         reminder.attachment_name, | ||||||
|  |         channel, | ||||||
|  |         reminder.avatar, | ||||||
|  |         reminder.content, | ||||||
|  |         reminder.embed_author, | ||||||
|  |         reminder.embed_author_url, | ||||||
|  |         reminder.embed_color, | ||||||
|  |         reminder.embed_description, | ||||||
|  |         reminder.embed_footer, | ||||||
|  |         reminder.embed_footer_url, | ||||||
|  |         reminder.embed_image_url, | ||||||
|  |         reminder.embed_thumbnail_url, | ||||||
|  |         reminder.embed_title, | ||||||
|  |         reminder.embed_fields, | ||||||
|  |         reminder.enabled, | ||||||
|  |         reminder.expires, | ||||||
|  |         reminder.interval_seconds, | ||||||
|  |         reminder.interval_days, | ||||||
|  |         reminder.interval_months, | ||||||
|  |         name, | ||||||
|  |         reminder.restartable, | ||||||
|  |         reminder.tts, | ||||||
|  |         username, | ||||||
|  |         reminder.utc_time, | ||||||
|  |     ) | ||||||
|  |     .execute(pool) | ||||||
|  |     .await | ||||||
|  |     { | ||||||
|  |         Ok(_) => sqlx::query_as_unchecked!( | ||||||
|  |             Reminder, | ||||||
|  |             "SELECT | ||||||
|  |              reminders.attachment, | ||||||
|  |              reminders.attachment_name, | ||||||
|  |              reminders.avatar, | ||||||
|  |              channels.channel, | ||||||
|  |              reminders.content, | ||||||
|  |              reminders.embed_author, | ||||||
|  |              reminders.embed_author_url, | ||||||
|  |              reminders.embed_color, | ||||||
|  |              reminders.embed_description, | ||||||
|  |              reminders.embed_footer, | ||||||
|  |              reminders.embed_footer_url, | ||||||
|  |              reminders.embed_image_url, | ||||||
|  |              reminders.embed_thumbnail_url, | ||||||
|  |              reminders.embed_title, | ||||||
|  |              reminders.embed_fields, | ||||||
|  |              reminders.enabled, | ||||||
|  |              reminders.expires, | ||||||
|  |              reminders.interval_seconds, | ||||||
|  |              reminders.interval_days, | ||||||
|  |              reminders.interval_months, | ||||||
|  |              reminders.name, | ||||||
|  |              reminders.restartable, | ||||||
|  |              reminders.tts, | ||||||
|  |              reminders.uid, | ||||||
|  |              reminders.username, | ||||||
|  |              reminders.utc_time | ||||||
|  |             FROM reminders | ||||||
|  |             LEFT JOIN channels ON channels.id = reminders.channel_id | ||||||
|  |             WHERE uid = ?", | ||||||
|  |             new_uid | ||||||
|  |         ) | ||||||
|  |         .fetch_one(pool) | ||||||
|  |         .await | ||||||
|  |         .map(|r| Ok(json!(r))) | ||||||
|  |         .unwrap_or_else(|e| { | ||||||
|  |             warn!("Failed to complete SQL query: {:?}", e); | ||||||
|  |  | ||||||
|  |             Err(json!({"error": "Could not load reminder"})) | ||||||
|  |         }), | ||||||
|  |  | ||||||
|  |         Err(e) => { | ||||||
|  |             warn!("Error in `create_reminder`: Could not execute query: {:?}", e); | ||||||
|  |  | ||||||
|  |             Err(json!({"error": "Unknown error"})) | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  | } | ||||||
|  |  | ||||||
| async fn create_database_channel( | async fn create_database_channel( | ||||||
|     ctx: impl AsRef<Http>, |     ctx: impl AsRef<Http>, | ||||||
|     channel: ChannelId, |     channel: ChannelId, | ||||||
|     pool: impl Executor<'_, Database = Database> + Copy, |     pool: impl Executor<'_, Database = Database> + Copy, | ||||||
| ) -> Result<u32, crate::Error> { | ) -> Result<u32, crate::Error> { | ||||||
|     println!("{:?}", channel); |  | ||||||
|  |  | ||||||
|     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(pool) | ||||||
|   | |||||||
| @@ -61,7 +61,10 @@ pub async fn get_user_info( | |||||||
|             .member(&ctx.inner(), user_id) |             .member(&ctx.inner(), user_id) | ||||||
|             .await; |             .await; | ||||||
|  |  | ||||||
|         let timezone = sqlx::query!("SELECT timezone FROM users WHERE user = ?", user_id) |         let timezone = sqlx::query!( | ||||||
|  |             "SELECT IFNULL(timezone, 'UTC') AS timezone FROM users WHERE user = ?", | ||||||
|  |             user_id | ||||||
|  |         ) | ||||||
|         .fetch_one(pool.inner()) |         .fetch_one(pool.inner()) | ||||||
|         .await |         .await | ||||||
|         .map_or(None, |q| Some(q.timezone)); |         .map_or(None, |q| Some(q.timezone)); | ||||||
|   | |||||||
| @@ -25,7 +25,6 @@ pub async fn discord_login( | |||||||
|         // Set the desired scopes. |         // Set the desired scopes. | ||||||
|         .add_scope(Scope::new("identify".to_string())) |         .add_scope(Scope::new("identify".to_string())) | ||||||
|         .add_scope(Scope::new("guilds".to_string())) |         .add_scope(Scope::new("guilds".to_string())) | ||||||
|         .add_scope(Scope::new("email".to_string())) |  | ||||||
|         // Set the PKCE code challenge. |         // Set the PKCE code challenge. | ||||||
|         .set_pkce_challenge(pkce_challenge) |         .set_pkce_challenge(pkce_challenge) | ||||||
|         .url(); |         .url(); | ||||||
| @@ -136,14 +135,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)")) | ||||||
|     } |     } | ||||||
| } | } | ||||||
|   | |||||||
| @@ -86,3 +86,21 @@ pub async fn help_macros() -> Template { | |||||||
|     let map: HashMap<&str, String> = HashMap::new(); |     let map: HashMap<&str, String> = HashMap::new(); | ||||||
|     Template::render("support/macros", &map) |     Template::render("support/macros", &map) | ||||||
| } | } | ||||||
|  |  | ||||||
|  | #[get("/intervals")] | ||||||
|  | pub async fn help_intervals() -> Template { | ||||||
|  |     let map: HashMap<&str, String> = HashMap::new(); | ||||||
|  |     Template::render("support/intervals", &map) | ||||||
|  | } | ||||||
|  |  | ||||||
|  | #[get("/dashboard")] | ||||||
|  | pub async fn help_dashboard() -> Template { | ||||||
|  |     let map: HashMap<&str, String> = HashMap::new(); | ||||||
|  |     Template::render("support/dashboard", &map) | ||||||
|  | } | ||||||
|  |  | ||||||
|  | #[get("/iemanager")] | ||||||
|  | pub async fn help_iemanager() -> Template { | ||||||
|  |     let map: HashMap<&str, String> = HashMap::new(); | ||||||
|  |     Template::render("support/iemanager", &map) | ||||||
|  | } | ||||||
|   | |||||||
| @@ -288,6 +288,10 @@ textarea, input { | |||||||
|     width: 100%; |     width: 100%; | ||||||
| } | } | ||||||
|  |  | ||||||
|  | input.default-width { | ||||||
|  |     width: initial; | ||||||
|  | } | ||||||
|  |  | ||||||
| .message-input:placeholder-shown { | .message-input:placeholder-shown { | ||||||
|     border-top: none; |     border-top: none; | ||||||
|     border-left: none; |     border-left: none; | ||||||
|   | |||||||
							
								
								
									
										
											BIN
										
									
								
								web/static/img/support/delete_reminder/cancel-1.png
									
									
									
									
									
										Normal file
									
								
							
							
						
						| After Width: | Height: | Size: 17 KiB | 
							
								
								
									
										
											BIN
										
									
								
								web/static/img/support/delete_reminder/cancel-2.png
									
									
									
									
									
										Normal file
									
								
							
							
						
						| After Width: | Height: | Size: 14 KiB | 
							
								
								
									
										
											BIN
										
									
								
								web/static/img/support/iemanager/edit_spreadsheet.png
									
									
									
									
									
										Normal file
									
								
							
							
						
						| After Width: | Height: | Size: 44 KiB | 
							
								
								
									
										
											BIN
										
									
								
								web/static/img/support/iemanager/format_text.png
									
									
									
									
									
										Normal file
									
								
							
							
						
						| After Width: | Height: | Size: 40 KiB | 
							
								
								
									
										
											BIN
										
									
								
								web/static/img/support/iemanager/import.png
									
									
									
									
									
										Normal file
									
								
							
							
						
						| After Width: | Height: | Size: 44 KiB | 
							
								
								
									
										
											BIN
										
									
								
								web/static/img/support/iemanager/select_export.png
									
									
									
									
									
										Normal file
									
								
							
							
						
						| After Width: | Height: | Size: 18 KiB | 
							
								
								
									
										
											BIN
										
									
								
								web/static/img/support/iemanager/sheets_settings.png
									
									
									
									
									
										Normal file
									
								
							
							
						
						| After Width: | Height: | Size: 20 KiB | 
| @@ -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); |  | ||||||
|     } |     } | ||||||
| } | } | ||||||
|  |  | ||||||
|   | |||||||
| @@ -12,12 +12,25 @@ const $createTemplateBtn = $createReminder.querySelector("button#createTemplate" | |||||||
| const $loadTemplateBtn = document.querySelector("button#load-template"); | const $loadTemplateBtn = document.querySelector("button#load-template"); | ||||||
| const $deleteTemplateBtn = document.querySelector("button#delete-template"); | const $deleteTemplateBtn = document.querySelector("button#delete-template"); | ||||||
| const $templateSelect = document.querySelector("select#templateSelect"); | const $templateSelect = document.querySelector("select#templateSelect"); | ||||||
|  | const $exportBtn = document.querySelector("button#export-data"); | ||||||
|  | const $importBtn = document.querySelector("button#import-data"); | ||||||
|  | const $downloader = document.querySelector("a#downloader"); | ||||||
|  | const $uploader = document.querySelector("input#uploader"); | ||||||
|  |  | ||||||
| let channels = []; | let channels = []; | ||||||
|  | let guildNames = {}; | ||||||
| let roles = []; | let roles = []; | ||||||
| let templates = {}; | let templates = {}; | ||||||
|  | let mentions = new Tribute({ | ||||||
|  |     values: [], | ||||||
|  |     allowSpaces: true, | ||||||
|  |     selectTemplate: (item) => { | ||||||
|  |         return `<@&${item.original.value}>`; | ||||||
|  |     }, | ||||||
|  | }); | ||||||
|  |  | ||||||
| let globalPatreon = false; | let globalPatreon = false; | ||||||
|  | let guildPatreon = false; | ||||||
|  |  | ||||||
| function guildId() { | function guildId() { | ||||||
|     return document.querySelector(".guildList a.is-active").dataset["guild"]; |     return document.querySelector(".guildList a.is-active").dataset["guild"]; | ||||||
| @@ -31,18 +44,6 @@ function intToColor(i) { | |||||||
|     return `#${i.toString(16).padStart(6, "0")}`; |     return `#${i.toString(16).padStart(6, "0")}`; | ||||||
| } | } | ||||||
|  |  | ||||||
| function resize_textareas() { |  | ||||||
|     document.querySelectorAll("textarea.autoresize").forEach((element) => { |  | ||||||
|         element.style.height = ""; |  | ||||||
|         element.style.height = element.scrollHeight + 3 + "px"; |  | ||||||
|  |  | ||||||
|         element.addEventListener("input", () => { |  | ||||||
|             element.style.height = ""; |  | ||||||
|             element.style.height = element.scrollHeight + 3 + "px"; |  | ||||||
|         }); |  | ||||||
|     }); |  | ||||||
| } |  | ||||||
|  |  | ||||||
| function switch_pane(selector) { | function switch_pane(selector) { | ||||||
|     document.querySelectorAll("aside a").forEach((el) => { |     document.querySelectorAll("aside a").forEach((el) => { | ||||||
|         el.classList.remove("is-active"); |         el.classList.remove("is-active"); | ||||||
| @@ -52,8 +53,6 @@ function switch_pane(selector) { | |||||||
|     }); |     }); | ||||||
|  |  | ||||||
|     document.getElementById(selector).classList.remove("is-hidden"); |     document.getElementById(selector).classList.remove("is-hidden"); | ||||||
|  |  | ||||||
|     resize_textareas(); |  | ||||||
| } | } | ||||||
|  |  | ||||||
| function update_select(sel) { | function update_select(sel) { | ||||||
| @@ -61,14 +60,15 @@ function update_select(sel) { | |||||||
|         sel.closest("div.reminderContent").querySelector("img.discord-avatar").src = |         sel.closest("div.reminderContent").querySelector("img.discord-avatar").src = | ||||||
|             sel.selectedOptions[0].dataset["webhookAvatar"]; |             sel.selectedOptions[0].dataset["webhookAvatar"]; | ||||||
|     } else { |     } else { | ||||||
|         sel.closest("div.reminderContent").querySelector("img.discord-avatar").src = ""; |         sel.closest("div.reminderContent").querySelector("img.discord-avatar").src = | ||||||
|  |             "/static/img/icon.png"; | ||||||
|     } |     } | ||||||
|     if (sel.selectedOptions[0].dataset["webhookName"]) { |     if (sel.selectedOptions[0].dataset["webhookName"]) { | ||||||
|         sel.closest("div.reminderContent").querySelector("input.discord-username").value = |         sel.closest("div.reminderContent").querySelector("input.discord-username").value = | ||||||
|             sel.selectedOptions[0].dataset["webhookName"]; |             sel.selectedOptions[0].dataset["webhookName"]; | ||||||
|     } else { |     } else { | ||||||
|         sel.closest("div.reminderContent").querySelector("input.discord-username").value = |         sel.closest("div.reminderContent").querySelector("input.discord-username").value = | ||||||
|             ""; |             "Reminder"; | ||||||
|     } |     } | ||||||
| } | } | ||||||
|  |  | ||||||
| @@ -78,6 +78,18 @@ function reset_guild_pane() { | |||||||
|         .forEach((opt) => opt.remove()); |         .forEach((opt) => opt.remove()); | ||||||
| } | } | ||||||
|  |  | ||||||
|  | async function fetch_patreon(guild_id) { | ||||||
|  |     fetch(`/dashboard/api/guild/${guild_id}/patreon`) | ||||||
|  |         .then((response) => response.json()) | ||||||
|  |         .then((data) => { | ||||||
|  |             if (data.error) { | ||||||
|  |                 show_error(data.error); | ||||||
|  |             } else { | ||||||
|  |                 return data.patreon; | ||||||
|  |             } | ||||||
|  |         }); | ||||||
|  | } | ||||||
|  |  | ||||||
| function fetch_roles(guild_id) { | function fetch_roles(guild_id) { | ||||||
|     fetch(`/dashboard/api/guild/${guild_id}/roles`) |     fetch(`/dashboard/api/guild/${guild_id}/roles`) | ||||||
|         .then((response) => response.json()) |         .then((response) => response.json()) | ||||||
| @@ -85,7 +97,16 @@ function fetch_roles(guild_id) { | |||||||
|             if (data.error) { |             if (data.error) { | ||||||
|                 show_error(data.error); |                 show_error(data.error); | ||||||
|             } else { |             } else { | ||||||
|                 roles = data; |                 let values = Array.from( | ||||||
|  |                     data.map((role) => { | ||||||
|  |                         return { | ||||||
|  |                             key: role.name, | ||||||
|  |                             value: role.id, | ||||||
|  |                         }; | ||||||
|  |                     }) | ||||||
|  |                 ); | ||||||
|  |  | ||||||
|  |                 mentions.collection[0].values = values; | ||||||
|             } |             } | ||||||
|         }); |         }); | ||||||
| } | } | ||||||
| @@ -158,6 +179,8 @@ async function fetch_reminders(guild_id) { | |||||||
|                     newFrame.querySelector(".reminderContent").dataset["uid"] = |                     newFrame.querySelector(".reminderContent").dataset["uid"] = | ||||||
|                         reminder["uid"]; |                         reminder["uid"]; | ||||||
|  |  | ||||||
|  |                     mentions.attach(newFrame.querySelector("textarea")); | ||||||
|  |  | ||||||
|                     deserialize_reminder(reminder, newFrame, "load"); |                     deserialize_reminder(reminder, newFrame, "load"); | ||||||
|  |  | ||||||
|                     $reminderBox.appendChild(newFrame); |                     $reminderBox.appendChild(newFrame); | ||||||
| @@ -297,9 +320,9 @@ async function serialize_reminder(node, mode) { | |||||||
|         embed_fields: fields, |         embed_fields: fields, | ||||||
|         expires: expiration_time, |         expires: expiration_time, | ||||||
|         interval_seconds: mode !== "template" ? interval.seconds : null, |         interval_seconds: mode !== "template" ? interval.seconds : null, | ||||||
|  |         interval_days: mode !== "template" ? interval.days : null, | ||||||
|         interval_months: mode !== "template" ? interval.months : null, |         interval_months: mode !== "template" ? interval.months : null, | ||||||
|         name: node.querySelector('input[name="name"]').value, |         name: node.querySelector('input[name="name"]').value, | ||||||
|         pin: node.querySelector('input[name="pin"]').checked, |  | ||||||
|         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, | ||||||
|         utc_time: utc_time, |         utc_time: utc_time, | ||||||
| @@ -310,6 +333,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) { | ||||||
| @@ -330,6 +356,8 @@ function deserialize_reminder(reminder, frame, mode) { | |||||||
|         } |         } | ||||||
|     } |     } | ||||||
|  |  | ||||||
|  |     update_interval(frame); | ||||||
|  |  | ||||||
|     const lastChild = frame.querySelector("div.embed-multifield-box .embed-field-box"); |     const lastChild = frame.querySelector("div.embed-multifield-box .embed-field-box"); | ||||||
|  |  | ||||||
|     for (let field of reminder["embed_fields"]) { |     for (let field of reminder["embed_fields"]) { | ||||||
| @@ -370,6 +398,10 @@ function deserialize_reminder(reminder, frame, mode) { | |||||||
| document.addEventListener("guildSwitched", async (e) => { | document.addEventListener("guildSwitched", async (e) => { | ||||||
|     $loader.classList.remove("is-hidden"); |     $loader.classList.remove("is-hidden"); | ||||||
|  |  | ||||||
|  |     document | ||||||
|  |         .querySelectorAll(".patreon-only") | ||||||
|  |         .forEach((el) => el.classList.add("is-locked")); | ||||||
|  |  | ||||||
|     let $anchor = document.querySelector( |     let $anchor = document.querySelector( | ||||||
|         `.switch-pane[data-guild="${e.detail.guild_id}"]` |         `.switch-pane[data-guild="${e.detail.guild_id}"]` | ||||||
|     ); |     ); | ||||||
| @@ -378,6 +410,12 @@ document.addEventListener("guildSwitched", async (e) => { | |||||||
|     reset_guild_pane(); |     reset_guild_pane(); | ||||||
|     $anchor.classList.add("is-active"); |     $anchor.classList.add("is-active"); | ||||||
|  |  | ||||||
|  |     if (globalPatreon || (await fetch_patreon(e.detail.guild_id))) { | ||||||
|  |         document | ||||||
|  |             .querySelectorAll(".patreon-only") | ||||||
|  |             .forEach((el) => el.classList.remove("is-locked")); | ||||||
|  |     } | ||||||
|  |  | ||||||
|     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); |     await fetch_channels(e.detail.guild_id); | ||||||
| @@ -392,8 +430,6 @@ document.addEventListener("guildSwitched", async (e) => { | |||||||
|         }); |         }); | ||||||
|     }); |     }); | ||||||
|  |  | ||||||
|     resize_textareas(); |  | ||||||
|  |  | ||||||
|     $loader.classList.add("is-hidden"); |     $loader.classList.add("is-hidden"); | ||||||
| }); | }); | ||||||
|  |  | ||||||
| @@ -468,6 +504,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"]; | ||||||
| @@ -531,6 +569,8 @@ document.querySelectorAll(".show-modal").forEach((element) => { | |||||||
| document.addEventListener("DOMContentLoaded", () => { | document.addEventListener("DOMContentLoaded", () => { | ||||||
|     $loader.classList.remove("is-hidden"); |     $loader.classList.remove("is-hidden"); | ||||||
|  |  | ||||||
|  |     mentions.attach(document.querySelectorAll("textarea")); | ||||||
|  |  | ||||||
|     document.querySelectorAll(".navbar-burger").forEach((el) => { |     document.querySelectorAll(".navbar-burger").forEach((el) => { | ||||||
|         el.addEventListener("click", () => { |         el.addEventListener("click", () => { | ||||||
|             const target = el.dataset["target"]; |             const target = el.dataset["target"]; | ||||||
| @@ -569,6 +609,8 @@ document.addEventListener("DOMContentLoaded", () => { | |||||||
|                 const $template = document.getElementById("guildListEntry"); |                 const $template = document.getElementById("guildListEntry"); | ||||||
|  |  | ||||||
|                 for (let guild of data) { |                 for (let guild of data) { | ||||||
|  |                     guildNames[guild.id] = guild.name; | ||||||
|  |  | ||||||
|                     document.querySelectorAll(".guildList").forEach((element) => { |                     document.querySelectorAll(".guildList").forEach((element) => { | ||||||
|                         const $clone = $template.content.cloneNode(true); |                         const $clone = $template.content.cloneNode(true); | ||||||
|                         const $anchor = $clone.querySelector("a"); |                         const $anchor = $clone.querySelector("a"); | ||||||
| @@ -585,11 +627,7 @@ document.addEventListener("DOMContentLoaded", () => { | |||||||
|  |  | ||||||
|                         $anchor.addEventListener("click", async (e) => { |                         $anchor.addEventListener("click", async (e) => { | ||||||
|                             e.preventDefault(); |                             e.preventDefault(); | ||||||
|                             window.history.pushState( |                             window.history.pushState({}, "", `/dashboard/${guild.id}`); | ||||||
|                                 {}, |  | ||||||
|                                 "", |  | ||||||
|                                 `/dashboard/${guild.id}?name=${guild.name}` |  | ||||||
|                             ); |  | ||||||
|                             const event = new CustomEvent("guildSwitched", { |                             const event = new CustomEvent("guildSwitched", { | ||||||
|                                 detail: { |                                 detail: { | ||||||
|                                     guild_name: guild.name, |                                     guild_name: guild.name, | ||||||
| @@ -607,8 +645,8 @@ document.addEventListener("DOMContentLoaded", () => { | |||||||
|                 const matches = window.location.href.match(/dashboard\/(\d+)/); |                 const matches = window.location.href.match(/dashboard\/(\d+)/); | ||||||
|                 if (matches) { |                 if (matches) { | ||||||
|                     let id = matches[1]; |                     let id = matches[1]; | ||||||
|                     let name = |                     let name = guildNames[id]; | ||||||
|                         new URLSearchParams(window.location.search).get("name") || id; |  | ||||||
|                     const event = new CustomEvent("guildSwitched", { |                     const event = new CustomEvent("guildSwitched", { | ||||||
|                         detail: { |                         detail: { | ||||||
|                             guild_name: name, |                             guild_name: name, | ||||||
| @@ -645,6 +683,39 @@ function has_source(string) { | |||||||
|     } |     } | ||||||
| } | } | ||||||
|  |  | ||||||
|  | $uploader.addEventListener("change", (ev) => { | ||||||
|  |     const urlTail = document.querySelector('input[name="exportSelect"]:checked').value; | ||||||
|  |  | ||||||
|  |     new Promise((resolve) => { | ||||||
|  |         let fileReader = new FileReader(); | ||||||
|  |         fileReader.onload = (e) => resolve(fileReader.result); | ||||||
|  |         fileReader.readAsDataURL($uploader.files[0]); | ||||||
|  |     }).then((dataUrl) => { | ||||||
|  |         fetch(`/dashboard/api/guild/${guildId()}/export/${urlTail}`, { | ||||||
|  |             method: "PUT", | ||||||
|  |             body: JSON.stringify({ body: dataUrl.split(",")[1] }), | ||||||
|  |         }).then(() => { | ||||||
|  |             delete $uploader.files[0]; | ||||||
|  |         }); | ||||||
|  |     }); | ||||||
|  | }); | ||||||
|  |  | ||||||
|  | $importBtn.addEventListener("click", () => { | ||||||
|  |     $uploader.click(); | ||||||
|  | }); | ||||||
|  |  | ||||||
|  | $exportBtn.addEventListener("click", () => { | ||||||
|  |     const urlTail = document.querySelector('input[name="exportSelect"]:checked').value; | ||||||
|  |  | ||||||
|  |     fetch(`/dashboard/api/guild/${guildId()}/export/${urlTail}`) | ||||||
|  |         .then((response) => response.json()) | ||||||
|  |         .then((data) => { | ||||||
|  |             $downloader.href = | ||||||
|  |                 "data:text/plain;charset=utf-8," + encodeURIComponent(data.body); | ||||||
|  |             $downloader.click(); | ||||||
|  |         }); | ||||||
|  | }); | ||||||
|  |  | ||||||
| $createReminderBtn.addEventListener("click", async () => { | $createReminderBtn.addEventListener("click", async () => { | ||||||
|     $createReminderBtn.querySelector("span.icon > i").classList = [ |     $createReminderBtn.querySelector("span.icon > i").classList = [ | ||||||
|         "fas fa-spinner fa-spin", |         "fas fa-spinner fa-spin", | ||||||
| @@ -653,6 +724,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; | ||||||
|     } |     } | ||||||
|  |  | ||||||
| @@ -770,13 +842,6 @@ $deleteTemplateBtn.addEventListener("click", (ev) => { | |||||||
|         }); |         }); | ||||||
| }); | }); | ||||||
|  |  | ||||||
| document.querySelectorAll("textarea.autoresize").forEach((element) => { |  | ||||||
|     element.addEventListener("input", () => { |  | ||||||
|         element.style.height = ""; |  | ||||||
|         element.style.height = element.scrollHeight + 3 + "px"; |  | ||||||
|     }); |  | ||||||
| }); |  | ||||||
|  |  | ||||||
| let $img; | let $img; | ||||||
| const $urlModal = document.querySelector("div#addImageModal"); | const $urlModal = document.querySelector("div#addImageModal"); | ||||||
| const $urlInput = $urlModal.querySelector("input"); | const $urlInput = $urlModal.querySelector("input"); | ||||||
| @@ -809,7 +874,7 @@ document.addEventListener("remindersLoaded", () => { | |||||||
|         }); |         }); | ||||||
|     }); |     }); | ||||||
|  |  | ||||||
|     const fileInput = document.querySelectorAll("input[type=file]"); |     const fileInput = document.querySelectorAll("input.file-input[type=file]"); | ||||||
|  |  | ||||||
|     fileInput.forEach((element) => { |     fileInput.forEach((element) => { | ||||||
|         element.addEventListener("change", () => { |         element.addEventListener("change", () => { | ||||||
| @@ -832,6 +897,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() { | ||||||
| @@ -901,7 +973,6 @@ document.addEventListener("DOMNodeInserted", () => { | |||||||
|     }); |     }); | ||||||
|  |  | ||||||
|     check_embed_fields(); |     check_embed_fields(); | ||||||
|     resize_textareas(); |  | ||||||
| }); | }); | ||||||
|  |  | ||||||
| document.addEventListener("click", (ev) => { | document.addEventListener("click", (ev) => { | ||||||
|   | |||||||
| @@ -27,8 +27,10 @@ | |||||||
|     <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"> | ||||||
|     <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" /> | ||||||
|  |  | ||||||
|     <script src="/static/js/luxon.min.js"></script> |     <script src="/static/js/luxon.min.js"></script> | ||||||
|  |     <script src="https://cdnjs.cloudflare.com/ajax/libs/tributejs/5.1.3/tribute.min.js" integrity="sha512-KJYWC7RKz/Abtsu1QXd7VJ1IJua7P7GTpl3IKUqfa21Otg2opvRYmkui/CXBC6qeDYCNlQZ7c+7JfDXnKdILUA==" crossorigin="anonymous" referrerpolicy="no-referrer"></script> | ||||||
| </head> | </head> | ||||||
| <body> | <body> | ||||||
| <nav class="navbar is-spaced is-size-4 is-hidden-desktop dashboard-navbar" role="navigation" | <nav class="navbar is-spaced is-size-4 is-hidden-desktop dashboard-navbar" role="navigation" | ||||||
| @@ -173,10 +175,32 @@ | |||||||
|             <button class="delete close-modal" aria-label="close"></button> |             <button class="delete close-modal" aria-label="close"></button> | ||||||
|         </header> |         </header> | ||||||
|         <section class="modal-card-body"> |         <section class="modal-card-body"> | ||||||
|  |             <div class="control"> | ||||||
|  |                 <div class="field"> | ||||||
|  |                     <label> | ||||||
|  |                         <input type="radio" class="default-width" name="exportSelect" value="reminders" checked> | ||||||
|  |                         Reminders | ||||||
|  |                     </label> | ||||||
|  |                 </div> | ||||||
|  |             </div> | ||||||
|  |             <div class="control"> | ||||||
|  |                 <div class="field"> | ||||||
|  |                     <label> | ||||||
|  |                         <input type="radio" class="default-width" name="exportSelect" value="todos"> | ||||||
|  |                         Todo Lists | ||||||
|  |                     </label> | ||||||
|  |                 </div> | ||||||
|  |             </div> | ||||||
|  |             <br> | ||||||
|             <div class="has-text-centered"> |             <div class="has-text-centered"> | ||||||
|  |                 <div style="color: red"> | ||||||
|  |                     Please first read the <a href="/help/iemanager">support page</a> | ||||||
|  |                 </div> | ||||||
|                 <button class="button is-success is-outlined" id="import-data">Import Data</button> |                 <button class="button is-success is-outlined" id="import-data">Import Data</button> | ||||||
|                 <button class="button is-success" id="export-data">Export Data</button> |                 <button class="button is-success" id="export-data">Export Data</button> | ||||||
|             </div> |             </div> | ||||||
|  |             <a id="downloader" download="export.csv" class="is-hidden"></a> | ||||||
|  |             <input id="uploader" type="file" hidden></input> | ||||||
|         </section> |         </section> | ||||||
|     </div> |     </div> | ||||||
|     <button class="modal-close is-large close-modal" aria-label="close"></button> |     <button class="modal-close is-large close-modal" aria-label="close"></button> | ||||||
|   | |||||||
| @@ -5,5 +5,5 @@ | |||||||
|     {% set show_contact = True %} |     {% set show_contact = True %} | ||||||
|  |  | ||||||
|     {% set page_title = "An Error Has Occurred" %} |     {% set page_title = "An Error Has Occurred" %} | ||||||
|     {% set page_subtitle = "A server error has occurred. Please contact me and I will try and resolve this" %} |     {% set page_subtitle = "A server error has occurred. Please retry, or ask in our Discord." %} | ||||||
| {% endblock %} | {% endblock %} | ||||||
|   | |||||||
| @@ -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,26 +52,54 @@ | |||||||
|                 </article> |                 </article> | ||||||
|             </div> |             </div> | ||||||
|         </div> |         </div> | ||||||
|  | <!--        <div class="tile is-ancestor">--> | ||||||
|  | <!--            <div class="tile is-parent">--> | ||||||
|  | <!--                <article class="tile is-child notification">--> | ||||||
|  | <!--                    <p class="title">Timers</p>--> | ||||||
|  | <!--                    <p class="subtitle">Learn to manage timers</p>--> | ||||||
|  | <!--                    <div class="content has-text-centered">--> | ||||||
|  | <!--                        <a class="button is-size-4 is-rounded is-light" href="/help/timers">--> | ||||||
|  | <!--                            <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">--> | ||||||
|  | <!--                <article class="tile is-child notification">--> | ||||||
|  | <!--                    <p class="title">Todo Lists</p>--> | ||||||
|  | <!--                    <p class="subtitle">Learn to manage various todo lists</p>--> | ||||||
|  | <!--                    <div class="content has-text-centered">--> | ||||||
|  | <!--                        <a class="button is-size-4 is-rounded is-light" href="/help/todo_lists">--> | ||||||
|  | <!--                            <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 is-vertical">--> | ||||||
|  | <!--                <article class="tile is-child notification">--> | ||||||
|  | <!--                    <p class="title">Macros</p>--> | ||||||
|  | <!--                    <p class="subtitle">Learn how to create combination commands called macros, to suit advanced use cases</p>--> | ||||||
|  | <!--                    <div class="content has-text-centered">--> | ||||||
|  | <!--                        <a class="button is-size-4 is-rounded is-light" href="/help/macros">--> | ||||||
|  | <!--                            <p class="is-size-4">--> | ||||||
|  | <!--                                Read <span class="icon"><i class="fas fa-chevron-right"></i></span>--> | ||||||
|  | <!--                            </p>--> | ||||||
|  | <!--                        </a>--> | ||||||
|  | <!--                    </div>--> | ||||||
|  | <!--                </article>--> | ||||||
|  | <!--            </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">Intervals</p> | ||||||
|                     <p class="subtitle">Learn to manage timers</p> |                     <p class="subtitle">Learn about repeating reminders</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/intervals"> | ||||||
|                             <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"> |  | ||||||
|                 <article class="tile is-child notification"> |  | ||||||
|                     <p class="title">Todo Lists</p> |  | ||||||
|                     <p class="subtitle">Learn to manage various todo lists</p> |  | ||||||
|                     <div class="content has-text-centered"> |  | ||||||
|                         <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> | ||||||
| @@ -81,10 +109,10 @@ | |||||||
|             </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">Import/Export</p> | ||||||
|                     <p class="subtitle">Learn how to create combination commands called macros, to suit advanced use cases</p> |                     <p class="subtitle">Learn how to import and export data from the dashboard</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/iemanager"> | ||||||
|                             <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> | ||||||
| @@ -92,7 +120,38 @@ | |||||||
|                     </div> |                     </div> | ||||||
|                 </article> |                 </article> | ||||||
|             </div> |             </div> | ||||||
|  |             <div class="tile is-parent"> | ||||||
|  | <!--                <article class="tile is-child notification">--> | ||||||
|  | <!--                    <p class="title">Dashboard</p>--> | ||||||
|  | <!--                    <p class="subtitle">Learn to use the interactive web dashboard</p>--> | ||||||
|  | <!--                    <div class="content has-text-centered">--> | ||||||
|  | <!--                        <a class="button is-size-4 is-rounded is-light" href="/help/dashboard">--> | ||||||
|  | <!--                            <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> | ||||||
|  |  | ||||||
|  |     <section class="hero is-small"> | ||||||
|  |         <div class="hero-body"> | ||||||
|  |             <div class="container has-text-centered"> | ||||||
|  |                 <p class="title">Need more help?</p> | ||||||
|  |                 <p class="content"> | ||||||
|  |                     Feel free to come and ask us! | ||||||
|  |                 </p> | ||||||
|  |             </div> | ||||||
|  |         </div> | ||||||
|  |         <div class="hero-foot has-text-centered"> | ||||||
|  |             <a class="button is-size-6 is-rounded is-primary" href="https://discord.jellywx.com"> | ||||||
|  |                 <p class="is-size-6"> | ||||||
|  |                     Join Discord <span class="icon"><i class="fas fa-chevron-right"></i></span> | ||||||
|  |                 </p> | ||||||
|  |             </a> | ||||||
|  |         </div> | ||||||
|  |     </section> | ||||||
|  |  | ||||||
| {% endblock %} | {% endblock %} | ||||||
|   | |||||||
| @@ -49,7 +49,7 @@ | |||||||
|         <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 class="is-size-5 pl-6"> | ||||||
|                 Your data may also be 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> | ||||||
|         </div> |         </div> | ||||||
| @@ -68,7 +68,7 @@ | |||||||
|                 <br> |                 <br> | ||||||
|                 <br> |                 <br> | ||||||
|                 Reminders deleted with <strong>/del</strong> or via the dashboard are removed from the live database |                 Reminders deleted with <strong>/del</strong> or via the dashboard are removed from the live database | ||||||
|                 instantly, but may persist in backups. |                 instantly, but may persist in backups for up to a year. | ||||||
|             </p> |             </p> | ||||||
|         </div> |         </div> | ||||||
|     </section> |     </section> | ||||||
|   | |||||||
| @@ -158,9 +158,9 @@ | |||||||
|         </div> |         </div> | ||||||
|  |  | ||||||
|         <div class="collapses"> |         <div class="collapses"> | ||||||
|             <div class="is-locked"> |             <div class="patreon-only"> | ||||||
|                 <div class="field"> |                 <div class="field"> | ||||||
|                     <label class="label">Interval <a class="foreground" href="/help/interval"><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" style="min-width: 400px;" > | ||||||
|                         <div class="input interval-group"> |                         <div class="input interval-group"> | ||||||
|                             <div class="interval-group-left"> |                             <div class="interval-group-left"> | ||||||
| @@ -206,11 +206,6 @@ | |||||||
|                         <label class="label">Enable TTS <input type="checkbox" name="tts"></label> |                         <label class="label">Enable TTS <input type="checkbox" name="tts"></label> | ||||||
|                     </div> |                     </div> | ||||||
|                 </div> |                 </div> | ||||||
|                 <div class="column has-text-centered"> |  | ||||||
|                     <div class="is-boxed"> |  | ||||||
|                         <label class="label">Pin Message <input type="checkbox" name="pin"></label> |  | ||||||
|                     </div> |  | ||||||
|                 </div> |  | ||||||
|                 <div class="column has-text-centered"> |                 <div class="column has-text-centered"> | ||||||
|                     <div class="file is-small is-boxed"> |                     <div class="file is-small is-boxed"> | ||||||
|                         <label class="file-label"> |                         <label class="file-label"> | ||||||
|   | |||||||
| @@ -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> | ||||||
|   | |||||||
							
								
								
									
										22
									
								
								web/templates/support/dashboard.html.tera
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,22 @@ | |||||||
|  | {% extends "base" %} | ||||||
|  |  | ||||||
|  | {% block init %} | ||||||
|  |     {% set title = "Support" %} | ||||||
|  |  | ||||||
|  |     {% set page_title = "Dashboard" %} | ||||||
|  |     {% set page_subtitle = "" %} | ||||||
|  | {% endblock %} | ||||||
|  |  | ||||||
|  | {% block content %} | ||||||
|  |  | ||||||
|  |     <section class="hero is-small"> | ||||||
|  |         <div class="hero-body"> | ||||||
|  |             <div class="container"> | ||||||
|  |                 <p class="title">Accessing the dashboard</p> | ||||||
|  |                 <p class="content"> | ||||||
|  |                 </p> | ||||||
|  |             </div> | ||||||
|  |         </div> | ||||||
|  |     </section> | ||||||
|  |  | ||||||
|  | {% endblock %} | ||||||
| @@ -33,6 +33,24 @@ | |||||||
|         </div> |         </div> | ||||||
|     </section> |     </section> | ||||||
|  |  | ||||||
|  |     <section class="hero is-small"> | ||||||
|  |         <div class="hero-body"> | ||||||
|  |             <div class="container"> | ||||||
|  |                 <p class="title">Deleting reminders you've just created</p> | ||||||
|  |                 <p class="content"> | ||||||
|  |                     If you made a mistake, you can quickly delete a reminder you made by pressing "Cancel" | ||||||
|  |                     <br> | ||||||
|  |                 </p> | ||||||
|  |                 <figure> | ||||||
|  |                     <img src="/static/img/support/delete_reminder/cancel-1.png" alt="Cancel button"> | ||||||
|  |                 </figure> | ||||||
|  |                 <figure> | ||||||
|  |                     <img src="/static/img/support/delete_reminder/cancel-2.png" alt="Reminder deleted"> | ||||||
|  |                 </figure> | ||||||
|  |             </div> | ||||||
|  |         </div> | ||||||
|  |     </section> | ||||||
|  |  | ||||||
|     <section class="hero is-small"> |     <section class="hero is-small"> | ||||||
|         <div class="hero-body"> |         <div class="hero-body"> | ||||||
|             <div class="container"> |             <div class="container"> | ||||||
|   | |||||||
							
								
								
									
										87
									
								
								web/templates/support/iemanager.html.tera
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,87 @@ | |||||||
|  | {% extends "base" %} | ||||||
|  |  | ||||||
|  | {% block init %} | ||||||
|  |     {% set title = "Support" %} | ||||||
|  |  | ||||||
|  |     {% set page_title = "Import/Export" %} | ||||||
|  |     {% set page_subtitle = "" %} | ||||||
|  | {% endblock %} | ||||||
|  |  | ||||||
|  | {% block content %} | ||||||
|  |  | ||||||
|  |     <section class="hero is-small"> | ||||||
|  |         <div class="hero-body"> | ||||||
|  |             <div class="container"> | ||||||
|  |                 <p class="title">Export data</p> | ||||||
|  |                 <p class="content"> | ||||||
|  |                     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. | ||||||
|  |                 </p> | ||||||
|  |             </div> | ||||||
|  |         </div> | ||||||
|  |     </section> | ||||||
|  |  | ||||||
|  |     <section class="hero is-small"> | ||||||
|  |         <div class="hero-body"> | ||||||
|  |             <div class="container"> | ||||||
|  |                 <p class="title">Import data</p> | ||||||
|  |                 <p class="content"> | ||||||
|  |                     You can import previous exports or modified exports. When importing a file, the new data will be added alongside existing data. | ||||||
|  |                 </p> | ||||||
|  |             </div> | ||||||
|  |         </div> | ||||||
|  |     </section> | ||||||
|  |  | ||||||
|  |     <section class="hero is-small"> | ||||||
|  |         <div class="hero-body"> | ||||||
|  |             <div class="container content"> | ||||||
|  |                 <p class="title">Edit your data</p> | ||||||
|  |                 <p> | ||||||
|  |                     The CSV can be edited either as a text file or in a spreadsheet editor such as LibreOffice Calc. To | ||||||
|  |                     set up LibreOffice Calc for editing, do the following: | ||||||
|  |                 </p> | ||||||
|  |                 <ol> | ||||||
|  |                     <li> | ||||||
|  |                         Export data from dashboard. | ||||||
|  |                         <figure> | ||||||
|  |                             <img src="/static/img/support/iemanager/select_export.png" alt="Selecting export button"> | ||||||
|  |                         </figure> | ||||||
|  |                     </li> | ||||||
|  |                     <li> | ||||||
|  |                         Open the file in LibreOffice. <strong>During the import dialogue, select "Format quoted field as text".</strong> | ||||||
|  |                         <figure> | ||||||
|  |                             <img src="/static/img/support/iemanager/format_text.png" alt="Selecting format button"> | ||||||
|  |                         </figure> | ||||||
|  |                     </li> | ||||||
|  |                     <li> | ||||||
|  |                         Make edits to the spreadsheet. You can add, edit, and remove rows for reminders. Don't remove the top-most (title) row. | ||||||
|  |                         <figure> | ||||||
|  |                             <img src="/static/img/support/iemanager/edit_spreadsheet.png" alt="Editing spreadsheet"> | ||||||
|  |                         </figure> | ||||||
|  |                     </li> | ||||||
|  |                     <li> | ||||||
|  |                         Save the edited CSV file and import it on the dashboard. | ||||||
|  |                         <figure> | ||||||
|  |                             <img src="/static/img/support/iemanager/import.png" alt="Import new reminders"> | ||||||
|  |                         </figure> | ||||||
|  |                     </li> | ||||||
|  |                 </ol> | ||||||
|  |                 Other spreadsheet tools can also be used to edit exports, as long as they are properly configured: | ||||||
|  |                 <ul> | ||||||
|  |                     <li> | ||||||
|  |                         <strong>Google Sheets</strong>: Create a new blank spreadsheet. Select <strong>File > Import > Upload > export.csv</strong>. | ||||||
|  |                         Use the following import settings: | ||||||
|  |                         <figure> | ||||||
|  |                             <img src="/static/img/support/iemanager/sheets_settings.png" alt="Google sheets import settings"> | ||||||
|  |                         </figure> | ||||||
|  |                     </li> | ||||||
|  |                     <li> | ||||||
|  |                         <strong>Excel (including Excel Online)</strong>: Avoid using Excel. Excel will not correctly import channels, or give | ||||||
|  |                         clear options to correct imports. | ||||||
|  |                     </li> | ||||||
|  |                 </ul> | ||||||
|  |             </div> | ||||||
|  |         </div> | ||||||
|  |     </section> | ||||||
|  |  | ||||||
|  | {% endblock %} | ||||||
							
								
								
									
										71
									
								
								web/templates/support/intervals.html.tera
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,71 @@ | |||||||
|  | {% extends "base" %} | ||||||
|  |  | ||||||
|  | {% block init %} | ||||||
|  |     {% set title = "Support" %} | ||||||
|  |  | ||||||
|  |     {% set page_title = "Intervals" %} | ||||||
|  |     {% set page_subtitle = "Interval reminders, or repeating reminders, are available to our Patreon supporters" %} | ||||||
|  | {% endblock %} | ||||||
|  |  | ||||||
|  | {% block content %} | ||||||
|  |  | ||||||
|  |     <section class="hero is-small"> | ||||||
|  |         <div class="hero-body"> | ||||||
|  |             <div class="container"> | ||||||
|  |                 <p class="title">Fixed intervals</p> | ||||||
|  |                 <p class="content"> | ||||||
|  |                     The main type of interval is the fixed interval. Fixed intervals are ideal for hourly, daily, or | ||||||
|  |                     reminders repeating at any other fixed amount of time. | ||||||
|  |                     <br> | ||||||
|  |                     You can create fixed interval reminders via the dashboard or via the <code>/remind</code> command. | ||||||
|  |                     When you have filled out the "time" and "content" on the command, press <kbd>tab</kbd>. Select the | ||||||
|  |                     "interval" option. Then, write the interval you wish to use: for example, "1 day" for daily (starting | ||||||
|  |                     at the time specified in "time"). | ||||||
|  |                 </p> | ||||||
|  |             </div> | ||||||
|  |         </div> | ||||||
|  |     </section> | ||||||
|  |  | ||||||
|  |     <section class="hero is-small"> | ||||||
|  |         <div class="hero-body"> | ||||||
|  |             <div class="container"> | ||||||
|  |                 <p class="title">Daylight savings</p> | ||||||
|  |                 <p class="content"> | ||||||
|  |                     If you live in a region that uses daylight savings (DST), then your interval reminders may become | ||||||
|  |                     offset by an hour due to clock changes. | ||||||
|  |                     <br> | ||||||
|  |                     Reminder Bot offers a quick solution to this via the <code>/offset</code> command. This command | ||||||
|  |                     moves all existing reminders on a server by a certain amount of time. You can use offset to move | ||||||
|  |                     your reminders forward or backward by an hour when daylight savings happens. | ||||||
|  |                 </p> | ||||||
|  |             </div> | ||||||
|  |         </div> | ||||||
|  |     </section> | ||||||
|  |  | ||||||
|  |     <section class="hero is-small"> | ||||||
|  |         <div class="hero-body"> | ||||||
|  |             <div class="container"> | ||||||
|  |                 <p class="title">Monthly/yearly intervals</p> | ||||||
|  |                 <p class="content"> | ||||||
|  |                     Monthly or yearly intervals are configured the same as fixed intervals. Instead of a fixed time | ||||||
|  |                     interval, these reminders repeat on a certain day each month or each year. This makes them ideal | ||||||
|  |                     for marking calendar events. | ||||||
|  |                 </p> | ||||||
|  |             </div> | ||||||
|  |         </div> | ||||||
|  |     </section> | ||||||
|  |  | ||||||
|  |     <section class="hero is-small"> | ||||||
|  |         <div class="hero-body"> | ||||||
|  |             <div class="container"> | ||||||
|  |                 <p class="title">Interval expiration</p> | ||||||
|  |                 <p class="content"> | ||||||
|  |                     An expiration time can also be specified, both via commands and dashboard, for repeating reminders. | ||||||
|  |                     This is optional, and if omitted, the reminder will repeat indefinitely. Otherwise, the reminder | ||||||
|  |                     will be deleted once the expiration date is reached. | ||||||
|  |                 </p> | ||||||
|  |             </div> | ||||||
|  |         </div> | ||||||
|  |     </section> | ||||||
|  |  | ||||||
|  | {% endblock %} | ||||||
| @@ -3,8 +3,8 @@ | |||||||
| {% block init %} | {% block init %} | ||||||
|     {% set title = "Support" %} |     {% set title = "Support" %} | ||||||
|  |  | ||||||
|     {% set page_title = "Timezone Help" %} |     {% set page_title = "Timezones" %} | ||||||
|     {% set page_subtitle = "Timezones are tricky. Read on for help" %} |     {% set page_subtitle = "" %} | ||||||
|     {% set show_invite = false %} |     {% set show_invite = false %} | ||||||
| {% endblock %} | {% endblock %} | ||||||
|  |  | ||||||
| @@ -31,7 +31,7 @@ | |||||||
|             <div class="container"> |             <div class="container"> | ||||||
|                 <p class="title">Selecting your timezone automatically</p> |                 <p class="title">Selecting your timezone automatically</p> | ||||||
|                 <p class="content"> |                 <p class="content"> | ||||||
|                     A new feature we offer is the ability to configure Reminder Bot's timezone from your browser. To do |                     You can also configure Reminder Bot's timezone from your browser. To do | ||||||
|                     this, go to our dashboard, press 'Timezone' in the bottom left (desktop) or at the bottom of the |                     this, go to our dashboard, press 'Timezone' in the bottom left (desktop) or at the bottom of the | ||||||
|                     navigation menu (mobile). Then, choose 'Set Bot Timezone' to set Reminder Bot to use your browser's |                     navigation menu (mobile). Then, choose 'Set Bot Timezone' to set Reminder Bot to use your browser's | ||||||
|                     timezone. |                     timezone. | ||||||
|   | |||||||
| @@ -20,11 +20,12 @@ | |||||||
|                 <br> |                 <br> | ||||||
|                 Violating the Terms of Service may result in receiving a permanent ban from the Discord server, |                 Violating the Terms of Service may result in receiving a permanent ban from the Discord server, | ||||||
|                 permanent restriction on your usage of Reminder Bot, or removal of some or all of your content on |                 permanent restriction on your usage of Reminder Bot, or removal of some or all of your content on | ||||||
|                 Reminder Bot or the Discord server. |                 Reminder Bot or the Discord server. None of these will necessarily be preceded or succeeded by a warning | ||||||
|  |                 or notice. | ||||||
|                 <br> |                 <br> | ||||||
|                 <br> |                 <br> | ||||||
|                 The Terms of Service may be updated at any time, and should be considered a guideline for appropriate |                 The Terms of Service may be updated. Notice will be provided via the Discord server. You | ||||||
|                 behaviour. |                 should consider the Terms of Service to be a strong for appropriate behaviour. | ||||||
|             </p> |             </p> | ||||||
|         </div> |         </div> | ||||||
|     </section> |     </section> | ||||||
| @@ -35,7 +36,14 @@ | |||||||
|             <ul class="is-size-5 pl-6"> |             <ul class="is-size-5 pl-6"> | ||||||
|                 <li>Reasonably disclose potential exploits or bugs to me by email or by Discord private message</li> |                 <li>Reasonably disclose potential exploits or bugs to me by email or by Discord private message</li> | ||||||
|                 <li>Do not use the bot to harass other Discord users</li> |                 <li>Do not use the bot to harass other Discord users</li> | ||||||
|                 <li>Do not use the bot to send more than 30 messages during a 60 second period</li> |                 <li>Do not use the bot to transmit malware or other illegal content</li> | ||||||
|  |                 <li>Do not use the bot to send more than 15 messages during a 60 second period</li> | ||||||
|  |                 <li> | ||||||
|  |                     Do not attempt to circumvent restrictions imposed by the bot or website, including trying to access | ||||||
|  |                     data of other users, circumvent Patreon restrictions, or uploading files and creating reminders that | ||||||
|  |                     are too large for the bot to send or process. Some or all of these actions may be illegal in your | ||||||
|  |                     country | ||||||
|  |                 </li> | ||||||
|             </ul> |             </ul> | ||||||
|         </div> |         </div> | ||||||
|     </section> |     </section> | ||||||
|   | |||||||