240 Commits

Author SHA1 Message Date
9a6b65f3a3 Don't delete guild data when guild becomes unavailable 2024-09-17 23:47:27 +01:00
b6ff149d51 Fix macro list/delete 2024-09-14 12:07:09 +01:00
748e33566b Fix patreon not sharing between guild members 2024-08-19 21:50:14 +01:00
d7e90614c8 Bump ver 2024-07-07 16:35:32 +01:00
b5dbfe336d Don't set activity in ready event 2024-07-07 16:31:23 +01:00
218be2f0b1 Bump ver 2024-06-18 19:32:47 +01:00
d7515f3611 Don't require View Channel permission 2024-06-18 19:28:53 +01:00
6ae1096d79 Bump ver 2024-06-12 17:44:55 +01:00
1f0d7adae3 Correct service file 2024-06-12 17:21:42 +01:00
fc96ae526f Default permission checks to true 2024-06-10 18:30:55 +01:00
8881ef0f85 Fix DM reminders trying to load guild data 2024-06-06 16:56:19 +01:00
5e82a687f9 Increase watchdog 2024-06-04 22:34:58 +01:00
de4ecf8dd6 QoL
* Made todo added responses ephemeral if /settings ephemeral is on
* Enabled systemd watchdog
* Move metrics to rocket
2024-06-04 18:40:49 +01:00
064efd4386 Bump version 2024-06-04 16:48:26 +01:00
65b8ba3b47 Redirect old dashboard routes to new routes 2024-06-04 16:42:42 +01:00
9d452ed8cb Fix role selector 2024-05-10 17:37:27 +01:00
441419b92b Bump ver 2024-05-04 13:00:30 +01:00
aecf2c15be Store times as local time not UTC 2024-05-04 10:24:20 +01:00
79da56c794 Bump ver 2024-05-03 16:26:52 +01:00
ef10902c1e Fix todo list deletion not working properly 2024-05-03 16:21:27 +01:00
c277f85c2a Bump dependencies 2024-05-03 16:07:34 +01:00
035653c7fa Bump ver 2024-04-29 08:57:47 +01:00
6358bc3deb Partially revert timezone change 2024-04-29 08:49:01 +01:00
9f5066f982 Bump ver 2024-04-29 08:46:36 +01:00
1d06999e41 Fix bugs with time picker
* Load UTC time correctly at page load
* Don't translate to/from timezone when using the browser date/time
  input
