commit ea042c33ba1723b122cbf2a799505755dab871aa Author: Artyom Belousov Date: Sun Feb 8 01:01:17 2026 +0300 Initial commit diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..4f83806 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,2 @@ +/target +/.env diff --git a/.env.template b/.env.template new file mode 100644 index 0000000..8094b70 --- /dev/null +++ b/.env.template @@ -0,0 +1,18 @@ +TELEGRAM_API_URL=https://api.telegram.org +TELEGRAM_SECRET= +TELEGRAM_TOKEN= +TELEGRAM_CALLBACK_URL= + +VK_API_URL=https://api.vk.ru +VK_TOKEN= +VK_SECRET= +VK_CONFIRMATION_CODE= + +SCG_SEARCH_URL=https://starcitygamesv2.searchapi-na.hawksearch.com +SCG_CLIENT_GUID=cc3be22005ef47d3969c3de28f09571b +SCG_DISPLAY_URL=https://starcitygames.com +SCG_MAX_OFFERS=5 + +SCRYFALL_URL=https://api.scryfall.com + +BIND_ADDRESS=0.0.0.0:3000 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..d1c71cd --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +/target +/.env +/.envrc diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..3d00d5b --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,2354 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + +[[package]] +name = "anstyle" +version = "1.0.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5192cca8006f1fd4f7237516f40fa183bb07f8fbdfedaa0036de5ea9b0b45e78" + +[[package]] +name = "anyhow" +version = "1.0.101" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f0e0fee31ef5ed1ba1316088939cea399010ed7731dba877ed44aeb407a75ea" + +[[package]] +name = "assert-json-diff" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47e4f2b81832e72834d7518d8487a0396a28cc408186a2e8854c0f98011faf12" +dependencies = [ + "serde", + "serde_json", +] + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + +[[package]] +name = "aws-lc-rs" +version = "1.15.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b7b6141e96a8c160799cc2d5adecd5cbbe5054cb8c7c4af53da0f83bb7ad256" +dependencies = [ + "aws-lc-sys", + "zeroize", +] + +[[package]] +name = "aws-lc-sys" +version = "0.37.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c34dda4df7017c8db52132f0f8a2e0f8161649d15723ed63fc00c82d0f2081a" +dependencies = [ + "cc", + "cmake", + "dunce", + "fs_extra", +] + +[[package]] +name = "axum" +version = "0.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b52af3cb4058c895d37317bb27508dccc8e5f2d39454016b297bf4a400597b8" +dependencies = [ + "axum-core", + "bytes", + "form_urlencoded", + "futures-util", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-util", + "itoa", + "matchit", + "memchr", + "mime", + "percent-encoding", + "pin-project-lite", + "serde_core", + "serde_json", + "serde_path_to_error", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tower", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "axum-core" +version = "0.5.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08c78f31d7b1291f7ee735c1c6780ccde7785daae9a9206026862dab7d8792d1" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "http-body-util", + "mime", + "pin-project-lite", + "sync_wrapper", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "bitflags" +version = "2.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "812e12b5285cc515a9c72a5c1d3b6d46a19dac5acfef5265968c166106e31dd3" + +[[package]] +name = "bumpalo" +version = "3.19.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5dd9dc738b7a8311c7ade152424974d8115f2cdad61e8dab8dac9f2362298510" + +[[package]] +name = "bytes" +version = "1.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" + +[[package]] +name = "cc" +version = "1.2.55" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47b26a0954ae34af09b50f0de26458fa95369a0d478d8236d3f93082b219bd29" +dependencies = [ + "find-msvc-tools", + "jobserver", + "libc", + "shlex", +] + +[[package]] +name = "cesu8" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d43a04d8753f35258c91f8ec639f792891f748a1edbd759cf1dcea3382ad83c" + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "cfg_aliases" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" + +[[package]] +name = "cmake" +version = "0.1.57" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75443c44cd6b379beb8c5b45d85d0773baf31cce901fe7bb252f4eff3008ef7d" +dependencies = [ + "cc", +] + +[[package]] +name = "colored" +version = "3.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "faf9468729b8cbcea668e36183cb69d317348c2e08e994829fb56ebfdfbaac34" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "combine" +version = "4.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba5a308b75df32fe02788e748662718f03fde005016435c444eea572398219fd" +dependencies = [ + "bytes", + "memchr", +] + +[[package]] +name = "core-foundation" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "displaydoc" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "downcast" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1435fa1053d8b2fbbe9be7e97eca7f33d37b28409959813daefc1446a14247f1" + +[[package]] +name = "dunce" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" + +[[package]] +name = "encoding_rs" +version = "0.8.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "fragile" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28dd6caf6059519a65843af8fe2a3ae298b14b80179855aeb4adc2c1934ee619" + +[[package]] +name = "fs_extra" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" + +[[package]] +name = "futures-channel" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2dff15bf788c671c1934e366d07e30c1814a8ef514e1af724a602e8a2fbe1b10" +dependencies = [ + "futures-core", +] + +[[package]] +name = "futures-core" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05f29059c0c2090612e8d742178b0580d2dc940c837851ad723096f87af6663e" + +[[package]] +name = "futures-macro" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "162ee34ebcb7c64a8abebc059ce0fee27c2262618d7b60ed8faf72fef13c3650" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "futures-sink" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e575fab7d1e0dcb8d0c7bcf9a63ee213816ab51902e6d244a95819acacf1d4f7" + +[[package]] +name = "futures-task" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f90f7dce0722e95104fcb095585910c0977252f286e354b5e3bd38902cd99988" + +[[package]] +name = "futures-timer" +version = "3.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f288b0a4f20f9a56b5d1da57e2227c661b7b16168e2f72365f57b63326e29b24" + +[[package]] +name = "futures-util" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9fa08315bb612088cc391249efdc3bc77536f16c91f6cf495e6fbe85b20a4a81" +dependencies = [ + "futures-core", + "futures-macro", + "futures-task", + "pin-project-lite", + "pin-utils", + "slab", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "wasi", + "wasm-bindgen", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "r-efi", + "wasip2", + "wasm-bindgen", +] + +[[package]] +name = "glob" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280" + +[[package]] +name = "h2" +version = "0.4.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f44da3a8150a6703ed5d34e164b875fd14c2cdab9af1252a9a1020bde2bdc54" +dependencies = [ + "atomic-waker", + "bytes", + "fnv", + "futures-core", + "futures-sink", + "http", + "indexmap", + "slab", + "tokio", + "tokio-util", + "tracing", +] + +[[package]] +name = "hashbrown" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" + +[[package]] +name = "http" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3ba2a386d7f85a81f119ad7498ebe444d2e22c2af0b86b069416ace48b3311a" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "http-body" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" +dependencies = [ + "bytes", + "http", +] + +[[package]] +name = "http-body-util" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "pin-project-lite", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "httpdate" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" + +[[package]] +name = "hyper" +version = "1.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ab2d4f250c3d7b1c9fcdff1cece94ea4e2dfbec68614f7b87cb205f24ca9d11" +dependencies = [ + "atomic-waker", + "bytes", + "futures-channel", + "futures-core", + "h2", + "http", + "http-body", + "httparse", + "httpdate", + "itoa", + "pin-project-lite", + "pin-utils", + "smallvec", + "tokio", + "want", +] + +[[package]] +name = "hyper-rustls" +version = "0.27.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3c93eb611681b207e1fe55d5a71ecf91572ec8a6705cdb6857f7d8d5242cf58" +dependencies = [ + "http", + "hyper", + "hyper-util", + "rustls", + "rustls-pki-types", + "tokio", + "tokio-rustls", + "tower-service", +] + +[[package]] +name = "hyper-util" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" +dependencies = [ + "base64", + "bytes", + "futures-channel", + "futures-util", + "http", + "http-body", + "hyper", + "ipnet", + "libc", + "percent-encoding", + "pin-project-lite", + "socket2", + "system-configuration", + "tokio", + "tower-service", + "tracing", + "windows-registry", +] + +[[package]] +name = "icu_collections" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c6b649701667bbe825c3b7e6388cb521c23d88644678e83c0c4d0a621a34b43" +dependencies = [ + "displaydoc", + "potential_utf", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edba7861004dd3714265b4db54a3c390e880ab658fec5f7db895fae2046b5bb6" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f6c8828b67bf8908d82127b2054ea1b4427ff0230ee9141c54251934ab1b599" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7aedcccd01fc5fe81e6b489c15b247b8b0690feb23304303a9e560f37efc560a" + +[[package]] +name = "icu_properties" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "020bfc02fe870ec3a66d93e677ccca0562506e5872c650f893269e08615d74ec" +dependencies = [ + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "616c294cf8d725c6afcd8f55abc17c56464ef6211f9ed59cccffe534129c77af" + +[[package]] +name = "icu_provider" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85962cf0ce02e1e0a629cc34e7ca3e373ce20dda4c4d7294bbd0bf1fdb59e614" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3acae9609540aa318d1bc588455225fb2085b9ed0c4f6bd0d9d5bcd86f1a0344" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "indexmap" +version = "2.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7714e70437a7dc3ac8eb7e6f8df75fd8eb422675fc7678aff7364301092b1017" +dependencies = [ + "equivalent", + "hashbrown", +] + +[[package]] +name = "ipnet" +version = "2.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "469fb0b9cefa57e3ef31275ee7cacb78f2fdca44e4765491884a2b119d4eb130" + +[[package]] +name = "iri-string" +version = "0.7.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c91338f0783edbd6195decb37bae672fd3b165faffb89bf7b9e6942f8b1a731a" +dependencies = [ + "memchr", + "serde", +] + +[[package]] +name = "itoa" +version = "1.0.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2" + +[[package]] +name = "jni" +version = "0.21.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a87aa2bb7d2af34197c04845522473242e1aa17c12f4935d5856491a7fb8c97" +dependencies = [ + "cesu8", + "cfg-if", + "combine", + "jni-sys", + "log", + "thiserror 1.0.69", + "walkdir", + "windows-sys 0.45.0", +] + +[[package]] +name = "jni-sys" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8eaf4bc02d17cbdd7ff4c7438cafcdf7fb9a4613313ad11b4f8fefe7d3fa0130" + +[[package]] +name = "jobserver" +version = "0.1.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33" +dependencies = [ + "getrandom 0.3.4", + "libc", +] + +[[package]] +name = "js-sys" +version = "0.3.85" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8c942ebf8e95485ca0d52d97da7c5a2c387d0e7f0ba4c35e93bfcaee045955b3" +dependencies = [ + "once_cell", + "wasm-bindgen", +] + +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + +[[package]] +name = "libc" +version = "0.2.180" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bcc35a38544a891a5f7c865aca548a982ccb3b8650a5b06d0fd33a10283c56fc" + +[[package]] +name = "litemap" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6373607a59f0be73a39b6fe456b8192fcc3585f602af20751600e974dd455e77" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" + +[[package]] +name = "lru-slab" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" + +[[package]] +name = "matchit" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3" + +[[package]] +name = "memchr" +version = "2.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" + +[[package]] +name = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + +[[package]] +name = "mio" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a69bcab0ad47271a0234d9422b131806bf3968021e5dc9328caf2d4cd58557fc" +dependencies = [ + "libc", + "wasi", + "windows-sys 0.61.2", +] + +[[package]] +name = "mockall" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f58d964098a5f9c6b63d0798e5372fd04708193510a7af313c22e9f29b7b620b" +dependencies = [ + "cfg-if", + "downcast", + "fragile", + "mockall_derive", + "predicates", + "predicates-tree", +] + +[[package]] +name = "mockall_derive" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca41ce716dda6a9be188b385aa78ee5260fc25cd3802cb2a8afdc6afbe6b6dbf" +dependencies = [ + "cfg-if", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "mockito" +version = "1.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90820618712cab19cfc46b274c6c22546a82affcb3c3bdf0f29e3db8e1bb92c0" +dependencies = [ + "assert-json-diff", + "bytes", + "colored", + "futures-core", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-util", + "log", + "pin-project-lite", + "rand", + "regex", + "serde_json", + "serde_urlencoded", + "similar", + "tokio", +] + +[[package]] +name = "mtg-price-bot" +version = "0.1.0" +dependencies = [ + "anyhow", + "axum", + "mockall", + "mockito", + "reqwest", + "rstest", + "serde", + "serde_json", + "tokio", + "tracing", + "tracing-subscriber", +] + +[[package]] +name = "nu-ansi-term" +version = "0.50.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "once_cell" +version = "1.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" + +[[package]] +name = "openssl-probe" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-link", +] + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "pin-project-lite" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b3cff922bd51709b605d9ead9aa71031d81447142d828eb4a6eba76fe619f9b" + +[[package]] +name = "pin-utils" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" + +[[package]] +name = "potential_utf" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b73949432f5e2a09657003c25bca5e19a0e9c84f8058ca374f49e0ebe605af77" +dependencies = [ + "zerovec", +] + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "predicates" +version = "3.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5d19ee57562043d37e82899fade9a22ebab7be9cef5026b07fda9cdd4293573" +dependencies = [ + "anstyle", + "predicates-core", +] + +[[package]] +name = "predicates-core" +version = "1.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "727e462b119fe9c93fd0eb1429a5f7647394014cf3c04ab2c0350eeb09095ffa" + +[[package]] +name = "predicates-tree" +version = "1.0.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72dd2d6d381dfb73a193c7fca536518d7caee39fc8503f74e7dc0be0531b425c" +dependencies = [ + "predicates-core", + "termtree", +] + +[[package]] +name = "proc-macro-crate" +version = "3.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "219cb19e96be00ab2e37d6e299658a0cfa83e52429179969b0f0121b4ac46983" +dependencies = [ + "toml_edit", +] + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quinn" +version = "0.11.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e20a958963c291dc322d98411f541009df2ced7b5a4f2bd52337638cfccf20" +dependencies = [ + "bytes", + "cfg_aliases", + "pin-project-lite", + "quinn-proto", + "quinn-udp", + "rustc-hash", + "rustls", + "socket2", + "thiserror 2.0.18", + "tokio", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-proto" +version = "0.11.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1906b49b0c3bc04b5fe5d86a77925ae6524a19b816ae38ce1e426255f1d8a31" +dependencies = [ + "aws-lc-rs", + "bytes", + "getrandom 0.3.4", + "lru-slab", + "rand", + "ring", + "rustc-hash", + "rustls", + "rustls-pki-types", + "slab", + "thiserror 2.0.18", + "tinyvec", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-udp" +version = "0.5.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "addec6a0dcad8a8d96a771f815f0eaf55f9d1805756410b39f5fa81332574cbd" +dependencies = [ + "cfg_aliases", + "libc", + "once_cell", + "socket2", + "tracing", + "windows-sys 0.60.2", +] + +[[package]] +name = "quote" +version = "1.0.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "21b2ebcf727b7760c461f091f9f0f539b77b8e87f2fd88131e7f1b433b3cece4" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "rand" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1" +dependencies = [ + "rand_chacha", + "rand_core", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core", +] + +[[package]] +name = "rand_core" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +dependencies = [ + "getrandom 0.3.4", +] + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags", +] + +[[package]] +name = "regex" +version = "1.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a96887878f22d7bad8a3b6dc5b7440e0ada9a245242924394987b21cf2210a4c" + +[[package]] +name = "relative-path" +version = "1.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba39f3699c378cd8970968dcbff9c43159ea4cfbd88d43c00b22f2ef10a435d2" + +[[package]] +name = "reqwest" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab3f43e3283ab1488b624b44b0e988d0acea0b3214e694730a055cb6b2efa801" +dependencies = [ + "base64", + "bytes", + "encoding_rs", + "futures-core", + "h2", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-util", + "js-sys", + "log", + "mime", + "percent-encoding", + "pin-project-lite", + "quinn", + "rustls", + "rustls-pki-types", + "rustls-platform-verifier", + "serde", + "serde_json", + "sync_wrapper", + "tokio", + "tokio-rustls", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.17", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + +[[package]] +name = "rstest" +version = "0.26.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f5a3193c063baaa2a95a33f03035c8a72b83d97a54916055ba22d35ed3839d49" +dependencies = [ + "futures-timer", + "futures-util", + "rstest_macros", +] + +[[package]] +name = "rstest_macros" +version = "0.26.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c845311f0ff7951c5506121a9ad75aec44d083c31583b2ea5a30bcb0b0abba0" +dependencies = [ + "cfg-if", + "glob", + "proc-macro-crate", + "proc-macro2", + "quote", + "regex", + "relative-path", + "rustc_version", + "syn", + "unicode-ident", +] + +[[package]] +name = "rustc-hash" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "357703d41365b4b27c590e3ed91eabb1b663f07c4c084095e60cbed4362dff0d" + +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + +[[package]] +name = "rustls" +version = "0.23.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c665f33d38cea657d9614f766881e4d510e0eda4239891eea56b4cadcf01801b" +dependencies = [ + "aws-lc-rs", + "once_cell", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-native-certs" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "612460d5f7bea540c490b2b6395d8e34a953e52b491accd6c86c8164c5932a63" +dependencies = [ + "openssl-probe", + "rustls-pki-types", + "schannel", + "security-framework", +] + +[[package]] +name = "rustls-pki-types" +version = "1.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be040f8b0a225e40375822a563fa9524378b9d63112f53e19ffff34df5d33fdd" +dependencies = [ + "web-time", + "zeroize", +] + +[[package]] +name = "rustls-platform-verifier" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d99feebc72bae7ab76ba994bb5e121b8d83d910ca40b36e0921f53becc41784" +dependencies = [ + "core-foundation 0.10.1", + "core-foundation-sys", + "jni", + "log", + "once_cell", + "rustls", + "rustls-native-certs", + "rustls-platform-verifier-android", + "rustls-webpki", + "security-framework", + "security-framework-sys", + "webpki-root-certs", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls-platform-verifier-android" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f87165f0995f63a9fbeea62b64d10b4d9d8e78ec6d7d51fb2125fda7bb36788f" + +[[package]] +name = "rustls-webpki" +version = "0.103.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7df23109aa6c1567d1c575b9952556388da57401e4ace1d15f79eedad0d8f53" +dependencies = [ + "aws-lc-rs", + "ring", + "rustls-pki-types", + "untrusted", +] + +[[package]] +name = "rustversion" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" + +[[package]] +name = "ryu" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a50f4cf475b65d88e057964e0e9bb1f0aa9bbb2036dc65c64596b42932536984" + +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "schannel" +version = "0.1.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "891d81b926048e76efe18581bf793546b4c0eaf8448d72be8de2bbee5fd166e1" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "security-framework" +version = "3.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b3297343eaf830f66ede390ea39da1d462b6b0c1b000f420d0a83f898bbbe6ef" +dependencies = [ + "bitflags", + "core-foundation 0.10.1", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework-sys" +version = "2.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc1f0cbffaac4852523ce30d8bd3c5cdc873501d96ff467ca09b6767bb8cd5c0" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "semver" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d767eb0aabc880b29956c35734170f26ed551a859dbd361d140cdbeca61ab1e2" + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.149" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_path_to_error" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10a9ff822e371bb5403e391ecd83e182e0e77ba7f6fe0160b795797109d1b457" +dependencies = [ + "itoa", + "serde", + "serde_core", +] + +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + +[[package]] +name = "sharded-slab" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6" +dependencies = [ + "lazy_static", +] + +[[package]] +name = "shlex" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" + +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + +[[package]] +name = "similar" +version = "2.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbbb5d9659141646ae647b42fe094daf6c6192d1620870b449d9557f748b2daa" + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" + +[[package]] +name = "socket2" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "86f4aa3ad99f2088c990dfa82d367e19cb29268ed67c574d10d0a4bfe71f07e0" +dependencies = [ + "libc", + "windows-sys 0.60.2", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "syn" +version = "2.0.114" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4d107df263a3013ef9b1879b0df87d706ff80f65a86ea879bd9c31f9b307c2a" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" +dependencies = [ + "futures-core", +] + +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "system-configuration" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a13f3d0daba03132c0aa9767f98351b3488edc2c100cda2d2ec2b04f3d8d3c8b" +dependencies = [ + "bitflags", + "core-foundation 0.9.4", + "system-configuration-sys", +] + +[[package]] +name = "system-configuration-sys" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e1d1b10ced5ca923a1fcb8d03e96b8d3268065d724548c0211415ff6ac6bac4" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "termtree" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f50febec83f5ee1df3015341d8bd429f2d1cc62bcba7ea2076759d315084683" + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl 1.0.69", +] + +[[package]] +name = "thiserror" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" +dependencies = [ + "thiserror-impl 2.0.18", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "thread_local" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f60246a4944f24f6e018aa17cdeffb7818b76356965d03b07d6a9886e8962185" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "tinystr" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42d3e9c45c09de15d06dd8acf5f4e0e399e85927b7f00711024eb7ae10fa4869" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "tinyvec" +version = "1.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa5fdc3bce6191a1dbc8c02d5c8bffcf557bafa17c124c5264a458f1b0613fa" +dependencies = [ + "tinyvec_macros", +] + +[[package]] +name = "tinyvec_macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" + +[[package]] +name = "tokio" +version = "1.49.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72a2903cd7736441aac9df9d7688bd0ce48edccaadf181c3b90be801e81d3d86" +dependencies = [ + "bytes", + "libc", + "mio", + "parking_lot", + "pin-project-lite", + "signal-hook-registry", + "socket2", + "tokio-macros", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-macros" +version = "2.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af407857209536a95c8e56f8231ef2c2e2aff839b22e07a1ffcbc617e9db9fa5" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tokio-rustls" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" +dependencies = [ + "rustls", + "tokio", +] + +[[package]] +name = "tokio-util" +version = "0.7.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "toml_datetime" +version = "0.7.5+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92e1cfed4a3038bc5a127e35a2d360f145e1f4b971b551a2ba5fd7aedf7e1347" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_edit" +version = "0.23.10+spec-1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "84c8b9f757e028cee9fa244aea147aab2a9ec09d5325a9b01e0a49730c2b5269" +dependencies = [ + "indexmap", + "toml_datetime", + "toml_parser", + "winnow", +] + +[[package]] +name = "toml_parser" +version = "1.0.6+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a3198b4b0a8e11f09dd03e133c0280504d0801269e9afa46362ffde1cbeebf44" +dependencies = [ + "winnow", +] + +[[package]] +name = "tower" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" +dependencies = [ + "futures-core", + "futures-util", + "pin-project-lite", + "sync_wrapper", + "tokio", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "tower-http" +version = "0.6.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4e6559d53cc268e5031cd8429d05415bc4cb4aefc4aa5d6cc35fbf5b924a1f8" +dependencies = [ + "bitflags", + "bytes", + "futures-util", + "http", + "http-body", + "iri-string", + "pin-project-lite", + "tower", + "tower-layer", + "tower-service", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "log", + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", + "valuable", +] + +[[package]] +name = "tracing-log" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3" +dependencies = [ + "log", + "once_cell", + "tracing-core", +] + +[[package]] +name = "tracing-subscriber" +version = "0.3.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f30143827ddab0d256fd843b7a66d164e9f271cfa0dde49142c5ca0ca291f1e" +dependencies = [ + "nu-ansi-term", + "sharded-slab", + "smallvec", + "thread_local", + "tracing-core", + "tracing-log", +] + +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + +[[package]] +name = "unicode-ident" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9312f7c4f6ff9069b165498234ce8be658059c6728633667c526e27dc2cf1df5" + +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", +] + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "valuable" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" + +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + +[[package]] +name = "want" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" +dependencies = [ + "try-lock", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasip2" +version = "1.0.2+wasi-0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9517f9239f02c069db75e65f174b3da828fe5f5b945c4dd26bd25d89c03ebcf5" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.108" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "64024a30ec1e37399cf85a7ffefebdb72205ca1c972291c51512360d90bd8566" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.58" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70a6e77fd0ae8029c9ea0063f87c46fde723e7d887703d74ad2616d792e51e6f" +dependencies = [ + "cfg-if", + "futures-util", + "js-sys", + "once_cell", + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.108" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "008b239d9c740232e71bd39e8ef6429d27097518b6b30bdf9086833bd5b6d608" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.108" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5256bae2d58f54820e6490f9839c49780dff84c65aeab9e772f15d5f0e913a55" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.108" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f01b580c9ac74c8d8f0c0e4afb04eeef2acf145458e52c03845ee9cd23e3d12" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "web-sys" +version = "0.3.85" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "312e32e551d92129218ea9a2452120f4aabc03529ef03e4d0d82fb2780608598" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "web-time" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "webpki-root-certs" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "804f18a4ac2676ffb4e8b5b5fa9ae38af06df08162314f96a68d2a363e21a8ca" +dependencies = [ + "rustls-pki-types", +] + +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-registry" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02752bf7fbdcce7f2a27a742f798510f3e5ad88dbe84871e5168e2120c3d5720" +dependencies = [ + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-sys" +version = "0.45.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75283be5efb2831d37ea142365f009c02ec203cd29a3ebecbc093d52315b66d0" +dependencies = [ + "windows-targets 0.42.2", +] + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" +dependencies = [ + "windows-targets 0.53.5", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-targets" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e5180c00cd44c9b1c88adb3693291f1cd93605ded80c250a75d472756b4d071" +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", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm 0.52.6", + "windows_aarch64_msvc 0.52.6", + "windows_i686_gnu 0.52.6", + "windows_i686_gnullvm 0.52.6", + "windows_i686_msvc 0.52.6", + "windows_x86_64_gnu 0.52.6", + "windows_x86_64_gnullvm 0.52.6", + "windows_x86_64_msvc 0.52.6", +] + +[[package]] +name = "windows-targets" +version = "0.53.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" +dependencies = [ + "windows-link", + "windows_aarch64_gnullvm 0.53.1", + "windows_aarch64_msvc 0.53.1", + "windows_i686_gnu 0.53.1", + "windows_i686_gnullvm 0.53.1", + "windows_i686_msvc 0.53.1", + "windows_x86_64_gnu 0.53.1", + "windows_x86_64_gnullvm 0.53.1", + "windows_x86_64_msvc 0.53.1", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "597a5118570b68bc08d8d59125332c54f1ba9d9adeedeef5b99b02ba2b0698f8" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" + +[[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.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" + +[[package]] +name = "windows_i686_gnu" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c61d927d8da41da96a81f029489353e68739737d3beca43145c8afec9a31a84f" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" + +[[package]] +name = "windows_i686_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44d840b6ec649f480a41c8d80f9c65108b92d89345dd94027bfe06ac444d1060" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_i686_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8de912b8b8feb55c064867cf047dda097f92d51efad5b491dfb98f6bbb70cb36" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26d41b46a36d453748aedef1486d5c7a85db22e56aff34643984ea85514e94a3" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" + +[[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.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" + +[[package]] +name = "winnow" +version = "0.7.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a5364e9d77fcdeeaa6062ced926ee3381faa2ee02d3eb83a5c27a8825540829" +dependencies = [ + "memchr", +] + +[[package]] +name = "wit-bindgen" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" + +[[package]] +name = "writeable" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9edde0db4769d2dc68579893f2306b26c6ecfbe0ef499b013d731b7b9247e0b9" + +[[package]] +name = "yoke" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72d6e5c6afb84d73944e5cedb052c4680d5657337201555f9f2a16b7406d4954" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b659052874eb698efe5b9e8cf382204678a0086ebf46982b79d6ca3182927e5d" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "zerocopy" +version = "0.8.39" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db6d35d663eadb6c932438e763b262fe1a70987f9ae936e60158176d710cae4a" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.39" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4122cd3169e94605190e77839c9a40d40ed048d305bfdc146e7df40ab0f3e517" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "zerofrom" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50cc42e0333e05660c3587f3bf9d0478688e15d870fab3346451ce7f8c9fbea5" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d71e5d6e06ab090c67b5e44993ec16b72dcbaabc526db883a360057678b48502" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "zeroize" +version = "1.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" + +[[package]] +name = "zerotrie" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a59c17a5562d507e4b54960e8569ebee33bee890c70aa3fe7b97e85a9fd7851" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c28719294829477f525be0186d13efa9a3c602f7ec202ca9e353d310fb9a002" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eadce39539ca5cb3985590102671f2567e659fca9666581ad3411d59207951f3" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "zmij" +version = "1.0.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ff05f8caa9038894637571ae6b9e29466c1f4f829d26c9b28f869a29cbe3445" diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..69a96e0 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,17 @@ +[package] +name = "mtg-price-bot" +version = "0.1.0" +edition = "2024" + +[dependencies] +anyhow = "1.0.101" +axum = "0.8.8" +mockall = "0.14.0" +mockito = "1.7.2" +reqwest = { version = "0.13.2", features=["json"] } +rstest = "0.26.1" +serde = { version = "1.0.228", features=["derive"] } +serde_json = "1.0.149" +tokio = { version = "1.49.0", features=["full"] } +tracing = "0.1.44" +tracing-subscriber = "0.3.22" diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..a0d5459 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,21 @@ +FROM lukemathwalker/cargo-chef:latest-rust-1 AS chef +WORKDIR /app + +FROM chef AS planner +COPY . . +RUN cargo chef prepare --recipe-path recipe.json + +FROM chef AS builder +COPY --from=planner /app/recipe.json recipe.json +# Build dependencies - this is the caching Docker layer! +RUN cargo chef cook --release --recipe-path recipe.json +# Build application +COPY . . +RUN cargo build --release --bin mtg-price-bot + +# We do not need the Rust toolchain to run the binary! +FROM debian:trixie-slim AS runtime +WORKDIR /app +COPY --from=builder /app/target/release/mtg-price-bot /usr/local/bin +COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/ +ENTRYPOINT ["/usr/local/bin/mtg-price-bot"] diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..6cead53 --- /dev/null +++ b/LICENSE @@ -0,0 +1,15 @@ +BSD Zero Clause License + +Copyright (c) 2026 Artyom Belousov + +Permission to use, copy, modify, and/or distribute this software for any +purpose with or without fee is hereby granted. + +THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH +REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY +AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, +INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM +LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR +OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR +PERFORMANCE OF THIS SOFTWARE. + diff --git a/Logo.jpg b/Logo.jpg new file mode 100644 index 0000000..e61820d Binary files /dev/null and b/Logo.jpg differ diff --git a/README.md b/README.md new file mode 100644 index 0000000..bcd1328 --- /dev/null +++ b/README.md @@ -0,0 +1,16 @@ +# MTG price bot +EN | [RU](https://codeberg.org/flygrounder/mtg-price-bot/src/branch/main/README_RU.md) + +![Logo](/Logo.jpg) + +VK bot: https://vk.ru/mtg_vk_bot +Telegram bot: https://t.me/MtgScgBot + +Search prices for MTG cards from http://www.starcitygames.com/ without leaving your favourite messenger. +No need to learn bot commands, just send it a card name in any language and it will find prices for different printings of that card. + +To find cards even more quickly, use command +!s XXX YYY +XXX - set number +YYY - collectors number +You can find them in the bottom left corner of a card. diff --git a/README_RU.md b/README_RU.md new file mode 100644 index 0000000..bfb11f8 --- /dev/null +++ b/README_RU.md @@ -0,0 +1,16 @@ +# MTG price bot +[EN](https://codeberg.org/flygrounder/mtg-price-bot/src/branch/main/README.md) | RU + +![Logo](/Logo.jpg) + +Бот в ВК: https://vk.ru/mtg_vk_bot +Бот в Telegram: https://t.me/MtgScgBot + +Ищите цены на карты MTG с сайта http://www.starcitygames.com/ не выходя из вашего любимого мессенджера. +Не нужно учить никаких команд, просто отправьте боту название карты на любом языке и он предложит вам цены на разные издания этой карты. + +Если же вы хотите искать карты ещё быстрее, то воспользуйтесь командой +!s XXX YYY +XXX - короткий код издания +YYY - коллекционный номер карты +Они расположены в нижнем левом углу карты diff --git a/compose.yaml b/compose.yaml new file mode 100644 index 0000000..286ee67 --- /dev/null +++ b/compose.yaml @@ -0,0 +1,8 @@ +services: + app: + restart: unless-stopped + build: . + env_file: + - .env + ports: + - 127.0.0.1:3000:3000 diff --git a/devbox.json b/devbox.json new file mode 100644 index 0000000..868debb --- /dev/null +++ b/devbox.json @@ -0,0 +1,20 @@ +{ + "$schema": "https://raw.githubusercontent.com/jetify-com/devbox/0.16.0/.schema/devbox.schema.json", + "packages": [ + "cargo@latest", + "rustc@latest", + "bacon@latest", + "rustfmt@latest", + "clippy@latest" + ], + "shell": { + "init_hook": [ + "echo 'Welcome to devbox!' > /dev/null" + ], + "scripts": { + "test": [ + "echo \"Error: no test specified\" && exit 1" + ] + } + } +} diff --git a/devbox.lock b/devbox.lock new file mode 100644 index 0000000..73d5782 --- /dev/null +++ b/devbox.lock @@ -0,0 +1,294 @@ +{ + "lockfile_version": "1", + "packages": { + "bacon@latest": { + "last_modified": "2026-01-23T17:20:52Z", + "resolved": "github:NixOS/nixpkgs/a1bab9e494f5f4939442a57a58d0449a109593fe#bacon", + "source": "devbox-search", + "version": "3.22.0", + "systems": { + "aarch64-darwin": { + "outputs": [ + { + "name": "out", + "path": "/nix/store/d1ikw8xrns7cbfqypwcgxcchwzvdq7cg-bacon-3.22.0", + "default": true + } + ], + "store_path": "/nix/store/d1ikw8xrns7cbfqypwcgxcchwzvdq7cg-bacon-3.22.0" + }, + "aarch64-linux": { + "outputs": [ + { + "name": "out", + "path": "/nix/store/kmhjc2p8hxf3hkdja6cqi0khqda5jxv4-bacon-3.22.0", + "default": true + } + ], + "store_path": "/nix/store/kmhjc2p8hxf3hkdja6cqi0khqda5jxv4-bacon-3.22.0" + }, + "x86_64-darwin": { + "outputs": [ + { + "name": "out", + "path": "/nix/store/5ymilhncmbc9iw2il7zj5wzpv7alnhd8-bacon-3.22.0", + "default": true + } + ], + "store_path": "/nix/store/5ymilhncmbc9iw2il7zj5wzpv7alnhd8-bacon-3.22.0" + }, + "x86_64-linux": { + "outputs": [ + { + "name": "out", + "path": "/nix/store/19ca86jyjzy2r6kppssjc0y9r8knxcpf-bacon-3.22.0", + "default": true + } + ], + "store_path": "/nix/store/19ca86jyjzy2r6kppssjc0y9r8knxcpf-bacon-3.22.0" + } + } + }, + "cargo@latest": { + "last_modified": "2026-01-23T17:20:52Z", + "resolved": "github:NixOS/nixpkgs/a1bab9e494f5f4939442a57a58d0449a109593fe#cargo", + "source": "devbox-search", + "version": "1.92.0", + "systems": { + "aarch64-darwin": { + "outputs": [ + { + "name": "out", + "path": "/nix/store/v4bvnkm0p5x41fhybskr0cf2zvkgyrvv-cargo-1.92.0", + "default": true + } + ], + "store_path": "/nix/store/v4bvnkm0p5x41fhybskr0cf2zvkgyrvv-cargo-1.92.0" + }, + "aarch64-linux": { + "outputs": [ + { + "name": "out", + "path": "/nix/store/piiqs6x2m8gv0n3z3pys8scn0y673piy-cargo-1.92.0", + "default": true + } + ], + "store_path": "/nix/store/piiqs6x2m8gv0n3z3pys8scn0y673piy-cargo-1.92.0" + }, + "x86_64-darwin": { + "outputs": [ + { + "name": "out", + "path": "/nix/store/wf6279pgydd1nny4s5nx1msian6dbf9p-cargo-1.92.0", + "default": true + } + ], + "store_path": "/nix/store/wf6279pgydd1nny4s5nx1msian6dbf9p-cargo-1.92.0" + }, + "x86_64-linux": { + "outputs": [ + { + "name": "out", + "path": "/nix/store/yqcsaywfvcyy9wmbzb5fawp29icgi7cb-cargo-1.92.0", + "default": true + } + ], + "store_path": "/nix/store/yqcsaywfvcyy9wmbzb5fawp29icgi7cb-cargo-1.92.0" + } + } + }, + "clippy@latest": { + "last_modified": "2026-01-23T17:20:52Z", + "resolved": "github:NixOS/nixpkgs/a1bab9e494f5f4939442a57a58d0449a109593fe#clippy", + "source": "devbox-search", + "version": "1.92.0", + "systems": { + "aarch64-darwin": { + "outputs": [ + { + "name": "out", + "path": "/nix/store/phhksxd0vv7ml9imsr0lwiqvvmiaz23p-clippy-1.92.0", + "default": true + } + ], + "store_path": "/nix/store/phhksxd0vv7ml9imsr0lwiqvvmiaz23p-clippy-1.92.0" + }, + "aarch64-linux": { + "outputs": [ + { + "name": "out", + "path": "/nix/store/1iqky89dxzj12yy9dsbyrarknswx6iyj-clippy-1.92.0", + "default": true + }, + { + "name": "debug", + "path": "/nix/store/anj7w4nnbw9cvsrsq85vnwqlw8miss5n-clippy-1.92.0-debug" + } + ], + "store_path": "/nix/store/1iqky89dxzj12yy9dsbyrarknswx6iyj-clippy-1.92.0" + }, + "x86_64-darwin": { + "outputs": [ + { + "name": "out", + "path": "/nix/store/34yqzpcjb1cms86lij7wkszcjxdivx6f-clippy-1.92.0", + "default": true + } + ], + "store_path": "/nix/store/34yqzpcjb1cms86lij7wkszcjxdivx6f-clippy-1.92.0" + }, + "x86_64-linux": { + "outputs": [ + { + "name": "out", + "path": "/nix/store/zmrbcbd77w6nylgwyagnxh87all5swjf-clippy-1.92.0", + "default": true + }, + { + "name": "debug", + "path": "/nix/store/yvamzvy4r4ml91sl0fap3jvp12wgwflx-clippy-1.92.0-debug" + } + ], + "store_path": "/nix/store/zmrbcbd77w6nylgwyagnxh87all5swjf-clippy-1.92.0" + } + } + }, + "github:NixOS/nixpkgs/nixpkgs-unstable": { + "last_modified": "2026-01-30T02:32:49Z", + "resolved": "github:NixOS/nixpkgs/6308c3b21396534d8aaeac46179c14c439a89b8a?lastModified=1769740369&narHash=sha256-xKPyJoMoXfXpDM5DFDZDsi9PHArf2k5BJjvReYXoFpM%3D" + }, + "rustc@latest": { + "last_modified": "2026-01-23T17:20:52Z", + "plugin_version": "0.0.1", + "resolved": "github:NixOS/nixpkgs/a1bab9e494f5f4939442a57a58d0449a109593fe#rustc", + "source": "devbox-search", + "version": "1.92.0", + "systems": { + "aarch64-darwin": { + "outputs": [ + { + "name": "out", + "path": "/nix/store/ymskl36napcfgl6wjz1xdjn0jd25inrv-rustc-wrapper-1.92.0", + "default": true + }, + { + "name": "man", + "path": "/nix/store/ch54xfkz0dlqvhbinzlbkva2898nvihl-rustc-wrapper-1.92.0-man", + "default": true + }, + { + "name": "doc", + "path": "/nix/store/09yhz82jqxwbmn4dbjy7p9hrvbr4g0mn-rustc-wrapper-1.92.0-doc" + } + ], + "store_path": "/nix/store/ymskl36napcfgl6wjz1xdjn0jd25inrv-rustc-wrapper-1.92.0" + }, + "aarch64-linux": { + "outputs": [ + { + "name": "out", + "path": "/nix/store/qnvqgfiqh8s08cqp452665l2b60a811h-rustc-wrapper-1.92.0", + "default": true + }, + { + "name": "man", + "path": "/nix/store/5ggpm5m3wkxj05si2id4b6sq4alf90qg-rustc-wrapper-1.92.0-man", + "default": true + }, + { + "name": "doc", + "path": "/nix/store/camfpqmi94ssc1p8vkygp2s621ykq80m-rustc-wrapper-1.92.0-doc" + } + ], + "store_path": "/nix/store/qnvqgfiqh8s08cqp452665l2b60a811h-rustc-wrapper-1.92.0" + }, + "x86_64-darwin": { + "outputs": [ + { + "name": "out", + "path": "/nix/store/zkcwgmgli06nsh0v8yv82gnh5whgcdyl-rustc-wrapper-1.92.0", + "default": true + }, + { + "name": "man", + "path": "/nix/store/2k3fdy9xjwkx06rlfwfn449w442vgk0i-rustc-wrapper-1.92.0-man", + "default": true + }, + { + "name": "doc", + "path": "/nix/store/7271gqq93bbxrqb9rw3hf5b5x6wdm2cn-rustc-wrapper-1.92.0-doc" + } + ], + "store_path": "/nix/store/zkcwgmgli06nsh0v8yv82gnh5whgcdyl-rustc-wrapper-1.92.0" + }, + "x86_64-linux": { + "outputs": [ + { + "name": "out", + "path": "/nix/store/qvpg842zrjkywv7sqgw2h05spdyzcj86-rustc-wrapper-1.92.0", + "default": true + }, + { + "name": "man", + "path": "/nix/store/n1d4093lcx7ljgks1j352fbrf5w551v9-rustc-wrapper-1.92.0-man", + "default": true + }, + { + "name": "doc", + "path": "/nix/store/425gvb2xnhkwb1izjr97wpwdwndwi516-rustc-wrapper-1.92.0-doc" + } + ], + "store_path": "/nix/store/qvpg842zrjkywv7sqgw2h05spdyzcj86-rustc-wrapper-1.92.0" + } + } + }, + "rustfmt@latest": { + "last_modified": "2026-01-23T17:20:52Z", + "resolved": "github:NixOS/nixpkgs/a1bab9e494f5f4939442a57a58d0449a109593fe#rustfmt", + "source": "devbox-search", + "version": "1.92.0", + "systems": { + "aarch64-darwin": { + "outputs": [ + { + "name": "out", + "path": "/nix/store/max1hp93q51kfj074lw6lg8w9m5nmh10-rustfmt-1.92.0", + "default": true + } + ], + "store_path": "/nix/store/max1hp93q51kfj074lw6lg8w9m5nmh10-rustfmt-1.92.0" + }, + "aarch64-linux": { + "outputs": [ + { + "name": "out", + "path": "/nix/store/vq6cxhd7iv2g444imys9wkmwqz3ffqbc-rustfmt-1.92.0", + "default": true + } + ], + "store_path": "/nix/store/vq6cxhd7iv2g444imys9wkmwqz3ffqbc-rustfmt-1.92.0" + }, + "x86_64-darwin": { + "outputs": [ + { + "name": "out", + "path": "/nix/store/dnjwscz1r33dkmikx3ms58qmhr60db9i-rustfmt-1.92.0", + "default": true + } + ], + "store_path": "/nix/store/dnjwscz1r33dkmikx3ms58qmhr60db9i-rustfmt-1.92.0" + }, + "x86_64-linux": { + "outputs": [ + { + "name": "out", + "path": "/nix/store/gl4y2v4lwiyllh85z712w8ajydia053l-rustfmt-1.92.0", + "default": true + } + ], + "store_path": "/nix/store/gl4y2v4lwiyllh85z712w8ajydia053l-rustfmt-1.92.0" + } + } + } + } +} diff --git a/src/application.rs b/src/application.rs new file mode 100644 index 0000000..bad7e42 --- /dev/null +++ b/src/application.rs @@ -0,0 +1,317 @@ +use anyhow::Context; +use anyhow::Result; +use mockall::automock; + +use crate::domain::CardOffersFetcher; +use crate::domain::ResponseMessage; +use crate::domain::ResponseMessageFormatter; +use crate::domain::{MessageSender, OriginalCardNameFetcher, UserQueryParser}; + +pub struct GetCardInfoHandler< + P: UserQueryParser, + N: OriginalCardNameFetcher, + S: MessageSender, + O: CardOffersFetcher, + F: ResponseMessageFormatter, +> { + parser: P, + name_fetcher: N, + sender: S, + offers_fetcher: O, + formatter: F, +} + +#[automock] +pub trait GetCardInfoUseCase { + fn get_card_info(&self, chat_id: i64, query: &str) -> impl Future>; +} + +impl< + P: UserQueryParser, + N: OriginalCardNameFetcher, + S: MessageSender, + O: CardOffersFetcher, + F: ResponseMessageFormatter, +> GetCardInfoHandler +{ + pub fn new(parser: P, name_fetcher: N, sender: S, offers_fetcher: O, formatter: F) -> Self { + Self { + parser, + name_fetcher, + sender, + offers_fetcher, + formatter, + } + } +} + +impl< + P: UserQueryParser, + N: OriginalCardNameFetcher, + S: MessageSender, + O: CardOffersFetcher, + F: ResponseMessageFormatter, +> GetCardInfoUseCase for GetCardInfoHandler +{ + async fn get_card_info(&self, chat_id: i64, query: &str) -> Result<()> { + let request = self.parser.parse_query(query); + let original_name = match self.name_fetcher.fetch_original_card_name(&request).await { + Ok(Some(name)) => name, + Ok(None) => { + let card_not_found = self + .formatter + .format_message(&ResponseMessage::CardNotFound); + self.sender + .send_message(chat_id, &card_not_found) + .await + .context("failed to send card not found message")?; + return Ok(()); + } + Err(err) => { + let service_unavailable = self + .formatter + .format_message(&ResponseMessage::ServiceUnavailable); + self.sender + .send_message(chat_id, &service_unavailable) + .await + .context("failed to send name fetcher error message")?; + return Err(err); + } + }; + let offers = match self.offers_fetcher.fetch_card_offers(&original_name).await { + Ok(offers) => offers, + Err(err) => { + let service_unavailable = self + .formatter + .format_message(&ResponseMessage::ServiceUnavailable); + self.sender + .send_message(chat_id, &service_unavailable) + .await + .context("failed to send offers fetcher error message")?; + return Err(err); + } + }; + let success = self.formatter.format_message(&ResponseMessage::Success { + original_name, + offers, + }); + self.sender + .send_message(chat_id, &success) + .await + .context("failed to send result message")?; + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use crate::domain::{ + CardInfoRequest, CardOffer, FormattedResponseMessage, MockCardOffersFetcher, + MockMessageSender, MockOriginalCardNameFetcher, MockResponseMessageFormatter, + MockUserQueryParser, OriginalCardName, + }; + + use super::*; + + #[tokio::test] + async fn test_get_card_name_error() { + let text = "Тефери, герой доминарии"; + + let mut parser = MockUserQueryParser::new(); + parser + .expect_parse_query() + .times(1) + .withf(move |query| query == text) + .returning(|_| CardInfoRequest::ByPrintedName { name: text.into() }); + + let mut name_fetcher = MockOriginalCardNameFetcher::new(); + name_fetcher + .expect_fetch_original_card_name() + .times(1) + .withf(|info| *info == CardInfoRequest::ByPrintedName { name: text.into() }) + .returning(|_| Box::pin(async { anyhow::bail!("error") })); + + let mut formatter = MockResponseMessageFormatter::new(); + formatter + .expect_format_message() + .times(1) + .withf(|message| matches!(message, ResponseMessage::ServiceUnavailable)) + .returning(|_| FormattedResponseMessage("service unavailable".into())); + + let mut sender = MockMessageSender::new(); + sender + .expect_send_message() + .times(1) + .withf(|chat_id, message| *chat_id == 123 && message.0 == "service unavailable") + .returning(|_, _| Box::pin(async { Ok(()) })); + + let use_case = GetCardInfoHandler::new( + parser, + name_fetcher, + sender, + MockCardOffersFetcher::new(), + formatter, + ); + + use_case.get_card_info(123, text).await.unwrap_err(); + } + + #[tokio::test] + async fn test_get_card_does_not_exist() { + let text = "invalid"; + + let mut parser = MockUserQueryParser::new(); + parser + .expect_parse_query() + .times(1) + .withf(move |query| query == text) + .returning(|_| CardInfoRequest::ByPrintedName { name: text.into() }); + + let mut name_fetcher = MockOriginalCardNameFetcher::new(); + name_fetcher + .expect_fetch_original_card_name() + .times(1) + .withf(|info| *info == CardInfoRequest::ByPrintedName { name: text.into() }) + .returning(|_| Box::pin(async { Ok(None) })); + + let mut formatter = MockResponseMessageFormatter::new(); + formatter + .expect_format_message() + .times(1) + .withf(|message| matches!(message, ResponseMessage::CardNotFound)) + .returning(|_| FormattedResponseMessage("not found".into())); + + let mut sender = MockMessageSender::new(); + sender + .expect_send_message() + .times(1) + .withf(|chat_id, message| *chat_id == 123 && message.0 == "not found") + .returning(|_, _| Box::pin(async { Ok(()) })); + + let use_case = GetCardInfoHandler::new( + parser, + name_fetcher, + sender, + MockCardOffersFetcher::new(), + formatter, + ); + + use_case.get_card_info(123, text).await.unwrap(); + } + + #[tokio::test] + async fn test_get_card_price_error() { + let text = "Тефери, герой доминарии"; + + let mut parser = MockUserQueryParser::new(); + parser + .expect_parse_query() + .times(1) + .withf(move |query| query == text) + .returning(|_| CardInfoRequest::ByPrintedName { name: text.into() }); + + let mut name_fetcher = MockOriginalCardNameFetcher::new(); + name_fetcher + .expect_fetch_original_card_name() + .times(1) + .withf(|info| *info == CardInfoRequest::ByPrintedName { name: text.into() }) + .returning(|_| { + Box::pin(async { Ok(Some(OriginalCardName("Teferi, Hero of Dominaria".into()))) }) + }); + + let mut offers_fetcher = MockCardOffersFetcher::new(); + offers_fetcher + .expect_fetch_card_offers() + .times(1) + .withf(|query| query.0 == "Teferi, Hero of Dominaria") + .returning(|_| Box::pin(async { anyhow::bail!("error") })); + + let mut formatter = MockResponseMessageFormatter::new(); + formatter + .expect_format_message() + .times(1) + .withf(|message| matches!(message, ResponseMessage::ServiceUnavailable)) + .returning(|_| FormattedResponseMessage("service unavailable".into())); + + let mut sender = MockMessageSender::new(); + sender + .expect_send_message() + .times(1) + .withf(|chat_id, message| *chat_id == 123 && message.0 == "service unavailable") + .returning(|_, _| Box::pin(async { Ok(()) })); + + let use_case = + GetCardInfoHandler::new(parser, name_fetcher, sender, offers_fetcher, formatter); + + use_case.get_card_info(123, text).await.unwrap_err(); + } + + #[tokio::test] + async fn test_get_card_prices_success() { + let text = "Тефери, герой доминарии"; + + let mut parser = MockUserQueryParser::new(); + parser + .expect_parse_query() + .times(1) + .withf(move |query| query == text) + .returning(|_| CardInfoRequest::ByPrintedName { name: text.into() }); + + let mut name_fetcher = MockOriginalCardNameFetcher::new(); + name_fetcher + .expect_fetch_original_card_name() + .times(1) + .withf(|info| *info == CardInfoRequest::ByPrintedName { name: text.into() }) + .returning(|_| { + Box::pin(async { Ok(Some(OriginalCardName("Teferi, Hero of Dominaria".into()))) }) + }); + + let mut offers_fetcher = MockCardOffersFetcher::new(); + offers_fetcher + .expect_fetch_card_offers() + .times(1) + .withf(|query| query.0 == "Teferi, Hero of Dominaria") + .returning(|_| { + Box::pin(async { + Ok(vec![CardOffer { + price: "4.99".into(), + set: "Dominaria".into(), + link: "link".into(), + }]) + }) + }); + + let mut formatter = MockResponseMessageFormatter::new(); + formatter + .expect_format_message() + .times(1) + .withf(|message| match message { + ResponseMessage::Success { + original_name, + offers, + } => { + original_name.0 == "Teferi, Hero of Dominaria" + && *offers + == vec![CardOffer { + price: "4.99".into(), + set: "Dominaria".into(), + link: "link".into(), + }] + } + _ => false, + }) + .returning(|_| FormattedResponseMessage("success".into())); + + let mut sender = MockMessageSender::new(); + sender + .expect_send_message() + .times(1) + .withf(|chat_id, message| *chat_id == 123 && message.0 == "success") + .returning(|_, _| Box::pin(async { Ok(()) })); + + let use_case = + GetCardInfoHandler::new(parser, name_fetcher, sender, offers_fetcher, formatter); + + use_case.get_card_info(123, text).await.unwrap(); + } +} diff --git a/src/domain.rs b/src/domain.rs new file mode 100644 index 0000000..2cf9dd0 --- /dev/null +++ b/src/domain.rs @@ -0,0 +1,63 @@ +use anyhow::Result; +use mockall::automock; + +#[automock] +pub trait UserQueryParser { + fn parse_query(&self, query: &str) -> CardInfoRequest; +} + +#[derive(PartialEq, Eq, Debug)] +pub enum CardInfoRequest { + ByPrintedName { name: String }, + BySetAndNumber { set: String, number: String }, +} + +#[automock] +pub trait OriginalCardNameFetcher { + fn fetch_original_card_name( + &self, + request: &CardInfoRequest, + ) -> impl Future>>; +} + +pub struct OriginalCardName(pub String); + +#[automock] +pub trait CardOffersFetcher { + fn fetch_card_offers( + &self, + original_name: &OriginalCardName, + ) -> impl Future>>; +} + +#[derive(Debug, PartialEq, Eq)] +pub struct CardOffer { + pub set: String, + pub price: String, + pub link: String, +} + +pub enum ResponseMessage { + Success { + original_name: OriginalCardName, + offers: Vec, + }, + CardNotFound, + ServiceUnavailable, +} + +#[automock] +pub trait ResponseMessageFormatter { + fn format_message(&self, message: &ResponseMessage) -> FormattedResponseMessage; +} + +pub struct FormattedResponseMessage(pub String); + +#[automock] +pub trait MessageSender { + fn send_message( + &self, + chat_id: i64, + message: &FormattedResponseMessage, + ) -> impl Future>; +} diff --git a/src/infrastructure.rs b/src/infrastructure.rs new file mode 100644 index 0000000..cca3f4e --- /dev/null +++ b/src/infrastructure.rs @@ -0,0 +1,5 @@ +pub mod scryfall; +pub mod starcitygames; +pub mod query; +pub mod vk; +pub mod telegram; diff --git a/src/infrastructure/query.rs b/src/infrastructure/query.rs new file mode 100644 index 0000000..a38fe5a --- /dev/null +++ b/src/infrastructure/query.rs @@ -0,0 +1,51 @@ +use crate::domain::{CardInfoRequest, UserQueryParser}; + +#[derive(Default)] +pub struct UserQueryChatParser; + +impl UserQueryChatParser { + pub fn new() -> Self { + Self + } +} + +impl UserQueryParser for UserQueryChatParser { + fn parse_query(&self, query: &str) -> crate::domain::CardInfoRequest { + let parts: Vec<&str> = query.split(" ").collect(); + match parts.as_slice() { + ["!s", set, code] => CardInfoRequest::BySetAndNumber { + set: (*set).into(), + number: (*code).into(), + }, + _ => CardInfoRequest::ByPrintedName { name: query.into() }, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_parse_name_request() { + let query = "Тефери, герой доминарии"; + let request: CardInfoRequest = UserQueryChatParser::new().parse_query(query); + assert_eq!( + request, + CardInfoRequest::ByPrintedName { name: query.into() } + ) + } + + #[test] + fn test_parse_set_and_code_request() { + let query = "!s GRN 1"; + let request: CardInfoRequest = UserQueryChatParser::new().parse_query(query); + assert_eq!( + request, + CardInfoRequest::BySetAndNumber { + set: "GRN".into(), + number: "1".into() + } + ) + } +} diff --git a/src/infrastructure/scryfall.rs b/src/infrastructure/scryfall.rs new file mode 100644 index 0000000..beb3b98 --- /dev/null +++ b/src/infrastructure/scryfall.rs @@ -0,0 +1,160 @@ +use anyhow::{Context, Result}; +use reqwest::{Client, StatusCode, Url}; +use serde::Deserialize; + +use crate::domain::{CardInfoRequest, OriginalCardName, OriginalCardNameFetcher}; + +pub struct ScryfallOriginalCardNameFetcher { + client: Client, + url: Url, +} + +#[derive(Deserialize)] +struct CardResponse { + name: String, +} + +impl ScryfallOriginalCardNameFetcher { + pub fn new(client: Client, url: Url) -> Self { + Self { client, url } + } +} + +impl OriginalCardNameFetcher for ScryfallOriginalCardNameFetcher { + async fn fetch_original_card_name( + &self, + request: &CardInfoRequest, + ) -> Result> { + let url = match request { + CardInfoRequest::ByPrintedName { name } => { + let mut url = self + .url + .join("/cards/named") + .context("failed to form scryfall named url")?; + url.query_pairs_mut().append_pair("fuzzy", name); + url + } + CardInfoRequest::BySetAndNumber { set, number } => self + .url + .join(&format!("/cards/{set}/{number}")) + .context("failed to form scryfall set+number url")?, + }; + let res = self + .client + .get(url) + .header("user-agent", "mtg-price-bot/1.0") + .header("accept", "application/json") + .send() + .await + .context("failed to get card info from scryfall")?; + if res.status() == StatusCode::NOT_FOUND { + return Ok(None); + } + let response = res + .json::() + .await + .context("failed to parse scryfall response")?; + Ok(Some(OriginalCardName(response.name))) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + use serde_json::json; + #[tokio::test] + async fn test_search_card_by_printed_name() { + let mut server = mockito::Server::new_async().await; + + let mock = server + .mock("GET", "/cards/named") + .match_query(mockito::Matcher::UrlEncoded( + "fuzzy".into(), + "Тефери, герой доминарии".into(), + )) + .with_status(200) + .with_header("content-type", "application/json") + .with_body( + json!({ + "name": "Teferi, Hero of Dominaria" + }) + .to_string(), + ) + .create_async() + .await; + + let url = Url::parse(server.url().as_str()).unwrap(); + + let name = ScryfallOriginalCardNameFetcher::new(Client::new(), url) + .fetch_original_card_name(&CardInfoRequest::ByPrintedName { + name: "Тефери, герой доминарии".into(), + }) + .await + .unwrap(); + + assert_eq!(name.unwrap().0, "Teferi, Hero of Dominaria"); + + mock.assert_async().await; + } + + #[tokio::test] + async fn test_search_card_by_set_and_number() { + let mut server = mockito::Server::new_async().await; + + let mock = server + .mock("GET", "/cards/GRN/123") + .with_status(200) + .with_header("content-type", "application/json") + .with_body( + json!({ + "name": "Teferi, Hero of Dominaria" + }) + .to_string(), + ) + .create_async() + .await; + + let url = Url::parse(server.url().as_str()).unwrap(); + + let name = ScryfallOriginalCardNameFetcher::new(Client::new(), url) + .fetch_original_card_name(&CardInfoRequest::BySetAndNumber { + set: "GRN".into(), + number: "123".into(), + }) + .await + .unwrap(); + + assert_eq!(name.unwrap().0, "Teferi, Hero of Dominaria"); + + mock.assert_async().await; + } + + #[tokio::test] + async fn test_search_card_not_found() { + let mut server = mockito::Server::new_async().await; + + let mock = server + .mock("GET", "/cards/named") + .match_query(mockito::Matcher::UrlEncoded( + "fuzzy".into(), + "Тефери, герой доминарии".into(), + )) + .with_status(404) + .create_async() + .await; + + let url = Url::parse(server.url().as_str()).unwrap(); + + let result = ScryfallOriginalCardNameFetcher::new(Client::new(), url) + .fetch_original_card_name(&CardInfoRequest::ByPrintedName { + name: "Тефери, герой доминарии".into(), + }) + .await + .unwrap(); + + assert!(result.is_none()); + + mock.assert_async().await; + } +} diff --git a/src/infrastructure/starcitygames.rs b/src/infrastructure/starcitygames.rs new file mode 100644 index 0000000..a6e268a --- /dev/null +++ b/src/infrastructure/starcitygames.rs @@ -0,0 +1,209 @@ +use anyhow::{Context, Result}; +use reqwest::{Client, Url}; +use serde::Deserialize; +use serde_json::json; + +use crate::domain::{CardOffer, CardOffersFetcher, OriginalCardName}; + +pub struct StarCityGamesCardOffersFetcher { + client: Client, + search_url: Url, + display_url: String, + client_guid: String, + max_offers: usize, +} + +#[derive(Deserialize)] +struct Response { + #[serde(rename = "Results")] + results: Vec, +} + +#[derive(Deserialize)] +struct ResponseResult { + #[serde(rename = "Document")] + document: ResponseDocument, +} + +#[derive(Deserialize)] +struct ResponseDocument { + filter_set: Vec, + hawk_child_attributes: Vec, + url_detail: Vec, +} + +#[derive(Deserialize)] +pub struct ResponseHawkChildAttributes { + calculated_price: Vec, +} + +impl StarCityGamesCardOffersFetcher { + pub fn new( + client: Client, + search_url: Url, + display_url: String, + client_guid: String, + max_offers: usize, + ) -> Self { + Self { + client, + search_url, + display_url, + client_guid, + max_offers, + } + } +} + +impl CardOffersFetcher for StarCityGamesCardOffersFetcher { + async fn fetch_card_offers(&self, original_name: &OriginalCardName) -> Result> { + let url = self + .search_url + .join("/api/v2/search") + .context("failed to form starcity url")?; + let response = self + .client + .post(url) + .json(&json!( + { + "FacetSelections": { + "item_display_name": [ + original_name.0 + ] + }, + "clientguid": self.client_guid, + } + )) + .send() + .await + .context("failed to get cards from starcity api")?; + + let response: Response = response + .json() + .await + .context("failed to parse starcity response")?; + + let offers = response + .results + .into_iter() + .flat_map(|result| { + let mut document = result.document; + let set = document.filter_set.pop()?; + let price = document + .hawk_child_attributes + .pop()? + .calculated_price + .pop()?; + let link = format!("{}{}", self.display_url, document.url_detail.pop()?); + Some(CardOffer { set, price, link }) + }) + .take(self.max_offers) + .collect(); + + Ok(offers) + } +} + +#[cfg(test)] +mod tests { + use rstest::rstest; + + use super::*; + + #[rstest] + #[case(1)] + #[case(2)] + #[tokio::test] + async fn test_search_offers(#[case] max_offers: usize) { + let mut server = mockito::Server::new_async().await; + + let mock = server + .mock("POST", "/api/v2/search") + .match_body(mockito::Matcher::Json(json!({ + "FacetSelections": { + "item_display_name": [ + "Teferi, Hero of Dominaria" + ] + }, + "clientguid": "guid", + }))) + .with_status(200) + .with_header("content-type", "application/json") + .with_body( + json!({ + "Results": [ + { + "Document": { + "filter_set": [ + "Dominaria (Foil)" + ], + "hawk_child_attributes": [ + { + "calculated_price": [ + "9.99" + ] + } + ], + "url_detail": [ + "/teferi-hero-of-dominaria-sgl-mtg-prm-prmr_2022_002-enn/" + ] + } + }, + { + "Document": { + "filter_set": [ + "Dominaria" + ], + "hawk_child_attributes": [ + { + "calculated_price": [ + "4.99" + ] + } + ], + "url_detail": [ + "/teferi-hero-of-dominaria-sgl-mtg-mb2-090-enn/" + ] + } + } + ] + }) + .to_string(), + ) + .create_async() + .await; + + let search_url = Url::parse(server.url().as_str()).unwrap(); + + let offers = StarCityGamesCardOffersFetcher::new( + Client::new(), + search_url, + "https://scg.com".into(), + "guid".into(), + max_offers, + ) + .fetch_card_offers(&OriginalCardName("Teferi, Hero of Dominaria".into())) + .await + .unwrap(); + let expect_offers: Vec = [ + CardOffer { + set: "Dominaria (Foil)".into(), + price: "9.99".into(), + link: "https://scg.com/teferi-hero-of-dominaria-sgl-mtg-prm-prmr_2022_002-enn/" + .into(), + }, + CardOffer { + set: "Dominaria".into(), + price: "4.99".into(), + link: "https://scg.com/teferi-hero-of-dominaria-sgl-mtg-mb2-090-enn/".into(), + }, + ] + .into_iter() + .take(max_offers) + .collect(); + + assert_eq!(offers, expect_offers); + + mock.assert_async().await; + } +} diff --git a/src/infrastructure/telegram.rs b/src/infrastructure/telegram.rs new file mode 100644 index 0000000..821a035 --- /dev/null +++ b/src/infrastructure/telegram.rs @@ -0,0 +1,178 @@ +use anyhow::{Context, Result}; +use reqwest::{Client, Url}; + +use crate::domain::{ + FormattedResponseMessage, MessageSender, ResponseMessage, ResponseMessageFormatter, +}; + +#[derive(Default)] +pub struct TelegramResponseFormatter; + +impl TelegramResponseFormatter { + pub fn new() -> Self { + Self + } +} + +impl ResponseMessageFormatter for TelegramResponseFormatter { + fn format_message(&self, info: &ResponseMessage) -> FormattedResponseMessage { + let message = match info { + ResponseMessage::Success { + original_name, + offers, + } => { + let offers: String = offers + .iter() + .enumerate() + .map(|(i, offer)| { + format!( + "{}. {}: ${}\n", + i + 1, + escape(&offer.link), + escape(&offer.set), + escape(&offer.price), + ) + }) + .collect(); + format!( + "Оригинальное название: {}\n\n{}", + escape(&original_name.0), + offers + ) + } + ResponseMessage::CardNotFound => "Карта не найдена".into(), + ResponseMessage::ServiceUnavailable => "Сервис временно недоступен".into(), + }; + FormattedResponseMessage(message) + } +} + +fn escape(text: &str) -> String { + text.replace("&", "&") + .replace("<", "<") + .replace(">", ">") +} + +pub struct TelegramMessageSender { + client: Client, + url: Url, + token: String, +} + +impl TelegramMessageSender { + pub fn new(client: Client, url: Url, token: String) -> Self { + Self { client, url, token } + } +} + +impl MessageSender for TelegramMessageSender { + async fn send_message(&self, chat_id: i64, message: &FormattedResponseMessage) -> Result<()> { + let url = self + .url + .join(&format!("/bot{}/sendMessage", self.token)) + .context("failed to form telegram url")?; + + self.client + .post(url) + .json(&serde_json::json!({ + "chat_id": chat_id, + "text": message.0, + "parse_mode": "HTML", + "link_preview_options": { + "is_disabled": true + } + })) + .send() + .await + .context("failed to send telegram message")?; + + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use mockito::Matcher; + use reqwest::Client; + + use crate::domain::{CardOffer, OriginalCardName}; + + use super::*; + + #[test] + fn test_formatting_normal() { + let formatter = TelegramResponseFormatter::new(); + let original_name = OriginalCardName("Teferi, Hero of Dominaria".into()); + let offers = vec![ + CardOffer { + set: "Dominaria (Foil)".into(), + price: "9.99".into(), + link: "link1".into(), + }, + CardOffer { + set: "Dominaria".into(), + price: "4.99".into(), + link: "link2".into(), + }, + ]; + let result = formatter.format_message(&ResponseMessage::Success { + original_name, + offers, + }); + assert_eq!( + result.0, + "Оригинальное название: Teferi, Hero of Dominaria\n\n1. Dominaria (Foil): $9.99\n2. Dominaria: $4.99\n" + ); + } + + #[test] + fn test_formatting_escaping() { + let formatter = TelegramResponseFormatter::new(); + let original_name = OriginalCardName("<&>".into()); + let offers = vec![CardOffer { + set: "<&>".into(), + price: "<&>".into(), + link: "<&>".into(), + }]; + let result = formatter.format_message(&ResponseMessage::Success { + original_name, + offers, + }); + assert_eq!( + result.0, + "Оригинальное название: <&>\n\n1. <&>: $<&>\n" + ); + } + + #[tokio::test] + async fn test_message_send() { + let mut server = mockito::Server::new_async().await; + + let mock = server + .mock("POST", "/bottoken/sendMessage") + .match_body(Matcher::Json(serde_json::json!( + { + "chat_id": 123, + "text": "not found", + "parse_mode": "HTML", + "link_preview_options": { + "is_disabled": true + } + } + ))) + .with_status(200) + .create_async() + .await; + + let url = Url::parse(server.url().as_str()).unwrap(); + + let sender = TelegramMessageSender::new(Client::new(), url, "token".into()); + + sender + .send_message(123, &FormattedResponseMessage("not found".into())) + .await + .unwrap(); + + mock.assert_async().await + } +} diff --git a/src/infrastructure/vk.rs b/src/infrastructure/vk.rs new file mode 100644 index 0000000..1dc099e --- /dev/null +++ b/src/infrastructure/vk.rs @@ -0,0 +1,144 @@ +use crate::domain::{ + FormattedResponseMessage, MessageSender, ResponseMessage, ResponseMessageFormatter, +}; +use anyhow::{Context, Result}; +use reqwest::{Client, Url}; + +#[derive(Default)] +pub struct VkResponseFormatter; + +impl VkResponseFormatter { + pub fn new() -> Self { + Self + } +} + +impl ResponseMessageFormatter for VkResponseFormatter { + fn format_message(&self, message: &ResponseMessage) -> FormattedResponseMessage { + let result = match message { + ResponseMessage::Success { + original_name, + offers, + } => { + let offers: String = offers + .iter() + .enumerate() + .map(|(i, offer)| { + format!( + "{}. {}: ${}\n{}\n", + i + 1, + offer.set, + offer.price, + offer.link + ) + }) + .collect(); + format!("Оригинальное название: {}\n\n{}", original_name.0, offers) + } + ResponseMessage::CardNotFound => "Карта не найдена.".into(), + ResponseMessage::ServiceUnavailable => "Сервис временно недоступен.".into(), + }; + FormattedResponseMessage(result) + } +} + +pub struct VkMessageSender { + client: Client, + url: Url, + token: String, +} + +impl VkMessageSender { + pub fn new(client: Client, url: Url, token: String) -> Self { + Self { client, url, token } + } +} + +impl MessageSender for VkMessageSender { + async fn send_message(&self, chat_id: i64, message: &FormattedResponseMessage) -> Result<()> { + let mut url = self + .url + .join("/method/messages.send") + .context("failed to form vk url")?; + + url.query_pairs_mut() + .append_pair("user_id", chat_id.to_string().as_str()) + .append_pair("random_id", "0") + .append_pair("message", &message.0) + .append_pair("access_token", &self.token) + .append_pair("v", "5.199"); + + self.client + .post(url) + .send() + .await + .context("failed to send vk message")?; + + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use mockito::Matcher; + + use crate::domain::{CardOffer, OriginalCardName}; + + use super::*; + + #[test] + fn test_format() { + let formatter = VkResponseFormatter::new(); + let original_name = OriginalCardName("Teferi, Hero of Dominaria".into()); + let offers = vec![ + CardOffer { + set: "Dominaria (Foil)".into(), + price: "9.99".into(), + link: "link1".into(), + }, + CardOffer { + set: "Dominaria".into(), + price: "4.99".into(), + link: "link2".into(), + }, + ]; + let result = formatter.format_message(&ResponseMessage::Success { + original_name, + offers, + }); + + assert_eq!( + result.0, + "Оригинальное название: Teferi, Hero of Dominaria\n\n1. Dominaria (Foil): $9.99\nlink1\n2. Dominaria: $4.99\nlink2\n" + ) + } + + #[tokio::test] + async fn test_send_message() { + let mut server = mockito::Server::new_async().await; + + let mock = server + .mock("POST", "/method/messages.send") + .match_query(Matcher::AllOf(vec![ + Matcher::UrlEncoded("user_id".into(), "123".parse().unwrap()), + Matcher::UrlEncoded("random_id".into(), "0".parse().unwrap()), + Matcher::UrlEncoded("message".into(), "not found".parse().unwrap()), + Matcher::UrlEncoded("access_token".into(), "token".parse().unwrap()), + Matcher::UrlEncoded("v".into(), "5.199".parse().unwrap()), + ])) + .with_status(200) + .create_async() + .await; + + let url = Url::parse(server.url().as_str()).unwrap(); + + let sender = VkMessageSender::new(Client::new(), url, "token".into()); + + sender + .send_message(123, &FormattedResponseMessage("not found".into())) + .await + .unwrap(); + + mock.assert_async().await + } +} diff --git a/src/lib.rs b/src/lib.rs new file mode 100644 index 0000000..c4e5009 --- /dev/null +++ b/src/lib.rs @@ -0,0 +1,4 @@ +pub mod domain; +pub mod application; +pub mod infrastructure; +pub mod presentation; diff --git a/src/main.rs b/src/main.rs new file mode 100644 index 0000000..80815c7 --- /dev/null +++ b/src/main.rs @@ -0,0 +1,191 @@ +use anyhow::Result; +use std::{env, sync::Arc}; + +use axum::{ + Router, + routing::{MethodRouter, post}, +}; +use mtg_price_bot::{ + application::GetCardInfoHandler, + infrastructure::{ + query::UserQueryChatParser, + scryfall::ScryfallOriginalCardNameFetcher, + starcitygames::StarCityGamesCardOffersFetcher, + telegram::{TelegramMessageSender, TelegramResponseFormatter}, + vk::{VkMessageSender, VkResponseFormatter}, + }, + presentation::{ + telegram::{TelegramState, TelegramWebhookSetter, handle_telegram_message}, + vk::{VkState, handle_vk_message}, + }, +}; +use reqwest::{Client, Url}; + +#[tokio::main] +async fn main() -> Result<()> { + tracing_subscriber::fmt().init(); + + let client = Client::new(); + let config = Config::from_env()?; + + let app = Router::new() + .route("/tg", get_telegram_handler(&config, client.clone()).await?) + .route("/vk", get_vk_handler(&config, client)); + + let listener = tokio::net::TcpListener::bind(config.bind_address).await?; + + axum::serve(listener, app).await?; + + Ok(()) +} + +async fn get_telegram_handler(config: &Config, client: Client) -> Result { + let setter = TelegramWebhookSetter::new( + client.clone(), + config.telegram.url.clone(), + config.telegram.secret.clone(), + config.telegram.token.clone(), + ); + + setter.set_webhook(&config.telegram.callback_url).await?; + + let parser = UserQueryChatParser::new(); + let name_fetcher = + ScryfallOriginalCardNameFetcher::new(client.clone(), config.scryfall_url.clone()); + let formatter = TelegramResponseFormatter::new(); + let telegram_sender = TelegramMessageSender::new( + client.clone(), + config.telegram.url.clone(), + config.telegram.token.clone(), + ); + let offers_fetcher = get_star_city_games_fetcher(&config.star_city_games, client.clone()); + + let handler = GetCardInfoHandler::new( + parser, + name_fetcher, + telegram_sender, + offers_fetcher, + formatter, + ); + + let state = TelegramState { + secret: config.telegram.secret.clone(), + handler, + }; + + Ok(post(handle_telegram_message).with_state(Arc::new(state))) +} + +fn get_vk_handler(config: &Config, client: Client) -> MethodRouter { + let parser = UserQueryChatParser::new(); + let name_fetcher = + ScryfallOriginalCardNameFetcher::new(client.clone(), config.scryfall_url.clone()); + let formatter = VkResponseFormatter::new(); + let sender = VkMessageSender::new( + client.clone(), + config.vk.url.clone(), + config.vk.token.clone(), + ); + let offers_fetcher = get_star_city_games_fetcher(&config.star_city_games, client.clone()); + + let handler = GetCardInfoHandler::new(parser, name_fetcher, sender, offers_fetcher, formatter); + + let state = VkState { + secret: config.vk.secret.clone(), + handler, + confirmation_code: config.vk.confirmation.clone(), + }; + + post(handle_vk_message).with_state(Arc::new(state)) +} + +fn get_star_city_games_fetcher( + config: &StarCityGamesConfig, + client: Client, +) -> StarCityGamesCardOffersFetcher { + StarCityGamesCardOffersFetcher::new( + client, + config.search_url.clone(), + config.display_url.clone(), + config.client_guid.clone(), + config.max_offers, + ) +} + +struct Config { + telegram: TelegramConfig, + vk: VkConfig, + star_city_games: StarCityGamesConfig, + scryfall_url: Url, + bind_address: String, +} + +impl Config { + fn from_env() -> Result { + let config = Self { + telegram: TelegramConfig::from_env()?, + vk: VkConfig::from_env()?, + star_city_games: StarCityGamesConfig::from_env()?, + scryfall_url: Url::parse(&env::var("SCRYFALL_URL")?)?, + bind_address: env::var("BIND_ADDRESS")?, + }; + Ok(config) + } +} + +struct TelegramConfig { + url: Url, + secret: String, + token: String, + callback_url: String, +} + +impl TelegramConfig { + fn from_env() -> Result { + let config = Self { + url: Url::parse(&env::var("TELEGRAM_API_URL")?)?, + secret: env::var("TELEGRAM_SECRET")?, + token: env::var("TELEGRAM_TOKEN")?, + callback_url: env::var("TELEGRAM_CALLBACK_URL")?, + }; + Ok(config) + } +} + +struct VkConfig { + url: Url, + token: String, + secret: String, + confirmation: String, +} + +impl VkConfig { + fn from_env() -> Result { + let config = Self { + url: Url::parse(&env::var("VK_API_URL")?)?, + token: env::var("VK_TOKEN")?, + secret: env::var("VK_SECRET")?, + confirmation: env::var("VK_CONFIRMATION_CODE")?, + }; + Ok(config) + } +} + +struct StarCityGamesConfig { + search_url: Url, + client_guid: String, + display_url: String, + max_offers: usize, +} + +impl StarCityGamesConfig { + fn from_env() -> Result { + let config = Self { + search_url: Url::parse(&env::var("SCG_SEARCH_URL")?)?, + client_guid: env::var("SCG_CLIENT_GUID")?, + display_url: env::var("SCG_DISPLAY_URL")?, + max_offers: env::var("SCG_MAX_OFFERS")?.parse()?, + }; + Ok(config) + } +} diff --git a/src/presentation.rs b/src/presentation.rs new file mode 100644 index 0000000..4375d70 --- /dev/null +++ b/src/presentation.rs @@ -0,0 +1,2 @@ +pub mod vk; +pub mod telegram; diff --git a/src/presentation/telegram.rs b/src/presentation/telegram.rs new file mode 100644 index 0000000..e49514a --- /dev/null +++ b/src/presentation/telegram.rs @@ -0,0 +1,182 @@ +use std::sync::Arc; + +use anyhow::Result; +use axum::{Json, extract::State, http::HeaderMap}; +use reqwest::{Client, StatusCode, Url}; +use serde::Deserialize; + +use crate::application::GetCardInfoUseCase; + +pub struct TelegramState { + pub secret: String, + pub handler: T, +} + +#[derive(Deserialize)] +pub struct TelegramUpdate { + message: Option, +} + +#[derive(Deserialize)] +struct TelegramMessage { + chat: TelegramChat, + text: Option, +} + +#[derive(Deserialize)] +struct TelegramChat { + id: i64, +} + +pub async fn handle_telegram_message( + headers: HeaderMap, + State(state): State>>, + Json(body): Json, +) -> StatusCode { + let secret_header = match headers.get("X-Telegram-Bot-Api-Secret-Token") { + Some(value) => value, + None => return StatusCode::FORBIDDEN, + }; + if secret_header.as_bytes() != state.secret.as_bytes() { + return StatusCode::FORBIDDEN; + } + let (chat_id, message) = match body { + TelegramUpdate { + message: + Some(TelegramMessage { + chat: TelegramChat { id }, + text: Some(text), + }), + } => (id, text), + _ => return StatusCode::OK, + }; + let res = state.handler.get_card_info(chat_id, &message).await; + if let Err(err) = res { + tracing::error!(?err); + } + StatusCode::OK +} + +pub struct TelegramWebhookSetter { + client: Client, + url: Url, + secret: String, + token: String, +} + +impl TelegramWebhookSetter { + pub fn new(client: Client, url: Url, secret: String, token: String) -> Self { + Self { + client, + url, + secret, + token, + } + } + + pub async fn set_webhook(&self, url: &str) -> Result<()> { + let mut api_url = self.url.join(&format!("/bot{}/setWebhook", self.token))?; + + api_url + .query_pairs_mut() + .append_pair("url", url) + .append_pair("secret_token", &self.secret); + + self.client.post(api_url).send().await?; + + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use axum::http::HeaderMap; + use mockito::Matcher; + use reqwest::{Client, Url}; + + use crate::application::MockGetCardInfoUseCase; + + use super::*; + + #[tokio::test] + async fn test_handle_telegram_message_invalid_secret() { + let state = TelegramState { + secret: "secret".into(), + handler: MockGetCardInfoUseCase::new(), + }; + let mut headers = HeaderMap::new(); + headers.append( + "X-Telegram-Bot-Api-Secret-Token", + "invalid".parse().unwrap(), + ); + let code = handle_telegram_message( + headers, + State(Arc::new(state)), + Json(TelegramUpdate { message: None }), + ) + .await; + assert_eq!(code, StatusCode::FORBIDDEN); + } + + #[tokio::test] + async fn test_handle_telegram_message_update_without_message() { + let mut headers = HeaderMap::new(); + headers.append("X-Telegram-Bot-Api-Secret-Token", "secret".parse().unwrap()); + let state = TelegramState { + secret: "secret".into(), + handler: MockGetCardInfoUseCase::new(), + }; + let update = TelegramUpdate { message: None }; + let code = handle_telegram_message(headers, State(Arc::new(state)), Json(update)).await; + assert_eq!(code, StatusCode::OK); + } + + #[tokio::test] + async fn test_handle_telegram_message_success() { + let mut handler = MockGetCardInfoUseCase::new(); + handler + .expect_get_card_info() + .times(1) + .withf(|chat_id, message| *chat_id == 1 && message == "text") + .returning(|_, _| Box::pin(async { Ok(()) })); + + let mut headers = HeaderMap::new(); + headers.append("X-Telegram-Bot-Api-Secret-Token", "secret".parse().unwrap()); + let state = TelegramState { + secret: "secret".into(), + handler, + }; + let update = TelegramUpdate { + message: Some(TelegramMessage { + chat: TelegramChat { id: 1 }, + text: Some("text".into()), + }), + }; + let code = handle_telegram_message(headers, State(Arc::new(state)), Json(update)).await; + assert_eq!(code, StatusCode::OK); + } + + #[tokio::test] + async fn test_set_telegram_webhook() { + let mut server = mockito::Server::new_async().await; + + let mock = server + .mock("POST", "/bottoken/setWebhook") + .match_query(Matcher::AllOf(vec![ + Matcher::UrlEncoded( + "url".into(), + "https://mtg-bot.flygrounder.ru/tg".parse().unwrap(), + ), + Matcher::UrlEncoded("secret_token".into(), "secret".parse().unwrap()), + ])) + .with_status(200) + .create_async() + .await; + + let url = Url::parse(server.url().as_str()).unwrap(); + let setter = TelegramWebhookSetter::new(Client::new(), url, "secret".into(), "token".into()); + setter.set_webhook("https://mtg-bot.flygrounder.ru/tg").await.unwrap(); + + mock.assert_async().await; + } +} diff --git a/src/presentation/vk.rs b/src/presentation/vk.rs new file mode 100644 index 0000000..53c052e --- /dev/null +++ b/src/presentation/vk.rs @@ -0,0 +1,162 @@ +use std::sync::Arc; + +use axum::{Json, extract::State}; +use reqwest::StatusCode; +use serde::Deserialize; + +use crate::application::GetCardInfoUseCase; +pub struct VkState { + pub secret: String, + pub handler: V, + pub confirmation_code: String, +} + +#[derive(Deserialize)] +#[serde(tag = "type")] +pub enum Request { + #[serde(rename = "message_new")] + MessageNew(MessageNew), + #[serde(rename = "confirmation")] + Confirmation(Confirmation), +} + +#[derive(Deserialize)] +pub struct Confirmation { + secret: String, +} + +#[derive(Deserialize)] +pub struct MessageNew { + object: Object, + secret: String, +} + +#[derive(Deserialize)] +struct Object { + message: Message, +} + +#[derive(Deserialize)] +struct Message { + from_id: i64, + text: String, +} + +pub async fn handle_vk_message( + State(state): State>>, + Json(request): Json, +) -> Result { + match request { + Request::MessageNew(request) => { + if request.secret != state.secret { + return Err(StatusCode::FORBIDDEN); + } + let message = request.object.message; + let result = state + .handler + .get_card_info(message.from_id, &message.text) + .await; + if let Err(err) = result { + tracing::error!(?err); + } + Ok("ok".into()) + } + Request::Confirmation(request) => { + if request.secret != state.secret { + return Err(StatusCode::FORBIDDEN); + } + Ok(state.confirmation_code.clone()) + }, + } +} + +#[cfg(test)] +mod tests { + use crate::application::MockGetCardInfoUseCase; + + use super::*; + + #[tokio::test] + async fn test_handle_vk_message_invalid_secret() { + let state = VkState { + secret: "secret".into(), + handler: MockGetCardInfoUseCase::new(), + confirmation_code: "confirmation".into(), + }; + let request = Request::MessageNew(MessageNew { + secret: "invalid".into(), + object: Object { + message: Message { + from_id: 1, + text: "text".into(), + }, + }, + }); + let code = handle_vk_message(State(Arc::new(state)), Json(request)) + .await + .unwrap_err(); + assert_eq!(code, StatusCode::FORBIDDEN); + } + + #[tokio::test] + async fn test_handle_vk_message_valid_secret() { + let mut handler = MockGetCardInfoUseCase::new(); + handler + .expect_get_card_info() + .times(1) + .withf(|chat_id, message| *chat_id == 1 && message == "text") + .returning(|_, _| Box::pin(async { Ok(()) })); + + let state = VkState { + secret: "secret".into(), + handler, + confirmation_code: "confirmation".into(), + }; + let request = Request::MessageNew(MessageNew { + secret: "secret".into(), + object: Object { + message: Message { + from_id: 1, + text: "text".into(), + }, + }, + }); + let result = handle_vk_message(State(Arc::new(state)), Json(request)) + .await + .unwrap(); + assert_eq!(result, "ok"); + } + + #[tokio::test] + async fn test_handle_confirmation_invalid_secret() { + let request = Request::Confirmation(Confirmation { + secret: "invalid".into(), + }); + let state = VkState { + secret: "secret".into(), + handler: MockGetCardInfoUseCase::new(), + confirmation_code: "confirmation".into(), + }; + let code = handle_vk_message(State(Arc::new(state)), Json(request)) + .await + .unwrap_err(); + assert_eq!(code, StatusCode::FORBIDDEN) + } + + #[tokio::test] + async fn test_handle_confirmation_success() { + let request = Request::Confirmation(Confirmation { + secret: "secret".into(), + }); + let state = VkState { + secret: "secret".into(), + handler: MockGetCardInfoUseCase::new(), + confirmation_code: "confirmation".into(), + }; + let confirmation = handle_vk_message(State(Arc::new(state)), Json(request)) + .await + .unwrap(); + + assert_eq!(confirmation, "confirmation") + } +}