├── .gitignore ├── .prettierrc.toml ├── Cargo.lock ├── Cargo.toml ├── Containerfile ├── LICENSE ├── README.md ├── Rocket.toml ├── build.rs ├── conf └── default.env ├── debian ├── postinst └── postrm ├── dp.py ├── migrations ├── 20210603000000_initial.sql ├── 20210623000000_reminder_message_embed.sql ├── 20210922000000_macro.sql ├── 20220201000000_reminder_variable_intervals.sql ├── 20220211000000_reminder_templates.sql ├── 20221210000000_reminder_daily_intervals.sql └── 20230511125236_reminder_threads.sql ├── nginx └── reminder-rs ├── postman ├── Cargo.toml └── src │ ├── lib.rs │ └── sender.rs ├── rustfmt.toml ├── src ├── commands │ ├── autocomplete.rs │ ├── command_macro │ │ ├── delete.rs │ │ ├── list.rs │ │ ├── migrate.rs │ │ ├── mod.rs │ │ ├── record.rs │ │ └── run.rs │ ├── info_cmds.rs │ ├── mod.rs │ ├── moderation_cmds.rs │ ├── reminder_cmds.rs │ └── todo_cmds.rs ├── component_models │ ├── mod.rs │ └── pager.rs ├── consts.rs ├── event_handlers.rs ├── hooks.rs ├── interval_parser.rs ├── main.rs ├── models │ ├── channel_data.rs │ ├── command_macro.rs │ ├── mod.rs │ ├── reminder │ │ ├── builder.rs │ │ ├── content.rs │ │ ├── errors.rs │ │ ├── helper.rs │ │ ├── look_flags.rs │ │ └── mod.rs │ ├── timer.rs │ └── user_data.rs ├── time_parser.rs └── utils.rs ├── systemd └── reminder-rs.service └── web ├── Cargo.toml ├── private ├── ca_cert.pem ├── ca_key.pem ├── ecdsa_nistp256_sha256_cert.pem ├── ecdsa_nistp256_sha256_key_pkcs8.pem ├── ecdsa_nistp384_sha384_cert.pem ├── ecdsa_nistp384_sha384_key_pkcs8.pem ├── ed25519_cert.pem ├── ed25519_key.pem ├── gen_certs.sh ├── rsa_sha256_cert.pem └── rsa_sha256_key.pem ├── src ├── consts.rs ├── lib.rs ├── macros.rs └── routes │ ├── dashboard │ ├── export.rs │ ├── guild.rs │ ├── mod.rs │ └── user.rs │ ├── login.rs │ └── mod.rs ├── static ├── css │ ├── bulma.min.css │ ├── dtsel.css │ ├── fa.css │ ├── font.css │ └── style.css ├── favicon │ ├── android-chrome-192x192.png │ ├── android-chrome-512x512.png │ ├── apple-touch-icon.png │ ├── browserconfig.xml │ ├── favicon-16x16.png │ ├── favicon-32x32.png │ ├── favicon.ico │ ├── mstile-150x150.png │ └── site.webmanifest ├── img │ ├── bg.webp │ ├── icon.png │ ├── logo_flat.jpg │ ├── logo_flat.webp │ ├── slash-commands.png │ ├── support │ │ ├── delete_reminder │ │ │ ├── 1.png │ │ │ ├── 2.png │ │ │ ├── 3.png │ │ │ ├── cancel-1.png │ │ │ ├── cancel-2.png │ │ │ ├── cmd-1.png │ │ │ └── cmd-2.png │ │ └── iemanager │ │ │ ├── edit_spreadsheet.png │ │ │ ├── format_text.png │ │ │ ├── import.png │ │ │ ├── select_export.png │ │ │ └── sheets_settings.png │ └── tournament-demo.png ├── js │ ├── dtsel.js │ ├── expand.js │ ├── interval.js │ ├── iro.js │ ├── js.cookie.min.js │ ├── luxon.min.js │ ├── main.js │ ├── sort.js │ └── timezone.js └── webfonts │ ├── fa-brands-400.eot │ ├── fa-brands-400.svg │ ├── fa-brands-400.ttf │ ├── fa-brands-400.woff │ ├── fa-brands-400.woff2 │ ├── fa-duotone-900.eot │ ├── fa-duotone-900.svg │ ├── fa-duotone-900.ttf │ ├── fa-duotone-900.woff │ ├── fa-duotone-900.woff2 │ ├── fa-light-300.eot │ ├── fa-light-300.svg │ ├── fa-light-300.ttf │ ├── fa-light-300.woff │ ├── fa-light-300.woff2 │ ├── fa-regular-400.eot │ ├── fa-regular-400.svg │ ├── fa-regular-400.ttf │ ├── fa-regular-400.woff │ ├── fa-regular-400.woff2 │ ├── fa-solid-900.eot │ ├── fa-solid-900.svg │ ├── fa-solid-900.ttf │ ├── fa-solid-900.woff │ └── fa-solid-900.woff2 └── templates ├── base.html.tera ├── cookies.html.tera ├── dashboard.html.tera ├── errors ├── 401.html.tera ├── 403.html.tera ├── 404.html.tera └── 500.html.tera ├── help.html.tera ├── index.html.tera ├── privacy.html.tera ├── reminder_dashboard ├── guild_reminder.html.tera └── reminder_dashboard.html.tera ├── return.html.tera ├── support ├── create_reminder.html.tera ├── dashboard.html.tera ├── delete_reminder.html.tera ├── iemanager.html.tera ├── intervals.html.tera ├── macros.html.tera ├── timers.html.tera ├── timezone.html.tera └── todo_lists.html.tera └── terms.html.tera /.gitignore: -------------------------------------------------------------------------------- 1 | /target 2 | .env 3 | /venv 4 | .cargo 5 | assets 6 | out.json 7 | /.idea 8 | -------------------------------------------------------------------------------- /.prettierrc.toml: -------------------------------------------------------------------------------- 1 | printWidth = 90 2 | tabWidth = 4 3 | -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "reminder-rs" 3 | version = "1.6.10" 4 | authors = ["Jude Southworth "] 5 | edition = "2021" 6 | license = "AGPL-3.0 only" 7 | description = "Reminder Bot for Discord, now in Rust" 8 | 9 | [dependencies] 10 | poise = "0.4" 11 | dotenv = "0.15" 12 | tokio = { version = "1", features = ["process", "full"] } 13 | reqwest = "0.11" 14 | lazy-regex = "2.3.0" 15 | regex = "1.6" 16 | log = "0.4" 17 | env_logger = "0.10" 18 | chrono = "0.4" 19 | chrono-tz = { version = "0.8", features = ["serde"] } 20 | lazy_static = "1.4" 21 | num-integer = "0.1" 22 | serde = "1.0" 23 | serde_json = "1.0" 24 | serde_repr = "0.1" 25 | rmp-serde = "1.1" 26 | rand = "0.8" 27 | levenshtein = "1.0" 28 | sqlx = { version = "0.6", features = ["runtime-tokio-rustls", "macros", "mysql", "bigdecimal", "chrono", "migrate"]} 29 | base64 = "0.13" 30 | 31 | [dependencies.postman] 32 | path = "postman" 33 | 34 | [dependencies.reminder_web] 35 | path = "web" 36 | 37 | [package.metadata.deb] 38 | depends = "$auto, python3-dateparser" 39 | suggests = "mysql-server-8.0, nginx" 40 | maintainer-scripts = "debian" 41 | assets = [ 42 | ["target/release/reminder-rs", "usr/bin/reminder-rs", "755"], 43 | ["conf/default.env", "etc/reminder-rs/default.env", "600"], 44 | # ["web/static/", "var/www/reminder-rs/static", "755"], 45 | # ["nginx/reminder-rs", "etc/nginx/sites-available/reminder-rs", "755"] 46 | ] 47 | 48 | [package.metadata.deb.systemd-units] 49 | unit-scripts = "systemd" 50 | start = false 51 | -------------------------------------------------------------------------------- /Containerfile: -------------------------------------------------------------------------------- 1 | FROM ubuntu:20.04 2 | 3 | ENV RUSTUP_HOME=/usr/local/rustup \ 4 | CARGO_HOME=/usr/local/cargo \ 5 | PATH=/usr/local/cargo/bin:$PATH 6 | 7 | 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 8 | RUN curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --no-modify-path --profile minimal 9 | RUN cargo install cargo-deb 10 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # reminder-rs 2 | Reminder Bot for Discord. 3 | 4 | ## How do I use it? 5 | I offer a hosted version of the bot. You can invite it with: **https://invite.reminder-bot.com**. The catch is that repeating 6 | reminders are paid on the hosted version of the bot. Keep reading if you want to host it yourself. 7 | 8 | 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) 9 | 10 | ### Compiling 11 | Install build requirements: 12 | `sudo apt install gcc gcc-multilib cmake libssl-dev build-essential python3-dateparser` 13 | 14 | Install Rust from https://rustup.rs 15 | 16 | Reminder Bot can then be built by running `cargo build --release` in the top level directory. It is necessary to create a 17 | folder called 'assets' containing an image file with its name specified in the environment as `WEBHOOK_AVATAR`, of 18 | dimensions 128x128px to be used as the webhook avatar. 19 | 20 | #### Compilation environment variables 21 | These environment variables must be provided when compiling the bot 22 | * `DATABASE_URL` - the URL of your MySQL database (`mysql://user[:password]@domain/database`) 23 | * `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** 24 | 25 | ### Setting up database 26 | Use MySQL 8. MariaDB is confirmed not working at the moment. 27 | 28 | Load the SQL files in order from "migrations" to generate the database schema. 29 | 30 | ### Setting up Python 31 | Reminder Bot uses `python3-dateparser` to handle dates. This depends on Python 3. 32 | 33 | ### Environment Variables 34 | 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. 35 | 36 | __Required Variables__ 37 | * `DATABASE_URL` - the URL of your MySQL database (`mysql://user[:password]@domain/database`) 38 | * `DISCORD_TOKEN` - your application's bot user's authorization token 39 | 40 | __Other Variables__ 41 | * `MIN_INTERVAL` - default `600`, defines the shortest interval the bot should accept 42 | * `LOCAL_TIMEZONE` - default `UTC`, necessary for calculations in the natural language processor 43 | * `SUBSCRIPTION_ROLES` - default `None`, accepts a list of Discord role IDs that are given to subscribed users 44 | * `CNC_GUILD` - default `None`, accepts a single Discord guild ID for the server that the subscription roles belong to 45 | * `PYTHON_LOCATION` - default `venv/bin/python3`. Can be changed if your Python executable is located somewhere else 46 | * `THEME_COLOR` - default `8fb677`. Specifies the hex value of the color to use on info message embeds 47 | -------------------------------------------------------------------------------- /Rocket.toml: -------------------------------------------------------------------------------- 1 | [default] 2 | address = "0.0.0.0" 3 | port = 18920 4 | template_dir = "web/templates" 5 | limits = { json = "10MiB" } 6 | 7 | [debug] 8 | secret_key = "tR8krio5FXTnnyIZNiJDXPondz0kI1v6X6BXZcBGIRY=" 9 | 10 | [debug.tls] 11 | certs = "web/private/rsa_sha256_cert.pem" 12 | key = "web/private/rsa_sha256_key.pem" 13 | 14 | [debug.rsa_sha256.tls] 15 | certs = "web/private/rsa_sha256_cert.pem" 16 | key = "web/private/rsa_sha256_key.pem" 17 | 18 | [debug.ecdsa_nistp256_sha256.tls] 19 | certs = "web/private/ecdsa_nistp256_sha256_cert.pem" 20 | key = "web/private/ecdsa_nistp256_sha256_key_pkcs8.pem" 21 | 22 | [debug.ecdsa_nistp384_sha384.tls] 23 | certs = "web/private/ecdsa_nistp384_sha384_cert.pem" 24 | key = "web/private/ecdsa_nistp384_sha384_key_pkcs8.pem" 25 | 26 | [debug.ed25519.tls] 27 | certs = "web/private/ed25519_cert.pem" 28 | key = "eb/private/ed25519_key.pem" 29 | -------------------------------------------------------------------------------- /build.rs: -------------------------------------------------------------------------------- 1 | fn main() { 2 | println!("cargo:rerun-if-changed=migrations"); 3 | } 4 | -------------------------------------------------------------------------------- /conf/default.env: -------------------------------------------------------------------------------- 1 | DATABASE_URL= 2 | 3 | DISCORD_TOKEN= 4 | PATREON_GUILD_ID= 5 | PATREON_ROLE_ID= 6 | 7 | LOCAL_TIMEZONE= 8 | MIN_INTERVAL= 9 | PYTHON_LOCATION=/usr/bin/python3 10 | DONTRUN=web 11 | SECRET_KEY= 12 | 13 | REMIND_INTERVAL= 14 | OAUTH2_DISCORD_CALLBACK= 15 | OAUTH2_CLIENT_ID= 16 | OAUTH2_CLIENT_SECRET= 17 | -------------------------------------------------------------------------------- /debian/postinst: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | set -e 4 | 5 | id -u reminder &>/dev/null || useradd -r -M reminder 6 | 7 | if [ ! -f /etc/reminder-rs/config.env ]; then 8 | cp /etc/reminder-rs/default.env /etc/reminder-rs/config.env 9 | fi 10 | 11 | chown reminder /etc/reminder-rs/config.env 12 | 13 | #DEBHELPER# 14 | -------------------------------------------------------------------------------- /debian/postrm: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | set -e 4 | 5 | id -u reminder &>/dev/null || userdel reminder 6 | 7 | if [ -f /etc/reminder-rs/config.env ]; then 8 | rm /etc/reminder-rs/config.env 9 | fi 10 | 11 | #DEBHELPER# 12 | -------------------------------------------------------------------------------- /dp.py: -------------------------------------------------------------------------------- 1 | import dateparser 2 | import sys 3 | import pytz 4 | from datetime import datetime 5 | 6 | dt = dateparser.parse(sys.argv[1], settings={ 7 | 'TIMEZONE': sys.argv[2], 8 | 'TO_TIMEZONE': sys.argv[3], 9 | 'RELATIVE_BASE': datetime.now(pytz.timezone(sys.argv[2])).replace(tzinfo=None), 10 | 'PREFER_DATES_FROM': 'future', 11 | }) 12 | 13 | sys.stdout.write(str(int(dt.timestamp()) if dt is not None else -1)) 14 | -------------------------------------------------------------------------------- /migrations/20210603000000_initial.sql: -------------------------------------------------------------------------------- 1 | SET FOREIGN_KEY_CHECKS=0; 2 | 3 | CREATE TABLE guilds ( 4 | id INT UNSIGNED UNIQUE NOT NULL AUTO_INCREMENT, 5 | guild BIGINT UNSIGNED UNIQUE NOT NULL, 6 | 7 | name VARCHAR(100), 8 | 9 | prefix VARCHAR(5) DEFAULT '$' NOT NULL, 10 | timezone VARCHAR(32) DEFAULT 'UTC' NOT NULL, 11 | 12 | default_channel_id INT UNSIGNED, 13 | default_username VARCHAR(32) DEFAULT 'Reminder' NOT NULL, 14 | default_avatar VARCHAR(512) DEFAULT 'https://raw.githubusercontent.com/reminder-bot/logos/master/Remind_Me_Bot_Logo_PPic.jpg' NOT NULL, 15 | 16 | PRIMARY KEY (id), 17 | FOREIGN KEY (default_channel_id) REFERENCES channels(id) ON DELETE SET NULL 18 | ); 19 | 20 | CREATE TABLE channels ( 21 | id INT UNSIGNED UNIQUE NOT NULL AUTO_INCREMENT, 22 | channel BIGINT UNSIGNED UNIQUE NOT NULL, 23 | 24 | name VARCHAR(100), 25 | 26 | nudge SMALLINT NOT NULL DEFAULT 0, 27 | blacklisted BOOL NOT NULL DEFAULT FALSE, 28 | 29 | webhook_id BIGINT UNSIGNED UNIQUE, 30 | webhook_token TEXT, 31 | 32 | paused BOOL NOT NULL DEFAULT 0, 33 | paused_until TIMESTAMP, 34 | 35 | guild_id INT UNSIGNED, 36 | 37 | PRIMARY KEY (id), 38 | FOREIGN KEY (guild_id) REFERENCES guilds(id) ON DELETE CASCADE 39 | ); 40 | 41 | CREATE TABLE users ( 42 | id INT UNSIGNED AUTO_INCREMENT UNIQUE NOT NULL, 43 | user BIGINT UNSIGNED UNIQUE NOT NULL, 44 | 45 | name VARCHAR(37) NOT NULL, 46 | 47 | dm_channel INT UNSIGNED UNIQUE NOT NULL, 48 | 49 | language VARCHAR(2) DEFAULT 'EN' NOT NULL, 50 | timezone VARCHAR(32) DEFAULT 'UTC' NOT NULL, 51 | meridian_time BOOLEAN DEFAULT 0 NOT NULL, 52 | 53 | allowed_dm BOOLEAN DEFAULT 1 NOT NULL, 54 | 55 | patreon BOOLEAN NOT NULL DEFAULT 0, 56 | 57 | PRIMARY KEY (id), 58 | FOREIGN KEY (dm_channel) REFERENCES channels(id) ON DELETE RESTRICT 59 | ); 60 | 61 | CREATE TABLE roles ( 62 | id INT UNSIGNED UNIQUE NOT NULL AUTO_INCREMENT, 63 | role BIGINT UNSIGNED UNIQUE NOT NULL, 64 | 65 | name VARCHAR(100), 66 | 67 | guild_id INT UNSIGNED NOT NULL, 68 | 69 | PRIMARY KEY (id), 70 | FOREIGN KEY (guild_id) REFERENCES guilds(id) ON DELETE CASCADE 71 | ); 72 | 73 | CREATE TABLE embeds ( 74 | id INT UNSIGNED AUTO_INCREMENT UNIQUE NOT NULL, 75 | 76 | title VARCHAR(256) NOT NULL DEFAULT '', 77 | description VARCHAR(2048) NOT NULL DEFAULT '', 78 | 79 | image_url VARCHAR(512), 80 | thumbnail_url VARCHAR(512), 81 | 82 | footer VARCHAR(2048) NOT NULL DEFAULT '', 83 | footer_icon VARCHAR(512), 84 | 85 | color MEDIUMINT UNSIGNED NOT NULL DEFAULT 0x0, 86 | 87 | PRIMARY KEY (id) 88 | ); 89 | 90 | CREATE TABLE embed_fields ( 91 | id INT UNSIGNED AUTO_INCREMENT UNIQUE NOT NULL, 92 | 93 | title VARCHAR(256) NOT NULL DEFAULT '', 94 | value VARCHAR(1024) NOT NULL DEFAULT '', 95 | inline BOOL NOT NULL DEFAULT 0, 96 | embed_id INT UNSIGNED NOT NULL, 97 | 98 | PRIMARY KEY (id), 99 | FOREIGN KEY (embed_id) REFERENCES embeds(id) ON DELETE CASCADE 100 | ); 101 | 102 | CREATE TABLE messages ( 103 | id INT UNSIGNED AUTO_INCREMENT UNIQUE NOT NULL, 104 | 105 | content VARCHAR(2048) NOT NULL DEFAULT '', 106 | tts BOOL NOT NULL DEFAULT 0, 107 | embed_id INT UNSIGNED, 108 | 109 | attachment MEDIUMBLOB, 110 | attachment_name VARCHAR(260), 111 | 112 | PRIMARY KEY (id), 113 | FOREIGN KEY (embed_id) REFERENCES embeds(id) ON DELETE SET NULL 114 | ); 115 | 116 | CREATE TABLE reminders ( 117 | id INT UNSIGNED AUTO_INCREMENT UNIQUE NOT NULL, 118 | uid VARCHAR(64) UNIQUE NOT NULL, 119 | 120 | name VARCHAR(24) NOT NULL DEFAULT 'Reminder', 121 | 122 | message_id INT UNSIGNED NOT NULL, 123 | channel_id INT UNSIGNED NOT NULL, 124 | 125 | `time` INT UNSIGNED DEFAULT 0 NOT NULL, 126 | `interval` INT UNSIGNED DEFAULT NULL, 127 | expires TIMESTAMP DEFAULT NULL, 128 | 129 | enabled BOOLEAN DEFAULT 1 NOT NULL, 130 | 131 | avatar VARCHAR(512), 132 | username VARCHAR(32), 133 | 134 | method ENUM('remind', 'natural', 'dashboard', 'todo', 'countdown'), 135 | set_at TIMESTAMP DEFAULT NOW(), 136 | set_by INT UNSIGNED, 137 | 138 | PRIMARY KEY (id), 139 | FOREIGN KEY (message_id) REFERENCES messages(id) ON DELETE RESTRICT, 140 | FOREIGN KEY (channel_id) REFERENCES channels(id) ON DELETE CASCADE, 141 | FOREIGN KEY (set_by) REFERENCES users(id) ON DELETE SET NULL 142 | ); 143 | 144 | CREATE TRIGGER message_cleanup AFTER DELETE ON reminders 145 | FOR EACH ROW 146 | DELETE FROM messages WHERE id = OLD.message_id; 147 | 148 | CREATE TRIGGER embed_cleanup AFTER DELETE ON messages 149 | FOR EACH ROW 150 | DELETE FROM embeds WHERE id = OLD.embed_id; 151 | 152 | CREATE TABLE todos ( 153 | id INT UNSIGNED AUTO_INCREMENT UNIQUE NOT NULL, 154 | user_id INT UNSIGNED, 155 | guild_id INT UNSIGNED, 156 | channel_id INT UNSIGNED, 157 | value VARCHAR(2000) NOT NULL, 158 | 159 | PRIMARY KEY (id), 160 | FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE SET NULL, 161 | FOREIGN KEY (guild_id) REFERENCES guilds(id) ON DELETE CASCADE, 162 | FOREIGN KEY (channel_id) REFERENCES channels(id) ON DELETE SET NULL 163 | ); 164 | 165 | CREATE TABLE command_restrictions ( 166 | id INT UNSIGNED AUTO_INCREMENT UNIQUE NOT NULL, 167 | 168 | role_id INT UNSIGNED NOT NULL, 169 | command ENUM('todos', 'natural', 'remind', 'interval', 'timer', 'del', 'look', 'alias', 'countdown') NOT NULL, 170 | 171 | PRIMARY KEY (id), 172 | FOREIGN KEY (role_id) REFERENCES roles(id) ON DELETE CASCADE, 173 | UNIQUE KEY (`role_id`, `command`) 174 | ); 175 | 176 | CREATE TABLE timers ( 177 | id INT UNSIGNED AUTO_INCREMENT UNIQUE NOT NULL, 178 | start_time TIMESTAMP NOT NULL DEFAULT NOW(), 179 | name VARCHAR(32) NOT NULL, 180 | owner BIGINT UNSIGNED NOT NULL, 181 | 182 | PRIMARY KEY (id) 183 | ); 184 | 185 | CREATE TABLE events ( 186 | id INT UNSIGNED AUTO_INCREMENT UNIQUE NOT NULL, 187 | `time` TIMESTAMP NOT NULL DEFAULT NOW(), 188 | 189 | event_name ENUM('edit', 'enable', 'disable', 'delete') NOT NULL, 190 | bulk_count INT UNSIGNED, 191 | 192 | guild_id INT UNSIGNED NOT NULL, 193 | user_id INT UNSIGNED, 194 | reminder_id INT UNSIGNED, 195 | 196 | PRIMARY KEY (id), 197 | FOREIGN KEY (guild_id) REFERENCES guilds(id) ON DELETE CASCADE, 198 | FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE SET NULL, 199 | FOREIGN KEY (reminder_id) REFERENCES reminders(id) ON DELETE SET NULL 200 | ); 201 | 202 | CREATE TABLE command_aliases ( 203 | id INT UNSIGNED AUTO_INCREMENT UNIQUE NOT NULL, 204 | 205 | guild_id INT UNSIGNED NOT NULL, 206 | name VARCHAR(12) NOT NULL, 207 | 208 | command VARCHAR(2048) NOT NULL, 209 | 210 | PRIMARY KEY (id), 211 | FOREIGN KEY (guild_id) REFERENCES guilds(id) ON DELETE CASCADE, 212 | UNIQUE KEY (`guild_id`, `name`) 213 | ); 214 | 215 | CREATE TABLE guild_users ( 216 | guild INT UNSIGNED NOT NULL, 217 | user INT UNSIGNED NOT NULL, 218 | 219 | can_access BOOL NOT NULL DEFAULT 0, 220 | 221 | FOREIGN KEY (guild) REFERENCES guilds(id) ON DELETE CASCADE, 222 | FOREIGN KEY (user) REFERENCES users(id) ON DELETE CASCADE, 223 | UNIQUE KEY (guild, user) 224 | ); 225 | 226 | CREATE EVENT event_cleanup 227 | ON SCHEDULE AT CURRENT_TIMESTAMP + INTERVAL 1 DAY 228 | ON COMPLETION PRESERVE 229 | DO DELETE FROM events WHERE `time` < DATE_SUB(NOW(), INTERVAL 5 DAY); 230 | -------------------------------------------------------------------------------- /migrations/20210922000000_macro.sql: -------------------------------------------------------------------------------- 1 | CREATE TABLE macro ( 2 | id INT UNSIGNED AUTO_INCREMENT, 3 | guild_id INT UNSIGNED NOT NULL, 4 | 5 | name VARCHAR(100) NOT NULL, 6 | description VARCHAR(100), 7 | commands TEXT NOT NULL, 8 | 9 | FOREIGN KEY (guild_id) REFERENCES guilds(id) ON DELETE CASCADE, 10 | PRIMARY KEY (id) 11 | ); 12 | -------------------------------------------------------------------------------- /migrations/20220201000000_reminder_variable_intervals.sql: -------------------------------------------------------------------------------- 1 | ALTER TABLE reminders RENAME COLUMN `interval` TO `interval_seconds`; 2 | ALTER TABLE reminders ADD COLUMN `interval_months` INT UNSIGNED DEFAULT NULL; 3 | -------------------------------------------------------------------------------- /migrations/20220211000000_reminder_templates.sql: -------------------------------------------------------------------------------- 1 | CREATE TABLE reminder_template ( 2 | `id` INT UNSIGNED NOT NULL AUTO_INCREMENT, 3 | 4 | `name` VARCHAR(24) NOT NULL DEFAULT 'Reminder', 5 | 6 | `guild_id` INT UNSIGNED NOT NULL, 7 | 8 | `username` VARCHAR(32) DEFAULT NULL, 9 | `avatar` VARCHAR(512) DEFAULT NULL, 10 | 11 | `content` VARCHAR(2048) NOT NULL DEFAULT '', 12 | `tts` BOOL NOT NULL DEFAULT 0, 13 | `attachment` MEDIUMBLOB, 14 | `attachment_name` VARCHAR(260), 15 | 16 | `embed_title` VARCHAR(256) NOT NULL DEFAULT '', 17 | `embed_description` VARCHAR(2048) NOT NULL DEFAULT '', 18 | `embed_image_url` VARCHAR(512), 19 | `embed_thumbnail_url` VARCHAR(512), 20 | `embed_footer` VARCHAR(2048) NOT NULL DEFAULT '', 21 | `embed_footer_url` VARCHAR(512), 22 | `embed_author` VARCHAR(256) NOT NULL DEFAULT '', 23 | `embed_author_url` VARCHAR(512), 24 | `embed_color` INT UNSIGNED NOT NULL DEFAULT 0x0, 25 | `embed_fields` JSON, 26 | 27 | PRIMARY KEY (id), 28 | 29 | FOREIGN KEY (`guild_id`) REFERENCES guilds (`id`) ON DELETE CASCADE 30 | ); 31 | 32 | ALTER TABLE reminders ADD COLUMN embed_fields JSON; 33 | 34 | update reminders 35 | inner join embed_fields as E 36 | on E.reminder_id = reminders.id 37 | set embed_fields = ( 38 | select JSON_ARRAYAGG( 39 | JSON_OBJECT( 40 | 'title', E.title, 41 | 'value', E.value, 42 | 'inline', 43 | if(inline = 1, cast(TRUE as json), cast(FALSE as json)) 44 | ) 45 | ) 46 | from embed_fields 47 | group by reminder_id 48 | having reminder_id = reminders.id 49 | ); 50 | -------------------------------------------------------------------------------- /migrations/20221210000000_reminder_daily_intervals.sql: -------------------------------------------------------------------------------- 1 | ALTER TABLE reminders ADD COLUMN `interval_days` INT UNSIGNED DEFAULT NULL; 2 | -------------------------------------------------------------------------------- /migrations/20230511125236_reminder_threads.sql: -------------------------------------------------------------------------------- 1 | -- Add migration script here 2 | ALTER TABLE reminders ADD COLUMN `thread_id` BIGINT DEFAULT NULL; 3 | -------------------------------------------------------------------------------- /nginx/reminder-rs: -------------------------------------------------------------------------------- 1 | server { 2 | server_name www.reminder-bot.com; 3 | 4 | return 301 $scheme://reminder-bot.com$request_uri; 5 | } 6 | 7 | server { 8 | listen 80; 9 | server_name reminder-bot.com; 10 | 11 | return 301 https://reminder-bot.com$request_uri; 12 | } 13 | 14 | server { 15 | listen 443 ssl; 16 | server_name reminder-bot.com; 17 | 18 | ssl_certificate /etc/letsencrypt/live/reminder-bot.com/fullchain.pem; 19 | ssl_certificate_key /etc/letsencrypt/live/reminder-bot.com/privkey.pem; 20 | 21 | access_log /var/log/nginx/access.log; 22 | error_log /var/log/nginx/error.log; 23 | 24 | proxy_buffer_size 128k; 25 | proxy_buffers 4 256k; 26 | proxy_busy_buffers_size 256k; 27 | 28 | location / { 29 | proxy_pass http://localhost:18920; 30 | proxy_redirect off; 31 | proxy_set_header Host $host; 32 | proxy_set_header X-Real-IP $remote_addr; 33 | proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; 34 | proxy_set_header X-Forwarded-Proto $scheme; 35 | } 36 | 37 | location /static { 38 | alias /var/www/reminder-rs/static; 39 | expires 30d; 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /postman/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "postman" 3 | version = "0.1.0" 4 | edition = "2021" 5 | 6 | [dependencies] 7 | tokio = { version = "1", features = ["process", "full"] } 8 | regex = "1.4" 9 | log = "0.4" 10 | chrono = "0.4" 11 | chrono-tz = { version = "0.5", features = ["serde"] } 12 | lazy_static = "1.4" 13 | num-integer = "0.1" 14 | serde = "1.0" 15 | sqlx = { version = "0.6", features = ["runtime-tokio-rustls", "macros", "mysql", "bigdecimal", "chrono", "json"]} 16 | serenity = { version = "0.11.1", default-features = false, features = ["builder", "cache", "client", "gateway", "http", "model", "utils", "rustls_backend"] } 17 | -------------------------------------------------------------------------------- /postman/src/lib.rs: -------------------------------------------------------------------------------- 1 | mod sender; 2 | 3 | use std::env; 4 | 5 | use log::{info, warn}; 6 | use serenity::client::Context; 7 | use sqlx::{Executor, MySql}; 8 | use tokio::{ 9 | sync::broadcast::Receiver, 10 | time::{sleep_until, Duration, Instant}, 11 | }; 12 | 13 | type Database = MySql; 14 | 15 | pub async fn initialize( 16 | mut kill: Receiver<()>, 17 | ctx: Context, 18 | pool: impl Executor<'_, Database = Database> + Copy, 19 | ) -> Result<(), &'static str> { 20 | tokio::select! { 21 | output = _initialize(ctx, pool) => Ok(output), 22 | _ = kill.recv() => { 23 | warn!("Received terminate signal. Goodbye"); 24 | Err("Received terminate signal. Goodbye") 25 | } 26 | } 27 | } 28 | 29 | async fn _initialize(ctx: Context, pool: impl Executor<'_, Database = Database> + Copy) { 30 | let remind_interval = env::var("REMIND_INTERVAL") 31 | .map(|inner| inner.parse::().ok()) 32 | .ok() 33 | .flatten() 34 | .unwrap_or(10); 35 | 36 | loop { 37 | let sleep_to = Instant::now() + Duration::from_secs(remind_interval); 38 | let reminders = sender::Reminder::fetch_reminders(pool).await; 39 | 40 | if reminders.len() > 0 { 41 | info!("Preparing to send {} reminders.", reminders.len()); 42 | 43 | for reminder in reminders { 44 | reminder.send(pool, ctx.clone()).await; 45 | } 46 | } 47 | 48 | sleep_until(sleep_to).await; 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /rustfmt.toml: -------------------------------------------------------------------------------- 1 | imports_granularity = "Crate" 2 | group_imports = "StdExternalCrate" 3 | use_small_heuristics = "Max" 4 | -------------------------------------------------------------------------------- /src/commands/autocomplete.rs: -------------------------------------------------------------------------------- 1 | use std::time::{SystemTime, UNIX_EPOCH}; 2 | 3 | use chrono_tz::TZ_VARIANTS; 4 | use poise::AutocompleteChoice; 5 | 6 | use crate::{models::CtxData, time_parser::natural_parser, Context}; 7 | 8 | pub async fn timezone_autocomplete(ctx: Context<'_>, partial: &str) -> Vec { 9 | if partial.is_empty() { 10 | ctx.data().popular_timezones.iter().map(|t| t.to_string()).collect::>() 11 | } else { 12 | TZ_VARIANTS 13 | .iter() 14 | .filter(|tz| tz.to_string().contains(&partial)) 15 | .take(25) 16 | .map(|t| t.to_string()) 17 | .collect::>() 18 | } 19 | } 20 | 21 | pub async fn macro_name_autocomplete(ctx: Context<'_>, partial: &str) -> Vec { 22 | sqlx::query!( 23 | " 24 | SELECT name 25 | FROM macro 26 | WHERE 27 | guild_id = (SELECT id FROM guilds WHERE guild = ?) 28 | AND name LIKE CONCAT(?, '%')", 29 | ctx.guild_id().unwrap().0, 30 | partial, 31 | ) 32 | .fetch_all(&ctx.data().database) 33 | .await 34 | .unwrap_or_default() 35 | .iter() 36 | .map(|s| s.name.clone()) 37 | .collect() 38 | } 39 | 40 | pub async fn time_hint_autocomplete( 41 | ctx: Context<'_>, 42 | partial: &str, 43 | ) -> Vec> { 44 | if partial.is_empty() { 45 | vec![AutocompleteChoice { 46 | name: "Start typing a time...".to_string(), 47 | value: "now".to_string(), 48 | }] 49 | } else { 50 | match natural_parser(partial, &ctx.timezone().await.to_string()).await { 51 | Some(timestamp) => match SystemTime::now().duration_since(UNIX_EPOCH) { 52 | Ok(now) => { 53 | let diff = timestamp - now.as_secs() as i64; 54 | 55 | if diff < 0 { 56 | vec![AutocompleteChoice { 57 | name: "Time is in the past".to_string(), 58 | value: "now".to_string(), 59 | }] 60 | } else { 61 | if diff > 86400 { 62 | vec![ 63 | AutocompleteChoice { 64 | name: partial.to_string(), 65 | value: partial.to_string(), 66 | }, 67 | AutocompleteChoice { 68 | name: format!( 69 | "In approximately {} days, {} hours", 70 | diff / 86400, 71 | (diff % 86400) / 3600 72 | ), 73 | value: partial.to_string(), 74 | }, 75 | ] 76 | } else if diff > 3600 { 77 | vec![ 78 | AutocompleteChoice { 79 | name: partial.to_string(), 80 | value: partial.to_string(), 81 | }, 82 | AutocompleteChoice { 83 | name: format!("In approximately {} hours", diff / 3600), 84 | value: partial.to_string(), 85 | }, 86 | ] 87 | } else { 88 | vec![ 89 | AutocompleteChoice { 90 | name: partial.to_string(), 91 | value: partial.to_string(), 92 | }, 93 | AutocompleteChoice { 94 | name: format!("In approximately {} minutes", diff / 60), 95 | value: partial.to_string(), 96 | }, 97 | ] 98 | } 99 | } 100 | } 101 | Err(_) => { 102 | vec![AutocompleteChoice { 103 | name: partial.to_string(), 104 | value: partial.to_string(), 105 | }] 106 | } 107 | }, 108 | 109 | None => { 110 | vec![AutocompleteChoice { 111 | name: "Time not recognised".to_string(), 112 | value: "now".to_string(), 113 | }] 114 | } 115 | } 116 | } 117 | } 118 | -------------------------------------------------------------------------------- /src/commands/command_macro/delete.rs: -------------------------------------------------------------------------------- 1 | use super::super::autocomplete::macro_name_autocomplete; 2 | use crate::{Context, Error}; 3 | 4 | /// Delete a recorded macro 5 | #[poise::command( 6 | slash_command, 7 | rename = "delete", 8 | guild_only = true, 9 | default_member_permissions = "MANAGE_GUILD", 10 | identifying_name = "delete_macro" 11 | )] 12 | pub async fn delete_macro( 13 | ctx: Context<'_>, 14 | #[description = "Name of macro to delete"] 15 | #[autocomplete = "macro_name_autocomplete"] 16 | name: String, 17 | ) -> Result<(), Error> { 18 | match sqlx::query!( 19 | " 20 | SELECT id FROM macro WHERE guild_id = (SELECT id FROM guilds WHERE guild = ?) AND name = ?", 21 | ctx.guild_id().unwrap().0, 22 | name 23 | ) 24 | .fetch_one(&ctx.data().database) 25 | .await 26 | { 27 | Ok(row) => { 28 | sqlx::query!("DELETE FROM macro WHERE id = ?", row.id) 29 | .execute(&ctx.data().database) 30 | .await 31 | .unwrap(); 32 | 33 | ctx.say(format!("Macro \"{}\" deleted", name)).await?; 34 | } 35 | 36 | Err(sqlx::Error::RowNotFound) => { 37 | ctx.say(format!("Macro \"{}\" not found", name)).await?; 38 | } 39 | 40 | Err(e) => { 41 | panic!("{}", e); 42 | } 43 | } 44 | 45 | Ok(()) 46 | } 47 | -------------------------------------------------------------------------------- /src/commands/command_macro/list.rs: -------------------------------------------------------------------------------- 1 | use poise::CreateReply; 2 | 3 | use crate::{ 4 | component_models::pager::{MacroPager, Pager}, 5 | consts::THEME_COLOR, 6 | models::{command_macro::CommandMacro, CtxData}, 7 | Context, Error, 8 | }; 9 | 10 | /// List recorded macros 11 | #[poise::command( 12 | slash_command, 13 | rename = "list", 14 | guild_only = true, 15 | default_member_permissions = "MANAGE_GUILD", 16 | identifying_name = "list_macro" 17 | )] 18 | pub async fn list_macro(ctx: Context<'_>) -> Result<(), Error> { 19 | let macros = ctx.command_macros().await?; 20 | 21 | let resp = show_macro_page(¯os, 0); 22 | 23 | ctx.send(|m| { 24 | *m = resp; 25 | m 26 | }) 27 | .await?; 28 | 29 | Ok(()) 30 | } 31 | 32 | pub fn max_macro_page(macros: &[CommandMacro]) -> usize { 33 | ((macros.len() as f64) / 25.0).ceil() as usize 34 | } 35 | 36 | pub fn show_macro_page(macros: &[CommandMacro], page: usize) -> CreateReply { 37 | let pager = MacroPager::new(page); 38 | 39 | if macros.is_empty() { 40 | let mut reply = CreateReply::default(); 41 | 42 | reply.embed(|e| { 43 | e.title("Macros") 44 | .description("No Macros Set Up. Use `/macro record` to get started.") 45 | .color(*THEME_COLOR) 46 | }); 47 | 48 | return reply; 49 | } 50 | 51 | let pages = max_macro_page(macros); 52 | 53 | let mut page = page; 54 | if page >= pages { 55 | page = pages - 1; 56 | } 57 | 58 | let lower = (page * 25).min(macros.len()); 59 | let upper = ((page + 1) * 25).min(macros.len()); 60 | 61 | let fields = macros[lower..upper].iter().map(|m| { 62 | if let Some(description) = &m.description { 63 | ( 64 | m.name.clone(), 65 | format!("*{}*\n- Has {} commands", description, m.commands.len()), 66 | true, 67 | ) 68 | } else { 69 | (m.name.clone(), format!("- Has {} commands", m.commands.len()), true) 70 | } 71 | }); 72 | 73 | let mut reply = CreateReply::default(); 74 | 75 | reply 76 | .embed(|e| { 77 | e.title("Macros") 78 | .fields(fields) 79 | .footer(|f| f.text(format!("Page {} of {}", page + 1, pages))) 80 | .color(*THEME_COLOR) 81 | }) 82 | .components(|comp| { 83 | pager.create_button_row(pages, comp); 84 | 85 | comp 86 | }); 87 | 88 | reply 89 | } 90 | -------------------------------------------------------------------------------- /src/commands/command_macro/mod.rs: -------------------------------------------------------------------------------- 1 | use crate::{Context, Error}; 2 | 3 | pub mod delete; 4 | pub mod list; 5 | pub mod migrate; 6 | pub mod record; 7 | pub mod run; 8 | 9 | /// Record and replay command sequences 10 | #[poise::command( 11 | slash_command, 12 | rename = "macro", 13 | guild_only = true, 14 | default_member_permissions = "MANAGE_GUILD", 15 | identifying_name = "macro_base" 16 | )] 17 | pub async fn macro_base(_ctx: Context<'_>) -> Result<(), Error> { 18 | Ok(()) 19 | } 20 | -------------------------------------------------------------------------------- /src/commands/command_macro/record.rs: -------------------------------------------------------------------------------- 1 | use std::collections::hash_map::Entry; 2 | 3 | use crate::{consts::THEME_COLOR, models::command_macro::CommandMacro, Context, Error}; 4 | 5 | /// Start recording up to 5 commands to replay 6 | #[poise::command( 7 | slash_command, 8 | rename = "record", 9 | guild_only = true, 10 | default_member_permissions = "MANAGE_GUILD", 11 | identifying_name = "record_macro" 12 | )] 13 | pub async fn record_macro( 14 | ctx: Context<'_>, 15 | #[description = "Name for the new macro"] name: String, 16 | #[description = "Description for the new macro"] description: Option, 17 | ) -> Result<(), Error> { 18 | if name.len() > 100 { 19 | ctx.say("Name must be less than 100 characters").await?; 20 | 21 | return Ok(()); 22 | } 23 | 24 | if description.as_ref().map_or(0, |d| d.len()) > 100 { 25 | ctx.say("Description must be less than 100 characters").await?; 26 | 27 | return Ok(()); 28 | } 29 | 30 | let guild_id = ctx.guild_id().unwrap(); 31 | 32 | let row = sqlx::query!( 33 | " 34 | SELECT 1 as _e FROM macro WHERE guild_id = (SELECT id FROM guilds WHERE guild = ?) AND name = ?", 35 | guild_id.0, 36 | name 37 | ) 38 | .fetch_one(&ctx.data().database) 39 | .await; 40 | 41 | if row.is_ok() { 42 | ctx.send(|m| { 43 | m.ephemeral(true).embed(|e| { 44 | e.title("Unique Name Required") 45 | .description( 46 | "A macro already exists under this name. 47 | Please select a unique name for your macro.", 48 | ) 49 | .color(*THEME_COLOR) 50 | }) 51 | }) 52 | .await?; 53 | } else { 54 | let okay = { 55 | let mut lock = ctx.data().recording_macros.write().await; 56 | 57 | if let Entry::Vacant(e) = lock.entry((guild_id, ctx.author().id)) { 58 | e.insert(CommandMacro { guild_id, name, description, commands: vec![] }); 59 | true 60 | } else { 61 | false 62 | } 63 | }; 64 | 65 | if okay { 66 | ctx.send(|m| { 67 | m.ephemeral(true).embed(|e| { 68 | e.title("Macro Recording Started") 69 | .description( 70 | "Run up to 5 commands, or type `/macro finish` to stop at any point. 71 | Any commands ran as part of recording will be inconsequential", 72 | ) 73 | .color(*THEME_COLOR) 74 | }) 75 | }) 76 | .await?; 77 | } else { 78 | ctx.send(|m| { 79 | m.ephemeral(true).embed(|e| { 80 | e.title("Macro Already Recording") 81 | .description( 82 | "You are already recording a macro in this server. 83 | Please use `/macro finish` to end this recording before starting another.", 84 | ) 85 | .color(*THEME_COLOR) 86 | }) 87 | }) 88 | .await?; 89 | } 90 | } 91 | 92 | Ok(()) 93 | } 94 | 95 | /// Finish current macro recording 96 | #[poise::command( 97 | slash_command, 98 | rename = "finish", 99 | guild_only = true, 100 | default_member_permissions = "MANAGE_GUILD", 101 | identifying_name = "finish_macro" 102 | )] 103 | pub async fn finish_macro(ctx: Context<'_>) -> Result<(), Error> { 104 | let key = (ctx.guild_id().unwrap(), ctx.author().id); 105 | 106 | { 107 | let lock = ctx.data().recording_macros.read().await; 108 | let contained = lock.get(&key); 109 | 110 | if contained.map_or(true, |cmacro| cmacro.commands.is_empty()) { 111 | ctx.send(|m| { 112 | m.embed(|e| { 113 | e.title("No Macro Recorded") 114 | .description("Use `/macro record` to start recording a macro") 115 | .color(*THEME_COLOR) 116 | }) 117 | }) 118 | .await?; 119 | } else { 120 | let command_macro = contained.unwrap(); 121 | let json = serde_json::to_string(&command_macro.commands).unwrap(); 122 | 123 | sqlx::query!( 124 | "INSERT INTO macro (guild_id, name, description, commands) VALUES ((SELECT id FROM guilds WHERE guild = ?), ?, ?, ?)", 125 | command_macro.guild_id.0, 126 | command_macro.name, 127 | command_macro.description, 128 | json 129 | ) 130 | .execute(&ctx.data().database) 131 | .await 132 | .unwrap(); 133 | 134 | ctx.send(|m| { 135 | m.embed(|e| { 136 | e.title("Macro Recorded") 137 | .description("Use `/macro run` to execute the macro") 138 | .color(*THEME_COLOR) 139 | }) 140 | }) 141 | .await?; 142 | } 143 | } 144 | 145 | { 146 | let mut lock = ctx.data().recording_macros.write().await; 147 | lock.remove(&key); 148 | } 149 | 150 | Ok(()) 151 | } 152 | -------------------------------------------------------------------------------- /src/commands/command_macro/run.rs: -------------------------------------------------------------------------------- 1 | use super::super::autocomplete::macro_name_autocomplete; 2 | use crate::{models::command_macro::guild_command_macro, Context, Data, Error, THEME_COLOR}; 3 | 4 | /// Run a recorded macro 5 | #[poise::command( 6 | slash_command, 7 | rename = "run", 8 | guild_only = true, 9 | default_member_permissions = "MANAGE_GUILD", 10 | identifying_name = "run_macro" 11 | )] 12 | pub async fn run_macro( 13 | ctx: poise::ApplicationContext<'_, Data, Error>, 14 | #[description = "Name of macro to run"] 15 | #[autocomplete = "macro_name_autocomplete"] 16 | name: String, 17 | ) -> Result<(), Error> { 18 | match guild_command_macro(&Context::Application(ctx), &name).await { 19 | Some(command_macro) => { 20 | Context::Application(ctx) 21 | .send(|b| { 22 | b.embed(|e| { 23 | e.title("Running Macro").color(*THEME_COLOR).description(format!( 24 | "Running macro {} ({} commands)", 25 | command_macro.name, 26 | command_macro.commands.len() 27 | )) 28 | }) 29 | }) 30 | .await?; 31 | 32 | for command in command_macro.commands { 33 | if let Some(action) = command.action { 34 | match (action)(poise::ApplicationContext { args: &command.options, ..ctx }) 35 | .await 36 | { 37 | Ok(()) => {} 38 | Err(e) => { 39 | println!("{:?}", e); 40 | } 41 | } 42 | } else { 43 | Context::Application(ctx) 44 | .say(format!("Command \"{}\" not found", command.command_name)) 45 | .await?; 46 | } 47 | } 48 | } 49 | 50 | None => { 51 | Context::Application(ctx).say(format!("Macro \"{}\" not found", name)).await?; 52 | } 53 | } 54 | 55 | Ok(()) 56 | } 57 | -------------------------------------------------------------------------------- /src/commands/info_cmds.rs: -------------------------------------------------------------------------------- 1 | use chrono::offset::Utc; 2 | use poise::{serenity_prelude as serenity, serenity_prelude::Mentionable}; 3 | 4 | use crate::{models::CtxData, Context, Error, THEME_COLOR}; 5 | 6 | fn footer( 7 | ctx: Context<'_>, 8 | ) -> impl FnOnce(&mut serenity::CreateEmbedFooter) -> &mut serenity::CreateEmbedFooter { 9 | let shard_count = ctx.discord().cache.shard_count(); 10 | let shard = ctx.discord().shard_id; 11 | 12 | move |f| { 13 | f.text(format!( 14 | "{}\nshard {} of {}", 15 | concat!(env!("CARGO_PKG_NAME"), " ver ", env!("CARGO_PKG_VERSION")), 16 | shard, 17 | shard_count, 18 | )) 19 | } 20 | } 21 | 22 | /// Get an overview of bot commands 23 | #[poise::command(slash_command)] 24 | pub async fn help(ctx: Context<'_>) -> Result<(), Error> { 25 | let footer = footer(ctx); 26 | 27 | ctx.send(|m| { 28 | m.ephemeral(true).embed(|e| { 29 | e.title("Help") 30 | .color(*THEME_COLOR) 31 | .description( 32 | "__Info Commands__ 33 | `/help` `/info` `/donate` `/dashboard` `/clock` 34 | *run these commands with no options* 35 | 36 | __Reminder Commands__ 37 | `/remind` - Create a new reminder that will send a message at a certain time 38 | `/timer` - Start a timer from now, that will count time passed. Also used to view and remove timers 39 | 40 | __Reminder Management__ 41 | `/del` - Delete reminders 42 | `/look` - View reminders 43 | `/pause` - Pause all reminders on the channel 44 | `/offset` - Move all reminders by a certain time 45 | `/nudge` - Move all new reminders on this channel by a certain time 46 | 47 | __Todo Commands__ 48 | `/todo` - Add, view and manage the server, channel or user todo lists 49 | 50 | __Setup Commands__ 51 | `/timezone` - Set your timezone (necessary for `/remind` to work properly) 52 | `/dm allow/block` - Change your DM settings for reminders. 53 | 54 | __Advanced Commands__ 55 | `/macro` - Record and replay command sequences 56 | ", 57 | ) 58 | .footer(footer) 59 | }) 60 | }) 61 | .await?; 62 | 63 | Ok(()) 64 | } 65 | 66 | /// Get information about the bot 67 | #[poise::command(slash_command)] 68 | pub async fn info(ctx: Context<'_>) -> Result<(), Error> { 69 | let footer = footer(ctx); 70 | 71 | let _ = ctx 72 | .send(|m| { 73 | m.ephemeral(true).embed(|e| { 74 | e.title("Info") 75 | .description( 76 | "Help: `/help` 77 | 78 | **Welcome to Reminder Bot!** 79 | Developer: <@203532103185465344> 80 | Icon: <@253202252821430272> 81 | Find me on https://discord.jellywx.com and on https://github.com/JellyWX :) 82 | 83 | Invite the bot: https://invite.reminder-bot.com/ 84 | Use our dashboard: https://reminder-bot.com/", 85 | ) 86 | .footer(footer) 87 | .color(*THEME_COLOR) 88 | }) 89 | }) 90 | .await; 91 | 92 | Ok(()) 93 | } 94 | 95 | /// Details on supporting the bot and Patreon benefits 96 | #[poise::command(slash_command)] 97 | pub async fn donate(ctx: Context<'_>) -> Result<(), Error> { 98 | let footer = footer(ctx); 99 | 100 | ctx.send(|m| m.embed(|e| { 101 | e.title("Donate") 102 | .description("Thinking of adding a monthly contribution? 103 | Click below for my Patreon and official bot server :) 104 | 105 | **https://www.patreon.com/jellywx/** 106 | **https://discord.jellywx.com/** 107 | 108 | When you subscribe, Patreon will automatically rank you up on our Discord server (make sure you link your Patreon and Discord accounts!) 109 | With your new rank, you'll be able to: 110 | • Set repeating reminders with `interval`, `natural` or the dashboard 111 | • Use unlimited uploads on SoundFX 112 | 113 | (Also, members of servers you __own__ will be able to set repeating reminders via commands) 114 | 115 | Just $2 USD/month! 116 | 117 | *Please note, you must be in the JellyWX Discord server to receive Patreon features*") 118 | .footer(footer) 119 | .color(*THEME_COLOR) 120 | }), 121 | ) 122 | .await?; 123 | 124 | Ok(()) 125 | } 126 | 127 | /// Get the link to the online dashboard 128 | #[poise::command(slash_command)] 129 | pub async fn dashboard(ctx: Context<'_>) -> Result<(), Error> { 130 | let footer = footer(ctx); 131 | 132 | ctx.send(|m| { 133 | m.ephemeral(true).embed(|e| { 134 | e.title("Dashboard") 135 | .description("**https://reminder-bot.com/dashboard**") 136 | .footer(footer) 137 | .color(*THEME_COLOR) 138 | }) 139 | }) 140 | .await?; 141 | 142 | Ok(()) 143 | } 144 | 145 | /// View the current time in your selected timezone 146 | #[poise::command(slash_command)] 147 | pub async fn clock(ctx: Context<'_>) -> Result<(), Error> { 148 | ctx.defer_ephemeral().await?; 149 | 150 | let tz = ctx.timezone().await; 151 | let now = Utc::now().with_timezone(&tz); 152 | 153 | ctx.send(|m| { 154 | m.ephemeral(true).content(format!("Time in **{}**: `{}`", tz, now.format("%H:%M"))) 155 | }) 156 | .await?; 157 | 158 | Ok(()) 159 | } 160 | 161 | /// View the current time in a user's selected timezone 162 | #[poise::command(context_menu_command = "View Local Time")] 163 | pub async fn clock_context_menu(ctx: Context<'_>, user: serenity::User) -> Result<(), Error> { 164 | ctx.defer_ephemeral().await?; 165 | 166 | let user_data = ctx.user_data(user.id).await?; 167 | let tz = user_data.timezone(); 168 | 169 | let now = Utc::now().with_timezone(&tz); 170 | 171 | ctx.send(|m| { 172 | m.ephemeral(true).content(format!( 173 | "Time in {}'s timezone: `{}`", 174 | user.mention(), 175 | now.format("%H:%M") 176 | )) 177 | }) 178 | .await?; 179 | 180 | Ok(()) 181 | } 182 | -------------------------------------------------------------------------------- /src/commands/mod.rs: -------------------------------------------------------------------------------- 1 | mod autocomplete; 2 | pub mod command_macro; 3 | pub mod info_cmds; 4 | pub mod moderation_cmds; 5 | pub mod reminder_cmds; 6 | pub mod todo_cmds; 7 | -------------------------------------------------------------------------------- /src/commands/moderation_cmds.rs: -------------------------------------------------------------------------------- 1 | use chrono::offset::Utc; 2 | use chrono_tz::{Tz, TZ_VARIANTS}; 3 | use levenshtein::levenshtein; 4 | use log::warn; 5 | 6 | use super::autocomplete::timezone_autocomplete; 7 | use crate::{consts::THEME_COLOR, models::CtxData, Context, Error}; 8 | 9 | /// Select your timezone 10 | #[poise::command(slash_command, identifying_name = "timezone")] 11 | pub async fn timezone( 12 | ctx: Context<'_>, 13 | #[description = "Timezone to use from this list: https://gist.github.com/JellyWX/913dfc8b63d45192ad6cb54c829324ee"] 14 | #[autocomplete = "timezone_autocomplete"] 15 | timezone: Option, 16 | ) -> Result<(), Error> { 17 | let mut user_data = ctx.author_data().await.unwrap(); 18 | 19 | let footer_text = format!("Current timezone: {}", user_data.timezone); 20 | 21 | if let Some(timezone) = timezone { 22 | match timezone.parse::() { 23 | Ok(tz) => { 24 | user_data.timezone = timezone.clone(); 25 | user_data.commit_changes(&ctx.data().database).await; 26 | 27 | let now = Utc::now().with_timezone(&tz); 28 | 29 | ctx.send(|m| { 30 | m.embed(|e| { 31 | e.title("Timezone Set") 32 | .description(format!( 33 | "Timezone has been set to **{}**. Your current time should be `{}`", 34 | timezone, 35 | now.format("%H:%M") 36 | )) 37 | .color(*THEME_COLOR) 38 | }) 39 | }) 40 | .await?; 41 | } 42 | 43 | Err(_) => { 44 | let filtered_tz = TZ_VARIANTS 45 | .iter() 46 | .filter(|tz| { 47 | timezone.contains(&tz.to_string()) 48 | || tz.to_string().contains(&timezone) 49 | || levenshtein(&tz.to_string(), &timezone) < 4 50 | }) 51 | .take(25) 52 | .map(|t| t.to_owned()) 53 | .collect::>(); 54 | 55 | let fields = filtered_tz.iter().map(|tz| { 56 | ( 57 | tz.to_string(), 58 | format!("🕗 `{}`", Utc::now().with_timezone(tz).format("%H:%M")), 59 | true, 60 | ) 61 | }); 62 | 63 | ctx.send(|m| { 64 | m.embed(|e| { 65 | e.title("Timezone Not Recognized") 66 | .description("Possibly you meant one of the following timezones, otherwise click [here](https://gist.github.com/JellyWX/913dfc8b63d45192ad6cb54c829324ee):") 67 | .color(*THEME_COLOR) 68 | .fields(fields) 69 | .footer(|f| f.text(footer_text)) 70 | .url("https://gist.github.com/JellyWX/913dfc8b63d45192ad6cb54c829324ee") 71 | }) 72 | }) 73 | .await?; 74 | } 75 | } 76 | } else { 77 | let popular_timezones_iter = ctx.data().popular_timezones.iter().map(|t| { 78 | (t.to_string(), format!("🕗 `{}`", Utc::now().with_timezone(t).format("%H:%M")), true) 79 | }); 80 | 81 | ctx.send(|m| { 82 | m.embed(|e| { 83 | e.title("Timezone Usage") 84 | .description( 85 | "**Usage:** 86 | `/timezone Name` 87 | 88 | **Example:** 89 | `/timezone Europe/London` 90 | 91 | You may want to use one of the popular timezones below, otherwise click [here](https://gist.github.com/JellyWX/913dfc8b63d45192ad6cb54c829324ee):", 92 | ) 93 | .color(*THEME_COLOR) 94 | .fields(popular_timezones_iter) 95 | .footer(|f| f.text(footer_text)) 96 | .url("https://gist.github.com/JellyWX/913dfc8b63d45192ad6cb54c829324ee") 97 | }) 98 | }) 99 | .await?; 100 | } 101 | 102 | Ok(()) 103 | } 104 | 105 | /// Configure whether other users can set reminders to your direct messages 106 | #[poise::command(slash_command, rename = "dm", identifying_name = "allowed_dm")] 107 | pub async fn allowed_dm(_ctx: Context<'_>) -> Result<(), Error> { 108 | Ok(()) 109 | } 110 | 111 | /// Allow other users to set reminders in your direct messages 112 | #[poise::command(slash_command, rename = "allow", identifying_name = "allowed_dm")] 113 | pub async fn set_allowed_dm(ctx: Context<'_>) -> Result<(), Error> { 114 | let mut user_data = ctx.author_data().await?; 115 | user_data.allowed_dm = true; 116 | user_data.commit_changes(&ctx.data().database).await; 117 | 118 | ctx.send(|r| { 119 | r.ephemeral(true).embed(|e| { 120 | e.title("DMs permitted") 121 | .description("You will receive a message if a user sets a DM reminder for you.") 122 | .color(*THEME_COLOR) 123 | }) 124 | }) 125 | .await?; 126 | 127 | Ok(()) 128 | } 129 | 130 | /// Block other users from setting reminders in your direct messages 131 | #[poise::command(slash_command, rename = "block", identifying_name = "allowed_dm")] 132 | pub async fn unset_allowed_dm(ctx: Context<'_>) -> Result<(), Error> { 133 | let mut user_data = ctx.author_data().await?; 134 | user_data.allowed_dm = false; 135 | user_data.commit_changes(&ctx.data().database).await; 136 | 137 | ctx.send(|r| { 138 | r.ephemeral(true).embed(|e| { 139 | e.title("DMs blocked") 140 | .description( 141 | "You can still set DM reminders for yourself or for users with DMs enabled.", 142 | ) 143 | .color(*THEME_COLOR) 144 | }) 145 | }) 146 | .await?; 147 | 148 | Ok(()) 149 | } 150 | 151 | /// View the webhook being used to send reminders to this channel 152 | #[poise::command( 153 | slash_command, 154 | identifying_name = "webhook_url", 155 | required_permissions = "ADMINISTRATOR" 156 | )] 157 | pub async fn webhook(ctx: Context<'_>) -> Result<(), Error> { 158 | match ctx.channel_data().await { 159 | Ok(data) => { 160 | if let (Some(id), Some(token)) = (data.webhook_id, data.webhook_token) { 161 | ctx.send(|b| { 162 | b.ephemeral(true).content(format!( 163 | "**Warning!** 164 | This link can be used by users to anonymously send messages, with or without permissions. 165 | Do not share it! 166 | || https://discord.com/api/webhooks/{}/{} ||", 167 | id, token, 168 | )) 169 | }) 170 | .await?; 171 | } else { 172 | ctx.say("No webhook configured on this channel.").await?; 173 | } 174 | } 175 | Err(e) => { 176 | warn!("Error fetching channel data: {:?}", e); 177 | 178 | ctx.say("No webhook configured on this channel.").await?; 179 | } 180 | } 181 | 182 | Ok(()) 183 | } 184 | -------------------------------------------------------------------------------- /src/consts.rs: -------------------------------------------------------------------------------- 1 | pub const DAY: u64 = 86_400; 2 | pub const HOUR: u64 = 3_600; 3 | pub const MINUTE: u64 = 60; 4 | 5 | pub const EMBED_DESCRIPTION_MAX_LENGTH: usize = 4096; 6 | pub const SELECT_MAX_ENTRIES: usize = 25; 7 | 8 | pub const CHARACTERS: &str = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_"; 9 | 10 | const THEME_COLOR_FALLBACK: u32 = 0x8fb677; 11 | pub const MACRO_MAX_COMMANDS: usize = 5; 12 | 13 | use std::{collections::HashSet, env, iter::FromIterator}; 14 | 15 | use poise::serenity_prelude::model::prelude::AttachmentType; 16 | use regex::Regex; 17 | 18 | lazy_static! { 19 | pub static ref DEFAULT_AVATAR: AttachmentType<'static> = ( 20 | include_bytes!(concat!( 21 | env!("CARGO_MANIFEST_DIR"), 22 | "/assets/", 23 | env!("WEBHOOK_AVATAR", "WEBHOOK_AVATAR not provided for compilation") 24 | )) as &[u8], 25 | env!("WEBHOOK_AVATAR"), 26 | ) 27 | .into(); 28 | pub static ref REGEX_CHANNEL_USER: Regex = Regex::new(r#"\s*<(#|@)(?:!)?(\d+)>\s*"#).unwrap(); 29 | pub static ref SUBSCRIPTION_ROLES: HashSet = HashSet::from_iter( 30 | env::var("PATREON_ROLE_ID") 31 | .map(|var| var 32 | .split(',') 33 | .filter_map(|item| { item.parse::().ok() }) 34 | .collect::>()) 35 | .unwrap_or_else(|_| Vec::new()) 36 | ); 37 | pub static ref CNC_GUILD: Option = 38 | env::var("PATREON_GUILD_ID").map(|var| var.parse::().ok()).ok().flatten(); 39 | pub static ref MIN_INTERVAL: i64 = 40 | env::var("MIN_INTERVAL").ok().and_then(|inner| inner.parse::().ok()).unwrap_or(600); 41 | pub static ref MAX_TIME: i64 = env::var("MAX_TIME") 42 | .ok() 43 | .and_then(|inner| inner.parse::().ok()) 44 | .unwrap_or(60 * 60 * 24 * 365 * 50); 45 | pub static ref LOCAL_TIMEZONE: String = 46 | env::var("LOCAL_TIMEZONE").unwrap_or_else(|_| "UTC".to_string()); 47 | pub static ref THEME_COLOR: u32 = env::var("THEME_COLOR") 48 | .map_or(THEME_COLOR_FALLBACK, |inner| u32::from_str_radix(&inner, 16) 49 | .unwrap_or(THEME_COLOR_FALLBACK)); 50 | pub static ref PYTHON_LOCATION: String = 51 | env::var("PYTHON_LOCATION").unwrap_or_else(|_| "venv/bin/python3".to_string()); 52 | } 53 | -------------------------------------------------------------------------------- /src/event_handlers.rs: -------------------------------------------------------------------------------- 1 | use std::{collections::HashMap, env}; 2 | 3 | use log::error; 4 | use poise::{ 5 | serenity_prelude as serenity, 6 | serenity_prelude::{model::application::interaction::Interaction, utils::shard_id}, 7 | }; 8 | 9 | use crate::{component_models::ComponentDataModel, Data, Error, THEME_COLOR}; 10 | 11 | pub async fn listener( 12 | ctx: &serenity::Context, 13 | event: &poise::Event<'_>, 14 | data: &Data, 15 | ) -> Result<(), Error> { 16 | match event { 17 | poise::Event::Ready { .. } => { 18 | ctx.set_activity(serenity::Activity::watching("for /remind")).await; 19 | } 20 | poise::Event::ChannelDelete { channel } => { 21 | sqlx::query!("DELETE FROM channels WHERE channel = ?", channel.id.as_u64()) 22 | .execute(&data.database) 23 | .await 24 | .unwrap(); 25 | } 26 | poise::Event::GuildCreate { guild, is_new } => { 27 | if *is_new { 28 | let guild_id = guild.id.as_u64().to_owned(); 29 | 30 | sqlx::query!("INSERT IGNORE INTO guilds (guild) VALUES (?)", guild_id) 31 | .execute(&data.database) 32 | .await?; 33 | 34 | if let Err(e) = post_guild_count(ctx, &data.http, guild_id).await { 35 | error!("DiscordBotList: {:?}", e); 36 | } 37 | 38 | let default_channel = guild.default_channel_guaranteed(); 39 | 40 | if let Some(default_channel) = default_channel { 41 | default_channel 42 | .send_message(&ctx, |m| { 43 | m.embed(|e| { 44 | e.title("Thank you for adding Reminder Bot!").description( 45 | "To get started: 46 | • Set your timezone with `/timezone` 47 | • Set up permissions in Server Settings 🠚 Integrations 🠚 Reminder Bot (desktop only) 48 | • Create your first reminder with `/remind` 49 | 50 | __Support__ 51 | If you need any support, please come and ask us! Join our [Discord](https://discord.jellywx.com). 52 | 53 | __Updates__ 54 | To stay up to date on the latest features and fixes, join our [Discord](https://discord.jellywx.com). 55 | ", 56 | ).color(*THEME_COLOR) 57 | }) 58 | }) 59 | .await?; 60 | } 61 | } 62 | } 63 | poise::Event::GuildDelete { incomplete, .. } => { 64 | let _ = sqlx::query!("DELETE FROM guilds WHERE guild = ?", incomplete.id.0) 65 | .execute(&data.database) 66 | .await; 67 | } 68 | poise::Event::InteractionCreate { interaction } => { 69 | if let Interaction::MessageComponent(component) = interaction { 70 | let component_model = ComponentDataModel::from_custom_id(&component.data.custom_id); 71 | 72 | component_model.act(ctx, data, component).await; 73 | } 74 | } 75 | _ => {} 76 | } 77 | 78 | Ok(()) 79 | } 80 | 81 | async fn post_guild_count( 82 | ctx: &serenity::Context, 83 | http: &reqwest::Client, 84 | guild_id: u64, 85 | ) -> Result<(), reqwest::Error> { 86 | if let Ok(token) = env::var("DISCORDBOTS_TOKEN") { 87 | let shard_count = ctx.cache.shard_count(); 88 | let current_shard_id = shard_id(guild_id, shard_count); 89 | 90 | let guild_count = ctx 91 | .cache 92 | .guilds() 93 | .iter() 94 | .filter(|g| shard_id(g.as_u64().to_owned(), shard_count) == current_shard_id) 95 | .count() as u64; 96 | 97 | let mut hm = HashMap::new(); 98 | hm.insert("server_count", guild_count); 99 | hm.insert("shard_id", current_shard_id); 100 | hm.insert("shard_count", shard_count); 101 | 102 | http.post( 103 | format!("https://top.gg/api/bots/{}/stats", ctx.cache.current_user_id().as_u64()) 104 | .as_str(), 105 | ) 106 | .header("Authorization", token) 107 | .json(&hm) 108 | .send() 109 | .await 110 | .map(|_| ()) 111 | } else { 112 | Ok(()) 113 | } 114 | } 115 | -------------------------------------------------------------------------------- /src/hooks.rs: -------------------------------------------------------------------------------- 1 | use poise::{ 2 | serenity_prelude::model::channel::Channel, ApplicationCommandOrAutocompleteInteraction, 3 | }; 4 | 5 | use crate::{consts::MACRO_MAX_COMMANDS, models::command_macro::RecordedCommand, Context, Error}; 6 | 7 | async fn macro_check(ctx: Context<'_>) -> bool { 8 | if let Context::Application(app_ctx) = ctx { 9 | if let ApplicationCommandOrAutocompleteInteraction::ApplicationCommand(_) = 10 | app_ctx.interaction 11 | { 12 | if let Some(guild_id) = ctx.guild_id() { 13 | if ctx.command().identifying_name != "finish_macro" { 14 | let mut lock = ctx.data().recording_macros.write().await; 15 | 16 | if let Some(command_macro) = lock.get_mut(&(guild_id, ctx.author().id)) { 17 | if command_macro.commands.len() >= MACRO_MAX_COMMANDS { 18 | let _ = ctx.send(|m| { 19 | m.ephemeral(true).content( 20 | format!("{} commands already recorded. Please use `/macro finish` to end recording.", MACRO_MAX_COMMANDS), 21 | ) 22 | }) 23 | .await; 24 | } else { 25 | let recorded = RecordedCommand { 26 | action: None, 27 | command_name: ctx.command().identifying_name.clone(), 28 | options: Vec::from(app_ctx.args), 29 | }; 30 | 31 | command_macro.commands.push(recorded); 32 | 33 | let _ = ctx 34 | .send(|m| m.ephemeral(true).content("Command recorded to macro")) 35 | .await; 36 | } 37 | 38 | return false; 39 | } 40 | } 41 | } 42 | } 43 | } 44 | 45 | true 46 | } 47 | 48 | async fn check_self_permissions(ctx: Context<'_>) -> bool { 49 | if let Some(guild) = ctx.guild() { 50 | let user_id = ctx.discord().cache.current_user_id(); 51 | 52 | let manage_webhooks = guild 53 | .member_permissions(&ctx.discord(), user_id) 54 | .await 55 | .map_or(false, |p| p.manage_webhooks()); 56 | 57 | let (view_channel, send_messages, embed_links) = ctx 58 | .channel_id() 59 | .to_channel(&ctx.discord()) 60 | .await 61 | .ok() 62 | .and_then(|c| { 63 | if let Channel::Guild(channel) = c { 64 | let perms = channel.permissions_for_user(&ctx.discord(), user_id).ok()?; 65 | 66 | Some((perms.view_channel(), perms.send_messages(), perms.embed_links())) 67 | } else { 68 | None 69 | } 70 | }) 71 | .unwrap_or((false, false, false)); 72 | 73 | if manage_webhooks && send_messages && embed_links { 74 | true 75 | } else { 76 | let _ = ctx 77 | .send(|m| { 78 | m.content(format!( 79 | "Please ensure the bot has the correct permissions: 80 | 81 | {} **View Channel** 82 | {} **Send Message** 83 | {} **Embed Links** 84 | {} **Manage Webhooks**", 85 | if view_channel { "✅" } else { "❌" }, 86 | if send_messages { "✅" } else { "❌" }, 87 | if embed_links { "✅" } else { "❌" }, 88 | if manage_webhooks { "✅" } else { "❌" }, 89 | )) 90 | }) 91 | .await; 92 | 93 | false 94 | } 95 | } else { 96 | true 97 | } 98 | } 99 | 100 | pub async fn all_checks(ctx: Context<'_>) -> Result { 101 | Ok(macro_check(ctx).await && check_self_permissions(ctx).await) 102 | } 103 | -------------------------------------------------------------------------------- /src/main.rs: -------------------------------------------------------------------------------- 1 | #![feature(int_roundings)] 2 | 3 | #[macro_use] 4 | extern crate lazy_static; 5 | 6 | mod commands; 7 | mod component_models; 8 | mod consts; 9 | mod event_handlers; 10 | mod hooks; 11 | mod interval_parser; 12 | mod models; 13 | mod time_parser; 14 | mod utils; 15 | 16 | use std::{ 17 | collections::HashMap, 18 | env, 19 | error::Error as StdError, 20 | fmt::{Debug, Display, Formatter}, 21 | path::Path, 22 | }; 23 | 24 | use chrono_tz::Tz; 25 | use log::{error, warn}; 26 | use poise::serenity_prelude::model::{ 27 | gateway::GatewayIntents, 28 | id::{GuildId, UserId}, 29 | }; 30 | use sqlx::{MySql, Pool}; 31 | use tokio::sync::{broadcast, broadcast::Sender, RwLock}; 32 | 33 | use crate::{ 34 | commands::{command_macro, info_cmds, moderation_cmds, reminder_cmds, todo_cmds}, 35 | consts::THEME_COLOR, 36 | event_handlers::listener, 37 | hooks::all_checks, 38 | models::command_macro::CommandMacro, 39 | utils::register_application_commands, 40 | }; 41 | 42 | type Database = MySql; 43 | 44 | type Error = Box; 45 | type Context<'a> = poise::Context<'a, Data, Error>; 46 | type ApplicationContext<'a> = poise::ApplicationContext<'a, Data, Error>; 47 | 48 | pub struct Data { 49 | database: Pool, 50 | http: reqwest::Client, 51 | recording_macros: RwLock>>, 52 | popular_timezones: Vec, 53 | _broadcast: Sender<()>, 54 | } 55 | 56 | impl Debug for Data { 57 | fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { 58 | write!(f, "Data {{ .. }}") 59 | } 60 | } 61 | 62 | struct Ended; 63 | 64 | impl Debug for Ended { 65 | fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { 66 | f.write_str("Process ended.") 67 | } 68 | } 69 | 70 | impl Display for Ended { 71 | fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { 72 | f.write_str("Process ended.") 73 | } 74 | } 75 | 76 | impl StdError for Ended {} 77 | 78 | #[tokio::main(flavor = "multi_thread")] 79 | async fn main() -> Result<(), Box> { 80 | let (tx, mut rx) = broadcast::channel(16); 81 | 82 | tokio::select! { 83 | output = _main(tx) => output, 84 | _ = rx.recv() => Err(Box::new(Ended) as Box) 85 | } 86 | } 87 | 88 | async fn _main(tx: Sender<()>) -> Result<(), Box> { 89 | env_logger::init(); 90 | 91 | if Path::new("/etc/reminder-rs/config.env").exists() { 92 | dotenv::from_path("/etc/reminder-rs/config.env")?; 93 | } else { 94 | dotenv::from_path(".env")?; 95 | } 96 | 97 | let discord_token = env::var("DISCORD_TOKEN").expect("Missing DISCORD_TOKEN from environment"); 98 | 99 | let options = poise::FrameworkOptions { 100 | commands: vec![ 101 | info_cmds::help(), 102 | info_cmds::info(), 103 | info_cmds::donate(), 104 | info_cmds::clock(), 105 | info_cmds::clock_context_menu(), 106 | info_cmds::dashboard(), 107 | moderation_cmds::timezone(), 108 | poise::Command { 109 | subcommands: vec![ 110 | moderation_cmds::set_allowed_dm(), 111 | moderation_cmds::unset_allowed_dm(), 112 | ], 113 | ..moderation_cmds::allowed_dm() 114 | }, 115 | moderation_cmds::webhook(), 116 | poise::Command { 117 | subcommands: vec![ 118 | command_macro::delete::delete_macro(), 119 | command_macro::record::finish_macro(), 120 | command_macro::list::list_macro(), 121 | command_macro::record::record_macro(), 122 | command_macro::run::run_macro(), 123 | command_macro::migrate::migrate_macro(), 124 | ], 125 | ..command_macro::macro_base() 126 | }, 127 | reminder_cmds::pause(), 128 | reminder_cmds::offset(), 129 | reminder_cmds::nudge(), 130 | reminder_cmds::look(), 131 | reminder_cmds::delete(), 132 | poise::Command { 133 | subcommands: vec![ 134 | reminder_cmds::list_timer(), 135 | reminder_cmds::start_timer(), 136 | reminder_cmds::delete_timer(), 137 | ], 138 | ..reminder_cmds::timer_base() 139 | }, 140 | reminder_cmds::multiline(), 141 | reminder_cmds::remind(), 142 | poise::Command { 143 | subcommands: vec![ 144 | poise::Command { 145 | subcommands: vec![ 146 | todo_cmds::todo_guild_add(), 147 | todo_cmds::todo_guild_view(), 148 | ], 149 | ..todo_cmds::todo_guild_base() 150 | }, 151 | poise::Command { 152 | subcommands: vec![ 153 | todo_cmds::todo_channel_add(), 154 | todo_cmds::todo_channel_view(), 155 | ], 156 | ..todo_cmds::todo_channel_base() 157 | }, 158 | poise::Command { 159 | subcommands: vec![todo_cmds::todo_user_add(), todo_cmds::todo_user_view()], 160 | ..todo_cmds::todo_user_base() 161 | }, 162 | ], 163 | ..todo_cmds::todo_base() 164 | }, 165 | ], 166 | allowed_mentions: None, 167 | command_check: Some(|ctx| Box::pin(all_checks(ctx))), 168 | listener: |ctx, event, _framework, data| Box::pin(listener(ctx, event, data)), 169 | ..Default::default() 170 | }; 171 | 172 | let database = 173 | Pool::connect(&env::var("DATABASE_URL").expect("No database URL provided")).await.unwrap(); 174 | 175 | sqlx::migrate!().run(&database).await?; 176 | 177 | let popular_timezones = sqlx::query!( 178 | "SELECT IFNULL(timezone, 'UTC') AS timezone 179 | FROM users 180 | WHERE timezone IS NOT NULL 181 | GROUP BY timezone 182 | ORDER BY COUNT(timezone) DESC 183 | LIMIT 21" 184 | ) 185 | .fetch_all(&database) 186 | .await 187 | .unwrap() 188 | .iter() 189 | .map(|t| t.timezone.parse::().unwrap()) 190 | .collect::>(); 191 | 192 | poise::Framework::builder() 193 | .token(discord_token) 194 | .user_data_setup(move |ctx, _bot, framework| { 195 | Box::pin(async move { 196 | register_application_commands(ctx, framework, None).await.unwrap(); 197 | 198 | let kill_tx = tx.clone(); 199 | let kill_recv = tx.subscribe(); 200 | 201 | let ctx1 = ctx.clone(); 202 | let ctx2 = ctx.clone(); 203 | 204 | let pool1 = database.clone(); 205 | let pool2 = database.clone(); 206 | 207 | let run_settings = env::var("DONTRUN").unwrap_or_else(|_| "".to_string()); 208 | 209 | if !run_settings.contains("postman") { 210 | tokio::spawn(async move { 211 | match postman::initialize(kill_recv, ctx1, &pool1).await { 212 | Ok(_) => {} 213 | Err(e) => { 214 | error!("postman exiting: {}", e); 215 | } 216 | }; 217 | }); 218 | } else { 219 | warn!("Not running postman"); 220 | } 221 | 222 | if !run_settings.contains("web") { 223 | tokio::spawn(async move { 224 | reminder_web::initialize(kill_tx, ctx2, pool2).await.unwrap(); 225 | }); 226 | } else { 227 | warn!("Not running web"); 228 | } 229 | 230 | Ok(Data { 231 | http: reqwest::Client::new(), 232 | database, 233 | popular_timezones, 234 | recording_macros: Default::default(), 235 | _broadcast: tx, 236 | }) 237 | }) 238 | }) 239 | .options(options) 240 | .intents(GatewayIntents::GUILDS) 241 | .run_autosharded() 242 | .await?; 243 | 244 | Ok(()) 245 | } 246 | -------------------------------------------------------------------------------- /src/models/channel_data.rs: -------------------------------------------------------------------------------- 1 | use chrono::NaiveDateTime; 2 | use poise::serenity_prelude::model::channel::Channel; 3 | use sqlx::MySqlPool; 4 | 5 | pub struct ChannelData { 6 | pub id: u32, 7 | pub name: Option, 8 | pub nudge: i16, 9 | pub blacklisted: bool, 10 | pub webhook_id: Option, 11 | pub webhook_token: Option, 12 | pub paused: bool, 13 | pub paused_until: Option, 14 | } 15 | 16 | impl ChannelData { 17 | pub async fn from_channel( 18 | channel: &Channel, 19 | pool: &MySqlPool, 20 | ) -> Result> { 21 | let channel_id = channel.id().as_u64().to_owned(); 22 | 23 | if let Ok(c) = sqlx::query_as_unchecked!( 24 | Self, 25 | " 26 | SELECT id, name, nudge, blacklisted, webhook_id, webhook_token, paused, paused_until FROM channels WHERE channel = ? 27 | ", 28 | channel_id 29 | ) 30 | .fetch_one(pool) 31 | .await 32 | { 33 | Ok(c) 34 | } else { 35 | let props = channel.to_owned().guild().map(|g| (g.guild_id.as_u64().to_owned(), g.name)); 36 | 37 | let (guild_id, channel_name) = if let Some((a, b)) = props { (Some(a), Some(b)) } else { (None, None) }; 38 | 39 | sqlx::query!( 40 | " 41 | INSERT IGNORE INTO channels (channel, name, guild_id) VALUES (?, ?, (SELECT id FROM guilds WHERE guild = ?)) 42 | ", 43 | channel_id, 44 | channel_name, 45 | guild_id 46 | ) 47 | .execute(&pool.clone()) 48 | .await?; 49 | 50 | Ok(sqlx::query_as_unchecked!( 51 | Self, 52 | " 53 | SELECT id, name, nudge, blacklisted, webhook_id, webhook_token, paused, paused_until FROM channels WHERE channel = ? 54 | ", 55 | channel_id 56 | ) 57 | .fetch_one(pool) 58 | .await?) 59 | } 60 | } 61 | 62 | pub async fn commit_changes(&self, pool: &MySqlPool) { 63 | sqlx::query!( 64 | " 65 | UPDATE channels SET name = ?, nudge = ?, blacklisted = ?, webhook_id = ?, webhook_token = ?, paused = ?, paused_until \ 66 | = ? WHERE id = ? 67 | ", 68 | self.name, 69 | self.nudge, 70 | self.blacklisted, 71 | self.webhook_id, 72 | self.webhook_token, 73 | self.paused, 74 | self.paused_until, 75 | self.id 76 | ) 77 | .execute(pool) 78 | .await 79 | .unwrap(); 80 | } 81 | } 82 | -------------------------------------------------------------------------------- /src/models/command_macro.rs: -------------------------------------------------------------------------------- 1 | use poise::serenity_prelude::model::{ 2 | application::interaction::application_command::CommandDataOption, id::GuildId, 3 | }; 4 | use serde::{Deserialize, Serialize}; 5 | use serde_json::Value; 6 | 7 | use crate::{Context, Data, Error}; 8 | 9 | type Func = for<'a> fn( 10 | poise::ApplicationContext<'a, U, E>, 11 | ) -> poise::BoxFuture<'a, Result<(), poise::FrameworkError<'a, U, E>>>; 12 | 13 | fn default_none() -> Option> { 14 | None 15 | } 16 | 17 | #[derive(Serialize, Deserialize)] 18 | pub struct RecordedCommand { 19 | #[serde(skip)] 20 | #[serde(default = "default_none::")] 21 | pub action: Option>, 22 | pub command_name: String, 23 | pub options: Vec, 24 | } 25 | 26 | pub struct CommandMacro { 27 | pub guild_id: GuildId, 28 | pub name: String, 29 | pub description: Option, 30 | pub commands: Vec>, 31 | } 32 | 33 | pub struct RawCommandMacro { 34 | pub guild_id: GuildId, 35 | pub name: String, 36 | pub description: Option, 37 | pub commands: Value, 38 | } 39 | 40 | pub async fn guild_command_macro( 41 | ctx: &Context<'_>, 42 | name: &str, 43 | ) -> Option> { 44 | let row = sqlx::query!( 45 | " 46 | SELECT * FROM macro WHERE guild_id = (SELECT id FROM guilds WHERE guild = ?) AND name = ? 47 | ", 48 | ctx.guild_id().unwrap().0, 49 | name 50 | ) 51 | .fetch_one(&ctx.data().database) 52 | .await 53 | .ok()?; 54 | 55 | let mut commands: Vec> = 56 | serde_json::from_str(&row.commands).unwrap(); 57 | 58 | for recorded_command in &mut commands { 59 | let command = &ctx 60 | .framework() 61 | .options() 62 | .commands 63 | .iter() 64 | .find(|c| c.identifying_name == recorded_command.command_name); 65 | 66 | recorded_command.action = command.map(|c| c.slash_action).flatten(); 67 | } 68 | 69 | let command_macro = CommandMacro { 70 | guild_id: ctx.guild_id().unwrap(), 71 | name: row.name, 72 | description: row.description, 73 | commands, 74 | }; 75 | 76 | Some(command_macro) 77 | } 78 | -------------------------------------------------------------------------------- /src/models/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod channel_data; 2 | pub mod command_macro; 3 | pub mod reminder; 4 | pub mod timer; 5 | pub mod user_data; 6 | 7 | use chrono_tz::Tz; 8 | use poise::serenity_prelude::{async_trait, model::id::UserId, ChannelType}; 9 | 10 | use crate::{ 11 | models::{channel_data::ChannelData, user_data::UserData}, 12 | CommandMacro, Context, Data, Error, GuildId, 13 | }; 14 | 15 | #[async_trait] 16 | pub trait CtxData { 17 | async fn user_data + Send>(&self, user_id: U) -> Result; 18 | 19 | async fn author_data(&self) -> Result; 20 | 21 | async fn timezone(&self) -> Tz; 22 | 23 | async fn channel_data(&self) -> Result; 24 | 25 | async fn command_macros(&self) -> Result>, Error>; 26 | } 27 | 28 | #[async_trait] 29 | impl CtxData for Context<'_> { 30 | async fn user_data + Send>( 31 | &self, 32 | user_id: U, 33 | ) -> Result> { 34 | UserData::from_user(user_id, &self.discord(), &self.data().database).await 35 | } 36 | 37 | async fn author_data(&self) -> Result> { 38 | UserData::from_user(&self.author().id, &self.discord(), &self.data().database).await 39 | } 40 | 41 | async fn timezone(&self) -> Tz { 42 | UserData::timezone_of(self.author().id, &self.data().database).await 43 | } 44 | 45 | async fn channel_data(&self) -> Result> { 46 | // If we're in a thread, get the parent channel. 47 | let recv_channel = self.channel_id().to_channel(&self.discord()).await?; 48 | 49 | let channel = match recv_channel.guild() { 50 | Some(guild_channel) => { 51 | if guild_channel.kind == ChannelType::PublicThread { 52 | guild_channel.parent_id.unwrap().to_channel_cached(&self.discord()).unwrap() 53 | } else { 54 | self.channel_id().to_channel_cached(&self.discord()).unwrap() 55 | } 56 | } 57 | 58 | None => self.channel_id().to_channel_cached(&self.discord()).unwrap(), 59 | }; 60 | 61 | ChannelData::from_channel(&channel, &self.data().database).await 62 | } 63 | 64 | async fn command_macros(&self) -> Result>, Error> { 65 | self.data().command_macros(self.guild_id().unwrap()).await 66 | } 67 | } 68 | 69 | impl Data { 70 | pub(crate) async fn command_macros( 71 | &self, 72 | guild_id: GuildId, 73 | ) -> Result>, Error> { 74 | let rows = sqlx::query!( 75 | "SELECT name, description, commands FROM macro WHERE guild_id = (SELECT id FROM guilds WHERE guild = ?)", 76 | guild_id.0 77 | ) 78 | .fetch_all(&self.database) 79 | .await?.iter().map(|row| CommandMacro { 80 | guild_id, 81 | name: row.name.clone(), 82 | description: row.description.clone(), 83 | commands: serde_json::from_str(&row.commands).unwrap(), 84 | }).collect(); 85 | 86 | Ok(rows) 87 | } 88 | } 89 | -------------------------------------------------------------------------------- /src/models/reminder/content.rs: -------------------------------------------------------------------------------- 1 | pub struct Content { 2 | pub content: String, 3 | pub tts: bool, 4 | pub attachment: Option>, 5 | pub attachment_name: Option, 6 | } 7 | 8 | impl Content { 9 | pub fn new() -> Self { 10 | Self { content: "".to_string(), tts: false, attachment: None, attachment_name: None } 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /src/models/reminder/errors.rs: -------------------------------------------------------------------------------- 1 | use crate::consts::{MAX_TIME, MIN_INTERVAL}; 2 | 3 | #[derive(PartialEq, Eq, Hash, Debug)] 4 | pub enum ReminderError { 5 | LongTime, 6 | LongInterval, 7 | PastTime, 8 | ShortInterval, 9 | InvalidTag, 10 | UserBlockedDm, 11 | DiscordError(String), 12 | } 13 | 14 | impl ToString for ReminderError { 15 | fn to_string(&self) -> String { 16 | match self { 17 | ReminderError::LongTime => { 18 | "That time is too far in the future. Please specify a shorter time.".to_string() 19 | } 20 | ReminderError::LongInterval => format!( 21 | "Please ensure the interval specified is less than {max_time} days", 22 | max_time = *MAX_TIME / 86_400 23 | ), 24 | ReminderError::PastTime => { 25 | "Please ensure the time provided is in the future. If the time should be in the future, please be more specific with the definition.".to_string() 26 | } 27 | ReminderError::ShortInterval => format!( 28 | "Please ensure the interval provided is longer than {min_interval} seconds", 29 | min_interval = *MIN_INTERVAL 30 | ), 31 | ReminderError::InvalidTag => { 32 | "Couldn't find a location by your tag. Your tag must be either a channel or a user (not a role)".to_string() 33 | } 34 | ReminderError::UserBlockedDm => { 35 | "User has DM reminders disabled".to_string() 36 | } 37 | ReminderError::DiscordError(s) => format!("A Discord error occurred: **{}**", s), 38 | } 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /src/models/reminder/helper.rs: -------------------------------------------------------------------------------- 1 | use rand::{rngs::OsRng, seq::IteratorRandom}; 2 | 3 | use crate::consts::CHARACTERS; 4 | 5 | pub fn generate_uid() -> String { 6 | let mut generator: OsRng = Default::default(); 7 | 8 | (0..64) 9 | .map(|_| CHARACTERS.chars().choose(&mut generator).unwrap().to_owned().to_string()) 10 | .collect::>() 11 | .join("") 12 | } 13 | -------------------------------------------------------------------------------- /src/models/reminder/look_flags.rs: -------------------------------------------------------------------------------- 1 | use poise::serenity_prelude::model::id::ChannelId; 2 | use serde::{Deserialize, Serialize}; 3 | use serde_repr::*; 4 | 5 | #[derive(Serialize_repr, Deserialize_repr, Copy, Clone, Debug)] 6 | #[repr(u8)] 7 | pub enum TimeDisplayType { 8 | Absolute = 0, 9 | Relative = 1, 10 | } 11 | 12 | #[derive(Serialize, Deserialize, Copy, Clone, Debug)] 13 | pub struct LookFlags { 14 | pub show_disabled: bool, 15 | pub channel_id: Option, 16 | pub time_display: TimeDisplayType, 17 | } 18 | 19 | impl Default for LookFlags { 20 | fn default() -> Self { 21 | Self { show_disabled: true, channel_id: None, time_display: TimeDisplayType::Relative } 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /src/models/timer.rs: -------------------------------------------------------------------------------- 1 | use chrono::{DateTime, Utc}; 2 | use sqlx::MySqlPool; 3 | 4 | pub struct Timer { 5 | pub name: String, 6 | pub start_time: DateTime, 7 | pub owner: u64, 8 | } 9 | 10 | impl Timer { 11 | pub async fn from_owner(owner: u64, pool: &MySqlPool) -> Vec { 12 | sqlx::query_as_unchecked!( 13 | Timer, 14 | " 15 | SELECT name, start_time, owner FROM timers WHERE owner = ? 16 | ", 17 | owner 18 | ) 19 | .fetch_all(pool) 20 | .await 21 | .unwrap() 22 | } 23 | 24 | pub async fn count_from_owner(owner: u64, pool: &MySqlPool) -> u32 { 25 | sqlx::query!( 26 | " 27 | SELECT COUNT(1) as count FROM timers WHERE owner = ? 28 | ", 29 | owner 30 | ) 31 | .fetch_one(pool) 32 | .await 33 | .unwrap() 34 | .count as u32 35 | } 36 | 37 | pub async fn create(name: &str, owner: u64, pool: &MySqlPool) { 38 | sqlx::query!( 39 | " 40 | INSERT INTO timers (name, owner) VALUES (?, ?) 41 | ", 42 | name, 43 | owner 44 | ) 45 | .execute(pool) 46 | .await 47 | .unwrap(); 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /src/models/user_data.rs: -------------------------------------------------------------------------------- 1 | use chrono_tz::Tz; 2 | use log::error; 3 | use poise::serenity_prelude::{http::CacheHttp, model::id::UserId}; 4 | use sqlx::MySqlPool; 5 | 6 | use crate::consts::LOCAL_TIMEZONE; 7 | 8 | pub struct UserData { 9 | pub id: u32, 10 | pub user: u64, 11 | pub dm_channel: u32, 12 | pub timezone: String, 13 | pub allowed_dm: bool, 14 | } 15 | 16 | impl UserData { 17 | pub async fn timezone_of(user: U, pool: &MySqlPool) -> Tz 18 | where 19 | U: Into, 20 | { 21 | let user_id = user.into().as_u64().to_owned(); 22 | 23 | match sqlx::query!( 24 | " 25 | SELECT IFNULL(timezone, 'UTC') AS timezone FROM users WHERE user = ? 26 | ", 27 | user_id 28 | ) 29 | .fetch_one(pool) 30 | .await 31 | { 32 | Ok(r) => r.timezone, 33 | 34 | Err(_) => LOCAL_TIMEZONE.clone(), 35 | } 36 | .parse() 37 | .unwrap() 38 | } 39 | 40 | pub async fn from_user>( 41 | user: U, 42 | ctx: impl CacheHttp, 43 | pool: &MySqlPool, 44 | ) -> Result> { 45 | let user_id = user.into(); 46 | 47 | match sqlx::query_as_unchecked!( 48 | Self, 49 | " 50 | SELECT id, user, dm_channel, IF(timezone IS NULL, ?, timezone) AS timezone, allowed_dm FROM users WHERE user = ? 51 | ", 52 | *LOCAL_TIMEZONE, 53 | user_id.0 54 | ) 55 | .fetch_one(pool) 56 | .await 57 | { 58 | Ok(c) => Ok(c), 59 | 60 | Err(sqlx::Error::RowNotFound) => { 61 | let dm_channel = user_id.create_dm_channel(ctx).await?; 62 | let pool_c = pool.clone(); 63 | 64 | sqlx::query!( 65 | " 66 | INSERT IGNORE INTO channels (channel) VALUES (?) 67 | ", 68 | dm_channel.id.0 69 | ) 70 | .execute(&pool_c) 71 | .await?; 72 | 73 | sqlx::query!( 74 | " 75 | INSERT INTO users (name, user, dm_channel, timezone) VALUES ('', ?, (SELECT id FROM channels WHERE channel = ?), ?) 76 | ", 77 | user_id.0, 78 | dm_channel.id.0, 79 | *LOCAL_TIMEZONE 80 | ) 81 | .execute(&pool_c) 82 | .await?; 83 | 84 | Ok(sqlx::query_as_unchecked!( 85 | Self, 86 | " 87 | SELECT id, user, dm_channel, timezone, allowed_dm FROM users WHERE user = ? 88 | ", 89 | user_id.0 90 | ) 91 | .fetch_one(pool) 92 | .await?) 93 | } 94 | 95 | Err(e) => { 96 | error!("Error querying for user: {:?}", e); 97 | 98 | Err(Box::new(e)) 99 | } 100 | } 101 | } 102 | 103 | pub async fn commit_changes(&self, pool: &MySqlPool) { 104 | sqlx::query!( 105 | " 106 | UPDATE users SET timezone = ?, allowed_dm = ? WHERE id = ? 107 | ", 108 | self.timezone, 109 | self.allowed_dm, 110 | self.id 111 | ) 112 | .execute(pool) 113 | .await 114 | .unwrap(); 115 | } 116 | 117 | pub fn timezone(&self) -> Tz { 118 | self.timezone.parse().unwrap() 119 | } 120 | } 121 | -------------------------------------------------------------------------------- /src/time_parser.rs: -------------------------------------------------------------------------------- 1 | use std::{ 2 | convert::TryFrom, 3 | fmt::{Display, Formatter, Result as FmtResult}, 4 | str::from_utf8, 5 | time::{SystemTime, UNIX_EPOCH}, 6 | }; 7 | 8 | use chrono::{DateTime, Datelike, Timelike, Utc}; 9 | use chrono_tz::Tz; 10 | use tokio::process::Command; 11 | 12 | use crate::consts::{LOCAL_TIMEZONE, PYTHON_LOCATION}; 13 | 14 | #[derive(Debug)] 15 | pub enum InvalidTime { 16 | ParseErrorDMY, 17 | ParseErrorHMS, 18 | ParseErrorDisplacement, 19 | ParseErrorChrono, 20 | } 21 | 22 | impl Display for InvalidTime { 23 | fn fmt(&self, f: &mut Formatter<'_>) -> FmtResult { 24 | write!(f, "InvalidTime: {:?}", self) 25 | } 26 | } 27 | 28 | impl std::error::Error for InvalidTime {} 29 | 30 | #[derive(Copy, Clone)] 31 | enum ParseType { 32 | Explicit, 33 | Displacement, 34 | } 35 | 36 | #[derive(Clone)] 37 | pub struct TimeParser { 38 | timezone: Tz, 39 | inverted: bool, 40 | time_string: String, 41 | parse_type: ParseType, 42 | } 43 | 44 | impl TryFrom<&TimeParser> for i64 { 45 | type Error = InvalidTime; 46 | 47 | fn try_from(value: &TimeParser) -> Result { 48 | value.timestamp() 49 | } 50 | } 51 | 52 | impl TimeParser { 53 | pub fn new(input: &str, timezone: Tz) -> Self { 54 | let inverted = input.starts_with('-'); 55 | 56 | let parse_type = if input.contains('/') || input.contains(':') { 57 | ParseType::Explicit 58 | } else { 59 | ParseType::Displacement 60 | }; 61 | 62 | Self { 63 | timezone, 64 | inverted, 65 | time_string: input.trim_start_matches('-').to_string(), 66 | parse_type, 67 | } 68 | } 69 | 70 | pub fn timestamp(&self) -> Result { 71 | match self.parse_type { 72 | ParseType::Explicit => Ok(self.process_explicit()?), 73 | 74 | ParseType::Displacement => { 75 | let now = SystemTime::now(); 76 | let since_epoch = now 77 | .duration_since(UNIX_EPOCH) 78 | .expect("Time calculated as going backwards. Very bad"); 79 | 80 | Ok(since_epoch.as_secs() as i64 + self.process_displacement()?) 81 | } 82 | } 83 | } 84 | 85 | pub fn displacement(&self) -> Result { 86 | match self.parse_type { 87 | ParseType::Explicit => { 88 | let now = SystemTime::now(); 89 | let since_epoch = now 90 | .duration_since(UNIX_EPOCH) 91 | .expect("Time calculated as going backwards. Very bad"); 92 | 93 | Ok(self.process_explicit()? - since_epoch.as_secs() as i64) 94 | } 95 | 96 | ParseType::Displacement => Ok(self.process_displacement()?), 97 | } 98 | } 99 | 100 | fn process_explicit(&self) -> Result { 101 | let mut time = Utc::now().with_timezone(&self.timezone).with_second(0).unwrap(); 102 | 103 | let mut segments = self.time_string.rsplit('-'); 104 | // this segment will always exist even if split fails 105 | let hms = segments.next().unwrap(); 106 | 107 | let h_m_s = hms.split(':'); 108 | 109 | for (t, setter) in 110 | h_m_s.take(3).zip(&[DateTime::with_hour, DateTime::with_minute, DateTime::with_second]) 111 | { 112 | time = setter(&time, t.parse().map_err(|_| InvalidTime::ParseErrorHMS)?) 113 | .map_or_else(|| Err(InvalidTime::ParseErrorHMS), Ok)?; 114 | } 115 | 116 | if let Some(dmy) = segments.next() { 117 | let mut d_m_y = dmy.split('/'); 118 | 119 | let day = d_m_y.next(); 120 | let month = d_m_y.next(); 121 | let year = d_m_y.next(); 122 | 123 | for (t, setter) in [day, month].iter().zip(&[DateTime::with_day, DateTime::with_month]) 124 | { 125 | if let Some(t) = t { 126 | time = setter(&time, t.parse().map_err(|_| InvalidTime::ParseErrorDMY)?) 127 | .map_or_else(|| Err(InvalidTime::ParseErrorDMY), Ok)?; 128 | } 129 | } 130 | 131 | if let Some(year) = year { 132 | if year.len() == 4 { 133 | time = time 134 | .with_year(year.parse().map_err(|_| InvalidTime::ParseErrorDMY)?) 135 | .map_or_else(|| Err(InvalidTime::ParseErrorDMY), Ok)?; 136 | } else if year.len() == 2 { 137 | time = time 138 | .with_year( 139 | format!("20{}", year) 140 | .parse() 141 | .map_err(|_| InvalidTime::ParseErrorDMY)?, 142 | ) 143 | .map_or_else(|| Err(InvalidTime::ParseErrorDMY), Ok)?; 144 | } else { 145 | return Err(InvalidTime::ParseErrorDMY); 146 | } 147 | } 148 | } 149 | 150 | Ok(time.timestamp() as i64) 151 | } 152 | 153 | fn process_displacement(&self) -> Result { 154 | let mut current_buffer = "0".to_string(); 155 | 156 | let mut seconds = 0_i64; 157 | let mut minutes = 0_i64; 158 | let mut hours = 0_i64; 159 | let mut days = 0_i64; 160 | 161 | for character in self.time_string.chars() { 162 | match character { 163 | 's' => { 164 | seconds = current_buffer.parse::().unwrap(); 165 | current_buffer = String::from("0"); 166 | } 167 | 168 | 'm' => { 169 | minutes = current_buffer.parse::().unwrap(); 170 | current_buffer = String::from("0"); 171 | } 172 | 173 | 'h' => { 174 | hours = current_buffer.parse::().unwrap(); 175 | current_buffer = String::from("0"); 176 | } 177 | 178 | 'd' => { 179 | days = current_buffer.parse::().unwrap(); 180 | current_buffer = String::from("0"); 181 | } 182 | 183 | c => { 184 | if c.is_digit(10) { 185 | current_buffer += &c.to_string(); 186 | } else { 187 | return Err(InvalidTime::ParseErrorDisplacement); 188 | } 189 | } 190 | } 191 | } 192 | 193 | let full = (seconds 194 | + (minutes * 60) 195 | + (hours * 3600) 196 | + (days * 86400) 197 | + current_buffer.parse::().unwrap()) 198 | * if self.inverted { -1 } else { 1 }; 199 | 200 | Ok(full) 201 | } 202 | } 203 | 204 | pub async fn natural_parser(time: &str, timezone: &str) -> Option { 205 | Command::new(&*PYTHON_LOCATION) 206 | .arg("-c") 207 | .arg(include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/dp.py"))) 208 | .arg(time) 209 | .arg(timezone) 210 | .arg(&*LOCAL_TIMEZONE) 211 | .output() 212 | .await 213 | .ok() 214 | .and_then(|inner| { 215 | if inner.status.success() { 216 | Some(from_utf8(&*inner.stdout).unwrap().parse::().unwrap()) 217 | } else { 218 | None 219 | } 220 | }) 221 | .and_then(|inner| if inner < 0 { None } else { Some(inner) }) 222 | } 223 | -------------------------------------------------------------------------------- /src/utils.rs: -------------------------------------------------------------------------------- 1 | use poise::{ 2 | serenity_prelude as serenity, 3 | serenity_prelude::{ 4 | builder::CreateApplicationCommands, 5 | http::CacheHttp, 6 | interaction::MessageFlags, 7 | model::id::{GuildId, UserId}, 8 | }, 9 | }; 10 | 11 | use crate::{ 12 | consts::{CNC_GUILD, SUBSCRIPTION_ROLES}, 13 | Data, Error, 14 | }; 15 | 16 | pub async fn register_application_commands( 17 | ctx: &serenity::Context, 18 | framework: &poise::Framework, 19 | guild_id: Option, 20 | ) -> Result<(), serenity::Error> { 21 | let mut commands_builder = CreateApplicationCommands::default(); 22 | let commands = &framework.options().commands; 23 | for command in commands { 24 | if let Some(slash_command) = command.create_as_slash_command() { 25 | commands_builder.add_application_command(slash_command); 26 | } 27 | if let Some(context_menu_command) = command.create_as_context_menu_command() { 28 | commands_builder.add_application_command(context_menu_command); 29 | } 30 | } 31 | let commands_builder = poise::serenity_prelude::json::Value::Array(commands_builder.0); 32 | 33 | if let Some(guild_id) = guild_id { 34 | ctx.http.create_guild_application_commands(guild_id.0, &commands_builder).await?; 35 | } else { 36 | ctx.http.create_global_application_commands(&commands_builder).await?; 37 | } 38 | 39 | Ok(()) 40 | } 41 | 42 | pub async fn check_subscription(cache_http: impl CacheHttp, user_id: impl Into) -> bool { 43 | if let Some(subscription_guild) = *CNC_GUILD { 44 | let guild_member = GuildId(subscription_guild).member(cache_http, user_id).await; 45 | 46 | if let Ok(member) = guild_member { 47 | for role in member.roles { 48 | if SUBSCRIPTION_ROLES.contains(role.as_u64()) { 49 | return true; 50 | } 51 | } 52 | } 53 | 54 | false 55 | } else { 56 | true 57 | } 58 | } 59 | 60 | pub async fn check_guild_subscription( 61 | cache_http: impl CacheHttp, 62 | guild_id: impl Into, 63 | ) -> bool { 64 | if let Some(guild) = cache_http.cache().unwrap().guild(guild_id) { 65 | let owner = guild.owner_id; 66 | 67 | check_subscription(&cache_http, owner).await 68 | } else { 69 | false 70 | } 71 | } 72 | 73 | /// Sends the message, specified via [`crate::CreateReply`], to the interaction initial response 74 | /// endpoint 75 | pub fn send_as_initial_response( 76 | data: poise::CreateReply<'_>, 77 | f: &mut serenity::CreateInteractionResponseData, 78 | ) { 79 | let poise::CreateReply { 80 | content, 81 | embeds, 82 | attachments: _, // serenity doesn't support attachments in initial response yet 83 | components, 84 | ephemeral, 85 | allowed_mentions, 86 | reference_message: _, // can't reply to a message in interactions 87 | } = data; 88 | 89 | if let Some(content) = content { 90 | f.content(content); 91 | } 92 | f.set_embeds(embeds); 93 | if let Some(allowed_mentions) = allowed_mentions { 94 | f.allowed_mentions(|f| { 95 | *f = allowed_mentions.clone(); 96 | f 97 | }); 98 | } 99 | if let Some(components) = components { 100 | f.components(|f| { 101 | f.0 = components.0; 102 | f 103 | }); 104 | } 105 | if ephemeral { 106 | f.flags(MessageFlags::EPHEMERAL); 107 | } 108 | } 109 | -------------------------------------------------------------------------------- /systemd/reminder-rs.service: -------------------------------------------------------------------------------- 1 | [Unit] 2 | Description=Reminder Bot 3 | 4 | [Service] 5 | User=reminder 6 | Type=simple 7 | ExecStart=/usr/bin/reminder-rs 8 | Restart=always 9 | RestartSec=4 10 | # Environment="RUST_LOG=warn,reminder_rs=info,postman=info" 11 | 12 | [Install] 13 | WantedBy=multi-user.target 14 | -------------------------------------------------------------------------------- /web/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "reminder_web" 3 | version = "0.1.0" 4 | authors = ["jellywx "] 5 | edition = "2018" 6 | 7 | [dependencies] 8 | rocket = { git = "https://github.com/SergioBenitez/Rocket", branch = "master", features = ["tls", "secrets", "json"] } 9 | rocket_dyn_templates = { git = "https://github.com/SergioBenitez/Rocket", branch = "master", features = ["tera"] } 10 | serenity = { version = "0.11.1", default-features = false, features = ["builder", "cache", "client", "gateway", "http", "model", "utils", "rustls_backend"] } 11 | oauth2 = "4" 12 | log = "0.4" 13 | reqwest = "0.11" 14 | serde = { version = "1.0", features = ["derive"] } 15 | sqlx = { version = "0.6", features = ["runtime-tokio-rustls", "macros", "mysql", "chrono", "json"] } 16 | chrono = "0.4" 17 | chrono-tz = "0.5" 18 | lazy_static = "1.4.0" 19 | rand = "0.7" 20 | base64 = "0.13" 21 | csv = "1.1" 22 | -------------------------------------------------------------------------------- /web/private/ca_cert.pem: -------------------------------------------------------------------------------- 1 | -----BEGIN CERTIFICATE----- 2 | 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 31 | I4/u 32 | -----END CERTIFICATE----- 33 | -------------------------------------------------------------------------------- /web/private/ca_key.pem: -------------------------------------------------------------------------------- 1 | -----BEGIN RSA PRIVATE KEY----- 2 | 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 51 | -----END RSA PRIVATE KEY----- 52 | -------------------------------------------------------------------------------- /web/private/ecdsa_nistp256_sha256_cert.pem: -------------------------------------------------------------------------------- 1 | -----BEGIN CERTIFICATE----- 2 | MIIDYTCCAUmgAwIBAgIUA/EaCcXKgOxBiuaMNTackeF9DgcwDQYJKoZIhvcNAQEL 3 | BQAwRzELMAkGA1UEBhMCVVMxCzAJBgNVBAgMAkNBMRIwEAYDVQQKDAlSb2NrZXQg 4 | Q0ExFzAVBgNVBAMMDlJvY2tldCBSb290IENBMB4XDTIxMDUxNzE5MDIyNFoXDTMx 5 | MDUxNTE5MDIyNFowPzELMAkGA1UEBhMCVVMxCzAJBgNVBAgMAkNBMQ8wDQYDVQQK 6 | DAZSb2NrZXQxEjAQBgNVBAMMCWxvY2FsaG9zdDBZMBMGByqGSM49AgEGCCqGSM49 7 | AwEHA0IABOEVdmVd218y3Yy+ocjtt5siaFsNR7tknvS21pzKzxsFc05UrF74YqRx 8 | Ic6/AQq56C48x6gDhUCdf+nMzD0NWTijGDAWMBQGA1UdEQQNMAuCCWxvY2FsaG9z 9 | dDANBgkqhkiG9w0BAQsFAAOCAgEAW32kadHWEIsN5WXacjtgE9jbFii/rWJjrAO/ 10 | GWuARwLvjWeEFM5xSTny1cTDEz/7Bmfd9GRRpfgxKCFBGaU7zTmOV/AokrjUay+s 11 | KPj0EV9ZE/84KVfr+MOuhwXhSSv+NvxduFw1sdhTUHs+Wopwjte+bnOUBXmnQH97 12 | ITSQuUvEamPUS4tJD/kQ0qSGJKNv3CShhbe3XkjUd0UKK7lZzn6hY2fy3CrkkJDT 13 | GyzFCRf3ZDfjW5/fGXN/TNoEK65pPQVr5taK/bovDesEO7rR4Y4YgtnGlJ7YToWh 14 | E6I77CbsJCJdmgpjPNwRlwQ8upoWAfxOHqwg32s6uWq20v4TXZTOnEKOPEJG74bh 15 | JHtwqsy2fIdGz4kZksKGerzJffyQPhX4f3qxxrijT9Hj2EoFIQ4u/jTRpK31IW3R 16 | gEeNB8O4iyg3crx0oHRwc6zX63O6wBJCZ+0uxLoyjwtjKCxVYbJpccjoQh6zO3UO 17 | pqAO+NKxQ469QDBV3uRyyQ7DRPu6p67/8gn1E/o8kyU1P7eLFIhBp4T4wCaMQBG6 18 | IpL5W7eCyuOcqfdm471N07Z4KAqiV8eBJcKaAE+WzNIvrFvCZ/zq7Gj8p07nkjK8 19 | +ZGDUieiY0mQEpiXLQZt1xNtT/MmlAzFYrK7t6gSygykQKjO1o4GG4g+eXu3h1YK 20 | avsOwtc= 21 | -----END CERTIFICATE----- 22 | -------------------------------------------------------------------------------- /web/private/ecdsa_nistp256_sha256_key_pkcs8.pem: -------------------------------------------------------------------------------- 1 | -----BEGIN PRIVATE KEY----- 2 | MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQgeo/7wBIYVax9bj4m 3 | 1UEe2H+H4YLsbApDCZMslZbubRuhRANCAAThFXZlXdtfMt2MvqHI7bebImhbDUe7 4 | ZJ70ttacys8bBXNOVKxe+GKkcSHOvwEKueguPMeoA4VAnX/pzMw9DVk4 5 | -----END PRIVATE KEY----- 6 | -------------------------------------------------------------------------------- /web/private/ecdsa_nistp384_sha384_cert.pem: -------------------------------------------------------------------------------- 1 | -----BEGIN CERTIFICATE----- 2 | MIIDfjCCAWagAwIBAgIUfmkM99JpQUsQsh8TVILPQDBGOhowDQYJKoZIhvcNAQEM 3 | BQAwRzELMAkGA1UEBhMCVVMxCzAJBgNVBAgMAkNBMRIwEAYDVQQKDAlSb2NrZXQg 4 | Q0ExFzAVBgNVBAMMDlJvY2tldCBSb290IENBMB4XDTIxMDUxNzE5MDIyNFoXDTMx 5 | MDUxNTE5MDIyNFowPzELMAkGA1UEBhMCVVMxCzAJBgNVBAgMAkNBMQ8wDQYDVQQK 6 | DAZSb2NrZXQxEjAQBgNVBAMMCWxvY2FsaG9zdDB2MBAGByqGSM49AgEGBSuBBAAi 7 | A2IABM5rQQNZEGWeDyrg2dVQS15c+JsX/ginN9/ArHpTgGO6xaLfkbekIj7gewUR 8 | VQcQrYulwu02HTFDzGVvf1DdCZzIJLJUpl+jagdI05yMPFg2s5lThzGB6HENyw1I 9 | hU1dMaMYMBYwFAYDVR0RBA0wC4IJbG9jYWxob3N0MA0GCSqGSIb3DQEBDAUAA4IC 10 | AQAzpXFEeCMvTBG+kzmHN/+esjCK8MO/Grrw3Qoa1stX0yjNWzfhlGHfWk9Mgwnp 11 | DnIEFT9lB+nT9uTkKL81Opu2bkWXuL0MyjyYmcCfEgJeDblUbD4HYzPO/s2P7QZu 12 | Oa1pNKBiSWe1xq+F/gkn0+nDfICq3QQOOQMzaAmlFmszN3v7pryz+x21MqTFsfuW 13 | ymycRcwZ3VyYeceZuBxjOhR2l8I5yZbR+KPj8VS7A3vgqvkS8ixTK0LrxcLwrTIz 14 | W9Z1skzVpux4K7iwn3qlWIJ7EbERi9Ydz0MzpjD+CHxGlgHTzT552DGZhPO8D7BE 15 | +4IoS0YeXEGLeC2a9Hmiy3FXbM4nt3aIWMiWyhjslXIbvWOJL1Vi5GNpdQCG+rB7 16 | lvA0IISp/tAi9duufdONjgRceRDsqf7o6TOBQdB3QxHRt9gCB2QquRh5llMUY8YH 17 | PxFJAEzF4X5b0q0k4b50HRVNfDY0SC/XIR7S2xnLDGuoUqrOpUligBzfXV4JHrPv 18 | YKHsWSOiVxU9eY1SYKuKqnOsGvXSSQlYqSK1ol94eOKDjNy8wekaVYAQNsdNL7o5 19 | QSWZUcbZLkaNMFdD9twWGduAs0eTichVgyvRgPdevjTGDPBHKNSUURZRTYeW4aIJ 20 | QzSQUQXwOXsQOmfCkaaTISsDwZAJ0wFqqST1AOhlCWD1OQ== 21 | -----END CERTIFICATE----- 22 | -------------------------------------------------------------------------------- /web/private/ecdsa_nistp384_sha384_key_pkcs8.pem: -------------------------------------------------------------------------------- 1 | -----BEGIN PRIVATE KEY----- 2 | MIG2AgEAMBAGByqGSM49AgEGBSuBBAAiBIGeMIGbAgEBBDCRBt7MeJXyJRq7grGQ 3 | jEdBHE31hG5LuKQ5iL8yTVGk75Kc8ISll2LewbvQ3NhR6E+hZANiAATOa0EDWRBl 4 | ng8q4NnVUEteXPibF/4IpzffwKx6U4BjusWi35G3pCI+4HsFEVUHEK2LpcLtNh0x 5 | Q8xlb39Q3QmcyCSyVKZfo2oHSNOcjDxYNrOZU4cxgehxDcsNSIVNXTE= 6 | -----END PRIVATE KEY----- 7 | -------------------------------------------------------------------------------- /web/private/ed25519_cert.pem: -------------------------------------------------------------------------------- 1 | -----BEGIN CERTIFICATE----- 2 | MIIDMjCCARqgAwIBAgIUSu1CGfaWlNPjjLG7SaEEgxI+AsAwDQYJKoZIhvcNAQEL 3 | BQAwRzELMAkGA1UEBhMCVVMxCzAJBgNVBAgMAkNBMRIwEAYDVQQKDAlSb2NrZXQg 4 | Q0ExFzAVBgNVBAMMDlJvY2tldCBSb290IENBMB4XDTIxMDUxNzE5MDIyNFoXDTMx 5 | MDUxNTE5MDIyNFowPzELMAkGA1UEBhMCVVMxCzAJBgNVBAgMAkNBMQ8wDQYDVQQK 6 | DAZSb2NrZXQxEjAQBgNVBAMMCWxvY2FsaG9zdDAqMAUGAytlcAMhAKNv1fkv5rxY 7 | xGT84C98g2xPpain+jsliHvYrqNkS1zhoxgwFjAUBgNVHREEDTALgglsb2NhbGhv 8 | c3QwDQYJKoZIhvcNAQELBQADggIBAMgWZhr3whYbFPNYm5RZxjgEonMY7cH/Rza1 9 | UrBkyoMRL9v00YKJTEvjl5Xl4ku7jqxY2qjValacDroD8hYTLhhkkbHxH6u+kJSC 10 | cKRQ8QVBZMmoor8qD2Y2BuXZYGWEtgeoXh+DLHWOim7gXDD6GyFPnYFGHnRhNmkE 11 | 6fPcfw1FZmBrMM9rk31EeK9nLVRabL+vuLDEddX1LH4LwK4OuV51wQMZGYiC3M2b 12 | JpmuPAUAUyiyN53Utuw/ubeih3cEglOwCyDPFt6XISYHO0UaQdw25uhpjxs8KTZB 13 | qOPJAI4cvGaN3GARXvz2P/QHUiTzsf2XOXXtrO0g+F9P7t6x2xL35c0A8yxKkfsa 14 | RKdCveRuVlsLXVLVBikqLE/OgPC6SIhIFvTYzXTaZyNfge6dRy2Wg6qWPcGwseeA 15 | QpFKgWiY3cuy7nJm6uybR6zOEMj5GgNWIbICGguQ5161MLnv0bEmKh2d9/jQ/XN5 16 | M+nixtGla/G4hL3FQIy9rhHVZzPWwSxl+/eE4qgnInEhmlQ2KrcpgneCt38t0vjJ 17 | dwHZSzVv8yX7fE0OSq/DWQXJraWUcT7Ds0g7T7jWkWfS0qStVd9VJ+rYQ8dBbE9Y 18 | gcmkm1DMUd+y9xDRDjxby7gMDWFiN2Au9FQgaIpIctTNCj0+agFBPnfXPVSIH6gX 19 | 10kA2ZVX 20 | -----END CERTIFICATE----- 21 | -------------------------------------------------------------------------------- /web/private/ed25519_key.pem: -------------------------------------------------------------------------------- 1 | -----BEGIN PRIVATE KEY----- 2 | MC4CAQAwBQYDK2VwBCIEIGtLkaYE4D+P5HLkoc3a/tNhPP+gnhPLF3jEtdYcAtpd 3 | -----END PRIVATE KEY----- 4 | -------------------------------------------------------------------------------- /web/private/gen_certs.sh: -------------------------------------------------------------------------------- 1 | #! /bin/bash 2 | 3 | # Usage: 4 | # ./gen_certs.sh [cert-kind] 5 | # 6 | # [cert-kind]: 7 | # ed25519 8 | # rsa_sha256 9 | # ecdsa_nistp256_sha256 10 | # ecdsa_nistp384_sha384 11 | # 12 | # Generate a certificate of the [cert-kind] key type, or if no cert-kind is 13 | # specified, all of the certificates. 14 | # 15 | # Examples: 16 | # ./gen_certs.sh ed25519 17 | # ./gen_certs.sh rsa_sha256 18 | 19 | # TODO: `rustls` (really, `webpki`) doesn't currently use the CN in the subject 20 | # to check if a certificate is valid for a server name sent via SNI. It's not 21 | # clear if this is intended, since certificates _should_ have a `subjectAltName` 22 | # with a DNS name, or if it simply hasn't been implemented yet. See 23 | # https://bugzilla.mozilla.org/show_bug.cgi?id=552346 for a bit more info. 24 | 25 | CA_SUBJECT="/C=US/ST=CA/O=Rocket CA/CN=Rocket Root CA" 26 | SUBJECT="/C=US/ST=CA/O=Rocket/CN=localhost" 27 | ALT="DNS:localhost" 28 | 29 | function gen_ca() { 30 | openssl genrsa -out ca_key.pem 4096 31 | openssl req -new -x509 -days 3650 -key ca_key.pem \ 32 | -subj "${CA_SUBJECT}" -out ca_cert.pem 33 | } 34 | 35 | function gen_ca_if_non_existent() { 36 | if ! [ -f ./ca_cert.pem ]; then gen_ca; fi 37 | } 38 | 39 | function gen_rsa_sha256() { 40 | gen_ca_if_non_existent 41 | 42 | openssl req -newkey rsa:4096 -nodes -sha256 -keyout rsa_sha256_key.pem \ 43 | -subj "${SUBJECT}" -out server.csr 44 | 45 | openssl x509 -req -sha256 -extfile <(printf "subjectAltName=${ALT}") -days 3650 \ 46 | -CA ca_cert.pem -CAkey ca_key.pem -CAcreateserial \ 47 | -in server.csr -out rsa_sha256_cert.pem 48 | 49 | rm ca_cert.srl server.csr 50 | } 51 | 52 | function gen_ed25519() { 53 | gen_ca_if_non_existent 54 | 55 | openssl genpkey -algorithm ED25519 > ed25519_key.pem 56 | 57 | openssl req -new -key ed25519_key.pem -subj "${SUBJECT}" -out server.csr 58 | openssl x509 -req -extfile <(printf "subjectAltName=${ALT}") -days 3650 \ 59 | -CA ca_cert.pem -CAkey ca_key.pem -CAcreateserial \ 60 | -in server.csr -out ed25519_cert.pem 61 | 62 | rm ca_cert.srl server.csr 63 | } 64 | 65 | function gen_ecdsa_nistp256_sha256() { 66 | gen_ca_if_non_existent 67 | 68 | openssl ecparam -out ecdsa_nistp256_sha256_key.pem -name prime256v1 -genkey 69 | 70 | # Convert to pkcs8 format supported by rustls 71 | openssl pkcs8 -topk8 -nocrypt -in ecdsa_nistp256_sha256_key.pem \ 72 | -out ecdsa_nistp256_sha256_key_pkcs8.pem 73 | 74 | openssl req -new -nodes -sha256 -key ecdsa_nistp256_sha256_key_pkcs8.pem \ 75 | -subj "${SUBJECT}" -out server.csr 76 | 77 | openssl x509 -req -sha256 -extfile <(printf "subjectAltName=${ALT}") -days 3650 \ 78 | -CA ca_cert.pem -CAkey ca_key.pem -CAcreateserial \ 79 | -in server.csr -out ecdsa_nistp256_sha256_cert.pem 80 | 81 | rm ca_cert.srl server.csr ecdsa_nistp256_sha256_key.pem 82 | } 83 | 84 | function gen_ecdsa_nistp384_sha384() { 85 | gen_ca_if_non_existent 86 | 87 | openssl ecparam -out ecdsa_nistp384_sha384_key.pem -name secp384r1 -genkey 88 | 89 | # Convert to pkcs8 format supported by rustls 90 | openssl pkcs8 -topk8 -nocrypt -in ecdsa_nistp384_sha384_key.pem \ 91 | -out ecdsa_nistp384_sha384_key_pkcs8.pem 92 | 93 | openssl req -new -nodes -sha384 -key ecdsa_nistp384_sha384_key_pkcs8.pem \ 94 | -subj "${SUBJECT}" -out server.csr 95 | 96 | openssl x509 -req -sha384 -extfile <(printf "subjectAltName=${ALT}") -days 3650 \ 97 | -CA ca_cert.pem -CAkey ca_key.pem -CAcreateserial \ 98 | -in server.csr -out ecdsa_nistp384_sha384_cert.pem 99 | 100 | rm ca_cert.srl server.csr ecdsa_nistp384_sha384_key.pem 101 | } 102 | 103 | case $1 in 104 | ed25519) gen_ed25519 ;; 105 | rsa_sha256) gen_rsa_sha256 ;; 106 | ecdsa_nistp256_sha256) gen_ecdsa_nistp256_sha256 ;; 107 | ecdsa_nistp384_sha384) gen_ecdsa_nistp384_sha384 ;; 108 | *) 109 | gen_ed25519 110 | gen_rsa_sha256 111 | gen_ecdsa_nistp256_sha256 112 | gen_ecdsa_nistp384_sha384 113 | ;; 114 | esac 115 | -------------------------------------------------------------------------------- /web/private/rsa_sha256_cert.pem: -------------------------------------------------------------------------------- 1 | -----BEGIN CERTIFICATE----- 2 | MIIFLDCCAxSgAwIBAgIUZ461KXDgTT25LP1S6PK6i9ZKtOYwDQYJKoZIhvcNAQEL 3 | BQAwRzELMAkGA1UEBhMCVVMxCzAJBgNVBAgMAkNBMRIwEAYDVQQKDAlSb2NrZXQg 4 | Q0ExFzAVBgNVBAMMDlJvY2tldCBSb290IENBMB4XDTIxMDUxNzE5MDIyNFoXDTMx 5 | MDUxNTE5MDIyNFowPzELMAkGA1UEBhMCVVMxCzAJBgNVBAgMAkNBMQ8wDQYDVQQK 6 | DAZSb2NrZXQxEjAQBgNVBAMMCWxvY2FsaG9zdDCCAiIwDQYJKoZIhvcNAQEBBQAD 7 | ggIPADCCAgoCggIBAKl8Re1dneFB2obYPOuZf3d6BR2xGcMhr2vmHpnVgkeg/xGI 8 | cwHhhB04mEg4TqbZ0Q1wdKN4ruNigdrQAXDqnm4y2IB1q/uTdoXVWNsvAZmDq4N4 9 | rPUDWagpkilV4HOHDQOI27Lo/+30wJX6H8i3J6Nag1y9spuySkL1F1SgQng9uzvP 10 | 3SwbsO9R/jS7qTZNEtirnJv3qJlxmT4BCvBuWNALShFlSZYnZk/2csYXEaVkWMUE 11 | rnWGmTmzMZEzDOdQdPC3sJDCPQbbgD4SnQANqhOVjPSdJ6Y6joN6XYtB2EP+6LJ8 12 | UBTICETnUROWeKqMm215Gsen32cyWkaCwEWtTFn7rJHbPvUY4vPYQZAB2/h07lYq 13 | v67W3r5lTq1KuoStD8M/7nCIYIuNhmJLMzzWrSfJrQpYexoMII6kjOxv2kiUgb1y 14 | bYKnFlVf4up3pTpi2/0We4SHRgc7xTcEPNvU5z5hc5JwHZIFjmi5dx50ZoAULrXl 15 | OUhfdUPut7H38UQg3riJiNAzedUiTubek26po8qH1iS/EMgAi5RboaBovjWhgjwq 16 | P/RP0vzpln6OdQIRaRlY9vZiik9HbFybafuA2zEkyc+zc4HqJk3vqX/0hgd9qWeL 17 | zgsHr6A86tNUXsUaoInQbzznjpbFE85klxd6HeqasOyVDCeDUs4lWoDNp0DbAgMB 18 | AAGjGDAWMBQGA1UdEQQNMAuCCWxvY2FsaG9zdDANBgkqhkiG9w0BAQsFAAOCAgEA 19 | sS2TUKKYtNYC68TEQb5KAa8/zi6Lhz9lyn9rpBXAcAVeBdxCqrBU0nRN8EjXEff1 20 | oBeau9Wi9PwdK6J+4xFuer9YrZZWuWLKUBXJL9ZXEfI+USo5WVz7v/puoKZSUSu2 21 | +Ee4+v1eoAsCtaA8IR0Sh1KTc9kg1QZW1nIdBV0zgAERWTzm1iy2Ff+euowM/lUR 22 | FczMAJsNq83O2kaH541fRx3gC2iMN/QLwRalBR2y8hTI5wQa0Yr8moC4IsTfRrKQ 23 | /SZDjFT1XoA9TnrByIvGQNlxK1Rm2hvK1OQn4OL6sdYK6F+MxfUn/atQNyBQ6CF+ 24 | oKuvOnE2jces8GGZIU1jYJ2271SQkzp0CW3N1ZKjClSQFAYED+5ERTGyeEL4o3Vr 25 | V/BUd0KC7HhbWBlFc2EDmPOoOTp/ID901Kf499M68pe2Fr/63/nCAON6WFk1NPYA 26 | +sWMfS25hikU5rOHGQgXxXAz90pwpvpoLQvaq0/azsbHvq9B4ubdcLE0xcoK+DGq 27 | +/aOeWlYkalWp93akPYpWN3UJXzDXzzagRID/1LEGS+Ssbh1WVhAhLKVoxzBvkgm 28 | ozVAhR3zHXI2QpQ2FEbOmkcRtpxv2CiaTsgHg2MK8cdmPx3G+ufCZjRbQx/VXVdN 29 | vaFRdmzr/5EQ7DT5Uy8w32h1to4Fm2sAtbAFYaFLXaM= 30 | -----END CERTIFICATE----- 31 | -------------------------------------------------------------------------------- /web/private/rsa_sha256_key.pem: -------------------------------------------------------------------------------- 1 | -----BEGIN PRIVATE KEY----- 2 | MIIJQgIBADANBgkqhkiG9w0BAQEFAASCCSwwggkoAgEAAoICAQCpfEXtXZ3hQdqG 3 | 2DzrmX93egUdsRnDIa9r5h6Z1YJHoP8RiHMB4YQdOJhIOE6m2dENcHSjeK7jYoHa 4 | 0AFw6p5uMtiAdav7k3aF1VjbLwGZg6uDeKz1A1moKZIpVeBzhw0DiNuy6P/t9MCV 5 | +h/ItyejWoNcvbKbskpC9RdUoEJ4Pbs7z90sG7DvUf40u6k2TRLYq5yb96iZcZk+ 6 | AQrwbljQC0oRZUmWJ2ZP9nLGFxGlZFjFBK51hpk5szGRMwznUHTwt7CQwj0G24A+ 7 | Ep0ADaoTlYz0nSemOo6Del2LQdhD/uiyfFAUyAhE51ETlniqjJtteRrHp99nMlpG 8 | gsBFrUxZ+6yR2z71GOLz2EGQAdv4dO5WKr+u1t6+ZU6tSrqErQ/DP+5wiGCLjYZi 9 | SzM81q0nya0KWHsaDCCOpIzsb9pIlIG9cm2CpxZVX+Lqd6U6Ytv9FnuEh0YHO8U3 10 | BDzb1Oc+YXOScB2SBY5ouXcedGaAFC615TlIX3VD7rex9/FEIN64iYjQM3nVIk7m 11 | 3pNuqaPKh9YkvxDIAIuUW6GgaL41oYI8Kj/0T9L86ZZ+jnUCEWkZWPb2YopPR2xc 12 | m2n7gNsxJMnPs3OB6iZN76l/9IYHfalni84LB6+gPOrTVF7FGqCJ0G88546WxRPO 13 | ZJcXeh3qmrDslQwng1LOJVqAzadA2wIDAQABAoICABT4gHp/Q+K0UEKxDNCl/ISe 14 | /3UODb78MwVpws2MAoO0YvsbZAeOjNdEwmrlNK4mc1xzVqtHanROIv0dEaCUFyhR 15 | eEJkzPPi6h5jKIxuQ4doKFerHdNvJ6/L/P7KVmxVAII4c96uP8SErTOhcD9Ykjn/ 16 | IBPgkPH83H1ucAWTksXn9XvQG3CyuHDUN1z0/1ntrXBLw6P0v9LEoI5weJcJQEn1 17 | q6N9Yd6HX3xzZP4nqpJJWUZ/bsqx7dGa3340z9rrNJz4TYuLzRtFG5gSm4R/LFUi 18 | Av/dViOWST3xbROnAQhgyRAUm6AGpCdKa9i9nI6VuUGRY4PivJy7OTpSQVIdwD2K 19 | VFbFauCmsTY7B+Z+DXe9JJV+Tlazvlwop1DApxCgLMNr2pVB/1yPzMrNYDB4Sg1c 20 | T3stu4A8PtljTykla2C2LPFrdIijvYv9n6PSPWV4vf1ix9uYs99FhZPQJMgm+CDr 21 | n3cFfuofIWIRJpCuu3hn4woGgW2bXor4pyMNg1yCp1T2c8g6eJUYAAIRpse0HDbT 22 | ZUifxlNBx819bf9aqv+lhwMU4UFlmWMv3NETcCBqgUn+z4scNJ3kZwO9ZodLRblK 23 | SpalZ6SNejTnVukg3zbwJG8w5vIrJvfjBkikAL1O5X6Kn3YqfrXAl38bIvA50ZBe 24 | eOFcgDPClLPGlNKxQXiZAoIBAQDh0aArTjWi8Kxt2Ga3Hu4jQ7IXklL9osGAlFCB 25 | wZs831/ktLY0c7vY+mbwrY4AtqK21A00MrNSTsp/McHwAUi410DdcTqzkGnm9BBZ 26 | FKVO1H9KGg2t344tOXcPsFTl6SR5a0aPqagyvgdH+YWC4ccfYZWMcLELn3sOGoEp 27 | a7VBxjLZZ+/b+/oIzhwxEaBi8N0U3IZ3SsC9Lmojnb9+LMwGs14TFfahD3uM1hnU 28 | vww1elwPwXafWc+7Ej1bXijWBXbyzkCITzHafmDsAatEZnemHL8zKKtTn2aSTUSj 29 | Fl/y94WR7/V4nVEQ0R3fjGC0rKh08BtKzxPjYBqjT5aI7+yfAoIBAQDAIzROr00o 30 | 65auD5TB2k/pSVKQpB/U6ESGafDxg4a+R9Ng2ESmDN5hUUZDefm0Uxf9AZqS6eno 31 | GSAqxNDixWPy2WFzbZ0mUCoyQn+Eh09WBvt0IqDZ2+ngBEXiKA/8dsvXinBHLuHV 32 | u+rJdLMzPfhWVo1/iXq7SOjBvDuthKROku5BEt6Q3Cs0nk6uneRIqKtaqa7KIInF 33 | BPtUifZtCP09h6iFFGNv2g2OYRYDg8TXvjt3scKy0DUC3kTeTCPvNvHaOObGbMRU 34 | Q85HkXburxWfsMto5m/lRsbY3COxddB47WIQ6QNewESfCab/R2lOdlXQeaH8fuxT 35 | wWC5DMz4F0ZFAoIBAFm+EjZDnaNEnHIHB0MNIryXAabGev7beKUdzCTVCVmWuChO 36 | /P45ZFTlppVNk9qKun2IJjsxTvyN3YHRB27XQ8xZlyiqABcudDfZlMmiH9QFNRUA 37 | 56DK8Fjetodgn0zDa8BpNqCPXw3TYVdkPX/3NEgvYtxuSJ4C4keHlv8cE+uw1bJ6 38 | 0OMO754iMyf5BlFrwaCxxyqPZauJT5sZ7Ok66lZbYC6bkukNGx+sUpWu2y5Bk2ab 39 | jwXjDmAc7o9qCzaK82upNhI1zu0zPldsjmDfi/tS/1VYe0X/WicYWAesM7N+VPHb 40 | eCVX98iEIqgdxKzo1QWsClyfkRrSraNrVLrVBqcCggEAaLIGK6YMNnMBPUGSPnt2 41 | NdlVWymDiuExjcimmQOhZYf/33KZHZ4/gunljpklfqQUmzHHh6xcX7NpOsTaSedj 42 | Sg43ss0U566g/5gKoi2VBnxxglvoKC5T51SMu+o2o8wb0QxHmBIszulBy5qClzZ6 43 | Xpl1Kvy/2tOkuQSXxDpVydb4ao8cpfTCuj5VA4NXxFvcW1/AtbU7PRc02GEA3XMb 44 | gu6r3jA46tb3shCnDS09Eo4/Gz7Kp+MaL8Dr5/G3Vv8qlE2TOqZD6OK1wXu7Qd43 45 | uzd772I5sMZ7TenOrUFUYsB/QlWmF3hPLBX3YH0KHc4PfrT4lnyWzCDAUrVt7vXH 46 | vQKCAQEAz1Q+jW+NG7CAqkOJ19n+KmGn+zddvy8x4txKx8kV0+3XIVNkseS6IT65 47 | uemD85Gn7MYwHxcKUHghHSKyJsvCb1vSmB3JtCnrsodmQNMb6Lsq89VlM8Ylc/s3 48 | F4AraiOlylqcEwLkanD3XSfPdQZhExZHEtpAW/Rr6zYT9J94VO1nK3+RkFI4a8kl 49 | pEbY+tqJj3LGTuaQkshB9rDtcBT5/JsaxlMk783qkKziCPZF47BWqrJb+t7tm3qg 50 | 5gf+QUInEjdW3k3uZBL4316RP/TJlLvo29PkEwoC8X4R2EgDeHQhhRC2Fi2Wpy4O 51 | ce4G+zZOOYXwvWGJLwNhgsve8C3oqg== 52 | -----END PRIVATE KEY----- 53 | -------------------------------------------------------------------------------- /web/src/consts.rs: -------------------------------------------------------------------------------- 1 | pub const DISCORD_OAUTH_TOKEN: &'static str = "https://discord.com/api/oauth2/token"; 2 | pub const DISCORD_OAUTH_AUTHORIZE: &'static str = "https://discord.com/api/oauth2/authorize"; 3 | pub const DISCORD_API: &'static str = "https://discord.com/api"; 4 | 5 | pub const MAX_CONTENT_LENGTH: usize = 2000; 6 | pub const MAX_EMBED_DESCRIPTION_LENGTH: usize = 4096; 7 | pub const MAX_EMBED_TITLE_LENGTH: usize = 256; 8 | pub const MAX_EMBED_AUTHOR_LENGTH: usize = 256; 9 | pub const MAX_EMBED_FOOTER_LENGTH: usize = 2048; 10 | pub const MAX_URL_LENGTH: usize = 512; 11 | pub const MAX_USERNAME_LENGTH: usize = 100; 12 | pub const MAX_EMBED_FIELDS: usize = 25; 13 | pub const MAX_EMBED_FIELD_TITLE_LENGTH: usize = 256; 14 | pub const MAX_EMBED_FIELD_VALUE_LENGTH: usize = 1024; 15 | 16 | pub const MINUTE: usize = 60; 17 | pub const HOUR: usize = 60 * MINUTE; 18 | pub const DAY: usize = 24 * HOUR; 19 | 20 | pub const CHARACTERS: &str = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_"; 21 | 22 | use std::{collections::HashSet, env, iter::FromIterator}; 23 | 24 | use lazy_static::lazy_static; 25 | use serenity::model::prelude::AttachmentType; 26 | 27 | lazy_static! { 28 | pub static ref DEFAULT_AVATAR: AttachmentType<'static> = ( 29 | include_bytes!(concat!(env!("CARGO_MANIFEST_DIR"), "/../assets/webhook.jpg")) as &[u8], 30 | "webhook.jpg", 31 | ) 32 | .into(); 33 | pub static ref SUBSCRIPTION_ROLES: HashSet = HashSet::from_iter( 34 | env::var("PATREON_ROLE_ID") 35 | .map(|var| var 36 | .split(',') 37 | .filter_map(|item| { item.parse::().ok() }) 38 | .collect::>()) 39 | .unwrap_or_else(|_| Vec::new()) 40 | ); 41 | pub static ref CNC_GUILD: Option = 42 | env::var("PATREON_GUILD_ID").map(|var| var.parse::().ok()).ok().flatten(); 43 | pub static ref MIN_INTERVAL: u32 = env::var("MIN_INTERVAL") 44 | .ok() 45 | .map(|inner| inner.parse::().ok()) 46 | .flatten() 47 | .unwrap_or(600); 48 | } 49 | -------------------------------------------------------------------------------- /web/src/lib.rs: -------------------------------------------------------------------------------- 1 | #[macro_use] 2 | extern crate rocket; 3 | 4 | mod consts; 5 | #[macro_use] 6 | mod macros; 7 | mod routes; 8 | 9 | use std::{collections::HashMap, env}; 10 | 11 | use oauth2::{basic::BasicClient, AuthUrl, ClientId, ClientSecret, RedirectUrl, TokenUrl}; 12 | use rocket::{ 13 | fs::FileServer, 14 | serde::json::{json, Value as JsonValue}, 15 | tokio::sync::broadcast::Sender, 16 | }; 17 | use rocket_dyn_templates::Template; 18 | use serenity::{ 19 | client::Context, 20 | http::CacheHttp, 21 | model::id::{GuildId, UserId}, 22 | }; 23 | use sqlx::{MySql, Pool}; 24 | 25 | use crate::consts::{CNC_GUILD, DISCORD_OAUTH_AUTHORIZE, DISCORD_OAUTH_TOKEN, SUBSCRIPTION_ROLES}; 26 | 27 | type Database = MySql; 28 | 29 | #[derive(Debug)] 30 | enum Error { 31 | SQLx(sqlx::Error), 32 | Serenity(serenity::Error), 33 | } 34 | 35 | #[catch(401)] 36 | async fn not_authorized() -> Template { 37 | let map: HashMap = HashMap::new(); 38 | Template::render("errors/401", &map) 39 | } 40 | 41 | #[catch(403)] 42 | async fn forbidden() -> Template { 43 | let map: HashMap = HashMap::new(); 44 | Template::render("errors/403", &map) 45 | } 46 | 47 | #[catch(404)] 48 | async fn not_found() -> Template { 49 | let map: HashMap = HashMap::new(); 50 | Template::render("errors/404", &map) 51 | } 52 | 53 | #[catch(413)] 54 | async fn payload_too_large() -> JsonValue { 55 | json!({"error": "Data too large.", "errors": ["Data too large."]}) 56 | } 57 | 58 | #[catch(422)] 59 | async fn unprocessable_entity() -> JsonValue { 60 | json!({"error": "Invalid request.", "errors": ["Invalid request."]}) 61 | } 62 | 63 | #[catch(500)] 64 | async fn internal_server_error() -> Template { 65 | let map: HashMap = HashMap::new(); 66 | Template::render("errors/500", &map) 67 | } 68 | 69 | pub async fn initialize( 70 | kill_channel: Sender<()>, 71 | serenity_context: Context, 72 | db_pool: Pool, 73 | ) -> Result<(), Box> { 74 | info!("Checking environment variables..."); 75 | env::var("OAUTH2_CLIENT_ID").expect("`OAUTH2_CLIENT_ID' not supplied"); 76 | env::var("OAUTH2_CLIENT_SECRET").expect("`OAUTH2_CLIENT_SECRET' not supplied"); 77 | env::var("OAUTH2_DISCORD_CALLBACK").expect("`OAUTH2_DISCORD_CALLBACK' not supplied"); 78 | env::var("PATREON_GUILD_ID").expect("`PATREON_GUILD_ID' not supplied"); 79 | info!("Done!"); 80 | 81 | let oauth2_client = BasicClient::new( 82 | ClientId::new(env::var("OAUTH2_CLIENT_ID")?), 83 | Some(ClientSecret::new(env::var("OAUTH2_CLIENT_SECRET")?)), 84 | AuthUrl::new(DISCORD_OAUTH_AUTHORIZE.to_string())?, 85 | Some(TokenUrl::new(DISCORD_OAUTH_TOKEN.to_string())?), 86 | ) 87 | .set_redirect_uri(RedirectUrl::new(env::var("OAUTH2_DISCORD_CALLBACK")?)?); 88 | 89 | let reqwest_client = reqwest::Client::new(); 90 | 91 | rocket::build() 92 | .attach(Template::fairing()) 93 | .register( 94 | "/", 95 | catchers![ 96 | not_authorized, 97 | forbidden, 98 | not_found, 99 | internal_server_error, 100 | unprocessable_entity, 101 | payload_too_large, 102 | ], 103 | ) 104 | .manage(oauth2_client) 105 | .manage(reqwest_client) 106 | .manage(serenity_context) 107 | .manage(db_pool) 108 | .mount("/static", FileServer::from(concat!(env!("CARGO_MANIFEST_DIR"), "/static"))) 109 | .mount( 110 | "/", 111 | routes![ 112 | routes::index, 113 | routes::cookies, 114 | routes::privacy, 115 | routes::terms, 116 | routes::return_to_same_site 117 | ], 118 | ) 119 | .mount( 120 | "/help", 121 | routes![ 122 | routes::help, 123 | routes::help_timezone, 124 | routes::help_create_reminder, 125 | routes::help_delete_reminder, 126 | routes::help_timers, 127 | routes::help_todo_lists, 128 | routes::help_macros, 129 | routes::help_intervals, 130 | routes::help_dashboard, 131 | routes::help_iemanager, 132 | ], 133 | ) 134 | .mount("/login", routes![routes::login::discord_login, routes::login::discord_callback]) 135 | .mount( 136 | "/dashboard", 137 | routes![ 138 | routes::dashboard::dashboard, 139 | routes::dashboard::dashboard_home, 140 | routes::dashboard::user::get_user_info, 141 | routes::dashboard::user::update_user_info, 142 | routes::dashboard::user::get_user_guilds, 143 | routes::dashboard::guild::get_guild_patreon, 144 | routes::dashboard::guild::get_guild_channels, 145 | routes::dashboard::guild::get_guild_roles, 146 | routes::dashboard::guild::get_reminder_templates, 147 | routes::dashboard::guild::create_reminder_template, 148 | routes::dashboard::guild::delete_reminder_template, 149 | routes::dashboard::guild::create_guild_reminder, 150 | routes::dashboard::guild::get_reminders, 151 | routes::dashboard::guild::edit_reminder, 152 | routes::dashboard::guild::delete_reminder, 153 | routes::dashboard::export::export_reminders, 154 | routes::dashboard::export::export_reminder_templates, 155 | routes::dashboard::export::export_todos, 156 | routes::dashboard::export::import_reminders, 157 | routes::dashboard::export::import_todos, 158 | ], 159 | ) 160 | .launch() 161 | .await?; 162 | 163 | warn!("Exiting rocket runtime"); 164 | // distribute kill signal 165 | match kill_channel.send(()) { 166 | Ok(_) => {} 167 | Err(e) => { 168 | error!("Failed to issue kill signal: {:?}", e); 169 | } 170 | } 171 | 172 | Ok(()) 173 | } 174 | 175 | pub async fn check_subscription(cache_http: impl CacheHttp, user_id: impl Into) -> bool { 176 | if let Some(subscription_guild) = *CNC_GUILD { 177 | let guild_member = GuildId(subscription_guild).member(cache_http, user_id).await; 178 | 179 | if let Ok(member) = guild_member { 180 | for role in member.roles { 181 | if SUBSCRIPTION_ROLES.contains(role.as_u64()) { 182 | return true; 183 | } 184 | } 185 | } 186 | 187 | false 188 | } else { 189 | true 190 | } 191 | } 192 | 193 | pub async fn check_guild_subscription( 194 | cache_http: impl CacheHttp, 195 | guild_id: impl Into, 196 | ) -> bool { 197 | if let Some(guild) = cache_http.cache().unwrap().guild(guild_id) { 198 | let owner = guild.owner_id; 199 | 200 | check_subscription(&cache_http, owner).await 201 | } else { 202 | false 203 | } 204 | } 205 | -------------------------------------------------------------------------------- /web/src/macros.rs: -------------------------------------------------------------------------------- 1 | macro_rules! check_length { 2 | ($max:ident, $field:expr) => { 3 | if $field.len() > $max { 4 | return Err(json!({ "error": format!("{} exceeded", stringify!($max)) })); 5 | } 6 | }; 7 | ($max:ident, $field:expr, $($fields:expr),+) => { 8 | check_length!($max, $field); 9 | check_length!($max, $($fields),+); 10 | }; 11 | } 12 | 13 | macro_rules! check_length_opt { 14 | ($max:ident, $field:expr) => { 15 | if let Some(field) = &$field { 16 | check_length!($max, field); 17 | } 18 | }; 19 | ($max:ident, $field:expr, $($fields:expr),+) => { 20 | check_length_opt!($max, $field); 21 | check_length_opt!($max, $($fields),+); 22 | }; 23 | } 24 | 25 | macro_rules! check_url { 26 | ($field:expr) => { 27 | if !($field.starts_with("http://") || $field.starts_with("https://")) { 28 | return Err(json!({ "error": "URL invalid" })); 29 | } 30 | }; 31 | ($field:expr, $($fields:expr),+) => { 32 | check_url!($max, $field); 33 | check_url!($max, $($fields),+); 34 | }; 35 | } 36 | 37 | macro_rules! check_url_opt { 38 | ($field:expr) => { 39 | if let Some(field) = &$field { 40 | check_url!(field); 41 | } 42 | }; 43 | ($field:expr, $($fields:expr),+) => { 44 | check_url_opt!($field); 45 | check_url_opt!($($fields),+); 46 | }; 47 | } 48 | 49 | macro_rules! check_authorization { 50 | ($cookies:expr, $ctx:expr, $guild:expr) => { 51 | use serenity::model::id::UserId; 52 | 53 | let user_id = $cookies.get_private("userid").map(|c| c.value().parse::().ok()).flatten(); 54 | 55 | match user_id { 56 | Some(user_id) => { 57 | match GuildId($guild).to_guild_cached($ctx) { 58 | Some(guild) => { 59 | let member = guild.member($ctx, UserId(user_id)).await; 60 | 61 | match member { 62 | Err(_) => { 63 | return Err(json!({"error": "User not in guild"})); 64 | } 65 | 66 | Ok(_) => {} 67 | } 68 | } 69 | 70 | None => { 71 | return Err(json!({"error": "Bot not in guild"})); 72 | } 73 | } 74 | } 75 | 76 | None => { 77 | return Err(json!({"error": "User not authorized"})); 78 | } 79 | } 80 | } 81 | } 82 | 83 | macro_rules! update_field { 84 | ($pool:expr, $error:ident, $reminder:ident.[$field:ident]) => { 85 | if let Some(value) = &$reminder.$field { 86 | match sqlx::query(concat!( 87 | "UPDATE reminders SET `", 88 | stringify!($field), 89 | "` = ? WHERE uid = ?" 90 | )) 91 | .bind(value) 92 | .bind(&$reminder.uid) 93 | .execute($pool) 94 | .await 95 | { 96 | Ok(_) => {} 97 | Err(e) => { 98 | warn!( 99 | concat!( 100 | "Error in `update_field!(", 101 | stringify!($pool), 102 | stringify!($reminder), 103 | stringify!($field), 104 | ")': {:?}" 105 | ), 106 | e 107 | ); 108 | 109 | $error.push(format!("Error setting field {}", stringify!($field))); 110 | } 111 | } 112 | } 113 | }; 114 | 115 | ($pool:expr, $error:ident, $reminder:ident.[$field:ident, $($fields:ident),+]) => { 116 | update_field!($pool, $error, $reminder.[$field]); 117 | update_field!($pool, $error, $reminder.[$($fields),+]); 118 | }; 119 | } 120 | 121 | macro_rules! json_err { 122 | ($message:expr) => { 123 | Err(json!({ "error": $message })) 124 | }; 125 | } 126 | -------------------------------------------------------------------------------- /web/src/routes/dashboard/user.rs: -------------------------------------------------------------------------------- 1 | use std::env; 2 | 3 | use chrono_tz::Tz; 4 | use reqwest::Client; 5 | use rocket::{ 6 | http::CookieJar, 7 | serde::json::{json, Json, Value as JsonValue}, 8 | State, 9 | }; 10 | use serde::{Deserialize, Serialize}; 11 | use serenity::{ 12 | client::Context, 13 | model::{ 14 | id::{GuildId, RoleId}, 15 | permissions::Permissions, 16 | }, 17 | }; 18 | use sqlx::{MySql, Pool}; 19 | 20 | use crate::consts::DISCORD_API; 21 | 22 | #[derive(Serialize)] 23 | struct UserInfo { 24 | name: String, 25 | patreon: bool, 26 | timezone: Option, 27 | } 28 | 29 | #[derive(Deserialize)] 30 | pub struct UpdateUser { 31 | timezone: String, 32 | } 33 | 34 | #[derive(Serialize)] 35 | struct GuildInfo { 36 | id: String, 37 | name: String, 38 | } 39 | 40 | #[derive(Deserialize)] 41 | pub struct PartialGuild { 42 | pub id: GuildId, 43 | pub icon: Option, 44 | pub name: String, 45 | #[serde(default)] 46 | pub owner: bool, 47 | #[serde(rename = "permissions_new")] 48 | pub permissions: Option, 49 | } 50 | 51 | #[get("/api/user")] 52 | pub async fn get_user_info( 53 | cookies: &CookieJar<'_>, 54 | ctx: &State, 55 | pool: &State>, 56 | ) -> JsonValue { 57 | if let Some(user_id) = 58 | cookies.get_private("userid").map(|u| u.value().parse::().ok()).flatten() 59 | { 60 | let member_res = GuildId(env::var("PATREON_GUILD_ID").unwrap().parse().unwrap()) 61 | .member(&ctx.inner(), user_id) 62 | .await; 63 | 64 | let timezone = sqlx::query!( 65 | "SELECT IFNULL(timezone, 'UTC') AS timezone FROM users WHERE user = ?", 66 | user_id 67 | ) 68 | .fetch_one(pool.inner()) 69 | .await 70 | .map_or(None, |q| Some(q.timezone)); 71 | 72 | let user_info = UserInfo { 73 | name: cookies 74 | .get_private("username") 75 | .map_or("DiscordUser#0000".to_string(), |c| c.value().to_string()), 76 | patreon: member_res.map_or(false, |member| { 77 | member 78 | .roles 79 | .contains(&RoleId(env::var("PATREON_ROLE_ID").unwrap().parse().unwrap())) 80 | }), 81 | timezone, 82 | }; 83 | 84 | json!(user_info) 85 | } else { 86 | json!({"error": "Not authorized"}) 87 | } 88 | } 89 | 90 | #[patch("/api/user", data = "")] 91 | pub async fn update_user_info( 92 | cookies: &CookieJar<'_>, 93 | user: Json, 94 | pool: &State>, 95 | ) -> JsonValue { 96 | if let Some(user_id) = 97 | cookies.get_private("userid").map(|u| u.value().parse::().ok()).flatten() 98 | { 99 | if user.timezone.parse::().is_ok() { 100 | let _ = sqlx::query!( 101 | "UPDATE users SET timezone = ? WHERE user = ?", 102 | user.timezone, 103 | user_id, 104 | ) 105 | .execute(pool.inner()) 106 | .await; 107 | 108 | json!({}) 109 | } else { 110 | json!({"error": "Timezone not recognized"}) 111 | } 112 | } else { 113 | json!({"error": "Not authorized"}) 114 | } 115 | } 116 | 117 | #[get("/api/user/guilds")] 118 | pub async fn get_user_guilds(cookies: &CookieJar<'_>, reqwest_client: &State) -> JsonValue { 119 | if let Some(access_token) = cookies.get_private("access_token") { 120 | let request_res = reqwest_client 121 | .get(format!("{}/users/@me/guilds", DISCORD_API)) 122 | .bearer_auth(access_token.value()) 123 | .send() 124 | .await; 125 | 126 | match request_res { 127 | Ok(response) => { 128 | let guilds_res = response.json::>().await; 129 | 130 | match guilds_res { 131 | Ok(guilds) => { 132 | let reduced_guilds = guilds 133 | .iter() 134 | .filter(|g| { 135 | g.owner 136 | || g.permissions.as_ref().map_or(false, |p| { 137 | let permissions = 138 | Permissions::from_bits_truncate(p.parse().unwrap()); 139 | 140 | permissions.manage_messages() 141 | || permissions.manage_guild() 142 | || permissions.administrator() 143 | }) 144 | }) 145 | .map(|g| GuildInfo { id: g.id.to_string(), name: g.name.to_string() }) 146 | .collect::>(); 147 | 148 | json!(reduced_guilds) 149 | } 150 | 151 | Err(e) => { 152 | warn!("Error constructing user from request: {:?}", e); 153 | 154 | json!({"error": "Could not get user details"}) 155 | } 156 | } 157 | } 158 | 159 | Err(e) => { 160 | warn!("Error getting user guilds: {:?}", e); 161 | 162 | json!({"error": "Could not reach Discord"}) 163 | } 164 | } 165 | } else { 166 | json!({"error": "Not authorized"}) 167 | } 168 | } 169 | -------------------------------------------------------------------------------- /web/src/routes/login.rs: -------------------------------------------------------------------------------- 1 | use log::warn; 2 | use oauth2::{ 3 | basic::BasicClient, reqwest::async_http_client, AuthorizationCode, CsrfToken, 4 | PkceCodeChallenge, PkceCodeVerifier, Scope, TokenResponse, 5 | }; 6 | use reqwest::Client; 7 | use rocket::{ 8 | http::{private::cookie::Expiration, Cookie, CookieJar, SameSite}, 9 | response::{Flash, Redirect}, 10 | uri, State, 11 | }; 12 | use serenity::model::user::User; 13 | 14 | use crate::consts::DISCORD_API; 15 | 16 | #[get("/discord")] 17 | pub async fn discord_login( 18 | oauth2_client: &State, 19 | cookies: &CookieJar<'_>, 20 | ) -> Redirect { 21 | let (pkce_challenge, pkce_verifier) = PkceCodeChallenge::new_random_sha256(); 22 | 23 | let (auth_url, csrf_token) = oauth2_client 24 | .authorize_url(CsrfToken::new_random) 25 | // Set the desired scopes. 26 | .add_scope(Scope::new("identify".to_string())) 27 | .add_scope(Scope::new("guilds".to_string())) 28 | // Set the PKCE code challenge. 29 | .set_pkce_challenge(pkce_challenge) 30 | .url(); 31 | 32 | // store the pkce secret to verify the authorization later 33 | cookies.add_private( 34 | Cookie::build("verify", pkce_verifier.secret().to_string()) 35 | .http_only(true) 36 | .path("/login") 37 | .same_site(SameSite::Lax) 38 | .expires(Expiration::Session) 39 | .finish(), 40 | ); 41 | 42 | // store the csrf token to verify no interference 43 | cookies.add_private( 44 | Cookie::build("csrf", csrf_token.secret().to_string()) 45 | .http_only(true) 46 | .path("/login") 47 | .same_site(SameSite::Lax) 48 | .expires(Expiration::Session) 49 | .finish(), 50 | ); 51 | 52 | Redirect::to(auth_url.to_string()) 53 | } 54 | 55 | #[get("/discord/authorized?&")] 56 | pub async fn discord_callback( 57 | code: &str, 58 | state: &str, 59 | cookies: &CookieJar<'_>, 60 | oauth2_client: &State, 61 | reqwest_client: &State, 62 | ) -> Result> { 63 | if let (Some(pkce_secret), Some(csrf_token)) = 64 | (cookies.get_private("verify"), cookies.get_private("csrf")) 65 | { 66 | if state == csrf_token.value() { 67 | let token_result = oauth2_client 68 | .exchange_code(AuthorizationCode::new(code.to_string())) 69 | // Set the PKCE code verifier. 70 | .set_pkce_verifier(PkceCodeVerifier::new(pkce_secret.value().to_string())) 71 | .request_async(async_http_client) 72 | .await; 73 | 74 | cookies.remove_private(Cookie::named("verify")); 75 | cookies.remove_private(Cookie::named("csrf")); 76 | 77 | match token_result { 78 | Ok(token) => { 79 | cookies.add_private( 80 | Cookie::build("access_token", token.access_token().secret().to_string()) 81 | .secure(true) 82 | .http_only(true) 83 | .path("/dashboard") 84 | .finish(), 85 | ); 86 | 87 | let request_res = reqwest_client 88 | .get(format!("{}/users/@me", DISCORD_API)) 89 | .bearer_auth(token.access_token().secret()) 90 | .send() 91 | .await; 92 | 93 | match request_res { 94 | Ok(response) => { 95 | let user_res = response.json::().await; 96 | 97 | match user_res { 98 | Ok(user) => { 99 | let user_name = format!("{}#{}", user.name, user.discriminator); 100 | let user_id = user.id.as_u64().to_string(); 101 | 102 | cookies.add_private(Cookie::new("username", user_name)); 103 | cookies.add_private(Cookie::new("userid", user_id)); 104 | 105 | Ok(Redirect::to(uri!(super::return_to_same_site("dashboard")))) 106 | } 107 | 108 | Err(e) => { 109 | warn!("Error constructing user from request: {:?}", e); 110 | 111 | Err(Flash::new( 112 | Redirect::to(uri!(super::return_to_same_site(""))), 113 | "danger", 114 | "Failed to contact Discord", 115 | )) 116 | } 117 | } 118 | } 119 | 120 | Err(e) => { 121 | warn!("Error getting user info: {:?}", e); 122 | 123 | Err(Flash::new( 124 | Redirect::to(uri!(super::return_to_same_site(""))), 125 | "danger", 126 | "Failed to contact Discord", 127 | )) 128 | } 129 | } 130 | } 131 | 132 | Err(e) => { 133 | warn!("Error in discord callback: {:?}", e); 134 | 135 | Err(Flash::new( 136 | Redirect::to(uri!(super::return_to_same_site(""))), 137 | "warning", 138 | "Your login request was rejected. The server may be misconfigured. Please retry or alert us in Discord.", 139 | )) 140 | } 141 | } 142 | } else { 143 | 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)")) 144 | } 145 | } else { 146 | 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)")) 147 | } 148 | } 149 | -------------------------------------------------------------------------------- /web/src/routes/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod dashboard; 2 | pub mod login; 3 | 4 | use std::collections::HashMap; 5 | 6 | use rocket::request::FlashMessage; 7 | use rocket_dyn_templates::Template; 8 | 9 | #[get("/")] 10 | pub async fn index(flash: Option>) -> Template { 11 | let mut map: HashMap<&str, String> = HashMap::new(); 12 | 13 | if let Some(message) = flash { 14 | map.insert("flashed_message", message.message().to_string()); 15 | map.insert("flashed_grade", message.kind().to_string()); 16 | } 17 | 18 | Template::render("index", &map) 19 | } 20 | 21 | #[get("/ret?")] 22 | pub async fn return_to_same_site(to: &str) -> Template { 23 | let mut map: HashMap<&str, String> = HashMap::new(); 24 | 25 | map.insert("to", to.to_string()); 26 | 27 | Template::render("return", &map) 28 | } 29 | 30 | #[get("/cookies")] 31 | pub async fn cookies() -> Template { 32 | let map: HashMap<&str, String> = HashMap::new(); 33 | Template::render("cookies", &map) 34 | } 35 | 36 | #[get("/privacy")] 37 | pub async fn privacy() -> Template { 38 | let map: HashMap<&str, String> = HashMap::new(); 39 | Template::render("privacy", &map) 40 | } 41 | 42 | #[get("/terms")] 43 | pub async fn terms() -> Template { 44 | let map: HashMap<&str, String> = HashMap::new(); 45 | Template::render("terms", &map) 46 | } 47 | 48 | #[get("/")] 49 | pub async fn help() -> Template { 50 | let map: HashMap<&str, String> = HashMap::new(); 51 | Template::render("help", &map) 52 | } 53 | 54 | #[get("/timezone")] 55 | pub async fn help_timezone() -> Template { 56 | let map: HashMap<&str, String> = HashMap::new(); 57 | Template::render("support/timezone", &map) 58 | } 59 | 60 | #[get("/create_reminder")] 61 | pub async fn help_create_reminder() -> Template { 62 | let map: HashMap<&str, String> = HashMap::new(); 63 | Template::render("support/create_reminder", &map) 64 | } 65 | 66 | #[get("/delete_reminder")] 67 | pub async fn help_delete_reminder() -> Template { 68 | let map: HashMap<&str, String> = HashMap::new(); 69 | Template::render("support/delete_reminder", &map) 70 | } 71 | 72 | #[get("/timers")] 73 | pub async fn help_timers() -> Template { 74 | let map: HashMap<&str, String> = HashMap::new(); 75 | Template::render("support/timers", &map) 76 | } 77 | 78 | #[get("/todo_lists")] 79 | pub async fn help_todo_lists() -> Template { 80 | let map: HashMap<&str, String> = HashMap::new(); 81 | Template::render("support/todo_lists", &map) 82 | } 83 | 84 | #[get("/macros")] 85 | pub async fn help_macros() -> Template { 86 | let map: HashMap<&str, String> = HashMap::new(); 87 | Template::render("support/macros", &map) 88 | } 89 | 90 | #[get("/intervals")] 91 | pub async fn help_intervals() -> Template { 92 | let map: HashMap<&str, String> = HashMap::new(); 93 | Template::render("support/intervals", &map) 94 | } 95 | 96 | #[get("/dashboard")] 97 | pub async fn help_dashboard() -> Template { 98 | let map: HashMap<&str, String> = HashMap::new(); 99 | Template::render("support/dashboard", &map) 100 | } 101 | 102 | #[get("/iemanager")] 103 | pub async fn help_iemanager() -> Template { 104 | let map: HashMap<&str, String> = HashMap::new(); 105 | Template::render("support/iemanager", &map) 106 | } 107 | -------------------------------------------------------------------------------- /web/static/css/dtsel.css: -------------------------------------------------------------------------------- 1 | .date-selector-wrapper { 2 | width: 200px; 3 | padding: 3px; 4 | background-color: #fff; 5 | box-shadow: 1px 1px 10px 1px #5c5c5c; 6 | position: absolute; 7 | font-size: 12px; 8 | -webkit-user-select: none; 9 | -khtml-user-select: none; 10 | -moz-user-select: none; 11 | -ms-user-select: none; 12 | -o-user-select: none; 13 | /* user-select: none; */ 14 | } 15 | .cal-header, .cal-row { 16 | display: flex; 17 | width: 100%; 18 | height: 30px; 19 | line-height: 30px; 20 | text-align: center; 21 | } 22 | .cal-cell, .cal-nav { 23 | cursor: pointer; 24 | } 25 | .cal-day-names { 26 | height: 25px; 27 | line-height: 25px; 28 | } 29 | .cal-day-names .cal-cell { 30 | cursor: default; 31 | font-weight: bold; 32 | } 33 | .cal-cell-prev, .cal-cell-next { 34 | color: #777; 35 | } 36 | .cal-months .cal-row, .cal-years .cal-row { 37 | height: 60px; 38 | line-height: 60px; 39 | } 40 | .cal-nav-prev, .cal-nav-next { 41 | flex: 0.15; 42 | } 43 | .cal-nav-current { 44 | flex: 0.75; 45 | font-weight: bold; 46 | } 47 | .cal-months .cal-cell, .cal-years .cal-cell { 48 | flex: 0.25; 49 | } 50 | .cal-days .cal-cell { 51 | flex: 0.143; 52 | } 53 | .cal-value { 54 | color: #fff; 55 | background-color: #286090; 56 | } 57 | .cal-cell:hover, .cal-nav:hover { 58 | background-color: #eee; 59 | } 60 | .cal-value:hover { 61 | background-color: #204d74; 62 | } 63 | 64 | /* time footer */ 65 | .cal-time { 66 | display: flex; 67 | justify-content: flex-start; 68 | height: 27px; 69 | line-height: 27px; 70 | } 71 | .cal-time-label, .cal-time-value { 72 | flex: 0.12; 73 | text-align: center; 74 | } 75 | .cal-time-slider { 76 | flex: 0.77; 77 | background-image: linear-gradient(to right, #d1d8dd, #d1d8dd); 78 | background-repeat: no-repeat; 79 | background-size: 100% 1px; 80 | background-position: left 50%; 81 | height: 100%; 82 | } 83 | .cal-time-slider input { 84 | width: 100%; 85 | -webkit-appearance: none; 86 | background: 0 0; 87 | cursor: pointer; 88 | height: 100%; 89 | outline: 0; 90 | user-select: auto; 91 | } -------------------------------------------------------------------------------- /web/static/css/font.css: -------------------------------------------------------------------------------- 1 | @font-face { 2 | font-family: 'Source Sans Pro'; 3 | font-style: italic; 4 | font-weight: 300; 5 | src: local('Source Sans Pro Light Italic'), local('SourceSansPro-LightItalic'), url(https://fonts.gstatic.com/s/sourcesanspro/v13/6xKwdSBYKcSV-LCoeQqfX1RYOo3qPZZMkids18E.ttf) format('truetype'); 6 | font-display: swap; 7 | } 8 | @font-face { 9 | font-family: 'Source Sans Pro'; 10 | font-style: italic; 11 | font-weight: 400; 12 | src: local('Source Sans Pro Italic'), local('SourceSansPro-Italic'), url(https://fonts.gstatic.com/s/sourcesanspro/v13/6xK1dSBYKcSV-LCoeQqfX1RYOo3qPZ7nsDc.ttf) format('truetype'); 13 | font-display: swap; 14 | } 15 | @font-face { 16 | font-family: 'Source Sans Pro'; 17 | font-style: italic; 18 | font-weight: 600; 19 | src: local('Source Sans Pro SemiBold Italic'), local('SourceSansPro-SemiBoldItalic'), url(https://fonts.gstatic.com/s/sourcesanspro/v13/6xKwdSBYKcSV-LCoeQqfX1RYOo3qPZY4lCds18E.ttf) format('truetype'); 20 | font-display: swap; 21 | } 22 | @font-face { 23 | font-family: 'Source Sans Pro'; 24 | font-style: normal; 25 | font-weight: 300; 26 | src: local('Source Sans Pro Light'), local('SourceSansPro-Light'), url(https://fonts.gstatic.com/s/sourcesanspro/v13/6xKydSBYKcSV-LCoeQqfX1RYOo3ik4zwlxdr.ttf) format('truetype'); 27 | font-display: swap; 28 | } 29 | @font-face { 30 | font-family: 'Source Sans Pro'; 31 | font-style: normal; 32 | font-weight: 400; 33 | src: local('Source Sans Pro Regular'), local('SourceSansPro-Regular'), url(https://fonts.gstatic.com/s/sourcesanspro/v13/6xK3dSBYKcSV-LCoeQqfX1RYOo3qOK7g.ttf) format('truetype'); 34 | font-display: swap; 35 | } 36 | @font-face { 37 | font-family: 'Source Sans Pro'; 38 | font-style: normal; 39 | font-weight: 600; 40 | src: local('Source Sans Pro SemiBold'), local('SourceSansPro-SemiBold'), url(https://fonts.gstatic.com/s/sourcesanspro/v13/6xKydSBYKcSV-LCoeQqfX1RYOo3i54rwlxdr.ttf) format('truetype'); 41 | font-display: swap; 42 | } 43 | @font-face { 44 | font-family: 'Source Sans Pro'; 45 | font-style: normal; 46 | font-weight: 700; 47 | src: local('Source Sans Pro Bold'), local('SourceSansPro-Bold'), url(https://fonts.gstatic.com/s/sourcesanspro/v13/6xKydSBYKcSV-LCoeQqfX1RYOo3ig4vwlxdr.ttf) format('truetype'); 48 | font-display: swap; 49 | } 50 | @font-face { 51 | font-family: 'Ubuntu'; 52 | font-style: normal; 53 | font-weight: 400; 54 | src: url(https://fonts.gstatic.com/s/ubuntu/v15/4iCs6KVjbNBYlgo6eA.ttf) format('truetype'); 55 | font-display: swap; 56 | } 57 | @font-face { 58 | font-family: 'Ubuntu'; 59 | font-style: normal; 60 | font-weight: 700; 61 | src: url(https://fonts.gstatic.com/s/ubuntu/v15/4iCv6KVjbNBYlgoCxCvTtw.ttf) format('truetype'); 62 | font-display: swap; 63 | } 64 | -------------------------------------------------------------------------------- /web/static/favicon/android-chrome-192x192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/reminder-bot/reminder-rs/bfa9d66ac8b5bc8ec562273a4b9adaee9457e11c/web/static/favicon/android-chrome-192x192.png -------------------------------------------------------------------------------- /web/static/favicon/android-chrome-512x512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/reminder-bot/reminder-rs/bfa9d66ac8b5bc8ec562273a4b9adaee9457e11c/web/static/favicon/android-chrome-512x512.png -------------------------------------------------------------------------------- /web/static/favicon/apple-touch-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/reminder-bot/reminder-rs/bfa9d66ac8b5bc8ec562273a4b9adaee9457e11c/web/static/favicon/apple-touch-icon.png -------------------------------------------------------------------------------- /web/static/favicon/browserconfig.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | #da532c 7 | 8 | 9 | 10 | -------------------------------------------------------------------------------- /web/static/favicon/favicon-16x16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/reminder-bot/reminder-rs/bfa9d66ac8b5bc8ec562273a4b9adaee9457e11c/web/static/favicon/favicon-16x16.png -------------------------------------------------------------------------------- /web/static/favicon/favicon-32x32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/reminder-bot/reminder-rs/bfa9d66ac8b5bc8ec562273a4b9adaee9457e11c/web/static/favicon/favicon-32x32.png -------------------------------------------------------------------------------- /web/static/favicon/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/reminder-bot/reminder-rs/bfa9d66ac8b5bc8ec562273a4b9adaee9457e11c/web/static/favicon/favicon.ico -------------------------------------------------------------------------------- /web/static/favicon/mstile-150x150.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/reminder-bot/reminder-rs/bfa9d66ac8b5bc8ec562273a4b9adaee9457e11c/web/static/favicon/mstile-150x150.png -------------------------------------------------------------------------------- /web/static/favicon/site.webmanifest: -------------------------------------------------------------------------------- 1 | { 2 | "name": "", 3 | "short_name": "", 4 | "icons": [ 5 | { 6 | "src": "/android-chrome-192x192.png", 7 | "sizes": "192x192", 8 | "type": "image/png" 9 | }, 10 | { 11 | "src": "/android-chrome-512x512.png", 12 | "sizes": "512x512", 13 | "type": "image/png" 14 | } 15 | ], 16 | "theme_color": "#ffffff", 17 | "background_color": "#ffffff", 18 | "display": "standalone" 19 | } 20 | -------------------------------------------------------------------------------- /web/static/img/bg.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/reminder-bot/reminder-rs/bfa9d66ac8b5bc8ec562273a4b9adaee9457e11c/web/static/img/bg.webp -------------------------------------------------------------------------------- /web/static/img/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/reminder-bot/reminder-rs/bfa9d66ac8b5bc8ec562273a4b9adaee9457e11c/web/static/img/icon.png -------------------------------------------------------------------------------- /web/static/img/logo_flat.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/reminder-bot/reminder-rs/bfa9d66ac8b5bc8ec562273a4b9adaee9457e11c/web/static/img/logo_flat.jpg -------------------------------------------------------------------------------- /web/static/img/logo_flat.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/reminder-bot/reminder-rs/bfa9d66ac8b5bc8ec562273a4b9adaee9457e11c/web/static/img/logo_flat.webp -------------------------------------------------------------------------------- /web/static/img/slash-commands.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/reminder-bot/reminder-rs/bfa9d66ac8b5bc8ec562273a4b9adaee9457e11c/web/static/img/slash-commands.png -------------------------------------------------------------------------------- /web/static/img/support/delete_reminder/1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/reminder-bot/reminder-rs/bfa9d66ac8b5bc8ec562273a4b9adaee9457e11c/web/static/img/support/delete_reminder/1.png -------------------------------------------------------------------------------- /web/static/img/support/delete_reminder/2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/reminder-bot/reminder-rs/bfa9d66ac8b5bc8ec562273a4b9adaee9457e11c/web/static/img/support/delete_reminder/2.png -------------------------------------------------------------------------------- /web/static/img/support/delete_reminder/3.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/reminder-bot/reminder-rs/bfa9d66ac8b5bc8ec562273a4b9adaee9457e11c/web/static/img/support/delete_reminder/3.png -------------------------------------------------------------------------------- /web/static/img/support/delete_reminder/cancel-1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/reminder-bot/reminder-rs/bfa9d66ac8b5bc8ec562273a4b9adaee9457e11c/web/static/img/support/delete_reminder/cancel-1.png -------------------------------------------------------------------------------- /web/static/img/support/delete_reminder/cancel-2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/reminder-bot/reminder-rs/bfa9d66ac8b5bc8ec562273a4b9adaee9457e11c/web/static/img/support/delete_reminder/cancel-2.png -------------------------------------------------------------------------------- /web/static/img/support/delete_reminder/cmd-1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/reminder-bot/reminder-rs/bfa9d66ac8b5bc8ec562273a4b9adaee9457e11c/web/static/img/support/delete_reminder/cmd-1.png -------------------------------------------------------------------------------- /web/static/img/support/delete_reminder/cmd-2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/reminder-bot/reminder-rs/bfa9d66ac8b5bc8ec562273a4b9adaee9457e11c/web/static/img/support/delete_reminder/cmd-2.png -------------------------------------------------------------------------------- /web/static/img/support/iemanager/edit_spreadsheet.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/reminder-bot/reminder-rs/bfa9d66ac8b5bc8ec562273a4b9adaee9457e11c/web/static/img/support/iemanager/edit_spreadsheet.png -------------------------------------------------------------------------------- /web/static/img/support/iemanager/format_text.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/reminder-bot/reminder-rs/bfa9d66ac8b5bc8ec562273a4b9adaee9457e11c/web/static/img/support/iemanager/format_text.png -------------------------------------------------------------------------------- /web/static/img/support/iemanager/import.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/reminder-bot/reminder-rs/bfa9d66ac8b5bc8ec562273a4b9adaee9457e11c/web/static/img/support/iemanager/import.png -------------------------------------------------------------------------------- /web/static/img/support/iemanager/select_export.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/reminder-bot/reminder-rs/bfa9d66ac8b5bc8ec562273a4b9adaee9457e11c/web/static/img/support/iemanager/select_export.png -------------------------------------------------------------------------------- /web/static/img/support/iemanager/sheets_settings.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/reminder-bot/reminder-rs/bfa9d66ac8b5bc8ec562273a4b9adaee9457e11c/web/static/img/support/iemanager/sheets_settings.png -------------------------------------------------------------------------------- /web/static/img/tournament-demo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/reminder-bot/reminder-rs/bfa9d66ac8b5bc8ec562273a4b9adaee9457e11c/web/static/img/tournament-demo.png -------------------------------------------------------------------------------- /web/static/js/expand.js: -------------------------------------------------------------------------------- 1 | function collapse_all() { 2 | document.querySelectorAll("div.reminderContent:not(.creator)").forEach((el) => { 3 | el.classList.add("is-collapsed"); 4 | }); 5 | } 6 | 7 | function expand_all() { 8 | document.querySelectorAll("div.reminderContent:not(.creator)").forEach((el) => { 9 | el.classList.remove("is-collapsed"); 10 | }); 11 | } 12 | 13 | const expandAll = document.querySelector("#expandAll"); 14 | 15 | expandAll.addEventListener("change", (ev) => { 16 | if (ev.target.value === "expand") { 17 | expand_all(); 18 | } else if (ev.target.value === "collapse") { 19 | collapse_all(); 20 | } 21 | 22 | ev.target.value = ""; 23 | }); 24 | -------------------------------------------------------------------------------- /web/static/js/interval.js: -------------------------------------------------------------------------------- 1 | function get_interval(element) { 2 | let months = element.querySelector('input[name="interval_months"]').value; 3 | let days = element.querySelector('input[name="interval_days"]').value; 4 | let hours = element.querySelector('input[name="interval_hours"]').value; 5 | let minutes = element.querySelector('input[name="interval_minutes"]').value; 6 | let seconds = element.querySelector('input[name="interval_seconds"]').value; 7 | 8 | return { 9 | months: parseInt(months) || null, 10 | days: parseInt(days) || null, 11 | seconds: 12 | (parseInt(hours) || 0) * 3600 + 13 | (parseInt(minutes) || 0) * 60 + 14 | (parseInt(seconds) || 0) || null, 15 | }; 16 | } 17 | 18 | function update_interval(element) { 19 | let months = element.querySelector('input[name="interval_months"]'); 20 | let days = element.querySelector('input[name="interval_days"]'); 21 | let hours = element.querySelector('input[name="interval_hours"]'); 22 | let minutes = element.querySelector('input[name="interval_minutes"]'); 23 | let seconds = element.querySelector('input[name="interval_seconds"]'); 24 | 25 | let interval = get_interval(element); 26 | 27 | if (interval.months === null && interval.days === null && interval.seconds === null) { 28 | months.value = ""; 29 | days.value = ""; 30 | hours.value = ""; 31 | minutes.value = ""; 32 | seconds.value = ""; 33 | } else { 34 | months.value = months.value.padStart(1, "0"); 35 | days.value = days.value.padStart(1, "0"); 36 | hours.value = hours.value.padStart(2, "0"); 37 | minutes.value = minutes.value.padStart(2, "0"); 38 | seconds.value = seconds.value.padStart(2, "0"); 39 | 40 | if (seconds.value >= 60) { 41 | let quotient = Math.floor(seconds.value / 60); 42 | let remainder = seconds.value % 60; 43 | 44 | seconds.value = String(remainder).padStart(2, "0"); 45 | minutes.value = String(Number(minutes.value) + Number(quotient)).padStart( 46 | 2, 47 | "0" 48 | ); 49 | } 50 | if (minutes.value >= 60) { 51 | let quotient = Math.floor(minutes.value / 60); 52 | let remainder = minutes.value % 60; 53 | 54 | minutes.value = String(remainder).padStart(2, "0"); 55 | hours.value = String(Number(hours.value) + Number(quotient)).padStart(2, "0"); 56 | } 57 | } 58 | } 59 | 60 | const $intervalGroup = document.querySelector(".interval-group"); 61 | 62 | document.querySelector(".interval-group").addEventListener( 63 | "blur", 64 | (ev) => { 65 | if (ev.target.nodeName !== "BUTTON") update_interval($intervalGroup); 66 | }, 67 | true 68 | ); 69 | 70 | $intervalGroup.querySelector("button.clear").addEventListener("click", () => { 71 | $intervalGroup.querySelectorAll("input").forEach((el) => { 72 | el.value = ""; 73 | }); 74 | }); 75 | 76 | document.addEventListener("remindersLoaded", (event) => { 77 | for (reminder of event.detail) { 78 | let $intervalGroup = reminder.node.querySelector(".interval-group"); 79 | 80 | $intervalGroup.addEventListener( 81 | "blur", 82 | (ev) => { 83 | if (ev.target.nodeName !== "BUTTON") update_interval($intervalGroup); 84 | }, 85 | true 86 | ); 87 | 88 | $intervalGroup.querySelector("button.clear").addEventListener("click", () => { 89 | $intervalGroup.querySelectorAll("input").forEach((el) => { 90 | el.value = ""; 91 | }); 92 | }); 93 | } 94 | }); 95 | -------------------------------------------------------------------------------- /web/static/js/js.cookie.min.js: -------------------------------------------------------------------------------- 1 | /*! js-cookie v3.0.0-rc.0 | MIT */ 2 | !function(e,t){"object"==typeof exports&&"undefined"!=typeof module?module.exports=t():"function"==typeof define&&define.amd?define(t):(e=e||self,function(){var r=e.Cookies,n=e.Cookies=t();n.noConflict=function(){return e.Cookies=r,n}}())}(this,function(){"use strict";function e(e){for(var t=1;t { 7 | let channel1 = a.querySelector("select.channel-selector").value; 8 | let channel2 = b.querySelector("select.channel-selector").value; 9 | 10 | return channel1 > channel2 ? 1 : -1; 11 | }) 12 | .forEach((node) => guildReminders.appendChild(node)); 13 | 14 | // go through and add channel categories 15 | let currentChannelGroup = null; 16 | for (let child of guildReminders.querySelectorAll("div.reminderContent")) { 17 | let thisChannelGroup = child.querySelector("select.channel-selector").value; 18 | 19 | if (currentChannelGroup !== thisChannelGroup) { 20 | let newNode = document.createElement("div"); 21 | newNode.textContent = 22 | "#" + channels.find((a) => a.id === thisChannelGroup).name; 23 | newNode.classList.add("channel-tag"); 24 | 25 | guildReminders.insertBefore(newNode, child); 26 | 27 | currentChannelGroup = thisChannelGroup; 28 | } 29 | } 30 | } else { 31 | // remove any channel tags if previous ordering was by channel 32 | guildReminders.querySelectorAll("div.channel-tag").forEach((el) => { 33 | el.remove(); 34 | }); 35 | 36 | if (cond === "time") { 37 | [...guildReminders.children] 38 | .sort((a, b) => { 39 | let time1 = luxon.DateTime.fromISO( 40 | a.querySelector('input[name="time"]').value 41 | ); 42 | let time2 = luxon.DateTime.fromISO( 43 | b.querySelector('input[name="time"]').value 44 | ); 45 | 46 | return time1 > time2 ? 1 : -1; 47 | }) 48 | .forEach((node) => guildReminders.appendChild(node)); 49 | } else { 50 | [...guildReminders.children] 51 | .sort((a, b) => { 52 | let name1 = a.querySelector('input[name="name"]').value; 53 | let name2 = b.querySelector('input[name="name"]').value; 54 | 55 | return name1 > name2 ? 1 : -1; 56 | }) 57 | .forEach((node) => guildReminders.appendChild(node)); 58 | } 59 | } 60 | } 61 | 62 | const selector = document.querySelector("#orderBy"); 63 | 64 | selector.addEventListener("change", () => { 65 | sort_by(selector.value); 66 | }); 67 | 68 | document.addEventListener("remindersLoaded", () => { 69 | sort_by(selector.value); 70 | }); 71 | -------------------------------------------------------------------------------- /web/static/js/timezone.js: -------------------------------------------------------------------------------- 1 | let timezone = luxon.DateTime.now().zone.name; 2 | const browserTimezone = luxon.DateTime.now().zone.name; 3 | let botTimezone = "UTC"; 4 | 5 | function update_times() { 6 | document.querySelectorAll("span.set-timezone").forEach((element) => { 7 | element.textContent = timezone; 8 | }); 9 | document.querySelectorAll("span.set-time").forEach((element) => { 10 | element.textContent = luxon.DateTime.now().setZone(timezone).toFormat("HH:mm"); 11 | }); 12 | document.querySelectorAll("span.browser-timezone").forEach((element) => { 13 | element.textContent = browserTimezone; 14 | }); 15 | document.querySelectorAll("span.browser-time").forEach((element) => { 16 | element.textContent = luxon.DateTime.now().toFormat("HH:mm"); 17 | }); 18 | document.querySelectorAll("span.bot-timezone").forEach((element) => { 19 | element.textContent = botTimezone; 20 | }); 21 | document.querySelectorAll("span.bot-time").forEach((element) => { 22 | element.textContent = luxon.DateTime.now().setZone(botTimezone).toFormat("HH:mm"); 23 | }); 24 | } 25 | 26 | window.setInterval(() => { 27 | update_times(); 28 | }, 30000); 29 | 30 | document.getElementById("set-bot-timezone").addEventListener("click", () => { 31 | timezone = botTimezone; 32 | update_times(); 33 | }); 34 | document.getElementById("set-browser-timezone").addEventListener("click", () => { 35 | timezone = browserTimezone; 36 | update_times(); 37 | }); 38 | document.getElementById("update-bot-timezone").addEventListener("click", () => { 39 | timezone = browserTimezone; 40 | fetch("/dashboard/api/user", { 41 | method: "PATCH", 42 | headers: { 43 | Accept: "application/json", 44 | "Content-Type": "application/json", 45 | }, 46 | body: JSON.stringify({ timezone: timezone }), 47 | }) 48 | .then((response) => response.json()) 49 | .then((data) => { 50 | if (data.error) { 51 | show_error(data.error); 52 | } else { 53 | botTimezone = browserTimezone; 54 | update_times(); 55 | } 56 | }); 57 | }); 58 | -------------------------------------------------------------------------------- /web/static/webfonts/fa-brands-400.eot: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/reminder-bot/reminder-rs/bfa9d66ac8b5bc8ec562273a4b9adaee9457e11c/web/static/webfonts/fa-brands-400.eot -------------------------------------------------------------------------------- /web/static/webfonts/fa-brands-400.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/reminder-bot/reminder-rs/bfa9d66ac8b5bc8ec562273a4b9adaee9457e11c/web/static/webfonts/fa-brands-400.ttf -------------------------------------------------------------------------------- /web/static/webfonts/fa-brands-400.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/reminder-bot/reminder-rs/bfa9d66ac8b5bc8ec562273a4b9adaee9457e11c/web/static/webfonts/fa-brands-400.woff -------------------------------------------------------------------------------- /web/static/webfonts/fa-brands-400.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/reminder-bot/reminder-rs/bfa9d66ac8b5bc8ec562273a4b9adaee9457e11c/web/static/webfonts/fa-brands-400.woff2 -------------------------------------------------------------------------------- /web/static/webfonts/fa-duotone-900.eot: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/reminder-bot/reminder-rs/bfa9d66ac8b5bc8ec562273a4b9adaee9457e11c/web/static/webfonts/fa-duotone-900.eot -------------------------------------------------------------------------------- /web/static/webfonts/fa-duotone-900.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/reminder-bot/reminder-rs/bfa9d66ac8b5bc8ec562273a4b9adaee9457e11c/web/static/webfonts/fa-duotone-900.ttf -------------------------------------------------------------------------------- /web/static/webfonts/fa-duotone-900.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/reminder-bot/reminder-rs/bfa9d66ac8b5bc8ec562273a4b9adaee9457e11c/web/static/webfonts/fa-duotone-900.woff -------------------------------------------------------------------------------- /web/static/webfonts/fa-duotone-900.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/reminder-bot/reminder-rs/bfa9d66ac8b5bc8ec562273a4b9adaee9457e11c/web/static/webfonts/fa-duotone-900.woff2 -------------------------------------------------------------------------------- /web/static/webfonts/fa-light-300.eot: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/reminder-bot/reminder-rs/bfa9d66ac8b5bc8ec562273a4b9adaee9457e11c/web/static/webfonts/fa-light-300.eot -------------------------------------------------------------------------------- /web/static/webfonts/fa-light-300.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/reminder-bot/reminder-rs/bfa9d66ac8b5bc8ec562273a4b9adaee9457e11c/web/static/webfonts/fa-light-300.ttf -------------------------------------------------------------------------------- /web/static/webfonts/fa-light-300.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/reminder-bot/reminder-rs/bfa9d66ac8b5bc8ec562273a4b9adaee9457e11c/web/static/webfonts/fa-light-300.woff -------------------------------------------------------------------------------- /web/static/webfonts/fa-light-300.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/reminder-bot/reminder-rs/bfa9d66ac8b5bc8ec562273a4b9adaee9457e11c/web/static/webfonts/fa-light-300.woff2 -------------------------------------------------------------------------------- /web/static/webfonts/fa-regular-400.eot: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/reminder-bot/reminder-rs/bfa9d66ac8b5bc8ec562273a4b9adaee9457e11c/web/static/webfonts/fa-regular-400.eot -------------------------------------------------------------------------------- /web/static/webfonts/fa-regular-400.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/reminder-bot/reminder-rs/bfa9d66ac8b5bc8ec562273a4b9adaee9457e11c/web/static/webfonts/fa-regular-400.ttf -------------------------------------------------------------------------------- /web/static/webfonts/fa-regular-400.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/reminder-bot/reminder-rs/bfa9d66ac8b5bc8ec562273a4b9adaee9457e11c/web/static/webfonts/fa-regular-400.woff -------------------------------------------------------------------------------- /web/static/webfonts/fa-regular-400.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/reminder-bot/reminder-rs/bfa9d66ac8b5bc8ec562273a4b9adaee9457e11c/web/static/webfonts/fa-regular-400.woff2 -------------------------------------------------------------------------------- /web/static/webfonts/fa-solid-900.eot: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/reminder-bot/reminder-rs/bfa9d66ac8b5bc8ec562273a4b9adaee9457e11c/web/static/webfonts/fa-solid-900.eot -------------------------------------------------------------------------------- /web/static/webfonts/fa-solid-900.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/reminder-bot/reminder-rs/bfa9d66ac8b5bc8ec562273a4b9adaee9457e11c/web/static/webfonts/fa-solid-900.ttf -------------------------------------------------------------------------------- /web/static/webfonts/fa-solid-900.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/reminder-bot/reminder-rs/bfa9d66ac8b5bc8ec562273a4b9adaee9457e11c/web/static/webfonts/fa-solid-900.woff -------------------------------------------------------------------------------- /web/static/webfonts/fa-solid-900.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/reminder-bot/reminder-rs/bfa9d66ac8b5bc8ec562273a4b9adaee9457e11c/web/static/webfonts/fa-solid-900.woff2 -------------------------------------------------------------------------------- /web/templates/cookies.html.tera: -------------------------------------------------------------------------------- 1 | {% extends "base" %} 2 | 3 | {% block init %} 4 | {% set title = "Cookies" %} 5 | 6 | {% set page_title = "Cookies" %} 7 | {% set page_subtitle = "Data this website stores on your computer" %} 8 | {% set page_emoji = "fa-gingerbread-man" %} 9 | {% endblock %} 10 | 11 | {% block content %} 12 | 13 |
14 |
15 |

User data

16 |

17 | This website uses some necessary cookies and session data to operate. None of this can be disabled, since 18 | it is all necessary for the site to function. However, it is worth mentioning that all of 19 | this data is first-party only, i.e we use no tracking scripts like Google Analytics, and 20 | no adverts are served on this site. 21 |

22 |
23 |
24 | 25 |
26 |
27 |

Cookies

28 |

29 | Cookies are data that are persistent between browser restarts. Cookies are read and written by the website 30 | running on your computer, not by our server. 31 |
32 | Cookies store information on your preferences, including if you prefer AM/PM or 24 hour 33 | clock, if you have closed the cookie popup before, and what 34 | order you have the servers in on the dashboard. 35 |

36 |
37 |
38 | 39 |
40 |
41 |

Session storage

42 |

43 | Session data are data that is stored just for the active browser session. Session storage is read and 44 | written by our server and cannot be modified on your computer. 45 |
46 | Session data stores an access token provided by Discord, used to retrieve information 47 | about your account. Also stored is an internal ID for use with our API. 48 |

49 |
50 |
51 | 52 |
53 |
54 |

How can we trust you?

55 |

56 | Feel free to audit this website. Go to our GitHub to get started, or just press F12 57 |

58 |
59 |
60 | 61 | {% endblock %} 62 | -------------------------------------------------------------------------------- /web/templates/errors/401.html.tera: -------------------------------------------------------------------------------- 1 | {% extends "base" %} 2 | 3 | {% block init %} 4 | {% set title = "401 Not Authorized" %} 5 | 6 | {% set show_login = True %} 7 | 8 | {% set page_title = "Not Authorized" %} 9 | {% set page_subtitle = "You must be logged in to access this page, if it exists." %} 10 | {% endblock %} 11 | -------------------------------------------------------------------------------- /web/templates/errors/403.html.tera: -------------------------------------------------------------------------------- 1 | {% extends "base" %} 2 | 3 | {% block init %} 4 | {% set title = "403 Forbidden" %} 5 | 6 | {% set show_contact = True %} 7 | 8 | {% set page_title = "Forbidden" %} 9 | {% set page_subtitle = "You currently cannot access this page, if it exists. Sorry." %} 10 | {% endblock %} 11 | -------------------------------------------------------------------------------- /web/templates/errors/404.html.tera: -------------------------------------------------------------------------------- 1 | {% extends "base" %} 2 | 3 | {% block init %} 4 | {% set title = "404 File Not Found" %} 5 | 6 | {% set show_contact = True %} 7 | 8 | {% set page_title = "File Not Found" %} 9 | {% set page_subtitle = "This page does not exist. Sorry." %} 10 | {% endblock %} 11 | -------------------------------------------------------------------------------- /web/templates/errors/500.html.tera: -------------------------------------------------------------------------------- 1 | {% extends "base" %} 2 | 3 | {% block init %} 4 | {% set title = "500 Internal Server Error" %} 5 | {% set show_contact = True %} 6 | 7 | {% set page_title = "An Error Has Occurred" %} 8 | {% set page_subtitle = "A server error has occurred. Please retry, or ask in our Discord." %} 9 | {% endblock %} 10 | -------------------------------------------------------------------------------- /web/templates/help.html.tera: -------------------------------------------------------------------------------- 1 | {% extends "base" %} 2 | 3 | {% block init %} 4 | {% set title = "Support" %} 5 | 6 | {% set page_title = "Support Articles" %} 7 | {% set page_subtitle = "Can't find what you're looking for? Join our Discord!" %} 8 | {% set show_contact = true %} 9 | {% endblock %} 10 | 11 | {% block content %} 12 | 13 |
14 |
15 |
16 | 27 |
28 |
29 | 40 |
41 |
42 | 53 |
54 |
55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | 74 | 75 | 76 | 77 | 78 | 79 | 80 | 81 | 82 | 83 | 84 | 85 | 86 | 87 | 88 | 89 | 90 | 91 | 92 | 93 | 94 | 95 | 96 |
97 |
98 | 109 |
110 |
111 | 122 |
123 |
124 | 125 | 126 | 127 | 128 | 129 | 130 | 131 | 132 | 133 | 134 | 135 |
136 |
137 |
138 | 139 |
140 |
141 |
142 |

Need more help?

143 |

144 | Feel free to come and ask us! 145 |

146 |
147 |
148 | 155 |
156 | 157 | {% endblock %} 158 | -------------------------------------------------------------------------------- /web/templates/index.html.tera: -------------------------------------------------------------------------------- 1 | {% extends "base" %} 2 | 3 | {% block init %} 4 | {% set title = "Home" %} 5 | 6 | {% set page_title = "Reminder Bot" %} 7 | {% set page_subtitle = "Powerful Discord Reminders" %} 8 | {% set page_emoji = "fa-hourglass-half" %} 9 | {% set show_invite = true %} 10 | {% endblock %} 11 | 12 | {% block content %} 13 | 14 |
15 |
16 |
17 |
18 |

Slash-command Ready

19 |

Set reminders easily and quickly from anywhere

20 |
21 | Discord slash commands demonstration 22 |
23 |
24 |
25 |
26 |
27 |

Advanced Options

28 |

Decorate your announcements with our web dashboard

29 |
30 | Advanced options demonstration 31 |
32 |
33 |
34 |
35 |
36 |

Unlimited Reminders

37 |

Never forget a thing

38 |
39 |
40 |

Repeating Reminders

41 |

Available to Patreon subscribers at $2/month

42 |
43 |
44 |
45 |
46 | 47 |
48 |
49 |
50 |

Ready to go?

51 |

52 | Add the bot to get started! 53 |

54 |
55 |
56 | 63 |
64 | 65 | {% endblock %} 66 | -------------------------------------------------------------------------------- /web/templates/privacy.html.tera: -------------------------------------------------------------------------------- 1 | {% extends "base" %} 2 | 3 | {% block init %} 4 | {% set title = "Privacy Policy" %} 5 | 6 | {% set page_title = "Privacy Policy" %} 7 | {% set page_subtitle = "" %} 8 | {% set page_emoji = "" %} 9 | {% endblock %} 10 | 11 | {% block content %} 12 | 13 |
14 |
15 |

Who we are

16 |

17 | Reminder Bot is operated solely by Jude Southworth. You can contact me by email at 18 | jude@jellywx.com, or via private/public message on Discord at 19 | https://discord.jellywx.com. 20 |

21 |
22 |
23 | 24 |
25 |
26 |

What data we collect

27 |

28 | Reminder Bot stores limited data necessary for the function of the bot. This data 29 | is your unique user ID, timezone, and direct message channel. 30 |
31 |
32 | Timezones are provided by the user or the user's browser. 33 |

34 |
35 |
36 | 37 |
38 |
39 |

Why we collect this data

40 |

41 | Unique user IDs are stored to keep track of who sets reminders. User timezones are 42 | stored to allow users to set reminders in their local timezone. Direct message channels are stored to 43 | allow the setting of reminders for your direct message channel. 44 |

45 |
46 |
47 | 48 |
49 |
50 |

Who your data is shared with

51 |

52 | Your data is also guarded by the privacy policies of MEGA, our backup provider, and 53 | Hetzner, our hosting provider. 54 |

55 |
56 |
57 | 58 |
59 |
60 |

Accessing or removing your data

61 |

62 | Your timezone can be removed with the command /timezone UTC. Other data can be removed 63 | on request. Please contact me. 64 |
65 |
66 | Reminders created in a guild/channel will be removed automatically when the bot is removed from the 67 | guild, the guild is deleted, or channel is deleted. Data is otherwise not removed automatically. 68 |
69 |
70 | Reminders deleted with /del or via the dashboard are removed from the live database 71 | instantly, but may persist in backups for up to a year. 72 |

73 |
74 |
75 | 76 | {% endblock %} 77 | -------------------------------------------------------------------------------- /web/templates/reminder_dashboard/reminder_dashboard.html.tera: -------------------------------------------------------------------------------- 1 |
2 | Create Reminder 3 |
4 | {% set creating = true %} 5 | {% include "reminder_dashboard/guild_reminder" %} 6 | {% set creating = false %} 7 |
8 |
9 | 10 |
11 |
12 |
13 | Reminders 14 |
15 |
16 |
17 |
18 | 23 |
24 |
25 | 26 |
27 |
28 |
29 |
30 |
31 |
32 | 37 |
38 |
39 | 40 |
41 |
42 |
43 |
44 |
45 | 46 |
47 | 48 |
49 |
50 | 51 | 52 | 53 | -------------------------------------------------------------------------------- /web/templates/return.html.tera: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Reminder Bot | Redirecting... 6 | 7 | 8 | Press here if you aren't redirected 9 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /web/templates/support/create_reminder.html.tera: -------------------------------------------------------------------------------- 1 | {% extends "base" %} 2 | 3 | {% block init %} 4 | {% set title = "Support" %} 5 | 6 | {% set page_title = "Create Reminders" %} 7 | {% set page_subtitle = "" %} 8 | {% endblock %} 9 | 10 | {% block content %} 11 | 12 |
13 |
14 |
15 |

Create reminders via commands

16 |

17 | You can create reminders with the /remind command. 18 |
19 | Fill out the "time" and "content" fields. If you wish, press on "Optional" to view other options 20 | for the reminder. 21 |

22 |
23 |
24 |
25 | 26 |
27 |
28 |
29 |

Create reminders via the dashboard

30 |

31 | Reminders can also be created on the dashboard. The dashboard offers more options for configuring 32 | reminders, and offers templates for quick recreation of reminders. 33 | 34 | Access the dashboard. 35 |

36 |
37 |
38 |
39 | 40 | {% endblock %} 41 | -------------------------------------------------------------------------------- /web/templates/support/dashboard.html.tera: -------------------------------------------------------------------------------- 1 | {% extends "base" %} 2 | 3 | {% block init %} 4 | {% set title = "Support" %} 5 | 6 | {% set page_title = "Dashboard" %} 7 | {% set page_subtitle = "" %} 8 | {% endblock %} 9 | 10 | {% block content %} 11 | 12 |
13 |
14 |
15 |

Accessing the dashboard

16 |

17 |

18 |
19 |
20 |
21 | 22 | {% endblock %} 23 | -------------------------------------------------------------------------------- /web/templates/support/delete_reminder.html.tera: -------------------------------------------------------------------------------- 1 | {% extends "base" %} 2 | 3 | {% block init %} 4 | {% set title = "Support" %} 5 | 6 | {% set page_title = "Deleting Reminders" %} 7 | {% set page_subtitle = "" %} 8 | {% set show_invite = false %} 9 | {% endblock %} 10 | 11 | {% block content %} 12 | 13 |
14 |
15 |
16 |

Deleting reminders via commands

17 |

18 | Deleting reminders is as easy as typing /del. 19 |
20 |

21 |
22 | /del 23 |
24 |
25 | Reminder deleted 26 |
27 |

28 | Note that you cannot delete reminders that were set for another user's direct messages. To delete 29 | reminders in your direct messages, use /del in the direct message channel with 30 | Reminder Bot. 31 |

32 |
33 |
34 |
35 | 36 |
37 |
38 |
39 |

Deleting reminders you've just created

40 |

41 | If you made a mistake, you can quickly delete a reminder you made by pressing "Cancel" 42 |
43 |

44 |
45 | Cancel button 46 |
47 |
48 | Reminder deleted 49 |
50 |
51 |
52 |
53 | 54 |
55 |
56 |
57 |

Deleting reminders via the dashboard

58 |

59 | Reminders in servers can be deleted via the dashboard. First, select your server from the menu. 60 |

61 |
62 | Selecting server 63 |
64 |
65 |

66 | Then, find the reminder you wish to delete. 67 |

68 |
69 | Finding reminder 70 |
71 |
72 |

73 | Finally, press the 'Delete' button under the reminder. 74 |

75 |
76 | Delete button 77 |
78 |
79 |
80 |
81 | 82 | {% endblock %} 83 | -------------------------------------------------------------------------------- /web/templates/support/iemanager.html.tera: -------------------------------------------------------------------------------- 1 | {% extends "base" %} 2 | 3 | {% block init %} 4 | {% set title = "Support" %} 5 | 6 | {% set page_title = "Import/Export" %} 7 | {% set page_subtitle = "" %} 8 | {% endblock %} 9 | 10 | {% block content %} 11 | 12 |
13 |
14 |
15 |

Export data

16 |

17 | You can export data associated with your server from the dashboard. The data will export as a CSV 18 | file. The CSV file can then be edited and imported to bulk edit server data. 19 |

20 |
21 |
22 |
23 | 24 |
25 |
26 |
27 |

Import data

28 |

29 | You can import previous exports or modified exports. When importing a file, the new data will be added alongside existing data. 30 |

31 |
32 |
33 |
34 | 35 |
36 |
37 |
38 |

Edit your data

39 |

40 | The CSV can be edited either as a text file or in a spreadsheet editor such as LibreOffice Calc. To 41 | set up LibreOffice Calc for editing, do the following: 42 |

43 |
    44 |
  1. 45 | Export data from dashboard. 46 |
    47 | Selecting export button 48 |
    49 |
  2. 50 |
  3. 51 | Open the file in LibreOffice. During the import dialogue, select "Format quoted field as text". 52 |
    53 | Selecting format button 54 |
    55 |
  4. 56 |
  5. 57 | Make edits to the spreadsheet. You can add, edit, and remove rows for reminders. Don't remove the top-most (title) row. 58 |
    59 | Editing spreadsheet 60 |
    61 |
  6. 62 |
  7. 63 | Save the edited CSV file and import it on the dashboard. 64 |
    65 | Import new reminders 66 |
    67 |
  8. 68 |
69 | Other spreadsheet tools can also be used to edit exports, as long as they are properly configured: 70 |
    71 |
  • 72 | Google Sheets: Create a new blank spreadsheet. Select File > Import > Upload > export.csv. 73 | Use the following import settings: 74 |
    75 | Google sheets import settings 76 |
    77 |
  • 78 |
  • 79 | Excel (including Excel Online): Avoid using Excel. Excel will not correctly import channels, or give 80 | clear options to correct imports. 81 |
  • 82 |
83 |
84 |
85 |
86 | 87 | {% endblock %} 88 | -------------------------------------------------------------------------------- /web/templates/support/intervals.html.tera: -------------------------------------------------------------------------------- 1 | {% extends "base" %} 2 | 3 | {% block init %} 4 | {% set title = "Support" %} 5 | 6 | {% set page_title = "Intervals" %} 7 | {% set page_subtitle = "Interval reminders, or repeating reminders, are available to our Patreon supporters" %} 8 | {% endblock %} 9 | 10 | {% block content %} 11 | 12 |
13 |
14 |
15 |

Fixed intervals

16 |

17 | The main type of interval is the fixed interval. Fixed intervals are ideal for hourly, daily, or 18 | reminders repeating at any other fixed amount of time. 19 |
20 | You can create fixed interval reminders via the dashboard or via the /remind command. 21 | When you have filled out the "time" and "content" on the command, press tab. Select the 22 | "interval" option. Then, write the interval you wish to use: for example, "1 day" for daily (starting 23 | at the time specified in "time"). 24 |

25 |
26 |
27 |
28 | 29 |
30 |
31 |
32 |

Daylight savings

33 |

34 | If you live in a region that uses daylight savings (DST), then your interval reminders may become 35 | offset by an hour due to clock changes. 36 |
37 | Reminder Bot offers a quick solution to this via the /offset command. This command 38 | moves all existing reminders on a server by a certain amount of time. You can use offset to move 39 | your reminders forward or backward by an hour when daylight savings happens. 40 |

41 |
42 |
43 |
44 | 45 |
46 |
47 |
48 |

Monthly/yearly intervals

49 |

50 | Monthly or yearly intervals are configured the same as fixed intervals. Instead of a fixed time 51 | interval, these reminders repeat on a certain day each month or each year. This makes them ideal 52 | for marking calendar events. 53 |

54 |
55 |
56 |
57 | 58 |
59 |
60 |
61 |

Interval expiration

62 |

63 | An expiration time can also be specified, both via commands and dashboard, for repeating reminders. 64 | This is optional, and if omitted, the reminder will repeat indefinitely. Otherwise, the reminder 65 | will be deleted once the expiration date is reached. 66 |

67 |
68 |
69 |
70 | 71 | {% endblock %} 72 | -------------------------------------------------------------------------------- /web/templates/support/macros.html.tera: -------------------------------------------------------------------------------- 1 | {% extends "base" %} 2 | 3 | {% block init %} 4 | {% set title = "Support" %} 5 | 6 | {% set page_title = "Manage Macros" %} 7 | {% set page_subtitle = "For advanced functionality" %} 8 | {% set show_invite = false %} 9 | {% endblock %} 10 | 11 | {% block content %} 12 | 13 |
14 |
15 |
16 |

Create macros via commands

17 |

18 |

19 |
20 |
21 |
22 | 23 | {% endblock %} 24 | -------------------------------------------------------------------------------- /web/templates/support/timers.html.tera: -------------------------------------------------------------------------------- 1 | {% extends "base" %} 2 | 3 | {% block init %} 4 | {% set title = "Support" %} 5 | 6 | {% set page_title = "Timers" %} 7 | {% set page_subtitle = "" %} 8 | {% set show_invite = false %} 9 | {% endblock %} 10 | 11 | {% block content %} 12 | 13 |
14 |
15 |
16 |

Create timers via commands

17 |

18 |

19 |
20 |
21 |
22 | 23 | {% endblock %} 24 | -------------------------------------------------------------------------------- /web/templates/support/timezone.html.tera: -------------------------------------------------------------------------------- 1 | {% extends "base" %} 2 | 3 | {% block init %} 4 | {% set title = "Support" %} 5 | 6 | {% set page_title = "Timezones" %} 7 | {% set page_subtitle = "" %} 8 | {% set show_invite = false %} 9 | {% endblock %} 10 | 11 | {% block content %} 12 | 13 |
14 |
15 |
16 |

Selecting your timezone manually

17 |

18 | To select your timezone manually, use /timezone. This will set your timezone 19 | across all servers with Reminder Bot. 20 |
21 | You should only ever have to do this once. To avoid needing to change timezone due to daylight 22 | savings, choose a DST-aware region, for example Europe/London instead of 23 | GMT, or US/New_York instead of EST. 24 |

25 |
26 |
27 |
28 | 29 |
30 |
31 |
32 |

Selecting your timezone automatically

33 |

34 | You can also configure Reminder Bot's timezone from your browser. To do 35 | this, go to our dashboard, press 'Timezone' in the bottom left (desktop) or at the bottom of the 36 | navigation menu (mobile). Then, choose 'Set Bot Timezone' to set Reminder Bot to use your browser's 37 | timezone. 38 |
39 | From here, you can also configure the dashboard to alternatively use the manually configured 40 | timezone instead of the browser's timezone, if your browser is reporting your timezone incorrectly, 41 | or if you have a special use-case. 42 |

43 |
44 |
45 |
46 | 47 | {% endblock %} 48 | -------------------------------------------------------------------------------- /web/templates/support/todo_lists.html.tera: -------------------------------------------------------------------------------- 1 | {% extends "base" %} 2 | 3 | {% block init %} 4 | {% set title = "Support" %} 5 | 6 | {% set page_title = "Todo lists" %} 7 | {% set page_subtitle = "" %} 8 | {% set show_invite = false %} 9 | {% endblock %} 10 | 11 | {% block content %} 12 | 13 |
14 |
15 |
16 |

Add to todo lists via commands

17 |

18 |

19 |
20 |
21 |
22 | 23 | {% endblock %} 24 | -------------------------------------------------------------------------------- /web/templates/terms.html.tera: -------------------------------------------------------------------------------- 1 | {% extends "base" %} 2 | 3 | {% block init %} 4 | {% set title = "Terms of Service" %} 5 | 6 | {% set page_title = "Terms of Service" %} 7 | {% set page_subtitle = "" %} 8 | {% set page_emoji = "" %} 9 | {% endblock %} 10 | 11 | {% block content %} 12 | 13 |
14 |
15 |

Outline

16 |

17 | The Terms of Service apply whenever you use Reminder Bot and the 18 | JellyWX's Home Discord server. 19 |
20 |
21 | Violating the Terms of Service may result in receiving a permanent ban from the Discord server, 22 | permanent restriction on your usage of Reminder Bot, or removal of some or all of your content on 23 | Reminder Bot or the Discord server. None of these will necessarily be preceded or succeeded by a warning 24 | or notice. 25 |
26 |
27 | The Terms of Service may be updated. Notice will be provided via the Discord server. You 28 | should consider the Terms of Service to be a strong for appropriate behaviour. 29 |

30 |
31 |
32 | 33 |
34 |
35 |

Reminder Bot

36 |
    37 |
  • Reasonably disclose potential exploits or bugs to me by email or by Discord private message
  • 38 |
  • Do not use the bot to harass other Discord users
  • 39 |
  • Do not use the bot to transmit malware or other illegal content
  • 40 |
  • Do not use the bot to send more than 15 messages during a 60 second period
  • 41 |
  • 42 | Do not attempt to circumvent restrictions imposed by the bot or website, including trying to access 43 | data of other users, circumvent Patreon restrictions, or uploading files and creating reminders that 44 | are too large for the bot to send or process. Some or all of these actions may be illegal in your 45 | country 46 |
  • 47 |
48 |
49 |
50 | 51 |
52 |
53 |

JellyWX's Home

54 |
    55 |
  • Do not discuss politics, harass other users, or use language intended to upset other users
  • 56 |
  • Do not share personal information about yourself or any other user. This includes but is not 57 | limited to real names1, addresses, phone numbers, country of origin2, religion, email address, 58 | IP address.
  • 59 |
  • Do not send malicious links or attachments
  • 60 |
  • Do not advertise
  • 61 |
  • Do not send unwarranted direct messages
  • 62 |
63 |

64 | 1 Some users may use their real name on their account. In this case, do not assert that 65 | this is a user's real name, or use it to try and identify a user. 66 |
67 | 2 Country of current residence may be discussed, as this is relevant to timezone and 68 | DST selection. 69 |

70 |
71 |
72 | 73 | {% endblock %} 74 | --------------------------------------------------------------------------------