Rewrite it in rust

This commit is contained in:
Artyom Belousov 2024-01-05 22:14:57 +03:00
parent bd149a64a7
commit 763288866c
36 changed files with 2506 additions and 9087 deletions

View file

@ -1,5 +1,6 @@
name: Push To Yandex Cloud CR
on:
pull_request:
push:
branches:
- 'master'
@ -29,6 +30,9 @@ jobs:
- name: Deploy Serverless Container
id: deploy-sls-container
uses: yc-actions/yc-sls-container-deploy@v2
env:
secret: e6qb3muh98m0as2r1r4b
revision: e6q2tsf58jo5qtpjqkf5
with:
yc-sa-json-credentials: ${{ secrets.YC_SA_JSON_CREDENTIALS }}
container-name: mtg-price-bot
@ -40,14 +44,14 @@ jobs:
revision-concurrency: 1
revision-image-url: cr.yandex/crp7gp50nf1pitkpk3hq/mtg-price-bot:${{ github.sha }}
revision-execution-timeout: 10
revision-env: |
YDB_CONNECTION_STRING=grpcs://ydb.serverless.yandexcloud.net:2135/ru-central1/b1gofbt3bqdgf7fash84/etnp6sprstj3c6k8n34s
revision-secrets: |
TG_TOKEN=e6qb3muh98m0as2r1r4b/e6q6lnhd66itrki6ao0s/TG_TOKEN
VK_SECRET_KEY=e6qb3muh98m0as2r1r4b/e6q6lnhd66itrki6ao0s/VK_SECRET_KEY
VK_GROUP_ID=e6qb3muh98m0as2r1r4b/e6q6lnhd66itrki6ao0s/VK_GROUP_ID
VK_TOKEN=e6qb3muh98m0as2r1r4b/e6q6lnhd66itrki6ao0s/VK_TOKEN
VK_CONFIRMATION_STRING=e6qb3muh98m0as2r1r4b/e6q6lnhd66itrki6ao0s/VK_CONFIRMATION_STRING
TELOXIDE_TOKEN=$secret/$revision/TELOXIDE_TOKEN
VK_TOKEN=$secret/$revision/VK_TOKEN
VK_GROUP_ID=$secret/$revision/VK_GROUP_ID
VK_SECRET=$secret/$revision/VK_SECRET
VK_CONFIRMATION_STRING=$secret/$revision/VK_CONFIRMATION_STRING
TG_SECRET=$secret/$revision/TG_SECRET
SCG_CLIENT_GUID=$secret/$revision/SCG_CLIENT_GUID
- name: Deploy API Gateway
id: deploy-gateway

11
.gitignore vendored
View file

@ -1,9 +1,2 @@
vendor
*.swp
hosts
.idea
.venv
coverage.out
.envrc
/my_token
/bot
/target
.env

1985
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 = "2021"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[dependencies]
anyhow = "1.0.79"
axum = "0.7.3"
regex = "1.10.2"
reqwest = "0.11.23"
scryfall = "0.14.2"
serde = { version = "1.0.194", features = ["derive"] }
serde_json = "1.0.111"
teloxide = "0.12.2"
tokio = { version = "1.35.1", features = ["full"] }

View file

