diff --git a/.github/workflows/build_and_test.yml b/.github/workflows/build_and_test.yml index 547e2e83..b9cebea5 100644 --- a/.github/workflows/build_and_test.yml +++ b/.github/workflows/build_and_test.yml @@ -97,6 +97,7 @@ jobs: with: cache-all-crates: "true" prefix-key: "macos" + - uses: taiki-e/install-action@nextest - name: Run WASM tests with Safari, Firefox, Chrome run: | rustup target add wasm32-unknown-unknown @@ -126,6 +127,7 @@ jobs: with: cache-all-crates: "true" prefix-key: "macos" + - uses: taiki-e/install-action@nextest - name: Run WASM tests with Safari, Firefox, Chrome run: | rustup target add wasm32-unknown-unknown diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 84e70210..0bad470e 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -10,3 +10,7 @@ ever since [we streamlined the process of doing so](https://github.com/polyphony If you'd like to contribute new functionality, check out [The 'Meta'-issues.](https://github.com/polyphony-chat/chorus/issues?q=is%3Aissue+label%3A%22Type%3A+Meta%22+) They contain a comprehensive list of all features which are yet missing for full Discord.com compatibility. Please feel free to open an Issue with the idea you have, or a Pull Request. + +## Merging + +All pull requests opened into the `dev` branch should be merged via the "Squash and Merge" option to keep the commit history small. Merging into the `main` branch should be done via a regular merge commit. This way, GitHub will correctly attribute contributors and count statistics for the insights tab. diff --git a/Cargo.lock b/Cargo.lock index 2d79f4a4..30d7b2a3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4,19 +4,13 @@ version = 3 [[package]] name = "addr2line" -version = "0.22.0" +version = "0.24.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6e4503c46a5c0c7844e948c9a4d6acd9f50cccb4de1c48eb9e291ea17470c678" +checksum = "f5fb1d8e4442bd405fdfd1dacb42792696b0cf9cb15882e5d097b742a676d375" dependencies = [ "gimli", ] -[[package]] -name = "adler" -version = "1.0.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f26201604c87b1e01bd3d98f8d5d9a8fcbb815e8cedb41ffccbeb4bf593a35fe" - [[package]] name = "adler2" version = "2.0.0" @@ -77,13 +71,13 @@ dependencies = [ [[package]] name = "async-trait" -version = "0.1.81" +version = "0.1.83" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6e0c28dcc82d7c8ead5cb13beb15405b57b8546e93215673ff8ca0349a028107" +checksum = "721cae7de5c34fbb2acd27e21e6d2cf7b886dce0c27388d46c4e6c47ea4318dd" dependencies = [ "proc-macro2", "quote", - "syn 2.0.76", + "syn 2.0.79", ] [[package]] @@ -114,23 +108,23 @@ checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" [[package]] name = "autocfg" -version = "1.3.0" +version = "1.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0c4b4d0bd25bd0b74681c0ad21497610ce1b7c91b1022cd21c80c6fbdd9476b0" +checksum = "ace50bade8e6234aa140d9a2f552bbee1db4d353f69b8217bc503490fc1a9f26" [[package]] name = "backtrace" -version = "0.3.73" +version = "0.3.74" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5cc23269a4f8976d0a4d2e7109211a419fe30e8d88d677cd60b6bc79c5732e0a" +checksum = "8d82cb332cdfaed17ae235a638438ac4d4839913cc2af585c3c6746e8f8bee1a" dependencies = [ "addr2line", - "cc", "cfg-if", "libc", - "miniz_oxide 0.7.4", + "miniz_oxide", "object", "rustc-demangle", + "windows-targets 0.52.6", ] [[package]] @@ -209,15 +203,15 @@ checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" [[package]] name = "bytes" -version = "1.7.1" +version = "1.7.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8318a53db07bb3f8dca91a600466bdb3f2eaadeedfdbcf02e1accbad9271ba50" +checksum = "428d9aa8fbc0670b7b8d6030a7fadd0f86151cae55e4dbbece15f3780a3dfaf3" [[package]] name = "cc" -version = "1.1.14" +version = "1.1.22" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "50d2eb3cd3d1bf4529e31c215ee6f93ec5a3d536d9f578f93d9d33ee19562932" +checksum = "9540e661f81799159abee814118cc139a2004b3a3aa3ea37724a1b66530b90e0" dependencies = [ "shlex", ] @@ -236,7 +230,7 @@ checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" [[package]] name = "chorus" -version = "0.16.0" +version = "0.17.0" dependencies = [ "async-trait", "base64 0.21.7", @@ -276,7 +270,7 @@ dependencies = [ "wasm-bindgen-futures", "wasm-bindgen-test", "wasmtimer", - "webpki-roots 0.26.3", + "webpki-roots 0.26.6", "ws_stream_wasm", ] @@ -286,7 +280,7 @@ version = "0.5.0" dependencies = [ "async-trait", "quote", - "syn 2.0.76", + "syn 2.0.79", ] [[package]] @@ -358,9 +352,9 @@ checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" [[package]] name = "cpufeatures" -version = "0.2.13" +version = "0.2.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "51e852e6dc9a5bed1fae92dd2375037bf2b768725bf3be87811edee3249d09ad" +checksum = "608697df725056feaccfa42cffdaeeec3fccc4ffc38358ecd19b243e716a78e0" dependencies = [ "libc", ] @@ -457,7 +451,7 @@ dependencies = [ "proc-macro2", "quote", "strsim", - "syn 2.0.76", + "syn 2.0.79", ] [[package]] @@ -468,7 +462,7 @@ checksum = "d336a2a514f6ccccaa3e09b02d41d35330c07ddf03a62165fcec10bb561c7806" dependencies = [ "darling_core", "quote", - "syn 2.0.76", + "syn 2.0.79", ] [[package]] @@ -590,12 +584,12 @@ checksum = "e8c02a5121d4ea3eb16a80748c74f5549a5665e4c21333c6098f283870fbdea6" [[package]] name = "flate2" -version = "1.0.33" +version = "1.0.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "324a1be68054ef05ad64b861cc9eaf1d623d2d8cb25b4bf2cb9cdd902b4bf253" +checksum = "a1b589b4dc103969ad3cf85c950899926ec64300a1a46d76c03a6072957036f0" dependencies = [ "crc32fast", - "miniz_oxide 0.8.0", + "miniz_oxide", ] [[package]] @@ -691,7 +685,7 @@ checksum = "87750cf4b7a4c0625b1529e4c543c2182106e4dedc60a2a6455e00d212c489ac" dependencies = [ "proc-macro2", "quote", - "syn 2.0.76", + "syn 2.0.79", ] [[package]] @@ -750,9 +744,9 @@ dependencies = [ [[package]] name = "gimli" -version = "0.29.0" +version = "0.31.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "40ecd4077b5ae9fd2e9e169b102c6c330d0605168eb0e8bf79952b256dbefffd" +checksum = "32085ea23f3234fc7846555e85283ba4de91e21016dc0455a16286d87a292d64" [[package]] name = "h2" @@ -766,7 +760,7 @@ dependencies = [ "futures-sink", "futures-util", "http 0.2.12", - "indexmap 2.4.0", + "indexmap 2.5.0", "slab", "tokio", "tokio-util", @@ -785,7 +779,7 @@ dependencies = [ "futures-core", "futures-sink", "http 1.1.0", - "indexmap 2.4.0", + "indexmap 2.5.0", "slab", "tokio", "tokio-util", @@ -1025,9 +1019,9 @@ dependencies = [ [[package]] name = "hyper-util" -version = "0.1.7" +version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cde7055719c54e36e95e8719f95883f22072a48ede39db7fc17a4e1d5281e9b9" +checksum = "41296eb09f183ac68eec06e03cdbea2e759633d4067b2f6552fc2e009bcad08b" dependencies = [ "bytes", "futures-util", @@ -1040,9 +1034,9 @@ dependencies = [ [[package]] name = "iana-time-zone" -version = "0.1.60" +version = "0.1.61" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e7ffbb5a1b541ea2561f8c41c087286cc091e21e556a4f09a8f6cbf17b69b141" +checksum = "235e081f3925a06703c2d0117ea8b91f042756fd6e7a6e5d901e8ca1a996b220" dependencies = [ "android_system_properties", "core-foundation-sys", @@ -1090,9 +1084,9 @@ dependencies = [ [[package]] name = "indexmap" -version = "2.4.0" +version = "2.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "93ead53efc7ea8ed3cfb0c79fc8023fbb782a5432b52830b6518941cebe6505c" +checksum = "68b900aa2f7301e21c36462b170ee99994de34dff39a4a6a528e80e7376d07e5" dependencies = [ "equivalent", "hashbrown 0.14.5", @@ -1110,9 +1104,9 @@ dependencies = [ [[package]] name = "ipnet" -version = "2.9.0" +version = "2.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8f518f335dce6725a761382244631d86cf0ccb2863413590b31338feb467f9c3" +checksum = "187674a687eed5fe42285b40c6291f9a01517d415fad1c3cbc6a9f778af7fcd4" [[package]] name = "ipnetwork" @@ -1163,9 +1157,9 @@ dependencies = [ [[package]] name = "libc" -version = "0.2.158" +version = "0.2.159" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d8adc4bb1803a324070e64a98ae98f38934d91957a99cfb3a43dcbc01bc56439" +checksum = "561d97a539a36e26a9a5fad1ea11a3039a67714694aaa379433e580854bc3dc5" [[package]] name = "libm" @@ -1260,15 +1254,6 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" -[[package]] -name = "miniz_oxide" -version = "0.7.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b8a240ddb74feaf34a79a7add65a741f3167852fba007066dcac1ca548d89c08" -dependencies = [ - "adler", -] - [[package]] name = "miniz_oxide" version = "0.8.0" @@ -1401,9 +1386,9 @@ dependencies = [ [[package]] name = "object" -version = "0.36.3" +version = "0.36.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "27b64972346851a39438c60b341ebc01bba47464ae329e55cf343eb93964efd9" +checksum = "084f1a5821ac4c651660a94a7153d27ac9d8a53736203f58b31945ded098070a" dependencies = [ "memchr", ] @@ -1422,9 +1407,9 @@ checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" [[package]] name = "parking" -version = "2.2.0" +version = "2.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bb813b8af86854136c6922af0598d719255ecb2179515e6e7730d468f05c9cae" +checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba" [[package]] name = "parking_lot" @@ -1444,7 +1429,7 @@ checksum = "1e401f977ab385c9e4e3ab30627d6f26d00e2c73eef317493c4ec6d468726cf8" dependencies = [ "cfg-if", "libc", - "redox_syscall 0.5.3", + "redox_syscall", "smallvec", "windows-targets 0.52.6", ] @@ -1524,9 +1509,9 @@ dependencies = [ [[package]] name = "pkg-config" -version = "0.3.30" +version = "0.3.31" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d231b230927b5e4ad203db57bbcbee2802f6bce620b1e4a9024a07d94e2907ec" +checksum = "953ec861398dccce10c670dfeaf3ec4911ca479e9c02154b3a215178c5f566f2" [[package]] name = "pnet_base" @@ -1560,9 +1545,9 @@ dependencies = [ [[package]] name = "poem" -version = "3.0.4" +version = "3.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f1ba1c27f8f89e1bccdda0c680f72790545a11a8d8555819472f5839d7a8ca9d" +checksum = "e5419c612a492fce4961c521dca0c2249b5c48dc46eb5c8048063843f37a711d" dependencies = [ "bytes", "futures-util", @@ -1594,14 +1579,14 @@ dependencies = [ [[package]] name = "poem-derive" -version = "3.0.4" +version = "3.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a62fea1692d80a000126f9b28d865012a160b80000abb53ccf152b428222c155" +checksum = "cdfed15c1102d2a9a51b9f1aba945628c72ccb52fc5d3e4ad4ffbbd222e11821" dependencies = [ "proc-macro-crate", "proc-macro2", "quote", - "syn 2.0.76", + "syn 2.0.79", ] [[package]] @@ -1632,9 +1617,9 @@ dependencies = [ [[package]] name = "proc-macro-crate" -version = "3.1.0" +version = "3.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6d37c51ca738a55da99dc0c4a34860fd675453b8b36209178c2249bb13651284" +checksum = "8ecf48c7ca261d60b74ab1a7b20da18bede46776b2e55535cb958eb595c5fa7b" dependencies = [ "toml_edit", ] @@ -1698,18 +1683,9 @@ dependencies = [ [[package]] name = "redox_syscall" -version = "0.4.1" +version = "0.5.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4722d768eff46b75989dd134e5c353f0d6296e5aaa3132e776cbdb56be7731aa" -dependencies = [ - "bitflags 1.3.2", -] - -[[package]] -name = "redox_syscall" -version = "0.5.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2a908a6e00f1fdd0dfd9c0eb08ce85126f6d8bbda50017e74bc4a4b7d4a926a4" +checksum = "355ae415ccd3a04315d3f8246e86d67689ea74d88d915576e1589a351062a13b" dependencies = [ "bitflags 2.6.0", ] @@ -1852,18 +1828,18 @@ checksum = "719b953e2095829ee67db738b3bfa9fa368c94900df327b3f07fe6e794d2fe1f" [[package]] name = "rustc_version" -version = "0.4.0" +version = "0.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bfa0f585226d2e68097d4f95d113b15b83a82e819ab25717ec0590d9584ef366" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" dependencies = [ "semver", ] [[package]] name = "rustix" -version = "0.38.34" +version = "0.38.37" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "70dc5ec042f7a43c4a73241207cecc9873a06d45debb38b329f8541d85c2730f" +checksum = "8acb788b847c24f28525660c4d7758620a7210875711f79e7f663cc152726811" dependencies = [ "bitflags 2.6.0", "errno", @@ -1886,14 +1862,14 @@ dependencies = [ [[package]] name = "rustls" -version = "0.23.12" +version = "0.23.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c58f8c84392efc0a126acce10fa59ff7b3d2ac06ab451a33f2741989b806b044" +checksum = "f2dabaac7466917e566adb06783a81ca48944c6898a1b08b9374106dd671f4c8" dependencies = [ "once_cell", "ring 0.17.8", "rustls-pki-types", - "rustls-webpki 0.102.6", + "rustls-webpki 0.102.8", "subtle", "zeroize", ] @@ -1919,9 +1895,9 @@ dependencies = [ [[package]] name = "rustls-pki-types" -version = "1.8.0" +version = "1.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fc0a2ce646f8655401bb81e7927b812614bd5d91dbc968696be50603510fcaf0" +checksum = "0e696e35370c65c9c541198af4543ccd580cf17fc25d8e05c5a242b202488c55" [[package]] name = "rustls-webpki" @@ -1935,9 +1911,9 @@ dependencies = [ [[package]] name = "rustls-webpki" -version = "0.102.6" +version = "0.102.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e6b52d4fda176fd835fdc55a835d4a89b8499cad995885a21149d5ad62f852e" +checksum = "64ca1bc8749bd4cf37b5ce386cc146580777b4e8572c7b97baf22c83f444bee9" dependencies = [ "ring 0.17.8", "rustls-pki-types", @@ -2004,9 +1980,9 @@ checksum = "cd0b0ec5f1c1ca621c432a25813d8d60c88abe6d3e08a3eb9cf37d97a0fe3d73" [[package]] name = "serde" -version = "1.0.209" +version = "1.0.210" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "99fce0ffe7310761ca6bf9faf5115afbc19688edd00171d81b1bb1b116c63e09" +checksum = "c8e3592472072e6e22e0a54d5904d9febf8508f65fb8552499a1abc7d1078c3a" dependencies = [ "serde_derive", ] @@ -2024,20 +2000,20 @@ dependencies = [ [[package]] name = "serde_derive" -version = "1.0.209" +version = "1.0.210" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a5831b979fd7b5439637af1752d535ff49f4860c0f341d1baeb6faf0f4242170" +checksum = "243902eda00fad750862fc144cea25caca5e20d615af0a81bee94ca738f1df1f" dependencies = [ "proc-macro2", "quote", - "syn 2.0.76", + "syn 2.0.79", ] [[package]] name = "serde_json" -version = "1.0.127" +version = "1.0.128" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8043c06d9f82bd7271361ed64f415fe5e12a77fdb52e573e7f06a516dea329ad" +checksum = "6ff5456707a1de34e7e37f2a6fd3d3f808c318259cbd01ab6377795054b483d8" dependencies = [ "itoa", "memchr", @@ -2053,7 +2029,7 @@ checksum = "6c64451ba24fc7a6a2d60fc75dd9c83c90903b19028d4eff35e88fc1e86564e9" dependencies = [ "proc-macro2", "quote", - "syn 2.0.76", + "syn 2.0.79", ] [[package]] @@ -2078,7 +2054,7 @@ dependencies = [ "chrono", "hex", "indexmap 1.9.3", - "indexmap 2.4.0", + "indexmap 2.5.0", "serde", "serde_derive", "serde_json", @@ -2095,7 +2071,7 @@ dependencies = [ "darling", "proc-macro2", "quote", - "syn 2.0.76", + "syn 2.0.79", ] [[package]] @@ -2213,9 +2189,9 @@ dependencies = [ [[package]] name = "sqlformat" -version = "0.2.4" +version = "0.2.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f895e3734318cc55f1fe66258926c9b910c124d47520339efecbb6c59cec7c1f" +checksum = "7bba3a93db0cc4f7bdece8bb09e77e2e785c20bfebf79eb8340ed80708048790" dependencies = [ "nom", "unicode_categories", @@ -2223,9 +2199,9 @@ dependencies = [ [[package]] name = "sqlx" -version = "0.8.1" +version = "0.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fcfa89bea9500db4a0d038513d7a060566bfc51d46d1c014847049a45cce85e8" +checksum = "93334716a037193fac19df402f8571269c84a00852f6a7066b5d2616dcd64d3e" dependencies = [ "sqlx-core", "sqlx-macros", @@ -2236,9 +2212,9 @@ dependencies = [ [[package]] name = "sqlx-core" -version = "0.8.1" +version = "0.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d06e2f2bd861719b1f3f0c7dbe1d80c30bf59e76cf019f07d9014ed7eefb8e08" +checksum = "d4d8060b456358185f7d50c55d9b5066ad956956fddec42ee2e8567134a8936e" dependencies = [ "atoi", "bigdecimal", @@ -2257,14 +2233,14 @@ dependencies = [ "hashbrown 0.14.5", "hashlink", "hex", - "indexmap 2.4.0", + "indexmap 2.5.0", "ipnetwork", "log", "memchr", "once_cell", "paste", "percent-encoding", - "rustls 0.23.12", + "rustls 0.23.13", "rustls-pemfile 2.1.3", "serde", "serde_json", @@ -2276,27 +2252,27 @@ dependencies = [ "tokio-stream", "tracing", "url", - "webpki-roots 0.26.3", + "webpki-roots 0.26.6", ] [[package]] name = "sqlx-macros" -version = "0.8.1" +version = "0.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2f998a9defdbd48ed005a89362bd40dd2117502f15294f61c8d47034107dbbdc" +checksum = "cac0692bcc9de3b073e8d747391827297e075c7710ff6276d9f7a1f3d58c6657" dependencies = [ "proc-macro2", "quote", "sqlx-core", "sqlx-macros-core", - "syn 2.0.76", + "syn 2.0.79", ] [[package]] name = "sqlx-macros-core" -version = "0.8.1" +version = "0.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3d100558134176a2629d46cec0c8891ba0be8910f7896abfdb75ef4ab6f4e7ce" +checksum = "1804e8a7c7865599c9c79be146dc8a9fd8cc86935fa641d3ea58e5f0688abaa5" dependencies = [ "dotenvy", "either", @@ -2312,7 +2288,7 @@ dependencies = [ "sqlx-mysql", "sqlx-postgres", "sqlx-sqlite", - "syn 2.0.76", + "syn 2.0.79", "tempfile", "tokio", "url", @@ -2320,9 +2296,9 @@ dependencies = [ [[package]] name = "sqlx-mysql" -version = "0.8.1" +version = "0.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "936cac0ab331b14cb3921c62156d913e4c15b74fb6ec0f3146bd4ef6e4fb3c12" +checksum = "64bb4714269afa44aef2755150a0fc19d756fb580a67db8885608cf02f47d06a" dependencies = [ "atoi", "base64 0.22.1", @@ -2364,9 +2340,9 @@ dependencies = [ [[package]] name = "sqlx-pg-uint" -version = "0.5.0" +version = "0.7.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ae1cfe6c40c1cd0053b9029a41729a533ceb32093052df626aa8bfbba45e45f6" +checksum = "60e5ec2fd2d274ebf9ad6b44b3986f9bcdbb554bb162c4b1ac4af05a439c66f2" dependencies = [ "bigdecimal", "serde", @@ -2377,19 +2353,19 @@ dependencies = [ [[package]] name = "sqlx-pg-uint-macros" -version = "0.4.0" +version = "0.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0ae3447aced07f8bc71d73dc8dd1c6d25c2f4d10ea62a22ceabc12af8410d7e2" +checksum = "0e527060e9f43479e5b386e4237ab320a36fce39394f6ed73c8870f4637f2e5f" dependencies = [ "quote", - "syn 2.0.76", + "syn 2.0.79", ] [[package]] name = "sqlx-postgres" -version = "0.8.1" +version = "0.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9734dbce698c67ecf67c442f768a5e90a49b2a4d61a9f1d59f73874bd4cf0710" +checksum = "6fa91a732d854c5d7726349bb4bb879bb9478993ceb764247660aee25f67c2f8" dependencies = [ "atoi", "base64 0.22.1", @@ -2429,9 +2405,9 @@ dependencies = [ [[package]] name = "sqlx-sqlite" -version = "0.8.1" +version = "0.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a75b419c3c1b1697833dd927bdc4c6545a620bc1bbafabd44e1efbe9afcd337e" +checksum = "d5b2cf34a45953bfd3daaf3db0f7a7878ab9b7a6b91b422d24a7a9e4c857b680" dependencies = [ "atoi", "chrono", @@ -2487,9 +2463,9 @@ dependencies = [ [[package]] name = "syn" -version = "2.0.76" +version = "2.0.79" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "578e081a14e0cefc3279b0472138c513f37b41a08d5a3cca9b6e4e8ceb6cd525" +checksum = "89132cd0bf050864e1d38dc3bbc07a0eb8e7530af26344d3d2bbbef83499f590" dependencies = [ "proc-macro2", "quote", @@ -2534,9 +2510,9 @@ dependencies = [ [[package]] name = "tempfile" -version = "3.12.0" +version = "3.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "04cbcdd0c794ebb0d4cf35e88edd2f7d2c4c3e9a5a6dab322839b321c6a87a64" +checksum = "f0f2c9fc62d0beef6951ccffd757e241266a2c833136efbe35af6cd2567dca5b" dependencies = [ "cfg-if", "fastrand", @@ -2547,22 +2523,22 @@ dependencies = [ [[package]] name = "thiserror" -version = "1.0.63" +version = "1.0.64" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c0342370b38b6a11b6cc11d6a805569958d54cfa061a29969c3b5ce2ea405724" +checksum = "d50af8abc119fb8bb6dbabcfa89656f46f84aa0ac7688088608076ad2b459a84" dependencies = [ "thiserror-impl", ] [[package]] name = "thiserror-impl" -version = "1.0.63" +version = "1.0.64" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a4558b58466b9ad7ca0f102865eccc95938dca1a74a856f2b57b6629050da261" +checksum = "08904e7672f5eb876eaaf87e0ce17857500934f4981c4a0ab2b4aa98baac7fc3" dependencies = [ "proc-macro2", "quote", - "syn 2.0.76", + "syn 2.0.79", ] [[package]] @@ -2613,9 +2589,9 @@ checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" [[package]] name = "tokio" -version = "1.39.3" +version = "1.40.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9babc99b9923bfa4804bd74722ff02c0381021eafa4db9949217e3be8e84fff5" +checksum = "e2b070231665d27ad9ec9b8df639893f46727666c6767db40317fbe920a5d998" dependencies = [ "backtrace", "bytes", @@ -2635,7 +2611,7 @@ checksum = "693d596312e88961bc67d7f1f97af8a70227d9f90c31bba5806eec004978d752" dependencies = [ "proc-macro2", "quote", - "syn 2.0.76", + "syn 2.0.79", ] [[package]] @@ -2650,9 +2626,9 @@ dependencies = [ [[package]] name = "tokio-stream" -version = "0.1.15" +version = "0.1.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "267ac89e0bec6e691e5813911606935d77c476ff49024f98abcea3e7b15e37af" +checksum = "4f4e6ce100d0eb49a2734f8c0812bcd324cf357d21810932c5df6b96ef2b86f1" dependencies = [ "futures-core", "pin-project-lite", @@ -2676,9 +2652,9 @@ dependencies = [ [[package]] name = "tokio-util" -version = "0.7.11" +version = "0.7.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9cf6b47b3771c49ac75ad09a6162f53ad4b8088b76ac60e8ec1455b31a189fe1" +checksum = "61e7c3654c13bcd040d4a03abee2c75b1d14a37b423cf5a813ceae1cc903ec6a" dependencies = [ "bytes", "futures-core", @@ -2695,11 +2671,11 @@ checksum = "0dd7358ecb8fc2f8d014bf86f6f638ce72ba252a2c3a2572f2a795f1d23efb41" [[package]] name = "toml_edit" -version = "0.21.1" +version = "0.22.22" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6a8534fd7f78b5405e860340ad6575217ce99f38d4d5c8f2442cb5ecb50090e1" +checksum = "4ae48d6208a266e853d946088ed816055e556cc6028c5e8e2b84d9fa5dd7c7f5" dependencies = [ - "indexmap 2.4.0", + "indexmap 2.5.0", "toml_datetime", "winnow", ] @@ -2730,7 +2706,7 @@ checksum = "34704c8d6ebcbc939824180af020566b01a7c01f80641264eba0999f6c2b6be7" dependencies = [ "proc-macro2", "quote", - "syn 2.0.76", + "syn 2.0.79", ] [[package]] @@ -2800,15 +2776,15 @@ checksum = "08f95100a766bf4f8f28f90d77e0a5461bbdb219042e7679bebe79004fed8d75" [[package]] name = "unicode-ident" -version = "1.0.12" +version = "1.0.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3354b9ac3fae1ff6755cb6db53683adb661634f67557942dea4facebec0fee4b" +checksum = "e91b56cd4cadaeb79bbf1a5645f6b4f8dc5bde8834ad5894a8db35fda9efa1fe" [[package]] name = "unicode-normalization" -version = "0.1.23" +version = "0.1.24" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a56d1686db2308d901306f92a263857ef59ea39678a5458e7cb17f01415101f5" +checksum = "5033c97c4262335cded6d6fc3e5c18ab755e1a3dc96376350f3d8e9f009ad956" dependencies = [ "tinyvec", ] @@ -2929,7 +2905,7 @@ dependencies = [ "once_cell", "proc-macro2", "quote", - "syn 2.0.76", + "syn 2.0.79", "wasm-bindgen-shared", ] @@ -2963,7 +2939,7 @@ checksum = "afc340c74d9005395cf9dd098506f7f44e38f2b4a21c6aaacf9a105ea5e1e836" dependencies = [ "proc-macro2", "quote", - "syn 2.0.76", + "syn 2.0.79", "wasm-bindgen-backend", "wasm-bindgen-shared", ] @@ -2997,7 +2973,7 @@ checksum = "4b8220be1fa9e4c889b30fd207d4906657e7e90b12e0e6b0c8b8d8709f5de021" dependencies = [ "proc-macro2", "quote", - "syn 2.0.76", + "syn 2.0.79", ] [[package]] @@ -3032,20 +3008,20 @@ checksum = "5f20c57d8d7db6d3b86154206ae5d8fba62dd39573114de97c2cb0578251f8e1" [[package]] name = "webpki-roots" -version = "0.26.3" +version = "0.26.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bd7c23921eeb1713a4e851530e9b9756e4fb0e89978582942612524cf09f01cd" +checksum = "841c67bff177718f1d4dfefde8d8f0e78f9b6589319ba88312f567fc5841a958" dependencies = [ "rustls-pki-types", ] [[package]] name = "whoami" -version = "1.5.1" +version = "1.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a44ab49fad634e88f55bf8f9bb3abd2f27d7204172a112c7c9987e01c1c94ea9" +checksum = "372d5b87f58ec45c384ba03563b03544dc5fadc3983e434b286913f5b4a9bb6d" dependencies = [ - "redox_syscall 0.4.1", + "redox_syscall", "wasite", ] @@ -3245,9 +3221,9 @@ checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" [[package]] name = "winnow" -version = "0.5.40" +version = "0.6.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f593a95398737aeed53e489c785df13f3618e41dbcd6718c6addbf1395aa6876" +checksum = "36c1fec1a2bb5866f07c25f68c26e565c4c200aebb96d7e55710c19d3e8ac49b" dependencies = [ "memchr", ] @@ -3299,7 +3275,7 @@ checksum = "fa4f8080344d4671fb4e831a13ad1e68092748387dfc4f55e356242fae12ce3e" dependencies = [ "proc-macro2", "quote", - "syn 2.0.76", + "syn 2.0.79", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 6d4830ed..675a4662 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,7 +1,7 @@ [package] name = "chorus" description = "A library for interacting with multiple Spacebar-compatible Instances at once." -version = "0.16.0" +version = "0.17.0" license = "MPL-2.0" edition = "2021" repository = "https://github.com/polyphony-chat/chorus" @@ -49,7 +49,7 @@ jsonwebtoken = "8.3.0" log = "0.4.22" async-trait = "0.1.81" chorus-macros = { path = "./chorus-macros", version = "0" } # Note: version here is used when releasing. This will use the latest release. Make sure to republish the crate when code in macros is changed! -sqlx = { version = "0.8.1", features = [ +sqlx = { version = "0.8.2", features = [ "json", "chrono", "ipnetwork", @@ -67,7 +67,7 @@ rand = "0.8.5" flate2 = { version = "1.0.33", optional = true } webpki-roots = "0.26.3" pubserve = { version = "1.1.0", features = ["async", "send"] } -sqlx-pg-uint = { version = "0.5.0", features = ["serde"], optional = true } +sqlx-pg-uint = { version = "0.7.2", features = ["serde"], optional = true } [target.'cfg(not(target_arch = "wasm32"))'.dependencies] rustls = "0.21.12" diff --git a/README.md b/README.md index afed9b2c..cdac65d4 100644 --- a/README.md +++ b/README.md @@ -45,7 +45,7 @@ To get started with Chorus, import it into your project by adding the following ```toml [dependencies] -chorus = "0.16.0" +chorus = "0.17.0" ``` ### Establishing a Connection diff --git a/src/api/auth/login.rs b/src/api/auth/login.rs index 3a9a9eeb..ab78a995 100644 --- a/src/api/auth/login.rs +++ b/src/api/auth/login.rs @@ -38,7 +38,7 @@ impl Instance { user.set_token(&login_result.token); user.settings = login_result.settings; - let object = User::get(&mut user, None).await?; + let object = User::get_current(&mut user).await?; *user.object.write().unwrap() = object; let mut identify = GatewayIdentifyPayload::common(); diff --git a/src/api/auth/mod.rs b/src/api/auth/mod.rs index b9050e14..96491351 100644 --- a/src/api/auth/mod.rs +++ b/src/api/auth/mod.rs @@ -25,7 +25,7 @@ impl Instance { pub async fn login_with_token(&mut self, token: &str) -> ChorusResult { let mut user = ChorusUser::shell(Arc::new(RwLock::new(self.clone())), token).await; - let object = User::get(&mut user, None).await?; + let object = User::get_current(&mut user).await?; let settings = User::get_settings(&mut user).await?; *user.object.write().unwrap() = object; diff --git a/src/api/auth/register.rs b/src/api/auth/register.rs index 821a52ff..d978e0ff 100644 --- a/src/api/auth/register.rs +++ b/src/api/auth/register.rs @@ -43,9 +43,10 @@ impl Instance { .deserialize_response::(&mut user) .await? .token; + user.set_token(&token); - let object = User::get(&mut user, None).await?; + let object = User::get_current(&mut user).await?; let settings = User::get_settings(&mut user).await?; *user.object.write().unwrap() = object; diff --git a/src/api/instance.rs b/src/api/instance.rs new file mode 100644 index 00000000..20680e41 --- /dev/null +++ b/src/api/instance.rs @@ -0,0 +1,104 @@ +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/. + +//! Contains miscellaneous api routes, such as /version and /ping +use serde_json::from_str; + +use crate::errors::{ChorusError, ChorusResult}; +use crate::instance::Instance; +use crate::types::{GeneralConfiguration, PingReturn, VersionReturn}; + +impl Instance { + /// Pings the instance, also fetches instance info. + /// + /// See: [PingReturn] + /// + /// # Notes + /// This is a Spacebar only endpoint. + /// + /// # Reference + /// See + pub async fn ping(&self) -> ChorusResult { + let endpoint_url = format!("{}/ping", self.urls.api.clone()); + + let request = match self.client.get(&endpoint_url).send().await { + Ok(result) => result, + Err(e) => { + return Err(ChorusError::RequestFailed { + url: endpoint_url, + error: e.to_string(), + }); + } + }; + + if !request.status().as_str().starts_with('2') { + return Err(ChorusError::ReceivedErrorCode { + error_code: request.status().as_u16(), + error: request.text().await.unwrap(), + }); + } + + let response_text = match request.text().await { + Ok(string) => string, + Err(e) => { + return Err(ChorusError::InvalidResponse { + error: format!( + "Error while trying to process the HTTP response into a String: {}", + e + ), + }); + } + }; + + match from_str::(&response_text) { + Ok(return_value) => Ok(return_value), + Err(e) => Err(ChorusError::InvalidResponse { error: format!("Error while trying to deserialize the JSON response into requested type T: {}. JSON Response: {}", + e, response_text) }) + } + } + + /// Fetches the instance's software implementation and version. + /// + /// See: [VersionReturn] + /// + /// # Notes + /// This is a Symfonia only endpoint. (For now, we hope that spacebar will adopt it as well) + pub async fn get_version(&self) -> ChorusResult { + let endpoint_url = format!("{}/version", self.urls.api.clone()); + + let request = match self.client.get(&endpoint_url).send().await { + Ok(result) => result, + Err(e) => { + return Err(ChorusError::RequestFailed { + url: endpoint_url, + error: e.to_string(), + }); + } + }; + + if !request.status().as_str().starts_with('2') { + return Err(ChorusError::ReceivedErrorCode { + error_code: request.status().as_u16(), + error: request.text().await.unwrap(), + }); + } + + let response_text = match request.text().await { + Ok(string) => string, + Err(e) => { + return Err(ChorusError::InvalidResponse { + error: format!( + "Error while trying to process the HTTP response into a String: {}", + e + ), + }); + } + }; + + match from_str::(&response_text) { + Ok(return_value) => Ok(return_value), + Err(e) => Err(ChorusError::InvalidResponse { error: format!("Error while trying to deserialize the JSON response into requested type T: {}. JSON Response: {}", e, response_text) }) + } + } +} diff --git a/src/api/mod.rs b/src/api/mod.rs index c9ca2792..5e2f7cab 100644 --- a/src/api/mod.rs +++ b/src/api/mod.rs @@ -10,6 +10,7 @@ pub use guilds::*; pub use invites::*; pub use policies::instance::instance::*; pub use users::*; +pub use instance::*; pub mod auth; pub mod channels; @@ -17,3 +18,4 @@ pub mod guilds; pub mod invites; pub mod policies; pub mod users; +pub mod instance; diff --git a/src/api/policies/instance/instance.rs b/src/api/policies/instance/instance.rs index 584db338..f2a40bc1 100644 --- a/src/api/policies/instance/instance.rs +++ b/src/api/policies/instance/instance.rs @@ -17,7 +17,7 @@ impl Instance { /// # Reference /// See pub async fn general_configuration_schema(&self) -> ChorusResult { - let endpoint_url = self.urls.api.clone() + "/policies/instance"; + let endpoint_url = self.urls.api.clone() + "/policies/instance/"; let request = match self.client.get(&endpoint_url).send().await { Ok(result) => result, Err(e) => { @@ -35,7 +35,28 @@ impl Instance { }); } - let body = request.text().await.unwrap(); - Ok(from_str::(&body).unwrap()) + let response_text = match request.text().await { + Ok(string) => string, + Err(e) => { + return Err(ChorusError::InvalidResponse { + error: format!( + "Error while trying to process the HTTP response into a String: {}", + e + ), + }); + } + }; + + match from_str::(&response_text) { + Ok(object) => Ok(object), + Err(e) => { + Err(ChorusError::InvalidResponse { + error: format!( + "Error while trying to deserialize the JSON response into requested type T: {}. JSON Response: {}", + e, response_text + ), + }) + } + } } } diff --git a/src/api/users/connections.rs b/src/api/users/connections.rs new file mode 100644 index 00000000..29c40df1 --- /dev/null +++ b/src/api/users/connections.rs @@ -0,0 +1,391 @@ +use futures_util::FutureExt; +use reqwest::Client; + +use crate::{ + errors::{ChorusError, ChorusResult}, + instance::ChorusUser, + ratelimiter::ChorusRequest, + types::{ + AuthorizeConnectionReturn, AuthorizeConnectionSchema, Connection, ConnectionSubreddit, + ConnectionType, CreateConnectionCallbackSchema, CreateContactSyncConnectionSchema, + CreateDomainConnectionError, CreateDomainConnectionReturn, GetConnectionAccessTokenReturn, + LimitType, ModifyConnectionSchema, + }, +}; + +impl ChorusUser { + /// Fetches a url that can be used for authorizing a new connection. + /// + /// The user should then visit the url and authenticate to create the connection. + /// + /// # Notes + /// This route seems to be preferred by the official infrastructure (client) to + /// [Self::create_connection_callback]. + /// + /// # Reference + /// See + /// + /// Note: it doesn't seem to be actually unauthenticated + pub async fn authorize_connection( + &mut self, + connection_type: ConnectionType, + query_parameters: AuthorizeConnectionSchema, + ) -> ChorusResult { + let connection_type_string = serde_json::to_string(&connection_type) + .expect("Failed to serialize connection type!") + .replace('"', ""); + + let request = Client::new() + .get(format!( + "{}/connections/{}/authorize", + self.belongs_to.read().unwrap().urls.api, + connection_type_string + )) + // Note: ommiting this header causes a 401 Unauthorized, + // even though discord.sex mentions it as unauthenticated + .header("Authorization", self.token()) + .query(&query_parameters); + + let chorus_request = ChorusRequest { + request, + limit_type: LimitType::default(), + }; + + chorus_request + .deserialize_response::(self) + .await + .map(|response| response.url) + } + + /// Creates a new connection for the current user. + /// + /// # Notes + /// The official infrastructure (client) prefers the route + /// [Self::authorize_connection] to this one. + /// + /// # Reference + /// See + // TODO: When is this called? When should it be used over authorize_connection? + pub async fn create_connection_callback( + &mut self, + connection_type: ConnectionType, + json_schema: CreateConnectionCallbackSchema, + ) -> ChorusResult { + let connection_type_string = serde_json::to_string(&connection_type) + .expect("Failed to serialize connection type!") + .replace('"', ""); + + let request = Client::new() + .post(format!( + "{}/connections/{}/callback", + self.belongs_to.read().unwrap().urls.api, + connection_type_string + )) + .header("Authorization", self.token()) + .json(&json_schema); + + let chorus_request = ChorusRequest { + request, + limit_type: LimitType::default(), + }; + + chorus_request.deserialize_response(self).await + } + + /// Creates a new contact sync connection for the current user. + /// + /// # Notes + /// To create normal connection types, see [Self::authorize_connection] and + /// [Self::create_connection_callback] + /// + /// # Reference + /// See + pub async fn create_contact_sync_connection( + &mut self, + connection_account_id: &String, + json_schema: CreateContactSyncConnectionSchema, + ) -> ChorusResult { + let request = Client::new() + .put(format!( + "{}/users/@me/connections/contacts/{}", + self.belongs_to.read().unwrap().urls.api, + connection_account_id + )) + .header("Authorization", self.token()) + .json(&json_schema); + + let chorus_request = ChorusRequest { + request, + limit_type: LimitType::default(), + }; + + chorus_request.deserialize_response(self).await + } + + /// Creates a new domain connection for the current user. + /// + /// This route has two possible successful return values: + /// [CreateDomainConnectionReturn::Ok] and [CreateDomainConnectionReturn::ProofNeeded] + /// + /// To properly handle both, please see their respective documentation pages. + /// + /// # Notes + /// To create normal connection types, see [Self::authorize_connection] and + /// [Self::create_connection_callback] + /// + /// As of 2024/08/21, Spacebar does not yet implement this endpoint. + /// + /// # Examples + /// ```no_run + /// let domain = "example.com".to_string(); + /// + /// let user: ChorusUser; // Get this by registering / logging in + /// + /// let result = user.create_domain_connection(&domain).await; + /// + /// if let Ok(returned) = result { + /// match returned { + /// CreateDomainConnectionReturn::ProofNeeded(proof) => { + /// println!("Additional proof needed!"); + /// println!("Either:"); + /// println!(""); + /// println!("- create a DNS TXT record with the name _discord.{domain} and content {proof}"); + /// println!("or"); + /// println!("- create a file at https://{domain}/.well-known/discord with the content {proof}"); + /// // Once the user has added the proof, retry calling the endpoint + /// } + /// CreateDomainConnectionReturn::Ok(connection) => { + /// println!("Successfulyl created connection! {:?}", connection); + /// } + /// } + /// } else { + /// println!("Failed to create connection: {:?}", result); + /// } + /// ``` + /// + /// # Reference + /// See + pub async fn create_domain_connection( + &mut self, + domain: &String, + ) -> ChorusResult { + let request = Client::new() + .post(format!( + "{}/users/@me/connections/domain/{}", + self.belongs_to.read().unwrap().urls.api, + domain + )) + .header("Authorization", self.token()); + + let chorus_request = ChorusRequest { + request, + limit_type: LimitType::default(), + }; + + let result = chorus_request + .deserialize_response::(self) + .await; + + if let Ok(connection) = result { + return Ok(CreateDomainConnectionReturn::Ok(connection)); + } + + let error = result.err().unwrap(); + + if let ChorusError::ReceivedErrorCode { + error_code, + error: ref error_string, + } = error + { + if error_code == 400 { + let try_deserialize: Result = + serde_json::from_str(error_string); + + if let Ok(deserialized_error) = try_deserialize { + return Ok(CreateDomainConnectionReturn::ProofNeeded( + deserialized_error.proof, + )); + } + } + } + + Err(error) + } + + /// Fetches the current user's [Connection]s + /// + /// # Reference + /// See + pub async fn get_connections(&mut self) -> ChorusResult> { + let request = Client::new() + .get(format!( + "{}/users/@me/connections", + self.belongs_to.read().unwrap().urls.api, + )) + .header("Authorization", self.token()); + + let chorus_request = ChorusRequest { + request, + limit_type: LimitType::default(), + }; + + chorus_request.deserialize_response(self).await + } + + /// Refreshes a local user's [Connection]. + /// + /// # Reference + /// See + pub async fn refresh_connection( + &mut self, + connection_type: ConnectionType, + connection_account_id: &String, + ) -> ChorusResult<()> { + let connection_type_string = serde_json::to_string(&connection_type) + .expect("Failed to serialize connection type!") + .replace('"', ""); + + let request = Client::new() + .post(format!( + "{}/users/@me/connections/{}/{}/refresh", + self.belongs_to.read().unwrap().urls.api, + connection_type_string, + connection_account_id + )) + .header("Authorization", self.token()); + + let chorus_request = ChorusRequest { + request, + limit_type: LimitType::default(), + }; + + chorus_request.handle_request_as_result(self).await + } + + /// Changes settings on a local user's [Connection]. + /// + /// # Notes + /// Not all connection types support all parameters. + /// + /// # Reference + /// See + pub async fn modify_connection( + &mut self, + connection_type: ConnectionType, + connection_account_id: &String, + json_schema: ModifyConnectionSchema, + ) -> ChorusResult { + let connection_type_string = serde_json::to_string(&connection_type) + .expect("Failed to serialize connection type!") + .replace('"', ""); + + let request = Client::new() + .patch(format!( + "{}/users/@me/connections/{}/{}", + self.belongs_to.read().unwrap().urls.api, + connection_type_string, + connection_account_id + )) + .header("Authorization", self.token()) + .json(&json_schema); + + let chorus_request = ChorusRequest { + request, + limit_type: LimitType::default(), + }; + + chorus_request.deserialize_response(self).await + } + + /// Deletes a local user's [Connection]. + /// + /// # Reference + /// See + pub async fn delete_connection( + &mut self, + connection_type: ConnectionType, + connection_account_id: &String, + ) -> ChorusResult<()> { + let connection_type_string = serde_json::to_string(&connection_type) + .expect("Failed to serialize connection type!") + .replace('"', ""); + + let request = Client::new() + .delete(format!( + "{}/users/@me/connections/{}/{}", + self.belongs_to.read().unwrap().urls.api, + connection_type_string, + connection_account_id + )) + .header("Authorization", self.token()); + + let chorus_request = ChorusRequest { + request, + limit_type: LimitType::default(), + }; + + chorus_request.handle_request_as_result(self).await + } + + /// Returns a new access token for the given connection. + /// + /// Only available for [ConnectionType::Twitch], [ConnectionType::YouTube] and [ConnectionType::Spotify] connections. + /// + /// # Reference + /// See + pub async fn get_connection_access_token( + &mut self, + connection_type: ConnectionType, + connection_account_id: &String, + ) -> ChorusResult { + let connection_type_string = serde_json::to_string(&connection_type) + .expect("Failed to serialize connection type!") + .replace('"', ""); + + let request = Client::new() + .get(format!( + "{}/users/@me/connections/{}/{}/access-token", + self.belongs_to.read().unwrap().urls.api, + connection_type_string, + connection_account_id + )) + .header("Authorization", self.token()); + + let chorus_request = ChorusRequest { + request, + limit_type: LimitType::default(), + }; + + chorus_request + .deserialize_response::(self) + .await + .map(|res| res.access_token) + } + + /// Fetches a list of [subreddits](crate::types::ConnectionSubreddit) + /// the connected account moderates. + /// + /// Only available for [ConnectionType::Reddit] connections. + /// + /// # Reference + /// See + pub async fn get_connection_subreddits( + &mut self, + connection_account_id: &String, + ) -> ChorusResult> { + let request = Client::new() + .get(format!( + "{}/users/@me/connections/reddit/{}/subreddits", + self.belongs_to.read().unwrap().urls.api, + connection_account_id + )) + .header("Authorization", self.token()); + + let chorus_request = ChorusRequest { + request, + limit_type: LimitType::default(), + }; + + chorus_request.deserialize_response(self).await + } +} diff --git a/src/api/users/mod.rs b/src/api/users/mod.rs index b11772ab..702233cb 100644 --- a/src/api/users/mod.rs +++ b/src/api/users/mod.rs @@ -4,11 +4,13 @@ #![allow(unused_imports)] pub use channels::*; +pub use connections::*; pub use guilds::*; pub use relationships::*; pub use users::*; pub mod channels; +pub mod connections; pub mod guilds; pub mod relationships; pub mod users; diff --git a/src/api/users/users.rs b/src/api/users/users.rs index 4f6ef579..483a85c1 100644 --- a/src/api/users/users.rs +++ b/src/api/users/users.rs @@ -2,7 +2,10 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. -use std::sync::{Arc, RwLock}; +use std::{ + collections::HashMap, + sync::{Arc, RwLock}, +}; use reqwest::Client; use serde_json::to_string; @@ -11,22 +14,69 @@ use crate::{ errors::{ChorusError, ChorusResult}, instance::{ChorusUser, Instance}, ratelimiter::ChorusRequest, - types::{LimitType, User, UserModifySchema, UserSettings}, + types::{ + AuthorizeConnectionSchema, BurstCreditsInfo, ConnectionType, CreateUserHarvestSchema, + DeleteDisableUserSchema, GetPomeloEligibilityReturn, GetPomeloSuggestionsReturn, + GetRecentMentionsSchema, GetUserProfileSchema, GuildAffinities, Harvest, + HarvestBackendType, LimitType, ModifyUserNoteSchema, PremiumUsage, PublicUser, Snowflake, + User, UserAffinities, UserModifyProfileSchema, UserModifySchema, UserNote, UserProfile, + UserProfileMetadata, UserSettings, VerifyUserEmailChangeResponse, + VerifyUserEmailChangeSchema, + }, }; impl ChorusUser { - /// Gets a user by id, or if the id is None, gets the current user. + /// Gets the local / current user. + /// + /// # Notes + /// This function is a wrapper around [`User::get_current`]. + /// + /// # Reference + /// See + pub async fn get_current_user(&mut self) -> ChorusResult { + User::get_current(self).await + } + + /// Gets a non-local user by their id /// /// # Notes /// This function is a wrapper around [`User::get`]. /// /// # Reference - /// See and - /// - pub async fn get_user(&mut self, id: Option<&String>) -> ChorusResult { + /// See + pub async fn get_user(&mut self, id: Snowflake) -> ChorusResult { User::get(self, id).await } + /// Gets a non-local user by their unique username. + /// + /// As of 2024/07/28, Spacebar does not yet implement this endpoint. + /// + /// If fetching with a pomelo username, discriminator should be set to None. + /// + /// This route also permits fetching users with their old pre-pomelo username#discriminator + /// combo. + /// + /// Note: + /// + /// "Unless the target user is a bot, you must be able to add + /// the user as a friend to resolve them by username. + /// + /// Due to this restriction, you are not able to resolve your own username." + /// + /// # Notes + /// This function is a wrapper around [`User::get_by_username`]. + /// + /// # Reference + /// See + pub async fn get_user_by_username( + &mut self, + username: &String, + discriminator: Option<&String>, + ) -> ChorusResult { + User::get_by_username(self, username, discriminator).await + } + /// Gets the user's settings. /// /// # Notes @@ -40,7 +90,6 @@ impl ChorusUser { /// # Reference /// See pub async fn modify(&mut self, modify_schema: UserModifySchema) -> ChorusResult { - // See , note 1 let requires_current_password = modify_schema.username.is_some() || modify_schema.discriminator.is_some() @@ -67,39 +116,583 @@ impl ChorusUser { chorus_request.deserialize_response::(self).await } - /// Deletes the user from the Instance. + /// Disables the current user's account. + /// + /// Invalidates all active tokens. + /// + /// Requires the user's current password (if any) + /// + /// # Notes + /// Requires MFA /// /// # Reference - /// See - pub async fn delete(mut self) -> ChorusResult<()> { + /// See + pub async fn disable(&mut self, schema: DeleteDisableUserSchema) -> ChorusResult<()> { + let request = Client::new() + .post(format!( + "{}/users/@me/disable", + self.belongs_to.read().unwrap().urls.api + )) + .header("Authorization", self.token()) + .json(&schema); + let chorus_request = ChorusRequest { + request, + limit_type: LimitType::default(), + }; + chorus_request.handle_request_as_result(self).await + } + + /// Deletes the current user from the Instance. + /// + /// Requires the user's current password (if any) + /// + /// # Notes + /// Requires MFA + /// + /// # Reference + /// See + pub async fn delete(&mut self, schema: DeleteDisableUserSchema) -> ChorusResult<()> { let request = Client::new() .post(format!( "{}/users/@me/delete", self.belongs_to.read().unwrap().urls.api )) .header("Authorization", self.token()) + .json(&schema); + let chorus_request = ChorusRequest { + request, + limit_type: LimitType::default(), + }; + chorus_request.handle_request_as_result(self).await + } + + /// Gets a user's profile object by their id. + /// + /// This endpoint requires one of the following: + /// + /// - The other user is a bot + /// - The other user shares a mutual guild with the current user + /// - The other user is a friend of the current user + /// - The other user is a friend suggestion of the current user + /// - The other user has an outgoing friend request to the current user + /// + /// # Notes + /// This function is a wrapper around [`User::get_profile`]. + /// + /// # Reference + /// See + pub async fn get_user_profile( + &mut self, + id: Snowflake, + query_parameters: GetUserProfileSchema, + ) -> ChorusResult { + User::get_profile(self, id, query_parameters).await + } + + /// Modifies the current user's profile. + /// + /// Returns the updated [UserProfileMetadata]. + /// + /// # Notes + /// This function is a wrapper around [`User::modify_profile`]. + /// + /// # Reference + /// See + pub async fn modify_profile( + &mut self, + schema: UserModifyProfileSchema, + ) -> ChorusResult { + User::modify_profile(self, schema).await + } + + /// Initiates the email change process. + /// + /// Sends a verification code to the current user's email. + /// + /// Should be followed up with [Self::verify_email_change] + /// + /// # Reference + /// See + pub async fn initiate_email_change(&mut self) -> ChorusResult<()> { + let request = Client::new() + .put(format!( + "{}/users/@me/email", + self.belongs_to.read().unwrap().urls.api + )) + .header("Authorization", self.token()); + let chorus_request = ChorusRequest { + request, + limit_type: LimitType::default(), + }; + chorus_request.handle_request_as_result(self).await + } + + /// Verifies a code sent to change the current user's email. + /// + /// This endpoint returns a token which can be used with [Self::modify] + /// to set a new email address (email_token). + /// + /// # Notes + /// Should be the follow-up to [Self::initiate_email_change] + /// + /// As of 2024/08/08, Spacebar does not yet implement this endpoint. + // FIXME: Does this mean PUT users/@me/email is different? + /// + /// # Reference + /// See + pub async fn verify_email_change( + &mut self, + schema: VerifyUserEmailChangeSchema, + ) -> ChorusResult { + let request = Client::new() + .post(format!( + "{}/users/@me/email/verify-code", + self.belongs_to.read().unwrap().urls.api + )) + .header("Authorization", self.token()) + .json(&schema); + let chorus_request = ChorusRequest { + request, + limit_type: LimitType::default(), + }; + chorus_request + .deserialize_response::(self) + .await + } + + /// Returns a suggested unique username based on the current user's username. + /// + /// # Notes: + /// "This endpoint is used during the pomelo migration flow. + /// + /// The user must be in the rollout to use this endpoint." + /// + /// If a user has already migrated, this endpoint will likely return a 401 Unauthorized + /// ([ChorusError::NoPermission]) + /// + /// As of 2024/08/08, Spacebar does not yet implement this endpoint. + /// + /// # Reference + /// See + pub async fn get_pomelo_suggestions(&mut self) -> ChorusResult { + let request = Client::new() + .get(format!( + "{}/users/@me/pomelo-suggestions", + self.belongs_to.read().unwrap().urls.api + )) + .header("Authorization", self.token()); + + let chorus_request = ChorusRequest { + request, + limit_type: LimitType::default(), + }; + chorus_request + .deserialize_response::(self) + .await + .map(|returned| returned.username) + } + + /// Checks whether a unique username is available. + /// + /// Returns whether the username is not taken yet. + /// + /// # Notes + /// As of 2024/08/08, Spacebar does not yet implement this endpoint. + /// + /// # Reference + /// See + pub async fn get_pomelo_eligibility(&mut self, username: &String) -> ChorusResult { + let request = Client::new() + .post(format!( + "{}/users/@me/pomelo-attempt", + self.belongs_to.read().unwrap().urls.api + )) + .header("Authorization", self.token()) + // FIXME: should we create a type for this? + .body(format!(r#"{{ "username": {:?} }}"#, username)) + .header("Content-Type", "application/json"); + + let chorus_request = ChorusRequest { + request, + limit_type: LimitType::default(), + }; + chorus_request + .deserialize_response::(self) + .await + .map(|returned| !returned.taken) + } + + /// Migrates the user from the username#discriminator to the unique username system. + /// + /// Fires a [UserUpdate](crate::types::UserUpdate) gateway event. + /// + /// Updates [Self::object] to an updated representation returned by the server. + // FIXME: Is this appropriate behaviour? + /// + /// # Notes + /// "This endpoint is used during the pomelo migration flow. + /// + /// The user must be in the rollout to use this endpoint." + /// + /// If a user has already migrated, this endpoint will likely return a 401 Unauthorized + /// ([ChorusError::NoPermission]) + // + /// As of 2024/08/08, Spacebar does not yet implement this endpoint. + /// + /// # Reference + /// See + pub async fn create_pomelo_migration(&mut self, username: &String) -> ChorusResult<()> { + let request = Client::new() + .post(format!( + "{}/users/@me/pomelo", + self.belongs_to.read().unwrap().urls.api + )) + .header("Authorization", self.token()) + // FIXME: should we create a type for this? + .body(format!(r#"{{ "username": {:?} }}"#, username)) .header("Content-Type", "application/json"); + + let chorus_request = ChorusRequest { + request, + limit_type: LimitType::default(), + }; + + let result = chorus_request.deserialize_response::(self).await; + + // FIXME: Does UserUpdate do this automatically? or would a user need to manually observe ChorusUser::object + if let Ok(new_object) = result { + *self.object.write().unwrap() = new_object; + return ChorusResult::Ok(()); + } + + ChorusResult::Err(result.err().unwrap()) + } + + /// Fetches a list of [Message](crate::types::Message)s that the current user has been + /// mentioned in during the last 7 days. + /// + /// # Notes + /// As of 2024/08/09, Spacebar does not yet implement this endpoint. + /// + /// # Reference + /// See + pub async fn get_recent_mentions( + &mut self, + query_parameters: GetRecentMentionsSchema, + ) -> ChorusResult> { + let request = Client::new() + .get(format!( + "{}/users/@me/mentions", + self.belongs_to.read().unwrap().urls.api + )) + .header("Authorization", self.token()) + .query(&query_parameters); + + let chorus_request = ChorusRequest { + request, + limit_type: LimitType::default(), + }; + + chorus_request + .deserialize_response::>(self) + .await + } + + /// Acknowledges a message the current user has been mentioned in. + /// + /// Fires a `RecentMentionDelete` gateway event. (Note: yet to be implemented in chorus, see [#545](https://github.com/polyphony-chat/chorus/issues/545)) + /// + /// # Notes + /// As of 2024/08/09, Spacebar does not yet implement this endpoint. + /// + /// # Reference + /// See + pub async fn delete_recent_mention(&mut self, message_id: Snowflake) -> ChorusResult<()> { + let request = Client::new() + .delete(format!( + "{}/users/@me/mentions/{}", + self.belongs_to.read().unwrap().urls.api, + message_id + )) + .header("Authorization", self.token()); + + let chorus_request = ChorusRequest { + request, + limit_type: LimitType::default(), + }; + + chorus_request.handle_request_as_result(self).await + } + + /// If it exists, returns the most recent [Harvest] (personal data harvest request). + /// + /// To create a new [Harvest], see [Self::create_harvest]. + /// + /// # Notes + /// As of 2024/08/09, Spacebar does not yet implement this endpoint. (Or data harvesting) + /// + /// # Reference + /// See + pub async fn get_harvest(&mut self) -> ChorusResult> { + let request = Client::new() + .get(format!( + "{}/users/@me/harvest", + self.belongs_to.read().unwrap().urls.api, + )) + .header("Authorization", self.token()); + + let chorus_request = ChorusRequest { + request, + limit_type: LimitType::default(), + }; + + // Manual handling, because a 204 with no harvest is a success state + // TODO: Maybe make this a method on ChorusRequest if we need it a lot + let response = chorus_request.send_request(self).await?; + log::trace!("Got response: {:?}", response); + + if response.status() == http::StatusCode::NO_CONTENT { + return Ok(None); + } + + let response_text = match response.text().await { + Ok(string) => string, + Err(e) => { + return Err(ChorusError::InvalidResponse { + error: format!( + "Error while trying to process the HTTP response into a String: {}", + e + ), + }); + } + }; + + let object = match serde_json::from_str::(&response_text) { + Ok(object) => object, + Err(e) => { + return Err(ChorusError::InvalidResponse { + error: format!( + "Error while trying to deserialize the JSON response into requested type T: {}. JSON Response: {}", + e, response_text + ), + }) + } + }; + Ok(Some(object)) + } + + /// Creates a personal data harvest request ([Harvest]) for the current user. + /// + /// # Notes + /// To fetch the latest existing harvest, see [Self::get_harvest]. + /// + /// Invalid options in the backends array are ignored. + /// + /// If the array is empty (after ignoring), it requests all [HarvestBackendType]s. + /// + /// As of 2024/08/09, Spacebar does not yet implement this endpoint. (Or data harvesting) + /// + /// # Reference + /// See + pub async fn create_harvest( + &mut self, + backends: Vec, + ) -> ChorusResult { + let schema = if backends.is_empty() { + CreateUserHarvestSchema { backends: None } + } else { + CreateUserHarvestSchema { + backends: Some(backends), + } + }; + + let request = Client::new() + .post(format!( + "{}/users/@me/harvest", + self.belongs_to.read().unwrap().urls.api, + )) + .header("Authorization", self.token()) + .json(&schema); + + let chorus_request = ChorusRequest { + request, + limit_type: LimitType::default(), + }; + + chorus_request.deserialize_response(self).await + } + + /// Returns a mapping of user IDs ([Snowflake]s) to notes ([String]s) for the current user. + /// + /// # Notes + /// As of 2024/08/21, Spacebar does not yet implement this endpoint. + /// + /// # Reference + /// See + pub async fn get_user_notes(&mut self) -> ChorusResult> { + let request = Client::new() + .get(format!( + "{}/users/@me/notes", + self.belongs_to.read().unwrap().urls.api, + )) + .header("Authorization", self.token()); + let chorus_request = ChorusRequest { request, limit_type: LimitType::default(), }; - chorus_request.handle_request_as_result(&mut self).await + + chorus_request.deserialize_response(self).await + } + + /// Fetches the note ([UserNote]) for the given user. + /// + /// If the current user has no note for the target, this endpoint + /// returns `Err(NotFound { error: "{\"message\": \"Unknown User\", \"code\": 10013}" })` + /// + /// # Notes + /// This function is a wrapper around [`User::get_note`]. + /// + /// # Reference + /// See + pub async fn get_user_note(&mut self, target_user_id: Snowflake) -> ChorusResult { + User::get_note(self, target_user_id).await + } + + /// Sets the note for the given user. + /// + /// Fires a `UserNoteUpdate` gateway event. (Note: yet to be implemented in chorus, see [#546](https://github.com/polyphony-chat/chorus/issues/546)) + /// + /// # Notes + /// This function is a wrapper around [`User::set_note`]. + /// + /// # Reference + /// See + pub async fn set_user_note( + &mut self, + target_user_id: Snowflake, + note: Option, + ) -> ChorusResult<()> { + User::set_note(self, target_user_id, note).await + } + + /// Fetches the current user's affinity scores for other users. + /// + /// (Affinity scores are a measure of how likely a user is to be friends with another user.) + /// + /// # Reference + /// See + pub async fn get_user_affinities(&mut self) -> ChorusResult { + let request = Client::new() + .get(format!( + "{}/users/@me/affinities/users", + self.belongs_to.read().unwrap().urls.api, + )) + .header("Authorization", self.token()); + + let chorus_request = ChorusRequest { + request, + limit_type: LimitType::default(), + }; + + chorus_request.deserialize_response(self).await + } + + /// Fetches the current user's affinity scores for their joined guilds. + /// + /// # Reference + /// See + pub async fn get_guild_affinities(&mut self) -> ChorusResult { + let request = Client::new() + .get(format!( + "{}/users/@me/affinities/guilds", + self.belongs_to.read().unwrap().urls.api, + )) + .header("Authorization", self.token()); + + let chorus_request = ChorusRequest { + request, + limit_type: LimitType::default(), + }; + + chorus_request.deserialize_response(self).await + } + + /// Fetches the current user's usage of various premium perks ([PremiumUsage] object). + /// + /// The local user must have premium (nitro), otherwise the request will fail + /// with a 404 NotFound error and the message {"message": "Premium usage not available", "code": 10084}. + /// + /// # Notes + /// As of 2024/08/16, Spacebar does not yet implement this endpoint. + /// + /// # Reference + /// See + pub async fn get_premium_usage(&mut self) -> ChorusResult { + let request = Client::new() + .get(format!( + "{}/users/@me/premium-usage", + self.belongs_to.read().unwrap().urls.api, + )) + .header("Authorization", self.token()); + + let chorus_request = ChorusRequest { + request, + limit_type: LimitType::default(), + }; + + chorus_request.deserialize_response(self).await + } + + /// Fetches info about the current user's burst credits + /// (how many are remaining, when they will replenish). + /// + /// Burst credits are used to create burst reactions. + /// + /// # Notes + /// As of 2024/08/18, Spacebar does not yet implement this endpoint. + pub async fn get_burst_credits(&mut self) -> ChorusResult { + let request = Client::new() + .get(format!( + "{}/users/@me/burst-credits", + self.belongs_to.read().unwrap().urls.api, + )) + .header("Authorization", self.token()); + + let chorus_request = ChorusRequest { + request, + limit_type: LimitType::default(), + }; + + chorus_request.deserialize_response(self).await } } impl User { - /// Gets a user by id, or if the id is None, gets the current user. + /// Gets the local / current user. /// /// # Reference - /// See and - /// - pub async fn get(user: &mut ChorusUser, id: Option<&String>) -> ChorusResult { + /// See + pub async fn get_current(user: &mut ChorusUser) -> ChorusResult { let url_api = user.belongs_to.read().unwrap().urls.api.clone(); - let url = if id.is_none() { - format!("{}/users/@me", url_api) - } else { - format!("{}/users/{}", url_api, id.unwrap()) + let url = format!("{}/users/@me", url_api); + let request = reqwest::Client::new() + .get(url) + .header("Authorization", user.token()); + let chorus_request = ChorusRequest { + request, + limit_type: LimitType::Global, }; + chorus_request.deserialize_response::(user).await + } + + /// Gets a non-local user by their id + /// + /// # Reference + /// See + pub async fn get(user: &mut ChorusUser, id: Snowflake) -> ChorusResult { + let url_api = user.belongs_to.read().unwrap().urls.api.clone(); + let url = format!("{}/users/{}", url_api, id); let request = reqwest::Client::new() .get(url) .header("Authorization", user.token()); @@ -107,16 +700,54 @@ impl User { request, limit_type: LimitType::Global, }; - match chorus_request.send_request(user).await { - Ok(result) => { - let result_text = result.text().await.unwrap(); - Ok(serde_json::from_str::(&result_text).unwrap()) - } - Err(e) => Err(e), + chorus_request + .deserialize_response::(user) + .await + } + + /// Gets a user by their unique username. + /// + /// As of 2024/07/28, Spacebar does not yet implement this endpoint. + /// + /// If fetching with a pomelo username, discriminator should be set to None. + /// + /// This route also permits fetching users with their old pre-pomelo username#discriminator + /// combo. + /// + /// Note: + /// + /// "Unless the target user is a bot, you must be able to add + /// the user as a friend to resolve them by username. + /// + /// Due to this restriction, you are not able to resolve your own username." + /// + /// # Reference + /// See + pub async fn get_by_username( + user: &mut ChorusUser, + username: &String, + discriminator: Option<&String>, + ) -> ChorusResult { + let url_api = user.belongs_to.read().unwrap().urls.api.clone(); + let url = format!("{}/users/username/{username}", url_api); + let mut request = reqwest::Client::new() + .get(url) + .header("Authorization", user.token()); + + if let Some(some_discriminator) = discriminator { + request = request.query(&[("discriminator", some_discriminator)]); } + + let chorus_request = ChorusRequest { + request, + limit_type: LimitType::Global, + }; + chorus_request + .deserialize_response::(user) + .await } - /// Gets the user's settings. + /// Gets the current user's settings. /// /// # Reference /// See @@ -129,12 +760,121 @@ impl User { request, limit_type: LimitType::Global, }; - match chorus_request.send_request(user).await { - Ok(result) => { - let result_text = result.text().await.unwrap(); - Ok(serde_json::from_str(&result_text).unwrap()) - } - Err(e) => Err(e), - } + chorus_request + .deserialize_response::(user) + .await + } + + /// Gets a user's profile object by their id. + /// + /// This endpoint requires one of the following: + /// + /// - The other user is a bot + /// - The other user shares a mutual guild with the current user + /// - The other user is a friend of the current user + /// - The other user is a friend suggestion of the current user + /// - The other user has an outgoing friend request to the current user + /// + /// # Reference + /// See + pub async fn get_profile( + user: &mut ChorusUser, + id: Snowflake, + query_parameters: GetUserProfileSchema, + ) -> ChorusResult { + let url_api = user.belongs_to.read().unwrap().urls.api.clone(); + let request: reqwest::RequestBuilder = Client::new() + .get(format!("{}/users/{}/profile", url_api, id)) + .header("Authorization", user.token()) + .query(&query_parameters); + + let chorus_request = ChorusRequest { + request, + limit_type: LimitType::Global, + }; + chorus_request + .deserialize_response::(user) + .await + } + + /// Modifies the current user's profile. + /// + /// Returns the updated [UserProfileMetadata]. + /// + /// # Reference + /// See + pub async fn modify_profile( + user: &mut ChorusUser, + schema: UserModifyProfileSchema, + ) -> ChorusResult { + let url_api = user.belongs_to.read().unwrap().urls.api.clone(); + let request: reqwest::RequestBuilder = Client::new() + .patch(format!("{}/users/@me/profile", url_api)) + .header("Authorization", user.token()) + .json(&schema); + let chorus_request = ChorusRequest { + request, + limit_type: LimitType::Global, + }; + chorus_request + .deserialize_response::(user) + .await + } + + /// Fetches the note ([UserNote]) for the given user. + /// + /// If the current user has no note for the target, this endpoint + /// returns `Err(NotFound { error: "{\"message\": \"Unknown User\", \"code\": 10013}" })` + /// + /// # Reference + /// See + pub async fn get_note( + user: &mut ChorusUser, + target_user_id: Snowflake, + ) -> ChorusResult { + let request = Client::new() + .get(format!( + "{}/users/@me/notes/{}", + user.belongs_to.read().unwrap().urls.api, + target_user_id + )) + .header("Authorization", user.token()); + + let chorus_request = ChorusRequest { + request, + limit_type: LimitType::default(), + }; + + chorus_request.deserialize_response(user).await + } + + /// Sets the note for the given user. + /// + /// Fires a `UserNoteUpdate` gateway event. (Note: yet to be implemented in chorus, see [#546](https://github.com/polyphony-chat/chorus/issues/546)) + /// + /// # Reference + /// See + pub async fn set_note( + user: &mut ChorusUser, + target_user_id: Snowflake, + note: Option, + ) -> ChorusResult<()> { + let schema = ModifyUserNoteSchema { note }; + + let request = Client::new() + .put(format!( + "{}/users/@me/notes/{}", + user.belongs_to.read().unwrap().urls.api, + target_user_id + )) + .header("Authorization", user.token()) + .json(&schema); + + let chorus_request = ChorusRequest { + request, + limit_type: LimitType::default(), + }; + + chorus_request.handle_request_as_result(user).await } } diff --git a/src/gateway/events.rs b/src/gateway/events.rs index 049434be..4663fe1a 100644 --- a/src/gateway/events.rs +++ b/src/gateway/events.rs @@ -69,12 +69,15 @@ pub struct Message { pub reaction_remove: Publisher, pub reaction_remove_all: Publisher, pub reaction_remove_emoji: Publisher, + pub recent_mention_delete: Publisher, pub ack: Publisher, } #[derive(Default, Debug)] pub struct User { pub update: Publisher, + pub connections_update: Publisher, + pub note_update: Publisher, pub guild_settings_update: Publisher, pub presence_update: Publisher, pub typing_start: Publisher, diff --git a/src/gateway/gateway.rs b/src/gateway/gateway.rs index 976769d2..20f86407 100644 --- a/src/gateway/gateway.rs +++ b/src/gateway/gateway.rs @@ -404,6 +404,7 @@ impl Gateway { "MESSAGE_REACTION_REMOVE" => message.reaction_remove, // TODO "MESSAGE_REACTION_REMOVE_ALL" => message.reaction_remove_all, // TODO "MESSAGE_REACTION_REMOVE_EMOJI" => message.reaction_remove_emoji, // TODO + "RECENT_MENTION_DELETE" => message.recent_mention_delete, "MESSAGE_ACK" => message.ack, "PRESENCE_UPDATE" => user.presence_update, // TODO "RELATIONSHIP_ADD" => relationship.add, @@ -413,6 +414,8 @@ impl Gateway { "STAGE_INSTANCE_DELETE" => stage_instance.delete, "TYPING_START" => user.typing_start, "USER_UPDATE" => user.update, // TODO + "USER_CONNECTIONS_UPDATE" => user.connections_update, // TODO + "USER_NOTE_UPDATE" => user.note_update, "USER_GUILD_SETTINGS_UPDATE" => user.guild_settings_update, "VOICE_STATE_UPDATE" => voice.state_update, // TODO "VOICE_SERVER_UPDATE" => voice.server_update, diff --git a/src/gateway/options.rs b/src/gateway/options.rs index 4ff6178b..b8ded327 100644 --- a/src/gateway/options.rs +++ b/src/gateway/options.rs @@ -2,6 +2,8 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. +use crate::instance::InstanceSoftware; + #[derive(Clone, PartialEq, Eq, Ord, PartialOrd, Debug, Default, Copy)] /// Options passed when initializing the gateway connection. /// @@ -22,6 +24,23 @@ pub struct GatewayOptions { } impl GatewayOptions { + /// Creates the ideal gateway options for an [InstanceSoftware], + /// based off which features it supports. + pub fn for_instance_software(software: InstanceSoftware) -> GatewayOptions { + // TODO: Support ETF + let encoding = GatewayEncoding::Json; + + let transport_compression = match software.supports_gateway_zlib() { + true => GatewayTransportCompression::ZLibStream, + false => GatewayTransportCompression::None, + }; + + GatewayOptions { + encoding, + transport_compression, + } + } + /// Adds the options to an existing gateway url /// /// Returns the new url diff --git a/src/instance.rs b/src/instance.rs index a8671e0a..3956dd06 100644 --- a/src/instance.rs +++ b/src/instance.rs @@ -28,11 +28,12 @@ use crate::UrlBundle; pub struct Instance { pub urls: UrlBundle, pub instance_info: GeneralConfiguration, + pub(crate) software: InstanceSoftware, pub limits_information: Option, #[serde(skip)] pub client: Client, #[serde(skip)] - pub gateway_options: GatewayOptions, + pub(crate) gateway_options: GatewayOptions, } #[derive(Debug, Clone, Serialize, Deserialize, Default, Eq)] @@ -72,6 +73,8 @@ impl Instance { /// If `options` is `None`, the default [`GatewayOptions`] will be used. /// /// To create an Instance from one singular url, use [`Instance::new()`]. + // Note: maybe make this just take urls and then add another method which creates an instance + // from urls and custom gateway options, since gateway options will be automatically generated? pub async fn from_url_bundle( urls: UrlBundle, options: Option, @@ -88,6 +91,7 @@ impl Instance { } else { limit_information = None } + let mut instance = Instance { urls: urls.clone(), // Will be overwritten in the next step @@ -95,7 +99,10 @@ impl Instance { limits_information: limit_information, client: Client::new(), gateway_options: options.unwrap_or_default(), + // Will also be detected soon + software: InstanceSoftware::Other, }; + instance.instance_info = match instance.general_configuration_schema().await { Ok(schema) => schema, Err(e) => { @@ -103,6 +110,13 @@ impl Instance { GeneralConfiguration::default() } }; + + instance.software = instance.detect_software().await; + + if options.is_none() { + instance.gateway_options = GatewayOptions::for_instance_software(instance.software()); + } + Ok(instance) } @@ -133,12 +147,97 @@ impl Instance { } } - /// Sets the [`GatewayOptions`] the instance will use when spawning new connections. + /// Detects which [InstanceSoftware] the instance is running. + pub async fn detect_software(&self) -> InstanceSoftware { + if let Ok(version) = self.get_version().await { + match version.server.to_lowercase().as_str() { + "symfonia" => return InstanceSoftware::Symfonia, + // We can dream this will be implemented one day + "spacebar" => return InstanceSoftware::SpacebarTypescript, + _ => {} + } + } + + // We know it isn't a symfonia server now, work around spacebar + // not really having a version endpoint + let ping = self.ping().await; + + if ping.is_ok() { + return InstanceSoftware::SpacebarTypescript; + } + + InstanceSoftware::Other + } + + /// Returns the [`GatewayOptions`] the instance uses when spawning new connections. + /// + /// These options are used on the gateways created when logging in and registering. + pub fn gateway_options(&self) -> GatewayOptions { + self.gateway_options + } + + /// Manually sets the [`GatewayOptions`] the instance should use when spawning new connections. /// /// These options are used on the gateways created when logging in and registering. pub fn set_gateway_options(&mut self, options: GatewayOptions) { self.gateway_options = options; } + + /// Returns which [`InstanceSoftware`] the instance is running. + pub fn software(&self) -> InstanceSoftware { + self.software + } + + /// Manually sets which [`InstanceSoftware`] the instance is running. + /// + /// Note: you should only use this if you are absolutely sure about an instance (e. g. you run it). + /// If set to an incorrect value, this may cause unexpected errors or even undefined behaviours. + /// + /// Manually setting the software is generally discouraged. Chorus should automatically detect + /// which type of software the instance is running. + pub fn set_software(&mut self, software: InstanceSoftware) { + self.software = software; + } +} + +#[derive(Debug, Copy, Clone, PartialEq, Eq, Serialize, Deserialize, Default)] +/// The software implementation the spacebar-compatible instance is running. +/// +/// This is useful since some softwares may support additional features, +/// while other do not fully implement the api yet. +pub enum InstanceSoftware { + /// The official typescript Spacebar server, available + /// at + SpacebarTypescript, + /// The Polyphony server written in rust, available at + /// at + Symfonia, + /// We could not determine the instance software or it + /// is one we don't specifically differentiate. + /// + /// Assume it implements all features of the spacebar protocol. + #[default] + Other, +} + +impl InstanceSoftware { + /// Returns whether the software supports z-lib stream compression on the gateway + pub fn supports_gateway_zlib(self) -> bool { + match self { + InstanceSoftware::SpacebarTypescript => true, + InstanceSoftware::Symfonia => false, + InstanceSoftware::Other => true, + } + } + + /// Returns whether the software supports sending data in the Erlang external term format on the gateway + pub fn supports_gateway_etf(self) -> bool { + match self { + InstanceSoftware::SpacebarTypescript => true, + InstanceSoftware::Symfonia => false, + InstanceSoftware::Other => true, + } + } } #[derive(Debug, PartialEq, Eq, Serialize, Deserialize)] diff --git a/src/types/config/types/subconfigs/defaults/user.rs b/src/types/config/types/subconfigs/defaults/user.rs index a533b0ac..982507ea 100644 --- a/src/types/config/types/subconfigs/defaults/user.rs +++ b/src/types/config/types/subconfigs/defaults/user.rs @@ -4,11 +4,13 @@ use serde::{Deserialize, Serialize}; +use crate::types::PremiumType; + #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Copy, Hash, PartialOrd, Ord)] #[serde(rename_all = "camelCase")] pub struct UserDefaults { pub premium: bool, - pub premium_type: u8, + pub premium_type: PremiumType, pub verified: bool, } @@ -16,7 +18,7 @@ impl Default for UserDefaults { fn default() -> Self { Self { premium: true, - premium_type: 2, + premium_type: PremiumType::Tier2, verified: true, } } diff --git a/src/types/entities/connection.rs b/src/types/entities/connection.rs new file mode 100644 index 00000000..e6421b07 --- /dev/null +++ b/src/types/entities/connection.rs @@ -0,0 +1,300 @@ +use std::{collections::HashMap, fmt::Display}; + +use serde::{Deserialize, Serialize}; +use serde_repr::{Deserialize_repr, Serialize_repr}; + +/// A 3rd party service connection to a user's account. +/// +/// # Reference +/// See +// TODO: Should (could) this type be Updateable and Composite? +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)] +#[cfg_attr(feature = "sqlx", derive(sqlx::FromRow))] +pub struct Connection { + /// The id of the account on the 3rd party service + #[serde(rename = "id")] + pub connected_account_id: String, + + /// The service of the connected account + #[serde(rename = "type")] + pub connection_type: ConnectionType, + + /// The username of the connection account + pub name: String, + + /// If the connection is verified + pub verified: bool, + + /// Service specific metadata about the connection / connected account + // FIXME: Is there a better type? As far as I see the value is always encoded as a string + pub metadata: Option>, + pub metadata_visibility: ConnectionVisibilityType, + + /// If the connection if revoked + pub revoked: bool, + + // TODO: Add integrations + pub friend_sync: bool, + + /// Whether activities related to this connection will be shown in presence + pub show_activity: bool, + + /// Whether this connection has a corresponding 3rd party OAuth2 token + pub two_way_link: bool, + + /// Who can see this connection + pub visibility: ConnectionVisibilityType, + + /// The access token for the connection account + /// + /// Note: not included when fetching a user's connections via OAuth2 + pub access_token: Option, +} + +impl Connection { + /// Converts self info a [PublicConnection], forgetting private data + pub fn into_public(self: Connection) -> PublicConnection { + PublicConnection { + name: self.name, + verified: self.verified, + connection_type: self.connection_type, + connected_account_id: self.connected_account_id, + metadata: self.metadata, + } + } +} + +/// A partial / public [Connection] type. +/// +/// # Reference +/// See +// FIXME: Should (could) this type also be Updateable and Composite? +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)] +pub struct PublicConnection { + /// The id of the account on the 3rd party service + #[serde(rename = "id")] + pub connected_account_id: String, + + #[serde(rename = "type")] + pub connection_type: ConnectionType, + + /// The username of the connection account + pub name: String, + + /// If the connection is verified + pub verified: bool, + + /// Service specific metadata about the connection / connected account + // FIXME: Is there a better type? As far as I see the value is always encoded as a string + pub metadata: Option>, +} + +impl From for PublicConnection { + fn from(value: Connection) -> Self { + value.into_public() + } +} + +#[derive(Serialize, Deserialize, Debug, Clone, Eq, PartialEq, Hash, Copy, PartialOrd, Ord)] +#[cfg_attr(feature = "sqlx", derive(sqlx::Type))] +#[serde(rename_all = "lowercase")] +/// A type of connection; the service the connection is for +/// +/// Note: this is subject to change, and the enum is likely non-exhaustive +/// +/// # Reference +/// See +pub enum ConnectionType { + #[serde(rename = "amazon-music")] + AmazonMusic, + /// Battle.net + BattleNet, + /// Bungie.net + Bungie, + /// Discord?'s contact sync + /// + /// (Not returned in Get User Profile or when fetching connections) + Contacts, + Crunchyroll, + /// Note: spacebar only + Discord, + Domain, + Ebay, + EpicGames, + Facebook, + GitHub, + Instagram, + LeagueOfLegends, + PayPal, + /// Playstation network + Playstation, + Reddit, + Roblox, + RiotGames, + /// Samsung Galaxy + /// + /// Users can no longer add this service + Samsung, + Spotify, + /// Users can no longer add this service + Skype, + Steam, + TikTok, + Twitch, + Twitter, + Xbox, + YouTube, +} + +impl Display for ConnectionType { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match *self { + Self::AmazonMusic => f.write_str("Amazon Music"), + Self::BattleNet => f.write_str("Battle.net"), + Self::Bungie => f.write_str("Bungie.net"), + Self::Ebay => f.write_str("eBay"), + Self::EpicGames => f.write_str("Epic Games"), + Self::LeagueOfLegends => f.write_str("League of Legends"), + Self::Playstation => f.write_str("PlayStation Network"), + Self::RiotGames => f.write_str("Riot Games"), + Self::Samsung => f.write_str("Samsung Galaxy"), + _ => f.write_str(format!("{:?}", self).as_str()), + } + } +} + +impl ConnectionType { + /// Returns an vector of all the connection types + // API note: this could be an array, but it is subject to change. + pub fn vector() -> Vec { + vec![ + ConnectionType::AmazonMusic, + ConnectionType::BattleNet, + ConnectionType::Bungie, + ConnectionType::Contacts, + ConnectionType::Crunchyroll, + ConnectionType::Discord, + ConnectionType::Domain, + ConnectionType::Ebay, + ConnectionType::EpicGames, + ConnectionType::Facebook, + ConnectionType::GitHub, + ConnectionType::Instagram, + ConnectionType::LeagueOfLegends, + ConnectionType::PayPal, + ConnectionType::Playstation, + ConnectionType::Reddit, + ConnectionType::RiotGames, + ConnectionType::Samsung, + ConnectionType::Spotify, + ConnectionType::Skype, + ConnectionType::Steam, + ConnectionType::TikTok, + ConnectionType::Twitch, + ConnectionType::Twitter, + ConnectionType::Xbox, + ConnectionType::YouTube, + ] + } + + /// Returns an vector of all the connection types available on discord + pub fn discord_vector() -> Vec { + vec![ + ConnectionType::AmazonMusic, + ConnectionType::BattleNet, + ConnectionType::Bungie, + ConnectionType::Contacts, + ConnectionType::Crunchyroll, + ConnectionType::Domain, + ConnectionType::Ebay, + ConnectionType::EpicGames, + ConnectionType::Facebook, + ConnectionType::GitHub, + ConnectionType::Instagram, + ConnectionType::LeagueOfLegends, + ConnectionType::PayPal, + ConnectionType::Playstation, + ConnectionType::Reddit, + ConnectionType::RiotGames, + ConnectionType::Samsung, + ConnectionType::Spotify, + ConnectionType::Skype, + ConnectionType::Steam, + ConnectionType::TikTok, + ConnectionType::Twitch, + ConnectionType::Twitter, + ConnectionType::Xbox, + ConnectionType::YouTube, + ] + } + + /// Returns an vector of all the connection types available on spacebar + pub fn spacebar_vector() -> Vec { + vec![ + ConnectionType::BattleNet, + ConnectionType::Discord, + ConnectionType::EpicGames, + ConnectionType::Facebook, + ConnectionType::GitHub, + ConnectionType::Reddit, + ConnectionType::Spotify, + ConnectionType::Twitch, + ConnectionType::Twitter, + ConnectionType::Xbox, + ConnectionType::YouTube, + ] + } +} + +#[derive( + Serialize_repr, Deserialize_repr, Debug, Clone, Eq, PartialEq, Hash, Copy, PartialOrd, Ord, +)] +#[cfg_attr(feature = "sqlx", derive(sqlx::Type))] +#[repr(u8)] +#[serde(rename_all = "SCREAMING_SNAKE_CASE")] +/// # Reference +/// See +pub enum ConnectionVisibilityType { + /// Invisible to everyone except the user themselves + None = 0, + /// Visible to everyone + Everyone = 1, +} + +#[derive(Serialize, Deserialize, Debug, Clone, Eq, PartialEq, Hash, Copy, PartialOrd, Ord)] +#[cfg_attr(feature = "sqlx", derive(sqlx::Type))] +#[serde(rename_all = "lowercase")] +/// A type of two-way connection link +/// +/// # Reference +/// See +pub enum TwoWayLinkType { + /// The connection is linked via web + Web, + /// The connection is linked via mobile + Mobile, + /// The connection is linked via desktop + Desktop, +} + +impl Display for TwoWayLinkType { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(format!("{:?}", self).as_str()) + } +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)] +/// Defines a subreddit as fetched through a Reddit connection ([[ConnectionType::Reddit]]). +/// +/// # Reference +/// See +pub struct ConnectionSubreddit { + /// The subreddit's internal id, e.g. "t5_388p4" + pub id: String, + + /// How many reddit users follow the subreddit + pub subscribers: usize, + + /// The subreddit's relative url, e.g. "/r/discordapp/" + pub url: String, +} diff --git a/src/types/entities/guild.rs b/src/types/entities/guild.rs index 423339a1..91850ac2 100644 --- a/src/types/entities/guild.rs +++ b/src/types/entities/guild.rs @@ -452,7 +452,7 @@ pub enum VerificationLevel { #[cfg_attr(not(feature = "sqlx"), repr(u8))] #[cfg_attr(feature = "sqlx", repr(i16))] #[serde(rename_all = "SCREAMING_SNAKE_CASE")] -/// See +/// See pub enum MFALevel { #[default] None = 0, @@ -476,7 +476,7 @@ pub enum MFALevel { #[cfg_attr(not(feature = "sqlx"), repr(u8))] #[cfg_attr(feature = "sqlx", repr(i16))] #[serde(rename_all = "SCREAMING_SNAKE_CASE")] -/// See +/// See pub enum NSFWLevel { #[default] Default = 0, @@ -502,12 +502,19 @@ pub enum NSFWLevel { #[cfg_attr(not(feature = "sqlx"), repr(u8))] #[cfg_attr(feature = "sqlx", repr(i16))] #[serde(rename_all = "SCREAMING_SNAKE_CASE")] -/// See +// Note: Maybe rename this to GuildPremiumTier? +/// **Guild** premium (Boosting) tier +/// +/// See pub enum PremiumTier { #[default] + /// No server boost perks None = 0, + /// Level 1 server boost perks Tier1 = 1, + /// Level 2 server boost perks Tier2 = 2, + /// Level 3 server boost perks Tier3 = 3, } diff --git a/src/types/entities/harvest.rs b/src/types/entities/harvest.rs new file mode 100644 index 00000000..b747641a --- /dev/null +++ b/src/types/entities/harvest.rs @@ -0,0 +1,97 @@ +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use serde_repr::{Deserialize_repr, Serialize_repr}; + +use crate::types::Snowflake; + +#[cfg(feature = "client")] +use crate::gateway::Updateable; + +// FIXME: Should this type be Composite? +#[derive(Serialize, Deserialize, Debug, Default, Clone, Copy, PartialEq, Eq, Hash)] +#[cfg_attr(feature = "sqlx", derive(sqlx::FromRow))] +/// A user's data harvest. +/// +/// # Reference +/// +/// See +pub struct Harvest { + pub harvest_id: Snowflake, + /// The id of the user being harvested + pub user_id: Snowflake, + /// How much the harvest has been processed + pub status: HarvestStatus, + /// The time the harvest was created + pub created_at: DateTime, + /// The time the harvest was last polled + pub polled_at: Option>, + /// The time the harvest was completed + pub completed_at: Option>, +} + +#[cfg(feature = "client")] +impl Updateable for Harvest { + #[cfg(not(tarpaulin_include))] + fn id(&self) -> Snowflake { + self.harvest_id + } +} + +#[derive( + Serialize_repr, + Deserialize_repr, + Debug, + Default, + Clone, + Eq, + PartialEq, + Hash, + Copy, + PartialOrd, + Ord, +)] +#[cfg_attr(feature = "sqlx", derive(sqlx::Type))] +#[repr(u8)] +#[serde(rename_all = "SCREAMING_SNAKE_CASE")] +/// Current status of a [Harvest] +/// +/// See and +pub enum HarvestStatus { + /// The harvest is queued and has not been started + Queued = 0, + /// The harvest is currently running / being processed + Running = 1, + /// The harvest has failed + Failed = 2, + /// The harvest has been completed successfully + Completed = 3, + #[default] + Unknown = 4, +} + +#[derive(Serialize, Deserialize, Debug, Clone, Eq, PartialEq, Hash, Copy, PartialOrd, Ord)] +#[cfg_attr(feature = "sqlx", derive(sqlx::Type))] +/// A type of backend / service a harvest can be requested for. +/// +/// See and +pub enum HarvestBackendType { + /// All account information; + Accounts, + /// Actions the user has taken; + /// + /// Represented as "Your Activity" in the discord client + Analytics, + /// First-party embedded activity information; + /// + /// e.g.: Chess in the Park, Checkers in the Park, Poker Night 2.0; + /// Sketch Heads, Watch Together, Letter League, Land-io, Know What I Meme + Activities, + /// The user's messages + Messages, + /// Official Discord programes; + /// + /// e.g.: Partner, HypeSquad, Verified Server + Programs, + /// Guilds the user is a member of; + Servers, +} diff --git a/src/types/entities/integration.rs b/src/types/entities/integration.rs index 50a82819..8afec21f 100644 --- a/src/types/entities/integration.rs +++ b/src/types/entities/integration.rs @@ -4,6 +4,7 @@ use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; +use serde_repr::{Deserialize_repr, Serialize_repr}; use crate::types::{ entities::{Application, User}, @@ -24,7 +25,7 @@ pub struct Integration { pub syncing: Option, pub role_id: Option, pub enabled_emoticons: Option, - pub expire_behaviour: Option, + pub expire_behaviour: Option, pub expire_grace_period: Option, #[cfg_attr(feature = "sqlx", sqlx(skip))] pub user: Option>, @@ -51,6 +52,7 @@ pub struct IntegrationAccount { #[serde(rename_all = "snake_case")] #[cfg_attr(feature = "sqlx", derive(sqlx::Type))] #[cfg_attr(feature = "sqlx", sqlx(rename_all = "snake_case"))] +/// See pub enum IntegrationType { #[default] Twitch, @@ -58,3 +60,32 @@ pub enum IntegrationType { Discord, GuildSubscription, } + +#[derive( + Serialize_repr, + Deserialize_repr, + Debug, + Default, + Clone, + Eq, + PartialEq, + Hash, + Copy, + PartialOrd, + Ord, +)] +#[cfg_attr(feature = "sqlx", derive(sqlx::Type))] +#[repr(u8)] +#[serde(rename_all = "SCREAMING_SNAKE_CASE")] +/// Defines the behaviour that is executed when a user's subscription to the integration expires. +/// +/// See +pub enum IntegrationExpireBehaviour { + #[default] + /// Remove the subscriber role from the user + RemoveRole = 0, + /// Kick the user from the guild + Kick = 1, +} + + diff --git a/src/types/entities/mod.rs b/src/types/entities/mod.rs index 545614cd..969d731a 100644 --- a/src/types/entities/mod.rs +++ b/src/types/entities/mod.rs @@ -8,9 +8,11 @@ pub use audit_log::*; pub use auto_moderation::*; pub use channel::*; pub use config::*; +pub use connection::*; pub use emoji::*; pub use guild::*; pub use guild_member::*; +pub use harvest::*; pub use integration::*; pub use invite::*; pub use message::*; @@ -49,9 +51,11 @@ mod audit_log; mod auto_moderation; mod channel; mod config; +mod connection; mod emoji; mod guild; mod guild_member; +mod harvest; mod integration; mod invite; mod message; diff --git a/src/types/entities/relationship.rs b/src/types/entities/relationship.rs index 08cb41f1..da2a9dc7 100644 --- a/src/types/entities/relationship.rs +++ b/src/types/entities/relationship.rs @@ -6,18 +6,27 @@ use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; use serde_repr::{Deserialize_repr, Serialize_repr}; +use crate::errors::ChorusError; use crate::types::{Shared, Snowflake}; use super::{arc_rwlock_ptr_eq, PublicUser}; #[derive(Debug, Deserialize, Serialize, Clone, Default)] +#[cfg_attr(feature = "sqlx", derive(sqlx::FromRow))] /// See pub struct Relationship { + /// The ID of the target user + #[cfg_attr(feature = "sqlx", sqlx(rename = "to_id"))] pub id: Snowflake, #[serde(rename = "type")] + #[cfg_attr(feature = "sqlx", sqlx(rename = "type"))] pub relationship_type: RelationshipType, + /// The nickname of the user in this relationship pub nickname: Option, + #[cfg_attr(feature = "sqlx", sqlx(skip))] // Can be derived from the user id + /// The target user pub user: Shared, + /// When the user requested a relationship pub since: Option>, } @@ -45,8 +54,7 @@ impl PartialEq for Relationship { Copy, Hash, )] -#[cfg_attr(not(feature = "sqlx"), repr(u8))] -#[cfg_attr(feature = "sqlx", repr(i16))] +#[repr(u8)] /// See pub enum RelationshipType { Suggestion = 6, @@ -58,3 +66,50 @@ pub enum RelationshipType { Friends = 1, None = 0, } + +#[cfg(feature = "sqlx")] +impl sqlx::Type for RelationshipType { + fn type_info() -> ::TypeInfo { + >::type_info() + } +} + +#[cfg(feature = "sqlx")] +impl<'q> sqlx::Encode<'q, sqlx::Postgres> for RelationshipType { + fn encode_by_ref( + &self, + buf: &mut ::ArgumentBuffer<'q>, + ) -> Result { + let sqlx_pg_uint = sqlx_pg_uint::PgU8::from(*self as u8); + sqlx_pg_uint.encode_by_ref(buf) + } +} + +#[cfg(feature = "sqlx")] +impl<'r> sqlx::Decode<'r, sqlx::Postgres> for RelationshipType { + fn decode( + value: ::ValueRef<'r>, + ) -> Result { + let sqlx_pg_uint = sqlx_pg_uint::PgU8::decode(value)?; + Self::try_from(sqlx_pg_uint.to_uint()).map_err(|e| e.into()) + } +} + +impl TryFrom for RelationshipType { + type Error = ChorusError; + + fn try_from(value: u8) -> Result { + match value { + 6 => Ok(Self::Suggestion), + 5 => Ok(Self::Implicit), + 4 => Ok(Self::Outgoing), + 3 => Ok(Self::Incoming), + 2 => Ok(Self::Blocked), + 1 => Ok(Self::Friends), + 0 => Ok(Self::None), + _ => Err(ChorusError::InvalidArguments { + error: format!("Value {} is not a valid RelationshipType", value), + }), + } + } +} diff --git a/src/types/entities/user.rs b/src/types/entities/user.rs index 866d66cf..5e761307 100644 --- a/src/types/entities/user.rs +++ b/src/types/entities/user.rs @@ -7,7 +7,8 @@ use crate::types::utils::Snowflake; use crate::{UInt32, UInt8}; use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; -use serde_aux::prelude::deserialize_option_number_from_string; +use serde_aux::prelude::{deserialize_default_from_null, deserialize_option_number_from_string}; +use serde_repr::{Deserialize_repr, Serialize_repr}; use std::array::TryFromSliceError; use std::fmt::Debug; @@ -23,7 +24,7 @@ use crate::gateway::GatewayHandle; #[cfg(feature = "client")] use chorus_macros::{Composite, Updateable}; -use super::Emoji; +use super::{Emoji, GuildMember, PublicConnection}; #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)] #[cfg_attr(feature = "sqlx", derive(sqlx::Type))] @@ -40,6 +41,8 @@ impl User { #[derive(Serialize, Deserialize, Debug, Default, Clone, PartialEq, Eq, Hash)] #[cfg_attr(feature = "client", derive(Updateable, Composite))] #[cfg_attr(feature = "sqlx", derive(sqlx::FromRow))] +/// # Reference +/// See pub struct User { pub id: Snowflake, pub username: String, @@ -58,8 +61,10 @@ pub struct User { #[serde(default)] #[serde(deserialize_with = "deserialize_option_number_from_string")] pub flags: Option, + pub premium: Option, + /// The type of premium (Nitro) a user has + pub premium_type: Option, pub premium_since: Option>, - pub premium_type: Option, pub pronouns: Option, pub public_flags: Option, pub banner: Option, @@ -67,13 +72,15 @@ pub struct User { pub theme_colors: Option, pub phone: Option, pub nsfw_allowed: Option, - pub premium: Option, pub purchased_flags: Option, pub premium_usage_flags: Option, pub disabled: Option, } #[derive(Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize, Copy)] +/// A user's theme colors, as u32s representing hex color codes +/// +/// found in [UserProfileMetadata] pub struct ThemeColors { #[serde(flatten)] inner: (u32, u32), @@ -140,6 +147,8 @@ impl sqlx::Type for ThemeColors { } #[derive(Debug, Default, Clone, PartialEq, Eq, Serialize, Deserialize, Hash)] +/// # Reference +/// See pub struct PublicUser { pub id: Snowflake, pub username: Option, @@ -151,7 +160,9 @@ pub struct PublicUser { pub pronouns: Option, pub bot: Option, pub bio: Option, - pub premium_type: Option, + /// The type of premium (Nitro) a user has + pub premium_type: Option, + /// The date the user's premium (Nitro) subscribtion started pub premium_since: Option>, pub public_flags: Option, } @@ -182,6 +193,8 @@ const CUSTOM_USER_FLAG_OFFSET: u64 = 1 << 32; bitflags::bitflags! { #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, chorus_macros::SerdeBitFlags)] #[cfg_attr(feature = "sqlx", derive(chorus_macros::SqlxBitFlags))] + /// # Reference + /// See pub struct UserFlags: u64 { const DISCORD_EMPLOYEE = 1 << 0; const PARTNERED_SERVER_OWNER = 1 << 1; @@ -195,6 +208,7 @@ bitflags::bitflags! { const EARLY_SUPPORTER = 1 << 9; const TEAM_USER = 1 << 10; const TRUST_AND_SAFETY = 1 << 11; + /// Note: deprecated by Discord const SYSTEM = 1 << 12; const HAS_UNREAD_URGENT_MESSAGES = 1 << 13; const BUGHUNTER_LEVEL_2 = 1 << 14; @@ -206,14 +220,683 @@ bitflags::bitflags! { } } +#[derive( + Serialize_repr, + Deserialize_repr, + Debug, + Default, + Clone, + Eq, + PartialEq, + Hash, + Copy, + PartialOrd, + Ord, +)] +#[repr(u8)] +#[serde(rename_all = "SCREAMING_SNAKE_CASE")] +/// **User** premium (Nitro) type +/// +/// See +pub enum PremiumType { + #[default] + /// No Nitro + None = 0, + /// Nitro Classic + Tier1 = 1, + /// Nitro + Tier2 = 2, + /// Nitro Basic + Tier3 = 3, +} + +impl TryFrom for PremiumType { + type Error = ChorusError; + + fn try_from(value: u8) -> Result { + match value { + 0 => Ok(Self::None), + 1 => Ok(Self::Tier1), + 2 => Ok(Self::Tier2), + 3 => Ok(Self::Tier3), + _ => Err(ChorusError::InvalidArguments { + error: "Value is not a valid PremiumType".to_string(), + }), + } + } +} + +#[cfg(feature = "sqlx")] +impl sqlx::Type for PremiumType { + fn type_info() -> ::TypeInfo { + >::type_info() + } +} + +#[cfg(feature = "sqlx")] +impl<'q> sqlx::Encode<'q, sqlx::Postgres> for PremiumType { + fn encode_by_ref( + &self, + buf: &mut ::ArgumentBuffer<'q>, + ) -> Result { + let sqlx_pg_uint = sqlx_pg_uint::PgU8::from(*self as u8); + sqlx_pg_uint.encode_by_ref(buf) + } +} + +#[cfg(feature = "sqlx")] +impl<'r> sqlx::Decode<'r, sqlx::Postgres> for PremiumType { + fn decode( + value: ::ValueRef<'r>, + ) -> Result { + let sqlx_pg_uint = sqlx_pg_uint::PgU8::decode(value)?; + PremiumType::try_from(sqlx_pg_uint.to_uint()).map_err(|e| e.into()) + } +} + #[derive(Debug, Deserialize, Serialize, Clone, PartialEq)] +/// # Reference +/// See pub struct UserProfileMetadata { + /// The guild ID this profile applies to, if it is a guild profile. pub guild_id: Option, + /// The user's pronouns, up to 40 characters + #[serde(deserialize_with = "deserialize_default_from_null")] + // Note: spacebar will send this is as null, while it should be "" + // See issue 1188 pub pronouns: String, + /// The user's bio / description, up to 190 characters pub bio: Option, + /// The hash used to retrieve the user's banned from the CDN pub banner: Option, + /// Banner color encoded as an i32 representation of a hex color code pub accent_color: Option, + /// See [ThemeColors] pub theme_colors: Option, pub popout_animation_particle_type: Option, pub emoji: Option, } + +#[derive(Debug, Deserialize, Serialize, Clone, PartialEq)] +/// A user's publically facing profile +/// +/// # Reference +/// See +pub struct UserProfile { + // TODO: add profile application object + pub user: PublicUser, + + #[serde(rename = "user_profile")] + pub profile_metadata: UserProfileMetadata, + + #[serde(default)] + pub badges: Vec, + + pub guild_member: Option, + + #[serde(rename = "guild_member_profile")] + pub guild_member_profile_metadata: Option, + + #[serde(default)] + pub guild_badges: Vec, + + /// The user's legacy username#discriminator, if existing and shown + pub legacy_username: Option, + + #[serde(default)] + pub mutual_guilds: Vec, + + #[serde(default)] + pub mutual_friends: Vec, + + pub mutual_friends_count: Option, + + pub connected_accounts: Vec, + + // TODO: Add application role connections! + /// The type of premium (Nitro) a user has + pub premium_type: Option, + /// The date the user's premium (Nitro) subscribtion started + pub premium_since: Option>, + /// The date the user's premium guild (Boosting) subscribtion started + pub premium_guild_since: Option>, +} + +#[derive(Debug, Deserialize, Serialize, Clone)] +/// Info about a badge on a user's profile ([UserProfile]) +/// +/// # Reference +/// See +/// +/// For a list of know badges, see +pub struct ProfileBadge { + /// The badge's unique id, e.g. "staff", "partner", "premium", ... + pub id: String, + /// Description of what the badge represents, e.g. "Discord Staff" + pub description: String, + /// An icon hash, to get the badge's icon from the CDN + pub icon: String, + /// A link (potentially used for href) for the badge. + /// + /// e.g.: + /// `"staff"` badge links to `"https://discord.com/company"` + /// `"certified_moderator"` links to `"https://discord.com/safety"` + pub link: Option, +} + +impl PartialEq for ProfileBadge { + fn eq(&self, other: &Self) -> bool { + // Note: does not include description, since it changes for some badges + // + // Think nitro "Subscriber since ...", "Server boosting since ..." + self.id.eq(&other.id) && self.icon.eq(&other.icon) && self.link.eq(&other.link) + } +} + +impl ProfileBadge { + /// Returns a badge representing the "staff" badge on Discord.com + /// + /// # Reference + /// See + pub fn discord_staff() -> Self { + Self { + id: "staff".to_string(), + description: "Discord Staff".to_string(), + icon: "5e74e9b61934fc1f67c65515d1f7e60d".to_string(), + link: Some("https://discord.com/company".to_string()), + } + } + + /// Returns a badge representing the partnered server owner badge on Discord.com + /// + /// # Reference + /// See + pub fn discord_partner() -> Self { + Self { + id: "partner".to_string(), + description: "Partnered Server Owner".to_string(), + icon: "3f9748e53446a137a052f3454e2de41e".to_string(), + link: Some("https://discord.com/partners".to_string()), + } + } + + /// Returns a badge representing the certified moderator badge on Discord.com + /// + /// # Reference + /// See + pub fn discord_certified_moderator() -> Self { + Self { + id: "certified_moderator".to_string(), + description: "Moderator Programs Alumni".to_string(), + icon: "fee1624003e2fee35cb398e125dc479b".to_string(), + link: Some("https://discord.com/safety".to_string()), + } + } + + /// Returns a badge representing the hypesquad events badge on Discord.com + /// + /// # Reference + /// See + pub fn discord_hypesquad() -> Self { + Self { + id: "hypesquad".to_string(), + description: "HypeSquad Events".to_string(), + icon: "bf01d1073931f921909045f3a39fd264".to_string(), + link: Some("https://support.discord.com/hc/en-us/articles/360035962891-Profile-Badges-101#h_01GM67K5EJ16ZHYZQ5MPRW3JT3".to_string()), + } + } + + /// Returns a badge representing the hypesquad bravery badge on Discord.com + /// + /// # Reference + /// See + pub fn discord_hypesquad_bravery() -> Self { + Self { + id: "hypesquad_house_1".to_string(), + description: "HypeSquad Bravery".to_string(), + icon: "8a88d63823d8a71cd5e390baa45efa02".to_string(), + link: Some("https://discord.com/settings/hypesquad-online".to_string()), + } + } + + /// Returns a badge representing the hypesquad brilliance badge on Discord.com + /// + /// # Reference + /// See + pub fn discord_hypesquad_brilliance() -> Self { + Self { + id: "hypesquad_house_2".to_string(), + description: "HypeSquad Brilliance".to_string(), + icon: "011940fd013da3f7fb926e4a1cd2e618".to_string(), + link: Some("https://discord.com/settings/hypesquad-online".to_string()), + } + } + + /// Returns a badge representing the hypesquad balance badge on Discord.com + /// + /// # Reference + /// See + pub fn discord_hypesquad_balance() -> Self { + Self { + id: "hypesquad_house_3".to_string(), + description: "HypeSquad Balance".to_string(), + icon: "3aa41de486fa12454c3761e8e223442e".to_string(), + link: Some("https://discord.com/settings/hypesquad-online".to_string()), + } + } + + /// Returns a badge representing the bug hunter level 1 badge on Discord.com + /// + /// # Reference + /// See + pub fn discord_bug_hunter_1() -> Self { + Self { + id: "bug_hunter_level_1".to_string(), + description: "Discord Bug Hunter".to_string(), + icon: "2717692c7dca7289b35297368a940dd0".to_string(), + link: Some( + "https://support.discord.com/hc/en-us/articles/360046057772-Discord-Bugs" + .to_string(), + ), + } + } + + /// Returns a badge representing the bug hunter level 2 badge on Discord.com + /// + /// # Reference + /// See + pub fn discord_bug_hunter_2() -> Self { + Self { + id: "bug_hunter_level_2".to_string(), + description: "Discord Bug Hunter".to_string(), + icon: "848f79194d4be5ff5f81505cbd0ce1e6".to_string(), + link: Some( + "https://support.discord.com/hc/en-us/articles/360046057772-Discord-Bugs" + .to_string(), + ), + } + } + + /// Returns a badge representing the active developer badge on Discord.com + /// + /// # Reference + /// See + pub fn discord_active_developer() -> Self { + Self { + id: "active_developer".to_string(), + description: "Active Developer".to_string(), + icon: "6bdc42827a38498929a4920da12695d9".to_string(), + link: Some( + "https://support-dev.discord.com/hc/en-us/articles/10113997751447?ref=badge" + .to_string(), + ), + } + } + + /// Returns a badge representing the early verified bot developer badge on Discord.com + /// + /// # Reference + /// See + pub fn discord_early_verified_developer() -> Self { + Self { + id: "verified_developer".to_string(), + description: "Early Verified Bot Developer".to_string(), + icon: "6df5892e0f35b051f8b61eace34f4967".to_string(), + link: None, + } + } + + /// Returns a badge representing the early supporter badge on Discord.com + /// + /// # Reference + /// See + pub fn discord_early_supporter() -> Self { + Self { + id: "early_supporter".to_string(), + description: "Early Supporter".to_string(), + icon: "7060786766c9c840eb3019e725d2b358".to_string(), + link: Some("https://discord.com/settings/premium".to_string()), + } + } + + /// Returns a badge representing the nitro subscriber badge on Discord.com + /// + /// Note: The description updates for the start date + /// + /// # Reference + /// See + pub fn discord_nitro() -> Self { + Self { + id: "premium".to_string(), + description: "Subscriber since 1 Jan 2015".to_string(), + icon: "2ba85e8026a8614b640c2837bcdfe21b".to_string(), + link: Some("https://discord.com/settings/premium".to_string()), + } + } + + /// Returns a badge representing the level 1 server boosting badge on Discord.com + /// + /// Note: The description updates for the start date + /// + /// # Reference + /// See + pub fn discord_server_boosting_1() -> Self { + Self { + id: "guild_booster_lvl1".to_string(), + description: "Server boosting since 1 Jan 2015".to_string(), + icon: "51040c70d4f20a921ad6674ff86fc95c".to_string(), + link: Some("https://discord.com/settings/premium".to_string()), + } + } + + /// Returns a badge representing the level 2 server boosting badge on Discord.com + /// + /// Note: The description updates for the start date + /// + /// # Reference + /// See + pub fn discord_server_boosting_2() -> Self { + Self { + id: "guild_booster_lvl2".to_string(), + description: "Server boosting since 1 Jan 2015".to_string(), + icon: "0e4080d1d333bc7ad29ef6528b6f2fb7".to_string(), + link: Some("https://discord.com/settings/premium".to_string()), + } + } + + /// Returns a badge representing the level 3 server boosting badge on Discord.com + /// + /// Note: The description updates for the start date + /// + /// # Reference + /// See + pub fn discord_server_boosting_3() -> Self { + Self { + id: "guild_booster_lvl3".to_string(), + description: "Server boosting since 1 Jan 2015".to_string(), + icon: "72bed924410c304dbe3d00a6e593ff59".to_string(), + link: Some("https://discord.com/settings/premium".to_string()), + } + } + + /// Returns a badge representing the level 4 server boosting badge on Discord.com + /// + /// Note: The description updates for the start date + /// + /// # Reference + /// See + pub fn discord_server_boosting_4() -> Self { + Self { + id: "guild_booster_lvl4".to_string(), + description: "Server boosting since 1 Jan 2015".to_string(), + icon: "df199d2050d3ed4ebf84d64ae83989f8".to_string(), + link: Some("https://discord.com/settings/premium".to_string()), + } + } + + /// Returns a badge representing the level 5 server boosting badge on Discord.com + /// + /// Note: The description updates for the start date + /// + /// # Reference + /// See + pub fn discord_server_boosting_5() -> Self { + Self { + id: "guild_booster_lvl5".to_string(), + description: "Server boosting since 1 Jan 2015".to_string(), + icon: "996b3e870e8a22ce519b3a50e6bdd52f".to_string(), + link: Some("https://discord.com/settings/premium".to_string()), + } + } + + /// Returns a badge representing the level 6 server boosting badge on Discord.com + /// + /// Note: The description updates for the start date + /// + /// # Reference + /// See + pub fn discord_server_boosting_6() -> Self { + Self { + id: "guild_booster_lvl6".to_string(), + description: "Server boosting since 1 Jan 2015".to_string(), + icon: "991c9f39ee33d7537d9f408c3e53141e".to_string(), + link: Some("https://discord.com/settings/premium".to_string()), + } + } + + /// Returns a badge representing the level 7 server boosting badge on Discord.com + /// + /// Note: The description updates for the start date + /// + /// # Reference + /// See + pub fn discord_server_boosting_7() -> Self { + Self { + id: "guild_booster_lvl7".to_string(), + description: "Server boosting since 1 Jan 2015".to_string(), + icon: "cb3ae83c15e970e8f3d410bc62cb8b99".to_string(), + link: Some("https://discord.com/settings/premium".to_string()), + } + } + + /// Returns a badge representing the level 8 server boosting badge on Discord.com + /// + /// Note: The description updates for the start date + /// + /// # Reference + /// See + pub fn discord_server_boosting_8() -> Self { + Self { + id: "guild_booster_lvl8".to_string(), + description: "Server boosting since 1 Jan 2015".to_string(), + icon: "7142225d31238f6387d9f09efaa02759".to_string(), + link: Some("https://discord.com/settings/premium".to_string()), + } + } + + /// Returns a badge representing the level 9 server boosting badge on Discord.com + /// + /// Note: The description updates for the start date + /// + /// # Reference + /// See + pub fn discord_server_boosting_9() -> Self { + Self { + id: "guild_booster_lvl9".to_string(), + description: "Server boosting since 1 Jan 2015".to_string(), + icon: "ec92202290b48d0879b7413d2dde3bab".to_string(), + link: Some("https://discord.com/settings/premium".to_string()), + } + } + + /// Returns a badge representing the legacy username badge on Discord.com + /// + /// # Reference + /// See + pub fn discord_legacy_username() -> Self { + Self { + id: "legacy_username".to_string(), + description: "Originally known as USERNAME".to_string(), + icon: "6de6d34650760ba5551a79732e98ed60".to_string(), + link: None, + } + } + + /// Returns a badge representing the legacy username badge on Discord.com, + /// with the provided username (which should already contain the #DISCRIM part) + /// + /// # Reference + /// See + pub fn discord_legacy_username_with_username(username: String) -> Self { + Self { + id: "legacy_username".to_string(), + description: format!("Originally known as {username}"), + icon: "6de6d34650760ba5551a79732e98ed60".to_string(), + link: None, + } + } + + /// Returns a badge representing the legacy username badge on Discord.com, + /// with the provided username and discriminator + /// + /// # Reference + /// See + pub fn discord_legacy_username_with_username_and_discriminator( + username: String, + discriminator: String, + ) -> Self { + Self { + id: "legacy_username".to_string(), + description: format!("Originally known as {username}#{discriminator}"), + icon: "6de6d34650760ba5551a79732e98ed60".to_string(), + link: None, + } + } + + /// Returns a badge representing the bot commands badge on Discord.com + /// + /// Note: This badge is only for bot accounts + /// + /// # Reference + /// See + pub fn discord_bot_commands() -> Self { + Self { + id: "bot_commands".to_string(), + description: "Supports Commands".to_string(), + icon: "6f9e37f9029ff57aef81db857890005e".to_string(), + link: Some( + "https://discord.com/blog/welcome-to-the-new-era-of-discord-apps?ref=badge" + .to_string(), + ), + } + } + + /// Returns a badge representing the bot automod badge on Discord.com + /// + /// Note: This badge is only for bot accounts + /// + /// # Reference + /// See + pub fn discord_bot_automod() -> Self { + Self { + id: "automod".to_string(), + description: "Uses AutoMod".to_string(), + icon: "f2459b691ac7453ed6039bbcfaccbfcd".to_string(), + link: None, + } + } + + /// Returns a badge representing the application guild subscription badge on Discord.com + /// + /// No idea where this badge could show up, but apparently it means a guild has an + /// application's premium + /// + /// # Reference + /// See + pub fn discord_application_guild_subscription() -> Self { + Self { + id: "application_guild_subscription".to_string(), + description: "This server has APPLICATION Premium".to_string(), + icon: "d2010c413a8da2208b7e4f35bd8cd4ac".to_string(), + link: None, + } + } +} + +/// Structure which shows a mutual guild with a user +/// +/// # Reference +/// See +#[derive(Debug, Deserialize, Serialize, Clone, PartialEq, Eq, PartialOrd, Ord)] +pub struct MutualGuild { + pub id: Snowflake, + /// The user's nickname in the guild, if any + pub nick: Option, +} + +/// Structure which is returned by the [crate::instance::ChorusUser::get_user_note] endpoint. +/// +/// Note that [crate::instance::ChorusUser::get_user_notes] endpoint +/// returns a completely different structure; +// Specualation: this is probably how Discord stores notes internally +/// +/// # Reference +/// See +#[derive(Debug, Deserialize, Serialize, Clone, PartialEq, Eq, PartialOrd, Ord)] +#[cfg_attr(feature = "sqlx", derive(sqlx::FromRow))] +pub struct UserNote { + /// Actual note contents; max 256 characters + pub note: String, + /// The ID of the user the note is on + pub note_user_id: Snowflake, + /// The ID of the user who created the note (always the current user) + pub user_id: Snowflake, +} + +/// Structure which defines an affinity the local user has with another user. +/// +/// # Reference +/// See +#[derive(Debug, Deserialize, Serialize, Clone, Copy, PartialEq, PartialOrd)] +pub struct UserAffinity { + /// The other user's id + pub user_id: Snowflake, + /// The affinity score + pub affinity: f32, +} + +/// Structure which defines an affinity the local user has with a guild. +/// +/// # Reference +/// See +#[derive(Debug, Deserialize, Serialize, Clone, Copy, PartialEq, PartialOrd)] +pub struct GuildAffinity { + /// The guild's id + pub guild_id: Snowflake, + /// The affinity score + pub affinity: f32, +} + +/// Structure which defines the local user's premium perk usage. +/// +/// # Reference +/// See +#[derive(Debug, Deserialize, Serialize, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] +pub struct PremiumUsage { + /// Number of Nitro stickers the user has sent + pub nitro_sticker_sends: PremiumUsageData, + /// Number of animated emojis the user has sent + pub total_animated_emojis: PremiumUsageData, + /// Number of global emojis the user has sent + pub total_global_emojis: PremiumUsageData, + /// Number of large uploads the user has made + pub total_large_uploads: PremiumUsageData, + /// Number of times the user has streamed in HD + pub total_hd_streams: PremiumUsageData, + /// Number of hours the user has streamed in HD + pub hd_hours_streamed: PremiumUsageData, +} + +/// Structure for the data in [PremiumUsage]. +/// +/// Currently only contains the number of uses of a premium perk. +/// +/// # Reference +/// See +#[derive(Debug, Deserialize, Serialize, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] +pub struct PremiumUsageData { + /// Total number of uses for this perk + pub value: usize, +} + +impl From for usize { + fn from(value: PremiumUsageData) -> Self { + value.value + } +} + +impl From for PremiumUsageData { + fn from(value: usize) -> Self { + PremiumUsageData { value } + } +} diff --git a/src/types/events/message.rs b/src/types/events/message.rs index 1b855dfc..d2ca3082 100644 --- a/src/types/events/message.rs +++ b/src/types/events/message.rs @@ -149,6 +149,15 @@ pub struct MessageReactionRemoveEmoji { pub emoji: Emoji, } +#[derive(Debug, Serialize, Deserialize, Default, Clone, Copy, WebSocketEvent)] +/// Sent when a message that mentioned the current user in the last week is acknowledged and deleted. +/// +/// # Reference +/// See +pub struct RecentMentionDelete { + pub message_id: Snowflake, +} + #[derive(Debug, Deserialize, Serialize, Default, Clone, WebSocketEvent)] /// Officially Undocumented /// diff --git a/src/types/events/ready.rs b/src/types/events/ready.rs index ffba526a..d8c11de1 100644 --- a/src/types/events/ready.rs +++ b/src/types/events/ready.rs @@ -2,38 +2,176 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. +use std::collections::HashMap; + use serde::{Deserialize, Serialize}; use crate::types::entities::{Guild, User}; use crate::types::events::{Session, WebSocketEvent}; -use crate::types::{Activity, Channel, ClientStatusObject, GuildMember, PresenceUpdate, Snowflake, VoiceState}; +use crate::types::{ + Activity, Channel, ClientStatusObject, GuildMember, PresenceUpdate, Relationship, Snowflake, + UserSettings, VoiceState, +}; +use crate::{UInt32, UInt64, UInt8}; #[derive(Debug, Deserialize, Serialize, Default, Clone, WebSocketEvent)] -/// 1/2 officially documented; -/// Received after identifying, provides initial user info; +/// Received after identifying, provides initial user information and client state. /// /// See and -// TODO: There are a LOT of fields missing here pub struct GatewayReady { - pub analytics_token: Option, - pub auth_session_id_hash: Option, - pub country_code: Option, - - pub v: u8, + #[serde(default)] + /// An array of stringified JSON values representing the connection trace, used for debugging + pub _trace: Vec, + /// The token used for analytical tracking requests + pub analytics_token: String, + /// The hash of the auth session ID corresponding to the auth token used to connect + pub auth_session_id_hash: String, + /// The detected ISO 3166-1 alpha-2 country code of the user's current IP address + pub country_code: String, + #[serde(rename = "v")] + /// API version + pub api_version: UInt8, + /// The connected user pub user: User, - /// For bots these are [crate::types::UnavailableGuild]s, for users they are [Guild] + #[serde(default)] + /// The guilds the user is in pub guilds: Vec, + /// The presences of the user's non-offline friends and implicit relationships (depending on the `NO_AFFINE_USER_IDS` Gateway capability). pub presences: Option>, pub sessions: Option>, + /// Unique session ID, used for resuming connections + pub session_id: String, + /// The type of session that was started + pub session_type: String, + /// WebSocket URL for resuming connections + pub resume_gateway_url: String, + /// The shard information (shard_id, num_shards) associated with this session, if sharded + pub shard: Option<(UInt64, UInt64)>, + /// The client settings for the user + pub user_settings: Option, + /// The base-64 encoded preloaded user settings for the user, (if missing, defaults are used) + pub user_settings_proto: Option, + #[serde(default)] + /// The relationships the user has with other users + pub relationships: Vec, + /// The number of friend suggestions the user has + pub friend_suggestion_count: UInt32, + #[serde(default)] + /// The DMs and group DMs the user is participating in + pub private_channels: Vec, + #[serde(default)] + /// A mapping of user IDs to notes the user has made for them + pub notes: HashMap, + /// The presences of the user's non-offline friends and implicit relationships (depending on the `NO_AFFINE_USER_IDS` Gateway capability), and any guild presences sent at startup + pub merged_presences: Option, + #[serde(default)] + /// The deduped users across all objects in the event + pub users: Vec, + /// The refreshed auth token for this user; if present, the client should discard the current auth token and use this in subsequent requests to the API + pub auth_token: Option, + #[serde(default)] + /// The types of multi-factor authenticators the user has enabled + pub authenticator_types: Vec, + /// The action a user is required to take before continuing to use Discord + pub required_action: Option, + #[serde(default)] + /// A geo-ordered list of RTC regions that can be used when when setting a voice channel's `rtc_region` or updating the client's voice state + pub geo_ordered_rtc_regions: Vec, + /// The tutorial state of the user, if any + /// TODO: Make tutorial object into object + pub tutorial: Option, + /// The API code version, used when re-identifying with client state v2 + pub api_code_version: UInt8, + #[serde(default)] + /// User experiment rollouts for the user + /// TODO: Make User Experiments into own struct + pub experiments: Vec, + #[serde(default)] + /// Guild experiment rollouts for the user + /// TODO: Make Guild Experiments into own struct + pub guild_experiments: Vec, +} + +#[derive(Debug, Deserialize, Serialize, Default, Clone, WebSocketEvent)] +/// Received after identifying, provides initial information about the bot session. +/// +/// See and +pub struct GatewayReadyBot { + #[serde(default)] + /// An array of stringified JSON values representing the connection trace, used for debugging + pub _trace: Vec, + #[serde(rename = "v")] + /// API version + pub api_version: UInt8, + /// The connected bot user + pub user: User, + #[serde(default)] + /// The guilds the bot user is in. Will be `UnavailableGuilds` at first. + pub guilds: Vec, + /// The presences of the user's non-offline friends and implicit relationships (depending on the `NO_AFFINE_USER_IDS` Gateway capability). + pub presences: Option>, + /// Unique session ID, used for resuming connections pub session_id: String, - pub session_type: Option, - pub resume_gateway_url: Option, - pub shard: Option<(u64, u64)>, + /// The type of session that was started + pub session_type: String, + /// WebSocket URL for resuming connections + pub resume_gateway_url: String, + /// The shard information (shard_id, num_shards) associated with this session, if sharded + pub shard: Option<(UInt64, UInt64)>, + /// The presences of the user's non-offline friends and implicit relationships (depending on the `NO_AFFINE_USER_IDS` Gateway capability), and any guild presences sent at startup + pub merged_presences: Option, + #[serde(default)] + /// The deduped users across all objects in the event + pub users: Vec, + #[serde(default)] + /// The types of multi-factor authenticators the user has enabled + pub authenticator_types: Vec, + #[serde(default)] + /// A geo-ordered list of RTC regions that can be used when when setting a voice channel's `rtc_region` or updating the client's voice state + pub geo_ordered_rtc_regions: Vec, + /// The API code version, used when re-identifying with client state v2 + pub api_code_version: UInt8, +} + +impl From for GatewayReadyBot { + fn from(value: GatewayReady) -> Self { + GatewayReadyBot { + api_version: value.api_version, + user: value.user, + guilds: value.guilds, + presences: value.presences, + session_id: value.session_id, + session_type: value.session_type, + resume_gateway_url: value.resume_gateway_url, + shard: value.shard, + merged_presences: value.merged_presences, + users: value.users, + authenticator_types: value.authenticator_types, + geo_ordered_rtc_regions: value.geo_ordered_rtc_regions, + api_code_version: value.api_code_version, + _trace: value._trace, + } + } +} + +impl GatewayReady { + /// Convert this struct into a [GatewayReadyBot] struct + pub fn to_bot(self) -> GatewayReadyBot { + self.into() + } +} +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, Hash)] +#[cfg_attr(not(feature = "sqlx"), repr(u8))] +#[cfg_attr(feature = "sqlx", repr(i16))] +pub enum AuthenticatorType { + WebAuthn = 1, + Totp = 2, + Sms = 3, } #[derive(Debug, Deserialize, Serialize, Default, Clone, WebSocketEvent)] /// Officially Undocumented; -/// Sent after the READY event when a client is a user, +/// Sent after the READY event when a client is a user, /// seems to somehow add onto the ready event; /// /// See @@ -52,7 +190,7 @@ pub struct GatewayReadySupplemental { pub struct MergedPresences { /// "Presences of the user's guilds in the same order as the guilds array in ready" /// (discord.sex) - pub guilds: Vec>, + pub guilds: Vec>, /// "Presences of the user's friends and implicit relationships" (discord.sex) pub friends: Vec, } diff --git a/src/types/events/request_members.rs b/src/types/events/request_members.rs index a6cffdf6..5228170d 100644 --- a/src/types/events/request_members.rs +++ b/src/types/events/request_members.rs @@ -5,7 +5,7 @@ use crate::types::{events::WebSocketEvent, Snowflake}; use serde::{Deserialize, Serialize}; -#[derive(Debug, Deserialize, Serialize, Default, WebSocketEvent)] +#[derive(Debug, Deserialize, Serialize, Default, WebSocketEvent, Clone)] /// See pub struct GatewayRequestGuildMembers { pub guild_id: Snowflake, @@ -16,4 +16,3 @@ pub struct GatewayRequestGuildMembers { pub user_ids: Option, pub nonce: Option, } - diff --git a/src/types/events/session.rs b/src/types/events/session.rs index a76ebc3c..8adaa336 100644 --- a/src/types/events/session.rs +++ b/src/types/events/session.rs @@ -30,7 +30,7 @@ pub struct Session { // Note: I don't think this one exists yet? Though I might've made a mistake and this might be a duplicate pub struct ClientInfo { pub client: Option, - pub os: String, + pub os: Option, pub version: u8, } diff --git a/src/types/events/user.rs b/src/types/events/user.rs index 877c96cd..fc72be4b 100644 --- a/src/types/events/user.rs +++ b/src/types/events/user.rs @@ -7,6 +7,7 @@ use serde::{Deserialize, Serialize}; use crate::types::entities::PublicUser; use crate::types::events::WebSocketEvent; use crate::types::utils::Snowflake; +use crate::types::Connection; #[derive(Debug, Default, Deserialize, Serialize, Clone, PartialEq, Eq, WebSocketEvent)] /// See ; @@ -16,6 +17,27 @@ pub struct UserUpdate { pub user: PublicUser, } +#[derive(Debug, Deserialize, Serialize, Clone, PartialEq, Eq, WebSocketEvent)] +/// Sent to indicate updates to a user's [Connection]. +/// +/// Not documented anywhere +pub struct UserConnectionsUpdate { + #[serde(flatten)] + pub connection: Connection, +} + +#[derive(Debug, Default, Deserialize, Serialize, Clone, PartialEq, Eq, WebSocketEvent)] +/// See ; +/// +/// Sent when a note the current user has on another user is modified; +/// +/// If the field "note" is an empty string, the note was removed. +pub struct UserNoteUpdate { + /// Id of the user the note is for + pub id: Snowflake, + pub note: String, +} + #[derive(Debug, Default, Deserialize, Serialize, Clone, PartialEq, Eq, WebSocketEvent)] /// Undocumented; /// diff --git a/src/types/schema/instance.rs b/src/types/schema/instance.rs new file mode 100644 index 00000000..7d19483c --- /dev/null +++ b/src/types/schema/instance.rs @@ -0,0 +1,84 @@ +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/. + +//! Contains schema for miscellaneous api routes, such as /version and /ping +//! +//! Implementations of those routes can be found in /api/instance.rs + +use serde::{Deserialize, Serialize}; + +use crate::types::{GeneralConfiguration, Snowflake}; + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +/// The return type of the spacebar-only /api/ping endpoint +pub struct PingReturn { + /// Note: always "pong!" + pub ping: String, + pub instance: PingInstance, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Hash)] +#[serde(rename_all = "camelCase")] +/// [GeneralConfiguration] as returned from the /api/ping endpoint +pub struct PingInstance { + pub id: Option, + pub name: String, + pub description: Option, + pub image: Option, + pub correspondence_email: Option, + pub correspondence_user_id: Option, + pub front_page: Option, + pub tos_page: Option, +} + +impl PingInstance { + /// Converts self into the [GeneralConfiguration] type + pub fn into_general_configuration(self) -> GeneralConfiguration { + GeneralConfiguration { + instance_name: self.name, + instance_description: self.description, + front_page: self.front_page, + tos_page: self.tos_page, + correspondence_email: self.correspondence_email, + correspondence_user_id: self.correspondence_user_id, + image: self.image, + instance_id: self.id, + } + } + + /// Converts the [GeneralConfiguration] type into self + pub fn from_general_configuration(other: GeneralConfiguration) -> Self { + Self { + id: other.instance_id, + name: other.instance_name, + description: other.instance_description, + image: other.image, + correspondence_email: other.correspondence_email, + correspondence_user_id: other.correspondence_user_id, + front_page: other.front_page, + tos_page: other.tos_page, + } + } +} + +impl From for GeneralConfiguration { + fn from(value: PingInstance) -> Self { + value.into_general_configuration() + } +} + +impl From for PingInstance { + fn from(value: GeneralConfiguration) -> Self { + Self::from_general_configuration(value) + } +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +/// The return type of the symfonia-only /version endpoint +pub struct VersionReturn { + /// The instance's software version, e. g. "0.1.0" + pub version: String, + /// The instance's software, e. g. "symfonia" or "spacebar" + pub server: String, +} diff --git a/src/types/schema/mod.rs b/src/types/schema/mod.rs index 09e542e4..2888046e 100644 --- a/src/types/schema/mod.rs +++ b/src/types/schema/mod.rs @@ -13,6 +13,7 @@ pub use role::*; pub use user::*; pub use invites::*; pub use voice_state::*; +pub use instance::*; mod apierror; mod audit_log; @@ -25,9 +26,10 @@ mod role; mod user; mod invites; mod voice_state; +mod instance; #[derive(Debug, serde::Deserialize, serde::Serialize, Clone, PartialEq, PartialOrd, Eq, Ord)] pub struct GenericSearchQueryWithLimit { pub query: String, pub limit: Option, -} \ No newline at end of file +} diff --git a/src/types/schema/user.rs b/src/types/schema/user.rs index 9e25093f..7a2543a6 100644 --- a/src/types/schema/user.rs +++ b/src/types/schema/user.rs @@ -4,10 +4,13 @@ use std::collections::HashMap; -use chrono::NaiveDate; +use chrono::{DateTime, NaiveDate, Utc}; use serde::{Deserialize, Serialize}; -use crate::types::Snowflake; +use crate::types::{ + Connection, GuildAffinity, HarvestBackendType, Snowflake, ThemeColors, TwoWayLinkType, + UserAffinity, +}; #[derive(Debug, Default, Deserialize, Serialize, Clone, PartialEq, Eq)] #[serde(rename_all = "snake_case")] @@ -41,7 +44,12 @@ pub struct UserModifySchema { pub email: Option, /// The user's email token from their previous email, required if a new email is set. /// - /// See and + /// See: + /// + /// - the endpoints and + /// + /// - the relevant methods [`ChorusUser::initiate_email_change`](crate::instance::ChorusUser::initiate_email_change) and [`ChorusUser::verify_email_change`](crate::instance::ChorusUser::verify_email_change) + /// /// for changing the user's email. /// /// # Note @@ -111,3 +119,347 @@ pub struct PrivateChannelCreateSchema { pub access_tokens: Option>, pub nicks: Option>, } + +#[derive(Debug, Default, Deserialize, Serialize, Clone, PartialEq, Eq)] +/// A schema used to modify the current user's profile. +/// +/// Similar to [crate::types::UserProfileMetadata] +/// +/// See +pub struct UserModifyProfileSchema { + // Note: one of these causes a 500 if it is sent + #[serde(skip_serializing_if = "Option::is_none")] + /// The user's new pronouns (max 40 characters) + pub pronouns: Option, + + #[serde(skip_serializing_if = "Option::is_none")] + /// The user's new bio (max 190 characters) + pub bio: Option, + + #[serde(skip_serializing_if = "Option::is_none")] + // TODO: Add banner -- do we have an image data struct + /// The user's new accent color encoded as an i32 representation of a hex color code + pub accent_color: Option, + + // Note: without the skip serializing this currently (2024/07/28) causes a 500! + // + // Which in turns locks the user's account, requiring phone number verification + #[serde(skip_serializing_if = "Option::is_none")] + /// The user's new [ThemeColors] + pub theme_colors: Option, + + #[serde(skip_serializing_if = "Option::is_none")] + /// The user's new profile popup animation particle type + pub popout_animation_particle_type: Option, + + #[serde(skip_serializing_if = "Option::is_none")] + /// The user's new profile emoji id + pub emoji_id: Option, + + #[serde(skip_serializing_if = "Option::is_none")] + /// The user's new profile ffect id + pub profile_effect_id: Option, +} + +#[derive(Debug, Default, Deserialize, Serialize, Clone, PartialEq, Eq)] +/// A schema used to delete or disable the current user's profile. +/// +/// See and +/// +pub struct DeleteDisableUserSchema { + /// The user's current password, if any + pub password: Option, +} + +#[derive(Debug, Default, Deserialize, Serialize, Clone, PartialEq, Eq)] +/// A schema used for [ChorusUser::verify_email_change](crate::instance::ChorusUser::verify_email_change) +/// +/// See +pub struct VerifyUserEmailChangeSchema { + /// The verification code sent to the user's email + pub code: String, +} + +#[derive(Debug, Default, Deserialize, Serialize, Clone, PartialEq, Eq)] +/// The return type of [ChorusUser::verify_email_change](crate::instance::ChorusUser::verify_email_change) +/// +/// See +pub struct VerifyUserEmailChangeResponse { + /// The email_token to be used in [ChorusUser::modify](crate::instance::ChorusUser::modify) + #[serde(rename = "token")] + pub email_token: String, +} + +#[derive(Debug, Default, Deserialize, Serialize, Clone, Copy, PartialEq, Eq)] +/// Query string parameters for the route GET /users/{user.id}/profile +/// ([crate::types::User::get_profile]) +/// +/// See +pub struct GetUserProfileSchema { + #[serde(skip_serializing_if = "Option::is_none")] + /// Whether to include the mutual guilds between the current user. + /// + /// If unset it will default to true + pub with_mutual_guilds: Option, + #[serde(skip_serializing_if = "Option::is_none")] + /// Whether to include the mutual friends between the current user. + /// + /// If unset it will default to false + pub with_mutual_friends: Option, + #[serde(skip_serializing_if = "Option::is_none")] + /// Whether to include the number of mutual friends between the current user + /// + /// If unset it will default to false + pub with_mutual_friends_count: Option, + #[serde(skip_serializing_if = "Option::is_none")] + /// The guild id to get the user's member profile in, if any. + /// + /// Note: + /// + /// when you click on a user in the member list in the discord client, a request is sent with + /// this property set to the selected guild id. + /// + /// This makes the request include fields such as guild_member and guild_member_profile + pub guild_id: Option, + #[serde(skip_serializing_if = "Option::is_none")] + /// The role id to get the user's application role connection metadata in, if any. + pub connections_role_id: Option, +} + +#[derive(Debug, Deserialize, Serialize, Clone, PartialEq, Eq)] +/// Internal type for the [crate::instance::ChorusUser::get_pomelo_suggestions] endpoint. +/// +/// See +pub(crate) struct GetPomeloSuggestionsReturn { + pub username: String, +} + +#[derive(Debug, Deserialize, Serialize, Clone, PartialEq, Eq)] +/// Internal type for the [crate::instance::ChorusUser::get_pomelo_eligibility] endpoint. +/// +/// See +pub(crate) struct GetPomeloEligibilityReturn { + pub taken: bool, +} + +#[derive(Debug, Default, Deserialize, Serialize, Clone, Copy, PartialEq, Eq)] +/// Query string parameters for the route GET /users/@me/mentions +/// ([crate::instance::ChorusUser::get_recent_mentions]) +/// +/// See +pub struct GetRecentMentionsSchema { + /// Only fetch messages before this message id + /// + /// Due to the nature of snowflakes, this can be easily used to fetch + /// messages before a certain timestamp + pub before: Option, + /// Max number of messages to return + /// + /// Should be between 1 and 100. + /// + /// If unset the limit is 25 messages + pub limit: Option, + /// Limit messages to a specific guild + pub guild_id: Option, + /// Whether to include role mentions. + /// + /// If unset the server assumes true + pub roles: Option, + /// Whether to include @everyone and @here mentions. + /// + /// If unset the server assumes true + pub everyone: Option, +} + +#[derive(Debug, Deserialize, Serialize, Clone, PartialEq, Eq)] +/// Internal type for the [crate::instance::ChorusUser::create_harvest] endpoint. +// (koza): imo it's nicer if the user can just pass a vec, instead of having to bother with +// a specific type +/// +/// See +pub(crate) struct CreateUserHarvestSchema { + pub backends: Option>, +} + +#[derive(Debug, Deserialize, Serialize, Clone, PartialEq, Eq)] +/// Internal type for the [crate::instance::ChorusUser::set_user_note] endpoint. +/// +/// See +pub(crate) struct ModifyUserNoteSchema { + pub note: Option, +} + +#[derive(Debug, Default, Deserialize, Serialize, Clone, PartialEq, Eq)] +/// Query string parameters for the route GET /connections/{connection.type}/authorize +/// ([crate::instance::ChorusUser::authorize_connection]) +/// +/// See +pub struct AuthorizeConnectionSchema { + /// The type of two-way link ([TwoWayLinkType]) to create + pub two_way_link_type: Option, + /// The device code to use for the two-way link + pub two_way_user_code: Option, + /// If this is a continuation of a previous authorization + pub continuation: bool, +} + +#[derive(Debug, Deserialize, Serialize, Clone, PartialEq, Eq)] +/// Internal type for the [crate::instance::ChorusUser::authorize_connection] endpoint. +/// +/// See +pub(crate) struct AuthorizeConnectionReturn { + pub url: String, +} + +#[derive(Debug, Default, Deserialize, Serialize, Clone, PartialEq, Eq)] +/// Json schema for the route POST /connections/{connection.type}/callback ([crate::instance::ChorusUser::create_connection_callback]). +/// +/// See +pub struct CreateConnectionCallbackSchema { + /// The authorization code for the connection + pub code: String, + /// The "state" used to authorize a connection + // TODO: what is this? + pub state: String, + pub two_way_link_code: Option, + pub insecure: Option, + pub friend_sync: Option, + /// Additional parameters used for OpenID Connect + // FIXME: Is this correct? in other connections additional info + // is provided like this, only being string - string + pub openid_params: Option>, +} + +#[derive(Debug, Default, Deserialize, Serialize, Clone, PartialEq, Eq)] +/// Json schema for the route PUT /users/@me/connections/contacts/{connection.id} ([crate::instance::ChorusUser::create_contact_sync_connection]). +/// +/// See +pub struct CreateContactSyncConnectionSchema { + /// The username of the connection account + pub name: String, + /// Whether to sync friends over the connection + pub friend_sync: Option, +} + +#[derive(Debug, Default, Deserialize, Serialize, Clone, PartialEq, Eq)] +/// Json schema for the route PATCH /users/@me/connections/{connection.type}/{connection.id} ([crate::instance::ChorusUser::modify_connection]). +/// +/// Note: not all connection types support all parameters. +/// +/// See +pub struct ModifyConnectionSchema { + /// The connection account's username + /// + /// Note: We have not found which connection this could apply to + #[serde(skip_serializing_if = "Option::is_none")] + pub name: Option, + + /// Whether activities related to this connection will be shown in presence + /// + /// e.g. on a Spotify connection, "Display Spotify as your status" + #[serde(skip_serializing_if = "Option::is_none")] + pub show_activity: Option, + + /// Whether or not to sync friends from this connection + /// + /// Note: we have not found which connections this can apply to + #[serde(skip_serializing_if = "Option::is_none")] + pub friend_sync: Option, + + /// Whether to show additional metadata on the user's profile + /// + /// e.g. on a Steam connection, "Display details on profile" + /// (number of games, items, member since) + /// + /// on a Twitter connection, number of posts / followers, member since + #[serde(skip_serializing_if = "Option::is_none")] + pub metadata_visibility: Option, + + /// Whether to show the connection on the user's profile + #[serde(skip_serializing_if = "Option::is_none")] + pub visibility: Option, +} + +#[derive(Debug, Deserialize, Serialize, Clone, PartialEq, Eq)] +/// Internal type for the [crate::instance::ChorusUser::get_connection_access_token] endpoint. +/// +/// See +pub(crate) struct GetConnectionAccessTokenReturn { + pub access_token: String, +} + +#[derive(Debug, Deserialize, Serialize, Clone, PartialEq, PartialOrd)] +/// Return type for the [crate::instance::ChorusUser::get_user_affinities] endpoint. +/// +/// See +pub struct UserAffinities { + pub user_affinities: Vec, + // FIXME: Is this also a UserAffinity vec? + // Also, no idea what this means + pub inverse_user_affinities: Vec, +} + +#[derive(Debug, Deserialize, Serialize, Clone, PartialEq, PartialOrd)] +/// Return type for the [crate::instance::ChorusUser::get_guild_affinities] endpoint. +/// +/// See +pub struct GuildAffinities { + pub guild_affinities: Vec, +} + +#[derive(Debug, Deserialize, Serialize, Clone, PartialEq, Eq, PartialOrd, Ord)] +/// Return type for the error in the [crate::instance::ChorusUser::create_domain_connection] endpoint. +/// +/// This allows us to retrieve the needed proof for actually verifying the connection. +/// +/// See +pub(crate) struct CreateDomainConnectionError { + pub message: String, + pub code: u16, + pub proof: String, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +/// Return type for the [crate::instance::ChorusUser::create_domain_connection] endpoint. +/// +/// See +pub enum CreateDomainConnectionReturn { + /// Additional proof is needed to verify domain ownership. + /// + /// The inner object is a proof string (e.g. + /// `dh=dceaca792e3c40fcf356a9297949940af5cfe538`) + /// + /// To verify ownership, either: + /// + /// - add the proof string as a TXT DNS record to the domain, + /// with the name of the record being `_discord.{domain}` or + /// + /// - serve the proof string as a file at `https://{domain}/.well-known/discord` + /// + /// After either of these proofs are added, the request should be retried. + /// + ProofNeeded(String), + /// The domain connection was successfully created, no further action is needed. + /// + /// The inner object is the new connection. + Ok(Connection), +} + +#[derive(Debug, Serialize, Deserialize, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] +/// Return type for the [crate::instance::ChorusUser::get_burst_credits] endpoint. +/// +/// # Reference +/// ```json +/// { +/// "amount": 2, +/// "replenished_today": false, +/// "next_replenish_at": "2024-08-18T23:53:17+00:00" +/// } +/// ``` +pub struct BurstCreditsInfo { + /// Amount of remaining burst credits the local user has + pub amount: u16, + pub replenished_today: bool, + /// When the user's burst credits will automatically replenish again + pub next_replenish_at: DateTime, +} diff --git a/tests/common/mod.rs b/tests/common/mod.rs index 4b4f9c14..ac85b859 100644 --- a/tests/common/mod.rs +++ b/tests/common/mod.rs @@ -5,7 +5,7 @@ use std::str::FromStr; use chorus::gateway::{Gateway, GatewayOptions}; -use chorus::types::{IntoShared, PermissionFlags}; +use chorus::types::{DeleteDisableUserSchema, IntoShared, PermissionFlags}; use chorus::{ instance::{ChorusUser, Instance}, types::{ @@ -146,5 +146,9 @@ pub(crate) async fn setup() -> TestBundle { pub(crate) async fn teardown(mut bundle: TestBundle) { let id = bundle.guild.read().unwrap().id; Guild::delete(&mut bundle.user, id).await.unwrap(); - bundle.user.delete().await.unwrap() + bundle + .user + .delete(DeleteDisableUserSchema { password: None }) + .await + .unwrap() } diff --git a/tests/instance.rs b/tests/instance.rs index eb5fc606..d83e5e74 100644 --- a/tests/instance.rs +++ b/tests/instance.rs @@ -3,6 +3,7 @@ // file, You can obtain one at http://mozilla.org/MPL/2.0/. mod common; +use chorus::instance::InstanceSoftware; #[cfg(target_arch = "wasm32")] use wasm_bindgen_test::*; #[cfg(target_arch = "wasm32")] @@ -19,3 +20,16 @@ async fn generate_general_configuration_schema() { .unwrap(); common::teardown(bundle).await; } + +#[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)] +#[cfg_attr(not(target_arch = "wasm32"), tokio::test)] +async fn detect_instance_software() { + let bundle = common::setup().await; + + let software = bundle.instance.detect_software().await; + assert_eq!(software, InstanceSoftware::SpacebarTypescript); + + assert_eq!(bundle.instance.software(), InstanceSoftware::SpacebarTypescript); + + common::teardown(bundle).await; +} diff --git a/tests/user.rs b/tests/user.rs index 2fbc1874..e267ae36 100644 --- a/tests/user.rs +++ b/tests/user.rs @@ -2,7 +2,19 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. -use chorus::types::{PublicUser, Snowflake, User}; +use chorus::{ + errors::ChorusError, + types::{ + ConnectionType, DeleteDisableUserSchema, PublicUser, Snowflake, User, + UserModifyProfileSchema, UserNote, + }, +}; +#[cfg(target_arch = "wasm32")] +use wasm_bindgen_test::*; +#[cfg(target_arch = "wasm32")] +wasm_bindgen_test_configure!(run_in_browser); + +mod common; #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)] #[cfg_attr(not(target_arch = "wasm32"), test)] @@ -20,3 +32,181 @@ fn to_public_user() { let from_user = user.into_public_user(); assert_eq!(public_user, from_user); } + +#[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)] +#[cfg_attr(not(target_arch = "wasm32"), tokio::test)] +async fn test_get_user_profile() { + let mut bundle = common::setup().await; + + let user_id = bundle.user.object.read().unwrap().id; + + let user_profile = bundle + .user + .get_user_profile(user_id, chorus::types::GetUserProfileSchema::default()) + .await; + + assert!(user_profile.is_ok()); + + common::teardown(bundle).await; +} + +#[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)] +#[cfg_attr(not(target_arch = "wasm32"), tokio::test)] +async fn test_modify_user_profile() { + let mut bundle = common::setup().await; + + let bio = Some(String::from("A user.")); + let pronouns = Some(String::from("they/them")); + + let modify = UserModifyProfileSchema { + bio: bio.clone(), + pronouns: pronouns.clone(), + ..Default::default() + }; + + bundle.user.modify_profile(modify).await.unwrap(); + + let user_id = bundle.user.object.read().unwrap().id; + + let user_profile = bundle + .user + .get_user_profile(user_id, chorus::types::GetUserProfileSchema::default()) + .await + .unwrap(); + + assert_eq!(user_profile.profile_metadata.bio, bio); + assert_eq!(user_profile.profile_metadata.pronouns, pronouns.unwrap()); + + common::teardown(bundle).await; +} + +#[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)] +#[cfg_attr(not(target_arch = "wasm32"), tokio::test)] +async fn test_disable_user() { + let mut bundle = common::setup().await; + + let mut other_user = bundle.create_user("integrationtestuser4").await; + + other_user + .disable(DeleteDisableUserSchema { password: None }) + .await + .unwrap(); + + common::teardown(bundle).await; +} + +// Note: these two tests are currently broken. +// FIXME: readd them once bitfl0wer/server#2 is merged +/* +#[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)] +#[cfg_attr(not(target_arch = "wasm32"), tokio::test)] +async fn test_get_user_note() { + let mut bundle = common::setup().await; + + let mut other_user = bundle.create_user("integrationtestuser3").await; + + let user_id = bundle.user.object.read().unwrap().id; + let other_user_id = other_user.object.read().unwrap().id; + + let result = bundle.user.get_user_note(other_user_id).await; + assert!(matches!( + result.err().unwrap(), + ChorusError::NotFound { .. } + )); + + bundle + .user + .set_user_note(other_user_id, Some(String::from("A note."))) + .await + .unwrap(); + + assert!(false); + + let result = bundle.user.get_user_note(other_user_id).await; + assert_eq!( + result, + Ok(UserNote { + user_id, + note_user_id: other_user_id, + note: String::from("A note.") + }) + ); + + other_user + .delete(DeleteDisableUserSchema { password: None }) + .await + .unwrap(); + common::teardown(bundle).await; +} + +#[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)] +#[cfg_attr(not(target_arch = "wasm32"), tokio::test)] +async fn test_set_user_note() { + let mut bundle = common::setup().await; + + let mut other_user = bundle.create_user("integrationtestuser3").await; + + let user_id = bundle.user.object.read().unwrap().id; + let other_user_id = other_user.object.read().unwrap().id; + + bundle + .user + .set_user_note(other_user_id, Some(String::from("A note."))) + .await + .unwrap(); + + let result = bundle.user.get_user_note(other_user_id).await; + assert_eq!( + result, + Ok(UserNote { + user_id, + note_user_id: other_user_id, + note: String::from("A note.") + }) + ); + + other_user + .delete(DeleteDisableUserSchema { password: None }) + .await + .unwrap(); + common::teardown(bundle).await; +}*/ + +#[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)] +#[cfg_attr(not(target_arch = "wasm32"), tokio::test)] +async fn test_get_user_affinities() { + let mut bundle = common::setup().await; + + let result = bundle.user.get_user_affinities().await.unwrap(); + + assert!(result.user_affinities.is_empty()); + assert!(result.inverse_user_affinities.is_empty()); + + common::teardown(bundle).await; +} + +#[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)] +#[cfg_attr(not(target_arch = "wasm32"), tokio::test)] +async fn test_get_guild_affinities() { + let mut bundle = common::setup().await; + + let result = bundle.user.get_guild_affinities().await.unwrap(); + + assert!(result.guild_affinities.is_empty()); + + common::teardown(bundle).await; +} + +#[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)] +#[cfg_attr(not(target_arch = "wasm32"), tokio::test)] +async fn test_get_connections() { + let mut bundle = common::setup().await; + + let result = bundle.user.get_connections().await.unwrap(); + + // We can't *really* test creating or getting connections... + // TODO: Find a way? + assert!(result.is_empty()); + + common::teardown(bundle).await; +}