diff --git a/.github/workflows/rust.yml b/.github/workflows/rust.yml new file mode 100644 index 0000000..656f3d7 --- /dev/null +++ b/.github/workflows/rust.yml @@ -0,0 +1,20 @@ +name: Rust + +on: + push: + branches: [ "main" ] + pull_request: + branches: [ "main" ] + +env: + CARGO_TERM_COLOR: always + +jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + - name: Build + run: cargo build --verbose + - name: Run tests + run: cargo test --verbose diff --git a/.gitignore b/.gitignore index 9b2e114..032fd39 100644 --- a/.gitignore +++ b/.gitignore @@ -2,5 +2,6 @@ /samples /venv /bin +.env* *.sh -*.bak \ No newline at end of file +**/*.bak \ No newline at end of file diff --git a/Cargo.lock b/Cargo.lock index 37a0ebe..5e6a738 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3,61 +3,42 @@ version = 3 [[package]] -name = "aho-corasick" -version = "0.7.20" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cc936419f96fa211c1b9166887b38e5e40b19958e5b895be7c1f93adec7071ac" -dependencies = [ - "memchr", -] - -[[package]] -name = "anstream" -version = "0.3.2" +name = "addr2line" +version = "0.21.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0ca84f3628370c59db74ee214b3263d58f9aadd9b4fe7e711fd87dc452b7f163" +checksum = "8a30b2e23b9e17a9f90641c7ab1549cd9b44f296d3ccbf309d2863cfe398a0cb" dependencies = [ - "anstyle", - "anstyle-parse", - "anstyle-query", - "anstyle-wincon", - "colorchoice", - "is-terminal", - "utf8parse", + "gimli", ] [[package]] -name = "anstyle" -version = "1.0.0" +name = "adler" +version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "41ed9a86bf92ae6580e0a31281f65a1b1d867c0cc68d5346e2ae128dddfa6a7d" +checksum = "f26201604c87b1e01bd3d98f8d5d9a8fcbb815e8cedb41ffccbeb4bf593a35fe" [[package]] -name = "anstyle-parse" -version = "0.2.0" +name = "aho-corasick" +version = "1.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e765fd216e48e067936442276d1d57399e37bce53c264d6fefbe298080cb57ee" +checksum = "b2969dcb958b36655471fc61f7e416fa76033bdd4bfed0678d8fee1e2d07a1f0" dependencies = [ - "utf8parse", + "memchr", ] [[package]] -name = "anstyle-query" -version = "1.0.0" +name = "android-tzdata" +version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5ca11d4be1bab0c8bc8734a9aa7bf4ee8316d462a08c6ac5052f888fef5b494b" -dependencies = [ - "windows-sys 0.48.0", -] +checksum = "e999941b234f3131b00bc13c22d06e8c5ff726d1b6318ac7eb276997bbb4fef0" [[package]] -name = "anstyle-wincon" -version = "1.0.1" +name = "android_system_properties" +version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "180abfa45703aebe0093f79badacc01b8fd4ea2e35118747e5811127f926e188" +checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" dependencies = [ - "anstyle", - "windows-sys 0.48.0", + "libc", ] [[package]] @@ -67,176 +48,145 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d468802bab17cbc0cc575e9b053f41e72aa36bfa6b7f55e3529ffa43161b97fa" [[package]] -name = "bitflags" -version = "1.3.2" +name = "backtrace" +version = "0.3.69" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" +checksum = "2089b7e3f35b9dd2d0ed921ead4f6d318c27680d4a5bd167b3ee120edb105837" +dependencies = [ + "addr2line", + "cc", + "cfg-if", + "libc", + "miniz_oxide", + "object", + "rustc-demangle", +] [[package]] name = "bitflags" -version = "2.2.1" +version = "1.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "24a6904aef64d73cf10ab17ebace7befb918b82164785cb89907993be7f83813" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" [[package]] -name = "bytes" -version = "1.4.0" +name = "bitflags" +version = "2.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "89b2fd2a0dcf38d7971e2194b6b6eebab45ae01067456a7fd93d5547a61b70be" +checksum = "327762f6e5a765692301e5bb513e0d9fef63be86bbc14528052b1cd3e6f03e07" [[package]] -name = "cc" -version = "1.0.79" +name = "bumpalo" +version = "3.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "50d30906286121d95be3d479533b458f87493b30a4b5f79a607db8f5d11aa91f" +checksum = "7f30e7476521f6f8af1a1c4c0b8cc94f0bee37d91763d0ca2665f299b6cd8aec" [[package]] -name = "cfg-if" -version = "1.0.0" +name = "bytes" +version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" +checksum = "a2bd12c1caf447e69cd4528f47f94d203fd2582878ecb9e9465484c4148a8223" [[package]] -name = "clap" -version = "4.2.7" +name = "cc" +version = "1.0.83" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "34d21f9bf1b425d2968943631ec91202fe5e837264063503708b83013f8fc938" +checksum = "f1174fb0b6ec23863f8b971027804a42614e347eafb0a95bf0b12cdae21fc4d0" dependencies = [ - "clap_builder", - "clap_derive", - "once_cell", + "libc", ] [[package]] -name = "clap_builder" -version = "4.2.7" +name = "cfg-if" +version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "914c8c79fb560f238ef6429439a30023c862f7a28e688c58f7203f12b29970bd" -dependencies = [ - "anstream", - "anstyle", - "bitflags 1.3.2", - "clap_lex", - "strsim", -] +checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" [[package]] -name = "clap_derive" -version = "4.2.0" +name = "chrono" +version = "0.4.31" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3f9644cd56d6b87dbe899ef8b053e331c0637664e9e21a33dfcdc36093f5c5c4" +checksum = "7f2c685bad3eb3d45a01354cedb7d5faa66194d1d58ba6e267a8de788f79db38" dependencies = [ - "heck", - "proc-macro2", - "quote", - "syn 2.0.15", + "android-tzdata", + "iana-time-zone", + "js-sys", + "num-traits", + "wasm-bindgen", + "windows-targets", ] -[[package]] -name = "clap_lex" -version = "0.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8a2dd5a6fe8c6e3502f568a6353e5273bbb15193ad9a89e457b9970798efbea1" - [[package]] name = "closur" version = "0.1.0" dependencies = [ - "bitflags 2.2.1", + "bitflags 2.4.1", "bytes", - "clap", + "chrono", "regex", "tokio", ] [[package]] -name = "colorchoice" -version = "1.0.0" +name = "core-foundation-sys" +version = "0.8.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "acbf1af155f9b9ef647e42cdc158db4b64a1b61f743629225fde6f3e0be2a7c7" +checksum = "e496a50fda8aacccc86d7529e2c1e0892dbd0f898a6b5645b5561b89c3210efa" [[package]] -name = "errno" -version = "0.3.1" +name = "gimli" +version = "0.28.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4bcfec3a70f97c962c307b2d2c56e358cf1d00b558d74262b5f929ee8cc7e73a" -dependencies = [ - "errno-dragonfly", - "libc", - "windows-sys 0.48.0", -] - -[[package]] -name = "errno-dragonfly" -version = "0.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "aa68f1b12764fab894d2755d2518754e71b4fd80ecfb822714a1206c2aab39bf" -dependencies = [ - "cc", - "libc", -] +checksum = "6fb8d784f27acf97159b40fc4db5ecd8aa23b9ad5ef69cdd136d3bc80665f0c0" [[package]] -name = "heck" -version = "0.4.1" +name = "hermit-abi" +version = "0.3.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "95505c38b4572b2d910cecb0281560f54b440a19336cbbcb27bf6ce6adc6f5a8" +checksum = "d77f7ec81a6d05a3abb01ab6eb7590f6083d08449fe5a1c8b1e620283546ccb7" [[package]] -name = "hermit-abi" -version = "0.2.6" +name = "iana-time-zone" +version = "0.1.58" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ee512640fe35acbfb4bb779db6f0d80704c2cacfa2e39b601ef3e3f47d1ae4c7" +checksum = "8326b86b6cff230b97d0d312a6c40a60726df3332e721f72a1b035f451663b20" dependencies = [ - "libc", + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "wasm-bindgen", + "windows-core", ] [[package]] -name = "hermit-abi" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fed44880c466736ef9a5c5b5facefb5ed0785676d0c02d612db14e54f0d84286" - -[[package]] -name = "io-lifetimes" -version = "1.0.10" +name = "iana-time-zone-haiku" +version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9c66c74d2ae7e79a5a8f7ac924adbe38ee42a859c6539ad869eb51f0b52dc220" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" dependencies = [ - "hermit-abi 0.3.1", - "libc", - "windows-sys 0.48.0", + "cc", ] [[package]] -name = "is-terminal" -version = "0.4.7" +name = "js-sys" +version = "0.3.64" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "adcf93614601c8129ddf72e2d5633df827ba6551541c6d8c59520a371475be1f" +checksum = "c5f195fe497f702db0f318b07fdd68edb16955aed830df8363d837542f8f935a" dependencies = [ - "hermit-abi 0.3.1", - "io-lifetimes", - "rustix", - "windows-sys 0.48.0", + "wasm-bindgen", ] [[package]] name = "libc" -version = "0.2.140" +version = "0.2.149" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "99227334921fae1a979cf0bfdfcc6b3e5ce376ef57e16fb6fb3ea2ed6095f80c" - -[[package]] -name = "linux-raw-sys" -version = "0.3.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b64f40e5e03e0d54f03845c8197d0291253cdbedfb1cb46b13c2c117554a9f4c" +checksum = "a08173bc88b7955d1b3145aa561539096c421ac8debde8cbc3612ec635fee29b" [[package]] name = "lock_api" -version = "0.4.9" +version = "0.4.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "435011366fe56583b16cf956f9df0095b405b82d76425bc8981c0e22e60ec4df" +checksum = "c1cc9717a20b1bb222f333e6a92fd32f7d8a18ddc5a3191a11af45dcbf4dcd16" dependencies = [ "autocfg", "scopeguard", @@ -244,46 +194,69 @@ dependencies = [ [[package]] name = "log" -version = "0.4.17" +version = "0.4.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "abb12e687cfb44aa40f41fc3978ef76448f9b6038cad6aef4259d3c095a2382e" -dependencies = [ - "cfg-if", -] +checksum = "b5e6163cb8c49088c2c36f57875e58ccd8c87c7427f7fbd50ea6710b2f3f2e8f" [[package]] name = "memchr" -version = "2.5.0" +version = "2.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2dffe52ecf27772e601905b7522cb4ef790d2cc203488bbd0e2fe85fcb74566d" +checksum = "f665ee40bc4a3c5590afb1e9677db74a508659dfd71e126420da8274909a0167" + +[[package]] +name = "miniz_oxide" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7810e0be55b428ada41041c41f32c9f1a42817901b4ccf45fa3d4b6561e74c7" +dependencies = [ + "adler", +] [[package]] name = "mio" -version = "0.8.6" +version = "0.8.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5b9d9a46eff5b4ff64b45a9e316a6d1e0bc719ef429cbec4dc630684212bfdf9" +checksum = "927a765cd3fc26206e66b296465fa9d3e5ab003e651c1b3c060e7956d96b19d2" dependencies = [ "libc", - "log", "wasi", - "windows-sys 0.45.0", + "windows-sys", +] + +[[package]] +name = "num-traits" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39e3200413f237f41ab11ad6d161bc7239c84dcb631773ccd7de3dfe4b5c267c" +dependencies = [ + "autocfg", ] [[package]] name = "num_cpus" -version = "1.15.0" +version = "1.16.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0fac9e2da13b5eb447a6ce3d392f23a29d8694bff781bf03a16cd9ac8697593b" +checksum = "4161fcb6d602d4d2081af7c3a45852d875a03dd337a6bfdd6e06407b61342a43" dependencies = [ - "hermit-abi 0.2.6", + "hermit-abi", "libc", ] +[[package]] +name = "object" +version = "0.32.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9cf5f9dd3933bd50a9e1f149ec995f39ae2c496d31fd772c1fd45ebc27e902b0" +dependencies = [ + "memchr", +] + [[package]] name = "once_cell" -version = "1.17.1" +version = "1.18.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b7e5500299e16ebb147ae15a00a942af264cf3688f47923b8fc2cd5858f23ad3" +checksum = "dd8b5dd2ae5ed71462c540258bedcb51965123ad7e7ccf4b9a8cafaa4a63576d" [[package]] name = "parking_lot" @@ -297,55 +270,67 @@ dependencies = [ [[package]] name = "parking_lot_core" -version = "0.9.7" +version = "0.9.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9069cbb9f99e3a5083476ccb29ceb1de18b9118cafa53e90c9551235de2b9521" +checksum = "93f00c865fe7cabf650081affecd3871070f26767e7b2070a3ffae14c654b447" dependencies = [ "cfg-if", "libc", "redox_syscall", "smallvec", - "windows-sys 0.45.0", + "windows-targets", ] [[package]] name = "pin-project-lite" -version = "0.2.9" +version = "0.2.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e0a7ae3ac2f1173085d398531c705756c94a4c56843785df85a60c1a0afac116" +checksum = "8afb450f006bf6385ca15ef45d71d2288452bc3683ce2e2cacc0d18e4be60b58" [[package]] name = "proc-macro2" -version = "1.0.56" +version = "1.0.69" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2b63bdb0cd06f1f4dedf69b254734f9b45af66e4a031e42a7480257d9898b435" +checksum = "134c189feb4956b20f6f547d2cf727d4c0fe06722b20a0eec87ed445a97f92da" dependencies = [ "unicode-ident", ] [[package]] name = "quote" -version = "1.0.26" +version = "1.0.33" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4424af4bf778aae2051a77b60283332f386554255d722233d09fbfc7e30da2fc" +checksum = "5267fca4496028628a95160fc423a33e8b2e6af8a5302579e322e4b520293cae" dependencies = [ "proc-macro2", ] [[package]] name = "redox_syscall" -version = "0.2.16" +version = "0.3.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fb5a58c1855b4b6819d59012155603f0b22ad30cad752600aadfcb695265519a" +checksum = "567664f262709473930a4bf9e51bf2ebf3348f2e748ccc50dea20646858f8f29" dependencies = [ "bitflags 1.3.2", ] [[package]] name = "regex" -version = "1.7.1" +version = "1.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "380b951a9c5e80ddfd6136919eef32310721aa4aacd4889a8d39124b026ab343" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "48aaa5748ba571fb95cd2c85c09f629215d3a6ece942baa100950af03a34f733" +checksum = "5f804c7828047e88b2d32e2d7fe5a105da8ee3264f01902f796c8e067dc2483f" dependencies = [ "aho-corasick", "memchr", @@ -354,29 +339,21 @@ dependencies = [ [[package]] name = "regex-syntax" -version = "0.6.28" +version = "0.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "456c603be3e8d448b072f410900c09faf164fbce2d480456f50eea6e25f9c848" +checksum = "c08c74e62047bb2de4ff487b251e4a92e24f48745648451635cec7d591162d9f" [[package]] -name = "rustix" -version = "0.37.7" +name = "rustc-demangle" +version = "0.1.23" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2aae838e49b3d63e9274e1c01833cc8139d3fec468c3b84688c628f44b1ae11d" -dependencies = [ - "bitflags 1.3.2", - "errno", - "io-lifetimes", - "libc", - "linux-raw-sys", - "windows-sys 0.45.0", -] +checksum = "d626bb9dae77e28219937af045c257c28bfd3f69333c512553507f5f9798cb76" [[package]] name = "scopeguard" -version = "1.1.0" +version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d29ab0c6d3fc0ee92fe66e2d99f700eab17a8d57d1c1d3b748380fb20baa78cd" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" [[package]] name = "signal-hook-registry" @@ -389,42 +366,25 @@ dependencies = [ [[package]] name = "smallvec" -version = "1.10.0" +version = "1.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a507befe795404456341dfab10cef66ead4c041f62b8b11bbb92bffe5d0953e0" +checksum = "942b4a808e05215192e39f4ab80813e599068285906cc91aa64f923db842bd5a" [[package]] name = "socket2" -version = "0.4.9" +version = "0.5.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "64a4a911eed85daf18834cfaa86a79b7d266ff93ff5ba14005426219480ed662" +checksum = "4031e820eb552adee9295814c0ced9e5cf38ddf1e8b7d566d6de8e2538ea989e" dependencies = [ "libc", - "winapi", + "windows-sys", ] -[[package]] -name = "strsim" -version = "0.10.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "73473c0e59e6d5812c5dfe2a064a6444949f089e20eec9a2e5506596494e4623" - [[package]] name = "syn" -version = "1.0.109" +version = "2.0.38" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" -dependencies = [ - "proc-macro2", - "quote", - "unicode-ident", -] - -[[package]] -name = "syn" -version = "2.0.15" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a34fcf3e8b60f57e6a14301a2e916d323af98b0ea63c599441eec8558660c822" +checksum = "e96b79aaa137db8f61e26363a0c9b47d8b4ec75da28b7d1d614c2303e232408b" dependencies = [ "proc-macro2", "quote", @@ -433,14 +393,13 @@ dependencies = [ [[package]] name = "tokio" -version = "1.26.0" +version = "1.33.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "03201d01c3c27a29c8a5cee5b55a93ddae1ccf6f08f65365c2c918f8c1b76f64" +checksum = "4f38200e3ef7995e5ef13baec2f432a6da0aa9ac495b2c0e8f3b7eec2c92d653" dependencies = [ - "autocfg", + "backtrace", "bytes", "libc", - "memchr", "mio", "num_cpus", "parking_lot", @@ -448,31 +407,25 @@ dependencies = [ "signal-hook-registry", "socket2", "tokio-macros", - "windows-sys 0.45.0", + "windows-sys", ] [[package]] name = "tokio-macros" -version = "1.8.2" +version = "2.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d266c00fde287f55d3f1c3e96c500c362a2b8c695076ec180f27918820bc6df8" +checksum = "630bdcf245f78637c13ec01ffae6187cca34625e8c63150d424b59e55af2675e" dependencies = [ "proc-macro2", "quote", - "syn 1.0.109", + "syn", ] [[package]] name = "unicode-ident" -version = "1.0.8" +version = "1.0.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e5464a87b239f13a63a501f2701565754bae92d243d4bb7eb12f6d57d2269bf4" - -[[package]] -name = "utf8parse" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "711b9620af191e0cdc7468a8d14e709c3dcdb115b36f838e601583af800a370a" +checksum = "3354b9ac3fae1ff6755cb6db53683adb661634f67557942dea4facebec0fee4b" [[package]] name = "wasi" @@ -481,155 +434,130 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9c8d87e72b64a3b4db28d11ce29237c246188f4f51057d65a7eab63b7987e423" [[package]] -name = "winapi" -version = "0.3.9" +name = "wasm-bindgen" +version = "0.2.87" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +checksum = "7706a72ab36d8cb1f80ffbf0e071533974a60d0a308d01a5d0375bf60499a342" dependencies = [ - "winapi-i686-pc-windows-gnu", - "winapi-x86_64-pc-windows-gnu", + "cfg-if", + "wasm-bindgen-macro", ] [[package]] -name = "winapi-i686-pc-windows-gnu" -version = "0.4.0" +name = "wasm-bindgen-backend" +version = "0.2.87" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" +checksum = "5ef2b6d3c510e9625e5fe6f509ab07d66a760f0885d858736483c32ed7809abd" +dependencies = [ + "bumpalo", + "log", + "once_cell", + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-shared", +] [[package]] -name = "winapi-x86_64-pc-windows-gnu" -version = "0.4.0" +name = "wasm-bindgen-macro" +version = "0.2.87" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" +checksum = "dee495e55982a3bd48105a7b947fd2a9b4a8ae3010041b9e0faab3f9cd028f1d" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] [[package]] -name = "windows-sys" -version = "0.45.0" +name = "wasm-bindgen-macro-support" +version = "0.2.87" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "75283be5efb2831d37ea142365f009c02ec203cd29a3ebecbc093d52315b66d0" +checksum = "54681b18a46765f095758388f2d0cf16eb8d4169b639ab575a8f5693af210c7b" dependencies = [ - "windows-targets 0.42.2", + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-backend", + "wasm-bindgen-shared", ] [[package]] -name = "windows-sys" -version = "0.48.0" +name = "wasm-bindgen-shared" +version = "0.2.87" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9" -dependencies = [ - "windows-targets 0.48.0", -] +checksum = "ca6ad05a4870b2bf5fe995117d3728437bd27d7cd5f06f13c17443ef369775a1" [[package]] -name = "windows-targets" -version = "0.42.2" +name = "windows-core" +version = "0.51.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e5180c00cd44c9b1c88adb3693291f1cd93605ded80c250a75d472756b4d071" +checksum = "f1f8cf84f35d2db49a46868f947758c7a1138116f7fac3bc844f43ade1292e64" dependencies = [ - "windows_aarch64_gnullvm 0.42.2", - "windows_aarch64_msvc 0.42.2", - "windows_i686_gnu 0.42.2", - "windows_i686_msvc 0.42.2", - "windows_x86_64_gnu 0.42.2", - "windows_x86_64_gnullvm 0.42.2", - "windows_x86_64_msvc 0.42.2", + "windows-targets", ] [[package]] -name = "windows-targets" +name = "windows-sys" version = "0.48.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7b1eb6f0cd7c80c79759c929114ef071b87354ce476d9d94271031c0497adfd5" +checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9" dependencies = [ - "windows_aarch64_gnullvm 0.48.0", - "windows_aarch64_msvc 0.48.0", - "windows_i686_gnu 0.48.0", - "windows_i686_msvc 0.48.0", - "windows_x86_64_gnu 0.48.0", - "windows_x86_64_gnullvm 0.48.0", - "windows_x86_64_msvc 0.48.0", + "windows-targets", ] [[package]] -name = "windows_aarch64_gnullvm" -version = "0.42.2" +name = "windows-targets" +version = "0.48.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "597a5118570b68bc08d8d59125332c54f1ba9d9adeedeef5b99b02ba2b0698f8" +checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c" +dependencies = [ + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", +] [[package]] name = "windows_aarch64_gnullvm" -version = "0.48.0" +version = "0.48.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "91ae572e1b79dba883e0d315474df7305d12f569b400fcf90581b06062f7e1bc" +checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8" [[package]] name = "windows_aarch64_msvc" -version = "0.42.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e08e8864a60f06ef0d0ff4ba04124db8b0fb3be5776a5cd47641e942e58c4d43" - -[[package]] -name = "windows_aarch64_msvc" -version = "0.48.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b2ef27e0d7bdfcfc7b868b317c1d32c641a6fe4629c171b8928c7b08d98d7cf3" - -[[package]] -name = "windows_i686_gnu" -version = "0.42.2" +version = "0.48.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c61d927d8da41da96a81f029489353e68739737d3beca43145c8afec9a31a84f" +checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc" [[package]] name = "windows_i686_gnu" -version = "0.48.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "622a1962a7db830d6fd0a69683c80a18fda201879f0f447f065a3b7467daa241" - -[[package]] -name = "windows_i686_msvc" -version = "0.42.2" +version = "0.48.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "44d840b6ec649f480a41c8d80f9c65108b92d89345dd94027bfe06ac444d1060" +checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e" [[package]] name = "windows_i686_msvc" -version = "0.48.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4542c6e364ce21bf45d69fdd2a8e455fa38d316158cfd43b3ac1c5b1b19f8e00" - -[[package]] -name = "windows_x86_64_gnu" -version = "0.42.2" +version = "0.48.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8de912b8b8feb55c064867cf047dda097f92d51efad5b491dfb98f6bbb70cb36" +checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406" [[package]] name = "windows_x86_64_gnu" -version = "0.48.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ca2b8a661f7628cbd23440e50b05d705db3686f894fc9580820623656af974b1" - -[[package]] -name = "windows_x86_64_gnullvm" -version = "0.42.2" +version = "0.48.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "26d41b46a36d453748aedef1486d5c7a85db22e56aff34643984ea85514e94a3" +checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e" [[package]] name = "windows_x86_64_gnullvm" -version = "0.48.0" +version = "0.48.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7896dbc1f41e08872e9d5e8f8baa8fdd2677f29468c4e156210174edc7f7b953" +checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc" [[package]] name = "windows_x86_64_msvc" -version = "0.42.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9aec5da331524158c6d1a4ac0ab1541149c0b9505fde06423b02f5ef0106b9f0" - -[[package]] -name = "windows_x86_64_msvc" -version = "0.48.0" +version = "0.48.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1a515f5799fe4961cb532f983ce2b23082366b898e52ffbce459c86f67c8378a" +checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538" diff --git a/Cargo.toml b/Cargo.toml index 53e1143..308a07d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -12,6 +12,4 @@ tokio = { version = "1.26.0", features = ["full"] } bytes = "1.4.0" regex = "1.7.1" bitflags = "2.2.1" - -[dev-dependencies] -clap = { version = "4.2.7", features = ["derive"] } +chrono = "0.4.31" diff --git a/README.md b/README.md index f731aa4..e417405 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ ![closur logo](./assets/logo.png) -closur: osu!bancho client library for Rust +closur: osu!bancho IRC library for Rust ## Usage diff --git a/examples/echobot.rs b/examples/echobot.rs index 9754f04..cab4759 100644 --- a/examples/echobot.rs +++ b/examples/echobot.rs @@ -1,35 +1,28 @@ -use closur::bancho::{Client, ClientOptions, Event}; +use closur::bancho::{Client, ClientOptionsBuilder, EventKind}; type Result = std::result::Result>; -// This example shows how to create a simple echo bot. +/// This example shows how to create a simple echo bot. #[tokio::main] async fn main() -> Result<()> { - // "BANCHO_USERNAME" and "BANCHO_PASSWORD" are **compile-time** environment variables. - // You need to set username and password, e.g. running - // `BANCHO_USERNAME=username BANCHO_PASSWORD=password cargo run --example echobot` - let options = ClientOptions::default() - .username(env!("BANCHO_USERNAME").to_string()) - .password(env!("BANCHO_PASSWORD").to_string()); + let options = ClientOptionsBuilder::default() + .username(std::env::var("BANCHO_USERNAME").unwrap()) + .password(std::env::var("BANCHO_PASSWORD").unwrap()) + .build(); - let mut client = Client::new(options).await?; - let mut writer = client.writer(); - client.auth().await?; - - println!("[ECHOBOT] Connected and authenticated, waiting for events..."); - let mut subscriber = client.events(); + let client = Client::new(options).await?; + let operator = client.operator(); + let mut subscriber = operator.subscribe(); loop { let event = subscriber.recv().await?; - match event { - Event::Message { from, body, .. } => { - println!("[PM] {}: {}", from.to_string(), body); - if body.starts_with("!echo") { - let message = body.trim_start_matches("!echo").trim(); - println!("[EVENT] Sent echo message to {}: {}", from, message); - writer.send_private_chat(from, message).await?; - } - }, - _ => {}, + match event.kind() { + EventKind::Message(m) => { + println!("[PM] {}: {}", m.user(), m.content()); + operator.send_user_chat(m.user(), m.content()).await?; + } + EventKind::Closed => break, + _ => {} } } -} \ No newline at end of file + Ok(()) +} diff --git a/examples/tbot.rs b/examples/tbot.rs index f3eb442..d7ab6e8 100644 --- a/examples/tbot.rs +++ b/examples/tbot.rs @@ -1,68 +1,70 @@ -use closur::bancho::{bot, multiplayer, Channel, Chat, Client, ClientOptions, Event}; +use closur::bancho::bot::command::{MpMake, MpPassword, MpSettings, MpStart}; +use closur::bancho::multiplayer; +use closur::bancho::{ChatBuilder, Client, ClientOptionsBuilder, EventKind}; type Result = std::result::Result>; -// This example shows how to create a simple lobby bot. +/// This example shows how to create a simple lobby bot. #[tokio::main] async fn main() -> Result<()> { - // "BANCHO_USERNAME" and "BANCHO_PASSWORD" are **compile-time** environment variables. - // You need to set username and password, e.g. running - // `BANCHO_USERNAME=username BANCHO_PASSWORD=password cargo run --example tbot` - let options = ClientOptions::default() - .username(env!("BANCHO_USERNAME").to_string()) - .password(env!("BANCHO_PASSWORD").to_string()); + let options = ClientOptionsBuilder::default() + .username(std::env::var("BANCHO_USERNAME").unwrap()) + .password(std::env::var("BANCHO_PASSWORD").unwrap()) + .build(); - let mut client = Client::new(options).await?; - let mut writer = client.writer(); + let client = Client::new(options).await?; + let operator = client.operator(); client.auth().await?; - println!("[TBOT] Connected and authenticated, waiting for events..."); let title = "TBOT LOBBY".to_string(); let password = "tbot".to_string(); - let response = writer - .send_private_bot_command(bot::Command::Make(title.clone())) + let response = operator + .send_bot_command(None, MpMake(title.clone())) .await?; - let response = response.make(); + let match_id = response.body().unwrap().id(); - let mut lobby = client.join_match(response.id()).await?; - lobby - .send_bot_command(bot::Command::Password(password.clone())) - .await?; + let lobby = operator.join_match(match_id).await?.unwrap(); + println!("[TBOT] Create multiplayer game ID: {}", match_id); + lobby.send_bot_command(MpPassword(None)).await?; println!("[TBOT] Set lobby password to \"{}\"", password); - // This event subscriber will only receive events from the lobby. - let mut subscriber = lobby.events(); + let mut subscriber = operator.subscribe(); loop { let event = subscriber.recv().await?; - match event { - Event::Message { from, body, .. } => { - println!("[LOBBY] {}: {}", from.to_string(), body); + match event.kind() { + EventKind::Message(m) if event.relates_to_match(match_id) => { + println!("[LOBBY] {}: {}", m.user(), m.content()); + let body = m.content(); if body.starts_with("!info") { lobby.send_chat("TBOT from closur examples").await?; } else if body.starts_with("!echo") { let message = body.trim_start_matches("!echo").trim(); lobby.send_chat(message).await?; } else if body.starts_with("!start") { - lobby.send_bot_command(bot::Command::Start(None)).await?; + lobby.send_bot_command(MpStart(None)).await?; } else if body.starts_with("!invite") { - let message = Chat::new("Join my multiplayer game: ") - .append_link(title.as_str(), lobby.invite_link(None).as_str()) - .to_string(); - lobby.send_chat(message.as_str()).await?; + lobby + .send_chat( + ChatBuilder::new() + .push("Join my multiplayer game: ") + .push_link(&title, lobby.invite_url()) + .chat(), + ) + .await?; } else if body.starts_with("!test") { - // Fetch settings for current lobby - let response = lobby.send_bot_command(bot::Command::Settings).await?; - let settings = response.settings(); + let response = lobby.send_bot_command(MpSettings).await?; + let settings = response.body().unwrap(); - let slots = settings.valid_slots(); - let with_highlight = slots - .iter() - .map(|x| x.user().name()) + let with_highlight = settings + .slots() + .valid_slots() + .map(|(_, x)| x.user().name()) .collect::>() .join(", "); - let without_highlight = slots - .iter() - .map(|x| x.user().name_without_highlight()) + let without_highlight = settings + .slots() + .valid_slots() + .map(|(_, x)| x.user().name_without_highlight()) .collect::>() .join(", "); @@ -76,18 +78,21 @@ async fn main() -> Result<()> { .await?; } } - Event::Multiplayer(_, event) => match event { - multiplayer::Event::PlayerJoined { user, .. } => { - lobby - .send_chat( - format!("Hey, {}! This is TBOT lobby, have fun here.", user.name()) - .as_str(), - ) - .await?; + EventKind::Multiplayer(e) if event.relates_to_match(match_id) => { + use multiplayer::EventKind; + match e.kind() { + EventKind::PlayerJoined { user, .. } => { + lobby + .send_chat(format!( + "Hey, {}! This is TBOT lobby, have fun here.", + user.name() + )) + .await?; + } + _ => {} } - _ => {} - }, - Event::Part(_) => { + } + EventKind::Part(_) | EventKind::Closed => { println!("See you next time."); break; } diff --git a/resources/tests/banchobot/README.md b/resources/tests/banchobot/README.md new file mode 100644 index 0000000..37a88f7 --- /dev/null +++ b/resources/tests/banchobot/README.md @@ -0,0 +1,3 @@ +# Test Cases for BanchoBot + +This folder stores external IRC messages for BanchoBot tests. diff --git a/rustfmt.toml b/rustfmt.toml new file mode 100644 index 0000000..b7150b0 --- /dev/null +++ b/rustfmt.toml @@ -0,0 +1,6 @@ +edition = "2021" +max_width = 100 +hard_tabs = false +tab_spaces = 4 +newline_style = "Auto" +match_arm_leading_pipes = "Never" \ No newline at end of file diff --git a/src/bancho.rs b/src/bancho.rs index a874e66..5ace420 100644 --- a/src/bancho.rs +++ b/src/bancho.rs @@ -1,16 +1,398 @@ pub mod bot; +mod cache; pub mod irc; pub mod multiplayer; +use regex::Regex; use tokio::net::TcpStream; use tokio::sync::{broadcast, mpsc, oneshot}; use tokio::task::JoinHandle; use tokio::time::{self, Duration, Instant}; -use core::fmt; -use std::collections::HashMap; +use std::borrow::Borrow; +use std::collections::{HashMap, VecDeque}; +use std::fmt; +use std::pin::Pin; +use std::str::FromStr; -use self::irc::{ToMessageTarget, Transport}; +use self::cache::UserCache; +use self::irc::Transport; +use self::multiplayer::{MatchId, MatchInternalId}; + +#[derive(Debug)] +pub enum TrackVerdict { + Skip, + Terminate, + Accept, + Body(T), + Err(E), +} + +pub trait IrcCommand { + type Body; + type Error; + fn command(&self) -> Option { + None + } + fn commands(&self) -> Vec { + Vec::new() + } + fn tracks_message( + &self, + context: &mut IrcContext, + message: irc::Message, + ) -> TrackVerdict; +} + +#[derive(Debug)] +pub struct IrcContext<'a> { + handle: &'a Client, + history: Vec, +} + +#[derive(Debug)] +pub struct IrcResponse { + body: StdResult, +} + +impl IrcResponse { + pub fn body(&self) -> StdResult<&T::Body, &T::Error> { + self.body.as_ref() + } +} + +impl<'a> IrcContext<'a> { + fn new(handle: &'a Client) -> Self { + Self { + handle, + history: Vec::new(), + } + } + pub fn options(&self) -> &ClientOptions { + &self.handle.options + } + pub fn history(&self) -> &[irc::Message] { + &self.history + } + pub fn push(&mut self, message: impl Into) { + self.history.push(message.into()); + } +} + +#[derive(Debug)] +pub struct Auth { + username: String, + password: Option, +} + +impl IrcCommand for Auth { + type Body = (); + type Error = String; + fn commands(&self) -> Vec { + let mut commands = match &self.password { + Some(password) => vec![irc::Command::PASS(password.clone())], + None => Vec::new(), + }; + commands.extend_from_slice(&[ + irc::Command::USER { + username: self.username.clone(), + mode: "0".to_string(), + realname: self.username.clone(), + }, + irc::Command::NICK(self.username.clone()), + ]); + commands + } + fn tracks_message( + &self, + _context: &mut IrcContext, + message: irc::Message, + ) -> TrackVerdict { + if message.prefix.map(|p| p.is_server()).unwrap_or_default() { + match message.command { + irc::Command::Response(code, mut params) => match code { + irc::Response::RPL_WELCOME => TrackVerdict::Body(()), + irc::Response::ERR_PASSWDMISMATCH => { + TrackVerdict::Err(params.pop().unwrap_or_default()) + } + _ => TrackVerdict::Skip, + }, + _ => TrackVerdict::Skip, + } + } else { + TrackVerdict::Skip + } + } +} + +#[derive(Debug)] +pub struct Join { + channel: Channel, +} + +impl IrcCommand for Join { + type Error = JoinError; + type Body = ChannelInfo; + fn command(&self) -> Option { + Some(irc::Command::JOIN(self.channel.to_string())) + } + fn tracks_message( + &self, + context: &mut IrcContext, + message: irc::Message, + ) -> TrackVerdict { + if message + .prefix + .as_ref() + .map(|p| { + p.is_user() && { User::irc_normalize(&context.options().username).eq(p.name()) } + }) + .unwrap_or_default() + { + match &message.command { + irc::Command::JOIN(c) if self.channel.to_string().eq(c) => { + context.push(message); + TrackVerdict::Accept + } + _ => TrackVerdict::Skip, + } + } else if message + .prefix + .as_ref() + .map(|p| p.is_server()) + .unwrap_or_default() + { + match message.command { + irc::Command::Response(irc::Response::ERR_NOSUCHCHANNEL, mut params) + if self.channel.to_string().eq(¶ms[1]) => + { + TrackVerdict::Err(JoinError::NotFound(params.pop().unwrap())) + } + irc::Command::Response( + irc::Response::RPL_TOPIC | irc::Response::RPL_TOPICWHOTIME, + ref params, + ) if self.channel.to_string().eq(¶ms[1]) => { + context.push(message); + TrackVerdict::Accept + } + irc::Command::Response(irc::Response::RPL_NAMREPLY, ref params) + if self.channel.to_string().eq(¶ms[2]) => + { + context.push(message); + TrackVerdict::Accept + } + irc::Command::Response(irc::Response::RPL_ENDOFNAMES, ref params) + if self.channel.to_string().eq(¶ms[1]) => + { + context.push(message); + let info = ChannelInfo::compose(&self.channel, context.history()); + TrackVerdict::Body(info) + } + _ => TrackVerdict::Skip, + } + } else { + TrackVerdict::Skip + } + } +} + +#[derive(Debug, Clone)] +pub enum JoinError { + NotFound(String), +} + +impl fmt::Display for JoinError { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + match self { + JoinError::NotFound(channel) => write!(f, "channel not found: {}", channel), + } + } +} +impl StdError for JoinError {} + +#[derive(Debug)] +pub struct Part { + channel: Channel, +} + +impl IrcCommand for Part { + type Body = (); + type Error = PartError; + fn command(&self) -> Option { + Some(irc::Command::PART(self.channel.to_string())) + } + fn tracks_message( + &self, + context: &mut IrcContext, + message: irc::Message, + ) -> TrackVerdict { + if message + .prefix + .as_ref() + .map(|p| { + p.is_user() && { User::irc_normalize(&context.options().username).eq(p.name()) } + }) + .unwrap_or_default() + { + match &message.command { + irc::Command::PART(c) if self.channel.to_string().eq(c) => TrackVerdict::Body(()), + _ => TrackVerdict::Skip, + } + } else if message + .prefix + .as_ref() + .map(|p| p.is_server()) + .unwrap_or_default() + { + match message.command { + irc::Command::Response(irc::Response::ERR_NOSUCHCHANNEL, mut params) + if self.channel.to_string().eq(¶ms[1]) => + { + TrackVerdict::Err(PartError::NotFound(params.pop().unwrap())) + } + _ => TrackVerdict::Skip, + } + } else { + TrackVerdict::Skip + } + } +} + +#[derive(Debug, Clone)] +pub enum PartError { + NotFound(String), +} + +impl fmt::Display for PartError { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + match self { + PartError::NotFound(channel) => write!(f, "channel not found: {}", channel), + } + } +} +impl StdError for PartError {} + +#[derive(Debug)] +pub struct Whois { + username: String, +} + +impl IrcCommand for Whois { + type Error = WhoisError; + type Body = User; + fn command(&self) -> Option { + Some(irc::Command::WHOIS(self.username.to_string())) + } + fn tracks_message( + &self, + context: &mut IrcContext, + message: irc::Message, + ) -> TrackVerdict { + if message.prefix().map(|p| p.is_server()).unwrap_or_default() { + match &message.command { + irc::Command::Response(irc::Response::RPL_WHOISUSER, ref params) + if self.username.eq(¶ms[1]) => + { + let id = Matcher::whois_user_id(¶ms[2]).unwrap(); + context.push(message); + TrackVerdict::Body(User::new(id, &self.username)) + } + irc::Command::Response(irc::Response::RPL_ENDOFNAMES, ref params) + if self.username.eq(¶ms[1]) => + { + context.push(message); + TrackVerdict::Err(WhoisError::NotFound(self.username.clone())) + } + _ => TrackVerdict::Skip, + } + } else { + TrackVerdict::Skip + } + } +} + +#[derive(Debug, Clone)] +pub enum WhoisError { + NotFound(String), +} + +impl fmt::Display for WhoisError { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + match self { + WhoisError::NotFound(username) => write!(f, "user not found: {}", username), + } + } +} +impl StdError for WhoisError {} + +#[derive(Debug, Clone)] +pub struct ChannelInfo { + channel: Channel, + topic: String, + match_internal_id: Option, + time: chrono::DateTime, + names: Vec, +} + +impl ChannelInfo { + fn compose(channel: impl Borrow, messages: &[irc::Message]) -> Self { + let mut info = ChannelInfo { + channel: channel.borrow().clone(), + topic: String::new(), + match_internal_id: None, + time: chrono::DateTime::default(), + names: Vec::new(), + }; + for message in messages { + match &message.command { + irc::Command::Response(irc::Response::RPL_TOPIC, params) => { + info.topic = params.last().cloned().unwrap_or_default(); + if channel.borrow().is_multiplayer() { + info.match_internal_id = Matcher::topic_game_id(&info.topic); + } + } + irc::Command::Response(irc::Response::RPL_TOPICWHOTIME, params) => { + let timestamp: i64 = params + .last() + .cloned() + .unwrap_or_default() + .parse() + .unwrap_or_default(); + info.time = chrono::DateTime::from_naive_utc_and_offset( + chrono::NaiveDateTime::from_timestamp_millis(timestamp * 1000).unwrap(), + chrono::Utc, + ); + } + irc::Command::Response(irc::Response::RPL_NAMREPLY, params) => info.names.extend( + params + .last() + .cloned() + .unwrap_or_default() + .split_terminator(' ') + .map(|s| User::name_only(s.trim().trim_start_matches("@+"))), + ), + _ => {} + }; + } + info.names.shrink_to_fit(); + info + } + pub fn channel(&self) -> &Channel { + &self.channel + } + pub fn users(&self) -> &[User] { + &self.names + } + pub fn topic(&self) -> &str { + &self.topic + } + pub fn created_at(&self) -> chrono::DateTime { + self.time + } + pub fn match_internal_id(&self) -> MatchInternalId { + self.match_internal_id.unwrap_or_default() + } +} use crate::{ error::{ConversionError, StdError}, @@ -23,9 +405,8 @@ type Result = StdResult; #[derive(Debug)] pub enum Error { + PingerTimeout, AuthError(String), - Transport(TransportError), - OperationTimeout, Irc(irc::Error), Io(tokio::io::Error), } @@ -35,11 +416,10 @@ impl StdError for Error {} impl fmt::Display for Error { fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { match self { - Error::AuthError(e) => write!(f, "Auth error: {}", e), - Error::Transport(e) => write!(f, "Transport error: {}", e), - Error::OperationTimeout => write!(f, "Operation timeout"), - Error::Irc(e) => write!(f, "IRC error: {}", e), - Error::Io(e) => write!(f, "IO error: {}", e), + Error::PingerTimeout => write!(f, "pinger timeout"), + Error::AuthError(e) => write!(f, "authenticate error: {}", e), + Error::Irc(e) => write!(f, "irc error: {}", e), + Error::Io(e) => write!(f, "io error: {}", e), } } } @@ -56,60 +436,323 @@ impl From for Error { } } -impl From for Error { - fn from(e: TransportError) -> Self { - Error::Transport(e) - } +#[derive(Debug)] +pub enum OperatorError { + Timeout, + TrackerLimitExceeded, + Cancelled, + Bot(bot::CommandError), + Queue(QueueError), } #[derive(Debug)] -pub enum TransportError { - PingerTimeout, - ConnectionClosed, +pub enum QueueError { + SendError(T), + RecvClosed, + RecvLagged(u64), +} + +impl fmt::Display for OperatorError { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + match self { + OperatorError::Timeout => write!(f, "operation timeout"), + OperatorError::TrackerLimitExceeded => write!(f, "message tracker limit exceeded"), + OperatorError::Cancelled => write!(f, "operation cancelled"), + OperatorError::Bot(e) => write!(f, "bot error: {}", e), + OperatorError::Queue(e) => write!(f, "queue error: {}", e), + } + } } -impl StdError for TransportError {} +impl StdError for OperatorError {} -impl fmt::Display for TransportError { +impl fmt::Display for QueueError { fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { match self { - TransportError::PingerTimeout => write!(f, "Pinger timeout"), - TransportError::ConnectionClosed => write!(f, "Connection closed"), + QueueError::SendError(_) => write!(f, "send error"), + QueueError::RecvClosed => write!(f, "receive channel closed"), + QueueError::RecvLagged(n) => write!(f, "receive channel lagged: skipped {}", n), + } + } +} + +impl StdError for QueueError {} + +impl From> for QueueError { + fn from(e: mpsc::error::SendError) -> Self { + Self::SendError(e.0) + } +} + +impl From for QueueError { + fn from(e: broadcast::error::RecvError) -> Self { + match e { + broadcast::error::RecvError::Closed => Self::RecvClosed, + broadcast::error::RecvError::Lagged(n) => Self::RecvLagged(n), + } + } +} + +impl From> for OperatorError { + fn from(e: mpsc::error::SendError) -> Self { + Self::Queue(e.into()) + } +} + +impl From for OperatorError { + fn from(e: broadcast::error::RecvError) -> Self { + Self::Queue(e.into()) + } +} + +impl From for OperatorError { + fn from(e: bot::CommandError) -> Self { + Self::Bot(e) + } +} + +#[derive(Debug, Clone, PartialEq)] +pub struct Message { + sender: User, + channel: Option, + content: String, +} + +impl Message { + /// Returns the sender of the message. + pub fn user(&self) -> &User { + &self.sender + } + /// Returns the channel where the message is sent. + pub fn channel(&self) -> Option<&Channel> { + self.channel.as_ref() + } + /// Returns the content of the message. + pub fn content(&self) -> &str { + &self.content + } + /// Checks if the message is sent directly. + pub fn is_private(&self) -> bool { + self.channel.is_none() + } + /// Checks if the message is sent in a channel. + pub fn is_public(&self) -> bool { + self.channel.is_some() + } +} + +#[derive(Debug, Clone)] +pub struct ChatBuilder(String); + +impl ChatBuilder { + pub fn new() -> Self { + Self(String::new()) + } + pub fn push(&mut self, content: impl AsRef) -> &mut Self { + self.0.push_str(content.as_ref()); + self + } + pub fn push_link(&mut self, title: impl AsRef, url: impl AsRef) -> &mut Self { + use std::fmt::Write; + write!(self.0, "({})[{}]", title.as_ref(), url.as_ref()).unwrap(); + self + } + pub fn chat(&self) -> String { + self.0.clone() + } + pub fn action(&self) -> String { + format!("\x01ACTION {}\x01", self.0) + } +} + +#[derive(Debug, Clone)] +pub struct Event { + kind: EventKind, + instant: Instant, + time: chrono::DateTime, +} + +impl Event { + pub fn kind(&self) -> &EventKind { + &self.kind + } + pub fn instant(&self) -> Instant { + self.instant + } + pub fn time(&self) -> chrono::DateTime { + self.time + } + pub fn relates_to_channel(&self, channel: &Channel) -> bool { + self.kind.relates_to_channel(channel) + } + pub fn relates_to_match(&self, match_id: MatchId) -> bool { + self.kind.relates_to_match(match_id) + } +} + +impl From for Event { + fn from(message: Message) -> Self { + Event { + kind: message.into(), + instant: Instant::now(), + time: chrono::Utc::now(), + } + } +} + +impl From for Event { + fn from(message: bot::Message) -> Self { + Event { + kind: message.into(), + instant: Instant::now(), + time: chrono::Utc::now(), + } + } +} + +impl From for Event { + fn from(event: multiplayer::Event) -> Self { + Event { + kind: event.into(), + instant: Instant::now(), + time: chrono::Utc::now(), + } + } +} + +impl From for Event { + fn from(kind: EventKind) -> Self { + Event { + kind, + instant: Instant::now(), + time: chrono::Utc::now(), + } + } +} + +#[derive(Debug, Clone)] +pub enum EventKind { + Quit(User), + Join(Channel), + Part(Channel), + Message(Message), + Bot(bot::Message), + + Multiplayer(multiplayer::Event), + + /// Maintenance alerts and countdown timers. + Maintenance(Option), + + /// The peer connection is closed. + Closed, +} + +impl EventKind { + pub fn relates_to_channel(&self, channel: &Channel) -> bool { + match self { + EventKind::Join(c) | EventKind::Part(c) => c == channel, + EventKind::Message(m) => m.channel() == Some(channel), + EventKind::Bot(m) => m.channel() == Some(channel), + EventKind::Multiplayer(m) => &m.channel() == channel, + _ => false, } } + pub fn relates_to_match(&self, match_id: MatchId) -> bool { + self.relates_to_channel(&Channel::Multiplayer(match_id)) + } +} + +impl From for EventKind { + fn from(message: Message) -> Self { + EventKind::Message(message) + } } -#[derive(Copy, Clone)] -pub enum Status { - Unspecific, - Disconnected, - Established, // TcpStream connected - Connected, // IRC connected +impl From for EventKind { + fn from(message: bot::Message) -> Self { + EventKind::Bot(message) + } +} + +impl From for EventKind { + fn from(event: multiplayer::Event) -> Self { + EventKind::Multiplayer(event) + } +} + +struct Matcher {} +impl Matcher { + // const MULTIPLAYER_CHANNEL_TOPIC: Regex = Regex::new(r"^multiplayer game #(\d+)$").unwrap(); + // const WHOIS_URL: Regex = Regex::new(r"^https?://osu.ppy.sh/u/(\d+)$").unwrap(); + fn topic_game_id(s: &str) -> Option { + let pattern = Regex::new(r"^multiplayer game #(\d+)$").unwrap(); + pattern + .captures(s) + .and_then(|c| c.get(1)) + .and_then(|m| m.as_str().parse().ok()) + } + fn whois_user_id(s: &str) -> Option { + let pattern = Regex::new(r"^https?://osu.ppy.sh/u/(\d+)$").unwrap(); + pattern + .captures(s) + .and_then(|c| c.get(1)) + .and_then(|m| m.as_str().parse().ok()) + } } #[derive(Debug, Clone)] pub struct ClientOptions { + // basic IRC options + /// Endpoint of IRC server, in form of `host:port` endpoint: String, + /// Username of IRC credentials username: String, + /// (Optional) Password of IRC credentials password: Option, + + /// BanchoBot user instance bot: User, + + /// Operation timeout operation_timeout: Duration, + /// Interval of sending IRC command `PING` to keepalive pinger_interval: Duration, + /// Timeout of receiving IRC command `PONG` to keepalive pinger_timeout: Duration, + + message_tracker_limit: usize, + // TODO: implement this + // ignore_irc_quit: bool, + + // TODO: implement this + // ignore BanchoBot's raw IRC messages + // ignore_irc_from_banchobot: bool, } impl ClientOptions { - pub fn endpoint(mut self, e: String) -> Self { - self.endpoint = e; - self + pub fn endpoint(&self) -> &str { + &self.endpoint } - pub fn username(mut self, u: String) -> Self { - self.username = u; - self + pub fn username(&self) -> &str { + &self.username } - pub fn password(mut self, p: String) -> Self { - self.password = Some(p); - self + pub fn password(&self) -> Option<&str> { + self.password.as_ref().map(|p| p.as_str()) + } + pub fn bot(&self) -> &User { + &self.bot + } + pub fn operation_timeout(&self) -> Duration { + self.operation_timeout + } + pub fn pinger_interval(&self) -> Duration { + self.pinger_interval + } + pub fn pinger_timeout(&self) -> Duration { + self.pinger_timeout + } + pub fn message_tracker_limit(&self) -> usize { + self.message_tracker_limit } } @@ -120,487 +763,649 @@ impl Default for ClientOptions { username: "".to_owned(), password: None, bot: User { - id: 3, + prefer_id: false, + id: Some(3), name: "BanchoBot".to_string(), + flags: UserFlags::BanchoBot, }, - // TODO: adjust timeout - operation_timeout: Duration::from_secs(5), + operation_timeout: Duration::from_secs(3), pinger_interval: Duration::from_secs(15), pinger_timeout: Duration::from_secs(30), + message_tracker_limit: 48, } } } -#[derive(Debug, Clone)] -pub struct Sender { - tx: mpsc::Sender, -} +#[derive(Debug, Default)] +pub struct ClientOptionsBuilder(ClientOptions); -impl Sender { - pub fn into_user_writer(self, user: User) -> UserSender { - UserSender { user, writer: self } +impl ClientOptionsBuilder { + pub fn new() -> Self { + Self::default() } - - pub fn into_channel_writer(self, channel: Channel) -> ChannelSender { - ChannelSender { - channel, - writer: self, - } + pub fn endpoint(&mut self, endpoint: String) -> &mut Self { + self.0.endpoint = endpoint; + self } - - // FIXME: all crate::Result<()> functions are unreliable, they only reflect the result of tx.send() - // and do not reflect the actual result of the operation. - pub async fn send_irc(&mut self, message: irc::Message) -> crate::Result<()> { - self.tx.send(Action::Raw(message)).await?; - Ok(()) + pub fn username(&mut self, username: String) -> &mut Self { + self.0.username = username; + self } - - pub async fn send_channel_chat(&mut self, channel: Channel, body: &str) -> crate::Result<()> { - self.tx - .send(Action::Chat { - target: channel.to_message_target(), - body: body.to_string(), - }) - .await?; - Ok(()) + pub fn password(&mut self, password: String) -> &mut Self { + self.0.password = Some(password); + self } - - pub async fn send_private_chat(&mut self, user: User, body: &str) -> crate::Result<()> { - self.tx - .send(Action::Chat { - target: user.to_message_target(), - body: body.to_string(), - }) - .await?; - Ok(()) + pub fn bot_user(&mut self, bot: User) -> &mut Self { + self.0.bot = bot; + self } - - pub async fn send_target_chat(&mut self, target: T, body: &str) -> crate::Result<()> - where - T: irc::ToMessageTarget, - { - self.tx - .send(Action::Chat { - target: target.to_message_target(), - body: body.to_string(), - }) - .await?; - Ok(()) + pub fn operation_timeout(&mut self, timeout: Duration) -> &mut Self { + self.0.operation_timeout = timeout; + self } - - pub async fn send_private_bot_command( - &mut self, - command: bot::Command, - ) -> crate::Result { - self.send_bot_command(None, command).await + pub fn pinger_interval(&mut self, timeout: Duration) -> &mut Self { + self.0.pinger_interval = timeout; + self } - - pub async fn send_channel_bot_command( - &mut self, - channel: Channel, - command: bot::Command, - ) -> crate::Result { - self.send_bot_command(Some(channel), command).await + pub fn pinger_timeout(&mut self, timeout: Duration) -> &mut Self { + self.0.pinger_timeout = timeout; + self } + pub fn build(&mut self) -> ClientOptions { + self.0.clone() + } +} - pub async fn send_bot_command( - &mut self, - channel: Option, - command: bot::Command, - ) -> crate::Result { - // Check if the command requires a channel to issue and ensure the channel is a multiplayer channel if provided. - if (channel.is_none() && command.requires_channel()) - || (channel.is_some() && !matches!(channel, Some(Channel::Multiplayer(_)))) - { - return Err("This command requires a multiplayer channel.".into()); - } +#[derive(Debug)] +pub struct Operator<'a> { + handle: &'a Client, + tx: mpsc::Sender, + rx: broadcast::Receiver, + irc_rx: broadcast::Receiver, +} - let (tx, rx) = oneshot::channel(); - self.tx - .send(Action::BotCommand { - channel, - command, - tx: Some(tx), - }) - .await?; - rx.await? +impl<'a> Operator<'a> { + /// Sends a raw IRC command to the server. + /// + /// This method is unreliable, because it does not track the response. To + /// track the response, use [`Operator::send_irc_command`] instead. + pub async fn send_irc_command_unreliable( + &self, + command: C, + ) -> StdResult<(), OperatorError> { + match command.command() { + Some(command) => self.tx.send(Action::Raw(command)).await?, + None => self.tx.send(Action::RawGroup(command.commands())).await?, + }; + Ok(()) } + /// Sends a raw IRC command to the server. + /// + /// This method tracks the response and thus generally costs more time than + /// [`Operator::send_irc_command_unreliable`]. + pub async fn send_irc_command( + &self, + command: C, + ) -> StdResult, OperatorError> { + let mut irc_rx = self.irc_rx.resubscribe(); + match command.command() { + Some(command) => self.tx.send(Action::Raw(command)).await?, + None => self.tx.send(Action::RawGroup(command.commands())).await?, + }; + let mut context = IrcContext::new(self.handle); - pub async fn send_unreliable_private_bot_command( - &mut self, - command: bot::Command, - ) -> crate::Result<()> { - self.send_unreliable_bot_command(None, command).await - } + let mut skip_count: usize = 0; - pub async fn send_unreliable_channel_bot_command( - &mut self, - channel: Channel, - command: bot::Command, - ) -> crate::Result<()> { - self.send_unreliable_bot_command(Some(channel), command) - .await + loop { + let message = irc_rx.recv().await?; + match command.tracks_message(&mut context, message) { + TrackVerdict::Accept => continue, + TrackVerdict::Body(body) => return Ok(IrcResponse { body: Ok(body) }), + TrackVerdict::Err(e) => return Ok(IrcResponse { body: Err(e) }), + TrackVerdict::Skip => { + skip_count += 1; + if skip_count > self.options().message_tracker_limit { + break Err(OperatorError::TrackerLimitExceeded); + } + } + TrackVerdict::Terminate => break Err(OperatorError::Cancelled), + } + } + } + async fn send_action(&self, action: Action) -> StdResult<(), OperatorError> { + Ok(self.tx.send(action).await?) } + pub fn options(&'a self) -> &'a ClientOptions { + &self.handle.options + } + /// Subscribes to the event stream. + pub fn subscribe(&self) -> broadcast::Receiver { + self.rx.resubscribe() + } + /// Subscribes to the raw IRC message stream. + pub fn irc(&self) -> broadcast::Receiver { + self.irc_rx.resubscribe() + } + async fn send_target_chats>( + &self, + target: impl Into, + contents: impl AsRef<[S]>, + ) -> StdResult<(), OperatorError> { + let target = User::irc_normalize(&target.into().to_string()); - pub async fn send_unreliable_bot_command( - &mut self, - channel: Option, - command: bot::Command, - ) -> crate::Result<()> { - self.tx - .send(Action::BotCommand { - channel, - command, - tx: None, + let messages = contents + .as_ref() + .iter() + .map(|line| irc::Command::PRIVMSG { + target: target.clone(), + body: line.as_ref().to_string(), }) - .await?; + .collect(); + self.send_action(Action::RawGroup(messages)).await?; Ok(()) } -} - -#[derive(Debug, Clone)] -enum Target { - Channel(Channel), - User(User), -} + pub async fn send_user_chats>( + &self, + user: impl Borrow, + contents: impl AsRef<[S]>, + ) -> StdResult<(), OperatorError> { + self.send_target_chats(user.borrow().clone(), contents) + .await + } + pub async fn send_channel_chats>( + &self, + channel: impl Borrow, + contents: impl AsRef<[S]>, + ) -> StdResult<(), OperatorError> { + self.send_target_chats(channel.borrow().clone(), contents) + .await + } + async fn send_target_chat( + &self, + target: impl Into, + content: impl AsRef, + ) -> StdResult<(), OperatorError> { + let action = Action::Raw(irc::Command::PRIVMSG { + target: User::irc_normalize(&target.into().to_string()), + body: content.as_ref().to_string(), + }); + self.send_action(action).await + } + pub async fn send_user_chat( + &self, + user: impl Borrow, + content: impl AsRef, + ) -> StdResult<(), OperatorError> { + let content = content.as_ref(); + self.send_target_chat(user.borrow().clone(), content).await + } + pub async fn send_channel_chat( + &self, + channel: impl Borrow, + content: impl AsRef, + ) -> StdResult<(), OperatorError> { + let content = content.as_ref(); + self.send_target_chat(channel.borrow().clone(), content) + .await + } + async fn send_target_bot_command_unreliable( + &self, + target: impl Into, + command: &C, + ) -> StdResult<(), OperatorError> { + let target = target.into(); + if match &target { + MessageTarget::User(u) => command.sendable_to_user(u), + MessageTarget::Channel(c) => command.sendable_in_channel(c), + } { + self.tx + .send(Action::Raw(irc::Command::PRIVMSG { + target: target.to_string(), + body: command.command_string(), + })) + .await?; + Ok(()) + } else { + Err(match target { + MessageTarget::User(u) => bot::CommandError::UserNotApplicable(u), + MessageTarget::Channel(c) => bot::CommandError::ChannelNotApplicable(c), + } + .into()) + } + } + async fn send_target_bot_command( + &self, + target: impl Into, + command: &C, + ) -> StdResult, OperatorError> { + let target = target.into(); + if match &target { + MessageTarget::User(u) => command.sendable_to_user(u), + MessageTarget::Channel(c) => command.sendable_in_channel(c), + } { + let mut rx = self.rx.resubscribe(); + self.tx + .send(Action::Raw(irc::Command::PRIVMSG { + target: target.to_string(), + body: command.command_string(), + })) + .await?; + let mut context = + bot::CommandContext::new(User::name_only(self.options().username()), target); + + let mut skip_count: usize = 0; -impl ToString for Target { - fn to_string(&self) -> String { - match self { - Target::Channel(channel) => channel.to_string(), - Target::User(user) => user.irc_name(), + loop { + let event = rx.recv().await?; + match event.kind { + EventKind::Bot(message) => { + match command.tracks_message(&mut context, message) { + TrackVerdict::Accept => continue, + TrackVerdict::Body(body) => { + return Ok(bot::Response { + command: command.clone(), + messages: context.history().to_vec(), + body: Ok(body), + }) + } + TrackVerdict::Err(e) => { + return Ok(bot::Response { + command: command.clone(), + messages: context.history().to_vec(), + body: Err(e), + }) + } + TrackVerdict::Skip => { + skip_count += 1; + if skip_count > self.options().message_tracker_limit { + break Err(OperatorError::TrackerLimitExceeded); + } + } + TrackVerdict::Terminate => break Err(OperatorError::Cancelled), + } + } + _ => { + skip_count += 1; + if skip_count > self.options().message_tracker_limit { + break Err(OperatorError::TrackerLimitExceeded); + } + } + } + } + } else { + Err(match target { + MessageTarget::User(u) => bot::CommandError::UserNotApplicable(u), + MessageTarget::Channel(c) => bot::CommandError::ChannelNotApplicable(c), + } + .into()) + } + } + pub async fn send_bot_command( + &self, + channel: Option, + command: impl Borrow, + ) -> StdResult, OperatorError> { + match channel { + None => { + self.send_target_bot_command(self.options().bot.clone(), command.borrow()) + .await + } + Some(channel) => { + self.send_target_bot_command(channel, command.borrow()) + .await + } } } -} - -impl From for Target { - fn from(channel: Channel) -> Self { - Self::Channel(channel) + pub async fn send_bot_command_unreliable( + &self, + channel: Option, + command: impl Borrow, + ) -> StdResult<(), OperatorError> { + match channel { + None => { + self.send_target_bot_command_unreliable( + self.options().bot.clone(), + command.borrow(), + ) + .await + } + Some(channel) => { + self.send_target_bot_command_unreliable(channel, command.borrow()) + .await + } + } } -} -impl From for Target { - fn from(user: User) -> Self { - Self::User(user) + /// Checks whether the client joined a channel, and joins if not. + /// + /// If the client has already joined the channel, only topic and creation + /// time are returned. + pub async fn ensure_join( + &self, + channel: impl Borrow, + ) -> StdResult, OperatorError> { + if let Some(info) = self.channel_info(channel.borrow()).await? { + Ok(IrcResponse { body: Ok(info) }) + } else { + self.join(channel).await + } } -} -#[derive(Debug, Clone)] -pub struct UserSender { - user: User, - writer: Sender, -} - -impl UserSender { - pub async fn send_chat(&mut self, body: &str) -> crate::Result<()> { - self.writer.send_private_chat(self.user.clone(), body).await - } - pub fn writer(&self) -> Sender { - self.writer.clone() + /// Joins a channel and returns channel information. + /// + /// # Errors + /// The outer error indicates any operator error, such as timeout or + /// cancellation. The error for inner response is [`JoinError`]. + /// When the client has already joined a channel, [`OperatorError::Timeout`] + /// is returned. Use [`Operator::ensure_join`] to avoid this. + pub async fn join( + &self, + channel: impl Borrow, + ) -> StdResult, OperatorError> { + self.send_irc_command(Join { + channel: channel.borrow().clone(), + }) + .await } - pub fn user(&self) -> &User { - &self.user + pub async fn join_unreliable( + &self, + channel: impl Borrow, + ) -> StdResult<(), OperatorError> { + self.send_irc_command_unreliable(Join { + channel: channel.borrow().clone(), + }) + .await } -} - -#[derive(Debug, Clone)] -pub struct ChannelSender { - channel: Channel, - writer: Sender, -} - -impl ChannelSender { - pub async fn send_chat(&mut self, body: &str) -> crate::Result<()> { - self.writer - .send_channel_chat(self.channel.clone(), body) - .await + pub async fn part( + &self, + channel: impl Borrow, + ) -> StdResult, OperatorError> { + self.send_irc_command(Part { + channel: channel.borrow().clone(), + }) + .await } - pub async fn send_bot_command( - &mut self, - command: bot::Command, - ) -> crate::Result { - self.writer - .send_channel_bot_command(self.channel.clone(), command) - .await + pub async fn part_unreliable( + &self, + channel: impl Borrow, + ) -> StdResult<(), OperatorError> { + self.send_irc_command_unreliable(Part { + channel: channel.borrow().clone(), + }) + .await } - pub async fn send_unreliable_bot_command( - &mut self, - command: bot::Command, - ) -> crate::Result<()> { - self.writer - .send_unreliable_channel_bot_command(self.channel.clone(), command) - .await + /// Returns channel information if the client has joined the channel. + async fn channel_info( + &self, + channel: impl Borrow, + ) -> StdResult, OperatorError> { + let (tx, rx) = oneshot::channel(); + self.tx + .send(Action::Channel(channel.borrow().clone(), tx)) + .await?; + rx.await.map_err(|_| OperatorError::Cancelled) + } + /// Joins a multiplayer match channel as a referee. + /// + /// Returns an optional [`multiplayer::Match`] instance. If the multiplayer + /// channel is not found or the client is not a referee, [`None`] is + /// returned. + /// + /// # Errors + /// The outer error indicates any operator error, such as timeout or + /// cancellation. + pub async fn join_match( + &self, + id: multiplayer::MatchId, + ) -> StdResult, OperatorError> { + let response = self.ensure_join(Channel::Multiplayer(id)).await?; + Ok(response.body().ok().map(|info| multiplayer::Match { + id, + internal_id: info.match_internal_id(), + operator: self.handle.operator(), + })) } - pub fn writer(&self) -> Sender { - self.writer.clone() + pub async fn has_joined( + &self, + channel: impl Borrow, + ) -> StdResult> { + self.channel_info(channel).await.map(|e| e.is_some()) } - pub fn channel(&self) -> &Channel { - &self.channel + pub async fn channels(&self) -> StdResult, OperatorError> { + let (tx, rx) = oneshot::channel(); + self.tx.send(Action::Channels(tx)).await?; + rx.await.map_err(|_| OperatorError::Cancelled) } } #[derive(Debug)] +pub enum Action { + Raw(irc::Command), + RawGroup(Vec), + Channel(Channel, oneshot::Sender>), + Channels(oneshot::Sender>), + User(String), +} + +#[derive(Debug, Clone)] struct ChannelState { - channel: Channel, topic: String, - tx: Option>, + match_internal_id: Option, + time: chrono::DateTime, } struct ClientActor { options: ClientOptions, - irc_b: broadcast::Sender, - irc_tx: mpsc::Sender, - irc_rx: mpsc::Receiver, - - bot_b: broadcast::Sender<(Option, bot::Message)>, + assembler: bot::MessageAssembler, + channels: HashMap, + user_cache: UserCache, + transport: Transport, action_rx: mpsc::Receiver, event_tx: broadcast::Sender, - - channels: HashMap, + irc_tx: broadcast::Sender, } impl ClientActor { - async fn process_irc( - &mut self, - bot_matcher: &bot::MessageMatcher<'_>, - message: &irc::Message, - ) -> crate::Result<()> { - match &message.command { - irc::Command::JOIN(channel) => { - if let Some(channel) = Channel::try_from(channel.as_str()).ok() { - self.event_tx.send(Event::Join(channel.clone()))?; - if !self.channels.contains_key(&channel) { - self.channels.insert(channel.clone(), ChannelState { - channel, - topic: String::new(), - tx: None, - }); + async fn dispatch_irc_privmsg(&mut self, from: String, target: String, body: String) { + let mut sender = User::name_only(from); + let is_bancho_bot = self.options.bot == sender; + if is_bancho_bot { + sender.flags |= UserFlags::BanchoBot; + } + let message = Message { + sender, + channel: target.parse().ok(), + content: body, + }; + if is_bancho_bot { + match self.assembler.convert(&message) { + Some(bot_message) => { + if let Some(terminated) = self.assembler.terminate(Some(&bot_message)) { + self.event_tx.send(terminated.into()).unwrap(); } - } - } - irc::Command::Raw(code, params) => { - if code == "332" && params.len() > 2 { - let channel = Channel::try_from(params[1].as_str()).ok(); - if let Some(channel) = channel { - let topic = params[2].clone(); - if let Some(state) = self.channels.get_mut(&channel) { - state.topic = topic; + self.event_tx.send(bot_message.clone().into()).unwrap(); + if bot_message.is_stateful() { + if let Some(tracked) = self.assembler.track(&bot_message) { + self.event_tx.send(tracked.into()).unwrap(); } } - return Ok(()) - } - } - irc::Command::PART(channel) => { - if let Some(channel) = Channel::try_from(channel.as_str()).ok() { - self.event_tx.send(Event::Part(channel.clone()))?; - self.channels.remove(&channel); - } - } - irc::Command::PRIVMSG { target, body } => { - let channel = Channel::try_from(target.as_str()).ok(); - let mut is_bot = false; - if let Some(irc::Prefix::User { nickname, .. }) = &message.prefix { - if nickname == self.options.bot.name.as_str() { - is_bot = true; - } - } - if is_bot { - match bot_matcher.matches(body.as_str()) { - Ok(message) => { - self.bot_b.send((channel.clone(), message.clone()))?; - if let Some(ref channel) = channel { - let event = match message { - // Responses to commands, translate some of them to events - bot::Message::MpAbortResponse => { - Some(multiplayer::Event::MatchAborted) - } - - // Events that we want to pass through - bot::Message::PlayerJoinedEvent { user, slot, team } => { - Some(multiplayer::Event::PlayerJoined { user, slot, team }) - } - bot::Message::PlayerMovedEvent { user, slot } => { - Some(multiplayer::Event::PlayerMoved { user, slot }) - } - bot::Message::PlayerChangedTeamEvent { user, team } => { - Some(multiplayer::Event::PlayerChangedTeam { user, team }) - } - bot::Message::PlayerLeftEvent(user) => { - Some(multiplayer::Event::PlayerLeft { user }) - } - bot::Message::HostChangedEvent(user) => { - Some(multiplayer::Event::HostChanged { host: user }) - } - bot::Message::HostChangingMapEvent => { - Some(multiplayer::Event::HostChangingMap) - } - bot::Message::MapChangedEvent(map) => { - Some(multiplayer::Event::MapChanged { map }) - } - bot::Message::PlayersReadyEvent => { - Some(multiplayer::Event::PlayersReady) - } - bot::Message::MatchStartedEvent => { - Some(multiplayer::Event::MatchStarted) - } - bot::Message::MatchFinishedEvent => { - Some(multiplayer::Event::MatchFinished) - } - bot::Message::MatchPlayerScoreEvent { user, score, alive } => { - Some(multiplayer::Event::MatchPlayerScore { - user, - score, - alive, - }) - } - _ => None, - }; - match event { - Some(event) => { - let event = - Event::Multiplayer(channel.clone(), event.clone()); - self.event_tx.send(event.clone())?; - self.channels.get(&channel).map(|state| { - if let Some(tx) = &state.tx { - tx.send(event); + if bot_message.is_multiplayer_event() { + if let Some(channel) = bot_message.channel() { + match channel { + Channel::Multiplayer(match_id) => { + let kind = multiplayer::EventKind::from_bot( + bot_message.kind().clone(), + ); + let state = self.channels.get(channel).unwrap(); + self.event_tx + .send( + multiplayer::Event { + match_id: *match_id, + match_internal_id: state.match_internal_id.unwrap(), + kind, } - }); - } - _ => {} - }; - } - } - Err(e) => { - println!("Error parsing bot message {}: {:?}", body, e); - } - } - } else { - if let Some(irc::Prefix::User { nickname, .. }) = &message.prefix { - let event = Event::Message { - from: nickname.as_str().try_into()?, - channel: channel.clone(), - body: body.to_string(), - }; - self.event_tx.send(event.clone())?; - if let Some(channel) = channel { - self.channels.get(&channel).map(|state| { - if let Some(tx) = &state.tx { - tx.send(event); + .into(), + ) + .unwrap(); } - }); + _ => {} + } } + } else if bot_message.is_maintenance() { + self.event_tx + .send(EventKind::Maintenance(bot_message.maintenance()).into()) + .unwrap(); } } + None => { + self.event_tx.send(message.into()).unwrap(); + } } - _ => {} - }; - Ok(()) + } else { + self.event_tx.send(message.into()).unwrap(); + } } - - async fn process_action(&mut self, action: Action) -> crate::Result<()> { - match action { - Action::Channels(tx) => { - let channels = self - .channels - .iter() - .map(|(channel, _)| channel.clone()) - .collect::>(); - tx.send(channels); - } - Action::Subscribe(channel, tx) => { - if let Some(state) = self.channels.get_mut(&channel) { - match &state.tx { - Some(event_tx) => { - tx.send(Some( - (state.topic.clone(), event_tx.subscribe()) - )); - }, - None => { - let (event_tx, rx) = broadcast::channel(Client::CHANNEL_BUFFER); - tx.send(Some( - (state.topic.clone(), rx) - )); - state.tx = Some(event_tx); - } - } - } else { - tx.send(None); + async fn dispatch_irc( + &mut self, + timeout: Pin<&mut time::Sleep>, + message: irc::Message, + ) -> StdResult<(), Error> { + let prefix = message.prefix; + let command = message.command; + + match command { + irc::Command::QUIT(..) => { + if let Some(message) = self.assembler.terminate(None) { + self.event_tx.send(message.into()).unwrap(); + } + if let Some(prefix) = &prefix { + self.event_tx + .send(EventKind::Quit(User::name_only(prefix.name())).into()) + .unwrap(); } } - Action::BotCommand { - channel, - command, - tx, - } => { - self.irc_tx - .send(irc::Message { + irc::Command::PING(s1, s2) => { + self.transport + .write(irc::Message { prefix: None, - command: irc::Command::PRIVMSG { - target: channel - .clone() - .map_or(self.options.bot.irc_name(), |c| c.to_message_target()), - body: command.to_string(), - }, + command: irc::Command::PONG(s1, s2), }) .await?; - - if let Some(tx) = tx { - let mut rx = self.bot_b.subscribe(); - let mut fsm = - bot::ResponseMatcher::new(self.options.username.clone(), &command); - let operation_timeout = self.options.operation_timeout; - tokio::spawn(async move { - let start = Instant::now(); - loop { - match time::timeout(operation_timeout, rx.recv()).await { - Ok(Ok((c, m))) => { - if c == channel { - if let Some(response) = fsm.next(&m) { - tx.send(Ok(response.clone())).unwrap(); - break; - } else if start.elapsed() > operation_timeout { - tx.send( - fsm.end().ok_or(Error::OperationTimeout.into()), - ) - .unwrap(); - break; - } - } - } - Ok(Err(_)) => break, - Err(_) => { - tx.send(fsm.end().ok_or(Error::OperationTimeout.into())) - .unwrap(); - break; - } - } - } + } + irc::Command::PONG(..) => { + timeout.reset(Instant::now() + self.options.pinger_timeout); + } + irc::Command::PRIVMSG { target, body } => match prefix { + Some(prefix) => match prefix { + irc::Prefix::User { nickname, .. } => { + self.dispatch_irc_privmsg(nickname, target, body).await; + } + _ => {} + }, + None => {} + }, + irc::Command::JOIN(channel) + if prefix + .as_ref() + .map(|p| p.name() == self.options.username) + .unwrap_or_default() => + { + let channel: Channel = channel.parse().unwrap(); + self.channels + .entry(channel.clone()) + .or_insert(ChannelState { + topic: String::new(), + match_internal_id: None, + time: chrono::DateTime::default(), }); - } + self.event_tx.send(EventKind::Join(channel).into()).unwrap(); } - Action::Chat { target, body } => { - self.irc_tx - .send(irc::Message { + irc::Command::PART(channel) + if prefix + .as_ref() + .map(|p| p.name() == self.options.username) + .unwrap_or_default() => + { + let channel = channel.parse().unwrap(); + self.channels.remove(&channel); + self.event_tx.send(EventKind::Part(channel).into()).unwrap(); + } + irc::Command::Response(irc::Response::RPL_TOPIC, params) => { + let mut deque = VecDeque::from(params); + let _client = deque.pop_front().unwrap(); + let channel = deque.pop_front().unwrap().parse().unwrap(); + let topic = deque.pop_front().unwrap_or_default(); + self.channels.entry(channel).and_modify(|s| { + s.topic = topic; + s.match_internal_id = Matcher::topic_game_id(&s.topic); + }); + } + irc::Command::Response(irc::Response::RPL_TOPICWHOTIME, params) => { + let mut deque = VecDeque::from(params); + let _client = deque.pop_front().unwrap(); + let channel = deque.pop_front().unwrap().parse().unwrap(); + let _creator = deque.pop_front().unwrap(); + let timestamp: i64 = deque.pop_front().unwrap().parse().unwrap_or_default(); + let naive = chrono::NaiveDateTime::from_timestamp_millis(timestamp * 1000).unwrap(); + self.channels.entry(channel).and_modify(|s| { + s.time = chrono::DateTime::from_naive_utc_and_offset(naive, chrono::Utc); + }); + } + irc::Command::Response(irc::Response::RPL_WHOISUSER, params) => { + let username = ¶ms[1]; + let id = Matcher::whois_user_id(¶ms[2]).unwrap(); + self.user_cache.put(User::new(id, username)); + } + _ => {} + } + + Ok(()) + } + async fn dispatch_action(&mut self, action: Action) -> Result<()> { + match action { + Action::Raw(command) => { + self.transport + .write(irc::Message { prefix: None, - command: irc::Command::PRIVMSG { - target: target.clone(), - body: body.to_string(), - }, + command, }) .await?; } - Action::Raw(message) => { - self.irc_tx.send(message.clone()).await?; + Action::RawGroup(commands) => { + for command in commands { + self.transport + .write(irc::Message { + prefix: None, + command, + }) + .await?; + } + } + Action::Channel(channel, tx) => { + tx.send(self.channels.get(&channel).map(|state| ChannelInfo { + channel: channel.clone(), + topic: state.topic.clone(), + match_internal_id: state.match_internal_id, + time: state.time, + names: Vec::new(), + })) + .unwrap(); + } + Action::Channels(tx) => { + tx.send(self.channels.keys().cloned().collect()).unwrap(); + } + Action::User(s) => { + let _u = self.user_cache.find_name(&s); } } Ok(()) } - - async fn run(&mut self, mut transport: Transport) -> crate::Result<()> { + async fn run(&mut self) -> StdResult<(), Error> { let mut pinger = time::interval(self.options.pinger_interval); + pinger.tick().await; let timeout = time::sleep(self.options.pinger_timeout); tokio::pin!(timeout); @@ -609,405 +1414,557 @@ impl ClientActor { command: irc::Command::PING(self.options.username.clone(), None), }; - let bot_matcher = bot::MessageMatcher::new(); - loop { tokio::select! { - // Pinger tick and timeout + action = self.action_rx.recv() => { + if let Some(action) = action { + self.dispatch_action(action).await?; + } + }, _ = pinger.tick() => { - transport.write(&ping_message).await?; + self.transport.write(&ping_message).await?; }, _ = &mut timeout => { - return Err(Error::Transport(TransportError::PingerTimeout).into()); - }, - // Read message from transport (IRC server) and send it to broadcast channel - message = transport.read() => { - if let Ok(Some(message)) = message { - match &message.command { - // FIXME: We ignore QUIT messages to reduce the pressure of message passing. - // This should be configurable in options. - irc::Command::QUIT(_) => {}, - irc::Command::PING(s1, s2) => { - transport.write(&irc::Message::new(irc::Command::PONG(s1.clone(), s2.clone()), None)).await?; - }, - irc::Command::PONG(..) => { - timeout.as_mut().reset(Instant::now() + self.options.pinger_timeout); - }, - _ => { - self.process_irc(&bot_matcher, &message).await?; - } - } - self.irc_b.send(message)?; - } else { - return Err(Error::Transport(TransportError::ConnectionClosed).into()); - } + return Err(Error::PingerTimeout.into()); }, - // Collect messages from outgoing channel and write them to transport (IRC server) - message = self.irc_rx.recv() => { - if let Some(message) = message { - transport.write(&message).await?; - } else { - return Err(Error::Transport(TransportError::ConnectionClosed).into()); + message = self.transport.read() => { + match message? { + Some(message) => { + self.irc_tx.send(message.clone()).unwrap(); + self.dispatch_irc(timeout.as_mut(), message).await?; + }, + None => { + self.event_tx.send(EventKind::Closed.into()).unwrap(); + break; + }, } }, - action = self.action_rx.recv() => { - if let Some(action) = action { - self.process_action(action).await?; - } else { - return Err(Error::Transport(TransportError::ConnectionClosed).into()); - } - } } } + Ok(()) } } #[derive(Debug)] pub struct Client { options: ClientOptions, - - irc_tx: mpsc::Sender, - irc_rx: broadcast::Receiver, - - bot_rx: broadcast::Receiver<(Option, bot::Message)>, - + actor: JoinHandle>, action_tx: mpsc::Sender, event_rx: broadcast::Receiver, - - actor: JoinHandle<()>, + irc_rx: broadcast::Receiver, } impl Client { - const CHANNEL_BUFFER: usize = 512; + const CHANNEL_BUFFER: usize = 2048; + const CACHE_SIZE: usize = 2048; pub async fn new(options: ClientOptions) -> Result { - let raw = TcpStream::connect(options.endpoint.clone()).await?; + let stream = TcpStream::connect(options.endpoint.clone()).await?; + let transport = Transport::new(stream); - let (action_tx, action_rx) = mpsc::channel(Client::CHANNEL_BUFFER); - let (irc_out_tx, irc_out_rx) = mpsc::channel(Client::CHANNEL_BUFFER); - let (irc_in_tx, irc_in_rx) = broadcast::channel(Self::CHANNEL_BUFFER); - let (bot_tx, bot_rx) = broadcast::channel(Self::CHANNEL_BUFFER); - let (event_tx, event_rx) = broadcast::channel(Self::CHANNEL_BUFFER); - let mut actor = ClientActor { - options: options.clone(), + let (irc_tx, irc_rx) = broadcast::channel(Self::CHANNEL_BUFFER); + let (event_tx, _event_rx) = broadcast::channel(Self::CHANNEL_BUFFER); + let (action_tx, action_rx) = mpsc::channel(Self::CHANNEL_BUFFER); - irc_b: irc_in_tx, - irc_rx: irc_out_rx, - irc_tx: irc_out_tx.clone(), + let rx = event_tx.subscribe(); + let tx = action_tx.clone(); - bot_b: bot_tx, - action_rx, - event_tx, + let mut actor = ClientActor { + assembler: bot::MessageAssembler::new(), + options: options.clone(), channels: HashMap::new(), + user_cache: UserCache::new(Self::CACHE_SIZE), + transport, + event_tx, + action_rx, + irc_tx, }; + let actor = tokio::spawn(async move { actor.run().await }); + Ok(Self { options, + actor, + action_tx: tx, + event_rx: rx, + irc_rx, + }) + } + pub async fn auth(&self) -> StdResult<(), Error> { + let response = self + .operator() + .send_irc_command(Auth { + username: self.options().username().to_string(), + password: self.options().password().map(|s| s.to_string()), + }) + .await + .unwrap(); + response + .body() + .copied() + .map_err(|s| Error::AuthError(s.to_string())) + } + pub fn options(&self) -> &ClientOptions { + &self.options + } + pub fn operator<'a>(&'a self) -> Operator<'a> { + Operator { + handle: &self, + tx: self.action_tx.clone(), + rx: self.event_rx.resubscribe(), + irc_rx: self.irc_rx.resubscribe(), + } + } + pub async fn shutdown(self) -> Result<()> { + self.actor.await.unwrap() + } +} - irc_tx: irc_out_tx, - irc_rx: irc_in_rx, - - bot_rx, - action_tx, - event_rx, +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub enum Channel { + Multiplayer(MatchId), + Raw(String), +} - actor: tokio::spawn( - async move { if let Err(e) = actor.run(Transport::new(raw)).await {} }, - ), - }) +impl Channel { + pub fn match_id(&self) -> MatchId { + match self { + Channel::Multiplayer(id) => *id, + _ => panic!("not a multiplayer channel"), + } + } + pub fn is_multiplayer(&self) -> bool { + match self { + Channel::Multiplayer(_) => true, + _ => false, + } } +} - fn bot(&self) -> User { - self.options.bot.clone() +impl From for Channel { + fn from(value: multiplayer::MatchId) -> Self { + Channel::Multiplayer(value) } +} - pub async fn auth(&mut self) -> crate::Result<()> { - let username = &self.options.username; +impl From<&str> for Channel { + fn from(value: &str) -> Self { + if value.starts_with('#') { + value.parse().unwrap() + } else { + Channel::Raw(value.to_string()) + } + } +} - let mut rx = self.irc_rx.resubscribe(); +impl From for Channel { + fn from(value: String) -> Self { + if value.starts_with('#') { + value.parse().unwrap() + } else { + Channel::Raw(value) + } + } +} - let mut list = Vec::new(); - if let Some(password) = &self.options.password { - list.push(irc::Command::PASS(password.clone())) +impl fmt::Display for Channel { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Channel::Multiplayer(id) => write!(f, "#mp_{}", id), + Channel::Raw(name) => write!(f, "#{}", name), } - list.append(&mut vec![ - irc::Command::USER { - username: username.clone(), - mode: "0".to_owned(), - realname: username.clone(), - }, - irc::Command::NICK(username.clone()), - ]); + } +} - for command in list { - self.irc_tx.send(irc::Message::new(command, None)).await?; +impl FromStr for Channel { + type Err = ConversionError; + fn from_str(s: &str) -> StdResult { + if !s.starts_with('#') { + return Err(ConversionError::InvalidChannel); + } + let trim = s.trim_start_matches('#'); + if trim.starts_with("mp_") { + let id = trim[3..] + .parse() + .map_err(|_| ConversionError::InvalidChannel)?; + Ok(Channel::Multiplayer(id)) + } else { + Ok(Channel::Raw(trim.to_string())) } + } +} - tokio::time::timeout(self.options.operation_timeout, async { - loop { - match rx.recv().await { - Ok(message) => { - if let irc::Command::Raw(code, params) = message.command { - if code == "001" { - return Ok(()); - } else if code == "464" { - return Err(Error::AuthError( - params.get(1).map_or("".to_string(), |m| m.clone()), - ) - .into()); - } - } - } - _ => {} - } - } - }) - .await - .map_err(|_| Error::OperationTimeout)? +bitflags! { + #[derive(Clone, Copy, Debug, PartialEq, Default, Eq, Hash)] + pub struct UserFlags: u32 { + // TODO: implement these flags + // const Moderator = 1 << 0; + // const IrcConnected = 1 << 1; + const BanchoBot = 1 << 2; + } +} + +#[derive(Debug, Clone, Default)] +pub struct User { + prefer_id: bool, + id: Option, + name: String, + flags: UserFlags, +} + +impl PartialEq for User { + fn eq(&self, other: &Self) -> bool { + other + .id + .map(|other| self.eq(&other)) + .unwrap_or(self.eq(&other.name())) + } +} + +impl PartialEq for User { + fn eq(&self, other: &UserId) -> bool { + self.id == Some(*other) + } +} + +impl PartialEq<&str> for User { + fn eq(&self, other: &&str) -> bool { + self.irc_normalized_name() + .eq_ignore_ascii_case(&Self::irc_normalize(other)) + } +} + +impl From<&str> for User { + fn from(name: &str) -> Self { + Self::name_only(name) + } +} + +impl From for User { + fn from(name: String) -> Self { + Self::name_only(name) + } +} + +impl From for User { + fn from(id: UserId) -> Self { + Self::id_only(id) + } +} + +impl User { + #[allow(dead_code)] + const MAX_LENGTH: usize = 32; + pub fn new(id: UserId, name: impl AsRef) -> Self { + Self { + prefer_id: false, + id: Some(id), + name: name.as_ref().to_string(), + flags: UserFlags::default(), + } + } + pub fn id_only(id: UserId) -> Self { + Self { + prefer_id: false, + id: Some(id), + flags: UserFlags::default(), + name: String::new(), + } } - - pub async fn channels(&self) -> crate::Result> { - let (tx, rx) = oneshot::channel(); - self.action_tx.send(Action::Channels(tx)).await?; - Ok(rx.await?) + pub fn name_only(name: impl AsRef) -> Self { + Self { + prefer_id: false, + id: None, + flags: UserFlags::default(), + name: name.as_ref().to_string(), + } } +} - async fn subscribe(&self, channel: &Channel) -> crate::Result)>> { - let (tx, rx) = oneshot::channel(); - self.action_tx.send(Action::Subscribe(channel.clone(), tx)).await?; - Ok(rx.await?) +impl User { + /// Normalize a name according to IRC specification. + pub fn irc_normalize(name: impl AsRef) -> String { + name.as_ref().replace(' ', "_") + } + + /// Normalize a name to canonical form (favoring underscore over space, and + /// lowercasing all characters). + /// + /// Names like `X_YZ`, `X YZ`, `x_Yz`, `x yz` are all refering to the exact + /// same user. + /// + /// ``` + /// # use closur::bancho::User; + /// assert_eq!(User::normalize("X_YZ"), "x_yz"); + /// assert_eq!(User::normalize("X YZ"), "x_yz"); + /// assert_eq!(User::normalize("x_Yz"), "x_yz"); + /// assert_eq!(User::normalize("x yz"), "x_yz"); + /// ``` + /// + /// This is useful for comparing two usernames. + pub fn normalize(name: impl AsRef) -> String { + Self::irc_normalize(name).to_lowercase() + } + + /// Returns user ID if available. + pub fn id(&self) -> Option { + self.id } - pub async fn join_channel_room(&mut self, channel: &Channel) -> crate::Result { - let entry = self.subscribe(channel).await?; - if entry.is_none() { - self.join(channel).await?; - } + /// Returns username if available. + pub fn name(&self) -> &str { + self.name.as_str() + } - let entry = self.subscribe(channel).await?; - match entry { - None => Err("Cannot join channel".into()), - Some((topic, rx)) => Ok(ChannelRoom { - topic, - writer: ChannelSender { channel: channel.clone(), writer: self.writer() }, - event_rx: rx, - }), + /// This function returns username with a zero-width space inserted between + /// first two characters. + /// + /// This is quite useful because osu! client will highlight a message for + /// users if it contains their username, the insertion of zero-space can + /// prevent this kind of disturbance. + pub fn name_without_highlight(&self) -> String { + let mut name = self.name.clone(); + if name.len() > 1 { + name.insert(1, '\u{200b}'); } + name } - pub async fn join_match(&mut self, id: u64) -> crate::Result { - let channel = Channel::Multiplayer(id); - let room = self.join_channel_room(&channel).await?; - Ok(room.try_into()?) + /// IRC name is almost same as osu! username, but all spaces are replaced + /// by underscores. + pub fn irc_normalized_name(&self) -> String { + Self::irc_normalize(&self.name) } - pub async fn join(&mut self, channel: &Channel) -> crate::Result<()> { - let command = irc::Command::JOIN(channel.to_string()); - let mut rx = self.irc_rx.resubscribe(); - self.irc_tx.send(irc::Message::new(command, None)).await?; - - time::timeout(self.options.operation_timeout, async { - loop { - let message = rx.recv().await?; - match message.command { - irc::Command::Raw(code, params) => { - if code == "332" && params.len() > 2 && params[1] == channel.to_string() { - return Ok(()) - - } else if code == "403" - && params.len() > 2 - && params[1] == channel.to_string() - { - return Err(params[2].clone().into()); - } - } - _ => {} - } - } - }) - .await - .map_err(|_| Error::OperationTimeout)? + /// Normalized name is the lowercase form of IRC name, as an representative + /// of all the names that are referring to the same user. + pub fn normalized_name(&self) -> String { + Self::normalize(&self.name) } - pub async fn leave(&mut self, channel: &Channel) -> crate::Result<()> { - let command = irc::Command::PART(channel.to_string()); - self.irc_tx.send(irc::Message::new(command, None)).await?; - Ok(()) + /// Convert users to incomplete ones, they have only usernames and no user + /// IDs (i.e. ID equals 0). + pub fn to_name_only(&self) -> Self { + Self { + prefer_id: self.prefer_id, + id: None, + name: self.name.clone(), + flags: self.flags, + } } - pub fn events(&self) -> broadcast::Receiver { - self.event_rx.resubscribe() + /// Convert users to incomplete ones, they have only user IDs and no + /// usernames (i.e. username is empty). + pub fn to_id_only(&self) -> Self { + Self { + prefer_id: self.prefer_id, + id: self.id, + name: String::new(), + flags: self.flags, + } } - pub fn writer(&self) -> Sender { - Sender { - tx: self.action_tx.clone(), + /// In some scenarios, we want to prefer user ID over username, this method + /// returns a user with ID preferred. + /// + /// ``` + /// use closur::bancho::User; + /// use closur::bancho::bot::{Command, command::MpHost}; + /// + /// let user = User::new(1234, "test_user"); + /// assert_eq!( + /// MpHost(user.clone()).command_string(), + /// "!mp host test_user" + /// ); + /// assert_eq!( + /// MpHost(user.to_id_preferred()).command_string(), + /// "!mp host #1234" + /// ); + /// ``` + pub fn to_id_preferred(&self) -> Self { + Self { + prefer_id: true, + id: self.id, + name: self.name.clone(), + flags: self.flags, } } - pub fn irc_writer(&self) -> mpsc::Sender { - self.irc_tx.clone() + /// Reverse operation of [`User::to_id_preferred`]. + pub fn to_name_preferred(&self) -> Self { + Self { + prefer_id: false, + id: self.id, + name: self.name.clone(), + flags: self.flags, + } } - pub fn irc_subscriber(&self) -> broadcast::Receiver { - self.irc_rx.resubscribe() + /// Tells whether the user is incomplete and only has username. + pub fn is_id_only(&self) -> bool { + self.has_id() && !self.has_name() } -} -#[derive(Debug, Clone, PartialEq, Eq, Hash)] -pub enum Channel { - Multiplayer(u64), - Raw(String), -} + /// Tells whether the user is incomplete and only has user ID. + pub fn is_name_only(&self) -> bool { + !self.has_id() && self.has_name() + } -impl Channel { - fn is_multiplayer(&self) -> bool { - match self { - Channel::Multiplayer(_) => true, - _ => false, - } + /// Tells whether the user has user ID. + pub fn has_id(&self) -> bool { + self.id.is_some() } -} -impl irc::ToMessageTarget for Channel { - fn to_message_target(&self) -> String { - self.to_string() + /// Tells whether the user has username. + pub fn has_name(&self) -> bool { + !self + .name + .trim_matches(|c| c == '_' || char::is_ascii_whitespace(&c)) + .is_empty() } -} -impl fmt::Display for Channel { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - match self { - Channel::Multiplayer(id) => write!(f, "#mp_{}", id), - Channel::Raw(name) => write!(f, "#{}", name), - } + /// Tells whether the user is `BanchoBot`. + pub fn is_bancho_bot(&self) -> bool { + self.flags.contains(UserFlags::BanchoBot) } } -impl TryFrom<&str> for Channel { - type Error = ConversionError; - fn try_from(s: &str) -> StdResult { - if !s.starts_with('#') { - return Err(ConversionError::InvalidChannel); - } - let trim = s.trim_start_matches('#'); - if trim.starts_with("mp_") { - let id = trim[3..] +pub type UserId = u64; +pub type UserScore = u64; +pub type UserRank = u32; +pub type UserPlayCount = u32; +pub type UserLevel = u16; +pub type UserAccuracy = f32; + +impl FromStr for User { + type Err = ConversionError; + fn from_str(s: &str) -> StdResult { + if s.starts_with('#') { + let id = s[1..] .parse() - .map_err(|_| ConversionError::InvalidChannel)?; - Ok(Channel::Multiplayer(id)) + // TODO: better error handling + .map_err(|_| ConversionError::InvalidUser)?; + Ok(User::id_only(id)) } else { - Ok(Channel::Raw(trim.to_string())) + Ok(User::name_only(s)) } } } -#[derive(Debug, Clone, Default)] -pub struct User { - id: u64, - name: String, -} - -impl User { - pub fn merge(&self, other: &Self) -> Self { - Self { - id: if self.id != 0 { self.id } else { other.id }, - name: if self.name.len() != 0 { - self.name.clone() +impl fmt::Display for User { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + if self.prefer_id { + if self.has_id() { + write!(f, "#{}", self.id.unwrap()) + } else if self.has_name() { + write!(f, "{}", self.name) } else { - other.name.clone() - }, + write!(f, "{}", self.name) + } + } else { + if self.has_name() { + write!(f, "{}", self.name) + } else if self.has_id() { + write!(f, "#{}", self.id.unwrap()) + } else { + write!(f, "{}", self.name) + } } } } -impl PartialEq for User { - fn eq(&self, other: &Self) -> bool { - self.id == other.id || self.name.eq(&other.name) || self.irc_name().eq(&other.irc_name()) +impl AsRef for User { + fn as_ref(&self) -> &str { + self.name.as_str() } } -impl fmt::Display for User { +trait Recipient: fmt::Display + Clone {} +impl Recipient for String {} +impl Recipient for &str {} +impl Recipient for User {} +impl Recipient for &User {} +impl Recipient for Channel {} +impl Recipient for &Channel {} + +#[derive(Debug, Clone, PartialEq)] +pub enum MessageTarget { + User(User), + Channel(Channel), +} + +impl fmt::Display for MessageTarget { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - if self.id == 0 { - write!(f, "{}", self.name) - } else { - write!(f, "{} (#{})", self.name, self.id) + match self { + MessageTarget::User(user) => write!(f, "{}", user), + MessageTarget::Channel(channel) => write!(f, "{}", channel), } } } -impl User { - pub fn id(&self) -> Option { - if self.id == 0 { - None - } else { - Some(self.id) +impl MessageTarget { + pub fn is_user(&self) -> bool { + match self { + MessageTarget::User(_) => true, + _ => false, } } - - pub fn name(&self) -> &str { - self.name.as_str() + pub fn is_channel(&self) -> bool { + match self { + MessageTarget::Channel(_) => true, + _ => false, + } } - - // osu! client will highlight messages if they contain username, - // to prevent this, a zero-width space is inserted between first two characters - pub fn name_without_highlight(&self) -> String { - let mut name = self.name.clone(); - if name.len() > 1 { - name.insert(1, '\u{200b}'); + pub fn user(&self) -> Option<&User> { + match self { + MessageTarget::User(user) => Some(user), + _ => None, } - name } - - // IRC name is almost same as osu! name, but all spaces are replaced with underscores - pub fn irc_name(&self) -> String { - self.name.replace(" ", "_") + pub fn channel(&self) -> Option<&Channel> { + match self { + MessageTarget::Channel(channel) => Some(channel), + _ => None, + } } - pub fn to_parameter(&self) -> String { - if self.id == 0 { - self.name.clone() - } else { - format!("#{}", self.id) + pub fn name(&self) -> String { + match self { + MessageTarget::User(user) => user.to_string(), + MessageTarget::Channel(channel) => channel.to_string(), } } } -impl irc::ToMessageTarget for User { - fn to_message_target(&self) -> String { - self.irc_name() +impl From for MessageTarget { + fn from(user: User) -> Self { + MessageTarget::User(user) } } -impl From for User { - fn from(id: u64) -> Self { - User { - id, - name: String::new(), +impl From for MessageTarget { + fn from(channel: Channel) -> Self { + MessageTarget::Channel(channel) + } +} + +impl PartialEq for MessageTarget { + fn eq(&self, other: &User) -> bool { + match self { + MessageTarget::User(user) => user == other, + _ => false, } } } -impl TryFrom<&str> for User { - type Error = ConversionError; - fn try_from(name: &str) -> StdResult { - if name.starts_with('#') { - let id = name[1..] - .parse() - .map_err(|_| ConversionError::InvalidUser)?; // TODO: better error handling - Ok(User { - id, - name: String::new(), - }) - } else { - Ok(User { - id: 0, - name: name.to_string(), - }) +impl PartialEq for MessageTarget { + fn eq(&self, other: &Channel) -> bool { + match self { + MessageTarget::Channel(channel) => channel == other, + _ => false, } } } -#[derive(Debug, Clone)] +#[derive(Debug, Copy, Clone, PartialEq, Eq)] pub enum UserStatus { Offline, Afk, @@ -1053,6 +2010,7 @@ impl TryFrom<&str> for UserStatus { fn try_from(s: &str) -> StdResult { match s.to_lowercase().as_str() { "" => Ok(UserStatus::Offline), + "offline" => Ok(UserStatus::Offline), "afk" => Ok(UserStatus::Afk), "idle" => Ok(UserStatus::Idle), "playing" => Ok(UserStatus::Playing), @@ -1069,207 +2027,63 @@ impl TryFrom<&str> for UserStatus { } } -#[derive(Debug, Clone, Default)] -pub struct Map { - id: u64, - name: String, -} - -impl Map { - pub fn id(&self) -> u64 { - self.id - } - pub fn name(&self) -> String { - self.name.clone() - } -} +impl FromStr for UserStatus { + type Err = ConversionError; -impl PartialEq for Map { - fn eq(&self, other: &Self) -> bool { - self.id == other.id + fn from_str(s: &str) -> StdResult { + Self::try_from(s) } } -#[derive(Debug, Clone)] -pub enum Event { - Join(Channel), - Part(Channel), - Message { - channel: Option, - from: User, - body: String, - }, - Multiplayer(Channel, multiplayer::Event), -} - -impl Event { - pub fn is_join(&self) -> bool { - match self { - Event::Join(_) => true, - _ => false, - } - } - pub fn is_part(&self) -> bool { - match self { - Event::Part(_) => true, - _ => false, - } - } - pub fn is_message(&self) -> bool { - match self { - Event::Message { .. } => true, - _ => false, - } - } - pub fn is_channel_message(&self) -> bool { - match self { - Event::Message { - channel: Some(_), .. - } => true, - _ => false, - } - } - pub fn is_private_message(&self) -> bool { - match self { - Event::Message { channel: None, .. } => true, - _ => false, - } - } - - pub fn is_multiplayer(&self) -> bool { - match self { - Event::Join(channel) => channel.is_multiplayer(), - Event::Part(channel) => channel.is_multiplayer(), - Event::Message { - channel: Some(channel), - .. - } => channel.is_multiplayer(), - Event::Multiplayer(..) => true, - _ => false, - } - } - - pub fn channel(&self) -> Option<&Channel> { - match self { - Event::Join(channel) => Some(channel), - Event::Part(channel) => Some(channel), - Event::Message { channel, .. } => channel.as_ref(), - Event::Multiplayer(channel, _) => Some(channel), - } - } - - pub fn is_from_channel(&self, channel: &Channel) -> bool { - self.channel() == Some(channel) - } -} +pub type MapId = u64; -#[derive(Debug, Clone)] -pub enum Chat { - Action(String), - Raw(String), +#[derive(Debug, Clone, Default)] +pub struct Map { + id: MapId, + name: String, } -impl Chat { - pub fn new(body: &str) -> Self { - Chat::Raw(body.to_string()) - } - pub fn new_action(body: &str) -> Self { - Chat::Action(body.to_string()) - } - pub fn append(&mut self, body: &str) -> &mut Self { - match self { - Chat::Action(s) => s.push_str(body), - Chat::Raw(s) => s.push_str(body), +impl Map { + pub fn new(id: MapId, name: impl AsRef) -> Self { + Self { + id, + name: name.as_ref().to_string(), } - self } - pub fn append_link(&mut self, title: &str, url: &str) -> &mut Self { - match self { - Chat::Action(s) => s.push_str(&format!("({})[{}]", title, url)), - Chat::Raw(s) => s.push_str(&format!("({})[{}]", title, url)), + pub fn id_only(id: MapId) -> Self { + Self { + id, + name: String::new(), } - self } } -impl ToString for Chat { - fn to_string(&self) -> String { - match self { - Chat::Action(body) => format!("\x01ACTION {}\x01", body), - Chat::Raw(body) => body.clone(), +impl From for Map { + fn from(id: MapId) -> Self { + Map { + id, + name: String::new(), } } } -#[derive(Debug)] -pub struct ChannelRoom { - topic: String, - writer: ChannelSender, - event_rx: broadcast::Receiver, -} - -impl ChannelRoom { - pub fn channel(&self) -> Channel { - self.writer.channel.clone() - } - pub fn topic(&self) -> &str { - self.topic.as_str() - } - pub fn channel_writer(&self) -> ChannelSender { - self.writer.clone() - } - pub async fn send_chat(&mut self, body: &str) -> crate::Result<()> { - self.writer.send_chat(body).await +impl Map { + pub fn id(&self) -> MapId { + self.id } - pub fn events(&self) -> broadcast::Receiver { - self.event_rx.resubscribe() + pub fn name(&self) -> &str { + &self.name } } -impl TryInto for ChannelRoom { - type Error = ConversionError; - fn try_into(self) -> StdResult { - if !self.writer.channel.is_multiplayer() { - return Err(ConversionError::ChannelTypeMismatch); - } - let id = match self.writer.channel { - Channel::Multiplayer(id) => id, - _ => 0, - }; - Ok(multiplayer::Match { - id, - inner_id: self.topic.trim_start_matches("multiplayer game #").parse().map_err(|_| ConversionError::InvalidMatchInnerId)?, - writer: self.writer, - event_rx: self.event_rx, - }) +impl PartialEq for Map { + fn eq(&self, other: &Self) -> bool { + self.id == other.id } } -// impl ChannelRoom { -// pub fn into_match(self) -> Std::Result { - -// } -// } - - -#[derive(Debug)] -enum Action { - Channels(oneshot::Sender>), - Subscribe(Channel, oneshot::Sender)>>), - BotCommand { - channel: Option, - command: bot::Command, - tx: Option>>, - }, - Chat { - target: String, - body: String, - }, - Raw(irc::Message), -} - bitflags! { - #[derive(Clone, Copy, Debug, PartialEq, Default, Eq, Hash)] + #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)] pub struct Mods: u32 { // const None = 0x00000000; const NoFail = 0x00000001; @@ -1307,9 +2121,8 @@ bitflags! { } impl Mods { - fn display(&self) -> &'static str { + fn display(&self) -> &str { match *self { - m if m.is_empty() => "None", Mods::NoFail => "No Fail", Mods::Easy => "Easy", Mods::TouchDevice => "Touch Device", @@ -1339,12 +2152,12 @@ impl Mods { Mods::Key3 => "Key3", Mods::Key2 => "Key2", Mods::Mirror => "Mirror", - _ => unreachable!("Invalid single mod"), + m if m.is_empty() => "None", + _ => unreachable!(""), } } fn name(&self) -> &'static str { match *self { - m if m.is_empty() => "None", Mods::NoFail => "NoFail", Mods::Easy => "Easy", Mods::TouchDevice => "TouchDevice", @@ -1374,12 +2187,12 @@ impl Mods { Mods::Key3 => "Key3", Mods::Key2 => "Key2", Mods::Mirror => "Mirror", - _ => unreachable!("Invalid single mod"), + m if m.is_empty() => "None", + _ => unreachable!(""), } } fn short_name(&self) -> &'static str { match *self { - m if m.is_empty() => "none", Mods::NoFail => "nf", Mods::Easy => "ez", Mods::TouchDevice => "td", @@ -1409,13 +2222,17 @@ impl Mods { Mods::Key3 => "k3", Mods::Key2 => "k2", Mods::Mirror => "mi", - _ => unreachable!("Invalid single mod"), + m if m.is_empty() => "none", + _ => unreachable!(), } } } impl fmt::Display for Mods { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + if self.is_empty() { + return write!(f, "None"); + } let mut iter = self.iter(); if let Some(first) = iter.next() { write!(f, "{}", first.display())?; @@ -1430,14 +2247,43 @@ impl fmt::Display for Mods { impl TryFrom<&str> for Mods { type Error = ConversionError; fn try_from(s: &str) -> StdResult { - Self::all() - .iter() - .filter(|m| { - m.name().eq_ignore_ascii_case(s) - || m.display().eq_ignore_ascii_case(s) - || m.short_name().eq_ignore_ascii_case(s) + s.split(',') + .map(|s| s.trim()) + .try_fold(Mods::empty(), |acc, s| { + Self::all() + .iter() + .chain([Mods::empty()]) + .find(|m| { + m.name().eq_ignore_ascii_case(s) + || m.display().eq_ignore_ascii_case(s) + || m.short_name().eq_ignore_ascii_case(s) + }) + .map(|m| m | acc) + .ok_or(ConversionError::InvalidModName) }) - .next() - .ok_or(ConversionError::InvalidModName) + } +} + +impl FromStr for Mods { + type Err = ConversionError; + + fn from_str(s: &str) -> StdResult { + Self::try_from(s) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn user_display() { + assert_eq!(User::name_only("y5c4l3").to_string(), "y5c4l3"); + assert_eq!(User::new(1234, "y5c4l3"), "y5c4l3"); + assert_eq!(User::id_only(1234).to_string(), "#1234"); + } + #[test] + fn user_partial_eq() { + assert_eq!(User::id_only(1234), User::id_only(1234)); } } diff --git a/src/bancho/bot.rs b/src/bancho/bot.rs index 2776845..05a7bd2 100644 --- a/src/bancho/bot.rs +++ b/src/bancho/bot.rs @@ -1,1698 +1,188 @@ -use std::time::{Duration, Instant}; +pub mod command; +mod message; -use regex::{Captures, Regex, RegexSet}; +use super::{Channel, MessageTarget, TrackVerdict, User}; -use crate::{error::StdError, StdResult}; +pub use message::{Message, MessageAssembler, MessageKind, MessageLine}; -use super::{ - multiplayer::{GameMode, ScoreMode, Slot, SlotStatus, Slots, Team, TeamMode, MAX_SLOTS}, - Map, Mods, User, UserStatus, -}; +pub struct CommandBuilder; -#[derive(Debug, Clone)] -pub enum Command { - // standard commands - Where(User), - Stats(User), - Roll(u32), - - // create or close multiplayer games - Make(String), - MakePrivate(String), - Close, - - // configure multiplayer game - Name(String), - Password(String), - Size(usize), - Set { - team_mode: Option, - score_mode: Option, - size: Option, - }, - Mods { - mods: Mods, - freemod: bool, - }, - Map(Map, Option), - - // multiplayer slot-related commands - Host(User), - ClearHost, - Lock, - Unlock, - Move { - user: User, - slot: usize, - }, - Team { - user: User, - team: Team, - }, - - // get multiplayer game settings - Settings, - - // multiplayer match commands - Start(Option), - Timer(Option), - AbortTimer, - Abort, +pub trait Command: Clone + Sized { + type Body; + type Error; - // multiplayer player management - Invite(User), - Kick(User), - Ban(User), - AddRef(User), - RemoveRef(User), - ListRefs, - - // attach tailing message to command - Tailing(Box, String), -} - -impl Command { - pub fn requires_channel(&self) -> bool { - match self { - Command::Stats(_) => false, - Command::Where(_) => false, - Command::Make(_) => false, - _ => true, - } + /// Indicates whether the command can be sent to a user. + fn sendable_to_user(&self, user: &User) -> bool { + user.is_bancho_bot() } - pub fn to_string(&self) -> String { - match self { - // standard commands - Command::Where(user) => format!("!where {}", user.to_parameter()), - Command::Stats(user) => format!("!stats {}", user.to_parameter()), - Command::Roll(range) => format!("!roll {}", range), - - // create or close multiplayer games - Command::Make(name) => format!("!mp make {}", name), - Command::MakePrivate(name) => format!("!mp makeprivate {}", name), - Command::Close => "!mp close".to_string(), - - // configure multiplayer game - Command::Name(name) => format!("!mp name {}", name), - Command::Password(password) => format!("!mp password {}", password), - Command::Size(size) => format!("!mp size {}", std::cmp::min(*size, MAX_SLOTS)), - Command::Set { - team_mode, - score_mode, - size, - } => match (team_mode, score_mode, size) { - (Some(team_mode), Some(score_mode), Some(size)) => format!( - "!mp set {} {} {}", - *team_mode as usize, - *score_mode as usize, - std::cmp::min(*size, MAX_SLOTS) - ), - (Some(team_mode), Some(score_mode), None) => { - format!("!mp set {} {}", *team_mode as usize, *score_mode as usize) - } - (Some(team_mode), None, Some(size)) => { - format!( - "!mp set {} _ {}", - *team_mode as usize, - std::cmp::min(*size, MAX_SLOTS) - ) - } - (None, Some(score_mode), Some(size)) => { - format!( - "!mp set _ {} {}", - *score_mode as usize, - std::cmp::min(*size, MAX_SLOTS) - ) - } - (Some(team_mode), None, None) => format!("!mp set {}", *team_mode as usize), - (None, Some(score_mode), None) => format!("!mp set _ {}", *score_mode as usize), - (None, None, Some(size)) => { - format!("!mp set _ _ {}", std::cmp::min(*size, MAX_SLOTS)) - } - (None, None, None) => "!mp set".to_string(), - }, - Command::Mods { mods, freemod } => format!( - "!mp mods {}{}", - mods.bits(), - if *freemod { " Freemod" } else { "" } - ), - Command::Map(map, mode) => match mode { - Some(mode) => format!("!mp map {} {}", map.id, *mode as usize), - None => format!("!mp map {}", map.id), - }, - - // multiplayer slot-related commands - Command::Host(user) => format!("!mp host {}", user.to_parameter()), - Command::ClearHost => "!mp clearhost".to_string(), - Command::Lock => "!mp lock".to_string(), - Command::Unlock => "!mp unlock".to_string(), - Command::Move { user, slot } => { - format!("!mp move {} {}", user.to_parameter(), slot + 1) - } - Command::Team { user, team } => format!("!mp team {} {}", user.to_parameter(), team), - - // get multiplayer settings - Command::Settings => "!mp settings".to_string(), - - // multiplayer match commands - Command::Start(duration) => match duration { - Some(duration) => format!("!mp start {}", duration.as_secs()), - None => "!mp start".to_string(), - }, - Command::Timer(duration) => match duration { - Some(duration) => format!("!mp timer {}", duration.as_secs()), - None => "!mp timer".to_string(), - }, - Command::AbortTimer => "!mp aborttimer".to_string(), - Command::Abort => "!mp abort".to_string(), - - // multiplayer player management - Command::Invite(user) => format!("!mp invite {}", user.to_parameter()), - Command::Kick(user) => format!("!mp kick {}", user.to_parameter()), - Command::Ban(user) => format!("!mp ban {}", user.to_parameter()), - Command::AddRef(user) => format!("!mp addref {}", user.to_parameter()), - Command::RemoveRef(user) => format!("!mp removeref {}", user.to_parameter()), - Command::ListRefs => "!mp listrefs".to_string(), - - // attach tailing message to command - Command::Tailing(c, t) => format!("{} {}", c.to_string(), t), - } + /// Indicates whether the command can be sent in a channel. + fn sendable_in_channel(&self, _channel: &Channel) -> bool { + true } + /// Returns a valid command string. + fn command_string(&self) -> String; + /// This is a stateful message tracker, to capture necessary messages for + /// further response construction. + fn tracks_message( + &self, + context: &mut CommandContext, + message: Message, + ) -> TrackVerdict; } -#[derive(Debug, Clone, Default)] -pub struct WhereResponse { - user: User, - location: String, -} - -#[derive(Debug, Clone, Default)] -pub struct StatsResponse { - user: User, - status: UserStatus, - score: u64, - rank: u32, - play_count: u32, - level: u32, - accuracy: f32, -} - -#[derive(Debug, Clone, Default)] -pub struct MpMakeResponse { - id: u64, - title: String, -} - -impl MpMakeResponse { - pub fn id(&self) -> u64 { - self.id - } - pub fn title(&self) -> &str { - self.title.as_str() - } -} - -#[derive(Debug, Clone, Default)] -pub struct MpSettingsResponse { - name: String, - id: u64, - map: Option, - team_mode: TeamMode, - score_mode: ScoreMode, - mods: Mods, - freemod: bool, - size: usize, - slots: Slots, +#[derive(Debug)] +pub struct CommandContext { + issuer: User, + target: MessageTarget, + history: Vec, } -impl MpSettingsResponse { - pub fn name(&self) -> &str { - self.name.as_str() - } - pub fn id(&self) -> u64 { - self.id - } - pub fn map(&self) -> Option { - self.map.clone() - } - pub fn team_mode(&self) -> TeamMode { - self.team_mode - } - pub fn score_mode(&self) -> ScoreMode { - self.score_mode - } - pub fn mods(&self) -> Mods { - self.mods +impl CommandContext { + pub fn new(issuer: User, target: impl Into) -> Self { + CommandContext { + issuer, + target: target.into(), + history: Vec::new(), + } } - pub fn freemod(&self) -> bool { - self.freemod + pub fn issuer(&self) -> &User { + &self.issuer } - pub fn size(&self) -> usize { - self.size + pub fn target(&self) -> &MessageTarget { + &self.target } - pub fn slots(&self) -> &Slots { - &self.slots + pub fn history(&self) -> &[Message] { + &self.history } - pub fn valid_slots(&self) -> Vec { - self.slots.iter().filter_map(|s| s.clone()).collect() + pub fn push(&mut self, message: impl Into) { + self.history.push(message.into()); } } -#[derive(Debug, Clone, Default)] -pub struct MpSetResponse { - size: Option, - team_mode: Option, - score_mode: Option, -} - -#[derive(Debug, Clone, Default)] -pub struct MpMapResponse { - map: Map, - mode: Option, -} - -#[derive(Debug, Clone, Default)] -pub struct MpTeamResponse { - user: User, - team: Team, -} - -#[derive(Debug, Clone, Default)] -pub struct MpMoveResponse { - user: User, - slot: usize, -} - -#[derive(Debug, Clone, Default)] -pub struct MpModsResponse { - mods: Mods, - freemod: bool, +/// This is defined for those commands which can be followed by a tail text with +/// unchanged semantics. +/// +/// For example, `!mp size` is tailable because the following command is valid +/// +/// ```bancho +/// !mp size 16 | Welcome to my lobby. +/// ``` +/// +/// and its effect remains the same as `!mp size 16`. +pub trait TailableCommand: Command + Sized { + /// Returns a valid command string tailed with specific string. + fn tailed_command_string(&self, tail: &str) -> String { + let mut s = self.command_string(); + s.push_str(&tail); + s + } + fn into_tailed(self, tail: impl AsRef) -> TailedCommand { + TailedCommand::new(self, tail.as_ref().to_string()) + } } +/// This is a wrapper for commands that implements [`TailableCommand`], which +/// adds a tail string to the command. +/// +/// For example, `TailedCommand::new(MpSettings).push(" | Hello")` is equivalent +/// to sending `!mp settings | Hello` command. #[derive(Debug, Clone)] -pub struct Response { - source: Command, - inner: ResponseInner, +pub struct TailedCommand { + inner: T, + tail: Option, } -impl Response { - pub fn inner(&self) -> &ResponseInner { - &self.inner +impl TailedCommand { + pub fn new(command: T, tail: impl AsRef) -> Self { + Self { + inner: command, + tail: Some(tail.as_ref().to_string()), + } } - pub fn source(&self) -> Command { - self.source.clone() + pub fn tail(&self) -> Option<&str> { + self.tail.as_deref() } - pub fn make(&self) -> &MpMakeResponse { - match self.inner { - ResponseInner::MpMake(ref make) => make, - _ => { - unreachable!("") - } - } + pub fn set_tail(&mut self, tail: Option>) { + self.tail = tail.map(|t| t.as_ref().to_string()); } - pub fn settings(&self) -> &MpSettingsResponse { - match self.inner { - ResponseInner::MpSettings(ref settings) => settings, - _ => { - unreachable!("") + pub fn push(&mut self, tail: impl AsRef) -> &mut Self { + match &mut self.tail { + None => { + self.tail = Some(tail.as_ref().to_string()); + } + Some(s) => { + s.push_str(tail.as_ref()); } } + self } +} - pub fn is_ok(&self) -> bool { - match self.inner { - ResponseInner::Err(_) => false, - _ => true, - } +impl Command for TailedCommand { + type Body = T::Body; + type Error = T::Error; + fn sendable_in_channel(&self, channel: &Channel) -> bool { + self.inner.sendable_in_channel(channel) } - - pub fn is_err(&self) -> bool { - !self.is_ok() + fn sendable_to_user(&self, user: &User) -> bool { + self.inner.sendable_to_user(user) } - - pub fn error(&self) -> Error { - match self.inner { - ResponseInner::Err(e) => e, - _ => unreachable!(""), + fn command_string(&self) -> String { + match &self.tail { + None => self.inner.command_string(), + Some(t) => self.inner.tailed_command_string(t), } } - - pub fn user(&self) -> User { - match self.inner { - ResponseInner::MpHost(ref user) => user.clone(), - ResponseInner::MpAddRef(ref user) => user.clone(), - ResponseInner::MpRemoveRef(ref user) => user.clone(), - _ => unreachable!(""), - } + fn tracks_message( + &self, + context: &mut CommandContext, + message: Message, + ) -> TrackVerdict { + self.inner.tracks_message(context, message) } } #[derive(Debug, Clone)] -pub enum ResponseInner { - Ok, - - // standard commands - Where(WhereResponse), - Stats(StatsResponse), - Roll(u32), - - // multiplayer commands - MpMake(MpMakeResponse), - - MpName(String), - - MpSize(usize), - MpSet(MpSetResponse), - MpMods(MpModsResponse), - MpMap(MpMapResponse), - - MpHost(User), - MpMove(MpMoveResponse), - MpTeam(MpTeamResponse), - - MpSettings(MpSettingsResponse), - - MpAddRef(User), - MpRemoveRef(User), - MpListRefs(Vec), - - Err(Error), +pub struct Response { + pub(super) command: T, + pub(super) messages: Vec, + pub(super) body: Result, } -#[derive(Debug, Copy, Clone)] -pub enum Error { - UserNotFound, - - WhereOffline, - - MpMakeExceeded, - MpSizeInvalid, - MpSetInvalid, - MpMapInvalid, - - MpMoveFailed, - - MpStartDuplicated, - - MpInviteAlreadyPresent, - MpRefNotFound, -} - -#[derive(Debug)] -struct RegexSetMatcher { - set: RegexSet, - regexes: Vec, -} - -impl RegexSetMatcher { - fn new(patterns: I) -> Self - where - S: AsRef + Clone, - I: IntoIterator, - { - let patterns: Vec = patterns.into_iter().collect(); - let set = RegexSet::new(patterns.as_slice()).unwrap(); - Self { - set, - regexes: patterns - .into_iter() - .map(|p| Regex::new(p.as_ref()).unwrap()) - .collect(), - } - } - fn matches<'a>(&'a self, s: &'a str) -> Option<(usize, regex::Captures)> { - self.set - .matches(s) - .into_iter() - .next() - .map(|i| (i, self.regexes[i].captures(s).unwrap())) +impl Response { + pub fn command(&self) -> &T { + &self.command } -} - -#[derive(Debug, Clone)] -pub enum Message { - // standard commands - WhereResponse { - user: String, - location: String, - }, - WhereOfflineError, - StatsResponseStatus { - user: User, - status: UserStatus, - }, - StatsResponseScoreRank { - score: u64, - rank: u32, - }, - StatsResponsePlayCountLevel { - play_count: u32, - level: u32, - }, - StatsResponseAccuracy { - accuracy: f32, - }, - - RollResponse { - user: String, - value: u32, - }, - - // create or close multiplayer games - MpMakeResponse { - id: u64, - title: String, - }, - MpMakeError, - MpCloseResponse, - - // configure multiplayer game - MpNameResponse(String), - MpPasswordResponseChanged, - MpPasswordResponseRemoved, - MpSizeResponse(usize), - MpSizeError, - MpSetResponse { - size: Option, - team_mode: Option, - score_mode: Option, - }, - MpSetError, - MpModsResponse { - mods: Mods, - freemod: bool, - }, - MpMapResponseMap(Map), - MpMapResponseGameMode(GameMode), - MpMapInvalidError, - - // multiplayer slot-related commands - MpHostResponse(User), - MpClearHostResponse, - MpLockResponse, - MpUnlockResponse, - MpMoveResponse { - user: User, - slot: usize, - }, - MpMoveError { - user: User, - slot: usize, - }, - MpTeamResponse { - user: User, - team: String, - }, - - // get multiplayer game settings - MpSettingsResponseRoomName { - name: String, - id: u64, - }, - MpSettingsResponseMap { - name: String, - id: u64, - }, - MpSettingsResponseTeamMode { - team_mode: TeamMode, - score_mode: ScoreMode, - }, - MpSettingsResponseActiveMods { - mods: Mods, - freemod: bool, - }, - MpSettingsResponsePlayers(usize), - MpSettingsResponseSlot { - slot: usize, - status: SlotStatus, - user: User, - mods: Mods, - host: bool, - team: Option, - }, - - // multiplayer match commands - MpStartResponse, - MpStartTimerResponse, - MpStartDuplicatedError, - MpAbortStartTimerResponse, - MpTimerResponse, - MpAbortTimerResponse, - MpAbortResponse, - - // multiplayer player management - MpInviteResponse(User), - MpInviteAlready, - MpKickResponse(User), - MpBanResponse(User), - MpAddrefResponse(User), - MpRemoverefResponse(User), - MpListRefsPrompt, - MpRefError, - - // general errors - UserNotFoundError, - - // multiplayer game events - // slot-related events - PlayerJoinedEvent { - user: User, - slot: usize, - team: Option, - }, - PlayerMovedEvent { - user: User, - slot: usize, - }, - PlayerChangedTeamEvent { - user: User, - team: Team, - }, - PlayerLeftEvent(User), - - // host-related events - HostChangedEvent(User), - HostChangingMapEvent, - MapChangedEvent(Map), - - // match-related events - PlayersReadyEvent, - MatchStartedEvent, - MatchPlayerScoreEvent { - user: User, - score: u64, - alive: bool, - }, - MatchFinishedEvent, - - // timer related events - StartTimer(usize), - StartTimerEnd, - Timer(usize), - TimerEnd, - - Raw(String), -} - -#[derive(Debug)] -pub struct MessageMatcher<'a> { - matcher: RegexSetMatcher, - keys: Vec<&'a str>, -} - -static MESSAGE_MAP: &[(&str, &str)] = &[ - // standard commands - // response to `!where` - ("where-response", r"^(?P.+) is in ?(?P.*)$"), - ("where-error", r"^The user is currently not online.$"), - // responses to `!stats` - ( - "stats-status", - r"^Stats for \((?P.+)\)\[https://osu.ppy.sh/u/(?P\d+)\](?: is )?(?PIdle|Afk|Playing|Lobby|Multiplayer|Multiplaying|Modding|Watching)?:$", - ), - ( - "stats-score", - r"^Score: *(?P[\d,]+) \(#(?P\d+)\)$", - ), - ( - "stats-playcount", - r"^Plays: *(?P\d+) \(lv(?P\d+)\)$", - ), - ("stats-accuracy", r"^Accuracy: +(?P[\d.]+)%$"), - ("stats-error", r"^No user specified$"), - ( - "roll-response", - r"^(?P.+) rolls (?P\d+) point\(s\)$", - ), - // create or close multiplayer games - // responses to `!mp make` `!mp makeprivate` and `!mp close` - ( - "make-response", - r"^Created the tournament match https://osu\.ppy\.sh/mp/(?P\d+) (?P.+)$", - ), - ( - "make-error", - r"^You cannot create any more tournament matches. Please close any previous tournament matches you have open.$", - ), - ("close-response", r"^Closed the match$"), - // configure multiplayer game - // response to `!mp name` - ("name-response", "^Room name updated to \"(?P.+)\"$"), - // responses to `!mp password` - ("password-changed", r"^Changed the match password$"), - ("password-removed", r"^Removed the match password$"), // when password is empty - // response to `!mp size` - ("size-response", r"^Changed match to size (?P\d+)$"), - ("size-error", r"^Invalid or no size provided$"), - // response to `!mp set` - ( - "set-response", - r"^Changed match settings to (?:(?P\d+) slots)?(?:, *)?(?PHeadToHead|TagCoop|TeamVs|TagTeamVs)(?:, *)?(?PScore|Accuracy|Combo|ScoreV2)?$", - ), - ("set-error", r"^Invalid or no settings provided$"), - // response to `!mp mods` - ( - "mods-response", - r"^(Enabled (?P.+)|Disabled all mods), (?Pdisabled|enabled) FreeMod", - ), - // responses to `!mp map` - ( - "map-response", - r"^Changed beatmap to https://osu\.ppy\.sh/b/(?P\d+) (?P.+)$", - ), - ( - "map-gamemode", - r"^Changed match mode to (?POsu|Taiko|CatchTheBeat|OsuMania)$", - ), - ("map-error", r"^Invalid map ID provided$"), - // multiplayer slot-related commands - // responses to `!mp host` and `!mp clearhost` - ("host-response", r"^Changed match host to (?P.+)$"), - ("clearhost-response", r"^Cleared match host$"), - // responses to `!mp lock` and `!mp unlock` - ("lock-response", r"^Locked the match$"), - ("unlock-response", r"^Unlocked the match$"), - // response to `!mp move` - ( - "move-response", - r"^Moved (?P.+) into slot (?P\d+)$", - ), - ( - "move-error", - r"^Failed to move player to slot (?P\d+)$", - ), - // response to `!mp team` - ( - "team-response", - r"^Moved (?P.+) to team (?PRed|Blue)$", - ), - // get multiplayer game settings - // responses to `!mp settings` - ( - "settings-name", - r"^Room name: (?P.+), History: https://osu\.ppy\.sh/mp/(?P\d+)$", - ), - ( - "settings-map", - r"^Beatmap: https://osu\.ppy\.sh/b/(?P\d+) (?P.+)$", - ), - ( - "settings-team", - r"^Team mode: (?P.+), Win condition: (?P.+)$", - ), - ("settings-mods", r"^Active mods: (?P.+)$"), - ("settings-size", r"^Players: (?P\d+)$"), - ( - "settings-slot", - r"^Slot (?P\d+) +(?PNot Ready|Ready|No Map) +https://osu\.ppy\.sh/u/(?P\d+) (?P.{16,}?) *(?:\[(?P(.+))\])?$", - ), - // multiplayer match commands - // responses to `!mp start` - ("start-response", r"^Started the match$"), - ( - "start-timer", - r"^Queued the match to start in (?P