diff --git a/.prettierrc.toml b/.prettierrc.toml new file mode 100644 index 0000000..943b086 --- /dev/null +++ b/.prettierrc.toml @@ -0,0 +1,2 @@ +printWidth = 90 +tabWidth = 4 diff --git a/Cargo.lock b/Cargo.lock index 1c04e94..adb2f78 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -49,7 +49,7 @@ version = "0.7.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fcb51a0695d8f838b1ee009b3fbf66bda078cd64590202a864a8f3e8c4315c47" dependencies = [ - "getrandom 0.2.4", + "getrandom 0.2.6", "once_cell", "version_check", ] @@ -74,9 +74,9 @@ dependencies = [ [[package]] name = "async-stream" -version = "0.3.2" +version = "0.3.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "171374e7e3b2504e0e5236e3b59260560f9fe94bfe9ac39ba5e4e929c5590625" +checksum = "dad5c83079eae9969be7fadefe640a1c566901f05ff91ab221de4b6f68d9507e" dependencies = [ "async-stream-impl", "futures-core", @@ -84,9 +84,9 @@ dependencies = [ [[package]] name = "async-stream-impl" -version = "0.3.2" +version = "0.3.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "648ed8c8d2ce5409ccd57453d9d1b214b342a0d69376a6feda1fd6cae3299308" +checksum = "10f203db73a71dfa2fb6dd22763990fa26f3d2625a6da2da900d23b87d26be27" dependencies = [ "proc-macro2", "quote", @@ -95,9 +95,9 @@ dependencies = [ [[package]] name = "async-trait" -version = "0.1.52" +version = "0.1.53" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "061a7acccaa286c011ddc30970520b98fa40e00c9d644633fb26b5fc63a265e3" +checksum = "ed6aa3524a2dfcf9fe180c51eae2b58738348d819517ceadf95789c51fff7600" dependencies = [ "proc-macro2", "quote", @@ -106,18 +106,18 @@ dependencies = [ [[package]] name = "async-tungstenite" -version = "0.16.1" +version = "0.17.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5682ea0913e5c20780fe5785abacb85a411e7437bf52a1bedb93ddb3972cb8dd" +checksum = "a1b71b31561643aa8e7df3effe284fa83ab1a840e52294c5f4bd7bfd8b2becbb" dependencies = [ "futures-io", "futures-util", "log", "pin-project-lite", "tokio", - "tokio-rustls 0.23.2", + "tokio-rustls 0.23.3", "tungstenite", - "webpki-roots 0.22.2", + "webpki-roots 0.22.3", ] [[package]] @@ -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", ] [[package]] @@ -151,15 +151,9 @@ dependencies = [ [[package]] name = "autocfg" -version = "0.1.7" +version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1d49d90015b3c36167a20fe2810c5cd875ad504b39cff3d4eae7977e6b7c1cb2" - -[[package]] -name = "autocfg" -version = "1.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cdb031dd78e28731d87d56cc8ffef4a8f36ca26c38fe2de700543e627f8a464a" +checksum = "d468802bab17cbc0cc575e9b053f41e72aa36bfa6b7f55e3529ffa43161b97fa" [[package]] name = "base64" @@ -169,9 +163,9 @@ checksum = "904dfeac50f3cdaba28fc6f57fdcddb75f49ed61346676a78c4ffe55877802fd" [[package]] name = "base64ct" -version = "1.1.1" +version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e6b4d9b1225d28d360ec6a231d65af1fd99a2a095154c8040689617290569c5c" +checksum = "dea908e7347a8c64e378c17e30ef880ad73e3b4498346b055c2c00ea342f3179" [[package]] name = "bigdecimal" @@ -270,9 +264,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" @@ -344,9 +338,9 @@ dependencies = [ [[package]] name = "const-oid" -version = "0.6.2" +version = "0.7.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9d6f2aa4d0537bcc1c74df8755072bd31c1ef1a3a1b85a68e8404a8c353b7b8b" +checksum = "e4c78c047431fee22c1a7bb92e00ad095a02a983affe4d8a72e2a2c62c1b94f3" [[package]] name = "cookie" @@ -357,19 +351,20 @@ 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", + "time 0.3.9", "version_check", ] [[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", @@ -383,9 +378,9 @@ checksum = "5827cebf4670468b8772dd191856768aedcb1b0278a04f989f7766351917b9dc" [[package]] name = "cpufeatures" -version = "0.2.1" +version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "95059428f66df56b63431fdb4e1947ed2190586af5c5a8a8b71122bdf5a7f469" +checksum = "59a6001667ab124aebae2a495118e11d30984c3a653e99d86d58971708cf5e4b" dependencies = [ "libc", ] @@ -407,28 +402,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.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b979d76c9fcb84dffc80a73f7290da0f83e4c95773494674cb44b76d13a7a110" +checksum = "1f25d8400f4a7a5778f0e4e52384a48cbd9b5c495d110786187fc750075277a2" dependencies = [ "cfg-if 1.0.0", "crossbeam-utils", @@ -436,9 +421,9 @@ dependencies = [ [[package]] name = "crossbeam-utils" -version = "0.8.6" +version = "0.8.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cfcae03edb34f947e64acdb1c33ec169824e20657e9ecb61cef6c8c74dcb8120" +checksum = "0bf124c720b7686e3c2663cf54062ab0f68a88af2fb6a030e87e30bf721fcb38" dependencies = [ "cfg-if 1.0.0", "lazy_static", @@ -446,22 +431,22 @@ dependencies = [ [[package]] name = "crypto-bigint" -version = "0.2.11" +version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f83bd3bb4314701c568e340cd8cf78c975aa0ca79e03d3f6d1677d5b0c9c0c03" +checksum = "03c6a1d5fa1de37e071642dfa44ec552ca5b299adb128fab16138e24b548fd21" dependencies = [ "generic-array 0.14.5", - "rand_core 0.6.3", "subtle", ] [[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,25 +459,72 @@ 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 = "5.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c8858831f7781322e539ea39e72449c46b059638250c14344fec8d0aa6e539c" dependencies = [ "cfg-if 1.0.0", "num_cpus", - "parking_lot", + "parking_lot 0.12.0", "serde", ] [[package]] name = "der" -version = "0.4.5" +version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "79b71cca7d95d7681a4b3b9cdf63c8dbc3730d0584c2c74e31416d64a90493f4" +checksum = "6919815d73839e7ad218de758883aae3a257ba6759ce7a9992501efbb53d705c" dependencies = [ "const-oid", "crypto-bigint", + "pem-rfc7468", +] + +[[package]] +name = "derivative" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fcc3dd5e9e9c0b295d6e1e4d811fb6f157d5ffd784b8d202fc62eac8035a770b" +dependencies = [ + "proc-macro2", + "quote", + "syn", ] [[package]] @@ -554,9 +586,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", @@ -577,9 +609,9 @@ checksum = "e78d4f1cc4ae33bbfc157ed5d5a5ef3bc29227303d595861deb238fcec4e9457" [[package]] name = "encoding_rs" -version = "0.8.30" +version = "0.8.31" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7896dc8abb250ffdda33912550faa54c88ec8b998dec0b2c55ab224921ce11df" +checksum = "9852635589dc9f9ea1b6fe9f05b50ef208c85c834a562f0c6abb1c475736ec2b" dependencies = [ "cfg-if 1.0.0", ] @@ -597,6 +629,12 @@ dependencies = [ "termcolor", ] +[[package]] +name = "event-listener" +version = "2.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77f3309417938f28bf8228fcff79a4a37103981e3e186d2ccd19c74b38f4eb71" + [[package]] name = "fake-simd" version = "0.1.2" @@ -628,9 +666,9 @@ dependencies = [ [[package]] name = "filetime" -version = "0.2.15" +version = "0.2.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "975ccf83d8d9d0d84682850a38c8169027be83368805971cc4f238c2b245bc98" +checksum = "c0408e2626025178a6a7f7ffc05a25bc47103229f19c113755de7bf63816290c" dependencies = [ "cfg-if 1.0.0", "libc", @@ -640,9 +678,9 @@ dependencies = [ [[package]] name = "flate2" -version = "1.0.22" +version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e6988e897c1c9c485f43b47a529cef42fde0547f9d8d41a7062518f1d8fc53f" +checksum = "b39522e96686d38f4bc984b9198e3a0613264abaebaff2c5c918bfa6b6da09af" dependencies = [ "cfg-if 1.0.0", "crc32fast", @@ -718,9 +756,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 +770,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 +780,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 +792,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", @@ -837,9 +875,9 @@ dependencies = [ [[package]] name = "getrandom" -version = "0.2.4" +version = "0.2.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "418d37c8b1d42553c93648be529cb70f920d3baf8ef469b74b9638df426e0b4c" +checksum = "9be70c98951c83b8d2f8f60d7065fa6d5146873094452a1008da8c2f1e4205ad" dependencies = [ "cfg-if 1.0.0", "js-sys", @@ -890,9 +928,9 @@ dependencies = [ [[package]] name = "h2" -version = "0.3.11" +version = "0.3.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d9f1f717ddc7b2ba36df7e871fd88db79326551d3d6f1fc406fbfd28b582ff8e" +checksum = "37a82c6d637fc9515a4694bbf1cb2457b79d81ce52b3108bdeea58b07dd34a57" dependencies = [ "bytes", "fnv", @@ -903,7 +941,7 @@ dependencies = [ "indexmap", "slab", "tokio", - "tokio-util", + "tokio-util 0.7.1", "tracing", ] @@ -927,9 +965,9 @@ dependencies = [ [[package]] name = "heck" -version = "0.3.3" +version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6d621efb26863f0e9924c6ac577e8275e5e6b77455db64ffa6c65c904e9e132c" +checksum = "2540771e65fc8cb83cd6e8a237f70c319bd5c29f78ed1084ba5d50eeac86f7f9" dependencies = [ "unicode-segmentation", ] @@ -951,20 +989,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 +1013,7 @@ checksum = "31f4c6746584866f0feabcc69893c5b51beef3831656a968ed7ae254cdc4fd03" dependencies = [ "bytes", "fnv", - "itoa 1.0.1", + "itoa", ] [[package]] @@ -991,9 +1029,9 @@ dependencies = [ [[package]] name = "httparse" -version = "1.5.1" +version = "1.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "acd94fdbe1d4ff688b67b04eee2e17bd50995534a61539e45adfefb45e5e5503" +checksum = "6330e8a36bd8c859f3fa6d9382911fbb7147ec39807f63b923933a247240b9ba" [[package]] name = "httpdate" @@ -1015,9 +1053,9 @@ checksum = "9a3a5bfb195931eeb336b2a7b4d761daec841b97f947d34394601737a7bba5e4" [[package]] name = "hyper" -version = "0.14.16" +version = "0.14.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b7ec3e62bdc98a2f0393a5048e4c30ef659440ea6e0e572965103e72bd836f55" +checksum = "b26ae0a80afebe130861d90abf98e3814a4f28a4c6ffeb5ab8ebb2be311e0ef2" dependencies = [ "bytes", "futures-channel", @@ -1028,7 +1066,7 @@ dependencies = [ "http-body", "httparse", "httpdate", - "itoa 0.4.8", + "itoa", "pin-project-lite", "socket2", "tokio", @@ -1045,9 +1083,9 @@ checksum = "d87c48c02e0dc5e3b849a2041db3029fd066650f8f717c07bf8ed78ccb895cac" dependencies = [ "http", "hyper", - "rustls 0.20.2", + "rustls 0.20.4", "tokio", - "tokio-rustls 0.23.2", + "tokio-rustls 0.23.3", ] [[package]] @@ -1063,6 +1101,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" @@ -1094,11 +1138,11 @@ dependencies = [ [[package]] name = "indexmap" -version = "1.8.0" +version = "1.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "282a6247722caba404c065016bbfa522806e51714c34f5dfc3e4a3a46fcb4223" +checksum = "0f647032dfaa1f8b6dc29bd3edb7bbef4861b8b8007ebb118d6db284fd59f6ee" dependencies = [ - "autocfg 1.0.1", + "autocfg", "hashbrown", "serde", ] @@ -1149,9 +1193,9 @@ dependencies = [ [[package]] name = "ipnet" -version = "2.3.1" +version = "2.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "68f2d64f2edebec4ce84ad108148e67e1064789bee435edc5b60ad398714a3a9" +checksum = "879d54834c8c76457ef4293a689b2a8c59b076067ad77b15efafbb05f92a592b" [[package]] name = "itertools" @@ -1162,12 +1206,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" @@ -1176,9 +1214,9 @@ checksum = "1aab8fc367588b89dcee83ab0fd66b72b50b72fa1904d7095045ace2b0c81c35" [[package]] name = "js-sys" -version = "0.3.56" +version = "0.3.57" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a38fc24e30fd564ce974c02bf1d337caddff65be6cc4735a1f7eab22a7440f04" +checksum = "671a26f820db17c2a2750743f1dd03bafd15b98c9f30c7c2628c024c05d73397" dependencies = [ "wasm-bindgen", ] @@ -1216,30 +1254,31 @@ checksum = "db13adb97ab515a3691f56e4dbab09283d0b86cb45abd991d8634a9d6f501760" [[package]] name = "libc" -version = "0.2.116" +version = "0.2.124" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "565dbd88872dbe4cc8a46e527f26483c1d1f7afa6b884a3bd6cd893d4f98da74" +checksum = "21a41fed9d98f27ab1c6d161da622a4fa35e8a54a8adc24bbf3ddd0ef70b0e50" [[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" -version = "0.4.6" +version = "0.4.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "88943dd7ef4a2e5a4bfa2753aaab3013e34ce2533d1996fb18ef591e315e2b3b" +checksum = "327fa5b6a6940e4699ec49a9beae1ea4845c6bab9314e4f84ac68742139d8c53" dependencies = [ + "autocfg", "scopeguard", ] [[package]] name = "log" -version = "0.4.14" +version = "0.4.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "51b9bbe6c47d51fc3e1a9b945965946b4c44142ab8792c50835a980d362c2710" +checksum = "6389c490849ff5bc16be905ae24bc913a9c8892e19b2341dbc175e14c341c2b8" dependencies = [ "cfg-if 1.0.0", ] @@ -1294,9 +1333,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", @@ -1310,12 +1349,11 @@ checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" [[package]] name = "miniz_oxide" -version = "0.4.4" +version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a92518e98c078586bc6c934028adcca4c92a53d6a958196de835170a01d84e4b" +checksum = "d2b29bd4bc3f33391105ebee3589c19197c4271e3e5a9ec9bfe8127eeff8f082" dependencies = [ "adler", - "autocfg 1.0.1", ] [[package]] @@ -1339,14 +1377,15 @@ dependencies = [ [[package]] name = "mio" -version = "0.7.14" +version = "0.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8067b404fe97c70829f082dec8bcf4f71225d7eaea1d8645349cb76fa06205cc" +checksum = "52da4364ffb0e4fe33a9841a98a3f3014fb964045ce4f7a45a398243c8d6b0c9" dependencies = [ "libc", "log", "miow 0.3.7", "ntapi", + "wasi 0.11.0+wasi-snapshot-preview1", "winapi 0.3.9", ] @@ -1397,17 +1436,17 @@ dependencies = [ "log", "memchr", "mime", - "spin 0.9.2", + "spin 0.9.3", "tokio", - "tokio-util", + "tokio-util 0.6.9", "version_check", ] [[package]] name = "native-tls" -version = "0.2.8" +version = "0.2.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "48ba9f7719b5a0f42f338907614285fb5fd70e53858141f69898a1fb7203b24d" +checksum = "fd7e2f3618557f980e0b17e8856252eee3c97fa12c54dff0ca290fb6266ca4a9" dependencies = [ "lazy_static", "libc", @@ -1434,13 +1473,12 @@ dependencies = [ [[package]] name = "nom" -version = "7.1.0" +version = "7.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1b1d11e1ef389c76fe5b81bcaf2ea32cf88b62bc494e19f493d0b30e7a930109" +checksum = "a8903e5a29a317527874d0402f867152a3d21c908bb0b933e416c65e301d4c36" dependencies = [ "memchr", "minimal-lexical", - "version_check", ] [[package]] @@ -1472,9 +1510,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,25 +1523,24 @@ version = "0.3.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5f6f7833f2cbf2360a6cfd58cd41a53aa7a90bd4c202f5b1c7dd2ed73c57b2c3" dependencies = [ - "autocfg 1.0.1", + "autocfg", "num-integer", "num-traits", ] [[package]] name = "num-bigint-dig" -version = "0.7.0" +version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4547ee5541c18742396ae2c895d0717d0f886d8823b8399cdaf7b07d63ad0480" +checksum = "566d173b2f9406afbc5510a90925d5a2cd80cae4605631f1212303df265de011" dependencies = [ - "autocfg 0.1.7", "byteorder", "lazy_static", "libm", "num-integer", "num-iter", "num-traits", - "rand 0.8.4", + "rand 0.8.5", "smallvec", "zeroize", ] @@ -1514,7 +1551,7 @@ version = "0.1.44" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d2cc698a63b549a70bc047073d2949cce27cd1c7b0a4a862d08a8031bc2801db" dependencies = [ - "autocfg 1.0.1", + "autocfg", "num-traits", ] @@ -1524,7 +1561,7 @@ version = "0.1.42" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b2021c8337a54d21aca0d59a92577a029af9431cb59b909b03252b9c164fad59" dependencies = [ - "autocfg 1.0.1", + "autocfg", "num-integer", "num-traits", ] @@ -1535,7 +1572,7 @@ version = "0.2.14" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9a64b1ec5cda2586e284722486d802acf1f7dbdc623e2bfc57e65ca1cd099290" dependencies = [ - "autocfg 1.0.1", + "autocfg", "libm", ] @@ -1551,9 +1588,9 @@ dependencies = [ [[package]] name = "num_threads" -version = "0.1.3" +version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "97ba99ba6393e2c3734791401b66902d981cb03bf190af674ca69949b6d5fb15" +checksum = "aba1801fb138d8e85e11d0fc70baf4fe1cdfffda7c6cd34a854905df588e5ed0" dependencies = [ "libc", ] @@ -1566,9 +1603,9 @@ checksum = "80e47cfc4c0a1a519d9a025ebfbac3a2439d1b5cdf397d72dcb79b11d9920dab" dependencies = [ "base64", "chrono", - "getrandom 0.2.4", + "getrandom 0.2.6", "http", - "rand 0.8.4", + "rand 0.8.5", "reqwest", "serde", "serde_json", @@ -1580,9 +1617,9 @@ dependencies = [ [[package]] name = "once_cell" -version = "1.9.0" +version = "1.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "da32515d9f6e6e489d7bc9d84c71b060db7247dc035bbe44eac88cf87486d8d5" +checksum = "87f3e037eac156d1775da914196f0f37741a274155e34a0b7e427c35d2a2ecb9" [[package]] name = "opaque-debug" @@ -1622,13 +1659,22 @@ version = "0.9.72" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7e46109c383602735fa0a2e48dd2b7c892b048e1bf69e5c3b1d804b7d9c203cb" dependencies = [ - "autocfg 1.0.1", + "autocfg", "cc", "libc", "pkg-config", "vcpkg", ] +[[package]] +name = "ordered-float" +version = "2.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7940cf2ca942593318d07fcf2596cdca60a85c9e7fab408a5e21a4f9dcd40d87" +dependencies = [ + "num-traits", +] + [[package]] name = "parking_lot" version = "0.11.2" @@ -1637,7 +1683,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.2", ] [[package]] @@ -1654,6 +1710,19 @@ dependencies = [ "winapi 0.3.9", ] +[[package]] +name = "parking_lot_core" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "995f667a6c822200b0433ac218e05582f0e2efa1b922a3fd2fbaadc5f87bab37" +dependencies = [ + "cfg-if 1.0.0", + "libc", + "redox_syscall", + "smallvec", + "windows-sys", +] + [[package]] name = "parse-zoneinfo" version = "0.3.0" @@ -1663,6 +1732,12 @@ dependencies = [ "regex", ] +[[package]] +name = "paste" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c520e05135d6e763148b6426a837e239041653ba7becd2e538c076c738025fc" + [[package]] name = "pear" version = "0.2.3" @@ -1688,9 +1763,9 @@ dependencies = [ [[package]] name = "pem-rfc7468" -version = "0.2.4" +version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "84e93a3b1cc0510b03020f33f21e62acdde3dcaef432edc95bea377fbd4c2cd4" +checksum = "01de5d978f34aa4b2296576379fcc416034702fd94117c56ffd8a1a767cefb30" dependencies = [ "base64ct", ] @@ -1770,7 +1845,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]] @@ -1797,33 +1872,61 @@ checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" [[package]] name = "pkcs1" -version = "0.2.4" +version = "0.3.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "116bee8279d783c0cf370efa1a94632f2108e5ef0bb32df31f051647810a4e2c" +checksum = "a78f66c04ccc83dd4486fd46c33896f4e17b24a7a3a6400dedc48ed0ddd72320" dependencies = [ "der", - "pem-rfc7468", + "pkcs8", "zeroize", ] [[package]] name = "pkcs8" -version = "0.7.6" +version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ee3ef9b64d26bad0536099c816c6734379e45bbd5f14798def6809e5cc350447" +checksum = "7cabda3fb821068a9a4fab19a683eac3af12edf0f34b94a8be53c4972b8149d0" dependencies = [ "der", - "pem-rfc7468", - "pkcs1", "spki", "zeroize", ] [[package]] name = "pkg-config" -version = "0.3.24" +version = "0.3.25" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "58893f751c9b0412871a09abd62ecd2a00298c6c83befa223ef98c52aef40cbe" +checksum = "1df8c4ec4b0627e53bdf214615ad287367e482558cf84b109250b37464dc03ae" + +[[package]] +name = "poise" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c2eb79ba8241eb65b549e778e22a242ea30f5829f7487f10d8d95cfff9a4729" +dependencies = [ + "async-trait", + "derivative", + "futures-core", + "futures-util", + "log", + "once_cell", + "poise_macros", + "regex", + "serenity", + "tokio", +] + +[[package]] +name = "poise_macros" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "15b786b4bdc58345204469420c306d8c638e465b4b1ecad7e27bf9c8a45096cf" +dependencies = [ + "darling", + "proc-macro2", + "quote", + "syn", +] [[package]] name = "polyval" @@ -1848,6 +1951,8 @@ dependencies = [ "log", "num-integer", "regex", + "serde", + "serde_json", "serenity", "sqlx", "tokio", @@ -1861,9 +1966,9 @@ checksum = "eb9f9e6e233e5c4a35559a617bf40a4ec447db2e84c20b55a6f83167b7e57872" [[package]] name = "proc-macro2" -version = "1.0.36" +version = "1.0.37" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c7342d5883fbccae1cc37a2353b09c87c9b0f3afd73f5fb9bba687a1f733b029" +checksum = "ec757218438d5fda206afc041538b2f6d889286160d649a86a24d37e1235afd1" dependencies = [ "unicode-xid", ] @@ -1883,9 +1988,9 @@ dependencies = [ [[package]] name = "quote" -version = "1.0.15" +version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "864d3e96a899863136fc6e99f3d7cae289dafe43bf2c5ac19b70df7210c0a145" +checksum = "a1feb54ed693b93a84e14094943b84b7c4eae204c512b7ccb95ab0c66d278ad1" dependencies = [ "proc-macro2", ] @@ -1900,19 +2005,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]] @@ -1950,7 +2054,7 @@ version = "0.6.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d34f1408f55294453790c48b2f1ebbb1c5b4b7563eb1f418bcfcfdbb06ebb4e7" dependencies = [ - "getrandom 0.2.4", + "getrandom 0.2.6", ] [[package]] @@ -1962,20 +2066,11 @@ 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" +version = "0.2.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8383f39639269cde97d255a32bdb68c047337295414940c68bdd30c2e13203ff" +checksum = "62f25bc4c7e55e0b0b7a1d43fb893f4fa1361d0abe38b9ce4f323c2adfe6ef42" dependencies = [ "bitflags", ] @@ -2002,9 +2097,9 @@ dependencies = [ [[package]] name = "regex" -version = "1.5.4" +version = "1.5.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d07a8629359eb56f1e2fb1652bb04212c072a87ba68546a04065d525673ac461" +checksum = "1a11647b6b25ff05a515cb92c365cec08801e83423a235b51e231e1808747286" dependencies = [ "aho-corasick", "memchr", @@ -2026,16 +2121,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" @@ -2045,22 +2130,20 @@ dependencies = [ "chrono-tz 0.5.3", "dotenv", "env_logger", - "humantime", "lazy_static", "levenshtein", "log", "num-integer", + "poise", "postman", "rand 0.7.3", "regex", - "regex_command_attr", "reminder_web", "reqwest", "rmp-serde", "serde", "serde_json", "serde_repr", - "serenity", "sqlx", "tokio", ] @@ -2069,14 +2152,18 @@ dependencies = [ name = "reminder_web" version = "0.1.0" dependencies = [ + "base64", "chrono", "chrono-tz 0.5.3", + "lazy_static", "log", "oauth2", + "rand 0.7.3", "reqwest", "rocket", "rocket_dyn_templates", "serde", + "serde_json", "serenity", "sqlx", ] @@ -2092,9 +2179,9 @@ dependencies = [ [[package]] name = "reqwest" -version = "0.11.9" +version = "0.11.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "87f242f1488a539a79bac6dbe7c8609ae43b7914b7736210f239a37cccb32525" +checksum = "46a1f7aa4f35e5e8b4160449f51afc758f0ce6454315a9fa7d0d113e958c41eb" dependencies = [ "base64", "bytes", @@ -2116,20 +2203,20 @@ dependencies = [ "native-tls", "percent-encoding", "pin-project-lite", - "rustls 0.20.2", + "rustls 0.20.4", "rustls-pemfile", "serde", "serde_json", "serde_urlencoded", "tokio", "tokio-native-tls", - "tokio-rustls 0.23.2", - "tokio-util", + "tokio-rustls 0.23.3", + "tokio-util 0.6.9", "url", "wasm-bindgen", "wasm-bindgen-futures", "web-sys", - "webpki-roots 0.22.2", + "webpki-roots 0.22.3", "winreg", ] @@ -2150,12 +2237,13 @@ dependencies = [ [[package]] name = "rmp" -version = "0.8.10" +version = "0.8.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4f55e5fa1446c4d5dd1f5daeed2a4fe193071771a2636274d0d7a3b082aa7ad6" +checksum = "44519172358fd6d58656c86ab8e7fbc9e1490c3e8f14d35ed78ca0dd07403c9f" dependencies = [ "byteorder", "num-traits", + "paste", ] [[package]] @@ -2172,7 +2260,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#49d138de14074d40fb9223467434a5626782f51a" dependencies = [ "async-stream", "async-trait", @@ -2188,9 +2276,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", @@ -2198,10 +2286,10 @@ dependencies = [ "serde_json", "state", "tempfile", - "time 0.3.7", + "time 0.3.9", "tokio", "tokio-stream", - "tokio-util", + "tokio-util 0.7.1", "ubyte", "version_check", "yansi", @@ -2210,7 +2298,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#49d138de14074d40fb9223467434a5626782f51a" dependencies = [ "devise", "glob", @@ -2225,7 +2313,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#49d138de14074d40fb9223467434a5626782f51a" dependencies = [ "glob", "normpath", @@ -2237,7 +2325,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#49d138de14074d40fb9223467434a5626782f51a" dependencies = [ "cookie", "either", @@ -2250,33 +2338,34 @@ dependencies = [ "percent-encoding", "pin-project-lite", "ref-cast", - "rustls 0.19.1", + "rustls 0.20.4", + "rustls-pemfile", "serde", "smallvec", "stable-pattern", "state", - "time 0.3.7", + "time 0.3.9", "tokio", - "tokio-rustls 0.22.0", + "tokio-rustls 0.23.3", "uncased", ] [[package]] name = "rsa" -version = "0.5.0" +version = "0.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e05c2603e2823634ab331437001b411b9ed11660fbc4066f3908c84a9439260d" +checksum = "4cf22754c49613d2b3b119f0e5d46e34a2c628a937e3024b8762de4e7d8c710b" dependencies = [ "byteorder", - "digest 0.9.0", - "lazy_static", + "digest 0.10.3", "num-bigint-dig", "num-integer", "num-iter", "num-traits", "pkcs1", "pkcs8", - "rand 0.8.4", + "rand_core 0.6.3", + "smallvec", "subtle", "zeroize", ] @@ -2296,9 +2385,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", @@ -2308,9 +2397,9 @@ dependencies = [ [[package]] name = "rustls-pemfile" -version = "0.2.1" +version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5eebeaeb360c87bfb72e84abdb3447159c0eaececf1bef2aecd65a8be949d1c9" +checksum = "1ee86d63972a7c661d1536fefe8c3c8407321c3df668891286de28abcd087360" dependencies = [ "base64", ] @@ -2380,9 +2469,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 +2482,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", @@ -2410,6 +2499,16 @@ dependencies = [ "serde_derive", ] +[[package]] +name = "serde-value" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3a1a3341211875ef120e117ea7fd5228530ae7e7036a779fdc9117be6b3282c" +dependencies = [ + "ordered-float", + "serde", +] + [[package]] name = "serde_derive" version = "1.0.136" @@ -2423,11 +2522,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,32 +2558,36 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" dependencies = [ "form_urlencoded", - "itoa 1.0.1", + "itoa", "ryu", "serde", ] [[package]] name = "serenity" -version = "0.10.10" -source = "git+https://github.com/serenity-rs/serenity?branch=next#9bf5f25ab8421a0513a199930a7a624da3d3c853" +version = "0.11.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "246008828de84241202b092316c4729da36c3edd8f0c17ed6893df8e414c3c9b" dependencies = [ "async-trait", "async-tungstenite", "base64", "bitflags", "bytes", + "cfg-if 1.0.0", "chrono", "dashmap", "flate2", "futures", "mime", "mime_guess", - "parking_lot", + "parking_lot 0.12.0", "percent-encoding", "reqwest", "serde", + "serde-value", "serde_json", + "time 0.3.9", "tokio", "tracing", "typemap_rev", @@ -2505,15 +2608,13 @@ dependencies = [ [[package]] name = "sha-1" -version = "0.9.8" +version = "0.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "99cd6713db3cf16b6c84e06321e049a9b9f699826e16096d23bbcc44d15d51a6" +checksum = "028f48d513f9678cda28f6e4064755b3fbb2af6acd672f2c209b62323f7aea0f" dependencies = [ - "block-buffer 0.9.0", "cfg-if 1.0.0", "cpufeatures", - "digest 0.9.0", - "opaque-debug 0.3.0", + "digest 0.10.3", ] [[package]] @@ -2531,13 +2632,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]] @@ -2560,15 +2661,15 @@ dependencies = [ [[package]] name = "siphasher" -version = "0.3.9" +version = "0.3.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a86232ab60fa71287d7f2ddae4a7073f6b7aac33631c3015abb556f08c6d0a3e" +checksum = "7bd3e3206899af3f8b12af284fafc038cc1dc2b41d1b89dd17297221c5d225de" [[package]] name = "slab" -version = "0.4.5" +version = "0.4.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9def91fd1e018fe007022791f865d0ccc9b3a0d5001e01aabb8b40e46000afb5" +checksum = "eb703cfe953bccee95685111adeedb76fabe4e97549a58d16f03ea7b9367bb32" [[package]] name = "slug" @@ -2603,16 +2704,17 @@ checksum = "6e63cff320ae2c57904679ba7cb63280a3dc4613885beafb148ee7bf9aa9042d" [[package]] name = "spin" -version = "0.9.2" +version = "0.9.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "511254be0c5bcf062b019a6c89c01a664aa359ded62f78aa72c6fc137c0590e5" +checksum = "c530c2b0d0bf8b69304b39fe2001993e267461948b890cd037d8ad4293fa1a0d" [[package]] name = "spki" -version = "0.4.1" +version = "0.5.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5c01a0c15da1b0b0e1494112e7af814a678fec9bd157881b49beac661e9b6f32" +checksum = "44d01ac02a6ccf3e07db148d2be087da624fea0221a16152ed01f0496a6b0a27" dependencies = [ + "base64ct", "der", ] @@ -2629,9 +2731,9 @@ dependencies = [ [[package]] name = "sqlx" -version = "0.5.10" +version = "0.5.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "692749de69603d81e016212199d73a2e14ee20e2def7d7914919e8db5d4d48b9" +checksum = "551873805652ba0d912fec5bbb0f8b4cdd96baf8e2ebf5970e5671092966019b" dependencies = [ "sqlx-core", "sqlx-macros", @@ -2639,9 +2741,9 @@ dependencies = [ [[package]] name = "sqlx-core" -version = "0.5.10" +version = "0.5.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "518be6f6fff5ca76f985d434f9c37f3662af279642acf730388f271dff7b9016" +checksum = "e48c61941ccf5ddcada342cd59e3e5173b007c509e1e8e990dafc830294d9dc5" dependencies = [ "ahash", "atoi", @@ -2651,11 +2753,10 @@ dependencies = [ "bytes", "chrono", "crc", - "crossbeam-channel", "crossbeam-queue", - "crossbeam-utils", - "digest 0.9.0", + "digest 0.10.3", "either", + "event-listener", "futures-channel", "futures-core", "futures-intrusive", @@ -2664,19 +2765,21 @@ 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", - "sha2 0.9.9", + "serde", + "serde_json", + "sha-1 0.10.0", + "sha2 0.10.2", "smallvec", "sqlformat", "sqlx-rt", @@ -2690,9 +2793,9 @@ dependencies = [ [[package]] name = "sqlx-macros" -version = "0.5.10" +version = "0.5.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "38e45140529cf1f90a5e1c2e561500ca345821a1c513652c8f486bbf07407cc8" +checksum = "bc0fba2b0cae21fc00fe6046f8baa4c7fcb49e379f0f592b04696607f69ed2e1" dependencies = [ "dotenv", "either", @@ -2700,7 +2803,8 @@ dependencies = [ "once_cell", "proc-macro2", "quote", - "sha2 0.9.9", + "serde_json", + "sha2 0.10.2", "sqlx-core", "sqlx-rt", "syn", @@ -2709,9 +2813,9 @@ dependencies = [ [[package]] name = "sqlx-rt" -version = "0.5.10" +version = "0.5.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8061cbaa91ee75041514f67a09398c65a64efed72c90151ecd47593bad53da99" +checksum = "4db708cd3e459078f85f39f96a00960bd841f66ee2a669e90bf36907f5a79aae" dependencies = [ "once_cell", "tokio", @@ -2746,6 +2850,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" @@ -2754,27 +2864,15 @@ checksum = "6bdef32e8150c2a081110b42772ffe7d7c9032b606bc226c8260fd97e0976601" [[package]] name = "syn" -version = "1.0.86" +version = "1.0.91" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8a65b3f4ffa0092e9887669db0eae07941f023991ab58ea44da8fe8e2d511c6b" +checksum = "b683b2b825c8eef438b77c36a06dc262294da3d5a5813fac20da149241dcd44d" dependencies = [ "proc-macro2", "quote", "unicode-xid", ] -[[package]] -name = "synstructure" -version = "0.12.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f36bdaa60a83aca3921b5259d5400cbf5e90fc51931376a9bd4a0eb79aa7210f" -dependencies = [ - "proc-macro2", - "quote", - "syn", - "unicode-xid", -] - [[package]] name = "tempfile" version = "3.3.0" @@ -2803,7 +2901,7 @@ dependencies = [ "percent-encoding", "pest", "pest_derive", - "rand 0.8.4", + "rand 0.8.5", "regex", "serde", "serde_json", @@ -2813,9 +2911,9 @@ dependencies = [ [[package]] name = "termcolor" -version = "1.1.2" +version = "1.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2dfed899f0eb03f32ee8c6a0aabdb8a7949659e3466561fc0adf54e26d88c5f4" +checksum = "bab24d30b911b2376f3a13cc2cd443142f0c81dda04c118693e35b3835757755" dependencies = [ "winapi-util", ] @@ -2861,21 +2959,22 @@ dependencies = [ [[package]] name = "time" -version = "0.3.7" +version = "0.3.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "004cbc98f30fa233c61a38bc77e96a9106e65c88f2d3bef182ae952027e5753d" +checksum = "c2702e08a7a860f005826c6815dcac101b19b5eb330c27fe4a5928fec1d20ddd" dependencies = [ - "itoa 1.0.1", + "itoa", "libc", "num_threads", + "serde", "time-macros", ] [[package]] name = "time-macros" -version = "0.2.3" +version = "0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "25eb0ca3468fc0acc11828786797f6ef9aa1555e4a211a60d64cc8e4d1be47d6" +checksum = "42657b1a6f4d817cda8e7a0ace261fe0cc946cf3a80314390b22cc61ae080792" [[package]] name = "tinyvec" @@ -2894,19 +2993,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.2", "num_cpus", "once_cell", - "parking_lot", + "parking_lot 0.12.0", "pin-project-lite", "signal-hook-registry", + "socket2", "tokio-macros", "winapi 0.3.9", ] @@ -2945,11 +3045,11 @@ dependencies = [ [[package]] name = "tokio-rustls" -version = "0.23.2" +version = "0.23.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a27d5f2b839802bd8267fa19b0530f5a08b9c08cd417976be2a65d130fe1c11b" +checksum = "4151fda0cf2798550ad0b34bcfc9b9dcc2a9d2471c895c68f3a8818e54f2389e" dependencies = [ - "rustls 0.20.2", + "rustls 0.20.4", "tokio", "webpki 0.22.0", ] @@ -2980,10 +3080,24 @@ dependencies = [ ] [[package]] -name = "toml" -version = "0.5.8" +name = "tokio-util" +version = "0.7.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a31142970826733df8241ef35dc040ef98c679ab14d7c3e54d827099b3acecaa" +checksum = "0edfdeb067411dba2044da6d1cb2df793dd35add7888d73c16e3381ded401764" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "pin-project-lite", + "tokio", + "tracing", +] + +[[package]] +name = "toml" +version = "0.5.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8d82e1a7758622a465f8cee077614c73484dac5b836c02ff6a40d5d1010324d7" dependencies = [ "serde", ] @@ -2996,9 +3110,9 @@ checksum = "360dfd1d6d30e05fda32ace2c8c70e9c0a9da713275777f5a4dbb8a1893930c6" [[package]] name = "tracing" -version = "0.1.29" +version = "0.1.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "375a639232caf30edfc78e8d89b2d4c375515393e7af7e16f01cd96917fb2105" +checksum = "5d0ecdcb44a79f0fe9844f0c4f33a342cbcbb5117de8001e6ba0dc2351327d09" dependencies = [ "cfg-if 1.0.0", "log", @@ -3009,9 +3123,9 @@ dependencies = [ [[package]] name = "tracing-attributes" -version = "0.1.18" +version = "0.1.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f4f480b8f81512e825f337ad51e94c1eb5d3bbdf2b363dcd01e2b19a9ffe3f8e" +checksum = "2e65ce065b4b5c53e73bb28912318cb8c9e9ad3921f1d669eb0e68b4c8143a2b" dependencies = [ "proc-macro2", "quote", @@ -3020,11 +3134,12 @@ dependencies = [ [[package]] name = "tracing-core" -version = "0.1.21" +version = "0.1.26" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1f4ed65637b8390770814083d20756f87bfa2c21bf2f110babdc5438351746e4" +checksum = "f54c8ca710e81886d498c2fd3331b56c93aa248d49de2222ad2742247c60072f" dependencies = [ "lazy_static", + "valuable", ] [[package]] @@ -3040,9 +3155,9 @@ dependencies = [ [[package]] name = "tracing-subscriber" -version = "0.3.7" +version = "0.3.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5312f325fe3588e277415f5a6cca1f4ccad0f248c4cd5a4bd33032d7286abc22" +checksum = "4bc28f93baff38037f64e6f43d34cfa1605f27a49c34e8a04c5e78b0babf2596" dependencies = [ "ansi_term", "lazy_static", @@ -3064,9 +3179,9 @@ checksum = "59547bce71d9c38b83d9c0e92b6066c4253371f15005def0c30d9657f50c7642" [[package]] name = "tungstenite" -version = "0.16.0" +version = "0.17.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6ad3713a14ae247f22a728a0456a545df14acf3867f905adff84be99e23b3ad1" +checksum = "d96a2dea40e7570482f28eb57afbe42d97551905da6a9400acc5c328d24004f5" dependencies = [ "base64", "byteorder", @@ -3074,9 +3189,9 @@ dependencies = [ "http", "httparse", "log", - "rand 0.8.4", - "rustls 0.20.2", - "sha-1 0.9.8", + "rand 0.8.5", + "rustls 0.20.4", + "sha-1 0.10.0", "thiserror", "url", "utf-8", @@ -3196,9 +3311,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" @@ -3248,13 +3363,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9" [[package]] -name = "uuid" -version = "0.8.2" +name = "valuable" +version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bc5cf98d8186244414c848017f0e2676b3fcb46807f6668a97dfe67359a3c4b7" -dependencies = [ - "getrandom 0.2.4", -] +checksum = "830b7e5d4d90034032940e4ace0d9a9a057e7a45cd94e6c007832e39edb82f6d" [[package]] name = "vcpkg" @@ -3302,10 +3414,16 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fd6fbd9a79829dd1ad0cc20627bf1ed606756a7f77edff7b66b7064f9cb327c6" [[package]] -name = "wasm-bindgen" -version = "0.2.79" +name = "wasi" +version = "0.11.0+wasi-snapshot-preview1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "25f1af7423d8588a3d840681122e72e6a24ddbcb3f0ec385cac0d12d24256c06" +checksum = "9c8d87e72b64a3b4db28d11ce29237c246188f4f51057d65a7eab63b7987e423" + +[[package]] +name = "wasm-bindgen" +version = "0.2.80" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27370197c907c55e3f1a9fbe26f44e937fe6451368324e009cba39e139dc08ad" dependencies = [ "cfg-if 1.0.0", "wasm-bindgen-macro", @@ -3313,9 +3431,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-backend" -version = "0.2.79" +version = "0.2.80" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8b21c0df030f5a177f3cba22e9bc4322695ec43e7257d865302900290bcdedca" +checksum = "53e04185bfa3a779273da532f5025e33398409573f348985af9a1cbf3774d3f4" dependencies = [ "bumpalo", "lazy_static", @@ -3328,9 +3446,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-futures" -version = "0.4.29" +version = "0.4.30" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2eb6ec270a31b1d3c7e266b999739109abce8b6c87e4b31fcfcd788b65267395" +checksum = "6f741de44b75e14c35df886aff5f1eb73aa114fa5d4d00dcd37b5e01259bf3b2" dependencies = [ "cfg-if 1.0.0", "js-sys", @@ -3340,9 +3458,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro" -version = "0.2.79" +version = "0.2.80" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2f4203d69e40a52ee523b2529a773d5ffc1dc0071801c87b3d270b471b80ed01" +checksum = "17cae7ff784d7e83a2fe7611cfe766ecf034111b49deb850a3dc7699c08251f5" dependencies = [ "quote", "wasm-bindgen-macro-support", @@ -3350,9 +3468,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro-support" -version = "0.2.79" +version = "0.2.80" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bfa8a30d46208db204854cadbb5d4baf5fcf8071ba5bf48190c3e59937962ebc" +checksum = "99ec0dc7a4756fffc231aab1b9f2f578d23cd391390ab27f952ae0c9b3ece20b" dependencies = [ "proc-macro2", "quote", @@ -3363,15 +3481,15 @@ dependencies = [ [[package]] name = "wasm-bindgen-shared" -version = "0.2.79" +version = "0.2.80" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3d958d035c4438e28c70e4321a2911302f10135ce78a9c7834c0cab4123d06a2" +checksum = "d554b7f530dee5964d9a9468d95c1f8b8acae4f282807e7d27d4b03099a46744" [[package]] name = "web-sys" -version = "0.3.56" +version = "0.3.57" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c060b319f29dd25724f09a2ba1418f142f539b2be99fbf4d2d5a8f7330afb8eb" +checksum = "7b17e741662c70c8bd24ac5c5b18de314a2c26c32bf8346ee1e6f53de919c283" dependencies = [ "js-sys", "wasm-bindgen", @@ -3408,9 +3526,9 @@ dependencies = [ [[package]] name = "webpki-roots" -version = "0.22.2" +version = "0.22.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "552ceb903e957524388c4d3475725ff2c8b7960922063af6ce53c9a43da07449" +checksum = "44d8de8415c823c8abd270ad483c6feeac771fad964890779f9a8cb24fbbc1bf" dependencies = [ "webpki 0.22.0", ] @@ -3459,10 +3577,53 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" [[package]] -name = "winreg" -version = "0.7.0" +name = "windows-sys" +version = "0.34.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0120db82e8a1e0b9fb3345a539c478767c0048d842860994d96113d5b667bd69" +checksum = "5acdd78cb4ba54c0045ac14f62d8f94a03d10047904ae2a40afa1e99d8f70825" +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.34.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "17cffbe740121affb56fad0fc0e421804adf0ae00891205213b5cecd30db881d" + +[[package]] +name = "windows_i686_gnu" +version = "0.34.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2564fde759adb79129d9b4f54be42b32c89970c18ebf93124ca8870a498688ed" + +[[package]] +name = "windows_i686_msvc" +version = "0.34.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9cd9d32ba70453522332c14d38814bceeb747d80b3958676007acadd7e166956" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.34.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfce6deae227ee8d356d19effc141a509cc503dfd1f850622ec4b0f84428e1f4" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.34.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d19538ccc21819d01deaf88d6a17eae6596a12e9aafdbb97916fb49896d89de9" + +[[package]] +name = "winreg" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "80d0f4e272c85def139476380b12f9ac60926689dd2e01d4923222f40580869d" dependencies = [ "winapi 0.3.9", ] @@ -3479,27 +3640,12 @@ dependencies = [ [[package]] name = "yansi" -version = "0.5.0" +version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9fc79f4a1e39857fc00c3f662cbf2651c771f00e9c15fe2abc341806bd46bd71" +checksum = "09041cd90cf85f7f8b2df60c646f853b7f535ce68f85244eb6731cf89fa498ec" [[package]] name = "zeroize" -version = "1.4.3" +version = "1.5.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d68d9dcec5f9b43a30d38c49f91dfedfaac384cb8f085faca366c26207dd1619" -dependencies = [ - "zeroize_derive", -] - -[[package]] -name = "zeroize_derive" -version = "1.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "81e8f13fef10b63c06356d65d416b070798ddabcadc10d3ece0c5be9b3c7eddb" -dependencies = [ - "proc-macro2", - "quote", - "syn", - "synstructure", -] +checksum = "7eb5728b8afd3f280a869ce1d4c554ffaed35f45c231fc41bfbd0381bef50317" diff --git a/Cargo.toml b/Cargo.toml index 4ade220..2b23fd1 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -5,8 +5,8 @@ authors = ["jellywx "] edition = "2018" [dependencies] +poise = "0.2" dotenv = "0.15" -humantime = "2.1" tokio = { version = "1", features = ["process", "full"] } reqwest = "0.11" regex = "1.4" @@ -25,28 +25,8 @@ 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" [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/Rocket.toml b/Rocket.toml index eef8409..1c51269 100644 --- a/Rocket.toml +++ b/Rocket.toml @@ -1,7 +1,8 @@ [default] -address = "127.0.0.1" +address = "0.0.0.0" port = 5000 template_dir = "web/templates" +limits = { json = "10MiB" } [debug] secret_key = "tR8krio5FXTnnyIZNiJDXPondz0kI1v6X6BXZcBGIRY=" 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/migration/04-reminder_templates.sql b/migration/04-reminder_templates.sql index e6a4208..49c5bdc 100644 --- a/migration/04-reminder_templates.sql +++ b/migration/04-reminder_templates.sql @@ -24,8 +24,11 @@ CREATE TABLE reminder_template ( `embed_author` VARCHAR(256) NOT NULL DEFAULT '', `embed_author_url` VARCHAR(512), `embed_color` INT UNSIGNED NOT NULL DEFAULT 0x0, + `embed_fields` JSON, PRIMARY KEY (id), - FOREIGN KEY (`guild_id`) REFERENCES channels (`id`) ON DELETE CASCADE, + FOREIGN KEY (`guild_id`) REFERENCES guilds (`id`) ON DELETE CASCADE ); + +ALTER TABLE reminders ADD COLUMN embed_fields JSON; diff --git a/postman/Cargo.toml b/postman/Cargo.toml index 5297ae6..c17d5d5 100644 --- a/postman/Cargo.toml +++ b/postman/Cargo.toml @@ -12,21 +12,7 @@ chrono = "0.4" chrono-tz = { version = "0.5", features = ["serde"] } lazy_static = "1.4" num-integer = "0.1" -sqlx = { version = "0.5.10", features = ["runtime-tokio-rustls", "macros", "mysql", "bigdecimal", "chrono"]} - -[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" -] +serde = "1.0" +serde_json = "1.0" +sqlx = { version = "0.5", features = ["runtime-tokio-rustls", "macros", "mysql", "bigdecimal", "chrono", "json"]} +serenity = { version = "0.11.1", default-features = false, features = ["builder", "cache", "client", "gateway", "http", "model", "utils", "rustls_backend"] } diff --git a/postman/src/lib.rs b/postman/src/lib.rs index ed1b852..a378dcf 100644 --- a/postman/src/lib.rs +++ b/postman/src/lib.rs @@ -1,23 +1,40 @@ mod sender; -use log::info; +use std::env; + +use log::{info, warn}; use serenity::client::Context; use sqlx::{Executor, MySql}; -use std::env; -use tokio::time::sleep_until; -use tokio::time::{Duration, Instant}; +use tokio::{ + sync::broadcast::Receiver, + 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") +pub async fn initialize( + mut kill: Receiver<()>, + ctx: Context, + pool: impl Executor<'_, Database = Database> + Copy, +) -> Result<(), &'static str> { + tokio::select! { + output = _initialize(ctx, pool) => Ok(output), + _ = kill.recv() => { + warn!("Received terminate signal. Goodbye"); + Err("Received terminate signal. Goodbye") + } + } +} + +async fn _initialize(ctx: Context, pool: impl Executor<'_, Database = Database> + Copy) { + 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..2feb704 100644 --- a/postman/src/sender.rs +++ b/postman/src/sender.rs @@ -1,10 +1,10 @@ -use crate::Database; use chrono::Duration; use chrono_tz::Tz; use lazy_static::lazy_static; use log::{error, info, warn}; use num_integer::Integer; use regex::{Captures, Regex}; +use serde::Deserialize; use serenity::{ builder::CreateEmbed, http::{CacheHttp, Http, StatusCode}, @@ -16,10 +16,15 @@ use serenity::{ Error, Result, }; use sqlx::{ - types::chrono::{NaiveDateTime, Utc}, + types::{ + chrono::{NaiveDateTime, Utc}, + Json, + }, Executor, }; +use crate::Database; + lazy_static! { pub static ref TIMEFROM_REGEX: Regex = Regex::new(r#"<\d+):(?P.+)?>>"#).unwrap(); @@ -93,11 +98,6 @@ pub fn substitute(string: &str) -> String { } struct Embed { - inner: EmbedInner, - fields: Vec, -} - -struct EmbedInner { title: String, description: String, image_url: Option, @@ -107,8 +107,10 @@ struct EmbedInner { author: String, author_url: Option, color: u32, + fields: Json>, } +#[derive(Deserialize)] struct EmbedField { title: String, value: String, @@ -120,76 +122,54 @@ impl Embed { pool: impl Executor<'_, Database = Database> + Copy, id: u32, ) -> Option { - let mut inner = sqlx::query_as_unchecked!( - EmbedInner, - " -SELECT - `embed_title` AS title, - `embed_description` AS description, - `embed_image_url` AS image_url, - `embed_thumbnail_url` AS thumbnail_url, - `embed_footer` AS footer, - `embed_footer_url` AS footer_url, - `embed_author` AS author, - `embed_author_url` AS author_url, - `embed_color` AS color -FROM - reminders -WHERE - `id` = ? - ", + let mut embed = sqlx::query_as!( + Self, + r#" + SELECT + `embed_title` AS title, + `embed_description` AS description, + `embed_image_url` AS image_url, + `embed_thumbnail_url` AS thumbnail_url, + `embed_footer` AS footer, + `embed_footer_url` AS footer_url, + `embed_author` AS author, + `embed_author_url` AS author_url, + `embed_color` AS color, + IFNULL(`embed_fields`, '[]') AS "fields:_" + FROM reminders + WHERE `id` = ?"#, id ) .fetch_one(pool) .await .unwrap(); - inner.title = substitute(&inner.title); - inner.description = substitute(&inner.description); - inner.footer = substitute(&inner.footer); + embed.title = substitute(&embed.title); + embed.description = substitute(&embed.description); + embed.footer = substitute(&embed.footer); - let mut fields = sqlx::query_as_unchecked!( - EmbedField, - " -SELECT - title, - value, - inline -FROM - embed_fields -WHERE - reminder_id = ? - ", - id - ) - .fetch_all(pool) - .await - .unwrap(); - - fields.iter_mut().for_each(|mut field| { + embed.fields.iter_mut().for_each(|mut field| { field.title = substitute(&field.title); field.value = substitute(&field.value); }); - let e = Embed { inner, fields }; - - if e.has_content() { - Some(e) + if embed.has_content() { + Some(embed) } else { None } } pub fn has_content(&self) -> bool { - if self.inner.title.is_empty() - && self.inner.description.is_empty() - && self.inner.image_url.is_none() - && self.inner.thumbnail_url.is_none() - && self.inner.footer.is_empty() - && self.inner.footer_url.is_none() - && self.inner.author.is_empty() - && self.inner.author_url.is_none() - && self.fields.is_empty() + if self.title.is_empty() + && self.description.is_empty() + && self.image_url.is_none() + && self.thumbnail_url.is_none() + && self.footer.is_empty() + && self.footer_url.is_none() + && self.author.is_empty() + && self.author_url.is_none() + && self.fields.0.is_empty() { false } else { @@ -202,37 +182,37 @@ impl Into for Embed { fn into(self) -> CreateEmbed { let mut c = CreateEmbed::default(); - c.title(&self.inner.title) - .description(&self.inner.description) - .color(self.inner.color) + c.title(&self.title) + .description(&self.description) + .color(self.color) .author(|a| { - a.name(&self.inner.author); + a.name(&self.author); - if let Some(author_icon) = &self.inner.author_url { + if let Some(author_icon) = &self.author_url { a.icon_url(author_icon); } a }) .footer(|f| { - f.text(&self.inner.footer); + f.text(&self.footer); - if let Some(footer_icon) = &self.inner.footer_url { + if let Some(footer_icon) = &self.footer_url { f.icon_url(footer_icon); } f }); - for field in &self.fields { + for field in &self.fields.0 { c.field(&field.title, &field.value, field.inline); } - if let Some(image_url) = &self.inner.image_url { + if let Some(image_url) = &self.image_url { c.image(image_url); } - if let Some(thumbnail_url) = &self.inner.thumbnail_url { + if let Some(thumbnail_url) = &self.thumbnail_url { c.thumbnail(thumbnail_url); } diff --git a/src/commands/info_cmds.rs b/src/commands/info_cmds.rs index f6232f5..fb8184c 100644 --- a/src/commands/info_cmds.rs +++ b/src/commands/info_cmds.rs @@ -1,16 +1,13 @@ use chrono::offset::Utc; -use regex_command_attr::command; -use serenity::{builder::CreateEmbedFooter, client::Context}; +use poise::{serenity_prelude as serenity, serenity_prelude::Mentionable}; -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 serenity::CreateEmbedFooter) -> &mut serenity::CreateEmbedFooter { + let shard_count = ctx.discord().cache.shard_count(); + let shard = ctx.discord().shard_id; move |f| { f.text(format!( @@ -22,19 +19,17 @@ 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| { - 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* @@ -58,23 +53,23 @@ __Setup Commands__ __Advanced Commands__ `/macro` - Record and replay command sequences ", - ) - .footer(footer) - }), - ) - .await; + ) + .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.ephemeral(true).embed(|e| { e.title("Info") .description(format!( "Help: `/help` @@ -89,23 +84,22 @@ 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| { - 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/** @@ -120,43 +114,67 @@ 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(()) } -#[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| { - e.title("Dashboard") - .description("**https://reminder-bot.com/dashboard**") - .footer(footer) - .color(*THEME_COLOR) - }), - ) - .await; + ctx.send(|m| { + m.ephemeral(true).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 your 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(()) +} + +/// 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/moderation_cmds.rs b/src/commands/moderation_cmds.rs index cada0e4..1f479ea 100644 --- a/src/commands/moderation_cmds.rs +++ b/src/commands/moderation_cmds.rs @@ -1,54 +1,63 @@ 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}, - models::{command_macro::CommandMacro, CtxData}, - PopularTimezones, RecordingMacros, RegexFramework, SQLPool, + models::{ + command_macro::{guild_command_macro, CommandMacro}, + CtxData, + }, + 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, identifying_name = "timezone")] +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 +65,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 +83,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 +105,311 @@ 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", + guild_only = true, + default_member_permissions = "MANAGE_GUILD", + 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", + guild_only = true, + default_member_permissions = "MANAGE_GUILD", + identifying_name = "record_macro" +)] +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", + guild_only = true, + default_member_permissions = "MANAGE_GUILD", + identifying_name = "finish_macro" +)] +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", + guild_only = true, + default_member_permissions = "MANAGE_GUILD", + identifying_name = "list_macro" +)] +pub async fn list_macro(ctx: Context<'_>) -> Result<(), Error> { + let macros = ctx.command_macros().await?; + + 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", + guild_only = true, + default_member_permissions = "MANAGE_GUILD", + identifying_name = "run_macro" +)] +pub async fn run_macro( + ctx: poise::ApplicationContext<'_, Data, Error>, + #[description = "Name of macro to run"] + #[autocomplete = "macro_name_autocomplete"] + name: String, +) -> Result<(), Error> { + match guild_command_macro(&Context::Application(ctx), &name).await { + Some(command_macro) => { + ctx.defer_response(false).await?; + + for command in command_macro.commands { + if let Some(action) = command.action { + match (action)(poise::ApplicationContext { args: &command.options, ..ctx }) + .await + { + Ok(()) => {} + Err(e) => { + println!("{:?}", e); + } + } + } else { + Context::Application(ctx) + .say(format!("Command \"{}\" not found", command.command_name)) + .await?; + } + } + } + + None => { + Context::Application(ctx).say(format!("Macro \"{}\" not found", name)).await?; + } + } + + Ok(()) +} + +/// Delete a recorded macro +#[poise::command( + slash_command, + rename = "delete", + guild_only = true, + default_member_permissions = "MANAGE_GUILD", + identifying_name = "delete_macro" +)] +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 +433,19 @@ 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 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 +488,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 +501,7 @@ pub fn show_macro_page(macros: &[CommandMacro], page: usize) -> CreateGenericRes pager.create_button_row(pages, comp); comp - }) + }); + + reply } diff --git a/src/commands/reminder_cmds.rs b/src/commands/reminder_cmds.rs index 8696c41..1059b4f 100644 --- a/src/commands/reminder_cmds.rs +++ b/src/commands/reminder_cmds.rs @@ -7,17 +7,20 @@ 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}, + 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::{ @@ -28,34 +31,30 @@ use crate::{ Reminder, }, timer::Timer, - user_data::UserData, CtxData, }, 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 +/// Pause all reminders on the current channel until a certain time or indefinitely +#[poise::command( + slash_command, + identifying_name = "pause", + default_member_permissions = "MANAGE_GUILD" )] -#[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(); +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 +62,57 @@ 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 +/// Move all reminders in the current server by a certain amount of time. Times get added together +#[poise::command( + slash_command, + identifying_name = "offset", + default_member_permissions = "MANAGE_GUILD" )] -#[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()); +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 +131,75 @@ 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 +/// Nudge all future reminders on this channel by a certain amount (don't use for DST! See `/offset`) +#[poise::command( + slash_command, + identifying_name = "nudge", + default_member_permissions = "MANAGE_GUILD" )] -#[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(); +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 +/// View reminders on a specific channel +#[poise::command( + slash_command, + identifying_name = "look", + default_member_permissions = "MANAGE_GUILD" )] -#[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; +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 +207,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.data().database, 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 +252,52 @@ 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", + identifying_name = "delete", + default_member_permissions = "MANAGE_GUILD" +)] +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.discord(), &ctx.data().database, 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 +322,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 +384,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 +424,230 @@ 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", + identifying_name = "timer_base", + default_member_permissions = "MANAGE_GUILD" +)] +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", + identifying_name = "list_timer", + default_member_permissions = "MANAGE_GUILD" +)] +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", + identifying_name = "start_timer", + default_member_permissions = "MANAGE_GUILD" +)] +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 +/// Delete a timer +#[poise::command( + slash_command, + rename = "delete", + identifying_name = "delete_timer", + default_member_permissions = "MANAGE_GUILD" )] -#[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`"), - ) +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, + identifying_name = "remind", + default_member_permissions = "MANAGE_GUILD" +)] +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, + #[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 +655,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..afbbe2a 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,218 @@ 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 +/// Manage todo lists +#[poise::command( + slash_command, + rename = "todo", + identifying_name = "todo_base", + default_member_permissions = "MANAGE_GUILD" )] -#[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 +pub async fn todo_base(_ctx: Context<'_>) -> Result<(), Error> { + Ok(()) +} + +/// Manage the server todo list +#[poise::command( + slash_command, + rename = "server", + guild_only = true, + identifying_name = "todo_guild_base", + default_member_permissions = "MANAGE_GUILD" )] -#[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 +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", + guild_only = true, + identifying_name = "todo_guild_add", + default_member_permissions = "MANAGE_GUILD" )] -#[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(); +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(); - 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), - }; + ctx.say("Item added to todo list").await?; - match args.get("task") { - Some(task) => { - let task = task.to_string(); + Ok(()) +} - 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(); - - 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 +/// View and remove from the server todo list +#[poise::command( + slash_command, + rename = "view", + guild_only = true, + identifying_name = "todo_guild_view", + default_member_permissions = "MANAGE_GUILD" +)] +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", + guild_only = true, + identifying_name = "todo_channel_base", + default_member_permissions = "MANAGE_GUILD" +)] +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", + guild_only = true, + identifying_name = "todo_channel_add", + default_member_permissions = "MANAGE_GUILD" +)] +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", + guild_only = true, + identifying_name = "todo_channel_view", + default_member_permissions = "MANAGE_GUILD" +)] +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", 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", identifying_name = "todo_user_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", 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 +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 +247,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 +302,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 +344,8 @@ pub fn show_todo_page( }) }) }) - }) + }); + + reply } } diff --git a/src/component_models/mod.rs b/src/component_models/mod.rs index bf16b2b..51d240b 100644 --- a/src/component_models/mod.rs +++ b/src/component_models/mod.rs @@ -3,9 +3,7 @@ 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::{ @@ -14,6 +12,8 @@ use serenity::{ prelude::InteractionApplicationCommandCallbackDataFlags, }, }; +use rmp_serde::Serializer; +use serde::{Deserialize, Serialize}; use crate::{ commands::{ @@ -23,9 +23,9 @@ 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, + utils::send_as_initial_response, + Data, }; #[derive(Deserialize, Serialize)] @@ -55,7 +55,7 @@ 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; @@ -72,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() @@ -122,7 +122,7 @@ impl ComponentDataModel { .create_interaction_response(&ctx, |r| { r.kind(InteractionResponseType::UpdateMessage).interaction_response_data( |response| { - response.embeds(vec![embed]).components(|comp| { + response.set_embeds(vec![embed]).components(|comp| { pager.create_button_row(pages, comp); comp @@ -133,45 +133,68 @@ 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 mut invoke = CommandInvoke::component(component); - let _ = invoke.respond(&ctx, resp).await; + let _ = component + .create_interaction_response(&ctx, |f| { + f.kind(InteractionResponseType::UpdateMessage).interaction_response_data( + |d| { + send_as_initial_response(resp, d); + d + }, + ) + }) + .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(&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 mut invoke = CommandInvoke::component(component); - let _ = invoke.respond(&ctx, resp).await; + let _ = component + .create_interaction_response(&ctx, |f| { + f.kind(InteractionResponseType::UpdateMessage).interaction_response_data( + |d| { + send_as_initial_response(resp, d); + d + }, + ) + }) + .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(&data.database) .await .unwrap() .iter() @@ -180,11 +203,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(&data.database) .await .unwrap() .iter() @@ -193,11 +216,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(&data.database) .await .unwrap() .iter() @@ -215,8 +238,15 @@ impl ComponentDataModel { pager.guild_id, ); - let mut invoke = CommandInvoke::component(component); - let _ = invoke.respond(&ctx, resp).await; + 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, |r| { @@ -233,11 +263,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(&data.database) .await .unwrap(); @@ -248,7 +277,7 @@ impl ComponentDataModel { selector.channel_id, selector.guild_id, ) - .fetch_all(&pool) + .fetch_all(&data.database) .await .unwrap() .iter() @@ -263,8 +292,15 @@ impl ComponentDataModel { selector.guild_id, ); - let mut invoke = CommandInvoke::component(component); - let _ = invoke.respond(&ctx, resp).await; + 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, |r| { @@ -280,15 +316,23 @@ impl ComponentDataModel { } } ComponentDataModel::MacroPager(pager) => { - let mut invoke = CommandInvoke::component(component); - - let macros = CommandMacro::from_guild(ctx, invoke.guild_id().unwrap()).await; + 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 _ = invoke.respond(&ctx, resp).await; + + 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/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 3e6de0e..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; @@ -6,11 +8,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..f1a786f 100644 --- a/src/event_handlers.rs +++ b/src/event_handlers.rs @@ -1,161 +1,126 @@ 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 log::{error, info, warn}; +use poise::{ + serenity::{model::interactions::Interaction, utils::shard_id}, + serenity_prelude as serenity, }; -use crate::{ComponentDataModel, Handler, RegexFramework, ReqwestClient, SQLPool}; +use crate::{component_models::ComponentDataModel, 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: &serenity::Context, + event: &poise::Event<'_>, + data: &Data, +) -> Result<(), Error> { + match event { + poise::Event::CacheReady { .. } => { + info!("Cache Ready! Preparing extra processes"); - 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 kill_tx = data.broadcast.clone(); + let kill_recv = data.broadcast.subscribe(); - let pool1 = ctx1.data.read().await.get::().cloned().unwrap(); - let pool2 = ctx2.data.read().await.get::().cloned().unwrap(); + let ctx1 = ctx.clone(); + let ctx2 = ctx.clone(); - let run_settings = env::var("DONTRUN").unwrap_or_else(|_| "".to_string()); + let pool1 = data.database.clone(); + let pool2 = data.database.clone(); - if !run_settings.contains("postman") { - tokio::spawn(async move { - postman::initialize(ctx1, &pool1).await; - }); - } else { - warn!("Not running postman") + let run_settings = env::var("DONTRUN").unwrap_or_else(|_| "".to_string()); + + if !run_settings.contains("postman") { + tokio::spawn(async move { + match postman::initialize(kill_recv, ctx1, &pool1).await { + Ok(_) => {} + Err(e) => { + error!("postman exiting: {}", e); + } + }; + }); + } else { + warn!("Not running postman") + } + + if !run_settings.contains("web") { + tokio::spawn(async move { + reminder_web::initialize(kill_tx, 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); } - } + poise::Event::ChannelDelete { channel } => { + 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 { + let guild_id = guild.id.as_u64().to_owned(); - 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!( - " -DELETE FROM channels WHERE channel = ? - ", - channel.id.as_u64() - ) - .execute(&pool) - .await - .unwrap(); - } - - 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, .. } => { + 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; + + component_model.act(ctx, data, component).await; } _ => {} - } + }, + _ => {} } + + Ok(()) } 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 fae9bcf..a87a1c1 100644 --- a/src/hooks.rs +++ b/src/hooks.rs @@ -1,107 +1,79 @@ -use regex_command_attr::check; -use serenity::{client::Context, model::channel::Channel}; +use poise::serenity::model::channel::Channel; -use crate::{ - framework::{CommandInvoke, CommandOptions, CreateGenericResponse, HookResult}, - moderation_cmds, RecordingMacros, -}; +use crate::{consts::MACRO_MAX_COMMANDS, models::command_macro::RecordedCommand, Context, Error}; -#[check] -pub async fn guild_only( - ctx: &Context, - invoke: &mut CommandInvoke, - _args: &CommandOptions, -) -> HookResult { - if invoke.guild_id().is_some() { - HookResult::Continue - } else { - let _ = invoke - .respond( - &ctx, - CreateGenericResponse::new().content("This command can only be used in servers"), - ) - .await; +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 != "finish_macro" { + let mut lock = ctx.data().recording_macros.write().await; - HookResult::Halt - } -} + 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), + }; -#[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; + command_macro.commands.push(recorded); - 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; + let _ = ctx + .send(|m| m.ephemeral(true).content("Command recorded to macro")) + .await; + } + + false } 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 } }) .flatten() .map_or((false, false, false), |p| { - (p.read_messages(), p.send_messages(), p.embed_links()) + (p.view_channel(), p.send_messages(), p.embed_links()) }); 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 +84,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..9b8eb70 100644 --- a/src/main.rs +++ b/src/main.rs @@ -6,7 +6,6 @@ mod commands; mod component_models; mod consts; mod event_handlers; -mod framework; mod hooks; mod interval_parser; mod models; @@ -16,145 +15,188 @@ mod utils; use std::{ collections::HashMap, env, - sync::{atomic::AtomicBool, Arc}, + error::Error as StdError, + fmt::{Debug, Display, Formatter}, + 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 tokio::sync::RwLock; +use sqlx::{MySql, Pool}; +use tokio::sync::{broadcast, broadcast::Sender, RwLock}; use crate::{ commands::{info_cmds, moderation_cmds, reminder_cmds, todo_cmds}, - component_models::ComponentDataModel, 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, + broadcast: Sender<()>, } +impl std::fmt::Debug for Data { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + write!(f, "Data {{ .. }}") + } +} + +struct Ended; + +impl Debug for Ended { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + f.write_str("Process ended.") + } +} + +impl Display for Ended { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + f.write_str("Process ended.") + } +} + +impl StdError for Ended {} + #[tokio::main] -async fn main() -> Result<(), Box> { +async fn main() -> Result<(), Box> { + let (tx, mut rx) = broadcast::channel(16); + + tokio::select! { + output = _main(tx) => output, + _ = rx.recv() => Err(Box::new(Ended) as Box) + } +} + +async fn _main(tx: Sender<()>) -> Result<(), Box> { env_logger::init(); 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::clock_context_menu(), + 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() + }, + 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))), + 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) + register_application_commands( + ctx, + framework, + env::var("DEBUG_GUILD") + .map(|inner| GuildId(inner.parse().expect("DEBUG_GUILD not valid"))) + .ok(), + ) + .await + .unwrap(); + + Ok(Data { + http: reqwest::Client::new(), + database, + popular_timezones, + recording_macros: Default::default(), + is_loop_running: AtomicBool::new(false), + broadcast: tx, + }) + }) + }) + .options(options) .intents(GatewayIntents::GUILDS) - .application_id(application_id.0) - .event_handler(Handler { is_loop_running: AtomicBool::from(false) }) - .await - .expect("Error occurred creating client"); - - { - 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?; + .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..5f7bfb1 100644 --- a/src/models/command_macro.rs +++ b/src/models/command_macro.rs @@ -1,33 +1,73 @@ -use serenity::{client::Context, model::id::GuildId}; +use poise::serenity::model::{ + id::GuildId, interactions::application_command::ApplicationCommandInteractionDataOption, +}; +use serde::{Deserialize, Serialize}; -use crate::{framework::CommandOptions, SQLPool}; +use crate::{Context, Data, Error}; -pub struct CommandMacro { +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)] + #[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 { pub guild_id: GuildId, pub name: String, pub description: Option, - pub commands: Vec, + 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(); +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()?; - 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::>() + 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 cf509c6..a94bd20 100644 --- a/src/models/mod.rs +++ b/src/models/mod.rs @@ -5,62 +5,71 @@ 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, + CommandMacro, Context, Data, Error, GuildId, }; #[async_trait] pub trait CtxData { - async fn user_data + Send + Sync>( - &self, - user_id: U, - ) -> Result>; + 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 fn command_macros(&self) -> Result>, Error>; } #[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 + } + + async fn command_macros(&self) -> Result>, Error> { + 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, commands FROM macro WHERE guild_id = (SELECT id FROM guilds WHERE guild = ?)", + guild_id.0 + ) + .fetch_all(&self.database) + .await?.iter().map(|row| CommandMacro { + guild_id, + name: row.name.clone(), + description: row.description.clone(), + commands: serde_json::from_str(&row.commands).unwrap(), + }).collect(); + + Ok(rows) } } 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..f50203d 100644 --- a/src/models/reminder/mod.rs +++ b/src/models/reminder/mod.rs @@ -6,15 +6,15 @@ pub mod look_flags; use chrono::{NaiveDateTime, TimeZone}; use chrono_tz::Tz; -use serenity::{ - client::Context, - model::id::{ChannelId, GuildId, UserId}, +use poise::{ + serenity::model::id::{ChannelId, GuildId, UserId}, + serenity_prelude::Cache, }; -use sqlx::MySqlPool; +use sqlx::Executor; use crate::{ models::reminder::look_flags::{LookFlags, TimeDisplayType}, - SQLPool, + Database, }; #[derive(Debug, Clone)] @@ -33,7 +33,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 +73,10 @@ WHERE } pub async fn from_channel>( - ctx: &Context, + pool: impl Executor<'_, Database = Database>, 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 +114,19 @@ ORDER BY channel_id.as_u64(), enabled, ) - .fetch_all(&pool) + .fetch_all(pool) .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( + 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); + 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(&pool) + .fetch_all(pool) .await } else { sqlx::query_as_unchecked!( @@ -196,7 +200,7 @@ WHERE ", guild_id.as_u64() ) - .fetch_all(&pool) + .fetch_all(pool) .await } } else { @@ -230,7 +234,7 @@ WHERE ", user.as_u64() ) - .fetch_all(&pool) + .fetch_all(pool) .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..ecdef54 100644 --- a/src/utils.rs +++ b/src/utils.rs @@ -1,9 +1,42 @@ -use serenity::{ - http::CacheHttp, - model::id::{GuildId, UserId}, +use poise::{ + serenity::{ + builder::CreateApplicationCommands, + http::CacheHttp, + model::id::{GuildId, UserId}, + }, + serenity_prelude as serenity, }; -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 { @@ -35,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.set_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/Cargo.toml b/web/Cargo.toml index 0d0742f..41771a2 100644 --- a/web/Cargo.toml +++ b/web/Cargo.toml @@ -7,11 +7,15 @@ edition = "2018" [dependencies] rocket = { git = "https://github.com/SergioBenitez/Rocket", branch = "master", features = ["tls", "secrets", "json"] } rocket_dyn_templates = { git = "https://github.com/SergioBenitez/Rocket", branch = "master", features = ["tera"] } -serenity = { git = "https://github.com/serenity-rs/serenity", branch = "next", default-features = false, features = ["builder", "cache", "client", "gateway", "http", "model", "utils", "rustls_backend"] } +serenity = { version = "0.11.1", default-features = false, features = ["builder", "cache", "client", "gateway", "http", "model", "utils", "rustls_backend"] } oauth2 = "4" log = "0.4" reqwest = "0.11" serde = { version = "1.0", features = ["derive"] } -sqlx = { version = "0.5", features = ["runtime-tokio-rustls", "macros", "mysql", "chrono"] } +serde_json = "1.0" +sqlx = { version = "0.5", features = ["runtime-tokio-rustls", "macros", "mysql", "chrono", "json"] } chrono = "0.4" chrono-tz = "0.5" +lazy_static = "1.4.0" +rand = "0.7" +base64 = "0.13" diff --git a/web/src/consts.rs b/web/src/consts.rs index 8a529bb..da09229 100644 --- a/web/src/consts.rs +++ b/web/src/consts.rs @@ -1,4 +1,52 @@ pub const DISCORD_OAUTH_TOKEN: &'static str = "https://discord.com/api/oauth2/token"; 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_FIELDS: usize = 25; +pub const MAX_EMBED_FIELD_TITLE_LENGTH: usize = 256; +pub const MAX_EMBED_FIELD_VALUE_LENGTH: usize = 1024; + +pub const MINUTE: usize = 60; +pub const HOUR: usize = 60 * MINUTE; +pub const DAY: usize = 24 * HOUR; + +pub const CHARACTERS: &str = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_"; + +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 21c6530..dbce207 100644 --- a/web/src/lib.rs +++ b/web/src/lib.rs @@ -2,22 +2,36 @@ extern crate rocket; mod consts; +#[macro_use] +mod macros; 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, + serde::json::{json, Value as JsonValue}, + tokio::sync::broadcast::Sender, +}; use rocket_dyn_templates::Template; -use serenity::client::Context; +use serenity::{ + client::Context, + http::CacheHttp, + model::id::{GuildId, UserId}, +}; use sqlx::{MySql, Pool}; -use std::env; + +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(); @@ -36,6 +50,16 @@ async fn not_found() -> Template { Template::render("errors/404", &map) } +#[catch(413)] +async fn payload_too_large() -> JsonValue { + json!({"error": "Data too large.", "errors": ["Data too large."]}) +} + +#[catch(422)] +async fn unprocessable_entity() -> JsonValue { + json!({"error": "Invalid request.", "errors": ["Invalid request."]}) +} + #[catch(500)] async fn internal_server_error() -> Template { let map: HashMap = HashMap::new(); @@ -43,9 +67,17 @@ async fn internal_server_error() -> Template { } pub async fn initialize( + kill_channel: Sender<()>, serenity_context: Context, db_pool: Pool, ) -> Result<(), Box> { + info!("Checking environment variables..."); + env::var("OAUTH2_CLIENT_ID").expect("`OAUTH2_CLIENT_ID' not supplied"); + env::var("OAUTH2_CLIENT_SECRET").expect("`OAUTH2_CLIENT_SECRET' not supplied"); + env::var("OAUTH2_DISCORD_CALLBACK").expect("`OAUTH2_DISCORD_CALLBACK' not supplied"); + env::var("PATREON_GUILD_ID").expect("`PATREON_GUILD' not supplied"); + info!("Done!"); + let oauth2_client = BasicClient::new( ClientId::new(env::var("OAUTH2_CLIENT_ID")?), Some(ClientSecret::new(env::var("OAUTH2_CLIENT_SECRET")?)), @@ -58,7 +90,17 @@ pub async fn initialize( rocket::build() .attach(Template::fairing()) - .register("/", catchers![not_authorized, forbidden, not_found, internal_server_error]) + .register( + "/", + catchers![ + not_authorized, + forbidden, + not_found, + internal_server_error, + unprocessable_entity, + payload_too_large, + ], + ) .manage(oauth2_client) .manage(reqwest_client) .manage(serenity_context) @@ -71,24 +113,36 @@ pub async fn initialize( routes::cookies, routes::privacy, routes::terms, - routes::help, routes::return_to_same_site ], ) + .mount( + "/help", + routes![ + routes::help, + routes::help_timezone, + routes::help_create_reminder, + routes::help_delete_reminder, + routes::help_timers, + routes::help_todo_lists, + routes::help_macros, + ], + ) .mount("/login", routes![routes::login::discord_login, routes::login::discord_callback]) .mount( "/dashboard", routes![ + routes::dashboard::dashboard, routes::dashboard::dashboard_home, routes::dashboard::user::get_user_info, routes::dashboard::user::update_user_info, routes::dashboard::user::get_user_guilds, - routes::dashboard::user::create_reminder, - routes::dashboard::user::get_reminders, - routes::dashboard::user::overwrite_reminder, - routes::dashboard::user::delete_reminder, + routes::dashboard::guild::get_guild_patreon, routes::dashboard::guild::get_guild_channels, routes::dashboard::guild::get_guild_roles, + routes::dashboard::guild::get_reminder_templates, + routes::dashboard::guild::create_reminder_template, + routes::dashboard::guild::delete_reminder_template, routes::dashboard::guild::create_reminder, routes::dashboard::guild::get_reminders, routes::dashboard::guild::edit_reminder, @@ -98,5 +152,45 @@ pub async fn initialize( .launch() .await?; + warn!("Exiting rocket runtime"); + // distribute kill signal + match kill_channel.send(()) { + Ok(_) => {} + Err(e) => { + error!("Failed to issue kill signal: {:?}", e); + } + } + 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..8b33616 --- /dev/null +++ b/web/src/macros.rs @@ -0,0 +1,119 @@ +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),+); + }; +} + +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"}); + } + } + } +} + +macro_rules! update_field { + ($pool:expr, $error:ident, $reminder:ident.[$field:ident]) => { + if let Some(value) = &$reminder.$field { + match sqlx::query(concat!( + "UPDATE reminders SET `", + stringify!($field), + "` = ? WHERE uid = ?" + )) + .bind(value) + .bind(&$reminder.uid) + .execute($pool) + .await + { + Ok(_) => {} + Err(e) => { + warn!( + concat!( + "Error in `update_field!(", + stringify!($pool), + stringify!($reminder), + stringify!($field), + ")': {:?}" + ), + e + ); + + $error.push(format!("Error setting field {}", stringify!($field))); + } + } + } + }; + + ($pool:expr, $error:ident, $reminder:ident.[$field:ident, $($fields:ident),+]) => { + update_field!($pool, $error, $reminder.[$field]); + update_field!($pool, $error, $reminder.[$($fields),+]); + }; +} diff --git a/web/src/routes/dashboard/guild.rs b/web/src/routes/dashboard/guild.rs index 81fc9ca..3e7b8c4 100644 --- a/web/src/routes/dashboard/guild.rs +++ b/web/src/routes/dashboard/guild.rs @@ -1,14 +1,35 @@ -use rocket::State; +use std::env; -use crate::consts::DISCORD_CDN; +use base64; +use chrono::Utc; +use rocket::{ + http::CookieJar, + serde::json::{json, Json, Value as JsonValue}, + State, +}; use serde::Serialize; +use serenity::{ + client::Context, + model::{ + channel::GuildChannel, + id::{ChannelId, GuildId, RoleId}, + }, +}; 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::{ + check_guild_subscription, check_subscription, + consts::{ + DAY, MAX_CONTENT_LENGTH, MAX_EMBED_AUTHOR_LENGTH, MAX_EMBED_DESCRIPTION_LENGTH, + MAX_EMBED_FIELDS, MAX_EMBED_FIELD_TITLE_LENGTH, MAX_EMBED_FIELD_VALUE_LENGTH, + MAX_EMBED_FOOTER_LENGTH, MAX_EMBED_TITLE_LENGTH, MAX_URL_LENGTH, MAX_USERNAME_LENGTH, + MIN_INTERVAL, + }, + routes::dashboard::{ + create_database_channel, generate_uid, name_default, template_name_default, DeleteReminder, + DeleteReminderTemplate, PatchReminder, Reminder, ReminderTemplate, + }, +}; #[derive(Serialize)] struct ChannelInfo { @@ -18,61 +39,69 @@ struct ChannelInfo { webhook_name: Option, } -// todo check the user can access this guild +#[get("/api/guild//patreon")] +pub async fn get_guild_patreon( + id: u64, + cookies: &CookieJar<'_>, + ctx: &State, +) -> JsonValue { + check_authorization!(cookies, ctx.inner(), id); + + match GuildId(id).to_guild_cached(ctx.inner()) { + Some(guild) => { + let member_res = GuildId(env::var("PATREON_GUILD_ID").unwrap().parse().unwrap()) + .member(&ctx.inner(), guild.owner_id) + .await; + + let patreon = member_res.map_or(false, |member| { + member + .roles + .contains(&RoleId(env::var("PATREON_ROLE_ID").unwrap().parse().unwrap())) + }); + + json!({ "patreon": patreon }) + } + + None => { + json!({"error": "Bot not in guild"}) + } + } +} + #[get("/api/guild//channels")] pub async fn get_guild_channels( id: u64, + cookies: &CookieJar<'_>, ctx: &State, - pool: &State>, ) -> JsonValue { - let channels_res = GuildId(id).channels(ctx.inner()).await; + check_authorization!(cookies, ctx.inner(), id); - match channels_res { - Ok(channels) => { - let mut channel_info = vec![]; + match GuildId(id).to_guild_cached(ctx.inner()) { + Some(guild) => { + let mut channels = guild + .channels + .iter() + .filter_map(|(id, channel)| channel.to_owned().guild().map(|c| (id.to_owned(), c))) + .filter(|(_, channel)| channel.is_text_based()) + .collect::>(); - for (channel_id, channel) in - channels.iter().filter(|(_, channel)| channel.is_text_based()) - { - let mut ch = ChannelInfo { + channels.sort_by(|(_, c1), (_, c2)| c1.position.cmp(&c2.position)); + + let channel_info = channels + .iter() + .map(|(channel_id, channel)| ChannelInfo { name: channel.name.to_string(), id: channel_id.to_string(), webhook_avatar: None, webhook_name: None, - }; - - if let Ok(webhook_details) = sqlx::query!( - "SELECT webhook_id, webhook_token FROM channels WHERE channel = ?", - channel.id.as_u64() - ) - .fetch_one(pool.inner()) - .await - { - if let (Some(webhook_id), Some(webhook_token)) = - (webhook_details.webhook_id, webhook_details.webhook_token) - { - let webhook_res = - ctx.http.get_webhook_with_token(webhook_id, &webhook_token).await; - - if let Ok(webhook) = webhook_res { - ch.webhook_avatar = webhook.avatar.map(|a| { - format!("{}/{}/{}.webp?size=128", DISCORD_CDN, webhook_id, a) - }); - - ch.webhook_name = webhook.name; - } - } - } - - channel_info.push(ch); - } + }) + .collect::>(); json!(channel_info) } - Err(e) => { - warn!("Could not fetch channels from {}: {:?}", id, e); - json!({"error": "Could not get channels"}) + None => { + json!({"error": "Bot not in guild"}) } } } @@ -83,9 +112,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 { @@ -105,14 +135,376 @@ pub async fn get_guild_roles(id: u64, ctx: &State) -> JsonValue { } } +#[get("/api/guild//templates")] +pub async fn get_reminder_templates( + id: u64, + cookies: &CookieJar<'_>, + ctx: &State, + pool: &State>, +) -> JsonValue { + check_authorization!(cookies, ctx.inner(), id); + + match sqlx::query_as_unchecked!( + ReminderTemplate, + "SELECT * FROM reminder_template WHERE guild_id = (SELECT id FROM guilds WHERE guild = ?)", + id + ) + .fetch_all(pool.inner()) + .await + { + Ok(templates) => { + json!(templates) + } + Err(e) => { + warn!("Could not fetch templates from {}: {:?}", id, e); + + json!({"error": "Could not get templates"}) + } + } +} + +#[post("/api/guild//templates", data = "")] +pub async fn create_reminder_template( + id: u64, + reminder_template: Json, + cookies: &CookieJar<'_>, + ctx: &State, + pool: &State>, +) -> JsonValue { + check_authorization!(cookies, ctx.inner(), id); + + // validate lengths + check_length!(MAX_CONTENT_LENGTH, reminder_template.content); + check_length!(MAX_EMBED_DESCRIPTION_LENGTH, reminder_template.embed_description); + check_length!(MAX_EMBED_TITLE_LENGTH, reminder_template.embed_title); + check_length!(MAX_EMBED_AUTHOR_LENGTH, reminder_template.embed_author); + check_length!(MAX_EMBED_FOOTER_LENGTH, reminder_template.embed_footer); + check_length_opt!(MAX_EMBED_FIELDS, reminder_template.embed_fields); + if let Some(fields) = &reminder_template.embed_fields { + for field in &fields.0 { + check_length!(MAX_EMBED_FIELD_VALUE_LENGTH, field.value); + check_length!(MAX_EMBED_FIELD_TITLE_LENGTH, field.title); + } + } + check_length_opt!(MAX_USERNAME_LENGTH, reminder_template.username); + check_length_opt!( + MAX_URL_LENGTH, + reminder_template.embed_footer_url, + reminder_template.embed_thumbnail_url, + reminder_template.embed_author_url, + reminder_template.embed_image_url, + reminder_template.avatar + ); + + // validate urls + check_url_opt!( + reminder_template.embed_footer_url, + reminder_template.embed_thumbnail_url, + reminder_template.embed_author_url, + reminder_template.embed_image_url, + reminder_template.avatar + ); + + let name = if reminder_template.name.is_empty() { + template_name_default() + } else { + reminder_template.name.clone() + }; + + match sqlx::query!( + "INSERT INTO reminder_template + (guild_id, + name, + attachment, + attachment_name, + avatar, + content, + embed_author, + embed_author_url, + embed_color, + embed_description, + embed_footer, + embed_footer_url, + embed_image_url, + embed_thumbnail_url, + embed_title, + embed_fields, + tts, + username + ) VALUES ((SELECT id FROM guilds WHERE guild = ?), ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", + id, name, + reminder_template.attachment, + reminder_template.attachment_name, + reminder_template.avatar, + reminder_template.content, + reminder_template.embed_author, + reminder_template.embed_author_url, + reminder_template.embed_color, + reminder_template.embed_description, + reminder_template.embed_footer, + reminder_template.embed_footer_url, + reminder_template.embed_image_url, + reminder_template.embed_thumbnail_url, + reminder_template.embed_title, + reminder_template.embed_fields, + reminder_template.tts, + reminder_template.username, + ) + .fetch_all(pool.inner()) + .await + { + Ok(_) => { + json!({}) + } + Err(e) => { + warn!("Could not fetch templates from {}: {:?}", id, e); + + json!({"error": "Could not get templates"}) + } + } +} + +#[delete("/api/guild//templates", data = "")] +pub async fn delete_reminder_template( + id: u64, + delete_reminder_template: Json, + cookies: &CookieJar<'_>, + ctx: &State, + pool: &State>, +) -> JsonValue { + check_authorization!(cookies, ctx.inner(), id); + + match sqlx::query!( + "DELETE FROM reminder_template WHERE guild_id = (SELECT id FROM guilds WHERE guild = ?) AND id = ?", + id, delete_reminder_template.id + ) + .fetch_all(pool.inner()) + .await + { + Ok(_) => { + json!({}) + } + Err(e) => { + warn!("Could not delete template from {}: {:?}", id, e); + + json!({"error": "Could not delete template"}) + } + } +} + #[post("/api/guild//reminders", data = "")] pub async fn create_reminder( id: u64, reminder: Json, + cookies: &CookieJar<'_>, serenity_context: &State, pool: &State>, ) -> JsonValue { - json!({"error": "Not implemented"}) + check_authorization!(cookies, serenity_context.inner(), id); + + 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()); + 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_EMBED_FIELDS, reminder.embed_fields); + if let Some(fields) = &reminder.embed_fields { + for field in &fields.0 { + check_length!(MAX_EMBED_FIELD_VALUE_LENGTH, field.value); + check_length!(MAX_EMBED_FIELD_TITLE_LENGTH, field.title); + } + } + check_length_opt!(MAX_USERNAME_LENGTH, reminder.username); + check_length_opt!( + MAX_URL_LENGTH, + reminder.embed_footer_url, + reminder.embed_thumbnail_url, + reminder.embed_author_url, + reminder.embed_image_url, + reminder.avatar + ); + + // 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_seconds.is_some() || reminder.interval_months.is_some() { + 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"}); + } + } + + // base64 decode error dropped here + let attachment_data = reminder.attachment.as_ref().map(|s| base64::decode(s).ok()).flatten(); + let name = if reminder.name.is_empty() { name_default() } else { reminder.name.clone() }; + + let new_uid = generate_uid(); + + // write to db + match sqlx::query!( + "INSERT INTO reminders ( + uid, + attachment, + attachment_name, + channel_id, + avatar, + content, + embed_author, + embed_author_url, + embed_color, + embed_description, + embed_footer, + embed_footer_url, + embed_image_url, + embed_thumbnail_url, + embed_title, + embed_fields, + enabled, + expires, + interval_seconds, + interval_months, + name, + pin, + restartable, + tts, + username, + `utc_time` + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", + new_uid, + attachment_data, + reminder.attachment_name, + channel, + reminder.avatar, + reminder.content, + reminder.embed_author, + reminder.embed_author_url, + reminder.embed_color, + reminder.embed_description, + reminder.embed_footer, + reminder.embed_footer_url, + reminder.embed_image_url, + reminder.embed_thumbnail_url, + reminder.embed_title, + reminder.embed_fields, + reminder.enabled, + reminder.expires, + reminder.interval_seconds, + reminder.interval_months, + name, + reminder.pin, + reminder.restartable, + reminder.tts, + reminder.username, + reminder.utc_time, + ) + .execute(pool.inner()) + .await + { + Ok(_) => 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.embed_fields, + 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 = ?", + new_uid + ) + .fetch_one(pool.inner()) + .await + .map(|r| json!(r)) + .unwrap_or_else(|e| { + warn!("Failed to complete SQL query: {:?}", e); + + json!({"error": "Could not load reminder"}) + }), + + Err(e) => { + warn!("Error in `create_reminder`: Could not execute query: {:?}", e); + + json!({"error": "Unknown error"}) + } + } } #[get("/api/guild//reminders")] @@ -130,42 +522,36 @@ pub async fn get_reminders(id: u64, ctx: &State, pool: &State/reminders", data = "")] pub async fn edit_reminder( id: u64, - reminder: Json, + reminder: Json, serenity_context: &State, pool: &State>, ) -> JsonValue { - json!({"error": "Not implemented"}) + let mut error = vec![]; + + update_field!(pool.inner(), error, reminder.[ + attachment, + attachment_name, + avatar, + content, + embed_author, + embed_author_url, + embed_color, + embed_description, + embed_footer, + embed_footer_url, + embed_image_url, + embed_thumbnail_url, + embed_title, + embed_fields, + enabled, + expires, + interval_seconds, + interval_months, + name, + pin, + restartable, + tts, + username, + utc_time + ]); + + if reminder.channel > 0 { + let channel = ChannelId(reminder.channel).to_channel_cached(&serenity_context.inner()); + match channel { + Some(channel) => { + let channel_matches_guild = channel.guild().map_or(false, |c| c.guild_id.0 == id); + + if !channel_matches_guild { + warn!( + "Error in `edit_reminder`: channel {:?} not found for guild {}", + reminder.channel, id + ); + + 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(); + + match sqlx::query!( + "UPDATE reminders SET channel_id = ? WHERE uid = ?", + channel, + reminder.uid + ) + .execute(pool.inner()) + .await + { + Ok(_) => {} + Err(e) => { + warn!("Error setting channel: {:?}", e); + + error.push("Couldn't set channel".to_string()) + } + } + } + + None => { + warn!( + "Error in `edit_reminder`: channel {:?} not found for guild {}", + reminder.channel, id + ); + + return json!({"error": "Channel not found"}); + } + } + } + + 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.embed_fields, + 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": reminder, "errors": error}), + + Err(e) => { + warn!("Error exiting `edit_reminder': {:?}", e); + + json!({"reminder": Option::::None, "errors": vec!["Unknown error"]}) + } + } } -#[delete("/api/guild//reminders", data = "")] +#[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..82b0913 100644 --- a/web/src/routes/dashboard/mod.rs +++ b/web/src/routes/dashboard/mod.rs @@ -1,19 +1,80 @@ +use std::collections::HashMap; + use chrono::naive::NaiveDateTime; -use rocket::http::CookieJar; -use rocket::response::Redirect; +use rand::{rngs::OsRng, seq::IteratorRandom}; +use rocket::{http::CookieJar, response::Redirect}; use rocket_dyn_templates::Template; use serde::{Deserialize, Serialize}; -use std::collections::HashMap; +use serenity::{http::Http, model::id::ChannelId}; +use sqlx::{types::Json, Executor}; + +use crate::{ + consts::{CHARACTERS, DEFAULT_AVATAR}, + Database, Error, +}; pub mod guild; pub mod user; +type Unset = Option; + fn name_default() -> String { "Reminder".to_string() } +fn template_name_default() -> String { + "Template".to_string() +} + +fn channel_default() -> u64 { + 0 +} + +fn id_default() -> u32 { + 0 +} + +#[derive(Serialize, Deserialize)] +pub struct ReminderTemplate { + #[serde(default = "id_default")] + id: u32, + #[serde(default = "id_default")] + guild_id: u32, + #[serde(default = "template_name_default")] + name: String, + attachment: Option>, + attachment_name: Option, + avatar: Option, + content: String, + embed_author: String, + embed_author_url: Option, + embed_color: u32, + embed_description: String, + embed_footer: String, + embed_footer_url: Option, + embed_image_url: Option, + embed_thumbnail_url: Option, + embed_title: String, + embed_fields: Option>>, + tts: bool, + username: Option, +} + +#[derive(Deserialize)] +pub struct DeleteReminderTemplate { + id: u32, +} + +#[derive(Serialize, Deserialize)] +pub struct EmbedField { + title: String, + value: String, + inline: bool, +} + #[derive(Serialize, Deserialize)] pub struct Reminder { + #[serde(with = "base64s")] attachment: Option>, attachment_name: Option, avatar: Option, @@ -29,25 +90,90 @@ pub struct Reminder { embed_image_url: Option, embed_thumbnail_url: Option, embed_title: String, - enabled: i8, + embed_fields: Option>>, + enabled: bool, expires: Option, interval_seconds: Option, interval_months: Option, #[serde(default = "name_default")] name: String, - pin: i8, - restartable: i8, - tts: i8, + pin: bool, + restartable: bool, + tts: bool, #[serde(default)] uid: String, username: Option, 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 = "channel_default")] + #[serde(with = "string")] + channel: u64, + #[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)] + embed_fields: 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(); + + (0..64) + .map(|_| CHARACTERS.chars().choose(&mut generator).unwrap().to_owned().to_string()) + .collect::>() + .join("") +} + // 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}; @@ -69,11 +195,104 @@ mod string { } } +mod base64s { + use serde::{de, Deserialize, Deserializer, Serializer}; + + pub fn serialize(value: &Option>, serializer: S) -> Result + where + S: Serializer, + { + if let Some(opt) = value { + serializer.collect_str(&base64::encode(opt)) + } else { + serializer.serialize_none() + } + } + + pub fn deserialize<'de, D>(deserializer: D) -> Result>, D::Error> + where + D: Deserializer<'de>, + { + let string = String::deserialize(deserializer)?; + Some(base64::decode(string).map_err(de::Error::custom)).transpose() + } +} + #[derive(Deserialize)] pub struct DeleteReminder { uid: String, } +async fn create_database_channel( + ctx: impl AsRef, + channel: ChannelId, + pool: impl Executor<'_, Database = Database> + Copy, +) -> Result { + println!("{:?}", channel); + + 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.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() { @@ -83,3 +302,13 @@ pub async fn dashboard_home(cookies: &CookieJar<'_>) -> Result")] +pub async fn dashboard(cookies: &CookieJar<'_>) -> Result { + if cookies.get_private("userid").is_some() { + let map: HashMap<&str, String> = HashMap::new(); + Ok(Template::render("dashboard", &map)) + } else { + Err(Redirect::to("/login/discord")) + } +} diff --git a/web/src/routes/dashboard/user.rs b/web/src/routes/dashboard/user.rs index a6521a4..882621a 100644 --- a/web/src/routes/dashboard/user.rs +++ b/web/src/routes/dashboard/user.rs @@ -1,22 +1,23 @@ -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}, + permissions::Permissions, + }, +}; +use sqlx::{MySql, Pool}; + +use crate::consts::DISCORD_API; #[derive(Serialize)] struct UserInfo { @@ -162,241 +163,3 @@ pub async fn get_user_guilds(cookies: &CookieJar<'_>, reqwest_client: &State, - ctx: &State, - pool: &State>, -) -> JsonValue { - match sqlx::query!( - "INSERT INTO reminders ( - 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 ( - 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` = ? - )", - 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`: {:?}", e); - - json!({"error": "Could not create reminder"}) - } - } -} - -#[get("/api/user/reminders")] -pub async fn get_reminders( - pool: &State>, - cookies: &CookieJar<'_>, - ctx: &State, -) -> JsonValue { - if let Some(user_id) = - cookies.get_private("userid").map(|c| c.value().parse::().ok()).flatten() - { - let query_res = sqlx::query!( - "SELECT channel FROM channels INNER JOIN users ON users.dm_channel = channels.id WHERE users.user = ?", - user_id - ) - .fetch_one(pool.inner()) - .await; - - let dm_channel = if let Ok(query) = query_res { - Some(query.channel) - } else { - if let Ok(dm_channel) = UserId(user_id).create_dm_channel(&ctx.inner()).await { - Some(dm_channel.id.as_u64().to_owned()) - } else { - None - } - }; - - if let Some(channel_id) = dm_channel { - let reminders = sqlx::query_as!( - 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 INNER JOIN channels ON channels.id = reminders.channel_id WHERE channels.channel = ?", - channel_id - ) - .fetch_all(pool.inner()) - .await - .unwrap_or(vec![]); - - json!(reminders) - } else { - json!({"error": "User's DM channel could not be determined"}) - } - } else { - json!({"error": "Not authorized"}) - } -} - -#[put("/api/user/reminders", data = "")] -pub async fn overwrite_reminder(reminder: Json, pool: &State>) -> JsonValue { - match sqlx::query!( - "UPDATE reminders SET - 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` = ? - WHERE uid = ?", - 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, - reminder.uid - ) - .execute(pool.inner()) - .await - { - Ok(_) => { - json!({}) - } - Err(e) => { - warn!("Error in `overwrite_reminder`: {:?}", e); - - json!({"error": "Could not modify reminder"}) - } - } -} - -#[delete("/api/user/reminders", data = "")] -pub async fn delete_reminder( - reminder: Json, - pool: &State>, -) -> JsonValue { - if sqlx::query!("DELETE FROM reminders WHERE uid = ?", reminder.uid) - .execute(pool.inner()) - .await - .is_ok() - { - json!({}) - } else { - json!({"error": "Could not delete reminder"}) - } -} 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/src/routes/mod.rs b/web/src/routes/mod.rs index c1560e8..d60fab1 100644 --- a/web/src/routes/mod.rs +++ b/web/src/routes/mod.rs @@ -1,9 +1,10 @@ pub mod dashboard; pub mod login; +use std::collections::HashMap; + use rocket::request::FlashMessage; use rocket_dyn_templates::Template; -use std::collections::HashMap; #[get("/")] pub async fn index(flash: Option>) -> Template { @@ -44,8 +45,44 @@ pub async fn terms() -> Template { Template::render("terms", &map) } -#[get("/help")] +#[get("/")] pub async fn help() -> Template { let map: HashMap<&str, String> = HashMap::new(); Template::render("help", &map) } + +#[get("/timezone")] +pub async fn help_timezone() -> Template { + let map: HashMap<&str, String> = HashMap::new(); + Template::render("support/timezone", &map) +} + +#[get("/create_reminder")] +pub async fn help_create_reminder() -> Template { + let map: HashMap<&str, String> = HashMap::new(); + Template::render("support/create_reminder", &map) +} + +#[get("/delete_reminder")] +pub async fn help_delete_reminder() -> Template { + let map: HashMap<&str, String> = HashMap::new(); + Template::render("support/delete_reminder", &map) +} + +#[get("/timers")] +pub async fn help_timers() -> Template { + let map: HashMap<&str, String> = HashMap::new(); + Template::render("support/timers", &map) +} + +#[get("/todo_lists")] +pub async fn help_todo_lists() -> Template { + let map: HashMap<&str, String> = HashMap::new(); + Template::render("support/todo_lists", &map) +} + +#[get("/macros")] +pub async fn help_macros() -> Template { + let map: HashMap<&str, String> = HashMap::new(); + Template::render("support/macros", &map) +} diff --git a/web/static/css/font.css b/web/static/css/font.css index 01b6e14..55de5c1 100644 --- a/web/static/css/font.css +++ b/web/static/css/font.css @@ -3,52 +3,61 @@ font-style: italic; font-weight: 300; src: local('Source Sans Pro Light Italic'), local('SourceSansPro-LightItalic'), url(https://fonts.gstatic.com/s/sourcesanspro/v13/6xKwdSBYKcSV-LCoeQqfX1RYOo3qPZZMkids18E.ttf) format('truetype'); + font-display: swap; } @font-face { font-family: 'Source Sans Pro'; font-style: italic; font-weight: 400; src: local('Source Sans Pro Italic'), local('SourceSansPro-Italic'), url(https://fonts.gstatic.com/s/sourcesanspro/v13/6xK1dSBYKcSV-LCoeQqfX1RYOo3qPZ7nsDc.ttf) format('truetype'); + font-display: swap; } @font-face { font-family: 'Source Sans Pro'; font-style: italic; font-weight: 600; src: local('Source Sans Pro SemiBold Italic'), local('SourceSansPro-SemiBoldItalic'), url(https://fonts.gstatic.com/s/sourcesanspro/v13/6xKwdSBYKcSV-LCoeQqfX1RYOo3qPZY4lCds18E.ttf) format('truetype'); + font-display: swap; } @font-face { font-family: 'Source Sans Pro'; font-style: normal; font-weight: 300; src: local('Source Sans Pro Light'), local('SourceSansPro-Light'), url(https://fonts.gstatic.com/s/sourcesanspro/v13/6xKydSBYKcSV-LCoeQqfX1RYOo3ik4zwlxdr.ttf) format('truetype'); + font-display: swap; } @font-face { font-family: 'Source Sans Pro'; font-style: normal; font-weight: 400; src: local('Source Sans Pro Regular'), local('SourceSansPro-Regular'), url(https://fonts.gstatic.com/s/sourcesanspro/v13/6xK3dSBYKcSV-LCoeQqfX1RYOo3qOK7g.ttf) format('truetype'); + font-display: swap; } @font-face { font-family: 'Source Sans Pro'; font-style: normal; font-weight: 600; src: local('Source Sans Pro SemiBold'), local('SourceSansPro-SemiBold'), url(https://fonts.gstatic.com/s/sourcesanspro/v13/6xKydSBYKcSV-LCoeQqfX1RYOo3i54rwlxdr.ttf) format('truetype'); + font-display: swap; } @font-face { font-family: 'Source Sans Pro'; font-style: normal; font-weight: 700; src: local('Source Sans Pro Bold'), local('SourceSansPro-Bold'), url(https://fonts.gstatic.com/s/sourcesanspro/v13/6xKydSBYKcSV-LCoeQqfX1RYOo3ig4vwlxdr.ttf) format('truetype'); + font-display: swap; } @font-face { font-family: 'Ubuntu'; font-style: normal; font-weight: 400; src: url(https://fonts.gstatic.com/s/ubuntu/v15/4iCs6KVjbNBYlgo6eA.ttf) format('truetype'); + font-display: swap; } @font-face { font-family: 'Ubuntu'; font-style: normal; font-weight: 700; src: url(https://fonts.gstatic.com/s/ubuntu/v15/4iCv6KVjbNBYlgoCxCvTtw.ttf) format('truetype'); + font-display: swap; } diff --git a/web/static/css/style.css b/web/static/css/style.css index b235076..2275fee 100644 --- a/web/static/css/style.css +++ b/web/static/css/style.css @@ -2,6 +2,89 @@ font-family: "Ubuntu Bold", "Ubuntu", sans-serif; } +button { + font-weight: 700; +} + +/* 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 .reminder-topbar { + display: inline-flex; + margin-bottom: 0px; + flex-grow: 1; + order: 2; +} + +div.reminderContent.is-collapsed input[name="name"] { + display: inline-flex; + flex-grow: 1; + border: none; + font-weight: 700; + background: none; +} + +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.inline-btn { + height: 100%; + padding: 5px; +} + +button.change-color { + position: absolute; + left: calc(-1rem - 40px); +} + +button.disable-enable[data-action="enable"]:after { + content: "Enable"; +} + +button.disable-enable[data-action="disable"]:after { + content: "Disable"; +} + +.media-content { + overflow-x: visible; +} + +div.discord-embed { + position: relative; +} + div.reminderContent { padding: 2px; background-color: #f5f5f5; @@ -9,6 +92,33 @@ div.reminderContent { margin: 8px; } +div.interval-group > button { + margin-left: auto; +} + +/* Interval inputs */ +div.interval-group > .interval-group-left input { + -webkit-appearance: none; + border-style: none; + background-color: #eee; + font-size: 1rem; + font-family: monospace; +} + +div.interval-group > .interval-group-left input.w2 { + width: 3ch; +} + +div.interval-group > .interval-group-left input.w3 { + width: 6ch; +} + +div.interval-group { + display: flex; + flex-direction: row; +} +/* !Interval inputs */ + .left-pad { padding-left: 1rem; padding-right: 0.2rem; @@ -52,6 +162,15 @@ span.patreon-color { color: #f96854; } +p.pageTitle { + margin-left: 12px; +} + +#welcome > div { + height: 100%; + padding-top: 30vh; +} + div#pageNavbar { background-color: #363636; } @@ -71,7 +190,7 @@ img.rounded-corners { div.brand { text-align: center; - height: 48px; + height: 52px; background-color: #8fb677; } @@ -87,24 +206,61 @@ div.dashboard-sidebar { padding-right: 0; } +div.dashboard-sidebar:not(.mobile-sidebar) { + display: flex; + flex-direction: column; +} + +div.dashboard-sidebar:not(.mobile-sidebar) .aside-footer { + position: fixed; + bottom: 0; + width: 226px; +} + div.mobile-sidebar { z-index: 100; - height: 100vh; + min-height: 100vh; position: absolute; top: 0; display: none; flex-direction: column; } +#expandAll { + width: 60px; +} + +div.mobile-sidebar .aside-footer { + margin-top: auto; +} + div.mobile-sidebar.is-active { display: flex; } +aside.menu { + display: flex; + flex-direction: column; + flex-grow: 1; +} + div.dashboard-frame { min-height: 100vh; margin-bottom: 0 !important; } +.embed-field-box[data-inlined="0"] .inline-btn > i { + transform: rotate(90deg); +} + +.embed-field-box[data-inlined="0"] { + min-width: 100%; +} + +.embed-field-box[data-inlined="1"] { + min-width: auto; +} + .menu a { color: #fff; } @@ -254,7 +410,6 @@ textarea, input { border-radius: 4px; border-left: 4px solid #fff; background-color: #2f3136; - width: 500px; } .embed-author-box { @@ -340,36 +495,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; @@ -381,3 +506,73 @@ a.level-item:hover { border-radius: 2px; border-bottom: 1px solid #fff; } + +@media only screen and (max-width: 768px) { + .customizable.thumbnail img { + width: 60px; + height: 60px; + } + + .customizable.is-24x24 img { + width: 16px; + height: 16px; + } +} + +/* loader */ +#loader { + position: fixed; + background-color: rgba(255, 255, 255, 0.8); + width: 100vw; + z-index: 999; +} + +#loader .title { + font-size: 6rem; +} + +/* END */ + +/* other stuff */ + +.half-rem { + width: 0.5rem; +} + +.pad-left { + width: 12px; +} + +#dead { + display: none; +} + +.colorpicker-container { + display: flex; + justify-content: center; +} + +.create-reminder { + margin: 0 12px 12px 12px; +} + +.button.is-success:not(.is-outlined) { + color: white; +} + +.button.is-outlined.is-success { + background-color: white; +} + +.is-locked { + pointer-events: none; + opacity: 0.4; +} + +.is-locked .foreground { + pointer-events: auto; +} + +.is-locked .field:last-of-type { + display: none; +} diff --git a/web/static/img/bg.webp b/web/static/img/bg.webp new file mode 100644 index 0000000..26e9fa0 Binary files /dev/null and b/web/static/img/bg.webp differ diff --git a/web/static/img/logo_flat.webp b/web/static/img/logo_flat.webp new file mode 100644 index 0000000..35f4a26 Binary files /dev/null and b/web/static/img/logo_flat.webp differ diff --git a/web/static/img/support/delete_reminder/1.png b/web/static/img/support/delete_reminder/1.png new file mode 100644 index 0000000..56bf390 Binary files /dev/null and b/web/static/img/support/delete_reminder/1.png differ diff --git a/web/static/img/support/delete_reminder/2.png b/web/static/img/support/delete_reminder/2.png new file mode 100644 index 0000000..5fd4dec Binary files /dev/null and b/web/static/img/support/delete_reminder/2.png differ diff --git a/web/static/img/support/delete_reminder/3.png b/web/static/img/support/delete_reminder/3.png new file mode 100644 index 0000000..de199c6 Binary files /dev/null and b/web/static/img/support/delete_reminder/3.png differ diff --git a/web/static/img/support/delete_reminder/cmd-1.png b/web/static/img/support/delete_reminder/cmd-1.png new file mode 100644 index 0000000..0236a74 Binary files /dev/null and b/web/static/img/support/delete_reminder/cmd-1.png differ diff --git a/web/static/img/support/delete_reminder/cmd-2.png b/web/static/img/support/delete_reminder/cmd-2.png new file mode 100644 index 0000000..ce8f655 Binary files /dev/null and b/web/static/img/support/delete_reminder/cmd-2.png differ 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/interval.js b/web/static/js/interval.js new file mode 100644 index 0000000..a28d75a --- /dev/null +++ b/web/static/js/interval.js @@ -0,0 +1,88 @@ +function get_interval(element) { + let months = element.querySelector('input[name="interval_months"]').value; + let days = element.querySelector('input[name="interval_days"]').value; + let hours = element.querySelector('input[name="interval_hours"]').value; + let minutes = element.querySelector('input[name="interval_minutes"]').value; + let seconds = element.querySelector('input[name="interval_seconds"]').value; + + return { + months: parseInt(months) || null, + seconds: + (parseInt(days) || 0) * 86400 + + (parseInt(hours) || 0) * 3600 + + (parseInt(minutes) || 0) * 60 + + (parseInt(seconds) || 0) || null, + }; +} + +function update_interval(element) { + let months = element.querySelector('input[name="interval_months"]'); + let days = element.querySelector('input[name="interval_days"]'); + let hours = element.querySelector('input[name="interval_hours"]'); + let minutes = element.querySelector('input[name="interval_minutes"]'); + let seconds = element.querySelector('input[name="interval_seconds"]'); + + months.value = months.value.padStart(1, "0"); + days.value = days.value.padStart(1, "0"); + hours.value = hours.value.padStart(2, "0"); + minutes.value = minutes.value.padStart(2, "0"); + seconds.value = seconds.value.padStart(2, "0"); + + if (seconds.value >= 60) { + let quotient = Math.floor(seconds.value / 60); + let remainder = seconds.value % 60; + + seconds.value = String(remainder).padStart(2, "0"); + minutes.value = String(Number(minutes.value) + Number(quotient)).padStart(2, "0"); + } + if (minutes.value >= 60) { + let quotient = Math.floor(minutes.value / 60); + let remainder = minutes.value % 60; + + minutes.value = String(remainder).padStart(2, "0"); + hours.value = String(Number(hours.value) + Number(quotient)).padStart(2, "0"); + } + if (hours.value >= 24) { + let quotient = Math.floor(hours.value / 24); + let remainder = hours.value % 24; + + hours.value = String(remainder).padStart(2, "0"); + days.value = Number(days.value) + Number(quotient); + } +} + +const $intervalGroup = document.querySelector(".interval-group"); + +document.querySelector(".interval-group").addEventListener( + "blur", + (ev) => { + if (ev.target.nodeName !== "BUTTON") update_interval($intervalGroup); + }, + true +); + +$intervalGroup.querySelector("button.clear").addEventListener("click", () => { + $intervalGroup.querySelectorAll("input").forEach((el) => { + el.value = ""; + }); +}); + +document.addEventListener("remindersLoaded", (event) => { + for (reminder of event.detail) { + let $intervalGroup = reminder.node.querySelector(".interval-group"); + + $intervalGroup.addEventListener( + "blur", + (ev) => { + if (ev.target.nodeName !== "BUTTON") update_interval($intervalGroup); + }, + true + ); + + $intervalGroup.querySelector("button.clear").addEventListener("click", () => { + $intervalGroup.querySelectorAll("input").forEach((el) => { + el.value = ""; + }); + }); + } +}); diff --git a/web/static/js/main.js b/web/static/js/main.js new file mode 100644 index 0000000..43b2d8d --- /dev/null +++ b/web/static/js/main.js @@ -0,0 +1,913 @@ +let colorPicker = new iro.ColorPicker("#colorpicker"); +let $discordFrame; +const $loader = document.querySelector("#loader"); +const $colorPickerModal = document.querySelector("div#pickColorModal"); +const $colorPickerInput = $colorPickerModal.querySelector("input"); +const $deleteReminderBtn = document.querySelector("#delete-reminder-confirm"); +const $reminderTemplate = document.querySelector("template#guildReminder"); +const $embedFieldTemplate = document.querySelector("template#embedFieldTemplate"); +const $createReminder = document.querySelector("#reminderCreator"); +const $createReminderBtn = $createReminder.querySelector("button#createReminder"); +const $createTemplateBtn = $createReminder.querySelector("button#createTemplate"); +const $loadTemplateBtn = document.querySelector("button#load-template"); +const $deleteTemplateBtn = document.querySelector("button#delete-template"); +const $templateSelect = document.querySelector("select#templateSelect"); + +let channels = []; +let roles = []; +let templates = {}; + +let globalPatreon = false; + +function guildId() { + return document.querySelector(".guildList a.is-active").dataset["guild"]; +} + +function colorToInt(r, g, b) { + return (r << 16) + (g << 8) + b; +} + +function intToColor(i) { + return `#${i.toString(16).padStart(6, "0")}`; +} + +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 { + roles = data; + } + }); +} + +function fetch_templates(guild_id) { + fetch(`/dashboard/api/guild/${guild_id}/templates`) + .then((response) => response.json()) + .then((data) => { + if (data.error) { + show_error(data.error); + } else { + templates = {}; + + const select = document.querySelector("#templateSelect"); + select.innerHTML = ""; + for (let template of data) { + templates[template["id"]] = template; + + let option = document.createElement("option"); + option.value = template["id"]; + option.textContent = template["name"]; + + select.appendChild(option); + } + } + }); +} + +async function fetch_channels(guild_id) { + const event = new Event("channelsLoading"); + document.dispatchEvent(event); + + await fetch(`/dashboard/api/guild/${guild_id}/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); + } + } else { + channels = data; + } + }) + .then(() => { + const event = new Event("channelsLoaded"); + document.dispatchEvent(event); + }); +} + +async function fetch_reminders(guild_id) { + document.dispatchEvent(new Event("remindersLoading")); + + const $reminderBox = document.querySelector("div#guildReminders"); + + // reset div contents + $reminderBox.innerHTML = ""; + + // fetch reminders + await fetch(`/dashboard/api/guild/${guild_id}/reminders`) + .then((response) => response.json()) + .then((data) => { + if (data.error) { + show_error(data.error); + } else { + for (let reminder of data) { + let newFrame = $reminderTemplate.content.cloneNode(true); + + newFrame.querySelector(".reminderContent").dataset["uid"] = + reminder["uid"]; + + deserialize_reminder(reminder, newFrame, "load"); + + $reminderBox.appendChild(newFrame); + + reminder.node = $reminderBox.lastElementChild; + } + + const remindersLoadedEvent = new CustomEvent("remindersLoaded", { + detail: data, + }); + + document.dispatchEvent(remindersLoadedEvent); + } + }); +} + +async function serialize_reminder(node, mode) { + let interval, utc_time, expiration_time; + + if (mode !== "template") { + interval = get_interval(node); + + utc_time = luxon.DateTime.fromISO( + node.querySelector('input[name="time"]').value + ).setZone("UTC"); + if (utc_time.invalid) { + return { error: "Time provided invalid." }; + } else { + utc_time = utc_time.toFormat("yyyy-LL-dd'T'HH:mm:ss"); + } + + expiration_time = luxon.DateTime.fromISO( + node.querySelector('input[name="time"]').value + ).setZone("UTC"); + if (expiration_time.invalid) { + return { error: "Expiration provided invalid." }; + } else { + expiration_time = expiration_time.toFormat("yyyy-LL-dd'T'HH:mm:ss"); + } + } + + let rgb_color = window.getComputedStyle( + node.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 fields = [ + ...node.querySelectorAll("div.embed-multifield-box div.embed-field-box"), + ] + .map((el) => { + return { + title: el.querySelector("textarea.discord-field-title").value, + value: el.querySelector("textarea.discord-field-value").value, + inline: el.dataset["inlined"] === "1", + }; + }) + .filter(({ title, value, inline }) => title.length + value.length > 0); + + let attachment = null; + let attachment_name = null; + + if (node.querySelector('input[name="attachment"]').files.length > 0) { + let file = node.querySelector('input[name="attachment"]').files[0]; + + if (file.size >= 8 * 1024 * 1024) { + return { error: "File too large." }; + } + + attachment = await new Promise((resolve) => { + let fileReader = new FileReader(); + fileReader.onload = (e) => resolve(fileReader.result); + fileReader.readAsDataURL(file); + }); + attachment = attachment.split(",")[1]; + attachment_name = file.name; + } + + let uid = ""; + if (mode === "edit") { + uid = node.closest(".reminderContent").dataset["uid"]; + } + + let enabled = null; + if (mode === "create") { + enabled = true; + } + + const content = node.querySelector('textarea[name="content"]').value; + const embed_author_url = has_source(node.querySelector("img.embed_author_url").src); + const embed_author = node.querySelector('textarea[name="embed_author"]').value; + const embed_description = node.querySelector( + 'textarea[name="embed_description"]' + ).value; + const embed_footer = node.querySelector('textarea[name="embed_footer"]').value; + const embed_footer_url = has_source(node.querySelector("img.embed_footer_url").src); + const embed_image_url = has_source(node.querySelector("img.embed_image_url").src); + const embed_thumbnail_url = has_source( + node.querySelector("img.embed_thumbnail_url").src + ); + const embed_title = node.querySelector('textarea[name="embed_title"]').value; + + if ( + attachment === null && + content.length == 0 && + embed_author_url === null && + embed_author.length == 0 && + embed_description.length == 0 && + embed_footer.length == 0 && + embed_footer_url === null && + embed_image_url === null && + embed_thumbnail_url === null + ) { + return { error: "Reminder needs content." }; + } + + return { + // if we're creating a reminder, ignore this field + uid: uid, + // if we're editing a reminder, ignore this field + enabled: enabled, + restartable: false, + attachment: attachment, + attachment_name: attachment_name, + avatar: has_source(node.querySelector("img.discord-avatar").src), + channel: node.querySelector("select.channel-selector").value, + content: content, + embed_author_url: embed_author_url, + embed_author: embed_author, + embed_color: color, + embed_description: embed_description, + embed_footer: embed_footer, + embed_footer_url: embed_footer_url, + embed_image_url: embed_image_url, + embed_thumbnail_url: embed_thumbnail_url, + embed_title: embed_title, + embed_fields: fields, + expires: expiration_time, + interval_seconds: mode !== "template" ? interval.seconds : null, + interval_months: mode !== "template" ? interval.months : null, + name: node.querySelector('input[name="name"]').value, + pin: node.querySelector('input[name="pin"]').checked, + tts: node.querySelector('input[name="tts"]').checked, + username: node.querySelector('input[name="username"]').value, + utc_time: utc_time, + }; +} + +function deserialize_reminder(reminder, frame, mode) { + // populate channels + set_channels(frame.querySelector("select.channel-selector")); + + // populate majority of items + for (let prop in reminder) { + if (reminder.hasOwnProperty(prop) && reminder[prop] !== null) { + if (prop === "attachment") { + } else if (prop === "attachment_name") { + frame.querySelector(".file-cta > .file-label").textContent = + reminder[prop]; + } else { + let $input = frame.querySelector(`*[name="${prop}"]`); + let $image = frame.querySelector(`img.${prop}`); + + if ($input !== null) { + $input.value = reminder[prop]; + } else if ($image !== null) { + $image.src = reminder[prop]; + } + } + } + } + + const lastChild = frame.querySelector("div.embed-multifield-box .embed-field-box"); + + for (let field of reminder["embed_fields"]) { + let embed_field = $embedFieldTemplate.content.cloneNode(true); + embed_field.querySelector("textarea.discord-field-title").value = field["title"]; + embed_field.querySelector("textarea.discord-field-value").value = field["value"]; + embed_field.querySelector(".embed-field-box").dataset["inlined"] = field["inline"] + ? "1" + : "0"; + + frame + .querySelector("div.embed-multifield-box") + .insertBefore(embed_field, lastChild); + } + + if (mode !== "template") { + if (reminder["interval_seconds"]) update_interval(frame); + + let $enableBtn = frame.querySelector(".disable-enable"); + $enableBtn.dataset["action"] = reminder["enabled"] ? "disable" : "enable"; + + let timeInput = frame.querySelector('input[name="time"]'); + let localTime = luxon.DateTime.fromISO(reminder["utc_time"], { + zone: "UTC", + }).setZone(timezone); + timeInput.value = localTime.toFormat("yyyy-LL-dd'T'HH:mm:ss"); + + if (reminder["expires"]) { + let expiresInput = frame.querySelector('input[name="time"]'); + let expiresTime = luxon.DateTime.fromISO(reminder["expires"], { + zone: "UTC", + }).setZone(timezone); + expiresInput.value = expiresTime.toFormat("yyyy-LL-dd'T'HH:mm:ss"); + } + } +} + +document.addEventListener("guildSwitched", async (e) => { + $loader.classList.remove("is-hidden"); + + let $anchor = document.querySelector( + `.switch-pane[data-guild="${e.detail.guild_id}"]` + ); + + switch_pane($anchor.dataset["pane"]); + reset_guild_pane(); + $anchor.classList.add("is-active"); + + fetch_roles(e.detail.guild_id); + fetch_templates(e.detail.guild_id); + await fetch_channels(e.detail.guild_id); + fetch_reminders(e.detail.guild_id); + + document.querySelectorAll("p.pageTitle").forEach((el) => { + el.textContent = `${e.detail.guild_name} Reminders`; + }); + document.querySelectorAll("select.channel-selector").forEach((el) => { + el.addEventListener("change", (e) => { + update_select(e.target); + }); + }); + + resize_textareas(); + + $loader.classList.add("is-hidden"); +}); + +document.addEventListener("channelsLoaded", () => { + document.querySelectorAll("select.channel-selector").forEach(set_channels); +}); + +document.addEventListener("remindersLoaded", (event) => { + const guild = guildId(); + + for (let reminder of event.detail) { + let node = reminder.node; + + node.querySelector("button.hide-box").addEventListener("click", () => { + node.closest(".reminderContent").classList.toggle("is-collapsed"); + }); + + node.querySelector("div.discord-embed").style.borderLeftColor = intToColor( + reminder.embed_color + ); + + const enableBtn = 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.dataset["action"] = data["enabled"] + ? "enable" + : "disable"; + } + }); + }); + + node.querySelector("button.delete-reminder").addEventListener("click", () => { + $deleteReminderBtn.dataset["uid"] = reminder["uid"]; + $deleteReminderBtn.closest(".modal").classList.toggle("is-active"); + }); + + const $saveBtn = node.querySelector("button.save-btn"); + + $saveBtn.addEventListener("click", async (event) => { + $saveBtn.querySelector("span.icon > i").classList = [ + "fas fa-spinner fa-spin", + ]; + + let reminder = await serialize_reminder(node, "edit"); + if (reminder.error) { + show_error(reminder.error); + return; + } + + let guild = guildId(); + + fetch(`/dashboard/api/guild/${guild}/reminders`, { + method: "PATCH", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify(reminder), + }) + .then((response) => response.json()) + .then((data) => { + for (let error of data.errors) show_error(error); + }); + + $saveBtn.querySelector("span.icon > i").classList = ["fas fa-check"]; + + window.setTimeout(() => { + $saveBtn.querySelector("span.icon > i").classList = ["fas fa-save"]; + }, 1500); + }); + } +}); + +$deleteReminderBtn.addEventListener("click", () => { + let guild = guildId(); + + fetch(`/dashboard/api/guild/${guild}/reminders`, { + method: "DELETE", + body: JSON.stringify({ + uid: $deleteReminderBtn.dataset["uid"], + }), + }).then(() => { + document.querySelector("#deleteReminderModal").classList.remove("is-active"); + fetch_reminders(guild); + }); +}); + +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); +} + +$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; +}); + +$colorPickerModal.querySelector("button.is-success").addEventListener("click", () => { + $discordFrame.style.borderLeftColor = colorPicker.color.rgbString; + + $colorPickerModal.classList.remove("is-active"); +}); + +document.querySelectorAll(".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"); + }); + }); + + 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 { + if (data.timezone !== null) botTimezone = data.timezone; + + globalPatreon = data.patreon; + + 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"); + + let $span = $clone.querySelector("a > span.guild-name"); + + $span.textContent = $span.textContent.replace( + "%guildname%", + guild.name + ); + $anchor.dataset["guild"] = guild.id; + $anchor.dataset["name"] = guild.name; + $anchor.href = `/dashboard/${guild.id}?name=${guild.name}`; + + $anchor.addEventListener("click", async (e) => { + e.preventDefault(); + window.history.pushState( + {}, + "", + `/dashboard/${guild.id}?name=${guild.name}` + ); + const event = new CustomEvent("guildSwitched", { + detail: { + guild_name: guild.name, + guild_id: guild.id, + }, + }); + + document.dispatchEvent(event); + }); + + element.append($clone); + }); + } + + const matches = window.location.href.match(/dashboard\/(\d+)/); + if (matches) { + let id = matches[1]; + let name = + new URLSearchParams(window.location.search).get("name") || id; + const event = new CustomEvent("guildSwitched", { + detail: { + guild_name: name, + guild_id: id, + }, + }); + + document.dispatchEvent(event); + } + } + }); + + $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; + + element.appendChild(newOption); + } + + update_select(element); +} + +function has_source(string) { + if (string.startsWith(`https://${window.location.hostname}`)) { + return null; + } else { + return string; + } +} + +$createReminderBtn.addEventListener("click", async () => { + $createReminderBtn.querySelector("span.icon > i").classList = [ + "fas fa-spinner fa-spin", + ]; + + let reminder = await serialize_reminder($createReminder, "create"); + if (reminder.error) { + show_error(reminder.error); + return; + } + + let guild = guildId(); + + fetch(`/dashboard/api/guild/${guild}/reminders`, { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify(reminder), + }) + .then((response) => response.json()) + .then((data) => { + if (data.error) { + show_error(data.error); + + $createReminderBtn.querySelector("span.icon > i").classList = [ + "fas fa-sparkles", + ]; + } else { + const $reminderBox = document.querySelector("div#guildReminders"); + let newFrame = $reminderTemplate.content.cloneNode(true); + + newFrame.querySelector(".reminderContent").dataset["uid"] = data["uid"]; + + deserialize_reminder(data, newFrame, "load"); + + $reminderBox.appendChild(newFrame); + + data.node = $reminderBox.lastElementChild; + + document.dispatchEvent( + new CustomEvent("remindersLoaded", { + detail: [data], + }) + ); + + $createReminderBtn.querySelector("span.icon > i").classList = [ + "fas fa-check", + ]; + + window.setTimeout(() => { + $createReminderBtn.querySelector("span.icon > i").classList = [ + "fas fa-sparkles", + ]; + }, 1500); + } + }); +}); + +$createTemplateBtn.addEventListener("click", async () => { + $createTemplateBtn.querySelector("span.icon > i").classList = [ + "fas fa-spinner fa-spin", + ]; + + let reminder = await serialize_reminder($createReminder, "template"); + let guild = guildId(); + + fetch(`/dashboard/api/guild/${guild}/templates`, { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify(reminder), + }) + .then((response) => response.json()) + .then((data) => { + if (data.error) { + show_error(data.error); + $createTemplateBtn.querySelector("span.icon > i").classList = [ + "fas fa-file-spreadsheet", + ]; + } else { + fetch_templates(guildId()); + + $createTemplateBtn.querySelector("span.icon > i").classList = [ + "fas fa-check", + ]; + + window.setTimeout(() => { + $createTemplateBtn.querySelector("span.icon > i").classList = [ + "fas fa-file-spreadsheet", + ]; + }, 1500); + } + }); +}); + +$loadTemplateBtn.addEventListener("click", (ev) => { + deserialize_reminder( + templates[parseInt($templateSelect.value)], + $createReminder, + "template" + ); +}); + +$deleteTemplateBtn.addEventListener("click", (ev) => { + fetch(`/dashboard/api/guild/${guildId()}/templates`, { + method: "DELETE", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ id: parseInt($templateSelect.value) }), + }) + .then((response) => response.json()) + .then((data) => { + if (data.error) { + show_error(data.error); + } else { + $templateSelect + .querySelector(`option[value="${$templateSelect.value}"]`) + .remove(); + } + }); +}); + +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#setImgUrl").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"); + }); +}); + +document.addEventListener("remindersLoaded", () => { + document.querySelectorAll(".customizable").forEach((element) => { + element.querySelector("a").addEventListener("click", (e) => { + e.preventDefault(); + + $img = element.querySelector("img"); + + $urlModal.classList.toggle("is-active"); + }); + }); + + 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(".change-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; + }); + }); +}); + +function check_embed_fields() { + document.querySelectorAll(".embed-field-box").forEach((element) => { + const $titleInput = element.querySelector(".discord-field-title"); + const $valueInput = element.querySelector(".discord-field-value"); + + // when the user clicks out of the field title and if the field title/value are empty, remove the field + $titleInput.addEventListener("blur", () => { + if ( + $titleInput.value === "" && + $valueInput.value === "" && + element.nextElementSibling !== null + ) { + element.remove(); + } + }); + + $valueInput.addEventListener("blur", () => { + if ( + $titleInput.value === "" && + $valueInput.value === "" && + element.nextElementSibling !== null + ) { + element.remove(); + } + }); + + // when the user inputs into the end field, create a new field after it + $titleInput.addEventListener("input", () => { + if ( + $titleInput.value !== "" && + $valueInput.value !== "" && + element.nextElementSibling === null + ) { + const $clone = $embedFieldTemplate.content.cloneNode(true); + element.parentElement.append($clone); + } + }); + + $valueInput.addEventListener("input", () => { + if ( + $titleInput.value !== "" && + $valueInput.value !== "" && + element.nextElementSibling === null + ) { + const $clone = $embedFieldTemplate.content.cloneNode(true); + element.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('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(); +}); + +document.addEventListener("click", (ev) => { + if (ev.target.closest("button.inline-btn") !== null) { + let inlined = ev.target.closest(".embed-field-box").dataset["inlined"]; + ev.target.closest(".embed-field-box").dataset["inlined"] = + inlined == "1" ? "0" : "1"; + } +}); diff --git a/web/static/js/sort.js b/web/static/js/sort.js new file mode 100644 index 0000000..7174d9c --- /dev/null +++ b/web/static/js/sort.js @@ -0,0 +1,70 @@ +let guildReminders = document.querySelector("#guildReminders"); + +function sort_by(cond) { + if (cond === "channel") { + [...guildReminders.children] + .sort((a, b) => { + let channel1 = a.querySelector("select.channel-selector").value; + let channel2 = b.querySelector("select.channel-selector").value; + + return channel1 > channel2 ? 1 : -1; + }) + .forEach((node) => guildReminders.appendChild(node)); + + // go through and add channel categories + 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) => { + let time1 = luxon.DateTime.fromISO( + a.querySelector('input[name="time"]').value + ); + let time2 = luxon.DateTime.fromISO( + b.querySelector('input[name="time"]').value + ); + + return time1 > time2 ? 1 : -1; + }) + .forEach((node) => guildReminders.appendChild(node)); + } else { + [...guildReminders.children] + .sort((a, b) => { + let name1 = a.querySelector('input[name="name"]').value; + let name2 = b.querySelector('input[name="name"]').value; + + return name1 > name2 ? 1 : -1; + }) + .forEach((node) => guildReminders.appendChild(node)); + } + } +} + +const selector = document.querySelector("#orderBy"); + +selector.addEventListener("change", () => { + sort_by(selector.value); +}); + +document.addEventListener("remindersLoaded", () => { + sort_by(selector.value); +}); diff --git a/web/static/js/timezone.js b/web/static/js/timezone.js new file mode 100644 index 0000000..9515830 --- /dev/null +++ b/web/static/js/timezone.js @@ -0,0 +1,57 @@ +let timezone = luxon.DateTime.now().zone.name; +const browserTimezone = luxon.DateTime.now().zone.name; +let botTimezone = "UTC"; + +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); + +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(); + } + }); +}); diff --git a/web/templates/base.html.tera b/web/templates/base.html.tera index 3af2575..a033a67 100644 --- a/web/templates/base.html.tera +++ b/web/templates/base.html.tera @@ -24,7 +24,6 @@ - {% if flashed_message %} @@ -37,7 +36,7 @@