@ -1,11 +1,21 @@
FROM golang:1.20.4-alpine3.18
COPY . /go/src/go-mtg-vk
WORKDIR /go/src/go-mtg-vk
RUN go build ./cmd/bot
FROM alpine:3.18
RUN mkdir /app
FROM lukemathwalker/cargo-chef:latest-rust-1 AS chef
WORKDIR /app
COPY --from=0 /go/src/go-mtg-vk/bot .
ENV GIN_MODE=release
ENTRYPOINT ./bot
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:bookworm-slim AS runtime
RUN apt-get update && apt-get -y install libssl3 ca-certificates && rm -rf /var/lib/apt/lists/*
WORKDIR /app
COPY --from=builder /app/target/release/mtg-price-bot /usr/local/bin
ENTRYPOINT ["/usr/local/bin/mtg-price-bot"]

View file

@ -1,142 +0,0 @@
package main
import (
"context"
"fmt"
"log"
"math/rand"
"os"
"strconv"
"time"
"gitlab.com/flygrounder/go-mtg-vk/internal/caching"
"gitlab.com/flygrounder/go-mtg-vk/internal/cardsinfo"
"gitlab.com/flygrounder/go-mtg-vk/internal/scenario"
"gitlab.com/flygrounder/go-mtg-vk/internal/telegram"
"github.com/gin-gonic/gin"
tgbotapi "github.com/go-telegram-bot-api/telegram-bot-api"
environ "github.com/ydb-platform/ydb-go-sdk-auth-environ"
"github.com/ydb-platform/ydb-go-sdk/v3"
"gitlab.com/flygrounder/go-mtg-vk/internal/vk"
)
type config struct {
tgToken string
vkGroupId int64
vkSecretKey string
vkToken string
vkConfirmationString string
ydbConnectionString string
port string
}
func getConfig() *config {
var cfg config
var exists bool
var err error
cfg.tgToken, exists = os.LookupEnv("TG_TOKEN")
if !exists {
panic("TG_TOKEN environment variable not defined")
}
vkGroupId, exists := os.LookupEnv("VK_GROUP_ID")
if !exists {
panic("VK_GROUP_ID environment variable not defined")
}
cfg.vkGroupId, err = strconv.ParseInt(vkGroupId, 10, 64)
if err != nil {
panic("VK_GROUP_ID is not a number")
}
cfg.vkSecretKey, exists = os.LookupEnv("VK_SECRET_KEY")
if !exists {
panic("VK_SECRET_KEY environment variable not defined")
}
cfg.vkToken, exists = os.LookupEnv("VK_TOKEN")
if !exists {
panic("VK_TOKEN environment variable not defined")
}
cfg.vkConfirmationString, exists = os.LookupEnv("VK_CONFIRMATION_STRING")
if !exists {
panic("VK_CONFIRMATION_STRING environment variable not defined")
}
cfg.ydbConnectionString, exists = os.LookupEnv("YDB_CONNECTION_STRING")
if !exists {
panic("YDB_CONNECTION_STRING environment variable not defined")
}
cfg.port, exists = os.LookupEnv("PORT")
if !exists {
panic("PORT environment variable not defined")
}
return &cfg
}
func main() {
rand.Seed(time.Now().UTC().UnixNano())
cfg := getConfig()
bot, _ := tgbotapi.NewBotAPI(cfg.tgToken)
r := gin.Default()
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
db, err := ydb.Open(ctx,
cfg.ydbConnectionString,
environ.WithEnvironCredentials(ctx),
)
if err != nil {
panic(fmt.Errorf("connect error: %w", err))
}
defer func() { _ = db.Close(ctx) }()
cache := &caching.CacheClient{
Storage: db.Table(),
Expiration: 12 * time.Hour,
Prefix: db.Name(),
}
err = cache.Init(context.Background())
if err != nil {
panic(fmt.Errorf("init error: %w", err))
}
logger := log.New(os.Stdout, "", 0)
fetcher := &cardsinfo.Fetcher{}
handler := vk.Handler{
Scenario: &scenario.Scenario{
Sender: &vk.ApiSender{
Token: cfg.vkToken,
Logger: logger,
},
Logger: logger,
InfoFetcher: fetcher,
Cache: cache,
},
SecretKey: cfg.vkSecretKey,
GroupId: cfg.vkGroupId,
ConfirmationString: cfg.vkConfirmationString,
}
tgHandler := telegram.Handler{
Scenario: &scenario.Scenario{
Sender: &telegram.Sender{
API: bot,
},
Logger: logger,
InfoFetcher: fetcher,
Cache: cache,
},
}
r.POST("vk", handler.HandleMessage)
r.POST("tg", tgHandler.HandleMessage)
_ = r.Run(":" + cfg.port)
}

24
go.mod
View file

@ -1,24 +0,0 @@
module gitlab.com/flygrounder/go-mtg-vk
go 1.12
require (
github.com/antchfx/htmlquery v1.2.3
github.com/gin-gonic/gin v1.4.0
github.com/go-telegram-bot-api/telegram-bot-api v4.6.4+incompatible
github.com/golang/protobuf v1.5.3 // indirect
github.com/google/go-cmp v0.5.9 // indirect
github.com/kr/pretty v0.3.0 // indirect
github.com/mattn/go-isatty v0.0.16 // indirect
github.com/pkg/errors v0.9.1
github.com/rogpeppe/go-internal v1.9.0 // indirect
github.com/stretchr/testify v1.8.1
github.com/technoweenie/multipartstreamer v1.0.1 // indirect
github.com/ydb-platform/ydb-go-sdk-auth-environ v0.1.3
github.com/ydb-platform/ydb-go-sdk/v3 v3.47.2
golang.org/x/net v0.10.0 // indirect
google.golang.org/protobuf v1.30.0 // indirect
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect
gopkg.in/h2non/gock.v1 v1.0.16
gopkg.in/yaml.v2 v2.2.4 // indirect
)

280
go.sum
View file

@ -1,280 +0,0 @@
cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
cloud.google.com/go v0.34.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
github.com/antchfx/htmlquery v1.2.3 h1:sP3NFDneHx2stfNXCKbhHFo8XgNjCACnU/4AO5gWz6M=
github.com/antchfx/htmlquery v1.2.3/go.mod h1:B0ABL+F5irhhMWg54ymEZinzMSi0Kt3I2if0BLYa3V0=
github.com/antchfx/xpath v1.1.6 h1:6sVh6hB5T6phw1pFpHRQ+C4bd8sNI+O58flqtg7h0R0=
github.com/antchfx/xpath v1.1.6/go.mod h1:Yee4kTMuNiPYJ7nSNorELQMr1J33uOpXDMByNYhvtNk=
github.com/antihax/optional v1.0.0/go.mod h1:uupD/76wgC+ih3iEmQUL+0Ugr19nfwCT1kdvxnR2qWY=
github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU=
github.com/cespare/xxhash/v2 v2.1.1/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw=
github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc=
github.com/cncf/udpa/go v0.0.0-20201120205902-5459f2c99403/go.mod h1:WmhPx2Nbnhtbo57+VJT5O0JRkEi1Wbu0z5j0R8u5Hbk=
github.com/cncf/udpa/go v0.0.0-20210930031921-04548b0d99d4/go.mod h1:6pvJx4me5XPnfI9Z40ddWsdw2W/uZgQLFXToKeRcDiI=
github.com/cncf/xds/go v0.0.0-20210805033703-aa0b78936158/go.mod h1:eXthEFrGJvWHgFFCl3hGmgk+/aYT6PnTQLykKQRLhEs=
github.com/cncf/xds/go v0.0.0-20210922020428-25de7278fc84/go.mod h1:eXthEFrGJvWHgFFCl3hGmgk+/aYT6PnTQLykKQRLhEs=
github.com/cncf/xds/go v0.0.0-20211001041855-01bcc9b48dfe/go.mod h1:eXthEFrGJvWHgFFCl3hGmgk+/aYT6PnTQLykKQRLhEs=
github.com/cncf/xds/go v0.0.0-20211011173535-cb28da3451f1/go.mod h1:eXthEFrGJvWHgFFCl3hGmgk+/aYT6PnTQLykKQRLhEs=
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98=
github.com/envoyproxy/go-control-plane v0.9.9-0.20201210154907-fd9021fe5dad/go.mod h1:cXg6YxExXjJnVBQHBLXeUAgxn2UodCpnH306RInaBQk=
github.com/envoyproxy/go-control-plane v0.9.9-0.20210217033140-668b12f5399d/go.mod h1:cXg6YxExXjJnVBQHBLXeUAgxn2UodCpnH306RInaBQk=
github.com/envoyproxy/go-control-plane v0.9.10-0.20210907150352-cf90f659a021/go.mod h1:AFq3mo9L8Lqqiid3OhADV3RfLJnjiw63cSpi+fDTRC0=
github.com/envoyproxy/go-control-plane v0.10.2-0.20220325020618-49ff273808a1/go.mod h1:KJwIaB5Mv44NWtYuAOFCVOjcI94vtpEz2JU/D2v6IjE=
github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c=
github.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04=
github.com/gin-contrib/sse v0.0.0-20190301062529-5545eab6dad3 h1:t8FVkw33L+wilf2QiWkw0UV77qRpcH/JHPKGpKa2E8g=
github.com/gin-contrib/sse v0.0.0-20190301062529-5545eab6dad3/go.mod h1:VJ0WA2NBN22VlZ2dKZQPAPnyWw5XTlK1KymzLKsr59s=
github.com/gin-gonic/gin v1.4.0 h1:3tMoCCfM7ppqsR0ptz/wi1impNpT7/9wQtMZ8lr1mCQ=
github.com/gin-gonic/gin v1.4.0/go.mod h1:OW2EZn3DO8Ln9oIKOvM++LBO+5UPHJJDH72/q/3rZdM=
github.com/go-telegram-bot-api/telegram-bot-api v4.6.4+incompatible h1:2cauKuaELYAEARXRkq2LrJ0yDDv1rW7+wrTEdVL3uaU=
github.com/go-telegram-bot-api/telegram-bot-api v4.6.4+incompatible/go.mod h1:qf9acutJ8cwBUhm1bqgz6Bei9/C/c93FPDljKWwsOgM=
github.com/golang-jwt/jwt v3.2.2+incompatible h1:IfV12K8xAKAnZqdXVzCZ+TOjboZ2keLg81eXfW3O+oY=
github.com/golang-jwt/jwt v3.2.2+incompatible/go.mod h1:8pz2t5EyA70fFQQSrl6XZXzqecmYZeUEB8OUGHkxJ+I=
github.com/golang-jwt/jwt/v4 v4.4.1 h1:pC5DB52sCeK48Wlb9oPcdhnjkz1TKt1D/P7WKJ0kUcQ=
github.com/golang-jwt/jwt/v4 v4.4.1/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0=
github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q=
github.com/golang/groupcache v0.0.0-20200121045136-8c9f03a8e57e h1:1r7pUrabqp18hOBcwBwiTsbnFeTZHV9eER/QT5JVZxY=
github.com/golang/groupcache v0.0.0-20200121045136-8c9f03a8e57e/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A=
github.com/golang/mock v1.6.0 h1:ErTB+efbowRARo13NNdxyJji2egdxLGQhRaY+DUumQc=
github.com/golang/mock v1.6.0/go.mod h1:p6yTPP+5HYm5mzsMV8JkE6ZKdX+/wYM6Hr+LicevLPs=
github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/golang/protobuf v1.3.3/go.mod h1:vzj43D7+SQXF/4pzW/hwtAqwc6iTitCiVSaWz5lYuqw=
github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8=
github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA=
github.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrUpVNzEA03Pprs=
github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w=
github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0=
github.com/golang/protobuf v1.4.1/go.mod h1:U8fpvMrcmy5pZrNK1lt4xCsGvpyWQ/VVv6QDs8UjoX8=
github.com/golang/protobuf v1.4.2/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI=
github.com/golang/protobuf v1.4.3/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI=
github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
github.com/golang/protobuf v1.5.2/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY=
github.com/golang/protobuf v1.5.3 h1:KhyjKVUg7Usr/dYsdSqoFveMYd5ko72D+zANwlG1mmg=
github.com/golang/protobuf v1.5.3/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY=
github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M=
github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38=
github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/uuid v1.1.2/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/google/uuid v1.3.0 h1:t6JiXgmwXMjEs8VusXIJk2BXHsn+wx8BZdTaoZ5fu7I=
github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/grpc-ecosystem/grpc-gateway v1.16.0/go.mod h1:BDjrQk3hbvj6Nolgz8mAMFbcEtjT1g+wF4CSlocrBnw=
github.com/h2non/parth v0.0.0-20190131123155-b4df798d6542 h1:2VTzZjLZBgl62/EtslCrtky5vbi9dd7HrQPQIx6wqiw=
github.com/h2non/parth v0.0.0-20190131123155-b4df798d6542/go.mod h1:Ow0tF8D4Kplbc8s8sSb3V2oUCygFHVp8gC3Dn6U4MNI=
github.com/jonboulle/clockwork v0.3.0 h1:9BSCMi8C+0qdApAp4auwX0RkLGUjs956h0EkuQymUhg=
github.com/jonboulle/clockwork v0.3.0/go.mod h1:Pkfl5aHPm1nk2H9h0bjmnJD/BcgbGXUBGnn1kMkgxc8=
github.com/json-iterator/go v1.1.6 h1:MrUvLMLTMxbqFJ9kzlvat/rYZqZnW3u4wkLzWTaFwKs=
github.com/json-iterator/go v1.1.6/go.mod h1:+SdeFBvtyEkXs7REEP0seUULqWtbJapLOCVDaaPEHmU=
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI=
github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0=
github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk=
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/mattn/go-isatty v0.0.7/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s=
github.com/mattn/go-isatty v0.0.16 h1:bq3VjFmv/sOjHtdEhmkEV4x1AJtvUvOJ2PFAZ5+peKQ=
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/reflect2 v1.0.1 h1:9f412s+6RmYXLWZSEzVVgPGK7C2PphHj5RJrvfx9AWI=
github.com/modern-go/reflect2 v1.0.1/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0=
github.com/nbio/st v0.0.0-20140626010706-e9e8d9816f32 h1:W6apQkHrMkS0Muv8G/TipAy/FJl/rCYT0+EuS8+Z0z4=
github.com/nbio/st v0.0.0-20140626010706-e9e8d9816f32/go.mod h1:9wM+0iRr9ahx58uYLpLIr5fm8diHn0JbqRycJi6w0Ms=
github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA=
github.com/rekby/fixenv v0.3.2/go.mod h1:/b5LRc06BYJtslRtHKxsPWFT/ySpHV+rWvzTg+XWk4c=
github.com/rogpeppe/fastuuid v1.2.0/go.mod h1:jVj6XXZzXRy/MSR5jhDC/2q6DgLz+nrA6LYCDYWNEvQ=
github.com/rogpeppe/go-internal v1.6.1/go.mod h1:xXDCJY+GAPziupqXw64V24skbSoqbTEfhy4qGm1nDQc=
github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8=
github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk=
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
github.com/technoweenie/multipartstreamer v1.0.1 h1:XRztA5MXiR1TIRHxH2uNxXxaIkKQDeX7m2XsSOlQEnM=
github.com/technoweenie/multipartstreamer v1.0.1/go.mod h1:jNVxdtShOxzAsukZwTSw6MDx5eUJoiEBsSvzDU9uzog=
github.com/ugorji/go v1.1.4 h1:j4s+tAvLfL3bZyefP2SEWmhBzmuIlH/eqNuPdFPgngw=
github.com/ugorji/go v1.1.4/go.mod h1:uQMGLiO92mf5W77hV/PUCpI3pbzQx3CRekS0kk+RGrc=
github.com/yandex-cloud/go-genproto v0.0.0-20211115083454-9ca41db5ed9e h1:9LPdmD1vqadsDQUva6t2O9MbnyvoOgo8nFNPaOIH5U8=
github.com/yandex-cloud/go-genproto v0.0.0-20211115083454-9ca41db5ed9e/go.mod h1:HEUYX/p8966tMUHHT+TsS0hF/Ca/NYwqprC5WXSDMfE=
github.com/ydb-platform/ydb-go-genproto v0.0.0-20220203104745-929cf9c248bc/go.mod h1:cc138nptTn9eKptCQl/grxP6pBKpo/bnXDiOxuVZtps=
github.com/ydb-platform/ydb-go-genproto v0.0.0-20221215182650-986f9d10542f h1:BBczNIM1MJHT7XkIUA8pThXWxJvxoBjcWvne3xwe2RI=
github.com/ydb-platform/ydb-go-genproto v0.0.0-20221215182650-986f9d10542f/go.mod h1:Er+FePu1dNUieD+XTMDduGpQuCPssK5Q4BjF+IIXJ3I=
github.com/ydb-platform/ydb-go-sdk-auth-environ v0.1.3 h1:+DrFgi0hjjLarFcWHwI6WPk28hcWr6N7ga+OHAxtemI=
github.com/ydb-platform/ydb-go-sdk-auth-environ v0.1.3/go.mod h1:K5wHHoLBfmWc7zyiETOA1Spx7DN+4skQ7YeWy5snXKo=
github.com/ydb-platform/ydb-go-sdk/v3 v3.25.3/go.mod h1:PFizF/vJsdAgEwjK3DVSBD52kdmRkWfSIS2q2pA+e88=
github.com/ydb-platform/ydb-go-sdk/v3 v3.47.1/go.mod h1:oSLwnuilwIpaF5bJJMAofnGgzPJusoI3zWMNb8I+GnM=
github.com/ydb-platform/ydb-go-sdk/v3 v3.47.2 h1:nBPXs27hwve26x2DIec45IO0EnxZiB63SwTIPanre/4=
github.com/ydb-platform/ydb-go-sdk/v3 v3.47.2/go.mod h1:oSLwnuilwIpaF5bJJMAofnGgzPJusoI3zWMNb8I+GnM=
github.com/ydb-platform/ydb-go-yc v0.10.2 h1:RAHy6g7ncxk1y0N4oS2MwYXLATqRqKBI6DYXuxpV2wo=
github.com/ydb-platform/ydb-go-yc v0.10.2/go.mod h1:U1dX3LJy6zADId2DciCXlgrU/vphK1+CQzaefKq21dQ=
github.com/ydb-platform/ydb-go-yc-metadata v0.5.2 h1:nMtixUijP0Z7iHJNT9fOL+dbmEzZxqU6Xk87ll7hqXg=
github.com/ydb-platform/ydb-go-yc-metadata v0.5.2/go.mod h1:82SQ4L3PewiEmFW4oTMc1sfPjODasIYxD/SKGsbK74s=
github.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
go.opentelemetry.io/proto/otlp v0.7.0/go.mod h1:PqfVotwruBrMGOCsRd/89rSnXhoiJIqeYNgFYFoEGnI=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU=
golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
golang.org/x/mod v0.4.2/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20190108225652-1e06a53dbb7e/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190503192946-f4e77d36d62c/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20191021144547-ec77196f6094/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20200421231249-e086a090c8fd/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
golang.org/x/net v0.0.0-20200822124328-c89045814202/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA=
golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4/go.mod h1:p54w0d4576C0XHj96bSt6lcn1PtDYWL6XObtHCRCNQM=
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
golang.org/x/net v0.10.0 h1:X2//UzNDwYmtCLn7To6G58Wr6f5ahEAQgKNzv9Y951M=
golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
golang.org/x/oauth2 v0.0.0-20200107190931-bf48bf16ab8d/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.1.0 h1:wsuoTGHzEhffawBOhz5CYhcrV4IdKZbEyZjBMuTp12o=
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190222072716-a9d3bda3a223/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210119212857-b64e53b001e4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210330210617-4fbd30eecc44/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210510120138-977fb7262007/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.8.0 h1:EBmGv8NaZBZTWvrbjNoL6HVt+IVy3QDQpJs7VRIw3tU=
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.5/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/text v0.9.0 h1:2sjJmO8cDvYveuX97RDLsxlyUxLl+GHoLxBiRdHllBE=
golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY=
golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
golang.org/x/tools v0.0.0-20190524140312-2c0ae7006135/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.1.1/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk=
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM=
google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc=
google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc=
google.golang.org/genproto v0.0.0-20200513103714-09dca8ec2884/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c=
google.golang.org/genproto v0.0.0-20200526211855-cb27e3aa2013/go.mod h1:NbSheEEYHJ7i3ixzK3sjbqSGDJWnxyFXZblF3eUsNvo=
google.golang.org/genproto v0.0.0-20220421151946-72621c1f0bd3 h1:SeX3QUcBj3fciwnfPT9kt5gBhFy/FCZtYZ+I/RB8agc=
google.golang.org/genproto v0.0.0-20220421151946-72621c1f0bd3/go.mod h1:8w6bsBMX6yCPbAVTeqQHvzxW0EIFigd5lZyahWgyfDo=
google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c=
google.golang.org/grpc v1.23.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg=
google.golang.org/grpc v1.25.1/go.mod h1:c3i+UQWmh7LiEpx4sFZnkU36qjEYZ0imhYfXVyQciAY=
google.golang.org/grpc v1.27.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk=
google.golang.org/grpc v1.33.1/go.mod h1:fr5YgcSWrqhRRxogOsw7RzIpsmvOZ6IcH4kBYTpR3n0=
google.golang.org/grpc v1.36.0/go.mod h1:qjiiYl8FncCW8feJPdyg3v6XW24KsRHe+dy9BAGRRjU=
google.golang.org/grpc v1.37.0/go.mod h1:NREThFqKR1f3iQ6oBuvc5LadQuXVGo9rkm5ZGrQdJfM=
google.golang.org/grpc v1.43.0/go.mod h1:k+4IHHFw41K8+bbowsex27ge2rCb65oeWqe4jJ590SU=
google.golang.org/grpc v1.45.0/go.mod h1:lN7owxKUQEqMfSyQikvvk5tf/6zMPsrK+ONuO11+0rQ=
google.golang.org/grpc v1.47.0/go.mod h1:vN9eftEi1UMyUsIF80+uQXhHjbXYbm0uXoFCACuMGWk=
google.golang.org/grpc v1.49.0 h1:WTLtQzmQori5FUH25Pq4WT22oCsv8USpQ+F6rqtsmxw=
google.golang.org/grpc v1.49.0/go.mod h1:ZgQEeidpAuNRZ8iRrlBKXZQP1ghovWIVhdJRyCDK+GI=
google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8=
google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0=
google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM=
google.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miEFZTKqfCUM6K7xSMQL9OKL/b6hQv+e19PK+JZNE=
google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo=
google.golang.org/protobuf v1.22.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
google.golang.org/protobuf v1.23.1-0.20200526195155-81db48ad09cc/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
google.golang.org/protobuf v1.25.0/go.mod h1:9JNX74DMeImyA3h4bdi1ymwjUzf21/xIlbajtzgsN7c=
google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc=
google.golang.org/protobuf v1.27.1/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc=
google.golang.org/protobuf v1.28.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I=
google.golang.org/protobuf v1.30.0 h1:kPPoIgf3TsEvrm0PFe15JQ+570QVxYzEvvHqChK+cng=
google.golang.org/protobuf v1.30.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI=
gopkg.in/go-playground/assert.v1 v1.2.1 h1:xoYuJVE7KT85PYWrN730RguIQO0ePzVRfFMXadIrXTM=
gopkg.in/go-playground/assert.v1 v1.2.1/go.mod h1:9RXL0bg/zibRAgZUYszZSwO/z8Y/a8bDuhia5mkpMnE=
gopkg.in/go-playground/validator.v8 v8.18.2 h1:lFB4DoMU6B626w8ny76MV7VX6W2VHct2GVOI3xgiMrQ=
gopkg.in/go-playground/validator.v8 v8.18.2/go.mod h1:RX2a/7Ha8BgOhfk7j780h4/u/RRjR0eouCJSH80/M2Y=
gopkg.in/h2non/gentleman.v1 v1.0.4/go.mod h1:JYuHVdFzS4MKOXe0o+chKJ4hCe6tqKKw9XH9YP6WFrg=
gopkg.in/h2non/gock.v1 v1.0.16 h1:F11k+OafeuFENsjei5t2vMTSTs9L62AdyTe4E1cgdG8=
gopkg.in/h2non/gock.v1 v1.0.16/go.mod h1:XVuDAssexPLwgxCLMvDTWNU5eqklsydR6I5phZ9oPB8=
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.2.3/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.2.4 h1:/eiJrUcujPVeJ3xlSWaiNi3uSVmDGBK1pDHUHAnao1I=
gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.0/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=

View file

@ -1,108 +0,0 @@
package caching
import (
"context"
"encoding/json"
"fmt"
"path"
"time"
"github.com/pkg/errors"
"github.com/ydb-platform/ydb-go-sdk/v3/table"
"github.com/ydb-platform/ydb-go-sdk/v3/table/options"
"github.com/ydb-platform/ydb-go-sdk/v3/table/result"
"github.com/ydb-platform/ydb-go-sdk/v3/table/result/named"
"github.com/ydb-platform/ydb-go-sdk/v3/table/types"
"gitlab.com/flygrounder/go-mtg-vk/internal/cardsinfo"
)
type CacheClient struct {
Storage table.Client
Expiration time.Duration
Prefix string
}
func (client *CacheClient) Init(ctx context.Context) error {
return client.Storage.Do(ctx, func(ctx context.Context, s table.Session) error {
return s.CreateTable(
ctx,
path.Join(client.Prefix, "cache"),
options.WithColumn("card", types.TypeString),
options.WithColumn("prices", types.Optional(types.TypeJSON)),
options.WithColumn("created_at", types.Optional(types.TypeTimestamp)),
options.WithTimeToLiveSettings(
options.NewTTLSettings().ColumnDateType("created_at").ExpireAfter(client.Expiration),
),
options.WithPrimaryKeyColumn("card"),
)
})
}
func (client *CacheClient) Set(ctx context.Context, key string, prices []cardsinfo.ScgCardPrice) error {
const query = `
DECLARE $cacheData AS List<Struct<
card: String,
prices: Json,
created_at: Timestamp>>;
INSERT INTO cache SELECT cd.card AS card, cd.prices AS prices, cd.created_at AS created_at FROM AS_TABLE($cacheData) cd LEFT OUTER JOIN cache c ON cd.card = c.card WHERE c.card IS NULL`
value, _ := json.Marshal(prices)
return client.Storage.Do(ctx, func(ctx context.Context, s table.Session) (err error) {
_, _, err = s.Execute(ctx, writeTx(), query, table.NewQueryParameters(
table.ValueParam("$cacheData", types.ListValue(
types.StructValue(
types.StructFieldValue("card", types.StringValueFromString(key)),
types.StructFieldValue("prices", types.JSONValueFromBytes(value)),
types.StructFieldValue("created_at", types.TimestampValueFromTime(time.Now())),
))),
))
return
})
}
func (client *CacheClient) Get(ctx context.Context, key string) ([]cardsinfo.ScgCardPrice, error) {
const query = `
DECLARE $card AS String;
SELECT UNWRAP(prices) AS prices FROM cache WHERE card = $card`
var pricesStr string
var res result.Result
err := client.Storage.Do(ctx, func(ctx context.Context, s table.Session) (err error) {
_, res, err = s.Execute(ctx, readTx(), query, table.NewQueryParameters(
table.ValueParam("$card", types.StringValueFromString(key)),
))
return
})
if err != nil {
return nil, errors.Wrap(err, "Failed to get key")
}
ok := res.NextResultSet(ctx)
if !ok {
return nil, errors.New("no key")
}
ok = res.NextRow()
if !ok {
return nil, errors.New("no key")
}
err = res.ScanNamed(
named.Required("prices", &pricesStr),
)
if err != nil {
return nil, fmt.Errorf("failed to scan prices: %w", err)
}
var prices []cardsinfo.ScgCardPrice
_ = json.Unmarshal([]byte(pricesStr), &prices)
return prices, nil
}
func writeTx() *table.TransactionControl {
return table.TxControl(table.BeginTx(
table.WithSerializableReadWrite(),
), table.CommitTx())
}
func readTx() *table.TransactionControl {
return table.TxControl(table.BeginTx(
table.WithOnlineReadOnly(),
), table.CommitTx())
}

View file

@ -1,23 +0,0 @@
package cardsinfo
import (
"strings"
)
type ScgCardPrice struct {
Price string
Edition string
Link string
}
type card struct {
Name string `json:"name"`
Layout string `json:"layout"`
}
func (c *card) getName() string {
if c.Layout == "transform" {
return strings.Replace(c.Name, "//", "|", 1)
}
return c.Name
}

View file

@ -1,4 +0,0 @@
package cardsinfo
type Fetcher struct {
}

View file

@ -1,51 +0,0 @@
package cardsinfo
import (
"encoding/json"
"io/ioutil"
"net/http"
"net/url"
"strings"
)
const scryfallUrl = "https://api.scryfall.com"
func (f *Fetcher) GetNameByCardId(set string, number string) string {
/*
Note: number is string because some cards contain letters in their numbers.
*/
path := scryfallUrl + "/cards/" + strings.ToLower(set) + "/" + number
return getCardByUrl(path)
}
func (f *Fetcher) GetOriginalName(name string) string {
path := scryfallUrl + "/cards/named?fuzzy=" + applyFilters(name)
result := getCardByUrl(path)
return result
}
func applyFilters(name string) string {
/*
Despite of the rules of Russian language, letter ё is replaced with e on cards
Sometimes it leads to wrong search results
*/
name = strings.ReplaceAll(name, "ё", "е")
return url.QueryEscape(name)
}
func getCardByUrl(path string) string {
response, err := http.Get(path)
if err != nil {
return ""
}
defer func() {
_ = response.Body.Close()
}()
data, _ := ioutil.ReadAll(response.Body)
var v card
err = json.Unmarshal(data, &v)
if err != nil {
return ""
}
return v.getName()
}

