2 Commits

Author SHA1 Message Date
jude
f35c5082f1 Restructure guilds table 2025-06-18 21:03:41 +01:00
jude
19cfacffe5 Restructure database tables 2025-05-08 20:55:17 +01:00
60 changed files with 1060 additions and 13512 deletions

4
.gitignore vendored
View File

@@ -3,8 +3,8 @@ target
/venv /venv
.cargo .cargo
.idea .idea
static/index.html web/static/index.html
static/assets web/static/assets
# Logs # Logs
logs logs
*.log *.log

View File

@@ -1,12 +0,0 @@
{
"db_name": "MySQL",
"query": "DELETE FROM patreon_link WHERE user_id = ?",
"describe": {
"columns": [],
"parameters": {
"Right": 1
},
"nullable": []
},
"hash": "0402e16b1ec89a96d893d43f6b40500ccbde3c619116a702c87954df49898e23"
}

View File

@@ -0,0 +1,12 @@
{
"db_name": "MySQL",
"query": "\n UPDATE reminders SET `status` = 'deleted' WHERE FIND_IN_SET(id, ?)\n ",
"describe": {
"columns": [],
"parameters": {
"Right": 1
},
"nullable": []
},
"hash": "09f6269e5df3acc01e8e2660532b5fcab21c0cd4fd126b580176d24578932d7e"
}

View File

@@ -1,24 +0,0 @@
{
"db_name": "MySQL",
"query": "\n SELECT IFNULL(timezone, 'UTC') AS timezone\n FROM users\n WHERE timezone IS NOT NULL\n GROUP BY timezone\n ORDER BY COUNT(timezone) DESC\n LIMIT 21\n ",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "timezone",
"type_info": {
"type": "VarString",
"flags": "NOT_NULL",
"max_size": 128
}
}
],
"parameters": {
"Right": 0
},
"nullable": [
false
]
},
"hash": "19bc60a2ff67ce6e169985a76405af51d7d16d4d7b84d1c239de5af79da93268"
}

View File

@@ -1,12 +0,0 @@
{
"db_name": "MySQL",
"query": "\n DELETE FROM reminders\n WHERE `utc_time` < NOW() - INTERVAL ? DAY\n AND status != 'pending'\n ORDER BY `utc_time`\n LIMIT 1000\n ",
"describe": {
"columns": [],
"parameters": {
"Right": 1
},
"nullable": []
},
"hash": "2d780695fe98347ea4ab2cb745462f0a9c55cf913c71d4d822b91958f4f8a729"
}

View File

@@ -0,0 +1,12 @@
{
"db_name": "MySQL",
"query": "\n UPDATE reminders\n INNER JOIN `channels`\n ON `channels`.id = reminders.channel_id\n SET reminders.`utc_time` = reminders.`utc_time` + ?\n WHERE channels.`channel` = ?\n ",
"describe": {
"columns": [],
"parameters": {
"Right": 2
},
"nullable": []
},
"hash": "2db489e076c93a5a2baf2dd48eb3278d68296aea93097a642e2bbb5112d51fe8"
}

View File

@@ -0,0 +1,134 @@
{
"db_name": "MySQL",
"query": "\n SELECT\n reminders.id,\n reminders.uid,\n channels.channel,\n reminders.utc_time,\n reminders.interval_seconds,\n reminders.interval_days,\n reminders.interval_months,\n reminders.expires,\n reminders.enabled,\n reminders.content,\n reminders.embed_description,\n reminders.set_by\n FROM\n reminders\n LEFT JOIN\n channels\n ON\n channels.id = reminders.channel_id\n WHERE\n `status` = 'pending' AND\n FIND_IN_SET(channels.channel, ?)\n ",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "id",
"type_info": {
"type": "Long",
"flags": "NOT_NULL | PRIMARY_KEY | UNSIGNED | AUTO_INCREMENT",
"max_size": 10
}
},
{
"ordinal": 1,
"name": "uid",
"type_info": {
"type": "VarString",
"flags": "NOT_NULL | UNIQUE_KEY | NO_DEFAULT_VALUE",
"max_size": 256
}
},
{
"ordinal": 2,
"name": "channel",
"type_info": {
"type": "LongLong",
"flags": "UNIQUE_KEY | UNSIGNED | NO_DEFAULT_VALUE",
"max_size": 20
}
},
{
"ordinal": 3,
"name": "utc_time",
"type_info": {
"type": "Datetime",
"flags": "NOT_NULL | MULTIPLE_KEY | BINARY | NO_DEFAULT_VALUE",
"max_size": 19
}
},
{
"ordinal": 4,
"name": "interval_seconds",
"type_info": {
"type": "Long",
"flags": "UNSIGNED",
"max_size": 10
}
},
{
"ordinal": 5,
"name": "interval_days",
"type_info": {
"type": "Long",
"flags": "UNSIGNED",
"max_size": 10
}
},
{
"ordinal": 6,
"name": "interval_months",
"type_info": {
"type": "Long",
"flags": "UNSIGNED",
"max_size": 10
}
},
{
"ordinal": 7,
"name": "expires",
"type_info": {
"type": "Datetime",
"flags": "BINARY",
"max_size": 19
}
},
{
"ordinal": 8,
"name": "enabled",
"type_info": {
"type": "Tiny",
"flags": "NOT_NULL",
"max_size": 1
}
},
{
"ordinal": 9,
"name": "content",
"type_info": {
"type": "VarString",
"flags": "NOT_NULL",
"max_size": 8192
}
},
{
"ordinal": 10,
"name": "embed_description",
"type_info": {
"type": "VarString",
"flags": "NOT_NULL",
"max_size": 8192
}
},
{
"ordinal": 11,
"name": "set_by",
"type_info": {
"type": "LongLong",
"flags": "MULTIPLE_KEY | UNSIGNED",
"max_size": 20
}
}
],
"parameters": {
"Right": 1
},
"nullable": [
false,
false,
true,
false,
true,
true,
true,
true,
false,
false,
false,
true
]
},
"hash": "3695f95cea95c075b2b3becdf1b5d75bf1ccace3b9a176086faa4ad76c0a0fbd"
}

View File

@@ -0,0 +1,12 @@
{
"db_name": "MySQL",
"query": "\n UPDATE reminders\n INNER JOIN `channels`\n ON `channels`.id = reminders.channel_id\n SET reminders.`utc_time` = DATE_ADD(reminders.`utc_time`, INTERVAL ? SECOND)\n WHERE FIND_IN_SET(channels.`channel`, ?)\n ",
"describe": {
"columns": [],
"parameters": {
"Right": 2
},
"nullable": []
},
"hash": "65496ff85dd92b5aaf12519628afdc16ca7d70131744c9c53880dc56b92991d9"
}

View File

@@ -0,0 +1,12 @@
{
"db_name": "MySQL",
"query": "\n DELETE FROM todos WHERE FIND_IN_SET(id, ?)\n ",
"describe": {
"columns": [],
"parameters": {
"Right": 1
},
"nullable": []
},
"hash": "6e00a27fa770d1aa8cac48cd8878e712ef536c67eeb4fb9a4a801459ada35715"
}

View File

@@ -1,24 +0,0 @@
{
"db_name": "MySQL",
"query": "SELECT user_id FROM patreon_link WHERE guild_id = ?",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "user_id",
"type_info": {
"type": "LongLong",
"flags": "NOT_NULL | PRIMARY_KEY | MULTIPLE_KEY | UNSIGNED | NO_DEFAULT_VALUE",
"max_size": 20
}
}
],
"parameters": {
"Right": 1
},
"nullable": [
false
]
},
"hash": "92cdd6af01e398b22112ffe88b9ff63d9cc61faaf0dee9eda974efbc8bf84173"
}

View File

@@ -1,12 +0,0 @@
{
"db_name": "MySQL",
"query": "\n DELETE FROM reminders\n WHERE `utc_time` < NOW() - INTERVAL ? DAY\n ORDER BY `utc_time`\n LIMIT 1000\n ",
"describe": {
"columns": [],
"parameters": {
"Right": 1
},
"nullable": []
},
"hash": "93897198be27266cd9de90063ee67594cf65c1216c9b9787fc96cd8ffcc1cdef"
}

View File

@@ -1,12 +0,0 @@
{
"db_name": "MySQL",
"query": "\n UPDATE reminders\n INNER JOIN `channels`\n ON `channels`.id = reminders.channel_id\n SET reminders.`utc_time` = reminders.`utc_time` + ?\n WHERE channels.`channel` = ?\n ",
"describe": {
"columns": [],
"parameters": {
"Right": 2
},
"nullable": []
},
"hash": "9b871f08d294555453696808185c6d29d4753619fbee6295a053cefaa9dcc0ae"
}

View File

@@ -1,12 +0,0 @@
{
"db_name": "MySQL",
"query": "INSERT INTO patreon_link (user_id, guild_id, linked_at) VALUES (?, ?, NOW())\n ON DUPLICATE KEY UPDATE guild_id = ?",
"describe": {
"columns": [],
"parameters": {
"Right": 3
},
"nullable": []
},
"hash": "a647934dc5485cfbf430c77b71f7b181f888b0961d5274621e5e1dd76417080e"
}

View File

@@ -0,0 +1,264 @@
{
"db_name": "MySQL",
"query": "\n SELECT\n reminders.attachment_name,\n reminders.avatar,\n channels.channel,\n reminders.content,\n reminders.embed_author,\n reminders.embed_author_url,\n reminders.embed_color,\n reminders.embed_description,\n reminders.embed_footer,\n reminders.embed_footer_url,\n reminders.embed_image_url,\n reminders.embed_thumbnail_url,\n reminders.embed_title,\n IFNULL(reminders.embed_fields, '[]') AS embed_fields,\n reminders.enabled,\n reminders.expires,\n reminders.interval_seconds,\n reminders.interval_days,\n reminders.interval_months,\n reminders.name,\n reminders.restartable,\n reminders.tts,\n reminders.uid,\n reminders.username,\n reminders.utc_time\n FROM reminders\n INNER JOIN channels ON channels.id = reminders.channel_id\n WHERE `status` = 'pending' AND FIND_IN_SET(channels.channel, ?)",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "attachment_name",
"type_info": {
"type": "VarString",
"flags": "",
"max_size": 1040
}
},
{
"ordinal": 1,
"name": "avatar",
"type_info": {
"type": "VarString",
"flags": "",
"max_size": 2048
}
},
{
"ordinal": 2,
"name": "channel",
"type_info": {
"type": "LongLong",
"flags": "NOT_NULL | UNIQUE_KEY | UNSIGNED | NO_DEFAULT_VALUE",
"max_size": 20
}
},
{
"ordinal": 3,
"name": "content",
"type_info": {
"type": "VarString",
"flags": "NOT_NULL",
"max_size": 8192
}
},
{
"ordinal": 4,
"name": "embed_author",
"type_info": {
"type": "VarString",
"flags": "NOT_NULL",
"max_size": 1024
}
},
{
"ordinal": 5,
"name": "embed_author_url",
"type_info": {
"type": "VarString",
"flags": "",
"max_size": 2048
}
},
{
"ordinal": 6,
"name": "embed_color",
"type_info": {
"type": "Long",
"flags": "NOT_NULL | UNSIGNED",
"max_size": 10
}
},
{
"ordinal": 7,
"name": "embed_description",
"type_info": {
"type": "VarString",
"flags": "NOT_NULL",
"max_size": 8192
}
},
{
"ordinal": 8,
"name": "embed_footer",
"type_info": {
"type": "VarString",
"flags": "NOT_NULL",
"max_size": 8192
}
},
{
"ordinal": 9,
"name": "embed_footer_url",
"type_info": {
"type": "VarString",
"flags": "",
"max_size": 2048
}
},
{
"ordinal": 10,
"name": "embed_image_url",
"type_info": {
"type": "VarString",
"flags": "",
"max_size": 2048
}
},
{
"ordinal": 11,
"name": "embed_thumbnail_url",
"type_info": {
"type": "VarString",
"flags": "",
"max_size": 2048
}
},
{
"ordinal": 12,
"name": "embed_title",
"type_info": {
"type": "VarString",
"flags": "NOT_NULL",
"max_size": 1024
}
},
{
"ordinal": 13,
"name": "embed_fields",
"type_info": {
"type": "VarString",
"flags": "NOT_NULL | BINARY",
"max_size": 4294967292
}
},
{
"ordinal": 14,
"name": "enabled",
"type_info": {
"type": "Tiny",
"flags": "NOT_NULL",
"max_size": 1
}
},
{
"ordinal": 15,
"name": "expires",
"type_info": {
"type": "Datetime",
"flags": "BINARY",
"max_size": 19
}
},
{
"ordinal": 16,
"name": "interval_seconds",
"type_info": {
"type": "Long",
"flags": "UNSIGNED",
"max_size": 10
}
},
{
"ordinal": 17,
"name": "interval_days",
"type_info": {
"type": "Long",
"flags": "UNSIGNED",
"max_size": 10
}
},
{
"ordinal": 18,
"name": "interval_months",
"type_info": {
"type": "Long",
"flags": "UNSIGNED",
"max_size": 10
}
},
{
"ordinal": 19,
"name": "name",
"type_info": {
"type": "VarString",
"flags": "NOT_NULL",
"max_size": 400
}
},
{
"ordinal": 20,
"name": "restartable",
"type_info": {
"type": "Tiny",
"flags": "NOT_NULL",
"max_size": 1
}
},
{
"ordinal": 21,
"name": "tts",
"type_info": {
"type": "Tiny",
"flags": "NOT_NULL",
"max_size": 1
}
},
{
"ordinal": 22,
"name": "uid",
"type_info": {
"type": "VarString",
"flags": "NOT_NULL | UNIQUE_KEY | NO_DEFAULT_VALUE",
"max_size": 256
}
},
{
"ordinal": 23,
"name": "username",
"type_info": {
"type": "VarString",
"flags": "",
"max_size": 128
}
},
{
"ordinal": 24,
"name": "utc_time",
"type_info": {
"type": "Datetime",
"flags": "NOT_NULL | MULTIPLE_KEY | BINARY | NO_DEFAULT_VALUE",
"max_size": 19
}
}
],
"parameters": {
"Right": 1
},
"nullable": [
true,
true,
false,
false,
false,
true,
false,
false,
false,
true,
true,
true,
false,
false,
false,
true,
true,
true,
true,
false,
false,
false,
false,
true,
false
]
},
"hash": "af5bf4c6b30ffd316ecebc2dd53554e41f0d4f40cad63736930d20cb18159b38"
}

