74 Commits

Author SHA1 Message Date
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
220 changed files with 4476 additions and 4809 deletions

1306
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@ -1,6 +1,6 @@
[package] [package]
name = "reminder-rs" name = "reminder-rs"
version = "1.7.0-rc5" version = "1.7.24"
authors = ["Jude Southworth <judesouthworth@pm.me>"] authors = ["Jude Southworth <judesouthworth@pm.me>"]
edition = "2021" edition = "2021"
license = "AGPL-3.0 only" license = "AGPL-3.0 only"
@ -10,13 +10,12 @@ description = "Reminder Bot for Discord, now in Rust"
poise = "0.6.1" poise = "0.6.1"
dotenv = "0.15" dotenv = "0.15"
tokio = { version = "1", features = ["process", "full"] } tokio = { version = "1", features = ["process", "full"] }
reqwest = "0.11" reqwest = { version = "0.12", features = ["json"] }
lazy-regex = "3.1"
regex = "1.10" regex = "1.10"
log = "0.4" log = "0.4"
env_logger = "0.11" env_logger = "0.11"
chrono = "0.4" chrono = "0.4"
chrono-tz = { version = "0.8", features = ["serde"] } chrono-tz = { version = "0.9", features = ["serde"] }
lazy_static = "1.4" lazy_static = "1.4"
num-integer = "0.1" num-integer = "0.1"
serde = "1.0" serde = "1.0"
@ -26,14 +25,16 @@ rmp-serde = "1.1"
rand = "0.8" rand = "0.8"
levenshtein = "1.0" levenshtein = "1.0"
sqlx = { version = "0.7", features = ["runtime-tokio-rustls", "macros", "mysql", "bigdecimal", "chrono", "migrate"] } sqlx = { version = "0.7", features = ["runtime-tokio-rustls", "macros", "mysql", "bigdecimal", "chrono", "migrate"] }
base64 = "0.21" base64 = "0.22"
secrecy = "0.8.0" secrecy = "0.8.0"
futures = "0.3.30"
[dependencies.postman] prometheus = "0.13.3"
path = "postman" rocket = { version = "0.5.0", features = ["tls", "secrets", "json"] }
rocket_dyn_templates = { version = "0.1.0", features = ["tera"] }
[dependencies.reminder_web] serenity = { version = "0.12", default-features = false, features = ["builder", "cache", "client", "gateway", "http", "model", "utils", "rustls_backend"] }
path = "web" oauth2 = "4"
csv = "1.2"
sd-notify = "0.4.1"
[dependencies.extract_derive] [dependencies.extract_derive]
path = "extract_derive" path = "extract_derive"
@ -47,19 +48,17 @@ suggests = "mysql-server-8.0, nginx"
maintainer-scripts = "debian" maintainer-scripts = "debian"
assets = [ assets = [
["target/release/reminder-rs", "usr/bin/reminder-rs", "755"], ["target/release/reminder-rs", "usr/bin/reminder-rs", "755"],
["web/static/css/*", "lib/reminder-rs/static/css", "644"], ["static/css/*", "lib/reminder-rs/static/css", "644"],
["web/static/favicon/*", "lib/reminder-rs/static/favicon", "644"], ["static/favicon/*", "lib/reminder-rs/static/favicon", "644"],
["web/static/img/*", "lib/reminder-rs/static/img", "644"], ["static/img/*", "lib/reminder-rs/static/img", "644"],
["web/static/js/*", "lib/reminder-rs/static/js", "644"], ["static/js/*", "lib/reminder-rs/static/js", "644"],
["web/static/webfonts/*", "lib/reminder-rs/static/webfonts", "644"], ["static/webfonts/*", "lib/reminder-rs/static/webfonts", "644"],
["web/static/site.webmanifest", "lib/reminder-rs/static/site.webmanifest", "644"], ["static/site.webmanifest", "lib/reminder-rs/static/site.webmanifest", "644"],
["web/templates/**/*", "lib/reminder-rs/templates", "644"], ["templates/**/*", "lib/reminder-rs/templates", "644"],
["reminder-dashboard/dist/static/assets/*", "lib/reminder-rs/static/assets", "644"], ["reminder-dashboard/dist/static/assets/*", "lib/reminder-rs/static/assets", "644"],
["reminder-dashboard/dist/index.html", "lib/reminder-rs/static/index.html", "644"], ["reminder-dashboard/dist/index.html", "lib/reminder-rs/static/index.html", "644"],
["conf/default.env", "etc/reminder-rs/config.env", "600"], ["conf/default.env", "etc/reminder-rs/config.env", "600"],
["conf/Rocket.toml", "etc/reminder-rs/Rocket.toml", "600"], ["conf/Rocket.toml", "etc/reminder-rs/Rocket.toml", "600"],
["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"] # ["nginx/reminder-rs", "etc/nginx/sites-available/reminder-rs", "755"]
] ]
conf-files = [ conf-files = [

View File

@ -4,6 +4,6 @@ ENV RUSTUP_HOME=/usr/local/rustup \
CARGO_HOME=/usr/local/cargo \ CARGO_HOME=/usr/local/cargo \
PATH=/usr/local/cargo/bin:$PATH 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 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 RUN cargo install cargo-deb

View File

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

View File

@ -1,3 +1,13 @@
use std::{path::Path, process::Command};
fn main() { fn main() {
println!("cargo:rerun-if-changed=migrations"); 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");
} }

View File

@ -1,13 +0,0 @@
#!/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

@ -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.9"
log = "0.4"
chrono = "0.4"
chrono-tz = { version = "0.8", features = ["serde"] }
lazy_static = "1.4"
num-integer = "0.1"
serde = "1.0"
sqlx = { version = "0.7", features = ["runtime-tokio-rustls", "macros", "mysql", "bigdecimal", "chrono", "json"]}
serenity = { version = "0.12", default-features = false, features = ["builder", "cache", "client", "gateway", "http", "model", "utils", "rustls_backend"] }

View File

@ -26,7 +26,6 @@
<link rel="stylesheet" href="/static/css/fa.css"> <link rel="stylesheet" href="/static/css/fa.css">
<link rel="stylesheet" href="/static/css/font.css"> <link rel="stylesheet" href="/static/css/font.css">
<link rel="stylesheet" href="/static/css/style.css"> <link rel="stylesheet" href="/static/css/style.css">
<link rel="stylesheet" href="/static/css/dtsel.css">
</head> </head>
<body> <body>
<div id="app"></div> <div id="app"></div>

File diff suppressed because it is too large Load Diff

View File

@ -5,8 +5,7 @@
"scripts": { "scripts": {
"dev": "vite build --watch --mode development", "dev": "vite build --watch --mode development",
"build": "vite build", "build": "vite build",
"preview": "vite preview", "preview": "vite preview"
"package": "mkdir -p reminder-dashboard/usr/share/reminder-dashboard && vite build --mode production --outDir reminder-dashboard/usr/share/reminder-dashboard && dpkg-deb --build reminder-dashboard"
}, },
"dependencies": { "dependencies": {
"axios": "^1.5.1", "axios": "^1.5.1",

View File

@ -1,5 +1,4 @@
import axios from "axios"; import axios from "axios";
import { DateTime } from "luxon";
type UserInfo = { type UserInfo = {
name: string; name: string;
@ -37,7 +36,7 @@ export type Reminder = {
embed_title: string; embed_title: string;
embed_fields: EmbedField[] | null; embed_fields: EmbedField[] | null;
enabled: boolean; enabled: boolean;
expires: DateTime | null; expires: string | null;
interval_seconds: number | null; interval_seconds: number | null;
interval_days: number | null; interval_days: number | null;
interval_months: number | null; interval_months: number | null;
@ -46,7 +45,22 @@ export type Reminder = {
tts: boolean; tts: boolean;
uid: string; uid: string;
username: string; username: string;
utc_time: DateTime; utc_time: string;
};
export type Todo = {
id: number;
channel_id: string;
value: string;
};
export type CreateTodo = {
channel_id: string;
value: string;
};
export type UpdateTodo = {
value: string;
}; };
export type ChannelInfo = { export type ChannelInfo = {
@ -59,6 +73,11 @@ type RoleInfo = {
name: string; name: string;
}; };
type EmojiInfo = {
fmt: string;
name: string;
};
type Template = { type Template = {
id: number; id: number;
name: string; name: string;
@ -81,7 +100,7 @@ type Template = {
const USER_INFO_STALE_TIME = 120_000; const USER_INFO_STALE_TIME = 120_000;
const GUILD_INFO_STALE_TIME = 300_000; const GUILD_INFO_STALE_TIME = 300_000;
const OTHER_STALE_TIME = 15_000; const OTHER_STALE_TIME = 120_000;
export const fetchUserInfo = () => ({ export const fetchUserInfo = () => ({
queryKey: ["USER_INFO"], queryKey: ["USER_INFO"],
@ -110,9 +129,13 @@ export const fetchGuildInfo = (guild: string) => ({
export const fetchGuildChannels = (guild: string) => ({ export const fetchGuildChannels = (guild: string) => ({
queryKey: ["GUILD_CHANNELS", guild], queryKey: ["GUILD_CHANNELS", guild],
queryFn: () => queryFn: () =>
axios.get(`/dashboard/api/guild/${guild}/channels`).then((resp) => resp.data) as Promise< axios
ChannelInfo[] .get(`/dashboard/api/guild/${guild}/channels`)
>, .then((resp) =>
resp.data.sort((a: ChannelInfo, b: ChannelInfo) =>
a.name == b.name ? 0 : a.name > b.name ? 1 : -1,
),
) as Promise<ChannelInfo[]>,
staleTime: GUILD_INFO_STALE_TIME, staleTime: GUILD_INFO_STALE_TIME,
}); });
@ -125,46 +148,37 @@ export const fetchGuildRoles = (guild: string) => ({
staleTime: GUILD_INFO_STALE_TIME, staleTime: GUILD_INFO_STALE_TIME,
}); });
export const fetchGuildEmojis = (guild: string) => ({
queryKey: ["GUILD_EMOJIS", guild],
queryFn: () =>
axios.get(`/dashboard/api/guild/${guild}/emojis`).then((resp) => resp.data) as Promise<
EmojiInfo[]
>,
staleTime: GUILD_INFO_STALE_TIME,
});
export const fetchGuildReminders = (guild: string) => ({ export const fetchGuildReminders = (guild: string) => ({
queryKey: ["GUILD_REMINDERS", guild], queryKey: ["GUILD_REMINDERS", guild],
queryFn: () => queryFn: () =>
axios axios.get(`/dashboard/api/guild/${guild}/reminders`).then((resp) => resp.data) as Promise<
.get(`/dashboard/api/guild/${guild}/reminders`) Reminder[]
.then((resp) => resp.data) >,
.then((value) =>
value.map((reminder) => ({
...reminder,
utc_time: DateTime.fromISO(reminder.utc_time, { zone: "UTC" }),
expires:
reminder.expires === null
? null
: DateTime.fromISO(reminder.expires, { zone: "UTC" }),
})),
) as Promise<Reminder[]>,
staleTime: OTHER_STALE_TIME, staleTime: OTHER_STALE_TIME,
}); });
export const patchGuildReminder = (guild: string) => ({ export const patchGuildReminder = (guild: string) => ({
mutationFn: (reminder: Reminder) => mutationFn: (reminder: Reminder) =>
axios.patch(`/dashboard/api/guild/${guild}/reminders`, { axios.patch(`/dashboard/api/guild/${guild}/reminders`, reminder),
...reminder,
utc_time: reminder.utc_time.toFormat("yyyy-LL-dd'T'HH:mm:ss"),
}),
}); });
export const postGuildReminder = (guild: string) => ({ export const postGuildReminder = (guild: string) => ({
mutationFn: (reminder: Reminder) => mutationFn: (reminder: Reminder) =>
axios axios.post(`/dashboard/api/guild/${guild}/reminders`, reminder).then((resp) => resp.data),
.post(`/dashboard/api/guild/${guild}/reminders`, {
...reminder,
utc_time: reminder.utc_time.toFormat("yyyy-LL-dd'T'HH:mm:ss"),
})
.then((resp) => resp.data),
}); });
export const deleteGuildReminder = (guild: string) => ({ export const deleteReminder = () => ({
mutationFn: (reminder: Reminder) => mutationFn: (reminder: Reminder) =>
axios.delete(`/dashboard/api/guild/${guild}/reminders`, { axios.delete(`/dashboard/api/reminders`, {
data: { data: {
uid: reminder.uid, uid: reminder.uid,
}, },
@ -182,12 +196,7 @@ export const fetchGuildTemplates = (guild: string) => ({
export const postGuildTemplate = (guild: string) => ({ export const postGuildTemplate = (guild: string) => ({
mutationFn: (reminder: Reminder) => mutationFn: (reminder: Reminder) =>
axios axios.post(`/dashboard/api/guild/${guild}/templates`, reminder).then((resp) => resp.data),
.post(`/dashboard/api/guild/${guild}/templates`, {
...reminder,
utc_time: reminder.utc_time.toFormat("yyyy-LL-dd'T'HH:mm:ss"),
})
.then((resp) => resp.data),
}); });
export const deleteGuildTemplate = (guild: string) => ({ export const deleteGuildTemplate = (guild: string) => ({
@ -198,3 +207,41 @@ export const deleteGuildTemplate = (guild: string) => ({
}, },
}), }),
}); });
export const fetchGuildTodos = (guild: string) => ({
queryKey: ["GUILD_TODOS", guild],
queryFn: () =>
axios.get(`/dashboard/api/guild/${guild}/todos`).then((resp) => resp.data) as Promise<
Todo[]
>,
staleTime: OTHER_STALE_TIME,
});
export const patchGuildTodo = (guild: string) => ({
mutationFn: ({ id, todo }) => axios.patch(`/dashboard/api/guild/${guild}/todos/${id}`, todo),
});
export const postGuildTodo = (guild: string) => ({
mutationFn: (todo: CreateTodo) =>
axios.post(`/dashboard/api/guild/${guild}/todos`, todo).then((resp) => resp.data),
});
export const deleteGuildTodo = (guild: string) => ({
mutationFn: (todoId: number) => axios.delete(`/dashboard/api/guild/${guild}/todos/${todoId}`),
});
export const fetchUserReminders = () => ({
queryKey: ["USER_REMINDERS"],
queryFn: () =>
axios.get(`/dashboard/api/user/reminders`).then((resp) => resp.data) as Promise<Reminder[]>,
staleTime: OTHER_STALE_TIME,
});
export const postUserReminder = () => ({
mutationFn: (reminder: Reminder) =>
axios.post(`/dashboard/api/user/reminders`, reminder).then((resp) => resp.data),
});
export const patchUserReminder = () => ({
mutationFn: (reminder: Reminder) => axios.patch(`/dashboard/api/user/reminders`, reminder),
});

View File

@ -0,0 +1,56 @@
import { useEffect, useMemo } from "preact/hooks";
import { useQuery } from "react-query";
import { fetchGuildChannels, fetchGuildRoles, fetchGuildEmojis } from "../../api";
import Tribute from "tributejs";
import { useGuild } from "./useGuild";
export const Mentions = ({ input }) => {
const guild = useGuild();
return <>{guild && <_Mentions guild={guild} input={input} />}</>;
};
const _Mentions = ({ guild, input }) => {
const { data: roles } = useQuery(fetchGuildRoles(guild));
const { data: channels } = useQuery(fetchGuildChannels(guild));
const { data: emojis } = useQuery(fetchGuildEmojis(guild));
const tribute = useMemo(() => {
return new Tribute({
collection: [
{
trigger: "@",
values: (roles || [])
.filter((role) => role.name !== "@everyone")
.map(({ id, name }) => ({ key: name, value: id })),
allowSpaces: true,
selectTemplate: (item) => `<@&${item.original.value}>`,
menuItemTemplate: (item) => `@${item.original.key}`,
},
{
trigger: "#",
values: (channels || []).map(({ id, name }) => ({ key: name, value: id })),
allowSpaces: true,
selectTemplate: (item) => `<#${item.original.value}>`,
menuItemTemplate: (item) => `#${item.original.key}`,
},
{
trigger: ":",
values: (emojis || []).map(({ fmt, name }) => ({ key: name, value: fmt })),
allowSpaces: true,
selectTemplate: (item) => item.original.value,
menuItemTemplate: (item) => `:${item.original.key}:`,
},
],
});
}, [roles, channels, emojis]);
useEffect(() => {
tribute.detach(input.current);
if (input.current !== null) {
tribute.attach(input.current);
}
}, [tribute]);
return <></>;
};

View File

@ -5,6 +5,9 @@ import { Welcome } from "../Welcome";
import { Guild } from "../Guild"; import { Guild } from "../Guild";
import { FlashProvider } from "./FlashProvider"; import { FlashProvider } from "./FlashProvider";
import { TimezoneProvider } from "./TimezoneProvider"; import { TimezoneProvider } from "./TimezoneProvider";
import { User } from "../User";
import { GuildReminders } from "../Guild/GuildReminders";
import { GuildTodos } from "../Guild/GuildTodos";
export function App() { export function App() {
const queryClient = new QueryClient(); const queryClient = new QueryClient();
@ -17,14 +20,32 @@ export function App() {
<div class="columns is-gapless dashboard-frame"> <div class="columns is-gapless dashboard-frame">
<Sidebar /> <Sidebar />
<div class="column is-main-content"> <div class="column is-main-content">
<div style={{ margin: "0 12px 12px 12px" }}>
<Switch> <Switch>
<Route path={"/:guild/reminders"} component={Guild}></Route> <Route path={"/@me/reminders"} component={User}></Route>
<Route
path={"/:guild/reminders"}
component={() => (
<Guild>
<GuildReminders />
</Guild>
)}
></Route>
<Route
path={"/:guild/todos"}
component={() => (
<Guild>
<GuildTodos />
</Guild>
)}
></Route>
<Route> <Route>
<Welcome /> <Welcome />
</Route> </Route>
</Switch> </Switch>
</div> </div>
</div> </div>
</div>
</Router> </Router>
</QueryClientProvider> </QueryClientProvider>
</FlashProvider> </FlashProvider>

View File

@ -0,0 +1,6 @@
import { useParams } from "wouter";
export const useGuild = () => {
const { guild } = useParams() as { guild?: string };
return guild || null;
};

View File

@ -5,7 +5,12 @@ export const GuildError = () => {
<div class="container has-text-centered"> <div class="container has-text-centered">
<p class="title">We couldn't get this server's data</p> <p class="title">We couldn't get this server's data</p>
<p class="subtitle"> <p class="subtitle">
Please check Reminder Bot is in the server, and has correct permissions. The bot may have just been restarted, in which case please try again in a
few minutes.
<br />
<br />
Otherwise, please check Reminder Bot is in the server, and has correct
permissions.
</p> </p>
<a <a
class="button is-size-4 is-rounded is-success" class="button is-size-4 is-rounded is-success"

View File

@ -1,10 +1,10 @@
import { useParams } from "wouter"; import { useQuery, useQueryClient } from "react-query";
import { useQuery } from "react-query";
import { fetchGuildChannels, fetchGuildReminders } from "../../api"; import { fetchGuildChannels, fetchGuildReminders } from "../../api";
import { EditReminder } from "../Reminder/EditReminder"; import { EditReminder } from "../Reminder/EditReminder";
import { CreateReminder } from "../Reminder/CreateReminder"; import { CreateReminder } from "../Reminder/CreateReminder";
import { useState } from "preact/hooks"; import { useCallback, useState } from "preact/hooks";
import { Loader } from "../Loader"; import { Loader } from "../Loader";
import { useGuild } from "../App/useGuild";
enum Sort { enum Sort {
Time = "time", Time = "time",
@ -13,7 +13,7 @@ enum Sort {
} }
export const GuildReminders = () => { export const GuildReminders = () => {
const { guild } = useParams(); const guild = useGuild();
const { const {
isSuccess, isSuccess,
@ -24,19 +24,25 @@ export const GuildReminders = () => {
const { data: channels } = useQuery(fetchGuildChannels(guild)); const { data: channels } = useQuery(fetchGuildChannels(guild));
const [collapsed, setCollapsed] = useState(false); const [collapsed, setCollapsed] = useState(false);
const [sort, setSort] = useState(Sort.Time); const [sort, _setSort] = useState(Sort.Time);
const queryClient = useQueryClient();
let prevReminder = null; let prevReminder = null;
const setSort = useCallback((sort) => {
queryClient.invalidateQueries(["GUILD_REMINDERS"]);
_setSort(sort);
}, []);
return ( return (
<> <>
{!isFetched && <Loader />} {!isFetched && <Loader />}
<div style={{ margin: "0 12px 12px 12px" }}>
<strong>Create Reminder</strong> <strong>Create Reminder</strong>
<div id={"reminderCreator"}> <div id={"reminderCreator"}>
<CreateReminder /> <CreateReminder />
</div> </div>
<br></br> <br />
<div class={"field"}> <div class={"field"}>
<div class={"columns is-mobile"}> <div class={"columns is-mobile"}>
<div class={"column"}> <div class={"column"}>
@ -57,10 +63,7 @@ export const GuildReminders = () => {
<option value={Sort.Name} selected={sort == Sort.Name}> <option value={Sort.Name} selected={sort == Sort.Name}>
Name Name
</option> </option>
<option <option value={Sort.Channel} selected={sort == Sort.Channel}>
value={Sort.Channel}
selected={sort == Sort.Channel}
>
Channel Channel
</option> </option>
</select> </select>
@ -136,7 +139,6 @@ export const GuildReminders = () => {
); );
})} })}
</div> </div>
</div>
</> </>
); );
}; };

View File

@ -0,0 +1,62 @@
import { useQuery } from "react-query";
import { ChannelInfo, fetchGuildChannels, fetchGuildTodos } from "../../api";
import { useGuild } from "../App/useGuild";
import { Todo } from "../Todo";
import { Todo as TodoT } from "../../api";
import { Loader } from "../Loader";
import { CreateTodo } from "../Todo/CreateTodo";
export const GuildTodos = () => {
const guild = useGuild();
const { isFetched, data: guildTodos } = useQuery(fetchGuildTodos(guild));
const { data: channels } = useQuery(fetchGuildChannels(guild));
if (!isFetched || !channels) {
return <Loader />;
}
const sortedTodos = guildTodos.sort((a, b) => (a.id < b.id ? -1 : 1));
const globalTodos = sortedTodos.filter((todo) => todo.channel_id === null);
return (
<>
<strong>Create todo list</strong>
<CreateTodo channel={null} showSelector={true} />
<strong>Todo lists</strong>
{globalTodos.length > 0 && (
<>
<h2>Server</h2>
{globalTodos.map((todo) => (
<>
<Todo todo={todo} key={todo.id} />
</>
))}
<CreateTodo channel={null} />
</>
)}
{channels
.map(
(channel) =>
[channel, sortedTodos.filter((todo) => todo.channel_id === channel.id)] as [
ChannelInfo,
TodoT[],
],
)
.filter(([_, todos]) => todos.length > 0)
.map(([channel, todos]) => {
return (
<>
<h2>#{channel.name}</h2>
{todos.map((todo) => (
<>
<Todo todo={todo} key={todo.id} />
</>
))}
<CreateTodo channel={channel.id} />
</>
);
})}
</>
);
};

View File

@ -0,0 +1,5 @@
.page-links {
> * {
margin: 2px;
}
}

View File

@ -1,13 +1,16 @@
import { useQuery } from "react-query"; import { useQuery } from "react-query";
import { fetchGuildInfo } from "../../api"; import { fetchGuildInfo } from "../../api";
import { useParams } from "wouter";
import { GuildReminders } from "./GuildReminders";
import { GuildError } from "./GuildError"; import { GuildError } from "./GuildError";
import { createPortal } from "preact/compat"; import { createPortal, PropsWithChildren } from "preact/compat";
import { Import } from "../Import"; import { Import } from "../Import";
import { useGuild } from "../App/useGuild";
import { Link } from "wouter";
import { usePathname } from "wouter/use-browser-location";
export const Guild = () => { import "./index.scss";
const { guild } = useParams();
export const Guild = ({ children }: PropsWithChildren) => {
const guild = useGuild();
const { isSuccess, data: guildInfo } = useQuery(fetchGuildInfo(guild)); const { isSuccess, data: guildInfo } = useQuery(fetchGuildInfo(guild));
if (!isSuccess) { if (!isSuccess) {
@ -16,11 +19,32 @@ export const Guild = () => {
return <GuildError />; return <GuildError />;
} else { } else {
const importModal = createPortal(<Import />, document.getElementById("bottom-sidebar")); const importModal = createPortal(<Import />, document.getElementById("bottom-sidebar"));
const path = usePathname();
return ( return (
<> <>
{importModal} {importModal}
<GuildReminders /> <div class="page-links">
<Link
class={`button is-outlined is-success is-small ${path.endsWith("reminders") && "is-focused"}`}
href={`/${guild}/reminders`}
>
<span>Reminders</span>
<span class="icon">
<i class="fa fa-chevron-right"></i>
</span>
</Link>
<Link
class={`button is-outlined is-success is-small ${path.endsWith("todos") && "is-focused"}`}
href={`/${guild}/todos`}
>
<span>Todo lists</span>
<span class="icon">
<i class="fa fa-chevron-right"></i>
</span>
</Link>
</div>
{children}
</> </>
); );
} }

View File

@ -3,6 +3,8 @@ import { useRef, useState } from "preact/hooks";
import { useParams } from "wouter"; import { useParams } from "wouter";
import axios from "axios"; import axios from "axios";
import { useFlash } from "../App/FlashContext"; import { useFlash } from "../App/FlashContext";
import { useGuild } from "../App/useGuild";
import { useQueryClient } from "react-query";
export const Import = () => { export const Import = () => {
const [modalOpen, setModalOpen] = useState(false); const [modalOpen, setModalOpen] = useState(false);
@ -27,7 +29,7 @@ export const Import = () => {
}; };
const ImportModal = ({ setModalOpen }) => { const ImportModal = ({ setModalOpen }) => {
const { guild } = useParams(); const guild = useGuild();
const aRef = useRef<HTMLAnchorElement>(); const aRef = useRef<HTMLAnchorElement>();
const inputRef = useRef<HTMLInputElement>(); const inputRef = useRef<HTMLInputElement>();
@ -35,6 +37,8 @@ const ImportModal = ({ setModalOpen }) => {
const [isImporting, setIsImporting] = useState(false); const [isImporting, setIsImporting] = useState(false);
const queryClient = useQueryClient();
return ( return (
<Modal <Modal
setModalOpen={setModalOpen} setModalOpen={setModalOpen}
@ -121,7 +125,7 @@ const ImportModal = ({ setModalOpen }) => {
axios axios
.put(`/dashboard/api/guild/${guild}/export/reminders`, { .put(`/dashboard/api/guild/${guild}/export/reminders`, {
body: JSON.stringify({ body: dataUrl.split(",")[1] }), body: dataUrl.split(",")[1],
}) })
.then(({ data }) => { .then(({ data }) => {
setIsImporting(false); setIsImporting(false);
@ -130,6 +134,9 @@ const ImportModal = ({ setModalOpen }) => {
flash({ message: data.error, type: "error" }); flash({ message: data.error, type: "error" });
} else { } else {
flash({ message: data.message, type: "success" }); flash({ message: data.message, type: "success" });
queryClient.invalidateQueries({
queryKey: ["GUILD_REMINDERS", guild],
});
} }
}) })
.then(() => { .then(() => {

View File

@ -1,8 +1,11 @@
import { useReminder } from "./ReminderContext"; import { useReminder } from "./ReminderContext";
import { useFlash } from "../App/FlashContext";
export const Attachment = () => { export const Attachment = () => {
const [{ attachment_name }, setReminder] = useReminder(); const [{ attachment_name }, setReminder] = useReminder();
const flash = useFlash();
return ( return (
<div class="file is-small is-boxed"> <div class="file is-small is-boxed">
<label class="file-label"> <label class="file-label">
@ -16,7 +19,8 @@ export const Attachment = () => {
let file = input.files[0]; let file = input.files[0];
if (file.size >= 8 * 1024 * 1024) { if (file.size >= 8 * 1024 * 1024) {
return { error: "File too large." }; flash({ message: "File too large (max. 8MB).", type: "error" });
return;
} }
let attachment: string = await new Promise((resolve) => { let attachment: string = await new Promise((resolve) => {
@ -35,12 +39,42 @@ export const Attachment = () => {
}} }}
></input> ></input>
<span class="file-cta"> <span class="file-cta">
<span class="file-label">{attachment_name || "Add Attachment"}</span> <span
class="file-label"
style={{
maxWidth: "200px",
}}
>
{attachment_name || "Add Attachment"}
</span>
<span class="file-icon"> <span class="file-icon">
<i class="fas fa-upload"></i> <i class="fas fa-upload"></i>
</span> </span>
</span> </span>
</label> </label>
{attachment_name && (
<>
<button
onClick={() => {
setReminder((reminder) => ({
...reminder,
attachment: null,
attachment_name: null,
}));
}}
style={{
border: "none",
background: "none",
cursor: "pointer",
}}
>
<span class="sr-only">Remove attachment</span>
<span class="icon">
<i class="fas fa-trash"></i>
</span>
</button>
</>
)}
</div> </div>
); );
}; };

View File

@ -0,0 +1,28 @@
import { ImagePicker } from "./ImagePicker";
import { useReminder } from "./ReminderContext";
import { useGuild } from "../App/useGuild";
export const Avatar = () => {
const guild = useGuild();
const [reminder, setReminder] = useReminder();
return guild ? (
<ImagePicker
class="is-rounded avatar"
url={reminder.avatar || "/static/img/icon.png"}
alt="Image for discord avatar"
setImage={(url: string) => {
setReminder((reminder) => ({
...reminder,
avatar: url,
}));
}}
></ImagePicker>
) : (
<img
class="is-rounded avatar"
alt="Image for discord avatar"
src={"/static/img/icon.png"}
></img>
);
};

View File

@ -1,14 +1,14 @@
import { LoadTemplate } from "../LoadTemplate"; import { LoadTemplate } from "../LoadTemplate";
import { useReminder } from "../ReminderContext"; import { useReminder } from "../ReminderContext";
import { useMutation, useQueryClient } from "react-query"; import { useMutation, useQueryClient } from "react-query";
import { postGuildReminder, postGuildTemplate } from "../../../api"; import { postGuildReminder, postGuildTemplate, postUserReminder } from "../../../api";
import { useParams } from "wouter";
import { useState } from "preact/hooks"; import { useState } from "preact/hooks";
import { ICON_FLASH_TIME } from "../../../consts"; import { ICON_FLASH_TIME } from "../../../consts";
import { useFlash } from "../../App/FlashContext"; import { useFlash } from "../../App/FlashContext";
import { useGuild } from "../../App/useGuild";
export const CreateButtonRow = () => { export const CreateButtonRow = () => {
const { guild } = useParams(); const guild = useGuild();
const [reminder] = useReminder(); const [reminder] = useReminder();
const [recentlyCreated, setRecentlyCreated] = useState(false); const [recentlyCreated, setRecentlyCreated] = useState(false);
@ -17,7 +17,13 @@ export const CreateButtonRow = () => {
const flash = useFlash(); const flash = useFlash();
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const mutation = useMutation({ const mutation = useMutation({
...postGuildReminder(guild), ...(guild ? postGuildReminder(guild) : postUserReminder()),
onError: (error) => {
flash({
message: `An error occurred: ${error}`,
type: "error",
});
},
onSuccess: (data) => { onSuccess: (data) => {
if (data.error) { if (data.error) {
flash({ flash({
@ -29,9 +35,15 @@ export const CreateButtonRow = () => {
message: "Reminder created", message: "Reminder created",
type: "success", type: "success",
}); });
if (guild) {
queryClient.invalidateQueries({ queryClient.invalidateQueries({
queryKey: ["GUILD_REMINDERS", guild], queryKey: ["GUILD_REMINDERS", guild],
}); });
} else {
queryClient.invalidateQueries({
queryKey: ["USER_REMINDERS"],
});
}
setRecentlyCreated(true); setRecentlyCreated(true);
setTimeout(() => { setTimeout(() => {
setRecentlyCreated(false); setRecentlyCreated(false);
@ -89,6 +101,7 @@ export const CreateButtonRow = () => {
)} )}
</button> </button>
</div> </div>
{guild && (
<div class="button-row-template"> <div class="button-row-template">
<div> <div>
<button <button
@ -117,6 +130,7 @@ export const CreateButtonRow = () => {
<LoadTemplate /> <LoadTemplate />
</div> </div>
</div> </div>
)}
</div> </div>
); );
}; };

View File

@ -2,9 +2,10 @@ import { useState } from "preact/hooks";
import { Modal } from "../../Modal"; import { Modal } from "../../Modal";
import { useMutation, useQueryClient } from "react-query"; import { useMutation, useQueryClient } from "react-query";
import { useReminder } from "../ReminderContext"; import { useReminder } from "../ReminderContext";
import { deleteGuildReminder } from "../../../api"; import { deleteReminder } from "../../../api";
import { useParams } from "wouter"; import { useParams } from "wouter";
import { useFlash } from "../../App/FlashContext"; import { useFlash } from "../../App/FlashContext";
import { useGuild } from "../../App/useGuild";
export const DeleteButton = () => { export const DeleteButton = () => {
const [modalOpen, setModalOpen] = useState(false); const [modalOpen, setModalOpen] = useState(false);
@ -26,20 +27,26 @@ export const DeleteButton = () => {
const DeleteModal = ({ setModalOpen }) => { const DeleteModal = ({ setModalOpen }) => {
const [reminder] = useReminder(); const [reminder] = useReminder();
const { guild } = useParams(); const guild = useGuild();
const flash = useFlash(); const flash = useFlash();
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const mutation = useMutation({ const mutation = useMutation({
...deleteGuildReminder(guild), ...deleteReminder(),
onSuccess: () => { onSuccess: () => {
flash({ flash({
message: "Reminder deleted", message: "Reminder deleted",
type: "success", type: "success",
}); });
if (guild) {
queryClient.invalidateQueries({ queryClient.invalidateQueries({
queryKey: ["GUILD_REMINDERS", guild], queryKey: ["GUILD_REMINDERS", guild],
}); });
} else {
queryClient.invalidateQueries({
queryKey: ["USER_REMINDERS"],
});
}
setModalOpen(false); setModalOpen(false);
}, },
}); });

View File

@ -1,29 +1,30 @@
import { useRef, useState } from "preact/hooks"; import { useRef, useState } from "preact/hooks";
import { useMutation, useQueryClient } from "react-query"; import { useMutation, useQueryClient } from "react-query";
import { patchGuildReminder } from "../../../api"; import { patchGuildReminder, patchUserReminder } from "../../../api";
import { useParams } from "wouter";
import { ICON_FLASH_TIME } from "../../../consts"; import { ICON_FLASH_TIME } from "../../../consts";
import { DeleteButton } from "./DeleteButton"; import { DeleteButton } from "./DeleteButton";
import { useReminder } from "../ReminderContext"; import { useReminder } from "../ReminderContext";
import { useFlash } from "../../App/FlashContext"; import { useFlash } from "../../App/FlashContext";
import { useGuild } from "../../App/useGuild";
export const EditButtonRow = () => { export const EditButtonRow = () => {
const { guild } = useParams(); const guild = useGuild();
const [reminder, setReminder] = useReminder(); const [reminder, setReminder] = useReminder();
const [recentlySaved, setRecentlySaved] = useState(false); const [recentlySaved, setRecentlySaved] = useState(false);
const queryClient = useQueryClient();
const iconFlashTimeout = useRef(0); const iconFlashTimeout = useRef(0);
const flash = useFlash(); const flash = useFlash();
const mutation = useMutation({ const mutation = useMutation({
...patchGuildReminder(guild), ...(guild ? patchGuildReminder(guild) : patchUserReminder()),
onSuccess: (response) => { onError: (error) => {
queryClient.invalidateQueries({ flash({
queryKey: ["GUILD_REMINDERS", guild], message: `An error occurred: ${error}`,
type: "error",
}); });
},
onSuccess: (response) => {
if (iconFlashTimeout.current !== null) { if (iconFlashTimeout.current !== null) {
clearTimeout(iconFlashTimeout.current); clearTimeout(iconFlashTimeout.current);
} }

View File

@ -1,9 +1,9 @@
import { useQuery } from "react-query"; import { useQuery } from "react-query";
import { useParams } from "wouter";
import { fetchGuildChannels } from "../../api"; import { fetchGuildChannels } from "../../api";
import { useGuild } from "../App/useGuild";
export const ChannelSelector = ({ channel, setChannel }) => { export const ChannelSelector = ({ channel, setChannel }) => {
const { guild } = useParams(); const guild = useGuild();
const { isSuccess, data } = useQuery(fetchGuildChannels(guild)); const { isSuccess, data } = useQuery(fetchGuildChannels(guild));
return ( return (

View File

@ -1,10 +1,16 @@
import { useReminder } from "./ReminderContext"; import { useReminder } from "./ReminderContext";
import { useRef } from "preact/hooks";
import { Mentions } from "../App/Mentions";
import { useGuild } from "../App/useGuild";
export const Content = () => { export const Content = () => {
const guild = useGuild();
const [reminder, setReminder] = useReminder(); const [reminder, setReminder] = useReminder();
const input = useRef(null);
return ( return (
<> <>
{guild && <Mentions input={input} />}
<label class="is-sr-only">Content</label> <label class="is-sr-only">Content</label>
<textarea <textarea
class="message-input autoresize discord-content" class="message-input autoresize discord-content"
@ -12,6 +18,7 @@ export const Content = () => {
maxlength={2000} maxlength={2000}
name="content" name="content"
rows={1} rows={1}
ref={input}
value={reminder.content} value={reminder.content}
onInput={(ev) => { onInput={(ev) => {
setReminder((reminder) => ({ setReminder((reminder) => ({

View File

@ -7,7 +7,9 @@ import { Message } from "./Message";
import { Settings } from "./Settings"; import { Settings } from "./Settings";
import { ReminderContext } from "./ReminderContext"; import { ReminderContext } from "./ReminderContext";
import { useQuery } from "react-query"; import { useQuery } from "react-query";
import { useParams } from "wouter"; import "./styles.scss";
import { useGuild } from "../App/useGuild";
import { DEFAULT_COLOR } from "./Embed";
function defaultReminder(): Reminder { function defaultReminder(): Reminder {
return { return {
@ -18,7 +20,7 @@ function defaultReminder(): Reminder {
content: "", content: "",
embed_author: "", embed_author: "",
embed_author_url: null, embed_author_url: null,
embed_color: 0, embed_color: DEFAULT_COLOR,
embed_description: "", embed_description: "",
embed_fields: [], embed_fields: [],
embed_footer: "", embed_footer: "",
@ -36,13 +38,44 @@ function defaultReminder(): Reminder {
tts: false, tts: false,
uid: "", uid: "",
username: "", username: "",
utc_time: DateTime.now(), utc_time: DateTime.now().setZone("UTC").toFormat("yyyy-LL-dd'T'HH:mm:ss"),
}; };
} }
export const CreateReminder = () => { export const CreateReminder = () => {
const { guild } = useParams(); const guild = useGuild();
if (guild) {
return <_Guild guild={guild} />;
} else {
return <_User />;
}
};
const _User = () => {
const [reminder, setReminder] = useState(defaultReminder());
const [collapsed, setCollapsed] = useState(false);
return (
<ReminderContext.Provider value={[reminder, setReminder]}>
<div class={collapsed ? "reminderContent is-collapsed" : "reminderContent"}>
<TopBar
isCreating={true}
toggleCollapsed={() => {
setCollapsed(!collapsed);
}}
/>
<div class="columns reminder-settings">
<Message />
<Settings />
</div>
<CreateButtonRow />
</div>
</ReminderContext.Provider>
);
};
const _Guild = ({ guild }) => {
const [reminder, setReminder] = useState(defaultReminder()); const [reminder, setReminder] = useState(defaultReminder());
const [collapsed, setCollapsed] = useState(false); const [collapsed, setCollapsed] = useState(false);
@ -59,6 +92,7 @@ export const CreateReminder = () => {
<ReminderContext.Provider value={[reminder, setReminder]}> <ReminderContext.Provider value={[reminder, setReminder]}>
<div class={collapsed ? "reminderContent is-collapsed" : "reminderContent"}> <div class={collapsed ? "reminderContent is-collapsed" : "reminderContent"}>
<TopBar <TopBar
isCreating={true}
toggleCollapsed={() => { toggleCollapsed={() => {
setCollapsed(!collapsed); setCollapsed(!collapsed);
}} }}

View File

@ -5,6 +5,7 @@ import { Message } from "./Message";
import { Settings } from "./Settings"; import { Settings } from "./Settings";
import { ReminderContext } from "./ReminderContext"; import { ReminderContext } from "./ReminderContext";
import { TopBar } from "./TopBar"; import { TopBar } from "./TopBar";
import "./styles.scss";
type Props = { type Props = {
reminder: Reminder; reminder: Reminder;
@ -27,9 +28,13 @@ export const EditReminder = ({ reminder: initialReminder, globalCollapse }: Prop
} }
return ( return (
<ReminderContext.Provider value={[reminder, setReminder]}> <ReminderContext.Provider value={[reminder, setReminder]} key={reminder.uid}>
<div class={collapsed ? "reminderContent is-collapsed" : "reminderContent"}> <div
class={collapsed ? "reminderContent is-collapsed" : "reminderContent"}
id={`reminder-${reminder.uid.slice(0, 12)}`}
>
<TopBar <TopBar
isCreating={false}
toggleCollapsed={() => { toggleCollapsed={() => {
setCollapsed(!collapsed); setCollapsed(!collapsed);
}} }}

View File

@ -1,5 +1,8 @@
import { ImagePicker } from "../ImagePicker"; import { ImagePicker } from "../ImagePicker";
import { Reminder } from "../../../api"; import { Reminder } from "../../../api";
import { Mentions } from "../../App/Mentions";
import { useRef } from "preact/hooks";
import { useGuild } from "../../App/useGuild";
type Props = { type Props = {
name: string; name: string;
@ -8,6 +11,9 @@ type Props = {
}; };
export const Author = ({ name, icon, setReminder }: Props) => { export const Author = ({ name, icon, setReminder }: Props) => {
const guild = useGuild();
const input = useRef(null);
return ( return (
<div class="embed-author-box"> <div class="embed-author-box">
<div class="a"> <div class="a">
@ -27,6 +33,7 @@ export const Author = ({ name, icon, setReminder }: Props) => {
</div> </div>
<div class="b"> <div class="b">
{guild && <Mentions input={input} />}
<label class="is-sr-only" for="embedAuthor"> <label class="is-sr-only" for="embedAuthor">
Embed Author Embed Author
</label> </label>
@ -34,6 +41,7 @@ export const Author = ({ name, icon, setReminder }: Props) => {
class="discord-embed-author message-input autoresize" class="discord-embed-author message-input autoresize"
placeholder="Embed Author..." placeholder="Embed Author..."
rows={1} rows={1}
ref={input}
maxlength={256} maxlength={256}
name="embed_author" name="embed_author"
value={name} value={name}

View File

@ -1,4 +1,4 @@
import { useEffect, useState } from "preact/hooks"; import { useState } from "preact/hooks";
import { HexColorPicker } from "react-colorful"; import { HexColorPicker } from "react-colorful";
import { Modal } from "../../Modal"; import { Modal } from "../../Modal";
import { Reminder } from "../../../api"; import { Reminder } from "../../../api";

View File

@ -1,5 +1,14 @@
export const Description = ({ description, onInput }) => ( import { Mentions } from "../../App/Mentions";
import { useRef } from "preact/hooks";
import { useGuild } from "../../App/useGuild";
export const Description = ({ description, onInput }) => {
const guild = useGuild();
const input = useRef(null);
return (
<> <>
{guild && <Mentions input={input} />}
<label class="is-sr-only" for="embedDescription"> <label class="is-sr-only" for="embedDescription">
Embed Description Embed Description
</label> </label>
@ -9,6 +18,7 @@ export const Description = ({ description, onInput }) => (
maxlength={4096} maxlength={4096}
name="embed_description" name="embed_description"
rows={1} rows={1}
ref={input}
value={description} value={description}
onInput={(ev) => { onInput={(ev) => {
onInput(ev.currentTarget.value); onInput(ev.currentTarget.value);
@ -16,3 +26,4 @@ export const Description = ({ description, onInput }) => (
></textarea> ></textarea>
</> </>
); );
};

View File

@ -1,4 +1,11 @@
import { useRef } from "preact/hooks";
import { Mentions } from "../../../App/Mentions";
import { useGuild } from "../../../App/useGuild";
export const Field = ({ title, value, inline, index, onUpdate }) => { export const Field = ({ title, value, inline, index, onUpdate }) => {
const guild = useGuild();
const input = useRef(null);
return ( return (
<div data-inlined={inline ? "1" : "0"} class="embed-field-box" key={index}> <div data-inlined={inline ? "1" : "0"} class="embed-field-box" key={index}>
<label class="is-sr-only" for="embedFieldTitle"> <label class="is-sr-only" for="embedFieldTitle">
@ -35,6 +42,7 @@ export const Field = ({ title, value, inline, index, onUpdate }) => {
)} )}
</div> </div>
{guild && <Mentions input={input} />}
<label class="is-sr-only" for="embedFieldValue"> <label class="is-sr-only" for="embedFieldValue">
Field Value Field Value
</label> </label>
@ -44,6 +52,7 @@ export const Field = ({ title, value, inline, index, onUpdate }) => {
maxlength={1024} maxlength={1024}
name="embed_field_value[]" name="embed_field_value[]"
rows={1} rows={1}
ref={input}
value={value} value={value}
onInput={(ev) => onInput={(ev) =>
onUpdate({ onUpdate({

View File

@ -1,5 +1,8 @@
import { Reminder } from "../../../api"; import { Reminder } from "../../../api";
import { ImagePicker } from "../ImagePicker"; import { ImagePicker } from "../ImagePicker";
import { Mentions } from "../../App/Mentions";
import { useRef } from "preact/hooks";
import { useGuild } from "../../App/useGuild";
type Props = { type Props = {
footer: string; footer: string;
@ -7,7 +10,11 @@ type Props = {
setReminder: (r: (reminder: Reminder) => Reminder) => void; setReminder: (r: (reminder: Reminder) => Reminder) => void;
}; };
export const Footer = ({ footer, icon, setReminder }: Props) => ( export const Footer = ({ footer, icon, setReminder }: Props) => {
const guild = useGuild();
const input = useRef(null);
return (
<div class="embed-footer-box"> <div class="embed-footer-box">
<p class="image is-20x20 customizable"> <p class="image is-20x20 customizable">
<ImagePicker <ImagePicker
@ -25,12 +32,14 @@ export const Footer = ({ footer, icon, setReminder }: Props) => (
<label class="is-sr-only" for="embedFooter"> <label class="is-sr-only" for="embedFooter">
Embed Footer text Embed Footer text
</label> </label>
{guild && <Mentions input={input} />}
<textarea <textarea
class="discord-embed-footer message-input autoresize " class="discord-embed-footer message-input autoresize "
placeholder="Embed Footer..." placeholder="Embed Footer..."
maxlength={2048} maxlength={2048}
name="embed_footer" name="embed_footer"
rows={1} rows={1}
ref={input}
value={footer} value={footer}
onInput={(ev) => { onInput={(ev) => {
setReminder((reminder) => ({ setReminder((reminder) => ({
@ -41,3 +50,4 @@ export const Footer = ({ footer, icon, setReminder }: Props) => (
></textarea> ></textarea>
</div> </div>
); );
};

View File

@ -1,5 +1,14 @@
export const Title = ({ title, onInput }) => ( import { useRef } from "preact/hooks";
import { useGuild } from "../../App/useGuild";
import { Mentions } from "../../App/Mentions";
export const Title = ({ title, onInput }) => {
const guild = useGuild();
const input = useRef(null);
return (
<> <>
{guild && <Mentions input={input} />}
<label class="is-sr-only" for="embedTitle"> <label class="is-sr-only" for="embedTitle">
Embed Title Embed Title
</label> </label>
@ -8,6 +17,7 @@ export const Title = ({ title, onInput }) => (
placeholder="Embed Title..." placeholder="Embed Title..."
maxlength={256} maxlength={256}
rows={1} rows={1}
ref={input}
name="embed_title" name="embed_title"
value={title} value={title}
onInput={(ev) => { onInput={(ev) => {
@ -16,3 +26,4 @@ export const Title = ({ title, onInput }) => (
></textarea> ></textarea>
</> </>
); );
};

View File

@ -12,7 +12,7 @@ function intToColor(num: number) {
return `#${num.toString(16).padStart(6, "0")}`; return `#${num.toString(16).padStart(6, "0")}`;
} }
const DEFAULT_COLOR = 9418359; export const DEFAULT_COLOR = 9418359;
export const Embed = () => { export const Embed = () => {
const [reminder, setReminder] = useReminder(); const [reminder, setReminder] = useReminder();

View File

@ -37,6 +37,11 @@ const ImagePickerModal = ({ setModalOpen, setImage }) => {
}} }}
onSubmitText={"Save"} onSubmitText={"Save"}
> >
<p>
Please note: if you attach an image directly from Discord, it will not be visible in
the dashboard, but will be visible on reminders. Other image-sharing sites such as
Imgur don't have this issue.
</p>
<input <input
class="input" class="input"
id="urlInput" id="urlInput"

View File

@ -1,28 +1,14 @@
import { ImagePicker } from "./ImagePicker";
import { Username } from "./Username"; import { Username } from "./Username";
import { Content } from "./Content"; import { Content } from "./Content";
import { Embed } from "./Embed"; import { Embed } from "./Embed";
import { useReminder } from "./ReminderContext"; import { Avatar } from "./Avatar";
export const Message = () => { export const Message = () => (
const [reminder, setReminder] = useReminder();
return (
<div class="column discord-frame"> <div class="column discord-frame">
<article class="media"> <article class="media">
<figure class="media-left"> <figure class="media-left">
<p class="image is-32x32 customizable"> <p class="image is-32x32 customizable">
<ImagePicker <Avatar />
class="is-rounded avatar"
url={reminder.avatar || "/static/img/icon.png"}
alt="Image for discord avatar"
setImage={(url: string) => {
setReminder((reminder) => ({
...reminder,
avatar: url,
}));
}}
></ImagePicker>
</p> </p>
</figure> </figure>
<div class="media-content"> <div class="media-content">
@ -35,4 +21,3 @@ export const Message = () => {
</article> </article>
</div> </div>
); );
};

View File

@ -7,13 +7,13 @@ import { useReminder } from "./ReminderContext";
import { Attachment } from "./Attachment"; import { Attachment } from "./Attachment";
import { TTS } from "./TTS"; import { TTS } from "./TTS";
import { TimeInput } from "./TimeInput"; import { TimeInput } from "./TimeInput";
import { useTimezone } from "../App/TimezoneProvider"; import { useGuild } from "../App/useGuild";
export const Settings = () => { export const Settings = () => {
const guild = useGuild();
const { isSuccess: userFetched, data: userInfo } = useQuery(fetchUserInfo()); const { isSuccess: userFetched, data: userInfo } = useQuery(fetchUserInfo());
const [reminder, setReminder] = useReminder(); const [reminder, setReminder] = useReminder();
const [timezone] = useTimezone();
if (!userFetched) { if (!userFetched) {
return <></>; return <></>;
@ -21,6 +21,7 @@ export const Settings = () => {
return ( return (
<div class="column settings"> <div class="column settings">
{guild && (
<div class="field channel-field"> <div class="field channel-field">
<div class="collapses"> <div class="collapses">
<label class="label" for="channelOption"> <label class="label" for="channelOption">
@ -37,17 +38,18 @@ export const Settings = () => {
}} }}
/> />
</div> </div>
)}
<div class="field"> <div class="field">
<div class="control"> <div class="control">
<label class="label collapses"> <label class="label collapses">
Time* Time*
<TimeInput <TimeInput
defaultValue={reminder.utc_time.setZone(timezone)} defaultValue={reminder.utc_time}
onInput={(time: DateTime) => { onInput={(time: string) => {
setReminder((reminder) => ({ setReminder((reminder) => ({
...reminder, ...reminder,
utc_time: time.toUTC(), utc_time: time,
})); }));
}} }}
/> />
@ -98,11 +100,11 @@ export const Settings = () => {
<label class="label"> <label class="label">
Expiration Expiration
<TimeInput <TimeInput
defaultValue={reminder.expires?.setZone(timezone)} defaultValue={reminder.expires}
onInput={(time: DateTime) => { onInput={(time: string) => {
setReminder((reminder) => ({ setReminder((reminder) => ({
...reminder, ...reminder,
expires: time?.toUTC(), expires: time,
})); }));
}} }}
/> />

View File

@ -1,15 +1,43 @@
import { useEffect, useRef, useState } from "preact/hooks"; import { useCallback, useEffect, useRef, useState } from "preact/hooks";
import { DateTime } from "luxon"; import { DateTime } from "luxon";
import { useFlash } from "../App/FlashContext"; import { useFlash } from "../App/FlashContext";
import { useTimezone } from "../App/TimezoneProvider";
type TimeUpdate = {
year?: number | null;
month?: number;
day?: number;
hour?: number;
minute?: number;
second?: number;
};
export const TimeInput = ({ defaultValue, onInput }) => { export const TimeInput = ({ defaultValue, onInput }) => {
const ref = useRef(null); const ref = useRef(null);
const [time, setTime] = useState(defaultValue); const [timezone] = useTimezone();
const [localTime, setLocalTime] = useState(
defaultValue ? DateTime.fromISO(defaultValue, { zone: "UTC" }).setZone(timezone) : null,
);
const updateTime = useCallback(
(upd: TimeUpdate) => {
if (upd === null) {
setLocalTime(null);
}
let newTime = localTime;
if (newTime === null) {
newTime = DateTime.now().setZone(timezone);
}
setLocalTime(newTime.set(upd));
},
[localTime, timezone],
);
useEffect(() => { useEffect(() => {
onInput(time); onInput(localTime?.setZone("UTC").toFormat("yyyy-LL-dd'T'HH:mm:ss"));
}, [time]); }, [localTime]);
const flash = useFlash(); const flash = useFlash();
@ -20,17 +48,17 @@ export const TimeInput = ({ defaultValue, onInput }) => {
onPaste={(ev) => { onPaste={(ev) => {
ev.preventDefault(); ev.preventDefault();
const pasteValue = ev.clipboardData.getData("text/plain"); const pasteValue = ev.clipboardData.getData("text/plain");
let dt = DateTime.fromISO(pasteValue); let dt = DateTime.fromISO(pasteValue, { zone: timezone });
if (dt.isValid) { if (dt.isValid) {
setTime(dt); setLocalTime(dt);
return; return;
} }
dt = DateTime.fromSQL(pasteValue); dt = DateTime.fromSQL(pasteValue);
if (dt.isValid) { if (dt.isValid) {
setTime(dt); setLocalTime(dt);
return; return;
} }
@ -54,12 +82,20 @@ export const TimeInput = ({ defaultValue, onInput }) => {
pattern="\d*" pattern="\d*"
maxlength={4} maxlength={4}
placeholder="YYYY" placeholder="YYYY"
value={time?.year.toLocaleString("en-US", { value={
localTime
? localTime.year.toLocaleString("en-US", {
minimumIntegerDigits: 4, minimumIntegerDigits: 4,
useGrouping: false, useGrouping: false,
})} })
: ""
}
onBlur={(ev) => { onBlur={(ev) => {
setTime(time.set({ year: ev.currentTarget.value })); ev.currentTarget.value
? updateTime({
year: parseInt(ev.currentTarget.value),
})
: updateTime(null);
}} }}
></input>{" "} ></input>{" "}
</label> </label>
@ -77,9 +113,19 @@ export const TimeInput = ({ defaultValue, onInput }) => {
pattern="\d*" pattern="\d*"
maxlength={2} maxlength={2}
placeholder="MM" placeholder="MM"
value={time?.month.toLocaleString("en-US", { minimumIntegerDigits: 2 })} value={
localTime
? localTime.month.toLocaleString("en-US", {
minimumIntegerDigits: 2,
})
: ""
}
onBlur={(ev) => { onBlur={(ev) => {
setTime(time.set({ month: ev.currentTarget.value })); ev.currentTarget.value
? updateTime({
month: parseInt(ev.currentTarget.value),
})
: updateTime(null);
}} }}
></input>{" "} ></input>{" "}
</label> </label>
@ -97,9 +143,19 @@ export const TimeInput = ({ defaultValue, onInput }) => {
pattern="\d*" pattern="\d*"
maxlength={2} maxlength={2}
placeholder="DD" placeholder="DD"
value={time?.day.toLocaleString("en-US", { minimumIntegerDigits: 2 })} value={
localTime
? localTime.day.toLocaleString("en-US", {
minimumIntegerDigits: 2,
})
: ""
}
onBlur={(ev) => { onBlur={(ev) => {
setTime(time.set({ day: ev.currentTarget.value })); ev.currentTarget.value
? updateTime({
day: parseInt(ev.currentTarget.value),
})
: updateTime(null);
}} }}
></input>{" "} ></input>{" "}
</label> </label>
@ -116,9 +172,19 @@ export const TimeInput = ({ defaultValue, onInput }) => {
pattern="\d*" pattern="\d*"
maxlength={2} maxlength={2}
placeholder="hh" placeholder="hh"
value={time?.hour.toLocaleString("en-US", { minimumIntegerDigits: 2 })} value={
localTime
? localTime.hour.toLocaleString("en-US", {
minimumIntegerDigits: 2,
})
: ""
}
onBlur={(ev) => { onBlur={(ev) => {
setTime(time.set({ hour: ev.currentTarget.value })); ev.currentTarget.value
? updateTime({
hour: parseInt(ev.currentTarget.value),
})
: updateTime(null);
}} }}
></input>{" "} ></input>{" "}
</label> </label>
@ -136,11 +202,19 @@ export const TimeInput = ({ defaultValue, onInput }) => {
pattern="\d*" pattern="\d*"
maxlength={2} maxlength={2}
placeholder="mm" placeholder="mm"
value={time?.minute.toLocaleString("en-US", { value={
localTime
? localTime.minute.toLocaleString("en-US", {
minimumIntegerDigits: 2, minimumIntegerDigits: 2,
})} })
: ""
}
onBlur={(ev) => { onBlur={(ev) => {
setTime(time.set({ minute: ev.currentTarget.value })); ev.currentTarget.value
? updateTime({
minute: parseInt(ev.currentTarget.value),
})
: updateTime(null);
}} }}
></input>{" "} ></input>{" "}
</label> </label>
@ -158,11 +232,19 @@ export const TimeInput = ({ defaultValue, onInput }) => {
pattern="\d*" pattern="\d*"
maxlength={2} maxlength={2}
placeholder="ss" placeholder="ss"
value={time?.second.toLocaleString("en-US", { value={
localTime
? localTime.second.toLocaleString("en-US", {
minimumIntegerDigits: 2, minimumIntegerDigits: 2,
})} })
: ""
}
onBlur={(ev) => { onBlur={(ev) => {
setTime(time.set({ second: ev.currentTarget.value })); ev.currentTarget.value
? updateTime({
second: parseInt(ev.currentTarget.value),
})
: updateTime(null);
}} }}
></input>{" "} ></input>{" "}
</label> </label>
@ -194,13 +276,17 @@ export const TimeInput = ({ defaultValue, onInput }) => {
type="datetime-local" type="datetime-local"
step="1" step="1"
value={ value={
time localTime
? time.toFormat("yyyy-LL-dd'T'HH:mm:ss") ? localTime.toFormat("yyyy-LL-dd'T'HH:mm:ss")
: DateTime.now().toFormat("yyyy-LL-dd'T'HH:mm:ss") : DateTime.now().setZone(timezone).toFormat("yyyy-LL-dd'T'HH:mm:ss")
} }
ref={ref} ref={ref}
onInput={(ev) => { onInput={(ev) => {
setTime(DateTime.fromISO(ev.currentTarget.value)); ev.currentTarget.value === ""
? updateTime(null)
: setLocalTime(
DateTime.fromISO(ev.currentTarget.value, { zone: timezone }),
);
}} }}
></input> ></input>
</> </>

View File

@ -1,30 +0,0 @@
import { useReminder } from "./ReminderContext";
import { Name } from "./Name";
import { fetchGuildChannels, Reminder } from "../../api";
import { useQuery } from "react-query";
import { useParams } from "wouter";
export const TopBar = ({ toggleCollapsed }) => {
const { guild } = useParams();
const [reminder] = useReminder();
const { isSuccess, data: guildChannels } = useQuery(fetchGuildChannels(guild));
const channelName = (reminder: Reminder) => {
const channel = guildChannels.find((c) => c.id === reminder.channel);
return channel === undefined ? "" : channel.name;
};
return (
<div class="columns is-mobile column reminder-topbar">
{isSuccess && <div class="invert-collapses channel-bar">#{channelName(reminder)}</div>}
<Name />
<div class="hide-button-bar">
<button class="button hide-box" onClick={toggleCollapsed}>
<span class="is-sr-only">Hide reminder</span>
<i class="fas fa-chevron-down"></i>
</button>
</div>
</div>
);
};

View File

@ -0,0 +1,67 @@
import { useGuild } from "../../App/useGuild";
import { useReminder } from "../ReminderContext";
import { useQuery } from "react-query";
import { fetchGuildChannels, Reminder } from "../../../api";
import { useCallback } from "preact/hooks";
import { DateTime } from "luxon";
import { Name } from "../Name";
export const Guild = ({ toggleCollapsed, isCreating }) => {
const guild = useGuild();
const [reminder] = useReminder();
const { isSuccess, data: guildChannels } = useQuery(fetchGuildChannels(guild));
const channelName = useCallback(
(reminder: Reminder) => {
const channel = guildChannels.find((c) => c.id === reminder.channel);
return channel === undefined ? "" : channel.name;
},
[guildChannels],
);
let days, hours, minutes, seconds;
seconds = Math.floor(
DateTime.fromISO(reminder.utc_time, { zone: "UTC" }).diffNow("seconds").seconds,
);
[days, seconds] = [Math.floor(seconds / 86400), seconds % 86400];
[hours, seconds] = [Math.floor(seconds / 3600), seconds % 3600];
[minutes, seconds] = [Math.floor(seconds / 60), seconds % 60];
let string;
if (days !== 0) {
if (hours !== 0) {
string = `${days} days, ${hours} hours`;
} else {
string = `${days} days`;
}
} else if (hours !== 0) {
if (minutes !== 0) {
string = `${hours} hours, ${minutes} minutes`;
} else {
string = `${hours} hours`;
}
} else if (minutes !== 0) {
if (seconds !== 0) {
string = `${minutes} minutes, ${seconds} seconds`;
} else {
string = `${minutes} minutes`;
}
} else {
string = `${seconds} seconds`;
}
return (
<div class="columns is-mobile column reminder-topbar">
{isSuccess && <div class="invert-collapses channel-bar">#{channelName(reminder)}</div>}
<Name />
{!isCreating && <div class="time-bar">in {string}</div>}
<div class="hide-button-bar">
<button class="button hide-box" onClick={toggleCollapsed}>
<span class="is-sr-only">Hide reminder</span>
<i class="fas fa-chevron-down"></i>
</button>
</div>
</div>
);
};

View File

@ -0,0 +1,51 @@
import { Name } from "../Name";
import { DateTime } from "luxon";
import { useReminder } from "../ReminderContext";
export const User = ({ toggleCollapsed }) => {
const [reminder] = useReminder();
let days, hours, minutes, seconds;
seconds = Math.floor(
DateTime.fromISO(reminder.utc_time, { zone: "UTC" }).diffNow("seconds").seconds,
);
[days, seconds] = [Math.floor(seconds / 86400), seconds % 86400];
[hours, seconds] = [Math.floor(seconds / 3600), seconds % 3600];
[minutes, seconds] = [Math.floor(seconds / 60), seconds % 60];
let string;
if (days !== 0) {
if (hours !== 0) {
string = `${days} days, ${hours} hours`;
} else {
string = `${days} days`;
}
} else if (hours !== 0) {
if (minutes !== 0) {
string = `${hours} hours, ${minutes} minutes`;
} else {
string = `${hours} hours`;
}
} else if (minutes !== 0) {
if (seconds !== 0) {
string = `${minutes} minutes, ${seconds} seconds`;
} else {
string = `${minutes} minutes`;
}
} else {
string = `${seconds} seconds`;
}
return (
<div class="columns is-mobile column reminder-topbar">
<Name />
<div class="time-bar">in {string}</div>
<div class="hide-button-bar">
<button class="button hide-box" onClick={toggleCollapsed}>
<span class="is-sr-only">Hide reminder</span>
<i class="fas fa-chevron-down"></i>
</button>
</div>
</div>
);
};

View File

@ -0,0 +1,13 @@
import { useGuild } from "../../App/useGuild";
import { Guild } from "./Guild";
import { User } from "./User";
export const TopBar = ({ toggleCollapsed, isCreating }) => {
const guild = useGuild();
if (guild) {
return <Guild toggleCollapsed={toggleCollapsed} isCreating={isCreating} />;
} else {
return <User toggleCollapsed={toggleCollapsed} />;
}
};

View File

@ -1,9 +1,11 @@
import { useReminder } from "./ReminderContext"; import { useReminder } from "./ReminderContext";
import { useGuild } from "../App/useGuild";
export const Username = () => { export const Username = () => {
const guild = useGuild();
const [reminder, setReminder] = useReminder(); const [reminder, setReminder] = useReminder();
return ( return guild ? (
<div class="discord-message-header"> <div class="discord-message-header">
<label class="is-sr-only">Username Override</label> <label class="is-sr-only">Username Override</label>
<input <input
@ -20,5 +22,9 @@ export const Username = () => {
}} }}
></input> ></input>
</div> </div>
) : (
<div class="discord-message-header">
<span class="discord-username">Reminder Bot</span>
</div>
); );
}; };

View File

@ -0,0 +1,28 @@
.time-bar {
display: flex;
justify-content: center;
flex-direction: column;
font-style: italic;
}
.tribute-container {
background-color: #2b2d31;
color: #fff;
border-radius: 8px;
margin: 4px;
padding: 4px;
box-shadow: 0 0 5px 0 rgba(0,0,0,0.75);
.highlight {
background-color: #35373c;
}
li {
padding: 8px 12px;
border-radius: 8px;
}
}
textarea.autoresize {
resize: vertical !important;
}

View File

@ -17,9 +17,6 @@ export const GuildEntry = ({ guild }: Props) => {
? "is-active switch-pane" ? "is-active switch-pane"
: "switch-pane" : "switch-pane"
} }
data-pane="guild"
data-guild={guild.id}
data-name={guild.name}
href={`/${guild.id}/reminders`} href={`/${guild.id}/reminders`}
> >
<> <>

View File

@ -4,16 +4,19 @@ import { MobileSidebar } from "./MobileSidebar";
import { Brand } from "./Brand"; import { Brand } from "./Brand";
import { Wave } from "./Wave"; import { Wave } from "./Wave";
import { GuildEntry } from "./GuildEntry"; import { GuildEntry } from "./GuildEntry";
import { fetchUserGuilds, GuildInfo } from "../../api"; import { fetchUserGuilds, fetchUserInfo, GuildInfo } from "../../api";
import { TimezonePicker } from "../TimezonePicker"; import { TimezonePicker } from "../TimezonePicker";
import "./style.scss"; import "./styles.scss";
import { Link, useLocation } from "wouter";
type ContentProps = { type ContentProps = {
guilds: GuildInfo[]; guilds: GuildInfo[];
}; };
const SidebarContent = ({ guilds }: ContentProps) => { const SidebarContent = ({ guilds }: ContentProps) => {
const guildEntries = guilds.map((guild) => <GuildEntry guild={guild}></GuildEntry>); const guildEntries = guilds.map((guild) => <GuildEntry guild={guild} />);
const [loc] = useLocation();
const { data: userInfo } = useQuery({ ...fetchUserInfo() });
return ( return (
<> <>
@ -22,9 +25,28 @@ const SidebarContent = ({ guilds }: ContentProps) => {
</a> </a>
<Wave /> <Wave />
<aside class="menu"> <aside class="menu">
<ul class="menu-list">
<li>
<Link
class={loc.startsWith("/@me") ? "is-active switch-pane" : "switch-pane"}
href={"/@me/reminders"}
>
<>
<span class="guild-name">@{userInfo?.name || "unknown"}</span>
</>
</Link>
</li>
</ul>
<p class="menu-label">Servers</p> <p class="menu-label">Servers</p>
<ul class="menu-list guildList">{guildEntries}</ul> <ul class="menu-list guildList">{guildEntries}</ul>
<div class="aside-footer"> <div
class="aside-footer"
style={{
position: "sticky",
bottom: "0px",
backgroundColor: "rgb(54, 54, 54)",
}}
>
<p class="menu-label">Options</p> <p class="menu-label">Options</p>
<ul class="menu-list"> <ul class="menu-list">
<li> <li>

View File

@ -53,7 +53,7 @@ export const TimezonePicker = () => {
const TimezoneModal = ({ setModalOpen }) => { const TimezoneModal = ({ setModalOpen }) => {
const browserTimezone = DateTime.now().zoneName; const browserTimezone = DateTime.now().zoneName;
const [selectedZone, setSelectedZone] = useTimezone(); const [selectedZone] = useTimezone();
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const { isLoading, isError, data } = useQuery(fetchUserInfo()); const { isLoading, isError, data } = useQuery(fetchUserInfo());
@ -86,36 +86,6 @@ const TimezoneModal = ({ setModalOpen }) => {
</p> </p>
<br></br> <br></br>
<div class="has-text-centered"> <div class="has-text-centered">
<button
class="button is-success"
style={{
margin: "2px",
}}
id="set-browser-timezone"
onClick={() => {
setSelectedZone(browserTimezone);
}}
>
<span>Use Browser Timezone</span>{" "}
<span class="icon">
<i class="fab fa-firefox-browser"></i>
</span>
</button>
<button
class="button is-success"
id="set-bot-timezone"
style={{
margin: "2px",
}}
onClick={() => {
setSelectedZone(data.timezone);
}}
>
<span>Use Bot Timezone</span>{" "}
<span class="icon">
<i class="fab fa-discord"></i>
</span>
</button>
<button <button
class="button is-success is-outlined" class="button is-success is-outlined"
id="update-bot-timezone" id="update-bot-timezone"

View File

@ -0,0 +1,91 @@
import { useMutation, useQuery, useQueryClient } from "react-query";
import { fetchGuildChannels, postGuildTodo } from "../../api";
import { useGuild } from "../App/useGuild";
import { useState } from "preact/hooks";
import { useFlash } from "../App/FlashContext";
import { ICON_FLASH_TIME } from "../../consts";
export const CreateTodo = ({ showSelector = false, channel }) => {
const guild = useGuild();
const [recentlyCreated, setRecentlyCreated] = useState(false);
const [newTodo, setNewTodo] = useState({ value: "", channel_id: channel });
const flash = useFlash();
const { isSuccess, data: channels } = useQuery(fetchGuildChannels(guild));
const queryClient = useQueryClient();
const mutation = useMutation({
...postGuildTodo(guild),
onSuccess: (data) => {
if (data.error) {
flash({
message: data.error,
type: "error",
});
} else {
flash({
message: "Todo created",
type: "success",
});
queryClient.invalidateQueries({
queryKey: ["GUILD_TODOS", guild],
});
setRecentlyCreated(true);
setTimeout(() => {
setRecentlyCreated(false);
}, ICON_FLASH_TIME);
}
},
});
return (
<div class="todo">
<textarea
class="input todo-input"
onInput={(ev) => setNewTodo((todo) => ({ ...todo, value: ev.currentTarget.value }))}
/>
{showSelector && (
<div class="control has-icons-left">
<div class="select">
<select
name="channel"
class="channel-selector"
onInput={(ev) =>
setNewTodo((todo) => ({
...todo,
channel_id: ev.currentTarget.value || null,
}))
}
>
<option value="">(None)</option>
{isSuccess &&
channels.map((c) => <option value={c.id}>{c.name}</option>)}
</select>
</div>
<div class="icon is-small is-left">
<i class="fas fa-hashtag"></i>
</div>
</div>
)}
<button onClick={() => mutation.mutate(newTodo)} class="button is-success save-btn">
<span class="icon">
{mutation.isLoading ? (
<span class="icon">
<i class="fas fa-spin fa-cog"></i>
</span>
) : recentlyCreated ? (
<span class="icon">
<i class="fas fa-check"></i>
</span>
) : (
<span class="icon">
<i class="fas fa-sparkles"></i>
</span>
)}
</span>
</button>
</div>
);
};

View File

@ -0,0 +1,10 @@
.todo {
display: flex;
flex-direction: row;
align-items: center;
margin: 6px 0;
> * {
margin: 0 3px;
}
}

View File

@ -0,0 +1,83 @@
import { deleteGuildTodo, patchGuildTodo, Todo as TodoT, UpdateTodo } from "../../api";
import "./index.scss";
import { useMutation, useQueryClient } from "react-query";
import { useFlash } from "../App/FlashContext";
import { useGuild } from "../App/useGuild";
import { useState } from "preact/hooks";
import { ICON_FLASH_TIME } from "../../consts";
type Props = {
todo: TodoT;
};
export const Todo = ({ todo }: Props) => {
const guild = useGuild();
const [updatedTodo, setUpdatedTodo] = useState<UpdateTodo>({ value: todo.value });
const [recentlySaved, setRecentlySaved] = useState(false);
const flash = useFlash();
const queryClient = useQueryClient();
const deleteMutation = useMutation({
...deleteGuildTodo(guild),
onSuccess: () => {
flash({
message: "Todo deleted",
type: "success",
});
queryClient.invalidateQueries({
queryKey: ["GUILD_TODOS", guild],
});
},
});
const patchMutation = useMutation({
...patchGuildTodo(guild),
onError: (error) => {
flash({
message: `An error occurred: ${error}`,
type: "error",
});
},
onSuccess: (response) => {
if (response.data.error) {
setRecentlySaved(false);
flash({ message: response.data.error, type: "error" });
} else {
setRecentlySaved(true);
setTimeout(() => {
setRecentlySaved(false);
}, ICON_FLASH_TIME);
}
},
});
return (
<div class="todo">
<textarea
class="input todo-input"
value={updatedTodo.value}
onInput={(ev) =>
setUpdatedTodo({
value: ev.currentTarget.value,
})
}
/>
<button
onClick={() => patchMutation.mutate({ id: todo.id, todo: updatedTodo })}
class="button is-success save-btn"
>
<span class="icon">
{recentlySaved ? <i class="fa fa-check"></i> : <i class="fa fa-save"></i>}
</span>
</button>
<button onClick={() => deleteMutation.mutate(todo.id)} class="button is-danger">
<span class="icon">
<i class="fa fa-trash"></i>
</span>
</button>
</div>
);
};

View File

@ -0,0 +1,102 @@
import { useQuery } from "react-query";
import { fetchUserReminders } from "../../api";
import { EditReminder } from "../Reminder/EditReminder";
import { CreateReminder } from "../Reminder/CreateReminder";
import { useState } from "preact/hooks";
import { Loader } from "../Loader";
enum Sort {
Time = "time",
Name = "name",
}
export const UserReminders = () => {
const { isSuccess, isFetching, isFetched, data: reminders } = useQuery(fetchUserReminders());
const [collapsed, setCollapsed] = useState(false);
const [sort, setSort] = useState(Sort.Time);
return (
<>
{!isFetched && <Loader />}
<div style={{ margin: "0 12px 12px 12px" }}>
<strong>Create Reminder</strong>
<div id={"reminderCreator"}>
<CreateReminder />
</div>
<br></br>
<div class={"field"}>
<div class={"columns is-mobile"}>
<div class={"column"}>
<strong>Reminders</strong>
</div>
<div class={"column is-narrow"}>
<div class="control has-icons-left">
<div class="select is-small">
<select
id="orderBy"
onInput={(ev) => {
setSort(ev.currentTarget.value as Sort);
}}
>
<option value={Sort.Time} selected={sort == Sort.Time}>
Time
</option>
<option value={Sort.Name} selected={sort == Sort.Name}>
Name
</option>
</select>
</div>
<div class="icon is-small is-left">
<i class="fas fa-sort-amount-down"></i>
</div>
</div>
</div>
<div class={"column is-narrow"}>
<div class="control has-icons-left">
<div class="select is-small">
<select
id="expandAll"
onInput={(ev) => {
if (ev.currentTarget.value === "expand") {
setCollapsed(false);
} else if (ev.currentTarget.value === "collapse") {
setCollapsed(true);
}
}}
>
<option value="" selected></option>
<option value="expand">Expand All</option>
<option value="collapse">Collapse All</option>
</select>
</div>
<div class="icon is-small is-left">
<i class="fas fa-expand-arrows"></i>
</div>
</div>
</div>
</div>
</div>
<div id={"guildReminders"} className={isFetching ? "loading" : ""}>
{isSuccess &&
reminders
.sort((r1, r2) => {
if (sort === Sort.Time) {
return r1.utc_time > r2.utc_time ? 1 : -1;
} else {
return r1.name > r2.name ? 1 : -1;
}
})
.map((reminder) => (
<EditReminder
key={reminder.uid}
reminder={reminder}
globalCollapse={collapsed}
/>
))}
</div>
</div>
</>
);
};

View File

@ -0,0 +1,9 @@
import { UserReminders } from "./UserReminders";
export const User = () => {
return (
<>
<UserReminders />
</>
);
};

View File

@ -1,4 +1,4 @@
use chrono::NaiveDateTime; use chrono::DateTime;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use crate::{ use crate::{
@ -24,10 +24,10 @@ impl Recordable for Options {
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 {
match NaiveDateTime::from_timestamp_opt(timestamp, 0) { match DateTime::from_timestamp(timestamp, 0) {
Some(dt) => { Some(dt) => {
channel.paused = true; channel.paused = true;
channel.paused_until = Some(dt); channel.paused_until = Some(dt.naive_utc());
channel.commit_changes(&ctx.data().database).await; channel.commit_changes(&ctx.data().database).await;

View File

@ -22,8 +22,8 @@ impl Recordable for Options {
CreateEmbed::new() CreateEmbed::new()
.title("Confirmations ephemeral") .title("Confirmations ephemeral")
.description(concat!( .description(concat!(
"Reminder confirmations will be sent privately, and removed when your client", "Reminder and todo confirmations will be sent privately, and removed when ",
" restarts." "your client restarts."
)) ))
.color(*THEME_COLOR), .color(*THEME_COLOR),
), ),

View File

@ -22,8 +22,8 @@ impl Recordable for Options {
CreateEmbed::new() CreateEmbed::new()
.title("Confirmations public") .title("Confirmations public")
.description(concat!( .description(concat!(
"Reminder confirmations will be sent as regular messages, and won't be ", "Reminder and todo confirmations will be sent as regular messages, and",
"removed automatically." " won't be removed automatically."
)) ))
.color(*THEME_COLOR), .color(*THEME_COLOR),
), ),

View File

@ -1,3 +1,4 @@
use poise::CreateReply;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use crate::{ use crate::{
@ -33,7 +34,13 @@ impl Recordable for Options {
.await .await
.unwrap(); .unwrap();
ctx.say("Item added to todo list").await?; let ephemeral = ctx
.guild_data()
.await
.map_or(false, |gr| gr.map_or(false, |g| g.ephemeral_confirmations));
ctx.send(CreateReply::default().content("Item added to todo list").ephemeral(ephemeral))
.await?;
Ok(()) Ok(())
} }

View File

@ -1,6 +1,8 @@
use poise::CreateReply;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use crate::{ use crate::{
models::CtxData,
utils::{Extract, Recordable}, utils::{Extract, Recordable},
Context, Error, Context, Error,
}; };
@ -26,7 +28,13 @@ impl Recordable for Options {
.await .await
.unwrap(); .unwrap();
ctx.say("Item added to todo list").await?; let ephemeral = ctx
.guild_data()
.await
.map_or(false, |gr| gr.map_or(false, |g| g.ephemeral_confirmations));
ctx.send(CreateReply::default().content("Item added to todo list").ephemeral(ephemeral))
.await?;
Ok(()) Ok(())
} }

View File

@ -1,6 +1,8 @@
use poise::CreateReply;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use crate::{ use crate::{
models::CtxData,
utils::{Extract, Recordable}, utils::{Extract, Recordable},
Context, Error, Context, Error,
}; };
@ -27,7 +29,13 @@ impl Recordable for Options {
.await .await
.unwrap(); .unwrap();
ctx.say("Item added to todo list").await?; let ephemeral = ctx
.guild_data()
.await
.map_or(false, |gr| gr.map_or(false, |g| g.ephemeral_confirmations));
ctx.send(CreateReply::default().content("Item added to todo list").ephemeral(ephemeral))
.await?;
Ok(()) Ok(())
} }

View File

@ -282,13 +282,43 @@ impl ComponentDataModel {
.await .await
.unwrap(); .unwrap();
let values = sqlx::query!( let values = if let Some(uid) = selector.user_id {
// fucking braindead mysql use <=> instead of = for null comparison sqlx::query!(
" "
SELECT id, value FROM todos WHERE user_id <=> ? AND channel_id <=> ? AND guild_id <=> ? SELECT todos.id, value FROM todos
INNER JOIN users ON todos.user_id = users.id
WHERE users.user = ?
",
uid,
)
.fetch_all(&data.database)
.await
.unwrap()
.iter()
.map(|row| (row.id as usize, row.value.clone()))
.collect::<Vec<(usize, String)>>()
} else if let Some(cid) = selector.channel_id {
sqlx::query!(
"
SELECT todos.id, value FROM todos
INNER JOIN channels ON todos.channel_id = channels.id
WHERE channels.channel = ?
",
cid,
)
.fetch_all(&data.database)
.await
.unwrap()
.iter()
.map(|row| (row.id as usize, row.value.clone()))
.collect::<Vec<(usize, String)>>()
} else {
sqlx::query!(
"
SELECT todos.id, value FROM todos
INNER JOIN guilds ON todos.guild_id = guilds.id
WHERE guilds.guild = ?
", ",
selector.user_id,
selector.channel_id,
selector.guild_id, selector.guild_id,
) )
.fetch_all(&data.database) .fetch_all(&data.database)
@ -296,7 +326,8 @@ impl ComponentDataModel {
.unwrap() .unwrap()
.iter() .iter()
.map(|row| (row.id as usize, row.value.clone())) .map(|row| (row.id as usize, row.value.clone()))
.collect::<Vec<(usize, String)>>(); .collect::<Vec<(usize, String)>>()
};
let resp = show_todo_page( let resp = show_todo_page(
&values, &values,

View File

@ -1,10 +1,11 @@
use poise::serenity_prelude::ActivityData;
use poise::{ use poise::{
serenity_prelude as serenity, serenity_prelude as serenity,
serenity_prelude::{CreateEmbed, CreateMessage, FullEvent}, serenity_prelude::{ActivityData, CreateEmbed, CreateMessage, FullEvent},
}; };
use crate::{component_models::ComponentDataModel, Data, Error, THEME_COLOR}; use crate::{
component_models::ComponentDataModel, metrics::COMMAND_COUNTER, Data, Error, THEME_COLOR,
};
pub async fn listener( pub async fn listener(
ctx: &serenity::Context, ctx: &serenity::Context,
@ -12,9 +13,6 @@ pub async fn listener(
data: &Data, data: &Data,
) -> Result<(), Error> { ) -> Result<(), Error> {
match event { match event {
FullEvent::Ready { .. } => {
ctx.set_activity(Some(ActivityData::watching("for /remind")));
}
FullEvent::ChannelDelete { channel, .. } => { FullEvent::ChannelDelete { channel, .. } => {
sqlx::query!("DELETE FROM channels WHERE channel = ?", channel.id.get()) sqlx::query!("DELETE FROM channels WHERE channel = ?", channel.id.get())
.execute(&data.database) .execute(&data.database)
@ -67,6 +65,10 @@ To stay up to date on the latest features and fixes, join our [Discord](https://
component_model.act(ctx, data, &component).await; component_model.act(ctx, data, &component).await;
} }
if let Some(command) = interaction.clone().command() {
COMMAND_COUNTER.with_label_values(&[command.data.name.as_str()]).inc();
}
} }
_ => {} _ => {}
} }

View File

@ -58,6 +58,10 @@ async fn macro_check(ctx: Context<'_>) -> bool {
async fn check_self_permissions(ctx: Context<'_>) -> bool { async fn check_self_permissions(ctx: Context<'_>) -> bool {
let user_id = ctx.serenity_context().cache.current_user().id; let user_id = ctx.serenity_context().cache.current_user().id;
let app_permissions = match ctx {
Context::Application(app_ctx) => app_ctx.interaction.app_permissions,
_ => None,
};
match ctx.guild().map(|g| g.to_owned()) { match ctx.guild().map(|g| g.to_owned()) {
Some(guild) => { Some(guild) => {
@ -66,42 +70,34 @@ async fn check_self_permissions(ctx: Context<'_>) -> bool {
.await .await
.map_or(false, |m| m.permissions(&ctx).map_or(false, |p| p.manage_webhooks())); .map_or(false, |m| m.permissions(&ctx).map_or(false, |p| p.manage_webhooks()));
let (view_channel, send_messages, embed_links) = ctx if let Some(permissions) = app_permissions {
.channel_id() return if permissions.send_messages()
.to_channel(&ctx) && permissions.embed_links()
.await && manage_webhooks
.ok() {
.and_then(|c| {
if let Channel::Guild(channel) = c {
let perms = channel.permissions_for_user(&ctx, user_id).ok()?;
Some((perms.view_channel(), perms.send_messages(), perms.embed_links()))
} else {
None
}
})
.unwrap_or((false, false, false));
if manage_webhooks && send_messages && embed_links {
true true
} else { } else {
let _ = ctx let _ = ctx
.send(CreateReply::default().content(format!( .send(CreateReply::default().content(format!(
"Please ensure the bot has the correct permissions: "The bot appears to be missing some permissions:
{} **View Channel**
{} **Send Message** {} **Send Message**
{} **Embed Links** {} **Embed Links**
{} **Manage Webhooks**", {} **Manage Webhooks**
if view_channel { "" } else { "" },
if send_messages { "" } else { "" }, Please check the bot's roles, and any channel overrides. Alternatively, giving the bot
if embed_links { "" } else { "" }, \"Administrator\" will bypass permission checks",
if permissions.send_messages() { "" } else { "" },
if permissions.embed_links() { "" } else { "" },
if manage_webhooks { "" } else { "" }, if manage_webhooks { "" } else { "" },
))) )))
.await; .await;
false false
};
} }
manage_webhooks
} }
None => { None => {

View File

@ -12,12 +12,15 @@ mod event_handlers;
#[cfg(not(test))] #[cfg(not(test))]
mod hooks; mod hooks;
mod interval_parser; mod interval_parser;
mod metrics;
#[cfg(not(test))] #[cfg(not(test))]
mod models; mod models;
mod postman;
#[cfg(test)] #[cfg(test)]
mod test; mod test;
mod time_parser; mod time_parser;
mod utils; mod utils;
mod web;
use std::{ use std::{
collections::HashMap, collections::HashMap,
@ -28,7 +31,7 @@ use std::{
}; };
use chrono_tz::Tz; use chrono_tz::Tz;
use log::{error, warn}; use log::warn;
use poise::serenity_prelude::{ use poise::serenity_prelude::{
model::{ model::{
gateway::GatewayIntents, gateway::GatewayIntents,
@ -36,9 +39,11 @@ use poise::serenity_prelude::{
}, },
ClientBuilder, ClientBuilder,
}; };
use serenity::all::ActivityData;
use sqlx::{MySql, Pool}; use sqlx::{MySql, Pool};
use tokio::sync::{broadcast, broadcast::Sender, RwLock}; use tokio::sync::{broadcast, broadcast::Sender, RwLock};
use crate::metrics::init_metrics;
#[cfg(test)] #[cfg(test)]
use crate::test::TestContext; use crate::test::TestContext;
#[cfg(not(test))] #[cfg(not(test))]
@ -206,6 +211,9 @@ async fn _main(tx: Sender<()>) -> Result<(), Box<dyn StdError + Send + Sync>> {
..Default::default() ..Default::default()
}; };
// Start metrics
init_metrics();
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();
@ -249,7 +257,7 @@ async fn _main(tx: Sender<()>) -> Result<(), Box<dyn StdError + Send + Sync>> {
match postman::initialize(kill_recv, ctx1, &pool1).await { match postman::initialize(kill_recv, ctx1, &pool1).await {
Ok(_) => {} Ok(_) => {}
Err(e) => { Err(e) => {
error!("postman exiting: {}", e); panic!("postman exiting: {}", e);
} }
}; };
}); });
@ -259,7 +267,7 @@ async fn _main(tx: Sender<()>) -> Result<(), Box<dyn StdError + Send + Sync>> {
if !run_settings.contains("web") { if !run_settings.contains("web") {
tokio::spawn(async move { tokio::spawn(async move {
reminder_web::initialize(kill_tx, ctx2, pool2).await.unwrap(); web::initialize(kill_tx, ctx2, pool2).await.unwrap();
}); });
} else { } else {
warn!("Not running web"); warn!("Not running web");
@ -276,8 +284,10 @@ async fn _main(tx: Sender<()>) -> Result<(), Box<dyn StdError + Send + Sync>> {
.options(options) .options(options)
.build(); .build();
let mut client = let mut client = ClientBuilder::new(&discord_token, GatewayIntents::GUILDS)
ClientBuilder::new(&discord_token, GatewayIntents::GUILDS).framework(framework).await?; .framework(framework)
.activity(ActivityData::watching("for /remind"))
.await?;
client.start_autosharded().await?; client.start_autosharded().await?;

26
src/metrics.rs Normal file
View File

@ -0,0 +1,26 @@
use lazy_static::lazy_static;
use prometheus::{IntCounterVec, Opts, Registry};
lazy_static! {
pub static ref REGISTRY: Registry = Registry::new();
pub static ref REQUEST_COUNTER: IntCounterVec =
IntCounterVec::new(Opts::new("requests", "Web requests"), &["method", "status", "route"])
.unwrap();
pub static ref REMINDER_COUNTER: IntCounterVec =
IntCounterVec::new(Opts::new("reminders_sent", "Reminders sent"), &["id", "channel"])
.unwrap();
pub static ref REMINDER_FAIL_COUNTER: IntCounterVec = IntCounterVec::new(
Opts::new("reminders_failed", "Reminders failed"),
&["id", "channel", "error"]
)
.unwrap();
pub static ref COMMAND_COUNTER: IntCounterVec =
IntCounterVec::new(Opts::new("commands", "Commands used"), &["command"]).unwrap();
}
pub fn init_metrics() {
REGISTRY.register(Box::new(REQUEST_COUNTER.clone())).unwrap();
REGISTRY.register(Box::new(REMINDER_COUNTER.clone())).unwrap();
REGISTRY.register(Box::new(REMINDER_FAIL_COUNTER.clone())).unwrap();
REGISTRY.register(Box::new(COMMAND_COUNTER.clone())).unwrap();
}

View File

@ -1,9 +1,13 @@
use chrono::NaiveDateTime; use chrono::NaiveDateTime;
use poise::serenity_prelude::model::channel::Channel; use poise::serenity_prelude::{model::channel::Channel, CacheHttp, ChannelId, CreateWebhook};
use secrecy::ExposeSecret;
use sqlx::MySqlPool; use sqlx::MySqlPool;
use crate::{consts::DEFAULT_AVATAR, Error};
pub struct ChannelData { pub struct ChannelData {
pub id: u32, pub id: u32,
pub channel: u64,
pub name: Option<String>, pub name: Option<String>,
pub nudge: i16, pub nudge: i16,
pub blacklisted: bool, pub blacklisted: bool,
@ -22,7 +26,12 @@ 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, channel, name, nudge, blacklisted, webhook_id, webhook_token, paused,
paused_until
FROM channels
WHERE channel = ?
",
channel_id channel_id
) )
.fetch_one(pool) .fetch_one(pool)
@ -32,7 +41,8 @@ impl ChannelData {
} else { } else {
let props = channel.to_owned().guild().map(|g| (g.guild_id.get().to_owned(), g.name)); let props = channel.to_owned().guild().map(|g| (g.guild_id.get().to_owned(), g.name));
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 = ?))",
@ -46,7 +56,9 @@ impl ChannelData {
Ok(sqlx::query_as_unchecked!( Ok(sqlx::query_as_unchecked!(
Self, Self,
" "
SELECT id, name, nudge, blacklisted, webhook_id, webhook_token, paused, paused_until FROM channels WHERE channel = ? SELECT id, channel, name, nudge, blacklisted, webhook_id, webhook_token, paused, paused_until
FROM channels
WHERE channel = ?
", ",
channel_id channel_id
) )
@ -58,8 +70,16 @@ SELECT id, name, nudge, blacklisted, webhook_id, webhook_token, paused, paused_u
pub async fn commit_changes(&self, pool: &MySqlPool) { pub async fn commit_changes(&self, pool: &MySqlPool) {
sqlx::query!( sqlx::query!(
" "
UPDATE channels SET name = ?, nudge = ?, blacklisted = ?, webhook_id = ?, webhook_token = ?, paused = ?, paused_until \ UPDATE channels
= ? WHERE id = ? SET
name = ?,
nudge = ?,
blacklisted = ?,
webhook_id = ?,
webhook_token = ?,
paused = ?,
paused_until = ?
WHERE id = ?
", ",
self.name, self.name,
self.nudge, self.nudge,
@ -74,4 +94,24 @@ UPDATE channels SET name = ?, nudge = ?, blacklisted = ?, webhook_id = ?, webhoo
.await .await
.unwrap(); .unwrap();
} }
pub async fn ensure_webhook(
&mut self,
ctx: impl CacheHttp,
pool: &MySqlPool,
) -> Result<(), Error> {
if self.webhook_id.is_none() || self.webhook_token.is_none() {
let guild_channel = ChannelId::new(self.channel);
let webhook = guild_channel
.create_webhook(ctx.http(), CreateWebhook::new("Reminder").avatar(&*DEFAULT_AVATAR))
.await?;
self.webhook_id = Some(webhook.id.get().to_owned());
self.webhook_token = webhook.token.map(|s| s.expose_secret().clone());
self.commit_changes(pool).await;
}
Ok(())
}
} }

View File

@ -1,21 +1,15 @@
use std::collections::HashSet; use std::collections::HashSet;
use chrono::{Duration, NaiveDateTime, Utc}; use chrono::{DateTime, NaiveDateTime, TimeDelta, Utc};
use chrono_tz::Tz; use chrono_tz::Tz;
use poise::serenity_prelude::{ use poise::serenity_prelude::{
http::CacheHttp, model::id::{ChannelId, GuildId, UserId},
model::{ ChannelType,
channel::GuildChannel,
id::{ChannelId, GuildId, UserId},
webhook::Webhook,
},
ChannelType, CreateWebhook, Result as SerenityResult,
}; };
use secrecy::ExposeSecret;
use sqlx::MySqlPool; use sqlx::MySqlPool;
use crate::{ use crate::{
consts::{DAY, DEFAULT_AVATAR, MAX_TIME, MIN_INTERVAL}, consts::{DAY, MAX_TIME, MIN_INTERVAL},
interval_parser::Interval, interval_parser::Interval,
models::{ models::{
channel_data::ChannelData, channel_data::ChannelData,
@ -25,25 +19,23 @@ use crate::{
Context, Context,
}; };
async fn create_webhook( #[derive(Hash, PartialEq, Eq, Copy, Clone)]
ctx: impl CacheHttp, pub struct ChannelWithThread {
channel: GuildChannel, pub channel_id: u64,
name: impl Into<String>, pub thread_id: Option<u64>,
) -> SerenityResult<Webhook> {
channel.create_webhook(ctx.http(), CreateWebhook::new(name).avatar(&*DEFAULT_AVATAR)).await
} }
#[derive(Hash, PartialEq, Eq)] #[derive(Hash, PartialEq, Eq)]
pub enum ReminderScope { pub enum ReminderScope {
User(u64), User(u64),
Channel(u64), Channel(ChannelWithThread),
} }
impl ReminderScope { impl ReminderScope {
pub fn mention(&self) -> String { pub fn mention(&self) -> String {
match self { match self {
Self::User(id) => format!("<@{}>", id), Self::User(id) => format!("<@{}>", id),
Self::Channel(id) => format!("<#{}>", id), Self::Channel(c) => format!("<#{}>", c.channel_id),
} }
} }
} }
@ -81,7 +73,7 @@ impl ReminderBuilder {
match queried_time.utc_time { match queried_time.utc_time {
Some(utc_time) => { Some(utc_time) => {
if utc_time < (Utc::now() - Duration::seconds(60)).naive_local() { if utc_time < (Utc::now() - TimeDelta::try_minutes(1).unwrap()).naive_local() {
Err(ReminderError::PastTime) Err(ReminderError::PastTime)
} else { } else {
sqlx::query!( sqlx::query!(
@ -89,6 +81,7 @@ impl ReminderBuilder {
INSERT INTO reminders ( INSERT INTO reminders (
`uid`, `uid`,
`channel_id`, `channel_id`,
`thread_id`,
`utc_time`, `utc_time`,
`timezone`, `timezone`,
`interval_seconds`, `interval_seconds`,
@ -101,11 +94,12 @@ impl ReminderBuilder {
`attachment`, `attachment`,
`set_by` `set_by`
) VALUES ( ) VALUES (
?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ? ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?
) )
", ",
self.uid, self.uid,
self.channel, self.channel,
self.thread_id,
utc_time, utc_time,
self.timezone, self.timezone,
self.interval_seconds, self.interval_seconds,
@ -171,7 +165,7 @@ 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 {
if let Some(utc_time) = NaiveDateTime::from_timestamp_opt(time.into(), 0) { if let Some(utc_time) = DateTime::from_timestamp(time.into(), 0).map(|d| d.naive_utc()) {
self.utc_time = utc_time; self.utc_time = utc_time;
} }
@ -179,7 +173,8 @@ impl<'a> MultiReminderBuilder<'a> {
} }
pub fn expires<T: Into<i64>>(mut self, time: Option<T>) -> Self { pub fn expires<T: Into<i64>>(mut self, time: Option<T>) -> Self {
self.expires = time.map(|t| NaiveDateTime::from_timestamp_opt(t.into(), 0)).flatten(); self.expires =
time.map(|t| DateTime::from_timestamp(t.into(), 0)).flatten().map(|d| d.naive_utc());
self self
} }
@ -218,7 +213,6 @@ impl<'a> MultiReminderBuilder<'a> {
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::new(user_id).to_user(&self.ctx).await { if let Ok(user) = UserId::new(user_id).to_user(&self.ctx).await {
@ -238,34 +232,34 @@ impl<'a> MultiReminderBuilder<'a> {
{ {
Err(ReminderError::UserBlockedDm) Err(ReminderError::UserBlockedDm)
} else { } else {
Ok(user_data.dm_channel) Ok((user_data.dm_channel, None))
} }
} else { } else {
Ok(user_data.dm_channel) Ok((user_data.dm_channel, None))
} }
} else { } else {
Err(ReminderError::InvalidTag) Err(ReminderError::InvalidTag)
} }
} }
ReminderScope::Channel(channel_id) => { ReminderScope::Channel(channel_with_thread) => {
let channel = let channel = ChannelId::new(channel_with_thread.channel_id)
ChannelId::new(channel_id).to_channel(&self.ctx).await.unwrap(); .to_channel(&self.ctx)
.await
.unwrap();
if let Some(mut guild_channel) = channel.clone().guild() { if let Some(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 = if guild_channel.kind let mut channel_data = if guild_channel.kind
== ChannelType::PublicThread == ChannelType::PublicThread
{ {
// fixme jesus christ
let parent = guild_channel let parent = guild_channel
.parent_id .parent_id
.unwrap() .unwrap()
.to_channel(&self.ctx) .to_channel(&self.ctx)
.await .await
.unwrap(); .unwrap();
guild_channel = parent.clone().guild().unwrap();
ChannelData::from_channel(&parent, &self.ctx.data().database) ChannelData::from_channel(&parent, &self.ctx.data().database)
.await .await
.unwrap() .unwrap()
@ -275,28 +269,13 @@ impl<'a> MultiReminderBuilder<'a> {
.unwrap() .unwrap()
}; };
if channel_data.webhook_id.is_none() match channel_data
|| channel_data.webhook_token.is_none() .ensure_webhook(&self.ctx, &self.ctx.data().database)
.await
.map_err(|e| ReminderError::DiscordError(e.to_string()))
{ {
match create_webhook(&self.ctx, guild_channel, "Reminder").await Ok(()) => Ok((channel_data.id, channel_with_thread.thread_id)),
{ Err(e) => Err(e),
Ok(webhook) => {
channel_data.webhook_id =
Some(webhook.id.get().to_owned());
channel_data.webhook_token =
webhook.token.map(|s| s.expose_secret().clone());
channel_data
.commit_changes(&self.ctx.data().database)
.await;
Ok(channel_data.id)
}
Err(e) => Err(ReminderError::DiscordError(e.to_string())),
}
} else {
Ok(channel_data.id)
} }
} }
} else { } else {
@ -306,11 +285,11 @@ impl<'a> MultiReminderBuilder<'a> {
}; };
match db_channel_id { match db_channel_id {
Ok(c) => { Ok((channel, thread_id)) => {
let builder = ReminderBuilder { let builder = ReminderBuilder {
pool: self.ctx.data().database.clone(), pool: self.ctx.data().database.clone(),
uid: generate_uid(), uid: generate_uid(),
channel: c, channel,
thread_id, thread_id,
utc_time: self.utc_time, utc_time: self.utc_time,
timezone: self.timezone.to_string(), timezone: self.timezone.to_string(),

View File

@ -13,7 +13,7 @@ use chrono_tz::Tz;
use poise::{ use poise::{
serenity_prelude::{ serenity_prelude::{
model::id::{ChannelId, GuildId, UserId}, model::id::{ChannelId, GuildId, UserId},
ButtonStyle, Cache, CreateActionRow, CreateButton, CreateEmbed, ReactionType, ButtonStyle, Cache, ChannelType, CreateActionRow, CreateButton, CreateEmbed, ReactionType,
}, },
CreateReply, CreateReply,
}; };
@ -26,7 +26,7 @@ use crate::{
interval_parser::parse_duration, interval_parser::parse_duration,
models::{ models::{
reminder::{ reminder::{
builder::{MultiReminderBuilder, ReminderScope}, builder::{ChannelWithThread, MultiReminderBuilder, ReminderScope},
content::Content, content::Content,
errors::ReminderError, errors::ReminderError,
}, },
@ -38,6 +38,7 @@ use crate::{
}; };
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
#[allow(dead_code)]
pub struct Reminder { pub struct Reminder {
pub id: u32, pub id: u32,
pub uid: String, pub uid: String,
@ -406,7 +407,8 @@ pub async fn create_reminder(
let id = i.get(2).unwrap().as_str().parse::<u64>().unwrap(); let id = i.get(2).unwrap().as_str().parse::<u64>().unwrap();
if pref == "#" { if pref == "#" {
ReminderScope::Channel(id) let channel_with_thread = ChannelWithThread { channel_id: id, thread_id: None };
ReminderScope::Channel(channel_with_thread)
} else { } else {
ReminderScope::User(id) ReminderScope::User(id)
} }
@ -481,8 +483,23 @@ pub async fn create_reminder(
let list = channels.map(|arg| parse_mention_list(&arg)).unwrap_or_default(); let list = channels.map(|arg| parse_mention_list(&arg)).unwrap_or_default();
if list.is_empty() { if list.is_empty() {
if ctx.guild_id().is_some() { if let Some(channel) = ctx.guild_channel().await {
vec![ReminderScope::Channel(ctx.channel_id().get())] if channel.kind == ChannelType::PublicThread
|| channel.kind == ChannelType::PrivateThread
{
let parent = channel.parent_id.unwrap();
let channel_with_threads = ChannelWithThread {
channel_id: parent.get(),
thread_id: Some(ctx.channel_id().get()),
};
vec![ReminderScope::Channel(channel_with_threads)]
} else {
let channel_with_threads = ChannelWithThread {
channel_id: ctx.channel_id().get(),
thread_id: None,
};
vec![ReminderScope::Channel(channel_with_threads)]
}
} else { } else {
vec![ReminderScope::User(ctx.author().id.get())] vec![ReminderScope::User(ctx.author().id.get())]
} }

View File

@ -4,6 +4,7 @@ use sqlx::MySqlPool;
pub struct Timer { pub struct Timer {
pub name: String, pub name: String,
pub start_time: DateTime<Utc>, pub start_time: DateTime<Utc>,
#[allow(dead_code)]
pub owner: u64, pub owner: u64,
} }

View File

@ -7,6 +7,7 @@ use crate::consts::LOCAL_TIMEZONE;
pub struct UserData { pub struct UserData {
pub id: u32, pub id: u32,
#[allow(dead_code)]
pub user: u64, pub user: u64,
pub dm_channel: u32, pub dm_channel: u32,
pub timezone: String, pub timezone: String,

View File

@ -3,7 +3,8 @@ mod sender;
use std::env; use std::env;
use log::{info, warn}; use log::{info, warn};
use serenity::client::Context; use poise::serenity_prelude::client::Context;
use sd_notify::{self, NotifyState};
use sqlx::{Executor, MySql}; use sqlx::{Executor, MySql};
use tokio::{ use tokio::{
sync::broadcast::Receiver, sync::broadcast::Receiver,
@ -33,6 +34,15 @@ async fn _initialize(ctx: Context, pool: impl Executor<'_, Database = Database>
.flatten() .flatten()
.unwrap_or(10); .unwrap_or(10);
let mut watchdog_interval = 0;
let watchdog = sd_notify::watchdog_enabled(false, &mut watchdog_interval);
if watchdog {
warn!("Watchdog enabled. Don't die!");
} else {
warn!("No watchdog running")
}
loop { loop {
let sleep_to = Instant::now() + Duration::from_secs(remind_interval); let sleep_to = Instant::now() + Duration::from_secs(remind_interval);
let reminders = sender::Reminder::fetch_reminders(pool).await; let reminders = sender::Reminder::fetch_reminders(pool).await;
@ -42,9 +52,11 @@ async fn _initialize(ctx: Context, pool: impl Executor<'_, Database = Database>
for reminder in reminders { for reminder in reminders {
reminder.send(pool, ctx.clone()).await; reminder.send(pool, ctx.clone()).await;
let _ = sd_notify::notify(false, &[NotifyState::Watchdog]);
} }
} }
sleep_until(sleep_to).await; sleep_until(sleep_to).await;
let _ = sd_notify::notify(false, &[NotifyState::Watchdog]);
} }
} }

View File

@ -1,13 +1,11 @@
use std::env; use std::env;
use chrono::{DateTime, Days, Duration, Months}; use chrono::{DateTime, Days, Months, TimeDelta};
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};
use num_integer::Integer; use num_integer::Integer;
use regex::{Captures, Regex}; use poise::serenity_prelude::{
use serde::Deserialize;
use serenity::{
all::{CreateAttachment, CreateEmbedFooter}, all::{CreateAttachment, CreateEmbedFooter},
builder::{CreateEmbed, CreateEmbedAuthor, CreateMessage, ExecuteWebhook}, builder::{CreateEmbed, CreateEmbedAuthor, CreateMessage, ExecuteWebhook},
http::{CacheHttp, Http, HttpError}, http::{CacheHttp, Http, HttpError},
@ -18,6 +16,8 @@ use serenity::{
}, },
Error, Result, Error, Result,
}; };
use regex::{Captures, Regex};
use serde::Deserialize;
use sqlx::{ use sqlx::{
types::{ types::{
chrono::{NaiveDateTime, Utc}, chrono::{NaiveDateTime, Utc},
@ -26,7 +26,10 @@ use sqlx::{
Executor, Executor,
}; };
use crate::Database; use crate::{
metrics::{REMINDER_COUNTER, REMINDER_FAIL_COUNTER},
Database,
};
lazy_static! { lazy_static! {
pub static ref TIMEFROM_REGEX: Regex = pub static ref TIMEFROM_REGEX: Regex =
@ -66,15 +69,15 @@ 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) {
match NaiveDateTime::from_timestamp_opt(final_time, 0) { match DateTime::from_timestamp(final_time, 0) {
Some(dt) => { Some(dt) => {
let now = Utc::now().naive_utc(); let now = Utc::now();
let difference = { let difference = {
if now < dt { if now < dt {
dt - Utc::now().naive_utc() dt - Utc::now()
} else { } else {
Utc::now().naive_utc() - dt Utc::now() - dt
} }
}; };
@ -232,6 +235,7 @@ pub struct Reminder {
id: u32, id: u32,
channel_id: u64, channel_id: u64,
thread_id: Option<u64>,
webhook_id: Option<u64>, webhook_id: Option<u64>,
webhook_token: Option<String>, webhook_token: Option<String>,
@ -266,6 +270,7 @@ SELECT
reminders.`id` AS id, reminders.`id` AS id,
channels.`channel` AS channel_id, channels.`channel` AS channel_id,
reminders.`thread_id` AS thread_id,
channels.`webhook_id` AS webhook_id, channels.`webhook_id` AS webhook_id,
channels.`webhook_token` AS webhook_token, channels.`webhook_token` AS webhook_token,
@ -337,7 +342,9 @@ WHERE
async fn reset_webhook(&self, pool: impl Executor<'_, Database = Database> + Copy) { async fn reset_webhook(&self, pool: impl Executor<'_, Database = Database> + Copy) {
let _ = sqlx::query!( let _ = sqlx::query!(
"UPDATE channels SET webhook_id = NULL, webhook_token = NULL WHERE channel = ?", "
UPDATE channels SET webhook_id = NULL, webhook_token = NULL WHERE channel = ?
",
self.channel_id self.channel_id
) )
.execute(pool) .execute(pool)
@ -393,7 +400,13 @@ WHERE
} }
if let Some(interval) = self.interval_seconds { if let Some(interval) = self.interval_seconds {
updated_reminder_time += Duration::seconds(interval as i64); updated_reminder_time += TimeDelta::try_seconds(interval as i64)
.unwrap_or_else(|| {
warn!("{}: Could not add {} seconds to a reminder", self.id, interval);
fail_count += 1;
TimeDelta::zero()
});
} }
} }
@ -432,10 +445,24 @@ WHERE
None => error.to_string(), None => error.to_string(),
}; };
REMINDER_FAIL_COUNTER
.get_metric_with_label_values(&[
self.id.to_string().as_str(),
self.channel_id.to_string().as_str(),
&message,
])
.map_or_else(|e| warn!("Couldn't count failed reminder: {:?}", e), |c| c.inc());
error!("[Reminder {}] {}", self.id, message); error!("[Reminder {}] {}", self.id, message);
} }
async fn log_success(&self) {} async fn log_success(&self) {
REMINDER_COUNTER
.get_metric_with_label_values(&[
self.id.to_string().as_str(),
self.channel_id.to_string().as_str(),
])
.map_or_else(|e| warn!("Couldn't count sent reminder: {:?}", e), |c| c.inc());
}
async fn set_sent(&self, pool: impl Executor<'_, Database = Database> + Copy) { async fn set_sent(&self, pool: impl Executor<'_, Database = Database> + Copy) {
sqlx::query!("UPDATE reminders SET `status` = 'sent' WHERE `id` = ?", self.id) sqlx::query!("UPDATE reminders SET `status` = 'sent' WHERE `id` = ?", self.id)
@ -473,7 +500,11 @@ WHERE
reminder: &Reminder, reminder: &Reminder,
embed: Option<CreateEmbed>, embed: Option<CreateEmbed>,
) -> Result<()> { ) -> Result<()> {
let channel = ChannelId::new(reminder.channel_id).to_channel(&cache_http).await; let channel = if let Some(thread_id) = reminder.thread_id {
ChannelId::new(thread_id).to_channel(&cache_http).await
} else {
ChannelId::new(reminder.channel_id).to_channel(&cache_http).await
};
let mut message = CreateMessage::new().content(&reminder.content).tts(reminder.tts); let mut message = CreateMessage::new().content(&reminder.content).tts(reminder.tts);
@ -524,7 +555,14 @@ WHERE
webhook: Webhook, webhook: Webhook,
embed: Option<CreateEmbed>, embed: Option<CreateEmbed>,
) -> Result<()> { ) -> Result<()> {
let mut builder = ExecuteWebhook::new().content(&reminder.content).tts(reminder.tts); let mut builder = if let Some(thread_id) = reminder.thread_id {
ExecuteWebhook::new()
.content(&reminder.content)
.tts(reminder.tts)
.in_thread(thread_id)
} else {
ExecuteWebhook::new().content(&reminder.content).tts(reminder.tts)
};
if let Some(username) = &reminder.username { if let Some(username) = &reminder.username {
if !username.is_empty() { if !username.is_empty() {
@ -571,7 +609,9 @@ WHERE
.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)

View File

@ -1,9 +1,9 @@
use std::collections::HashMap; use std::collections::HashMap;
use rocket::serde::json::json; use rocket::{catch, serde::json::json};
use rocket_dyn_templates::Template; use rocket_dyn_templates::Template;
use crate::JsonValue; use crate::web::JsonValue;
#[catch(403)] #[catch(403)]
pub(crate) async fn forbidden() -> Template { pub(crate) async fn forbidden() -> Template {

View File

@ -20,14 +20,14 @@ pub const DAY: usize = 24 * HOUR;
pub const CHARACTERS: &str = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_"; pub const CHARACTERS: &str = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_";
use std::{collections::HashSet, env, iter::FromIterator}; use std::{collections::HashSet, env};
use lazy_static::lazy_static; use lazy_static::lazy_static;
use serenity::builder::CreateAttachment; use serenity::builder::CreateAttachment;
lazy_static! { lazy_static! {
pub static ref DEFAULT_AVATAR: CreateAttachment = CreateAttachment::bytes( pub static ref DEFAULT_AVATAR: CreateAttachment = CreateAttachment::bytes(
include_bytes!(concat!(env!("CARGO_MANIFEST_DIR"), "/../assets/webhook.jpg")) as &[u8], include_bytes!(concat!(env!("CARGO_MANIFEST_DIR"), "/assets/webhook.jpg")) as &[u8],
"webhook.jpg", "webhook.jpg",
); );
pub static ref SUBSCRIPTION_ROLES: HashSet<u64> = HashSet::from_iter( pub static ref SUBSCRIPTION_ROLES: HashSet<u64> = HashSet::from_iter(
@ -45,4 +45,5 @@ lazy_static! {
.map(|inner| inner.parse::<u32>().ok()) .map(|inner| inner.parse::<u32>().ok())
.flatten() .flatten()
.unwrap_or(600); .unwrap_or(600);
pub static ref SALT: String = env::var("SALT").unwrap();
} }

View File

@ -0,0 +1,27 @@
use rocket::{
fairing::{Fairing, Info, Kind},
Request, Response,
};
use crate::metrics::REQUEST_COUNTER;
pub struct MetricProducer;
#[rocket::async_trait]
impl Fairing for MetricProducer {
fn info(&self) -> Info {
Info { name: "Metrics fairing", kind: Kind::Response }
}
async fn on_response<'r>(&self, req: &'r Request<'_>, resp: &mut Response<'r>) {
if let Some(route) = req.route() {
REQUEST_COUNTER
.with_label_values(&[
req.method().as_str(),
&resp.status().code.to_string(),
&route.uri.to_string(),
])
.inc();
}
}
}

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

@ -0,0 +1 @@
pub mod metrics;

View File

@ -20,6 +20,7 @@ impl Transaction<'_> {
} }
#[derive(Debug)] #[derive(Debug)]
#[allow(dead_code)]
pub enum TransactionError { pub enum TransactionError {
Error(sqlx::Error), Error(sqlx::Error),
Missing, Missing,

View File

@ -1,42 +1,96 @@
#[macro_use]
extern crate rocket;
mod consts; mod consts;
#[macro_use] #[macro_use]
mod macros; mod macros;
mod catchers; mod catchers;
mod fairings;
mod guards; mod guards;
mod metrics;
mod routes; mod routes;
pub mod string {
use std::{fmt::Display, str::FromStr};
use serde::{de, Deserialize, Deserializer, Serializer};
pub fn serialize<T, S>(value: &T, serializer: S) -> Result<S::Ok, S::Error>
where
T: Display,
S: Serializer,
{
serializer.collect_str(value)
}
pub fn deserialize<'de, T, D>(deserializer: D) -> Result<T, D::Error>
where
T: FromStr,
T::Err: Display,
D: Deserializer<'de>,
{
String::deserialize(deserializer)?.parse().map_err(de::Error::custom)
}
}
pub mod string_opt {
use std::{fmt::Display, str::FromStr};
use serde::{de, Deserialize, Deserializer, Serializer};
pub fn serialize<T, S>(value: &Option<T>, serializer: S) -> Result<S::Ok, S::Error>
where
T: Display,
S: Serializer,
{
if let Some(v) = value {
serializer.collect_str(v)
} else {
serializer.serialize_none()
}
}
pub fn deserialize<'de, T, D>(deserializer: D) -> Result<Option<T>, D::Error>
where
T: FromStr,
T::Err: Display,
D: Deserializer<'de>,
{
Option::<String>::deserialize(deserializer)?
.map(|s| s.parse().map_err(de::Error::custom))
.transpose()
}
}
use std::{env, path::Path}; use std::{env, path::Path};
use log::{error, info, warn};
use oauth2::{basic::BasicClient, AuthUrl, ClientId, ClientSecret, RedirectUrl, TokenUrl}; use oauth2::{basic::BasicClient, AuthUrl, ClientId, ClientSecret, RedirectUrl, TokenUrl};
use rocket::{ use poise::serenity_prelude::{
fs::FileServer,
http::CookieJar,
serde::json::{json, Value as JsonValue},
tokio::sync::broadcast::Sender,
};
use rocket_dyn_templates::Template;
use serenity::{
client::Context, client::Context,
http::CacheHttp, http::CacheHttp,
model::id::{GuildId, UserId}, model::id::{GuildId, UserId},
}; };
use rocket::{
catchers,
fs::FileServer,
http::CookieJar,
routes,
serde::json::{json, Value as JsonValue},
tokio::sync::broadcast::Sender,
};
use rocket_dyn_templates::Template;
use sqlx::{MySql, Pool}; use sqlx::{MySql, Pool};
use crate::{ use crate::web::{
consts::{CNC_GUILD, DISCORD_OAUTH_AUTHORIZE, DISCORD_OAUTH_TOKEN, SUBSCRIPTION_ROLES}, consts::{CNC_GUILD, DISCORD_OAUTH_AUTHORIZE, DISCORD_OAUTH_TOKEN, SUBSCRIPTION_ROLES},
metrics::{init_metrics, MetricProducer}, fairings::metrics::MetricProducer,
}; };
type Database = MySql; type Database = MySql;
#[derive(Debug)] #[derive(Debug)]
enum Error { enum Error {
SQLx, #[allow(unused)]
Serenity, SQLx(sqlx::Error),
#[allow(unused)]
Serenity(serenity::Error),
} }
pub async fn initialize( pub async fn initialize(
@ -66,9 +120,7 @@ pub async fn initialize(
let reqwest_client = reqwest::Client::new(); let reqwest_client = reqwest::Client::new();
let static_path = let static_path =
if Path::new("web/static").exists() { "web/static" } else { "/lib/reminder-rs/static" }; if Path::new("static").exists() { "static" } else { "/lib/reminder-rs/static" };
init_metrics();
rocket::build() rocket::build()
.attach(MetricProducer) .attach(MetricProducer)
@ -94,11 +146,11 @@ pub async fn initialize(
routes![ routes![
routes::cookies, routes::cookies,
routes::index, routes::index,
routes::metrics::metrics,
routes::privacy, routes::privacy,
routes::report::report_error, routes::report::report_error,
routes::return_to_same_site, routes::return_to_same_site,
routes::terms, routes::terms,
routes::metrics,
], ],
) )
.mount( .mount(
@ -127,21 +179,31 @@ pub async fn initialize(
.mount( .mount(
"/dashboard", "/dashboard",
routes![ routes![
routes::dashboard::reminders_redirect,
routes::dashboard::todos_redirect,
routes::dashboard::dashboard, routes::dashboard::dashboard,
routes::dashboard::dashboard_home, routes::dashboard::dashboard_home,
routes::dashboard::api::delete_reminder,
routes::dashboard::api::user::get_user_info, routes::dashboard::api::user::get_user_info,
routes::dashboard::api::user::update_user_info, routes::dashboard::api::user::update_user_info,
routes::dashboard::api::user::get_user_guilds, routes::dashboard::api::user::get_user_guilds,
routes::dashboard::api::user::get_reminders,
routes::dashboard::api::user::edit_reminder,
routes::dashboard::api::user::create_user_reminder,
routes::dashboard::api::guild::get_guild_info, routes::dashboard::api::guild::get_guild_info,
routes::dashboard::api::guild::get_guild_channels, routes::dashboard::api::guild::get_guild_channels,
routes::dashboard::api::guild::get_guild_roles, routes::dashboard::api::guild::get_guild_roles,
routes::dashboard::api::guild::get_guild_emojis,
routes::dashboard::api::guild::get_reminder_templates, routes::dashboard::api::guild::get_reminder_templates,
routes::dashboard::api::guild::create_reminder_template, routes::dashboard::api::guild::create_reminder_template,
routes::dashboard::api::guild::delete_reminder_template, routes::dashboard::api::guild::delete_reminder_template,
routes::dashboard::api::guild::create_guild_reminder, routes::dashboard::api::guild::create_guild_reminder,
routes::dashboard::api::guild::get_reminders, routes::dashboard::api::guild::get_reminders,
routes::dashboard::api::guild::edit_reminder, routes::dashboard::api::guild::edit_reminder,
routes::dashboard::api::guild::delete_reminder, routes::dashboard::api::guild::todos::create_todo,
routes::dashboard::api::guild::todos::get_todo,
routes::dashboard::api::guild::todos::update_todo,
routes::dashboard::api::guild::todos::delete_todo,
routes::dashboard::export::export_reminders, routes::dashboard::export::export_reminders,
routes::dashboard::export::export_reminder_templates, routes::dashboard::export::export_reminder_templates,
routes::dashboard::export::export_todos, routes::dashboard::export::export_todos,
@ -149,7 +211,6 @@ pub async fn initialize(
routes::dashboard::export::import_todos, routes::dashboard::export::import_todos,
], ],
) )
.mount("/admin", routes![routes::admin::admin_dashboard_home, routes::admin::bot_data])
.launch() .launch()
.await?; .await?;

View File

@ -1,4 +1,4 @@
use rocket::{http::CookieJar, serde::json::json, State}; use rocket::{get, http::CookieJar, serde::json::json, State};
use serde::Serialize; use serde::Serialize;
use serenity::{ use serenity::{
client::Context, client::Context,
@ -8,7 +8,7 @@ use serenity::{
}, },
}; };
use crate::{check_authorization, routes::JsonResult}; use crate::web::{check_authorization, routes::JsonResult};
#[derive(Serialize)] #[derive(Serialize)]
struct ChannelInfo { struct ChannelInfo {

View File

@ -0,0 +1,84 @@
use std::{collections::HashMap, sync::OnceLock, time::Instant};
use log::warn;
use rocket::{get, http::CookieJar, serde::json::json, State};
use serde::Serialize;
use serenity::{client::Context, model::id::GuildId};
use tokio::sync::RwLock;
use crate::web::{check_authorization, routes::JsonResult};
#[derive(Serialize, Clone)]
struct EmojiInfo {
fmt: String,
name: String,
}
#[derive(Clone)]
struct EmojiCache {
emojis: Vec<EmojiInfo>,
timestamp: Instant,
}
const CACHE_LENGTH: u64 = 120;
static EMOJI_CACHE: OnceLock<RwLock<HashMap<GuildId, EmojiCache>>> = OnceLock::new();
#[get("/api/guild/<id>/emojis")]
pub async fn get_guild_emojis(
id: u64,
cookies: &CookieJar<'_>,
ctx: &State<Context>,
) -> JsonResult {
offline!(Ok(json!(vec![] as Vec<EmojiInfo>)));
check_authorization(cookies, ctx.inner(), id).await?;
let cache_value = {
let cache = EMOJI_CACHE.get_or_init(|| RwLock::new(HashMap::new()));
let read_lock = cache.read().await;
read_lock.get(&GuildId::new(id)).cloned()
};
if let Some(emojis) = cache_value
.map(|v| {
if Instant::now().duration_since(v.timestamp).as_secs() < CACHE_LENGTH {
Some(v.emojis)
} else {
None
}
})
.flatten()
{
Ok(json!(emojis))
} else {
let emojis_res = ctx.http.get_emojis(GuildId::new(id)).await;
match emojis_res {
Ok(emojis) => {
let emojis = emojis
.iter()
.map(|emoji| EmojiInfo {
fmt: format!("{}", emoji),
name: emoji.name.to_string(),
})
.collect::<Vec<EmojiInfo>>();
{
let cache = EMOJI_CACHE.get_or_init(|| RwLock::new(HashMap::new()));
let mut write_lock = cache.write().await;
write_lock.insert(
GuildId::new(id),
EmojiCache { emojis: emojis.clone(), timestamp: Instant::now() },
);
}
Ok(json!(emojis))
}
Err(e) => {
warn!("Could not fetch emojis from {}: {:?}", id, e);
json_err!("Could not get emojis")
}
}
}
}

View File

@ -1,21 +1,24 @@
mod channels; mod channels;
mod emojis;
mod reminders; mod reminders;
mod roles; mod roles;
mod templates; mod templates;
pub mod todos;
use std::env; use std::env;
pub use channels::*; pub use channels::get_guild_channels;
pub use emojis::get_guild_emojis;
pub use reminders::*; pub use reminders::*;
use rocket::{http::CookieJar, serde::json::json, State}; use rocket::{get, http::CookieJar, serde::json::json, State};
pub use roles::*; pub use roles::get_guild_roles;
use serenity::{ use serenity::{
client::Context, client::Context,
model::id::{GuildId, RoleId}, model::id::{GuildId, RoleId},
}; };
pub use templates::*; pub use templates::*;
use crate::{check_authorization, routes::JsonResult}; use crate::web::{check_authorization, routes::JsonResult};
#[get("/api/guild/<id>")] #[get("/api/guild/<id>")]
pub async fn get_guild_info(id: u64, cookies: &CookieJar<'_>, ctx: &State<Context>) -> JsonResult { pub async fn get_guild_info(id: u64, cookies: &CookieJar<'_>, ctx: &State<Context>) -> JsonResult {

View File

@ -1,5 +1,8 @@
use log::warn;
use rocket::{ use rocket::{
get,
http::CookieJar, http::CookieJar,
patch, post,
serde::json::{json, Json}, serde::json::{json, Json},
State, State,
}; };
@ -9,13 +12,13 @@ use serenity::{
}; };
use sqlx::{MySql, Pool}; use sqlx::{MySql, Pool};
use crate::{ use crate::web::{
check_authorization, check_guild_subscription, check_subscription, check_authorization, check_guild_subscription, check_subscription,
consts::MIN_INTERVAL, consts::MIN_INTERVAL,
guards::transaction::Transaction, guards::transaction::Transaction,
routes::{ routes::{
dashboard::{ dashboard::{
create_database_channel, create_reminder, DeleteReminder, PatchReminder, Reminder, create_database_channel, create_reminder, CreateReminder, GetReminder, PatchReminder,
}, },
JsonResult, JsonResult,
}, },
@ -25,7 +28,7 @@ use crate::{
#[post("/api/guild/<id>/reminders", data = "<reminder>")] #[post("/api/guild/<id>/reminders", data = "<reminder>")]
pub async fn create_guild_reminder( pub async fn create_guild_reminder(
id: u64, id: u64,
reminder: Json<Reminder>, reminder: Json<CreateReminder>,
cookies: &CookieJar<'_>, cookies: &CookieJar<'_>,
ctx: &State<Context>, ctx: &State<Context>,
mut transaction: Transaction<'_>, mut transaction: Transaction<'_>,
@ -77,9 +80,9 @@ pub async fn get_reminders(
.join(","); .join(",");
sqlx::query_as_unchecked!( sqlx::query_as_unchecked!(
Reminder, GetReminder,
"SELECT "
reminders.attachment, SELECT
reminders.attachment_name, reminders.attachment_name,
reminders.avatar, reminders.avatar,
channels.channel, channels.channel,
@ -106,7 +109,7 @@ pub async fn get_reminders(
reminders.username, reminders.username,
reminders.utc_time reminders.utc_time
FROM reminders FROM reminders
LEFT JOIN channels ON channels.id = reminders.channel_id INNER JOIN channels ON channels.id = reminders.channel_id
WHERE `status` = 'pending' AND FIND_IN_SET(channels.channel, ?)", WHERE `status` = 'pending' AND FIND_IN_SET(channels.channel, ?)",
channels channels
) )
@ -191,7 +194,7 @@ pub async fn edit_reminder(
.await .await
.map_err(|e| { .map_err(|e| {
warn!("Error updating reminder interval: {:?}", e); warn!("Error updating reminder interval: {:?}", e);
json!({ "reminder": Option::<Reminder>::None, "errors": vec!["Unknown error"] }) json!({ "reminder": Option::<GetReminder>::None, "errors": vec!["Unknown error"] })
})? })?
.days .days
.unwrap_or(0), .unwrap_or(0),
@ -205,7 +208,7 @@ pub async fn edit_reminder(
.await .await
.map_err(|e| { .map_err(|e| {
warn!("Error updating reminder interval: {:?}", e); warn!("Error updating reminder interval: {:?}", e);
json!({ "reminder": Option::<Reminder>::None, "errors": vec!["Unknown error"] }) json!({ "reminder": Option::<GetReminder>::None, "errors": vec!["Unknown error"] })
})? })?
.months .months
.unwrap_or(0), .unwrap_or(0),
@ -219,7 +222,7 @@ pub async fn edit_reminder(
.await .await
.map_err(|e| { .map_err(|e| {
warn!("Error updating reminder interval: {:?}", e); warn!("Error updating reminder interval: {:?}", e);
json!({ "reminder": Option::<Reminder>::None, "errors": vec!["Unknown error"] }) json!({ "reminder": Option::<GetReminder>::None, "errors": vec!["Unknown error"] })
})? })?
.seconds .seconds
.unwrap_or(0), .unwrap_or(0),
@ -248,7 +251,7 @@ pub async fn edit_reminder(
.await .await
.map_err(|e| { .map_err(|e| {
warn!("Error updating reminder interval: {:?}", e); warn!("Error updating reminder interval: {:?}", e);
json!({ "reminder": Option::<Reminder>::None, "errors": vec!["Unknown error"] }) json!({ "reminder": Option::<GetReminder>::None, "errors": vec!["Unknown error"] })
})?; })?;
} }
@ -320,8 +323,9 @@ pub async fn edit_reminder(
} }
match sqlx::query_as_unchecked!( match sqlx::query_as_unchecked!(
Reminder, GetReminder,
"SELECT reminders.attachment, "
SELECT
reminders.attachment_name, reminders.attachment_name,
reminders.avatar, reminders.avatar,
channels.channel, channels.channel,
@ -360,31 +364,7 @@ pub async fn edit_reminder(
Err(e) => { Err(e) => {
warn!("Error exiting `edit_reminder': {:?}", e); warn!("Error exiting `edit_reminder': {:?}", e);
Err(json!({"reminder": Option::<Reminder>::None, "errors": vec!["Unknown error"]})) Err(json!({"reminder": Option::<GetReminder>::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

@ -1,8 +1,9 @@
use rocket::{http::CookieJar, serde::json::json, State}; use log::warn;
use rocket::{get, http::CookieJar, serde::json::json, State};
use serde::Serialize; use serde::Serialize;
use serenity::client::Context; use serenity::client::Context;
use crate::{check_authorization, routes::JsonResult}; use crate::web::{check_authorization, routes::JsonResult};
#[derive(Serialize)] #[derive(Serialize)]
struct RoleInfo { struct RoleInfo {

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