├── .gitignore ├── .gitlab-ci.yml ├── Makefile ├── alerts.yml ├── cmd ├── bot │ └── main.go └── reportservice │ └── main.go ├── config.example.yaml ├── docker-compose.yml ├── go.mod ├── go.sum ├── img ├── screen-bot-category-choice.png ├── screen-bot-currency.png ├── screen-bot-enter-sum.png ├── screen-bot-load-data.png ├── screen-bot-menu.png ├── screen-bot-report.png └── screen-bot-set-limit.png ├── internal ├── api │ ├── gen.sh │ ├── report.pb.go │ ├── report.proto │ └── report_grpc.pb.go ├── cache │ ├── lru.go │ └── lru_test.go ├── clients │ ├── cbr │ │ └── cbrclient.go │ └── tg │ │ └── tgclient.go ├── config │ └── config.go ├── helpers │ ├── dbutils │ │ ├── pgxwrapper.go │ │ └── sqlxwrapper.go │ ├── kafka │ │ ├── kafkaconsumer.go │ │ └── kafkaproducer.go │ ├── net_http │ │ └── net_http.go │ └── timeutils │ │ └── timeutils.go ├── logger │ └── logger.go ├── metrics │ └── metrics.go ├── mocks │ ├── cbr │ │ └── cbr_mocks.go │ ├── exchangerates │ │ └── exchangerates_mocks.go │ └── messages │ │ └── messages_mocks.go ├── model │ ├── bottypes │ │ └── bottypes.go │ ├── db │ │ ├── ratesstorage.go │ │ ├── usersstorage.go │ │ └── usersstorage_test.go │ ├── exchangerates │ │ ├── exchangerates.go │ │ └── exchangerates_test.go │ └── messages │ │ ├── incoming_msg.go │ │ └── incoming_msg_test.go ├── tasks │ ├── exchangeuploader │ │ └── exchangeuploader.go │ └── reportserver │ │ └── reportserver.go └── tracing │ └── tracing.go ├── migrations ├── 20221011174232_init_user_table.sql ├── 20221012151204_init_usercategories_table.sql ├── 20221012213211_init_usermoneytransactions_table.sql ├── 20221013201644_init_exchangerates_table.sql ├── 20221013232044_insert_test_userdata.sql └── 20221014153602_insert_test_ratedata.sql ├── prometheus.yml └── readme.md /.gitignore: -------------------------------------------------------------------------------- 1 | /bin 2 | /.idea 3 | /data 4 | -------------------------------------------------------------------------------- /.gitlab-ci.yml: -------------------------------------------------------------------------------- 1 | default: 2 | image: golang:latest 3 | 4 | stages: 5 | - build 6 | - test 7 | 8 | build: 9 | stage: build 10 | script: 11 | - make build 12 | 13 | test: 14 | stage: test 15 | script: 16 | - make test 17 | 18 | lint: 19 | stage: test 20 | script: 21 | - make lint 22 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | CURDIR=$(shell pwd) 2 | BINDIR=${CURDIR}/bin 3 | GOVER=$(shell go version | perl -nle '/(go\d\S+)/; print $$1;') 4 | MOCKGEN=${BINDIR}/mockgen_${GOVER} 5 | SMARTIMPORTS=${BINDIR}/smartimports_${GOVER} 6 | LINTVER=v1.49.0 7 | LINTBIN=${BINDIR}/lint_${GOVER}_${LINTVER} 8 | PACKAGE=github.com/ellavs/tg-bot-golang/cmd/bot 9 | 10 | all: format build test lint 11 | 12 | build: bindir 13 | go build -o ${BINDIR}/bot ${PACKAGE} 14 | 15 | test: 16 | go test ./... 17 | 18 | run: 19 | go run ${PACKAGE} 20 | 21 | generate: install-mockgen 22 | ${MOCKGEN} -source=internal/model/messages/incoming_msg.go -destination=internal/mocks/messages/messages_mocks.go 23 | ${MOCKGEN} -source=internal/model/exchangerates/exchangerates.go -destination=internal/mocks/exchangerates/exchangerates_mocks.go 24 | ${MOCKGEN} -source=internal/clients/cbr/cbrclient.go -destination=internal/mocks/cbr/cbr_mocks.go 25 | 26 | lint: install-lint 27 | ${LINTBIN} run 28 | 29 | precommit: format build test lint 30 | echo "OK" 31 | 32 | bindir: 33 | mkdir -p ${BINDIR} 34 | 35 | format: install-smartimports 36 | ${SMARTIMPORTS} -exclude internal/mocks 37 | 38 | install-mockgen: bindir 39 | test -f ${MOCKGEN} || \ 40 | (GOBIN=${BINDIR} go install github.com/golang/mock/mockgen@v1.6.0 && \ 41 | mv ${BINDIR}/mockgen ${MOCKGEN}) 42 | 43 | install-lint: bindir 44 | test -f ${LINTBIN} || \ 45 | (GOBIN=${BINDIR} go install github.com/golangci/golangci-lint/cmd/golangci-lint@${LINTVER} && \ 46 | mv ${BINDIR}/golangci-lint ${LINTBIN}) 47 | 48 | install-smartimports: bindir 49 | test -f ${SMARTIMPORTS} || \ 50 | (GOBIN=${BINDIR} go install github.com/pav5000/smartimports/cmd/smartimports@latest && \ 51 | mv ${BINDIR}/smartimports ${SMARTIMPORTS}) 52 | 53 | docker-run: 54 | sudo docker compose up 55 | -------------------------------------------------------------------------------- /alerts.yml: -------------------------------------------------------------------------------- 1 | groups: 2 | - name: alerts 3 | rules: 4 | - alert: TargetIsDown 5 | expr: up == 0 6 | for: 30s 7 | labels: 8 | severity: medium 9 | annotations: 10 | summary: "The target {{ $labels.job }} is down" 11 | description: "Instance {{ $labels.instance }} of job {{ $labels.job }} has been down for more than 30 seconds." 12 | -------------------------------------------------------------------------------- /cmd/bot/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "github.com/ellavs/tg-bot-golang/internal/cache" 6 | "github.com/ellavs/tg-bot-golang/internal/helpers/kafka" 7 | "github.com/ellavs/tg-bot-golang/internal/metrics" 8 | "github.com/ellavs/tg-bot-golang/internal/tasks/reportserver" 9 | "github.com/ellavs/tg-bot-golang/internal/tracing" 10 | "os/signal" 11 | "syscall" 12 | "time" 13 | 14 | "github.com/ellavs/tg-bot-golang/internal/clients/cbr" 15 | "github.com/ellavs/tg-bot-golang/internal/clients/tg" 16 | "github.com/ellavs/tg-bot-golang/internal/config" 17 | "github.com/ellavs/tg-bot-golang/internal/helpers/dbutils" 18 | "github.com/ellavs/tg-bot-golang/internal/helpers/net_http" 19 | "github.com/ellavs/tg-bot-golang/internal/logger" 20 | "github.com/ellavs/tg-bot-golang/internal/model/db" 21 | rates "github.com/ellavs/tg-bot-golang/internal/model/exchangerates" 22 | "github.com/ellavs/tg-bot-golang/internal/model/messages" 23 | uploader "github.com/ellavs/tg-bot-golang/internal/tasks/exchangeuploader" 24 | ) 25 | 26 | // Параметры по умолчанию (могут быть изменены через config) 27 | var ( 28 | mainCurrency = "RUB" // Основная валюта для хранения данных. 29 | currenciesName = []string{"USD", "CNY", "EUR", "RUB"} // Массив используемых валют. 30 | currenciesUpdatePeriod = 30 * time.Minute // Периодичность обновления курсов валют (раз в 30 минут). 31 | currenciesUpdateCachePeriod = 30 * time.Minute // Периодичность кэширования курсов валют из базы данных (раз в 30 минут). 32 | connectionStringDB = "" // Строка подключения к базе данных. 33 | kafkaTopic = "tgbot" // Наименование топика Kafka. 34 | brokersList = []string{"localhost:9092"} // Список адресов брокеров сообщений (адрес Kafka). 35 | ) 36 | 37 | func main() { 38 | 39 | logger.Info("Старт приложения") 40 | 41 | ctx := context.Background() 42 | 43 | config, err := config.New() 44 | if err != nil { 45 | logger.Fatal("Ошибка получения файла конфигурации:", "err", err) 46 | } 47 | 48 | // Изменение параметров по умолчанию из заданной конфигурации. 49 | setConfigSettings(config.GetConfig()) 50 | 51 | // Оборачивание в Middleware функции обработки сообщения для метрик и трейсинга. 52 | tgProcessingFuncHandler := tg.HandlerFunc(tg.ProcessingMessages) 53 | tgProcessingFuncHandler = metrics.MetricsMiddleware(tgProcessingFuncHandler) 54 | tgProcessingFuncHandler = tracing.TracingMiddleware(tgProcessingFuncHandler) 55 | 56 | // Инициализация телеграм клиента. 57 | tgClient, err := tg.New(config, tgProcessingFuncHandler) 58 | if err != nil { 59 | logger.Fatal("Ошибка инициализации ТГ-клиента:", "err", err) 60 | } 61 | 62 | // Инициализация хранилищ (подключение к базе данных). 63 | dbconn, err := dbutils.NewDBConnect(connectionStringDB) 64 | if err != nil { 65 | logger.Fatal("Ошибка подключения к базе данных:", "err", err) 66 | } 67 | // БД информации пользователей. 68 | userStorage := db.NewUserStorage(dbconn, mainCurrency, 0) 69 | // БД курсов валют. 70 | exchangeRatesStorage := db.NewExchangeRatesStorage(dbconn, currenciesName) 71 | 72 | // Инициализация клиента загрузки курсов валют из внешнего источника. 73 | ctx, cancel := signal.NotifyContext(ctx, 74 | syscall.SIGHUP, 75 | syscall.SIGINT, 76 | syscall.SIGTERM, 77 | syscall.SIGQUIT) 78 | defer cancel() 79 | httpClient := net_http.New[cbr.ExchangeRatesJson]() 80 | cbrClient := cbr.New(ctx, httpClient) 81 | 82 | // Инициализация локального экземпляра класса для работы с курсами валют. 83 | exchangeRates := rates.New(ctx, cbrClient, currenciesName, mainCurrency, exchangeRatesStorage) 84 | 85 | // Инициализация кэша для кэширования отчетов пользователей. 86 | cacheLRU := cache.NewLRU(5) 87 | 88 | // Инициализация кафки для отправки сообщений в очередь. 89 | kafkaProducer, err := kafka.NewSyncProducer(brokersList, kafkaTopic) 90 | if err != nil { 91 | logger.Fatal("Ошибка инициализации кафки для отправки сообщений:", "err", err) 92 | } 93 | 94 | // Инициализация основной модели. 95 | msgModel := messages.New(ctx, tgClient, userStorage, exchangeRates, cacheLRU, kafkaProducer) 96 | 97 | // Запуск периодическое обновление курсов валют. 98 | uploader.ExchangeRatesUpdater(ctx, exchangeRates, currenciesUpdatePeriod) 99 | 100 | // Запуск периодическое обновление локального кэша курсов валют из БД. 101 | uploader.ExchangeRatesFromStorageLoader(ctx, exchangeRates, currenciesUpdateCachePeriod) 102 | 103 | // Запуск сервера для получения отчетов пользователя. 104 | reportserver.StartReportServer(msgModel) 105 | 106 | // Запуск ТГ-клиента. 107 | tgClient.ListenUpdates(msgModel) 108 | 109 | logger.Info("Завершение приложения") 110 | } 111 | 112 | // Замена параметров по умолчанию параметрами из конфиг.файла. 113 | func setConfigSettings(config config.Config) { 114 | if config.MainCurrency != "" { 115 | mainCurrency = config.MainCurrency 116 | } 117 | if len(config.CurrenciesName) > 0 { 118 | currenciesName = config.CurrenciesName 119 | } 120 | if config.CurrenciesUpdatePeriod > 0 { 121 | currenciesUpdatePeriod = time.Duration(config.CurrenciesUpdatePeriod) * time.Minute 122 | } 123 | if config.CurrenciesUpdateCachePeriod > 0 { 124 | currenciesUpdateCachePeriod = time.Duration(config.CurrenciesUpdateCachePeriod) * time.Minute 125 | } 126 | if config.ConnectionStringDB != "" { 127 | connectionStringDB = config.ConnectionStringDB 128 | } 129 | if config.KafkaTopic != "" { 130 | kafkaTopic = config.KafkaTopic 131 | } 132 | if len(config.BrokersList) > 0 { 133 | brokersList = config.BrokersList 134 | } 135 | } 136 | -------------------------------------------------------------------------------- /cmd/reportservice/main.go: -------------------------------------------------------------------------------- 1 | // Сервис генерации отчетов. 2 | package main 3 | 4 | import ( 5 | "context" 6 | "github.com/pkg/errors" 7 | "github.com/ellavs/tg-bot-golang/internal/api" 8 | "github.com/ellavs/tg-bot-golang/internal/config" 9 | "github.com/ellavs/tg-bot-golang/internal/helpers/dbutils" 10 | "github.com/ellavs/tg-bot-golang/internal/helpers/kafka" 11 | "github.com/ellavs/tg-bot-golang/internal/logger" 12 | "github.com/ellavs/tg-bot-golang/internal/model/bottypes" 13 | "github.com/ellavs/tg-bot-golang/internal/model/db" 14 | "google.golang.org/grpc" 15 | "google.golang.org/grpc/credentials/insecure" 16 | "log" 17 | "os/signal" 18 | "strconv" 19 | "syscall" 20 | "time" 21 | ) 22 | 23 | // Параметры по умолчанию (могут быть изменены через config) 24 | var ( 25 | mainCurrency = "RUB" // Основная валюта для хранения данных. 26 | connectionStringDB = "" // Строка подключения к базе данных. 27 | kafkaTopic = "tgbot" // Наименование топика Kafka. 28 | brokersList = []string{"localhost:9092"} // Список адресов брокеров сообщений (адрес Kafka). 29 | ) 30 | 31 | func main() { 32 | 33 | logger.Info("[Report service] Старт приложения") 34 | 35 | ctx, cancel := signal.NotifyContext(context.Background(), 36 | syscall.SIGHUP, 37 | syscall.SIGINT, 38 | syscall.SIGTERM, 39 | syscall.SIGQUIT) 40 | defer cancel() 41 | 42 | config, err := config.New() 43 | if err != nil { 44 | logger.Fatal("[Report service] Ошибка получения файла конфигурации:", "err", err) 45 | } 46 | 47 | // Изменение параметров по умолчанию из заданной конфигурации. 48 | setConfigSettings(config.GetConfig()) 49 | 50 | // Инициализация хранилищ (подключение к базе данных). 51 | dbconn, err := dbutils.NewDBConnect(connectionStringDB) 52 | if err != nil { 53 | logger.Fatal("[Report service] Ошибка подключения к базе данных:", "err", err) 54 | } 55 | // БД информации пользователей. 56 | userStorage := db.NewUserStorage(dbconn, mainCurrency, 0) 57 | 58 | // Инициализация кафки для получения сообщений из очереди. 59 | kafkaConsumer, err := kafka.NewConsumer(ctx, brokersList, kafkaTopic) 60 | if err != nil { 61 | logger.Fatal("[Report service] Ошибка инициализации кафки:", "err", err) 62 | } 63 | 64 | // Назначение функции, которая будет обрабатывать входящие сообщения из кафки. 65 | handlerFunc := func(ctx context.Context, key string, value string) error { 66 | return getKafkaMessage(ctx, userStorage, key, value) 67 | } 68 | 69 | // Запуск чтения сообщений из очереди. 70 | if err := kafkaConsumer.RunConsume(handlerFunc); err != nil { 71 | logger.Fatal("[Report service] Ошибка чтения кафки:", "err", err) 72 | } 73 | 74 | <-ctx.Done() 75 | logger.Info("[Report service] Завершение приложения") 76 | } 77 | 78 | // setConfigSettings Замена параметров по умолчанию параметрами из конфиг.файла. 79 | func setConfigSettings(config config.Config) { 80 | if config.MainCurrency != "" { 81 | mainCurrency = config.MainCurrency 82 | } 83 | if config.ConnectionStringDB != "" { 84 | connectionStringDB = config.ConnectionStringDB 85 | } 86 | } 87 | 88 | // getKafkaMessage Получение запроса на построение отчета пользователя из кафки. 89 | func getKafkaMessage(ctx context.Context, userStorage *db.UserStorage, key string, value string) error { 90 | if key == "" || value == "" { 91 | logger.Error("[Report service] Сообщение кафка содержит пустой ключ или значение.") 92 | return nil 93 | } 94 | userID, err := strconv.Atoi(key) 95 | if err != nil { 96 | logger.Error("[Report service] Сообщение кафка содержит некорректный ключ.", "err", err) 97 | return nil 98 | } 99 | // Получение данных для отчета из БД. 100 | periodDate := time.Now() 101 | switch value { 102 | case "w": 103 | periodDate = periodDate.AddDate(0, 0, -7) 104 | case "m": 105 | periodDate = periodDate.AddDate(0, -1, 0) 106 | case "y": 107 | periodDate = periodDate.AddDate(-1, 0, 0) 108 | } 109 | dt, err := userStorage.GetUserDataRecord(ctx, int64(userID), periodDate) 110 | if err != nil { 111 | logger.Error("[Report service] Ошибка получения отчета.", "err", err) 112 | return err 113 | } 114 | // Вызов бота для отправки отчета пользователю. 115 | err = sendReportToBot(dt, int64(userID), value) 116 | if err != nil { 117 | logger.Error("[Report service] Ошибка отправки отчета.", "err", err) 118 | return err 119 | } 120 | return nil 121 | } 122 | 123 | // sendReportToBot Вызов сервиса бота, принимающего данные для отчета по gRPC 124 | func sendReportToBot(dt []bottypes.UserDataReportRecord, userID int64, reportKey string) error { 125 | 126 | // Соединение с сервисом бота. 127 | conn, err := grpc.Dial("localhost:50051", grpc.WithTransportCredentials(insecure.NewCredentials())) 128 | if err != nil { 129 | log.Fatalf("did not connect: %v", err) 130 | } 131 | defer conn.Close() 132 | c := api.NewUserReportsReciverClient(conn) 133 | 134 | // Подготовка данных для отправки по gRPC. 135 | items := make([]*api.ReportItem, len(dt)) 136 | for ind, r := range dt { 137 | items[ind] = &api.ReportItem{Category: r.Category, Sum: float32(r.Sum)} 138 | } 139 | 140 | // Отправка данных. 141 | ctx, cancel := context.WithTimeout(context.Background(), time.Second) 142 | defer cancel() 143 | r, err := c.PutReport(ctx, &api.ReportRequest{Items: items, UserID: userID, ReportKey: reportKey}) 144 | if err != nil { 145 | logger.Error("Ошибка отправки", "err", err) 146 | } 147 | 148 | if r.Valid { 149 | return nil 150 | } 151 | return errors.New("Отчет не отправлен.") 152 | } 153 | -------------------------------------------------------------------------------- /config.example.yaml: -------------------------------------------------------------------------------- 1 | # Файл config.yaml необходимо поместить в директорию data. 2 | # Токен бота в телеграме. 3 | token: XXX:XXXX 4 | # Основная валюта, в которой хранятся данные. 5 | mainCurrency: RUB 6 | # Список используемых валют. 7 | CurrenciesName: 8 | - USD 9 | - CNY 10 | - EUR 11 | - RUB 12 | # Периодичность обновления курсов валют из внешнего источника (в минутах). 13 | CurrenciesUpdatePeriod: 30 14 | # Периодичность кэширования курсов валют из базы данных (в минутах). 15 | CurrenciesUpdateCachePeriod: 30 16 | # Строка подключения к базе данных. 17 | ConnectionStringDB: host=localhost port=5432 dbname=tgbot user=tgbotadmin password=tgbotadminpass sslmode=disable 18 | # Наименование топика Kafka. 19 | KafkaTopic: tgbot 20 | # Список адресов брокеров сообщений (адрес Kafka). 21 | BrokersList: 22 | - localhost:9092 -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | services: 2 | postgres: 3 | container_name: postgres_tgbot 4 | image: postgres:alpine3.16 5 | restart: always 6 | environment: 7 | POSTGRES_DB: tgbot 8 | POSTGRES_USER: tgbotadmin 9 | POSTGRES_PASSWORD: tgbotadminpass 10 | PG_TRUST_LOCALNET: true 11 | ports: 12 | - "5432:5432" 13 | volumes: 14 | - postgres_data:/var/lib/postgresql/data 15 | prometheus: 16 | image: prom/prometheus 17 | ports: 18 | - 9090:9090 19 | volumes: 20 | - ./prometheus.yml:/etc/prometheus/prometheus.yml 21 | - ./alerts.yml:/etc/prometheus/alerts.yml 22 | extra_hosts: 23 | - host.docker.internal:host-gateway 24 | grafana: 25 | image: grafana/grafana-oss 26 | ports: 27 | - 3000:3000 28 | volumes: 29 | - ./data:/var/lib/grafana 30 | links: 31 | - prometheus 32 | jaeger: 33 | image: jaegertracing/all-in-one:1.18 34 | ports: 35 | - 5775:5775/udp 36 | - 6831:6831/udp 37 | - 6832:6832/udp 38 | - 5778:5778 39 | - 16686:16686 # web 40 | - 14268:14268 41 | - 9411:9411 42 | kafka: 43 | image: wurstmeister/kafka 44 | hostname: kafka 45 | ports: 46 | - "9092:9092" 47 | links: 48 | - zookeeper 49 | environment: 50 | KAFKA_ADVERTISED_HOST_NAME: "127.0.0.1" 51 | KAFKA_ADVERTISED_PORT: "9092" 52 | KAFKA_ZOOKEEPER_CONNECT: "zookeeper:2181" 53 | KAFKA_CREATE_TOPICS: "tgbot:2:1" 54 | depends_on: 55 | - zookeeper 56 | container_name: tgbot-kafka 57 | zookeeper: 58 | image: wurstmeister/zookeeper 59 | ports: 60 | - "2181:2181" 61 | container_name: tgbot-zookeeper 62 | 63 | volumes: 64 | postgres_data: 65 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/ellavs/tg-bot-golang 2 | 3 | go 1.19 4 | 5 | require ( 6 | github.com/Shopify/sarama v1.37.2 7 | github.com/go-telegram-bot-api/telegram-bot-api/v5 v5.5.1 8 | github.com/golang/mock v1.6.0 9 | github.com/jackc/pgx/v4 v4.18.2 10 | github.com/jmoiron/sqlx v1.3.5 11 | github.com/opentracing/opentracing-go v1.2.0 12 | github.com/pkg/errors v0.9.1 13 | github.com/prometheus/client_golang v1.13.0 14 | github.com/stretchr/testify v1.8.1 15 | github.com/uber/jaeger-client-go v2.30.0+incompatible 16 | github.com/zhashkevych/go-sqlxmock v1.5.1 17 | go.uber.org/multierr v1.8.0 18 | go.uber.org/zap v1.13.0 19 | google.golang.org/grpc v1.56.3 20 | google.golang.org/protobuf v1.33.0 21 | gopkg.in/yaml.v3 v3.0.1 22 | ) 23 | 24 | require ( 25 | github.com/HdrHistogram/hdrhistogram-go v1.1.2 // indirect 26 | github.com/beorn7/perks v1.0.1 // indirect 27 | github.com/cespare/xxhash/v2 v2.2.0 // indirect 28 | github.com/davecgh/go-spew v1.1.1 // indirect 29 | github.com/eapache/go-resiliency v1.3.0 // indirect 30 | github.com/eapache/go-xerial-snappy v0.0.0-20180814174437-776d5712da21 // indirect 31 | github.com/eapache/queue v1.1.0 // indirect 32 | github.com/golang/protobuf v1.5.3 // indirect 33 | github.com/golang/snappy v0.0.4 // indirect 34 | github.com/hashicorp/errwrap v1.0.0 // indirect 35 | github.com/hashicorp/go-multierror v1.1.1 // indirect 36 | github.com/hashicorp/go-uuid v1.0.3 // indirect 37 | github.com/jackc/chunkreader/v2 v2.0.1 // indirect 38 | github.com/jackc/pgconn v1.14.3 // indirect 39 | github.com/jackc/pgio v1.0.0 // indirect 40 | github.com/jackc/pgpassfile v1.0.0 // indirect 41 | github.com/jackc/pgproto3/v2 v2.3.3 // indirect 42 | github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a // indirect 43 | github.com/jackc/pgtype v1.14.0 // indirect 44 | github.com/jcmturner/aescts/v2 v2.0.0 // indirect 45 | github.com/jcmturner/dnsutils/v2 v2.0.0 // indirect 46 | github.com/jcmturner/gofork v1.7.6 // indirect 47 | github.com/jcmturner/gokrb5/v8 v8.4.3 // indirect 48 | github.com/jcmturner/rpc/v2 v2.0.3 // indirect 49 | github.com/klauspost/compress v1.15.11 // indirect 50 | github.com/matttproud/golang_protobuf_extensions v1.0.1 // indirect 51 | github.com/pierrec/lz4/v4 v4.1.17 // indirect 52 | github.com/pmezard/go-difflib v1.0.0 // indirect 53 | github.com/prometheus/client_model v0.2.0 // indirect 54 | github.com/prometheus/common v0.37.0 // indirect 55 | github.com/prometheus/procfs v0.8.0 // indirect 56 | github.com/rcrowley/go-metrics v0.0.0-20201227073835-cf1acfcdf475 // indirect 57 | github.com/rogpeppe/go-internal v1.9.0 // indirect 58 | github.com/uber/jaeger-lib v2.4.1+incompatible // indirect 59 | go.uber.org/atomic v1.7.0 // indirect 60 | golang.org/x/crypto v0.36.0 // indirect 61 | golang.org/x/lint v0.0.0-20200302205851-738671d3881b // indirect 62 | golang.org/x/net v0.38.0 // indirect 63 | golang.org/x/sys v0.31.0 // indirect 64 | golang.org/x/text v0.23.0 // indirect 65 | golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d // indirect 66 | google.golang.org/genproto v0.0.0-20230410155749-daa745c078e1 // indirect 67 | ) 68 | -------------------------------------------------------------------------------- /img/screen-bot-category-choice.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ellavs/tg-bot-golang/e0447c1eaf434f7ca1c03bf67da50558c2e0ccbf/img/screen-bot-category-choice.png -------------------------------------------------------------------------------- /img/screen-bot-currency.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ellavs/tg-bot-golang/e0447c1eaf434f7ca1c03bf67da50558c2e0ccbf/img/screen-bot-currency.png -------------------------------------------------------------------------------- /img/screen-bot-enter-sum.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ellavs/tg-bot-golang/e0447c1eaf434f7ca1c03bf67da50558c2e0ccbf/img/screen-bot-enter-sum.png -------------------------------------------------------------------------------- /img/screen-bot-load-data.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ellavs/tg-bot-golang/e0447c1eaf434f7ca1c03bf67da50558c2e0ccbf/img/screen-bot-load-data.png -------------------------------------------------------------------------------- /img/screen-bot-menu.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ellavs/tg-bot-golang/e0447c1eaf434f7ca1c03bf67da50558c2e0ccbf/img/screen-bot-menu.png -------------------------------------------------------------------------------- /img/screen-bot-report.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ellavs/tg-bot-golang/e0447c1eaf434f7ca1c03bf67da50558c2e0ccbf/img/screen-bot-report.png -------------------------------------------------------------------------------- /img/screen-bot-set-limit.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ellavs/tg-bot-golang/e0447c1eaf434f7ca1c03bf67da50558c2e0ccbf/img/screen-bot-set-limit.png -------------------------------------------------------------------------------- /internal/api/gen.sh: -------------------------------------------------------------------------------- 1 | protoc --go_out=. --go_opt=paths=source_relative \ 2 | --go-grpc_out=. --go-grpc_opt=paths=source_relative \ 3 | report.proto -------------------------------------------------------------------------------- /internal/api/report.pb.go: -------------------------------------------------------------------------------- 1 | // Code generated by protoc-gen-go. DO NOT EDIT. 2 | // versions: 3 | // protoc-gen-go v1.28.1 4 | // protoc v3.21.9 5 | // source: report.proto 6 | 7 | package api 8 | 9 | import ( 10 | protoreflect "google.golang.org/protobuf/reflect/protoreflect" 11 | protoimpl "google.golang.org/protobuf/runtime/protoimpl" 12 | reflect "reflect" 13 | sync "sync" 14 | ) 15 | 16 | const ( 17 | // Verify that this generated code is sufficiently up-to-date. 18 | _ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion) 19 | // Verify that runtime/protoimpl is sufficiently up-to-date. 20 | _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20) 21 | ) 22 | 23 | type ReportItem struct { 24 | state protoimpl.MessageState 25 | sizeCache protoimpl.SizeCache 26 | unknownFields protoimpl.UnknownFields 27 | 28 | Category string `protobuf:"bytes,1,opt,name=category,proto3" json:"category,omitempty"` // Категория. 29 | Sum float32 `protobuf:"fixed32,2,opt,name=sum,proto3" json:"sum,omitempty"` // Сумма расходов по категории. 30 | } 31 | 32 | func (x *ReportItem) Reset() { 33 | *x = ReportItem{} 34 | if protoimpl.UnsafeEnabled { 35 | mi := &file_report_proto_msgTypes[0] 36 | ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) 37 | ms.StoreMessageInfo(mi) 38 | } 39 | } 40 | 41 | func (x *ReportItem) String() string { 42 | return protoimpl.X.MessageStringOf(x) 43 | } 44 | 45 | func (*ReportItem) ProtoMessage() {} 46 | 47 | func (x *ReportItem) ProtoReflect() protoreflect.Message { 48 | mi := &file_report_proto_msgTypes[0] 49 | if protoimpl.UnsafeEnabled && x != nil { 50 | ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) 51 | if ms.LoadMessageInfo() == nil { 52 | ms.StoreMessageInfo(mi) 53 | } 54 | return ms 55 | } 56 | return mi.MessageOf(x) 57 | } 58 | 59 | // Deprecated: Use ReportItem.ProtoReflect.Descriptor instead. 60 | func (*ReportItem) Descriptor() ([]byte, []int) { 61 | return file_report_proto_rawDescGZIP(), []int{0} 62 | } 63 | 64 | func (x *ReportItem) GetCategory() string { 65 | if x != nil { 66 | return x.Category 67 | } 68 | return "" 69 | } 70 | 71 | func (x *ReportItem) GetSum() float32 { 72 | if x != nil { 73 | return x.Sum 74 | } 75 | return 0 76 | } 77 | 78 | type ReportRequest struct { 79 | state protoimpl.MessageState 80 | sizeCache protoimpl.SizeCache 81 | unknownFields protoimpl.UnknownFields 82 | 83 | Items []*ReportItem `protobuf:"bytes,1,rep,name=items,proto3" json:"items,omitempty"` // Список строк отчета. 84 | UserID int64 `protobuf:"varint,2,opt,name=UserID,proto3" json:"UserID,omitempty"` 85 | ReportKey string `protobuf:"bytes,3,opt,name=ReportKey,proto3" json:"ReportKey,omitempty"` 86 | } 87 | 88 | func (x *ReportRequest) Reset() { 89 | *x = ReportRequest{} 90 | if protoimpl.UnsafeEnabled { 91 | mi := &file_report_proto_msgTypes[1] 92 | ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) 93 | ms.StoreMessageInfo(mi) 94 | } 95 | } 96 | 97 | func (x *ReportRequest) String() string { 98 | return protoimpl.X.MessageStringOf(x) 99 | } 100 | 101 | func (*ReportRequest) ProtoMessage() {} 102 | 103 | func (x *ReportRequest) ProtoReflect() protoreflect.Message { 104 | mi := &file_report_proto_msgTypes[1] 105 | if protoimpl.UnsafeEnabled && x != nil { 106 | ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) 107 | if ms.LoadMessageInfo() == nil { 108 | ms.StoreMessageInfo(mi) 109 | } 110 | return ms 111 | } 112 | return mi.MessageOf(x) 113 | } 114 | 115 | // Deprecated: Use ReportRequest.ProtoReflect.Descriptor instead. 116 | func (*ReportRequest) Descriptor() ([]byte, []int) { 117 | return file_report_proto_rawDescGZIP(), []int{1} 118 | } 119 | 120 | func (x *ReportRequest) GetItems() []*ReportItem { 121 | if x != nil { 122 | return x.Items 123 | } 124 | return nil 125 | } 126 | 127 | func (x *ReportRequest) GetUserID() int64 { 128 | if x != nil { 129 | return x.UserID 130 | } 131 | return 0 132 | } 133 | 134 | func (x *ReportRequest) GetReportKey() string { 135 | if x != nil { 136 | return x.ReportKey 137 | } 138 | return "" 139 | } 140 | 141 | type ReportResponse struct { 142 | state protoimpl.MessageState 143 | sizeCache protoimpl.SizeCache 144 | unknownFields protoimpl.UnknownFields 145 | 146 | Valid bool `protobuf:"varint,1,opt,name=valid,proto3" json:"valid,omitempty"` 147 | } 148 | 149 | func (x *ReportResponse) Reset() { 150 | *x = ReportResponse{} 151 | if protoimpl.UnsafeEnabled { 152 | mi := &file_report_proto_msgTypes[2] 153 | ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) 154 | ms.StoreMessageInfo(mi) 155 | } 156 | } 157 | 158 | func (x *ReportResponse) String() string { 159 | return protoimpl.X.MessageStringOf(x) 160 | } 161 | 162 | func (*ReportResponse) ProtoMessage() {} 163 | 164 | func (x *ReportResponse) ProtoReflect() protoreflect.Message { 165 | mi := &file_report_proto_msgTypes[2] 166 | if protoimpl.UnsafeEnabled && x != nil { 167 | ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) 168 | if ms.LoadMessageInfo() == nil { 169 | ms.StoreMessageInfo(mi) 170 | } 171 | return ms 172 | } 173 | return mi.MessageOf(x) 174 | } 175 | 176 | // Deprecated: Use ReportResponse.ProtoReflect.Descriptor instead. 177 | func (*ReportResponse) Descriptor() ([]byte, []int) { 178 | return file_report_proto_rawDescGZIP(), []int{2} 179 | } 180 | 181 | func (x *ReportResponse) GetValid() bool { 182 | if x != nil { 183 | return x.Valid 184 | } 185 | return false 186 | } 187 | 188 | var File_report_proto protoreflect.FileDescriptor 189 | 190 | var file_report_proto_rawDesc = []byte{ 191 | 0x0a, 0x0c, 0x72, 0x65, 0x70, 0x6f, 0x72, 0x74, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x12, 0x06, 192 | 0x72, 0x65, 0x70, 0x6f, 0x72, 0x74, 0x22, 0x3a, 0x0a, 0x0a, 0x52, 0x65, 0x70, 0x6f, 0x72, 0x74, 193 | 0x49, 0x74, 0x65, 0x6d, 0x12, 0x1a, 0x0a, 0x08, 0x63, 0x61, 0x74, 0x65, 0x67, 0x6f, 0x72, 0x79, 194 | 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, 0x63, 0x61, 0x74, 0x65, 0x67, 0x6f, 0x72, 0x79, 195 | 0x12, 0x10, 0x0a, 0x03, 0x73, 0x75, 0x6d, 0x18, 0x02, 0x20, 0x01, 0x28, 0x02, 0x52, 0x03, 0x73, 196 | 0x75, 0x6d, 0x22, 0x6f, 0x0a, 0x0d, 0x52, 0x65, 0x70, 0x6f, 0x72, 0x74, 0x52, 0x65, 0x71, 0x75, 197 | 0x65, 0x73, 0x74, 0x12, 0x28, 0x0a, 0x05, 0x69, 0x74, 0x65, 0x6d, 0x73, 0x18, 0x01, 0x20, 0x03, 198 | 0x28, 0x0b, 0x32, 0x12, 0x2e, 0x72, 0x65, 0x70, 0x6f, 0x72, 0x74, 0x2e, 0x52, 0x65, 0x70, 0x6f, 199 | 0x72, 0x74, 0x49, 0x74, 0x65, 0x6d, 0x52, 0x05, 0x69, 0x74, 0x65, 0x6d, 0x73, 0x12, 0x16, 0x0a, 200 | 0x06, 0x55, 0x73, 0x65, 0x72, 0x49, 0x44, 0x18, 0x02, 0x20, 0x01, 0x28, 0x03, 0x52, 0x06, 0x55, 201 | 0x73, 0x65, 0x72, 0x49, 0x44, 0x12, 0x1c, 0x0a, 0x09, 0x52, 0x65, 0x70, 0x6f, 0x72, 0x74, 0x4b, 202 | 0x65, 0x79, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x09, 0x52, 0x65, 0x70, 0x6f, 0x72, 0x74, 203 | 0x4b, 0x65, 0x79, 0x22, 0x26, 0x0a, 0x0e, 0x52, 0x65, 0x70, 0x6f, 0x72, 0x74, 0x52, 0x65, 0x73, 204 | 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x14, 0x0a, 0x05, 0x76, 0x61, 0x6c, 0x69, 0x64, 0x18, 0x01, 205 | 0x20, 0x01, 0x28, 0x08, 0x52, 0x05, 0x76, 0x61, 0x6c, 0x69, 0x64, 0x32, 0x50, 0x0a, 0x12, 0x55, 206 | 0x73, 0x65, 0x72, 0x52, 0x65, 0x70, 0x6f, 0x72, 0x74, 0x73, 0x52, 0x65, 0x63, 0x69, 0x76, 0x65, 207 | 0x72, 0x12, 0x3a, 0x0a, 0x09, 0x50, 0x75, 0x74, 0x52, 0x65, 0x70, 0x6f, 0x72, 0x74, 0x12, 0x15, 208 | 0x2e, 0x72, 0x65, 0x70, 0x6f, 0x72, 0x74, 0x2e, 0x52, 0x65, 0x70, 0x6f, 0x72, 0x74, 0x52, 0x65, 209 | 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x16, 0x2e, 0x72, 0x65, 0x70, 0x6f, 0x72, 0x74, 0x2e, 0x52, 210 | 0x65, 0x70, 0x6f, 0x72, 0x74, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x42, 0x3d, 0x5a, 211 | 0x3b, 0x67, 0x69, 0x74, 0x6c, 0x61, 0x62, 0x2e, 0x6f, 0x7a, 0x6f, 0x6e, 0x2e, 0x64, 0x65, 0x76, 212 | 0x2f, 0x69, 0x6e, 0x66, 0x6f, 0x73, 0x65, 0x74, 0x69, 0x2f, 0x73, 0x6c, 0x65, 0x73, 0x61, 0x72, 213 | 0x65, 0x76, 0x61, 0x2d, 0x65, 0x6c, 0x6c, 0x61, 0x2d, 0x74, 0x67, 0x2d, 0x62, 0x6f, 0x74, 0x2f, 214 | 0x69, 0x6e, 0x74, 0x65, 0x72, 0x6e, 0x61, 0x6c, 0x2f, 0x61, 0x70, 0x69, 0x62, 0x06, 0x70, 0x72, 215 | 0x6f, 0x74, 0x6f, 0x33, 216 | } 217 | 218 | var ( 219 | file_report_proto_rawDescOnce sync.Once 220 | file_report_proto_rawDescData = file_report_proto_rawDesc 221 | ) 222 | 223 | func file_report_proto_rawDescGZIP() []byte { 224 | file_report_proto_rawDescOnce.Do(func() { 225 | file_report_proto_rawDescData = protoimpl.X.CompressGZIP(file_report_proto_rawDescData) 226 | }) 227 | return file_report_proto_rawDescData 228 | } 229 | 230 | var file_report_proto_msgTypes = make([]protoimpl.MessageInfo, 3) 231 | var file_report_proto_goTypes = []interface{}{ 232 | (*ReportItem)(nil), // 0: report.ReportItem 233 | (*ReportRequest)(nil), // 1: report.ReportRequest 234 | (*ReportResponse)(nil), // 2: report.ReportResponse 235 | } 236 | var file_report_proto_depIdxs = []int32{ 237 | 0, // 0: report.ReportRequest.items:type_name -> report.ReportItem 238 | 1, // 1: report.UserReportsReciver.PutReport:input_type -> report.ReportRequest 239 | 2, // 2: report.UserReportsReciver.PutReport:output_type -> report.ReportResponse 240 | 2, // [2:3] is the sub-list for method output_type 241 | 1, // [1:2] is the sub-list for method input_type 242 | 1, // [1:1] is the sub-list for extension type_name 243 | 1, // [1:1] is the sub-list for extension extendee 244 | 0, // [0:1] is the sub-list for field type_name 245 | } 246 | 247 | func init() { file_report_proto_init() } 248 | func file_report_proto_init() { 249 | if File_report_proto != nil { 250 | return 251 | } 252 | if !protoimpl.UnsafeEnabled { 253 | file_report_proto_msgTypes[0].Exporter = func(v interface{}, i int) interface{} { 254 | switch v := v.(*ReportItem); i { 255 | case 0: 256 | return &v.state 257 | case 1: 258 | return &v.sizeCache 259 | case 2: 260 | return &v.unknownFields 261 | default: 262 | return nil 263 | } 264 | } 265 | file_report_proto_msgTypes[1].Exporter = func(v interface{}, i int) interface{} { 266 | switch v := v.(*ReportRequest); i { 267 | case 0: 268 | return &v.state 269 | case 1: 270 | return &v.sizeCache 271 | case 2: 272 | return &v.unknownFields 273 | default: 274 | return nil 275 | } 276 | } 277 | file_report_proto_msgTypes[2].Exporter = func(v interface{}, i int) interface{} { 278 | switch v := v.(*ReportResponse); i { 279 | case 0: 280 | return &v.state 281 | case 1: 282 | return &v.sizeCache 283 | case 2: 284 | return &v.unknownFields 285 | default: 286 | return nil 287 | } 288 | } 289 | } 290 | type x struct{} 291 | out := protoimpl.TypeBuilder{ 292 | File: protoimpl.DescBuilder{ 293 | GoPackagePath: reflect.TypeOf(x{}).PkgPath(), 294 | RawDescriptor: file_report_proto_rawDesc, 295 | NumEnums: 0, 296 | NumMessages: 3, 297 | NumExtensions: 0, 298 | NumServices: 1, 299 | }, 300 | GoTypes: file_report_proto_goTypes, 301 | DependencyIndexes: file_report_proto_depIdxs, 302 | MessageInfos: file_report_proto_msgTypes, 303 | }.Build() 304 | File_report_proto = out.File 305 | file_report_proto_rawDesc = nil 306 | file_report_proto_goTypes = nil 307 | file_report_proto_depIdxs = nil 308 | } 309 | -------------------------------------------------------------------------------- /internal/api/report.proto: -------------------------------------------------------------------------------- 1 | syntax = "proto3"; 2 | 3 | package report; 4 | option go_package = "github.com/ellavs/tg-bot-golang/internal/api"; 5 | 6 | service UserReportsReciver { 7 | rpc PutReport(ReportRequest) returns (ReportResponse); 8 | } 9 | 10 | message ReportItem { 11 | string category = 1; // Категория. 12 | float sum = 2; // Сумма расходов по категории. 13 | } 14 | 15 | message ReportRequest { 16 | repeated ReportItem items = 1; // Список строк отчета. 17 | int64 UserID = 2; 18 | string ReportKey = 3; 19 | } 20 | 21 | message ReportResponse { 22 | bool valid = 1; 23 | } -------------------------------------------------------------------------------- /internal/api/report_grpc.pb.go: -------------------------------------------------------------------------------- 1 | // Code generated by protoc-gen-go-grpc. DO NOT EDIT. 2 | // versions: 3 | // - protoc-gen-go-grpc v1.2.0 4 | // - protoc v3.21.9 5 | // source: report.proto 6 | 7 | package api 8 | 9 | import ( 10 | context "context" 11 | grpc "google.golang.org/grpc" 12 | codes "google.golang.org/grpc/codes" 13 | status "google.golang.org/grpc/status" 14 | ) 15 | 16 | // This is a compile-time assertion to ensure that this generated file 17 | // is compatible with the grpc package it is being compiled against. 18 | // Requires gRPC-Go v1.32.0 or later. 19 | const _ = grpc.SupportPackageIsVersion7 20 | 21 | // UserReportsReciverClient is the client API for UserReportsReciver service. 22 | // 23 | // For semantics around ctx use and closing/ending streaming RPCs, please refer to https://pkg.go.dev/google.golang.org/grpc/?tab=doc#ClientConn.NewStream. 24 | type UserReportsReciverClient interface { 25 | PutReport(ctx context.Context, in *ReportRequest, opts ...grpc.CallOption) (*ReportResponse, error) 26 | } 27 | 28 | type userReportsReciverClient struct { 29 | cc grpc.ClientConnInterface 30 | } 31 | 32 | func NewUserReportsReciverClient(cc grpc.ClientConnInterface) UserReportsReciverClient { 33 | return &userReportsReciverClient{cc} 34 | } 35 | 36 | func (c *userReportsReciverClient) PutReport(ctx context.Context, in *ReportRequest, opts ...grpc.CallOption) (*ReportResponse, error) { 37 | out := new(ReportResponse) 38 | err := c.cc.Invoke(ctx, "/report.UserReportsReciver/PutReport", in, out, opts...) 39 | if err != nil { 40 | return nil, err 41 | } 42 | return out, nil 43 | } 44 | 45 | // UserReportsReciverServer is the server API for UserReportsReciver service. 46 | // All implementations must embed UnimplementedUserReportsReciverServer 47 | // for forward compatibility 48 | type UserReportsReciverServer interface { 49 | PutReport(context.Context, *ReportRequest) (*ReportResponse, error) 50 | mustEmbedUnimplementedUserReportsReciverServer() 51 | } 52 | 53 | // UnimplementedUserReportsReciverServer must be embedded to have forward compatible implementations. 54 | type UnimplementedUserReportsReciverServer struct { 55 | } 56 | 57 | func (UnimplementedUserReportsReciverServer) PutReport(context.Context, *ReportRequest) (*ReportResponse, error) { 58 | return nil, status.Errorf(codes.Unimplemented, "method PutReport not implemented") 59 | } 60 | func (UnimplementedUserReportsReciverServer) mustEmbedUnimplementedUserReportsReciverServer() {} 61 | 62 | // UnsafeUserReportsReciverServer may be embedded to opt out of forward compatibility for this service. 63 | // Use of this interface is not recommended, as added methods to UserReportsReciverServer will 64 | // result in compilation errors. 65 | type UnsafeUserReportsReciverServer interface { 66 | mustEmbedUnimplementedUserReportsReciverServer() 67 | } 68 | 69 | func RegisterUserReportsReciverServer(s grpc.ServiceRegistrar, srv UserReportsReciverServer) { 70 | s.RegisterService(&UserReportsReciver_ServiceDesc, srv) 71 | } 72 | 73 | func _UserReportsReciver_PutReport_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { 74 | in := new(ReportRequest) 75 | if err := dec(in); err != nil { 76 | return nil, err 77 | } 78 | if interceptor == nil { 79 | return srv.(UserReportsReciverServer).PutReport(ctx, in) 80 | } 81 | info := &grpc.UnaryServerInfo{ 82 | Server: srv, 83 | FullMethod: "/report.UserReportsReciver/PutReport", 84 | } 85 | handler := func(ctx context.Context, req interface{}) (interface{}, error) { 86 | return srv.(UserReportsReciverServer).PutReport(ctx, req.(*ReportRequest)) 87 | } 88 | return interceptor(ctx, in, info, handler) 89 | } 90 | 91 | // UserReportsReciver_ServiceDesc is the grpc.ServiceDesc for UserReportsReciver service. 92 | // It's only intended for direct use with grpc.RegisterService, 93 | // and not to be introspected or modified (even as a copy) 94 | var UserReportsReciver_ServiceDesc = grpc.ServiceDesc{ 95 | ServiceName: "report.UserReportsReciver", 96 | HandlerType: (*UserReportsReciverServer)(nil), 97 | Methods: []grpc.MethodDesc{ 98 | { 99 | MethodName: "PutReport", 100 | Handler: _UserReportsReciver_PutReport_Handler, 101 | }, 102 | }, 103 | Streams: []grpc.StreamDesc{}, 104 | Metadata: "report.proto", 105 | } 106 | -------------------------------------------------------------------------------- /internal/cache/lru.go: -------------------------------------------------------------------------------- 1 | // Package cache - пакет для работы с LRU кэшем 2 | package cache 3 | 4 | import ( 5 | "container/list" 6 | "sync" 7 | ) 8 | 9 | type Item struct { 10 | Key string 11 | Value any 12 | } 13 | 14 | type LRU struct { 15 | mutex *sync.RWMutex 16 | capacity int 17 | queue *list.List 18 | items map[string]*list.Element 19 | } 20 | 21 | func NewLRU(capacity int) *LRU { 22 | return &LRU{ 23 | mutex: new(sync.RWMutex), 24 | capacity: capacity, 25 | queue: list.New(), 26 | items: make(map[string]*list.Element), 27 | } 28 | } 29 | 30 | // Add сохранить значение в кэш по заданному ключу. 31 | func (c *LRU) Add(key string, value any) { 32 | c.mutex.Lock() 33 | defer c.mutex.Unlock() 34 | 35 | if element, exists := c.items[key]; exists { 36 | c.queue.MoveToFront(element) 37 | element.Value.(*Item).Value = value 38 | return 39 | } 40 | 41 | if c.queue.Len() == c.capacity { 42 | c.clear() 43 | } 44 | 45 | item := &Item{ 46 | Key: key, 47 | Value: value, 48 | } 49 | 50 | element := c.queue.PushFront(item) 51 | c.items[item.Key] = element 52 | } 53 | 54 | // Get получить значение из кэша по заданному ключу. 55 | func (c *LRU) Get(key string) any { 56 | c.mutex.RLock() 57 | defer c.mutex.RUnlock() 58 | 59 | element, exists := c.items[key] 60 | if !exists { 61 | return nil 62 | } 63 | 64 | c.queue.MoveToFront(element) 65 | return element.Value.(*Item).Value 66 | } 67 | 68 | func (c *LRU) Remove(key string) { 69 | c.mutex.Lock() 70 | defer c.mutex.Unlock() 71 | if val, found := c.items[key]; found { 72 | c.deleteItem(val) 73 | } 74 | } 75 | 76 | func (c *LRU) Len() int { 77 | c.mutex.RLock() 78 | defer c.mutex.RUnlock() 79 | return len(c.items) 80 | } 81 | 82 | func (c *LRU) clear() { 83 | if element := c.queue.Back(); element != nil { 84 | c.deleteItem(element) 85 | } 86 | } 87 | 88 | func (c *LRU) deleteItem(element *list.Element) { 89 | item := c.queue.Remove(element).(*Item) 90 | delete(c.items, item.Key) 91 | } 92 | -------------------------------------------------------------------------------- /internal/cache/lru_test.go: -------------------------------------------------------------------------------- 1 | package cache 2 | 3 | import ( 4 | "sync" 5 | "testing" 6 | 7 | "github.com/stretchr/testify/assert" 8 | ) 9 | 10 | func TestLRU_Add_existElementWithFullQueueSync_moveToFront(t *testing.T) { 11 | //Arrange 12 | lru := NewLRU(3) 13 | lru.Add("someKey1", 8) 14 | lru.Add("someKey2", "56") 15 | emptyMap := make(map[string]int) 16 | lru.Add("someKey3", emptyMap) 17 | 18 | //Act 19 | lru.Add("someKey1", 10) 20 | 21 | //Assert 22 | frontItem := lru.queue.Front().Value.(*Item) 23 | backItem := lru.queue.Back().Value.(*Item) 24 | assert.Equal(t, "someKey1", frontItem.Key) 25 | assert.Equal(t, 10, frontItem.Value) 26 | assert.Equal(t, "someKey2", backItem.Key) 27 | assert.Equal(t, "56", backItem.Value) 28 | assert.Equal(t, 3, lru.queue.Len()) 29 | } 30 | 31 | func TestLRU_Add_existElementSync_moveToFront(t *testing.T) { 32 | //Arrange 33 | lru := NewLRU(3) 34 | lru.Add("someKey1", 8) 35 | lru.Add("someKey2", "56") 36 | 37 | //Act 38 | lru.Add("someKey1", 10) 39 | 40 | //Assert 41 | frontItem := lru.queue.Front().Value.(*Item) 42 | backItem := lru.queue.Back().Value.(*Item) 43 | assert.Equal(t, "someKey1", frontItem.Key) 44 | assert.Equal(t, 10, frontItem.Value) 45 | assert.Equal(t, "someKey2", backItem.Key) 46 | assert.Equal(t, "56", backItem.Value) 47 | assert.Equal(t, 2, lru.queue.Len()) 48 | } 49 | 50 | func TestLRU_Add_newElementWithFullQueueSync_clearAndPushToFront(t *testing.T) { 51 | //Arrange 52 | lru := NewLRU(3) 53 | lru.Add("someKey1", 8) 54 | lru.Add("someKey2", "56") 55 | lru.Add("someKey3", Item{"key", 7}) 56 | 57 | //Act 58 | lru.Add("someKey4", 5) 59 | 60 | //Assert 61 | frontItem := lru.queue.Front().Value.(*Item) 62 | backItem := lru.queue.Back().Value.(*Item) 63 | assert.Equal(t, "someKey4", frontItem.Key) 64 | assert.Equal(t, 5, frontItem.Value) 65 | assert.Equal(t, "someKey2", backItem.Key) 66 | assert.Equal(t, "56", backItem.Value) 67 | assert.Equal(t, 3, lru.queue.Len()) 68 | assert.Nil(t, lru.Get("someKey1")) 69 | } 70 | 71 | func TestLRU_Add_newElementSync_pushToFront(t *testing.T) { 72 | //Arrange 73 | lru := NewLRU(3) 74 | lru.Add("someKey1", 8) 75 | 76 | //Act 77 | lru.Add("someKey2", 5) 78 | 79 | //Assert 80 | frontItem := lru.queue.Front().Value.(*Item) 81 | backItem := lru.queue.Back().Value.(*Item) 82 | assert.Equal(t, "someKey2", frontItem.Key) 83 | assert.Equal(t, 5, frontItem.Value) 84 | assert.Equal(t, "someKey1", backItem.Key) 85 | assert.Equal(t, 8, backItem.Value) 86 | assert.Equal(t, 2, lru.queue.Len()) 87 | } 88 | 89 | func TestLRU_Add_newElementAsync_allKeysExists(t *testing.T) { 90 | //Arrange 91 | wg := sync.WaitGroup{} 92 | lru := NewLRU(3) 93 | wg.Add(3) 94 | 95 | //Act 96 | go func() { 97 | lru.Add("someKey1", 8) 98 | wg.Done() 99 | }() 100 | go func() { 101 | lru.Add("someKey2", "56") 102 | wg.Done() 103 | }() 104 | go func() { 105 | lru.Add("someKey3", Item{"key", 7}) 106 | wg.Done() 107 | }() 108 | 109 | wg.Wait() 110 | 111 | //Assert 112 | assert.Equal(t, 8, lru.Get("someKey1")) 113 | assert.Equal(t, "56", lru.Get("someKey2")) 114 | assert.Equal(t, Item{"key", 7}, lru.Get("someKey3")) 115 | assert.Equal(t, 3, lru.queue.Len()) 116 | } 117 | 118 | func TestLRU_Get_hasElement_returnItAndMoveToFront(t *testing.T) { 119 | //Arrange 120 | lru := NewLRU(3) 121 | lru.Add("someKey1", 8) 122 | lru.Add("someKey2", 5) 123 | lru.Add("someKey3", "90") 124 | 125 | //Act 126 | item := lru.Get("someKey2") 127 | 128 | //Assert 129 | frontItem := lru.queue.Front().Value.(*Item) 130 | backItem := lru.queue.Back().Value.(*Item) 131 | assert.Equal(t, 5, item) 132 | assert.Equal(t, "someKey2", frontItem.Key) 133 | assert.Equal(t, 5, frontItem.Value) 134 | assert.Equal(t, "someKey1", backItem.Key) 135 | assert.Equal(t, 8, backItem.Value) 136 | assert.Equal(t, 3, lru.queue.Len()) 137 | } 138 | 139 | func TestLRU_Get_hasNotElement_returnNil(t *testing.T) { 140 | //Arrange 141 | lru := NewLRU(3) 142 | lru.Add("someKey1", 8) 143 | lru.Add("someKey2", 5) 144 | lru.Add("someKey3", "90") 145 | 146 | //Act 147 | item := lru.Get("someKey") 148 | 149 | //Assert 150 | frontItem := lru.queue.Front().Value.(*Item) 151 | backItem := lru.queue.Back().Value.(*Item) 152 | assert.Nil(t, item) 153 | assert.Equal(t, "someKey3", frontItem.Key) 154 | assert.Equal(t, "90", frontItem.Value) 155 | assert.Equal(t, "someKey1", backItem.Key) 156 | assert.Equal(t, 8, backItem.Value) 157 | assert.Equal(t, 3, lru.queue.Len()) 158 | } 159 | 160 | func TestLRU_Remove_hasElement_removeIt(t *testing.T) { 161 | //Arrange 162 | lru := NewLRU(3) 163 | lru.Add("someKey1", 8) 164 | lru.Add("someKey2", 5) 165 | lru.Add("someKey3", "90") 166 | 167 | //Act 168 | lru.Remove("someKey2") 169 | 170 | //Assert 171 | frontItem := lru.queue.Front().Value.(*Item) 172 | backItem := lru.queue.Back().Value.(*Item) 173 | assert.Nil(t, lru.Get("someKey2")) 174 | assert.Equal(t, "someKey3", frontItem.Key) 175 | assert.Equal(t, "90", frontItem.Value) 176 | assert.Equal(t, "someKey1", backItem.Key) 177 | assert.Equal(t, 8, backItem.Value) 178 | assert.Equal(t, 2, lru.queue.Len()) 179 | } 180 | 181 | func TestLRU_Remove_hasNotElement_doNothing(t *testing.T) { 182 | //Arrange 183 | lru := NewLRU(3) 184 | lru.Add("someKey1", 8) 185 | lru.Add("someKey2", 5) 186 | lru.Add("someKey3", "90") 187 | 188 | //Act 189 | lru.Remove("someKey") 190 | 191 | //Assert 192 | frontItem := lru.queue.Front().Value.(*Item) 193 | backItem := lru.queue.Back().Value.(*Item) 194 | assert.Equal(t, "someKey3", frontItem.Key) 195 | assert.Equal(t, "90", frontItem.Value) 196 | assert.Equal(t, "someKey1", backItem.Key) 197 | assert.Equal(t, 8, backItem.Value) 198 | assert.Equal(t, 3, lru.queue.Len()) 199 | } 200 | -------------------------------------------------------------------------------- /internal/clients/cbr/cbrclient.go: -------------------------------------------------------------------------------- 1 | package cbr 2 | 3 | // Пакет для загрузки курсов валют из источника: 4 | // https://www.cbr-xml-daily.ru/latest.js 5 | 6 | import ( 7 | "context" 8 | "github.com/ellavs/tg-bot-golang/internal/logger" 9 | "time" 10 | 11 | types "github.com/ellavs/tg-bot-golang/internal/model/bottypes" 12 | ) 13 | 14 | const currenciesURL = "https://www.cbr-xml-daily.ru/latest.js" // URL для загрузки курса валют. 15 | 16 | type httpClient[T any] interface { 17 | GetJsonByURL(ctx context.Context, url string, jsonStruct *T) error 18 | } 19 | 20 | type CbrClient struct { 21 | ctx context.Context 22 | httpClient httpClient[ExchangeRatesJson] 23 | } 24 | 25 | // Структура для загрузки курса валют из JSON. 26 | type ExchangeRatesJson struct { 27 | Timestamp int64 `json:"timestamp"` 28 | Rates types.ExchangeRate `json:"rates"` 29 | } 30 | 31 | func New(ctx context.Context, httpClient httpClient[ExchangeRatesJson]) *CbrClient { 32 | return &CbrClient{ 33 | ctx: ctx, 34 | httpClient: httpClient, 35 | } 36 | } 37 | 38 | // Загрузка курсов валют. 39 | func (cbrClient *CbrClient) LoadExchangeRates() (types.ExchangeRate, time.Time, error) { 40 | // Переменная для загрузки данных из JSON. 41 | var curExchangeRates ExchangeRatesJson 42 | // Получение JSON данных по заданному URL и перенос их в указанную структуру. 43 | err := cbrClient.httpClient.GetJsonByURL(cbrClient.ctx, currenciesURL, &curExchangeRates) 44 | if err != nil { 45 | logger.Error("Ошибка получения данных курсов валют по URL", "err", err) 46 | return nil, time.Time{}, err 47 | } 48 | // Парсинг даты курса. 49 | period := time.Unix(curExchangeRates.Timestamp, 0) 50 | return curExchangeRates.Rates, period, nil 51 | } 52 | -------------------------------------------------------------------------------- /internal/clients/tg/tgclient.go: -------------------------------------------------------------------------------- 1 | package tg 2 | 3 | import ( 4 | "fmt" 5 | "github.com/ellavs/tg-bot-golang/internal/logger" 6 | "strings" 7 | 8 | tgbotapi "github.com/go-telegram-bot-api/telegram-bot-api/v5" 9 | "github.com/pkg/errors" 10 | types "github.com/ellavs/tg-bot-golang/internal/model/bottypes" 11 | "github.com/ellavs/tg-bot-golang/internal/model/messages" 12 | ) 13 | 14 | type HandlerFunc func(tgUpdate tgbotapi.Update, c *Client, msgModel *messages.Model) 15 | 16 | func (f HandlerFunc) RunFunc(tgUpdate tgbotapi.Update, c *Client, msgModel *messages.Model) { 17 | f(tgUpdate, c, msgModel) 18 | } 19 | 20 | type Client struct { 21 | client *tgbotapi.BotAPI 22 | handlerProcessingFunc HandlerFunc // Функция обработки входящих сообщений. 23 | } 24 | 25 | type TokenGetter interface { 26 | Token() string 27 | } 28 | 29 | func New(tokenGetter TokenGetter, handlerProcessingFunc HandlerFunc) (*Client, error) { 30 | client, err := tgbotapi.NewBotAPI(tokenGetter.Token()) 31 | if err != nil { 32 | return nil, errors.Wrap(err, "Ошибка NewBotAPI") 33 | } 34 | 35 | return &Client{ 36 | client: client, 37 | handlerProcessingFunc: handlerProcessingFunc, 38 | }, nil 39 | } 40 | 41 | func (c *Client) SendMessage(text string, userID int64) error { 42 | msg := tgbotapi.NewMessage(userID, text) 43 | msg.ParseMode = "markdown" 44 | _, err := c.client.Send(msg) 45 | if err != nil { 46 | return errors.Wrap(err, "Ошибка отправки сообщения client.Send") 47 | } 48 | return nil 49 | } 50 | 51 | func (c *Client) ListenUpdates(msgModel *messages.Model) { 52 | u := tgbotapi.NewUpdate(0) 53 | u.Timeout = 60 54 | 55 | updates := c.client.GetUpdatesChan(u) 56 | 57 | logger.Info("Start listening for tg messages") 58 | 59 | for update := range updates { 60 | // Функция обработки сообщений (обернутая в middleware). 61 | c.handlerProcessingFunc.RunFunc(update, c, msgModel) 62 | //вместо ProcessingMessages(update, c, msgModel) 63 | } 64 | } 65 | 66 | // ProcessingMessages функция обработки сообщений. 67 | func ProcessingMessages(tgUpdate tgbotapi.Update, c *Client, msgModel *messages.Model) { 68 | if tgUpdate.Message != nil { 69 | // Пользователь написал текстовое сообщение. 70 | logger.Info(fmt.Sprintf("[%s][%v] %s", tgUpdate.Message.From.UserName, tgUpdate.Message.From.ID, tgUpdate.Message.Text)) 71 | err := msgModel.IncomingMessage(messages.Message{ 72 | Text: tgUpdate.Message.Text, 73 | UserID: tgUpdate.Message.From.ID, 74 | UserName: tgUpdate.Message.From.UserName, 75 | UserDisplayName: strings.TrimSpace(tgUpdate.Message.From.FirstName + " " + tgUpdate.Message.From.LastName), 76 | }) 77 | if err != nil { 78 | logger.Error("error processing message:", "err", err) 79 | } 80 | } else if tgUpdate.CallbackQuery != nil { 81 | // Пользователь нажал кнопку. 82 | logger.Info(fmt.Sprintf("[%s][%v] Callback: %s", tgUpdate.CallbackQuery.From.UserName, tgUpdate.CallbackQuery.From.ID, tgUpdate.CallbackQuery.Data)) 83 | callback := tgbotapi.NewCallback(tgUpdate.CallbackQuery.ID, tgUpdate.CallbackQuery.Data) 84 | if _, err := c.client.Request(callback); err != nil { 85 | logger.Error("Ошибка Request callback:", "err", err) 86 | } 87 | if err := deleteInlineButtons(c, tgUpdate.CallbackQuery.From.ID, tgUpdate.CallbackQuery.Message.MessageID, tgUpdate.CallbackQuery.Message.Text); err != nil { 88 | logger.Error("Ошибка удаления кнопок:", "err", err) 89 | } 90 | err := msgModel.IncomingMessage(messages.Message{ 91 | Text: tgUpdate.CallbackQuery.Data, 92 | UserID: tgUpdate.CallbackQuery.From.ID, 93 | UserName: tgUpdate.CallbackQuery.From.UserName, 94 | UserDisplayName: strings.TrimSpace(tgUpdate.CallbackQuery.From.FirstName + " " + tgUpdate.CallbackQuery.From.LastName), 95 | IsCallback: true, 96 | CallbackMsgID: tgUpdate.CallbackQuery.InlineMessageID, 97 | }) 98 | if err != nil { 99 | logger.Error("error processing message from callback:", "err", err) 100 | } 101 | } 102 | } 103 | 104 | // ShowInlineButtons Отображение кнопок меню под сообщением с ответом. 105 | // Их нажатие ожидает коллбек-ответ. 106 | func (c *Client) ShowInlineButtons(text string, buttons []types.TgRowButtons, userID int64) error { 107 | keyboard := make([][]tgbotapi.InlineKeyboardButton, len(buttons)) 108 | for i := 0; i < len(buttons); i++ { 109 | tgRowButtons := buttons[i] 110 | keyboard[i] = make([]tgbotapi.InlineKeyboardButton, len(tgRowButtons)) 111 | for j := 0; j < len(tgRowButtons); j++ { 112 | tgInlineButton := tgRowButtons[j] 113 | keyboard[i][j] = tgbotapi.NewInlineKeyboardButtonData(tgInlineButton.DisplayName, tgInlineButton.Value) 114 | } 115 | } 116 | var numericKeyboard = tgbotapi.NewInlineKeyboardMarkup(keyboard...) 117 | msg := tgbotapi.NewMessage(userID, text) 118 | msg.ReplyMarkup = numericKeyboard 119 | msg.ParseMode = "markdown" 120 | _, err := c.client.Send(msg) 121 | if err != nil { 122 | logger.Error("Ошибка отправки сообщения", "err", err) 123 | return errors.Wrap(err, "client.Send with inline-buttons") 124 | } 125 | return nil 126 | } 127 | 128 | func deleteInlineButtons(c *Client, userID int64, msgID int, sourceText string) error { 129 | msg := tgbotapi.NewEditMessageText(userID, msgID, sourceText) 130 | _, err := c.client.Send(msg) 131 | if err != nil { 132 | logger.Error("Ошибка отправки сообщения", "err", err) 133 | return errors.Wrap(err, "client.Send remove inline-buttons") 134 | } 135 | return nil 136 | } 137 | -------------------------------------------------------------------------------- /internal/config/config.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "github.com/ellavs/tg-bot-golang/internal/logger" 5 | "os" 6 | 7 | "github.com/pkg/errors" 8 | "gopkg.in/yaml.v3" 9 | ) 10 | 11 | const configFile = "data/config.yaml" 12 | 13 | type Config struct { 14 | Token string `yaml:"token"` // Токен бота в телеграме. 15 | MainCurrency string `yaml:"mainCurrency"` // Основная валюта, в которой хранятся данные. 16 | CurrenciesName []string `yaml:"CurrenciesName"` // Список используемых валют. 17 | CurrenciesUpdatePeriod int64 `yaml:"CurrenciesUpdatePeriod"` // Периодичность обновления курсов валют (в минутах). 18 | CurrenciesUpdateCachePeriod int64 `yaml:"CurrenciesUpdateCachePeriod"` // Периодичность кэширования курсов валют из базы данных (в минутах). 19 | ConnectionStringDB string `yaml:"ConnectionStringDB"` // Строка подключения в базе данных. 20 | KafkaTopic string `yaml:"KafkaTopic"` // Наименование топика Kafka. 21 | BrokersList []string `yaml:"BrokersList"` // Список адресов брокеров сообщений (адрес Kafka). 22 | } 23 | 24 | type Service struct { 25 | config Config 26 | } 27 | 28 | func New() (*Service, error) { 29 | s := &Service{} 30 | 31 | rawYAML, err := os.ReadFile(configFile) 32 | if err != nil { 33 | logger.Error("Ошибка reading config file", "err", err) 34 | return nil, errors.Wrap(err, "reading config file") 35 | } 36 | 37 | err = yaml.Unmarshal(rawYAML, &s.config) 38 | if err != nil { 39 | logger.Error("Ошибка parsing yaml", "err", err) 40 | return nil, errors.Wrap(err, "parsing yaml") 41 | } 42 | 43 | return s, nil 44 | } 45 | 46 | func (s *Service) Token() string { 47 | return s.config.Token 48 | } 49 | 50 | func (s *Service) GetConfig() Config { 51 | return s.config 52 | } 53 | -------------------------------------------------------------------------------- /internal/helpers/dbutils/pgxwrapper.go: -------------------------------------------------------------------------------- 1 | // Package dbutils Хелпер-обёртка для выполнения запросов на базе sqlx и для функций подключения к БД (pgx). 2 | package dbutils 3 | 4 | // Хелпер-обёртка для функций подключения к БД (pgx) 5 | 6 | import ( 7 | "bytes" 8 | "context" 9 | "fmt" 10 | "github.com/jackc/pgx/v4" 11 | "github.com/jackc/pgx/v4/stdlib" 12 | "github.com/jmoiron/sqlx" 13 | "github.com/ellavs/tg-bot-golang/internal/logger" 14 | ) 15 | 16 | // pgxLogger Логгер для pgx, реализующий интерфейс Logger пакета pgx. 17 | type pgxLogger struct{} 18 | 19 | // Log Функция реализации интерфейса Logger пакета pgx. 20 | func (pl *pgxLogger) Log(ctx context.Context, level pgx.LogLevel, msg string, data map[string]any) { 21 | var buffer bytes.Buffer 22 | buffer.WriteString(msg) 23 | for k, v := range data { 24 | buffer.WriteString(fmt.Sprintf(" %s=%+v", k, v)) 25 | } 26 | switch level { 27 | case pgx.LogLevelTrace, pgx.LogLevelNone, pgx.LogLevelDebug: 28 | logger.Debug(buffer.String()) 29 | case pgx.LogLevelInfo: 30 | logger.Info(buffer.String()) 31 | case pgx.LogLevelWarn: 32 | logger.Warn(buffer.String()) 33 | case pgx.LogLevelError: 34 | logger.Error(buffer.String()) 35 | default: 36 | logger.Debug(buffer.String()) 37 | } 38 | } 39 | 40 | // NewDBConnect Инициализация подключения к базе данных по заданным параметрам. 41 | func NewDBConnect(connString string) (*sqlx.DB, error) { 42 | connConfig, err := pgx.ParseConfig(connString) 43 | if err != nil { 44 | logger.Error("Ошибка парсинга строки подключения", "err", err) 45 | return nil, err 46 | } 47 | connConfig.RuntimeParams["application_name"] = "tg-bot" 48 | connConfig.Logger = &pgxLogger{} 49 | connConfig.LogLevel = pgx.LogLevelDebug 50 | connStr := stdlib.RegisterConnConfig(connConfig) 51 | dbh, err := sqlx.Connect("pgx", connStr) 52 | if err != nil { 53 | logger.Error("Ошибка соединения с БД", "err", err) 54 | return nil, fmt.Errorf("Ошибка: prepare db connection: %w", err) 55 | } 56 | return dbh, nil 57 | } 58 | -------------------------------------------------------------------------------- /internal/helpers/dbutils/sqlxwrapper.go: -------------------------------------------------------------------------------- 1 | // Package dbutils Хелпер-обёртка для выполнения запросов на базе sqlx и для функций подключения к БД (pgx). 2 | package dbutils 3 | 4 | // Хелпер-обёртка для выполнения запросов на базе sqlx 5 | 6 | import ( 7 | "context" 8 | "database/sql" 9 | "fmt" 10 | "github.com/jmoiron/sqlx" 11 | "go.uber.org/multierr" 12 | ) 13 | 14 | // sqlErr Форматирование текстов ошибок. 15 | func sqlErr(err error, query string, args ...any) error { 16 | return fmt.Errorf(`run query "%s" with args %+v: %w`, query, args, err) 17 | } 18 | 19 | // namedQuery Заполнение запросов именованными параметрами. 20 | func namedQuery(query string, arg any) (nq string, args []any, err error) { 21 | nq, args, err = sqlx.Named(query, arg) 22 | if err != nil { 23 | return "", nil, sqlErr(err, query, args...) 24 | } 25 | return nq, args, nil 26 | } 27 | 28 | // Exec Выполнение запросов с параметрами (неименованные, в виде $1...$n). 29 | func Exec(ctx context.Context, db sqlx.ExecerContext, query string, args ...any) (sql.Result, error) { 30 | res, err := db.ExecContext(ctx, query, args...) 31 | if err != nil { 32 | return res, sqlErr(err, query, args...) 33 | } 34 | 35 | return res, nil 36 | } 37 | 38 | // NamedExec Выполнение запросов с именованными параметрами. 39 | func NamedExec(ctx context.Context, db sqlx.ExtContext, query string, arg any) (sql.Result, error) { 40 | nq, args, err := namedQuery(query, arg) 41 | if err != nil { 42 | return nil, err 43 | } 44 | 45 | return Exec(ctx, db, db.Rebind(nq), args...) 46 | } 47 | 48 | // Select Выборка по запросу с параметрами (неименованные, в виде $1...$n). 49 | func Select(ctx context.Context, db sqlx.QueryerContext, dest any, query string, args ...any) error { 50 | if err := sqlx.SelectContext(ctx, db, dest, query, args...); err != nil { 51 | return sqlErr(err, query, args...) 52 | } 53 | 54 | return nil 55 | } 56 | 57 | // GetMap Выборка по запросу с параметрами (неименованные, в виде $1...$n). 58 | // Возвращаемое значение - map - map[string]any 59 | func GetMap(ctx context.Context, db sqlx.QueryerContext, query string, args ...any) (ret map[string]any, err error) { 60 | row := db.QueryRowxContext(ctx, query, args...) 61 | if row.Err() != nil { 62 | return nil, sqlErr(row.Err(), query, args...) 63 | } 64 | 65 | ret = map[string]any{} 66 | if err := row.MapScan(ret); err != nil { 67 | return nil, sqlErr(err, query, args...) 68 | } 69 | 70 | return ret, nil 71 | } 72 | 73 | // TxFunc Описание типа вложенной функции для выполнения в транзакции. 74 | type TxFunc func(tx *sqlx.Tx) error 75 | 76 | // TxRunner Интерфейс для запуска транзакции (sqlx). 77 | type TxRunner interface { 78 | BeginTxx(context.Context, *sql.TxOptions) (*sqlx.Tx, error) 79 | } 80 | 81 | // RunTx 82 | // 83 | // Запуск транзакции (в случае ошибки выполнения вложенной функции вызовет откат транзакции). 84 | // Вложенная функция (f TxFunc) должна возвращать ошибку в случае присутствия условий, требущих откат транзакции. 85 | func RunTx(ctx context.Context, db TxRunner, f TxFunc) (err error) { 86 | var tx *sqlx.Tx 87 | 88 | opts := &sql.TxOptions{ 89 | Isolation: sql.LevelReadCommitted, 90 | } 91 | // Запуск транзакции. 92 | tx, err = db.BeginTxx(ctx, opts) 93 | if err != nil { 94 | return fmt.Errorf("begin transaction: %w", err) 95 | } 96 | // Откат или коммит транзакции при завершении функции. 97 | defer func() { 98 | if err != nil { 99 | // Откат транзакции, т.к. вернулась ошибка. 100 | err = multierr.Combine(err, tx.Rollback()) 101 | } else { 102 | // Коммит транзакции. 103 | err = tx.Commit() 104 | } 105 | }() 106 | // Выполнение вложенной функции и возврат результата. 107 | return f(tx) 108 | } 109 | -------------------------------------------------------------------------------- /internal/helpers/kafka/kafkaconsumer.go: -------------------------------------------------------------------------------- 1 | // Package kafka Хелпер для работы с кафкой 2 | package kafka 3 | 4 | import ( 5 | "context" 6 | "github.com/Shopify/sarama" 7 | "github.com/pkg/errors" 8 | "log" 9 | ) 10 | 11 | type KafkaConsumer struct { 12 | ctx context.Context 13 | consumer sarama.ConsumerGroup 14 | topic string 15 | } 16 | 17 | func NewConsumer(ctx context.Context, brokerList []string, topic string) (*KafkaConsumer, error) { 18 | 19 | config := sarama.NewConfig() 20 | config.Version = sarama.V2_5_0_0 21 | config.Consumer.Offsets.Initial = sarama.OffsetOldest 22 | config.Consumer.Group.Rebalance.GroupStrategies = []sarama.BalanceStrategy{sarama.BalanceStrategyRange} 23 | 24 | // Create consumer group 25 | kafkaConsumerGroup := topic + "-consumer-group" 26 | consumerGroup, err := sarama.NewConsumerGroup(brokerList, kafkaConsumerGroup, config) 27 | if err != nil { 28 | return &KafkaConsumer{}, errors.Wrap(err, "Starting consumer group") 29 | } 30 | 31 | kafkaConsumer := &KafkaConsumer{ 32 | ctx: ctx, 33 | consumer: consumerGroup, 34 | topic: topic, 35 | } 36 | 37 | return kafkaConsumer, nil 38 | } 39 | 40 | func (c *KafkaConsumer) RunConsume(handlerFunc func(ctx context.Context, key string, value string) error) error { 41 | consumerGroupHandler := Consumer{ 42 | ctx: c.ctx, 43 | handlerFunc: handlerFunc, 44 | } 45 | err := c.consumer.Consume(c.ctx, []string{c.topic}, &consumerGroupHandler) 46 | if err != nil { 47 | return errors.Wrap(err, "consuming via handler") 48 | } 49 | return nil 50 | } 51 | 52 | // Consumer represents a Sarama consumer group consumer. 53 | type Consumer struct { 54 | ctx context.Context 55 | handlerFunc func(ctx context.Context, key string, value string) error 56 | } 57 | 58 | // Setup is run at the beginning of a new session, before ConsumeClaim. 59 | func (consumer *Consumer) Setup(sarama.ConsumerGroupSession) error { 60 | log.Println("consumer - setup") 61 | return nil 62 | } 63 | 64 | // Cleanup is run at the end of a session, once all ConsumeClaim goroutines have exited. 65 | func (consumer *Consumer) Cleanup(sarama.ConsumerGroupSession) error { 66 | log.Println("consumer - cleanup") 67 | return nil 68 | } 69 | 70 | // ConsumeClaim must start a consumer loop of ConsumerGroupClaim's Messages(). 71 | func (consumer *Consumer) ConsumeClaim(session sarama.ConsumerGroupSession, claim sarama.ConsumerGroupClaim) error { 72 | for message := range claim.Messages() { 73 | err := consumer.handlerFunc(consumer.ctx, string(message.Key), string(message.Value)) 74 | if err == nil { 75 | session.MarkMessage(message, "") 76 | } 77 | } 78 | return nil 79 | } 80 | -------------------------------------------------------------------------------- /internal/helpers/kafka/kafkaproducer.go: -------------------------------------------------------------------------------- 1 | // Package kafka Хелпер для работы с кафкой 2 | package kafka 3 | 4 | import ( 5 | "github.com/Shopify/sarama" 6 | "github.com/pkg/errors" 7 | "time" 8 | ) 9 | 10 | type KafkaProducer struct { 11 | producer sarama.SyncProducer 12 | topic string 13 | } 14 | 15 | func NewSyncProducer(brokerList []string, topic string) (*KafkaProducer, error) { 16 | config := sarama.NewConfig() 17 | config.Version = sarama.V2_8_0_0 18 | // Waits for all in-sync replicas to commit before responding. 19 | config.Producer.RequiredAcks = sarama.WaitForAll 20 | // The total number of times to retry sending a message (default 3). 21 | config.Producer.Retry.Max = 3 22 | // How long to wait for the cluster to settle between retries (default 100ms). 23 | config.Producer.Retry.Backoff = time.Millisecond * 250 24 | // idempotent producer has a unique producer ID and uses sequence IDs for each message, 25 | // allowing the broker to ensure, on a per-partition basis, that it is committing ordered messages with no duplication. 26 | //config.Producer.Idempotent = true 27 | if config.Producer.Idempotent { 28 | config.Producer.Retry.Max = 1 29 | config.Net.MaxOpenRequests = 1 30 | } 31 | // Successfully delivered messages will be returned on the Successes channe 32 | config.Producer.Return.Successes = true 33 | // Generates partitioners for choosing the partition to send messages to (defaults to hashing the message key) 34 | _ = config.Producer.Partitioner 35 | 36 | producer, err := sarama.NewSyncProducer(brokerList, config) 37 | if err != nil { 38 | return &KafkaProducer{}, errors.Wrap(err, "Starting Sarama producer") 39 | } 40 | 41 | kafkaProducer := &KafkaProducer{ 42 | producer: producer, 43 | topic: topic, 44 | } 45 | 46 | return kafkaProducer, nil 47 | } 48 | 49 | func (k *KafkaProducer) SendMessage(key string, value string) (partition int32, offset int64, err error) { 50 | msg := sarama.ProducerMessage{ 51 | Topic: k.topic, 52 | Key: sarama.StringEncoder(key), 53 | Value: sarama.StringEncoder(value), 54 | } 55 | p, o, err := k.producer.SendMessage(&msg) 56 | if err != nil { 57 | return 0, 0, err 58 | } 59 | return p, o, nil 60 | } 61 | 62 | func (k *KafkaProducer) GetTopic() string { 63 | return k.topic 64 | } 65 | -------------------------------------------------------------------------------- /internal/helpers/net_http/net_http.go: -------------------------------------------------------------------------------- 1 | package net_http 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | "io" 7 | "net/http" 8 | "time" 9 | ) 10 | 11 | type HttpClient[T any] struct { 12 | HttpClient http.Client 13 | } 14 | 15 | func New[T any]() *HttpClient[T] { 16 | var netClient = http.Client{ 17 | Timeout: time.Second * 5, 18 | } 19 | clt := &HttpClient[T]{ 20 | HttpClient: netClient, 21 | } 22 | return clt 23 | } 24 | 25 | // Отправка запроса по указанному URL, получение JSON и запись в указанную структуру. 26 | func (clt *HttpClient[T]) GetJsonByURL(ctx context.Context, url string, jsonStruct *T) error { 27 | 28 | ctx, cancel := context.WithTimeout(ctx, time.Second*5) 29 | defer cancel() 30 | 31 | request, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) 32 | if err != nil { 33 | return err 34 | } 35 | 36 | res, err := clt.HttpClient.Do(request) 37 | 38 | if err != nil { 39 | return err 40 | } 41 | 42 | defer res.Body.Close() 43 | 44 | body, err := io.ReadAll(res.Body) 45 | 46 | if err != nil { 47 | return err 48 | } 49 | 50 | jsonErr := json.Unmarshal(body, jsonStruct) 51 | 52 | if jsonErr != nil { 53 | return err 54 | } 55 | 56 | return nil 57 | } 58 | -------------------------------------------------------------------------------- /internal/helpers/timeutils/timeutils.go: -------------------------------------------------------------------------------- 1 | // Package timeutils Хелпер для операций с датами и временем 2 | package timeutils 3 | 4 | import "time" 5 | 6 | // BeginOfMonth Функция возвращает момент начала месяца указанной даты. 7 | // Например, при t = "16.10.2022 15:22:30" функция вернет дату "01.10.2022 00:00:00" 8 | func BeginOfMonth(t time.Time) time.Time { 9 | return time.Date(t.Year(), t.Month(), 1, 0, 0, 0, 0, time.UTC) 10 | } 11 | 12 | // BeginOfNextMonth Функция возвращает момент начала следующего месяца указанной даты. 13 | // Например, при t = "16.10.2022 15:22:30" функция вернет дату "01.11.2022 00:00:00" 14 | func BeginOfNextMonth(t time.Time) time.Time { 15 | m := t.Month() + 1 16 | y := t.Year() 17 | if m > 12 { 18 | m = 1 19 | y++ 20 | } 21 | return time.Date(y, m, 1, 0, 0, 0, 0, time.UTC) 22 | } 23 | -------------------------------------------------------------------------------- /internal/logger/logger.go: -------------------------------------------------------------------------------- 1 | package logger 2 | 3 | import ( 4 | "log" 5 | 6 | "go.uber.org/zap" 7 | ) 8 | 9 | // Глобальная переменная логгера. 10 | var logger *zap.Logger 11 | 12 | // init Инициализация логгера для использования его во всем приложении. 13 | // init будет выполнен один раз, независимо от количества импортов в разных местах приложения. 14 | func init() { 15 | // Инициализация для режима разработки. 16 | localLogger, err := zap.NewDevelopment() 17 | // Вариант инициализации для продакшена. 18 | //localLogger, err := zap.NewProduction() 19 | 20 | if err != nil { 21 | log.Fatal("Ошибка инициализации логгера zap", err) 22 | } 23 | 24 | logger = localLogger 25 | 26 | } 27 | 28 | // Fatal - запись в лог, уровень Fatal. 29 | func Fatal(msg string, keysAndValues ...interface{}) { 30 | sugar := logger.Sugar() 31 | sugar.Fatalw(msg, keysAndValues...) 32 | } 33 | 34 | // Error - запись в лог, уровень Error. 35 | func Error(msg string, keysAndValues ...interface{}) { 36 | sugar := logger.Sugar() 37 | sugar.Errorw(msg, keysAndValues...) 38 | } 39 | 40 | // Warn - запись в лог, уровень Warn. 41 | func Warn(msg string, keysAndValues ...interface{}) { 42 | sugar := logger.Sugar() 43 | sugar.Warnw(msg, keysAndValues...) 44 | } 45 | 46 | // Info - запись в лог, уровень Info. 47 | func Info(msg string, keysAndValues ...interface{}) { 48 | sugar := logger.Sugar() 49 | sugar.Infow(msg, keysAndValues...) 50 | } 51 | 52 | // Debug - запись в лог, уровень Debug. 53 | func Debug(msg string, keysAndValues ...interface{}) { 54 | sugar := logger.Sugar() 55 | sugar.Debugw(msg, keysAndValues...) 56 | } 57 | 58 | // DebugZap - запись в лог, уровень DebugZap. 59 | func DebugZap(msg string, fields ...zap.Field) { 60 | logger.Debug(msg, fields...) 61 | } 62 | -------------------------------------------------------------------------------- /internal/metrics/metrics.go: -------------------------------------------------------------------------------- 1 | package metrics 2 | 3 | import ( 4 | tgbotapi "github.com/go-telegram-bot-api/telegram-bot-api/v5" 5 | "github.com/prometheus/client_golang/prometheus/promhttp" 6 | "github.com/ellavs/tg-bot-golang/internal/clients/tg" 7 | "github.com/ellavs/tg-bot-golang/internal/logger" 8 | "github.com/ellavs/tg-bot-golang/internal/model/messages" 9 | "net/http" 10 | "strings" 11 | "time" 12 | 13 | "github.com/prometheus/client_golang/prometheus" 14 | "github.com/prometheus/client_golang/prometheus/promauto" 15 | ) 16 | 17 | type TgHandler interface { 18 | RunFunc(tgUpdate tgbotapi.Update, c *tg.Client, msgModel *messages.Model) 19 | } 20 | 21 | // Метрики. 22 | var ( 23 | InFlightRequests = promauto.NewGauge(prometheus.GaugeOpts{ 24 | Namespace: "tg", 25 | Subsystem: "messages", 26 | Name: "messages_total", // Общее количество сообщений. 27 | }) 28 | SummaryResponseTime = promauto.NewSummary(prometheus.SummaryOpts{ 29 | Namespace: "tg", 30 | Subsystem: "messages", 31 | Name: "summary_response_time_seconds", // Время обработки сообщений. 32 | Objectives: map[float64]float64{ 33 | 0.5: 0.1, 34 | 0.9: 0.01, 35 | 0.99: 0.001, 36 | }, 37 | }) 38 | HistogramResponseTime = promauto.NewHistogramVec( 39 | prometheus.HistogramOpts{ 40 | Namespace: "tg", 41 | Subsystem: "messages", 42 | Name: "histogram_response_time_seconds", 43 | Buckets: []float64{0.0001, 0.0005, 0.001, 0.005, 0.01, 0.05, 0.1, 0.5, 1, 2}, 44 | }, 45 | []string{"cmd"}, 46 | ) 47 | ) 48 | 49 | var labels []string 50 | 51 | func init() { 52 | labels = []string{"start", "cat", "curr", "report", "add_tbl", "add_cat", "add_rec", "choice_currency", "set_limit"} 53 | // Для просмотра значений метрик по адресу http://127.0.0.1:8080/ 54 | http.Handle("/", promhttp.Handler()) 55 | logger.Info("Старт сервиса метрик.") 56 | go func() { 57 | err := http.ListenAndServe("0.0.0.0:8080", nil) 58 | if err != nil { 59 | logger.Error("Metrics public error", "err", err) 60 | } 61 | }() 62 | } 63 | 64 | // MetricsMiddleware Функция сбора метрик. 65 | func MetricsMiddleware(next tg.HandlerFunc) tg.HandlerFunc { 66 | 67 | handler := tg.HandlerFunc(func(tgUpdate tgbotapi.Update, c *tg.Client, msgModel *messages.Model) { 68 | 69 | // Сохранение времени начала обработки сообщения. 70 | startTime := time.Now() 71 | // Выполнение процесса обработки сообщения. 72 | next.RunFunc(tgUpdate, c, msgModel) 73 | // Расчет продолжительности обработки сообщения. 74 | duration := time.Since(startTime) 75 | 76 | // Сохранение метрик продолжительности обработки. 77 | SummaryResponseTime.Observe(duration.Seconds()) 78 | 79 | // Определение команды для сохранения в метрике. 80 | cmd := "none" 81 | msg := "" 82 | if tgUpdate.Message == nil { 83 | if tgUpdate.CallbackQuery != nil { 84 | msg = tgUpdate.CallbackQuery.Data 85 | } 86 | } else { 87 | msg = tgUpdate.Message.Text 88 | } 89 | if msg != "" { 90 | for _, lbl := range labels { 91 | if strings.Contains(msg, "/"+lbl) { 92 | cmd = lbl 93 | break 94 | } 95 | } 96 | } 97 | HistogramResponseTime. 98 | WithLabelValues(cmd). 99 | Observe(duration.Seconds()) 100 | 101 | }) 102 | // Подсчет количества сообщений. 103 | InFlightRequests.Dec() 104 | 105 | return handler 106 | } 107 | -------------------------------------------------------------------------------- /internal/mocks/cbr/cbr_mocks.go: -------------------------------------------------------------------------------- 1 | // Code generated by MockGen. DO NOT EDIT. 2 | // Source: internal/clients/cbr/cbrclient.go 3 | 4 | // Package mock_cbr is a generated GoMock package. 5 | package mock_cbr 6 | 7 | import ( 8 | context "context" 9 | reflect "reflect" 10 | 11 | gomock "github.com/golang/mock/gomock" 12 | ) 13 | 14 | // MockhttpClient is a mock of httpClient interface. 15 | type MockhttpClient struct { 16 | ctrl *gomock.Controller 17 | recorder *MockhttpClientMockRecorder 18 | } 19 | 20 | // MockhttpClientMockRecorder is the mock recorder for MockhttpClient. 21 | type MockhttpClientMockRecorder struct { 22 | mock *MockhttpClient 23 | } 24 | 25 | // NewMockhttpClient creates a new mock instance. 26 | func NewMockhttpClient(ctrl *gomock.Controller) *MockhttpClient { 27 | mock := &MockhttpClient{ctrl: ctrl} 28 | mock.recorder = &MockhttpClientMockRecorder{mock} 29 | return mock 30 | } 31 | 32 | // EXPECT returns an object that allows the caller to indicate expected use. 33 | func (m *MockhttpClient) EXPECT() *MockhttpClientMockRecorder { 34 | return m.recorder 35 | } 36 | 37 | // GetJsonByURL mocks base method. 38 | func (m *MockhttpClient) GetJsonByURL(ctx context.Context, url string, jsonStruct *interface{}) error { 39 | m.ctrl.T.Helper() 40 | ret := m.ctrl.Call(m, "GetJsonByURL", ctx, url, jsonStruct) 41 | ret0, _ := ret[0].(error) 42 | return ret0 43 | } 44 | 45 | // GetJsonByURL indicates an expected call of GetJsonByURL. 46 | func (mr *MockhttpClientMockRecorder) GetJsonByURL(ctx, url, jsonStruct interface{}) *gomock.Call { 47 | mr.mock.ctrl.T.Helper() 48 | return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetJsonByURL", reflect.TypeOf((*MockhttpClient)(nil).GetJsonByURL), ctx, url, jsonStruct) 49 | } 50 | -------------------------------------------------------------------------------- /internal/mocks/exchangerates/exchangerates_mocks.go: -------------------------------------------------------------------------------- 1 | // Code generated by MockGen. DO NOT EDIT. 2 | // Source: internal/model/exchangerates/exchangerates.go 3 | 4 | // Package mock_exchangerates is a generated GoMock package. 5 | package mock_exchangerates 6 | 7 | import ( 8 | context "context" 9 | reflect "reflect" 10 | time "time" 11 | 12 | gomock "github.com/golang/mock/gomock" 13 | bottypes "github.com/ellavs/tg-bot-golang/internal/model/bottypes" 14 | ) 15 | 16 | // MockCbrClient is a mock of CbrClient interface. 17 | type MockCbrClient struct { 18 | ctrl *gomock.Controller 19 | recorder *MockCbrClientMockRecorder 20 | } 21 | 22 | // MockCbrClientMockRecorder is the mock recorder for MockCbrClient. 23 | type MockCbrClientMockRecorder struct { 24 | mock *MockCbrClient 25 | } 26 | 27 | // NewMockCbrClient creates a new mock instance. 28 | func NewMockCbrClient(ctrl *gomock.Controller) *MockCbrClient { 29 | mock := &MockCbrClient{ctrl: ctrl} 30 | mock.recorder = &MockCbrClientMockRecorder{mock} 31 | return mock 32 | } 33 | 34 | // EXPECT returns an object that allows the caller to indicate expected use. 35 | func (m *MockCbrClient) EXPECT() *MockCbrClientMockRecorder { 36 | return m.recorder 37 | } 38 | 39 | // LoadExchangeRates mocks base method. 40 | func (m *MockCbrClient) LoadExchangeRates() (bottypes.ExchangeRate, time.Time, error) { 41 | m.ctrl.T.Helper() 42 | ret := m.ctrl.Call(m, "LoadExchangeRates") 43 | ret0, _ := ret[0].(bottypes.ExchangeRate) 44 | ret1, _ := ret[1].(time.Time) 45 | ret2, _ := ret[2].(error) 46 | return ret0, ret1, ret2 47 | } 48 | 49 | // LoadExchangeRates indicates an expected call of LoadExchangeRates. 50 | func (mr *MockCbrClientMockRecorder) LoadExchangeRates() *gomock.Call { 51 | mr.mock.ctrl.T.Helper() 52 | return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "LoadExchangeRates", reflect.TypeOf((*MockCbrClient)(nil).LoadExchangeRates)) 53 | } 54 | 55 | // MockRatesDataStorage is a mock of RatesDataStorage interface. 56 | type MockRatesDataStorage struct { 57 | ctrl *gomock.Controller 58 | recorder *MockRatesDataStorageMockRecorder 59 | } 60 | 61 | // MockRatesDataStorageMockRecorder is the mock recorder for MockRatesDataStorage. 62 | type MockRatesDataStorageMockRecorder struct { 63 | mock *MockRatesDataStorage 64 | } 65 | 66 | // NewMockRatesDataStorage creates a new mock instance. 67 | func NewMockRatesDataStorage(ctrl *gomock.Controller) *MockRatesDataStorage { 68 | mock := &MockRatesDataStorage{ctrl: ctrl} 69 | mock.recorder = &MockRatesDataStorageMockRecorder{mock} 70 | return mock 71 | } 72 | 73 | // EXPECT returns an object that allows the caller to indicate expected use. 74 | func (m *MockRatesDataStorage) EXPECT() *MockRatesDataStorageMockRecorder { 75 | return m.recorder 76 | } 77 | 78 | // GetLastExchangeRates mocks base method. 79 | func (m *MockRatesDataStorage) GetLastExchangeRates(ctx context.Context) (bottypes.ExchangeRate, error) { 80 | m.ctrl.T.Helper() 81 | ret := m.ctrl.Call(m, "GetLastExchangeRates", ctx) 82 | ret0, _ := ret[0].(bottypes.ExchangeRate) 83 | ret1, _ := ret[1].(error) 84 | return ret0, ret1 85 | } 86 | 87 | // GetLastExchangeRates indicates an expected call of GetLastExchangeRates. 88 | func (mr *MockRatesDataStorageMockRecorder) GetLastExchangeRates(ctx interface{}) *gomock.Call { 89 | mr.mock.ctrl.T.Helper() 90 | return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetLastExchangeRates", reflect.TypeOf((*MockRatesDataStorage)(nil).GetLastExchangeRates), ctx) 91 | } 92 | 93 | // InsertExchangeRatesToDate mocks base method. 94 | func (m *MockRatesDataStorage) InsertExchangeRatesToDate(ctx context.Context, rates bottypes.ExchangeRate, period time.Time) error { 95 | m.ctrl.T.Helper() 96 | ret := m.ctrl.Call(m, "InsertExchangeRatesToDate", ctx, rates, period) 97 | ret0, _ := ret[0].(error) 98 | return ret0 99 | } 100 | 101 | // InsertExchangeRatesToDate indicates an expected call of InsertExchangeRatesToDate. 102 | func (mr *MockRatesDataStorageMockRecorder) InsertExchangeRatesToDate(ctx, rates, period interface{}) *gomock.Call { 103 | mr.mock.ctrl.T.Helper() 104 | return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "InsertExchangeRatesToDate", reflect.TypeOf((*MockRatesDataStorage)(nil).InsertExchangeRatesToDate), ctx, rates, period) 105 | } 106 | -------------------------------------------------------------------------------- /internal/mocks/messages/messages_mocks.go: -------------------------------------------------------------------------------- 1 | // Code generated by MockGen. DO NOT EDIT. 2 | // Source: internal/model/messages/incoming_msg.go 3 | 4 | // Package mock_messages is a generated GoMock package. 5 | package mock_messages 6 | 7 | import ( 8 | context "context" 9 | reflect "reflect" 10 | time "time" 11 | 12 | gomock "github.com/golang/mock/gomock" 13 | bottypes "github.com/ellavs/tg-bot-golang/internal/model/bottypes" 14 | ) 15 | 16 | // MockMessageSender is a mock of MessageSender interface. 17 | type MockMessageSender struct { 18 | ctrl *gomock.Controller 19 | recorder *MockMessageSenderMockRecorder 20 | } 21 | 22 | // MockMessageSenderMockRecorder is the mock recorder for MockMessageSender. 23 | type MockMessageSenderMockRecorder struct { 24 | mock *MockMessageSender 25 | } 26 | 27 | // NewMockMessageSender creates a new mock instance. 28 | func NewMockMessageSender(ctrl *gomock.Controller) *MockMessageSender { 29 | mock := &MockMessageSender{ctrl: ctrl} 30 | mock.recorder = &MockMessageSenderMockRecorder{mock} 31 | return mock 32 | } 33 | 34 | // EXPECT returns an object that allows the caller to indicate expected use. 35 | func (m *MockMessageSender) EXPECT() *MockMessageSenderMockRecorder { 36 | return m.recorder 37 | } 38 | 39 | // SendMessage mocks base method. 40 | func (m *MockMessageSender) SendMessage(text string, userID int64) error { 41 | m.ctrl.T.Helper() 42 | ret := m.ctrl.Call(m, "SendMessage", text, userID) 43 | ret0, _ := ret[0].(error) 44 | return ret0 45 | } 46 | 47 | // SendMessage indicates an expected call of SendMessage. 48 | func (mr *MockMessageSenderMockRecorder) SendMessage(text, userID interface{}) *gomock.Call { 49 | mr.mock.ctrl.T.Helper() 50 | return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SendMessage", reflect.TypeOf((*MockMessageSender)(nil).SendMessage), text, userID) 51 | } 52 | 53 | // ShowInlineButtons mocks base method. 54 | func (m *MockMessageSender) ShowInlineButtons(text string, buttons []bottypes.TgRowButtons, userID int64) error { 55 | m.ctrl.T.Helper() 56 | ret := m.ctrl.Call(m, "ShowInlineButtons", text, buttons, userID) 57 | ret0, _ := ret[0].(error) 58 | return ret0 59 | } 60 | 61 | // ShowInlineButtons indicates an expected call of ShowInlineButtons. 62 | func (mr *MockMessageSenderMockRecorder) ShowInlineButtons(text, buttons, userID interface{}) *gomock.Call { 63 | mr.mock.ctrl.T.Helper() 64 | return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ShowInlineButtons", reflect.TypeOf((*MockMessageSender)(nil).ShowInlineButtons), text, buttons, userID) 65 | } 66 | 67 | // MockUserDataStorage is a mock of UserDataStorage interface. 68 | type MockUserDataStorage struct { 69 | ctrl *gomock.Controller 70 | recorder *MockUserDataStorageMockRecorder 71 | } 72 | 73 | // MockUserDataStorageMockRecorder is the mock recorder for MockUserDataStorage. 74 | type MockUserDataStorageMockRecorder struct { 75 | mock *MockUserDataStorage 76 | } 77 | 78 | // NewMockUserDataStorage creates a new mock instance. 79 | func NewMockUserDataStorage(ctrl *gomock.Controller) *MockUserDataStorage { 80 | mock := &MockUserDataStorage{ctrl: ctrl} 81 | mock.recorder = &MockUserDataStorageMockRecorder{mock} 82 | return mock 83 | } 84 | 85 | // EXPECT returns an object that allows the caller to indicate expected use. 86 | func (m *MockUserDataStorage) EXPECT() *MockUserDataStorageMockRecorder { 87 | return m.recorder 88 | } 89 | 90 | // GetUserCategory mocks base method. 91 | func (m *MockUserDataStorage) GetUserCategory(ctx context.Context, userID int64) ([]string, error) { 92 | m.ctrl.T.Helper() 93 | ret := m.ctrl.Call(m, "GetUserCategory", ctx, userID) 94 | ret0, _ := ret[0].([]string) 95 | ret1, _ := ret[1].(error) 96 | return ret0, ret1 97 | } 98 | 99 | // GetUserCategory indicates an expected call of GetUserCategory. 100 | func (mr *MockUserDataStorageMockRecorder) GetUserCategory(ctx, userID interface{}) *gomock.Call { 101 | mr.mock.ctrl.T.Helper() 102 | return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetUserCategory", reflect.TypeOf((*MockUserDataStorage)(nil).GetUserCategory), ctx, userID) 103 | } 104 | 105 | // GetUserCurrency mocks base method. 106 | func (m *MockUserDataStorage) GetUserCurrency(ctx context.Context, userID int64) (string, error) { 107 | m.ctrl.T.Helper() 108 | ret := m.ctrl.Call(m, "GetUserCurrency", ctx, userID) 109 | ret0, _ := ret[0].(string) 110 | ret1, _ := ret[1].(error) 111 | return ret0, ret1 112 | } 113 | 114 | // GetUserCurrency indicates an expected call of GetUserCurrency. 115 | func (mr *MockUserDataStorageMockRecorder) GetUserCurrency(ctx, userID interface{}) *gomock.Call { 116 | mr.mock.ctrl.T.Helper() 117 | return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetUserCurrency", reflect.TypeOf((*MockUserDataStorage)(nil).GetUserCurrency), ctx, userID) 118 | } 119 | 120 | // GetUserDataRecord mocks base method. 121 | func (m *MockUserDataStorage) GetUserDataRecord(ctx context.Context, userID int64, period time.Time) ([]bottypes.UserDataReportRecord, error) { 122 | m.ctrl.T.Helper() 123 | ret := m.ctrl.Call(m, "GetUserDataRecord", ctx, userID, period) 124 | ret0, _ := ret[0].([]bottypes.UserDataReportRecord) 125 | ret1, _ := ret[1].(error) 126 | return ret0, ret1 127 | } 128 | 129 | // GetUserDataRecord indicates an expected call of GetUserDataRecord. 130 | func (mr *MockUserDataStorageMockRecorder) GetUserDataRecord(ctx, userID, period interface{}) *gomock.Call { 131 | mr.mock.ctrl.T.Helper() 132 | return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetUserDataRecord", reflect.TypeOf((*MockUserDataStorage)(nil).GetUserDataRecord), ctx, userID, period) 133 | } 134 | 135 | // GetUserLimit mocks base method. 136 | func (m *MockUserDataStorage) GetUserLimit(ctx context.Context, userID int64) (int64, error) { 137 | m.ctrl.T.Helper() 138 | ret := m.ctrl.Call(m, "GetUserLimit", ctx, userID) 139 | ret0, _ := ret[0].(int64) 140 | ret1, _ := ret[1].(error) 141 | return ret0, ret1 142 | } 143 | 144 | // GetUserLimit indicates an expected call of GetUserLimit. 145 | func (mr *MockUserDataStorageMockRecorder) GetUserLimit(ctx, userID interface{}) *gomock.Call { 146 | mr.mock.ctrl.T.Helper() 147 | return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetUserLimit", reflect.TypeOf((*MockUserDataStorage)(nil).GetUserLimit), ctx, userID) 148 | } 149 | 150 | // InsertCategory mocks base method. 151 | func (m *MockUserDataStorage) InsertCategory(ctx context.Context, userID int64, catName, userName string) error { 152 | m.ctrl.T.Helper() 153 | ret := m.ctrl.Call(m, "InsertCategory", ctx, userID, catName, userName) 154 | ret0, _ := ret[0].(error) 155 | return ret0 156 | } 157 | 158 | // InsertCategory indicates an expected call of InsertCategory. 159 | func (mr *MockUserDataStorageMockRecorder) InsertCategory(ctx, userID, catName, userName interface{}) *gomock.Call { 160 | mr.mock.ctrl.T.Helper() 161 | return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "InsertCategory", reflect.TypeOf((*MockUserDataStorage)(nil).InsertCategory), ctx, userID, catName, userName) 162 | } 163 | 164 | // InsertUserDataRecord mocks base method. 165 | func (m *MockUserDataStorage) InsertUserDataRecord(ctx context.Context, userID int64, rec bottypes.UserDataRecord, userName string, limitPeriod time.Time) (bool, error) { 166 | m.ctrl.T.Helper() 167 | ret := m.ctrl.Call(m, "InsertUserDataRecord", ctx, userID, rec, userName, limitPeriod) 168 | ret0, _ := ret[0].(bool) 169 | ret1, _ := ret[1].(error) 170 | return ret0, ret1 171 | } 172 | 173 | // InsertUserDataRecord indicates an expected call of InsertUserDataRecord. 174 | func (mr *MockUserDataStorageMockRecorder) InsertUserDataRecord(ctx, userID, rec, userName, limitPeriod interface{}) *gomock.Call { 175 | mr.mock.ctrl.T.Helper() 176 | return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "InsertUserDataRecord", reflect.TypeOf((*MockUserDataStorage)(nil).InsertUserDataRecord), ctx, userID, rec, userName, limitPeriod) 177 | } 178 | 179 | // SetUserCurrency mocks base method. 180 | func (m *MockUserDataStorage) SetUserCurrency(ctx context.Context, userID int64, currencyName, userName string) error { 181 | m.ctrl.T.Helper() 182 | ret := m.ctrl.Call(m, "SetUserCurrency", ctx, userID, currencyName, userName) 183 | ret0, _ := ret[0].(error) 184 | return ret0 185 | } 186 | 187 | // SetUserCurrency indicates an expected call of SetUserCurrency. 188 | func (mr *MockUserDataStorageMockRecorder) SetUserCurrency(ctx, userID, currencyName, userName interface{}) *gomock.Call { 189 | mr.mock.ctrl.T.Helper() 190 | return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SetUserCurrency", reflect.TypeOf((*MockUserDataStorage)(nil).SetUserCurrency), ctx, userID, currencyName, userName) 191 | } 192 | 193 | // SetUserLimit mocks base method. 194 | func (m *MockUserDataStorage) SetUserLimit(ctx context.Context, userID, limits int64, userName string) error { 195 | m.ctrl.T.Helper() 196 | ret := m.ctrl.Call(m, "SetUserLimit", ctx, userID, limits, userName) 197 | ret0, _ := ret[0].(error) 198 | return ret0 199 | } 200 | 201 | // SetUserLimit indicates an expected call of SetUserLimit. 202 | func (mr *MockUserDataStorageMockRecorder) SetUserLimit(ctx, userID, limits, userName interface{}) *gomock.Call { 203 | mr.mock.ctrl.T.Helper() 204 | return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SetUserLimit", reflect.TypeOf((*MockUserDataStorage)(nil).SetUserLimit), ctx, userID, limits, userName) 205 | } 206 | 207 | // MockExchangeRates is a mock of ExchangeRates interface. 208 | type MockExchangeRates struct { 209 | ctrl *gomock.Controller 210 | recorder *MockExchangeRatesMockRecorder 211 | } 212 | 213 | // MockExchangeRatesMockRecorder is the mock recorder for MockExchangeRates. 214 | type MockExchangeRatesMockRecorder struct { 215 | mock *MockExchangeRates 216 | } 217 | 218 | // NewMockExchangeRates creates a new mock instance. 219 | func NewMockExchangeRates(ctrl *gomock.Controller) *MockExchangeRates { 220 | mock := &MockExchangeRates{ctrl: ctrl} 221 | mock.recorder = &MockExchangeRatesMockRecorder{mock} 222 | return mock 223 | } 224 | 225 | // EXPECT returns an object that allows the caller to indicate expected use. 226 | func (m *MockExchangeRates) EXPECT() *MockExchangeRatesMockRecorder { 227 | return m.recorder 228 | } 229 | 230 | // ConvertSumFromBaseToCurrency mocks base method. 231 | func (m *MockExchangeRates) ConvertSumFromBaseToCurrency(currencyName string, sum int64) (int64, error) { 232 | m.ctrl.T.Helper() 233 | ret := m.ctrl.Call(m, "ConvertSumFromBaseToCurrency", currencyName, sum) 234 | ret0, _ := ret[0].(int64) 235 | ret1, _ := ret[1].(error) 236 | return ret0, ret1 237 | } 238 | 239 | // ConvertSumFromBaseToCurrency indicates an expected call of ConvertSumFromBaseToCurrency. 240 | func (mr *MockExchangeRatesMockRecorder) ConvertSumFromBaseToCurrency(currencyName, sum interface{}) *gomock.Call { 241 | mr.mock.ctrl.T.Helper() 242 | return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ConvertSumFromBaseToCurrency", reflect.TypeOf((*MockExchangeRates)(nil).ConvertSumFromBaseToCurrency), currencyName, sum) 243 | } 244 | 245 | // ConvertSumFromCurrencyToBase mocks base method. 246 | func (m *MockExchangeRates) ConvertSumFromCurrencyToBase(currencyName string, sum int64) (int64, error) { 247 | m.ctrl.T.Helper() 248 | ret := m.ctrl.Call(m, "ConvertSumFromCurrencyToBase", currencyName, sum) 249 | ret0, _ := ret[0].(int64) 250 | ret1, _ := ret[1].(error) 251 | return ret0, ret1 252 | } 253 | 254 | // ConvertSumFromCurrencyToBase indicates an expected call of ConvertSumFromCurrencyToBase. 255 | func (mr *MockExchangeRatesMockRecorder) ConvertSumFromCurrencyToBase(currencyName, sum interface{}) *gomock.Call { 256 | mr.mock.ctrl.T.Helper() 257 | return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ConvertSumFromCurrencyToBase", reflect.TypeOf((*MockExchangeRates)(nil).ConvertSumFromCurrencyToBase), currencyName, sum) 258 | } 259 | 260 | // GetCurrenciesList mocks base method. 261 | func (m *MockExchangeRates) GetCurrenciesList() []string { 262 | m.ctrl.T.Helper() 263 | ret := m.ctrl.Call(m, "GetCurrenciesList") 264 | ret0, _ := ret[0].([]string) 265 | return ret0 266 | } 267 | 268 | // GetCurrenciesList indicates an expected call of GetCurrenciesList. 269 | func (mr *MockExchangeRatesMockRecorder) GetCurrenciesList() *gomock.Call { 270 | mr.mock.ctrl.T.Helper() 271 | return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetCurrenciesList", reflect.TypeOf((*MockExchangeRates)(nil).GetCurrenciesList)) 272 | } 273 | 274 | // GetExchangeRate mocks base method. 275 | func (m *MockExchangeRates) GetExchangeRate(currencyName string) (float64, error) { 276 | m.ctrl.T.Helper() 277 | ret := m.ctrl.Call(m, "GetExchangeRate", currencyName) 278 | ret0, _ := ret[0].(float64) 279 | ret1, _ := ret[1].(error) 280 | return ret0, ret1 281 | } 282 | 283 | // GetExchangeRate indicates an expected call of GetExchangeRate. 284 | func (mr *MockExchangeRatesMockRecorder) GetExchangeRate(currencyName interface{}) *gomock.Call { 285 | mr.mock.ctrl.T.Helper() 286 | return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetExchangeRate", reflect.TypeOf((*MockExchangeRates)(nil).GetExchangeRate), currencyName) 287 | } 288 | 289 | // GetMainCurrency mocks base method. 290 | func (m *MockExchangeRates) GetMainCurrency() string { 291 | m.ctrl.T.Helper() 292 | ret := m.ctrl.Call(m, "GetMainCurrency") 293 | ret0, _ := ret[0].(string) 294 | return ret0 295 | } 296 | 297 | // GetMainCurrency indicates an expected call of GetMainCurrency. 298 | func (mr *MockExchangeRatesMockRecorder) GetMainCurrency() *gomock.Call { 299 | mr.mock.ctrl.T.Helper() 300 | return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetMainCurrency", reflect.TypeOf((*MockExchangeRates)(nil).GetMainCurrency)) 301 | } 302 | -------------------------------------------------------------------------------- /internal/model/bottypes/bottypes.go: -------------------------------------------------------------------------------- 1 | package bottypes 2 | 3 | import ( 4 | "time" 5 | ) 6 | 7 | type Empty struct{} 8 | 9 | // Множество уникальных категорий покупок пользователя 10 | type UserCategorySet map[string]Empty 11 | 12 | // Тип для записей о тратах. 13 | type UserDataRecord struct { 14 | UserID int64 15 | Category string 16 | Sum int64 17 | Period time.Time 18 | } 19 | 20 | // Тип для записей отчета. 21 | type UserDataReportRecord struct { 22 | Category string // Категория. 23 | Sum float64 // Сумма расходов по категории. 24 | } 25 | 26 | // Типы для описания состава кнопок телеграм сообщения. 27 | // Кнопка сообщения. 28 | type TgInlineButton struct { 29 | DisplayName string 30 | Value string 31 | } 32 | 33 | // Строка с кнопками сообщения. 34 | type TgRowButtons []TgInlineButton 35 | 36 | // Тип для хранения курса валюты в формате "USD" = 0.01659657 37 | type ExchangeRate map[string]float64 38 | -------------------------------------------------------------------------------- /internal/model/db/ratesstorage.go: -------------------------------------------------------------------------------- 1 | package db 2 | 3 | // Работа с хранилищем курсов валют. 4 | 5 | import ( 6 | "context" 7 | "time" 8 | 9 | "github.com/jmoiron/sqlx" 10 | "github.com/ellavs/tg-bot-golang/internal/helpers/dbutils" 11 | types "github.com/ellavs/tg-bot-golang/internal/model/bottypes" 12 | ) 13 | 14 | // ExchangeRatesStorage Тип для хранилища курсов валют. 15 | type ExchangeRatesStorage struct { 16 | db *sqlx.DB 17 | usedCurrenciesNames []string 18 | } 19 | 20 | // RateDB Тип, принимающий структуру таблицы курсов валют. 21 | type RateDB struct { 22 | Name string `db:"currency"` 23 | Rate float64 `db:"rate"` 24 | } 25 | 26 | // NewExchangeRatesStorage Инициализация хранилища курсов валют. 27 | // db - *sqlx.DB - ссылка на подключение к БД. 28 | // usedCurrenciesNames - []string - массив используемых в приложении валют. 29 | func NewExchangeRatesStorage(db *sqlx.DB, usedCurrenciesNames []string) *ExchangeRatesStorage { 30 | return &ExchangeRatesStorage{ 31 | db: db, 32 | usedCurrenciesNames: usedCurrenciesNames, 33 | } 34 | } 35 | 36 | // InsertExchangeRatesToDate Добавление курсов валют в базу данных 37 | func (storage *ExchangeRatesStorage) InsertExchangeRatesToDate(ctx context.Context, rates types.ExchangeRate, period time.Time) error { 38 | // Преобразование map в слайсы по используемым валютам для удобства вставки в БД. 39 | ratesNames := make([]string, len(storage.usedCurrenciesNames)-1) 40 | ratesValues := make([]float64, len(storage.usedCurrenciesNames)-1) 41 | for ind, cName := range storage.usedCurrenciesNames { 42 | if rate, ok := rates[cName]; ok { 43 | ratesNames[ind] = cName 44 | ratesValues[ind] = rate 45 | } 46 | } 47 | 48 | // Запрос на добавление данных. 49 | const sqlString = ` 50 | INSERT INTO exchangerates (currency, rate, period) 51 | SELECT *, $1 FROM unnest($2::text[], $3::float[]) 52 | ON CONFLICT (currency, period) DO NOTHING` 53 | 54 | // Выполнение запроса на добавление данных. 55 | _, err := dbutils.Exec(ctx, storage.db, sqlString, period, ratesNames, ratesValues) 56 | if err != nil { 57 | return err 58 | } 59 | 60 | return nil 61 | } 62 | 63 | // GetLastExchangeRates Получение последних курсов валют из базы данных. 64 | func (storage *ExchangeRatesStorage) GetLastExchangeRates(ctx context.Context) (types.ExchangeRate, error) { 65 | // Отбор последних курсов заданных валют. 66 | const sqlString = ` 67 | SELECT rt.currency, rt.rate 68 | FROM exchangerates AS rt 69 | INNER JOIN (SELECT currency, 70 | MAX(period) AS maxperiod 71 | FROM exchangerates 72 | WHERE currency = ANY($1) 73 | GROUP BY currency) 74 | AS rtMaxPeriod 75 | ON rt.period = rtMaxPeriod.maxperiod 76 | AND rt.currency = rtMaxPeriod.currency;` 77 | 78 | var rates []RateDB 79 | // Выполнение запроса на выборку данных (запись в переменную rates). 80 | if err := dbutils.Select(ctx, storage.db, &rates, sqlString, storage.usedCurrenciesNames); err != nil { 81 | return nil, err 82 | } 83 | 84 | exchangeRates := types.ExchangeRate{} 85 | for _, rate := range rates { 86 | exchangeRates[rate.Name] = rate.Rate 87 | } 88 | return exchangeRates, nil 89 | } 90 | -------------------------------------------------------------------------------- /internal/model/db/usersstorage.go: -------------------------------------------------------------------------------- 1 | // Package db - Работа с хранилищами (базой данных). 2 | package db 3 | 4 | // Работа с хранилищем информации о пользователях. 5 | 6 | import ( 7 | "context" 8 | "time" 9 | 10 | "github.com/jmoiron/sqlx" 11 | "github.com/pkg/errors" 12 | "github.com/ellavs/tg-bot-golang/internal/helpers/dbutils" 13 | "github.com/ellavs/tg-bot-golang/internal/helpers/timeutils" 14 | types "github.com/ellavs/tg-bot-golang/internal/model/bottypes" 15 | ) 16 | 17 | // UserDataReportRecordDB - Тип, принимающий структуру записей о расходах. 18 | type UserDataReportRecordDB struct { 19 | Category string `db:"name"` 20 | Sum int64 `db:"sum"` 21 | } 22 | 23 | // UserStorage - Тип для хранилища информации о пользователях. 24 | type UserStorage struct { 25 | db *sqlx.DB 26 | defaultCurrency string 27 | defaultLimits int64 28 | } 29 | 30 | // NewUserStorage - Инициализация хранилища информации о пользователях. 31 | // db - *sqlx.DB - ссылка на подключение к БД. 32 | // defaultCurrency - string - валюта по умолчанию. 33 | // defaultLimits - int64 - бюджет по умолчанию. 34 | func NewUserStorage(db *sqlx.DB, defaultCurrency string, defaultLimits int64) *UserStorage { 35 | return &UserStorage{ 36 | db: db, 37 | defaultCurrency: defaultCurrency, 38 | defaultLimits: defaultLimits, 39 | } 40 | } 41 | 42 | // InsertUser Добавление пользователя в базу данных. 43 | func (storage *UserStorage) InsertUser(ctx context.Context, userID int64, userName string) error { 44 | // Запрос на добавление данных. 45 | const sqlString = ` 46 | INSERT INTO users (tg_id, name, currency, limits) 47 | VALUES ($1, $2, $3, $4) 48 | ON CONFLICT (tg_id) DO NOTHING;` 49 | 50 | // Выполнение запроса на добавление данных. 51 | if _, err := dbutils.Exec(ctx, storage.db, sqlString, userID, userName, storage.defaultCurrency, storage.defaultLimits); err != nil { 52 | return err 53 | } 54 | return nil 55 | } 56 | 57 | // CheckIfUserExist Проверка существования пользователя в базе данных. 58 | func (storage *UserStorage) CheckIfUserExist(ctx context.Context, userID int64) (bool, error) { 59 | // Запрос на выборку пользователя. 60 | const sqlString = `SELECT COUNT(id) AS countusers FROM users WHERE tg_id = $1;` 61 | 62 | // Выполнение запроса на получение данных. 63 | cnt, err := dbutils.GetMap(ctx, storage.db, sqlString, userID) 64 | if err != nil { 65 | return false, err 66 | } 67 | // Приведение результата запроса к нужному типу. 68 | countusers, ok := cnt["countusers"].(int64) 69 | if !ok { 70 | return false, errors.New("Ошибка приведения типа результата запроса.") 71 | } 72 | if countusers == 0 { 73 | return false, nil 74 | } 75 | return true, nil 76 | } 77 | 78 | // CheckIfUserExistAndAdd Проверка существования пользователя в базе данных добавление, если не существует. 79 | func (storage *UserStorage) CheckIfUserExistAndAdd(ctx context.Context, userID int64, userName string) (bool, error) { 80 | exist, err := storage.CheckIfUserExist(ctx, userID) 81 | if err != nil { 82 | return false, err 83 | } 84 | if !exist { 85 | // Добавление пользователя в базу, если не существует. 86 | err := storage.InsertUser(ctx, userID, userName) 87 | if err != nil { 88 | return false, err 89 | } 90 | } 91 | return true, nil 92 | } 93 | 94 | // InsertUserDataRecord Добавление записи о расходах пользователя (в транзакции с проверкой превышения лимита). 95 | func (storage *UserStorage) InsertUserDataRecord(ctx context.Context, userID int64, rec types.UserDataRecord, userName string, limitPeriod time.Time) (bool, error) { 96 | // Проверка существования пользователя в БД. 97 | _, err := storage.CheckIfUserExistAndAdd(ctx, userID, userName) 98 | if err != nil { 99 | return false, err 100 | } 101 | 102 | // Проверка, что не превышен лимит расходов. 103 | isOverLimit, err := checkIfUserOverLimit(ctx, storage.db, userID, limitPeriod) 104 | if err != nil { 105 | return false, err 106 | } 107 | if isOverLimit { 108 | return true, nil 109 | } 110 | 111 | // Запуск транзакции. 112 | err = dbutils.RunTx(ctx, storage.db, 113 | // Функция, выполняемая внутри транзакции. 114 | // Если функция вернет ошибку, произойдет откат транзакции. 115 | func(tx *sqlx.Tx) error { 116 | isOverLimit, err = insertUserDataRecordTx(ctx, tx, userID, rec, limitPeriod) 117 | return err 118 | }) 119 | 120 | // Функция возвращает признак isOverLimit: 121 | // - true - запись не добавлена из-за превышения лимита, 122 | // - false - запись добавлена (при err == nil). 123 | return isOverLimit, err 124 | } 125 | 126 | // GetUserDataRecord Получение информации о расходах по категориям за период. 127 | func (storage *UserStorage) GetUserDataRecord(ctx context.Context, userID int64, period time.Time) ([]types.UserDataReportRecord, error) { 128 | // Отбор записей по пользователю за указанный период с группировкой по категориям. 129 | const sqlString = ` 130 | SELECT c.name, SUM(r.sum) 131 | FROM usermoneytransactions AS r 132 | INNER JOIN usercategories AS c 133 | ON r.category_id = c.id 134 | INNER JOIN users AS u 135 | ON r.user_id = u.id 136 | WHERE u.tg_id = $1 AND r.period >= $2 137 | GROUP BY c.name 138 | ORDER BY c.name;` 139 | 140 | var recs []UserDataReportRecordDB 141 | // Выполнение запроса на выборку данных (запись в переменную recs). 142 | if err := dbutils.Select(ctx, storage.db, &recs, sqlString, userID, period); err != nil { 143 | return nil, errors.Wrap(err, "Get user data record error") 144 | } 145 | 146 | result := make([]types.UserDataReportRecord, len(recs)) 147 | for ind, rec := range recs { 148 | result[ind] = types.UserDataReportRecord{ 149 | Category: rec.Category, 150 | Sum: float64(rec.Sum), 151 | } 152 | } 153 | return result, nil 154 | } 155 | 156 | // InsertCategory Добавление категории пользователя. 157 | func (storage *UserStorage) InsertCategory(ctx context.Context, userID int64, catName string, userName string) error { 158 | // Проверка существования пользователя в БД. 159 | _, err := storage.CheckIfUserExistAndAdd(ctx, userID, userName) 160 | if err != nil { 161 | return err 162 | } 163 | // Обрезка до 30 символов для удобства дальнейших отчетов. 164 | if len(catName) > 30 { 165 | catName = string(catName[:30]) 166 | } 167 | // Запрос на добавление данных. 168 | const sqlString = ` 169 | INSERT INTO usercategories (user_id, name) 170 | (SELECT id, $1 FROM users WHERE users.tg_id = $2) 171 | ON CONFLICT (user_id, lower(name)) DO NOTHING;` 172 | 173 | // Выполнение запроса на добавление данных. 174 | if _, err := dbutils.Exec(ctx, storage.db, sqlString, catName, userID); err != nil { 175 | return err 176 | } 177 | return nil 178 | } 179 | 180 | // GetUserCategory Получение списка категорий пользователя. 181 | func (storage *UserStorage) GetUserCategory(ctx context.Context, userID int64) ([]string, error) { 182 | // Отбор категорий по пользователю. 183 | const sqlString = ` 184 | SELECT c.name 185 | FROM usercategories AS c 186 | INNER JOIN users AS u 187 | ON c.user_id = u.id 188 | WHERE u.tg_id = $1 189 | GROUP BY c.name 190 | ORDER BY c.name;` 191 | 192 | var recs []string 193 | // Выполнение запроса на выборку данных (запись в переменную recs). 194 | if err := dbutils.Select(ctx, storage.db, &recs, sqlString, userID); err != nil { 195 | return nil, errors.Wrap(err, "Get user category error") 196 | } 197 | return recs, nil 198 | } 199 | 200 | // GetUserCurrency Получение выбранной валюты пользователя. 201 | func (storage *UserStorage) GetUserCurrency(ctx context.Context, userID int64) (string, error) { 202 | // Получение валюты по пользователю. 203 | const sqlString = `SELECT currency FROM users WHERE tg_id = $1;` 204 | 205 | // Выполнение запроса на выборку данных (запись результата запроса в map). 206 | result, err := dbutils.GetMap(ctx, storage.db, sqlString, userID) 207 | if err != nil { 208 | return "", errors.Wrap(err, "Get user currency error") 209 | } 210 | // Приведение результата запроса к нужному типу. 211 | currency, ok := result["currency"].(string) 212 | if !ok { 213 | return "", errors.New("Ошибка приведения типа результата запроса.") 214 | } 215 | return currency, nil 216 | } 217 | 218 | // SetUserCurrency Сохранение выбранной валюты пользователя. 219 | func (storage *UserStorage) SetUserCurrency(ctx context.Context, userID int64, currencyName string, userName string) error { 220 | // Проверка существования пользователя в БД. 221 | _, err := storage.CheckIfUserExistAndAdd(ctx, userID, userName) 222 | if err != nil { 223 | return err 224 | } 225 | // Запрос на обновление данных. 226 | const sqlString = `UPDATE users SET currency = $1 WHERE tg_id = $2;` 227 | 228 | // Выполнение запроса на обновление данных. 229 | if _, err := dbutils.Exec(ctx, storage.db, sqlString, currencyName, userID); err != nil { 230 | return err 231 | } 232 | return nil 233 | } 234 | 235 | // GetUserLimit Получение бюджета пользователя. 236 | func (storage *UserStorage) GetUserLimit(ctx context.Context, userID int64) (int64, error) { 237 | // Получение бюджета по пользователю. 238 | const sqlString = `SELECT limits FROM users WHERE tg_id = $1;` 239 | 240 | // Выполнение запроса на выборку данных (запись результата запроса в map). 241 | result, err := dbutils.GetMap(ctx, storage.db, sqlString, userID) 242 | if err != nil { 243 | return 0, errors.Wrap(err, "Get user limits error") 244 | } 245 | // Приведение результата запроса к нужному типу. 246 | limits, ok := result["limits"].(int64) 247 | if !ok { 248 | return 0, errors.New("Ошибка приведения типа результата запроса.") 249 | } 250 | return limits, nil 251 | } 252 | 253 | // SetUserLimit Сохранение бюджета пользователя. 254 | func (storage *UserStorage) SetUserLimit(ctx context.Context, userID int64, limits int64, userName string) error { 255 | // Проверка существования пользователя в БД. 256 | _, err := storage.CheckIfUserExistAndAdd(ctx, userID, userName) 257 | if err != nil { 258 | return err 259 | } 260 | // Запрос на обновление данных. 261 | const sqlString = `UPDATE users SET limits = $1 WHERE tg_id = $2;` 262 | 263 | // Выполнение запроса на обновление данных. 264 | if _, err := dbutils.Exec(ctx, storage.db, sqlString, limits, userID); err != nil { 265 | return err 266 | } 267 | return nil 268 | } 269 | 270 | // checkIfUserOverLimit Проверка, что текущие расходы пользователя не превзошли лимит (можно вызывать внутри транзакции). 271 | func checkIfUserOverLimit(ctx context.Context, db sqlx.QueryerContext, userID int64, period time.Time) (bool, error) { 272 | // Проверка наличия записей о расходах. 273 | isRecsExist, err := checkIfUserRecordsExistInPeriod(ctx, db, userID, period) 274 | if err != nil { 275 | return false, err 276 | } 277 | if !isRecsExist { 278 | // Записей о расходах в указанном периоде нет, превышения нет. 279 | return false, nil 280 | } 281 | 282 | // Запрос на проверку лимита. 283 | const sqlString = ` 284 | SELECT SUM(r.sum) > u.limits AND NOT u.limits = 0 AS overlimit 285 | FROM usermoneytransactions AS r 286 | INNER JOIN users AS u 287 | ON r.user_id = u.id 288 | WHERE u.tg_id = $1 AND r.period >= $2 AND r.period < $3 289 | GROUP BY u.limits;` 290 | 291 | // Выполнение запроса на получение данных. 292 | res, err := dbutils.GetMap(ctx, db, sqlString, userID, period, timeutils.BeginOfNextMonth(period)) 293 | if err != nil { 294 | return false, err 295 | } 296 | // Приведение результата запроса к нужному типу. 297 | overlimit, ok := res["overlimit"].(bool) 298 | if !ok { 299 | return false, errors.New("Ошибка приведения типа результата запроса.") 300 | } 301 | if overlimit { 302 | return true, nil 303 | } 304 | return false, nil 305 | } 306 | 307 | // checkIfUserRecordsExistInPeriod Проверка наличия записей о расходах пользователя 308 | // в базе данных (можно вызывать внутри транзакции). 309 | func checkIfUserRecordsExistInPeriod(ctx context.Context, db sqlx.QueryerContext, userID int64, period time.Time) (bool, error) { 310 | // Запрос на проверку лимита. 311 | const sqlString = ` 312 | SELECT COUNT(r.id) AS counter 313 | FROM users AS u 314 | INNER JOIN usermoneytransactions AS r 315 | ON r.user_id = u.id 316 | WHERE u.tg_id = $1 317 | AND r.period >= $2 AND r.period < $3 318 | ;` 319 | 320 | // Выполнение запроса на получение данных. 321 | cnt, err := dbutils.GetMap(ctx, db, sqlString, userID, period, timeutils.BeginOfNextMonth(period)) 322 | if err != nil { 323 | return false, err 324 | } 325 | // Приведение результата запроса к нужному типу. 326 | counter, ok := cnt["counter"].(int64) 327 | if !ok { 328 | return false, errors.New("Ошибка приведения типа результата запроса.") 329 | } 330 | if counter == 0 { 331 | return false, nil 332 | } 333 | return true, nil 334 | } 335 | 336 | // insertUserDataRecordTx Функция добавления расхода, выполняемая внутри транзакции (tx). 337 | func insertUserDataRecordTx(ctx context.Context, tx sqlx.ExtContext, userID int64, rec types.UserDataRecord, limitPeriod time.Time) (bool, error) { 338 | 339 | // Запрос на добаление записи с проверкой существования категории. 340 | const sqlString = ` 341 | WITH rows AS (INSERT INTO usercategories (user_id, name) 342 | (SELECT id, :category_name FROM users WHERE users.tg_id = :tg_id) 343 | ON CONFLICT (user_id, lower(name)) DO NOTHING) 344 | INSERT INTO usermoneytransactions (user_id, category_id, sum, period) 345 | (SELECT u.id, c.id, :sum, :period 346 | FROM usercategories AS c 347 | INNER JOIN users AS u ON c.user_id = u.id 348 | WHERE u.tg_id = :tg_id AND lower(c.name) = lower(:category_name)) 349 | ON CONFLICT DO NOTHING;` 350 | 351 | // Именованные параметры запроса. 352 | args := map[string]any{ 353 | "tg_id": userID, 354 | "category_name": rec.Category, 355 | "sum": rec.Sum, 356 | "period": rec.Period, 357 | } 358 | 359 | // Запуск на выполнение запроса с именованными параметрами. 360 | if _, err := dbutils.NamedExec(ctx, tx, sqlString, args); err != nil { 361 | // Ошибка выполнения запроса (вызовет откат транзакции). 362 | return false, err 363 | } 364 | 365 | // Проверка превышения (для отката транзакции в случае превышения). 366 | isOverLimit, err := checkIfUserOverLimit(ctx, tx, userID, limitPeriod) 367 | if err != nil { 368 | // Ошибка чтения из базы (вызовет откат транзакции). 369 | return false, err 370 | } 371 | if isOverLimit { 372 | // Признак превышения лимита (вызовет откат транзакции) 373 | return true, errors.New("Превышение лимита.") 374 | } 375 | // Возвращается признак, что превышения не было, ошибки отсутствуют 376 | // (вызовет коммит транзакции). 377 | return false, nil 378 | } 379 | -------------------------------------------------------------------------------- /internal/model/db/usersstorage_test.go: -------------------------------------------------------------------------------- 1 | package db 2 | 3 | import ( 4 | "context" 5 | sqlxmock "github.com/zhashkevych/go-sqlxmock" 6 | "regexp" 7 | "testing" 8 | ) 9 | 10 | func Test_UserStorage_InsertUser(t *testing.T) { 11 | 12 | ctx := context.Background() 13 | db, mock, err := sqlxmock.Newx() 14 | if err != nil { 15 | t.Fatalf("an error '%s' was not expected when opening a stub database connection", err) 16 | } 17 | defer db.Close() 18 | 19 | s := NewUserStorage(db, "RUB", 10000) 20 | 21 | tests := []struct { 22 | name string 23 | s *UserStorage 24 | userID int64 25 | userName string 26 | mock func() 27 | want int64 28 | wantErr bool 29 | }{ 30 | { 31 | name: "Должно быть без ошибок", 32 | s: s, 33 | userID: 15236, 34 | userName: "test user name", 35 | mock: func() { 36 | mock.ExpectExec("INSERT INTO users"). 37 | WithArgs(15236, "test user name", "RUB", 10000).WillReturnResult(sqlxmock.NewResult(0, 0)) 38 | }, 39 | wantErr: false, 40 | }, 41 | } 42 | 43 | for _, tt := range tests { 44 | t.Run(tt.name, func(t *testing.T) { 45 | tt.mock() 46 | err := tt.s.InsertUser(ctx, tt.userID, tt.userName) 47 | if (err != nil) != tt.wantErr { 48 | t.Errorf("Не совпало ожидание ошибки: Get() error new = %v, wantErr %v", err, tt.wantErr) 49 | return 50 | } 51 | }) 52 | } 53 | } 54 | 55 | func Test_UserStorage_CheckIfUserExist(t *testing.T) { 56 | 57 | ctx := context.Background() 58 | db, mock, err := sqlxmock.Newx() 59 | if err != nil { 60 | t.Fatalf("an error '%s' was not expected when opening a stub database connection", err) 61 | } 62 | defer db.Close() 63 | 64 | s := NewUserStorage(db, "RUB", 10000) 65 | 66 | tests := []struct { 67 | name string 68 | s *UserStorage 69 | userID int64 70 | mock func() 71 | want bool 72 | wantErr bool 73 | }{ 74 | { 75 | name: "Тест 1. Должно быть без ошибок (пользователь существует).", 76 | s: s, 77 | userID: 15236, 78 | mock: func() { 79 | rows := sqlxmock.NewRows([]string{"countusers"}).AddRow(1) 80 | mock.ExpectQuery(regexp.QuoteMeta("SELECT COUNT(id) AS countusers FROM users WHERE tg_id = $1;")). 81 | WithArgs(15236).WillReturnRows(rows) 82 | }, 83 | wantErr: false, 84 | want: true, 85 | }, 86 | { 87 | name: "Тест 2. Должно быть без ошибок (пользователь не существует).", 88 | s: s, 89 | userID: 15237, 90 | mock: func() { 91 | rows := sqlxmock.NewRows([]string{"countusers"}).AddRow(0) 92 | mock.ExpectQuery(regexp.QuoteMeta("SELECT COUNT(id) AS countusers FROM users WHERE tg_id = $1;")). 93 | WithArgs(15237).WillReturnRows(rows) 94 | }, 95 | wantErr: false, 96 | want: false, 97 | }, 98 | } 99 | 100 | for _, tt := range tests { 101 | t.Run(tt.name, func(t *testing.T) { 102 | tt.mock() 103 | got, err := tt.s.CheckIfUserExist(ctx, tt.userID) 104 | if (err != nil) != tt.wantErr { 105 | t.Errorf("Не совпало ожидание ошибки: Get() error new = %v, wantErr %v", err, tt.wantErr) 106 | return 107 | } 108 | if err == nil && got != tt.want { 109 | t.Errorf("Не совпало ожидание получаемого значения: Get() = %v, want %v", got, tt.want) 110 | } 111 | }) 112 | } 113 | } 114 | -------------------------------------------------------------------------------- /internal/model/exchangerates/exchangerates.go: -------------------------------------------------------------------------------- 1 | package exchangerates 2 | 3 | // Пакет для работы с курсами валют 4 | 5 | import ( 6 | "context" 7 | "github.com/ellavs/tg-bot-golang/internal/logger" 8 | "sync" 9 | "time" 10 | 11 | "github.com/pkg/errors" 12 | types "github.com/ellavs/tg-bot-golang/internal/model/bottypes" 13 | ) 14 | 15 | type CbrClient interface { 16 | // Загрузка курсов валют из открытого источника. 17 | LoadExchangeRates() (types.ExchangeRate, time.Time, error) 18 | } 19 | 20 | // Интерфейс для работы с хранилищем курсов валют. 21 | type RatesDataStorage interface { 22 | // Запись курсов валют в базу данных на определенную дату. 23 | InsertExchangeRatesToDate(ctx context.Context, rates types.ExchangeRate, period time.Time) error 24 | // Получение последних курсов валют из базы данных. 25 | GetLastExchangeRates(ctx context.Context) (types.ExchangeRate, error) 26 | } 27 | 28 | // Структура для хранения текущей информации о валютах. 29 | type ExchangeRates struct { 30 | sync.RWMutex 31 | Ctx context.Context 32 | IsLoaded bool // Флаг, что хотя бы одна загрузка была произведена. 33 | LoadDateTime time.Time // Время последней загрузки. 34 | Rates types.ExchangeRate // Последние курсы валют (кэш-данных из БД). 35 | CurrenciesName []string // Массив используемых валют. 36 | MainCurrency string // Основная валюта для хранения данных. 37 | CbrClient CbrClient // Источник загружаемых курсов валют. 38 | RatesDataStorage RatesDataStorage // Хранилище курсов валют. 39 | } 40 | 41 | // Инициализация экземпляра класса информации о курсах валют. 42 | func New(ctx context.Context, cbrClient CbrClient, currenciesName []string, mainCurrency string, storage RatesDataStorage) *ExchangeRates { 43 | exchangeRatesStorage := ExchangeRates{ 44 | Ctx: ctx, 45 | Rates: types.ExchangeRate{}, 46 | CbrClient: cbrClient, 47 | CurrenciesName: currenciesName, 48 | MainCurrency: mainCurrency, 49 | RatesDataStorage: storage, 50 | } 51 | // курс базовой валюты устанавливаем в единицу. 52 | exchangeRatesStorage.Rates[mainCurrency] = 1 53 | return &exchangeRatesStorage 54 | } 55 | 56 | // Получение курса указанной валюты. 57 | func (currenciesStorage *ExchangeRates) GetExchangeRate(currencyName string) (float64, error) { 58 | currenciesStorage.RLock() 59 | // Попытка получить курс из локального кэша. 60 | if currenciesStorage.IsLoaded { 61 | if rate, ok := currenciesStorage.Rates[currencyName]; ok { 62 | currenciesStorage.RUnlock() 63 | return rate, nil 64 | } 65 | } 66 | currenciesStorage.RUnlock() 67 | 68 | // Валюты нет в локальном кэше, попытка принудительно синхронно загрузить курсы валют из хранилища (из базы данных). 69 | if err := currenciesStorage.LoadExchangeRatesFromStorage(); err != nil { 70 | logger.Error("Ошибка получения курсов валют из БД", "err", err) 71 | return 0, err 72 | } 73 | 74 | // Валюты нет в хранилище (в БД), попытка принудительно синхронно загрузить курсы валют из внешнего источника. 75 | if err := currenciesStorage.UpdateExchangeRates(); err != nil { 76 | logger.Error("Ошибка загрузки курсов валют", "err", err) 77 | return 0, err 78 | } 79 | 80 | // Попытка получить курс из кэша после принудительного синхронного обновления курсов в кэше. 81 | currenciesStorage.RLock() 82 | defer currenciesStorage.RUnlock() 83 | if rate, ok := currenciesStorage.Rates[currencyName]; ok { 84 | return rate, nil 85 | } 86 | return 0, errors.New("Курс валюты получить не удалось.") 87 | } 88 | 89 | // Получение названия основной валюты. 90 | func (currenciesStorage *ExchangeRates) GetMainCurrency() string { 91 | return currenciesStorage.MainCurrency 92 | } 93 | 94 | // Получение списка используемых валют. 95 | func (currenciesStorage *ExchangeRates) GetCurrenciesList() []string { 96 | return currenciesStorage.CurrenciesName 97 | } 98 | 99 | // Конвертация суммы в указанную валюту из базовой валюты. 100 | func (currenciesStorage *ExchangeRates) ConvertSumFromBaseToCurrency(currencyName string, sum int64) (int64, error) { 101 | return currenciesStorage.convertSum(currencyName, sum, false) 102 | } 103 | 104 | // Конвертация суммы из указанной валюты в базовую валюту. 105 | func (currenciesStorage *ExchangeRates) ConvertSumFromCurrencyToBase(currencyName string, sum int64) (int64, error) { 106 | return currenciesStorage.convertSum(currencyName, sum, true) 107 | } 108 | 109 | // Конвертация суммы из/в базовую валюту в/из указанной. 110 | // Флаг isFrom == true означает конвертацию из указанной валюты в базовую. 111 | func (currenciesStorage *ExchangeRates) convertSum(currencyName string, sum int64, isFrom bool) (int64, error) { 112 | if sum == 0 { 113 | return 0, nil 114 | } 115 | if currenciesStorage.MainCurrency == currencyName { 116 | return sum, nil 117 | } 118 | currentRate, err := currenciesStorage.GetExchangeRate(currencyName) 119 | if err != nil { 120 | logger.Error("Ошибка получения курса валюты", "err", err) 121 | return 0, err 122 | } 123 | if currentRate == 0 { 124 | logger.Error("Ошибка указания курса валюты (0)") 125 | return 0, errors.New("Курс валюты некорректный.") 126 | } 127 | var res int64 128 | if isFrom { 129 | // Конвертация из указанной валюты в базовую. 130 | res = int64(float64(sum) / currentRate) 131 | } else { 132 | // Конвертация из базовой валюты в указанную. 133 | res = int64(float64(sum) * currentRate) 134 | } 135 | return res, nil 136 | } 137 | 138 | // Загрузка курсов валют из внешнего источника. 139 | func (curStorage *ExchangeRates) UpdateExchangeRates() error { 140 | // Загрузка курсов из заданного источника. 141 | curExchangeRates, period, err := curStorage.CbrClient.LoadExchangeRates() 142 | if err != nil { 143 | logger.Error("Ошибка загрузки курсов валют", "err", err) 144 | return err 145 | } 146 | // Обновление курсов в локальном кэше, если загрузка прошла успешно. 147 | curStorage.Lock() 148 | curStorage.IsLoaded = true 149 | curStorage.LoadDateTime = period 150 | for _, cName := range curStorage.CurrenciesName { 151 | if rate, ok := curExchangeRates[cName]; ok { 152 | curStorage.Rates[cName] = rate 153 | } 154 | } 155 | curStorage.Unlock() 156 | // Обновление курсов в хранилище (БД). 157 | if err := curStorage.RatesDataStorage.InsertExchangeRatesToDate(curStorage.Ctx, curExchangeRates, curStorage.LoadDateTime); err != nil { 158 | logger.Error("Ошибка обновления курсов валют", "err", err) 159 | return err 160 | } 161 | return nil 162 | } 163 | 164 | // Получение курсов валют из хранилища (БД) в локальный кэш. 165 | func (curStorage *ExchangeRates) LoadExchangeRatesFromStorage() error { 166 | // Загрузка последних курсов из хранилища. 167 | curExchangeRates, err := curStorage.RatesDataStorage.GetLastExchangeRates(curStorage.Ctx) 168 | if err != nil { 169 | logger.Error("Ошибка загрузки последних курсов валют", "err", err) 170 | return err 171 | } 172 | // Обновление курсов в локальном кэше, если загрузка прошла успешно. 173 | curStorage.Lock() 174 | curStorage.IsLoaded = true 175 | curStorage.LoadDateTime = time.Now() 176 | for _, cName := range curStorage.CurrenciesName { 177 | if rate, ok := curExchangeRates[cName]; ok { 178 | curStorage.Rates[cName] = rate 179 | } 180 | } 181 | curStorage.Unlock() 182 | return nil 183 | } 184 | -------------------------------------------------------------------------------- /internal/model/exchangerates/exchangerates_test.go: -------------------------------------------------------------------------------- 1 | package exchangerates 2 | 3 | import ( 4 | "context" 5 | "testing" 6 | "time" 7 | 8 | "github.com/golang/mock/gomock" 9 | "github.com/stretchr/testify/assert" 10 | mocks "github.com/ellavs/tg-bot-golang/internal/mocks/exchangerates" 11 | types "github.com/ellavs/tg-bot-golang/internal/model/bottypes" 12 | ) 13 | 14 | func Test_UpdateExchangeRates_ShouldWithoutError(t *testing.T) { 15 | // Тестирование процедуры загрузки курсов валют. 16 | period := time.Now() 17 | ctx := context.Background() 18 | 19 | // Имитируем ответ сервиса курсов валют. 20 | ctrl := gomock.NewController(t) 21 | cbrClient := mocks.NewMockCbrClient(ctrl) 22 | // Ожидаем вызова загрузки курса валют. 23 | cbrClient.EXPECT().LoadExchangeRates().Return(types.ExchangeRate{"USD": 0.0006, "CNY": 0.012}, period, nil) 24 | 25 | // Имитируем наличие базы данных. 26 | ctrlStorage := gomock.NewController(t) 27 | ratesDataStorage := mocks.NewMockRatesDataStorage(ctrlStorage) 28 | // Имитация сохранения курсов в БД. 29 | ratesDataStorage.EXPECT().InsertExchangeRatesToDate(ctx, types.ExchangeRate{"USD": 0.0006, "CNY": 0.012}, period).Return(nil) 30 | 31 | // Запускаем тест 32 | exchangeRates := New(ctx, cbrClient, []string{"USD", "CNY", "EUR", "RUB"}, "RUB", ratesDataStorage) 33 | err := exchangeRates.UpdateExchangeRates() 34 | 35 | assert.NoError(t, err) 36 | assert.Equal(t, 37 | &ExchangeRates{ 38 | IsLoaded: true, 39 | Rates: types.ExchangeRate{"USD": 0.0006, "CNY": 0.012, "RUB": 1}, 40 | CurrenciesName: []string{"USD", "CNY", "EUR", "RUB"}, 41 | MainCurrency: "RUB", 42 | CbrClient: cbrClient, 43 | LoadDateTime: period, 44 | Ctx: ctx, 45 | RatesDataStorage: ratesDataStorage, 46 | }, 47 | exchangeRates, 48 | ) 49 | } 50 | 51 | // Тестирование функции конвертации валют. 52 | func Test_ConvertSumFromBaseToCurrency_RUB_ShouldWithoutError(t *testing.T) { 53 | exchangeRates := New(context.Background(), nil, []string{"USD", "CNY", "EUR", "RUB"}, "RUB", nil) 54 | exchangeRates.Rates = types.ExchangeRate{"USD": 0.0006, "CNY": 0.012, "RUB": 1} 55 | 56 | var testSum int64 = 10000 // 100 рублей 57 | res, err := exchangeRates.ConvertSumFromBaseToCurrency("RUB", testSum) 58 | 59 | assert.NoError(t, err) 60 | assert.Equal(t, testSum, res) // 100 рублей 61 | } 62 | 63 | // Тестирование функции конвертации валют. 64 | func Test_ConvertSumFromBaseToCurrency_USD_ShouldWithoutError(t *testing.T) { 65 | exchangeRates := New(context.Background(), nil, []string{"USD", "CNY", "EUR", "RUB"}, "RUB", nil) 66 | exchangeRates.Rates = types.ExchangeRate{"USD": 0.016, "CNY": 0.1, "RUB": 1} 67 | exchangeRates.IsLoaded = true 68 | 69 | var testSum int64 = 10000 // 100 рублей 70 | res, err := exchangeRates.ConvertSumFromBaseToCurrency("USD", testSum) 71 | 72 | assert.NoError(t, err) 73 | assert.Equal(t, int64(160), res) // 1 доллар 16 центов 74 | } 75 | -------------------------------------------------------------------------------- /internal/model/messages/incoming_msg.go: -------------------------------------------------------------------------------- 1 | package messages 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "github.com/opentracing/opentracing-go" 7 | "github.com/ellavs/tg-bot-golang/internal/logger" 8 | "regexp" 9 | "strconv" 10 | "strings" 11 | "time" 12 | 13 | "github.com/pkg/errors" 14 | "github.com/ellavs/tg-bot-golang/internal/helpers/timeutils" 15 | types "github.com/ellavs/tg-bot-golang/internal/model/bottypes" 16 | ) 17 | 18 | // Область "Константы и переменные": начало. 19 | 20 | const ( 21 | txtStart = "Привет, *%v*. Я помогаю вести учет расходов. Выберите действие." 22 | txtUnknownCommand = "К сожалению, данная команда мне неизвестна. Для начала работы введите /start" 23 | txtReportError = "Не удалось получить данные." 24 | txtReportEmpty = "За указанный период данные отсутствуют." 25 | txtReportWait = "Формирование отчета. Пожалуйста, подождите..." 26 | txtCatAdd = "Введите название категории (не более 30 символов). Для отмены введите 0." 27 | txtCatView = "Выберите категорию, а затем введите сумму." 28 | txtCatChoice = "Выбрана категория *%v*. Введите сумму (только число). Для отмены введите 0. Используемая валюта: *%v*" 29 | txtCatSave = "Категория успешно сохранена." 30 | txtCatEmpty = "Пока нет категорий, сначала добавьте хотя бы одну категорию." 31 | txtRecSave = "Запись успешно сохранена." 32 | txtRecOverLimit = "Запись не сохранена: превышен бюджет раходов в текущем месяце." 33 | txtRecTbl = "Для загрузки истории расходов введите таблицу в следующем формате (дата сумма категория):\n`YYYY-MM-DD 0.00 XXX`\nНапример: \n`2022-09-20 1500 Кино`\n`2022-07-12 350.50 Продукты, еда`\n`2022-08-30 8000 Одежда и обувь`\n`2022-09-01 60 Бензин`\n`2022-09-27 425 Такси`\n`2022-09-26 1500 Бензин`\n`2022-09-26 950 Кошка`\n`2022-09-25 50 Бензин`\nИспользуемая валюта: *%v*" 34 | txtReportQP = "За какой период будем смотреть отчет? Команды периодов: /report_w - неделя, /report_m - месяц, /report_y - год" 35 | txtHelp = "Я - бот, помогающий вести учет расходов. Для начала работы введите /start" 36 | txtCurrencyChoice = "В качестве основной задана валюта: *%v*. Для изменения выберите другую валюту." 37 | txtCurrencySet = "Валюта изменена на *%v*." 38 | txtCurrencySetError = "Ошибка сохранения валюты." 39 | txtLimitInfo = "Текущий ежемесячный бюджет: *%v*. Для изменения введите число, например, 80000." 40 | txtLimitSet = "Бюджет изменен на *%v*." 41 | ) 42 | 43 | // Команды стартовых действий. 44 | var btnStart = []types.TgRowButtons{ 45 | {types.TgInlineButton{DisplayName: "Добавить категорию", Value: "/add_cat"}, types.TgInlineButton{DisplayName: "Добавить расход", Value: "/add_rec"}}, 46 | {types.TgInlineButton{DisplayName: "Отчет за неделю", Value: "/report_w"}, types.TgInlineButton{DisplayName: "Отчет за месяц", Value: "/report_m"}, types.TgInlineButton{DisplayName: "Отчет за год", Value: "/report_y"}}, 47 | {types.TgInlineButton{DisplayName: "Ввести данные за прошлый период", Value: "/add_tbl"}}, 48 | {types.TgInlineButton{DisplayName: "Выбрать валюту", Value: "/choice_currency"}, types.TgInlineButton{DisplayName: "Установить бюджет", Value: "/set_limit"}}, 49 | } 50 | 51 | var lineRegexp = regexp.MustCompile(`^(\d{4}-\d{2}-\d{2}) (\d+.?\d{0,2}) (.+)$`) 52 | 53 | // Область "Константы и переменные": конец. 54 | 55 | // Область "Внешний интерфейс": начало. 56 | 57 | // MessageSender Интерфейс для работы с сообщениями. 58 | type MessageSender interface { 59 | SendMessage(text string, userID int64) error 60 | ShowInlineButtons(text string, buttons []types.TgRowButtons, userID int64) error 61 | } 62 | 63 | // UserDataStorage Интерфейс для работы с хранилищем данных. 64 | type UserDataStorage interface { 65 | InsertUserDataRecord(ctx context.Context, userID int64, rec types.UserDataRecord, userName string, limitPeriod time.Time) (bool, error) 66 | GetUserDataRecord(ctx context.Context, userID int64, period time.Time) ([]types.UserDataReportRecord, error) 67 | InsertCategory(ctx context.Context, userID int64, catName string, userName string) error 68 | GetUserCategory(ctx context.Context, userID int64) ([]string, error) 69 | GetUserCurrency(ctx context.Context, userID int64) (string, error) 70 | SetUserCurrency(ctx context.Context, userID int64, currencyName string, userName string) error 71 | GetUserLimit(ctx context.Context, userID int64) (int64, error) 72 | SetUserLimit(ctx context.Context, userID int64, limits int64, userName string) error 73 | } 74 | 75 | // ExchangeRates Интерфейс для работы с курсами валют. 76 | type ExchangeRates interface { 77 | ConvertSumFromBaseToCurrency(currencyName string, sum int64) (int64, error) 78 | ConvertSumFromCurrencyToBase(currencyName string, sum int64) (int64, error) 79 | GetExchangeRate(currencyName string) (float64, error) 80 | GetMainCurrency() string 81 | GetCurrenciesList() []string 82 | } 83 | 84 | // LRUCache Интерфейс для работы с кэшем отчетов. 85 | type LRUCache interface { 86 | Add(key string, value any) 87 | Get(key string) any 88 | } 89 | 90 | // kafkaProducer Интерфейс для отправки сообщений в кафку. 91 | type kafkaProducer interface { 92 | SendMessage(key string, value string) (partition int32, offset int64, err error) 93 | GetTopic() string 94 | } 95 | 96 | // Model Модель бота (клиент, хранилище, последние команды пользователя) 97 | type Model struct { 98 | ctx context.Context 99 | tgClient MessageSender // Клиент. 100 | storage UserDataStorage // Хранилище пользовательской информации. 101 | currencies ExchangeRates // Хранилише курсов валют. 102 | reportCache LRUCache // Хранилише кэша. 103 | kafkaProducer kafkaProducer // Кафка 104 | lastUserCat map[int64]string // Последняя выбранная пользователем категория. 105 | lastUserCommand map[int64]string // Последняя выбранная пользователем команда. 106 | } 107 | 108 | // New Генерация сущности для хранения клиента ТГ и хранилища пользователей и курсов валют. 109 | func New(ctx context.Context, tgClient MessageSender, storage UserDataStorage, currencies ExchangeRates, reportCache LRUCache, kafka kafkaProducer) *Model { 110 | return &Model{ 111 | ctx: ctx, 112 | tgClient: tgClient, 113 | storage: storage, 114 | lastUserCat: map[int64]string{}, 115 | lastUserCommand: map[int64]string{}, 116 | currencies: currencies, 117 | reportCache: reportCache, 118 | kafkaProducer: kafka, 119 | } 120 | } 121 | 122 | // Message Структура сообщения для обработки. 123 | type Message struct { 124 | Text string 125 | UserID int64 126 | UserName string 127 | UserDisplayName string 128 | IsCallback bool 129 | CallbackMsgID string 130 | } 131 | 132 | func (s *Model) GetCtx() context.Context { 133 | return s.ctx 134 | } 135 | 136 | func (s *Model) SetCtx(ctx context.Context) { 137 | s.ctx = ctx 138 | } 139 | 140 | // IncomingMessage Обработка входящего сообщения. 141 | func (s *Model) IncomingMessage(msg Message) error { 142 | span, ctx := opentracing.StartSpanFromContext(s.ctx, "IncomingMessage") 143 | s.ctx = ctx 144 | defer span.Finish() 145 | 146 | lastUserCat := s.lastUserCat[msg.UserID] 147 | lastUserCommand := s.lastUserCommand[msg.UserID] 148 | 149 | // Обнуление выбранной категории и команды. 150 | s.lastUserCat[msg.UserID] = "" 151 | s.lastUserCommand[msg.UserID] = "" 152 | 153 | // Проверка ввода суммы расхода по выбранной категории и сохранение, если введено. 154 | if isNeedReturn, err := checkIfEnterCategorySum(s, msg, lastUserCat); err != nil || isNeedReturn { 155 | return err 156 | } 157 | 158 | // Проверка ввода новой категории и сохранение, если введено. 159 | if isNeedReturn, err := checkIfEnterNewCategory(s, msg, lastUserCommand); err != nil || isNeedReturn { 160 | return err 161 | } 162 | 163 | // Проверка ввода бюджета и сохранение, если введено. 164 | if isNeedReturn, err := checkIfEnterNewLimit(s, msg, lastUserCommand); err != nil || isNeedReturn { 165 | return err 166 | } 167 | 168 | // Проверка ввода данных в виде таблицы и сохранение, если введено. 169 | if isNeedReturn, err := checkIfEnterTableData(s, msg, lastUserCommand); err != nil || isNeedReturn { 170 | return err 171 | } 172 | 173 | // Проверка выбора категории для ввода расхода. 174 | if isNeedReturn, err := checkIfCoiceCategory(s, msg); err != nil || isNeedReturn { 175 | return err 176 | } 177 | 178 | // Проверка выбора валюты. 179 | if isNeedReturn, err := checkIfCoiceCurrency(s, msg); err != nil || isNeedReturn { 180 | return err 181 | } 182 | 183 | // Распознавание стандартных команд. 184 | if isNeedReturn, err := checkBotCommands(s, msg); err != nil || isNeedReturn { 185 | return err 186 | } 187 | 188 | // Отправка ответа по умолчанию. 189 | return s.tgClient.SendMessage(txtUnknownCommand, msg.UserID) 190 | } 191 | 192 | // SendReportToUser Отправка отчета за период. 193 | func (s *Model) SendReportToUser(dt []types.UserDataReportRecord, userID int64, reportKey string) error { 194 | span, ctx := opentracing.StartSpanFromContext(s.ctx, "SendReportToUser") 195 | s.ctx = ctx 196 | defer span.Finish() 197 | 198 | strReportTitle := "Отчет за " 199 | switch reportKey { 200 | case "w": 201 | strReportTitle += "*последнюю неделю*" 202 | case "m": 203 | strReportTitle += "*последний месяц*" 204 | case "y": 205 | strReportTitle += "*последний год*" 206 | } 207 | 208 | // Получение данных из БД. 209 | userCurrency := getUserCurrency(s, userID) 210 | answerText := formatReport(s, dt, userCurrency) 211 | if len(answerText) == 0 { 212 | answerText = txtReportEmpty 213 | } else { 214 | answerText = fmt.Sprintln(strReportTitle+" ("+userCurrency+")") + answerText 215 | } 216 | // Сохранение значения в кэш. 217 | reportCacheKey := strconv.Itoa(int(userID)) + reportKey 218 | s.reportCache.Add(reportCacheKey, answerText) 219 | err := s.tgClient.SendMessage(answerText, userID) 220 | if err != nil { 221 | logger.Error("Ошибка отправки сообщения в ТГ", "err", err) 222 | return err 223 | } 224 | return nil 225 | } 226 | 227 | // Область "Внешний интерфейс": конец. 228 | 229 | // Область "Служебные функции": начало. 230 | 231 | // Область "Распознавание входящих команд": начало. 232 | 233 | // Проверка ввода суммы расхода по выбранной категории. 234 | func checkIfEnterCategorySum(s *Model, msg Message, lastUserCat string) (bool, error) { 235 | // Если выбрана категория и введена сумма, то сохранение записи о расходах. 236 | if lastUserCat != "" && msg.Text != "" { 237 | span, ctx := opentracing.StartSpanFromContext(s.ctx, "checkIfEnterCategorySum") 238 | s.ctx = ctx 239 | defer span.Finish() 240 | 241 | // Парсинг и конвертация введенной суммы. 242 | catSum, err := parseAndConvertSumFromCurrency(s, msg.UserID, msg.Text) 243 | if err != nil { 244 | return true, err 245 | } 246 | // Сохранение записи. 247 | newRec := types.UserDataRecord{UserID: msg.UserID, Category: lastUserCat, Sum: catSum, Period: time.Now()} 248 | isOverLimit, err := s.storage.InsertUserDataRecord(s.ctx, msg.UserID, newRec, msg.UserName, timeutils.BeginOfMonth(newRec.Period)) 249 | if err != nil { 250 | if isOverLimit { 251 | // Было превышение лимита. 252 | return true, s.tgClient.SendMessage(txtRecOverLimit, msg.UserID) 253 | } else { 254 | logger.Error("Ошибка сохранения записи", "err", err) 255 | return true, errors.Wrap(err, "Insert data record error") 256 | } 257 | } 258 | // Ответ пользователю об успешном сохранении. 259 | return true, s.tgClient.SendMessage(txtRecSave, msg.UserID) 260 | } 261 | // Это не ввод расхода. 262 | return false, nil 263 | } 264 | 265 | // Проверка ввода новой категории и сохранение, если введено. 266 | func checkIfEnterNewCategory(s *Model, msg Message, lastUserCommand string) (bool, error) { 267 | if lastUserCommand == "/add_cat" { 268 | span, ctx := opentracing.StartSpanFromContext(s.ctx, "checkIfEnterNewCategory") 269 | s.ctx = ctx 270 | defer span.Finish() 271 | 272 | // Выбрано добавление категории. 273 | if msg.Text == "0" { 274 | // Ввод категории отменен. 275 | return true, nil 276 | } else { 277 | // Сохранение категории. 278 | err := s.storage.InsertCategory(s.ctx, msg.UserID, msg.Text, msg.UserName) 279 | if err != nil { 280 | logger.Error("Ошибка сохранения категории", "err", err) 281 | return true, errors.Wrap(err, "Insert category error") 282 | } 283 | // Ответ пользователю об успешном сохранении. 284 | return true, s.tgClient.SendMessage(txtCatSave, msg.UserID) 285 | } 286 | } 287 | // Это не ввод новой категории. 288 | return false, nil 289 | } 290 | 291 | // Проверка ввода бюджета и сохранение, если введено. 292 | func checkIfEnterNewLimit(s *Model, msg Message, lastUserCommand string) (bool, error) { 293 | // Если выбрано добавление бюджета и введена сумма, то сохранение. 294 | if lastUserCommand == "/set_limit" && msg.Text != "" { 295 | span, ctx := opentracing.StartSpanFromContext(s.ctx, "checkIfEnterNewLimit") 296 | s.ctx = ctx 297 | defer span.Finish() 298 | 299 | // Парсинг и конвертация введенной суммы. 300 | limit, err := parseAndConvertSumFromCurrency(s, msg.UserID, msg.Text) 301 | if err != nil { 302 | return true, err 303 | } 304 | if limit >= 0 { 305 | // Сохранение бюджета. 306 | err = s.storage.SetUserLimit(s.ctx, msg.UserID, limit, msg.UserName) 307 | if err != nil { 308 | logger.Error("Ошибка сохранения бюджета", "err", err) 309 | return true, errors.Wrap(err, "Ошибка сохранения бюджета.") 310 | } 311 | // Ответ пользователю об успешном сохранении. 312 | return true, s.tgClient.SendMessage(fmt.Sprintf(txtLimitSet, msg.Text), msg.UserID) 313 | } 314 | } 315 | // Это не ввод бюджета. 316 | return false, nil 317 | } 318 | 319 | // Проверка ввода данных в виде таблицы и сохранение, если введено. 320 | func checkIfEnterTableData(s *Model, msg Message, lastUserCommand string) (bool, error) { 321 | if lastUserCommand == "/add_tbl" { 322 | span, ctx := opentracing.StartSpanFromContext(s.ctx, "checkIfEnterTableData") 323 | s.ctx = ctx 324 | defer span.Finish() 325 | 326 | // Выбрано добавление таблиных данных. 327 | answerText := "" 328 | if msg.Text == "0" { 329 | // Ввод отменен. 330 | return true, nil 331 | } else { 332 | // Парсинг данных. 333 | lines := strings.Split(msg.Text, "\n") 334 | 335 | for ind, line := range lines { 336 | isError := false 337 | txtError := "" 338 | rec, err := parseLineRec(line) 339 | if err != nil { 340 | isError = true 341 | txtError = "Ошибка распознавания формата строки." 342 | } else { 343 | // Сохранение данных. 344 | if err := s.storage.InsertCategory(s.ctx, msg.UserID, rec.Category, msg.UserName); err != nil { 345 | isError = true 346 | txtError = "Ошибка добавления категории." 347 | } else { 348 | rec.UserID = msg.UserID 349 | // Конвертация из валюты пользователя в базовую. 350 | if sum, err := convertSumFromCurrency(s, msg.UserID, rec.Sum); err != nil { 351 | isError = true 352 | txtError = "Ошибка конвертации валюты." 353 | } else { 354 | rec.Sum = sum 355 | // Сохранение записи. 356 | if isOverLimit, err := s.storage.InsertUserDataRecord(s.ctx, msg.UserID, rec, msg.UserName, timeutils.BeginOfMonth(rec.Period)); err != nil { 357 | isError = true 358 | if isOverLimit { 359 | txtError = "Превышение бюджета." 360 | } else { 361 | txtError = "Ошибка сохранения записи." 362 | } 363 | } 364 | } 365 | } 366 | } 367 | if isError { 368 | answerText += fmt.Sprintf("%v. Ошибка. %v \n", ind+1, txtError) 369 | } else { 370 | answerText += fmt.Sprintf("%v. ОК\n", ind+1) 371 | } 372 | } 373 | // Ответ пользователю об сохранении. 374 | answerText = txtRecSave + "\n" + answerText 375 | return true, s.tgClient.SendMessage(answerText, msg.UserID) 376 | } 377 | } 378 | // Это не ввод таблицы. 379 | return false, nil 380 | } 381 | 382 | // Проверка выбора категории для ввода расхода. 383 | func checkIfCoiceCategory(s *Model, msg Message) (bool, error) { 384 | // Распознавание нажатых кнопок выбора категорий. 385 | if msg.IsCallback { 386 | if strings.Contains(msg.Text, "/cat ") { 387 | span, ctx := opentracing.StartSpanFromContext(s.ctx, "checkIfCoiceCategory") 388 | s.ctx = ctx 389 | defer span.Finish() 390 | 391 | // Пользователь выбрал категорию. 392 | cat := strings.Replace(msg.Text, "/cat ", "", -1) 393 | answerText := fmt.Sprintf(txtCatChoice, cat, getUserCurrency(s, msg.UserID)) 394 | s.lastUserCat[msg.UserID] = cat 395 | return true, s.tgClient.SendMessage(answerText, msg.UserID) 396 | } 397 | } 398 | // Это не выбор категории. 399 | return false, nil 400 | } 401 | 402 | // Проверка выбора валюты. 403 | func checkIfCoiceCurrency(s *Model, msg Message) (bool, error) { 404 | // Распознавание нажатых кнопок выбора валюты. 405 | if msg.IsCallback { 406 | if strings.Contains(msg.Text, "/curr ") { 407 | span, ctx := opentracing.StartSpanFromContext(s.ctx, "checkIfCoiceCurrency") 408 | s.ctx = ctx 409 | defer span.Finish() 410 | 411 | // Пользователь выбрал валюту. 412 | choice := strings.Replace(msg.Text, "/curr ", "", -1) 413 | answerText := fmt.Sprintf(txtCurrencySet, choice) 414 | // Сохранение выбранной валюты. 415 | if err := s.storage.SetUserCurrency(s.ctx, msg.UserID, choice, msg.UserName); err != nil { 416 | return true, s.tgClient.SendMessage(txtCurrencySetError, msg.UserID) 417 | } else { 418 | return true, s.tgClient.SendMessage(answerText, msg.UserID) 419 | } 420 | } 421 | } 422 | // Это не выбор категории. 423 | return false, nil 424 | } 425 | 426 | // Распознавание стандартных команд бота. 427 | func checkBotCommands(s *Model, msg Message) (bool, error) { 428 | span, ctx := opentracing.StartSpanFromContext(s.ctx, "checkBotCommands") 429 | s.ctx = ctx 430 | defer span.Finish() 431 | 432 | switch msg.Text { 433 | case "/start": 434 | displayName := msg.UserDisplayName 435 | if len(displayName) == 0 { 436 | displayName = msg.UserName 437 | } 438 | // Отображение команд стартовых действий. 439 | return true, s.tgClient.ShowInlineButtons(fmt.Sprintf(txtStart, displayName), btnStart, msg.UserID) 440 | case "/report": 441 | return true, s.tgClient.SendMessage(txtReportQP, msg.UserID) 442 | case "/help": 443 | return true, s.tgClient.SendMessage(txtHelp, msg.UserID) 444 | case "/add_tbl": 445 | s.lastUserCommand[msg.UserID] = "/add_tbl" 446 | userCurrency := getUserCurrency(s, msg.UserID) 447 | return true, s.tgClient.SendMessage(fmt.Sprintf(txtRecTbl, userCurrency), msg.UserID) 448 | case "/report_w", "/report_m", "/report_y": 449 | // Отображение отчета. 450 | return true, s.tgClient.SendMessage(getReportByPeriod(s, msg), msg.UserID) 451 | case "/add_cat": 452 | // Отображение сообщения о вводе категории. 453 | s.lastUserCommand[msg.UserID] = "/add_cat" 454 | return true, s.tgClient.SendMessage(txtCatAdd, msg.UserID) 455 | case "/add_rec": 456 | s.lastUserCommand[msg.UserID] = "/add_rec" 457 | // Отображение кнопок с существующими категориями для выбора. 458 | if btnCat, err := getCategoryButtons(s, msg.UserID); err != nil || btnCat == nil { 459 | return true, err 460 | } else { 461 | return true, s.tgClient.ShowInlineButtons(txtCatView, btnCat, msg.UserID) 462 | } 463 | case "/choice_currency": 464 | // Отображение кнопок выбора валюты. 465 | userCurrency := getUserCurrency(s, msg.UserID) 466 | if btnCurr, err := getCurrencyButtons(s, userCurrency); err != nil { 467 | return true, err 468 | } else { 469 | return true, s.tgClient.ShowInlineButtons(fmt.Sprintf(txtCurrencyChoice, userCurrency), btnCurr, msg.UserID) 470 | } 471 | case "/set_limit": 472 | // Отображение сообщения о вводе бюджета. 473 | s.lastUserCommand[msg.UserID] = "/set_limit" 474 | answerText := fmt.Sprintf(txtLimitInfo, "без ограничений") 475 | userLimit, _ := getUserLimit(s, msg.UserID) 476 | if userLimit > 0 { 477 | answerText = fmt.Sprintf(txtLimitInfo, userLimit/100) 478 | } 479 | return true, s.tgClient.SendMessage(answerText, msg.UserID) 480 | } 481 | // Команда не распознана. 482 | return false, nil 483 | } 484 | 485 | // Область "Распознавание входящих команд": конец. 486 | 487 | // Область "Формирование отчета": начало. 488 | 489 | // Получение отчета за период. 490 | func getReportByPeriod(s *Model, msg Message) string { 491 | span, ctx := opentracing.StartSpanFromContext(s.ctx, "getReportByPeriod") 492 | s.ctx = ctx 493 | defer span.Finish() 494 | 495 | answerText := "" 496 | reportKey := strings.Replace(msg.Text, "/report_", "", -1) 497 | 498 | // Ключ для поиска в кэше. 499 | reportCacheKey := strconv.Itoa(int(msg.UserID)) + reportKey 500 | // Попытка получить значение из кэша. 501 | cacheValue := s.reportCache.Get(reportCacheKey) 502 | if cacheValue != nil { 503 | answerText, ok := cacheValue.(string) 504 | if ok { 505 | return answerText 506 | } else { 507 | logger.Error("Ошибка приведения значения кэша к строке.") 508 | } 509 | } 510 | 511 | // Отправка запроса на формирование отчета в кафку. 512 | p, o, err := s.kafkaProducer.SendMessage(strconv.Itoa(int(msg.UserID)), reportKey) 513 | if err != nil { 514 | logger.Error("Ошибка отправки сообщения в кафку", "err", err) 515 | answerText = txtReportError 516 | } else { 517 | logger.Debug(fmt.Sprintf("[KAFKA] Successful to write message, topic %s, offset:%d, partition: %d\n", s.kafkaProducer.GetTopic(), o, p)) 518 | answerText = txtReportWait 519 | } 520 | 521 | return answerText 522 | } 523 | 524 | // Форматирование массива данных в строку для вывода отчета. 525 | func formatReport(s *Model, recs []types.UserDataReportRecord, userCurrency string) string { 526 | var res strings.Builder 527 | totalSum := 0.0 528 | for ind, rec := range recs { 529 | // Конвертация сумм в валюту пользователя. 530 | sumCurrency, err := s.currencies.ConvertSumFromBaseToCurrency(userCurrency, int64(rec.Sum)) 531 | if err != nil { 532 | logger.Error("Ошибка конвертации валюты", "err", err) 533 | return "Ошибка конвертации валюты" 534 | } 535 | recs[ind].Sum = float64(sumCurrency) / 100 536 | totalSum += float64(sumCurrency) / 100 537 | } 538 | maxSumStr := fmt.Sprintf("%.2f", totalSum) 539 | 540 | res.WriteString(fmt.Sprintf("`%*s | %v`", len(maxSumStr)+1, "Сумма", "Категория") + "\n") 541 | res.WriteString(fmt.Sprintf("`%v`", strings.Repeat("-", len(maxSumStr)+15)) + "\n") 542 | 543 | for _, rec := range recs { 544 | // Форматирование категории и числа до нужной ширины. 545 | res.WriteString(fmt.Sprintf("`%*.2f | %v`", len(maxSumStr)+1, rec.Sum, rec.Category) + "\n") 546 | } 547 | if len(recs) > 0 { 548 | res.WriteString(fmt.Sprintf("`%v`", strings.Repeat("-", len(maxSumStr)+15)) + "\n") 549 | res.WriteString(fmt.Sprintf("`%*.2f | %v`", len(maxSumStr)+1, totalSum, "ИТОГО") + "\n") 550 | } 551 | return res.String() 552 | } 553 | 554 | // Область "Формирование отчета": конец. 555 | 556 | // Область "Получение данных пользователя": начало. 557 | 558 | // Получение кнопок с существующими категориями для выбора. 559 | func getCategoryButtons(s *Model, userID int64) ([]types.TgRowButtons, error) { 560 | userCategories, err := s.storage.GetUserCategory(s.ctx, userID) 561 | if err != nil { 562 | logger.Error("Ошибка получения категорий", "err", err) 563 | return nil, errors.Wrap(err, "Get user categories error") 564 | } 565 | if len(userCategories) == 0 { 566 | return nil, s.tgClient.SendMessage(txtCatEmpty, userID) 567 | } 568 | var btnCat = []types.TgRowButtons{} 569 | rowCounter := 0 570 | btnCat = append(btnCat, types.TgRowButtons{}) 571 | for ind, cat := range userCategories { 572 | if ind%3 == 0 && ind > 0 { 573 | rowCounter++ 574 | btnCat = append(btnCat, types.TgRowButtons{}) 575 | } 576 | btnCat[rowCounter] = append(btnCat[rowCounter], types.TgInlineButton{DisplayName: cat, Value: "/cat " + cat}) 577 | } 578 | return btnCat, nil 579 | } 580 | 581 | // Получение кнопок с валютами для выбора. 582 | func getCurrencyButtons(s *Model, userCurrency string) ([]types.TgRowButtons, error) { 583 | // Получение списка используемых валют. 584 | currenciesName := s.currencies.GetCurrenciesList() 585 | var buttons = make([]types.TgRowButtons, 1) 586 | for _, name := range currenciesName { 587 | // Добавление кнопок валют, кроме выбранной ранее пользователем. 588 | if name != userCurrency { 589 | buttons[0] = append(buttons[0], types.TgInlineButton{DisplayName: name, Value: "/curr " + name}) 590 | } 591 | } 592 | return buttons, nil 593 | } 594 | 595 | // Получение выбранной валюты пользователя. 596 | func getUserCurrency(s *Model, userID int64) string { 597 | userCurrency, _ := s.storage.GetUserCurrency(s.ctx, userID) 598 | if userCurrency == "" { 599 | // Если не задано, то - основная валюта. 600 | userCurrency = s.currencies.GetMainCurrency() 601 | } 602 | return userCurrency 603 | } 604 | 605 | // Получение бюджета пользователя. 606 | func getUserLimit(s *Model, userID int64) (int64, error) { 607 | userLimit, err := s.storage.GetUserLimit(s.ctx, userID) 608 | if err != nil { 609 | logger.Error("Ошибка получения бюджета", "err", err) 610 | return 0, err 611 | } 612 | return userLimit, nil 613 | } 614 | 615 | // Область "Получение данных пользователя": конец. 616 | 617 | // Область "Другие функции": начало. 618 | 619 | // Парсинг строки данных. 620 | func parseLineRec(line string) (types.UserDataRecord, error) { 621 | matches := lineRegexp.FindStringSubmatch(line) 622 | if len(matches) < 4 { 623 | return types.UserDataRecord{}, errors.New("Неверный формат строки.") 624 | } 625 | 626 | periodStr := matches[1] 627 | amountStr := matches[2] 628 | category := matches[3] 629 | 630 | // Парсинг числа. 631 | amount, err := strconv.ParseFloat(amountStr, 64) 632 | if err != nil { 633 | return types.UserDataRecord{}, errors.Wrap(err, "Некорректная сумма.") 634 | } 635 | 636 | // Парсинг даты. 637 | period, err := time.Parse("2006-01-02", periodStr) 638 | if err != nil { 639 | return types.UserDataRecord{}, errors.Wrap(err, "Некорректная дата.") 640 | } 641 | 642 | return types.UserDataRecord{ 643 | Category: category, 644 | Sum: int64(amount * 100), 645 | Period: period, 646 | }, nil 647 | } 648 | 649 | // Конвертация суммы в базовую валюту для хранения. 650 | func convertSumFromCurrency(s *Model, userID int64, sum int64) (int64, error) { 651 | userCurrency := getUserCurrency(s, userID) 652 | sumBase, err := s.currencies.ConvertSumFromCurrencyToBase(userCurrency, sum) 653 | if err != nil { 654 | logger.Error("Ошибка конвертации валюты", "err", err) 655 | return 0, err 656 | } 657 | return sumBase, nil 658 | } 659 | 660 | // Парсинг вводимого пользователем числа и конвертация суммы в базовую валюту. 661 | func parseAndConvertSumFromCurrency(s *Model, userID int64, sumString string) (int64, error) { 662 | // Парсинг числа. 663 | sum, err := strconv.ParseFloat(sumString, 64) 664 | if err != nil { 665 | return 0, errors.Wrap(err, "Error parse sum") 666 | } 667 | // Сумма в разменных денежных единицах (1/100, для рублей - копейки, для долларов - центы и т.п.) 668 | nominalAmount := int64(sum * 100) 669 | // Конвертация из валюты пользователя в базовую валюту. 670 | if nominalAmount, err = convertSumFromCurrency(s, userID, nominalAmount); err != nil { 671 | return 0, errors.Wrap(err, "Ошибка конвертации валюты.") 672 | } 673 | return nominalAmount, nil 674 | } 675 | 676 | // Область "Другие функции": конец. 677 | 678 | // Область "Служебные функции": конец. 679 | -------------------------------------------------------------------------------- /internal/model/messages/incoming_msg_test.go: -------------------------------------------------------------------------------- 1 | package messages 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "testing" 7 | "time" 8 | 9 | "github.com/golang/mock/gomock" 10 | "github.com/stretchr/testify/assert" 11 | mocks "github.com/ellavs/tg-bot-golang/internal/mocks/messages" 12 | types "github.com/ellavs/tg-bot-golang/internal/model/bottypes" 13 | ) 14 | 15 | func Test_OnStartCommand_ShouldAnswerWithIntroMessage(t *testing.T) { 16 | ctrl := gomock.NewController(t) 17 | sender := mocks.NewMockMessageSender(ctrl) 18 | // Ожидаем ответ в виде сообщения c именем пользователя и кнопок меню. 19 | sender.EXPECT().ShowInlineButtons(fmt.Sprintf(txtStart, "Test"), btnStart, int64(123)) 20 | 21 | // Запускаем тест модели - команда старт 22 | model := New(context.Background(), sender, nil, nil, nil, nil) 23 | err := model.IncomingMessage(Message{ 24 | Text: "/start", 25 | UserID: 123, 26 | UserName: "test", 27 | UserDisplayName: "Test", 28 | }) 29 | 30 | assert.NoError(t, err) 31 | } 32 | 33 | func Test_OnUnknownCommand_ShouldAnswerWithHelpMessage(t *testing.T) { 34 | ctrl := gomock.NewController(t) 35 | sender := mocks.NewMockMessageSender(ctrl) 36 | // Ожидаем ответ, что такая команда неизвестна. 37 | sender.EXPECT().SendMessage(txtUnknownCommand, int64(123)) 38 | 39 | model := New(context.Background(), sender, nil, nil, nil, nil) 40 | err := model.IncomingMessage(Message{ 41 | Text: "some test text", 42 | UserID: 123, 43 | }) 44 | 45 | assert.NoError(t, err) 46 | } 47 | 48 | func Test_parseLineRec_ShouldFillFields(t *testing.T) { 49 | line := "2022-09-20 1500 Кино" 50 | userDataRec, err := parseLineRec(line) 51 | 52 | assert.NoError(t, err) 53 | assert.Equal(t, 54 | types.UserDataRecord{ 55 | Category: "Кино", 56 | Sum: 150000, 57 | Period: time.Date(2022, 9, 20, 0, 0, 0, 0, time.UTC), 58 | }, 59 | userDataRec, 60 | ) 61 | } 62 | 63 | func Test_parseLineRec_ShouldFillFields_WhenSumIsFloat(t *testing.T) { 64 | line := "2022-07-12 350.50 Продукты, еда" 65 | userDataRec, err := parseLineRec(line) 66 | 67 | assert.NoError(t, err) 68 | assert.Equal(t, 69 | types.UserDataRecord{ 70 | Category: "Продукты, еда", 71 | Sum: 35050, 72 | Period: time.Date(2022, 7, 12, 0, 0, 0, 0, time.UTC), 73 | }, 74 | userDataRec, 75 | ) 76 | } 77 | 78 | func Test_parseLineRec_ShouldReturnError_WhenNoSum(t *testing.T) { 79 | line := "2022-04-10 Кошка" 80 | _, err := parseLineRec(line) 81 | 82 | assert.Error(t, err) 83 | } 84 | 85 | func Test_parseLineRec_ShouldReturnError_WhenBadDate(t *testing.T) { 86 | line := "2022-22-05 150 Еда" 87 | _, err := parseLineRec(line) 88 | 89 | assert.Error(t, err) 90 | } 91 | -------------------------------------------------------------------------------- /internal/tasks/exchangeuploader/exchangeuploader.go: -------------------------------------------------------------------------------- 1 | package exchangeuploader 2 | 3 | import ( 4 | "context" 5 | "github.com/ellavs/tg-bot-golang/internal/logger" 6 | "time" 7 | ) 8 | 9 | type ExchangeRates interface { 10 | UpdateExchangeRates() error 11 | LoadExchangeRatesFromStorage() error 12 | } 13 | 14 | // Процедура периодического обновления курсов валют из внешнего источника. 15 | func ExchangeRatesUpdater(ctx context.Context, exchangeRatesStorage ExchangeRates, currenciesUpdatePeriod time.Duration) { 16 | // Создаем таймер на указанную периодичность. 17 | ticker := time.NewTicker(currenciesUpdatePeriod) 18 | // Запускаем горутину, обновляющую курсы валют по таймеру. 19 | go func() { 20 | for { 21 | select { 22 | case <-ctx.Done(): 23 | // Завершение горутины. 24 | return 25 | case <-ticker.C: 26 | // Запуск процедуры загрузки. 27 | logger.Info("Загрузка курсов валют.") 28 | if err := exchangeRatesStorage.UpdateExchangeRates(); err != nil { 29 | logger.Error("Ошибка загрузки курсов валют:", "err", err) 30 | } 31 | } 32 | } 33 | }() 34 | } 35 | 36 | // Процедура периодической загрузки курсов валют из хранилища (БД) в локальный кэш. 37 | func ExchangeRatesFromStorageLoader(ctx context.Context, exchangeRatesStorage ExchangeRates, currenciesUpdateCachePeriod time.Duration) { 38 | // Создаем таймер на указанную периодичность. 39 | ticker := time.NewTicker(currenciesUpdateCachePeriod) 40 | // Запускаем горутину, загружающую курсы валют по таймеру. 41 | go func() { 42 | for { 43 | select { 44 | case <-ctx.Done(): 45 | // Завершение горутины. 46 | return 47 | case <-ticker.C: 48 | // Запуск процедуры загрузки. 49 | logger.Info("Загрузка курсов валют из БД в кэш.") 50 | if err := exchangeRatesStorage.LoadExchangeRatesFromStorage(); err != nil { 51 | logger.Error("Ошибка загрузки курсов валют из БД в кэш:", "err", err) 52 | } 53 | } 54 | } 55 | }() 56 | } 57 | -------------------------------------------------------------------------------- /internal/tasks/reportserver/reportserver.go: -------------------------------------------------------------------------------- 1 | package reportserver 2 | 3 | import ( 4 | "context" 5 | "github.com/ellavs/tg-bot-golang/internal/api" 6 | "github.com/ellavs/tg-bot-golang/internal/logger" 7 | types "github.com/ellavs/tg-bot-golang/internal/model/bottypes" 8 | "google.golang.org/grpc" 9 | "log" 10 | "net" 11 | ) 12 | 13 | type MessageSender interface { 14 | SendReportToUser(dt []types.UserDataReportRecord, userID int64, reportKey string) error 15 | } 16 | 17 | // server is used to implement UserReportsReciverServer. 18 | type server struct { 19 | api.UnimplementedUserReportsReciverServer 20 | msgModel MessageSender 21 | } 22 | 23 | // PutReport implements UserReportsReciverServer. Функция, принимающая данные по gRPC. 24 | func (s *server) PutReport(ctx context.Context, in *api.ReportRequest) (*api.ReportResponse, error) { 25 | // Получение и преобразование полученных данных. 26 | userID := in.GetUserID() 27 | reportKey := in.GetReportKey() 28 | items := in.GetItems() 29 | logger.Debug("Received", "msg len", len(items), "userID", userID, "reportKey", reportKey) 30 | 31 | itemsReport := make([]types.UserDataReportRecord, len(items)) 32 | for ind, r := range items { 33 | itemsReport[ind] = types.UserDataReportRecord{Category: r.Category, Sum: float64(r.Sum)} 34 | } 35 | 36 | // Отправка полученного отчета пользователю в телеграм. 37 | err := s.msgModel.SendReportToUser(itemsReport, userID, reportKey) 38 | if err != nil { 39 | return &api.ReportResponse{Valid: false}, nil 40 | } 41 | 42 | // Отправка успешного ответа об обработке. 43 | return &api.ReportResponse{Valid: true}, nil 44 | } 45 | 46 | // StartReportServer запуск сервиса, слушающего сервис формирования отчетов. 47 | func StartReportServer(msgModel MessageSender) { 48 | lis, err := net.Listen("tcp", ":50051") 49 | if err != nil { 50 | logger.Fatal("failed to listen", "err", err) 51 | } 52 | s := grpc.NewServer() 53 | api.RegisterUserReportsReciverServer(s, &server{msgModel: msgModel}) 54 | log.Printf("server listening at %v", lis.Addr()) 55 | go func() { 56 | if err := s.Serve(lis); err != nil { 57 | logger.Fatal("failed to serve", "err", err) 58 | } 59 | }() 60 | } 61 | -------------------------------------------------------------------------------- /internal/tracing/tracing.go: -------------------------------------------------------------------------------- 1 | package tracing 2 | 3 | import ( 4 | tgbotapi "github.com/go-telegram-bot-api/telegram-bot-api/v5" 5 | "github.com/opentracing/opentracing-go" 6 | "github.com/opentracing/opentracing-go/ext" 7 | "github.com/uber/jaeger-client-go" 8 | "github.com/uber/jaeger-client-go/config" 9 | "github.com/ellavs/tg-bot-golang/internal/clients/tg" 10 | "github.com/ellavs/tg-bot-golang/internal/logger" 11 | "github.com/ellavs/tg-bot-golang/internal/model/messages" 12 | ) 13 | 14 | func init() { 15 | cfg := config.Configuration{ 16 | Sampler: &config.SamplerConfig{ 17 | Type: "const", 18 | Param: 1, 19 | }, 20 | } 21 | 22 | _, err := cfg.InitGlobalTracer("tg") 23 | if err != nil { 24 | logger.Fatal("Cannot init tracing", "err", err) 25 | } 26 | } 27 | 28 | // TracingMiddleware Функция трейсинга. 29 | func TracingMiddleware(next tg.HandlerFunc) tg.HandlerFunc { 30 | 31 | handler := tg.HandlerFunc(func(tgUpdate tgbotapi.Update, c *tg.Client, msgModel *messages.Model) { 32 | span, ctx := opentracing.StartSpanFromContext(msgModel.GetCtx(), "ProcessingMessages") 33 | defer span.Finish() 34 | if spanContext, ok := span.Context().(jaeger.SpanContext); ok { 35 | logger.Info("start span trace", "traceId", spanContext.TraceID().String()) 36 | } 37 | // Выполнение процесса обработки сообщения. 38 | msgModel.SetCtx(ctx) 39 | next.RunFunc(tgUpdate, c, msgModel) 40 | 41 | ext.SpanKindRPCClient.Set(span) 42 | }) 43 | 44 | return handler 45 | } 46 | -------------------------------------------------------------------------------- /migrations/20221011174232_init_user_table.sql: -------------------------------------------------------------------------------- 1 | -- +goose Up 2 | -- +goose StatementBegin 3 | create table if not exists users 4 | ( 5 | id integer generated by default as identity primary key, 6 | tg_id integer not null, 7 | name text not null 8 | constraint users_name_check 9 | check (name <> ''::text), 10 | currency text not null, 11 | limits integer not null 12 | ); 13 | 14 | comment on table users is 'Пользователи ТГ'; 15 | 16 | -- Индекс по ТГ-идентификатору пользователя для ускорения поиска. 17 | create unique index if not exists users_tg_id 18 | on users (tg_id); 19 | -- +goose StatementEnd 20 | 21 | -- +goose Down 22 | -- +goose StatementBegin 23 | drop index users_tg_id; 24 | DROP TABLE IF EXISTS "user"; 25 | -- +goose StatementEnd -------------------------------------------------------------------------------- /migrations/20221012151204_init_usercategories_table.sql: -------------------------------------------------------------------------------- 1 | -- +goose Up 2 | -- +goose StatementBegin 3 | create table if not exists usercategories 4 | ( 5 | id integer generated by default as identity primary key, 6 | user_id integer not null references users (id) on delete cascade, 7 | name text not null 8 | constraint usercategories_name_check 9 | check (name <> ''::text) 10 | ); 11 | 12 | comment on table usercategories is 'Категории расходов пользователей'; 13 | 14 | -- Индекс по пользователю и наименованию категории (lower для регистронезависимого поиска). 15 | create unique index if not exists usercategories_user_id_lower_name 16 | on usercategories (user_id, lower(name)); 17 | -- +goose StatementEnd 18 | 19 | -- +goose Down 20 | -- +goose StatementBegin 21 | drop index usercategories_user_id_lower_name; 22 | DROP TABLE IF EXISTS "usercategories"; 23 | -- +goose StatementEnd -------------------------------------------------------------------------------- /migrations/20221012213211_init_usermoneytransactions_table.sql: -------------------------------------------------------------------------------- 1 | -- +goose Up 2 | -- +goose StatementBegin 3 | create table if not exists usermoneytransactions 4 | ( 5 | id integer generated by default as identity primary key, 6 | user_id integer not null references users (id) on delete cascade, 7 | category_id integer not null references usercategories (id) on delete cascade, 8 | period timestamptz not null, 9 | sum integer not null -- (сумма в копейках) 10 | ); 11 | 12 | comment on table usermoneytransactions is 'Записи пользователей о расходах'; 13 | 14 | -- Индекс по пользователю и времени операции для ускорения поиска записей за определенный период. 15 | create index if not exists usermoneytransactions_user_id_period 16 | on usermoneytransactions (user_id, period); 17 | -- +goose StatementEnd 18 | 19 | -- +goose Down 20 | -- +goose StatementBegin 21 | drop index usermoneytransactions_user_id_period; 22 | DROP TABLE IF EXISTS "usermoneytransactions"; 23 | -- +goose StatementEnd -------------------------------------------------------------------------------- /migrations/20221013201644_init_exchangerates_table.sql: -------------------------------------------------------------------------------- 1 | -- +goose Up 2 | -- +goose StatementBegin 3 | create table if not exists exchangerates 4 | ( 5 | id integer generated by default as identity primary key, 6 | period timestamptz not null, 7 | currency text not null 8 | constraint exchangerates_currency_check 9 | check (currency <> ''::text), 10 | rate float not null 11 | ); 12 | 13 | comment on table exchangerates is 'Курсы валют по периодам'; 14 | 15 | -- Индекс по валюте и дате для ускорения поиска курсов валют на определенные дату/время. 16 | create unique index if not exists exchangerates_currency_period 17 | on exchangerates (currency, period); 18 | -- +goose StatementEnd 19 | 20 | -- +goose Down 21 | -- +goose StatementBegin 22 | drop index exchangerates_currency_period; 23 | DROP TABLE IF EXISTS "exchangerates"; 24 | -- +goose StatementEnd -------------------------------------------------------------------------------- /migrations/20221013232044_insert_test_userdata.sql: -------------------------------------------------------------------------------- 1 | -- +goose Up 2 | -- +goose StatementBegin 3 | DO 4 | $$ 5 | DECLARE 6 | testUserId integer; 7 | recs RECORD; 8 | BEGIN 9 | -- Вставка тестового пользователя. 10 | INSERT INTO users 11 | (tg_id, name, currency, limits) 12 | VALUES (1234567890, 'ellavs', 'RUB', 50000) 13 | ON CONFLICT (tg_id) DO NOTHING; 14 | 15 | SELECT id FROM users WHERE tg_id = 1234567890 INTO testUserId; 16 | 17 | -- Вставка тестовых категорий пользователя. 18 | INSERT INTO usercategories (user_id, name) 19 | VALUES (testUserId, 'Бензин'), 20 | (testUserId, 'Продукты'), 21 | (testUserId, 'Здоровье'), 22 | (testUserId, 'Обувь'), 23 | (testUserId, 'Интернет') 24 | ON CONFLICT (user_id, lower(name)) DO NOTHING; 25 | 26 | -- Вставка тестовых расходов пользователя. 27 | FOR recs IN 28 | SELECT id, name 29 | FROM usercategories 30 | WHERE user_id = testUserId 31 | LOOP 32 | CASE recs.name 33 | WHEN 'Бензин' THEN INSERT INTO usermoneytransactions (user_id, category_id, period, sum) 34 | VALUES (testUserId, recs.id, CURRENT_TIMESTAMP, 50000), 35 | (testUserId, recs.id, CURRENT_TIMESTAMP - interval '20 hour', 150000), 36 | (testUserId, recs.id, CURRENT_TIMESTAMP - interval '2 days', 55000), 37 | (testUserId, recs.id, CURRENT_TIMESTAMP - interval '10 days', 45000), 38 | (testUserId, recs.id, CURRENT_TIMESTAMP - interval '1 month 1 days', 135000), 39 | -- Тестовая запись с периодом, выходящим за пределы последнего года 40 | (testUserId, recs.id, CURRENT_TIMESTAMP - interval '2 years', 60000) 41 | ON CONFLICT DO NOTHING; 42 | WHEN 'Продукты' THEN INSERT INTO usermoneytransactions (user_id, category_id, period, sum) 43 | VALUES (testUserId, recs.id, CURRENT_TIMESTAMP - interval '2 hour', 253555), 44 | (testUserId, recs.id, CURRENT_TIMESTAMP - interval '32 hour', 85600), 45 | (testUserId, recs.id, CURRENT_TIMESTAMP - interval '1 days', 74500), 46 | (testUserId, recs.id, CURRENT_TIMESTAMP - interval '32 days', 95230), 47 | (testUserId, recs.id, CURRENT_TIMESTAMP - interval '36 days', 345000), 48 | (testUserId, recs.id, CURRENT_TIMESTAMP - interval '3 month', 156000), 49 | -- Тестовая запись с периодом, выходящим за пределы последнего года 50 | (testUserId, recs.id, CURRENT_TIMESTAMP - interval '2 years', 85000) 51 | ON CONFLICT DO NOTHING; 52 | WHEN 'Здоровье' THEN INSERT INTO usermoneytransactions (user_id, category_id, period, sum) 53 | VALUES (testUserId, recs.id, CURRENT_TIMESTAMP - interval '1 days', 1562000), 54 | (testUserId, recs.id, CURRENT_TIMESTAMP - interval '33 days', 860000), 55 | (testUserId, recs.id, CURRENT_TIMESTAMP - interval '37 days', 530000), 56 | (testUserId, recs.id, CURRENT_TIMESTAMP - interval '2 month', 2300000), 57 | -- Тестовая запись с периодом, выходящим за пределы последнего года 58 | (testUserId, recs.id, CURRENT_TIMESTAMP - interval '2 years', 1500000) 59 | ON CONFLICT DO NOTHING; 60 | WHEN 'Обувь' THEN INSERT INTO usermoneytransactions (user_id, category_id, period, sum) 61 | VALUES (testUserId, recs.id, CURRENT_TIMESTAMP - interval '3 days', 890000), 62 | (testUserId, recs.id, CURRENT_TIMESTAMP - interval '38 days', 260000), 63 | (testUserId, recs.id, CURRENT_TIMESTAMP - interval '5 month', 1300000), 64 | -- Тестовая запись с периодом, выходящим за пределы последнего года 65 | (testUserId, recs.id, CURRENT_TIMESTAMP - interval '2 years', 120000) 66 | ON CONFLICT DO NOTHING; 67 | WHEN 'Интернет' THEN INSERT INTO usermoneytransactions (user_id, category_id, period, sum) 68 | VALUES (testUserId, recs.id, CURRENT_TIMESTAMP - interval '20 days', 96000), 69 | (testUserId, recs.id, CURRENT_TIMESTAMP - interval '2 month', 96000), 70 | (testUserId, recs.id, CURRENT_TIMESTAMP - interval '3 month', 96000), 71 | (testUserId, recs.id, CURRENT_TIMESTAMP - interval '4 month', 95000), 72 | (testUserId, recs.id, CURRENT_TIMESTAMP - interval '5 month', 95000), 73 | -- Тестовая запись с периодом, выходящим за пределы последнего года 74 | (testUserId, recs.id, CURRENT_TIMESTAMP - interval '2 years', 90000) 75 | ON CONFLICT DO NOTHING; 76 | END CASE; 77 | END LOOP; 78 | END 79 | $$; 80 | -- +goose StatementEnd 81 | 82 | -- +goose Down 83 | -- +goose StatementBegin 84 | -- Удаление остальных данных произойдет каскадно. 85 | DELETE FROM users WHERE tg_id = 1234567890 86 | -- +goose StatementEnd -------------------------------------------------------------------------------- /migrations/20221014153602_insert_test_ratedata.sql: -------------------------------------------------------------------------------- 1 | -- +goose Up 2 | -- +goose StatementBegin 3 | -- Вставка тестовых курсов валют. 4 | INSERT INTO exchangerates (currency, rate, period) 5 | VALUES ('USD', 0.015858969, CURRENT_TIMESTAMP), 6 | ('EUR', 0.0160078, CURRENT_TIMESTAMP), 7 | ('CNY', 0.11505467, CURRENT_TIMESTAMP) 8 | ON CONFLICT (currency, period) DO NOTHING; 9 | -- +goose StatementEnd 10 | 11 | -- +goose Down 12 | -- +goose StatementBegin 13 | DELETE FROM exchangerates 14 | -- +goose StatementEnd -------------------------------------------------------------------------------- /prometheus.yml: -------------------------------------------------------------------------------- 1 | global: 2 | scrape_interval: 2s 3 | scrape_timeout: 2s 4 | evaluation_interval: 1s # Evaluate rules 5 | 6 | rule_files: 7 | - "alerts.yml" 8 | 9 | scrape_configs: 10 | # The job name is added as a label `job=` to any timeseries scraped from this config. 11 | - job_name: "prometheus" 12 | static_configs: 13 | - targets: ["localhost:9090"] 14 | - job_name: "tgbot" 15 | static_configs: 16 | - targets: 17 | - "host.docker.internal:8080" 18 | -------------------------------------------------------------------------------- /readme.md: -------------------------------------------------------------------------------- 1 | # Телеграм бот на GO (Golang) 2 | 3 | Это учебный проект, демонстрирующий работу с телеграм ботом и др. на примере учета расходов пользователя по категориям. 4 | 5 | В данном проекте: 6 | 1. получение и отправка сообщений пользователю через телеграм бот (используется библиотека [go-telegram-bot-api](https://github.com/go-telegram-bot-api/telegram-bot-api)), 7 | 2. пример загрузки конфигурационного файла приложения, 8 | 3. работа с PostgreSQL, 9 | 4. взаимодействие с внешним API (скачивание курсов валют из открытого источника), 10 | 5. работа с горутинами (периодическое обновление курсов валют), 11 | 6. примеры юнит и мок тестов, 12 | 7. вывод логов (zap), 13 | 8. пример middleware-функций для трейсинга и метрик, 14 | 9. пример compose для работы с докер-контенерами (postgres, prometheus, grafana, jaeger, kafka, zookeeper), 15 | 10. пример реализации LRU кэширования, 16 | 11. взаимодействие с брокером сообщений Кафка (Kafka), 17 | 12. отправка/получение прото-файлов по gRPC между сервисами, 18 | 13. отдельный сервис для получения сообщений из кафки и отправки данных по gRPC, 19 | 14. пример функции-дженерика (см. пакет net_http) 20 | 21 | ## Описание интерфейса работы с ботом в телеграме 22 | 23 | Для начала работы необходимо ввети команду `/start`. Будет отображено основное меню: 24 | 25 | ![alt Основное меню](img/screen-bot-menu.png "Основное меню") 26 | 27 | ### Ввод суммы расхода по категории 28 | 29 | Для ввода суммы расхода, необхоходимо нажать кнопку `Добавить расход` и в появившемся списке категорий выбрать ту, по которой был совершен расход: 30 | 31 | ![alt Выбор категории](img/screen-bot-category-choice.png "Выбор категории") 32 | 33 | После выбора категории необходимо ввести сумму расхода (допускается ввод с копейками, например, 150.5): 34 | 35 | ![alt Ввод расхода](img/screen-bot-enter-sum.png "Ввод расхода") 36 | 37 | Будет сохранен расход текущей датой. 38 | 39 | Для добавления категории необходимо в основном меню нажать кнопку `Добавить категорию` и ввести название новой категории. 40 | 41 | ### Загрузка истории данных 42 | 43 | Для загрузки истории расходов введите таблицу в следующем формате (дата сумма категория): 44 | `YYYY-MM-DD 0.00 XXX`, например: 45 | 46 | ``` 47 | 2022-09-20 1500 Кино 48 | 2022-07-12 350.50 Продукты, еда 49 | 2022-08-30 8000 Одежда и обувь 50 | 2022-09-01 60 Бензин 51 | 2022-09-27 425 Такси 52 | ``` 53 | 54 | ![alt Ввод истории данных](img/screen-bot-load-data.png "Ввод истории данных") 55 | 56 | ### Вывод отчета 57 | 58 | Для вывода отчета необходимо в основном меню нажать соответствующие кнопки: `Отчет за день`, `Отчет за месяц` или `Отчет за год`. Будет выведена таблица с отчетом по категориям за выбранный период: 59 | 60 | ![alt Отчет](img/screen-bot-report.png "Отчет") 61 | 62 | ### Изменение валюты 63 | 64 | Для смены основной валюты для ввода расходов и отображения отчетов нажмите кнопку "Выбрать валюту" в основном меню, а затем кнопку с выбранной валютой. 65 | 66 | ![alt Выбор валюты](img/screen-bot-currency.png "Выбор валюты") 67 | 68 | ### Установка лимита расходов на месяц 69 | 70 | Для установки ежемесячного лимита расходов необхоходимо нажать кнопку `Установить бюджет` в основном меню ввести максимальную сумму бюджета (например, 150000): 71 | 72 | ![alt Установка бюджета](img/screen-bot-set-limit.png "Установка бюджета") 73 | 74 | При установке бюджета все вводимые расходы, превышающие бюджет, будут отклонены. 75 | --------------------------------------------------------------------------------