View File

@@ -0,0 +1,24 @@
{
"db_name": "MySQL",
"query": "\n SELECT IFNULL(timezone, 'UTC') AS timezone\n FROM users\n WHERE timezone IS NOT NULL\n GROUP BY timezone\n ORDER BY COUNT(timezone) DESC\n LIMIT 21\n ",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "timezone",
"type_info": {
"type": "VarString",
"flags": "NOT_NULL",
"max_size": 128
}
}
],
"parameters": {
"Right": 0
},
"nullable": [
false
]
},
"hash": "bbdd4bd7ebffb97efab8ba7e829159e104615260929341ec0e961b4d5cd6ca0c"
}

View File

@@ -1,6 +1,6 @@
{ {
"db_name": "MySQL", "db_name": "MySQL",
"query": "\n SELECT\n reminders.id,\n reminders.uid,\n channels.channel,\n reminders.utc_time,\n reminders.interval_seconds,\n reminders.interval_days,\n reminders.interval_months,\n reminders.expires,\n reminders.enabled,\n reminders.content,\n reminders.embed_description,\n reminders.set_by\n FROM\n reminders\n INNER JOIN\n channels\n ON\n reminders.channel_id = channels.id\n WHERE\n `status` = 'pending' AND\n channels.channel = ? AND\n reminders.enabled >= ?\n ORDER BY\n reminders.utc_time\n ", "query": "\n SELECT\n reminders.id,\n reminders.uid,\n channels.channel,\n reminders.utc_time,\n reminders.interval_seconds,\n reminders.interval_days,\n reminders.interval_months,\n reminders.expires,\n reminders.enabled,\n reminders.content,\n reminders.embed_description,\n reminders.set_by\n FROM\n reminders\n INNER JOIN\n channels\n ON\n reminders.channel_id = channels.id\n WHERE\n `status` = 'pending' AND\n channels.channel = ? AND\n FIND_IN_SET(reminders.enabled, ?)\n ORDER BY\n reminders.utc_time\n ",
"describe": { "describe": {
"columns": [ "columns": [
{ {
@@ -130,5 +130,5 @@
true true
] ]
}, },
"hash": "d2921961627fef0e12892dbbcd4b891e58c0e52c20897aab3d95365774c01bda" "hash": "d60ea641070dbd882cd53878fa109d08bd3f65e0da8c263e78fe0b200228bc2b"
} }

View File

@@ -0,0 +1,264 @@
{
"db_name": "MySQL",
"query": "SELECT\n reminders.attachment,\n reminders.attachment_name,\n reminders.avatar,\n CONCAT('#', channels.channel) AS channel,\n reminders.content,\n reminders.embed_author,\n reminders.embed_author_url,\n reminders.embed_color,\n reminders.embed_description,\n reminders.embed_footer,\n reminders.embed_footer_url,\n reminders.embed_image_url,\n reminders.embed_thumbnail_url,\n reminders.embed_title,\n reminders.embed_fields,\n reminders.enabled,\n reminders.expires,\n reminders.interval_seconds,\n reminders.interval_days,\n reminders.interval_months,\n reminders.name,\n reminders.restartable,\n reminders.tts,\n reminders.username,\n reminders.utc_time\n FROM reminders\n LEFT JOIN channels ON channels.id = reminders.channel_id\n WHERE FIND_IN_SET(channels.channel, ?) AND status = 'pending'",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "attachment",
"type_info": {
"type": "Blob",
"flags": "BLOB | BINARY",
"max_size": 16777215
}
},
{
"ordinal": 1,
"name": "attachment_name",
"type_info": {
"type": "VarString",
"flags": "",
"max_size": 1040
}
},
{
"ordinal": 2,
"name": "avatar",
"type_info": {
"type": "VarString",
"flags": "",
"max_size": 2048
}
},
{
"ordinal": 3,
"name": "channel",
"type_info": {
"type": "VarString",
"flags": "",
"max_size": 84
}
},
{
"ordinal": 4,
"name": "content",
"type_info": {
"type": "VarString",
"flags": "NOT_NULL",
"max_size": 8192
}
},
{
"ordinal": 5,
"name": "embed_author",
"type_info": {
"type": "VarString",
"flags": "NOT_NULL",
"max_size": 1024
}
},
{
"ordinal": 6,
"name": "embed_author_url",
"type_info": {
"type": "VarString",
"flags": "",
"max_size": 2048
}
},
{
"ordinal": 7,
"name": "embed_color",
"type_info": {
"type": "Long",
"flags": "NOT_NULL | UNSIGNED",
"max_size": 10
}
},
{
"ordinal": 8,
"name": "embed_description",
"type_info": {
"type": "VarString",
"flags": "NOT_NULL",
"max_size": 8192
}
},
{
"ordinal": 9,
"name": "embed_footer",
"type_info": {
"type": "VarString",
"flags": "NOT_NULL",
"max_size": 8192
}
},
{
"ordinal": 10,
"name": "embed_footer_url",
"type_info": {
"type": "VarString",
"flags": "",
"max_size": 2048
}
},
{
"ordinal": 11,
"name": "embed_image_url",
"type_info": {
"type": "VarString",
"flags": "",
"max_size": 2048
}
},
{
"ordinal": 12,
"name": "embed_thumbnail_url",
"type_info": {
"type": "VarString",
"flags": "",
"max_size": 2048
}
},
{
"ordinal": 13,
"name": "embed_title",
"type_info": {
"type": "VarString",
"flags": "NOT_NULL",
"max_size": 1024
}
},
{
"ordinal": 14,
"name": "embed_fields",
"type_info": {
"type": "Json",
"flags": "BLOB | BINARY",
"max_size": 4294967295
}
},
{
"ordinal": 15,
"name": "enabled",
"type_info": {
"type": "Tiny",
"flags": "NOT_NULL",
"max_size": 1
}
},
{
"ordinal": 16,
"name": "expires",
"type_info": {
"type": "Datetime",
"flags": "BINARY",
"max_size": 19
}
},
{
"ordinal": 17,
"name": "interval_seconds",
"type_info": {
"type": "Long",
"flags": "UNSIGNED",
"max_size": 10
}
},
{
"ordinal": 18,
"name": "interval_days",
"type_info": {
"type": "Long",
"flags": "UNSIGNED",
"max_size": 10
}
},
{
"ordinal": 19,
"name": "interval_months",
"type_info": {
"type": "Long",
"flags": "UNSIGNED",
"max_size": 10
}
},
{
"ordinal": 20,
"name": "name",
"type_info": {
"type": "VarString",
"flags": "NOT_NULL",
"max_size": 400
}
},
{
"ordinal": 21,
"name": "restartable",
"type_info": {
"type": "Tiny",
"flags": "NOT_NULL",
"max_size": 1
}
},
{
"ordinal": 22,
"name": "tts",
"type_info": {
"type": "Tiny",
"flags": "NOT_NULL",
"max_size": 1
}
},
{
"ordinal": 23,
"name": "username",
"type_info": {
"type": "VarString",
"flags": "",
"max_size": 128
}
},
{
"ordinal": 24,
"name": "utc_time",
"type_info": {
"type": "Datetime",
"flags": "NOT_NULL | MULTIPLE_KEY | BINARY | NO_DEFAULT_VALUE",
"max_size": 19
}
}
],
"parameters": {
"Right": 1
},
"nullable": [
true,
true,
true,
true,
false,
false,
true,
false,
false,
false,
true,
true,
true,
false,
true,
false,
true,
true,
true,
true,
false,
false,
false,
true,
false
]
},
"hash": "db69def9391283efb9bf915223d7d6b2d169203b7dc45481a509137f7590d9a6"
}

View File

@@ -1,24 +0,0 @@
# Caddy v2 configuration for local development (HTTPS on localhost)
# Reverse-proxy to the bot service and serve static assets.
# Uses Caddy's internal CA to generate a self-signed certificate for localhost.
# HTTP -> HTTPS redirect for local development
:80 {
redir https://localhost{uri}
}
# Local HTTPS site with self-signed cert
localhost {
encode zstd gzip
# Issue a locally-trusted certificate via Caddy's internal CA
tls internal
# Serve static files under /static from the mounted volume
handle_path /static* {
root * /var/www/reminder-rs/static
file_server
}
# Proxy everything else to the bot service inside the Docker network
reverse_proxy bot:18920
}

14
Cargo.lock generated
View File

@@ -1,6 +1,6 @@
# This file is automatically @generated by Cargo. # This file is automatically @generated by Cargo.
# It is not intended for manual editing. # It is not intended for manual editing.
version = 4 version = 3
[[package]] [[package]]
name = "addr2line" name = "addr2line"
@@ -524,15 +524,6 @@ dependencies = [
"cfg-if", "cfg-if",
] ]
[[package]]
name = "cron-parser"
version = "0.10.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "baa5650eabdaa360e2c240c2a5f544f10185b439cd76d748e44e3f28128a016b"
dependencies = [
"chrono",
]
[[package]] [[package]]
name = "crossbeam-channel" name = "crossbeam-channel"
version = "0.5.13" version = "0.5.13"
@@ -2623,12 +2614,11 @@ checksum = "2b15c43186be67a4fd63bee50d0303afffcef381492ebe2c5d87f324e1b8815c"
[[package]] [[package]]
name = "reminder-rs" name = "reminder-rs"
version = "1.7.41" version = "1.7.37"
dependencies = [ dependencies = [
"base64 0.22.1", "base64 0.22.1",
"chrono", "chrono",
"chrono-tz", "chrono-tz",
"cron-parser",
"csv", "csv",
"dotenv", "dotenv",
"env_logger", "env_logger",

View File

@@ -1,6 +1,6 @@
[package] [package]
name = "reminder-rs" name = "reminder-rs"
version = "1.7.41" version = "1.7.37"
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"
@@ -35,7 +35,6 @@ serenity = { version = "0.12", default-features = false, features = ["builder",
oauth2 = "4" oauth2 = "4"
csv = "1.2" csv = "1.2"
sd-notify = "0.4.1" sd-notify = "0.4.1"
cron-parser = "0.10"
[dependencies.extract_derive] [dependencies.extract_derive]
path = "extract_derive" path = "extract_derive"

View File

@@ -17,7 +17,13 @@ COPY ./conf ./conf
COPY ./extract_derive ./extract_derive COPY ./extract_derive ./extract_derive
COPY ./migrations ./migrations COPY ./migrations ./migrations
COPY ./recordable_derive ./recordable_derive COPY ./recordable_derive ./recordable_derive
COPY ./reminder-dashboard ./reminder-dashboard COPY ./reminder-dashboard/public ./reminder-dashboard/public
COPY ./reminder-dashboard/src ./reminder-dashboard/src
COPY ./reminder-dashboard/index.html ./reminder-dashboard/
COPY ./reminder-dashboard/package.json ./reminder-dashboard/
COPY ./reminder-dashboard/package-lock.json ./reminder-dashboard/
COPY ./reminder-dashboard/tsconfig.json ./reminder-dashboard/
COPY ./reminder-dashboard/vite.config.ts ./reminder-dashboard/
COPY ./src ./src COPY ./src ./src
COPY ./static ./static COPY ./static ./static
COPY ./templates ./templates COPY ./templates ./templates
@@ -26,11 +32,6 @@ COPY ./Cargo.lock ./
COPY ./Cargo.toml ./ COPY ./Cargo.toml ./
COPY ./dp.py ./ COPY ./dp.py ./
# Build dashboard assets explicitly to ensure dist exists
RUN npm ci --prefix reminder-dashboard && npm run build --prefix reminder-dashboard
# Build and install the Rust binary
RUN cargo install --path . RUN cargo install --path .
EXPOSE 18920
CMD ["reminder-rs"] CMD ["reminder-rs"]

View File

@@ -1,78 +1,52 @@
# reminder-rs # reminder-rs
Reminder Bot for Discord. Reminder Bot for Discord.
## How do I use it? ## How do I use it?
I offer a hosted version of the bot. You can invite it with: **https://invite.reminder-bot.com**. The catch is that repeating
I offer a hosted version of the bot. You can invite it with: **https://invite.reminder-bot.com**.
The catch is that repeating
reminders are paid on the hosted version of the bot. Keep reading if you want to host it yourself. reminders are paid on the hosted version of the bot. Keep reading if you want to host it yourself.
You'll need rustc and cargo for compilation. To run, you'll need Python 3 still (due to no suitable You'll need rustc and cargo for compilation. To run, you'll need Python 3 still (due to no suitable replacement for dateparser in Rust)
replacement for dateparser in Rust)
### Build APT package ### Build APT package
Recommended method. Recommended method.
By default, this builds targeting Ubuntu 20.04. Modify the Containerfile if you wish to target a By default, this builds targeting Ubuntu 20.04. Modify the Containerfile if you wish to target a different platform. These instructions are written using `podman`, but `docker` should work too.
different platform. These instructions are written using `podman`, but `docker` should work too.
1. Install container software: `sudo apt install podman`. 1. Install container software: `sudo apt install podman`.
2. Install database server: `sudo apt install mysql-server-8.0`. Create a database called 2. Install database server: `sudo apt install mysql-server-8.0`. Create a database called `reminders`
`reminders`
3. Install SQLx CLI: `cargo install sqlx-cli` 3. Install SQLx CLI: `cargo install sqlx-cli`
4. From the source code directory, execute `sqlx migrate run` 4. From the source code directory, execute `sqlx migrate run`
5. Build container image: `podman build -t reminder-rs .` 5. Build container image: `podman build -t reminder-rs .`
6. Build with podman: 6. Build with podman: `podman run --rm --network=host -v "$PWD":/mnt -w /mnt -e "DATABASE_URL=mysql://user@localhost/reminders" reminder-rs cargo deb`
`podman run --rm --network=host -v "$PWD":/mnt -w /mnt -e "DATABASE_URL=mysql://user@localhost/reminders" reminder-rs cargo deb`
### Compiling for other target ### Compiling for other target
1. Install requirements: 1. Install requirements:
`sudo apt install gcc gcc-multilib cmake libssl-dev build-essential python3-dateparser` `sudo apt install gcc gcc-multilib cmake libssl-dev build-essential python3-dateparser`
2. Install rustup from https://rustup.rs 2. Install rustup from https://rustup.rs
3. Install the nightly toolchain: `rustup toolchain default nightly` 3. Install the nightly toolchain: `rustup toolchain default nightly`
4. Install database server: `sudo apt install mysql-server-8.0`. Create a database called 4. Install database server: `sudo apt install mysql-server-8.0`. Create a database called `reminders`.
`reminders`.
5. Install `sqlx-cli`: `cargo install sqlx-cli`. 5. Install `sqlx-cli`: `cargo install sqlx-cli`.
6. Run migrations: `sqlx migrate run`. 6. Run migrations: `sqlx migrate run`.
7. Set environment variables: 7. Set environment variables:
* `DATABASE_URL` - the URL of your MySQL database (`mysql://user[:password]@domain/database`) * `DATABASE_URL` - the URL of your MySQL database (`mysql://user[:password]@domain/database`)
8. Build: `cargo build --release` 8. Build: `cargo build --release`
### Configuring ### Configuring
Reminder Bot reads a number of environment variables. Some are essential, and others have hardcoded Reminder Bot reads a number of environment variables. Some are essential, and others have hardcoded fallbacks. Environment variables can be loaded from a .env file in the working directory.
fallbacks. Environment variables can be loaded from a .env file in the working directory.
__Required Variables__ __Required Variables__
* `DATABASE_URL` - the URL of your MySQL database (`mysql://user[:password]@domain/database`) * `DATABASE_URL` - the URL of your MySQL database (`mysql://user[:password]@domain/database`)
* `DISCORD_TOKEN` - your application's bot user's authorization token * `DISCORD_TOKEN` - your application's bot user's authorization token
__Other Variables__ __Other Variables__
* `MIN_INTERVAL` - default `600`, defines the shortest interval the bot should accept * `MIN_INTERVAL` - default `600`, defines the shortest interval the bot should accept
* `LOCAL_TIMEZONE` - default `UTC`, necessary for calculations in the natural language processor * `LOCAL_TIMEZONE` - default `UTC`, necessary for calculations in the natural language processor
* `SUBSCRIPTION_ROLES` - default `None`, accepts a list of Discord role IDs that are given to * `SUBSCRIPTION_ROLES` - default `None`, accepts a list of Discord role IDs that are given to subscribed users
subscribed users * `CNC_GUILD` - default `None`, accepts a single Discord guild ID for the server that the subscription roles belong to
* `CNC_GUILD` - default `None`, accepts a single Discord guild ID for the server that the * `PYTHON_LOCATION` - default `/usr/bin/python3`. Can be changed if your Python executable is located somewhere else
subscription roles belong to * `THEME_COLOR` - default `8fb677`. Specifies the hex value of the color to use on info message embeds
* `PYTHON_LOCATION` - default `/usr/bin/python3`. Can be changed if your Python executable is
located somewhere else
* `THEME_COLOR` - default `8fb677`. Specifies the hex value of the color to use on info message
embeds
## Running with Docker
A `compose.yml` file is provided to aid in running the bot agnostically using docker.
* Populate a `.env` file as in `conf/default.env`
* Add the additional variable `ROCKET_SECRET_KEY` with a key generated from
`head -c64 /dev/urandom | base64`
* Run `docker compose up`
Please note that this is _not_ production-ready when run via compose. We do not offer a way for
backing up of your data, or a way to run the dashboard securely via HTTPS, which is required for
OAuth.

View File

@@ -10,17 +10,4 @@ fn main() {
.current_dir(Path::new("reminder-dashboard")) .current_dir(Path::new("reminder-dashboard"))
.spawn() .spawn()
.expect("Failed to build NPM"); .expect("Failed to build NPM");
Command::new("cp")
.arg("reminder-dashboard/dist/index.html")
.arg("static/index.html")
.spawn()
.expect("Failed to copy index.html");
Command::new("cp")
.arg("-r")
.arg("reminder-dashboard/dist/static/assets")
.arg("static/")
.spawn()
.expect("Failed to copy assets");
} }

View File

@@ -1,59 +1,21 @@
version: '3.3'
services: services:
bot:
build: ./Containerfile.run
image: reminder-rs-run
restart: always
expose:
- '80'
database: database:
image: mysql:8.0 image: mysql:8.0
restart: always restart: always
command: --log-bin-trust-function-creators=1
environment: environment:
MYSQL_ROOT_PASSWORD: root MYSQL_DATABASE: 'reminders'
MYSQL_DATABASE: reminders MYSQL_USER: 'reminder-bot'
MYSQL_USER: reminder-bot ports:
MYSQL_PASSWORD: password - '3306:3306'
expose:
- '3306'
volumes: volumes:
- reminders:/var/lib/mysql - reminders:/var/lib/mysql
bot:
build:
context: .
dockerfile: Containerfile.run
image: reminder-rs-run
restart: always
depends_on:
- database
env_file:
- .env
environment:
DATABASE_URL: "mysql://reminder-bot:password@database/reminders"
DISCORD_TOKEN:
PATREON_GUILD_ID:
PATREON_ROLE_ID:
LOCAL_TIMEZONE:
MIN_INTERVAL:
ROCKET_SECRET_KEY:
ROCKET_ADDRESS: "0.0.0.0"
ROCKET_PORT: "18920"
REMIND_INTERVAL:
OAUTH2_DISCORD_CALLBACK:
OAUTH2_CLIENT_ID:
OAUTH2_CLIENT_SECRET:
ports:
- "18920:18920"
proxy:
image: caddy:2.4.6-alpine
restart: always
depends_on:
- bot
ports:
- "80:80"
- "443:443"
volumes:
- ./Caddyfile:/etc/caddy/Caddyfile:ro
- ./static:/var/www/reminder-rs/static:ro
- caddy_data:/data
- caddy_config:/config
volumes:
reminders:
caddy_data:
caddy_config:

12676
gb-ipv4.csv

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,83 @@
-- Drop all old tables
DROP TABLE IF EXISTS users_old;
DROP TABLE IF EXISTS messages;
DROP TABLE IF EXISTS embeds;
DROP TABLE IF EXISTS embed_fields;
DROP TABLE IF EXISTS command_aliases;
DROP TABLE IF EXISTS macro;
DROP TABLE IF EXISTS roles;
SET FOREIGN_KEY_CHECKS=0;
-- Drop columns from channels that are no longer used
ALTER TABLE channels DROP COLUMN `name`;
ALTER TABLE channels DROP COLUMN `blacklisted`;
-- Drop columns from guilds table that are no longer used and rebuild table
CREATE TABLE guilds_new (
id BIGINT UNSIGNED NOT NULL PRIMARY KEY,
ephemeral_confirmations BOOLEAN NOT NULL DEFAULT 0
);
INSERT INTO guilds_new (id, ephemeral_confirmations) SELECT guild, ephemeral_confirmations FROM guilds;
RENAME TABLE guilds TO guilds_old;
RENAME TABLE guilds_new TO guilds;
-- Update fk on channels to point at new guild table
ALTER TABLE channels
DROP FOREIGN KEY `channels_ibfk_1`,
MODIFY COLUMN guild_id BIGINT UNSIGNED,
ADD FOREIGN KEY `fk_guild_id` (`guild_id`)
REFERENCES `guilds` (`id`)
ON DELETE CASCADE
ON UPDATE CASCADE;
UPDATE channels SET guild_id = (SELECT guild FROM guilds_old WHERE id = guild_id);
-- Rebuild todos table
CREATE TABLE `todos_new` (
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
`guild_id` BIGINT UNSIGNED DEFAULT NULL,
`channel_id` INT UNSIGNED DEFAULT NULL,
`user_id` BIGINT UNSIGNED DEFAULT NULL,
`value` VARCHAR(2000) NOT NULL,
PRIMARY KEY (`id`),
FOREIGN KEY `fk_channel_id` (`channel_id`)
REFERENCES `channels` (`id`)
ON DELETE SET NULL
ON UPDATE CASCADE,
FOREIGN KEY `fk_guild_id` (`guild_id`)
REFERENCES `guilds` (`id`)
ON DELETE CASCADE
ON UPDATE CASCADE,
FOREIGN KEY `fk_user_id` (`user_id`)
REFERENCES `users` (`id`)
ON DELETE SET NULL
ON UPDATE CASCADE
);
INSERT INTO todos_new (id, guild_id, channel_id, user_id, value) SELECT id, (SELECT guild FROM guilds_old WHERE id = guild_id), channel_id, user_id, value FROM todos;
RENAME TABLE todos TO todos_old;
RENAME TABLE todos_new TO todos;
DROP TABLE todos_old;
-- Update fk on reminder_template to point at new guild table
ALTER TABLE reminder_template
DROP FOREIGN KEY `reminder_template_ibfk_1`,
MODIFY COLUMN guild_id BIGINT UNSIGNED NOT NULL,
ADD FOREIGN KEY `fk_guild_id` (`guild_id`)
REFERENCES `guilds` (`id`)
ON DELETE CASCADE
ON UPDATE CASCADE;
UPDATE reminder_template SET guild_id = (SELECT guild FROM guilds_old WHERE id = guild_id);
-- Update fk on command_macro to point at new guild table
ALTER TABLE command_macro
DROP FOREIGN KEY `command_macro_ibfk_1`,
MODIFY COLUMN guild_id BIGINT UNSIGNED NOT NULL,
ADD FOREIGN KEY `fk_guild_id` (`guild_id`)
REFERENCES `guilds` (`id`)
ON DELETE CASCADE
ON UPDATE CASCADE;
UPDATE command_macro SET guild_id = (SELECT guild FROM guilds_old WHERE id = guild_id);
DROP TABLE guilds_old;
SET FOREIGN_KEY_CHECKS=1;

View File

@@ -1,11 +0,0 @@
CREATE TABLE patreon_link
(
user_id BIGINT UNSIGNED NOT NULL,
guild_id BIGINT UNSIGNED NOT NULL,
linked_at DATETIME DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (user_id),
INDEX `idx_user_id` (user_id),
INDEX `idx_guild_id` (guild_id),
INDEX `idx_linked_at` (linked_at)
);

View File

@@ -3,7 +3,6 @@ use std::time::{SystemTime, UNIX_EPOCH};
use chrono_tz::TZ_VARIANTS; use chrono_tz::TZ_VARIANTS;
use poise::serenity_prelude::AutocompleteChoice; use poise::serenity_prelude::AutocompleteChoice;
use crate::time_parser::cron_next_timestamp;
use crate::{models::CtxData, time_parser::natural_parser, Context}; use crate::{models::CtxData, time_parser::natural_parser, Context};
pub async fn timezone_autocomplete(ctx: Context<'_>, partial: &str) -> Vec<String> { pub async fn timezone_autocomplete(ctx: Context<'_>, partial: &str) -> Vec<String> {
@@ -43,13 +42,7 @@ pub async fn time_hint_autocomplete(ctx: Context<'_>, partial: &str) -> Vec<Auto
if partial.is_empty() { if partial.is_empty() {
vec![AutocompleteChoice::new("Start typing a time...".to_string(), "now".to_string())] vec![AutocompleteChoice::new("Start typing a time...".to_string(), "now".to_string())]
} else { } else {
let timezone = ctx.timezone().await; match natural_parser(partial, &ctx.timezone().await.to_string()).await {
let timestamp = match cron_next_timestamp(partial, timezone) {
Some(ts) => Some(ts),
None => natural_parser(partial, &timezone.to_string()).await,
};
match timestamp {
Some(timestamp) => match SystemTime::now().duration_since(UNIX_EPOCH) { Some(timestamp) => match SystemTime::now().duration_since(UNIX_EPOCH) {
Ok(now) => { Ok(now) => {
let diff = timestamp - now.as_secs() as i64; let diff = timestamp - now.as_secs() as i64;

View File

@@ -75,8 +75,8 @@ Please select a unique name for your macro.",
CreateEmbed::new() CreateEmbed::new()
.title("Macro Recording Started") .title("Macro Recording Started")
.description( .description(
"Run up to 5 commands to record in this macro. Use `/macro finish` to stop recording at any point. "Run up to 5 commands, or type `/macro finish` to stop at any point.
Any commands performed during recording won't take any actual action- they are only captured for the macro.", Any commands ran as part of recording will be inconsequential",
) )
.color(*THEME_COLOR), .color(*THEME_COLOR),
), ),

View File

@@ -15,8 +15,8 @@ impl Recordable for Options {
let footer = footer(ctx); let footer = footer(ctx);
ctx.send(CreateReply::default().embed(CreateEmbed::new().title("Donate") ctx.send(CreateReply::default().embed(CreateEmbed::new().title("Donate")
.description("Thinking of subscribing? .description("Thinking of adding a monthly contribution?
Click below for my Patreon and official bot server Click below for my Patreon and official bot server :)
**https://www.patreon.com/jellywx/** **https://www.patreon.com/jellywx/**
**https://discord.jellywx.com/** **https://discord.jellywx.com/**
@@ -26,7 +26,7 @@ With your new rank, you'll be able to:
Set repeating reminders with `/remind` or the dashboard Set repeating reminders with `/remind` or the dashboard
Use unlimited uploads on SoundFX Use unlimited uploads on SoundFX
Members of servers you __own__ will be able to set repeating reminders via commands. You can also choose to share your membership with one other server. (Also, members of servers you __own__ will be able to set repeating reminders via commands)
Just $2 USD/month! Just $2 USD/month!
@@ -41,8 +41,8 @@ Just $2 USD/month!
} }
} }
/// Show Patreon information /// Details on supporting the bot and Patreon benefits
#[poise::command(slash_command, rename = "info", identifying_name = "patreon_info")] #[poise::command(slash_command, rename = "patreon", identifying_name = "patreon")]
pub async fn info(ctx: Context<'_>) -> Result<(), Error> { pub async fn command(ctx: Context<'_>) -> Result<(), Error> {
(Options {}).run(ctx).await (Options {}).run(ctx).await
} }

View File

@@ -12,6 +12,8 @@ pub mod dashboard;
#[cfg(not(test))] #[cfg(not(test))]
pub mod delete; pub mod delete;
#[cfg(not(test))] #[cfg(not(test))]
pub mod donate;
#[cfg(not(test))]
pub mod help; pub mod help;
#[cfg(not(test))] #[cfg(not(test))]
pub mod info; pub mod info;
@@ -24,8 +26,6 @@ pub mod nudge;
#[cfg(not(test))] #[cfg(not(test))]
pub mod offset; pub mod offset;
#[cfg(not(test))] #[cfg(not(test))]
pub mod patreon;
#[cfg(not(test))]
pub mod pause; pub mod pause;
#[cfg(not(test))] #[cfg(not(test))]
pub mod remind; pub mod remind;

View File

@@ -1,57 +0,0 @@
use poise::CreateReply;
use serde::{Deserialize, Serialize};
use crate::{
utils::{check_user_subscription, Extract, Recordable},
Context, Error,
};
#[derive(Serialize, Deserialize, Extract)]
pub struct Options;
impl Recordable for Options {
async fn run(self, ctx: Context<'_>) -> Result<(), Error> {
let guild_id = ctx.guild_id().ok_or("This command must be used in a server")?;
let user_id = ctx.author().id;
// Check if user has Patreon subscription
if !check_user_subscription(ctx, user_id).await {
ctx.send(CreateReply::default()
.content("❌ You must be a Patreon subscriber to use this command. Use `/patreon info` for more information.")
.ephemeral(true)
).await?;
return Ok(());
}
// Insert or update the patreon_link entry
sqlx::query!(
"INSERT INTO patreon_link (user_id, guild_id, linked_at) VALUES (?, ?, NOW())
ON DUPLICATE KEY UPDATE guild_id = ?",
user_id.get(),
guild_id.get(),
guild_id.get()
)
.execute(&ctx.data().database)
.await?;
ctx.send(
CreateReply::default()
.content("✅ Successfully linked your Patreon subscription to this server!")
.ephemeral(true),
)
.await?;
Ok(())
}
}
/// Link your Patreon subscription to this server to allow other users Patreon access.
#[poise::command(
slash_command,
rename = "link",
identifying_name = "patreon_link",
guild_only = true
)]
pub async fn link(ctx: Context<'_>) -> Result<(), Error> {
(Options {}).run(ctx).await
}

View File

@@ -1,11 +0,0 @@
pub mod info;
pub mod link;
pub mod unlink;
use crate::{Context, Error};
/// Manage Patreon subscription features
#[poise::command(slash_command, rename = "patreon", identifying_name = "patreon")]
pub async fn command(_ctx: Context<'_>) -> Result<(), Error> {
Ok(())
}

View File

@@ -1,50 +0,0 @@
use poise::CreateReply;
use serde::{Deserialize, Serialize};
use crate::{
utils::{Extract, Recordable},
Context, Error,
};
#[derive(Serialize, Deserialize, Extract)]
pub struct Options;
impl Recordable for Options {
async fn run(self, ctx: Context<'_>) -> Result<(), Error> {
let user_id = ctx.author().id;
// Remove the patreon_link entry
let result = sqlx::query!("DELETE FROM patreon_link WHERE user_id = ?", user_id.get())
.execute(&ctx.data().database)
.await?;
if result.rows_affected() > 0 {
ctx.send(
CreateReply::default()
.content("✅ Successfully unlinked your Patreon subscription!")
.ephemeral(true),
)
.await?;
} else {
ctx.send(
CreateReply::default()
.content("❌ No existing Patreon link found.")
.ephemeral(true),
)
.await?;
}
Ok(())
}
}
/// Unlink your Patreon subscription
#[poise::command(
slash_command,
rename = "unlink",
identifying_name = "patreon_unlink",
guild_only = false
)]
pub async fn unlink(ctx: Context<'_>) -> Result<(), Error> {
(Options {}).run(ctx).await
}

View File

@@ -21,7 +21,7 @@ impl Recordable for Options {
" "
INSERT INTO todos (guild_id, channel_id, value) INSERT INTO todos (guild_id, channel_id, value)
VALUES ( VALUES (
(SELECT id FROM guilds WHERE guild = ?), ?,
(SELECT id FROM channels WHERE channel = ?), (SELECT id FROM channels WHERE channel = ?),
? ?
) )

View File

@@ -23,7 +23,7 @@ pub async fn listener(
if is_new.unwrap_or(false) { if is_new.unwrap_or(false) {
let guild_id = guild.id.get().to_owned(); let guild_id = guild.id.get().to_owned();
sqlx::query!("INSERT IGNORE INTO guilds (guild) VALUES (?)", guild_id) sqlx::query!("INSERT IGNORE INTO guilds (id) VALUES (?)", guild_id)
.execute(&data.database) .execute(&data.database)
.await?; .await?;
@@ -56,7 +56,7 @@ To stay up to date on the latest features and fixes, join our [Discord](https://
} }
FullEvent::GuildDelete { incomplete, .. } => { FullEvent::GuildDelete { incomplete, .. } => {
if !incomplete.unavailable { if !incomplete.unavailable {
let _ = sqlx::query!("DELETE FROM guilds WHERE guild = ?", incomplete.id.get()) let _ = sqlx::query!("DELETE FROM guilds WHERE id = ?", incomplete.id.get())
.execute(&data.database) .execute(&data.database)
.await; .await;
} }

View File

@@ -1,6 +1,4 @@
use crate::consts::THEME_COLOR; use poise::{CommandInteractionType, CreateReply};
use poise::{serenity_prelude::CreateEmbed, CommandInteractionType, CreateReply};
use serenity::builder::CreateEmbedFooter;
use crate::{consts::MACRO_MAX_COMMANDS, models::command_macro::RecordedCommand, Context, Error}; use crate::{consts::MACRO_MAX_COMMANDS, models::command_macro::RecordedCommand, Context, Error};
@@ -20,18 +18,7 @@ async fn macro_check(ctx: Context<'_>) -> bool {
.send( .send(
CreateReply::default() CreateReply::default()
.ephemeral(true) .ephemeral(true)
.embed(CreateEmbed::new() .content(format!("{} commands already recorded. Please use `/macro finish` to end recording.", MACRO_MAX_COMMANDS))
.title("💾 Currently recording macro")
.description(
format!("{} commands already recorded. Please use `/macro finish` to end recording.", MACRO_MAX_COMMANDS),
)
.footer(
CreateEmbedFooter::new(
"Any commands performed during recording won't take any actual action- they are only captured for the macro"
)
)
.color(*THEME_COLOR),
),
) )
.await; .await;
} else { } else {
@@ -41,19 +28,9 @@ async fn macro_check(ctx: Context<'_>) -> bool {
let _ = ctx let _ = ctx
.send( .send(
CreateReply::default().ephemeral(true).embed( CreateReply::default()
CreateEmbed::new() .ephemeral(true)
.title("💾 Currently recording macro") .content("Command recorded to macro"),
.description(
"Command recorded. Use `/macro finish` to end recording.",
)
.footer(
CreateEmbedFooter::new(
"Any commands performed during recording won't take any actual action- they are only captured for the macro"
)
)
.color(*THEME_COLOR),
),
) )
.await; .await;
} }
@@ -61,18 +38,8 @@ async fn macro_check(ctx: Context<'_>) -> bool {
None => { None => {
let _ = ctx let _ = ctx
.send( .send(
CreateReply::default().ephemeral(true).embed( CreateReply::default().ephemeral(true).content(
CreateEmbed::new() "This command is not supported in macros yet.",
.title("💾 Currently recording macro")
.description(
"This command is not supported in macros, so it hasn't been recorded. Use `/macro finish` to end recording.",
)
.footer(
CreateEmbedFooter::new(
"Any commands performed during recording won't take any actual action- they are only captured for the macro"
)
)
.color(*THEME_COLOR),
), ),
) )
.await; .await;
@@ -107,7 +74,6 @@ async fn check_self_permissions(ctx: Context<'_>) -> bool {
return if permissions.send_messages() return if permissions.send_messages()
&& permissions.embed_links() && permissions.embed_links()
&& manage_webhooks && manage_webhooks
&& permissions.view_channel()
{ {
true true
} else { } else {
@@ -115,13 +81,12 @@ async fn check_self_permissions(ctx: Context<'_>) -> bool {
.send(CreateReply::default().content(format!( .send(CreateReply::default().content(format!(
"The bot appears to be missing some permissions: "The bot appears to be missing some permissions:
{} **View Channels**
{} **Send Message** {} **Send Message**
{} **Embed Links** {} **Embed Links**
{} **Manage Webhooks** {} **Manage Webhooks**
Please check the bot's roles, and any channel overrides. Alternatively, giving the bot \"Administrator\" will bypass permission checks", Please check the bot's roles, and any channel overrides. Alternatively, giving the bot
if permissions.view_channel() { "" } else { "" }, \"Administrator\" will bypass permission checks",
if permissions.send_messages() { "" } else { "" }, if permissions.send_messages() { "" } else { "" },
if permissions.embed_links() { "" } else { "" }, if permissions.embed_links() { "" } else { "" },
if manage_webhooks { "" } else { "" }, if manage_webhooks { "" } else { "" },
@@ -135,7 +100,9 @@ Please check the bot's roles, and any channel overrides. Alternatively, giving t
manage_webhooks manage_webhooks
} }
None => true, None => {
return true;
}
} }
} }

View File

@@ -48,8 +48,8 @@ use crate::test::TestContext;
use crate::{ use crate::{
commands::{ commands::{
allowed_dm, clock, clock_context_menu::clock_context_menu, command_macro, dashboard, allowed_dm, clock, clock_context_menu::clock_context_menu, command_macro, dashboard,
delete, help, info, look, multiline, nudge, offset, patreon, pause, remind, settings, delete, donate, help, info, look, multiline, nudge, offset, pause, remind, settings, timer,
timer, timezone, todo, webhook, timezone, todo, webhook,
}, },
consts::THEME_COLOR, consts::THEME_COLOR,
event_handlers::listener, event_handlers::listener,
@@ -165,14 +165,7 @@ async fn _main(tx: Sender<()>) -> Result<(), Box<dyn StdError + Send + Sync>> {
help::command(), help::command(),
info::command(), info::command(),
clock::command(), clock::command(),
poise::Command { donate::command(),
subcommands: vec![
patreon::link::link(),
patreon::unlink::unlink(),
patreon::info::info(),
],
..patreon::command()
},
clock_context_menu(), clock_context_menu(),
dashboard::command(), dashboard::command(),
timezone::command(), timezone::command(),

View File

@@ -8,9 +8,7 @@ use crate::{consts::DEFAULT_AVATAR, Error};
pub struct ChannelData { pub struct ChannelData {
pub id: u32, pub id: u32,
pub channel: u64, pub channel: u64,
pub name: Option<String>,
pub nudge: i16, pub nudge: i16,
pub blacklisted: bool,
pub webhook_id: Option<u64>, pub webhook_id: Option<u64>,
pub webhook_token: Option<String>, pub webhook_token: Option<String>,
pub paused: bool, pub paused: bool,
@@ -27,7 +25,7 @@ impl ChannelData {
if let Ok(c) = sqlx::query_as_unchecked!( if let Ok(c) = sqlx::query_as_unchecked!(
Self, Self,
" "
SELECT id, channel, name, nudge, blacklisted, webhook_id, webhook_token, paused, SELECT id, channel, nudge, webhook_id, webhook_token, paused,
paused_until paused_until
FROM channels FROM channels
WHERE channel = ? WHERE channel = ?
@@ -39,15 +37,11 @@ impl ChannelData {
{ {
Ok(c) Ok(c)
} else { } else {
let props = channel.to_owned().guild().map(|g| (g.guild_id.get().to_owned(), g.name)); let guild_id = channel.to_owned().guild().map(|g| g.guild_id.get().to_owned());
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, guild_id) VALUES (?, ?)",
channel_id, channel_id,
channel_name,
guild_id guild_id
) )
.execute(&pool.clone()) .execute(&pool.clone())
@@ -56,7 +50,7 @@ impl ChannelData {
Ok(sqlx::query_as_unchecked!( Ok(sqlx::query_as_unchecked!(
Self, Self,
" "
SELECT id, channel, name, nudge, blacklisted, webhook_id, webhook_token, paused, paused_until SELECT id, channel, nudge, webhook_id, webhook_token, paused, paused_until
FROM channels FROM channels
WHERE channel = ? WHERE channel = ?
", ",
@@ -72,18 +66,14 @@ impl ChannelData {
" "
UPDATE channels UPDATE channels
SET SET
name = ?,
nudge = ?, nudge = ?,
blacklisted = ?,
webhook_id = ?, webhook_id = ?,
webhook_token = ?, webhook_token = ?,
paused = ?, paused = ?,
paused_until = ? paused_until = ?
WHERE id = ? WHERE id = ?
", ",
self.name,
self.nudge, self.nudge,
self.blacklisted,
self.webhook_id, self.webhook_id,
self.webhook_token, self.webhook_token,
self.paused, self.paused,

View File

@@ -43,7 +43,7 @@ pub enum RecordedCommand {
#[serde(rename = "delete")] #[serde(rename = "delete")]
Delete(crate::commands::delete::Options), Delete(crate::commands::delete::Options),
#[serde(rename = "donate")] #[serde(rename = "donate")]
Donate(crate::commands::patreon::info::Options), Donate(crate::commands::donate::Options),
#[serde(rename = "help")] #[serde(rename = "help")]
Help(crate::commands::help::Options), Help(crate::commands::help::Options),
#[serde(rename = "info")] #[serde(rename = "info")]
@@ -111,7 +111,7 @@ impl RecordedCommand {
"clock" => Some(Self::Clock(crate::commands::clock::Options::extract(ctx))), "clock" => Some(Self::Clock(crate::commands::clock::Options::extract(ctx))),
"dashboard" => Some(Self::Dashboard(crate::commands::dashboard::Options::extract(ctx))), "dashboard" => Some(Self::Dashboard(crate::commands::dashboard::Options::extract(ctx))),
"delete" => Some(Self::Delete(crate::commands::delete::Options::extract(ctx))), "delete" => Some(Self::Delete(crate::commands::delete::Options::extract(ctx))),
"donate" => Some(Self::Donate(crate::commands::patreon::info::Options::extract(ctx))), "donate" => Some(Self::Donate(crate::commands::donate::Options::extract(ctx))),
"help" => Some(Self::Help(crate::commands::help::Options::extract(ctx))), "help" => Some(Self::Help(crate::commands::help::Options::extract(ctx))),
"info" => Some(Self::Info(crate::commands::info::Options::extract(ctx))), "info" => Some(Self::Info(crate::commands::info::Options::extract(ctx))),
"look" => Some(Self::Look(crate::commands::look::Options::extract(ctx))), "look" => Some(Self::Look(crate::commands::look::Options::extract(ctx))),

View File

@@ -3,7 +3,7 @@ use sqlx::MySqlPool;
pub struct GuildData { pub struct GuildData {
pub ephemeral_confirmations: bool, pub ephemeral_confirmations: bool,
pub id: u32, pub guild_id: u64,
} }
impl GuildData { impl GuildData {
@@ -13,7 +13,7 @@ impl GuildData {
) -> Result<Self, Box<dyn std::error::Error + Sync + Send>> { ) -> Result<Self, Box<dyn std::error::Error + Sync + Send>> {
if let Ok(c) = sqlx::query_as_unchecked!( if let Ok(c) = sqlx::query_as_unchecked!(
Self, Self,
"SELECT id, ephemeral_confirmations FROM guilds WHERE guild = ?", "SELECT ephemeral_confirmations, id as guild_id FROM guilds WHERE id = ?",
guild_id.get() guild_id.get()
) )
.fetch_one(pool) .fetch_one(pool)
@@ -21,13 +21,13 @@ impl GuildData {
{ {
Ok(c) Ok(c)
} else { } else {
sqlx::query!("INSERT IGNORE INTO guilds (guild) VALUES (?)", guild_id.get()) sqlx::query!("INSERT IGNORE INTO guilds (id) VALUES (?)", guild_id.get())
.execute(&pool.clone()) .execute(&pool.clone())
.await?; .await?;
Ok(sqlx::query_as_unchecked!( Ok(sqlx::query_as_unchecked!(
Self, Self,
"SELECT id, ephemeral_confirmations FROM guilds WHERE guild = ?", "SELECT ephemeral_confirmations, id as guild_id FROM guilds WHERE id = ?",
guild_id.get() guild_id.get()
) )
.fetch_one(pool) .fetch_one(pool)
@@ -39,7 +39,7 @@ impl GuildData {
sqlx::query!( sqlx::query!(
"UPDATE guilds SET ephemeral_confirmations = ? WHERE id = ?", "UPDATE guilds SET ephemeral_confirmations = ? WHERE id = ?",
self.ephemeral_confirmations, self.ephemeral_confirmations,
self.id self.guild_id
) )
.execute(pool) .execute(pool)
.await .await

View File

@@ -68,16 +68,19 @@ impl Data {
guild_id: GuildId, guild_id: GuildId,
) -> Result<Vec<CommandMacro>, Error> { ) -> Result<Vec<CommandMacro>, Error> {
let rows = sqlx::query!( let rows = sqlx::query!(
"SELECT name, description, commands FROM command_macro WHERE guild_id = (SELECT id FROM guilds WHERE guild = ?)", "SELECT name, description, commands FROM command_macro WHERE guild_id = ?",
guild_id.get() guild_id.get()
) )
.fetch_all(&self.database) .fetch_all(&self.database)
.await?.iter().map(|row| CommandMacro { .await?
.iter()
.map(|row| CommandMacro {
guild_id, guild_id,
name: row.name.clone(), name: row.name.clone(),
description: row.description.clone(), description: row.description.clone(),
commands: serde_json::from_str(&row.commands.to_string()).unwrap(), commands: serde_json::from_str(&row.commands.to_string()).unwrap(),
}).collect(); })
.collect();
Ok(rows) Ok(rows)
} }

View File

@@ -16,8 +16,8 @@ use crate::{
}, },
CtxData, CtxData,
}, },
time_parser::{cron_next_timestamp, natural_parser}, time_parser::natural_parser,
utils::check_subscription, utils::{check_guild_subscription, check_subscription},
Context, Database, Error, Context, Database, Error,
}; };
use chrono::{DateTime, NaiveDateTime, Utc}; use chrono::{DateTime, NaiveDateTime, Utc};
@@ -262,7 +262,7 @@ impl Reminder {
channels.id = reminders.channel_id channels.id = reminders.channel_id
WHERE WHERE
`status` = 'pending' AND `status` = 'pending' AND
channels.guild_id = (SELECT id FROM guilds WHERE guild = ?) channels.guild_id = ?
", ",
guild_id.get() guild_id.get()
) )
@@ -486,10 +486,7 @@ pub async fn create_reminder(
let user_data = ctx.author_data().await.unwrap(); let user_data = ctx.author_data().await.unwrap();
let timezone = timezone.unwrap_or(ctx.timezone().await); let timezone = timezone.unwrap_or(ctx.timezone().await);
let time = match cron_next_timestamp(&time, timezone) { let time = natural_parser(&time, &timezone.to_string()).await;
Some(ts) => Some(ts),
None => natural_parser(&time, &timezone.to_string()).await,
};
match time { match time {
Some(time) => { Some(time) => {
@@ -532,8 +529,9 @@ pub async fn create_reminder(
}; };
let (processed_interval, processed_expires) = if let Some(repeat) = &interval { let (processed_interval, processed_expires) = if let Some(repeat) = &interval {
if check_subscription(&ctx, &ctx.data().database, ctx.author().id, ctx.guild_id()) if check_subscription(&ctx, ctx.author().id).await
.await || (ctx.guild_id().is_some()
&& check_guild_subscription(&ctx, ctx.guild_id().unwrap()).await)
{ {
( (
parse_duration(repeat) parse_duration(repeat)
@@ -549,7 +547,7 @@ pub async fn create_reminder(
) )
} else { } else {
ctx.send(CreateReply::default().content( ctx.send(CreateReply::default().content(
"`interval` is only available to Patreon subscribers or self-hosted users", "`repeat` is only available to Patreon subscribers or self-hosted users",
)) ))
.await?; .await?;

View File

@@ -34,10 +34,8 @@ use crate::{
lazy_static! { lazy_static! {
pub static ref TIMEFROM_REGEX: Regex = pub static ref TIMEFROM_REGEX: Regex =
Regex::new(r#"<<timefrom:(?P<time>\d+):(?P<format>.+)?>>"#).unwrap(); Regex::new(r#"<<timefrom:(?P<time>\d+):(?P<format>.+)?>>"#).unwrap();
pub static ref TIMENOW_REGEX: Regex = Regex::new( pub static ref TIMENOW_REGEX: Regex =
r#"<<timenow(?:(?P<sign>[+-])(?P<offset>\d+))?:(?P<timezone>(?:\w|/|_)+?):(?P<format>.+?)?>>"# Regex::new(r#"<<timenow:(?P<timezone>(?:\w|/|_)+):(?P<format>.+)?>>"#).unwrap();
)
.unwrap();
pub static ref LOG_TO_DATABASE: bool = env::var("LOG_TO_DATABASE").map_or(true, |v| v == "1"); pub static ref LOG_TO_DATABASE: bool = env::var("LOG_TO_DATABASE").map_or(true, |v| v == "1");
} }
@@ -66,7 +64,7 @@ fn fmt_displacement(format: &str, seconds: u64) -> String {
} }
pub fn substitute(string: &str) -> String { pub fn substitute(string: &str) -> String {
let new = TIMEFROM_REGEX.replace_all(string, |caps: &Captures| { let new = TIMEFROM_REGEX.replace(string, |caps: &Captures| {
let final_time = caps.name("time").map(|m| m.as_str().parse::<i64>().ok()).flatten(); let final_time = caps.name("time").map(|m| m.as_str().parse::<i64>().ok()).flatten();
let format = caps.name("format").map(|m| m.as_str()); let format = caps.name("format").map(|m| m.as_str());
@@ -94,26 +92,12 @@ pub fn substitute(string: &str) -> String {
}); });
TIMENOW_REGEX TIMENOW_REGEX
.replace_all(&new, |caps: &Captures| { .replace(&new, |caps: &Captures| {
let timezone = caps.name("timezone").map(|m| m.as_str().parse::<Tz>().ok()).flatten(); let timezone = caps.name("timezone").map(|m| m.as_str().parse::<Tz>().ok()).flatten();
let format = caps.name("format").map(|m| m.as_str()); let format = caps.name("format").map(|m| m.as_str());
let sign = caps.name("sign").map(|m| m.as_str());
let offset = caps.name("offset").map(|m| m.as_str().parse::<i64>().ok()).flatten();
if let (Some(timezone), Some(format)) = (timezone, format) { if let (Some(timezone), Some(format)) = (timezone, format) {
let mut now = Utc::now().with_timezone(&timezone); let now = Utc::now().with_timezone(&timezone);
if let (Some(sign), Some(offset)) = (sign, offset) {
now = now
.checked_add_signed(TimeDelta::seconds(
offset * {
match sign {
"-" => -1,
_ => 1,
}
},
))
.unwrap_or(now)
}
now.format(format).to_string() now.format(format).to_string()
} else { } else {

View File

@@ -3,8 +3,6 @@ use poise::{
CreateReply, CreateReply,
}; };
use serde_json::Value; use serde_json::Value;
use serenity::all::Http;
use serenity::http::CacheHttp;
use tokio::sync::Mutex; use tokio::sync::Mutex;
use crate::{Data, Error}; use crate::{Data, Error};
@@ -21,12 +19,6 @@ pub(crate) struct TestContext<'a> {
pub(crate) shard_id: usize, pub(crate) shard_id: usize,
} }
impl CacheHttp for TestContext<'_> {
fn http(&self) -> &Http {
todo!()
}
}
pub(crate) struct MockUser { pub(crate) struct MockUser {
pub(crate) id: UserId, pub(crate) id: UserId,
} }

View File

@@ -6,7 +6,6 @@ use std::{
use chrono::{DateTime, Datelike, Timelike, Utc}; use chrono::{DateTime, Datelike, Timelike, Utc};
use chrono_tz::Tz; use chrono_tz::Tz;
use cron_parser::parse;
use tokio::process::Command; use tokio::process::Command;
use crate::consts::{LOCAL_TIMEZONE, PYTHON_LOCATION}; use crate::consts::{LOCAL_TIMEZONE, PYTHON_LOCATION};
@@ -220,7 +219,3 @@ pub async fn natural_parser(time: &str, timezone: &str) -> Option<i64> {
}) })
.and_then(|inner| if inner < 0 { None } else { Some(inner) }) .and_then(|inner| if inner < 0 { None } else { Some(inner) })
} }
pub fn cron_next_timestamp(expr: &str, timezone: Tz) -> Option<i64> {
parse(expr, &Utc::now().with_timezone(&timezone)).ok().map(|next| next.timestamp() as i64)
}

View File

@@ -9,62 +9,10 @@ use poise::{
use crate::{ use crate::{
consts::{CNC_GUILD, SUBSCRIPTION_ROLES}, consts::{CNC_GUILD, SUBSCRIPTION_ROLES},
ApplicationContext, Context, Database, Error, ApplicationContext, Context, Error,
}; };
/// Check if this user/guild combination should be considered subscribed. pub async fn check_subscription(cache_http: impl CacheHttp, user_id: impl Into<UserId>) -> bool {
/// If the guild has a patreon linked, check the user involved in the link.
/// Otherwise, check the user and the guild's owner
pub async fn check_subscription(
ctx: impl CacheHttp,
database: impl Executor<'_, Database = Database>,
user_id: UserId,
guild_id: Option<GuildId>,
) -> bool {
if let Some(subscription_guild) = *CNC_GUILD {
let user_subscribed = check_user_subscription(&ctx, user_id).await;
let owner_subscribed = match guild_id {
Some(guild_id) => {
if let Some(owner) = ctx.cache().unwrap().guild(guild_id).map(|g| g.owner_id) {
check_user_subscription(&ctx, owner).await
} else {
false
}
}
None => false,
};
let link_subscribed = match guild_id {
Some(guild_id) => {
if let Ok(row) = sqlx::query!(
"SELECT user_id FROM patreon_link WHERE guild_id = ?",
guild_id.get()
)
.fetch_one(database)
.await
{
check_user_subscription(&ctx, row.user_id).await
} else {
false
}
}
None => false,
};
user_subscribed || owner_subscribed || link_subscribed
} else {
true
}
}
/// Check a user's subscription status, ignoring Patreon linkage
pub async fn check_user_subscription(
cache_http: impl CacheHttp,
user_id: impl Into<UserId>,
) -> bool {
if let Some(subscription_guild) = *CNC_GUILD { if let Some(subscription_guild) = *CNC_GUILD {
let guild_member = GuildId::new(subscription_guild).member(cache_http, user_id).await; let guild_member = GuildId::new(subscription_guild).member(cache_http, user_id).await;
@@ -82,6 +30,17 @@ pub async fn check_user_subscription(
} }
} }
pub async fn check_guild_subscription(
cache_http: impl CacheHttp,
guild_id: impl Into<GuildId>,
) -> bool {
if let Some(owner) = cache_http.cache().unwrap().guild(guild_id).map(|g| g.owner_id) {
check_subscription(&cache_http, owner).await
} else {
false
}
}
pub fn reply_to_interaction_response_message( pub fn reply_to_interaction_response_message(
reply: CreateReply, reply: CreateReply,
) -> CreateInteractionResponseMessage { ) -> CreateInteractionResponseMessage {
@@ -117,7 +76,6 @@ pub trait Extract {
} }
pub use extract_derive::Extract; pub use extract_derive::Extract;
use sqlx::Executor;
macro_rules! extract_arg { macro_rules! extract_arg {
($ctx:ident, $name:ident, String) => { ($ctx:ident, $name:ident, String) => {

View File

@@ -92,8 +92,6 @@ enum Error {
SQLx(sqlx::Error), SQLx(sqlx::Error),
#[allow(unused)] #[allow(unused)]
Serenity(serenity::Error), Serenity(serenity::Error),
#[allow(unused)]
MissingDiscordPermission(&'static str),
} }
pub async fn initialize( pub async fn initialize(
@@ -126,7 +124,6 @@ pub async fn initialize(
let static_path = let static_path =
if Path::new("static").exists() { "static" } else { "/lib/reminder-rs/static" }; if Path::new("static").exists() { "static" } else { "/lib/reminder-rs/static" };
info!("Using static path: {}", static_path);
rocket::build() rocket::build()
.attach(MetricProducer) .attach(MetricProducer)
@@ -234,6 +231,39 @@ pub async fn initialize(
Ok(()) Ok(())
} }
pub async fn check_subscription(cache_http: impl CacheHttp, user_id: impl Into<UserId>) -> bool {
offline!(true);
if let Some(subscription_guild) = *CNC_GUILD {
let guild_member = GuildId::new(subscription_guild).member(cache_http, user_id).await;
if let Ok(member) = guild_member {
for role in member.roles {
if SUBSCRIPTION_ROLES.contains(&role.get()) {
return true;
}
}
}
false
} else {
true
}
}
pub async fn check_guild_subscription(
cache_http: impl CacheHttp,
guild_id: impl Into<GuildId>,
) -> bool {
offline!(true);
if let Some(owner) = cache_http.cache().unwrap().guild(guild_id).map(|guild| guild.owner_id) {
check_subscription(&cache_http, owner).await
} else {
false
}
}
pub async fn check_authorization( pub async fn check_authorization(
cookies: &CookieJar<'_>, cookies: &CookieJar<'_>,
ctx: &Context, ctx: &Context,

View File

@@ -7,43 +7,38 @@ pub mod todos;
use std::env; use std::env;
use crate::utils::check_subscription;
use crate::web::guards::transaction::Transaction;
use crate::web::{check_authorization, routes::JsonResult};
pub use channels::get_guild_channels; pub use channels::get_guild_channels;
pub use emojis::get_guild_emojis; pub use emojis::get_guild_emojis;
pub use reminders::*; pub use reminders::*;
use rocket::{get, http::CookieJar, serde::json::json, State}; use rocket::{get, http::CookieJar, serde::json::json, State};
pub use roles::get_guild_roles; pub use roles::get_guild_roles;
use serenity::all::UserId;
use serenity::{ use serenity::{
client::Context, client::Context,
model::id::{GuildId, RoleId}, model::id::{GuildId, RoleId},
}; };
pub use templates::*; pub use templates::*;
use crate::web::{check_authorization, routes::JsonResult};
#[get("/api/guild/<id>")] #[get("/api/guild/<id>")]
pub async fn get_guild_info( pub async fn get_guild_info(id: u64, cookies: &CookieJar<'_>, ctx: &State<Context>) -> JsonResult {
id: u64,
cookies: &CookieJar<'_>,
ctx: &State<Context>,
mut transaction: Transaction<'_>,
) -> JsonResult {
offline!(Ok(json!({ "patreon": true, "name": "Guild" }))); offline!(Ok(json!({ "patreon": true, "name": "Guild" })));
check_authorization(cookies, ctx.inner(), id).await?; check_authorization(cookies, ctx.inner(), id).await?;
let user_id = match GuildId::new(id)
cookies.get_private("userid").map(|c| c.value().parse::<u64>().ok()).flatten().unwrap(); .to_guild_cached(ctx.inner())
.map(|guild| (guild.owner_id, guild.name.clone()))
{
Some((owner_id, name)) => {
let member_res = GuildId::new(env::var("PATREON_GUILD_ID").unwrap().parse().unwrap())
.member(&ctx.inner(), owner_id)
.await;
match GuildId::new(id).to_guild_cached(ctx.inner()).map(|guild| guild.name.clone()) { let patreon = member_res.map_or(false, |member| {
Some(name) => { member
let patreon = check_subscription( .roles
ctx.inner(), .contains(&RoleId::new(env::var("PATREON_ROLE_ID").unwrap().parse().unwrap()))
transaction.executor(), });
UserId::from(user_id),
Some(GuildId::from(id)),
)
.await;
Ok(json!({ "patreon": patreon, "name": name })) Ok(json!({ "patreon": patreon, "name": name }))
} }

View File

@@ -12,9 +12,8 @@ use serenity::{
}; };
use sqlx::{MySql, Pool}; use sqlx::{MySql, Pool};
use crate::utils::check_subscription;
use crate::web::{ use crate::web::{
check_authorization, check_authorization, check_guild_subscription, check_subscription,
consts::MIN_INTERVAL, consts::MIN_INTERVAL,
guards::transaction::Transaction, guards::transaction::Transaction,
routes::{ routes::{
@@ -187,13 +186,8 @@ pub async fn edit_reminder(
|| reminder.interval_months.flatten().is_some() || reminder.interval_months.flatten().is_some()
|| reminder.interval_seconds.flatten().is_some() || reminder.interval_seconds.flatten().is_some()
{ {
if check_subscription( if check_guild_subscription(&ctx.inner(), id).await
ctx.inner(), || check_subscription(&ctx.inner(), user_id).await
transaction.executor(),
UserId::from(user_id),
Some(GuildId::from(id)),
)
.await
{ {
let new_interval_length = match reminder.interval_days { let new_interval_length = match reminder.interval_days {
Some(interval) => interval.unwrap_or(0), Some(interval) => interval.unwrap_or(0),
@@ -311,15 +305,7 @@ pub async fn edit_reminder(
Err(e) => { Err(e) => {
warn!("`create_database_channel` returned an error code: {:?}", e); warn!("`create_database_channel` returned an error code: {:?}", e);
// Provide more specific error messages based on the error type error.push("Failed to configure channel for reminders. Please check the bot permissions".to_string());
match e {
crate::web::Error::MissingDiscordPermission(permission) => {
error.push(format!("Please ensure the bot has the \"{}\" permission in the channel", permission));
}
_ => {
error.push("Failed to configure channel for reminders. Please check the bot permissions".to_string());
}
}
} }
} }
} }

View File

@@ -31,7 +31,7 @@ pub async fn get_reminder_templates(
match sqlx::query_as_unchecked!( match sqlx::query_as_unchecked!(
ReminderTemplate, ReminderTemplate,
"SELECT * FROM reminder_template WHERE guild_id = (SELECT id FROM guilds WHERE guild = ?)", "SELECT * FROM reminder_template WHERE guild_id = ?",
id id
) )
.fetch_all(pool.inner()) .fetch_all(pool.inner())
@@ -87,7 +87,7 @@ pub async fn delete_reminder_template(
check_authorization(cookies, ctx.inner(), id).await?; check_authorization(cookies, ctx.inner(), id).await?;
match sqlx::query!( match sqlx::query!(
"DELETE FROM reminder_template WHERE guild_id = (SELECT id FROM guilds WHERE guild = ?) AND id = ?", "DELETE FROM reminder_template WHERE guild_id = ? AND id = ?",
id, delete_reminder_template.id id, delete_reminder_template.id
) )
.fetch_all(pool.inner()) .fetch_all(pool.inner())

View File

@@ -66,7 +66,7 @@ pub async fn create_todo(
" "
INSERT INTO todos (guild_id, channel_id, value) INSERT INTO todos (guild_id, channel_id, value)
VALUES ( VALUES (
(SELECT id FROM guilds WHERE guild = ?), ?,
(SELECT id FROM channels WHERE channel = ?), (SELECT id FROM channels WHERE channel = ?),
? ?
) )
@@ -88,7 +88,7 @@ pub async fn create_todo(
" "
INSERT INTO todos (guild_id, channel_id, value) INSERT INTO todos (guild_id, channel_id, value)
VALUES ( VALUES (
(SELECT id FROM guilds WHERE guild = ?), ?,
NULL, NULL,
? ?
) )
@@ -130,11 +130,9 @@ pub async fn get_todo(
channels.channel AS channel_id, channels.channel AS channel_id,
value value
FROM todos FROM todos
INNER JOIN guilds
ON guilds.id = todos.guild_id
LEFT JOIN channels LEFT JOIN channels
ON channels.id = todos.channel_id ON channels.id = todos.channel_id
WHERE guilds.guild = ? WHERE todos.guild_id = ?
", ",
id id
) )
@@ -167,7 +165,7 @@ pub async fn update_todo(
" "
UPDATE todos UPDATE todos
SET value = ? SET value = ?
WHERE guild_id = (SELECT id FROM guilds WHERE guild = ?) WHERE guild_id = ?
AND id = ? AND id = ?
", ",
todo.value, todo.value,
@@ -202,7 +200,7 @@ pub async fn delete_todo(
sqlx::query!( sqlx::query!(
" "
DELETE FROM todos DELETE FROM todos
WHERE guild_id = (SELECT id FROM guilds WHERE guild = ?) WHERE guild_id = ?
AND id = ? AND id = ?
", ",
guild_id, guild_id,

View File

@@ -6,8 +6,8 @@ use serde::{Deserialize, Serialize};
use serenity::{client::Context, model::id::UserId}; use serenity::{client::Context, model::id::UserId};
use sqlx::types::Json; use sqlx::types::Json;
use crate::utils::check_subscription;
use crate::web::{ use crate::web::{
check_subscription,
consts::{ consts::{
DAY, MAX_CONTENT_LENGTH, MAX_EMBED_AUTHOR_LENGTH, MAX_EMBED_DESCRIPTION_LENGTH, DAY, MAX_CONTENT_LENGTH, MAX_EMBED_AUTHOR_LENGTH, MAX_EMBED_DESCRIPTION_LENGTH,
MAX_EMBED_FIELDS, MAX_EMBED_FIELD_TITLE_LENGTH, MAX_EMBED_FIELD_VALUE_LENGTH, MAX_EMBED_FIELDS, MAX_EMBED_FIELD_TITLE_LENGTH, MAX_EMBED_FIELD_VALUE_LENGTH,
@@ -65,16 +65,7 @@ pub async fn create_reminder(
if let Err(e) = channel { if let Err(e) = channel {
warn!("`create_database_channel` returned an error code: {:?}", e); warn!("`create_database_channel` returned an error code: {:?}", e);
// Provide more specific error messages based on the error type return Err(json!({"error": "Failed to configure channel for reminders."}));
let error_msg = match e {
Error::MissingDiscordPermission(permission) => format!(
"Please ensure the bot has the \"{}\" permission in the channel",
permission
),
_ => "Failed to configure channel for reminders.".to_string(),
};
return Err(json!({"error": error_msg}));
} }
let channel = channel.unwrap(); let channel = channel.unwrap();
@@ -131,7 +122,7 @@ pub async fn create_reminder(
|| reminder.interval_days.is_some() || reminder.interval_days.is_some()
|| reminder.interval_months.is_some() || reminder.interval_months.is_some()
{ {
if !check_subscription(&ctx, transaction.executor(), user_id, None).await { if !check_subscription(&ctx, user_id).await {
return Err(json!({"error": "Patreon is required to set intervals"})); return Err(json!({"error": "Patreon is required to set intervals"}));
} }
} }

View File

@@ -9,8 +9,8 @@ use rocket::{
use serenity::{client::Context, model::id::UserId}; use serenity::{client::Context, model::id::UserId};
use sqlx::{MySql, Pool}; use sqlx::{MySql, Pool};
use crate::utils::check_subscription;
use crate::web::{ use crate::web::{
check_subscription,
guards::transaction::Transaction, guards::transaction::Transaction,
routes::{ routes::{
dashboard::{ dashboard::{
@@ -162,9 +162,7 @@ pub async fn edit_reminder(
|| reminder.interval_months.flatten().is_some() || reminder.interval_months.flatten().is_some()
|| reminder.interval_seconds.flatten().is_some() || reminder.interval_seconds.flatten().is_some()
{ {
if check_subscription(&ctx.inner(), transaction.executor(), UserId::from(user_id), None) if check_subscription(&ctx.inner(), user_id).await {
.await
{
let new_interval_length = match reminder.interval_days { let new_interval_length = match reminder.interval_days {
Some(interval) => interval.unwrap_or(0), Some(interval) => interval.unwrap_or(0),
None => sqlx::query!( None => sqlx::query!(

View File

@@ -39,28 +39,31 @@ pub async fn export(
match sqlx::query_as_unchecked!( match sqlx::query_as_unchecked!(
ReminderTemplateCsv, ReminderTemplateCsv,
"SELECT "
name, SELECT
attachment, name,
attachment_name, attachment,
avatar, attachment_name,
content, avatar,
embed_author, content,
embed_author_url, embed_author,
embed_color, embed_author_url,
embed_description, embed_color,
embed_footer, embed_description,
embed_footer_url, embed_footer,
embed_image_url, embed_footer_url,
embed_thumbnail_url, embed_image_url,
embed_title, embed_thumbnail_url,
embed_fields, embed_title,
interval_seconds, embed_fields,
interval_days, interval_seconds,
interval_months, interval_days,
tts, interval_months,
username tts,
FROM reminder_template WHERE guild_id = (SELECT id FROM guilds WHERE guild = ?)", username
FROM reminder_template
WHERE guild_id = ?
",
id id
) )
.fetch_all(pool.inner()) .fetch_all(pool.inner())

View File

@@ -38,10 +38,11 @@ pub async fn export(
match sqlx::query_as_unchecked!( match sqlx::query_as_unchecked!(
TodoCsv, TodoCsv,
"SELECT value, CONCAT('#', channels.channel) AS channel_id FROM todos "
SELECT value, CONCAT('#', channels.channel) AS channel_id FROM todos
LEFT JOIN channels ON todos.channel_id = channels.id LEFT JOIN channels ON todos.channel_id = channels.id
INNER JOIN guilds ON todos.guild_id = guilds.id INNER JOIN guilds ON todos.guild_id = ?
WHERE guilds.guild = ?", ",
id id
) )
.fetch_all(pool.inner()) .fetch_all(pool.inner())
@@ -96,7 +97,7 @@ pub async fn import(
Ok(body) => { Ok(body) => {
let mut reader = csv::Reader::from_reader(body.as_slice()); let mut reader = csv::Reader::from_reader(body.as_slice());
let query_placeholder = "(?, (SELECT id FROM channels WHERE channel = ?), (SELECT id FROM guilds WHERE guild = ?))"; let query_placeholder = "(?, (SELECT id FROM channels WHERE channel = ?), ?)";
let mut query_params = vec![]; let mut query_params = vec![];
for result in reader.deserialize::<TodoCsv>() { for result in reader.deserialize::<TodoCsv>() {

View File

@@ -10,7 +10,6 @@ use rocket::{
use rocket_dyn_templates::Template; use rocket_dyn_templates::Template;
use secrecy::ExposeSecret; use secrecy::ExposeSecret;
use serde::{de, Deserialize, Deserializer, Serialize, Serializer}; use serde::{de, Deserialize, Deserializer, Serialize, Serializer};
use serenity::http::HttpError;
use serenity::{ use serenity::{
all::CacheHttp, all::CacheHttp,
builder::CreateWebhook, builder::CreateWebhook,
@@ -20,9 +19,9 @@ use serenity::{
use sqlx::types::Json; use sqlx::types::Json;
use sqlx::FromRow; use sqlx::FromRow;
use crate::utils::check_subscription;
use crate::web::{ use crate::web::{
catchers::internal_server_error, catchers::internal_server_error,
check_guild_subscription, check_subscription,
consts::{ consts::{
CHARACTERS, DAY, DEFAULT_AVATAR, MAX_CONTENT_LENGTH, MAX_EMBED_AUTHOR_LENGTH, CHARACTERS, DAY, DEFAULT_AVATAR, MAX_CONTENT_LENGTH, MAX_EMBED_AUTHOR_LENGTH,
MAX_EMBED_DESCRIPTION_LENGTH, MAX_EMBED_FIELDS, MAX_EMBED_FIELD_TITLE_LENGTH, MAX_EMBED_DESCRIPTION_LENGTH, MAX_EMBED_FIELDS, MAX_EMBED_FIELD_TITLE_LENGTH,
@@ -374,12 +373,12 @@ pub(crate) async fn create_reminder(
reminder: CreateReminder, reminder: CreateReminder,
) -> JsonResult { ) -> JsonResult {
// check guild in db // check guild in db
match sqlx::query!("SELECT 1 as A FROM guilds WHERE guild = ?", guild_id.get()) match sqlx::query!("SELECT 1 as A FROM guilds WHERE id = ?", guild_id.get())
.fetch_one(transaction.executor()) .fetch_one(transaction.executor())
.await .await
{ {
Err(sqlx::Error::RowNotFound) => { Err(sqlx::Error::RowNotFound) => {
if sqlx::query!("INSERT INTO guilds (guild) VALUES (?)", guild_id.get()) if sqlx::query!("INSERT INTO guilds (id) VALUES (?)", guild_id.get())
.execute(transaction.executor()) .execute(transaction.executor())
.await .await
.is_err() .is_err()
@@ -405,19 +404,9 @@ pub(crate) async fn create_reminder(
if let Err(e) = channel { if let Err(e) = channel {
warn!("`create_database_channel` returned an error code: {:?}", e); warn!("`create_database_channel` returned an error code: {:?}", e);
// Provide more specific error messages based on the error type return Err(
let error_msg = match e { json!({"error": "Failed to configure channel for reminders. Please check the bot permissions"}),
Error::MissingDiscordPermission(permission) => { );
format!(
"Please ensure the bot has the \"{}\" permission in the channel",
permission
)
}
_ => "Failed to configure channel for reminders. Please check the bot permissions"
.to_string(),
};
return Err(json!({"error": error_msg}));
} }
let channel = channel.unwrap(); let channel = channel.unwrap();
@@ -477,7 +466,9 @@ pub(crate) async fn create_reminder(
|| reminder.interval_days.is_some() || reminder.interval_days.is_some()
|| reminder.interval_months.is_some() || reminder.interval_months.is_some()
{ {
if !check_subscription(&ctx, transaction.executor(), user_id, Some(guild_id)).await { if !check_guild_subscription(&ctx, guild_id).await
&& !check_subscription(&ctx, user_id).await
{
return Err(json!({"error": "Patreon is required to set intervals"})); return Err(json!({"error": "Patreon is required to set intervals"}));
} }
} }
@@ -668,7 +659,7 @@ pub(crate) async fn create_reminder_template(
interval_months, interval_months,
tts, tts,
username username
) VALUES ((SELECT id FROM guilds WHERE guild = ?), ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?,
?, ?, ?, ?, ?, ?, ?)", ?, ?, ?, ?, ?, ?, ?)",
guild_id.get(), guild_id.get(),
name, name,
@@ -725,36 +716,13 @@ async fn create_database_channel(
match row { match row {
Ok(row) => { Ok(row) => {
let is_dm = channel let is_dm =
.to_channel(&ctx) channel.to_channel(&ctx).await.map_err(|e| Error::Serenity(e))?.private().is_some();
.await
.map_err(|e| {
if let serenity::Error::Http(http_error) = &e {
if let HttpError::UnsuccessfulRequest(response) = http_error {
if response.error.code == 50001 {
return Error::MissingDiscordPermission("View Channel");
}
}
}
Error::Serenity(e)
})?
.private()
.is_some();
if !is_dm && (row.webhook_token.is_none() || row.webhook_id.is_none()) { if !is_dm && (row.webhook_token.is_none() || row.webhook_id.is_none()) {
let webhook = channel let webhook = channel
.create_webhook(&ctx, CreateWebhook::new("Reminder").avatar(&*DEFAULT_AVATAR)) .create_webhook(&ctx, CreateWebhook::new("Reminder").avatar(&*DEFAULT_AVATAR))
.await .await
.map_err(|e| match &e { .map_err(|e| Error::Serenity(e))?;
serenity::Error::Http(HttpError::UnsuccessfulRequest(response)) => {
match response.error.code {
50001 => Error::MissingDiscordPermission("View Channel"),
50013 => Error::MissingDiscordPermission("Manage Webhooks"),
_ => Error::Serenity(e),
}
}
_ => Error::Serenity(e),
})?;
let token = webhook.token.unwrap(); let token = webhook.token.unwrap();
@@ -779,16 +747,7 @@ async fn create_database_channel(
let webhook = channel let webhook = channel
.create_webhook(&ctx, CreateWebhook::new("Reminder").avatar(&*DEFAULT_AVATAR)) .create_webhook(&ctx, CreateWebhook::new("Reminder").avatar(&*DEFAULT_AVATAR))
.await .await
.map_err(|e| match &e { .map_err(|e| Error::Serenity(e))?;
serenity::Error::Http(HttpError::UnsuccessfulRequest(response)) => {
match response.error.code {
50001 => Error::MissingDiscordPermission("View Channel"),
50013 => Error::MissingDiscordPermission("Manage Webhooks"),
_ => Error::Serenity(e),
}
}
_ => Error::Serenity(e),
})?;
let token = webhook.token.unwrap(); let token = webhook.token.unwrap();
@@ -847,15 +806,22 @@ pub async fn todos_redirect(id: &str) -> Redirect {
#[get("/")] #[get("/")]
pub async fn dashboard_home(cookies: &CookieJar<'_>) -> DashboardPage { pub async fn dashboard_home(cookies: &CookieJar<'_>) -> DashboardPage {
render_dashboard(cookies).await if cookies.get_private("userid").is_some() {
} match NamedFile::open(Path::new(path!("static/index.html"))).await {
Ok(f) => DashboardPage::Ok(f),
#[get("/<_..>")] Err(e) => {
pub async fn dashboard(cookies: &CookieJar<'_>) -> DashboardPage { warn!("Couldn't render dashboard: {:?}", e);
render_dashboard(cookies).await
} DashboardPage::NotConfigured(internal_server_error().await)
}
async fn render_dashboard(cookies: &CookieJar<'_>) -> DashboardPage { }
} else {
DashboardPage::Unauthorised(Redirect::to("/login/discord"))
}
}
#[get("/<_..>")]
pub async fn dashboard(cookies: &CookieJar<'_>) -> DashboardPage {
if cookies.get_private("userid").is_some() { if cookies.get_private("userid").is_some() {
match NamedFile::open(Path::new(path!("static/index.html"))).await { match NamedFile::open(Path::new(path!("static/index.html"))).await {
Ok(f) => DashboardPage::Ok(f), Ok(f) => DashboardPage::Ok(f),

1
static/assets Symbolic link
View File

@@ -0,0 +1 @@
/home/jude/reminder-bot/reminder-dashboard/dist/static/assets

View File

@@ -1,34 +0,0 @@
<!DOCTYPE html>
<html lang="EN">
<head>
<meta name="description" content="The most powerful Discord Reminders Bot">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta charset="UTF-8">
<meta name="yandex-verification" content="bb77b8681eb64a90"/>
<meta name="google-site-verification" content="7h7UVTeEe0AOzHiH3cFtsqMULYGN-zCZdMT_YCkW1Ho"/>
<!-- <meta http-equiv="Content-Security-Policy" content="default-src 'self'; img-src *; font-src fonts.gstatic.com 'self'"> -->
<!-- favicon -->
<link rel="apple-touch-icon" sizes="180x180"
href="/static/favicon/apple-touch-icon.png">
<link rel="icon" type="image/png" sizes="32x32"
href="/static/favicon/favicon-32x32.png">
<link rel="icon" type="image/png" sizes="16x16"
href="/static/favicon/favicon-16x16.png">
<link rel="manifest" href="/static/site.webmanifest">
<meta name="msapplication-TileColor" content="#da532c">
<meta name="theme-color" content="#ffffff">
<title>Reminder Bot | Dashboard</title>
<!-- styles -->
<link rel="stylesheet" href="/static/css/fa.css">
<link rel="stylesheet" href="/static/css/font.css">
<link rel="stylesheet" href="/static/css/style.css">
<script type="module" crossorigin src="/static/assets/index-B8f0viPI.js"></script>
<link rel="stylesheet" crossorigin href="/static/assets/index-BZ8NJuKt.css">
</head>
<body>
<div id="app"></div>
</body>
</html>

1
static/index.html Symbolic link
View File

@@ -0,0 +1 @@
/home/jude/reminder-bot/reminder-dashboard/dist/index.html

View File

@@ -19,24 +19,6 @@
Fill out the "time" and "content" fields. If you wish, press on "Optional" to view other options Fill out the "time" and "content" fields. If you wish, press on "Optional" to view other options
for the reminder. for the reminder.
</p> </p>
<p class="subtitle">Time</p>
<p class="content">
The bot will take a "best-guess" at what time you entered. It will favour UK date formats
over US date formats (MM/DD/YY) where possible.
<br>
You can also use <code>cron</code>-like syntax to specify the time. For example, using
<code>0 0 1 * *</code> will send the reminder at midnight on the first of the next month.
For more information on cron syntax, see <a href="https://crontab.guru/">crontab.guru</a>.
<br>
<strong>Cron syntax is not repeating</strong>. Please use the optional "interval" field to specify a repetition interval.
</p>
<p class="subtitle">Pings</p>
<p class="content">
Roles and users can be pinged by including their @ mention in the "content" field.
To ping a role, the role must be set as mentionable, and the bot must have permissions to mention the role.
<br>
Please note that when using the dashboard, roles can only be pinged in the "Content..." field and not the embed fields.
</p>
</div> </div>
</div> </div>
</section> </section>
@@ -55,40 +37,4 @@
</div> </div>
</section> </section>
<section class="hero is-small">
<div class="hero-body">
<div class="container">
<p class="title">Custom formatting rules</p>
<p class="content">
Reminder content can be customized using formatting rules.
</p>
<p class="subtitle">timefrom</p>
<p class="content">
The <code>timefrom</code> formatting rule will display a formatted difference
between the time the reminder sends and a specified time.
<br>
For example, if the current time is 1755800000 (UNIX time), the format string
<code>&lt;&lt;timefrom:1755803600&gt;&gt;</code> would display "1 hour"
</p>
<p class="subtitle">timenow</p>
<p class="content">
The <code>timenow</code> formatting rule displays the current time or an offset
from the current time in a given timezone in a custom format.
<br>
For example, if the current time is 1755800000 (UNIX time), the format string
<code>&lt;&lt;timenow:UTC:%H:%M:%S&gt;&gt;</code> would display "18:13:20"
<br>
Optionally, an offset can be provided to display a time from your current time.
For example, if the current time is 1755800000 (UNIX time), the format string
<code>&lt;&lt;timenow+120:UTC:%H:%M:%S&gt;&gt;</code> would display "18:15:20",
or <code>&lt;&lt;timenow-120:UTC:%H:%M:%S&gt;&gt;</code> would display "18:11:20"
<br>
You can use this feature alongside Discord's timestamp formatting. The following
will show the text "in 2 minutes" for all users as a Discord timestamp:
<code>&lt;t:&lt;&lt;timenow+120:UTC:%s&gt;&gt;:R&gt;</code>
</p>
</div>
</div>
</section>
{% endblock %} {% endblock %}