View file

@ -1,62 +0,0 @@
package cardsinfo
import (
"errors"
"net/http"
"testing"
"github.com/stretchr/testify/assert"
"gopkg.in/h2non/gock.v1"
)
func TestGetNameByCardId(t *testing.T) {
defer gock.Off()
gock.New(scryfallUrl + "/set/1").Reply(http.StatusOK).JSON(card{
Name: "card",
})
f := &Fetcher{}
name := f.GetNameByCardId("set", "1")
assert.Equal(t, "card", name)
}
func TestGetOriginalName_Scryfall(t *testing.T) {
defer gock.Off()
gock.New(scryfallUrl + "/cards/named?fuzzy=card").Reply(http.StatusOK).JSON(card{
Name: "Result Card",
})
f := &Fetcher{}
name := f.GetOriginalName("card")
assert.Equal(t, "Result Card", name)
}
func TestGetOriginalName_BadJson(t *testing.T) {
defer gock.Off()
gock.New(scryfallUrl + "/cards/named?fuzzy=card").Reply(http.StatusOK).BodyString("}")
f := &Fetcher{}
name := f.GetOriginalName("card")
assert.Equal(t, "", name)
}
func TestGetOriginalName_DoubleSide(t *testing.T) {
defer gock.Off()
gock.New(scryfallUrl + "/cards/named?fuzzy=card").Reply(http.StatusOK).JSON(card{
Name: "Legion's Landing // Adanto, the First Fort",
Layout: "transform",
})
f := &Fetcher{}
name := f.GetOriginalName("card")
assert.Equal(t, "Legion's Landing | Adanto, the First Fort", name)
}
func TestGetOriginalName_Error(t *testing.T) {
defer gock.Off()
gock.New(scryfallUrl + "/cards/named?fuzzy=card").ReplyError(errors.New("internal server error"))
f := &Fetcher{}
name := f.GetOriginalName("card")
assert.Equal(t, "", name)
}

