diff --git a/.gitignore b/.gitignore index 8483bc435..1e8bce961 100644 --- a/.gitignore +++ b/.gitignore @@ -11,4 +11,6 @@ venv/ .vercel .DS_Store -.idea \ No newline at end of file +livekit.yml +.idea +start \ No newline at end of file diff --git a/Cargo.lock b/Cargo.lock index 01223df48..8843305f8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -136,9 +136,9 @@ dependencies = [ [[package]] name = "anyhow" -version = "1.0.99" +version = "1.0.100" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b0674a1ddeecb70197781e945de4b3b8ffb61fa939a5597bcf48503737663100" +checksum = "a23eb6b1614318a8071c9b2521f36b424b2c83db5eb3a0fead4a6c0809af6e61" dependencies = [ "backtrace", ] @@ -822,11 +822,11 @@ dependencies = [ "hyper-util", "pin-project-lite 0.2.16", "rustls 0.21.12", - "rustls 0.23.31", + "rustls 0.23.32", "rustls-native-certs 0.8.1", "rustls-pki-types", "tokio 1.47.1", - "tokio-rustls 0.26.2", + "tokio-rustls 0.26.3", "tower", "tracing", ] @@ -1067,7 +1067,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "11eeb275b20a4c750c9fe7bf5a750e97e7944563003efd1c82e70c229a612ca1" dependencies = [ "darling 0.20.11", - "heck", + "heck 0.5.0", "proc-macro-error", "quote 1.0.40", "syn 2.0.106", @@ -1275,7 +1275,7 @@ dependencies = [ "getrandom 0.2.16", "getrandom 0.3.3", "hex", - "indexmap 2.11.1", + "indexmap 2.11.4", "js-sys", "once_cell", "rand 0.9.2", @@ -1382,9 +1382,9 @@ checksum = "a2698f953def977c68f935bb0dfa959375ad4638570e969e2f1e9f433cbf1af6" [[package]] name = "cc" -version = "1.2.37" +version = "1.2.38" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "65193589c6404eb80b450d618eaf9a2cafaaafd57ecce47370519ef674a7bd44" +checksum = "80f41ae168f955c12fb8960b057d70d0ca153fb83182b57d86380443527be7e9" dependencies = [ "find-msvc-tools", "jobserver", @@ -1874,6 +1874,16 @@ dependencies = [ "darling_macro 0.20.11", ] +[[package]] +name = "darling" +version = "0.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9cdf337090841a411e2a7f3deb9187445851f91b309c0c0a29e05f74a00a48c0" +dependencies = [ + "darling_core 0.21.3", + "darling_macro 0.21.3", +] + [[package]] name = "darling_core" version = "0.13.4" @@ -1916,6 +1926,20 @@ dependencies = [ "syn 2.0.106", ] +[[package]] +name = "darling_core" +version = "0.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1247195ecd7e3c85f83c8d2a366e4210d588e802133e1e355180a9870b517ea4" +dependencies = [ + "fnv", + "ident_case", + "proc-macro2", + "quote 1.0.40", + "strsim 0.11.1", + "syn 2.0.106", +] + [[package]] name = "darling_macro" version = "0.13.4" @@ -1949,6 +1973,17 @@ dependencies = [ "syn 2.0.106", ] +[[package]] +name = "darling_macro" +version = "0.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d38308df82d1080de0afee5d069fa14b0326a88c14f15c5ccda35b4a6c414c81" +dependencies = [ + "darling_core 0.21.3", + "quote 1.0.40", + "syn 2.0.106", +] + [[package]] name = "dashmap" version = "5.5.3" @@ -2316,7 +2351,7 @@ version = "0.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a1e6a265c649f3f5979b601d26f1d05ada116434c87741c9493cb56218f76cbc" dependencies = [ - "heck", + "heck 0.5.0", "proc-macro2", "quote 1.0.40", "syn 2.0.106", @@ -2554,9 +2589,9 @@ dependencies = [ [[package]] name = "find-msvc-tools" -version = "0.1.1" +version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7fd99930f64d146689264c637b5af2f0233a933bef0d8570e2526bf9e083192d" +checksum = "1ced73b1dacfc750a6db6c0a0c3a3853c8b41997e2e2c563dc90804ae6867959" [[package]] name = "findshlibs" @@ -2570,6 +2605,12 @@ dependencies = [ "winapi", ] +[[package]] +name = "fixedbitset" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ce7134b9999ecaf8bcd65542e436736ef32ddca1b3e06094cb6ec5755203b80" + [[package]] name = "flate2" version = "1.1.2" @@ -2924,7 +2965,7 @@ dependencies = [ "js-sys", "libc", "r-efi", - "wasi 0.14.5+wasi-0.2.4", + "wasi 0.14.7+wasi-0.2.4", "wasm-bindgen", ] @@ -3031,7 +3072,7 @@ dependencies = [ "futures-sink", "futures-util", "http 0.2.12", - "indexmap 2.11.1", + "indexmap 2.11.4", "slab", "tokio 1.47.1", "tokio-util", @@ -3050,7 +3091,7 @@ dependencies = [ "futures-core", "futures-sink", "http 1.3.1", - "indexmap 2.11.1", + "indexmap 2.11.4", "slab", "tokio 1.47.1", "tokio-util", @@ -3117,6 +3158,12 @@ dependencies = [ "foldhash", ] +[[package]] +name = "hashbrown" +version = "0.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5419bdc4f6a9207fbeba6d11b604d481addf78ecd10c11ad51e76c2f6482748d" + [[package]] name = "headers" version = "0.4.1" @@ -3141,6 +3188,12 @@ dependencies = [ "http 1.3.1", ] +[[package]] +name = "heck" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95505c38b4572b2d910cecb0281560f54b440a19336cbbcb27bf6ce6adc6f5a8" + [[package]] name = "heck" version = "0.5.0" @@ -3447,11 +3500,11 @@ dependencies = [ "http 1.3.1", "hyper 1.7.0", "hyper-util", - "rustls 0.23.31", + "rustls 0.23.32", "rustls-native-certs 0.8.1", "rustls-pki-types", "tokio 1.47.1", - "tokio-rustls 0.26.2", + "tokio-rustls 0.26.3", "tower-service", ] @@ -3486,9 +3539,9 @@ dependencies = [ [[package]] name = "hyper-util" -version = "0.1.16" +version = "0.1.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8d9b05277c7e8da2c93a568989bb6207bef0112e8d17df7a6eda4a3cf143bc5e" +checksum = "3c6995591a8f1380fcb4ba966a252a4b29188d51d2b89e3a252f5305be65aea8" dependencies = [ "base64 0.22.1", "bytes 1.10.1", @@ -3759,13 +3812,14 @@ dependencies = [ [[package]] name = "indexmap" -version = "2.11.1" +version = "2.11.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "206a8042aec68fa4a62e8d3f7aa4ceb508177d9324faf261e1959e495b7a1921" +checksum = "4b0f83760fb341a774ed326568e19f5a863af4a952def8c39f9ab92fd95b88e5" dependencies = [ "equivalent", - "hashbrown 0.15.5", + "hashbrown 0.16.0", "serde", + "serde_core", ] [[package]] @@ -3903,6 +3957,15 @@ dependencies = [ "time", ] +[[package]] +name = "itertools" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1c173a5686ce8bfa551b3563d0c2170bf24ca44da99c7ca4bfdab5418c3fe57" +dependencies = [ + "either", +] + [[package]] name = "itertools" version = "0.12.1" @@ -3939,9 +4002,9 @@ dependencies = [ [[package]] name = "js-sys" -version = "0.3.78" +version = "0.3.80" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0c0b063578492ceec17683ef2f8c5e89121fbd0b172cbc280635ab7567db2738" +checksum = "852f13bec5eba4ba9afbeb93fd7c13fe56147f055939ae21c43a29a0ecb2702e" dependencies = [ "once_cell", "wasm-bindgen", @@ -3958,6 +4021,19 @@ dependencies = [ "serde", ] +[[package]] +name = "jsonwebtoken" +version = "9.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a87cc7a48537badeae96744432de36f4be2b4a34a05a5ef32e9dd8a1c169dde" +dependencies = [ + "base64 0.22.1", + "js-sys", + "ring", + "serde", + "serde_json", +] + [[package]] name = "jwt-simple" version = "0.11.9" @@ -4318,6 +4394,68 @@ version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "241eaef5fd12c88705a01fc1066c48c4b36e0dd4377dcdc7ec3942cea7a69956" +[[package]] +name = "livekit-api" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a17951fa8d398241f4a9503b57a7b38b3f40fb9dc94954f17b9fe99683e6b9b" +dependencies = [ + "base64 0.21.7", + "http 0.2.12", + "jsonwebtoken", + "livekit-protocol", + "log", + "parking_lot", + "pbjson-types", + "prost", + "rand 0.9.2", + "reqwest 0.11.27", + "scopeguard", + "serde", + "serde_json", + "sha2", + "thiserror 1.0.69", + "url", +] + +[[package]] +name = "livekit-protocol" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "45c90494efa508ec40228f870affec648826d23e1a9621a4430f67d187901eb4" +dependencies = [ + "futures-util", + "livekit-runtime 0.4.0", + "parking_lot", + "pbjson", + "pbjson-types", + "prost", + "prost-types", + "serde", + "thiserror 1.0.69", + "tokio 1.47.1", +] + +[[package]] +name = "livekit-runtime" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ae53eb874eb86e96e8ccffc31b5a00926d46174cbcb1c24ce4e57b1fcc5c8f6" +dependencies = [ + "tokio 1.47.1", + "tokio-stream", +] + +[[package]] +name = "livekit-runtime" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "532e84c6cdc5fe774f2b5d9912597b5f3bea561927a48296d03e24549d21c3f6" +dependencies = [ + "tokio 1.47.1", + "tokio-stream", +] + [[package]] name = "lock_api" version = "0.4.13" @@ -4732,7 +4870,7 @@ dependencies = [ "percent-encoding", "rand 0.8.5", "rustc_version_runtime", - "rustls 0.23.31", + "rustls 0.23.32", "rustversion", "serde", "serde_bytes", @@ -4745,7 +4883,7 @@ dependencies = [ "take_mut", "thiserror 1.0.69", "tokio 1.47.1", - "tokio-rustls 0.26.2", + "tokio-rustls 0.26.3", "tokio-util", "typed-builder", "uuid", @@ -4793,6 +4931,12 @@ dependencies = [ "version_check", ] +[[package]] +name = "multimap" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d87ecb2933e8aeadb3e3a02b828fed80a7528047e68b4f424523a0981a3a084" + [[package]] name = "mutate_once" version = "0.1.2" @@ -5180,6 +5324,43 @@ version = "0.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "df94ce210e5bc13cb6651479fa48d14f601d9858cfe0467f43ae157023b938d3" +[[package]] +name = "pbjson" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1030c719b0ec2a2d25a5df729d6cff1acf3cc230bf766f4f97833591f7577b90" +dependencies = [ + "base64 0.21.7", + "serde", +] + +[[package]] +name = "pbjson-build" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2580e33f2292d34be285c5bc3dba5259542b083cfad6037b6d70345f24dcb735" +dependencies = [ + "heck 0.4.1", + "itertools 0.11.0", + "prost", + "prost-types", +] + +[[package]] +name = "pbjson-types" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "18f596653ba4ac51bdecbb4ef6773bc7f56042dc13927910de1684ad3d32aa12" +dependencies = [ + "bytes 1.10.1", + "chrono", + "pbjson", + "pbjson-build", + "prost", + "prost-build", + "serde", +] + [[package]] name = "pbkdf2" version = "0.11.0" @@ -5301,6 +5482,16 @@ dependencies = [ "sha2", ] +[[package]] +name = "petgraph" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4c5cc86750666a3ed20bdaf5ca2a0344f9c67674cae0515bec2da16fbaa47db" +dependencies = [ + "fixedbitset", + "indexmap 2.11.4", +] + [[package]] name = "phf" version = "0.10.1" @@ -5486,12 +5677,12 @@ checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" [[package]] name = "plist" -version = "1.7.4" +version = "1.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3af6b589e163c5a788fab00ce0c0366f6efbb9959c2f9874b224936af7fce7e1" +checksum = "740ebea15c5d1428f910cd1a5f52cebf8d25006245ed8ade92702f4943d91e07" dependencies = [ "base64 0.22.1", - "indexmap 2.11.1", + "indexmap 2.11.4", "quick-xml", "serde", "time", @@ -5741,11 +5932,64 @@ dependencies = [ "thiserror 1.0.69", ] +[[package]] +name = "prost" +version = "0.12.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "deb1435c188b76130da55f17a466d252ff7b1418b2ad3e037d127b94e3411f29" +dependencies = [ + "bytes 1.10.1", + "prost-derive", +] + +[[package]] +name = "prost-build" +version = "0.12.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22505a5c94da8e3b7c2996394d1c933236c4d743e81a410bcca4e6989fc066a4" +dependencies = [ + "bytes 1.10.1", + "heck 0.5.0", + "itertools 0.12.1", + "log", + "multimap", + "once_cell", + "petgraph", + "prettyplease", + "prost", + "prost-types", + "regex", + "syn 2.0.106", + "tempfile", +] + +[[package]] +name = "prost-derive" +version = "0.12.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "81bddcdb20abf9501610992b6759a4c888aef7d1a7247ef75e2404275ac24af1" +dependencies = [ + "anyhow", + "itertools 0.12.1", + "proc-macro2", + "quote 1.0.40", + "syn 2.0.106", +] + +[[package]] +name = "prost-types" +version = "0.12.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9091c90b0a32608e984ff2fa4091273cbdd755d54935c51d520887f4a1dbd5b0" +dependencies = [ + "prost", +] + [[package]] name = "pxfm" -version = "0.1.23" +version = "0.1.24" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f55f4fedc84ed39cb7a489322318976425e42a147e2be79d8f878e2884f94e84" +checksum = "83f9b339b02259ada5c0f4a389b7fb472f933aa17ce176fd2ad98f28bb401fde" dependencies = [ "num-traits", ] @@ -6355,7 +6599,7 @@ dependencies = [ name = "revolt-coalesced" version = "0.8.8" dependencies = [ - "indexmap 2.11.1", + "indexmap 2.11.4", "lru 0.16.1", "tokio 1.47.1", ] @@ -6409,6 +6653,9 @@ dependencies = [ "isahc", "iso8601-timestamp", "linkify 0.8.1", + "livekit-api", + "livekit-protocol", + "livekit-runtime 0.3.1", "log", "lru 0.11.1", "mongodb", @@ -6453,6 +6700,8 @@ dependencies = [ "iso8601-timestamp", "lettre", "linkify 0.6.0", + "livekit-api", + "livekit-protocol", "log", "lru 0.7.8", "nanoid", @@ -6660,6 +6909,7 @@ name = "revolt-result" version = "0.8.8" dependencies = [ "axum", + "log", "revolt_okapi", "revolt_rocket_okapi", "rocket", @@ -6669,6 +6919,34 @@ dependencies = [ "utoipa", ] +[[package]] +name = "revolt-voice-ingress" +version = "0.7.1" +dependencies = [ + "amqprs", + "async-std", + "chrono", + "futures", + "livekit-api", + "livekit-protocol", + "livekit-runtime 0.3.1", + "log", + "lru 0.7.8", + "redis-kiss", + "revolt-config", + "revolt-database", + "revolt-models", + "revolt-permissions", + "revolt-result", + "rmp-serde", + "rocket", + "rocket_empty", + "sentry", + "serde", + "serde_json", + "ulid 0.5.0", +] + [[package]] name = "revolt_a2" version = "0.10.1" @@ -6830,7 +7108,7 @@ dependencies = [ "either", "figment", "futures", - "indexmap 2.11.1", + "indexmap 2.11.4", "log", "memchr", "multer", @@ -6878,7 +7156,7 @@ checksum = "575d32d7ec1a9770108c879fc7c47815a80073f96ca07ff9525a94fcede1dd46" dependencies = [ "devise", "glob", - "indexmap 2.11.1", + "indexmap 2.11.4", "proc-macro2", "quote 1.0.40", "rocket_http", @@ -6925,7 +7203,7 @@ dependencies = [ "futures", "http 0.2.12", "hyper 0.14.32", - "indexmap 2.11.1", + "indexmap 2.11.4", "log", "memchr", "pear", @@ -7083,16 +7361,16 @@ dependencies = [ [[package]] name = "rustls" -version = "0.23.31" +version = "0.23.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c0ebcbd2f03de0fc1122ad9bb24b127a5a6cd51d72604a3f3c50ac459762b6cc" +checksum = "cd3c25631629d034ce7cd9940adc9d45762d46de2b0f57193c4443b92c6d4d40" dependencies = [ "aws-lc-rs", "log", "once_cell", "ring", "rustls-pki-types", - "rustls-webpki 0.103.5", + "rustls-webpki 0.103.6", "subtle", "zeroize", ] @@ -7171,9 +7449,9 @@ dependencies = [ [[package]] name = "rustls-webpki" -version = "0.103.5" +version = "0.103.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b5a37813727b78798e53c2bec3f5e8fe12a6d6f8389bf9ca7802add4c9905ad8" +checksum = "8572f3c2cb9934231157b45499fc41e1f58c589fdfb81a844ba873265e80f8eb" dependencies = [ "aws-lc-rs", "ring", @@ -7573,9 +7851,9 @@ dependencies = [ [[package]] name = "serde" -version = "1.0.223" +version = "1.0.225" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a505d71960adde88e293da5cb5eda57093379f64e61cf77bf0e6a63af07a7bac" +checksum = "fd6c24dee235d0da097043389623fb913daddf92c76e9f5a1db88607a0bcbd1d" dependencies = [ "serde_core", "serde_derive", @@ -7583,9 +7861,9 @@ dependencies = [ [[package]] name = "serde_bytes" -version = "0.11.18" +version = "0.11.19" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fe07b5d88710e3b807c16a06ccbc9dfecd5fff6a4d2745c59e3e26774f10de6a" +checksum = "a5d440709e79d88e51ac01c4b72fc6cb7314017bb7da9eeff678aa94c10e3ea8" dependencies = [ "serde", "serde_core", @@ -7602,18 +7880,18 @@ dependencies = [ [[package]] name = "serde_core" -version = "1.0.223" +version = "1.0.225" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "20f57cbd357666aa7b3ac84a90b4ea328f1d4ddb6772b430caa5d9e1309bb9e9" +checksum = "659356f9a0cb1e529b24c01e43ad2bdf520ec4ceaf83047b83ddcc2251f96383" dependencies = [ "serde_derive", ] [[package]] name = "serde_derive" -version = "1.0.223" +version = "1.0.225" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3d428d07faf17e306e699ec1e91996e5a165ba5d6bce5b5155173e91a8a01a56" +checksum = "0ea936adf78b1f766949a4977b91d2f5595825bd6ec079aa9543ad2685fc4516" dependencies = [ "proc-macro2", "quote 1.0.40", @@ -7637,7 +7915,7 @@ version = "1.0.145" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "402a6f66d8c709116cf22f558eab210f5a50187f702eb4d7e5ef38d9a7f1c79c" dependencies = [ - "indexmap 2.11.1", + "indexmap 2.11.4", "itoa", "memchr", "ryu", @@ -7647,9 +7925,9 @@ dependencies = [ [[package]] name = "serde_path_to_error" -version = "0.1.19" +version = "0.1.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a30a8abed938137c7183c173848e3c9b3517f5e038226849a4ecc9b21a4b4e2a" +checksum = "10a9ff822e371bb5403e391ecd83e182e0e77ba7f6fe0160b795797109d1b457" dependencies = [ "itoa", "serde", @@ -7679,15 +7957,15 @@ dependencies = [ [[package]] name = "serde_with" -version = "3.14.0" +version = "3.14.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f2c45cd61fefa9db6f254525d46e392b852e0e61d9a1fd36e5bd183450a556d5" +checksum = "c522100790450cf78eeac1507263d0a350d4d5b30df0c8e1fe051a10c22b376e" dependencies = [ "base64 0.22.1", "chrono", "hex", "indexmap 1.9.3", - "indexmap 2.11.1", + "indexmap 2.11.4", "schemars 0.9.0", "schemars 1.0.4", "serde", @@ -7699,11 +7977,11 @@ dependencies = [ [[package]] name = "serde_with_macros" -version = "3.14.0" +version = "3.14.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "de90945e6565ce0d9a25098082ed4ee4002e047cb59892c318d66821e14bb30f" +checksum = "327ada00f7d64abaac1e55a6911e90cf665aa051b9a561c7006c157f4633135e" dependencies = [ - "darling 0.20.11", + "darling 0.21.3", "proc-macro2", "quote 1.0.40", "syn 2.0.106", @@ -8018,7 +8296,7 @@ version = "0.26.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4c6bee85a5a24955dc440386795aa378cd9cf82acd5f764469152d2270e581be" dependencies = [ - "heck", + "heck 0.5.0", "proc-macro2", "quote 1.0.40", "rustversion", @@ -8184,7 +8462,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a3e535eb8dded36d55ec13eddacd30dec501792ff23a0b1682c38601b8cf2349" dependencies = [ "cfg-expr", - "heck", + "heck 0.5.0", "pkg-config", "toml 0.8.23", "version-compare", @@ -8312,11 +8590,12 @@ dependencies = [ [[package]] name = "time" -version = "0.3.43" +version = "0.3.44" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "83bde6f1ec10e72d583d91623c939f623002284ef622b87de38cfd546cbf2031" +checksum = "91e7d9e3bb61134e77bde20dd4825b97c010155709965fedf0f49bb138e52a9d" dependencies = [ "deranged", + "itoa", "libc", "num-conv", "num_threads", @@ -8477,11 +8756,11 @@ dependencies = [ [[package]] name = "tokio-rustls" -version = "0.26.2" +version = "0.26.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e727b36a1a0e8b74c376ac2211e40c2c8af09fb4013c60d910495810f008e9b" +checksum = "05f63835928ca123f1bef57abbcd23bb2ba0ac9ae1235f1e65bda0d06e7786bd" dependencies = [ - "rustls 0.23.31", + "rustls 0.23.32", "tokio 1.47.1", ] @@ -8546,7 +8825,7 @@ version = "0.19.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1b5bb770da30e5cbfde35a2d7b9b8a2c4b8ef89548a7a6aeab5c9a576e3e7421" dependencies = [ - "indexmap 2.11.1", + "indexmap 2.11.4", "toml_datetime", "winnow 0.5.40", ] @@ -8557,7 +8836,7 @@ version = "0.22.27" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a" dependencies = [ - "indexmap 2.11.1", + "indexmap 2.11.4", "serde", "serde_spanned", "toml_datetime", @@ -9035,7 +9314,7 @@ version = "4.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c5afb1a60e207dca502682537fefcfd9921e71d0b83e9576060f09abc6efab23" dependencies = [ - "indexmap 2.11.1", + "indexmap 2.11.4", "serde", "serde_json", "utoipa-gen", @@ -9224,18 +9503,18 @@ checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" [[package]] name = "wasi" -version = "0.14.5+wasi-0.2.4" +version = "0.14.7+wasi-0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a4494f6290a82f5fe584817a676a34b9d6763e8d9d18204009fb31dceca98fd4" +checksum = "883478de20367e224c0090af9cf5f9fa85bed63a95c1abf3afc5c083ebc06e8c" dependencies = [ "wasip2", ] [[package]] name = "wasip2" -version = "1.0.0+wasi-0.2.4" +version = "1.0.1+wasi-0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "03fa2761397e5bd52002cd7e73110c71af2109aca4e521a9f40473fe685b0a24" +checksum = "0562428422c63773dad2c345a1882263bbf4d65cf3f42e90921f787ef5ad58e7" dependencies = [ "wit-bindgen", ] @@ -9251,9 +9530,9 @@ dependencies = [ [[package]] name = "wasm-bindgen" -version = "0.2.101" +version = "0.2.103" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7e14915cadd45b529bb8d1f343c4ed0ac1de926144b746e2710f9cd05df6603b" +checksum = "ab10a69fbd0a177f5f649ad4d8d3305499c42bab9aef2f7ff592d0ec8f833819" dependencies = [ "cfg-if", "once_cell", @@ -9264,9 +9543,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-backend" -version = "0.2.101" +version = "0.2.103" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e28d1ba982ca7923fd01448d5c30c6864d0a14109560296a162f80f305fb93bb" +checksum = "0bb702423545a6007bbc368fde243ba47ca275e549c8a28617f56f6ba53b1d1c" dependencies = [ "bumpalo", "log", @@ -9278,9 +9557,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-futures" -version = "0.4.51" +version = "0.4.53" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0ca85039a9b469b38336411d6d6ced91f3fc87109a2a27b0c197663f5144dffe" +checksum = "a0b221ff421256839509adbb55998214a70d829d3a28c69b4a6672e9d2a42f67" dependencies = [ "cfg-if", "js-sys", @@ -9291,9 +9570,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro" -version = "0.2.101" +version = "0.2.103" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7c3d463ae3eff775b0c45df9da45d68837702ac35af998361e2c84e7c5ec1b0d" +checksum = "fc65f4f411d91494355917b605e1480033152658d71f722a90647f56a70c88a0" dependencies = [ "quote 1.0.40", "wasm-bindgen-macro-support", @@ -9301,9 +9580,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro-support" -version = "0.2.101" +version = "0.2.103" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7bb4ce89b08211f923caf51d527662b75bdc9c9c7aab40f86dcb9fb85ac552aa" +checksum = "ffc003a991398a8ee604a401e194b6b3a39677b3173d6e74495eb51b82e99a32" dependencies = [ "proc-macro2", "quote 1.0.40", @@ -9314,9 +9593,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-shared" -version = "0.2.101" +version = "0.2.103" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f143854a3b13752c6950862c906306adb27c7e839f7414cec8fea35beab624c1" +checksum = "293c37f4efa430ca14db3721dfbe48d8c33308096bd44d80ebaa775ab71ba1cf" dependencies = [ "unicode-ident", ] @@ -9345,9 +9624,9 @@ dependencies = [ [[package]] name = "web-sys" -version = "0.3.78" +version = "0.3.80" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "77e4b637749ff0d92b8fad63aa1f7cff3cbe125fd49c175cd6345e7272638b12" +checksum = "fbe734895e869dc429d78c4b433f8d17d95f8d05317440b4fad5ab2d33e596dc" dependencies = [ "js-sys", "wasm-bindgen", @@ -9854,9 +10133,9 @@ dependencies = [ [[package]] name = "wit-bindgen" -version = "0.45.1" +version = "0.46.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5c573471f125075647d03df72e026074b7203790d41351cd6edc96f46bcccd36" +checksum = "f17a85883d4e6d00e8a97c586de764dabcc06133f7f1d55dce5cdc070ad7fe59" [[package]] name = "writeable" diff --git a/Revolt.toml b/Revolt.toml index 33e044348..76f908f38 100644 --- a/Revolt.toml +++ b/Revolt.toml @@ -26,6 +26,11 @@ january = "http://local.revolt.chat:14705" voso_legacy = "" voso_legacy_ws = "" +# Public urls for livekit nodes +# each entry here should have a corresponding entry under `api.livekit.nodes` +[hosts.livekit] +worldwide = "ws://local.revolt.chat:14706" + [api] [api.smtp] @@ -40,6 +45,18 @@ port = 14025 use_tls = false use_starttls = false +[api.livekit] + +# Config for livekit nodes +# Make sure to change the secret when deploying +# The key and secret should match the values livekit is using +[api.livekit.nodes.worldwide] +url = "http://livekit" +lat = 0.0 +lon = 0.0 +key = "worldwide_key" +secret = "ZjCofRlfm6GGtjlifmNpCDkcQbEIIVC0" + [files.s3] # S3 protocol endpoint endpoint = "http://127.0.0.1:14009" diff --git a/compose.yml b/compose.yml index 89b295940..028969aae 100644 --- a/compose.yml +++ b/compose.yml @@ -40,7 +40,7 @@ services: # Rabbit rabbit: - image: rabbitmq:3-management + image: rabbitmq:4-management environment: RABBITMQ_DEFAULT_USER: rabbituser RABBITMQ_DEFAULT_PASS: rabbitpass diff --git a/crates/bonfire/src/config.rs b/crates/bonfire/src/config.rs index 05f7cf5a0..d70a63205 100644 --- a/crates/bonfire/src/config.rs +++ b/crates/bonfire/src/config.rs @@ -136,6 +136,7 @@ impl handshake::server::Callback for WebsocketHandshakeCallback { channels: false, members: false, emojis: false, + voice_states: false, user_settings: Vec::new(), channel_unreads: false, policy_changes: false, @@ -168,6 +169,7 @@ impl handshake::server::Callback for WebsocketHandshakeCallback { "channels" => ready_payload_fields.channels = true, "members" => ready_payload_fields.members = true, "emojis" => ready_payload_fields.emojis = true, + "voice_states" => ready_payload_fields.voice_states = true, "channel_unreads" => ready_payload_fields.channel_unreads = true, "user_settings" => { if let Some(subkey) = captures.get(1) { diff --git a/crates/bonfire/src/events/impl.rs b/crates/bonfire/src/events/impl.rs index 83b0d4c6f..e52d10a61 100644 --- a/crates/bonfire/src/events/impl.rs +++ b/crates/bonfire/src/events/impl.rs @@ -4,6 +4,7 @@ use futures::future::join_all; use revolt_database::{ events::client::{EventV1, ReadyPayloadFields}, util::permissions::DatabasePermissionQuery, + voice::get_channel_voice_state, Channel, Database, Member, MemberCompositeKey, Presence, RelationshipStatus, }; use revolt_models::v0; @@ -17,8 +18,9 @@ use super::state::{Cache, State}; impl Cache { /// Check whether the current user can view a channel pub async fn can_view_channel(&self, db: &Database, channel: &Channel) -> bool { + #[allow(deprecated)] match &channel { - Channel::TextChannel { server, .. } | Channel::VoiceChannel { server, .. } => { + Channel::TextChannel { server, .. } => { let member = self.members.get(server); let server = self.servers.get(server); let mut query = @@ -210,6 +212,28 @@ impl State { None }; + let voice_states = if fields.voice_states { + // fetch voice states for all the channels we can see + let mut voice_states = Vec::new(); + + for channel in channels.iter().filter(|c| { + matches!( + c, + Channel::DirectMessage { .. } + | Channel::Group { .. } + | Channel::TextChannel { voice: Some(_), .. } + ) + }) { + if let Ok(Some(voice_state)) = get_channel_voice_state(channel).await { + voice_states.push(voice_state) + } + } + + Some(voice_states) + } else { + None + }; + // Copy data into local state cache. self.cache.users = users.iter().cloned().map(|x| (x.id.clone(), x)).collect(); self.cache @@ -268,6 +292,8 @@ impl State { } else { None }, + voice_states, + emojis, user_settings, channel_unreads, @@ -285,19 +311,14 @@ impl State { let id = &id.to_string(); for (channel_id, channel) in &self.cache.channels { - match channel { - Channel::TextChannel { server, .. } | Channel::VoiceChannel { server, .. } => { - if server == id { - channel_ids.insert(channel_id.clone()); - - if self.cache.can_view_channel(db, channel).await { - added_channels.push(channel_id.clone()); - } else { - removed_channels.push(channel_id.clone()); - } - } + if channel.server() == Some(id) { + channel_ids.insert(channel_id.clone()); + + if self.cache.can_view_channel(db, channel).await { + added_channels.push(channel_id.clone()); + } else { + removed_channels.push(channel_id.clone()); } - _ => {} } } @@ -465,6 +486,7 @@ impl State { server, channels, emojis: _, + voice_states: _, } => { self.insert_subscription(id.clone()).await; diff --git a/crates/bonfire/src/main.rs b/crates/bonfire/src/main.rs index f4e8a3870..3f68e4729 100644 --- a/crates/bonfire/src/main.rs +++ b/crates/bonfire/src/main.rs @@ -1,7 +1,9 @@ -use std::env; +use std::{env, sync::Arc}; use async_std::net::TcpListener; use revolt_presence::clear_region; +use once_cell::sync::OnceCell; +use revolt_database::voice::VoiceClient; #[macro_use] extern crate log; @@ -12,6 +14,15 @@ pub mod events; mod database; mod websocket; +pub static VOICE_CLIENT: OnceCell> = OnceCell::new(); + +pub fn get_voice_client() -> Arc { + VOICE_CLIENT + .get() + .expect("get_voice_client called before set") + .clone() +} + #[async_std::main] async fn main() { // Configure requirements for Bonfire. @@ -24,6 +35,8 @@ async fn main() { clear_region(None).await; } + VOICE_CLIENT.set(Arc::new(VoiceClient::from_revolt_config().await)).unwrap(); + // Setup a TCP listener to accept WebSocket connections on. // By default, we bind to port 14703 on all interfaces. let bind = env::var("HOST").unwrap_or_else(|_| "0.0.0.0:14703".into()); diff --git a/crates/core/config/Revolt.toml b/crates/core/config/Revolt.toml index 0f2644577..75d3713e2 100644 --- a/crates/core/config/Revolt.toml +++ b/crates/core/config/Revolt.toml @@ -22,6 +22,8 @@ january = "http://local.revolt.chat/january" voso_legacy = "" voso_legacy_ws = "" +[hosts.livekit] + [rabbit] host = "rabbit" port = 5672 @@ -68,8 +70,13 @@ hcaptcha_sitekey = "" # Maximum concurrent connections (to proxy server) max_concurrent_connections = 50 -[api.users] +[api.livekit] +# How long to ring devices for when calling in dms/groups, in seconds +call_ring_duration = 30 + +[api.livekit.nodes] +[api.users] [pushd] # this changes the names of the queues to not overlap @@ -87,6 +94,7 @@ message_queue = "notifications.origin.message" mass_mention_queue = "notifications.origin.mass_mention" # handles messages that contain role or everyone mentions fr_accepted_queue = "notifications.ingest.fr_accepted" # friend request accepted fr_received_queue = "notifications.ingest.fr_received" # friend request received +dm_call_queue = "notifications.ingest.dm_call" # friend request received generic_queue = "notifications.ingest.generic" # generic messages (title + body) ack_queue = "notifications.process.ack" # updates badges for apple devices @@ -230,6 +238,18 @@ message_attachments = 5 # Maximum number of servers the user can create/join servers = 50 +# Maximum audio frequency (Hz) in voice calls +voice_quality = 16000 + +# Whether the user can use video streams in voice calls +video = true + +# Mamimum resolution (width, height) of video streams in voice calls +video_resolution = [1080, 720] + +# Minimum and maximum aspect ratio of video streams in voice calls +video_aspect_ratio = [0.3, 2.5] + [features.limits.new_user.file_upload_size_limit] # Maximum file size limits (in bytes) attachments = 20_000_000 @@ -257,6 +277,18 @@ message_attachments = 5 # Maximum number of servers the user can create/join servers = 100 +# Maximum audio frequency (Hz) in voice calls +voice_quality = 16000 + +# Whether the user can use video streams in voice calls +video = true + +# Mamimum resolution (width, height) of video streams in voice calls +video_resolution = [1080, 720] + +# Minimum and maximum aspect ratio of video streams in voice calls +video_aspect_ratio = [0.3, 2.5] + [features.limits.default.file_upload_size_limit] # Maximum file size limits (in bytes) attachments = 20_000_000 @@ -275,6 +307,7 @@ process_message_delay_limit = 5 # Configuration for Sentry error reporting api = "" events = "" +voice_ingress = "" files = "" proxy = "" pushd = "" diff --git a/crates/core/config/src/lib.rs b/crates/core/config/src/lib.rs index 5863cbc4d..46b087765 100644 --- a/crates/core/config/src/lib.rs +++ b/crates/core/config/src/lib.rs @@ -125,8 +125,7 @@ pub struct Hosts { pub events: String, pub autumn: String, pub january: String, - pub voso_legacy: String, - pub voso_legacy_ws: String, + pub livekit: HashMap, } #[derive(Deserialize, Debug, Clone)] @@ -198,6 +197,25 @@ pub struct ApiWorkers { pub max_concurrent_connections: usize, } +#[derive(Deserialize, Debug, Clone)] +pub struct ApiLiveKit { + pub call_ring_duration: usize, + pub nodes: HashMap, +} + +#[derive(Deserialize, Debug, Clone)] +pub struct LiveKitNode { + pub url: String, + pub lat: f64, + pub lon: f64, + pub key: String, + pub secret: String, + + // whether to hide the node in the nodes list + #[serde(default)] + pub private: bool, +} + #[derive(Deserialize, Debug, Clone)] pub struct ApiUsers { pub early_adopter_cutoff: Option, @@ -209,6 +227,7 @@ pub struct Api { pub smtp: ApiSmtp, pub security: ApiSecurity, pub workers: ApiWorkers, + pub livekit: ApiLiveKit, pub users: ApiUsers, } @@ -221,6 +240,7 @@ pub struct Pushd { // Queues pub message_queue: String, pub mass_mention_queue: String, + pub dm_call_queue: String, pub fr_accepted_queue: String, pub fr_received_queue: String, pub generic_queue: String, @@ -251,6 +271,10 @@ impl Pushd { self.get_routing_key(self.mass_mention_queue.clone()) } + pub fn get_dm_call_routing_key(&self) -> String { + self.get_routing_key(self.dm_call_queue.clone()) + } + pub fn get_fr_accepted_routing_key(&self) -> String { self.get_routing_key(self.fr_accepted_queue.clone()) } @@ -318,6 +342,10 @@ pub struct FeaturesLimits { pub message_length: usize, pub message_attachments: usize, pub servers: usize, + pub voice_quality: u32, + pub video: bool, + pub video_resolution: [u32; 2], + pub video_aspect_ratio: [f32; 2], pub file_upload_size_limit: HashMap, } @@ -362,6 +390,7 @@ pub struct Features { pub struct Sentry { pub api: String, pub events: String, + pub voice_ingress: String, pub files: String, pub proxy: String, pub pushd: String, diff --git a/crates/core/database/Cargo.toml b/crates/core/database/Cargo.toml index 02e554118..59c3fbb88 100644 --- a/crates/core/database/Cargo.toml +++ b/crates/core/database/Cargo.toml @@ -96,3 +96,8 @@ authifier = { version = "1.0.15" } # RabbitMQ amqprs = { version = "1.7.0" } + +# Voice +livekit-api = "0.4.4" +livekit-protocol = "0.4.0" +livekit-runtime = { version = "0.3.1", features = ["tokio"] } diff --git a/crates/core/database/src/amqp/amqp.rs b/crates/core/database/src/amqp/amqp.rs index b567c7f7c..b29105edf 100644 --- a/crates/core/database/src/amqp/amqp.rs +++ b/crates/core/database/src/amqp/amqp.rs @@ -2,7 +2,8 @@ use std::collections::HashSet; use crate::events::rabbit::*; use crate::User; -use amqprs::channel::BasicPublishArguments; +use amqprs::channel::{BasicPublishArguments, ExchangeDeclareArguments}; +use amqprs::connection::OpenConnectionArguments; use amqprs::{channel::Channel, connection::Connection, error::Error as AMQPError}; use amqprs::{BasicProperties, FieldTable}; use revolt_models::v0::PushNotification; @@ -25,6 +26,35 @@ impl AMQP { } } + pub async fn new_auto() -> AMQP { + let config = revolt_config::config().await; + + let connection = Connection::open(&OpenConnectionArguments::new( + &config.rabbit.host, + config.rabbit.port, + &config.rabbit.username, + &config.rabbit.password, + )) + .await + .expect("Failed to connect to RabbitMQ"); + + let channel = connection + .open_channel(None) + .await + .expect("Failed to open RabbitMQ channel"); + + channel + .exchange_declare( + ExchangeDeclareArguments::new(&config.pushd.exchange, "direct") + .durable(true) + .finish(), + ) + .await + .expect("Failed to declare exchange"); + + AMQP::new(connection, channel) + } + pub async fn friend_request_accepted( &self, accepted_request_user: &User, @@ -240,4 +270,50 @@ impl AMQP { ) .await } + + /// # DM Call Update + /// Used to send an update about a DM call, eg. start or end of a call. + /// Recipients can be used to narrow the scope of recipients, otherwise all recipients will be notified. + /// `ended` refers to the ringing period, not necessarily the call itself. + pub async fn dm_call_updated( + &self, + initiator_id: &str, + channel_id: &str, + started_at: Option<&str>, + ended: bool, + recipients: Option>, + ) -> Result<(), AMQPError> { + let config = revolt_config::config().await; + + let payload = InternalDmCallPayload { + payload: DmCallPayload { + initiator_id: initiator_id.to_string(), + channel_id: channel_id.to_string(), + started_at: started_at.map(|f| f.to_string()), + ended, + }, + recipients, + }; + let payload = to_string(&payload).unwrap(); + + debug!( + "Sending dm call update payload on channel {}: {}", + config.pushd.get_dm_call_routing_key(), + payload + ); + + self.channel + .basic_publish( + BasicProperties::default() + .with_content_type("application/json") + .with_persistence(true) + .finish(), + payload.into(), + BasicPublishArguments::new( + &config.pushd.exchange, + &config.pushd.get_dm_call_routing_key(), + ), + ) + .await + } } diff --git a/crates/core/database/src/drivers/mod.rs b/crates/core/database/src/drivers/mod.rs index 2a2c7b851..9618ee13b 100644 --- a/crates/core/database/src/drivers/mod.rs +++ b/crates/core/database/src/drivers/mod.rs @@ -35,7 +35,7 @@ pub enum DatabaseInfo { } /// Database -#[derive(Clone)] +#[derive(Clone, Debug)] pub enum Database { /// Mock database Reference(ReferenceDb), diff --git a/crates/core/database/src/drivers/mongodb.rs b/crates/core/database/src/drivers/mongodb.rs index fc5dbe3c2..baeddb1da 100644 --- a/crates/core/database/src/drivers/mongodb.rs +++ b/crates/core/database/src/drivers/mongodb.rs @@ -11,6 +11,7 @@ use serde::Serialize; database_derived!( /// MongoDB implementation + #[derive(Debug)] pub struct MongoDb(pub ::mongodb::Client, pub String); ); diff --git a/crates/core/database/src/drivers/reference.rs b/crates/core/database/src/drivers/reference.rs index ff18f2533..e02eae644 100644 --- a/crates/core/database/src/drivers/reference.rs +++ b/crates/core/database/src/drivers/reference.rs @@ -10,7 +10,7 @@ use crate::{ database_derived!( /// Reference implementation - #[derive(Default)] + #[derive(Default, Debug)] pub struct ReferenceDb { pub bots: Arc>>, pub channels: Arc>>, diff --git a/crates/core/database/src/events/client.rs b/crates/core/database/src/events/client.rs index 996347cac..04abf2a06 100644 --- a/crates/core/database/src/events/client.rs +++ b/crates/core/database/src/events/client.rs @@ -3,10 +3,7 @@ use revolt_result::Error; use serde::{Deserialize, Serialize}; use revolt_models::v0::{ - AppendMessage, Channel, ChannelUnread, Emoji, FieldsChannel, FieldsMember, FieldsMessage, - FieldsRole, FieldsServer, FieldsUser, FieldsWebhook, Member, MemberCompositeKey, Message, - PartialChannel, PartialMember, PartialMessage, PartialRole, PartialServer, PartialUser, - PartialWebhook, PolicyChange, RemovalIntention, Report, Server, User, UserSettings, Webhook, + AppendMessage, Channel, ChannelUnread, ChannelVoiceState, Emoji, FieldsChannel, FieldsMember, FieldsMessage, FieldsRole, FieldsServer, FieldsUser, FieldsWebhook, Member, MemberCompositeKey, Message, PartialChannel, PartialMember, PartialMessage, PartialRole, PartialServer, PartialUser, PartialUserVoiceState, PartialWebhook, PolicyChange, RemovalIntention, Report, Server, User, UserSettings, UserVoiceState, Webhook }; use crate::Database; @@ -27,6 +24,7 @@ pub struct ReadyPayloadFields { pub channels: bool, pub members: bool, pub emojis: bool, + pub voice_states: bool, pub user_settings: Vec, pub channel_unreads: bool, pub policy_changes: bool, @@ -40,6 +38,7 @@ impl Default for ReadyPayloadFields { channels: true, members: true, emojis: true, + voice_states: true, user_settings: Vec::new(), channel_unreads: false, policy_changes: true, @@ -72,6 +71,8 @@ pub enum EventV1 { members: Option>, #[serde(skip_serializing_if = "Option::is_none")] emojis: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + voice_states: Option>, #[serde(skip_serializing_if = "Option::is_none")] user_settings: Option, @@ -138,6 +139,7 @@ pub enum EventV1 { server: Server, channels: Vec, emojis: Vec, + voice_states: Vec }, /// Update existing server @@ -270,6 +272,31 @@ pub enum EventV1 { /// Auth events Auth(AuthifierEvent), + + /// Voice events + VoiceChannelJoin { + id: String, + state: UserVoiceState, + }, + VoiceChannelLeave { + id: String, + user: String, + }, + VoiceChannelMove { + user: String, + from: String, + to: String, + state: UserVoiceState + }, + UserVoiceStateUpdate { + id: String, + channel_id: String, + data: PartialUserVoiceState, + }, + UserMoveVoiceChannel { + node: String, + token: String + } } impl EventV1 { diff --git a/crates/core/database/src/events/rabbit.rs b/crates/core/database/src/events/rabbit.rs index 0ae91be11..6f5c9ab3c 100644 --- a/crates/core/database/src/events/rabbit.rs +++ b/crates/core/database/src/events/rabbit.rs @@ -37,6 +37,20 @@ pub struct GenericPayload { pub user: User, } +#[derive(Serialize, Deserialize, Clone)] +pub struct DmCallPayload { + pub initiator_id: String, + pub channel_id: String, + pub started_at: Option, + pub ended: bool, +} + +#[derive(Serialize, Deserialize, Clone)] +pub struct InternalDmCallPayload { + pub payload: DmCallPayload, + pub recipients: Option>, +} + #[derive(Serialize, Deserialize)] #[serde(tag = "type", content = "data")] #[allow(clippy::large_enum_variant)] @@ -46,6 +60,7 @@ pub enum PayloadKind { FRReceived(FRReceivedPayload), BadgeUpdate(usize), Generic(GenericPayload), + DmCallStartEnd(DmCallPayload), } #[derive(Serialize, Deserialize)] diff --git a/crates/core/database/src/lib.rs b/crates/core/database/src/lib.rs index f9827d03c..5c7640da7 100644 --- a/crates/core/database/src/lib.rs +++ b/crates/core/database/src/lib.rs @@ -112,6 +112,9 @@ pub mod tasks; mod amqp; pub use amqp::amqp::AMQP; +pub mod voice; + + /// Utility function to check if a boolean value is false pub fn if_false(t: &bool) -> bool { !t diff --git a/crates/core/database/src/models/admin_migrations/ops/mongodb/scripts.rs b/crates/core/database/src/models/admin_migrations/ops/mongodb/scripts.rs index d164cab10..31ddf9f01 100644 --- a/crates/core/database/src/models/admin_migrations/ops/mongodb/scripts.rs +++ b/crates/core/database/src/models/admin_migrations/ops/mongodb/scripts.rs @@ -9,14 +9,13 @@ use crate::{ bson::{doc, from_bson, from_document, to_document, Bson, DateTime, Document}, options::FindOptions, }, - AbstractChannels, AbstractServers, Channel, Invite, MongoDb, User, DISCRIMINATOR_SEARCH_SPACE, + AbstractServers, Invite, MongoDb, User, DISCRIMINATOR_SEARCH_SPACE, }; use bson::{oid::ObjectId, to_bson}; use futures::StreamExt; use iso8601_timestamp::Timestamp; use rand::seq::SliceRandom; -use revolt_permissions::DEFAULT_WEBHOOK_PERMISSIONS; -use revolt_result::{Error, ErrorType}; +use revolt_permissions::{ChannelPermission, DEFAULT_WEBHOOK_PERMISSIONS}; use serde::{Deserialize, Serialize}; use unicode_segmentation::UnicodeSegmentation; @@ -26,7 +25,7 @@ struct MigrationInfo { revision: i32, } -pub const LATEST_REVISION: i32 = 42; // MUST BE +1 to last migration +pub const LATEST_REVISION: i32 = 48; // MUST BE +1 to last migration pub async fn migrate_database(db: &MongoDb) { let migrations = db.col::("migrations"); @@ -914,6 +913,7 @@ pub async fn run_migrations(db: &MongoDb, revision: i32) -> i32 { } if revision <= 26 { + // Need to migrate fields on attachments, change `user_id`, `object_id`, etc to `parent`. info!("Running migration [revision 26 / 15-05-2024]: fix invites being incorrectly serialized with wrong enum tagging."); auto_derived!( @@ -1080,6 +1080,14 @@ pub async fn run_migrations(db: &MongoDb, revision: i32) -> i32 { channel_id: String, } + #[allow(clippy::enum_variant_names)] + #[derive(serde::Serialize, serde::Deserialize)] + enum Channel { + Group { owner: String }, + TextChannel { server: String }, + VoiceChannel { server: String } + } + let webhooks = db .db() .collection::("channel_webhooks") @@ -1091,8 +1099,8 @@ pub async fn run_migrations(db: &MongoDb, revision: i32) -> i32 { .await; for webhook in webhooks { - match db.fetch_channel(&webhook.channel_id).await { - Ok(channel) => { + match db.col::("channels").find_one(doc! { "_id": &webhook.channel_id }).await.unwrap() { + Some(channel) => { let creator_id = match channel { Channel::Group { owner, .. } => owner, Channel::TextChannel { server, .. } @@ -1100,7 +1108,6 @@ pub async fn run_migrations(db: &MongoDb, revision: i32) -> i32 { let server = db.fetch_server(&server).await.expect("server"); server.owner } - _ => unreachable!("not server or group channel!"), }; db.db() @@ -1118,17 +1125,13 @@ pub async fn run_migrations(db: &MongoDb, revision: i32) -> i32 { .await .expect("update webhook"); } - Err(Error { - error_type: ErrorType::NotFound, - .. - }) => { + None => { db.db() .collection::("channel_webhooks") .delete_one(doc! { "_id": webhook._id }) .await .expect("failed to delete invalid webhook"); } - Err(err) => panic!("{err:?}"), } } } @@ -1169,9 +1172,9 @@ pub async fn run_migrations(db: &MongoDb, revision: i32) -> i32 { .expect("failed to update users"); } - if revision <= 41 { + if revision <= 43 { info!( - "Running migration [revision 41 / 05-06-2025]: convert role ranks to uniform numbers." + "Running migration [revision 43 / 05-06-2025]: convert role ranks to uniform numbers." ); #[derive(Serialize, Deserialize, Clone)] @@ -1226,6 +1229,41 @@ pub async fn run_migrations(db: &MongoDb, revision: i32) -> i32 { } } + if revision <= 46 { + info!("Running migration [revision 46 / 29-04-2025]: Convert all `VoiceChannel`'s into `TextChannel`"); + + db.col::("channels") + .update_many( + doc! { "channel_type": "VoiceChannel" }, + doc! { + "$set": { + "channel_type": "TextChannel", + "voice": {} + } + } + ) + .await + .expect("Failed to update voice channels"); + }; + + if revision <= 47 { + info!("Running migration [revision 47 / 29-04-2025]: Add Video to default permissions"); + + db.col::("servers") + .update_many( + doc! { }, + doc! { + "$bit": { + "default_permissions": { + "or": ChannelPermission::Video as i64 + }, + } + } + ) + .await + .expect("Failed to update default_permissions"); + }; + // Reminder to update LATEST_REVISION when adding new migrations. LATEST_REVISION.max(revision) } diff --git a/crates/core/database/src/models/channel_invites/model.rs b/crates/core/database/src/models/channel_invites/model.rs index e3af921ab..a5d877fee 100644 --- a/crates/core/database/src/models/channel_invites/model.rs +++ b/crates/core/database/src/models/channel_invites/model.rs @@ -69,7 +69,7 @@ impl Invite { creator: creator.id.clone(), channel: id.clone(), }), - Channel::TextChannel { id, server, .. } | Channel::VoiceChannel { id, server, .. } => { + Channel::TextChannel { id, server, .. } => { Ok(Invite::Server { code, creator: creator.id.clone(), diff --git a/crates/core/database/src/models/channels/model.rs b/crates/core/database/src/models/channels/model.rs index 673e5e508..d8a485b94 100644 --- a/crates/core/database/src/models/channels/model.rs +++ b/crates/core/database/src/models/channels/model.rs @@ -1,4 +1,5 @@ -use std::collections::HashMap; +#![allow(deprecated)] +use std::{borrow::Cow, collections::HashMap}; use revolt_config::config; use revolt_models::v0::{self, MessageAuthor}; @@ -8,8 +9,7 @@ use serde::{Deserialize, Serialize}; use ulid::Ulid; use crate::{ - events::client::EventV1, Database, File, PartialServer, - Server, SystemMessage, User, AMQP, + events::client::EventV1, Database, File, PartialServer, Server, SystemMessage, User, AMQP, }; #[cfg(feature = "mongodb")] @@ -106,39 +106,19 @@ auto_derived!( /// Whether this channel is marked as not safe for work #[serde(skip_serializing_if = "crate::if_false", default)] nsfw: bool, - }, - /// Voice channel belonging to a server - VoiceChannel { - /// Unique Id - #[serde(rename = "_id")] - id: String, - /// Id of the server this channel belongs to - server: String, - - /// Display name of the channel - name: String, - #[serde(skip_serializing_if = "Option::is_none")] - /// Channel description - description: Option, - /// Custom icon attachment - #[serde(skip_serializing_if = "Option::is_none")] - icon: Option, - /// Default permissions assigned to users in this channel + /// Voice Information for when this channel is also a voice channel #[serde(skip_serializing_if = "Option::is_none")] - default_permissions: Option, - /// Permissions assigned based on role to this channel - #[serde( - default = "HashMap::::new", - skip_serializing_if = "HashMap::::is_empty" - )] - role_permissions: HashMap, - - /// Whether this channel is marked as not safe for work - #[serde(skip_serializing_if = "crate::if_false", default)] - nsfw: bool, + voice: Option, }, } + + #[derive(Default)] + pub struct VoiceInformation { + /// Maximium amount of users allowed in the voice channel at once + #[serde(skip_serializing_if = "Option::is_none")] + pub max_users: Option, + } ); auto_derived!( @@ -164,6 +144,8 @@ auto_derived!( pub default_permissions: Option, #[serde(skip_serializing_if = "Option::is_none")] pub last_message_id: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub voice: Option, } /// Optional fields on channel object @@ -171,6 +153,7 @@ auto_derived!( Description, Icon, DefaultPermissions, + Voice, } ); @@ -222,16 +205,19 @@ impl Channel { default_permissions: None, role_permissions: HashMap::new(), nsfw: data.nsfw.unwrap_or(false), + voice: data.voice.map(|voice| voice.into()), }, - v0::LegacyServerChannelType::Voice => Channel::VoiceChannel { + v0::LegacyServerChannelType::Voice => Channel::TextChannel { id: id.clone(), server: server.id.to_owned(), name: data.name, description: data.description, icon: None, + last_message_id: None, default_permissions: None, role_permissions: HashMap::new(), nsfw: data.nsfw.unwrap_or(false), + voice: Some(data.voice.unwrap_or_default().into()), }, }; @@ -432,8 +418,28 @@ impl Channel { Channel::DirectMessage { id, .. } | Channel::Group { id, .. } | Channel::SavedMessages { id, .. } - | Channel::TextChannel { id, .. } - | Channel::VoiceChannel { id, .. } => id, + | Channel::TextChannel { id, .. } => id, + } + } + + /// Clone this channel's server id + pub fn server(&self) -> Option<&str> { + match self { + Channel::TextChannel { server, .. } => Some(server), + _ => None, + } + } + + /// Gets this channel's voice information + pub fn voice(&self) -> Option> { + match self { + Self::DirectMessage { .. } | Self::Group { .. } => { + Some(Cow::Owned(VoiceInformation::default())) + } + Self::TextChannel { + voice: Some(voice), .. + } => Some(Cow::Borrowed(voice)), + _ => None, } } @@ -450,12 +456,6 @@ impl Channel { server, role_permissions, .. - } - | Channel::VoiceChannel { - id, - server, - role_permissions, - .. } => { db.set_channel_role_permission(id, role_id, permissions) .await?; @@ -502,7 +502,7 @@ impl Channel { clear: remove.into_iter().map(|v| v.into()).collect(), } .p(match self { - Self::TextChannel { server, .. } | Self::VoiceChannel { server, .. } => server.clone(), + Self::TextChannel { server, .. } => server.clone(), _ => id, }) .await; @@ -514,17 +514,13 @@ impl Channel { pub fn remove_field(&mut self, field: &FieldsChannel) { match field { FieldsChannel::Description => match self { - Self::Group { description, .. } - | Self::TextChannel { description, .. } - | Self::VoiceChannel { description, .. } => { + Self::Group { description, .. } | Self::TextChannel { description, .. } => { description.take(); } _ => {} }, FieldsChannel::Icon => match self { - Self::Group { icon, .. } - | Self::TextChannel { icon, .. } - | Self::VoiceChannel { icon, .. } => { + Self::Group { icon, .. } | Self::TextChannel { icon, .. } => { icon.take(); } _ => {} @@ -533,15 +529,17 @@ impl Channel { Self::TextChannel { default_permissions, .. - } - | Self::VoiceChannel { - default_permissions, - .. } => { default_permissions.take(); } _ => {} }, + FieldsChannel::Voice => match self { + Self::TextChannel { voice, .. } => { + voice.take(); + } + _ => {} + }, } } @@ -553,6 +551,7 @@ impl Channel { } /// Apply partial channel to channel + #[allow(deprecated)] pub fn apply_options(&mut self, partial: PartialChannel) { match self { Self::SavedMessages { .. } => {} @@ -601,15 +600,7 @@ impl Channel { nsfw, default_permissions, role_permissions, - .. - } - | Self::VoiceChannel { - name, - description, - icon, - nsfw, - default_permissions, - role_permissions, + voice, .. } => { if let Some(v) = partial.name { @@ -635,6 +626,10 @@ impl Channel { if let Some(v) = partial.default_permissions { default_permissions.replace(v); } + + if let Some(v) = partial.voice { + voice.replace(v); + } } } } @@ -777,6 +772,7 @@ impl IntoDocumentPath for FieldsChannel { FieldsChannel::Description => "description", FieldsChannel::Icon => "icon", FieldsChannel::DefaultPermissions => "default_permissions", + FieldsChannel::Voice => "voice", }) } } diff --git a/crates/core/database/src/models/channels/ops/mongodb.rs b/crates/core/database/src/models/channels/ops/mongodb.rs index b247a6834..79612f8d7 100644 --- a/crates/core/database/src/models/channels/ops/mongodb.rs +++ b/crates/core/database/src/models/channels/ops/mongodb.rs @@ -184,7 +184,7 @@ impl AbstractChannels for MongoDb { async fn delete_channel(&self, channel: &Channel) -> Result<()> { let id = channel.id().to_string(); let server_id = match channel { - Channel::TextChannel { server, .. } | Channel::VoiceChannel { server, .. } => { + Channel::TextChannel { server, .. } => { Some(server) } _ => None, diff --git a/crates/core/database/src/models/channels/ops/reference.rs b/crates/core/database/src/models/channels/ops/reference.rs index 7d5c9a59f..518808bcc 100644 --- a/crates/core/database/src/models/channels/ops/reference.rs +++ b/crates/core/database/src/models/channels/ops/reference.rs @@ -94,9 +94,6 @@ impl AbstractChannels for ReferenceDb { match &mut channel { Channel::TextChannel { role_permissions, .. - } - | Channel::VoiceChannel { - role_permissions, .. } => { if role_permissions.get(role_id).is_some() { role_permissions.remove(role_id); diff --git a/crates/core/database/src/models/messages/model.rs b/crates/core/database/src/models/messages/model.rs index 53d380122..38d3167bc 100644 --- a/crates/core/database/src/models/messages/model.rs +++ b/crates/core/database/src/models/messages/model.rs @@ -114,6 +114,11 @@ auto_derived!( MessagePinned { id: String, by: String }, #[serde(rename = "message_unpinned")] MessageUnpinned { id: String, by: String }, + #[serde(rename = "call_started")] + CallStarted { + by: String, + finished_at: Option, + }, } /// Name and / or avatar override information @@ -326,9 +331,7 @@ impl Message { } let server_id = match channel { - Channel::TextChannel { ref server, .. } | Channel::VoiceChannel { ref server, .. } => { - Some(server.clone()) - } + Channel::TextChannel { ref server, .. } => Some(server.clone()), _ => None, }; @@ -478,6 +481,7 @@ impl Message { // Validate the mentions go to users in the channel/server if !user_mentions.is_empty() { + #[allow(deprecated)] match channel { Channel::DirectMessage { ref recipients, .. } | Channel::Group { ref recipients, .. } => { @@ -485,8 +489,7 @@ impl Message { user_mentions.retain(|m| recipients_hash.contains(m)); role_mentions.clear(); } - Channel::TextChannel { ref server, .. } - | Channel::VoiceChannel { ref server, .. } => { + Channel::TextChannel { ref server, .. }=> { let mentions_vec = Vec::from_iter(user_mentions.iter().cloned()); let valid_members = db.fetch_members(server.as_str(), &mentions_vec[..]).await; @@ -678,7 +681,6 @@ impl Message { ) .await?; - if !self.has_suppressed_notifications() && (self.mentions.is_some() || self.contains_mass_push_mention()) { @@ -794,7 +796,7 @@ impl Message { query: MessageQuery, perspective: &User, include_users: Option, - server_id: Option, + server_id: Option<&str>, ) -> Result { let messages: Vec = db .fetch_messages(query) @@ -837,6 +839,7 @@ impl Message { v0::SystemMessage::MessageUnpinned { by, .. } => { users.push(by.clone()); } + v0::SystemMessage::CallStarted { by, .. } => users.push(by.clone()), } } users @@ -851,7 +854,7 @@ impl Message { users, members: if let Some(server_id) = server_id { Some( - db.fetch_members(&server_id, &user_ids) + db.fetch_members(server_id, &user_ids) .await? .into_iter() .map(Into::into) diff --git a/crates/core/database/src/models/server_members/model.rs b/crates/core/database/src/models/server_members/model.rs index ee5e354bc..2856cecf5 100644 --- a/crates/core/database/src/models/server_members/model.rs +++ b/crates/core/database/src/models/server_members/model.rs @@ -1,12 +1,21 @@ use iso8601_timestamp::Timestamp; use revolt_permissions::{calculate_channel_permissions, ChannelPermission}; use revolt_result::{create_error, Result}; +use crate::voice::get_channel_voice_state; use crate::{ - events::client::EventV1, util::permissions::DatabasePermissionQuery, Channel, Database, File, - Server, SystemMessage, User, + events::client::EventV1, util::permissions::DatabasePermissionQuery, Channel, + Database, File, Server, SystemMessage, User, }; +fn default_true() -> bool { + true +} + +fn is_true(x: &bool) -> bool { + *x +} + auto_derived_partial!( /// Server Member pub struct Member { @@ -30,6 +39,14 @@ auto_derived_partial!( /// Timestamp this member is timed out until #[serde(skip_serializing_if = "Option::is_none")] pub timeout: Option, + + /// Whether the member is server-wide voice muted + #[serde(skip_serializing_if = "is_true", default = "default_true")] + pub can_publish: bool, + /// Whether the member is server-wide voice deafened + #[serde(skip_serializing_if = "is_true", default = "default_true")] + pub can_receive: bool, + // This value only exists in the database, not the models. // If it is not-None, the database layer should return None to member fetching queries. // pub pending_deletion_at: Option @@ -53,6 +70,8 @@ auto_derived!( Avatar, Roles, Timeout, + CanReceive, + CanPublish, JoinedAt, } @@ -73,6 +92,8 @@ impl Default for Member { avatar: None, roles: vec![], timeout: None, + can_publish: true, + can_receive: true, } } } @@ -127,6 +148,14 @@ impl Member { let emojis = db.fetch_emoji_by_parent_id(&server.id).await?; + let mut voice_states = Vec::new(); + + for channel in &channels { + if let Ok(Some(voice_state)) = get_channel_voice_state(channel).await { + voice_states.push(voice_state) + } + } + EventV1::ServerMemberJoin { id: server.id.clone(), user: user.id.clone(), @@ -144,6 +173,7 @@ impl Member { .map(|channel| channel.into()) .collect(), emojis: emojis.into_iter().map(|emoji| emoji.into()).collect(), + voice_states } .private(user.id.clone()) .await; @@ -198,6 +228,8 @@ impl Member { FieldsMember::Nickname => self.nickname = None, FieldsMember::Roles => self.roles.clear(), FieldsMember::Timeout => self.timeout = None, + FieldsMember::CanReceive => self.can_receive = true, + FieldsMember::CanPublish => self.can_publish = true, } } diff --git a/crates/core/database/src/models/server_members/ops/mongodb.rs b/crates/core/database/src/models/server_members/ops/mongodb.rs index cd9c50954..96bb41595 100644 --- a/crates/core/database/src/models/server_members/ops/mongodb.rs +++ b/crates/core/database/src/models/server_members/ops/mongodb.rs @@ -336,6 +336,8 @@ impl IntoDocumentPath for FieldsMember { FieldsMember::Nickname => "nickname", FieldsMember::Roles => "roles", FieldsMember::Timeout => "timeout", + FieldsMember::CanPublish => "is_publishing", + FieldsMember::CanReceive => "is_receiving", }) } } diff --git a/crates/core/database/src/tasks/ack.rs b/crates/core/database/src/tasks/ack.rs index c12e41f3c..c26de03b7 100644 --- a/crates/core/database/src/tasks/ack.rs +++ b/crates/core/database/src/tasks/ack.rs @@ -14,7 +14,7 @@ use validator::HasLen; use revolt_result::Result; use super::DelayedTask; -use crate::Channel::{TextChannel, VoiceChannel}; +use crate::Channel::TextChannel; /// Enumeration of possible events #[derive(Debug, Eq, PartialEq)] @@ -191,17 +191,14 @@ pub async fn handle_ack_event( .await .expect("Failed to fetch channel from db"); - match channel { - TextChannel { server, .. } | VoiceChannel { server, .. } => { - if let Err(err) = - amqp.mass_mention_message_sent(server, mass_mentions).await - { - revolt_config::capture_error(&err); - } - } - _ => { - panic!("Unknown channel type when sending mass mention event"); + if let TextChannel { server, .. } = channel { + if let Err(err) = + amqp.mass_mention_message_sent(server, mass_mentions).await + { + revolt_config::capture_error(&err); } + } else { + panic!("Unknown channel type when sending mass mention event"); } } } diff --git a/crates/core/database/src/util/bridge/v0.rs b/crates/core/database/src/util/bridge/v0.rs index de64a4bf2..915b75ce1 100644 --- a/crates/core/database/src/util/bridge/v0.rs +++ b/crates/core/database/src/util/bridge/v0.rs @@ -143,6 +143,7 @@ impl From for FieldsWebhook { } impl From for Channel { + #[allow(deprecated)] fn from(value: crate::Channel) -> Self { match value { crate::Channel::SavedMessages { id, user } => Channel::SavedMessages { id, user }, @@ -188,6 +189,7 @@ impl From for Channel { default_permissions, role_permissions, nsfw, + voice, } => Channel::TextChannel { id, server, @@ -198,31 +200,14 @@ impl From for Channel { default_permissions, role_permissions, nsfw, - }, - crate::Channel::VoiceChannel { - id, - server, - name, - description, - icon, - default_permissions, - role_permissions, - nsfw, - } => Channel::VoiceChannel { - id, - server, - name, - description, - icon: icon.map(|file| file.into()), - default_permissions, - role_permissions, - nsfw, + voice: voice.map(|voice| voice.into()), }, } } } impl From for crate::Channel { + #[allow(deprecated)] fn from(value: Channel) -> crate::Channel { match value { Channel::SavedMessages { id, user } => crate::Channel::SavedMessages { id, user }, @@ -268,6 +253,7 @@ impl From for crate::Channel { default_permissions, role_permissions, nsfw, + voice, } => crate::Channel::TextChannel { id, server, @@ -278,25 +264,7 @@ impl From for crate::Channel { default_permissions, role_permissions, nsfw, - }, - Channel::VoiceChannel { - id, - server, - name, - description, - icon, - default_permissions, - role_permissions, - nsfw, - } => crate::Channel::VoiceChannel { - id, - server, - name, - description, - icon: icon.map(|file| file.into()), - default_permissions, - role_permissions, - nsfw, + voice: voice.map(|voice| voice.into()), }, } } @@ -315,6 +283,7 @@ impl From for PartialChannel { role_permissions: value.role_permissions, default_permissions: value.default_permissions, last_message_id: value.last_message_id, + voice: value.voice.map(|voice| voice.into()) } } } @@ -332,6 +301,7 @@ impl From for crate::PartialChannel { role_permissions: value.role_permissions, default_permissions: value.default_permissions, last_message_id: value.last_message_id, + voice: value.voice.map(|voice| voice.into()) } } } @@ -342,6 +312,7 @@ impl From for crate::FieldsChannel { FieldsChannel::Description => crate::FieldsChannel::Description, FieldsChannel::Icon => crate::FieldsChannel::Icon, FieldsChannel::DefaultPermissions => crate::FieldsChannel::DefaultPermissions, + FieldsChannel::Voice => crate::FieldsChannel::Voice, } } } @@ -352,6 +323,7 @@ impl From for FieldsChannel { crate::FieldsChannel::Description => FieldsChannel::Description, crate::FieldsChannel::Icon => FieldsChannel::Icon, crate::FieldsChannel::DefaultPermissions => FieldsChannel::DefaultPermissions, + crate::FieldsChannel::Voice => FieldsChannel::Voice, } } } @@ -543,6 +515,7 @@ impl From for SystemMessage { crate::SystemMessage::UserRemove { id, by } => Self::UserRemove { id, by }, crate::SystemMessage::MessagePinned { id, by } => Self::MessagePinned { id, by }, crate::SystemMessage::MessageUnpinned { id, by } => Self::MessageUnpinned { id, by }, + crate::SystemMessage::CallStarted { by, finished_at } => Self::CallStarted { by, finished_at } } } } @@ -639,6 +612,8 @@ impl From for Member { avatar: value.avatar.map(|f| f.into()), roles: value.roles, timeout: value.timeout, + can_publish: value.can_publish, + can_receive: value.can_receive, } } } @@ -652,6 +627,8 @@ impl From for crate::Member { avatar: value.avatar.map(|f| f.into()), roles: value.roles, timeout: value.timeout, + can_publish: value.can_publish, + can_receive: value.can_receive, } } } @@ -665,6 +642,8 @@ impl From for PartialMember { avatar: value.avatar.map(|f| f.into()), roles: value.roles, timeout: value.timeout, + can_publish: value.can_publish, + can_receive: value.can_receive, } } } @@ -678,6 +657,8 @@ impl From for crate::PartialMember { avatar: value.avatar.map(|f| f.into()), roles: value.roles, timeout: value.timeout, + can_publish: value.can_publish, + can_receive: value.can_receive, } } } @@ -707,6 +688,8 @@ impl From for FieldsMember { crate::FieldsMember::Nickname => FieldsMember::Nickname, crate::FieldsMember::Roles => FieldsMember::Roles, crate::FieldsMember::Timeout => FieldsMember::Timeout, + crate::FieldsMember::CanReceive => FieldsMember::CanReceive, + crate::FieldsMember::CanPublish => FieldsMember::CanPublish, crate::FieldsMember::JoinedAt => FieldsMember::JoinedAt, } } @@ -719,6 +702,8 @@ impl From for crate::FieldsMember { FieldsMember::Nickname => crate::FieldsMember::Nickname, FieldsMember::Roles => crate::FieldsMember::Roles, FieldsMember::Timeout => crate::FieldsMember::Timeout, + FieldsMember::CanReceive => crate::FieldsMember::CanReceive, + FieldsMember::CanPublish => crate::FieldsMember::CanPublish, FieldsMember::JoinedAt => crate::FieldsMember::JoinedAt, } } @@ -1387,3 +1372,19 @@ impl From for crate::FieldsMessage { } } } + +impl From for crate::VoiceInformation { + fn from(value: VoiceInformation) -> Self { + crate::VoiceInformation { + max_users: value.max_users + } + } +} + +impl From for VoiceInformation { + fn from(value: crate::VoiceInformation) -> Self { + VoiceInformation { + max_users: value.max_users + } + } +} \ No newline at end of file diff --git a/crates/core/database/src/util/bulk_permissions.rs b/crates/core/database/src/util/bulk_permissions.rs index 9e7a1f7a1..976b0d692 100644 --- a/crates/core/database/src/util/bulk_permissions.rs +++ b/crates/core/database/src/util/bulk_permissions.rs @@ -144,10 +144,6 @@ impl<'z> BulkDatabasePermissionQuery<'z> { Channel::TextChannel { default_permissions, .. - } - | Channel::VoiceChannel { - default_permissions, - .. } => default_permissions.unwrap_or_default().into(), _ => Default::default(), } @@ -156,16 +152,14 @@ impl<'z> BulkDatabasePermissionQuery<'z> { } } - #[allow(dead_code)] + #[allow(dead_code, deprecated)] fn get_channel_type(&mut self) -> ChannelType { if let Some(channel) = &self.channel { match channel { Channel::DirectMessage { .. } => ChannelType::DirectMessage, Channel::Group { .. } => ChannelType::Group, Channel::SavedMessages { .. } => ChannelType::SavedMessages, - Channel::TextChannel { .. } | Channel::VoiceChannel { .. } => { - ChannelType::ServerChannel - } + Channel::TextChannel { .. } => ChannelType::ServerChannel, } } else { ChannelType::Unknown @@ -179,9 +173,6 @@ impl<'z> BulkDatabasePermissionQuery<'z> { match channel { Channel::TextChannel { role_permissions, .. - } - | Channel::VoiceChannel { - role_permissions, .. } => role_permissions, _ => panic!("Not supported for non-server channels"), } @@ -208,12 +199,6 @@ async fn calculate_members_permissions<'a>( role_permissions, default_permissions, .. - } - | Channel::VoiceChannel { - id, - role_permissions, - default_permissions, - .. } => (id, role_permissions, default_permissions), _ => panic!("Calculation of member permissions must be done on a server channel"), }; diff --git a/crates/core/database/src/util/funcs.rs b/crates/core/database/src/util/funcs.rs new file mode 100644 index 000000000..24f9906d4 --- /dev/null +++ b/crates/core/database/src/util/funcs.rs @@ -0,0 +1,24 @@ +use crate::Database; +use revolt_result::Result; + +/// Formats a user's name depending on their optional features and location. +/// Factors in server display names and user display names before falling back to username#discriminator. +/// Passing a server in which the user is not a member will result in an Err. +pub async fn format_display_name( + db: &Database, + user_id: &str, + server_id: Option<&str>, +) -> Result { + if let Some(server_id) = server_id { + let member = db.fetch_member(server_id, user_id).await?; + if let Some(nick) = member.nickname { + return Ok(nick); + } + } + + let user = db.fetch_user(user_id).await?; + if let Some(display) = user.display_name { + return Ok(display); + } + Ok(format!("{}#{}", user.username, user.discriminator)) +} diff --git a/crates/core/database/src/util/mod.rs b/crates/core/database/src/util/mod.rs index 1baf7d8ba..1a03a1768 100644 --- a/crates/core/database/src/util/mod.rs +++ b/crates/core/database/src/util/mod.rs @@ -1,6 +1,9 @@ pub mod bridge; pub mod bulk_permissions; +mod funcs; pub mod idempotency; pub mod permissions; pub mod reference; pub mod test_fixtures; + +pub use funcs::*; diff --git a/crates/core/database/src/util/permissions.rs b/crates/core/database/src/util/permissions.rs index e592988eb..85db48267 100644 --- a/crates/core/database/src/util/permissions.rs +++ b/crates/core/database/src/util/permissions.rs @@ -185,9 +185,26 @@ impl PermissionQuery for DatabasePermissionQuery<'_> { } } + async fn do_we_have_publish_overwrites(&mut self) -> bool { + if let Some(member) = &self.member { + member.can_publish + } else { + true + } + } + + async fn do_we_have_receive_overwrites(&mut self) -> bool { + if let Some(member) = &self.member { + member.can_receive + } else { + true + } + } + // * For calculating channel permission /// Get the type of the channel + #[allow(deprecated)] async fn get_channel_type(&mut self) -> ChannelType { if let Some(channel) = &self.channel { match channel { @@ -199,9 +216,7 @@ impl PermissionQuery for DatabasePermissionQuery<'_> { Cow::Borrowed(Channel::SavedMessages { .. }) | Cow::Owned(Channel::SavedMessages { .. }) => ChannelType::SavedMessages, Cow::Borrowed(Channel::TextChannel { .. }) - | Cow::Owned(Channel::TextChannel { .. }) - | Cow::Borrowed(Channel::VoiceChannel { .. }) - | Cow::Owned(Channel::VoiceChannel { .. }) => ChannelType::ServerChannel, + | Cow::Owned(Channel::TextChannel { .. }) => ChannelType::ServerChannel, } } else { ChannelType::Unknown @@ -225,14 +240,6 @@ impl PermissionQuery for DatabasePermissionQuery<'_> { | Cow::Owned(Channel::TextChannel { default_permissions, .. - }) - | Cow::Borrowed(Channel::VoiceChannel { - default_permissions, - .. - }) - | Cow::Owned(Channel::VoiceChannel { - default_permissions, - .. }) => default_permissions.unwrap_or_default().into(), _ => Default::default(), } @@ -250,12 +257,6 @@ impl PermissionQuery for DatabasePermissionQuery<'_> { }) | Cow::Owned(Channel::TextChannel { role_permissions, .. - }) - | Cow::Borrowed(Channel::VoiceChannel { - role_permissions, .. - }) - | Cow::Owned(Channel::VoiceChannel { - role_permissions, .. }) => { if let Some(server) = &self.server { let member_roles = self @@ -343,11 +344,10 @@ impl PermissionQuery for DatabasePermissionQuery<'_> { /// (this will only ever be called for server channels, use unimplemented!() for other code paths) async fn set_server_from_channel(&mut self) { if let Some(channel) = &self.channel { + #[allow(deprecated)] match channel { Cow::Borrowed(Channel::TextChannel { server, .. }) - | Cow::Owned(Channel::TextChannel { server, .. }) - | Cow::Borrowed(Channel::VoiceChannel { server, .. }) - | Cow::Owned(Channel::VoiceChannel { server, .. }) => { + | Cow::Owned(Channel::TextChannel { server, .. }) => { if let Some(known_server) = // I'm not sure why I can't just pattern match both at once here? // It throws some weird error and the provided fix doesn't work :/ diff --git a/crates/core/database/src/voice/mod.rs b/crates/core/database/src/voice/mod.rs new file mode 100644 index 000000000..8a66b7ede --- /dev/null +++ b/crates/core/database/src/voice/mod.rs @@ -0,0 +1,564 @@ +use crate::{ + events::client::EventV1, + models::{Channel, User}, + util::{permissions::DatabasePermissionQuery, reference::Reference}, + Database, Server, +}; +use iso8601_timestamp::{Duration, Timestamp}; +use livekit_protocol::ParticipantPermission; +use redis_kiss::{get_connection as _get_connection, redis::Pipeline, AsyncCommands, Conn}; +use revolt_config::FeaturesLimits; +use revolt_models::v0::{self, PartialUserVoiceState, UserVoiceState}; +use revolt_permissions::{calculate_channel_permissions, ChannelPermission, PermissionValue}; +use revolt_result::{create_error, Result, ToRevoltError}; + +mod voice_client; +pub use voice_client::VoiceClient; + +async fn get_connection() -> Result { + _get_connection().await.to_internal_error() +} + +pub async fn raise_if_in_voice(user: &User, channel_id: &str) -> Result<()> { + let mut conn = get_connection().await?; + + if user.bot.is_some() + // bots can be in as many voice channels as it wants so we just check if its already connected to the one its trying to connect to + && conn.sismember(format!("vc:{}", &user.id), channel_id) + .await + .to_internal_error()? + { + Err(create_error!(AlreadyConnected)) + } else if conn + .scard::<_, u32>(format!("vc:{}", &user.id)) // check if the current vc set is empty + .await + .to_internal_error()? + > 0 + { + Err(create_error!(NotConnected)) + } else { + Ok(()) + } +} + +pub async fn set_channel_node(channel: &str, node: &str) -> Result<()> { + get_connection() + .await? + .set(format!("node:{channel}"), node) + .await + .to_internal_error() +} + +pub async fn get_channel_node(channel: &str) -> Result> { + get_connection() + .await? + .get(format!("node:{channel}")) + .await + .to_internal_error() +} + +pub async fn get_user_voice_channels(user_id: &str) -> Result> { + get_connection() + .await? + .smembers(format!("vc:{user_id}")) + .await + .to_internal_error() +} + +pub async fn set_user_moved_from_voice( + old_channel: &str, + new_channel: &str, + user_id: &str, +) -> Result<()> { + get_connection() + .await? + .set_ex( + format!("moved_from:{user_id}:{old_channel}"), + new_channel, + 10, + ) + .await + .to_internal_error() +} + +pub async fn get_user_moved_from_voice(channel_id: &str, user_id: &str) -> Result> { + get_connection() + .await? + .get_del(format!("moved_from:{user_id}:{channel_id}")) + .await + .to_internal_error() +} + +pub async fn set_user_moved_to_voice( + new_channel: &str, + old_channel: &str, + user_id: &str, +) -> Result<()> { + get_connection() + .await? + .set_ex(format!("moved_to:{user_id}:{new_channel}"), old_channel, 10) + .await + .to_internal_error() +} + +pub async fn get_user_moved_to_voice(channel_id: &str, user_id: &str) -> Result> { + get_connection() + .await? + .get_del(format!("moved_to:{user_id}:{channel_id}")) + .await + .to_internal_error() +} + +pub async fn is_in_voice_channel(user_id: &str, channel_id: &str) -> Result { + get_connection() + .await? + .sismember(format!("vc:{user_id}"), channel_id) + .await + .to_internal_error() +} + +pub async fn get_user_voice_channel_in_server( + user_id: &str, + server_id: &str, +) -> Result> { + let mut conn = get_connection().await?; + + let unique_key = format!("{user_id}:{server_id}"); + + conn.get(&unique_key).await.to_internal_error() +} + +pub fn get_allowed_sources( + limits: &FeaturesLimits, + permissions: PermissionValue, +) -> Vec<&'static str> { + let mut allowed_sources = Vec::new(); + + if permissions.has(ChannelPermission::Speak as u64) { + allowed_sources.push("microphone") + }; + + if permissions.has(ChannelPermission::Video as u64) && limits.video { + allowed_sources.extend(["camera", "screen_share", "screen_share_audio"]); + }; + + allowed_sources +} + +pub async fn create_voice_state( + channel_id: &str, + server_id: Option<&str>, + user_id: &str, + joined_at: Timestamp, +) -> Result { + let unique_key = format!("{}:{}", &user_id, server_id.unwrap_or(channel_id)); + + let voice_state = UserVoiceState { + joined_at, + id: user_id.to_string(), + is_receiving: true, + is_publishing: false, + screensharing: false, + camera: false, + }; + + Pipeline::new() + .sadd(format!("vc_members:{channel_id}"), user_id) + .sadd(format!("vc:{user_id}"), channel_id) + .set(&unique_key, channel_id) + .set( + format!("joined_at:{unique_key}"), + joined_at + .duration_since(Timestamp::UNIX_EPOCH) + .whole_milliseconds() as i64, + ) + .set( + format!("is_publishing:{unique_key}"), + voice_state.is_publishing, + ) + .set( + format!("is_receiving:{unique_key}"), + voice_state.is_receiving, + ) + .set( + format!("screensharing:{unique_key}"), + voice_state.screensharing, + ) + .set(format!("camera:{unique_key}"), voice_state.camera) + .query_async::<_, ()>(&mut get_connection().await?.into_inner()) + .await + .to_internal_error()?; + + Ok(voice_state) +} + +pub async fn delete_voice_state( + channel_id: &str, + server_id: Option<&str>, + user_id: &str, +) -> Result<()> { + let unique_key = format!("{}:{}", &user_id, server_id.unwrap_or(channel_id)); + + Pipeline::new() + .srem(format!("vc_members:{channel_id}"), user_id) + .srem(format!("vc:{user_id}"), channel_id) + .del(&[ + format!("joined_at:{unique_key}"), + format!("is_publishing:{unique_key}"), + format!("is_receiving:{unique_key}"), + format!("screensharing:{unique_key}"), + format!("camera:{unique_key}"), + unique_key.clone(), + ]) + .query_async(&mut get_connection().await?.into_inner()) + .await + .to_internal_error() +} + +pub async fn delete_channel_voice_state( + channel_id: &str, + server_id: Option<&str>, + user_ids: &[String], +) -> Result<()> { + let parent_id = server_id.unwrap_or(channel_id); + + let mut pipeline = Pipeline::new(); + pipeline.del(format!("vc_members:{channel_id}")); + + for user_id in user_ids { + let unique_key = format!("{user_id}:{parent_id}"); + + pipeline.srem(format!("vc:{user_id}"), channel_id).del(&[ + format!("joined_at:{unique_key}"), + format!("is_publishing:{unique_key}"), + format!("is_receiving:{unique_key}"), + format!("screensharing:{unique_key}"), + format!("camera:{unique_key}"), + unique_key.clone(), + ]); + } + + pipeline + .query_async(&mut get_connection().await?.into_inner()) + .await + .to_internal_error() +} + +pub async fn update_voice_state_tracks( + channel_id: &str, + server_id: Option<&str>, + user_id: &str, + added: bool, + track: i32, +) -> Result { + let partial = match track { + /* TrackSource::Unknown */ 0 => PartialUserVoiceState::default(), + /* TrackSource::Camera */ + 1 => PartialUserVoiceState { + camera: Some(added), + ..Default::default() + }, + /* TrackSource::Microphone */ + 2 => PartialUserVoiceState { + is_publishing: Some(added), + ..Default::default() + }, + /* TrackSource::ScreenShare | TrackSource::ScreenShareAudio */ + 3 | 4 => PartialUserVoiceState { + screensharing: Some(added), + ..Default::default() + }, + _ => unreachable!(), + }; + + update_voice_state(channel_id, server_id, user_id, &partial).await?; + + Ok(partial) +} + +pub async fn update_voice_state( + channel_id: &str, + server_id: Option<&str>, + user_id: &str, + partial: &PartialUserVoiceState, +) -> Result<()> { + let unique_key = format!("{}:{}", &user_id, server_id.unwrap_or(channel_id)); + + let mut pipeline = Pipeline::new(); + + if let Some(camera) = &partial.camera { + pipeline.set(format!("camera:{unique_key}"), camera); + }; + + if let Some(is_publishing) = &partial.is_publishing { + pipeline.set(format!("is_publishing:{unique_key}"), is_publishing); + } + + if let Some(is_receiving) = &partial.is_receiving { + pipeline.set(format!("is_receiving:{unique_key}"), is_receiving); + } + + if let Some(screensharing) = &partial.screensharing { + pipeline.set(format!("screensharing:{unique_key}"), screensharing); + } + + pipeline + .query_async(&mut get_connection().await?.into_inner()) + .await + .to_internal_error() +} + +pub async fn get_voice_channel_members(channel_id: &str) -> Result>> { + get_connection() + .await? + .smembers::<_, Option>>(format!("vc_members:{channel_id}")) + .await + .to_internal_error() + .map(|opt| opt.and_then(|v| if v.is_empty() { None } else { Some(v) })) +} + +pub async fn get_voice_state( + channel_id: &str, + server_id: Option<&str>, + user_id: &str, +) -> Result> { + let unique_key = format!("{}:{}", user_id, server_id.unwrap_or(channel_id)); + + let (joined_at, is_publishing, is_receiving, screensharing, camera) = get_connection() + .await? + .mget(&[ + format!("joined_at:{unique_key}"), + format!("is_publishing:{unique_key}"), + format!("is_receiving:{unique_key}"), + format!("screensharing:{unique_key}"), + format!("camera:{unique_key}"), + ]) + .await + .to_internal_error()?; + + match ( + joined_at, + is_publishing, + is_receiving, + screensharing, + camera, + ) { + ( + Some(joined_at), + Some(is_publishing), + Some(is_receiving), + Some(screensharing), + Some(camera), + ) => Ok(Some(v0::UserVoiceState { + joined_at: Timestamp::UNIX_EPOCH + .checked_add(Duration::milliseconds(joined_at)) + .unwrap(), + id: user_id.to_string(), + is_receiving, + is_publishing, + screensharing, + camera, + })), + _ => Ok(None), + } +} + +pub async fn get_channel_voice_state(channel: &Channel) -> Result> { + let members = get_voice_channel_members(channel.id()).await?; + + let server = channel.server(); + + if let Some(members) = members { + let mut participants = Vec::with_capacity(members.len()); + + for user_id in members { + if let Some(voice_state) = get_voice_state(channel.id(), server, &user_id).await? { + participants.push(voice_state); + } else { + log::info!("Voice state not found but member in voice channel members, removing."); + + delete_voice_state(channel.id(), server, &user_id).await?; + } + } + + // In case a user voice state failed to be fetched, the vec's capacity will be larger than the length, shrink it + participants.shrink_to_fit(); + + Ok(Some(v0::ChannelVoiceState { + id: channel.id().to_string(), + participants, + })) + } else { + Ok(None) + } +} + +pub async fn move_user(user: &str, from: &str, to: &str) -> Result<()> { + get_connection() + .await? + .smove( + format!("vc-members-{from}"), + format!("vc-members-{to}"), + user, + ) + .await + .to_internal_error() +} + +pub async fn sync_voice_permissions( + db: &Database, + voice_client: &VoiceClient, + channel: &Channel, + server: Option<&Server>, + role_id: Option<&str>, +) -> Result<()> { + let node = get_channel_node(channel.id()).await?.unwrap(); + + for user_id in get_voice_channel_members(channel.id()) + .await? + .iter() + .flatten() + { + let user = Reference::from_unchecked(user_id).as_user(db).await?; + + sync_user_voice_permissions(db, voice_client, &node, &user, channel, server, role_id) + .await?; + } + + Ok(()) +} + +pub async fn sync_user_voice_permissions( + db: &Database, + voice_client: &VoiceClient, + node: &str, + user: &User, + channel: &Channel, + server: Option<&Server>, + role_id: Option<&str>, +) -> Result<()> { + let channel_id = channel.id(); + let server_id = server.as_ref().map(|s| s.id.as_str()); + + let member = match server_id { + Some(server_id) => Some( + Reference::from_unchecked(&user.id) + .as_member(db, server_id) + .await?, + ), + None => None, + }; + + if role_id.is_none_or(|role_id| { + member + .as_ref() + .is_none_or(|member| member.roles.iter().any(|r| r == role_id)) + }) { + let voice_state = get_voice_state(channel_id, server_id, &user.id) + .await? + .unwrap(); + + let mut query = DatabasePermissionQuery::new(db, user) + .channel(channel) + .user(user); + + if let (Some(server), Some(member)) = (server, member.as_ref()) { + query = query.member(member).server(server) + } + + let permissions = calculate_channel_permissions(&mut query).await; + let limits = user.limits().await; + + let mut update_event = PartialUserVoiceState { + id: Some(user.id.clone()), + ..Default::default() + }; + + let before = update_event.clone(); + + let can_video = + limits.video && permissions.has_channel_permission(ChannelPermission::Video); + let can_speak = permissions.has_channel_permission(ChannelPermission::Speak); + let can_listen = permissions.has_channel_permission(ChannelPermission::Listen); + + update_event.camera = voice_state.camera.then_some(can_video); + update_event.screensharing = voice_state.screensharing.then_some(can_video); + update_event.is_publishing = voice_state.is_publishing.then_some(can_speak); + + update_voice_state(channel_id, server_id, &user.id, &update_event).await?; + + voice_client + .update_permissions( + node, + user, + channel_id, + ParticipantPermission { + can_subscribe: can_listen, + can_publish: can_speak, + can_publish_data: can_speak, + ..Default::default() + }, + ) + .await?; + + if update_event != before { + EventV1::UserVoiceStateUpdate { + id: user.id.clone(), + channel_id: channel_id.to_string(), + data: update_event, + } + .p(channel_id.to_string()) + .await; + }; + }; + + Ok(()) +} + +pub async fn set_channel_call_started_system_message( + channel_id: &str, + message_id: &str, +) -> Result<()> { + get_connection() + .await? + .set(format!("call_started_message:{channel_id}"), message_id) + .await + .to_internal_error() +} + +pub async fn take_channel_call_started_system_message(channel_id: &str) -> Result> { + get_connection() + .await? + .get_del(format!("call_started_message:{channel_id}")) + .await + .to_internal_error() +} + +pub async fn set_call_notification_recipients( + channel_id: &str, + user_id: &str, + recipients: &[String], +) -> Result<()> { + get_connection() + .await? + .set_ex( + format!("call_notification_recipients:{channel_id}-{user_id}"), + recipients, + 10, + ) + .await + .to_internal_error() +} + +pub async fn get_call_notification_recipients( + channel_id: &str, + user_id: &str, +) -> Result>> { + get_connection() + .await? + .get_del(format!( + "call_notification_recipients:{channel_id}-{user_id}" + )) + .await + .to_internal_error() +} diff --git a/crates/core/database/src/voice/voice_client.rs b/crates/core/database/src/voice/voice_client.rs new file mode 100644 index 000000000..967f3fefc --- /dev/null +++ b/crates/core/database/src/voice/voice_client.rs @@ -0,0 +1,156 @@ +use crate::{ + models::{Channel, User}, + Database, +}; +use livekit_api::{ + access_token::{AccessToken, VideoGrants}, + services::room::{CreateRoomOptions, RoomClient as InnerRoomClient, UpdateParticipantOptions}, +}; +use livekit_protocol::{ParticipantInfo, ParticipantPermission, Room}; +use revolt_config::{config, LiveKitNode}; +use revolt_permissions::{ChannelPermission, PermissionValue}; +use revolt_result::{create_error, Result, ToRevoltError}; +use std::{collections::HashMap, time::Duration}; + +use super::get_allowed_sources; + +#[derive(Debug)] +pub struct RoomClient { + pub client: InnerRoomClient, + pub node: LiveKitNode, +} + +#[derive(Debug)] +pub struct VoiceClient { + pub rooms: HashMap, +} + +impl VoiceClient { + pub fn new(nodes: HashMap) -> Self { + Self { + rooms: nodes + .into_iter() + .map(|(name, node)| { + ( + name, + RoomClient { + client: InnerRoomClient::with_api_key( + &node.url, + &node.key, + &node.secret, + ), + node, + }, + ) + }) + .collect(), + } + } + + pub fn is_enabled(&self) -> bool { + !self.rooms.is_empty() + } + + pub async fn from_revolt_config() -> Self { + let config = config().await; + + Self::new(config.api.livekit.nodes.clone()) + } + + pub fn get_node(&self, name: &str) -> Result<&RoomClient> { + self.rooms + .get(name) + .ok_or_else(|| create_error!(UnknownNode)) + } + + pub async fn create_token( + &self, + node: &str, + db: &Database, + user: &User, + permissions: PermissionValue, + channel: &Channel, + ) -> Result { + let room = self.get_node(node)?; + + let limits = user.limits().await; + let allowed_sources = get_allowed_sources(&limits, permissions); + + AccessToken::with_api_key(&room.node.key, &room.node.secret) + .with_name(&format!("{}#{}", user.username, user.discriminator)) + .with_identity(&user.id) + .with_metadata( + &serde_json::to_string(&user.clone().into(db, None).await).to_internal_error()?, + ) + .with_ttl(Duration::from_secs(10)) + .with_grants(VideoGrants { + room_join: true, + can_publish: true, + can_publish_data: false, + can_publish_sources: allowed_sources + .into_iter() + .map(ToString::to_string) + .collect(), + can_subscribe: permissions.has_channel_permission(ChannelPermission::Listen), + room: channel.id().to_string(), + ..Default::default() + }) + .to_jwt() + .to_internal_error() + } + + pub async fn create_room(&self, node: &str, channel: &Channel) -> Result { + let room = self.get_node(node)?; + + room.client + .create_room( + channel.id(), + CreateRoomOptions { + empty_timeout: 5 * 60, // 5 minutes, + ..Default::default() + }, + ) + .await + .to_internal_error() + } + + pub async fn update_permissions( + &self, + node: &str, + user: &User, + channel_id: &str, + new_permissions: ParticipantPermission, + ) -> Result { + let room = self.get_node(node)?; + + room.client + .update_participant( + channel_id, + &user.id, + UpdateParticipantOptions { + permission: Some(new_permissions), + ..Default::default() + }, + ) + .await + .to_internal_error() + } + + pub async fn remove_user(&self, node: &str, user_id: &str, channel_id: &str) -> Result<()> { + let room = self.get_node(node)?; + + room.client + .remove_participant(channel_id, user_id) + .await + .to_internal_error() + } + + pub async fn delete_room(&self, node: &str, channel_id: &str) -> Result<()> { + let room = self.get_node(node)?; + + room.client + .delete_room(channel_id) + .await + .to_internal_error() + } +} diff --git a/crates/core/models/src/v0/channels.rs b/crates/core/models/src/v0/channels.rs index ec25257b8..d8b4e228d 100644 --- a/crates/core/models/src/v0/channels.rs +++ b/crates/core/models/src/v0/channels.rs @@ -1,4 +1,5 @@ -use super::File; +#![allow(deprecated)] +use super::{File, UserVoiceState}; use revolt_permissions::{Override, OverrideField}; use std::collections::{HashMap, HashSet}; @@ -107,46 +108,23 @@ auto_derived!( serde(skip_serializing_if = "crate::if_false", default) )] nsfw: bool, - }, - /// Voice channel belonging to a server - VoiceChannel { - /// Unique Id - #[cfg_attr(feature = "serde", serde(rename = "_id"))] - id: String, - /// Id of the server this channel belongs to - server: String, - /// Display name of the channel - name: String, - #[cfg_attr(feature = "serde", serde(skip_serializing_if = "Option::is_none"))] - /// Channel description - description: Option, - /// Custom icon attachment + /// Voice Information for when this channel is also a voice channel #[cfg_attr(feature = "serde", serde(skip_serializing_if = "Option::is_none"))] - icon: Option, - - /// Default permissions assigned to users in this channel - #[cfg_attr(feature = "serde", serde(skip_serializing_if = "Option::is_none"))] - default_permissions: Option, - /// Permissions assigned based on role to this channel - #[cfg_attr( - feature = "serde", - serde( - default = "HashMap::::new", - skip_serializing_if = "HashMap::::is_empty" - ) - )] - role_permissions: HashMap, - - /// Whether this channel is marked as not safe for work - #[cfg_attr( - feature = "serde", - serde(skip_serializing_if = "crate::if_false", default) - )] - nsfw: bool, + voice: Option, }, } + /// Voice information for a channel + #[derive(Default)] + #[cfg_attr(feature = "validator", derive(validator::Validate))] + pub struct VoiceInformation { + /// Maximium amount of users allowed in the voice channel at once + #[cfg_attr(feature = "validator", validate(range(min = 1)))] + #[cfg_attr(feature = "serde", serde(skip_serializing_if = "Option::is_none"))] + pub max_users: Option, + } + /// Partial representation of a channel #[derive(Default)] pub struct PartialChannel { @@ -170,6 +148,8 @@ auto_derived!( pub default_permissions: Option, #[cfg_attr(feature = "serde", serde(skip_serializing_if = "Option::is_none"))] pub last_message_id: Option, + #[cfg_attr(feature = "serde", serde(skip_serializing_if = "Option::is_none"))] + pub voice: Option, } /// Optional fields on channel object @@ -177,6 +157,7 @@ auto_derived!( Description, Icon, DefaultPermissions, + Voice, } /// New webhook information @@ -205,6 +186,9 @@ auto_derived!( /// Whether this channel is archived pub archived: Option, + /// Voice Information for voice channels + pub voice: Option, + /// Fields to remove from channel #[cfg_attr(feature = "serde", serde(default))] pub remove: Vec, @@ -260,6 +244,10 @@ auto_derived!( /// Whether this channel is age restricted #[serde(skip_serializing_if = "Option::is_none")] pub nsfw: Option, + + /// Voice Information for when this channel is also a voice channel + #[serde(skip_serializing_if = "Option::is_none")] + pub voice: Option, } /// New default permissions @@ -270,7 +258,7 @@ auto_derived!( permissions: u64, }, Field { - /// Allow / deny values to set for members in this `TextChannel` or `VoiceChannel` + /// Allow / deny values to set for members in this server channel permissions: Override, }, } @@ -289,9 +277,32 @@ auto_derived!( } /// Voice server token response - pub struct LegacyCreateVoiceUserResponse { + pub struct CreateVoiceUserResponse { /// Token for authenticating with the voice server - token: String, + pub token: String, + /// Url of the livekit server to connect to + pub url: String, + } + + /// Voice state for a channel + pub struct ChannelVoiceState { + pub id: String, + /// The states of the users who are connected to the channel + pub participants: Vec, + } + + /// Join a voice channel + pub struct DataJoinCall { + /// Name of the node to join + pub node: Option, + /// Whether to force disconnect any other existing voice connections + /// + /// Useful for disconnecting on another device and joining on a new. + pub force_disconnect: Option, + /// Users which should be notified of the call starting + /// + /// Only used when the user is the first one connected. + pub recipients: Option>, } ); @@ -302,8 +313,7 @@ impl Channel { Channel::DirectMessage { id, .. } | Channel::Group { id, .. } | Channel::SavedMessages { id, .. } - | Channel::TextChannel { id, .. } - | Channel::VoiceChannel { id, .. } => id, + | Channel::TextChannel { id, .. } => id, } } @@ -315,9 +325,7 @@ impl Channel { match self { Channel::DirectMessage { .. } => None, Channel::SavedMessages { .. } => Some("Saved Messages"), - Channel::TextChannel { name, .. } - | Channel::Group { name, .. } - | Channel::VoiceChannel { name, .. } => Some(name), + Channel::TextChannel { name, .. } | Channel::Group { name, .. } => Some(name), } } } diff --git a/crates/core/models/src/v0/messages.rs b/crates/core/models/src/v0/messages.rs index 2116d98ba..67af69f85 100644 --- a/crates/core/models/src/v0/messages.rs +++ b/crates/core/models/src/v0/messages.rs @@ -132,6 +132,8 @@ auto_derived!( MessagePinned { id: String, by: String }, #[serde(rename = "message_unpinned")] MessageUnpinned { id: String, by: String }, + #[serde(rename = "call_started")] + CallStarted { by: String, finished_at: Option }, } /// Name and / or avatar override information @@ -445,6 +447,7 @@ impl From for String { } SystemMessage::MessagePinned { .. } => "Message pinned.".to_string(), SystemMessage::MessageUnpinned { .. } => "Message unpinned.".to_string(), + SystemMessage::CallStarted { .. } => "Call started.".to_string(), } } } diff --git a/crates/core/models/src/v0/server_members.rs b/crates/core/models/src/v0/server_members.rs index 048f696bc..d1ccb0589 100644 --- a/crates/core/models/src/v0/server_members.rs +++ b/crates/core/models/src/v0/server_members.rs @@ -31,6 +31,14 @@ pub static RE_COLOUR: Lazy = Lazy::new(|| { Regex::new(r"(?i)^(?:[a-z ]+|var\(--[a-z\d-]+\)|rgba?\([\d, ]+\)|#[a-f0-9]+|(repeating-)?(linear|conic|radial)-gradient\(([a-z ]+|var\(--[a-z\d-]+\)|rgba?\([\d, ]+\)|#[a-f0-9]+|\d+deg)([ ]+(\d{1,3}%|0))?(,[ ]*([a-z ]+|var\(--[a-z\d-]+\)|rgba?\([\d, ]+\)|#[a-f0-9]+)([ ]+(\d{1,3}%|0))?)+\))$").unwrap() }); +fn default_true() -> bool { + true +} + +fn is_true(x: &bool) -> bool { + *x +} + auto_derived_partial!( /// Server Member pub struct Member { @@ -57,6 +65,13 @@ auto_derived_partial!( /// Timestamp this member is timed out until #[cfg_attr(feature = "serde", serde(skip_serializing_if = "Option::is_none"))] pub timeout: Option, + + /// Whether the member is server-wide voice muted + #[serde(skip_serializing_if = "is_true", default = "default_true")] + pub can_publish: bool, + /// Whether the member is server-wide voice deafened + #[serde(skip_serializing_if = "is_true", default = "default_true")] + pub can_receive: bool, }, "PartialMember" ); @@ -77,6 +92,8 @@ auto_derived!( Avatar, Roles, Timeout, + CanReceive, + CanPublish, JoinedAt, } @@ -124,6 +141,12 @@ auto_derived!( pub roles: Option>, /// Timestamp this member is timed out until pub timeout: Option, + /// server-wide voice muted + pub can_publish: Option, + /// server-wide voice deafened + pub can_receive: Option, + /// voice channel to move to if already in a voice channel + pub voice_channel: Option, /// Fields to remove from channel object #[cfg_attr(feature = "serde", serde(default))] pub remove: Vec, diff --git a/crates/core/models/src/v0/users.rs b/crates/core/models/src/v0/users.rs index fc681e003..73957768b 100644 --- a/crates/core/models/src/v0/users.rs +++ b/crates/core/models/src/v0/users.rs @@ -1,3 +1,4 @@ +use iso8601_timestamp::Timestamp; use once_cell::sync::Lazy; use regex::Regex; @@ -279,6 +280,19 @@ auto_derived!( } ); +auto_derived_partial!( + /// Voice State information for a user + pub struct UserVoiceState { + pub id: String, + pub joined_at: Timestamp, + pub is_receiving: bool, + pub is_publishing: bool, + pub screensharing: bool, + pub camera: bool, + }, + "PartialUserVoiceState" +); + pub trait CheckRelationship { fn with(&self, user: &str) -> RelationshipStatus; } diff --git a/crates/core/permissions/src/impl.rs b/crates/core/permissions/src/impl.rs index 9975eeda5..064fee3e6 100644 --- a/crates/core/permissions/src/impl.rs +++ b/crates/core/permissions/src/impl.rs @@ -61,6 +61,15 @@ pub async fn calculate_server_permissions(query: &mut P) -> permissions.apply(role_override); } + if !query.do_we_have_publish_overwrites().await { + permissions.revoke(ChannelPermission::Speak as u64); + permissions.revoke(ChannelPermission::Video as u64); + } + + if !query.do_we_have_receive_overwrites().await { + permissions.revoke(ChannelPermission::Listen as u64); + } + if query.are_we_timed_out().await { permissions.restrict(*ALLOW_IN_TIMEOUT); } diff --git a/crates/core/permissions/src/models/channel.rs b/crates/core/permissions/src/models/channel.rs index aca22b5c2..9e9160263 100644 --- a/crates/core/permissions/src/models/channel.rs +++ b/crates/core/permissions/src/models/channel.rs @@ -89,6 +89,8 @@ pub enum ChannelPermission { DeafenMembers = 1 << 34, /// Move members between voice channels MoveMembers = 1 << 35, + /// Listen to other users + Listen = 1 << 36, // * Channel permissions two electric boogaloo /// Mention everyone and online members @@ -130,7 +132,9 @@ pub static DEFAULT_PERMISSION: Lazy = Lazy::new(|| { + ChannelPermission::SendEmbeds + ChannelPermission::UploadFiles + ChannelPermission::Connect - + ChannelPermission::Speak, + + ChannelPermission::Speak + + ChannelPermission::Listen + + ChannelPermission::Video ) }); diff --git a/crates/core/permissions/src/models/server.rs b/crates/core/permissions/src/models/server.rs index e6355eedc..8391029a0 100644 --- a/crates/core/permissions/src/models/server.rs +++ b/crates/core/permissions/src/models/server.rs @@ -39,7 +39,7 @@ pub enum DataPermissionPoly { permissions: u64, }, Field { - /// Allow / deny values to set for members in this `TextChannel` or `VoiceChannel` + /// Allow / deny values to set for members in this server channel permissions: Override, }, } diff --git a/crates/core/permissions/src/test.rs b/crates/core/permissions/src/test.rs index e4dadd485..93e1dea2c 100644 --- a/crates/core/permissions/src/test.rs +++ b/crates/core/permissions/src/test.rs @@ -64,6 +64,14 @@ async fn validate_user_permissions() { unreachable!() } + async fn do_we_have_publish_overwrites(&mut self) -> bool { + true + } + + async fn do_we_have_receive_overwrites(&mut self) -> bool { + true + } + async fn get_channel_type(&mut self) -> ChannelType { ChannelType::DirectMessage } @@ -153,6 +161,14 @@ async fn validate_group_permissions() { unreachable!() } + async fn do_we_have_publish_overwrites(&mut self) -> bool { + true + } + + async fn do_we_have_receive_overwrites(&mut self) -> bool { + true + } + async fn get_channel_type(&mut self) -> ChannelType { ChannelType::Group } @@ -254,6 +270,14 @@ async fn validate_server_permissions() { false } + async fn do_we_have_publish_overwrites(&mut self) -> bool { + true + } + + async fn do_we_have_receive_overwrites(&mut self) -> bool { + true + } + async fn get_channel_type(&mut self) -> ChannelType { ChannelType::ServerChannel } @@ -346,6 +370,14 @@ async fn validate_timed_out_member() { true } + async fn do_we_have_publish_overwrites(&mut self) -> bool { + true + } + + async fn do_we_have_receive_overwrites(&mut self) -> bool { + true + } + async fn get_channel_type(&mut self) -> ChannelType { ChannelType::ServerChannel } diff --git a/crates/core/permissions/src/trait.rs b/crates/core/permissions/src/trait.rs index d4a7c3d36..96b42caf8 100644 --- a/crates/core/permissions/src/trait.rs +++ b/crates/core/permissions/src/trait.rs @@ -39,6 +39,12 @@ pub trait PermissionQuery { /// Is our perspective user timed out on this server? async fn are_we_timed_out(&mut self) -> bool; + /// Is the member muted? + async fn do_we_have_publish_overwrites(&mut self) -> bool; + + /// Is the member deafend? + async fn do_we_have_receive_overwrites(&mut self) -> bool; + // * For calculating channel permission /// Get the type of the channel diff --git a/crates/core/result/Cargo.toml b/crates/core/result/Cargo.toml index 3a25d206f..90ffd639d 100644 --- a/crates/core/result/Cargo.toml +++ b/crates/core/result/Cargo.toml @@ -32,5 +32,7 @@ rocket = { optional = true, version = "0.5.0-rc.2", default-features = false } revolt_rocket_okapi = { version = "0.10.0", optional = true } revolt_okapi = { version = "0.9.1", optional = true } +# utilities +log = "0.4" # Axum axum = { version = "0.7.5", optional = true } diff --git a/crates/core/result/src/axum.rs b/crates/core/result/src/axum.rs index e4caf8183..8750ef5f1 100644 --- a/crates/core/result/src/axum.rs +++ b/crates/core/result/src/axum.rs @@ -75,6 +75,11 @@ impl IntoResponse for Error { ErrorType::NotFound => StatusCode::NOT_FOUND, ErrorType::NoEffect => StatusCode::OK, ErrorType::FailedValidation { .. } => StatusCode::BAD_REQUEST, + ErrorType::LiveKitUnavailable => StatusCode::BAD_REQUEST, + ErrorType::NotConnected => StatusCode::BAD_REQUEST, + ErrorType::NotAVoiceChannel => StatusCode::BAD_REQUEST, + ErrorType::AlreadyConnected => StatusCode::BAD_REQUEST, + ErrorType::UnknownNode => StatusCode::BAD_REQUEST, ErrorType::InvalidFlagValue => StatusCode::BAD_REQUEST, ErrorType::FeatureDisabled { .. } => StatusCode::BAD_REQUEST, diff --git a/crates/core/result/src/lib.rs b/crates/core/result/src/lib.rs index 10b56d5d4..9fcfcd284 100644 --- a/crates/core/result/src/lib.rs +++ b/crates/core/result/src/lib.rs @@ -1,3 +1,4 @@ +use std::panic::Location; use std::fmt::Display; #[cfg(feature = "serde")] @@ -158,6 +159,12 @@ pub enum ErrorType { error: String, }, + // ? Voice errors + LiveKitUnavailable, + NotAVoiceChannel, + AlreadyConnected, + NotConnected, + UnknownNode, // ? Micro-service errors ProxyError, FileTooSmall, @@ -197,6 +204,57 @@ macro_rules! create_database_error { }; } +#[macro_export] +#[cfg(debug_assertions)] +macro_rules! query { + ( $self: ident, $type: ident, $collection: expr, $($rest:expr),+ ) => { + Ok($self.$type($collection, $($rest),+).await.unwrap()) + }; +} + +#[macro_export] +#[cfg(not(debug_assertions))] +macro_rules! query { + ( $self: ident, $type: ident, $collection: expr, $($rest:expr),+ ) => { + $self.$type($collection, $($rest),+).await + .map_err(|_| create_database_error!(stringify!($type), $collection)) + }; +} + +pub trait ToRevoltError { + #[track_caller] + fn to_internal_error(self) -> Result; +} + +impl ToRevoltError for Result { + #[track_caller] + fn to_internal_error(self) -> Result { + let loc = Location::caller(); + + self + .map_err(|_| { + Error { + error_type: ErrorType::InternalError, + location: format!("{}:{}:{}", loc.file(), loc.line(), loc.column()) + } + }) + } +} + +impl ToRevoltError for Option { + #[track_caller] + fn to_internal_error(self) -> Result { + let loc = Location::caller(); + + self.ok_or_else(|| { + Error { + error_type: ErrorType::InternalError, + location: format!("{}:{}:{}", loc.file(), loc.line(), loc.column()) + } + }) + } +} + #[cfg(test)] mod tests { use crate::ErrorType; diff --git a/crates/core/result/src/rocket.rs b/crates/core/result/src/rocket.rs index 1d996a173..6e95ec241 100644 --- a/crates/core/result/src/rocket.rs +++ b/crates/core/result/src/rocket.rs @@ -78,10 +78,14 @@ impl<'r> Responder<'r, 'static> for Error { ErrorType::InvalidSession => Status::Unauthorized, ErrorType::NotAuthenticated => Status::Unauthorized, ErrorType::DuplicateNonce => Status::Conflict, - ErrorType::VosoUnavailable => Status::BadRequest, ErrorType::NotFound => Status::NotFound, ErrorType::NoEffect => Status::Ok, ErrorType::FailedValidation { .. } => Status::BadRequest, + ErrorType::LiveKitUnavailable => Status::BadRequest, + ErrorType::NotAVoiceChannel => Status::BadRequest, + ErrorType::AlreadyConnected => Status::BadRequest, + ErrorType::NotConnected => Status::BadRequest, + ErrorType::UnknownNode => Status::BadRequest, ErrorType::FeatureDisabled { .. } => Status::BadRequest, ErrorType::ProxyError => Status::BadRequest, @@ -90,6 +94,7 @@ impl<'r> Responder<'r, 'static> for Error { ErrorType::FileTypeNotAllowed => Status::BadRequest, ErrorType::ImageProcessingFailed => Status::InternalServerError, ErrorType::NoEmbedData => Status::BadRequest, + ErrorType::VosoUnavailable => Status::BadRequest, }; // Serialize the error data structure into JSON. diff --git a/crates/daemons/pushd/src/consumers/inbound/dm_call.rs b/crates/daemons/pushd/src/consumers/inbound/dm_call.rs new file mode 100644 index 000000000..b5b89dc13 --- /dev/null +++ b/crates/daemons/pushd/src/consumers/inbound/dm_call.rs @@ -0,0 +1,168 @@ +use std::collections::HashMap; + +use crate::consumers::inbound::internal::*; +use amqprs::{ + channel::{BasicPublishArguments, Channel}, + connection::Connection, + consumer::AsyncConsumer, + BasicProperties, Deliver, +}; +use anyhow::Result; +use async_trait::async_trait; +use log::debug; +use revolt_database::{events::rabbit::*, Database}; + +pub struct DmCallConsumer { + #[allow(dead_code)] + db: Database, + authifier_db: authifier::Database, + conn: Option, + channel: Option, +} + +impl Channeled for DmCallConsumer { + fn get_connection(&self) -> Option<&Connection> { + if self.conn.is_none() { + None + } else { + Some(self.conn.as_ref().unwrap()) + } + } + + fn get_channel(&self) -> Option<&Channel> { + if self.channel.is_none() { + None + } else { + Some(self.channel.as_ref().unwrap()) + } + } + + fn set_connection(&mut self, conn: Connection) { + self.conn = Some(conn); + } + + fn set_channel(&mut self, channel: Channel) { + self.channel = Some(channel) + } +} + +impl DmCallConsumer { + pub fn new(db: Database, authifier_db: authifier::Database) -> DmCallConsumer { + DmCallConsumer { + db, + authifier_db, + conn: None, + channel: None, + } + } + + async fn consume_event( + &mut self, + _channel: &Channel, + _deliver: Deliver, + _basic_properties: BasicProperties, + content: Vec, + ) -> Result<()> { + let content = String::from_utf8(content)?; + let _p: InternalDmCallPayload = serde_json::from_str(content.as_str())?; + let payload = _p.payload; + + debug!("Received dm call start/stop event"); + + let (revolt_database::Channel::DirectMessage { recipients, .. } + | revolt_database::Channel::Group { recipients, .. }) = + self.db.fetch_channel(&payload.channel_id).await? + else { + warn!( + "Discarding dm call start/stop event for non-dm/group channel {}", + payload.channel_id + ); + + return Ok(()); + }; + + let call_recipients = if let Some(user_recipients) = _p.recipients { + user_recipients + .into_iter() + .filter(|user_id| recipients.contains(user_id) && user_id != &payload.initiator_id) + .collect() + } else { + recipients + .into_iter() + .filter(|user_id| user_id != &payload.initiator_id) + .collect::>() + }; + + let config = revolt_config::config().await; + + for user_id in call_recipients { + if let Ok(sessions) = self.authifier_db.find_sessions(&user_id).await { + for session in sessions { + if let Some(sub) = session.subscription { + let mut sendable = PayloadToService { + notification: PayloadKind::DmCallStartEnd(payload.clone()), + token: sub.auth, + user_id: session.user_id, + session_id: session.id, + extras: HashMap::new(), + }; + + let args: BasicPublishArguments; + + if sub.endpoint == "apn" { + args = BasicPublishArguments::new( + config.pushd.exchange.as_str(), + config.pushd.apn.queue.as_str(), + ) + .finish(); + } else if sub.endpoint == "fcm" { + args = BasicPublishArguments::new( + config.pushd.exchange.as_str(), + config.pushd.fcm.queue.as_str(), + ) + .finish(); + } else { + // web push (vapid) + args = BasicPublishArguments::new( + config.pushd.exchange.as_str(), + config.pushd.vapid.queue.as_str(), + ) + .finish(); + sendable.extras.insert("p265dh".to_string(), sub.p256dh); + sendable + .extras + .insert("endpoint".to_string(), sub.endpoint.clone()); + } + + let payload = serde_json::to_string(&sendable)?; + + publish_message(self, payload.into(), args).await; + } + } + } + } + + Ok(()) + } +} + +#[allow(unused_variables)] +#[async_trait] +impl AsyncConsumer for DmCallConsumer { + /// This consumer handles delegating messages into their respective platform queues. + async fn consume( + &mut self, + channel: &Channel, + deliver: Deliver, + basic_properties: BasicProperties, + content: Vec, + ) { + if let Err(err) = self + .consume_event(channel, deliver, basic_properties, content) + .await + { + revolt_config::capture_anyhow(&err); + warn!("Failed to process dm call start/stop event: {err:?}"); + } + } +} diff --git a/crates/daemons/pushd/src/consumers/inbound/mod.rs b/crates/daemons/pushd/src/consumers/inbound/mod.rs index 9c3593367..4d6d36b23 100644 --- a/crates/daemons/pushd/src/consumers/inbound/mod.rs +++ b/crates/daemons/pushd/src/consumers/inbound/mod.rs @@ -1,4 +1,5 @@ pub mod ack; +pub mod dm_call; pub mod fr_accepted; pub mod fr_received; pub mod generic; diff --git a/crates/daemons/pushd/src/consumers/outbound/apn.rs b/crates/daemons/pushd/src/consumers/outbound/apn.rs index 7cc43c76a..ec478c1f0 100644 --- a/crates/daemons/pushd/src/consumers/outbound/apn.rs +++ b/crates/daemons/pushd/src/consumers/outbound/apn.rs @@ -47,6 +47,32 @@ impl<'a> PayloadLike for MessagePayload<'a> { } } +#[derive(Serialize, Debug)] +struct CallStartStopPayload<'a> { + aps: APS<'a>, + #[serde(skip_serializing)] + options: NotificationOptions<'a>, + #[serde(skip_serializing)] + device_token: &'a str, + + initiator_id: &'a str, + #[serde(rename = "camelCase")] + channel_id: &'a str, + #[serde(rename = "camelCase")] + started_at: &'a str, + #[serde(rename = "camelCase")] + ended: bool, +} + +impl<'a> PayloadLike for CallStartStopPayload<'a> { + fn get_device_token(&self) -> &'a str { + self.device_token + } + fn get_options(&self) -> &NotificationOptions { + &self.options + } +} + // region: consumer pub struct ApnsOutboundConsumer { @@ -63,10 +89,11 @@ impl ApnsOutboundConsumer { // in a dm it should just be "Sendername". // not sure how feasible all those are given the PushNotification object as it currently stands. + #[allow(deprecated)] match ¬ification.channel { Channel::DirectMessage { .. } => notification.author.clone(), Channel::Group { name, .. } => format!("{}, #{}", notification.author, name), - Channel::TextChannel { name, .. } | Channel::VoiceChannel { name, .. } => { + Channel::TextChannel { name, .. } => { format!("{} in #{}", notification.author, name) } _ => "Unknown".to_string(), @@ -309,6 +336,7 @@ impl ApnsOutboundConsumer { ); resp = self.client.send(apn_payload).await; } + PayloadKind::BadgeUpdate(badge) => { let apn_payload = Payload { aps: APS { @@ -323,6 +351,35 @@ impl ApnsOutboundConsumer { debug!("Sending badge update for user: {:}", &payload.user_id); resp = self.client.send(apn_payload).await; } + + PayloadKind::DmCallStartEnd(alert) => { + let started_at = alert.started_at.map_or(String::new(), |f| f.clone()); + + let apn_payload = CallStartStopPayload { + aps: APS { + alert: None, + badge: self.get_badge_count(&payload.user_id).await, + sound: None, + thread_id: None, + content_available: None, + category: None, + mutable_content: Some(1), + url_args: None, + }, + device_token: &payload.token, + options: payload_options.clone(), + initiator_id: &alert.initiator_id, + channel_id: &alert.channel_id, + started_at: &started_at, + ended: alert.ended, + }; + + debug!( + "Sending call start/stop notification for user: {:}", + &payload.user_id + ); + resp = self.client.send(apn_payload).await; + } } if let Err(err) = resp { diff --git a/crates/daemons/pushd/src/consumers/outbound/fcm.rs b/crates/daemons/pushd/src/consumers/outbound/fcm.rs index 1e3f1d2ad..196a9eeac 100644 --- a/crates/daemons/pushd/src/consumers/outbound/fcm.rs +++ b/crates/daemons/pushd/src/consumers/outbound/fcm.rs @@ -5,11 +5,12 @@ use amqprs::{channel::Channel as AmqpChannel, consumer::AsyncConsumer, BasicProp use anyhow::{anyhow, bail, Result}; use async_trait::async_trait; use fcm_v1::{ - android::AndroidConfig, + android::{AndroidConfig, AndroidMessagePriority}, auth::{Authenticator, ServiceAccountKey}, message::{Message, Notification}, Client, Error as FcmError, }; +use revolt_config::config; use revolt_database::{events::rabbit::*, Database}; use revolt_models::v0::{Channel, PushNotification}; use serde_json::Value; @@ -27,10 +28,11 @@ impl FcmOutboundConsumer { // in a dm it should just be "Sendername". // not sure how feasible all those are given the PushNotification object as it currently stands. + #[allow(deprecated)] match ¬ification.channel { Channel::DirectMessage { .. } => notification.author.clone(), Channel::Group { name, .. } => format!("{}, #{}", notification.author, name), - Channel::TextChannel { name, .. } | Channel::VoiceChannel { name, .. } => { + Channel::TextChannel { name, .. } => { format!("{} in #{}", notification.author, name) } _ => "Unknown".to_string(), @@ -169,6 +171,37 @@ impl FcmOutboundConsumer { resp = self.client.send(&msg).await; } + PayloadKind::DmCallStartEnd(alert) => { + let mut data: HashMap = HashMap::new(); + data.insert( + "initiator_id".to_string(), + Value::String(alert.initiator_id), + ); + data.insert("channel_id".to_string(), Value::String(alert.channel_id)); + data.insert( + "started_at".to_string(), + Value::String(alert.started_at.unwrap_or_else(|| "".to_string())), + ); + data.insert("ended".to_string(), Value::Bool(alert.ended)); + + let msg = Message { + token: Some(payload.token), + notification: None, + data: Some(data), + android: Some(AndroidConfig { + priority: Some(AndroidMessagePriority::High), + ttl: Some(format!( + "{}s", + config().await.api.livekit.call_ring_duration + )), + ..Default::default() + }), + ..Default::default() + }; + + resp = self.client.send(&msg).await; + } + PayloadKind::BadgeUpdate(_) => { bail!("FCM cannot handle badge updates and they should not be sent here."); } diff --git a/crates/daemons/pushd/src/consumers/outbound/vapid.rs b/crates/daemons/pushd/src/consumers/outbound/vapid.rs index 92ce6e569..67ec31fb8 100644 --- a/crates/daemons/pushd/src/consumers/outbound/vapid.rs +++ b/crates/daemons/pushd/src/consumers/outbound/vapid.rs @@ -8,7 +8,7 @@ use base64::{ engine::{self}, Engine as _, }; -use revolt_database::{events::rabbit::*, Database}; +use revolt_database::{events::rabbit::*, util::format_display_name, Database}; use web_push::{ ContentEncoding, IsahcWebPushClient, SubscriptionInfo, SubscriptionKeys, VapidSignatureBuilder, WebPushClient, WebPushError, WebPushMessageBuilder, @@ -107,6 +107,33 @@ impl VapidOutboundConsumer { PayloadKind::MessageNotification(alert) => { payload_body = serde_json::to_string(&alert)?; } + PayloadKind::DmCallStartEnd(alert) => { + let initiator_name = if let Some(server_id) = + self.db.fetch_channel(&alert.channel_id).await?.server() + { + format_display_name(&self.db, &alert.initiator_id, Some(server_id)).await + } else { + format_display_name(&self.db, &alert.initiator_id, None).await + }?; + + let channel = self.db.fetch_channel(&alert.channel_id).await?; + let mut body = HashMap::new(); + + match channel { + revolt_database::Channel::DirectMessage { .. } => { + body.insert("body", format!("{} is calling you", initiator_name)); + } + revolt_database::Channel::Group { name, .. } => { + body.insert( + "body", + format!("{} is calling your group, {}", initiator_name, name), + ); + } + _ => bail!("Invalid DmCallStart/End channel type"), + } + + payload_body = serde_json::to_string(&body)?; + } PayloadKind::BadgeUpdate(_) => { bail!("Vapid cannot handle badge updates and they should not be sent here."); } diff --git a/crates/daemons/pushd/src/main.rs b/crates/daemons/pushd/src/main.rs index 78845a634..472caafe4 100644 --- a/crates/daemons/pushd/src/main.rs +++ b/crates/daemons/pushd/src/main.rs @@ -16,8 +16,9 @@ use tokio::sync::Notify; mod consumers; use consumers::{ inbound::{ - ack::AckConsumer, fr_accepted::FRAcceptedConsumer, fr_received::FRReceivedConsumer, - generic::GenericConsumer, mass_mention::MassMessageConsumer, message::MessageConsumer, + ack::AckConsumer, dm_call::DmCallConsumer, fr_accepted::FRAcceptedConsumer, + fr_received::FRReceivedConsumer, generic::GenericConsumer, + mass_mention::MassMessageConsumer, message::MessageConsumer, }, outbound::{apn::ApnsOutboundConsumer, fcm::FcmOutboundConsumer, vapid::VapidOutboundConsumer}, }; @@ -102,6 +103,7 @@ async fn main() { .await, ); + // inbound: Mass Mentions connections.push( make_queue_and_consume( &config, @@ -113,6 +115,18 @@ async fn main() { .await, ); + // inbound: Dm Calls + connections.push( + make_queue_and_consume( + &config, + &config.pushd.dm_call_queue, + config.pushd.get_dm_call_routing_key().as_str(), + None, + DmCallConsumer::new(db.clone(), authifier.clone()), + ) + .await, + ); + if !config.pushd.apn.pkcs8.is_empty() { connections.push( make_queue_and_consume( diff --git a/crates/daemons/voice-ingress/.gitignore b/crates/daemons/voice-ingress/.gitignore new file mode 100644 index 000000000..c41cc9e35 --- /dev/null +++ b/crates/daemons/voice-ingress/.gitignore @@ -0,0 +1 @@ +/target \ No newline at end of file diff --git a/crates/daemons/voice-ingress/Cargo.toml b/crates/daemons/voice-ingress/Cargo.toml new file mode 100644 index 000000000..34a8ad1a4 --- /dev/null +++ b/crates/daemons/voice-ingress/Cargo.toml @@ -0,0 +1,48 @@ +[package] +name = "revolt-voice-ingress" +version = "0.7.1" +license = "AGPL-3.0-or-later" +edition = "2021" + +# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html + +[dependencies] +# util +log = "*" +sentry = "0.31.5" +lru = "0.7.6" +ulid = "0.5.0" +redis-kiss = "0.1.4" +chrono = "0.4.15" + +# Serde +serde_json = "1.0.79" +rmp-serde = "1.0.0" +serde = "1.0.136" + +# Http +rocket = { version = "0.5.0-rc.2", features = ["json"] } +rocket_empty = "0.1.1" + +# Async +futures = "0.3.21" +async-std = { version = "1.8.0", features = [ + "tokio1", + "tokio02", + "attributes", +] } + +# Core +revolt-result = { path = "../../core/result" } +revolt-models = { path = "../../core/models" } +revolt-config = { path = "../../core/config" } +revolt-database = { path = "../../core/database" } +revolt-permissions = { path = "../../core/permissions" } + +# Voice +livekit-api = "0.4.4" +livekit-protocol = "0.4.0" +livekit-runtime = { version = "0.3.1", features = ["tokio"] } + +# RabbitMQ +amqprs = { version = "1.7.0" } diff --git a/crates/daemons/voice-ingress/Dockerfile b/crates/daemons/voice-ingress/Dockerfile new file mode 100644 index 000000000..4749d5cc9 --- /dev/null +++ b/crates/daemons/voice-ingress/Dockerfile @@ -0,0 +1,11 @@ +# Build Stage +FROM ghcr.io/revoltchat/base:latest AS builder +FROM debian:12 AS debian + +# Bundle Stage +FROM gcr.io/distroless/cc-debian12:nonroot +COPY --from=builder /home/rust/src/target/release/revolt-voice-ingress ./ +COPY --from=debian /usr/bin/uname /usr/bin/uname + +USER nonroot +CMD ["./revolt-voice-ingress"] \ No newline at end of file diff --git a/crates/daemons/voice-ingress/src/api.rs b/crates/daemons/voice-ingress/src/api.rs new file mode 100644 index 000000000..9905f5484 --- /dev/null +++ b/crates/daemons/voice-ingress/src/api.rs @@ -0,0 +1,256 @@ +use chrono::DateTime; +use livekit_api::{access_token::TokenVerifier, webhooks::WebhookReceiver}; +use livekit_protocol::TrackType; +use revolt_database::{ + events::client::EventV1, + iso8601_timestamp::{Duration, Timestamp}, + util::reference::Reference, + voice::{ + create_voice_state, delete_voice_state, get_call_notification_recipients, + get_user_moved_from_voice, get_user_moved_to_voice, get_voice_channel_members, + set_channel_call_started_system_message, take_channel_call_started_system_message, + update_voice_state_tracks, VoiceClient, + }, + Database, PartialMessage, SystemMessage, AMQP, +}; +use revolt_models::v0; +use revolt_result::{Result, ToRevoltError}; +use rocket::{post, State}; +use rocket_empty::EmptyResponse; +use ulid::Ulid; + +use crate::guard::AuthHeader; + +#[post("/", data = "")] +pub async fn ingress( + db: &State, + voice_client: &State, + amqp: &State, + node: &str, + auth_header: AuthHeader<'_>, + body: &str, +) -> Result { + log::debug!("received event: {body:?}"); + + let config = revolt_config::config().await; + + let node_info = config + .api + .livekit + .nodes + .get(node) + .to_internal_error() + .inspect_err(|_| { + log::error!("Unknown node {node}, make sure livekit has the correct node name set and matches `hosts.livekit` and `api.livekit.nodes` in the Revolt config.") + })?; + + let webhook_receiver = WebhookReceiver::new(TokenVerifier::with_api_key( + &node_info.key, + &node_info.secret, + )); + + let event = webhook_receiver + .receive(body, &auth_header) + .to_internal_error()?; + + let channel_id = event.room.as_ref().map(|r| &r.name); + let user_id = event.participant.as_ref().map(|r| &r.identity); + + match event.event.as_str() { + // User joined a channel + "participant_joined" => { + let channel_id = channel_id.to_internal_error()?; + let user_id = user_id.to_internal_error()?; + + let channel = Reference::from_unchecked(channel_id).as_channel(db).await?; + + let joined_at = Timestamp::UNIX_EPOCH + .checked_add(Duration::seconds(event.created_at)) + .unwrap(); + + let voice_state = + create_voice_state(channel_id, channel.server(), user_id, joined_at).await?; + + // Only publish one event when a user is moved from one channel to another. + if let Some(moved_from) = get_user_moved_to_voice(channel_id, user_id).await? { + EventV1::VoiceChannelMove { + user: user_id.to_string(), + from: moved_from, + to: channel_id.to_string(), + state: voice_state, + } + .p(channel_id.to_string()) + .await; + } else { + EventV1::VoiceChannelJoin { + id: channel_id.to_string(), + state: voice_state, + } + .p(channel_id.to_string()) + .await; + }; + + // First user who joined - send call started system message. + if event.room.as_ref().unwrap().num_participants == 1 { + let user = Reference::from_unchecked(user_id).as_user(db).await?; + + let message_id = + Ulid::from_datetime(DateTime::from_timestamp_secs(event.created_at).unwrap()) + .to_string(); + + let mut call_started_message = SystemMessage::CallStarted { + by: user_id.to_string(), + finished_at: None, + } + .into_message(channel.id().to_string()); + + call_started_message.id = message_id; + + set_channel_call_started_system_message(channel.id(), &call_started_message.id) + .await?; + + call_started_message + .send( + db, + Some(amqp), + v0::MessageAuthor::System { + username: &user.username, + avatar: user.avatar.as_ref().map(|file| file.id.as_ref()), + }, + None, + None, + &channel, + false, + ) + .await?; + + let recipients = get_call_notification_recipients(&channel_id, &user_id).await?; + let now = joined_at.format_short().to_string(); + + if let Err(e) = amqp + .dm_call_updated(&user.id, channel.id(), Some(&now), false, recipients) + .await + { + revolt_config::capture_error(&e); + } + } + } + // User left a channel + "participant_left" => { + let channel_id = channel_id.to_internal_error()?; + let user_id = user_id.to_internal_error()?; + + let channel = Reference::from_unchecked(channel_id).as_channel(db).await?; + + delete_voice_state(channel_id, channel.server(), user_id).await?; + + // Dont send leave event when a user is moved + if get_user_moved_from_voice(channel_id, user_id) + .await? + .is_none() + { + EventV1::VoiceChannelLeave { + id: channel_id.clone(), + user: user_id.clone(), + } + .p(channel_id.clone()) + .await; + }; + + // Update CallStarted system message if everyone has left with the end time + let members = get_voice_channel_members(channel_id).await?; + + if members.is_none_or(|m| m.is_empty()) { + // The channel is empty so send out an "end" message for ringing + if let Err(e) = amqp + .dm_call_updated(user_id, channel_id, None, true, None) + .await + { + revolt_config::capture_internal_error!(&e); + } + + if let Some(system_message_id) = + take_channel_call_started_system_message(channel_id).await? + { + // Could have been deleted + if let Ok(mut message) = Reference::from_unchecked(&system_message_id) + .as_message(db) + .await + { + if let Some(SystemMessage::CallStarted { finished_at, .. }) = + &mut message.system + { + *finished_at = Some(Timestamp::now_utc()); + + message + .update( + db, + PartialMessage { + system: message.system.clone(), + ..Default::default() + }, + Vec::new(), + ) + .await?; + } else { + log::error!("Broken State: Call started message ID ({}) does not contain a CallStarted system message.", &message.id) + } + }; + }; + } + } + // Audio/video track was started/stopped + "track_published" | "track_unpublished" => { + let channel_id = channel_id.to_internal_error()?; + let user_id = user_id.to_internal_error()?; + let track = event.track.as_ref().to_internal_error()?; + + let channel = Reference::from_unchecked(channel_id).as_channel(db).await?; + + let user = Reference::from_unchecked(user_id).as_user(db).await?; + + let user_limits = user.limits().await; + + // forbid any size which goes over the limit and also limit the aspect ratio to stop people from making too tall or too wide and bypassing the limit. + // TODO: figure out how to track audio stream quality + + if event.event == "track_published" + && (track.r#type == TrackType::Data as i32 + || (track.r#type == TrackType::Video as i32 + && (user_limits.video_resolution[0] != 0 + && user_limits.video_resolution[1] != 0 + && track.width * track.height + > user_limits.video_resolution[0] + * user_limits.video_resolution[1]) + || (user_limits.video_aspect_ratio[0] + != user_limits.video_aspect_ratio[1] + && !(user_limits.video_aspect_ratio[0] + ..=user_limits.video_aspect_ratio[1]) + .contains(&(track.width as f32 / track.height as f32))))) + { + voice_client.remove_user(node, user_id, channel_id).await?; + delete_voice_state(channel_id, channel.server(), user_id).await?; + } else { + let partial = update_voice_state_tracks( + channel_id, + channel.server(), + user_id, + event.event == "track_published", // to avoid duplicating this entire case twice + track.source, + ) + .await?; + + EventV1::UserVoiceStateUpdate { + id: user_id.clone(), + channel_id: channel_id.clone(), + data: partial, + } + .p(channel_id.clone()) + .await; + } + } + _ => {} + }; + + Ok(EmptyResponse) +} diff --git a/crates/daemons/voice-ingress/src/guard.rs b/crates/daemons/voice-ingress/src/guard.rs new file mode 100644 index 000000000..f6ce11ddb --- /dev/null +++ b/crates/daemons/voice-ingress/src/guard.rs @@ -0,0 +1,28 @@ +use revolt_result::{create_error, Error}; +use rocket::{ + http::Status, + request::{FromRequest, Outcome}, + Request, +}; + +pub struct AuthHeader<'a>(&'a str); + +#[rocket::async_trait] +impl<'r> FromRequest<'r> for AuthHeader<'r> { + type Error = Error; + + async fn from_request(request: &'r Request<'_>) -> Outcome { + match request.headers().get("Authorization").next() { + Some(token) => Outcome::Success(Self(token)), + None => Outcome::Error((Status::Unauthorized, create_error!(NotAuthenticated))), + } + } +} + +impl std::ops::Deref for AuthHeader<'_> { + type Target = str; + + fn deref(&self) -> &Self::Target { + self.0 + } +} diff --git a/crates/daemons/voice-ingress/src/main.rs b/crates/daemons/voice-ingress/src/main.rs new file mode 100644 index 000000000..45191c2c5 --- /dev/null +++ b/crates/daemons/voice-ingress/src/main.rs @@ -0,0 +1,37 @@ +use std::env; + +use revolt_database::DatabaseInfo; +use revolt_database::{voice::VoiceClient, AMQP}; +use revolt_result::Result; +use rocket::{build, routes, Config}; +use std::net::Ipv4Addr; + +mod api; +mod guard; + +#[rocket::main] +async fn main() -> Result<(), rocket::Error> { + revolt_config::configure!(voice_ingress); + + let amqp = AMQP::new_auto().await; + + let database = DatabaseInfo::Auto.connect().await.unwrap(); + let voice_client = VoiceClient::from_revolt_config().await; + + let _rocket = build() + .manage(database) + .manage(voice_client) + .manage(amqp) + .mount("/", routes![api::ingress]) + .configure(Config { + port: 8500, + address: Ipv4Addr::new(0, 0, 0, 0).into(), + ..Default::default() + }) + .ignite() + .await? + .launch() + .await?; + + Ok(()) +} diff --git a/crates/delta/Cargo.toml b/crates/delta/Cargo.toml index 889b11672..9b43fef58 100644 --- a/crates/delta/Cargo.toml +++ b/crates/delta/Cargo.toml @@ -83,5 +83,9 @@ revolt-result = { path = "../core/result", features = ["rocket", "okapi"] } revolt-permissions = { path = "../core/permissions", features = ["schemas"] } revolt-ratelimits = { path = "../core/ratelimits", features = ["rocket"] } +# voice +livekit-api = "0.4.4" +livekit-protocol = "0.4.0" + [build-dependencies] vergen = "7.5.0" diff --git a/crates/delta/src/main.rs b/crates/delta/src/main.rs index 6d0eaa605..e7c060be7 100644 --- a/crates/delta/src/main.rs +++ b/crates/delta/src/main.rs @@ -25,6 +25,7 @@ use amqprs::{ use async_std::channel::unbounded; use authifier::AuthifierEvent; use rocket::data::ToByteUnit; +use revolt_database::voice::VoiceClient; pub async fn web() -> Rocket { // Get settings @@ -35,6 +36,7 @@ pub async fn web() -> Rocket { // Setup database let db = revolt_database::DatabaseInfo::Auto.connect().await.unwrap(); + log::info!("database_here {db:?}"); db.migrate_database().await.unwrap(); // Setup Authifier event channel @@ -90,6 +92,16 @@ pub async fn web() -> Rocket { ) .into(); + let swagger_0_8 = revolt_rocket_okapi::swagger_ui::make_swagger_ui( + &revolt_rocket_okapi::swagger_ui::SwaggerUIConfig { + url: "/0.8/openapi.json".to_owned(), + ..Default::default() + }, + ) + .into(); + + // Voice handler + let voice_client = VoiceClient::new(config.api.livekit.nodes.clone()); // Configure Rabbit let connection = Connection::open(&OpenConnectionArguments::new( &config.rabbit.host, @@ -137,6 +149,7 @@ pub async fn web() -> Rocket { .manage(db) .manage(amqp) .manage(cors.clone()) + .manage(voice_client) .manage(ratelimits) .attach(ratelimiter::RatelimitFairing) .attach(cors) diff --git a/crates/delta/src/routes/bots/delete.rs b/crates/delta/src/routes/bots/delete.rs index 4d4daefbe..c8bf5cb3a 100644 --- a/crates/delta/src/routes/bots/delete.rs +++ b/crates/delta/src/routes/bots/delete.rs @@ -1,4 +1,4 @@ -use revolt_database::{util::reference::Reference, Database, User}; +use revolt_database::{util::reference::Reference, voice::{delete_voice_state, get_channel_node, get_user_voice_channels, VoiceClient}, Database, User}; use revolt_result::{create_error, Result}; use rocket::State; use rocket_empty::EmptyResponse; @@ -10,6 +10,7 @@ use rocket_empty::EmptyResponse; #[delete("/")] pub async fn delete_bot( db: &State, + voice_client: &State, user: User, target: Reference<'_>, ) -> Result { @@ -18,7 +19,18 @@ pub async fn delete_bot( return Err(create_error!(NotFound)); } - bot.delete(db).await.map(|_| EmptyResponse) + bot.delete(db).await?; + + for channel_id in get_user_voice_channels(&bot.id).await? { + let node = get_channel_node(&channel_id).await?.unwrap(); + let channel = Reference::from_unchecked(&channel_id).as_channel(db).await?; + + voice_client.remove_user(&node, &bot.id, &channel_id).await?; + + delete_voice_state(&channel_id, channel.server(), &bot.id).await?; + } + + Ok(EmptyResponse) } #[cfg(test)] diff --git a/crates/delta/src/routes/channels/channel_delete.rs b/crates/delta/src/routes/channels/channel_delete.rs index 9d2b37b82..792162f76 100644 --- a/crates/delta/src/routes/channels/channel_delete.rs +++ b/crates/delta/src/routes/channels/channel_delete.rs @@ -1,5 +1,8 @@ use revolt_database::{ util::{permissions::DatabasePermissionQuery, reference::Reference}, + voice::{ + delete_channel_voice_state, delete_voice_state, get_channel_node, get_voice_channel_members, is_in_voice_channel, VoiceClient + }, Channel, Database, PartialChannel, User, AMQP, }; use revolt_models::v0; @@ -15,6 +18,7 @@ use rocket_empty::EmptyResponse; #[delete("/?")] pub async fn delete( db: &State, + voice_client: &State, amqp: &State, user: User, target: Reference<'_>, @@ -26,34 +30,57 @@ pub async fn delete( permissions.throw_if_lacking_channel_permission(ChannelPermission::ViewChannel)?; + #[allow(deprecated)] match &channel { - Channel::SavedMessages { .. } => Err(create_error!(NoEffect)), - Channel::DirectMessage { .. } => channel - .update( - db, - PartialChannel { - active: Some(false), - ..Default::default() - }, - vec![], - ) - .await - .map(|_| EmptyResponse), - Channel::Group { .. } => channel - .remove_user_from_group( - db, - amqp, - &user, - None, - options.leave_silently.unwrap_or_default(), - ) - .await - .map(|_| EmptyResponse), - Channel::TextChannel { .. } | Channel::VoiceChannel { .. } => { + Channel::SavedMessages { .. } => Err(create_error!(NoEffect))?, + Channel::DirectMessage { .. } => { + channel + .update( + db, + PartialChannel { + active: Some(false), + ..Default::default() + }, + vec![], + ) + .await? + } + Channel::Group { .. } => { + channel + .remove_user_from_group( + db, + amqp, + &user, + None, + options.leave_silently.unwrap_or_default(), + ) + .await?; + + if is_in_voice_channel(&user.id, channel.id()).await? { + let node = get_channel_node(channel.id()).await?.unwrap(); + + voice_client + .remove_user(&node, &user.id, channel.id()) + .await?; + + delete_voice_state(channel.id(), None, &user.id).await?; + }; + } + Channel::TextChannel { .. } => { permissions.throw_if_lacking_channel_permission(ChannelPermission::ManageChannel)?; - channel.delete(db).await.map(|_| EmptyResponse) + channel.delete(db).await?; + + if let Some(users) = get_voice_channel_members(channel.id()).await? { + let node = get_channel_node(channel.id()).await?.unwrap(); + + voice_client.delete_room(&node, channel.id()).await?; + + delete_channel_voice_state(channel.id(), channel.server(), &users).await?; + }; } - } + }; + + Ok(EmptyResponse) } #[cfg(test)] diff --git a/crates/delta/src/routes/channels/channel_edit.rs b/crates/delta/src/routes/channels/channel_edit.rs index 881656f60..7887679c4 100644 --- a/crates/delta/src/routes/channels/channel_edit.rs +++ b/crates/delta/src/routes/channels/channel_edit.rs @@ -1,6 +1,5 @@ use revolt_database::{ - util::{permissions::DatabasePermissionQuery, reference::Reference}, - Channel, Database, File, PartialChannel, SystemMessage, User, AMQP, + util::{permissions::DatabasePermissionQuery, reference::Reference}, voice::{delete_channel_voice_state, get_channel_node, get_voice_channel_members, VoiceClient}, Channel, Database, File, PartialChannel, SystemMessage, User, AMQP }; use revolt_models::v0; use revolt_permissions::{calculate_channel_permissions, ChannelPermission}; @@ -15,6 +14,7 @@ use validator::Validate; #[patch("/", data = "")] pub async fn edit( db: &State, + voice_client: &State, amqp: &State, user: User, target: Reference<'_>, @@ -38,6 +38,7 @@ pub async fn edit( && data.icon.is_none() && data.nsfw.is_none() && data.owner.is_none() + && data.voice.is_none() && data.remove.is_empty() { return Ok(Json(channel.into())); @@ -95,22 +96,6 @@ pub async fn edit( icon, nsfw, .. - } - | Channel::TextChannel { - id, - name, - description, - icon, - nsfw, - .. - } - | Channel::VoiceChannel { - id, - name, - description, - icon, - nsfw, - .. } => { if data.remove.contains(&v0::FieldsChannel::Icon) { if let Some(icon) = &icon { @@ -151,73 +136,139 @@ pub async fn edit( } // Send out mutation system messages. - if let Channel::Group { .. } = &channel { - if let Some(name) = &partial.name { - SystemMessage::ChannelRenamed { - name: name.to_string(), - by: user.id.clone(), - } - .into_message(channel.id().to_string()) - .send( - db, - Some(amqp), - user.as_author_for_system(), - None, - None, - &channel, - false, - ) - .await - .ok(); + if let Some(name) = &partial.name { + SystemMessage::ChannelRenamed { + name: name.to_string(), + by: user.id.clone(), } + .into_message(channel.id().to_string()) + .send( + db, + Some(amqp), + user.as_author_for_system(), + None, + None, + &channel, + false, + ) + .await + .ok(); + } - if partial.description.is_some() { - SystemMessage::ChannelDescriptionChanged { - by: user.id.clone(), - } - .into_message(channel.id().to_string()) - .send( - db, - Some(amqp), - user.as_author_for_system(), - None, - None, - &channel, - false, - ) - .await - .ok(); + if partial.description.is_some() { + SystemMessage::ChannelDescriptionChanged { + by: user.id.clone(), + } + .into_message(channel.id().to_string()) + .send( + db, + Some(amqp), + user.as_author_for_system(), + None, + None, + &channel, + false, + ) + .await + .ok(); + } + + if partial.icon.is_some() { + SystemMessage::ChannelIconChanged { + by: user.id.clone(), + } + .into_message(channel.id().to_string()) + .send( + db, + Some(amqp), + user.as_author_for_system(), + None, + None, + &channel, + false, + ) + .await + .ok(); + } + } + Channel::TextChannel { + id, + name, + description, + icon, + nsfw, + voice, + .. + } => { + if data.remove.contains(&v0::FieldsChannel::Icon) { + if let Some(icon) = &icon { + db.mark_attachment_as_deleted(&icon.id).await?; } + } - if partial.icon.is_some() { - SystemMessage::ChannelIconChanged { - by: user.id.clone(), + for field in &data.remove { + match field { + v0::FieldsChannel::Description => { + description.take(); + } + v0::FieldsChannel::Icon => { + icon.take(); + } + v0::FieldsChannel::Voice => { + voice.take(); } - .into_message(channel.id().to_string()) - .send( - db, - Some(amqp), - user.as_author_for_system(), - None, - None, - &channel, - false, - ) - .await - .ok(); + _ => {} } } - channel - .update( - db, - partial, - data.remove.into_iter().map(|f| f.into()).collect(), - ) - .await?; + if let Some(icon_id) = data.icon { + partial.icon = Some(File::use_channel_icon(db, &icon_id, id, &user.id).await?); + *icon = partial.icon.clone(); + } + + if let Some(new_name) = data.name { + *name = new_name.clone(); + partial.name = Some(new_name); + } + + if let Some(new_description) = data.description { + partial.description = Some(new_description); + *description = partial.description.clone(); + } + + if let Some(new_nsfw) = data.nsfw { + *nsfw = new_nsfw; + partial.nsfw = Some(new_nsfw); + } + + if let Some(new_voice) = data.voice { + *voice = Some(new_voice.clone().into()); + partial.voice = Some(new_voice.into()); + } } _ => return Err(create_error!(InvalidOperation)), }; + channel + .update( + db, + partial, + data.remove + .into_iter() + .map(|f| f.into()) + .collect(), + ) + .await?; + + if channel.voice().is_none() { + if let Some(users) = get_voice_channel_members(channel.id()).await? { + let node = get_channel_node(channel.id()).await?.unwrap(); + + voice_client.delete_room(&node, channel.id()).await?; + + delete_channel_voice_state(channel.id(), channel.server(), &users).await?; + }; + } + Ok(Json(channel.into())) } diff --git a/crates/delta/src/routes/channels/group_remove_member.rs b/crates/delta/src/routes/channels/group_remove_member.rs index 467168bac..0ded3ef3a 100644 --- a/crates/delta/src/routes/channels/group_remove_member.rs +++ b/crates/delta/src/routes/channels/group_remove_member.rs @@ -1,4 +1,4 @@ -use revolt_database::{util::reference::Reference, Channel, Database, User, AMQP}; +use revolt_database::{util::reference::Reference, voice::{delete_voice_state, get_channel_node, is_in_voice_channel, VoiceClient}, Channel, Database, User, AMQP}; use revolt_permissions::ChannelPermission; use revolt_result::{create_error, Result}; @@ -12,6 +12,7 @@ use rocket_empty::EmptyResponse; #[delete("//recipients/")] pub async fn remove_member( db: &State, + voice_client: &State, amqp: &State, user: User, target: Reference<'_>, @@ -23,32 +24,37 @@ pub async fn remove_member( let channel = target.as_channel(db).await?; - match &channel { - Channel::Group { - owner, recipients, .. - } => { - if &user.id != owner { - return Err(create_error!(MissingPermission { - permission: ChannelPermission::ManageChannel.to_string() - })); - } - - let member = member.as_user(db).await?; - if user.id == member.id { - return Err(create_error!(CannotRemoveYourself)); - } - - if !recipients.iter().any(|x| *x == member.id) { - return Err(create_error!(NotInGroup)); - } - - channel - .remove_user_from_group(db, amqp, &member, Some(&user.id), false) - .await - .map(|_| EmptyResponse) + if let Channel::Group { owner, recipients, .. } = &channel { + if &user.id != owner { + return Err(create_error!(MissingPermission { + permission: ChannelPermission::ManageChannel.to_string() + })); } - _ => Err(create_error!(InvalidOperation)), - } + + let member = member.as_user(db).await?; + if user.id == member.id { + return Err(create_error!(CannotRemoveYourself)); + } + + if !recipients.contains(&member.id) { + return Err(create_error!(NotInGroup)); + } + + channel + .remove_user_from_group(db, amqp, &member, Some(&user.id), false) + .await?; + } else { + return Err(create_error!(InvalidOperation)) + }; + + if is_in_voice_channel(&user.id, channel.id()).await? { + let node = get_channel_node(channel.id()).await?.unwrap(); + + voice_client.remove_user(&node, &user.id, channel.id()).await?; + delete_voice_state(channel.id(), None, &user.id).await?; + }; + + Ok(EmptyResponse) } #[cfg(test)] diff --git a/crates/delta/src/routes/channels/message_query.rs b/crates/delta/src/routes/channels/message_query.rs index 27df85e42..ff66e3757 100644 --- a/crates/delta/src/routes/channels/message_query.rs +++ b/crates/delta/src/routes/channels/message_query.rs @@ -1,6 +1,6 @@ use revolt_database::{ util::{permissions::DatabasePermissionQuery, reference::Reference}, - Channel, Database, Message, MessageFilter, MessageQuery, MessageTimePeriod, User, + Database, Message, MessageFilter, MessageQuery, MessageTimePeriod, User, }; use revolt_models::v0::{self, MessageSort}; use revolt_permissions::{calculate_channel_permissions, ChannelPermission}; @@ -65,12 +65,7 @@ pub async fn query( }, &user, include_users, - match channel { - Channel::TextChannel { server, .. } | Channel::VoiceChannel { server, .. } => { - Some(server) - } - _ => None, - }, + channel.server(), ) .await .map(Json) diff --git a/crates/delta/src/routes/channels/message_search.rs b/crates/delta/src/routes/channels/message_search.rs index 85676823d..74bc20171 100644 --- a/crates/delta/src/routes/channels/message_search.rs +++ b/crates/delta/src/routes/channels/message_search.rs @@ -1,6 +1,6 @@ use revolt_database::{ util::{permissions::DatabasePermissionQuery, reference::Reference}, - Channel, Database, Message, MessageFilter, MessageQuery, MessageTimePeriod, User, + Database, Message, MessageFilter, MessageQuery, MessageTimePeriod, User, }; use revolt_models::v0; use revolt_permissions::{calculate_channel_permissions, ChannelPermission}; @@ -31,7 +31,7 @@ pub async fn search( })?; if options.query.is_some() && options.pinned.is_some() { - return Err(create_error!(InvalidOperation)) + return Err(create_error!(InvalidOperation)); } let channel = target.as_channel(db).await?; @@ -69,12 +69,7 @@ pub async fn search( }, &user, include_users, - match channel { - Channel::TextChannel { server, .. } | Channel::VoiceChannel { server, .. } => { - Some(server) - } - _ => None, - }, + channel.server(), ) .await .map(Json) diff --git a/crates/delta/src/routes/channels/message_send.rs b/crates/delta/src/routes/channels/message_send.rs index cb05f94bc..e0dac2765 100644 --- a/crates/delta/src/routes/channels/message_send.rs +++ b/crates/delta/src/routes/channels/message_send.rs @@ -151,6 +151,7 @@ mod test { name: "Hidden Channel".to_string(), description: None, nsfw: Some(false), + voice: None }, true, ) @@ -193,6 +194,7 @@ mod test { d: ChannelPermission::ViewChannel as i64, }), last_message_id: None, + voice: None, }; locked_channel .update(&harness.db, partial, vec![]) @@ -287,6 +289,8 @@ mod test { avatar: None, timeout: None, roles: Some(second_member_roles), + can_publish: None, + can_receive: None }; second_member .update(&harness.db, partial, vec![]) @@ -613,6 +617,8 @@ mod test { nickname: None, roles: Some(vec![role_id.clone()]), timeout: None, + can_publish: None, + can_receive: None }, vec![], ) diff --git a/crates/delta/src/routes/channels/mod.rs b/crates/delta/src/routes/channels/mod.rs index c57fbaa88..5d3fbc850 100644 --- a/crates/delta/src/routes/channels/mod.rs +++ b/crates/delta/src/routes/channels/mod.rs @@ -25,6 +25,7 @@ mod message_unreact; mod permissions_set; mod permissions_set_default; mod voice_join; +mod voice_stop_ring; mod webhook_create; mod webhook_fetch_all; @@ -49,6 +50,7 @@ pub fn routes() -> (Vec, OpenApi) { group_add_member::add_member, group_remove_member::remove_member, voice_join::call, + voice_stop_ring::stop_ring, permissions_set::set_role_permissions, permissions_set_default::set_default_channel_permissions, message_react::react_message, diff --git a/crates/delta/src/routes/channels/permissions_set.rs b/crates/delta/src/routes/channels/permissions_set.rs index ee42e403d..545f6bb8d 100644 --- a/crates/delta/src/routes/channels/permissions_set.rs +++ b/crates/delta/src/routes/channels/permissions_set.rs @@ -1,6 +1,5 @@ use revolt_database::{ - util::{permissions::DatabasePermissionQuery, reference::Reference}, - Database, User, + util::{permissions::DatabasePermissionQuery, reference::Reference}, voice::{sync_voice_permissions, VoiceClient}, Database, User }; use revolt_models::v0; use revolt_permissions::{calculate_channel_permissions, ChannelPermission, Override}; @@ -11,19 +10,20 @@ use rocket::{serde::json::Json, State}; /// /// Sets permissions for the specified role in this channel. /// -/// Channel must be a `TextChannel` or `VoiceChannel`. +/// Channel must be a `TextChannel`. #[openapi(tag = "Channel Permissions")] #[put("//permissions/", data = "", rank = 2)] pub async fn set_role_permissions( db: &State, + voice_client: &State, user: User, target: Reference<'_>, role_id: String, data: Json, ) -> Result> { - let mut channel = target.as_channel(db).await?; + let channel = target.as_channel(db).await?; let mut query = DatabasePermissionQuery::new(db, &user).channel(&channel); - let permissions = calculate_channel_permissions(&mut query).await; + let permissions: revolt_permissions::PermissionValue = calculate_channel_permissions(&mut query).await; permissions.throw_if_lacking_channel_permission(ChannelPermission::ManagePermissions)?; @@ -38,11 +38,15 @@ pub async fn set_role_permissions( .throw_permission_override(current_value, &data.permissions) .await?; - channel + let mut new_channel = channel.clone(); + + new_channel .set_role_permission(db, &role_id, data.permissions.clone().into()) .await?; - Ok(Json(channel.into())) + sync_voice_permissions(db, voice_client, &new_channel, Some(server), Some(&role_id)).await?; + + Ok(Json(new_channel.into())) } else { Err(create_error!(NotFound)) } diff --git a/crates/delta/src/routes/channels/permissions_set_default.rs b/crates/delta/src/routes/channels/permissions_set_default.rs index 700b29e42..ad14a88ea 100644 --- a/crates/delta/src/routes/channels/permissions_set_default.rs +++ b/crates/delta/src/routes/channels/permissions_set_default.rs @@ -1,6 +1,5 @@ use revolt_database::{ - util::{permissions::DatabasePermissionQuery, reference::Reference}, - Channel, Database, PartialChannel, User, + util::{permissions::DatabasePermissionQuery, reference::Reference}, voice::{sync_voice_permissions, VoiceClient}, Channel, Database, PartialChannel, User }; use revolt_models::v0::{self, DataDefaultChannelPermissions}; use revolt_permissions::{calculate_channel_permissions, ChannelPermission}; @@ -11,11 +10,12 @@ use rocket::{serde::json::Json, State}; /// /// Sets permissions for the default role in this channel. /// -/// Channel must be a `Group`, `TextChannel` or `VoiceChannel`. +/// Channel must be a `Group` or `TextChannel`. #[openapi(tag = "Channel Permissions")] #[put("//permissions/default", data = "", rank = 1)] pub async fn set_default_channel_permissions( db: &State, + voice_client: &State, user: User, target: Reference<'_>, data: Json, @@ -48,10 +48,6 @@ pub async fn set_default_channel_permissions( Channel::TextChannel { default_permissions, .. - } - | Channel::VoiceChannel { - default_permissions, - .. } => { if let DataDefaultChannelPermissions::Field { permissions: field } = data { permissions @@ -75,5 +71,12 @@ pub async fn set_default_channel_permissions( _ => return Err(create_error!(InvalidOperation)), } + let server = match channel.server() { + Some(server_id) => Some(Reference::from_unchecked(server_id).as_server(db).await?), + None => None + }; + + sync_voice_permissions(db, voice_client, &channel, server.as_ref(), None).await?; + Ok(Json(channel.into())) } diff --git a/crates/delta/src/routes/channels/voice_join.rs b/crates/delta/src/routes/channels/voice_join.rs index c04e00118..9ed3a0ba9 100644 --- a/crates/delta/src/routes/channels/voice_join.rs +++ b/crates/delta/src/routes/channels/voice_join.rs @@ -1,105 +1,115 @@ use revolt_config::config; use revolt_database::{ - util::{permissions::DatabasePermissionQuery, reference::Reference}, - Channel, Database, User, + util::{permissions::perms, reference::Reference}, + voice::{ + delete_voice_state, get_channel_node, get_user_voice_channels, get_voice_channel_members, + raise_if_in_voice, set_call_notification_recipients, VoiceClient, + }, + Database, User, }; use revolt_models::v0; use revolt_permissions::{calculate_channel_permissions, ChannelPermission}; use revolt_result::{create_error, Result}; + use rocket::{serde::json::Json, State}; /// # Join Call /// /// Asks the voice server for a token to join the call. #[openapi(tag = "Voice")] -#[post("//join_call")] +#[post("//join_call", data = "")] pub async fn call( db: &State, + voice_client: &State, user: User, target: Reference<'_>, -) -> Result> { + data: Json, +) -> Result> { + if !voice_client.is_enabled() { + return Err(create_error!(LiveKitUnavailable)); + } + + let v0::DataJoinCall { + node, + force_disconnect, + recipients, + } = data.into_inner(); + + if user.bot.is_some() && force_disconnect == Some(true) { + return Err(create_error!(IsBot)); + } + let channel = target.as_channel(db).await?; - let mut query = DatabasePermissionQuery::new(db, &user).channel(&channel); - calculate_channel_permissions(&mut query) - .await - .throw_if_lacking_channel_permission(ChannelPermission::Connect)?; - let config = config().await; - if config.api.security.voso_legacy_token.is_empty() { - return Err(create_error!(VosoUnavailable)); + let Some(voice_info) = channel.voice() else { + return Err(create_error!(NotAVoiceChannel)); + }; + + let mut permissions = perms(db, &user).channel(&channel); + + let current_permissions = calculate_channel_permissions(&mut permissions).await; + current_permissions.throw_if_lacking_channel_permission(ChannelPermission::Connect)?; + + if get_voice_channel_members(channel.id()) + .await? + .zip(voice_info.max_users) + .is_some_and(|(ms, max_users)| ms.len() >= max_users) + && !current_permissions.has(ChannelPermission::ManageChannel as u64) + { + return Err(create_error!(CannotJoinCall)); } - match channel { - Channel::SavedMessages { .. } | Channel::TextChannel { .. } => { - return Err(create_error!(CannotJoinCall)) + let existing_node = get_channel_node(channel.id()).await?; + + let node = existing_node + .or(node) + .ok_or_else(|| create_error!(UnknownNode))?; + + let config = config().await; + + let node_host = config + .hosts + .livekit + .get(&node) + .ok_or_else(|| create_error!(UnknownNode))? + .clone(); + + if force_disconnect == Some(true) { + // Finds and disconnects any existing voice connections by the user, + // should only ever loop once but just to cover our backs. + + for channel_id in get_user_voice_channels(&user.id).await? { + let node = get_channel_node(&channel_id).await?.unwrap(); + let channel = Reference::from_unchecked(&channel_id) + .as_channel(db) + .await?; + + voice_client + .remove_user(&node, &user.id, &channel_id) + .await?; + + delete_voice_state(&channel_id, channel.server(), &user.id).await?; } - _ => {} + } else { + raise_if_in_voice(&user, channel.id()).await?; } - // To join a call: - // - Check if the room exists. - // - If not, create it. - let client = reqwest::Client::new(); - let result = client - .get(format!( - "{}/room/{}", - config.hosts.voso_legacy, - channel.id() - )) - .header( - reqwest::header::AUTHORIZATION, - config.api.security.voso_legacy_token.clone(), - ) - .send() - .await; - - match result { - Err(_) => return Err(create_error!(VosoUnavailable)), - Ok(result) => match result.status() { - reqwest::StatusCode::OK => (), - reqwest::StatusCode::NOT_FOUND => { - if (client - .post(format!( - "{}/room/{}", - config.hosts.voso_legacy, - channel.id() - )) - .header( - reqwest::header::AUTHORIZATION, - config.api.security.voso_legacy_token.clone(), - ) - .send() - .await) - .is_err() - { - return Err(create_error!(VosoUnavailable)); - } - } - _ => return Err(create_error!(VosoUnavailable)), - }, - } + let token = voice_client + .create_token(&node, db, &user, current_permissions, &channel) + .await?; - // Then create a user for the room. - if let Ok(response) = client - .post(format!( - "{}/room/{}/user/{}", - config.hosts.voso_legacy, - channel.id(), - user.id - )) - .header( - reqwest::header::AUTHORIZATION, - config.api.security.voso_legacy_token, - ) - .send() - .await - { - response - .json() - .await - .map_err(|_| create_error!(InvalidOperation)) - .map(Json) - } else { - Err(create_error!(VosoUnavailable)) + let room = voice_client.create_room(&node, &channel).await?; + + log::debug!("Created room {}", room.name); + + if let Some(recipients) = recipients { + if room.num_participants == 0 { + set_call_notification_recipients(channel.id(), &user.id, &recipients).await?; + } } + + Ok(Json(v0::CreateVoiceUserResponse { + token, + url: node_host.clone(), + })) } diff --git a/crates/delta/src/routes/channels/voice_stop_ring.rs b/crates/delta/src/routes/channels/voice_stop_ring.rs new file mode 100644 index 000000000..f8db8fc44 --- /dev/null +++ b/crates/delta/src/routes/channels/voice_stop_ring.rs @@ -0,0 +1,69 @@ +use revolt_database::{ + util::reference::Reference, + voice::{get_voice_state, VoiceClient}, + Channel, Database, User, AMQP, +}; +use revolt_result::{create_error, Result, ToRevoltError}; + +use rocket::State; +use rocket_empty::EmptyResponse; + +/// # Stop Ring +/// Stops ringing a specific user in a dm call. +/// You must be in the call to use this endpoint, returns NotConnected otherwise. +/// Only valid in DM/Group channels, will return NoEffect in servers. +/// Returns NotFound if the user is not in the dm/group channel +#[openapi(tag = "Voice")] +#[put("//end_ring/")] +pub async fn stop_ring( + db: &State, + amqp: &State, + voice: &State, + user: User, + target: Reference<'_>, + target_user: Reference<'_>, +) -> Result { + if !voice.is_enabled() { + return Err(create_error!(LiveKitUnavailable)); + } + + let channel = target.as_channel(db).await?; + if channel.server().is_some() { + return Err(create_error!(NoEffect)); + } + + if get_voice_state(channel.id(), None, &user.id) + .await? + .is_none() + { + return Err(create_error!(NotConnected)); + } + + let members = match channel { + Channel::DirectMessage { ref recipients, .. } | Channel::Group { ref recipients, .. } => { + recipients + } + _ => return Err(create_error!(NoEffect)), + }; + + if members.iter().any(|m| &target_user.id == m) { + if let Err(e) = amqp + .dm_call_updated( + &user.id, + channel.id(), + None, + true, + Some(vec![target_user.id.to_string()]), + ) + .await + .to_internal_error() + { + revolt_config::capture_internal_error!(&e); + return Err(e); + } + + Ok(EmptyResponse) + } else { + Err(create_error!(NotFound)) + } +} diff --git a/crates/delta/src/routes/invites/invite_fetch.rs b/crates/delta/src/routes/invites/invite_fetch.rs index 7c44d4ae6..7c1b7550c 100644 --- a/crates/delta/src/routes/invites/invite_fetch.rs +++ b/crates/delta/src/routes/invites/invite_fetch.rs @@ -23,13 +23,6 @@ pub async fn fetch(db: &State, target: Reference<'_>) -> Result { let server = db.fetch_server(&server).await?; @@ -202,6 +195,7 @@ mod test { name: "Voice Channel".to_string(), description: None, nsfw: Some(false), + voice: None }, true, ) diff --git a/crates/delta/src/routes/root.rs b/crates/delta/src/routes/root.rs index c90c53c0b..3ddbfcc7c 100644 --- a/crates/delta/src/routes/root.rs +++ b/crates/delta/src/routes/root.rs @@ -21,15 +21,22 @@ pub struct Feature { pub url: String, } +/// # Information about a livekit node +#[derive(Serialize, JsonSchema, Debug)] +pub struct VoiceNode { + pub name: String, + pub lat: f64, + pub lon: f64, + pub public_url: String, +} + /// # Voice Server Configuration #[derive(Serialize, JsonSchema, Debug)] pub struct VoiceFeature { /// Whether voice is enabled pub enabled: bool, - /// URL pointing to the voice API - pub url: String, - /// URL pointing to the voice WebSocket server - pub ws: String, + /// All livekit nodes + pub nodes: Vec, } /// # Feature Configuration @@ -46,7 +53,7 @@ pub struct RevoltFeatures { /// Proxy service configuration pub january: Feature, /// Voice server configuration - pub voso: VoiceFeature, + pub livekit: VoiceFeature, } /// # Build Information @@ -94,22 +101,38 @@ pub async fn root() -> Result> { features: RevoltFeatures { captcha: CaptchaFeature { enabled: !config.api.security.captcha.hcaptcha_key.is_empty(), - key: config.api.security.captcha.hcaptcha_sitekey, + key: config.api.security.captcha.hcaptcha_sitekey.clone(), }, email: !config.api.smtp.host.is_empty(), invite_only: config.api.registration.invite_only, autumn: Feature { enabled: !config.hosts.autumn.is_empty(), - url: config.hosts.autumn, + url: config.hosts.autumn.clone(), }, january: Feature { enabled: !config.hosts.january.is_empty(), - url: config.hosts.january, + url: config.hosts.january.clone(), }, - voso: VoiceFeature { - enabled: !config.hosts.voso_legacy.is_empty(), - url: config.hosts.voso_legacy, - ws: config.hosts.voso_legacy_ws, + livekit: VoiceFeature { + enabled: !config.hosts.livekit.is_empty(), + nodes: config + .api + .livekit + .nodes + .iter() + .filter(|(_, node)| !node.private) + .map(|(name, value)| VoiceNode { + name: name.clone(), + lat: value.lat, + lon: value.lon, + public_url: config + .hosts + .livekit + .get(name) + .expect("Missing corresponding host for voice node") + .clone(), + }) + .collect(), }, }, ws: config.hosts.events, diff --git a/crates/delta/src/routes/servers/ban_create.rs b/crates/delta/src/routes/servers/ban_create.rs index 13c0ef3b3..3d7985014 100644 --- a/crates/delta/src/routes/servers/ban_create.rs +++ b/crates/delta/src/routes/servers/ban_create.rs @@ -1,5 +1,6 @@ use revolt_database::{ util::{permissions::DatabasePermissionQuery, reference::Reference}, + voice::{delete_voice_state, get_channel_node, get_user_voice_channel_in_server, VoiceClient}, Database, RemovalIntention, ServerBan, User, }; use revolt_models::v0; @@ -16,6 +17,7 @@ use validator::Validate; #[put("//bans/", data = "")] pub async fn ban( db: &State, + voice_client: &State, user: User, server: Reference<'_>, target: Reference<'_>, @@ -54,6 +56,15 @@ pub async fn ban( member .remove(db, &server, RemovalIntention::Ban, false) .await?; + + // If the member is in a voice channel while banned kick them from the voice channel + if let Some(channel_id) = get_user_voice_channel_in_server(&user.id, &server.id).await? { + let node = get_channel_node(&channel_id).await?.unwrap(); + + voice_client.remove_user(&node, &user.id, &channel_id).await?; + + delete_voice_state(&channel_id, Some(&server.id), &user.id).await? + } } ServerBan::create(db, &server, target.id, data.reason) diff --git a/crates/delta/src/routes/servers/member_edit.rs b/crates/delta/src/routes/servers/member_edit.rs index e33935e86..158d7c863 100644 --- a/crates/delta/src/routes/servers/member_edit.rs +++ b/crates/delta/src/routes/servers/member_edit.rs @@ -1,14 +1,25 @@ use std::collections::HashSet; use revolt_database::{ - util::{permissions::DatabasePermissionQuery, reference::Reference}, + events::client::EventV1, + util::{ + permissions::{perms, DatabasePermissionQuery}, + reference::Reference, + }, + voice::{ + get_channel_node, get_user_voice_channel_in_server, set_channel_node, + set_user_moved_from_voice, set_user_moved_to_voice, sync_user_voice_permissions, + VoiceClient, + }, Database, File, PartialMember, User, }; -use revolt_models::v0; +use revolt_models::v0::{self, FieldsMember}; -use revolt_permissions::{calculate_server_permissions, ChannelPermission}; +use revolt_permissions::{ + calculate_channel_permissions, calculate_server_permissions, ChannelPermission, +}; use revolt_result::{create_error, Result}; -use rocket::{serde::json::Json, State}; +use rocket::{form::validate::Contains, serde::json::Json, State}; use validator::Validate; /// # Edit Member @@ -18,6 +29,7 @@ use validator::Validate; #[patch("//members/", data = "")] pub async fn edit( db: &State, + voice_client: &State, user: User, server: Reference<'_>, member: Reference<'_>, @@ -32,6 +44,7 @@ pub async fn edit( // Fetch server and member let mut server = server.as_server(db).await?; + let target_user = member.as_user(db).await?; let mut member = member.as_member(db, &server.id).await?; // Fetch our currrent permissions @@ -67,6 +80,44 @@ pub async fn edit( permissions.throw_if_lacking_channel_permission(ChannelPermission::TimeoutMembers)?; } + if data.can_publish.is_some() { + permissions.throw_if_lacking_channel_permission(ChannelPermission::MuteMembers)?; + } + + if data.can_receive.is_some() { + permissions.throw_if_lacking_channel_permission(ChannelPermission::DeafenMembers)?; + } + + let new_voice_channel = if let Some(new_channel) = &data.voice_channel { + if !voice_client.is_enabled() { + return Err(create_error!(LiveKitUnavailable)); + }; + + permissions.throw_if_lacking_channel_permission(ChannelPermission::MoveMembers)?; + + // ensure the channel we are moving them to is in the server and is a voice channel + + let channel = Reference::from_unchecked(new_channel) + .as_channel(db) + .await + .map_err(|_| create_error!(UnknownChannel))?; + + if channel.server().is_none_or(|v| v != member.id.server) { + Err(create_error!(UnknownChannel))? + } + + if get_user_voice_channel_in_server(&target_user.id, &server.id) + .await? + .is_none() + { + Err(create_error!(NotConnected))? + }; + + Some(channel) + } else { + None + }; + // Resolve our ranking let our_ranking = query.get_member_rank().unwrap_or(i64::MIN); @@ -102,12 +153,17 @@ pub async fn edit( roles, timeout, remove, + can_publish, + can_receive, + voice_channel: _, } = data; let mut partial = PartialMember { nickname, roles, timeout, + can_publish, + can_receive, ..Default::default() }; @@ -123,9 +179,67 @@ pub async fn edit( partial.avatar = Some(File::use_user_avatar(db, &avatar, &user.id, &user.id).await?); } + let remove_contains_voice = remove.contains(FieldsMember::CanPublish) || remove.contains(FieldsMember::CanReceive); + member .update(db, partial, remove.into_iter().map(Into::into).collect()) .await?; + if let Some(new_voice_channel) = new_voice_channel { + if let Some(channel) = get_user_voice_channel_in_server(&target_user.id, &server.id).await? + { + let old_node = get_channel_node(&channel).await?.unwrap(); + + let new_node = match get_channel_node(new_voice_channel.id()).await? { + Some(node) => node, + None => { + set_channel_node(new_voice_channel.id(), &old_node).await?; + old_node.clone() + } + }; + + set_user_moved_from_voice(&channel, new_voice_channel.id(), &target_user.id).await?; + set_user_moved_to_voice(new_voice_channel.id(), &channel, &target_user.id).await?; + + let mut query = perms(db, &target_user).channel(&new_voice_channel); + let permissions = calculate_channel_permissions(&mut query).await; + + voice_client + .create_room(&new_node, &new_voice_channel) + .await?; + let token = voice_client + .create_token(&new_node, db, &target_user, permissions, &new_voice_channel) + .await?; + + voice_client + .remove_user(&old_node, &target_user.id, &channel) + .await?; + + EventV1::UserMoveVoiceChannel { + node: new_node, + token, + } + .p_user(target_user.id.clone(), db) + .await; + }; + } else if can_publish.is_some() || can_receive.is_some() || remove_contains_voice { + if let Some(channel) = get_user_voice_channel_in_server(&target_user.id, &server.id).await? + { + let node = get_channel_node(&channel).await?.unwrap(); + let channel = Reference::from_unchecked(&channel).as_channel(db).await?; + + sync_user_voice_permissions( + db, + voice_client, + &node, + &user, + &channel, + Some(&server), + None, + ) + .await?; + }; + }; + Ok(Json(member.into())) } diff --git a/crates/delta/src/routes/servers/member_remove.rs b/crates/delta/src/routes/servers/member_remove.rs index 230042671..f2a3b654c 100644 --- a/crates/delta/src/routes/servers/member_remove.rs +++ b/crates/delta/src/routes/servers/member_remove.rs @@ -1,5 +1,6 @@ use revolt_database::{ util::{permissions::DatabasePermissionQuery, reference::Reference}, + voice::{delete_voice_state, get_channel_node, get_user_voice_channel_in_server, VoiceClient}, Database, RemovalIntention, User, }; use revolt_permissions::{calculate_server_permissions, ChannelPermission}; @@ -14,6 +15,7 @@ use rocket_empty::EmptyResponse; #[delete("//members/")] pub async fn kick( db: &State, + voice_client: &State, user: User, target: Reference<'_>, member: Reference<'_>, @@ -42,6 +44,15 @@ pub async fn kick( member .remove(db, &server, RemovalIntention::Kick, false) - .await - .map(|_| EmptyResponse) + .await?; + + if let Some(channel_id) = get_user_voice_channel_in_server(&user.id, &server.id).await? { + let node = get_channel_node(&channel_id).await?.unwrap(); + + voice_client.remove_user(&node, &user.id, &channel_id).await?; + + delete_voice_state(&channel_id, Some(&server.id), &user.id).await?; + }; + + Ok(EmptyResponse) } diff --git a/crates/delta/src/routes/servers/permissions_set.rs b/crates/delta/src/routes/servers/permissions_set.rs index f0d8099fd..41979a61b 100644 --- a/crates/delta/src/routes/servers/permissions_set.rs +++ b/crates/delta/src/routes/servers/permissions_set.rs @@ -1,5 +1,6 @@ use revolt_database::{ util::{permissions::DatabasePermissionQuery, reference::Reference}, + voice::{sync_voice_permissions, VoiceClient}, Database, User, }; use revolt_models::v0; @@ -14,6 +15,7 @@ use rocket::{serde::json::Json, State}; #[put("//permissions/", data = "", rank = 2)] pub async fn set_role_permission( db: &State, + voice_client: &State, user: User, target: Reference<'_>, role_id: String, @@ -22,30 +24,38 @@ pub async fn set_role_permission( let data = data.into_inner(); let mut server = target.as_server(db).await?; - if let Some((current_value, rank)) = server.roles.get(&role_id).map(|x| (x.permissions, x.rank)) - { - let mut query = DatabasePermissionQuery::new(db, &user).server(&server); - let permissions = calculate_server_permissions(&mut query).await; - - permissions.throw_if_lacking_channel_permission(ChannelPermission::ManagePermissions)?; - - // Prevent us from editing roles above us - if rank <= query.get_member_rank().unwrap_or(i64::MIN) { - return Err(create_error!(NotElevated)); - } - - // Ensure we have access to grant these permissions forwards - let current_value: Override = current_value.into(); - permissions - .throw_permission_override(current_value, &data.permissions) - .await?; - - server - .set_role_permission(db, &role_id, data.permissions.into()) - .await?; - - Ok(Json(server.into())) - } else { - Err(create_error!(NotFound)) + + let (current_value, rank) = server + .roles + .get(&role_id) + .map(|x| (x.permissions, x.rank)) + .ok_or_else(|| create_error!(NotFound))?; + + let mut query = DatabasePermissionQuery::new(db, &user).server(&server); + let permissions = calculate_server_permissions(&mut query).await; + + permissions.throw_if_lacking_channel_permission(ChannelPermission::ManagePermissions)?; + + // Prevent us from editing roles above us + if rank <= query.get_member_rank().unwrap_or(i64::MIN) { + return Err(create_error!(NotElevated)); } + + // Ensure we have access to grant these permissions forwards + let current_value: Override = current_value.into(); + permissions + .throw_permission_override(current_value, &data.permissions) + .await?; + + server + .set_role_permission(db, &role_id, data.permissions.into()) + .await?; + + for channel_id in &server.channels { + let channel = Reference::from_unchecked(channel_id).as_channel(db).await?; + + sync_voice_permissions(db, voice_client, &channel, Some(&server), Some(&role_id)).await?; + }; + + Ok(Json(server.into())) } diff --git a/crates/delta/src/routes/servers/permissions_set_default.rs b/crates/delta/src/routes/servers/permissions_set_default.rs index 6ee5b8135..bf9dd3048 100644 --- a/crates/delta/src/routes/servers/permissions_set_default.rs +++ b/crates/delta/src/routes/servers/permissions_set_default.rs @@ -1,6 +1,5 @@ use revolt_database::{ - util::{permissions::DatabasePermissionQuery, reference::Reference}, - Database, PartialServer, User, + util::{permissions::DatabasePermissionQuery, reference::Reference}, voice::{sync_voice_permissions, VoiceClient}, Database, PartialServer, User }; use revolt_models::v0; use revolt_permissions::{ @@ -16,6 +15,7 @@ use rocket::{serde::json::Json, State}; #[put("//permissions/default", data = "", rank = 1)] pub async fn set_default_server_permissions( db: &State, + voice_client: &State, user: User, target: Reference<'_>, data: Json, @@ -50,5 +50,11 @@ pub async fn set_default_server_permissions( ) .await?; + for channel_id in &server.channels { + let channel = Reference::from_unchecked(channel_id).as_channel(db).await?; + + sync_voice_permissions(db, voice_client, &channel, Some(&server), None).await?; + }; + Ok(Json(server.into())) } diff --git a/crates/delta/src/routes/servers/roles_delete.rs b/crates/delta/src/routes/servers/roles_delete.rs index 6867b647f..77df2501d 100644 --- a/crates/delta/src/routes/servers/roles_delete.rs +++ b/crates/delta/src/routes/servers/roles_delete.rs @@ -1,5 +1,6 @@ use revolt_database::{ util::{permissions::DatabasePermissionQuery, reference::Reference}, + voice::{sync_voice_permissions, VoiceClient}, Database, User, }; use revolt_permissions::{calculate_server_permissions, ChannelPermission}; @@ -17,6 +18,7 @@ pub async fn delete( user: User, target: Reference<'_>, role_id: String, + voice_client: &State, ) -> Result { let mut server = target.as_server(db).await?; let mut query = DatabasePermissionQuery::new(db, &user).server(&server); @@ -26,15 +28,22 @@ pub async fn delete( let member_rank = query.get_member_rank().unwrap_or(i64::MIN); - if let Some(role) = server.roles.remove(&role_id) { - if role.rank <= member_rank { - return Err(create_error!(NotElevated)); - } + let role = server + .roles + .remove(&role_id) + .ok_or_else(|| create_error!(NotFound))?; - role.delete(db, &server.id, &role_id) - .await - .map(|_| EmptyResponse) - } else { - Err(create_error!(NotFound)) + if role.rank <= member_rank { + return Err(create_error!(NotElevated)); } + + role.delete(db, &server.id, &role_id).await?; + + for channel_id in &server.channels { + let channel = Reference::from_unchecked(channel_id).as_channel(db).await?; + + sync_voice_permissions(db, voice_client, &channel, Some(&server), Some(&role_id)).await?; + } + + Ok(EmptyResponse) } diff --git a/crates/delta/src/routes/servers/roles_edit.rs b/crates/delta/src/routes/servers/roles_edit.rs index f18bd7d33..639589afa 100644 --- a/crates/delta/src/routes/servers/roles_edit.rs +++ b/crates/delta/src/routes/servers/roles_edit.rs @@ -1,6 +1,7 @@ use revolt_database::{ util::{permissions::DatabasePermissionQuery, reference::Reference}, - Database, PartialRole, User, + voice::{sync_voice_permissions, VoiceClient}, + Database, PartialRole, User }; use revolt_models::v0; use revolt_permissions::{calculate_server_permissions, ChannelPermission}; @@ -15,6 +16,7 @@ use validator::Validate; #[patch("//roles/", data = "", rank = 1)] pub async fn edit( db: &State, + voice_client: &State, user: User, target: Reference<'_>, role_id: String, @@ -65,6 +67,12 @@ pub async fn edit( ) .await?; + for channel_id in &server.channels { + let channel = Reference::from_unchecked(channel_id).as_channel(db).await?; + + sync_voice_permissions(db, voice_client, &channel, Some(&server), Some(&role_id)).await?; + }; + Ok(Json(role.into())) } else { Err(create_error!(NotFound)) diff --git a/crates/delta/src/routes/servers/roles_edit_positions.rs b/crates/delta/src/routes/servers/roles_edit_positions.rs index d27259e5b..4b55e95af 100644 --- a/crates/delta/src/routes/servers/roles_edit_positions.rs +++ b/crates/delta/src/routes/servers/roles_edit_positions.rs @@ -1,6 +1,5 @@ use revolt_database::{ - util::{permissions::DatabasePermissionQuery, reference::Reference}, - Database, User, + util::{permissions::DatabasePermissionQuery, reference::Reference}, voice::{sync_voice_permissions, VoiceClient}, Database, User }; use revolt_models::v0; use revolt_permissions::{calculate_server_permissions, ChannelPermission}; @@ -14,6 +13,7 @@ use rocket::{serde::json::Json, State}; #[patch("//roles/ranks", data = "")] pub async fn edit_role_ranks( db: &State, + voice_client: &State, user: User, target: Reference<'_>, data: Json, @@ -70,6 +70,12 @@ pub async fn edit_role_ranks( server.set_role_ordering(db, new_order).await?; + for channel_id in &server.channels { + let channel = Reference::from_unchecked(channel_id).as_channel(db).await?; + + sync_voice_permissions(db, voice_client, &channel, Some(&server), None).await?; + }; + Ok(Json(server.into())) } diff --git a/crates/delta/src/routes/servers/server_delete.rs b/crates/delta/src/routes/servers/server_delete.rs index 1302db5b5..ce323006d 100644 --- a/crates/delta/src/routes/servers/server_delete.rs +++ b/crates/delta/src/routes/servers/server_delete.rs @@ -1,4 +1,8 @@ -use revolt_database::{util::reference::Reference, Database, RemovalIntention, User}; +use revolt_database::{ + util::reference::Reference, + voice::{delete_channel_voice_state, delete_voice_state, get_channel_node, get_user_voice_channel_in_server, get_voice_channel_members, VoiceClient}, + Database, RemovalIntention, User, +}; use revolt_models::v0; use revolt_result::Result; use rocket::State; @@ -12,6 +16,7 @@ use rocket_empty::EmptyResponse; #[delete("/?")] pub async fn delete( db: &State, + voice_client: &State, user: User, target: Reference<'_>, options: v0::OptionsServerDelete, @@ -20,8 +25,28 @@ pub async fn delete( let member = db.fetch_member(target.id, &user.id).await?; if server.owner == user.id { + for channel_id in &server.channels { + if let Some(users) = get_voice_channel_members(channel_id).await? { + let node = get_channel_node(channel_id).await?.unwrap(); + + voice_client.delete_room(&node, channel_id).await?; + + delete_channel_voice_state(channel_id, Some(&server.id), &users).await?; + }; + } + server.delete(db).await } else { + if let Some(channel_id) = get_user_voice_channel_in_server(&user.id, &server.id).await? { + if server.channels.iter().any(|c| c == &channel_id) { + let node = get_channel_node(&channel_id).await?.unwrap(); + + voice_client.remove_user(&node, &user.id, &channel_id).await?; + + delete_voice_state(&channel_id, Some(&server.id), &user.id).await?; + } + }; + member .remove( db, diff --git a/crates/delta/src/util/test.rs b/crates/delta/src/util/test.rs index 067f8bd0a..b90a1a5cd 100644 --- a/crates/delta/src/util/test.rs +++ b/crates/delta/src/util/test.rs @@ -165,6 +165,7 @@ impl TestHarness { name: "Test Channel".to_string(), description: None, nsfw: Some(false), + voice: None }, true, ) diff --git a/rust-toolchain.toml b/rust-toolchain.toml new file mode 100644 index 000000000..31578d3bf --- /dev/null +++ b/rust-toolchain.toml @@ -0,0 +1,2 @@ +[toolchain] +channel = "stable" \ No newline at end of file diff --git a/scripts/build-image-layer.sh b/scripts/build-image-layer.sh index 064cee896..e75a1c9bb 100644 --- a/scripts/build-image-layer.sh +++ b/scripts/build-image-layer.sh @@ -37,7 +37,8 @@ deps() { crates/services/january/src \ crates/services/gifbox/src \ crates/daemons/crond/src \ - crates/daemons/pushd/src + crates/daemons/pushd/src \ + crates/daemons/voice-ingress/src echo 'fn main() { panic!("stub"); }' | tee crates/bonfire/src/main.rs | tee crates/delta/src/main.rs | @@ -45,7 +46,8 @@ deps() { tee crates/services/january/src/main.rs | tee crates/services/gifbox/src/main.rs | tee crates/daemons/crond/src/main.rs | - tee crates/daemons/pushd/src/main.rs + tee crates/daemons/pushd/src/main.rs | + tee crates/daemons/voice-ingress/src/main.rs echo '' | tee crates/core/config/src/lib.rs | tee crates/core/database/src/lib.rs | @@ -70,6 +72,7 @@ apps() { crates/delta/src/main.rs \ crates/daemons/crond/src/main.rs \ crates/daemons/pushd/src/main.rs \ + crates/daemons/voice-ingress/src/main.rs \ crates/core/config/src/lib.rs \ crates/core/database/src/lib.rs \ crates/core/models/src/lib.rs \ diff --git a/scripts/publish-debug-image.sh b/scripts/publish-debug-image.sh index cc67d9fdc..564adc4f6 100755 --- a/scripts/publish-debug-image.sh +++ b/scripts/publish-debug-image.sh @@ -28,6 +28,7 @@ docker build -t ghcr.io/revoltchat/january:$TAG - < crates/services/january/Dock docker build -t ghcr.io/revoltchat/gifbox:$TAG - < crates/services/gifbox/Dockerfile docker build -t ghcr.io/revoltchat/crond:$TAG - < crates/daemons/crond/Dockerfile docker build -t ghcr.io/revoltchat/pushd:$TAG - < crates/daemons/pushd/Dockerfile +docker build -t ghcr.io/revoltchat/voice-ingress:$TAG - < crates/daemons/voice-ingress/Dockerfile if [ "$DEBUG" = "true" ]; then git restore Cargo.toml @@ -40,3 +41,4 @@ docker push ghcr.io/revoltchat/january:$TAG docker push ghcr.io/revoltchat/gifbox:$TAG docker push ghcr.io/revoltchat/crond:$TAG docker push ghcr.io/revoltchat/pushd:$TAG +docker push ghcr.io/revoltchat/voice-ingress:$TAG \ No newline at end of file