169 Commits

Author SHA1 Message Date
e7160215b0 Defer offset response 2023-11-11 13:36:40 +00:00
6eaa6f0f28 Bump version 2023-10-19 20:32:01 +01:00
9db0fa2513 Fix attachment decoding 2023-10-19 20:10:40 +01:00
ca13fd4fa7 Restructure code 2023-10-08 18:24:04 +01:00
55acc8fd16 Bump ver 2023-10-08 12:39:31 +01:00
145711fa5d Add version strings to files 2023-10-08 12:21:38 +01:00
5524215786 Bump ver 2023-10-07 16:10:01 +01:00
e8bd05893f Transmit guild name with patreon information 2023-10-07 16:08:25 +01:00
e3d3418f99 Change routing. Remove a macro 2023-10-05 18:54:53 +01:00
2681280a39 Fix interval parsing for different cases 2023-10-01 09:42:58 +01:00
00579428a1 Bump version 2023-09-25 18:20:22 +01:00
b8ef999710 Reload reminders after import 2023-09-25 18:17:19 +01:00
e8f84e281a Bump version 2023-09-24 14:53:16 +01:00
8ddff698e5 Show messages when imports succeed. 2023-09-24 14:14:21 +01:00
541633270c Fix margin on bottom of collapsed reminders 2023-09-24 13:58:24 +01:00
25286da5e0 Use transactions for certain routes 2023-09-24 13:57:27 +01:00
4bad1324b9 Restructure
Move some code out to other files. Add transaction guard
2023-09-24 13:11:53 +01:00
bd1462a00c Reposition "options"
Fix import/export
2023-09-23 23:38:16 +01:00
56ffc43616 Store intervals in templates 2023-09-23 22:47:21 +01:00
52cf642455 Send edit button to beta dashboard 2023-09-23 20:32:57 +01:00
0bf578357a Bump version 2023-09-23 18:31:51 +01:00
6e9eccb62e Update dependencies 2023-09-23 18:29:25 +01:00
6ea28284ce Bump ver 2023-09-23 18:24:39 +01:00
a6525f3052 Move button row down. Correct image sizes on some browsers 2023-09-23 18:14:01 +01:00
348639270d Move button row down 2023-09-23 18:05:26 +01:00
37177c2431 Update styles for mobile 2023-09-23 18:04:41 +01:00
8587bed703 Bump ver 2023-08-19 17:10:45 +01:00
6c9af1ae8e Correct styles on mobile burger 2023-08-19 17:09:46 +01:00
7695b7a476 Fix delete command 2023-08-19 14:35:07 +01:00
651da7b28e Improve some styles. Add an offline mode 2023-08-19 14:20:48 +01:00
eb086146bf Bump version 2023-08-16 17:05:18 +01:00
4ebd705e5e Add clearer indication of interval patreon requirements 2023-08-16 17:03:38 +01:00
5a85f1d83a Extract error sections to templates 2023-08-13 18:29:30 +01:00
68ba25886a Correct javascript comparisons 2023-08-11 13:19:31 +01:00
e25bf6b828 bump 2023-08-10 18:41:47 +01:00
5a386daa9d Fix expirations 2023-08-10 18:25:41 +01:00
0d4a02fb1e Bump ver 2023-08-08 17:48:49 +01:00
e135a74a9b Fix avatars not loading correctly 2023-08-08 17:44:40 +01:00
77f17c8dc2 Partially fix reminder usernames resetting 2023-08-07 21:50:11 +01:00
6a94f990cf Bump ver 2023-08-03 20:08:14 +01:00
3aa5bd37aa Fix duplicating reminder fields 2023-08-03 19:57:28 +01:00
fa83fed1af Fix interval updating 2023-08-03 19:50:15 +01:00
666cb7fa2f Fix padding etc. 2023-08-03 19:28:12 +01:00
a5678e15dc Fix styling on buttons
Prevent template buttons from wrapping by consuming more vertical space on middle-sized screens
2023-08-03 18:07:03 +01:00
9405cfcee9 Fix "Reminder needs content".
Certain fields were not being checked correctly for content.
2023-08-03 17:32:17 +01:00
cb25d02cdf Bump ver 2023-08-01 21:12:15 +01:00
bfe651a125 Change autocomplete to use a past date in the past 2023-08-01 20:13:05 +01:00
dc5e52d9ce Default datetime inputs to current date/time 2023-08-01 17:51:29 +01:00
229ada83e1 Fix cron username 2023-07-31 20:14:45 +01:00
13171d6744 Bump ver 2023-07-31 20:09:00 +01:00
2ad941c94c Fix not sending followup reminders 2023-07-31 20:07:54 +01:00
924d31e978 Bump ver 2023-07-31 20:05:43 +01:00
f9a1b23212 Update privacy 2023-07-31 19:28:23 +01:00
ae5795a7ea Update opcode handling 2023-07-31 19:25:06 +01:00
ee36c38eda Update manifest 2023-07-31 19:18:53 +01:00
eca7df3d9f Update style 2023-07-31 18:39:39 +01:00
902b7e1b4a Change reminder sending behaviour to keep reminders but flag them as sent 2023-07-31 18:39:27 +01:00
db1a53a797 Bump ver 2023-07-31 18:04:16 +01:00
3605d71b73 Suppress errors. Restyle 2023-07-31 17:59:38 +01:00
ea2cea573e Bump ver. Round failure rate. 2023-07-30 19:17:44 +01:00
d5fa8036e8 Add data to admin page for success/fail history 2023-07-30 19:09:48 +01:00
b8707bbc9a Fix deleting template making a call on empty template list 2023-07-30 17:16:37 +01:00
99eea16f62 Bump ver 2023-07-30 17:11:37 +01:00
88737302f3 Log reminder send status 2023-07-30 17:00:55 +01:00
213e3a5100 Fix styles. Feedback button 2023-07-30 15:50:46 +01:00
8fa1402ecc Bump ver 2023-07-30 15:42:46 +01:00
e63996bb61 Fix create template not testing for errors 2023-07-30 15:36:58 +01:00
9ede879630 Stats table migration 2023-07-30 15:28:26 +01:00
88e9826a62 Update terms. Fix issue with role picker 2023-07-30 15:26:51 +01:00
5d655c7e6d Update privacy policy 2023-07-30 15:16:34 +01:00
51c9d8a7ae Fix client error on selecting server with no channels 2023-07-30 15:11:34 +01:00
90df265114 Add handler for 50001 Missing Access 2023-07-30 14:13:20 +01:00
e65429aa9c Fix interval input styles 2023-07-30 13:22:57 +01:00
8d2232f0da Bump ver. Use Discord's error codes where possible to improve logging 2023-07-30 12:44:01 +01:00
a58b9866ea Reduce log level 2023-07-30 12:14:47 +01:00
b1f25be5d7 Use transparent background with dashboard logo 2023-07-29 17:13:05 +01:00
f0f9787326 Bump ver 2023-07-23 17:00:09 +01:00
302f5835e6 Fix wrapping on long server names 2023-07-23 16:30:15 +01:00
58c778632e Fix wrapping on long server names 2023-07-23 16:28:27 +01:00
5671fd462b Update contrast on the burger button. fix error thrown by update_select 2023-07-23 16:15:24 +01:00
5ac9733f15 Bump ver 2023-07-23 14:44:35 +01:00
01dc0334fd Fix arbitrary access to reminder list. 2023-07-23 14:29:59 +01:00
4a17aac15c Bump ver 2023-07-23 12:36:25 +01:00
8ce4fc9c6d Fix enable/disable button. Hide demo button 2023-07-23 12:16:09 +01:00
b4f07cfc1c Fix some mobile styles. Fix race condition in client side 2023-07-23 12:06:03 +01:00
8799089b2d Increase the size of reminder names. Restyle. 2023-07-22 15:09:06 +01:00
88c4830209 Fix dashboard embed fields 2023-07-22 13:34:18 +01:00
4dd3df5cc2 bump ver 2023-07-22 13:13:46 +01:00
369a325a46 bump ver 2023-07-22 10:46:33 +01:00
1a1a0fdefb show total reminders and intervals on admin dash 2023-07-10 09:59:11 +01:00
dda8bd3e10 Fix dead link. Hopefully extract mysql details from environment 2023-06-23 11:56:53 +01:00
edbfc92cb9 Add health check email notifications 2023-06-23 09:44:42 +01:00
6de11f09db Change graph periods 2023-06-21 15:36:05 +01:00
284bfcd9ad Split intervals 2023-06-21 15:24:43 +01:00
3d627b5bf0 Add charts 2023-06-21 15:09:24 +01:00
c3c0dbbbae Fetch upcoming schedule and backlog count 2023-06-21 13:26:28 +01:00
64dd81e941 Admin only routes 2023-06-21 10:54:20 +01:00
799298ca34 Add fail cutoff for reminder updating 2023-06-20 15:41:28 +01:00
fa542bb24f Clear up warning from new Rust version 2023-06-20 15:33:25 +01:00
e025d945cf Fix serious issue with adding days. Origin chrono v4.23 2023-06-20 15:30:44 +01:00
bb1c61d0b9 Fallback for reminder days 2023-06-20 14:44:05 +01:00
1519474f93 Report errors to server 2023-06-20 13:13:26 +01:00
9d8622f418 Add logout button 2023-06-20 08:50:12 +01:00
a66db37b33 update poise 2023-06-18 10:47:31 +01:00
c8c1a171d4 Bump version 2023-06-18 10:04:55 +01:00
88cfb829e3 Use conffiles 2023-06-17 12:49:01 +01:00
16be7a328e Correct permissions 2023-06-16 14:00:44 +01:00
04babf7930 updated some dashboard text. fixed authentication. hidden broken stuff 2023-06-16 13:38:42 +01:00
96bc09e8b5 correct authentication 2023-06-16 10:20:42 +01:00
976fb91ecc set default logs 2023-06-15 10:53:13 +01:00
1305b6e64e Bump version 2023-06-14 17:50:56 +01:00
cdfe44d958 Configure permissions properly on Rocket.toml. Make static path behave better 2023-06-14 13:29:48 +01:00
c824a36832 Corrected a number of apt packaging issues 2023-06-13 10:40:48 +01:00
c4bd2c1d18 bump dateparser requirement 2023-06-12 22:47:23 +01:00
561555ab7e updated tos and privacy 2023-05-27 16:40:41 +01:00
115fbd44cb update some frontend 2023-05-27 16:12:09 +01:00
aa931328b0 Support ephemeral reminder confirmations 2023-05-11 19:40:33 +01:00
4b42966284 Moved stuff around since threads are ridiculous 2023-05-11 18:33:06 +01:00
523ab7f03a Partial thread support 2023-05-11 18:32:58 +01:00
6e831c8253 Add migration for threads. Add ability to load .env from wd 2023-05-11 18:32:50 +01:00
4416e5d175 Remove need to supply webhook avatar 2023-05-08 17:32:59 +01:00
734a39a001 Change default Python location. Update build instructions. Add container build instructions 2023-05-08 17:04:51 +01:00
98191d29ee deb-related stuff 2023-05-07 21:08:59 +01:00
1c4c4a8b31 Add deb stuff. Correct dependency on database name 2023-05-07 20:59:07 +01:00
d496c81003 Correct typo in path 2023-05-07 20:38:08 +01:00
094d210f64 Fix orphaned channels issue again 2023-03-24 19:52:41 +00:00
314c72e132 Changed data import to add alongside rather than removing. 2023-03-24 19:41:34 +00:00
4e0163f2cb Rename some environment variables. Add partial deb metadata 2023-03-24 17:44:43 +00:00
e5b8c418af Merge remote-tracking branch 'origin/next' into next 2023-03-24 11:11:59 +00:00
3ef8584189 Use SQLx migrations 2023-03-24 11:11:51 +00:00
df2ad09c86 Update README.md 2023-01-21 12:25:24 +00:00
d70fb24eb1 Fix todo viewing not working for large entries
Was not checking the length of the item when trying to add it to the
dropdown, causing failures.
2023-01-06 17:08:09 +00:00
3150c7267d Add validating to length-validated fields on edit
Can't just replace edit logic with overwrite logic because partial editing is used in enabling/disabling. So need to replicate logic in a sensible way.
2022-12-18 13:38:43 +00:00
6e65e4ff3d update some help pages 2022-12-18 13:09:02 +00:00
67a4db2e9a Ensure interval updating is performed properly
Validate patreon status. Validate interval length against minimum. Update the reminder pane to reflect changes that were made. Properly deserialize.
2022-12-11 10:09:26 +00:00
e9bcb1973f Update web for daily intervals 2022-12-10 16:21:43 +00:00
9b87fd4258 Ver bump 2022-12-10 15:38:21 +00:00
a49a849917 Support daily intervals
Add new database column for interval_days. Update humantime to return days as a separate field.
2022-12-10 15:32:49 +00:00
aa74a7f9a3 Use timezones wherever possible.
Replace uses of NaiveDateTime with DateTime<Utc>. Use timezones in postman to update days correctly. Use chrono::Months to update months rather than using MySQL query.
2022-11-22 20:41:07 +00:00
08e4c6cb57 ver bump 2022-11-20 12:20:52 +00:00
6e087bd2dd Fix character counting on /look. Initial support for jumping over DST boundaries 2022-11-20 12:20:10 +00:00
e9792e6322 ver bump 2022-09-26 16:59:57 +01:00
130504b964 Add notice to macro initial run 2022-09-26 16:44:30 +01:00
2a8117d0c1 Revert multiline changes 2022-09-20 17:00:33 +01:00
94bfd39085 Patch compilation against live schema 2022-09-17 13:05:50 +01:00
40cd5f8a36 Patch compilation against live schema 2022-09-17 13:03:52 +01:00
133b00a2ce Patch compilation against live schema 2022-09-17 12:52:03 +01:00
57336f5c81 Change macro list to use fields to prevent going over limit
Add length checks for name and description
2022-09-17 12:37:58 +01:00
b62d24c024 Add an autocomplete for time hints
Shows the approximate time until a reminder will send in the autocomplete area.
2022-09-12 17:49:10 +01:00
8f8235a86e Move macro commands to own module
Lots of code here
2022-09-12 16:45:00 +01:00
c8f646a8fa Override timezone per command
Timezone option that will override the timezone on a per-command basis
2022-09-11 18:59:46 +01:00
ecaa382a1e Add join message 2022-09-11 17:38:53 +01:00
8991198fd3 Use autocomplete to ensure content box is shown 2022-09-11 15:24:02 +01:00
f20b95a482 Upgrade poise. Combine remind/multiline into one command 2022-09-08 17:58:05 +01:00
8dd7dc6409 Added command for multiline reminders 2022-09-07 18:27:13 +01:00
c799d10727 Move extra processes to user data setup 2022-09-03 16:19:59 +01:00
ceb6fb7b12 bump version 2022-09-03 15:49:05 +01:00
6708abdb0f Merge pull request #10 from reminder-bot/jellywx/fix-dm-reminders
group by channel instead of guild
2022-09-03 15:44:00 +01:00
a38f6024c1 Migrate natural commands 2022-09-03 15:40:29 +01:00
7d8748e3ef group by channel instead of guild 2022-08-19 09:04:12 +01:00
bb3386c4e8 migration for $r commands 2022-08-14 16:22:00 +01:00
25b84880a5 Don't send non-interval disabled reminders
Skip the sending logic as some users use disabled one-time reminders as presets
2022-08-04 19:06:29 +01:00
7b6e967a5d Block/allow DM reminders
Only affects slash commands but this is sort of a non-issue post September
2022-07-29 19:22:15 +01:00
2781f2923e Restrict reminder selection to one-per-guild during fetch loop 2022-07-28 19:20:15 +01:00
03f08f0a18 Update deps. Drop limiter on reminder query 2022-07-27 21:42:09 +01:00
79c86d43f2 Changed return types to results 2022-07-24 20:06:37 +01:00
e19af54caf Import todo lists. Export other data. 2022-07-22 23:30:45 +01:00
f4213c6a83 Cache channel in todo list command
Channel was not being cached, placing channel todos into the server todo list.
2022-07-02 08:31:17 +01:00
f56db14720 Webhook command
Add a command to view the webhook, as some users wish to use the webhook to edit past reminders.
2022-06-17 17:15:48 +01:00
114 changed files with 7141 additions and 3516 deletions

2
.gitignore vendored
View File

@ -2,6 +2,4 @@
.env .env
/venv /venv
.cargo .cargo
assets
out.json
/.idea /.idea

2639
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@ -1,32 +1,58 @@
[package] [package]
name = "reminder_rs" name = "reminder-rs"
version = "1.6.0" version = "1.6.48"
authors = ["jellywx <judesouthworth@pm.me>"] authors = ["Jude Southworth <judesouthworth@pm.me>"]
edition = "2018" edition = "2021"
license = "AGPL-3.0 only"
description = "Reminder Bot for Discord, now in Rust"
[dependencies] [dependencies]
poise = "0.2" poise = "0.5"
dotenv = "0.15" dotenv = "0.15"
tokio = { version = "1", features = ["process", "full"] } tokio = { version = "1", features = ["process", "full"] }
reqwest = "0.11" reqwest = "0.11"
regex = "1.4" lazy-regex = "3.0.2"
regex = "1.9"
log = "0.4" log = "0.4"
env_logger = "0.8" env_logger = "0.10"
chrono = "0.4" chrono = "0.4"
chrono-tz = { version = "0.5", features = ["serde"] } chrono-tz = { version = "0.8", features = ["serde"] }
lazy_static = "1.4" lazy_static = "1.4"
num-integer = "0.1" num-integer = "0.1"
serde = "1.0" serde = "1.0"
serde_json = "1.0" serde_json = "1.0"
serde_repr = "0.1" serde_repr = "0.1"
rmp-serde = "0.15" rmp-serde = "1.1"
rand = "0.7" rand = "0.8"
levenshtein = "1.0" levenshtein = "1.0"
sqlx = { version = "0.5", features = ["runtime-tokio-rustls", "macros", "mysql", "bigdecimal", "chrono"]} sqlx = { version = "0.7", features = ["runtime-tokio-rustls", "macros", "mysql", "bigdecimal", "chrono", "migrate"]}
base64 = "0.13.0" base64 = "0.21.0"
[dependencies.postman] [dependencies.postman]
path = "postman" path = "postman"
[dependencies.reminder_web] [dependencies.reminder_web]
path = "web" path = "web"
[package.metadata.deb]
depends = "$auto, python3-dateparser (>= 1.0.0)"
suggests = "mysql-server-8.0, nginx"
maintainer-scripts = "debian"
assets = [
["target/release/reminder-rs", "usr/bin/reminder-rs", "755"],
["conf/default.env", "etc/reminder-rs/config.env", "600"],
["conf/Rocket.toml", "etc/reminder-rs/Rocket.toml", "600"],
["web/static/**/*", "lib/reminder-rs/static", "644"],
["web/templates/**/*", "lib/reminder-rs/templates", "644"],
["healthcheck", "lib/reminder-rs/healthcheck", "755"],
["cron.d/reminder_health", "etc/cron.d/reminder_health", "644"],
# ["nginx/reminder-rs", "etc/nginx/sites-available/reminder-rs", "755"]
]
conf-files = [
"/etc/reminder-rs/config.env",
"/etc/reminder-rs/Rocket.toml",
]
[package.metadata.deb.systemd-units]
unit-scripts = "systemd"
start = false

9
Containerfile Normal file
View File

@ -0,0 +1,9 @@
FROM ubuntu:20.04
ENV RUSTUP_HOME=/usr/local/rustup \
CARGO_HOME=/usr/local/cargo \
PATH=/usr/local/cargo/bin:$PATH
RUN apt update && DEBIAN_FRONTEND=noninteractive TZ=Etc/UTC apt install -y gcc gcc-multilib cmake pkg-config libssl-dev curl mysql-client-8.0
RUN curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --no-modify-path --profile minimal --default-toolchain nightly
RUN cargo install cargo-deb

View File

@ -7,25 +7,36 @@ reminders are paid on the hosted version of the bot. Keep reading if you want to
You'll need rustc and cargo for compilation. To run, you'll need Python 3 still (due to no suitable replacement for dateparser in Rust) You'll need rustc and cargo for compilation. To run, you'll need Python 3 still (due to no suitable replacement for dateparser in Rust)
### Compiling ### Build APT package
Install build requirements:
`sudo apt install gcc gcc-multilib cmake libssl-dev build-essential`
Install Rust from https://rustup.rs Recommended method.
Reminder Bot can then be built by running `cargo build --release` in the top level directory. It is necessary to create a By default, this builds targeting Ubuntu 20.04. Modify the Containerfile if you wish to target a different platform. These instructions are written using `podman`, but `docker` should work too.
folder called 'assets' containing an image file with its name specified in the environment as `WEBHOOK_AVATAR`, of
dimensions 128x128px to be used as the webhook avatar.
#### Compilation environment variables 1. Install container software: `sudo apt install podman`.
These environment variables must be provided when compiling the bot 2. Install database server: `sudo apt install mysql-server-8.0`. Create a database called `reminders`
* `DATABASE_URL` - the URL of your MySQL database (`mysql://user[:password]@domain/database`) 3. Install SQLx CLI: `cargo install sqlx-cli`
* `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** 4. From the source code directory, execute `sqlx migrate run`
5. Build container image: `podman build -t reminder-rs .`
6. Build with podman: `podman run --rm --network=host -v "$PWD":/mnt -w /mnt -e "DATABASE_URL=mysql://user@localhost/reminders" reminder-rs cargo deb`
### Setting up Python
Reminder Bot by default looks for a venv within it's working directory to run Python out of. To set up a venv, install `python3-venv` and run `python3 -m venv venv`. Then, run `source venv/bin/activate` to activate the venv, and do `pip install dateparser` to install the required library
### Environment Variables ### Compiling for other target
1. Install requirements:
`sudo apt install gcc gcc-multilib cmake libssl-dev build-essential python3-dateparser`
2. Install rustup from https://rustup.rs
3. Install the nightly toolchain: `rustup toolchain default nightly`
4. Install database server: `sudo apt install mysql-server-8.0`. Create a database called `reminders`.
5. Install `sqlx-cli`: `cargo install sqlx-cli`.
6. Run migrations: `sqlx migrate run`.
7. Set environment variables:
* `DATABASE_URL` - the URL of your MySQL database (`mysql://user[:password]@domain/database`)
8. Build: `cargo build --release`
### Configuring
Reminder Bot reads a number of environment variables. Some are essential, and others have hardcoded fallbacks. Environment variables can be loaded from a .env file in the working directory. Reminder Bot reads a number of environment variables. Some are essential, and others have hardcoded fallbacks. Environment variables can be loaded from a .env file in the working directory.
__Required Variables__ __Required Variables__
@ -37,10 +48,5 @@ __Other Variables__
* `LOCAL_TIMEZONE` - default `UTC`, necessary for calculations in the natural language processor * `LOCAL_TIMEZONE` - default `UTC`, necessary for calculations in the natural language processor
* `SUBSCRIPTION_ROLES` - default `None`, accepts a list of Discord role IDs that are given to subscribed users * `SUBSCRIPTION_ROLES` - default `None`, accepts a list of Discord role IDs that are given to subscribed users
* `CNC_GUILD` - default `None`, accepts a single Discord guild ID for the server that the subscription roles belong to * `CNC_GUILD` - default `None`, accepts a single Discord guild ID for the server that the subscription roles belong to
* `PYTHON_LOCATION` - default `venv/bin/python3`. Can be changed if your Python executable is located somewhere else * `PYTHON_LOCATION` - default `/usr/bin/python3`. Can be changed if your Python executable is located somewhere else
* `THEME_COLOR` - default `8fb677`. Specifies the hex value of the color to use on info message embeds * `THEME_COLOR` - default `8fb677`. Specifies the hex value of the color to use on info message embeds
* `DM_ENABLED` - default `1`, if `1`, Reminder Bot will respond to direct messages
### Todo List
* Convert aliases to macros

View File

@ -1,6 +1,6 @@
[default] [default]
address = "0.0.0.0" address = "0.0.0.0"
port = 5000 port = 18920
template_dir = "web/templates" template_dir = "web/templates"
limits = { json = "10MiB" } limits = { json = "10MiB" }
@ -11,18 +11,18 @@ secret_key = "tR8krio5FXTnnyIZNiJDXPondz0kI1v6X6BXZcBGIRY="
certs = "web/private/rsa_sha256_cert.pem" certs = "web/private/rsa_sha256_cert.pem"
key = "web/private/rsa_sha256_key.pem" key = "web/private/rsa_sha256_key.pem"
[rsa_sha256.tls] [debug.rsa_sha256.tls]
certs = "web/private/rsa_sha256_cert.pem" certs = "web/private/rsa_sha256_cert.pem"
key = "web/private/rsa_sha256_key.pem" key = "web/private/rsa_sha256_key.pem"
[ecdsa_nistp256_sha256.tls] [debug.ecdsa_nistp256_sha256.tls]
certs = "web/private/ecdsa_nistp256_sha256_cert.pem" certs = "web/private/ecdsa_nistp256_sha256_cert.pem"
key = "web/private/ecdsa_nistp256_sha256_key_pkcs8.pem" key = "web/private/ecdsa_nistp256_sha256_key_pkcs8.pem"
[ecdsa_nistp384_sha384.tls] [debug.ecdsa_nistp384_sha384.tls]
certs = "web/private/ecdsa_nistp384_sha384_cert.pem" certs = "web/private/ecdsa_nistp384_sha384_cert.pem"
key = "web/private/ecdsa_nistp384_sha384_key_pkcs8.pem" key = "web/private/ecdsa_nistp384_sha384_key_pkcs8.pem"
[ed25519.tls] [debug.ed25519.tls]
certs = "web/private/ed25519_cert.pem" certs = "web/private/ed25519_cert.pem"
key = "eb/private/ed25519_key.pem" key = "eb/private/ed25519_key.pem"

BIN
assets/webhook.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

3
build.rs Normal file
View File

@ -0,0 +1,3 @@
fn main() {
println!("cargo:rerun-if-changed=migrations");
}

8
conf/Rocket.toml Normal file
View File

@ -0,0 +1,8 @@
[default]
address = "127.0.0.1"
port = 18920
template_dir = "/lib/reminder-rs/templates"
limits = { json = "10MiB" }
[release]
# secret_key = ""

19
conf/default.env Normal file
View File

@ -0,0 +1,19 @@
DATABASE_URL=
DISCORD_TOKEN=
PATREON_GUILD_ID=
PATREON_ROLE_ID=
LOCAL_TIMEZONE=
MIN_INTERVAL=
PYTHON_LOCATION=/usr/bin/python3
DONTRUN=
SECRET_KEY=
REMIND_INTERVAL=
OAUTH2_DISCORD_CALLBACK=
OAUTH2_CLIENT_ID=
OAUTH2_CLIENT_SECRET=
REPORT_EMAIL=
LOG_TO_DATABASE=1

1
cron.d/reminder_health Normal file
View File

@ -0,0 +1 @@
*/10 * * * * reminder /lib/reminder-rs/healthcheck

9
debian/postinst vendored Normal file
View File

@ -0,0 +1,9 @@
#!/bin/bash
set -e
id -u reminder &>/dev/null || useradd -r -M reminder
chown -R reminder /etc/reminder-rs
#DEBHELPER#

7
debian/postrm vendored Normal file
View File

@ -0,0 +1,7 @@
#!/bin/bash
set -e
id -u reminder &>/dev/null || userdel reminder
#DEBHELPER#

13
healthcheck Executable file
View File

@ -0,0 +1,13 @@
#!/bin/bash
export $(grep -v '^#' /etc/reminder-rs/config.env | xargs -d '\n')
REGEX='mysql://([A-Za-z]+)@(.+)/(.+)'
[[ $DATABASE_URL =~ $REGEX ]]
VAR=$(mysql -u "${BASH_REMATCH[1]}" -h "${BASH_REMATCH[2]}" -N -D "${BASH_REMATCH[3]}" -e "SELECT COUNT(1) FROM reminders WHERE utc_time < NOW() - INTERVAL 10 MINUTE AND enabled = 1 AND status = 'pending'")
if [ "$VAR" -gt 0 ]
then
echo "This is to inform that there is a reminder backlog which must be resolved." | mail -s "Backlog: $VAR" "$REPORT_EMAIL"
fi

View File

@ -1,4 +0,0 @@
USE reminders;
ALTER TABLE reminders.reminders RENAME COLUMN `interval` TO `interval_seconds`;
ALTER TABLE reminders.reminders ADD COLUMN `interval_months` INT UNSIGNED DEFAULT NULL;

View File

@ -1,10 +1,6 @@
CREATE DATABASE IF NOT EXISTS reminders;
SET FOREIGN_KEY_CHECKS=0; SET FOREIGN_KEY_CHECKS=0;
USE reminders; CREATE TABLE guilds (
CREATE TABLE reminders.guilds (
id INT UNSIGNED UNIQUE NOT NULL AUTO_INCREMENT, id INT UNSIGNED UNIQUE NOT NULL AUTO_INCREMENT,
guild BIGINT UNSIGNED UNIQUE NOT NULL, guild BIGINT UNSIGNED UNIQUE NOT NULL,
@ -18,10 +14,10 @@ CREATE TABLE reminders.guilds (
default_avatar VARCHAR(512) DEFAULT 'https://raw.githubusercontent.com/reminder-bot/logos/master/Remind_Me_Bot_Logo_PPic.jpg' NOT NULL, default_avatar VARCHAR(512) DEFAULT 'https://raw.githubusercontent.com/reminder-bot/logos/master/Remind_Me_Bot_Logo_PPic.jpg' NOT NULL,
PRIMARY KEY (id), PRIMARY KEY (id),
FOREIGN KEY (default_channel_id) REFERENCES reminders.channels(id) ON DELETE SET NULL FOREIGN KEY (default_channel_id) REFERENCES channels(id) ON DELETE SET NULL
); );
CREATE TABLE reminders.channels ( CREATE TABLE channels (
id INT UNSIGNED UNIQUE NOT NULL AUTO_INCREMENT, id INT UNSIGNED UNIQUE NOT NULL AUTO_INCREMENT,
channel BIGINT UNSIGNED UNIQUE NOT NULL, channel BIGINT UNSIGNED UNIQUE NOT NULL,
@ -39,10 +35,10 @@ CREATE TABLE reminders.channels (
guild_id INT UNSIGNED, guild_id INT UNSIGNED,
PRIMARY KEY (id), PRIMARY KEY (id),
FOREIGN KEY (guild_id) REFERENCES reminders.guilds(id) ON DELETE CASCADE FOREIGN KEY (guild_id) REFERENCES guilds(id) ON DELETE CASCADE
); );
CREATE TABLE reminders.users ( CREATE TABLE users (
id INT UNSIGNED AUTO_INCREMENT UNIQUE NOT NULL, id INT UNSIGNED AUTO_INCREMENT UNIQUE NOT NULL,
user BIGINT UNSIGNED UNIQUE NOT NULL, user BIGINT UNSIGNED UNIQUE NOT NULL,
@ -59,10 +55,10 @@ CREATE TABLE reminders.users (
patreon BOOLEAN NOT NULL DEFAULT 0, patreon BOOLEAN NOT NULL DEFAULT 0,
PRIMARY KEY (id), PRIMARY KEY (id),
FOREIGN KEY (dm_channel) REFERENCES reminders.channels(id) ON DELETE RESTRICT FOREIGN KEY (dm_channel) REFERENCES channels(id) ON DELETE RESTRICT
); );
CREATE TABLE reminders.roles ( CREATE TABLE roles (
id INT UNSIGNED UNIQUE NOT NULL AUTO_INCREMENT, id INT UNSIGNED UNIQUE NOT NULL AUTO_INCREMENT,
role BIGINT UNSIGNED UNIQUE NOT NULL, role BIGINT UNSIGNED UNIQUE NOT NULL,
@ -71,10 +67,10 @@ CREATE TABLE reminders.roles (
guild_id INT UNSIGNED NOT NULL, guild_id INT UNSIGNED NOT NULL,
PRIMARY KEY (id), PRIMARY KEY (id),
FOREIGN KEY (guild_id) REFERENCES reminders.guilds(id) ON DELETE CASCADE FOREIGN KEY (guild_id) REFERENCES guilds(id) ON DELETE CASCADE
); );
CREATE TABLE reminders.embeds ( CREATE TABLE embeds (
id INT UNSIGNED AUTO_INCREMENT UNIQUE NOT NULL, id INT UNSIGNED AUTO_INCREMENT UNIQUE NOT NULL,
title VARCHAR(256) NOT NULL DEFAULT '', title VARCHAR(256) NOT NULL DEFAULT '',
@ -91,7 +87,7 @@ CREATE TABLE reminders.embeds (
PRIMARY KEY (id) PRIMARY KEY (id)
); );
CREATE TABLE reminders.embed_fields ( CREATE TABLE embed_fields (
id INT UNSIGNED AUTO_INCREMENT UNIQUE NOT NULL, id INT UNSIGNED AUTO_INCREMENT UNIQUE NOT NULL,
title VARCHAR(256) NOT NULL DEFAULT '', title VARCHAR(256) NOT NULL DEFAULT '',
@ -100,10 +96,10 @@ CREATE TABLE reminders.embed_fields (
embed_id INT UNSIGNED NOT NULL, embed_id INT UNSIGNED NOT NULL,
PRIMARY KEY (id), PRIMARY KEY (id),
FOREIGN KEY (embed_id) REFERENCES reminders.embeds(id) ON DELETE CASCADE FOREIGN KEY (embed_id) REFERENCES embeds(id) ON DELETE CASCADE
); );
CREATE TABLE reminders.messages ( CREATE TABLE messages (
id INT UNSIGNED AUTO_INCREMENT UNIQUE NOT NULL, id INT UNSIGNED AUTO_INCREMENT UNIQUE NOT NULL,
content VARCHAR(2048) NOT NULL DEFAULT '', content VARCHAR(2048) NOT NULL DEFAULT '',
@ -114,10 +110,10 @@ CREATE TABLE reminders.messages (
attachment_name VARCHAR(260), attachment_name VARCHAR(260),
PRIMARY KEY (id), PRIMARY KEY (id),
FOREIGN KEY (embed_id) REFERENCES reminders.embeds(id) ON DELETE SET NULL FOREIGN KEY (embed_id) REFERENCES embeds(id) ON DELETE SET NULL
); );
CREATE TABLE reminders.reminders ( CREATE TABLE reminders (
id INT UNSIGNED AUTO_INCREMENT UNIQUE NOT NULL, id INT UNSIGNED AUTO_INCREMENT UNIQUE NOT NULL,
uid VARCHAR(64) UNIQUE NOT NULL, uid VARCHAR(64) UNIQUE NOT NULL,
@ -140,20 +136,20 @@ CREATE TABLE reminders.reminders (
set_by INT UNSIGNED, set_by INT UNSIGNED,
PRIMARY KEY (id), PRIMARY KEY (id),
FOREIGN KEY (message_id) REFERENCES reminders.messages(id) ON DELETE RESTRICT, FOREIGN KEY (message_id) REFERENCES messages(id) ON DELETE RESTRICT,
FOREIGN KEY (channel_id) REFERENCES reminders.channels(id) ON DELETE CASCADE, FOREIGN KEY (channel_id) REFERENCES channels(id) ON DELETE CASCADE,
FOREIGN KEY (set_by) REFERENCES reminders.users(id) ON DELETE SET NULL FOREIGN KEY (set_by) REFERENCES users(id) ON DELETE SET NULL
); );
CREATE TRIGGER message_cleanup AFTER DELETE ON reminders.reminders CREATE TRIGGER message_cleanup AFTER DELETE ON reminders
FOR EACH ROW FOR EACH ROW
DELETE FROM reminders.messages WHERE id = OLD.message_id; DELETE FROM messages WHERE id = OLD.message_id;
CREATE TRIGGER embed_cleanup AFTER DELETE ON reminders.messages CREATE TRIGGER embed_cleanup AFTER DELETE ON messages
FOR EACH ROW FOR EACH ROW
DELETE FROM reminders.embeds WHERE id = OLD.embed_id; DELETE FROM embeds WHERE id = OLD.embed_id;
CREATE TABLE reminders.todos ( CREATE TABLE todos (
id INT UNSIGNED AUTO_INCREMENT UNIQUE NOT NULL, id INT UNSIGNED AUTO_INCREMENT UNIQUE NOT NULL,
user_id INT UNSIGNED, user_id INT UNSIGNED,
guild_id INT UNSIGNED, guild_id INT UNSIGNED,
@ -161,23 +157,23 @@ CREATE TABLE reminders.todos (
value VARCHAR(2000) NOT NULL, value VARCHAR(2000) NOT NULL,
PRIMARY KEY (id), PRIMARY KEY (id),
FOREIGN KEY (user_id) REFERENCES reminders.users(id) ON DELETE SET NULL, FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE SET NULL,
FOREIGN KEY (guild_id) REFERENCES reminders.guilds(id) ON DELETE CASCADE, FOREIGN KEY (guild_id) REFERENCES guilds(id) ON DELETE CASCADE,
FOREIGN KEY (channel_id) REFERENCES reminders.channels(id) ON DELETE SET NULL FOREIGN KEY (channel_id) REFERENCES channels(id) ON DELETE SET NULL
); );
CREATE TABLE reminders.command_restrictions ( CREATE TABLE command_restrictions (
id INT UNSIGNED AUTO_INCREMENT UNIQUE NOT NULL, id INT UNSIGNED AUTO_INCREMENT UNIQUE NOT NULL,
role_id INT UNSIGNED NOT NULL, role_id INT UNSIGNED NOT NULL,
command ENUM('todos', 'natural', 'remind', 'interval', 'timer', 'del', 'look', 'alias', 'countdown') NOT NULL, command ENUM('todos', 'natural', 'remind', 'interval', 'timer', 'del', 'look', 'alias', 'countdown') NOT NULL,
PRIMARY KEY (id), PRIMARY KEY (id),
FOREIGN KEY (role_id) REFERENCES reminders.roles(id) ON DELETE CASCADE, FOREIGN KEY (role_id) REFERENCES roles(id) ON DELETE CASCADE,
UNIQUE KEY (`role_id`, `command`) UNIQUE KEY (`role_id`, `command`)
); );
CREATE TABLE reminders.timers ( CREATE TABLE timers (
id INT UNSIGNED AUTO_INCREMENT UNIQUE NOT NULL, id INT UNSIGNED AUTO_INCREMENT UNIQUE NOT NULL,
start_time TIMESTAMP NOT NULL DEFAULT NOW(), start_time TIMESTAMP NOT NULL DEFAULT NOW(),
name VARCHAR(32) NOT NULL, name VARCHAR(32) NOT NULL,
@ -186,7 +182,7 @@ CREATE TABLE reminders.timers (
PRIMARY KEY (id) PRIMARY KEY (id)
); );
CREATE TABLE reminders.events ( CREATE TABLE events (
id INT UNSIGNED AUTO_INCREMENT UNIQUE NOT NULL, id INT UNSIGNED AUTO_INCREMENT UNIQUE NOT NULL,
`time` TIMESTAMP NOT NULL DEFAULT NOW(), `time` TIMESTAMP NOT NULL DEFAULT NOW(),
@ -198,12 +194,12 @@ CREATE TABLE reminders.events (
reminder_id INT UNSIGNED, reminder_id INT UNSIGNED,
PRIMARY KEY (id), PRIMARY KEY (id),
FOREIGN KEY (guild_id) REFERENCES reminders.guilds(id) ON DELETE CASCADE, FOREIGN KEY (guild_id) REFERENCES guilds(id) ON DELETE CASCADE,
FOREIGN KEY (user_id) REFERENCES reminders.users(id) ON DELETE SET NULL, FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE SET NULL,
FOREIGN KEY (reminder_id) REFERENCES reminders.reminders(id) ON DELETE SET NULL FOREIGN KEY (reminder_id) REFERENCES reminders(id) ON DELETE SET NULL
); );
CREATE TABLE reminders.command_aliases ( CREATE TABLE command_aliases (
id INT UNSIGNED AUTO_INCREMENT UNIQUE NOT NULL, id INT UNSIGNED AUTO_INCREMENT UNIQUE NOT NULL,
guild_id INT UNSIGNED NOT NULL, guild_id INT UNSIGNED NOT NULL,
@ -212,22 +208,22 @@ CREATE TABLE reminders.command_aliases (
command VARCHAR(2048) NOT NULL, command VARCHAR(2048) NOT NULL,
PRIMARY KEY (id), PRIMARY KEY (id),
FOREIGN KEY (guild_id) REFERENCES reminders.guilds(id) ON DELETE CASCADE, FOREIGN KEY (guild_id) REFERENCES guilds(id) ON DELETE CASCADE,
UNIQUE KEY (`guild_id`, `name`) UNIQUE KEY (`guild_id`, `name`)
); );
CREATE TABLE reminders.guild_users ( CREATE TABLE guild_users (
guild INT UNSIGNED NOT NULL, guild INT UNSIGNED NOT NULL,
user INT UNSIGNED NOT NULL, user INT UNSIGNED NOT NULL,
can_access BOOL NOT NULL DEFAULT 0, can_access BOOL NOT NULL DEFAULT 0,
FOREIGN KEY (guild) REFERENCES reminders.guilds(id) ON DELETE CASCADE, FOREIGN KEY (guild) REFERENCES guilds(id) ON DELETE CASCADE,
FOREIGN KEY (user) REFERENCES reminders.users(id) ON DELETE CASCADE, FOREIGN KEY (user) REFERENCES users(id) ON DELETE CASCADE,
UNIQUE KEY (guild, user) UNIQUE KEY (guild, user)
); );
CREATE EVENT reminders.event_cleanup CREATE EVENT event_cleanup
ON SCHEDULE AT CURRENT_TIMESTAMP + INTERVAL 1 DAY ON SCHEDULE AT CURRENT_TIMESTAMP + INTERVAL 1 DAY
ON COMPLETION PRESERVE ON COMPLETION PRESERVE
DO DELETE FROM reminders.events WHERE `time` < DATE_SUB(NOW(), INTERVAL 5 DAY); DO DELETE FROM events WHERE `time` < DATE_SUB(NOW(), INTERVAL 5 DAY);

View File

@ -1,5 +1,3 @@
USE reminders;
SET FOREIGN_KEY_CHECKS = 0; SET FOREIGN_KEY_CHECKS = 0;
DROP TABLE IF EXISTS reminders_new; DROP TABLE IF EXISTS reminders_new;
@ -157,4 +155,9 @@ CREATE TABLE events (
FOREIGN KEY (reminder_id) REFERENCES reminders_new(id) ON DELETE SET NULL FOREIGN KEY (reminder_id) REFERENCES reminders_new(id) ON DELETE SET NULL
); );
DROP TABLE reminders;
DROP TABLE embed_fields;
RENAME TABLE reminders_new TO reminders;
RENAME TABLE embed_fields_new TO embed_fields;
SET FOREIGN_KEY_CHECKS = 1; SET FOREIGN_KEY_CHECKS = 1;

View File

@ -1,5 +1,3 @@
USE reminders;
CREATE TABLE macro ( CREATE TABLE macro (
id INT UNSIGNED AUTO_INCREMENT, id INT UNSIGNED AUTO_INCREMENT,
guild_id INT UNSIGNED NOT NULL, guild_id INT UNSIGNED NOT NULL,

View File

@ -0,0 +1,2 @@
ALTER TABLE reminders RENAME COLUMN `interval` TO `interval_seconds`;
ALTER TABLE reminders ADD COLUMN `interval_months` INT UNSIGNED DEFAULT NULL;

View File

@ -1,5 +1,3 @@
USE reminders;
CREATE TABLE reminder_template ( CREATE TABLE reminder_template (
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT, `id` INT UNSIGNED NOT NULL AUTO_INCREMENT,

View File

@ -0,0 +1 @@
ALTER TABLE reminders ADD COLUMN `interval_days` INT UNSIGNED DEFAULT NULL;

View File

@ -0,0 +1 @@
ALTER TABLE reminders ADD COLUMN `thread_id` BIGINT DEFAULT NULL;

View File

@ -0,0 +1 @@
ALTER TABLE guilds ADD COLUMN ephemeral_confirmations BOOL NOT NULL DEFAULT 0;

View File

@ -0,0 +1,2 @@
ALTER TABLE reminders MODIFY `name` VARCHAR(100) NOT NULL DEFAULT 'Reminder';
ALTER TABLE reminder_template MODIFY `name` VARCHAR(100) NOT NULL DEFAULT 'Reminder';

View File

@ -0,0 +1,9 @@
CREATE TABLE stat (
`id` BIGINT NOT NULL AUTO_INCREMENT,
`utc_time` DATETIME NOT NULL DEFAULT NOW(),
`type` ENUM('reminder_sent', 'reminder_failed'),
`reminder_id` INT UNSIGNED,
`message` TEXT,
PRIMARY KEY (`id`)
);

View File

@ -0,0 +1,2 @@
ALTER TABLE reminders ADD COLUMN `status` ENUM ('pending', 'sent', 'failed', 'deleted') NOT NULL DEFAULT 'pending';
ALTER TABLE reminders ADD COLUMN `status_message` TEXT;

View File

@ -0,0 +1,3 @@
ALTER TABLE `reminder_template` ADD COLUMN `interval_seconds` INT UNSIGNED;
ALTER TABLE `reminder_template` ADD COLUMN `interval_days` INT UNSIGNED;
ALTER TABLE `reminder_template` ADD COLUMN `interval_months` INT UNSIGNED;

41
nginx/reminder-rs Normal file
View File

@ -0,0 +1,41 @@
server {
server_name www.reminder-bot.com;
return 301 $scheme://reminder-bot.com$request_uri;
}
server {
listen 80;
server_name reminder-bot.com;
return 301 https://reminder-bot.com$request_uri;
}
server {
listen 443 ssl;
server_name reminder-bot.com;
ssl_certificate /etc/letsencrypt/live/reminder-bot.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/reminder-bot.com/privkey.pem;
access_log /var/log/nginx/access.log;
error_log /var/log/nginx/error.log;
proxy_buffer_size 128k;
proxy_buffers 4 256k;
proxy_busy_buffers_size 256k;
location / {
proxy_pass http://localhost:18920;
proxy_redirect off;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
location /static {
alias /var/www/reminder-rs/static;
expires 30d;
}
}

View File

@ -5,14 +5,12 @@ edition = "2021"
[dependencies] [dependencies]
tokio = { version = "1", features = ["process", "full"] } tokio = { version = "1", features = ["process", "full"] }
regex = "1.4" regex = "1.9"
log = "0.4" log = "0.4"
env_logger = "0.8"
chrono = "0.4" chrono = "0.4"
chrono-tz = { version = "0.5", features = ["serde"] } chrono-tz = { version = "0.8", features = ["serde"] }
lazy_static = "1.4" lazy_static = "1.4"
num-integer = "0.1" num-integer = "0.1"
serde = "1.0" serde = "1.0"
serde_json = "1.0" sqlx = { version = "0.7", features = ["runtime-tokio-rustls", "macros", "mysql", "bigdecimal", "chrono", "json"]}
sqlx = { version = "0.5", features = ["runtime-tokio-rustls", "macros", "mysql", "bigdecimal", "chrono", "json"]} serenity = { version = "0.11", default-features = false, features = ["builder", "cache", "client", "gateway", "http", "model", "utils", "rustls_backend"] }
serenity = { version = "0.11.1", default-features = false, features = ["builder", "cache", "client", "gateway", "http", "model", "utils", "rustls_backend"] }

View File

@ -1,4 +1,6 @@
use chrono::Duration; use std::env;
use chrono::{DateTime, Days, Duration, Months};
use chrono_tz::Tz; use chrono_tz::Tz;
use lazy_static::lazy_static; use lazy_static::lazy_static;
use log::{error, info, warn}; use log::{error, info, warn};
@ -7,7 +9,7 @@ use regex::{Captures, Regex};
use serde::Deserialize; use serde::Deserialize;
use serenity::{ use serenity::{
builder::CreateEmbed, builder::CreateEmbed,
http::{CacheHttp, Http, HttpError, StatusCode}, http::{CacheHttp, Http, HttpError},
model::{ model::{
channel::{Channel, Embed as SerenityEmbed}, channel::{Channel, Embed as SerenityEmbed},
id::ChannelId, id::ChannelId,
@ -30,6 +32,7 @@ lazy_static! {
Regex::new(r#"<<timefrom:(?P<time>\d+):(?P<format>.+)?>>"#).unwrap(); Regex::new(r#"<<timefrom:(?P<time>\d+):(?P<format>.+)?>>"#).unwrap();
pub static ref TIMENOW_REGEX: Regex = pub static ref TIMENOW_REGEX: Regex =
Regex::new(r#"<<timenow:(?P<timezone>(?:\w|/|_)+):(?P<format>.+)?>>"#).unwrap(); Regex::new(r#"<<timenow:(?P<timezone>(?:\w|/|_)+):(?P<format>.+)?>>"#).unwrap();
pub static ref LOG_TO_DATABASE: bool = env::var("LOG_TO_DATABASE").map_or(true, |v| v == "1");
} }
fn fmt_displacement(format: &str, seconds: u64) -> String { fn fmt_displacement(format: &str, seconds: u64) -> String {
@ -62,18 +65,23 @@ pub fn substitute(string: &str) -> String {
let format = caps.name("format").map(|m| m.as_str()); let format = caps.name("format").map(|m| m.as_str());
if let (Some(final_time), Some(format)) = (final_time, format) { if let (Some(final_time), Some(format)) = (final_time, format) {
let dt = NaiveDateTime::from_timestamp(final_time, 0); match NaiveDateTime::from_timestamp_opt(final_time, 0) {
let now = Utc::now().naive_utc(); Some(dt) => {
let now = Utc::now().naive_utc();
let difference = { let difference = {
if now < dt { if now < dt {
dt - Utc::now().naive_utc() dt - Utc::now().naive_utc()
} else { } else {
Utc::now().naive_utc() - dt Utc::now().naive_utc() - dt
}
};
fmt_displacement(format, difference.num_seconds() as u64)
} }
};
fmt_displacement(format, difference.num_seconds() as u64) None => String::new(),
}
} else { } else {
String::new() String::new()
} }
@ -146,7 +154,7 @@ impl Embed {
embed.description = substitute(&embed.description); embed.description = substitute(&embed.description);
embed.footer = substitute(&embed.footer); embed.footer = substitute(&embed.footer);
embed.fields.iter_mut().for_each(|mut field| { embed.fields.iter_mut().for_each(|field| {
field.title = substitute(&field.title); field.title = substitute(&field.title);
field.value = substitute(&field.value); field.value = substitute(&field.value);
}); });
@ -226,7 +234,6 @@ impl Into<CreateEmbed> for Embed {
} }
} }
#[derive(Debug)]
pub struct Reminder { pub struct Reminder {
id: u32, id: u32,
@ -244,11 +251,12 @@ pub struct Reminder {
attachment: Option<Vec<u8>>, attachment: Option<Vec<u8>>,
attachment_name: Option<String>, attachment_name: Option<String>,
utc_time: NaiveDateTime, utc_time: DateTime<Utc>,
timezone: String, timezone: String,
restartable: bool, restartable: bool,
expires: Option<NaiveDateTime>, expires: Option<DateTime<Utc>>,
interval_seconds: Option<u32>, interval_seconds: Option<u32>,
interval_days: Option<u32>,
interval_months: Option<u32>, interval_months: Option<u32>,
avatar: Option<String>, avatar: Option<String>,
@ -282,6 +290,7 @@ SELECT
reminders.`restartable` AS restartable, reminders.`restartable` AS restartable,
reminders.`expires` AS 'expires', reminders.`expires` AS 'expires',
reminders.`interval_seconds` AS 'interval_seconds', reminders.`interval_seconds` AS 'interval_seconds',
reminders.`interval_days` AS 'interval_days',
reminders.`interval_months` AS 'interval_months', reminders.`interval_months` AS 'interval_months',
reminders.`avatar` AS avatar, reminders.`avatar` AS avatar,
@ -293,9 +302,24 @@ INNER JOIN
ON ON
reminders.channel_id = channels.id reminders.channel_id = channels.id
WHERE WHERE
reminders.`utc_time` < NOW() reminders.`status` = 'pending' AND
LIMIT 25 reminders.`id` IN (
"#, SELECT
MIN(id)
FROM
reminders
WHERE
reminders.`utc_time` <= NOW() AND
`status` = 'pending' AND
(
reminders.`interval_seconds` IS NOT NULL
OR reminders.`interval_months` IS NOT NULL
OR reminders.`interval_days` IS NOT NULL
OR reminders.enabled
)
GROUP BY channel_id
)
"#,
) )
.fetch_all(pool) .fetch_all(pool)
.await .await
@ -319,9 +343,7 @@ LIMIT 25
async fn reset_webhook(&self, pool: impl Executor<'_, Database = Database> + Copy) { async fn reset_webhook(&self, pool: impl Executor<'_, Database = Database> + Copy) {
let _ = sqlx::query!( let _ = sqlx::query!(
" "UPDATE channels SET webhook_id = NULL, webhook_token = NULL WHERE channel = ?",
UPDATE channels SET webhook_id = NULL, webhook_token = NULL WHERE channel = ?
",
self.channel_id self.channel_id
) )
.execute(pool) .execute(pool)
@ -329,56 +351,72 @@ UPDATE channels SET webhook_id = NULL, webhook_token = NULL WHERE channel = ?
} }
async fn refresh(&self, pool: impl Executor<'_, Database = Database> + Copy) { async fn refresh(&self, pool: impl Executor<'_, Database = Database> + Copy) {
if self.interval_seconds.is_some() || self.interval_months.is_some() { if self.interval_seconds.is_some()
let now = Utc::now().naive_local(); || self.interval_months.is_some()
let mut updated_reminder_time = self.utc_time; || self.interval_days.is_some()
{
if let Some(interval) = self.interval_months { // If all intervals are zero then dont care
match sqlx::query!( if self.interval_seconds == Some(0)
// use the second date_add to force return value to datetime && self.interval_days == Some(0)
"SELECT DATE_ADD(DATE_ADD(?, INTERVAL ? MONTH), INTERVAL 0 SECOND) AS new_time", && self.interval_months == Some(0)
updated_reminder_time, {
interval self.set_sent(pool).await;
)
.fetch_one(pool)
.await
{
Ok(row) => match row.new_time {
Some(datetime) => {
updated_reminder_time = datetime;
}
None => {
warn!("Could not update interval by months: got NULL");
updated_reminder_time += Duration::days(30);
}
},
Err(e) => {
warn!("Could not update interval by months: {:?}", e);
// naively fallback to adding 30 days
updated_reminder_time += Duration::days(30);
}
}
} }
if let Some(interval) = self.interval_seconds { let now = Utc::now();
while updated_reminder_time < now { let mut updated_reminder_time =
self.utc_time.with_timezone(&self.timezone.parse().unwrap_or(Tz::UTC));
let mut fail_count = 0;
while updated_reminder_time < now && fail_count < 4 {
if let Some(interval) = self.interval_months {
if interval != 0 {
updated_reminder_time = updated_reminder_time
.checked_add_months(Months::new(interval))
.unwrap_or_else(|| {
warn!(
"{}: Could not add {} months to a reminder",
interval, self.id
);
fail_count += 1;
updated_reminder_time
});
}
}
if let Some(interval) = self.interval_days {
if interval != 0 {
updated_reminder_time = updated_reminder_time
.checked_add_days(Days::new(interval as u64))
.unwrap_or_else(|| {
warn!("{}: Could not add {} days to a reminder", self.id, interval);
fail_count += 1;
updated_reminder_time
})
}
}
if let Some(interval) = self.interval_seconds {
updated_reminder_time += Duration::seconds(interval as i64); updated_reminder_time += Duration::seconds(interval as i64);
} }
} }
if self.expires.map_or(false, |expires| { if fail_count >= 4 {
NaiveDateTime::from_timestamp(updated_reminder_time.timestamp(), 0) > expires self.log_error(
}) { pool,
self.force_delete(pool).await; "Failed to update 4 times and so is being deleted",
None::<&'static str>,
)
.await;
self.set_failed(pool, "Failed to update 4 times and so is being deleted").await;
} else if self.expires.map_or(false, |expires| updated_reminder_time > expires) {
self.set_sent(pool).await;
} else { } else {
sqlx::query!( sqlx::query!(
" "UPDATE reminders SET `utc_time` = ? WHERE `id` = ?",
UPDATE reminders SET `utc_time` = ? WHERE `id` = ? updated_reminder_time.with_timezone(&Utc),
",
updated_reminder_time,
self.id self.id
) )
.execute(pool) .execute(pool)
@ -386,15 +424,67 @@ UPDATE reminders SET `utc_time` = ? WHERE `id` = ?
.expect(&format!("Could not update time on Reminder {}", self.id)); .expect(&format!("Could not update time on Reminder {}", self.id));
} }
} else { } else {
self.force_delete(pool).await; self.set_sent(pool).await;
} }
} }
async fn force_delete(&self, pool: impl Executor<'_, Database = Database> + Copy) { async fn log_error(
&self,
pool: impl Executor<'_, Database = Database> + Copy,
error: &'static str,
debug_info: Option<impl std::fmt::Debug>,
) {
let message = match debug_info {
Some(info) => format!(
"{}
{:?}",
error, info
),
None => error.to_string(),
};
error!("[Reminder {}] {}", self.id, message);
if *LOG_TO_DATABASE {
sqlx::query!(
"INSERT INTO stat (type, reminder_id, message) VALUES ('reminder_failed', ?, ?)",
self.id,
message,
)
.execute(pool)
.await
.expect("Could not log error to database");
}
}
async fn log_success(&self, pool: impl Executor<'_, Database = Database> + Copy) {
if *LOG_TO_DATABASE {
sqlx::query!(
"INSERT INTO stat (type, reminder_id) VALUES ('reminder_sent', ?)",
self.id,
)
.execute(pool)
.await
.expect("Could not log success to database");
}
}
async fn set_sent(&self, pool: impl Executor<'_, Database = Database> + Copy) {
sqlx::query!("UPDATE reminders SET `status` = 'sent' WHERE `id` = ?", self.id)
.execute(pool)
.await
.expect(&format!("Could not delete Reminder {}", self.id));
}
async fn set_failed(
&self,
pool: impl Executor<'_, Database = Database> + Copy,
message: &'static str,
) {
sqlx::query!( sqlx::query!(
" "UPDATE reminders SET `status` = 'failed', `status_message` = ? WHERE `id` = ?",
DELETE FROM reminders WHERE `id` = ? message,
",
self.id self.id
) )
.execute(pool) .execute(pool)
@ -493,7 +583,9 @@ DELETE FROM reminders WHERE `id` = ?
w.content(&reminder.content).tts(reminder.tts); w.content(&reminder.content).tts(reminder.tts);
if let Some(username) = &reminder.username { if let Some(username) = &reminder.username {
w.username(username); if !username.is_empty() {
w.username(username);
}
} }
if let Some(avatar) = &reminder.avatar { if let Some(avatar) = &reminder.avatar {
@ -537,9 +629,7 @@ DELETE FROM reminders WHERE `id` = ?
.map_or(true, |inner| inner >= Utc::now().naive_local())) .map_or(true, |inner| inner >= Utc::now().naive_local()))
{ {
let _ = sqlx::query!( let _ = sqlx::query!(
" "UPDATE `channels` SET paused = 0, paused_until = NULL WHERE `channel` = ?",
UPDATE `channels` SET paused = 0, paused_until = NULL WHERE `channel` = ?
",
self.channel_id self.channel_id
) )
.execute(pool) .execute(pool)
@ -556,7 +646,7 @@ UPDATE `channels` SET paused = 0, paused_until = NULL WHERE `channel` = ?
if let Ok(webhook) = webhook_res { if let Ok(webhook) = webhook_res {
send_to_webhook(cache_http, &self, webhook, embed).await send_to_webhook(cache_http, &self, webhook, embed).await
} else { } else {
warn!("Webhook vanished: {:?}", webhook_res); warn!("Webhook vanished for reminder {}: {:?}", self.id, webhook_res);
self.reset_webhook(pool).await; self.reset_webhook(pool).await;
send_to_channel(cache_http, &self, embed).await send_to_channel(cache_http, &self, embed).await
@ -566,24 +656,84 @@ UPDATE `channels` SET paused = 0, paused_until = NULL WHERE `channel` = ?
}; };
if let Err(e) = result { if let Err(e) = result {
error!("Error sending {:?}: {:?}", self, e);
if let Error::Http(error) = e { if let Error::Http(error) = e {
if error.status_code() == Some(StatusCode::NOT_FOUND) { if let HttpError::UnsuccessfulRequest(http_error) = *error {
warn!("Seeing channel is deleted. Removing reminder"); match http_error.error.code {
self.force_delete(pool).await; 10003 => {
} else if let HttpError::UnsuccessfulRequest(error) = *error { self.log_error(
if error.error.code == 50007 { pool,
warn!("User cannot receive DMs"); "Could not be sent as channel does not exist",
self.force_delete(pool).await; None::<&'static str>,
} else { )
self.refresh(pool).await; .await;
self.set_failed(
pool,
"Could not be sent as channel does not exist",
)
.await;
}
10004 => {
self.log_error(
pool,
"Could not be sent as guild does not exist",
None::<&'static str>,
)
.await;
self.set_failed(pool, "Could not be sent as guild does not exist")
.await;
}
50001 => {
self.log_error(
pool,
"Could not be sent as missing access",
None::<&'static str>,
)
.await;
self.set_failed(pool, "Could not be sent as missing access").await;
}
50007 => {
self.log_error(
pool,
"Could not be sent as user has DMs disabled",
None::<&'static str>,
)
.await;
self.set_failed(pool, "Could not be sent as user has DMs disabled")
.await;
}
50013 => {
self.log_error(
pool,
"Could not be sent as permissions are invalid",
None::<&'static str>,
)
.await;
self.set_failed(
pool,
"Could not be sent as permissions are invalid",
)
.await;
}
_ => {
self.log_error(
pool,
"HTTP error sending reminder",
Some(http_error),
)
.await;
self.refresh(pool).await;
}
} }
} else {
self.log_error(pool, "(Likely) a parsing error", Some(error)).await;
self.refresh(pool).await;
} }
} else { } else {
self.log_error(pool, "Non-HTTP error", Some(e)).await;
self.refresh(pool).await; self.refresh(pool).await;
} }
} else { } else {
self.log_success(pool).await;
self.refresh(pool).await; self.refresh(pool).await;
} }
} else { } else {

View File

@ -0,0 +1,117 @@
use std::time::{SystemTime, UNIX_EPOCH};
use chrono_tz::TZ_VARIANTS;
use poise::AutocompleteChoice;
use crate::{models::CtxData, time_parser::natural_parser, Context};
pub async fn timezone_autocomplete(ctx: Context<'_>, partial: &str) -> Vec<String> {
if partial.is_empty() {
ctx.data().popular_timezones.iter().map(|t| t.to_string()).collect::<Vec<String>>()
} else {
TZ_VARIANTS
.iter()
.filter(|tz| tz.to_string().contains(&partial))
.take(25)
.map(|t| t.to_string())
.collect::<Vec<String>>()
}
}
pub async fn macro_name_autocomplete(ctx: Context<'_>, partial: &str) -> Vec<String> {
sqlx::query!(
"
SELECT name
FROM macro
WHERE
guild_id = (SELECT id FROM guilds WHERE guild = ?)
AND name LIKE CONCAT(?, '%')",
ctx.guild_id().unwrap().0,
partial,
)
.fetch_all(&ctx.data().database)
.await
.unwrap_or_default()
.iter()
.map(|s| s.name.clone())
.collect()
}
pub async fn time_hint_autocomplete(
ctx: Context<'_>,
partial: &str,
) -> Vec<AutocompleteChoice<String>> {
if partial.is_empty() {
vec![AutocompleteChoice {
name: "Start typing a time...".to_string(),
value: "now".to_string(),
}]
} else {
match natural_parser(partial, &ctx.timezone().await.to_string()).await {
Some(timestamp) => match SystemTime::now().duration_since(UNIX_EPOCH) {
Ok(now) => {
let diff = timestamp - now.as_secs() as i64;
if diff < 0 {
vec![AutocompleteChoice {
name: "Time is in the past".to_string(),
value: "1 year ago".to_string(),
}]
} else {
if diff > 86400 {
vec![
AutocompleteChoice {
name: partial.to_string(),
value: partial.to_string(),
},
AutocompleteChoice {
name: format!(
"In approximately {} days, {} hours",
diff / 86400,
(diff % 86400) / 3600
),
value: partial.to_string(),
},
]
} else if diff > 3600 {
vec![
AutocompleteChoice {
name: partial.to_string(),
value: partial.to_string(),
},
AutocompleteChoice {
name: format!("In approximately {} hours", diff / 3600),
value: partial.to_string(),
},
]
} else {
vec![
AutocompleteChoice {
name: partial.to_string(),
value: partial.to_string(),
},
AutocompleteChoice {
name: format!("In approximately {} minutes", diff / 60),
value: partial.to_string(),
},
]
}
}
}
Err(_) => {
vec![AutocompleteChoice {
name: partial.to_string(),
value: partial.to_string(),
}]
}
},
None => {
vec![AutocompleteChoice {
name: "Time not recognised".to_string(),
value: "now".to_string(),
}]
}
}
}
}

View File

@ -0,0 +1,46 @@
use super::super::autocomplete::macro_name_autocomplete;
use crate::{Context, Error};
/// Delete a recorded macro
#[poise::command(
slash_command,
rename = "delete",
guild_only = true,
default_member_permissions = "MANAGE_GUILD",
identifying_name = "delete_macro"
)]
pub async fn delete_macro(
ctx: Context<'_>,
#[description = "Name of macro to delete"]
#[autocomplete = "macro_name_autocomplete"]
name: String,
) -> Result<(), Error> {
match sqlx::query!(
"
SELECT id FROM macro WHERE guild_id = (SELECT id FROM guilds WHERE guild = ?) AND name = ?",
ctx.guild_id().unwrap().0,
name
)
.fetch_one(&ctx.data().database)
.await
{
Ok(row) => {
sqlx::query!("DELETE FROM macro WHERE id = ?", row.id)
.execute(&ctx.data().database)
.await
.unwrap();
ctx.say(format!("Macro \"{}\" deleted", name)).await?;
}
Err(sqlx::Error::RowNotFound) => {
ctx.say(format!("Macro \"{}\" not found", name)).await?;
}
Err(e) => {
panic!("{}", e);
}
}
Ok(())
}

View File

@ -0,0 +1,89 @@
use poise::CreateReply;
use crate::{
component_models::pager::{MacroPager, Pager},
consts::THEME_COLOR,
models::{command_macro::CommandMacro, CtxData},
Context, Error,
};
/// List recorded macros
#[poise::command(
slash_command,
rename = "list",
guild_only = true,
default_member_permissions = "MANAGE_GUILD",
identifying_name = "list_macro"
)]
pub async fn list_macro(ctx: Context<'_>) -> Result<(), Error> {
let macros = ctx.command_macros().await?;
let resp = show_macro_page(&macros, 0);
ctx.send(|m| {
*m = resp;
m
})
.await?;
Ok(())
}
pub fn max_macro_page<U, E>(macros: &[CommandMacro<U, E>]) -> usize {
((macros.len() as f64) / 25.0).ceil() as usize
}
pub fn show_macro_page<U, E>(macros: &[CommandMacro<U, E>], page: usize) -> CreateReply {
let pager = MacroPager::new(page);
if macros.is_empty() {
let mut reply = CreateReply::default();
reply.embed(|e| {
e.title("Macros")
.description("No Macros Set Up. Use `/macro record` to get started.")
.color(*THEME_COLOR)
});
return reply;
}
let pages = max_macro_page(macros);
let mut page = page;
if page >= pages {
page = pages - 1;
}
let lower = (page * 25).min(macros.len());
let upper = ((page + 1) * 25).min(macros.len());
let fields = macros[lower..upper].iter().map(|m| {
if let Some(description) = &m.description {
(
m.name.clone(),
format!("*{}*\n- Has {} commands", description, m.commands.len()),
true,
)
} else {
(m.name.clone(), format!("- Has {} commands", m.commands.len()), true)
}
});
let mut reply = CreateReply::default();
reply
.embed(|e| {
e.title("Macros")
.fields(fields)
.footer(|f| f.text(format!("Page {} of {}", page + 1, pages)))
.color(*THEME_COLOR)
})
.components(|comp| {
pager.create_button_row(pages, comp);
comp
});
reply
}

View File

@ -0,0 +1,229 @@
use lazy_regex::regex;
use poise::serenity_prelude::command::CommandOptionType;
use regex::Captures;
use serde_json::{json, Value};
use crate::{models::command_macro::RawCommandMacro, Context, Error, GuildId};
struct Alias {
name: String,
command: String,
}
/// Migrate old $alias reminder commands to macros. Only macro names that are not taken will be used.
#[poise::command(
slash_command,
rename = "migrate",
guild_only = true,
default_member_permissions = "MANAGE_GUILD",
identifying_name = "migrate_macro"
)]
pub async fn migrate_macro(ctx: Context<'_>) -> Result<(), Error> {
let guild_id = ctx.guild_id().unwrap();
let mut transaction = ctx.data().database.begin().await?;
let aliases = sqlx::query_as!(
Alias,
"SELECT name, command FROM command_aliases WHERE guild_id = (SELECT id FROM guilds WHERE guild = ?)",
guild_id.0
)
.fetch_all(&mut *transaction)
.await?;
let mut added_aliases = 0;
for alias in aliases {
match parse_text_command(guild_id, alias.name, &alias.command) {
Some(cmd_macro) => {
sqlx::query!(
"INSERT INTO macro (guild_id, name, description, commands) VALUES ((SELECT id FROM guilds WHERE guild = ?), ?, ?, ?)",
cmd_macro.guild_id.0,
cmd_macro.name,
cmd_macro.description,
cmd_macro.commands
)
.execute(&mut *transaction)
.await?;
added_aliases += 1;
}
None => {}
}
}
transaction.commit().await?;
ctx.send(|b| b.content(format!("Added {} macros.", added_aliases))).await?;
Ok(())
}
fn parse_text_command(
guild_id: GuildId,
alias_name: String,
command: &str,
) -> Option<RawCommandMacro> {
match command.split_once(" ") {
Some((command_word, args)) => {
let command_word = command_word.to_lowercase();
if command_word == "r"
|| command_word == "i"
|| command_word == "remind"
|| command_word == "interval"
{
let matcher = regex!(
r#"(?P<mentions>(?:<@\d+>\s+|<@!\d+>\s+|<#\d+>\s+)*)(?P<time>(?:(?:\d+)(?:s|m|h|d|:|/|-|))+)(?:\s+(?P<interval>(?:(?:\d+)(?:s|m|h|d|))+))?(?:\s+(?P<expires>(?:(?:\d+)(?:s|m|h|d|:|/|-|))+))?\s+(?P<content>.*)"#s
);
match matcher.captures(&args) {
Some(captures) => {
let mut args: Vec<Value> = vec![];
if let Some(group) = captures.name("time") {
let content = group.as_str();
args.push(json!({
"name": "time",
"value": content,
"type": CommandOptionType::String,
}));
}
if let Some(group) = captures.name("content") {
let content = group.as_str();
args.push(json!({
"name": "content",
"value": content,
"type": CommandOptionType::String,
}));
}
if let Some(group) = captures.name("interval") {
let content = group.as_str();
args.push(json!({
"name": "interval",
"value": content,
"type": CommandOptionType::String,
}));
}
if let Some(group) = captures.name("expires") {
let content = group.as_str();
args.push(json!({
"name": "expires",
"value": content,
"type": CommandOptionType::String,
}));
}
if let Some(group) = captures.name("mentions") {
let content = group.as_str();
args.push(json!({
"name": "channels",
"value": content,
"type": CommandOptionType::String,
}));
}
Some(RawCommandMacro {
guild_id,
name: alias_name,
description: None,
commands: json!([
{
"command_name": "remind",
"options": args,
}
]),
})
}
None => None,
}
} else if command_word == "n" || command_word == "natural" {
let matcher_primary = regex!(
r#"(?P<time>.*?)(?:\s+)(?:send|say)(?:\s+)(?P<content>.*?)(?:(?:\s+)to(?:\s+)(?P<mentions>((?:<@\d+>)|(?:<@!\d+>)|(?:<#\d+>)|(?:\s+))+))?$"#s
);
let matcher_secondary = regex!(
r#"(?P<msg>.*)(?:\s+)every(?:\s+)(?P<interval>.*?)(?:(?:\s+)(?:until|for)(?:\s+)(?P<expires>.*?))?$"#s
);
match matcher_primary.captures(&args) {
Some(captures) => {
let captures_secondary = matcher_secondary.captures(&args);
let mut args: Vec<Value> = vec![];
if let Some(group) = captures.name("time") {
let content = group.as_str();
args.push(json!({
"name": "time",
"value": content,
"type": CommandOptionType::String,
}));
}
if let Some(group) = captures.name("content") {
let content = group.as_str();
args.push(json!({
"name": "content",
"value": content,
"type": CommandOptionType::String,
}));
}
if let Some(group) =
captures_secondary.as_ref().and_then(|c: &Captures| c.name("interval"))
{
let content = group.as_str();
args.push(json!({
"name": "interval",
"value": content,
"type": CommandOptionType::String,
}));
}
if let Some(group) =
captures_secondary.and_then(|c: Captures| c.name("expires"))
{
let content = group.as_str();
args.push(json!({
"name": "expires",
"value": content,
"type": CommandOptionType::String,
}));
}
if let Some(group) = captures.name("mentions") {
let content = group.as_str();
args.push(json!({
"name": "channels",
"value": content,
"type": CommandOptionType::String,
}));
}
Some(RawCommandMacro {
guild_id,
name: alias_name,
description: None,
commands: json!([
{
"command_name": "remind",
"options": args,
}
]),
})
}
None => None,
}
} else {
None
}
}
None => None,
}
}

View File

@ -0,0 +1,19 @@
use crate::{Context, Error};
pub mod delete;
pub mod list;
pub mod migrate;
pub mod record;
pub mod run;
/// Record and replay command sequences
#[poise::command(
slash_command,
rename = "macro",
guild_only = true,
default_member_permissions = "MANAGE_GUILD",
identifying_name = "macro_base"
)]
pub async fn macro_base(_ctx: Context<'_>) -> Result<(), Error> {
Ok(())
}

View File

@ -0,0 +1,151 @@
use std::collections::hash_map::Entry;
use crate::{consts::THEME_COLOR, models::command_macro::CommandMacro, Context, Error};
/// Start recording up to 5 commands to replay
#[poise::command(
slash_command,
rename = "record",
guild_only = true,
default_member_permissions = "MANAGE_GUILD",
identifying_name = "record_macro"
)]
pub async fn record_macro(
ctx: Context<'_>,
#[description = "Name for the new macro"] name: String,
#[description = "Description for the new macro"] description: Option<String>,
) -> Result<(), Error> {
if name.len() > 100 {
ctx.say("Name must be less than 100 characters").await?;
return Ok(());
}
if description.as_ref().map_or(0, |d| d.len()) > 100 {
ctx.say("Description must be less than 100 characters").await?;
return Ok(());
}
let guild_id = ctx.guild_id().unwrap();
let row = sqlx::query!(
"
SELECT 1 as _e FROM macro WHERE guild_id = (SELECT id FROM guilds WHERE guild = ?) AND name = ?",
guild_id.0,
name
)
.fetch_one(&ctx.data().database)
.await;
if row.is_ok() {
ctx.send(|m| {
m.ephemeral(true).embed(|e| {
e.title("Unique Name Required")
.description(
"A macro already exists under this name.
Please select a unique name for your macro.",
)
.color(*THEME_COLOR)
})
})
.await?;
} else {
let okay = {
let mut lock = ctx.data().recording_macros.write().await;
if let Entry::Vacant(e) = lock.entry((guild_id, ctx.author().id)) {
e.insert(CommandMacro { guild_id, name, description, commands: vec![] });
true
} else {
false
}
};
if okay {
ctx.send(|m| {
m.ephemeral(true).embed(|e| {
e.title("Macro Recording Started")
.description(
"Run up to 5 commands, or type `/macro finish` to stop at any point.
Any commands ran as part of recording will be inconsequential",
)
.color(*THEME_COLOR)
})
})
.await?;
} else {
ctx.send(|m| {
m.ephemeral(true).embed(|e| {
e.title("Macro Already Recording")
.description(
"You are already recording a macro in this server.
Please use `/macro finish` to end this recording before starting another.",
)
.color(*THEME_COLOR)
})
})
.await?;
}
}
Ok(())
}
/// Finish current macro recording
#[poise::command(
slash_command,
rename = "finish",
guild_only = true,
default_member_permissions = "MANAGE_GUILD",
identifying_name = "finish_macro"
)]
pub async fn finish_macro(ctx: Context<'_>) -> Result<(), Error> {
let key = (ctx.guild_id().unwrap(), ctx.author().id);
{
let lock = ctx.data().recording_macros.read().await;
let contained = lock.get(&key);
if contained.map_or(true, |cmacro| cmacro.commands.is_empty()) {
ctx.send(|m| {
m.embed(|e| {
e.title("No Macro Recorded")
.description("Use `/macro record` to start recording a macro")
.color(*THEME_COLOR)
})
})
.await?;
} else {
let command_macro = contained.unwrap();
let json = serde_json::to_string(&command_macro.commands).unwrap();
sqlx::query!(
"INSERT INTO macro (guild_id, name, description, commands) VALUES ((SELECT id FROM guilds WHERE guild = ?), ?, ?, ?)",
command_macro.guild_id.0,
command_macro.name,
command_macro.description,
json
)
.execute(&ctx.data().database)
.await
.unwrap();
ctx.send(|m| {
m.embed(|e| {
e.title("Macro Recorded")
.description("Use `/macro run` to execute the macro")
.color(*THEME_COLOR)
})
})
.await?;
}
}
{
let mut lock = ctx.data().recording_macros.write().await;
lock.remove(&key);
}
Ok(())
}

View File

@ -0,0 +1,56 @@
use super::super::autocomplete::macro_name_autocomplete;
use crate::{models::command_macro::guild_command_macro, Context, Data, Error, THEME_COLOR};
/// Run a recorded macro
#[poise::command(
slash_command,
rename = "run",
guild_only = true,
default_member_permissions = "MANAGE_GUILD",
identifying_name = "run_macro"
)]
pub async fn run_macro(
ctx: poise::ApplicationContext<'_, Data, Error>,
#[description = "Name of macro to run"]
#[autocomplete = "macro_name_autocomplete"]
name: String,
) -> Result<(), Error> {
match guild_command_macro(&Context::Application(ctx), &name).await {
Some(command_macro) => {
Context::Application(ctx)
.send(|b| {
b.embed(|e| {
e.title("Running Macro").color(*THEME_COLOR).description(format!(
"Running macro {} ({} commands)",
command_macro.name,
command_macro.commands.len()
))
})
})
.await?;
for command in command_macro.commands {
if let Some(action) = command.action {
match (action)(poise::ApplicationContext { args: &command.options, ..ctx })
.await
{
Ok(()) => {}
Err(e) => {
println!("{:?}", e);
}
}
} else {
Context::Application(ctx)
.say(format!("Command \"{}\" not found", command.command_name))
.await?;
}
}
}
None => {
Context::Application(ctx).say(format!("Macro \"{}\" not found", name)).await?;
}
}
Ok(())
}

View File

@ -6,8 +6,8 @@ use crate::{models::CtxData, Context, Error, THEME_COLOR};
fn footer( fn footer(
ctx: Context<'_>, ctx: Context<'_>,
) -> impl FnOnce(&mut serenity::CreateEmbedFooter) -> &mut serenity::CreateEmbedFooter { ) -> impl FnOnce(&mut serenity::CreateEmbedFooter) -> &mut serenity::CreateEmbedFooter {
let shard_count = ctx.discord().cache.shard_count(); let shard_count = ctx.serenity_context().cache.shard_count();
let shard = ctx.discord().shard_id; let shard = ctx.serenity_context().shard_id;
move |f| { move |f| {
f.text(format!( f.text(format!(
@ -49,6 +49,7 @@ __Todo Commands__
__Setup Commands__ __Setup Commands__
`/timezone` - Set your timezone (necessary for `/remind` to work properly) `/timezone` - Set your timezone (necessary for `/remind` to work properly)
`/dm allow/block` - Change your DM settings for reminders.
__Advanced Commands__ __Advanced Commands__
`/macro` - Record and replay command sequences `/macro` - Record and replay command sequences

View File

@ -1,3 +1,5 @@
mod autocomplete;
pub mod command_macro;
pub mod info_cmds; pub mod info_cmds;
pub mod moderation_cmds; pub mod moderation_cmds;
pub mod reminder_cmds; pub mod reminder_cmds;

View File

@ -1,32 +1,10 @@
use std::collections::hash_map::Entry;
use chrono::offset::Utc; use chrono::offset::Utc;
use chrono_tz::{Tz, TZ_VARIANTS}; use chrono_tz::{Tz, TZ_VARIANTS};
use levenshtein::levenshtein; use levenshtein::levenshtein;
use poise::CreateReply; use log::warn;
use crate::{ use super::autocomplete::timezone_autocomplete;
component_models::pager::{MacroPager, Pager}, use crate::{consts::THEME_COLOR, models::CtxData, Context, Error};
consts::{EMBED_DESCRIPTION_MAX_LENGTH, THEME_COLOR},
models::{
command_macro::{guild_command_macro, CommandMacro},
CtxData,
},
Context, Data, Error,
};
async fn timezone_autocomplete(ctx: Context<'_>, partial: String) -> Vec<String> {
if partial.is_empty() {
ctx.data().popular_timezones.iter().map(|t| t.to_string()).collect::<Vec<String>>()
} else {
TZ_VARIANTS
.iter()
.filter(|tz| tz.to_string().contains(&partial))
.take(25)
.map(|t| t.to_string())
.collect::<Vec<String>>()
}
}
/// Select your timezone /// Select your timezone
#[poise::command(slash_command, identifying_name = "timezone")] #[poise::command(slash_command, identifying_name = "timezone")]
@ -124,376 +102,154 @@ You may want to use one of the popular timezones below, otherwise click [here](h
Ok(()) Ok(())
} }
async fn macro_name_autocomplete(ctx: Context<'_>, partial: String) -> Vec<String> { /// Configure server settings
sqlx::query!(
"
SELECT name
FROM macro
WHERE
guild_id = (SELECT id FROM guilds WHERE guild = ?)
AND name LIKE CONCAT(?, '%')",
ctx.guild_id().unwrap().0,
partial,
)
.fetch_all(&ctx.data().database)
.await
.unwrap_or_default()
.iter()
.map(|s| s.name.clone())
.collect()
}
/// Record and replay command sequences
#[poise::command( #[poise::command(
slash_command, slash_command,
rename = "macro", rename = "settings",
guild_only = true, identifying_name = "settings",
default_member_permissions = "MANAGE_GUILD", guild_only = true
identifying_name = "macro_base"
)] )]
pub async fn macro_base(_ctx: Context<'_>) -> Result<(), Error> { pub async fn settings(_ctx: Context<'_>) -> Result<(), Error> {
Ok(()) Ok(())
} }
/// Start recording up to 5 commands to replay /// Configure ephemeral setup
#[poise::command( #[poise::command(
slash_command, slash_command,
rename = "record", rename = "ephemeral",
guild_only = true, identifying_name = "ephemeral_confirmations",
default_member_permissions = "MANAGE_GUILD", guild_only = true
identifying_name = "record_macro"
)] )]
pub async fn record_macro( pub async fn ephemeral_confirmations(_ctx: Context<'_>) -> Result<(), Error> {
ctx: Context<'_>, Ok(())
#[description = "Name for the new macro"] name: String, }
#[description = "Description for the new macro"] description: Option<String>,
) -> Result<(), Error> {
let guild_id = ctx.guild_id().unwrap();
let row = sqlx::query!( /// Set reminder confirmations to be sent "ephemerally" (private and cleared automatically)
" #[poise::command(
SELECT 1 as _e FROM macro WHERE guild_id = (SELECT id FROM guilds WHERE guild = ?) AND name = ?", slash_command,
guild_id.0, rename = "on",
name identifying_name = "set_ephemeral_confirmations",
) guild_only = true
.fetch_one(&ctx.data().database) )]
.await; pub async fn set_ephemeral_confirmations(ctx: Context<'_>) -> Result<(), Error> {
let mut guild_data = ctx.guild_data().await.unwrap()?;
guild_data.ephemeral_confirmations = true;
guild_data.commit_changes(&ctx.data().database).await;
if row.is_ok() { ctx.send(|r| {
ctx.send(|m| { r.ephemeral(true).embed(|e| {
m.ephemeral(true).embed(|e| { e.title("Confirmations ephemeral")
e.title("Unique Name Required") .description("Reminder confirmations will be sent privately, and removed when your client restarts.")
.description( .color(*THEME_COLOR)
"A macro already exists under this name.
Please select a unique name for your macro.",
)
.color(*THEME_COLOR)
})
}) })
.await?;
} else {
let okay = {
let mut lock = ctx.data().recording_macros.write().await;
if let Entry::Vacant(e) = lock.entry((guild_id, ctx.author().id)) {
e.insert(CommandMacro { guild_id, name, description, commands: vec![] });
true
} else {
false
}
};
if okay {
ctx.send(|m| {
m.ephemeral(true).embed(|e| {
e.title("Macro Recording Started")
.description(
"Run up to 5 commands, or type `/macro finish` to stop at any point.
Any commands ran as part of recording will be inconsequential",
)
.color(*THEME_COLOR)
})
})
.await?;
} else {
ctx.send(|m| {
m.ephemeral(true).embed(|e| {
e.title("Macro Already Recording")
.description(
"You are already recording a macro in this server.
Please use `/macro finish` to end this recording before starting another.",
)
.color(*THEME_COLOR)
})
})
.await?;
}
}
Ok(())
}
/// Finish current macro recording
#[poise::command(
slash_command,
rename = "finish",
guild_only = true,
default_member_permissions = "MANAGE_GUILD",
identifying_name = "finish_macro"
)]
pub async fn finish_macro(ctx: Context<'_>) -> Result<(), Error> {
let key = (ctx.guild_id().unwrap(), ctx.author().id);
{
let lock = ctx.data().recording_macros.read().await;
let contained = lock.get(&key);
if contained.map_or(true, |cmacro| cmacro.commands.is_empty()) {
ctx.send(|m| {
m.embed(|e| {
e.title("No Macro Recorded")
.description("Use `/macro record` to start recording a macro")
.color(*THEME_COLOR)
})
})
.await?;
} else {
let command_macro = contained.unwrap();
let json = serde_json::to_string(&command_macro.commands).unwrap();
sqlx::query!(
"INSERT INTO macro (guild_id, name, description, commands) VALUES ((SELECT id FROM guilds WHERE guild = ?), ?, ?, ?)",
command_macro.guild_id.0,
command_macro.name,
command_macro.description,
json
)
.execute(&ctx.data().database)
.await
.unwrap();
ctx.send(|m| {
m.embed(|e| {
e.title("Macro Recorded")
.description("Use `/macro run` to execute the macro")
.color(*THEME_COLOR)
})
})
.await?;
}
}
{
let mut lock = ctx.data().recording_macros.write().await;
lock.remove(&key);
}
Ok(())
}
/// List recorded macros
#[poise::command(
slash_command,
rename = "list",
guild_only = true,
default_member_permissions = "MANAGE_GUILD",
identifying_name = "list_macro"
)]
pub async fn list_macro(ctx: Context<'_>) -> Result<(), Error> {
let macros = ctx.command_macros().await?;
let resp = show_macro_page(&macros, 0);
ctx.send(|m| {
*m = resp;
m
}) })
.await?; .await?;
Ok(()) Ok(())
} }
/// Run a recorded macro /// Set reminder confirmations to persist indefinitely
#[poise::command( #[poise::command(
slash_command, slash_command,
rename = "run", rename = "off",
guild_only = true, identifying_name = "unset_ephemeral_confirmations",
default_member_permissions = "MANAGE_GUILD", guild_only = true
identifying_name = "run_macro"
)] )]
pub async fn run_macro( pub async fn unset_ephemeral_confirmations(ctx: Context<'_>) -> Result<(), Error> {
ctx: poise::ApplicationContext<'_, Data, Error>, let mut guild_data = ctx.guild_data().await.unwrap()?;
#[description = "Name of macro to run"] guild_data.ephemeral_confirmations = false;
#[autocomplete = "macro_name_autocomplete"] guild_data.commit_changes(&ctx.data().database).await;
name: String,
) -> Result<(), Error> {
match guild_command_macro(&Context::Application(ctx), &name).await {
Some(command_macro) => {
ctx.defer_response(false).await?;
for command in command_macro.commands { ctx.send(|r| {
if let Some(action) = command.action { r.ephemeral(true).embed(|e| {
match (action)(poise::ApplicationContext { args: &command.options, ..ctx }) e.title("Confirmations public")
.await .description(
{ "Reminder confirmations will be sent as regular messages, and won't be removed automatically.",
Ok(()) => {} )
Err(e) => { .color(*THEME_COLOR)
println!("{:?}", e); })
} })
} .await?;
} else {
Context::Application(ctx)
.say(format!("Command \"{}\" not found", command.command_name))
.await?;
}
}
}
None => {
Context::Application(ctx).say(format!("Macro \"{}\" not found", name)).await?;
}
}
Ok(()) Ok(())
} }
/// Delete a recorded macro /// Configure whether other users can set reminders to your direct messages
#[poise::command(slash_command, rename = "dm", identifying_name = "allowed_dm")]
pub async fn allowed_dm(_ctx: Context<'_>) -> Result<(), Error> {
Ok(())
}
/// Allow other users to set reminders in your direct messages
#[poise::command(slash_command, rename = "allow", identifying_name = "set_allowed_dm")]
pub async fn set_allowed_dm(ctx: Context<'_>) -> Result<(), Error> {
let mut user_data = ctx.author_data().await?;
user_data.allowed_dm = true;
user_data.commit_changes(&ctx.data().database).await;
ctx.send(|r| {
r.ephemeral(true).embed(|e| {
e.title("DMs permitted")
.description("You will receive a message if a user sets a DM reminder for you.")
.color(*THEME_COLOR)
})
})
.await?;
Ok(())
}
/// Block other users from setting reminders in your direct messages
#[poise::command(slash_command, rename = "block", identifying_name = "unset_allowed_dm")]
pub async fn unset_allowed_dm(ctx: Context<'_>) -> Result<(), Error> {
let mut user_data = ctx.author_data().await?;
user_data.allowed_dm = false;
user_data.commit_changes(&ctx.data().database).await;
ctx.send(|r| {
r.ephemeral(true).embed(|e| {
e.title("DMs blocked")
.description(
"You can still set DM reminders for yourself or for users with DMs enabled.",
)
.color(*THEME_COLOR)
})
})
.await?;
Ok(())
}
/// View the webhook being used to send reminders to this channel
#[poise::command( #[poise::command(
slash_command, slash_command,
rename = "delete", identifying_name = "webhook_url",
guild_only = true, required_permissions = "ADMINISTRATOR"
default_member_permissions = "MANAGE_GUILD",
identifying_name = "delete_macro"
)] )]
pub async fn delete_macro( pub async fn webhook(ctx: Context<'_>) -> Result<(), Error> {
ctx: Context<'_>, match ctx.channel_data().await {
#[description = "Name of macro to delete"] Ok(data) => {
#[autocomplete = "macro_name_autocomplete"] if let (Some(id), Some(token)) = (data.webhook_id, data.webhook_token) {
name: String, ctx.send(|b| {
) -> Result<(), Error> { b.ephemeral(true).content(format!(
match sqlx::query!( "**Warning!**
" This link can be used by users to anonymously send messages, with or without permissions.
SELECT id FROM macro WHERE guild_id = (SELECT id FROM guilds WHERE guild = ?) AND name = ?", Do not share it!
ctx.guild_id().unwrap().0, || https://discord.com/api/webhooks/{}/{} ||",
name id, token,
) ))
.fetch_one(&ctx.data().database) })
.await .await?;
{ } else {
Ok(row) => { ctx.say("No webhook configured on this channel.").await?;
sqlx::query!("DELETE FROM macro WHERE id = ?", row.id) }
.execute(&ctx.data().database)
.await
.unwrap();
ctx.say(format!("Macro \"{}\" deleted", name)).await?;
} }
Err(sqlx::Error::RowNotFound) => {
ctx.say(format!("Macro \"{}\" not found", name)).await?;
}
Err(e) => { Err(e) => {
panic!("{}", e); warn!("Error fetching channel data: {:?}", e);
ctx.say("No webhook configured on this channel.").await?;
} }
} }
Ok(()) Ok(())
} }
pub fn max_macro_page<U, E>(macros: &[CommandMacro<U, E>]) -> usize {
let mut skipped_char_count = 0;
macros
.iter()
.map(|m| {
if let Some(description) = &m.description {
format!("**{}**\n- *{}*\n- Has {} commands", m.name, description, m.commands.len())
} else {
format!("**{}**\n- Has {} commands", m.name, m.commands.len())
}
})
.fold(1, |mut pages, p| {
skipped_char_count += p.len();
if skipped_char_count > EMBED_DESCRIPTION_MAX_LENGTH {
skipped_char_count = p.len();
pages += 1;
}
pages
})
}
pub fn show_macro_page<U, E>(macros: &[CommandMacro<U, E>], page: usize) -> CreateReply {
let pager = MacroPager::new(page);
if macros.is_empty() {
let mut reply = CreateReply::default();
reply.embed(|e| {
e.title("Macros")
.description("No Macros Set Up. Use `/macro record` to get started.")
.color(*THEME_COLOR)
});
return reply;
}
let pages = max_macro_page(macros);
let mut page = page;
if page >= pages {
page = pages - 1;
}
let mut char_count = 0;
let mut skipped_char_count = 0;
let mut skipped_pages = 0;
let display_vec: Vec<String> = macros
.iter()
.map(|m| {
if let Some(description) = &m.description {
format!("**{}**\n- *{}*\n- Has {} commands", m.name, description, m.commands.len())
} else {
format!("**{}**\n- Has {} commands", m.name, m.commands.len())
}
})
.skip_while(|p| {
skipped_char_count += p.len();
if skipped_char_count > EMBED_DESCRIPTION_MAX_LENGTH {
skipped_char_count = p.len();
skipped_pages += 1;
}
skipped_pages < page
})
.take_while(|p| {
char_count += p.len();
char_count < EMBED_DESCRIPTION_MAX_LENGTH
})
.collect::<Vec<String>>();
let display = display_vec.join("\n");
let mut reply = CreateReply::default();
reply
.embed(|e| {
e.title("Macros")
.description(display)
.footer(|f| f.text(format!("Page {} of {}", page + 1, pages)))
.color(*THEME_COLOR)
})
.components(|comp| {
pager.create_button_row(pages, comp);
comp
});
reply
}

View File

@ -1,19 +1,18 @@
use std::{ use std::{collections::HashSet, string::ToString};
collections::HashSet,
string::ToString,
time::{SystemTime, UNIX_EPOCH},
};
use chrono::NaiveDateTime; use chrono::{DateTime, NaiveDateTime, Utc};
use chrono_tz::Tz; use chrono_tz::Tz;
use log::warn;
use num_integer::Integer; use num_integer::Integer;
use poise::{ use poise::{
serenity::{builder::CreateEmbed, model::channel::Channel}, serenity_prelude::{
serenity_prelude::{ButtonStyle, ReactionType}, builder::CreateEmbed, component::ButtonStyle, model::channel::Channel, ReactionType,
CreateReply, },
CreateReply, Modal,
}; };
use crate::{ use crate::{
commands::autocomplete::{time_hint_autocomplete, timezone_autocomplete},
component_models::{ component_models::{
pager::{DelPager, LookPager, Pager}, pager::{DelPager, LookPager, Pager},
ComponentDataModel, DelSelector, UndoReminder, ComponentDataModel, DelSelector, UndoReminder,
@ -36,7 +35,7 @@ use crate::{
}, },
time_parser::natural_parser, time_parser::natural_parser,
utils::{check_guild_subscription, check_subscription}, utils::{check_guild_subscription, check_subscription},
Context, Error, ApplicationContext, Context, Error,
}; };
/// Pause all reminders on the current channel until a certain time or indefinitely /// Pause all reminders on the current channel until a certain time or indefinitely
@ -58,18 +57,27 @@ pub async fn pause(
let parsed = natural_parser(&until, &timezone.to_string()).await; let parsed = natural_parser(&until, &timezone.to_string()).await;
if let Some(timestamp) = parsed { if let Some(timestamp) = parsed {
let dt = NaiveDateTime::from_timestamp(timestamp, 0); match NaiveDateTime::from_timestamp_opt(timestamp, 0) {
Some(dt) => {
channel.paused = true;
channel.paused_until = Some(dt);
channel.paused = true; channel.commit_changes(&ctx.data().database).await;
channel.paused_until = Some(dt);
channel.commit_changes(&ctx.data().database).await; ctx.say(format!(
"Reminders in this channel have been silenced until **<t:{}:D>**",
timestamp
))
.await?;
}
ctx.say(format!( None => {
"Reminders in this channel have been silenced until **<t:{}:D>**", ctx.say(format!(
timestamp "Time processed could not be interpreted as `DateTime`. Please write the time as clearly as possible",
)) ))
.await?; .await?;
}
}
} else { } else {
ctx.say( ctx.say(
"Time could not be processed. Please write the time as clearly as possible", "Time could not be processed. Please write the time as clearly as possible",
@ -106,6 +114,8 @@ pub async fn offset(
#[description = "Number of minutes to offset by"] minutes: Option<isize>, #[description = "Number of minutes to offset by"] minutes: Option<isize>,
#[description = "Number of seconds to offset by"] seconds: Option<isize>, #[description = "Number of seconds to offset by"] seconds: Option<isize>,
) -> Result<(), Error> { ) -> Result<(), Error> {
ctx.defer().await?;
let combined_time = hours.map_or(0, |h| h * HOUR as isize) let combined_time = hours.map_or(0, |h| h * HOUR as isize)
+ minutes.map_or(0, |m| m * MINUTE as isize) + minutes.map_or(0, |m| m * MINUTE as isize)
+ seconds.map_or(0, |s| s); + seconds.map_or(0, |s| s);
@ -208,7 +218,7 @@ pub async fn look(
}), }),
}; };
let channel_opt = ctx.channel_id().to_channel_cached(&ctx.discord()); let channel_opt = ctx.channel_id().to_channel_cached(&ctx);
let channel_id = if let Some(Channel::Guild(channel)) = channel_opt { let channel_id = if let Some(Channel::Guild(channel)) = channel_opt {
if Some(channel.guild_id) == ctx.guild_id() { if Some(channel.guild_id) == ctx.guild_id() {
@ -220,12 +230,11 @@ pub async fn look(
ctx.channel_id() ctx.channel_id()
}; };
let channel_name = let channel_name = if let Some(Channel::Guild(channel)) = channel_id.to_channel_cached(&ctx) {
if let Some(Channel::Guild(channel)) = channel_id.to_channel_cached(&ctx.discord()) { Some(channel.name)
Some(channel.name) } else {
} else { None
None };
};
let reminders = Reminder::from_channel(&ctx.data().database, channel_id, &flags).await; let reminders = Reminder::from_channel(&ctx.data().database, channel_id, &flags).await;
@ -243,7 +252,7 @@ pub async fn look(
char_count < EMBED_DESCRIPTION_MAX_LENGTH char_count < EMBED_DESCRIPTION_MAX_LENGTH
}) })
.collect::<Vec<String>>() .collect::<Vec<String>>()
.join("\n"); .join("");
let pages = reminders let pages = reminders
.iter() .iter()
@ -287,8 +296,7 @@ pub async fn delete(ctx: Context<'_>) -> Result<(), Error> {
let timezone = ctx.timezone().await; let timezone = ctx.timezone().await;
let reminders = let reminders =
Reminder::from_guild(&ctx.discord(), &ctx.data().database, ctx.guild_id(), ctx.author().id) Reminder::from_guild(&ctx, &ctx.data().database, ctx.guild_id(), ctx.author().id).await;
.await;
let resp = show_delete_page(&reminders, 0, timezone); let resp = show_delete_page(&reminders, 0, timezone);
@ -430,11 +438,8 @@ pub fn show_delete_page(reminders: &[Reminder], page: usize, timezone: Tz) -> Cr
reply reply
} }
fn time_difference(start_time: NaiveDateTime) -> String { fn time_difference(start_time: DateTime<Utc>) -> String {
let unix_time = SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_secs() as i64; let delta = (Utc::now() - start_time).num_seconds();
let now = NaiveDateTime::from_timestamp(unix_time, 0);
let delta = (now - start_time).num_seconds();
let (minutes, seconds) = delta.div_rem(&60); let (minutes, seconds) = delta.div_rem(&60);
let (hours, minutes) = minutes.div_rem(&60); let (hours, minutes) = minutes.div_rem(&60);
@ -548,23 +553,104 @@ pub async fn delete_timer(
Ok(()) Ok(())
} }
/// Create a new reminder #[derive(poise::Modal)]
#[name = "Reminder"]
struct ContentModal {
#[name = "Content"]
#[placeholder = "Message..."]
#[paragraph]
#[max_length = 2000]
content: String,
}
/// Create a reminder with multi-line content. Press "+4 more" for other options.
#[poise::command(
slash_command,
identifying_name = "multiline",
default_member_permissions = "MANAGE_GUILD"
)]
pub async fn multiline(
ctx: ApplicationContext<'_>,
#[description = "A description of the time to set the reminder for"]
#[autocomplete = "time_hint_autocomplete"]
time: String,
#[description = "Channel or user mentions to set the reminder for"] channels: Option<String>,
#[description = "(Patreon only) Time to wait before repeating the reminder. Leave blank for one-shot reminder"]
interval: Option<String>,
#[description = "(Patreon only) For repeating reminders, the time at which the reminder will stop repeating"]
expires: Option<String>,
#[description = "Set the TTS flag on the reminder message, similar to the /tts command"]
tts: Option<bool>,
#[description = "Set a timezone override for this reminder only"]
#[autocomplete = "timezone_autocomplete"]
timezone: Option<String>,
) -> Result<(), Error> {
let tz = timezone.map(|t| t.parse::<Tz>().ok()).flatten();
let data_opt = ContentModal::execute(ctx).await?;
match data_opt {
Some(data) => {
create_reminder(
Context::Application(ctx),
time,
data.content,
channels,
interval,
expires,
tts,
tz,
)
.await
}
None => {
warn!("Unexpected None encountered in /multiline");
Ok(Context::Application(ctx)
.send(|m| m.content("Unexpected error.").ephemeral(true))
.await
.map(|_| ())?)
}
}
}
/// Create a reminder. Press "+4 more" for other options. Use "/multiline" for multiline content.
#[poise::command( #[poise::command(
slash_command, slash_command,
identifying_name = "remind", identifying_name = "remind",
default_member_permissions = "MANAGE_GUILD" default_member_permissions = "MANAGE_GUILD"
)] )]
pub async fn remind( pub async fn remind(
ctx: Context<'_>, ctx: ApplicationContext<'_>,
#[description = "A description of the time to set the reminder for"] time: String, #[description = "The time (and optionally date) to set the reminder for"]
#[autocomplete = "time_hint_autocomplete"]
time: String,
#[description = "The message content to send"] content: String, #[description = "The message content to send"] content: String,
#[description = "Channel or user mentions to set the reminder for"] channels: Option<String>, #[description = "Channel or user mentions to set the reminder for"] channels: Option<String>,
#[description = "(Patreon only) Time to wait before repeating the reminder. Leave blank for one-shot reminder"] #[description = "(Patreon only) Time to wait before repeating the reminder. Leave blank for one-shot reminder"]
interval: Option<String>, interval: Option<String>,
#[description = "(Patreon only) For repeating reminders, the time at which the reminder will stop sending"] #[description = "(Patreon only) For repeating reminders, the time at which the reminder will stop repeating"]
expires: Option<String>, expires: Option<String>,
#[description = "Set the TTS flag on the reminder message, similar to the /tts command"] #[description = "Set the TTS flag on the reminder message, similar to the /tts command"]
tts: Option<bool>, tts: Option<bool>,
#[description = "Set a timezone override for this reminder only"]
#[autocomplete = "timezone_autocomplete"]
timezone: Option<String>,
) -> Result<(), Error> {
let tz = timezone.map(|t| t.parse::<Tz>().ok()).flatten();
create_reminder(Context::Application(ctx), time, content, channels, interval, expires, tts, tz)
.await
}
async fn create_reminder(
ctx: Context<'_>,
time: String,
content: String,
channels: Option<String>,
interval: Option<String>,
expires: Option<String>,
tts: Option<bool>,
timezone: Option<Tz>,
) -> Result<(), Error> { ) -> Result<(), Error> {
if interval.is_none() && expires.is_some() { if interval.is_none() && expires.is_some() {
ctx.say("`expires` can only be used with `interval`").await?; ctx.say("`expires` can only be used with `interval`").await?;
@ -572,10 +658,16 @@ pub async fn remind(
return Ok(()); return Ok(());
} }
ctx.defer().await?; let ephemeral =
ctx.guild_data().await.map_or(false, |gr| gr.map_or(false, |g| g.ephemeral_confirmations));
if ephemeral {
ctx.defer_ephemeral().await?;
} else {
ctx.defer().await?;
}
let user_data = ctx.author_data().await.unwrap(); let user_data = ctx.author_data().await.unwrap();
let timezone = ctx.timezone().await; let timezone = timezone.unwrap_or(ctx.timezone().await);
let time = natural_parser(&time, &timezone.to_string()).await; let time = natural_parser(&time, &timezone.to_string()).await;
@ -602,9 +694,9 @@ pub async fn remind(
}; };
let (processed_interval, processed_expires) = if let Some(repeat) = &interval { let (processed_interval, processed_expires) = if let Some(repeat) = &interval {
if check_subscription(&ctx.discord(), ctx.author().id).await if check_subscription(&ctx, ctx.author().id).await
|| (ctx.guild_id().is_some() || (ctx.guild_id().is_some()
&& check_guild_subscription(&ctx.discord(), ctx.guild_id().unwrap()).await) && check_guild_subscription(&ctx, ctx.guild_id().unwrap()).await)
{ {
( (
parse_duration(repeat) parse_duration(repeat)
@ -619,9 +711,10 @@ pub async fn remind(
}, },
) )
} else { } else {
ctx.say( ctx.send(|b| {
"`repeat` is only available to Patreon subscribers or self-hosted users", b.content(
) "`repeat` is only available to Patreon subscribers or self-hosted users")
})
.await?; .await?;
return Ok(()); return Ok(());
@ -631,13 +724,18 @@ pub async fn remind(
}; };
if processed_interval.is_none() && interval.is_some() { if processed_interval.is_none() && interval.is_some() {
ctx.say( ctx.send(|b| {
"Repeat interval could not be processed. Try similar to `1 hour` or `4 days`", b.content(
) "Repeat interval could not be processed. Try similar to `1 hour` or `4 days`")
})
.await?; .await?;
} else if processed_expires.is_none() && expires.is_some() { } else if processed_expires.is_none() && expires.is_some() {
ctx.say("Expiry time failed to process. Please make it as clear as possible") ctx.send(|b| {
.await?; b.ephemeral(true).content(
"Expiry time failed to process. Please make it as clear as possible",
)
})
.await?;
} else { } else {
let mut builder = MultiReminderBuilder::new(&ctx, ctx.guild_id()) let mut builder = MultiReminderBuilder::new(&ctx, ctx.guild_id())
.author(user_data) .author(user_data)
@ -677,7 +775,7 @@ pub async fn remind(
b.emoji(ReactionType::Unicode("📝".to_string())) b.emoji(ReactionType::Unicode("📝".to_string()))
.label("Edit") .label("Edit")
.style(ButtonStyle::Link) .style(ButtonStyle::Link)
.url("https://reminder-bot.com/dashboard") .url("https://beta.reminder-bot.com/dashboard")
}) })
}) })
}) })
@ -694,6 +792,7 @@ pub async fn remind(
} }
} }
} }
None => { None => {
ctx.say("Time could not be processed").await?; ctx.say("Time could not be processed").await?;
} }

View File

@ -6,6 +6,7 @@ use crate::{
ComponentDataModel, TodoSelector, ComponentDataModel, TodoSelector,
}, },
consts::{EMBED_DESCRIPTION_MAX_LENGTH, SELECT_MAX_ENTRIES, THEME_COLOR}, consts::{EMBED_DESCRIPTION_MAX_LENGTH, SELECT_MAX_ENTRIES, THEME_COLOR},
models::CtxData,
Context, Error, Context, Error,
}; };
@ -116,6 +117,9 @@ pub async fn todo_channel_add(
ctx: Context<'_>, ctx: Context<'_>,
#[description = "The task to add to the todo list"] task: String, #[description = "The task to add to the todo list"] task: String,
) -> Result<(), Error> { ) -> Result<(), Error> {
// ensure channel is cached
let _ = ctx.channel_data().await;
sqlx::query!( sqlx::query!(
"INSERT INTO todos (guild_id, channel_id, value) "INSERT INTO todos (guild_id, channel_id, value)
VALUES ((SELECT id FROM guilds WHERE guild = ?), (SELECT id FROM channels WHERE channel = ?), ?)", VALUES ((SELECT id FROM guilds WHERE guild = ?), (SELECT id FROM channels WHERE channel = ?), ?)",
@ -336,7 +340,18 @@ pub fn show_todo_page(
opt.create_option(|o| { opt.create_option(|o| {
o.label(format!("Mark {} complete", count + first_num)) o.label(format!("Mark {} complete", count + first_num))
.value(id) .value(id)
.description(disp.split_once(' ').unwrap_or(("", "")).1) .description({
let c = disp.split_once(' ').unwrap_or(("", "")).1;
if c.len() > 100 {
format!(
"{}...",
c.chars().take(97).collect::<String>()
)
} else {
c.to_string()
}
})
}); });
} }

View File

@ -2,28 +2,29 @@ pub(crate) mod pager;
use std::io::Cursor; use std::io::Cursor;
use base64::{engine::general_purpose, Engine};
use chrono_tz::Tz; use chrono_tz::Tz;
use log::warn; use log::warn;
use poise::{ use poise::{
serenity::{
builder::CreateEmbed,
client::Context,
model::{
channel::Channel,
interactions::{
message_component::MessageComponentInteraction, InteractionResponseType,
},
prelude::InteractionApplicationCommandCallbackDataFlags,
},
},
serenity_prelude as serenity, serenity_prelude as serenity,
serenity_prelude::{
builder::CreateEmbed,
model::{
application::interaction::{
message_component::MessageComponentInteraction, InteractionResponseType,
MessageFlags,
},
channel::Channel,
},
Context,
},
}; };
use rmp_serde::Serializer; use rmp_serde::Serializer;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use crate::{ use crate::{
commands::{ commands::{
moderation_cmds::{max_macro_page, show_macro_page}, command_macro::list::{max_macro_page, show_macro_page},
reminder_cmds::{max_delete_page, show_delete_page}, reminder_cmds::{max_delete_page, show_delete_page},
todo_cmds::{max_todo_page, show_todo_page}, todo_cmds::{max_todo_page, show_todo_page},
}, },
@ -51,11 +52,12 @@ impl ComponentDataModel {
pub fn to_custom_id(&self) -> String { pub fn to_custom_id(&self) -> String {
let mut buf = Vec::new(); let mut buf = Vec::new();
self.serialize(&mut Serializer::new(&mut buf)).unwrap(); self.serialize(&mut Serializer::new(&mut buf)).unwrap();
base64::encode(buf) general_purpose::STANDARD.encode(buf)
} }
pub fn from_custom_id(data: &String) -> Self { pub fn from_custom_id(data: &String) -> Self {
let buf = base64::decode(data) let buf = general_purpose::STANDARD
.decode(data)
.map_err(|e| format!("Could not decode `custom_id' {}: {:?}", data, e)) .map_err(|e| format!("Could not decode `custom_id' {}: {:?}", data, e))
.unwrap(); .unwrap();
let cur = Cursor::new(buf); let cur = Cursor::new(buf);
@ -113,7 +115,7 @@ impl ComponentDataModel {
char_count < EMBED_DESCRIPTION_MAX_LENGTH char_count < EMBED_DESCRIPTION_MAX_LENGTH
}) })
.collect::<Vec<String>>() .collect::<Vec<String>>()
.join("\n"); .join("");
let mut embed = CreateEmbed::default(); let mut embed = CreateEmbed::default();
embed embed
@ -166,10 +168,13 @@ impl ComponentDataModel {
ComponentDataModel::DelSelector(selector) => { ComponentDataModel::DelSelector(selector) => {
let selected_id = component.data.values.join(","); let selected_id = component.data.values.join(",");
sqlx::query!("DELETE FROM reminders WHERE FIND_IN_SET(id, ?)", selected_id) sqlx::query!(
.execute(&data.database) "UPDATE reminders SET `status` = 'deleted' WHERE FIND_IN_SET(id, ?)",
.await selected_id
.unwrap(); )
.execute(&data.database)
.await
.unwrap();
let reminders = Reminder::from_guild( let reminders = Reminder::from_guild(
&ctx, &ctx,
@ -260,7 +265,7 @@ WHERE guilds.guild = ?",
r.kind(InteractionResponseType::ChannelMessageWithSource) r.kind(InteractionResponseType::ChannelMessageWithSource)
.interaction_response_data(|d| { .interaction_response_data(|d| {
d.flags( d.flags(
InteractionApplicationCommandCallbackDataFlags::EPHEMERAL, MessageFlags::EPHEMERAL,
) )
.content("Only the user who performed the command can use these components") .content("Only the user who performed the command can use these components")
}) })
@ -314,7 +319,7 @@ WHERE guilds.guild = ?",
r.kind(InteractionResponseType::ChannelMessageWithSource) r.kind(InteractionResponseType::ChannelMessageWithSource)
.interaction_response_data(|d| { .interaction_response_data(|d| {
d.flags( d.flags(
InteractionApplicationCommandCallbackDataFlags::EPHEMERAL, MessageFlags::EPHEMERAL,
) )
.content("Only the user who performed the command can use these components") .content("Only the user who performed the command can use these components")
}) })

View File

@ -1,7 +1,7 @@
// todo split pager out into a single struct // todo split pager out into a single struct
use chrono_tz::Tz; use chrono_tz::Tz;
use poise::serenity::{ use poise::serenity_prelude::{
builder::CreateComponents, model::interactions::message_component::ButtonStyle, builder::CreateComponents, model::application::component::ButtonStyle,
}; };
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use serde_repr::*; use serde_repr::*;

View File

@ -2,7 +2,7 @@ pub const DAY: u64 = 86_400;
pub const HOUR: u64 = 3_600; pub const HOUR: u64 = 3_600;
pub const MINUTE: u64 = 60; pub const MINUTE: u64 = 60;
pub const EMBED_DESCRIPTION_MAX_LENGTH: usize = 4000; pub const EMBED_DESCRIPTION_MAX_LENGTH: usize = 4096;
pub const SELECT_MAX_ENTRIES: usize = 25; pub const SELECT_MAX_ENTRIES: usize = 25;
pub const CHARACTERS: &str = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_"; pub const CHARACTERS: &str = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_";
@ -12,22 +12,18 @@ pub const MACRO_MAX_COMMANDS: usize = 5;
use std::{collections::HashSet, env, iter::FromIterator}; use std::{collections::HashSet, env, iter::FromIterator};
use poise::serenity::model::prelude::AttachmentType; use poise::serenity_prelude::model::prelude::AttachmentType;
use regex::Regex; use regex::Regex;
lazy_static! { lazy_static! {
pub static ref DEFAULT_AVATAR: AttachmentType<'static> = ( pub static ref DEFAULT_AVATAR: AttachmentType<'static> = (
include_bytes!(concat!( include_bytes!(concat!(env!("CARGO_MANIFEST_DIR"), "/assets/webhook.jpg")) as &[u8],
env!("CARGO_MANIFEST_DIR"), "webhook.jpg",
"/assets/",
env!("WEBHOOK_AVATAR", "WEBHOOK_AVATAR not provided for compilation")
)) as &[u8],
env!("WEBHOOK_AVATAR"),
) )
.into(); .into();
pub static ref REGEX_CHANNEL_USER: Regex = Regex::new(r#"\s*<(#|@)(?:!)?(\d+)>\s*"#).unwrap(); pub static ref REGEX_CHANNEL_USER: Regex = Regex::new(r#"\s*<(#|@)(?:!)?(\d+)>\s*"#).unwrap();
pub static ref SUBSCRIPTION_ROLES: HashSet<u64> = HashSet::from_iter( pub static ref SUBSCRIPTION_ROLES: HashSet<u64> = HashSet::from_iter(
env::var("SUBSCRIPTION_ROLES") env::var("PATREON_ROLE_ID")
.map(|var| var .map(|var| var
.split(',') .split(',')
.filter_map(|item| { item.parse::<u64>().ok() }) .filter_map(|item| { item.parse::<u64>().ok() })
@ -35,7 +31,7 @@ lazy_static! {
.unwrap_or_else(|_| Vec::new()) .unwrap_or_else(|_| Vec::new())
); );
pub static ref CNC_GUILD: Option<u64> = pub static ref CNC_GUILD: Option<u64> =
env::var("CNC_GUILD").map(|var| var.parse::<u64>().ok()).ok().flatten(); env::var("PATREON_GUILD_ID").map(|var| var.parse::<u64>().ok()).ok().flatten();
pub static ref MIN_INTERVAL: i64 = pub static ref MIN_INTERVAL: i64 =
env::var("MIN_INTERVAL").ok().and_then(|inner| inner.parse::<i64>().ok()).unwrap_or(600); env::var("MIN_INTERVAL").ok().and_then(|inner| inner.parse::<i64>().ok()).unwrap_or(600);
pub static ref MAX_TIME: i64 = env::var("MAX_TIME") pub static ref MAX_TIME: i64 = env::var("MAX_TIME")
@ -48,5 +44,5 @@ lazy_static! {
.map_or(THEME_COLOR_FALLBACK, |inner| u32::from_str_radix(&inner, 16) .map_or(THEME_COLOR_FALLBACK, |inner| u32::from_str_radix(&inner, 16)
.unwrap_or(THEME_COLOR_FALLBACK)); .unwrap_or(THEME_COLOR_FALLBACK));
pub static ref PYTHON_LOCATION: String = pub static ref PYTHON_LOCATION: String =
env::var("PYTHON_LOCATION").unwrap_or_else(|_| "venv/bin/python3".to_string()); env::var("PYTHON_LOCATION").unwrap_or_else(|_| "/usr/bin/python3".to_string());
} }

View File

@ -1,12 +1,12 @@
use std::{collections::HashMap, env, sync::atomic::Ordering}; use std::{collections::HashMap, env};
use log::{error, info, warn}; use log::error;
use poise::{ use poise::{
serenity::{model::interactions::Interaction, utils::shard_id},
serenity_prelude as serenity, serenity_prelude as serenity,
serenity_prelude::{model::application::interaction::Interaction, utils::shard_id},
}; };
use crate::{component_models::ComponentDataModel, Data, Error}; use crate::{component_models::ComponentDataModel, Data, Error, THEME_COLOR};
pub async fn listener( pub async fn listener(
ctx: &serenity::Context, ctx: &serenity::Context,
@ -17,45 +17,6 @@ pub async fn listener(
poise::Event::Ready { .. } => { poise::Event::Ready { .. } => {
ctx.set_activity(serenity::Activity::watching("for /remind")).await; ctx.set_activity(serenity::Activity::watching("for /remind")).await;
} }
poise::Event::CacheReady { .. } => {
info!("Cache Ready! Preparing extra processes");
if !data.is_loop_running.load(Ordering::Relaxed) {
let kill_tx = data.broadcast.clone();
let kill_recv = data.broadcast.subscribe();
let ctx1 = ctx.clone();
let ctx2 = ctx.clone();
let pool1 = data.database.clone();
let pool2 = data.database.clone();
let run_settings = env::var("DONTRUN").unwrap_or_else(|_| "".to_string());
if !run_settings.contains("postman") {
tokio::spawn(async move {
match postman::initialize(kill_recv, ctx1, &pool1).await {
Ok(_) => {}
Err(e) => {
error!("postman exiting: {}", e);
}
};
});
} else {
warn!("Not running postman");
}
if !run_settings.contains("web") {
tokio::spawn(async move {
reminder_web::initialize(kill_tx, ctx2, pool2).await.unwrap();
});
} else {
warn!("Not running web");
}
data.is_loop_running.swap(true, Ordering::Relaxed);
}
}
poise::Event::ChannelDelete { channel } => { poise::Event::ChannelDelete { channel } => {
sqlx::query!("DELETE FROM channels WHERE channel = ?", channel.id.as_u64()) sqlx::query!("DELETE FROM channels WHERE channel = ?", channel.id.as_u64())
.execute(&data.database) .execute(&data.database)
@ -66,46 +27,36 @@ pub async fn listener(
if *is_new { if *is_new {
let guild_id = guild.id.as_u64().to_owned(); let guild_id = guild.id.as_u64().to_owned();
sqlx::query!("INSERT INTO guilds (guild) VALUES (?)", guild_id) sqlx::query!("INSERT IGNORE INTO guilds (guild) VALUES (?)", guild_id)
.execute(&data.database) .execute(&data.database)
.await .await?;
.unwrap();
if let Ok(token) = env::var("DISCORDBOTS_TOKEN") { if let Err(e) = post_guild_count(ctx, &data.http, guild_id).await {
let shard_count = ctx.cache.shard_count(); error!("DiscordBotList: {:?}", e);
let current_shard_id = shard_id(guild_id, shard_count); }
let guild_count = ctx let default_channel = guild.default_channel_guaranteed();
.cache
.guilds() if let Some(default_channel) = default_channel {
.iter() default_channel
.filter(|g| { .send_message(&ctx, |m| {
shard_id(g.as_u64().to_owned(), shard_count) == current_shard_id m.embed(|e| {
e.title("Thank you for adding Reminder Bot!").description(
"To get started:
• Set your timezone with `/timezone`
• Set up permissions in Server Settings 🠚 Integrations 🠚 Reminder Bot (desktop only)
• Create your first reminder with `/remind`
__Support__
If you need any support, please come and ask us! Join our [Discord](https://discord.jellywx.com).
__Updates__
To stay up to date on the latest features and fixes, join our [Discord](https://discord.jellywx.com).
",
).color(*THEME_COLOR)
})
}) })
.count() as u64; .await?;
let mut hm = HashMap::new();
hm.insert("server_count", guild_count);
hm.insert("shard_id", current_shard_id);
hm.insert("shard_count", shard_count);
let response = data
.http
.post(
format!(
"https://top.gg/api/bots/{}/stats",
ctx.cache.current_user_id().as_u64()
)
.as_str(),
)
.header("Authorization", token)
.json(&hm)
.send()
.await;
if let Err(res) = response {
println!("DiscordBots Response: {:?}", res);
}
} }
} }
} }
@ -126,3 +77,38 @@ pub async fn listener(
Ok(()) Ok(())
} }
async fn post_guild_count(
ctx: &serenity::Context,
http: &reqwest::Client,
guild_id: u64,
) -> Result<(), reqwest::Error> {
if let Ok(token) = env::var("DISCORDBOTS_TOKEN") {
let shard_count = ctx.cache.shard_count();
let current_shard_id = shard_id(guild_id, shard_count);
let guild_count = ctx
.cache
.guilds()
.iter()
.filter(|g| shard_id(g.as_u64().to_owned(), shard_count) == current_shard_id)
.count() as u64;
let mut hm = HashMap::new();
hm.insert("server_count", guild_count);
hm.insert("shard_id", current_shard_id);
hm.insert("shard_count", shard_count);
http.post(
format!("https://top.gg/api/bots/{}/stats", ctx.cache.current_user_id().as_u64())
.as_str(),
)
.header("Authorization", token)
.json(&hm)
.send()
.await
.map(|_| ())
} else {
Ok(())
}
}

View File

@ -1,36 +1,42 @@
use poise::serenity::model::channel::Channel; use poise::{
serenity_prelude::model::channel::Channel, ApplicationCommandOrAutocompleteInteraction,
};
use crate::{consts::MACRO_MAX_COMMANDS, models::command_macro::RecordedCommand, Context, Error}; use crate::{consts::MACRO_MAX_COMMANDS, models::command_macro::RecordedCommand, Context, Error};
async fn macro_check(ctx: Context<'_>) -> bool { async fn macro_check(ctx: Context<'_>) -> bool {
if let Context::Application(app_ctx) = ctx { if let Context::Application(app_ctx) = ctx {
if let Some(guild_id) = ctx.guild_id() { if let ApplicationCommandOrAutocompleteInteraction::ApplicationCommand(_) =
if ctx.command().identifying_name != "finish_macro" { app_ctx.interaction
let mut lock = ctx.data().recording_macros.write().await; {
if let Some(guild_id) = ctx.guild_id() {
if ctx.command().identifying_name != "finish_macro" {
let mut lock = ctx.data().recording_macros.write().await;
if let Some(command_macro) = lock.get_mut(&(guild_id, ctx.author().id)) { if let Some(command_macro) = lock.get_mut(&(guild_id, ctx.author().id)) {
if command_macro.commands.len() >= MACRO_MAX_COMMANDS { if command_macro.commands.len() >= MACRO_MAX_COMMANDS {
let _ = ctx.send(|m| { let _ = ctx.send(|m| {
m.ephemeral(true).content( m.ephemeral(true).content(
format!("{} commands already recorded. Please use `/macro finish` to end recording.", MACRO_MAX_COMMANDS), format!("{} commands already recorded. Please use `/macro finish` to end recording.", MACRO_MAX_COMMANDS),
) )
}) })
.await; .await;
} else { } else {
let recorded = RecordedCommand { let recorded = RecordedCommand {
action: None, action: None,
command_name: ctx.command().identifying_name.clone(), command_name: ctx.command().identifying_name.clone(),
options: Vec::from(app_ctx.args), options: Vec::from(app_ctx.args),
}; };
command_macro.commands.push(recorded); command_macro.commands.push(recorded);
let _ = ctx let _ = ctx
.send(|m| m.ephemeral(true).content("Command recorded to macro")) .send(|m| m.ephemeral(true).content("Command recorded to macro"))
.await; .await;
}
return false;
} }
return false;
} }
} }
} }
@ -41,25 +47,26 @@ async fn macro_check(ctx: Context<'_>) -> bool {
async fn check_self_permissions(ctx: Context<'_>) -> bool { async fn check_self_permissions(ctx: Context<'_>) -> bool {
if let Some(guild) = ctx.guild() { if let Some(guild) = ctx.guild() {
let user_id = ctx.discord().cache.current_user_id(); let user_id = ctx.serenity_context().cache.current_user_id();
let manage_webhooks =
guild.member_permissions(&ctx, user_id).await.map_or(false, |p| p.manage_webhooks());
let manage_webhooks = guild
.member_permissions(&ctx.discord(), user_id)
.await
.map_or(false, |p| p.manage_webhooks());
let (view_channel, send_messages, embed_links) = ctx let (view_channel, send_messages, embed_links) = ctx
.channel_id() .channel_id()
.to_channel_cached(&ctx.discord()) .to_channel(&ctx)
.await
.ok()
.and_then(|c| { .and_then(|c| {
if let Channel::Guild(channel) = c { if let Channel::Guild(channel) = c {
channel.permissions_for_user(&ctx.discord(), user_id).ok() let perms = channel.permissions_for_user(&ctx, user_id).ok()?;
Some((perms.view_channel(), perms.send_messages(), perms.embed_links()))
} else { } else {
None None
} }
}) })
.map_or((false, false, false), |p| { .unwrap_or((false, false, false));
(p.view_channel(), p.send_messages(), p.embed_links())
});
if manage_webhooks && send_messages && embed_links { if manage_webhooks && send_messages && embed_links {
true true
@ -75,8 +82,8 @@ async fn check_self_permissions(ctx: Context<'_>) -> bool {
{} **Manage Webhooks**", {} **Manage Webhooks**",
if view_channel { "" } else { "" }, if view_channel { "" } else { "" },
if send_messages { "" } else { "" }, if send_messages { "" } else { "" },
if manage_webhooks { "" } else { "" },
if embed_links { "" } else { "" }, if embed_links { "" } else { "" },
if manage_webhooks { "" } else { "" },
)) ))
}) })
.await; .await;

View File

@ -110,13 +110,14 @@ impl OverflowOp for u64 {
#[derive(Copy, Clone)] #[derive(Copy, Clone)]
pub struct Interval { pub struct Interval {
pub month: u64, pub month: u64,
pub day: u64,
pub sec: u64, pub sec: u64,
} }
struct Parser<'a> { struct Parser<'a> {
iter: Chars<'a>, iter: Chars<'a>,
src: &'a str, src: &'a str,
current: (u64, u64, u64), current: (u64, u64, u64, u64),
} }
impl<'a> Parser<'a> { impl<'a> Parser<'a> {
@ -140,17 +141,17 @@ impl<'a> Parser<'a> {
Ok(None) Ok(None)
} }
fn parse_unit(&mut self, n: u64, start: usize, end: usize) -> Result<(), Error> { fn parse_unit(&mut self, n: u64, start: usize, end: usize) -> Result<(), Error> {
let (mut month, mut sec, nsec) = match &self.src[start..end] { let (mut month, mut day, mut sec, nsec) = match &self.src[start..end] {
"nanos" | "nsec" | "ns" => (0u64, 0u64, n), "nanos" | "nsec" | "ns" => (0, 0u64, 0u64, n),
"usec" | "us" => (0, 0u64, n.mul(1000)?), "usec" | "us" => (0, 0, 0u64, n.mul(1000)?),
"millis" | "msec" | "ms" => (0, 0u64, n.mul(1_000_000)?), "millis" | "msec" | "ms" => (0, 0, 0u64, n.mul(1_000_000)?),
"seconds" | "second" | "secs" | "sec" | "s" => (0, n, 0), "seconds" | "second" | "secs" | "sec" | "s" => (0, 0, n, 0),
"minutes" | "minute" | "min" | "mins" | "m" => (0, n.mul(60)?, 0), "minutes" | "minute" | "min" | "mins" | "m" => (0, 0, n.mul(60)?, 0),
"hours" | "hour" | "hr" | "hrs" | "h" => (0, n.mul(3600)?, 0), "hours" | "hour" | "hr" | "hrs" | "h" => (0, 0, n.mul(3600)?, 0),
"days" | "day" | "d" => (0, n.mul(86400)?, 0), "days" | "day" | "d" => (0, n, 0, 0),
"weeks" | "week" | "w" => (0, n.mul(86400 * 7)?, 0), "weeks" | "week" | "w" => (0, n.mul(7)?, 0, 0),
"months" | "month" | "M" => (n, 0, 0), "months" | "month" => (n, 0, 0, 0),
"years" | "year" | "y" => (12, 0, 0), "years" | "year" | "y" => (n.mul(12)?, 0, 0, 0),
_ => { _ => {
return Err(Error::UnknownUnit { return Err(Error::UnknownUnit {
start, start,
@ -160,15 +161,16 @@ impl<'a> Parser<'a> {
}); });
} }
}; };
let mut nsec = self.current.2 + nsec; let mut nsec = self.current.3 + nsec;
if nsec > 1_000_000_000 { if nsec > 1_000_000_000 {
sec += nsec / 1_000_000_000; sec += nsec / 1_000_000_000;
nsec %= 1_000_000_000; nsec %= 1_000_000_000;
} }
sec += self.current.1; sec += self.current.2;
day += self.current.1;
month += self.current.0; month += self.current.0;
self.current = (month, sec, nsec); self.current = (month, day, sec, nsec);
Ok(()) Ok(())
} }
@ -215,7 +217,13 @@ impl<'a> Parser<'a> {
self.parse_unit(n, start, off)?; self.parse_unit(n, start, off)?;
n = match self.parse_first_char()? { n = match self.parse_first_char()? {
Some(n) => n, Some(n) => n,
None => return Ok(Interval { month: self.current.0, sec: self.current.1 }), None => {
return Ok(Interval {
month: self.current.0,
day: self.current.1,
sec: self.current.2,
})
}
}; };
} }
} }
@ -247,5 +255,82 @@ impl<'a> Parser<'a> {
/// assert_eq!(parse_duration("32ms"), Ok(Duration::new(0, 32_000_000))); /// assert_eq!(parse_duration("32ms"), Ok(Duration::new(0, 32_000_000)));
/// ``` /// ```
pub fn parse_duration(s: &str) -> Result<Interval, Error> { pub fn parse_duration(s: &str) -> Result<Interval, Error> {
Parser { iter: s.chars(), src: s, current: (0, 0, 0) }.parse() Parser { iter: s.to_lowercase().chars(), src: &s.to_lowercase(), current: (0, 0, 0, 0) }.parse()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parse_seconds() {
let interval = parse_duration("10 seconds").unwrap();
assert_eq!(interval.sec, 10);
assert_eq!(interval.day, 0);
assert_eq!(interval.month, 0);
}
#[test]
fn parse_minutes() {
let interval = parse_duration("10 minutes").unwrap();
assert_eq!(interval.sec, 600);
assert_eq!(interval.day, 0);
assert_eq!(interval.month, 0);
}
#[test]
fn parse_hours() {
let interval = parse_duration("10 hours").unwrap();
assert_eq!(interval.sec, 36_000);
assert_eq!(interval.day, 0);
assert_eq!(interval.month, 0);
}
#[test]
fn parse_days() {
let interval = parse_duration("10 days").unwrap();
assert_eq!(interval.sec, 0);
assert_eq!(interval.day, 10);
assert_eq!(interval.month, 0);
}
#[test]
fn parse_weeks() {
let interval = parse_duration("10 weeks").unwrap();
assert_eq!(interval.sec, 0);
assert_eq!(interval.day, 70);
assert_eq!(interval.month, 0);
}
#[test]
fn parse_months() {
let interval = parse_duration("10 months").unwrap();
assert_eq!(interval.sec, 0);
assert_eq!(interval.day, 0);
assert_eq!(interval.month, 10);
}
#[test]
fn parse_years() {
let interval = parse_duration("10 years").unwrap();
assert_eq!(interval.sec, 0);
assert_eq!(interval.day, 0);
assert_eq!(interval.month, 120);
}
#[test]
fn parse_case() {
let interval = parse_duration("200 Seconds").unwrap();
assert_eq!(interval.sec, 200);
assert_eq!(interval.day, 0);
assert_eq!(interval.month, 0);
}
} }

View File

@ -18,12 +18,12 @@ use std::{
env, env,
error::Error as StdError, error::Error as StdError,
fmt::{Debug, Display, Formatter}, fmt::{Debug, Display, Formatter},
sync::atomic::AtomicBool, path::Path,
}; };
use chrono_tz::Tz; use chrono_tz::Tz;
use dotenv::dotenv; use log::{error, warn};
use poise::serenity::model::{ use poise::serenity_prelude::model::{
gateway::GatewayIntents, gateway::GatewayIntents,
id::{GuildId, UserId}, id::{GuildId, UserId},
}; };
@ -31,7 +31,7 @@ use sqlx::{MySql, Pool};
use tokio::sync::{broadcast, broadcast::Sender, RwLock}; use tokio::sync::{broadcast, broadcast::Sender, RwLock};
use crate::{ use crate::{
commands::{info_cmds, moderation_cmds, reminder_cmds, todo_cmds}, commands::{command_macro, info_cmds, moderation_cmds, reminder_cmds, todo_cmds},
consts::THEME_COLOR, consts::THEME_COLOR,
event_handlers::listener, event_handlers::listener,
hooks::all_checks, hooks::all_checks,
@ -43,14 +43,14 @@ type Database = MySql;
type Error = Box<dyn std::error::Error + Send + Sync>; type Error = Box<dyn std::error::Error + Send + Sync>;
type Context<'a> = poise::Context<'a, Data, Error>; type Context<'a> = poise::Context<'a, Data, Error>;
type ApplicationContext<'a> = poise::ApplicationContext<'a, Data, Error>;
pub struct Data { pub struct Data {
database: Pool<Database>, database: Pool<Database>,
http: reqwest::Client, http: reqwest::Client,
recording_macros: RwLock<HashMap<(GuildId, UserId), CommandMacro<Data, Error>>>, recording_macros: RwLock<HashMap<(GuildId, UserId), CommandMacro<Data, Error>>>,
popular_timezones: Vec<Tz>, popular_timezones: Vec<Tz>,
is_loop_running: AtomicBool, _broadcast: Sender<()>,
broadcast: Sender<()>,
} }
impl Debug for Data { impl Debug for Data {
@ -75,7 +75,7 @@ impl Display for Ended {
impl StdError for Ended {} impl StdError for Ended {}
#[tokio::main] #[tokio::main(flavor = "multi_thread")]
async fn main() -> Result<(), Box<dyn StdError + Send + Sync>> { async fn main() -> Result<(), Box<dyn StdError + Send + Sync>> {
let (tx, mut rx) = broadcast::channel(16); let (tx, mut rx) = broadcast::channel(16);
@ -88,7 +88,11 @@ async fn main() -> Result<(), Box<dyn StdError + Send + Sync>> {
async fn _main(tx: Sender<()>) -> Result<(), Box<dyn StdError + Send + Sync>> { async fn _main(tx: Sender<()>) -> Result<(), Box<dyn StdError + Send + Sync>> {
env_logger::init(); env_logger::init();
dotenv()?; if Path::new("/etc/reminder-rs/config.env").exists() {
dotenv::from_path("/etc/reminder-rs/config.env")?;
} else {
let _ = dotenv::dotenv();
}
let discord_token = env::var("DISCORD_TOKEN").expect("Missing DISCORD_TOKEN from environment"); let discord_token = env::var("DISCORD_TOKEN").expect("Missing DISCORD_TOKEN from environment");
@ -103,13 +107,32 @@ async fn _main(tx: Sender<()>) -> Result<(), Box<dyn StdError + Send + Sync>> {
moderation_cmds::timezone(), moderation_cmds::timezone(),
poise::Command { poise::Command {
subcommands: vec![ subcommands: vec![
moderation_cmds::delete_macro(), moderation_cmds::set_allowed_dm(),
moderation_cmds::finish_macro(), moderation_cmds::unset_allowed_dm(),
moderation_cmds::list_macro(),
moderation_cmds::record_macro(),
moderation_cmds::run_macro(),
], ],
..moderation_cmds::macro_base() ..moderation_cmds::allowed_dm()
},
poise::Command {
subcommands: vec![poise::Command {
subcommands: vec![
moderation_cmds::set_ephemeral_confirmations(),
moderation_cmds::unset_ephemeral_confirmations(),
],
..moderation_cmds::ephemeral_confirmations()
}],
..moderation_cmds::settings()
},
moderation_cmds::webhook(),
poise::Command {
subcommands: vec![
command_macro::delete::delete_macro(),
command_macro::record::finish_macro(),
command_macro::list::list_macro(),
command_macro::record::record_macro(),
command_macro::run::run_macro(),
command_macro::migrate::migrate_macro(),
],
..command_macro::macro_base()
}, },
reminder_cmds::pause(), reminder_cmds::pause(),
reminder_cmds::offset(), reminder_cmds::offset(),
@ -124,6 +147,7 @@ async fn _main(tx: Sender<()>) -> Result<(), Box<dyn StdError + Send + Sync>> {
], ],
..reminder_cmds::timer_base() ..reminder_cmds::timer_base()
}, },
reminder_cmds::multiline(),
reminder_cmds::remind(), reminder_cmds::remind(),
poise::Command { poise::Command {
subcommands: vec![ subcommands: vec![
@ -151,15 +175,36 @@ async fn _main(tx: Sender<()>) -> Result<(), Box<dyn StdError + Send + Sync>> {
], ],
allowed_mentions: None, allowed_mentions: None,
command_check: Some(|ctx| Box::pin(all_checks(ctx))), command_check: Some(|ctx| Box::pin(all_checks(ctx))),
listener: |ctx, event, _framework, data| Box::pin(listener(ctx, event, data)), event_handler: |ctx, event, _framework, data| Box::pin(listener(ctx, event, data)),
on_error: |error| {
Box::pin(async move {
match error {
poise::FrameworkError::CommandCheckFailed { .. } => {
// suppress error
}
error => {
if let Err(e) = poise::builtins::on_error(error).await {
log::error!("Error while handling error: {}", e);
}
}
}
})
},
..Default::default() ..Default::default()
}; };
let database = let database =
Pool::connect(&env::var("DATABASE_URL").expect("No database URL provided")).await.unwrap(); Pool::connect(&env::var("DATABASE_URL").expect("No database URL provided")).await.unwrap();
sqlx::migrate!().run(&database).await?;
let popular_timezones = sqlx::query!( let popular_timezones = sqlx::query!(
"SELECT timezone FROM users GROUP BY timezone ORDER BY COUNT(timezone) DESC LIMIT 21" "SELECT IFNULL(timezone, 'UTC') AS timezone
FROM users
WHERE timezone IS NOT NULL
GROUP BY timezone
ORDER BY COUNT(timezone) DESC
LIMIT 21"
) )
.fetch_all(&database) .fetch_all(&database)
.await .await
@ -168,27 +213,50 @@ async fn _main(tx: Sender<()>) -> Result<(), Box<dyn StdError + Send + Sync>> {
.map(|t| t.timezone.parse::<Tz>().unwrap()) .map(|t| t.timezone.parse::<Tz>().unwrap())
.collect::<Vec<Tz>>(); .collect::<Vec<Tz>>();
poise::Framework::build() poise::Framework::builder()
.token(discord_token) .token(discord_token)
.user_data_setup(move |ctx, _bot, framework| { .setup(move |ctx, _bot, framework| {
Box::pin(async move { Box::pin(async move {
register_application_commands( register_application_commands(ctx, framework, None).await.unwrap();
ctx,
framework, let kill_tx = tx.clone();
env::var("DEBUG_GUILD") let kill_recv = tx.subscribe();
.map(|inner| GuildId(inner.parse().expect("DEBUG_GUILD not valid")))
.ok(), let ctx1 = ctx.clone();
) let ctx2 = ctx.clone();
.await
.unwrap(); let pool1 = database.clone();
let pool2 = database.clone();
let run_settings = env::var("DONTRUN").unwrap_or_else(|_| "".to_string());
if !run_settings.contains("postman") {
tokio::spawn(async move {
match postman::initialize(kill_recv, ctx1, &pool1).await {
Ok(_) => {}
Err(e) => {
error!("postman exiting: {}", e);
}
};
});
} else {
warn!("Not running postman");
}
if !run_settings.contains("web") {
tokio::spawn(async move {
reminder_web::initialize(kill_tx, ctx2, pool2).await.unwrap();
});
} else {
warn!("Not running web");
}
Ok(Data { Ok(Data {
http: reqwest::Client::new(), http: reqwest::Client::new(),
database, database,
popular_timezones, popular_timezones,
recording_macros: Default::default(), recording_macros: Default::default(),
is_loop_running: AtomicBool::new(false), _broadcast: tx,
broadcast: tx,
}) })
}) })
}) })

View File

@ -1,5 +1,5 @@
use chrono::NaiveDateTime; use chrono::NaiveDateTime;
use poise::serenity::model::channel::Channel; use poise::serenity_prelude::model::channel::Channel;
use sqlx::MySqlPool; use sqlx::MySqlPool;
pub struct ChannelData { pub struct ChannelData {
@ -22,9 +22,7 @@ impl ChannelData {
if let Ok(c) = sqlx::query_as_unchecked!( if let Ok(c) = sqlx::query_as_unchecked!(
Self, Self,
" "SELECT id, name, nudge, blacklisted, webhook_id, webhook_token, paused, paused_until FROM channels WHERE channel = ?",
SELECT id, name, nudge, blacklisted, webhook_id, webhook_token, paused, paused_until FROM channels WHERE channel = ?
",
channel_id channel_id
) )
.fetch_one(pool) .fetch_one(pool)
@ -37,9 +35,7 @@ SELECT id, name, nudge, blacklisted, webhook_id, webhook_token, paused, paused_u
let (guild_id, channel_name) = if let Some((a, b)) = props { (Some(a), Some(b)) } else { (None, None) }; let (guild_id, channel_name) = if let Some((a, b)) = props { (Some(a), Some(b)) } else { (None, None) };
sqlx::query!( sqlx::query!(
" "INSERT IGNORE INTO channels (channel, name, guild_id) VALUES (?, ?, (SELECT id FROM guilds WHERE guild = ?))",
INSERT IGNORE INTO channels (channel, name, guild_id) VALUES (?, ?, (SELECT id FROM guilds WHERE guild = ?))
",
channel_id, channel_id,
channel_name, channel_name,
guild_id guild_id

View File

@ -1,7 +1,8 @@
use poise::serenity::model::{ use poise::serenity_prelude::model::{
id::GuildId, interactions::application_command::ApplicationCommandInteractionDataOption, application::interaction::application_command::CommandDataOption, id::GuildId,
}; };
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use serde_json::Value;
use crate::{Context, Data, Error}; use crate::{Context, Data, Error};
@ -19,7 +20,7 @@ pub struct RecordedCommand<U, E> {
#[serde(default = "default_none::<U, E>")] #[serde(default = "default_none::<U, E>")]
pub action: Option<Func<U, E>>, pub action: Option<Func<U, E>>,
pub command_name: String, pub command_name: String,
pub options: Vec<ApplicationCommandInteractionDataOption>, pub options: Vec<CommandDataOption>,
} }
pub struct CommandMacro<U, E> { pub struct CommandMacro<U, E> {
@ -29,6 +30,13 @@ pub struct CommandMacro<U, E> {
pub commands: Vec<RecordedCommand<U, E>>, pub commands: Vec<RecordedCommand<U, E>>,
} }
pub struct RawCommandMacro {
pub guild_id: GuildId,
pub name: String,
pub description: Option<String>,
pub commands: Value,
}
pub async fn guild_command_macro( pub async fn guild_command_macro(
ctx: &Context<'_>, ctx: &Context<'_>,
name: &str, name: &str,

48
src/models/guild_data.rs Normal file
View File

@ -0,0 +1,48 @@
use poise::serenity_prelude::GuildId;
use sqlx::MySqlPool;
pub struct GuildData {
pub ephemeral_confirmations: bool,
pub id: u32,
}
impl GuildData {
pub async fn from_guild(
guild_id: GuildId,
pool: &MySqlPool,
) -> Result<Self, Box<dyn std::error::Error + Sync + Send>> {
if let Ok(c) = sqlx::query_as_unchecked!(
Self,
"SELECT id, ephemeral_confirmations FROM guilds WHERE guild = ?",
guild_id.0
)
.fetch_one(pool)
.await
{
Ok(c)
} else {
sqlx::query!("INSERT IGNORE INTO guilds (guild) VALUES (?)", guild_id.0)
.execute(&pool.clone())
.await?;
Ok(sqlx::query_as_unchecked!(
Self,
"SELECT id, ephemeral_confirmations FROM guilds WHERE guild = ?",
guild_id.0
)
.fetch_one(pool)
.await?)
}
}
pub async fn commit_changes(&self, pool: &MySqlPool) {
sqlx::query!(
"UPDATE guilds SET ephemeral_confirmations = ? WHERE id = ?",
self.ephemeral_confirmations,
self.id
)
.execute(pool)
.await
.unwrap();
}
}

View File

@ -1,14 +1,15 @@
pub mod channel_data; pub mod channel_data;
pub mod command_macro; pub mod command_macro;
pub mod guild_data;
pub mod reminder; pub mod reminder;
pub mod timer; pub mod timer;
pub mod user_data; pub mod user_data;
use chrono_tz::Tz; use chrono_tz::Tz;
use poise::serenity::{async_trait, model::id::UserId}; use poise::serenity_prelude::{async_trait, model::id::UserId, ChannelType};
use crate::{ use crate::{
models::{channel_data::ChannelData, user_data::UserData}, models::{channel_data::ChannelData, guild_data::GuildData, user_data::UserData},
CommandMacro, Context, Data, Error, GuildId, CommandMacro, Context, Data, Error, GuildId,
}; };
@ -18,6 +19,8 @@ pub trait CtxData {
async fn author_data(&self) -> Result<UserData, Error>; async fn author_data(&self) -> Result<UserData, Error>;
async fn guild_data(&self) -> Option<Result<GuildData, Error>>;
async fn timezone(&self) -> Tz; async fn timezone(&self) -> Tz;
async fn channel_data(&self) -> Result<ChannelData, Error>; async fn channel_data(&self) -> Result<ChannelData, Error>;
@ -27,15 +30,21 @@ pub trait CtxData {
#[async_trait] #[async_trait]
impl CtxData for Context<'_> { impl CtxData for Context<'_> {
async fn user_data<U: Into<UserId> + Send>( async fn user_data<U: Into<UserId> + Send>(&self, user_id: U) -> Result<UserData, Error> {
&self, UserData::from_user(user_id, &self.serenity_context(), &self.data().database).await
user_id: U,
) -> Result<UserData, Box<dyn std::error::Error + Sync + Send>> {
UserData::from_user(user_id, &self.discord(), &self.data().database).await
} }
async fn author_data(&self) -> Result<UserData, Box<dyn std::error::Error + Sync + Send>> { async fn author_data(&self) -> Result<UserData, Error> {
UserData::from_user(&self.author().id, &self.discord(), &self.data().database).await UserData::from_user(&self.author().id, &self.serenity_context(), &self.data().database)
.await
}
async fn guild_data(&self) -> Option<Result<GuildData, Error>> {
if let Some(guild_id) = self.guild_id() {
Some(GuildData::from_guild(guild_id, &self.data().database).await)
} else {
None
}
} }
async fn timezone(&self) -> Tz { async fn timezone(&self) -> Tz {
@ -43,7 +52,20 @@ impl CtxData for Context<'_> {
} }
async fn channel_data(&self) -> Result<ChannelData, Box<dyn std::error::Error + Sync + Send>> { async fn channel_data(&self) -> Result<ChannelData, Box<dyn std::error::Error + Sync + Send>> {
let channel = self.channel_id().to_channel_cached(&self.discord()).unwrap(); // If we're in a thread, get the parent channel.
let recv_channel = self.channel_id().to_channel(&self).await?;
let channel = match recv_channel.guild() {
Some(guild_channel) => {
if guild_channel.kind == ChannelType::PublicThread {
guild_channel.parent_id.unwrap().to_channel_cached(&self).unwrap()
} else {
self.channel_id().to_channel_cached(&self).unwrap()
}
}
None => self.channel_id().to_channel_cached(&self).unwrap(),
};
ChannelData::from_channel(&channel, &self.data().database).await ChannelData::from_channel(&channel, &self.data().database).await
} }

View File

@ -2,14 +2,14 @@ use std::{collections::HashSet, fmt::Display};
use chrono::{Duration, NaiveDateTime, Utc}; use chrono::{Duration, NaiveDateTime, Utc};
use chrono_tz::Tz; use chrono_tz::Tz;
use poise::serenity::{ use poise::serenity_prelude::{
http::CacheHttp, http::CacheHttp,
model::{ model::{
channel::GuildChannel, channel::GuildChannel,
id::{ChannelId, GuildId, UserId}, id::{ChannelId, GuildId, UserId},
webhook::Webhook, webhook::Webhook,
}, },
Result as SerenityResult, ChannelType, Result as SerenityResult,
}; };
use sqlx::MySqlPool; use sqlx::MySqlPool;
@ -51,9 +51,11 @@ pub struct ReminderBuilder {
pool: MySqlPool, pool: MySqlPool,
uid: String, uid: String,
channel: u32, channel: u32,
thread_id: Option<u64>,
utc_time: NaiveDateTime, utc_time: NaiveDateTime,
timezone: String, timezone: String,
interval_secs: Option<i64>, interval_seconds: Option<i64>,
interval_days: Option<i64>,
interval_months: Option<i64>, interval_months: Option<i64>,
expires: Option<NaiveDateTime>, expires: Option<NaiveDateTime>,
content: String, content: String,
@ -87,6 +89,7 @@ INSERT INTO reminders (
`utc_time`, `utc_time`,
`timezone`, `timezone`,
`interval_seconds`, `interval_seconds`,
`interval_days`,
`interval_months`, `interval_months`,
`expires`, `expires`,
`content`, `content`,
@ -106,6 +109,7 @@ INSERT INTO reminders (
?, ?,
?, ?,
?, ?,
?,
? ?
) )
", ",
@ -113,7 +117,8 @@ INSERT INTO reminders (
self.channel, self.channel,
utc_time, utc_time,
self.timezone, self.timezone,
self.interval_secs, self.interval_seconds,
self.interval_days,
self.interval_months, self.interval_months,
self.expires, self.expires,
self.content, self.content,
@ -175,17 +180,15 @@ impl<'a> MultiReminderBuilder<'a> {
} }
pub fn time<T: Into<i64>>(mut self, time: T) -> Self { pub fn time<T: Into<i64>>(mut self, time: T) -> Self {
self.utc_time = NaiveDateTime::from_timestamp(time.into(), 0); if let Some(utc_time) = NaiveDateTime::from_timestamp_opt(time.into(), 0) {
self.utc_time = utc_time;
}
self self
} }
pub fn expires<T: Into<i64>>(mut self, time: Option<T>) -> Self { pub fn expires<T: Into<i64>>(mut self, time: Option<T>) -> Self {
if let Some(t) = time { self.expires = time.map(|t| NaiveDateTime::from_timestamp_opt(t.into(), 0)).flatten();
self.expires = Some(NaiveDateTime::from_timestamp(t.into(), 0));
} else {
self.expires = None;
}
self self
} }
@ -212,27 +215,37 @@ impl<'a> MultiReminderBuilder<'a> {
let mut ok_locs = HashSet::new(); let mut ok_locs = HashSet::new();
if self.interval.map_or(false, |i| ((i.sec + i.month * 30 * DAY) as i64) < *MIN_INTERVAL) { if self
.interval
.map_or(false, |i| ((i.sec + i.day * DAY + i.month * 30 * DAY) as i64) < *MIN_INTERVAL)
{
errors.insert(ReminderError::ShortInterval); errors.insert(ReminderError::ShortInterval);
} else if self.interval.map_or(false, |i| ((i.sec + i.month * 30 * DAY) as i64) > *MAX_TIME) } else if self
.interval
.map_or(false, |i| ((i.sec + i.day * DAY + i.month * 30 * DAY) as i64) > *MAX_TIME)
{ {
errors.insert(ReminderError::LongInterval); errors.insert(ReminderError::LongInterval);
} else { } else {
for scope in self.scopes { for scope in self.scopes {
let thread_id = None;
let db_channel_id = match scope { let db_channel_id = match scope {
ReminderScope::User(user_id) => { ReminderScope::User(user_id) => {
if let Ok(user) = UserId(user_id).to_user(&self.ctx.discord()).await { if let Ok(user) = UserId(user_id).to_user(&self.ctx).await {
let user_data = UserData::from_user( let user_data = UserData::from_user(
&user, &user,
&self.ctx.discord(), &self.ctx.serenity_context(),
&self.ctx.data().database, &self.ctx.data().database,
) )
.await .await
.unwrap(); .unwrap();
if let Some(guild_id) = self.guild_id { if let Some(guild_id) = self.guild_id {
if guild_id.member(&self.ctx.discord(), user).await.is_err() { if guild_id.member(&self.ctx, user).await.is_err() {
Err(ReminderError::InvalidTag) Err(ReminderError::InvalidTag)
} else if self.set_by.map_or(true, |i| i != user_data.id)
&& !user_data.allowed_dm
{
Err(ReminderError::UserBlockedDm)
} else { } else {
Ok(user_data.dm_channel) Ok(user_data.dm_channel)
} }
@ -244,27 +257,36 @@ impl<'a> MultiReminderBuilder<'a> {
} }
} }
ReminderScope::Channel(channel_id) => { ReminderScope::Channel(channel_id) => {
let channel = let channel = ChannelId(channel_id).to_channel(&self.ctx).await.unwrap();
ChannelId(channel_id).to_channel(&self.ctx.discord()).await.unwrap();
if let Some(guild_channel) = channel.clone().guild() { if let Some(mut guild_channel) = channel.clone().guild() {
if Some(guild_channel.guild_id) != self.guild_id { if Some(guild_channel.guild_id) != self.guild_id {
Err(ReminderError::InvalidTag) Err(ReminderError::InvalidTag)
} else { } else {
let mut channel_data = let mut channel_data = if guild_channel.kind
ChannelData::from_channel(&channel, &self.ctx.data().database) == ChannelType::PublicThread
{
// fixme jesus christ
let parent = guild_channel
.parent_id
.unwrap()
.to_channel(&self.ctx)
.await .await
.unwrap(); .unwrap();
guild_channel = parent.clone().guild().unwrap();
ChannelData::from_channel(&parent, &self.ctx.data().database)
.await
.unwrap()
} else {
ChannelData::from_channel(&channel, &self.ctx.data().database)
.await
.unwrap()
};
if channel_data.webhook_id.is_none() if channel_data.webhook_id.is_none()
|| channel_data.webhook_token.is_none() || channel_data.webhook_token.is_none()
{ {
match create_webhook( match create_webhook(&self.ctx, guild_channel, "Reminder").await
&self.ctx.discord(),
guild_channel,
"Reminder",
)
.await
{ {
Ok(webhook) => { Ok(webhook) => {
channel_data.webhook_id = channel_data.webhook_id =
@ -296,9 +318,11 @@ impl<'a> MultiReminderBuilder<'a> {
pool: self.ctx.data().database.clone(), pool: self.ctx.data().database.clone(),
uid: generate_uid(), uid: generate_uid(),
channel: c, channel: c,
thread_id,
utc_time: self.utc_time, utc_time: self.utc_time,
timezone: self.timezone.to_string(), timezone: self.timezone.to_string(),
interval_secs: self.interval.map(|i| i.sec as i64), interval_seconds: self.interval.map(|i| i.sec as i64),
interval_days: self.interval.map(|i| i.day as i64),
interval_months: self.interval.map(|i| i.month as i64), interval_months: self.interval.map(|i| i.month as i64),
expires: self.expires, expires: self.expires,
content: self.content.content.clone(), content: self.content.content.clone(),

View File

@ -7,6 +7,7 @@ pub enum ReminderError {
PastTime, PastTime,
ShortInterval, ShortInterval,
InvalidTag, InvalidTag,
UserBlockedDm,
DiscordError(String), DiscordError(String),
} }
@ -30,6 +31,9 @@ impl ToString for ReminderError {
ReminderError::InvalidTag => { ReminderError::InvalidTag => {
"Couldn't find a location by your tag. Your tag must be either a channel or a user (not a role)".to_string() "Couldn't find a location by your tag. Your tag must be either a channel or a user (not a role)".to_string()
} }
ReminderError::UserBlockedDm => {
"User has DM reminders disabled".to_string()
}
ReminderError::DiscordError(s) => format!("A Discord error occurred: **{}**", s), ReminderError::DiscordError(s) => format!("A Discord error occurred: **{}**", s),
} }
} }

View File

@ -1,4 +1,4 @@
use poise::serenity::model::id::ChannelId; use poise::serenity_prelude::model::id::ChannelId;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use serde_repr::*; use serde_repr::*;

View File

@ -6,11 +6,11 @@ pub mod look_flags;
use std::hash::{Hash, Hasher}; use std::hash::{Hash, Hasher};
use chrono::{NaiveDateTime, TimeZone}; use chrono::{DateTime, NaiveDateTime, Utc};
use chrono_tz::Tz; use chrono_tz::Tz;
use poise::{ use poise::serenity_prelude::{
serenity::model::id::{ChannelId, GuildId, UserId}, model::id::{ChannelId, GuildId, UserId},
serenity_prelude::Cache, Cache,
}; };
use sqlx::Executor; use sqlx::Executor;
@ -24,8 +24,9 @@ pub struct Reminder {
pub id: u32, pub id: u32,
pub uid: String, pub uid: String,
pub channel: u64, pub channel: u64,
pub utc_time: NaiveDateTime, pub utc_time: DateTime<Utc>,
pub interval_seconds: Option<u32>, pub interval_seconds: Option<u32>,
pub interval_days: Option<u32>,
pub interval_months: Option<u32>, pub interval_months: Option<u32>,
pub expires: Option<NaiveDateTime>, pub expires: Option<NaiveDateTime>,
pub enabled: bool, pub enabled: bool,
@ -59,6 +60,7 @@ SELECT
channels.channel, channels.channel,
reminders.utc_time, reminders.utc_time,
reminders.interval_seconds, reminders.interval_seconds,
reminders.interval_days,
reminders.interval_months, reminders.interval_months,
reminders.expires, reminders.expires,
reminders.enabled, reminders.enabled,
@ -95,6 +97,7 @@ SELECT
channels.channel, channels.channel,
reminders.utc_time, reminders.utc_time,
reminders.interval_seconds, reminders.interval_seconds,
reminders.interval_days,
reminders.interval_months, reminders.interval_months,
reminders.expires, reminders.expires,
reminders.enabled, reminders.enabled,
@ -138,6 +141,7 @@ SELECT
channels.channel, channels.channel,
reminders.utc_time, reminders.utc_time,
reminders.interval_seconds, reminders.interval_seconds,
reminders.interval_days,
reminders.interval_months, reminders.interval_months,
reminders.expires, reminders.expires,
reminders.enabled, reminders.enabled,
@ -155,6 +159,7 @@ LEFT JOIN
ON ON
reminders.set_by = users.id reminders.set_by = users.id
WHERE WHERE
`status` = 'pending' AND
channels.channel = ? AND channels.channel = ? AND
FIND_IN_SET(reminders.enabled, ?) FIND_IN_SET(reminders.enabled, ?)
ORDER BY ORDER BY
@ -195,6 +200,7 @@ SELECT
channels.channel, channels.channel,
reminders.utc_time, reminders.utc_time,
reminders.interval_seconds, reminders.interval_seconds,
reminders.interval_days,
reminders.interval_months, reminders.interval_months,
reminders.expires, reminders.expires,
reminders.enabled, reminders.enabled,
@ -212,6 +218,7 @@ LEFT JOIN
ON ON
reminders.set_by = users.id reminders.set_by = users.id
WHERE WHERE
`status` = 'pending' AND
FIND_IN_SET(channels.channel, ?) FIND_IN_SET(channels.channel, ?)
", ",
channels channels
@ -228,6 +235,7 @@ SELECT
channels.channel, channels.channel,
reminders.utc_time, reminders.utc_time,
reminders.interval_seconds, reminders.interval_seconds,
reminders.interval_days,
reminders.interval_months, reminders.interval_months,
reminders.expires, reminders.expires,
reminders.enabled, reminders.enabled,
@ -245,6 +253,7 @@ LEFT JOIN
ON ON
reminders.set_by = users.id reminders.set_by = users.id
WHERE WHERE
`status` = 'pending' AND
channels.guild_id = (SELECT id FROM guilds WHERE guild = ?) channels.guild_id = (SELECT id FROM guilds WHERE guild = ?)
", ",
guild_id.as_u64() guild_id.as_u64()
@ -262,6 +271,7 @@ SELECT
channels.channel, channels.channel,
reminders.utc_time, reminders.utc_time,
reminders.interval_seconds, reminders.interval_seconds,
reminders.interval_days,
reminders.interval_months, reminders.interval_months,
reminders.expires, reminders.expires,
reminders.enabled, reminders.enabled,
@ -279,6 +289,7 @@ LEFT JOIN
ON ON
reminders.set_by = users.id reminders.set_by = users.id
WHERE WHERE
`status` = 'pending' AND
channels.id = (SELECT dm_channel FROM users WHERE user = ?) channels.id = (SELECT dm_channel FROM users WHERE user = ?)
", ",
user.as_u64() user.as_u64()
@ -293,7 +304,10 @@ WHERE
&self, &self,
db: impl Executor<'_, Database = Database>, db: impl Executor<'_, Database = Database>,
) -> Result<(), sqlx::Error> { ) -> Result<(), sqlx::Error> {
sqlx::query!("DELETE FROM reminders WHERE uid = ?", self.uid).execute(db).await.map(|_| ()) sqlx::query!("UPDATE reminders SET `status` = 'deleted' WHERE uid = ?", self.uid)
.execute(db)
.await
.map(|_| ())
} }
pub fn display_content(&self) -> &str { pub fn display_content(&self) -> &str {
@ -310,30 +324,32 @@ WHERE
count + 1, count + 1,
self.display_content(), self.display_content(),
self.channel, self.channel,
timezone.timestamp(self.utc_time.timestamp(), 0).format("%Y-%m-%d %H:%M:%S") self.utc_time.with_timezone(timezone).format("%Y-%m-%d %H:%M:%S")
) )
} }
pub fn display(&self, flags: &LookFlags, timezone: &Tz) -> String { pub fn display(&self, flags: &LookFlags, timezone: &Tz) -> String {
let time_display = match flags.time_display { let time_display = match flags.time_display {
TimeDisplayType::Absolute => timezone TimeDisplayType::Absolute => {
.timestamp(self.utc_time.timestamp(), 0) self.utc_time.with_timezone(timezone).format("%Y-%m-%d %H:%M:%S").to_string()
.format("%Y-%m-%d %H:%M:%S") }
.to_string(),
TimeDisplayType::Relative => format!("<t:{}:R>", self.utc_time.timestamp()), TimeDisplayType::Relative => format!("<t:{}:R>", self.utc_time.timestamp()),
}; };
if self.interval_seconds.is_some() || self.interval_months.is_some() { if self.interval_seconds.is_some()
|| self.interval_days.is_some()
|| self.interval_months.is_some()
{
format!( format!(
"'{}' *occurs next at* **{}**, repeating (set by {})", "'{}' *occurs next at* **{}**, repeating (set by {})\n",
self.display_content(), self.display_content(),
time_display, time_display,
self.set_by.map(|i| format!("<@{}>", i)).unwrap_or_else(|| "unknown".to_string()) self.set_by.map(|i| format!("<@{}>", i)).unwrap_or_else(|| "unknown".to_string())
) )
} else { } else {
format!( format!(
"'{}' *occurs next at* **{}** (set by {})", "'{}' *occurs next at* **{}** (set by {})\n",
self.display_content(), self.display_content(),
time_display, time_display,
self.set_by.map(|i| format!("<@{}>", i)).unwrap_or_else(|| "unknown".to_string()) self.set_by.map(|i| format!("<@{}>", i)).unwrap_or_else(|| "unknown".to_string())

View File

@ -1,9 +1,9 @@
use chrono::NaiveDateTime; use chrono::{DateTime, Utc};
use sqlx::MySqlPool; use sqlx::MySqlPool;
pub struct Timer { pub struct Timer {
pub name: String, pub name: String,
pub start_time: NaiveDateTime, pub start_time: DateTime<Utc>,
pub owner: u64, pub owner: u64,
} }

View File

@ -1,6 +1,6 @@
use chrono_tz::Tz; use chrono_tz::Tz;
use log::error; use log::error;
use poise::serenity::{http::CacheHttp, model::id::UserId}; use poise::serenity_prelude::{http::CacheHttp, model::id::UserId};
use sqlx::MySqlPool; use sqlx::MySqlPool;
use crate::consts::LOCAL_TIMEZONE; use crate::consts::LOCAL_TIMEZONE;
@ -10,6 +10,7 @@ pub struct UserData {
pub user: u64, pub user: u64,
pub dm_channel: u32, pub dm_channel: u32,
pub timezone: String, pub timezone: String,
pub allowed_dm: bool,
} }
impl UserData { impl UserData {
@ -21,7 +22,7 @@ impl UserData {
match sqlx::query!( match sqlx::query!(
" "
SELECT timezone FROM users WHERE user = ? SELECT IFNULL(timezone, 'UTC') AS timezone FROM users WHERE user = ?
", ",
user_id user_id
) )
@ -46,7 +47,7 @@ SELECT timezone FROM users WHERE user = ?
match sqlx::query_as_unchecked!( match sqlx::query_as_unchecked!(
Self, Self,
" "
SELECT id, user, dm_channel, IF(timezone IS NULL, ?, timezone) AS timezone FROM users WHERE user = ? SELECT id, user, dm_channel, IF(timezone IS NULL, ?, timezone) AS timezone, allowed_dm FROM users WHERE user = ?
", ",
*LOCAL_TIMEZONE, *LOCAL_TIMEZONE,
user_id.0 user_id.0
@ -83,7 +84,7 @@ INSERT INTO users (name, user, dm_channel, timezone) VALUES ('', ?, (SELECT id F
Ok(sqlx::query_as_unchecked!( Ok(sqlx::query_as_unchecked!(
Self, Self,
" "
SELECT id, user, dm_channel, timezone FROM users WHERE user = ? SELECT id, user, dm_channel, timezone, allowed_dm FROM users WHERE user = ?
", ",
user_id.0 user_id.0
) )
@ -102,9 +103,10 @@ SELECT id, user, dm_channel, timezone FROM users WHERE user = ?
pub async fn commit_changes(&self, pool: &MySqlPool) { pub async fn commit_changes(&self, pool: &MySqlPool) {
sqlx::query!( sqlx::query!(
" "
UPDATE users SET timezone = ? WHERE id = ? UPDATE users SET timezone = ?, allowed_dm = ? WHERE id = ?
", ",
self.timezone, self.timezone,
self.allowed_dm,
self.id self.id
) )
.execute(pool) .execute(pool)

View File

@ -1,10 +1,11 @@
use poise::{ use poise::{
serenity::{ serenity_prelude as serenity,
serenity_prelude::{
builder::CreateApplicationCommands, builder::CreateApplicationCommands,
http::CacheHttp, http::CacheHttp,
interaction::MessageFlags,
model::id::{GuildId, UserId}, model::id::{GuildId, UserId},
}, },
serenity_prelude as serenity,
}; };
use crate::{ use crate::{
@ -13,10 +14,10 @@ use crate::{
}; };
pub async fn register_application_commands( pub async fn register_application_commands(
ctx: &poise::serenity::client::Context, ctx: &serenity::Context,
framework: &poise::Framework<Data, Error>, framework: &poise::Framework<Data, Error>,
guild_id: Option<GuildId>, guild_id: Option<GuildId>,
) -> Result<(), poise::serenity::Error> { ) -> Result<(), serenity::Error> {
let mut commands_builder = CreateApplicationCommands::default(); let mut commands_builder = CreateApplicationCommands::default();
let commands = &framework.options().commands; let commands = &framework.options().commands;
for command in commands { for command in commands {
@ -27,7 +28,7 @@ pub async fn register_application_commands(
commands_builder.add_application_command(context_menu_command); commands_builder.add_application_command(context_menu_command);
} }
} }
let commands_builder = poise::serenity::json::Value::Array(commands_builder.0); let commands_builder = poise::serenity_prelude::json::Value::Array(commands_builder.0);
if let Some(guild_id) = guild_id { if let Some(guild_id) = guild_id {
ctx.http.create_guild_application_commands(guild_id.0, &commands_builder).await?; ctx.http.create_guild_application_commands(guild_id.0, &commands_builder).await?;
@ -82,7 +83,7 @@ pub fn send_as_initial_response(
components, components,
ephemeral, ephemeral,
allowed_mentions, allowed_mentions,
reference_message: _, // can't reply to a message in interactions reply: _,
} = data; } = data;
if let Some(content) = content { if let Some(content) = content {
@ -102,6 +103,6 @@ pub fn send_as_initial_response(
}); });
} }
if ephemeral { if ephemeral {
f.flags(serenity::InteractionApplicationCommandCallbackDataFlags::EPHEMERAL); f.flags(MessageFlags::EPHEMERAL);
} }
} }

View File

@ -0,0 +1,14 @@
[Unit]
Description=Reminder Bot
[Service]
User=reminder
Type=simple
ExecStart=/usr/bin/reminder-rs
WorkingDirectory=/etc/reminder-rs
Restart=always
RestartSec=4
Environment="reminder_rs=warn,postman=warn"
[Install]
WantedBy=multi-user.target

View File

@ -1,21 +1,21 @@
[package] [package]
name = "reminder_web" name = "reminder_web"
version = "0.1.0" version = "0.1.2"
authors = ["jellywx <judesouthworth@pm.me>"] authors = ["jellywx <judesouthworth@pm.me>"]
edition = "2018" edition = "2018"
[dependencies] [dependencies]
rocket = { git = "https://github.com/SergioBenitez/Rocket", branch = "master", features = ["tls", "secrets", "json"] } rocket = { git = "https://github.com/SergioBenitez/Rocket", branch = "master", features = ["tls", "secrets", "json"] }
rocket_dyn_templates = { git = "https://github.com/SergioBenitez/Rocket", branch = "master", features = ["tera"] } rocket_dyn_templates = { git = "https://github.com/SergioBenitez/Rocket", branch = "master", features = ["tera"] }
serenity = { version = "0.11.1", default-features = false, features = ["builder", "cache", "client", "gateway", "http", "model", "utils", "rustls_backend"] } serenity = { version = "0.11", default-features = false, features = ["builder", "cache", "client", "gateway", "http", "model", "utils", "rustls_backend"] }
oauth2 = "4" oauth2 = "4"
log = "0.4" log = "0.4"
reqwest = "0.11" reqwest = "0.11"
serde = { version = "1.0", features = ["derive"] } serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0" sqlx = { version = "0.7", features = ["runtime-tokio-rustls", "macros", "mysql", "chrono", "json"] }
sqlx = { version = "0.5", features = ["runtime-tokio-rustls", "macros", "mysql", "chrono", "json"] }
chrono = "0.4" chrono = "0.4"
chrono-tz = "0.5" chrono-tz = "0.8"
lazy_static = "1.4.0" lazy_static = "1.4.0"
rand = "0.7" rand = "0.8"
base64 = "0.13" base64 = "0.13"
csv = "1.2"

40
web/src/catchers.rs Normal file
View File

@ -0,0 +1,40 @@
use std::collections::HashMap;
use rocket::serde::json::json;
use rocket_dyn_templates::Template;
use crate::JsonValue;
#[catch(403)]
pub(crate) async fn forbidden() -> Template {
let map: HashMap<String, String> = HashMap::new();
Template::render("errors/403", &map)
}
#[catch(500)]
pub(crate) async fn internal_server_error() -> Template {
let map: HashMap<String, String> = HashMap::new();
Template::render("errors/500", &map)
}
#[catch(401)]
pub(crate) async fn not_authorized() -> Template {
let map: HashMap<String, String> = HashMap::new();
Template::render("errors/401", &map)
}
#[catch(404)]
pub(crate) async fn not_found() -> Template {
let map: HashMap<String, String> = HashMap::new();
Template::render("errors/404", &map)
}
#[catch(413)]
pub(crate) async fn payload_too_large() -> JsonValue {
json!({"error": "Data too large.", "errors": ["Data too large."]})
}
#[catch(422)]
pub(crate) async fn unprocessable_entity() -> JsonValue {
json!({"error": "Invalid request.", "errors": ["Invalid request."]})
}

View File

@ -2,6 +2,7 @@ pub const DISCORD_OAUTH_TOKEN: &'static str = "https://discord.com/api/oauth2/to
pub const DISCORD_OAUTH_AUTHORIZE: &'static str = "https://discord.com/api/oauth2/authorize"; pub const DISCORD_OAUTH_AUTHORIZE: &'static str = "https://discord.com/api/oauth2/authorize";
pub const DISCORD_API: &'static str = "https://discord.com/api"; pub const DISCORD_API: &'static str = "https://discord.com/api";
pub const MAX_NAME_LENGTH: usize = 100;
pub const MAX_CONTENT_LENGTH: usize = 2000; pub const MAX_CONTENT_LENGTH: usize = 2000;
pub const MAX_EMBED_DESCRIPTION_LENGTH: usize = 4096; pub const MAX_EMBED_DESCRIPTION_LENGTH: usize = 4096;
pub const MAX_EMBED_TITLE_LENGTH: usize = 256; pub const MAX_EMBED_TITLE_LENGTH: usize = 256;
@ -26,16 +27,12 @@ use serenity::model::prelude::AttachmentType;
lazy_static! { lazy_static! {
pub static ref DEFAULT_AVATAR: AttachmentType<'static> = ( pub static ref DEFAULT_AVATAR: AttachmentType<'static> = (
include_bytes!(concat!( include_bytes!(concat!(env!("CARGO_MANIFEST_DIR"), "/../assets/webhook.jpg")) as &[u8],
env!("CARGO_MANIFEST_DIR"), "webhook.jpg",
"/../assets/",
env!("WEBHOOK_AVATAR", "WEBHOOK_AVATAR not provided for compilation")
)) as &[u8],
env!("WEBHOOK_AVATAR"),
) )
.into(); .into();
pub static ref SUBSCRIPTION_ROLES: HashSet<u64> = HashSet::from_iter( pub static ref SUBSCRIPTION_ROLES: HashSet<u64> = HashSet::from_iter(
env::var("SUBSCRIPTION_ROLES") env::var("PATREON_ROLE_ID")
.map(|var| var .map(|var| var
.split(',') .split(',')
.filter_map(|item| { item.parse::<u64>().ok() }) .filter_map(|item| { item.parse::<u64>().ok() })
@ -43,7 +40,7 @@ lazy_static! {
.unwrap_or_else(|_| Vec::new()) .unwrap_or_else(|_| Vec::new())
); );
pub static ref CNC_GUILD: Option<u64> = pub static ref CNC_GUILD: Option<u64> =
env::var("CNC_GUILD").map(|var| var.parse::<u64>().ok()).ok().flatten(); env::var("PATREON_GUILD_ID").map(|var| var.parse::<u64>().ok()).ok().flatten();
pub static ref MIN_INTERVAL: u32 = env::var("MIN_INTERVAL") pub static ref MIN_INTERVAL: u32 = env::var("MIN_INTERVAL")
.ok() .ok()
.map(|inner| inner.parse::<u32>().ok()) .map(|inner| inner.parse::<u32>().ok())

1
web/src/guards/mod.rs Normal file
View File

@ -0,0 +1 @@
pub(crate) mod transaction;

View File

@ -0,0 +1,44 @@
use rocket::{
http::Status,
request::{FromRequest, Outcome},
Request, State,
};
use sqlx::Pool;
use crate::Database;
pub struct Transaction<'a>(sqlx::Transaction<'a, Database>);
impl Transaction<'_> {
pub fn executor(&mut self) -> impl sqlx::Executor<'_, Database = Database> {
&mut *(self.0)
}
pub async fn commit(self) -> Result<(), sqlx::Error> {
self.0.commit().await
}
}
#[derive(Debug)]
pub enum TransactionError {
Error(sqlx::Error),
Missing,
}
#[rocket::async_trait]
impl<'r> FromRequest<'r> for Transaction<'r> {
type Error = TransactionError;
async fn from_request(request: &'r Request<'_>) -> Outcome<Self, Self::Error> {
match request.guard::<&State<Pool<Database>>>().await {
Outcome::Success(pool) => match pool.begin().await {
Ok(transaction) => Outcome::Success(Transaction(transaction)),
Err(e) => {
Outcome::Failure((Status::InternalServerError, TransactionError::Error(e)))
}
},
Outcome::Failure(e) => Outcome::Failure((e.0, TransactionError::Missing)),
Outcome::Forward(f) => Outcome::Forward(f),
}
}
}

View File

@ -4,13 +4,16 @@ extern crate rocket;
mod consts; mod consts;
#[macro_use] #[macro_use]
mod macros; mod macros;
mod catchers;
mod guards;
mod routes; mod routes;
use std::{collections::HashMap, env}; use std::{env, path::Path};
use oauth2::{basic::BasicClient, AuthUrl, ClientId, ClientSecret, RedirectUrl, TokenUrl}; use oauth2::{basic::BasicClient, AuthUrl, ClientId, ClientSecret, RedirectUrl, TokenUrl};
use rocket::{ use rocket::{
fs::FileServer, fs::FileServer,
http::CookieJar,
serde::json::{json, Value as JsonValue}, serde::json::{json, Value as JsonValue},
tokio::sync::broadcast::Sender, tokio::sync::broadcast::Sender,
}; };
@ -32,50 +35,20 @@ enum Error {
Serenity(serenity::Error), Serenity(serenity::Error),
} }
#[catch(401)]
async fn not_authorized() -> Template {
let map: HashMap<String, String> = HashMap::new();
Template::render("errors/401", &map)
}
#[catch(403)]
async fn forbidden() -> Template {
let map: HashMap<String, String> = HashMap::new();
Template::render("errors/403", &map)
}
#[catch(404)]
async fn not_found() -> Template {
let map: HashMap<String, String> = HashMap::new();
Template::render("errors/404", &map)
}
#[catch(413)]
async fn payload_too_large() -> JsonValue {
json!({"error": "Data too large.", "errors": ["Data too large."]})
}
#[catch(422)]
async fn unprocessable_entity() -> JsonValue {
json!({"error": "Invalid request.", "errors": ["Invalid request."]})
}
#[catch(500)]
async fn internal_server_error() -> Template {
let map: HashMap<String, String> = HashMap::new();
Template::render("errors/500", &map)
}
pub async fn initialize( pub async fn initialize(
kill_channel: Sender<()>, kill_channel: Sender<()>,
serenity_context: Context, serenity_context: Context,
db_pool: Pool<Database>, db_pool: Pool<Database>,
) -> Result<(), Box<dyn std::error::Error>> { ) -> Result<(), Box<dyn std::error::Error>> {
info!("Checking environment variables..."); info!("Checking environment variables...");
env::var("OAUTH2_CLIENT_ID").expect("`OAUTH2_CLIENT_ID' not supplied");
env::var("OAUTH2_CLIENT_SECRET").expect("`OAUTH2_CLIENT_SECRET' not supplied"); if env::var("OFFLINE").map_or(true, |v| v != "1") {
env::var("OAUTH2_DISCORD_CALLBACK").expect("`OAUTH2_DISCORD_CALLBACK' not supplied"); env::var("OAUTH2_CLIENT_ID").expect("`OAUTH2_CLIENT_ID' not supplied");
env::var("PATREON_GUILD_ID").expect("`PATREON_GUILD' not supplied"); env::var("OAUTH2_CLIENT_SECRET").expect("`OAUTH2_CLIENT_SECRET' not supplied");
env::var("OAUTH2_DISCORD_CALLBACK").expect("`OAUTH2_DISCORD_CALLBACK' not supplied");
env::var("PATREON_GUILD_ID").expect("`PATREON_GUILD_ID' not supplied");
}
info!("Done!"); info!("Done!");
let oauth2_client = BasicClient::new( let oauth2_client = BasicClient::new(
@ -88,24 +61,27 @@ pub async fn initialize(
let reqwest_client = reqwest::Client::new(); let reqwest_client = reqwest::Client::new();
let static_path =
if Path::new("web/static").exists() { "web/static" } else { "/lib/reminder-rs/static" };
rocket::build() rocket::build()
.attach(Template::fairing()) .attach(Template::fairing())
.register( .register(
"/", "/",
catchers![ catchers![
not_authorized, catchers::not_authorized,
forbidden, catchers::forbidden,
not_found, catchers::not_found,
internal_server_error, catchers::internal_server_error,
unprocessable_entity, catchers::unprocessable_entity,
payload_too_large, catchers::payload_too_large,
], ],
) )
.manage(oauth2_client) .manage(oauth2_client)
.manage(reqwest_client) .manage(reqwest_client)
.manage(serenity_context) .manage(serenity_context)
.manage(db_pool) .manage(db_pool)
.mount("/static", FileServer::from(concat!(env!("CARGO_MANIFEST_DIR"), "/static"))) .mount("/static", FileServer::from(static_path))
.mount( .mount(
"/", "/",
routes![ routes![
@ -113,7 +89,8 @@ pub async fn initialize(
routes::cookies, routes::cookies,
routes::privacy, routes::privacy,
routes::terms, routes::terms,
routes::return_to_same_site routes::return_to_same_site,
routes::report::report_error,
], ],
) )
.mount( .mount(
@ -131,27 +108,40 @@ pub async fn initialize(
routes::help_iemanager, routes::help_iemanager,
], ],
) )
.mount("/login", routes![routes::login::discord_login, routes::login::discord_callback]) .mount(
"/login",
routes![
routes::login::discord_login,
routes::login::discord_logout,
routes::login::discord_callback
],
)
.mount( .mount(
"/dashboard", "/dashboard",
routes![ routes![
routes::dashboard::dashboard, routes::dashboard::dashboard,
routes::dashboard::dashboard_home, routes::dashboard::dashboard_home,
routes::dashboard::user::get_user_info, routes::dashboard::api::user::get_user_info,
routes::dashboard::user::update_user_info, routes::dashboard::api::user::update_user_info,
routes::dashboard::user::get_user_guilds, routes::dashboard::api::user::get_user_guilds,
routes::dashboard::guild::get_guild_patreon, routes::dashboard::api::guild::get_guild_info,
routes::dashboard::guild::get_guild_channels, routes::dashboard::api::guild::get_guild_channels,
routes::dashboard::guild::get_guild_roles, routes::dashboard::api::guild::get_guild_roles,
routes::dashboard::guild::get_reminder_templates, routes::dashboard::api::guild::get_reminder_templates,
routes::dashboard::guild::create_reminder_template, routes::dashboard::api::guild::create_reminder_template,
routes::dashboard::guild::delete_reminder_template, routes::dashboard::api::guild::delete_reminder_template,
routes::dashboard::guild::create_reminder, routes::dashboard::api::guild::create_guild_reminder,
routes::dashboard::guild::get_reminders, routes::dashboard::api::guild::get_reminders,
routes::dashboard::guild::edit_reminder, routes::dashboard::api::guild::edit_reminder,
routes::dashboard::guild::delete_reminder, routes::dashboard::api::guild::delete_reminder,
routes::dashboard::export::export_reminders,
routes::dashboard::export::export_reminder_templates,
routes::dashboard::export::export_todos,
routes::dashboard::export::import_reminders,
routes::dashboard::export::import_todos,
], ],
) )
.mount("/admin", routes![routes::admin::admin_dashboard_home, routes::admin::bot_data])
.launch() .launch()
.await?; .await?;
@ -168,6 +158,8 @@ pub async fn initialize(
} }
pub async fn check_subscription(cache_http: impl CacheHttp, user_id: impl Into<UserId>) -> bool { pub async fn check_subscription(cache_http: impl CacheHttp, user_id: impl Into<UserId>) -> bool {
offline!(true);
if let Some(subscription_guild) = *CNC_GUILD { if let Some(subscription_guild) = *CNC_GUILD {
let guild_member = GuildId(subscription_guild).member(cache_http, user_id).await; let guild_member = GuildId(subscription_guild).member(cache_http, user_id).await;
@ -189,6 +181,8 @@ pub async fn check_guild_subscription(
cache_http: impl CacheHttp, cache_http: impl CacheHttp,
guild_id: impl Into<GuildId>, guild_id: impl Into<GuildId>,
) -> bool { ) -> bool {
offline!(true);
if let Some(guild) = cache_http.cache().unwrap().guild(guild_id) { if let Some(guild) = cache_http.cache().unwrap().guild(guild_id) {
let owner = guild.owner_id; let owner = guild.owner_id;
@ -197,3 +191,65 @@ pub async fn check_guild_subscription(
false false
} }
} }
pub async fn check_authorization(
cookies: &CookieJar<'_>,
ctx: &Context,
guild: u64,
) -> Result<(), JsonValue> {
let user_id = cookies.get_private("userid").map(|c| c.value().parse::<u64>().ok()).flatten();
if std::env::var("OFFLINE").map_or(true, |v| v != "1") {
match user_id {
Some(user_id) => {
let admin_id = std::env::var("ADMIN_ID")
.map_or(false, |u| u.parse::<u64>().map_or(false, |u| u == user_id));
if admin_id {
return Ok(());
}
match GuildId(guild).to_guild_cached(ctx) {
Some(guild) => {
let member_res = guild.member(ctx, UserId(user_id)).await;
match member_res {
Err(_) => {
return Err(json!({"error": "User not in guild"}));
}
Ok(member) => {
let permissions_res = member.permissions(ctx);
match permissions_res {
Err(_) => {
return Err(json!({"error": "Couldn't fetch permissions"}));
}
Ok(permissions) => {
if !(permissions.manage_messages()
|| permissions.manage_guild()
|| permissions.administrator())
{
return Err(json!({"error": "Incorrect permissions"}));
}
}
}
}
}
}
None => {
return Err(json!({"error": "Bot not in guild"}));
}
}
}
None => {
return Err(json!({"error": "User not authorized"}));
}
}
}
Ok(())
}

View File

@ -1,7 +1,15 @@
macro_rules! offline {
($field:expr) => {
if std::env::var("OFFLINE").map_or(false, |v| v == "1") {
return $field;
}
};
}
macro_rules! check_length { macro_rules! check_length {
($max:ident, $field:expr) => { ($max:ident, $field:expr) => {
if $field.len() > $max { if $field.len() > $max {
return json!({ "error": format!("{} exceeded", stringify!($max)) }); return Err(json!({ "error": format!("{} exceeded", stringify!($max)) }));
} }
}; };
($max:ident, $field:expr, $($fields:expr),+) => { ($max:ident, $field:expr, $($fields:expr),+) => {
@ -25,7 +33,7 @@ macro_rules! check_length_opt {
macro_rules! check_url { macro_rules! check_url {
($field:expr) => { ($field:expr) => {
if !($field.starts_with("http://") || $field.starts_with("https://")) { if !($field.starts_with("http://") || $field.starts_with("https://")) {
return json!({ "error": "URL invalid" }); return Err(json!({ "error": "URL invalid" }));
} }
}; };
($field:expr, $($fields:expr),+) => { ($field:expr, $($fields:expr),+) => {
@ -46,40 +54,6 @@ macro_rules! check_url_opt {
}; };
} }
macro_rules! check_authorization {
($cookies:expr, $ctx:expr, $guild:expr) => {
use serenity::model::id::UserId;
let user_id = $cookies.get_private("userid").map(|c| c.value().parse::<u64>().ok()).flatten();
match user_id {
Some(user_id) => {
match GuildId($guild).to_guild_cached($ctx) {
Some(guild) => {
let member = guild.member($ctx, UserId(user_id)).await;
match member {
Err(_) => {
return json!({"error": "User not in guild"})
}
Ok(_) => {}
}
}
None => {
return json!({"error": "Bot not in guild"})
}
}
}
None => {
return json!({"error": "User not authorized"});
}
}
}
}
macro_rules! update_field { macro_rules! update_field {
($pool:expr, $error:ident, $reminder:ident.[$field:ident]) => { ($pool:expr, $error:ident, $reminder:ident.[$field:ident]) => {
if let Some(value) = &$reminder.$field { if let Some(value) = &$reminder.$field {
@ -117,3 +91,9 @@ macro_rules! update_field {
update_field!($pool, $error, $reminder.[$($fields),+]); update_field!($pool, $error, $reminder.[$($fields),+]);
}; };
} }
macro_rules! json_err {
($message:expr) => {
Err(json!({ "error": $message }))
};
}

218
web/src/routes/admin.rs Normal file
View File

@ -0,0 +1,218 @@
use std::{collections::HashMap, env};
use chrono::{DateTime, Utc};
use rocket::{
http::{CookieJar, Status},
serde::json::json,
State,
};
use rocket_dyn_templates::Template;
use serde::Serialize;
use sqlx::{MySql, Pool};
use crate::routes::JsonResult;
fn is_admin(cookies: &CookieJar<'_>) -> bool {
cookies
.get_private("userid")
.map_or(false, |cookie| Some(cookie.value().to_string()) == env::var("ADMIN_ID").ok())
}
#[get("/")]
pub async fn admin_dashboard_home(cookies: &CookieJar<'_>) -> Result<Template, Status> {
if let Some(cookie) = cookies.get_private("userid") {
let map: HashMap<&str, String> = HashMap::new();
if Some(cookie.value().to_string()) == env::var("ADMIN_ID").ok() {
Ok(Template::render("admin_dashboard", &map))
} else {
Err(Status::Forbidden)
}
} else {
Err(Status::Unauthorized)
}
}
#[derive(Serialize)]
struct TimeFrame {
time_key: DateTime<Utc>,
count: i64,
}
#[get("/data")]
pub async fn bot_data(cookies: &CookieJar<'_>, pool: &State<Pool<MySql>>) -> JsonResult {
if !is_admin(cookies) {
return json_err!("Not authorized");
}
let backlog = sqlx::query!(
"SELECT COUNT(1) AS backlog FROM reminders WHERE `utc_time` < NOW() AND enabled = 1 AND `status` = 'pending'"
)
.fetch_one(pool.inner())
.await
.unwrap();
let schedule_once = sqlx::query_as_unchecked!(
TimeFrame,
"SELECT
FROM_UNIXTIME(FLOOR(UNIX_TIMESTAMP(`utc_time`) / 300) * 300) AS `time_key`,
COUNT(1) AS `count`
FROM reminders
WHERE
`utc_time` < DATE_ADD(NOW(), INTERVAL 1 DAY) AND
`utc_time` >= NOW() AND
`enabled` = 1 AND
`status` = 'pending' AND
`interval_seconds` IS NULL AND
`interval_months` IS NULL AND
`interval_days` IS NULL
GROUP BY `time_key`
ORDER BY `time_key`"
)
.fetch_all(pool.inner())
.await
.unwrap();
let schedule_interval = sqlx::query_as_unchecked!(
TimeFrame,
"SELECT
FROM_UNIXTIME(FLOOR(UNIX_TIMESTAMP(`utc_time`) / 300) * 300) AS `time_key`,
COUNT(1) AS `count`
FROM reminders
WHERE
`utc_time` < DATE_ADD(NOW(), INTERVAL 1 DAY) AND
`utc_time` >= NOW() AND
`status` = 'pending' AND
`enabled` = 1 AND (
`interval_seconds` IS NOT NULL OR
`interval_months` IS NOT NULL OR
`interval_days` IS NOT NULL
)
GROUP BY `time_key`
ORDER BY `time_key`"
)
.fetch_all(pool.inner())
.await
.unwrap();
let schedule_once_long = sqlx::query_as_unchecked!(
TimeFrame,
"SELECT
FROM_UNIXTIME(FLOOR(UNIX_TIMESTAMP(`utc_time`) / 86400) * 86400) AS `time_key`,
COUNT(1) AS `count`
FROM reminders
WHERE
`utc_time` < DATE_ADD(NOW(), INTERVAL 31 DAY) AND
`utc_time` >= NOW() AND
`enabled` = 1 AND
`status` = 'pending' AND
`interval_seconds` IS NULL AND
`interval_months` IS NULL AND
`interval_days` IS NULL
GROUP BY `time_key`
ORDER BY `time_key`"
)
.fetch_all(pool.inner())
.await
.unwrap();
let schedule_interval_long = sqlx::query_as_unchecked!(
TimeFrame,
"SELECT
FROM_UNIXTIME(FLOOR(UNIX_TIMESTAMP(`utc_time`) / 86400) * 86400) AS `time_key`,
COUNT(1) AS `count`
FROM reminders
WHERE
`utc_time` < DATE_ADD(NOW(), INTERVAL 31 DAY) AND
`utc_time` >= NOW() AND
`status` = 'pending' AND
`enabled` = 1 AND (
`interval_seconds` IS NOT NULL OR
`interval_months` IS NOT NULL OR
`interval_days` IS NOT NULL
)
GROUP BY `time_key`
ORDER BY `time_key`"
)
.fetch_all(pool.inner())
.await
.unwrap();
let history = sqlx::query_as_unchecked!(
TimeFrame,
"SELECT
FROM_UNIXTIME(FLOOR(UNIX_TIMESTAMP(`utc_time`) / 86400) * 86400) AS `time_key`,
COUNT(1) AS `count`
FROM stat
WHERE
`utc_time` > DATE_SUB(NOW(), INTERVAL 31 DAY) AND
`type` = 'reminder_sent'
GROUP BY `time_key`
ORDER BY `time_key`"
)
.fetch_all(pool.inner())
.await
.unwrap();
let history_failed = sqlx::query_as_unchecked!(
TimeFrame,
"SELECT
FROM_UNIXTIME(FLOOR(UNIX_TIMESTAMP(`utc_time`) / 86400) * 86400) AS `time_key`,
COUNT(1) AS `count`
FROM stat
WHERE
`utc_time` > DATE_SUB(NOW(), INTERVAL 31 DAY) AND
`type` = 'reminder_failed'
GROUP BY `time_key`
ORDER BY `time_key`"
)
.fetch_all(pool.inner())
.await
.unwrap();
let interval_count = sqlx::query!(
"SELECT COUNT(1) AS count
FROM reminders
WHERE
`status` = 'pending' AND (
`interval_seconds` IS NOT NULL OR
`interval_months` IS NOT NULL OR
`interval_days` IS NOT NULL
)"
)
.fetch_one(pool.inner())
.await
.unwrap();
let reminder_count = sqlx::query!(
"SELECT COUNT(1) AS count
FROM reminders
WHERE
`status` = 'pending' AND
`interval_seconds` IS NULL AND
`interval_months` IS NULL AND
`interval_days` IS NULL"
)
.fetch_one(pool.inner())
.await
.unwrap();
Ok(json!({
"backlog": backlog.backlog,
"scheduleShort": {
"once": schedule_once,
"interval": schedule_interval
},
"scheduleLong": {
"once": schedule_once_long,
"interval": schedule_interval_long,
},
"historyLong": {
"sent": history,
"failed": history_failed,
},
"count": {
"reminders": reminder_count.count,
"intervals": interval_count.count,
}
}))
}

View File

@ -0,0 +1,61 @@
use rocket::{http::CookieJar, serde::json::json, State};
use serde::Serialize;
use serenity::{
client::Context,
model::{
channel::GuildChannel,
id::{ChannelId, GuildId},
},
};
use crate::{check_authorization, routes::JsonResult};
#[derive(Serialize)]
struct ChannelInfo {
id: String,
name: String,
webhook_avatar: Option<String>,
webhook_name: Option<String>,
}
#[get("/api/guild/<id>/channels")]
pub async fn get_guild_channels(
id: u64,
cookies: &CookieJar<'_>,
ctx: &State<Context>,
) -> JsonResult {
offline!(Ok(json!(vec![ChannelInfo {
name: "general".to_string(),
id: "1".to_string(),
webhook_avatar: None,
webhook_name: None,
}])));
check_authorization(cookies, ctx.inner(), id).await?;
match GuildId(id).to_guild_cached(ctx.inner()) {
Some(guild) => {
let mut channels = guild
.channels
.iter()
.filter_map(|(id, channel)| channel.to_owned().guild().map(|c| (id.to_owned(), c)))
.filter(|(_, channel)| channel.is_text_based())
.collect::<Vec<(ChannelId, GuildChannel)>>();
channels.sort_by(|(_, c1), (_, c2)| c1.position.cmp(&c2.position));
let channel_info = channels
.iter()
.map(|(channel_id, channel)| ChannelInfo {
name: channel.name.to_string(),
id: channel_id.to_string(),
webhook_avatar: None,
webhook_name: None,
})
.collect::<Vec<ChannelInfo>>();
Ok(json!(channel_info))
}
None => json_err!("Bot not in guild"),
}
}

View File

@ -0,0 +1,42 @@
mod channels;
mod reminders;
mod roles;
mod templates;
use std::env;
pub use channels::*;
pub use reminders::*;
use rocket::{http::CookieJar, serde::json::json, State};
pub use roles::*;
use serenity::{
client::Context,
model::id::{GuildId, RoleId},
};
pub use templates::*;
use crate::{check_authorization, routes::JsonResult};
#[get("/api/guild/<id>")]
pub async fn get_guild_info(id: u64, cookies: &CookieJar<'_>, ctx: &State<Context>) -> JsonResult {
offline!(Ok(json!({ "patreon": true, "name": "Guild" })));
check_authorization(cookies, ctx.inner(), id).await?;
match GuildId(id).to_guild_cached(ctx.inner()) {
Some(guild) => {
let member_res = GuildId(env::var("PATREON_GUILD_ID").unwrap().parse().unwrap())
.member(&ctx.inner(), guild.owner_id)
.await;
let patreon = member_res.map_or(false, |member| {
member
.roles
.contains(&RoleId(env::var("PATREON_ROLE_ID").unwrap().parse().unwrap()))
});
Ok(json!({ "patreon": patreon, "name": guild.name }))
}
None => json_err!("Bot not in guild"),
}
}

View File

@ -0,0 +1,373 @@
use rocket::{
http::CookieJar,
serde::json::{json, Json},
State,
};
use serenity::{
client::Context,
model::id::{ChannelId, GuildId, UserId},
};
use sqlx::{MySql, Pool};
use crate::{
check_authorization, check_guild_subscription, check_subscription,
consts::MIN_INTERVAL,
guards::transaction::Transaction,
routes::{
dashboard::{
create_database_channel, create_reminder, DeleteReminder, PatchReminder, Reminder,
},
JsonResult,
},
Database,
};
#[post("/api/guild/<id>/reminders", data = "<reminder>")]
pub async fn create_guild_reminder(
id: u64,
reminder: Json<Reminder>,
cookies: &CookieJar<'_>,
ctx: &State<Context>,
mut transaction: Transaction<'_>,
) -> JsonResult {
check_authorization(cookies, ctx.inner(), id).await?;
let user_id =
cookies.get_private("userid").map(|c| c.value().parse::<u64>().ok()).flatten().unwrap();
match create_reminder(
ctx.inner(),
&mut transaction,
GuildId(id),
UserId(user_id),
reminder.into_inner(),
)
.await
{
Ok(r) => match transaction.commit().await {
Ok(_) => Ok(r),
Err(e) => {
warn!("Couldn't commit transaction: {:?}", e);
json_err!("Couldn't commit transaction.")
}
},
Err(e) => Err(e),
}
}
#[get("/api/guild/<id>/reminders")]
pub async fn get_reminders(
id: u64,
cookies: &CookieJar<'_>,
ctx: &State<Context>,
pool: &State<Pool<MySql>>,
) -> JsonResult {
check_authorization(cookies, ctx.inner(), id).await?;
let channels_res = GuildId(id).channels(&ctx.inner()).await;
match channels_res {
Ok(channels) => {
let channels = channels
.keys()
.into_iter()
.map(|k| k.as_u64().to_string())
.collect::<Vec<String>>()
.join(",");
sqlx::query_as_unchecked!(
Reminder,
"SELECT
reminders.attachment,
reminders.attachment_name,
reminders.avatar,
channels.channel,
reminders.content,
reminders.embed_author,
reminders.embed_author_url,
reminders.embed_color,
reminders.embed_description,
reminders.embed_footer,
reminders.embed_footer_url,
reminders.embed_image_url,
reminders.embed_thumbnail_url,
reminders.embed_title,
IFNULL(reminders.embed_fields, '[]') AS embed_fields,
reminders.enabled,
reminders.expires,
reminders.interval_seconds,
reminders.interval_days,
reminders.interval_months,
reminders.name,
reminders.restartable,
reminders.tts,
reminders.uid,
reminders.username,
reminders.utc_time
FROM reminders
LEFT JOIN channels ON channels.id = reminders.channel_id
WHERE `status` = 'pending' AND FIND_IN_SET(channels.channel, ?)",
channels
)
.fetch_all(pool.inner())
.await
.map(|r| Ok(json!(r)))
.unwrap_or_else(|e| {
warn!("Failed to complete SQL query: {:?}", e);
json_err!("Could not load reminders")
})
}
Err(e) => {
warn!("Could not fetch channels from {}: {:?}", id, e);
Ok(json!([]))
}
}
}
#[patch("/api/guild/<id>/reminders", data = "<reminder>")]
pub async fn edit_reminder(
id: u64,
reminder: Json<PatchReminder>,
ctx: &State<Context>,
mut transaction: Transaction<'_>,
pool: &State<Pool<Database>>,
cookies: &CookieJar<'_>,
) -> JsonResult {
check_authorization(cookies, ctx.inner(), id).await?;
let mut error = vec![];
let user_id =
cookies.get_private("userid").map(|c| c.value().parse::<u64>().ok()).flatten().unwrap();
if reminder.message_ok() {
update_field!(transaction.executor(), error, reminder.[
content,
embed_author,
embed_description,
embed_footer,
embed_title,
embed_fields,
username
]);
} else {
error.push("Message exceeds limits.".to_string());
}
update_field!(transaction.executor(), error, reminder.[
attachment,
attachment_name,
avatar,
embed_author_url,
embed_color,
embed_footer_url,
embed_image_url,
embed_thumbnail_url,
enabled,
expires,
name,
restartable,
tts,
utc_time
]);
if reminder.interval_days.flatten().is_some()
|| reminder.interval_months.flatten().is_some()
|| reminder.interval_seconds.flatten().is_some()
{
if check_guild_subscription(&ctx.inner(), id).await
|| check_subscription(&ctx.inner(), user_id).await
{
let new_interval_length = match reminder.interval_days {
Some(interval) => interval.unwrap_or(0),
None => sqlx::query!(
"SELECT interval_days AS days FROM reminders WHERE uid = ?",
reminder.uid
)
.fetch_one(transaction.executor())
.await
.map_err(|e| {
warn!("Error updating reminder interval: {:?}", e);
json!({ "reminder": Option::<Reminder>::None, "errors": vec!["Unknown error"] })
})?
.days
.unwrap_or(0),
} * 86400 + match reminder.interval_months {
Some(interval) => interval.unwrap_or(0),
None => sqlx::query!(
"SELECT interval_months AS months FROM reminders WHERE uid = ?",
reminder.uid
)
.fetch_one(transaction.executor())
.await
.map_err(|e| {
warn!("Error updating reminder interval: {:?}", e);
json!({ "reminder": Option::<Reminder>::None, "errors": vec!["Unknown error"] })
})?
.months
.unwrap_or(0),
} * 2592000 + match reminder.interval_seconds {
Some(interval) => interval.unwrap_or(0),
None => sqlx::query!(
"SELECT interval_seconds AS seconds FROM reminders WHERE uid = ?",
reminder.uid
)
.fetch_one(transaction.executor())
.await
.map_err(|e| {
warn!("Error updating reminder interval: {:?}", e);
json!({ "reminder": Option::<Reminder>::None, "errors": vec!["Unknown error"] })
})?
.seconds
.unwrap_or(0),
};
if new_interval_length < *MIN_INTERVAL {
error.push(String::from("New interval is too short."));
} else {
update_field!(transaction.executor(), error, reminder.[
interval_days,
interval_months,
interval_seconds
]);
}
}
}
if reminder.channel > 0 {
let channel = ChannelId(reminder.channel).to_channel_cached(&ctx.inner());
match channel {
Some(channel) => {
let channel_matches_guild = channel.guild().map_or(false, |c| c.guild_id.0 == id);
if !channel_matches_guild {
warn!(
"Error in `edit_reminder`: channel {:?} not found for guild {}",
reminder.channel, id
);
return Err(json!({"error": "Channel not found"}));
}
let channel = create_database_channel(
ctx.inner(),
ChannelId(reminder.channel),
&mut transaction,
)
.await;
if let Err(e) = channel {
warn!("`create_database_channel` returned an error code: {:?}", e);
return Err(
json!({"error": "Failed to configure channel for reminders. Please check the bot permissions"}),
);
}
let channel = channel.unwrap();
match sqlx::query!(
"UPDATE reminders SET channel_id = ? WHERE uid = ?",
channel,
reminder.uid
)
.execute(transaction.executor())
.await
{
Ok(_) => {}
Err(e) => {
warn!("Error setting channel: {:?}", e);
error.push("Couldn't set channel".to_string())
}
}
}
None => {
warn!(
"Error in `edit_reminder`: channel {:?} not found for guild {}",
reminder.channel, id
);
return Err(json!({"error": "Channel not found"}));
}
}
}
if let Err(e) = transaction.commit().await {
warn!("Couldn't commit transaction: {:?}", e);
return json_err!("Couldn't commit transaction");
}
match sqlx::query_as_unchecked!(
Reminder,
"SELECT reminders.attachment,
reminders.attachment_name,
reminders.avatar,
channels.channel,
reminders.content,
reminders.embed_author,
reminders.embed_author_url,
reminders.embed_color,
reminders.embed_description,
reminders.embed_footer,
reminders.embed_footer_url,
reminders.embed_image_url,
reminders.embed_thumbnail_url,
reminders.embed_title,
reminders.embed_fields,
reminders.enabled,
reminders.expires,
reminders.interval_seconds,
reminders.interval_days,
reminders.interval_months,
reminders.name,
reminders.restartable,
reminders.tts,
reminders.uid,
reminders.username,
reminders.utc_time
FROM reminders
LEFT JOIN channels ON channels.id = reminders.channel_id
WHERE uid = ?",
reminder.uid
)
.fetch_one(pool.inner())
.await
{
Ok(reminder) => Ok(json!({"reminder": reminder, "errors": error})),
Err(e) => {
warn!("Error exiting `edit_reminder': {:?}", e);
Err(json!({"reminder": Option::<Reminder>::None, "errors": vec!["Unknown error"]}))
}
}
}
#[delete("/api/guild/<id>/reminders", data = "<reminder>")]
pub async fn delete_reminder(
cookies: &CookieJar<'_>,
id: u64,
reminder: Json<DeleteReminder>,
ctx: &State<Context>,
pool: &State<Pool<MySql>>,
) -> JsonResult {
check_authorization(cookies, ctx.inner(), id).await?;
match sqlx::query!("UPDATE reminders SET `status` = 'deleted' WHERE uid = ?", reminder.uid)
.execute(pool.inner())
.await
{
Ok(_) => Ok(json!({})),
Err(e) => {
warn!("Error in `delete_reminder`: {:?}", e);
Err(json!({"error": "Could not delete reminder"}))
}
}
}

View File

@ -0,0 +1,35 @@
use rocket::{http::CookieJar, serde::json::json, State};
use serde::Serialize;
use serenity::client::Context;
use crate::{check_authorization, routes::JsonResult};
#[derive(Serialize)]
struct RoleInfo {
id: String,
name: String,
}
#[get("/api/guild/<id>/roles")]
pub async fn get_guild_roles(id: u64, cookies: &CookieJar<'_>, ctx: &State<Context>) -> JsonResult {
offline!(Ok(json!(vec![RoleInfo { name: "@everyone".to_string(), id: "1".to_string() }])));
check_authorization(cookies, ctx.inner(), id).await?;
let roles_res = ctx.cache.guild_roles(id);
match roles_res {
Some(roles) => {
let roles = roles
.iter()
.map(|(_, r)| RoleInfo { id: r.id.to_string(), name: r.name.to_string() })
.collect::<Vec<RoleInfo>>();
Ok(json!(roles))
}
None => {
warn!("Could not fetch roles from {}", id);
json_err!("Could not get roles")
}
}
}

View File

@ -0,0 +1,181 @@
use rocket::{
http::CookieJar,
serde::json::{json, Json},
State,
};
use serenity::client::Context;
use sqlx::{MySql, Pool};
use crate::{
check_authorization,
consts::{
MAX_CONTENT_LENGTH, MAX_EMBED_AUTHOR_LENGTH, MAX_EMBED_DESCRIPTION_LENGTH,
MAX_EMBED_FIELDS, MAX_EMBED_FIELD_TITLE_LENGTH, MAX_EMBED_FIELD_VALUE_LENGTH,
MAX_EMBED_FOOTER_LENGTH, MAX_EMBED_TITLE_LENGTH, MAX_URL_LENGTH, MAX_USERNAME_LENGTH,
},
routes::{
dashboard::{template_name_default, DeleteReminderTemplate, ReminderTemplate},
JsonResult,
},
};
#[get("/api/guild/<id>/templates")]
pub async fn get_reminder_templates(
id: u64,
cookies: &CookieJar<'_>,
ctx: &State<Context>,
pool: &State<Pool<MySql>>,
) -> JsonResult {
check_authorization(cookies, ctx.inner(), id).await?;
match sqlx::query_as_unchecked!(
ReminderTemplate,
"SELECT * FROM reminder_template WHERE guild_id = (SELECT id FROM guilds WHERE guild = ?)",
id
)
.fetch_all(pool.inner())
.await
{
Ok(templates) => Ok(json!(templates)),
Err(e) => {
warn!("Could not fetch templates from {}: {:?}", id, e);
json_err!("Could not get templates")
}
}
}
#[post("/api/guild/<id>/templates", data = "<reminder_template>")]
pub async fn create_reminder_template(
id: u64,
reminder_template: Json<ReminderTemplate>,
cookies: &CookieJar<'_>,
ctx: &State<Context>,
pool: &State<Pool<MySql>>,
) -> JsonResult {
check_authorization(cookies, ctx.inner(), id).await?;
// validate lengths
check_length!(MAX_CONTENT_LENGTH, reminder_template.content);
check_length!(MAX_EMBED_DESCRIPTION_LENGTH, reminder_template.embed_description);
check_length!(MAX_EMBED_TITLE_LENGTH, reminder_template.embed_title);
check_length!(MAX_EMBED_AUTHOR_LENGTH, reminder_template.embed_author);
check_length!(MAX_EMBED_FOOTER_LENGTH, reminder_template.embed_footer);
check_length_opt!(MAX_EMBED_FIELDS, reminder_template.embed_fields);
if let Some(fields) = &reminder_template.embed_fields {
for field in &fields.0 {
check_length!(MAX_EMBED_FIELD_VALUE_LENGTH, field.value);
check_length!(MAX_EMBED_FIELD_TITLE_LENGTH, field.title);
}
}
check_length_opt!(MAX_USERNAME_LENGTH, reminder_template.username);
check_length_opt!(
MAX_URL_LENGTH,
reminder_template.embed_footer_url,
reminder_template.embed_thumbnail_url,
reminder_template.embed_author_url,
reminder_template.embed_image_url,
reminder_template.avatar
);
// validate urls
check_url_opt!(
reminder_template.embed_footer_url,
reminder_template.embed_thumbnail_url,
reminder_template.embed_author_url,
reminder_template.embed_image_url,
reminder_template.avatar
);
let name = if reminder_template.name.is_empty() {
template_name_default()
} else {
reminder_template.name.clone()
};
match sqlx::query!(
"INSERT INTO reminder_template
(guild_id,
name,
attachment,
attachment_name,
avatar,
content,
embed_author,
embed_author_url,
embed_color,
embed_description,
embed_footer,
embed_footer_url,
embed_image_url,
embed_thumbnail_url,
embed_title,
embed_fields,
interval_seconds,
interval_days,
interval_months,
tts,
username
) VALUES ((SELECT id FROM guilds WHERE guild = ?), ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?,
?, ?, ?, ?, ?, ?, ?)",
id,
name,
reminder_template.attachment,
reminder_template.attachment_name,
reminder_template.avatar,
reminder_template.content,
reminder_template.embed_author,
reminder_template.embed_author_url,
reminder_template.embed_color,
reminder_template.embed_description,
reminder_template.embed_footer,
reminder_template.embed_footer_url,
reminder_template.embed_image_url,
reminder_template.embed_thumbnail_url,
reminder_template.embed_title,
reminder_template.embed_fields,
reminder_template.interval_seconds,
reminder_template.interval_days,
reminder_template.interval_months,
reminder_template.tts,
reminder_template.username,
)
.fetch_all(pool.inner())
.await
{
Ok(_) => Ok(json!({})),
Err(e) => {
warn!("Could not create template for {}: {:?}", id, e);
json_err!("Could not create template")
}
}
}
#[delete("/api/guild/<id>/templates", data = "<delete_reminder_template>")]
pub async fn delete_reminder_template(
id: u64,
delete_reminder_template: Json<DeleteReminderTemplate>,
cookies: &CookieJar<'_>,
ctx: &State<Context>,
pool: &State<Pool<MySql>>,
) -> JsonResult {
check_authorization(cookies, ctx.inner(), id).await?;
match sqlx::query!(
"DELETE FROM reminder_template WHERE guild_id = (SELECT id FROM guilds WHERE guild = ?) AND id = ?",
id, delete_reminder_template.id
)
.fetch_all(pool.inner())
.await
{
Ok(_) => {
Ok(json!({}))
}
Err(e) => {
warn!("Could not delete template from {}: {:?}", id, e);
json_err!("Could not delete template")
}
}
}

View File

@ -0,0 +1,2 @@
pub mod guild;
pub mod user;

View File

@ -1,36 +1,14 @@
use std::env;
use chrono_tz::Tz;
use reqwest::Client; use reqwest::Client;
use rocket::{ use rocket::{
http::CookieJar, http::CookieJar,
serde::json::{json, Json, Value as JsonValue}, serde::json::{json, Value as JsonValue},
State, State,
}; };
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use serenity::{ use serenity::model::{id::GuildId, permissions::Permissions};
client::Context,
model::{
id::{GuildId, RoleId},
permissions::Permissions,
},
};
use sqlx::{MySql, Pool};
use crate::consts::DISCORD_API; use crate::consts::DISCORD_API;
#[derive(Serialize)]
struct UserInfo {
name: String,
patreon: bool,
timezone: Option<String>,
}
#[derive(Deserialize)]
pub struct UpdateUser {
timezone: String,
}
#[derive(Serialize)] #[derive(Serialize)]
struct GuildInfo { struct GuildInfo {
id: String, id: String,
@ -38,9 +16,8 @@ struct GuildInfo {
} }
#[derive(Deserialize)] #[derive(Deserialize)]
pub struct PartialGuild { struct PartialGuild {
pub id: GuildId, pub id: GuildId,
pub icon: Option<String>,
pub name: String, pub name: String,
#[serde(default)] #[serde(default)]
pub owner: bool, pub owner: bool,
@ -48,71 +25,10 @@ pub struct PartialGuild {
pub permissions: Option<String>, pub permissions: Option<String>,
} }
#[get("/api/user")]
pub async fn get_user_info(
cookies: &CookieJar<'_>,
ctx: &State<Context>,
pool: &State<Pool<MySql>>,
) -> JsonValue {
if let Some(user_id) =
cookies.get_private("userid").map(|u| u.value().parse::<u64>().ok()).flatten()
{
let member_res = GuildId(env::var("PATREON_GUILD_ID").unwrap().parse().unwrap())
.member(&ctx.inner(), user_id)
.await;
let timezone = sqlx::query!("SELECT timezone FROM users WHERE user = ?", user_id)
.fetch_one(pool.inner())
.await
.map_or(None, |q| Some(q.timezone));
let user_info = UserInfo {
name: cookies
.get_private("username")
.map_or("DiscordUser#0000".to_string(), |c| c.value().to_string()),
patreon: member_res.map_or(false, |member| {
member
.roles
.contains(&RoleId(env::var("PATREON_ROLE_ID").unwrap().parse().unwrap()))
}),
timezone,
};
json!(user_info)
} else {
json!({"error": "Not authorized"})
}
}
#[patch("/api/user", data = "<user>")]
pub async fn update_user_info(
cookies: &CookieJar<'_>,
user: Json<UpdateUser>,
pool: &State<Pool<MySql>>,
) -> JsonValue {
if let Some(user_id) =
cookies.get_private("userid").map(|u| u.value().parse::<u64>().ok()).flatten()
{
if user.timezone.parse::<Tz>().is_ok() {
let _ = sqlx::query!(
"UPDATE users SET timezone = ? WHERE user = ?",
user.timezone,
user_id,
)
.execute(pool.inner())
.await;
json!({})
} else {
json!({"error": "Timezone not recognized"})
}
} else {
json!({"error": "Not authorized"})
}
}
#[get("/api/user/guilds")] #[get("/api/user/guilds")]
pub async fn get_user_guilds(cookies: &CookieJar<'_>, reqwest_client: &State<Client>) -> JsonValue { pub async fn get_user_guilds(cookies: &CookieJar<'_>, reqwest_client: &State<Client>) -> JsonValue {
offline!(json!(vec![GuildInfo { id: "1".to_string(), name: "Guild".to_string() }]));
if let Some(access_token) = cookies.get_private("access_token") { if let Some(access_token) = cookies.get_private("access_token") {
let request_res = reqwest_client let request_res = reqwest_client
.get(format!("{}/users/@me/guilds", DISCORD_API)) .get(format!("{}/users/@me/guilds", DISCORD_API))

View File

@ -0,0 +1,97 @@
mod guilds;
use std::env;
use chrono_tz::Tz;
pub use guilds::*;
use rocket::{
http::CookieJar,
serde::json::{json, Json, Value as JsonValue},
State,
};
use serde::{Deserialize, Serialize};
use serenity::{
client::Context,
model::id::{GuildId, RoleId},
};
use sqlx::{MySql, Pool};
#[derive(Serialize)]
struct UserInfo {
name: String,
patreon: bool,
timezone: Option<String>,
}
#[derive(Deserialize)]
pub struct UpdateUser {
timezone: String,
}
#[get("/api/user")]
pub async fn get_user_info(
cookies: &CookieJar<'_>,
ctx: &State<Context>,
pool: &State<Pool<MySql>>,
) -> JsonValue {
offline!(json!(UserInfo { name: "Discord".to_string(), patreon: true, timezone: None }));
if let Some(user_id) =
cookies.get_private("userid").map(|u| u.value().parse::<u64>().ok()).flatten()
{
let member_res = GuildId(env::var("PATREON_GUILD_ID").unwrap().parse().unwrap())
.member(&ctx.inner(), user_id)
.await;
let timezone = sqlx::query!(
"SELECT IFNULL(timezone, 'UTC') AS timezone FROM users WHERE user = ?",
user_id
)
.fetch_one(pool.inner())
.await
.map_or(None, |q| Some(q.timezone));
let user_info = UserInfo {
name: cookies
.get_private("username")
.map_or("DiscordUser#0000".to_string(), |c| c.value().to_string()),
patreon: member_res.map_or(false, |member| {
member
.roles
.contains(&RoleId(env::var("PATREON_ROLE_ID").unwrap().parse().unwrap()))
}),
timezone,
};
json!(user_info)
} else {
json!({"error": "Not authorized"})
}
}
#[patch("/api/user", data = "<user>")]
pub async fn update_user_info(
cookies: &CookieJar<'_>,
user: Json<UpdateUser>,
pool: &State<Pool<MySql>>,
) -> JsonValue {
if let Some(user_id) =
cookies.get_private("userid").map(|u| u.value().parse::<u64>().ok()).flatten()
{
if user.timezone.parse::<Tz>().is_ok() {
let _ = sqlx::query!(
"UPDATE users SET timezone = ? WHERE user = ?",
user.timezone,
user_id,
)
.execute(pool.inner())
.await;
json!({})
} else {
json!({"error": "Timezone not recognized"})
}
} else {
json!({"error": "Not authorized"})
}
}

View File

@ -0,0 +1,20 @@
use std::env;
use chrono_tz::Tz;
use reqwest::Client;
use rocket::{
http::CookieJar,
serde::json::{json, Json, Value as JsonValue},
State,
};
use serde::{Deserialize, Serialize};
use serenity::{
client::Context,
model::{
id::{GuildId, RoleId},
permissions::Permissions,
},
};
use sqlx::{MySql, Pool};
use crate::{consts::DISCORD_API, routes::JsonResult};

View File

@ -0,0 +1,29 @@
use std::env;
use chrono_tz::Tz;
use reqwest::Client;
use rocket::{
http::CookieJar,
serde::json::{json, Json, Value as JsonValue},
State,
};
use serde::{Deserialize, Serialize};
use serenity::{
client::Context,
model::{
id::{GuildId, RoleId},
permissions::Permissions,
},
};
use sqlx::{MySql, Pool};
use crate::{consts::DISCORD_API, routes::JsonResult};
#[get("/api/user/reminders")]
pub async fn get_reminders(
cookies: &CookieJar<'_>,
ctx: &State<Context>,
pool: &State<Pool<MySql>>,
) -> JsonResult {
Ok(json! {})
}

View File

@ -0,0 +1,447 @@
use csv::{QuoteStyle, WriterBuilder};
use rocket::{
http::CookieJar,
serde::json::{json, serde_json, Json},
State,
};
use serenity::{
client::Context,
model::id::{ChannelId, GuildId, UserId},
};
use sqlx::{MySql, Pool};
use crate::{
check_authorization,
guards::transaction::Transaction,
routes::{
dashboard::{
create_reminder, generate_uid, ImportBody, Reminder, ReminderCsv, ReminderTemplateCsv,
TodoCsv,
},
JsonResult,
},
};
#[get("/api/guild/<id>/export/reminders")]
pub async fn export_reminders(
id: u64,
cookies: &CookieJar<'_>,
ctx: &State<Context>,
pool: &State<Pool<MySql>>,
) -> JsonResult {
check_authorization(cookies, ctx.inner(), id).await?;
let mut csv_writer = WriterBuilder::new().quote_style(QuoteStyle::Always).from_writer(vec![]);
let channels_res = GuildId(id).channels(&ctx.inner()).await;
match channels_res {
Ok(channels) => {
let channels = channels
.keys()
.into_iter()
.map(|k| k.as_u64().to_string())
.collect::<Vec<String>>()
.join(",");
let result = sqlx::query_as_unchecked!(
ReminderCsv,
"SELECT
reminders.attachment,
reminders.attachment_name,
reminders.avatar,
CONCAT('#', channels.channel) AS channel,
reminders.content,
reminders.embed_author,
reminders.embed_author_url,
reminders.embed_color,
reminders.embed_description,
reminders.embed_footer,
reminders.embed_footer_url,
reminders.embed_image_url,
reminders.embed_thumbnail_url,
reminders.embed_title,
reminders.embed_fields,
reminders.enabled,
reminders.expires,
reminders.interval_seconds,
reminders.interval_days,
reminders.interval_months,
reminders.name,
reminders.restartable,
reminders.tts,
reminders.username,
reminders.utc_time
FROM reminders
LEFT JOIN channels ON channels.id = reminders.channel_id
WHERE FIND_IN_SET(channels.channel, ?) AND status = 'pending'",
channels
)
.fetch_all(pool.inner())
.await;
match result {
Ok(reminders) => {
reminders.iter().for_each(|reminder| {
csv_writer.serialize(reminder).unwrap();
});
match csv_writer.into_inner() {
Ok(inner) => match String::from_utf8(inner) {
Ok(encoded) => Ok(json!({ "body": encoded })),
Err(e) => {
warn!("Failed to write UTF-8: {:?}", e);
Err(json!({"error": "Failed to write UTF-8"}))
}
},
Err(e) => {
warn!("Failed to extract CSV: {:?}", e);
Err(json!({"error": "Failed to extract CSV"}))
}
}
}
Err(e) => {
warn!("Failed to complete SQL query: {:?}", e);
Err(json!({"error": "Failed to query reminders"}))
}
}
}
Err(e) => {
warn!("Could not fetch channels from {}: {:?}", id, e);
Err(json!({"error": "Failed to get guild channels"}))
}
}
}
#[put("/api/guild/<id>/export/reminders", data = "<body>")]
pub(crate) async fn import_reminders(
id: u64,
cookies: &CookieJar<'_>,
body: Json<ImportBody>,
ctx: &State<Context>,
mut transaction: Transaction<'_>,
) -> JsonResult {
check_authorization(cookies, ctx.inner(), id).await?;
let user_id =
cookies.get_private("userid").map(|c| c.value().parse::<u64>().ok()).flatten().unwrap();
match base64::decode(&body.body) {
Ok(body) => {
let mut reader = csv::Reader::from_reader(body.as_slice());
let mut count = 0;
for result in reader.deserialize::<ReminderCsv>() {
match result {
Ok(record) => {
let channel_id = record.channel.split_at(1).1;
match channel_id.parse::<u64>() {
Ok(channel_id) => {
let reminder = Reminder {
attachment: record.attachment,
attachment_name: record.attachment_name,
avatar: record.avatar,
channel: channel_id,
content: record.content,
embed_author: record.embed_author,
embed_author_url: record.embed_author_url,
embed_color: record.embed_color,
embed_description: record.embed_description,
embed_footer: record.embed_footer,
embed_footer_url: record.embed_footer_url,
embed_image_url: record.embed_image_url,
embed_thumbnail_url: record.embed_thumbnail_url,
embed_title: record.embed_title,
embed_fields: record
.embed_fields
.map(|s| serde_json::from_str(&s).ok())
.flatten(),
enabled: record.enabled,
expires: record.expires,
interval_seconds: record.interval_seconds,
interval_days: record.interval_days,
interval_months: record.interval_months,
name: record.name,
restartable: record.restartable,
tts: record.tts,
uid: generate_uid(),
username: record.username,
utc_time: record.utc_time,
};
create_reminder(
ctx.inner(),
&mut transaction,
GuildId(id),
UserId(user_id),
reminder,
)
.await?;
count += 1;
}
Err(_) => {
return json_err!(format!(
"Failed to parse channel {}",
channel_id
));
}
}
}
Err(e) => {
warn!("Couldn't deserialize CSV row: {:?}", e);
return json_err!("Deserialize error. Aborted");
}
}
}
match transaction.commit().await {
Ok(_) => Ok(json!({
"message": format!("Imported {} reminders", count)
})),
Err(e) => {
warn!("Failed to commit transaction: {:?}", e);
json_err!("Couldn't commit transaction")
}
}
}
Err(_) => {
json_err!("Malformed base64")
}
}
}
#[get("/api/guild/<id>/export/todos")]
pub async fn export_todos(
id: u64,
cookies: &CookieJar<'_>,
ctx: &State<Context>,
pool: &State<Pool<MySql>>,
) -> JsonResult {
check_authorization(cookies, ctx.inner(), id).await?;
let mut csv_writer = WriterBuilder::new().quote_style(QuoteStyle::Always).from_writer(vec![]);
match sqlx::query_as_unchecked!(
TodoCsv,
"SELECT value, CONCAT('#', channels.channel) AS channel_id FROM todos
LEFT JOIN channels ON todos.channel_id = channels.id
INNER JOIN guilds ON todos.guild_id = guilds.id
WHERE guilds.guild = ?",
id
)
.fetch_all(pool.inner())
.await
{
Ok(todos) => {
todos.iter().for_each(|todo| {
csv_writer.serialize(todo).unwrap();
});
match csv_writer.into_inner() {
Ok(inner) => match String::from_utf8(inner) {
Ok(encoded) => Ok(json!({ "body": encoded })),
Err(e) => {
warn!("Failed to write UTF-8: {:?}", e);
json_err!("Failed to write UTF-8")
}
},
Err(e) => {
warn!("Failed to extract CSV: {:?}", e);
json_err!("Failed to extract CSV")
}
}
}
Err(e) => {
warn!("Could not fetch templates from {}: {:?}", id, e);
json_err!("Failed to query templates")
}
}
}
#[put("/api/guild/<id>/export/todos", data = "<body>")]
pub async fn import_todos(
id: u64,
cookies: &CookieJar<'_>,
body: Json<ImportBody>,
ctx: &State<Context>,
pool: &State<Pool<MySql>>,
) -> JsonResult {
check_authorization(cookies, ctx.inner(), id).await?;
let channels_res = GuildId(id).channels(&ctx.inner()).await;
match channels_res {
Ok(channels) => match base64::decode(&body.body) {
Ok(body) => {
let mut reader = csv::Reader::from_reader(body.as_slice());
let query_placeholder = "(?, (SELECT id FROM channels WHERE channel = ?), (SELECT id FROM guilds WHERE guild = ?))";
let mut query_params = vec![];
for result in reader.deserialize::<TodoCsv>() {
match result {
Ok(record) => match record.channel_id {
Some(channel_id) => {
let channel_id = channel_id.split_at(1).1;
match channel_id.parse::<u64>() {
Ok(channel_id) => {
if channels.contains_key(&ChannelId(channel_id)) {
query_params.push((record.value, Some(channel_id), id));
} else {
return json_err!(format!(
"Invalid channel ID {}",
channel_id
));
}
}
Err(_) => {
return json_err!(format!(
"Invalid channel ID {}",
channel_id
));
}
}
}
None => {
query_params.push((record.value, None, id));
}
},
Err(e) => {
warn!("Couldn't deserialize CSV row: {:?}", e);
return json_err!("Deserialize error. Aborted");
}
}
}
let query_str = format!(
"INSERT INTO todos (value, channel_id, guild_id) VALUES {}",
vec![query_placeholder].repeat(query_params.len()).join(",")
);
let mut query = sqlx::query(&query_str);
for param in query_params {
query = query.bind(param.0).bind(param.1).bind(param.2);
}
let res = query.execute(pool.inner()).await;
match res {
Ok(_) => Ok(json!({})),
Err(e) => {
warn!("Couldn't execute todo query: {:?}", e);
json_err!("An unexpected error occured.")
}
}
}
Err(_) => {
json_err!("Malformed base64")
}
},
Err(e) => {
warn!("Couldn't fetch channels for guild {}: {:?}", id, e);
json_err!("Couldn't fetch channels.")
}
}
}
#[get("/api/guild/<id>/export/reminder_templates")]
pub async fn export_reminder_templates(
id: u64,
cookies: &CookieJar<'_>,
ctx: &State<Context>,
pool: &State<Pool<MySql>>,
) -> JsonResult {
check_authorization(cookies, ctx.inner(), id).await?;
let mut csv_writer = WriterBuilder::new().quote_style(QuoteStyle::Always).from_writer(vec![]);
match sqlx::query_as_unchecked!(
ReminderTemplateCsv,
"SELECT
name,
attachment,
attachment_name,
avatar,
content,
embed_author,
embed_author_url,
embed_color,
embed_description,
embed_footer,
embed_footer_url,
embed_image_url,
embed_thumbnail_url,
embed_title,
embed_fields,
interval_seconds,
interval_days,
interval_months,
tts,
username
FROM reminder_template WHERE guild_id = (SELECT id FROM guilds WHERE guild = ?)",
id
)
.fetch_all(pool.inner())
.await
{
Ok(templates) => {
templates.iter().for_each(|template| {
csv_writer.serialize(template).unwrap();
});
match csv_writer.into_inner() {
Ok(inner) => match String::from_utf8(inner) {
Ok(encoded) => Ok(json!({ "body": encoded })),
Err(e) => {
warn!("Failed to write UTF-8: {:?}", e);
json_err!("Failed to write UTF-8")
}
},
Err(e) => {
warn!("Failed to extract CSV: {:?}", e);
json_err!("Failed to extract CSV")
}
}
}
Err(e) => {
warn!("Could not fetch templates from {}: {:?}", id, e);
json_err!("Failed to query templates")
}
}
}

View File

@ -1,727 +1 @@
use std::env;
use base64;
use chrono::Utc;
use rocket::{
http::CookieJar,
serde::json::{json, Json, Value as JsonValue},
State,
};
use serde::Serialize;
use serenity::{
client::Context,
model::{
channel::GuildChannel,
id::{ChannelId, GuildId, RoleId},
},
};
use sqlx::{MySql, Pool};
use crate::{
check_guild_subscription, check_subscription,
consts::{
DAY, MAX_CONTENT_LENGTH, MAX_EMBED_AUTHOR_LENGTH, MAX_EMBED_DESCRIPTION_LENGTH,
MAX_EMBED_FIELDS, MAX_EMBED_FIELD_TITLE_LENGTH, MAX_EMBED_FIELD_VALUE_LENGTH,
MAX_EMBED_FOOTER_LENGTH, MAX_EMBED_TITLE_LENGTH, MAX_URL_LENGTH, MAX_USERNAME_LENGTH,
MIN_INTERVAL,
},
routes::dashboard::{
create_database_channel, generate_uid, name_default, template_name_default, DeleteReminder,
DeleteReminderTemplate, PatchReminder, Reminder, ReminderTemplate,
},
};
#[derive(Serialize)]
struct ChannelInfo {
id: String,
name: String,
webhook_avatar: Option<String>,
webhook_name: Option<String>,
}
#[get("/api/guild/<id>/patreon")]
pub async fn get_guild_patreon(
id: u64,
cookies: &CookieJar<'_>,
ctx: &State<Context>,
) -> JsonValue {
check_authorization!(cookies, ctx.inner(), id);
match GuildId(id).to_guild_cached(ctx.inner()) {
Some(guild) => {
let member_res = GuildId(env::var("PATREON_GUILD_ID").unwrap().parse().unwrap())
.member(&ctx.inner(), guild.owner_id)
.await;
let patreon = member_res.map_or(false, |member| {
member
.roles
.contains(&RoleId(env::var("PATREON_ROLE_ID").unwrap().parse().unwrap()))
});
json!({ "patreon": patreon })
}
None => {
json!({"error": "Bot not in guild"})
}
}
}
#[get("/api/guild/<id>/channels")]
pub async fn get_guild_channels(
id: u64,
cookies: &CookieJar<'_>,
ctx: &State<Context>,
) -> JsonValue {
check_authorization!(cookies, ctx.inner(), id);
match GuildId(id).to_guild_cached(ctx.inner()) {
Some(guild) => {
let mut channels = guild
.channels
.iter()
.filter_map(|(id, channel)| channel.to_owned().guild().map(|c| (id.to_owned(), c)))
.filter(|(_, channel)| channel.is_text_based())
.collect::<Vec<(ChannelId, GuildChannel)>>();
channels.sort_by(|(_, c1), (_, c2)| c1.position.cmp(&c2.position));
let channel_info = channels
.iter()
.map(|(channel_id, channel)| ChannelInfo {
name: channel.name.to_string(),
id: channel_id.to_string(),
webhook_avatar: None,
webhook_name: None,
})
.collect::<Vec<ChannelInfo>>();
json!(channel_info)
}
None => {
json!({"error": "Bot not in guild"})
}
}
}
#[derive(Serialize)]
struct RoleInfo {
id: String,
name: String,
}
#[get("/api/guild/<id>/roles")]
pub async fn get_guild_roles(id: u64, cookies: &CookieJar<'_>, ctx: &State<Context>) -> JsonValue {
check_authorization!(cookies, ctx.inner(), id);
let roles_res = ctx.cache.guild_roles(id);
match roles_res {
Some(roles) => {
let roles = roles
.iter()
.map(|(_, r)| RoleInfo { id: r.id.to_string(), name: r.name.to_string() })
.collect::<Vec<RoleInfo>>();
json!(roles)
}
None => {
warn!("Could not fetch roles from {}", id);
json!({"error": "Could not get roles"})
}
}
}
#[get("/api/guild/<id>/templates")]
pub async fn get_reminder_templates(
id: u64,
cookies: &CookieJar<'_>,
ctx: &State<Context>,
pool: &State<Pool<MySql>>,
) -> JsonValue {
check_authorization!(cookies, ctx.inner(), id);
match sqlx::query_as_unchecked!(
ReminderTemplate,
"SELECT * FROM reminder_template WHERE guild_id = (SELECT id FROM guilds WHERE guild = ?)",
id
)
.fetch_all(pool.inner())
.await
{
Ok(templates) => {
json!(templates)
}
Err(e) => {
warn!("Could not fetch templates from {}: {:?}", id, e);
json!({"error": "Could not get templates"})
}
}
}
#[post("/api/guild/<id>/templates", data = "<reminder_template>")]
pub async fn create_reminder_template(
id: u64,
reminder_template: Json<ReminderTemplate>,
cookies: &CookieJar<'_>,
ctx: &State<Context>,
pool: &State<Pool<MySql>>,
) -> JsonValue {
check_authorization!(cookies, ctx.inner(), id);
// validate lengths
check_length!(MAX_CONTENT_LENGTH, reminder_template.content);
check_length!(MAX_EMBED_DESCRIPTION_LENGTH, reminder_template.embed_description);
check_length!(MAX_EMBED_TITLE_LENGTH, reminder_template.embed_title);
check_length!(MAX_EMBED_AUTHOR_LENGTH, reminder_template.embed_author);
check_length!(MAX_EMBED_FOOTER_LENGTH, reminder_template.embed_footer);
check_length_opt!(MAX_EMBED_FIELDS, reminder_template.embed_fields);
if let Some(fields) = &reminder_template.embed_fields {
for field in &fields.0 {
check_length!(MAX_EMBED_FIELD_VALUE_LENGTH, field.value);
check_length!(MAX_EMBED_FIELD_TITLE_LENGTH, field.title);
}
}
check_length_opt!(MAX_USERNAME_LENGTH, reminder_template.username);
check_length_opt!(
MAX_URL_LENGTH,
reminder_template.embed_footer_url,
reminder_template.embed_thumbnail_url,
reminder_template.embed_author_url,
reminder_template.embed_image_url,
reminder_template.avatar
);
// validate urls
check_url_opt!(
reminder_template.embed_footer_url,
reminder_template.embed_thumbnail_url,
reminder_template.embed_author_url,
reminder_template.embed_image_url,
reminder_template.avatar
);
let name = if reminder_template.name.is_empty() {
template_name_default()
} else {
reminder_template.name.clone()
};
match sqlx::query!(
"INSERT INTO reminder_template
(guild_id,
name,
attachment,
attachment_name,
avatar,
content,
embed_author,
embed_author_url,
embed_color,
embed_description,
embed_footer,
embed_footer_url,
embed_image_url,
embed_thumbnail_url,
embed_title,
embed_fields,
tts,
username
) VALUES ((SELECT id FROM guilds WHERE guild = ?), ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
id, name,
reminder_template.attachment,
reminder_template.attachment_name,
reminder_template.avatar,
reminder_template.content,
reminder_template.embed_author,
reminder_template.embed_author_url,
reminder_template.embed_color,
reminder_template.embed_description,
reminder_template.embed_footer,
reminder_template.embed_footer_url,
reminder_template.embed_image_url,
reminder_template.embed_thumbnail_url,
reminder_template.embed_title,
reminder_template.embed_fields,
reminder_template.tts,
reminder_template.username,
)
.fetch_all(pool.inner())
.await
{
Ok(_) => {
json!({})
}
Err(e) => {
warn!("Could not fetch templates from {}: {:?}", id, e);
json!({"error": "Could not get templates"})
}
}
}
#[delete("/api/guild/<id>/templates", data = "<delete_reminder_template>")]
pub async fn delete_reminder_template(
id: u64,
delete_reminder_template: Json<DeleteReminderTemplate>,
cookies: &CookieJar<'_>,
ctx: &State<Context>,
pool: &State<Pool<MySql>>,
) -> JsonValue {
check_authorization!(cookies, ctx.inner(), id);
match sqlx::query!(
"DELETE FROM reminder_template WHERE guild_id = (SELECT id FROM guilds WHERE guild = ?) AND id = ?",
id, delete_reminder_template.id
)
.fetch_all(pool.inner())
.await
{
Ok(_) => {
json!({})
}
Err(e) => {
warn!("Could not delete template from {}: {:?}", id, e);
json!({"error": "Could not delete template"})
}
}
}
#[post("/api/guild/<id>/reminders", data = "<reminder>")]
pub async fn create_reminder(
id: u64,
reminder: Json<Reminder>,
cookies: &CookieJar<'_>,
serenity_context: &State<Context>,
pool: &State<Pool<MySql>>,
) -> JsonValue {
check_authorization!(cookies, serenity_context.inner(), id);
let user_id =
cookies.get_private("userid").map(|c| c.value().parse::<u64>().ok()).flatten().unwrap();
// validate channel
let channel = ChannelId(reminder.channel).to_channel_cached(&serenity_context.inner());
let channel_exists = channel.is_some();
let channel_matches_guild =
channel.map_or(false, |c| c.guild().map_or(false, |c| c.guild_id.0 == id));
if !channel_matches_guild || !channel_exists {
warn!(
"Error in `create_reminder`: channel {} not found for guild {} (channel exists: {})",
reminder.channel, id, channel_exists
);
return json!({"error": "Channel not found"});
}
let channel = create_database_channel(
serenity_context.inner(),
ChannelId(reminder.channel),
pool.inner(),
)
.await;
if let Err(e) = channel {
warn!("`create_database_channel` returned an error code: {:?}", e);
return json!({"error": "Failed to configure channel for reminders. Please check the bot permissions"});
}
let channel = channel.unwrap();
// validate lengths
check_length!(MAX_CONTENT_LENGTH, reminder.content);
check_length!(MAX_EMBED_DESCRIPTION_LENGTH, reminder.embed_description);
check_length!(MAX_EMBED_TITLE_LENGTH, reminder.embed_title);
check_length!(MAX_EMBED_AUTHOR_LENGTH, reminder.embed_author);
check_length!(MAX_EMBED_FOOTER_LENGTH, reminder.embed_footer);
check_length_opt!(MAX_EMBED_FIELDS, reminder.embed_fields);
if let Some(fields) = &reminder.embed_fields {
for field in &fields.0 {
check_length!(MAX_EMBED_FIELD_VALUE_LENGTH, field.value);
check_length!(MAX_EMBED_FIELD_TITLE_LENGTH, field.title);
}
}
check_length_opt!(MAX_USERNAME_LENGTH, reminder.username);
check_length_opt!(
MAX_URL_LENGTH,
reminder.embed_footer_url,
reminder.embed_thumbnail_url,
reminder.embed_author_url,
reminder.embed_image_url,
reminder.avatar
);
// validate urls
check_url_opt!(
reminder.embed_footer_url,
reminder.embed_thumbnail_url,
reminder.embed_author_url,
reminder.embed_image_url,
reminder.avatar
);
// validate time and interval
if reminder.utc_time < Utc::now().naive_utc() {
return json!({"error": "Time must be in the future"});
}
if reminder.interval_seconds.is_some() || reminder.interval_months.is_some() {
if reminder.interval_months.unwrap_or(0) * 30 * DAY as u32
+ reminder.interval_seconds.unwrap_or(0)
< *MIN_INTERVAL
{
return json!({"error": "Interval too short"});
}
}
// check patreon if necessary
if reminder.interval_seconds.is_some() || reminder.interval_months.is_some() {
if !check_guild_subscription(serenity_context.inner(), GuildId(id)).await
&& !check_subscription(serenity_context.inner(), user_id).await
{
return json!({"error": "Patreon is required to set intervals"});
}
}
// base64 decode error dropped here
let attachment_data = reminder.attachment.as_ref().map(|s| base64::decode(s).ok()).flatten();
let name = if reminder.name.is_empty() { name_default() } else { reminder.name.clone() };
let new_uid = generate_uid();
// write to db
match sqlx::query!(
"INSERT INTO reminders (
uid,
attachment,
attachment_name,
channel_id,
avatar,
content,
embed_author,
embed_author_url,
embed_color,
embed_description,
embed_footer,
embed_footer_url,
embed_image_url,
embed_thumbnail_url,
embed_title,
embed_fields,
enabled,
expires,
interval_seconds,
interval_months,
name,
restartable,
tts,
username,
`utc_time`
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
new_uid,
attachment_data,
reminder.attachment_name,
channel,
reminder.avatar,
reminder.content,
reminder.embed_author,
reminder.embed_author_url,
reminder.embed_color,
reminder.embed_description,
reminder.embed_footer,
reminder.embed_footer_url,
reminder.embed_image_url,
reminder.embed_thumbnail_url,
reminder.embed_title,
reminder.embed_fields,
reminder.enabled,
reminder.expires,
reminder.interval_seconds,
reminder.interval_months,
name,
reminder.restartable,
reminder.tts,
reminder.username,
reminder.utc_time,
)
.execute(pool.inner())
.await
{
Ok(_) => sqlx::query_as_unchecked!(
Reminder,
"SELECT
reminders.attachment,
reminders.attachment_name,
reminders.avatar,
channels.channel,
reminders.content,
reminders.embed_author,
reminders.embed_author_url,
reminders.embed_color,
reminders.embed_description,
reminders.embed_footer,
reminders.embed_footer_url,
reminders.embed_image_url,
reminders.embed_thumbnail_url,
reminders.embed_title,
reminders.embed_fields,
reminders.enabled,
reminders.expires,
reminders.interval_seconds,
reminders.interval_months,
reminders.name,
reminders.restartable,
reminders.tts,
reminders.uid,
reminders.username,
reminders.utc_time
FROM reminders
LEFT JOIN channels ON channels.id = reminders.channel_id
WHERE uid = ?",
new_uid
)
.fetch_one(pool.inner())
.await
.map(|r| json!(r))
.unwrap_or_else(|e| {
warn!("Failed to complete SQL query: {:?}", e);
json!({"error": "Could not load reminder"})
}),
Err(e) => {
warn!("Error in `create_reminder`: Could not execute query: {:?}", e);
json!({"error": "Unknown error"})
}
}
}
#[get("/api/guild/<id>/reminders")]
pub async fn get_reminders(id: u64, ctx: &State<Context>, pool: &State<Pool<MySql>>) -> JsonValue {
let channels_res = GuildId(id).channels(&ctx.inner()).await;
match channels_res {
Ok(channels) => {
let channels = channels
.keys()
.into_iter()
.map(|k| k.as_u64().to_string())
.collect::<Vec<String>>()
.join(",");
sqlx::query_as_unchecked!(
Reminder,
"SELECT
reminders.attachment,
reminders.attachment_name,
reminders.avatar,
channels.channel,
reminders.content,
reminders.embed_author,
reminders.embed_author_url,
reminders.embed_color,
reminders.embed_description,
reminders.embed_footer,
reminders.embed_footer_url,
reminders.embed_image_url,
reminders.embed_thumbnail_url,
reminders.embed_title,
reminders.embed_fields,
reminders.enabled,
reminders.expires,
reminders.interval_seconds,
reminders.interval_months,
reminders.name,
reminders.restartable,
reminders.tts,
reminders.uid,
reminders.username,
reminders.utc_time
FROM reminders
LEFT JOIN channels ON channels.id = reminders.channel_id
WHERE FIND_IN_SET(channels.channel, ?)",
channels
)
.fetch_all(pool.inner())
.await
.map(|r| json!(r))
.unwrap_or_else(|e| {
warn!("Failed to complete SQL query: {:?}", e);
json!({"error": "Could not load reminders"})
})
}
Err(e) => {
warn!("Could not fetch channels from {}: {:?}", id, e);
json!([])
}
}
}
#[patch("/api/guild/<id>/reminders", data = "<reminder>")]
pub async fn edit_reminder(
id: u64,
reminder: Json<PatchReminder>,
serenity_context: &State<Context>,
pool: &State<Pool<MySql>>,
) -> JsonValue {
let mut error = vec![];
update_field!(pool.inner(), error, reminder.[
attachment,
attachment_name,
avatar,
content,
embed_author,
embed_author_url,
embed_color,
embed_description,
embed_footer,
embed_footer_url,
embed_image_url,
embed_thumbnail_url,
embed_title,
embed_fields,
enabled,
expires,
interval_seconds,
interval_months,
name,
restartable,
tts,
username,
utc_time
]);
if reminder.channel > 0 {
let channel = ChannelId(reminder.channel).to_channel_cached(&serenity_context.inner());
match channel {
Some(channel) => {
let channel_matches_guild = channel.guild().map_or(false, |c| c.guild_id.0 == id);
if !channel_matches_guild {
warn!(
"Error in `edit_reminder`: channel {:?} not found for guild {}",
reminder.channel, id
);
return json!({"error": "Channel not found"});
}
let channel = create_database_channel(
serenity_context.inner(),
ChannelId(reminder.channel),
pool.inner(),
)
.await;
if let Err(e) = channel {
warn!("`create_database_channel` returned an error code: {:?}", e);
return json!({"error": "Failed to configure channel for reminders. Please check the bot permissions"});
}
let channel = channel.unwrap();
match sqlx::query!(
"UPDATE reminders SET channel_id = ? WHERE uid = ?",
channel,
reminder.uid
)
.execute(pool.inner())
.await
{
Ok(_) => {}
Err(e) => {
warn!("Error setting channel: {:?}", e);
error.push("Couldn't set channel".to_string())
}
}
}
None => {
warn!(
"Error in `edit_reminder`: channel {:?} not found for guild {}",
reminder.channel, id
);
return json!({"error": "Channel not found"});
}
}
}
match sqlx::query_as_unchecked!(
Reminder,
"SELECT reminders.attachment,
reminders.attachment_name,
reminders.avatar,
channels.channel,
reminders.content,
reminders.embed_author,
reminders.embed_author_url,
reminders.embed_color,
reminders.embed_description,
reminders.embed_footer,
reminders.embed_footer_url,
reminders.embed_image_url,
reminders.embed_thumbnail_url,
reminders.embed_title,
reminders.embed_fields,
reminders.enabled,
reminders.expires,
reminders.interval_seconds,
reminders.interval_months,
reminders.name,
reminders.restartable,
reminders.tts,
reminders.uid,
reminders.username,
reminders.utc_time
FROM reminders
LEFT JOIN channels ON channels.id = reminders.channel_id
WHERE uid = ?",
reminder.uid
)
.fetch_one(pool.inner())
.await
{
Ok(reminder) => json!({"reminder": reminder, "errors": error}),
Err(e) => {
warn!("Error exiting `edit_reminder': {:?}", e);
json!({"reminder": Option::<Reminder>::None, "errors": vec!["Unknown error"]})
}
}
}
#[delete("/api/guild/<_>/reminders", data = "<reminder>")]
pub async fn delete_reminder(
reminder: Json<DeleteReminder>,
pool: &State<Pool<MySql>>,
) -> JsonValue {
match sqlx::query!("DELETE FROM reminders WHERE uid = ?", reminder.uid)
.execute(pool.inner())
.await
{
Ok(_) => {
json!({})
}
Err(e) => {
warn!("Error in `delete_reminder`: {:?}", e);
json!({"error": "Could not delete reminder"})
}
}
}

View File

@ -1,20 +1,33 @@
use std::collections::HashMap; use std::collections::HashMap;
use chrono::naive::NaiveDateTime; use chrono::{naive::NaiveDateTime, Utc};
use rand::{rngs::OsRng, seq::IteratorRandom}; use rand::{rngs::OsRng, seq::IteratorRandom};
use rocket::{http::CookieJar, response::Redirect}; use rocket::{http::CookieJar, response::Redirect, serde::json::json};
use rocket_dyn_templates::Template; use rocket_dyn_templates::Template;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Deserializer, Serialize};
use serenity::{http::Http, model::id::ChannelId}; use serenity::{
use sqlx::{types::Json, Executor}; client::Context,
http::Http,
model::id::{ChannelId, GuildId, UserId},
};
use sqlx::types::Json;
use crate::{ use crate::{
consts::{CHARACTERS, DEFAULT_AVATAR}, check_guild_subscription, check_subscription,
Database, Error, consts::{
CHARACTERS, DAY, DEFAULT_AVATAR, MAX_CONTENT_LENGTH, MAX_EMBED_AUTHOR_LENGTH,
MAX_EMBED_DESCRIPTION_LENGTH, MAX_EMBED_FIELDS, MAX_EMBED_FIELD_TITLE_LENGTH,
MAX_EMBED_FIELD_VALUE_LENGTH, MAX_EMBED_FOOTER_LENGTH, MAX_EMBED_TITLE_LENGTH,
MAX_NAME_LENGTH, MAX_URL_LENGTH, MAX_USERNAME_LENGTH, MIN_INTERVAL,
},
guards::transaction::Transaction,
routes::JsonResult,
Error,
}; };
pub mod api;
pub mod export;
pub mod guild; pub mod guild;
pub mod user;
type Unset<T> = Option<T>; type Unset<T> = Option<T>;
@ -34,6 +47,18 @@ fn id_default() -> u32 {
0 0
} }
fn interval_default() -> Unset<Option<u32>> {
None
}
fn deserialize_optional_field<'de, T, D>(deserializer: D) -> Result<Option<Option<T>>, D::Error>
where
D: Deserializer<'de>,
T: Deserialize<'de>,
{
Ok(Some(Option::deserialize(deserializer)?))
}
#[derive(Serialize, Deserialize)] #[derive(Serialize, Deserialize)]
pub struct ReminderTemplate { pub struct ReminderTemplate {
#[serde(default = "id_default")] #[serde(default = "id_default")]
@ -56,6 +81,34 @@ pub struct ReminderTemplate {
embed_thumbnail_url: Option<String>, embed_thumbnail_url: Option<String>,
embed_title: String, embed_title: String,
embed_fields: Option<Json<Vec<EmbedField>>>, embed_fields: Option<Json<Vec<EmbedField>>>,
interval_seconds: Option<u32>,
interval_days: Option<u32>,
interval_months: Option<u32>,
tts: bool,
username: Option<String>,
}
#[derive(Serialize, Deserialize)]
pub struct ReminderTemplateCsv {
#[serde(default = "template_name_default")]
name: String,
attachment: Option<Vec<u8>>,
attachment_name: Option<String>,
avatar: Option<String>,
content: String,
embed_author: String,
embed_author_url: Option<String>,
embed_color: u32,
embed_description: String,
embed_footer: String,
embed_footer_url: Option<String>,
embed_image_url: Option<String>,
embed_thumbnail_url: Option<String>,
embed_title: String,
embed_fields: Option<String>,
interval_seconds: Option<u32>,
interval_days: Option<u32>,
interval_months: Option<u32>,
tts: bool, tts: bool,
username: Option<String>, username: Option<String>,
} }
@ -94,6 +147,7 @@ pub struct Reminder {
enabled: bool, enabled: bool,
expires: Option<NaiveDateTime>, expires: Option<NaiveDateTime>,
interval_seconds: Option<u32>, interval_seconds: Option<u32>,
interval_days: Option<u32>,
interval_months: Option<u32>, interval_months: Option<u32>,
#[serde(default = "name_default")] #[serde(default = "name_default")]
name: String, name: String,
@ -105,14 +159,48 @@ pub struct Reminder {
utc_time: NaiveDateTime, utc_time: NaiveDateTime,
} }
#[derive(Serialize, Deserialize)]
pub struct ReminderCsv {
#[serde(with = "base64s")]
attachment: Option<Vec<u8>>,
attachment_name: Option<String>,
avatar: Option<String>,
channel: String,
content: String,
embed_author: String,
embed_author_url: Option<String>,
embed_color: u32,
embed_description: String,
embed_footer: String,
embed_footer_url: Option<String>,
embed_image_url: Option<String>,
embed_thumbnail_url: Option<String>,
embed_title: String,
embed_fields: Option<String>,
enabled: bool,
expires: Option<NaiveDateTime>,
interval_seconds: Option<u32>,
interval_days: Option<u32>,
interval_months: Option<u32>,
#[serde(default = "name_default")]
name: String,
restartable: bool,
tts: bool,
username: Option<String>,
utc_time: NaiveDateTime,
}
#[derive(Deserialize)] #[derive(Deserialize)]
pub struct PatchReminder { pub struct PatchReminder {
uid: String, uid: String,
#[serde(default)] #[serde(default)]
#[serde(deserialize_with = "deserialize_optional_field")]
attachment: Unset<Option<String>>, attachment: Unset<Option<String>>,
#[serde(default)] #[serde(default)]
#[serde(deserialize_with = "deserialize_optional_field")]
attachment_name: Unset<Option<String>>, attachment_name: Unset<Option<String>>,
#[serde(default)] #[serde(default)]
#[serde(deserialize_with = "deserialize_optional_field")]
avatar: Unset<Option<String>>, avatar: Unset<Option<String>>,
#[serde(default = "channel_default")] #[serde(default = "channel_default")]
#[serde(with = "string")] #[serde(with = "string")]
@ -122,6 +210,7 @@ pub struct PatchReminder {
#[serde(default)] #[serde(default)]
embed_author: Unset<String>, embed_author: Unset<String>,
#[serde(default)] #[serde(default)]
#[serde(deserialize_with = "deserialize_optional_field")]
embed_author_url: Unset<Option<String>>, embed_author_url: Unset<Option<String>>,
#[serde(default)] #[serde(default)]
embed_color: Unset<u32>, embed_color: Unset<u32>,
@ -130,10 +219,13 @@ pub struct PatchReminder {
#[serde(default)] #[serde(default)]
embed_footer: Unset<String>, embed_footer: Unset<String>,
#[serde(default)] #[serde(default)]
#[serde(deserialize_with = "deserialize_optional_field")]
embed_footer_url: Unset<Option<String>>, embed_footer_url: Unset<Option<String>>,
#[serde(default)] #[serde(default)]
#[serde(deserialize_with = "deserialize_optional_field")]
embed_image_url: Unset<Option<String>>, embed_image_url: Unset<Option<String>>,
#[serde(default)] #[serde(default)]
#[serde(deserialize_with = "deserialize_optional_field")]
embed_thumbnail_url: Unset<Option<String>>, embed_thumbnail_url: Unset<Option<String>>,
#[serde(default)] #[serde(default)]
embed_title: Unset<String>, embed_title: Unset<String>,
@ -142,10 +234,16 @@ pub struct PatchReminder {
#[serde(default)] #[serde(default)]
enabled: Unset<bool>, enabled: Unset<bool>,
#[serde(default)] #[serde(default)]
#[serde(deserialize_with = "deserialize_optional_field")]
expires: Unset<Option<NaiveDateTime>>, expires: Unset<Option<NaiveDateTime>>,
#[serde(default)] #[serde(default = "interval_default")]
#[serde(deserialize_with = "deserialize_optional_field")]
interval_seconds: Unset<Option<u32>>, interval_seconds: Unset<Option<u32>>,
#[serde(default)] #[serde(default = "interval_default")]
#[serde(deserialize_with = "deserialize_optional_field")]
interval_days: Unset<Option<u32>>,
#[serde(default = "interval_default")]
#[serde(deserialize_with = "deserialize_optional_field")]
interval_months: Unset<Option<u32>>, interval_months: Unset<Option<u32>>,
#[serde(default)] #[serde(default)]
name: Unset<String>, name: Unset<String>,
@ -154,11 +252,36 @@ pub struct PatchReminder {
#[serde(default)] #[serde(default)]
tts: Unset<bool>, tts: Unset<bool>,
#[serde(default)] #[serde(default)]
#[serde(deserialize_with = "deserialize_optional_field")]
username: Unset<Option<String>>, username: Unset<Option<String>>,
#[serde(default)] #[serde(default)]
utc_time: Unset<NaiveDateTime>, utc_time: Unset<NaiveDateTime>,
} }
impl PatchReminder {
fn message_ok(&self) -> bool {
self.content.as_ref().map_or(true, |c| c.len() <= MAX_CONTENT_LENGTH)
&& self.embed_author.as_ref().map_or(true, |c| c.len() <= MAX_EMBED_AUTHOR_LENGTH)
&& self
.embed_description
.as_ref()
.map_or(true, |c| c.len() <= MAX_EMBED_DESCRIPTION_LENGTH)
&& self.embed_footer.as_ref().map_or(true, |c| c.len() <= MAX_EMBED_FOOTER_LENGTH)
&& self.embed_title.as_ref().map_or(true, |c| c.len() <= MAX_EMBED_TITLE_LENGTH)
&& self.embed_fields.as_ref().map_or(true, |c| {
c.0.len() <= MAX_EMBED_FIELDS
&& c.0.iter().all(|f| {
f.title.len() <= MAX_EMBED_FIELD_TITLE_LENGTH
&& f.value.len() <= MAX_EMBED_FIELD_VALUE_LENGTH
})
})
&& self
.username
.as_ref()
.map_or(true, |c| c.as_ref().map_or(true, |v| v.len() <= MAX_USERNAME_LENGTH))
}
}
pub fn generate_uid() -> String { pub fn generate_uid() -> String {
let mut generator: OsRng = Default::default(); let mut generator: OsRng = Default::default();
@ -220,16 +343,259 @@ pub struct DeleteReminder {
uid: String, uid: String,
} }
#[derive(Deserialize)]
pub struct ImportBody {
body: String,
}
#[derive(Serialize, Deserialize)]
pub struct TodoCsv {
value: String,
channel_id: Option<String>,
}
pub(crate) async fn create_reminder(
ctx: &Context,
transaction: &mut Transaction<'_>,
guild_id: GuildId,
user_id: UserId,
reminder: Reminder,
) -> JsonResult {
// check guild in db
match sqlx::query!("SELECT 1 as A FROM guilds WHERE guild = ?", guild_id.0)
.fetch_one(transaction.executor())
.await
{
Err(sqlx::Error::RowNotFound) => {
if sqlx::query!("INSERT INTO guilds (guild) VALUES (?)", guild_id.0)
.execute(transaction.executor())
.await
.is_err()
{
return Err(json!({"error": "Guild could not be created"}));
}
}
_ => {}
}
// validate channel
let channel = ChannelId(reminder.channel).to_channel_cached(&ctx);
let channel_exists = channel.is_some();
let channel_matches_guild =
channel.map_or(false, |c| c.guild().map_or(false, |c| c.guild_id == guild_id));
if !channel_matches_guild || !channel_exists {
warn!(
"Error in `create_reminder`: channel {} not found for guild {} (channel exists: {})",
reminder.channel, guild_id, channel_exists
);
return Err(json!({"error": "Channel not found"}));
}
let channel = create_database_channel(&ctx, ChannelId(reminder.channel), transaction).await;
if let Err(e) = channel {
warn!("`create_database_channel` returned an error code: {:?}", e);
return Err(
json!({"error": "Failed to configure channel for reminders. Please check the bot permissions"}),
);
}
let channel = channel.unwrap();
// validate lengths
check_length!(MAX_NAME_LENGTH, reminder.name);
check_length!(MAX_CONTENT_LENGTH, reminder.content);
check_length!(MAX_EMBED_DESCRIPTION_LENGTH, reminder.embed_description);
check_length!(MAX_EMBED_TITLE_LENGTH, reminder.embed_title);
check_length!(MAX_EMBED_AUTHOR_LENGTH, reminder.embed_author);
check_length!(MAX_EMBED_FOOTER_LENGTH, reminder.embed_footer);
check_length_opt!(MAX_EMBED_FIELDS, reminder.embed_fields);
if let Some(fields) = &reminder.embed_fields {
for field in &fields.0 {
check_length!(MAX_EMBED_FIELD_VALUE_LENGTH, field.value);
check_length!(MAX_EMBED_FIELD_TITLE_LENGTH, field.title);
}
}
check_length_opt!(MAX_USERNAME_LENGTH, reminder.username);
check_length_opt!(
MAX_URL_LENGTH,
reminder.embed_footer_url,
reminder.embed_thumbnail_url,
reminder.embed_author_url,
reminder.embed_image_url,
reminder.avatar
);
// validate urls
check_url_opt!(
reminder.embed_footer_url,
reminder.embed_thumbnail_url,
reminder.embed_author_url,
reminder.embed_image_url,
reminder.avatar
);
// validate time and interval
if reminder.utc_time < Utc::now().naive_utc() {
return Err(json!({"error": "Time must be in the future"}));
}
if reminder.interval_seconds.is_some()
|| reminder.interval_days.is_some()
|| reminder.interval_months.is_some()
{
if reminder.interval_months.unwrap_or(0) * 30 * DAY as u32
+ reminder.interval_days.unwrap_or(0) * DAY as u32
+ reminder.interval_seconds.unwrap_or(0)
< *MIN_INTERVAL
{
return Err(json!({"error": "Interval too short"}));
}
}
// check patreon if necessary
if reminder.interval_seconds.is_some()
|| reminder.interval_days.is_some()
|| reminder.interval_months.is_some()
{
if !check_guild_subscription(&ctx, guild_id).await
&& !check_subscription(&ctx, user_id).await
{
return Err(json!({"error": "Patreon is required to set intervals"}));
}
}
let name = if reminder.name.is_empty() { name_default() } else { reminder.name.clone() };
let username = if reminder.username.as_ref().map(|s| s.is_empty()).unwrap_or(true) {
None
} else {
reminder.username
};
let new_uid = generate_uid();
// write to db
match sqlx::query!(
"INSERT INTO reminders (
uid,
attachment,
attachment_name,
channel_id,
avatar,
content,
embed_author,
embed_author_url,
embed_color,
embed_description,
embed_footer,
embed_footer_url,
embed_image_url,
embed_thumbnail_url,
embed_title,
embed_fields,
enabled,
expires,
interval_seconds,
interval_days,
interval_months,
name,
restartable,
tts,
username,
`utc_time`
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
new_uid,
reminder.attachment,
reminder.attachment_name,
channel,
reminder.avatar,
reminder.content,
reminder.embed_author,
reminder.embed_author_url,
reminder.embed_color,
reminder.embed_description,
reminder.embed_footer,
reminder.embed_footer_url,
reminder.embed_image_url,
reminder.embed_thumbnail_url,
reminder.embed_title,
reminder.embed_fields,
reminder.enabled,
reminder.expires,
reminder.interval_seconds,
reminder.interval_days,
reminder.interval_months,
name,
reminder.restartable,
reminder.tts,
username,
reminder.utc_time,
)
.execute(transaction.executor())
.await
{
Ok(_) => sqlx::query_as_unchecked!(
Reminder,
"SELECT
reminders.attachment,
reminders.attachment_name,
reminders.avatar,
channels.channel,
reminders.content,
reminders.embed_author,
reminders.embed_author_url,
reminders.embed_color,
reminders.embed_description,
reminders.embed_footer,
reminders.embed_footer_url,
reminders.embed_image_url,
reminders.embed_thumbnail_url,
reminders.embed_title,
reminders.embed_fields,
reminders.enabled,
reminders.expires,
reminders.interval_seconds,
reminders.interval_days,
reminders.interval_months,
reminders.name,
reminders.restartable,
reminders.tts,
reminders.uid,
reminders.username,
reminders.utc_time
FROM reminders
LEFT JOIN channels ON channels.id = reminders.channel_id
WHERE uid = ?",
new_uid
)
.fetch_one(transaction.executor())
.await
.map(|r| Ok(json!(r)))
.unwrap_or_else(|e| {
warn!("Failed to complete SQL query: {:?}", e);
Err(json!({"error": "Could not load reminder"}))
}),
Err(e) => {
warn!("Error in `create_reminder`: Could not execute query: {:?}", e);
Err(json!({"error": "Unknown error"}))
}
}
}
async fn create_database_channel( async fn create_database_channel(
ctx: impl AsRef<Http>, ctx: impl AsRef<Http>,
channel: ChannelId, channel: ChannelId,
pool: impl Executor<'_, Database = Database> + Copy, transaction: &mut Transaction<'_>,
) -> Result<u32, crate::Error> { ) -> Result<u32, crate::Error> {
println!("{:?}", channel);
let row = let row =
sqlx::query!("SELECT webhook_token, webhook_id FROM channels WHERE channel = ?", channel.0) sqlx::query!("SELECT webhook_token, webhook_id FROM channels WHERE channel = ?", channel.0)
.fetch_one(pool) .fetch_one(transaction.executor())
.await; .await;
match row { match row {
@ -246,7 +612,7 @@ async fn create_database_channel(
webhook.token, webhook.token,
channel.0 channel.0
) )
.execute(pool) .execute(transaction.executor())
.await .await
.map_err(|e| Error::SQLx(e))?; .map_err(|e| Error::SQLx(e))?;
} }
@ -272,7 +638,7 @@ async fn create_database_channel(
webhook.token, webhook.token,
channel.0 channel.0
) )
.execute(pool) .execute(transaction.executor())
.await .await
.map_err(|e| Error::SQLx(e))?; .map_err(|e| Error::SQLx(e))?;
@ -283,7 +649,7 @@ async fn create_database_channel(
}?; }?;
let row = sqlx::query!("SELECT id FROM channels WHERE channel = ?", channel.0) let row = sqlx::query!("SELECT id FROM channels WHERE channel = ?", channel.0)
.fetch_one(pool) .fetch_one(transaction.executor())
.await .await
.map_err(|e| Error::SQLx(e))?; .map_err(|e| Error::SQLx(e))?;
@ -293,17 +659,19 @@ async fn create_database_channel(
#[get("/")] #[get("/")]
pub async fn dashboard_home(cookies: &CookieJar<'_>) -> Result<Template, Redirect> { pub async fn dashboard_home(cookies: &CookieJar<'_>) -> Result<Template, Redirect> {
if cookies.get_private("userid").is_some() { if cookies.get_private("userid").is_some() {
let map: HashMap<&str, String> = HashMap::new(); let mut map = HashMap::new();
map.insert("version", env!("CARGO_PKG_VERSION"));
Ok(Template::render("dashboard", &map)) Ok(Template::render("dashboard", &map))
} else { } else {
Err(Redirect::to("/login/discord")) Err(Redirect::to("/login/discord"))
} }
} }
#[get("/<_>")] #[get("/<_..>")]
pub async fn dashboard(cookies: &CookieJar<'_>) -> Result<Template, Redirect> { pub async fn dashboard(cookies: &CookieJar<'_>) -> Result<Template, Redirect> {
if cookies.get_private("userid").is_some() { if cookies.get_private("userid").is_some() {
let map: HashMap<&str, String> = HashMap::new(); let mut map = HashMap::new();
map.insert("version", env!("CARGO_PKG_VERSION"));
Ok(Template::render("dashboard", &map)) Ok(Template::render("dashboard", &map))
} else { } else {
Err(Redirect::to("/login/discord")) Err(Redirect::to("/login/discord"))

View File

@ -11,7 +11,7 @@ use rocket::{
}; };
use serenity::model::user::User; use serenity::model::user::User;
use crate::consts::DISCORD_API; use crate::{consts::DISCORD_API, routes};
#[get("/discord")] #[get("/discord")]
pub async fn discord_login( pub async fn discord_login(
@ -25,7 +25,6 @@ pub async fn discord_login(
// Set the desired scopes. // Set the desired scopes.
.add_scope(Scope::new("identify".to_string())) .add_scope(Scope::new("identify".to_string()))
.add_scope(Scope::new("guilds".to_string())) .add_scope(Scope::new("guilds".to_string()))
.add_scope(Scope::new("email".to_string()))
// Set the PKCE code challenge. // Set the PKCE code challenge.
.set_pkce_challenge(pkce_challenge) .set_pkce_challenge(pkce_challenge)
.url(); .url();
@ -53,6 +52,15 @@ pub async fn discord_login(
Redirect::to(auth_url.to_string()) Redirect::to(auth_url.to_string())
} }
#[get("/discord/logout")]
pub async fn discord_logout(cookies: &CookieJar<'_>) -> Redirect {
cookies.remove_private(Cookie::named("username"));
cookies.remove_private(Cookie::named("userid"));
cookies.remove_private(Cookie::named("access_token"));
Redirect::to(uri!(routes::index))
}
#[get("/discord/authorized?<code>&<state>")] #[get("/discord/authorized?<code>&<state>")]
pub async fn discord_callback( pub async fn discord_callback(
code: &str, code: &str,
@ -136,14 +144,14 @@ pub async fn discord_callback(
Err(Flash::new( Err(Flash::new(
Redirect::to(uri!(super::return_to_same_site(""))), Redirect::to(uri!(super::return_to_same_site(""))),
"warning", "warning",
"Your login request was rejected", "Your login request was rejected. The server may be misconfigured. Please retry or alert us in Discord.",
)) ))
} }
} }
} else { } else {
Err(Flash::new(Redirect::to(uri!(super::return_to_same_site(""))), "danger", "Your request failed to validate, and so has been rejected (error: CSRF Validation Failure)")) Err(Flash::new(Redirect::to(uri!(super::return_to_same_site(""))), "danger", "Your request failed to validate, and so has been rejected (CSRF Validation Failure)"))
} }
} else { } else {
Err(Flash::new(Redirect::to(uri!(super::return_to_same_site(""))), "warning", "Your request was missing information, and so has been rejected (error: CSRF Validation Tokens Missing)")) Err(Flash::new(Redirect::to(uri!(super::return_to_same_site(""))), "warning", "Your request was missing information, and so has been rejected (CSRF Validation Tokens Missing)"))
} }
} }

View File

@ -1,11 +1,15 @@
pub mod admin;
pub mod dashboard; pub mod dashboard;
pub mod login; pub mod login;
pub mod report;
use std::collections::HashMap; use std::collections::HashMap;
use rocket::request::FlashMessage; use rocket::{request::FlashMessage, serde::json::Value as JsonValue};
use rocket_dyn_templates::Template; use rocket_dyn_templates::Template;
pub type JsonResult = Result<JsonValue, JsonValue>;
#[get("/")] #[get("/")]
pub async fn index(flash: Option<FlashMessage<'_>>) -> Template { pub async fn index(flash: Option<FlashMessage<'_>>) -> Template {
let mut map: HashMap<&str, String> = HashMap::new(); let mut map: HashMap<&str, String> = HashMap::new();

48
web/src/routes/report.rs Normal file
View File

@ -0,0 +1,48 @@
use rocket::{
http::CookieJar,
serde::{
json::{json, Json},
Deserialize,
},
};
use crate::routes::JsonResult;
#[derive(Deserialize)]
pub struct ClientError {
#[serde(rename = "reporterId")]
reporter_id: String,
url: String,
#[serde(rename = "relativeTimestamp")]
relative_timestamp: i64,
#[serde(rename = "errorMessage")]
error_message: String,
#[serde(rename = "errorLine")]
error_line: u64,
#[serde(rename = "errorFile")]
error_file: String,
#[serde(rename = "errorType")]
error_type: String,
}
#[post("/report", data = "<client_error>")]
pub async fn report_error(cookies: &CookieJar<'_>, client_error: Json<ClientError>) -> JsonResult {
if let Some(user_id) = cookies.get_private("userid") {
error!(
"User {} reports a client-side error.
{}, {}:{} at {}ms
{}: {}
Chain: {}",
user_id,
client_error.url,
client_error.error_file,
client_error.error_line,
client_error.relative_timestamp,
client_error.error_type,
client_error.error_message,
client_error.reporter_id
);
}
Ok(json!({}))
}

View File

@ -11,10 +11,26 @@ div.reminderContent.is-collapsed .column.discord-frame {
display: none; display: none;
} }
div.reminderContent.is-collapsed .collapses { div.reminderContent.is-collapsed .column.settings {
display: none; display: none;
} }
div.reminderContent.is-collapsed .reminder-settings {
margin-bottom: 0;
}
div.reminderContent.is-collapsed .button-row {
display: none;
}
div.reminderContent.is-collapsed .button-row-edit {
display: none;
}
div.reminderContent.is-collapsed .reminder-topbar {
padding-bottom: 0;
}
div.reminderContent.is-collapsed .invert-collapses { div.reminderContent.is-collapsed .invert-collapses {
display: inline-flex; display: inline-flex;
} }
@ -23,42 +39,42 @@ div.reminderContent .invert-collapses {
display: none; display: none;
} }
div.reminderContent.is-collapsed .settings {
display: flex;
flex-direction: row;
padding-bottom: 0;
}
div.reminderContent.is-collapsed .channel-field {
display: inline-flex;
order: 1;
}
div.reminderContent.is-collapsed .reminder-topbar {
display: inline-flex;
margin-bottom: 0px;
flex-grow: 1;
order: 2;
}
div.reminderContent.is-collapsed input[name="name"] { div.reminderContent.is-collapsed input[name="name"] {
display: inline-flex; display: inline-flex;
flex-grow: 1; flex-grow: 1;
border: none; border: none;
font-weight: 700;
background: none; background: none;
box-shadow: none;
opacity: 1;
} }
div.reminderContent.is-collapsed button.hide-box { div.reminderContent.is-collapsed .hide-box {
display: inline-flex; display: inline-flex;
} }
div.reminderContent.is-collapsed button.hide-box i { div.reminderContent.is-collapsed .hide-box i {
transform: rotate(90deg); transform: rotate(90deg);
} }
/* END */ /* END */
/* dashboard styles */ /* dashboard styles */
.hide-box {
border: none;
background: none;
}
.hide-box:focus {
outline: none;
box-shadow: none !important;
}
.channel-bar {
display: flex;
justify-content: center;
flex-direction: column;
font-weight: bold;
}
button.inline-btn { button.inline-btn {
height: 100%; height: 100%;
padding: 5px; padding: 5px;
@ -85,18 +101,86 @@ div.discord-embed {
position: relative; position: relative;
} }
div.reminderContent { div.split-controls {
padding: 2px; display: flex;
background-color: #f5f5f5; flex-direction: column;
border-radius: 8px; justify-content: space-between;
margin: 8px; flex-grow: 2;
} }
div.interval-group > button { .reminder-topbar > div {
margin-left: auto; padding-left: 6px;
padding-right: 6px;
}
.settings {
display: flex;
flex-direction: column;
}
.name-bar {
flex-grow: 1;
flex-shrink: 1;
}
.hide-button-bar {
flex-grow: 0;
flex-shrink: 0;
}
.patreon-only {
padding-bottom: 16px;
}
.tts-row {
padding-bottom: 10px;
}
.reminder-topbar {
display: flex;
margin-bottom: 0 !important;
}
.reminder-settings {
margin-top: 0 !important;
}
.reminder-settings > .column {
flex-grow: 0;
flex-shrink: 0;
flex-basis: 50%;
}
div.reminderContent {
margin-top: 10px;
margin-bottom: 10px;
padding: 14px;
background-color: #f5f5f5;
border-radius: 8px;
} }
/* Interval inputs */ /* Interval inputs */
div.interval-group {
height: unset !important;
}
div.interval-group .clear:focus {
outline: none;
box-shadow: none !important;
}
div.interval-group .no-break {
text-wrap: avoid;
white-space: nowrap;
}
div.interval-group .clear {
border: none;
background: none;
padding: 1px;
margin-right: -3px;
}
div.interval-group > .interval-group-left input { div.interval-group > .interval-group-left input {
-webkit-appearance: none; -webkit-appearance: none;
border-style: none; border-style: none;
@ -110,12 +194,13 @@ div.interval-group > .interval-group-left input.w2 {
} }
div.interval-group > .interval-group-left input.w3 { div.interval-group > .interval-group-left input.w3 {
width: 6ch; width: 3ch;
} }
div.interval-group { div.interval-group {
display: flex; display: flex;
flex-direction: row; flex-direction: row;
justify-content: space-between;
} }
/* !Interval inputs */ /* !Interval inputs */
@ -180,6 +265,23 @@ div#pageNavbar a {
text-align: center; text-align: center;
} }
.navbar-burger {
flex-shrink: 0;
}
.navbar-item.pageTitle {
flex-shrink: 1;
white-space: nowrap;
overflow: hidden;
}
.dashboard-burger, .dashboard-burger:active, .dashboard-burger.is-active {
background-color: #adc99c !important;
border-radius: 14px;
padding: 6px;
background-clip: content-box;
}
div#pageNavbar a:hover { div#pageNavbar a:hover {
background-color: #4a4a4a; background-color: #4a4a4a;
} }
@ -206,17 +308,24 @@ div.dashboard-sidebar {
padding-right: 0; padding-right: 0;
} }
div.dashboard-sidebar:not(.mobile-sidebar) { ul.guildList {
display: flex; flex-grow: 1;
flex-direction: column; flex-shrink: 1;
overflow: auto;
} }
div.dashboard-sidebar:not(.mobile-sidebar) .aside-footer { div.dashboard-sidebar:not(.mobile-sidebar) .aside-footer {
flex-shrink: 0;
flex-grow: 0;
position: fixed; position: fixed;
bottom: 0; bottom: 0;
width: 226px; width: 226px;
} }
div.dashboard-sidebar svg {
flex-shrink: 0;
}
div.mobile-sidebar { div.mobile-sidebar {
z-index: 100; z-index: 100;
min-height: 100vh; min-height: 100vh;
@ -293,10 +402,7 @@ input.default-width {
} }
.message-input:placeholder-shown { .message-input:placeholder-shown {
border-top: none; font-style: italic;
border-left: none;
border-right: none;
border-bottom-style: dashed;
background-color: #40444b; background-color: #40444b;
color: #fff; color: #fff;
} }
@ -367,8 +473,7 @@ input.default-width {
.customizable.is-400x300 img { .customizable.is-400x300 img {
margin-top: 10px; margin-top: 10px;
width: 100%; width: 100%;
min-height: 100px; height: 100px;
max-height: 400px;
} }
.customizable.is-32x32 img { .customizable.is-32x32 img {
@ -462,6 +567,7 @@ input.default-width {
flex-grow: 1; flex-grow: 1;
flex-shrink: 1; flex-shrink: 1;
flex-basis: auto; flex-basis: auto;
margin-right: 4px;
} }
.embed-body input, .embed-body textarea { .embed-body input, .embed-body textarea {
@ -511,21 +617,84 @@ input.default-width {
border-bottom: 1px solid #fff; border-bottom: 1px solid #fff;
} }
@media only screen and (max-width: 768px) { .channel-selector {
width: 100%;
}
.select {
width: 100%;
}
li.highlight {
margin-bottom: 0 !important;
}
.button-row {
display: flex;
}
.button-row .button-row-reminder {
flex-grow: 0;
padding: 2px;
}
.button-row-template {
display: flex;
flex-grow: 1;
justify-content: space-between;
}
.button-row .button-row-template > div {
padding: 2px;
}
@media only screen and (max-width: 1023px) {
p.title.pageTitle {
display: none;
}
.dashboard-frame {
margin-top: 4rem !important;
}
.customizable.thumbnail img { .customizable.thumbnail img {
width: 60px; width: 60px;
height: 60px; height: 60px;
} }
}
.customizable.is-24x24 img { @media only screen and (max-width: 768px) {
width: 16px; .button-row {
height: 16px; display: flex;
flex-direction: column;
}
.button-row .button-row-reminder {
width: 100%;
}
.button-row .button-row-template > div {
flex-basis: 0;
flex-grow: 1;
}
.button-row button {
width: 100%;
}
.reminder-settings {
margin-bottom: 0 !important;
}
.tts-row {
padding-bottom: 0;
} }
} }
/* loader */ /* loader */
#loader { #loader {
position: fixed; position: fixed;
top: 0;
background-color: rgba(255, 255, 255, 0.8); background-color: rgba(255, 255, 255, 0.8);
width: 100vw; width: 100vw;
z-index: 999; z-index: 999;
@ -537,6 +706,86 @@ input.default-width {
/* END */ /* END */
div.reminderError {
margin: 10px;
padding: 14px;
background-color: #f5f5f5;
border-radius: 8px;
}
div.reminderError .errorHead {
display: flex;
flex-direction: row;
}
div.reminderError .errorIcon {
padding: 8px;
border-radius: 4px;
margin-right: 12px;
}
div.reminderError .errorIcon .fas {
display: none
}
div.reminderError[data-case="deleted"] .errorIcon {
background-color: #e7e5e4;
}
div.reminderError[data-case="failed"] .errorIcon {
background-color: #fecaca;
}
div.reminderError[data-case="sent"] .errorIcon {
background-color: #d9f99d;
}
div.reminderError[data-case="deleted"] .errorIcon .fas.fa-trash {
display: block;
}
div.reminderError[data-case="failed"] .errorIcon .fas.fa-exclamation-triangle {
display: block;
}
div.reminderError[data-case="sent"] .errorIcon .fas.fa-check {
display: block;
}
div.reminderError .errorHead .reminderName {
font-size: 1rem;
display: flex;
flex-direction: column;
justify-content: center;
color: rgb(54, 54, 54);
flex-grow: 1;
}
div.reminderError .errorHead .reminderTime {
font-size: 1rem;
display: flex;
flex-direction: column;
flex-shrink: 1;
justify-content: center;
color: rgb(54, 54, 54);
background-color: #ffffff;
padding: 8px;
border-radius: 4px;
border-color: #e5e5e5;
border-width: 1px;
border-style: solid;
}
div.reminderError .reminderMessage {
font-size: 1rem;
display: flex;
flex-direction: column;
justify-content: center;
color: rgb(54, 54, 54);
flex-grow: 1;
font-style: italic;
}
/* other stuff */ /* other stuff */
.half-rem { .half-rem {
@ -568,11 +817,44 @@ input.default-width {
background-color: white; background-color: white;
} }
a.switch-pane {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.guild-submenu {
display: none;
}
.guild-submenu li {
font-size: 0.8rem;
}
a.switch-pane.is-active ~ .guild-submenu {
display: block;
}
.feedback {
background-color: #5865F2;
}
.is-locked { .is-locked {
pointer-events: none; pointer-events: none;
}
.is-locked > :not(.patreon-invert) {
opacity: 0.4; opacity: 0.4;
} }
.is-locked .patreon-invert {
display: block;
}
.patreon-invert {
display: none;
}
.is-locked .foreground { .is-locked .foreground {
pointer-events: auto; pointer-events: auto;
} }
@ -580,3 +862,27 @@ input.default-width {
.is-locked .field:last-of-type { .is-locked .field:last-of-type {
display: none; display: none;
} }
.stat-row {
display: flex;
flex-direction: row;
}
.stat-box {
flex-grow: 1;
border-radius: 6px;
background-color: #fcfcfc;
border-color: #efefef;
border-style: solid;
border-width: 1px;
margin: 4px;
padding: 4px;
}
.figure {
text-align: center;
}
.figure-num {
font-size: 2rem;
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 81 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

131
web/static/js/admin.js Normal file
View File

@ -0,0 +1,131 @@
document.addEventListener("DOMContentLoaded", () => {
fetch("/admin/data")
.then((resp) => resp.json())
.then((data) => {
document.querySelector("#backlog").textContent = data.backlog;
document.querySelector("#reminders").textContent = data.count.reminders;
document.querySelector("#intervals").textContent = data.count.intervals;
let historySent = data.historyLong.sent.reduce(
(iv, frame) => iv + frame.count,
0
);
let historyFailed = data.historyLong.failed.reduce(
(iv, frame) => iv + frame.count,
0
);
let rate = historyFailed / (historySent + historyFailed);
let formatted = Math.round(rate * 10000) / 100;
document.querySelector("#historySent").textContent = historySent;
document.querySelector("#historyFailed").textContent = historyFailed;
document.querySelector("#failRate").textContent = `${formatted}%`;
new Chart(document.getElementById("schedule"), {
type: "bar",
data: {
labels: [
...data.scheduleShort.once,
...data.scheduleShort.interval,
].map((row) => luxon.DateTime.fromISO(row.time_key)),
datasets: [
{
label: "Reminders",
data: data.scheduleShort.once.map((row) => row.count),
},
{
label: "Intervals",
data: data.scheduleShort.interval.map((row) => row.count),
},
],
},
options: {
responsive: true,
maintainAspectRatio: false,
scales: {
x: {
stacked: true,
type: "time",
time: {
unit: "minute",
},
},
y: {
stacked: true,
},
},
},
});
new Chart(document.getElementById("scheduleLong"), {
type: "bar",
data: {
labels: [
...data.scheduleLong.once,
...data.scheduleLong.interval,
].map((row) => luxon.DateTime.fromISO(row.time_key)),
datasets: [
{
label: "Reminders",
data: data.scheduleLong.once.map((row) => row.count),
},
{
label: "Intervals",
data: data.scheduleLong.interval.map((row) => row.count),
},
],
},
options: {
responsive: true,
maintainAspectRatio: false,
scales: {
x: {
stacked: true,
type: "time",
time: {
unit: "day",
},
},
y: {
stacked: true,
},
},
},
});
new Chart(document.getElementById("historyLong"), {
type: "bar",
data: {
labels: [...data.historyLong.sent, ...data.historyLong.failed].map(
(row) => luxon.DateTime.fromISO(row.time_key)
),
datasets: [
{
label: "Success",
data: data.historyLong.sent.map((row) => row.count),
},
{
label: "Fail",
data: data.historyLong.failed.map((row) => row.count),
},
],
},
options: {
responsive: true,
maintainAspectRatio: false,
scales: {
x: {
stacked: true,
type: "time",
time: {
unit: "day",
},
},
y: {
stacked: true,
},
},
},
});
});
});

20
web/static/js/chart.js Normal file

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1,7 @@
/*!
* chartjs-adapter-luxon v1.3.1
* https://www.chartjs.org
* (c) 2023 chartjs-adapter-luxon Contributors
* Released under the MIT license
*/
!function(e,t){"object"==typeof exports&&"undefined"!=typeof module?t(require("chart.js"),require("luxon")):"function"==typeof define&&define.amd?define(["chart.js","luxon"],t):t((e="undefined"!=typeof globalThis?globalThis:e||self).Chart,e.luxon)}(this,(function(e,t){"use strict";const n={datetime:t.DateTime.DATETIME_MED_WITH_SECONDS,millisecond:"h:mm:ss.SSS a",second:t.DateTime.TIME_WITH_SECONDS,minute:t.DateTime.TIME_SIMPLE,hour:{hour:"numeric"},day:{day:"numeric",month:"short"},week:"DD",month:{month:"short",year:"numeric"},quarter:"'Q'q - yyyy",year:{year:"numeric"}};e._adapters._date.override({_id:"luxon",_create:function(e){return t.DateTime.fromMillis(e,this.options)},init(e){this.options.locale||(this.options.locale=e.locale)},formats:function(){return n},parse:function(e,n){const i=this.options,r=typeof e;return null===e||"undefined"===r?null:("number"===r?e=this._create(e):"string"===r?e="string"==typeof n?t.DateTime.fromFormat(e,n,i):t.DateTime.fromISO(e,i):e instanceof Date?e=t.DateTime.fromJSDate(e,i):"object"!==r||e instanceof t.DateTime||(e=t.DateTime.fromObject(e,i)),e.isValid?e.valueOf():null)},format:function(e,t){const n=this._create(e);return"string"==typeof t?n.toFormat(t):n.toLocaleString(t)},add:function(e,t,n){const i={};return i[n]=t,this._create(e).plus(i).valueOf()},diff:function(e,t,n){return this._create(e).diff(this._create(t)).as(n).valueOf()},startOf:function(e,t,n){if("isoWeek"===t){n=Math.trunc(Math.min(Math.max(0,n),6));const t=this._create(e);return t.minus({days:(t.weekday-n+7)%7}).startOf("day").valueOf()}return t?this._create(e).startOf(t).valueOf():e},endOf:function(e,t){return this._create(e).endOf(t).valueOf()}})}));

View File

@ -7,9 +7,9 @@ function get_interval(element) {
return { return {
months: parseInt(months) || null, months: parseInt(months) || null,
days: parseInt(days) || null,
seconds: seconds:
(parseInt(days) || 0) * 86400 + (parseInt(hours) || 0) * 3600 +
(parseInt(hours) || 0) * 3600 +
(parseInt(minutes) || 0) * 60 + (parseInt(minutes) || 0) * 60 +
(parseInt(seconds) || 0) || null, (parseInt(seconds) || 0) || null,
}; };
@ -22,32 +22,38 @@ function update_interval(element) {
let minutes = element.querySelector('input[name="interval_minutes"]'); let minutes = element.querySelector('input[name="interval_minutes"]');
let seconds = element.querySelector('input[name="interval_seconds"]'); let seconds = element.querySelector('input[name="interval_seconds"]');
months.value = months.value.padStart(1, "0"); let interval = get_interval(element);
days.value = days.value.padStart(1, "0");
hours.value = hours.value.padStart(2, "0");
minutes.value = minutes.value.padStart(2, "0");
seconds.value = seconds.value.padStart(2, "0");
if (seconds.value >= 60) { if (interval.months === null && interval.days === null && interval.seconds === null) {
let quotient = Math.floor(seconds.value / 60); months.value = "";
let remainder = seconds.value % 60; days.value = "";
hours.value = "";
minutes.value = "";
seconds.value = "";
} else {
months.value = months.value.padStart(1, "0");
days.value = days.value.padStart(1, "0");
hours.value = hours.value.padStart(2, "0");
minutes.value = minutes.value.padStart(2, "0");
seconds.value = seconds.value.padStart(2, "0");
seconds.value = String(remainder).padStart(2, "0"); if (seconds.value >= 60) {
minutes.value = String(Number(minutes.value) + Number(quotient)).padStart(2, "0"); let quotient = Math.floor(seconds.value / 60);
} let remainder = seconds.value % 60;
if (minutes.value >= 60) {
let quotient = Math.floor(minutes.value / 60);
let remainder = minutes.value % 60;
minutes.value = String(remainder).padStart(2, "0"); seconds.value = String(remainder).padStart(2, "0");
hours.value = String(Number(hours.value) + Number(quotient)).padStart(2, "0"); minutes.value = String(Number(minutes.value) + Number(quotient)).padStart(
} 2,
if (hours.value >= 24) { "0"
let quotient = Math.floor(hours.value / 24); );
let remainder = hours.value % 24; }
if (minutes.value >= 60) {
let quotient = Math.floor(minutes.value / 60);
let remainder = minutes.value % 60;
hours.value = String(remainder).padStart(2, "0"); minutes.value = String(remainder).padStart(2, "0");
days.value = Number(days.value) + Number(quotient); hours.value = String(Number(hours.value) + Number(quotient)).padStart(2, "0");
}
} }
} }

View File

@ -12,6 +12,10 @@ const $createTemplateBtn = $createReminder.querySelector("button#createTemplate"
const $loadTemplateBtn = document.querySelector("button#load-template"); const $loadTemplateBtn = document.querySelector("button#load-template");
const $deleteTemplateBtn = document.querySelector("button#delete-template"); const $deleteTemplateBtn = document.querySelector("button#delete-template");
const $templateSelect = document.querySelector("select#templateSelect"); const $templateSelect = document.querySelector("select#templateSelect");
const $exportBtn = document.querySelector("button#export-data");
const $importBtn = document.querySelector("button#import-data");
const $downloader = document.querySelector("a#downloader");
const $uploader = document.querySelector("input#uploader");
let channels = []; let channels = [];
let guildNames = {}; let guildNames = {};
@ -29,7 +33,16 @@ let globalPatreon = false;
let guildPatreon = false; let guildPatreon = false;
function guildId() { function guildId() {
return document.querySelector(".guildList a.is-active").dataset["guild"]; return window.location.pathname.match(/dashboard\/(\d+)/)[1];
}
function pane() {
const match = window.location.pathname.match(/dashboard\/\d+\/(.+)/);
if (match === null) {
return null;
} else {
return match[1];
}
} }
function colorToInt(r, g, b) { function colorToInt(r, g, b) {
@ -52,18 +65,36 @@ function switch_pane(selector) {
} }
function update_select(sel) { function update_select(sel) {
if (sel.selectedOptions[0].dataset["webhookAvatar"]) { let channelDisplay = sel.closest("div.reminderContent").querySelector(".channel-bar");
sel.closest("div.reminderContent").querySelector("img.discord-avatar").src =
sel.selectedOptions[0].dataset["webhookAvatar"]; if (channelDisplay !== null) {
} else { channelDisplay.textContent = `#${sel.selectedOptions[0].textContent}`;
sel.closest("div.reminderContent").querySelector("img.discord-avatar").src = "";
} }
if (sel.selectedOptions[0].dataset["webhookName"]) {
sel.closest("div.reminderContent").querySelector("input.discord-username").value = if (sel.selectedOptions[0] === undefined) {
sel.selectedOptions[0].dataset["webhookName"]; return;
} else { }
sel.closest("div.reminderContent").querySelector("input.discord-username").value =
""; const avatarInput = sel.closest("div.reminderContent").querySelector("img.avatar");
if (!avatarInput.dataset["set"]) {
if (sel.selectedOptions[0].dataset["webhookAvatar"]) {
avatarInput.src = sel.selectedOptions[0].dataset["webhookAvatar"];
} else {
avatarInput.src = "/static/img/icon.png";
}
}
const usernameInput = sel
.closest("div.reminderContent")
.querySelector("input.discord-username");
if (usernameInput.value.length === 0) {
if (sel.selectedOptions[0].dataset["webhookName"]) {
usernameInput.value = sel.selectedOptions[0].dataset["webhookName"];
} else {
usernameInput.value = "Reminder";
}
} }
} }
@ -74,7 +105,7 @@ function reset_guild_pane() {
} }
async function fetch_patreon(guild_id) { async function fetch_patreon(guild_id) {
fetch(`/dashboard/api/guild/${guild_id}/patreon`) fetch(`/dashboard/api/guild/${guild_id}`)
.then((response) => response.json()) .then((response) => response.json())
.then((data) => { .then((data) => {
if (data.error) { if (data.error) {
@ -134,12 +165,18 @@ async function fetch_channels(guild_id) {
const event = new Event("channelsLoading"); const event = new Event("channelsLoading");
document.dispatchEvent(event); document.dispatchEvent(event);
let hasError = false;
await fetch(`/dashboard/api/guild/${guild_id}/channels`) await fetch(`/dashboard/api/guild/${guild_id}/channels`)
.then((response) => response.json()) .then((response) => response.json())
.then((data) => { .then((data) => {
if (data.error) { if (data.error) {
if (data.error === "Bot not in guild") { if (data.error === "Bot not in guild") {
switch_pane("guild-error"); switch_pane("guild-error");
hasError = true;
} else if (data.error === "Incorrect permissions") {
switch_pane("user-error");
hasError = true;
} else { } else {
show_error(data.error); show_error(data.error);
} }
@ -151,6 +188,8 @@ async function fetch_channels(guild_id) {
const event = new Event("channelsLoaded"); const event = new Event("channelsLoaded");
document.dispatchEvent(event); document.dispatchEvent(event);
}); });
return hasError;
} }
async function fetch_reminders(guild_id) { async function fetch_reminders(guild_id) {
@ -193,30 +232,39 @@ async function fetch_reminders(guild_id) {
} }
async function serialize_reminder(node, mode) { async function serialize_reminder(node, mode) {
let interval, utc_time, expiration_time; let utc_time, expiration_time;
let interval = get_interval(node);
if (mode !== "template") { if (mode !== "template") {
interval = get_interval(node);
utc_time = luxon.DateTime.fromISO( utc_time = luxon.DateTime.fromISO(
node.querySelector('input[name="time"]').value node.querySelector('input[name="time"]').value
).setZone("UTC"); ).setZone("UTC");
if (utc_time.invalid) { if (utc_time.invalid) {
return { error: "Time provided invalid." }; return { error: "Time provided invalid." };
} else { } else {
utc_time = utc_time.toFormat("yyyy-LL-dd'T'HH:mm:ss"); utc_time = utc_time.toFormat("yyyy-LL-dd'T'HH:mm:ss");
} }
expiration_time = luxon.DateTime.fromISO( let expiration = node.querySelector('input[name="expiration"]').value;
node.querySelector('input[name="time"]').value
).setZone("UTC"); if (expiration) {
if (expiration_time.invalid) { expiration_time = luxon.DateTime.fromISO(
return { error: "Expiration provided invalid." }; node.querySelector('input[name="expiration"]').value
} else { ).setZone("UTC");
expiration_time = expiration_time.toFormat("yyyy-LL-dd'T'HH:mm:ss"); if (expiration_time.invalid) {
return { error: "Expiration provided invalid." };
} else {
expiration_time = expiration_time.toFormat("yyyy-LL-dd'T'HH:mm:ss");
}
} }
} }
let name = node.querySelector('input[name="name"]').value;
if (name.length > 100) {
return { error: "Name exceeds maximum length (100)." };
}
let rgb_color = window.getComputedStyle( let rgb_color = window.getComputedStyle(
node.querySelector("div.discord-embed") node.querySelector("div.discord-embed")
).borderLeftColor; ).borderLeftColor;
@ -279,15 +327,17 @@ async function serialize_reminder(node, mode) {
const embed_title = node.querySelector('textarea[name="embed_title"]').value; const embed_title = node.querySelector('textarea[name="embed_title"]').value;
if ( if (
attachment === null && content.length === 0 &&
content.length == 0 && embed_author.length === 0 &&
embed_title.length === 0 &&
embed_description.length === 0 &&
embed_footer.length === 0 &&
embed_author_url === null && embed_author_url === null &&
embed_author.length == 0 &&
embed_description.length == 0 &&
embed_footer.length == 0 &&
embed_footer_url === null && embed_footer_url === null &&
embed_image_url === null && embed_image_url === null &&
embed_thumbnail_url === null embed_thumbnail_url === null &&
fields.length === 0 &&
attachment === null
) { ) {
return { error: "Reminder needs content." }; return { error: "Reminder needs content." };
} }
@ -300,7 +350,7 @@ async function serialize_reminder(node, mode) {
restartable: false, restartable: false,
attachment: attachment, attachment: attachment,
attachment_name: attachment_name, attachment_name: attachment_name,
avatar: has_source(node.querySelector("img.discord-avatar").src), avatar: has_source(node.querySelector("img.avatar").src),
channel: node.querySelector("select.channel-selector").value, channel: node.querySelector("select.channel-selector").value,
content: content, content: content,
embed_author_url: embed_author_url, embed_author_url: embed_author_url,
@ -314,8 +364,9 @@ async function serialize_reminder(node, mode) {
embed_title: embed_title, embed_title: embed_title,
embed_fields: fields, embed_fields: fields,
expires: expiration_time, expires: expiration_time,
interval_seconds: mode !== "template" ? interval.seconds : null, interval_seconds: interval.seconds,
interval_months: mode !== "template" ? interval.months : null, interval_days: interval.days,
interval_months: interval.months,
name: node.querySelector('input[name="name"]').value, name: node.querySelector('input[name="name"]').value,
tts: node.querySelector('input[name="tts"]').checked, tts: node.querySelector('input[name="tts"]').checked,
username: node.querySelector('input[name="username"]').value, username: node.querySelector('input[name="username"]').value,
@ -327,6 +378,9 @@ function deserialize_reminder(reminder, frame, mode) {
// populate channels // populate channels
set_channels(frame.querySelector("select.channel-selector")); set_channels(frame.querySelector("select.channel-selector"));
frame.querySelector(`*[name="interval_hours"]`).value = 0;
frame.querySelector(`*[name="interval_minutes"]`).value = 0;
// populate majority of items // populate majority of items
for (let prop in reminder) { for (let prop in reminder) {
if (reminder.hasOwnProperty(prop) && reminder[prop] !== null) { if (reminder.hasOwnProperty(prop) && reminder[prop] !== null) {
@ -341,15 +395,27 @@ function deserialize_reminder(reminder, frame, mode) {
if ($input !== null) { if ($input !== null) {
$input.value = reminder[prop]; $input.value = reminder[prop];
} else if ($image !== null) { } else if ($image !== null) {
console.log(`loading img ${prop}`);
$image.src = reminder[prop]; $image.src = reminder[prop];
$image.dataset["set"] = "1";
} }
} }
} }
} }
const lastChild = frame.querySelector("div.embed-multifield-box .embed-field-box"); update_interval(frame);
update_select(frame.querySelector(".channel-selector"));
for (let field of reminder["embed_fields"]) { const lastChild = frame.querySelector(
"div.embed-multifield-box .embed-field-box:last-child"
);
// Drop existing fields
frame
.querySelectorAll(".embed-field-box:not(:last-child)")
.forEach((el) => el.remove());
for (let field of reminder["embed_fields"] || []) {
let embed_field = $embedFieldTemplate.content.cloneNode(true); let embed_field = $embedFieldTemplate.content.cloneNode(true);
embed_field.querySelector("textarea.discord-field-title").value = field["title"]; embed_field.querySelector("textarea.discord-field-title").value = field["title"];
embed_field.querySelector("textarea.discord-field-value").value = field["value"]; embed_field.querySelector("textarea.discord-field-value").value = field["value"];
@ -362,9 +428,9 @@ function deserialize_reminder(reminder, frame, mode) {
.insertBefore(embed_field, lastChild); .insertBefore(embed_field, lastChild);
} }
if (mode !== "template") { if (reminder["interval_seconds"]) update_interval(frame);
if (reminder["interval_seconds"]) update_interval(frame);
if (mode !== "template") {
let $enableBtn = frame.querySelector(".disable-enable"); let $enableBtn = frame.querySelector(".disable-enable");
$enableBtn.dataset["action"] = reminder["enabled"] ? "disable" : "enable"; $enableBtn.dataset["action"] = reminder["enabled"] ? "disable" : "enable";
@ -375,7 +441,7 @@ function deserialize_reminder(reminder, frame, mode) {
timeInput.value = localTime.toFormat("yyyy-LL-dd'T'HH:mm:ss"); timeInput.value = localTime.toFormat("yyyy-LL-dd'T'HH:mm:ss");
if (reminder["expires"]) { if (reminder["expires"]) {
let expiresInput = frame.querySelector('input[name="time"]'); let expiresInput = frame.querySelector('input[name="expiration"]');
let expiresTime = luxon.DateTime.fromISO(reminder["expires"], { let expiresTime = luxon.DateTime.fromISO(reminder["expires"], {
zone: "UTC", zone: "UTC",
}).setZone(timezone); }).setZone(timezone);
@ -395,9 +461,19 @@ document.addEventListener("guildSwitched", async (e) => {
`.switch-pane[data-guild="${e.detail.guild_id}"]` `.switch-pane[data-guild="${e.detail.guild_id}"]`
); );
switch_pane($anchor.dataset["pane"]); let hasError = false;
if (pane() === null) {
window.history.replaceState({}, "", `/dashboard/${guildId()}/reminders`);
}
switch_pane(pane());
if ($anchor !== null) {
$anchor.classList.add("is-active");
}
reset_guild_pane(); reset_guild_pane();
$anchor.classList.add("is-active");
if (globalPatreon || (await fetch_patreon(e.detail.guild_id))) { if (globalPatreon || (await fetch_patreon(e.detail.guild_id))) {
document document
@ -405,19 +481,21 @@ document.addEventListener("guildSwitched", async (e) => {
.forEach((el) => el.classList.remove("is-locked")); .forEach((el) => el.classList.remove("is-locked"));
} }
fetch_roles(e.detail.guild_id); hasError = await fetch_channels(e.detail.guild_id);
fetch_templates(e.detail.guild_id); if (!hasError) {
await fetch_channels(e.detail.guild_id); fetch_roles(e.detail.guild_id);
fetch_reminders(e.detail.guild_id); fetch_templates(e.detail.guild_id);
fetch_reminders(e.detail.guild_id);
document.querySelectorAll("p.pageTitle").forEach((el) => { document.querySelectorAll("p.pageTitle").forEach((el) => {
el.textContent = `${e.detail.guild_name} Reminders`; el.textContent = `${e.detail.guild_name} Reminders`;
});
document.querySelectorAll("select.channel-selector").forEach((el) => {
el.addEventListener("change", (e) => {
update_select(e.target);
}); });
}); document.querySelectorAll("select.channel-selector").forEach((el) => {
el.addEventListener("change", (e) => {
update_select(e.target);
});
});
}
$loader.classList.add("is-hidden"); $loader.classList.add("is-hidden");
}); });
@ -429,6 +507,12 @@ document.addEventListener("channelsLoaded", () => {
document.addEventListener("remindersLoaded", (event) => { document.addEventListener("remindersLoaded", (event) => {
const guild = guildId(); const guild = guildId();
document.querySelectorAll("select.channel-selector").forEach((el) => {
el.addEventListener("change", (e) => {
update_select(e.target);
});
});
for (let reminder of event.detail) { for (let reminder of event.detail) {
let node = reminder.node; let node = reminder.node;
@ -456,9 +540,9 @@ document.addEventListener("remindersLoaded", (event) => {
if (data.error) { if (data.error) {
show_error(data.error); show_error(data.error);
} else { } else {
enableBtn.dataset["action"] = data["enabled"] enableBtn.dataset["action"] = data.reminder["enabled"]
? "enable" ? "disable"
: "disable"; : "enable";
} }
}); });
}); });
@ -493,6 +577,8 @@ document.addEventListener("remindersLoaded", (event) => {
.then((response) => response.json()) .then((response) => response.json())
.then((data) => { .then((data) => {
for (let error of data.errors) show_error(error); for (let error of data.errors) show_error(error);
deserialize_reminder(data.reminder, node, "reload");
}); });
$saveBtn.querySelector("span.icon > i").classList = ["fas fa-check"]; $saveBtn.querySelector("span.icon > i").classList = ["fas fa-check"];
@ -528,6 +614,16 @@ function show_error(error) {
}, 5000); }, 5000);
} }
function show_success(error) {
document.getElementById("success").querySelector("span.success-message").textContent =
error;
document.getElementById("success").classList.add("is-active");
window.setTimeout(() => {
document.getElementById("success").classList.remove("is-active");
}, 5000);
}
$colorPickerInput.value = colorPicker.color.hexString; $colorPickerInput.value = colorPicker.color.hexString;
$colorPickerInput.addEventListener("input", () => { $colorPickerInput.addEventListener("input", () => {
@ -553,7 +649,7 @@ document.querySelectorAll(".show-modal").forEach((element) => {
}); });
}); });
document.addEventListener("DOMContentLoaded", () => { document.addEventListener("DOMContentLoaded", async () => {
$loader.classList.remove("is-hidden"); $loader.classList.remove("is-hidden");
mentions.attach(document.querySelectorAll("textarea")); mentions.attach(document.querySelectorAll("textarea"));
@ -573,7 +669,7 @@ document.addEventListener("DOMContentLoaded", () => {
hideBox.closest(".reminderContent").classList.toggle("is-collapsed"); hideBox.closest(".reminderContent").classList.toggle("is-collapsed");
}); });
fetch("/dashboard/api/user") await fetch("/dashboard/api/user")
.then((response) => response.json()) .then((response) => response.json())
.then((data) => { .then((data) => {
if (data.error) { if (data.error) {
@ -587,7 +683,7 @@ document.addEventListener("DOMContentLoaded", () => {
} }
}); });
fetch("/dashboard/api/user/guilds") await fetch("/dashboard/api/user/guilds")
.then((response) => response.json()) .then((response) => response.json())
.then((data) => { .then((data) => {
if (data.error) { if (data.error) {
@ -610,11 +706,15 @@ document.addEventListener("DOMContentLoaded", () => {
); );
$anchor.dataset["guild"] = guild.id; $anchor.dataset["guild"] = guild.id;
$anchor.dataset["name"] = guild.name; $anchor.dataset["name"] = guild.name;
$anchor.href = `/dashboard/${guild.id}?name=${guild.name}`; $anchor.href = `/dashboard/${guild.id}/reminders`;
$anchor.addEventListener("click", async (e) => { $anchor.addEventListener("click", async (e) => {
e.preventDefault(); e.preventDefault();
window.history.pushState({}, "", `/dashboard/${guild.id}`); window.history.pushState(
{},
"",
`/dashboard/${guild.id}/reminders`
);
const event = new CustomEvent("guildSwitched", { const event = new CustomEvent("guildSwitched", {
detail: { detail: {
guild_name: guild.name, guild_name: guild.name,
@ -670,6 +770,53 @@ function has_source(string) {
} }
} }
$uploader.addEventListener("change", (ev) => {
const urlTail = document.querySelector('input[name="exportSelect"]:checked').value;
new Promise((resolve) => {
let fileReader = new FileReader();
fileReader.onload = (e) => resolve(fileReader.result);
fileReader.readAsDataURL($uploader.files[0]);
}).then((dataUrl) => {
$importBtn.setAttribute("disabled", true);
fetch(`/dashboard/api/guild/${guildId()}/export/${urlTail}`, {
method: "PUT",
body: JSON.stringify({ body: dataUrl.split(",")[1] }),
})
.then((response) => response.json())
.then((data) => {
$importBtn.removeAttribute("disabled");
if (data.error) {
show_error(data.error);
} else {
show_success(data.message);
}
})
.then(() => {
delete $uploader.files[0];
fetch_reminders(guild);
});
});
});
$importBtn.addEventListener("click", () => {
$uploader.click();
});
$exportBtn.addEventListener("click", () => {
const urlTail = document.querySelector('input[name="exportSelect"]:checked').value;
fetch(`/dashboard/api/guild/${guildId()}/export/${urlTail}`)
.then((response) => response.json())
.then((data) => {
$downloader.href =
"data:text/plain;charset=utf-8," + encodeURIComponent(data.body);
$downloader.click();
});
});
$createReminderBtn.addEventListener("click", async () => { $createReminderBtn.addEventListener("click", async () => {
$createReminderBtn.querySelector("span.icon > i").classList = [ $createReminderBtn.querySelector("span.icon > i").classList = [
"fas fa-spinner fa-spin", "fas fa-spinner fa-spin",
@ -678,6 +825,7 @@ $createReminderBtn.addEventListener("click", async () => {
let reminder = await serialize_reminder($createReminder, "create"); let reminder = await serialize_reminder($createReminder, "create");
if (reminder.error) { if (reminder.error) {
show_error(reminder.error); show_error(reminder.error);
$createReminderBtn.querySelector("span.icon > i").classList = ["fas fa-sparkles"];
return; return;
} }
@ -735,6 +883,14 @@ $createTemplateBtn.addEventListener("click", async () => {
]; ];
let reminder = await serialize_reminder($createReminder, "template"); let reminder = await serialize_reminder($createReminder, "template");
if (reminder.error) {
show_error(reminder.error);
$createTemplateBtn.querySelector("span.icon > i").classList = [
"fas fa-file-spreadsheet",
];
return;
}
let guild = guildId(); let guild = guildId();
fetch(`/dashboard/api/guild/${guild}/templates`, { fetch(`/dashboard/api/guild/${guild}/templates`, {
@ -776,30 +932,25 @@ $loadTemplateBtn.addEventListener("click", (ev) => {
}); });
$deleteTemplateBtn.addEventListener("click", (ev) => { $deleteTemplateBtn.addEventListener("click", (ev) => {
fetch(`/dashboard/api/guild/${guildId()}/templates`, { if (parseInt($templateSelect.value) !== null) {
method: "DELETE", fetch(`/dashboard/api/guild/${guildId()}/templates`, {
headers: { method: "DELETE",
"Content-Type": "application/json", headers: {
}, "Content-Type": "application/json",
body: JSON.stringify({ id: parseInt($templateSelect.value) }), },
}) body: JSON.stringify({ id: parseInt($templateSelect.value) }),
.then((response) => response.json()) })
.then((data) => { .then((response) => response.json())
if (data.error) { .then((data) => {
show_error(data.error); if (data.error) {
} else { show_error(data.error);
$templateSelect } else {
.querySelector(`option[value="${$templateSelect.value}"]`) $templateSelect
.remove(); .querySelector(`option[value="${$templateSelect.value}"]`)
} .remove();
}); }
}); });
}
document.querySelectorAll("textarea.autoresize").forEach((element) => {
element.addEventListener("input", () => {
element.style.height = "";
element.style.height = element.scrollHeight + 3 + "px";
});
}); });
let $img; let $img;
@ -834,7 +985,7 @@ document.addEventListener("remindersLoaded", () => {
}); });
}); });
const fileInput = document.querySelectorAll("input[type=file]"); const fileInput = document.querySelectorAll("input.file-input[type=file]");
fileInput.forEach((element) => { fileInput.forEach((element) => {
element.addEventListener("change", () => { element.addEventListener("change", () => {
@ -857,6 +1008,13 @@ document.addEventListener("remindersLoaded", () => {
window.getComputedStyle($discordFrame).borderLeftColor; window.getComputedStyle($discordFrame).borderLeftColor;
}); });
}); });
document.querySelectorAll("textarea.autoresize").forEach((element) => {
element.addEventListener("input", () => {
element.style.height = "";
element.style.height = element.scrollHeight + 3 + "px";
});
});
}); });
function check_embed_fields() { function check_embed_fields() {
@ -932,6 +1090,13 @@ document.addEventListener("click", (ev) => {
if (ev.target.closest("button.inline-btn") !== null) { if (ev.target.closest("button.inline-btn") !== null) {
let inlined = ev.target.closest(".embed-field-box").dataset["inlined"]; let inlined = ev.target.closest(".embed-field-box").dataset["inlined"];
ev.target.closest(".embed-field-box").dataset["inlined"] = ev.target.closest(".embed-field-box").dataset["inlined"] =
inlined == "1" ? "0" : "1"; inlined === "1" ? "0" : "1";
} }
}); });
document.addEventListener("DOMContentLoaded", () => {
let now = luxon.DateTime.now().setZone(timezone);
document.querySelectorAll(".prefill-now").forEach((el) => {
el.value = now.toFormat("yyyy-LL-dd'T'HH:mm:ss");
});
});

16
web/static/js/reporter.js Normal file
View File

@ -0,0 +1,16 @@
const REPORTER_ID = crypto.randomUUID();
window.addEventListener("error", async (ev) => {
await fetch("/report", {
method: "POST",
body: JSON.stringify({
reporterId: REPORTER_ID,
url: window.location.href,
relativeTimestamp: ev.timeStamp,
errorMessage: ev.message,
errorLine: ev.lineno,
errorFile: ev.filename,
errorType: ev.type,
}),
});
});

View File

@ -1,14 +1,15 @@
{ {
"name": "", "name": "Reminder Bot Dashboard",
"short_name": "", "short_name": "Reminders",
"start_url": "/dashboard",
"icons": [ "icons": [
{ {
"src": "/android-chrome-192x192.png", "src": "/static/favicon/android-chrome-192x192.png",
"sizes": "192x192", "sizes": "192x192",
"type": "image/png" "type": "image/png"
}, },
{ {
"src": "/android-chrome-512x512.png", "src": "/static/favicon/android-chrome-512x512.png",
"sizes": "512x512", "sizes": "512x512",
"type": "image/png" "type": "image/png"
} }

View File

@ -0,0 +1,89 @@
<!DOCTYPE html>
<html lang="EN">
<head>
<script src="/static/js/reporter.js" type="application/javascript"></script>
<meta name="description" content="The most powerful Discord Reminders Bot">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta charset="UTF-8">
<meta name="yandex-verification" content="bb77b8681eb64a90"/>
<meta name="google-site-verification" content="7h7UVTeEe0AOzHiH3cFtsqMULYGN-zCZdMT_YCkW1Ho"/>
<!-- <meta http-equiv="Content-Security-Policy" content="default-src 'self'; img-src *; font-src fonts.gstatic.com 'self'"> -->
<!-- favicon -->
<link rel="apple-touch-icon" sizes="180x180"
href="/static/favicon/apple-touch-icon.png">
<link rel="icon" type="image/png" sizes="32x32"
href="/static/favicon/favicon-32x32.png">
<link rel="icon" type="image/png" sizes="16x16"
href="/static/favicon/favicon-16x16.png">
<link rel="manifest" href="/static/favicon/site.webmanifest">
<meta name="msapplication-TileColor" content="#da532c">
<meta name="theme-color" content="#ffffff">
<title>Reminder Bot | Admin</title>
<!-- styles -->
<link rel="stylesheet" href="/static/css/bulma.min.css">
<link rel="stylesheet" href="/static/css/fa.css">
<link rel="stylesheet" href="/static/css/font.css">
<link rel="stylesheet" href="/static/css/style.css">
<link rel="stylesheet" href="/static/css/dtsel.css">
<script src="/static/js/luxon.min.js"></script>
</head>
<body style="width: 100%;">
<p class="title pageTitle">Admin dashboard</p>
<section id="main">
<div class="stat-row">
<div class="stat-box" style="height: 400px;">
<canvas id="schedule"></canvas>
</div>
</div>
<div class="stat-row">
<div class="stat-box figure">
<p>Backlog</p>
<p class="figure-num" id="backlog">?</p>
</div>
<div class="stat-box figure">
<p>Reminders</p>
<p class="figure-num" id="reminders">?</p>
</div>
<div class="stat-box figure">
<p>Intervals</p>
<p class="figure-num" id="intervals">?</p>
</div>
</div>
<div class="stat-row">
<div class="stat-box" style="height: 400px;">
<canvas id="scheduleLong"></canvas>
</div>
</div>
<div class="stat-row">
<div class="stat-box figure">
<p>Last 31 days (success)</p>
<p class="figure-num" id="historySent">?</p>
</div>
<div class="stat-box figure">
<p>Last 31 days (failed)</p>
<p class="figure-num" id="historyFailed">?</p>
</div>
<div class="stat-box figure">
<p>Last 31 days (failure rate)</p>
<p class="figure-num" id="failRate">?</p>
</div>
</div>
<div class="stat-row">
<div class="stat-box" style="height: 400px;">
<canvas id="historyLong"></canvas>
</div>
</div>
</section>
<script src="/static/js/chart.js" defer></script>
<script src="/static/js/chartjs-adapter-luxon.js" defer></script>
<script src="/static/js/admin.js" defer></script>
</body>
</html>

Some files were not shown because too many files have changed in this diff Show More