2024-04-16 12:44:19 +01:00
1cf707140c Bump version 2024-04-16 12:22:02 +01:00
e38c63f5ba Don't show empty channels 2024-04-16 11:42:19 +01:00
d52b8b26f2 Upgrade dependencies 2024-04-16 11:19:21 +01:00
bb2128a7ed Tweaks
* Don't show @everyone in the role picker
* Show some text on the image picker talking about Discord CDN
* Correct == in todos
2024-04-11 15:40:50 +01:00
5e99a6f9de Add create todo under each channel
Sort channels for consistency
2024-04-11 15:32:34 +01:00
5406e6b8ec Show all channels and filter todos accordingly 2024-04-11 15:26:24 +01:00
4ee0bc4e37 Bump ver 2024-04-11 12:43:22 +01:00
b99bb7dcbf Fix todo sorting 2024-04-11 12:39:02 +01:00
98f925dc84 Bump version 2024-04-10 18:54:30 +01:00
24e316b12f Add delete/patch todos 2024-04-10 18:42:29 +01:00
4063334953 More work on todo list 2024-04-09 21:21:46 +01:00
e128b9848f More work on todo list support 2024-04-07 20:20:16 +01:00
9989ab3b35 Start work on todo list support for dashboard 2024-04-06 14:27:58 +01:00
b951db3f55 Bump version 2024-03-31 13:30:30 +01:00
884a47bf36 Always show remaining time in top bar 2024-03-31 12:54:48 +01:00
b0f932445c Add server emoji picker 2024-03-31 12:49:52 +01:00
2861cdda0b Bump version 2024-03-29 16:28:09 +00:00
7ba8fcd6b7 Add note to the server data page that states the bot might be restarting 2024-03-29 16:24:24 +00:00
850f0fad57 Bump version 2024-03-29 16:22:13 +00:00
a770a17ee7 Don't invalidate reminders when saving 2024-03-29 16:15:01 +00:00
d15a66d9d9 Bump version 2024-03-28 19:36:23 +00:00
30f011fcd5 Don't send attachments over API 2024-03-28 19:34:30 +00:00
15dbed2f0f Bump version 2024-03-27 17:28:35 +00:00
18cac0345b Allow removing attachments
Show HTTP errors
2024-03-27 17:19:19 +00:00
334b1bc084 Bump version 2024-03-26 17:46:56 +00:00
ba3c76c25f Fix import/export showing "Malformed base64" 2024-03-26 17:43:35 +00:00
67b6f30c62 Add IDs to metrics 2024-03-25 16:41:49 +00:00
8ae311190f Fix panic????????? 2024-03-25 06:07:30 +00:00
016164affb Remove unused javascript/css 2024-03-24 21:00:27 +00:00
2c0aeef700 Fix build. Bump version 2024-03-24 20:55:07 +00:00
ecd75d6f55 Add metrics 2024-03-24 20:38:19 +00:00
4a80d42f86 Move postman and web inside src 2024-03-24 20:23:16 +00:00
075fde71df Bump version 2024-03-11 18:17:22 +00:00
55136aecdc Set default embed color correctly 2024-03-11 18:14:27 +00:00
63fc2cdcbc Block editing username and avatar on DMs 2024-03-10 19:43:57 +00:00
3190738fc5 Extend user reminder API endpoints 2024-03-09 16:17:55 +00:00
8f4810b532 Convert to/from timezone 2024-03-08 16:36:24 +00:00
a5e6c41fa5 Bump ver
Update build file
2024-03-05 20:55:20 +00:00
5f0aa0f834 Add routes for getting/posting user reminders 2024-03-05 20:36:38 +00:00
dbe8e8e358 Add mentioning for channels 2024-03-04 20:36:37 +00:00
85a114e55c Start adding stuff for user reminders 2024-03-03 21:58:48 +00:00
329492b244 Add mention support
Allow vertical resizing of inputs
2024-03-03 21:44:35 +00:00
66135ecd08 Show time until on collapsed reminders 2024-03-03 20:38:17 +00:00
382c2a5a1e Stick options 2024-03-03 19:43:02 +00:00
b91245a3f7 Build dashboard with bot 2024-03-03 13:21:06 +00:00
6f0bdf9852 Support sending reminders to threads 2024-03-03 13:04:50 +00:00
dcee9e0d2a Begin to work on thread support 2024-03-03 11:58:22 +00:00
8e6e1a18b7 Bump ver 2024-03-01 18:04:34 +00:00
72af0532fa Fix timezones 2024-03-01 17:54:05 +00:00
e83b643d86 Show error for files that are too large 2024-03-01 16:56:31 +00:00
0e0ab053f3 Fix time inputs 2024-03-01 16:54:56 +00:00
8c2296b9c8 Bump versions 2024-02-28 21:37:10 +00:00
1c6103142f Fix color picker not working 2024-02-28 21:30:53 +00:00
328127c55e Fix images not setting properly 2024-02-28 21:30:49 +00:00
b0e37b56c0 Bump version 2024-02-26 10:42:46 +00:00
45f5b6261a Convert times to/from UTC 2024-02-26 10:26:07 +00:00
5f6326179c Move styles into Vite
Make sidebar work better
2024-02-25 09:50:10 +00:00
6254f91841 Bump version 2024-02-25 09:18:04 +00:00
60b90a61d4 Adjust permission check
Correct response code for oauth redirect
2024-02-25 09:09:00 +00:00
90f05758d0 Bypass self permission check for DMs 2024-02-24 22:27:29 +00:00
74b7b5d711 Remove glob patterns from static file includes 2024-02-24 17:56:27 +00:00
90550dc2c7 Add loader 2024-02-24 17:47:00 +00:00
79e6498245 Add overlay when data fetching 2024-02-24 17:31:39 +00:00
a8ef3d03f9 Add dashboard to build 2024-02-24 16:12:34 +00:00
53e13844f9 Add unit tests 2024-02-24 15:02:34 +00:00
dd7e681285 Update rust 2024-02-22 18:35:37 +00:00
6c20bf2a0f Bump version 2024-02-22 17:47:40 +00:00
15aa9ccffd Update help text 2024-02-22 17:42:29 +00:00
525471bcad Correct help text 2024-02-22 17:35:50 +00:00
86d53b63b6 Bump deps 2024-02-20 17:09:50 +00:00
d8f266852a Add remaining commands 2024-02-18 14:32:58 +00:00
76a286076b Link all top-level commands with macro recording/replaying logic 2024-02-18 13:24:37 +00:00
5e39e16060 Add option types for top-level commands 2024-02-18 11:04:43 +00:00
c1305cfb36 Extract trait 2024-02-17 20:25:14 +00:00
4823754955 Move all commands to their own files 2024-02-17 18:55:16 +00:00
eb92eacb90 Rearranged some commands
Working on a macro to automatically add option wrappers
2024-02-17 14:09:01 +00:00
d0833b7bca Add macro for extracting arguments 2024-02-16 20:09:32 +00:00
b81c3c80c1 Record some parameters for /remind 2024-02-15 17:28:43 +00:00
2f6d035efe Rename table references 2024-02-14 19:44:53 +00:00
96012ce43c Add migration script 2024-02-14 19:35:23 +00:00
fa7ec8731b Fix hook 2024-02-09 17:03:04 +00:00
def43bfa78 Refactor macros 2024-02-06 20:08:59 +00:00
e4e9af2bb4 Wip commit 2024-01-07 17:10:22 +00:00
cce0de7c75 wip bump versions 2023-12-22 19:12:42 +00:00
e7803b98e8 Merge pull request 'jude/react-dashboard' (#3) from jude/react-dashboard into current
Reviewed-on: #3
2023-12-22 16:58:30 +00:00
7aae246388 Remove submodule 2023-12-22 16:58:30 +00:00
a2d442bc54 Reset intervals correctly 2023-12-22 16:58:30 +00:00
59982df827 Correct merge errors 2023-12-22 16:58:30 +00:00
7a6372ed02 Update styles for notification flash 2023-12-22 16:58:30 +00:00
14a54471f7 Build dashboard 2023-12-22 16:58:30 +00:00
5d3b77f1cd Add metrics
Change dashboards to load an index.html file compiled otherwise
2023-12-22 16:58:30 +00:00
1d64c8bb79 Remove stat table 2023-12-22 16:58:03 +00:00
8ba0f02b98 Bump version 2023-11-12 10:00:46 +00:00
d36438c6ce Bump package lock. Add attachment serializer 2023-11-12 09:39:45 +00:00
e0c60e2ce3 Decode attachments correctly when patching a reminder 2023-11-11 15:05:35 +00:00
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
373 changed files with 86009 additions and 6821 deletions

29
.gitignore vendored
View File

@ -1,5 +1,30 @@
/target
target
.env
/venv
.cargo
/.idea
.idea
web/static/index.html
web/static/assets
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

3096
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@ -1,22 +1,21 @@
[package]
name = "reminder-rs"
version = "1.6.10"
version = "1.7.27"
authors = ["Jude Southworth <judesouthworth@pm.me>"]
edition = "2021"
license = "AGPL-3.0 only"
description = "Reminder Bot for Discord, now in Rust"
[dependencies]
poise = "0.4"
poise = "0.6.1"
dotenv = "0.15"
tokio = { version = "1", features = ["process", "full"] }
reqwest = "0.11"
lazy-regex = "2.3.0"
regex = "1.6"
reqwest = { version = "0.12", features = ["json"] }
regex = "1.10"
log = "0.4"
env_logger = "0.10"
env_logger = "0.11"
chrono = "0.4"
chrono-tz = { version = "0.8", features = ["serde"] }
chrono-tz = { version = "0.9", features = ["serde"] }
lazy_static = "1.4"
num-integer = "0.1"
serde = "1.0"
@ -25,25 +24,46 @@ serde_repr = "0.1"
rmp-serde = "1.1"
rand = "0.8"
levenshtein = "1.0"
sqlx = { version = "0.6", features = ["runtime-tokio-rustls", "macros", "mysql", "bigdecimal", "chrono", "migrate"]}
base64 = "0.13"
sqlx = { version = "0.7", features = ["runtime-tokio-rustls", "macros", "mysql", "bigdecimal", "chrono", "migrate"] }
base64 = "0.22"
secrecy = "0.8.0"
futures = "0.3.30"
prometheus = "0.13.3"
rocket = { version = "0.5.0", features = ["tls", "secrets", "json"] }
rocket_dyn_templates = { version = "0.1.0", features = ["tera"] }
serenity = { version = "0.12", default-features = false, features = ["builder", "cache", "client", "gateway", "http", "model", "utils", "rustls_backend"] }
oauth2 = "4"
csv = "1.2"
sd-notify = "0.4.1"
[dependencies.postman]
path = "postman"
[dependencies.extract_derive]
path = "extract_derive"
[dependencies.reminder_web]
path = "web"
[dependencies.recordable_derive]
path = "recordable_derive"
[package.metadata.deb]
depends = "$auto, python3-dateparser"
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/default.env", "600"],
["web/static/**/*", "var/www/reminder-rs/static", "755"],
["web/templates/**/*", "var/www/reminder-rs/templates", "755"],
# ["nginx/reminder-rs", "etc/nginx/sites-available/reminder-rs", "755"]
["static/css/*", "lib/reminder-rs/static/css", "644"],
["static/favicon/*", "lib/reminder-rs/static/favicon", "644"],
["static/img/*", "lib/reminder-rs/static/img", "644"],
["static/js/*", "lib/reminder-rs/static/js", "644"],
["static/webfonts/*", "lib/reminder-rs/static/webfonts", "644"],
["static/site.webmanifest", "lib/reminder-rs/static/site.webmanifest", "644"],
["templates/**/*", "lib/reminder-rs/templates", "644"],
["reminder-dashboard/dist/static/assets/*", "lib/reminder-rs/static/assets", "644"],
["reminder-dashboard/dist/index.html", "lib/reminder-rs/static/index.html", "644"],
["conf/default.env", "etc/reminder-rs/config.env", "600"],
["conf/Rocket.toml", "etc/reminder-rs/Rocket.toml", "600"],
# ["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]

View File

@ -4,6 +4,6 @@ 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 apt update && DEBIAN_FRONTEND=noninteractive TZ=Etc/UTC apt install -y gcc gcc-multilib cmake pkg-config libssl-dev curl mysql-client-8.0 npm
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,7 +7,22 @@ reminders are paid on the hosted version of the bot. Keep reading if you want to
You'll need rustc and cargo for compilation. To run, you'll need Python 3 still (due to no suitable replacement for dateparser in Rust)
### Compiling for local target
### Build APT package
Recommended method.
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.
1. Install container software: `sudo apt install podman`.
2. Install database server: `sudo apt install mysql-server-8.0`. Create a database called `reminders`
3. Install SQLx CLI: `cargo install sqlx-cli`
4. From the source code directory, execute `sqlx migrate run`
5. Build container image: `podman build -t reminder-rs .`
6. Build with podman: `podman run --rm --network=host -v "$PWD":/mnt -w /mnt -e "DATABASE_URL=mysql://user@localhost/reminders" reminder-rs cargo deb`
### 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
@ -19,18 +34,9 @@ You'll need rustc and cargo for compilation. To run, you'll need Python 3 still
* `DATABASE_URL` - the URL of your MySQL database (`mysql://user[:password]@domain/database`)
8. Build: `cargo build --release`
### Compiling for other target
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.
1. Install container software: `sudo apt install podman`.
2. Install database server: `sudo apt install mysql-server-8.0`. Create a database called `reminders`
3. Install SQLx CLI: `cargo install sqlx-cli`
4. From the source code directory, execute `sqlx migrate run`
5. Build container image: `podman build -t reminder-rs .`
6. Build with podman: `podman run --rm --network=host -v "$PWD":/mnt -w /mnt -e "DATABASE_URL=mysql://user@localhost/reminders" reminder-rs cargo deb`
### Configuring
Reminder Bot reads a number of environment variables. Some are essential, and others have hardcoded fallbacks. Environment variables can be loaded from a .env file in the working directory.
__Required Variables__

View File

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

View File

@ -1,3 +1,13 @@
use std::{path::Path, process::Command};
fn main() {
println!("cargo:rerun-if-changed=migrations");
println!("cargo:rerun-if-changed=reminder-dashboard");
Command::new("npm")
.arg("run")
.arg("build")
.current_dir(Path::new("reminder-dashboard"))
.spawn()
.expect("Failed to build NPM");
}

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 = ""

View File

@ -7,10 +7,13 @@ PATREON_ROLE_ID=
LOCAL_TIMEZONE=
MIN_INTERVAL=
PYTHON_LOCATION=/usr/bin/python3
DONTRUN=web
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

6
debian/postinst vendored
View File

@ -4,10 +4,6 @@ set -e
id -u reminder &>/dev/null || useradd -r -M reminder
if [ ! -f /etc/reminder-rs/config.env ]; then
cp /etc/reminder-rs/default.env /etc/reminder-rs/config.env
fi
chown reminder /etc/reminder-rs/config.env
chown -R reminder /etc/reminder-rs
#DEBHELPER#

4
debian/postrm vendored
View File

@ -4,8 +4,4 @@ set -e
id -u reminder &>/dev/null || userdel reminder
if [ -f /etc/reminder-rs/config.env ]; then
rm /etc/reminder-rs/config.env
fi
#DEBHELPER#

46
extract_derive/Cargo.lock generated Normal file
View File

@ -0,0 +1,46 @@
# This file is automatically @generated by Cargo.
# It is not intended for manual editing.
version = 3
[[package]]
name = "extract_macro"
version = "0.1.0"
dependencies = [
"quote",
"syn",
]
[[package]]
name = "proc-macro2"
version = "1.0.78"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e2422ad645d89c99f8f3e6b88a9fdeca7fabeac836b1002371c4367c8f984aae"
dependencies = [
"unicode-ident",
]
[[package]]
name = "quote"
version = "1.0.35"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "291ec9ab5efd934aaf503a6466c5d5251535d108ee747472c3977cc5acc868ef"
dependencies = [
"proc-macro2",
]
[[package]]
name = "syn"
version = "2.0.49"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "915aea9e586f80826ee59f8453c1101f9d1c4b3964cd2460185ee8e299ada496"
dependencies = [
"proc-macro2",
"quote",
"unicode-ident",
]
[[package]]
name = "unicode-ident"
version = "1.0.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3354b9ac3fae1ff6755cb6db53683adb661634f67557942dea4facebec0fee4b"

11
extract_derive/Cargo.toml Normal file
View File

@ -0,0 +1,11 @@
[package]
name = "extract_derive"
version = "0.1.0"
edition = "2021"
[lib]
proc-macro = true
[dependencies]
quote = "1.0.35"
syn = { version = "2.0.49", features = ["full"] }

53
extract_derive/src/lib.rs Normal file
View File

@ -0,0 +1,53 @@
use proc_macro::TokenStream;
use syn::{spanned::Spanned, Data, Fields};
#[proc_macro_derive(Extract)]
pub fn extract(input: TokenStream) -> TokenStream {
let ast = syn::parse_macro_input!(input);
impl_extract(&ast)
}
fn impl_extract(ast: &syn::DeriveInput) -> TokenStream {
let name = &ast.ident;
match &ast.data {
// Dispatch over struct: extract args directly from context
Data::Struct(st) => match &st.fields {
Fields::Named(fields) => {
let extracted = fields.named.iter().map(|field| {
let ident = &field.ident;
let ty = &field.ty;
quote::quote_spanned! {field.span()=>
#ident : crate::utils::extract_arg!(ctx, #ident, #ty)
}
});
TokenStream::from(quote::quote! {
impl Extract for #name {
fn extract(ctx: crate::ApplicationContext) -> Self {
Self {
#(#extracted,)*
}
}
}
})
}
Fields::Unit => TokenStream::from(quote::quote! {
impl Extract for #name {
fn extract(ctx: crate::ApplicationContext) -> Self {
Self {}
}
}
}),
_ => {
panic!("Only named/unit structs can derive Extract");
}
},
_ => {
panic!("Only structs can derive Extract");
}
}
}

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;

