├── configs ├── filed │ ├── offsets.yaml │ └── filed.yaml ├── db-init.sql ├── config.migration.yaml ├── report-service.yaml ├── prometheus.yaml ├── config.example.yaml └── graylog.conf ├── entrypoint.sh ├── docs ├── gohw-4.pdf ├── architecture.jpg └── architecture.pdf ├── .gitignore ├── entrypoint-report-service.sh ├── internal ├── metrics │ ├── config.go │ ├── message_type.go │ ├── tracer_middleware.go │ ├── init_tracer.go │ ├── amount_messages_middleware.go │ ├── latency_messages_middleware.go │ ├── tracer_iteration_message.go │ ├── tracer_telegram_client.go │ ├── tracer_user_postgres.go │ ├── tracer_cache_service.go │ ├── tracer_usercontext.go │ ├── amount_errors_cache_service.go │ ├── tracer_waste_postgres.go │ ├── latency_user_postgres.go │ ├── latency_usercontext.go │ ├── amount_errors_user_postgres.go │ ├── amount_errors_usercontext.go │ ├── latency_telegram_client.go │ ├── latency_cache_service.go │ ├── latency_waste_postgres.go │ └── amount_errors_waste_postgres.go ├── models │ ├── kafka_message.go │ ├── report.go │ ├── enums │ │ ├── user_context.go │ │ └── command_type.go │ ├── exchange_data.go │ ├── message.go │ ├── waste.go │ ├── user.go │ └── requests │ │ ├── get_report.go │ │ └── get_report_easyjson.go ├── migrations │ ├── 20221020152413_waste_limits.sql │ ├── 20221020145127_indexes.sql │ ├── atlas.sum │ └── 20221020082300_init.sql ├── ent │ ├── generate.go │ ├── predicate │ │ └── predicate.go │ ├── runtime │ │ └── runtime.go │ ├── runtime.go │ ├── schema │ │ ├── user.go │ │ └── waste.go │ ├── context.go │ ├── config.go │ ├── user │ │ └── user.go │ ├── waste │ │ └── waste.go │ ├── migrate │ │ ├── schema.go │ │ └── migrate.go │ ├── enttest │ │ └── enttest.go │ ├── user_delete.go │ ├── waste_delete.go │ ├── user.go │ ├── waste.go │ ├── hook │ │ └── hook.go │ └── tx.go ├── api │ ├── gen.sh │ ├── telegram_bot.proto │ ├── telegram_bot_grpc.pb.go │ └── telegram_bot.pb.go ├── clients │ ├── exchange │ │ ├── dto │ │ │ └── exchange_data.go │ │ └── client.go │ ├── grpc │ │ └── telegram_bot.go │ └── telegram │ │ └── client.go ├── app │ ├── startup │ │ ├── logger.go │ │ ├── migration_config.go │ │ ├── kafka.go │ │ ├── redis.go │ │ ├── db.go │ │ ├── config_report_service.go │ │ └── config.go │ └── app.go ├── service │ ├── kafka │ │ ├── producer.go │ │ └── consumer.go │ ├── cache │ │ └── cache.go │ ├── usercontext │ │ └── usercontext.go │ ├── exchange │ │ └── exchange.go │ └── wastereport │ │ └── waste_report.go ├── bot │ ├── handlers │ │ ├── get_limit_handler.go │ │ ├── helpers.go │ │ ├── default_handler.go │ │ ├── report_handler.go │ │ ├── set_limit_handler.go │ │ ├── currency_handler.go │ │ ├── message_handlers.go │ │ └── add_handler.go │ ├── iteration_message.go │ ├── bot.go │ └── middlewares.go ├── http │ └── router.go ├── grpc │ ├── telegram_bot.go │ └── server.go └── repository │ ├── users.go │ └── wastes.go ├── .dockerignore ├── .gitlab-ci.yml ├── Dockerfile ├── Dockerfile.report-service ├── pkg └── log │ ├── logger.go │ └── zap │ └── logger_impl.go ├── Makefile ├── cmd ├── migrate │ └── main.go ├── report-service │ └── main.go └── bot │ └── main.go ├── go.mod ├── docker-compose.yaml └── README.md /configs/filed/offsets.yaml: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /configs/db-init.sql: -------------------------------------------------------------------------------- 1 | create database money_wastes_db; -------------------------------------------------------------------------------- /entrypoint.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | /app/bot -config /app/config.yaml | tee /app/telegram-bot.log 4 | -------------------------------------------------------------------------------- /docs/gohw-4.pdf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Willsem/route256-wastes-telegram-bot/HEAD/docs/gohw-4.pdf -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | ## JetBrains files 2 | /.idea 3 | 4 | ## Local binaries 5 | /bin 6 | 7 | ## Logs 8 | /logs 9 | -------------------------------------------------------------------------------- /docs/architecture.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Willsem/route256-wastes-telegram-bot/HEAD/docs/architecture.jpg -------------------------------------------------------------------------------- /docs/architecture.pdf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Willsem/route256-wastes-telegram-bot/HEAD/docs/architecture.pdf -------------------------------------------------------------------------------- /entrypoint-report-service.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | /app/report-service -config /app/config.yaml | tee /app/report-service.log 4 | -------------------------------------------------------------------------------- /internal/metrics/config.go: -------------------------------------------------------------------------------- 1 | package metrics 2 | 3 | type Config struct { 4 | JaegerURL string `yaml:"jaeger_url"` 5 | } 6 | -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | ** 2 | !cmd 3 | !internal 4 | !pkg 5 | !go.mod 6 | !go.sum 7 | !entrypoint.sh 8 | !entrypoint-report-service.sh 9 | -------------------------------------------------------------------------------- /internal/models/kafka_message.go: -------------------------------------------------------------------------------- 1 | package models 2 | 3 | type KafkaMessage struct { 4 | Key []byte 5 | Message []byte 6 | } 7 | -------------------------------------------------------------------------------- /internal/migrations/20221020152413_waste_limits.sql: -------------------------------------------------------------------------------- 1 | -- modify "users" table 2 | ALTER TABLE "users" ADD COLUMN "waste_limit" bigint NULL; 3 | -------------------------------------------------------------------------------- /internal/ent/generate.go: -------------------------------------------------------------------------------- 1 | package ent 2 | 3 | //go:generate go run -mod=mod entgo.io/ent/cmd/ent generate --feature sql/versioned-migration ./schema 4 | -------------------------------------------------------------------------------- /internal/models/report.go: -------------------------------------------------------------------------------- 1 | package models 2 | 3 | type CategoryReport struct { 4 | Sum int64 `json:"sum"` 5 | Category string `json:"category"` 6 | } 7 | -------------------------------------------------------------------------------- /configs/config.migration.yaml: -------------------------------------------------------------------------------- 1 | database: 2 | host: 'localhost' 3 | port: 5432 4 | user: 'postgres' 5 | password: 'postgres' 6 | db_name: 'money_wastes_db' 7 | ssl_mode: 'disable' 8 | -------------------------------------------------------------------------------- /internal/api/gen.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | protoc \ 4 | --go_out=. --go_opt=paths=source_relative \ 5 | --go-grpc_out=. --go-grpc_opt=paths=source_relative \ 6 | telegram_bot.proto 7 | 8 | -------------------------------------------------------------------------------- /internal/models/enums/user_context.go: -------------------------------------------------------------------------------- 1 | package enums 2 | 3 | type UserContext int 4 | 5 | const ( 6 | NoContext UserContext = iota 7 | AddWaste 8 | ChangeCurrency 9 | SetLimit 10 | ) 11 | -------------------------------------------------------------------------------- /internal/clients/exchange/dto/exchange_data.go: -------------------------------------------------------------------------------- 1 | package dto 2 | 3 | type ExchangeData struct { 4 | Base string `json:"base"` 5 | Rates map[string]float64 `json:"rates"` 6 | Success bool `json:"success"` 7 | } 8 | -------------------------------------------------------------------------------- /internal/migrations/20221020145127_indexes.sql: -------------------------------------------------------------------------------- 1 | -- create index "user_id" to table: "users" 2 | CREATE UNIQUE INDEX "user_id" ON "users" ("id"); 3 | -- create index "waste_category" to table: "wastes" 4 | CREATE INDEX "waste_category" ON "wastes" ("category"); 5 | -------------------------------------------------------------------------------- /internal/metrics/message_type.go: -------------------------------------------------------------------------------- 1 | package metrics 2 | 3 | func messageType(message string, commands []string) string { 4 | for _, v := range commands { 5 | if message == "/"+v { 6 | return v + " command" 7 | } 8 | } 9 | 10 | return "text" 11 | } 12 | -------------------------------------------------------------------------------- /internal/migrations/atlas.sum: -------------------------------------------------------------------------------- 1 | h1:xhk/6ybVYRCIDuo7d4pyxxjb6Oh9gdWr8ApUpL4hmtI= 2 | 20221020082300_init.sql h1:LYzXfaN24rDdGbNvzg1UQoSrj2zCJkF56iim5it9ZhI= 3 | 20221020145127_indexes.sql h1:ajQJmp4oZLiWatTpIBwKAC4bqLUmH3FTdvHEq+rJ1Ig= 4 | 20221020152413_waste_limits.sql h1:b8BAucZT3o3M59WJIfWzNYHN8cQQYgDqF6Wf0na8x38= 5 | -------------------------------------------------------------------------------- /internal/models/exchange_data.go: -------------------------------------------------------------------------------- 1 | package models 2 | 3 | type ExchangeData struct { 4 | Base string 5 | Rates map[string]float64 6 | } 7 | 8 | func NewExchangeData(base string, rates map[string]float64) *ExchangeData { 9 | return &ExchangeData{ 10 | Base: base, 11 | Rates: rates, 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /.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 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM golang:1.19.2-alpine3.16 AS build 2 | 3 | WORKDIR /app 4 | 5 | COPY go.mod ./ 6 | COPY go.sum ./ 7 | RUN go mod download 8 | 9 | COPY . ./ 10 | RUN go build ./cmd/bot 11 | 12 | FROM alpine:3.16 13 | 14 | WORKDIR /app 15 | 16 | COPY --from=build /app/bot /app/entrypoint.sh ./ 17 | 18 | CMD ["./entrypoint.sh"] 19 | -------------------------------------------------------------------------------- /internal/ent/predicate/predicate.go: -------------------------------------------------------------------------------- 1 | // Code generated by ent, DO NOT EDIT. 2 | 3 | package predicate 4 | 5 | import ( 6 | "entgo.io/ent/dialect/sql" 7 | ) 8 | 9 | // User is the predicate function for user builders. 10 | type User func(*sql.Selector) 11 | 12 | // Waste is the predicate function for waste builders. 13 | type Waste func(*sql.Selector) 14 | -------------------------------------------------------------------------------- /internal/app/startup/logger.go: -------------------------------------------------------------------------------- 1 | package startup 2 | 3 | import ( 4 | "gitlab.ozon.dev/stepanov.ao.dev/telegram-bot/pkg/log" 5 | "gitlab.ozon.dev/stepanov.ao.dev/telegram-bot/pkg/log/zap" 6 | "go.uber.org/zap/zapcore" 7 | ) 8 | 9 | func NewLogger(loggerName string, logLevel zapcore.Level) log.Logger { 10 | return zap.NewLogger(loggerName, logLevel) 11 | } 12 | -------------------------------------------------------------------------------- /internal/models/message.go: -------------------------------------------------------------------------------- 1 | package models 2 | 3 | import "time" 4 | 5 | type Message struct { 6 | ID int 7 | From *User 8 | Date time.Time 9 | Text string 10 | } 11 | 12 | func NewMessage(id int, from *User, date int, text string) *Message { 13 | return &Message{ 14 | ID: id, 15 | From: from, 16 | Date: time.Unix(int64(date), 0), 17 | Text: text, 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /Dockerfile.report-service: -------------------------------------------------------------------------------- 1 | FROM golang:1.19.2-alpine3.16 AS build 2 | 3 | WORKDIR /app 4 | 5 | COPY go.mod ./ 6 | COPY go.sum ./ 7 | RUN go mod download 8 | 9 | COPY . ./ 10 | RUN go build ./cmd/report-service 11 | 12 | FROM alpine:3.16 13 | 14 | WORKDIR /app 15 | 16 | COPY --from=build /app/report-service /app/entrypoint-report-service.sh ./ 17 | 18 | CMD ["./entrypoint-report-service.sh"] 19 | -------------------------------------------------------------------------------- /internal/api/telegram_bot.proto: -------------------------------------------------------------------------------- 1 | syntax = "proto3"; 2 | 3 | package api; 4 | option go_package = "gitlab.ozon.ru/stepanov.ao.dev/telegram-bot/internal/api"; 5 | 6 | service TelegramBot { 7 | rpc SendMessage(Message) returns (EmptyMessage) {} 8 | } 9 | 10 | message Message { 11 | int64 user_id = 1; 12 | string text = 2; 13 | string command = 3; 14 | } 15 | 16 | message EmptyMessage {} 17 | -------------------------------------------------------------------------------- /internal/ent/runtime/runtime.go: -------------------------------------------------------------------------------- 1 | // Code generated by ent, DO NOT EDIT. 2 | 3 | package runtime 4 | 5 | // The schema-stitching logic is generated in gitlab.ozon.dev/stepanov.ao.dev/telegram-bot/internal/ent/runtime.go 6 | 7 | const ( 8 | Version = "v0.11.3" // Version of ent codegen. 9 | Sum = "h1:F5FBGAWiDCGder7YT+lqMnyzXl6d0xU3xMBM/SO3CMc=" // Sum of ent codegen. 10 | ) 11 | -------------------------------------------------------------------------------- /internal/models/waste.go: -------------------------------------------------------------------------------- 1 | package models 2 | 3 | import ( 4 | "time" 5 | 6 | "gitlab.ozon.dev/stepanov.ao.dev/telegram-bot/internal/ent" 7 | ) 8 | 9 | type Waste struct { 10 | *ent.Waste 11 | } 12 | 13 | func NewWaste(category string, cost int64, date time.Time) *Waste { 14 | return &Waste{ 15 | Waste: &ent.Waste{ 16 | Cost: cost, 17 | Category: category, 18 | Date: date, 19 | }, 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /internal/models/user.go: -------------------------------------------------------------------------------- 1 | package models 2 | 3 | import "gitlab.ozon.dev/stepanov.ao.dev/telegram-bot/internal/ent" 4 | 5 | type User struct { 6 | *ent.User 7 | } 8 | 9 | func NewUser(id int64, firstName string, lastName string, userName string) *User { 10 | return &User{ 11 | User: &ent.User{ 12 | ID: id, 13 | FirstName: firstName, 14 | LastName: lastName, 15 | UserName: userName, 16 | }, 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /internal/models/requests/get_report.go: -------------------------------------------------------------------------------- 1 | package requests 2 | 3 | type Period int 4 | 5 | const ( 6 | PeriodWeek Period = iota 7 | PeriodMonth 8 | PeriodYear 9 | ) 10 | 11 | //easyjson:json 12 | type GetReport struct { 13 | UserID int64 `json:"user_id"` 14 | Period Period `json:"period"` 15 | CurrencyExchange float64 `json:"currency_exchange"` 16 | CurrencyDesignation string `json:"currency_designation"` 17 | } 18 | -------------------------------------------------------------------------------- /configs/filed/filed.yaml: -------------------------------------------------------------------------------- 1 | pipelines: 2 | file_to_graylog: 3 | input: 4 | type: file 5 | persistence_mode: async 6 | watching_dir: /tmp/logs 7 | filename_pattern: telegram-bot.log 8 | offsets_file: /tmp/offsets.yaml 9 | offsets_op: reset 10 | 11 | actions: 12 | - type: rename 13 | msg: message 14 | ts: time 15 | 16 | output: 17 | type: gelf 18 | endpoint: "graylog:12201" 19 | reconnect_interval: 5s 20 | default_short_message_value: "message isn't provided" 21 | -------------------------------------------------------------------------------- /configs/report-service.yaml: -------------------------------------------------------------------------------- 1 | log_level: "debug" 2 | 3 | app: 4 | graceful_timeout: "1m" 5 | 6 | database: 7 | host: "postgres" 8 | port: 5432 9 | user: "postgres" 10 | password: "postgres" 11 | db_name: "money_wastes_db" 12 | ssl_mode: "disable" 13 | 14 | kafka: 15 | brockers: ["kafka:9092"] 16 | topic: "wastes-telegram-bot" 17 | 18 | consumer: 19 | buffer_size: 100 20 | 21 | http: 22 | port: 3000 23 | 24 | grpc_client: 25 | host: "telegram-bot" 26 | port: 8080 27 | 28 | metrics: 29 | jaeger_url: "http://jaeger:14268/api/traces" 30 | -------------------------------------------------------------------------------- /configs/prometheus.yaml: -------------------------------------------------------------------------------- 1 | global: 2 | scrape_interval: 2s 3 | scrape_timeout: 2s 4 | evaluation_interval: 1s # Evaluate rules 5 | 6 | scrape_configs: 7 | - job_name: "prometheus" 8 | static_configs: 9 | - targets: ["prometheus:9090"] 10 | 11 | - job_name: "grafana" 12 | static_configs: 13 | - targets: ["grafana:3000"] 14 | 15 | - job_name: "telegram-bot" 16 | static_configs: 17 | - targets: ["telegram-bot:3000"] 18 | 19 | - job_name: "report-service" 20 | static_configs: 21 | - targets: ["report-service:3000"] 22 | -------------------------------------------------------------------------------- /internal/migrations/20221020082300_init.sql: -------------------------------------------------------------------------------- 1 | -- create "users" table 2 | CREATE TABLE "users" ("id" bigint NOT NULL GENERATED BY DEFAULT AS IDENTITY, "first_name" character varying NOT NULL, "last_name" character varying NOT NULL, "user_name" character varying NOT NULL, PRIMARY KEY ("id")); 3 | -- create "wastes" table 4 | CREATE TABLE "wastes" ("id" uuid NOT NULL, "cost" bigint NOT NULL, "category" character varying NOT NULL, "date" timestamptz NOT NULL, "user_wastes" bigint NULL, PRIMARY KEY ("id"), CONSTRAINT "wastes_users_wastes" FOREIGN KEY ("user_wastes") REFERENCES "users" ("id") ON DELETE SET NULL); 5 | -------------------------------------------------------------------------------- /pkg/log/logger.go: -------------------------------------------------------------------------------- 1 | package log 2 | 3 | type Logger interface { 4 | With(args ...interface{}) Logger 5 | WithError(err error) Logger 6 | 7 | Debug(args ...interface{}) 8 | Info(args ...interface{}) 9 | Warn(args ...interface{}) 10 | Error(args ...interface{}) 11 | Fatal(args ...interface{}) 12 | 13 | Debugf(template string, args ...interface{}) 14 | Infof(template string, args ...interface{}) 15 | Warnf(template string, args ...interface{}) 16 | Errorf(template string, args ...interface{}) 17 | Fatalf(template string, args ...interface{}) 18 | } 19 | 20 | const ComponentKey = "component" 21 | -------------------------------------------------------------------------------- /internal/app/startup/migration_config.go: -------------------------------------------------------------------------------- 1 | package startup 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | 7 | "gopkg.in/yaml.v3" 8 | ) 9 | 10 | type MigrationConfig struct { 11 | Database DatabaseConfig `yaml:"database"` 12 | } 13 | 14 | func NewMigrationConfig(configFile string) (*MigrationConfig, error) { 15 | rawYAML, err := os.ReadFile(configFile) 16 | if err != nil { 17 | return nil, fmt.Errorf("reading file error: %w", err) 18 | } 19 | 20 | cfg := &MigrationConfig{} 21 | if err = yaml.Unmarshal(rawYAML, cfg); err != nil { 22 | return nil, fmt.Errorf("yaml parsing error: %w", err) 23 | } 24 | 25 | return cfg, nil 26 | } 27 | -------------------------------------------------------------------------------- /internal/service/kafka/producer.go: -------------------------------------------------------------------------------- 1 | package kafka 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | 7 | "github.com/segmentio/kafka-go" 8 | ) 9 | 10 | type Producer struct { 11 | client *kafka.Writer 12 | } 13 | 14 | func NewProducer(client *kafka.Writer) *Producer { 15 | return &Producer{ 16 | client: client, 17 | } 18 | } 19 | 20 | func (p *Producer) SendMessage(ctx context.Context, key []byte, value []byte) error { 21 | err := p.client.WriteMessages(ctx, kafka.Message{ 22 | Key: key, 23 | Value: value, 24 | }) 25 | if err != nil { 26 | return fmt.Errorf("failed to send the message to kafka: %w", err) 27 | } 28 | 29 | return nil 30 | } 31 | -------------------------------------------------------------------------------- /internal/app/startup/kafka.go: -------------------------------------------------------------------------------- 1 | package startup 2 | 3 | import "github.com/segmentio/kafka-go" 4 | 5 | type KafkaConfig struct { 6 | Brockers []string `yaml:"brockers"` 7 | Topic string `yaml:"topic"` 8 | } 9 | 10 | func NewKafkaProducer(config KafkaConfig) *kafka.Writer { 11 | return &kafka.Writer{ 12 | Addr: kafka.TCP(config.Brockers...), 13 | Topic: config.Topic, 14 | Balancer: &kafka.LeastBytes{}, 15 | } 16 | } 17 | 18 | func NewKafkaConsumer(config KafkaConfig) *kafka.Reader { 19 | return kafka.NewReader(kafka.ReaderConfig{ 20 | Brokers: config.Brockers, 21 | Topic: config.Topic, 22 | Partition: 0, 23 | MinBytes: 10e3, 24 | MaxBytes: 10e6, 25 | }) 26 | } 27 | -------------------------------------------------------------------------------- /internal/app/startup/redis.go: -------------------------------------------------------------------------------- 1 | package startup 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | 7 | "github.com/go-redis/redis/v9" 8 | ) 9 | 10 | type RedisConfig struct { 11 | Host string `yaml:"host"` 12 | Port int `yaml:"port"` 13 | Password string `yaml:"password"` 14 | DB int `yaml:"db"` 15 | } 16 | 17 | func RedisConnect(config RedisConfig) (*redis.Client, error) { 18 | client := redis.NewClient(&redis.Options{ 19 | Addr: fmt.Sprintf("%s:%d", config.Host, config.Port), 20 | Password: config.Password, 21 | DB: config.DB, 22 | }) 23 | 24 | if err := client.Ping(context.Background()).Err(); err != nil { 25 | return nil, fmt.Errorf("failed to connect to the redis: %w", err) 26 | } 27 | 28 | return client, nil 29 | } 30 | -------------------------------------------------------------------------------- /internal/ent/runtime.go: -------------------------------------------------------------------------------- 1 | // Code generated by ent, DO NOT EDIT. 2 | 3 | package ent 4 | 5 | import ( 6 | "github.com/google/uuid" 7 | "gitlab.ozon.dev/stepanov.ao.dev/telegram-bot/internal/ent/schema" 8 | "gitlab.ozon.dev/stepanov.ao.dev/telegram-bot/internal/ent/waste" 9 | ) 10 | 11 | // The init function reads all schema descriptors with runtime code 12 | // (default values, validators, hooks and policies) and stitches it 13 | // to their package variables. 14 | func init() { 15 | wasteFields := schema.Waste{}.Fields() 16 | _ = wasteFields 17 | // wasteDescID is the schema descriptor for id field. 18 | wasteDescID := wasteFields[0].Descriptor() 19 | // waste.DefaultID holds the default value on creation for the id field. 20 | waste.DefaultID = wasteDescID.Default.(func() uuid.UUID) 21 | } 22 | -------------------------------------------------------------------------------- /internal/metrics/tracer_middleware.go: -------------------------------------------------------------------------------- 1 | package metrics 2 | 3 | import ( 4 | "context" 5 | 6 | "gitlab.ozon.dev/stepanov.ao.dev/telegram-bot/internal/bot" 7 | "gitlab.ozon.dev/stepanov.ao.dev/telegram-bot/internal/models" 8 | tracesdk "go.opentelemetry.io/otel/sdk/trace" 9 | ) 10 | 11 | func TracingMiddleware(tracerProvider *tracesdk.TracerProvider, commands []string) bot.MessageMiddleware { 12 | tracer := tracerProvider.Tracer("message-middleware") 13 | 14 | middleware := func(next bot.MessageHandler) bot.MessageHandler { 15 | return func(ctx context.Context, message *models.Message) (*bot.MessageResponse, error) { 16 | ctxTrace, span := tracer.Start(ctx, messageType(message.Text, commands)) 17 | defer span.End() 18 | 19 | return next(ctxTrace, message) 20 | } 21 | } 22 | 23 | return middleware 24 | } 25 | -------------------------------------------------------------------------------- /internal/metrics/init_tracer.go: -------------------------------------------------------------------------------- 1 | package metrics 2 | 3 | import ( 4 | "fmt" 5 | 6 | "go.opentelemetry.io/otel/exporters/jaeger" 7 | "go.opentelemetry.io/otel/sdk/resource" 8 | tracesdk "go.opentelemetry.io/otel/sdk/trace" 9 | semconv "go.opentelemetry.io/otel/semconv/v1.12.0" 10 | ) 11 | 12 | func InitTracer(config Config, serviceName string) (*tracesdk.TracerProvider, error) { 13 | exp, err := jaeger.New(jaeger.WithCollectorEndpoint(jaeger.WithEndpoint(config.JaegerURL))) 14 | if err != nil { 15 | return nil, fmt.Errorf("failed to create jaeger tracer: %w", err) 16 | } 17 | 18 | tp := tracesdk.NewTracerProvider( 19 | tracesdk.WithBatcher(exp), 20 | tracesdk.WithResource(resource.NewWithAttributes( 21 | semconv.SchemaURL, 22 | semconv.ServiceNameKey.String(serviceName), 23 | semconv.DeploymentEnvironmentKey.String("production"), 24 | )), 25 | ) 26 | 27 | return tp, nil 28 | } 29 | -------------------------------------------------------------------------------- /internal/ent/schema/user.go: -------------------------------------------------------------------------------- 1 | package schema 2 | 3 | import ( 4 | "entgo.io/ent" 5 | "entgo.io/ent/schema/edge" 6 | "entgo.io/ent/schema/field" 7 | "entgo.io/ent/schema/index" 8 | ) 9 | 10 | // User holds the schema definition for the User entity. 11 | type User struct { 12 | ent.Schema 13 | } 14 | 15 | // Fields of the User. 16 | func (User) Fields() []ent.Field { 17 | return []ent.Field{ 18 | field.Int64("id"), 19 | field.String("first_name"), 20 | field.String("last_name"), 21 | field.String("user_name"), 22 | field.Uint64("waste_limit"). 23 | Optional(). 24 | Nillable(), 25 | } 26 | } 27 | 28 | // Edges of the User. 29 | func (User) Edges() []ent.Edge { 30 | return []ent.Edge{ 31 | edge.To("wastes", Waste.Type), 32 | } 33 | } 34 | 35 | // Indexes if the User. 36 | func (User) Indexes() []ent.Index { 37 | return []ent.Index{ 38 | index.Fields("id"). 39 | Unique(), 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /internal/ent/schema/waste.go: -------------------------------------------------------------------------------- 1 | package schema 2 | 3 | import ( 4 | "entgo.io/ent" 5 | "entgo.io/ent/schema/edge" 6 | "entgo.io/ent/schema/field" 7 | "entgo.io/ent/schema/index" 8 | "github.com/google/uuid" 9 | ) 10 | 11 | // Waste holds the schema definition for the Waste entity. 12 | type Waste struct { 13 | ent.Schema 14 | } 15 | 16 | // Fields of the Waste. 17 | func (Waste) Fields() []ent.Field { 18 | return []ent.Field{ 19 | field.UUID("id", uuid.UUID{}). 20 | Default(uuid.New), 21 | field.Int64("cost"), 22 | field.String("category"), 23 | field.Time("date"), 24 | } 25 | } 26 | 27 | // Edges of the Waste. 28 | func (Waste) Edges() []ent.Edge { 29 | return []ent.Edge{ 30 | edge.From("user", User.Type). 31 | Ref("wastes"). 32 | Unique(), 33 | } 34 | } 35 | 36 | // Indexes of the Waste. 37 | func (Waste) Indexes() []ent.Index { 38 | return []ent.Index{ 39 | index.Fields("category"), 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /internal/metrics/amount_messages_middleware.go: -------------------------------------------------------------------------------- 1 | package metrics 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/prometheus/client_golang/prometheus" 7 | "github.com/prometheus/client_golang/prometheus/promauto" 8 | "gitlab.ozon.dev/stepanov.ao.dev/telegram-bot/internal/bot" 9 | "gitlab.ozon.dev/stepanov.ao.dev/telegram-bot/internal/models" 10 | ) 11 | 12 | func AmountMetricMiddleware(commands []string) bot.MessageMiddleware { 13 | countMessages := promauto.NewCounterVec( 14 | prometheus.CounterOpts{ 15 | Name: "telegram_messages_count", 16 | Help: "Count of messages", 17 | }, []string{"type"}, 18 | ) 19 | 20 | middleware := func(next bot.MessageHandler) bot.MessageHandler { 21 | return func(ctx context.Context, message *models.Message) (*bot.MessageResponse, error) { 22 | countMessages.WithLabelValues(messageType(message.Text, commands)).Inc() 23 | return next(ctx, message) 24 | } 25 | } 26 | 27 | return middleware 28 | } 29 | -------------------------------------------------------------------------------- /configs/config.example.yaml: -------------------------------------------------------------------------------- 1 | log_level: "debug" 2 | 3 | app: 4 | graceful_timeout: "1m" 5 | 6 | telegram: 7 | token: "" 8 | timeout: 60 9 | message_buffer: 10 10 | 11 | exchange_client: 12 | endpoint: "https://api.exchangerate.host/latest" 13 | 14 | currency: 15 | update_timeout: "10m" 16 | retry_timeout: "20s" 17 | default: "RUB" 18 | designation_default: "Руб" 19 | used: ["USD", "EUR", "CNY"] 20 | designation_used: ["$", "€", "¥"] 21 | 22 | database: 23 | host: "postgres" 24 | port: 5432 25 | user: "postgres" 26 | password: "postgres" 27 | db_name: "money_wastes_db" 28 | ssl_mode: "disable" 29 | 30 | redis: 31 | host: "redis" 32 | port: 6379 33 | password: "redis" 34 | db: 0 35 | 36 | kafka: 37 | brockers: ["kafka:9092"] 38 | topic: "wastes-telegram-bot" 39 | 40 | cache: 41 | expiration: "1h" 42 | 43 | http: 44 | port: 3000 45 | 46 | grpc: 47 | port: 8080 48 | 49 | metrics: 50 | jaeger_url: "http://jaeger:14268/api/traces" 51 | -------------------------------------------------------------------------------- /internal/app/startup/db.go: -------------------------------------------------------------------------------- 1 | package startup 2 | 3 | import ( 4 | "fmt" 5 | 6 | "entgo.io/ent/dialect" 7 | "entgo.io/ent/dialect/sql" 8 | _ "github.com/lib/pq" 9 | 10 | "gitlab.ozon.dev/stepanov.ao.dev/telegram-bot/internal/ent" 11 | ) 12 | 13 | type DatabaseConfig struct { 14 | Host string `yaml:"host"` 15 | Port int `yaml:"port"` 16 | User string `yaml:"user"` 17 | Password string `yaml:"password"` 18 | DBName string `yaml:"db_name"` 19 | SslMode string `yaml:"ssl_mode"` 20 | } 21 | 22 | func DatabaseConnect(config DatabaseConfig) (*ent.Client, error) { 23 | dsn := fmt.Sprintf("host=%s port=%d user=%s dbname=%s password=%s sslmode=%s", 24 | config.Host, config.Port, config.User, config.DBName, config.Password, config.SslMode) 25 | driver, err := sql.Open(dialect.Postgres, dsn) 26 | if err != nil { 27 | return nil, err 28 | } 29 | 30 | err = driver.DB().Ping() 31 | if err != nil { 32 | return nil, err 33 | } 34 | 35 | return ent.NewClient(ent.Driver(driver)), nil 36 | } 37 | -------------------------------------------------------------------------------- /internal/ent/context.go: -------------------------------------------------------------------------------- 1 | // Code generated by ent, DO NOT EDIT. 2 | 3 | package ent 4 | 5 | import ( 6 | "context" 7 | ) 8 | 9 | type clientCtxKey struct{} 10 | 11 | // FromContext returns a Client stored inside a context, or nil if there isn't one. 12 | func FromContext(ctx context.Context) *Client { 13 | c, _ := ctx.Value(clientCtxKey{}).(*Client) 14 | return c 15 | } 16 | 17 | // NewContext returns a new context with the given Client attached. 18 | func NewContext(parent context.Context, c *Client) context.Context { 19 | return context.WithValue(parent, clientCtxKey{}, c) 20 | } 21 | 22 | type txCtxKey struct{} 23 | 24 | // TxFromContext returns a Tx stored inside a context, or nil if there isn't one. 25 | func TxFromContext(ctx context.Context) *Tx { 26 | tx, _ := ctx.Value(txCtxKey{}).(*Tx) 27 | return tx 28 | } 29 | 30 | // NewTxContext returns a new context with the given Tx attached. 31 | func NewTxContext(parent context.Context, tx *Tx) context.Context { 32 | return context.WithValue(parent, txCtxKey{}, tx) 33 | } 34 | -------------------------------------------------------------------------------- /internal/bot/handlers/get_limit_handler.go: -------------------------------------------------------------------------------- 1 | package handlers 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | 7 | "gitlab.ozon.dev/stepanov.ao.dev/telegram-bot/internal/bot" 8 | "gitlab.ozon.dev/stepanov.ao.dev/telegram-bot/internal/models" 9 | ) 10 | 11 | const ( 12 | messageGetLimit = "Текущий лимит на месяц:" 13 | messageNullLimit = "Лимит на месяц не установлен" 14 | ) 15 | 16 | func (h *MessageHandlers) getLimitHandler(ctx context.Context, message *models.Message) (*bot.MessageResponse, error) { 17 | limit, err := h.userRepo.GetWasteLimit(ctx, message.From.ID) 18 | if err != nil { 19 | return nil, fmt.Errorf("failed to get the limit: %w", err) 20 | } 21 | 22 | if limit == nil { 23 | return &bot.MessageResponse{ 24 | Message: messageNullLimit, 25 | }, nil 26 | } 27 | 28 | exchange, designation, err := h.getExchangeOfUser(ctx, message.From.ID) 29 | if err != nil { 30 | return nil, fmt.Errorf("failed to get exchange and designation of user: %w", err) 31 | } 32 | 33 | return &bot.MessageResponse{ 34 | Message: fmt.Sprintf("%s %.2f %s", 35 | messageGetLimit, h.convertFromDefaultCurrency(*limit, exchange), designation), 36 | }, nil 37 | } 38 | -------------------------------------------------------------------------------- /internal/metrics/latency_messages_middleware.go: -------------------------------------------------------------------------------- 1 | package metrics 2 | 3 | import ( 4 | "context" 5 | "time" 6 | 7 | "github.com/prometheus/client_golang/prometheus" 8 | "github.com/prometheus/client_golang/prometheus/promauto" 9 | "gitlab.ozon.dev/stepanov.ao.dev/telegram-bot/internal/bot" 10 | "gitlab.ozon.dev/stepanov.ao.dev/telegram-bot/internal/models" 11 | ) 12 | 13 | func LatencyMetricMiddleware(commands []string) bot.MessageMiddleware { 14 | latencyMessages := promauto.NewHistogramVec( 15 | prometheus.HistogramOpts{ 16 | Name: "telegram_messages_response_latency", 17 | Help: "Duration of message response", 18 | Buckets: []float64{0.0001, 0.0005, 0.001, 0.005, 0.01, 0.05, 0.1, 0.5, 1.0, 2.0}, 19 | }, []string{"type"}, 20 | ) 21 | 22 | middleware := func(next bot.MessageHandler) bot.MessageHandler { 23 | return func(ctx context.Context, message *models.Message) (*bot.MessageResponse, error) { 24 | startTime := time.Now() 25 | response, err := next(ctx, message) 26 | duration := time.Since(startTime) 27 | 28 | latencyMessages.WithLabelValues(messageType(message.Text, commands)).Observe(duration.Seconds()) 29 | 30 | return response, err 31 | } 32 | } 33 | 34 | return middleware 35 | } 36 | -------------------------------------------------------------------------------- /internal/bot/handlers/helpers.go: -------------------------------------------------------------------------------- 1 | package handlers 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | ) 7 | 8 | const ( 9 | messageIncorrectFormat = "Неправильный формат" 10 | 11 | convertToMainCurrency = 100.0 12 | ) 13 | 14 | func (h *MessageHandlers) convertFromDefaultCurrency(money uint64, exchange float64) float64 { 15 | return float64(money) * exchange / convertToMainCurrency 16 | } 17 | 18 | func (h *MessageHandlers) convertToDefaultCurrency(money float64, exchange float64) uint64 { 19 | return uint64(float64(money) / exchange * convertToMainCurrency) 20 | } 21 | 22 | func (h *MessageHandlers) getExchangeOfUser(ctx context.Context, userID int64) (float64, string, error) { 23 | currency, err := h.userContextService.GetCurrency(ctx, userID) 24 | if err != nil { 25 | return 0, "", fmt.Errorf("failed to get user currency: %w", err) 26 | } 27 | 28 | exchange, err := h.exchangeService.GetExchange(currency) 29 | if err != nil { 30 | return 0, "", fmt.Errorf("failed to get exchange of user: %w", err) 31 | } 32 | 33 | designation, err := h.exchangeService.GetDesignation(currency) 34 | if err != nil { 35 | return 0, "", fmt.Errorf("failed to get designation os user: %w", err) 36 | } 37 | 38 | return exchange, designation, nil 39 | } 40 | -------------------------------------------------------------------------------- /internal/models/enums/command_type.go: -------------------------------------------------------------------------------- 1 | package enums 2 | 3 | import "fmt" 4 | 5 | type CommandType string 6 | 7 | const ( 8 | CommandTypeAdd CommandType = "/add" 9 | CommandTypeSetLimit CommandType = "/setLimit" 10 | CommandTypeGetLimit CommandType = "/getLimit" 11 | CommandTypeWeekReport CommandType = "/week" 12 | CommandTypeMonthReport CommandType = "/month" 13 | CommandTypeYearReport CommandType = "/year" 14 | CommandTypeCurrency CommandType = "/currency" 15 | 16 | CommandTypeUnknown CommandType = "" 17 | ) 18 | 19 | func ParseCommandType(command string) (CommandType, error) { 20 | switch command { 21 | case string(CommandTypeAdd): 22 | return CommandTypeAdd, nil 23 | case string(CommandTypeSetLimit): 24 | return CommandTypeSetLimit, nil 25 | case string(CommandTypeGetLimit): 26 | return CommandTypeGetLimit, nil 27 | case string(CommandTypeWeekReport): 28 | return CommandTypeWeekReport, nil 29 | case string(CommandTypeMonthReport): 30 | return CommandTypeMonthReport, nil 31 | case string(CommandTypeYearReport): 32 | return CommandTypeYearReport, nil 33 | case string(CommandTypeCurrency): 34 | return CommandTypeCurrency, nil 35 | default: 36 | return CommandTypeUnknown, fmt.Errorf("Unknown command type") 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | CURDIR=$(shell pwd) 2 | BINDIR=${CURDIR}/bin 3 | 4 | LINTVER=v1.49.0 5 | LINTBIN=${BINDIR}/lint_${GOVER}_${LINTVER} 6 | 7 | PACKAGE=gitlab.ozon.dev/stepanov.ao.dev/telegram-bot/cmd/bot 8 | 9 | PACKAGE_MIGRATE=gitlab.ozon.dev/stepanov.ao.dev/telegram-bot/cmd/migrate 10 | MIGRATIONS_DIR=file://internal/migrations 11 | DATABASE_URL=postgresql://${DATABASE_USER}:${DATABASE_PASS}@${DATABASE_HOST}:${DATABASE_PORT}/${DATABASE_DB}?sslmode=disable 12 | 13 | all: build test lint 14 | 15 | build: bindir 16 | @go build -o ${BINDIR}/bot ${PACKAGE} 17 | 18 | bindir: 19 | @mkdir -p ${BINDIR} 20 | 21 | test: 22 | @go test ./... 23 | 24 | run: 25 | @go run ${PACKAGE} -config configs/config.example.yaml | pino-pretty 26 | 27 | generate: 28 | @go generate ./... 29 | @go run ${PACKAGE_MIGRATE} -config configs/config.migrate.yaml -name ${MIGRATION_NAME} 30 | 31 | lint: install-lint 32 | @${LINTBIN} run 33 | 34 | install-lint: bindir 35 | @test -f ${LINTBIN} || \ 36 | (GOBIN=${BINDIR} go install github.com/golangci/golangci-lint/cmd/golangci-lint@${LINTVER} && \ 37 | mv ${BINDIR}/golangci-lint ${LINTBIN}) 38 | 39 | precommit: build test lint 40 | echo "OK" 41 | 42 | docker-run: 43 | @sudo docker compose up -d 44 | 45 | migrate: 46 | @atlas migrate apply --dir "${MIGRATIONS_DIR}" --url "${DATABASE_URL}" -------------------------------------------------------------------------------- /configs/graylog.conf: -------------------------------------------------------------------------------- 1 | is_master = true 2 | node_id_file = /usr/share/graylog/data/config/node-id 3 | password_secret = 4 | root_password_sha2 = 5 | bin_dir = /usr/share/graylog/bin 6 | data_dir = /usr/share/graylog/data 7 | plugin_dir = /usr/share/graylog/plugin 8 | http_bind_address = 0.0.0.0:9000 9 | elasticsearch_hosts = http://elasticsearch:9200 10 | rotation_strategy = count 11 | elasticsearch_max_docs_per_index = 20000000 12 | elasticsearch_max_number_of_indices = 20 13 | retention_strategy = delete 14 | elasticsearch_shards = 4 15 | elasticsearch_replicas = 0 16 | elasticsearch_index_prefix = graylog 17 | allow_leading_wildcard_searches = false 18 | allow_highlighting = false 19 | elasticsearch_analyzer = standard 20 | output_batch_size = 500 21 | output_flush_interval = 1 22 | output_fault_count_threshold = 5 23 | output_fault_penalty_seconds = 30 24 | processbuffer_processors = 5 25 | outputbuffer_processors = 3 26 | processor_wait_strategy = blocking 27 | ring_size = 65536 28 | inputbuffer_ring_size = 65536 29 | inputbuffer_processors = 2 30 | inputbuffer_wait_strategy = blocking 31 | message_journal_enabled = true 32 | message_journal_dir = data/journal 33 | lb_recognition_period_seconds = 3 34 | mongodb_uri = mongodb://mongodb/graylog 35 | mongodb_max_connections = 1000 36 | mongodb_threads_allowed_to_block_multiplier = 5 37 | proxied_requests_thread_pool_size = 32 38 | -------------------------------------------------------------------------------- /internal/app/startup/config_report_service.go: -------------------------------------------------------------------------------- 1 | package startup 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | 7 | "go.uber.org/zap/zapcore" 8 | "gopkg.in/yaml.v3" 9 | 10 | "gitlab.ozon.dev/stepanov.ao.dev/telegram-bot/internal/app" 11 | "gitlab.ozon.dev/stepanov.ao.dev/telegram-bot/internal/clients/grpc" 12 | "gitlab.ozon.dev/stepanov.ao.dev/telegram-bot/internal/http" 13 | "gitlab.ozon.dev/stepanov.ao.dev/telegram-bot/internal/metrics" 14 | "gitlab.ozon.dev/stepanov.ao.dev/telegram-bot/internal/service/kafka" 15 | ) 16 | 17 | type ReportServiceConfig struct { 18 | App app.Config `yaml:"app"` 19 | Database DatabaseConfig `yaml:"database"` 20 | Kafka KafkaConfig `yaml:"kafka"` 21 | Consumer kafka.ConsumerConfig `yaml:"consumer"` 22 | Http http.Config `yaml:"http"` 23 | Grpc grpc.Config `yaml:"grpc_client"` 24 | Metrics metrics.Config `yaml:"metrics"` 25 | 26 | LogLevel zapcore.Level `yaml:"log_level"` 27 | } 28 | 29 | func NewReportServiceConfig(configFile string) (*ReportServiceConfig, error) { 30 | rawYAML, err := os.ReadFile(configFile) 31 | if err != nil { 32 | return nil, fmt.Errorf("reading file error: %w", err) 33 | } 34 | 35 | cfg := &ReportServiceConfig{} 36 | if err = yaml.Unmarshal(rawYAML, cfg); err != nil { 37 | return nil, fmt.Errorf("yaml parsing error: %w", err) 38 | } 39 | 40 | return cfg, nil 41 | } 42 | -------------------------------------------------------------------------------- /internal/metrics/tracer_iteration_message.go: -------------------------------------------------------------------------------- 1 | package metrics 2 | 3 | import ( 4 | "context" 5 | 6 | "gitlab.ozon.dev/stepanov.ao.dev/telegram-bot/internal/bot" 7 | "gitlab.ozon.dev/stepanov.ao.dev/telegram-bot/internal/models" 8 | "gitlab.ozon.dev/stepanov.ao.dev/telegram-bot/pkg/log" 9 | tracesdk "go.opentelemetry.io/otel/sdk/trace" 10 | "go.opentelemetry.io/otel/trace" 11 | ) 12 | 13 | //go:generate mockery --name=iterationMessage --dir . --output ./mocks --exported 14 | type iterationMessage interface { 15 | Iterate(ctx context.Context, message *models.Message, handler bot.MessageHandler, logger log.Logger) 16 | } 17 | 18 | type IterationMessageTracerDecorator struct { 19 | iterationMessage iterationMessage 20 | tracer trace.Tracer 21 | } 22 | 23 | func NewIterationMessageTracerDecorator(iterationMessage iterationMessage, tracerProvider *tracesdk.TracerProvider) *IterationMessageTracerDecorator { 24 | return &IterationMessageTracerDecorator{ 25 | iterationMessage: iterationMessage, 26 | tracer: tracerProvider.Tracer("iteration-message"), 27 | } 28 | } 29 | 30 | func (d *IterationMessageTracerDecorator) Iterate(ctx context.Context, message *models.Message, handler bot.MessageHandler, logger log.Logger) { 31 | ctxTrace, span := d.tracer.Start(ctx, "IterateMessage") 32 | defer span.End() 33 | 34 | d.iterationMessage.Iterate(ctxTrace, message, handler, logger) 35 | } 36 | -------------------------------------------------------------------------------- /internal/ent/config.go: -------------------------------------------------------------------------------- 1 | // Code generated by ent, DO NOT EDIT. 2 | 3 | package ent 4 | 5 | import ( 6 | "entgo.io/ent" 7 | "entgo.io/ent/dialect" 8 | ) 9 | 10 | // Option function to configure the client. 11 | type Option func(*config) 12 | 13 | // Config is the configuration for the client and its builder. 14 | type config struct { 15 | // driver used for executing database requests. 16 | driver dialect.Driver 17 | // debug enable a debug logging. 18 | debug bool 19 | // log used for logging on debug mode. 20 | log func(...any) 21 | // hooks to execute on mutations. 22 | hooks *hooks 23 | } 24 | 25 | // hooks per client, for fast access. 26 | type hooks struct { 27 | User []ent.Hook 28 | Waste []ent.Hook 29 | } 30 | 31 | // Options applies the options on the config object. 32 | func (c *config) options(opts ...Option) { 33 | for _, opt := range opts { 34 | opt(c) 35 | } 36 | if c.debug { 37 | c.driver = dialect.Debug(c.driver, c.log) 38 | } 39 | } 40 | 41 | // Debug enables debug logging on the ent.Driver. 42 | func Debug() Option { 43 | return func(c *config) { 44 | c.debug = true 45 | } 46 | } 47 | 48 | // Log sets the logging function for debug mode. 49 | func Log(fn func(...any)) Option { 50 | return func(c *config) { 51 | c.log = fn 52 | } 53 | } 54 | 55 | // Driver configures the client driver. 56 | func Driver(driver dialect.Driver) Option { 57 | return func(c *config) { 58 | c.driver = driver 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /internal/http/router.go: -------------------------------------------------------------------------------- 1 | package http 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "net" 7 | "net/http" 8 | 9 | "github.com/prometheus/client_golang/prometheus/promhttp" 10 | "gitlab.ozon.dev/stepanov.ao.dev/telegram-bot/pkg/log" 11 | ) 12 | 13 | type Config struct { 14 | Port int `yaml:"port"` 15 | } 16 | 17 | type HttpRouter struct { 18 | port int 19 | server *http.Server 20 | logger log.Logger 21 | } 22 | 23 | func NewHttpRouter(config Config, logger log.Logger) *HttpRouter { 24 | serveMux := http.NewServeMux() 25 | serveMux.Handle("/metrics", promhttp.Handler()) 26 | 27 | return &HttpRouter{ 28 | port: config.Port, 29 | server: &http.Server{ 30 | Handler: serveMux, 31 | }, 32 | logger: logger.With(log.ComponentKey, "Http server"), 33 | } 34 | } 35 | 36 | func (r *HttpRouter) Start() error { 37 | listener, err := net.Listen("tcp", fmt.Sprintf(":%d", r.port)) 38 | if err != nil { 39 | return fmt.Errorf("failed to listen the port %d: %w", r.port, err) 40 | } 41 | 42 | go func() { 43 | r.logger.Infof("server is listening the port %d", r.port) 44 | 45 | if err := r.server.Serve(listener); err != http.ErrServerClosed { 46 | r.logger.WithError(err). 47 | Fatalf("fail to serve the server on the port %d", r.port) 48 | } 49 | }() 50 | return nil 51 | } 52 | 53 | func (r *HttpRouter) Stop(ctx context.Context) error { 54 | r.logger.Info("http router is stopping") 55 | return r.server.Shutdown(ctx) 56 | } 57 | -------------------------------------------------------------------------------- /internal/grpc/telegram_bot.go: -------------------------------------------------------------------------------- 1 | package grpc 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | 7 | "gitlab.ozon.dev/stepanov.ao.dev/telegram-bot/internal/api" 8 | "gitlab.ozon.dev/stepanov.ao.dev/telegram-bot/internal/models/enums" 9 | ) 10 | 11 | //go:generate mockery --name=telegramClient --dir . --output ./mocks --exported 12 | type telegramClient interface { 13 | SendMessage(ctx context.Context, userID int64, text string) error 14 | } 15 | 16 | type cacheService interface { 17 | Set(ctx context.Context, userID int64, command enums.CommandType, value string) error 18 | } 19 | 20 | type TelegramBotClient struct { 21 | api.UnimplementedTelegramBotServer 22 | 23 | tgClient telegramClient 24 | cache cacheService 25 | } 26 | 27 | func NewTelegramBotClient(tgClient telegramClient, cacheService cacheService) *TelegramBotClient { 28 | return &TelegramBotClient{ 29 | tgClient: tgClient, 30 | cache: cacheService, 31 | } 32 | } 33 | 34 | func (c *TelegramBotClient) SendMessage(ctx context.Context, msg *api.Message) (*api.EmptyMessage, error) { 35 | err := c.tgClient.SendMessage(ctx, msg.GetUserId(), msg.GetText()) 36 | if err != nil { 37 | return nil, fmt.Errorf("failed to send message by tg client: %w", err) 38 | } 39 | 40 | err = c.cache.Set(ctx, msg.GetUserId(), enums.CommandType(msg.GetCommand()), msg.GetText()) 41 | if err != nil { 42 | return nil, fmt.Errorf("failed to set value to the cache: %w", err) 43 | } 44 | 45 | return &api.EmptyMessage{}, nil 46 | } 47 | -------------------------------------------------------------------------------- /cmd/migrate/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "flag" 6 | "fmt" 7 | "log" 8 | 9 | atlas "ariga.io/atlas/sql/migrate" 10 | "entgo.io/ent/dialect" 11 | "entgo.io/ent/dialect/sql/schema" 12 | _ "github.com/lib/pq" 13 | 14 | "gitlab.ozon.dev/stepanov.ao.dev/telegram-bot/internal/app/startup" 15 | "gitlab.ozon.dev/stepanov.ao.dev/telegram-bot/internal/ent/migrate" 16 | ) 17 | 18 | func main() { 19 | configFile := flag.String("config", "", "path to configuration file") 20 | migrationName := flag.String("name", "", "name of current migration") 21 | flag.Parse() 22 | 23 | config, err := startup.NewMigrationConfig(*configFile) 24 | if err != nil { 25 | log.Fatalf("failed to init config: %v", err) 26 | } 27 | 28 | ctx := context.Background() 29 | 30 | dir, err := atlas.NewLocalDir("./internal/migrations") 31 | if err != nil { 32 | log.Fatalf("failed creating atlas migration directory: %v", err) 33 | } 34 | 35 | opts := []schema.MigrateOption{ 36 | schema.WithDir(dir), 37 | schema.WithMigrationMode(schema.ModeInspect), 38 | schema.WithDialect(dialect.Postgres), 39 | schema.WithFormatter(atlas.DefaultFormatter), 40 | } 41 | 42 | url := fmt.Sprintf("postgresql://%s:%s@%s:%d/%s?sslmode=%s", 43 | config.Database.User, config.Database.Password, config.Database.Host, 44 | config.Database.Port, config.Database.DBName, config.Database.SslMode) 45 | err = migrate.NamedDiff(ctx, url, *migrationName, opts...) 46 | if err != nil { 47 | log.Fatalf("failed generating migration file: %v", err) 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /internal/clients/grpc/telegram_bot.go: -------------------------------------------------------------------------------- 1 | package grpc 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | 7 | "google.golang.org/grpc" 8 | "google.golang.org/grpc/credentials/insecure" 9 | 10 | "gitlab.ozon.dev/stepanov.ao.dev/telegram-bot/internal/api" 11 | "gitlab.ozon.dev/stepanov.ao.dev/telegram-bot/internal/models/enums" 12 | "gitlab.ozon.dev/stepanov.ao.dev/telegram-bot/pkg/log" 13 | ) 14 | 15 | type Config struct { 16 | Host string `yaml:"host"` 17 | Port int `yaml:"port"` 18 | } 19 | 20 | type TelegramBot struct { 21 | config Config 22 | logger log.Logger 23 | 24 | conn *grpc.ClientConn 25 | client api.TelegramBotClient 26 | } 27 | 28 | func NewTelegramBot(config Config, logger log.Logger) *TelegramBot { 29 | return &TelegramBot{ 30 | config: config, 31 | logger: logger, 32 | } 33 | } 34 | 35 | func (b *TelegramBot) Start() error { 36 | conn, err := grpc.Dial(fmt.Sprintf("%s:%d", b.config.Host, b.config.Port), 37 | grpc.WithTransportCredentials(insecure.NewCredentials())) 38 | if err != nil { 39 | return fmt.Errorf("failed to connect to grpc on address %s:%d: %w", b.config.Host, b.config.Port, err) 40 | } 41 | 42 | b.conn = conn 43 | b.client = api.NewTelegramBotClient(b.conn) 44 | 45 | return nil 46 | } 47 | 48 | func (b *TelegramBot) Stop(ctx context.Context) error { 49 | return b.conn.Close() 50 | } 51 | 52 | func (b *TelegramBot) SendMessage(ctx context.Context, userID int64, text string, command enums.CommandType) error { 53 | _, err := b.client.SendMessage(ctx, &api.Message{ 54 | UserId: userID, 55 | Text: text, 56 | Command: string(command), 57 | }) 58 | return err 59 | } 60 | -------------------------------------------------------------------------------- /internal/grpc/server.go: -------------------------------------------------------------------------------- 1 | package grpc 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "net" 7 | 8 | "google.golang.org/grpc" 9 | 10 | "gitlab.ozon.dev/stepanov.ao.dev/telegram-bot/internal/api" 11 | "gitlab.ozon.dev/stepanov.ao.dev/telegram-bot/pkg/log" 12 | ) 13 | 14 | type Config struct { 15 | Port int `yaml:"port"` 16 | } 17 | 18 | type Server struct { 19 | port int 20 | logger log.Logger 21 | 22 | server *grpc.Server 23 | 24 | tgClient telegramClient 25 | cache cacheService 26 | 27 | isClosed bool 28 | } 29 | 30 | func NewServer(config Config, tgClient telegramClient, cacheService cacheService, logger log.Logger) *Server { 31 | return &Server{ 32 | port: config.Port, 33 | logger: logger.With(log.ComponentKey, "Grpc server"), 34 | 35 | tgClient: tgClient, 36 | cache: cacheService, 37 | 38 | isClosed: false, 39 | } 40 | } 41 | 42 | func (s *Server) Start() error { 43 | listener, err := net.Listen("tcp", fmt.Sprintf(":%d", s.port)) 44 | if err != nil { 45 | return fmt.Errorf("failed to listen port %d: %w", s.port, err) 46 | } 47 | 48 | s.server = grpc.NewServer() 49 | api.RegisterTelegramBotServer(s.server, NewTelegramBotClient(s.tgClient, s.cache)) 50 | 51 | go func() { 52 | s.logger.Infof("server is listening the port %d", s.port) 53 | 54 | if err := s.server.Serve(listener); err != nil && !s.isClosed { 55 | s.logger.WithError(err). 56 | Fatalf("fail to serve the server on the port %d", s.port) 57 | } 58 | }() 59 | 60 | return nil 61 | } 62 | 63 | func (s *Server) Stop(ctx context.Context) error { 64 | s.logger.Info("grpc server is stopping") 65 | s.isClosed = true 66 | s.server.Stop() 67 | return nil 68 | } 69 | -------------------------------------------------------------------------------- /internal/metrics/tracer_telegram_client.go: -------------------------------------------------------------------------------- 1 | package metrics 2 | 3 | import ( 4 | "context" 5 | 6 | "gitlab.ozon.dev/stepanov.ao.dev/telegram-bot/internal/models" 7 | tracesdk "go.opentelemetry.io/otel/sdk/trace" 8 | "go.opentelemetry.io/otel/trace" 9 | ) 10 | 11 | type TelegramClientTracerDecorator struct { 12 | tgClient telegramClient 13 | tracer trace.Tracer 14 | } 15 | 16 | func NewTelegramClientTracerDecorator(tgClient telegramClient, tracerProvider *tracesdk.TracerProvider) *TelegramClientTracerDecorator { 17 | return &TelegramClientTracerDecorator{ 18 | tgClient: tgClient, 19 | tracer: tracerProvider.Tracer("telegram-bot-client"), 20 | } 21 | } 22 | 23 | func (d *TelegramClientTracerDecorator) SendMessage(ctx context.Context, userID int64, text string) error { 24 | ctxTrace, span := d.tracer.Start(ctx, "SendMessage") 25 | defer span.End() 26 | 27 | return d.tgClient.SendMessage(ctxTrace, userID, text) 28 | } 29 | 30 | func (d *TelegramClientTracerDecorator) SendMessageWithoutRemovingKeyboard(ctx context.Context, userID int64, text string) error { 31 | ctxTrace, span := d.tracer.Start(ctx, "SendMessageWithoutRemovingKeyboard") 32 | defer span.End() 33 | 34 | return d.tgClient.SendMessageWithoutRemovingKeyboard(ctxTrace, userID, text) 35 | } 36 | 37 | func (d *TelegramClientTracerDecorator) SendKeyboard(ctx context.Context, userID int64, text string, rows [][]string) error { 38 | ctxTrace, span := d.tracer.Start(ctx, "SendKeyboard") 39 | defer span.End() 40 | 41 | return d.tgClient.SendKeyboard(ctxTrace, userID, text, rows) 42 | } 43 | 44 | func (d *TelegramClientTracerDecorator) GetUpdatesChan() <-chan *models.Message { 45 | return d.tgClient.GetUpdatesChan() 46 | } 47 | -------------------------------------------------------------------------------- /internal/metrics/tracer_user_postgres.go: -------------------------------------------------------------------------------- 1 | package metrics 2 | 3 | import ( 4 | "context" 5 | 6 | "gitlab.ozon.dev/stepanov.ao.dev/telegram-bot/internal/models" 7 | tracesdk "go.opentelemetry.io/otel/sdk/trace" 8 | "go.opentelemetry.io/otel/trace" 9 | ) 10 | 11 | type UserRepositoryTracerDecorator struct { 12 | userRepo userRepository 13 | tracer trace.Tracer 14 | } 15 | 16 | func NewUserRepositoryTracerDecorator(userRepo userRepository, tracerProvider *tracesdk.TracerProvider) *UserRepositoryTracerDecorator { 17 | return &UserRepositoryTracerDecorator{ 18 | userRepo: userRepo, 19 | tracer: tracerProvider.Tracer("user-repository"), 20 | } 21 | } 22 | 23 | func (d *UserRepositoryTracerDecorator) UserExists(ctx context.Context, id int64) (bool, error) { 24 | ctxTrace, span := d.tracer.Start(ctx, "UserExists") 25 | defer span.End() 26 | 27 | return d.userRepo.UserExists(ctxTrace, id) 28 | } 29 | 30 | func (d *UserRepositoryTracerDecorator) AddUser(ctx context.Context, user *models.User) (*models.User, error) { 31 | ctxTrace, span := d.tracer.Start(ctx, "AddUser") 32 | defer span.End() 33 | 34 | return d.userRepo.AddUser(ctxTrace, user) 35 | } 36 | 37 | func (d *UserRepositoryTracerDecorator) SetWasteLimit(ctx context.Context, id int64, limit uint64) (*models.User, error) { 38 | ctxTrace, span := d.tracer.Start(ctx, "SetWasteLimit") 39 | defer span.End() 40 | 41 | return d.userRepo.SetWasteLimit(ctxTrace, id, limit) 42 | } 43 | 44 | func (d *UserRepositoryTracerDecorator) GetWasteLimit(ctx context.Context, id int64) (*uint64, error) { 45 | ctxTrace, span := d.tracer.Start(ctx, "GetWasteLimit") 46 | defer span.End() 47 | 48 | return d.userRepo.GetWasteLimit(ctxTrace, id) 49 | } 50 | -------------------------------------------------------------------------------- /internal/metrics/tracer_cache_service.go: -------------------------------------------------------------------------------- 1 | package metrics 2 | 3 | import ( 4 | "context" 5 | 6 | "gitlab.ozon.dev/stepanov.ao.dev/telegram-bot/internal/models/enums" 7 | tracesdk "go.opentelemetry.io/otel/sdk/trace" 8 | "go.opentelemetry.io/otel/trace" 9 | ) 10 | 11 | type CacheServiceTracerDecorator struct { 12 | service cacheService 13 | tracer trace.Tracer 14 | } 15 | 16 | func NewCacheServiceTracerDecorator(service cacheService, tracerProvider *tracesdk.TracerProvider) *CacheServiceTracerDecorator { 17 | return &CacheServiceTracerDecorator{ 18 | service: service, 19 | tracer: tracerProvider.Tracer("cache-service"), 20 | } 21 | } 22 | 23 | func (d *CacheServiceTracerDecorator) Set(ctx context.Context, userID int64, command enums.CommandType, value string) error { 24 | ctxTrace, span := d.tracer.Start(ctx, "Set") 25 | defer span.End() 26 | 27 | return d.service.Set(ctxTrace, userID, command, value) 28 | } 29 | 30 | func (d *CacheServiceTracerDecorator) Get(ctx context.Context, userID int64, command enums.CommandType) (string, error) { 31 | ctxTrace, span := d.tracer.Start(ctx, "Get") 32 | defer span.End() 33 | 34 | return d.service.Get(ctxTrace, userID, command) 35 | } 36 | 37 | func (d *CacheServiceTracerDecorator) Clear(ctx context.Context, userID int64, command enums.CommandType) error { 38 | ctxTrace, span := d.tracer.Start(ctx, "Clear") 39 | defer span.End() 40 | 41 | return d.service.Clear(ctxTrace, userID, command) 42 | } 43 | 44 | func (d *CacheServiceTracerDecorator) ClearKeys(ctx context.Context, userID int64, commands ...enums.CommandType) error { 45 | ctxTrace, span := d.tracer.Start(ctx, "ClearKeys") 46 | defer span.End() 47 | 48 | return d.service.ClearKeys(ctxTrace, userID, commands...) 49 | } 50 | -------------------------------------------------------------------------------- /internal/bot/iteration_message.go: -------------------------------------------------------------------------------- 1 | package bot 2 | 3 | import ( 4 | "context" 5 | 6 | "gitlab.ozon.dev/stepanov.ao.dev/telegram-bot/internal/models" 7 | "gitlab.ozon.dev/stepanov.ao.dev/telegram-bot/pkg/log" 8 | ) 9 | 10 | // IterationMessage just start the handler which is choosen by Bot. 11 | // 12 | // Separate from Bot for metrics and tracer. 13 | type IterationMessage struct { 14 | tgClient telegramClient 15 | } 16 | 17 | func NewIterationMessage(tgClient telegramClient) *IterationMessage { 18 | return &IterationMessage{ 19 | tgClient: tgClient, 20 | } 21 | } 22 | 23 | // Iterate runs the message handler. 24 | func (i *IterationMessage) Iterate(ctx context.Context, message *models.Message, handler MessageHandler, logger log.Logger) { 25 | response, err := handler(ctx, message) 26 | if err != nil { 27 | logger.WithError(err). 28 | With("message", message). 29 | Error("failed to respond the message") 30 | err := i.tgClient.SendMessage(ctx, message.From.ID, messageInternalError) 31 | if err != nil { 32 | logger.WithError(err). 33 | With("response", response). 34 | With("message", message). 35 | Error("failed to send the message") 36 | } 37 | return 38 | } 39 | 40 | if response.Keyboard != nil { 41 | err = i.tgClient.SendKeyboard(ctx, message.From.ID, response.Message, response.Keyboard) 42 | } else if response.DoNotRemoveKeyboard { 43 | err = i.tgClient.SendMessageWithoutRemovingKeyboard(ctx, message.From.ID, response.Message) 44 | } else { 45 | err = i.tgClient.SendMessage(ctx, message.From.ID, response.Message) 46 | } 47 | 48 | if err != nil { 49 | logger.WithError(err). 50 | With("response", response). 51 | With("message", message). 52 | Error("failed to send the message") 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /internal/metrics/tracer_usercontext.go: -------------------------------------------------------------------------------- 1 | package metrics 2 | 3 | import ( 4 | "context" 5 | 6 | "gitlab.ozon.dev/stepanov.ao.dev/telegram-bot/internal/models/enums" 7 | tracesdk "go.opentelemetry.io/otel/sdk/trace" 8 | "go.opentelemetry.io/otel/trace" 9 | ) 10 | 11 | type UserContextServiceTracerDecorator struct { 12 | service userContextService 13 | tracer trace.Tracer 14 | } 15 | 16 | func NewUserContextServiceTracerDecorator(service userContextService, tracerProvider *tracesdk.TracerProvider) *UserContextServiceTracerDecorator { 17 | return &UserContextServiceTracerDecorator{ 18 | service: service, 19 | tracer: tracerProvider.Tracer("usercontext-service"), 20 | } 21 | } 22 | 23 | func (d *UserContextServiceTracerDecorator) SetContext(ctx context.Context, userID int64, context enums.UserContext) error { 24 | ctxTrace, span := d.tracer.Start(ctx, "SetContext") 25 | defer span.End() 26 | 27 | return d.service.SetContext(ctxTrace, userID, context) 28 | } 29 | 30 | func (d *UserContextServiceTracerDecorator) GetContext(ctx context.Context, userID int64) (enums.UserContext, error) { 31 | ctxTrace, span := d.tracer.Start(ctx, "GetContext") 32 | defer span.End() 33 | 34 | return d.service.GetContext(ctxTrace, userID) 35 | } 36 | 37 | func (d *UserContextServiceTracerDecorator) SetCurrency(ctx context.Context, userID int64, currency string) error { 38 | ctxTrace, span := d.tracer.Start(ctx, "SetCurrency") 39 | defer span.End() 40 | 41 | return d.service.SetCurrency(ctxTrace, userID, currency) 42 | } 43 | 44 | func (d *UserContextServiceTracerDecorator) GetCurrency(ctx context.Context, userID int64) (string, error) { 45 | ctxTrace, span := d.tracer.Start(ctx, "GetCurrency") 46 | defer span.End() 47 | 48 | return d.service.GetCurrency(ctxTrace, userID) 49 | } 50 | -------------------------------------------------------------------------------- /internal/bot/handlers/default_handler.go: -------------------------------------------------------------------------------- 1 | package handlers 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | 7 | "gitlab.ozon.dev/stepanov.ao.dev/telegram-bot/internal/bot" 8 | "gitlab.ozon.dev/stepanov.ao.dev/telegram-bot/internal/models" 9 | "gitlab.ozon.dev/stepanov.ao.dev/telegram-bot/internal/models/enums" 10 | ) 11 | 12 | const ( 13 | messageHelp = `**Данный бот предназначен для ведения трат по категориям** 14 | 15 | /add - для добавления новой траты 16 | /setLimit - установить лимит на месяц 17 | /getLimit - узнать текущий лимит на месяц 18 | /week - отчет по тратам за последнюю неделю 19 | /month - отчет по тратам за последний месяц 20 | /year - отчет по тратам за последний год 21 | /currency - сменить валюту` 22 | 23 | messageIncorrectContext = "Неизвестное состояние пользователя, состояние сброшено до стандартного" 24 | ) 25 | 26 | func (h *MessageHandlers) defaultHandler(ctx context.Context, message *models.Message) (*bot.MessageResponse, error) { 27 | userContext, err := h.userContextService.GetContext(ctx, message.From.ID) 28 | if err != nil { 29 | return nil, fmt.Errorf("failed to get user context: %w", err) 30 | } 31 | 32 | switch userContext { 33 | case enums.NoContext: 34 | return &bot.MessageResponse{ 35 | Message: messageHelp, 36 | }, nil 37 | 38 | case enums.AddWaste: 39 | return h.addWaste(ctx, message) 40 | 41 | case enums.ChangeCurrency: 42 | return h.changeCurrency(ctx, message) 43 | 44 | case enums.SetLimit: 45 | return h.setLimit(ctx, message) 46 | 47 | default: 48 | err := h.userContextService.SetContext(ctx, message.From.ID, enums.NoContext) 49 | if err != nil { 50 | return nil, fmt.Errorf("failed to set user context: %w", err) 51 | } 52 | return &bot.MessageResponse{ 53 | Message: messageIncorrectContext, 54 | }, nil 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /internal/clients/exchange/client.go: -------------------------------------------------------------------------------- 1 | package exchange 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | "fmt" 7 | "net/http" 8 | "net/url" 9 | "strings" 10 | 11 | "gitlab.ozon.dev/stepanov.ao.dev/telegram-bot/internal/clients/exchange/dto" 12 | "gitlab.ozon.dev/stepanov.ao.dev/telegram-bot/internal/models" 13 | ) 14 | 15 | type Config struct { 16 | Endpoint string `yaml:"endpoint"` 17 | } 18 | 19 | type Client struct { 20 | endpoint *url.URL 21 | httpClient http.Client 22 | } 23 | 24 | func NewClient(config Config) (*Client, error) { 25 | return NewClientWithHttpClient(config, http.Client{}) 26 | } 27 | 28 | func NewClientWithHttpClient(config Config, httpClient http.Client) (*Client, error) { 29 | endpoint, err := url.Parse(config.Endpoint) 30 | if err != nil { 31 | return nil, fmt.Errorf("failed to parse endpoint: %w", err) 32 | } 33 | 34 | return &Client{ 35 | endpoint: endpoint, 36 | httpClient: httpClient, 37 | }, nil 38 | } 39 | 40 | func (c *Client) GetExchange(ctx context.Context, base string, symbols []string) (*models.ExchangeData, error) { 41 | reqURL := c.endpoint 42 | values := reqURL.Query() 43 | values.Add("base", base) 44 | values.Add("symbols", strings.Join(symbols, ",")) 45 | reqURL.RawQuery = values.Encode() 46 | 47 | request, err := http.NewRequestWithContext(ctx, "GET", reqURL.String(), nil) 48 | if err != nil { 49 | return nil, fmt.Errorf("create request: %w", err) 50 | } 51 | 52 | resp, err := c.httpClient.Do(request) 53 | if err != nil { 54 | return nil, fmt.Errorf("do request: %w", err) 55 | } 56 | 57 | var result dto.ExchangeData 58 | if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { 59 | return nil, fmt.Errorf("parse body: %w", err) 60 | } 61 | 62 | return models.NewExchangeData(result.Base, result.Rates), nil 63 | } 64 | -------------------------------------------------------------------------------- /internal/ent/user/user.go: -------------------------------------------------------------------------------- 1 | // Code generated by ent, DO NOT EDIT. 2 | 3 | package user 4 | 5 | const ( 6 | // Label holds the string label denoting the user type in the database. 7 | Label = "user" 8 | // FieldID holds the string denoting the id field in the database. 9 | FieldID = "id" 10 | // FieldFirstName holds the string denoting the first_name field in the database. 11 | FieldFirstName = "first_name" 12 | // FieldLastName holds the string denoting the last_name field in the database. 13 | FieldLastName = "last_name" 14 | // FieldUserName holds the string denoting the user_name field in the database. 15 | FieldUserName = "user_name" 16 | // FieldWasteLimit holds the string denoting the waste_limit field in the database. 17 | FieldWasteLimit = "waste_limit" 18 | // EdgeWastes holds the string denoting the wastes edge name in mutations. 19 | EdgeWastes = "wastes" 20 | // Table holds the table name of the user in the database. 21 | Table = "users" 22 | // WastesTable is the table that holds the wastes relation/edge. 23 | WastesTable = "wastes" 24 | // WastesInverseTable is the table name for the Waste entity. 25 | // It exists in this package in order to avoid circular dependency with the "waste" package. 26 | WastesInverseTable = "wastes" 27 | // WastesColumn is the table column denoting the wastes relation/edge. 28 | WastesColumn = "user_wastes" 29 | ) 30 | 31 | // Columns holds all SQL columns for user fields. 32 | var Columns = []string{ 33 | FieldID, 34 | FieldFirstName, 35 | FieldLastName, 36 | FieldUserName, 37 | FieldWasteLimit, 38 | } 39 | 40 | // ValidColumn reports if the column name is valid (part of the table columns). 41 | func ValidColumn(column string) bool { 42 | for i := range Columns { 43 | if column == Columns[i] { 44 | return true 45 | } 46 | } 47 | return false 48 | } 49 | -------------------------------------------------------------------------------- /internal/app/startup/config.go: -------------------------------------------------------------------------------- 1 | package startup 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | 7 | "go.uber.org/zap/zapcore" 8 | "gopkg.in/yaml.v3" 9 | 10 | "gitlab.ozon.dev/stepanov.ao.dev/telegram-bot/internal/app" 11 | exchangeclient "gitlab.ozon.dev/stepanov.ao.dev/telegram-bot/internal/clients/exchange" 12 | "gitlab.ozon.dev/stepanov.ao.dev/telegram-bot/internal/clients/telegram" 13 | "gitlab.ozon.dev/stepanov.ao.dev/telegram-bot/internal/grpc" 14 | "gitlab.ozon.dev/stepanov.ao.dev/telegram-bot/internal/http" 15 | "gitlab.ozon.dev/stepanov.ao.dev/telegram-bot/internal/metrics" 16 | "gitlab.ozon.dev/stepanov.ao.dev/telegram-bot/internal/service/cache" 17 | exchangeservice "gitlab.ozon.dev/stepanov.ao.dev/telegram-bot/internal/service/exchange" 18 | ) 19 | 20 | type Config struct { 21 | App app.Config `yaml:"app"` 22 | Telegram telegram.Config `yaml:"telegram"` 23 | ExchangeClient exchangeclient.Config `yaml:"exchange_client"` 24 | Currency exchangeservice.Config `yaml:"currency"` 25 | Database DatabaseConfig `yaml:"database"` 26 | Redis RedisConfig `yaml:"redis"` 27 | Kafka KafkaConfig `yaml:"kafka"` 28 | Cache cache.Config `yaml:"cache"` 29 | Http http.Config `yaml:"http"` 30 | Grpc grpc.Config `yaml:"grpc"` 31 | Metrics metrics.Config `yaml:"metrics"` 32 | 33 | LogLevel zapcore.Level `yaml:"log_level"` 34 | } 35 | 36 | func NewConfig(configFile string) (*Config, error) { 37 | rawYAML, err := os.ReadFile(configFile) 38 | if err != nil { 39 | return nil, fmt.Errorf("reading file error: %w", err) 40 | } 41 | 42 | cfg := &Config{} 43 | if err = yaml.Unmarshal(rawYAML, cfg); err != nil { 44 | return nil, fmt.Errorf("yaml parsing error: %w", err) 45 | } 46 | 47 | return cfg, nil 48 | } 49 | -------------------------------------------------------------------------------- /internal/service/cache/cache.go: -------------------------------------------------------------------------------- 1 | package cache 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "time" 7 | 8 | "github.com/go-redis/redis/v9" 9 | "gitlab.ozon.dev/stepanov.ao.dev/telegram-bot/internal/models/enums" 10 | ) 11 | 12 | type Config struct { 13 | Expiration time.Duration `yaml:"expiration"` 14 | } 15 | 16 | type Service struct { 17 | client *redis.Client 18 | config Config 19 | } 20 | 21 | func NewService(client *redis.Client, config Config) *Service { 22 | return &Service{ 23 | client: client, 24 | config: config, 25 | } 26 | } 27 | 28 | func getKey(userID int64, command enums.CommandType) string { 29 | return fmt.Sprintf("cache_%d_%s", userID, command) 30 | } 31 | 32 | func (s *Service) Set(ctx context.Context, userID int64, command enums.CommandType, value string) error { 33 | err := s.client.Set(ctx, getKey(userID, command), value, s.config.Expiration).Err() 34 | if err != nil { 35 | return fmt.Errorf("failed to set value to the cache: %w", err) 36 | } 37 | 38 | return nil 39 | } 40 | 41 | func (s *Service) Get(ctx context.Context, userID int64, command enums.CommandType) (string, error) { 42 | value, err := s.client.Get(ctx, getKey(userID, command)).Result() 43 | if err != nil { 44 | return "", fmt.Errorf("failed to get value from the cache: %w", err) 45 | } 46 | 47 | return value, nil 48 | } 49 | 50 | func (s *Service) Clear(ctx context.Context, userID int64, command enums.CommandType) error { 51 | err := s.client.Del(ctx, getKey(userID, command)).Err() 52 | if err != nil { 53 | return fmt.Errorf("failed to delete a key from the cache: %w", err) 54 | } 55 | 56 | return nil 57 | } 58 | 59 | func (s *Service) ClearKeys(ctx context.Context, userID int64, commands ...enums.CommandType) error { 60 | for _, command := range commands { 61 | err := s.Clear(ctx, userID, command) 62 | if err != nil { 63 | return err 64 | } 65 | } 66 | 67 | return nil 68 | } 69 | -------------------------------------------------------------------------------- /internal/app/app.go: -------------------------------------------------------------------------------- 1 | package app 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "os/signal" 7 | "syscall" 8 | "time" 9 | 10 | "golang.org/x/sync/errgroup" 11 | 12 | "gitlab.ozon.dev/stepanov.ao.dev/telegram-bot/pkg/log" 13 | ) 14 | 15 | type component interface { 16 | Start() error 17 | Stop(ctx context.Context) error 18 | } 19 | 20 | type Config struct { 21 | GracefulTimeout time.Duration `yaml:"graceful_timeout"` 22 | } 23 | 24 | type App struct { 25 | components []component 26 | config Config 27 | logger log.Logger 28 | } 29 | 30 | func New(config Config, logger log.Logger, components ...component) *App { 31 | return &App{ 32 | components: components, 33 | config: config, 34 | logger: logger.With(log.ComponentKey, "App"), 35 | } 36 | } 37 | 38 | func (a *App) Run(ctx context.Context) error { 39 | a.logger.Info("App is starting") 40 | defer a.logger.Info("App closed") 41 | 42 | ctxNotify, stop := signal.NotifyContext(ctx, syscall.SIGINT, syscall.SIGTERM) 43 | defer stop() 44 | 45 | g := &errgroup.Group{} 46 | 47 | for _, c := range a.components { 48 | g.Go(c.Start) 49 | } 50 | 51 | if err := g.Wait(); err != nil { 52 | return fmt.Errorf("failed to start components: %w", err) 53 | } 54 | 55 | a.logger.Info("All components have been started") 56 | 57 | <-ctxNotify.Done() 58 | a.logger.Info("App received stop signal, all components are stopping") 59 | 60 | if err := a.stop(); err != nil { 61 | return fmt.Errorf("failed to stop components: %w", err) 62 | } 63 | 64 | return nil 65 | } 66 | 67 | func (a *App) stop() error { 68 | ctx, cancel := context.WithTimeout(context.Background(), a.config.GracefulTimeout) 69 | defer cancel() 70 | 71 | g := errgroup.Group{} 72 | 73 | for _, c := range a.components { 74 | func(c component) { 75 | g.Go(func() error { 76 | return c.Stop(ctx) 77 | }) 78 | }(c) 79 | } 80 | 81 | return g.Wait() 82 | } 83 | -------------------------------------------------------------------------------- /internal/bot/handlers/report_handler.go: -------------------------------------------------------------------------------- 1 | package handlers 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | 7 | "gitlab.ozon.dev/stepanov.ao.dev/telegram-bot/internal/bot" 8 | "gitlab.ozon.dev/stepanov.ao.dev/telegram-bot/internal/models" 9 | "gitlab.ozon.dev/stepanov.ao.dev/telegram-bot/internal/models/requests" 10 | ) 11 | 12 | const generatingReportMessage = "Отчет генерируется..." 13 | 14 | func (h *MessageHandlers) weekHandler(ctx context.Context, message *models.Message) (*bot.MessageResponse, error) { 15 | return h.generateReportForUser(ctx, message, requests.PeriodWeek) 16 | } 17 | 18 | func (h *MessageHandlers) monthHandler(ctx context.Context, message *models.Message) (*bot.MessageResponse, error) { 19 | return h.generateReportForUser(ctx, message, requests.PeriodMonth) 20 | } 21 | 22 | func (h *MessageHandlers) yearHandler(ctx context.Context, message *models.Message) (*bot.MessageResponse, error) { 23 | return h.generateReportForUser(ctx, message, requests.PeriodYear) 24 | } 25 | 26 | func (h *MessageHandlers) generateReportForUser(ctx context.Context, message *models.Message, period requests.Period) (*bot.MessageResponse, error) { 27 | exchange, designation, err := h.getExchangeOfUser(ctx, message.From.ID) 28 | if err != nil { 29 | return nil, fmt.Errorf("failed to get exchage and designation for the user: %w", err) 30 | } 31 | 32 | req := requests.GetReport{ 33 | UserID: message.From.ID, 34 | Period: period, 35 | CurrencyExchange: exchange, 36 | CurrencyDesignation: designation, 37 | } 38 | 39 | key := []byte{} 40 | value, err := req.MarshalJSON() 41 | if err != nil { 42 | return nil, fmt.Errorf("failed to marshal the request: %w", err) 43 | } 44 | 45 | err = h.kafkaProducer.SendMessage(ctx, key, value) 46 | if err != nil { 47 | return nil, fmt.Errorf("failed to send the message to the kafka: %w", err) 48 | } 49 | 50 | return &bot.MessageResponse{ 51 | Message: generatingReportMessage, 52 | }, nil 53 | } 54 | -------------------------------------------------------------------------------- /internal/metrics/amount_errors_cache_service.go: -------------------------------------------------------------------------------- 1 | package metrics 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/prometheus/client_golang/prometheus" 7 | "github.com/prometheus/client_golang/prometheus/promauto" 8 | "gitlab.ozon.dev/stepanov.ao.dev/telegram-bot/internal/models/enums" 9 | ) 10 | 11 | type CacheServiceAmountErrorsDecorator struct { 12 | service cacheService 13 | countErrors *prometheus.CounterVec 14 | } 15 | 16 | func NewCacheServiceAmountErrorsDecorator(service cacheService) *CacheServiceAmountErrorsDecorator { 17 | return &CacheServiceAmountErrorsDecorator{ 18 | service: service, 19 | countErrors: promauto.NewCounterVec(prometheus.CounterOpts{ 20 | Name: "count_errors_cache_service", 21 | Help: "Count of errors in CacheService methods", 22 | }, []string{"method"}), 23 | } 24 | } 25 | 26 | func (d *CacheServiceAmountErrorsDecorator) Set(ctx context.Context, userID int64, command enums.CommandType, value string) error { 27 | err := d.service.Set(ctx, userID, command, value) 28 | if err != nil { 29 | d.countErrors.WithLabelValues("Set").Inc() 30 | } 31 | return err 32 | } 33 | 34 | func (d *CacheServiceAmountErrorsDecorator) Get(ctx context.Context, userID int64, command enums.CommandType) (string, error) { 35 | res, err := d.service.Get(ctx, userID, command) 36 | if err != nil { 37 | d.countErrors.WithLabelValues("Get").Inc() 38 | } 39 | return res, err 40 | } 41 | 42 | func (d *CacheServiceAmountErrorsDecorator) Clear(ctx context.Context, userID int64, command enums.CommandType) error { 43 | err := d.service.Clear(ctx, userID, command) 44 | if err != nil { 45 | d.countErrors.WithLabelValues("Clear").Inc() 46 | } 47 | return err 48 | } 49 | 50 | func (d *CacheServiceAmountErrorsDecorator) ClearKeys(ctx context.Context, userID int64, commands ...enums.CommandType) error { 51 | err := d.service.ClearKeys(ctx, userID, commands...) 52 | if err != nil { 53 | d.countErrors.WithLabelValues("ClearKeys").Inc() 54 | } 55 | return err 56 | } 57 | -------------------------------------------------------------------------------- /internal/bot/handlers/set_limit_handler.go: -------------------------------------------------------------------------------- 1 | package handlers 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "strconv" 7 | 8 | "gitlab.ozon.dev/stepanov.ao.dev/telegram-bot/internal/bot" 9 | "gitlab.ozon.dev/stepanov.ao.dev/telegram-bot/internal/models" 10 | "gitlab.ozon.dev/stepanov.ao.dev/telegram-bot/internal/models/enums" 11 | ) 12 | 13 | const ( 14 | messageSetLimitResponse = "Введите желаемый лимит в виде положительного вещественного числа в текущей валюте" 15 | messageSuccessfulSetLimit = "Лимит трат за месяц успешно установлен" 16 | ) 17 | 18 | func (h *MessageHandlers) setLimitHandler(ctx context.Context, message *models.Message) (*bot.MessageResponse, error) { 19 | err := h.userContextService.SetContext(ctx, message.From.ID, enums.SetLimit) 20 | if err != nil { 21 | return nil, fmt.Errorf("failed to set context for user: %w", err) 22 | } 23 | 24 | return &bot.MessageResponse{ 25 | Message: messageSetLimitResponse, 26 | }, nil 27 | } 28 | 29 | func (h *MessageHandlers) setLimit(ctx context.Context, message *models.Message) (*bot.MessageResponse, error) { 30 | limit, err := strconv.ParseFloat(message.Text, 64) 31 | if err != nil { 32 | return &bot.MessageResponse{ 33 | Message: messageIncorrectFormat, 34 | }, nil 35 | } 36 | 37 | if limit < 0 { 38 | return &bot.MessageResponse{ 39 | Message: messageIncorrectFormat, 40 | }, nil 41 | } 42 | 43 | exchange, _, err := h.getExchangeOfUser(ctx, message.From.ID) 44 | if err != nil { 45 | return nil, fmt.Errorf("failed to get exchange and designation for user: %w", err) 46 | } 47 | 48 | _, err = h.userRepo.SetWasteLimit(ctx, message.From.ID, h.convertToDefaultCurrency(limit, exchange)) 49 | if err != nil { 50 | return nil, fmt.Errorf("failed to set waste for user: %w", err) 51 | } 52 | 53 | err = h.userContextService.SetContext(ctx, message.From.ID, enums.NoContext) 54 | if err != nil { 55 | return nil, fmt.Errorf("failed to set context for user: %w", err) 56 | } 57 | 58 | return &bot.MessageResponse{ 59 | Message: messageSuccessfulSetLimit, 60 | }, nil 61 | } 62 | -------------------------------------------------------------------------------- /internal/service/kafka/consumer.go: -------------------------------------------------------------------------------- 1 | package kafka 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/segmentio/kafka-go" 7 | 8 | "gitlab.ozon.dev/stepanov.ao.dev/telegram-bot/internal/models" 9 | "gitlab.ozon.dev/stepanov.ao.dev/telegram-bot/pkg/log" 10 | ) 11 | 12 | type ConsumerConfig struct { 13 | BufferSize uint `yaml:"buffer_size"` 14 | } 15 | 16 | type Consumer struct { 17 | client *kafka.Reader 18 | config ConsumerConfig 19 | logger log.Logger 20 | 21 | cancel context.CancelFunc 22 | done chan struct{} 23 | 24 | messages chan *models.KafkaMessage 25 | } 26 | 27 | func NewConsumer(client *kafka.Reader, config ConsumerConfig, logger log.Logger) *Consumer { 28 | return &Consumer{ 29 | client: client, 30 | config: config, 31 | logger: logger.With(log.ComponentKey, "Kafka consumer"), 32 | } 33 | } 34 | 35 | func (c *Consumer) Start() error { 36 | ctx, cancel := context.WithCancel(context.Background()) 37 | 38 | c.cancel = cancel 39 | c.done = make(chan struct{}) 40 | 41 | c.messages = make(chan *models.KafkaMessage, c.config.BufferSize) 42 | 43 | go c.run(ctx) 44 | 45 | return nil 46 | } 47 | 48 | func (c *Consumer) Stop(ctx context.Context) error { 49 | c.cancel() 50 | 51 | select { 52 | case <-c.done: 53 | return nil 54 | case <-ctx.Done(): 55 | return ctx.Err() 56 | } 57 | } 58 | 59 | func (c *Consumer) run(ctx context.Context) { 60 | for { 61 | select { 62 | case <-ctx.Done(): 63 | c.logger.WithError(ctx.Err()).Info("kafka consumer has been closed") 64 | close(c.messages) 65 | close(c.done) 66 | 67 | default: 68 | msg, err := c.client.ReadMessage(ctx) 69 | if err != nil { 70 | c.logger. 71 | WithError(err). 72 | Error("failed to read message from kafka") 73 | continue 74 | } 75 | 76 | c.logger.With("kafka_message", msg).Debug("recieved message from Kafka") 77 | 78 | c.messages <- &models.KafkaMessage{ 79 | Key: msg.Key, 80 | Message: msg.Value, 81 | } 82 | } 83 | } 84 | } 85 | 86 | func (c *Consumer) GetMessageChan() <-chan *models.KafkaMessage { 87 | return c.messages 88 | } 89 | -------------------------------------------------------------------------------- /internal/ent/waste/waste.go: -------------------------------------------------------------------------------- 1 | // Code generated by ent, DO NOT EDIT. 2 | 3 | package waste 4 | 5 | import ( 6 | "github.com/google/uuid" 7 | ) 8 | 9 | const ( 10 | // Label holds the string label denoting the waste type in the database. 11 | Label = "waste" 12 | // FieldID holds the string denoting the id field in the database. 13 | FieldID = "id" 14 | // FieldCost holds the string denoting the cost field in the database. 15 | FieldCost = "cost" 16 | // FieldCategory holds the string denoting the category field in the database. 17 | FieldCategory = "category" 18 | // FieldDate holds the string denoting the date field in the database. 19 | FieldDate = "date" 20 | // EdgeUser holds the string denoting the user edge name in mutations. 21 | EdgeUser = "user" 22 | // Table holds the table name of the waste in the database. 23 | Table = "wastes" 24 | // UserTable is the table that holds the user relation/edge. 25 | UserTable = "wastes" 26 | // UserInverseTable is the table name for the User entity. 27 | // It exists in this package in order to avoid circular dependency with the "user" package. 28 | UserInverseTable = "users" 29 | // UserColumn is the table column denoting the user relation/edge. 30 | UserColumn = "user_wastes" 31 | ) 32 | 33 | // Columns holds all SQL columns for waste fields. 34 | var Columns = []string{ 35 | FieldID, 36 | FieldCost, 37 | FieldCategory, 38 | FieldDate, 39 | } 40 | 41 | // ForeignKeys holds the SQL foreign-keys that are owned by the "wastes" 42 | // table and are not defined as standalone fields in the schema. 43 | var ForeignKeys = []string{ 44 | "user_wastes", 45 | } 46 | 47 | // ValidColumn reports if the column name is valid (part of the table columns). 48 | func ValidColumn(column string) bool { 49 | for i := range Columns { 50 | if column == Columns[i] { 51 | return true 52 | } 53 | } 54 | for i := range ForeignKeys { 55 | if column == ForeignKeys[i] { 56 | return true 57 | } 58 | } 59 | return false 60 | } 61 | 62 | var ( 63 | // DefaultID holds the default value on creation for the "id" field. 64 | DefaultID func() uuid.UUID 65 | ) 66 | -------------------------------------------------------------------------------- /pkg/log/zap/logger_impl.go: -------------------------------------------------------------------------------- 1 | package zap 2 | 3 | import ( 4 | "os" 5 | 6 | "gitlab.ozon.dev/stepanov.ao.dev/telegram-bot/pkg/log" 7 | "go.uber.org/zap" 8 | "go.uber.org/zap/zapcore" 9 | ) 10 | 11 | const errKey = "error" 12 | 13 | type LoggerImpl struct { 14 | logger *zap.SugaredLogger 15 | } 16 | 17 | func NewLogger(loggerName string, logLevel zapcore.Level) log.Logger { 18 | config := zap.NewProductionEncoderConfig() 19 | config.EncodeTime = zapcore.ISO8601TimeEncoder 20 | encoder := zapcore.NewJSONEncoder(config) 21 | 22 | core := zapcore.NewCore(encoder, zapcore.AddSync(os.Stdout), logLevel) 23 | logger := zap.New(core, 24 | zap.Fields(zap.String("name", loggerName)), 25 | ).Sugar() 26 | 27 | return &LoggerImpl{ 28 | logger: logger, 29 | } 30 | } 31 | 32 | func (log *LoggerImpl) With(args ...interface{}) log.Logger { 33 | return &LoggerImpl{ 34 | logger: log.logger.With(args...), 35 | } 36 | } 37 | 38 | func (log *LoggerImpl) WithError(err error) log.Logger { 39 | return log.With(errKey, err) 40 | } 41 | 42 | func (log *LoggerImpl) Debug(args ...interface{}) { 43 | log.logger.Debug(args...) 44 | } 45 | 46 | func (log *LoggerImpl) Info(args ...interface{}) { 47 | log.logger.Info(args...) 48 | } 49 | 50 | func (log *LoggerImpl) Warn(args ...interface{}) { 51 | log.logger.Warn(args...) 52 | } 53 | 54 | func (log *LoggerImpl) Error(args ...interface{}) { 55 | log.logger.Error(args...) 56 | } 57 | 58 | func (log *LoggerImpl) Fatal(args ...interface{}) { 59 | log.logger.Fatal(args...) 60 | } 61 | 62 | func (log *LoggerImpl) Debugf(template string, args ...interface{}) { 63 | log.logger.Debugf(template, args...) 64 | } 65 | 66 | func (log *LoggerImpl) Infof(template string, args ...interface{}) { 67 | log.logger.Infof(template, args...) 68 | } 69 | 70 | func (log *LoggerImpl) Warnf(template string, args ...interface{}) { 71 | log.logger.Warnf(template, args...) 72 | } 73 | 74 | func (log *LoggerImpl) Errorf(template string, args ...interface{}) { 75 | log.logger.Errorf(template, args...) 76 | } 77 | 78 | func (log *LoggerImpl) Fatalf(template string, args ...interface{}) { 79 | log.logger.Fatalf(template, args...) 80 | } 81 | -------------------------------------------------------------------------------- /internal/repository/users.go: -------------------------------------------------------------------------------- 1 | package repository 2 | 3 | import ( 4 | "context" 5 | 6 | "gitlab.ozon.dev/stepanov.ao.dev/telegram-bot/internal/ent" 7 | "gitlab.ozon.dev/stepanov.ao.dev/telegram-bot/internal/ent/user" 8 | "gitlab.ozon.dev/stepanov.ao.dev/telegram-bot/internal/models" 9 | ) 10 | 11 | type UserRepository struct { 12 | client *ent.Client 13 | } 14 | 15 | func NewUserRepository(client *ent.Client) *UserRepository { 16 | return &UserRepository{ 17 | client: client, 18 | } 19 | } 20 | 21 | func (r *UserRepository) AddUser(ctx context.Context, user *models.User) (*models.User, error) { 22 | model, err := r.client.User. 23 | Create(). 24 | SetID(user.ID). 25 | SetFirstName(user.FirstName). 26 | SetLastName(user.LastName). 27 | SetUserName(user.UserName). 28 | Save(ctx) 29 | if err != nil { 30 | return nil, err 31 | } 32 | 33 | return &models.User{ 34 | User: model, 35 | }, nil 36 | } 37 | 38 | func (r *UserRepository) UserExists(ctx context.Context, id int64) (bool, error) { 39 | exists, err := r.client.User.Query(). 40 | Where(user.ID(id)). 41 | Exist(ctx) 42 | if err != nil { 43 | return false, err 44 | } 45 | 46 | return exists, err 47 | } 48 | 49 | func (r *UserRepository) GetUser(ctx context.Context, id int64) (*models.User, error) { 50 | model, err := r.client.User.Get(ctx, id) 51 | if err != nil { 52 | return nil, err 53 | } 54 | 55 | return &models.User{ 56 | User: model, 57 | }, nil 58 | } 59 | 60 | func (r *UserRepository) SetWasteLimit(ctx context.Context, id int64, limit uint64) (*models.User, error) { 61 | model, err := r.GetUser(ctx, id) 62 | if err != nil { 63 | return nil, err 64 | } 65 | 66 | updated, err := model.Update().SetWasteLimit(limit).Save(ctx) 67 | if err != nil { 68 | return nil, err 69 | } 70 | 71 | return &models.User{ 72 | User: updated, 73 | }, nil 74 | } 75 | 76 | func (r *UserRepository) GetWasteLimit(ctx context.Context, id int64) (*uint64, error) { 77 | model, err := r.client.User.Query(). 78 | Select(user.FieldWasteLimit). 79 | Where(user.ID(id)). 80 | First(ctx) 81 | if err != nil { 82 | return nil, err 83 | } 84 | 85 | return model.WasteLimit, nil 86 | } 87 | -------------------------------------------------------------------------------- /internal/metrics/tracer_waste_postgres.go: -------------------------------------------------------------------------------- 1 | package metrics 2 | 3 | import ( 4 | "context" 5 | "time" 6 | 7 | "gitlab.ozon.dev/stepanov.ao.dev/telegram-bot/internal/models" 8 | tracesdk "go.opentelemetry.io/otel/sdk/trace" 9 | "go.opentelemetry.io/otel/trace" 10 | ) 11 | 12 | type WasteRepositoryTracerDecorator struct { 13 | wasteRepo wasteRepository 14 | tracer trace.Tracer 15 | } 16 | 17 | func NewWasteRepositoryTracerDecorator(wasteRepo wasteRepository, tracerProvider *tracesdk.TracerProvider) *WasteRepositoryTracerDecorator { 18 | return &WasteRepositoryTracerDecorator{ 19 | wasteRepo: wasteRepo, 20 | tracer: tracerProvider.Tracer("waste-repository"), 21 | } 22 | } 23 | 24 | func (d *WasteRepositoryTracerDecorator) GetReportLastWeek(ctx context.Context, userID int64) ([]*models.CategoryReport, error) { 25 | ctxTrace, span := d.tracer.Start(ctx, "GetReportLstWeek") 26 | defer span.End() 27 | 28 | return d.wasteRepo.GetReportLastWeek(ctxTrace, userID) 29 | } 30 | 31 | func (d *WasteRepositoryTracerDecorator) GetReportLastMonth(ctx context.Context, userID int64) ([]*models.CategoryReport, error) { 32 | ctxTrace, span := d.tracer.Start(ctx, "GetReportLastMonth") 33 | defer span.End() 34 | 35 | return d.wasteRepo.GetReportLastMonth(ctxTrace, userID) 36 | } 37 | 38 | func (d *WasteRepositoryTracerDecorator) GetReportLastYear(ctx context.Context, userID int64) ([]*models.CategoryReport, error) { 39 | ctxTrace, span := d.tracer.Start(ctx, "GetReportLastYear") 40 | defer span.End() 41 | 42 | return d.wasteRepo.GetReportLastYear(ctxTrace, userID) 43 | } 44 | 45 | func (d *WasteRepositoryTracerDecorator) SumOfWastesAfterDate(ctx context.Context, userID int64, date time.Time) (int64, error) { 46 | ctxTrace, span := d.tracer.Start(ctx, "SumOfWastesAfterDate") 47 | defer span.End() 48 | 49 | return d.wasteRepo.SumOfWastesAfterDate(ctxTrace, userID, date) 50 | } 51 | 52 | func (d *WasteRepositoryTracerDecorator) AddWasteToUser(ctx context.Context, userID int64, waste *models.Waste) (*models.Waste, error) { 53 | ctxTrace, span := d.tracer.Start(ctx, "AddWasteToUser") 54 | defer span.End() 55 | 56 | return d.wasteRepo.AddWasteToUser(ctxTrace, userID, waste) 57 | } 58 | -------------------------------------------------------------------------------- /internal/bot/handlers/currency_handler.go: -------------------------------------------------------------------------------- 1 | package handlers 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | 7 | "gitlab.ozon.dev/stepanov.ao.dev/telegram-bot/internal/bot" 8 | "gitlab.ozon.dev/stepanov.ao.dev/telegram-bot/internal/models" 9 | "gitlab.ozon.dev/stepanov.ao.dev/telegram-bot/internal/models/enums" 10 | ) 11 | 12 | const ( 13 | messageChooseCurrency = "Выберите валюту из предложенных на клавиатуре" 14 | messageSuccessfulChangeCurrency = "Валюта успешно изменена на " 15 | ) 16 | 17 | func (h *MessageHandlers) currencyHandler(ctx context.Context, message *models.Message) (*bot.MessageResponse, error) { 18 | err := h.userContextService.SetContext(ctx, message.From.ID, enums.ChangeCurrency) 19 | if err != nil { 20 | return nil, fmt.Errorf("failed to set context for user: %w", err) 21 | } 22 | 23 | defaultCurrency := h.exchangeService.GetDefaultCurrency() 24 | usedCurrencies := h.exchangeService.GetUsedCurrencies() 25 | 26 | usedCurrenciesKeyboardButtons := make([][]string, len(usedCurrencies)+1) 27 | usedCurrenciesKeyboardButtons[0] = []string{defaultCurrency} 28 | for i := range usedCurrencies { 29 | usedCurrenciesKeyboardButtons[i+1] = []string{usedCurrencies[i]} 30 | } 31 | 32 | return &bot.MessageResponse{ 33 | Message: messageChooseCurrency, 34 | Keyboard: usedCurrenciesKeyboardButtons, 35 | }, nil 36 | } 37 | 38 | func (h *MessageHandlers) changeCurrency(ctx context.Context, message *models.Message) (*bot.MessageResponse, error) { 39 | _, err := h.exchangeService.GetExchange(message.Text) 40 | if err != nil { 41 | return &bot.MessageResponse{ 42 | Message: messageChooseCurrency, 43 | DoNotRemoveKeyboard: true, 44 | }, nil 45 | } 46 | 47 | err = h.userContextService.SetContext(ctx, message.From.ID, enums.NoContext) 48 | if err != nil { 49 | return nil, fmt.Errorf("failed to set context for user: %w", err) 50 | } 51 | 52 | err = h.userContextService.SetCurrency(ctx, message.From.ID, message.Text) 53 | if err != nil { 54 | return nil, fmt.Errorf("failed to set currency for user: %w", err) 55 | } 56 | 57 | return &bot.MessageResponse{ 58 | Message: messageSuccessfulChangeCurrency + message.Text, 59 | }, nil 60 | } 61 | -------------------------------------------------------------------------------- /internal/ent/migrate/schema.go: -------------------------------------------------------------------------------- 1 | // Code generated by ent, DO NOT EDIT. 2 | 3 | package migrate 4 | 5 | import ( 6 | "entgo.io/ent/dialect/sql/schema" 7 | "entgo.io/ent/schema/field" 8 | ) 9 | 10 | var ( 11 | // UsersColumns holds the columns for the "users" table. 12 | UsersColumns = []*schema.Column{ 13 | {Name: "id", Type: field.TypeInt64, Increment: true}, 14 | {Name: "first_name", Type: field.TypeString}, 15 | {Name: "last_name", Type: field.TypeString}, 16 | {Name: "user_name", Type: field.TypeString}, 17 | {Name: "waste_limit", Type: field.TypeUint64, Nullable: true}, 18 | } 19 | // UsersTable holds the schema information for the "users" table. 20 | UsersTable = &schema.Table{ 21 | Name: "users", 22 | Columns: UsersColumns, 23 | PrimaryKey: []*schema.Column{UsersColumns[0]}, 24 | Indexes: []*schema.Index{ 25 | { 26 | Name: "user_id", 27 | Unique: true, 28 | Columns: []*schema.Column{UsersColumns[0]}, 29 | }, 30 | }, 31 | } 32 | // WastesColumns holds the columns for the "wastes" table. 33 | WastesColumns = []*schema.Column{ 34 | {Name: "id", Type: field.TypeUUID}, 35 | {Name: "cost", Type: field.TypeInt64}, 36 | {Name: "category", Type: field.TypeString}, 37 | {Name: "date", Type: field.TypeTime}, 38 | {Name: "user_wastes", Type: field.TypeInt64, Nullable: true}, 39 | } 40 | // WastesTable holds the schema information for the "wastes" table. 41 | WastesTable = &schema.Table{ 42 | Name: "wastes", 43 | Columns: WastesColumns, 44 | PrimaryKey: []*schema.Column{WastesColumns[0]}, 45 | ForeignKeys: []*schema.ForeignKey{ 46 | { 47 | Symbol: "wastes_users_wastes", 48 | Columns: []*schema.Column{WastesColumns[4]}, 49 | RefColumns: []*schema.Column{UsersColumns[0]}, 50 | OnDelete: schema.SetNull, 51 | }, 52 | }, 53 | Indexes: []*schema.Index{ 54 | { 55 | Name: "waste_category", 56 | Unique: false, 57 | Columns: []*schema.Column{WastesColumns[2]}, 58 | }, 59 | }, 60 | } 61 | // Tables holds all the tables in the schema. 62 | Tables = []*schema.Table{ 63 | UsersTable, 64 | WastesTable, 65 | } 66 | ) 67 | 68 | func init() { 69 | WastesTable.ForeignKeys[0].RefTable = UsersTable 70 | } 71 | -------------------------------------------------------------------------------- /internal/metrics/latency_user_postgres.go: -------------------------------------------------------------------------------- 1 | package metrics 2 | 3 | import ( 4 | "context" 5 | "time" 6 | 7 | "github.com/prometheus/client_golang/prometheus" 8 | "github.com/prometheus/client_golang/prometheus/promauto" 9 | "gitlab.ozon.dev/stepanov.ao.dev/telegram-bot/internal/models" 10 | ) 11 | 12 | type UserRepositoryLatencyDecorator struct { 13 | userRepo userRepository 14 | latency *prometheus.HistogramVec 15 | } 16 | 17 | func NewUserRepositoryLatencyDecorator(userRepo userRepository) *UserRepositoryLatencyDecorator { 18 | return &UserRepositoryLatencyDecorator{ 19 | userRepo: userRepo, 20 | latency: promauto.NewHistogramVec(prometheus.HistogramOpts{ 21 | Name: "latency_user_repository", 22 | Help: "Duration of UserRepository methods", 23 | Buckets: []float64{0.0001, 0.0005, 0.001, 0.005, 0.01, 0.05, 0.1, 0.5, 1.0, 2.0}, 24 | }, []string{"method"}), 25 | } 26 | } 27 | 28 | func (d *UserRepositoryLatencyDecorator) UserExists(ctx context.Context, id int64) (bool, error) { 29 | startTime := time.Now() 30 | res, err := d.userRepo.UserExists(ctx, id) 31 | duration := time.Since(startTime) 32 | 33 | d.latency.WithLabelValues("UserExists").Observe(duration.Seconds()) 34 | 35 | return res, err 36 | } 37 | 38 | func (d *UserRepositoryLatencyDecorator) AddUser(ctx context.Context, user *models.User) (*models.User, error) { 39 | startTime := time.Now() 40 | res, err := d.userRepo.AddUser(ctx, user) 41 | duration := time.Since(startTime) 42 | 43 | d.latency.WithLabelValues("AddUser").Observe(duration.Seconds()) 44 | 45 | return res, err 46 | } 47 | 48 | func (d *UserRepositoryLatencyDecorator) SetWasteLimit(ctx context.Context, id int64, limit uint64) (*models.User, error) { 49 | startTime := time.Now() 50 | res, err := d.userRepo.SetWasteLimit(ctx, id, limit) 51 | duration := time.Since(startTime) 52 | 53 | d.latency.WithLabelValues("SetWasteLimit").Observe(duration.Seconds()) 54 | 55 | return res, err 56 | } 57 | 58 | func (d *UserRepositoryLatencyDecorator) GetWasteLimit(ctx context.Context, id int64) (*uint64, error) { 59 | startTime := time.Now() 60 | res, err := d.userRepo.GetWasteLimit(ctx, id) 61 | duration := time.Since(startTime) 62 | 63 | d.latency.WithLabelValues("GetWasteLimit").Observe(duration.Seconds()) 64 | 65 | return res, err 66 | } 67 | -------------------------------------------------------------------------------- /internal/ent/enttest/enttest.go: -------------------------------------------------------------------------------- 1 | // Code generated by ent, DO NOT EDIT. 2 | 3 | package enttest 4 | 5 | import ( 6 | "context" 7 | 8 | "gitlab.ozon.dev/stepanov.ao.dev/telegram-bot/internal/ent" 9 | // required by schema hooks. 10 | _ "gitlab.ozon.dev/stepanov.ao.dev/telegram-bot/internal/ent/runtime" 11 | 12 | "entgo.io/ent/dialect/sql/schema" 13 | "gitlab.ozon.dev/stepanov.ao.dev/telegram-bot/internal/ent/migrate" 14 | ) 15 | 16 | type ( 17 | // TestingT is the interface that is shared between 18 | // testing.T and testing.B and used by enttest. 19 | TestingT interface { 20 | FailNow() 21 | Error(...any) 22 | } 23 | 24 | // Option configures client creation. 25 | Option func(*options) 26 | 27 | options struct { 28 | opts []ent.Option 29 | migrateOpts []schema.MigrateOption 30 | } 31 | ) 32 | 33 | // WithOptions forwards options to client creation. 34 | func WithOptions(opts ...ent.Option) Option { 35 | return func(o *options) { 36 | o.opts = append(o.opts, opts...) 37 | } 38 | } 39 | 40 | // WithMigrateOptions forwards options to auto migration. 41 | func WithMigrateOptions(opts ...schema.MigrateOption) Option { 42 | return func(o *options) { 43 | o.migrateOpts = append(o.migrateOpts, opts...) 44 | } 45 | } 46 | 47 | func newOptions(opts []Option) *options { 48 | o := &options{} 49 | for _, opt := range opts { 50 | opt(o) 51 | } 52 | return o 53 | } 54 | 55 | // Open calls ent.Open and auto-run migration. 56 | func Open(t TestingT, driverName, dataSourceName string, opts ...Option) *ent.Client { 57 | o := newOptions(opts) 58 | c, err := ent.Open(driverName, dataSourceName, o.opts...) 59 | if err != nil { 60 | t.Error(err) 61 | t.FailNow() 62 | } 63 | migrateSchema(t, c, o) 64 | return c 65 | } 66 | 67 | // NewClient calls ent.NewClient and auto-run migration. 68 | func NewClient(t TestingT, opts ...Option) *ent.Client { 69 | o := newOptions(opts) 70 | c := ent.NewClient(o.opts...) 71 | migrateSchema(t, c, o) 72 | return c 73 | } 74 | func migrateSchema(t TestingT, c *ent.Client, o *options) { 75 | tables, err := schema.CopyTables(migrate.Tables) 76 | if err != nil { 77 | t.Error(err) 78 | t.FailNow() 79 | } 80 | if err := migrate.Create(context.Background(), c.Schema, tables, o.migrateOpts...); err != nil { 81 | t.Error(err) 82 | t.FailNow() 83 | } 84 | } 85 | -------------------------------------------------------------------------------- /internal/metrics/latency_usercontext.go: -------------------------------------------------------------------------------- 1 | package metrics 2 | 3 | import ( 4 | "context" 5 | "time" 6 | 7 | "github.com/prometheus/client_golang/prometheus" 8 | "github.com/prometheus/client_golang/prometheus/promauto" 9 | "gitlab.ozon.dev/stepanov.ao.dev/telegram-bot/internal/models/enums" 10 | ) 11 | 12 | type UserContextServiceLatencyDecorator struct { 13 | service userContextService 14 | latency *prometheus.HistogramVec 15 | } 16 | 17 | func NewUserContextServiceLatencyDecorator(service userContextService) *UserContextServiceLatencyDecorator { 18 | return &UserContextServiceLatencyDecorator{ 19 | service: service, 20 | latency: promauto.NewHistogramVec(prometheus.HistogramOpts{ 21 | Name: "latency_usercontext_service", 22 | Help: "Duration of UserContextService methods", 23 | Buckets: []float64{0.0001, 0.0005, 0.001, 0.005, 0.01, 0.05, 0.1, 0.5, 1.0, 2.0}, 24 | }, []string{"method"}), 25 | } 26 | } 27 | 28 | func (d *UserContextServiceLatencyDecorator) SetContext(ctx context.Context, userID int64, context enums.UserContext) error { 29 | startTime := time.Now() 30 | err := d.service.SetContext(ctx, userID, context) 31 | duration := time.Since(startTime) 32 | 33 | d.latency.WithLabelValues("SetContext").Observe(duration.Seconds()) 34 | 35 | return err 36 | } 37 | 38 | func (d *UserContextServiceLatencyDecorator) GetContext(ctx context.Context, userID int64) (enums.UserContext, error) { 39 | startTime := time.Now() 40 | res, err := d.service.GetContext(ctx, userID) 41 | duration := time.Since(startTime) 42 | 43 | d.latency.WithLabelValues("GetContext").Observe(duration.Seconds()) 44 | 45 | return res, err 46 | } 47 | 48 | func (d *UserContextServiceLatencyDecorator) SetCurrency(ctx context.Context, userID int64, currency string) error { 49 | startTime := time.Now() 50 | err := d.service.SetCurrency(ctx, userID, currency) 51 | duration := time.Since(startTime) 52 | 53 | d.latency.WithLabelValues("SetCurrency").Observe(duration.Seconds()) 54 | 55 | return err 56 | } 57 | 58 | func (d *UserContextServiceLatencyDecorator) GetCurrency(ctx context.Context, userID int64) (string, error) { 59 | startTime := time.Now() 60 | res, err := d.service.GetCurrency(ctx, userID) 61 | duration := time.Since(startTime) 62 | 63 | d.latency.WithLabelValues("GetCurrency").Observe(duration.Seconds()) 64 | 65 | return res, err 66 | } 67 | -------------------------------------------------------------------------------- /internal/metrics/amount_errors_user_postgres.go: -------------------------------------------------------------------------------- 1 | package metrics 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/prometheus/client_golang/prometheus" 7 | "github.com/prometheus/client_golang/prometheus/promauto" 8 | "gitlab.ozon.dev/stepanov.ao.dev/telegram-bot/internal/models" 9 | ) 10 | 11 | //go:generate mockery --name=userRepository --dir . --output ./mocks --exported 12 | type userRepository interface { 13 | UserExists(ctx context.Context, id int64) (bool, error) 14 | AddUser(ctx context.Context, user *models.User) (*models.User, error) 15 | 16 | SetWasteLimit(ctx context.Context, id int64, limit uint64) (*models.User, error) 17 | GetWasteLimit(ctx context.Context, id int64) (*uint64, error) 18 | } 19 | 20 | type UserRepositoryAmountErrorsDecorator struct { 21 | userRepo userRepository 22 | countErrors *prometheus.CounterVec 23 | } 24 | 25 | func NewUserRepositoryAmountErrorsDecorator(userRepo userRepository) *UserRepositoryAmountErrorsDecorator { 26 | return &UserRepositoryAmountErrorsDecorator{ 27 | userRepo: userRepo, 28 | countErrors: promauto.NewCounterVec(prometheus.CounterOpts{ 29 | Name: "count_errors_user_repository", 30 | Help: "Count of errors in UserRepository methods", 31 | }, []string{"method"}), 32 | } 33 | } 34 | 35 | func (d *UserRepositoryAmountErrorsDecorator) UserExists(ctx context.Context, id int64) (bool, error) { 36 | res, err := d.userRepo.UserExists(ctx, id) 37 | if err != nil { 38 | d.countErrors.WithLabelValues("UserExists").Inc() 39 | } 40 | return res, err 41 | } 42 | 43 | func (d *UserRepositoryAmountErrorsDecorator) AddUser(ctx context.Context, user *models.User) (*models.User, error) { 44 | res, err := d.userRepo.AddUser(ctx, user) 45 | if err != nil { 46 | d.countErrors.WithLabelValues("AddUser").Inc() 47 | } 48 | return res, err 49 | } 50 | 51 | func (d *UserRepositoryAmountErrorsDecorator) SetWasteLimit(ctx context.Context, id int64, limit uint64) (*models.User, error) { 52 | res, err := d.userRepo.SetWasteLimit(ctx, id, limit) 53 | if err != nil { 54 | d.countErrors.WithLabelValues("SetWasteLimit").Inc() 55 | } 56 | return res, err 57 | } 58 | 59 | func (d *UserRepositoryAmountErrorsDecorator) GetWasteLimit(ctx context.Context, id int64) (*uint64, error) { 60 | res, err := d.userRepo.GetWasteLimit(ctx, id) 61 | if err != nil { 62 | d.countErrors.WithLabelValues("GetWasteLimit").Inc() 63 | } 64 | return res, err 65 | } 66 | -------------------------------------------------------------------------------- /internal/metrics/amount_errors_usercontext.go: -------------------------------------------------------------------------------- 1 | package metrics 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/prometheus/client_golang/prometheus" 7 | "github.com/prometheus/client_golang/prometheus/promauto" 8 | "gitlab.ozon.dev/stepanov.ao.dev/telegram-bot/internal/models/enums" 9 | ) 10 | 11 | //go:generate mockery --name=userContextService --dir . --output ./mocks --exported 12 | type userContextService interface { 13 | SetContext(ctx context.Context, userID int64, context enums.UserContext) error 14 | GetContext(ctx context.Context, userID int64) (enums.UserContext, error) 15 | SetCurrency(ctx context.Context, userID int64, currency string) error 16 | GetCurrency(ctx context.Context, userID int64) (string, error) 17 | } 18 | 19 | type UserContextServiceAmountErrorsDecorator struct { 20 | service userContextService 21 | countErrors *prometheus.CounterVec 22 | } 23 | 24 | func NewUserContextServiceAmountErrorsDecorator(service userContextService) *UserContextServiceAmountErrorsDecorator { 25 | return &UserContextServiceAmountErrorsDecorator{ 26 | service: service, 27 | countErrors: promauto.NewCounterVec(prometheus.CounterOpts{ 28 | Name: "count_errors_usercontext_service", 29 | Help: "Count of errors in UserContextService methods", 30 | }, []string{"method"}), 31 | } 32 | } 33 | 34 | func (d *UserContextServiceAmountErrorsDecorator) SetContext(ctx context.Context, userID int64, context enums.UserContext) error { 35 | err := d.service.SetContext(ctx, userID, context) 36 | if err != nil { 37 | d.countErrors.WithLabelValues("SetContext").Inc() 38 | } 39 | return err 40 | } 41 | 42 | func (d *UserContextServiceAmountErrorsDecorator) GetContext(ctx context.Context, userID int64) (enums.UserContext, error) { 43 | res, err := d.service.GetContext(ctx, userID) 44 | if err != nil { 45 | d.countErrors.WithLabelValues("GetContext").Inc() 46 | } 47 | return res, err 48 | } 49 | 50 | func (d *UserContextServiceAmountErrorsDecorator) SetCurrency(ctx context.Context, userID int64, currency string) error { 51 | err := d.service.SetCurrency(ctx, userID, currency) 52 | if err != nil { 53 | d.countErrors.WithLabelValues("SetCurrency").Inc() 54 | } 55 | return err 56 | } 57 | 58 | func (d *UserContextServiceAmountErrorsDecorator) GetCurrency(ctx context.Context, userID int64) (string, error) { 59 | res, err := d.service.GetCurrency(ctx, userID) 60 | if err != nil { 61 | d.countErrors.WithLabelValues("GetCurrency").Inc() 62 | } 63 | return res, err 64 | } 65 | -------------------------------------------------------------------------------- /internal/metrics/latency_telegram_client.go: -------------------------------------------------------------------------------- 1 | package metrics 2 | 3 | import ( 4 | "context" 5 | "time" 6 | 7 | "github.com/prometheus/client_golang/prometheus" 8 | "github.com/prometheus/client_golang/prometheus/promauto" 9 | "gitlab.ozon.dev/stepanov.ao.dev/telegram-bot/internal/models" 10 | ) 11 | 12 | //go:generate mockery --name=telegramClient --dir . --output ./mocks --exported 13 | type telegramClient interface { 14 | SendMessage(ctx context.Context, userID int64, text string) error 15 | SendMessageWithoutRemovingKeyboard(ctx context.Context, userID int64, text string) error 16 | SendKeyboard(ctx context.Context, userID int64, text string, rows [][]string) error 17 | GetUpdatesChan() <-chan *models.Message 18 | } 19 | 20 | type TelegramClientLatencyDecorator struct { 21 | tgClient telegramClient 22 | latency *prometheus.HistogramVec 23 | } 24 | 25 | func NewTelegramClientLatencyDecorator(tgClient telegramClient) *TelegramClientLatencyDecorator { 26 | return &TelegramClientLatencyDecorator{ 27 | tgClient: tgClient, 28 | latency: promauto.NewHistogramVec(prometheus.HistogramOpts{ 29 | Name: "latency_telegram_client", 30 | Help: "Duration of TelegramCLient methods", 31 | Buckets: []float64{0.0001, 0.0005, 0.001, 0.005, 0.01, 0.05, 0.1, 0.5, 1.0, 2.0}, 32 | }, []string{"method"}), 33 | } 34 | } 35 | 36 | func (d *TelegramClientLatencyDecorator) SendMessage(ctx context.Context, userID int64, text string) error { 37 | startTime := time.Now() 38 | err := d.tgClient.SendMessage(ctx, userID, text) 39 | duration := time.Since(startTime) 40 | 41 | d.latency.WithLabelValues("SendMessage").Observe(duration.Seconds()) 42 | 43 | return err 44 | } 45 | 46 | func (d *TelegramClientLatencyDecorator) SendMessageWithoutRemovingKeyboard(ctx context.Context, userID int64, text string) error { 47 | startTime := time.Now() 48 | err := d.tgClient.SendMessageWithoutRemovingKeyboard(ctx, userID, text) 49 | duration := time.Since(startTime) 50 | 51 | d.latency.WithLabelValues("SendMessageWithoutRemovingKeyboard").Observe(duration.Seconds()) 52 | 53 | return err 54 | } 55 | 56 | func (d *TelegramClientLatencyDecorator) SendKeyboard(ctx context.Context, userID int64, text string, rows [][]string) error { 57 | startTime := time.Now() 58 | err := d.tgClient.SendKeyboard(ctx, userID, text, rows) 59 | duration := time.Since(startTime) 60 | 61 | d.latency.WithLabelValues("SendKeyboard").Observe(duration.Seconds()) 62 | 63 | return err 64 | } 65 | 66 | func (d *TelegramClientLatencyDecorator) GetUpdatesChan() <-chan *models.Message { 67 | return d.tgClient.GetUpdatesChan() 68 | } 69 | -------------------------------------------------------------------------------- /internal/service/usercontext/usercontext.go: -------------------------------------------------------------------------------- 1 | package usercontext 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | 7 | "github.com/go-redis/redis/v9" 8 | "gitlab.ozon.dev/stepanov.ao.dev/telegram-bot/internal/models/enums" 9 | ) 10 | 11 | const ( 12 | userContext = "usercontext" 13 | userCurrency = "usercurrency" 14 | ) 15 | 16 | type Service struct { 17 | client *redis.Client 18 | 19 | defaultCurrency string 20 | currencies map[int64]string 21 | } 22 | 23 | func getKey(userID int64, key string) string { 24 | return fmt.Sprintf("user_context_%s_%d", key, userID) 25 | } 26 | 27 | func NewService(client *redis.Client, defaultCurrency string) *Service { 28 | return &Service{ 29 | client: client, 30 | 31 | defaultCurrency: defaultCurrency, 32 | currencies: make(map[int64]string), 33 | } 34 | } 35 | 36 | func (s *Service) SetContext(ctx context.Context, userID int64, context enums.UserContext) error { 37 | err := s.client.Set(ctx, getKey(userID, userContext), int(context), 0).Err() 38 | if err != nil { 39 | return fmt.Errorf("failed to set user context: %w", err) 40 | } 41 | 42 | return nil 43 | } 44 | 45 | func (s *Service) GetContext(ctx context.Context, userID int64) (enums.UserContext, error) { 46 | userContext, err := s.client.Get(ctx, getKey(userID, userContext)).Int() 47 | if err == redis.Nil { 48 | err := s.SetContext(ctx, userID, enums.NoContext) 49 | if err != nil { 50 | return enums.NoContext, fmt.Errorf("failed to set context after receiving nil of getting: %w", err) 51 | } 52 | 53 | return enums.NoContext, nil 54 | } 55 | if err != nil { 56 | return enums.NoContext, fmt.Errorf("failed to set user context: %w", err) 57 | } 58 | 59 | return enums.UserContext(userContext), nil 60 | } 61 | 62 | func (s *Service) SetCurrency(ctx context.Context, userID int64, currency string) error { 63 | err := s.client.Set(ctx, getKey(userID, userCurrency), currency, 0).Err() 64 | if err != nil { 65 | return fmt.Errorf("failed to set user currency: %w", err) 66 | } 67 | 68 | return nil 69 | } 70 | 71 | func (s *Service) GetCurrency(ctx context.Context, userID int64) (string, error) { 72 | currency, err := s.client.Get(ctx, getKey(userID, userCurrency)).Result() 73 | if err == redis.Nil { 74 | err := s.SetCurrency(ctx, userID, s.defaultCurrency) 75 | if err != nil { 76 | return "", fmt.Errorf("failed to set currency after receiving nil of getting: %w", err) 77 | } 78 | 79 | return s.defaultCurrency, nil 80 | } 81 | if err != nil { 82 | return "", fmt.Errorf("failed to get user currency: %w", err) 83 | } 84 | 85 | return currency, nil 86 | } 87 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module gitlab.ozon.dev/stepanov.ao.dev/telegram-bot 2 | 3 | go 1.19 4 | 5 | require ( 6 | ariga.io/atlas v0.7.3-0.20221011160332-3ca609863edd 7 | entgo.io/ent v0.11.4 8 | github.com/go-redis/redis/v9 v9.0.0-rc.1 9 | github.com/go-telegram-bot-api/telegram-bot-api/v5 v5.5.1 10 | github.com/google/uuid v1.3.0 11 | github.com/lib/pq v1.10.7 12 | github.com/mailru/easyjson v0.7.7 13 | github.com/olekukonko/tablewriter v0.0.5 14 | github.com/prometheus/client_golang v1.13.0 15 | github.com/segmentio/kafka-go v0.4.36 16 | go.opentelemetry.io/otel v1.11.1 17 | go.opentelemetry.io/otel/exporters/jaeger v1.11.1 18 | go.opentelemetry.io/otel/sdk v1.11.1 19 | go.opentelemetry.io/otel/trace v1.11.1 20 | go.uber.org/zap v1.23.0 21 | golang.org/x/sync v0.0.0-20220929204114-8fcdb60fdcc0 22 | google.golang.org/grpc v1.50.1 23 | google.golang.org/protobuf v1.28.1 24 | gopkg.in/yaml.v3 v3.0.1 25 | ) 26 | 27 | require ( 28 | github.com/agext/levenshtein v1.2.1 // indirect 29 | github.com/apparentlymart/go-textseg/v13 v13.0.0 // indirect 30 | github.com/beorn7/perks v1.0.1 // indirect 31 | github.com/cespare/xxhash/v2 v2.1.2 // indirect 32 | github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect 33 | github.com/go-logr/logr v1.2.3 // indirect 34 | github.com/go-logr/stdr v1.2.2 // indirect 35 | github.com/go-openapi/inflect v0.19.0 // indirect 36 | github.com/golang/protobuf v1.5.2 // indirect 37 | github.com/google/go-cmp v0.5.9 // indirect 38 | github.com/hashicorp/hcl/v2 v2.13.0 // indirect 39 | github.com/josharian/intern v1.0.0 // indirect 40 | github.com/klauspost/compress v1.15.9 // indirect 41 | github.com/mattn/go-runewidth v0.0.9 // indirect 42 | github.com/matttproud/golang_protobuf_extensions v1.0.1 // indirect 43 | github.com/mitchellh/go-wordwrap v0.0.0-20150314170334-ad45545899c7 // indirect 44 | github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e // indirect 45 | github.com/pierrec/lz4/v4 v4.1.15 // indirect 46 | github.com/prometheus/client_model v0.2.0 // indirect 47 | github.com/prometheus/common v0.37.0 // indirect 48 | github.com/prometheus/procfs v0.8.0 // indirect 49 | github.com/zclconf/go-cty v1.8.0 // indirect 50 | go.uber.org/atomic v1.10.0 // indirect 51 | go.uber.org/multierr v1.8.0 // indirect 52 | golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4 // indirect 53 | golang.org/x/net v0.2.0 // indirect 54 | golang.org/x/sys v0.2.0 // indirect 55 | golang.org/x/text v0.4.0 // indirect 56 | google.golang.org/genproto v0.0.0-20221107162902-2d387536bcdd // indirect 57 | gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f // indirect 58 | ) 59 | -------------------------------------------------------------------------------- /cmd/report-service/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "flag" 6 | "log" 7 | 8 | "gitlab.ozon.dev/stepanov.ao.dev/telegram-bot/internal/app" 9 | "gitlab.ozon.dev/stepanov.ao.dev/telegram-bot/internal/app/startup" 10 | "gitlab.ozon.dev/stepanov.ao.dev/telegram-bot/internal/clients/grpc" 11 | "gitlab.ozon.dev/stepanov.ao.dev/telegram-bot/internal/http" 12 | "gitlab.ozon.dev/stepanov.ao.dev/telegram-bot/internal/metrics" 13 | "gitlab.ozon.dev/stepanov.ao.dev/telegram-bot/internal/repository" 14 | "gitlab.ozon.dev/stepanov.ao.dev/telegram-bot/internal/service/kafka" 15 | "gitlab.ozon.dev/stepanov.ao.dev/telegram-bot/internal/service/wastereport" 16 | ) 17 | 18 | const serviceName = "report-service" 19 | 20 | func main() { 21 | configFile := flag.String("config", "", "path to configuration file") 22 | flag.Parse() 23 | 24 | config, err := startup.NewReportServiceConfig(*configFile) 25 | if err != nil { 26 | log.Fatalf("failed to init config: %v", err) 27 | } 28 | 29 | logger := startup.NewLogger(serviceName, config.LogLevel) 30 | 31 | logger.With("config", config).Info("application staring with this config") 32 | 33 | tracerProvider, err := metrics.InitTracer(config.Metrics, serviceName) 34 | if err != nil { 35 | logger.WithError(err). 36 | Fatal("failed to create tracer") 37 | } 38 | 39 | dbClient, err := startup.DatabaseConnect(config.Database) 40 | if err != nil { 41 | logger.WithError(err). 42 | Fatal("failed to connect to database") 43 | } 44 | defer func() { 45 | if err := dbClient.Close(); err != nil { 46 | logger.WithError(err). 47 | Warn("failed to close database") 48 | } 49 | }() 50 | 51 | kafkaClient := startup.NewKafkaConsumer(config.Kafka) 52 | defer func() { 53 | if err := kafkaClient.Close(); err != nil { 54 | logger.WithError(err). 55 | Warn("failed to close kafka connection") 56 | } 57 | }() 58 | 59 | wasteRepo := metrics.NewWasteRepositoryTracerDecorator( 60 | metrics.NewWasteRepositoryAmountErrorsDecorator( 61 | metrics.NewWasteRepositoryLatencyDecorator( 62 | repository.NewWasteRepository(dbClient), 63 | ), 64 | ), tracerProvider, 65 | ) 66 | 67 | consumerComponent := kafka.NewConsumer(kafkaClient, config.Consumer, logger) 68 | 69 | httpRouter := http.NewHttpRouter(config.Http, logger) 70 | grpcClient := grpc.NewTelegramBot(config.Grpc, logger) 71 | 72 | reportService := wastereport.NewService(consumerComponent, wasteRepo, grpcClient, logger) 73 | 74 | err = app.New(config.App, logger, 75 | consumerComponent, 76 | httpRouter, 77 | grpcClient, 78 | reportService, 79 | ).Run(context.Background()) 80 | if err != nil { 81 | logger.WithError(err).Fatal("failed during running app") 82 | } 83 | } 84 | -------------------------------------------------------------------------------- /internal/metrics/latency_cache_service.go: -------------------------------------------------------------------------------- 1 | package metrics 2 | 3 | import ( 4 | "context" 5 | "time" 6 | 7 | "github.com/prometheus/client_golang/prometheus" 8 | "github.com/prometheus/client_golang/prometheus/promauto" 9 | "gitlab.ozon.dev/stepanov.ao.dev/telegram-bot/internal/models/enums" 10 | ) 11 | 12 | //go:generate mockery --name=cacheService --dir . --output ./mocks --exported 13 | type cacheService interface { 14 | Set(ctx context.Context, userID int64, command enums.CommandType, value string) error 15 | Get(ctx context.Context, userID int64, command enums.CommandType) (string, error) 16 | Clear(ctx context.Context, userID int64, command enums.CommandType) error 17 | ClearKeys(ctx context.Context, userID int64, commands ...enums.CommandType) error 18 | } 19 | 20 | type CacheServiceLatencyDecorator struct { 21 | service cacheService 22 | latency *prometheus.HistogramVec 23 | } 24 | 25 | func NewCacheServiceLatencyDecorator(service cacheService) *CacheServiceLatencyDecorator { 26 | return &CacheServiceLatencyDecorator{ 27 | service: service, 28 | latency: promauto.NewHistogramVec(prometheus.HistogramOpts{ 29 | Name: "latency_cache_service", 30 | Help: "Duration of CacheService methods", 31 | Buckets: []float64{0.0001, 0.0005, 0.001, 0.005, 0.01, 0.05, 0.1, 0.5, 1.0, 2.0}, 32 | }, []string{"method"}), 33 | } 34 | } 35 | 36 | func (d *CacheServiceLatencyDecorator) Set(ctx context.Context, userID int64, command enums.CommandType, value string) error { 37 | startTime := time.Now() 38 | err := d.service.Set(ctx, userID, command, value) 39 | duration := time.Since(startTime) 40 | 41 | d.latency.WithLabelValues("Set").Observe(duration.Seconds()) 42 | 43 | return err 44 | } 45 | 46 | func (d *CacheServiceLatencyDecorator) Get(ctx context.Context, userID int64, command enums.CommandType) (string, error) { 47 | startTime := time.Now() 48 | res, err := d.service.Get(ctx, userID, command) 49 | duration := time.Since(startTime) 50 | 51 | d.latency.WithLabelValues("Set").Observe(duration.Seconds()) 52 | 53 | return res, err 54 | } 55 | 56 | func (d *CacheServiceLatencyDecorator) Clear(ctx context.Context, userID int64, command enums.CommandType) error { 57 | startTime := time.Now() 58 | err := d.service.Clear(ctx, userID, command) 59 | duration := time.Since(startTime) 60 | 61 | d.latency.WithLabelValues("Clear").Observe(duration.Seconds()) 62 | 63 | return err 64 | } 65 | 66 | func (d *CacheServiceLatencyDecorator) ClearKeys(ctx context.Context, userID int64, commands ...enums.CommandType) error { 67 | startTime := time.Now() 68 | err := d.service.ClearKeys(ctx, userID, commands...) 69 | duration := time.Since(startTime) 70 | 71 | d.latency.WithLabelValues("ClearKeys").Observe(duration.Seconds()) 72 | 73 | return err 74 | } 75 | -------------------------------------------------------------------------------- /internal/metrics/latency_waste_postgres.go: -------------------------------------------------------------------------------- 1 | package metrics 2 | 3 | import ( 4 | "context" 5 | "time" 6 | 7 | "github.com/prometheus/client_golang/prometheus" 8 | "github.com/prometheus/client_golang/prometheus/promauto" 9 | "gitlab.ozon.dev/stepanov.ao.dev/telegram-bot/internal/models" 10 | ) 11 | 12 | type WasteRepositoryLatencyDecorator struct { 13 | wasteRepo wasteRepository 14 | latency *prometheus.HistogramVec 15 | } 16 | 17 | func NewWasteRepositoryLatencyDecorator(wasteRepo wasteRepository) *WasteRepositoryLatencyDecorator { 18 | return &WasteRepositoryLatencyDecorator{ 19 | wasteRepo: wasteRepo, 20 | latency: promauto.NewHistogramVec(prometheus.HistogramOpts{ 21 | Name: "latency_waste_repository", 22 | Help: "Duration of WasteRepository methods", 23 | Buckets: []float64{0.0001, 0.0005, 0.001, 0.005, 0.01, 0.05, 0.1, 0.5, 1.0, 2.0}, 24 | }, []string{"method"}), 25 | } 26 | } 27 | 28 | func (d *WasteRepositoryLatencyDecorator) GetReportLastWeek(ctx context.Context, userID int64) ([]*models.CategoryReport, error) { 29 | startTime := time.Now() 30 | res, err := d.wasteRepo.GetReportLastWeek(ctx, userID) 31 | duration := time.Since(startTime) 32 | 33 | d.latency.WithLabelValues("GetReportLastWeek").Observe(duration.Seconds()) 34 | 35 | return res, err 36 | } 37 | 38 | func (d *WasteRepositoryLatencyDecorator) GetReportLastMonth(ctx context.Context, userID int64) ([]*models.CategoryReport, error) { 39 | startTime := time.Now() 40 | res, err := d.wasteRepo.GetReportLastMonth(ctx, userID) 41 | duration := time.Since(startTime) 42 | 43 | d.latency.WithLabelValues("GetReportLastMonth").Observe(duration.Seconds()) 44 | 45 | return res, err 46 | } 47 | 48 | func (d *WasteRepositoryLatencyDecorator) GetReportLastYear(ctx context.Context, userID int64) ([]*models.CategoryReport, error) { 49 | startTime := time.Now() 50 | res, err := d.wasteRepo.GetReportLastYear(ctx, userID) 51 | duration := time.Since(startTime) 52 | 53 | d.latency.WithLabelValues("GetReportLastYear").Observe(duration.Seconds()) 54 | 55 | return res, err 56 | } 57 | 58 | func (d *WasteRepositoryLatencyDecorator) SumOfWastesAfterDate(ctx context.Context, userID int64, date time.Time) (int64, error) { 59 | startTime := time.Now() 60 | res, err := d.wasteRepo.SumOfWastesAfterDate(ctx, userID, date) 61 | duration := time.Since(startTime) 62 | 63 | d.latency.WithLabelValues("SumOfWastesAfterDate").Observe(duration.Seconds()) 64 | 65 | return res, err 66 | } 67 | 68 | func (d *WasteRepositoryLatencyDecorator) AddWasteToUser(ctx context.Context, userID int64, waste *models.Waste) (*models.Waste, error) { 69 | startTime := time.Now() 70 | res, err := d.wasteRepo.AddWasteToUser(ctx, userID, waste) 71 | duration := time.Since(startTime) 72 | 73 | d.latency.WithLabelValues("AddWasteToUser").Observe(duration.Seconds()) 74 | 75 | return res, err 76 | } 77 | -------------------------------------------------------------------------------- /internal/metrics/amount_errors_waste_postgres.go: -------------------------------------------------------------------------------- 1 | package metrics 2 | 3 | import ( 4 | "context" 5 | "time" 6 | 7 | "github.com/prometheus/client_golang/prometheus" 8 | "github.com/prometheus/client_golang/prometheus/promauto" 9 | "gitlab.ozon.dev/stepanov.ao.dev/telegram-bot/internal/models" 10 | ) 11 | 12 | //go:generate mockery --name=wasteRepository --dir . --output ./mocks --exported 13 | type wasteRepository interface { 14 | GetReportLastWeek(ctx context.Context, userID int64) ([]*models.CategoryReport, error) 15 | GetReportLastMonth(ctx context.Context, userID int64) ([]*models.CategoryReport, error) 16 | GetReportLastYear(ctx context.Context, userID int64) ([]*models.CategoryReport, error) 17 | SumOfWastesAfterDate(ctx context.Context, userID int64, date time.Time) (int64, error) 18 | 19 | AddWasteToUser(ctx context.Context, userID int64, waste *models.Waste) (*models.Waste, error) 20 | } 21 | 22 | type WasteRepositoryAmountErrorsDecorator struct { 23 | wasteRepo wasteRepository 24 | countErrors *prometheus.CounterVec 25 | } 26 | 27 | func NewWasteRepositoryAmountErrorsDecorator(wasteRepo wasteRepository) *WasteRepositoryAmountErrorsDecorator { 28 | return &WasteRepositoryAmountErrorsDecorator{ 29 | wasteRepo: wasteRepo, 30 | countErrors: promauto.NewCounterVec(prometheus.CounterOpts{ 31 | Name: "count_errors_waste_repository", 32 | Help: "Count of errors in WasteRepository methods", 33 | }, []string{"method"}), 34 | } 35 | } 36 | 37 | func (d *WasteRepositoryAmountErrorsDecorator) GetReportLastWeek(ctx context.Context, userID int64) ([]*models.CategoryReport, error) { 38 | res, err := d.wasteRepo.GetReportLastWeek(ctx, userID) 39 | if err != nil { 40 | d.countErrors.WithLabelValues("GetReportLastWeek").Inc() 41 | } 42 | return res, err 43 | } 44 | 45 | func (d *WasteRepositoryAmountErrorsDecorator) GetReportLastMonth(ctx context.Context, userID int64) ([]*models.CategoryReport, error) { 46 | res, err := d.wasteRepo.GetReportLastMonth(ctx, userID) 47 | if err != nil { 48 | d.countErrors.WithLabelValues("GetReportLastMonth").Inc() 49 | } 50 | return res, err 51 | } 52 | 53 | func (d *WasteRepositoryAmountErrorsDecorator) GetReportLastYear(ctx context.Context, userID int64) ([]*models.CategoryReport, error) { 54 | res, err := d.wasteRepo.GetReportLastYear(ctx, userID) 55 | if err != nil { 56 | d.countErrors.WithLabelValues("GetReportLastYear").Inc() 57 | } 58 | return res, err 59 | } 60 | 61 | func (d *WasteRepositoryAmountErrorsDecorator) SumOfWastesAfterDate(ctx context.Context, userID int64, date time.Time) (int64, error) { 62 | res, err := d.wasteRepo.SumOfWastesAfterDate(ctx, userID, date) 63 | if err != nil { 64 | d.countErrors.WithLabelValues("SumOfWastesAfterDate").Inc() 65 | } 66 | return res, err 67 | } 68 | 69 | func (d *WasteRepositoryAmountErrorsDecorator) AddWasteToUser(ctx context.Context, userID int64, waste *models.Waste) (*models.Waste, error) { 70 | res, err := d.wasteRepo.AddWasteToUser(ctx, userID, waste) 71 | if err != nil { 72 | d.countErrors.WithLabelValues("AddWasteToUser").Inc() 73 | } 74 | return res, err 75 | } 76 | -------------------------------------------------------------------------------- /internal/bot/handlers/message_handlers.go: -------------------------------------------------------------------------------- 1 | package handlers 2 | 3 | import ( 4 | "context" 5 | "time" 6 | 7 | "gitlab.ozon.dev/stepanov.ao.dev/telegram-bot/internal/bot" 8 | "gitlab.ozon.dev/stepanov.ao.dev/telegram-bot/internal/models" 9 | "gitlab.ozon.dev/stepanov.ao.dev/telegram-bot/internal/models/enums" 10 | ) 11 | 12 | //go:generate mockery --name=userRepository --dir . --output ./mocks --exported 13 | type userRepository interface { 14 | UserExists(ctx context.Context, id int64) (bool, error) 15 | AddUser(ctx context.Context, user *models.User) (*models.User, error) 16 | 17 | SetWasteLimit(ctx context.Context, id int64, limit uint64) (*models.User, error) 18 | GetWasteLimit(ctx context.Context, id int64) (*uint64, error) 19 | } 20 | 21 | //go:generate mockery --name=wasteRepository --dir . --output ./mocks --exported 22 | type wasteRepository interface { 23 | SumOfWastesAfterDate(ctx context.Context, userID int64, date time.Time) (int64, error) 24 | AddWasteToUser(ctx context.Context, userID int64, waste *models.Waste) (*models.Waste, error) 25 | } 26 | 27 | //go:generate mockery --name=exchangeService --dir . --output ./mocks --exported 28 | type exchangeService interface { 29 | GetDefaultCurrency() string 30 | GetUsedCurrencies() []string 31 | GetExchange(currency string) (float64, error) 32 | GetDesignation(currency string) (string, error) 33 | } 34 | 35 | //go:generate mockery --name=userContextService --dir . --output ./mocks --exported 36 | type userContextService interface { 37 | SetContext(ctx context.Context, userID int64, context enums.UserContext) error 38 | GetContext(ctx context.Context, userID int64) (enums.UserContext, error) 39 | SetCurrency(ctx context.Context, userID int64, currency string) error 40 | GetCurrency(ctx context.Context, userID int64) (string, error) 41 | } 42 | 43 | //go:generate mockery --name=kafkaProducer --dir . --output ./mocks --exported 44 | type kafkaProducer interface { 45 | SendMessage(ctx context.Context, key []byte, value []byte) error 46 | } 47 | 48 | type MessageHandlers struct { 49 | userRepo userRepository 50 | wasteRepo wasteRepository 51 | exchangeService exchangeService 52 | userContextService userContextService 53 | kafkaProducer kafkaProducer 54 | } 55 | 56 | func NewMessageHandlers( 57 | userRepo userRepository, 58 | wasteRepo wasteRepository, 59 | exchangeService exchangeService, 60 | userContextService userContextService, 61 | kafkaProducer kafkaProducer, 62 | ) *MessageHandlers { 63 | return &MessageHandlers{ 64 | userRepo: userRepo, 65 | wasteRepo: wasteRepo, 66 | exchangeService: exchangeService, 67 | userContextService: userContextService, 68 | kafkaProducer: kafkaProducer, 69 | } 70 | } 71 | 72 | func (h *MessageHandlers) GetHandlers() map[string]bot.MessageHandler { 73 | return map[string]bot.MessageHandler{ 74 | "/add": h.addHandler, 75 | "/setLimit": h.setLimitHandler, 76 | "/getLimit": h.getLimitHandler, 77 | "/week": h.weekHandler, 78 | "/month": h.monthHandler, 79 | "/year": h.yearHandler, 80 | "/currency": h.currencyHandler, 81 | "default": h.defaultHandler, 82 | } 83 | } 84 | -------------------------------------------------------------------------------- /internal/bot/bot.go: -------------------------------------------------------------------------------- 1 | package bot 2 | 3 | import ( 4 | "context" 5 | 6 | "gitlab.ozon.dev/stepanov.ao.dev/telegram-bot/internal/models" 7 | "gitlab.ozon.dev/stepanov.ao.dev/telegram-bot/pkg/log" 8 | ) 9 | 10 | const messageInternalError = "Внутренняя ошибка" 11 | 12 | //go:generate mockery --name=telegramClient --dir . --output ./mocks --exported 13 | type telegramClient interface { 14 | SendMessage(ctx context.Context, userID int64, text string) error 15 | SendMessageWithoutRemovingKeyboard(ctx context.Context, userID int64, text string) error 16 | SendKeyboard(ctx context.Context, userID int64, text string, rows [][]string) error 17 | GetUpdatesChan() <-chan *models.Message 18 | } 19 | 20 | //go:generate mockery --name=iterationMessage --dir . --output ./mocks --exported 21 | type iterationMessage interface { 22 | Iterate(ctx context.Context, message *models.Message, handler MessageHandler, logger log.Logger) 23 | } 24 | 25 | // MessageResponse is a result of working bot handlers. 26 | type MessageResponse struct { 27 | Message string 28 | Keyboard [][]string 29 | DoNotRemoveKeyboard bool 30 | } 31 | 32 | // MessageHandler is a type which represents a handler for messages. 33 | type MessageHandler func(ctx context.Context, message *models.Message) (*MessageResponse, error) 34 | 35 | // Bot is a router which choose which handler to run and run middlewares before the handlers. 36 | type Bot struct { 37 | tgClient telegramClient 38 | iterationMessage iterationMessage 39 | handlers map[string]MessageHandler 40 | 41 | logger log.Logger 42 | cancel context.CancelFunc 43 | done chan struct{} 44 | } 45 | 46 | func New(tg telegramClient, iterationMessage iterationMessage, logger log.Logger, handlers map[string]MessageHandler) *Bot { 47 | return &Bot{ 48 | tgClient: tg, 49 | iterationMessage: iterationMessage, 50 | handlers: handlers, 51 | 52 | logger: logger.With(log.ComponentKey, "Bot"), 53 | } 54 | } 55 | 56 | func (b *Bot) Start() error { 57 | ctx, cancel := context.WithCancel(context.Background()) 58 | 59 | b.cancel = cancel 60 | b.done = make(chan struct{}) 61 | 62 | go b.run(ctx) 63 | 64 | return nil 65 | } 66 | 67 | func (b *Bot) Stop(ctx context.Context) error { 68 | b.cancel() 69 | 70 | select { 71 | case <-b.done: 72 | return nil 73 | case <-ctx.Done(): 74 | return ctx.Err() 75 | } 76 | } 77 | 78 | func (b *Bot) run(ctx context.Context) { 79 | for { 80 | select { 81 | case message := <-b.tgClient.GetUpdatesChan(): 82 | handler, ok := b.handlers[message.Text] 83 | if !ok { 84 | handler = b.handlers["default"] 85 | } 86 | b.iterationMessage.Iterate(ctx, message, handler, b.logger) 87 | 88 | case <-ctx.Done(): 89 | b.logger.WithError(ctx.Err()).Info("bot has been stopped") 90 | close(b.done) 91 | 92 | return 93 | } 94 | } 95 | } 96 | 97 | // UseMiddleware adds a function which will be runned before all previous added middlewares 98 | // and message handler. 99 | func (b *Bot) UseMiddleware(middleware func(next MessageHandler) MessageHandler) { 100 | for key, v := range b.handlers { 101 | b.handlers[key] = middleware(v) 102 | } 103 | } 104 | -------------------------------------------------------------------------------- /internal/clients/telegram/client.go: -------------------------------------------------------------------------------- 1 | package telegram 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | 7 | tgbotapi "github.com/go-telegram-bot-api/telegram-bot-api/v5" 8 | 9 | "gitlab.ozon.dev/stepanov.ao.dev/telegram-bot/internal/models" 10 | "gitlab.ozon.dev/stepanov.ao.dev/telegram-bot/pkg/log" 11 | ) 12 | 13 | type Config struct { 14 | Token string `yaml:"token"` 15 | Timeout int `yaml:"timeout"` 16 | MessageBuffer int `yaml:"message_buffer"` 17 | } 18 | 19 | type Client struct { 20 | client *tgbotapi.BotAPI 21 | logger log.Logger 22 | 23 | timeout int 24 | 25 | messageUpdates chan *models.Message 26 | } 27 | 28 | func NewClient(config Config, logger log.Logger) (*Client, error) { 29 | client, err := tgbotapi.NewBotAPI(config.Token) 30 | if err != nil { 31 | return nil, fmt.Errorf("failed to connecting to telegrag bot: %w", err) 32 | } 33 | 34 | c := &Client{ 35 | client: client, 36 | logger: logger.With(log.ComponentKey, "Telegram client"), 37 | timeout: config.Timeout, 38 | messageUpdates: make(chan *models.Message, config.MessageBuffer), 39 | } 40 | 41 | go c.listenUpdates() 42 | 43 | return c, nil 44 | } 45 | 46 | func (c *Client) SendMessage(ctx context.Context, userID int64, text string) error { 47 | msg := tgbotapi.NewMessage(userID, text) 48 | msg.ReplyMarkup = tgbotapi.NewRemoveKeyboard(true) 49 | msg.ParseMode = tgbotapi.ModeMarkdown 50 | return c.sendMessage(msg) 51 | } 52 | 53 | func (c *Client) SendMessageWithoutRemovingKeyboard(ctx context.Context, userID int64, text string) error { 54 | msg := tgbotapi.NewMessage(userID, text) 55 | msg.ParseMode = tgbotapi.ModeMarkdown 56 | return c.sendMessage(msg) 57 | } 58 | 59 | func (c *Client) GetUpdatesChan() <-chan *models.Message { 60 | return c.messageUpdates 61 | } 62 | 63 | func (c *Client) SendKeyboard(ctx context.Context, userID int64, text string, rows [][]string) error { 64 | buttons := make([][]tgbotapi.KeyboardButton, 0) 65 | 66 | for _, row := range rows { 67 | cols := make([]tgbotapi.KeyboardButton, 0) 68 | for _, col := range row { 69 | cols = append(cols, tgbotapi.NewKeyboardButton(col)) 70 | } 71 | buttons = append(buttons, cols) 72 | } 73 | 74 | msg := tgbotapi.NewMessage(userID, text) 75 | msg.ParseMode = tgbotapi.ModeMarkdown 76 | msg.ReplyMarkup = tgbotapi.NewReplyKeyboard(buttons...) 77 | return c.sendMessage(msg) 78 | } 79 | 80 | func (c *Client) sendMessage(msg tgbotapi.MessageConfig) error { 81 | _, err := c.client.Send(msg) 82 | if err != nil { 83 | return fmt.Errorf("sending message to telegram: %w", err) 84 | } 85 | return nil 86 | } 87 | 88 | func (c *Client) listenUpdates() { 89 | u := tgbotapi.NewUpdate(0) 90 | u.Timeout = c.timeout 91 | 92 | updates := c.client.GetUpdatesChan(u) 93 | 94 | for update := range updates { 95 | if update.Message != nil { 96 | msg := update.Message 97 | usr := msg.From 98 | c.logger.Debugf("[%s] %s", usr.UserName, msg.Text) 99 | 100 | c.messageUpdates <- models.NewMessage( 101 | msg.MessageID, 102 | models.NewUser(usr.ID, usr.FirstName, usr.LastName, usr.UserName), 103 | msg.Date, msg.Text, 104 | ) 105 | } 106 | } 107 | } 108 | -------------------------------------------------------------------------------- /internal/models/requests/get_report_easyjson.go: -------------------------------------------------------------------------------- 1 | // Code generated by easyjson for marshaling/unmarshaling. DO NOT EDIT. 2 | 3 | package requests 4 | 5 | import ( 6 | json "encoding/json" 7 | easyjson "github.com/mailru/easyjson" 8 | jlexer "github.com/mailru/easyjson/jlexer" 9 | jwriter "github.com/mailru/easyjson/jwriter" 10 | ) 11 | 12 | // suppress unused package warning 13 | var ( 14 | _ *json.RawMessage 15 | _ *jlexer.Lexer 16 | _ *jwriter.Writer 17 | _ easyjson.Marshaler 18 | ) 19 | 20 | func easyjson59e4e729DecodeGitlabOzonDevStepanovAoDevTelegramBotInternalModelsRequests(in *jlexer.Lexer, out *GetReport) { 21 | isTopLevel := in.IsStart() 22 | if in.IsNull() { 23 | if isTopLevel { 24 | in.Consumed() 25 | } 26 | in.Skip() 27 | return 28 | } 29 | in.Delim('{') 30 | for !in.IsDelim('}') { 31 | key := in.UnsafeFieldName(false) 32 | in.WantColon() 33 | if in.IsNull() { 34 | in.Skip() 35 | in.WantComma() 36 | continue 37 | } 38 | switch key { 39 | case "user_id": 40 | out.UserID = int64(in.Int64()) 41 | case "period": 42 | out.Period = Period(in.Int()) 43 | case "currency_exchange": 44 | out.CurrencyExchange = float64(in.Float64()) 45 | case "currency_designation": 46 | out.CurrencyDesignation = string(in.String()) 47 | default: 48 | in.SkipRecursive() 49 | } 50 | in.WantComma() 51 | } 52 | in.Delim('}') 53 | if isTopLevel { 54 | in.Consumed() 55 | } 56 | } 57 | func easyjson59e4e729EncodeGitlabOzonDevStepanovAoDevTelegramBotInternalModelsRequests(out *jwriter.Writer, in GetReport) { 58 | out.RawByte('{') 59 | first := true 60 | _ = first 61 | { 62 | const prefix string = ",\"user_id\":" 63 | out.RawString(prefix[1:]) 64 | out.Int64(int64(in.UserID)) 65 | } 66 | { 67 | const prefix string = ",\"period\":" 68 | out.RawString(prefix) 69 | out.Int(int(in.Period)) 70 | } 71 | { 72 | const prefix string = ",\"currency_exchange\":" 73 | out.RawString(prefix) 74 | out.Float64(float64(in.CurrencyExchange)) 75 | } 76 | { 77 | const prefix string = ",\"currency_designation\":" 78 | out.RawString(prefix) 79 | out.String(string(in.CurrencyDesignation)) 80 | } 81 | out.RawByte('}') 82 | } 83 | 84 | // MarshalJSON supports json.Marshaler interface 85 | func (v GetReport) MarshalJSON() ([]byte, error) { 86 | w := jwriter.Writer{} 87 | easyjson59e4e729EncodeGitlabOzonDevStepanovAoDevTelegramBotInternalModelsRequests(&w, v) 88 | return w.Buffer.BuildBytes(), w.Error 89 | } 90 | 91 | // MarshalEasyJSON supports easyjson.Marshaler interface 92 | func (v GetReport) MarshalEasyJSON(w *jwriter.Writer) { 93 | easyjson59e4e729EncodeGitlabOzonDevStepanovAoDevTelegramBotInternalModelsRequests(w, v) 94 | } 95 | 96 | // UnmarshalJSON supports json.Unmarshaler interface 97 | func (v *GetReport) UnmarshalJSON(data []byte) error { 98 | r := jlexer.Lexer{Data: data} 99 | easyjson59e4e729DecodeGitlabOzonDevStepanovAoDevTelegramBotInternalModelsRequests(&r, v) 100 | return r.Error() 101 | } 102 | 103 | // UnmarshalEasyJSON supports easyjson.Unmarshaler interface 104 | func (v *GetReport) UnmarshalEasyJSON(l *jlexer.Lexer) { 105 | easyjson59e4e729DecodeGitlabOzonDevStepanovAoDevTelegramBotInternalModelsRequests(l, v) 106 | } 107 | -------------------------------------------------------------------------------- /internal/ent/user_delete.go: -------------------------------------------------------------------------------- 1 | // Code generated by ent, DO NOT EDIT. 2 | 3 | package ent 4 | 5 | import ( 6 | "context" 7 | "fmt" 8 | 9 | "entgo.io/ent/dialect/sql" 10 | "entgo.io/ent/dialect/sql/sqlgraph" 11 | "entgo.io/ent/schema/field" 12 | "gitlab.ozon.dev/stepanov.ao.dev/telegram-bot/internal/ent/predicate" 13 | "gitlab.ozon.dev/stepanov.ao.dev/telegram-bot/internal/ent/user" 14 | ) 15 | 16 | // UserDelete is the builder for deleting a User entity. 17 | type UserDelete struct { 18 | config 19 | hooks []Hook 20 | mutation *UserMutation 21 | } 22 | 23 | // Where appends a list predicates to the UserDelete builder. 24 | func (ud *UserDelete) Where(ps ...predicate.User) *UserDelete { 25 | ud.mutation.Where(ps...) 26 | return ud 27 | } 28 | 29 | // Exec executes the deletion query and returns how many vertices were deleted. 30 | func (ud *UserDelete) Exec(ctx context.Context) (int, error) { 31 | var ( 32 | err error 33 | affected int 34 | ) 35 | if len(ud.hooks) == 0 { 36 | affected, err = ud.sqlExec(ctx) 37 | } else { 38 | var mut Mutator = MutateFunc(func(ctx context.Context, m Mutation) (Value, error) { 39 | mutation, ok := m.(*UserMutation) 40 | if !ok { 41 | return nil, fmt.Errorf("unexpected mutation type %T", m) 42 | } 43 | ud.mutation = mutation 44 | affected, err = ud.sqlExec(ctx) 45 | mutation.done = true 46 | return affected, err 47 | }) 48 | for i := len(ud.hooks) - 1; i >= 0; i-- { 49 | if ud.hooks[i] == nil { 50 | return 0, fmt.Errorf("ent: uninitialized hook (forgotten import ent/runtime?)") 51 | } 52 | mut = ud.hooks[i](mut) 53 | } 54 | if _, err := mut.Mutate(ctx, ud.mutation); err != nil { 55 | return 0, err 56 | } 57 | } 58 | return affected, err 59 | } 60 | 61 | // ExecX is like Exec, but panics if an error occurs. 62 | func (ud *UserDelete) ExecX(ctx context.Context) int { 63 | n, err := ud.Exec(ctx) 64 | if err != nil { 65 | panic(err) 66 | } 67 | return n 68 | } 69 | 70 | func (ud *UserDelete) sqlExec(ctx context.Context) (int, error) { 71 | _spec := &sqlgraph.DeleteSpec{ 72 | Node: &sqlgraph.NodeSpec{ 73 | Table: user.Table, 74 | ID: &sqlgraph.FieldSpec{ 75 | Type: field.TypeInt64, 76 | Column: user.FieldID, 77 | }, 78 | }, 79 | } 80 | if ps := ud.mutation.predicates; len(ps) > 0 { 81 | _spec.Predicate = func(selector *sql.Selector) { 82 | for i := range ps { 83 | ps[i](selector) 84 | } 85 | } 86 | } 87 | affected, err := sqlgraph.DeleteNodes(ctx, ud.driver, _spec) 88 | if err != nil && sqlgraph.IsConstraintError(err) { 89 | err = &ConstraintError{msg: err.Error(), wrap: err} 90 | } 91 | return affected, err 92 | } 93 | 94 | // UserDeleteOne is the builder for deleting a single User entity. 95 | type UserDeleteOne struct { 96 | ud *UserDelete 97 | } 98 | 99 | // Exec executes the deletion query. 100 | func (udo *UserDeleteOne) Exec(ctx context.Context) error { 101 | n, err := udo.ud.Exec(ctx) 102 | switch { 103 | case err != nil: 104 | return err 105 | case n == 0: 106 | return &NotFoundError{user.Label} 107 | default: 108 | return nil 109 | } 110 | } 111 | 112 | // ExecX is like Exec, but panics if an error occurs. 113 | func (udo *UserDeleteOne) ExecX(ctx context.Context) { 114 | udo.ud.ExecX(ctx) 115 | } 116 | -------------------------------------------------------------------------------- /internal/ent/waste_delete.go: -------------------------------------------------------------------------------- 1 | // Code generated by ent, DO NOT EDIT. 2 | 3 | package ent 4 | 5 | import ( 6 | "context" 7 | "fmt" 8 | 9 | "entgo.io/ent/dialect/sql" 10 | "entgo.io/ent/dialect/sql/sqlgraph" 11 | "entgo.io/ent/schema/field" 12 | "gitlab.ozon.dev/stepanov.ao.dev/telegram-bot/internal/ent/predicate" 13 | "gitlab.ozon.dev/stepanov.ao.dev/telegram-bot/internal/ent/waste" 14 | ) 15 | 16 | // WasteDelete is the builder for deleting a Waste entity. 17 | type WasteDelete struct { 18 | config 19 | hooks []Hook 20 | mutation *WasteMutation 21 | } 22 | 23 | // Where appends a list predicates to the WasteDelete builder. 24 | func (wd *WasteDelete) Where(ps ...predicate.Waste) *WasteDelete { 25 | wd.mutation.Where(ps...) 26 | return wd 27 | } 28 | 29 | // Exec executes the deletion query and returns how many vertices were deleted. 30 | func (wd *WasteDelete) Exec(ctx context.Context) (int, error) { 31 | var ( 32 | err error 33 | affected int 34 | ) 35 | if len(wd.hooks) == 0 { 36 | affected, err = wd.sqlExec(ctx) 37 | } else { 38 | var mut Mutator = MutateFunc(func(ctx context.Context, m Mutation) (Value, error) { 39 | mutation, ok := m.(*WasteMutation) 40 | if !ok { 41 | return nil, fmt.Errorf("unexpected mutation type %T", m) 42 | } 43 | wd.mutation = mutation 44 | affected, err = wd.sqlExec(ctx) 45 | mutation.done = true 46 | return affected, err 47 | }) 48 | for i := len(wd.hooks) - 1; i >= 0; i-- { 49 | if wd.hooks[i] == nil { 50 | return 0, fmt.Errorf("ent: uninitialized hook (forgotten import ent/runtime?)") 51 | } 52 | mut = wd.hooks[i](mut) 53 | } 54 | if _, err := mut.Mutate(ctx, wd.mutation); err != nil { 55 | return 0, err 56 | } 57 | } 58 | return affected, err 59 | } 60 | 61 | // ExecX is like Exec, but panics if an error occurs. 62 | func (wd *WasteDelete) ExecX(ctx context.Context) int { 63 | n, err := wd.Exec(ctx) 64 | if err != nil { 65 | panic(err) 66 | } 67 | return n 68 | } 69 | 70 | func (wd *WasteDelete) sqlExec(ctx context.Context) (int, error) { 71 | _spec := &sqlgraph.DeleteSpec{ 72 | Node: &sqlgraph.NodeSpec{ 73 | Table: waste.Table, 74 | ID: &sqlgraph.FieldSpec{ 75 | Type: field.TypeUUID, 76 | Column: waste.FieldID, 77 | }, 78 | }, 79 | } 80 | if ps := wd.mutation.predicates; len(ps) > 0 { 81 | _spec.Predicate = func(selector *sql.Selector) { 82 | for i := range ps { 83 | ps[i](selector) 84 | } 85 | } 86 | } 87 | affected, err := sqlgraph.DeleteNodes(ctx, wd.driver, _spec) 88 | if err != nil && sqlgraph.IsConstraintError(err) { 89 | err = &ConstraintError{msg: err.Error(), wrap: err} 90 | } 91 | return affected, err 92 | } 93 | 94 | // WasteDeleteOne is the builder for deleting a single Waste entity. 95 | type WasteDeleteOne struct { 96 | wd *WasteDelete 97 | } 98 | 99 | // Exec executes the deletion query. 100 | func (wdo *WasteDeleteOne) Exec(ctx context.Context) error { 101 | n, err := wdo.wd.Exec(ctx) 102 | switch { 103 | case err != nil: 104 | return err 105 | case n == 0: 106 | return &NotFoundError{waste.Label} 107 | default: 108 | return nil 109 | } 110 | } 111 | 112 | // ExecX is like Exec, but panics if an error occurs. 113 | func (wdo *WasteDeleteOne) ExecX(ctx context.Context) { 114 | wdo.wd.ExecX(ctx) 115 | } 116 | -------------------------------------------------------------------------------- /internal/bot/handlers/add_handler.go: -------------------------------------------------------------------------------- 1 | package handlers 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "strconv" 7 | "strings" 8 | "time" 9 | 10 | "gitlab.ozon.dev/stepanov.ao.dev/telegram-bot/internal/bot" 11 | "gitlab.ozon.dev/stepanov.ao.dev/telegram-bot/internal/models" 12 | "gitlab.ozon.dev/stepanov.ao.dev/telegram-bot/internal/models/enums" 13 | ) 14 | 15 | const warningLimitCoeff = 0.9 16 | 17 | const userDateLayout = "02.01.2006" 18 | 19 | const ( 20 | messageAddResponse = `Для добавления траты введите сообщение в формате: 21 | 22 | <Название категории> 23 | <Сумма траты> 24 | <Дата траты в формате DD.MM.YYYY> (необязательно)` 25 | 26 | messageSuccessfulAddWaste = "Трата успешно добавлена" 27 | messageWarningLimit = "До превышения лимита за текущий месяц осталось:" 28 | messageLimitExceeded = "Лимит на текущий месяц превышен на" 29 | ) 30 | 31 | func (h *MessageHandlers) addHandler(ctx context.Context, message *models.Message) (*bot.MessageResponse, error) { 32 | err := h.userContextService.SetContext(ctx, message.From.ID, enums.AddWaste) 33 | if err != nil { 34 | return nil, fmt.Errorf("failed to set user context: %w", err) 35 | } 36 | 37 | return &bot.MessageResponse{ 38 | Message: messageAddResponse, 39 | }, nil 40 | } 41 | 42 | func (h *MessageHandlers) addWaste(ctx context.Context, message *models.Message) (*bot.MessageResponse, error) { 43 | text := message.Text 44 | lines := strings.Split(text, "\n") 45 | 46 | if len(lines) < 2 || len(lines) > 3 { 47 | return &bot.MessageResponse{ 48 | Message: messageIncorrectFormat, 49 | }, nil 50 | } 51 | 52 | cost, err := strconv.ParseFloat(lines[1], 64) 53 | if err != nil { 54 | return &bot.MessageResponse{ 55 | Message: messageIncorrectFormat, 56 | }, nil 57 | } 58 | 59 | date := message.Date 60 | if len(lines) == 3 { 61 | date, err = time.Parse(userDateLayout, lines[2]) 62 | if err != nil { 63 | return &bot.MessageResponse{ 64 | Message: messageIncorrectFormat, 65 | }, nil 66 | } 67 | } 68 | 69 | exchange, designation, err := h.getExchangeOfUser(ctx, message.From.ID) 70 | if err != nil { 71 | return nil, fmt.Errorf("failed to get exchage and designation for user: %w", err) 72 | } 73 | 74 | waste := models.NewWaste(lines[0], int64(cost/exchange*convertToMainCurrency), date) 75 | _, err = h.wasteRepo.AddWasteToUser(ctx, message.From.ID, waste) 76 | if err != nil { 77 | return nil, fmt.Errorf("failed to add waste: %w", err) 78 | } 79 | 80 | err = h.userContextService.SetContext(ctx, message.From.ID, enums.NoContext) 81 | if err != nil { 82 | return nil, fmt.Errorf("failed to set context for user: %w", err) 83 | } 84 | 85 | msg := messageSuccessfulAddWaste + "\n" 86 | 87 | sum, err := h.wasteRepo.SumOfWastesAfterDate(ctx, message.From.ID, getFirstDayOfMonth()) 88 | if err != nil { 89 | return nil, fmt.Errorf("failed to get sum of wastes: %w", err) 90 | } 91 | 92 | limit, err := h.userRepo.GetWasteLimit(ctx, message.From.ID) 93 | if err != nil { 94 | return nil, fmt.Errorf("failed to get limit of wastes: %w", err) 95 | } 96 | 97 | if limit != nil { 98 | fsum := float64(sum) 99 | flimit := float64(*limit) 100 | 101 | if fsum > flimit { 102 | diff := h.convertFromDefaultCurrency(uint64(sum)-(*limit), exchange) 103 | msg += fmt.Sprintf("%s %.2f %s", messageLimitExceeded, diff, designation) 104 | } else if fsum > warningLimitCoeff*flimit { 105 | diff := h.convertFromDefaultCurrency((*limit)-uint64(sum), exchange) 106 | msg += fmt.Sprintf("%s %.2f %s", messageWarningLimit, diff, designation) 107 | } 108 | } 109 | 110 | return &bot.MessageResponse{ 111 | Message: msg, 112 | }, nil 113 | } 114 | 115 | func getFirstDayOfMonth() time.Time { 116 | now := time.Now() 117 | currentYear, currentMonth, _ := now.Date() 118 | currentLocation := now.Location() 119 | 120 | return time.Date(currentYear, currentMonth, 1, 0, 0, 0, 0, currentLocation) 121 | } 122 | -------------------------------------------------------------------------------- /internal/api/telegram_bot_grpc.pb.go: -------------------------------------------------------------------------------- 1 | // Code generated by protoc-gen-go-grpc. DO NOT EDIT. 2 | 3 | package api 4 | 5 | import ( 6 | context "context" 7 | grpc "google.golang.org/grpc" 8 | codes "google.golang.org/grpc/codes" 9 | status "google.golang.org/grpc/status" 10 | ) 11 | 12 | // This is a compile-time assertion to ensure that this generated file 13 | // is compatible with the grpc package it is being compiled against. 14 | // Requires gRPC-Go v1.32.0 or later. 15 | const _ = grpc.SupportPackageIsVersion7 16 | 17 | // TelegramBotClient is the client API for TelegramBot service. 18 | // 19 | // 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. 20 | type TelegramBotClient interface { 21 | SendMessage(ctx context.Context, in *Message, opts ...grpc.CallOption) (*EmptyMessage, error) 22 | } 23 | 24 | type telegramBotClient struct { 25 | cc grpc.ClientConnInterface 26 | } 27 | 28 | func NewTelegramBotClient(cc grpc.ClientConnInterface) TelegramBotClient { 29 | return &telegramBotClient{cc} 30 | } 31 | 32 | func (c *telegramBotClient) SendMessage(ctx context.Context, in *Message, opts ...grpc.CallOption) (*EmptyMessage, error) { 33 | out := new(EmptyMessage) 34 | err := c.cc.Invoke(ctx, "/api.TelegramBot/SendMessage", in, out, opts...) 35 | if err != nil { 36 | return nil, err 37 | } 38 | return out, nil 39 | } 40 | 41 | // TelegramBotServer is the server API for TelegramBot service. 42 | // All implementations must embed UnimplementedTelegramBotServer 43 | // for forward compatibility 44 | type TelegramBotServer interface { 45 | SendMessage(context.Context, *Message) (*EmptyMessage, error) 46 | mustEmbedUnimplementedTelegramBotServer() 47 | } 48 | 49 | // UnimplementedTelegramBotServer must be embedded to have forward compatible implementations. 50 | type UnimplementedTelegramBotServer struct { 51 | } 52 | 53 | func (UnimplementedTelegramBotServer) SendMessage(context.Context, *Message) (*EmptyMessage, error) { 54 | return nil, status.Errorf(codes.Unimplemented, "method SendMessage not implemented") 55 | } 56 | func (UnimplementedTelegramBotServer) mustEmbedUnimplementedTelegramBotServer() {} 57 | 58 | // UnsafeTelegramBotServer may be embedded to opt out of forward compatibility for this service. 59 | // Use of this interface is not recommended, as added methods to TelegramBotServer will 60 | // result in compilation errors. 61 | type UnsafeTelegramBotServer interface { 62 | mustEmbedUnimplementedTelegramBotServer() 63 | } 64 | 65 | func RegisterTelegramBotServer(s grpc.ServiceRegistrar, srv TelegramBotServer) { 66 | s.RegisterService(&TelegramBot_ServiceDesc, srv) 67 | } 68 | 69 | func _TelegramBot_SendMessage_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { 70 | in := new(Message) 71 | if err := dec(in); err != nil { 72 | return nil, err 73 | } 74 | if interceptor == nil { 75 | return srv.(TelegramBotServer).SendMessage(ctx, in) 76 | } 77 | info := &grpc.UnaryServerInfo{ 78 | Server: srv, 79 | FullMethod: "/api.TelegramBot/SendMessage", 80 | } 81 | handler := func(ctx context.Context, req interface{}) (interface{}, error) { 82 | return srv.(TelegramBotServer).SendMessage(ctx, req.(*Message)) 83 | } 84 | return interceptor(ctx, in, info, handler) 85 | } 86 | 87 | // TelegramBot_ServiceDesc is the grpc.ServiceDesc for TelegramBot service. 88 | // It's only intended for direct use with grpc.RegisterService, 89 | // and not to be introspected or modified (even as a copy) 90 | var TelegramBot_ServiceDesc = grpc.ServiceDesc{ 91 | ServiceName: "api.TelegramBot", 92 | HandlerType: (*TelegramBotServer)(nil), 93 | Methods: []grpc.MethodDesc{ 94 | { 95 | MethodName: "SendMessage", 96 | Handler: _TelegramBot_SendMessage_Handler, 97 | }, 98 | }, 99 | Streams: []grpc.StreamDesc{}, 100 | Metadata: "telegram_bot.proto", 101 | } 102 | -------------------------------------------------------------------------------- /internal/ent/migrate/migrate.go: -------------------------------------------------------------------------------- 1 | // Code generated by ent, DO NOT EDIT. 2 | 3 | package migrate 4 | 5 | import ( 6 | "context" 7 | "fmt" 8 | "io" 9 | 10 | "entgo.io/ent/dialect" 11 | "entgo.io/ent/dialect/sql/schema" 12 | ) 13 | 14 | var ( 15 | // WithGlobalUniqueID sets the universal ids options to the migration. 16 | // If this option is enabled, ent migration will allocate a 1<<32 range 17 | // for the ids of each entity (table). 18 | // Note that this option cannot be applied on tables that already exist. 19 | WithGlobalUniqueID = schema.WithGlobalUniqueID 20 | // WithDropColumn sets the drop column option to the migration. 21 | // If this option is enabled, ent migration will drop old columns 22 | // that were used for both fields and edges. This defaults to false. 23 | WithDropColumn = schema.WithDropColumn 24 | // WithDropIndex sets the drop index option to the migration. 25 | // If this option is enabled, ent migration will drop old indexes 26 | // that were defined in the schema. This defaults to false. 27 | // Note that unique constraints are defined using `UNIQUE INDEX`, 28 | // and therefore, it's recommended to enable this option to get more 29 | // flexibility in the schema changes. 30 | WithDropIndex = schema.WithDropIndex 31 | // WithForeignKeys enables creating foreign-key in schema DDL. This defaults to true. 32 | WithForeignKeys = schema.WithForeignKeys 33 | ) 34 | 35 | // Schema is the API for creating, migrating and dropping a schema. 36 | type Schema struct { 37 | drv dialect.Driver 38 | } 39 | 40 | // NewSchema creates a new schema client. 41 | func NewSchema(drv dialect.Driver) *Schema { return &Schema{drv: drv} } 42 | 43 | // Create creates all schema resources. 44 | func (s *Schema) Create(ctx context.Context, opts ...schema.MigrateOption) error { 45 | return Create(ctx, s, Tables, opts...) 46 | } 47 | 48 | // Create creates all table resources using the given schema driver. 49 | func Create(ctx context.Context, s *Schema, tables []*schema.Table, opts ...schema.MigrateOption) error { 50 | migrate, err := schema.NewMigrate(s.drv, opts...) 51 | if err != nil { 52 | return fmt.Errorf("ent/migrate: %w", err) 53 | } 54 | return migrate.Create(ctx, tables...) 55 | } 56 | 57 | // Diff compares the state read from a database connection or migration directory with 58 | // the state defined by the Ent schema. Changes will be written to new migration files. 59 | func Diff(ctx context.Context, url string, opts ...schema.MigrateOption) error { 60 | return NamedDiff(ctx, url, "changes", opts...) 61 | } 62 | 63 | // NamedDiff compares the state read from a database connection or migration directory with 64 | // the state defined by the Ent schema. Changes will be written to new named migration files. 65 | func NamedDiff(ctx context.Context, url, name string, opts ...schema.MigrateOption) error { 66 | return schema.Diff(ctx, url, name, Tables, opts...) 67 | } 68 | 69 | // Diff creates a migration file containing the statements to resolve the diff 70 | // between the Ent schema and the connected database. 71 | func (s *Schema) Diff(ctx context.Context, opts ...schema.MigrateOption) error { 72 | migrate, err := schema.NewMigrate(s.drv, opts...) 73 | if err != nil { 74 | return fmt.Errorf("ent/migrate: %w", err) 75 | } 76 | return migrate.Diff(ctx, Tables...) 77 | } 78 | 79 | // NamedDiff creates a named migration file containing the statements to resolve the diff 80 | // between the Ent schema and the connected database. 81 | func (s *Schema) NamedDiff(ctx context.Context, name string, opts ...schema.MigrateOption) error { 82 | migrate, err := schema.NewMigrate(s.drv, opts...) 83 | if err != nil { 84 | return fmt.Errorf("ent/migrate: %w", err) 85 | } 86 | return migrate.NamedDiff(ctx, name, Tables...) 87 | } 88 | 89 | // WriteTo writes the schema changes to w instead of running them against the database. 90 | // 91 | // if err := client.Schema.WriteTo(context.Background(), os.Stdout); err != nil { 92 | // log.Fatal(err) 93 | // } 94 | func (s *Schema) WriteTo(ctx context.Context, w io.Writer, opts ...schema.MigrateOption) error { 95 | return Create(ctx, &Schema{drv: &schema.WriteDriver{Writer: w, Driver: s.drv}}, Tables, opts...) 96 | } 97 | -------------------------------------------------------------------------------- /internal/bot/middlewares.go: -------------------------------------------------------------------------------- 1 | package bot 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | 7 | "gitlab.ozon.dev/stepanov.ao.dev/telegram-bot/internal/models" 8 | "gitlab.ozon.dev/stepanov.ao.dev/telegram-bot/internal/models/enums" 9 | "gitlab.ozon.dev/stepanov.ao.dev/telegram-bot/pkg/log" 10 | ) 11 | 12 | type MessageMiddleware func(next MessageHandler) MessageHandler 13 | 14 | func LoggerMiddleware(l log.Logger) MessageMiddleware { 15 | logger := l.With(log.ComponentKey, "Logger middleware") 16 | 17 | middleware := func(next MessageHandler) MessageHandler { 18 | return func(ctx context.Context, message *models.Message) (*MessageResponse, error) { 19 | logger.Infof("Message \"%s\" from user %s(%d)", message.Text, message.From.UserName, message.From.ID) 20 | return next(ctx, message) 21 | } 22 | } 23 | 24 | return middleware 25 | } 26 | 27 | //go:generate mockery --name=userRepository --dir . --output ./mocks --exported 28 | type userRepository interface { 29 | UserExists(ctx context.Context, id int64) (bool, error) 30 | AddUser(ctx context.Context, user *models.User) (*models.User, error) 31 | } 32 | 33 | // CheckUserMiddleware middleware for adding new users 34 | // and make sure that user exists during the running message handler. 35 | func CheckUserMiddleware(userRepo userRepository) MessageMiddleware { 36 | middleware := func(next MessageHandler) MessageHandler { 37 | return func(ctx context.Context, message *models.Message) (*MessageResponse, error) { 38 | exists, err := userRepo.UserExists(ctx, message.From.ID) 39 | if err != nil { 40 | return nil, fmt.Errorf("failed to check exising user: %w", err) 41 | } 42 | 43 | if !exists { 44 | _, err := userRepo.AddUser(ctx, message.From) 45 | if err != nil { 46 | return nil, fmt.Errorf("failed to adding user: %w", err) 47 | } 48 | } 49 | 50 | return next(ctx, message) 51 | } 52 | } 53 | 54 | return middleware 55 | } 56 | 57 | //go:generate mockery --name=cacheService --dir . --output ./mocks --exported 58 | type cacheService interface { 59 | Set(ctx context.Context, userID int64, command enums.CommandType, value string) error 60 | Get(ctx context.Context, userID int64, command enums.CommandType) (string, error) 61 | Clear(ctx context.Context, userID int64, command enums.CommandType) error 62 | ClearKeys(ctx context.Context, userID int64, commands ...enums.CommandType) error 63 | } 64 | 65 | func CacheMiddleware(cacheService cacheService, logger log.Logger) MessageMiddleware { 66 | logger = logger.With(log.ComponentKey, "Cache middleware") 67 | middleware := func(next MessageHandler) MessageHandler { 68 | return func(ctx context.Context, message *models.Message) (*MessageResponse, error) { 69 | command, err := enums.ParseCommandType(message.Text) 70 | if err != nil { 71 | return next(ctx, message) 72 | } 73 | 74 | switch command { 75 | case enums.CommandTypeCurrency: 76 | err = cacheService.Clear(ctx, message.From.ID, enums.CommandTypeGetLimit) 77 | if err != nil { 78 | logger.WithError(err). 79 | Info("failed to clear key in the cache") 80 | } 81 | fallthrough 82 | 83 | case enums.CommandTypeAdd: 84 | err = cacheService.ClearKeys(ctx, message.From.ID, 85 | enums.CommandTypeWeekReport, 86 | enums.CommandTypeMonthReport, 87 | enums.CommandTypeYearReport, 88 | ) 89 | if err != nil { 90 | logger.WithError(err). 91 | Info("failed to clear key in the cache") 92 | } 93 | return next(ctx, message) 94 | 95 | case enums.CommandTypeSetLimit: 96 | err = cacheService.Clear(ctx, message.From.ID, enums.CommandTypeGetLimit) 97 | if err != nil { 98 | logger.WithError(err). 99 | Info("failed to clear key in the cache") 100 | } 101 | return next(ctx, message) 102 | 103 | default: 104 | result, err := cacheService.Get(ctx, message.From.ID, command) 105 | if err == nil { 106 | return &MessageResponse{ 107 | Message: result, 108 | }, nil 109 | } 110 | } 111 | 112 | resp, err := next(ctx, message) 113 | 114 | if command != enums.CommandTypeWeekReport && 115 | command != enums.CommandTypeMonthReport && 116 | command != enums.CommandTypeYearReport { 117 | if err := cacheService.Set(ctx, message.From.ID, command, resp.Message); err != nil { 118 | logger.WithError(err). 119 | Error("failed to upload the message to the cache") 120 | } 121 | } 122 | 123 | return resp, err 124 | } 125 | } 126 | 127 | return middleware 128 | } 129 | -------------------------------------------------------------------------------- /internal/repository/wastes.go: -------------------------------------------------------------------------------- 1 | package repository 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "time" 7 | 8 | "gitlab.ozon.dev/stepanov.ao.dev/telegram-bot/internal/ent" 9 | "gitlab.ozon.dev/stepanov.ao.dev/telegram-bot/internal/ent/user" 10 | "gitlab.ozon.dev/stepanov.ao.dev/telegram-bot/internal/ent/waste" 11 | "gitlab.ozon.dev/stepanov.ao.dev/telegram-bot/internal/models" 12 | ) 13 | 14 | var ErrNotFound = errors.New("wastes not found") 15 | 16 | const ( 17 | weekDuration = time.Hour * 24 * 7 18 | monthDuration = weekDuration * 4 19 | yearDuration = monthDuration * 12 20 | ) 21 | 22 | type WasteRepository struct { 23 | client *ent.Client 24 | } 25 | 26 | func NewWasteRepository(client *ent.Client) *WasteRepository { 27 | return &WasteRepository{ 28 | client: client, 29 | } 30 | } 31 | 32 | func (r *WasteRepository) GetWastesByUser(ctx context.Context, userID int64) ([]*models.Waste, error) { 33 | wastes, err := r.client.Waste. 34 | Query(). 35 | Where(waste.HasUserWith(user.ID(userID))). 36 | All(ctx) 37 | if err != nil { 38 | return nil, err 39 | } 40 | 41 | result := make([]*models.Waste, 0, len(wastes)) 42 | for _, v := range wastes { 43 | result = append(result, &models.Waste{ 44 | Waste: v, 45 | }) 46 | } 47 | 48 | return result, nil 49 | } 50 | 51 | func (r *WasteRepository) GetWastesByUserLastWeek(ctx context.Context, userID int64) ([]*models.Waste, error) { 52 | return r.GetWastesByUserAfterDate(ctx, userID, time.Now().Add(-weekDuration)) 53 | } 54 | 55 | func (r *WasteRepository) GetWastesByUserLastMonth(ctx context.Context, userID int64) ([]*models.Waste, error) { 56 | return r.GetWastesByUserAfterDate(ctx, userID, time.Now().Add(-monthDuration)) 57 | } 58 | 59 | func (r *WasteRepository) GetWastesByUserLastYear(ctx context.Context, userID int64) ([]*models.Waste, error) { 60 | return r.GetWastesByUserAfterDate(ctx, userID, time.Now().Add(-yearDuration)) 61 | } 62 | 63 | func (r *WasteRepository) GetWastesByUserAfterDate( 64 | ctx context.Context, userID int64, date time.Time, 65 | ) ([]*models.Waste, error) { 66 | wastes, err := r.client.Waste.Query(). 67 | Where(waste.HasUserWith(user.ID(userID)), waste.DateGTE(date)). 68 | All(ctx) 69 | if err != nil { 70 | return nil, err 71 | } 72 | 73 | result := make([]*models.Waste, 0, len(wastes)) 74 | for _, v := range wastes { 75 | result = append(result, &models.Waste{ 76 | Waste: v, 77 | }) 78 | } 79 | 80 | return result, nil 81 | } 82 | 83 | func (r *WasteRepository) GetReportLastWeek(ctx context.Context, userID int64) ([]*models.CategoryReport, error) { 84 | return r.GetReportAfterDate(ctx, userID, time.Now().Add(-weekDuration)) 85 | } 86 | 87 | func (r *WasteRepository) GetReportLastMonth(ctx context.Context, userID int64) ([]*models.CategoryReport, error) { 88 | return r.GetReportAfterDate(ctx, userID, time.Now().Add(-monthDuration)) 89 | } 90 | 91 | func (r *WasteRepository) GetReportLastYear(ctx context.Context, userID int64) ([]*models.CategoryReport, error) { 92 | return r.GetReportAfterDate(ctx, userID, time.Now().Add(-yearDuration)) 93 | } 94 | 95 | func (r *WasteRepository) GetReportAfterDate( 96 | ctx context.Context, userID int64, date time.Time, 97 | ) ([]*models.CategoryReport, error) { 98 | var report []*models.CategoryReport 99 | err := r.client.Waste.Query(). 100 | Where(waste.HasUserWith(user.ID(userID)), waste.DateGTE(date)). 101 | GroupBy(waste.FieldCategory). 102 | Aggregate(ent.Sum(waste.FieldCost)). 103 | Scan(ctx, &report) 104 | if err != nil { 105 | return nil, err 106 | } 107 | 108 | return report, nil 109 | } 110 | 111 | func (r *WasteRepository) AddWasteToUser( 112 | ctx context.Context, userID int64, waste *models.Waste, 113 | ) (*models.Waste, error) { 114 | model, err := r.client.Waste. 115 | Create(). 116 | SetCost(waste.Cost). 117 | SetCategory(waste.Category). 118 | SetDate(waste.Date). 119 | SetUserID(userID). 120 | Save(ctx) 121 | if err != nil { 122 | return nil, err 123 | } 124 | 125 | return &models.Waste{ 126 | Waste: model, 127 | }, nil 128 | } 129 | 130 | func (r *WasteRepository) SumOfWastesAfterDate(ctx context.Context, userID int64, date time.Time) (int64, error) { 131 | var result []struct { 132 | Sum int64 `json:"sum"` 133 | UserWastes interface{} `json:"user_wastes"` 134 | } 135 | err := r.client.Waste.Query(). 136 | Select(waste.FieldCost). 137 | Where(waste.HasUserWith(user.ID(userID))). 138 | GroupBy(waste.UserColumn). 139 | Aggregate(ent.Sum(waste.FieldCost)). 140 | Scan(ctx, &result) 141 | if err != nil { 142 | return 0, err 143 | } 144 | 145 | return result[0].Sum, nil 146 | } 147 | -------------------------------------------------------------------------------- /internal/service/exchange/exchange.go: -------------------------------------------------------------------------------- 1 | package exchange 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "sync" 7 | "time" 8 | 9 | "gitlab.ozon.dev/stepanov.ao.dev/telegram-bot/internal/models" 10 | "gitlab.ozon.dev/stepanov.ao.dev/telegram-bot/pkg/log" 11 | ) 12 | 13 | type Config struct { 14 | Default string `yaml:"default"` 15 | DesignationDefault string `yaml:"designation_default"` 16 | 17 | Used []string `yaml:"used"` 18 | DesignationUsed []string `yaml:"designation_used"` 19 | 20 | UpdateTimeout time.Duration `yaml:"update_timeout"` 21 | RetryTimeout time.Duration `yaml:"retry_timeout"` 22 | } 23 | 24 | //go:generate mockery --name=exchangeClient --dir . --output ./mocks --exported 25 | type exchangeClient interface { 26 | GetExchange(ctx context.Context, base string, symbols []string) (*models.ExchangeData, error) 27 | } 28 | 29 | var ( 30 | ErrIncorrectConfig = errors.New("different length of designations and currencies in a configs") 31 | ErrCurrencyNotFound = errors.New("currency not found in repository") 32 | ErrDataNotPrepared = errors.New("currency exchange does not prepared") 33 | ) 34 | 35 | // Service is updating data about exchange from external service each timeout. 36 | type Service struct { 37 | exchangeClient exchangeClient 38 | config Config 39 | logger log.Logger 40 | 41 | data map[string]float64 42 | designations map[string]string 43 | mutex *sync.RWMutex 44 | 45 | cancel context.CancelFunc 46 | done chan struct{} 47 | } 48 | 49 | func NewService(config Config, exchangeClient exchangeClient, logger log.Logger) (*Service, error) { 50 | if len(config.Used) != len(config.DesignationUsed) { 51 | return nil, ErrIncorrectConfig 52 | } 53 | 54 | s := &Service{ 55 | exchangeClient: exchangeClient, 56 | config: config, 57 | logger: logger.With(log.ComponentKey, "Exchange service"), 58 | mutex: &sync.RWMutex{}, 59 | } 60 | 61 | designations := make(map[string]string) 62 | designations[config.Default] = config.DesignationDefault 63 | for i := range config.Used { 64 | designations[config.Used[i]] = config.DesignationUsed[i] 65 | } 66 | 67 | s.designations = designations 68 | 69 | return s, nil 70 | } 71 | 72 | func (s *Service) Start() error { 73 | ctx, cancel := context.WithCancel(context.Background()) 74 | 75 | s.cancel = cancel 76 | s.done = make(chan struct{}) 77 | 78 | go s.updateDataByTicker(ctx) 79 | 80 | return s.updateData(ctx) 81 | } 82 | 83 | func (s *Service) Stop(ctx context.Context) error { 84 | s.cancel() 85 | 86 | select { 87 | case <-s.done: 88 | return nil 89 | case <-ctx.Done(): 90 | return ctx.Err() 91 | } 92 | } 93 | 94 | func (s *Service) GetDefaultCurrency() string { 95 | return s.config.Default 96 | } 97 | 98 | func (s *Service) GetUsedCurrencies() []string { 99 | return s.config.Used 100 | } 101 | 102 | func (s *Service) GetDesignation(currency string) (string, error) { 103 | s.mutex.RLock() 104 | data := s.designations 105 | s.mutex.RUnlock() 106 | 107 | designation, ok := data[currency] 108 | if !ok { 109 | return "", ErrCurrencyNotFound 110 | } 111 | 112 | return designation, nil 113 | } 114 | 115 | func (s *Service) GetExchange(currency string) (float64, error) { 116 | s.mutex.RLock() 117 | data := s.data 118 | s.mutex.RUnlock() 119 | 120 | if data == nil { 121 | return 0, ErrDataNotPrepared 122 | } 123 | 124 | exchange, ok := data[currency] 125 | if !ok { 126 | return 0, ErrCurrencyNotFound 127 | } 128 | 129 | return exchange, nil 130 | } 131 | 132 | func (s *Service) updateDataByTicker(ctx context.Context) { 133 | ticker := time.NewTicker(s.config.UpdateTimeout) 134 | 135 | for { 136 | select { 137 | case <-ticker.C: 138 | err := s.updateData(ctx) 139 | if err != nil { 140 | s.logger.WithError(err).Warn("failed to update exchanges data") 141 | ticker.Reset(s.config.RetryTimeout) 142 | } else { 143 | ticker.Reset(s.config.UpdateTimeout) 144 | } 145 | 146 | case <-ctx.Done(): 147 | s.logger.WithError(ctx.Err()).Info("exchange repository has been closed") 148 | close(s.done) 149 | 150 | return 151 | } 152 | } 153 | } 154 | 155 | func (s *Service) updateData(ctx context.Context) error { 156 | s.logger.Info("try to update exchange data") 157 | 158 | data, err := s.exchangeClient.GetExchange(ctx, s.config.Default, s.config.Used) 159 | if err != nil { 160 | return err 161 | } 162 | 163 | s.mutex.Lock() 164 | if s.data == nil { 165 | s.data = make(map[string]float64, len(data.Rates)) 166 | } 167 | for key, value := range data.Rates { 168 | s.data[key] = value 169 | } 170 | s.data[s.config.Default] = 1.0 171 | s.mutex.Unlock() 172 | 173 | s.logger. 174 | With("exchange data", data). 175 | Info("exchange data updated successfully") 176 | 177 | return nil 178 | } 179 | -------------------------------------------------------------------------------- /internal/ent/user.go: -------------------------------------------------------------------------------- 1 | // Code generated by ent, DO NOT EDIT. 2 | 3 | package ent 4 | 5 | import ( 6 | "fmt" 7 | "strings" 8 | 9 | "entgo.io/ent/dialect/sql" 10 | "gitlab.ozon.dev/stepanov.ao.dev/telegram-bot/internal/ent/user" 11 | ) 12 | 13 | // User is the model entity for the User schema. 14 | type User struct { 15 | config `json:"-"` 16 | // ID of the ent. 17 | ID int64 `json:"id,omitempty"` 18 | // FirstName holds the value of the "first_name" field. 19 | FirstName string `json:"first_name,omitempty"` 20 | // LastName holds the value of the "last_name" field. 21 | LastName string `json:"last_name,omitempty"` 22 | // UserName holds the value of the "user_name" field. 23 | UserName string `json:"user_name,omitempty"` 24 | // WasteLimit holds the value of the "waste_limit" field. 25 | WasteLimit *uint64 `json:"waste_limit,omitempty"` 26 | // Edges holds the relations/edges for other nodes in the graph. 27 | // The values are being populated by the UserQuery when eager-loading is set. 28 | Edges UserEdges `json:"edges"` 29 | } 30 | 31 | // UserEdges holds the relations/edges for other nodes in the graph. 32 | type UserEdges struct { 33 | // Wastes holds the value of the wastes edge. 34 | Wastes []*Waste `json:"wastes,omitempty"` 35 | // loadedTypes holds the information for reporting if a 36 | // type was loaded (or requested) in eager-loading or not. 37 | loadedTypes [1]bool 38 | } 39 | 40 | // WastesOrErr returns the Wastes value or an error if the edge 41 | // was not loaded in eager-loading. 42 | func (e UserEdges) WastesOrErr() ([]*Waste, error) { 43 | if e.loadedTypes[0] { 44 | return e.Wastes, nil 45 | } 46 | return nil, &NotLoadedError{edge: "wastes"} 47 | } 48 | 49 | // scanValues returns the types for scanning values from sql.Rows. 50 | func (*User) scanValues(columns []string) ([]any, error) { 51 | values := make([]any, len(columns)) 52 | for i := range columns { 53 | switch columns[i] { 54 | case user.FieldID, user.FieldWasteLimit: 55 | values[i] = new(sql.NullInt64) 56 | case user.FieldFirstName, user.FieldLastName, user.FieldUserName: 57 | values[i] = new(sql.NullString) 58 | default: 59 | return nil, fmt.Errorf("unexpected column %q for type User", columns[i]) 60 | } 61 | } 62 | return values, nil 63 | } 64 | 65 | // assignValues assigns the values that were returned from sql.Rows (after scanning) 66 | // to the User fields. 67 | func (u *User) assignValues(columns []string, values []any) error { 68 | if m, n := len(values), len(columns); m < n { 69 | return fmt.Errorf("mismatch number of scan values: %d != %d", m, n) 70 | } 71 | for i := range columns { 72 | switch columns[i] { 73 | case user.FieldID: 74 | value, ok := values[i].(*sql.NullInt64) 75 | if !ok { 76 | return fmt.Errorf("unexpected type %T for field id", value) 77 | } 78 | u.ID = int64(value.Int64) 79 | case user.FieldFirstName: 80 | if value, ok := values[i].(*sql.NullString); !ok { 81 | return fmt.Errorf("unexpected type %T for field first_name", values[i]) 82 | } else if value.Valid { 83 | u.FirstName = value.String 84 | } 85 | case user.FieldLastName: 86 | if value, ok := values[i].(*sql.NullString); !ok { 87 | return fmt.Errorf("unexpected type %T for field last_name", values[i]) 88 | } else if value.Valid { 89 | u.LastName = value.String 90 | } 91 | case user.FieldUserName: 92 | if value, ok := values[i].(*sql.NullString); !ok { 93 | return fmt.Errorf("unexpected type %T for field user_name", values[i]) 94 | } else if value.Valid { 95 | u.UserName = value.String 96 | } 97 | case user.FieldWasteLimit: 98 | if value, ok := values[i].(*sql.NullInt64); !ok { 99 | return fmt.Errorf("unexpected type %T for field waste_limit", values[i]) 100 | } else if value.Valid { 101 | u.WasteLimit = new(uint64) 102 | *u.WasteLimit = uint64(value.Int64) 103 | } 104 | } 105 | } 106 | return nil 107 | } 108 | 109 | // QueryWastes queries the "wastes" edge of the User entity. 110 | func (u *User) QueryWastes() *WasteQuery { 111 | return (&UserClient{config: u.config}).QueryWastes(u) 112 | } 113 | 114 | // Update returns a builder for updating this User. 115 | // Note that you need to call User.Unwrap() before calling this method if this User 116 | // was returned from a transaction, and the transaction was committed or rolled back. 117 | func (u *User) Update() *UserUpdateOne { 118 | return (&UserClient{config: u.config}).UpdateOne(u) 119 | } 120 | 121 | // Unwrap unwraps the User entity that was returned from a transaction after it was closed, 122 | // so that all future queries will be executed through the driver which created the transaction. 123 | func (u *User) Unwrap() *User { 124 | _tx, ok := u.config.driver.(*txDriver) 125 | if !ok { 126 | panic("ent: User is not a transactional entity") 127 | } 128 | u.config.driver = _tx.drv 129 | return u 130 | } 131 | 132 | // String implements the fmt.Stringer. 133 | func (u *User) String() string { 134 | var builder strings.Builder 135 | builder.WriteString("User(") 136 | builder.WriteString(fmt.Sprintf("id=%v, ", u.ID)) 137 | builder.WriteString("first_name=") 138 | builder.WriteString(u.FirstName) 139 | builder.WriteString(", ") 140 | builder.WriteString("last_name=") 141 | builder.WriteString(u.LastName) 142 | builder.WriteString(", ") 143 | builder.WriteString("user_name=") 144 | builder.WriteString(u.UserName) 145 | builder.WriteString(", ") 146 | if v := u.WasteLimit; v != nil { 147 | builder.WriteString("waste_limit=") 148 | builder.WriteString(fmt.Sprintf("%v", *v)) 149 | } 150 | builder.WriteByte(')') 151 | return builder.String() 152 | } 153 | 154 | // Users is a parsable slice of User. 155 | type Users []*User 156 | 157 | func (u Users) config(cfg config) { 158 | for _i := range u { 159 | u[_i].config = cfg 160 | } 161 | } 162 | -------------------------------------------------------------------------------- /internal/service/wastereport/waste_report.go: -------------------------------------------------------------------------------- 1 | package wastereport 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "strings" 7 | 8 | "github.com/olekukonko/tablewriter" 9 | 10 | "gitlab.ozon.dev/stepanov.ao.dev/telegram-bot/internal/models" 11 | "gitlab.ozon.dev/stepanov.ao.dev/telegram-bot/internal/models/enums" 12 | "gitlab.ozon.dev/stepanov.ao.dev/telegram-bot/internal/models/requests" 13 | "gitlab.ozon.dev/stepanov.ao.dev/telegram-bot/pkg/log" 14 | ) 15 | 16 | const convertToMainCurrency = 100.0 17 | const messageWasteNotFound = "Траты за указанный период не найдены" 18 | 19 | //go:generate mockery --name=wasteRepository --dir . --output ./mocks --exported 20 | type wasteRepository interface { 21 | GetReportLastWeek(ctx context.Context, userID int64) ([]*models.CategoryReport, error) 22 | GetReportLastMonth(ctx context.Context, userID int64) ([]*models.CategoryReport, error) 23 | GetReportLastYear(ctx context.Context, userID int64) ([]*models.CategoryReport, error) 24 | } 25 | 26 | //go:generate mockery --name=consumerMessages --dir . --output ./mocks --exported 27 | type consumerMessages interface { 28 | GetMessageChan() <-chan *models.KafkaMessage 29 | } 30 | 31 | //go:generate mockery --name=telegramClient --dir . --output ./mocks --exported 32 | type telegramClient interface { 33 | SendMessage(ctx context.Context, userID int64, text string, command enums.CommandType) error 34 | } 35 | 36 | type Service struct { 37 | consumer consumerMessages 38 | wasteRepo wasteRepository 39 | tgClient telegramClient 40 | 41 | logger log.Logger 42 | 43 | cancel context.CancelFunc 44 | done chan struct{} 45 | } 46 | 47 | func NewService(consumer consumerMessages, wasteRepo wasteRepository, tgClient telegramClient, logger log.Logger) *Service { 48 | return &Service{ 49 | consumer: consumer, 50 | wasteRepo: wasteRepo, 51 | tgClient: tgClient, 52 | 53 | logger: logger.With(log.ComponentKey, "Waste report"), 54 | } 55 | } 56 | 57 | func (s *Service) Start() error { 58 | ctx, cancel := context.WithCancel(context.Background()) 59 | 60 | s.cancel = cancel 61 | s.done = make(chan struct{}) 62 | 63 | go s.run(ctx) 64 | 65 | return nil 66 | } 67 | 68 | func (s *Service) Stop(ctx context.Context) error { 69 | s.cancel() 70 | 71 | select { 72 | case <-s.done: 73 | return nil 74 | case <-ctx.Done(): 75 | return ctx.Err() 76 | } 77 | } 78 | 79 | func (s *Service) run(ctx context.Context) { 80 | for { 81 | select { 82 | case msg := <-s.consumer.GetMessageChan(): 83 | var req requests.GetReport 84 | err := req.UnmarshalJSON(msg.Message) 85 | if err != nil { 86 | s.logger. 87 | WithError(err). 88 | With("recieved message", msg). 89 | Warn("failed to unmarshall message") 90 | } 91 | s.sendReport(ctx, req) 92 | 93 | case <-ctx.Done(): 94 | s.logger.WithError(ctx.Err()).Info("waste report service has been closed") 95 | close(s.done) 96 | 97 | return 98 | 99 | //nolint:staticcheck // does not recieve the messages without default branch 100 | default: 101 | } 102 | } 103 | } 104 | 105 | func (s *Service) sendReport(ctx context.Context, req requests.GetReport) { 106 | var report []*models.CategoryReport 107 | var err error 108 | var command enums.CommandType 109 | 110 | switch req.Period { 111 | case requests.PeriodWeek: 112 | report, err = s.wasteRepo.GetReportLastWeek(ctx, req.UserID) 113 | command = enums.CommandTypeWeekReport 114 | case requests.PeriodMonth: 115 | report, err = s.wasteRepo.GetReportLastMonth(ctx, req.UserID) 116 | command = enums.CommandTypeMonthReport 117 | case requests.PeriodYear: 118 | report, err = s.wasteRepo.GetReportLastYear(ctx, req.UserID) 119 | command = enums.CommandTypeYearReport 120 | default: 121 | s.logger.With("report request", req).Warn("unexpected type of period") 122 | return 123 | } 124 | 125 | if err != nil { 126 | s.logger.WithError(err).Error("failed to get the report from repository") 127 | } 128 | 129 | msg := "" 130 | if len(report) == 0 { 131 | msg = messageWasteNotFound 132 | } else { 133 | stringReport, err := s.generateStringReport(report, req.Period, req.CurrencyExchange, req.CurrencyDesignation) 134 | if err != nil { 135 | s.logger.WithError(err).Error("failed to generate string report") 136 | } 137 | 138 | msg = stringReport 139 | } 140 | 141 | err = s.tgClient.SendMessage(ctx, req.UserID, msg, command) 142 | if err != nil { 143 | s.logger.WithError(err).Error("failed to send the message") 144 | } 145 | } 146 | 147 | func (s *Service) generateStringReport( 148 | report []*models.CategoryReport, period requests.Period, currencyExchange float64, currencyDesignation string, 149 | ) (string, error) { 150 | textMessageHeader := "Отчет по тратам за " 151 | 152 | switch period { 153 | case requests.PeriodWeek: 154 | textMessageHeader += "последнюю неделю:\n\n```\n" 155 | case requests.PeriodMonth: 156 | textMessageHeader += "последний месяц:\n\n```\n" 157 | case requests.PeriodYear: 158 | textMessageHeader += "последний год:\n\n```\n" 159 | default: 160 | return "", fmt.Errorf("unexpected type of period: %d", period) 161 | } 162 | 163 | data := make([][]string, 0) 164 | sum := 0.0 165 | for _, category := range report { 166 | curr := float64(category.Sum) * currencyExchange / convertToMainCurrency 167 | sum += curr 168 | 169 | data = append(data, []string{ 170 | category.Category, 171 | fmt.Sprintf("%.2f %s", 172 | curr, currencyDesignation), 173 | }) 174 | } 175 | 176 | tableString := &strings.Builder{} 177 | table := tablewriter.NewWriter(tableString) 178 | 179 | table.SetHeader([]string{"КАТЕГОРИЯ", "ПОТРАЧЕНО"}) 180 | table.SetFooter([]string{"СУММА", fmt.Sprintf("%.2f %s", sum, currencyDesignation)}) 181 | table.AppendBulk(data) 182 | 183 | table.Render() 184 | 185 | return textMessageHeader + tableString.String() + "```", nil 186 | } 187 | -------------------------------------------------------------------------------- /cmd/bot/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "flag" 6 | "log" 7 | 8 | "gitlab.ozon.dev/stepanov.ao.dev/telegram-bot/internal/app" 9 | "gitlab.ozon.dev/stepanov.ao.dev/telegram-bot/internal/app/startup" 10 | "gitlab.ozon.dev/stepanov.ao.dev/telegram-bot/internal/bot" 11 | "gitlab.ozon.dev/stepanov.ao.dev/telegram-bot/internal/bot/handlers" 12 | exchangeclient "gitlab.ozon.dev/stepanov.ao.dev/telegram-bot/internal/clients/exchange" 13 | "gitlab.ozon.dev/stepanov.ao.dev/telegram-bot/internal/clients/telegram" 14 | "gitlab.ozon.dev/stepanov.ao.dev/telegram-bot/internal/grpc" 15 | "gitlab.ozon.dev/stepanov.ao.dev/telegram-bot/internal/http" 16 | "gitlab.ozon.dev/stepanov.ao.dev/telegram-bot/internal/metrics" 17 | "gitlab.ozon.dev/stepanov.ao.dev/telegram-bot/internal/repository" 18 | "gitlab.ozon.dev/stepanov.ao.dev/telegram-bot/internal/service/cache" 19 | exchangeservice "gitlab.ozon.dev/stepanov.ao.dev/telegram-bot/internal/service/exchange" 20 | "gitlab.ozon.dev/stepanov.ao.dev/telegram-bot/internal/service/kafka" 21 | "gitlab.ozon.dev/stepanov.ao.dev/telegram-bot/internal/service/usercontext" 22 | ) 23 | 24 | const serviceName = "telegram-bot" 25 | 26 | func main() { 27 | configFile := flag.String("config", "", "path to configuration file") 28 | flag.Parse() 29 | 30 | config, err := startup.NewConfig(*configFile) 31 | if err != nil { 32 | log.Fatalf("failed to init config: %v", err) 33 | } 34 | 35 | logger := startup.NewLogger(serviceName, config.LogLevel) 36 | 37 | logger.With("config", config).Info("application staring with this config") 38 | 39 | tracerProvider, err := metrics.InitTracer(config.Metrics, serviceName) 40 | if err != nil { 41 | logger.WithError(err). 42 | Fatal("failed to create tracer") 43 | } 44 | 45 | tgClient, err := telegram.NewClient(config.Telegram, logger) 46 | if err != nil { 47 | logger.WithError(err). 48 | Fatal("failed to connect to telegram") 49 | } 50 | 51 | tgClientDecorator := metrics.NewTelegramClientTracerDecorator( 52 | metrics.NewTelegramClientLatencyDecorator(tgClient), tracerProvider, 53 | ) 54 | 55 | exchangeClient, err := exchangeclient.NewClient(config.ExchangeClient) 56 | if err != nil { 57 | logger.WithError(err). 58 | Fatal("failed to create exchange client") 59 | } 60 | 61 | dbClient, err := startup.DatabaseConnect(config.Database) 62 | if err != nil { 63 | logger.WithError(err). 64 | Fatal("failed to connect to database") 65 | } 66 | defer func() { 67 | if err := dbClient.Close(); err != nil { 68 | logger.WithError(err). 69 | Warn("failed to close database") 70 | } 71 | }() 72 | 73 | redisClient, err := startup.RedisConnect(config.Redis) 74 | if err != nil { 75 | logger.WithError(err). 76 | Fatal("failed to connect to redis") 77 | } 78 | defer func() { 79 | if err := redisClient.Close(); err != nil { 80 | logger.WithError(err). 81 | Warn("failed to close redis") 82 | } 83 | }() 84 | 85 | kafkaClient := startup.NewKafkaProducer(config.Kafka) 86 | defer func() { 87 | if err := kafkaClient.Close(); err != nil { 88 | logger.WithError(err). 89 | Warn("failed to close kafka") 90 | } 91 | }() 92 | 93 | kafkaProducer := kafka.NewProducer(kafkaClient) 94 | 95 | userRepo := metrics.NewUserRepositoryTracerDecorator( 96 | metrics.NewUserRepositoryAmountErrorsDecorator( 97 | metrics.NewUserRepositoryLatencyDecorator( 98 | repository.NewUserRepository(dbClient), 99 | ), 100 | ), tracerProvider, 101 | ) 102 | wasteRepo := metrics.NewWasteRepositoryTracerDecorator( 103 | metrics.NewWasteRepositoryAmountErrorsDecorator( 104 | metrics.NewWasteRepositoryLatencyDecorator( 105 | repository.NewWasteRepository(dbClient), 106 | ), 107 | ), tracerProvider, 108 | ) 109 | 110 | exchangeService, err := exchangeservice.NewService(config.Currency, exchangeClient, logger) 111 | if err != nil { 112 | logger.WithError(err). 113 | Fatal("failed to create exchange repository") 114 | } 115 | 116 | userContextService := metrics.NewUserContextServiceTracerDecorator( 117 | metrics.NewUserContextServiceAmountErrorsDecorator( 118 | metrics.NewUserContextServiceLatencyDecorator( 119 | usercontext.NewService(redisClient, config.Currency.Default), 120 | ), 121 | ), tracerProvider, 122 | ) 123 | 124 | cacheService := metrics.NewCacheServiceTracerDecorator( 125 | metrics.NewCacheServiceAmountErrorsDecorator( 126 | metrics.NewCacheServiceLatencyDecorator( 127 | cache.NewService(redisClient, config.Cache), 128 | ), 129 | ), tracerProvider, 130 | ) 131 | 132 | handlers := handlers.NewMessageHandlers( 133 | userRepo, 134 | wasteRepo, 135 | exchangeService, 136 | userContextService, 137 | kafkaProducer, 138 | ) 139 | 140 | commands := []string{"add", "setLimit", "getLimit", "week", "month", "year", "currency"} 141 | 142 | iterationMessage := metrics.NewIterationMessageTracerDecorator(bot.NewIterationMessage(tgClientDecorator), tracerProvider) 143 | botComponent := bot.New(tgClientDecorator, iterationMessage, logger, handlers.GetHandlers()) 144 | botComponent.UseMiddleware(bot.CheckUserMiddleware(userRepo)) 145 | botComponent.UseMiddleware(bot.CacheMiddleware(cacheService, logger)) 146 | botComponent.UseMiddleware(bot.LoggerMiddleware(logger)) 147 | botComponent.UseMiddleware(metrics.LatencyMetricMiddleware(commands)) 148 | botComponent.UseMiddleware(metrics.AmountMetricMiddleware(commands)) 149 | botComponent.UseMiddleware(metrics.TracingMiddleware(tracerProvider, commands)) 150 | 151 | httpRouter := http.NewHttpRouter(config.Http, logger) 152 | grpcServer := grpc.NewServer(config.Grpc, tgClientDecorator, cacheService, logger) 153 | 154 | err = app.New(config.App, logger, 155 | exchangeService, 156 | botComponent, 157 | httpRouter, 158 | grpcServer, 159 | ).Run(context.Background()) 160 | if err != nil { 161 | logger.WithError(err).Fatal("failed during running app") 162 | } 163 | } 164 | -------------------------------------------------------------------------------- /internal/ent/waste.go: -------------------------------------------------------------------------------- 1 | // Code generated by ent, DO NOT EDIT. 2 | 3 | package ent 4 | 5 | import ( 6 | "fmt" 7 | "strings" 8 | "time" 9 | 10 | "entgo.io/ent/dialect/sql" 11 | "github.com/google/uuid" 12 | "gitlab.ozon.dev/stepanov.ao.dev/telegram-bot/internal/ent/user" 13 | "gitlab.ozon.dev/stepanov.ao.dev/telegram-bot/internal/ent/waste" 14 | ) 15 | 16 | // Waste is the model entity for the Waste schema. 17 | type Waste struct { 18 | config `json:"-"` 19 | // ID of the ent. 20 | ID uuid.UUID `json:"id,omitempty"` 21 | // Cost holds the value of the "cost" field. 22 | Cost int64 `json:"cost,omitempty"` 23 | // Category holds the value of the "category" field. 24 | Category string `json:"category,omitempty"` 25 | // Date holds the value of the "date" field. 26 | Date time.Time `json:"date,omitempty"` 27 | // Edges holds the relations/edges for other nodes in the graph. 28 | // The values are being populated by the WasteQuery when eager-loading is set. 29 | Edges WasteEdges `json:"edges"` 30 | user_wastes *int64 31 | } 32 | 33 | // WasteEdges holds the relations/edges for other nodes in the graph. 34 | type WasteEdges struct { 35 | // User holds the value of the user edge. 36 | User *User `json:"user,omitempty"` 37 | // loadedTypes holds the information for reporting if a 38 | // type was loaded (or requested) in eager-loading or not. 39 | loadedTypes [1]bool 40 | } 41 | 42 | // UserOrErr returns the User value or an error if the edge 43 | // was not loaded in eager-loading, or loaded but was not found. 44 | func (e WasteEdges) UserOrErr() (*User, error) { 45 | if e.loadedTypes[0] { 46 | if e.User == nil { 47 | // Edge was loaded but was not found. 48 | return nil, &NotFoundError{label: user.Label} 49 | } 50 | return e.User, nil 51 | } 52 | return nil, &NotLoadedError{edge: "user"} 53 | } 54 | 55 | // scanValues returns the types for scanning values from sql.Rows. 56 | func (*Waste) scanValues(columns []string) ([]any, error) { 57 | values := make([]any, len(columns)) 58 | for i := range columns { 59 | switch columns[i] { 60 | case waste.FieldCost: 61 | values[i] = new(sql.NullInt64) 62 | case waste.FieldCategory: 63 | values[i] = new(sql.NullString) 64 | case waste.FieldDate: 65 | values[i] = new(sql.NullTime) 66 | case waste.FieldID: 67 | values[i] = new(uuid.UUID) 68 | case waste.ForeignKeys[0]: // user_wastes 69 | values[i] = new(sql.NullInt64) 70 | default: 71 | return nil, fmt.Errorf("unexpected column %q for type Waste", columns[i]) 72 | } 73 | } 74 | return values, nil 75 | } 76 | 77 | // assignValues assigns the values that were returned from sql.Rows (after scanning) 78 | // to the Waste fields. 79 | func (w *Waste) assignValues(columns []string, values []any) error { 80 | if m, n := len(values), len(columns); m < n { 81 | return fmt.Errorf("mismatch number of scan values: %d != %d", m, n) 82 | } 83 | for i := range columns { 84 | switch columns[i] { 85 | case waste.FieldID: 86 | if value, ok := values[i].(*uuid.UUID); !ok { 87 | return fmt.Errorf("unexpected type %T for field id", values[i]) 88 | } else if value != nil { 89 | w.ID = *value 90 | } 91 | case waste.FieldCost: 92 | if value, ok := values[i].(*sql.NullInt64); !ok { 93 | return fmt.Errorf("unexpected type %T for field cost", values[i]) 94 | } else if value.Valid { 95 | w.Cost = value.Int64 96 | } 97 | case waste.FieldCategory: 98 | if value, ok := values[i].(*sql.NullString); !ok { 99 | return fmt.Errorf("unexpected type %T for field category", values[i]) 100 | } else if value.Valid { 101 | w.Category = value.String 102 | } 103 | case waste.FieldDate: 104 | if value, ok := values[i].(*sql.NullTime); !ok { 105 | return fmt.Errorf("unexpected type %T for field date", values[i]) 106 | } else if value.Valid { 107 | w.Date = value.Time 108 | } 109 | case waste.ForeignKeys[0]: 110 | if value, ok := values[i].(*sql.NullInt64); !ok { 111 | return fmt.Errorf("unexpected type %T for edge-field user_wastes", value) 112 | } else if value.Valid { 113 | w.user_wastes = new(int64) 114 | *w.user_wastes = int64(value.Int64) 115 | } 116 | } 117 | } 118 | return nil 119 | } 120 | 121 | // QueryUser queries the "user" edge of the Waste entity. 122 | func (w *Waste) QueryUser() *UserQuery { 123 | return (&WasteClient{config: w.config}).QueryUser(w) 124 | } 125 | 126 | // Update returns a builder for updating this Waste. 127 | // Note that you need to call Waste.Unwrap() before calling this method if this Waste 128 | // was returned from a transaction, and the transaction was committed or rolled back. 129 | func (w *Waste) Update() *WasteUpdateOne { 130 | return (&WasteClient{config: w.config}).UpdateOne(w) 131 | } 132 | 133 | // Unwrap unwraps the Waste entity that was returned from a transaction after it was closed, 134 | // so that all future queries will be executed through the driver which created the transaction. 135 | func (w *Waste) Unwrap() *Waste { 136 | _tx, ok := w.config.driver.(*txDriver) 137 | if !ok { 138 | panic("ent: Waste is not a transactional entity") 139 | } 140 | w.config.driver = _tx.drv 141 | return w 142 | } 143 | 144 | // String implements the fmt.Stringer. 145 | func (w *Waste) String() string { 146 | var builder strings.Builder 147 | builder.WriteString("Waste(") 148 | builder.WriteString(fmt.Sprintf("id=%v, ", w.ID)) 149 | builder.WriteString("cost=") 150 | builder.WriteString(fmt.Sprintf("%v", w.Cost)) 151 | builder.WriteString(", ") 152 | builder.WriteString("category=") 153 | builder.WriteString(w.Category) 154 | builder.WriteString(", ") 155 | builder.WriteString("date=") 156 | builder.WriteString(w.Date.Format(time.ANSIC)) 157 | builder.WriteByte(')') 158 | return builder.String() 159 | } 160 | 161 | // Wastes is a parsable slice of Waste. 162 | type Wastes []*Waste 163 | 164 | func (w Wastes) config(cfg config) { 165 | for _i := range w { 166 | w[_i].config = cfg 167 | } 168 | } 169 | -------------------------------------------------------------------------------- /docker-compose.yaml: -------------------------------------------------------------------------------- 1 | version: "3.9" 2 | 3 | services: 4 | mongodb: 5 | container_name: mongodb 6 | restart: unless-stopped 7 | image: mongo:3 8 | volumes: 9 | - mongo-data:/data/db 10 | networks: 11 | - telegram-bot-net 12 | 13 | elasticsearch: 14 | container_name: elasticsearch 15 | restart: unless-stopped 16 | image: elasticsearch:7.17.6 17 | volumes: 18 | - elasticsearch-data:/usr/share/elasticsearch/data 19 | environment: 20 | - discovery.type=single-node 21 | - xpack.security.enabled=false 22 | - "ES_JAVA_OPTS=-Xms512m -Xmx512m" 23 | networks: 24 | - telegram-bot-net 25 | 26 | graylog: 27 | container_name: graylog 28 | restart: unless-stopped 29 | image: graylog/graylog:4.3 30 | volumes: 31 | - ./configs/graylog.conf:/usr/share/graylog/data/config/graylog.conf 32 | environment: 33 | - GRAYLOG_PASSWORD_SECRET=EabOdthinPafivup 34 | - GRAYLOG_ROOT_PASSWORD_SHA2=8c6976e5b5410415bde908bd4dee15dfb167a9c873fc4bb8a81f6f2ab448a918 35 | - GRAYLOG_HTTP_BIND_ADDRESS=0.0.0.0:7555 36 | - GRAYLOG_HTTP_EXTERNAL_URI=http://127.0.0.1:7555/ 37 | depends_on: 38 | - mongodb 39 | - elasticsearch 40 | ports: 41 | - 7555:7555 42 | - 8514:8514 43 | - 8514:8514/udp 44 | - 12201:12201 45 | networks: 46 | - telegram-bot-net 47 | 48 | filed: 49 | container_name: filed 50 | restart: unless-stopped 51 | image: ozonru/file.d:latest-linux-amd64 52 | command: /file.d/file.d --config /config.yaml 53 | volumes: 54 | - ./logs/telegram-bot.log:/tmp/logs/telegram-bot.log 55 | - ./logs/report-service.log:/tmp/logs/report-service.log 56 | - ./configs/filed/offsets.yaml:/tmp/offsets.yaml 57 | - ./configs/filed/filed.yaml:/config.yaml 58 | depends_on: 59 | - graylog 60 | networks: 61 | - telegram-bot-net 62 | 63 | prometheus: 64 | container_name: prometheus 65 | restart: unless-stopped 66 | image: prom/prometheus:v2.39.1 67 | ports: 68 | - 9090:9090 69 | volumes: 70 | - ./configs/prometheus.yaml:/etc/prometheus/prometheus.yaml 71 | command: 72 | - "--config.file=/etc/prometheus/prometheus.yaml" 73 | networks: 74 | - telegram-bot-net 75 | 76 | grafana: 77 | container_name: grafana 78 | restart: unless-stopped 79 | image: grafana/grafana-oss:9.2.2 80 | ports: 81 | - 3000:3000 82 | volumes: 83 | - grafana-data:/var/lib/grafana 84 | networks: 85 | - telegram-bot-net 86 | 87 | jaeger: 88 | container_name: jaeger 89 | restart: unless-stopped 90 | image: jaegertracing/all-in-one:1.18 91 | ports: 92 | - 5775:5775/udp 93 | - 6831:6831/udp 94 | - 6832:6832/udp 95 | - 5778:5778 96 | - 16686:16686 97 | - 14268:14268 98 | - 9411:9411 99 | networks: 100 | - telegram-bot-net 101 | 102 | postgres: 103 | container_name: postgres 104 | restart: unless-stopped 105 | image: postgres:14 106 | environment: 107 | POSTGRES_USER: postgres 108 | POSTGRES_PASSWORD: postgres 109 | volumes: 110 | - postgres-data:/var/lib/postgresql/data 111 | - ./configs/db-init.sql:/docker-entrypoint-initdb.d/db.sql 112 | ports: 113 | - "5432:5432" 114 | networks: 115 | - telegram-bot-net 116 | healthcheck: 117 | test: pg_isready -U postgres 118 | start_period: 30s 119 | interval: 10s 120 | timeout: 10s 121 | retries: 30 122 | 123 | redis: 124 | container_name: redis 125 | restart: unless-stopped 126 | image: redis:7 127 | volumes: 128 | - redis-data:/data 129 | ports: 130 | - "6379:6379" 131 | command: redis-server --save 20 1 --loglevel warning --requirepass redis 132 | networks: 133 | - telegram-bot-net 134 | healthcheck: 135 | test: redis-cli --raw incr ping 136 | start_period: 30s 137 | interval: 10s 138 | timeout: 10s 139 | retries: 30 140 | 141 | zookeeper: 142 | container_name: zookeeper 143 | restart: unless-stopped 144 | image: zookeeper:3.5.9 145 | ports: 146 | - "2181:2181" 147 | networks: 148 | - telegram-bot-net 149 | healthcheck: 150 | test: nc -z localhost 2181 || exit -1 151 | start_period: 30s 152 | interval: 10s 153 | timeout: 10s 154 | retries: 30 155 | 156 | kafka: 157 | container_name: kafka 158 | restart: unless-stopped 159 | image: wurstmeister/kafka:2.13-2.8.1 160 | hostname: kafka 161 | ports: 162 | - "9092:9092" 163 | environment: 164 | KAFKA_LISTENERS: "PLAINTEXT://:9092" 165 | KAFKA_ADVERTISED_LISTENERS: "PLAINTEXT://kafka:9092" 166 | KAFKA_ZOOKEEPER_CONNECT: "zookeeper:2181" 167 | KAFKA_CREATE_TOPICS: "example-topic:2:1" 168 | depends_on: 169 | zookeeper: 170 | condition: service_healthy 171 | networks: 172 | - telegram-bot-net 173 | healthcheck: 174 | test: nc -z localhost 9092 || exit -1 175 | start_period: 30s 176 | interval: 10s 177 | timeout: 10s 178 | retries: 30 179 | 180 | telegram-bot: 181 | container_name: telegram-bot 182 | restart: unless-stopped 183 | build: ./ 184 | volumes: 185 | - ./configs/config.example.yaml:/app/config.yaml 186 | - ./logs/telegram-bot.log:/app/telegram-bot.log 187 | ports: 188 | - "3001:3000" 189 | - "8080:8080" 190 | depends_on: 191 | postgres: 192 | condition: service_healthy 193 | redis: 194 | condition: service_healthy 195 | kafka: 196 | condition: service_healthy 197 | networks: 198 | - telegram-bot-net 199 | 200 | report-service: 201 | container_name: report-service 202 | restart: unless-stopped 203 | build: 204 | context: . 205 | dockerfile: Dockerfile.report-service 206 | volumes: 207 | - ./configs/report-service.yaml:/app/config.yaml 208 | - ./logs/report-service.log:/app/report-service.log 209 | ports: 210 | - "3002:3000" 211 | depends_on: 212 | postgres: 213 | condition: service_healthy 214 | kafka: 215 | condition: service_healthy 216 | networks: 217 | - telegram-bot-net 218 | 219 | volumes: 220 | mongo-data: 221 | elasticsearch-data: 222 | grafana-data: 223 | postgres-data: 224 | redis-data: 225 | 226 | networks: 227 | telegram-bot-net: 228 | -------------------------------------------------------------------------------- /internal/ent/hook/hook.go: -------------------------------------------------------------------------------- 1 | // Code generated by ent, DO NOT EDIT. 2 | 3 | package hook 4 | 5 | import ( 6 | "context" 7 | "fmt" 8 | 9 | "gitlab.ozon.dev/stepanov.ao.dev/telegram-bot/internal/ent" 10 | ) 11 | 12 | // The UserFunc type is an adapter to allow the use of ordinary 13 | // function as User mutator. 14 | type UserFunc func(context.Context, *ent.UserMutation) (ent.Value, error) 15 | 16 | // Mutate calls f(ctx, m). 17 | func (f UserFunc) Mutate(ctx context.Context, m ent.Mutation) (ent.Value, error) { 18 | mv, ok := m.(*ent.UserMutation) 19 | if !ok { 20 | return nil, fmt.Errorf("unexpected mutation type %T. expect *ent.UserMutation", m) 21 | } 22 | return f(ctx, mv) 23 | } 24 | 25 | // The WasteFunc type is an adapter to allow the use of ordinary 26 | // function as Waste mutator. 27 | type WasteFunc func(context.Context, *ent.WasteMutation) (ent.Value, error) 28 | 29 | // Mutate calls f(ctx, m). 30 | func (f WasteFunc) Mutate(ctx context.Context, m ent.Mutation) (ent.Value, error) { 31 | mv, ok := m.(*ent.WasteMutation) 32 | if !ok { 33 | return nil, fmt.Errorf("unexpected mutation type %T. expect *ent.WasteMutation", m) 34 | } 35 | return f(ctx, mv) 36 | } 37 | 38 | // Condition is a hook condition function. 39 | type Condition func(context.Context, ent.Mutation) bool 40 | 41 | // And groups conditions with the AND operator. 42 | func And(first, second Condition, rest ...Condition) Condition { 43 | return func(ctx context.Context, m ent.Mutation) bool { 44 | if !first(ctx, m) || !second(ctx, m) { 45 | return false 46 | } 47 | for _, cond := range rest { 48 | if !cond(ctx, m) { 49 | return false 50 | } 51 | } 52 | return true 53 | } 54 | } 55 | 56 | // Or groups conditions with the OR operator. 57 | func Or(first, second Condition, rest ...Condition) Condition { 58 | return func(ctx context.Context, m ent.Mutation) bool { 59 | if first(ctx, m) || second(ctx, m) { 60 | return true 61 | } 62 | for _, cond := range rest { 63 | if cond(ctx, m) { 64 | return true 65 | } 66 | } 67 | return false 68 | } 69 | } 70 | 71 | // Not negates a given condition. 72 | func Not(cond Condition) Condition { 73 | return func(ctx context.Context, m ent.Mutation) bool { 74 | return !cond(ctx, m) 75 | } 76 | } 77 | 78 | // HasOp is a condition testing mutation operation. 79 | func HasOp(op ent.Op) Condition { 80 | return func(_ context.Context, m ent.Mutation) bool { 81 | return m.Op().Is(op) 82 | } 83 | } 84 | 85 | // HasAddedFields is a condition validating `.AddedField` on fields. 86 | func HasAddedFields(field string, fields ...string) Condition { 87 | return func(_ context.Context, m ent.Mutation) bool { 88 | if _, exists := m.AddedField(field); !exists { 89 | return false 90 | } 91 | for _, field := range fields { 92 | if _, exists := m.AddedField(field); !exists { 93 | return false 94 | } 95 | } 96 | return true 97 | } 98 | } 99 | 100 | // HasClearedFields is a condition validating `.FieldCleared` on fields. 101 | func HasClearedFields(field string, fields ...string) Condition { 102 | return func(_ context.Context, m ent.Mutation) bool { 103 | if exists := m.FieldCleared(field); !exists { 104 | return false 105 | } 106 | for _, field := range fields { 107 | if exists := m.FieldCleared(field); !exists { 108 | return false 109 | } 110 | } 111 | return true 112 | } 113 | } 114 | 115 | // HasFields is a condition validating `.Field` on fields. 116 | func HasFields(field string, fields ...string) Condition { 117 | return func(_ context.Context, m ent.Mutation) bool { 118 | if _, exists := m.Field(field); !exists { 119 | return false 120 | } 121 | for _, field := range fields { 122 | if _, exists := m.Field(field); !exists { 123 | return false 124 | } 125 | } 126 | return true 127 | } 128 | } 129 | 130 | // If executes the given hook under condition. 131 | // 132 | // hook.If(ComputeAverage, And(HasFields(...), HasAddedFields(...))) 133 | func If(hk ent.Hook, cond Condition) ent.Hook { 134 | return func(next ent.Mutator) ent.Mutator { 135 | return ent.MutateFunc(func(ctx context.Context, m ent.Mutation) (ent.Value, error) { 136 | if cond(ctx, m) { 137 | return hk(next).Mutate(ctx, m) 138 | } 139 | return next.Mutate(ctx, m) 140 | }) 141 | } 142 | } 143 | 144 | // On executes the given hook only for the given operation. 145 | // 146 | // hook.On(Log, ent.Delete|ent.Create) 147 | func On(hk ent.Hook, op ent.Op) ent.Hook { 148 | return If(hk, HasOp(op)) 149 | } 150 | 151 | // Unless skips the given hook only for the given operation. 152 | // 153 | // hook.Unless(Log, ent.Update|ent.UpdateOne) 154 | func Unless(hk ent.Hook, op ent.Op) ent.Hook { 155 | return If(hk, Not(HasOp(op))) 156 | } 157 | 158 | // FixedError is a hook returning a fixed error. 159 | func FixedError(err error) ent.Hook { 160 | return func(ent.Mutator) ent.Mutator { 161 | return ent.MutateFunc(func(context.Context, ent.Mutation) (ent.Value, error) { 162 | return nil, err 163 | }) 164 | } 165 | } 166 | 167 | // Reject returns a hook that rejects all operations that match op. 168 | // 169 | // func (T) Hooks() []ent.Hook { 170 | // return []ent.Hook{ 171 | // Reject(ent.Delete|ent.Update), 172 | // } 173 | // } 174 | func Reject(op ent.Op) ent.Hook { 175 | hk := FixedError(fmt.Errorf("%s operation is not allowed", op)) 176 | return On(hk, op) 177 | } 178 | 179 | // Chain acts as a list of hooks and is effectively immutable. 180 | // Once created, it will always hold the same set of hooks in the same order. 181 | type Chain struct { 182 | hooks []ent.Hook 183 | } 184 | 185 | // NewChain creates a new chain of hooks. 186 | func NewChain(hooks ...ent.Hook) Chain { 187 | return Chain{append([]ent.Hook(nil), hooks...)} 188 | } 189 | 190 | // Hook chains the list of hooks and returns the final hook. 191 | func (c Chain) Hook() ent.Hook { 192 | return func(mutator ent.Mutator) ent.Mutator { 193 | for i := len(c.hooks) - 1; i >= 0; i-- { 194 | mutator = c.hooks[i](mutator) 195 | } 196 | return mutator 197 | } 198 | } 199 | 200 | // Append extends a chain, adding the specified hook 201 | // as the last ones in the mutation flow. 202 | func (c Chain) Append(hooks ...ent.Hook) Chain { 203 | newHooks := make([]ent.Hook, 0, len(c.hooks)+len(hooks)) 204 | newHooks = append(newHooks, c.hooks...) 205 | newHooks = append(newHooks, hooks...) 206 | return Chain{newHooks} 207 | } 208 | 209 | // Extend extends a chain, adding the specified chain 210 | // as the last ones in the mutation flow. 211 | func (c Chain) Extend(chain Chain) Chain { 212 | return c.Append(chain.hooks...) 213 | } 214 | -------------------------------------------------------------------------------- /internal/ent/tx.go: -------------------------------------------------------------------------------- 1 | // Code generated by ent, DO NOT EDIT. 2 | 3 | package ent 4 | 5 | import ( 6 | "context" 7 | "sync" 8 | 9 | "entgo.io/ent/dialect" 10 | ) 11 | 12 | // Tx is a transactional client that is created by calling Client.Tx(). 13 | type Tx struct { 14 | config 15 | // User is the client for interacting with the User builders. 16 | User *UserClient 17 | // Waste is the client for interacting with the Waste builders. 18 | Waste *WasteClient 19 | 20 | // lazily loaded. 21 | client *Client 22 | clientOnce sync.Once 23 | 24 | // completion callbacks. 25 | mu sync.Mutex 26 | onCommit []CommitHook 27 | onRollback []RollbackHook 28 | 29 | // ctx lives for the life of the transaction. It is 30 | // the same context used by the underlying connection. 31 | ctx context.Context 32 | } 33 | 34 | type ( 35 | // Committer is the interface that wraps the Commit method. 36 | Committer interface { 37 | Commit(context.Context, *Tx) error 38 | } 39 | 40 | // The CommitFunc type is an adapter to allow the use of ordinary 41 | // function as a Committer. If f is a function with the appropriate 42 | // signature, CommitFunc(f) is a Committer that calls f. 43 | CommitFunc func(context.Context, *Tx) error 44 | 45 | // CommitHook defines the "commit middleware". A function that gets a Committer 46 | // and returns a Committer. For example: 47 | // 48 | // hook := func(next ent.Committer) ent.Committer { 49 | // return ent.CommitFunc(func(ctx context.Context, tx *ent.Tx) error { 50 | // // Do some stuff before. 51 | // if err := next.Commit(ctx, tx); err != nil { 52 | // return err 53 | // } 54 | // // Do some stuff after. 55 | // return nil 56 | // }) 57 | // } 58 | // 59 | CommitHook func(Committer) Committer 60 | ) 61 | 62 | // Commit calls f(ctx, m). 63 | func (f CommitFunc) Commit(ctx context.Context, tx *Tx) error { 64 | return f(ctx, tx) 65 | } 66 | 67 | // Commit commits the transaction. 68 | func (tx *Tx) Commit() error { 69 | txDriver := tx.config.driver.(*txDriver) 70 | var fn Committer = CommitFunc(func(context.Context, *Tx) error { 71 | return txDriver.tx.Commit() 72 | }) 73 | tx.mu.Lock() 74 | hooks := append([]CommitHook(nil), tx.onCommit...) 75 | tx.mu.Unlock() 76 | for i := len(hooks) - 1; i >= 0; i-- { 77 | fn = hooks[i](fn) 78 | } 79 | return fn.Commit(tx.ctx, tx) 80 | } 81 | 82 | // OnCommit adds a hook to call on commit. 83 | func (tx *Tx) OnCommit(f CommitHook) { 84 | tx.mu.Lock() 85 | defer tx.mu.Unlock() 86 | tx.onCommit = append(tx.onCommit, f) 87 | } 88 | 89 | type ( 90 | // Rollbacker is the interface that wraps the Rollback method. 91 | Rollbacker interface { 92 | Rollback(context.Context, *Tx) error 93 | } 94 | 95 | // The RollbackFunc type is an adapter to allow the use of ordinary 96 | // function as a Rollbacker. If f is a function with the appropriate 97 | // signature, RollbackFunc(f) is a Rollbacker that calls f. 98 | RollbackFunc func(context.Context, *Tx) error 99 | 100 | // RollbackHook defines the "rollback middleware". A function that gets a Rollbacker 101 | // and returns a Rollbacker. For example: 102 | // 103 | // hook := func(next ent.Rollbacker) ent.Rollbacker { 104 | // return ent.RollbackFunc(func(ctx context.Context, tx *ent.Tx) error { 105 | // // Do some stuff before. 106 | // if err := next.Rollback(ctx, tx); err != nil { 107 | // return err 108 | // } 109 | // // Do some stuff after. 110 | // return nil 111 | // }) 112 | // } 113 | // 114 | RollbackHook func(Rollbacker) Rollbacker 115 | ) 116 | 117 | // Rollback calls f(ctx, m). 118 | func (f RollbackFunc) Rollback(ctx context.Context, tx *Tx) error { 119 | return f(ctx, tx) 120 | } 121 | 122 | // Rollback rollbacks the transaction. 123 | func (tx *Tx) Rollback() error { 124 | txDriver := tx.config.driver.(*txDriver) 125 | var fn Rollbacker = RollbackFunc(func(context.Context, *Tx) error { 126 | return txDriver.tx.Rollback() 127 | }) 128 | tx.mu.Lock() 129 | hooks := append([]RollbackHook(nil), tx.onRollback...) 130 | tx.mu.Unlock() 131 | for i := len(hooks) - 1; i >= 0; i-- { 132 | fn = hooks[i](fn) 133 | } 134 | return fn.Rollback(tx.ctx, tx) 135 | } 136 | 137 | // OnRollback adds a hook to call on rollback. 138 | func (tx *Tx) OnRollback(f RollbackHook) { 139 | tx.mu.Lock() 140 | defer tx.mu.Unlock() 141 | tx.onRollback = append(tx.onRollback, f) 142 | } 143 | 144 | // Client returns a Client that binds to current transaction. 145 | func (tx *Tx) Client() *Client { 146 | tx.clientOnce.Do(func() { 147 | tx.client = &Client{config: tx.config} 148 | tx.client.init() 149 | }) 150 | return tx.client 151 | } 152 | 153 | func (tx *Tx) init() { 154 | tx.User = NewUserClient(tx.config) 155 | tx.Waste = NewWasteClient(tx.config) 156 | } 157 | 158 | // txDriver wraps the given dialect.Tx with a nop dialect.Driver implementation. 159 | // The idea is to support transactions without adding any extra code to the builders. 160 | // When a builder calls to driver.Tx(), it gets the same dialect.Tx instance. 161 | // Commit and Rollback are nop for the internal builders and the user must call one 162 | // of them in order to commit or rollback the transaction. 163 | // 164 | // If a closed transaction is embedded in one of the generated entities, and the entity 165 | // applies a query, for example: User.QueryXXX(), the query will be executed 166 | // through the driver which created this transaction. 167 | // 168 | // Note that txDriver is not goroutine safe. 169 | type txDriver struct { 170 | // the driver we started the transaction from. 171 | drv dialect.Driver 172 | // tx is the underlying transaction. 173 | tx dialect.Tx 174 | } 175 | 176 | // newTx creates a new transactional driver. 177 | func newTx(ctx context.Context, drv dialect.Driver) (*txDriver, error) { 178 | tx, err := drv.Tx(ctx) 179 | if err != nil { 180 | return nil, err 181 | } 182 | return &txDriver{tx: tx, drv: drv}, nil 183 | } 184 | 185 | // Tx returns the transaction wrapper (txDriver) to avoid Commit or Rollback calls 186 | // from the internal builders. Should be called only by the internal builders. 187 | func (tx *txDriver) Tx(context.Context) (dialect.Tx, error) { return tx, nil } 188 | 189 | // Dialect returns the dialect of the driver we started the transaction from. 190 | func (tx *txDriver) Dialect() string { return tx.drv.Dialect() } 191 | 192 | // Close is a nop close. 193 | func (*txDriver) Close() error { return nil } 194 | 195 | // Commit is a nop commit for the internal builders. 196 | // User must call `Tx.Commit` in order to commit the transaction. 197 | func (*txDriver) Commit() error { return nil } 198 | 199 | // Rollback is a nop rollback for the internal builders. 200 | // User must call `Tx.Rollback` in order to rollback the transaction. 201 | func (*txDriver) Rollback() error { return nil } 202 | 203 | // Exec calls tx.Exec. 204 | func (tx *txDriver) Exec(ctx context.Context, query string, args, v any) error { 205 | return tx.tx.Exec(ctx, query, args, v) 206 | } 207 | 208 | // Query calls tx.Query. 209 | func (tx *txDriver) Query(ctx context.Context, query string, args, v any) error { 210 | return tx.tx.Query(ctx, query, args, v) 211 | } 212 | 213 | var _ dialect.Driver = (*txDriver)(nil) 214 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Телеграм бот для учета трат 2 | 3 | ## Структура проекта 4 | 5 | ### `cmd` 6 | 7 | - `bot` - основной сервис с телеграм ботом 8 | - `report-service` - сервис получения отчетов по тратам за период времени 9 | - `migrate` - cli для обновления файлов atlas миграции для базы данных 10 | 11 | ### `internal` 12 | 13 | - `api` - proto файлы для grpc взаимодействия с сервисом бота 14 | - `app` - пакет для запуска приложения 15 | - `bot` - бизнес-логика бота, обработка сообщений 16 | - `clients` - клиенты для внешних сервисов 17 | - `exchange` - клиент внешнего http сервиса с данными о курсе валют 18 | - `grpc` - клиент для общения `report-service` с сервисом `bot` 19 | - `telegram` - клиент для взаимодействия с telegram 20 | - `ent` - сгенерированные файлы для работы с PostgreSQL 21 | - `grpc` - компонент grpc-сервера 22 | - `http` - компонент http-роутера 23 | - `metrics` - декораторы для подстчета метрик и трейсинга 24 | - `migrations` - сгенированные файлы atlas миграции для базы данных 25 | - `models` - модели базы данных 26 | - `repository` - репозитории для взаимодействия с базой данных 27 | - `service` - внутренние сервисы 28 | - `cache` - сервис кеширования 29 | - `exchange` - сервис получения курса валют 30 | - `kafka` - взаимодействие `bot` и `report-service` через очередь сообщений 31 | - `usercontext` - контекст общения с пользователями, хранящийся в redis 32 | - `wastereport` - сервис генерации отчета по тратам 33 | 34 | ### `pkg` 35 | 36 | - `log` - интерфейс и реализация для логгера для всего проекта 37 | 38 | ## Архитектура 39 | 40 | [![Архитектура](./docs/architecture.jpg)](./docs/architecture.pdf) 41 | 42 | [Pdf файл](./docs/gohw-4.pdf) с архитектурой сервиса и расчетами нагрузки на 1000, 100000 и 1000000 пользователей (ДЗ 4) 43 | 44 | ## Инфраструктура 45 | 46 | - Метрики сервиса: [http://localhost:8080/metrics](http://localhost:8080/metrics) 47 | - Graylog: [http://localhost:7555](http://localhost:7555) 48 | - Prometheus: [http://localhost:9090](http://localhost:9090) 49 | - Grafana: [http://localhost:3000](http://localhost:3000) 50 | - Jaeger: [http://localhost:16686](http://localhost:16686) 51 | 52 | ## Задания 53 | 54 | ### 1 неделя 55 | 56 | Продолжить работу над ботом, каркас которого создали на воркшопе. 57 | 58 | Нужно добавить функционал: 59 | 60 | - Команда добавления новой финансовой "траты". В трате должна присутствовать сумма, категория и дата. Но можете добавить еще поля, если считаете нужным. Придумайте, как оформить команду так, чтобы пользователю было удобно ее использовать. 61 | - Хранение трат в памяти, базы данных пока не используем. 62 | - Команда запроса отчета за последнюю неделю/месяц/год. В отчете должны быть суммы трат по категориям. 63 | 64 | ### 2 неделя 65 | 66 | Нужно в нашем боте добавить новый функционал: 67 | 68 | 1. Команда переключения бота на конкретную валюту - "выбрать валюту" 69 | 2. После ввода команды бот предлагает выбрать интересующую валюту из четырех: USD, CNY, EUR, RUB 70 | 3. При нажатии на нужную валюту переключаем бота на нее - результат получение трат конвертируется в выбранную валюту. 71 | 4. Храним траты всегда в рублях, конвертацию используем только для отображения, ввода и отчетов 72 | 73 | _Особенности_ 74 | 75 | 1. При запуске сервиса мы в отдельном потоке запрашиваем курсы валют. 76 | 2. Запрос курса валют происходит из любого из открытых источников. 77 | 3. Сервис должен завершаться gracefully. 78 | 79 | ### 3 неделя 80 | 81 | 1. Завести PostgreSQL в Docker, резметить таблички и схемы, любые действия с БД проводить только через миграции 82 | 2. Перенести хранение данных из памяти приложения в базу данных 83 | 3. Добавить бюджеты/лимиты на траты в месяц, при проведении транзакций проверять согласованность данных (превышен ли бюджет и тд) 84 | 4. Сгенерировать тестовые данные для таблицы расходов 85 | 5. Создать индексы на таблицу расходов, в комментариях к миграции пояснить выбор индексируемых колонок и типа индекса 86 | 87 | _Что будет оцениваться:_ 88 | 89 | - низкая связность кода, зависимости через интерфейсы 90 | - нормализованная структура данных в БД 91 | - правильность обработки транзакций и ошибок 92 | 93 | _Дополнительные задания:_ 94 | 95 | - покрыть бизнес логику тестами, взаимодействие с базой замокать 96 | - интеграционные тесты на sql код 97 | 98 | ### 4 неделя 99 | 100 | _Задание_ 101 | 102 | Нарисовать три схемы на (1000, 100 000, 1 000 000 пользователей). Сделать это можно в любом подходящем для этого редакторе (app.diagrams.net, miro.com, etc...). 103 | При проектировании на схеме необходимо создать табличку где будем указывать следующие вводные, разберем на примере сервиса почтовых рассылок. 104 | 105 | **Функциональные требования (зачем нужен сервис, какую проблему он решает):** 106 | 107 | - отправляет почтовые рассылки 108 | - позволяет тестировать а/б 109 | - предоставляет статистику 110 | 111 | **Не функциональные требования:** 112 | 113 | - высокая скорость работы 114 | - высокая отказоустойчивость 115 | 116 | **Дополнительные требования:** 117 | 118 | - возможность push нотификаций 119 | - отслеживания доставки в реальном времени на grafana 120 | 121 | **Нагрузка:** 122 | 123 | - n RPS (средняя, максимальная) 124 | 125 | **Оценка хранилища:** 126 | 127 | - за год мы отправим n сообщений 128 | - в год мы ожидаем прирост на n петабайт для хранения данных 129 | - через n времени необходимо будет реплицировать базу, перевести запросы на чтение с асинхронной реплики для аналитики 130 | - бекапы (x3 к размеру данных) 131 | 132 | **Оценка размера оперативной памяти:** 133 | 134 | - Базе данных требуется n ГБ на инстанс 135 | - Кеш хранит 20% запросов за 24 часа, в среднем сообщение занимает n byte, необходимо n гигабайт памяти. 136 | 137 | Вам необходимо сделать оценку по данному шаблону, с вашими цифрами и формулировками по сервису учета расходов. Табличку можно сделать общую на три схемы. 138 | 139 | ### 5 неделя 140 | 141 | 1. Перевести бота на ведение структурированных логов в STDOUT. 142 | 2. Инструментировать код трейсингом. Создавать спан на каждое пришедшее сообщение. Корректно прокидывать контекст внутрь дерева функций и покрыть спанами важные части. 143 | 3. Добавить метрики количества сообщений и времени обработки одного сообщения от пользователя. Разбить эти метрики по разным типам команд. 144 | 145 | _Задания на бонусы:_ 146 | 147 | 1. Придумать и реализовать еще несколько полезных метрик для своего бота 148 | 2. Добавить в свой композ-файл и настроить инфраструктуру сбора метрик и создать рабочий дашборд с несколькими панелями в Графане 149 | 3. Добавить в композ-файл и настроить инфраструктуру сбора трейсов. Трейсы должны успешно искаться через веб-интерфейс Jaeger 150 | 151 | ### 6 неделя 152 | 153 | _Задание_ 154 | 155 | Нужно в нашем боте добавить функционал кэширования расходов из отчета: 156 | 157 | 1. Если пользователь уже запрашивал отчет за конкретный период, то возвращать расходы по нему из кэша 158 | 2. Для кэширования использовать либо LRU с воркшопа, либо любое другое решение, например, Redis или Memcache 159 | 160 | ### 7 неделя 161 | 162 | Добавить сервис по построению произвольных отчетов вашего бота. 163 | Сервис должен быть реализован как отдельный микросервис, который запускается вместе с остальными в вашем компоуз файле. 164 | 165 | Взаимодействие с сервисом построения отчетов осуществляется следующим образом: 166 | 167 | 1. пользователь выбирает в телеграм боте построить n отчет 168 | 2. сервис телеграм бота отправляет (продюсит) запрос на построение отчета в кафку 169 | 3. сервис построения отчетов потребляет (консюмит) сообщение из кафки, формирует нужный отчет 170 | 4. вызывает сервис телеграм бота по gRPC с результатами отчета 171 | 5. результаты возвращаются пользователю в телеграм 172 | 173 | _Задания на бонусы:_ 174 | 175 | - добавить в интерцепторы gRPC метрики на вызываемые методы 176 | - добавить метрики в продюсере и консюмере 177 | - создать на стороне сервера grpc-gateway с возможностью вызова по gRPC и с помощью RESTful API 178 | - добавить валидацию запросов путем добавления плагина в proto файле 179 | -------------------------------------------------------------------------------- /internal/api/telegram_bot.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.5 5 | // source: telegram_bot.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 Message struct { 24 | state protoimpl.MessageState 25 | sizeCache protoimpl.SizeCache 26 | unknownFields protoimpl.UnknownFields 27 | 28 | UserId int64 `protobuf:"varint,1,opt,name=user_id,json=userId,proto3" json:"user_id,omitempty"` 29 | Text string `protobuf:"bytes,2,opt,name=text,proto3" json:"text,omitempty"` 30 | Command string `protobuf:"bytes,3,opt,name=command,proto3" json:"command,omitempty"` 31 | } 32 | 33 | func (x *Message) Reset() { 34 | *x = Message{} 35 | if protoimpl.UnsafeEnabled { 36 | mi := &file_telegram_bot_proto_msgTypes[0] 37 | ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) 38 | ms.StoreMessageInfo(mi) 39 | } 40 | } 41 | 42 | func (x *Message) String() string { 43 | return protoimpl.X.MessageStringOf(x) 44 | } 45 | 46 | func (*Message) ProtoMessage() {} 47 | 48 | func (x *Message) ProtoReflect() protoreflect.Message { 49 | mi := &file_telegram_bot_proto_msgTypes[0] 50 | if protoimpl.UnsafeEnabled && x != nil { 51 | ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) 52 | if ms.LoadMessageInfo() == nil { 53 | ms.StoreMessageInfo(mi) 54 | } 55 | return ms 56 | } 57 | return mi.MessageOf(x) 58 | } 59 | 60 | // Deprecated: Use Message.ProtoReflect.Descriptor instead. 61 | func (*Message) Descriptor() ([]byte, []int) { 62 | return file_telegram_bot_proto_rawDescGZIP(), []int{0} 63 | } 64 | 65 | func (x *Message) GetUserId() int64 { 66 | if x != nil { 67 | return x.UserId 68 | } 69 | return 0 70 | } 71 | 72 | func (x *Message) GetText() string { 73 | if x != nil { 74 | return x.Text 75 | } 76 | return "" 77 | } 78 | 79 | func (x *Message) GetCommand() string { 80 | if x != nil { 81 | return x.Command 82 | } 83 | return "" 84 | } 85 | 86 | type EmptyMessage struct { 87 | state protoimpl.MessageState 88 | sizeCache protoimpl.SizeCache 89 | unknownFields protoimpl.UnknownFields 90 | } 91 | 92 | func (x *EmptyMessage) Reset() { 93 | *x = EmptyMessage{} 94 | if protoimpl.UnsafeEnabled { 95 | mi := &file_telegram_bot_proto_msgTypes[1] 96 | ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) 97 | ms.StoreMessageInfo(mi) 98 | } 99 | } 100 | 101 | func (x *EmptyMessage) String() string { 102 | return protoimpl.X.MessageStringOf(x) 103 | } 104 | 105 | func (*EmptyMessage) ProtoMessage() {} 106 | 107 | func (x *EmptyMessage) ProtoReflect() protoreflect.Message { 108 | mi := &file_telegram_bot_proto_msgTypes[1] 109 | if protoimpl.UnsafeEnabled && x != nil { 110 | ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) 111 | if ms.LoadMessageInfo() == nil { 112 | ms.StoreMessageInfo(mi) 113 | } 114 | return ms 115 | } 116 | return mi.MessageOf(x) 117 | } 118 | 119 | // Deprecated: Use EmptyMessage.ProtoReflect.Descriptor instead. 120 | func (*EmptyMessage) Descriptor() ([]byte, []int) { 121 | return file_telegram_bot_proto_rawDescGZIP(), []int{1} 122 | } 123 | 124 | var File_telegram_bot_proto protoreflect.FileDescriptor 125 | 126 | var file_telegram_bot_proto_rawDesc = []byte{ 127 | 0x0a, 0x12, 0x74, 0x65, 0x6c, 0x65, 0x67, 0x72, 0x61, 0x6d, 0x5f, 0x62, 0x6f, 0x74, 0x2e, 0x70, 128 | 0x72, 0x6f, 0x74, 0x6f, 0x12, 0x03, 0x61, 0x70, 0x69, 0x22, 0x50, 0x0a, 0x07, 0x4d, 0x65, 0x73, 129 | 0x73, 0x61, 0x67, 0x65, 0x12, 0x17, 0x0a, 0x07, 0x75, 0x73, 0x65, 0x72, 0x5f, 0x69, 0x64, 0x18, 130 | 0x01, 0x20, 0x01, 0x28, 0x03, 0x52, 0x06, 0x75, 0x73, 0x65, 0x72, 0x49, 0x64, 0x12, 0x12, 0x0a, 131 | 0x04, 0x74, 0x65, 0x78, 0x74, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x74, 0x65, 0x78, 132 | 0x74, 0x12, 0x18, 0x0a, 0x07, 0x63, 0x6f, 0x6d, 0x6d, 0x61, 0x6e, 0x64, 0x18, 0x03, 0x20, 0x01, 133 | 0x28, 0x09, 0x52, 0x07, 0x63, 0x6f, 0x6d, 0x6d, 0x61, 0x6e, 0x64, 0x22, 0x0e, 0x0a, 0x0c, 0x45, 134 | 0x6d, 0x70, 0x74, 0x79, 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x32, 0x3f, 0x0a, 0x0b, 0x54, 135 | 0x65, 0x6c, 0x65, 0x67, 0x72, 0x61, 0x6d, 0x42, 0x6f, 0x74, 0x12, 0x30, 0x0a, 0x0b, 0x53, 0x65, 136 | 0x6e, 0x64, 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x12, 0x0c, 0x2e, 0x61, 0x70, 0x69, 0x2e, 137 | 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x1a, 0x11, 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x45, 0x6d, 138 | 0x70, 0x74, 0x79, 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x22, 0x00, 0x42, 0x3a, 0x5a, 0x38, 139 | 0x67, 0x69, 0x74, 0x6c, 0x61, 0x62, 0x2e, 0x6f, 0x7a, 0x6f, 0x6e, 0x2e, 0x72, 0x75, 0x2f, 0x73, 140 | 0x74, 0x65, 0x70, 0x61, 0x6e, 0x6f, 0x76, 0x2e, 0x61, 0x6f, 0x2e, 0x64, 0x65, 0x76, 0x2f, 0x74, 141 | 0x65, 0x6c, 0x65, 0x67, 0x72, 0x61, 0x6d, 0x2d, 0x62, 0x6f, 0x74, 0x2f, 0x69, 0x6e, 0x74, 0x65, 142 | 0x72, 0x6e, 0x61, 0x6c, 0x2f, 0x61, 0x70, 0x69, 0x62, 0x06, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33, 143 | } 144 | 145 | var ( 146 | file_telegram_bot_proto_rawDescOnce sync.Once 147 | file_telegram_bot_proto_rawDescData = file_telegram_bot_proto_rawDesc 148 | ) 149 | 150 | func file_telegram_bot_proto_rawDescGZIP() []byte { 151 | file_telegram_bot_proto_rawDescOnce.Do(func() { 152 | file_telegram_bot_proto_rawDescData = protoimpl.X.CompressGZIP(file_telegram_bot_proto_rawDescData) 153 | }) 154 | return file_telegram_bot_proto_rawDescData 155 | } 156 | 157 | var file_telegram_bot_proto_msgTypes = make([]protoimpl.MessageInfo, 2) 158 | var file_telegram_bot_proto_goTypes = []interface{}{ 159 | (*Message)(nil), // 0: api.Message 160 | (*EmptyMessage)(nil), // 1: api.EmptyMessage 161 | } 162 | var file_telegram_bot_proto_depIdxs = []int32{ 163 | 0, // 0: api.TelegramBot.SendMessage:input_type -> api.Message 164 | 1, // 1: api.TelegramBot.SendMessage:output_type -> api.EmptyMessage 165 | 1, // [1:2] is the sub-list for method output_type 166 | 0, // [0:1] is the sub-list for method input_type 167 | 0, // [0:0] is the sub-list for extension type_name 168 | 0, // [0:0] is the sub-list for extension extendee 169 | 0, // [0:0] is the sub-list for field type_name 170 | } 171 | 172 | func init() { file_telegram_bot_proto_init() } 173 | func file_telegram_bot_proto_init() { 174 | if File_telegram_bot_proto != nil { 175 | return 176 | } 177 | if !protoimpl.UnsafeEnabled { 178 | file_telegram_bot_proto_msgTypes[0].Exporter = func(v interface{}, i int) interface{} { 179 | switch v := v.(*Message); i { 180 | case 0: 181 | return &v.state 182 | case 1: 183 | return &v.sizeCache 184 | case 2: 185 | return &v.unknownFields 186 | default: 187 | return nil 188 | } 189 | } 190 | file_telegram_bot_proto_msgTypes[1].Exporter = func(v interface{}, i int) interface{} { 191 | switch v := v.(*EmptyMessage); i { 192 | case 0: 193 | return &v.state 194 | case 1: 195 | return &v.sizeCache 196 | case 2: 197 | return &v.unknownFields 198 | default: 199 | return nil 200 | } 201 | } 202 | } 203 | type x struct{} 204 | out := protoimpl.TypeBuilder{ 205 | File: protoimpl.DescBuilder{ 206 | GoPackagePath: reflect.TypeOf(x{}).PkgPath(), 207 | RawDescriptor: file_telegram_bot_proto_rawDesc, 208 | NumEnums: 0, 209 | NumMessages: 2, 210 | NumExtensions: 0, 211 | NumServices: 1, 212 | }, 213 | GoTypes: file_telegram_bot_proto_goTypes, 214 | DependencyIndexes: file_telegram_bot_proto_depIdxs, 215 | MessageInfos: file_telegram_bot_proto_msgTypes, 216 | }.Build() 217 | File_telegram_bot_proto = out.File 218 | file_telegram_bot_proto_rawDesc = nil 219 | file_telegram_bot_proto_goTypes = nil 220 | file_telegram_bot_proto_depIdxs = nil 221 | } 222 | --------------------------------------------------------------------------------