Initial commit

This commit is contained in:
Artyom Belousov 2026-02-08 01:01:17 +03:00 committed by Artyom Belousov
commit ea042c33ba
26 changed files with 4452 additions and 0 deletions

2
.dockerignore Normal file
View file

@ -0,0 +1,2 @@
/target
/.env

18
.env.template Normal file
View 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
View file

@ -0,0 +1,3 @@
/target
/.env
/.envrc

2354
Cargo.lock generated Normal file

File diff suppressed because it is too large Load diff

17
Cargo.toml Normal file
View 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
View 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
View 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

Binary file not shown.

After

Width:  |  Height:  |  Size: 79 KiB

16
README.md Normal file
View file

@ -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.

16
README_RU.md Normal file
View file

@ -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 - коллекционный номер карты
Они расположены в нижнем левом углу карты

8
compose.yaml Normal file
View 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
View 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
View 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
View 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
View 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
View file

@ -0,0 +1,5 @@
pub mod scryfall;
pub mod starcitygames;
pub mod query;
pub mod vk;
pub mod telegram;

View 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()
}
)
}
}

View 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;
}
}

View 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;
}
}

View 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("&", "&amp;")
.replace("<", "&lt;")
.replace(">", "&gt;")
}
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,
"Оригинальное название: &lt;&amp;&gt;\n\n1. <a href=\"&lt;&amp;&gt;\">&lt;&amp;&gt;</a>: $&lt;&amp;&gt;\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
View 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
View file

@ -0,0 +1,4 @@
pub mod domain;
pub mod application;
pub mod infrastructure;
pub mod presentation;

191
src/main.rs Normal file
View 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
View file

@ -0,0 +1,2 @@
pub mod vk;
pub mod telegram;

View 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
View 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")
}
}