Compare commits

...
Author SHA1 Message Date
jude 439575acd0 Extend message features 2026-10-11 13:16:02 +01:00
jude 2665c61d33 Move message content to message_json 2026-10-11 12:43:43 +01:00
jude 644adc3f87 Bump version 2026-10-10 20:07:36 +01:00
jude f41846d88b Don't attempt to send "empty" embeds 2026-10-10 20:03:15 +01:00
jude d77ff6b216 2nd attempt 2026-05-25 20:29:14 +01:00
jude b1fda50d4c Fix re-rendering issue 2026-05-23 18:15:41 +01:00
jude 05c7b9151c Remove column drop 2026-05-21 20:15:43 +01:00
jude f1e0a7cdf8 Disable cache lookup for users 2026-05-21 20:09:06 +01:00
jude 050277ac8b Add dark mode support 2026-05-16 19:17:29 +01:00
jude 9c6c324b02 Remove npm build as its done by build.rs 2025-12-22 16:54:21 +00:00
jude d7065cf0d6 Remove ignored file 2025-12-17 18:32:16 +00:00
jude b0c6eedba9 Update run containerfile for /remind support 2025-11-26 18:35:20 +00:00
jude 77690b57c6 Merge pull request 'jude/docker' (#5) from jude/docker into current
Reviewed-on: #5
2025-11-23 10:34:03 +00:00
jude b273d8035a Use Caddy to serve localhost HTTPS 2025-11-23 10:32:37 +00:00
jude a4ec39e4a2 Fix dashboard rendering 2025-11-09 16:07:14 +00:00
jude 901cf575c4 Docker compose setup 2025-11-05 18:30:49 +00:00
jude e98cb67f5f Bump version 2025-10-27 17:53:47 +00:00
jude 1d8fd39d13 Apply patreon sharing across web/bot 2025-10-27 17:52:31 +00:00
jude 91310d47d3 Add patreon-sharing option 2025-10-04 18:09:31 +01:00
jude 5ae4baa2a6 Bump version 2025-09-16 21:09:25 +01:00
jude 6884adc5b2 Add some docs 2025-09-16 21:06:51 +01:00
jude 6ade91e11b Add cron parser for start time of a reminder 2025-09-16 21:00:58 +01:00
jude 20f0fb1c20 Merge pull request 'jude/custom-timestamp-formatting' (#4) from jude/custom-timestamp-formatting into current
Reviewed-on: #4
2025-09-16 19:19:07 +00:00
jude 4d14365f2b Add another example 2025-08-22 20:14:28 +01:00
jude 0f4df703eb Fix formatting strings 2025-08-21 22:51:57 +01:00
jude a9edcec43c Deduplicate dashboard frontend code 2025-06-24 19:56:45 +01:00
jude cc5f6d9d55 Bump version 2025-06-18 22:13:05 +01:00
jude 761d545496 Improve errors and wording 2025-06-18 22:08:32 +01:00
100 changed files with 3598 additions and 1468 deletions

No files matched your search

+3 -2
View File
@@ -3,8 +3,8 @@ target
/venv
.cargo
.idea
web/static/index.html
web/static/assets
static/index.html
static/assets
# Logs
logs
*.log
@@ -28,3 +28,4 @@ dist-ssr
*.njsproj
*.sln
*.sw?
.junie
@@ -0,0 +1,84 @@
{
"db_name": "MySQL",
"query": "\n SELECT id, channel, nudge, webhook_id, webhook_token, paused,\n paused_until\n FROM channels\n WHERE channel = ?\n ",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "id",
"type_info": {
"type": "Long",
"flags": "NOT_NULL | PRIMARY_KEY | UNIQUE_KEY | UNSIGNED | AUTO_INCREMENT",
"max_size": 10
}
},
{
"ordinal": 1,
"name": "channel",
"type_info": {
"type": "LongLong",
"flags": "NOT_NULL | UNIQUE_KEY | UNSIGNED | NO_DEFAULT_VALUE",
"max_size": 20
}
},
{
"ordinal": 2,
"name": "nudge",
"type_info": {
"type": "Short",
"flags": "NOT_NULL",
"max_size": 6
}
},
{
"ordinal": 3,
"name": "webhook_id",
"type_info": {
"type": "LongLong",
"flags": "UNIQUE_KEY | UNSIGNED",
"max_size": 20
}
},
{
"ordinal": 4,
"name": "webhook_token",
"type_info": {
"type": "Blob",
"flags": "BLOB",
"max_size": 262140
}
},
{
"ordinal": 5,
"name": "paused",
"type_info": {
"type": "Tiny",
"flags": "NOT_NULL",
"max_size": 1
}
},
{
"ordinal": 6,
"name": "paused_until",
"type_info": {
"type": "Timestamp",
"flags": "BINARY",
"max_size": 19
}
}
],
"parameters": {
"Right": 1
},
"nullable": [
false,
false,
false,
true,
true,
false,
true
]
},
"hash": "017b3f73101926a3fe029ed75c412ec902aa74fee334c23fd75fe0cde1e324ca"
}
@@ -0,0 +1,12 @@
{
"db_name": "MySQL",
"query": "DELETE FROM patreon_link WHERE user_id = ?",
"describe": {
"columns": [],
"parameters": {
"Right": 1
},
"nullable": []
},
"hash": "0402e16b1ec89a96d893d43f6b40500ccbde3c619116a702c87954df49898e23"
}
@@ -1,12 +0,0 @@
{
"db_name": "MySQL",
"query": "\n UPDATE reminders SET `status` = 'deleted' WHERE FIND_IN_SET(id, ?)\n ",
"describe": {
"columns": [],
"parameters": {
"Right": 1
},
"nullable": []
},
"hash": "09f6269e5df3acc01e8e2660532b5fcab21c0cd4fd126b580176d24578932d7e"
}
@@ -1,19 +1,10 @@
{
"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'",
"query": "\n SELECT\n reminders.attachment_name,\n reminders.avatar,\n channels.channel,\n reminders.message_json,\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.uid,\n reminders.username,\n reminders.utc_time\n FROM reminders\n LEFT JOIN channels ON channels.id = reminders.channel_id\n WHERE uid = ?",
"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",
@@ -22,7 +13,7 @@
}
},
{
"ordinal": 2,
"ordinal": 1,
"name": "avatar",
"type_info": {
"type": "VarString",
@@ -31,12 +22,21 @@
}
},
{
"ordinal": 3,
"ordinal": 2,
"name": "channel",
"type_info": {
"type": "VarString",
"flags": "",
"max_size": 84
"type": "LongLong",
"flags": "UNIQUE_KEY | UNSIGNED | NO_DEFAULT_VALUE",
"max_size": 20
}
},
{
"ordinal": 3,
"name": "message_json",
"type_info": {
"type": "Json",
"flags": "BLOB | BINARY",
"max_size": 4294967295
}
},
{
@@ -212,6 +212,15 @@
},
{
"ordinal": 23,
"name": "uid",
"type_info": {
"type": "VarString",
"flags": "NOT_NULL | UNIQUE_KEY | NO_DEFAULT_VALUE",
"max_size": 256
}
},
{
"ordinal": 24,
"name": "username",
"type_info": {
"type": "VarString",
@@ -220,7 +229,7 @@
}
},
{
"ordinal": 24,
"ordinal": 25,
"name": "utc_time",
"type_info": {
"type": "Datetime",
@@ -256,9 +265,10 @@
false,
false,
false,
false,
true,
false
]
},
"hash": "db69def9391283efb9bf915223d7d6b2d169203b7dc45481a509137f7590d9a6"
"hash": "168e34b942db6025a5674dca06ac154309cc8f86341272e7c771abfad1b61003"
}
@@ -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": "19bc60a2ff67ce6e169985a76405af51d7d16d4d7b84d1c239de5af79da93268"
}
@@ -0,0 +1,84 @@
{
"db_name": "MySQL",
"query": "\n SELECT id, channel, nudge, webhook_id, webhook_token, paused, paused_until\n FROM channels\n WHERE channel = ?\n ",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "id",
"type_info": {
"type": "Long",
"flags": "NOT_NULL | PRIMARY_KEY | UNIQUE_KEY | UNSIGNED | AUTO_INCREMENT",
"max_size": 10
}
},
{
"ordinal": 1,
"name": "channel",
"type_info": {
"type": "LongLong",
"flags": "NOT_NULL | UNIQUE_KEY | UNSIGNED | NO_DEFAULT_VALUE",
"max_size": 20
}
},
{
"ordinal": 2,
"name": "nudge",
"type_info": {
"type": "Short",
"flags": "NOT_NULL",
"max_size": 6
}
},
{
"ordinal": 3,
"name": "webhook_id",
"type_info": {
"type": "LongLong",
"flags": "UNIQUE_KEY | UNSIGNED",
"max_size": 20
}
},
{
"ordinal": 4,
"name": "webhook_token",
"type_info": {
"type": "Blob",
"flags": "BLOB",
"max_size": 262140
}
},
{
"ordinal": 5,
"name": "paused",
"type_info": {
"type": "Tiny",
"flags": "NOT_NULL",
"max_size": 1
}
},
{
"ordinal": 6,
"name": "paused_until",
"type_info": {
"type": "Timestamp",
"flags": "BINARY",
"max_size": 19
}
}
],
"parameters": {
"Right": 1
},
"nullable": [
false,
false,
false,
true,
true,
false,
true
]
},
"hash": "21b660ecbeffcac31ef283f59e420360a28b344759407024a11dc4a04da9341d"
}
@@ -1,12 +1,12 @@
{
"db_name": "MySQL",
"query": "\n INSERT INTO reminders (\n `uid`,\n `channel_id`,\n `thread_id`,\n `utc_time`,\n `timezone`,\n `interval_seconds`,\n `interval_days`,\n `interval_months`,\n `expires`,\n `content`,\n `tts`,\n `attachment_name`,\n `attachment`,\n `set_by`\n ) VALUES (\n ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?\n )\n ",
"query": "\n INSERT INTO reminders (\n `uid`,\n `channel_id`,\n `thread_id`,\n `utc_time`,\n `timezone`,\n `interval_seconds`,\n `interval_days`,\n `interval_months`,\n `expires`,\n `message_json`,\n `attachment_name`,\n `attachment`,\n `set_by`\n ) VALUES (\n ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?\n )\n ",
"describe": {
"columns": [],
"parameters": {
"Right": 14
"Right": 13
},
"nullable": []
},
"hash": "f9d2d9f6809b6c852297e5c60b6c356af5176c6b81b86cd701c1b8af8212f194"
"hash": "22ea7fbff927d2696e5c3dc78f714d1cb5dadcc560854e22f15def122c64fc0c"
}
@@ -1,6 +1,6 @@
{
"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 reminders.id = ?\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.message_json,\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 reminders.id = ?\n ",
"describe": {
"columns": [
{
@@ -86,6 +86,15 @@
},
{
"ordinal": 9,
"name": "message_json",
"type_info": {
"type": "Json",
"flags": "BLOB | BINARY",
"max_size": 4294967295
}
},
{
"ordinal": 10,
"name": "content",
"type_info": {
"type": "VarString",
@@ -94,7 +103,7 @@
}
},
{
"ordinal": 10,
"ordinal": 11,
"name": "embed_description",
"type_info": {
"type": "VarString",
@@ -103,7 +112,7 @@
}
},
{
"ordinal": 11,
"ordinal": 12,
"name": "set_by",
"type_info": {
"type": "LongLong",
@@ -125,10 +134,11 @@
true,
true,
false,
true,
false,
false,
true
]
},
"hash": "67b970d7b85f7bf3e71a58af10bedf7281cf0d8601b149383037d604a22a64c7"
"hash": "2753eb51d5f4b51771b696555752cc59d8d0c78737fc35210c5f6108d32f3540"
}
@@ -0,0 +1,12 @@
{
"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"
}
@@ -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": "2db489e076c93a5a2baf2dd48eb3278d68296aea93097a642e2bbb5112d51fe8"
}
@@ -1,134 +0,0 @@
{
"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"
}
@@ -1,6 +1,6 @@
{
"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 reminders.uid = ?\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.message_json,\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 reminders.uid = ?\n ",
"describe": {
"columns": [
{
@@ -86,6 +86,15 @@
},
{
"ordinal": 9,
"name": "message_json",
"type_info": {
"type": "Json",
"flags": "BLOB | BINARY",
"max_size": 4294967295
}
},
{
"ordinal": 10,
"name": "content",
"type_info": {
"type": "VarString",
@@ -94,7 +103,7 @@
}
},
{
"ordinal": 10,
"ordinal": 11,
"name": "embed_description",
"type_info": {
"type": "VarString",
@@ -103,7 +112,7 @@
}
},
{
"ordinal": 11,
"ordinal": 12,
"name": "set_by",
"type_info": {
"type": "LongLong",
@@ -125,10 +134,11 @@
true,
true,
false,
true,
false,
false,
true
]
},
"hash": "d7f469dd1c7f37f1aca643e9018083b48da1cf988030b9efeae451e77e54a23d"
"hash": "40ceeb037b5266e743a77a4a657d222b769e871b521d3a1745caed0292e9e6cc"
}
@@ -1,12 +0,0 @@
{
"db_name": "MySQL",
"query": "INSERT INTO reminders (\n uid,\n attachment,\n attachment_name,\n channel_id,\n avatar,\n content,\n embed_author,\n embed_author_url,\n embed_color,\n embed_description,\n embed_footer,\n embed_footer_url,\n embed_image_url,\n embed_thumbnail_url,\n embed_title,\n embed_fields,\n enabled,\n expires,\n interval_seconds,\n interval_days,\n interval_months,\n name,\n restartable,\n tts,\n username,\n `utc_time`\n ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
"describe": {
"columns": [],
"parameters": {
"Right": 26
},
"nullable": []
},
"hash": "4cc3ff2bbdf8502f6e739a002c7aa16df8c4f24de0966b26b268efab97e205e3"
}
@@ -1,6 +1,6 @@
{
"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 channels.id = reminders.channel_id\n WHERE\n `status` = 'pending' AND\n channels.id = (SELECT dm_channel FROM users WHERE id = ?)\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.message_json,\n reminders.content,\n reminders.embed_description,\n reminders.set_by\n FROM\n reminders\n INNER JOIN\n channels\n ON\n channels.id = reminders.channel_id\n WHERE\n `status` = 'pending' AND\n channels.id = (SELECT dm_channel FROM users WHERE id = ?)\n ",
"describe": {
"columns": [
{
@@ -86,6 +86,15 @@
},
{
"ordinal": 9,
"name": "message_json",
"type_info": {
"type": "Json",
"flags": "BLOB | BINARY",
"max_size": 4294967295
}
},
{
"ordinal": 10,
"name": "content",
"type_info": {
"type": "VarString",
@@ -94,7 +103,7 @@
}
},
{
"ordinal": 10,
"ordinal": 11,
"name": "embed_description",
"type_info": {
"type": "VarString",
@@ -103,7 +112,7 @@
}
},
{
"ordinal": 11,
"ordinal": 12,
"name": "set_by",
"type_info": {
"type": "LongLong",
@@ -125,10 +134,11 @@
true,
true,
false,
true,
false,
false,
true
]
},
"hash": "45e555a2a0664662f55cc738f91abf8c51648fb687f9568c4dd5f48e9273253a"
"hash": "5575971e2a460476adcc70147810caa91fb07f52d788af0f7ec319a15ee5020c"
}
@@ -0,0 +1,12 @@
{
"db_name": "MySQL",
"query": "INSERT IGNORE INTO channels (channel, guild_id) VALUES (?, (SELECT id FROM guilds WHERE guild = ?))",
"describe": {
"columns": [],
"parameters": {
"Right": 2
},
"nullable": []
},
"hash": "5d7a787e3a6f487c3fe00bd39d308c9350c82e58b0ad1aae0b92824375786f02"
}
@@ -1,6 +1,6 @@
{
"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 channels.guild_id = (SELECT id FROM guilds WHERE guild = ?)\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.message_json,\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 channels.guild_id = (SELECT id FROM guilds WHERE guild = ?)\n ",
"describe": {
"columns": [
{
@@ -86,6 +86,15 @@
},
{
"ordinal": 9,
"name": "message_json",
"type_info": {
"type": "Json",
"flags": "BLOB | BINARY",
"max_size": 4294967295
}
},
{
"ordinal": 10,
"name": "content",
"type_info": {
"type": "VarString",
@@ -94,7 +103,7 @@
}
},
{
"ordinal": 10,
"ordinal": 11,
"name": "embed_description",
"type_info": {
"type": "VarString",
@@ -103,7 +112,7 @@
}
},
{
"ordinal": 11,
"ordinal": 12,
"name": "set_by",
"type_info": {
"type": "LongLong",
@@ -125,10 +134,11 @@
true,
true,
false,
true,
false,
false,
true
]
},
"hash": "4dc78a4ebdad0d9fb721389013537093dd814c313bc8ecc80fc9767420afb798"
"hash": "5efa565633215c62ae826b670140c2f36ccc019d56db7a0182ec00f1a2713562"
}
@@ -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` = DATE_ADD(reminders.`utc_time`, INTERVAL ? SECOND)\n WHERE FIND_IN_SET(channels.`channel`, ?)\n ",
"describe": {
"columns": [],
"parameters": {
"Right": 2
},
"nullable": []
},
"hash": "65496ff85dd92b5aaf12519628afdc16ca7d70131744c9c53880dc56b92991d9"
}
@@ -1,6 +1,6 @@
{
"db_name": "MySQL",
"query": "\n SELECT\n reminders.`id` AS id,\n\n channels.`channel` AS channel_id,\n reminders.`thread_id` AS thread_id,\n channels.`webhook_id` AS webhook_id,\n channels.`webhook_token` AS webhook_token,\n\n channels.`paused` AS 'channel_paused',\n channels.`paused_until` AS 'channel_paused_until',\n reminders.`enabled` AS 'enabled',\n\n reminders.`tts` AS tts,\n reminders.`pin` AS pin,\n reminders.`content` AS content,\n reminders.`attachment` AS attachment,\n reminders.`attachment_name` AS attachment_name,\n\n reminders.`utc_time` AS 'utc_time',\n reminders.`timezone` AS timezone,\n reminders.`restartable` AS restartable,\n reminders.`expires` AS 'expires',\n reminders.`interval_seconds` AS 'interval_seconds',\n reminders.`interval_days` AS 'interval_days',\n reminders.`interval_months` AS 'interval_months',\n\n reminders.`avatar` AS avatar,\n reminders.`username` AS username\n FROM\n reminders\n INNER JOIN\n channels\n ON\n reminders.channel_id = channels.id\n WHERE\n reminders.`status` = 'pending' AND\n reminders.`id` IN (\n SELECT\n MIN(id)\n FROM\n reminders\n WHERE\n reminders.`utc_time` <= NOW() AND\n `status` = 'pending' AND\n (\n reminders.`interval_seconds` IS NOT NULL\n OR reminders.`interval_months` IS NOT NULL\n OR reminders.`interval_days` IS NOT NULL\n OR reminders.enabled\n )\n GROUP BY channel_id\n )\n ",
"query": "\n SELECT\n reminders.`id` AS id,\n\n channels.`channel` AS channel_id,\n reminders.`thread_id` AS thread_id,\n channels.`webhook_id` AS webhook_id,\n channels.`webhook_token` AS webhook_token,\n\n channels.`paused` AS 'channel_paused',\n channels.`paused_until` AS 'channel_paused_until',\n reminders.`enabled` AS 'enabled',\n\n reminders.`tts` AS tts,\n reminders.`pin` AS pin,\n reminders.`content` AS content,\n reminders.`message_json` AS message_json,\n reminders.`attachment` AS attachment,\n reminders.`attachment_name` AS attachment_name,\n\n reminders.`utc_time` AS 'utc_time',\n reminders.`timezone` AS timezone,\n reminders.`restartable` AS restartable,\n reminders.`expires` AS 'expires',\n reminders.`interval_seconds` AS 'interval_seconds',\n reminders.`interval_days` AS 'interval_days',\n reminders.`interval_months` AS 'interval_months',\n\n reminders.`avatar` AS avatar,\n reminders.`username` AS username\n FROM\n reminders\n INNER JOIN\n channels\n ON\n reminders.channel_id = channels.id\n WHERE\n reminders.`status` = 'pending' AND\n reminders.`id` IN (\n SELECT\n MIN(id)\n FROM\n reminders\n WHERE\n reminders.`utc_time` <= NOW() AND\n `status` = 'pending' AND\n (\n reminders.`interval_seconds` IS NOT NULL\n OR reminders.`interval_months` IS NOT NULL\n OR reminders.`interval_days` IS NOT NULL\n OR reminders.enabled\n )\n GROUP BY channel_id\n )\n ",
"describe": {
"columns": [
{
@@ -104,6 +104,15 @@
},
{
"ordinal": 11,
"name": "message_json",
"type_info": {
"type": "Json",
"flags": "BLOB | BINARY",
"max_size": 4294967295
}
},
{
"ordinal": 12,
"name": "attachment",
"type_info": {
"type": "Blob",
@@ -112,7 +121,7 @@
}
},
{
"ordinal": 12,
"ordinal": 13,
"name": "attachment_name",
"type_info": {
"type": "VarString",
@@ -121,7 +130,7 @@
}
},
{
"ordinal": 13,
"ordinal": 14,
"name": "utc_time",
"type_info": {
"type": "Datetime",
@@ -130,7 +139,7 @@
}
},
{
"ordinal": 14,
"ordinal": 15,
"name": "timezone",
"type_info": {
"type": "String",
@@ -139,7 +148,7 @@
}
},
{
"ordinal": 15,
"ordinal": 16,
"name": "restartable",
"type_info": {
"type": "Tiny",
@@ -148,7 +157,7 @@
}
},
{
"ordinal": 16,
"ordinal": 17,
"name": "expires",
"type_info": {
"type": "Datetime",
@@ -157,7 +166,7 @@
}
},
{
"ordinal": 17,
"ordinal": 18,
"name": "interval_seconds",
"type_info": {
"type": "Long",
@@ -166,7 +175,7 @@
}
},
{
"ordinal": 18,
"ordinal": 19,
"name": "interval_days",
"type_info": {
"type": "Long",
@@ -175,7 +184,7 @@
}
},
{
"ordinal": 19,
"ordinal": 20,
"name": "interval_months",
"type_info": {
"type": "Long",
@@ -184,7 +193,7 @@
}
},
{
"ordinal": 20,
"ordinal": 21,
"name": "avatar",
"type_info": {
"type": "VarString",
@@ -193,7 +202,7 @@
}
},
{
"ordinal": 21,
"ordinal": 22,
"name": "username",
"type_info": {
"type": "VarString",
@@ -219,6 +228,7 @@
false,
true,
true,
true,
false,
false,
false,
@@ -230,5 +240,5 @@
true
]
},
"hash": "a33ef7167746183766b6a9b4e31784dd3712fa2b4f72482d4b681e0a9fdbaf1d"
"hash": "6cc6fafaa4dab75639c6e5348d2a836bc6be510e76534c36df709883a4694859"
}
@@ -1,12 +0,0 @@
{
"db_name": "MySQL",
"query": "\n DELETE FROM todos WHERE FIND_IN_SET(id, ?)\n ",
"describe": {
"columns": [],
"parameters": {
"Right": 1
},
"nullable": []
},
"hash": "6e00a27fa770d1aa8cac48cd8878e712ef536c67eeb4fb9a4a801459ada35715"
}
@@ -1,12 +0,0 @@
{
"db_name": "MySQL",
"query": "INSERT INTO reminders (\n uid,\n attachment,\n attachment_name,\n channel_id,\n content,\n embed_author,\n embed_author_url,\n embed_color,\n embed_description,\n embed_footer,\n embed_footer_url,\n embed_image_url,\n embed_thumbnail_url,\n embed_title,\n embed_fields,\n enabled,\n expires,\n interval_seconds,\n interval_days,\n interval_months,\n name,\n tts,\n `utc_time`\n ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
"describe": {
"columns": [],
"parameters": {
"Right": 23
},
"nullable": []
},
"hash": "7f44451c441507ccd7f8d41633fb156f95a28389fcc9c489fdb228446f88342e"
}
@@ -0,0 +1,12 @@
{
"db_name": "MySQL",
"query": "INSERT INTO reminders (\n uid, attachment, attachment_name, channel_id, message_json,\n enabled, expires, interval_seconds, interval_days, interval_months,\n name, restartable, `utc_time`\n ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
"describe": {
"columns": [],
"parameters": {
"Right": 13
},
"nullable": []
},
"hash": "8051034e8334a31d5c8ffca9893f85b689955c1e3d144e1162d4c991a47d9852"
}
@@ -1,10 +1,19 @@
{
"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 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.uid,\n reminders.username,\n reminders.utc_time\n FROM reminders\n LEFT JOIN channels ON channels.id = reminders.channel_id\n WHERE uid = ?",
"query": "\n SELECT reminders.attachment,\n reminders.attachment_name,\n reminders.message_json,\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.tts,\n reminders.uid,\n reminders.utc_time\n FROM reminders\n LEFT JOIN channels ON channels.id = reminders.channel_id\n WHERE uid = ?\n ",
"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",
@@ -12,22 +21,13 @@
"max_size": 1040
}
},
{
"ordinal": 1,
"name": "avatar",
"type_info": {
"type": "VarString",
"flags": "",
"max_size": 2048
}
},
{
"ordinal": 2,
"name": "channel",
"name": "message_json",
"type_info": {
"type": "LongLong",
"flags": "UNIQUE_KEY | UNSIGNED | NO_DEFAULT_VALUE",
"max_size": 20
"type": "Json",
"flags": "BLOB | BINARY",
"max_size": 4294967295
}
},
{
@@ -185,15 +185,6 @@
},
{
"ordinal": 20,
"name": "restartable",
"type_info": {
"type": "Tiny",
"flags": "NOT_NULL",
"max_size": 1
}
},
{
"ordinal": 21,
"name": "tts",
"type_info": {
"type": "Tiny",
@@ -202,7 +193,7 @@
}
},
{
"ordinal": 22,
"ordinal": 21,
"name": "uid",
"type_info": {
"type": "VarString",
@@ -211,16 +202,7 @@
}
},
{
"ordinal": 23,
"name": "username",
"type_info": {
"type": "VarString",
"flags": "",
"max_size": 128
}
},
{
"ordinal": 24,
"ordinal": 22,
"name": "utc_time",
"type_info": {
"type": "Datetime",
@@ -255,10 +237,8 @@
false,
false,
false,
false,
true,
false
]
},
"hash": "fa58f079f00bec4c3f9cbb2ee0a607f63c5b586654c541bff6b99eff83ae0a44"
"hash": "8e5e68f6cc7549dc3c1e777def9f6fd8a6b61c6790ee9594ca20c5504ca834fa"
}
@@ -1,6 +1,6 @@
{
"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 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.uid,\n reminders.username,\n reminders.utc_time\n FROM reminders\n LEFT JOIN channels ON channels.id = reminders.channel_id\n WHERE uid = ?",
"query": "\n SELECT\n reminders.attachment_name,\n reminders.avatar,\n channels.channel,\n reminders.message_json,\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.uid,\n reminders.username,\n reminders.utc_time\n FROM reminders\n LEFT JOIN channels ON channels.id = reminders.channel_id\n WHERE uid = ?",
"describe": {
"columns": [
{
@@ -32,6 +32,15 @@
},
{
"ordinal": 3,
"name": "message_json",
"type_info": {
"type": "Json",
"flags": "BLOB | BINARY",
"max_size": 4294967295
}
},
{
"ordinal": 4,
"name": "content",
"type_info": {
"type": "VarString",
@@ -40,7 +49,7 @@
}
},
{
"ordinal": 4,
"ordinal": 5,
"name": "embed_author",
"type_info": {
"type": "VarString",
@@ -49,7 +58,7 @@
}
},
{
"ordinal": 5,
"ordinal": 6,
"name": "embed_author_url",
"type_info": {
"type": "VarString",
@@ -58,7 +67,7 @@
}
},
{
"ordinal": 6,
"ordinal": 7,
"name": "embed_color",
"type_info": {
"type": "Long",
@@ -67,7 +76,7 @@
}
},
{
"ordinal": 7,
"ordinal": 8,
"name": "embed_description",
"type_info": {
"type": "VarString",
@@ -76,7 +85,7 @@
}
},
{
"ordinal": 8,
"ordinal": 9,
"name": "embed_footer",
"type_info": {
"type": "VarString",
@@ -85,7 +94,7 @@
}
},
{
"ordinal": 9,
"ordinal": 10,
"name": "embed_footer_url",
"type_info": {
"type": "VarString",
@@ -94,7 +103,7 @@
}
},
{
"ordinal": 10,
"ordinal": 11,
"name": "embed_image_url",
"type_info": {
"type": "VarString",
@@ -103,7 +112,7 @@
}
},
{
"ordinal": 11,
"ordinal": 12,
"name": "embed_thumbnail_url",
"type_info": {
"type": "VarString",
@@ -112,7 +121,7 @@
}
},
{
"ordinal": 12,
"ordinal": 13,
"name": "embed_title",
"type_info": {
"type": "VarString",
@@ -121,7 +130,7 @@
}
},
{
"ordinal": 13,
"ordinal": 14,
"name": "embed_fields",
"type_info": {
"type": "Json",
@@ -130,7 +139,7 @@
}
},
{
"ordinal": 14,
"ordinal": 15,
"name": "enabled",
"type_info": {
"type": "Tiny",
@@ -139,7 +148,7 @@
}
},
{
"ordinal": 15,
"ordinal": 16,
"name": "expires",
"type_info": {
"type": "Datetime",
@@ -148,7 +157,7 @@
}
},
{
"ordinal": 16,
"ordinal": 17,
"name": "interval_seconds",
"type_info": {
"type": "Long",
@@ -157,7 +166,7 @@
}
},
{
"ordinal": 17,
"ordinal": 18,
"name": "interval_days",
"type_info": {
"type": "Long",
@@ -166,7 +175,7 @@
}
},
{
"ordinal": 18,
"ordinal": 19,
"name": "interval_months",
"type_info": {
"type": "Long",
@@ -175,7 +184,7 @@
}
},
{
"ordinal": 19,
"ordinal": 20,
"name": "name",
"type_info": {
"type": "VarString",
@@ -184,7 +193,7 @@
}
},
{
"ordinal": 20,
"ordinal": 21,
"name": "restartable",
"type_info": {
"type": "Tiny",
@@ -193,7 +202,7 @@
}
},
{
"ordinal": 21,
"ordinal": 22,
"name": "tts",
"type_info": {
"type": "Tiny",
@@ -202,7 +211,7 @@
}
},
{
"ordinal": 22,
"ordinal": 23,
"name": "uid",
"type_info": {
"type": "VarString",
@@ -211,7 +220,7 @@
}
},
{
"ordinal": 23,
"ordinal": 24,
"name": "username",
"type_info": {
"type": "VarString",
@@ -220,7 +229,7 @@
}
},
{
"ordinal": 24,
"ordinal": 25,
"name": "utc_time",
"type_info": {
"type": "Datetime",
@@ -236,6 +245,7 @@
true,
true,
true,
true,
false,
false,
true,
@@ -260,5 +270,5 @@
false
]
},
"hash": "53db321bc70b45800f696115b65da8550de208104017f8e0d0a163781488a534"
"hash": "8e8d6ebf8b5e69d246a73ef3b746ed3dc5dc6f8204f69aeb55dc5e2ae17319aa"
}
@@ -0,0 +1,24 @@
{
"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"
}
@@ -0,0 +1,12 @@
{
"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"
}
@@ -0,0 +1,12 @@
{
"db_name": "MySQL",
"query": "INSERT INTO reminders (\n uid, attachment, attachment_name, channel_id, message_json,\n enabled, expires, interval_seconds, interval_days, interval_months,\n name, `utc_time`\n ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
"describe": {
"columns": [],
"parameters": {
"Right": 12
},
"nullable": []
},
"hash": "992666a8cec803ab31a5a86266a07daa4fdf26eeb0969abd4d7987f9672dfae8"
}
@@ -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": "9b871f08d294555453696808185c6d29d4753619fbee6295a053cefaa9dcc0ae"
}
@@ -0,0 +1,12 @@
{
"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"
}
@@ -0,0 +1,12 @@
{
"db_name": "MySQL",
"query": "\n UPDATE channels\n SET\n nudge = ?,\n webhook_id = ?,\n webhook_token = ?,\n paused = ?,\n paused_until = ?\n WHERE id = ?\n ",
"describe": {
"columns": [],
"parameters": {
"Right": 6
},
"nullable": []
},
"hash": "af08f3524c316f438feb62a24c67b96197e3655a87efe128914ed386be20d900"
}
@@ -1,264 +0,0 @@
{
"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"
}
@@ -1,6 +1,6 @@
{
"db_name": "MySQL",
"query": "SELECT\n reminders.attachment,\n reminders.attachment_name,\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.tts,\n reminders.uid,\n reminders.utc_time\n FROM reminders\n WHERE uid = ?",
"query": "SELECT\n reminders.attachment,\n reminders.attachment_name,\n reminders.message_json,\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.tts,\n reminders.uid,\n reminders.utc_time\n FROM reminders\n WHERE uid = ?",
"describe": {
"columns": [
{
@@ -23,6 +23,15 @@
},
{
"ordinal": 2,
"name": "message_json",
"type_info": {
"type": "Json",
"flags": "BLOB | BINARY",
"max_size": 4294967295
}
},
{
"ordinal": 3,
"name": "content",
"type_info": {
"type": "VarString",
@@ -31,7 +40,7 @@
}
},
{
"ordinal": 3,
"ordinal": 4,
"name": "embed_author",
"type_info": {
"type": "VarString",
@@ -40,7 +49,7 @@
}
},
{
"ordinal": 4,
"ordinal": 5,
"name": "embed_author_url",
"type_info": {
"type": "VarString",
@@ -49,7 +58,7 @@
}
},
{
"ordinal": 5,
"ordinal": 6,
"name": "embed_color",
"type_info": {
"type": "Long",
@@ -58,7 +67,7 @@
}
},
{
"ordinal": 6,
"ordinal": 7,
"name": "embed_description",
"type_info": {
"type": "VarString",
@@ -67,7 +76,7 @@
}
},
{
"ordinal": 7,
"ordinal": 8,
"name": "embed_footer",
"type_info": {
"type": "VarString",
@@ -76,7 +85,7 @@
}
},
{
"ordinal": 8,
"ordinal": 9,
"name": "embed_footer_url",
"type_info": {
"type": "VarString",
@@ -85,7 +94,7 @@
}
},
{
"ordinal": 9,
"ordinal": 10,
"name": "embed_image_url",
"type_info": {
"type": "VarString",
@@ -94,7 +103,7 @@
}
},
{
"ordinal": 10,
"ordinal": 11,
"name": "embed_thumbnail_url",
"type_info": {
"type": "VarString",
@@ -103,7 +112,7 @@
}
},
{
"ordinal": 11,
"ordinal": 12,
"name": "embed_title",
"type_info": {
"type": "VarString",
@@ -112,7 +121,7 @@
}
},
{
"ordinal": 12,
"ordinal": 13,
"name": "embed_fields",
"type_info": {
"type": "Json",
@@ -121,7 +130,7 @@
}
},
{
"ordinal": 13,
"ordinal": 14,
"name": "enabled",
"type_info": {
"type": "Tiny",
@@ -130,7 +139,7 @@
}
},
{
"ordinal": 14,
"ordinal": 15,
"name": "expires",
"type_info": {
"type": "Datetime",
@@ -139,7 +148,7 @@
}
},
{
"ordinal": 15,
"ordinal": 16,
"name": "interval_seconds",
"type_info": {
"type": "Long",
@@ -148,7 +157,7 @@
}
},
{
"ordinal": 16,
"ordinal": 17,
"name": "interval_days",
"type_info": {
"type": "Long",
@@ -157,7 +166,7 @@
}
},
{
"ordinal": 17,
"ordinal": 18,
"name": "interval_months",
"type_info": {
"type": "Long",
@@ -166,7 +175,7 @@
}
},
{
"ordinal": 18,
"ordinal": 19,
"name": "name",
"type_info": {
"type": "VarString",
@@ -175,7 +184,7 @@
}
},
{
"ordinal": 19,
"ordinal": 20,
"name": "tts",
"type_info": {
"type": "Tiny",
@@ -184,7 +193,7 @@
}
},
{
"ordinal": 20,
"ordinal": 21,
"name": "uid",
"type_info": {
"type": "VarString",
@@ -193,7 +202,7 @@
}
},
{
"ordinal": 21,
"ordinal": 22,
"name": "utc_time",
"type_info": {
"type": "Datetime",
@@ -206,6 +215,7 @@
"Right": 1
},
"nullable": [
true,
true,
true,
false,
@@ -230,5 +240,5 @@
false
]
},
"hash": "2613618c00941649726b31f7c50b5ef50d85323a73def5c87bab2a58c80bf6cd"
"hash": "b708e057ab42c09c36fdc71bda77970fe04e8c066fd9c2701ac25afb059c32b6"
}
@@ -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": "bbdd4bd7ebffb97efab8ba7e829159e104615260929341ec0e961b4d5cd6ca0c"
}
@@ -1,6 +1,6 @@
{
"db_name": "MySQL",
"query": "\n SELECT\n reminders.attachment,\n reminders.attachment_name,\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.tts,\n reminders.uid,\n reminders.utc_time\n FROM reminders\n INNER JOIN channels ON channels.id = reminders.channel_id\n WHERE `status` = 'pending' AND channels.channel = ?\n ",
"query": "\n SELECT\n reminders.attachment,\n reminders.attachment_name,\n reminders.message_json,\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.tts,\n reminders.uid,\n reminders.utc_time\n FROM reminders\n INNER JOIN channels ON channels.id = reminders.channel_id\n WHERE `status` = 'pending' AND channels.channel = ?\n ",
"describe": {
"columns": [
{
@@ -23,6 +23,15 @@
},
{
"ordinal": 2,
"name": "message_json",
"type_info": {
"type": "Json",
"flags": "BLOB | BINARY",
"max_size": 4294967295
}
},
{
"ordinal": 3,
"name": "content",
"type_info": {
"type": "VarString",
@@ -31,7 +40,7 @@
}
},
{
"ordinal": 3,
"ordinal": 4,
"name": "embed_author",
"type_info": {
"type": "VarString",
@@ -40,7 +49,7 @@
}
},
{
"ordinal": 4,
"ordinal": 5,
"name": "embed_author_url",
"type_info": {
"type": "VarString",
@@ -49,7 +58,7 @@
}
},
{
"ordinal": 5,
"ordinal": 6,
"name": "embed_color",
"type_info": {
"type": "Long",
@@ -58,7 +67,7 @@
}
},
{
"ordinal": 6,
"ordinal": 7,
"name": "embed_description",
"type_info": {
"type": "VarString",
@@ -67,7 +76,7 @@
}
},
{
"ordinal": 7,
"ordinal": 8,
"name": "embed_footer",
"type_info": {
"type": "VarString",
@@ -76,7 +85,7 @@
}
},
{
"ordinal": 8,
"ordinal": 9,
"name": "embed_footer_url",
"type_info": {
"type": "VarString",
@@ -85,7 +94,7 @@
}
},
{
"ordinal": 9,
"ordinal": 10,
"name": "embed_image_url",
"type_info": {
"type": "VarString",
@@ -94,7 +103,7 @@
}
},
{
"ordinal": 10,
"ordinal": 11,
"name": "embed_thumbnail_url",
"type_info": {
"type": "VarString",
@@ -103,7 +112,7 @@
}
},
{
"ordinal": 11,
"ordinal": 12,
"name": "embed_title",
"type_info": {
"type": "VarString",
@@ -112,7 +121,7 @@
}
},
{
"ordinal": 12,
"ordinal": 13,
"name": "embed_fields",
"type_info": {
"type": "VarString",
@@ -121,7 +130,7 @@
}
},
{
"ordinal": 13,
"ordinal": 14,
"name": "enabled",
"type_info": {
"type": "Tiny",
@@ -130,7 +139,7 @@
}
},
{
"ordinal": 14,
"ordinal": 15,
"name": "expires",
"type_info": {
"type": "Datetime",
@@ -139,7 +148,7 @@
}
},
{
"ordinal": 15,
"ordinal": 16,
"name": "interval_seconds",
"type_info": {
"type": "Long",
@@ -148,7 +157,7 @@
}
},
{
"ordinal": 16,
"ordinal": 17,
"name": "interval_days",
"type_info": {
"type": "Long",
@@ -157,7 +166,7 @@
}
},
{
"ordinal": 17,
"ordinal": 18,
"name": "interval_months",
"type_info": {
"type": "Long",
@@ -166,7 +175,7 @@
}
},
{
"ordinal": 18,
"ordinal": 19,
"name": "name",
"type_info": {
"type": "VarString",
@@ -175,7 +184,7 @@
}
},
{
"ordinal": 19,
"ordinal": 20,
"name": "tts",
"type_info": {
"type": "Tiny",
@@ -184,7 +193,7 @@
}
},
{
"ordinal": 20,
"ordinal": 21,
"name": "uid",
"type_info": {
"type": "VarString",
@@ -193,7 +202,7 @@
}
},
{
"ordinal": 21,
"ordinal": 22,
"name": "utc_time",
"type_info": {
"type": "Datetime",
@@ -206,6 +215,7 @@
"Right": 1
},
"nullable": [
true,
true,
true,
false,
@@ -230,5 +240,5 @@
false
]
},
"hash": "48a9268e9106a3a05e86ab36551944ba855a07b0241e912c6b1adee779c6f0ea"
"hash": "d9e0f517df37a6c03868b6e1470169eaf06d2e6f1a9df62165656d3e97dbe929"
}
@@ -1,6 +1,6 @@
{
"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 FIND_IN_SET(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.message_json,\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 ",
"describe": {
"columns": [
{
@@ -86,6 +86,15 @@
},
{
"ordinal": 9,
"name": "message_json",
"type_info": {
"type": "Json",
"flags": "BLOB | BINARY",
"max_size": 4294967295
}
},
{
"ordinal": 10,
"name": "content",
"type_info": {
"type": "VarString",
@@ -94,7 +103,7 @@
}
},
{
"ordinal": 10,
"ordinal": 11,
"name": "embed_description",
"type_info": {
"type": "VarString",
@@ -103,7 +112,7 @@
}
},
{
"ordinal": 11,
"ordinal": 12,
"name": "set_by",
"type_info": {
"type": "LongLong",
@@ -125,10 +134,11 @@
true,
true,
false,
true,
false,
false,
true
]
},
"hash": "d60ea641070dbd882cd53878fa109d08bd3f65e0da8c263e78fe0b200228bc2b"
"hash": "ec12254245aa433f8d31711c2942254d91c61f6b47a5b427c43a8cac468379a3"
}
@@ -1,234 +0,0 @@
{
"db_name": "MySQL",
"query": "\n SELECT reminders.attachment,\n reminders.attachment_name,\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.tts,\n reminders.uid,\n reminders.utc_time\n FROM reminders\n LEFT JOIN channels ON channels.id = reminders.channel_id\n WHERE uid = ?\n ",
"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": "content",
"type_info": {
"type": "VarString",
"flags": "NOT_NULL",
"max_size": 8192
}
},
{
"ordinal": 3,
"name": "embed_author",
"type_info": {
"type": "VarString",
"flags": "NOT_NULL",
"max_size": 1024
}
},
{
"ordinal": 4,
"name": "embed_author_url",
"type_info": {
"type": "VarString",
"flags": "",
"max_size": 2048
}
},
{
"ordinal": 5,
"name": "embed_color",
"type_info": {
"type": "Long",
"flags": "NOT_NULL | UNSIGNED",
"max_size": 10
}
},
{
"ordinal": 6,
"name": "embed_description",
"type_info": {
"type": "VarString",
"flags": "NOT_NULL",
"max_size": 8192
}
},
{
"ordinal": 7,
"name": "embed_footer",
"type_info": {
"type": "VarString",
"flags": "NOT_NULL",
"max_size": 8192
}
},
{
"ordinal": 8,
"name": "embed_footer_url",
"type_info": {
"type": "VarString",
"flags": "",
"max_size": 2048
}
},
{
"ordinal": 9,
"name": "embed_image_url",
"type_info": {
"type": "VarString",
"flags": "",
"max_size": 2048
}
},
{
"ordinal": 10,
"name": "embed_thumbnail_url",
"type_info": {
"type": "VarString",
"flags": "",
"max_size": 2048
}
},
{
"ordinal": 11,
"name": "embed_title",
"type_info": {
"type": "VarString",
"flags": "NOT_NULL",
"max_size": 1024
}
},
{
"ordinal": 12,
"name": "embed_fields",
"type_info": {
"type": "Json",
"flags": "BLOB | BINARY",
"max_size": 4294967295
}
},
{
"ordinal": 13,
"name": "enabled",
"type_info": {
"type": "Tiny",
"flags": "NOT_NULL",
"max_size": 1
}
},
{
"ordinal": 14,
"name": "expires",
"type_info": {
"type": "Datetime",
"flags": "BINARY",
"max_size": 19
}
},
{
"ordinal": 15,
"name": "interval_seconds",
"type_info": {
"type": "Long",
"flags": "UNSIGNED",
"max_size": 10
}
},
{
"ordinal": 16,
"name": "interval_days",
"type_info": {
"type": "Long",
"flags": "UNSIGNED",
"max_size": 10
}
},
{
"ordinal": 17,
"name": "interval_months",
"type_info": {
"type": "Long",
"flags": "UNSIGNED",
"max_size": 10
}
},
{
"ordinal": 18,
"name": "name",
"type_info": {
"type": "VarString",
"flags": "NOT_NULL",
"max_size": 400
}
},
{
"ordinal": 19,
"name": "tts",
"type_info": {
"type": "Tiny",
"flags": "NOT_NULL",
"max_size": 1
}
},
{
"ordinal": 20,
"name": "uid",
"type_info": {
"type": "VarString",
"flags": "NOT_NULL | UNIQUE_KEY | NO_DEFAULT_VALUE",
"max_size": 256
}
},
{
"ordinal": 21,
"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,
true,
false,
false,
false,
true,
true,
true,
false,
true,
false,
true,
true,
true,
true,
false,
false,
false,
false
]
},
"hash": "ecbea4acb61da864a4f83004b1163391068456a366fee95daff5f46413bcf494"
}
+24
View File
@@ -0,0 +1,24 @@
# 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
}
Generated
+259 -76
View File
@@ -1,6 +1,6 @@
# This file is automatically @generated by Cargo.
# It is not intended for manual editing.
version = 3
version = 4
[[package]]
name = "addr2line"
@@ -35,7 +35,7 @@ checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0"
dependencies = [
"cfg-if",
"cipher",
"cpufeatures",
"cpufeatures 0.2.15",
]
[[package]]
@@ -389,6 +389,23 @@ version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd"
[[package]]
name = "cfg_aliases"
version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527"
[[package]]
name = "chacha20"
version = "0.10.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "65c35e4b699c7e15ccbe7ee35c005e4fc0a278d22238a2857e6ce2dadeda1b06"
dependencies = [
"cfg-if",
"cpufeatures 0.3.1",
"rand_core 0.10.1",
]
[[package]]
name = "chrono"
version = "0.4.38"
@@ -468,7 +485,7 @@ dependencies = [
"base64 0.22.1",
"hkdf",
"percent-encoding",
"rand",
"rand 0.8.5",
"sha2",
"subtle",
"time",
@@ -500,6 +517,15 @@ dependencies = [
"libc",
]
[[package]]
name = "cpufeatures"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5ca28b0ae3115b884660db4118d803791fd6756b6e88f39c0f3f7859060d7566"
dependencies = [
"libc",
]
[[package]]
name = "crc"
version = "3.2.1"
@@ -524,6 +550,15 @@ dependencies = [
"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]]
name = "crossbeam-channel"
version = "0.5.13"
@@ -574,7 +609,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1bfb12502f3fc46cca1bb51ac28df9d618d813cdc3d2f25b9fe775a34af26bb3"
dependencies = [
"generic-array",
"rand_core",
"rand_core 0.6.4",
"typenum",
]
@@ -1057,15 +1092,6 @@ dependencies = [
"slab",
]
[[package]]
name = "fxhash"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c31b6d751ae2c7f11320402d34e41349dd1016f8d5d45e48c4312bc8625af50c"
dependencies = [
"byteorder",
]
[[package]]
name = "generator"
version = "0.7.5"
@@ -1102,6 +1128,20 @@ dependencies = [
"wasm-bindgen",
]
[[package]]
name = "getrandom"
version = "0.4.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099"
dependencies = [
"cfg-if",
"js-sys",
"libc",
"r-efi",
"rand_core 0.10.1",
"wasm-bindgen",
]
[[package]]
name = "ghash"
version = "0.5.1"
@@ -1418,6 +1458,7 @@ dependencies = [
"tokio",
"tokio-rustls 0.26.0",
"tower-service",
"webpki-roots 0.26.6",
]
[[package]]
@@ -1716,10 +1757,12 @@ checksum = "49f1f14873335454500d59611f1cf4a4b0f786f9ac11f4312a78e4cf2566695b"
[[package]]
name = "js-sys"
version = "0.3.72"
version = "0.3.105"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6a88f1bda2bd75b0452a14784937d796722fdebfe50df998aeb3f0b7603019a9"
checksum = "ce57d20d1ea864ce2ac172ab472d409214f4fd359f0b2a2775abdf522e2af99e"
dependencies = [
"cfg-if",
"futures-util",
"wasm-bindgen",
]
@@ -1835,6 +1878,12 @@ dependencies = [
"tracing-subscriber",
]
[[package]]
name = "lru-slab"
version = "0.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154"
[[package]]
name = "matchers"
version = "0.1.0"
@@ -2036,7 +2085,7 @@ dependencies = [
"num-integer",
"num-iter",
"num-traits",
"rand",
"rand 0.8.5",
"smallvec",
"zeroize",
]
@@ -2095,15 +2144,15 @@ checksum = "c38841cdd844847e3e7c8d29cef9dcfed8877f8f56f9071f77843ecf3baf937f"
dependencies = [
"base64 0.13.1",
"chrono",
"getrandom",
"getrandom 0.2.15",
"http 0.2.12",
"rand",
"rand 0.8.5",
"reqwest 0.11.27",
"serde",
"serde_json",
"serde_path_to_error",
"sha2",
"thiserror",
"thiserror 1.0.69",
"url",
]
@@ -2267,7 +2316,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "879952a81a83930934cbf1786752d6dedc3b1f29e8f8fb2ad1d0a36f377cf442"
dependencies = [
"memchr",
"thiserror",
"thiserror 1.0.69",
"ucd-trie",
]
@@ -2331,7 +2380,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "48e4cc64c2ad9ebe670cb8fd69dd50ae301650392e81c05f9bfcb2d5bdbc24b0"
dependencies = [
"phf_shared",
"rand",
"rand 0.8.5",
]
[[package]]
@@ -2418,7 +2467,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9d1fe60d06143b2430aa532c94cfe9e29783047f06c0d7fd359a9a51b729fa25"
dependencies = [
"cfg-if",
"cpufeatures",
"cpufeatures 0.2.15",
"opaque-debug",
"universal-hash",
]
@@ -2440,9 +2489,9 @@ dependencies = [
[[package]]
name = "proc-macro2"
version = "1.0.89"
version = "1.0.107"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f139b0662de085916d1fb67d2b4169d1addddda1919e696f3252b740b629986e"
checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9"
dependencies = [
"unicode-ident",
]
@@ -2472,7 +2521,7 @@ dependencies = [
"memchr",
"parking_lot",
"protobuf",
"thiserror",
"thiserror 1.0.69",
]
[[package]]
@@ -2492,6 +2541,62 @@ dependencies = [
"unicase",
]
[[package]]
name = "quinn"
version = "0.11.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0c1a41e437b6bbd489372cd4971de128e85c855f56c57f283d20ff016cf7c0a8"
dependencies = [
"bytes",
"cfg_aliases",
"pin-project-lite",
"quinn-proto",
"quinn-udp",
"rustc-hash",
"rustls 0.23.16",
"socket2",
"thiserror 2.0.20",
"tokio",
"tracing",
"web-time",
]
[[package]]
name = "quinn-proto"
version = "0.11.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "04759210543be93709136e28212294a659ef5001836ff4eab4d663e4529bba83"
dependencies = [
"bytes",
"getrandom 0.4.3",
"lru-slab",
"rand 0.10.2",
"rand_pcg",
"ring",
"rustc-hash",
"rustls 0.23.16",
"rustls-pki-types",
"slab",
"thiserror 2.0.20",
"tinyvec",
"tracing",
"web-time",
]
[[package]]
name = "quinn-udp"
version = "0.5.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "35a133f956daabe89a61a685c2649f13d82d5aa4bd5d12d1277e1072a21c0694"
dependencies = [
"cfg_aliases",
"libc",
"once_cell",
"socket2",
"tracing",
"windows-sys 0.59.0",
]
[[package]]
name = "quote"
version = "1.0.37"
@@ -2501,6 +2606,12 @@ dependencies = [
"proc-macro2",
]
[[package]]
name = "r-efi"
version = "6.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
[[package]]
name = "rand"
version = "0.8.5"
@@ -2509,7 +2620,18 @@ checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404"
dependencies = [
"libc",
"rand_chacha",
"rand_core",
"rand_core 0.6.4",
]
[[package]]
name = "rand"
version = "0.10.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c7f5fa3a058cd35567ef9bfa5e75732bee0f9e4c55fa90477bef2dfcdbc4be80"
dependencies = [
"chacha20",
"getrandom 0.4.3",
"rand_core 0.10.1",
]
[[package]]
@@ -2519,7 +2641,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88"
dependencies = [
"ppv-lite86",
"rand_core",
"rand_core 0.6.4",
]
[[package]]
@@ -2528,7 +2650,22 @@ version = "0.6.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c"
dependencies = [
"getrandom",
"getrandom 0.2.15",
]
[[package]]
name = "rand_core"
version = "0.10.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69"
[[package]]
name = "rand_pcg"
version = "0.10.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "caa0f4137e1c0a72f4c651489402276c8e8e1cf081f3b0ba156d2cbeef09e86a"
dependencies = [
"rand_core 0.10.1",
]
[[package]]
@@ -2614,11 +2751,12 @@ checksum = "2b15c43186be67a4fd63bee50d0303afffcef381492ebe2c5d87f324e1b8815c"
[[package]]
name = "reminder-rs"
version = "1.7.37"
version = "1.7.45"
dependencies = [
"base64 0.22.1",
"chrono",
"chrono-tz",
"cron-parser",
"csv",
"dotenv",
"env_logger",
@@ -2631,7 +2769,7 @@ dependencies = [
"oauth2",
"poise",
"prometheus",
"rand",
"rand 0.8.5",
"recordable_derive",
"regex",
"reqwest 0.12.9",
@@ -2668,7 +2806,6 @@ dependencies = [
"js-sys",
"log",
"mime",
"mime_guess",
"once_cell",
"percent-encoding",
"pin-project-lite",
@@ -2681,12 +2818,10 @@ dependencies = [
"system-configuration 0.5.1",
"tokio",
"tokio-rustls 0.24.1",
"tokio-util",
"tower-service",
"url",
"wasm-bindgen",
"wasm-bindgen-futures",
"wasm-streams",
"web-sys",
"webpki-roots 0.25.4",
"winreg",
@@ -2715,11 +2850,15 @@ dependencies = [
"js-sys",
"log",
"mime",
"mime_guess",
"native-tls",
"once_cell",
"percent-encoding",
"pin-project-lite",
"quinn",
"rustls 0.23.16",
"rustls-pemfile 2.2.0",
"rustls-pki-types",
"serde",
"serde_json",
"serde_urlencoded",
@@ -2727,11 +2866,15 @@ dependencies = [
"system-configuration 0.6.1",
"tokio",
"tokio-native-tls",
"tokio-rustls 0.26.0",
"tokio-util",
"tower-service",
"url",
"wasm-bindgen",
"wasm-bindgen-futures",
"wasm-streams",
"web-sys",
"webpki-roots 0.26.6",
"windows-registry",
]
@@ -2743,7 +2886,7 @@ checksum = "c17fa4cb658e3583423e915b9f3acc01cceaee1860e33d59ebae66adc3a2dc0d"
dependencies = [
"cc",
"cfg-if",
"getrandom",
"getrandom 0.2.15",
"libc",
"spin",
"untrusted",
@@ -2793,7 +2936,7 @@ dependencies = [
"num_cpus",
"parking_lot",
"pin-project-lite",
"rand",
"rand 0.8.5",
"ref-cast",
"rocket_codegen",
"rocket_http",
@@ -2883,7 +3026,7 @@ dependencies = [
"num-traits",
"pkcs1",
"pkcs8",
"rand_core",
"rand_core 0.6.4",
"signature",
"spki",
"subtle",
@@ -2896,6 +3039,12 @@ version = "0.1.24"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "719b953e2095829ee67db738b3bfa9fa368c94900df327b3f07fe6e794d2fe1f"
[[package]]
name = "rustc-hash"
version = "2.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d"
[[package]]
name = "rustix"
version = "0.38.40"
@@ -2972,6 +3121,9 @@ name = "rustls-pki-types"
version = "1.10.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "16f1201b3c9a7ee8039bcadc17b7e605e2945b27eee7631788c1bd2b0643674b"
dependencies = [
"web-time",
]
[[package]]
name = "rustls-webpki"
@@ -3179,9 +3331,9 @@ dependencies = [
[[package]]
name = "serenity"
version = "0.12.4"
version = "0.12.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3d72ec4323681bf9a3cabe40fd080abc2435859b502a1b5aa9bf693f125bfa76"
checksum = "9bde37f42765dfdc34e2a039e0c84afbf79a3101c1941763b0beb816c2f17541"
dependencies = [
"arrayvec",
"async-trait",
@@ -3192,11 +3344,11 @@ dependencies = [
"dashmap",
"flate2",
"futures",
"fxhash",
"mime_guess",
"parking_lot",
"percent-encoding",
"reqwest 0.11.27",
"reqwest 0.12.9",
"rustc-hash",
"secrecy",
"serde",
"serde_cow",
@@ -3217,7 +3369,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba"
dependencies = [
"cfg-if",
"cpufeatures",
"cpufeatures 0.2.15",
"digest",
]
@@ -3228,7 +3380,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "793db75ad2bcafc3ffa7c68b215fee268f537982cd901d132f89c6343f3a3dc8"
dependencies = [
"cfg-if",
"cpufeatures",
"cpufeatures 0.2.15",
"digest",
]
@@ -3263,7 +3415,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de"
dependencies = [
"digest",
"rand_core",
"rand_core 0.6.4",
]
[[package]]
@@ -3403,7 +3555,7 @@ dependencies = [
"sha2",
"smallvec",
"sqlformat",
"thiserror",
"thiserror 1.0.69",
"tokio",
"tokio-stream",
"tracing",
@@ -3481,7 +3633,7 @@ dependencies = [
"memchr",
"once_cell",
"percent-encoding",
"rand",
"rand 0.8.5",
"rsa",
"serde",
"sha1",
@@ -3489,7 +3641,7 @@ dependencies = [
"smallvec",
"sqlx-core",
"stringprep",
"thiserror",
"thiserror 1.0.69",
"tracing",
"whoami",
]
@@ -3523,14 +3675,14 @@ dependencies = [
"memchr",
"num-bigint",
"once_cell",
"rand",
"rand 0.8.5",
"serde",
"serde_json",
"sha2",
"smallvec",
"sqlx-core",
"stringprep",
"thiserror",
"thiserror 1.0.69",
"tracing",
"whoami",
]
@@ -3628,6 +3780,17 @@ dependencies = [
"unicode-ident",
]
[[package]]
name = "syn"
version = "3.0.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8593e8e72159ed2257d083c7a454a85cbf854f37a0966d8d483aff8c8a3ebcee"
dependencies = [
"proc-macro2",
"quote",
"unicode-ident",
]
[[package]]
name = "sync_wrapper"
version = "0.1.2"
@@ -3729,7 +3892,7 @@ dependencies = [
"percent-encoding",
"pest",
"pest_derive",
"rand",
"rand 0.8.5",
"regex",
"serde",
"serde_json",
@@ -3743,7 +3906,16 @@ version = "1.0.69"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52"
dependencies = [
"thiserror-impl",
"thiserror-impl 1.0.69",
]
[[package]]
name = "thiserror"
version = "2.0.20"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ec86235f5fcc2a73650310756d2ac5b138a5780bbbdfae3eeccec992c435ba4f"
dependencies = [
"thiserror-impl 2.0.20",
]
[[package]]
@@ -3757,6 +3929,17 @@ dependencies = [
"syn 2.0.87",
]
[[package]]
name = "thiserror-impl"
version = "2.0.20"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bc04cd3e1236dd4a98afca4569f2deb3f120e5422a4023be2cb683f8486292af"
dependencies = [
"proc-macro2",
"quote",
"syn 3.0.6",
]
[[package]]
name = "thread_local"
version = "1.1.8"
@@ -4060,11 +4243,11 @@ dependencies = [
"http 1.1.0",
"httparse",
"log",
"rand",
"rand 0.8.5",
"rustls 0.22.4",
"rustls-pki-types",
"sha1",
"thiserror",
"thiserror 1.0.69",
"url",
"utf-8",
]
@@ -4333,27 +4516,14 @@ checksum = "b8dad83b4f25e74f184f64c43b150b91efe7647395b42289f38e50566d82855b"
[[package]]
name = "wasm-bindgen"
version = "0.2.95"
version = "0.2.128"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "128d1e363af62632b8eb57219c8fd7877144af57558fb2ef0368d0087bddeb2e"
checksum = "aecb87a33d3b0c5e3b7aa46336eaf486cffafbd281b195e4c8b80d50df2351bf"
dependencies = [
"cfg-if",
"once_cell",
"rustversion",
"wasm-bindgen-macro",
]
[[package]]
name = "wasm-bindgen-backend"
version = "0.2.95"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cb6dd4d3ca0ddffd1dd1c9c04f94b868c37ff5fac97c30b97cff2d74fce3a358"
dependencies = [
"bumpalo",
"log",
"once_cell",
"proc-macro2",
"quote",
"syn 2.0.87",
"wasm-bindgen-shared",
]
@@ -4371,9 +4541,9 @@ dependencies = [
[[package]]
name = "wasm-bindgen-macro"
version = "0.2.95"
version = "0.2.128"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e79384be7f8f5a9dd5d7167216f022090cf1f9ec128e6e6a482a2cb5c5422c56"
checksum = "a690d511e3c1a8b3a55e33511e3c2c00c78415cd23650f32b808627f5696b9ed"
dependencies = [
"quote",
"wasm-bindgen-macro-support",
@@ -4381,22 +4551,25 @@ dependencies = [
[[package]]
name = "wasm-bindgen-macro-support"
version = "0.2.95"
version = "0.2.128"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "26c6ab57572f7a24a4985830b120de1594465e5d500f24afe89e16b4e833ef68"
checksum = "411e4887f0071ef2d2164a9d5fdf2d20efbef78fccd3a78b0c10a1dc5295e48a"
dependencies = [
"bumpalo",
"proc-macro2",
"quote",
"syn 2.0.87",
"wasm-bindgen-backend",
"syn 3.0.6",
"wasm-bindgen-shared",
]
[[package]]
name = "wasm-bindgen-shared"
version = "0.2.95"
version = "0.2.128"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "65fc09f10666a9f147042251e0dda9c18f166ff7de300607007e96bdebc1068d"
checksum = "81941cd78d0c92026c33e5e01312845a4cb1e9af3407f9134b100dd03144103e"
dependencies = [
"unicode-ident",
]
[[package]]
name = "wasm-streams"
@@ -4421,6 +4594,16 @@ dependencies = [
"wasm-bindgen",
]
[[package]]
name = "web-time"
version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb"
dependencies = [
"js-sys",
"wasm-bindgen",
]
[[package]]
name = "webpki-roots"
version = "0.25.4"
+3 -2
View File
@@ -1,6 +1,6 @@
[package]
name = "reminder-rs"
version = "1.7.37"
version = "1.7.45"
authors = ["Jude Southworth <judesouthworth@pm.me>"]
edition = "2021"
license = "AGPL-3.0 only"
@@ -31,10 +31,11 @@ futures = "0.3.30"
prometheus = "0.13.3"
rocket = { version = "0.5.0", features = ["tls", "secrets", "json"] }
rocket_dyn_templates = { version = "0.2.0", features = ["tera"] }
serenity = { version = "0.12", default-features = false, features = ["builder", "cache", "client", "gateway", "http", "model", "utils", "rustls_backend"] }
serenity = { version = "0.12.5", default-features = false, features = ["builder", "cache", "client", "gateway", "http", "model", "utils", "rustls_backend"] }
oauth2 = "4"
csv = "1.2"
sd-notify = "0.4.1"
cron-parser = "0.10"
[dependencies.extract_derive]
path = "extract_derive"
+16 -10
View File
@@ -1,11 +1,10 @@
FROM ubuntu:24.04
FROM alpine:latest AS build
ENV RUSTUP_HOME=/usr/local/rustup \
CARGO_HOME=/usr/local/cargo \
PATH=/usr/local/cargo/bin:$PATH
RUN apt update
RUN DEBIAN_FRONTEND=noninteractive TZ=Etc/UTC apt install -y gcc gcc-multilib pkg-config libssl-dev curl mysql-client-8.0 npm
RUN apk add gcc build-base pkgconfig openssl-dev openssl-libs-static curl mysql-client npm
RUN curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --no-modify-path --profile minimal --default-toolchain nightly
WORKDIR /usr/src/reminder-rs
@@ -17,13 +16,7 @@ COPY ./conf ./conf
COPY ./extract_derive ./extract_derive
COPY ./migrations ./migrations
COPY ./recordable_derive ./recordable_derive
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 ./reminder-dashboard ./reminder-dashboard
COPY ./src ./src
COPY ./static ./static
COPY ./templates ./templates
@@ -32,6 +25,19 @@ COPY ./Cargo.lock ./
COPY ./Cargo.toml ./
COPY ./dp.py ./
# Build and install the Rust binary
RUN cargo install --path .
FROM alpine:latest AS runtime
WORKDIR /usr/src/reminder-rs
COPY --from=build /usr/local/cargo/bin/reminder-rs /usr/local/bin/reminder-rs
COPY --from=build /usr/src/reminder-rs/static /usr/src/reminder-rs/static
COPY --from=build /usr/src/reminder-rs/templates /usr/src/reminder-rs/templates
RUN apk add python3 py3-pip
RUN pip3 install --no-cache --break-system-packages dateparser
EXPOSE 18920
CMD ["reminder-rs"]
+42 -16
View File
@@ -1,52 +1,78 @@
# reminder-rs
Reminder Bot for Discord.
## 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.
You'll need rustc and cargo for compilation. To run, you'll need Python 3 still (due to no suitable replacement for dateparser in Rust)
You'll need rustc and cargo for compilation. To run, you'll need Python 3 still (due to no suitable
replacement for dateparser in Rust)
### Build APT package
Recommended method.
By default, this builds targeting Ubuntu 20.04. Modify the Containerfile if you wish to target a different platform. These instructions are written using `podman`, but `docker` should work too.
By default, this builds targeting Ubuntu 20.04. Modify the Containerfile if you wish to target a
different platform. These instructions are written using `podman`, but `docker` should work too.
1. Install container software: `sudo apt install podman`.
2. Install database server: `sudo apt install mysql-server-8.0`. Create a database called `reminders`
2. Install database server: `sudo apt install mysql-server-8.0`. Create a database called
`reminders`
3. Install SQLx CLI: `cargo install sqlx-cli`
4. From the source code directory, execute `sqlx migrate run`
5. Build container image: `podman build -t reminder-rs .`
6. Build with podman: `podman run --rm --network=host -v "$PWD":/mnt -w /mnt -e "DATABASE_URL=mysql://user@localhost/reminders" reminder-rs cargo deb`
6. Build with podman:
`podman run --rm --network=host -v "$PWD":/mnt -w /mnt -e "DATABASE_URL=mysql://user@localhost/reminders" reminder-rs cargo deb`
### Compiling for other target
1. Install requirements:
`sudo apt install gcc gcc-multilib cmake libssl-dev build-essential python3-dateparser`
1. Install requirements:
`sudo apt install gcc gcc-multilib cmake libssl-dev build-essential python3-dateparser`
2. Install rustup from https://rustup.rs
3. Install the nightly toolchain: `rustup toolchain default nightly`
4. Install database server: `sudo apt install mysql-server-8.0`. Create a database called `reminders`.
4. Install database server: `sudo apt install mysql-server-8.0`. Create a database called
`reminders`.
5. Install `sqlx-cli`: `cargo install sqlx-cli`.
6. Run migrations: `sqlx migrate run`.
7. Set environment variables:
* `DATABASE_URL` - the URL of your MySQL database (`mysql://user[:password]@domain/database`)
* `DATABASE_URL` - the URL of your MySQL database (`mysql://user[:password]@domain/database`)
8. Build: `cargo build --release`
### Configuring
Reminder Bot reads a number of environment variables. Some are essential, and others have hardcoded fallbacks. Environment variables can be loaded from a .env file in the working directory.
Reminder Bot reads a number of environment variables. Some are essential, and others have hardcoded
fallbacks. Environment variables can be loaded from a .env file in the working directory.
__Required Variables__
* `DATABASE_URL` - the URL of your MySQL database (`mysql://user[:password]@domain/database`)
* `DISCORD_TOKEN` - your application's bot user's authorization token
__Other Variables__
* `MIN_INTERVAL` - default `600`, defines the shortest interval the bot should accept
* `LOCAL_TIMEZONE` - default `UTC`, necessary for calculations in the natural language processor
* `SUBSCRIPTION_ROLES` - default `None`, accepts a list of Discord role IDs that are given to subscribed users
* `CNC_GUILD` - default `None`, accepts a single Discord guild ID for the server that the subscription roles belong to
* `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
* `SUBSCRIPTION_ROLES` - default `None`, accepts a list of Discord role IDs that are given to
subscribed users
* `CNC_GUILD` - default `None`, accepts a single Discord guild ID for the server that the
subscription roles belong to
* `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.
+21 -1
View File
@@ -8,6 +8,26 @@ fn main() {
.arg("run")
.arg("build")
.current_dir(Path::new("reminder-dashboard"))
.env("VITE_VERSION", env!("CARGO_PKG_VERSION"))
.spawn()
.expect("Failed to build NPM");
.expect("Failed to build NPM")
.wait()
.expect("Failed to wait for NPM build");
Command::new("cp")
.arg("reminder-dashboard/dist/index.html")
.arg("static/index.html")
.spawn()
.expect("Failed to copy index.html")
.wait()
.expect("Failed to wait for index.html copy");
Command::new("cp")
.arg("-r")
.arg("reminder-dashboard/dist/static/assets")
.arg("static/")
.spawn()
.expect("Failed to copy assets")
.wait()
.expect("Failed to wait for assets copy");
}
+52 -14
View File
@@ -1,21 +1,59 @@
version: '3.3'
services:
bot:
build: ./Containerfile.run
image: reminder-rs-run
restart: always
expose:
- '80'
database:
image: mysql:8.0
restart: always
command: --log-bin-trust-function-creators=1
environment:
MYSQL_DATABASE: 'reminders'
MYSQL_USER: 'reminder-bot'
ports:
- '3306:3306'
expose:
- '3306'
MYSQL_ROOT_PASSWORD: root
MYSQL_DATABASE: reminders
MYSQL_USER: reminder-bot
MYSQL_PASSWORD: password
volumes:
- 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:
@@ -0,0 +1,11 @@
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)
);
@@ -0,0 +1,13 @@
SET foreign_key_checks = 0;
-- 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;
DROP TABLE IF EXISTS command_restrictions;
SET foreign_key_checks = 1;
@@ -0,0 +1 @@
ALTER TABLE reminders ADD COLUMN message_json JSON DEFAULT NULL;
+3 -1
View File
@@ -5,7 +5,9 @@
"scripts": {
"dev": "vite build --watch --mode development",
"build": "vite build",
"preview": "vite preview"
"test": "node --test tests/*.test.mjs",
"preview": "vite preview",
"prettier": "prettier -w src/"
},
"dependencies": {
"axios": "^1.5.1",
+14 -6
View File
@@ -1,3 +1,5 @@
import { prepareReminder } from "./components/Reminder/message";
import type { MessagePayload } from "./components/Reminder/message";
import axios from "axios";
enum ColorScheme {
@@ -38,6 +40,7 @@ type EmbedField = {
};
export type Reminder = {
message_json?: MessagePayload | null;
attachment: string | null;
attachment_name: string | null;
avatar: string | null;
@@ -116,7 +119,7 @@ type Template = {
embed_fields: EmbedField[] | null;
};
const USER_INFO_STALE_TIME = 120_000;
const USER_INFO_STALE_TIME = 86_400_000;
const GUILD_INFO_STALE_TIME = 300_000;
const OTHER_STALE_TIME = 120_000;
@@ -186,12 +189,14 @@ export const fetchGuildReminders = (guild: string) => ({
export const patchGuildReminder = (guild: string) => ({
mutationFn: (reminder: Reminder) =>
axios.patch(`/dashboard/api/guild/${guild}/reminders`, reminder),
axios.patch(`/dashboard/api/guild/${guild}/reminders`, prepareReminder(reminder)),
});
export const postGuildReminder = (guild: string) => ({
mutationFn: (reminder: Reminder) =>
axios.post(`/dashboard/api/guild/${guild}/reminders`, reminder).then((resp) => resp.data),
axios
.post(`/dashboard/api/guild/${guild}/reminders`, prepareReminder(reminder))
.then((resp) => resp.data),
});
export const deleteReminder = () => ({
@@ -236,7 +241,7 @@ export const fetchGuildTodos = (guild: string) => ({
});
export const patchGuildTodo = (guild: string) => ({
mutationFn: ({ id, todo }) => axios.patch(`/dashboard/api/guild/${guild}/todos/${id}`, todo),
mutationFn: ({id, todo}) => axios.patch(`/dashboard/api/guild/${guild}/todos/${id}`, todo),
});
export const postGuildTodo = (guild: string) => ({
@@ -257,9 +262,12 @@ export const fetchUserReminders = () => ({
export const postUserReminder = () => ({
mutationFn: (reminder: Reminder) =>
axios.post(`/dashboard/api/user/reminders`, reminder).then((resp) => resp.data),
axios
.post(`/dashboard/api/user/reminders`, prepareReminder(reminder))
.then((resp) => resp.data),
});
export const patchUserReminder = () => ({
mutationFn: (reminder: Reminder) => axios.patch(`/dashboard/api/user/reminders`, reminder),
mutationFn: (reminder: Reminder) =>
axios.patch(`/dashboard/api/user/reminders`, prepareReminder(reminder)),
});
@@ -0,0 +1,43 @@
import { createContext } from "preact";
import { useContext } from "preact/compat";
import { useEffect, useState } from "preact/hooks";
import { fetchUserInfo } from "../../api";
import { useQuery } from "react-query";
type TColorScheme = "light" | "dark";
type TColorSchemeContext = {
colorScheme: TColorScheme;
};
const ColorSchemeContext = createContext({ colorScheme: "light" } as TColorSchemeContext);
export const ColorSchemeProvider = ({ children }) => {
const { data } = useQuery({ ...fetchUserInfo() });
const [activeScheme, setActiveScheme] = useState<TColorScheme>("light");
useEffect(() => {
const preference = data?.preferences?.dashboard_color_scheme || "system";
if (preference === "system") {
const mediaQuery = window.matchMedia("(prefers-color-scheme: dark)");
const handleChange = () => {
setActiveScheme(mediaQuery.matches ? "dark" : "light");
};
handleChange();
mediaQuery.addEventListener("change", handleChange);
return () => mediaQuery.removeEventListener("change", handleChange);
} else {
setActiveScheme(preference as TColorScheme);
}
}, [data]);
return (
<ColorSchemeContext.Provider value={{ colorScheme: activeScheme }}>
{children}
</ColorSchemeContext.Provider>
);
};
export const useColorScheme = () => useContext(ColorSchemeContext);
+58 -51
View File
@@ -1,59 +1,66 @@
import { Sidebar } from "../Sidebar";
import { QueryClient, QueryClientProvider } from "react-query";
import { Route, Router, Switch } from "wouter";
import { Welcome } from "../Welcome";
import { Guild } from "../Guild";
import { FlashProvider } from "./FlashProvider";
import { TimezoneProvider } from "./TimezoneProvider";
import { User } from "../User";
import { GuildReminders } from "../Guild/GuildReminders";
import { GuildTodos } from "../Guild/GuildTodos";
import {Sidebar} from "../Sidebar";
import {QueryClient, QueryClientProvider} from "react-query";
import {Route, Router, Switch} from "wouter";
import {Welcome} from "../Welcome";
import {Guild} from "../Guild";
import {FlashProvider} from "./FlashProvider";
import {TimezoneProvider} from "./TimezoneProvider";
import {User} from "../User";
import {GuildReminders} from "../Guild/GuildReminders";
import {GuildTodos} from "../Guild/GuildTodos";
import {ColorSchemeProvider, useColorScheme} from "./ColorSchemeProvider";
import {useEffect} from "preact/hooks";
export function App() {
const queryClient = new QueryClient();
const InnerApp = () => {
const {colorScheme} = useColorScheme();
let scheme = "light";
// if (window.matchMedia("(prefers-color-scheme: dark)").matches) {
// scheme = "dark";
// }
useEffect(() => {
const body = document.querySelector("body");
body.className = body.className.replace(/scheme-\w+/g, "");
body.classList.add(`scheme-${colorScheme}`);
}, [colorScheme]);
return (
<Router base={"/dashboard"}>
<div class={`columns is-gapless dashboard-frame scheme-${colorScheme}`}>
<Sidebar/>
<div class="column is-main-content">
<div style={{margin: "0 12px 12px 12px"}}>
<Switch>
<Route path={"/@me/reminders"} component={User}></Route>
<Route path={"/:guild/reminders"}>
<Guild>
<GuildReminders/>
</Guild>
</Route>
<Route path={"/:guild/todos"}>
<Guild>
<GuildTodos/>
</Guild>
</Route>
<Route>
<Welcome/>
</Route>
</Switch>
</div>
</div>
</div>
</Router>
);
};
const queryClient = new QueryClient();
export function App() {
return (
<QueryClientProvider client={queryClient}>
<TimezoneProvider>
<FlashProvider>
<Router base={"/dashboard"}>
<div class={`columns is-gapless dashboard-frame scheme-${scheme}`}>
<Sidebar />
<div class="column is-main-content">
<div style={{ margin: "0 12px 12px 12px" }}>
<Switch>
<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>
<Welcome />
</Route>
</Switch>
</div>
</div>
</div>
</Router>
</FlashProvider>
</TimezoneProvider>
<ColorSchemeProvider>
<TimezoneProvider>
<FlashProvider>
<InnerApp/>
</FlashProvider>
</TimezoneProvider>
</ColorSchemeProvider>
</QueryClientProvider>
);
}
@@ -2,4 +2,4 @@
> * {
margin: 2px;
}
}
}
@@ -1,5 +1,5 @@
import {JSX} from "preact";
import {createPortal} from "preact/compat";
import { JSX } from "preact";
import { createPortal } from "preact/compat";
type Props = {
setModalOpen: (open: boolean) => never;
@@ -9,7 +9,7 @@ type Props = {
children: string | JSX.Element | JSX.Element[] | (() => JSX.Element);
};
export const Modal = ({setModalOpen, title, onSubmit, onSubmitText, children}: Props) => {
export const Modal = ({ setModalOpen, title, onSubmit, onSubmitText, children }: Props) => {
const body = document.querySelector("body");
return createPortal(
@@ -0,0 +1,69 @@
import { Embed } from "./index";
import { ReminderContext, useReminder } from "../ReminderContext";
import { Reminder } from "../../../api";
import {
addEmbed,
embedFields,
embedTextLength,
MAX_EMBEDS,
MAX_EMBED_TEXT,
reminderEmbeds,
removeEmbed,
updateEmbed,
} from "../message";
const EmbedEditor = ({ index }: { index: number }) => {
const [reminder, setReminder] = useReminder();
const embed = reminderEmbeds(reminder)[index];
const update = (change: (r: Reminder) => Reminder) => {
setReminder((current) => updateEmbed(current, index, change));
};
return (
<div class="reminder-embed-editor" role="group" aria-label={`Embed ${index + 1}`}>
<ReminderContext.Provider value={[{ ...reminder, ...embedFields(embed) }, update]}>
<Embed />
</ReminderContext.Provider>
<button
type="button"
class="button is-small is-text remove-embed"
aria-label={`Remove embed ${index + 1}`}
onClick={() => setReminder((current) => removeEmbed(current, index))}
>
<i class="fas fa-trash" aria-hidden="true" />
</button>
</div>
);
};
export const Embeds = () => {
const [reminder, setReminder] = useReminder();
const embeds = reminderEmbeds(reminder);
const textLength = embedTextLength(embeds);
return (
<div class="reminder-embeds">
{embeds.map((_, index) => (
<EmbedEditor key={index} index={index} />
))}
<div class="add-embed-row">
<button
type="button"
class="button is-small is-rounded add-embed"
aria-label="Add embed"
title={embeds.length >= MAX_EMBEDS ? "Maximum of 10 embeds" : "Add embed"}
disabled={embeds.length >= MAX_EMBEDS}
onClick={() => setReminder(addEmbed)}
>
<i class="fas fa-plus" aria-hidden="true" />
</button>
<span
class={`embed-limit${textLength > MAX_EMBED_TEXT ? " has-text-danger" : ""}`}
aria-live="polite"
>
{embeds.length}/{MAX_EMBEDS} embeds · {textLength}/{MAX_EMBED_TEXT} characters
</span>
</div>
</div>
);
};
@@ -6,7 +6,10 @@ export const Fields = () => {
return (
<div class={"embed-multifield-box"}>
{[...embed_fields, { value: "", title: "", inline: true }].map((field, index) => (
{[
...(embed_fields || []),
...(embed_fields?.length >= 25 ? [] : [{ value: "", title: "", inline: true }]),
].map((field, index) => (
<Field
{...field}
index={index}
@@ -6,13 +6,14 @@ import { Color } from "./Color";
import { Fields } from "./Fields";
import { Reminder } from "../../../api";
import { ImagePicker } from "../ImagePicker";
import { DEFAULT_EMBED_COLOR } from "../message";
import { useReminder } from "../ReminderContext";
function intToColor(num: number) {
return `#${num.toString(16).padStart(6, "0")}`;
}
export const DEFAULT_COLOR = 9418359;
export const DEFAULT_COLOR = DEFAULT_EMBED_COLOR;
export const Embed = () => {
const [reminder, setReminder] = useReminder();
@@ -21,12 +22,12 @@ export const Embed = () => {
<div
class="discord-embed"
style={{
borderLeftColor: intToColor(reminder.embed_color || DEFAULT_COLOR),
borderLeftColor: intToColor(reminder.embed_color ?? DEFAULT_COLOR),
}}
>
<div class="embed-body">
<Color
color={intToColor(reminder.embed_color || DEFAULT_COLOR)}
color={intToColor(reminder.embed_color ?? DEFAULT_COLOR)}
setReminder={setReminder}
></Color>
<div class="a">
@@ -0,0 +1,82 @@
import { useReminder } from "./ReminderContext";
import {
addLinkButton,
canAddLinkButton,
linkButtons,
MAX_LINK_BUTTONS,
updateLinkButton,
} from "./message";
export const LinkButtons = () => {
const [reminder, setReminder] = useReminder();
const buttons = linkButtons(reminder);
return (
<div class="reminder-link-buttons" role="group" aria-label="Link buttons">
{buttons.map(({ button, rowIndex, buttonIndex }, index) => (
<div class="link-button-editor" key={`${rowIndex}-${buttonIndex}`}>
<label class="link-button-label">
Button label
<input
class="input is-small"
aria-label={`Link button ${index + 1} label`}
maxLength={80}
value={button.label || ""}
onInput={(event) => {
const label = event.currentTarget.value;
setReminder((current) =>
updateLinkButton(current, rowIndex, buttonIndex, { label }),
);
}}
/>
</label>
<label class="link-button-url">
URL
<input
class="input is-small"
type="url"
aria-label={`Link button ${index + 1} URL`}
placeholder="https://example.com"
maxLength={512}
value={button.url || ""}
onInput={(event) => {
const url = event.currentTarget.value;
setReminder((current) =>
updateLinkButton(current, rowIndex, buttonIndex, { url }),
);
}}
/>
</label>
<button
type="button"
class="button is-small is-text"
aria-label={`Remove link button ${index + 1}`}
onClick={() =>
setReminder((current) =>
updateLinkButton(current, rowIndex, buttonIndex, null),
)
}
>
<i class="fas fa-trash" aria-hidden="true" />
</button>
</div>
))}
<div class="add-link-button-row">
<button
type="button"
class="button is-small is-rounded"
disabled={!canAddLinkButton(reminder)}
onClick={() => setReminder(addLinkButton)}
>
<span class="icon is-small">
<i class="fas fa-plus" aria-hidden="true" />
</span>
<span>Add link button</span>
</button>
<span class="link-button-limit" aria-live="polite">
{buttons.length}/{MAX_LINK_BUTTONS} link buttons
</span>
</div>
</div>
);
};
@@ -82,6 +82,7 @@ const LoadTemplateModal = ({ setModalOpen }) => {
setReminder((reminder) => ({
...reminder,
...template,
message_json: null,
// drop the template's ID
id: undefined,
}));
@@ -1,7 +1,8 @@
import { Username } from "./Username";
import { Content } from "./Content";
import { Embed } from "./Embed";
import { Embeds } from "./Embed/Embeds";
import { Avatar } from "./Avatar";
import { LinkButtons } from "./LinkButtons";
export const Message = () => (
<div class="column discord-frame">
@@ -15,7 +16,8 @@ export const Message = () => (
<div class="content">
<Username />
<Content />
<Embed />
<Embeds />
<LinkButtons />
</div>
</div>
</article>
@@ -0,0 +1,303 @@
import type { Reminder } from "../../api";
export const MAX_EMBEDS = 10;
export const MAX_EMBED_TEXT = 6000;
export const DEFAULT_EMBED_COLOR = 9418359;
export const MAX_BUTTON_ROWS = 5;
export const MAX_BUTTONS_PER_ROW = 5;
export const MAX_LINK_BUTTONS = MAX_BUTTON_ROWS * MAX_BUTTONS_PER_ROW;
export type MessageComponent = {
type: number;
style?: number;
label?: string;
url?: string;
components?: MessageComponent[];
[key: string]: unknown;
};
export type DiscordEmbed = {
title?: string;
description?: string;
color?: number;
author?: { name?: string; icon_url?: string; [key: string]: unknown };
footer?: { text?: string; icon_url?: string; [key: string]: unknown };
image?: { url?: string; [key: string]: unknown };
thumbnail?: { url?: string; [key: string]: unknown };
fields?: { name: string; value: string; inline?: boolean }[];
[key: string]: unknown;
};
export type MessagePayload = {
content?: string;
tts?: boolean;
username?: string;
avatar_url?: string;
embeds?: DiscordEmbed[];
attachments?: { id: number; filename: string }[];
components?: MessageComponent[];
[key: string]: unknown;
};
export function embedFields(embed: DiscordEmbed) {
return {
embed_title: embed.title || "",
embed_description: embed.description || "",
embed_color: embed.color ?? DEFAULT_EMBED_COLOR,
embed_author: embed.author?.name || "",
embed_author_url: embed.author?.icon_url || null,
embed_footer: embed.footer?.text || "",
embed_footer_url: embed.footer?.icon_url || null,
embed_image_url: embed.image?.url || null,
embed_thumbnail_url: embed.thumbnail?.url || null,
embed_fields: (embed.fields || []).map(({ name, value, inline }) => ({
title: name,
value,
inline: inline ?? false,
})),
};
}
export function toEmbed(reminder: Reminder, original: DiscordEmbed = {}): DiscordEmbed {
const embed: DiscordEmbed = { ...original, color: reminder.embed_color };
for (const [key, value] of [
["title", reminder.embed_title],
["description", reminder.embed_description],
]) {
if (value) embed[key] = value;
else delete embed[key];
}
for (const [key, textKey, text, icon] of [
["author", "name", reminder.embed_author, reminder.embed_author_url],
["footer", "text", reminder.embed_footer, reminder.embed_footer_url],
]) {
const part = { ...(original[key] as object) };
if (text) part[textKey] = text;
else delete part[textKey];
if (icon) part["icon_url"] = icon;
else delete part["icon_url"];
if (Object.keys(part).length) embed[key] = part;
else delete embed[key];
}
for (const [key, url] of [
["image", reminder.embed_image_url],
["thumbnail", reminder.embed_thumbnail_url],
]) {
if (url) embed[key] = { ...(original[key] as object), url };
else delete embed[key];
}
const fields = (reminder.embed_fields || []).map(({ title, value, inline }) => ({
name: title,
value,
inline,
}));
if (fields.length) embed.fields = fields;
else delete embed.fields;
return embed;
}
export function hasEmbedContent(embed: DiscordEmbed): boolean {
return (
[
embed.title,
embed.description,
embed.author?.name,
embed.footer?.text,
embed.footer?.icon_url,
embed.image?.url,
embed.thumbnail?.url,
].some((value) => !!value?.trim()) ||
!!embed.fields?.length ||
Object.keys(embed).some(
(key) =>
![
"title",
"description",
"author",
"footer",
"image",
"thumbnail",
"color",
"fields",
].includes(key),
)
);
}
export function reminderEmbeds(reminder: Reminder): DiscordEmbed[] {
const embeds = reminder.message_json?.embeds;
if (embeds) return embeds.length ? embeds : [{ color: DEFAULT_EMBED_COLOR }];
return [toEmbed(reminder)];
}
export function updateEmbed(
reminder: Reminder,
index: number,
update: (r: Reminder) => Reminder,
): Reminder {
const embeds = [...reminderEmbeds(reminder)];
const original = embeds[index];
if (!original) return reminder;
const changed = update({ ...reminder, ...embedFields(original) });
embeds[index] = toEmbed(changed, original);
return {
...reminder,
...embedFields(embeds[0]),
message_json: { ...reminder.message_json, embeds },
};
}
export function addEmbed(reminder: Reminder): Reminder {
const embeds = reminderEmbeds(reminder);
if (embeds.length >= MAX_EMBEDS) return reminder;
return {
...reminder,
message_json: {
...reminder.message_json,
embeds: [...embeds, { color: DEFAULT_EMBED_COLOR }],
},
};
}
export function removeEmbed(reminder: Reminder, index: number): Reminder {
const embeds = reminderEmbeds(reminder).filter((_, i) => i !== index);
return {
...reminder,
...embedFields(embeds[0] || {}),
message_json: { ...reminder.message_json, embeds },
};
}
export function embedTextLength(embeds: DiscordEmbed[]): number {
return embeds.reduce(
(sum, embed) =>
sum +
[
embed.title,
embed.description,
embed.author?.name,
embed.footer?.text,
...(embed.fields || []).flatMap((field) => [field.name, field.value]),
].reduce((sum, value) => sum + Array.from((value || "").trim()).length, 0),
0,
);
}
export function linkButtons(reminder: Reminder) {
return (reminder.message_json?.components || []).flatMap((row, rowIndex) =>
row.type === 1
? (row.components || []).flatMap((button, buttonIndex) =>
button.type === 2 && button.style === 5
? [{ button, rowIndex, buttonIndex }]
: [],
)
: [],
);
}
function availableButtonRow(components: MessageComponent[]): number {
return components.findIndex(
(row) =>
row.type === 1 &&
!!row.components &&
row.components.length < MAX_BUTTONS_PER_ROW &&
row.components.every((button) => button.type === 2),
);
}
export function canAddLinkButton(reminder: Reminder): boolean {
const components = reminder.message_json?.components || [];
return availableButtonRow(components) >= 0 || components.length < MAX_BUTTON_ROWS;
}
export function addLinkButton(reminder: Reminder): Reminder {
if (!canAddLinkButton(reminder)) return reminder;
const components = [...(reminder.message_json?.components || [])];
const rowIndex = availableButtonRow(components);
const button: MessageComponent = { type: 2, style: 5, label: "Open link", url: "" };
if (rowIndex >= 0) {
const row = components[rowIndex];
components[rowIndex] = { ...row, components: [...row.components, button] };
} else components.push({ type: 1, components: [button] });
return { ...reminder, message_json: { ...reminder.message_json, components } };
}
export function updateLinkButton(
reminder: Reminder,
rowIndex: number,
buttonIndex: number,
change: { label?: string; url?: string } | null,
): Reminder {
const components = [...(reminder.message_json?.components || [])];
const row = components[rowIndex];
const button = row?.components?.[buttonIndex];
if (row?.type !== 1 || button?.type !== 2 || button.style !== 5) return reminder;
const buttons = [...row.components];
if (change) buttons[buttonIndex] = { ...button, ...change };
else buttons.splice(buttonIndex, 1);
if (buttons.length) components[rowIndex] = { ...row, components: buttons };
else components.splice(rowIndex, 1);
return { ...reminder, message_json: { ...reminder.message_json, components } };
}
function validateComponents(components: MessageComponent[] = []) {
if (components.length > MAX_BUTTON_ROWS)
throw new Error(`A reminder can have at most ${MAX_BUTTON_ROWS} button rows.`);
for (const row of components) {
if (row.type !== 1) continue;
if (
!Array.isArray(row.components) ||
!row.components.length ||
row.components.length > MAX_BUTTONS_PER_ROW
)
throw new Error(
`A button row must have between 1 and ${MAX_BUTTONS_PER_ROW} components.`,
);
for (const button of row.components) {
if (button.type !== 2 || button.style !== 5) continue;
if (
(button.label !== undefined && typeof button.label !== "string") ||
Array.from(button.label || "").length > 80 ||
(!button.label && (!button.emoji || typeof button.emoji !== "object"))
)
throw new Error(
"Link buttons require a label of at most 80 characters or an emoji.",
);
let url: URL;
try {
url = new URL(button.url);
} catch {
throw new Error("Link buttons require a valid HTTP or HTTPS URL.");
}
if (!["http:", "https:"].includes(url.protocol) || Array.from(button.url).length > 512)
throw new Error(
"Link buttons require an HTTP or HTTPS URL of at most 512 characters.",
);
if (button.custom_id !== undefined || button.sku_id !== undefined)
throw new Error("Link buttons cannot have a custom_id or sku_id.");
}
}
}
export function prepareReminder(reminder: Reminder): Reminder {
validateComponents(reminder.message_json?.components);
const embeds = reminderEmbeds(reminder).filter(hasEmbedContent);
if (embeds.length > MAX_EMBEDS)
throw new Error(`A reminder can have at most ${MAX_EMBEDS} embeds.`);
if (embedTextLength(embeds) > MAX_EMBED_TEXT)
throw new Error(`Embed text exceeds ${MAX_EMBED_TEXT} characters across all embeds.`);
const message: MessagePayload = {
...reminder.message_json,
content: reminder.content,
tts: reminder.tts,
embeds,
};
if (reminder.username) message.username = reminder.username;
else delete message.username;
if (reminder.avatar) message.avatar_url = reminder.avatar;
else delete message.avatar_url;
if (reminder.attachment_name)
message.attachments = [{ id: 0, filename: reminder.attachment_name }];
else delete message.attachments;
return { ...reminder, ...embedFields(embeds[0] || {}), message_json: message };
}
@@ -11,7 +11,7 @@
border-radius: 8px;
margin: 4px;
padding: 4px;
box-shadow: 0 0 5px 0 rgba(0,0,0,0.75);
box-shadow: 0 0 5px 0 rgba(0, 0, 0, 0.75);
.highlight {
background-color: #35373c;
@@ -34,3 +34,55 @@ div.reminderContent {
background-color: #f5f5f5;
border-radius: 8px;
}
.reminder-embed-editor {
position: relative;
margin-bottom: 12px;
.discord-embed {
padding-bottom: 36px;
}
.remove-embed {
position: absolute;
right: 24px;
bottom: 4px;
}
}
.add-embed-row,
.add-link-button-row {
display: flex;
align-items: center;
gap: 10px;
margin-top: 8px;
.embed-limit,
.link-button-limit {
font-size: 0.8rem;
color: #737373;
}
}
.reminder-link-buttons {
margin-top: 16px;
}
.link-button-editor {
display: flex;
align-items: flex-end;
flex-wrap: wrap;
gap: 8px;
margin-bottom: 8px;
label {
font-size: 0.8rem;
}
.link-button-label {
flex: 1 1 140px;
}
.link-button-url {
flex: 2 1 200px;
}
}
@@ -66,7 +66,7 @@ aside.menu {
padding-left: 4px;
}
@media screen and (max-width: 1023px),print {
@media screen and (max-width: 1023px), print {
.columns:not(.is-desktop) {
flex-direction: column;
}
@@ -5,6 +5,12 @@ import { useRef, useState } from "preact/hooks";
import { ICON_FLASH_TIME } from "../../consts";
import { useFlash } from "../App/FlashContext";
enum ColorScheme {
System = "system",
Dark = "dark",
Light = "light",
}
export const UserPreferences = () => {
const [modalOpen, setModalOpen] = useState(false);
@@ -96,6 +102,40 @@ const PreferencesModal = ({ setModalOpen }) => {
</label>
</div>
<br></br>
<div style={{ display: "flex", flexDirection: "row", alignContent: "center" }}>
<label>
<div class={"is-inline-block"} style={{ marginRight: "6px" }}>
Dashboard Color Scheme:
</div>
<div class={"control"}>
<div class={"is-inline-block select"}>
{isLoading && <i class={"fa fa-spinner"} />}
{isSuccess && (
<select
class={"channel-selector"}
value={
updatedSettings.dashboard_color_scheme === undefined
? data.preferences.dashboard_color_scheme
: updatedSettings.dashboard_color_scheme
}
onChange={(e) =>
setUpdatedSettings((s) => ({
...s,
dashboard_color_scheme: (e.target as HTMLSelectElement)
.value as ColorScheme,
}))
}
>
<option value={ColorScheme.System}>System default</option>
<option value={ColorScheme.Light}>Light</option>
<option value={ColorScheme.Dark}>Dark</option>
</select>
)}
</div>
</div>
</label>
</div>
<br></br>
<div class="has-text-centered">
<button
class="button is-success is-outlined"
@@ -14,6 +14,9 @@ export const Welcome = () => (
<p>
<strong>Please report bugs!</strong> I can't fix issues if I am unaware of them.
</p>
<p>
Client version: {import.meta.env.VITE_VERSION}
</p>
</div>
</section>
);
+240 -18
View File
@@ -2,58 +2,280 @@
/* override styles for when the div is collapsed */
div.reminderContent.is-collapsed .column.discord-frame {
display: none;
display: none;
}
div.reminderContent.is-collapsed .column.settings {
display: none;
display: none;
}
div.reminderContent.is-collapsed .reminder-settings {
margin-bottom: 0;
margin-bottom: 0;
}
div.reminderContent.is-collapsed .button-row {
display: none;
display: none;
}
div.reminderContent.is-collapsed .button-row-edit {
display: none;
display: none;
}
div.reminderContent.is-collapsed .reminder-topbar {
padding-bottom: 0;
padding-bottom: 0;
}
div.reminderContent.is-collapsed .invert-collapses {
display: inline-flex;
display: inline-flex;
}
div.reminderContent .invert-collapses {
display: none;
display: none;
}
div.reminderContent.is-collapsed input[name="name"] {
display: inline-flex;
flex-grow: 1;
border: none;
background: none;
box-shadow: none;
opacity: 1;
display: inline-flex;
flex-grow: 1;
border: none;
background: none;
box-shadow: none;
opacity: 1;
}
div.reminderContent.is-collapsed .hide-box {
display: inline-flex;
display: inline-flex;
}
div.reminderContent.is-collapsed .hide-box i {
transform: rotate(90deg);
transform: rotate(90deg);
}
.button.is-success:not(.is-outlined) {
color: white;
color: white;
}
.button.is-outlined.is-success:not(.is-focused, :hover, :focus) {
background-color: white;
background-color: white;
}
@import "vars";
.scheme-light {
background-color: $primary-background-light;
color: $primary-text-light;
.column.is-main-content {
background-color: $primary-background-light;
}
.reminderContent {
background-color: $secondary-background-light;
}
}
.scheme-dark {
background-color: $secondary-background-dark;
color: $primary-text-dark;
.column.is-main-content {
background-color: $secondary-background-dark;
}
.reminderContent {
background-color: $primary-background-dark;
color: $primary-text-dark;
}
.title,
.subtitle,
.label,
.help,
strong {
color: $primary-text-dark;
}
input,
textarea,
select,
.input,
.textarea {
background-color: $secondary-background-dark;
color: $primary-text-dark;
border-color: $contrast-background-dark;
&::placeholder {
color: #aaa;
}
}
// Buttons
.button.is-outlined.is-success:not(.is-focused, :hover, :focus) {
background-color: $primary-background-dark;
color: #48c78e;
border-color: #48c78e;
}
.button.is-outlined.is-danger:not(.is-focused, :hover, :focus) {
background-color: $primary-background-dark;
}
.button.is-outlined.is-warning:not(.is-focused, :hover, :focus) {
background-color: $primary-background-dark;
}
.button:not(.is-success, .is-danger, .is-warning, .is-light) {
background-color: $secondary-background-dark;
color: $primary-text-dark;
border-color: $contrast-background-dark;
}
.button.is-light {
background-color: $secondary-background-dark;
color: $primary-text-dark;
}
// Modal
.modal-card-head,
.modal-card-foot {
background-color: $contrast-background-dark;
border-color: $contrast-background-dark;
}
.modal-card-head .modal-card-title {
color: $primary-text-dark;
}
.modal-card-body {
background-color: $primary-background-dark;
color: $primary-text-dark;
}
// Navbar
.navbar {
background-color: $contrast-background-dark;
}
.navbar-item,
.navbar-burger {
color: $primary-text-dark;
}
// Select dropdown arrow
.select:not(.is-multiple):not(.is-loading)::after {
border-color: $primary-text-dark;
}
.select select {
background-color: $secondary-background-dark;
color: $primary-text-dark;
border-color: $contrast-background-dark;
}
// Interval selector inputs
.interval-group input {
background-color: $secondary-background-dark !important;
color: $primary-text-dark;
}
// Notification
.notification {
background-color: $secondary-background-dark;
color: $primary-text-dark;
}
// Links
a:not(.menu a):not(.button) {
color: #7eaef1;
}
// Content area
.content {
color: $primary-text-dark;
}
// Checkbox
.checkbox {
color: $primary-text-dark;
}
// Message input
.message-input {
background-color: $secondary-background-dark;
color: $primary-text-dark;
}
// Box
.box {
background-color: $secondary-background-dark;
color: $primary-text-dark;
}
// Panel
.panel {
background-color: $secondary-background-dark;
.panel-heading {
background-color: $contrast-background-dark;
color: $primary-text-dark;
}
.panel-block {
color: $primary-text-dark;
border-color: $contrast-background-dark;
}
}
// Horizontal rule
hr {
background-color: $contrast-background-dark;
}
// Tag
.tag:not(.is-success, .is-danger, .is-warning, .is-info) {
background-color: $contrast-background-dark;
color: $primary-text-dark;
}
// Todo
.todo {
color: $primary-text-dark;
}
// Page links
.page-links .button {
background-color: $secondary-background-dark;
color: $primary-text-dark;
border-color: $contrast-background-dark;
}
// Loading overlay
.load-screen {
background-color: rgba(36, 36, 36, 0.8);
color: $primary-text-dark;
}
// File/attachment input
.file-cta {
background-color: $secondary-background-dark;
color: $primary-text-dark;
border-color: $contrast-background-dark;
}
.file-name {
background-color: $secondary-background-dark;
color: $primary-text-dark;
border-color: $contrast-background-dark;
}
// Date/time inputs color scheme
input[type="date"],
input[type="time"],
input[type="datetime-local"] {
color-scheme: dark;
}
// Modal background overlay
.modal-background {
background-color: rgba(10, 10, 10, 0.86);
}
}
+186
View File
@@ -0,0 +1,186 @@
import assert from "node:assert/strict";
import { readFile } from "node:fs/promises";
import test from "node:test";
import ts from "typescript";
// Run the pure editor helpers without a browser or additional test dependencies.
const source = await readFile(
new URL("../src/components/Reminder/message.ts", import.meta.url),
"utf8",
);
const { outputText } = ts.transpileModule(source, {
compilerOptions: { module: ts.ModuleKind.ESNext, target: ts.ScriptTarget.ES2020 },
});
const {
addEmbed,
embedFields,
prepareReminder,
reminderEmbeds,
removeEmbed,
updateEmbed,
addLinkButton,
canAddLinkButton,
linkButtons,
updateLinkButton,
} = await import(`data:text/javascript;base64,${Buffer.from(outputText).toString("base64")}`);
function reminder(embeds) {
return {
content: "hello",
tts: false,
username: "",
avatar: null,
attachment_name: null,
uid: "unchanged",
utc_time: "2026-10-12T12:00:00",
...embedFields(embeds?.[0] || {}),
message_json: embeds ? { embeds, allowed_mentions: { parse: [] } } : null,
};
}
test("editing one embed preserves the others and unedited payload properties", () => {
const original = reminder([
{ title: "first" },
{ description: "second", author: { name: "author", url: "https://example.com" } },
]);
const edited = updateEmbed(original, 1, (r) => ({ ...r, embed_description: "updated" }));
const payload = prepareReminder(edited).message_json;
assert.equal(payload.embeds[0].title, "first");
assert.equal(payload.embeds[1].description, "updated");
assert.equal(payload.embeds[1].author.url, "https://example.com");
assert.deepEqual(payload.allowed_mentions, { parse: [] });
assert.equal(edited.uid, original.uid);
assert.equal(original.message_json.embeds[1].description, "second");
});
test("adding stops at ten embeds and removal permits another", () => {
let value = reminder([{ title: "first" }]);
for (let i = 0; i < 12; i++) value = addEmbed(value);
assert.equal(reminderEmbeds(value).length, 10);
assert.equal(addEmbed(value), value);
value = removeEmbed(value, 0);
assert.equal(reminderEmbeds(value).length, 9);
value = updateEmbed(value, 0, (r) => ({ ...r, embed_title: "new first" }));
assert.equal(prepareReminder(value).message_json.embeds[0].title, "new first");
assert.equal(reminderEmbeds(addEmbed(value)).length, 10);
});
test("saving and reloading multiple embeds preserves order and drops blank editors", () => {
let value = addEmbed(reminder([{ description: "first" }]));
value = updateEmbed(value, 1, (r) => ({ ...r, embed_description: "second" }));
value = addEmbed(value);
const saved = prepareReminder(value);
assert.deepEqual(
saved.message_json.embeds.map((embed) => embed.description),
["first", "second"],
);
assert.deepEqual(reminderEmbeds(JSON.parse(JSON.stringify(saved))), saved.message_json.embeds);
assert.deepEqual(prepareReminder(reminder()).message_json.embeds, []);
});
test("legacy embeds are retained when a second embed is added", () => {
const legacy = {
...reminder(),
embed_title: "legacy",
embed_fields: [{ title: "name", value: "value", inline: true }],
};
const saved = prepareReminder(addEmbed(legacy));
assert.equal(saved.message_json.embeds[0].title, "legacy");
assert.deepEqual(saved.message_json.embeds[0].fields, [
{ name: "name", value: "value", inline: true },
]);
});
test("combined embed text accepts 6000 characters and rejects 6001", () => {
const embeds = [{ description: "x".repeat(3000) }, { description: "y".repeat(3000) }];
assert.equal(prepareReminder(reminder(embeds)).message_json.embeds.length, 2);
assert.throws(
() => prepareReminder(reminder([embeds[0], { description: "y".repeat(3001) }])),
/6000/,
);
assert.throws(
() => prepareReminder(reminder(Array.from({ length: 11 }, () => ({ title: "embed" })))),
/10 embeds/,
);
});
test("link buttons can be added, edited, saved, reloaded and removed without losing embeds", () => {
const original = reminder([{ title: "first" }, { title: "second" }]);
let value = addLinkButton(original);
value = updateLinkButton(value, 0, 0, { label: "Visit", url: "https://example.com" });
const saved = prepareReminder(value);
assert.deepEqual(saved.message_json.components, [
{
type: 1,
components: [{ type: 2, style: 5, label: "Visit", url: "https://example.com" }],
},
]);
assert.deepEqual(saved.message_json.embeds, original.message_json.embeds);
assert.deepEqual(saved.message_json.allowed_mentions, { parse: [] });
assert.equal(
linkButtons(JSON.parse(JSON.stringify(saved)))[0].button.url,
"https://example.com",
);
assert.deepEqual(
prepareReminder(updateLinkButton(saved, 0, 0, null)).message_json.components,
[],
);
assert.equal(original.message_json.components, undefined);
const legacy = updateLinkButton(addLinkButton({ ...reminder(), embed_title: "legacy" }), 0, 0, {
url: "https://example.com",
});
assert.equal(prepareReminder(legacy).message_json.embeds[0].title, "legacy");
});
test("link buttons fill five rows of five and preserve other components", () => {
let value = reminder();
for (let i = 0; i < 26; i++) value = addLinkButton(value);
assert.equal(linkButtons(value).length, 25);
assert.deepEqual(
value.message_json.components.map((row) => row.components.length),
[5, 5, 5, 5, 5],
);
assert.equal(canAddLinkButton(value), false);
assert.equal(addLinkButton(value), value);
value = updateLinkButton(value, 2, 2, null);
assert.equal(canAddLinkButton(value), true);
assert.equal(linkButtons(addLinkButton(value)).length, 25);
const other = { type: 1, id: 7, components: [{ type: 3, custom_id: "select", options: [] }] };
value = { ...reminder(), message_json: { components: [other] } };
value = addLinkButton(value);
assert.deepEqual(value.message_json.components[0], other);
assert.equal(linkButtons(value)[0].rowIndex, 1);
});
test("invalid link button URLs, labels and row limits are rejected on save", () => {
let value = addLinkButton(reminder());
assert.throws(() => prepareReminder(value), /URL/);
value = updateLinkButton(value, 0, 0, {
url: "https://example.com/" + "x".repeat(492),
label: "x".repeat(80),
});
assert.equal(prepareReminder(value).message_json.components.length, 1);
for (const change of [
{ url: "invalid" },
{ url: "javascript:alert(1)" },
{ url: "https://example.com/" + "x".repeat(493) },
{ label: "" },
{ label: "x".repeat(81) },
])
assert.throws(() => prepareReminder(updateLinkButton(value, 0, 0, change)));
const row = value.message_json.components[0];
assert.throws(
() => prepareReminder({ ...value, message_json: { components: Array(6).fill(row) } }),
/5 button rows/,
);
assert.throws(
() =>
prepareReminder({
...value,
message_json: {
components: [{ ...row, components: Array(6).fill(row.components[0]) }],
},
}),
/5 components/,
);
});
+8 -1
View File
@@ -3,6 +3,7 @@ use std::time::{SystemTime, UNIX_EPOCH};
use chrono_tz::TZ_VARIANTS;
use poise::serenity_prelude::AutocompleteChoice;
use crate::time_parser::cron_next_timestamp;
use crate::{models::CtxData, time_parser::natural_parser, Context};
pub async fn timezone_autocomplete(ctx: Context<'_>, partial: &str) -> Vec<String> {
@@ -42,7 +43,13 @@ pub async fn time_hint_autocomplete(ctx: Context<'_>, partial: &str) -> Vec<Auto
if partial.is_empty() {
vec![AutocompleteChoice::new("Start typing a time...".to_string(), "now".to_string())]
} else {
match natural_parser(partial, &ctx.timezone().await.to_string()).await {
let timezone = ctx.timezone().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) {
Ok(now) => {
let diff = timestamp - now.as_secs() as i64;
+2 -2
View File
@@ -75,8 +75,8 @@ Please select a unique name for your macro.",
CreateEmbed::new()
.title("Macro Recording Started")
.description(
"Run up to 5 commands, or type `/macro finish` to stop at any point.
Any commands ran as part of recording will be inconsequential",
"Run up to 5 commands to record in this macro. Use `/macro finish` to stop recording at any point.
Any commands performed during recording won't take any actual action- they are only captured for the macro.",
)
.color(*THEME_COLOR),
),
+2 -3
View File
@@ -56,7 +56,7 @@ impl Recordable for Options {
}),
};
let channel_id = if let Some(channel) = ctx.channel_id().to_channel_cached(&ctx.cache()) {
let channel_id = if let Some(channel) = ctx.cache().channel(ctx.channel_id()) {
if Some(channel.guild_id) == ctx.guild_id() {
flags.channel_id.unwrap_or_else(|| ctx.channel_id())
} else {
@@ -66,8 +66,7 @@ impl Recordable for Options {
ctx.channel_id()
};
let channel_name =
channel_id.to_channel_cached(&ctx.cache()).map(|channel| channel.name.clone());
let channel_name = ctx.cache().channel(channel_id).map(|channel| channel.name.clone());
let reminders = Reminder::from_channel(&ctx.data().database, channel_id, &flags).await;
+2 -2
View File
@@ -12,8 +12,6 @@ pub mod dashboard;
#[cfg(not(test))]
pub mod delete;
#[cfg(not(test))]
pub mod donate;
#[cfg(not(test))]
pub mod help;
#[cfg(not(test))]
pub mod info;
@@ -26,6 +24,8 @@ pub mod nudge;
#[cfg(not(test))]
pub mod offset;
#[cfg(not(test))]
pub mod patreon;
#[cfg(not(test))]
pub mod pause;
#[cfg(not(test))]
pub mod remind;
@@ -15,8 +15,8 @@ impl Recordable for Options {
let footer = footer(ctx);
ctx.send(CreateReply::default().embed(CreateEmbed::new().title("Donate")
.description("Thinking of adding a monthly contribution?
Click below for my Patreon and official bot server :)
.description("Thinking of subscribing?
Click below for my Patreon and official bot server
**https://www.patreon.com/jellywx/**
**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
• Use unlimited uploads on SoundFX
(Also, members of servers you __own__ will be able to set repeating reminders via commands)
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.
Just $2 USD/month!
@@ -41,8 +41,8 @@ Just $2 USD/month!
}
}
/// Details on supporting the bot and Patreon benefits
#[poise::command(slash_command, rename = "patreon", identifying_name = "patreon")]
pub async fn command(ctx: Context<'_>) -> Result<(), Error> {
/// Show Patreon information
#[poise::command(slash_command, rename = "info", identifying_name = "patreon_info")]
pub async fn info(ctx: Context<'_>) -> Result<(), Error> {
(Options {}).run(ctx).await
}
+57
View File
@@ -0,0 +1,57 @@
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
}
+11
View File
@@ -0,0 +1,11 @@
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(())
}
+50
View File
@@ -0,0 +1,50 @@
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
}
+2 -2
View File
@@ -63,7 +63,7 @@ impl ComponentDataModel {
let flags = pager.flags;
let channel_id = {
let channel_opt = component.channel_id.to_channel_cached(&ctx.cache);
let channel_opt = ctx.cache.channel(component.channel_id);
if let Some(channel) = channel_opt {
if Some(channel.guild_id) == component.guild_id {
@@ -85,7 +85,7 @@ impl ComponentDataModel {
.div_ceil(EMBED_DESCRIPTION_MAX_LENGTH);
let channel_name =
channel_id.to_channel_cached(&ctx.cache).map(|channel| channel.name.clone());
ctx.cache.channel(channel_id).map(|channel| channel.name.clone());
let next_page = pager.next_page(pages);
+45 -12
View File
@@ -1,4 +1,6 @@
use poise::{CommandInteractionType, CreateReply};
use crate::consts::THEME_COLOR;
use poise::{serenity_prelude::CreateEmbed, CommandInteractionType, CreateReply};
use serenity::builder::CreateEmbedFooter;
use crate::{consts::MACRO_MAX_COMMANDS, models::command_macro::RecordedCommand, Context, Error};
@@ -18,7 +20,18 @@ async fn macro_check(ctx: Context<'_>) -> bool {
.send(
CreateReply::default()
.ephemeral(true)
.content(format!("{} commands already recorded. Please use `/macro finish` to end recording.", MACRO_MAX_COMMANDS))
.embed(CreateEmbed::new()
.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;
} else {
@@ -28,9 +41,19 @@ async fn macro_check(ctx: Context<'_>) -> bool {
let _ = ctx
.send(
CreateReply::default()
.ephemeral(true)
.content("Command recorded to macro"),
CreateReply::default().ephemeral(true).embed(
CreateEmbed::new()
.title("💾 Currently recording 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;
}
@@ -38,8 +61,18 @@ async fn macro_check(ctx: Context<'_>) -> bool {
None => {
let _ = ctx
.send(
CreateReply::default().ephemeral(true).content(
"This command is not supported in macros yet.",
CreateReply::default().ephemeral(true).embed(
CreateEmbed::new()
.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;
@@ -74,6 +107,7 @@ async fn check_self_permissions(ctx: Context<'_>) -> bool {
return if permissions.send_messages()
&& permissions.embed_links()
&& manage_webhooks
&& permissions.view_channel()
{
true
} else {
@@ -81,12 +115,13 @@ async fn check_self_permissions(ctx: Context<'_>) -> bool {
.send(CreateReply::default().content(format!(
"The bot appears to be missing some permissions:
{} **View Channels**
{} **Send Message**
{} **Embed Links**
{} **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 \"Administrator\" will bypass permission checks",
if permissions.view_channel() { "✅" } else { "❌" },
if permissions.send_messages() { "✅" } else { "❌" },
if permissions.embed_links() { "✅" } else { "❌" },
if manage_webhooks { "✅" } else { "❌" },
@@ -100,9 +135,7 @@ Please check the bot's roles, and any channel overrides. Alternatively, giving t
manage_webhooks
}
None => {
return true;
}
None => true,
}
}
+11 -3
View File
@@ -10,6 +10,7 @@ mod event_handlers;
#[cfg(not(test))]
mod hooks;
mod interval_parser;
mod message;
mod metrics;
#[cfg(not(test))]
mod models;
@@ -48,8 +49,8 @@ use crate::test::TestContext;
use crate::{
commands::{
allowed_dm, clock, clock_context_menu::clock_context_menu, command_macro, dashboard,
delete, donate, help, info, look, multiline, nudge, offset, pause, remind, settings, timer,
timezone, todo, webhook,
delete, help, info, look, multiline, nudge, offset, patreon, pause, remind, settings,
timer, timezone, todo, webhook,
},
consts::THEME_COLOR,
event_handlers::listener,
@@ -165,7 +166,14 @@ async fn _main(tx: Sender<()>) -> Result<(), Box<dyn StdError + Send + Sync>> {
help::command(),
info::command(),
clock::command(),
donate::command(),
poise::Command {
subcommands: vec![
patreon::link::link(),
patreon::unlink::unlink(),
patreon::info::info(),
],
..patreon::command()
},
clock_context_menu(),
dashboard::command(),
timezone::command(),
+604
View File
@@ -0,0 +1,604 @@
//! Conversion between the dashboard's message fields and Discord webhook payloads.
use serde::{Deserialize, Serialize};
use serde_json::{json, Value};
use sqlx::{types::Json, FromRow, MySqlConnection};
#[derive(Default, Deserialize, Serialize, FromRow)]
#[serde(default)]
struct MessageFields {
content: String,
tts: bool,
username: Option<String>,
avatar: Option<String>,
embed_title: String,
embed_description: String,
embed_author: String,
embed_author_url: Option<String>,
embed_footer: String,
embed_footer_url: Option<String>,
embed_image_url: Option<String>,
embed_thumbnail_url: Option<String>,
embed_color: u32,
embed_fields: Option<Json<Value>>,
message_json: Option<Json<Value>>,
attachment_name: Option<String>,
has_attachment: bool,
}
const EMBED_PATHS: &[(&str, &[&str])] = &[
("embed_title", &["title"]),
("embed_description", &["description"]),
("embed_author", &["author", "name"]),
("embed_author_url", &["author", "icon_url"]),
("embed_footer", &["footer", "text"]),
("embed_footer_url", &["footer", "icon_url"]),
("embed_image_url", &["image", "url"]),
("embed_thumbnail_url", &["thumbnail", "url"]),
("embed_color", &["color"]),
("embed_fields", &["fields"]),
];
fn set_path(target: &mut Value, path: &[&str], value: Value) {
if !target.is_object() {
*target = json!({});
}
let object = target.as_object_mut().unwrap();
if path.len() == 1 {
if value.is_null() {
object.remove(path[0]);
} else {
object.insert(path[0].to_string(), value);
}
} else {
set_path(object.entry(path[0]).or_insert_with(|| json!({})), &path[1..], value);
}
}
fn embed_has_content(embed: &Value) -> bool {
["/title", "/description", "/author/name", "/footer/text", "/footer/icon_url",
"/image/url", "/thumbnail/url"]
.iter()
.any(|path| embed.pointer(path).and_then(Value::as_str).is_some_and(|s| !s.trim().is_empty()))
|| embed["fields"].as_array().is_some_and(|fields| !fields.is_empty())
// Preserve payload features the dashboard does not edit.
|| embed.as_object().is_some_and(|object| object.keys().any(|key|
!["title", "description", "author", "footer", "image", "thumbnail", "color", "fields"].contains(&key.as_str())))
}
fn discord_fields(value: &Value) -> Value {
match value.as_array() {
Some(fields) => Value::Array(
fields
.iter()
.map(|field| {
json!({
"name": field["title"], "value": field["value"], "inline": field["inline"]
})
})
.collect(),
),
None => json!([]),
}
}
/// Use an explicit payload verbatim, or build one from existing dashboard fields.
pub fn payload(reminder: &impl Serialize) -> Value {
let fields = serde_json::to_value(reminder).expect("Reminder fields are serializable");
if !fields["message_json"].is_null() {
return fields["message_json"].clone();
}
let mut payload = json!({ "content": fields["content"], "tts": fields["tts"] });
if fields["username"].as_str().is_some_and(|s| !s.is_empty()) {
payload["username"] = fields["username"].clone();
}
if !fields["avatar"].is_null() {
payload["avatar_url"] = fields["avatar"].clone();
}
let mut embed = json!({});
for (field, path) in EMBED_PATHS {
let value = if *field == "embed_fields" {
discord_fields(&fields[*field])
} else {
fields[*field].clone()
};
set_path(&mut embed, path, value);
}
if embed_has_content(&embed) {
payload["embeds"] = json!([embed]);
}
if (!fields["attachment"].is_null() || fields["has_attachment"].as_bool() == Some(true))
&& fields["attachment_name"].is_string()
{
payload["attachments"] = json!([{ "id": 0, "filename": fields["attachment_name"] }]);
}
payload
}
/// Validate Discord's link buttons, embed count and text limits.
pub fn validate_payload(message: &Value) -> Result<(), String> {
if !message.is_object() {
return Err("message_json must be an object".into());
}
if let Some(content) = message.get("content") {
if content.as_str().is_none_or(|text| text.chars().count() > 2000) {
return Err("Message content exceeds 2000 characters or is not text".into());
}
}
validate_components(message)?;
let Some(embeds) = message.get("embeds") else {
return Ok(());
};
let embeds = embeds.as_array().ok_or("embeds must be an array")?;
if embeds.len() > 10 {
return Err("A reminder can have at most 10 embeds".into());
}
let mut total = 0;
for (index, embed) in embeds.iter().enumerate() {
if !embed.is_object() {
return Err(format!("Embed {} must be an object", index + 1));
}
let mut check_text = |value: Option<&Value>, limit: usize| -> Result<(), String> {
if let Some(value) = value {
let text = value
.as_str()
.ok_or_else(|| format!("Embed {} has a non-text field", index + 1))?;
let length = text.trim().chars().count();
if length > limit {
return Err(format!(
"Embed {} exceeds a {} character field limit",
index + 1,
limit
));
}
total += length;
}
Ok(())
};
for (path, limit) in
[("/title", 256), ("/description", 4096), ("/author/name", 256), ("/footer/text", 2048)]
{
check_text(embed.pointer(path), limit)?;
}
if let Some(fields) = embed.get("fields") {
let fields = fields.as_array().ok_or("Embed fields must be an array")?;
if fields.len() > 25 {
return Err("An embed can have at most 25 fields".into());
}
for field in fields {
if !field.is_object() {
return Err("Embed fields must be objects".into());
}
for (name, limit) in [("name", 256), ("value", 1024)] {
let text = field.get(name).ok_or("Embed fields require a name and value")?;
if text.as_str().is_none_or(|text| text.trim().is_empty()) {
return Err("Embed fields require a non-empty name and value".into());
}
check_text(Some(text), limit)?;
}
}
}
}
if total > 6000 {
return Err("Embed text exceeds 6000 characters across all embeds".into());
}
Ok(())
}
fn validate_components(message: &Value) -> Result<(), String> {
let Some(components) = message.get("components") else {
return Ok(());
};
let rows = components.as_array().ok_or("components must be an array")?;
// Components V2 has a different layout and count limit. Leave its validation to Discord.
if message["flags"].as_u64().is_some_and(|flags| flags & (1 << 15) != 0) {
return Ok(());
}
if rows.len() > 5 {
return Err("A reminder can have at most 5 button rows (25 buttons)".into());
}
for row in rows {
if row["type"].as_u64() != Some(1) {
continue;
}
let buttons = row["components"].as_array().ok_or("Button rows require components")?;
if buttons.is_empty() || buttons.len() > 5 {
return Err("A button row must have between 1 and 5 components".into());
}
for button in buttons {
if button["type"].as_u64() != Some(2) || button["style"].as_u64() != Some(5) {
continue;
}
let label = button.get("label").and_then(Value::as_str);
if label.is_some_and(|label| label.chars().count() > 80)
|| (label.is_none_or(str::is_empty) && !button["emoji"].is_object())
|| button.get("label").is_some_and(|label| !label.is_string())
{
return Err(
"Link buttons require a label of at most 80 characters or an emoji".into()
);
}
let url = button["url"].as_str().ok_or("Link buttons require a URL")?;
if url.chars().count() > 512
|| !reqwest::Url::parse(url)
.is_ok_and(|url| ["http", "https"].contains(&url.scheme()))
{
return Err(
"Link buttons require an HTTP or HTTPS URL of at most 512 characters".into()
);
}
if button.get("custom_id").is_some() || button.get("sku_id").is_some() {
return Err("Link buttons cannot have a custom_id or sku_id".into());
}
}
}
Ok(())
}
/// Present the first embed through the existing dashboard controls.
pub fn response(reminder: impl Serialize) -> Value {
let mut reminder = serde_json::to_value(reminder).expect("Reminder fields are serializable");
if let Some(reminders) = reminder.as_array_mut() {
for reminder in reminders {
*reminder = response(&*reminder);
}
return reminder;
}
let payload = reminder["message_json"].clone();
if payload.is_null() {
return reminder;
}
reminder["content"] = payload["content"].as_str().unwrap_or_default().into();
reminder["tts"] = payload["tts"].as_bool().unwrap_or(false).into();
reminder["username"] = payload["username"].clone();
reminder["avatar"] = payload["avatar_url"].clone();
for (field, path) in EMBED_PATHS {
let value = payload["embeds"][0]
.pointer(&format!("/{}", path.join("/")))
.cloned()
.unwrap_or(Value::Null);
reminder[*field] = match *field {
"embed_fields" => Value::Array(value.as_array().map(|fields| fields.iter().map(|field| json!({
"title": field["name"], "value": field["value"], "inline": field["inline"].as_bool().unwrap_or(false)
})).collect()).unwrap_or_default()),
"embed_color" => value.as_u64().unwrap_or(0).into(),
"embed_title" | "embed_description" | "embed_author" | "embed_footer" => value.as_str().unwrap_or_default().into(),
_ => value,
};
}
reminder
}
pub fn is_message_field(field: &str) -> bool {
["content", "tts", "username", "avatar"].contains(&field)
|| EMBED_PATHS.iter().any(|(name, _)| *name == field)
}
fn patch(payload: &mut Value, field: &str, mut value: Value) {
if let Some((_, path)) = EMBED_PATHS.iter().find(|(name, _)| *name == field) {
if !payload["embeds"].is_array() {
payload["embeds"] = json!([]);
}
let embeds = payload["embeds"].as_array_mut().unwrap();
if embeds.is_empty() {
embeds.push(json!({}));
}
if field == "embed_fields" {
value = discord_fields(&value);
}
set_path(&mut embeds[0], path, value);
if !embed_has_content(&embeds[0]) {
embeds.remove(0);
}
} else {
if field == "username" && value.as_str() == Some("") {
value = Value::Null;
}
set_path(payload, &[if field == "avatar" { "avatar_url" } else { field }], value);
}
}
/// Convert legacy rows on first edit and preserve unedited payload keys.
pub async fn update_field(
connection: &mut MySqlConnection,
uid: &str,
field: &str,
value: &impl Serialize,
) -> Result<(), sqlx::Error> {
let fields = sqlx::query_as::<_, MessageFields>(
"SELECT content, tts, username, avatar, embed_title, embed_description,
embed_author, embed_author_url, embed_footer, embed_footer_url, embed_image_url,
embed_thumbnail_url, embed_color, embed_fields, message_json, attachment_name,
attachment IS NOT NULL AS has_attachment FROM reminders WHERE uid = ? FOR UPDATE",
)
.bind(uid)
.fetch_one(&mut *connection)
.await?;
let mut message = payload(&fields);
let value = if field == "attachments" {
if fields.has_attachment {
fields
.attachment_name
.as_ref()
.map(|name| json!([{ "id": 0, "filename": name }]))
.unwrap_or_else(|| json!([]))
} else {
json!([])
}
} else {
serde_json::to_value(value).map_err(|e| sqlx::Error::Encode(e.into()))?
};
patch(&mut message, field, value);
validate_payload(&message).map_err(sqlx::Error::Protocol)?;
sqlx::query("UPDATE reminders SET message_json = ? WHERE uid = ?")
.bind(Json(message))
.bind(uid)
.execute(connection)
.await?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn builds_discord_payload_and_omits_empty_embed() {
assert_eq!(
payload(&json!({"content": "hello", "tts": true})),
json!({"content": "hello", "tts": true})
);
let message = payload(&json!({"content": "", "tts": false, "embed_description": "body",
"embed_fields": [{"title": "heading", "value": "value", "inline": true}],
"attachment": "bytes", "attachment_name": "file.txt"}));
assert_eq!(message["embeds"][0]["fields"][0]["name"], "heading");
assert_eq!(message["attachments"], json!([{ "id": 0, "filename": "file.txt" }]));
assert_eq!(response(json!({"message_json": message}))["embed_description"], "body");
}
#[test]
fn explicit_payload_is_exact() {
let exact = json!({"content": "<<timenow:UTC:%Y>>", "flags": 32768, "components": [], "custom": true});
assert_eq!(
payload(&json!({"message_json": exact, "content": "ignored", "tts": true})),
exact
);
}
#[test]
fn null_payload_falls_back_to_legacy_fields() {
let legacy = json!({
"message_json": null, "content": "legacy", "tts": false,
"username": "Reminder", "avatar": "https://example.com/avatar.png"
});
assert_eq!(
payload(&legacy),
json!({
"content": "legacy", "tts": false, "username": "Reminder",
"avatar_url": "https://example.com/avatar.png"
})
);
let expected_response = legacy.clone();
assert_eq!(response(legacy), expected_response);
}
#[test]
fn dashboard_response_uses_payload_and_preserves_schedule() {
let reminder = response(json!({
"uid": "reminder-id", "utc_time": "2026-10-12T12:00:00", "enabled": true,
"content": "stale", "embed_description": "stale", "tts": false,
"message_json": {
"content": "current", "tts": true,
"embeds": [{"description": "body", "fields": [{"name": "heading", "value": "value"}]}]
}
}));
assert_eq!(reminder["content"], "current");
assert_eq!(reminder["tts"], true);
assert_eq!(reminder["embed_description"], "body");
assert_eq!(
reminder["embed_fields"],
json!([{"title": "heading", "value": "value", "inline": false}])
);
assert_eq!(reminder["uid"], "reminder-id");
assert_eq!(reminder["utc_time"], "2026-10-12T12:00:00");
assert_eq!(reminder["enabled"], true);
assert_eq!(reminder["message_json"]["embeds"].as_array().unwrap().len(), 1);
}
#[test]
fn clearing_webhook_identity_preserves_other_payload_keys() {
let mut message = json!({
"content": "hello", "username": "Reminder",
"avatar_url": "https://example.com/avatar.png", "flags": 4
});
patch(&mut message, "username", json!(""));
patch(&mut message, "avatar", Value::Null);
assert_eq!(message, json!({"content": "hello", "flags": 4}));
}
#[test]
fn edits_preserve_extra_payload_features() {
let mut message = json!({"flags": 4, "embeds": [{"description": "before", "url": "https://example.com"}, {"title": "second"}]});
patch(&mut message, "embed_description", json!("after"));
patch(&mut message, "embed_author_url", json!("https://example.com/icon.png"));
assert_eq!(message["flags"], 4);
assert_eq!(message["embeds"][0]["description"], "after");
assert_eq!(message["embeds"][0]["url"], "https://example.com");
assert_eq!(message["embeds"][1]["title"], "second");
patch(&mut message, "embed_author_url", Value::Null);
assert!(message["embeds"][0]["author"]["icon_url"].is_null());
}
#[test]
fn clearing_last_embed_content_removes_embed() {
let mut message = json!({"embeds": [{"description": "body", "color": 123}]});
patch(&mut message, "embed_description", json!(""));
assert_eq!(message["embeds"], json!([]));
}
#[test]
fn validates_embed_count_and_combined_text_boundaries() {
assert!(validate_payload(&json!({"embeds": vec![json!({"title": "embed"}); 10]})).is_ok());
assert!(validate_payload(&json!({"embeds": vec![json!({"title": "embed"}); 11]})).is_err());
let mut message = json!({"embeds": [{"description": "x".repeat(3000)}, {"description": "y".repeat(3000)}]});
assert!(validate_payload(&message).is_ok());
message["embeds"][1]["title"] = json!("x");
assert!(validate_payload(&message).is_err());
}
#[test]
fn validates_each_embed_and_field() {
assert!(validate_payload(&json!({"embeds": [{"description": "x".repeat(4097)}]})).is_err());
assert!(validate_payload(
&json!({"embeds": [{"fields": vec![json!({"name": "x", "value": "y"}); 26]}]})
)
.is_err());
for message in [
json!([]),
json!({"embeds": {}}),
json!({"embeds": [null]}),
json!({"embeds": [{"fields": [{"name": "x"}]}]}),
json!({"embeds": [{"description": 1}]}),
] {
assert!(validate_payload(&message).is_err());
}
}
#[test]
fn dashboard_response_retains_all_embeds_for_editing() {
let message = json!({"embeds": [{"title": "first"}, {"title": "second"}], "allowed_mentions": {"parse": []}});
let displayed =
response(json!({"message_json": message, "content": "", "uid": "unchanged"}));
assert_eq!(displayed["message_json"], message);
assert_eq!(displayed["embed_title"], "first");
assert_eq!(displayed["uid"], "unchanged");
}
#[test]
fn link_buttons_survive_payload_response_and_legacy_edits() {
let components = json!([{"type": 1, "components": [{
"type": 2, "style": 5, "label": "Open link", "url": "https://example.com"
}]}]);
let mut message = json!({"content": "hello", "components": components});
assert!(validate_payload(&message).is_ok());
assert_eq!(payload(&json!({"message_json": message})), message);
assert_eq!(response(json!({"message_json": message}))["message_json"], message);
patch(&mut message, "content", json!("updated"));
patch(&mut message, "embed_description", json!("embed"));
assert_eq!(message["components"], components);
}
#[test]
fn validates_link_button_count_and_text_boundaries() {
let button = json!({"type": 2, "style": 5, "label": "x".repeat(80),
"url": format!("https://example.com/{}", "x".repeat(492))});
let row = json!({"type": 1, "components": vec![button.clone(); 5]});
assert!(validate_payload(&json!({"components": vec![row.clone(); 5]})).is_ok());
assert!(validate_payload(&json!({"components": vec![row; 6]})).is_err());
assert!(validate_payload(
&json!({"components": [{"type": 1, "components": vec![button.clone(); 6]}]})
)
.is_err());
for (field, value) in [
("label", json!("x".repeat(81))),
("url", json!(format!("https://example.com/{}", "x".repeat(493)))),
] {
let mut invalid = button.clone();
invalid[field] = value;
assert!(validate_payload(
&json!({"components": [{"type": 1, "components": [invalid]}]})
)
.is_err());
}
}
#[test]
fn rejects_invalid_link_buttons_without_embeds() {
for change in [
json!({"url": ""}),
json!({"url": "javascript:alert(1)"}),
json!({"url": "not a URL"}),
json!({"label": ""}),
json!({"label": 1}),
json!({"custom_id": "interactive"}),
] {
let mut button =
json!({"type": 2, "style": 5, "label": "Open", "url": "https://example.com"});
button.as_object_mut().unwrap().extend(change.as_object().unwrap().clone());
assert!(validate_payload(
&json!({"components": [{"type": 1, "components": [button]}]})
)
.is_err());
}
assert!(validate_payload(&json!({"components": {}})).is_err());
assert!(validate_payload(&json!({"components": [{"type": 1, "components": []}]})).is_err());
assert!(validate_payload(&json!({"components": [{"type": 1, "components": [{"type": 2, "style": 5, "emoji": {"name": "🔗"}, "url": "https://example.com"}]}]})).is_ok());
}
#[tokio::test]
#[ignore = "requires MESSAGE_JSON_TEST_DATABASE_URL pointing to an isolated MySQL database"]
async fn database_edits_convert_legacy_messages_and_preserve_payload() {
let pool =
sqlx::MySqlPool::connect(&std::env::var("MESSAGE_JSON_TEST_DATABASE_URL").unwrap())
.await
.unwrap();
let mut transaction = pool.begin().await.unwrap();
let channel = sqlx::query("INSERT INTO channels (channel) VALUES (?)")
.bind(rand::random::<u64>())
.execute(&mut *transaction)
.await
.unwrap()
.last_insert_id();
let uid = format!("message-json-test-{}", rand::random::<u64>());
sqlx::query("INSERT INTO reminders (uid, channel_id, `utc_time`, content, embed_description, attachment, attachment_name) VALUES (?, ?, NOW(), 'legacy', 'embed', 'bytes', 'file.txt')")
.bind(&uid).bind(channel).execute(&mut *transaction).await.unwrap();
update_field(&mut transaction, &uid, "content", &"edited").await.unwrap();
let stored: (Json<Value>, String) =
sqlx::query_as("SELECT message_json, content FROM reminders WHERE uid = ?")
.bind(&uid)
.fetch_one(&mut *transaction)
.await
.unwrap();
assert_eq!(stored.0["content"], "edited");
assert_eq!(stored.0["embeds"][0]["description"], "embed");
assert_eq!(stored.0["attachments"][0]["filename"], "file.txt");
assert_eq!(stored.1, "legacy");
let exact = json!({"content": "exact", "custom": [1, 2], "embeds": [{"description": "first"}, {"description": "second"}]});
sqlx::query("UPDATE reminders SET message_json = ? WHERE uid = ?")
.bind(Json(exact))
.bind(&uid)
.execute(&mut *transaction)
.await
.unwrap();
update_field(&mut transaction, &uid, "tts", &true).await.unwrap();
update_field(&mut transaction, &uid, "embed_description", &"updated").await.unwrap();
let stored: (Json<Value>,) =
sqlx::query_as("SELECT message_json FROM reminders WHERE uid = ?")
.bind(&uid)
.fetch_one(&mut *transaction)
.await
.unwrap();
assert_eq!(stored.0["tts"], true);
assert_eq!(stored.0["custom"], json!([1, 2]));
assert_eq!(stored.0["embeds"][0]["description"], "updated");
assert_eq!(stored.0["embeds"][1]["description"], "second");
sqlx::query("UPDATE reminders SET attachment = NULL WHERE uid = ?")
.bind(&uid)
.execute(&mut *transaction)
.await
.unwrap();
update_field(&mut transaction, &uid, "attachments", &()).await.unwrap();
let stored: (Json<Value>,) =
sqlx::query_as("SELECT message_json FROM reminders WHERE uid = ?")
.bind(&uid)
.fetch_one(&mut *transaction)
.await
.unwrap();
assert_eq!(stored.0["attachments"], json!([]));
transaction.rollback().await.unwrap();
}
}
+3 -10
View File
@@ -8,9 +8,7 @@ use crate::{consts::DEFAULT_AVATAR, Error};
pub struct ChannelData {
pub id: u32,
pub channel: u64,
pub name: Option<String>,
pub nudge: i16,
pub blacklisted: bool,
pub webhook_id: Option<u64>,
pub webhook_token: Option<String>,
pub paused: bool,
@@ -27,7 +25,7 @@ impl ChannelData {
if let Ok(c) = sqlx::query_as_unchecked!(
Self,
"
SELECT id, channel, name, nudge, blacklisted, webhook_id, webhook_token, paused,
SELECT id, channel, nudge, webhook_id, webhook_token, paused,
paused_until
FROM channels
WHERE channel = ?
@@ -45,9 +43,8 @@ impl ChannelData {
if let Some((a, b)) = props { (Some(a), Some(b)) } else { (None, None) };
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 (?, (SELECT id FROM guilds WHERE guild = ?))",
channel_id,
channel_name,
guild_id
)
.execute(&pool.clone())
@@ -56,7 +53,7 @@ impl ChannelData {
Ok(sqlx::query_as_unchecked!(
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
WHERE channel = ?
",
@@ -72,18 +69,14 @@ impl ChannelData {
"
UPDATE channels
SET
name = ?,
nudge = ?,
blacklisted = ?,
webhook_id = ?,
webhook_token = ?,
paused = ?,
paused_until = ?
WHERE id = ?
",
self.name,
self.nudge,
self.blacklisted,
self.webhook_id,
self.webhook_token,
self.paused,
+2 -2
View File
@@ -43,7 +43,7 @@ pub enum RecordedCommand {
#[serde(rename = "delete")]
Delete(crate::commands::delete::Options),
#[serde(rename = "donate")]
Donate(crate::commands::donate::Options),
Donate(crate::commands::patreon::info::Options),
#[serde(rename = "help")]
Help(crate::commands::help::Options),
#[serde(rename = "info")]
@@ -111,7 +111,7 @@ impl RecordedCommand {
"clock" => Some(Self::Clock(crate::commands::clock::Options::extract(ctx))),
"dashboard" => Some(Self::Dashboard(crate::commands::dashboard::Options::extract(ctx))),
"delete" => Some(Self::Delete(crate::commands::delete::Options::extract(ctx))),
"donate" => Some(Self::Donate(crate::commands::donate::Options::extract(ctx))),
"donate" => Some(Self::Donate(crate::commands::patreon::info::Options::extract(ctx))),
"help" => Some(Self::Help(crate::commands::help::Options::extract(ctx))),
"info" => Some(Self::Info(crate::commands::info::Options::extract(ctx))),
"look" => Some(Self::Look(crate::commands::look::Options::extract(ctx))),
+30 -26
View File
@@ -6,7 +6,7 @@ use poise::serenity_prelude::{
model::id::{ChannelId, GuildId, UserId},
ChannelType,
};
use sqlx::MySqlPool;
use sqlx::{types::Json, MySqlPool};
use crate::{
consts::{DAY, MAX_TIME, MIN_INTERVAL},
@@ -76,6 +76,12 @@ impl ReminderBuilder {
if utc_time < (Utc::now() - TimeDelta::try_minutes(1).unwrap()).naive_local() {
Err(ReminderError::PastTime)
} else {
let mut message_json =
serde_json::json!({"content": self.content, "tts": self.tts});
if let (Some(_), Some(name)) = (&self.attachment, &self.attachment_name) {
message_json["attachments"] =
serde_json::json!([{ "id": 0, "filename": name }]);
}
sqlx::query!(
"
INSERT INTO reminders (
@@ -88,13 +94,12 @@ impl ReminderBuilder {
`interval_days`,
`interval_months`,
`expires`,
`content`,
`tts`,
`message_json`,
`attachment_name`,
`attachment`,
`set_by`
) VALUES (
?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?
?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?
)
",
self.uid,
@@ -106,8 +111,7 @@ impl ReminderBuilder {
self.interval_days,
self.interval_months,
self.expires,
self.content,
self.tts,
Json(message_json),
self.attachment_name,
self.attachment,
self.set_by
@@ -215,30 +219,30 @@ impl<'a> MultiReminderBuilder<'a> {
for scope in self.scopes {
let db_channel_id = match scope {
ReminderScope::User(user_id) => {
if let Ok(user) = UserId::new(user_id).to_user(&self.ctx).await {
let user_data = UserData::from_user(
&user,
&self.ctx.serenity_context(),
&self.ctx.data().database,
)
.await
.unwrap();
if let Some(guild_id) = self.guild_id {
if guild_id.member(&self.ctx, user).await.is_err() {
Err(ReminderError::InvalidTag)
} else if self.set_by.map_or(true, |i| i != user_data.id)
&& !user_data.allowed_dm
{
Err(ReminderError::UserBlockedDm)
let user_id = UserId::new(user_id);
match UserData::from_user(
&user_id,
&self.ctx.serenity_context(),
&self.ctx.data().database,
)
.await
{
Ok(user_data) => {
if let Some(guild_id) = self.guild_id {
if guild_id.member(&self.ctx, user_id).await.is_err() {
Err(ReminderError::InvalidTag)
} else if self.set_by.map_or(true, |i| i != user_data.id)
&& !user_data.allowed_dm
{
Err(ReminderError::UserBlockedDm)
} else {
Ok((user_data.dm_channel, None))
}
} else {
Ok((user_data.dm_channel, None))
}
} else {
Ok((user_data.dm_channel, None))
}
} else {
Err(ReminderError::InvalidTag)
Err(_) => Err(ReminderError::InvalidTag),
}
}
ReminderScope::Channel(channel_with_thread) => {
+23 -7
View File
@@ -16,8 +16,8 @@ use crate::{
},
CtxData,
},
time_parser::natural_parser,
utils::{check_guild_subscription, check_subscription},
time_parser::{cron_next_timestamp, natural_parser},
utils::check_subscription,
Context, Database, Error,
};
use chrono::{DateTime, NaiveDateTime, Utc};
@@ -49,6 +49,7 @@ pub struct Reminder {
pub interval_months: Option<u32>,
pub expires: Option<NaiveDateTime>,
pub enabled: bool,
pub message_json: Option<sqlx::types::Json<serde_json::Value>>,
pub content: String,
pub embed_description: String,
pub set_by: Option<u64>,
@@ -83,6 +84,7 @@ impl Reminder {
reminders.interval_months,
reminders.expires,
reminders.enabled,
reminders.message_json,
reminders.content,
reminders.embed_description,
reminders.set_by
@@ -116,6 +118,7 @@ impl Reminder {
reminders.interval_months,
reminders.expires,
reminders.enabled,
reminders.message_json,
reminders.content,
reminders.embed_description,
reminders.set_by
@@ -156,6 +159,7 @@ impl Reminder {
reminders.interval_months,
reminders.expires,
reminders.enabled,
reminders.message_json,
reminders.content,
reminders.embed_description,
reminders.set_by
@@ -215,6 +219,7 @@ impl Reminder {
reminders.interval_months,
reminders.expires,
reminders.enabled,
reminders.message_json,
reminders.content,
reminders.embed_description,
reminders.set_by
@@ -251,6 +256,7 @@ impl Reminder {
reminders.interval_months,
reminders.expires,
reminders.enabled,
reminders.message_json,
reminders.content,
reminders.embed_description,
reminders.set_by
@@ -284,6 +290,7 @@ impl Reminder {
reminders.interval_months,
reminders.expires,
reminders.enabled,
reminders.message_json,
reminders.content,
reminders.embed_description,
reminders.set_by
@@ -321,6 +328,13 @@ impl Reminder {
}
pub fn display_content(&self) -> &str {
if let Some(message) = &self.message_json {
let content = message["content"].as_str().unwrap_or_default();
if !content.is_empty() {
return content;
}
return message["embeds"][0]["description"].as_str().unwrap_or_default();
}
if self.content.is_empty() {
&self.embed_description
} else {
@@ -486,7 +500,10 @@ pub async fn create_reminder(
let user_data = ctx.author_data().await.unwrap();
let timezone = timezone.unwrap_or(ctx.timezone().await);
let time = natural_parser(&time, &timezone.to_string()).await;
let time = match cron_next_timestamp(&time, timezone) {
Some(ts) => Some(ts),
None => natural_parser(&time, &timezone.to_string()).await,
};
match time {
Some(time) => {
@@ -529,9 +546,8 @@ pub async fn create_reminder(
};
let (processed_interval, processed_expires) = if let Some(repeat) = &interval {
if check_subscription(&ctx, ctx.author().id).await
|| (ctx.guild_id().is_some()
&& check_guild_subscription(&ctx, ctx.guild_id().unwrap()).await)
if check_subscription(&ctx, &ctx.data().database, ctx.author().id, ctx.guild_id())
.await
{
(
parse_duration(repeat)
@@ -547,7 +563,7 @@ pub async fn create_reminder(
)
} else {
ctx.send(CreateReply::default().content(
"`repeat` is only available to Patreon subscribers or self-hosted users",
"`interval` is only available to Patreon subscribers or self-hosted users",
))
.await?;
+167 -19
View File
@@ -17,6 +17,7 @@ use poise::serenity_prelude::{
Error, Result,
};
use regex::{Captures, Regex};
use secrecy::ExposeSecret;
use serde::Deserialize;
use sqlx::{
types::{
@@ -34,8 +35,10 @@ use crate::{
lazy_static! {
pub static ref TIMEFROM_REGEX: Regex =
Regex::new(r#"<<timefrom:(?P<time>\d+):(?P<format>.+)?>>"#).unwrap();
pub static ref TIMENOW_REGEX: Regex =
Regex::new(r#"<<timenow:(?P<timezone>(?:\w|/|_)+):(?P<format>.+)?>>"#).unwrap();
pub static ref TIMENOW_REGEX: Regex = Regex::new(
r#"<<timenow(?:(?P<sign>[+-])(?P<offset>\d+))?:(?P<timezone>(?:\w|/|_)+?):(?P<format>.+?)?>>"#
)
.unwrap();
pub static ref LOG_TO_DATABASE: bool = env::var("LOG_TO_DATABASE").map_or(true, |v| v == "1");
}
@@ -64,7 +67,7 @@ fn fmt_displacement(format: &str, seconds: u64) -> String {
}
pub fn substitute(string: &str) -> String {
let new = TIMEFROM_REGEX.replace(string, |caps: &Captures| {
let new = TIMEFROM_REGEX.replace_all(string, |caps: &Captures| {
let final_time = caps.name("time").map(|m| m.as_str().parse::<i64>().ok()).flatten();
let format = caps.name("format").map(|m| m.as_str());
@@ -92,12 +95,26 @@ pub fn substitute(string: &str) -> String {
});
TIMENOW_REGEX
.replace(&new, |caps: &Captures| {
.replace_all(&new, |caps: &Captures| {
let timezone = caps.name("timezone").map(|m| m.as_str().parse::<Tz>().ok()).flatten();
let format = caps.name("format").map(|m| m.as_str());
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) {
let now = Utc::now().with_timezone(&timezone);
let mut 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()
} else {
@@ -179,21 +196,100 @@ impl Embed {
}
pub fn has_content(&self) -> bool {
if self.title.is_empty()
&& self.description.is_empty()
&& self.image_url.is_none()
&& self.thumbnail_url.is_none()
&& self.footer.is_empty()
&& self.footer_url.is_none()
&& self.author.is_empty()
&& self.author_url.is_none()
&& self.fields.0.is_empty()
{
false
} else {
true
// Discord reports "description required" for empty embeds, but descriptions are optional.
// An author icon alone does not count as content; a footer icon does.
!self.title.trim().is_empty()
|| !self.description.trim().is_empty()
|| self.image_url.as_ref().is_some_and(|url| !url.trim().is_empty())
|| self.thumbnail_url.as_ref().is_some_and(|url| !url.trim().is_empty())
|| !self.footer.trim().is_empty()
|| self.footer_url.as_ref().is_some_and(|url| !url.trim().is_empty())
|| !self.author.trim().is_empty()
|| !self.fields.0.is_empty()
}
}
#[cfg(test)]
mod tests {
use super::*;
fn embed(description: &str) -> Embed {
Embed {
title: String::new(),
description: description.to_string(),
image_url: None,
thumbnail_url: None,
footer: String::new(),
footer_url: None,
author: String::new(),
author_url: None,
color: 0,
fields: Json(vec![]),
}
}
#[test]
fn embed_without_description_can_have_content() {
let url = Some("https://example.com/image.png".to_string());
let embeds = [
Embed { title: "Reminder".to_string(), ..embed("") },
Embed { image_url: url.clone(), ..embed("") },
Embed { thumbnail_url: url.clone(), ..embed("") },
Embed { footer: "Footer".to_string(), ..embed("") },
Embed { footer_url: url, ..embed("") },
Embed { author: "Author".to_string(), ..embed("") },
Embed {
fields: Json(vec![EmbedField {
title: String::new(),
value: String::new(),
inline: false,
}]),
..embed("")
},
];
for embed in embeds {
assert!(embed.has_content());
}
}
#[test]
fn empty_embed_after_substitution_has_no_content() {
let mut embed = embed("<<timenow:Invalid/Timezone:%H>>");
embed.author_url = Some("https://example.com/image.png".to_string());
embed.description = substitute(&embed.description);
assert!(embed.description.is_empty());
assert!(!embed.has_content());
}
#[test]
fn embed_with_only_description_has_content() {
assert!(embed("Remember to do the thing").has_content());
}
#[test]
fn empty_or_whitespace_embed_has_no_content() {
assert!(!embed("").has_content());
let embed = Embed {
title: " \n\t ".to_string(),
image_url: Some(String::new()),
thumbnail_url: Some(" ".to_string()),
footer: " \n\t ".to_string(),
footer_url: Some(String::new()),
author: " \n\t ".to_string(),
color: 12345,
..embed(" \n\t ")
};
assert!(!embed.has_content());
}
#[test]
fn author_icon_alone_has_no_content() {
let embed =
Embed { author_url: Some("https://example.com/image.png".to_string()), ..embed("") };
assert!(!embed.has_content());
}
}
impl Into<CreateEmbed> for Embed {
@@ -246,6 +342,7 @@ pub struct Reminder {
tts: bool,
pin: bool,
content: String,
message_json: Option<Json<serde_json::Value>>,
attachment: Option<Vec<u8>>,
attachment_name: Option<String>,
@@ -281,6 +378,7 @@ impl Reminder {
reminders.`tts` AS tts,
reminders.`pin` AS pin,
reminders.`content` AS content,
reminders.`message_json` AS message_json,
reminders.`attachment` AS attachment,
reminders.`attachment_name` AS attachment_name,
@@ -490,6 +588,15 @@ impl Reminder {
let _ = http.as_ref().pin_message(self.channel_id.into(), message_id.into(), None).await;
}
fn files(&self) -> Vec<CreateAttachment> {
match (&self.attachment, &self.attachment_name) {
(Some(attachment), Some(name)) => {
vec![CreateAttachment::bytes(attachment.clone(), name)]
}
_ => vec![],
}
}
pub async fn send(
&self,
pool: impl Executor<'_, Database = Database> + Copy,
@@ -500,6 +607,22 @@ impl Reminder {
reminder: &Reminder,
embed: Option<CreateEmbed>,
) -> Result<()> {
if let Some(payload) = &reminder.message_json {
let files = reminder.files();
let message = cache_http
.http()
.send_message(
ChannelId::new(reminder.thread_id.unwrap_or(reminder.channel_id)),
files,
&payload.0,
)
.await?;
if reminder.pin {
reminder.pin_message(message.id, cache_http.http()).await;
}
return Ok(());
}
let channel = if let Some(thread_id) = reminder.thread_id {
ChannelId::new(thread_id).to_channel(&cache_http).await
} else {
@@ -555,6 +678,27 @@ impl Reminder {
webhook: Webhook,
embed: Option<CreateEmbed>,
) -> Result<()> {
if let Some(payload) = &reminder.message_json {
let token = webhook.token.as_ref().ok_or(Error::Other("Webhook token missing"))?;
let message = cache_http
.http()
.execute_webhook_with_components(
webhook.id,
reminder.thread_id.map(ChannelId::new),
token.expose_secret(),
reminder.pin || reminder.restartable,
reminder.files(),
&payload.0,
)
.await?;
if reminder.pin {
if let Some(message) = message {
reminder.pin_message(message.id, cache_http.http()).await;
}
}
return Ok(());
}
let mut builder = if let Some(thread_id) = reminder.thread_id {
ExecuteWebhook::new()
.content(&reminder.content)
@@ -617,7 +761,11 @@ impl Reminder {
.execute(pool)
.await;
let embed = Embed::from_id(pool, self.id).await.map(|e| e.into());
let embed = if self.message_json.is_none() {
Embed::from_id(pool, self.id).await.map(|e| e.into())
} else {
None
};
let result = if let (Some(webhook_id), Some(webhook_token)) =
(self.webhook_id, &self.webhook_token)
+8
View File
@@ -3,6 +3,8 @@ use poise::{
CreateReply,
};
use serde_json::Value;
use serenity::all::Http;
use serenity::http::CacheHttp;
use tokio::sync::Mutex;
use crate::{Data, Error};
@@ -19,6 +21,12 @@ pub(crate) struct TestContext<'a> {
pub(crate) shard_id: usize,
}
impl CacheHttp for TestContext<'_> {
fn http(&self) -> &Http {
todo!()
}
}
pub(crate) struct MockUser {
pub(crate) id: UserId,
}
+5
View File
@@ -6,6 +6,7 @@ use std::{
use chrono::{DateTime, Datelike, Timelike, Utc};
use chrono_tz::Tz;
use cron_parser::parse;
use tokio::process::Command;
use crate::consts::{LOCAL_TIMEZONE, PYTHON_LOCATION};
@@ -219,3 +220,7 @@ pub async fn natural_parser(time: &str, timezone: &str) -> Option<i64> {
})
.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)
}
+55 -13
View File
@@ -9,10 +9,62 @@ use poise::{
use crate::{
consts::{CNC_GUILD, SUBSCRIPTION_ROLES},
ApplicationContext, Context, Error,
ApplicationContext, Context, Database, Error,
};
pub async fn check_subscription(cache_http: impl CacheHttp, user_id: impl Into<UserId>) -> bool {
/// Check if this user/guild combination should be considered subscribed.
/// 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 {
let guild_member = GuildId::new(subscription_guild).member(cache_http, user_id).await;
@@ -30,17 +82,6 @@ pub async fn check_subscription(cache_http: impl CacheHttp, user_id: impl Into<U
}
}
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(
reply: CreateReply,
) -> CreateInteractionResponseMessage {
@@ -76,6 +117,7 @@ pub trait Extract {
}
pub use extract_derive::Extract;
use sqlx::Executor;
macro_rules! extract_arg {
($ctx:ident, $name:ident, String) => {
+1 -1
View File
@@ -10,7 +10,7 @@ use crate::Database;
pub struct Transaction<'a>(sqlx::Transaction<'a, Database>);
impl Transaction<'_> {
pub fn executor(&mut self) -> impl sqlx::Executor<'_, Database = Database> {
pub fn executor(&mut self) -> &mut sqlx::MySqlConnection {
&mut *(self.0)
}
+1 -1
View File
@@ -42,7 +42,7 @@ impl IpBlocking {
fn contains<I: Into<u32>>(&self, ip: I) -> bool {
let ip: u32 = ip.into();
let mut prev_index = self.upper_ips.len() - 1;
let _prev_index = self.upper_ips.len() - 1;
let mut index = self.upper_ips.len() / 2;
loop {
if self.upper_ips[index] <= ip && self.lower_ips[index] >= ip {
+27 -9
View File
@@ -57,16 +57,34 @@ macro_rules! check_url_opt {
macro_rules! update_field {
($pool:expr, $error:ident, $reminder:ident.[$field:ident]) => {
if let Some(value) = &$reminder.$field {
match sqlx::query(concat!(
"UPDATE reminders SET `",
stringify!($field),
"` = ? WHERE uid = ?"
))
.bind(value)
.bind(&$reminder.uid)
.execute($pool)
.await
// The complete payload takes precedence over legacy message controls.
let result = if crate::message::is_message_field(stringify!($field))
&& $reminder.message_json.is_some()
{
Ok(())
} else if crate::message::is_message_field(stringify!($field)) {
crate::message::update_field($pool, &$reminder.uid, stringify!($field), value).await
} else {
let result = sqlx::query(concat!(
"UPDATE reminders SET `",
stringify!($field),
"` = ? WHERE uid = ?"
))
.bind(value)
.bind(&$reminder.uid)
.execute($pool)
.await
.map(|_| ());
if result.is_ok()
&& $reminder.message_json.is_none()
&& ["attachment", "attachment_name"].contains(&stringify!($field))
{
crate::message::update_field($pool, &$reminder.uid, "attachments", &()).await
} else {
result
}
};
match result {
Ok(_) => {}
Err(e) => {
warn!(
+5 -39
View File
@@ -63,7 +63,6 @@ use log::{error, info, warn};
use oauth2::{basic::BasicClient, AuthUrl, ClientId, ClientSecret, RedirectUrl, TokenUrl};
use poise::serenity_prelude::{
client::Context,
http::CacheHttp,
model::id::{GuildId, UserId},
};
use rocket::{
@@ -76,13 +75,10 @@ use rocket::{
};
use rocket_dyn_templates::Template;
use sqlx::{MySql, Pool};
use std::net::Ipv4Addr;
use std::{env, path::Path};
use crate::web::{
consts::{CNC_GUILD, DISCORD_OAUTH_AUTHORIZE, DISCORD_OAUTH_TOKEN, SUBSCRIPTION_ROLES},
fairings::metrics::MetricProducer,
};
use crate::web::consts::{DISCORD_OAUTH_AUTHORIZE, DISCORD_OAUTH_TOKEN};
use crate::web::fairings::metrics::MetricProducer;
type Database = MySql;
@@ -92,6 +88,8 @@ enum Error {
SQLx(sqlx::Error),
#[allow(unused)]
Serenity(serenity::Error),
#[allow(unused)]
MissingDiscordPermission(&'static str),
}
pub async fn initialize(
@@ -124,6 +122,7 @@ pub async fn initialize(
let static_path =
if Path::new("static").exists() { "static" } else { "/lib/reminder-rs/static" };
info!("Using static path: {}", static_path);
rocket::build()
.attach(MetricProducer)
@@ -231,39 +230,6 @@ pub async fn initialize(
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(
cookies: &CookieJar<'_>,
ctx: &Context,
+22 -22
View File
@@ -5,40 +5,40 @@ mod roles;
mod templates;
pub mod todos;
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 emojis::get_guild_emojis;
pub use reminders::*;
use rocket::{get, http::CookieJar, serde::json::json, State};
pub use roles::get_guild_roles;
use serenity::{
client::Context,
model::id::{GuildId, RoleId},
};
use serenity::all::UserId;
use serenity::{client::Context, model::id::GuildId};
pub use templates::*;
use crate::web::{check_authorization, routes::JsonResult};
#[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>,
mut transaction: Transaction<'_>,
) -> JsonResult {
offline!(Ok(json!({ "patreon": true, "name": "Guild" })));
check_authorization(cookies, ctx.inner(), id).await?;
match GuildId::new(id)
.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;
let user_id =
cookies.get_private("userid").map(|c| c.value().parse::<u64>().ok()).flatten().unwrap();
let patreon = member_res.map_or(false, |member| {
member
.roles
.contains(&RoleId::new(env::var("PATREON_ROLE_ID").unwrap().parse().unwrap()))
});
match GuildId::new(id).to_guild_cached(ctx.inner()).map(|guild| guild.name.clone()) {
Some(name) => {
let patreon = check_subscription(
ctx.inner(),
transaction.executor(),
UserId::from(user_id),
Some(GuildId::from(id)),
)
.await;
Ok(json!({ "patreon": patreon, "name": name }))
}
@@ -12,8 +12,9 @@ use serenity::{
};
use sqlx::{MySql, Pool};
use crate::utils::check_subscription;
use crate::web::{
check_authorization, check_guild_subscription, check_subscription,
check_authorization,
consts::MIN_INTERVAL,
guards::transaction::Transaction,
routes::{
@@ -82,6 +83,7 @@ pub async fn get_reminders(
reminders.attachment_name,
reminders.avatar,
channels.channel,
reminders.message_json,
reminders.content,
reminders.embed_author,
reminders.embed_author_url,
@@ -120,7 +122,7 @@ pub async fn get_reminders(
query
.fetch_all(pool.inner())
.await
.map(|reminders| Ok(json!(reminders)))
.map(|reminders| Ok(crate::message::response(reminders)))
.unwrap_or_else(|e| {
warn!("Failed to complete SQL query: {:?}", e);
@@ -151,6 +153,11 @@ pub async fn edit_reminder(
let user_id =
cookies.get_private("userid").map(|c| c.value().parse::<u64>().ok()).flatten().unwrap();
if let Some(message) = &reminder.message_json {
crate::message::validate_payload(message).map_err(|error| json!({"error": error}))?;
update_field!(transaction.executor(), error, reminder.[message_json]);
}
if reminder.message_ok() {
update_field!(transaction.executor(), error, reminder.[
content,
@@ -186,8 +193,13 @@ pub async fn edit_reminder(
|| reminder.interval_months.flatten().is_some()
|| reminder.interval_seconds.flatten().is_some()
{
if check_guild_subscription(&ctx.inner(), id).await
|| check_subscription(&ctx.inner(), user_id).await
if check_subscription(
ctx.inner(),
transaction.executor(),
UserId::from(user_id),
Some(GuildId::from(id)),
)
.await
{
let new_interval_length = match reminder.interval_days {
Some(interval) => interval.unwrap_or(0),
@@ -261,9 +273,8 @@ pub async fn edit_reminder(
}
if reminder.channel > 0 {
let channel_guild = ChannelId::new(reminder.channel)
.to_channel_cached(&ctx.cache)
.map(|channel| channel.guild_id);
let channel_guild =
ctx.cache.channel(ChannelId::new(reminder.channel)).map(|channel| channel.guild_id);
match channel_guild {
Some(channel_guild) => {
let channel_matches_guild = channel_guild.get() == id;
@@ -305,7 +316,15 @@ pub async fn edit_reminder(
Err(e) => {
warn!("`create_database_channel` returned an error code: {:?}", e);
error.push("Failed to configure channel for reminders. Please check the bot permissions".to_string());
// Provide more specific error messages based on the error type
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());
}
}
}
}
}
@@ -336,6 +355,7 @@ pub async fn edit_reminder(
reminders.attachment_name,
reminders.avatar,
channels.channel,
reminders.message_json,
reminders.content,
reminders.embed_author,
reminders.embed_author_url,
@@ -366,7 +386,9 @@ pub async fn edit_reminder(
.fetch_one(pool.inner())
.await
{
Ok(reminder) => Ok(json!({"reminder": reminder, "errors": error})),
Ok(reminder) => {
Ok(json!({"reminder": crate::message::response(reminder), "errors": error}))
}
Err(e) => {
warn!("Error exiting `edit_reminder': {:?}", e);
+59 -65
View File
@@ -6,8 +6,8 @@ use serde::{Deserialize, Serialize};
use serenity::{client::Context, model::id::UserId};
use sqlx::types::Json;
use crate::utils::check_subscription;
use crate::web::{
check_subscription,
consts::{
DAY, MAX_CONTENT_LENGTH, MAX_EMBED_AUTHOR_LENGTH, MAX_EMBED_DESCRIPTION_LENGTH,
MAX_EMBED_FIELDS, MAX_EMBED_FIELD_TITLE_LENGTH, MAX_EMBED_FIELD_VALUE_LENGTH,
@@ -24,17 +24,24 @@ use crate::web::{
#[derive(Serialize, Deserialize)]
pub struct Reminder {
pub message_json: Option<Json<serde_json::Value>>,
pub attachment: Option<Attachment>,
pub attachment_name: Option<String>,
#[serde(default)]
pub content: String,
#[serde(default)]
pub embed_author: String,
pub embed_author_url: Option<String>,
#[serde(default)]
pub embed_color: u32,
#[serde(default)]
pub embed_description: String,
#[serde(default)]
pub embed_footer: String,
pub embed_footer_url: Option<String>,
pub embed_image_url: Option<String>,
pub embed_thumbnail_url: Option<String>,
#[serde(default)]
pub embed_title: String,
pub embed_fields: Option<Json<Vec<EmbedField>>>,
pub enabled: bool,
@@ -44,6 +51,7 @@ pub struct Reminder {
pub interval_months: Option<u32>,
#[serde(default = "name_default")]
pub name: String,
#[serde(default)]
pub tts: bool,
#[serde(default)]
pub uid: String,
@@ -65,40 +73,56 @@ pub async fn create_reminder(
if let Err(e) = channel {
warn!("`create_database_channel` returned an error code: {:?}", e);
return Err(json!({"error": "Failed to configure channel for reminders."}));
// Provide more specific error messages based on the error type
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 message_json = Json(crate::message::payload(&reminder));
if let Err(error) = crate::message::validate_payload(&message_json) {
return Err(json!({"error": error}));
}
// validate lengths
check_length!(MAX_NAME_LENGTH, reminder.name);
check_length!(MAX_CONTENT_LENGTH, reminder.content);
check_length!(MAX_EMBED_DESCRIPTION_LENGTH, reminder.embed_description);
check_length!(MAX_EMBED_TITLE_LENGTH, reminder.embed_title);
check_length!(MAX_EMBED_AUTHOR_LENGTH, reminder.embed_author);
check_length!(MAX_EMBED_FOOTER_LENGTH, reminder.embed_footer);
check_length_opt!(MAX_EMBED_FIELDS, reminder.embed_fields);
if let Some(fields) = &reminder.embed_fields {
for field in &fields.0 {
check_length!(MAX_EMBED_FIELD_VALUE_LENGTH, field.value);
check_length!(MAX_EMBED_FIELD_TITLE_LENGTH, field.title);
if reminder.message_json.is_none() {
check_length!(MAX_CONTENT_LENGTH, reminder.content);
check_length!(MAX_EMBED_DESCRIPTION_LENGTH, reminder.embed_description);
check_length!(MAX_EMBED_TITLE_LENGTH, reminder.embed_title);
check_length!(MAX_EMBED_AUTHOR_LENGTH, reminder.embed_author);
check_length!(MAX_EMBED_FOOTER_LENGTH, reminder.embed_footer);
check_length_opt!(MAX_EMBED_FIELDS, reminder.embed_fields);
if let Some(fields) = &reminder.embed_fields {
for field in &fields.0 {
check_length!(MAX_EMBED_FIELD_VALUE_LENGTH, field.value);
check_length!(MAX_EMBED_FIELD_TITLE_LENGTH, field.title);
}
}
}
check_length_opt!(
MAX_URL_LENGTH,
reminder.embed_footer_url,
reminder.embed_thumbnail_url,
reminder.embed_author_url,
reminder.embed_image_url
);
check_length_opt!(
MAX_URL_LENGTH,
reminder.embed_footer_url,
reminder.embed_thumbnail_url,
reminder.embed_author_url,
reminder.embed_image_url
);
// validate urls
check_url_opt!(
reminder.embed_footer_url,
reminder.embed_thumbnail_url,
reminder.embed_author_url,
reminder.embed_image_url
);
// validate urls
check_url_opt!(
reminder.embed_footer_url,
reminder.embed_thumbnail_url,
reminder.embed_author_url,
reminder.embed_image_url
);
}
// validate time and interval
if reminder.utc_time < Utc::now().naive_utc() {
@@ -122,7 +146,7 @@ pub async fn create_reminder(
|| reminder.interval_days.is_some()
|| reminder.interval_months.is_some()
{
if !check_subscription(&ctx, user_id).await {
if !check_subscription(&ctx, transaction.executor(), user_id, None).await {
return Err(json!({"error": "Patreon is required to set intervals"}));
}
}
@@ -133,52 +157,21 @@ pub async fn create_reminder(
// write to db
match sqlx::query!(
"INSERT INTO reminders (
uid,
attachment,
attachment_name,
channel_id,
content,
embed_author,
embed_author_url,
embed_color,
embed_description,
embed_footer,
embed_footer_url,
embed_image_url,
embed_thumbnail_url,
embed_title,
embed_fields,
enabled,
expires,
interval_seconds,
interval_days,
interval_months,
name,
tts,
`utc_time`
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
uid, attachment, attachment_name, channel_id, message_json,
enabled, expires, interval_seconds, interval_days, interval_months,
name, `utc_time`
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
new_uid,
reminder.attachment,
reminder.attachment_name,
channel,
reminder.content,
reminder.embed_author,
reminder.embed_author_url,
reminder.embed_color,
reminder.embed_description,
reminder.embed_footer,
reminder.embed_footer_url,
reminder.embed_image_url,
reminder.embed_thumbnail_url,
reminder.embed_title,
reminder.embed_fields,
message_json,
reminder.enabled,
reminder.expires,
reminder.interval_seconds,
reminder.interval_days,
reminder.interval_months,
name,
reminder.tts,
reminder.utc_time,
)
.execute(transaction.executor())
@@ -189,6 +182,7 @@ pub async fn create_reminder(
"SELECT
reminders.attachment,
reminders.attachment_name,
reminders.message_json,
reminders.content,
reminders.embed_author,
reminders.embed_author_url,
@@ -215,7 +209,7 @@ pub async fn create_reminder(
)
.fetch_one(transaction.executor())
.await
.map(|r| Ok(json!(r)))
.map(|r| Ok(crate::message::response(r)))
.unwrap_or_else(|e| {
warn!("Failed to complete SQL query: {:?}", e);
+15 -4
View File
@@ -9,8 +9,8 @@ use rocket::{
use serenity::{client::Context, model::id::UserId};
use sqlx::{MySql, Pool};
use crate::utils::check_subscription;
use crate::web::{
check_subscription,
guards::transaction::Transaction,
routes::{
dashboard::{
@@ -69,6 +69,7 @@ pub async fn get_reminders(
SELECT
reminders.attachment,
reminders.attachment_name,
reminders.message_json,
reminders.content,
reminders.embed_author,
reminders.embed_author_url,
@@ -97,7 +98,7 @@ pub async fn get_reminders(
)
.fetch_all(pool.inner())
.await
.map(|r| Ok(json!(r)))
.map(|r| Ok(crate::message::response(r)))
.unwrap_or_else(|e| {
warn!("Failed to complete SQL query: {:?}", e);
@@ -130,6 +131,11 @@ pub async fn edit_reminder(
let mut error = vec![];
let user_id = user_id_cookie.unwrap();
if let Some(message) = &reminder.message_json {
crate::message::validate_payload(message).map_err(|error| json!({"error": error}))?;
update_field!(transaction.executor(), error, reminder.[message_json]);
}
if reminder.message_ok() {
update_field!(transaction.executor(), error, reminder.[
content,
@@ -162,7 +168,9 @@ pub async fn edit_reminder(
|| reminder.interval_months.flatten().is_some()
|| reminder.interval_seconds.flatten().is_some()
{
if check_subscription(&ctx.inner(), user_id).await {
if check_subscription(&ctx.inner(), transaction.executor(), UserId::from(user_id), None)
.await
{
let new_interval_length = match reminder.interval_days {
Some(interval) => interval.unwrap_or(0),
None => sqlx::query!(
@@ -244,6 +252,7 @@ pub async fn edit_reminder(
"
SELECT reminders.attachment,
reminders.attachment_name,
reminders.message_json,
reminders.content,
reminders.embed_author,
reminders.embed_author_url,
@@ -273,7 +282,9 @@ pub async fn edit_reminder(
.fetch_one(pool.inner())
.await
{
Ok(reminder) => Ok(json!({"reminder": reminder, "errors": error})),
Ok(reminder) => {
Ok(json!({"reminder": crate::message::response(reminder), "errors": error}))
}
Err(e) => {
warn!("Error exiting `edit_reminder': {:?}", e);
@@ -3,13 +3,10 @@ use crate::web::{
check_authorization,
guards::transaction::Transaction,
routes::{
dashboard::{
create_reminder, CreateReminder, ImportBody, ReminderCsv, ReminderTemplateCsv, TodoCsv,
},
dashboard::{ImportBody, ReminderTemplateCsv},
JsonResult,
},
};
use crate::Database;
use base64::{prelude::BASE64_STANDARD, Engine};
use csv::{QuoteStyle, WriterBuilder};
use log::warn;
@@ -20,10 +17,7 @@ use rocket::{
serde::json::{json, Json},
State,
};
use serenity::{
client::Context,
model::id::{ChannelId, GuildId, UserId},
};
use serenity::{client::Context, model::id::GuildId};
use sqlx::{MySql, Pool};
#[get("/api/guild/<id>/export/reminder_templates")]
+8 -4
View File
@@ -2,9 +2,7 @@ use crate::web::{
check_authorization,
guards::transaction::Transaction,
routes::{
dashboard::{
create_reminder, CreateReminder, ImportBody, ReminderCsv, ReminderTemplateCsv, TodoCsv,
},
dashboard::{create_reminder, CreateReminder, ImportBody, ReminderCsv},
JsonResult,
},
};
@@ -21,7 +19,7 @@ use rocket::{
};
use serenity::{
client::Context,
model::id::{ChannelId, GuildId, UserId},
model::id::{GuildId, UserId},
};
use sqlx::{MySql, Pool};
@@ -50,6 +48,7 @@ pub async fn export(
reminders.attachment_name,
reminders.avatar,
CONCAT('#', channels.channel) AS channel,
CAST(reminders.message_json AS CHAR) AS message_json,
reminders.content,
reminders.embed_author,
reminders.embed_author_url,
@@ -152,6 +151,11 @@ pub async fn import(
match channel_id.parse::<u64>() {
Ok(channel_id) => {
let reminder = CreateReminder {
message_json: record
.message_json
.map(|s| serde_json::from_str(&s).map(sqlx::types::Json))
.transpose()
.map_err(|_| json!({"error": "Invalid message_json"}))?,
attachment: record.attachment,
attachment_name: record.attachment_name,
avatar: record.avatar,
+2 -6
View File
@@ -1,14 +1,10 @@
use crate::web::{
check_authorization,
guards::transaction::Transaction,
routes::{
dashboard::{
create_reminder, CreateReminder, ImportBody, ReminderCsv, ReminderTemplateCsv, TodoCsv,
},
dashboard::{ImportBody, TodoCsv},
JsonResult,
},
};
use crate::Database;
use base64::{prelude::BASE64_STANDARD, Engine};
use csv::{QuoteStyle, WriterBuilder};
use log::warn;
@@ -21,7 +17,7 @@ use rocket::{
};
use serenity::{
client::Context,
model::id::{ChannelId, GuildId, UserId},
model::id::{ChannelId, GuildId},
};
use sqlx::{MySql, Pool};
+163 -99
View File
@@ -10,6 +10,7 @@ use rocket::{
use rocket_dyn_templates::Template;
use secrecy::ExposeSecret;
use serde::{de, Deserialize, Deserializer, Serialize, Serializer};
use serenity::http::HttpError;
use serenity::{
all::CacheHttp,
builder::CreateWebhook,
@@ -19,9 +20,9 @@ use serenity::{
use sqlx::types::Json;
use sqlx::FromRow;
use crate::utils::check_subscription;
use crate::web::{
catchers::internal_server_error,
check_guild_subscription, check_subscription,
consts::{
CHARACTERS, DAY, DEFAULT_AVATAR, MAX_CONTENT_LENGTH, MAX_EMBED_AUTHOR_LENGTH,
MAX_EMBED_DESCRIPTION_LENGTH, MAX_EMBED_FIELDS, MAX_EMBED_FIELD_TITLE_LENGTH,
@@ -147,22 +148,29 @@ pub struct EmbedField {
inline: bool,
}
#[derive(Deserialize)]
#[derive(Serialize, Deserialize)]
pub struct CreateReminder {
message_json: Option<Json<serde_json::Value>>,
attachment: Option<Attachment>,
attachment_name: Option<String>,
avatar: Option<String>,
#[serde(with = "string")]
channel: u64,
#[serde(default)]
content: String,
#[serde(default)]
embed_author: String,
embed_author_url: Option<String>,
#[serde(default)]
embed_color: u32,
#[serde(default)]
embed_description: String,
#[serde(default)]
embed_footer: String,
embed_footer_url: Option<String>,
embed_image_url: Option<String>,
embed_thumbnail_url: Option<String>,
#[serde(default)]
embed_title: String,
embed_fields: Option<Json<Vec<EmbedField>>>,
enabled: bool,
@@ -173,6 +181,7 @@ pub struct CreateReminder {
#[serde(default = "name_default")]
name: String,
restartable: bool,
#[serde(default)]
tts: bool,
username: Option<String>,
utc_time: NaiveDateTime,
@@ -180,6 +189,7 @@ pub struct CreateReminder {
#[derive(Serialize, FromRow)]
pub struct GetReminder {
message_json: Option<Json<serde_json::Value>>,
attachment_name: Option<String>,
avatar: Option<String>,
#[serde(with = "string")]
@@ -212,6 +222,8 @@ pub struct GetReminder {
#[derive(Serialize, Deserialize, FromRow)]
pub struct ReminderCsv {
#[serde(default)]
message_json: Option<String>,
attachment: Option<Attachment>,
attachment_name: Option<String>,
avatar: Option<String>,
@@ -242,6 +254,8 @@ pub struct ReminderCsv {
#[derive(Deserialize)]
pub struct PatchReminder {
#[serde(default)]
message_json: Option<Json<serde_json::Value>>,
uid: String,
#[serde(default)]
#[serde(deserialize_with = "deserialize_optional_field")]
@@ -310,6 +324,9 @@ pub struct PatchReminder {
impl PatchReminder {
fn message_ok(&self) -> bool {
if let Some(message) = &self.message_json {
return crate::message::validate_payload(message).is_ok();
}
self.content.as_ref().map_or(true, |c| c.len() <= MAX_CONTENT_LENGTH)
&& self.embed_author.as_ref().map_or(true, |c| c.len() <= MAX_EMBED_AUTHOR_LENGTH)
&& self
@@ -404,45 +421,62 @@ pub(crate) async fn create_reminder(
if let Err(e) = channel {
warn!("`create_database_channel` returned an error code: {:?}", e);
return Err(
json!({"error": "Failed to configure channel for reminders. Please check the bot permissions"}),
);
// Provide more specific error messages based on the error type
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. Please check the bot permissions"
.to_string(),
};
return Err(json!({"error": error_msg}));
}
let channel = channel.unwrap();
let message_json = Json(crate::message::payload(&reminder));
if let Err(error) = crate::message::validate_payload(&message_json) {
return Err(json!({"error": error}));
}
// validate lengths
check_length!(MAX_NAME_LENGTH, reminder.name);
check_length!(MAX_CONTENT_LENGTH, reminder.content);
check_length!(MAX_EMBED_DESCRIPTION_LENGTH, reminder.embed_description);
check_length!(MAX_EMBED_TITLE_LENGTH, reminder.embed_title);
check_length!(MAX_EMBED_AUTHOR_LENGTH, reminder.embed_author);
check_length!(MAX_EMBED_FOOTER_LENGTH, reminder.embed_footer);
check_length_opt!(MAX_EMBED_FIELDS, reminder.embed_fields);
if let Some(fields) = &reminder.embed_fields {
for field in &fields.0 {
check_length!(MAX_EMBED_FIELD_VALUE_LENGTH, field.value);
check_length!(MAX_EMBED_FIELD_TITLE_LENGTH, field.title);
if reminder.message_json.is_none() {
check_length!(MAX_CONTENT_LENGTH, reminder.content);
check_length!(MAX_EMBED_DESCRIPTION_LENGTH, reminder.embed_description);
check_length!(MAX_EMBED_TITLE_LENGTH, reminder.embed_title);
check_length!(MAX_EMBED_AUTHOR_LENGTH, reminder.embed_author);
check_length!(MAX_EMBED_FOOTER_LENGTH, reminder.embed_footer);
check_length_opt!(MAX_EMBED_FIELDS, reminder.embed_fields);
if let Some(fields) = &reminder.embed_fields {
for field in &fields.0 {
check_length!(MAX_EMBED_FIELD_VALUE_LENGTH, field.value);
check_length!(MAX_EMBED_FIELD_TITLE_LENGTH, field.title);
}
}
}
check_length_opt!(MAX_USERNAME_LENGTH, reminder.username);
check_length_opt!(
MAX_URL_LENGTH,
reminder.embed_footer_url,
reminder.embed_thumbnail_url,
reminder.embed_author_url,
reminder.embed_image_url,
reminder.avatar
);
check_length_opt!(MAX_USERNAME_LENGTH, reminder.username);
check_length_opt!(
MAX_URL_LENGTH,
reminder.embed_footer_url,
reminder.embed_thumbnail_url,
reminder.embed_author_url,
reminder.embed_image_url,
reminder.avatar
);
// validate urls
check_url_opt!(
reminder.embed_footer_url,
reminder.embed_thumbnail_url,
reminder.embed_author_url,
reminder.embed_image_url,
reminder.avatar
);
// validate urls
check_url_opt!(
reminder.embed_footer_url,
reminder.embed_thumbnail_url,
reminder.embed_author_url,
reminder.embed_image_url,
reminder.avatar
);
}
// validate time and interval
if reminder.utc_time < Utc::now().naive_utc() {
@@ -466,68 +500,27 @@ pub(crate) async fn create_reminder(
|| reminder.interval_days.is_some()
|| reminder.interval_months.is_some()
{
if !check_guild_subscription(&ctx, guild_id).await
&& !check_subscription(&ctx, user_id).await
{
if !check_subscription(&ctx, transaction.executor(), user_id, Some(guild_id)).await {
return Err(json!({"error": "Patreon is required to set intervals"}));
}
}
let name = if reminder.name.is_empty() { name_default() } else { reminder.name.clone() };
let username = if reminder.username.as_ref().map(|s| s.is_empty()).unwrap_or(true) {
None
} else {
reminder.username
};
let new_uid = generate_uid();
// write to db
match sqlx::query!(
"INSERT INTO reminders (
uid,
attachment,
attachment_name,
channel_id,
avatar,
content,
embed_author,
embed_author_url,
embed_color,
embed_description,
embed_footer,
embed_footer_url,
embed_image_url,
embed_thumbnail_url,
embed_title,
embed_fields,
enabled,
expires,
interval_seconds,
interval_days,
interval_months,
name,
restartable,
tts,
username,
`utc_time`
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
uid, attachment, attachment_name, channel_id, message_json,
enabled, expires, interval_seconds, interval_days, interval_months,
name, restartable, `utc_time`
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
new_uid,
reminder.attachment,
reminder.attachment_name,
channel,
reminder.avatar,
reminder.content,
reminder.embed_author,
reminder.embed_author_url,
reminder.embed_color,
reminder.embed_description,
reminder.embed_footer,
reminder.embed_footer_url,
reminder.embed_image_url,
reminder.embed_thumbnail_url,
reminder.embed_title,
reminder.embed_fields,
message_json,
reminder.enabled,
reminder.expires,
reminder.interval_seconds,
@@ -535,8 +528,6 @@ pub(crate) async fn create_reminder(
reminder.interval_months,
name,
reminder.restartable,
reminder.tts,
username,
reminder.utc_time,
)
.execute(transaction.executor())
@@ -549,6 +540,7 @@ pub(crate) async fn create_reminder(
reminders.attachment_name,
reminders.avatar,
channels.channel,
reminders.message_json,
reminders.content,
reminders.embed_author,
reminders.embed_author_url,
@@ -578,7 +570,7 @@ pub(crate) async fn create_reminder(
)
.fetch_one(transaction.executor())
.await
.map(|r| Ok(json!(r)))
.map(|r| Ok(crate::message::response(r)))
.unwrap_or_else(|e| {
warn!("Failed to complete SQL query: {:?}", e);
@@ -716,13 +708,36 @@ async fn create_database_channel(
match row {
Ok(row) => {
let is_dm =
channel.to_channel(&ctx).await.map_err(|e| Error::Serenity(e))?.private().is_some();
let is_dm = channel
.to_channel(&ctx)
.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()) {
let webhook = channel
.create_webhook(&ctx, CreateWebhook::new("Reminder").avatar(&*DEFAULT_AVATAR))
.await
.map_err(|e| Error::Serenity(e))?;
.map_err(|e| match &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();
@@ -747,7 +762,16 @@ async fn create_database_channel(
let webhook = channel
.create_webhook(&ctx, CreateWebhook::new("Reminder").avatar(&*DEFAULT_AVATAR))
.await
.map_err(|e| Error::Serenity(e))?;
.map_err(|e| match &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();
@@ -806,22 +830,15 @@ pub async fn todos_redirect(id: &str) -> Redirect {
#[get("/")]
pub async fn dashboard_home(cookies: &CookieJar<'_>) -> DashboardPage {
if cookies.get_private("userid").is_some() {
match NamedFile::open(Path::new(path!("static/index.html"))).await {
Ok(f) => DashboardPage::Ok(f),
Err(e) => {
warn!("Couldn't render dashboard: {:?}", e);
DashboardPage::NotConfigured(internal_server_error().await)
}
}
} else {
DashboardPage::Unauthorised(Redirect::to("/login/discord"))
}
render_dashboard(cookies).await
}
#[get("/<_..>")]
pub async fn dashboard(cookies: &CookieJar<'_>) -> DashboardPage {
render_dashboard(cookies).await
}
async fn render_dashboard(cookies: &CookieJar<'_>) -> DashboardPage {
if cookies.get_private("userid").is_some() {
match NamedFile::open(Path::new(path!("static/index.html"))).await {
Ok(f) => DashboardPage::Ok(f),
@@ -835,3 +852,50 @@ pub async fn dashboard(cookies: &CookieJar<'_>) -> DashboardPage {
DashboardPage::Unauthorised(Redirect::to("/login/discord"))
}
}
#[cfg(test)]
mod message_json_tests {
use super::*;
#[tokio::test]
#[ignore = "requires MESSAGE_JSON_TEST_DATABASE_URL pointing to an isolated MySQL database"]
async fn patch_keeps_embeds_and_buttons_and_ignores_legacy_message_fields() {
let pool =
sqlx::MySqlPool::connect(&std::env::var("MESSAGE_JSON_TEST_DATABASE_URL").unwrap())
.await
.unwrap();
let mut transaction = pool.begin().await.unwrap();
let channel = sqlx::query("INSERT INTO channels (channel) VALUES (?)")
.bind(rand::random::<u64>())
.execute(&mut *transaction)
.await
.unwrap()
.last_insert_id();
let uid = format!("multi-embed-test-{}", rand::random::<u64>());
sqlx::query("INSERT INTO reminders (uid, channel_id, `utc_time`) VALUES (?, ?, NOW())")
.bind(&uid)
.bind(channel)
.execute(&mut *transaction)
.await
.unwrap();
let payload = json!({"content": "current", "embeds": [{"title": "first"}, {"title": "second"}],
"components": [{"type": 1, "components": [{"type": 2, "style": 5,
"label": "Open link", "url": "https://example.com"}]}]});
let reminder: PatchReminder = serde_json::from_value(json!({
"uid": uid, "message_json": payload, "content": "stale", "embed_title": "stale"
}))
.unwrap();
let mut errors = vec![];
assert!(reminder.message_ok());
update_field!(&mut *transaction, errors, reminder.[message_json, content, embed_title]);
assert!(errors.is_empty());
let stored: (Json<serde_json::Value>,) =
sqlx::query_as("SELECT message_json FROM reminders WHERE uid = ?")
.bind(&uid)
.fetch_one(&mut *transaction)
.await
.unwrap();
assert_eq!(stored.0 .0, payload);
transaction.rollback().await.unwrap();
}
}
-1
View File
@@ -1 +0,0 @@
/home/jude/reminder-bot/reminder-dashboard/dist/static/assets
-1
View File
@@ -1 +0,0 @@
/home/jude/reminder-bot/reminder-dashboard/dist/index.html
@@ -19,6 +19,24 @@
Fill out the "time" and "content" fields. If you wish, press on "Optional" to view other options
for the reminder.
</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>
</section>
@@ -37,4 +55,40 @@
</div>
</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 %}