diff --git a/.env b/.env new file mode 100644 index 0000000..be42785 --- /dev/null +++ b/.env @@ -0,0 +1,2 @@ +DATABASE_URL="sqlite://test-db/dicebot.sqlite" +SQLX_OFFLINE="true" diff --git a/.gitignore b/.gitignore index 6c5b343..6cb4892 100644 --- a/.gitignore +++ b/.gitignore @@ -10,3 +10,5 @@ bot-db* # We store a disabled async test in this file bigboy .#* +*.sqlite +.tmp* diff --git a/Cargo.lock b/Cargo.lock index ff8523e..95caf01 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -66,6 +66,23 @@ dependencies = [ "opaque-debug", ] +[[package]] +name = "ahash" +version = "0.4.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "739f4a8db6605981345c5654f3a85b056ce52f37a39d34da03f25bf2151ea16e" + +[[package]] +name = "ahash" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "796540673305a66d127804eef19ad696f1f204b8c1025aaca4958c17eab32877" +dependencies = [ + "getrandom 0.2.2", + "once_cell", + "version_check", +] + [[package]] name = "aho-corasick" version = "0.7.18" @@ -75,6 +92,15 @@ dependencies = [ "memchr", ] +[[package]] +name = "ansi_term" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d52a9bb7ec0cf484c551830a7ce27bd20d67eac647e1befb56b0be4ee39a55d2" +dependencies = [ + "winapi", +] + [[package]] name = "arrayvec" version = "0.5.2" @@ -98,6 +124,15 @@ dependencies = [ "syn", ] +[[package]] +name = "atoi" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "616896e05fc0e2649463a93a15183c6a16bf03413a7af88ef1285ddedfa9cda5" +dependencies = [ + "num-traits", +] + [[package]] name = "atomic" version = "0.5.0" @@ -107,17 +142,6 @@ dependencies = [ "autocfg", ] -[[package]] -name = "atty" -version = "0.2.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d9b39be18770d11421cdb1b9947a45dd3f37e93092cbf377614828a319d5fee8" -dependencies = [ - "hermit-abi", - "libc", - "winapi", -] - [[package]] name = "autocfg" version = "1.0.1" @@ -138,6 +162,12 @@ dependencies = [ "tokio", ] +[[package]] +name = "barrel" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d67c978b1322c8031145b1f6c236fc371292f52c565bc96018b2971afcbffe1" + [[package]] name = "base-x" version = "0.2.8" @@ -165,6 +195,18 @@ version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cf1de2fe8c75bc145a2f577add951f8134889b4795d47466a54a5c846d691693" +[[package]] +name = "bitvec" +version = "0.19.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8942c8d352ae1838c9dda0b0ca2ab657696ef2232a20147cf1b30ae1a9cb4321" +dependencies = [ + "funty", + "radium", + "tap", + "wyz", +] + [[package]] name = "block-buffer" version = "0.9.0" @@ -174,6 +216,12 @@ dependencies = [ "generic-array", ] +[[package]] +name = "build_const" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4ae4235e6dac0694637c763029ecea1a2ec9e4e06ec2729bd21ba4d9c863eb7" + [[package]] name = "bumpalo" version = "3.6.1" @@ -227,6 +275,19 @@ dependencies = [ "zeroize", ] +[[package]] +name = "chrono" +version = "0.4.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "670ad68c9088c2a963aaa298cb369688cf3f9465ce5e2d4ca10e6e0098a1ce73" +dependencies = [ + "libc", + "num-integer", + "num-traits", + "time 0.1.43", + "winapi", +] + [[package]] name = "cipher" version = "0.2.5" @@ -279,9 +340,9 @@ checksum = "ea221b5284a47e40033bf9b66f35f984ec0ea2931eb03505246cd27a963f981b" [[package]] name = "cpufeatures" -version = "0.1.3" +version = "0.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "281f563b2c3a0e535ab12d81d3c5859045795256ad269afa7c19542585b68f93" +checksum = "ed00c67cb5d0a7d64a44f6ad2668db7e7530311dd53ea79bcd4fb022c64911c8" dependencies = [ "libc", ] @@ -292,6 +353,15 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dcb25d077389e53838a8158c8e99174c5a9d902dee4904320db714f3c653ffba" +[[package]] +name = "crc" +version = "1.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d663548de7f5cca343f1e0a48d14dcfb0e9eb4e079ec58883b7251539fa10aeb" +dependencies = [ + "build_const", +] + [[package]] name = "crc32fast" version = "1.2.1" @@ -301,6 +371,16 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "crossbeam-channel" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06ed27e177f16d65f0f0c22a213e17c696ace5dd64b14258b52f9417ccb52db4" +dependencies = [ + "cfg-if", + "crossbeam-utils", +] + [[package]] name = "crossbeam-epoch" version = "0.9.4" @@ -314,6 +394,16 @@ dependencies = [ "scopeguard", ] +[[package]] +name = "crossbeam-queue" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f6cb3c7f5b8e51bc3ebb73a2327ad4abdbd119dc13223f14f961d2f38486756" +dependencies = [ + "cfg-if", + "crossbeam-utils", +] + [[package]] name = "crossbeam-utils" version = "0.8.4" @@ -389,11 +479,20 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "212d0f5754cb6769937f4501cc0e67f4f4483c8d2c3e1e922ee9edbe4ab4c7c0" +[[package]] +name = "dotenv" +version = "0.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77c90badedccf4105eca100756a0b1289e191f6fcbdadd3cee1d2f614f97da8f" + [[package]] name = "either" version = "1.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e78d4f1cc4ae33bbfc157ed5d5a5ef3bc29227303d595861deb238fcec4e9457" +dependencies = [ + "serde", +] [[package]] name = "encoding_rs" @@ -405,17 +504,16 @@ dependencies = [ ] [[package]] -name = "env_logger" -version = "0.8.3" +name = "fallible-iterator" +version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "17392a012ea30ef05a610aa97dfb49496e71c9f676b27879922ea5bdf60d9d3f" -dependencies = [ - "atty", - "humantime", - "log", - "regex", - "termcolor", -] +checksum = "4443176a9f2c162692bd3d352d745ef9413eec5782a80d8fd6f8a1ac692a07f7" + +[[package]] +name = "fallible-streaming-iterator" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a" [[package]] name = "fnv" @@ -458,6 +556,12 @@ dependencies = [ "winapi", ] +[[package]] +name = "funty" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fed34cd105917e91daa4da6b3728c47b068749d6a62c59811f06ed2ac71d9da7" + [[package]] name = "futf" version = "0.1.4" @@ -671,6 +775,27 @@ name = "hashbrown" version = "0.9.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d7afe4a420e3fe79967a00898cc1f4db7c8a49a9333a29f8a4bd76a253d5cd04" +dependencies = [ + "ahash 0.4.7", +] + +[[package]] +name = "hashlink" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d99cf782f0dc4372d26846bec3de7804ceb5df083c2d4462c0b8d2330e894fa8" +dependencies = [ + "hashbrown", +] + +[[package]] +name = "heck" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "87cbf45460356b7deeb5e3415b5563308c0a9b057c85e12b06ad551f98d0a6ac" +dependencies = [ + "unicode-segmentation", +] [[package]] name = "hermit-abi" @@ -681,6 +806,12 @@ dependencies = [ "libc", ] +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + [[package]] name = "hmac" version = "0.10.1" @@ -750,12 +881,6 @@ version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "05842d0d43232b23ccb7060ecb0f0626922c21f30012e97b767b30afd4a5d4b9" -[[package]] -name = "humantime" -version = "2.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9a3a5bfb195931eeb336b2a7b4d761daec841b97f947d34394601737a7bba5e4" - [[package]] name = "hyper" version = "0.14.7" @@ -901,6 +1026,17 @@ version = "0.2.94" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "18794a8ad5b29321f790b55d93dfba91e125cb1a9edbd4f8e3150acc771c1a5e" +[[package]] +name = "libsqlite3-sys" +version = "0.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "64d31059f22935e6c31830db5249ba2b7ecd54fd73a9909286f0a67aa55c2fbd" +dependencies = [ + "cc", + "pkg-config", + "vcpkg", +] + [[package]] name = "lock_api" version = "0.4.4" @@ -957,6 +1093,15 @@ dependencies = [ "xml5ever", ] +[[package]] +name = "matchers" +version = "0.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f099785f7595cc4b4553a174ce30dd7589ef93391ff414dbb67f62392b9e0ce1" +dependencies = [ + "regex-automata", +] + [[package]] name = "matches" version = "0.1.8" @@ -966,7 +1111,7 @@ checksum = "7ffc5c5338469d4d3ea17d269fa8ea3512ad247247c30bd2df69e68309ed0a08" [[package]] name = "matrix-sdk" version = "0.2.0" -source = "git+https://github.com/matrix-org/matrix-rust-sdk?branch=master#0bdcc0fbf90286b7555fda48403151f02dde6717" +source = "git+https://github.com/matrix-org/matrix-rust-sdk?branch=master#cd77441d1bf8318ecf4cc9e1886696558ea6ed7b" dependencies = [ "backoff", "bytes", @@ -990,7 +1135,7 @@ dependencies = [ [[package]] name = "matrix-sdk-base" version = "0.2.0" -source = "git+https://github.com/matrix-org/matrix-rust-sdk?branch=master#0bdcc0fbf90286b7555fda48403151f02dde6717" +source = "git+https://github.com/matrix-org/matrix-rust-sdk?branch=master#cd77441d1bf8318ecf4cc9e1886696558ea6ed7b" dependencies = [ "chacha20poly1305", "dashmap", @@ -1013,7 +1158,7 @@ dependencies = [ [[package]] name = "matrix-sdk-common" version = "0.2.0" -source = "git+https://github.com/matrix-org/matrix-rust-sdk?branch=master#0bdcc0fbf90286b7555fda48403151f02dde6717" +source = "git+https://github.com/matrix-org/matrix-rust-sdk?branch=master#cd77441d1bf8318ecf4cc9e1886696558ea6ed7b" dependencies = [ "async-trait", "futures", @@ -1029,7 +1174,7 @@ dependencies = [ [[package]] name = "matrix-sdk-crypto" version = "0.2.0" -source = "git+https://github.com/matrix-org/matrix-rust-sdk?branch=master#0bdcc0fbf90286b7555fda48403151f02dde6717" +source = "git+https://github.com/matrix-org/matrix-rust-sdk?branch=master#cd77441d1bf8318ecf4cc9e1886696558ea6ed7b" dependencies = [ "aes-ctr", "aes-gcm", @@ -1136,6 +1281,19 @@ dependencies = [ "version_check", ] +[[package]] +name = "nom" +version = "6.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7413f999671bd4745a7b624bd370a569fb6bc574b23c83a3c5ed2e453f3d5e2" +dependencies = [ + "bitvec", + "funty", + "lexical-core", + "memchr", + "version_check", +] + [[package]] name = "ntapi" version = "0.3.6" @@ -1145,6 +1303,25 @@ dependencies = [ "winapi", ] +[[package]] +name = "num-integer" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2cc698a63b549a70bc047073d2949cce27cd1c7b0a4a862d08a8031bc2801db" +dependencies = [ + "autocfg", + "num-traits", +] + +[[package]] +name = "num-traits" +version = "0.2.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a64b1ec5cda2586e284722486d802acf1f7dbdc623e2bfc57e65ca1cd099290" +dependencies = [ + "autocfg", +] + [[package]] name = "num_cpus" version = "1.13.0" @@ -1433,6 +1610,12 @@ dependencies = [ "proc-macro2", ] +[[package]] +name = "radium" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "941ba9d78d8e2f7ce474c015eea4d9c6d25b6a3327f9832ee29a4de27f91bbb8" + [[package]] name = "rand" version = "0.7.3" @@ -1543,6 +1726,50 @@ dependencies = [ "redox_syscall", ] +[[package]] +name = "refinery" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e29bd9c881127d714f4b5b9fdd9ea7651f3dd254922e959a10f6ada620e841da" +dependencies = [ + "refinery-core", + "refinery-macros", +] + +[[package]] +name = "refinery-core" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53260bc01535ea10c553ce0fc410609ba2dc0a9f4c9b4503e0af842dd4a6f89d" +dependencies = [ + "async-trait", + "cfg-if", + "chrono", + "lazy_static", + "log", + "regex", + "rusqlite", + "serde", + "siphasher", + "thiserror", + "toml", + "url", + "walkdir", +] + +[[package]] +name = "refinery-macros" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a79ff62c9b674b62c06a09cc8becf06cbafba9952afa1d8174e7e15f2c4ed43" +dependencies = [ + "proc-macro2", + "quote", + "refinery-core", + "regex", + "syn", +] + [[package]] name = "regex" version = "1.5.4" @@ -1554,6 +1781,16 @@ dependencies = [ "regex-syntax", ] +[[package]] +name = "regex-automata" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae1ded71d66a4a97f5e961fd0cb25a5f366a42a41570d16a763a69c092c26ae4" +dependencies = [ + "byteorder", + "regex-syntax", +] + [[package]] name = "regex-syntax" version = "0.6.25" @@ -1605,8 +1842,9 @@ dependencies = [ [[package]] name = "ruma" -version = "0.0.3" -source = "git+https://github.com/ruma/ruma?rev=3bdead1cf207e3ab9c8fcbfc454c054c726ba6f5#3bdead1cf207e3ab9c8fcbfc454c054c726ba6f5" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b4014a3def1ed10e7fa0fe28fec48418f2deb6135e7f5fe15901ba1302b581c" dependencies = [ "assign", "js_int", @@ -1621,8 +1859,9 @@ dependencies = [ [[package]] name = "ruma-api" -version = "0.17.0-alpha.4" -source = "git+https://github.com/ruma/ruma?rev=3bdead1cf207e3ab9c8fcbfc454c054c726ba6f5#3bdead1cf207e3ab9c8fcbfc454c054c726ba6f5" +version = "0.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "51df85b3e2c4097abc60919864502083def5c3b12982b0c46f6431e5b1e1476d" dependencies = [ "bytes", "http", @@ -1637,8 +1876,9 @@ dependencies = [ [[package]] name = "ruma-api-macros" -version = "0.17.0-alpha.4" -source = "git+https://github.com/ruma/ruma?rev=3bdead1cf207e3ab9c8fcbfc454c054c726ba6f5#3bdead1cf207e3ab9c8fcbfc454c054c726ba6f5" +version = "0.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe79932728de6a753163f4f30acfd70ebe4355c35fc638edb3f47c7cf47ab128" dependencies = [ "proc-macro-crate", "proc-macro2", @@ -1648,8 +1888,9 @@ dependencies = [ [[package]] name = "ruma-client-api" -version = "0.10.0-alpha.3" -source = "git+https://github.com/ruma/ruma?rev=3bdead1cf207e3ab9c8fcbfc454c054c726ba6f5#3bdead1cf207e3ab9c8fcbfc454c054c726ba6f5" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fda53002660ebdf6eca81102bf4362d0f9234a4d3f7a3e5f9878ed7c1f133843" dependencies = [ "assign", "bytes", @@ -1668,8 +1909,9 @@ dependencies = [ [[package]] name = "ruma-common" -version = "0.5.0" -source = "git+https://github.com/ruma/ruma?rev=3bdead1cf207e3ab9c8fcbfc454c054c726ba6f5#3bdead1cf207e3ab9c8fcbfc454c054c726ba6f5" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c87e1a95f3625cae17772e98b3479059e7aa18593fb623bcff498d32d399d956" dependencies = [ "indexmap", "js_int", @@ -1683,8 +1925,9 @@ dependencies = [ [[package]] name = "ruma-events" -version = "0.22.0-alpha.3" -source = "git+https://github.com/ruma/ruma?rev=3bdead1cf207e3ab9c8fcbfc454c054c726ba6f5#3bdead1cf207e3ab9c8fcbfc454c054c726ba6f5" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "15c222b0f008461b00b039e76319c3184633346605b8201b24f54a6be99cbd98" dependencies = [ "indoc", "js_int", @@ -1698,8 +1941,9 @@ dependencies = [ [[package]] name = "ruma-events-macros" -version = "0.22.0-alpha.3" -source = "git+https://github.com/ruma/ruma?rev=3bdead1cf207e3ab9c8fcbfc454c054c726ba6f5#3bdead1cf207e3ab9c8fcbfc454c054c726ba6f5" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "270df7148218b4d45d11bc1621c4ce5da8571abaf2db3f407a4b0f983a48625c" dependencies = [ "proc-macro-crate", "proc-macro2", @@ -1709,8 +1953,9 @@ dependencies = [ [[package]] name = "ruma-federation-api" -version = "0.1.0-alpha.2" -source = "git+https://github.com/ruma/ruma?rev=3bdead1cf207e3ab9c8fcbfc454c054c726ba6f5#3bdead1cf207e3ab9c8fcbfc454c054c726ba6f5" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40fe42fd5951c688b5c7b0c32ed74d973d21c33ba31f9d64d40bf4f98c01eb48" dependencies = [ "js_int", "ruma-api", @@ -1724,8 +1969,9 @@ dependencies = [ [[package]] name = "ruma-identifiers" -version = "0.19.0" -source = "git+https://github.com/ruma/ruma?rev=3bdead1cf207e3ab9c8fcbfc454c054c726ba6f5#3bdead1cf207e3ab9c8fcbfc454c054c726ba6f5" +version = "0.19.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b63396866eab1d0dafe5bd3c1af2e16f1f71a725fcc89a3e3f42572bc20c65a" dependencies = [ "paste", "ruma-identifiers-macros", @@ -1737,8 +1983,9 @@ dependencies = [ [[package]] name = "ruma-identifiers-macros" -version = "0.19.0" -source = "git+https://github.com/ruma/ruma?rev=3bdead1cf207e3ab9c8fcbfc454c054c726ba6f5#3bdead1cf207e3ab9c8fcbfc454c054c726ba6f5" +version = "0.19.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a77e9bf8fd334e944d40f041155d4950da75a9b64cd05046ffc8ea670ad4ad12" dependencies = [ "quote", "ruma-identifiers-validation", @@ -1748,12 +1995,14 @@ dependencies = [ [[package]] name = "ruma-identifiers-validation" version = "0.3.0" -source = "git+https://github.com/ruma/ruma?rev=3bdead1cf207e3ab9c8fcbfc454c054c726ba6f5#3bdead1cf207e3ab9c8fcbfc454c054c726ba6f5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ecb664a32ba1cccf0d5cec34bef6bfccc042b54b8b5f9610729a128fcdf569a5" [[package]] name = "ruma-serde" -version = "0.3.1" -source = "git+https://github.com/ruma/ruma?rev=3bdead1cf207e3ab9c8fcbfc454c054c726ba6f5#3bdead1cf207e3ab9c8fcbfc454c054c726ba6f5" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c888d69db1d5f134cbce7101f5610f1802966f359646ff48dbaf0f433d7bbd9f" dependencies = [ "bytes", "form_urlencoded", @@ -1766,8 +2015,9 @@ dependencies = [ [[package]] name = "ruma-serde-macros" -version = "0.3.1" -source = "git+https://github.com/ruma/ruma?rev=3bdead1cf207e3ab9c8fcbfc454c054c726ba6f5#3bdead1cf207e3ab9c8fcbfc454c054c726ba6f5" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be418f3ee1593ebf1522d9ace1b1de0455bbcdd69c5d584c00d41e7717f8d0af" dependencies = [ "proc-macro-crate", "proc-macro2", @@ -1775,6 +2025,21 @@ dependencies = [ "syn", ] +[[package]] +name = "rusqlite" +version = "0.24.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d5f38ee71cbab2c827ec0ac24e76f82eca723cee92c509a65f67dee393c25112" +dependencies = [ + "bitflags", + "fallible-iterator", + "fallible-streaming-iterator", + "hashlink", + "libsqlite3-sys", + "memchr", + "smallvec", +] + [[package]] name = "rustc_version" version = "0.2.3" @@ -1790,6 +2055,15 @@ version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "71d301d4193d031abdd79ff7e3dd721168a9572ef3fe51a1517aba235bd8f86e" +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + [[package]] name = "schannel" version = "0.1.19" @@ -1876,6 +2150,7 @@ version = "1.0.64" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "799e97dc9fdae36a5c8b8f2cae9ce2ee9fdce2058c57a93e6099d919fd982f79" dependencies = [ + "indexmap", "itoa", "ryu", "serde", @@ -1912,6 +2187,15 @@ dependencies = [ "opaque-debug", ] +[[package]] +name = "sharded-slab" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "79c719719ee05df97490f80a45acfc99e5a30ce98a1e4fb67aee422745ae14e3" +dependencies = [ + "lazy_static", +] + [[package]] name = "signal-hook-registry" version = "1.3.0" @@ -1965,6 +2249,105 @@ dependencies = [ "winapi", ] +[[package]] +name = "sqlformat" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d86e3c77ff882a828346ba401a7ef4b8e440df804491c6064fe8295765de71c" +dependencies = [ + "lazy_static", + "maplit", + "nom 6.1.2", + "regex", + "unicode_categories", +] + +[[package]] +name = "sqlx" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2739d54a2ae9fdd0f545cb4e4b5574efb95e2ec71b7f921678e246fb20dcaaf" +dependencies = [ + "sqlx-core", + "sqlx-macros", +] + +[[package]] +name = "sqlx-core" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1cad9cae4ca8947eba1a90e8ec7d3c59e7a768e2f120dc9013b669c34a90711" +dependencies = [ + "ahash 0.6.3", + "atoi", + "bitflags", + "byteorder", + "bytes", + "crc", + "crossbeam-channel", + "crossbeam-queue", + "crossbeam-utils", + "either", + "futures-channel", + "futures-core", + "futures-util", + "hashlink", + "hex", + "itoa", + "libc", + "libsqlite3-sys", + "log", + "memchr", + "once_cell", + "parking_lot", + "percent-encoding", + "serde", + "sha2", + "smallvec", + "sqlformat", + "sqlx-rt", + "stringprep", + "thiserror", + "tokio-stream", + "url", + "whoami", +] + +[[package]] +name = "sqlx-macros" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "01caee2b3935b4efe152f3262afbe51546ce3b1fc27ad61014e1b3cf5f55366e" +dependencies = [ + "dotenv", + "either", + "futures", + "heck", + "hex", + "once_cell", + "proc-macro2", + "quote", + "serde", + "serde_json", + "sha2", + "sqlx-core", + "sqlx-rt", + "syn", + "url", +] + +[[package]] +name = "sqlx-rt" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ce2e16b6774c671cc183e1d202386fdf9cde1e8468c1894a7f2a63eb671c4f4" +dependencies = [ + "native-tls", + "once_cell", + "tokio", + "tokio-native-tls", +] + [[package]] name = "standback" version = "0.2.17" @@ -2054,6 +2437,16 @@ dependencies = [ "quote", ] +[[package]] +name = "stringprep" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ee348cb74b87454fff4b551cbf727025810a004f88aeacae7f85b87f4e9a1c1" +dependencies = [ + "unicode-bidi", + "unicode-normalization", +] + [[package]] name = "subtle" version = "2.4.0" @@ -2083,6 +2476,12 @@ dependencies = [ "unicode-xid", ] +[[package]] +name = "tap" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55937e1799185b12863d447f42597ed69d9928686b8d88a1df17376a097d8369" + [[package]] name = "tempfile" version = "3.2.0" @@ -2113,11 +2512,11 @@ name = "tenebrous-dicebot" version = "0.10.0" dependencies = [ "async-trait", + "barrel", "bincode", "byteorder", "combine", "dirs", - "env_logger", "futures", "html2text", "indoc", @@ -2125,27 +2524,22 @@ dependencies = [ "log", "matrix-sdk", "memmem", - "nom", + "nom 5.1.2", "phf", "rand 0.8.3", + "refinery", "serde", "sled", + "sqlx", + "tempfile", "thiserror", "tokio", "toml", + "tracing-subscriber", "url", "zerocopy", ] -[[package]] -name = "termcolor" -version = "1.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2dfed899f0eb03f32ee8c6a0aabdb8a7949659e3466561fc0adf54e26d88c5f4" -dependencies = [ - "winapi-util", -] - [[package]] name = "thiserror" version = "1.0.24" @@ -2166,6 +2560,15 @@ dependencies = [ "syn", ] +[[package]] +name = "thread_local" +version = "1.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8018d24e04c95ac8790716a5987d0fec4f8b27249ffa0f7d33f1369bdfb88cbd" +dependencies = [ + "once_cell", +] + [[package]] name = "time" version = "0.1.43" @@ -2270,6 +2673,17 @@ dependencies = [ "tokio", ] +[[package]] +name = "tokio-stream" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8864d706fdb3cc0843a49647ac892720dac98a6eeb818b77190592cf4994066" +dependencies = [ + "futures-core", + "pin-project-lite", + "tokio", +] + [[package]] name = "tokio-util" version = "0.6.7" @@ -2341,6 +2755,49 @@ dependencies = [ "tracing", ] +[[package]] +name = "tracing-log" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6923477a48e41c1951f1999ef8bb5a3023eb723ceadafe78ffb65dc366761e3" +dependencies = [ + "lazy_static", + "log", + "tracing-core", +] + +[[package]] +name = "tracing-serde" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fb65ea441fbb84f9f6748fd496cf7f63ec9af5bca94dd86456978d055e8eb28b" +dependencies = [ + "serde", + "tracing-core", +] + +[[package]] +name = "tracing-subscriber" +version = "0.2.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa5553bf0883ba7c9cbe493b085c29926bd41b66afc31ff72cf17ff4fb60dcd5" +dependencies = [ + "ansi_term", + "chrono", + "lazy_static", + "matchers", + "regex", + "serde", + "serde_json", + "sharded-slab", + "smallvec", + "thread_local", + "tracing", + "tracing-core", + "tracing-log", + "tracing-serde", +] + [[package]] name = "try-lock" version = "0.2.3" @@ -2371,6 +2828,12 @@ dependencies = [ "tinyvec", ] +[[package]] +name = "unicode-segmentation" +version = "1.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb0d2e7be6ae3a5fa87eed5fb451aff96f2573d2694942e40543ae0bbe19c796" + [[package]] name = "unicode-width" version = "0.1.8" @@ -2383,6 +2846,12 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8ccb82d61f80a663efe1f787a51b16b5a51e3314d6ac365b08639f52387b33f3" +[[package]] +name = "unicode_categories" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39ec24b3121d976906ece63c9daad25b85969647682eee313cb5779fdd69e14e" + [[package]] name = "unindent" version = "0.1.7" @@ -2439,6 +2908,17 @@ version = "0.9.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5fecdca9a5291cc2b8dcf7dc02453fee791a280f3743cb0905f8822ae463b3fe" +[[package]] +name = "walkdir" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "808cf2735cd4b6866113f648b791c6adc5714537bc222d9347bb203386ffda56" +dependencies = [ + "same-file", + "winapi", + "winapi-util", +] + [[package]] name = "want" version = "0.3.0" @@ -2539,6 +3019,16 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "whoami" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4abacf325c958dfeaf1046931d37f2a901b6dfe0968ee965a29e94c6766b2af6" +dependencies = [ + "wasm-bindgen", + "web-sys", +] + [[package]] name = "wildmatch" version = "2.1.0" @@ -2585,6 +3075,12 @@ dependencies = [ "winapi", ] +[[package]] +name = "wyz" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85e60b0d1b5f99db2556934e21937020776a5d31520bf169e851ac44e6420214" + [[package]] name = "xml5ever" version = "0.16.1" diff --git a/Cargo.toml b/Cargo.toml index ec26237..b166367 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -10,9 +10,13 @@ repository = 'https://git.agnos.is/projectmoon/matrix-dicebot' keywords = ["games", "dice", "matrix", "bot"] categories = ["games"] +[[bin]] +name = "dicebot-migrate" +path = "src/migrate_cli.rs" + [dependencies] log = "0.4" -env_logger = "0.8" +tracing-subscriber = "0.2" toml = "0.5" nom = "5" rand = "0.8" @@ -32,6 +36,13 @@ bincode = "1.3" html2text = "0.2" phf = { version = "0.8", features = ["macros"] } matrix-sdk = { git = "https://github.com/matrix-org/matrix-rust-sdk", branch = "master" } +refinery = { version = "0.5", features = ["rusqlite"]} +barrel = { version = "0.6", features = ["sqlite3"] } +tempfile = "3" + +[dependencies.sqlx] +version = "0.5" +features = [ "offline", "sqlite", "runtime-tokio-native-tls" ] [dependencies.serde] version = "1" diff --git a/sqlx-data.json b/sqlx-data.json new file mode 100644 index 0000000..9edc662 --- /dev/null +++ b/sqlx-data.json @@ -0,0 +1,159 @@ +{ + "db": "SQLite", + "19d89370cac05c1bc4de0eb3508712da9ca133b1cf9445b5407d238f89c3ab0c": { + "query": "SELECT device_id FROM bot_state limit 1", + "describe": { + "columns": [ + { + "name": "device_id", + "ordinal": 0, + "type_info": "Text" + } + ], + "parameters": { + "Right": 0 + }, + "nullable": [ + false + ] + } + }, + "59313c67900a1a9399389720b522e572f181ae503559cd2b49d6305acb9e2207": { + "query": "SELECT key, value as \"value: i32\" FROM user_variables\n WHERE room_id = ? AND user_id = ?", + "describe": { + "columns": [ + { + "name": "key", + "ordinal": 0, + "type_info": "Text" + }, + { + "name": "value: i32", + "ordinal": 1, + "type_info": "Int64" + } + ], + "parameters": { + "Right": 2 + }, + "nullable": [ + false, + false + ] + } + }, + "636b1b868eaf04cd234fbf17747d94a66e81f7bc1b060ba14151dbfaf40eeefc": { + "query": "SELECT value as \"value: i32\" FROM user_variables\n WHERE user_id = ? AND room_id = ? AND key = ?", + "describe": { + "columns": [ + { + "name": "value: i32", + "ordinal": 0, + "type_info": "Int64" + } + ], + "parameters": { + "Right": 3 + }, + "nullable": [ + false + ] + } + }, + "711d222911c1258365a6a0de1fe00eeec4686fd3589e976e225ad599e7cfc75d": { + "query": "SELECT count(*) as \"count: i32\" FROM user_variables\n WHERE room_id = ? and user_id = ?", + "describe": { + "columns": [ + { + "name": "count: i32", + "ordinal": 0, + "type_info": "Int" + } + ], + "parameters": { + "Right": 2 + }, + "nullable": [ + false + ] + } + }, + "7248c8ae30bbe4bc5866e80cc277312c7f8cb9af5a8801fd8eaf178fd99eae18": { + "query": "SELECT room_id FROM room_users\n WHERE username = ?", + "describe": { + "columns": [ + { + "name": "room_id", + "ordinal": 0, + "type_info": "Text" + } + ], + "parameters": { + "Right": 1 + }, + "nullable": [ + false + ] + } + }, + "97f5d58f62baca51efd8c295ca6737d1240923c69c973621cd0a718ac9eed99f": { + "query": "SELECT room_id, room_name FROM room_info\n WHERE room_id = ?", + "describe": { + "columns": [ + { + "name": "room_id", + "ordinal": 0, + "type_info": "Text" + }, + { + "name": "room_name", + "ordinal": 1, + "type_info": "Text" + } + ], + "parameters": { + "Right": 1 + }, + "nullable": [ + false, + false + ] + } + }, + "b302d586e5ac4c72c2970361ea5a5936c0b8c6dad10033c626a0ce0404cadb25": { + "query": "SELECT username FROM room_users\n WHERE room_id = ?", + "describe": { + "columns": [ + { + "name": "username", + "ordinal": 0, + "type_info": "Text" + } + ], + "parameters": { + "Right": 1 + }, + "nullable": [ + false + ] + } + }, + "bba0fc255e7c30d1d2d9468c68ba38db6e8a13be035aa1152933ba9247b14f8c": { + "query": "SELECT event_id FROM room_events\n WHERE room_id = ? AND event_id = ?", + "describe": { + "columns": [ + { + "name": "event_id", + "ordinal": 0, + "type_info": "Text" + } + ], + "parameters": { + "Right": 2 + }, + "nullable": [ + false + ] + } + } +} \ No newline at end of file diff --git a/src/bin/dicebot-cmd.rs b/src/bin/dicebot-cmd.rs index 79ce2b9..dc38d71 100644 --- a/src/bin/dicebot-cmd.rs +++ b/src/bin/dicebot-cmd.rs @@ -2,7 +2,7 @@ use matrix_sdk::identifiers::room_id; use tenebrous_dicebot::commands; use tenebrous_dicebot::commands::ResponseExtractor; use tenebrous_dicebot::context::{Context, RoomContext}; -use tenebrous_dicebot::db::Database; +use tenebrous_dicebot::db::sqlite::Database; use tenebrous_dicebot::error::BotError; use url::Url; @@ -15,9 +15,17 @@ async fn main() -> Result<(), BotError> { }; let homeserver = Url::parse("http://example.com")?; + let db_path = tempfile::NamedTempFile::new_in(".").unwrap(); + let db = Database::new( + db_path + .path() + .to_str() + .expect("Could not get path to temporary db"), + ) + .await?; let context = Context { - db: Database::new_temp()?, + db: db, matrix_client: &matrix_sdk::Client::new(homeserver) .expect("Could not create matrix client"), room: RoomContext { diff --git a/src/bin/dicebot.rs b/src/bin/dicebot.rs index 1f4ff5c..a5abbb1 100644 --- a/src/bin/dicebot.rs +++ b/src/bin/dicebot.rs @@ -1,20 +1,25 @@ //Needed for nested Result handling from tokio. Probably can go away after 1.47.0. #![type_length_limit = "7605144"] -use env_logger::Env; use log::error; +use std::env; use std::sync::{Arc, RwLock}; use tenebrous_dicebot::bot::DiceBot; use tenebrous_dicebot::config::*; -use tenebrous_dicebot::db::Database; +use tenebrous_dicebot::db::sqlite::Database; use tenebrous_dicebot::error::BotError; use tenebrous_dicebot::state::DiceBotState; +use tracing_subscriber::filter::EnvFilter; #[tokio::main] async fn main() { - env_logger::Builder::from_env( - Env::default().default_filter_or("tenebrous_dicebot=info,dicebot=info"), - ) - .init(); + let filter = if env::var("RUST_LOG").is_ok() { + EnvFilter::from_default_env() + } else { + EnvFilter::new("tenebrous_dicebot=info,dicebot=info,refinery=info") + }; + + tracing_subscriber::fmt().with_env_filter(filter).init(); + match run().await { Ok(_) => (), Err(e) => error!("Error: {}", e), @@ -28,11 +33,10 @@ async fn run() -> Result<(), BotError> { .expect("Need a config as an argument"); let cfg = Arc::new(read_config(config_path)?); - let db = Database::new(&cfg.database_path())?; + let sqlite_path = format!("{}/dicebot.sqlite", cfg.database_path()); + let db = Database::new(&sqlite_path).await?; let state = Arc::new(RwLock::new(DiceBotState::new(&cfg))); - db.migrate(cfg.migration_version())?; - match DiceBot::new(&cfg, &state, &db) { Ok(bot) => bot.run().await?, Err(e) => println!("Error connecting: {:?}", e), diff --git a/src/bin/migrate_sled.rs b/src/bin/migrate_sled.rs new file mode 100644 index 0000000..0e7be05 --- /dev/null +++ b/src/bin/migrate_sled.rs @@ -0,0 +1,37 @@ +use tenebrous_dicebot::db::sqlite::{Database as SqliteDatabase, Variables}; +use tenebrous_dicebot::db::Database; +use tenebrous_dicebot::error::BotError; + +#[tokio::main] +async fn main() -> Result<(), BotError> { + let sled_path = std::env::args() + .skip(1) + .next() + .expect("Need a path to a Sled database as an arument."); + + let sqlite_path = std::env::args() + .skip(2) + .next() + .expect("Need a path to an sqlite database as an arument."); + + let db = Database::new(&sled_path)?; + + let all_variables = db.variables.get_all_variables()?; + + let sql_db = SqliteDatabase::new(&sqlite_path).await?; + + for var in all_variables { + if let ((username, room_id, variable_name), value) = var { + println!( + "Migrating {}::{}::{} = {} to sql", + username, room_id, variable_name, value + ); + + sql_db + .set_user_variable(&username, &room_id, &variable_name, value) + .await; + } + } + + Ok(()) +} diff --git a/src/bot.rs b/src/bot.rs index b3d8531..58b9b20 100644 --- a/src/bot.rs +++ b/src/bot.rs @@ -1,13 +1,14 @@ use crate::commands::{execute_command, ExecutionError, ExecutionResult, ResponseExtractor}; use crate::config::*; use crate::context::{Context, RoomContext}; -use crate::db::Database; +use crate::db::sqlite::Database; +use crate::db::sqlite::DbState; use crate::error::BotError; use crate::matrix; use crate::state::DiceBotState; use dirs; use futures::stream::{self, StreamExt}; -use log::info; +use log::{error, info}; use matrix_sdk::{self, identifiers::EventId, room::Joined, Client, ClientConfig, SyncSettings}; use std::clone::Clone; use std::path::PathBuf; @@ -61,6 +62,13 @@ async fn handle_single_result( room: &Joined, event_id: EventId, ) { + if cmd_result.is_err() { + error!( + "Command execution error: {}", + cmd_result.as_ref().err().unwrap() + ); + } + let html = cmd_result.message_html(respond_to); matrix::send_message(client, room.room_id(), &html, Some(event_id)).await; } @@ -86,6 +94,10 @@ async fn handle_multiple_results( }) .collect(); + for result in errors.iter() { + error!("Command execution error: '{}' - {}", result.0, result.1); + } + let message = if errors.len() == 0 { format!("{}: Executed {} commands", respond_to, results.len()) } else { @@ -134,7 +146,7 @@ impl DiceBot { // Pull device ID from database, if it exists. Then write it // to DB if the library generated one for us. - let device_id: Option = self.db.state.get_device_id()?; + let device_id: Option = self.db.get_device_id().await?; let device_id: Option<&str> = device_id.as_deref(); client @@ -143,7 +155,7 @@ impl DiceBot { if device_id.is_none() { let device_id = client.device_id().await.ok_or(BotError::NoDeviceIdFound)?; - self.db.state.set_device_id(device_id.as_str())?; + self.db.set_device_id(device_id.as_str()).await?; info!("Recorded new device ID: {}", device_id.as_str()); } else { info!("Using existing device ID: {}", device_id.unwrap()); @@ -160,25 +172,12 @@ impl DiceBot { let client = self.client.clone(); self.login(&client).await?; - // Initial sync without event handler prevents responding to - // messages received while bot was offline. TODO: selectively - // respond to old messages? e.g. comands missed while offline. - info!("Performing intial sync (no commands will be responded to)"); - self.client.sync_once(SyncSettings::default()).await?; - client.set_event_handler(Box::new(self)).await; info!("Listening for commands"); - let token = client - .sync_token() - .await - .ok_or(BotError::SyncTokenRequired)?; - - let settings = SyncSettings::default().token(token); - // TODO replace with sync_with_callback for cleaner shutdown // process. - client.sync(settings).await; + client.sync(SyncSettings::default()).await; Ok(()) } diff --git a/src/bot/event_handlers.rs b/src/bot/event_handlers.rs index 98f4072..7ffa293 100644 --- a/src/bot/event_handlers.rs +++ b/src/bot/event_handlers.rs @@ -4,7 +4,8 @@ * SDK example code. */ use super::DiceBot; -use crate::db::Database; +use crate::db::sqlite::Database; +use crate::db::sqlite::Rooms; use crate::error::BotError; use crate::logic::record_room_information; use async_trait::async_trait; @@ -19,9 +20,9 @@ use matrix_sdk::{ room::Room, EventHandler, }; -use std::clone::Clone; use std::ops::Sub; use std::time::{Duration, SystemTime}; +use std::{clone::Clone, time::UNIX_EPOCH}; /// Check if a message is recent enough to actually process. If the /// message is within "oldest_message_age" seconds, this function @@ -31,7 +32,11 @@ fn check_message_age( event: &SyncMessageEvent, oldest_message_age: u64, ) -> bool { - let sending_time = event.origin_server_ts; + let sending_time = event + .origin_server_ts + .to_system_time() + .unwrap_or(UNIX_EPOCH); + let oldest_timestamp = SystemTime::now().sub(Duration::from_secs(oldest_message_age)); if sending_time > oldest_timestamp { @@ -90,9 +95,9 @@ async fn should_process_message<'a>( Ok((msg_body, sender_username)) } -fn should_process_event(db: &Database, room_id: &str, event_id: &str) -> bool { - db.rooms - .should_process(room_id, event_id) +async fn should_process_event(db: &Database, room_id: &str, event_id: &str) -> bool { + db.should_process(room_id, event_id) + .await .unwrap_or_else(|e| { error!( "Database error when checking if we should process an event: {}", @@ -116,7 +121,7 @@ impl EventHandler for DiceBot { let room_id_str = room_id.as_str(); let username = &event.state_key; - if !should_process_event(&self.db, room_id_str, event.event_id.as_str()) { + if !should_process_event(&self.db, room_id_str, event.event_id.as_str()).await { return; } @@ -126,17 +131,20 @@ impl EventHandler for DiceBot { false }; + // user_joing is true if a user is joining this room, and + // false if they have left for some reason. This user may be + // us, or another user in the room. use MembershipChange::*; - let adding_user = match event.membership_change() { + let user_joining = match event.membership_change() { Joined => true, Banned | Left | Kicked | KickedAndBanned => false, _ => return, }; - let result = if event_affects_us && !adding_user { + let result = if event_affects_us && !user_joining { info!("Clearing all information for room ID {}", room_id); - self.db.rooms.clear_info(room_id_str) - } else if event_affects_us && adding_user { + self.db.clear_info(room_id_str).await.map_err(|e| e.into()) + } else if event_affects_us && user_joining { info!("Joined room {}; recording room information", room_id); record_room_information( &self.client, @@ -146,12 +154,18 @@ impl EventHandler for DiceBot { &event.state_key, ) .await - } else if !event_affects_us && adding_user { + } else if !event_affects_us && user_joining { info!("Adding user {} to room ID {}", username, room_id); - self.db.rooms.add_user_to_room(username, room_id_str) - } else if !event_affects_us && !adding_user { + self.db + .add_user_to_room(username, room_id_str) + .await + .map_err(|e| e.into()) + } else if !event_affects_us && !user_joining { info!("Removing user {} from room ID {}", username, room_id); - self.db.rooms.remove_user_from_room(username, room_id_str) + self.db + .remove_user_from_room(username, room_id_str) + .await + .map_err(|e| e.into()) } else { debug!("Ignoring a room member event: {:#?}", event); Ok(()) @@ -196,7 +210,7 @@ impl EventHandler for DiceBot { }; let room_id = room.room_id().as_str(); - if !should_process_event(&self.db, room_id, event.event_id.as_str()) { + if !should_process_event(&self.db, room_id, event.event_id.as_str()).await { return; } diff --git a/src/cofd/dice.rs b/src/cofd/dice.rs index 5cf86d2..aae34b8 100644 --- a/src/cofd/dice.rs +++ b/src/cofd/dice.rs @@ -325,7 +325,8 @@ pub async fn roll_pool(pool: &DicePoolWithContext<'_>) -> Result for ExecutionError { fn from(error: crate::db::errors::DataError) -> Self { - Self(BotError::DataError(error)) + Self(DataError(error)) + } +} + +impl From for ExecutionError { + fn from(error: crate::db::sqlite::errors::DataError) -> Self { + Self(SqliteDataError(error)) } } @@ -129,10 +136,15 @@ mod tests { )); } - #[tokio::test] + #[tokio::test(flavor = "multi_thread", worker_threads = 1)] async fn unrecognized_command() { - let db = crate::db::Database::new_temp().unwrap(); + let db_path = tempfile::NamedTempFile::new_in(".").unwrap(); + let db = crate::db::sqlite::Database::new(db_path.path().to_str().unwrap()) + .await + .unwrap(); + let homeserver = Url::parse("http://example.com").unwrap(); + let ctx = Context { db: db, matrix_client: &matrix_sdk::Client::new(homeserver).unwrap(), diff --git a/src/commands/variables.rs b/src/commands/variables.rs index 326041d..c03ca70 100644 --- a/src/commands/variables.rs +++ b/src/commands/variables.rs @@ -1,6 +1,7 @@ use super::{Command, Execution, ExecutionResult}; use crate::context::Context; -use crate::db::errors::DataError; +use crate::db::sqlite::errors::DataError; +use crate::db::sqlite::Variables; use crate::db::variables::UserAndRoom; use async_trait::async_trait; @@ -13,8 +14,10 @@ impl Command for GetAllVariablesCommand { } async fn execute(&self, ctx: &Context<'_>) -> ExecutionResult { - let key = UserAndRoom(&ctx.username, &ctx.room_id().as_str()); - let variables = ctx.db.variables.get_user_variables(&key)?; + let variables = ctx + .db + .get_user_variables(&ctx.username, ctx.room_id().as_str()) + .await?; let mut variable_list: Vec = variables .into_iter() @@ -43,8 +46,10 @@ impl Command for GetVariableCommand { async fn execute(&self, ctx: &Context<'_>) -> ExecutionResult { let name = &self.0; - let key = UserAndRoom(&ctx.username, &ctx.room_id().as_str()); - let result = ctx.db.variables.get_user_variable(&key, name); + let result = ctx + .db + .get_user_variable(&ctx.username, ctx.room_id().as_str(), name) + .await; let value = match result { Ok(num) => format!("{} = {}", name, num), @@ -68,9 +73,10 @@ impl Command for SetVariableCommand { async fn execute(&self, ctx: &Context<'_>) -> ExecutionResult { let name = &self.0; let value = self.1; - let key = UserAndRoom(&ctx.username, ctx.room_id().as_str()); - ctx.db.variables.set_user_variable(&key, name, value)?; + ctx.db + .set_user_variable(&ctx.username, ctx.room_id().as_str(), name, value) + .await?; let content = format!("{} = {}", name, value); let html = format!("Set Variable: {}", content); @@ -88,8 +94,10 @@ impl Command for DeleteVariableCommand { async fn execute(&self, ctx: &Context<'_>) -> ExecutionResult { let name = &self.0; - let key = UserAndRoom(&ctx.username, ctx.room_id().as_str()); - let result = ctx.db.variables.delete_user_variable(&key, name); + let result = ctx + .db + .delete_user_variable(&ctx.username, ctx.room_id().as_str(), name) + .await; let value = match result { Ok(()) => format!("{} now unset", name), diff --git a/src/context.rs b/src/context.rs index e971b39..04f2b40 100644 --- a/src/context.rs +++ b/src/context.rs @@ -1,4 +1,4 @@ -use crate::db::Database; +use crate::db::sqlite::Database; use matrix_sdk::identifiers::RoomId; use matrix_sdk::room::Joined; use matrix_sdk::Client; diff --git a/src/cthulhu/dice.rs b/src/cthulhu/dice.rs index e13cf84..fffaeaa 100644 --- a/src/cthulhu/dice.rs +++ b/src/cthulhu/dice.rs @@ -1,7 +1,11 @@ +use crate::db::sqlite::Variables; use crate::error::{BotError, DiceRollingError}; use crate::parser::{Amount, Element}; use crate::{context::Context, db::variables::UserAndRoom}; use crate::{dice::calculate_single_die_amount, parser::DiceParsingError}; +use rand::rngs::StdRng; +use rand::Rng; +use rand::SeedableRng; use std::convert::TryFrom; use std::fmt; @@ -270,10 +274,11 @@ macro_rules! is_variable { }; } -///A version of DieRoller that uses a rand::Rng to roll numbers. -struct RngDieRoller(R); +/// A die roller than can have an RNG implementation injected, but +/// must be thread-safe. Required for the async dice rolling code. +struct RngDieRoller(R); -impl DieRoller for RngDieRoller { +impl DieRoller for RngDieRoller { fn roll(&mut self) -> u32 { self.0.gen_range(0..=9) } @@ -361,7 +366,7 @@ pub async fn regular_roll( let target = calculate_single_die_amount(&roll_with_ctx.0.amount, roll_with_ctx.1).await?; let target = u32::try_from(target).map_err(|_| DiceRollingError::InvalidAmount)?; - let mut roller = RngDieRoller(rand::thread_rng()); + let mut roller = RngDieRoller::(SeedableRng::from_entropy()); let rolled_dice = roll_regular_dice(&roll_with_ctx.0.modifier, target, &mut roller); Ok(ExecutedDiceRoll { @@ -371,11 +376,12 @@ pub async fn regular_roll( }) } -fn update_skill(ctx: &Context, variable: &str, value: u32) -> Result<(), BotError> { +async fn update_skill(ctx: &Context<'_>, variable: &str, value: u32) -> Result<(), BotError> { use std::convert::TryInto; let value: i32 = value.try_into()?; - let key = UserAndRoom(ctx.username, ctx.room_id().as_str()); - ctx.db.variables.set_user_variable(&key, variable, value)?; + ctx.db + .set_user_variable(&ctx.username, &ctx.room_id().as_str(), variable, value) + .await?; Ok(()) } @@ -397,12 +403,14 @@ pub async fn advancement_roll( return Err(DiceRollingError::InvalidAmount.into()); } - let mut roller = RngDieRoller(rand::thread_rng()); + let mut roller = RngDieRoller::(SeedableRng::from_entropy()); let roll = roll_advancement_dice(target, &mut roller); + drop(roller); + if roll.successful && is_variable!(existing_skill) { let variable_name: &str = extract_variable(existing_skill)?; - update_skill(roll_with_ctx.1, variable_name, roll.new_skill_amount())?; + update_skill(roll_with_ctx.1, variable_name, roll.new_skill_amount()).await?; } Ok(ExecutedAdvancementRoll { target, roll }) @@ -411,7 +419,7 @@ pub async fn advancement_roll( #[cfg(test)] mod tests { use super::*; - use crate::db::Database; + use crate::db::sqlite::Database; use crate::parser::{Amount, Element, Operator}; use url::Url; @@ -474,7 +482,7 @@ mod tests { assert!(matches!(result, Err(DiceParsingError::WrongElementType))); } - #[tokio::test] + #[tokio::test(flavor = "multi_thread", worker_threads = 1)] async fn regular_roll_rejects_negative_numbers() { let roll = DiceRoll { amount: Amount { @@ -484,7 +492,15 @@ mod tests { modifier: DiceRollModifier::Normal, }; - let db = Database::new_temp().unwrap(); + let db_path = tempfile::NamedTempFile::new_in(".").unwrap(); + crate::db::sqlite::migrator::migrate(db_path.path().to_str().unwrap()) + .await + .unwrap(); + + let db = Database::new(db_path.path().to_str().unwrap()) + .await + .unwrap(); + let homeserver = Url::parse("http://example.com").unwrap(); let ctx = Context { db: db, @@ -503,7 +519,7 @@ mod tests { )); } - #[tokio::test] + #[tokio::test(flavor = "multi_thread", worker_threads = 1)] async fn advancement_roll_rejects_negative_numbers() { let roll = AdvancementRoll { existing_skill: Amount { @@ -512,7 +528,15 @@ mod tests { }, }; - let db = Database::new_temp().unwrap(); + let db_path = tempfile::NamedTempFile::new_in(".").unwrap(); + crate::db::sqlite::migrator::migrate(db_path.path().to_str().unwrap()) + .await + .unwrap(); + + let db = Database::new(db_path.path().to_str().unwrap()) + .await + .unwrap(); + let homeserver = Url::parse("http://example.com").unwrap(); let ctx = Context { db: db, @@ -531,7 +555,7 @@ mod tests { )); } - #[tokio::test] + #[tokio::test(flavor = "multi_thread", worker_threads = 1)] async fn advancement_roll_rejects_big_numbers() { let roll = AdvancementRoll { existing_skill: Amount { @@ -540,7 +564,15 @@ mod tests { }, }; - let db = Database::new_temp().unwrap(); + let db_path = tempfile::NamedTempFile::new_in(".").unwrap(); + crate::db::sqlite::migrator::migrate(db_path.path().to_str().unwrap()) + .await + .unwrap(); + + let db = Database::new(db_path.path().to_str().unwrap()) + .await + .unwrap(); + let homeserver = Url::parse("http://example.com").unwrap(); let ctx = Context { db: db, diff --git a/src/db.rs b/src/db.rs index b50e92a..e714957 100644 --- a/src/db.rs +++ b/src/db.rs @@ -12,13 +12,14 @@ pub mod errors; pub mod migrations; pub mod rooms; pub mod schema; +pub mod sqlite; pub mod state; pub mod variables; #[derive(Clone)] pub struct Database { db: Db, - pub(crate) variables: Variables, + pub variables: Variables, pub(crate) migrations: Migrations, pub(crate) rooms: Rooms, pub(crate) state: DbState, diff --git a/src/db/errors.rs b/src/db/errors.rs index 8b1757f..c776594 100644 --- a/src/db/errors.rs +++ b/src/db/errors.rs @@ -26,6 +26,9 @@ pub enum DataError { #[error("expected i32, but i32 schema was violated")] I32SchemaViolation, + #[error("parse error")] + ParseError(#[from] std::num::ParseIntError), + #[error("unexpected or corruptd data bytes")] InvalidValue, diff --git a/src/db/sqlite/errors.rs b/src/db/sqlite/errors.rs new file mode 100644 index 0000000..2132120 --- /dev/null +++ b/src/db/sqlite/errors.rs @@ -0,0 +1,72 @@ +use std::num::TryFromIntError; + +use sled::transaction::{TransactionError, UnabortableTransactionError}; +use thiserror::Error; + +#[derive(Error, Debug)] +pub enum DataError { + #[error("value does not exist for key: {0}")] + KeyDoesNotExist(String), + + #[error("too many entries")] + TooManyEntries, + + #[error("expected i32, but i32 schema was violated")] + I32SchemaViolation, + + #[error("unexpected or corruptd data bytes")] + InvalidValue, + + #[error("expected string ref, but utf8 schema was violated: {0}")] + Utf8RefSchemaViolation(#[from] std::str::Utf8Error), + + #[error("expected string, but utf8 schema was violated: {0}")] + Utf8SchemaViolation(#[from] std::string::FromUtf8Error), + + #[error("internal database error: {0}")] + InternalError(#[from] sled::Error), + + #[error("transaction error: {0}")] + TransactionError(#[from] sled::transaction::TransactionError), + + #[error("unabortable transaction error: {0}")] + UnabortableTransactionError(#[from] UnabortableTransactionError), + + #[error("data migration error: {0}")] + MigrationError(#[from] super::migrator::MigrationError), + + #[error("deserialization error: {0}")] + DeserializationError(#[from] bincode::Error), + + #[error("sqlx error: {0}")] + SqlxError(#[from] sqlx::Error), + + #[error("numeric conversion error")] + NumericConversionError(#[from] TryFromIntError), +} + +/// This From implementation is necessary to deal with the recursive +/// error type in the error enum. We defined a transaction error, but +/// the only place we use it is when converting from +/// sled::transaction::TransactionError. This converter +/// extracts the inner data error from transaction aborted errors, and +/// forwards anything else onward as-is, but wrapped in DataError. +impl From> for DataError { + fn from(error: TransactionError) -> Self { + match error { + TransactionError::Abort(data_err) => data_err, + TransactionError::Storage(storage_err) => { + DataError::TransactionError(TransactionError::Storage(storage_err)) + } + } + } +} + +/// Automatically aborts transactions that hit a DataError by using +/// the try (question mark) operator when this trait implementation is +/// in scope. +impl From for sled::transaction::ConflictableTransactionError { + fn from(error: DataError) -> Self { + sled::transaction::ConflictableTransactionError::Abort(error) + } +} diff --git a/src/db/sqlite/migrator/migrations/V1__variables.rs b/src/db/sqlite/migrator/migrations/V1__variables.rs new file mode 100644 index 0000000..a494bc7 --- /dev/null +++ b/src/db/sqlite/migrator/migrations/V1__variables.rs @@ -0,0 +1,15 @@ +use barrel::backend::Sqlite; +use barrel::{types, Migration}; + +pub fn migration() -> String { + let mut m = Migration::new(); + + m.create_table("user_variables", |t| { + t.add_column("room_id", types::text()); + t.add_column("user_id", types::text()); + t.add_column("key", types::text()); + t.add_column("value", types::integer()); + }); + + m.make::() +} diff --git a/src/db/sqlite/migrator/migrations/V2__room_info.rs b/src/db/sqlite/migrator/migrations/V2__room_info.rs new file mode 100644 index 0000000..fb6ebb2 --- /dev/null +++ b/src/db/sqlite/migrator/migrations/V2__room_info.rs @@ -0,0 +1,18 @@ +use barrel::backend::Sqlite; +use barrel::{types, types::Type, Migration}; + +fn primary_uuid() -> Type { + types::text().unique(true).primary(true).nullable(false) +} + +pub fn migration() -> String { + let mut m = Migration::new(); + + //Table for basic room information: room ID, room name + m.create_table("room_info", move |t| { + t.add_column("room_id", primary_uuid()); + t.add_column("room_name", types::text()); + }); + + m.make::() +} diff --git a/src/db/sqlite/migrator/migrations/V3__dbstate.rs b/src/db/sqlite/migrator/migrations/V3__dbstate.rs new file mode 100644 index 0000000..3bb25c0 --- /dev/null +++ b/src/db/sqlite/migrator/migrations/V3__dbstate.rs @@ -0,0 +1,13 @@ +use barrel::backend::Sqlite; +use barrel::{types, Migration}; + +pub fn migration() -> String { + let mut m = Migration::new(); + + //Basic state table with only device ID for now. Uses only one row. + m.create_table("bot_state", move |t| { + t.add_column("device_id", types::text()); + }); + + m.make::() +} diff --git a/src/db/sqlite/migrator/migrations/V4__room_events.rs b/src/db/sqlite/migrator/migrations/V4__room_events.rs new file mode 100644 index 0000000..8657a98 --- /dev/null +++ b/src/db/sqlite/migrator/migrations/V4__room_events.rs @@ -0,0 +1,22 @@ +use barrel::backend::Sqlite; +use barrel::{types, types::Type, Migration}; +pub fn migration() -> String { + let mut m = Migration::new(); + + //Table of room ID, event ID, event timestamp + m.create_table("room_events", move |t| { + t.add_column("room_id", types::text().nullable(false)); + t.add_column("event_id", types::text().nullable(false)); + t.add_column("event_timestamp", types::integer()); + }); + + let mut res = m.make::(); + + //This is a hack that gives us a composite primary key. + if res.ends_with(");") { + res.pop(); + res.pop(); + } + + format!("{}, PRIMARY KEY (room_id, event_id));", res) +} diff --git a/src/db/sqlite/migrator/migrations/V5__room_users.rs b/src/db/sqlite/migrator/migrations/V5__room_users.rs new file mode 100644 index 0000000..eedec4d --- /dev/null +++ b/src/db/sqlite/migrator/migrations/V5__room_users.rs @@ -0,0 +1,22 @@ +use barrel::backend::Sqlite; +use barrel::{types, types::Type, Migration}; + +pub fn migration() -> String { + let mut m = Migration::new(); + + //Table of users in rooms. + m.create_table("room_users", move |t| { + t.add_column("room_id", types::text()); + t.add_column("username", types::text()); + }); + + let mut res = m.make::(); + + //This is a hack that gives us a composite primary key. + if res.ends_with(");") { + res.pop(); + res.pop(); + } + + format!("{}, PRIMARY KEY (room_id, username));", res) +} diff --git a/src/db/sqlite/migrator/migrations/mod.rs b/src/db/sqlite/migrator/migrations/mod.rs new file mode 100644 index 0000000..a7ece4d --- /dev/null +++ b/src/db/sqlite/migrator/migrations/mod.rs @@ -0,0 +1,2 @@ +use refinery::include_migration_mods; +include_migration_mods!("src/db/sqlite/migrator/migrations"); diff --git a/src/db/sqlite/migrator/mod.rs b/src/db/sqlite/migrator/mod.rs new file mode 100644 index 0000000..33be059 --- /dev/null +++ b/src/db/sqlite/migrator/mod.rs @@ -0,0 +1,33 @@ +use log::info; +use refinery::config::{Config, ConfigDbType}; +use sqlx::sqlite::SqliteConnectOptions; +use sqlx::ConnectOptions; +use std::str::FromStr; +use thiserror::Error; + +pub mod migrations; + +#[derive(Error, Debug)] +pub enum MigrationError { + #[error("sqlite connection error: {0}")] + SqlxError(#[from] sqlx::Error), + + #[error("refinery migration error: {0}")] + RefineryError(#[from] refinery::Error), +} + +/// Run database migrations against the sqlite database. +pub async fn migrate(db_path: &str) -> Result<(), MigrationError> { + //Create database if missing. + let conn = SqliteConnectOptions::from_str(&format!("sqlite://{}", db_path))? + .create_if_missing(true) + .connect() + .await?; + + drop(conn); + + let mut conn = Config::new(ConfigDbType::Sqlite).set_db_path(db_path); + info!("Running migrations"); + migrations::runner().run(&mut conn)?; + Ok(()) +} diff --git a/src/db/sqlite/mod.rs b/src/db/sqlite/mod.rs new file mode 100644 index 0000000..e470764 --- /dev/null +++ b/src/db/sqlite/mod.rs @@ -0,0 +1,116 @@ +use async_trait::async_trait; +use errors::DataError; +use sqlx::sqlite::{SqliteConnectOptions, SqlitePool, SqlitePoolOptions}; +use sqlx::ConnectOptions; +use std::clone::Clone; +use std::collections::{HashMap, HashSet}; +use std::str::FromStr; + +use crate::models::RoomInfo; + +pub mod errors; +pub mod migrator; +pub mod rooms; +pub mod state; +pub mod variables; + +#[async_trait] +pub(crate) trait DbState { + async fn get_device_id(&self) -> Result, DataError>; + + async fn set_device_id(&self, device_id: &str) -> Result<(), DataError>; +} + +#[async_trait] +pub(crate) trait Rooms { + async fn should_process(&self, room_id: &str, event_id: &str) -> Result; + + async fn insert_room_info(&self, info: &RoomInfo) -> Result<(), DataError>; + + async fn get_room_info(&self, room_id: &str) -> Result, DataError>; + + async fn get_rooms_for_user(&self, user_id: &str) -> Result, DataError>; + + async fn get_users_in_room(&self, room_id: &str) -> Result, DataError>; + + async fn add_user_to_room(&self, username: &str, room_id: &str) -> Result<(), DataError>; + + async fn remove_user_from_room(&self, username: &str, room_id: &str) -> Result<(), DataError>; + + async fn clear_info(&self, room_id: &str) -> Result<(), DataError>; +} + +// TODO move this up to the top once we delete sled. Traits will be the +// main API, then we can have different impls for different DBs. +#[async_trait] +pub trait Variables { + async fn get_user_variables( + &self, + user: &str, + room_id: &str, + ) -> Result, DataError>; + + async fn get_variable_count(&self, user: &str, room_id: &str) -> Result; + + async fn get_user_variable( + &self, + user: &str, + room_id: &str, + variable_name: &str, + ) -> Result; + + async fn set_user_variable( + &self, + user: &str, + room_id: &str, + variable_name: &str, + value: i32, + ) -> Result<(), DataError>; + + async fn delete_user_variable( + &self, + user: &str, + room_id: &str, + variable_name: &str, + ) -> Result<(), DataError>; +} + +pub struct Database { + conn: SqlitePool, +} + +impl Database { + fn new_db(conn: SqlitePool) -> Result { + let database = Database { conn: conn.clone() }; + Ok(database) + } + + pub async fn new(path: &str) -> Result { + //Create database if missing. + let conn = SqliteConnectOptions::from_str(path)? + .create_if_missing(true) + .connect() + .await?; + + drop(conn); + + //Migrate database. + migrator::migrate(&path).await?; + + //Return actual conncetion pool. + let conn = SqlitePoolOptions::new() + .max_connections(5) + .connect(path) + .await?; + + Self::new_db(conn) + } +} + +impl Clone for Database { + fn clone(&self) -> Self { + Database { + conn: self.conn.clone(), + } + } +} diff --git a/src/db/sqlite/rooms.rs b/src/db/sqlite/rooms.rs new file mode 100644 index 0000000..14e8ce4 --- /dev/null +++ b/src/db/sqlite/rooms.rs @@ -0,0 +1,379 @@ +use super::errors::DataError; +use super::{Database, Rooms}; +use crate::models::RoomInfo; +use async_trait::async_trait; +use sqlx::SqlitePool; +use std::collections::{HashMap, HashSet}; +use std::time::{SystemTime, UNIX_EPOCH}; + +async fn record_event(conn: &SqlitePool, room_id: &str, event_id: &str) -> Result<(), DataError> { + use std::convert::TryFrom; + let now: i64 = i64::try_from( + SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("Clock has gone backwards") + .as_secs(), + )?; + + sqlx::query( + r#"INSERT INTO room_events + (room_id, event_id, event_timestamp) + VALUES (?, ?, ?)"#, + ) + .bind(room_id) + .bind(event_id) + .bind(now) + .execute(conn) + .await?; + + Ok(()) +} + +#[async_trait] +impl Rooms for Database { + async fn should_process(&self, room_id: &str, event_id: &str) -> Result { + let row = sqlx::query!( + r#"SELECT event_id FROM room_events + WHERE room_id = ? AND event_id = ?"#, + room_id, + event_id + ) + .fetch_optional(&self.conn) + .await?; + + match row { + Some(_) => Ok(false), + None => { + record_event(&self.conn, room_id, event_id).await?; + Ok(true) + } + } + } + + async fn insert_room_info(&self, info: &RoomInfo) -> Result<(), DataError> { + sqlx::query( + r#"INSERT INTO room_info (room_id, room_name) VALUES (?, ?) + ON CONFLICT(room_id) DO UPDATE SET room_name = ?"#, + ) + .bind(&info.room_id) + .bind(&info.room_name) + .bind(&info.room_name) + .execute(&self.conn) + .await?; + + Ok(()) + } + + async fn get_room_info(&self, room_id: &str) -> Result, DataError> { + let info = sqlx::query!( + r#"SELECT room_id, room_name FROM room_info + WHERE room_id = ?"#, + room_id + ) + .fetch_optional(&self.conn) + .await?; + + Ok(info.map(|i| RoomInfo { + room_id: i.room_id, + room_name: i.room_name, + })) + } + + async fn get_rooms_for_user(&self, user_id: &str) -> Result, DataError> { + let room_ids = sqlx::query!( + r#"SELECT room_id FROM room_users + WHERE username = ?"#, + user_id + ) + .fetch_all(&self.conn) + .await?; + + Ok(room_ids.into_iter().map(|row| row.room_id).collect()) + } + + async fn get_users_in_room(&self, room_id: &str) -> Result, DataError> { + let usernames = sqlx::query!( + r#"SELECT username FROM room_users + WHERE room_id = ?"#, + room_id + ) + .fetch_all(&self.conn) + .await?; + + Ok(usernames.into_iter().map(|row| row.username).collect()) + } + + async fn add_user_to_room(&self, username: &str, room_id: &str) -> Result<(), DataError> { + sqlx::query( + "INSERT INTO room_users (room_id, username) VALUES (?, ?) + ON CONFLICT DO NOTHING", + ) + .bind(room_id) + .bind(username) + .execute(&self.conn) + .await?; + + Ok(()) + } + + async fn remove_user_from_room(&self, username: &str, room_id: &str) -> Result<(), DataError> { + sqlx::query("DELETE FROM room_users where username = ? AND room_id = ?") + .bind(username) + .bind(room_id) + .execute(&self.conn) + .await?; + + Ok(()) + } + + async fn clear_info(&self, room_id: &str) -> Result<(), DataError> { + // We do not clear event history here, because if we rejoin a + // room, we would re-process events we've already seen. + let mut tx = self.conn.begin().await?; + + sqlx::query("DELETE FROM room_info where room_id = ?") + .bind(room_id) + .execute(&mut tx) + .await?; + + sqlx::query("DELETE FROM room_users where room_id = ?") + .bind(room_id) + .execute(&mut tx) + .await?; + + tx.commit().await?; + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::super::Rooms; + use super::*; + + async fn create_db() -> Database { + let db_path = tempfile::NamedTempFile::new_in(".").unwrap(); + crate::db::sqlite::migrator::migrate(db_path.path().to_str().unwrap()) + .await + .unwrap(); + + Database::new(db_path.path().to_str().unwrap()) + .await + .unwrap() + } + + #[tokio::test(flavor = "multi_thread", worker_threads = 1)] + async fn should_process_test() { + let db = create_db().await; + + let first_check = db + .should_process("myroom", "myeventid") + .await + .expect("should_process failed in first insert"); + + assert_eq!(first_check, true); + + let second_check = db + .should_process("myroom", "myeventid") + .await + .expect("should_process failed in first insert"); + + assert_eq!(second_check, false); + } + + #[tokio::test(flavor = "multi_thread", worker_threads = 1)] + async fn insert_and_get_room_info_test() { + let db = create_db().await; + + let info = RoomInfo { + room_id: "myroomid".to_string(), + room_name: "myroomname".to_string(), + }; + + db.insert_room_info(&info) + .await + .expect("Could not insert room info."); + + let retrieved_info = db + .get_room_info("myroomid") + .await + .expect("Could not retrieve room info."); + + assert!(retrieved_info.is_some()); + assert_eq!(info, retrieved_info.unwrap()); + } + + #[tokio::test(flavor = "multi_thread", worker_threads = 1)] + async fn insert_room_info_updates_existing() { + let db = create_db().await; + + let info1 = RoomInfo { + room_id: "myroomid".to_string(), + room_name: "myroomname".to_string(), + }; + + db.insert_room_info(&info1) + .await + .expect("Could not insert room info1."); + + let info2 = RoomInfo { + room_id: "myroomid".to_string(), + room_name: "myroomname2".to_string(), + }; + + db.insert_room_info(&info2) + .await + .expect("Could not update room info after first insert"); + + let retrieved_info = db + .get_room_info("myroomid") + .await + .expect("Could not get room info"); + + assert!(retrieved_info.is_some()); + let retrieved_info = retrieved_info.unwrap(); + + assert_eq!(retrieved_info.room_id, "myroomid"); + assert_eq!(retrieved_info.room_name, "myroomname2"); + } + + #[tokio::test(flavor = "multi_thread", worker_threads = 1)] + async fn add_user_to_room_test() { + let db = create_db().await; + + db.add_user_to_room("myuser", "myroom") + .await + .expect("Could not add user to room."); + + let users_in_room = db + .get_users_in_room("myroom") + .await + .expect("Could not get users in room."); + + assert_eq!(users_in_room.len(), 1); + assert!(users_in_room.contains("myuser")); + + let rooms_for_user = db + .get_rooms_for_user("myuser") + .await + .expect("Could not get rooms for user"); + + assert_eq!(rooms_for_user.len(), 1); + assert!(rooms_for_user.contains("myroom")); + } + + #[tokio::test(flavor = "multi_thread", worker_threads = 1)] + async fn add_user_to_room_does_not_have_constraint_violation() { + let db = create_db().await; + + db.add_user_to_room("myuser", "myroom") + .await + .expect("Could not add user to room."); + + let second_attempt = db.add_user_to_room("myuser", "myroom").await; + + assert!(second_attempt.is_ok()); + + let users_in_room = db + .get_users_in_room("myroom") + .await + .expect("Could not get users in room."); + + assert_eq!(users_in_room.len(), 1); + assert!(users_in_room.contains("myuser")); + } + + #[tokio::test(flavor = "multi_thread", worker_threads = 1)] + async fn remove_user_from_room_test() { + let db = create_db().await; + + db.add_user_to_room("myuser", "myroom") + .await + .expect("Could not add user to room."); + + let remove_attempt = db.remove_user_from_room("myuser", "myroom").await; + + assert!(remove_attempt.is_ok()); + + let users_in_room = db + .get_users_in_room("myroom") + .await + .expect("Could not get users in room."); + + assert_eq!(users_in_room.len(), 0); + } + + #[tokio::test(flavor = "multi_thread", worker_threads = 1)] + async fn clear_info_does_not_delete_other_rooms() { + let db = create_db().await; + + let info1 = RoomInfo { + room_id: "myroomid".to_string(), + room_name: "myroomname".to_string(), + }; + + let info2 = RoomInfo { + room_id: "myroomid2".to_string(), + room_name: "myroomname2".to_string(), + }; + + db.insert_room_info(&info1) + .await + .expect("Could not insert room info1."); + + db.insert_room_info(&info2) + .await + .expect("Could not insert room info2."); + + db.add_user_to_room("myuser", &info1.room_id) + .await + .expect("Could not add user to room."); + + db.clear_info(&info1.room_id) + .await + .expect("Could not clear room info1"); + + let room_info2 = db + .get_room_info(&info2.room_id) + .await + .expect("Could not get room info2."); + + assert!(room_info2.is_some()); + } + + #[tokio::test(flavor = "multi_thread", worker_threads = 1)] + async fn clear_info_test() { + let db = create_db().await; + + let info = RoomInfo { + room_id: "myroomid".to_string(), + room_name: "myroomname".to_string(), + }; + + db.insert_room_info(&info) + .await + .expect("Could not insert room info."); + + db.add_user_to_room("myuser", &info.room_id) + .await + .expect("Could not add user to room."); + + db.clear_info(&info.room_id) + .await + .expect("Could not clear room info"); + + let users_in_room = db + .get_users_in_room(&info.room_id) + .await + .expect("Could not get users in room."); + + assert_eq!(users_in_room.len(), 0); + + let room_info = db + .get_room_info(&info.room_id) + .await + .expect("Could not get room info."); + + assert!(room_info.is_none()); + } +} diff --git a/src/db/sqlite/state.rs b/src/db/sqlite/state.rs new file mode 100644 index 0000000..0d831cb --- /dev/null +++ b/src/db/sqlite/state.rs @@ -0,0 +1,90 @@ +use super::errors::DataError; +use super::{Database, DbState}; +use async_trait::async_trait; + +#[async_trait] +impl DbState for Database { + async fn get_device_id(&self) -> Result, DataError> { + let state = sqlx::query!(r#"SELECT device_id FROM bot_state limit 1"#) + .fetch_optional(&self.conn) + .await?; + + Ok(state.map(|s| s.device_id)) + } + + async fn set_device_id(&self, device_id: &str) -> Result<(), DataError> { + // This will have to be updated if we ever add another column + // to this table! + sqlx::query("DELETE FROM bot_state") + .execute(&self.conn) + .await + .ok(); + + sqlx::query( + r#"INSERT INTO bot_state + (device_id) + VALUES (?)"#, + ) + .bind(device_id) + .execute(&self.conn) + .await?; + + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::super::DbState; + use super::*; + + async fn create_db() -> Database { + let db_path = tempfile::NamedTempFile::new_in(".").unwrap(); + crate::db::sqlite::migrator::migrate(db_path.path().to_str().unwrap()) + .await + .unwrap(); + + Database::new(db_path.path().to_str().unwrap()) + .await + .unwrap() + } + + #[tokio::test(flavor = "multi_thread", worker_threads = 1)] + async fn set_and_get_device_id() { + let db = create_db().await; + + db.set_device_id("device_id") + .await + .expect("Could not set device ID"); + + let device_id = db.get_device_id().await.expect("Could not get device ID"); + + assert!(device_id.is_some()); + assert_eq!(device_id.unwrap(), "device_id"); + } + + #[tokio::test(flavor = "multi_thread", worker_threads = 1)] + async fn no_device_id_set_returns_none() { + let db = create_db().await; + let device_id = db.get_device_id().await.expect("Could not get device ID"); + assert!(device_id.is_none()); + } + + #[tokio::test(flavor = "multi_thread", worker_threads = 1)] + async fn can_update_device_id() { + let db = create_db().await; + + db.set_device_id("device_id") + .await + .expect("Could not set device ID"); + + db.set_device_id("device_id2") + .await + .expect("Could not set device ID"); + + let device_id = db.get_device_id().await.expect("Could not get device ID"); + + assert!(device_id.is_some()); + assert_eq!(device_id.unwrap(), "device_id2"); + } +} diff --git a/src/db/sqlite/variables.rs b/src/db/sqlite/variables.rs new file mode 100644 index 0000000..f93f499 --- /dev/null +++ b/src/db/sqlite/variables.rs @@ -0,0 +1,251 @@ +use super::errors::DataError; +use super::{Database, Variables}; +use async_trait::async_trait; +use std::collections::HashMap; + +struct UserVariableRow { + key: String, + value: i32, +} + +#[async_trait] +impl Variables for Database { + async fn get_user_variables( + &self, + user: &str, + room_id: &str, + ) -> Result, DataError> { + let rows = sqlx::query!( + r#"SELECT key, value as "value: i32" FROM user_variables + WHERE room_id = ? AND user_id = ?"#, + room_id, + user + ) + .fetch_all(&self.conn) + .await?; + + Ok(rows.into_iter().map(|row| (row.key, row.value)).collect()) + } + + async fn get_variable_count(&self, user: &str, room_id: &str) -> Result { + let row = sqlx::query!( + r#"SELECT count(*) as "count: i32" FROM user_variables + WHERE room_id = ? and user_id = ?"#, + room_id, + user + ) + .fetch_optional(&self.conn) + .await?; + + Ok(row.map(|r| r.count).unwrap_or(0)) + } + + async fn get_user_variable( + &self, + user: &str, + room_id: &str, + variable_name: &str, + ) -> Result { + let row = sqlx::query!( + r#"SELECT value as "value: i32" FROM user_variables + WHERE user_id = ? AND room_id = ? AND key = ?"#, + user, + room_id, + variable_name + ) + .fetch_optional(&self.conn) + .await?; + + row.map(|r| r.value) + .ok_or_else(|| DataError::KeyDoesNotExist(variable_name.to_string())) + } + + async fn set_user_variable( + &self, + user: &str, + room_id: &str, + variable_name: &str, + value: i32, + ) -> Result<(), DataError> { + sqlx::query( + "INSERT INTO user_variables + (user_id, room_id, key, value) + values (?, ?, ?, ?)", + ) + .bind(user) + .bind(room_id) + .bind(variable_name) + .bind(value) + .execute(&self.conn) + .await?; + + Ok(()) + } + + async fn delete_user_variable( + &self, + user: &str, + room_id: &str, + variable_name: &str, + ) -> Result<(), DataError> { + sqlx::query( + "DELETE FROM user_variables + WHERE user_id = ? AND room_id = ? AND key = ?", + ) + .bind(user) + .bind(room_id) + .bind(variable_name) + .execute(&self.conn) + .await?; + + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::super::Variables; + use super::*; + + async fn create_db() -> Database { + let db_path = tempfile::NamedTempFile::new_in(".").unwrap(); + crate::db::sqlite::migrator::migrate(db_path.path().to_str().unwrap()) + .await + .unwrap(); + + Database::new(db_path.path().to_str().unwrap()) + .await + .unwrap() + } + + #[tokio::test(flavor = "multi_thread", worker_threads = 1)] + async fn set_and_get_variable_test() { + use super::super::Variables; + let db = create_db().await; + + db.set_user_variable("myuser", "myroom", "myvariable", 1) + .await + .expect("Could not set variable"); + + let value = db + .get_user_variable("myuser", "myroom", "myvariable") + .await + .expect("Could not get variable"); + + assert_eq!(value, 1); + } + + #[tokio::test(flavor = "multi_thread", worker_threads = 1)] + async fn get_missing_variable_test() { + use super::super::Variables; + let db = create_db().await; + + let value = db.get_user_variable("myuser", "myroom", "myvariable").await; + + assert!(value.is_err()); + assert!(matches!( + value.err().unwrap(), + DataError::KeyDoesNotExist(_) + )); + } + + #[tokio::test(flavor = "multi_thread", worker_threads = 1)] + async fn get_other_user_variable_test() { + use super::super::Variables; + let db = create_db().await; + + db.set_user_variable("myuser1", "myroom", "myvariable", 1) + .await + .expect("Could not set variable"); + + let value = db + .get_user_variable("myuser2", "myroom", "myvariable") + .await; + + assert!(value.is_err()); + assert!(matches!( + value.err().unwrap(), + DataError::KeyDoesNotExist(_) + )); + } + + #[tokio::test(flavor = "multi_thread", worker_threads = 1)] + async fn count_variables_test() { + let db = create_db().await; + + for variable_name in &["var1", "var2", "var3"] { + db.set_user_variable("myuser", "myroom", variable_name, 1) + .await + .expect("Could not set variable"); + } + + let count = db + .get_variable_count("myuser", "myroom") + .await + .expect("Could not get count."); + + assert_eq!(count, 3); + } + + #[tokio::test(flavor = "multi_thread", worker_threads = 1)] + async fn count_variables_respects_user_id() { + let db = create_db().await; + + for variable_name in &["var1", "var2", "var3"] { + db.set_user_variable("different-user", "myroom", variable_name, 1) + .await + .expect("Could not set variable"); + } + + let count = db + .get_variable_count("myuser", "myroom") + .await + .expect("Could not get count."); + + assert_eq!(count, 0); + } + + #[tokio::test(flavor = "multi_thread", worker_threads = 1)] + async fn count_variables_respects_room_id() { + let db = create_db().await; + + for variable_name in &["var1", "var2", "var3"] { + db.set_user_variable("myuser", "different-room", variable_name, 1) + .await + .expect("Could not set variable"); + } + + let count = db + .get_variable_count("myuser", "myroom") + .await + .expect("Could not get count."); + + assert_eq!(count, 0); + } + + #[tokio::test(flavor = "multi_thread", worker_threads = 1)] + async fn delete_variable_test() { + let db = create_db().await; + + for variable_name in &["var1", "var2", "var3"] { + db.set_user_variable("myuser", "myroom", variable_name, 1) + .await + .expect("Could not set variable"); + } + + db.delete_user_variable("myuser", "myroom", "var1") + .await + .expect("Could not delete variable."); + + let count = db + .get_variable_count("myuser", "myroom") + .await + .expect("Could not get count"); + + assert_eq!(count, 2); + + let var1 = db.get_user_variable("myuser", "myroom", "var1").await; + assert!(var1.is_err()); + assert!(matches!(var1.err().unwrap(), DataError::KeyDoesNotExist(_))); + } +} diff --git a/src/db/variables.rs b/src/db/variables.rs index 1c3834c..947e78b 100644 --- a/src/db/variables.rs +++ b/src/db/variables.rs @@ -10,6 +10,8 @@ use std::str; use zerocopy::byteorder::I32; use zerocopy::AsBytes; +use super::errors; + pub(super) mod migrations; #[derive(Clone)] @@ -67,6 +69,9 @@ fn alter_room_variable_count( Ok(new_count) } +/// Room ID, Username, Variable Name +pub type AllVariablesKey = (String, String, String); + impl Variables { pub(in crate::db) fn new(db: &sled::Db) -> Result { Ok(Variables { @@ -75,6 +80,40 @@ impl Variables { }) } + pub fn get_all_variables(&self) -> Result, DataError> { + use std::convert::TryFrom; + let variables: Result, DataError> = self + .room_user_variables + .scan_prefix("") + .map(|entry| match entry { + Ok((key, raw_value)) => { + let keys: Vec<_> = key + .split(|&b| b == 0xfe || b == 0xff) + .map(|b| str::from_utf8(b)) + .collect(); + + if let &[Ok(room_id), Ok(username), Ok(variable_name), ..] = keys.as_slice() { + Ok(( + ( + room_id.to_owned(), + username.to_owned(), + variable_name.to_owned(), + ), + convert_i32(&raw_value)?, + )) + } else { + Err(errors::DataError::InvalidValue) + } + } + Err(e) => Err(e.into()), + }) + .collect(); + + // Convert tuples to hash map with collect(), inferred via + // return type. + variables.map(|entries| entries.into_iter().collect()) + } + pub fn get_user_variables( &self, key: &UserAndRoom<'_>, diff --git a/src/dice.rs b/src/dice.rs index 5b18867..897225d 100644 --- a/src/dice.rs +++ b/src/dice.rs @@ -1,4 +1,5 @@ use crate::context::Context; +use crate::db::sqlite::Variables; use crate::db::variables::UserAndRoom; use crate::error::BotError; use crate::error::DiceRollingError; @@ -22,8 +23,10 @@ pub async fn calculate_single_die_amount( /// it cannot find a variable defined, or if the database errors. pub async fn calculate_dice_amount(amounts: &[Amount], ctx: &Context<'_>) -> Result { let stream = stream::iter(amounts); - let key = UserAndRoom(&ctx.username, ctx.room_id().as_str()); - let variables = &ctx.db.variables.get_user_variables(&key)?; + let variables = &ctx + .db + .get_user_variables(&ctx.username, ctx.room_id().as_str()) + .await?; use DiceRollingError::VariableNotFound; let dice_amount: i32 = stream diff --git a/src/error.rs b/src/error.rs index 69b2004..3cc9376 100644 --- a/src/error.rs +++ b/src/error.rs @@ -1,6 +1,6 @@ -use crate::commands::CommandError; use crate::config::ConfigError; use crate::db::errors::DataError; +use crate::{commands::CommandError, db::sqlite::migrator}; use thiserror::Error; #[derive(Error, Debug)] @@ -21,6 +21,9 @@ pub enum BotError { #[error("database error: {0}")] DataError(#[from] DataError), + #[error("sqlite database error: {0}")] + SqliteDataError(#[from] crate::db::sqlite::errors::DataError), + #[error("the message should not be processed because it failed validation")] ShouldNotProcessError, @@ -33,10 +36,10 @@ pub enum BotError { #[error("error in matrix state store: {0}")] MatrixStateStoreError(#[from] matrix_sdk::StoreError), - #[error("uncategorized matrix SDK error")] + #[error("uncategorized matrix SDK error: {0}")] MatrixError(#[from] matrix_sdk::Error), - #[error("uncategorized matrix SDK base error")] + #[error("uncategorized matrix SDK base error: {0}")] MatrixBaseError(#[from] matrix_sdk::BaseError), #[error("future canceled")] @@ -73,6 +76,9 @@ pub enum BotError { #[error("database error")] DatabaseError(#[from] sled::Error), + #[error("database migration error: {0}")] + SqliteError(#[from] migrator::MigrationError), + #[error("too many commands or message was too large")] MessageTooLarge, diff --git a/src/logic.rs b/src/logic.rs index 3b03190..7b67ce0 100644 --- a/src/logic.rs +++ b/src/logic.rs @@ -1,18 +1,24 @@ -use crate::db::errors::DataError; +use crate::db::sqlite::errors::DataError; +use crate::db::sqlite::Rooms; +use crate::error::BotError; use crate::matrix; use crate::models::RoomInfo; +use futures::stream::{self, StreamExt, TryStreamExt}; use matrix_sdk::{self, identifiers::RoomId, Client}; /// Record the information about a room, including users in it. pub async fn record_room_information( client: &Client, - db: &crate::db::Database, + db: &crate::db::sqlite::Database, room_id: &RoomId, room_display_name: &str, our_username: &str, -) -> Result<(), DataError> { +) -> Result<(), BotError> { + //Clear out any old room info first. + db.clear_info(room_id.as_str()).await?; + let room_id_str = room_id.as_str(); - let usernames = matrix::get_users_in_room(&client, &room_id).await; + let usernames = matrix::get_users_in_room(&client, &room_id).await?; let info = RoomInfo { room_id: room_id_str.to_owned(), @@ -21,11 +27,23 @@ pub async fn record_room_information( // TODO this and the username adding should be one whole // transaction in the db. - db.rooms.insert_room_info(&info)?; + db.insert_room_info(&info).await?; - usernames + let filtered_usernames = usernames .into_iter() - .filter(|username| username != our_username) - .map(|username| db.rooms.add_user_to_room(&username, room_id_str)) - .collect() //Make use of collect impl on Result. + .filter(|username| username != our_username); + + // Async collect into vec of results, then use into_iter of result + // to go to from Result> to just Result<()>. Easier than + // attempting to async-collect our way to a single Result<()>. + stream::iter(filtered_usernames) + .then(|username| async move { + db.add_user_to_room(&username, &room_id_str) + .await + .map_err(|e| e.into()) + }) + .collect::>>() + .await + .into_iter() + .collect() } diff --git a/src/matrix.rs b/src/matrix.rs index 173a2ae..6c8e7d8 100644 --- a/src/matrix.rs +++ b/src/matrix.rs @@ -20,16 +20,19 @@ fn extract_error_message(error: MatrixError) -> String { } /// Retrieve a list of users in a given room. -pub async fn get_users_in_room(client: &Client, room_id: &RoomId) -> Vec { +pub async fn get_users_in_room( + client: &Client, + room_id: &RoomId, +) -> Result, MatrixError> { if let Some(joined_room) = client.get_joined_room(room_id) { - let members = joined_room.joined_members().await.ok().unwrap_or_default(); + let members = joined_room.joined_members().await?; - members + Ok(members .into_iter() .map(|member| member.user_id().to_string()) - .collect() + .collect()) } else { - vec![] + Ok(vec![]) } } @@ -50,7 +53,7 @@ pub async fn send_message( )); content.relates_to = reply_to.map(|event_id| Relation::Reply { - in_reply_to: InReplyTo { event_id }, + in_reply_to: InReplyTo::new(event_id), }); let content = AnyMessageEventContent::RoomMessage(content); diff --git a/src/migrate_cli.rs b/src/migrate_cli.rs new file mode 100644 index 0000000..9478b13 --- /dev/null +++ b/src/migrate_cli.rs @@ -0,0 +1,16 @@ +use std::env; +use tenebrous_dicebot::db::sqlite::migrator; + +#[tokio::main] +async fn main() -> Result<(), migrator::MigrationError> { + let args: Vec = env::args().collect(); + let db_path: &str = match &args[..] { + [_, path] => path.as_ref(), + [_, _, ..] => panic!("Expected exactly 0 or 1 argument"), + _ => "dicebot.sqlite", + }; + + println!("Using database: {}", db_path); + + migrator::migrate(db_path).await +}