Initial commit
This commit is contained in:
commit
ea042c33ba
26 changed files with 4452 additions and 0 deletions
2
.dockerignore
Normal file
2
.dockerignore
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
/target
|
||||
/.env
|
||||
18
.env.template
Normal file
18
.env.template
Normal file
|
|
@ -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
|
||||
3
.gitignore
vendored
Normal file
3
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
/target
|
||||
/.env
|
||||
/.envrc
|
||||
2354
Cargo.lock
generated
Normal file
2354
Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load diff
17
Cargo.toml
Normal file
17
Cargo.toml
Normal file
|
|
@ -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"
|
||||
21
Dockerfile
Normal file
21
Dockerfile
Normal file
|
|
@ -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"]
|
||||
15
LICENSE
Normal file
15
LICENSE
Normal file
|
|
@ -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.
|
||||
|
||||
BIN
Logo.jpg
Normal file
BIN
Logo.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 79 KiB |
16
README.md
Normal file
16
README.md
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
# MTG price bot
|
||||
EN | [RU](https://codeberg.org/flygrounder/mtg-price-bot/src/branch/main/README_RU.md)
|
||||
|
||||

|
||||
|
||||
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.
|
||||
16
README_RU.md
Normal file
16
README_RU.md
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
# MTG price bot
|
||||
[EN](https://codeberg.org/flygrounder/mtg-price-bot/src/branch/main/README.md) | RU
|
||||
|
||||

|
||||
|
||||
Бот в ВК: https://vk.ru/mtg_vk_bot
|
||||
Бот в Telegram: https://t.me/MtgScgBot
|
||||
|
||||
Ищите цены на карты MTG с сайта http://www.starcitygames.com/ не выходя из вашего любимого мессенджера.
|
||||
Не нужно учить никаких команд, просто отправьте боту название карты на любом языке и он предложит вам цены на разные издания этой карты.
|
||||
|
||||
Если же вы хотите искать карты ещё быстрее, то воспользуйтесь командой
|
||||
!s XXX YYY
|
||||
XXX - короткий код издания
|
||||
YYY - коллекционный номер карты
|
||||
Они расположены в нижнем левом углу карты
|
||||
8
compose.yaml
Normal file
8
compose.yaml
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
services:
|
||||
app:
|
||||
restart: unless-stopped
|
||||
build: .
|
||||
env_file:
|
||||
- .env
|
||||
ports:
|
||||
- 127.0.0.1:3000:3000
|
||||
20
devbox.json
Normal file
20
devbox.json
Normal file
|
|
@ -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"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
294
devbox.lock
Normal file
294
devbox.lock
Normal file
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
317
src/application.rs
Normal file
317
src/application.rs
Normal file
|
|
@ -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<Output = Result<()>>;
|
||||
}
|
||||
|
||||
impl<
|
||||
P: UserQueryParser,
|
||||
N: OriginalCardNameFetcher,
|
||||
S: MessageSender,
|
||||
O: CardOffersFetcher,
|
||||
F: ResponseMessageFormatter,
|
||||
> GetCardInfoHandler<P, N, S, O, F>
|
||||
{
|
||||
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<P, N, S, O, F>
|
||||
{
|
||||
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();
|
||||
}
|
||||
}
|
||||
63
src/domain.rs
Normal file
63
src/domain.rs
Normal file
|
|
@ -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<Output = Result<Option<OriginalCardName>>>;
|
||||
}
|
||||
|
||||
pub struct OriginalCardName(pub String);
|
||||
|
||||
#[automock]
|
||||
pub trait CardOffersFetcher {
|
||||
fn fetch_card_offers(
|
||||
&self,
|
||||
original_name: &OriginalCardName,
|
||||
) -> impl Future<Output = Result<Vec<CardOffer>>>;
|
||||
}
|
||||
|
||||
#[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<CardOffer>,
|
||||
},
|
||||
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<Output = Result<()>>;
|
||||
}
|
||||
5
src/infrastructure.rs
Normal file
5
src/infrastructure.rs
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
pub mod scryfall;
|
||||
pub mod starcitygames;
|
||||
pub mod query;
|
||||
pub mod vk;
|
||||
pub mod telegram;
|
||||
51
src/infrastructure/query.rs
Normal file
51
src/infrastructure/query.rs
Normal file
|
|
@ -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()
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
160
src/infrastructure/scryfall.rs
Normal file
160
src/infrastructure/scryfall.rs
Normal file
|
|
@ -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<Option<OriginalCardName>> {
|
||||
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::<CardResponse>()
|
||||
.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;
|
||||
}
|
||||
}
|
||||
209
src/infrastructure/starcitygames.rs
Normal file
209
src/infrastructure/starcitygames.rs
Normal file
|
|
@ -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<ResponseResult>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct ResponseResult {
|
||||
#[serde(rename = "Document")]
|
||||
document: ResponseDocument,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct ResponseDocument {
|
||||
filter_set: Vec<String>,
|
||||
hawk_child_attributes: Vec<ResponseHawkChildAttributes>,
|
||||
url_detail: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct ResponseHawkChildAttributes {
|
||||
calculated_price: Vec<String>,
|
||||
}
|
||||
|
||||
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<Vec<CardOffer>> {
|
||||
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> = [
|
||||
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;
|
||||
}
|
||||
}
|
||||
178
src/infrastructure/telegram.rs
Normal file
178
src/infrastructure/telegram.rs
Normal file
|
|
@ -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!(
|
||||
"{}. <a href=\"{}\">{}</a>: ${}\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. <a href=\"link1\">Dominaria (Foil)</a>: $9.99\n2. <a href=\"link2\">Dominaria</a>: $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. <a href=\"<&>\"><&></a>: $<&>\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
|
||||
}
|
||||
}
|
||||
144
src/infrastructure/vk.rs
Normal file
144
src/infrastructure/vk.rs
Normal file
|
|
@ -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
|
||||
}
|
||||
}
|
||||
4
src/lib.rs
Normal file
4
src/lib.rs
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
pub mod domain;
|
||||
pub mod application;
|
||||
pub mod infrastructure;
|
||||
pub mod presentation;
|
||||
191
src/main.rs
Normal file
191
src/main.rs
Normal file
|
|
@ -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<MethodRouter> {
|
||||
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<Self> {
|
||||
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<Self> {
|
||||
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<Self> {
|
||||
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<Self> {
|
||||
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)
|
||||
}
|
||||
}
|
||||
2
src/presentation.rs
Normal file
2
src/presentation.rs
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
pub mod vk;
|
||||
pub mod telegram;
|
||||
182
src/presentation/telegram.rs
Normal file
182
src/presentation/telegram.rs
Normal file
|
|
@ -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<T: GetCardInfoUseCase> {
|
||||
pub secret: String,
|
||||
pub handler: T,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct TelegramUpdate {
|
||||
message: Option<TelegramMessage>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct TelegramMessage {
|
||||
chat: TelegramChat,
|
||||
text: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct TelegramChat {
|
||||
id: i64,
|
||||
}
|
||||
|
||||
pub async fn handle_telegram_message<T: GetCardInfoUseCase>(
|
||||
headers: HeaderMap,
|
||||
State(state): State<Arc<TelegramState<T>>>,
|
||||
Json(body): Json<TelegramUpdate>,
|
||||
) -> 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;
|
||||
}
|
||||
}
|
||||
162
src/presentation/vk.rs
Normal file
162
src/presentation/vk.rs
Normal file
|
|
@ -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<V: GetCardInfoUseCase> {
|
||||
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<V: GetCardInfoUseCase>(
|
||||
State(state): State<Arc<VkState<V>>>,
|
||||
Json(request): Json<Request>,
|
||||
) -> Result<String, StatusCode> {
|
||||
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")
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue