From 84ee7e77c59d87bd6a5c83753d303b8942880684 Mon Sep 17 00:00:00 2001 From: jude Date: Sat, 19 Feb 2022 14:32:03 +0000 Subject: [PATCH 01/27] 2nd attempt at doing poise stuff --- Cargo.lock | 474 ++++++++++++++-------- Cargo.toml | 19 +- src/commands/info_cmds.rs | 113 +++--- src/commands/mod.rs | 4 +- src/commands/moderation_cmds.rs | 649 +++++++++++++++--------------- src/consts.rs | 3 +- src/event_handlers.rs | 227 +++++------ src/hooks.rs | 160 +++----- src/interval_parser.rs | 6 +- src/main.rs | 197 ++++----- src/models/channel_data.rs | 2 +- src/models/command_macro.rs | 46 +-- src/models/mod.rs | 51 +-- src/models/reminder/builder.rs | 47 ++- src/models/reminder/look_flags.rs | 2 +- src/models/reminder/mod.rs | 36 +- src/models/user_data.rs | 36 +- src/utils.rs | 34 +- 18 files changed, 1071 insertions(+), 1035 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 1c04e94..62d8d9a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -135,7 +135,7 @@ version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b88d82667eca772c4aa12f0f1348b3ae643424c8876448f3f7bd5787032e234c" dependencies = [ - "autocfg 1.0.1", + "autocfg 1.1.0", ] [[package]] @@ -151,15 +151,18 @@ dependencies = [ [[package]] name = "autocfg" -version = "0.1.7" +version = "0.1.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1d49d90015b3c36167a20fe2810c5cd875ad504b39cff3d4eae7977e6b7c1cb2" +checksum = "0dde43e75fd43e8a1bf86103336bc699aa8d17ad1be60c76c0bdfd4828e19b78" +dependencies = [ + "autocfg 1.1.0", +] [[package]] name = "autocfg" -version = "1.0.1" +version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cdb031dd78e28731d87d56cc8ffef4a8f36ca26c38fe2de700543e627f8a464a" +checksum = "d468802bab17cbc0cc575e9b053f41e72aa36bfa6b7f55e3529ffa43161b97fa" [[package]] name = "base64" @@ -270,9 +273,9 @@ checksum = "c4872d67bab6358e59559027aa3b9157c53d9358c51423c17554809a8858e0f8" [[package]] name = "cc" -version = "1.0.72" +version = "1.0.73" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "22a9137b95ea06864e018375b72adfb7db6e6f68cfc8df5a04d00288050485ee" +checksum = "2fff2a6927b3bb87f9595d67196a70493f627687a71d87a0d692242c33f58c11" [[package]] name = "cfg-if" @@ -357,9 +360,10 @@ dependencies = [ "aes-gcm", "base64", "hkdf", + "hmac", "percent-encoding", - "rand 0.8.4", - "sha2 0.10.1", + "rand 0.8.5", + "sha2 0.10.2", "subtle", "time 0.3.7", "version_check", @@ -367,9 +371,9 @@ dependencies = [ [[package]] name = "core-foundation" -version = "0.9.2" +version = "0.9.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6888e10551bb93e424d8df1d07f1a8b4fceb0001a3a4b048bfc47554946f47b3" +checksum = "194a7a9e6de53fa55116934067c844d9d749312f75c6f6d0980e8c252f8c2146" dependencies = [ "core-foundation-sys", "libc", @@ -407,28 +411,18 @@ checksum = "ccaeedb56da03b09f598226e25e80088cb4cd25f316e6e4df7d695f0feeb1403" [[package]] name = "crc32fast" -version = "1.3.1" +version = "1.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a2209c310e29876f7f0b2721e7e26b84aff178aa3da5d091f9bfbf47669e60e3" +checksum = "b540bd8bc810d3885c6ea91e2018302f68baba2129ab3e88f32389ee9370880d" dependencies = [ "cfg-if 1.0.0", ] -[[package]] -name = "crossbeam-channel" -version = "0.5.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e54ea8bc3fb1ee042f5aace6e3c6e025d3874866da222930f70ce62aceba0bfa" -dependencies = [ - "cfg-if 1.0.0", - "crossbeam-utils", -] - [[package]] name = "crossbeam-queue" -version = "0.3.3" +version = "0.3.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b979d76c9fcb84dffc80a73f7290da0f83e4c95773494674cb44b76d13a7a110" +checksum = "4dd435b205a4842da59efd07628f921c096bc1cc0a156835b4fa0bcb9a19bcce" dependencies = [ "cfg-if 1.0.0", "crossbeam-utils", @@ -436,9 +430,9 @@ dependencies = [ [[package]] name = "crossbeam-utils" -version = "0.8.6" +version = "0.8.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cfcae03edb34f947e64acdb1c33ec169824e20657e9ecb61cef6c8c74dcb8120" +checksum = "b5e5bed1f1c269533fa816a0a5492b3545209a205ca1a54842be180eb63a16a6" dependencies = [ "cfg-if 1.0.0", "lazy_static", @@ -457,11 +451,12 @@ dependencies = [ [[package]] name = "crypto-common" -version = "0.1.2" +version = "0.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a4600d695eb3f6ce1cd44e6e291adceb2cc3ab12f20a33777ecd0bf6eba34e06" +checksum = "57952ca27b5e3606ff4dd79b0020231aaf9d6aa76dc05fd30137538c50bd3ce8" dependencies = [ "generic-array 0.14.5", + "typenum", ] [[package]] @@ -474,14 +469,48 @@ dependencies = [ ] [[package]] -name = "dashmap" -version = "5.0.0" +name = "darling" +version = "0.12.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b799062aaf67eb976af3bdca031ee6f846d2f0a5710ddbb0d2efee33f3cc4760" +checksum = "5f2c43f534ea4b0b049015d00269734195e6d3f0f6635cb692251aca6f9f8b3c" +dependencies = [ + "darling_core", + "darling_macro", +] + +[[package]] +name = "darling_core" +version = "0.12.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e91455b86830a1c21799d94524df0845183fa55bafd9aa137b01c7d1065fa36" +dependencies = [ + "fnv", + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn", +] + +[[package]] +name = "darling_macro" +version = "0.12.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29b5acf0dea37a7f66f7b25d2c5e93fd46f8f6968b1a5d7a3e02e97768afc95a" +dependencies = [ + "darling_core", + "quote", + "syn", +] + +[[package]] +name = "dashmap" +version = "4.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e77a43b28d0668df09411cb0bc9a8c2adc40f9a048afe863e05fd43251e8e39c" dependencies = [ "cfg-if 1.0.0", "num_cpus", - "parking_lot", "serde", ] @@ -554,9 +583,9 @@ dependencies = [ [[package]] name = "digest" -version = "0.10.2" +version = "0.10.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8cb780dce4f9a8f5c087362b3a4595936b2019e7c8b30f2c3e9a7e94e6ae9837" +checksum = "f2fb860ca6fafa5552fb6d0e816a69c8e49f0908bf524e30a90d97c85892d506" dependencies = [ "block-buffer 0.10.2", "crypto-common", @@ -718,9 +747,9 @@ checksum = "3dcaa9ae7725d12cdb85b3ad99a434db70b468c09ded17e012d86b5c1010f7a7" [[package]] name = "futures" -version = "0.3.19" +version = "0.3.21" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "28560757fe2bb34e79f907794bb6b22ae8b0e5c669b638a1132f2592b19035b4" +checksum = "f73fe65f54d1e12b726f517d3e2135ca3125a437b6d998caf1962961f7172d9e" dependencies = [ "futures-channel", "futures-core", @@ -732,9 +761,9 @@ dependencies = [ [[package]] name = "futures-channel" -version = "0.3.19" +version = "0.3.21" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ba3dda0b6588335f360afc675d0564c17a77a2bda81ca178a4b6081bd86c7f0b" +checksum = "c3083ce4b914124575708913bca19bfe887522d6e2e6d0952943f5eac4a74010" dependencies = [ "futures-core", "futures-sink", @@ -742,9 +771,9 @@ dependencies = [ [[package]] name = "futures-core" -version = "0.3.19" +version = "0.3.21" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d0c8ff0461b82559810cdccfde3215c3f373807f5e5232b71479bff7bb2583d7" +checksum = "0c09fd04b7e4073ac7156a9539b57a484a8ea920f79c7c675d05d289ab6110d3" [[package]] name = "futures-intrusive" @@ -754,32 +783,32 @@ checksum = "62007592ac46aa7c2b6416f7deb9a8a8f63a01e0f1d6e1787d5630170db2b63e" dependencies = [ "futures-core", "lock_api", - "parking_lot", + "parking_lot 0.11.2", ] [[package]] name = "futures-io" -version = "0.3.19" +version = "0.3.21" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b1f9d34af5a1aac6fb380f735fe510746c38067c5bf16c7fd250280503c971b2" +checksum = "fc4045962a5a5e935ee2fdedaa4e08284547402885ab326734432bed5d12966b" [[package]] name = "futures-sink" -version = "0.3.19" +version = "0.3.21" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e3055baccb68d74ff6480350f8d6eb8fcfa3aa11bdc1a1ae3afdd0514617d508" +checksum = "21163e139fa306126e6eedaf49ecdb4588f939600f0b1e770f4205ee4b7fa868" [[package]] name = "futures-task" -version = "0.3.19" +version = "0.3.21" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6ee7c6485c30167ce4dfb83ac568a849fe53274c831081476ee13e0dce1aad72" +checksum = "57c66a976bf5909d801bbef33416c41372779507e7a6b3a5e25e4749c58f776a" [[package]] name = "futures-util" -version = "0.3.19" +version = "0.3.21" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d9b5cf40b47a271f77a8b1bec03ca09044d99d2372c0de244e66430761127164" +checksum = "d8b7abd5d659d9b90c8cba917f6ec750a74e2dc23902ef9cd4cc8c8b22e6036a" dependencies = [ "futures-channel", "futures-core", @@ -903,7 +932,7 @@ dependencies = [ "indexmap", "slab", "tokio", - "tokio-util", + "tokio-util 0.6.9", "tracing", ] @@ -951,20 +980,20 @@ checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" [[package]] name = "hkdf" -version = "0.12.2" +version = "0.12.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "158bc31e00a68e380286904cc598715f861f2b0ccf7aa6fe20c6d0c49ca5d0f6" +checksum = "791a029f6b9fc27657f6f188ec6e5e43f6911f6f878e0dc5501396e09809d437" dependencies = [ "hmac", ] [[package]] name = "hmac" -version = "0.12.0" +version = "0.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ddca131f3e7f2ce2df364b57949a9d47915cfbd35e46cfee355ccebbf794d6a2" +checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" dependencies = [ - "digest 0.10.2", + "digest 0.10.3", ] [[package]] @@ -975,7 +1004,7 @@ checksum = "31f4c6746584866f0feabcc69893c5b51beef3831656a968ed7ae254cdc4fd03" dependencies = [ "bytes", "fnv", - "itoa 1.0.1", + "itoa", ] [[package]] @@ -991,9 +1020,9 @@ dependencies = [ [[package]] name = "httparse" -version = "1.5.1" +version = "1.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "acd94fdbe1d4ff688b67b04eee2e17bd50995534a61539e45adfefb45e5e5503" +checksum = "9100414882e15fb7feccb4897e5f0ff0ff1ca7d1a86a23208ada4d7a18e6c6c4" [[package]] name = "httpdate" @@ -1015,9 +1044,9 @@ checksum = "9a3a5bfb195931eeb336b2a7b4d761daec841b97f947d34394601737a7bba5e4" [[package]] name = "hyper" -version = "0.14.16" +version = "0.14.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b7ec3e62bdc98a2f0393a5048e4c30ef659440ea6e0e572965103e72bd836f55" +checksum = "043f0e083e9901b6cc658a77d1eb86f4fc650bbb977a4337dd63192826aa85dd" dependencies = [ "bytes", "futures-channel", @@ -1028,7 +1057,7 @@ dependencies = [ "http-body", "httparse", "httpdate", - "itoa 0.4.8", + "itoa", "pin-project-lite", "socket2", "tokio", @@ -1045,7 +1074,7 @@ checksum = "d87c48c02e0dc5e3b849a2041db3029fd066650f8f717c07bf8ed78ccb895cac" dependencies = [ "http", "hyper", - "rustls 0.20.2", + "rustls 0.20.4", "tokio", "tokio-rustls 0.23.2", ] @@ -1063,6 +1092,12 @@ dependencies = [ "tokio-native-tls", ] +[[package]] +name = "ident_case" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" + [[package]] name = "idna" version = "0.2.3" @@ -1098,7 +1133,7 @@ version = "1.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "282a6247722caba404c065016bbfa522806e51714c34f5dfc3e4a3a46fcb4223" dependencies = [ - "autocfg 1.0.1", + "autocfg 1.1.0", "hashbrown", "serde", ] @@ -1162,12 +1197,6 @@ dependencies = [ "either", ] -[[package]] -name = "itoa" -version = "0.4.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b71991ff56294aa922b450139ee08b3bfc70982c6b2c7562771375cf73542dd4" - [[package]] name = "itoa" version = "1.0.1" @@ -1216,15 +1245,15 @@ checksum = "db13adb97ab515a3691f56e4dbab09283d0b86cb45abd991d8634a9d6f501760" [[package]] name = "libc" -version = "0.2.116" +version = "0.2.118" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "565dbd88872dbe4cc8a46e527f26483c1d1f7afa6b884a3bd6cd893d4f98da74" +checksum = "06e509672465a0504304aa87f9f176f2b2b716ed8fb105ebe5c02dc6dce96a94" [[package]] name = "libm" -version = "0.2.1" +version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c7d73b3f436185384286bd8098d17ec07c9a7d2388a6599f824d8502b529702a" +checksum = "33a33a362ce288760ec6a508b94caaec573ae7d3bbbd91b87aa0bad4456839db" [[package]] name = "lock_api" @@ -1294,9 +1323,9 @@ checksum = "2a60c7ce501c71e03a9c9c0d35b861413ae925bd979cc7a4e30d060069aaac8d" [[package]] name = "mime_guess" -version = "2.0.3" +version = "2.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2684d4c2e97d99848d30b324b00c8fcc7e5c897b7cbb5819b09e7c90e8baf212" +checksum = "4192263c238a5f0d0c6bfd21f336a313a4ce1c450542449ca191bb657b4642ef" dependencies = [ "mime", "unicase", @@ -1315,7 +1344,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a92518e98c078586bc6c934028adcca4c92a53d6a958196de835170a01d84e4b" dependencies = [ "adler", - "autocfg 1.0.1", + "autocfg 1.1.0", ] [[package]] @@ -1339,9 +1368,9 @@ dependencies = [ [[package]] name = "mio" -version = "0.7.14" +version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8067b404fe97c70829f082dec8bcf4f71225d7eaea1d8645349cb76fa06205cc" +checksum = "ba272f85fa0b41fc91872be579b3bbe0f56b792aa361a380eb669469f68dafb2" dependencies = [ "libc", "log", @@ -1399,7 +1428,7 @@ dependencies = [ "mime", "spin 0.9.2", "tokio", - "tokio-util", + "tokio-util 0.6.9", "version_check", ] @@ -1472,9 +1501,9 @@ dependencies = [ [[package]] name = "ntapi" -version = "0.3.6" +version = "0.3.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3f6bb902e437b6d86e03cce10a7e2af662292c5dfef23b65899ea3ac9354ad44" +checksum = "c28774a7fd2fbb4f0babd8237ce554b73af68021b5f695a3cebd6c59bac0980f" dependencies = [ "winapi 0.3.9", ] @@ -1485,7 +1514,7 @@ version = "0.3.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5f6f7833f2cbf2360a6cfd58cd41a53aa7a90bd4c202f5b1c7dd2ed73c57b2c3" dependencies = [ - "autocfg 1.0.1", + "autocfg 1.1.0", "num-integer", "num-traits", ] @@ -1496,14 +1525,14 @@ version = "0.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4547ee5541c18742396ae2c895d0717d0f886d8823b8399cdaf7b07d63ad0480" dependencies = [ - "autocfg 0.1.7", + "autocfg 0.1.8", "byteorder", "lazy_static", "libm", "num-integer", "num-iter", "num-traits", - "rand 0.8.4", + "rand 0.8.5", "smallvec", "zeroize", ] @@ -1514,7 +1543,7 @@ version = "0.1.44" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d2cc698a63b549a70bc047073d2949cce27cd1c7b0a4a862d08a8031bc2801db" dependencies = [ - "autocfg 1.0.1", + "autocfg 1.1.0", "num-traits", ] @@ -1524,7 +1553,7 @@ version = "0.1.42" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b2021c8337a54d21aca0d59a92577a029af9431cb59b909b03252b9c164fad59" dependencies = [ - "autocfg 1.0.1", + "autocfg 1.1.0", "num-integer", "num-traits", ] @@ -1535,7 +1564,7 @@ version = "0.2.14" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9a64b1ec5cda2586e284722486d802acf1f7dbdc623e2bfc57e65ca1cd099290" dependencies = [ - "autocfg 1.0.1", + "autocfg 1.1.0", "libm", ] @@ -1568,7 +1597,7 @@ dependencies = [ "chrono", "getrandom 0.2.4", "http", - "rand 0.8.4", + "rand 0.8.5", "reqwest", "serde", "serde_json", @@ -1622,7 +1651,7 @@ version = "0.9.72" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7e46109c383602735fa0a2e48dd2b7c892b048e1bf69e5c3b1d804b7d9c203cb" dependencies = [ - "autocfg 1.0.1", + "autocfg 1.1.0", "cc", "libc", "pkg-config", @@ -1637,7 +1666,17 @@ checksum = "7d17b78036a60663b797adeaee46f5c9dfebb86948d1255007a1d6be0271ff99" dependencies = [ "instant", "lock_api", - "parking_lot_core", + "parking_lot_core 0.8.5", +] + +[[package]] +name = "parking_lot" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "87f5ec2493a61ac0506c0f4199f99070cbe83857b0337006a30f3e6719b8ef58" +dependencies = [ + "lock_api", + "parking_lot_core 0.9.1", ] [[package]] @@ -1654,6 +1693,19 @@ dependencies = [ "winapi 0.3.9", ] +[[package]] +name = "parking_lot_core" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28141e0cc4143da2443301914478dc976a61ffdb3f043058310c70df2fed8954" +dependencies = [ + "cfg-if 1.0.0", + "libc", + "redox_syscall", + "smallvec", + "windows-sys", +] + [[package]] name = "parse-zoneinfo" version = "0.3.0" @@ -1663,6 +1715,12 @@ dependencies = [ "regex", ] +[[package]] +name = "paste" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0744126afe1a6dd7f394cb50a716dbe086cb06e255e53d8d0185d82828358fb5" + [[package]] name = "pear" version = "0.2.3" @@ -1770,7 +1828,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5d5285893bb5eb82e6aaf5d59ee909a06a16737a8970984dd7746ba9283498d6" dependencies = [ "phf_shared", - "rand 0.8.4", + "rand 0.8.5", ] [[package]] @@ -1825,6 +1883,32 @@ version = "0.3.24" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "58893f751c9b0412871a09abd62ecd2a00298c6c83befa223ef98c52aef40cbe" +[[package]] +name = "poise" +version = "0.1.0" +source = "git+https://github.com/kangalioo/poise?branch=master#38bcca284cbc9fb52cd770d7af64fbd4b3495cc8" +dependencies = [ + "async-trait", + "futures-core", + "futures-util", + "once_cell", + "poise_macros", + "regex", + "serenity", + "tokio", +] + +[[package]] +name = "poise_macros" +version = "0.1.0" +source = "git+https://github.com/kangalioo/poise?branch=master#38bcca284cbc9fb52cd770d7af64fbd4b3495cc8" +dependencies = [ + "darling", + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "polyval" version = "0.5.3" @@ -1900,19 +1984,18 @@ dependencies = [ "libc", "rand_chacha 0.2.2", "rand_core 0.5.1", - "rand_hc 0.2.0", + "rand_hc", ] [[package]] name = "rand" -version = "0.8.4" +version = "0.8.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2e7573632e6454cf6b99d7aac4ccca54be06da05aca2ef7423d22d27d4d4bcd8" +checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" dependencies = [ "libc", "rand_chacha 0.3.1", "rand_core 0.6.3", - "rand_hc 0.3.1", ] [[package]] @@ -1962,15 +2045,6 @@ dependencies = [ "rand_core 0.5.1", ] -[[package]] -name = "rand_hc" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d51e9f596de227fda2ea6c84607f5558e196eeaf43c986b724ba4fb8fdf497e7" -dependencies = [ - "rand_core 0.6.3", -] - [[package]] name = "redox_syscall" version = "0.2.10" @@ -2045,11 +2119,11 @@ dependencies = [ "chrono-tz 0.5.3", "dotenv", "env_logger", - "humantime", "lazy_static", "levenshtein", "log", "num-integer", + "poise", "postman", "rand 0.7.3", "regex", @@ -2060,7 +2134,6 @@ dependencies = [ "serde", "serde_json", "serde_repr", - "serenity", "sqlx", "tokio", ] @@ -2116,7 +2189,7 @@ dependencies = [ "native-tls", "percent-encoding", "pin-project-lite", - "rustls 0.20.2", + "rustls 0.20.4", "rustls-pemfile", "serde", "serde_json", @@ -2124,7 +2197,7 @@ dependencies = [ "tokio", "tokio-native-tls", "tokio-rustls 0.23.2", - "tokio-util", + "tokio-util 0.6.9", "url", "wasm-bindgen", "wasm-bindgen-futures", @@ -2172,7 +2245,7 @@ dependencies = [ [[package]] name = "rocket" version = "0.5.0-rc.1" -source = "git+https://github.com/SergioBenitez/Rocket?branch=master#8cae077ba1d54b92cdef3e171a730b819d5eeb8e" +source = "git+https://github.com/SergioBenitez/Rocket?branch=master#66d18bf66517e2765494d082629e9b9748ff8ad6" dependencies = [ "async-stream", "async-trait", @@ -2188,9 +2261,9 @@ dependencies = [ "memchr", "multer", "num_cpus", - "parking_lot", + "parking_lot 0.12.0", "pin-project-lite", - "rand 0.8.4", + "rand 0.8.5", "ref-cast", "rocket_codegen", "rocket_http", @@ -2201,7 +2274,7 @@ dependencies = [ "time 0.3.7", "tokio", "tokio-stream", - "tokio-util", + "tokio-util 0.7.0", "ubyte", "version_check", "yansi", @@ -2210,7 +2283,7 @@ dependencies = [ [[package]] name = "rocket_codegen" version = "0.5.0-rc.1" -source = "git+https://github.com/SergioBenitez/Rocket?branch=master#8cae077ba1d54b92cdef3e171a730b819d5eeb8e" +source = "git+https://github.com/SergioBenitez/Rocket?branch=master#66d18bf66517e2765494d082629e9b9748ff8ad6" dependencies = [ "devise", "glob", @@ -2225,7 +2298,7 @@ dependencies = [ [[package]] name = "rocket_dyn_templates" version = "0.1.0-rc.1" -source = "git+https://github.com/SergioBenitez/Rocket?branch=master#8cae077ba1d54b92cdef3e171a730b819d5eeb8e" +source = "git+https://github.com/SergioBenitez/Rocket?branch=master#66d18bf66517e2765494d082629e9b9748ff8ad6" dependencies = [ "glob", "normpath", @@ -2237,7 +2310,7 @@ dependencies = [ [[package]] name = "rocket_http" version = "0.5.0-rc.1" -source = "git+https://github.com/SergioBenitez/Rocket?branch=master#8cae077ba1d54b92cdef3e171a730b819d5eeb8e" +source = "git+https://github.com/SergioBenitez/Rocket?branch=master#66d18bf66517e2765494d082629e9b9748ff8ad6" dependencies = [ "cookie", "either", @@ -2276,7 +2349,7 @@ dependencies = [ "num-traits", "pkcs1", "pkcs8", - "rand 0.8.4", + "rand 0.8.5", "subtle", "zeroize", ] @@ -2296,9 +2369,9 @@ dependencies = [ [[package]] name = "rustls" -version = "0.20.2" +version = "0.20.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d37e5e2290f3e040b594b1a9e04377c2c671f1a1cfd9bfdef82106ac1c113f84" +checksum = "4fbfeb8d0ddb84706bc597a5574ab8912817c52a397f819e5b614e2265206921" dependencies = [ "log", "ring", @@ -2380,9 +2453,9 @@ dependencies = [ [[package]] name = "security-framework" -version = "2.6.0" +version = "2.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3fed7948b6c68acbb6e20c334f55ad635dc0f75506963de4464289fbd3b051ac" +checksum = "2dc14f172faf8a0194a3aded622712b0de276821addc574fa54fc0a1167e10dc" dependencies = [ "bitflags", "core-foundation", @@ -2393,9 +2466,9 @@ dependencies = [ [[package]] name = "security-framework-sys" -version = "2.6.0" +version = "2.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a57321bf8bc2362081b2599912d2961fe899c0efadf1b4b2f8d48b3e253bb96c" +checksum = "0160a13a177a45bfb43ce71c01580998474f556ad854dcbca936dd2841a5c556" dependencies = [ "core-foundation-sys", "libc", @@ -2423,11 +2496,11 @@ dependencies = [ [[package]] name = "serde_json" -version = "1.0.78" +version = "1.0.79" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d23c1ba4cf0efd44be32017709280b32d1cea5c3f1275c3b6d9e8bc54f758085" +checksum = "8e8d9fa5c3b304765ce1fd9c4c8a3de2c8db365a5b91be52f186efc675681d95" dependencies = [ - "itoa 1.0.1", + "itoa", "ryu", "serde", ] @@ -2459,7 +2532,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" dependencies = [ "form_urlencoded", - "itoa 1.0.1", + "itoa", "ryu", "serde", ] @@ -2467,7 +2540,7 @@ dependencies = [ [[package]] name = "serenity" version = "0.10.10" -source = "git+https://github.com/serenity-rs/serenity?branch=next#9bf5f25ab8421a0513a199930a7a624da3d3c853" +source = "git+https://github.com/serenity-rs/serenity?branch=next#85b3d8c665d9c15f47aeb7afeb738c58a32a5e64" dependencies = [ "async-trait", "async-tungstenite", @@ -2480,7 +2553,7 @@ dependencies = [ "futures", "mime", "mime_guess", - "parking_lot", + "parking_lot 0.11.2", "percent-encoding", "reqwest", "serde", @@ -2531,13 +2604,13 @@ dependencies = [ [[package]] name = "sha2" -version = "0.10.1" +version = "0.10.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "99c3bd8169c58782adad9290a9af5939994036b76187f7b4f0e6de91dbbfc0ec" +checksum = "55deaec60f81eefe3cce0dc50bda92d6d8e88f2a27df7c5033b42afeb1ed2676" dependencies = [ "cfg-if 1.0.0", "cpufeatures", - "digest 0.10.2", + "digest 0.10.3", ] [[package]] @@ -2629,9 +2702,9 @@ dependencies = [ [[package]] name = "sqlx" -version = "0.5.10" +version = "0.5.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "692749de69603d81e016212199d73a2e14ee20e2def7d7914919e8db5d4d48b9" +checksum = "fc15591eb44ffb5816a4a70a7efd5dd87bfd3aa84c4c200401c4396140525826" dependencies = [ "sqlx-core", "sqlx-macros", @@ -2639,9 +2712,9 @@ dependencies = [ [[package]] name = "sqlx-core" -version = "0.5.10" +version = "0.5.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "518be6f6fff5ca76f985d434f9c37f3662af279642acf730388f271dff7b9016" +checksum = "195183bf6ff8328bb82c0511a83faf60aacf75840103388851db61d7a9854ae3" dependencies = [ "ahash", "atoi", @@ -2651,9 +2724,7 @@ dependencies = [ "bytes", "chrono", "crc", - "crossbeam-channel", "crossbeam-queue", - "crossbeam-utils", "digest 0.9.0", "either", "futures-channel", @@ -2664,15 +2735,15 @@ dependencies = [ "hashlink", "hex", "indexmap", - "itoa 1.0.1", + "itoa", "libc", "log", "memchr", "num-bigint", "once_cell", - "parking_lot", + "paste", "percent-encoding", - "rand 0.8.4", + "rand 0.8.5", "rsa", "rustls 0.19.1", "sha-1 0.9.8", @@ -2690,9 +2761,9 @@ dependencies = [ [[package]] name = "sqlx-macros" -version = "0.5.10" +version = "0.5.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "38e45140529cf1f90a5e1c2e561500ca345821a1c513652c8f486bbf07407cc8" +checksum = "eee35713129561f5e55c554bba1c378e2a7e67f81257b7311183de98c50e6f94" dependencies = [ "dotenv", "either", @@ -2709,9 +2780,9 @@ dependencies = [ [[package]] name = "sqlx-rt" -version = "0.5.10" +version = "0.5.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8061cbaa91ee75041514f67a09398c65a64efed72c90151ecd47593bad53da99" +checksum = "b555e70fbbf84e269ec3858b7a6515bcfe7a166a7cc9c636dd6efd20431678b6" dependencies = [ "once_cell", "tokio", @@ -2746,6 +2817,12 @@ dependencies = [ "unicode-normalization", ] +[[package]] +name = "strsim" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73473c0e59e6d5812c5dfe2a064a6444949f089e20eec9a2e5506596494e4623" + [[package]] name = "subtle" version = "2.4.1" @@ -2803,7 +2880,7 @@ dependencies = [ "percent-encoding", "pest", "pest_derive", - "rand 0.8.4", + "rand 0.8.5", "regex", "serde", "serde_json", @@ -2865,7 +2942,7 @@ version = "0.3.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "004cbc98f30fa233c61a38bc77e96a9106e65c88f2d3bef182ae952027e5753d" dependencies = [ - "itoa 1.0.1", + "itoa", "libc", "num_threads", "time-macros", @@ -2894,19 +2971,20 @@ checksum = "cda74da7e1a664f795bb1f8a87ec406fb89a02522cf6e50620d016add6dbbf5c" [[package]] name = "tokio" -version = "1.16.1" +version = "1.17.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0c27a64b625de6d309e8c57716ba93021dccf1b3b5c97edd6d3dd2d2135afc0a" +checksum = "2af73ac49756f3f7c01172e34a23e5d0216f6c32333757c2c61feb2bbff5a5ee" dependencies = [ "bytes", "libc", "memchr", - "mio 0.7.14", + "mio 0.8.0", "num_cpus", "once_cell", - "parking_lot", + "parking_lot 0.12.0", "pin-project-lite", "signal-hook-registry", + "socket2", "tokio-macros", "winapi 0.3.9", ] @@ -2949,7 +3027,7 @@ version = "0.23.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a27d5f2b839802bd8267fa19b0530f5a08b9c08cd417976be2a65d130fe1c11b" dependencies = [ - "rustls 0.20.2", + "rustls 0.20.4", "tokio", "webpki 0.22.0", ] @@ -2979,6 +3057,20 @@ dependencies = [ "tokio", ] +[[package]] +name = "tokio-util" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "64910e1b9c1901aaf5375561e35b9c057d95ff41a44ede043a03e09279eabaf1" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "log", + "pin-project-lite", + "tokio", +] + [[package]] name = "toml" version = "0.5.8" @@ -2996,9 +3088,9 @@ checksum = "360dfd1d6d30e05fda32ace2c8c70e9c0a9da713275777f5a4dbb8a1893930c6" [[package]] name = "tracing" -version = "0.1.29" +version = "0.1.31" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "375a639232caf30edfc78e8d89b2d4c375515393e7af7e16f01cd96917fb2105" +checksum = "f6c650a8ef0cd2dd93736f033d21cbd1224c5a967aa0c258d00fcf7dafef9b9f" dependencies = [ "cfg-if 1.0.0", "log", @@ -3009,9 +3101,9 @@ dependencies = [ [[package]] name = "tracing-attributes" -version = "0.1.18" +version = "0.1.19" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f4f480b8f81512e825f337ad51e94c1eb5d3bbdf2b363dcd01e2b19a9ffe3f8e" +checksum = "8276d9a4a3a558d7b7ad5303ad50b53d58264641b82914b7ada36bd762e7a716" dependencies = [ "proc-macro2", "quote", @@ -3020,11 +3112,12 @@ dependencies = [ [[package]] name = "tracing-core" -version = "0.1.21" +version = "0.1.22" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1f4ed65637b8390770814083d20756f87bfa2c21bf2f110babdc5438351746e4" +checksum = "03cfcb51380632a72d3111cb8d3447a8d908e577d31beeac006f836383d29a23" dependencies = [ "lazy_static", + "valuable", ] [[package]] @@ -3040,9 +3133,9 @@ dependencies = [ [[package]] name = "tracing-subscriber" -version = "0.3.7" +version = "0.3.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5312f325fe3588e277415f5a6cca1f4ccad0f248c4cd5a4bd33032d7286abc22" +checksum = "9e0ab7bdc962035a87fba73f3acca9b8a8d0034c2e6f60b84aeaaddddc155dce" dependencies = [ "ansi_term", "lazy_static", @@ -3074,8 +3167,8 @@ dependencies = [ "http", "httparse", "log", - "rand 0.8.4", - "rustls 0.20.2", + "rand 0.8.5", + "rustls 0.20.4", "sha-1 0.9.8", "thiserror", "url", @@ -3196,9 +3289,9 @@ dependencies = [ [[package]] name = "unicode-segmentation" -version = "1.8.0" +version = "1.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8895849a949e7845e06bd6dc1aa51731a103c42707010a5b591c0038fb73385b" +checksum = "7e8820f5d777f6224dc4be3632222971ac30164d4a258d595640799554ebfd99" [[package]] name = "unicode-xid" @@ -3256,6 +3349,12 @@ dependencies = [ "getrandom 0.2.4", ] +[[package]] +name = "valuable" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "830b7e5d4d90034032940e4ace0d9a9a057e7a45cd94e6c007832e39edb82f6d" + [[package]] name = "vcpkg" version = "0.2.15" @@ -3458,6 +3557,49 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" +[[package]] +name = "windows-sys" +version = "0.32.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3df6e476185f92a12c072be4a189a0210dcdcf512a1891d6dff9edb874deadc6" +dependencies = [ + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_msvc", +] + +[[package]] +name = "windows_aarch64_msvc" +version = "0.32.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8e92753b1c443191654ec532f14c199742964a061be25d77d7a96f09db20bf5" + +[[package]] +name = "windows_i686_gnu" +version = "0.32.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a711c68811799e017b6038e0922cb27a5e2f43a2ddb609fe0b6f3eeda9de615" + +[[package]] +name = "windows_i686_msvc" +version = "0.32.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "146c11bb1a02615db74680b32a68e2d61f553cc24c4eb5b4ca10311740e44172" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.32.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c912b12f7454c6620635bbff3450962753834be2a594819bd5e945af18ec64bc" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.32.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "504a2476202769977a040c6364301a3f65d0cc9e3fb08600b2bda150a0488316" + [[package]] name = "winreg" version = "0.7.0" @@ -3494,9 +3636,9 @@ dependencies = [ [[package]] name = "zeroize_derive" -version = "1.3.1" +version = "1.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "81e8f13fef10b63c06356d65d416b070798ddabcadc10d3ece0c5be9b3c7eddb" +checksum = "3f8f187641dad4f680d25c4bfc4225b418165984179f26ca76ec4fb6441d3a17" dependencies = [ "proc-macro2", "quote", diff --git a/Cargo.toml b/Cargo.toml index 4ade220..23f13b0 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -5,8 +5,8 @@ authors = ["jellywx "] edition = "2018" [dependencies] +poise = { git = "https://github.com/kangalioo/poise", branch = "master" } dotenv = "0.15" -humantime = "2.1" tokio = { version = "1", features = ["process", "full"] } reqwest = "0.11" regex = "1.4" @@ -33,20 +33,3 @@ path = "postman" [dependencies.reminder_web] path = "web" - -[dependencies.serenity] -git = "https://github.com/serenity-rs/serenity" -branch = "next" -default-features = false -features = [ - "builder", - "client", - "cache", - "gateway", - "http", - "model", - "utils", - "rustls_backend", - "collector", - "unstable_discord_api" -] diff --git a/src/commands/info_cmds.rs b/src/commands/info_cmds.rs index f6232f5..3376f64 100644 --- a/src/commands/info_cmds.rs +++ b/src/commands/info_cmds.rs @@ -1,16 +1,11 @@ use chrono::offset::Utc; -use regex_command_attr::command; -use serenity::{builder::CreateEmbedFooter, client::Context}; +use poise::serenity::builder::CreateEmbedFooter; -use crate::{ - framework::{CommandInvoke, CreateGenericResponse}, - models::CtxData, - THEME_COLOR, -}; +use crate::{models::CtxData, Context, Error, THEME_COLOR}; -fn footer(ctx: &Context) -> impl FnOnce(&mut CreateEmbedFooter) -> &mut CreateEmbedFooter { - let shard_count = ctx.cache.shard_count(); - let shard = ctx.shard_id; +fn footer(ctx: Context<'_>) -> impl FnOnce(&mut CreateEmbedFooter) -> &mut CreateEmbedFooter { + let shard_count = ctx.discord().cache.shard_count(); + let shard = ctx.discord().shard_id; move |f| { f.text(format!( @@ -22,15 +17,14 @@ fn footer(ctx: &Context) -> impl FnOnce(&mut CreateEmbedFooter) -> &mut CreateEm } } -#[command] -#[description("Get an overview of the bot commands")] -async fn help(ctx: &Context, invoke: &mut CommandInvoke) { +/// Get an overview of bot commands +#[poise::command(slash_command)] +pub async fn help(ctx: Context<'_>) -> Result<(), Error> { let footer = footer(ctx); - let _ = invoke - .respond( - &ctx, - CreateGenericResponse::new().embed(|e| { + let _ = ctx + .send(|m| { + m.embed(|e| { e.title("Help") .color(*THEME_COLOR) .description( @@ -60,21 +54,21 @@ __Advanced Commands__ ", ) .footer(footer) - }), - ) + }) + }) .await; + + Ok(()) } -#[command] -#[aliases("invite")] -#[description("Get information about the bot")] -async fn info(ctx: &Context, invoke: &mut CommandInvoke) { +/// Get information about the bot +#[poise::command(slash_command)] +pub async fn info(ctx: Context<'_>) -> Result<(), Error> { let footer = footer(ctx); - let _ = invoke - .respond( - ctx.http.clone(), - CreateGenericResponse::new().embed(|e| { + let _ = ctx + .send(|m| { + m.embed(|e| { e.title("Info") .description(format!( "Help: `/help` @@ -89,21 +83,19 @@ Use our dashboard: https://reminder-bot.com/", )) .footer(footer) .color(*THEME_COLOR) - }), - ) + }) + }) .await; + + Ok(()) } -#[command] -#[description("Details on supporting the bot and Patreon benefits")] -#[group("Info")] -async fn donate(ctx: &Context, invoke: &mut CommandInvoke) { +/// Details on supporting the bot and Patreon benefits +#[poise::command(slash_command)] +pub async fn donate(ctx: Context<'_>) -> Result<(), Error> { let footer = footer(ctx); - let _ = invoke - .respond( - ctx.http.clone(), - CreateGenericResponse::new().embed(|e| { + let _ = ctx.send(|m| m.embed(|e| { e.title("Donate") .description("Thinking of adding a monthly contribution? Click below for my Patreon and official bot server :) @@ -125,38 +117,41 @@ Just $2 USD/month! }), ) .await; + + Ok(()) } -#[command] -#[description("Get the link to the online dashboard")] -#[group("Info")] -async fn dashboard(ctx: &Context, invoke: &mut CommandInvoke) { +/// Get the link to the online dashboard +#[poise::command(slash_command)] +pub async fn dashboard(ctx: Context<'_>) -> Result<(), Error> { let footer = footer(ctx); - let _ = invoke - .respond( - ctx.http.clone(), - CreateGenericResponse::new().embed(|e| { + let _ = ctx + .send(|m| { + m.embed(|e| { e.title("Dashboard") .description("**https://reminder-bot.com/dashboard**") .footer(footer) .color(*THEME_COLOR) - }), - ) + }) + }) .await; + + Ok(()) } -#[command] -#[description("View the current time in your selected timezone")] -#[group("Info")] -async fn clock(ctx: &Context, invoke: &mut CommandInvoke) { - let ud = ctx.user_data(&invoke.author_id()).await.unwrap(); - let now = Utc::now().with_timezone(&ud.timezone()); +/// View the current time in a user's selected timezone +#[poise::command(slash_command)] +pub async fn clock(ctx: Context<'_>) -> Result<(), Error> { + ctx.defer_ephemeral().await?; - let _ = invoke - .respond( - ctx.http.clone(), - CreateGenericResponse::new().content(format!("Current time: {}", now.format("%H:%M"))), - ) - .await; + let tz = ctx.timezone().await; + let now = Utc::now().with_timezone(&tz); + + ctx.send(|m| { + m.ephemeral(true).content(format!("Time in **{}**: `{}`", tz, now.format("%H:%M"))) + }) + .await?; + + Ok(()) } diff --git a/src/commands/mod.rs b/src/commands/mod.rs index e53c6f3..8ad997e 100644 --- a/src/commands/mod.rs +++ b/src/commands/mod.rs @@ -1,4 +1,4 @@ pub mod info_cmds; pub mod moderation_cmds; -pub mod reminder_cmds; -pub mod todo_cmds; +//pub mod reminder_cmds; +//pub mod todo_cmds; diff --git a/src/commands/moderation_cmds.rs b/src/commands/moderation_cmds.rs index cada0e4..b554cc4 100644 --- a/src/commands/moderation_cmds.rs +++ b/src/commands/moderation_cmds.rs @@ -1,54 +1,60 @@ use chrono::offset::Utc; use chrono_tz::{Tz, TZ_VARIANTS}; use levenshtein::levenshtein; -use regex_command_attr::command; -use serenity::client::Context; +use poise::CreateReply; use crate::{ - component_models::pager::{MacroPager, Pager}, consts::{EMBED_DESCRIPTION_MAX_LENGTH, THEME_COLOR}, - framework::{CommandInvoke, CommandOptions, CreateGenericResponse, OptionValue}, - hooks::{CHECK_GUILD_PERMISSIONS_HOOK, GUILD_ONLY_HOOK}, + hooks::guild_only, models::{command_macro::CommandMacro, CtxData}, - PopularTimezones, RecordingMacros, RegexFramework, SQLPool, + Context, Data, Error, }; -#[command("timezone")] -#[description("Select your timezone")] -#[arg( - name = "timezone", - description = "Timezone to use from this list: https://gist.github.com/JellyWX/913dfc8b63d45192ad6cb54c829324ee", - kind = "String", - required = false -)] -async fn timezone(ctx: &Context, invoke: &mut CommandInvoke, args: CommandOptions) { - let pool = ctx.data.read().await.get::().cloned().unwrap(); - let mut user_data = ctx.user_data(invoke.author_id()).await.unwrap(); +async fn timezone_autocomplete(ctx: Context<'_>, partial: String) -> Vec { + if partial.is_empty() { + ctx.data().popular_timezones.iter().map(|t| t.to_string()).collect::>() + } else { + TZ_VARIANTS + .iter() + .filter(|tz| tz.to_string().contains(&partial)) + .take(25) + .map(|t| t.to_string()) + .collect::>() + } +} + +/// Select your timezone +#[poise::command(slash_command)] +pub async fn timezone( + ctx: Context<'_>, + #[description = "Timezone to use from this list: https://gist.github.com/JellyWX/913dfc8b63d45192ad6cb54c829324ee"] + #[autocomplete = "timezone_autocomplete"] + timezone: Option, +) -> Result<(), Error> { + let mut user_data = ctx.author_data().await.unwrap(); let footer_text = format!("Current timezone: {}", user_data.timezone); - if let Some(OptionValue::String(timezone)) = args.get("timezone") { + if let Some(timezone) = timezone { match timezone.parse::() { Ok(tz) => { user_data.timezone = timezone.clone(); - user_data.commit_changes(&pool).await; + user_data.commit_changes(&ctx.data().database).await; let now = Utc::now().with_timezone(&tz); - let _ = invoke - .respond( - ctx.http.clone(), - CreateGenericResponse::new().embed(|e| { - e.title("Timezone Set") - .description(format!( - "Timezone has been set to **{}**. Your current time should be `{}`", - timezone, - now.format("%H:%M").to_string() - )) - .color(*THEME_COLOR) - }), - ) - .await; + ctx.send(|m| { + m.embed(|e| { + e.title("Timezone Set") + .description(format!( + "Timezone has been set to **{}**. Your current time should be `{}`", + timezone, + now.format("%H:%M").to_string() + )) + .color(*THEME_COLOR) + }) + }) + .await?; } Err(_) => { @@ -56,8 +62,8 @@ async fn timezone(ctx: &Context, invoke: &mut CommandInvoke, args: CommandOption .iter() .filter(|tz| { timezone.contains(&tz.to_string()) - || tz.to_string().contains(timezone) - || levenshtein(&tz.to_string(), timezone) < 4 + || tz.to_string().contains(&timezone) + || levenshtein(&tz.to_string(), &timezone) < 4 }) .take(25) .map(|t| t.to_owned()) @@ -74,25 +80,21 @@ async fn timezone(ctx: &Context, invoke: &mut CommandInvoke, args: CommandOption ) }); - let _ = invoke - .respond( - ctx.http.clone(), - CreateGenericResponse::new().embed(|e| { - e.title("Timezone Not Recognized") - .description("Possibly you meant one of the following timezones, otherwise click [here](https://gist.github.com/JellyWX/913dfc8b63d45192ad6cb54c829324ee):") - .color(*THEME_COLOR) - .fields(fields) - .footer(|f| f.text(footer_text)) - .url("https://gist.github.com/JellyWX/913dfc8b63d45192ad6cb54c829324ee") - }), - ) - .await; + ctx.send(|m| { + m.embed(|e| { + e.title("Timezone Not Recognized") + .description("Possibly you meant one of the following timezones, otherwise click [here](https://gist.github.com/JellyWX/913dfc8b63d45192ad6cb54c829324ee):") + .color(*THEME_COLOR) + .fields(fields) + .footer(|f| f.text(footer_text)) + .url("https://gist.github.com/JellyWX/913dfc8b63d45192ad6cb54c829324ee") + }) + }) + .await?; } } } else { - let popular_timezones = ctx.data.read().await.get::().cloned().unwrap(); - - let popular_timezones_iter = popular_timezones.iter().map(|t| { + let popular_timezones_iter = ctx.data().popular_timezones.iter().map(|t| { ( t.to_string(), format!("🕗 `{}`", Utc::now().with_timezone(t).format("%H:%M").to_string()), @@ -100,279 +102,278 @@ async fn timezone(ctx: &Context, invoke: &mut CommandInvoke, args: CommandOption ) }); - let _ = invoke - .respond( - ctx.http.clone(), - CreateGenericResponse::new().embed(|e| { - e.title("Timezone Usage") - .description( - "**Usage:** + ctx.send(|m| { + m.embed(|e| { + e.title("Timezone Usage") + .description( + "**Usage:** `/timezone Name` **Example:** `/timezone Europe/London` You may want to use one of the popular timezones below, otherwise click [here](https://gist.github.com/JellyWX/913dfc8b63d45192ad6cb54c829324ee):", + ) + .color(*THEME_COLOR) + .fields(popular_timezones_iter) + .footer(|f| f.text(footer_text)) + .url("https://gist.github.com/JellyWX/913dfc8b63d45192ad6cb54c829324ee") + }) + }) + .await?; + } + + Ok(()) +} + +async fn macro_name_autocomplete(ctx: Context<'_>, partial: String) -> Vec { + sqlx::query!( + " +SELECT name +FROM macro +WHERE + guild_id = (SELECT id FROM guilds WHERE guild = ?) + AND name LIKE CONCAT(?, '%')", + ctx.guild_id().unwrap().0, + partial, + ) + .fetch_all(&ctx.data().database) + .await + .unwrap_or(vec![]) + .iter() + .map(|s| s.name.clone()) + .collect() +} + +/// Record and replay command sequences +#[poise::command(slash_command, rename = "macro", check = "guild_only")] +pub async fn macro_base(_ctx: Context<'_>) -> Result<(), Error> { + Ok(()) +} + +/// Start recording up to 5 commands to replay +#[poise::command(slash_command, rename = "record", check = "guild_only")] +pub async fn record_macro( + ctx: Context<'_>, + #[description = "Name for the new macro"] name: String, + #[description = "Description for the new macro"] description: Option, +) -> Result<(), Error> { + let guild_id = ctx.guild_id().unwrap(); + + let row = sqlx::query!( + " +SELECT 1 as _e FROM macro WHERE guild_id = (SELECT id FROM guilds WHERE guild = ?) AND name = ?", + guild_id.0, + name + ) + .fetch_one(&ctx.data().database) + .await; + + if row.is_ok() { + ctx.send(|m| { + m.ephemeral(true).embed(|e| { + e.title("Unique Name Required") + .description( + "A macro already exists under this name. +Please select a unique name for your macro.", + ) + .color(*THEME_COLOR) + }) + }) + .await?; + } else { + let okay = { + let mut lock = ctx.data().recording_macros.write().await; + + if lock.contains_key(&(guild_id, ctx.author().id)) { + false + } else { + lock.insert( + (guild_id, ctx.author().id), + CommandMacro { guild_id, name, description, commands: vec![] }, + ); + true + } + }; + + if okay { + ctx.send(|m| { + m.ephemeral(true).embed(|e| { + e.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", ) .color(*THEME_COLOR) - .fields(popular_timezones_iter) - .footer(|f| f.text(footer_text)) - .url("https://gist.github.com/JellyWX/913dfc8b63d45192ad6cb54c829324ee") - }), - ) - .await; - } -} - -#[command("macro")] -#[description("Record and replay command sequences")] -#[subcommand("record")] -#[description("Start recording up to 5 commands to replay")] -#[arg(name = "name", description = "Name for the new macro", kind = "String", required = true)] -#[arg( - name = "description", - description = "Description for the new macro", - kind = "String", - required = false -)] -#[subcommand("finish")] -#[description("Finish current recording")] -#[subcommand("list")] -#[description("List recorded macros")] -#[subcommand("run")] -#[description("Run a recorded macro")] -#[arg(name = "name", description = "Name of the macro to run", kind = "String", required = true)] -#[subcommand("delete")] -#[description("Delete a recorded macro")] -#[arg(name = "name", description = "Name of the macro to delete", kind = "String", required = true)] -#[supports_dm(false)] -#[hook(GUILD_ONLY_HOOK)] -#[hook(CHECK_GUILD_PERMISSIONS_HOOK)] -async fn macro_cmd(ctx: &Context, invoke: &mut CommandInvoke, args: CommandOptions) { - let pool = ctx.data.read().await.get::().cloned().unwrap(); - - match args.subcommand.clone().unwrap().as_str() { - "record" => { - let guild_id = invoke.guild_id().unwrap(); - - let name = args.get("name").unwrap().to_string(); - - let row = sqlx::query!( - "SELECT 1 as _e FROM macro WHERE guild_id = (SELECT id FROM guilds WHERE guild = ?) AND name = ?", - guild_id.0, - name - ) - .fetch_one(&pool) - .await; - - if row.is_ok() { - let _ = invoke - .respond( - &ctx, - CreateGenericResponse::new().ephemeral().embed(|e| { - e - .title("Unique Name Required") - .description("A macro already exists under this name. Please select a unique name for your macro.") - .color(*THEME_COLOR) - }), - ) - .await; - } else { - let macro_buffer = ctx.data.read().await.get::().cloned().unwrap(); - - let okay = { - let mut lock = macro_buffer.write().await; - - if lock.contains_key(&(guild_id, invoke.author_id())) { - false - } else { - lock.insert( - (guild_id, invoke.author_id()), - CommandMacro { - guild_id, - name, - description: args.get("description").map(|d| d.to_string()), - commands: vec![], - }, - ); - true - } - }; - - if okay { - let _ = invoke - .respond( - &ctx, - CreateGenericResponse::new().ephemeral().embed(|e| { - e - .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") - .color(*THEME_COLOR) - }), - ) - .await; - } else { - let _ = invoke - .respond( - &ctx, - CreateGenericResponse::new().ephemeral().embed(|e| { - e.title("Macro Already Recording") - .description( - "You are already recording a macro in this server. + }) + }) + .await?; + } else { + ctx.send(|m| { + m.ephemeral(true).embed(|e| { + e.title("Macro Already Recording") + .description( + "You are already recording a macro in this server. Please use `/macro finish` to end this recording before starting another.", - ) - .color(*THEME_COLOR) - }), ) - .await; - } - } + .color(*THEME_COLOR) + }) + }) + .await?; } - "finish" => { - let key = (invoke.guild_id().unwrap(), invoke.author_id()); - let macro_buffer = ctx.data.read().await.get::().cloned().unwrap(); - - { - let lock = macro_buffer.read().await; - let contained = lock.get(&key); - - if contained.map_or(true, |cmacro| cmacro.commands.is_empty()) { - let _ = invoke - .respond( - &ctx, - CreateGenericResponse::new().embed(|e| { - e.title("No Macro Recorded") - .description("Use `/macro record` to start recording a macro") - .color(*THEME_COLOR) - }), - ) - .await; - } else { - let command_macro = contained.unwrap(); - let json = serde_json::to_string(&command_macro.commands).unwrap(); - - sqlx::query!( - "INSERT INTO macro (guild_id, name, description, commands) VALUES ((SELECT id FROM guilds WHERE guild = ?), ?, ?, ?)", - command_macro.guild_id.0, - command_macro.name, - command_macro.description, - json - ) - .execute(&pool) - .await - .unwrap(); - - let _ = invoke - .respond( - &ctx, - CreateGenericResponse::new().embed(|e| { - e.title("Macro Recorded") - .description("Use `/macro run` to execute the macro") - .color(*THEME_COLOR) - }), - ) - .await; - } - } - - { - let mut lock = macro_buffer.write().await; - lock.remove(&key); - } - } - "list" => { - let macros = CommandMacro::from_guild(ctx, invoke.guild_id().unwrap()).await; - - let resp = show_macro_page(¯os, 0); - - invoke.respond(&ctx, resp).await.unwrap(); - } - "run" => { - let macro_name = args.get("name").unwrap().to_string(); - - match sqlx::query!( - "SELECT commands FROM macro WHERE guild_id = (SELECT id FROM guilds WHERE guild = ?) AND name = ?", - invoke.guild_id().unwrap().0, - macro_name - ) - .fetch_one(&pool) - .await - { - Ok(row) => { - invoke.defer(&ctx).await; - - let commands: Vec = - serde_json::from_str(&row.commands).unwrap(); - let framework = ctx.data.read().await.get::().cloned().unwrap(); - - for command in commands { - framework.run_command_from_options(ctx, invoke, command).await; - } - } - - Err(sqlx::Error::RowNotFound) => { - let _ = invoke - .respond( - &ctx, - CreateGenericResponse::new() - .content(format!("Macro \"{}\" not found", macro_name)), - ) - .await; - } - - Err(e) => { - panic!("{}", e); - } - } - } - "delete" => { - let macro_name = args.get("name").unwrap().to_string(); - - match sqlx::query!( - "SELECT id FROM macro WHERE guild_id = (SELECT id FROM guilds WHERE guild = ?) AND name = ?", - invoke.guild_id().unwrap().0, - macro_name - ) - .fetch_one(&pool) - .await - { - Ok(row) => { - sqlx::query!("DELETE FROM macro WHERE id = ?", row.id) - .execute(&pool) - .await - .unwrap(); - - let _ = invoke - .respond( - &ctx, - CreateGenericResponse::new() - .content(format!("Macro \"{}\" deleted", macro_name)), - ) - .await; - } - - Err(sqlx::Error::RowNotFound) => { - let _ = invoke - .respond( - &ctx, - CreateGenericResponse::new() - .content(format!("Macro \"{}\" not found", macro_name)), - ) - .await; - } - - Err(e) => { - panic!("{}", e); - } - } - } - _ => {} } + + Ok(()) } -pub fn max_macro_page(macros: &[CommandMacro]) -> usize { +/// Finish current macro recording +#[poise::command( + slash_command, + rename = "finish", + check = "guild_only", + identifying_name = "macro_finish" +)] +pub async fn finish_macro(ctx: Context<'_>) -> Result<(), Error> { + let key = (ctx.guild_id().unwrap(), ctx.author().id); + + { + let lock = ctx.data().recording_macros.read().await; + let contained = lock.get(&key); + + if contained.map_or(true, |cmacro| cmacro.commands.is_empty()) { + ctx.send(|m| { + m.embed(|e| { + e.title("No Macro Recorded") + .description("Use `/macro record` to start recording a macro") + .color(*THEME_COLOR) + }) + }) + .await?; + } else { + let command_macro = contained.unwrap(); + let json = serde_json::to_string(&command_macro.commands).unwrap(); + + sqlx::query!( + "INSERT INTO macro (guild_id, name, description, commands) VALUES ((SELECT id FROM guilds WHERE guild = ?), ?, ?, ?)", + command_macro.guild_id.0, + command_macro.name, + command_macro.description, + json + ) + .execute(&ctx.data().database) + .await + .unwrap(); + + ctx.send(|m| { + m.embed(|e| { + e.title("Macro Recorded") + .description("Use `/macro run` to execute the macro") + .color(*THEME_COLOR) + }) + }) + .await?; + } + } + + { + let mut lock = ctx.data().recording_macros.write().await; + lock.remove(&key); + } + + Ok(()) +} + +/// List recorded macros +#[poise::command(slash_command, rename = "list", check = "guild_only")] +pub async fn list_macro(ctx: Context<'_>) -> Result<(), Error> { + // let macros = CommandMacro::from_guild(&ctx.data().database, ctx.guild_id().unwrap()).await; + let macros: Vec> = vec![]; + + let resp = show_macro_page(¯os, 0); + + ctx.send(|m| { + *m = resp; + m + }) + .await?; + + Ok(()) +} + +/// Run a recorded macro +#[poise::command(slash_command, rename = "run", check = "guild_only")] +pub async fn run_macro( + ctx: Context<'_>, + #[description = "Name of macro to run"] + #[autocomplete = "macro_name_autocomplete"] + name: String, +) -> Result<(), Error> { + match sqlx::query!( + " +SELECT commands FROM macro WHERE guild_id = (SELECT id FROM guilds WHERE guild = ?) AND name = ?", + ctx.guild_id().unwrap().0, + name + ) + .fetch_one(&ctx.data().database) + .await + { + Ok(row) => { + ctx.defer().await?; + + // TODO TODO TODO!!!!!!!! RUN COMMAND FROM MACRO + } + + Err(sqlx::Error::RowNotFound) => { + ctx.say(format!("Macro \"{}\" not found", name)).await?; + } + + Err(e) => { + panic!("{}", e); + } + } + + Ok(()) +} + +/// Delete a recorded macro +#[poise::command(slash_command, rename = "delete", check = "guild_only")] +pub async fn delete_macro( + ctx: Context<'_>, + #[description = "Name of macro to delete"] + #[autocomplete = "macro_name_autocomplete"] + name: String, +) -> Result<(), Error> { + match sqlx::query!( + " +SELECT id FROM macro WHERE guild_id = (SELECT id FROM guilds WHERE guild = ?) AND name = ?", + ctx.guild_id().unwrap().0, + name + ) + .fetch_one(&ctx.data().database) + .await + { + Ok(row) => { + sqlx::query!("DELETE FROM macro WHERE id = ?", row.id) + .execute(&ctx.data().database) + .await + .unwrap(); + + ctx.say(format!("Macro \"{}\" deleted", name)).await?; + } + + Err(sqlx::Error::RowNotFound) => { + ctx.say(format!("Macro \"{}\" not found", name)).await?; + } + + Err(e) => { + panic!("{}", e); + } + } + + Ok(()) +} + +pub fn max_macro_page(macros: &[CommandMacro]) -> usize { let mut skipped_char_count = 0; macros @@ -396,15 +397,30 @@ pub fn max_macro_page(macros: &[CommandMacro]) -> usize { }) } -pub fn show_macro_page(macros: &[CommandMacro], page: usize) -> CreateGenericResponse { +pub fn show_macro_page(macros: &[CommandMacro], page: usize) -> CreateReply { + let mut reply = CreateReply::default(); + + reply.embed(|e| { + e.title("Macros") + .description("No Macros Set Up. Use `/macro record` to get started.") + .color(*THEME_COLOR) + }); + + reply + + /* let pager = MacroPager::new(page); if macros.is_empty() { - return CreateGenericResponse::new().embed(|e| { + let mut reply = CreateReply::default(); + + reply.embed(|e| { e.title("Macros") .description("No Macros Set Up. Use `/macro record` to get started.") .color(*THEME_COLOR) }); + + return reply; } let pages = max_macro_page(macros); @@ -447,7 +463,9 @@ pub fn show_macro_page(macros: &[CommandMacro], page: usize) -> CreateGenericRes let display = display_vec.join("\n"); - CreateGenericResponse::new() + let mut reply = CreateReply::default(); + + reply .embed(|e| { e.title("Macros") .description(display) @@ -458,5 +476,8 @@ pub fn show_macro_page(macros: &[CommandMacro], page: usize) -> CreateGenericRes pager.create_button_row(pages, comp); comp - }) + }); + + reply + */ } diff --git a/src/consts.rs b/src/consts.rs index 3e6de0e..f1c47ba 100644 --- a/src/consts.rs +++ b/src/consts.rs @@ -6,11 +6,12 @@ pub const SELECT_MAX_ENTRIES: usize = 25; pub const CHARACTERS: &str = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_"; const THEME_COLOR_FALLBACK: u32 = 0x8fb677; +pub const MACRO_MAX_COMMANDS: usize = 5; use std::{collections::HashSet, env, iter::FromIterator}; +use poise::serenity::model::prelude::AttachmentType; use regex::Regex; -use serenity::model::prelude::AttachmentType; lazy_static! { pub static ref DEFAULT_AVATAR: AttachmentType<'static> = ( diff --git a/src/event_handlers.rs b/src/event_handlers.rs index 3f42bf4..dfcf1c2 100644 --- a/src/event_handlers.rs +++ b/src/event_handlers.rs @@ -1,161 +1,116 @@ use std::{collections::HashMap, env, sync::atomic::Ordering}; use log::{info, warn}; -use serenity::{ - async_trait, - client::{Context, EventHandler}, - model::{ - channel::GuildChannel, - gateway::{Activity, Ready}, - guild::{Guild, UnavailableGuild}, - id::GuildId, - interactions::Interaction, - }, - utils::shard_id, -}; +use poise::serenity::{client::Context, model::interactions::Interaction, utils::shard_id}; -use crate::{ComponentDataModel, Handler, RegexFramework, ReqwestClient, SQLPool}; +use crate::{Data, Error}; -#[async_trait] -impl EventHandler for Handler { - async fn cache_ready(&self, ctx_base: Context, _guilds: Vec) { - info!("Cache Ready!"); - info!("Preparing to send reminders"); +pub async fn listener(ctx: &Context, event: &poise::Event<'_>, data: &Data) -> Result<(), Error> { + match event { + poise::Event::CacheReady { .. } => { + info!("Cache Ready!"); + info!("Preparing to send reminders"); - if !self.is_loop_running.load(Ordering::Relaxed) { - let ctx1 = ctx_base.clone(); - let ctx2 = ctx_base.clone(); + if !data.is_loop_running.load(Ordering::Relaxed) { + let ctx1 = ctx.clone(); + let ctx2 = ctx.clone(); - let pool1 = ctx1.data.read().await.get::().cloned().unwrap(); - let pool2 = ctx2.data.read().await.get::().cloned().unwrap(); + let pool1 = data.database.clone(); + let pool2 = data.database.clone(); - let run_settings = env::var("DONTRUN").unwrap_or_else(|_| "".to_string()); + let run_settings = env::var("DONTRUN").unwrap_or_else(|_| "".to_string()); - if !run_settings.contains("postman") { - tokio::spawn(async move { - postman::initialize(ctx1, &pool1).await; - }); - } else { - warn!("Not running postman") + if !run_settings.contains("postman") { + tokio::spawn(async move { + postman::initialize(ctx1, &pool1).await; + }); + } else { + warn!("Not running postman") + } + + if !run_settings.contains("web") { + tokio::spawn(async move { + reminder_web::initialize(ctx2, pool2).await.unwrap(); + }); + } else { + warn!("Not running web") + } + + data.is_loop_running.swap(true, Ordering::Relaxed); } - - if !run_settings.contains("web") { - tokio::spawn(async move { - reminder_web::initialize(ctx2, pool2).await.unwrap(); - }); - } else { - warn!("Not running web") - } - - self.is_loop_running.swap(true, Ordering::Relaxed); } - } - - async fn channel_delete(&self, ctx: Context, channel: &GuildChannel) { - let pool = ctx - .data - .read() - .await - .get::() - .cloned() - .expect("Could not get SQLPool from data"); - - sqlx::query!( - " + poise::Event::ChannelDelete { channel } => { + sqlx::query!( + " DELETE FROM channels WHERE channel = ? - ", - channel.id.as_u64() - ) - .execute(&pool) - .await - .unwrap(); - } + ", + channel.id.as_u64() + ) + .execute(&data.database) + .await + .unwrap(); + } + poise::Event::GuildCreate { guild, is_new } => { + if *is_new { + let guild_id = guild.id.as_u64().to_owned(); - async fn guild_create(&self, ctx: Context, guild: Guild, is_new: bool) { - if is_new { - let guild_id = guild.id.as_u64().to_owned(); - - { - let pool = ctx.data.read().await.get::().cloned().unwrap(); - - let _ = sqlx::query!("INSERT INTO guilds (guild) VALUES (?)", guild_id) - .execute(&pool) - .await; - } - - if let Ok(token) = env::var("DISCORDBOTS_TOKEN") { - let shard_count = ctx.cache.shard_count(); - let current_shard_id = shard_id(guild_id, shard_count); - - let guild_count = ctx - .cache - .guilds() - .iter() - .filter(|g| shard_id(g.as_u64().to_owned(), shard_count) == current_shard_id) - .count() as u64; - - let mut hm = HashMap::new(); - hm.insert("server_count", guild_count); - hm.insert("shard_id", current_shard_id); - hm.insert("shard_count", shard_count); - - let client = ctx - .data - .read() + sqlx::query!("INSERT INTO guilds (guild) VALUES (?)", guild_id) + .execute(&data.database) .await - .get::() - .cloned() - .expect("Could not get ReqwestClient from data"); + .unwrap(); - let response = client - .post( - format!( - "https://top.gg/api/bots/{}/stats", - ctx.cache.current_user_id().as_u64() + if let Ok(token) = env::var("DISCORDBOTS_TOKEN") { + let shard_count = ctx.cache.shard_count(); + let current_shard_id = shard_id(guild_id, shard_count); + + let guild_count = ctx + .cache + .guilds() + .iter() + .filter(|g| { + shard_id(g.as_u64().to_owned(), shard_count) == current_shard_id + }) + .count() as u64; + + let mut hm = HashMap::new(); + hm.insert("server_count", guild_count); + hm.insert("shard_id", current_shard_id); + hm.insert("shard_count", shard_count); + + let response = data + .http + .post( + format!( + "https://top.gg/api/bots/{}/stats", + ctx.cache.current_user_id().as_u64() + ) + .as_str(), ) - .as_str(), - ) - .header("Authorization", token) - .json(&hm) - .send() - .await; + .header("Authorization", token) + .json(&hm) + .send() + .await; - if let Err(res) = response { - println!("DiscordBots Response: {:?}", res); + if let Err(res) = response { + println!("DiscordBots Response: {:?}", res); + } } } } - } - - async fn guild_delete(&self, ctx: Context, incomplete: UnavailableGuild, _full: Option) { - let pool = ctx.data.read().await.get::().cloned().unwrap(); - let _ = sqlx::query!("DELETE FROM guilds WHERE guild = ?", incomplete.id.0) - .execute(&pool) - .await; - } - - async fn ready(&self, ctx: Context, _: Ready) { - ctx.set_activity(Activity::watching("for /remind")).await; - } - - async fn interaction_create(&self, ctx: Context, interaction: Interaction) { - match interaction { - Interaction::ApplicationCommand(application_command) => { - let framework = ctx - .data - .read() - .await - .get::() - .cloned() - .expect("RegexFramework not found in context"); - - framework.execute(ctx, application_command).await; - } + poise::Event::GuildDelete { incomplete, full } => { + let _ = sqlx::query!("DELETE FROM guilds WHERE guild = ?", incomplete.id.0) + .execute(&data.database) + .await; + } + poise::Event::InteractionCreate { interaction } => match interaction { Interaction::MessageComponent(component) => { - let component_model = ComponentDataModel::from_custom_id(&component.data.custom_id); - component_model.act(&ctx, component).await; + //let component_model = ComponentDataModel::from_custom_id(&component.data.custom_id); + //component_model.act(&ctx, component).await; } _ => {} - } + }, + _ => {} } + + Ok(()) } diff --git a/src/hooks.rs b/src/hooks.rs index fae9bcf..2b5ccd7 100644 --- a/src/hooks.rs +++ b/src/hooks.rs @@ -1,91 +1,74 @@ -use regex_command_attr::check; -use serenity::{client::Context, model::channel::Channel}; +use poise::{serenity::model::channel::Channel, ApplicationCommandOrAutocompleteInteraction}; -use crate::{ - framework::{CommandInvoke, CommandOptions, CreateGenericResponse, HookResult}, - moderation_cmds, RecordingMacros, -}; +use crate::{consts::MACRO_MAX_COMMANDS, Context, Error}; -#[check] -pub async fn guild_only( - ctx: &Context, - invoke: &mut CommandInvoke, - _args: &CommandOptions, -) -> HookResult { - if invoke.guild_id().is_some() { - HookResult::Continue +pub async fn guild_only(ctx: Context<'_>) -> Result { + if ctx.guild_id().is_some() { + Ok(true) } else { - let _ = invoke - .respond( - &ctx, - CreateGenericResponse::new().content("This command can only be used in servers"), - ) - .await; + let _ = ctx.say("This command can only be used in servers").await; - HookResult::Halt + Ok(false) } } -#[check] -pub async fn macro_check( - ctx: &Context, - invoke: &mut CommandInvoke, - args: &CommandOptions, -) -> HookResult { - if let Some(guild_id) = invoke.guild_id() { - if args.command != moderation_cmds::MACRO_CMD_COMMAND.names[0] { - let active_recordings = - ctx.data.read().await.get::().cloned().unwrap(); - let mut lock = active_recordings.write().await; +async fn macro_check(ctx: Context<'_>) -> bool { + if let Context::Application(app_ctx) = ctx { + if let ApplicationCommandOrAutocompleteInteraction::ApplicationCommand(interaction) = + app_ctx.interaction + { + if let Some(guild_id) = ctx.guild_id() { + if ctx.command().identifying_name != "macro_finish" { + let mut lock = ctx.data().recording_macros.write().await; - if let Some(command_macro) = lock.get_mut(&(guild_id, invoke.author_id())) { - if command_macro.commands.len() >= 5 { - let _ = invoke - .respond( - &ctx, - CreateGenericResponse::new().content("5 commands already recorded. Please use `/macro finish` to end recording."), - ) - .await; + if let Some(command_macro) = lock.get_mut(&(guild_id, ctx.author().id)) { + if command_macro.commands.len() >= MACRO_MAX_COMMANDS { + let _ = ctx.send(|m| { + m.ephemeral(true).content( + "5 commands already recorded. Please use `/macro finish` to end recording.", + ) + }) + .await; + } else { + // TODO TODO TODO write command to macro + + let _ = ctx + .send(|m| m.ephemeral(true).content("Command recorded to macro")) + .await; + } + + false + } else { + true + } } else { - command_macro.commands.push(args.clone()); - - let _ = invoke - .respond( - &ctx, - CreateGenericResponse::new().content("Command recorded to macro"), - ) - .await; + true } - - HookResult::Halt } else { - HookResult::Continue + true } } else { - HookResult::Continue + true } } else { - HookResult::Continue + true } } -#[check] -pub async fn check_self_permissions( - ctx: &Context, - invoke: &mut CommandInvoke, - _args: &CommandOptions, -) -> HookResult { - if let Some(guild) = invoke.guild(&ctx) { - let user_id = ctx.cache.current_user_id(); +async fn check_self_permissions(ctx: Context<'_>) -> bool { + if let Some(guild) = ctx.guild() { + let user_id = ctx.discord().cache.current_user_id(); - let manage_webhooks = - guild.member_permissions(&ctx, user_id).await.map_or(false, |p| p.manage_webhooks()); - let (view_channel, send_messages, embed_links) = invoke + let manage_webhooks = guild + .member_permissions(&ctx.discord(), user_id) + .await + .map_or(false, |p| p.manage_webhooks()); + let (view_channel, send_messages, embed_links) = ctx .channel_id() - .to_channel_cached(&ctx) + .to_channel_cached(&ctx.discord()) .map(|c| { if let Channel::Guild(channel) = c { - channel.permissions_for_user(ctx, user_id).ok() + channel.permissions_for_user(&ctx.discord(), user_id).ok() } else { None } @@ -96,12 +79,11 @@ pub async fn check_self_permissions( }); if manage_webhooks && send_messages && embed_links { - HookResult::Continue + true } else { - let _ = invoke - .respond( - &ctx, - CreateGenericResponse::new().content(format!( + let _ = ctx + .send(|m| { + m.content(format!( "Please ensure the bot has the correct permissions: {} **View Channel** @@ -112,41 +94,17 @@ pub async fn check_self_permissions( if send_messages { "✅" } else { "❌" }, if manage_webhooks { "✅" } else { "❌" }, if embed_links { "✅" } else { "❌" }, - )), - ) + )) + }) .await; - HookResult::Halt + false } } else { - HookResult::Continue + true } } -#[check] -pub async fn check_guild_permissions( - ctx: &Context, - invoke: &mut CommandInvoke, - _args: &CommandOptions, -) -> HookResult { - if let Some(guild) = invoke.guild(&ctx) { - let permissions = guild.member_permissions(&ctx, invoke.author_id()).await.unwrap(); - - if !permissions.manage_guild() { - let _ = invoke - .respond( - &ctx, - CreateGenericResponse::new().content( - "You must have the \"Manage Server\" permission to use this command", - ), - ) - .await; - - HookResult::Halt - } else { - HookResult::Continue - } - } else { - HookResult::Continue - } +pub async fn all_checks(ctx: Context<'_>) -> Result { + Ok(macro_check(ctx).await && check_self_permissions(ctx).await) } diff --git a/src/interval_parser.rs b/src/interval_parser.rs index 1d6b933..4ac8ef0 100644 --- a/src/interval_parser.rs +++ b/src/interval_parser.rs @@ -1,5 +1,9 @@ /* -Copyright 2021 Paul Colomiets, 2022 Jude Southworth +With modifications, 2022 Jude Southworth + +Original copyright notice: + +Copyright 2021 Paul Colomiets Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, diff --git a/src/main.rs b/src/main.rs index 1912073..89dc26b 100644 --- a/src/main.rs +++ b/src/main.rs @@ -3,70 +3,45 @@ extern crate lazy_static; mod commands; -mod component_models; +// mod component_models; mod consts; mod event_handlers; -mod framework; mod hooks; mod interval_parser; mod models; mod time_parser; mod utils; -use std::{ - collections::HashMap, - env, - sync::{atomic::AtomicBool, Arc}, -}; +use std::{collections::HashMap, env, sync::atomic::AtomicBool}; use chrono_tz::Tz; use dotenv::dotenv; -use log::info; -use serenity::{ - client::Client, - http::client::Http, - model::{ - gateway::GatewayIntents, - id::{GuildId, UserId}, - }, - prelude::TypeMapKey, +use poise::serenity::model::{ + gateway::{Activity, GatewayIntents}, + id::{GuildId, UserId}, }; -use sqlx::mysql::MySqlPool; +use sqlx::{MySql, Pool}; use tokio::sync::RwLock; use crate::{ - commands::{info_cmds, moderation_cmds, reminder_cmds, todo_cmds}, - component_models::ComponentDataModel, + commands::{info_cmds, moderation_cmds}, consts::THEME_COLOR, - framework::RegexFramework, + event_handlers::listener, + hooks::all_checks, models::command_macro::CommandMacro, + utils::register_application_commands, }; -struct SQLPool; +type Database = MySql; -impl TypeMapKey for SQLPool { - type Value = MySqlPool; -} +type Error = Box; +type Context<'a> = poise::Context<'a, Data, Error>; -struct ReqwestClient; - -impl TypeMapKey for ReqwestClient { - type Value = Arc; -} - -struct PopularTimezones; - -impl TypeMapKey for PopularTimezones { - type Value = Arc>; -} - -struct RecordingMacros; - -impl TypeMapKey for RecordingMacros { - type Value = Arc>>; -} - -struct Handler { +pub struct Data { + database: Pool, + http: reqwest::Client, + recording_macros: RwLock>>, + popular_timezones: Vec, is_loop_running: AtomicBool, } @@ -76,85 +51,77 @@ async fn main() -> Result<(), Box> { dotenv()?; - let token = env::var("DISCORD_TOKEN").expect("Missing DISCORD_TOKEN from environment"); + let discord_token = env::var("DISCORD_TOKEN").expect("Missing DISCORD_TOKEN from environment"); - let application_id = { - let http = Http::new_with_token(&token); - - http.get_current_application_info().await?.id + let options = poise::FrameworkOptions { + commands: vec![ + info_cmds::help(), + info_cmds::info(), + info_cmds::donate(), + info_cmds::clock(), + info_cmds::dashboard(), + moderation_cmds::timezone(), + poise::Command { + subcommands: vec![ + moderation_cmds::delete_macro(), + moderation_cmds::finish_macro(), + moderation_cmds::list_macro(), + moderation_cmds::record_macro(), + moderation_cmds::run_macro(), + ], + ..moderation_cmds::macro_base() + }, + ], + allowed_mentions: None, + command_check: Some(|ctx| Box::pin(all_checks(ctx))), + listener: |ctx, event, _framework, data| Box::pin(listener(ctx, event, data)), + ..Default::default() }; - let dm_enabled = env::var("DM_ENABLED").map_or(true, |var| var == "1"); + let database = + Pool::connect(&env::var("DATABASE_URL").expect("No database URL provided")).await.unwrap(); - let framework = RegexFramework::new() - .ignore_bots(env::var("IGNORE_BOTS").map_or(true, |var| var == "1")) - .debug_guild(env::var("DEBUG_GUILD").map_or(None, |g| { - Some(GuildId(g.parse::().expect("DEBUG_GUILD must be a guild ID"))) - })) - .dm_enabled(dm_enabled) - // info commands - .add_command(&info_cmds::HELP_COMMAND) - .add_command(&info_cmds::INFO_COMMAND) - .add_command(&info_cmds::DONATE_COMMAND) - .add_command(&info_cmds::DASHBOARD_COMMAND) - .add_command(&info_cmds::CLOCK_COMMAND) - // reminder commands - .add_command(&reminder_cmds::TIMER_COMMAND) - .add_command(&reminder_cmds::REMIND_COMMAND) - // management commands - .add_command(&reminder_cmds::DELETE_COMMAND) - .add_command(&reminder_cmds::LOOK_COMMAND) - .add_command(&reminder_cmds::PAUSE_COMMAND) - .add_command(&reminder_cmds::OFFSET_COMMAND) - .add_command(&reminder_cmds::NUDGE_COMMAND) - // to-do commands - .add_command(&todo_cmds::TODO_COMMAND) - // moderation commands - .add_command(&moderation_cmds::TIMEZONE_COMMAND) - .add_command(&moderation_cmds::MACRO_CMD_COMMAND) - .add_hook(&hooks::CHECK_SELF_PERMISSIONS_HOOK) - .add_hook(&hooks::MACRO_CHECK_HOOK); + let popular_timezones = sqlx::query!( + " +SELECT timezone FROM users GROUP BY timezone ORDER BY COUNT(timezone) DESC LIMIT 21 + " + ) + .fetch_all(&database) + .await + .unwrap() + .iter() + .map(|t| t.timezone.parse::().unwrap()) + .collect::>(); - let framework_arc = Arc::new(framework); + poise::Framework::build() + .token(discord_token) + .user_data_setup(move |ctx, _bot, framework| { + Box::pin(async move { + ctx.set_activity(Activity::watching("for /remind")).await; - let mut client = Client::builder(&token) - .intents(GatewayIntents::GUILDS) - .application_id(application_id.0) - .event_handler(Handler { is_loop_running: AtomicBool::from(false) }) - .await - .expect("Error occurred creating client"); + register_application_commands( + ctx, + framework, + env::var("DEBUG_GUILD") + .map(|inner| GuildId(inner.parse().expect("DEBUG_GUILD not valid"))) + .ok(), + ) + .await + .unwrap(); - { - let pool = MySqlPool::connect( - &env::var("DATABASE_URL").expect("Missing DATABASE_URL from environment"), - ) - .await - .unwrap(); - - let popular_timezones = sqlx::query!( - "SELECT timezone FROM users GROUP BY timezone ORDER BY COUNT(timezone) DESC LIMIT 21" - ) - .fetch_all(&pool) - .await - .unwrap() - .iter() - .map(|t| t.timezone.parse::().unwrap()) - .collect::>(); - - let mut data = client.data.write().await; - - data.insert::(pool); - data.insert::(Arc::new(popular_timezones)); - data.insert::(Arc::new(reqwest::Client::new())); - data.insert::(framework_arc.clone()); - data.insert::(Arc::new(RwLock::new(HashMap::new()))); - } - - framework_arc.build_slash(&client.cache_and_http.http).await; - - info!("Starting client as autosharded"); - - client.start_autosharded().await?; + Ok(Data { + http: reqwest::Client::new(), + database, + popular_timezones, + recording_macros: Default::default(), + is_loop_running: AtomicBool::new(false), + }) + }) + }) + .options(options) + .client_settings(move |client_builder| client_builder.intents(GatewayIntents::GUILDS)) + .run_autosharded() + .await?; Ok(()) } diff --git a/src/models/channel_data.rs b/src/models/channel_data.rs index 114ca9d..a4d7337 100644 --- a/src/models/channel_data.rs +++ b/src/models/channel_data.rs @@ -1,5 +1,5 @@ use chrono::NaiveDateTime; -use serenity::model::channel::Channel; +use poise::serenity::model::channel::Channel; use sqlx::MySqlPool; pub struct ChannelData { diff --git a/src/models/command_macro.rs b/src/models/command_macro.rs index 40be85e..6ad655e 100644 --- a/src/models/command_macro.rs +++ b/src/models/command_macro.rs @@ -1,33 +1,25 @@ -use serenity::{client::Context, model::id::GuildId}; +use poise::serenity::{ + client::Context, + model::{ + id::GuildId, interactions::application_command::ApplicationCommandInteractionDataOption, + }, +}; +use serde::Serialize; -use crate::{framework::CommandOptions, SQLPool}; +#[derive(Serialize)] +pub struct RecordedCommand { + #[serde(skip)] + action: for<'a> fn( + poise::ApplicationContext<'a, U, E>, + &'a [ApplicationCommandInteractionDataOption], + ) -> poise::BoxFuture<'a, Result<(), poise::FrameworkError<'a, U, E>>>, + command_name: String, + options: Vec, +} -pub struct CommandMacro { +pub struct CommandMacro { pub guild_id: GuildId, pub name: String, pub description: Option, - pub commands: Vec, -} - -impl CommandMacro { - pub async fn from_guild(ctx: &Context, guild_id: impl Into) -> Vec { - let pool = ctx.data.read().await.get::().cloned().unwrap(); - let guild_id = guild_id.into(); - - sqlx::query!( - "SELECT * FROM macro WHERE guild_id = (SELECT id FROM guilds WHERE guild = ?)", - guild_id.0 - ) - .fetch_all(&pool) - .await - .unwrap() - .iter() - .map(|row| Self { - guild_id, - name: row.name.clone(), - description: row.description.clone(), - commands: serde_json::from_str(&row.commands).unwrap(), - }) - .collect::>() - } + pub commands: Vec>, } diff --git a/src/models/mod.rs b/src/models/mod.rs index cf509c6..cd33af0 100644 --- a/src/models/mod.rs +++ b/src/models/mod.rs @@ -5,62 +5,47 @@ pub mod timer; pub mod user_data; use chrono_tz::Tz; -use serenity::{ - async_trait, - model::id::{ChannelId, UserId}, - prelude::Context, -}; +use poise::serenity::{async_trait, model::id::UserId}; use crate::{ models::{channel_data::ChannelData, user_data::UserData}, - SQLPool, + Context, }; #[async_trait] pub trait CtxData { - async fn user_data + Send + Sync>( + async fn user_data + Send>( &self, user_id: U, ) -> Result>; - async fn timezone + Send + Sync>(&self, user_id: U) -> Tz; + async fn author_data(&self) -> Result>; - async fn channel_data + Send + Sync>( - &self, - channel_id: C, - ) -> Result>; + async fn timezone(&self) -> Tz; + + async fn channel_data(&self) -> Result>; } #[async_trait] -impl CtxData for Context { - async fn user_data + Send + Sync>( +impl CtxData for Context<'_> { + async fn user_data + Send>( &self, user_id: U, ) -> Result> { - let user_id = user_id.into(); - let pool = self.data.read().await.get::().cloned().unwrap(); - - let user = user_id.to_user(self).await.unwrap(); - - UserData::from_user(&user, &self, &pool).await + UserData::from_user(user_id, &self.discord(), &self.data().database).await } - async fn timezone + Send + Sync>(&self, user_id: U) -> Tz { - let user_id = user_id.into(); - let pool = self.data.read().await.get::().cloned().unwrap(); - - UserData::timezone_of(user_id, &pool).await + async fn author_data(&self) -> Result> { + UserData::from_user(&self.author().id, &self.discord(), &self.data().database).await } - async fn channel_data + Send + Sync>( - &self, - channel_id: C, - ) -> Result> { - let channel_id = channel_id.into(); - let pool = self.data.read().await.get::().cloned().unwrap(); + async fn timezone(&self) -> Tz { + UserData::timezone_of(self.author().id, &self.data().database).await + } - let channel = channel_id.to_channel_cached(&self).unwrap(); + async fn channel_data(&self) -> Result> { + let channel = self.channel_id().to_channel_cached(&self.discord()).unwrap(); - ChannelData::from_channel(&channel, &pool).await + ChannelData::from_channel(&channel, &self.data().database).await } } diff --git a/src/models/reminder/builder.rs b/src/models/reminder/builder.rs index 1a2b5d9..de53b81 100644 --- a/src/models/reminder/builder.rs +++ b/src/models/reminder/builder.rs @@ -2,8 +2,7 @@ use std::{collections::HashSet, fmt::Display}; use chrono::{Duration, NaiveDateTime, Utc}; use chrono_tz::Tz; -use serenity::{ - client::Context, +use poise::serenity::{ http::CacheHttp, model::{ channel::GuildChannel, @@ -15,15 +14,14 @@ use serenity::{ use sqlx::MySqlPool; use crate::{ - consts, - consts::{DAY, MAX_TIME, MIN_INTERVAL}, + consts::{DAY, DEFAULT_AVATAR, MAX_TIME, MIN_INTERVAL}, interval_parser::Interval, models::{ channel_data::ChannelData, reminder::{content::Content, errors::ReminderError, helper::generate_uid, Reminder}, user_data::UserData, }, - SQLPool, + Context, }; async fn create_webhook( @@ -31,7 +29,7 @@ async fn create_webhook( channel: GuildChannel, name: impl Display, ) -> SerenityResult { - channel.create_webhook_with_avatar(ctx.http(), name, consts::DEFAULT_AVATAR.clone()).await + channel.create_webhook_with_avatar(ctx.http(), name, DEFAULT_AVATAR.clone()).await } #[derive(Hash, PartialEq, Eq)] @@ -145,7 +143,7 @@ pub struct MultiReminderBuilder<'a> { expires: Option, content: Content, set_by: Option, - ctx: &'a Context, + ctx: &'a Context<'a>, guild_id: Option, } @@ -210,8 +208,6 @@ impl<'a> MultiReminderBuilder<'a> { } pub async fn build(self) -> (HashSet, HashSet) { - let pool = self.ctx.data.read().await.get::().cloned().unwrap(); - let mut errors = HashSet::new(); let mut ok_locs = HashSet::new(); @@ -225,12 +221,17 @@ impl<'a> MultiReminderBuilder<'a> { for scope in self.scopes { let db_channel_id = match scope { ReminderScope::User(user_id) => { - if let Ok(user) = UserId(user_id).to_user(&self.ctx).await { - let user_data = - UserData::from_user(&user, &self.ctx, &pool).await.unwrap(); + if let Ok(user) = UserId(user_id).to_user(&self.ctx.discord()).await { + let user_data = UserData::from_user( + &user, + &self.ctx.discord(), + &self.ctx.data().database, + ) + .await + .unwrap(); if let Some(guild_id) = self.guild_id { - if guild_id.member(&self.ctx, user).await.is_err() { + if guild_id.member(&self.ctx.discord(), user).await.is_err() { Err(ReminderError::InvalidTag) } else { Ok(user_data.dm_channel) @@ -243,26 +244,36 @@ impl<'a> MultiReminderBuilder<'a> { } } ReminderScope::Channel(channel_id) => { - let channel = ChannelId(channel_id).to_channel(&self.ctx).await.unwrap(); + let channel = + ChannelId(channel_id).to_channel(&self.ctx.discord()).await.unwrap(); if let Some(guild_channel) = channel.clone().guild() { if Some(guild_channel.guild_id) != self.guild_id { Err(ReminderError::InvalidTag) } else { let mut channel_data = - ChannelData::from_channel(&channel, &pool).await.unwrap(); + ChannelData::from_channel(&channel, &self.ctx.data().database) + .await + .unwrap(); if channel_data.webhook_id.is_none() || channel_data.webhook_token.is_none() { - match create_webhook(&self.ctx, guild_channel, "Reminder").await + match create_webhook( + &self.ctx.discord(), + guild_channel, + "Reminder", + ) + .await { Ok(webhook) => { channel_data.webhook_id = Some(webhook.id.as_u64().to_owned()); channel_data.webhook_token = webhook.token; - channel_data.commit_changes(&pool).await; + channel_data + .commit_changes(&self.ctx.data().database) + .await; Ok(channel_data.id) } @@ -282,7 +293,7 @@ impl<'a> MultiReminderBuilder<'a> { match db_channel_id { Ok(c) => { let builder = ReminderBuilder { - pool: pool.clone(), + pool: self.ctx.data().database.clone(), uid: generate_uid(), channel: c, utc_time: self.utc_time, diff --git a/src/models/reminder/look_flags.rs b/src/models/reminder/look_flags.rs index 5568f85..a2a87f0 100644 --- a/src/models/reminder/look_flags.rs +++ b/src/models/reminder/look_flags.rs @@ -1,6 +1,6 @@ +use poise::serenity::model::id::ChannelId; use serde::{Deserialize, Serialize}; use serde_repr::*; -use serenity::model::id::ChannelId; #[derive(Serialize_repr, Deserialize_repr, Copy, Clone, Debug)] #[repr(u8)] diff --git a/src/models/reminder/mod.rs b/src/models/reminder/mod.rs index 5b856fe..6b46a74 100644 --- a/src/models/reminder/mod.rs +++ b/src/models/reminder/mod.rs @@ -6,15 +6,12 @@ pub mod look_flags; use chrono::{NaiveDateTime, TimeZone}; use chrono_tz::Tz; -use serenity::{ - client::Context, - model::id::{ChannelId, GuildId, UserId}, -}; -use sqlx::MySqlPool; +use poise::serenity::model::id::{ChannelId, GuildId, UserId}; +use sqlx::Executor; use crate::{ models::reminder::look_flags::{LookFlags, TimeDisplayType}, - SQLPool, + Context, Data, Database, }; #[derive(Debug, Clone)] @@ -33,7 +30,10 @@ pub struct Reminder { } impl Reminder { - pub async fn from_uid(pool: &MySqlPool, uid: String) -> Option { + pub async fn from_uid( + pool: impl Executor<'_, Database = Database>, + uid: String, + ) -> Option { sqlx::query_as_unchecked!( Self, " @@ -70,12 +70,10 @@ WHERE } pub async fn from_channel>( - ctx: &Context, + ctx: &Context<'_>, channel_id: C, flags: &LookFlags, ) -> Vec { - let pool = ctx.data.read().await.get::().cloned().unwrap(); - let enabled = if flags.show_disabled { "0,1" } else { "1" }; let channel_id = channel_id.into(); @@ -113,16 +111,18 @@ ORDER BY channel_id.as_u64(), enabled, ) - .fetch_all(&pool) + .fetch_all(&ctx.data().database) .await .unwrap() } - pub async fn from_guild(ctx: &Context, guild_id: Option, user: UserId) -> Vec { - let pool = ctx.data.read().await.get::().cloned().unwrap(); - + pub async fn from_guild( + ctx: &Context<'_>, + guild_id: Option, + user: UserId, + ) -> Vec { if let Some(guild_id) = guild_id { - let guild_opt = guild_id.to_guild_cached(&ctx); + let guild_opt = guild_id.to_guild_cached(&ctx.discord()); if let Some(guild) = guild_opt { let channels = guild @@ -163,7 +163,7 @@ WHERE ", channels ) - .fetch_all(&pool) + .fetch_all(&ctx.data().database) .await } else { sqlx::query_as_unchecked!( @@ -196,7 +196,7 @@ WHERE ", guild_id.as_u64() ) - .fetch_all(&pool) + .fetch_all(&ctx.data().database) .await } } else { @@ -230,7 +230,7 @@ WHERE ", user.as_u64() ) - .fetch_all(&pool) + .fetch_all(&ctx.data().database) .await } .unwrap() diff --git a/src/models/user_data.rs b/src/models/user_data.rs index 76d730c..8a9f188 100644 --- a/src/models/user_data.rs +++ b/src/models/user_data.rs @@ -1,9 +1,6 @@ use chrono_tz::Tz; use log::error; -use serenity::{ - http::CacheHttp, - model::{id::UserId, user::User}, -}; +use poise::serenity::{http::CacheHttp, model::id::UserId}; use sqlx::MySqlPool; use crate::consts::LOCAL_TIMEZONE; @@ -11,7 +8,6 @@ use crate::consts::LOCAL_TIMEZONE; pub struct UserData { pub id: u32, pub user: u64, - pub name: String, pub dm_channel: u32, pub timezone: String, } @@ -40,20 +36,20 @@ SELECT timezone FROM users WHERE user = ? .unwrap() } - pub async fn from_user( - user: &User, + pub async fn from_user>( + user: U, ctx: impl CacheHttp, pool: &MySqlPool, ) -> Result> { - let user_id = user.id.as_u64().to_owned(); + let user_id = user.into(); match sqlx::query_as_unchecked!( Self, " -SELECT id, user, name, dm_channel, IF(timezone IS NULL, ?, timezone) AS timezone FROM users WHERE user = ? +SELECT id, user, dm_channel, IF(timezone IS NULL, ?, timezone) AS timezone FROM users WHERE user = ? ", *LOCAL_TIMEZONE, - user_id + user_id.0 ) .fetch_one(pool) .await @@ -61,27 +57,24 @@ SELECT id, user, name, dm_channel, IF(timezone IS NULL, ?, timezone) AS timezone Ok(c) => Ok(c), Err(sqlx::Error::RowNotFound) => { - let dm_channel = user.create_dm_channel(ctx).await?; - let dm_id = dm_channel.id.as_u64().to_owned(); - + let dm_channel = user_id.create_dm_channel(ctx).await?; let pool_c = pool.clone(); sqlx::query!( " INSERT IGNORE INTO channels (channel) VALUES (?) ", - dm_id + dm_channel.id.0 ) .execute(&pool_c) .await?; sqlx::query!( " -INSERT INTO users (user, name, dm_channel, timezone) VALUES (?, ?, (SELECT id FROM channels WHERE channel = ?), ?) +INSERT INTO users (user, dm_channel, timezone) VALUES (?, (SELECT id FROM channels WHERE channel = ?), ?) ", - user_id, - user.name, - dm_id, + user_id.0, + dm_channel.id.0, *LOCAL_TIMEZONE ) .execute(&pool_c) @@ -90,9 +83,9 @@ INSERT INTO users (user, name, dm_channel, timezone) VALUES (?, ?, (SELECT id FR Ok(sqlx::query_as_unchecked!( Self, " -SELECT id, user, name, dm_channel, timezone FROM users WHERE user = ? +SELECT id, user, dm_channel, timezone FROM users WHERE user = ? ", - user_id + user_id.0 ) .fetch_one(pool) .await?) @@ -109,9 +102,8 @@ SELECT id, user, name, dm_channel, timezone FROM users WHERE user = ? pub async fn commit_changes(&self, pool: &MySqlPool) { sqlx::query!( " -UPDATE users SET name = ?, timezone = ? WHERE id = ? +UPDATE users SET timezone = ? WHERE id = ? ", - self.name, self.timezone, self.id ) diff --git a/src/utils.rs b/src/utils.rs index aa37258..e11f47c 100644 --- a/src/utils.rs +++ b/src/utils.rs @@ -1,9 +1,39 @@ -use serenity::{ +use poise::serenity::{ + builder::CreateApplicationCommands, http::CacheHttp, model::id::{GuildId, UserId}, }; -use crate::consts::{CNC_GUILD, SUBSCRIPTION_ROLES}; +use crate::{ + consts::{CNC_GUILD, SUBSCRIPTION_ROLES}, + Data, Error, +}; + +pub async fn register_application_commands( + ctx: &poise::serenity::client::Context, + framework: &poise::Framework, + guild_id: Option, +) -> Result<(), poise::serenity::Error> { + let mut commands_builder = CreateApplicationCommands::default(); + let commands = &framework.options().commands; + for command in commands { + if let Some(slash_command) = command.create_as_slash_command() { + commands_builder.add_application_command(slash_command); + } + if let Some(context_menu_command) = command.create_as_context_menu_command() { + commands_builder.add_application_command(context_menu_command); + } + } + let commands_builder = poise::serenity::json::Value::Array(commands_builder.0); + + if let Some(guild_id) = guild_id { + ctx.http.create_guild_application_commands(guild_id.0, &commands_builder).await?; + } else { + ctx.http.create_global_application_commands(&commands_builder).await?; + } + + Ok(()) +} pub async fn check_subscription(cache_http: impl CacheHttp, user_id: impl Into) -> bool { if let Some(subscription_guild) = *CNC_GUILD { From afc376c44fd0c1b6d7c8967200330c4de1abb8d7 Mon Sep 17 00:00:00 2001 From: jude Date: Sat, 19 Feb 2022 18:21:11 +0000 Subject: [PATCH 02/27] everything except component model actions --- Cargo.lock | 20 - Cargo.toml | 3 - command_attributes/Cargo.toml | 16 - command_attributes/src/attributes.rs | 351 ------------- command_attributes/src/consts.rs | 10 - command_attributes/src/lib.rs | 321 ------------ command_attributes/src/structures.rs | 331 ------------ command_attributes/src/util.rs | 176 ------- src/commands/mod.rs | 4 +- src/commands/moderation_cmds.rs | 58 +-- src/commands/reminder_cmds.rs | 751 +++++++++++---------------- src/commands/todo_cmds.rs | 300 ++++++----- src/component_models/mod.rs | 113 ++-- src/component_models/pager.rs | 4 +- src/consts.rs | 2 + src/event_handlers.rs | 31 +- src/framework.rs | 692 ------------------------ src/hooks.rs | 12 +- src/main.rs | 41 +- src/models/command_macro.rs | 74 ++- src/models/mod.rs | 31 +- 21 files changed, 718 insertions(+), 2623 deletions(-) delete mode 100644 command_attributes/Cargo.toml delete mode 100644 command_attributes/src/attributes.rs delete mode 100644 command_attributes/src/consts.rs delete mode 100644 command_attributes/src/lib.rs delete mode 100644 command_attributes/src/structures.rs delete mode 100644 command_attributes/src/util.rs delete mode 100644 src/framework.rs diff --git a/Cargo.lock b/Cargo.lock index 62d8d9a..aa5f7e1 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2100,16 +2100,6 @@ version = "0.6.25" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f497285884f3fcff424ffc933e56d7cbca511def0c9831a7f9b5f6153e3cc89b" -[[package]] -name = "regex_command_attr" -version = "0.3.6" -dependencies = [ - "proc-macro2", - "quote", - "syn", - "uuid", -] - [[package]] name = "reminder_rs" version = "1.6.0-beta3" @@ -2127,7 +2117,6 @@ dependencies = [ "postman", "rand 0.7.3", "regex", - "regex_command_attr", "reminder_web", "reqwest", "rmp-serde", @@ -3340,15 +3329,6 @@ version = "0.7.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9" -[[package]] -name = "uuid" -version = "0.8.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bc5cf98d8186244414c848017f0e2676b3fcb46807f6668a97dfe67359a3c4b7" -dependencies = [ - "getrandom 0.2.4", -] - [[package]] name = "valuable" version = "0.1.0" diff --git a/Cargo.toml b/Cargo.toml index 23f13b0..15969dc 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -25,9 +25,6 @@ levenshtein = "1.0" sqlx = { version = "0.5", features = ["runtime-tokio-rustls", "macros", "mysql", "bigdecimal", "chrono"]} base64 = "0.13.0" -[dependencies.regex_command_attr] -path = "command_attributes" - [dependencies.postman] path = "postman" diff --git a/command_attributes/Cargo.toml b/command_attributes/Cargo.toml deleted file mode 100644 index a4ce1d3..0000000 --- a/command_attributes/Cargo.toml +++ /dev/null @@ -1,16 +0,0 @@ -[package] -name = "regex_command_attr" -version = "0.3.6" -authors = ["acdenisSK ", "jellywx "] -edition = "2018" -description = "Procedural macros for command creation for the Serenity library." -license = "ISC" - -[lib] -proc-macro = true - -[dependencies] -quote = "^1.0" -syn = { version = "^1.0", features = ["full", "derive", "extra-traits"] } -proc-macro2 = "1.0" -uuid = { version = "0.8", features = ["v4"] } diff --git a/command_attributes/src/attributes.rs b/command_attributes/src/attributes.rs deleted file mode 100644 index 1293186..0000000 --- a/command_attributes/src/attributes.rs +++ /dev/null @@ -1,351 +0,0 @@ -use std::fmt::{self, Write}; - -use proc_macro2::Span; -use syn::{ - parse::{Error, Result}, - spanned::Spanned, - Attribute, Ident, Lit, LitStr, Meta, NestedMeta, Path, -}; - -use crate::{ - structures::{ApplicationCommandOptionType, Arg}, - util::{AsOption, LitExt}, -}; - -#[derive(Debug, Clone, Copy, PartialEq)] -pub enum ValueKind { - // #[] - Name, - - // #[ = ] - Equals, - - // #[([, , , ...])] - List, - - // #[([ = , = , ...])] - EqualsList, - - // #[()] - SingleList, -} - -impl fmt::Display for ValueKind { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - match self { - ValueKind::Name => f.pad("`#[]`"), - ValueKind::Equals => f.pad("`#[ = ]`"), - ValueKind::List => f.pad("`#[([, , , ...])]`"), - ValueKind::EqualsList => { - f.pad("`#[([ = , = , ...])]`") - } - ValueKind::SingleList => f.pad("`#[()]`"), - } - } -} - -fn to_ident(p: Path) -> Result { - if p.segments.is_empty() { - return Err(Error::new(p.span(), "cannot convert an empty path to an identifier")); - } - - if p.segments.len() > 1 { - return Err(Error::new(p.span(), "the path must not have more than one segment")); - } - - if !p.segments[0].arguments.is_empty() { - return Err(Error::new(p.span(), "the singular path segment must not have any arguments")); - } - - Ok(p.segments[0].ident.clone()) -} - -#[derive(Debug)] -pub struct Values { - pub name: Ident, - pub literals: Vec<(Option, Lit)>, - pub kind: ValueKind, - pub span: Span, -} - -impl Values { - #[inline] - pub fn new( - name: Ident, - kind: ValueKind, - literals: Vec<(Option, Lit)>, - span: Span, - ) -> Self { - Values { name, literals, kind, span } - } -} - -pub fn parse_values(attr: &Attribute) -> Result { - fn is_list_or_named_list(meta: &NestedMeta) -> ValueKind { - match meta { - // catch if the nested value is a literal value - NestedMeta::Lit(_) => ValueKind::List, - // catch if the nested value is a meta value - NestedMeta::Meta(m) => match m { - // path => some quoted value - Meta::Path(_) => ValueKind::List, - Meta::List(_) | Meta::NameValue(_) => ValueKind::EqualsList, - }, - } - } - - let meta = attr.parse_meta()?; - - match meta { - Meta::Path(path) => { - let name = to_ident(path)?; - - Ok(Values::new(name, ValueKind::Name, Vec::new(), attr.span())) - } - Meta::List(meta) => { - let name = to_ident(meta.path)?; - let nested = meta.nested; - - if nested.is_empty() { - return Err(Error::new(attr.span(), "list cannot be empty")); - } - - if is_list_or_named_list(nested.first().unwrap()) == ValueKind::List { - let mut lits = Vec::with_capacity(nested.len()); - - for meta in nested { - match meta { - // catch if the nested value is a literal value - NestedMeta::Lit(l) => lits.push((None, l)), - // catch if the nested value is a meta value - NestedMeta::Meta(m) => match m { - // path => some quoted value - Meta::Path(path) => { - let i = to_ident(path)?; - lits.push((None, Lit::Str(LitStr::new(&i.to_string(), i.span())))) - } - Meta::List(_) | Meta::NameValue(_) => { - return Err(Error::new(attr.span(), "cannot nest a list; only accept literals and identifiers at this level")) - } - }, - } - } - - let kind = if lits.len() == 1 { ValueKind::SingleList } else { ValueKind::List }; - - Ok(Values::new(name, kind, lits, attr.span())) - } else { - let mut lits = Vec::with_capacity(nested.len()); - - for meta in nested { - match meta { - // catch if the nested value is a literal value - NestedMeta::Lit(_) => { - return Err(Error::new(attr.span(), "key-value pairs expected")) - } - // catch if the nested value is a meta value - NestedMeta::Meta(m) => match m { - Meta::NameValue(n) => { - let name = to_ident(n.path)?.to_string(); - let value = n.lit; - - lits.push((Some(name), value)); - } - Meta::List(_) | Meta::Path(_) => { - return Err(Error::new(attr.span(), "key-value pairs expected")) - } - }, - } - } - - Ok(Values::new(name, ValueKind::EqualsList, lits, attr.span())) - } - } - Meta::NameValue(meta) => { - let name = to_ident(meta.path)?; - let lit = meta.lit; - - Ok(Values::new(name, ValueKind::Equals, vec![(None, lit)], attr.span())) - } - } -} - -#[derive(Debug, Clone)] -struct DisplaySlice<'a, T>(&'a [T]); - -impl<'a, T: fmt::Display> fmt::Display for DisplaySlice<'a, T> { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - let mut iter = self.0.iter().enumerate(); - - match iter.next() { - None => f.write_str("nothing")?, - Some((idx, elem)) => { - write!(f, "{}: {}", idx, elem)?; - - for (idx, elem) in iter { - f.write_char('\n')?; - write!(f, "{}: {}", idx, elem)?; - } - } - } - - Ok(()) - } -} - -#[inline] -fn is_form_acceptable(expect: &[ValueKind], kind: ValueKind) -> bool { - if expect.contains(&ValueKind::List) && kind == ValueKind::SingleList { - true - } else { - expect.contains(&kind) - } -} - -#[inline] -fn validate(values: &Values, forms: &[ValueKind]) -> Result<()> { - if !is_form_acceptable(forms, values.kind) { - return Err(Error::new( - values.span, - // Using the `_args` version here to avoid an allocation. - format_args!("the attribute must be in of these forms:\n{}", DisplaySlice(forms)), - )); - } - - Ok(()) -} - -#[inline] -pub fn parse(values: Values) -> Result { - T::parse(values) -} - -pub trait AttributeOption: Sized { - fn parse(values: Values) -> Result; -} - -impl AttributeOption for Vec { - fn parse(values: Values) -> Result { - validate(&values, &[ValueKind::List])?; - - Ok(values.literals.into_iter().map(|(_, l)| l.to_str()).collect()) - } -} - -impl AttributeOption for String { - #[inline] - fn parse(values: Values) -> Result { - validate(&values, &[ValueKind::Equals, ValueKind::SingleList])?; - - Ok(values.literals[0].1.to_str()) - } -} - -impl AttributeOption for bool { - #[inline] - fn parse(values: Values) -> Result { - validate(&values, &[ValueKind::Name, ValueKind::SingleList])?; - - Ok(values.literals.get(0).map_or(true, |(_, l)| l.to_bool())) - } -} - -impl AttributeOption for Ident { - #[inline] - fn parse(values: Values) -> Result { - validate(&values, &[ValueKind::SingleList])?; - - Ok(values.literals[0].1.to_ident()) - } -} - -impl AttributeOption for Vec { - #[inline] - fn parse(values: Values) -> Result { - validate(&values, &[ValueKind::List])?; - - Ok(values.literals.into_iter().map(|(_, l)| l.to_ident()).collect()) - } -} - -impl AttributeOption for Option { - fn parse(values: Values) -> Result { - validate(&values, &[ValueKind::Name, ValueKind::Equals, ValueKind::SingleList])?; - - Ok(values.literals.get(0).map(|(_, l)| l.to_str())) - } -} - -impl AttributeOption for Arg { - fn parse(values: Values) -> Result { - validate(&values, &[ValueKind::EqualsList])?; - - let mut arg: Arg = Default::default(); - - for (key, value) in &values.literals { - match key { - Some(s) => match s.as_str() { - "name" => { - arg.name = value.to_str(); - } - "description" => { - arg.description = value.to_str(); - } - "required" => { - arg.required = value.to_bool(); - } - "kind" => arg.kind = ApplicationCommandOptionType::from_str(value.to_str()), - _ => { - return Err(Error::new(key.span(), "unexpected attribute")); - } - }, - _ => { - return Err(Error::new(key.span(), "unnamed attribute")); - } - } - } - - Ok(arg) - } -} - -impl AttributeOption for AsOption { - #[inline] - fn parse(values: Values) -> Result { - Ok(AsOption(Some(T::parse(values)?))) - } -} - -macro_rules! attr_option_num { - ($($n:ty),*) => { - $( - impl AttributeOption for $n { - fn parse(values: Values) -> Result { - validate(&values, &[ValueKind::SingleList])?; - - Ok(match &values.literals[0].1 { - Lit::Int(l) => l.base10_parse::<$n>()?, - l => { - let s = l.to_str(); - // Use `as_str` to guide the compiler to use `&str`'s parse method. - // We don't want to use our `parse` method here (`impl AttributeOption for String`). - match s.as_str().parse::<$n>() { - Ok(n) => n, - Err(_) => return Err(Error::new(l.span(), "invalid integer")), - } - } - }) - } - } - - impl AttributeOption for Option<$n> { - #[inline] - fn parse(values: Values) -> Result { - <$n as AttributeOption>::parse(values).map(Some) - } - } - )* - } -} - -attr_option_num!(u16, u32, usize); diff --git a/command_attributes/src/consts.rs b/command_attributes/src/consts.rs deleted file mode 100644 index 8c334b4..0000000 --- a/command_attributes/src/consts.rs +++ /dev/null @@ -1,10 +0,0 @@ -pub mod suffixes { - pub const COMMAND: &str = "COMMAND"; - pub const ARG: &str = "ARG"; - pub const SUBCOMMAND: &str = "SUBCOMMAND"; - pub const SUBCOMMAND_GROUP: &str = "GROUP"; - pub const CHECK: &str = "CHECK"; - pub const HOOK: &str = "HOOK"; -} - -pub use self::suffixes::*; diff --git a/command_attributes/src/lib.rs b/command_attributes/src/lib.rs deleted file mode 100644 index cce792e..0000000 --- a/command_attributes/src/lib.rs +++ /dev/null @@ -1,321 +0,0 @@ -#![deny(rust_2018_idioms)] -#![deny(broken_intra_doc_links)] - -use proc_macro::TokenStream; -use proc_macro2::Ident; -use quote::quote; -use syn::{parse::Error, parse_macro_input, parse_quote, spanned::Spanned, Lit, Type}; -use uuid::Uuid; - -pub(crate) mod attributes; -pub(crate) mod consts; -pub(crate) mod structures; - -#[macro_use] -pub(crate) mod util; - -use attributes::*; -use consts::*; -use structures::*; -use util::*; - -macro_rules! match_options { - ($v:expr, $values:ident, $options:ident, $span:expr => [$($name:ident);*]) => { - match $v { - $( - stringify!($name) => $options.$name = propagate_err!($crate::attributes::parse($values)), - )* - _ => { - return Error::new($span, format_args!("invalid attribute: {:?}", $v)) - .to_compile_error() - .into(); - }, - } - }; -} - -#[proc_macro_attribute] -pub fn command(attr: TokenStream, input: TokenStream) -> TokenStream { - enum LastItem { - Fun, - SubFun, - SubGroup, - SubGroupFun, - } - - let mut fun = parse_macro_input!(input as CommandFun); - - let _name = if !attr.is_empty() { - parse_macro_input!(attr as Lit).to_str() - } else { - fun.name.to_string() - }; - - let mut hooks: Vec = Vec::new(); - let mut options = Options::new(); - let mut last_desc = LastItem::Fun; - - for attribute in &fun.attributes { - let span = attribute.span(); - let values = propagate_err!(parse_values(attribute)); - - let name = values.name.to_string(); - let name = &name[..]; - - match name { - "subcommand" => { - let new_subcommand = Subcommand::new(propagate_err!(attributes::parse(values))); - - if let Some(subcommand_group) = options.subcommand_groups.last_mut() { - last_desc = LastItem::SubGroupFun; - subcommand_group.subcommands.push(new_subcommand); - } else { - last_desc = LastItem::SubFun; - options.subcommands.push(new_subcommand); - } - } - "subcommandgroup" => { - let new_group = SubcommandGroup::new(propagate_err!(attributes::parse(values))); - last_desc = LastItem::SubGroup; - - options.subcommand_groups.push(new_group); - } - "arg" => { - let arg = propagate_err!(attributes::parse(values)); - - match last_desc { - LastItem::Fun => { - options.cmd_args.push(arg); - } - LastItem::SubFun => { - options.subcommands.last_mut().unwrap().cmd_args.push(arg); - } - LastItem::SubGroup => { - panic!("Argument not expected under subcommand group"); - } - LastItem::SubGroupFun => { - options - .subcommand_groups - .last_mut() - .unwrap() - .subcommands - .last_mut() - .unwrap() - .cmd_args - .push(arg); - } - } - } - "example" => { - options.examples.push(propagate_err!(attributes::parse(values))); - } - "description" => { - let line: String = propagate_err!(attributes::parse(values)); - - match last_desc { - LastItem::Fun => { - util::append_line(&mut options.description, line); - } - LastItem::SubFun => { - util::append_line( - &mut options.subcommands.last_mut().unwrap().description, - line, - ); - } - LastItem::SubGroup => { - util::append_line( - &mut options.subcommand_groups.last_mut().unwrap().description, - line, - ); - } - LastItem::SubGroupFun => { - util::append_line( - &mut options - .subcommand_groups - .last_mut() - .unwrap() - .subcommands - .last_mut() - .unwrap() - .description, - line, - ); - } - } - } - "hook" => { - hooks.push(propagate_err!(attributes::parse(values))); - } - _ => { - match_options!(name, values, options, span => [ - aliases; - group; - can_blacklist; - supports_dm - ]); - } - } - } - - let Options { - aliases, - description, - group, - examples, - can_blacklist, - supports_dm, - mut cmd_args, - mut subcommands, - mut subcommand_groups, - } = options; - - let visibility = fun.visibility; - let name = fun.name.clone(); - let body = fun.body; - - let root_ident = name.with_suffix(COMMAND); - - let command_path = quote!(crate::framework::Command); - - populate_fut_lifetimes_on_refs(&mut fun.args); - - let mut subcommand_group_idents = subcommand_groups - .iter() - .map(|subcommand| { - root_ident - .with_suffix(subcommand.name.replace("-", "_").as_str()) - .with_suffix(SUBCOMMAND_GROUP) - }) - .collect::>(); - - let mut subcommand_idents = subcommands - .iter() - .map(|subcommand| { - root_ident - .with_suffix(subcommand.name.replace("-", "_").as_str()) - .with_suffix(SUBCOMMAND) - }) - .collect::>(); - - let mut arg_idents = cmd_args - .iter() - .map(|arg| root_ident.with_suffix(arg.name.replace("-", "_").as_str()).with_suffix(ARG)) - .collect::>(); - - let mut tokens = quote! {}; - - tokens.extend( - subcommand_groups - .iter_mut() - .zip(subcommand_group_idents.iter()) - .map(|(group, group_ident)| group.as_tokens(group_ident)) - .fold(quote! {}, |mut a, b| { - a.extend(b); - a - }), - ); - - tokens.extend( - subcommands - .iter_mut() - .zip(subcommand_idents.iter()) - .map(|(subcommand, sc_ident)| subcommand.as_tokens(sc_ident)) - .fold(quote! {}, |mut a, b| { - a.extend(b); - a - }), - ); - - tokens.extend( - cmd_args.iter_mut().zip(arg_idents.iter()).map(|(arg, ident)| arg.as_tokens(ident)).fold( - quote! {}, - |mut a, b| { - a.extend(b); - a - }, - ), - ); - - arg_idents.append(&mut subcommand_group_idents); - arg_idents.append(&mut subcommand_idents); - - let args = fun.args; - - let variant = if args.len() == 2 { - quote!(crate::framework::CommandFnType::Multi) - } else { - let string: Type = parse_quote!(String); - - let final_arg = args.get(2).unwrap(); - - if final_arg.kind == string { - quote!(crate::framework::CommandFnType::Text) - } else { - quote!(crate::framework::CommandFnType::Slash) - } - }; - - tokens.extend(quote! { - #[allow(missing_docs)] - pub static #root_ident: #command_path = #command_path { - fun: #variant(#name), - names: &[#_name, #(#aliases),*], - desc: #description, - group: #group, - examples: &[#(#examples),*], - can_blacklist: #can_blacklist, - supports_dm: #supports_dm, - args: &[#(&#arg_idents),*], - hooks: &[#(&#hooks),*], - }; - - #[allow(missing_docs)] - #visibility fn #name<'fut> (#(#args),*) -> ::serenity::futures::future::BoxFuture<'fut, ()> { - use ::serenity::futures::future::FutureExt; - - async move { - #(#body)*; - }.boxed() - } - }); - - tokens.into() -} - -#[proc_macro_attribute] -pub fn check(_attr: TokenStream, input: TokenStream) -> TokenStream { - let mut fun = parse_macro_input!(input as CommandFun); - - let n = fun.name.clone(); - let name = n.with_suffix(HOOK); - let fn_name = n.with_suffix(CHECK); - let visibility = fun.visibility; - - let body = fun.body; - let ret = fun.ret; - populate_fut_lifetimes_on_refs(&mut fun.args); - let args = fun.args; - - let hook_path = quote!(crate::framework::Hook); - let uuid = Uuid::new_v4().as_u128(); - - (quote! { - #[allow(missing_docs)] - #visibility fn #fn_name<'fut>(#(#args),*) -> ::serenity::futures::future::BoxFuture<'fut, #ret> { - use ::serenity::futures::future::FutureExt; - - async move { - let _output: #ret = { #(#body)* }; - #[allow(unreachable_code)] - _output - }.boxed() - } - - #[allow(missing_docs)] - pub static #name: #hook_path = #hook_path { - fun: #fn_name, - uuid: #uuid, - }; - }) - .into() -} diff --git a/command_attributes/src/structures.rs b/command_attributes/src/structures.rs deleted file mode 100644 index f77ce46..0000000 --- a/command_attributes/src/structures.rs +++ /dev/null @@ -1,331 +0,0 @@ -use proc_macro2::TokenStream as TokenStream2; -use quote::{quote, ToTokens}; -use syn::{ - braced, - parse::{Error, Parse, ParseStream, Result}, - spanned::Spanned, - Attribute, Block, FnArg, Ident, Pat, ReturnType, Stmt, Token, Type, Visibility, -}; - -use crate::{ - consts::{ARG, SUBCOMMAND}, - util::{Argument, IdentExt2, Parenthesised}, -}; - -fn parse_argument(arg: FnArg) -> Result { - match arg { - FnArg::Typed(typed) => { - let pat = typed.pat; - let kind = typed.ty; - - match *pat { - Pat::Ident(id) => { - let name = id.ident; - let mutable = id.mutability; - - Ok(Argument { mutable, name, kind: *kind }) - } - Pat::Wild(wild) => { - let token = wild.underscore_token; - - let name = Ident::new("_", token.spans[0]); - - Ok(Argument { mutable: None, name, kind: *kind }) - } - _ => Err(Error::new(pat.span(), format_args!("unsupported pattern: {:?}", pat))), - } - } - FnArg::Receiver(_) => { - Err(Error::new(arg.span(), format_args!("`self` arguments are prohibited: {:?}", arg))) - } - } -} - -#[derive(Debug)] -pub struct CommandFun { - /// `#[...]`-style attributes. - pub attributes: Vec, - /// Populated cooked attributes. These are attributes outside of the realm of this crate's procedural macros - /// and will appear in generated output. - pub visibility: Visibility, - pub name: Ident, - pub args: Vec, - pub ret: Type, - pub body: Vec, -} - -impl Parse for CommandFun { - fn parse(input: ParseStream<'_>) -> Result { - let attributes = input.call(Attribute::parse_outer)?; - - let visibility = input.parse::()?; - - input.parse::()?; - - input.parse::()?; - let name = input.parse()?; - - // (...) - let Parenthesised(args) = input.parse::>()?; - - let ret = match input.parse::()? { - ReturnType::Type(_, t) => (*t).clone(), - ReturnType::Default => Type::Verbatim(quote!(())), - }; - - // { ... } - let bcont; - braced!(bcont in input); - let body = bcont.call(Block::parse_within)?; - - let args = args.into_iter().map(parse_argument).collect::>>()?; - - Ok(Self { attributes, visibility, name, args, ret, body }) - } -} - -impl ToTokens for CommandFun { - fn to_tokens(&self, stream: &mut TokenStream2) { - let Self { attributes: _, visibility, name, args, ret, body } = self; - - stream.extend(quote! { - #visibility async fn #name (#(#args),*) -> #ret { - #(#body)* - } - }); - } -} - -#[derive(Debug)] -pub(crate) enum ApplicationCommandOptionType { - SubCommand, - SubCommandGroup, - String, - Integer, - Boolean, - User, - Channel, - Role, - Mentionable, - Number, - Unknown, -} - -impl ApplicationCommandOptionType { - pub fn from_str(s: String) -> Self { - match s.as_str() { - "SubCommand" => Self::SubCommand, - "SubCommandGroup" => Self::SubCommandGroup, - "String" => Self::String, - "Integer" => Self::Integer, - "Boolean" => Self::Boolean, - "User" => Self::User, - "Channel" => Self::Channel, - "Role" => Self::Role, - "Mentionable" => Self::Mentionable, - "Number" => Self::Number, - _ => Self::Unknown, - } - } -} - -impl ToTokens for ApplicationCommandOptionType { - fn to_tokens(&self, stream: &mut TokenStream2) { - let path = quote!( - serenity::model::interactions::application_command::ApplicationCommandOptionType - ); - let variant = match self { - ApplicationCommandOptionType::SubCommand => quote!(SubCommand), - ApplicationCommandOptionType::SubCommandGroup => quote!(SubCommandGroup), - ApplicationCommandOptionType::String => quote!(String), - ApplicationCommandOptionType::Integer => quote!(Integer), - ApplicationCommandOptionType::Boolean => quote!(Boolean), - ApplicationCommandOptionType::User => quote!(User), - ApplicationCommandOptionType::Channel => quote!(Channel), - ApplicationCommandOptionType::Role => quote!(Role), - ApplicationCommandOptionType::Mentionable => quote!(Mentionable), - ApplicationCommandOptionType::Number => quote!(Number), - ApplicationCommandOptionType::Unknown => quote!(Unknown), - }; - - stream.extend(quote! { - #path::#variant - }); - } -} - -#[derive(Debug)] -pub(crate) struct Arg { - pub name: String, - pub description: String, - pub kind: ApplicationCommandOptionType, - pub required: bool, -} - -impl Arg { - pub fn as_tokens(&self, ident: &Ident) -> TokenStream2 { - let arg_path = quote!(crate::framework::Arg); - let Arg { name, description, kind, required } = self; - - quote! { - #[allow(missing_docs)] - pub static #ident: #arg_path = #arg_path { - name: #name, - description: #description, - kind: #kind, - required: #required, - options: &[] - }; - } - } -} - -impl Default for Arg { - fn default() -> Self { - Self { - name: String::new(), - description: String::new(), - kind: ApplicationCommandOptionType::String, - required: false, - } - } -} - -#[derive(Debug)] -pub(crate) struct Subcommand { - pub name: String, - pub description: String, - pub cmd_args: Vec, -} - -impl Subcommand { - pub fn as_tokens(&mut self, ident: &Ident) -> TokenStream2 { - let arg_path = quote!(crate::framework::Arg); - let subcommand_path = ApplicationCommandOptionType::SubCommand; - - let arg_idents = self - .cmd_args - .iter() - .map(|arg| ident.with_suffix(arg.name.as_str()).with_suffix(ARG)) - .collect::>(); - - let mut tokens = self - .cmd_args - .iter_mut() - .zip(arg_idents.iter()) - .map(|(arg, ident)| arg.as_tokens(ident)) - .fold(quote! {}, |mut a, b| { - a.extend(b); - a - }); - - let Subcommand { name, description, .. } = self; - - tokens.extend(quote! { - #[allow(missing_docs)] - pub static #ident: #arg_path = #arg_path { - name: #name, - description: #description, - kind: #subcommand_path, - required: false, - options: &[#(&#arg_idents),*], - }; - }); - - tokens - } -} - -impl Default for Subcommand { - fn default() -> Self { - Self { name: String::new(), description: String::new(), cmd_args: vec![] } - } -} - -impl Subcommand { - pub(crate) fn new(name: String) -> Self { - Self { name, ..Default::default() } - } -} - -#[derive(Debug)] -pub(crate) struct SubcommandGroup { - pub name: String, - pub description: String, - pub subcommands: Vec, -} - -impl SubcommandGroup { - pub fn as_tokens(&mut self, ident: &Ident) -> TokenStream2 { - let arg_path = quote!(crate::framework::Arg); - let subcommand_group_path = ApplicationCommandOptionType::SubCommandGroup; - - let arg_idents = self - .subcommands - .iter() - .map(|arg| { - ident - .with_suffix(self.name.as_str()) - .with_suffix(arg.name.as_str()) - .with_suffix(SUBCOMMAND) - }) - .collect::>(); - - let mut tokens = self - .subcommands - .iter_mut() - .zip(arg_idents.iter()) - .map(|(subcommand, ident)| subcommand.as_tokens(ident)) - .fold(quote! {}, |mut a, b| { - a.extend(b); - a - }); - - let SubcommandGroup { name, description, .. } = self; - - tokens.extend(quote! { - #[allow(missing_docs)] - pub static #ident: #arg_path = #arg_path { - name: #name, - description: #description, - kind: #subcommand_group_path, - required: false, - options: &[#(&#arg_idents),*], - }; - }); - - tokens - } -} - -impl Default for SubcommandGroup { - fn default() -> Self { - Self { name: String::new(), description: String::new(), subcommands: vec![] } - } -} - -impl SubcommandGroup { - pub(crate) fn new(name: String) -> Self { - Self { name, ..Default::default() } - } -} - -#[derive(Debug, Default)] -pub(crate) struct Options { - pub aliases: Vec, - pub description: String, - pub group: String, - pub examples: Vec, - pub can_blacklist: bool, - pub supports_dm: bool, - pub cmd_args: Vec, - pub subcommands: Vec, - pub subcommand_groups: Vec, -} - -impl Options { - #[inline] - pub fn new() -> Self { - Self { group: "None".to_string(), ..Default::default() } - } -} diff --git a/command_attributes/src/util.rs b/command_attributes/src/util.rs deleted file mode 100644 index 0c01e73..0000000 --- a/command_attributes/src/util.rs +++ /dev/null @@ -1,176 +0,0 @@ -use proc_macro::TokenStream; -use proc_macro2::{Span, TokenStream as TokenStream2}; -use quote::{format_ident, quote, ToTokens}; -use syn::{ - braced, bracketed, parenthesized, - parse::{Error, Parse, ParseStream, Result as SynResult}, - punctuated::Punctuated, - token::{Comma, Mut}, - Ident, Lifetime, Lit, Type, -}; - -pub trait LitExt { - fn to_str(&self) -> String; - fn to_bool(&self) -> bool; - fn to_ident(&self) -> Ident; -} - -impl LitExt for Lit { - fn to_str(&self) -> String { - match self { - Lit::Str(s) => s.value(), - Lit::ByteStr(s) => unsafe { String::from_utf8_unchecked(s.value()) }, - Lit::Char(c) => c.value().to_string(), - Lit::Byte(b) => (b.value() as char).to_string(), - _ => panic!("values must be a (byte)string or a char"), - } - } - - fn to_bool(&self) -> bool { - if let Lit::Bool(b) = self { - b.value - } else { - self.to_str() - .parse() - .unwrap_or_else(|_| panic!("expected bool from {:?}", self)) - } - } - - #[inline] - fn to_ident(&self) -> Ident { - Ident::new(&self.to_str(), self.span()) - } -} - -pub trait IdentExt2: Sized { - fn to_uppercase(&self) -> Self; - fn with_suffix(&self, suf: &str) -> Ident; -} - -impl IdentExt2 for Ident { - #[inline] - fn to_uppercase(&self) -> Self { - format_ident!("{}", self.to_string().to_uppercase()) - } - - #[inline] - fn with_suffix(&self, suffix: &str) -> Ident { - format_ident!("{}_{}", self.to_string().to_uppercase(), suffix) - } -} - -#[inline] -pub fn into_stream(e: Error) -> TokenStream { - e.to_compile_error().into() -} - -macro_rules! propagate_err { - ($res:expr) => {{ - match $res { - Ok(v) => v, - Err(e) => return $crate::util::into_stream(e), - } - }}; -} - -#[derive(Debug)] -pub struct Bracketed(pub Punctuated); - -impl Parse for Bracketed { - fn parse(input: ParseStream<'_>) -> SynResult { - let content; - bracketed!(content in input); - - Ok(Bracketed(content.parse_terminated(T::parse)?)) - } -} - -#[derive(Debug)] -pub struct Braced(pub Punctuated); - -impl Parse for Braced { - fn parse(input: ParseStream<'_>) -> SynResult { - let content; - braced!(content in input); - - Ok(Braced(content.parse_terminated(T::parse)?)) - } -} - -#[derive(Debug)] -pub struct Parenthesised(pub Punctuated); - -impl Parse for Parenthesised { - fn parse(input: ParseStream<'_>) -> SynResult { - let content; - parenthesized!(content in input); - - Ok(Parenthesised(content.parse_terminated(T::parse)?)) - } -} - -#[derive(Debug)] -pub struct AsOption(pub Option); - -impl ToTokens for AsOption { - fn to_tokens(&self, stream: &mut TokenStream2) { - match &self.0 { - Some(o) => stream.extend(quote!(Some(#o))), - None => stream.extend(quote!(None)), - } - } -} - -impl Default for AsOption { - #[inline] - fn default() -> Self { - AsOption(None) - } -} - -#[derive(Debug)] -pub struct Argument { - pub mutable: Option, - pub name: Ident, - pub kind: Type, -} - -impl ToTokens for Argument { - fn to_tokens(&self, stream: &mut TokenStream2) { - let Argument { - mutable, - name, - kind, - } = self; - - stream.extend(quote! { - #mutable #name: #kind - }); - } -} - -#[inline] -pub fn populate_fut_lifetimes_on_refs(args: &mut Vec) { - for arg in args { - if let Type::Reference(reference) = &mut arg.kind { - reference.lifetime = Some(Lifetime::new("'fut", Span::call_site())); - } - } -} - -pub fn append_line(desc: &mut String, mut line: String) { - if line.starts_with(' ') { - line.remove(0); - } - - match line.rfind("\\$") { - Some(i) => { - desc.push_str(line[..i].trim_end()); - desc.push(' '); - } - None => { - desc.push_str(&line); - desc.push('\n'); - } - } -} diff --git a/src/commands/mod.rs b/src/commands/mod.rs index 8ad997e..e53c6f3 100644 --- a/src/commands/mod.rs +++ b/src/commands/mod.rs @@ -1,4 +1,4 @@ pub mod info_cmds; pub mod moderation_cmds; -//pub mod reminder_cmds; -//pub mod todo_cmds; +pub mod reminder_cmds; +pub mod todo_cmds; diff --git a/src/commands/moderation_cmds.rs b/src/commands/moderation_cmds.rs index b554cc4..e1b6939 100644 --- a/src/commands/moderation_cmds.rs +++ b/src/commands/moderation_cmds.rs @@ -4,9 +4,13 @@ use levenshtein::levenshtein; use poise::CreateReply; use crate::{ + component_models::pager::{MacroPager, Pager}, consts::{EMBED_DESCRIPTION_MAX_LENGTH, THEME_COLOR}, hooks::guild_only, - models::{command_macro::CommandMacro, CtxData}, + models::{ + command_macro::{guild_command_macro, CommandMacro}, + CtxData, + }, Context, Data, Error, }; @@ -286,8 +290,7 @@ pub async fn finish_macro(ctx: Context<'_>) -> Result<(), Error> { /// List recorded macros #[poise::command(slash_command, rename = "list", check = "guild_only")] pub async fn list_macro(ctx: Context<'_>) -> Result<(), Error> { - // let macros = CommandMacro::from_guild(&ctx.data().database, ctx.guild_id().unwrap()).await; - let macros: Vec> = vec![]; + let macros = ctx.command_macros().await?; let resp = show_macro_page(¯os, 0); @@ -303,32 +306,31 @@ pub async fn list_macro(ctx: Context<'_>) -> Result<(), Error> { /// Run a recorded macro #[poise::command(slash_command, rename = "run", check = "guild_only")] pub async fn run_macro( - ctx: Context<'_>, + ctx: poise::ApplicationContext<'_, Data, Error>, #[description = "Name of macro to run"] #[autocomplete = "macro_name_autocomplete"] name: String, ) -> Result<(), Error> { - match sqlx::query!( - " -SELECT commands FROM macro WHERE guild_id = (SELECT id FROM guilds WHERE guild = ?) AND name = ?", - ctx.guild_id().unwrap().0, - name - ) - .fetch_one(&ctx.data().database) - .await - { - Ok(row) => { - ctx.defer().await?; + match guild_command_macro(&Context::Application(ctx), &name).await { + Some(command_macro) => { + ctx.defer_response(false).await?; - // TODO TODO TODO!!!!!!!! RUN COMMAND FROM MACRO + for command in command_macro.commands { + if let Some(action) = command.action { + (action)(poise::ApplicationContext { args: &command.options, ..ctx }) + .await + .ok() + .unwrap(); + } else { + Context::Application(ctx) + .say(format!("Command \"{}\" failed to execute", command.command_name)) + .await?; + } + } } - Err(sqlx::Error::RowNotFound) => { - ctx.say(format!("Macro \"{}\" not found", name)).await?; - } - - Err(e) => { - panic!("{}", e); + None => { + Context::Application(ctx).say(format!("Macro \"{}\" not found", name)).await?; } } @@ -398,17 +400,6 @@ pub fn max_macro_page(macros: &[CommandMacro]) -> usize { } pub fn show_macro_page(macros: &[CommandMacro], page: usize) -> CreateReply { - let mut reply = CreateReply::default(); - - reply.embed(|e| { - e.title("Macros") - .description("No Macros Set Up. Use `/macro record` to get started.") - .color(*THEME_COLOR) - }); - - reply - - /* let pager = MacroPager::new(page); if macros.is_empty() { @@ -479,5 +470,4 @@ pub fn show_macro_page(macros: &[CommandMacro], page: usize) -> Crea }); reply - */ } diff --git a/src/commands/reminder_cmds.rs b/src/commands/reminder_cmds.rs index 8696c41..aca114d 100644 --- a/src/commands/reminder_cmds.rs +++ b/src/commands/reminder_cmds.rs @@ -7,17 +7,21 @@ use std::{ use chrono::NaiveDateTime; use chrono_tz::Tz; use num_integer::Integer; -use regex_command_attr::command; -use serenity::{builder::CreateEmbed, client::Context, model::channel::Channel}; +use poise::{ + serenity::{builder::CreateEmbed, model::channel::Channel}, + serenity_prelude::ActionRole::Create, + CreateReply, +}; use crate::{ component_models::{ pager::{DelPager, LookPager, Pager}, ComponentDataModel, DelSelector, }, - consts::{EMBED_DESCRIPTION_MAX_LENGTH, REGEX_CHANNEL_USER, SELECT_MAX_ENTRIES, THEME_COLOR}, - framework::{CommandInvoke, CommandOptions, CreateGenericResponse, OptionValue}, - hooks::CHECK_GUILD_PERMISSIONS_HOOK, + consts::{ + EMBED_DESCRIPTION_MAX_LENGTH, HOUR, MINUTE, REGEX_CHANNEL_USER, SELECT_MAX_ENTRIES, + THEME_COLOR, + }, interval_parser::parse_duration, models::{ reminder::{ @@ -33,29 +37,22 @@ use crate::{ }, time_parser::natural_parser, utils::{check_guild_subscription, check_subscription}, - SQLPool, + Context, Error, }; -#[command("pause")] -#[description("Pause all reminders on the current channel until a certain time or indefinitely")] -#[arg( - name = "until", - description = "When to pause until (hint: try 'next Wednesday', or '10 minutes')", - kind = "String", - required = false -)] -#[supports_dm(false)] -#[hook(CHECK_GUILD_PERMISSIONS_HOOK)] -async fn pause(ctx: &Context, invoke: &mut CommandInvoke, args: CommandOptions) { - let pool = ctx.data.read().await.get::().cloned().unwrap(); +/// Pause all reminders on the current channel until a certain time or indefinitely +#[poise::command(slash_command)] +pub async fn pause( + ctx: Context<'_>, + #[description = "When to pause until"] until: Option, +) -> Result<(), Error> { + let timezone = ctx.timezone().await; - let timezone = UserData::timezone_of(&invoke.author_id(), &pool).await; + let mut channel = ctx.channel_data().await.unwrap(); - let mut channel = ctx.channel_data(invoke.channel_id()).await.unwrap(); - - match args.get("until") { - Some(OptionValue::String(until)) => { - let parsed = natural_parser(until, &timezone.to_string()).await; + match until { + Some(until) => { + let parsed = natural_parser(&until, &timezone.to_string()).await; if let Some(timestamp) = parsed { let dt = NaiveDateTime::from_timestamp(timestamp, 0); @@ -63,92 +60,53 @@ async fn pause(ctx: &Context, invoke: &mut CommandInvoke, args: CommandOptions) channel.paused = true; channel.paused_until = Some(dt); - channel.commit_changes(&pool).await; + channel.commit_changes(&ctx.data().database).await; - let _ = invoke - .respond( - ctx.http.clone(), - CreateGenericResponse::new().content(format!( - "Reminders in this channel have been silenced until ****", - timestamp - )), - ) - .await; + ctx.say(format!( + "Reminders in this channel have been silenced until ****", + timestamp + )) + .await?; } else { - let _ = invoke - .respond( - ctx.http.clone(), - CreateGenericResponse::new() - .content("Time could not be processed. Please write the time as clearly as possible"), - ) - .await; + ctx.say( + "Time could not be processed. Please write the time as clearly as possible", + ) + .await?; } } _ => { channel.paused = !channel.paused; channel.paused_until = None; - channel.commit_changes(&pool).await; + channel.commit_changes(&ctx.data().database).await; if channel.paused { - let _ = invoke - .respond( - ctx.http.clone(), - CreateGenericResponse::new() - .content("Reminders in this channel have been silenced indefinitely"), - ) - .await; + ctx.say("Reminders in this channel have been silenced indefinitely").await?; } else { - let _ = invoke - .respond( - ctx.http.clone(), - CreateGenericResponse::new() - .content("Reminders in this channel have been unsilenced"), - ) - .await; + ctx.say("Reminders in this channel have been unsilenced").await?; } } } + + Ok(()) } -#[command("offset")] -#[description("Move all reminders in the current server by a certain amount of time. Times get added together")] -#[arg( - name = "hours", - description = "Number of hours to offset by", - kind = "Integer", - required = false -)] -#[arg( - name = "minutes", - description = "Number of minutes to offset by", - kind = "Integer", - required = false -)] -#[arg( - name = "seconds", - description = "Number of seconds to offset by", - kind = "Integer", - required = false -)] -#[hook(CHECK_GUILD_PERMISSIONS_HOOK)] -async fn offset(ctx: &Context, invoke: &mut CommandInvoke, args: CommandOptions) { - let pool = ctx.data.read().await.get::().cloned().unwrap(); - - let combined_time = args.get("hours").map_or(0, |h| h.as_i64().unwrap() * 3600) - + args.get("minutes").map_or(0, |m| m.as_i64().unwrap() * 60) - + args.get("seconds").map_or(0, |s| s.as_i64().unwrap()); +/// Move all reminders in the current server by a certain amount of time. Times get added together +#[poise::command(slash_command)] +pub async fn offset( + ctx: Context<'_>, + #[description = "Number of hours to offset by"] hours: Option, + #[description = "Number of minutes to offset by"] minutes: Option, + #[description = "Number of seconds to offset by"] seconds: Option, +) -> Result<(), Error> { + let combined_time = hours.map_or(0, |h| h * HOUR as isize) + + minutes.map_or(0, |m| m * MINUTE as isize) + + seconds.map_or(0, |s| s); if combined_time == 0 { - let _ = invoke - .respond( - ctx.http.clone(), - CreateGenericResponse::new() - .content("Please specify one of `hours`, `minutes` or `seconds`"), - ) - .await; + ctx.say("Please specify one of `hours`, `minutes` or `seconds`").await?; } else { - if let Some(guild) = invoke.guild(ctx.cache.clone()) { + if let Some(guild) = ctx.guild() { let channels = guild .channels .iter() @@ -167,110 +125,67 @@ INNER JOIN `channels` ON `channels`.id = reminders.channel_id SET reminders.`utc_time` = DATE_ADD(reminders.`utc_time`, INTERVAL ? SECOND) WHERE FIND_IN_SET(channels.`channel`, ?)", - combined_time, + combined_time as i64, channels ) - .execute(&pool) + .execute(&ctx.data().database) .await .unwrap(); } else { sqlx::query!( "UPDATE reminders INNER JOIN `channels` ON `channels`.id = reminders.channel_id SET reminders.`utc_time` = reminders.`utc_time` + ? WHERE channels.`channel` = ?", - combined_time, - invoke.channel_id().0 + combined_time as i64, + ctx.channel_id().0 ) - .execute(&pool) + .execute(&ctx.data().database) .await .unwrap(); } - let _ = invoke - .respond( - ctx.http.clone(), - CreateGenericResponse::new() - .content(format!("All reminders offset by {} seconds", combined_time)), - ) - .await; + ctx.say(format!("All reminders offset by {} seconds", combined_time)).await?; } + + Ok(()) } -#[command("nudge")] -#[description("Nudge all future reminders on this channel by a certain amount (don't use for DST! See `/offset`)")] -#[arg( - name = "minutes", - description = "Number of minutes to nudge new reminders by", - kind = "Integer", - required = false -)] -#[arg( - name = "seconds", - description = "Number of seconds to nudge new reminders by", - kind = "Integer", - required = false -)] -#[hook(CHECK_GUILD_PERMISSIONS_HOOK)] -async fn nudge(ctx: &Context, invoke: &mut CommandInvoke, args: CommandOptions) { - let pool = ctx.data.read().await.get::().cloned().unwrap(); +/// Nudge all future reminders on this channel by a certain amount (don't use for DST! See `/offset`) +#[poise::command(slash_command)] +pub async fn nudge( + ctx: Context<'_>, + #[description = "Number of minutes to nudge new reminders by"] minutes: Option, + #[description = "Number of seconds to nudge new reminders by"] seconds: Option, +) -> Result<(), Error> { + let combined_time = minutes.map_or(0, |m| m * MINUTE as isize) + seconds.map_or(0, |s| s); - let combined_time = args.get("minutes").map_or(0, |m| m.as_i64().unwrap() * 60) - + args.get("seconds").map_or(0, |s| s.as_i64().unwrap()); - - if combined_time < i16::MIN as i64 || combined_time > i16::MAX as i64 { - let _ = invoke - .respond( - ctx.http.clone(), - CreateGenericResponse::new().content("Nudge times must be less than 500 minutes"), - ) - .await; + if combined_time < i16::MIN as isize || combined_time > i16::MAX as isize { + ctx.say("Nudge times must be less than 500 minutes").await?; } else { - let mut channel_data = ctx.channel_data(invoke.channel_id()).await.unwrap(); + let mut channel_data = ctx.channel_data().await.unwrap(); channel_data.nudge = combined_time as i16; - channel_data.commit_changes(&pool).await; + channel_data.commit_changes(&ctx.data().database).await; - let _ = invoke - .respond( - ctx.http.clone(), - CreateGenericResponse::new().content(format!( - "Future reminders will be nudged by {} seconds", - combined_time - )), - ) - .await; + ctx.say(format!("Future reminders will be nudged by {} seconds", combined_time)).await?; } + + Ok(()) } -#[command("look")] -#[description("View reminders on a specific channel")] -#[arg( - name = "channel", - description = "The channel to view reminders on", - kind = "Channel", - required = false -)] -#[arg( - name = "disabled", - description = "Whether to show disabled reminders or not", - kind = "Boolean", - required = false -)] -#[arg( - name = "relative", - description = "Whether to display times as relative or exact times", - kind = "Boolean", - required = false -)] -#[hook(CHECK_GUILD_PERMISSIONS_HOOK)] -async fn look(ctx: &Context, invoke: &mut CommandInvoke, args: CommandOptions) { - let pool = ctx.data.read().await.get::().cloned().unwrap(); - - let timezone = UserData::timezone_of(&invoke.author_id(), &pool).await; +/// View reminders on a specific channel +#[poise::command(slash_command)] +pub async fn look( + ctx: Context<'_>, + #[description = "Channel to view reminders on"] channel: Option, + #[description = "Whether to show disabled reminders or not"] disabled: Option, + #[description = "Whether to display times as relative or exact times"] relative: Option, +) -> Result<(), Error> { + let timezone = ctx.timezone().await; let flags = LookFlags { - show_disabled: args.get("disabled").map(|i| i.as_bool()).flatten().unwrap_or(true), - channel_id: args.get("channel").map(|i| i.as_channel_id()).flatten(), - time_display: args.get("relative").map_or(TimeDisplayType::Relative, |b| { - if b.as_bool() == Some(true) { + show_disabled: disabled.unwrap_or(true), + channel_id: channel.map(|c| c.id()), + time_display: relative.map_or(TimeDisplayType::Relative, |b| { + if b { TimeDisplayType::Relative } else { TimeDisplayType::Absolute @@ -278,33 +193,29 @@ async fn look(ctx: &Context, invoke: &mut CommandInvoke, args: CommandOptions) { }), }; - let channel_opt = invoke.channel_id().to_channel_cached(&ctx); + let channel_opt = ctx.channel_id().to_channel_cached(&ctx.discord()); let channel_id = if let Some(Channel::Guild(channel)) = channel_opt { - if Some(channel.guild_id) == invoke.guild_id() { - flags.channel_id.unwrap_or_else(|| invoke.channel_id()) + if Some(channel.guild_id) == ctx.guild_id() { + flags.channel_id.unwrap_or_else(|| ctx.channel_id()) } else { - invoke.channel_id() + ctx.channel_id() } } else { - invoke.channel_id() + ctx.channel_id() }; - let channel_name = if let Some(Channel::Guild(channel)) = channel_id.to_channel_cached(&ctx) { - Some(channel.name) - } else { - None - }; + let channel_name = + if let Some(Channel::Guild(channel)) = channel_id.to_channel_cached(&ctx.discord()) { + Some(channel.name) + } else { + None + }; - let reminders = Reminder::from_channel(ctx, channel_id, &flags).await; + let reminders = Reminder::from_channel(&ctx, channel_id, &flags).await; if reminders.is_empty() { - let _ = invoke - .respond( - ctx.http.clone(), - CreateGenericResponse::new().content("No reminders on specified channel"), - ) - .await; + let _ = ctx.say("No reminders on specified channel").await; } else { let mut char_count = 0; @@ -327,41 +238,45 @@ async fn look(ctx: &Context, invoke: &mut CommandInvoke, args: CommandOptions) { let pager = LookPager::new(flags, timezone); - invoke - .respond( - ctx.http.clone(), - CreateGenericResponse::new() - .embed(|e| { - e.title(format!( - "Reminders{}", - channel_name.map_or(String::new(), |n| format!(" on #{}", n)) - )) - .description(display) - .footer(|f| f.text(format!("Page {} of {}", 1, pages))) - .color(*THEME_COLOR) - }) - .components(|comp| { - pager.create_button_row(pages, comp); + ctx.send(|r| { + r.ephemeral(true) + .embed(|e| { + e.title(format!( + "Reminders{}", + channel_name.map_or(String::new(), |n| format!(" on #{}", n)) + )) + .description(display) + .footer(|f| f.text(format!("Page {} of {}", 1, pages))) + .color(*THEME_COLOR) + }) + .components(|comp| { + pager.create_button_row(pages, comp); - comp - }), - ) - .await - .unwrap(); + comp + }) + }) + .await?; } + + Ok(()) } -#[command("del")] -#[description("Delete reminders")] -#[hook(CHECK_GUILD_PERMISSIONS_HOOK)] -async fn delete(ctx: &Context, invoke: &mut CommandInvoke, _args: CommandOptions) { - let timezone = ctx.timezone(invoke.author_id()).await; +/// Delete reminders +#[poise::command(slash_command, rename = "del")] +pub async fn delete(ctx: Context<'_>) -> Result<(), Error> { + let timezone = ctx.timezone().await; - let reminders = Reminder::from_guild(ctx, invoke.guild_id(), invoke.author_id()).await; + let reminders = Reminder::from_guild(&ctx, ctx.guild_id(), ctx.author().id).await; let resp = show_delete_page(&reminders, 0, timezone); - let _ = invoke.respond(&ctx, resp).await; + ctx.send(|r| { + *r = resp; + r + }) + .await?; + + Ok(()) } pub fn max_delete_page(reminders: &[Reminder], timezone: &Tz) -> usize { @@ -386,20 +301,20 @@ pub fn max_delete_page(reminders: &[Reminder], timezone: &Tz) -> usize { }) } -pub fn show_delete_page( - reminders: &[Reminder], - page: usize, - timezone: Tz, -) -> CreateGenericResponse { +pub fn show_delete_page(reminders: &[Reminder], page: usize, timezone: Tz) -> CreateReply { let pager = DelPager::new(page, timezone); if reminders.is_empty() { - return CreateGenericResponse::new() + let mut reply = CreateReply::default(); + + reply .embed(|e| e.title("Delete Reminders").description("No Reminders").color(*THEME_COLOR)) .components(|comp| { pager.create_button_row(0, comp); comp }); + + return reply; } let pages = max_delete_page(reminders, &timezone); @@ -448,7 +363,9 @@ pub fn show_delete_page( let del_selector = ComponentDataModel::DelSelector(DelSelector { page, timezone }); - CreateGenericResponse::new() + let mut reply = CreateReply::default(); + + reply .embed(|e| { e.title("Delete Reminders") .description(display) @@ -486,290 +403,206 @@ pub fn show_delete_page( }) }) }) + }); + + reply +} + +fn time_difference(start_time: NaiveDateTime) -> String { + let unix_time = SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_secs() as i64; + let now = NaiveDateTime::from_timestamp(unix_time, 0); + + let delta = (now - start_time).num_seconds(); + + let (minutes, seconds) = delta.div_rem(&60); + let (hours, minutes) = minutes.div_rem(&60); + let (days, hours) = hours.div_rem(&24); + + format!("{} days, {:02}:{:02}:{:02}", days, hours, minutes, seconds) +} + +/// Manage timers +#[poise::command(slash_command, rename = "timer")] +pub async fn timer_base(_ctx: Context<'_>) -> Result<(), Error> { + Ok(()) +} + +/// List the timers in this server or DM channel +#[poise::command(slash_command, rename = "list")] +pub async fn list_timer(ctx: Context<'_>) -> Result<(), Error> { + let owner = ctx.guild_id().map(|g| g.0).unwrap_or_else(|| ctx.author().id.0); + + let timers = Timer::from_owner(owner, &ctx.data().database).await; + + if !timers.is_empty() { + ctx.send(|m| { + m.embed(|e| { + e.fields(timers.iter().map(|timer| { + (&timer.name, format!("⌚ `{}`", time_difference(timer.start_time)), false) + })) + .color(*THEME_COLOR) + }) }) + .await?; + } else { + ctx.say("No timers currently. Use `/timer start` to create a new timer").await?; + } + + Ok(()) } -#[command("timer")] -#[description("Manage timers")] -#[subcommand("list")] -#[description("List the timers in this server or DM channel")] -#[subcommand("start")] -#[description("Start a new timer from now")] -#[arg(name = "name", description = "Name for the new timer", kind = "String", required = true)] -#[subcommand("delete")] -#[description("Delete a timer")] -#[arg(name = "name", description = "Name of the timer to delete", kind = "String", required = true)] -#[hook(CHECK_GUILD_PERMISSIONS_HOOK)] -async fn timer(ctx: &Context, invoke: &mut CommandInvoke, args: CommandOptions) { - fn time_difference(start_time: NaiveDateTime) -> String { - let unix_time = SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_secs() as i64; - let now = NaiveDateTime::from_timestamp(unix_time, 0); +/// Start a new timer from now +#[poise::command(slash_command, rename = "start")] +pub async fn start_timer( + ctx: Context<'_>, + #[description = "Name for the new timer"] name: String, +) -> Result<(), Error> { + let owner = ctx.guild_id().map(|g| g.0).unwrap_or_else(|| ctx.author().id.0); - let delta = (now - start_time).num_seconds(); + let count = Timer::count_from_owner(owner, &ctx.data().database).await; - let (minutes, seconds) = delta.div_rem(&60); - let (hours, minutes) = minutes.div_rem(&60); - let (days, hours) = hours.div_rem(&24); + if count >= 25 { + ctx.say("You already have 25 timers. Please delete some timers before creating a new one") + .await?; + } else { + if name.len() <= 32 { + Timer::create(&name, owner, &ctx.data().database).await; - format!("{} days, {:02}:{:02}:{:02}", days, hours, minutes, seconds) + ctx.say("Created a new timer").await?; + } else { + ctx.say(format!( + "Please name your timer something shorted (max. 32 characters, you used {})", + name.len() + )) + .await?; + } } - let pool = ctx.data.read().await.get::().cloned().unwrap(); - - let owner = invoke.guild_id().map(|g| g.0).unwrap_or_else(|| invoke.author_id().0); - - match args.subcommand.clone().unwrap().as_str() { - "start" => { - let count = Timer::count_from_owner(owner, &pool).await; - - if count >= 25 { - let _ = invoke - .respond( - ctx.http.clone(), - CreateGenericResponse::new() - .content("You already have 25 timers. Please delete some timers before creating a new one"), - ) - .await; - } else { - let name = args.get("name").unwrap().to_string(); - - if name.len() <= 32 { - Timer::create(&name, owner, &pool).await; - - let _ = invoke - .respond( - ctx.http.clone(), - CreateGenericResponse::new().content("Created a new timer"), - ) - .await; - } else { - let _ = invoke - .respond( - ctx.http.clone(), - CreateGenericResponse::new() - .content(format!("Please name your timer something shorted (max. 32 characters, you used {})", name.len())), - ) - .await; - } - } - } - "delete" => { - let name = args.get("name").unwrap().to_string(); - - let exists = sqlx::query!( - " -SELECT 1 as _r FROM timers WHERE owner = ? AND name = ? - ", - owner, - name - ) - .fetch_one(&pool) - .await; - - if exists.is_ok() { - sqlx::query!( - " -DELETE FROM timers WHERE owner = ? AND name = ? - ", - owner, - name - ) - .execute(&pool) - .await - .unwrap(); - - let _ = invoke - .respond( - ctx.http.clone(), - CreateGenericResponse::new().content("Deleted a timer"), - ) - .await; - } else { - let _ = invoke - .respond( - ctx.http.clone(), - CreateGenericResponse::new().content("Could not find a timer by that name"), - ) - .await; - } - } - "list" => { - let timers = Timer::from_owner(owner, &pool).await; - - if !timers.is_empty() { - let _ = invoke - .respond( - ctx.http.clone(), - CreateGenericResponse::new().embed(|e| { - e.fields(timers.iter().map(|timer| { - ( - &timer.name, - format!("⌚ `{}`", time_difference(timer.start_time)), - false, - ) - })) - .color(*THEME_COLOR) - }), - ) - .await; - } else { - let _ = invoke - .respond( - ctx.http.clone(), - CreateGenericResponse::new().content( - "No timers currently. Use `/timer start` to create a new timer", - ), - ) - .await; - } - } - _ => {} - } + Ok(()) } -#[command("remind")] -#[description("Create a new reminder")] -#[arg( - name = "time", - description = "A description of the time to set the reminder for", - kind = "String", - required = true -)] -#[arg( - name = "content", - description = "The message content to send", - kind = "String", - required = true -)] -#[arg( - name = "channels", - description = "Channel or user mentions to set the reminder for", - kind = "String", - required = false -)] -#[arg( - name = "interval", - description = "(Patreon only) Time to wait before repeating the reminder. Leave blank for one-shot reminder", - kind = "String", - required = false -)] -#[arg( - name = "expires", - description = "(Patreon only) For repeating reminders, the time at which the reminder will stop sending", - kind = "String", - required = false -)] -#[arg( - name = "tts", - description = "Set the TTS flag on the reminder message (like the /tts command)", - kind = "Boolean", - required = false -)] -#[hook(CHECK_GUILD_PERMISSIONS_HOOK)] -async fn remind(ctx: &Context, invoke: &mut CommandInvoke, args: CommandOptions) { - if args.get("interval").is_none() && args.get("expires").is_some() { - let _ = invoke - .respond( - &ctx, - CreateGenericResponse::new().content("`expires` can only be used with `interval`"), - ) +/// Delete a timer +#[poise::command(slash_command, rename = "delete")] +pub async fn delete_timer( + ctx: Context<'_>, + #[description = "Name of timer to delete"] name: String, +) -> Result<(), Error> { + let owner = ctx.guild_id().map(|g| g.0).unwrap_or_else(|| ctx.author().id.0); + + let exists = + sqlx::query!("SELECT 1 as _r FROM timers WHERE owner = ? AND name = ?", owner, name) + .fetch_one(&ctx.data().database) .await; - return; + if exists.is_ok() { + sqlx::query!("DELETE FROM timers WHERE owner = ? AND name = ?", owner, name) + .execute(&ctx.data().database) + .await + .unwrap(); + + ctx.say("Deleted a timer").await?; + } else { + ctx.say("Could not find a timer by that name").await?; } - invoke.defer(&ctx).await; + Ok(()) +} - let user_data = ctx.user_data(invoke.author_id()).await.unwrap(); - let timezone = user_data.timezone(); +/// Create a new reminder +#[poise::command(slash_command)] +pub(crate) async fn remind( + ctx: Context<'_>, + #[description = "A description of the time to set the reminder for"] time: String, + #[description = "The message content to send"] content: String, + #[description = "Channel or user mentions to set the reminder for"] channels: Option, + #[description = "(Patreon only) Time to wait before repeating the reminder. Leave blank for one-shot reminder"] + interval: Option, + #[description = "(Patreon only) For repeating reminders, the time at which the reminder will stop sending"] + expires: Option, + #[description = "Set the TTS flag on the reminder message, similar to the /tts command"] + tts: Option, +) -> Result<(), Error> { + if interval.is_none() && expires.is_some() { + ctx.say("`expires` can only be used with `interval`").await?; - let time = { - let time_str = args.get("time").unwrap().to_string(); + return Ok(()); + } - natural_parser(&time_str, &timezone.to_string()).await - }; + ctx.defer().await?; + + let user_data = ctx.author_data().await.unwrap(); + let timezone = ctx.timezone().await; + + let time = natural_parser(&time, &timezone.to_string()).await; match time { Some(time) => { let content = { - let content = args.get("content").unwrap().to_string(); - let tts = args.get("tts").map_or(false, |arg| arg.as_bool().unwrap_or(false)); + let tts = tts.unwrap_or(false); Content { content, tts, attachment: None, attachment_name: None } }; let scopes = { - let list = args - .get("channels") - .map(|arg| parse_mention_list(&arg.to_string())) - .unwrap_or_default(); + let list = + channels.map(|arg| parse_mention_list(&arg.to_string())).unwrap_or_default(); if list.is_empty() { - if invoke.guild_id().is_some() { - vec![ReminderScope::Channel(invoke.channel_id().0)] + if ctx.guild_id().is_some() { + vec![ReminderScope::Channel(ctx.channel_id().0)] } else { - vec![ReminderScope::User(invoke.author_id().0)] + vec![ReminderScope::User(ctx.author().id.0)] } } else { list } }; - let (interval, expires) = if let Some(repeat) = args.get("interval") { - if check_subscription(&ctx, invoke.author_id()).await - || (invoke.guild_id().is_some() - && check_guild_subscription(&ctx, invoke.guild_id().unwrap()).await) + let (processed_interval, processed_expires) = if let Some(repeat) = &interval { + if check_subscription(&ctx.discord(), ctx.author().id).await + || (ctx.guild_id().is_some() + && check_guild_subscription(&ctx.discord(), ctx.guild_id().unwrap()).await) { ( - parse_duration(&repeat.to_string()) + parse_duration(repeat) .or_else(|_| parse_duration(&format!("1 {}", repeat.to_string()))) .ok(), { - if let Some(arg) = args.get("expires") { - natural_parser(&arg.to_string(), &timezone.to_string()).await + if let Some(arg) = &expires { + natural_parser(arg, &timezone.to_string()).await } else { None } }, ) } else { - let _ = invoke - .respond(&ctx, CreateGenericResponse::new() - .content("`repeat` is only available to Patreon subscribers or self-hosted users") - ).await; + ctx.say( + "`repeat` is only available to Patreon subscribers or self-hosted users", + ) + .await?; - return; + return Ok(()); } } else { (None, None) }; - if interval.is_none() && args.get("interval").is_some() { - let _ = invoke - .respond( - &ctx, - CreateGenericResponse::new().content( - "Repeat interval could not be processed. Try and format the repetition similar to `1 hour` or `4 days`", - ), - ) - .await; - } else if expires.is_none() && args.get("expires").is_some() { - let _ = invoke - .respond( - &ctx, - CreateGenericResponse::new().content( - "Expiry time failed to process. Please make it as clear as possible", - ), - ) - .await; + if processed_interval.is_none() && interval.is_some() { + ctx.say( + "Repeat interval could not be processed. Try similar to `1 hour` or `4 days`", + ) + .await?; + } else if processed_expires.is_none() && expires.is_some() { + ctx.say("Expiry time failed to process. Please make it as clear as possible") + .await?; } else { - let mut builder = MultiReminderBuilder::new(ctx, invoke.guild_id()) + let mut builder = MultiReminderBuilder::new(&ctx, ctx.guild_id()) .author(user_data) .content(content) .time(time) .timezone(timezone) - .expires(expires) - .interval(interval); + .expires(processed_expires) + .interval(processed_interval); builder.set_scopes(scopes); @@ -777,23 +610,21 @@ async fn remind(ctx: &Context, invoke: &mut CommandInvoke, args: CommandOptions) let embed = create_response(successes, errors, time); - let _ = invoke - .respond( - &ctx, - CreateGenericResponse::new().embed(|c| { - *c = embed; - c - }), - ) - .await; + ctx.send(|m| { + m.embed(|c| { + *c = embed; + c + }) + }) + .await?; } } None => { - let _ = invoke - .respond(&ctx, CreateGenericResponse::new().content("Time could not be processed")) - .await; + ctx.say("Time could not be processed").await?; } } + + Ok(()) } fn create_response( diff --git a/src/commands/todo_cmds.rs b/src/commands/todo_cmds.rs index 9f004e1..e79113c 100644 --- a/src/commands/todo_cmds.rs +++ b/src/commands/todo_cmds.rs @@ -1,5 +1,4 @@ -use regex_command_attr::command; -use serenity::client::Context; +use poise::CreateReply; use crate::{ component_models::{ @@ -7,134 +6,177 @@ use crate::{ ComponentDataModel, TodoSelector, }, consts::{EMBED_DESCRIPTION_MAX_LENGTH, SELECT_MAX_ENTRIES, THEME_COLOR}, - framework::{CommandInvoke, CommandOptions, CreateGenericResponse}, - hooks::CHECK_GUILD_PERMISSIONS_HOOK, - SQLPool, + Context, Error, }; -#[command] -#[description("Manage todo lists")] -#[subcommandgroup("server")] -#[description("Manage the server todo list")] -#[subcommand("add")] -#[description("Add an item to the server todo list")] -#[arg( - name = "task", - description = "The task to add to the todo list", - kind = "String", - required = true -)] -#[subcommand("view")] -#[description("View and remove from the server todo list")] -#[subcommandgroup("channel")] -#[description("Manage the channel todo list")] -#[subcommand("add")] -#[description("Add to the channel todo list")] -#[arg( - name = "task", - description = "The task to add to the todo list", - kind = "String", - required = true -)] -#[subcommand("view")] -#[description("View and remove from the channel todo list")] -#[subcommandgroup("user")] -#[description("Manage your personal todo list")] -#[subcommand("add")] -#[description("Add to your personal todo list")] -#[arg( - name = "task", - description = "The task to add to the todo list", - kind = "String", - required = true -)] -#[subcommand("view")] -#[description("View and remove from your personal todo list")] -#[hook(CHECK_GUILD_PERMISSIONS_HOOK)] -async fn todo(ctx: &Context, invoke: &mut CommandInvoke, args: CommandOptions) { - if invoke.guild_id().is_none() && args.subcommand_group != Some("user".to_string()) { - let _ = invoke - .respond( - &ctx, - CreateGenericResponse::new().content("Please use `/todo user` in direct messages"), - ) - .await; - } else { - let pool = ctx.data.read().await.get::().cloned().unwrap(); +/// Manage todo lists +#[poise::command(slash_command, rename = "todo")] +pub async fn todo_base(_ctx: Context<'_>) -> Result<(), Error> { + Ok(()) +} - let keys = match args.subcommand_group.as_ref().unwrap().as_str() { - "server" => (None, None, invoke.guild_id().map(|g| g.0)), - "channel" => (None, Some(invoke.channel_id().0), invoke.guild_id().map(|g| g.0)), - _ => (Some(invoke.author_id().0), None, None), - }; +/// Manage the server todo list +#[poise::command(slash_command, rename = "server")] +pub async fn todo_guild_base(_ctx: Context<'_>) -> Result<(), Error> { + Ok(()) +} - match args.get("task") { - Some(task) => { - let task = task.to_string(); +/// Add an item to the server todo list +#[poise::command(slash_command, rename = "add")] +pub async fn todo_guild_add( + ctx: Context<'_>, + #[description = "The task to add to the todo list"] task: String, +) -> Result<(), Error> { + sqlx::query!( + "INSERT INTO todos (guild_id, value) +VALUES ((SELECT id FROM guilds WHERE guild = ?), ?)", + ctx.guild_id().unwrap().0, + task + ) + .execute(&ctx.data().database) + .await + .unwrap(); - sqlx::query!( - "INSERT INTO todos (user_id, channel_id, guild_id, value) VALUES ((SELECT id FROM users WHERE user = ?), (SELECT id FROM channels WHERE channel = ?), (SELECT id FROM guilds WHERE guild = ?), ?)", - keys.0, - keys.1, - keys.2, - task - ) - .execute(&pool) - .await - .unwrap(); + ctx.say("Item added to todo list").await?; - let _ = invoke - .respond(&ctx, CreateGenericResponse::new().content("Item added to todo list")) - .await; - } - None => { - let values = if let Some(uid) = keys.0 { - sqlx::query!( - "SELECT todos.id, value FROM todos -INNER JOIN users ON todos.user_id = users.id -WHERE users.user = ?", - uid, - ) - .fetch_all(&pool) - .await - .unwrap() - .iter() - .map(|row| (row.id as usize, row.value.clone())) - .collect::>() - } else if let Some(cid) = keys.1 { - sqlx::query!( - "SELECT todos.id, value FROM todos -INNER JOIN channels ON todos.channel_id = channels.id -WHERE channels.channel = ?", - cid, - ) - .fetch_all(&pool) - .await - .unwrap() - .iter() - .map(|row| (row.id as usize, row.value.clone())) - .collect::>() - } else { - sqlx::query!( - "SELECT todos.id, value FROM todos + Ok(()) +} + +/// View and remove from the server todo list +#[poise::command(slash_command, rename = "view")] +pub async fn todo_guild_view(ctx: Context<'_>) -> Result<(), Error> { + let values = sqlx::query!( + "SELECT todos.id, value FROM todos INNER JOIN guilds ON todos.guild_id = guilds.id WHERE guilds.guild = ?", - keys.2, - ) - .fetch_all(&pool) - .await - .unwrap() - .iter() - .map(|row| (row.id as usize, row.value.clone())) - .collect::>() - }; + ctx.guild_id().unwrap().0, + ) + .fetch_all(&ctx.data().database) + .await + .unwrap() + .iter() + .map(|row| (row.id as usize, row.value.clone())) + .collect::>(); - let resp = show_todo_page(&values, 0, keys.0, keys.1, keys.2); + let resp = show_todo_page(&values, 0, None, None, ctx.guild_id().map(|g| g.0)); - invoke.respond(&ctx, resp).await.unwrap(); - } - } - } + ctx.send(|r| { + *r = resp; + r + }) + .await?; + + Ok(()) +} + +/// Manage the channel todo list +#[poise::command(slash_command, rename = "channel")] +pub async fn todo_channel_base(_ctx: Context<'_>) -> Result<(), Error> { + Ok(()) +} + +/// Add an item to the channel todo list +#[poise::command(slash_command, rename = "add")] +pub async fn todo_channel_add( + ctx: Context<'_>, + #[description = "The task to add to the todo list"] task: String, +) -> Result<(), Error> { + sqlx::query!( + "INSERT INTO todos (guild_id, channel_id, value) +VALUES ((SELECT id FROM guilds WHERE guild = ?), (SELECT id FROM channels WHERE channel = ?), ?)", + ctx.guild_id().unwrap().0, + ctx.channel_id().0, + task + ) + .execute(&ctx.data().database) + .await + .unwrap(); + + ctx.say("Item added to todo list").await?; + + Ok(()) +} + +/// View and remove from the channel todo list +#[poise::command(slash_command, rename = "view")] +pub async fn todo_channel_view(ctx: Context<'_>) -> Result<(), Error> { + let values = sqlx::query!( + "SELECT todos.id, value FROM todos +INNER JOIN channels ON todos.channel_id = channels.id +WHERE channels.channel = ?", + ctx.channel_id().0, + ) + .fetch_all(&ctx.data().database) + .await + .unwrap() + .iter() + .map(|row| (row.id as usize, row.value.clone())) + .collect::>(); + + let resp = + show_todo_page(&values, 0, None, Some(ctx.channel_id().0), ctx.guild_id().map(|g| g.0)); + + ctx.send(|r| { + *r = resp; + r + }) + .await?; + + Ok(()) +} + +/// Manage your personal todo list +#[poise::command(slash_command, rename = "user")] +pub async fn todo_user_base(_ctx: Context<'_>) -> Result<(), Error> { + Ok(()) +} + +/// Add an item to your personal todo list +#[poise::command(slash_command, rename = "add")] +pub async fn todo_user_add( + ctx: Context<'_>, + #[description = "The task to add to the todo list"] task: String, +) -> Result<(), Error> { + sqlx::query!( + "INSERT INTO todos (user_id, value) +VALUES ((SELECT id FROM users WHERE user = ?), ?)", + ctx.author().id.0, + task + ) + .execute(&ctx.data().database) + .await + .unwrap(); + + ctx.say("Item added to todo list").await?; + + Ok(()) +} + +/// View and remove from your personal todo list +#[poise::command(slash_command, rename = "view")] +pub async fn todo_user_view(ctx: Context<'_>) -> Result<(), Error> { + let values = sqlx::query!( + "SELECT todos.id, value FROM todos +INNER JOIN users ON todos.user_id = users.id +WHERE users.user = ?", + ctx.author().id.0, + ) + .fetch_all(&ctx.data().database) + .await + .unwrap() + .iter() + .map(|row| (row.id as usize, row.value.clone())) + .collect::>(); + + let resp = show_todo_page(&values, 0, Some(ctx.author().id.0), None, None); + + ctx.send(|r| { + *r = resp; + r + }) + .await?; + + Ok(()) } pub fn max_todo_page(todo_values: &[(usize, String)]) -> usize { @@ -164,7 +206,7 @@ pub fn show_todo_page( user_id: Option, channel_id: Option, guild_id: Option, -) -> CreateGenericResponse { +) -> CreateReply { let pager = TodoPager::new(page, user_id, channel_id, guild_id); let pages = max_todo_page(todo_values); @@ -219,17 +261,23 @@ pub fn show_todo_page( }; if todo_ids.is_empty() { - CreateGenericResponse::new().embed(|e| { + let mut reply = CreateReply::default(); + + reply.embed(|e| { e.title(format!("{} Todo List", title)) .description("Todo List Empty!") .footer(|f| f.text(format!("Page {} of {}", page + 1, pages))) .color(*THEME_COLOR) - }) + }); + + reply } else { let todo_selector = ComponentDataModel::TodoSelector(TodoSelector { page, user_id, channel_id, guild_id }); - CreateGenericResponse::new() + let mut reply = CreateReply::default(); + + reply .embed(|e| { e.title(format!("{} Todo List", title)) .description(display) @@ -255,6 +303,8 @@ pub fn show_todo_page( }) }) }) - }) + }); + + reply } } diff --git a/src/component_models/mod.rs b/src/component_models/mod.rs index bf16b2b..82fc879 100644 --- a/src/component_models/mod.rs +++ b/src/component_models/mod.rs @@ -3,17 +3,16 @@ pub(crate) mod pager; use std::io::Cursor; use chrono_tz::Tz; -use rmp_serde::Serializer; -use serde::{Deserialize, Serialize}; -use serenity::{ +use poise::serenity::{ builder::CreateEmbed, - client::Context, model::{ channel::Channel, interactions::{message_component::MessageComponentInteraction, InteractionResponseType}, prelude::InteractionApplicationCommandCallbackDataFlags, }, }; +use rmp_serde::Serializer; +use serde::{Deserialize, Serialize}; use crate::{ commands::{ @@ -23,9 +22,8 @@ use crate::{ }, component_models::pager::{DelPager, LookPager, MacroPager, Pager, TodoPager}, consts::{EMBED_DESCRIPTION_MAX_LENGTH, THEME_COLOR}, - framework::CommandInvoke, - models::{command_macro::CommandMacro, reminder::Reminder}, - SQLPool, + models::{reminder::Reminder, CtxData}, + Context, Data, }; #[derive(Deserialize, Serialize)] @@ -55,12 +53,12 @@ impl ComponentDataModel { rmp_serde::from_read(cur).unwrap() } - pub async fn act(&self, ctx: &Context, component: MessageComponentInteraction) { + pub async fn act(&self, ctx: Context<'_>, component: &MessageComponentInteraction) { match self { ComponentDataModel::LookPager(pager) => { let flags = pager.flags; - let channel_opt = component.channel_id.to_channel_cached(&ctx); + let channel_opt = component.channel_id.to_channel_cached(&ctx.discord()); let channel_id = if let Some(Channel::Guild(channel)) = channel_opt { if Some(channel.guild_id) == component.guild_id { @@ -72,7 +70,7 @@ impl ComponentDataModel { component.channel_id }; - let reminders = Reminder::from_channel(ctx, channel_id, &flags).await; + let reminders = Reminder::from_channel(&ctx, channel_id, &flags).await; let pages = reminders .iter() @@ -80,12 +78,13 @@ impl ComponentDataModel { .fold(0, |t, r| t + r.len()) .div_ceil(EMBED_DESCRIPTION_MAX_LENGTH); - let channel_name = - if let Some(Channel::Guild(channel)) = channel_id.to_channel_cached(&ctx) { - Some(channel.name) - } else { - None - }; + let channel_name = if let Some(Channel::Guild(channel)) = + channel_id.to_channel_cached(&ctx.discord()) + { + Some(channel.name) + } else { + None + }; let next_page = pager.next_page(pages); @@ -119,7 +118,7 @@ impl ComponentDataModel { .color(*THEME_COLOR); let _ = component - .create_interaction_response(&ctx, |r| { + .create_interaction_response(&ctx.discord(), |r| { r.kind(InteractionResponseType::UpdateMessage).interaction_response_data( |response| { response.embeds(vec![embed]).components(|comp| { @@ -134,44 +133,49 @@ impl ComponentDataModel { } ComponentDataModel::DelPager(pager) => { let reminders = - Reminder::from_guild(ctx, component.guild_id, component.user.id).await; + Reminder::from_guild(&ctx, component.guild_id, component.user.id).await; let max_pages = max_delete_page(&reminders, &pager.timezone); let resp = show_delete_page(&reminders, pager.next_page(max_pages), pager.timezone); - let mut invoke = CommandInvoke::component(component); - let _ = invoke.respond(&ctx, resp).await; + let _ = ctx + .send(|r| { + *r = resp; + r + }) + .await; } ComponentDataModel::DelSelector(selector) => { - let pool = ctx.data.read().await.get::().cloned().unwrap(); let selected_id = component.data.values.join(","); sqlx::query!("DELETE FROM reminders WHERE FIND_IN_SET(id, ?)", selected_id) - .execute(&pool) + .execute(&ctx.data().database) .await .unwrap(); let reminders = - Reminder::from_guild(ctx, component.guild_id, component.user.id).await; + Reminder::from_guild(&ctx, component.guild_id, component.user.id).await; let resp = show_delete_page(&reminders, selector.page, selector.timezone); - let mut invoke = CommandInvoke::component(component); - let _ = invoke.respond(&ctx, resp).await; + let _ = ctx + .send(|r| { + *r = resp; + r + }) + .await; } ComponentDataModel::TodoPager(pager) => { if Some(component.user.id.0) == pager.user_id || pager.user_id.is_none() { - let pool = ctx.data.read().await.get::().cloned().unwrap(); - let values = if let Some(uid) = pager.user_id { sqlx::query!( "SELECT todos.id, value FROM todos - INNER JOIN users ON todos.user_id = users.id - WHERE users.user = ?", +INNER JOIN users ON todos.user_id = users.id +WHERE users.user = ?", uid, ) - .fetch_all(&pool) + .fetch_all(&ctx.data().database) .await .unwrap() .iter() @@ -180,11 +184,11 @@ impl ComponentDataModel { } else if let Some(cid) = pager.channel_id { sqlx::query!( "SELECT todos.id, value FROM todos - INNER JOIN channels ON todos.channel_id = channels.id - WHERE channels.channel = ?", +INNER JOIN channels ON todos.channel_id = channels.id +WHERE channels.channel = ?", cid, ) - .fetch_all(&pool) + .fetch_all(&ctx.data().database) .await .unwrap() .iter() @@ -193,11 +197,11 @@ impl ComponentDataModel { } else { sqlx::query!( "SELECT todos.id, value FROM todos - INNER JOIN guilds ON todos.guild_id = guilds.id - WHERE guilds.guild = ?", +INNER JOIN guilds ON todos.guild_id = guilds.id +WHERE guilds.guild = ?", pager.guild_id, ) - .fetch_all(&pool) + .fetch_all(&ctx.data().database) .await .unwrap() .iter() @@ -215,11 +219,15 @@ impl ComponentDataModel { pager.guild_id, ); - let mut invoke = CommandInvoke::component(component); - let _ = invoke.respond(&ctx, resp).await; + let _ = ctx + .send(|r| { + *r = resp; + r + }) + .await; } else { let _ = component - .create_interaction_response(&ctx, |r| { + .create_interaction_response(&ctx.discord(), |r| { r.kind(InteractionResponseType::ChannelMessageWithSource) .interaction_response_data(|d| { d.flags( @@ -233,11 +241,10 @@ impl ComponentDataModel { } ComponentDataModel::TodoSelector(selector) => { if Some(component.user.id.0) == selector.user_id || selector.user_id.is_none() { - let pool = ctx.data.read().await.get::().cloned().unwrap(); let selected_id = component.data.values.join(","); sqlx::query!("DELETE FROM todos WHERE FIND_IN_SET(id, ?)", selected_id) - .execute(&pool) + .execute(&ctx.data().database) .await .unwrap(); @@ -248,7 +255,7 @@ impl ComponentDataModel { selector.channel_id, selector.guild_id, ) - .fetch_all(&pool) + .fetch_all(&ctx.data().database) .await .unwrap() .iter() @@ -263,11 +270,15 @@ impl ComponentDataModel { selector.guild_id, ); - let mut invoke = CommandInvoke::component(component); - let _ = invoke.respond(&ctx, resp).await; + let _ = ctx + .send(|r| { + *r = resp; + r + }) + .await; } else { let _ = component - .create_interaction_response(&ctx, |r| { + .create_interaction_response(&ctx.discord(), |r| { r.kind(InteractionResponseType::ChannelMessageWithSource) .interaction_response_data(|d| { d.flags( @@ -280,15 +291,19 @@ impl ComponentDataModel { } } ComponentDataModel::MacroPager(pager) => { - let mut invoke = CommandInvoke::component(component); - - let macros = CommandMacro::from_guild(ctx, invoke.guild_id().unwrap()).await; + let macros = ctx.command_macros().await.unwrap(); let max_page = max_macro_page(¯os); let page = pager.next_page(max_page); let resp = show_macro_page(¯os, page); - let _ = invoke.respond(&ctx, resp).await; + + let _ = ctx + .send(|r| { + *r = resp; + r + }) + .await; } } } diff --git a/src/component_models/pager.rs b/src/component_models/pager.rs index 8ab83e2..e82ff58 100644 --- a/src/component_models/pager.rs +++ b/src/component_models/pager.rs @@ -1,8 +1,10 @@ // todo split pager out into a single struct use chrono_tz::Tz; +use poise::serenity::{ + builder::CreateComponents, model::interactions::message_component::ButtonStyle, +}; use serde::{Deserialize, Serialize}; use serde_repr::*; -use serenity::{builder::CreateComponents, model::interactions::message_component::ButtonStyle}; use crate::{component_models::ComponentDataModel, models::reminder::look_flags::LookFlags}; diff --git a/src/consts.rs b/src/consts.rs index f1c47ba..8df74b9 100644 --- a/src/consts.rs +++ b/src/consts.rs @@ -1,4 +1,6 @@ pub const DAY: u64 = 86_400; +pub const HOUR: u64 = 3_600; +pub const MINUTE: u64 = 60; pub const EMBED_DESCRIPTION_MAX_LENGTH: usize = 4000; pub const SELECT_MAX_ENTRIES: usize = 25; diff --git a/src/event_handlers.rs b/src/event_handlers.rs index dfcf1c2..bc7e014 100644 --- a/src/event_handlers.rs +++ b/src/event_handlers.rs @@ -1,11 +1,27 @@ -use std::{collections::HashMap, env, sync::atomic::Ordering}; +use std::{ + collections::HashMap, + env, + sync::atomic::{AtomicBool, Ordering}, +}; use log::{info, warn}; -use poise::serenity::{client::Context, model::interactions::Interaction, utils::shard_id}; +use poise::{ + serenity::{model::interactions::Interaction, utils::shard_id}, + serenity_prelude as serenity, + serenity_prelude::{ + ApplicationCommandInteraction, ApplicationCommandInteractionData, ApplicationCommandType, + InteractionType, + }, + ApplicationCommandOrAutocompleteInteraction, ApplicationContext, Command, +}; -use crate::{Data, Error}; +use crate::{component_models::ComponentDataModel, Context, Data, Error}; -pub async fn listener(ctx: &Context, event: &poise::Event<'_>, data: &Data) -> Result<(), Error> { +pub async fn listener( + ctx: &serenity::Context, + event: &poise::Event<'_>, + data: &Data, +) -> Result<(), Error> { match event { poise::Event::CacheReady { .. } => { info!("Cache Ready!"); @@ -97,15 +113,16 @@ DELETE FROM channels WHERE channel = ? } } } - poise::Event::GuildDelete { incomplete, full } => { + poise::Event::GuildDelete { incomplete, .. } => { let _ = sqlx::query!("DELETE FROM guilds WHERE guild = ?", incomplete.id.0) .execute(&data.database) .await; } poise::Event::InteractionCreate { interaction } => match interaction { Interaction::MessageComponent(component) => { - //let component_model = ComponentDataModel::from_custom_id(&component.data.custom_id); - //component_model.act(&ctx, component).await; + let component_model = ComponentDataModel::from_custom_id(&component.data.custom_id); + + // component_model.act(ctx, component).await; } _ => {} }, diff --git a/src/framework.rs b/src/framework.rs deleted file mode 100644 index aaf9a28..0000000 --- a/src/framework.rs +++ /dev/null @@ -1,692 +0,0 @@ -// todo move framework to its own module, split out permission checks - -use std::{ - collections::{HashMap, HashSet}, - hash::{Hash, Hasher}, - sync::Arc, -}; - -use log::info; -use serde::{Deserialize, Serialize}; -use serenity::{ - builder::{CreateApplicationCommands, CreateComponents, CreateEmbed}, - cache::Cache, - client::Context, - futures::prelude::future::BoxFuture, - http::Http, - model::{ - guild::Guild, - id::{ChannelId, GuildId, RoleId, UserId}, - interactions::{ - application_command::{ - ApplicationCommand, ApplicationCommandInteraction, ApplicationCommandOptionType, - }, - message_component::MessageComponentInteraction, - InteractionApplicationCommandCallbackDataFlags, InteractionResponseType, - }, - prelude::application_command::ApplicationCommandInteractionDataOption, - }, - prelude::TypeMapKey, - Result as SerenityResult, -}; - -use crate::SQLPool; - -pub struct CreateGenericResponse { - content: String, - embed: Option, - components: Option, - flags: InteractionApplicationCommandCallbackDataFlags, -} - -impl CreateGenericResponse { - pub fn new() -> Self { - Self { - content: "".to_string(), - embed: None, - components: None, - flags: InteractionApplicationCommandCallbackDataFlags::empty(), - } - } - - pub fn ephemeral(mut self) -> Self { - self.flags.insert(InteractionApplicationCommandCallbackDataFlags::EPHEMERAL); - - self - } - - pub fn content(mut self, content: D) -> Self { - self.content = content.to_string(); - - self - } - - pub fn embed &mut CreateEmbed>(mut self, f: F) -> Self { - let mut embed = CreateEmbed::default(); - f(&mut embed); - - self.embed = Some(embed); - self - } - - pub fn components &mut CreateComponents>( - mut self, - f: F, - ) -> Self { - let mut components = CreateComponents::default(); - f(&mut components); - - self.components = Some(components); - self - } -} - -#[derive(Clone)] -enum InvokeModel { - Slash(ApplicationCommandInteraction), - Component(MessageComponentInteraction), -} - -#[derive(Clone)] -pub struct CommandInvoke { - model: InvokeModel, - already_responded: bool, - deferred: bool, -} - -impl CommandInvoke { - pub fn component(component: MessageComponentInteraction) -> Self { - Self { model: InvokeModel::Component(component), already_responded: false, deferred: false } - } - - fn slash(interaction: ApplicationCommandInteraction) -> Self { - Self { model: InvokeModel::Slash(interaction), already_responded: false, deferred: false } - } - - pub async fn defer(&mut self, http: impl AsRef) { - if !self.deferred { - match &self.model { - InvokeModel::Slash(i) => { - i.create_interaction_response(http, |r| { - r.kind(InteractionResponseType::DeferredChannelMessageWithSource) - }) - .await - .unwrap(); - - self.deferred = true; - } - InvokeModel::Component(i) => { - i.create_interaction_response(http, |r| { - r.kind(InteractionResponseType::DeferredChannelMessageWithSource) - }) - .await - .unwrap(); - - self.deferred = true; - } - } - } - } - - pub fn channel_id(&self) -> ChannelId { - match &self.model { - InvokeModel::Slash(i) => i.channel_id, - InvokeModel::Component(i) => i.channel_id, - } - } - - pub fn guild_id(&self) -> Option { - match &self.model { - InvokeModel::Slash(i) => i.guild_id, - InvokeModel::Component(i) => i.guild_id, - } - } - - pub fn guild(&self, cache: impl AsRef) -> Option { - self.guild_id().map(|id| id.to_guild_cached(cache)).flatten() - } - - pub fn author_id(&self) -> UserId { - match &self.model { - InvokeModel::Slash(i) => i.user.id, - InvokeModel::Component(i) => i.user.id, - } - } - - pub async fn respond( - &mut self, - http: impl AsRef, - generic_response: CreateGenericResponse, - ) -> SerenityResult<()> { - match &self.model { - InvokeModel::Slash(i) => { - if self.already_responded { - i.create_followup_message(http, |d| { - d.allowed_mentions(|m| m.empty_parse()); - d.content(generic_response.content); - - if let Some(embed) = generic_response.embed { - d.add_embed(embed); - } - - if let Some(components) = generic_response.components { - d.components(|c| { - *c = components; - c - }); - } - - d - }) - .await - .map(|_| ()) - } else if self.deferred { - i.edit_original_interaction_response(http, |d| { - d.allowed_mentions(|m| m.empty_parse()); - d.content(generic_response.content); - - if let Some(embed) = generic_response.embed { - d.add_embed(embed); - } - - if let Some(components) = generic_response.components { - d.components(|c| { - *c = components; - c - }); - } - - d - }) - .await - .map(|_| ()) - } else { - i.create_interaction_response(http, |r| { - r.kind(InteractionResponseType::ChannelMessageWithSource) - .interaction_response_data(|d| { - d.allowed_mentions(|m| m.empty_parse()); - d.content(generic_response.content); - - if let Some(embed) = generic_response.embed { - d.add_embed(embed); - } - - if let Some(components) = generic_response.components { - d.components(|c| { - *c = components; - c - }); - } - - d - }) - }) - .await - .map(|_| ()) - } - } - InvokeModel::Component(i) => i - .create_interaction_response(http, |r| { - r.kind(InteractionResponseType::UpdateMessage).interaction_response_data(|d| { - d.allowed_mentions(|m| m.empty_parse()); - d.content(generic_response.content); - - if let Some(embed) = generic_response.embed { - d.add_embed(embed); - } - - if let Some(components) = generic_response.components { - d.components(|c| { - *c = components; - c - }); - } - - d - }) - }) - .await - .map(|_| ()), - }?; - - self.already_responded = true; - - Ok(()) - } -} - -#[derive(Debug)] -pub struct Arg { - pub name: &'static str, - pub description: &'static str, - pub kind: ApplicationCommandOptionType, - pub required: bool, - pub options: &'static [&'static Self], -} - -#[derive(Serialize, Deserialize, Clone)] -pub enum OptionValue { - String(String), - Integer(i64), - Boolean(bool), - User(UserId), - Channel(ChannelId), - Role(RoleId), - Mentionable(u64), - Number(f64), -} - -impl OptionValue { - pub fn as_i64(&self) -> Option { - match self { - OptionValue::Integer(i) => Some(*i), - _ => None, - } - } - - pub fn as_bool(&self) -> Option { - match self { - OptionValue::Boolean(b) => Some(*b), - _ => None, - } - } - - pub fn as_channel_id(&self) -> Option { - match self { - OptionValue::Channel(c) => Some(*c), - _ => None, - } - } - - pub fn to_string(&self) -> String { - match self { - OptionValue::String(s) => s.to_string(), - OptionValue::Integer(i) => i.to_string(), - OptionValue::Boolean(b) => b.to_string(), - OptionValue::User(u) => u.to_string(), - OptionValue::Channel(c) => c.to_string(), - OptionValue::Role(r) => r.to_string(), - OptionValue::Mentionable(m) => m.to_string(), - OptionValue::Number(n) => n.to_string(), - } - } -} - -#[derive(Serialize, Deserialize, Clone)] -pub struct CommandOptions { - pub command: String, - pub subcommand: Option, - pub subcommand_group: Option, - pub options: HashMap, -} - -impl CommandOptions { - pub fn get(&self, key: &str) -> Option<&OptionValue> { - self.options.get(key) - } -} - -impl CommandOptions { - fn new(command: &'static Command) -> Self { - Self { - command: command.names[0].to_string(), - subcommand: None, - subcommand_group: None, - options: Default::default(), - } - } - - fn populate(mut self, interaction: &ApplicationCommandInteraction) -> Self { - fn match_option( - option: ApplicationCommandInteractionDataOption, - cmd_opts: &mut CommandOptions, - ) { - match option.kind { - ApplicationCommandOptionType::SubCommand => { - cmd_opts.subcommand = Some(option.name); - - for opt in option.options { - match_option(opt, cmd_opts); - } - } - ApplicationCommandOptionType::SubCommandGroup => { - cmd_opts.subcommand_group = Some(option.name); - - for opt in option.options { - match_option(opt, cmd_opts); - } - } - ApplicationCommandOptionType::String => { - cmd_opts.options.insert( - option.name, - OptionValue::String(option.value.unwrap().as_str().unwrap().to_string()), - ); - } - ApplicationCommandOptionType::Integer => { - cmd_opts.options.insert( - option.name, - OptionValue::Integer(option.value.map(|m| m.as_i64()).flatten().unwrap()), - ); - } - ApplicationCommandOptionType::Boolean => { - cmd_opts.options.insert( - option.name, - OptionValue::Boolean(option.value.map(|m| m.as_bool()).flatten().unwrap()), - ); - } - ApplicationCommandOptionType::User => { - cmd_opts.options.insert( - option.name, - OptionValue::User(UserId( - option - .value - .map(|m| m.as_str().map(|s| s.parse::().ok())) - .flatten() - .flatten() - .unwrap(), - )), - ); - } - ApplicationCommandOptionType::Channel => { - cmd_opts.options.insert( - option.name, - OptionValue::Channel(ChannelId( - option - .value - .map(|m| m.as_str().map(|s| s.parse::().ok())) - .flatten() - .flatten() - .unwrap(), - )), - ); - } - ApplicationCommandOptionType::Role => { - cmd_opts.options.insert( - option.name, - OptionValue::Role(RoleId( - option - .value - .map(|m| m.as_str().map(|s| s.parse::().ok())) - .flatten() - .flatten() - .unwrap(), - )), - ); - } - ApplicationCommandOptionType::Mentionable => { - cmd_opts.options.insert( - option.name, - OptionValue::Mentionable( - option.value.map(|m| m.as_u64()).flatten().unwrap(), - ), - ); - } - ApplicationCommandOptionType::Number => { - cmd_opts.options.insert( - option.name, - OptionValue::Number(option.value.map(|m| m.as_f64()).flatten().unwrap()), - ); - } - _ => {} - } - } - - for option in &interaction.data.options { - match_option(option.clone(), &mut self) - } - - self - } -} - -pub enum HookResult { - Continue, - Halt, -} - -type SlashCommandFn = - for<'fut> fn(&'fut Context, &'fut mut CommandInvoke, CommandOptions) -> BoxFuture<'fut, ()>; - -type MultiCommandFn = for<'fut> fn(&'fut Context, &'fut mut CommandInvoke) -> BoxFuture<'fut, ()>; - -pub type HookFn = for<'fut> fn( - &'fut Context, - &'fut mut CommandInvoke, - &'fut CommandOptions, -) -> BoxFuture<'fut, HookResult>; - -pub enum CommandFnType { - Slash(SlashCommandFn), - Multi(MultiCommandFn), -} - -pub struct Hook { - pub fun: HookFn, - pub uuid: u128, -} - -impl PartialEq for Hook { - fn eq(&self, other: &Self) -> bool { - self.uuid == other.uuid - } -} - -pub struct Command { - pub fun: CommandFnType, - - pub names: &'static [&'static str], - - pub desc: &'static str, - pub examples: &'static [&'static str], - pub group: &'static str, - - pub args: &'static [&'static Arg], - - pub can_blacklist: bool, - pub supports_dm: bool, - - pub hooks: &'static [&'static Hook], -} - -impl Hash for Command { - fn hash(&self, state: &mut H) { - self.names[0].hash(state) - } -} - -impl PartialEq for Command { - fn eq(&self, other: &Self) -> bool { - self.names[0] == other.names[0] - } -} - -impl Eq for Command {} - -pub struct RegexFramework { - pub commands_map: HashMap, - pub commands: HashSet<&'static Command>, - ignore_bots: bool, - dm_enabled: bool, - debug_guild: Option, - hooks: Vec<&'static Hook>, -} - -impl TypeMapKey for RegexFramework { - type Value = Arc; -} - -impl RegexFramework { - pub fn new() -> Self { - Self { - commands_map: HashMap::new(), - commands: HashSet::new(), - ignore_bots: true, - dm_enabled: true, - debug_guild: None, - hooks: vec![], - } - } - - pub fn ignore_bots(mut self, ignore_bots: bool) -> Self { - self.ignore_bots = ignore_bots; - - self - } - - pub fn dm_enabled(mut self, dm_enabled: bool) -> Self { - self.dm_enabled = dm_enabled; - - self - } - - pub fn add_hook(mut self, fun: &'static Hook) -> Self { - self.hooks.push(fun); - - self - } - - pub fn add_command(mut self, command: &'static Command) -> Self { - self.commands.insert(command); - - for name in command.names { - self.commands_map.insert(name.to_string(), command); - } - - self - } - - pub fn debug_guild(mut self, guild_id: Option) -> Self { - self.debug_guild = guild_id; - - self - } - - fn _populate_commands<'a>( - &self, - commands: &'a mut CreateApplicationCommands, - ) -> &'a mut CreateApplicationCommands { - for command in &self.commands { - commands.create_application_command(|c| { - c.name(command.names[0]).description(command.desc); - - for arg in command.args { - c.create_option(|o| { - o.name(arg.name) - .description(arg.description) - .kind(arg.kind) - .required(arg.required); - - for option in arg.options { - o.create_sub_option(|s| { - s.name(option.name) - .description(option.description) - .kind(option.kind) - .required(option.required); - - for sub_option in option.options { - s.create_sub_option(|ss| { - ss.name(sub_option.name) - .description(sub_option.description) - .kind(sub_option.kind) - .required(sub_option.required) - }); - } - - s - }); - } - - o - }); - } - - c - }); - } - - commands - } - - pub async fn build_slash(&self, http: impl AsRef) { - info!("Building slash commands..."); - - match self.debug_guild { - None => { - ApplicationCommand::set_global_application_commands(&http, |c| { - self._populate_commands(c) - }) - .await - .unwrap(); - } - Some(debug_guild) => { - debug_guild - .set_application_commands(&http, |c| self._populate_commands(c)) - .await - .unwrap(); - } - } - - info!("Slash commands built!"); - } - - pub async fn execute(&self, ctx: Context, interaction: ApplicationCommandInteraction) { - { - if let Some(guild_id) = interaction.guild_id { - let pool = ctx.data.read().await.get::().cloned().unwrap(); - let _ = sqlx::query!("INSERT IGNORE INTO guilds (guild) VALUES (?)", guild_id.0) - .execute(&pool) - .await; - } - } - - let command = { - self.commands_map - .get(&interaction.data.name) - .expect(&format!("Received invalid command: {}", interaction.data.name)) - }; - - let args = CommandOptions::new(command).populate(&interaction); - let mut command_invoke = CommandInvoke::slash(interaction); - - for hook in command.hooks { - match (hook.fun)(&ctx, &mut command_invoke, &args).await { - HookResult::Continue => {} - HookResult::Halt => { - return; - } - } - } - - for hook in &self.hooks { - match (hook.fun)(&ctx, &mut command_invoke, &args).await { - HookResult::Continue => {} - HookResult::Halt => { - return; - } - } - } - - match command.fun { - CommandFnType::Slash(t) => t(&ctx, &mut command_invoke, args).await, - CommandFnType::Multi(m) => m(&ctx, &mut command_invoke).await, - } - } - - pub async fn run_command_from_options( - &self, - ctx: &Context, - command_invoke: &mut CommandInvoke, - command_options: CommandOptions, - ) { - let command = { - self.commands_map - .get(&command_options.command) - .expect(&format!("Received invalid command: {}", command_options.command)) - }; - - match command.fun { - CommandFnType::Slash(t) => t(&ctx, command_invoke, command_options).await, - CommandFnType::Multi(m) => m(&ctx, command_invoke).await, - } - } -} diff --git a/src/hooks.rs b/src/hooks.rs index 2b5ccd7..35cd4df 100644 --- a/src/hooks.rs +++ b/src/hooks.rs @@ -1,6 +1,6 @@ use poise::{serenity::model::channel::Channel, ApplicationCommandOrAutocompleteInteraction}; -use crate::{consts::MACRO_MAX_COMMANDS, Context, Error}; +use crate::{consts::MACRO_MAX_COMMANDS, models::command_macro::RecordedCommand, Context, Error}; pub async fn guild_only(ctx: Context<'_>) -> Result { if ctx.guild_id().is_some() { @@ -25,12 +25,18 @@ async fn macro_check(ctx: Context<'_>) -> bool { if command_macro.commands.len() >= MACRO_MAX_COMMANDS { let _ = ctx.send(|m| { m.ephemeral(true).content( - "5 commands already recorded. Please use `/macro finish` to end recording.", + format!("{} commands already recorded. Please use `/macro finish` to end recording.", MACRO_MAX_COMMANDS), ) }) .await; } else { - // TODO TODO TODO write command to macro + let recorded = RecordedCommand { + action: None, + command_name: ctx.command().identifying_name.clone(), + options: Vec::from(app_ctx.args), + }; + + command_macro.commands.push(recorded); let _ = ctx .send(|m| m.ephemeral(true).content("Command recorded to macro")) diff --git a/src/main.rs b/src/main.rs index 89dc26b..44f3fe4 100644 --- a/src/main.rs +++ b/src/main.rs @@ -3,7 +3,7 @@ extern crate lazy_static; mod commands; -// mod component_models; +mod component_models; mod consts; mod event_handlers; mod hooks; @@ -24,7 +24,7 @@ use sqlx::{MySql, Pool}; use tokio::sync::RwLock; use crate::{ - commands::{info_cmds, moderation_cmds}, + commands::{info_cmds, moderation_cmds, reminder_cmds, todo_cmds}, consts::THEME_COLOR, event_handlers::listener, hooks::all_checks, @@ -71,6 +71,43 @@ async fn main() -> Result<(), Box> { ], ..moderation_cmds::macro_base() }, + reminder_cmds::pause(), + reminder_cmds::offset(), + reminder_cmds::nudge(), + reminder_cmds::look(), + reminder_cmds::delete(), + poise::Command { + subcommands: vec![ + reminder_cmds::list_timer(), + reminder_cmds::start_timer(), + reminder_cmds::delete_timer(), + ], + ..reminder_cmds::timer_base() + }, + reminder_cmds::remind(), + poise::Command { + subcommands: vec![ + poise::Command { + subcommands: vec![ + todo_cmds::todo_guild_add(), + todo_cmds::todo_guild_view(), + ], + ..todo_cmds::todo_guild_base() + }, + poise::Command { + subcommands: vec![ + todo_cmds::todo_channel_add(), + todo_cmds::todo_channel_view(), + ], + ..todo_cmds::todo_channel_base() + }, + poise::Command { + subcommands: vec![todo_cmds::todo_user_add(), todo_cmds::todo_user_view()], + ..todo_cmds::todo_user_base() + }, + ], + ..todo_cmds::todo_base() + }, ], allowed_mentions: None, command_check: Some(|ctx| Box::pin(all_checks(ctx))), diff --git a/src/models/command_macro.rs b/src/models/command_macro.rs index 6ad655e..5f7bfb1 100644 --- a/src/models/command_macro.rs +++ b/src/models/command_macro.rs @@ -1,20 +1,29 @@ -use poise::serenity::{ - client::Context, - model::{ - id::GuildId, interactions::application_command::ApplicationCommandInteractionDataOption, - }, +use poise::serenity::model::{ + id::GuildId, interactions::application_command::ApplicationCommandInteractionDataOption, }; -use serde::Serialize; +use serde::{Deserialize, Serialize}; -#[derive(Serialize)] +use crate::{Context, Data, Error}; + +fn default_none() -> Option< + for<'a> fn( + poise::ApplicationContext<'a, U, E>, + ) -> poise::BoxFuture<'a, Result<(), poise::FrameworkError<'a, U, E>>>, +> { + None +} + +#[derive(Serialize, Deserialize)] pub struct RecordedCommand { #[serde(skip)] - action: for<'a> fn( - poise::ApplicationContext<'a, U, E>, - &'a [ApplicationCommandInteractionDataOption], - ) -> poise::BoxFuture<'a, Result<(), poise::FrameworkError<'a, U, E>>>, - command_name: String, - options: Vec, + #[serde(default = "default_none::")] + pub action: Option< + for<'a> fn( + poise::ApplicationContext<'a, U, E>, + ) -> poise::BoxFuture<'a, Result<(), poise::FrameworkError<'a, U, E>>>, + >, + pub command_name: String, + pub options: Vec, } pub struct CommandMacro { @@ -23,3 +32,42 @@ pub struct CommandMacro { pub description: Option, pub commands: Vec>, } + +pub async fn guild_command_macro( + ctx: &Context<'_>, + name: &str, +) -> Option> { + let row = sqlx::query!( + " +SELECT * FROM macro WHERE guild_id = (SELECT id FROM guilds WHERE guild = ?) AND name = ? + ", + ctx.guild_id().unwrap().0, + name + ) + .fetch_one(&ctx.data().database) + .await + .ok()?; + + let mut commands: Vec> = + serde_json::from_str(&row.commands).unwrap(); + + for recorded_command in &mut commands { + let command = &ctx + .framework() + .options() + .commands + .iter() + .find(|c| c.identifying_name == recorded_command.command_name); + + recorded_command.action = command.map(|c| c.slash_action).flatten().clone(); + } + + let command_macro = CommandMacro { + guild_id: ctx.guild_id().unwrap(), + name: row.name, + description: row.description, + commands, + }; + + Some(command_macro) +} diff --git a/src/models/mod.rs b/src/models/mod.rs index cd33af0..63c19a1 100644 --- a/src/models/mod.rs +++ b/src/models/mod.rs @@ -9,21 +9,20 @@ use poise::serenity::{async_trait, model::id::UserId}; use crate::{ models::{channel_data::ChannelData, user_data::UserData}, - Context, + CommandMacro, Context, Data, Error, }; #[async_trait] pub trait CtxData { - async fn user_data + Send>( - &self, - user_id: U, - ) -> Result>; + async fn user_data + Send>(&self, user_id: U) -> Result; - async fn author_data(&self) -> Result>; + async fn author_data(&self) -> Result; async fn timezone(&self) -> Tz; - async fn channel_data(&self) -> Result>; + async fn channel_data(&self) -> Result; + + async fn command_macros(&self) -> Result>, Error>; } #[async_trait] @@ -48,4 +47,22 @@ impl CtxData for Context<'_> { ChannelData::from_channel(&channel, &self.data().database).await } + + async fn command_macros(&self) -> Result>, Error> { + let guild_id = self.guild_id().unwrap(); + + let rows = sqlx::query!( + "SELECT name, description FROM macro WHERE guild_id = (SELECT id FROM guilds WHERE guild = ?)", + guild_id.0 + ) + .fetch_all(&self.data().database) + .await?.iter().map(|row| CommandMacro { + guild_id, + name: row.name.clone(), + description: row.description.clone(), + commands: vec![] + }).collect(); + + Ok(rows) + } } From 06c4deeaa91fdc05c12510ad3c583feec9348815 Mon Sep 17 00:00:00 2001 From: jude Date: Sat, 19 Feb 2022 22:11:21 +0000 Subject: [PATCH 03/27] component models --- postman/src/lib.rs | 10 +-- postman/src/sender.rs | 3 +- src/commands/info_cmds.rs | 87 +++++++++++++-------- src/commands/reminder_cmds.rs | 8 +- src/commands/todo_cmds.rs | 5 +- src/component_models/mod.rs | 121 ++++++++++++++++++------------ src/event_handlers.rs | 28 ++----- src/hooks.rs | 46 +++++------- src/main.rs | 1 + src/models/mod.rs | 13 +++- src/models/reminder/mod.rs | 22 +++--- src/utils.rs | 48 +++++++++++- web/src/lib.rs | 12 ++- web/src/routes/dashboard/guild.rs | 13 ++-- web/src/routes/dashboard/user.rs | 36 ++++----- 15 files changed, 269 insertions(+), 184 deletions(-) diff --git a/postman/src/lib.rs b/postman/src/lib.rs index ed1b852..1de374b 100644 --- a/postman/src/lib.rs +++ b/postman/src/lib.rs @@ -1,23 +1,23 @@ mod sender; +use std::env; + use log::info; use serenity::client::Context; use sqlx::{Executor, MySql}; -use std::env; -use tokio::time::sleep_until; -use tokio::time::{Duration, Instant}; +use tokio::time::{sleep_until, Duration, Instant}; type Database = MySql; pub async fn initialize(ctx: Context, pool: impl Executor<'_, Database = Database> + Copy) { - let REMIND_INTERVAL = env::var("REMIND_INTERVAL") + let remind_interval = env::var("REMIND_INTERVAL") .map(|inner| inner.parse::().ok()) .ok() .flatten() .unwrap_or(10); loop { - let sleep_to = Instant::now() + Duration::from_secs(REMIND_INTERVAL); + let sleep_to = Instant::now() + Duration::from_secs(remind_interval); let reminders = sender::Reminder::fetch_reminders(pool).await; if reminders.len() > 0 { diff --git a/postman/src/sender.rs b/postman/src/sender.rs index 140a14d..fd17451 100644 --- a/postman/src/sender.rs +++ b/postman/src/sender.rs @@ -1,4 +1,3 @@ -use crate::Database; use chrono::Duration; use chrono_tz::Tz; use lazy_static::lazy_static; @@ -20,6 +19,8 @@ use sqlx::{ Executor, }; +use crate::Database; + lazy_static! { pub static ref TIMEFROM_REGEX: Regex = Regex::new(r#"<\d+):(?P.+)?>>"#).unwrap(); diff --git a/src/commands/info_cmds.rs b/src/commands/info_cmds.rs index 3376f64..fb8184c 100644 --- a/src/commands/info_cmds.rs +++ b/src/commands/info_cmds.rs @@ -1,9 +1,11 @@ use chrono::offset::Utc; -use poise::serenity::builder::CreateEmbedFooter; +use poise::{serenity_prelude as serenity, serenity_prelude::Mentionable}; use crate::{models::CtxData, Context, Error, THEME_COLOR}; -fn footer(ctx: Context<'_>) -> impl FnOnce(&mut CreateEmbedFooter) -> &mut CreateEmbedFooter { +fn footer( + ctx: Context<'_>, +) -> impl FnOnce(&mut serenity::CreateEmbedFooter) -> &mut serenity::CreateEmbedFooter { let shard_count = ctx.discord().cache.shard_count(); let shard = ctx.discord().shard_id; @@ -22,13 +24,12 @@ fn footer(ctx: Context<'_>) -> impl FnOnce(&mut CreateEmbedFooter) -> &mut Creat pub async fn help(ctx: Context<'_>) -> Result<(), Error> { let footer = footer(ctx); - let _ = ctx - .send(|m| { - m.embed(|e| { - e.title("Help") - .color(*THEME_COLOR) - .description( - "__Info Commands__ + ctx.send(|m| { + m.ephemeral(true).embed(|e| { + e.title("Help") + .color(*THEME_COLOR) + .description( + "__Info Commands__ `/help` `/info` `/donate` `/dashboard` `/clock` *run these commands with no options* @@ -52,11 +53,11 @@ __Setup Commands__ __Advanced Commands__ `/macro` - Record and replay command sequences ", - ) - .footer(footer) - }) + ) + .footer(footer) }) - .await; + }) + .await?; Ok(()) } @@ -68,7 +69,7 @@ pub async fn info(ctx: Context<'_>) -> Result<(), Error> { let _ = ctx .send(|m| { - m.embed(|e| { + m.ephemeral(true).embed(|e| { e.title("Info") .description(format!( "Help: `/help` @@ -95,9 +96,10 @@ Use our dashboard: https://reminder-bot.com/", pub async fn donate(ctx: Context<'_>) -> Result<(), Error> { let footer = footer(ctx); - let _ = ctx.send(|m| m.embed(|e| { - e.title("Donate") - .description("Thinking of adding a monthly contribution? Click below for my Patreon and official bot server :) + ctx.send(|m| m.embed(|e| { + e.title("Donate") + .description("Thinking of adding a monthly contribution? +Click below for my Patreon and official bot server :) **https://www.patreon.com/jellywx/** **https://discord.jellywx.com/** @@ -112,11 +114,11 @@ With your new rank, you'll be able to: Just $2 USD/month! *Please note, you must be in the JellyWX Discord server to receive Patreon features*") - .footer(footer) - .color(*THEME_COLOR) - }), - ) - .await; + .footer(footer) + .color(*THEME_COLOR) + }), + ) + .await?; Ok(()) } @@ -126,21 +128,20 @@ Just $2 USD/month! pub async fn dashboard(ctx: Context<'_>) -> Result<(), Error> { let footer = footer(ctx); - let _ = ctx - .send(|m| { - m.embed(|e| { - e.title("Dashboard") - .description("**https://reminder-bot.com/dashboard**") - .footer(footer) - .color(*THEME_COLOR) - }) + ctx.send(|m| { + m.ephemeral(true).embed(|e| { + e.title("Dashboard") + .description("**https://reminder-bot.com/dashboard**") + .footer(footer) + .color(*THEME_COLOR) }) - .await; + }) + .await?; Ok(()) } -/// View the current time in a user's selected timezone +/// View the current time in your selected timezone #[poise::command(slash_command)] pub async fn clock(ctx: Context<'_>) -> Result<(), Error> { ctx.defer_ephemeral().await?; @@ -155,3 +156,25 @@ pub async fn clock(ctx: Context<'_>) -> Result<(), Error> { Ok(()) } + +/// View the current time in a user's selected timezone +#[poise::command(context_menu_command = "View Local Time")] +pub async fn clock_context_menu(ctx: Context<'_>, user: serenity::User) -> Result<(), Error> { + ctx.defer_ephemeral().await?; + + let user_data = ctx.user_data(user.id).await?; + let tz = user_data.timezone(); + + let now = Utc::now().with_timezone(&tz); + + ctx.send(|m| { + m.ephemeral(true).content(format!( + "Time in {}'s timezone: `{}`", + user.mention(), + now.format("%H:%M") + )) + }) + .await?; + + Ok(()) +} diff --git a/src/commands/reminder_cmds.rs b/src/commands/reminder_cmds.rs index aca114d..007dcab 100644 --- a/src/commands/reminder_cmds.rs +++ b/src/commands/reminder_cmds.rs @@ -9,7 +9,6 @@ use chrono_tz::Tz; use num_integer::Integer; use poise::{ serenity::{builder::CreateEmbed, model::channel::Channel}, - serenity_prelude::ActionRole::Create, CreateReply, }; @@ -32,7 +31,6 @@ use crate::{ Reminder, }, timer::Timer, - user_data::UserData, CtxData, }, time_parser::natural_parser, @@ -212,7 +210,7 @@ pub async fn look( None }; - let reminders = Reminder::from_channel(&ctx, channel_id, &flags).await; + let reminders = Reminder::from_channel(&ctx.data().database, channel_id, &flags).await; if reminders.is_empty() { let _ = ctx.say("No reminders on specified channel").await; @@ -266,7 +264,9 @@ pub async fn look( pub async fn delete(ctx: Context<'_>) -> Result<(), Error> { let timezone = ctx.timezone().await; - let reminders = Reminder::from_guild(&ctx, ctx.guild_id(), ctx.author().id).await; + let reminders = + Reminder::from_guild(&ctx.discord(), &ctx.data().database, ctx.guild_id(), ctx.author().id) + .await; let resp = show_delete_page(&reminders, 0, timezone); diff --git a/src/commands/todo_cmds.rs b/src/commands/todo_cmds.rs index e79113c..39bf319 100644 --- a/src/commands/todo_cmds.rs +++ b/src/commands/todo_cmds.rs @@ -6,6 +6,7 @@ use crate::{ ComponentDataModel, TodoSelector, }, consts::{EMBED_DESCRIPTION_MAX_LENGTH, SELECT_MAX_ENTRIES, THEME_COLOR}, + hooks::guild_only, Context, Error, }; @@ -16,7 +17,7 @@ pub async fn todo_base(_ctx: Context<'_>) -> Result<(), Error> { } /// Manage the server todo list -#[poise::command(slash_command, rename = "server")] +#[poise::command(slash_command, rename = "server", check = "guild_only")] pub async fn todo_guild_base(_ctx: Context<'_>) -> Result<(), Error> { Ok(()) } @@ -70,7 +71,7 @@ WHERE guilds.guild = ?", } /// Manage the channel todo list -#[poise::command(slash_command, rename = "channel")] +#[poise::command(slash_command, rename = "channel", check = "guild_only")] pub async fn todo_channel_base(_ctx: Context<'_>) -> Result<(), Error> { Ok(()) } diff --git a/src/component_models/mod.rs b/src/component_models/mod.rs index 82fc879..a84cab8 100644 --- a/src/component_models/mod.rs +++ b/src/component_models/mod.rs @@ -5,6 +5,7 @@ use std::io::Cursor; use chrono_tz::Tz; use poise::serenity::{ builder::CreateEmbed, + client::Context, model::{ channel::Channel, interactions::{message_component::MessageComponentInteraction, InteractionResponseType}, @@ -22,8 +23,9 @@ use crate::{ }, component_models::pager::{DelPager, LookPager, MacroPager, Pager, TodoPager}, consts::{EMBED_DESCRIPTION_MAX_LENGTH, THEME_COLOR}, - models::{reminder::Reminder, CtxData}, - Context, Data, + models::reminder::Reminder, + utils::send_as_initial_response, + Data, }; #[derive(Deserialize, Serialize)] @@ -53,12 +55,12 @@ impl ComponentDataModel { rmp_serde::from_read(cur).unwrap() } - pub async fn act(&self, ctx: Context<'_>, component: &MessageComponentInteraction) { + pub async fn act(&self, ctx: &Context, data: &Data, component: &MessageComponentInteraction) { match self { ComponentDataModel::LookPager(pager) => { let flags = pager.flags; - let channel_opt = component.channel_id.to_channel_cached(&ctx.discord()); + let channel_opt = component.channel_id.to_channel_cached(&ctx); let channel_id = if let Some(Channel::Guild(channel)) = channel_opt { if Some(channel.guild_id) == component.guild_id { @@ -70,7 +72,7 @@ impl ComponentDataModel { component.channel_id }; - let reminders = Reminder::from_channel(&ctx, channel_id, &flags).await; + let reminders = Reminder::from_channel(&data.database, channel_id, &flags).await; let pages = reminders .iter() @@ -78,13 +80,12 @@ impl ComponentDataModel { .fold(0, |t, r| t + r.len()) .div_ceil(EMBED_DESCRIPTION_MAX_LENGTH); - let channel_name = if let Some(Channel::Guild(channel)) = - channel_id.to_channel_cached(&ctx.discord()) - { - Some(channel.name) - } else { - None - }; + let channel_name = + if let Some(Channel::Guild(channel)) = channel_id.to_channel_cached(&ctx) { + Some(channel.name) + } else { + None + }; let next_page = pager.next_page(pages); @@ -118,7 +119,7 @@ impl ComponentDataModel { .color(*THEME_COLOR); let _ = component - .create_interaction_response(&ctx.discord(), |r| { + .create_interaction_response(&ctx, |r| { r.kind(InteractionResponseType::UpdateMessage).interaction_response_data( |response| { response.embeds(vec![embed]).components(|comp| { @@ -132,17 +133,26 @@ impl ComponentDataModel { .await; } ComponentDataModel::DelPager(pager) => { - let reminders = - Reminder::from_guild(&ctx, component.guild_id, component.user.id).await; + let reminders = Reminder::from_guild( + &ctx, + &data.database, + component.guild_id, + component.user.id, + ) + .await; let max_pages = max_delete_page(&reminders, &pager.timezone); let resp = show_delete_page(&reminders, pager.next_page(max_pages), pager.timezone); - let _ = ctx - .send(|r| { - *r = resp; - r + let _ = component + .create_interaction_response(&ctx, |f| { + f.kind(InteractionResponseType::UpdateMessage).interaction_response_data( + |d| { + send_as_initial_response(resp, d); + d + }, + ) }) .await; } @@ -150,19 +160,28 @@ impl ComponentDataModel { let selected_id = component.data.values.join(","); sqlx::query!("DELETE FROM reminders WHERE FIND_IN_SET(id, ?)", selected_id) - .execute(&ctx.data().database) + .execute(&data.database) .await .unwrap(); - let reminders = - Reminder::from_guild(&ctx, component.guild_id, component.user.id).await; + let reminders = Reminder::from_guild( + &ctx, + &data.database, + component.guild_id, + component.user.id, + ) + .await; let resp = show_delete_page(&reminders, selector.page, selector.timezone); - let _ = ctx - .send(|r| { - *r = resp; - r + let _ = component + .create_interaction_response(&ctx, |f| { + f.kind(InteractionResponseType::UpdateMessage).interaction_response_data( + |d| { + send_as_initial_response(resp, d); + d + }, + ) }) .await; } @@ -175,7 +194,7 @@ INNER JOIN users ON todos.user_id = users.id WHERE users.user = ?", uid, ) - .fetch_all(&ctx.data().database) + .fetch_all(&data.database) .await .unwrap() .iter() @@ -188,7 +207,7 @@ INNER JOIN channels ON todos.channel_id = channels.id WHERE channels.channel = ?", cid, ) - .fetch_all(&ctx.data().database) + .fetch_all(&data.database) .await .unwrap() .iter() @@ -201,7 +220,7 @@ INNER JOIN guilds ON todos.guild_id = guilds.id WHERE guilds.guild = ?", pager.guild_id, ) - .fetch_all(&ctx.data().database) + .fetch_all(&data.database) .await .unwrap() .iter() @@ -219,15 +238,18 @@ WHERE guilds.guild = ?", pager.guild_id, ); - let _ = ctx - .send(|r| { - *r = resp; - r + let _ = component + .create_interaction_response(&ctx, |f| { + f.kind(InteractionResponseType::UpdateMessage) + .interaction_response_data(|d| { + send_as_initial_response(resp, d); + d + }) }) .await; } else { let _ = component - .create_interaction_response(&ctx.discord(), |r| { + .create_interaction_response(&ctx, |r| { r.kind(InteractionResponseType::ChannelMessageWithSource) .interaction_response_data(|d| { d.flags( @@ -244,7 +266,7 @@ WHERE guilds.guild = ?", let selected_id = component.data.values.join(","); sqlx::query!("DELETE FROM todos WHERE FIND_IN_SET(id, ?)", selected_id) - .execute(&ctx.data().database) + .execute(&data.database) .await .unwrap(); @@ -255,7 +277,7 @@ WHERE guilds.guild = ?", selector.channel_id, selector.guild_id, ) - .fetch_all(&ctx.data().database) + .fetch_all(&data.database) .await .unwrap() .iter() @@ -270,15 +292,18 @@ WHERE guilds.guild = ?", selector.guild_id, ); - let _ = ctx - .send(|r| { - *r = resp; - r + let _ = component + .create_interaction_response(&ctx, |f| { + f.kind(InteractionResponseType::UpdateMessage) + .interaction_response_data(|d| { + send_as_initial_response(resp, d); + d + }) }) .await; } else { let _ = component - .create_interaction_response(&ctx.discord(), |r| { + .create_interaction_response(&ctx, |r| { r.kind(InteractionResponseType::ChannelMessageWithSource) .interaction_response_data(|d| { d.flags( @@ -291,17 +316,21 @@ WHERE guilds.guild = ?", } } ComponentDataModel::MacroPager(pager) => { - let macros = ctx.command_macros().await.unwrap(); + let macros = data.command_macros(component.guild_id.unwrap()).await.unwrap(); let max_page = max_macro_page(¯os); let page = pager.next_page(max_page); let resp = show_macro_page(¯os, page); - let _ = ctx - .send(|r| { - *r = resp; - r + let _ = component + .create_interaction_response(&ctx, |f| { + f.kind(InteractionResponseType::UpdateMessage).interaction_response_data( + |d| { + send_as_initial_response(resp, d); + d + }, + ) }) .await; } diff --git a/src/event_handlers.rs b/src/event_handlers.rs index bc7e014..b5c6ba1 100644 --- a/src/event_handlers.rs +++ b/src/event_handlers.rs @@ -1,21 +1,12 @@ -use std::{ - collections::HashMap, - env, - sync::atomic::{AtomicBool, Ordering}, -}; +use std::{collections::HashMap, env, sync::atomic::Ordering}; use log::{info, warn}; use poise::{ serenity::{model::interactions::Interaction, utils::shard_id}, serenity_prelude as serenity, - serenity_prelude::{ - ApplicationCommandInteraction, ApplicationCommandInteractionData, ApplicationCommandType, - InteractionType, - }, - ApplicationCommandOrAutocompleteInteraction, ApplicationContext, Command, }; -use crate::{component_models::ComponentDataModel, Context, Data, Error}; +use crate::{component_models::ComponentDataModel, Data, Error}; pub async fn listener( ctx: &serenity::Context, @@ -56,15 +47,10 @@ pub async fn listener( } } poise::Event::ChannelDelete { channel } => { - sqlx::query!( - " -DELETE FROM channels WHERE channel = ? - ", - channel.id.as_u64() - ) - .execute(&data.database) - .await - .unwrap(); + sqlx::query!("DELETE FROM channels WHERE channel = ?", channel.id.as_u64()) + .execute(&data.database) + .await + .unwrap(); } poise::Event::GuildCreate { guild, is_new } => { if *is_new { @@ -122,7 +108,7 @@ DELETE FROM channels WHERE channel = ? Interaction::MessageComponent(component) => { let component_model = ComponentDataModel::from_custom_id(&component.data.custom_id); - // component_model.act(ctx, component).await; + component_model.act(ctx, data, component).await; } _ => {} }, diff --git a/src/hooks.rs b/src/hooks.rs index 35cd4df..612c3ec 100644 --- a/src/hooks.rs +++ b/src/hooks.rs @@ -1,4 +1,4 @@ -use poise::{serenity::model::channel::Channel, ApplicationCommandOrAutocompleteInteraction}; +use poise::serenity::model::channel::Channel; use crate::{consts::MACRO_MAX_COMMANDS, models::command_macro::RecordedCommand, Context, Error}; @@ -14,39 +14,33 @@ pub async fn guild_only(ctx: Context<'_>) -> Result { async fn macro_check(ctx: Context<'_>) -> bool { if let Context::Application(app_ctx) = ctx { - if let ApplicationCommandOrAutocompleteInteraction::ApplicationCommand(interaction) = - app_ctx.interaction - { - if let Some(guild_id) = ctx.guild_id() { - if ctx.command().identifying_name != "macro_finish" { - let mut lock = ctx.data().recording_macros.write().await; + if let Some(guild_id) = ctx.guild_id() { + if ctx.command().identifying_name != "macro_finish" { + let mut lock = ctx.data().recording_macros.write().await; - if let Some(command_macro) = lock.get_mut(&(guild_id, ctx.author().id)) { - if command_macro.commands.len() >= MACRO_MAX_COMMANDS { - let _ = ctx.send(|m| { + if let Some(command_macro) = lock.get_mut(&(guild_id, ctx.author().id)) { + if command_macro.commands.len() >= MACRO_MAX_COMMANDS { + let _ = ctx.send(|m| { m.ephemeral(true).content( format!("{} commands already recorded. Please use `/macro finish` to end recording.", MACRO_MAX_COMMANDS), ) }) .await; - } else { - let recorded = RecordedCommand { - action: None, - command_name: ctx.command().identifying_name.clone(), - options: Vec::from(app_ctx.args), - }; - - command_macro.commands.push(recorded); - - let _ = ctx - .send(|m| m.ephemeral(true).content("Command recorded to macro")) - .await; - } - - false } else { - true + let recorded = RecordedCommand { + action: None, + command_name: ctx.command().identifying_name.clone(), + options: Vec::from(app_ctx.args), + }; + + command_macro.commands.push(recorded); + + let _ = ctx + .send(|m| m.ephemeral(true).content("Command recorded to macro")) + .await; } + + false } else { true } diff --git a/src/main.rs b/src/main.rs index 44f3fe4..33299ff 100644 --- a/src/main.rs +++ b/src/main.rs @@ -59,6 +59,7 @@ async fn main() -> Result<(), Box> { info_cmds::info(), info_cmds::donate(), info_cmds::clock(), + info_cmds::clock_context_menu(), info_cmds::dashboard(), moderation_cmds::timezone(), poise::Command { diff --git a/src/models/mod.rs b/src/models/mod.rs index 63c19a1..14acbdd 100644 --- a/src/models/mod.rs +++ b/src/models/mod.rs @@ -9,7 +9,7 @@ use poise::serenity::{async_trait, model::id::UserId}; use crate::{ models::{channel_data::ChannelData, user_data::UserData}, - CommandMacro, Context, Data, Error, + CommandMacro, Context, Data, Error, GuildId, }; #[async_trait] @@ -49,13 +49,20 @@ impl CtxData for Context<'_> { } async fn command_macros(&self) -> Result>, Error> { - let guild_id = self.guild_id().unwrap(); + self.data().command_macros(self.guild_id().unwrap()).await + } +} +impl Data { + pub(crate) async fn command_macros( + &self, + guild_id: GuildId, + ) -> Result>, Error> { let rows = sqlx::query!( "SELECT name, description FROM macro WHERE guild_id = (SELECT id FROM guilds WHERE guild = ?)", guild_id.0 ) - .fetch_all(&self.data().database) + .fetch_all(&self.database) .await?.iter().map(|row| CommandMacro { guild_id, name: row.name.clone(), diff --git a/src/models/reminder/mod.rs b/src/models/reminder/mod.rs index 6b46a74..f50203d 100644 --- a/src/models/reminder/mod.rs +++ b/src/models/reminder/mod.rs @@ -6,12 +6,15 @@ pub mod look_flags; use chrono::{NaiveDateTime, TimeZone}; use chrono_tz::Tz; -use poise::serenity::model::id::{ChannelId, GuildId, UserId}; +use poise::{ + serenity::model::id::{ChannelId, GuildId, UserId}, + serenity_prelude::Cache, +}; use sqlx::Executor; use crate::{ models::reminder::look_flags::{LookFlags, TimeDisplayType}, - Context, Data, Database, + Database, }; #[derive(Debug, Clone)] @@ -70,7 +73,7 @@ WHERE } pub async fn from_channel>( - ctx: &Context<'_>, + pool: impl Executor<'_, Database = Database>, channel_id: C, flags: &LookFlags, ) -> Vec { @@ -111,18 +114,19 @@ ORDER BY channel_id.as_u64(), enabled, ) - .fetch_all(&ctx.data().database) + .fetch_all(pool) .await .unwrap() } pub async fn from_guild( - ctx: &Context<'_>, + cache: impl AsRef, + pool: impl Executor<'_, Database = Database>, guild_id: Option, user: UserId, ) -> Vec { if let Some(guild_id) = guild_id { - let guild_opt = guild_id.to_guild_cached(&ctx.discord()); + let guild_opt = guild_id.to_guild_cached(cache); if let Some(guild) = guild_opt { let channels = guild @@ -163,7 +167,7 @@ WHERE ", channels ) - .fetch_all(&ctx.data().database) + .fetch_all(pool) .await } else { sqlx::query_as_unchecked!( @@ -196,7 +200,7 @@ WHERE ", guild_id.as_u64() ) - .fetch_all(&ctx.data().database) + .fetch_all(pool) .await } } else { @@ -230,7 +234,7 @@ WHERE ", user.as_u64() ) - .fetch_all(&ctx.data().database) + .fetch_all(pool) .await } .unwrap() diff --git a/src/utils.rs b/src/utils.rs index e11f47c..ca2c11f 100644 --- a/src/utils.rs +++ b/src/utils.rs @@ -1,7 +1,10 @@ -use poise::serenity::{ - builder::CreateApplicationCommands, - http::CacheHttp, - model::id::{GuildId, UserId}, +use poise::{ + serenity::{ + builder::CreateApplicationCommands, + http::CacheHttp, + model::id::{GuildId, UserId}, + }, + serenity_prelude as serenity, }; use crate::{ @@ -65,3 +68,40 @@ pub async fn check_guild_subscription( false } } + +/// Sends the message, specified via [`crate::CreateReply`], to the interaction initial response +/// endpoint +pub fn send_as_initial_response( + data: poise::CreateReply<'_>, + f: &mut serenity::CreateInteractionResponseData, +) { + let poise::CreateReply { + content, + embeds, + attachments: _, // serenity doesn't support attachments in initial response yet + components, + ephemeral, + allowed_mentions, + reference_message: _, // can't reply to a message in interactions + } = data; + + if let Some(content) = content { + f.content(content); + } + f.embeds(embeds); + if let Some(allowed_mentions) = allowed_mentions { + f.allowed_mentions(|f| { + *f = allowed_mentions.clone(); + f + }); + } + if let Some(components) = components { + f.components(|f| { + f.0 = components.0; + f + }); + } + if ephemeral { + f.flags(serenity::InteractionApplicationCommandCallbackDataFlags::EPHEMERAL); + } +} diff --git a/web/src/lib.rs b/web/src/lib.rs index 21c6530..ed3ce7f 100644 --- a/web/src/lib.rs +++ b/web/src/lib.rs @@ -4,17 +4,15 @@ extern crate rocket; mod consts; mod routes; -use rocket::fs::{relative, FileServer}; -use std::collections::HashMap; +use std::{collections::HashMap, env}; -use oauth2::basic::BasicClient; -use oauth2::{AuthUrl, ClientId, ClientSecret, RedirectUrl, TokenUrl}; - -use crate::consts::{DISCORD_OAUTH_AUTHORIZE, DISCORD_OAUTH_TOKEN}; +use oauth2::{basic::BasicClient, AuthUrl, ClientId, ClientSecret, RedirectUrl, TokenUrl}; +use rocket::fs::FileServer; use rocket_dyn_templates::Template; use serenity::client::Context; use sqlx::{MySql, Pool}; -use std::env; + +use crate::consts::{DISCORD_OAUTH_AUTHORIZE, DISCORD_OAUTH_TOKEN}; type Database = MySql; diff --git a/web/src/routes/dashboard/guild.rs b/web/src/routes/dashboard/guild.rs index 81fc9ca..55f40e9 100644 --- a/web/src/routes/dashboard/guild.rs +++ b/web/src/routes/dashboard/guild.rs @@ -1,14 +1,13 @@ -use rocket::State; - -use crate::consts::DISCORD_CDN; +use rocket::{ + serde::json::{json, Json, Value as JsonValue}, + State, +}; use serde::Serialize; +use serenity::{client::Context, model::id::GuildId}; use sqlx::{MySql, Pool}; use super::Reminder; -use rocket::serde::json::{json, Json, Value as JsonValue}; -use serenity::client::Context; -use serenity::http::CacheHttp; -use serenity::model::id::GuildId; +use crate::consts::DISCORD_CDN; #[derive(Serialize)] struct ChannelInfo { diff --git a/web/src/routes/dashboard/user.rs b/web/src/routes/dashboard/user.rs index a6521a4..1d64efc 100644 --- a/web/src/routes/dashboard/user.rs +++ b/web/src/routes/dashboard/user.rs @@ -1,22 +1,24 @@ -use rocket::serde::json::{json, Json, Value as JsonValue}; -use rocket::{http::CookieJar, State}; - -use reqwest::Client; - -use serde::{Deserialize, Serialize}; -use serenity::model::{ - id::{GuildId, RoleId}, - permissions::Permissions, -}; -use sqlx::{MySql, Pool}; use std::env; -use super::Reminder; -use crate::consts::DISCORD_API; -use crate::routes::dashboard::DeleteReminder; use chrono_tz::Tz; -use serenity::client::Context; -use serenity::model::id::UserId; +use reqwest::Client; +use rocket::{ + http::CookieJar, + serde::json::{json, Json, Value as JsonValue}, + State, +}; +use serde::{Deserialize, Serialize}; +use serenity::{ + client::Context, + model::{ + id::{GuildId, RoleId, UserId}, + permissions::Permissions, + }, +}; +use sqlx::{MySql, Pool}; + +use super::Reminder; +use crate::{consts::DISCORD_API, routes::dashboard::DeleteReminder}; #[derive(Serialize)] struct UserInfo { @@ -166,7 +168,7 @@ pub async fn get_user_guilds(cookies: &CookieJar<'_>, reqwest_client: &State, - ctx: &State, + _ctx: &State, pool: &State>, ) -> JsonValue { match sqlx::query!( From 6ae2353c927d80e734d03b2c464ddc6926407ae2 Mon Sep 17 00:00:00 2001 From: jude Date: Sun, 20 Feb 2022 12:19:39 +0000 Subject: [PATCH 04/27] add distinct identifying names. log errors in run_macro --- Cargo.lock | 2 -- Cargo.toml | 3 +- src/commands/moderation_cmds.rs | 51 ++++++++++++++++++++++++++------- src/commands/reminder_cmds.rs | 22 +++++++------- src/commands/todo_cmds.rs | 50 +++++++++++++++++++++++++------- src/hooks.rs | 2 +- src/main.rs | 12 +++++--- 7 files changed, 102 insertions(+), 40 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index aa5f7e1..318b043 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1886,7 +1886,6 @@ checksum = "58893f751c9b0412871a09abd62ecd2a00298c6c83befa223ef98c52aef40cbe" [[package]] name = "poise" version = "0.1.0" -source = "git+https://github.com/kangalioo/poise?branch=master#38bcca284cbc9fb52cd770d7af64fbd4b3495cc8" dependencies = [ "async-trait", "futures-core", @@ -1901,7 +1900,6 @@ dependencies = [ [[package]] name = "poise_macros" version = "0.1.0" -source = "git+https://github.com/kangalioo/poise?branch=master#38bcca284cbc9fb52cd770d7af64fbd4b3495cc8" dependencies = [ "darling", "proc-macro2", diff --git a/Cargo.toml b/Cargo.toml index 15969dc..0104bb2 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -5,7 +5,8 @@ authors = ["jellywx "] edition = "2018" [dependencies] -poise = { git = "https://github.com/kangalioo/poise", branch = "master" } +#poise = { git = "https://github.com/kangalioo/poise", branch = "master" } +poise = { path = "/home/jude/poise" } dotenv = "0.15" tokio = { version = "1", features = ["process", "full"] } reqwest = "0.11" diff --git a/src/commands/moderation_cmds.rs b/src/commands/moderation_cmds.rs index e1b6939..329daa8 100644 --- a/src/commands/moderation_cmds.rs +++ b/src/commands/moderation_cmds.rs @@ -28,7 +28,7 @@ async fn timezone_autocomplete(ctx: Context<'_>, partial: String) -> Vec } /// Select your timezone -#[poise::command(slash_command)] +#[poise::command(slash_command, identifying_name = "timezone")] pub async fn timezone( ctx: Context<'_>, #[description = "Timezone to use from this list: https://gist.github.com/JellyWX/913dfc8b63d45192ad6cb54c829324ee"] @@ -150,13 +150,23 @@ WHERE } /// Record and replay command sequences -#[poise::command(slash_command, rename = "macro", check = "guild_only")] +#[poise::command( + slash_command, + rename = "macro", + check = "guild_only", + identifying_name = "macro_base" +)] pub async fn macro_base(_ctx: Context<'_>) -> Result<(), Error> { Ok(()) } /// Start recording up to 5 commands to replay -#[poise::command(slash_command, rename = "record", check = "guild_only")] +#[poise::command( + slash_command, + rename = "record", + check = "guild_only", + identifying_name = "record_macro" +)] pub async fn record_macro( ctx: Context<'_>, #[description = "Name for the new macro"] name: String, @@ -235,7 +245,7 @@ Please use `/macro finish` to end this recording before starting another.", slash_command, rename = "finish", check = "guild_only", - identifying_name = "macro_finish" + identifying_name = "finish_macro" )] pub async fn finish_macro(ctx: Context<'_>) -> Result<(), Error> { let key = (ctx.guild_id().unwrap(), ctx.author().id); @@ -288,7 +298,12 @@ pub async fn finish_macro(ctx: Context<'_>) -> Result<(), Error> { } /// List recorded macros -#[poise::command(slash_command, rename = "list", check = "guild_only")] +#[poise::command( + slash_command, + rename = "list", + check = "guild_only", + identifying_name = "list_macro" +)] pub async fn list_macro(ctx: Context<'_>) -> Result<(), Error> { let macros = ctx.command_macros().await?; @@ -304,7 +319,12 @@ pub async fn list_macro(ctx: Context<'_>) -> Result<(), Error> { } /// Run a recorded macro -#[poise::command(slash_command, rename = "run", check = "guild_only")] +#[poise::command( + slash_command, + rename = "run", + check = "guild_only", + identifying_name = "run_macro" +)] pub async fn run_macro( ctx: poise::ApplicationContext<'_, Data, Error>, #[description = "Name of macro to run"] @@ -317,13 +337,17 @@ pub async fn run_macro( for command in command_macro.commands { if let Some(action) = command.action { - (action)(poise::ApplicationContext { args: &command.options, ..ctx }) + match (action)(poise::ApplicationContext { args: &command.options, ..ctx }) .await - .ok() - .unwrap(); + { + Ok(()) => {} + Err(e) => { + println!("{:?}", e); + } + } } else { Context::Application(ctx) - .say(format!("Command \"{}\" failed to execute", command.command_name)) + .say(format!("Command \"{}\" not found", command.command_name)) .await?; } } @@ -338,7 +362,12 @@ pub async fn run_macro( } /// Delete a recorded macro -#[poise::command(slash_command, rename = "delete", check = "guild_only")] +#[poise::command( + slash_command, + rename = "delete", + check = "guild_only", + identifying_name = "delete_macro" +)] pub async fn delete_macro( ctx: Context<'_>, #[description = "Name of macro to delete"] diff --git a/src/commands/reminder_cmds.rs b/src/commands/reminder_cmds.rs index 007dcab..2e3fc3f 100644 --- a/src/commands/reminder_cmds.rs +++ b/src/commands/reminder_cmds.rs @@ -39,7 +39,7 @@ use crate::{ }; /// Pause all reminders on the current channel until a certain time or indefinitely -#[poise::command(slash_command)] +#[poise::command(slash_command, identifying_name = "pause")] pub async fn pause( ctx: Context<'_>, #[description = "When to pause until"] until: Option, @@ -90,7 +90,7 @@ pub async fn pause( } /// Move all reminders in the current server by a certain amount of time. Times get added together -#[poise::command(slash_command)] +#[poise::command(slash_command, identifying_name = "offset")] pub async fn offset( ctx: Context<'_>, #[description = "Number of hours to offset by"] hours: Option, @@ -147,7 +147,7 @@ WHERE FIND_IN_SET(channels.`channel`, ?)", } /// Nudge all future reminders on this channel by a certain amount (don't use for DST! See `/offset`) -#[poise::command(slash_command)] +#[poise::command(slash_command, identifying_name = "nudge")] pub async fn nudge( ctx: Context<'_>, #[description = "Number of minutes to nudge new reminders by"] minutes: Option, @@ -170,7 +170,7 @@ pub async fn nudge( } /// View reminders on a specific channel -#[poise::command(slash_command)] +#[poise::command(slash_command, identifying_name = "look")] pub async fn look( ctx: Context<'_>, #[description = "Channel to view reminders on"] channel: Option, @@ -260,7 +260,7 @@ pub async fn look( } /// Delete reminders -#[poise::command(slash_command, rename = "del")] +#[poise::command(slash_command, rename = "del", identifying_name = "delete")] pub async fn delete(ctx: Context<'_>) -> Result<(), Error> { let timezone = ctx.timezone().await; @@ -422,13 +422,13 @@ fn time_difference(start_time: NaiveDateTime) -> String { } /// Manage timers -#[poise::command(slash_command, rename = "timer")] +#[poise::command(slash_command, rename = "timer", identifying_name = "timer_base")] pub async fn timer_base(_ctx: Context<'_>) -> Result<(), Error> { Ok(()) } /// List the timers in this server or DM channel -#[poise::command(slash_command, rename = "list")] +#[poise::command(slash_command, rename = "list", identifying_name = "list_timer")] pub async fn list_timer(ctx: Context<'_>) -> Result<(), Error> { let owner = ctx.guild_id().map(|g| g.0).unwrap_or_else(|| ctx.author().id.0); @@ -452,7 +452,7 @@ pub async fn list_timer(ctx: Context<'_>) -> Result<(), Error> { } /// Start a new timer from now -#[poise::command(slash_command, rename = "start")] +#[poise::command(slash_command, rename = "start", identifying_name = "start_timer")] pub async fn start_timer( ctx: Context<'_>, #[description = "Name for the new timer"] name: String, @@ -482,7 +482,7 @@ pub async fn start_timer( } /// Delete a timer -#[poise::command(slash_command, rename = "delete")] +#[poise::command(slash_command, rename = "delete", identifying_name = "delete_timer")] pub async fn delete_timer( ctx: Context<'_>, #[description = "Name of timer to delete"] name: String, @@ -509,8 +509,8 @@ pub async fn delete_timer( } /// Create a new reminder -#[poise::command(slash_command)] -pub(crate) async fn remind( +#[poise::command(slash_command, identifying_name = "remind")] +pub async fn remind( ctx: Context<'_>, #[description = "A description of the time to set the reminder for"] time: String, #[description = "The message content to send"] content: String, diff --git a/src/commands/todo_cmds.rs b/src/commands/todo_cmds.rs index 39bf319..bab13df 100644 --- a/src/commands/todo_cmds.rs +++ b/src/commands/todo_cmds.rs @@ -11,19 +11,29 @@ use crate::{ }; /// Manage todo lists -#[poise::command(slash_command, rename = "todo")] +#[poise::command(slash_command, rename = "todo", identifying_name = "todo_base")] pub async fn todo_base(_ctx: Context<'_>) -> Result<(), Error> { Ok(()) } /// Manage the server todo list -#[poise::command(slash_command, rename = "server", check = "guild_only")] +#[poise::command( + slash_command, + rename = "server", + check = "guild_only", + identifying_name = "todo_guild_base" +)] pub async fn todo_guild_base(_ctx: Context<'_>) -> Result<(), Error> { Ok(()) } /// Add an item to the server todo list -#[poise::command(slash_command, rename = "add")] +#[poise::command( + slash_command, + rename = "add", + check = "guild_only", + identifying_name = "todo_guild_add" +)] pub async fn todo_guild_add( ctx: Context<'_>, #[description = "The task to add to the todo list"] task: String, @@ -44,7 +54,12 @@ VALUES ((SELECT id FROM guilds WHERE guild = ?), ?)", } /// View and remove from the server todo list -#[poise::command(slash_command, rename = "view")] +#[poise::command( + slash_command, + rename = "view", + check = "guild_only", + identifying_name = "todo_guild_view" +)] pub async fn todo_guild_view(ctx: Context<'_>) -> Result<(), Error> { let values = sqlx::query!( "SELECT todos.id, value FROM todos @@ -71,13 +86,23 @@ WHERE guilds.guild = ?", } /// Manage the channel todo list -#[poise::command(slash_command, rename = "channel", check = "guild_only")] +#[poise::command( + slash_command, + rename = "channel", + check = "guild_only", + identifying_name = "todo_channel_base" +)] pub async fn todo_channel_base(_ctx: Context<'_>) -> Result<(), Error> { Ok(()) } /// Add an item to the channel todo list -#[poise::command(slash_command, rename = "add")] +#[poise::command( + slash_command, + rename = "add", + check = "guild_only", + identifying_name = "todo_channel_add" +)] pub async fn todo_channel_add( ctx: Context<'_>, #[description = "The task to add to the todo list"] task: String, @@ -99,7 +124,12 @@ VALUES ((SELECT id FROM guilds WHERE guild = ?), (SELECT id FROM channels WHERE } /// View and remove from the channel todo list -#[poise::command(slash_command, rename = "view")] +#[poise::command( + slash_command, + rename = "view", + check = "guild_only", + identifying_name = "todo_channel_view" +)] pub async fn todo_channel_view(ctx: Context<'_>) -> Result<(), Error> { let values = sqlx::query!( "SELECT todos.id, value FROM todos @@ -127,13 +157,13 @@ WHERE channels.channel = ?", } /// Manage your personal todo list -#[poise::command(slash_command, rename = "user")] +#[poise::command(slash_command, rename = "user", identifying_name = "todo_user_base")] pub async fn todo_user_base(_ctx: Context<'_>) -> Result<(), Error> { Ok(()) } /// Add an item to your personal todo list -#[poise::command(slash_command, rename = "add")] +#[poise::command(slash_command, rename = "add", identifying_name = "todo_user_add")] pub async fn todo_user_add( ctx: Context<'_>, #[description = "The task to add to the todo list"] task: String, @@ -154,7 +184,7 @@ VALUES ((SELECT id FROM users WHERE user = ?), ?)", } /// View and remove from your personal todo list -#[poise::command(slash_command, rename = "view")] +#[poise::command(slash_command, rename = "view", identifying_name = "todo_user_view")] pub async fn todo_user_view(ctx: Context<'_>) -> Result<(), Error> { let values = sqlx::query!( "SELECT todos.id, value FROM todos diff --git a/src/hooks.rs b/src/hooks.rs index 612c3ec..5fdf406 100644 --- a/src/hooks.rs +++ b/src/hooks.rs @@ -15,7 +15,7 @@ pub async fn guild_only(ctx: Context<'_>) -> Result { async fn macro_check(ctx: Context<'_>) -> bool { if let Context::Application(app_ctx) = ctx { if let Some(guild_id) = ctx.guild_id() { - if ctx.command().identifying_name != "macro_finish" { + if ctx.command().identifying_name != "finish_macro" { let mut lock = ctx.data().recording_macros.write().await; if let Some(command_macro) = lock.get_mut(&(guild_id, ctx.author().id)) { diff --git a/src/main.rs b/src/main.rs index 33299ff..ec07695 100644 --- a/src/main.rs +++ b/src/main.rs @@ -12,7 +12,7 @@ mod models; mod time_parser; mod utils; -use std::{collections::HashMap, env, sync::atomic::AtomicBool}; +use std::{collections::HashMap, env, fmt::Formatter, sync::atomic::AtomicBool}; use chrono_tz::Tz; use dotenv::dotenv; @@ -45,6 +45,12 @@ pub struct Data { is_loop_running: AtomicBool, } +impl std::fmt::Debug for Data { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + write!(f, "Data {{ .. }}") + } +} + #[tokio::main] async fn main() -> Result<(), Box> { env_logger::init(); @@ -120,9 +126,7 @@ async fn main() -> Result<(), Box> { Pool::connect(&env::var("DATABASE_URL").expect("No database URL provided")).await.unwrap(); let popular_timezones = sqlx::query!( - " -SELECT timezone FROM users GROUP BY timezone ORDER BY COUNT(timezone) DESC LIMIT 21 - " + "SELECT timezone FROM users GROUP BY timezone ORDER BY COUNT(timezone) DESC LIMIT 21" ) .fetch_all(&database) .await From e2e5b022a0109061a7904f5c93ef3d1a04345b34 Mon Sep 17 00:00:00 2001 From: jude Date: Sat, 5 Mar 2022 19:43:02 +0000 Subject: [PATCH 05/27] create reminder route. formatting on frontend --- Cargo.lock | 1 + web/Cargo.toml | 1 + web/src/consts.rs | 47 ++++ web/src/lib.rs | 47 +++- web/src/macros.rs | 47 ++++ web/src/routes/dashboard/guild.rs | 205 +++++++++++++++++- web/src/routes/dashboard/mod.rs | 89 +++++++- web/src/routes/login.rs | 18 +- web/templates/cookies.html.tera | 6 +- web/templates/dashboard.html.tera | 21 ++ web/templates/privacy.html.tera | 57 ++++- .../guild_reminder.html.tera | 78 ++++--- 12 files changed, 564 insertions(+), 53 deletions(-) create mode 100644 web/src/macros.rs diff --git a/Cargo.lock b/Cargo.lock index 318b043..ab7f6f6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2131,6 +2131,7 @@ version = "0.1.0" dependencies = [ "chrono", "chrono-tz 0.5.3", + "lazy_static", "log", "oauth2", "reqwest", diff --git a/web/Cargo.toml b/web/Cargo.toml index 0d0742f..16bcb28 100644 --- a/web/Cargo.toml +++ b/web/Cargo.toml @@ -15,3 +15,4 @@ serde = { version = "1.0", features = ["derive"] } sqlx = { version = "0.5", features = ["runtime-tokio-rustls", "macros", "mysql", "chrono"] } chrono = "0.4" chrono-tz = "0.5" +lazy_static = "1.4.0" diff --git a/web/src/consts.rs b/web/src/consts.rs index 8a529bb..1e18130 100644 --- a/web/src/consts.rs +++ b/web/src/consts.rs @@ -2,3 +2,50 @@ pub const DISCORD_OAUTH_TOKEN: &'static str = "https://discord.com/api/oauth2/to pub const DISCORD_OAUTH_AUTHORIZE: &'static str = "https://discord.com/api/oauth2/authorize"; pub const DISCORD_API: &'static str = "https://discord.com/api"; pub const DISCORD_CDN: &'static str = "https://cdn.discordapp.com/avatars"; + +pub const MAX_CONTENT_LENGTH: usize = 2000; +pub const MAX_EMBED_DESCRIPTION_LENGTH: usize = 4096; +pub const MAX_EMBED_TITLE_LENGTH: usize = 256; +pub const MAX_EMBED_AUTHOR_LENGTH: usize = 256; +pub const MAX_EMBED_FOOTER_LENGTH: usize = 2048; +pub const MAX_URL_LENGTH: usize = 512; +pub const MAX_USERNAME_LENGTH: usize = 100; +pub const MAX_EMBED_FIELD_TITLE_LENGTH: usize = 256; +pub const MAX_EMBED_FIELD_VALUE_LENGTH: usize = 1024; +pub const MAX_EMBED_FIELDS: usize = 25; + +pub const MINUTE: usize = 60; +pub const HOUR: usize = 60 * MINUTE; +pub const DAY: usize = 24 * HOUR; + +use std::{collections::HashSet, env, iter::FromIterator}; + +use lazy_static::lazy_static; +use serenity::model::prelude::AttachmentType; + +lazy_static! { + pub static ref DEFAULT_AVATAR: AttachmentType<'static> = ( + include_bytes!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/../assets/", + env!("WEBHOOK_AVATAR", "WEBHOOK_AVATAR not provided for compilation") + )) as &[u8], + env!("WEBHOOK_AVATAR"), + ) + .into(); + pub static ref SUBSCRIPTION_ROLES: HashSet = HashSet::from_iter( + env::var("SUBSCRIPTION_ROLES") + .map(|var| var + .split(',') + .filter_map(|item| { item.parse::().ok() }) + .collect::>()) + .unwrap_or_else(|_| Vec::new()) + ); + pub static ref CNC_GUILD: Option = + env::var("CNC_GUILD").map(|var| var.parse::().ok()).ok().flatten(); + pub static ref MIN_INTERVAL: u32 = env::var("MIN_INTERVAL") + .ok() + .map(|inner| inner.parse::().ok()) + .flatten() + .unwrap_or(600); +} diff --git a/web/src/lib.rs b/web/src/lib.rs index ed3ce7f..0442fa5 100644 --- a/web/src/lib.rs +++ b/web/src/lib.rs @@ -2,6 +2,8 @@ extern crate rocket; mod consts; +#[macro_use] +mod macros; mod routes; use std::{collections::HashMap, env}; @@ -9,13 +11,23 @@ use std::{collections::HashMap, env}; use oauth2::{basic::BasicClient, AuthUrl, ClientId, ClientSecret, RedirectUrl, TokenUrl}; use rocket::fs::FileServer; use rocket_dyn_templates::Template; -use serenity::client::Context; +use serenity::{ + client::Context, + http::CacheHttp, + model::id::{GuildId, UserId}, +}; use sqlx::{MySql, Pool}; -use crate::consts::{DISCORD_OAUTH_AUTHORIZE, DISCORD_OAUTH_TOKEN}; +use crate::consts::{CNC_GUILD, DISCORD_OAUTH_AUTHORIZE, DISCORD_OAUTH_TOKEN, SUBSCRIPTION_ROLES}; type Database = MySql; +#[derive(Debug)] +enum Error { + SQLx(sqlx::Error), + serenity(serenity::Error), +} + #[catch(401)] async fn not_authorized() -> Template { let map: HashMap = HashMap::new(); @@ -98,3 +110,34 @@ pub async fn initialize( Ok(()) } + +pub async fn check_subscription(cache_http: impl CacheHttp, user_id: impl Into) -> bool { + if let Some(subscription_guild) = *CNC_GUILD { + let guild_member = GuildId(subscription_guild).member(cache_http, user_id).await; + + if let Ok(member) = guild_member { + for role in member.roles { + if SUBSCRIPTION_ROLES.contains(role.as_u64()) { + return true; + } + } + } + + false + } else { + true + } +} + +pub async fn check_guild_subscription( + cache_http: impl CacheHttp, + guild_id: impl Into, +) -> bool { + if let Some(guild) = cache_http.cache().unwrap().guild(guild_id) { + let owner = guild.owner_id; + + check_subscription(&cache_http, owner).await + } else { + false + } +} diff --git a/web/src/macros.rs b/web/src/macros.rs new file mode 100644 index 0000000..f5cd0ae --- /dev/null +++ b/web/src/macros.rs @@ -0,0 +1,47 @@ +macro_rules! check_length { + ($max:ident, $field:expr) => { + if $field.len() > $max { + return json!({ "error": format!("{} exceeded", stringify!($max)) }); + } + }; + ($max:ident, $field:expr, $($fields:expr),+) => { + check_length!($max, $field); + check_length!($max, $($fields),+); + }; +} + +macro_rules! check_length_opt { + ($max:ident, $field:expr) => { + if let Some(field) = &$field { + check_length!($max, field); + } + }; + ($max:ident, $field:expr, $($fields:expr),+) => { + check_length_opt!($max, $field); + check_length_opt!($max, $($fields),+); + }; +} + +macro_rules! check_url { + ($field:expr) => { + if $field.starts_with("http://") || $field.starts_with("https://") { + return json!({ "error": "URL invalid" }); + } + }; + ($field:expr, $($fields:expr),+) => { + check_url!($max, $field); + check_url!($max, $($fields),+); + }; +} + +macro_rules! check_url_opt { + ($field:expr) => { + if let Some(field) = &$field { + check_url!(field); + } + }; + ($field:expr, $($fields:expr),+) => { + check_url_opt!($field); + check_url_opt!($($fields),+); + }; +} diff --git a/web/src/routes/dashboard/guild.rs b/web/src/routes/dashboard/guild.rs index 55f40e9..c29e4e6 100644 --- a/web/src/routes/dashboard/guild.rs +++ b/web/src/routes/dashboard/guild.rs @@ -1,13 +1,25 @@ +use chrono::Utc; use rocket::{ + http::CookieJar, serde::json::{json, Json, Value as JsonValue}, State, }; use serde::Serialize; -use serenity::{client::Context, model::id::GuildId}; +use serenity::{ + client::Context, + model::id::{ChannelId, GuildId}, +}; use sqlx::{MySql, Pool}; -use super::Reminder; -use crate::consts::DISCORD_CDN; +use crate::{ + check_guild_subscription, check_subscription, + consts::{ + DAY, DISCORD_CDN, MAX_CONTENT_LENGTH, MAX_EMBED_AUTHOR_LENGTH, + MAX_EMBED_DESCRIPTION_LENGTH, MAX_EMBED_FOOTER_LENGTH, MAX_EMBED_TITLE_LENGTH, + MAX_URL_LENGTH, MAX_USERNAME_LENGTH, MIN_INTERVAL, + }, + routes::dashboard::{create_database_channel, DeleteReminder, Reminder}, +}; #[derive(Serialize)] struct ChannelInfo { @@ -108,10 +120,178 @@ pub async fn get_guild_roles(id: u64, ctx: &State) -> JsonValue { pub async fn create_reminder( id: u64, reminder: Json, + cookies: &CookieJar<'_>, serenity_context: &State, pool: &State>, ) -> JsonValue { - json!({"error": "Not implemented"}) + // get userid from cookies + let user_id = cookies.get_private("userid").map(|c| c.value().parse::().ok()).flatten(); + + if user_id.is_none() { + return json!({"error": "User not authorized"}); + } + + let user_id = user_id.unwrap(); + + // validate channel + let channel = ChannelId(reminder.channel).to_channel_cached(&serenity_context.inner()); + let channel_exists = channel.is_some(); + + let channel_matches_guild = + channel.map_or(false, |c| c.guild().map_or(false, |c| c.guild_id.0 == id)); + + if !channel_matches_guild || !channel_exists { + warn!( + "Error in `create_reminder`: channel {} not found for guild {} (channel exists: {})", + reminder.channel, id, channel_exists + ); + + return json!({"error": "Channel not found"}); + } + + let channel = create_database_channel( + serenity_context.inner(), + ChannelId(reminder.channel), + pool.inner(), + ) + .await; + + if let Err(e) = channel { + warn!("`create_database_channel` returned an error code: {:?}", e); + + return json!({"error": "Failed to configure channel for reminders. Please check the bot permissions"}); + } + + let channel = channel.unwrap(); + + // validate lengths + 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_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 time and interval + if reminder.utc_time < Utc::now().naive_utc() { + return json!({"error": "Time must be in the future"}); + } + if reminder.interval_months.unwrap_or(0) * 30 * DAY as u32 + + reminder.interval_seconds.unwrap_or(0) + < *MIN_INTERVAL + { + return json!({"error": "Interval too short"}); + } + + // check patreon if necessary + if reminder.interval_seconds.is_some() || reminder.interval_months.is_some() { + if !check_guild_subscription(serenity_context.inner(), GuildId(id)).await + && !check_subscription(serenity_context.inner(), user_id).await + { + return json!({"error": "Patreon is required to set intervals"}); + } + } + + // write to db + match sqlx::query!( + "INSERT INTO reminders ( + 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, + enabled, + expires, + interval_seconds, + interval_months, + name, + pin, + restartable, + tts, + username, + `utc_time` + ) VALUES ( + 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 = ?, + enabled = ?, + expires = ?, + interval_seconds = ?, + interval_months = ?, + name = ?, + pin = ?, + restartable = ?, + tts = ?, + username = ?, + `utc_time` = ? + )", + 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.enabled, + reminder.expires, + reminder.interval_seconds, + reminder.interval_months, + reminder.name, + reminder.pin, + reminder.restartable, + reminder.tts, + reminder.username, + reminder.utc_time, + ) + .execute(pool.inner()) + .await + { + Ok(_) => json!({}), + + Err(e) => { + warn!("Error in `create_reminder`: Could not execute query: {:?}", e); + + json!({"error": "Unknown error"}) + } + } } #[get("/api/guild//reminders")] @@ -197,8 +377,21 @@ pub async fn edit_reminder( #[delete("/api/guild//reminders", data = "")] pub async fn delete_reminder( id: u64, - reminder: Json, + reminder: Json, pool: &State>, ) -> JsonValue { - json!({"error": "Not implemented"}) + match sqlx::query!("DELETE FROM reminders WHERE uid = ?", reminder.uid) + .execute(pool.inner()) + .await + { + Ok(_) => { + json!({}) + } + + Err(e) => { + warn!("Error in `delete_reminder`: {:?}", e); + + json!({"error": "Could not delete reminder"}) + } + } } diff --git a/web/src/routes/dashboard/mod.rs b/web/src/routes/dashboard/mod.rs index 8276b51..df80d75 100644 --- a/web/src/routes/dashboard/mod.rs +++ b/web/src/routes/dashboard/mod.rs @@ -1,9 +1,17 @@ +use std::collections::HashMap; + use chrono::naive::NaiveDateTime; -use rocket::http::CookieJar; -use rocket::response::Redirect; +use rocket::{http::CookieJar, response::Redirect}; use rocket_dyn_templates::Template; use serde::{Deserialize, Serialize}; -use std::collections::HashMap; +use serenity::{ + client::Context, + http::{CacheHttp, Http}, + model::id::ChannelId, +}; +use sqlx::{Executor, Pool}; + +use crate::{consts::DEFAULT_AVATAR, Database, Error}; pub mod guild; pub mod user; @@ -46,8 +54,7 @@ pub struct Reminder { // https://github.com/serde-rs/json/issues/329#issuecomment-305608405 mod string { - use std::fmt::Display; - use std::str::FromStr; + use std::{fmt::Display, str::FromStr}; use serde::{de, Deserialize, Deserializer, Serializer}; @@ -74,6 +81,78 @@ pub struct DeleteReminder { uid: String, } +async fn create_database_channel( + ctx: impl AsRef, + channel: ChannelId, + pool: impl Executor<'_, Database = Database> + Copy, +) -> Result { + let row = + sqlx::query!("SELECT webhook_token, webhook_id FROM channels WHERE channel = ?", channel.0) + .fetch_one(pool) + .await; + + match row { + Ok(row) => { + if row.webhook_token.is_none() || row.webhook_id.is_none() { + let webhook = channel + .create_webhook_with_avatar(&ctx, "Reminder", DEFAULT_AVATAR.clone()) + .await + .map_err(|e| Error::serenity(e))?; + + sqlx::query!( + "UPDATE channels SET webhook_id = ?, webhook_token = ? WHERE channel = ?", + webhook.id.0, + webhook.token, + channel.0 + ) + .execute(pool) + .await + .map_err(|e| Error::SQLx(e))?; + } + + Ok(()) + } + + Err(sqlx::Error::RowNotFound) => { + // create webhook + let webhook = channel + .create_webhook_with_avatar(&ctx, "Reminder", DEFAULT_AVATAR.clone()) + .await + .map_err(|e| Error::serenity(e))?; + + // create database entry + sqlx::query!( + "INSERT INTO channels ( + webhook_id, + webhook_token, + channel + ) VALUES ( + webhook_id = ?, + webhook_token = ?, + channel = ? + )", + webhook.id.0, + webhook.token, + channel.0 + ) + .execute(pool) + .await + .map_err(|e| Error::SQLx(e))?; + + Ok(()) + } + + Err(e) => Err(Error::SQLx(e)), + }?; + + let row = sqlx::query!("SELECT id FROM channels WHERE channel = ?", channel.0) + .fetch_one(pool) + .await + .map_err(|e| Error::SQLx(e))?; + + Ok(row.id) +} + #[get("/")] pub async fn dashboard_home(cookies: &CookieJar<'_>) -> Result { if cookies.get_private("userid").is_some() { diff --git a/web/src/routes/login.rs b/web/src/routes/login.rs index c7a3d95..0c83c04 100644 --- a/web/src/routes/login.rs +++ b/web/src/routes/login.rs @@ -1,18 +1,18 @@ -use crate::consts::DISCORD_API; use log::warn; -use oauth2::basic::BasicClient; -use oauth2::reqwest::async_http_client; use oauth2::{ - AuthorizationCode, CsrfToken, PkceCodeChallenge, PkceCodeVerifier, Scope, TokenResponse, + basic::BasicClient, reqwest::async_http_client, AuthorizationCode, CsrfToken, + PkceCodeChallenge, PkceCodeVerifier, Scope, TokenResponse, }; use reqwest::Client; -use rocket::http::private::cookie::Expiration; -use rocket::http::{Cookie, CookieJar, SameSite}; -use rocket::response::{Flash, Redirect}; -use rocket::uri; -use rocket::State; +use rocket::{ + http::{private::cookie::Expiration, Cookie, CookieJar, SameSite}, + response::{Flash, Redirect}, + uri, State, +}; use serenity::model::user::User; +use crate::consts::DISCORD_API; + #[get("/discord")] pub async fn discord_login( oauth2_client: &State, diff --git a/web/templates/cookies.html.tera b/web/templates/cookies.html.tera index 4d6912b..b6272b4 100644 --- a/web/templates/cookies.html.tera +++ b/web/templates/cookies.html.tera @@ -12,7 +12,7 @@
-

User Data

+

User data

This website uses some necessary cookies and session data to operate. None of this can be disabled, since it is all necessary for the site to function. However, it is worth mentioning that all of @@ -38,7 +38,7 @@

-

Session Storage

+

Session storage

Session data are data that is stored just for the active browser session. Session storage is read and written by our server and cannot be modified on your computer. @@ -51,7 +51,7 @@

-

How Can We Trust You?

+

How can we trust you?

Feel free to audit this website. Go to our GitHub to get started, or just press F12

diff --git a/web/templates/dashboard.html.tera b/web/templates/dashboard.html.tera index fb3dbb0..481037a 100644 --- a/web/templates/dashboard.html.tera +++ b/web/templates/dashboard.html.tera @@ -722,6 +722,27 @@ document.querySelector('div#reminderCreator').classList.toggle('is-hidden'); }); + let $showInterval = document.querySelectorAll('a.intervalLabel'); + + $showInterval.forEach((element) => { + element.addEventListener('click', () => { + element.querySelector('i').classList.toggle('fa-chevron-right'); + element.querySelector('i').classList.toggle('fa-chevron-down'); + element.nextElementSibling.classList.toggle('is-hidden'); + }); + }); + + const fileInput = document.querySelectorAll('input[type=file]'); + + fileInput.forEach((element) => { + element.addEventListener('change', () => { + if (element.files.length > 0) { + const fileName = element.parentElement.querySelector('.file-label'); + fileName.textContent = element.files[0].name; + } + }) + }); + document.querySelectorAll('.discord-field-title').forEach((element) => { const $template = document.querySelector('template#embedFieldTemplate'); const $complement = element.parentElement.querySelector('.discord-field-value'); diff --git a/web/templates/privacy.html.tera b/web/templates/privacy.html.tera index d02cf1b..d99d5d4 100644 --- a/web/templates/privacy.html.tera +++ b/web/templates/privacy.html.tera @@ -12,8 +12,63 @@
-

Privacy Policy

+

Who we are

+ Reminder Bot is operated solely by Jude Southworth. You can contact me by email at + jude@jellywx.com, or via private/public message on Discord at + https://discord.jellywx.com. +

+
+
+ +
+
+

What data we collect

+

+ Reminder Bot stores limited data necessary for the function of the bot. This data + is your unique user ID, timezone, and direct message channel. +
+
+ Timezones are provided by the user or the user's browser. +

+
+
+ +
+
+

Why we collect this data

+

+ Unique user IDs are stored to keep track of who sets reminders. User timezones are + stored to allow users to set reminders in their local timezone. Direct message channels are stored to + allow the setting of reminders for your direct message channel. +

+
+
+ +
+
+

Who your data is shared with

+

+ Your data may also be guarded by the privacy policies of MEGA, our backup provider, and + Hetzner, our hosting provider. +

+
+
+ +
+
+

Accessing or removing your data

+

+ Your timezone can be removed with the command /timezone UTC. Other data can be removed + on request. Please contact me. +
+
+ Reminders created in a guild/channel will be removed automatically when the bot is removed from the + guild, the guild is deleted, or channel is deleted. Data is otherwise not removed automatically. +
+
+ Reminders deleted with /del or via the dashboard are removed from the live database + instantly, but may persist in backups.

diff --git a/web/templates/reminder_dashboard/guild_reminder.html.tera b/web/templates/reminder_dashboard/guild_reminder.html.tera index 94f3ff0..70c7c00 100644 --- a/web/templates/reminder_dashboard/guild_reminder.html.tera +++ b/web/templates/reminder_dashboard/guild_reminder.html.tera @@ -126,45 +126,69 @@
+
- +
-
- + +
+
+
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+
+
+
+ +
+
+
+ +
+
+
+
+ +
+
+
+
+ +

- Set Embed Color -

-
- -
- -

- Attach File - - - -

-
-
- -

- Set Interval + Set Embed Color

+ {% if creating %}
@@ -128,7 +128,7 @@
- +
@@ -140,13 +140,13 @@
- +
- +
- +
diff --git a/web/templates/reminder_dashboard/reminder_dashboard.html.tera b/web/templates/reminder_dashboard/reminder_dashboard.html.tera index 8fe976e..3e3736b 100644 --- a/web/templates/reminder_dashboard/reminder_dashboard.html.tera +++ b/web/templates/reminder_dashboard/reminder_dashboard.html.tera @@ -6,6 +6,7 @@
{% set creating = true %} {% include "reminder_dashboard/guild_reminder" %} + {% set creating = false %}
diff --git a/web/templates/terms.html.tera b/web/templates/terms.html.tera index e34f2d4..9847d8f 100644 --- a/web/templates/terms.html.tera +++ b/web/templates/terms.html.tera @@ -45,10 +45,20 @@

JellyWX's Home

  • Do not discuss politics, harass other users, or use language intended to upset other users
  • -
  • Do not send malicious links
  • +
  • Do not share personal information about yourself or any other user. This includes but is not + limited to real names1, addresses, phone numbers, country of origin2, religion, email address, + IP address.
  • +
  • Do not send malicious links or attachments
  • Do not advertise
  • Do not send unwarranted direct messages
+

+ 1 Some users may use their real name on their account. In this case, do not assert that + this is a user's real name, or use it to try and identify a user. +
+ 2 Country of current residence may be discussed, as this is relevant to timezone and + DST selection. +

From 3e4dd0fa48b9759cc7af173dbc164b2b17a878f3 Mon Sep 17 00:00:00 2001 From: jude Date: Sat, 19 Mar 2022 21:28:11 +0000 Subject: [PATCH 07/27] channel selection shows properly. loader --- web/src/lib.rs | 2 +- web/src/macros.rs | 34 +++++++++++ web/src/routes/dashboard/guild.rs | 19 +++--- web/src/routes/dashboard/mod.rs | 4 +- web/templates/dashboard.html.tera | 98 +++++++++++++++++++------------ web/templates/help.html.tera | 61 +++++++++++++++++-- 6 files changed, 163 insertions(+), 55 deletions(-) diff --git a/web/src/lib.rs b/web/src/lib.rs index 0442fa5..07510aa 100644 --- a/web/src/lib.rs +++ b/web/src/lib.rs @@ -25,7 +25,7 @@ type Database = MySql; #[derive(Debug)] enum Error { SQLx(sqlx::Error), - serenity(serenity::Error), + Serenity(serenity::Error), } #[catch(401)] diff --git a/web/src/macros.rs b/web/src/macros.rs index 8da595a..e14d3a4 100644 --- a/web/src/macros.rs +++ b/web/src/macros.rs @@ -45,3 +45,37 @@ macro_rules! check_url_opt { check_url_opt!($($fields),+); }; } + +macro_rules! check_authorization { + ($cookies:expr, $ctx:expr, $guild:expr) => { + use serenity::model::id::UserId; + + let user_id = $cookies.get_private("userid").map(|c| c.value().parse::().ok()).flatten(); + + match user_id { + Some(user_id) => { + match GuildId($guild).to_guild_cached($ctx) { + Some(guild) => { + let member = guild.member($ctx, UserId(user_id)).await; + + match member { + Err(_) => { + return json!({"error": "User not in guild"}) + } + + Ok(_) => {} + } + } + + None => { + return json!({"error": "Bot not in guild"}) + } + } + } + + None => { + return json!({"error": "User not authorized"}); + } + } + } +} diff --git a/web/src/routes/dashboard/guild.rs b/web/src/routes/dashboard/guild.rs index 50214d5..22b2a10 100644 --- a/web/src/routes/dashboard/guild.rs +++ b/web/src/routes/dashboard/guild.rs @@ -31,13 +31,15 @@ struct ChannelInfo { webhook_name: Option, } -// todo check the user can access this guild #[get("/api/guild//channels")] pub async fn get_guild_channels( id: u64, + cookies: &CookieJar<'_>, ctx: &State, pool: &State>, ) -> JsonValue { + check_authorization!(cookies, ctx.inner(), id); + let channels_res = GuildId(id).channels(ctx.inner()).await; match channels_res { @@ -96,9 +98,10 @@ struct RoleInfo { name: String, } -// todo check the user can access this guild #[get("/api/guild//roles")] -pub async fn get_guild_roles(id: u64, ctx: &State) -> JsonValue { +pub async fn get_guild_roles(id: u64, cookies: &CookieJar<'_>, ctx: &State) -> JsonValue { + check_authorization!(cookies, ctx.inner(), id); + let roles_res = ctx.cache.guild_roles(id); match roles_res { @@ -126,14 +129,10 @@ pub async fn create_reminder( serenity_context: &State, pool: &State>, ) -> JsonValue { - // get userid from cookies - let user_id = cookies.get_private("userid").map(|c| c.value().parse::().ok()).flatten(); + check_authorization!(cookies, serenity_context.inner(), id); - if user_id.is_none() { - return json!({"error": "User not authorized"}); - } - - let user_id = user_id.unwrap(); + let user_id = + cookies.get_private("userid").map(|c| c.value().parse::().ok()).flatten().unwrap(); // validate channel let channel = ChannelId(reminder.channel).to_channel_cached(&serenity_context.inner()); diff --git a/web/src/routes/dashboard/mod.rs b/web/src/routes/dashboard/mod.rs index d8e1403..431a9f6 100644 --- a/web/src/routes/dashboard/mod.rs +++ b/web/src/routes/dashboard/mod.rs @@ -106,7 +106,7 @@ async fn create_database_channel( let webhook = channel .create_webhook_with_avatar(&ctx, "Reminder", DEFAULT_AVATAR.clone()) .await - .map_err(|e| Error::serenity(e))?; + .map_err(|e| Error::Serenity(e))?; sqlx::query!( "UPDATE channels SET webhook_id = ?, webhook_token = ? WHERE channel = ?", @@ -127,7 +127,7 @@ async fn create_database_channel( let webhook = channel .create_webhook_with_avatar(&ctx, "Reminder", DEFAULT_AVATAR.clone()) .await - .map_err(|e| Error::serenity(e))?; + .map_err(|e| Error::Serenity(e))?; // create database entry sqlx::query!( diff --git a/web/templates/dashboard.html.tera b/web/templates/dashboard.html.tera index 51fda97..3bef65f 100644 --- a/web/templates/dashboard.html.tera +++ b/web/templates/dashboard.html.tera @@ -54,6 +54,19 @@
+ + @@ -158,7 +171,7 @@ - - - - - function resize_textareas() { - document.querySelectorAll('textarea.autoresize').forEach((element) => { - element.style.height = ""; - element.style.height = element.scrollHeight + 3 + "px"; - - element.addEventListener('input', () => { - element.style.height = ""; - element.style.height = element.scrollHeight + 3 + "px"; - }); - }); - } - - function switch_pane(selector) { - document.querySelectorAll('aside a').forEach((el) => { - el.classList.remove('is-active'); - }); - document.querySelectorAll('div.is-main-content > section').forEach((el) => { - el.classList.add('is-hidden'); - }); - - document.getElementById(selector).classList.remove('is-hidden'); - - resize_textareas(); - } - - function update_select(sel) { - if (sel.selectedOptions[0].dataset['webhookAvatar']) { - sel - .closest('div.reminderContent') - .querySelector('img.discord-avatar') - .src = sel.selectedOptions[0].dataset['webhookAvatar']; - } else { - sel - .closest('div.reminderContent') - .querySelector('img.discord-avatar') - .src = ''; - } - if (sel.selectedOptions[0].dataset['webhookName']) { - sel - .closest('div.reminderContent') - .querySelector('input.discord-username') - .value = sel.selectedOptions[0].dataset['webhookName']; - } else { - sel - .closest('div.reminderContent') - .querySelector('input.discord-username') - .value = ''; - } - } - - function reset_guild_pane() { - document.querySelectorAll('select.channel-selector option').forEach((opt) => opt.remove()); - } - - function fetch_roles(guild_id) { - fetch(`/dashboard/api/guild/${guild_id}/roles`) - .then(response => response.json()) - .then(data => { - if (data.error) { - show_error(data.error); - } else { - for (let role of data) { - // todo - } - } - }) - } - - function fetch_reminders(guild_id) { - // fetch dm reminders instead - if (guild_id === undefined) { - const $reminderBox = document.querySelector('div#personalReminders'); - - // reset div contents - $reminderBox.innerHTML = ''; - - // fetch reminders - fetch('dashboard/api/user/reminders') - .then(response => response.json()) - .then(data => { - if (data.error) { - show_error(data.error); - } else { - const $template = document.querySelector('template#personalReminder'); - - for (let reminder of data) { - let newFrame = $template.content.cloneNode(true); - - for (let prop in reminder) { - if (reminder.hasOwnProperty(prop) && reminder[prop] !== null) { - let $input = newFrame.querySelector(`*[name="${prop}"]`); - let $image = newFrame.querySelector(`img.${prop}`); - - if ($input !== null) { - $input.value = reminder[prop]; - } else if ($image !== null) { - $image.src = reminder[prop]; - } - } - } - - $reminderBox.append(newFrame); - } - } - }); - } else { - const $reminderBox = document.querySelector('div#guildReminders'); - - // reset div contents - $reminderBox.innerHTML = ''; - - // fetch reminders - fetch(`dashboard/api/guild/${guild_id}/reminders`) - .then(response => response.json()) - .then(data => { - if (data.error) { - show_error(data.error); - } else { - console.log(data); - - const $template = document.querySelector('template#guildReminder'); - - for (let reminder of data) { - let newFrame = $template.content.cloneNode(true); - - // populate channels - set_channels(newFrame.querySelector('select.channel-selector')) - - // populate majority of items - for (let prop in reminder) { - if (reminder.hasOwnProperty(prop) && reminder[prop] !== null) { - let $input = newFrame.querySelector(`*[name="${prop}"]`); - let $image = newFrame.querySelector(`img.${prop}`); - - if ($input !== null) { - $input.value = reminder[prop]; - } else if ($image !== null) { - $image.src = reminder[prop]; - } - } - } - - let timeInput = newFrame.querySelector('input[name="time"]'); - let localTime = luxon.DateTime.fromISO(reminder["utc_time"]).setZone(timezone); - timeInput.value = localTime.toFormat("yyyy-LL-dd'T'HH:mm:ss"); - - $reminderBox.appendChild(newFrame); - } - } - }); - } - } - - function show_error(error) { - document.getElementById('errors').querySelector('span.error-message').textContent = error; - document.getElementById('errors').classList.add('is-active'); - - window.setTimeout(() => { - document.getElementById('errors').classList.remove('is-active'); - }, 5000); - } - - function update_times() { - document.querySelectorAll('span.set-timezone').forEach((element) => { - element.textContent = timezone; - }); - document.querySelectorAll('span.set-time').forEach((element) => { - element.textContent = luxon.DateTime.now().setZone(timezone).toFormat('HH:mm'); - }); - document.querySelectorAll('span.browser-timezone').forEach((element) => { - element.textContent = browserTimezone; - }); - document.querySelectorAll('span.browser-time').forEach((element) => { - element.textContent = luxon.DateTime.now().toFormat('HH:mm'); - }); - document.querySelectorAll('span.bot-timezone').forEach((element) => { - element.textContent = botTimezone; - }); - document.querySelectorAll('span.bot-time').forEach((element) => { - element.textContent = luxon.DateTime.now().setZone(botTimezone).toFormat('HH:mm'); - }); - } - - window.setInterval(() => { - update_times(); - }, 30000) - - let colorPicker = new iro.ColorPicker('#colorpicker'); - let $discordFrame; - const $loader = document.querySelector('#loader'); - const $colorPickerModal = document.querySelector('div#pickColorModal'); - const $colorPickerInput = $colorPickerModal.querySelector('input'); - - let timezone = luxon.DateTime.now().zone.name; - const browserTimezone = luxon.DateTime.now().zone.name; - let botTimezone = 'UTC'; - - let channels; - - document.getElementById('set-bot-timezone').addEventListener('click', () => { - timezone = botTimezone; - update_times(); - }); - document.getElementById('set-browser-timezone').addEventListener('click', () => { - timezone = browserTimezone; - update_times(); - }); - document.getElementById('update-bot-timezone').addEventListener('click', () => { - timezone = browserTimezone; - fetch('/dashboard/api/user', { - method: 'PATCH', - headers: { - 'Accept': 'application/json', - 'Content-Type': 'application/json' - }, - body: JSON.stringify({timezone: timezone}) - }) - .then(response => response.json()) - .then(data => { - if (data.error) { - show_error(data.error) - } else { - botTimezone = browserTimezone; - update_times(); - } - }); - }); - - $colorPickerInput.value = colorPicker.color.hexString; - - $colorPickerInput.addEventListener('input', () => { - if (/^#[0-9a-fA-F]{6}$/.test($colorPickerInput.value) === true) { - colorPicker.color.hexString = $colorPickerInput.value; - } - }); - - colorPicker.on('color:change', function (color) { - $colorPickerInput.value = color.hexString; - }); - - document.querySelectorAll('.discord-embed').forEach((element) => { - element.addEventListener('click', (e) => { - if (e.offsetX < parseInt(window.getComputedStyle(element).borderLeftWidth)) { - $discordFrame = element; - $colorPickerModal.classList.toggle('is-active'); - colorPicker.color.rgbString = window.getComputedStyle($discordFrame).borderLeftColor; - } - }) - }); - - document.querySelectorAll('.set-color').forEach((element) => { - element.addEventListener('click', (e) => { - e.preventDefault(); - - $discordFrame = element.closest('div.reminderContent').querySelector('div.discord-embed'); - $colorPickerModal.classList.toggle('is-active'); - colorPicker.color.rgbString = window.getComputedStyle($discordFrame).borderLeftColor; - }) - }); - - $colorPickerModal.querySelector('button.is-success').addEventListener('click', () => { - $discordFrame.style.borderLeftColor = colorPicker.color.rgbString; - - $colorPickerModal.classList.remove('is-active') - }); - - document.querySelectorAll('a.show-modal').forEach((element) => { - element.addEventListener('click', (e) => { - e.preventDefault(); - document.getElementById(element.dataset['modal']).classList.toggle('is-active'); - }) - }) - - document.addEventListener('DOMContentLoaded', () => { - $loader.classList.remove('is-hidden'); - - document.querySelectorAll('.navbar-burger').forEach(el => { - el.addEventListener('click', () => { - const target = el.dataset.target; - const $target = document.getElementById(target); - - el.classList.toggle('is-active'); - $target.classList.toggle('is-active'); - }); - }); - - fetch('/dashboard/api/user') - .then(response => response.json()) - .then(data => { - if (data.error) { - show_error(data.error); - } else { - document.querySelectorAll('a.switch-pane').forEach((element) => { - element.innerHTML = element.innerHTML.replace('%username%', data.name); - - element.addEventListener('click', (e) => { - e.preventDefault(); - - switch_pane(element.dataset['pane']); - - element.classList.add('is-active'); - - resize_textareas(); - - document.querySelectorAll('p.pageTitle').forEach((el) => { - el.textContent = 'Your Reminders'; - }); - }); - }); - - if (data.timezone !== null) { - botTimezone = data.timezone; - } - - update_times(); - } - }); - - fetch('/dashboard/api/user/guilds') - .then(response => response.json()) - .then(data => { - if (data.error) { - show_error(data.error); - } else { - const $template = document.getElementById('guildListEntry'); - - for (let guild of data) { - document.querySelectorAll('.guildList').forEach((element) => { - const $clone = $template.content.cloneNode(true); - const $anchor = $clone.querySelector('a'); - - $anchor.innerHTML = $clone.querySelector('a').innerHTML.replace('%guildname%', guild.name); - $anchor.dataset['guild'] = guild.id; - $anchor.dataset['name'] = guild.name; - - $anchor.addEventListener('click', async (e) => { - e.preventDefault(); - - $loader.classList.remove('is-hidden'); - - switch_pane($anchor.dataset['pane']); - - reset_guild_pane(); - - fetch_roles($anchor.dataset['guild']); - - await fetch(`/dashboard/api/guild/${$anchor.dataset['guild']}/channels`) - .then(response => response.json()) - .then(data => { - if (data.error) { - show_error(data.error); - } else { - channels = data; - - document.querySelectorAll('select.channel-selector').forEach(set_channels); - } - }); - - fetch_reminders($anchor.dataset['guild']); - - document.querySelectorAll('p.pageTitle').forEach((el) => { - el.textContent = $anchor.dataset['name'] + ' Reminders'; - }); - document.querySelectorAll('select.channel-selector').forEach((el) => { - el.addEventListener('change', (e) => { - update_select(e.target); - }) - }); - $anchor.classList.add('is-active'); - resize_textareas(); - - $loader.classList.add('is-hidden'); - }); - - element.append($clone); - }); - } - } - }); - - $loader.classList.add('is-hidden'); - }); - - function set_channels(element) { - for (let channel of channels) { - let newOption = document.createElement('option'); - - newOption.value = channel.id; - newOption.textContent = channel.name; - if (channel.webhook_avatar !== null) { - newOption.dataset['webhookAvatar'] = channel.webhook_avatar; - } - if (channel.webhook_name !== null) { - newOption.dataset['webhookName'] = channel.webhook_name; - } - - element.appendChild(newOption); - } - - update_select(element); - } - - let $createReminder = document.querySelector('#reminderCreator'); - - $createReminder.querySelector('button#createReminder').addEventListener('click', () => { - // create reminder object - let seconds = parseInt($createReminder.querySelector('input[name="interval_seconds"]').value) || null; - let months = parseInt($createReminder.querySelector('input[name="interval_months"]').value) || null; - - let rgb_color = window.getComputedStyle($createReminder.querySelector('div.discord-embed')).borderLeftColor; - let rgb = rgb_color.match(/\d+/g); - let color = colorToInt(parseInt(rgb[0]), parseInt(rgb[1]), parseInt(rgb[2])); - - let utc_time = luxon.DateTime.fromISO($createReminder.querySelector('input[name="time"]').value).setZone('UTC'); - - let reminder = { - avatar: $createReminder.querySelector('img.discord-avatar').src, - channel: $createReminder.querySelector('select.channel-selector').value, - content: $createReminder.querySelector('textarea#messageContent').value, - embed_author_url: $createReminder.querySelector('img.embed_author_url').src, - embed_author: $createReminder.querySelector('textarea#embedAuthor').value, - embed_color: color, - embed_description: $createReminder.querySelector('textarea#embedDescription').value, - embed_footer: $createReminder.querySelector('textarea#embedFooter').value, - embed_footer_url: $createReminder.querySelector('img.embed_footer_url').src, - embed_image_url: $createReminder.querySelector('img.embed_image_url').src, - embed_thumbnail_url: $createReminder.querySelector('img.embed_thumbnail_url').src, - embed_title: $createReminder.querySelector('textarea#embedTitle').value, - enabled: true, - expires: null, - interval_seconds: seconds, - interval_months: months, - name: $createReminder.querySelector('input[name="name"]').value, - pin: $createReminder.querySelector('input[name="pin"]').checked, - restartable: false, - tts: $createReminder.querySelector('input[name="tts"]').checked, - username: $createReminder.querySelector('input#reminderUsername').value, - utc_time: utc_time.toFormat("yyyy-LL-dd'T'HH:mm:ss") - } - - // send to server - let guild = document.querySelector('.guildList a.is-active').dataset['guild']; - - fetch(`/dashboard/api/guild/${guild}/reminders`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json' - }, - body: JSON.stringify(reminder) - }).then(response => response.json()).then(data => console.log(data)) - - // process response - - // reset inputs - }); - - document.querySelectorAll('textarea.autoresize').forEach((element) => { - element.addEventListener('input', () => { - element.style.height = ""; - element.style.height = element.scrollHeight + 3 + "px"; - }); - }); - - let $img; - const $urlModal = document.querySelector('div#addImageModal'); - const $urlInput = $urlModal.querySelector('input'); - - $urlModal.querySelector('button.is-success').addEventListener('click', () => { - $img.src = $urlInput.value; - - $urlInput.value = ''; - $urlModal.classList.remove('is-active') - }); - - document.querySelectorAll('button.close-modal').forEach((element) => { - element.addEventListener('click', () => { - let $modal = element.closest('div.modal'); - - $urlInput.value = ''; - - $modal.classList.remove('is-active') - }); - }); - - function colorToInt(r, g, b) { - return (r << 16) + (g << 8) + (b); - } - - document.querySelectorAll('.customizable').forEach((element) => { - element.querySelector('a').addEventListener('click', (e) => { - e.preventDefault(); - - $img = element.querySelector('img'); - - $urlModal.classList.toggle('is-active') - }); - }); - - document.querySelectorAll('a.icon-toggle').forEach((element) => { - element.addEventListener('click', (e) => { - e.preventDefault(); - - element.classList.toggle('is-active'); - }) - }); - - let $showButton = document.querySelector('button#showReminderCreator'); - - $showButton.addEventListener('click', () => { - $showButton.querySelector('span.icon i').classList.toggle('fa-chevron-right'); - $showButton.querySelector('span.icon i').classList.toggle('fa-chevron-down'); - document.querySelector('div#reminderCreator').classList.toggle('is-hidden'); - }); - - let $showInterval = document.querySelectorAll('a.intervalLabel'); - - $showInterval.forEach((element) => { - element.addEventListener('click', () => { - element.querySelector('i').classList.toggle('fa-chevron-right'); - element.querySelector('i').classList.toggle('fa-chevron-down'); - element.nextElementSibling.classList.toggle('is-hidden'); - }); - }); - - const fileInput = document.querySelectorAll('input[type=file]'); - - fileInput.forEach((element) => { - element.addEventListener('change', () => { - if (element.files.length > 0) { - const fileName = element.parentElement.querySelector('.file-label'); - fileName.textContent = element.files[0].name; - } - }) - }); - - document.querySelectorAll('.discord-field-title').forEach((element) => { - const $template = document.querySelector('template#embedFieldTemplate'); - const $complement = element.parentElement.querySelector('.discord-field-value'); - - // when the user clicks out of the field title and if the field title/value are empty, remove the field - element.addEventListener('blur', () => { - if (element.value === '' && $complement.value === '' && element.parentElement.nextElementSibling !== null) { - element.parentElement.remove(); - } - }); - - $complement.addEventListener('blur', () => { - if (element.value === '' && $complement.value === '' && element.parentElement.nextElementSibling !== null) { - element.parentElement.remove(); - } - }); - - // when the user inputs into the end field, create a new field after it - element.addEventListener('input', () => { - if (element.value !== '' && $complement.value !== '' && element.parentElement.nextElementSibling === null) { - const $clone = $template.content.cloneNode(true); - element.parentElement.parentElement.append($clone); - } - }); - - $complement.addEventListener('input', () => { - if (element.value !== '' && $complement.value !== '' && element.parentElement.nextElementSibling === null) { - const $clone = $template.content.cloneNode(true); - element.parentElement.parentElement.append($clone); - } - }); - }); - - document.addEventListener('DOMNodeInserted', () => { - document.querySelectorAll('div.mobile-sidebar a').forEach((element) => { - element.addEventListener('click', (e) => { - document.getElementById('mobileSidebar').classList.remove('is-active'); - document.querySelectorAll('.navbar-burger').forEach((el) => { - el.classList.remove('is-active'); - }); - }); - }); - - document.querySelectorAll('.discord-field-title').forEach((element) => { - const $template = document.querySelector('template#embedFieldTemplate'); - const $complement = element.parentElement.querySelector('.discord-field-value'); - - // when the user clicks out of the field title and if the field title/value are empty, remove the field - element.addEventListener('blur', () => { - if (element.value === '' && $complement.value === '' && element.parentElement.nextElementSibling !== null) { - element.parentElement.remove(); - } - }); - - $complement.addEventListener('blur', () => { - if (element.value === '' && $complement.value === '' && element.parentElement.nextElementSibling !== null) { - element.parentElement.remove(); - } - }); - - // when the user inputs into the end field, create a new field after it - element.addEventListener('input', () => { - if (element.value !== '' && $complement.value !== '' && element.parentElement.nextElementSibling === null) { - const $clone = $template.content.cloneNode(true); - element.parentElement.parentElement.append($clone); - } - }); - - $complement.addEventListener('input', () => { - if (element.value !== '' && $complement.value !== '' && element.parentElement.nextElementSibling === null) { - const $clone = $template.content.cloneNode(true); - element.parentElement.parentElement.append($clone); - } - }); - }); - - resize_textareas(); - }); - - diff --git a/web/templates/help_timezone.html.tera b/web/templates/help_timezone.html.tera new file mode 100644 index 0000000..680c34d --- /dev/null +++ b/web/templates/help_timezone.html.tera @@ -0,0 +1,47 @@ +{% extends "base" %} + +{% block init %} + {% set title = "Support" %} + + {% set page_title = "Timezone Help" %} + {% set page_subtitle = "Timezones are tricky. Read on for help" %} + {% set show_invite = false %} +{% endblock %} + +{% block content %} + +
+
+
+

Selecting your timezone manually

+

+ To select your timezone manually, use /timezone. This will set your timezone + across all servers with Reminder Bot. +
+ You should only ever have to do this once. To avoid needing to change timezone due to daylight + savings, choose a DST-aware region, for example Europe/London instead of + GMT, or US/New_York instead of EST. +

+
+
+
+ +
+
+
+

Selecting your timezone automatically

+

+ A new feature we offer is the ability to configure Reminder Bot's timezone from your browser. To do + this, go to our dashboard, press 'Timezone' in the bottom left (desktop) or at the bottom of the + navigation menu (mobile). Then, choose 'Set Bot Timezone' to set Reminder Bot to use your browser's + timezone. +
+ From here, you can also configure the dashboard to alternatively use the manually configured + timezone instead of the browser's timezone, if your browser is reporting your timezone incorrectly, + or if you have a special use-case. +

+
+
+
+ +{% endblock %} From 3b6e02e16e5dbf1f02cd1a9ea9a58e696fe5dedc Mon Sep 17 00:00:00 2001 From: jude Date: Sun, 20 Mar 2022 00:10:19 +0000 Subject: [PATCH 09/27] working on editing reminders --- web/src/routes/dashboard/guild.rs | 13 +++-- web/src/routes/dashboard/mod.rs | 53 +++++++++++++++++++ web/static/js/main.js | 17 ++++++ .../guild_reminder.html.tera | 2 +- 4 files changed, 81 insertions(+), 4 deletions(-) diff --git a/web/src/routes/dashboard/guild.rs b/web/src/routes/dashboard/guild.rs index 22b2a10..cb4556b 100644 --- a/web/src/routes/dashboard/guild.rs +++ b/web/src/routes/dashboard/guild.rs @@ -19,7 +19,8 @@ use crate::{ MAX_URL_LENGTH, MAX_USERNAME_LENGTH, MIN_INTERVAL, }, routes::dashboard::{ - create_database_channel, generate_uid, name_default, DeleteReminder, Reminder, + create_database_channel, generate_uid, name_default, DeleteReminder, PatchReminder, + Reminder, }, }; @@ -351,11 +352,17 @@ WHERE #[patch("/api/guild//reminders", data = "")] pub async fn edit_reminder( id: u64, - reminder: Json, + reminder: Json, serenity_context: &State, pool: &State>, ) -> JsonValue { - json!({"error": "Not implemented"}) + if let Some(enabled) = reminder.enabled { + sqlx::query!("UPDATE reminders SET enabled = ? WHERE uid = ?", enabled, reminder.uid) + .execute(pool.inner()) + .await; + } + + json!({}) } #[delete("/api/guild//reminders", data = "")] diff --git a/web/src/routes/dashboard/mod.rs b/web/src/routes/dashboard/mod.rs index 431a9f6..2e130a5 100644 --- a/web/src/routes/dashboard/mod.rs +++ b/web/src/routes/dashboard/mod.rs @@ -16,6 +16,8 @@ use crate::{ pub mod guild; pub mod user; +type Unset = Option; + fn name_default() -> String { "Reminder".to_string() } @@ -52,6 +54,57 @@ pub struct Reminder { utc_time: NaiveDateTime, } +#[derive(Deserialize)] +pub struct PatchReminder { + uid: String, + #[serde(default)] + attachment: Unset>>, + #[serde(default)] + attachment_name: Unset>, + #[serde(default)] + avatar: Unset>, + #[serde(default)] + content: Unset, + #[serde(default)] + embed_author: Unset, + #[serde(default)] + embed_author_url: Unset>, + #[serde(default)] + embed_color: Unset, + #[serde(default)] + embed_description: Unset, + #[serde(default)] + embed_footer: Unset, + #[serde(default)] + embed_footer_url: Unset>, + #[serde(default)] + embed_image_url: Unset>, + #[serde(default)] + embed_thumbnail_url: Unset>, + #[serde(default)] + embed_title: Unset, + #[serde(default)] + enabled: Unset, + #[serde(default)] + expires: Unset>, + #[serde(default)] + interval_seconds: Unset>, + #[serde(default)] + interval_months: Unset>, + #[serde(default)] + name: Unset, + #[serde(default)] + pin: Unset, + #[serde(default)] + restartable: Unset, + #[serde(default)] + tts: Unset, + #[serde(default)] + username: Unset>, + #[serde(default)] + utc_time: Unset, +} + pub fn generate_uid() -> String { let mut generator: OsRng = Default::default(); diff --git a/web/static/js/main.js b/web/static/js/main.js index 98408d5..c504ceb 100644 --- a/web/static/js/main.js +++ b/web/static/js/main.js @@ -138,6 +138,8 @@ async function fetch_reminders(guild_id) { for (let reminder of data) { let newFrame = $template.content.cloneNode(true); + newFrame.querySelector('.reminderContent').dataset.uid = reminder['uid']; + // populate channels set_channels(newFrame.querySelector('select.channel-selector')) @@ -155,6 +157,21 @@ async function fetch_reminders(guild_id) { } } + let guild = document.querySelector('.guildList a.is-active').dataset['guild']; + + let $enableBtn = newFrame.querySelector('.disable-enable'); + + $enableBtn.textContent = reminder['enabled'] ? 'Disable' : 'Enable'; + $enableBtn.addEventListener('click', () => { + fetch(`/dashboard/api/guild/${guild}/reminders`, { + method: 'PATCH', + body: JSON.stringify({ + uid: reminder['uid'], + enabled: false + }) + }) + }) + let timeInput = newFrame.querySelector('input[name="time"]'); let localTime = luxon.DateTime.fromISO(reminder["utc_time"]).setZone(timezone); timeInput.value = localTime.toFormat("yyyy-LL-dd'T'HH:mm:ss"); diff --git a/web/templates/reminder_dashboard/guild_reminder.html.tera b/web/templates/reminder_dashboard/guild_reminder.html.tera index cb47d95..16f38a3 100644 --- a/web/templates/reminder_dashboard/guild_reminder.html.tera +++ b/web/templates/reminder_dashboard/guild_reminder.html.tera @@ -204,7 +204,7 @@ - - - - +
+
+
+ +
+
+ +
+
@@ -21,3 +29,5 @@
+ + From af9d8bea6288dd5f107b064c182aaa6793ec9269 Mon Sep 17 00:00:00 2001 From: jude Date: Sun, 20 Mar 2022 18:29:27 +0000 Subject: [PATCH 11/27] collapse/expand elements. moved the embed color picker --- .prettierrc.toml | 1 + web/src/routes/dashboard/guild.rs | 44 +- web/static/css/style.css | 93 ++- web/static/js/expand.js | 23 + web/static/js/main.js | 566 +++++++----------- web/static/js/sort.js | 26 +- web/templates/dashboard.html.tera | 4 +- .../guild_reminder.html.tera | 363 +++++------ .../reminder_dashboard.html.tera | 47 +- 9 files changed, 599 insertions(+), 568 deletions(-) create mode 100644 web/static/js/expand.js diff --git a/.prettierrc.toml b/.prettierrc.toml index 3d14781..943b086 100644 --- a/.prettierrc.toml +++ b/.prettierrc.toml @@ -1 +1,2 @@ +printWidth = 90 tabWidth = 4 diff --git a/web/src/routes/dashboard/guild.rs b/web/src/routes/dashboard/guild.rs index cb4556b..b2c5243 100644 --- a/web/src/routes/dashboard/guild.rs +++ b/web/src/routes/dashboard/guild.rs @@ -362,7 +362,49 @@ pub async fn edit_reminder( .await; } - json!({}) + match sqlx::query_as_unchecked!( + Reminder, + "SELECT reminders.attachment, + reminders.attachment_name, + reminders.avatar, + channels.channel, + reminders.content, + reminders.embed_author, + reminders.embed_author_url, + reminders.embed_color, + reminders.embed_description, + reminders.embed_footer, + reminders.embed_footer_url, + reminders.embed_image_url, + reminders.embed_thumbnail_url, + reminders.embed_title, + reminders.enabled, + reminders.expires, + reminders.interval_seconds, + reminders.interval_months, + reminders.name, + reminders.pin, + reminders.restartable, + reminders.tts, + reminders.uid, + reminders.username, + reminders.utc_time + FROM reminders + LEFT JOIN channels ON channels.id = reminders.channel_id + WHERE uid = ?", + reminder.uid + ) + .fetch_one(pool.inner()) + .await + { + Ok(reminder) => json!(reminder), + + Err(e) => { + warn!("Error exiting `edit_reminder': {:?}", e); + + json!({"error": "Unknown error"}) + } + } } #[delete("/api/guild//reminders", data = "")] diff --git a/web/static/css/style.css b/web/static/css/style.css index b235076..20f898a 100644 --- a/web/static/css/style.css +++ b/web/static/css/style.css @@ -2,6 +2,69 @@ font-family: "Ubuntu Bold", "Ubuntu", sans-serif; } +/* override styles for when the div is collapsed */ +div.reminderContent.is-collapsed .column.discord-frame { + display: none; +} + +div.reminderContent.is-collapsed .collapses { + display: none; +} + +div.reminderContent.is-collapsed .invert-collapses { + display: inline-flex; +} + +div.reminderContent .invert-collapses { + display: none; +} + +div.reminderContent.is-collapsed .settings { + display: flex; + flex-direction: row; + padding-bottom: 0; +} + +div.reminderContent.is-collapsed .channel-field { + display: inline-flex; + order: 1; +} + +div.reminderContent.is-collapsed .columns { + display: inline-flex; + margin-bottom: 0px; + flex-grow: 1; + order: 2; +} + +div.reminderContent.is-collapsed input[name="name"] { + display: inline-flex; + flex-grow: 1; +} + +div.reminderContent.is-collapsed button.hide-box { + display: inline-flex; +} + +div.reminderContent.is-collapsed button.hide-box i { + transform: rotate(90deg); +} +/* END */ + +/* dashboard styles */ +button.change-color { + position: absolute; + left: calc(-1rem - 40px); +} + +.media-content { + overflow-x: visible; +} + +div.discord-embed { + position: relative; +} + div.reminderContent { padding: 2px; background-color: #f5f5f5; @@ -340,36 +403,6 @@ textarea, input { flex-wrap: wrap; } -.icon-toggle { - color: #fff; - opacity: 0.2; -} - -.preview-toggle { - color: #fcb620; - opacity: 0.2; -} - -a.level-item.preview-toggle:hover { - color: #fcb620; -} - -.preview-toggle.is-active { - opacity: 1; -} - -a.level-item.icon-toggle:hover { - color: inherit; -} - -a.level-item:hover { - color: rgb(55, 127, 242) -} - -.icon-toggle.is-active { - opacity: 1; -} - .channel-select { font-size: 1.125rem; margin-bottom: 4px; diff --git a/web/static/js/expand.js b/web/static/js/expand.js new file mode 100644 index 0000000..c74a72d --- /dev/null +++ b/web/static/js/expand.js @@ -0,0 +1,23 @@ +function collapse_all() { + document.querySelectorAll("div.reminderContent:not(.creator)").forEach((el) => { + el.classList.add("is-collapsed"); + }); +} + +function expand_all() { + document.querySelectorAll("div.reminderContent:not(.creator)").forEach((el) => { + el.classList.remove("is-collapsed"); + }); +} + +const expandAll = document.querySelector("#expandAll"); + +expandAll.addEventListener("change", (ev) => { + if (ev.target.value === "expand") { + expand_all(); + } else if (ev.target.value === "collapse") { + collapse_all(); + } + + ev.target.value = ""; +}); diff --git a/web/static/js/main.js b/web/static/js/main.js index 5dc4b61..164208e 100644 --- a/web/static/js/main.js +++ b/web/static/js/main.js @@ -11,8 +11,6 @@ let botTimezone = "UTC"; let channels; let roles; -const remindersLoadedEvent = new Event("remindersLoaded"); - function colorToInt(r, g, b) { return (r << 16) + (g << 8) + b; } @@ -44,24 +42,17 @@ function switch_pane(selector) { function update_select(sel) { if (sel.selectedOptions[0].dataset["webhookAvatar"]) { - sel - .closest("div.reminderContent") - .querySelector("img.discord-avatar").src = + sel.closest("div.reminderContent").querySelector("img.discord-avatar").src = sel.selectedOptions[0].dataset["webhookAvatar"]; } else { - sel - .closest("div.reminderContent") - .querySelector("img.discord-avatar").src = ""; + sel.closest("div.reminderContent").querySelector("img.discord-avatar").src = ""; } if (sel.selectedOptions[0].dataset["webhookName"]) { - sel - .closest("div.reminderContent") - .querySelector("input.discord-username").value = + sel.closest("div.reminderContent").querySelector("input.discord-username").value = sel.selectedOptions[0].dataset["webhookName"]; } else { - sel - .closest("div.reminderContent") - .querySelector("input.discord-username").value = ""; + sel.closest("div.reminderContent").querySelector("input.discord-username").value = + ""; } } @@ -84,145 +75,106 @@ function fetch_roles(guild_id) { } async function fetch_reminders(guild_id) { - // fetch dm reminders instead - if (guild_id === undefined) { - const $reminderBox = document.querySelector("div#personalReminders"); + const $reminderBox = document.querySelector("div#guildReminders"); - // reset div contents - $reminderBox.innerHTML = ""; + // reset div contents + $reminderBox.innerHTML = ""; - // fetch reminders - await fetch("dashboard/api/user/reminders") - .then((response) => response.json()) - .then((data) => { - if (data.error) { - show_error(data.error); - } else { - const $template = document.querySelector( - "template#personalReminder" - ); + // fetch reminders + await fetch(`dashboard/api/guild/${guild_id}/reminders`) + .then((response) => response.json()) + .then((data) => { + if (data.error) { + show_error(data.error); + } else { + const $template = document.querySelector("template#guildReminder"); - for (let reminder of data) { - let newFrame = $template.content.cloneNode(true); + for (let reminder of data) { + let newFrame = $template.content.cloneNode(true); - for (let prop in reminder) { - if ( - reminder.hasOwnProperty(prop) && - reminder[prop] !== null - ) { - let $input = newFrame.querySelector( - `*[name="${prop}"]` - ); - let $image = newFrame.querySelector( - `img.${prop}` - ); + newFrame.querySelector(".reminderContent").dataset.uid = + reminder["uid"]; - if ($input !== null) { - $input.value = reminder[prop]; - } else if ($image !== null) { - $image.src = reminder[prop]; - } + // populate channels + set_channels(newFrame.querySelector("select.channel-selector")); + + // populate majority of items + for (let prop in reminder) { + if (reminder.hasOwnProperty(prop) && reminder[prop] !== null) { + let $input = newFrame.querySelector(`*[name="${prop}"]`); + let $image = newFrame.querySelector(`img.${prop}`); + + if ($input !== null) { + $input.value = reminder[prop]; + } else if ($image !== null) { + $image.src = reminder[prop]; } } - - $reminderBox.append(newFrame); } - } - }); - } else { - const $reminderBox = document.querySelector("div#guildReminders"); - // reset div contents - $reminderBox.innerHTML = ""; + let $enableBtn = newFrame.querySelector(".disable-enable"); + $enableBtn.textContent = reminder["enabled"] ? "Disable" : "Enable"; + $enableBtn.dataset.action = reminder["enabled"] + ? "disable" + : "enable"; - // fetch reminders - await fetch(`dashboard/api/guild/${guild_id}/reminders`) - .then((response) => response.json()) - .then((data) => { - if (data.error) { - show_error(data.error); - } else { - console.log(data); - - const $template = document.querySelector( - "template#guildReminder" + let timeInput = newFrame.querySelector('input[name="time"]'); + let localTime = luxon.DateTime.fromISO(reminder["utc_time"]).setZone( + timezone ); + timeInput.value = localTime.toFormat("yyyy-LL-dd'T'HH:mm:ss"); - for (let reminder of data) { - let newFrame = $template.content.cloneNode(true); + $reminderBox.appendChild(newFrame); - newFrame.querySelector(".reminderContent").dataset.uid = - reminder["uid"]; - - // populate channels - set_channels( - newFrame.querySelector("select.channel-selector") - ); - - // populate majority of items - for (let prop in reminder) { - if ( - reminder.hasOwnProperty(prop) && - reminder[prop] !== null - ) { - let $input = newFrame.querySelector( - `*[name="${prop}"]` - ); - let $image = newFrame.querySelector( - `img.${prop}` - ); - - if ($input !== null) { - $input.value = reminder[prop]; - } else if ($image !== null) { - $image.src = reminder[prop]; - } - } - } - - let guild = document.querySelector( - ".guildList a.is-active" - ).dataset["guild"]; - - let $enableBtn = - newFrame.querySelector(".disable-enable"); - - $enableBtn.textContent = reminder["enabled"] - ? "Disable" - : "Enable"; - $enableBtn.addEventListener("click", () => { - fetch(`/dashboard/api/guild/${guild}/reminders`, { - method: "PATCH", - body: JSON.stringify({ - uid: reminder["uid"], - enabled: false, - }), - }); - }); - - let timeInput = - newFrame.querySelector('input[name="time"]'); - let localTime = luxon.DateTime.fromISO( - reminder["utc_time"] - ).setZone(timezone); - timeInput.value = localTime.toFormat( - "yyyy-LL-dd'T'HH:mm:ss" - ); - - $reminderBox.appendChild(newFrame); - } + reminder.node = $reminderBox.lastElementChild; } - }); - } - document.dispatchEvent(remindersLoadedEvent); + const remindersLoadedEvent = new CustomEvent("remindersLoaded", { + detail: data, + }); + + document.dispatchEvent(remindersLoadedEvent); + } + }); + register_interval_hide(); } +document.addEventListener("remindersLoaded", (event) => { + const guild = document.querySelector(".guildList a.is-active").dataset["guild"]; + + for (let reminder of event.detail) { + reminder.node.querySelector("button.hide-box").addEventListener("click", () => { + reminder.node.closest(".reminderContent").classList.toggle("is-collapsed"); + }); + + const enableBtn = reminder.node.querySelector(".disable-enable"); + enableBtn.addEventListener("click", () => { + let enable = enableBtn.dataset.action === "enable"; + + fetch(`/dashboard/api/guild/${guild}/reminders`, { + method: "PATCH", + body: JSON.stringify({ + uid: reminder["uid"], + enabled: enable, + }), + }) + .then((response) => response.json()) + .then((data) => { + if (data.error) { + show_error(data.error); + } else { + enableBtn.textContent = data["enabled"] ? "Disable" : "Enable"; + enableBtn.dataset.action = data["enabled"] ? "enable" : "disable"; + } + }); + }); + } +}); + function show_error(error) { - document - .getElementById("errors") - .querySelector("span.error-message").textContent = error; + document.getElementById("errors").querySelector("span.error-message").textContent = + error; document.getElementById("errors").classList.add("is-active"); window.setTimeout(() => { @@ -235,9 +187,7 @@ function update_times() { element.textContent = timezone; }); document.querySelectorAll("span.set-time").forEach((element) => { - element.textContent = luxon.DateTime.now() - .setZone(timezone) - .toFormat("HH:mm"); + element.textContent = luxon.DateTime.now().setZone(timezone).toFormat("HH:mm"); }); document.querySelectorAll("span.browser-timezone").forEach((element) => { element.textContent = browserTimezone; @@ -249,9 +199,7 @@ function update_times() { element.textContent = botTimezone; }); document.querySelectorAll("span.bot-time").forEach((element) => { - element.textContent = luxon.DateTime.now() - .setZone(botTimezone) - .toFormat("HH:mm"); + element.textContent = luxon.DateTime.now().setZone(botTimezone).toFormat("HH:mm"); }); } @@ -263,12 +211,10 @@ document.getElementById("set-bot-timezone").addEventListener("click", () => { timezone = botTimezone; update_times(); }); -document - .getElementById("set-browser-timezone") - .addEventListener("click", () => { - timezone = browserTimezone; - update_times(); - }); +document.getElementById("set-browser-timezone").addEventListener("click", () => { + timezone = browserTimezone; + update_times(); +}); document.getElementById("update-bot-timezone").addEventListener("click", () => { timezone = browserTimezone; fetch("/dashboard/api/user", { @@ -302,21 +248,7 @@ colorPicker.on("color:change", function (color) { $colorPickerInput.value = color.hexString; }); -document.querySelectorAll(".discord-embed").forEach((element) => { - element.addEventListener("click", (e) => { - if ( - e.offsetX < - parseInt(window.getComputedStyle(element).borderLeftWidth) - ) { - $discordFrame = element; - $colorPickerModal.classList.toggle("is-active"); - colorPicker.color.rgbString = - window.getComputedStyle($discordFrame).borderLeftColor; - } - }); -}); - -document.querySelectorAll(".set-color").forEach((element) => { +document.querySelectorAll(".change-color").forEach((element) => { element.addEventListener("click", (e) => { e.preventDefault(); @@ -329,20 +261,16 @@ document.querySelectorAll(".set-color").forEach((element) => { }); }); -$colorPickerModal - .querySelector("button.is-success") - .addEventListener("click", () => { - $discordFrame.style.borderLeftColor = colorPicker.color.rgbString; +$colorPickerModal.querySelector("button.is-success").addEventListener("click", () => { + $discordFrame.style.borderLeftColor = colorPicker.color.rgbString; - $colorPickerModal.classList.remove("is-active"); - }); + $colorPickerModal.classList.remove("is-active"); +}); document.querySelectorAll("a.show-modal").forEach((element) => { element.addEventListener("click", (e) => { e.preventDefault(); - document - .getElementById(element.dataset["modal"]) - .classList.toggle("is-active"); + document.getElementById(element.dataset["modal"]).classList.toggle("is-active"); }); }); @@ -359,34 +287,35 @@ document.addEventListener("DOMContentLoaded", () => { }); }); + let hideBox = document.querySelector("#reminderCreator button.hide-box"); + hideBox.addEventListener("click", () => { + hideBox.closest(".reminderContent").classList.toggle("is-collapsed"); + }); + fetch("/dashboard/api/user") .then((response) => response.json()) .then((data) => { if (data.error) { show_error(data.error); } else { - document - .querySelectorAll("a.switch-pane") - .forEach((element) => { - element.innerHTML = element.innerHTML.replace( - "%username%", - data.name - ); + document.querySelectorAll("a.switch-pane").forEach((element) => { + element.innerHTML = element.innerHTML.replace( + "%username%", + data.name + ); - element.addEventListener("click", (e) => { - e.preventDefault(); + element.addEventListener("click", (e) => { + e.preventDefault(); - switch_pane(element.dataset["pane"]); + switch_pane(element.dataset["pane"]); - element.classList.add("is-active"); + element.classList.add("is-active"); - document - .querySelectorAll("p.pageTitle") - .forEach((el) => { - el.textContent = "Your Reminders"; - }); + document.querySelectorAll("p.pageTitle").forEach((el) => { + el.textContent = "Your Reminders"; }); }); + }); if (data.timezone !== null) { botTimezone = data.timezone; @@ -405,78 +334,67 @@ document.addEventListener("DOMContentLoaded", () => { const $template = document.getElementById("guildListEntry"); for (let guild of data) { - document - .querySelectorAll(".guildList") - .forEach((element) => { - const $clone = $template.content.cloneNode(true); - const $anchor = $clone.querySelector("a"); + document.querySelectorAll(".guildList").forEach((element) => { + const $clone = $template.content.cloneNode(true); + const $anchor = $clone.querySelector("a"); - $anchor.innerHTML = $clone - .querySelector("a") - .innerHTML.replace("%guildname%", guild.name); - $anchor.dataset["guild"] = guild.id; - $anchor.dataset["name"] = guild.name; + $anchor.innerHTML = $clone + .querySelector("a") + .innerHTML.replace("%guildname%", guild.name); + $anchor.dataset["guild"] = guild.id; + $anchor.dataset["name"] = guild.name; - $anchor.addEventListener("click", async (e) => { - e.preventDefault(); + $anchor.addEventListener("click", async (e) => { + e.preventDefault(); - $loader.classList.remove("is-hidden"); + $loader.classList.remove("is-hidden"); - switch_pane($anchor.dataset["pane"]); + switch_pane($anchor.dataset["pane"]); - reset_guild_pane(); + reset_guild_pane(); - fetch_roles($anchor.dataset["guild"]); + fetch_roles($anchor.dataset["guild"]); - await fetch( - `/dashboard/api/guild/${$anchor.dataset["guild"]}/channels` - ) - .then((response) => response.json()) - .then((data) => { - if (data.error) { - if ( - data.error === - "Bot not in guild" - ) { - switch_pane("guild-error"); - } else { - show_error(data.error); - } + await fetch( + `/dashboard/api/guild/${$anchor.dataset["guild"]}/channels` + ) + .then((response) => response.json()) + .then((data) => { + if (data.error) { + if (data.error === "Bot not in guild") { + switch_pane("guild-error"); } else { - channels = data; - - document - .querySelectorAll( - "select.channel-selector" - ) - .forEach(set_channels); + show_error(data.error); } - }); + } else { + channels = data; - fetch_reminders($anchor.dataset["guild"]); + document + .querySelectorAll("select.channel-selector") + .forEach(set_channels); + } + }); - document - .querySelectorAll("p.pageTitle") - .forEach((el) => { - el.textContent = - $anchor.dataset["name"] + - " Reminders"; - }); - document - .querySelectorAll("select.channel-selector") - .forEach((el) => { - el.addEventListener("change", (e) => { - update_select(e.target); - }); - }); - $anchor.classList.add("is-active"); - resize_textareas(); + fetch_reminders($anchor.dataset["guild"]); - $loader.classList.add("is-hidden"); + document.querySelectorAll("p.pageTitle").forEach((el) => { + el.textContent = $anchor.dataset["name"] + " Reminders"; }); + document + .querySelectorAll("select.channel-selector") + .forEach((el) => { + el.addEventListener("change", (e) => { + update_select(e.target); + }); + }); + $anchor.classList.add("is-active"); + resize_textareas(); - element.append($clone); + $loader.classList.add("is-hidden"); }); + + element.append($clone); + }); } } }); @@ -505,95 +423,68 @@ function set_channels(element) { let $createReminder = document.querySelector("#reminderCreator"); -$createReminder - .querySelector("button#createReminder") - .addEventListener("click", () => { - // create reminder object - let seconds = - parseInt( - $createReminder.querySelector('input[name="interval_seconds"]') - .value - ) || null; - let months = - parseInt( - $createReminder.querySelector('input[name="interval_months"]') - .value - ) || null; +$createReminder.querySelector("button#createReminder").addEventListener("click", () => { + // create reminder object + let seconds = + parseInt($createReminder.querySelector('input[name="interval_seconds"]').value) || + null; + let months = + parseInt($createReminder.querySelector('input[name="interval_months"]').value) || + null; - let rgb_color = window.getComputedStyle( - $createReminder.querySelector("div.discord-embed") - ).borderLeftColor; - let rgb = rgb_color.match(/\d+/g); - let color = colorToInt( - parseInt(rgb[0]), - parseInt(rgb[1]), - parseInt(rgb[2]) - ); + let rgb_color = window.getComputedStyle( + $createReminder.querySelector("div.discord-embed") + ).borderLeftColor; + let rgb = rgb_color.match(/\d+/g); + let color = colorToInt(parseInt(rgb[0]), parseInt(rgb[1]), parseInt(rgb[2])); - let utc_time = luxon.DateTime.fromISO( - $createReminder.querySelector('input[name="time"]').value - ).setZone("UTC"); + let utc_time = luxon.DateTime.fromISO( + $createReminder.querySelector('input[name="time"]').value + ).setZone("UTC"); - let reminder = { - avatar: $createReminder.querySelector("img.discord-avatar").src, - channel: $createReminder.querySelector("select.channel-selector") - .value, - content: $createReminder.querySelector("textarea#messageContent") - .value, - embed_author_url: $createReminder.querySelector( - "img.embed_author_url" - ).src, - embed_author: $createReminder.querySelector("textarea#embedAuthor") - .value, - embed_color: color, - embed_description: $createReminder.querySelector( - "textarea#embedDescription" - ).value, - embed_footer: $createReminder.querySelector("textarea#embedFooter") - .value, - embed_footer_url: $createReminder.querySelector( - "img.embed_footer_url" - ).src, - embed_image_url: $createReminder.querySelector( - "img.embed_image_url" - ).src, - embed_thumbnail_url: $createReminder.querySelector( - "img.embed_thumbnail_url" - ).src, - embed_title: $createReminder.querySelector("textarea#embedTitle") - .value, - enabled: true, - expires: null, - interval_seconds: seconds, - interval_months: months, - name: $createReminder.querySelector('input[name="name"]').value, - pin: $createReminder.querySelector('input[name="pin"]').checked, - restartable: false, - tts: $createReminder.querySelector('input[name="tts"]').checked, - username: $createReminder.querySelector("input#reminderUsername") - .value, - utc_time: utc_time.toFormat("yyyy-LL-dd'T'HH:mm:ss"), - }; + let reminder = { + avatar: $createReminder.querySelector("img.discord-avatar").src, + channel: $createReminder.querySelector("select.channel-selector").value, + content: $createReminder.querySelector("textarea#messageContent").value, + embed_author_url: $createReminder.querySelector("img.embed_author_url").src, + embed_author: $createReminder.querySelector("textarea#embedAuthor").value, + embed_color: color, + embed_description: $createReminder.querySelector("textarea#embedDescription") + .value, + embed_footer: $createReminder.querySelector("textarea#embedFooter").value, + embed_footer_url: $createReminder.querySelector("img.embed_footer_url").src, + embed_image_url: $createReminder.querySelector("img.embed_image_url").src, + embed_thumbnail_url: $createReminder.querySelector("img.embed_thumbnail_url").src, + embed_title: $createReminder.querySelector("textarea#embedTitle").value, + enabled: true, + expires: null, + interval_seconds: seconds, + interval_months: months, + name: $createReminder.querySelector('input[name="name"]').value, + pin: $createReminder.querySelector('input[name="pin"]').checked, + restartable: false, + tts: $createReminder.querySelector('input[name="tts"]').checked, + username: $createReminder.querySelector("input#reminderUsername").value, + utc_time: utc_time.toFormat("yyyy-LL-dd'T'HH:mm:ss"), + }; - // send to server - let guild = document.querySelector(".guildList a.is-active").dataset[ - "guild" - ]; + // send to server + let guild = document.querySelector(".guildList a.is-active").dataset["guild"]; - fetch(`/dashboard/api/guild/${guild}/reminders`, { - method: "POST", - headers: { - "Content-Type": "application/json", - }, - body: JSON.stringify(reminder), - }) - .then((response) => response.json()) - .then((data) => console.log(data)); + fetch(`/dashboard/api/guild/${guild}/reminders`, { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify(reminder), + }) + .then((response) => response.json()) + .then((data) => console.log(data)); - // process response + // process response - // reset inputs - }); + // reset inputs +}); document.querySelectorAll("textarea.autoresize").forEach((element) => { element.addEventListener("input", () => { @@ -641,18 +532,6 @@ document.querySelectorAll("a.icon-toggle").forEach((element) => { }); }); -let $showButton = document.querySelector("button#showReminderCreator"); - -$showButton.addEventListener("click", () => { - $showButton - .querySelector("span.icon i") - .classList.toggle("fa-chevron-right"); - $showButton - .querySelector("span.icon i") - .classList.toggle("fa-chevron-down"); - document.querySelector("div#reminderCreator").classList.toggle("is-hidden"); -}); - function register_interval_hide() { let $showInterval = document.querySelectorAll("a.intervalLabel"); @@ -679,9 +558,7 @@ fileInput.forEach((element) => { function check_embed_fields() { document.querySelectorAll(".discord-field-title").forEach((element) => { const $template = document.querySelector("template#embedFieldTemplate"); - const $complement = element.parentElement.querySelector( - ".discord-field-value" - ); + const $complement = element.parentElement.querySelector(".discord-field-value"); // when the user clicks out of the field title and if the field title/value are empty, remove the field element.addEventListener("blur", () => { @@ -732,15 +609,18 @@ function check_embed_fields() { document.addEventListener("DOMNodeInserted", () => { document.querySelectorAll("div.mobile-sidebar a").forEach((element) => { element.addEventListener("click", (e) => { - document - .getElementById("mobileSidebar") - .classList.remove("is-active"); + document.getElementById("mobileSidebar").classList.remove("is-active"); document.querySelectorAll(".navbar-burger").forEach((el) => { el.classList.remove("is-active"); }); }); }); + document.querySelectorAll('input[type="datetime-local"]').forEach((el) => { + let now = luxon.DateTime.now().setZone(timezone); + el.min = now.toFormat("yyyy-LL-dd'T'HH:mm:ss"); + }); + check_embed_fields(); resize_textareas(); }); diff --git a/web/static/js/sort.js b/web/static/js/sort.js index 17b468c..3797d39 100644 --- a/web/static/js/sort.js +++ b/web/static/js/sort.js @@ -12,9 +12,27 @@ function sort_by(cond) { .forEach((node) => guildReminders.appendChild(node)); // go through and add channel categories - for (let child in guildReminders.children) { + let currentChannelGroup = null; + for (let child of guildReminders.querySelectorAll("div.reminderContent")) { + let thisChannelGroup = child.querySelector("select.channel-selector").value; + + if (currentChannelGroup !== thisChannelGroup) { + let newNode = document.createElement("div"); + newNode.textContent = + "#" + channels.find((a) => a.id === thisChannelGroup).name; + newNode.classList.add("channel-tag"); + + guildReminders.insertBefore(newNode, child); + + currentChannelGroup = thisChannelGroup; + } } } else { + // remove any channel tags if previous ordering was by channel + guildReminders.querySelectorAll("div.channel-tag").forEach((el) => { + el.remove(); + }); + if (cond === "time") { [...guildReminders.children] .sort((a, b) => { @@ -41,8 +59,10 @@ function sort_by(cond) { } } -document.querySelector("#orderBy").addEventListener("change", (element) => { - sort_by(element.value); +const selector = document.querySelector("#orderBy"); + +selector.addEventListener("change", () => { + sort_by(selector.value); }); document.addEventListener("remindersLoaded", () => { diff --git a/web/templates/dashboard.html.tera b/web/templates/dashboard.html.tera index 73a6ccc..820313a 100644 --- a/web/templates/dashboard.html.tera +++ b/web/templates/dashboard.html.tera @@ -54,14 +54,14 @@ -