View file

@ -1,48 +0,0 @@
package cardsinfo
import (
"net/url"
"strings"
"github.com/antchfx/htmlquery"
"github.com/pkg/errors"
)
const scgDomain = "https://starcitygames.com"
const scgSearchUrlTemplate = "https://starcitygames.hawksearch.com/sites/starcitygames/?tournament_legality=Legal&search_query="
func (f *Fetcher) GetPrices(name string) ([]ScgCardPrice, error) {
prices, err := getPricesScg(name)
if err != nil {
return nil, err
}
if len(prices) > 5 {
return prices[:5], nil
}
return prices, nil
}
func getPricesScg(name string) ([]ScgCardPrice, error) {
escapedName := url.QueryEscape(name)
searchUrl := scgSearchUrlTemplate + escapedName
node, err := htmlquery.LoadURL(searchUrl)
if err != nil {
return nil, errors.Wrap(err, "cannot load url")
}
blocks := htmlquery.Find(node, "//div[@class=\"hawk-results-item\"]")
var results []ScgCardPrice
for _, block := range blocks {
price := ScgCardPrice{}
linkNode := htmlquery.FindOne(block, "//h2/a")
price.Link = scgDomain + htmlquery.SelectAttr(linkNode, "href")
editionNode := htmlquery.FindOne(block, "//p[@class=\"hawk-results-item__category\"]/a")
if !strings.HasPrefix(htmlquery.SelectAttr(editionNode, "href"), "/shop/singles/") {
continue
}
price.Edition = editionNode.FirstChild.Data
priceNode := htmlquery.FindOne(block, "//span[@class='hawk-old-price']|//div[contains(concat(' ',normalize-space(@class),' '),' hawk-results-item__options-table-cell--price ')]")
price.Price = priceNode.FirstChild.Data
results = append(results, price)
}
return results, nil
}

View file

@ -1,106 +0,0 @@
package cardsinfo
import (
"net/http"
"os"
"testing"
"gopkg.in/h2non/gock.v1"
"github.com/stretchr/testify/assert"
)
func TestGetPrices_Ok(t *testing.T) {
defer gock.Off()
file, _ := os.Open("test_data/AcademyRuinsTest.html")
gock.New(scgSearchUrlTemplate + "card").Reply(http.StatusOK).Body(file)
f := &Fetcher{}
prices, err := f.GetPrices("card")
assert.Nil(t, err)
assert.Equal(t, []ScgCardPrice{
{
Price: "$6.99",
Edition: "Double Masters",
Link: "https://starcitygames.com/academy-ruins-sgl-mtg-2xm-309-enn/?sku=SGL-MTG-2XM-309-ENN1",
},
{
Price: "$9.99",
Edition: "Double Masters (Foil)",
Link: "https://starcitygames.com/academy-ruins-sgl-mtg-2xm-309-enf/?sku=SGL-MTG-2XM-309-ENF1",
},
{
Price: "$11.99",
Edition: "Double Masters - Variants",
Link: "https://starcitygames.com/academy-ruins-sgl-mtg-2xm2-369-enn/?sku=SGL-MTG-2XM2-369-ENN1",
},
{
Price: "$14.99",
Edition: "Double Masters - Variants (Foil)",
Link: "https://starcitygames.com/academy-ruins-sgl-mtg-2xm2-369-enf/?sku=SGL-MTG-2XM2-369-ENF1",
},
{
Price: "$7.99",
Edition: "Modern Masters: 2013 Edition",
Link: "https://starcitygames.com/academy-ruins-sgl-mtg-mma-219-enn/?sku=SGL-MTG-MMA-219-ENN1",
},
}, prices)
}
func TestGetPrices_Unavailable(t *testing.T) {
defer gock.Off()
gock.New(scgSearchUrlTemplate + "card").Reply(http.StatusBadGateway)
f := &Fetcher{}
_, err := f.GetPrices("card")
assert.NotNil(t, err)
}
func TestGetPrices_Empty(t *testing.T) {
defer gock.Off()
file, _ := os.Open("test_data/EmptyTest.html")
gock.New(scgSearchUrlTemplate + "card").Reply(http.StatusOK).Body(file)
f := &Fetcher{}
prices, err := f.GetPrices("card")
assert.Nil(t, err)
assert.Nil(t, prices)
}
func TestGetPrices_FilterNonCards(t *testing.T) {
defer gock.Off()
file, _ := os.Open("test_data/NonCards.html")
gock.New(scgSearchUrlTemplate + "card").Reply(http.StatusOK).Body(file)
f := &Fetcher{}
prices, err := f.GetPrices("card")
assert.Nil(t, err)
expected := []ScgCardPrice{
{
Price: "$72.99",
Edition: "3rd Edition - Black Border",
Link: "https://starcitygames.com/sol-ring-sgl-mtg-3bb-274-frn/?sku=SGL-MTG-3BB-274-FRN3",
},
{
Price: "$24.99",
Edition: "3rd Edition/Revised",
Link: "https://starcitygames.com/sol-ring-sgl-mtg-3ed-274-enn/?sku=SGL-MTG-3ED-274-ENN1",
},
{
Price: "$1,999.99",
Edition: "Alpha",
Link: "https://starcitygames.com/sol-ring-sgl-mtg-lea-269-enn/?sku=SGL-MTG-LEA-269-ENN1",
},
{
Price: "$1,199.99",
Edition: "Beta",
Link: "https://starcitygames.com/sol-ring-sgl-mtg-leb-270-enn/?sku=SGL-MTG-LEB-270-ENN1",
},
{
Price: "$99.99",
Edition: "Collectors' Edition",
Link: "https://starcitygames.com/sol-ring-sgl-mtg-ced-270-enn/?sku=SGL-MTG-CED-270-ENN1",
},
}
assert.Equal(t, expected, prices)
}

File diff suppressed because it is too large Load diff

View file