View File

@ -0,0 +1,50 @@
CREATE TABLE command_macro (
id INT UNSIGNED AUTO_INCREMENT,
guild_id INT UNSIGNED NOT NULL,
name VARCHAR(100) NOT NULL,
description VARCHAR(100),
commands JSON NOT NULL,
FOREIGN KEY (guild_id) REFERENCES guilds(id) ON DELETE CASCADE,
PRIMARY KEY (id)
);
# New JSON structure is {command_name: "Remind", "<option name>": "<option value>", ...}
INSERT INTO command_macro (guild_id, description, name, commands)
SELECT
guild_id,
description,
name,
(
SELECT JSON_ARRAYAGG(
(
SELECT JSON_OBJECTAGG(t2.name, t2.value)
FROM JSON_TABLE(
JSON_ARRAY_APPEND(t1.options, '$', JSON_OBJECT('name', 'command_name', 'value', t1.command_name)),
'$[*]' COLUMNS (
name VARCHAR(64) PATH '$.name' ERROR ON ERROR,
value TEXT PATH '$.value' ERROR ON ERROR
)) AS t2
)
)
FROM macro m2
JOIN JSON_TABLE(
commands,
'$[*]' COLUMNS (
command_name VARCHAR(64) PATH '$.command_name' ERROR ON ERROR,
options JSON PATH '$.options' ERROR ON ERROR
)) AS t1
WHERE m1.id = m2.id
)
FROM macro m1;
# # Check which commands are used in macros
# SELECT DISTINCT command_name
# FROM macro m2
# JOIN JSON_TABLE(
# commands,
# '$[*]' COLUMNS (
# command_name VARCHAR(64) PATH '$.command_name' ERROR ON ERROR,
# options JSON PATH '$.options' ERROR ON ERROR
# )) AS t1

View File

@ -0,0 +1,5 @@
-- Add migration script here
ALTER TABLE reminders
ADD INDEX `utc_time_index` (`utc_time`);
ALTER TABLE reminders
ADD INDEX `status_index` (`status`);

53
nginx/reminder-bot Normal file
View File

