13 Commits

Author SHA1 Message Date
jude
a2df5b9e36 WIP Docker compose setup 2025-11-02 13:22:26 +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
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
54 changed files with 13375 additions and 1035 deletions

View File

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

View File

@@ -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"
}

View File

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

View File

@@ -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"
}

View File

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

View File

@@ -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"
}

View File

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

View File

@@ -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"
}

View File

@@ -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"
}

View File

@@ -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"
}

View File

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

View File

@@ -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"
}

View File

@@ -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"
}

View File

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

View File

@@ -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.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": [
{
@@ -130,5 +130,5 @@
true
]
},
"hash": "d60ea641070dbd882cd53878fa109d08bd3f65e0da8c263e78fe0b200228bc2b"
"hash": "d2921961627fef0e12892dbbcd4b891e58c0e52c20897aab3d95365774c01bda"
}

View File

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

14
Cargo.lock generated
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"
@@ -524,6 +524,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"
@@ -2614,11 +2623,12 @@ checksum = "2b15c43186be67a4fd63bee50d0303afffcef381492ebe2c5d87f324e1b8815c"
[[package]]
name = "reminder-rs"
version = "1.7.37"
version = "1.7.41"
dependencies = [
"base64 0.22.1",
"chrono",
"chrono-tz",
"cron-parser",
"csv",
"dotenv",
"env_logger",

View File

@@ -1,6 +1,6 @@
[package]
name = "reminder-rs"
version = "1.7.37"
version = "1.7.41"
authors = ["Jude Southworth <judesouthworth@pm.me>"]
edition = "2021"
license = "AGPL-3.0 only"
@@ -35,6 +35,7 @@ serenity = { version = "0.12", default-features = false, features = ["builder",
oauth2 = "4"
csv = "1.2"
sd-notify = "0.4.1"
cron-parser = "0.10"
[dependencies.extract_derive]
path = "extract_derive"

View File

@@ -34,4 +34,5 @@ COPY ./dp.py ./
RUN cargo install --path .
EXPOSE 18920
CMD ["reminder-rs"]

View File

@@ -1,21 +1,41 @@
version: '3.3'
services:
bot:
build: ./Containerfile.run
image: reminder-rs-run
restart: always
expose:
- '80'
database:
image: mysql:8.0
restart: always
environment:
MYSQL_DATABASE: 'reminders'
MYSQL_USER: 'reminder-bot'
ports:
- '3306:3306'
expose:
- '3306'
volumes:
- reminders:/var/lib/mysql
database:
image: mysql:8.0
restart: always
command: --log-bin-trust-function-creators=1
environment:
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:
SECRET_KEY:
REMIND_INTERVAL:
OAUTH2_DISCORD_CALLBACK:
OAUTH2_CLIENT_ID:
OAUTH2_CLIENT_SECRET:
ports:
- 18920:18920
volumes:
reminders:

12676
gb-ipv4.csv Normal file

File diff suppressed because it is too large Load Diff

View File

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

View File

@@ -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)
);

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;

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

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;

View File

@@ -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
}

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
}

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(())
}

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
}

View File

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

View File

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

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

View File

@@ -48,8 +48,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 +165,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(),

View File

@@ -8,7 +8,9 @@ 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,
@@ -25,7 +27,7 @@ impl ChannelData {
if let Ok(c) = sqlx::query_as_unchecked!(
Self,
"
SELECT id, channel, nudge, webhook_id, webhook_token, paused,
SELECT id, channel, name, nudge, blacklisted, webhook_id, webhook_token, paused,
paused_until
FROM channels
WHERE channel = ?
@@ -37,11 +39,15 @@ impl ChannelData {
{
Ok(c)
} else {
let guild_id = channel.to_owned().guild().map(|g| g.guild_id.get().to_owned());
let props = channel.to_owned().guild().map(|g| (g.guild_id.get().to_owned(), g.name));
let (guild_id, channel_name) =
if let Some((a, b)) = props { (Some(a), Some(b)) } else { (None, None) };
sqlx::query!(
"INSERT IGNORE INTO channels (channel, guild_id) VALUES (?, ?)",
"INSERT IGNORE INTO channels (channel, name, guild_id) VALUES (?, ?, (SELECT id FROM guilds WHERE guild = ?))",
channel_id,
channel_name,
guild_id
)
.execute(&pool.clone())
@@ -50,7 +56,7 @@ impl ChannelData {
Ok(sqlx::query_as_unchecked!(
Self,
"
SELECT id, channel, nudge, webhook_id, webhook_token, paused, paused_until
SELECT id, channel, name, nudge, blacklisted, webhook_id, webhook_token, paused, paused_until
FROM channels
WHERE channel = ?
",
@@ -66,14 +72,18 @@ 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,

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

View File

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

View File

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

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};
@@ -262,7 +262,7 @@ impl Reminder {
channels.id = reminders.channel_id
WHERE
`status` = 'pending' AND
channels.guild_id = ?
channels.guild_id = (SELECT id FROM guilds WHERE guild = ?)
",
guild_id.get()
)
@@ -486,7 +486,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 +532,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 +549,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?;

View File

@@ -34,8 +34,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 +66,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 +94,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 {

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

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

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) => {

View File

@@ -92,6 +92,8 @@ enum Error {
SQLx(sqlx::Error),
#[allow(unused)]
Serenity(serenity::Error),
#[allow(unused)]
MissingDiscordPermission(&'static str),
}
pub async fn initialize(
@@ -231,39 +233,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,

View File

@@ -7,38 +7,43 @@ 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::all::UserId;
use serenity::{
client::Context,
model::id::{GuildId, RoleId},
};
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 }))
}

View File

@@ -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::{
@@ -186,8 +187,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),
@@ -305,7 +311,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());
}
}
}
}
}

View File

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

View File

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

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,
@@ -65,7 +65,16 @@ 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();
@@ -122,7 +131,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"}));
}
}

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::{
@@ -162,7 +162,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!(

View File

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

View File

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

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,
@@ -373,12 +374,12 @@ pub(crate) async fn create_reminder(
reminder: CreateReminder,
) -> JsonResult {
// check guild in db
match sqlx::query!("SELECT 1 as A FROM guilds WHERE id = ?", guild_id.get())
match sqlx::query!("SELECT 1 as A FROM guilds WHERE guild = ?", guild_id.get())
.fetch_one(transaction.executor())
.await
{
Err(sqlx::Error::RowNotFound) => {
if sqlx::query!("INSERT INTO guilds (id) VALUES (?)", guild_id.get())
if sqlx::query!("INSERT INTO guilds (guild) VALUES (?)", guild_id.get())
.execute(transaction.executor())
.await
.is_err()
@@ -404,9 +405,19 @@ 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();
@@ -466,9 +477,7 @@ 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"}));
}
}
@@ -659,7 +668,7 @@ pub(crate) async fn create_reminder_template(
interval_months,
tts,
username
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?,
) VALUES ((SELECT id FROM guilds WHERE guild = ?), ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?,
?, ?, ?, ?, ?, ?, ?)",
guild_id.get(),
name,
@@ -716,13 +725,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 +779,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 +847,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),

View File

@@ -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 %}