@ -1,356 +0,0 @@
<div>
<div id="hawkmetarobots"><meta name="robots" content="noindex,nofollow" /></div>
<div id="hawkheadertitle"><title>Nosuchcardever at Star City Games</title></div>
<div id="hawkmetadescription"></div>
<div id="hawkmetakeywords"></div>
<div id="hawkrelcanonical"></div>
<div id="hawklandingpagecontent"></div>
<div id="hawktoptext">
<div id="hawk-backToTop">
<img src="//manage.hawksearch.com/sites/shared/images/top.png" />
</div>
</div>
<div id="hawktoppager">
<div class="clearfix">
<div class="hawk-searchView">
</div>
</div>
</div>
<div id="hawkitemlist">
<div style="padding-top:15px;" class="hawk-">
Search was unable to find any results for <b>"nosuchcardever"</b>, you may have typed your word incorrectly, or are being too specific. Try using a broader search phrase.
</div>
</div>
<div id="hawkbottompager">
</div>
<div id="hawkexplain">
</div>
<div id="hawksmartbug">
</div>
<div id="hawkrelated">
</div>
<div id="hawkfacets">
<input type="hidden" id="hdnhawkcompare" value="" />
<input type="hidden" id="hdnhawkprv" value="" />
<input type="hidden" id="hdnhawklp" value="" />
<input type="hidden" id="hdnhawkadv" value="" />
<input type="hidden" id="hdnhawkit" value="" />
<input type="hidden" id="hdnhawkoperator" value="" />
<input type="hidden" id="hdnhawkexpand" value="" />
<input type="hidden" id="hdnhawkkeyword" value="nosuchcardever" />
<input type="hidden" id="hdnhawkpg" value="" />
<input type="hidden" id="hdnhawkmpp" value="24" />
<input type="hidden" id="hdnhawksortby" value="" />
<input type="hidden" id="hdnhawkb" value="" />
<input type="hidden" id="hdnhawkcustom" value="" />
<input type="hidden" id="hdnhawkdefaultmpp" value="24" />
<input type="hidden" id="hdnhawkkeywordfield" value="search_query" />
<input type="hidden" id="hdnhawktrackingid" value="54966a4b-5aca-48a6-bdc6-634cac18c27b" />
<input type="hidden" id="hdnhawkflags" value="" />
<input type="hidden" id="hdnhawktrackingversion" value="v2" />
<input type="hidden" id="hdnhawkaid" value="" />
<input type="hidden" id="hdnhawkp" value="" />
<input type="hidden" id="hdnhawkssfid" value="35158" />
<input type="hidden" id="hdnhawkislpc" value="0" />
<input type="hidden" id="hdnhawkislpcip" value="0" />
<input type="hidden" id="hdnhawksearchable" value="" />
<input type="hidden" id="hdnhawksearchablefacets" value="" />
<div class="hawk-railNavHeading">
Narrow Results
</div>
<div class="hawkRailNav">
<div class="hawk-guidedNavWrapper">
</div>
</div>
<div class="clear"></div>
</div>
<div id="hawkbannertop" class="hawk-bannerTop">
</div>
<div id="hawkbannertopmobile" class="bannerTopMobile">
</div>
<div id="hawkbannerlefttop" class="hawk-bannerLeftTop">
</div>
<div id="hawkbannerlefttopMobile" class="bannerLeftTopMobile">
</div>
<div id="hawkbannerleftbottom" class="hawk-bannerLeftBottom">
<div>
<div class="TagCloud">
<span class="TagWeight3" title="Count: 10534"><a href="/search?keyword=blood moon">blood moon</a></span>
<span class="TagWeight5" title="Count: 8885"><a href="/search?keyword=chrome mox">chrome mox</a></span>
<span class="TagWeight4" title="Count: 9488"><a href="/search?keyword=demonic tutor">demonic tutor</a></span>
<span class="TagWeight1" title="Count: 47974"><a href="/search?keyword=force of will">force of will</a></span>
<span class="TagWeight5" title="Count: 8719"><a href="/search?keyword=grim tutor">grim tutor</a></span>
<span class="TagWeight4" title="Count: 9751"><a href="/search?keyword=jeweled lotus">jeweled lotus</a></span>
<span class="TagWeight5" title="Count: 8370"><a href="/search?keyword=lightning bolt">lightning bolt</a></span>
<span class="TagWeight5" title="Count: 8885"><a href="/search?keyword=lotus cobra">lotus cobra</a></span>
<span class="TagWeight2" title="Count: 16372"><a href="/search?keyword=mana crypt">mana crypt</a></span>
<span class="TagWeight3" title="Count: 12372"><a href="/search?keyword=mana drain">mana drain</a></span>
<span class="TagWeight4" title="Count: 9300"><a href="/search?keyword=mox opal">mox opal</a></span>
<span class="TagWeight2" title="Count: 14914"><a href="/search?keyword=sol ring">sol ring</a></span>
<span class="TagWeight3" title="Count: 12017"><a href="/search?keyword=thoughtseize">thoughtseize</a></span>
<span class="TagWeight5" title="Count: 7886"><a href="/search?keyword=uro">uro</a></span>
<span class="TagWeight4" title="Count: 10162"><a href="/search?keyword=vampiric tutor">vampiric tutor</a></span>
</div>
</div>
</div>
<div id="hawkbannerleftbottommobile" class="bannerLeftBottomMobile">
</div>
<div id="hawkbannerbottom" class="hawk-bannerBottom">
<p><span style="background-color:#ffff00;"></span></p><span style="font-size:14px;">&nbsp;- Please double check your spelling.</span><br style="font-size:14px;" /><span style="font-size:14px;">&nbsp;- Try searching for an item that is less specific.</span><br style="font-size:14px;" /><span style="font-size:14px;">&nbsp;- You can always narrow your search results later.&nbsp;</span><span style="background-color:#ffff00;"></span><span style="background-color:#ffff00;"><br /></span><p><span style="background-color:#ffff00;"></span><span style="background-color:#ffff00;"><br /></span><br /></p>
</div>
<div id="hawkbannerbottomMobile" class="bannerBottomMobile">
</div>
<div id="hawkbannerbottom2" class="hawk-bannerBottom2"><div id="ctl00_BannerBottom2_FeaturedBottom2_titleDiv" class="hawk-featured-title">Popular Items</div>
<div class="grid_3 " >
<div class="itemWrapper hawk-itemWrapper">
<a href="/rathi-dragon-sgl-mtg-9ed-210-jaf/" id="ctl00_BannerBottom2_FeaturedBottom2_lvItems_ctrl0_ctl00_item_lnk1" class="itemLink" onclick="return HawkSearch.link(event,&#39;54966a4b-5aca-48a6-bdc6-634cac18c27b&#39;,1,&#39;99998&#39;,0)">
<img class="itemImage hawk-itemImage" src="" alt="" style="height:190px;" />
</a>
<h3 class="itemTitle">
<em style="display: block;"></em>
<a href="/rathi-dragon-sgl-mtg-9ed-210-jaf/" id="ctl00_BannerBottom2_FeaturedBottom2_lvItems_ctrl0_ctl00_item_lnk2" onclick="return HawkSearch.link(event,&#39;54966a4b-5aca-48a6-bdc6-634cac18c27b&#39;,1,&#39;99998&#39;,0)">Rathi Dragon</a>
</h3>
<span class="itemSku">SGL-MTG-9ED-210-JAF</span>
<p class="itemDesc"></p>
<p class="itemPrice">
$19.99
</p>
<div class="clearfix">
</div>
<div class="clearfix">
<div class="itemButtons clearfix">
<a href="/rathi-dragon-sgl-mtg-9ed-210-jaf/" id="ctl00_BannerBottom2_FeaturedBottom2_lvItems_ctrl0_ctl00_item_lnk3" class="btnWrapper" onclick="return HawkSearch.link(event,&#39;54966a4b-5aca-48a6-bdc6-634cac18c27b&#39;,1,&#39;99998&#39;,0)"><span class="btn">View Details</span></a>
</div>
</div>
</div>
</div>
<div class="grid_3 " >
<div class="itemWrapper hawk-itemWrapper">
<a href="/rathi-dragon-sgl-mtg-9ed-210-zsf/" id="ctl00_BannerBottom2_FeaturedBottom2_lvItems_ctrl0_ctl01_item_lnk1" class="itemLink" onclick="return HawkSearch.link(event,&#39;54966a4b-5aca-48a6-bdc6-634cac18c27b&#39;,2,&#39;99996&#39;,0)">
<img class="itemImage hawk-itemImage" src="" alt="" style="height:190px;" />
</a>
<h3 class="itemTitle">
<em style="display: block;"></em>
<a href="/rathi-dragon-sgl-mtg-9ed-210-zsf/" id="ctl00_BannerBottom2_FeaturedBottom2_lvItems_ctrl0_ctl01_item_lnk2" onclick="return HawkSearch.link(event,&#39;54966a4b-5aca-48a6-bdc6-634cac18c27b&#39;,2,&#39;99996&#39;,0)">Rathi Dragon</a>
</h3>
<span class="itemSku">SGL-MTG-9ED-210-ZSF</span>
<p class="itemDesc"></p>
<p class="itemPrice">
$14.99
</p>
<div class="clearfix">
</div>
<div class="clearfix">
<div class="itemButtons clearfix">
<a href="/rathi-dragon-sgl-mtg-9ed-210-zsf/" id="ctl00_BannerBottom2_FeaturedBottom2_lvItems_ctrl0_ctl01_item_lnk3" class="btnWrapper" onclick="return HawkSearch.link(event,&#39;54966a4b-5aca-48a6-bdc6-634cac18c27b&#39;,2,&#39;99996&#39;,0)"><span class="btn">View Details</span></a>
</div>
</div>
</div>
</div>
<div class="grid_3 " >
<div class="itemWrapper hawk-itemWrapper">
<a href="/rathi-dragon-sgl-mtg-9ed-210-enn/" id="ctl00_BannerBottom2_FeaturedBottom2_lvItems_ctrl0_ctl02_item_lnk1" class="itemLink" onclick="return HawkSearch.link(event,&#39;54966a4b-5aca-48a6-bdc6-634cac18c27b&#39;,3,&#39;99995&#39;,0)">
<img class="itemImage hawk-itemImage" src="https&#58;&#47;&#47;cdn11.bigcommerce.com&#47;s-3b5vpig99v&#47;products&#47;99995&#47;images&#47;402488&#47;RathiDragon__87752.1590611958.386.513.jpg&#63;c&#61;2" alt="" style="height:190px;" />
</a>
<h3 class="itemTitle">
<em style="display: block;"></em>
<a href="/rathi-dragon-sgl-mtg-9ed-210-enn/" id="ctl00_BannerBottom2_FeaturedBottom2_lvItems_ctrl0_ctl02_item_lnk2" onclick="return HawkSearch.link(event,&#39;54966a4b-5aca-48a6-bdc6-634cac18c27b&#39;,3,&#39;99995&#39;,0)">Rathi Dragon</a>
</h3>
<span class="itemSku">SGL-MTG-9ED-210-ENN</span>
<p class="itemDesc"></p>
<p class="itemPrice">
$0.49
</p>
<div class="clearfix">
</div>
<div class="clearfix">
<div class="itemButtons clearfix">
<a href="/rathi-dragon-sgl-mtg-9ed-210-enn/" id="ctl00_BannerBottom2_FeaturedBottom2_lvItems_ctrl0_ctl02_item_lnk3" class="btnWrapper" onclick="return HawkSearch.link(event,&#39;54966a4b-5aca-48a6-bdc6-634cac18c27b&#39;,3,&#39;99995&#39;,0)"><span class="btn">View Details</span></a>
</div>
</div>
</div>
</div>
</div>
<div id="hawkbannerbottom2Mobile" class="bannerBottom2Mobile">
</div>
<div id="hawkbreadcrumb"><div class="breadcrumbs"><div class="brmbwrpr"><span><a href="https&#58;&#47;&#47;starcitygames.com&#47;search">Home</a></span> &gt; <span><a href="https&#58;&#47;&#47;starcitygames.com&#47;search&#63;keyword&#61;nosuchcardever"><b>nosuchcardever</b></a></span></div></div></div>
<div id="hawktitle">
<h1 class="hdng" id="pageHeading" >Search Results for Nosuchcardever</h1>
</div>
</div>

File diff suppressed because it is too large Load diff

View file