@ -0,0 +1,53 @@
server {
server_name www.reminder-bot.com;
return 301 https://reminder-bot.com$request_uri;
}
server {
listen 80;
server_name beta.reminder-bot.com;
return 301 https://reminder-bot.com$request_uri;
}
server {
listen 443 ssl;
server_name beta.reminder-bot.com;
ssl_certificate /etc/letsencrypt/live/beta.reminder-bot.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/beta.reminder-bot.com/privkey.pem;
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;
client_max_body_size 10M;
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

@ -1,41 +0,0 @@
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

@ -1,16 +0,0 @@
[package]
name = "postman"
version = "0.1.0"
edition = "2021"
[dependencies]
tokio = { version = "1", features = ["process", "full"] }
regex = "1.4"
log = "0.4"
chrono = "0.4"
chrono-tz = { version = "0.5", features = ["serde"] }
lazy_static = "1.4"
num-integer = "0.1"
serde = "1.0"
sqlx = { version = "0.6", features = ["runtime-tokio-rustls", "macros", "mysql", "bigdecimal", "chrono", "json"]}
serenity = { version = "0.11.1", default-features = false, features = ["builder", "cache", "client", "gateway", "http", "model", "utils", "rustls_backend"] }

View File

@ -1,594 +0,0 @@
use chrono::{DateTime, Days, Duration, Months};
use chrono_tz::Tz;
use lazy_static::lazy_static;
use log::{error, info, warn};
use num_integer::Integer;
use regex::{Captures, Regex};
use serde::Deserialize;
use serenity::{
builder::CreateEmbed,
http::{CacheHttp, Http, HttpError, StatusCode},
model::{
channel::{Channel, Embed as SerenityEmbed},
id::ChannelId,
webhook::Webhook,
},
Error, Result,
};
use sqlx::{
types::{
chrono::{NaiveDateTime, Utc},
Json,
},
Executor,
};
use crate::Database;
lazy_static! {
pub static ref TIMEFROM_REGEX: Regex =
Regex::new(r#"<<timefrom:(?P<time>\d+):(?P<format>.+)?>>"#).unwrap();
pub static ref TIMENOW_REGEX: Regex =
Regex::new(r#"<<timenow:(?P<timezone>(?:\w|/|_)+):(?P<format>.+)?>>"#).unwrap();
}
fn fmt_displacement(format: &str, seconds: u64) -> String {
let mut seconds = seconds;
let mut days: u64 = 0;
let mut hours: u64 = 0;
let mut minutes: u64 = 0;
for (rep, time_type, div) in
[("%d", &mut days, 86400), ("%h", &mut hours, 3600), ("%m", &mut minutes, 60)].iter_mut()
{
if format.contains(*rep) {
let (divided, new_seconds) = seconds.div_rem(&div);
**time_type = divided;
seconds = new_seconds;
}
}
format
.replace("%s", &seconds.to_string())
.replace("%m", &minutes.to_string())
.replace("%h", &hours.to_string())
.replace("%d", &days.to_string())
}
pub fn substitute(string: &str) -> String {
let new = TIMEFROM_REGEX.replace(string, |caps: &Captures| {
let final_time = caps.name("time").map(|m| m.as_str().parse::<i64>().ok()).flatten();
let format = caps.name("format").map(|m| m.as_str());
if let (Some(final_time), Some(format)) = (final_time, format) {
match NaiveDateTime::from_timestamp_opt(final_time, 0) {
Some(dt) => {
let now = Utc::now().naive_utc();
let difference = {
if now < dt {
dt - Utc::now().naive_utc()
} else {
Utc::now().naive_utc() - dt
}
};
fmt_displacement(format, difference.num_seconds() as u64)
}
None => String::new(),
}
} else {
String::new()
}
});
TIMENOW_REGEX
.replace(&new, |caps: &Captures| {
let timezone = caps.name("timezone").map(|m| m.as_str().parse::<Tz>().ok()).flatten();
let format = caps.name("format").map(|m| m.as_str());
if let (Some(timezone), Some(format)) = (timezone, format) {
let now = Utc::now().with_timezone(&timezone);
now.format(format).to_string()
} else {
String::new()
}
})
.to_string()
}
struct Embed {
title: String,
description: String,
image_url: Option<String>,
thumbnail_url: Option<String>,
footer: String,
footer_url: Option<String>,
author: String,
author_url: Option<String>,
color: u32,
fields: Json<Vec<EmbedField>>,
}
#[derive(Deserialize)]
struct EmbedField {
title: String,
value: String,
inline: bool,
}
impl Embed {
pub async fn from_id(
pool: impl Executor<'_, Database = Database> + Copy,
id: u32,
) -> Option<Self> {
match sqlx::query_as!(
Self,
r#"
SELECT
`embed_title` AS title,
`embed_description` AS description,
`embed_image_url` AS image_url,
`embed_thumbnail_url` AS thumbnail_url,
`embed_footer` AS footer,
`embed_footer_url` AS footer_url,
`embed_author` AS author,
`embed_author_url` AS author_url,
`embed_color` AS color,
IFNULL(`embed_fields`, '[]') AS "fields:_"
FROM reminders
WHERE `id` = ?"#,
id
)
.fetch_one(pool)
.await
{
Ok(mut embed) => {
embed.title = substitute(&embed.title);
embed.description = substitute(&embed.description);
embed.footer = substitute(&embed.footer);
embed.fields.iter_mut().for_each(|mut field| {
field.title = substitute(&field.title);
field.value = substitute(&field.value);
});
if embed.has_content() {
Some(embed)
} else {
None
}
}
Err(e) => {
warn!("Error loading embed from reminder: {:?}", e);
None
}
}
}
pub fn has_content(&self) -> bool {
if self.title.is_empty()
&& self.description.is_empty()
&& self.image_url.is_none()
&& self.thumbnail_url.is_none()
&& self.footer.is_empty()
&& self.footer_url.is_none()
&& self.author.is_empty()
&& self.author_url.is_none()
&& self.fields.0.is_empty()
{
false
} else {
true
}
}
}
impl Into<CreateEmbed> for Embed {
fn into(self) -> CreateEmbed {
let mut c = CreateEmbed::default();
c.title(&self.title)
.description(&self.description)
.color(self.color)
.author(|a| {
a.name(&self.author);
if let Some(author_icon) = &self.author_url {
a.icon_url(author_icon);
}
a
})
.footer(|f| {
f.text(&self.footer);
if let Some(footer_icon) = &self.footer_url {
f.icon_url(footer_icon);
}
f
});
for field in &self.fields.0 {
c.field(&field.title, &field.value, field.inline);
}
if let Some(image_url) = &self.image_url {
c.image(image_url);
}
if let Some(thumbnail_url) = &self.thumbnail_url {
c.thumbnail(thumbnail_url);
}
c
}
}
pub struct Reminder {
id: u32,
channel_id: u64,
webhook_id: Option<u64>,
webhook_token: Option<String>,
channel_paused: bool,
channel_paused_until: Option<NaiveDateTime>,
enabled: bool,
tts: bool,
pin: bool,
content: String,
attachment: Option<Vec<u8>>,
attachment_name: Option<String>,
utc_time: DateTime<Utc>,
timezone: String,
restartable: bool,
expires: Option<DateTime<Utc>>,
interval_seconds: Option<u32>,
interval_days: Option<u32>,
interval_months: Option<u32>,
avatar: Option<String>,
username: Option<String>,
}
impl Reminder {
pub async fn fetch_reminders(pool: impl Executor<'_, Database = Database> + Copy) -> Vec<Self> {
match sqlx::query_as_unchecked!(
Reminder,
r#"
SELECT
reminders.`id` AS id,
channels.`channel` AS channel_id,
channels.`webhook_id` AS webhook_id,
channels.`webhook_token` AS webhook_token,
channels.`paused` AS 'channel_paused',
channels.`paused_until` AS 'channel_paused_until',
reminders.`enabled` AS 'enabled',
reminders.`tts` AS tts,
reminders.`pin` AS pin,
reminders.`content` AS content,
reminders.`attachment` AS attachment,
reminders.`attachment_name` AS attachment_name,
reminders.`utc_time` AS 'utc_time',
reminders.`timezone` AS timezone,
reminders.`restartable` AS restartable,
reminders.`expires` AS 'expires',
reminders.`interval_seconds` AS 'interval_seconds',
reminders.`interval_days` AS 'interval_days',
reminders.`interval_months` AS 'interval_months',
reminders.`avatar` AS avatar,
reminders.`username` AS username
FROM
reminders
INNER JOIN
channels
ON
reminders.channel_id = channels.id
WHERE
reminders.`id` IN (
SELECT
MIN(id)
FROM
reminders
WHERE
reminders.`utc_time` <= NOW()
AND (
reminders.`interval_seconds` IS NOT NULL
OR reminders.`interval_months` IS NOT NULL
OR reminders.enabled
)
GROUP BY channel_id
)
"#,
)
.fetch_all(pool)
.await
{
Ok(reminders) => reminders
.into_iter()
.map(|mut rem| {
rem.content = substitute(&rem.content);
rem
})
.collect::<Vec<Self>>(),
Err(e) => {
warn!("Could not fetch reminders: {:?}", e);
vec![]
}
}
}
async fn reset_webhook(&self, pool: impl Executor<'_, Database = Database> + Copy) {
let _ = sqlx::query!(
"UPDATE channels SET webhook_id = NULL, webhook_token = NULL WHERE channel = ?",
self.channel_id
)
.execute(pool)
.await;
}
async fn refresh(&self, pool: impl Executor<'_, Database = Database> + Copy) {
if self.interval_seconds.is_some() || self.interval_months.is_some() {
let now = Utc::now();
let mut updated_reminder_time =
self.utc_time.with_timezone(&self.timezone.parse().unwrap_or(Tz::UTC));
while updated_reminder_time < now {
if let Some(interval) = self.interval_months {
updated_reminder_time = updated_reminder_time
.checked_add_months(Months::new(interval))
.unwrap_or_else(|| {
warn!("Could not add months to a reminder");
updated_reminder_time
});
}
if let Some(interval) = self.interval_days {
updated_reminder_time = updated_reminder_time
.checked_add_days(Days::new(interval as u64))
.unwrap_or_else(|| {
warn!("Could not add days to a reminder");
updated_reminder_time
});
}
if let Some(interval) = self.interval_seconds {
updated_reminder_time =
updated_reminder_time + Duration::seconds(interval as i64);
}
}
if self.expires.map_or(false, |expires| updated_reminder_time > expires) {
self.force_delete(pool).await;
} else {
sqlx::query!(
"UPDATE reminders SET `utc_time` = ? WHERE `id` = ?",
updated_reminder_time.with_timezone(&Utc),
self.id
)
.execute(pool)
.await
.expect(&format!("Could not update time on Reminder {}", self.id));
}
} else {
self.force_delete(pool).await;
}
}
async fn force_delete(&self, pool: impl Executor<'_, Database = Database> + Copy) {
sqlx::query!("DELETE FROM reminders WHERE `id` = ?", self.id)
.execute(pool)
.await
.expect(&format!("Could not delete Reminder {}", self.id));
}
async fn pin_message<M: Into<u64>>(&self, message_id: M, http: impl AsRef<Http>) {
let _ = http.as_ref().pin_message(self.channel_id, message_id.into(), None).await;
}
pub async fn send(
&self,
pool: impl Executor<'_, Database = Database> + Copy,
cache_http: impl CacheHttp,
) {
async fn send_to_channel(
cache_http: impl CacheHttp,
reminder: &Reminder,
embed: Option<CreateEmbed>,
) -> Result<()> {
let channel = ChannelId(reminder.channel_id).to_channel(&cache_http).await;
match channel {
Ok(Channel::Guild(channel)) => {
match channel
.send_message(&cache_http, |m| {
m.content(&reminder.content).tts(reminder.tts);
if let (Some(attachment), Some(name)) =
(&reminder.attachment, &reminder.attachment_name)
{
m.add_file((attachment as &[u8], name.as_str()));
}
if let Some(embed) = embed {
m.set_embed(embed);
}
m
})
.await
{
Ok(m) => {
if reminder.pin {
reminder.pin_message(m.id, cache_http.http()).await;
}
Ok(())
}
Err(e) => Err(e),
}
}
Ok(Channel::Private(channel)) => {
match channel
.send_message(&cache_http.http(), |m| {
m.content(&reminder.content).tts(reminder.tts);
if let (Some(attachment), Some(name)) =
(&reminder.attachment, &reminder.attachment_name)
{
m.add_file((attachment as &[u8], name.as_str()));
}
if let Some(embed) = embed {
m.set_embed(embed);
}
m
})
.await
{
Ok(m) => {
if reminder.pin {
reminder.pin_message(m.id, cache_http.http()).await;
}
Ok(())
}
Err(e) => Err(e),
}
}
Err(e) => Err(e),
_ => Err(Error::Other("Channel not of valid type")),
}
}
async fn send_to_webhook(
cache_http: impl CacheHttp,
reminder: &Reminder,
webhook: Webhook,
embed: Option<CreateEmbed>,
) -> Result<()> {
match webhook
.execute(&cache_http.http(), reminder.pin || reminder.restartable, |w| {
w.content(&reminder.content).tts(reminder.tts);
if let Some(username) = &reminder.username {
if !username.is_empty() {
w.username(username);
}
}
if let Some(avatar) = &reminder.avatar {
w.avatar_url(avatar);
}
if let (Some(attachment), Some(name)) =
(&reminder.attachment, &reminder.attachment_name)
{
w.add_file((attachment as &[u8], name.as_str()));
}
if let Some(embed) = embed {
w.embeds(vec![SerenityEmbed::fake(|c| {
*c = embed;
c
})]);
}
w
})
.await
{
Ok(m) => {
if reminder.pin {
if let Some(message) = m {
reminder.pin_message(message.id, cache_http.http()).await;
}
}
Ok(())
}
Err(e) => Err(e),
}
}
if self.enabled
&& !(self.channel_paused
&& self
.channel_paused_until
.map_or(true, |inner| inner >= Utc::now().naive_local()))
{
let _ = sqlx::query!(
"UPDATE `channels` SET paused = 0, paused_until = NULL WHERE `channel` = ?",
self.channel_id
)
.execute(pool)
.await;
let embed = Embed::from_id(pool, self.id).await.map(|e| e.into());
let result = if let (Some(webhook_id), Some(webhook_token)) =
(self.webhook_id, &self.webhook_token)
{
let webhook_res =
cache_http.http().get_webhook_with_token(webhook_id, webhook_token).await;
if let Ok(webhook) = webhook_res {
send_to_webhook(cache_http, &self, webhook, embed).await
} else {
warn!("Webhook vanished: {:?}", webhook_res);
self.reset_webhook(pool).await;
send_to_channel(cache_http, &self, embed).await
}
} else {
send_to_channel(cache_http, &self, embed).await
};
if let Err(e) = result {
error!("Error sending reminder {}: {:?}", self.id, e);
if let Error::Http(error) = e {
if error.status_code() == Some(StatusCode::NOT_FOUND) {
warn!("Seeing channel is deleted. Removing reminder");
self.force_delete(pool).await;
} else if let HttpError::UnsuccessfulRequest(error) = *error {
if error.error.code == 50007 {
warn!("User cannot receive DMs");
self.force_delete(pool).await;
} else {
self.refresh(pool).await;
}
}
} else {
self.refresh(pool).await;
}
} else {
self.refresh(pool).await;
}
} else {
info!("Reminder {} is paused", self.id);
self.refresh(pool).await;
}
}
}

View File

@ -0,0 +1,11 @@
[package]
name = "recordable_derive"
version = "0.1.0"
edition = "2021"
[lib]
proc-macro = true
[dependencies]
quote = "1.0.35"
syn = { version = "2.0.49", features = ["full"] }

View File

@ -0,0 +1,42 @@
use proc_macro::TokenStream;
use syn::{spanned::Spanned, Data};
/// Macro to allow Recordable to be implemented on an enum by dispatching over each variant.
#[proc_macro_derive(Recordable)]
pub fn extract(input: TokenStream) -> TokenStream {
let ast = syn::parse_macro_input!(input);
impl_recordable(&ast)
}
fn impl_recordable(ast: &syn::DeriveInput) -> TokenStream {
let name = &ast.ident;
match &ast.data {
Data::Enum(en) => {
let extracted = en.variants.iter().map(|var| {
let ident = &var.ident;
quote::quote_spanned! {var.span()=>
Self::#ident (opt) => opt.run(ctx).await?
}
});
TokenStream::from(quote::quote! {
impl Recordable for #name {
async fn run(self, ctx: crate::Context<'_>) -> Result<(), crate::Error> {
match self {
#(#extracted,)*
}
Ok(())
}
}
})
}
_ => {
panic!("Only enums can derive Recordable");
}
}
}

View File

@ -0,0 +1,2 @@
printWidth = 100
tabWidth = 4

View File

@ -0,0 +1,19 @@
# reminder-dashboard
The re-re-rewrite of the dashboard.
## Why
The existing beta variant of the dashboard is written using vanilla JavaScript. This is fine,
but annoying to update. This would've been okay if I was more dedicated to updating the vanilla
JavaScript too, but I want to experiment with "new" things.
This also allows me to expand my frontend skills, which is relevant to part of my job.
## Developing
1. Run both `npm run dev` and `cargo run`
2. Symlink assets: assuming cloned
into `$HOME`, `ln -s $HOME/reminder-bot/reminder-dashboard/dist/index.html $HOME/reminder-bot/web/static/index.html`
and
`ln -s $HOME/reminder-bot/reminder-dashboard/dist/static/assets $HOME/reminder-bot/web/static/assets`

View File

@ -0,0 +1,34 @@
<!DOCTYPE html>
<html lang="EN">
<head>
<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/site.webmanifest">
<meta name="msapplication-TileColor" content="#da532c">
<meta name="theme-color" content="#ffffff">
<title>Reminder Bot | Dashboard</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">
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/index.tsx"></script>
</body>
</html>

5626
reminder-dashboard/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,32 @@
{
"name": "example",
"private": true,
"type": "module",
"scripts": {
"dev": "vite build --watch --mode development",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"axios": "^1.5.1",
"bulma": "^0.9.4",
"luxon": "^3.4.3",
"preact": "^10.13.1",
"react-colorful": "^5.6.1",
"react-query": "^3.39.3",
"tributejs": "^5.1.3",
"use-debounce": "^10.0.0",
"wouter": "^3.0"
},
"devDependencies": {
"@preact/preset-vite": "^2.5.0",
"@types/luxon": "^3.3.2",
"eslint": "^8.50.0",
"eslint-config-preact": "^1.3.0",
"prettier": "^3.0.3",
"react-datepicker": "^4.21.0",
"sass": "^1.71.1",
"typescript": "^5.2.2",
"vite": "^5.1"
}
}

View File

@ -11,10 +11,26 @@ div.reminderContent.is-collapsed .column.discord-frame {
display: none;
}
div.reminderContent.is-collapsed .collapses {
div.reminderContent.is-collapsed .column.settings {
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 {
display: inline-flex;
}
@ -23,42 +39,42 @@ div.reminderContent .invert-collapses {
display: none;
}
div.reminderContent.is-collapsed .settings {
display: flex;
flex-direction: row;
padding-bottom: 0;
}
div.reminderContent.is-collapsed .channel-field {
display: inline-flex;
order: 1;
}
div.reminderContent.is-collapsed .reminder-topbar {
display: inline-flex;
margin-bottom: 0px;
flex-grow: 1;
order: 2;
}
div.reminderContent.is-collapsed input[name="name"] {
display: inline-flex;
flex-grow: 1;
border: none;
font-weight: 700;
background: none;
box-shadow: none;
opacity: 1;
}
div.reminderContent.is-collapsed button.hide-box {
div.reminderContent.is-collapsed .hide-box {
display: inline-flex;
}
div.reminderContent.is-collapsed button.hide-box i {
div.reminderContent.is-collapsed .hide-box i {
transform: rotate(90deg);
}
/* END */
/* dashboard styles */
.hide-box {
border: none;
background: none;
}
.hide-box:focus {
outline: none;
box-shadow: none !important;
}
.channel-bar {
display: flex;
justify-content: center;
flex-direction: column;
font-weight: bold;
}
button.inline-btn {
height: 100%;
padding: 5px;
@ -85,18 +101,86 @@ div.discord-embed {
position: relative;
}
div.reminderContent {
padding: 2px;
background-color: #f5f5f5;
border-radius: 8px;
margin: 8px;
div.split-controls {
display: flex;
flex-direction: column;
justify-content: space-between;
flex-grow: 2;
}
div.interval-group > button {
margin-left: auto;
.reminder-topbar > div {
padding-left: 6px;
padding-right: 6px;
}
.settings {
display: flex;
flex-direction: column;
}
.name-bar {
flex-grow: 1;
flex-shrink: 1;
}
.hide-button-bar {
flex-grow: 0;
flex-shrink: 0;
}
.patreon-only {
padding-bottom: 16px;
}
.tts-row {
padding-bottom: 10px;
}
.reminder-topbar {
display: flex;
margin-bottom: 0 !important;
}
.reminder-settings {
margin-top: 0 !important;
}
.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 */
div.interval-group {
height: unset !important;
}
div.interval-group .clear:focus {
outline: none;
box-shadow: none !important;
}
div.interval-group .no-break {
text-wrap: avoid;
white-space: nowrap;
}
div.interval-group .clear {
border: none;
background: none;
padding: 1px;
margin-right: -3px;
}
div.interval-group > .interval-group-left input {
-webkit-appearance: none;
border-style: none;
@ -110,12 +194,13 @@ div.interval-group > .interval-group-left input.w2 {
}
div.interval-group > .interval-group-left input.w3 {
width: 6ch;
width: 3ch;
}
div.interval-group {
display: flex;
flex-direction: row;
justify-content: space-between;
}
/* !Interval inputs */
@ -180,6 +265,23 @@ div#pageNavbar a {
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 {
background-color: #4a4a4a;
}
@ -206,17 +308,24 @@ div.dashboard-sidebar {
padding-right: 0;
}
div.dashboard-sidebar:not(.mobile-sidebar) {
display: flex;
flex-direction: column;
ul.guildList {
flex-grow: 1;
flex-shrink: 1;
overflow: auto;
}
div.dashboard-sidebar:not(.mobile-sidebar) .aside-footer {
flex-shrink: 0;
flex-grow: 0;
position: fixed;
bottom: 0;
width: 226px;
}
div.dashboard-sidebar svg {
flex-shrink: 0;
}
div.mobile-sidebar {
z-index: 100;
min-height: 100vh;
@ -293,10 +402,7 @@ input.default-width {
}
.message-input:placeholder-shown {
border-top: none;
border-left: none;
border-right: none;
border-bottom-style: dashed;
font-style: italic;
background-color: #40444b;
color: #fff;
}
@ -367,8 +473,7 @@ input.default-width {
.customizable.is-400x300 img {
margin-top: 10px;
width: 100%;
min-height: 100px;
max-height: 400px;
height: 100px;
}
.customizable.is-32x32 img {
@ -462,6 +567,7 @@ input.default-width {
flex-grow: 1;
flex-shrink: 1;
flex-basis: auto;
margin-right: 4px;
}
.embed-body input, .embed-body textarea {
@ -511,21 +617,84 @@ input.default-width {
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 {
width: 60px;
height: 60px;
}
}
.customizable.is-24x24 img {
width: 16px;
height: 16px;
@media only screen and (max-width: 768px) {
.button-row {
display: flex;
flex-direction: column;
}
.button-row .button-row-reminder {
width: 100%;
}
.button-row .button-row-template > div {
flex-basis: 0;
flex-grow: 1;
}
.button-row button {
width: 100%;
}
.reminder-settings {
margin-bottom: 0 !important;
}
.tts-row {
padding-bottom: 0;
}
}
/* loader */
#loader {
position: fixed;
top: 0;
background-color: rgba(255, 255, 255, 0.8);
width: 100vw;
z-index: 999;
@ -537,6 +706,86 @@ input.default-width {
/* 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 */
.half-rem {
@ -568,11 +817,44 @@ input.default-width {
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 {
pointer-events: none;
}
.is-locked > :not(.patreon-invert) {
opacity: 0.4;
}
.is-locked .patreon-invert {
display: block;
}
.patreon-invert {
display: none;
}
.is-locked .foreground {
pointer-events: auto;
}
@ -580,3 +862,27 @@ input.default-width {
.is-locked .field:last-of-type {
display: none;
}
.stat-row {
display: flex;
flex-direction: row;
}
.stat-box {
flex-grow: 1;
border-radius: 6px;
background-color: #fcfcfc;
border-color: #efefef;
border-style: solid;
border-width: 1px;
margin: 4px;
padding: 4px;
}
.figure {
text-align: center;
}
.figure-num {
font-size: 2rem;
}

View File

Before

Width:  |  Height:  |  Size: 7.2 KiB

After

Width:  |  Height:  |  Size: 7.2 KiB

View File

Before

Width:  |  Height:  |  Size: 20 KiB

After

Width:  |  Height:  |  Size: 20 KiB

View File

Before

Width:  |  Height:  |  Size: 6.8 KiB

After

Width:  |  Height:  |  Size: 6.8 KiB

View File

Before

Width:  |  Height:  |  Size: 1.8 KiB

After

Width:  |  Height:  |  Size: 1.8 KiB

View File

Before

Width:  |  Height:  |  Size: 2.3 KiB

After

Width:  |  Height:  |  Size: 2.3 KiB

View File

Before

Width:  |  Height:  |  Size: 7.2 KiB

After

Width:  |  Height:  |  Size: 7.2 KiB

View File

Before

Width:  |  Height:  |  Size: 4.9 KiB

After

Width:  |  Height:  |  Size: 4.9 KiB

View File

Before

Width:  |  Height:  |  Size: 762 B

After

Width:  |  Height:  |  Size: 762 B

View File

Before

Width:  |  Height:  |  Size: 11 KiB

After

Width:  |  Height:  |  Size: 11 KiB

View File

Before

Width:  |  Height:  |  Size: 323 KiB

After

Width:  |  Height:  |  Size: 323 KiB

View File

Before

Width:  |  Height:  |  Size: 61 KiB

After

Width:  |  Height:  |  Size: 61 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 81 KiB

View File

Before

Width:  |  Height:  |  Size: 23 KiB

After

Width:  |  Height:  |  Size: 23 KiB

View File

Before

Width:  |  Height:  |  Size: 35 KiB

After

Width:  |  Height:  |  Size: 35 KiB

View File

Before

Width:  |  Height:  |  Size: 55 KiB

After

Width:  |  Height:  |  Size: 55 KiB

View File

Before

Width:  |  Height:  |  Size: 3.6 KiB

After

Width:  |  Height:  |  Size: 3.6 KiB

View File

Before

Width:  |  Height:  |  Size: 17 KiB

After

Width:  |  Height:  |  Size: 17 KiB

View File

Before

Width:  |  Height:  |  Size: 14 KiB

After

Width:  |  Height:  |  Size: 14 KiB

View File

Before

Width:  |  Height:  |  Size: 24 KiB

After

Width:  |  Height:  |  Size: 24 KiB

View File

Before

Width:  |  Height:  |  Size: 17 KiB

After

Width:  |  Height:  |  Size: 17 KiB

View File

Before

Width:  |  Height:  |  Size: 44 KiB

After

Width:  |  Height:  |  Size: 44 KiB

View File

Before

Width:  |  Height:  |  Size: 40 KiB

After

Width:  |  Height:  |  Size: 40 KiB

View File

Before

Width:  |  Height:  |  Size: 44 KiB

After

Width:  |  Height:  |  Size: 44 KiB

View File

Before

Width:  |  Height:  |  Size: 18 KiB

After

Width:  |  Height:  |  Size: 18 KiB

View File

Before

Width:  |  Height:  |  Size: 20 KiB

After

Width:  |  Height:  |  Size: 20 KiB

View File

Before

Width:  |  Height:  |  Size: 65 KiB

After

Width:  |  Height:  |  Size: 65 KiB

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,
},
},
},
});
});
});

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

@ -56,19 +56,36 @@ function switch_pane(selector) {
}
function update_select(sel) {
if (sel.selectedOptions[0].dataset["webhookAvatar"]) {
sel.closest("div.reminderContent").querySelector("img.discord-avatar").src =
sel.selectedOptions[0].dataset["webhookAvatar"];
} else {
sel.closest("div.reminderContent").querySelector("img.discord-avatar").src =
"/static/img/icon.png";
let channelDisplay = sel.closest("div.reminderContent").querySelector(".channel-bar");
if (channelDisplay !== null) {
channelDisplay.textContent = `#${sel.selectedOptions[0].textContent}`;
}
if (sel.selectedOptions[0].dataset["webhookName"]) {
sel.closest("div.reminderContent").querySelector("input.discord-username").value =
sel.selectedOptions[0].dataset["webhookName"];
if (sel.selectedOptions[0] === undefined) {
return;
}
const avatarInput = sel.closest("div.reminderContent").querySelector("img.avatar");
if (!avatarInput.dataset["set"]) {
if (sel.selectedOptions[0].dataset["webhookAvatar"]) {
avatarInput.src = sel.selectedOptions[0].dataset["webhookAvatar"];
} else {
sel.closest("div.reminderContent").querySelector("input.discord-username").value =
"Reminder";
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";
}
}
}
@ -139,12 +156,18 @@ async function fetch_channels(guild_id) {
const event = new Event("channelsLoading");
document.dispatchEvent(event);
let hasError = false;
await fetch(`/dashboard/api/guild/${guild_id}/channels`)
.then((response) => response.json())
.then((data) => {
if (data.error) {
if (data.error === "Bot not in guild") {
switch_pane("guild-error");
hasError = true;
} else if (data.error === "Incorrect permissions") {
switch_pane("user-error");
hasError = true;
} else {
show_error(data.error);
}
@ -156,6 +179,8 @@ async function fetch_channels(guild_id) {
const event = new Event("channelsLoaded");
document.dispatchEvent(event);
});
return hasError;
}
async function fetch_reminders(guild_id) {
@ -198,22 +223,25 @@ async function fetch_reminders(guild_id) {
}
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") {
interval = get_interval(node);
utc_time = luxon.DateTime.fromISO(
node.querySelector('input[name="time"]').value
).setZone("UTC");
if (utc_time.invalid) {
return { error: "Time provided invalid." };
} else {
utc_time = utc_time.toFormat("yyyy-LL-dd'T'HH:mm:ss");
}
let expiration = node.querySelector('input[name="expiration"]').value;
if (expiration) {
expiration_time = luxon.DateTime.fromISO(
node.querySelector('input[name="time"]').value
node.querySelector('input[name="expiration"]').value
).setZone("UTC");
if (expiration_time.invalid) {
return { error: "Expiration provided invalid." };
@ -221,6 +249,12 @@ async function serialize_reminder(node, mode) {
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(
node.querySelector("div.discord-embed")
@ -284,15 +318,17 @@ async function serialize_reminder(node, mode) {
const embed_title = node.querySelector('textarea[name="embed_title"]').value;
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.length == 0 &&
embed_description.length == 0 &&
embed_footer.length == 0 &&
embed_footer_url === null &&
embed_image_url === null &&
embed_thumbnail_url === null
embed_thumbnail_url === null &&
fields.length === 0 &&
attachment === null
) {
return { error: "Reminder needs content." };
}
@ -305,7 +341,7 @@ async function serialize_reminder(node, mode) {
restartable: false,
attachment: attachment,
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,
content: content,
embed_author_url: embed_author_url,
@ -319,9 +355,9 @@ async function serialize_reminder(node, mode) {
embed_title: embed_title,
embed_fields: fields,
expires: expiration_time,
interval_seconds: mode !== "template" ? interval.seconds : null,
interval_days: mode !== "template" ? interval.days : null,
interval_months: mode !== "template" ? interval.months : null,
interval_seconds: interval.seconds,
interval_days: interval.days,
interval_months: interval.months,
name: node.querySelector('input[name="name"]').value,
tts: node.querySelector('input[name="tts"]').checked,
username: node.querySelector('input[name="username"]').value,
@ -350,17 +386,27 @@ function deserialize_reminder(reminder, frame, mode) {
if ($input !== null) {
$input.value = reminder[prop];
} else if ($image !== null) {
console.log(`loading img ${prop}`);
$image.src = reminder[prop];
$image.dataset["set"] = "1";
}
}
}
}
update_interval(frame);
update_select(frame.querySelector(".channel-selector"));
const lastChild = frame.querySelector("div.embed-multifield-box .embed-field-box");
const lastChild = frame.querySelector(
"div.embed-multifield-box .embed-field-box:last-child"
);
for (let field of reminder["embed_fields"]) {
// 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);
embed_field.querySelector("textarea.discord-field-title").value = field["title"];
embed_field.querySelector("textarea.discord-field-value").value = field["value"];
@ -373,9 +419,9 @@ function deserialize_reminder(reminder, frame, mode) {
.insertBefore(embed_field, lastChild);
}
if (mode !== "template") {
if (reminder["interval_seconds"]) update_interval(frame);
if (mode !== "template") {
let $enableBtn = frame.querySelector(".disable-enable");
$enableBtn.dataset["action"] = reminder["enabled"] ? "disable" : "enable";
@ -386,7 +432,7 @@ function deserialize_reminder(reminder, frame, mode) {
timeInput.value = localTime.toFormat("yyyy-LL-dd'T'HH:mm:ss");
if (reminder["expires"]) {
let expiresInput = frame.querySelector('input[name="time"]');
let expiresInput = frame.querySelector('input[name="expiration"]');
let expiresTime = luxon.DateTime.fromISO(reminder["expires"], {
zone: "UTC",
}).setZone(timezone);
@ -406,6 +452,14 @@ document.addEventListener("guildSwitched", async (e) => {
`.switch-pane[data-guild="${e.detail.guild_id}"]`
);
let hasError = false;
if ($anchor === null) {
switch_pane("user-error");
hasError = true;
return;
}
switch_pane($anchor.dataset["pane"]);
reset_guild_pane();
$anchor.classList.add("is-active");
@ -416,9 +470,10 @@ document.addEventListener("guildSwitched", async (e) => {
.forEach((el) => el.classList.remove("is-locked"));
}
hasError = await fetch_channels(e.detail.guild_id);
if (!hasError) {
fetch_roles(e.detail.guild_id);
fetch_templates(e.detail.guild_id);
await fetch_channels(e.detail.guild_id);
fetch_reminders(e.detail.guild_id);
document.querySelectorAll("p.pageTitle").forEach((el) => {
@ -429,6 +484,7 @@ document.addEventListener("guildSwitched", async (e) => {
update_select(e.target);
});
});
}
$loader.classList.add("is-hidden");
});
@ -440,6 +496,12 @@ document.addEventListener("channelsLoaded", () => {
document.addEventListener("remindersLoaded", (event) => {
const guild = guildId();
document.querySelectorAll("select.channel-selector").forEach((el) => {
el.addEventListener("change", (e) => {
update_select(e.target);
});
});
for (let reminder of event.detail) {
let node = reminder.node;
@ -467,9 +529,9 @@ document.addEventListener("remindersLoaded", (event) => {
if (data.error) {
show_error(data.error);
} else {
enableBtn.dataset["action"] = data["enabled"]
? "enable"
: "disable";
enableBtn.dataset["action"] = data.reminder["enabled"]
? "disable"
: "enable";
}
});
});
@ -541,6 +603,16 @@ function show_error(error) {
}, 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.addEventListener("input", () => {
@ -566,7 +638,7 @@ document.querySelectorAll(".show-modal").forEach((element) => {
});
});
document.addEventListener("DOMContentLoaded", () => {
document.addEventListener("DOMContentLoaded", async () => {
$loader.classList.remove("is-hidden");
mentions.attach(document.querySelectorAll("textarea"));
@ -586,7 +658,7 @@ document.addEventListener("DOMContentLoaded", () => {
hideBox.closest(".reminderContent").classList.toggle("is-collapsed");
});
fetch("/dashboard/api/user")
await fetch("/dashboard/api/user")
.then((response) => response.json())
.then((data) => {
if (data.error) {
@ -600,7 +672,7 @@ document.addEventListener("DOMContentLoaded", () => {
}
});
fetch("/dashboard/api/user/guilds")
await fetch("/dashboard/api/user/guilds")
.then((response) => response.json())
.then((data) => {
if (data.error) {
@ -691,11 +763,25 @@ $uploader.addEventListener("change", (ev) => {
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(() => {
})
.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);
});
});
});
@ -782,6 +868,14 @@ $createTemplateBtn.addEventListener("click", async () => {
];
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();
fetch(`/dashboard/api/guild/${guild}/templates`, {
@ -823,6 +917,7 @@ $loadTemplateBtn.addEventListener("click", (ev) => {
});
$deleteTemplateBtn.addEventListener("click", (ev) => {
if (parseInt($templateSelect.value) !== null) {
fetch(`/dashboard/api/guild/${guildId()}/templates`, {
method: "DELETE",
headers: {
@ -840,6 +935,7 @@ $deleteTemplateBtn.addEventListener("click", (ev) => {
.remove();
}
});
}
});
let $img;
@ -979,6 +1075,13 @@ document.addEventListener("click", (ev) => {
if (ev.target.closest("button.inline-btn") !== null) {
let 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");
});
});

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": "",
"short_name": "",
"name": "Reminder Bot Dashboard",
"short_name": "Reminders",
"start_url": "/dashboard",
"icons": [
{
"src": "/android-chrome-192x192.png",
"src": "/static/favicon/android-chrome-192x192.png",
"sizes": "192x192",
"type": "image/png"
},
{
"src": "/android-chrome-512x512.png",
"src": "/static/favicon/android-chrome-512x512.png",
"sizes": "512x512",
"type": "image/png"
}

View File

Before

Width:  |  Height:  |  Size: 712 KiB

After

Width:  |  Height:  |  Size: 712 KiB

View File

Before

Width:  |  Height:  |  Size: 2.5 MiB

After

Width:  |  Height:  |  Size: 2.5 MiB

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