@ -1,91 +0,0 @@
package scenario
import (
"context"
"errors"
"fmt"
"log"
"strings"
"gitlab.com/flygrounder/go-mtg-vk/internal/cardsinfo"
)
const (
incorrectMessage = "Некорректная команда"
cardNotFoundMessage = "Карта не найдена"
pricesUnavailableMessage = "Цены временно недоступны, попробуйте позже"
)
type Scenario struct {
Sender Sender
Logger *log.Logger
InfoFetcher CardInfoFetcher
Cache CardCache
}
type UserMessage struct {
Body string
UserId int64
}
type CardCache interface {
Init(ctx context.Context) error
Get(ctx context.Context, cardName string) ([]cardsinfo.ScgCardPrice, error)
Set(ctx context.Context, cardName string, prices []cardsinfo.ScgCardPrice) error
}
type CardInfoFetcher interface {
GetNameByCardId(set string, number string) string
GetOriginalName(name string) string
GetPrices(name string) ([]cardsinfo.ScgCardPrice, error)
}
type Sender interface {
Send(userId int64, message string)
SendPrices(userId int64, cardName string, prices []cardsinfo.ScgCardPrice)
}
func (s *Scenario) HandleSearch(ctx context.Context, msg *UserMessage) {
cardName, err := s.getCardNameByCommand(msg.Body)
if err != nil {
s.Sender.Send(msg.UserId, incorrectMessage)
s.Logger.Printf("[info] Not correct command. Message: %s user input: %s", err.Error(), msg.Body)
} else if cardName == "" {
s.Sender.Send(msg.UserId, cardNotFoundMessage)
s.Logger.Printf("[info] Could not find card. User input: %s", msg.Body)
} else {
prices, err := s.Cache.Get(ctx, cardName)
if err == nil {
s.Sender.SendPrices(msg.UserId, cardName, prices)
return
}
prices, err = s.InfoFetcher.GetPrices(cardName)
if err != nil {
s.Sender.Send(msg.UserId, pricesUnavailableMessage)
s.Logger.Printf("[error] Could not find SCG prices. Message: %s card name: %s", err.Error(), cardName)
return
}
err = s.Cache.Set(ctx, cardName, prices)
if err != nil {
s.Logger.Println(fmt.Errorf("failed add entry in cache: %w", err))
}
s.Sender.SendPrices(msg.UserId, cardName, prices)
}
}
func (s *Scenario) getCardNameByCommand(command string) (string, error) {
var name string
switch {
case strings.HasPrefix(command, "!s"):
split := strings.Split(command, " ")
if len(split) < 3 {
return "", errors.New("wrong command")
}
set := split[1]
number := split[2]
name = s.InfoFetcher.GetNameByCardId(set, number)
default:
name = s.InfoFetcher.GetOriginalName(command)
}
return name, nil
}

View file

@ -1,83 +0,0 @@
package scenario
import (
"context"
"strings"
"testing"
"github.com/stretchr/testify/assert"
)
func TestScenario_HandleSearch_BadCommand(t *testing.T) {
testCtx := GetTestScenarioCtx()
testCtx.Scenario.HandleSearch(context.Background(), &UserMessage{
Body: "!s",
UserId: 1,
})
assert.Equal(t, []testMessage{
{
userId: 1,
message: incorrectMessage,
},
}, testCtx.Sender.sent)
assert.True(t, strings.Contains(testCtx.LogBuf.String(), "[info]"))
}
func TestScenario_HandleSearch_GoodCommand(t *testing.T) {
testCtx := GetTestScenarioCtx()
testCtx.Scenario.HandleSearch(context.Background(), &UserMessage{
Body: "!s grn 228",
UserId: 1,
})
assert.Equal(t, []testMessage{
{
userId: 1,
message: "good",
},
}, testCtx.Sender.sent)
}
func TestScenario_HandleSearch_NotFoundCard(t *testing.T) {
testCtx := GetTestScenarioCtx()
testCtx.Scenario.HandleSearch(context.Background(), &UserMessage{
Body: "absolutely_random_card",
UserId: 1,
})
assert.Equal(t, []testMessage{
{
userId: 1,
message: cardNotFoundMessage,
},
}, testCtx.Sender.sent)
assert.True(t, strings.Contains(testCtx.LogBuf.String(), "[info]"))
}
func TestScenario_HandleSearch_BadCard(t *testing.T) {
testCtx := GetTestScenarioCtx()
testCtx.Scenario.HandleSearch(context.Background(), &UserMessage{
Body: "bad",
UserId: 1,
})
assert.Equal(t, []testMessage{
{
userId: 1,
message: pricesUnavailableMessage,
},
}, testCtx.Sender.sent)
assert.True(t, strings.Contains(testCtx.LogBuf.String(), "[error]"))
}
func TestScenario_HandleSearch_Uncached(t *testing.T) {
testCtx := GetTestScenarioCtx()
testCtx.Scenario.HandleSearch(context.Background(), &UserMessage{
Body: "uncached",
UserId: 1,
})
assert.Equal(t, []testMessage{
{
userId: 1,
message: "uncached",
},
}, testCtx.Sender.sent)
_, err := testCtx.Scenario.Cache.Get(context.Background(), "uncached")
assert.Nil(t, err)
}

View file

@ -1,29 +0,0 @@
package scenario
import (
"context"
"errors"
"gitlab.com/flygrounder/go-mtg-vk/internal/cardsinfo"
)
type testCache struct {
table map[string][]cardsinfo.ScgCardPrice
}
func (t *testCache) Init(ctx context.Context) error {
return nil
}
func (t *testCache) Get(ctx context.Context, cardName string) ([]cardsinfo.ScgCardPrice, error) {
msg, ok := t.table[cardName]
if !ok {
return nil, errors.New("test")
}
return msg, nil
}
func (t *testCache) Set(ctx context.Context, cardName string, prices []cardsinfo.ScgCardPrice) error {
t.table[cardName] = prices
return nil
}

View file

@ -1,39 +0,0 @@
package scenario
import (
"bytes"
"log"
"gitlab.com/flygrounder/go-mtg-vk/internal/cardsinfo"
)
type TestScenarioCtx struct {
Scenario *Scenario
Sender *testSender
LogBuf *bytes.Buffer
}
func GetTestScenarioCtx() TestScenarioCtx {
sender := &testSender{}
buf := &bytes.Buffer{}
return TestScenarioCtx{
LogBuf: buf,
Scenario: &Scenario{
Sender: sender,
Logger: log.New(buf, "", 0),
InfoFetcher: &testInfoFetcher{},
Cache: &testCache{
table: map[string][]cardsinfo.ScgCardPrice{
"good": {
{
Price: "1",
Edition: "alpha",
Link: "scg",
},
},
},
},
},
Sender: sender,
}
}

View file

@ -1,27 +0,0 @@
package scenario
import (
"errors"
"gitlab.com/flygrounder/go-mtg-vk/internal/cardsinfo"
)
type testInfoFetcher struct{}
func (t *testInfoFetcher) GetPrices(name string) ([]cardsinfo.ScgCardPrice, error) {
if name == "good" || name == "uncached" {
return nil, nil
}
return nil, errors.New("test")
}
func (t *testInfoFetcher) GetNameByCardId(_ string, _ string) string {
return "good"
}
func (t *testInfoFetcher) GetOriginalName(name string) string {
if name == "good" || name == "bad" || name == "uncached" {
return name
}
return ""
}

View file

@ -1,26 +0,0 @@
package scenario
import "gitlab.com/flygrounder/go-mtg-vk/internal/cardsinfo"
type testSender struct {
sent []testMessage
}
func (s *testSender) SendPrices(userId int64, cardName string, prices []cardsinfo.ScgCardPrice) {
s.sent = append(s.sent, testMessage{
userId: userId,
message: cardName,
})
}
type testMessage struct {
userId int64
message string
}
func (s *testSender) Send(userId int64, message string) {
s.sent = append(s.sent, testMessage{
userId: userId,
message: message,
})
}

View file

@ -1,33 +0,0 @@
package telegram
import (
"context"
"github.com/gin-gonic/gin"
tgbotapi "github.com/go-telegram-bot-api/telegram-bot-api"
"gitlab.com/flygrounder/go-mtg-vk/internal/scenario"
)
const welcomeMessage = "Здравствуйте, вас приветствует бот для поиска цен на карты MTG, введите название карты, которая вас интересует."
type Handler struct {
Scenario *scenario.Scenario
}
func (h *Handler) HandleMessage(c *gin.Context) {
var upd tgbotapi.Update
err := c.Bind(&upd)
if err != nil || upd.Message == nil {
return
}
if upd.Message.Text == "/start" {
h.Scenario.Sender.Send(upd.Message.Chat.ID, welcomeMessage)
return
}
h.Scenario.HandleSearch(context.Background(), &scenario.UserMessage{
Body: upd.Message.Text,
UserId: upd.Message.Chat.ID,
})
}

View file

@ -1,41 +0,0 @@
package telegram
import (
"fmt"
"strings"
tgbotapi "github.com/go-telegram-bot-api/telegram-bot-api"
"gitlab.com/flygrounder/go-mtg-vk/internal/cardsinfo"
)
type Sender struct {
API *tgbotapi.BotAPI
}
func (s *Sender) SendPrices(userId int64, cardName string, prices []cardsinfo.ScgCardPrice) {
msg := formatCardPrices(cardName, prices)
s.Send(userId, msg)
}
func (h *Sender) Send(userId int64, message string) {
msg := tgbotapi.NewMessage(userId, message)
msg.DisableWebPagePreview = true
msg.ParseMode = tgbotapi.ModeMarkdown
h.API.Send(msg)
}
func formatCardPrices(name string, prices []cardsinfo.ScgCardPrice) string {
escapedName := strings.ReplaceAll(name, "_", "\\_")
message := fmt.Sprintf("Оригинальное название: %v\n\n", escapedName)
for i, v := range prices {
message += fmt.Sprintf("%v. %v", i+1, formatPrice(v))
}
if len(prices) == 0 {
message += "Цен не найдено\n"
}
return message
}
func formatPrice(s cardsinfo.ScgCardPrice) string {
return fmt.Sprintf("[%v](%v): %v\n", s.Edition, s.Link, s.Price)
}

View file

@ -1,31 +0,0 @@
package telegram
import (
"testing"
"github.com/stretchr/testify/assert"
"gitlab.com/flygrounder/go-mtg-vk/internal/cardsinfo"
)
func Test_formatCardPrices(t *testing.T) {
prices := []cardsinfo.ScgCardPrice{
{
Price: "1",
Edition: "Alpha",
Link: "scg1",
},
{
Price: "2",
Edition: "Beta",
Link: "scg2",
},
}
result := formatCardPrices("card", prices)
assert.Equal(t, "Оригинальное название: card\n\n1. [Alpha](scg1): 1\n2. [Beta](scg2): 2\n", result)
}
func Test_formatCardPricesEscapeUnderscore(t *testing.T) {
prices := []cardsinfo.ScgCardPrice{}
result := formatCardPrices("_____", prices)
assert.Equal(t, "Оригинальное название: \\_\\_\\_\\_\\_\n\nЦен не найдено\n", result)
}

View file

@ -1,22 +0,0 @@
package vk
import (
"fmt"
"gitlab.com/flygrounder/go-mtg-vk/internal/cardsinfo"
)
func formatCardPrices(name string, prices []cardsinfo.ScgCardPrice) string {
message := fmt.Sprintf("Оригинальное название: %v\n\n", name)
for i, v := range prices {
message += fmt.Sprintf("%v. %v", i+1, formatPrice(v))
}
if len(prices) == 0 {
message += "Цен не найдено\n"
}
return message
}
func formatPrice(s cardsinfo.ScgCardPrice) string {
return fmt.Sprintf("%v: %v\n%v\n", s.Edition, s.Price, s.Link)
}

View file

@ -1,19 +0,0 @@
package vk
import (
"testing"
"github.com/stretchr/testify/assert"
"gitlab.com/flygrounder/go-mtg-vk/internal/cardsinfo"
)
func TestFormatCardPrices(t *testing.T) {
formatted := formatCardPrices("card", []cardsinfo.ScgCardPrice{
{
Price: "1.5$",
Edition: "ED",
Link: "scg.com",
},
})
assert.Equal(t, "Оригинальное название: card\n\n1. ED: 1.5$\nscg.com\n", formatted)
}

View file

@ -1,70 +0,0 @@
package vk
import (
"context"
"net/http"
"github.com/gin-gonic/gin"
"gitlab.com/flygrounder/go-mtg-vk/internal/scenario"
)
type Handler struct {
Scenario *scenario.Scenario
SecretKey string
GroupId int64
ConfirmationString string
DictPath string
}
type cardInfoFetcher interface {
GetFormattedCardPrices(name string) (string, error)
GetNameByCardId(set string, number string) string
GetOriginalName(name string) string
}
type cardCache interface {
Get(cardName string) (string, error)
Set(cardName string, message string)
}
type messageRequest struct {
Type string `json:"type"`
GroupId int64 `json:"group_id"`
Object userMessage `json:"object"`
Secret string `json:"secret"`
}
type userMessage struct {
Body string `json:"text"`
UserId int64 `json:"peer_id"`
}
const (
incorrectMessage = "Некорректная команда"
cardNotFoundMessage = "Карта не найдена"
pricesUnavailableMessage = "Цены временно недоступны, попробуйте позже"
)
func (h *Handler) HandleMessage(c *gin.Context) {
var req messageRequest
_ = c.BindJSON(&req)
if req.Secret != h.SecretKey {
return
}
switch req.Type {
case "confirmation":
h.handleConfirmation(c, &req)
case "message_new":
h.Scenario.HandleSearch(context.Background(), &scenario.UserMessage{
Body: req.Object.Body,
UserId: req.Object.UserId,
})
c.String(http.StatusOK, "ok")
}
}
func (h *Handler) handleConfirmation(c *gin.Context, req *messageRequest) {
if (req.Type == "confirmation") && (req.GroupId == h.GroupId) {
c.String(http.StatusOK, h.ConfirmationString)
}
}

View file

@ -1,38 +0,0 @@
package vk
import (
"testing"
"github.com/stretchr/testify/assert"
)
func TestHandler_HandleMessage_Confirm(t *testing.T) {
testCtx := getTestHandlerCtx()
ctx := getTestRequestCtx(&messageRequest{
Type: "confirmation",
GroupId: testCtx.handler.GroupId,
Secret: testCtx.handler.SecretKey,
}, testCtx.recorder)
testCtx.handler.HandleMessage(ctx)
assert.Equal(t, testCtx.handler.ConfirmationString, testCtx.recorder.Body.String())
}
func TestHandler_HandleMessage_Message(t *testing.T) {
testCtx := getTestHandlerCtx()
ctx := getTestRequestCtx(&messageRequest{
Type: "message_new",
Secret: testCtx.handler.SecretKey,
}, testCtx.recorder)
testCtx.handler.HandleMessage(ctx)
assert.Equal(t, "ok", testCtx.recorder.Body.String())
}
func TestHandler_HandleMessage_NoSecretKey(t *testing.T) {
testCtx := getTestHandlerCtx()
ctx := getTestRequestCtx(&messageRequest{
Type: "message_new",
}, testCtx.recorder)
testCtx.handler.HandleMessage(ctx)
assert.Equal(t, "", testCtx.recorder.Body.String())
}

View file

@ -1,64 +0,0 @@
package vk
import (
"encoding/json"
"io/ioutil"
"log"
"math/rand"
"net/http"
"net/url"
"strconv"
"strings"
"gitlab.com/flygrounder/go-mtg-vk/internal/cardsinfo"
)
const sendMessageUrl = "https://api.vk.com/method/messages.send"
type sender interface {
send(userId int64, message string)
}
type ApiSender struct {
Token string
Logger *log.Logger
}
func (s *ApiSender) SendPrices(userId int64, cardName string, prices []cardsinfo.ScgCardPrice) {
msg := formatCardPrices(cardName, prices)
s.Send(userId, msg)
}
type sendMessageResponse struct {
Error errorResponse `json:"error"`
}
type errorResponse struct {
ErrorCode int `json:"error_code"`
ErrorMsg string `json:"error_msg"`
}
func (s *ApiSender) Send(userId int64, message string) {
randomId := rand.Int63()
params := []string{
"access_token=" + s.Token,
"peer_id=" + strconv.FormatInt(userId, 10),
"message=" + url.QueryEscape(message),
"v=5.95",
"random_id=" + strconv.FormatInt(randomId, 10),
"dont_parse_links=1",
}
joined := strings.Join(params, "&")
reqUrl := sendMessageUrl + "?" + joined
resp, err := http.Get(reqUrl)
if err != nil || resp.StatusCode != http.StatusOK {
s.Logger.Printf("[error] Could not Send message. User: %d", userId)
return
}
respContent, _ := ioutil.ReadAll(resp.Body)
var unmarshalled sendMessageResponse
_ = json.Unmarshal(respContent, &unmarshalled)
if unmarshalled.Error.ErrorCode != 0 {
s.Logger.Printf("[error] Message was not sent. User: %d error message: %s", userId, unmarshalled.Error.ErrorMsg)
}
}

View file

@ -1,65 +0,0 @@
package vk
import (
"bytes"
"log"
"net/http"
"strings"
"testing"
"github.com/stretchr/testify/assert"
"gopkg.in/h2non/gock.v1"
)
func TestApiSender_Send_OK(t *testing.T) {
defer gock.Off()
gock.New(sendMessageUrl).MatchParams(
map[string]string{
"access_token": "token",
"peer_id": "1",
"message": "msg",
"v": "5.95",
"dont_parse_links": "1",
},
).ParamPresent("random_id").Reply(http.StatusOK)
sender := ApiSender{Token: "token"}
sender.Send(1, "msg")
assert.False(t, gock.HasUnmatchedRequest())
}
func TestApiSender_Send_NotOK(t *testing.T) {
defer gock.Off()
gock.New(sendMessageUrl).Reply(http.StatusInternalServerError)
b := &bytes.Buffer{}
sender := ApiSender{
Token: "token",
Logger: log.New(b, "", 0),
}
sender.Send(1, "msg")
assert.True(t, strings.Contains(b.String(), "[error]"))
}
func TestApiSender_Send_ErrorCode(t *testing.T) {
defer gock.Off()
gock.New(sendMessageUrl).Reply(http.StatusOK).JSON(
map[string]interface{}{
"error": map[string]interface{}{
"error_code": 100,
"error_msg": "bad user",
},
},
)
b := &bytes.Buffer{}
sender := ApiSender{
Token: "token",
Logger: log.New(b, "", 0),
}
sender.Send(1, "msg")
assert.True(t, strings.Contains(b.String(), "[error]"))
}

View file

@ -1,35 +0,0 @@
package vk
import (
"bytes"
"encoding/json"
"net/http/httptest"
"github.com/gin-gonic/gin"
"gitlab.com/flygrounder/go-mtg-vk/internal/scenario"
)
type testCtx struct {
handler *Handler
recorder *httptest.ResponseRecorder
}
func getTestHandlerCtx() testCtx {
s := scenario.GetTestScenarioCtx()
return testCtx{
handler: &Handler{
SecretKey: "sec",
GroupId: 10,
ConfirmationString: "con",
Scenario: s.Scenario,
},
recorder: httptest.NewRecorder(),
}
}
func getTestRequestCtx(msgReq *messageRequest, recorder *httptest.ResponseRecorder) *gin.Context {
ctx, _ := gin.CreateTestContext(recorder)
body, _ := json.Marshal(msgReq)
ctx.Request = httptest.NewRequest("POST", "/", bytes.NewReader(body))
return ctx
}

4
justfile Normal file
View file

@ -0,0 +1,4 @@
set dotenv-load
run:
cargo watch -x run

467
src/main.rs Normal file
View file

@ -0,0 +1,467 @@
use anyhow::{Context, Result};
use axum::extract::State;
use axum::http::{HeaderMap, StatusCode};
use axum::routing::post;
use axum::{Json, Router};
use regex::Regex;
use scryfall::card::Card;
use serde::{Deserialize, Serialize};
use std::env;
use std::fmt::Display;
use std::sync::Arc;
use teloxide::payloads::SendMessage;
use teloxide::prelude::*;
use teloxide::requests::JsonRequest;
use teloxide::types::ParseMode;
use teloxide::utils::markdown::{escape, escape_link_url};
use teloxide::Bot;
use tokio::net::TcpListener;
#[derive(Clone)]
struct AppState {
vk_client: Arc<VkClient>,
telegram_client: Arc<TelegramClient>,
price_fetcher: Arc<PriceFetcher>,
}
async fn get_card_name(query: &str) -> Result<String> {
let number_search_regex = Regex::new(r#"^!s (?<set>\w{3}) (?<number>\d+)$"#)
.context("failed to compile number search regex")?;
let name = if let Some(captures) = number_search_regex.captures(query) {
let set = captures
.name("set")
.context("failed to get 'set' value from capture")?
.as_str()
.to_string();
let number: usize = captures
.name("number")
.context("failed to get 'number' from capture")?
.as_str()
.parse()
.context("failed to parse collector number")?;
Card::set_and_number(&set, number)
.await
.map(|card| card.name)
.context("failed to get card by set and number")?
} else {
Card::named_fuzzy(query)
.await
.map(|card| card.name)
.context("failed to find card by it's fuzzy name")?
};
Ok(name)
}
#[derive(Serialize)]
struct StarCityRequestPayload {
keyword: String,
#[serde(rename = "ClientGuid")]
client_guid: String,
#[serde(rename = "SortBy")]
sort_by: String,
#[serde(rename = "FacetSelections")]
facet_selections: StarCityFacetSelection,
}
#[derive(Serialize)]
struct StarCityFacetSelection {
product_type: Vec<String>,
}
#[derive(Deserialize)]
struct StarCityResponse {
#[serde(rename = "Results")]
results: Vec<StarCityResponseResult>,
}
#[derive(Deserialize)]
struct StarCityResponseResult {
#[serde(rename = "Document")]
document: StarCityResponseDocument,
}
#[derive(Deserialize)]
struct StarCityResponseDocument {
set: Vec<String>,
hawk_child_attributes: Vec<StarCityResponseAttributesVariants>,
}
#[derive(Deserialize)]
#[serde(untagged)]
enum StarCityResponseAttributesVariants {
Available(StarCityResponseAttributes),
Unavailable {},
}
impl StarCityResponseAttributesVariants {
fn get_card_info(&self, set: &str) -> Option<CardInfo> {
if let StarCityResponseAttributesVariants::Available(res) = self {
let condition = res.condition.first().cloned()?;
let price = res.price.first().cloned()?.parse().ok()?;
let url = res.url.first().cloned()?;
if condition == "Near Mint" {
Some(CardInfo {
set: set.to_string(),
price,
url: format!("https://starcitygames.com{url}"),
})
} else {
None
}
} else {
None
}
}
}
#[derive(Deserialize)]
struct StarCityResponseAttributes {
price: Vec<String>,
condition: Vec<String>,
url: Vec<String>,
}
struct CardInfo {
set: String,
price: f32,
url: String,
}
struct PriceFetcher {
client_guid: String,
}
impl PriceFetcher {
fn from_env() -> Result<Self> {
let client_guid =
env::var("SCG_CLIENT_GUID").context("SCG_CLIENT_GUID env variable is not set")?;
Ok(Self { client_guid })
}
async fn get_card_prices(&self, name: &str) -> Result<Vec<CardInfo>> {
let client = reqwest::ClientBuilder::new().build()?;
let resp = client
.post("https://essearchapi-na.hawksearch.com/api/v2/search")
.json(&StarCityRequestPayload {
keyword: name.to_string(),
client_guid: self.client_guid.clone(),
sort_by: "score".into(),
facet_selections: StarCityFacetSelection {
product_type: vec!["Singles".into()],
},
})
.send()
.await
.context("request to SCG failed")?;
let response: StarCityResponse = resp.json().await.context("SCG returned invalid json")?;
let res = response
.results
.iter()
.flat_map(|result| {
let set = result.document.set.first().cloned()?;
let info = result
.document
.hawk_child_attributes
.iter()
.flat_map(|res| res.get_card_info(&set))
.collect::<Vec<_>>();
Some(info)
})
.flatten()
.collect::<Vec<_>>();
Ok(res)
}
}
struct TelegramClient {
bot: Bot,
secret: String,
}
impl TelegramClient {
fn from_env() -> Result<Self> {
let secret = env::var("TG_SECRET").context("failed to get TG_SECRET env var")?;
Ok(Self {
bot: Bot::from_env(),
secret,
})
}
}
#[derive(Deserialize)]
struct TelegramUpdate {
message: TelegramMessage,
}
#[derive(Deserialize)]
struct TelegramMessage {
text: String,
chat: TelegramChat,
}
#[derive(Deserialize)]
struct TelegramChat {
id: i64,
}
async fn telegram(
headers: HeaderMap,
State(state): State<AppState>,
Json(payload): Json<TelegramUpdate>,
) -> Result<&'static str, StatusCode> {
let secret = headers
.get("X-Telegram-Bot-Api-Secret-Token")
.ok_or(StatusCode::FORBIDDEN)?
.to_str()
.map_err(|_| StatusCode::FORBIDDEN)?;
if secret != state.telegram_client.secret {
Err(StatusCode::FORBIDDEN)?;
}
let chat_id = payload.message.chat.id;
let text = &payload.message.text;
let res = handle_telegram_message(&state, chat_id, text).await;
if let Err(err) = res {
report_error(state.telegram_client.as_ref(), chat_id, err).await;
}
Ok("OK")
}
#[derive(Debug)]
enum ErrorContext {
Scryfall,
Scg,
Telegram,
Vk,
}
impl Display for ErrorContext {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
let text = match self {
Self::Scryfall => "failed to get card from scryfall",
Self::Scg => "failed to get card from SCG",
Self::Telegram => "failed to send message through Telergam",
Self::Vk => "failed to send message through VK",
};
f.write_str(text)
}
}
async fn handle_telegram_message(state: &AppState, chat_id: i64, message: &str) -> Result<()> {
let name = get_card_name(message)
.await
.context(ErrorContext::Scryfall)?;
let prices = state
.price_fetcher
.get_card_prices(&name)
.await
.context(ErrorContext::Scg)?;
let content = prices
.iter()
.take(5)
.enumerate()
.map(|(i, info)| {
format!(
"{}\\. [{}]({}): {}",
i + 1,
escape(&info.set),
escape_link_url(&info.url),
escape(&format!("${}", info.price))
)
})
.collect::<Vec<_>>()
.join("\n");
let header = escape(&format!("Оригинальное название: {}", name));
let response = format!("{header}\n\n{content}");
let request = SendMessage::new(ChatId(chat_id), response)
.parse_mode(ParseMode::MarkdownV2)
.disable_web_page_preview(true);
JsonRequest::new(state.telegram_client.bot.clone(), request)
.send()
.await
.context(ErrorContext::Telegram)?;
Ok(())
}
struct VkClient {
client: reqwest::Client,
token: String,
group_id: i64,
confirmation: String,
secret: String,
}
impl VkClient {
fn from_env() -> Result<Self> {
let client = reqwest::Client::new();
let token = env::var("VK_TOKEN").context("failed to get VK_TOKEN")?;
let group_id = env::var("VK_GROUP_ID")
.context("failed to get VK_GROUP_ID")
.and_then(|x| x.parse().context("failed to parse VK_GROUP_ID as a number"))?;
let confirmation =
env::var("VK_CONFIRMATION_STRING").context("failed to get VK_CONFIRMATION_STRING")?;
let secret = env::var("VK_SECRET").context("failed to get VK_SECRET")?;
Ok(Self {
client,
token,
group_id,
confirmation,
secret,
})
}
async fn send(&self, user_id: i64, message: &str) -> Result<()> {
self.client
.get("https://api.vk.com/method/messages.send")
.query(&[
("user_id", user_id.to_string().as_str()),
("v", "5.131"),
("access_token", &self.token),
("random_id", "0"),
("message", message),
])
.send()
.await
.context(ErrorContext::Vk)?;
Ok(())
}
}
#[derive(Deserialize)]
#[serde(tag = "type")]
enum VkRequest {
#[serde(rename = "message_new")]
Message(VkMessageRequest),
#[serde(rename = "confirmation")]
Confirmation(VkConfirmationRequest),
}
#[derive(Deserialize)]
struct VkMessageRequest {
object: VkMessageObject,
secret: String,
}
#[derive(Deserialize)]
struct VkMessageObject {
from_id: i64,
text: String,
}
#[derive(Deserialize)]
struct VkConfirmationRequest {
group_id: i64,
}
async fn vk(State(state): State<AppState>, Json(request): Json<VkRequest>) -> (StatusCode, String) {
match request {
VkRequest::Message(payload) => {
if payload.secret != state.vk_client.secret {
return (StatusCode::FORBIDDEN, "Access denied".into());
}
let user_id = payload.object.from_id;
let message = &payload.object.text;
let res = handle_vk_message(&state, user_id, message).await;
if let Err(err) = res {
report_error(state.vk_client.as_ref(), user_id, err).await;
}
}
VkRequest::Confirmation(confirmation) => {
if confirmation.group_id != state.vk_client.group_id {
return (StatusCode::FORBIDDEN, "Access denied".into());
}
return (StatusCode::OK, state.vk_client.confirmation.clone());
}
}
(StatusCode::OK, "OK".into())
}
async fn handle_vk_message(state: &AppState, user_id: i64, message: &str) -> Result<()> {
let name = get_card_name(message)
.await
.context(ErrorContext::Scryfall)?;
let prices = state
.price_fetcher
.get_card_prices(&name)
.await
.context(ErrorContext::Scg)?;
let header = escape(&format!("Оригинальное название: {}", name));
let content = prices
.iter()
.take(5)
.enumerate()
.map(|(i, info)| format!("{}. {}: ${}\n{}", i + 1, info.set, info.price, info.url))
.collect::<Vec<_>>()
.join("\n");
let response = format!("{header}\n\n{content}");
state.vk_client.send(user_id, &response).await
}
trait MessageSender {
async fn send(&self, user_id: i64, message: &str) -> Result<()>;
}
impl MessageSender for VkClient {
async fn send(&self, user_id: i64, message: &str) -> Result<()> {
self.send(user_id, message).await?;
Ok(())
}
}
impl MessageSender for TelegramClient {
async fn send(&self, user_id: i64, message: &str) -> Result<()> {
self.bot.send_message(ChatId(user_id), message).await?;
Ok(())
}
}
async fn report_error<T: MessageSender>(sender: &T, chat_id: i64, err: anyhow::Error) {
if !matches!(
err.downcast_ref::<scryfall::Error>(),
Some(scryfall::Error::ScryfallError(_))
) {
println!("error: {:#}", err);
}
let sent = match err.downcast_ref::<ErrorContext>() {
Some(ErrorContext::Scryfall) => sender
.send(chat_id, "Карта не найдена")
.await
.map(|_| ())
.context("failed to send error message"),
Some(ErrorContext::Scg) => sender
.send(chat_id, "Цены не найдены")
.await
.map(|_| ())
.context("failed to send error message"),
_ => Ok(()),
};
if let Err(err) = sent {
println!("error: {:#}", err);
}
}
#[tokio::main]
async fn main() -> Result<()> {
let port = env::var("PORT")
.ok()
.and_then(|x| x.parse().ok())
.unwrap_or(3000);
let addr: (&str, u16) = ("0.0.0.0", port);
let listener = TcpListener::bind(addr)
.await
.context("failed to create tcp listener")?;
let vk_client = VkClient::from_env().context("failed to init vk client")?;
let telegram_client = TelegramClient::from_env().context("failed to init telegram client")?;
let price_fetcher = PriceFetcher::from_env().context("failed to init price fetcher")?;
let state = AppState {
vk_client: Arc::new(vk_client),
telegram_client: Arc::new(telegram_client),
price_fetcher: Arc::new(price_fetcher),
};
let app = Router::new()
.route("/tg", post(telegram))
.route("/vk", post(vk))
.with_state(state);
axum::serve(listener, app).await.unwrap();
Ok(())
}