diff --git a/.github/workflows/workflow.yml b/.github/workflows/workflow.yml index 67a367d..58ebb8e 100644 --- a/.github/workflows/workflow.yml +++ b/.github/workflows/workflow.yml @@ -21,17 +21,21 @@ jobs: with: go-version: '1.23.3' - - name: Install rsrc - run: go install github.com/akavel/rsrc@latest - - name: Add icon and build binary run: | - cd ./cmd - rsrc -ico ./icon.ico + cd ./cmd/algobot GOOS=windows GOARCH=amd64 go build -o ./algobot.exe + - name: build binary migrator + run: | + cd ./cmd/migrator + GOOS=windows GOARCH=amd64 go build -o ./migrator.exe + - name: Upload Windows binary uses: actions/upload-artifact@v4 with: - name: algobot - path: ./cmd/algobot.exe + name: binary + path: | + ./cmd/migrator/migrator.exe + ./cmd/algobot/algobot.exe + ./config/dev.yaml diff --git a/.gitignore b/.gitignore index 0471d0c..2c5fdc7 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,5 @@ -/base.db +*.db .env -/.idea \ No newline at end of file +/.idea +config/local.yaml +*_mock.go \ No newline at end of file diff --git a/Makefile b/Makefile index 2e516d8..c63267b 100644 --- a/Makefile +++ b/Makefile @@ -5,3 +5,24 @@ gen: --go-grpc_out=. \ --go-grpc_opt=paths=source_relative \ ./protos/*.proto + +.PHONY: dev +dev: + go run ./cmd/algobot/main.go -config=./config/local.yaml + + +.PHONY: mock-gen +mock-gen: + cd test && go generate ./... + +.PHONY: grpc-gen +grpc-gen: + protoc --go_out=. \ + --go_opt=paths=source_relative \ + --go-grpc_out=. \ + --go-grpc_opt=paths=source_relative \ + ./protos/*.proto + +.PHONY: migrate +migrate: + go run ./cmd/migrator/main.go -migrations-path=./migrations -storage-path=./storage/storage.db \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..3f1ddf2 --- /dev/null +++ b/README.md @@ -0,0 +1,102 @@ +# Algobot + +## Описание +**Алгобот** — это Telegram-бот, предназначенный для помощи в ведении учительной деятельности Алгоритмика + +Репозиторий состоит из двух приложений: +- `algobot` — основной Telegram-бот +- `migrator` — утилита для применения миграций с помощью [Goose](https://github.com/pressly/goose) + +Бот использует: +- [gpt4free-grpc-gateway](https://github.com/LZTD1/gpt4free-grpc-gateway) — gRPC-сервер для обращения к нейросетям +- [telebot-context-router](https://github.com/LZTD1/telebot-context-router) — роутер для удобного управления обработчиками сообщений в Telebot + +--- + +## Структура проекта +``` +cmd/ +├── algobot/ # Точка входа для Telegram-бота +│ └── main.go +├── migrator/ # Миграции SQLite базы через Goose +│ └── main.go + +config/ # Конфигурация +migrations/ # SQL миграции +storage/ # SQLite база данных +protos/ # gRPC proto-файлы +``` + +--- + +## Конфигурация + +Файл `config.yaml`: +```yaml +env: local # или prod +storage_path: "./storage/storage.db" # путь то файла базы данных +# telegram_token можно указать в .env или через переменные окружения TELEGRAM_TOKEN +migrations_path: "./migrations" # путь до папки с миграциями +grpc: # настройки grpc + host: "localhost" # адрес grpc сервера с нейронкой + port: 50051 # порт grpc сервера + timeout: 300s # таймаут ответа +rate_limit: # rate limit для запросов в бота + fill_period: 800ms # период пополнение бакета + bucket_limit: 6 # величина бакета +backoffice: # конфигурация для работы с бэкофисом + message_timer: 5m # период опроса новых сообщений от учеников + retries: 3 # количество ретраев при ошибке от сервера + retries_timeout: 5s # ожидание между ретраями + response_timeout: 15s # ожидание ответа от сервера +``` + +Можно передать путь до файла конфига через аргумент `--config` или переменную окружения `CONFIG_PATH`. + +--- + +## Запуск + +### 1. Сборка +```bash +go build -o migrator ./cmd/migrator +go build -o algobot ./cmd/algobot +``` + +### 2. Применение миграций +```bash +./migrator -migrations-path=./migrations -storage-path=./storage/storage.db +``` + +### 3. Запуск бота +```bash +./algobot -config ./config/config.yaml +``` + + +--- + +## Makefile команды +Для удобства сборки и генерации предусмотрен Makefile: + +| Цель | Описание | +|------------------|-----------------------------------------------------------------| +| `make gen` | Генерация Go и gRPC кода из `.proto` файлов (`./protos`) | +| `make grpc-gen` | То же самое, альтернатива `make gen` | +| `make dev` | Запуск бота в dev-режиме с конфигом `./config/local.yaml` | +| `make mock-gen` | Генерация mock'ов в папке `test/` | +| `make migrate` | Запуск миграций через `cmd/migrator` | + +Пример: +```bash +make migrate +make dev +``` + +--- + +## Переменные окружения +| Название | Описание | +|-------------------|-------------------------------| +| `TELEGRAM_TOKEN` | Токен Telegram-бота | +| `CONFIG_PATH` | Путь к файлу конфигурации | diff --git a/cmd/algobot/main.go b/cmd/algobot/main.go new file mode 100644 index 0000000..7a2089b --- /dev/null +++ b/cmd/algobot/main.go @@ -0,0 +1,51 @@ +package main + +import ( + "algobot/internal/app" + "algobot/internal/config" + "algobot/internal/lib/logger/handlers/slogpretty" + "log/slog" + "os" + "os/signal" + "syscall" +) + +const ( + envProd string = "prod" + envLocal string = "local" +) + +func main() { + cfg := config.MustLoad() + log := setupLogger(cfg.Env) + log.Info("starting application") + + application := app.New(log, cfg) + + go application.TelegramBot.Run() + log.Info("started telegram bot") + go application.Scheduler.Run() + log.Info("starting msg scheduler") + + // graceful shutdown + ch := make(chan os.Signal, 1) + signal.Notify(ch, os.Interrupt, os.Kill, syscall.SIGTERM) + <-ch + log.Info("shutting down application") + application.TelegramBot.Stop() + application.Scheduler.Stop() + log.Info("application gracefully stopped") +} + +func setupLogger(env string) *slog.Logger { + var log *slog.Logger + + switch env { + case envLocal: + log = slog.New(slogpretty.NewHandler(&slog.HandlerOptions{Level: slog.LevelDebug})) + case envProd: + log = slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelInfo})) + } + + return log +} diff --git a/cmd/icon.ico b/cmd/icon.ico deleted file mode 100644 index e785eb6..0000000 Binary files a/cmd/icon.ico and /dev/null differ diff --git a/cmd/main.go b/cmd/main.go deleted file mode 100644 index ca788ef..0000000 --- a/cmd/main.go +++ /dev/null @@ -1,107 +0,0 @@ -package main - -import ( - "database/sql" - "embed" - "github.com/joho/godotenv" - _ "github.com/ncruces/go-sqlite3/driver" - _ "github.com/ncruces/go-sqlite3/embed" - tele "gopkg.in/telebot.v4" - middleware2 "gopkg.in/telebot.v4/middleware" - "log" - "os" - "tgbot/internal/clients" - "tgbot/internal/contextHandlers" - "tgbot/internal/domain" - "tgbot/internal/middleware" - "tgbot/internal/schedulers" - "tgbot/internal/service" - "tgbot/internal/stateMachine" - "time" -) - -//go:embed migrations/*.sql -var migrationsFS embed.FS - -func main() { - // TODO зарефачить main - godotenv.Load() - - TOKEN := os.Getenv("TELEGRAM_TOKEN") - PORT := os.Getenv("GRPC_PORT") - - db, closeDb := getSqliteBase("base.db") - defer closeDb() - - sqlite3 := domain.NewSqlite3(db) - sqlite3.Migrate(migrationsFS, "migrations") - - pref := tele.Settings{ - Token: TOKEN, - Poller: &tele.LongPoller{Timeout: 10 * time.Second}, - } - - b, err := tele.NewBot(pref) - if err != nil { - log.Fatal(err) - return - } - defer b.Stop() - os.Setenv("TELEGRAM_NAME", b.Me.Username) - - boClient := clients.NewBackoffice("", clients.BackofficeSetting{ - Retry: 3, - Timeout: 2 * time.Second, - RetryTimeout: 1 * time.Second, - }) - - svc := service.NewDefaultService(sqlite3, boClient) - state := stateMachine.NewMemory() - - regMid := middleware.NewRegister(svc) - aiService := service.NewAiService(PORT) - - msgHandler := contextHandlers.NewOnText(svc, state, aiService) - callbackHandler := contextHandlers.NewOnCallback(svc, state) - - tickerStop := goSchedule(b, svc) - defer tickerStop() - - b.Use(regMid.Middleware, middleware.MessageLogger, middleware2.AutoRespond()) - b.Handle(tele.OnText, msgHandler.Handle) - b.Handle(tele.OnCallback, callbackHandler.Handle) - - b.Start() -} - -func goSchedule(b *tele.Bot, svc *service.DefaultService) func() { - sch := schedulers.NewMessage(b, svc) - ticker := time.NewTicker(10 * time.Minute) - - go func() { - for { - select { - case <-ticker.C: - log.Println("SCHEDULER | Просмотр сообщений от детей ...") - sch.Schedule() - } - } - }() - - return ticker.Stop -} - -func getSqliteBase(name string) (*sql.DB, func() error) { - db, err := sql.Open("sqlite3", "file:"+name) - if err != nil { - log.Fatal(err) - } - - err = db.Ping() - if err != nil { - log.Println(err) - } - - log.Print("Подключение к базе данных установлено\n") - return db, db.Close -} diff --git a/cmd/migrator/main.go b/cmd/migrator/main.go new file mode 100644 index 0000000..271e708 --- /dev/null +++ b/cmd/migrator/main.go @@ -0,0 +1,38 @@ +package main + +import ( + "database/sql" + "flag" + "fmt" + _ "github.com/ncruces/go-sqlite3/driver" + _ "github.com/ncruces/go-sqlite3/embed" + "github.com/pressly/goose/v3" +) + +func main() { + var migrationsPath, storagePath string + + flag.StringVar(&migrationsPath, "migrations-path", "", "path to migrations folder") + flag.StringVar(&storagePath, "storage-path", "", "path to storage file") + flag.Parse() + + if migrationsPath == "" { + panic("migrations-path is required") + } + + if storagePath == "" { + panic("storage-path is required") + } + + db, err := sql.Open("sqlite3", fmt.Sprintf("file:%s", storagePath)) + if err != nil { + panic(err) + } + + if err := goose.SetDialect("sqlite3"); err != nil { + panic(err) + } + if err := goose.Up(db, "migrations"); err != nil { + panic(err) + } +} diff --git a/config/dev.yaml b/config/dev.yaml new file mode 100644 index 0000000..8648790 --- /dev/null +++ b/config/dev.yaml @@ -0,0 +1,16 @@ +env: local # or prod +storage_path: "./storage/storage.db" +# telegram_token: TOKEN or set in env variables - TELEGRAM_TOKEN +migrations_path: "./migrations" +grpc: + host: "localhost" + port: 50051 + timeout: 300s +rate_limit: + fill_period: 800ms + bucket_limit: 6 +backoffice: + message_timer: 5m + retries: 3 + retries_timeout: 5s + response_timeout: 15s \ No newline at end of file diff --git a/go.mod b/go.mod index 4a82b5c..b5a6721 100644 --- a/go.mod +++ b/go.mod @@ -1,24 +1,39 @@ -module tgbot +module algobot go 1.23.3 require ( - github.com/PuerkitoBio/goquery v1.10.1 - github.com/golang/mock v1.6.0 + github.com/LZTD1/telebot-context-router v1.1.0 + github.com/PuerkitoBio/goquery v1.10.2 + github.com/google/uuid v1.6.0 + github.com/ilyakaznacheev/cleanenv v1.5.0 github.com/jxskiss/base62 v1.1.0 - github.com/ncruces/go-sqlite3 v0.22.0 - google.golang.org/grpc v1.71.0 - google.golang.org/protobuf v1.36.5 + github.com/ncruces/go-sqlite3 v0.25.0 + github.com/pressly/goose/v3 v3.24.2 + github.com/stretchr/testify v1.10.0 + go.uber.org/mock v0.5.0 + golang.org/x/time v0.0.0-20191024005414-555d28b269f0 + google.golang.org/grpc v1.71.1 + google.golang.org/protobuf v1.36.6 gopkg.in/telebot.v4 v4.0.0-beta.4 ) require ( + github.com/BurntSushi/toml v1.5.0 // indirect github.com/andybalholm/cascadia v1.3.3 // indirect + github.com/davecgh/go-spew v1.1.1 // indirect github.com/joho/godotenv v1.5.1 // indirect + github.com/mfridman/interpolate v0.0.2 // indirect github.com/ncruces/julianday v1.0.0 // indirect - github.com/tetratelabs/wazero v1.8.2 // indirect - golang.org/x/net v0.34.0 // indirect - golang.org/x/sys v0.29.0 // indirect - golang.org/x/text v0.21.0 // indirect - google.golang.org/genproto v0.0.0-20220519153652-3a47de7e79bd // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect + github.com/sethvargo/go-retry v0.3.0 // indirect + github.com/tetratelabs/wazero v1.9.0 // indirect + go.uber.org/multierr v1.11.0 // indirect + golang.org/x/net v0.38.0 // indirect + golang.org/x/sync v0.12.0 // indirect + golang.org/x/sys v0.31.0 // indirect + golang.org/x/text v0.23.0 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20250324211829-b45e905df463 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect + olympos.io/encoding/edn v0.0.0-20201019073823-d3554ca0b0a3 // indirect ) diff --git a/go.sum b/go.sum index 01ad168..03e7fec 100644 --- a/go.sum +++ b/go.sum @@ -55,11 +55,16 @@ cloud.google.com/go/storage v1.10.0/go.mod h1:FLPqc6j+Ki4BU591ie1oL6qBQGu2Bl/tZ9 cloud.google.com/go/storage v1.14.0/go.mod h1:GrKmX003DSIwi9o29oFT7YDnHYwZoctc3fOKtUw0Xmo= dmitri.shuralyov.com/gpu/mtl v0.0.0-20190408044501-666a987793e9/go.mod h1:H6x//7gZCb22OMCxBHrMx7a5I7Hp++hsVxbQ4BYO7hU= github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= +github.com/BurntSushi/toml v1.2.1/go.mod h1:CxXYINrC8qIiEnFrOxCa7Jy5BFHlXnUU2pbicEuybxQ= +github.com/BurntSushi/toml v1.5.0 h1:W5quZX/G/csjUnuI8SUYlsHs9M38FC7znL0lIO+DvMg= +github.com/BurntSushi/toml v1.5.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo= github.com/DataDog/datadog-go v3.2.0+incompatible/go.mod h1:LButxg5PwREeZtORoXG3tL4fMGNddJ+vMq1mwgfaqoQ= +github.com/LZTD1/telebot-context-router v1.1.0 h1:WCdHvW+dcgGU0NBWIq3Cc6X++UeWQ4BzQ6tr3AdHxfI= +github.com/LZTD1/telebot-context-router v1.1.0/go.mod h1:9A7AdlYAjrjNy6bnvB8MTl1UK8jNakfNAPKigTcgjU8= github.com/OneOfOne/xxhash v1.2.2/go.mod h1:HSdplMjZKSmBqAxg5vPj2TmRDmfkzw+cTzAElWljhcU= -github.com/PuerkitoBio/goquery v1.10.1 h1:Y8JGYUkXWTGRB6Ars3+j3kN0xg1YqqlwvdTV8WTFQcU= -github.com/PuerkitoBio/goquery v1.10.1/go.mod h1:IYiHrOMps66ag56LEH7QYDDupKXyo5A8qrjIx3ZtujY= +github.com/PuerkitoBio/goquery v1.10.2 h1:7fh2BdHcG6VFZsK7toXBT/Bh1z5Wmy8Q9MV9HqT2AM8= +github.com/PuerkitoBio/goquery v1.10.2/go.mod h1:0guWGjcLu9AYC7C1GHnpysHy056u9aEkUHwhdnePMCU= github.com/alecthomas/template v0.0.0-20160405071501-a0175ee3bccc/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc= github.com/alecthomas/template v0.0.0-20190718012654-fb15b899a751/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc= github.com/alecthomas/units v0.0.0-20151022065526-2efee857e7cf/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0= @@ -102,6 +107,8 @@ github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSs github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/dustin/go-humanize v1.0.0/go.mod h1:HtrtbFcZ19U5GC7JDqmcUSB87Iq5E25KnS6fMYU6eOk= +github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= +github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98= @@ -154,7 +161,6 @@ github.com/golang/mock v1.4.1/go.mod h1:UOMv5ysSaYNkG+OFQykRIcU/QvvxJf3p21QfJ2Bt github.com/golang/mock v1.4.3/go.mod h1:UOMv5ysSaYNkG+OFQykRIcU/QvvxJf3p21QfJ2Bt3cw= github.com/golang/mock v1.4.4/go.mod h1:l3mdAwkq5BuhzHwde/uurv3sEJeZMXNpwsxVWU71h+4= github.com/golang/mock v1.5.0/go.mod h1:CWnOUgYIOo4TcNZ0wHX3YZCqsaM1I1Jvs6v3mP3KVu8= -github.com/golang/mock v1.6.0 h1:ErTB+efbowRARo13NNdxyJji2egdxLGQhRaY+DUumQc= github.com/golang/mock v1.6.0/go.mod h1:p6yTPP+5HYm5mzsMV8JkE6ZKdX+/wYM6Hr+LicevLPs= github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= @@ -258,6 +264,8 @@ github.com/hashicorp/serf v0.9.6/go.mod h1:TXZNMjZQijwlDvp+r0b63xZ45H7JmCmgg4gpT github.com/hashicorp/serf v0.9.7/go.mod h1:TXZNMjZQijwlDvp+r0b63xZ45H7JmCmgg4gpTwn9UV4= github.com/ianlancetaylor/demangle v0.0.0-20181102032728-5e5cf60278f6/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc= github.com/ianlancetaylor/demangle v0.0.0-20200824232613-28f6c0f3b639/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc= +github.com/ilyakaznacheev/cleanenv v1.5.0 h1:0VNZXggJE2OYdXE87bfSSwGxeiGt9moSR2lOrsHHvr4= +github.com/ilyakaznacheev/cleanenv v1.5.0/go.mod h1:a5aDzaJrLCQZsazHol1w8InnDcOX0OColm64SlIi6gk= github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= github.com/jpillora/backoff v1.0.0/go.mod h1:J/6gKK9jxlEcS3zixgDgUAsiuZ7yrSoa/FX5e0EB2j4= @@ -280,9 +288,11 @@ github.com/kr/fs v0.1.0/go.mod h1:FFnZGqtBN9Gxj7eW1uZ42v5BccTP0vu6NEaFoC2HwRg= github.com/kr/logfmt v0.0.0-20140226030751-b84e30acd515/go.mod h1:+0opPa2QZZtGFBFZlji/RkVcI2GknAs/DXo4wKdlNEc= github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= github.com/kr/pretty v0.2.0/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= +github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0= github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk= github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/leodido/go-urn v1.2.0/go.mod h1:+8+nEpDfqqsY+g338gtMEUOtuK+4dEMhiQEgxpxOKII= github.com/magiconair/properties v1.8.6/go.mod h1:y3VJvCyxH9uVvJTWEGAELF3aiYNyPKd5NZ3oSwXrF60= @@ -298,7 +308,11 @@ github.com/mattn/go-isatty v0.0.10/go.mod h1:qgIWMr58cqv1PHHyhnkY9lrL7etaEgOFcME github.com/mattn/go-isatty v0.0.11/go.mod h1:PhnuNfih5lzO57/f3n+odYbM4JtupLOxQOAqxQCu2WE= github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU= github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/matttproud/golang_protobuf_extensions v1.0.1/go.mod h1:D8He9yQNgCq6Z5Ld7szi9bcBfOoFv/3dc6xSMkL2PC0= +github.com/mfridman/interpolate v0.0.2 h1:pnuTK7MQIxxFz1Gr+rjSIx9u7qVjf5VOoM/u6BbAxPY= +github.com/mfridman/interpolate v0.0.2/go.mod h1:p+7uk6oE07mpE/Ik1b8EckO0O4ZXiGAfshKBWLUM9Xg= github.com/miekg/dns v1.1.26/go.mod h1:bPDLeHnStXmXAq1m/Ch/hvfNHr14JKNPMBo3VZKjuso= github.com/miekg/dns v1.1.41/go.mod h1:p6aan82bvRIyn+zDIv9xYNUpwa73JcSh9BKwknJysuI= github.com/mitchellh/cli v1.1.0/go.mod h1:xcISNoH86gajksDmfB23e/pu+B+GeFRMYmoHXxx3xhI= @@ -315,8 +329,10 @@ github.com/modern-go/reflect2 v1.0.1/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3Rllmb github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= github.com/mwitkow/go-conntrack v0.0.0-20161129095857-cc309e4a2223/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U= github.com/mwitkow/go-conntrack v0.0.0-20190716064945-2f068394615f/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U= -github.com/ncruces/go-sqlite3 v0.22.0 h1:FkGSBhd0TY6e66k1LVhyEpA+RnG/8QkQNed5pjIk4cs= -github.com/ncruces/go-sqlite3 v0.22.0/go.mod h1:ueXOZXYZS2OFQirCU3mHneDwJm5fGKHrtccYBeGEV7M= +github.com/ncruces/go-sqlite3 v0.25.0 h1:trugKUs98Zwy9KwRr/EUxZHL92LYt7UqcKqAfpGpK+I= +github.com/ncruces/go-sqlite3 v0.25.0/go.mod h1:n6Z7036yFilJx04yV0mi5JWaF66rUmXn1It9Ux8dx68= +github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4= +github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= github.com/ncruces/julianday v1.0.0 h1:fH0OKwa7NWvniGQtxdJRxAgkBMolni2BjDHaWTxqt7M= github.com/ncruces/julianday v1.0.0/go.mod h1:Dusn2KvZrrovOMJuOt0TNXL6tB7U2E8kvza5fFc9G7g= github.com/pascaldekloe/goe v0.0.0-20180627143212-57f6aae5913c/go.mod h1:lzWF7FIEvWOWxwDKqyGYQf6ZUaNfKdP144TG7ZOy1lc= @@ -331,6 +347,8 @@ github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZb github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/posener/complete v1.1.1/go.mod h1:em0nMJCgc9GFtwrmVmEMR/ZL6WyhyjMBndrE9hABlRI= github.com/posener/complete v1.2.3/go.mod h1:WZIdtGGp+qx0sLrYKtIRAruyNpv6hFCicSgv7Sy7s/s= +github.com/pressly/goose/v3 v3.24.2 h1:c/ie0Gm8rnIVKvnDQ/scHErv46jrDv9b4I0WRcFJzYU= +github.com/pressly/goose/v3 v3.24.2/go.mod h1:kjefwFB0eR4w30Td2Gj2Mznyw94vSP+2jJYkOVNbD1k= github.com/prometheus/client_golang v0.9.1/go.mod h1:7SWBe2y4D6OKWSNQJUaRYU/AaXPKyh/dDVn+NZz0KFw= github.com/prometheus/client_golang v1.0.0/go.mod h1:db9x61etRT2tGnBNRi70OPL5FsnadC4Ky3P0J6CfImo= github.com/prometheus/client_golang v1.4.0/go.mod h1:e9GMxYsXl05ICDXkRhurwBS4Q3OK1iX/F2sw+iXX5zU= @@ -349,12 +367,18 @@ github.com/prometheus/procfs v0.0.2/go.mod h1:TjEm7ze935MbeOT/UhFTIMYKhuLP4wbCsT github.com/prometheus/procfs v0.0.8/go.mod h1:7Qr8sr6344vo1JqZ6HhLceV9o3AJ1Ff+GxbHq6oeK9A= github.com/prometheus/procfs v0.1.3/go.mod h1:lV6e/gmhEcM9IjHGsFOCxxuZ+z1YqCvr4OA4YeYWdaU= github.com/prometheus/procfs v0.6.0/go.mod h1:cz+aTbrPOrUb4q7XlbU9ygM+/jj0fzG6c1xBZuNvfVA= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= github.com/rogpeppe/fastuuid v1.2.0/go.mod h1:jVj6XXZzXRy/MSR5jhDC/2q6DgLz+nrA6LYCDYWNEvQ= github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4= github.com/rogpeppe/go-internal v1.6.1/go.mod h1:xXDCJY+GAPziupqXw64V24skbSoqbTEfhy4qGm1nDQc= +github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII= +github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o= github.com/ryanuber/columnize v0.0.0-20160712163229-9b3edd62028f/go.mod h1:sm1tb6uqfes/u+d4ooFouqFdy9/2g9QGwK3SQygK0Ts= github.com/sagikazarmark/crypt v0.6.0/go.mod h1:U8+INwJo3nBv1m6A/8OBXAq7Jnpspk5AxSgDyEQcea8= github.com/sean-/seed v0.0.0-20170313163322-e2103e2c3529/go.mod h1:DxrIzT+xaE7yg65j358z/aeFdxmN0P9QXhEzd20vsDc= +github.com/sethvargo/go-retry v0.3.0 h1:EEt31A35QhrcRZtrYFDTBg91cqZVnFL2navjDrah2SE= +github.com/sethvargo/go-retry v0.3.0/go.mod h1:mNX17F0C/HguQMyMyJxcnU471gOZGxCLyYaFyAZraas= github.com/sirupsen/logrus v1.2.0/go.mod h1:LxeOpSwHxABJmUn/MG1IvRgCAasNZTLOkJPxbbu5VWo= github.com/sirupsen/logrus v1.4.2/go.mod h1:tLMulIdttU9McNUspp0xgXVQah82FyeX6MwdIuYE2rE= github.com/sirupsen/logrus v1.6.0/go.mod h1:7uNnSEd1DgxDLC74fIahvMZmmYsHGZGEOFrfsX/uA88= @@ -375,11 +399,12 @@ github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/ github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.5/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= -github.com/stretchr/testify v1.8.0 h1:pSgiaMZlXftHpm5L7V1+rVB+AZJydKsMxsQBIJw4PKk= github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= +github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= +github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= github.com/subosito/gotenv v1.4.1/go.mod h1:ayKnFf/c6rvx/2iiLrJUk1e6plDbT3edrFNGqEflhK0= -github.com/tetratelabs/wazero v1.8.2 h1:yIgLR/b2bN31bjxwXHD8a3d+BogigR952csSDdLYEv4= -github.com/tetratelabs/wazero v1.8.2/go.mod h1:yAI0XTsMBhREkM/YDAK/zNou3GoiAce1P6+rp/wQhjs= +github.com/tetratelabs/wazero v1.9.0 h1:IcZ56OuxrtaEz8UYNRHBrUa9bYeX9oVY93KspZZBf/I= +github.com/tetratelabs/wazero v1.9.0/go.mod h1:TSbcXCfFP0L2FGkRPxHphadXPjo1T6W+CseNNY7EkjM= github.com/tv42/httpunix v0.0.0-20150427012821-b75d8614f926/go.mod h1:9ESjWnEqriFuLhtthL60Sar/7RFoluCcXsuvEwTV5KM= github.com/yuin/goldmark v1.1.25/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= @@ -400,19 +425,23 @@ go.opencensus.io v0.22.5/go.mod h1:5pWMHQbX5EPX2/62yrJeAkowc+lfs/XD7Uxpq3pI6kk= go.opencensus.io v0.23.0/go.mod h1:XItmlyltB5F7CS4xOC1DcqMoFqwtC6OG2xF7mCv7P7E= go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA= go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A= -go.opentelemetry.io/otel v1.34.0 h1:zRLXxLCgL1WyKsPVrgbSdMN4c0FMkDAskSTQP+0hdUY= -go.opentelemetry.io/otel v1.34.0/go.mod h1:OWFPOQ+h4G8xpyjgqo4SxJYdDQ/qmRH+wivy7zzx9oI= +go.opentelemetry.io/otel v1.35.0 h1:xKWKPxrxB6OtMCbmMY021CqC45J+3Onta9MqjhnusiQ= +go.opentelemetry.io/otel v1.35.0/go.mod h1:UEqy8Zp11hpkUrL73gSlELM0DupHoiq72dR+Zqel/+Y= go.opentelemetry.io/otel/metric v1.34.0 h1:+eTR3U0MyfWjRDhmFMxe2SsW64QrZ84AOhvqS7Y+PoQ= go.opentelemetry.io/otel/metric v1.34.0/go.mod h1:CEDrp0fy2D0MvkXE+dPV7cMi8tWZwX3dmaIhwPOaqHE= go.opentelemetry.io/otel/sdk v1.34.0 h1:95zS4k/2GOy069d321O8jWgYsW3MzVV+KuSPKp7Wr1A= go.opentelemetry.io/otel/sdk v1.34.0/go.mod h1:0e/pNiaMAqaykJGKbi+tSjWfNNHMTxoC9qANsCzbyxU= go.opentelemetry.io/otel/sdk/metric v1.34.0 h1:5CeK9ujjbFVL5c1PhLuStg1wxA7vQv7ce1EK0Gyvahk= go.opentelemetry.io/otel/sdk/metric v1.34.0/go.mod h1:jQ/r8Ze28zRKoNRdkjCZxfs6YvBTG1+YIqyFVFYec5w= -go.opentelemetry.io/otel/trace v1.34.0 h1:+ouXS2V8Rd4hp4580a8q23bg0azF2nI8cqLYnC8mh/k= -go.opentelemetry.io/otel/trace v1.34.0/go.mod h1:Svm7lSjQD7kG7KJ/MUHPVXSDGz2OX4h0M2jHBhmSfRE= +go.opentelemetry.io/otel/trace v1.35.0 h1:dPpEfJu1sDIqruz7BHFG3c7528f6ddfSWfFDVt/xgMs= +go.opentelemetry.io/otel/trace v1.35.0/go.mod h1:WUk7DtFp1Aw2MkvqGdwiXYDZZNvA/1J8o6xRXLrIkyc= go.opentelemetry.io/proto/otlp v0.7.0/go.mod h1:PqfVotwruBrMGOCsRd/89rSnXhoiJIqeYNgFYFoEGnI= go.uber.org/atomic v1.7.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc= +go.uber.org/mock v0.5.0 h1:KAMbZvZPyBPWgD14IrIQ38QCyjwpvVVV6K/bHl1IwQU= +go.uber.org/mock v0.5.0/go.mod h1:ge71pBPLYDk7QIi1LupWxdAykm7KIEFchiOqd6z7qMM= go.uber.org/multierr v1.6.0/go.mod h1:cdWPpRnG4AhwMwsgIHip0KRBQjJy5kYEpYjJxpXp9iU= +go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= +go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= go.uber.org/zap v1.17.0/go.mod h1:MXVU+bhUf/A7Xi2HNOnopQOrmycQ5Ih87HtOu4q5SSo= golang.org/x/crypto v0.0.0-20180904163835-0709b304e793/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= @@ -439,6 +468,8 @@ golang.org/x/exp v0.0.0-20191227195350-da58074b4299/go.mod h1:2RIsYlXP63K8oxa1u0 golang.org/x/exp v0.0.0-20200119233911-0405dc783f0a/go.mod h1:2RIsYlXP63K8oxa1u096TMicItID8zy7Y6sNkU49FU4= golang.org/x/exp v0.0.0-20200207192155-f17229e696bd/go.mod h1:J/WKrq2StrnmMY6+EHIKF9dgMWnmCNThgcyBT1FY9mM= golang.org/x/exp v0.0.0-20200224162631-6cc2880d07d6/go.mod h1:3jZMyOhIsHpP37uCMkUooju7aAi5cS1Q23tOzKc+0MU= +golang.org/x/exp v0.0.0-20250305212735-054e65f0b394 h1:nDVHiLt8aIbd/VzvPWN6kSOPE7+F/fNFDSXLVYkE/Iw= +golang.org/x/exp v0.0.0-20250305212735-054e65f0b394/go.mod h1:sIifuuw/Yco/y6yb6+bDNfyeQ/MdPUy/hKEMYQV17cM= golang.org/x/image v0.0.0-20190227222117-0694c2d4d067/go.mod h1:kZ7UVZpmo3dzQBMxlp+ypCbDeSB+sBbTgSJuh5dn5js= golang.org/x/image v0.0.0-20190802002840-cff245a6509b/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0= golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= @@ -523,8 +554,8 @@ golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk= golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44= golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM= golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4= -golang.org/x/net v0.34.0 h1:Mb7Mrk043xzHgnRM88suvJFwzVrRfHEHJEl5/71CKw0= -golang.org/x/net v0.34.0/go.mod h1:di0qlW3YNM5oh6GqDGQr92MyTozJPmybPK4Ev/Gm31k= +golang.org/x/net v0.38.0 h1:vRMAPTMaeGqVhG5QyLJHqNDwecKTomGeqbnfZyKlBI8= +golang.org/x/net v0.38.0/go.mod h1:ivrbrMbzFq5J41QOQh0siUuly180yBYtLp+CKbEaFx8= golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= @@ -563,6 +594,8 @@ golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y= golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +golang.org/x/sync v0.12.0 h1:MHc5BpPuC30uJk597Ri8TV3CNZcTLu6B6z4lJy+g6Jw= +golang.org/x/sync v0.12.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= golang.org/x/sys v0.0.0-20180823144017-11551d06cbcc/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= @@ -648,8 +681,8 @@ golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= -golang.org/x/sys v0.29.0 h1:TPYlXGxvx1MGTn2GiZDhnjPA9wZzZeGKHHmKhHYvgaU= -golang.org/x/sys v0.29.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.31.0 h1:ioabZlmFYtWhL+TRYpcnNlLwhyxaM9kWTDEmfnprqik= +golang.org/x/sys v0.31.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= @@ -673,10 +706,12 @@ golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= -golang.org/x/text v0.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo= golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ= +golang.org/x/text v0.23.0 h1:D71I7dUrlY+VX0gQShAThNGHFxZ13dGLBHQLVl1mJlY= +golang.org/x/text v0.23.0/go.mod h1:/BLNzu4aZCJ1+kcD0DNRotWKage4q2rGVAg4o22unh4= golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= +golang.org/x/time v0.0.0-20191024005414-555d28b269f0 h1:/5xXl8Y5W96D+TtHSlonuFqGHIWVuyCkGJLwGh9JJFs= golang.org/x/time v0.0.0-20191024005414-555d28b269f0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= @@ -866,8 +901,9 @@ google.golang.org/genproto v0.0.0-20220414192740-2d67ff6cf2b4/go.mod h1:8w6bsBMX google.golang.org/genproto v0.0.0-20220421151946-72621c1f0bd3/go.mod h1:8w6bsBMX6yCPbAVTeqQHvzxW0EIFigd5lZyahWgyfDo= google.golang.org/genproto v0.0.0-20220429170224-98d788798c3e/go.mod h1:8w6bsBMX6yCPbAVTeqQHvzxW0EIFigd5lZyahWgyfDo= google.golang.org/genproto v0.0.0-20220505152158-f39f71e6c8f3/go.mod h1:RAyBrSAP7Fh3Nc84ghnVLDPuV51xc9agzmm4Ph6i0Q4= -google.golang.org/genproto v0.0.0-20220519153652-3a47de7e79bd h1:e0TwkXOdbnH/1x5rc5MZ/VYyiZ4v+RdVfrGMqEwT68I= google.golang.org/genproto v0.0.0-20220519153652-3a47de7e79bd/go.mod h1:RAyBrSAP7Fh3Nc84ghnVLDPuV51xc9agzmm4Ph6i0Q4= +google.golang.org/genproto/googleapis/rpc v0.0.0-20250324211829-b45e905df463 h1:e0AIkUUhxyBKh6ssZNrAMeqhA7RKUj42346d1y02i2g= +google.golang.org/genproto/googleapis/rpc v0.0.0-20250324211829-b45e905df463/go.mod h1:qQ0YXyHHx3XkvlzUtpXDkS29lDSafHMZBAZDc03LQ3A= google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c= google.golang.org/grpc v1.20.1/go.mod h1:10oTOabMzJvdu6/UiuZezV6QK5dSlG84ov/aaiqXj38= google.golang.org/grpc v1.21.1/go.mod h1:oYelfM1adQP15Ek0mdvEgi9Df8B9CZIaU1084ijfRaM= @@ -898,8 +934,8 @@ google.golang.org/grpc v1.44.0/go.mod h1:k+4IHHFw41K8+bbowsex27ge2rCb65oeWqe4jJ5 google.golang.org/grpc v1.45.0/go.mod h1:lN7owxKUQEqMfSyQikvvk5tf/6zMPsrK+ONuO11+0rQ= google.golang.org/grpc v1.46.0/go.mod h1:vN9eftEi1UMyUsIF80+uQXhHjbXYbm0uXoFCACuMGWk= google.golang.org/grpc v1.46.2/go.mod h1:vN9eftEi1UMyUsIF80+uQXhHjbXYbm0uXoFCACuMGWk= -google.golang.org/grpc v1.71.0 h1:kF77BGdPTQ4/JZWMlb9VpJ5pa25aqvVqogsxNHHdeBg= -google.golang.org/grpc v1.71.0/go.mod h1:H0GRtasmQOh9LkFoCPDu3ZrwUtD1YGE+b2vYBYd/8Ec= +google.golang.org/grpc v1.71.1 h1:ffsFWr7ygTUscGPI0KKK6TLrGz0476KUvvsbqWK0rPI= +google.golang.org/grpc v1.71.1/go.mod h1:H0GRtasmQOh9LkFoCPDu3ZrwUtD1YGE+b2vYBYd/8Ec= google.golang.org/grpc/cmd/protoc-gen-go-grpc v1.1.0/go.mod h1:6Kw0yEErY5E/yWrBtf03jp27GLLJujG4z/JK95pnjjw= google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8= google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0= @@ -915,11 +951,12 @@ google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp0 google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc= google.golang.org/protobuf v1.27.1/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc= google.golang.org/protobuf v1.28.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I= -google.golang.org/protobuf v1.36.5 h1:tPhr+woSbjfYvY6/GPufUoYizxw1cF/yFoxJ2fmpwlM= -google.golang.org/protobuf v1.36.5/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE= +google.golang.org/protobuf v1.36.6 h1:z1NpPI8ku2WgiWnf+t9wTPsn6eP1L7ksHUlkfLvd9xY= +google.golang.org/protobuf v1.36.6/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY= gopkg.in/alecthomas/kingpin.v2 v2.2.6/go.mod h1:FMv+mEhP44yOT+4EoQTLFTRgOQ1FBLkstjWtayDeSgw= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo= gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI= gopkg.in/ini.v1 v1.67.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k= @@ -944,6 +981,16 @@ honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWh honnef.co/go/tools v0.0.1-2019.2.3/go.mod h1:a3bituU0lyd329TUQxRnasdCoJDkEUEAqEt0JzvZhAg= honnef.co/go/tools v0.0.1-2020.1.3/go.mod h1:X/FiERA/W4tHapMX5mGpAtMSVEeEUOyHaw9vFzvIQ3k= honnef.co/go/tools v0.0.1-2020.1.4/go.mod h1:X/FiERA/W4tHapMX5mGpAtMSVEeEUOyHaw9vFzvIQ3k= +modernc.org/libc v1.61.13 h1:3LRd6ZO1ezsFiX1y+bHd1ipyEHIJKvuprv0sLTBwLW8= +modernc.org/libc v1.61.13/go.mod h1:8F/uJWL/3nNil0Lgt1Dpz+GgkApWh04N3el3hxJcA6E= +modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU= +modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg= +modernc.org/memory v1.9.1 h1:V/Z1solwAVmMW1yttq3nDdZPJqV1rM05Ccq6KMSZ34g= +modernc.org/memory v1.9.1/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw= +modernc.org/sqlite v1.36.2 h1:vjcSazuoFve9Wm0IVNHgmJECoOXLZM1KfMXbcX2axHA= +modernc.org/sqlite v1.36.2/go.mod h1:ADySlx7K4FdY5MaJcEv86hTJ0PjedAloTUuif0YS3ws= +olympos.io/encoding/edn v0.0.0-20201019073823-d3554ca0b0a3 h1:slmdOY3vp8a7KQbHkL+FLbvbkgMqmXojpFUO/jENuqQ= +olympos.io/encoding/edn v0.0.0-20201019073823-d3554ca0b0a3/go.mod h1:oVgVk4OWVDi43qWBEyGhXgYxt7+ED4iYNpTngSLX2Iw= rsc.io/binaryregexp v0.2.0/go.mod h1:qTv7/COck+e2FymRvadv62gMdZztPaShugOCi3I+8D8= rsc.io/quote/v3 v3.1.0/go.mod h1:yEA65RcK8LyAZtP9Kv3t0HmxON59tX3rD+tICJqUlj0= rsc.io/sampler v1.3.0/go.mod h1:T1hPZKmBbMNahiBKFy5HrXp6adAjACjK9JXDnKaTXpA= diff --git a/internal/app/app.go b/internal/app/app.go new file mode 100644 index 0000000..277b23e --- /dev/null +++ b/internal/app/app.go @@ -0,0 +1,41 @@ +package app + +import ( + "algobot/internal/app/scheduler" + "algobot/internal/app/telegram" + "algobot/internal/config" + "algobot/internal/lib/backoffice" + backoffice2 "algobot/internal/services/backoffice" + "algobot/internal/storage/sqlite" + "log/slog" +) + +type App struct { + log *slog.Logger + cfg *config.Config + TelegramBot *telegram.App + Scheduler *scheduler.App +} + +func New(log *slog.Logger, cfg *config.Config) *App { + + storage, err := sqlite.NewDB(cfg) + if err != nil { + panic(err) + } + + bo := backoffice.NewBackoffice(&cfg.Backoffice) + boSvc := backoffice2.NewBackoffice(log, storage, bo, bo, bo, bo) + + sch := scheduler.New(log, cfg, storage, boSvc) + botApplication := telegram.New( + log, + cfg, + storage, + bo, + boSvc, + sch.Chan(), + ) + + return &App{log: log, cfg: cfg, TelegramBot: botApplication, Scheduler: sch} +} diff --git a/internal/app/scheduler/app.go b/internal/app/scheduler/app.go new file mode 100644 index 0000000..701fcda --- /dev/null +++ b/internal/app/scheduler/app.go @@ -0,0 +1,102 @@ +package scheduler + +import ( + "algobot/internal/config" + "algobot/internal/domain/models" + "algobot/internal/domain/scheduler" + "algobot/internal/lib/logger/sl" + "log/slog" + "time" +) + +type Domain interface { + UsersByNotification(wantNotif int) ([]models.User, error) + ChaneNotifDate(uid int64, lastnotif string) error +} +type Backoffice interface { + MessagesUser(uid int64, lastTime string) ([]scheduler.Message, error) +} + +type App struct { + ch chan scheduler.Message + cfg *config.Config + ticker *time.Ticker + log *slog.Logger + domain Domain + bo Backoffice +} + +func New(log *slog.Logger, cfg *config.Config, domain Domain, bo Backoffice) *App { + + return &App{ + log: log, + cfg: cfg, + bo: bo, + domain: domain, + ch: make(chan scheduler.Message), + } +} + +func (a *App) Run() { + const op = "scheduler.Run" + log := a.log.With( + slog.String("op", op), + ) + log.Info("start scheduling") + + ticker := time.NewTicker(a.cfg.Backoffice.MessageTimer) + a.ticker = ticker + + go func() { + for { + select { + case <-ticker.C: + a.GetMessage() + } + } + }() +} + +func (a *App) GetMessage() { + // TODO: maybe refactor into other struct + const op = "scheduler.app.GetMessage" + log := a.log.With( + slog.String("op", op), + ) + users, err := a.domain.UsersByNotification(1) + if err != nil { + log.Warn("error while get users by notif", sl.Err(err)) + } + for _, user := range users { + msgs, err := a.bo.MessagesUser(user.Uid, user.LastNotification) + if err != nil { + log.Warn("error while fetch MessagesUser", sl.Err(err)) + continue + } + for _, msg := range msgs { + a.ch <- msg + err := a.domain.ChaneNotifDate(msg.To, msg.Time) + if err != nil { + log.Warn("error while fetch ChaneNotifDate", sl.Err(err)) + } + } + } + +} + +func (a *App) Stop() { + const op = "scheduler.Stop" + log := a.log.With( + slog.String("op", op), + ) + log.Info("stop scheduling") + + if a.ticker != nil { + a.ticker.Stop() + } + close(a.ch) +} + +func (a *App) Chan() chan scheduler.Message { + return a.ch +} diff --git a/internal/app/telegram/app.go b/internal/app/telegram/app.go new file mode 100644 index 0000000..9a028e1 --- /dev/null +++ b/internal/app/telegram/app.go @@ -0,0 +1,138 @@ +package telegram + +import ( + "algobot/internal/config" + "algobot/internal/domain/scheduler" + backoffice3 "algobot/internal/lib/backoffice" + "algobot/internal/lib/fsm" + "algobot/internal/lib/fsm/memory" + "algobot/internal/lib/logger/sl" + "algobot/internal/lib/serdes/base62" + "algobot/internal/services/backoffice" + "algobot/internal/services/groups" + grpc2 "algobot/internal/services/grpc" + "algobot/internal/services/schedule" + "algobot/internal/storage/sqlite" + "algobot/internal/telegram/handlers/callback" + "algobot/internal/telegram/handlers/text" + "algobot/internal/telegram/middleware/auth" + "algobot/internal/telegram/middleware/logger" + "algobot/internal/telegram/middleware/rate" + "algobot/internal/telegram/middleware/stater" + "algobot/internal/telegram/middleware/trace" + "fmt" + router "github.com/LZTD1/telebot-context-router" + tele "gopkg.in/telebot.v4" + "gopkg.in/telebot.v4/middleware" + "log/slog" + "os" + "regexp" + "time" +) + +type App struct { + log *slog.Logger + bot *tele.Bot +} + +func New(log *slog.Logger, cfg *config.Config, storage *sqlite.Sqlite, bo *backoffice3.Backoffice, boSvc *backoffice.Backoffice, messages chan scheduler.Message) *App { + const op = "telegram.New" + + nlog := log.With( + slog.String("op", op), + ) + + pref := tele.Settings{ + Token: cfg.TelegramToken, + Poller: &tele.LongPoller{ + Timeout: 10 * time.Second, + }, + OnError: func(e error, c tele.Context) { // TODO : refactor into handler + traceID := c.Get("trace_id") + c.Send(fmt.Sprintf("[%s]\n\nУпс! Произошла какая-то непредвиденная ошибка!\nОбратитесь к администратору", traceID), tele.ModeHTML) + log.Warn("cant handle error", sl.Err(e), slog.Any("trace_id", traceID)) + }, + } + b, err := tele.NewBot(pref) + if err != nil { + nlog.Warn("error by creating telegram bot: ", sl.Err(err)) + os.Exit(1) + } + + // dependencies + groupServ := groups.NewGroup(log, storage, bo, storage, bo) + stateMachine := memory.New() + serdes := base62.NewSerdes(log) + grpc := grpc2.NewAIService( + cfg.GRPC, + grpc2.WithLogger(log), + ) + sch := schedule.NewSchedule(messages, b) + go sch.Process() + + // initialize routes + b.Use(trace.New(log)) + b.Use(middleware.AutoRespond()) + b.Use(middleware.Recover()) + b.Use(logger.New(log)) + b.Use(auth.New(storage, log)) + b.Use(rate.New(log, cfg.RateLimit)) + + // create routing + r := router.NewRouter() + r.Group(func(r router.Router) { // Routes for default state + r.Use(stater.New(stateMachine, fsm.Default)) + + // message + r.HandleFuncText("/start", text.NewStart(stateMachine)) + r.HandleFuncText("Настройки", text.NewSettings(storage, log)) + r.HandleFuncText("AI 🔹", text.NewAI(grpc, log, stateMachine)) + r.HandleText("Мои группы", text.NewMyGroup(log, groupServ, serdes, b.Me.Username)) + r.HandleFuncText("Получить отсутсвующих", text.NewMissingKids(log, groupServ)) + r.HandleFuncRegexpText(regexp.MustCompile(`^(?m)\/abs(.*)$`), text.NewAbsentKids(groupServ, log)) + + r.HandleRegexpText(regexp.MustCompile(`^(?m)\/start\s(.+)$`), text.NewViewInformer(serdes, boSvc, log, b.Me.Username)) + + // callbacks + r.HandleFuncCallback("\fset_cookie", callback.NewChangeCookie(stateMachine)) + r.HandleFuncCallback("\fchange_notification", callback.NewChangeNotification(storage, log)) + r.HandleFuncCallback("\frefresh_groups", callback.RefreshGroup(groupServ, log)) + + r.HandleFuncRegexpCallback(regexp.MustCompile(`^\fget_creds_(.+)$`), callback.GetCreds(boSvc, log)) + r.HandleFuncRegexpCallback(regexp.MustCompile(`^\fclose_lesson_(.+)$`), callback.LessonStatus(boSvc, backoffice.CloseLesson, log)) + r.HandleFuncRegexpCallback(regexp.MustCompile(`^\fopen_lesson_(.+)$`), callback.LessonStatus(boSvc, backoffice.OpenLesson, log)) + }) + + r.Group(func(r router.Router) { // Routes for SendingCookie state + r.Use(stater.New(stateMachine, fsm.SendingCookie)) + + // message + r.HandleFuncText("⬅️ Назад", text.NewStart(stateMachine)) + r.HandleFuncRegexpText(regexp.MustCompile(".+"), text.NewSendingCookie(log, storage, stateMachine)) + }) + + r.Group(func(r router.Router) { // Routes for ChattingAI state + r.Use(stater.New(stateMachine, fsm.ChattingAI)) + + // message + r.HandleFuncText("⬅️ Назад", text.NewStart(stateMachine)) + r.HandleFuncText("/reset", text.NewReset(grpc, log)) + r.HandleFuncRegexpText(regexp.MustCompile(`^(?m)\/image\s(.+)$`), text.GenerateImage(grpc, log)) + r.HandleFuncRegexpText(regexp.MustCompile(`^[^/].*$`), text.ChatAI(grpc, log)) + }) + + r.NotFound(text.NewStart(stateMachine)) + + b.Handle(tele.OnText, r.ServeContext) + b.Handle(tele.OnCallback, r.ServeContext) + + return &App{log: log, bot: b} +} + +func (a *App) Run() { + a.bot.Start() +} + +func (a *App) Stop() { + a.bot.Stop() +} diff --git a/internal/clients/backoffice.go b/internal/clients/backoffice.go deleted file mode 100644 index 45f9521..0000000 --- a/internal/clients/backoffice.go +++ /dev/null @@ -1,305 +0,0 @@ -package clients - -import ( - "encoding/json" - "errors" - "fmt" - "github.com/PuerkitoBio/goquery" - "io" - "net/http" - "net/url" - "strconv" - "strings" - appError "tgbot/internal/error" - "time" -) - -const defaultBackofficeUrl = "https://backoffice.algoritmika.org" - -type Backoffice struct { - url string - settings BackofficeSetting -} -type BackofficeSetting struct { - Retry int - Timeout time.Duration - RetryTimeout time.Duration -} - -func NewBackoffice(url string, settings BackofficeSetting) *Backoffice { - if strings.TrimSpace(url) == "" { - url = defaultBackofficeUrl - } - return &Backoffice{url: url, settings: settings} -} - -func (b Backoffice) GetKidInfo(cookie string, kidID string) (*FullKidInfo, error) { - req, err := b.createReq("GET", "/api/v2/student/default/view/"+kidID, cookie, map[string]string{ - "expand": "groups", - }, nil) - - if err != nil { - return nil, fmt.Errorf("Backoffice.GetKidInfo(%s, %s) : %w", cookie, kidID, err) - } - data, err := b.doReq(req) - if err != nil { - return nil, fmt.Errorf("Backoffice.GetKidInfo(%s, %s) : %w", cookie, kidID, err) - } - - var response FullKidInfo - err = json.NewDecoder(data.Body).Decode(&response) - if err != nil { - return nil, fmt.Errorf("Backoffice.GetKidInfo(%s, %s) : %w", cookie, kidID, err) - } - - return &response, nil -} - -func (b Backoffice) GetGroupInfo(cookie string, group string) (*FullGroupInfo, error) { - req, err := b.createReq("GET", "/api/v1/group/"+group, cookie, map[string]string{ - "expand": "venue,teacher,curator,branch", - }, nil) - if err != nil { - return nil, fmt.Errorf("Backoffice.GetGroupInfo(%s, %s) : %w", cookie, group, err) - } - data, err := b.doReq(req) - if err != nil { - return nil, fmt.Errorf("Backoffice.GetGroupInfo(%s, %s) : %w", cookie, group, err) - } - - var response FullGroupInfo - err = json.NewDecoder(data.Body).Decode(&response) - if err != nil { - return nil, fmt.Errorf("Backoffice.GetGroupInfo(%s, %s) : %w", cookie, group, err) - } - - return &response, nil -} - -func (b Backoffice) GetKidsNamesByGroup(cookie string, group int) (*GroupResponse, error) { - - req, err := b.createReq("GET", "/api/v2/group/student/index", cookie, map[string]string{ - "groupId": strconv.Itoa(group), - "expand": "lastGroup, groups", - }, nil) - if err != nil { - return nil, fmt.Errorf("Backoffice.GetKidsNamesByGroup(%s, %s) : %w", cookie, group, err) - } - - data, reqErr := b.doReq(req) - if reqErr != nil { - return nil, fmt.Errorf("Backoffice.GetKidsNamesByGroup(%s, %s) : %w", cookie, group, reqErr) - } - - var response GroupResponse - err = json.NewDecoder(data.Body).Decode(&response) - if err != nil { - return nil, fmt.Errorf("Backoffice.GetKidsNamesByGroup(%s, %s) : %w", cookie, group, err) - } - - return &response, nil -} - -func (b Backoffice) GetKidsStatsByGroup(cookie, group string) (*KidsStats, error) { - req, err := b.createReq("GET", "/api/v1/stats/default/attendance", cookie, map[string]string{ - "group": group, - }, nil) - if err != nil { - return nil, fmt.Errorf("Backoffice.GetKidsStatsByGroup(%s, %s) : %w", cookie, group, err) - } - - data, reqErr := b.doReq(req) - if reqErr != nil { - return nil, fmt.Errorf("Backoffice.GetKidsStatsByGroup(%s, %s) : %w", cookie, group, reqErr) - } - - var response KidsStats - err = json.NewDecoder(data.Body).Decode(&response) - if err != nil { - return nil, fmt.Errorf("Backoffice.GetKidsStatsByGroup(%s, %s) : %w", cookie, group, err) - } - - return &response, nil -} - -func (b Backoffice) GetKidsMessages(cookie string) (*KidsMessages, error) { - req, err := b.createReq("GET", "/api/v1/teacherComment/projects", cookie, map[string]string{ - "from": "0", - "limit": "30", - }, nil) - if err != nil { - return nil, fmt.Errorf("Backoffice.GetKidsMessages(%s) : %w", cookie, err) - } - - data, reqErr := b.doReq(req) - if reqErr != nil { - return nil, fmt.Errorf("Backoffice.GetKidsMessages(%s) : %w", cookie, reqErr) - } - - var response KidsMessages - err = json.NewDecoder(data.Body).Decode(&response) - if err != nil { - return nil, fmt.Errorf("Backoffice.GetKidsMessages(%s) : %w", cookie, err) - } - - return &response, nil -} - -func (b Backoffice) GetAllGroupsByUser(cookie string) ([]AllGroupsUser, error) { - req, err := b.createReq("GET", "/group", cookie, map[string]string{ - "GroupSearch[status][]": "active", - "presetType": "all", - "_pjax": "#group-grid-pjax", - }, nil) - if err != nil { - return nil, fmt.Errorf("Backoffice.GetAllGroupsByUser(%s) : %w", cookie, err) - } - data, reqErr := b.doReq(req) - if reqErr != nil { - return nil, fmt.Errorf("Backoffice.GetAllGroupsByUser(%s) : %w", cookie, reqErr) - } - - res, err := parsedHtml(data.Body) - if err != nil { - return nil, fmt.Errorf("Backoffice.GetAllGroupsByUser(%s) : %w", cookie, err) - } - return res, nil -} - -func (b Backoffice) OpenLession(cookie, group, lession string) error { - params := url.Values{} - params.Add("ajaxUrl", "/api/v2/group/lesson/status") - params.Add("btnClass", "btn btn-xs btn-danger") - params.Add("status", "10") - params.Add("lessonId", lession) - params.Add("groupId", group) - query := params.Encode() - - req, err := b.createReq("POST", "/api/v2/group/lesson/status", cookie, map[string]string{}, strings.NewReader(query)) - if err != nil { - return fmt.Errorf("Backoffice.OpenLession(%s, %s, %s) : %w", cookie, group, lession, err) - } - _, reqErr := b.doReq(req) - if reqErr != nil { - return fmt.Errorf("Backoffice.OpenLession(%s, %s, %s) : %w", cookie, group, lession, reqErr) - } - - return nil -} - -func (b Backoffice) CloseLession(cookie, group, lession string) error { - params := url.Values{} - params.Add("ajaxUrl", "/api/v2/group/lesson/status") - params.Add("btnClass", "btn btn-xs btn-danger") - params.Add("status", "0") - params.Add("lessonId", lession) - params.Add("groupId", group) - query := params.Encode() - - req, err := b.createReq("POST", "/api/v2/group/lesson/status", cookie, map[string]string{}, strings.NewReader(query)) - if err != nil { - return fmt.Errorf("Backoffice.CloseLession(%s, %s, %s) : %w", cookie, group, lession, err) - } - - _, reqErr := b.doReq(req) - if reqErr != nil { - return fmt.Errorf("Backoffice.CloseLession(%s, %s, %s) : %w", cookie, group, lession, reqErr) - } - - return nil -} - -func parsedHtml(body io.ReadCloser) ([]AllGroupsUser, error) { - doc, err := goquery.NewDocumentFromReader(body) - if err != nil { - return nil, fmt.Errorf("Backoffice.parsedHtml() : %w", err) - } - var groups []AllGroupsUser - - doc.Find("tr.group-grid").Each(func(i int, row *goquery.Selection) { - groupId := row.Find("td[data-col-seq='id']").First().Text() - groupId = strings.TrimSpace(groupId) - - titleCell := row.Find("td[data-col-seq='title']").First() - - groupTitle := titleCell.Find("p").First().Text() - groupTitle = strings.TrimSpace(groupTitle) - - groupTime := titleCell.Find("a").First().Text() - groupTime = strings.TrimSpace(groupTime) - - nextLessonTime := row.Find("td[data-col-seq='nextLessonTime']").First().Text() - nextLessonTime = strings.TrimSpace(nextLessonTime) - - if nextLessonTime != "" { - groups = append(groups, AllGroupsUser{ - Title: strings.ReplaceAll(groupTitle, "\u00A0", " "), - GroupId: strings.ReplaceAll(groupId, "\u00A0", " "), - TimeLesson: strings.ReplaceAll(nextLessonTime, "\u00A0", " "), - RegularTime: strings.ReplaceAll(groupTime, "\u00A0", " "), - }) - } - }) - - return groups, nil -} -func (b Backoffice) doReq(req *http.Request) (*http.Response, error) { - client := &http.Client{ - Timeout: b.settings.Timeout, - } - - var returnErr error - - for i := 0; i < b.settings.Retry; i++ { - resp, err := client.Do(req) - if err != nil { - returnErr = fmt.Errorf("Backoffice.doReq() : %w", err) - time.Sleep(b.settings.RetryTimeout) - continue - } - if resp.StatusCode >= 500 { - returnErr = fmt.Errorf("Backoffice.doReq() : %w", errors.New(resp.Status+" "+getString(resp.Body))) - time.Sleep(b.settings.RetryTimeout) - continue - } - if resp.StatusCode >= 400 && resp.StatusCode < 500 { - return nil, fmt.Errorf("Backoffice.doReq() : %w : %w", appError.ErrNotFound, getErrorByCode(resp.Status, resp.Body)) - } - if resp.StatusCode >= 200 && resp.StatusCode < 300 { - return resp, nil - } - } - return nil, returnErr -} - -func getErrorByCode(status string, body io.Reader) error { - return errors.New(status + " " + getString(body)) -} - -func getString(body io.Reader) string { - all, err := io.ReadAll(body) - if err != nil { - return "" - } - return string(all) -} -func (b Backoffice) createReq(method, uri, cookie string, params map[string]string, body io.Reader) (*http.Request, error) { - reqUrl, _ := url.Parse(fmt.Sprintf("%s%s", b.url, uri)) - p := url.Values{} - for key, val := range params { - p.Add(key, val) - } - reqUrl.RawQuery = p.Encode() - req, err := http.NewRequest(method, reqUrl.String(), body) - - if err != nil { - return nil, fmt.Errorf("Backoffice.createReq() : %w", err) - } - req.Header.Add("Cookie", cookie) - - if method == "POST" { - req.Header.Set("Content-Type", "application/x-www-form-urlencoded; charset=UTF-8") - } - - return req, nil -} diff --git a/internal/config/config.go b/internal/config/config.go new file mode 100644 index 0000000..3d10424 --- /dev/null +++ b/internal/config/config.go @@ -0,0 +1,59 @@ +package config + +import ( + "flag" + "github.com/ilyakaznacheev/cleanenv" + "os" + "time" +) + +type Config struct { + Env string `yaml:"env" env-default:"prod"` + TelegramToken string `yaml:"telegram_token" env:"TELEGRAM_TOKEN" env-required:"true"` + MigrationsPath string `yaml:"migrations_path" env-default:"./migrations"` + StoragePath string `yaml:"storage_path" env-default:"./storage/storage.db"` + GRPC GRPC `yaml:"grpc"` + RateLimit RateLimit `yaml:"rate_limit"` + Backoffice Backoffice `yaml:"backoffice"` +} + +type Backoffice struct { + Retries int `yaml:"retries" env-default:"3"` + MessageTimer time.Duration `yaml:"message_timer" env-default:"5m"` + RetriesTimeout time.Duration `yaml:"retries_timeout" env-default:"5s"` + ResponseTimeout time.Duration `yaml:"response_timeout" env-default:"15s"` +} + +type RateLimit struct { + FillPeriod time.Duration `yaml:"fill_period" env-default:"1s"` + BucketLimit int `yaml:"bucket_limit" env-default:"10"` +} + +type GRPC struct { + Host string `yaml:"host" env-default:"localhost"` + Port string `yaml:"port" env-default:"50051"` + Timeout time.Duration `yaml:"timeout" env-default:"600s"` +} + +func MustLoad() *Config { + var configPath string + flag.StringVar(&configPath, "config", "", "path to config file") + flag.Parse() + + if configPath == "" { + configPath = os.Getenv("CONFIG_PATH") + } + if configPath == "" { + panic("config file path is empty") + } + + if _, err := os.Stat(configPath); os.IsNotExist(err) { + panic("config file does not exist : " + err.Error()) + } + var cfg Config + if err := cleanenv.ReadConfig(configPath, &cfg); err != nil { + panic("can't read config : " + err.Error()) + } + + return &cfg +} diff --git a/internal/config/keyboards.go b/internal/config/keyboards.go deleted file mode 100644 index 255a9db..0000000 --- a/internal/config/keyboards.go +++ /dev/null @@ -1,50 +0,0 @@ -package config - -import tele "gopkg.in/telebot.v4" - -var ( - StartKeyboard = &tele.ReplyMarkup{ResizeKeyboard: true} - MissingBtn = StartKeyboard.Text("Получить отсутсвующих") - MyGroupsBtn = StartKeyboard.Text("Мои группы") - SettingsBtn = StartKeyboard.Text("Настройки") - AIBtn = StartKeyboard.Text("AI 🔹") - - MyGroupsKeyboard = &tele.ReplyMarkup{ResizeKeyboard: true} - refreshGroupsBtn = MyGroupsKeyboard.Data("Обновить группы", "refresh_groups") - - SettingsKeyboard = &tele.ReplyMarkup{ResizeKeyboard: true} - SetCookieBtn = SettingsKeyboard.Data("Установить Cookie", "set_cookie") - ChangeNotificationBtn = SettingsKeyboard.Data("Переключить уведомления", "change_notification") - - RejectKeyboard = &tele.ReplyMarkup{ResizeKeyboard: true} - RejectActionBtn = RejectKeyboard.Text("Отменить действие") - - AIKeyboard = &tele.ReplyMarkup{ResizeKeyboard: true} - BackBtn = StartKeyboard.Text("⬅️ Назад") - ClearHistoryBtn = StartKeyboard.Text("Отчистить чат") -) - -func init() { - StartKeyboard.Reply( - StartKeyboard.Row(MissingBtn), - StartKeyboard.Row(MyGroupsBtn, SettingsBtn), - StartKeyboard.Row(AIBtn), - ) - - MyGroupsKeyboard.Inline( - MyGroupsKeyboard.Row(refreshGroupsBtn), - ) - - SettingsKeyboard.Inline( - SettingsKeyboard.Row(SetCookieBtn), - SettingsKeyboard.Row(ChangeNotificationBtn), - ) - - RejectKeyboard.Reply( - RejectKeyboard.Row(RejectActionBtn), - ) - - AIKeyboard.Reply( - RejectKeyboard.Row(BackBtn, ClearHistoryBtn), - ) -} diff --git a/internal/config/texts.go b/internal/config/texts.go deleted file mode 100644 index 38e6a14..0000000 --- a/internal/config/texts.go +++ /dev/null @@ -1,34 +0,0 @@ -package config - -const SetParam = "✅" -const NotSetParam = "❌" - -const HelloWorld = "Здравствуйте, вы успешно зарегистрировались в боте, для продолжения, воспользуйтесь кнопками на клавиатуре" -const StartText = "Открыто главное меню:" -const Incorrect = "Упс, такие команды я не понимаю" -const Settings = "🔧 Ваши настройки:" -const Cookie = "Куки:" -const ChatNotifications = "Уведомление от чата:" - -const CookieNotSetException = "Сперва вам необходимо установить куки!" - -const GroupName = "Группа по курсу: " -const Lection = "Лекция: " -const TotalKids = "Общее число детей: " -const MissingKids = "Отсутствуют: " - -const MyGroups = "Всего групп: " -const UserDontHaveGroup = "У вас нету никаких групп!" -const CurrentGroupDontFind = "В данный момент, никакой группы не найдено!\nПопробуйте обновить свои группы" - -const SendingCookie = "Отправьте мне свои cookie 🍪\nИнструкция: https://telegra.ph/Kak-dobavit-v-bota-svoi-Cookie-02-05" -const CookieSet = "Куки успешно установлены" - -const UpdateStarted = "Происходит процесс обновления ..." -const UpdateEnd = "Успешно обновлено!" - -const OpenLessonBtn = "Открыть лекцию" -const CloseLessonBtn = "Закрыть лекцию" -const GetCredsBtn = "Получить аккаунты" - -const SuccessfulChangeStatus = "Статус лекции успешно изменен" diff --git a/internal/contextHandlers/callbackHandlers/changeNotification.go b/internal/contextHandlers/callbackHandlers/changeNotification.go deleted file mode 100644 index 0a142e4..0000000 --- a/internal/contextHandlers/callbackHandlers/changeNotification.go +++ /dev/null @@ -1,39 +0,0 @@ -package callbackHandlers - -import ( - "gopkg.in/telebot.v4" - "tgbot/internal/contextHandlers/defaultHandler" - "tgbot/internal/contextHandlers/textHandlers/defaultState" - "tgbot/internal/helpers" - "tgbot/internal/service" -) - -type ChangeNotification struct { - svc service.Service - settings defaultHandler.ContextHandler -} - -func NewChangeNotification(svc service.Service) *ChangeNotification { - return &ChangeNotification{svc: svc, settings: defaultState.NewSettings(svc)} -} - -func (c ChangeNotification) CanHandle(ctx telebot.Context) bool { - if ctx.Callback().Data == "change_notification" { - return true - } - return false -} - -func (c ChangeNotification) Process(ctx telebot.Context) error { - uid := ctx.Callback().Sender.ID - notify, err := c.svc.Notification(uid) - if err != nil { - return helpers.LogError(err, ctx, "Ошибка при получении нотификаций!") - } - err = c.svc.SetNotification(uid, !notify) - if err != nil { - return helpers.LogError(err, ctx, "Ошибка при установлении нотификаций!") - } - - return ctx.Edit("Настройки уведомлений были изменены!") -} diff --git a/internal/contextHandlers/callbackHandlers/closeLesson.go b/internal/contextHandlers/callbackHandlers/closeLesson.go deleted file mode 100644 index 21a70f2..0000000 --- a/internal/contextHandlers/callbackHandlers/closeLesson.go +++ /dev/null @@ -1,48 +0,0 @@ -package callbackHandlers - -import ( - "fmt" - "gopkg.in/telebot.v4" - "strconv" - "strings" - "tgbot/internal/config" - appError "tgbot/internal/error" - "tgbot/internal/helpers" - "tgbot/internal/service" -) - -type CloseLesson struct { - s service.Service -} - -func NewCloseLesson(s service.Service) *CloseLesson { - return &CloseLesson{s: s} -} - -func (c CloseLesson) CanHandle(ctx telebot.Context) bool { - if strings.HasPrefix(ctx.Callback().Data, "close_lesson_") { - return true - } - return false -} - -func (c CloseLesson) Process(ctx telebot.Context) error { - data := strings.Split(strings.TrimPrefix(ctx.Callback().Data, "close_lesson_"), "_") - if len(data) != 2 { - return helpers.LogError(fmt.Errorf("%s : %w", ctx.Callback().Data, appError.NotEnoughArgs), ctx, "(1) Ошибка при анализе данных от кнопки!") - } - groupID, err := strconv.Atoi(data[0]) - if err != nil { - return helpers.LogError(err, ctx, "(2) Ошибка при анализе данных от кнопки!") - } - lessionID, err := strconv.Atoi(data[1]) - if err != nil { - return helpers.LogError(err, ctx, "(3) Ошибка при анализе данных от кнопки!") - } - err = c.s.CloseLesson(ctx.Callback().Sender.ID, groupID, lessionID) - if err != nil { - return helpers.LogError(err, ctx, "Ошибка при закрытии лекции") - } - - return ctx.Send(config.SuccessfulChangeStatus) -} diff --git a/internal/contextHandlers/callbackHandlers/getCredentials.go b/internal/contextHandlers/callbackHandlers/getCredentials.go deleted file mode 100644 index cf909fa..0000000 --- a/internal/contextHandlers/callbackHandlers/getCredentials.go +++ /dev/null @@ -1,50 +0,0 @@ -package callbackHandlers - -import ( - "fmt" - "gopkg.in/telebot.v4" - "strconv" - "strings" - "tgbot/internal/helpers" - "tgbot/internal/service" -) - -type GetCredentials struct { - s service.Service -} - -func NewGetCredentials(s service.Service) *GetCredentials { - return &GetCredentials{s: s} -} - -func (c GetCredentials) CanHandle(ctx telebot.Context) bool { - if strings.HasPrefix(ctx.Callback().Data, "get_creds_") { - return true - } - return false -} - -func (c GetCredentials) Process(ctx telebot.Context) error { - data := strings.TrimPrefix(ctx.Callback().Data, "get_creds_") - - groupID, err := strconv.Atoi(data) - if err != nil { - return helpers.LogError(err, ctx, "(1) Ошибка при анализе данных от кнопки!") - } - - creds, err := c.s.AllCredentials(ctx.Callback().Sender.ID, groupID) - if err != nil { - return helpers.LogError(err, ctx, "(2) Ошибка при анализе данных от кнопки!") - } - - return ctx.Send(getCreds(creds)) -} - -func getCreds(creds map[string]string) string { - sb := strings.Builder{} - sb.WriteString("Учетные записи детей:\n") - for key, val := range creds { - sb.WriteString(fmt.Sprintf("\n%v [%v]", key, val)) - } - return sb.String() -} diff --git a/internal/contextHandlers/callbackHandlers/openLesson.go b/internal/contextHandlers/callbackHandlers/openLesson.go deleted file mode 100644 index d366db1..0000000 --- a/internal/contextHandlers/callbackHandlers/openLesson.go +++ /dev/null @@ -1,48 +0,0 @@ -package callbackHandlers - -import ( - "fmt" - "gopkg.in/telebot.v4" - "strconv" - "strings" - "tgbot/internal/config" - appError "tgbot/internal/error" - "tgbot/internal/helpers" - "tgbot/internal/service" -) - -type OpenLesson struct { - s service.Service -} - -func NewOpenLesson(s service.Service) *OpenLesson { - return &OpenLesson{s: s} -} - -func (c OpenLesson) CanHandle(ctx telebot.Context) bool { - if strings.HasPrefix(ctx.Callback().Data, "open_lesson_") { - return true - } - return false -} - -func (c OpenLesson) Process(ctx telebot.Context) error { - data := strings.Split(strings.TrimPrefix(ctx.Callback().Data, "open_lesson_"), "_") - if len(data) != 2 { - return helpers.LogError(fmt.Errorf("%s : %w", ctx.Callback().Data, appError.NotEnoughArgs), ctx, "(1) Ошибка при анализе данных от кнопки!") - } - groupID, err := strconv.Atoi(data[0]) - if err != nil { - return helpers.LogError(err, ctx, "(2) Ошибка при анализе данных от кнопки!") - } - lessionID, err := strconv.Atoi(data[1]) - if err != nil { - return helpers.LogError(err, ctx, "(3) Ошибка при анализе данных от кнопки!") - } - err = c.s.OpenLesson(ctx.Callback().Sender.ID, groupID, lessionID) - if err != nil { - return helpers.LogError(err, ctx, "Ошибка при открытии лекции") - } - - return ctx.Send(config.SuccessfulChangeStatus) -} diff --git a/internal/contextHandlers/callbackHandlers/refreshGroups.go b/internal/contextHandlers/callbackHandlers/refreshGroups.go deleted file mode 100644 index 4eea2b8..0000000 --- a/internal/contextHandlers/callbackHandlers/refreshGroups.go +++ /dev/null @@ -1,42 +0,0 @@ -package callbackHandlers - -import ( - "errors" - "gopkg.in/telebot.v4" - "tgbot/internal/config" - appError "tgbot/internal/error" - "tgbot/internal/helpers" - "tgbot/internal/service" -) - -type RefreshGroups struct { - svc service.Service -} - -func NewRefreshGroups(svc service.Service) *RefreshGroups { - return &RefreshGroups{svc: svc} -} - -func (r RefreshGroups) CanHandle(ctx telebot.Context) bool { - if ctx.Callback().Data == "refresh_groups" { - return true - } - return false -} - -func (r RefreshGroups) Process(ctx telebot.Context) error { - err := ctx.Edit(config.UpdateStarted) - if err != nil { - return err - } - - err = r.svc.RefreshGroups(ctx.Sender().ID) - if err != nil { - if errors.Is(err, appError.ErrHasNone) { - return ctx.Edit("Я не смог найти ни одной группы, может быть дело в cookie?") - } - return helpers.LogError(err, ctx, "Ошибка при обновлении групп!") - } - - return ctx.Edit(config.UpdateEnd) -} diff --git a/internal/contextHandlers/callbackHandlers/setCookie.go b/internal/contextHandlers/callbackHandlers/setCookie.go deleted file mode 100644 index b2a01b8..0000000 --- a/internal/contextHandlers/callbackHandlers/setCookie.go +++ /dev/null @@ -1,31 +0,0 @@ -package callbackHandlers - -import ( - "gopkg.in/telebot.v4" - "tgbot/internal/config" - "tgbot/internal/service" - "tgbot/internal/stateMachine" -) - -type SetCookie struct { - svc service.Service - state stateMachine.StateMachine -} - -func NewSetCookie(svc service.Service, state stateMachine.StateMachine) *SetCookie { - return &SetCookie{svc: svc, state: state} -} - -func (s *SetCookie) CanHandle(ctx telebot.Context) bool { - if ctx.Callback().Data == "set_cookie" { - return true - } - - return false -} - -func (s *SetCookie) Process(ctx telebot.Context) error { - s.state.SetStatement(ctx.Callback().Sender.ID, stateMachine.SendingCookie) - - return ctx.Send(config.SendingCookie, config.RejectKeyboard) -} diff --git a/internal/contextHandlers/defaultHandler/handler.go b/internal/contextHandlers/defaultHandler/handler.go deleted file mode 100644 index 81a57a0..0000000 --- a/internal/contextHandlers/defaultHandler/handler.go +++ /dev/null @@ -1,8 +0,0 @@ -package defaultHandler - -import "gopkg.in/telebot.v4" - -type ContextHandler interface { - CanHandle(ctx telebot.Context) bool - Process(ctx telebot.Context) error -} diff --git a/internal/contextHandlers/handlersHolders/DefaultCBHolder.go b/internal/contextHandlers/handlersHolders/DefaultCBHolder.go deleted file mode 100644 index f973b8b..0000000 --- a/internal/contextHandlers/handlersHolders/DefaultCBHolder.go +++ /dev/null @@ -1,32 +0,0 @@ -package handlersHolders - -import ( - "tgbot/internal/contextHandlers/callbackHandlers" - "tgbot/internal/contextHandlers/defaultHandler" - "tgbot/internal/service" - "tgbot/internal/stateMachine" -) - -type DefaultCBHolder struct { - service service.Service - state stateMachine.StateMachine -} - -func NewDefaultCBHolder(service service.Service, state stateMachine.StateMachine) *DefaultCBHolder { - return &DefaultCBHolder{service: service, state: state} -} - -func (d DefaultCBHolder) HolderType() stateMachine.Statement { - return stateMachine.Default -} - -func (d DefaultCBHolder) GetHandlers() []defaultHandler.ContextHandler { - return []defaultHandler.ContextHandler{ - callbackHandlers.NewSetCookie(d.service, d.state), - callbackHandlers.NewChangeNotification(d.service), - callbackHandlers.NewRefreshGroups(d.service), - callbackHandlers.NewCloseLesson(d.service), - callbackHandlers.NewOpenLesson(d.service), - callbackHandlers.NewGetCredentials(d.service), - } -} diff --git a/internal/contextHandlers/handlersHolders/DefaultHolder.go b/internal/contextHandlers/handlersHolders/DefaultHolder.go deleted file mode 100644 index b53dd13..0000000 --- a/internal/contextHandlers/handlersHolders/DefaultHolder.go +++ /dev/null @@ -1,33 +0,0 @@ -package handlersHolders - -import ( - "tgbot/internal/contextHandlers/defaultHandler" - "tgbot/internal/contextHandlers/textHandlers/defaultState" - "tgbot/internal/service" - "tgbot/internal/stateMachine" -) - -type DefaultHolders struct { - service service.Service - state stateMachine.StateMachine -} - -func NewDefaultHolders(service service.Service, state stateMachine.StateMachine) *DefaultHolders { - return &DefaultHolders{service: service, state: state} -} - -func (d DefaultHolders) HolderType() stateMachine.Statement { - return stateMachine.Default -} - -func (d DefaultHolders) GetHandlers() []defaultHandler.ContextHandler { - return []defaultHandler.ContextHandler{ - &defaultState.Start{}, - defaultState.NewMissingKids(d.service), - defaultState.NewSettings(d.service), - defaultState.NewMyGroups(d.service), - defaultState.NewAbsentKids(d.service), - defaultState.NewStartWithPayload(d.service), - defaultState.NewAIChat(d.service, d.state), - } -} diff --git a/internal/contextHandlers/handlersHolders/SendingCookie.go b/internal/contextHandlers/handlersHolders/SendingCookie.go deleted file mode 100644 index f80811a..0000000 --- a/internal/contextHandlers/handlersHolders/SendingCookie.go +++ /dev/null @@ -1,28 +0,0 @@ -package handlersHolders - -import ( - "tgbot/internal/contextHandlers/defaultHandler" - "tgbot/internal/contextHandlers/textHandlers/sendingCookieState" - "tgbot/internal/service" - "tgbot/internal/stateMachine" -) - -type SendingCookie struct { - service service.Service - state stateMachine.StateMachine -} - -func NewSendingCookie(service service.Service, state stateMachine.StateMachine) *SendingCookie { - return &SendingCookie{service: service, state: state} -} - -func (s SendingCookie) HolderType() stateMachine.Statement { - return stateMachine.SendingCookie -} - -func (s SendingCookie) GetHandlers() []defaultHandler.ContextHandler { - return []defaultHandler.ContextHandler{ - sendingCookieState.NewRejectAction(s.state), - sendingCookieState.NewSendingCookieAction(s.state, s.service), - } -} diff --git a/internal/contextHandlers/handlersHolders/chattingAI.go b/internal/contextHandlers/handlersHolders/chattingAI.go deleted file mode 100644 index b8f083c..0000000 --- a/internal/contextHandlers/handlersHolders/chattingAI.go +++ /dev/null @@ -1,34 +0,0 @@ -package handlersHolders - -import ( - "tgbot/internal/contextHandlers/defaultHandler" - "tgbot/internal/contextHandlers/textHandlers/chattingAi" - "tgbot/internal/service" - "tgbot/internal/stateMachine" -) - -type ChattingAi struct { - state stateMachine.StateMachine - service service.Service - ai service.AIService -} - -func NewChattingAi(service service.Service, state stateMachine.StateMachine, ai service.AIService) *ChattingAi { - return &ChattingAi{ - service: service, - state: state, - ai: ai, - } -} - -func (c *ChattingAi) HolderType() stateMachine.Statement { - return stateMachine.ChattingAI -} - -func (c *ChattingAi) GetHandlers() []defaultHandler.ContextHandler { - return []defaultHandler.ContextHandler{ - chattingAi.NewBackAction(c.state), - chattingAi.NewClearHistory(c.ai), - chattingAi.NewAnyMessage(c.ai), - } -} diff --git a/internal/contextHandlers/handlersHolders/holder.go b/internal/contextHandlers/handlersHolders/holder.go deleted file mode 100644 index d70fd54..0000000 --- a/internal/contextHandlers/handlersHolders/holder.go +++ /dev/null @@ -1,11 +0,0 @@ -package handlersHolders - -import ( - "tgbot/internal/contextHandlers/defaultHandler" - "tgbot/internal/stateMachine" -) - -type HandlersHolder interface { - HolderType() stateMachine.Statement - GetHandlers() []defaultHandler.ContextHandler -} diff --git a/internal/contextHandlers/onCallback.go b/internal/contextHandlers/onCallback.go deleted file mode 100644 index 32533e8..0000000 --- a/internal/contextHandlers/onCallback.go +++ /dev/null @@ -1,58 +0,0 @@ -package contextHandlers - -import ( - "errors" - "fmt" - "gopkg.in/telebot.v4" - "strings" - "tgbot/internal/config" - "tgbot/internal/contextHandlers/handlersHolders" - "tgbot/internal/helpers" - "tgbot/internal/service" - "tgbot/internal/stateMachine" -) - -type OnCallback struct { - holders []handlersHolders.HandlersHolder - state stateMachine.StateMachine -} - -func NewOnCallback(service service.Service, state stateMachine.StateMachine) *OnCallback { - h := []handlersHolders.HandlersHolder{ - handlersHolders.NewDefaultCBHolder(service, state), - } - - return &OnCallback{holders: h, state: state} -} - -func (h *OnCallback) Handle(ctx telebot.Context) error { - st := h.state.GetStatement(ctx.Sender().ID) - holder := h.getHolder(st) - - if holder != nil { - - if strings.HasPrefix(ctx.Callback().Data, "\f") { - ctx.Callback().Data = strings.TrimPrefix(ctx.Callback().Data, "\f") - } - - for _, hand := range holder.GetHandlers() { - if hand.CanHandle(ctx) { - return hand.Process(ctx) - } - } - h.state.SetStatement(ctx.Sender().ID, stateMachine.Default) - return ctx.Send(config.Incorrect, config.StartKeyboard) - } - - h.state.SetStatement(ctx.Sender().ID, stateMachine.Default) - return helpers.LogError(errors.New(fmt.Sprintf("HolderNotFound(%v,%v)", st, ctx)), ctx, "Ошибка обработки запроса!\nНажмите - /start , для возвращения в меню") -} - -func (h *OnCallback) getHolder(st stateMachine.Statement) handlersHolders.HandlersHolder { - for _, holder := range h.holders { - if holder.HolderType() == st { - return holder - } - } - return nil -} diff --git a/internal/contextHandlers/onText.go b/internal/contextHandlers/onText.go deleted file mode 100644 index e11e0b7..0000000 --- a/internal/contextHandlers/onText.go +++ /dev/null @@ -1,56 +0,0 @@ -package contextHandlers - -import ( - "errors" - "fmt" - "gopkg.in/telebot.v4" - "tgbot/internal/config" - "tgbot/internal/contextHandlers/handlersHolders" - "tgbot/internal/helpers" - "tgbot/internal/service" - "tgbot/internal/stateMachine" -) - -type OnText struct { - holders []handlersHolders.HandlersHolder - state stateMachine.StateMachine - ai service.AIService -} - -func NewOnText(service service.Service, state stateMachine.StateMachine, ai service.AIService) *OnText { - - h := []handlersHolders.HandlersHolder{ - handlersHolders.NewDefaultHolders(service, state), - handlersHolders.NewSendingCookie(service, state), - handlersHolders.NewChattingAi(service, state, ai), - } - - return &OnText{holders: h, state: state, ai: ai} -} - -func (m *OnText) Handle(ctx telebot.Context) error { - st := m.state.GetStatement(ctx.Sender().ID) - - holder := m.getHolder(st) - if holder != nil { - for _, h := range holder.GetHandlers() { - if h.CanHandle(ctx) { - return h.Process(ctx) - } - } - m.state.SetStatement(ctx.Sender().ID, stateMachine.Default) - return ctx.Send(config.Incorrect, config.StartKeyboard) - } - - m.state.SetStatement(ctx.Sender().ID, stateMachine.Default) - return helpers.LogError(errors.New(fmt.Sprintf("HolderNotFound(%v,%v)", st, ctx)), ctx, "Ошибка обработки запроса!\nНажмите - /start , для возвращения в меню") -} - -func (m *OnText) getHolder(st stateMachine.Statement) handlersHolders.HandlersHolder { - for _, holder := range m.holders { - if holder.HolderType() == st { - return holder - } - } - return nil -} diff --git a/internal/contextHandlers/textHandlers/chattingAi/AnyMessage.go b/internal/contextHandlers/textHandlers/chattingAi/AnyMessage.go deleted file mode 100644 index 766660a..0000000 --- a/internal/contextHandlers/textHandlers/chattingAi/AnyMessage.go +++ /dev/null @@ -1,37 +0,0 @@ -package chattingAi - -import ( - "gopkg.in/telebot.v4" - "strconv" - "tgbot/internal/config" - "tgbot/internal/helpers" - "tgbot/internal/schedulers" - "tgbot/internal/service" -) - -type AnyMessage struct { - s service.AIService -} - -func NewAnyMessage(s service.AIService) *AnyMessage { - return &AnyMessage{s: s} -} - -func (a AnyMessage) CanHandle(ctx telebot.Context) bool { - if ctx.Message().Text != config.BackBtn.Text && ctx.Message().Text != config.ClearHistoryBtn.Text { - return true - } - return false -} - -func (a AnyMessage) Process(ctx telebot.Context) error { - m, _ := ctx.Bot().Send(schedulers.RecipientUser{ - ID: strconv.FormatInt(ctx.Sender().ID, 10), - }, "⚙️ Думаю как ответить ....") - suggest, err := a.s.GetSuggestion(ctx.Sender().ID, ctx.Message().Text) - if err != nil { - return helpers.LogError(err, ctx, "Ошибка при получении ответа от бота!") - } - _, err = ctx.Bot().Edit(m, suggest, telebot.ModeMarkdown) - return err -} diff --git a/internal/contextHandlers/textHandlers/chattingAi/ClearHistory.go b/internal/contextHandlers/textHandlers/chattingAi/ClearHistory.go deleted file mode 100644 index f349e46..0000000 --- a/internal/contextHandlers/textHandlers/chattingAi/ClearHistory.go +++ /dev/null @@ -1,32 +0,0 @@ -package chattingAi - -import ( - "gopkg.in/telebot.v4" - "tgbot/internal/config" - "tgbot/internal/helpers" - "tgbot/internal/service" -) - -type ClearHistory struct { - ai service.AIService -} - -func NewClearHistory(ai service.AIService) *ClearHistory { - return &ClearHistory{ai: ai} -} - -func (c ClearHistory) CanHandle(ctx telebot.Context) bool { - if ctx.Message().Text == config.ClearHistoryBtn.Text { - return true - } - return false -} - -func (c ClearHistory) Process(ctx telebot.Context) error { - err := c.ai.ClearAllHistory(ctx.Sender().ID) - if err != nil { - return helpers.LogError(err, ctx, "Ошибка при отчистке памяти!") - } - - return ctx.Send("Успешно отчищено!") -} diff --git a/internal/contextHandlers/textHandlers/chattingAi/backAction.go b/internal/contextHandlers/textHandlers/chattingAi/backAction.go deleted file mode 100644 index 28276ad..0000000 --- a/internal/contextHandlers/textHandlers/chattingAi/backAction.go +++ /dev/null @@ -1,27 +0,0 @@ -package chattingAi - -import ( - "gopkg.in/telebot.v4" - "tgbot/internal/config" - "tgbot/internal/stateMachine" -) - -type BackAction struct { - state stateMachine.StateMachine -} - -func NewBackAction(s stateMachine.StateMachine) *BackAction { - return &BackAction{state: s} -} - -func (b *BackAction) CanHandle(ctx telebot.Context) bool { - if ctx.Message().Text == config.BackBtn.Text { - return true - } - return false -} - -func (b *BackAction) Process(ctx telebot.Context) error { - b.state.SetStatement(ctx.Sender().ID, stateMachine.Default) - return ctx.Send(config.StartText, config.StartKeyboard) -} diff --git a/internal/contextHandlers/textHandlers/defaultState/absentKids.go b/internal/contextHandlers/textHandlers/defaultState/absentKids.go deleted file mode 100644 index e205dbb..0000000 --- a/internal/contextHandlers/textHandlers/defaultState/absentKids.go +++ /dev/null @@ -1,66 +0,0 @@ -package defaultState - -import ( - "errors" - "gopkg.in/telebot.v4" - "strings" - "tgbot/internal/config" - appError "tgbot/internal/error" - "tgbot/internal/helpers" - "tgbot/internal/service" - "time" -) - -type AbsentKids struct { - s service.Service -} - -func NewAbsentKids(s service.Service) *AbsentKids { - return &AbsentKids{s: s} -} - -func (a AbsentKids) CanHandle(ctx telebot.Context) bool { - if strings.HasPrefix(ctx.Message().Text, "/abs") { - return true - } - return false -} - -func (a AbsentKids) Process(ctx telebot.Context) error { - if ctx.Message().Payload == "" { - return ctx.Send("Формат сообщения - '/abs 2025-01-12 15:32'\nВыдаст статистику за 2025г. 12 Января, 15ч 32м") - } - t, err := time.Parse("2006-01-02 15:04", ctx.Message().Payload) - if err != nil { - return ctx.Send("Формат сообщения - '/abs 2025-01-12 15:32'\nВыдаст статистику за 2025г. 12 Января, 15ч 32м") - } - uid := ctx.Message().Sender.ID - - g, e := a.s.CurrentGroup(uid, t) - if e != nil { - if errors.Is(e, appError.ErrHasNone) { - return ctx.Send(config.CurrentGroupDontFind) - } - - return helpers.LogError(e, ctx, "Произошла непредвиденная ошибка при попытке получить текущую группу") - } - actual, err := a.s.ActualInformation(uid, t, g.GroupID) - if err != nil { - if errors.Is(err, appError.ErrNotValid) { - return ctx.Send(config.CookieNotSetException) - } - - return helpers.LogError(e, ctx, "Произошла непредвиденная ошибка при попытке подгрузить информацию о группе") - } - - allKids, err := a.s.AllKidsNames(uid, g.GroupID) - if err != nil { - if errors.Is(err, appError.ErrNotValid) { - return ctx.Send(config.CookieNotSetException) - } - - return helpers.LogError(e, ctx, "Произошла непредвиденная ошибка при попытке подгрузить имена детей") - } - - return ctx.Send(msgMissingKids(g, actual, allKids), getMissingKidsKeyboard(g, actual), telebot.ModeMarkdown) -} diff --git a/internal/contextHandlers/textHandlers/defaultState/aiChat.go b/internal/contextHandlers/textHandlers/defaultState/aiChat.go deleted file mode 100644 index 5b650ec..0000000 --- a/internal/contextHandlers/textHandlers/defaultState/aiChat.go +++ /dev/null @@ -1,29 +0,0 @@ -package defaultState - -import ( - "gopkg.in/telebot.v4" - "tgbot/internal/config" - "tgbot/internal/service" - "tgbot/internal/stateMachine" -) - -type AIChat struct { - svc service.Service - state stateMachine.StateMachine -} - -func NewAIChat(s service.Service, state stateMachine.StateMachine) *AIChat { - return &AIChat{svc: s, state: state} -} - -func (a *AIChat) CanHandle(ctx telebot.Context) bool { - if ctx.Message().Text == config.AIBtn.Text { - return true - } - return false -} - -func (a *AIChat) Process(ctx telebot.Context) error { - a.state.SetStatement(ctx.Sender().ID, stateMachine.ChattingAI) - return ctx.Send("Привет! Используй чат и клавиатуру для общения со мной!", config.AIKeyboard) -} diff --git a/internal/contextHandlers/textHandlers/defaultState/missingKids.go b/internal/contextHandlers/textHandlers/defaultState/missingKids.go deleted file mode 100644 index 4190e43..0000000 --- a/internal/contextHandlers/textHandlers/defaultState/missingKids.go +++ /dev/null @@ -1,98 +0,0 @@ -package defaultState - -import ( - "errors" - "fmt" - "gopkg.in/telebot.v4" - "strings" - "tgbot/internal/config" - appError "tgbot/internal/error" - "tgbot/internal/helpers" - "tgbot/internal/models" - "tgbot/internal/service" -) - -type MissingKids struct { - s service.Service -} - -func NewMissingKids(s service.Service) *MissingKids { - return &MissingKids{s} -} - -func (m *MissingKids) CanHandle(ctx telebot.Context) bool { - if ctx.Message().Text == "Получить отсутсвующих" { - return true - } - return false -} - -func (m *MissingKids) Process(ctx telebot.Context) error { - t := ctx.Message().Time() - uid := ctx.Message().Sender.ID - - g, e := m.s.CurrentGroup(uid, t) - if e != nil { - if errors.Is(e, appError.ErrHasNone) { - return ctx.Send(config.CurrentGroupDontFind) - } - - return helpers.LogError(e, ctx, "Произошла непредвиденная ошибка при попытке получить текущую группу") - } - - actual, err := m.s.ActualInformation(uid, t, g.GroupID) - if err != nil { - if errors.Is(err, appError.ErrNotValid) { - return ctx.Send(config.CookieNotSetException) - } - - return helpers.LogError(e, ctx, "Произошла непредвиденная ошибка при попытке подгрузить информацию о группе") - } - - allKids, err := m.s.AllKidsNames(uid, g.GroupID) - if err != nil { - if errors.Is(err, appError.ErrNotValid) { - return ctx.Send(config.CookieNotSetException) - } - - return helpers.LogError(e, ctx, "Произошла непредвиденная ошибка при попытке подгрузить имена детей") - } - - return ctx.Send(msgMissingKids(g, actual, allKids), getMissingKidsKeyboard(g, actual), telebot.ModeMarkdown) -} - -func getMissingKidsKeyboard(g models.Group, actual models.ActualInformation) *telebot.ReplyMarkup { - markup := telebot.ReplyMarkup{ResizeKeyboard: true} - markup.Inline( - markup.Row(markup.Data(config.CloseLessonBtn, fmt.Sprintf("close_lesson_%d_%d", g.GroupID, actual.LessonId)), markup.Data(config.OpenLessonBtn, fmt.Sprintf("open_lesson_%d_%d", g.GroupID, actual.LessonId))), - markup.Row(markup.Data(config.GetCredsBtn, fmt.Sprintf("get_creds_%d", g.GroupID))), - ) - - return &markup -} - -func msgMissingKids(g models.Group, actual models.ActualInformation, kids models.AllKids) string { - miss := strings.Builder{} - miss.WriteString("\n```Отсутствующие\n") - missingCount := 0 - for _, kid := range actual.MissingKids { - if v, ok := kids[kid.Id]; ok == true { - missingCount++ - miss.WriteString(fmt.Sprintf("%s", v.FullName)) - if kid.Count > 1 { - miss.WriteString(fmt.Sprintf(" (Уже %d занятие)", kid.Count)) - } - miss.WriteString("\n") - } - } - miss.WriteString("```") - - sb := strings.Builder{} - sb.WriteString(fmt.Sprintf("%s%s", config.GroupName, g.Title)) - sb.WriteString(fmt.Sprintf("\n%s%s\n", config.Lection, actual.LessonTitle)) - sb.WriteString(fmt.Sprintf("\n%s%d", config.TotalKids, len(kids))) - sb.WriteString(fmt.Sprintf("\n%s%d\n", config.MissingKids, missingCount)) - sb.WriteString(miss.String()) - - return sb.String() -} diff --git a/internal/contextHandlers/textHandlers/defaultState/myGroups.go b/internal/contextHandlers/textHandlers/defaultState/myGroups.go deleted file mode 100644 index 4bbbe40..0000000 --- a/internal/contextHandlers/textHandlers/defaultState/myGroups.go +++ /dev/null @@ -1,88 +0,0 @@ -package defaultState - -import ( - "errors" - "fmt" - "gopkg.in/telebot.v4" - "os" - "strconv" - "strings" - "tgbot/internal/config" - appError "tgbot/internal/error" - "tgbot/internal/helpers" - "tgbot/internal/models" - "tgbot/internal/serdes" - "tgbot/internal/service" - "time" -) - -var locales = map[time.Weekday]string{ - time.Monday: "пн", - time.Tuesday: "вт", - time.Wednesday: "ср", - time.Thursday: "чт", - time.Friday: "пт", - time.Saturday: "сб", - time.Sunday: "вс", -} - -type MyGroups struct { - s service.Service -} - -func NewMyGroups(s service.Service) *MyGroups { - return &MyGroups{s} -} - -func (m MyGroups) CanHandle(ctx telebot.Context) bool { - if ctx.Message().Text == "Мои группы" { - return true - } - return false -} -func (m MyGroups) Process(ctx telebot.Context) error { - g, e := m.s.Groups(ctx.Message().Sender.ID) - - if e != nil { - if errors.Is(e, appError.ErrHasNone) { - return ctx.Send(config.UserDontHaveGroup, config.MyGroupsKeyboard) - } - return helpers.LogError(e, ctx, "Ошибка при попытке получить группы!") - } - sorted := helpers.GetSortedGroups(g) - - return ctx.Send(GetMyGroupsMessage(sorted), config.MyGroupsKeyboard, telebot.ModeMarkdown, telebot.NoPreview) -} - -func GetMyGroupsMessage(g []models.Group) string { - s := strings.Builder{} - s.WriteString(fmt.Sprintf("%s%d\n", config.MyGroups, len(g))) - - before := g[0].TimeLesson.Weekday() - c := 1 - for _, group := range g { - if before != group.TimeLesson.Weekday() { - c = 1 - before = group.TimeLesson.Weekday() - s.WriteString("\n") - } - s.WriteString("\n") - s.WriteString(fmt.Sprintf("%d. %s 🕐 %s %s", c, getGroupTitle(group), getLocale(group.TimeLesson), group.TimeLesson.Format("15:04"))) - c += 1 - } - - return s.String() -} - -func getGroupTitle(group models.Group) string { - ser := serdes.Serialize(models.StartPayload{ - Action: models.GetGroupInfo, - Payload: []string{strconv.Itoa(group.GroupID)}, - }) - - return fmt.Sprintf("[%s](t.me/%s?start=%s)", group.Title, os.Getenv("TELEGRAM_NAME"), ser) -} - -func getLocale(t time.Time) string { - return locales[t.Weekday()] -} diff --git a/internal/contextHandlers/textHandlers/defaultState/settings.go b/internal/contextHandlers/textHandlers/defaultState/settings.go deleted file mode 100644 index 92e3db8..0000000 --- a/internal/contextHandlers/textHandlers/defaultState/settings.go +++ /dev/null @@ -1,59 +0,0 @@ -package defaultState - -import ( - "gopkg.in/telebot.v4" - "strings" - "tgbot/internal/config" - "tgbot/internal/helpers" - "tgbot/internal/service" -) - -type Settings struct { - svc service.Service -} - -func NewSettings(svc service.Service) *Settings { - return &Settings{svc: svc} -} - -func (s *Settings) CanHandle(ctx telebot.Context) bool { - if ctx.Message().Text == "Настройки" { - return true - } - return false -} -func (s *Settings) Process(ctx telebot.Context) error { - uid := ctx.Message().Sender.ID - - c, err := s.svc.Cookie(uid) - if err != nil { - return helpers.LogError(err, ctx, "Ошибка при формировании настроек (получение cookie) !") - } - n, err := s.svc.Notification(uid) - if err != nil { - return helpers.LogError(err, ctx, "Ошибка при формировании настроек (получение нотификаций) !") - } - - return ctx.Send(GetMessageSettings(c, n), config.SettingsKeyboard) -} - -func GetMessageSettings(c string, n bool) string { - msg := strings.Builder{} - msg.WriteString(config.Settings) - msg.WriteString("\n\n") - msg.WriteString(config.Cookie) - if c != "" { - msg.WriteString(config.SetParam) - } else { - msg.WriteString(config.NotSetParam) - } - msg.WriteString("\n") - msg.WriteString(config.ChatNotifications) - if n { - msg.WriteString(config.SetParam) - } else { - msg.WriteString(config.NotSetParam) - } - - return msg.String() -} diff --git a/internal/contextHandlers/textHandlers/defaultState/start.go b/internal/contextHandlers/textHandlers/defaultState/start.go deleted file mode 100644 index 64d9065..0000000 --- a/internal/contextHandlers/textHandlers/defaultState/start.go +++ /dev/null @@ -1,19 +0,0 @@ -package defaultState - -import ( - "gopkg.in/telebot.v4" - "tgbot/internal/config" -) - -type Start struct { -} - -func (h *Start) CanHandle(ctx telebot.Context) bool { - if ctx.Message().Text == "/start" { - return true - } - return false -} -func (h *Start) Process(ctx telebot.Context) error { - return ctx.Send(config.StartText, config.StartKeyboard) -} diff --git a/internal/contextHandlers/textHandlers/defaultState/startWithPayload.go b/internal/contextHandlers/textHandlers/defaultState/startWithPayload.go deleted file mode 100644 index d467413..0000000 --- a/internal/contextHandlers/textHandlers/defaultState/startWithPayload.go +++ /dev/null @@ -1,140 +0,0 @@ -package defaultState - -import ( - "fmt" - "gopkg.in/telebot.v4" - "os" - "regexp" - "strconv" - "strings" - "tgbot/internal/helpers" - "tgbot/internal/models" - "tgbot/internal/serdes" - "tgbot/internal/service" -) - -var statuses = map[int]string{ - 0: "🟢 Учится", - 20: "🔴 Выбыл", - 10: "🟡 Переведен", -} - -type StartWithPayload struct { - svc service.Service -} - -func NewStartWithPayload(svc service.Service) *StartWithPayload { - return &StartWithPayload{svc: svc} -} - -func (s StartWithPayload) CanHandle(ctx telebot.Context) bool { - if strings.HasPrefix(ctx.Message().Text, "/start") { - return true - } - - return false -} - -func (s StartWithPayload) Process(ctx telebot.Context) error { - payload, err := serdes.Deserialize(ctx.Message().Payload) - if err != nil { - return helpers.LogError(err, ctx, "Ошибка десериализации") - } - - switch payload.Action { - case models.GetGroupInfo: - return s.getGroupInfo(ctx, payload) - case models.GetKidInfo: - return s.getKidInfo(ctx, payload) - default: - return ctx.Send("Not supported") - } -} - -func (s StartWithPayload) getGroupInfo(ctx telebot.Context, payload models.StartPayload) error { - g, _ := strconv.Atoi(payload.Payload[0]) - full, err := s.svc.FullGroupInfo(ctx.Sender().ID, g) - if err != nil { - return helpers.LogError(err, ctx, "Ошибка при получении данной группы!") - } - - msg := GetGroupInfoMessage(full) - return ctx.Send(msg, telebot.ModeHTML, telebot.NoPreview) -} - -func GetGroupInfoMessage(full models.FullGroupInfo) string { - msg := strings.Builder{} - msg.WriteString(fmt.Sprintf("%s %s\n", full.GroupID, full.GroupTitle, full.GroupContent)) - msg.WriteString(fmt.Sprintf("\nСледующая лекция: %s\n", full.NextLessonTime)) - msg.WriteString(fmt.Sprintf("Всего пройдено %d лекций из %d\n", full.LessonsPassed, full.LessonsTotal)) - msg.WriteString(fmt.Sprintf("\nАктивные дети: %d | Выбыло: %d | Всего: %d\n", len(full.ActiveKids), len(full.NotActiveKids), len(full.ActiveKids)+len(full.NotActiveKids))) - msg.WriteString("Активные дети:\n") - for i, kid := range full.ActiveKids { - ser := serdes.Serialize(models.StartPayload{ - Action: models.GetKidInfo, - Payload: []string{strconv.Itoa(kid.ID), strconv.Itoa(full.GroupID)}, - }) - - msg.WriteString(fmt.Sprintf("%d. %s\n", i+1, os.Getenv("TELEGRAM_NAME"), ser, kid.FullName)) - } - msg.WriteString("Выбыли дети:\n") - for i, kid := range full.NotActiveKids { - ser := serdes.Serialize(models.StartPayload{ - Action: models.GetKidInfo, - Payload: []string{strconv.Itoa(kid.ID), strconv.Itoa(full.GroupID)}, - }) - - if kid.LastGroup.ID == full.GroupID { - msg.WriteString(fmt.Sprintf("%d. %s (🔴 Выбыл: %s)\n", i+1, os.Getenv("TELEGRAM_NAME"), ser, kid.FullName, kid.LastGroup.EndTime.Format("2006-01-02"))) - } else { - msg.WriteString(fmt.Sprintf("%d. %s (🟡 Переведен: %s)\n", i+1, os.Getenv("TELEGRAM_NAME"), ser, kid.FullName, kid.LastGroup.StartTime.Format("2006-01-02"))) - } - } - return msg.String() -} - -func (s StartWithPayload) getKidInfo(ctx telebot.Context, payload models.StartPayload) error { - - id, _ := strconv.Atoi(payload.Payload[0]) - groupId, _ := strconv.Atoi(payload.Payload[1]) - full, err := s.svc.FullKidInfo(ctx.Sender().ID, id, groupId) - if err != nil { - return helpers.LogError(err, ctx, "Ошибка при получении данного ученика!") - } - - m := GetKidInfoMessage(full) - return ctx.Send(m, telebot.ModeHTML, telebot.NoPreview) -} - -func GetKidInfoMessage(full models.FullKidInfo) string { - parentPhone := regexp.MustCompile(`[^0-9+]`).ReplaceAllString(full.Kid.Phone, "") - - msg := strings.Builder{} - if full.Extra == models.NotAccessible { - msg.WriteString(fmt.Sprintf("⚠️ У вас больше нету доступа к ребенку\n")) - } - msg.WriteString(fmt.Sprintf("%s\n", full.Kid.FullName)) - msg.WriteString(fmt.Sprintf("Возраст: %d\n", full.Kid.Age)) - msg.WriteString(fmt.Sprintf("День рождения: %s\n", full.Kid.BirthDate.Format("2006-01-02"))) - msg.WriteString("\nДанные от аккаунта:\n") - msg.WriteString(fmt.Sprintf("Логин: %s\n", full.Kid.Username)) - msg.WriteString(fmt.Sprintf("Пароль: %s\n", full.Kid.Password)) - msg.WriteString("\nРодитель:\n") - msg.WriteString(fmt.Sprintf("Имя: %s\n", full.Kid.ParentName)) - - msg.WriteString(fmt.Sprintf("Телефон: %s 🟩 Whatsapp\n", parentPhone, strings.TrimPrefix(parentPhone, "+"))) - msg.WriteString(fmt.Sprintf("Почта: %s\n", full.Kid.Email)) - msg.WriteString("\nГруппы\n") - - groups := full.Kid.Groups - for i := len(groups) - 1; i >= 0; i-- { - msg.WriteString(fmt.Sprintf("%d . %s %s\n", len(groups)-i, groups[i].ID, groups[i].Title, groups[i].Content)) - v, ok := statuses[groups[i].Status] - if !ok { - v = fmt.Sprintf("Статус [%d]", groups[i].Status) - } - msg.WriteString(fmt.Sprintf("%s (%s - %s)\n\n", v, groups[i].StartTime.Format("2006-01-02"), groups[i].EndTime.Format("2006-01-02"))) - } - m := msg.String() - return m -} diff --git a/internal/contextHandlers/textHandlers/sendingCookieState/rejectAction.go b/internal/contextHandlers/textHandlers/sendingCookieState/rejectAction.go deleted file mode 100644 index 9f1fedd..0000000 --- a/internal/contextHandlers/textHandlers/sendingCookieState/rejectAction.go +++ /dev/null @@ -1,26 +0,0 @@ -package sendingCookieState - -import ( - "gopkg.in/telebot.v4" - "tgbot/internal/config" - "tgbot/internal/stateMachine" -) - -type RejectAction struct { - state stateMachine.StateMachine -} - -func NewRejectAction(state stateMachine.StateMachine) *RejectAction { - return &RejectAction{state: state} -} - -func (r RejectAction) CanHandle(ctx telebot.Context) bool { - if ctx.Message().Text == "Отменить действие" { - return true - } - return false -} -func (r RejectAction) Process(ctx telebot.Context) error { - r.state.SetStatement(ctx.Sender().ID, stateMachine.Default) - return ctx.Send(config.StartText, config.StartKeyboard) -} diff --git a/internal/contextHandlers/textHandlers/sendingCookieState/sendCookie.go b/internal/contextHandlers/textHandlers/sendingCookieState/sendCookie.go deleted file mode 100644 index 0d17782..0000000 --- a/internal/contextHandlers/textHandlers/sendingCookieState/sendCookie.go +++ /dev/null @@ -1,38 +0,0 @@ -package sendingCookieState - -import ( - "gopkg.in/telebot.v4" - "tgbot/internal/config" - "tgbot/internal/helpers" - "tgbot/internal/service" - "tgbot/internal/stateMachine" -) - -type SendingCookieAction struct { - state stateMachine.StateMachine - service service.Service -} - -func NewSendingCookieAction(state stateMachine.StateMachine, service service.Service) *SendingCookieAction { - return &SendingCookieAction{state: state, service: service} -} - -func (s SendingCookieAction) CanHandle(ctx telebot.Context) bool { - if ctx.Message().Text != "Отменить действие" { - return true - } - return false -} - -func (s SendingCookieAction) Process(ctx telebot.Context) error { - uid := ctx.Sender().ID - cookie := ctx.Message().Text - - err := s.service.SetCookie(uid, cookie) - if err != nil { - return helpers.LogError(err, ctx, "Ошибка при установке Cookie!") - } - s.state.SetStatement(uid, stateMachine.Default) - - return ctx.Send(config.CookieSet, config.StartKeyboard) -} diff --git a/internal/clients/webClient.go b/internal/domain/backoffice/fullGroupInfo.go similarity index 57% rename from internal/clients/webClient.go rename to internal/domain/backoffice/fullGroupInfo.go index 3a8ccd8..555d362 100644 --- a/internal/clients/webClient.go +++ b/internal/domain/backoffice/fullGroupInfo.go @@ -1,140 +1,46 @@ -package clients +package backoffice -import ( - "fmt" - "time" -) - -type ClientError struct { - Code int - Message string -} - -func (c ClientError) Error() string { - return fmt.Sprintf("%d: %s", c.Code, c.Message) -} -func GetError(code int, message string) *ClientError { - return &ClientError{code, message} -} - -type WebClient interface { - // GetKidsNamesByGroup получить всех детей в группе - GetKidsNamesByGroup(cookie string, group int) (*GroupResponse, error) - // GetKidsStatsByGroup получить статистику посещения детей в группе - GetKidsStatsByGroup(cookie, group string) (*KidsStats, error) - // OpenLession открыть лекцию с идентификатором {lession} - OpenLession(cookie, group, lession string) error - // CloseLession закрыть лекцию с идентификатором {lession} - CloseLession(cookie, group, lession string) error - // GetKidsMessages получить новые сообщения детей на платформе - GetKidsMessages(cookie string) (*KidsMessages, error) - // GetAllGroupsByUser получить все группы - GetAllGroupsByUser(cookie string) ([]AllGroupsUser, error) - // GetGroupInfo получить информацию о группе - GetGroupInfo(cookie string, group string) (*FullGroupInfo, error) - // GetKidInfo получить информацию о ребенке - GetKidInfo(cookie string, kidID string) (*FullKidInfo, error) -} - -type GroupResponse struct { - Status string `json:"status"` - Data GroupData `json:"data"` -} -type GroupData struct { - Items []Student `json:"items"` -} -type Student struct { - ID int `json:"id"` - FirstName string `json:"firstName"` - LastName string `json:"lastName"` - FullName string `json:"fullName"` - ParentName string `json:"parentName"` - Email string `json:"email"` - HasLaptop int `json:"hasLaptop"` - Phone string `json:"phone"` - Age int `json:"age"` - BirthDate time.Time `json:"birthDate"` - CreatedAt time.Time `json:"createdAt"` - UpdatedAt time.Time `json:"updatedAt"` - DeletedAt interface{} `json:"deletedAt"` - HasBranchAccess bool `json:"hasBranchAccess"` - Username string `json:"username"` - Password string `json:"password"` - LastGroup Group `json:"lastGroup"` - Groups []Group `json:"groups"` - Links Links `json:"_links"` -} - -type Group struct { - ID int `json:"id"` - GroupStudentID int `json:"groupStudentId"` - Title string `json:"title"` - Content string `json:"content"` - Track int `json:"track"` - Status int `json:"status"` - StartTime time.Time `json:"startTime"` - EndTime time.Time `json:"endTime"` - CourseID int `json:"courseId"` - CreatedAt time.Time `json:"createdAt"` - UpdatedAt time.Time `json:"updatedAt"` - DeletedAt interface{} `json:"deletedAt"` -} - -type Links struct { - Self SelfLink `json:"self"` -} - -type SelfLink struct { - Href string `json:"href"` -} - -type KidsStats struct { - Status string `json:"status"` - Data []KidStat `json:"data"` -} - -type KidStat struct { - StudentID int `json:"student_id"` - Attendance []Attendance `json:"attendance"` -} - -type Attendance struct { - LessonID int `json:"lesson_id"` - LessonTitle string `json:"lesson_title"` - StartTimeFormatted string `json:"start_time_formatted"` - Status string `json:"status"` -} - -type KidsMessages struct { - Status string `json:"status"` - Data MessagesData `json:"data"` -} - -type MessagesData struct { - Projects []Message `json:"projects"` -} - -type Message struct { - UID string `json:"uid"` - New bool `json:"new"` - SenderID int `json:"senderId"` - SenderScope string `json:"senderScope"` - Type string `json:"type"` - Content string `json:"content"` - Name string `json:"name"` - LastTime string `json:"lastTime"` - Title string `json:"title"` - Link string `json:"link"` +type GroupInfo struct { + Status string `json:"status"` + Data GroupDataFull `json:"data"` } - -type AllGroupsUser struct { - Title string - GroupId string - TimeLesson string - RegularTime string +type GroupDataFull struct { + ID int `json:"id"` + Title string `json:"title"` + Content string `json:"content"` + Type TypeFull `json:"type"` + Status StatusFull `json:"status"` + StatusChangedAt string `json:"status_changed_at"` + StartTime string `json:"start_time"` + NextLessonTime string `json:"next_lesson_time"` + LessonsTotal int `json:"lessons_total"` + LessonsPassed int `json:"lessons_passed"` + HardwareNeeded int `json:"hardware_needed"` + Branch BranchFull `json:"branch"` + Venue VenueFull `json:"venue"` + Curator UserFull `json:"curator"` + Teacher TeacherFull `json:"teacher"` + Teachers []TeacherFull `json:"teachers"` + ClientManager interface{} `json:"client_manager"` + Course CourseFull `json:"course"` + LanguageID interface{} `json:"language_id"` + Journal bool `json:"journal"` + ShowJournal bool `json:"show_journal"` + ShowOnlineRoom bool `json:"showOnlineRoom"` + IsOnline bool `json:"isOnline"` + ActiveStudentCount int `json:"active_student_count"` + OnlineRoomURL string `json:"online_room_url"` + UseClientManager int `json:"use_client_manager"` + DisplayLessonDurationInMinutes int `json:"display_lesson_duration_in_minutes"` + DeletedAt interface{} `json:"deleted_at"` + DeletedBy interface{} `json:"deleted_by"` + PriorityLevel PriorityLevelFull `json:"priority_level"` + IsFull bool `json:"is_full"` + CreatedAt string `json:"created_at"` + CreatedBy UserFull `json:"created_by"` + Related RelatedFull `json:"_related"` } -// //////////////////// type StatusFull struct { Value int `json:"value"` Label string `json:"label"` @@ -257,67 +163,3 @@ type FullGroupInfo struct { Status string `json:"status"` Data GroupDataFull `json:"data"` } - -type GroupDataFull struct { - ID int `json:"id"` - Title string `json:"title"` - Content string `json:"content"` - Type TypeFull `json:"type"` - Status StatusFull `json:"status"` - StatusChangedAt string `json:"status_changed_at"` - StartTime string `json:"start_time"` - NextLessonTime string `json:"next_lesson_time"` - LessonsTotal int `json:"lessons_total"` - LessonsPassed int `json:"lessons_passed"` - HardwareNeeded int `json:"hardware_needed"` - Branch BranchFull `json:"branch"` - Venue VenueFull `json:"venue"` - Curator UserFull `json:"curator"` - Teacher TeacherFull `json:"teacher"` - Teachers []TeacherFull `json:"teachers"` - ClientManager interface{} `json:"client_manager"` - Course CourseFull `json:"course"` - LanguageID interface{} `json:"language_id"` - Journal bool `json:"journal"` - ShowJournal bool `json:"show_journal"` - ShowOnlineRoom bool `json:"showOnlineRoom"` - IsOnline bool `json:"isOnline"` - ActiveStudentCount int `json:"active_student_count"` - OnlineRoomURL string `json:"online_room_url"` - UseClientManager int `json:"use_client_manager"` - DisplayLessonDurationInMinutes int `json:"display_lesson_duration_in_minutes"` - DeletedAt interface{} `json:"deleted_at"` - DeletedBy interface{} `json:"deleted_by"` - PriorityLevel PriorityLevelFull `json:"priority_level"` - IsFull bool `json:"is_full"` - CreatedAt string `json:"created_at"` - CreatedBy UserFull `json:"created_by"` - Related RelatedFull `json:"_related"` -} - -// FullKidInfo -type LinksKidInfo struct { - Self struct { - Href string `json:"href"` - } `json:"self"` -} - -type GroupKidInfo struct { - ID int `json:"id"` - GroupStudentID int `json:"groupStudentId"` - Title string `json:"title"` - Content string `json:"content"` - Track int `json:"track"` - Status int `json:"status"` - StartTime time.Time `json:"startTime"` - EndTime time.Time `json:"endTime"` - CourseID int `json:"courseId"` - CreatedAt time.Time `json:"createdAt"` - UpdatedAt time.Time `json:"updatedAt"` - DeletedAt any `json:"deletedAt"` -} - -type FullKidInfo struct { - Status string `json:"status"` - Data Student `json:"data"` -} diff --git a/internal/domain/backoffice/kidVIew.go b/internal/domain/backoffice/kidVIew.go new file mode 100644 index 0000000..ed9a268 --- /dev/null +++ b/internal/domain/backoffice/kidVIew.go @@ -0,0 +1,23 @@ +package backoffice + +import "time" + +type KidView struct { + Status string `json:"status"` + Data Student `json:"data"` +} + +type GroupKidInfo struct { + ID int `json:"id"` + GroupStudentID int `json:"groupStudentId"` + Title string `json:"title"` + Content string `json:"content"` + Track int `json:"track"` + Status int `json:"status"` + StartTime time.Time `json:"startTime"` + EndTime time.Time `json:"endTime"` + CourseID int `json:"courseId"` + CreatedAt time.Time `json:"createdAt"` + UpdatedAt time.Time `json:"updatedAt"` + DeletedAt any `json:"deletedAt"` +} diff --git a/internal/domain/backoffice/namesByGroup.go b/internal/domain/backoffice/namesByGroup.go new file mode 100644 index 0000000..8d95826 --- /dev/null +++ b/internal/domain/backoffice/namesByGroup.go @@ -0,0 +1,80 @@ +package backoffice + +import "time" + +type NamesByGroup struct { + Status string `json:"status"` + Data GroupData `json:"data"` +} +type GroupData struct { + Items []Student `json:"items"` +} + +type Group struct { + ID int `json:"id"` + GroupStudentID int `json:"groupStudentId"` + Title string `json:"title"` + Content string `json:"content"` + Track int `json:"track"` + Status int `json:"status"` + StartTime time.Time `json:"startTime"` + EndTime time.Time `json:"endTime"` + CourseID int `json:"courseId"` + CreatedAt time.Time `json:"createdAt"` + UpdatedAt time.Time `json:"updatedAt"` + DeletedAt interface{} `json:"deletedAt"` +} + +type Links struct { + Self SelfLink `json:"self"` +} + +type SelfLink struct { + Href string `json:"href"` +} + +type KidsStats struct { + Status string `json:"status"` + Data []KidStat `json:"data"` +} + +type KidStat struct { + StudentID int `json:"student_id"` + Attendance []Attendance `json:"attendance"` +} + +type Attendance struct { + LessonID int `json:"lesson_id"` + LessonTitle string `json:"lesson_title"` + StartTimeFormatted string `json:"start_time_formatted"` + Status string `json:"status"` +} + +type KidsMessages struct { + Status string `json:"status"` + Data MessagesData `json:"data"` +} + +type MessagesData struct { + Projects []Message `json:"projects"` +} + +type Message struct { + UID string `json:"uid"` + New bool `json:"new"` + SenderID int `json:"senderId"` + SenderScope string `json:"senderScope"` + Type string `json:"type"` + Content string `json:"content"` + Name string `json:"name"` + LastTime string `json:"lastTime"` + Title string `json:"title"` + Link string `json:"link"` +} + +type AllGroupsUser struct { + Title string + GroupId string + TimeLesson string + RegularTime string +} diff --git a/internal/domain/backoffice/student.go b/internal/domain/backoffice/student.go new file mode 100644 index 0000000..a8e76db --- /dev/null +++ b/internal/domain/backoffice/student.go @@ -0,0 +1,25 @@ +package backoffice + +import "time" + +type Student struct { + ID int `json:"id"` + FirstName string `json:"firstName"` + LastName string `json:"lastName"` + FullName string `json:"fullName"` + ParentName string `json:"parentName"` + Email string `json:"email"` + HasLaptop int `json:"hasLaptop"` + Phone string `json:"phone"` + Age int `json:"age"` + BirthDate time.Time `json:"birthDate"` + CreatedAt time.Time `json:"createdAt"` + UpdatedAt time.Time `json:"updatedAt"` + DeletedAt interface{} `json:"deletedAt"` + HasBranchAccess bool `json:"hasBranchAccess"` + Username string `json:"username"` + Password string `json:"password"` + LastGroup Group `json:"lastGroup"` + Groups []Group `json:"groups"` + Links Links `json:"_links"` +} diff --git a/internal/domain/domain.go b/internal/domain/domain.go deleted file mode 100644 index 0951918..0000000 --- a/internal/domain/domain.go +++ /dev/null @@ -1,34 +0,0 @@ -package domain - -import ( - "time" -) - -type Group struct { - GroupID int - Title string - TimeLesson time.Time -} - -type User struct { - UID int64 - Cookie string - UserAgent string - Notifications bool - Groups []Group -} - -type Domain interface { - User(uid int64) (User, error) - Cookie(uid int64) (string, error) - SetCookie(uid int64, cookie string) error - SetUserAgent(uid int64, agent string) error - Groups(uid int64) ([]Group, error) - SetGroups(uid int64, groups []Group) error - Notification(uid int64) (bool, error) - SetNotification(uid int64, value bool) error - RegisterUser(uid int64) error - GetUsersByNotification(notifications int) ([]User, error) - LastNotificationDate(uid int64) (string, error) - SetLastNotificationDate(uid int64, data string) error -} diff --git a/internal/domain/models/aiinfo.go b/internal/domain/models/aiinfo.go new file mode 100644 index 0000000..9c95769 --- /dev/null +++ b/internal/domain/models/aiinfo.go @@ -0,0 +1,6 @@ +package models + +type AIInfo struct { + TextModel string + ImageModel string +} diff --git a/internal/domain/models/creds.go b/internal/domain/models/creds.go new file mode 100644 index 0000000..96893f5 --- /dev/null +++ b/internal/domain/models/creds.go @@ -0,0 +1,7 @@ +package models + +type Credential struct { + Fullname string + Login string + Password string +} diff --git a/internal/domain/models/currentGroup.go b/internal/domain/models/currentGroup.go new file mode 100644 index 0000000..127b900 --- /dev/null +++ b/internal/domain/models/currentGroup.go @@ -0,0 +1,15 @@ +package models + +type CurrentGroup struct { + GroupID int + Title string + Lesson string + LessonID int + Kids []string + MissingKids []MissingKid +} +type MissingKid struct { + Fullname string + KidID int + Count int +} diff --git a/internal/domain/models/fullGroupInfo.go b/internal/domain/models/fullGroupInfo.go new file mode 100644 index 0000000..7cb27f3 --- /dev/null +++ b/internal/domain/models/fullGroupInfo.go @@ -0,0 +1,25 @@ +package models + +import "time" + +type GroupView struct { + GroupID int + GroupTitle string + GroupContent string + NextLessonTime string + LessonsTotal int + LessonsPassed int + ActiveKids []GroupKid + NotActiveKids []GroupKid +} + +type GroupKid struct { + ID int + FullName string + LastGroup KidGroup +} +type KidGroup struct { + ID int + StartTime time.Time + EndTime time.Time +} diff --git a/internal/domain/models/group.go b/internal/domain/models/group.go new file mode 100644 index 0000000..2589b2a --- /dev/null +++ b/internal/domain/models/group.go @@ -0,0 +1,9 @@ +package models + +import "time" + +type Group struct { + GroupID int + Title string + TimeLesson time.Time +} diff --git a/internal/domain/models/kidView.go b/internal/domain/models/kidView.go new file mode 100644 index 0000000..e92ff42 --- /dev/null +++ b/internal/domain/models/kidView.go @@ -0,0 +1,32 @@ +package models + +import "time" + +type ExtraInfo string + +var NotAccessible ExtraInfo = "not_accessible" + +type KidView struct { + Extra ExtraInfo + Kid Kid +} +type Kid struct { + FullName string `json:"fullName"` + ParentName string `json:"parentName"` + Email string `json:"email"` + Phone string `json:"phone"` + Age int `json:"age"` + BirthDate time.Time `json:"birthDate"` + Username string `json:"username"` + Password string `json:"password"` + Groups []KidViewGroup `json:"groups"` +} + +type KidViewGroup struct { + ID int `json:"id"` + Title string `json:"title"` + Content string `json:"content"` + Status int `json:"status"` + StartTime time.Time `json:"startTime"` + EndTime time.Time `json:"endTime"` +} diff --git a/internal/domain/models/user.go b/internal/domain/models/user.go new file mode 100644 index 0000000..ad18feb --- /dev/null +++ b/internal/domain/models/user.go @@ -0,0 +1,9 @@ +package models + +type User struct { + ID int + Uid int64 + Cookie string + LastNotification string + Notification int +} diff --git a/internal/domain/scheduler/message.go b/internal/domain/scheduler/message.go new file mode 100644 index 0000000..7081574 --- /dev/null +++ b/internal/domain/scheduler/message.go @@ -0,0 +1,11 @@ +package scheduler + +type Message struct { + To int64 + From string + Theme string + Link string + Text string + LinkURL string + Time string +} diff --git a/internal/domain/serialize.go b/internal/domain/serialize.go new file mode 100644 index 0000000..da22ca9 --- /dev/null +++ b/internal/domain/serialize.go @@ -0,0 +1,13 @@ +package domain + +type SerType int + +const ( + GroupType SerType = iota + UserType +) + +type SerializeMessage struct { + Type SerType + Data []string +} diff --git a/internal/domain/sqlite3.go b/internal/domain/sqlite3.go deleted file mode 100644 index 4be830e..0000000 --- a/internal/domain/sqlite3.go +++ /dev/null @@ -1,349 +0,0 @@ -package domain - -import ( - "database/sql" - "errors" - "fmt" - "io/fs" - "log" - appError "tgbot/internal/error" - "time" -) - -type Sqlite3 struct { - db *sql.DB -} - -func NewSqlite3(db *sql.DB) *Sqlite3 { - return &Sqlite3{db: db} -} - -func (s Sqlite3) GetUsersByNotification(notif int) ([]User, error) { - row, err := s.db.Query(`SELECT u.uid, u.cookie, u.user_agent, u.notification FROM users u WHERE notification = ?`, notif) - if err != nil { - return nil, fmt.Errorf("NewSqlite3.GetUsersByNotification(%d) : %w", notif, row.Err()) - } - - var users []User - for row.Next() { - var baseId sql.NullInt64 - var cookie sql.NullString - var userAgent sql.NullString - var notifications bool - - err := row.Scan(&baseId, &cookie, &userAgent, ¬ifications) - if err != nil { - if errors.Is(err, sql.ErrNoRows) { - return nil, fmt.Errorf("NewSqlite3.GetUsersByNotification(%d) : %w", notif, appError.ErrHasNone) - } - return nil, fmt.Errorf("NewSqlite3.GetUsersByNotification(%d) : %w", notif, row.Err()) - } - - u := User{ - UID: baseId.Int64, - Cookie: cookie.String, - UserAgent: userAgent.String, - Notifications: notifications, - Groups: nil, - } - users = append(users, u) - s.appendGroups(&u, baseId.Int64) - } - - return users, nil -} - -func (s Sqlite3) LastNotificationDate(uid int64) (string, error) { - row := s.db.QueryRow(`SELECT u.last_notification_msg FROM users u WHERE u.uid = ?`, uid) - - if row.Err() != nil { - return "", fmt.Errorf("NewSqlite3.LastNotificationDate(%d) : %w", uid, row.Err()) - } - - var notifString sql.NullString - - err := row.Scan(¬ifString) - if err != nil { - return "", fmt.Errorf("NewSqlite3.LastNotificationDate(%d) : %w", uid, err) - } - - if !notifString.Valid || notifString.String == "" { - return "", fmt.Errorf("NewSqlite3.LastNotificationDate(%d) : %w", uid, appError.ErrNotValid) - } - return notifString.String, nil -} - -func (s Sqlite3) SetLastNotificationDate(uid int64, data string) error { - _, err := s.db.Exec("UPDATE users SET last_notification_msg=? WHERE uid=?", data, uid) - if err != nil { - return fmt.Errorf("NewSqlite3.SetLastNotificationDate(%d, %s) : %w", uid, data, err) - } - return nil -} - -func (s Sqlite3) User(uid int64) (User, error) { - row := s.db.QueryRow(`SELECT u.uid, u.cookie, u.user_agent, u.notification FROM users u WHERE u.uid = ?`, uid) - if row.Err() != nil { - return User{}, fmt.Errorf("NewSqlite3.User(%d) : %w", uid, row.Err()) - } - - var baseId sql.NullInt64 - var cookie sql.NullString - var userAgent sql.NullString - var notifications bool - - err := row.Scan(&baseId, &cookie, &userAgent, ¬ifications) - if err != nil { - if errors.Is(err, sql.ErrNoRows) { - return User{}, fmt.Errorf("NewSqlite3.User(%d) : %w", uid, appError.ErrNotFound) - } - return User{}, fmt.Errorf("NewSqlite3.User(%d) : %w", uid, err) - } - - if !baseId.Valid { - return User{}, fmt.Errorf("NewSqlite3.User(%d) : %w", uid, appError.ErrNotValid) - } - - u := User{ - Cookie: cookie.String, - UserAgent: userAgent.String, - Notifications: notifications, - } - - s.appendGroups(&u, baseId.Int64) - - return u, nil -} - -func (s Sqlite3) Cookie(uid int64) (string, error) { - row := s.db.QueryRow(`SELECT u.cookie FROM users u WHERE u.uid = ?`, uid) - - if row.Err() != nil { - return "", fmt.Errorf("NewSqlite3.Cookie(%d) : %w", uid, row.Err()) - } - - var cookie sql.NullString - - err := row.Scan(&cookie) - if err != nil { - return "", fmt.Errorf("NewSqlite3.Cookie(%d) : %w", uid, err) - } - - if !cookie.Valid { - return "", fmt.Errorf("NewSqlite3.Cookie(%d) : %w", uid, appError.ErrNotValid) - } - return cookie.String, nil -} - -func (s Sqlite3) SetCookie(uid int64, cookie string) error { - _, err := s.db.Exec("UPDATE users SET cookie=? WHERE uid=?", cookie, uid) - if err != nil { - return fmt.Errorf("NewSqlite3.SetCookie(%d, %s) : %w", uid, cookie, err) - } - return nil -} - -func (s Sqlite3) SetUserAgent(uid int64, agent string) error { - _, err := s.db.Exec("UPDATE users SET user_agent=? WHERE uid= ?;", agent, uid) - if err != nil { - return fmt.Errorf("NewSqlite3.SetUserAgent(%d, %s) : %w", uid, agent, err) - } - return nil -} - -func (s Sqlite3) Groups(uid int64) ([]Group, error) { - - rows, err := s.db.Query(`SELECT g.group_id, g.title, g.time_lesson - FROM groups g - WHERE g.owner_id = ?;`, uid) - if err != nil { - return nil, fmt.Errorf("NewSqlite3.Groups(%d) : %w", uid, err) - } - defer rows.Close() - - groups := make([]Group, 0) - for rows.Next() { - var groupId sql.NullInt64 - var title sql.NullString - var timeGroup sql.NullString - - if err := rows.Scan(&groupId, &title, &timeGroup); err != nil { - return nil, fmt.Errorf("NewSqlite3.Groups(%d) : %w", uid, err) - } - - parsedTime, err := time.Parse("2006-01-02 15:04:05", timeGroup.String) - if err != nil { - return nil, fmt.Errorf("NewSqlite3.Groups(%d) : %w", uid, err) - } - - groups = append(groups, Group{ - GroupID: int(groupId.Int64), - Title: title.String, - TimeLesson: parsedTime, - }) - } - - if len(groups) == 0 { - return nil, fmt.Errorf("NewSqlite3.Groups(%d) : %w", uid, appError.ErrHasNone) - } - return groups, nil -} - -func (s Sqlite3) SetGroups(uid int64, groups []Group) error { - tx, err := s.db.Begin() - if err != nil { - return fmt.Errorf("NewSqlite3.SetGroups(%d, %#v) : %w", uid, groups, err) - } - if _, err := tx.Exec("DELETE FROM groups WHERE owner_id=?", uid); err != nil { - tx.Rollback() - return fmt.Errorf("NewSqlite3.SetGroups(%d, %#v) : %w", uid, groups, err) - } - - stmt, err := tx.Prepare("INSERT INTO groups (group_id, owner_id, title, time_lesson) VALUES (?, ?, ?, ?)") - if err != nil { - tx.Rollback() - return fmt.Errorf("NewSqlite3.SetGroups(%d, %#v) : %w", uid, groups, err) - } - defer stmt.Close() - - for _, g := range groups { - if _, err := stmt.Exec(g.GroupID, uid, g.Title, g.TimeLesson.Format("2006-01-02 15:04:05")); err != nil { - tx.Rollback() - return fmt.Errorf("NewSqlite3.SetGroups(%d, %#v) : %w", uid, groups, err) - } - } - - if err := tx.Commit(); err != nil { - return fmt.Errorf("NewSqlite3.SetGroups(%d, %#v) : %w", uid, groups, err) - } - - return nil -} - -func (s Sqlite3) Notification(uid int64) (bool, error) { - row := s.db.QueryRow("SELECT notification FROM users WHERE uid=?", uid) - - var notif sql.NullBool - if err := row.Scan(¬if); err != nil { - return false, fmt.Errorf("NewSqlite3.Notification(%d) : %w", uid, err) - } - - if !notif.Valid { - return false, fmt.Errorf("NewSqlite3.Notification(%d) : %w", uid, appError.ErrNotValid) - } - - return notif.Bool, nil -} -func (s Sqlite3) SetNotification(uid int64, notification bool) error { - digit := 0 - if notification { - digit = 1 - } - - _, err := s.db.Exec("UPDATE users SET notification=? WHERE uid=?", digit, uid) - if err != nil { - return fmt.Errorf("NewSqlite3.SetNotification(%d, %v) : %w", uid, notification, err) - } - return nil -} -func (s Sqlite3) RegisterUser(uid int64) error { - _, err := s.db.Exec("INSERT INTO users (uid, user_agent, cookie, notification) VALUES (?, NULL, NULL, 0)", uid) - if err != nil { - return fmt.Errorf("NewSqlite3.RegisterUser(%d) : %w", uid, err) - } - return nil -} - -func (s Sqlite3) appendGroups(u *User, id int64) { - sqlQuery := "SELECT g.group_id, g.title, g.time_lesson FROM groups g WHERE g.owner_id = ?" - query, err := s.db.Query(sqlQuery, id) - if err != nil { - log.Printf("Ошибка при выполнении запроса, %s, со значением %v", sqlQuery, id) - return - } - defer query.Close() - for query.Next() { - var groupId sql.NullInt64 - var title sql.NullString - var timeGroup sql.NullString - - query.Scan(&groupId, &title, &timeGroup) - - parsedTime, err := time.Parse("2006-01-02 15:04:05", timeGroup.String) - if err != nil { - log.Printf("2 Ошибка при парсинге даты %v - %v", timeGroup.String, err) - return - } - - u.Groups = append(u.Groups, Group{ - GroupID: int(groupId.Int64), - Title: title.String, - TimeLesson: parsedTime, - }) - } -} - -func (s Sqlite3) Migrate(eFs fs.FS, dir string) { - log.Println("Начинаю процесс миграции базы") - files, err := fs.ReadDir(eFs, dir) - if err != nil { - log.Fatal(err) - } - - if _, err := s.db.Exec(`CREATE TABLE IF NOT EXISTS migrations ( - name TEXT PRIMARY KEY, - executed_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP)`); err != nil { - log.Fatalf("ошибка создания таблицы миграций: %v\n", err) - } - - for _, file := range files { - if file.IsDir() { - continue - } - log.Printf("Миграция файла - %s\n", file.Name()) - var exists bool - err := s.db.QueryRow( - `SELECT EXISTS (SELECT 1 FROM migrations WHERE name = ?)`, - file.Name(), - ).Scan(&exists) - if err != nil { - log.Fatalf("Ошибка проверки таблицы: %v\n", err) - } - if exists { - log.Printf("Миграция %s, уже существует\n", file.Name()) - continue - } - - content, err := fs.ReadFile(eFs, "migrations/"+file.Name()) - if err != nil { - log.Fatalf("ошибка чтения файла %s: %v", file.Name(), err) - } - _ = content - - tx, err := s.db.Begin() - if err != nil { - log.Fatal(err) - } - - if _, err := tx.Exec(string(content)); err != nil { - tx.Rollback() - log.Fatalf("ошибка выполнения миграции %s: %w", file.Name(), err) - } - - if _, err := tx.Exec( - "INSERT INTO migrations (name) VALUES (?)", - file.Name(), - ); err != nil { - tx.Rollback() - log.Fatalf("ошибка записи миграции %s: %w", file.Name(), err) - } - - if err := tx.Commit(); err != nil { - log.Fatalf("ошибка коммита транзакции: %w", err) - } - - log.Printf("Применена миграция: %s\n", file.Name()) - } - - log.Print("База данных готова к использованию!\n") -} diff --git a/internal/domain/telegram/keyboards/missingKids.go b/internal/domain/telegram/keyboards/missingKids.go new file mode 100644 index 0000000..a1f811c --- /dev/null +++ b/internal/domain/telegram/keyboards/missingKids.go @@ -0,0 +1,19 @@ +package keyboards + +import ( + "fmt" + "gopkg.in/telebot.v4" +) + +func MissingKids(groupID, lessonID int) *telebot.ReplyMarkup { + markup := telebot.ReplyMarkup{ResizeKeyboard: true} + markup.Inline( + markup.Row( + markup.Data("Закрыть лекцию", fmt.Sprintf("close_lesson_%d_%d", groupID, lessonID)), + markup.Data("Открыть лекцию", fmt.Sprintf("open_lesson_%d_%d", groupID, lessonID)), + ), + markup.Row(markup.Data("Получить аккаунты", fmt.Sprintf("get_creds_%d", groupID))), + ) + + return &markup +} diff --git a/internal/domain/telegram/keyboards/refreshGroups.go b/internal/domain/telegram/keyboards/refreshGroups.go new file mode 100644 index 0000000..2e7e674 --- /dev/null +++ b/internal/domain/telegram/keyboards/refreshGroups.go @@ -0,0 +1,14 @@ +package keyboards + +import tele "gopkg.in/telebot.v4" + +func RefreshGroups() *tele.ReplyMarkup { + refreshKb := &tele.ReplyMarkup{ResizeKeyboard: true} + refresh := refreshKb.Data("Обновить группы", "refresh_groups") + + refreshKb.Inline( + refreshKb.Row(refresh), + ) + + return refreshKb +} diff --git a/internal/domain/telegram/keyboards/sendingCookie.go b/internal/domain/telegram/keyboards/sendingCookie.go new file mode 100644 index 0000000..8714213 --- /dev/null +++ b/internal/domain/telegram/keyboards/sendingCookie.go @@ -0,0 +1,14 @@ +package keyboards + +import tele "gopkg.in/telebot.v4" + +func RejectKeyboard() *tele.ReplyMarkup { + rejectKb := &tele.ReplyMarkup{ResizeKeyboard: true} + reject := rejectKb.Text("⬅️ Назад") + + rejectKb.Reply( + rejectKb.Row(reject), + ) + + return rejectKb +} diff --git a/internal/domain/telegram/keyboards/settings.go b/internal/domain/telegram/keyboards/settings.go new file mode 100644 index 0000000..64cfa81 --- /dev/null +++ b/internal/domain/telegram/keyboards/settings.go @@ -0,0 +1,16 @@ +package keyboards + +import tele "gopkg.in/telebot.v4" + +func Settings() *tele.ReplyMarkup { + settingsKb := &tele.ReplyMarkup{ResizeKeyboard: true} + setCookie := settingsKb.Data("Установить Cookie", "set_cookie") + changeNotification := settingsKb.Data("Переключить уведомления", "change_notification") + + settingsKb.Inline( + settingsKb.Row(setCookie), + settingsKb.Row(changeNotification), + ) + + return settingsKb +} diff --git a/internal/domain/telegram/keyboards/start.go b/internal/domain/telegram/keyboards/start.go new file mode 100644 index 0000000..c26679d --- /dev/null +++ b/internal/domain/telegram/keyboards/start.go @@ -0,0 +1,20 @@ +package keyboards + +import tele "gopkg.in/telebot.v4" + +func Start() *tele.ReplyMarkup { + startKb := &tele.ReplyMarkup{ResizeKeyboard: true} + + missing := startKb.Text("Получить отсутсвующих") + myGroups := startKb.Text("Мои группы") + settings := startKb.Text("Настройки") + ai := startKb.Text("AI 🔹") + + startKb.Reply( + startKb.Row(missing), + startKb.Row(myGroups, settings), + startKb.Row(ai), + ) + + return startKb +} diff --git a/internal/error/error.go b/internal/error/error.go deleted file mode 100644 index 0189de7..0000000 --- a/internal/error/error.go +++ /dev/null @@ -1,10 +0,0 @@ -package appError - -import ( - "errors" -) - -var ErrNotValid = errors.New("not valid") -var ErrNotFound = errors.New("not found") -var ErrHasNone = errors.New("has no one") -var NotEnoughArgs = errors.New("not enough arguments") diff --git a/internal/helpers/group.go b/internal/helpers/group.go deleted file mode 100644 index 7ab2b56..0000000 --- a/internal/helpers/group.go +++ /dev/null @@ -1,68 +0,0 @@ -package helpers - -import ( - "sort" - appError "tgbot/internal/error" - "tgbot/internal/models" - "time" -) - -// GetSortedGroups получение списка групп отсортированных по дням -// И внутри дней по часам -func GetSortedGroups(groups []models.Group) []models.Group { - sort.Slice(groups, func(i, j int) bool { - dayI, dayJ := groups[i].TimeLesson.Weekday(), groups[j].TimeLesson.Weekday() - if dayI != dayJ { - return dayI > dayJ - } - return groups[i].TimeLesson.Hour() < groups[j].TimeLesson.Hour() || - (groups[i].TimeLesson.Hour() == groups[j].TimeLesson.Hour() && groups[i].TimeLesson.Minute() < groups[j].TimeLesson.Minute()) - }) - return groups -} - -// GetCurrentGroup , логика выдачи групп: -// За 30 минут до начала и во время группы выдывать конкретную группу -func GetCurrentGroup(t time.Time, g []models.Group) (models.Group, error) { - current, err := GetGroupsByDay(t, g) - if err != nil { - return models.Group{}, err - } - for _, group := range current { - if inDiapazon(-30, 90, t, group.TimeLesson) { - return group, nil - } - } - - return models.Group{}, appError.ErrHasNone -} - -// GetGroupsByDay получение групп по текущему дню -func GetGroupsByDay(t time.Time, g []models.Group) ([]models.Group, error) { - var filtered []models.Group - - for _, group := range g { - if t.Weekday() == group.TimeLesson.Weekday() { - filtered = append(filtered, group) - } - } - if len(filtered) == 0 { - return nil, appError.ErrHasNone - } - - return filtered, nil -} - -func inDiapazon(start, end int, now, group time.Time) bool { - s := group.Add(time.Duration(start) * time.Minute) - e := group.Add(time.Duration(end) * time.Minute) - - startTime := time.Date(1970, 1, 1, s.Hour(), s.Minute(), 0, 0, time.UTC) - endTime := time.Date(1970, 1, 1, e.Hour(), e.Minute(), 0, 0, time.UTC) - currentTime := time.Date(1970, 1, 1, now.Hour(), now.Minute(), 0, 0, time.UTC) - - if currentTime.After(startTime) && currentTime.Before(endTime) { - return true - } - return false -} diff --git a/internal/helpers/group_test.go b/internal/helpers/group_test.go deleted file mode 100644 index 0697a31..0000000 --- a/internal/helpers/group_test.go +++ /dev/null @@ -1,100 +0,0 @@ -package helpers - -import ( - "reflect" - "testing" - "tgbot/internal/models" - "time" -) - -func Test_GetGroupsByDay(t *testing.T) { - all := []models.Group{ - { - GroupID: 1, - Title: "Lesson 1", - TimeLesson: getDayByTime(21, 12, 30), - }, - { - GroupID: 2, - Title: "Lesson 2", - TimeLesson: getDayByTime(27, 14, 00), - }, - { - GroupID: 3, - Title: "Lesson 3", - TimeLesson: getDayByTime(26, 14, 00), - }, - { - GroupID: 4, - Title: "Lesson 4", - TimeLesson: getDayByTime(28, 15, 30), - }, - } - t.Run("If groups exists", func(t *testing.T) { - g, e := GetGroupsByDay(getDayByTime(14, 9, 9), all) - if e != nil { - t.Fatalf("Unexpected error %v", e) - } - want := []models.Group{ - all[0], - all[3], - } - if reflect.DeepEqual(want, g) == false { - t.Fatalf("Expected: %v, Got: %v", want, g) - } - }) - t.Run("If groups not exists", func(t *testing.T) { - _, e := GetGroupsByDay(getDayByTime(18, 9, 9), all) - if e == nil { - t.Fatalf("Wanted error, got nil!") - } - }) -} - -func Test_GetCurrentGroup(t *testing.T) { - all := []models.Group{ - { - GroupID: 1, - Title: "Lesson 1", // вск - TimeLesson: getDayByTime(21, 12, 30), - }, - { - GroupID: 2, - Title: "Lesson 2", // суб - TimeLesson: getDayByTime(27, 14, 00), - }, - { - GroupID: 3, - Title: "Lesson 3", // птн - TimeLesson: getDayByTime(26, 14, 00), - }, - { - GroupID: 4, - Title: "Lesson 4", // вск - TimeLesson: getDayByTime(28, 15, 30), - }, - } - t.Run("If group exists", func(t *testing.T) { - date := getDayByTime(14, 12, 10) - g, e := GetCurrentGroup(date, all) - if e != nil { - t.Fatalf("Unexpected error %v", e) - } - want := all[0] - if reflect.DeepEqual(want, g) == false { - t.Fatalf("Expected: %v, Got: %v", want, g) - } - }) - t.Run("If group end", func(t *testing.T) { - date := getDayByTime(14, 21, 10) - _, e := GetCurrentGroup(date, all) - if e == nil { - t.Fatalf("Expected error, got nil") - } - }) -} - -// getDayByTime 28, 21 вск || 27, 20 сб -func getDayByTime(day, hour, min int) time.Time { - return time.Date(2025, 9, day, hour, min, 0, 0, time.UTC) -} diff --git a/internal/helpers/logError.go b/internal/helpers/logError.go deleted file mode 100644 index 1bd32de..0000000 --- a/internal/helpers/logError.go +++ /dev/null @@ -1,32 +0,0 @@ -package helpers - -import ( - "fmt" - "gopkg.in/telebot.v4" - "log" - "math/rand" - "time" -) - -func token() string { - charset := "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789" - - rng := rand.New(rand.NewSource(time.Now().UnixNano())) - token := make([]byte, 7) - for i := range token { - token[i] = charset[rng.Intn(len(charset))] - } - - return string(token) -} - -func LogError(err error, ctx telebot.Context, reason string) error { - token := token() - - log.Printf("ERR: %s | %s\n", token, err.Error()) - if reason == "" { - reason = "Произошла ошибка, классифицировать не удалось :(" - } - - return ctx.Send(fmt.Sprintf("[%s] %s", token, reason), telebot.ModeHTML) -} diff --git a/internal/lib/backoffice/backoffice.go b/internal/lib/backoffice/backoffice.go new file mode 100644 index 0000000..c6fb507 --- /dev/null +++ b/internal/lib/backoffice/backoffice.go @@ -0,0 +1,111 @@ +package backoffice + +import ( + "algobot/internal/config" + "algobot/internal/lib/logger/handlers/slogpretty" + "algobot/internal/lib/logger/sl" + "errors" + "fmt" + "io" + "log/slog" + "net/http" + "net/url" + "time" +) + +var ( + ErrBadCode = errors.New("bad code") + ErrNotFound = errors.New("not found") +) + +type Option func(*Backoffice) + +type Backoffice struct { + url string + client *http.Client + log *slog.Logger + cfg *config.Backoffice +} + +func NewBackoffice(cfg *config.Backoffice, fn ...Option) *Backoffice { + bo := &Backoffice{ + cfg: cfg, + url: "https://backoffice.algoritmika.org", + client: &http.Client{ + Timeout: cfg.ResponseTimeout, + }, + log: slog.New(slogpretty.NewHandler(&slog.HandlerOptions{Level: slog.LevelDebug})), + } + for _, o := range fn { + o(bo) + } + + return bo +} + +func WithURL(url string) func(*Backoffice) { + return func(bo *Backoffice) { + bo.url = url + } +} +func WithLogger(log *slog.Logger) func(*Backoffice) { + return func(bo *Backoffice) { + bo.log = log + } +} + +func (bo *Backoffice) doReq(req *http.Request) (*http.Response, error) { + const op = "backoffice.doReq" + log := bo.log.With( + slog.String("op", op), + ) + + var err error + var resp *http.Response + + for i := 0; i < bo.cfg.Retries; i++ { + resp, err = bo.client.Do(req) + if err != nil { + log.Debug("error while req bo", sl.Err(err)) + time.Sleep(bo.cfg.RetriesTimeout) + continue + } + if resp.StatusCode != http.StatusOK { + log.Debug("received not 200 OK", slog.Int("status", resp.StatusCode)) + err = ErrBadCode + time.Sleep(bo.cfg.RetriesTimeout) + continue + } + return resp, nil + } + + if err != nil { + log.Warn("error while req bo", sl.Err(err)) + return nil, fmt.Errorf("%s error while trying send request: %w", op, err) + } + + return resp, nil +} +func (bo *Backoffice) createReq(method, uri, cookie string, params map[string]string, body io.Reader) (*http.Request, error) { + const op = "backoffice.createReq" + + reqUrl, _ := url.Parse(fmt.Sprintf("%s%s", bo.url, uri)) + + p := url.Values{} + for key, val := range params { + p.Add(key, val) + } + reqUrl.RawQuery = p.Encode() + + req, err := http.NewRequest(method, reqUrl.String(), body) + if err != nil { + return nil, fmt.Errorf("%s error while creating req: %w", op, err) + } + + req.Header.Add("Cookie", cookie) + if method == "POST" { + req.Header.Set("Content-Type", "application/x-www-form-urlencoded; charset=UTF-8") + } + + return req, nil +} diff --git a/internal/lib/backoffice/backoffice_test.go b/internal/lib/backoffice/backoffice_test.go new file mode 100644 index 0000000..5e10585 --- /dev/null +++ b/internal/lib/backoffice/backoffice_test.go @@ -0,0 +1,117 @@ +package backoffice + +import ( + "algobot/internal/config" + "algobot/test/mocks" + "context" + "github.com/stretchr/testify/assert" + "math/rand" + "net/http" + "net/http/httptest" + "testing" + "time" +) + +func TestRequester(t *testing.T) { + t.Run("test timeout", func(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) { + time.Sleep(1 * time.Second) + rw.WriteHeader(http.StatusOK) + })) + defer server.Close() + + bo := NewBackoffice(&config.Backoffice{ + Retries: 1, + RetriesTimeout: time.Millisecond, + ResponseTimeout: 10 * time.Millisecond, + }, WithLogger(mocks.NewMockLogger()), WithURL(server.URL)) + + req, _ := http.NewRequest(http.MethodGet, server.URL, nil) + _, err := bo.doReq(req) + + assert.Error(t, err) + assert.ErrorIs(t, err, context.DeadlineExceeded) + }) + t.Run("test retry not 200 OK", func(t *testing.T) { + counter := 0 + server := httptest.NewServer(http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) { + counter++ + rw.WriteHeader(rand.Intn(200) + 300) + })) + defer server.Close() + + bo := NewBackoffice(&config.Backoffice{ + Retries: 5, + RetriesTimeout: time.Millisecond, + ResponseTimeout: 100 * time.Millisecond, + }, WithLogger(mocks.NewMockLogger()), WithURL(server.URL)) + + req, _ := http.NewRequest(http.MethodGet, server.URL, nil) + _, err := bo.doReq(req) + + assert.Error(t, err) + assert.Equal(t, 5, counter) + assert.ErrorIs(t, err, ErrBadCode) + }) + t.Run("test retries count", func(t *testing.T) { + counter := 0 + server := httptest.NewServer(http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) { + counter++ + time.Sleep(1 * time.Second) + rw.WriteHeader(http.StatusOK) + })) + defer server.Close() + + bo := NewBackoffice(&config.Backoffice{ + Retries: 5, + RetriesTimeout: time.Millisecond, + ResponseTimeout: 10 * time.Millisecond, + }, WithLogger(mocks.NewMockLogger()), WithURL(server.URL)) + + req, _ := http.NewRequest(http.MethodGet, server.URL, nil) + _, err := bo.doReq(req) + + assert.Error(t, err) + assert.Equal(t, 5, counter) + assert.ErrorIs(t, err, context.DeadlineExceeded) + }) + t.Run("test retries timeout", func(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) { + time.Sleep(1 * time.Second) + rw.WriteHeader(http.StatusOK) + })) + defer server.Close() + + bo := NewBackoffice(&config.Backoffice{ + Retries: 5, + RetriesTimeout: 5 * time.Millisecond, + ResponseTimeout: 10 * time.Millisecond, + }, WithLogger(mocks.NewMockLogger()), WithURL(server.URL)) + + req, _ := http.NewRequest(http.MethodGet, server.URL, nil) + + timeStart := time.Now() + _, err := bo.doReq(req) + timeEnd := time.Now() + + assert.Error(t, err) + assert.InDelta(t, 75, timeEnd.Sub(timeStart).Milliseconds(), 5) + }) + t.Run("happy path", func(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) { + rw.WriteHeader(http.StatusOK) + })) + defer server.Close() + + bo := NewBackoffice(&config.Backoffice{ + Retries: 5, + RetriesTimeout: 50 * time.Millisecond, + ResponseTimeout: 100 * time.Millisecond, + }, WithLogger(mocks.NewMockLogger()), WithURL(server.URL)) + req, _ := http.NewRequest(http.MethodGet, server.URL, nil) + + resp, err := bo.doReq(req) + assert.NoError(t, err) + assert.Equal(t, 200, resp.StatusCode) + }) +} diff --git a/internal/lib/backoffice/group.go b/internal/lib/backoffice/group.go new file mode 100644 index 0000000..03e41b6 --- /dev/null +++ b/internal/lib/backoffice/group.go @@ -0,0 +1,91 @@ +package backoffice + +import ( + "algobot/internal/domain/models" + "algobot/internal/lib/logger/sl" + "fmt" + "github.com/PuerkitoBio/goquery" + "io" + "log/slog" + "strconv" + "strings" + "time" +) + +func (bo *Backoffice) Group(cookie string) ([]models.Group, error) { + const op = "backoffice.Group" + log := bo.log.With( + slog.String("op", op), + ) + + req, err := bo.createReq("GET", "/group", cookie, map[string]string{ + "GroupSearch[status][]": "active", + "presetType": "all", + "_pjax": "#group-grid-pjax", + }, nil) + + if err != nil { + log.Warn("failed to create request", sl.Err(err)) + return nil, fmt.Errorf("%s failed to create request: %w", op, err) + } + data, err := bo.doReq(req) + if err != nil { + return nil, fmt.Errorf("%s failed to doReq: %w", op, err) + } + + res, err := parseHTML(data.Body, bo.log) + if err != nil { + return nil, fmt.Errorf("%s failed to parse HTML: %w", op, err) + } + + return res, nil +} + +func parseHTML(body io.ReadCloser, log *slog.Logger) ([]models.Group, error) { + const op = "backoffice.parseHTML" + log = log.With(slog.String("op", op)) + + doc, err := goquery.NewDocumentFromReader(body) + if err != nil { + return nil, fmt.Errorf("%s failed parse doc: %w", op, err) + } + + var groups []models.Group + + doc.Find("tr.group-grid").Each(func(i int, row *goquery.Selection) { + groupId := row.Find("td[data-col-seq='id']").First().Text() + groupId = strings.TrimSpace(groupId) + + titleCell := row.Find("td[data-col-seq='title']").First() + + groupTitle := titleCell.Find("p").First().Text() + groupTitle = strings.TrimSpace(groupTitle) + + groupTime := titleCell.Find("a").First().Text() + groupTime = strings.TrimSpace(groupTime) + + nextLessonTime := row.Find("td[data-col-seq='nextLessonTime']").First().Text() + nextLessonTime = strings.TrimSpace(nextLessonTime) + + if nextLessonTime != "" { + groupID, err := strconv.Atoi(strings.ReplaceAll(groupId, "\u00A0", " ")) + if err != nil { + log.Warn("failed to convert group id to int", sl.Err(err)) + return + } + timeLession, err := time.Parse("02.01.2006 15:04", strings.ReplaceAll(nextLessonTime, "\u00A0", " ")) + if err != nil { + log.Warn("failed to convert timeLession to time", sl.Err(err)) + return + } + + groups = append(groups, models.Group{ + GroupID: groupID, + Title: strings.ReplaceAll(groupTitle, "\u00A0", " "), + TimeLesson: timeLession, + }) + } + }) + + return groups, nil +} diff --git a/internal/lib/backoffice/groupView.go b/internal/lib/backoffice/groupView.go new file mode 100644 index 0000000..15d7270 --- /dev/null +++ b/internal/lib/backoffice/groupView.go @@ -0,0 +1,31 @@ +package backoffice + +import ( + "algobot/internal/domain/backoffice" + "encoding/json" + "fmt" +) + +func (bo *Backoffice) GroupView(groupID string, cookie string) (backoffice.GroupInfo, error) { + const op = "backoffice.GetGroupView" + + req, err := bo.createReq("GET", "/api/v1/group/"+groupID, cookie, map[string]string{ + "expand": "venue,teacher,curator,branch", + }, nil) + if err != nil { + return backoffice.GroupInfo{}, fmt.Errorf("%s err create req: %w", op, err) + } + + data, err := bo.doReq(req) + if err != nil { + return backoffice.GroupInfo{}, fmt.Errorf("%s err doReq: %w", op, err) + } + + var response backoffice.GroupInfo + err = json.NewDecoder(data.Body).Decode(&response) + if err != nil { + return backoffice.GroupInfo{}, fmt.Errorf("%s err decode json: %w", op, err) + } + + return response, nil +} diff --git a/internal/lib/backoffice/kidView.go b/internal/lib/backoffice/kidView.go new file mode 100644 index 0000000..fe4382a --- /dev/null +++ b/internal/lib/backoffice/kidView.go @@ -0,0 +1,31 @@ +package backoffice + +import ( + backoffice2 "algobot/internal/domain/backoffice" + "encoding/json" + "fmt" +) + +func (bo *Backoffice) KidView(kidID string, cookie string) (backoffice2.KidView, error) { + const op = "backoffice.KidView" + + req, err := bo.createReq("GET", "/api/v2/student/default/view/"+kidID, cookie, map[string]string{ + "expand": "groups", + }, nil) + + if err != nil { + return backoffice2.KidView{}, fmt.Errorf("%s createReq err: %w", op, err) + } + data, err := bo.doReq(req) + if err != nil { + return backoffice2.KidView{}, fmt.Errorf("%s do req err: %w", op, err) + } + + var response backoffice2.KidView + err = json.NewDecoder(data.Body).Decode(&response) + if err != nil { + return backoffice2.KidView{}, fmt.Errorf("%s decode res err: %w", op, err) + } + + return response, nil +} diff --git a/internal/lib/backoffice/kidsMessages.go b/internal/lib/backoffice/kidsMessages.go new file mode 100644 index 0000000..022ae18 --- /dev/null +++ b/internal/lib/backoffice/kidsMessages.go @@ -0,0 +1,32 @@ +package backoffice + +import ( + "algobot/internal/domain/backoffice" + "encoding/json" + "fmt" +) + +func (bo *Backoffice) KidsMessages(cookie string) (backoffice.KidsMessages, error) { + const op = "backoffice.KidsMessages" + + req, err := bo.createReq("GET", "/api/v1/teacherComment/projects", cookie, map[string]string{ + "from": "0", + "limit": "30", + }, nil) + if err != nil { + return backoffice.KidsMessages{}, fmt.Errorf("%s err create req: %w", op, err) + } + + data, reqErr := bo.doReq(req) + if reqErr != nil { + return backoffice.KidsMessages{}, fmt.Errorf("%s err doReq: %w", op, err) + } + + var response backoffice.KidsMessages + err = json.NewDecoder(data.Body).Decode(&response) + if err != nil { + return backoffice.KidsMessages{}, fmt.Errorf("%s err decode json: %w", op, err) + } + + return response, nil +} diff --git a/internal/lib/backoffice/kidsNamesByGroup.go b/internal/lib/backoffice/kidsNamesByGroup.go new file mode 100644 index 0000000..f21a903 --- /dev/null +++ b/internal/lib/backoffice/kidsNamesByGroup.go @@ -0,0 +1,32 @@ +package backoffice + +import ( + "algobot/internal/domain/backoffice" + "encoding/json" + "fmt" +) + +func (bo *Backoffice) KidsNamesByGroup(groupId string, cookie string) (backoffice.NamesByGroup, error) { + const op = "backoffice.KidsNamesByGroup" + + req, err := bo.createReq("GET", "/api/v2/group/student/index", cookie, map[string]string{ + "groupId": groupId, + "expand": "lastGroup, groups", + }, nil) + if err != nil { + return backoffice.NamesByGroup{}, fmt.Errorf("%s cant create req: %w", op, err) + } + + data, reqErr := bo.doReq(req) + if reqErr != nil { + return backoffice.NamesByGroup{}, fmt.Errorf("%s cant do req: %w", op, err) + } + + var response backoffice.NamesByGroup + err = json.NewDecoder(data.Body).Decode(&response) + if err != nil { + return backoffice.NamesByGroup{}, fmt.Errorf("%s cant decode res: %w", op, err) + } + + return response, nil +} diff --git a/internal/lib/backoffice/kidsStats.go b/internal/lib/backoffice/kidsStats.go new file mode 100644 index 0000000..897dcf6 --- /dev/null +++ b/internal/lib/backoffice/kidsStats.go @@ -0,0 +1,32 @@ +package backoffice + +import ( + "algobot/internal/domain/backoffice" + "encoding/json" + "fmt" + "strconv" +) + +func (bo *Backoffice) KidsStats(cookie string, groupID int) (backoffice.KidsStats, error) { + const op = "backoffice.KidsStats" + + req, err := bo.createReq("GET", "/api/v1/stats/default/attendance", cookie, map[string]string{ + "group": strconv.Itoa(groupID), + }, nil) + if err != nil { + return backoffice.KidsStats{}, fmt.Errorf("%s err createReq: %w", op, err) + } + + data, reqErr := bo.doReq(req) + if reqErr != nil { + return backoffice.KidsStats{}, fmt.Errorf("%s err doReq: %w", op, err) + } + + var response backoffice.KidsStats + err = json.NewDecoder(data.Body).Decode(&response) + if err != nil { + return backoffice.KidsStats{}, fmt.Errorf("%s err while docding: %w", op, err) + } + + return response, nil +} diff --git a/internal/lib/backoffice/lession-status.go b/internal/lib/backoffice/lession-status.go new file mode 100644 index 0000000..8bdf4d7 --- /dev/null +++ b/internal/lib/backoffice/lession-status.go @@ -0,0 +1,54 @@ +package backoffice + +import ( + "fmt" + "net/url" + "strings" +) + +func (bo *Backoffice) OpenLesson(cookie, group, lession string) error { + const op = "backoffice.OpenLesson" + + params := url.Values{} + params.Add("ajaxUrl", "/api/v2/group/lesson/status") + params.Add("btnClass", "btn btn-xs btn-danger") + params.Add("status", "10") + params.Add("lessonId", lession) + params.Add("groupId", group) + query := params.Encode() + + req, err := bo.createReq("POST", "/api/v2/group/lesson/status", cookie, map[string]string{}, strings.NewReader(query)) + if err != nil { + return fmt.Errorf("%s err createReq: %w", op, err) + } + _, reqErr := bo.doReq(req) + if reqErr != nil { + return fmt.Errorf("%s err doReq: %w", op, err) + } + + return nil +} + +func (bo *Backoffice) CloseLesson(cookie, group, lession string) error { + const op = "backoffice.CloseLesson" + + params := url.Values{} + params.Add("ajaxUrl", "/api/v2/group/lesson/status") + params.Add("btnClass", "btn btn-xs btn-danger") + params.Add("status", "0") + params.Add("lessonId", lession) + params.Add("groupId", group) + query := params.Encode() + + req, err := bo.createReq("POST", "/api/v2/group/lesson/status", cookie, map[string]string{}, strings.NewReader(query)) + if err != nil { + return fmt.Errorf("%s err createReq: %w", op, err) + } + + _, reqErr := bo.doReq(req) + if reqErr != nil { + return fmt.Errorf("%s err doReq: %w", op, err) + } + + return nil +} diff --git a/internal/lib/fsm/fsm.go b/internal/lib/fsm/fsm.go new file mode 100644 index 0000000..2c8fe97 --- /dev/null +++ b/internal/lib/fsm/fsm.go @@ -0,0 +1,9 @@ +package fsm + +type State int + +const ( + Default State = iota + SendingCookie + ChattingAI +) diff --git a/internal/lib/fsm/memory/fsm.go b/internal/lib/fsm/memory/fsm.go new file mode 100644 index 0000000..3a49f82 --- /dev/null +++ b/internal/lib/fsm/memory/fsm.go @@ -0,0 +1,36 @@ +package memory + +import ( + "algobot/internal/lib/fsm" + "sync" +) + +type Memory struct { + mu sync.Mutex + statements map[int64]fsm.State +} + +func New() *Memory { + return &Memory{ + statements: make(map[int64]fsm.State), + } +} + +func (m *Memory) SetState(uid int64, statement fsm.State) { + m.mu.Lock() + defer m.mu.Unlock() + + m.statements[uid] = statement +} + +func (m *Memory) State(uid int64) fsm.State { + m.mu.Lock() + defer m.mu.Unlock() + + v, ok := m.statements[uid] + if ok { + return v + } + m.statements[uid] = fsm.Default + return m.statements[uid] +} diff --git a/internal/lib/logger/handlers/slogpretty/slogpretty.go b/internal/lib/logger/handlers/slogpretty/slogpretty.go new file mode 100644 index 0000000..120b2de --- /dev/null +++ b/internal/lib/logger/handlers/slogpretty/slogpretty.go @@ -0,0 +1,256 @@ +package slogpretty + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "log/slog" + "os" + "strconv" + "strings" + "sync" +) + +const ( + timeFormat = "2006-01-02 15:04:05.000" + + reset = "\033[0m" + + black = 30 + red = 31 + green = 32 + yellow = 33 + blue = 34 + magenta = 35 + cyan = 36 + lightGray = 37 + darkGray = 90 + lightRed = 91 + lightGreen = 92 + lightYellow = 93 + lightBlue = 94 + lightMagenta = 95 + lightCyan = 96 + white = 97 +) + +func colorizer(colorCode int, v string) string { + return fmt.Sprintf("\033[%sm%s%s", strconv.Itoa(colorCode), v, reset) +} + +type Handler struct { + h slog.Handler + r func([]string, slog.Attr) slog.Attr + b *bytes.Buffer + m *sync.Mutex + writer io.Writer + colorize bool + outputEmptyAttrs bool +} + +func (h *Handler) Enabled(ctx context.Context, level slog.Level) bool { + return h.h.Enabled(ctx, level) +} + +func (h *Handler) WithAttrs(attrs []slog.Attr) slog.Handler { + return &Handler{h: h.h.WithAttrs(attrs), b: h.b, r: h.r, m: h.m, writer: h.writer, colorize: h.colorize} +} + +func (h *Handler) WithGroup(name string) slog.Handler { + return &Handler{h: h.h.WithGroup(name), b: h.b, r: h.r, m: h.m, writer: h.writer, colorize: h.colorize} +} + +func (h *Handler) computeAttrs( + ctx context.Context, + r slog.Record, +) (map[string]any, error) { + h.m.Lock() + defer func() { + h.b.Reset() + h.m.Unlock() + }() + if err := h.h.Handle(ctx, r); err != nil { + return nil, fmt.Errorf("error when calling inner handler's Handle: %w", err) + } + + var attrs map[string]any + err := json.Unmarshal(h.b.Bytes(), &attrs) + if err != nil { + return nil, fmt.Errorf("error when unmarshaling inner handler's Handle result: %w", err) + } + return attrs, nil +} + +func (h *Handler) Handle(ctx context.Context, r slog.Record) error { + colorize := func(code int, value string) string { + return value + } + if h.colorize { + colorize = colorizer + } + + var level string + levelAttr := slog.Attr{ + Key: slog.LevelKey, + Value: slog.AnyValue(r.Level), + } + if h.r != nil { + levelAttr = h.r([]string{}, levelAttr) + } + + var debugColor = white + if !levelAttr.Equal(slog.Attr{}) { + level = levelAttr.Value.String() + + if r.Level <= slog.LevelDebug { + level = colorize(lightBlue, level) // Debug level + debugColor = lightBlue + } else if r.Level <= slog.LevelInfo { + level = colorize(white, level) + " " + debugColor = white + } else if r.Level < slog.LevelWarn { + level = colorize(yellow, level) + debugColor = yellow + } else if r.Level < slog.LevelError { // Warn level + level = colorize(lightYellow, level) + " " + debugColor = lightYellow + } else if r.Level <= slog.LevelError+1 { + level = colorize(lightRed, level) + debugColor = lightRed + } else if r.Level > slog.LevelError+1 { + level = colorize(lightMagenta, level) + debugColor = lightMagenta + } + } + + var timestamp string + timeAttr := slog.Attr{ + Key: slog.TimeKey, + Value: slog.StringValue(r.Time.Format(timeFormat)), + } + if h.r != nil { + timeAttr = h.r([]string{}, timeAttr) + } + if !timeAttr.Equal(slog.Attr{}) { + timestamp = colorize(lightGreen, timeAttr.Value.String()) + } + + var msg string + msgAttr := slog.Attr{ + Key: slog.MessageKey, + Value: slog.StringValue(r.Message), + } + if h.r != nil { + msgAttr = h.r([]string{}, msgAttr) + } + if !msgAttr.Equal(slog.Attr{}) { + msg = colorize(debugColor, msgAttr.Value.String()) + } + + attrs, err := h.computeAttrs(ctx, r) + if err != nil { + return err + } + + var attrsAsBytes []byte + if h.outputEmptyAttrs || len(attrs) > 0 { + if r.Level >= slog.LevelWarn { + attrsAsBytes, err = json.MarshalIndent(attrs, "", " ") + } else { + attrsAsBytes, err = json.Marshal(attrs) + } + if err != nil { + return fmt.Errorf("error when marshaling attrs: %w", err) + } + } + + out := strings.Builder{} + if len(timestamp) > 0 { + out.WriteString(timestamp) + out.WriteString(" | ") + } + if len(level) > 0 { + out.WriteString(level) + out.WriteString(" | ") + } + if len(msg) > 0 { + out.WriteString(msg) + out.WriteString(" ") + } + if len(attrsAsBytes) > 0 { + out.WriteString(colorize(darkGray, string(attrsAsBytes))) + } + + _, err = io.WriteString(h.writer, out.String()+"\n") + if err != nil { + return err + } + + return nil +} + +func suppressDefaults( + next func([]string, slog.Attr) slog.Attr, +) func([]string, slog.Attr) slog.Attr { + return func(groups []string, a slog.Attr) slog.Attr { + if a.Key == slog.TimeKey || + a.Key == slog.LevelKey || + a.Key == slog.MessageKey { + return slog.Attr{} + } + if next == nil { + return a + } + return next(groups, a) + } +} + +func New(handlerOptions *slog.HandlerOptions, options ...Option) *Handler { + if handlerOptions == nil { + handlerOptions = &slog.HandlerOptions{} + } + + buf := &bytes.Buffer{} + handler := &Handler{ + b: buf, + h: slog.NewJSONHandler(buf, &slog.HandlerOptions{ + Level: handlerOptions.Level, + AddSource: handlerOptions.AddSource, + ReplaceAttr: suppressDefaults(handlerOptions.ReplaceAttr), + }), + r: handlerOptions.ReplaceAttr, + m: &sync.Mutex{}, + } + + for _, opt := range options { + opt(handler) + } + + return handler +} + +func NewHandler(opts *slog.HandlerOptions) *Handler { + return New(opts, WithDestinationWriter(os.Stdout), WithColor(), WithOutputEmptyAttrs()) +} + +type Option func(h *Handler) + +func WithDestinationWriter(writer io.Writer) Option { + return func(h *Handler) { + h.writer = writer + } +} + +func WithColor() Option { + return func(h *Handler) { + h.colorize = true + } +} + +func WithOutputEmptyAttrs() Option { + return func(h *Handler) { + h.outputEmptyAttrs = true + } +} diff --git a/internal/lib/logger/sl/sl.go b/internal/lib/logger/sl/sl.go new file mode 100644 index 0000000..ac19308 --- /dev/null +++ b/internal/lib/logger/sl/sl.go @@ -0,0 +1,10 @@ +package sl + +import "log/slog" + +func Err(err error) slog.Attr { + return slog.Attr{ + Key: "error", + Value: slog.StringValue(err.Error()), + } +} diff --git a/internal/lib/mappers/bo-to-svc.go b/internal/lib/mappers/bo-to-svc.go new file mode 100644 index 0000000..a61266b --- /dev/null +++ b/internal/lib/mappers/bo-to-svc.go @@ -0,0 +1,65 @@ +package mappers + +import ( + "algobot/internal/domain/backoffice" + "algobot/internal/domain/models" +) + +func MapKid(item backoffice.Student) models.GroupKid { + m := models.GroupKid{} + m.ID = item.ID + m.FullName = item.FullName + m.LastGroup = MapGroup(item.LastGroup) + + return m +} + +func MapGroup(group backoffice.Group) models.KidGroup { + m := models.KidGroup{} + m.ID = group.ID + m.StartTime = group.StartTime + m.EndTime = group.EndTime + + return m +} + +func MapKidView(view backoffice.KidView) models.KidView { + m := models.KidView{} + m.Kid.FullName = view.Data.FullName + m.Kid.ParentName = view.Data.ParentName + m.Kid.Email = view.Data.Email + m.Kid.Phone = view.Data.Phone + m.Kid.Age = view.Data.Age + m.Kid.BirthDate = view.Data.BirthDate + m.Kid.Username = view.Data.Username + m.Kid.Password = view.Data.Password + + m.Kid.Groups = make([]models.KidViewGroup, len(view.Data.Groups)) + for i, group := range view.Data.Groups { + m.Kid.Groups[i] = models.KidViewGroup{ + ID: group.ID, + Title: group.Title, + Content: group.Content, + Status: group.Status, + StartTime: group.StartTime, + EndTime: group.EndTime, + } + } + + return m +} + +func MapGroups(groups []backoffice.Group) []models.KidViewGroup { + mappedGroups := make([]models.KidViewGroup, len(groups)) + for i, group := range groups { + mappedGroups[i] = models.KidViewGroup{ + ID: group.ID, + Title: group.Title, + Content: group.Content, + Status: group.Status, + StartTime: group.StartTime, + EndTime: group.EndTime, + } + } + return mappedGroups +} diff --git a/internal/lib/serdes/base62/base62.go b/internal/lib/serdes/base62/base62.go new file mode 100644 index 0000000..d9893c7 --- /dev/null +++ b/internal/lib/serdes/base62/base62.go @@ -0,0 +1,56 @@ +package base62 + +import ( + "algobot/internal/domain" + "fmt" + "github.com/jxskiss/base62" + "log/slog" + "strconv" + "strings" +) + +type Serdes struct { + log *slog.Logger +} + +func NewSerdes(log *slog.Logger) *Serdes { + return &Serdes{log: log} +} + +func (s *Serdes) Serialize(msg domain.SerializeMessage) (string, error) { + encoded := base62.EncodeToString([]byte(fmt.Sprintf( + "%d-%s", + msg.Type, + strings.Join(msg.Data, ","), + ))) + return encoded, nil +} + +func (s *Serdes) Deserialize(decoded string) (*domain.SerializeMessage, error) { + const op = "serdes.Deserialize" + log := s.log.With( + slog.String("op", op), + ) + + encoded, err := base62.DecodeString(decoded) + if err != nil { + log.Warn("Failed to decode serdes") + return nil, fmt.Errorf("%s: %w", op, err) + } + + encodedMsg := strings.Split(string(encoded), "-") + encodedType := encodedMsg[0] + encodedData := strings.Split(encodedMsg[1], ",") + + encodedID, err := strconv.Atoi(encodedType) + if err != nil { + log.Warn("Failed to decode serdes") + return nil, fmt.Errorf("%s: %w", op, err) + } + serType := domain.SerType(encodedID) + + return &domain.SerializeMessage{ + Type: serType, + Data: encodedData, + }, nil +} diff --git a/internal/lib/serdes/group.go b/internal/lib/serdes/group.go new file mode 100644 index 0000000..62370c3 --- /dev/null +++ b/internal/lib/serdes/group.go @@ -0,0 +1,7 @@ +package serdes + +import "errors" + +var ( + ErrUnrecognized = errors.New("unrecognized sertype") +) diff --git a/internal/lib/sort/groups.go b/internal/lib/sort/groups.go new file mode 100644 index 0000000..c40eab2 --- /dev/null +++ b/internal/lib/sort/groups.go @@ -0,0 +1,17 @@ +package sort + +import ( + "algobot/internal/domain/models" + "sort" +) + +func GroupsByDate(groups []models.Group) { + sort.Slice(groups, func(i, j int) bool { + dayI, dayJ := groups[i].TimeLesson.Weekday(), groups[j].TimeLesson.Weekday() + if dayI != dayJ { + return dayI > dayJ + } + return groups[i].TimeLesson.Hour() < groups[j].TimeLesson.Hour() || + (groups[i].TimeLesson.Hour() == groups[j].TimeLesson.Hour() && groups[i].TimeLesson.Minute() < groups[j].TimeLesson.Minute()) + }) +} diff --git a/internal/middleware/logger.go b/internal/middleware/logger.go deleted file mode 100644 index 4ab85c2..0000000 --- a/internal/middleware/logger.go +++ /dev/null @@ -1,18 +0,0 @@ -package middleware - -import ( - tele "gopkg.in/telebot.v4" - "log" -) - -func MessageLogger(next tele.HandlerFunc) tele.HandlerFunc { - return func(c tele.Context) error { - if c.Callback() != nil { - log.Printf("[CLB] %s, запрос \"%s\"\n", c.Callback().Sender.FirstName+c.Callback().Sender.LastName, c.Callback().Data) - } else { - log.Printf("[MSG] %s, запрос \"%s\"\n", c.Message().Sender.FirstName+c.Message().Sender.LastName, c.Message().Text) - } - - return next(c) - } -} diff --git a/internal/middleware/register.go b/internal/middleware/register.go deleted file mode 100644 index be49274..0000000 --- a/internal/middleware/register.go +++ /dev/null @@ -1,38 +0,0 @@ -package middleware - -import ( - "errors" - "gopkg.in/telebot.v4" - "tgbot/internal/config" - appError "tgbot/internal/error" - "tgbot/internal/helpers" - "tgbot/internal/service" -) - -type Register struct { - svc service.Service -} - -func NewRegister(svc service.Service) *Register { - return &Register{svc: svc} -} - -func (r *Register) Middleware(next telebot.HandlerFunc) telebot.HandlerFunc { - return func(context telebot.Context) error { - uid := context.Sender().ID - - reg, err := r.svc.IsUserRegistered(uid) - if err != nil && !errors.Is(err, appError.ErrNotFound) { - return helpers.LogError(err, context, "Произошла ошибка при проверки регистрации!") - } - if reg == false { - err := r.svc.RegisterUser(uid) - if err != nil { - return helpers.LogError(err, context, "Произошла ошибка при регистрации!") - } - context.Send(config.HelloWorld, config.StartKeyboard) - } - - return next(context) - } -} diff --git a/internal/models/models.go b/internal/models/models.go deleted file mode 100644 index 49fc349..0000000 --- a/internal/models/models.go +++ /dev/null @@ -1,81 +0,0 @@ -package models - -import ( - "tgbot/internal/clients" - "tgbot/internal/domain" - "time" -) - -type MissingKid struct { - Id int - Count int -} - -type ActualInformation struct { - LessonTitle string - LessonId int - MissingKids []MissingKid -} - -type KidData struct { - FullName string - Login string - Password string -} -type ScheduleData struct { - UID int64 - Cookie string -} - -type Message struct { - Id string - From string - Theme string - Type string - Link string - Content string -} - -type AllKids map[int]KidData - -type Group struct { - GroupID int - Title string - TimeLesson time.Time -} - -func GroupMap(domains []domain.Group) []Group { - gr := make([]Group, len(domains)) - for i, group := range domains { - gr[i] = mapGroup(group) - } - return gr -} - -func mapGroup(group domain.Group) Group { - return Group{ - GroupID: group.GroupID, - Title: group.Title, - TimeLesson: group.TimeLesson, - } -} - -type FullGroupInfo struct { - GroupID int - GroupTitle string - GroupContent string - NextLessonTime string - LessonsTotal int - LessonsPassed int - ActiveKids []clients.Student - NotActiveKids []clients.Student -} - -type ExtraInfo string - -var NotAccessible ExtraInfo = "not_accessible" - -type FullKidInfo struct { - Extra ExtraInfo - Kid clients.Student -} diff --git a/internal/models/startPayload.go b/internal/models/startPayload.go deleted file mode 100644 index 75739b8..0000000 --- a/internal/models/startPayload.go +++ /dev/null @@ -1,11 +0,0 @@ -package models - -type ActionType string - -var GetGroupInfo ActionType = "getGroupInfo" -var GetKidInfo ActionType = "getKidInfo" - -type StartPayload struct { - Action ActionType - Payload []string -} diff --git a/internal/schedulers/message.go b/internal/schedulers/message.go deleted file mode 100644 index 0e05928..0000000 --- a/internal/schedulers/message.go +++ /dev/null @@ -1,63 +0,0 @@ -package schedulers - -import ( - "fmt" - "gopkg.in/telebot.v4" - "log" - "strconv" - "strings" - "tgbot/internal/models" - "tgbot/internal/service" -) - -type Message struct { - b telebot.API - svc service.Service -} - -func NewMessage(b telebot.API, svc service.Service) *Message { - return &Message{b: b, svc: svc} -} - -func (m Message) Schedule() { - users, err := m.svc.UsersByNotif(true) - if err != nil { - log.Println(err) - } - for _, user := range users { - allMessages, err := m.svc.NewMessageByUID(user.UID) - if err != nil { - log.Println(err) - } - for _, msg := range allMessages { - if msg.Type == "img" { - p := &telebot.Photo{File: telebot.FromURL(msg.Content), Caption: getMsg(msg)} - m.b.Send(RecipientUser{strconv.FormatInt(user.UID, 10)}, p, telebot.ModeMarkdown, telebot.NoPreview) - } else { - m.b.Send(RecipientUser{strconv.FormatInt(user.UID, 10)}, getMsg(msg), telebot.ModeMarkdown, telebot.NoPreview) - } - } - } -} - -func getMsg(msg models.Message) string { - sb := strings.Builder{} - sb.WriteString("🔔 Новое сообщение\n\n") - sb.WriteString(fmt.Sprintf("От: %s\n", msg.From)) - sb.WriteString(fmt.Sprintf("Тема: %s\n", msg.Theme)) - sb.WriteString(fmt.Sprintf("Ссылка: %s\n\n", msg.Link)) - if msg.Type != "img" { - sb.WriteString("```Сообщение:\n") - sb.WriteString(msg.Content) - sb.WriteString("\n```") - } - return sb.String() -} - -type RecipientUser struct { - ID string -} - -func (r RecipientUser) Recipient() string { - return r.ID -} diff --git a/internal/serdes/simple.go b/internal/serdes/simple.go deleted file mode 100644 index c73dc93..0000000 --- a/internal/serdes/simple.go +++ /dev/null @@ -1,25 +0,0 @@ -package serdes - -import ( - "fmt" - "github.com/jxskiss/base62" - "strings" - "tgbot/internal/models" -) - -func Serialize(m models.StartPayload) string { - return base62.EncodeToString([]byte(fmt.Sprintf("%s-%s", m.Action, strings.Join(m.Payload, "-")))) -} -func Deserialize(s string) (models.StartPayload, error) { - decodeString, err := base62.DecodeString(s) - if err != nil { - return models.StartPayload{}, err - } - arr := strings.Split(string(decodeString), "-") - - m := models.StartPayload{ - Action: models.ActionType(arr[0]), - Payload: arr[1:], - } - return m, nil -} diff --git a/internal/service/AIService.go b/internal/service/AIService.go deleted file mode 100644 index fe157c4..0000000 --- a/internal/service/AIService.go +++ /dev/null @@ -1,55 +0,0 @@ -package service - -import ( - "context" - "fmt" - "google.golang.org/grpc" - "google.golang.org/grpc/credentials/insecure" - "log" - pkg "tgbot/protos" -) - -type AIService interface { - GetSuggestion(uid int64, text string) (string, error) - ClearAllHistory(uid int64) error -} - -type aiService struct { - grpc pkg.AiClient -} - -func NewAiService(port string) AIService { - conn, err := grpc.NewClient("localhost:"+port, grpc.WithTransportCredentials(insecure.NewCredentials())) - if err != nil { - log.Fatalf("did not connect: %v", err) - } - - return &aiService{ - grpc: pkg.NewAiClient(conn), - } -} - -func (a aiService) GetSuggestion(uid int64, text string) (string, error) { - ctx := context.Background() - suggest, err := a.grpc.GetSuggest(ctx, &pkg.SuggestRequest{ - Uid: uid, - Suggest: text, - }) - if err != nil { - return "", fmt.Errorf("aiService.GetSuggestion(%v, %v) : %w", uid, text, err) - } - - return suggest.GetRequest(), nil -} - -func (a aiService) ClearAllHistory(uid int64) error { - ctx := context.Background() - _, err := a.grpc.ClearHistory(ctx, &pkg.ClearHistoryRequest{ - Uid: uid, - }) - if err != nil { - return fmt.Errorf("aiService.ClearAllHistory(%v) : %w", uid, err) - } - - return nil -} diff --git a/internal/service/DefaultService.go b/internal/service/DefaultService.go deleted file mode 100644 index be2fe17..0000000 --- a/internal/service/DefaultService.go +++ /dev/null @@ -1,428 +0,0 @@ -package service - -import ( - "errors" - "fmt" - "log" - "regexp" - "strconv" - "strings" - "tgbot/internal/clients" - "tgbot/internal/domain" - appError "tgbot/internal/error" - "tgbot/internal/helpers" - "tgbot/internal/models" - "time" -) - -var dateMap map[string]string = map[string]string{ - "янв": "01", - "февр": "02", - "мар": "03", - "апр": "04", - "мая": "05", - "июн": "06", - "июл": "07", - "авг": "08", - "сент": "09", - "окт": "10", - "нояб": "11", - "дек": "12", -} - -type DefaultService struct { - domain domain.Domain - webClient clients.WebClient -} - -func NewDefaultService(domain domain.Domain, webClient clients.WebClient) *DefaultService { - return &DefaultService{domain: domain, webClient: webClient} -} - -func (d DefaultService) FullKidInfo(uid int64, kidID int, groupId int) (models.FullKidInfo, error) { - cookie, err := d.Cookie(uid) - if err != nil { - return models.FullKidInfo{}, fmt.Errorf("DefaultService.FullKidInfo(%d, %d) : %w", uid, kidID, err) - } - kid, err := d.webClient.GetKidInfo(cookie, strconv.Itoa(kidID)) - if err != nil { - if errors.Is(err, appError.ErrNotFound) { - info, err := d.webClient.GetKidsNamesByGroup(cookie, groupId) - if err != nil { - return models.FullKidInfo{}, fmt.Errorf("DefaultService.FullKidInfo(%d, %d) : %w", uid, kidID, err) - } - for _, item := range info.Data.Items { - if item.ID == kidID { - return models.FullKidInfo{ - Extra: models.NotAccessible, - Kid: item, - }, nil - } - } - } - return models.FullKidInfo{}, fmt.Errorf("DefaultService.FullKidInfo(%d, %d) : %w", uid, kidID, err) - } - - return models.FullKidInfo{ - Kid: kid.Data, - }, nil -} - -func (d DefaultService) UsersByNotif(status bool) ([]models.ScheduleData, error) { - notif := 0 - if status { - notif = 1 - } - - notification, err := d.domain.GetUsersByNotification(notif) - if err != nil { - return nil, fmt.Errorf("DefaultService.UserUidsByNotif(%v) : %w", status, err) - } - - data := make([]models.ScheduleData, len(notification)) - for i, user := range notification { - data[i] = models.ScheduleData{ - UID: user.UID, - Cookie: user.Cookie, - } - } - return data, nil -} - -func (d DefaultService) NewMessageByUID(uid int64) ([]models.Message, error) { - cookie, err := d.Cookie(uid) - if err != nil { - return nil, fmt.Errorf("DefaultService.NewMessageByUID(%d) : %w", uid, err) - } - messages, err := d.webClient.GetKidsMessages(cookie) - if err != nil { - return nil, fmt.Errorf("DefaultService.NewMessageByUID(%d) : %w", uid, err) - } - - var msgs []models.Message - lastNotif, err := d.domain.LastNotificationDate(uid) - var lastNotifData time.Time - if err != nil { - if !errors.Is(err, appError.ErrNotValid) { - return nil, fmt.Errorf("DefaultService.NewMessageByUID(%d) : %w", uid, err) - } - lastNotifData = time.Time{} - } else { - lastNotifData = parseDate(lastNotif) - } - var lastNotifString string - for i := len(messages.Data.Projects) - 1; i >= 0; i-- { - if messages.Data.Projects[i].SenderScope == "student" { - if lastNotifData.Before(parseDate(messages.Data.Projects[i].LastTime)) { - m := models.Message{ - Id: messages.Data.Projects[i].UID, - Type: messages.Data.Projects[i].Type, - From: messages.Data.Projects[i].Name, - Theme: messages.Data.Projects[i].Title, - Link: fmt.Sprintf("https://backoffice.algoritmika.org%s", messages.Data.Projects[i].Link), - Content: messages.Data.Projects[i].Content, - } - if m.Type == "img" { - m.Content = fmt.Sprintf("https://backoffice.algoritmika.org%s", m.Content) - } - msgs = append(msgs, m) - lastNotifString = messages.Data.Projects[i].LastTime - } - } - } - if lastNotifString != "" { - err := d.domain.SetLastNotificationDate(uid, lastNotifString) - if err != nil { - return nil, fmt.Errorf("DefaultService.NewMessageByUID(%d) : %w", uid, err) - } - } - - return msgs, nil -} - -func parseDate(lastTime string) time.Time { - parts := strings.Split(lastTime, " ") - - day := parts[0] - month := dateMap[strings.Replace(parts[1], ".", "", -1)] - - if len(parts) == 3 { - timeHour := parts[2] - - retTime, _ := time.Parse("2 01 2006 15:04", fmt.Sprintf("%s %s %d %s", day, month, time.Now().Year(), timeHour)) - return retTime - } - if len(parts) == 4 { - year := strings.Replace(parts[2], "`", "", -1) - year = strings.Replace(year, ",", "", -1) - timeHour := parts[3] - - retTime, _ := time.Parse("2 01 06 15:04", fmt.Sprintf("%s %s %s %s", day, month, year, timeHour)) - return retTime - } - return time.Date(0, 1, 1, 0, 0, 0, 0, time.UTC) -} - -func (d DefaultService) FullGroupInfo(uid int64, groupId int) (models.FullGroupInfo, error) { - cookie, err := d.Cookie(uid) - if err != nil { - return models.FullGroupInfo{}, fmt.Errorf("DefaultService.FullGroupInfo(%d, %d) : %w", uid, groupId, err) - } - retObject := models.FullGroupInfo{} - info, err := d.webClient.GetGroupInfo(cookie, strconv.Itoa(groupId)) - if err != nil { - return models.FullGroupInfo{}, fmt.Errorf("DefaultService.FullGroupInfo(%d, %d) : %w", uid, groupId, err) - } - retObject.GroupID = info.Data.ID - retObject.GroupTitle = info.Data.Title - retObject.GroupContent = info.Data.Content - retObject.NextLessonTime = info.Data.NextLessonTime - retObject.LessonsPassed = info.Data.LessonsPassed - retObject.LessonsTotal = info.Data.LessonsTotal - - names, err := d.webClient.GetKidsNamesByGroup(cookie, groupId) - if err != nil { - return models.FullGroupInfo{}, fmt.Errorf("DefaultService.FullGroupInfo(%d, %d) : %w", uid, groupId, err) - } - for _, item := range names.Data.Items { - if item.LastGroup.ID == groupId && item.LastGroup.Status == 0 { - retObject.ActiveKids = append(retObject.ActiveKids, item) - } else { - retObject.NotActiveKids = append(retObject.NotActiveKids, item) - } - } - - return retObject, nil -} - -func (d DefaultService) OpenLesson(uid int64, groupId int, lessonId int) error { - cookie, err := d.Cookie(uid) - if err != nil { - return fmt.Errorf("DefaultService.OpenLesson(%d, %d, %d) : %w", uid, lessonId, groupId, err) - } - err = d.webClient.OpenLession(cookie, strconv.Itoa(groupId), strconv.Itoa(lessonId)) - if err != nil { - return fmt.Errorf("DefaultService.OpenLesson(%d, %d, %d) : %w", uid, lessonId, groupId, err) - } - - return nil -} -func (d DefaultService) CloseLesson(uid int64, groupId int, lessonId int) error { - cookie, err := d.Cookie(uid) - if err != nil { - return fmt.Errorf("DefaultService.CloseLesson(%d, %d, %d) : %w", uid, lessonId, groupId, err) - } - err = d.webClient.CloseLession(cookie, strconv.Itoa(groupId), strconv.Itoa(lessonId)) - if err != nil { - return fmt.Errorf("DefaultService.CloseLesson(%d, %d, %d) : %w", uid, lessonId, groupId, err) - } - - return nil -} - -func (d DefaultService) AllCredentials(uid int64, groupId int) (map[string]string, error) { - names, err := d.AllKidsNames(uid, groupId) - if err != nil { - return nil, fmt.Errorf("DefaultService.AllCredentials(%d, %d) : %w", uid, groupId, err) - } - - creds := make(map[string]string, len(names)) - for _, kid := range names { - creds[kid.FullName] = fmt.Sprintf("%s:%s", kid.Login, kid.Password) - } - - return creds, nil -} - -func (d DefaultService) Groups(uid int64) ([]models.Group, error) { - data, err := d.domain.Groups(uid) - if err != nil { - return nil, fmt.Errorf("DefaultService.Groups(%d) : %w", uid, err) - } - return models.GroupMap(data), nil -} - -func (d DefaultService) Cookie(uid int64) (string, error) { - c, err := d.domain.Cookie(uid) - if err != nil { - if errors.Is(err, appError.ErrNotValid) { - return "", nil - } - return "", fmt.Errorf("DefaultService.Cookie(%d) : %w", uid, err) - } - return c, nil -} -func (d DefaultService) SetCookie(uid int64, cookie string) error { - err := d.domain.SetCookie(uid, cookie) - if err != nil { - return fmt.Errorf("DefaultService.SetCookie(%d, %s) : %w", uid, cookie, err) - } - return nil -} - -func (d DefaultService) Notification(uid int64) (bool, error) { - n, err := d.domain.Notification(uid) - if err != nil { - if errors.Is(err, appError.ErrNotValid) { - return false, nil - } - return false, fmt.Errorf("DefaultService.Notification(%d) : %w", uid, err) - } - return n, nil -} -func (d DefaultService) SetNotification(uid int64, notification bool) error { - err := d.domain.SetNotification(uid, notification) - if err != nil { - return fmt.Errorf("DefaultService.SetNotification(%d, %v) : %w", uid, notification, err) - } - return nil -} - -func (d DefaultService) CurrentGroup(uid int64, t time.Time) (models.Group, error) { - allGroups, err := d.Groups(uid) - if err != nil { - return models.Group{}, fmt.Errorf("DefaultService.CurrentGroup(%d, %v) : %w", uid, t, err) - } - - group, err := helpers.GetCurrentGroup(t, allGroups) - if err != nil { - return models.Group{}, fmt.Errorf("DefaultService.CurrentGroup(%d, %v) : %w", uid, t, err) - } - - return group, nil -} -func (d DefaultService) ActualInformation(uid int64, t time.Time, groupId int) (models.ActualInformation, error) { - cookie, err := d.Cookie(uid) - if err != nil { - return models.ActualInformation{}, fmt.Errorf("DefaultService.ActualInformation(%d, %v, %d) : %w", uid, t, groupId, err) - } - stats, err := d.webClient.GetKidsStatsByGroup(cookie, strconv.Itoa(groupId)) - if err != nil { - return models.ActualInformation{}, fmt.Errorf("DefaultService.ActualInformation(%d, %v, %d) : %w", uid, t, groupId, err) - } - - actual := models.ActualInformation{} - for _, datum := range stats.Data { - studentID := datum.StudentID - count := 0 - for _, attendance := range datum.Attendance { - count++ - if attendance.Status != "absent" { - count = 0 - } - if matchDates(attendance.StartTimeFormatted, t) { - actual.LessonTitle = attendance.LessonTitle - actual.LessonId = attendance.LessonID - - if attendance.Status == "absent" { - actual.MissingKids = append(actual.MissingKids, models.MissingKid{ - Id: studentID, - Count: count, - }) - break - } - } - } - } - - return actual, nil -} -func (d DefaultService) AllKidsNames(uid int64, groupId int) (models.AllKids, error) { - cookie, err := d.Cookie(uid) - if err != nil { - return nil, fmt.Errorf("DefaultService.AllKidsNames(%d, %d) : %w", uid, groupId, err) - } - group, err := d.webClient.GetKidsNamesByGroup(cookie, groupId) - if err != nil { - return nil, fmt.Errorf("DefaultService.AllKidsNames(%d, %d) : %w", uid, groupId, err) - } - - names := make(map[int]models.KidData, len(group.Data.Items)) - for _, datum := range group.Data.Items { - if datum.LastGroup.ID == groupId && datum.LastGroup.Status == 0 { - names[datum.ID] = models.KidData{ - FullName: datum.FullName, - Login: datum.Username, - Password: datum.Password, - } - } - } - - return names, nil -} - -func (d DefaultService) RefreshGroups(uid int64) error { - cookie, err := d.Cookie(uid) - if err != nil { - return fmt.Errorf("DefaultService.RefreshGroups(%d) : %w", uid, err) - } - groups, err := d.webClient.GetAllGroupsByUser(cookie) - if err != nil { - return fmt.Errorf("DefaultService.RefreshGroups(%d) : %w", uid, err) - } - - groupsFormatted := make([]domain.Group, len(groups)) - for i, group := range groups { - groupIdStr := group.GroupId - groupIdInt, err := strconv.Atoi(groupIdStr) - - if err != nil { - return fmt.Errorf("DefaultService.RefreshGroups(%d) : %w", uid, err) - } - - groupsFormatted[i] = domain.Group{ - GroupID: groupIdInt, - Title: group.Title, - TimeLesson: getTime(group.TimeLesson), - } - } - - if len(groupsFormatted) == 0 { - return fmt.Errorf("DefaultService.RefreshGroups(%d) : %w", uid, appError.ErrHasNone) - } - err = d.domain.SetGroups(uid, groupsFormatted) - if err != nil { - return fmt.Errorf("DefaultService.RefreshGroups(%d) : %w", uid, err) - } - - return nil -} - -func (d DefaultService) RegisterUser(uid int64) error { - err := d.domain.RegisterUser(uid) - if err != nil { - return fmt.Errorf("DefaultService.RegisterUser(%d) : %w", uid, err) - } - return nil -} -func (d DefaultService) IsUserRegistered(uid int64) (bool, error) { - _, err := d.domain.User(uid) - if err != nil { - if errors.Is(err, appError.ErrNotValid) { - return false, nil - } - return false, fmt.Errorf("DefaultService.IsUserRegistered(%d) : %w", uid, err) - } - return true, nil -} - -func matchDates(timeStr string, t time.Time) bool { - timeStr = regexp.MustCompile(`^[а-яА-Я]+(\s+)?`).ReplaceAllString(timeStr, "") - timeFormatted, err := time.Parse("02.01.06 15:04", timeStr) - if err != nil { - log.Printf("Cant convert date str to Time - '%s'\n", timeStr) - return false - } - - if t.YearDay() == timeFormatted.YearDay() { - return true - } - return false -} -func getTime(lesson string) time.Time { - parse, err := time.Parse("02.01.2006 15:04", lesson) - if err != nil { - return time.Time{} - } - return parse -} diff --git a/internal/service/service.go b/internal/service/service.go deleted file mode 100644 index 6e49af1..0000000 --- a/internal/service/service.go +++ /dev/null @@ -1,27 +0,0 @@ -package service - -import ( - "tgbot/internal/models" - "time" -) - -type Service interface { - CurrentGroup(uid int64, t time.Time) (models.Group, error) - Groups(uid int64) ([]models.Group, error) - Cookie(uid int64) (string, error) - SetCookie(uid int64, cookie string) error - Notification(uid int64) (bool, error) - SetNotification(uid int64, notification bool) error - IsUserRegistered(uid int64) (bool, error) - RegisterUser(uid int64) error - RefreshGroups(uid int64) error - ActualInformation(uid int64, t time.Time, groupId int) (models.ActualInformation, error) - AllKidsNames(uid int64, groupId int) (models.AllKids, error) - OpenLesson(uid int64, lessonId int, groupId int) error - CloseLesson(uid int64, lessonId int, groupId int) error - AllCredentials(uid int64, groupId int) (map[string]string, error) - UsersByNotif(status bool) ([]models.ScheduleData, error) - NewMessageByUID(uid int64) ([]models.Message, error) - FullGroupInfo(uid int64, groupId int) (models.FullGroupInfo, error) - FullKidInfo(uid int64, kidID int, groupId int) (models.FullKidInfo, error) -} diff --git a/internal/services/backoffice/backoffice.go b/internal/services/backoffice/backoffice.go new file mode 100644 index 0000000..b191908 --- /dev/null +++ b/internal/services/backoffice/backoffice.go @@ -0,0 +1,20 @@ +package backoffice + +import "log/slog" + +type CookieGetter interface { + Cookies(uid int64) (string, error) +} + +type Backoffice struct { + log *slog.Logger + cookieGetter CookieGetter + groupView GroupView + kidViewer KidViewer + lessonStatuser LessonStatuser + msgFetcher MessageFetcher +} + +func NewBackoffice(log *slog.Logger, cookieGetter CookieGetter, groupView GroupView, kidViewer KidViewer, lessonStatuser LessonStatuser, msgFetcher MessageFetcher) *Backoffice { + return &Backoffice{log: log, cookieGetter: cookieGetter, groupView: groupView, kidViewer: kidViewer, lessonStatuser: lessonStatuser, msgFetcher: msgFetcher} +} diff --git a/internal/services/backoffice/creds.go b/internal/services/backoffice/creds.go new file mode 100644 index 0000000..dd83a72 --- /dev/null +++ b/internal/services/backoffice/creds.go @@ -0,0 +1,37 @@ +package backoffice + +import ( + "algobot/internal/domain/models" + "algobot/internal/lib/logger/sl" + "fmt" + "log/slog" +) + +func (bo *Backoffice) Creds(uid int64, groupID string, traceID interface{}) ([]models.Credential, error) { + const op = "services.backoffice.Creds" + log := bo.log.With( + slog.String("op", op), + slog.Any("trace_id", traceID), + ) + + cookie, err := bo.cookieGetter.Cookies(uid) + if err != nil { + log.Warn("failed to get cookies", sl.Err(err)) + return nil, fmt.Errorf("%s failed to get cookies: %w", op, err) + } + group, err := bo.groupView.KidsNamesByGroup(groupID, cookie) + if err != nil { + return nil, fmt.Errorf("%s failed to get KidsNamesByGroup: %w", op, err) + } + + creds := make([]models.Credential, len(group.Data.Items)) + for i, item := range group.Data.Items { + creds[i] = models.Credential{ + Fullname: item.FullName, + Login: item.Username, + Password: item.Password, + } + } + + return creds, nil +} diff --git a/internal/services/backoffice/groupView.go b/internal/services/backoffice/groupView.go new file mode 100644 index 0000000..2353edb --- /dev/null +++ b/internal/services/backoffice/groupView.go @@ -0,0 +1,63 @@ +package backoffice + +import ( + "algobot/internal/domain/backoffice" + "algobot/internal/domain/models" + "algobot/internal/lib/logger/sl" + "algobot/internal/lib/mappers" + "fmt" + "log/slog" + "strconv" +) + +type GroupView interface { + GroupView(groupID string, cookie string) (backoffice.GroupInfo, error) + KidsNamesByGroup(groupId string, cookie string) (backoffice.NamesByGroup, error) +} + +func (bo *Backoffice) GroupView(uid int64, groupID string, traceID interface{}) (models.GroupView, error) { + const op = "services.backoffice.GetGroupView" + log := bo.log.With( + slog.String("op", op), + slog.Any("traceID", traceID), + ) + + cookie, err := bo.cookieGetter.Cookies(uid) + if err != nil { + log.Warn("failed to get cookies", sl.Err(err)) + return models.GroupView{}, fmt.Errorf("%s failed to get cookies: %w", op, err) + } + + grView, err := bo.groupView.GroupView(groupID, cookie) + if err != nil { + log.Warn("failed to group view", sl.Err(err)) + return models.GroupView{}, fmt.Errorf("%s failed to group view: %w", op, err) + } + kidsNames, err := bo.groupView.KidsNamesByGroup(groupID, cookie) + if err != nil { + log.Warn("failed to get kids names", sl.Err(err)) + return models.GroupView{}, fmt.Errorf("%s failed to get kids names: %w", op, err) + } + + return mapResp(grView, kidsNames, groupID), nil +} + +func mapResp(info backoffice.GroupInfo, names backoffice.NamesByGroup, groupID string) models.GroupView { + m := models.GroupView{} + m.GroupID = info.Data.ID + m.GroupTitle = info.Data.Title + m.GroupContent = info.Data.Content + m.NextLessonTime = info.Data.NextLessonTime + m.LessonsPassed = info.Data.LessonsPassed + m.LessonsTotal = info.Data.LessonsTotal + + for _, item := range names.Data.Items { + if strconv.Itoa(item.LastGroup.ID) == groupID && item.LastGroup.Status == 0 { + m.ActiveKids = append(m.ActiveKids, mappers.MapKid(item)) + } else { + m.NotActiveKids = append(m.NotActiveKids, mappers.MapKid(item)) + } + } + + return m +} diff --git a/internal/services/backoffice/kidView.go b/internal/services/backoffice/kidView.go new file mode 100644 index 0000000..95443b6 --- /dev/null +++ b/internal/services/backoffice/kidView.go @@ -0,0 +1,65 @@ +package backoffice + +import ( + backoffice2 "algobot/internal/domain/backoffice" + "algobot/internal/domain/models" + "algobot/internal/lib/backoffice" + "algobot/internal/lib/logger/sl" + "algobot/internal/lib/mappers" + "errors" + "fmt" + "log/slog" + "strconv" +) + +type KidViewer interface { + KidView(kidID string, cookie string) (backoffice2.KidView, error) + KidsNamesByGroup(groupId string, cookie string) (backoffice2.NamesByGroup, error) +} + +func (bo *Backoffice) KidView(uid int64, kidID string, groupId string, traceID interface{}) (models.KidView, error) { + const op = "services.backoffice.KidView" + log := bo.log.With( + slog.String("op", op), + slog.Any("traceID", traceID), + ) + + cookie, err := bo.cookieGetter.Cookies(uid) + if err != nil { + log.Warn("failed to get cookies", sl.Err(err)) + return models.KidView{}, fmt.Errorf("%s failed to get cookies: %w", op, err) + } + + view, err := bo.kidViewer.KidView(kidID, cookie) + if err != nil { + if errors.Is(err, backoffice.ErrNotFound) { // TODO : maybe refactor into single request + info, err := bo.kidViewer.KidsNamesByGroup(groupId, cookie) + if err != nil { + log.Warn("failed to get kids names by group", sl.Err(err)) + return models.KidView{}, fmt.Errorf("%s failed to get kids names by group: %w", op, err) + } + for _, item := range info.Data.Items { + if strconv.Itoa(item.ID) == kidID { + return models.KidView{ + Extra: models.NotAccessible, + Kid: models.Kid{ + FullName: item.FullName, + ParentName: item.ParentName, + Email: item.Email, + Phone: item.Phone, + Age: item.Age, + BirthDate: item.BirthDate, + Username: item.Username, + Password: item.Password, + Groups: mappers.MapGroups(item.Groups), + }, + }, nil + } + } + } + log.Warn("failed to kid view", sl.Err(err)) + return models.KidView{}, fmt.Errorf("%s failed to kid view: %w", op, err) + } + + return mappers.MapKidView(view), nil +} diff --git a/internal/services/backoffice/lesson-status.go b/internal/services/backoffice/lesson-status.go new file mode 100644 index 0000000..2df10e2 --- /dev/null +++ b/internal/services/backoffice/lesson-status.go @@ -0,0 +1,48 @@ +package backoffice + +import ( + "algobot/internal/lib/logger/sl" + "fmt" + "log/slog" +) + +type LessonStatus int + +const ( + CloseLesson LessonStatus = iota + OpenLesson +) + +type LessonStatuser interface { + OpenLesson(cookie, group, lession string) error + CloseLesson(cookie, group, lession string) error +} + +func (bo *Backoffice) SetLessonStatus(uid int64, groupID string, lessonID string, status LessonStatus, traceID interface{}) error { + const op = "services.backoffice.GetGroupView" + log := bo.log.With( + slog.String("op", op), + slog.Any("traceID", traceID), + ) + + cookie, err := bo.cookieGetter.Cookies(uid) + if err != nil { + log.Warn("failed to get cookies", sl.Err(err)) + return fmt.Errorf("%s failed to get cookies: %w", op, err) + } + + switch status { + case CloseLesson: + if err := bo.lessonStatuser.CloseLesson(cookie, groupID, lessonID); err != nil { + return fmt.Errorf("%s error while CloseLesson : %w", op, err) + } + case OpenLesson: + if err := bo.lessonStatuser.OpenLesson(cookie, groupID, lessonID); err != nil { + return fmt.Errorf("%s error while OpenLesson : %w", op, err) + } + default: + return fmt.Errorf("%s invalid lesson status: %d", op, status) + } + + return nil +} diff --git a/internal/services/backoffice/messagesUser.go b/internal/services/backoffice/messagesUser.go new file mode 100644 index 0000000..8cc34ca --- /dev/null +++ b/internal/services/backoffice/messagesUser.go @@ -0,0 +1,124 @@ +package backoffice + +import ( + "algobot/internal/domain/backoffice" + "algobot/internal/domain/scheduler" + "algobot/internal/lib/logger/sl" + "fmt" + "log/slog" + "strings" + "time" +) + +var dateMap = map[string]string{ + "янв": "01", + "февр": "02", + "мар": "03", + "апр": "04", + "мая": "05", + "июн": "06", + "июл": "07", + "авг": "08", + "сент": "09", + "окт": "10", + "нояб": "11", + "дек": "12", +} +var dateReverseMap = map[int]string{ + 1: "янв", + 2: "февр", + 3: "мар", + 4: "апр", + 5: "мая", + 6: "июн", + 7: "июл", + 8: "авг", + 9: "сент", + 10: "окт", + 11: "нояб", + 12: "дек", +} + +type MessageFetcher interface { + KidsMessages(cookie string) (backoffice.KidsMessages, error) +} + +func (bo *Backoffice) MessagesUser(uid int64, lastTime string) ([]scheduler.Message, error) { + const op = "services.backoffice.MessagesUser" + log := bo.log.With( + slog.String("op", op), + ) + + cookie, err := bo.cookieGetter.Cookies(uid) + if err != nil { + log.Warn("failed to get cookies", sl.Err(err)) + return nil, fmt.Errorf("%s failed to get cookies: %w", op, err) + } + + messages, err := bo.msgFetcher.KidsMessages(cookie) + if err != nil { + return nil, fmt.Errorf("%s failed to get KidsMessages: %w", op, err) + } + + var dateNotif time.Time + if lastTime != "" { + dateNotif = parseDate(lastTime) + } else { + timeNow := time.Now() + return []scheduler.Message{{ + From: "", + Theme: "", + Link: "", + Text: "", + Time: timeNow.Format(fmt.Sprintf("2 %s. 15:04", dateReverseMap[int(timeNow.Month())])), + To: uid, + }}, nil + } + + var msgs []scheduler.Message + for i := len(messages.Data.Projects) - 1; i >= 0; i-- { + if messages.Data.Projects[i].SenderScope == "student" { + if dateNotif.Before(parseDate(messages.Data.Projects[i].LastTime)) { + m := scheduler.Message{ + From: messages.Data.Projects[i].Name, + Theme: messages.Data.Projects[i].Title, + Link: fmt.Sprintf("https://backoffice.algoritmika.org%s", messages.Data.Projects[i].Link), + Text: messages.Data.Projects[i].Content, + Time: messages.Data.Projects[i].LastTime, + To: uid, + } + + if messages.Data.Projects[i].Type == "img" { + m.LinkURL = fmt.Sprintf("https://backoffice.algoritmika.org%s", m.Text) + } + + msgs = append(msgs, m) + } + } + } + + return msgs, nil +} + +func parseDate(lastTime string) time.Time { + parts := strings.Split(lastTime, " ") + + day := parts[0] + month := dateMap[strings.Replace(parts[1], ".", "", -1)] + + if len(parts) == 3 { + timeHour := parts[2] + + retTime, _ := time.Parse("2 01 2006 15:04", fmt.Sprintf("%s %s %d %s", day, month, time.Now().Year(), timeHour)) + return retTime + } + if len(parts) == 4 { + year := strings.Replace(parts[2], "`", "", -1) + year = strings.Replace(year, ",", "", -1) + timeHour := parts[3] + + retTime, _ := time.Parse("2 01 06 15:04", fmt.Sprintf("%s %s %s %s", day, month, year, timeHour)) + return retTime + } + return time.Date(0, 1, 1, 0, 0, 0, 0, time.UTC) +} diff --git a/internal/services/groups/currentGroup.go b/internal/services/groups/currentGroup.go new file mode 100644 index 0000000..f1d2e45 --- /dev/null +++ b/internal/services/groups/currentGroup.go @@ -0,0 +1,129 @@ +package groups + +import ( + "algobot/internal/domain/backoffice" + "algobot/internal/domain/models" + "algobot/internal/lib/logger/sl" + "fmt" + "log" + "log/slog" + "regexp" + "strconv" + "strings" + "time" +) + +type KidStats interface { + KidsStats(cookie string, groupID int) (backoffice.KidsStats, error) + KidsNamesByGroup(groupId string, cookie string) (backoffice.NamesByGroup, error) +} + +func (g *Group) CurrentGroup(uid int64, time time.Time, traceID interface{}) (models.CurrentGroup, error) { + const op = "services.groups.CurrentGroup" + log := g.log.With( + slog.String("op", op), + slog.Any("trace_id", traceID), + ) + + cookie, err := g.domain.Cookies(uid) + if err != nil { + log.Warn("failed to get cookies", sl.Err(err)) + return models.CurrentGroup{}, fmt.Errorf("%s failed to get cookies: %w", op, err) + } + if cookie == "" { + return models.CurrentGroup{}, fmt.Errorf("%s cookie is empty: %w", op, ErrNotValidCookie) + } + + group, err := g.Groups(uid, traceID) + if err != nil { + log.Warn("failed to get groups", sl.Err(err)) + return models.CurrentGroup{}, fmt.Errorf("%s failed to get groups: %w", op, err) + } + + m := models.CurrentGroup{} + missingKids := make(map[int]models.MissingKid) + + actual, err := CurrentGroup(time, group) + if err != nil { + return models.CurrentGroup{}, fmt.Errorf("%s : %w", op, err) + } + m.Title = actual.Title + m.GroupID = actual.GroupID + + stats, err := g.kidsStats.KidsStats(cookie, actual.GroupID) + if err != nil { + log.Warn("error while fetching kids stats", sl.Err(err)) + return models.CurrentGroup{}, fmt.Errorf("%s error while fetching kids stats: %w", op, err) + } + + for _, datum := range stats.Data { + studentID := datum.StudentID + count := 0 + for _, attendance := range datum.Attendance { + count++ + if attendance.Status != "absent" { + count = 0 + } + if matchDates(attendance.StartTimeFormatted, time) { + m.Lesson = attendance.LessonTitle + m.LessonID = attendance.LessonID + + if attendance.Status == "absent" { + missingKids[studentID] = models.MissingKid{ + Fullname: "", + Count: count, + KidID: studentID, + } + break + } + } + } + } + + names, err := g.kidsStats.KidsNamesByGroup(strconv.Itoa(actual.GroupID), cookie) + if err != nil { + log.Warn("error while fetching KidsNamesByGroup", sl.Err(err)) + return models.CurrentGroup{}, fmt.Errorf("%s error while fetching KidsNamesByGroup: %w", op, err) + } + + for _, datum := range names.Data.Items { + if datum.LastGroup.ID == actual.GroupID && datum.LastGroup.Status == 0 { + + missingKids[datum.ID] = models.MissingKid{ + Fullname: datum.FullName, + Count: missingKids[datum.ID].Count, + KidID: missingKids[datum.ID].KidID, + } + } + } + + split(&m, missingKids) + + return m, nil +} + +func split(m *models.CurrentGroup, kids map[int]models.MissingKid) { + m.MissingKids = make([]models.MissingKid, 0, len(kids)) + for _, kid := range kids { + if kid.Count != 0 && strings.TrimSpace(kid.Fullname) != "" { + m.MissingKids = append(m.MissingKids, kid) + } + m.Kids = append(m.Kids, kid.Fullname) + } +} + +func matchDates(timeStr string, t time.Time) bool { + timeStr = regexp.MustCompile(`^[а-яА-Я]+(\s+)?`).ReplaceAllString(timeStr, "") + timeFormatted, err := time.Parse("02.01.06 15:04", timeStr) + + if err != nil { + log.Printf("Cant convert date str to Time - '%s'\n", timeStr) + return false + } + + if t.YearDay() == timeFormatted.YearDay() { + return true + } + + return false +} diff --git a/internal/services/groups/group.go b/internal/services/groups/group.go new file mode 100644 index 0000000..1b9d8d8 --- /dev/null +++ b/internal/services/groups/group.go @@ -0,0 +1,103 @@ +package groups + +import ( + "algobot/internal/domain/models" + "algobot/internal/lib/logger/sl" + "algobot/internal/lib/sort" + "errors" + "fmt" + "log/slog" + "time" +) + +var ( + ErrNotValidCookie = errors.New("not a valid cookie") + ErrNoGroups = errors.New("groups not found") +) + +type GroupGetter interface { + Groups(uid int64) ([]models.Group, error) +} + +type Group struct { + log *slog.Logger + getter GroupGetter + groupFetcher GroupFetcher + domain DomainSetter + kidsStats KidStats +} + +func NewGroup( + log *slog.Logger, + getter GroupGetter, + groupFetcher GroupFetcher, + domain DomainSetter, + kidsStats KidStats, +) *Group { + return &Group{ + log: log, + getter: getter, + groupFetcher: groupFetcher, + domain: domain, + kidsStats: kidsStats, + } +} + +func (g *Group) Groups(uid int64, traceID interface{}) ([]models.Group, error) { + const op = "services.Group.Groups" + log := g.log.With( + slog.String("op", op), + slog.Any("trace_id", traceID), + ) + + groups, err := g.getter.Groups(uid) + if err != nil { + log.Warn("error while get groups", sl.Err(err)) + return []models.Group{}, fmt.Errorf("%s error while get groups: %w", op, err) + } + + sort.GroupsByDate(groups) + + return groups, nil +} + +func CurrentGroup(t time.Time, g []models.Group) (models.Group, error) { + current, err := GroupsByDay(t, g) + if err != nil { + return models.Group{}, err + } + for _, group := range current { + if inDiapazon(-30, 90, t, group.TimeLesson) { + return group, nil + } + } + + return models.Group{}, ErrNoGroups +} +func GroupsByDay(t time.Time, g []models.Group) ([]models.Group, error) { + var filtered []models.Group + + for _, group := range g { + if t.Weekday() == group.TimeLesson.Weekday() { + filtered = append(filtered, group) + } + } + if len(filtered) == 0 { + return nil, ErrNoGroups + } + + return filtered, nil +} +func inDiapazon(start, end int, now, group time.Time) bool { + s := group.Add(time.Duration(start) * time.Minute) + e := group.Add(time.Duration(end) * time.Minute) + + startTime := time.Date(1970, 1, 1, s.Hour(), s.Minute(), 0, 0, time.UTC) + endTime := time.Date(1970, 1, 1, e.Hour(), e.Minute(), 0, 0, time.UTC) + currentTime := time.Date(1970, 1, 1, now.Hour(), now.Minute(), 0, 0, time.UTC) + + if currentTime.After(startTime) && currentTime.Before(endTime) { + return true + } + return false +} diff --git a/internal/services/groups/refreshGroups.go b/internal/services/groups/refreshGroups.go new file mode 100644 index 0000000..ce8cb5f --- /dev/null +++ b/internal/services/groups/refreshGroups.go @@ -0,0 +1,52 @@ +package groups + +import ( + "algobot/internal/domain/models" + "algobot/internal/lib/logger/sl" + "fmt" + "log/slog" +) + +type DomainSetter interface { + SetGroups(uid int64, groups []models.Group) error + Cookies(uid int64) (string, error) +} + +type GroupFetcher interface { + Group(cookie string) ([]models.Group, error) +} + +func (g *Group) RefreshGroup(uid int64, traceID interface{}) error { + const op = "services.Group.RefreshGroup" + + log := g.log.With( + slog.String("op", op), + slog.Any("trace_id", traceID), + ) + + cookie, err := g.domain.Cookies(uid) + if err != nil { + log.Warn("failed to get cookies", sl.Err(err)) + return fmt.Errorf("%s failed to get cookies: %w", op, err) + } + if cookie == "" { + return fmt.Errorf("%s cookie is empty: %w", op, ErrNotValidCookie) + } + + groups, err := g.groupFetcher.Group(cookie) + if err != nil { + log.Warn("failed to fetch groups", sl.Err(err)) + return fmt.Errorf("%s failed to fetch groups: %w", op, err) + } + + if len(groups) == 0 { + return fmt.Errorf("%s no groups found: %w", op, ErrNoGroups) + } + + if err := g.domain.SetGroups(uid, groups); err != nil { + log.Warn("failed to set groups", sl.Err(err)) + return fmt.Errorf("%s failed to set groups: %w", op, err) + } + + return nil +} diff --git a/internal/services/grpc/ai.go b/internal/services/grpc/ai.go new file mode 100644 index 0000000..480c045 --- /dev/null +++ b/internal/services/grpc/ai.go @@ -0,0 +1,77 @@ +package grpc + +import ( + "algobot/internal/config" + "algobot/internal/domain/models" + "algobot/internal/lib/logger/handlers/slogpretty" + "algobot/internal/lib/logger/sl" + aiv1 "algobot/protos" + "context" + "errors" + "fmt" + "google.golang.org/grpc" + "google.golang.org/grpc/credentials/insecure" + "log/slog" +) + +var ( + ErrNotValidResponse = errors.New("not valid response") +) + +type AiOption func(*AIService) + +type AIService struct { + grpc aiv1.AiClient + log *slog.Logger + cfg config.GRPC +} + +func NewAIService(cfg config.GRPC, fn ...func(*AIService)) *AIService { + conn, err := grpc.NewClient(fmt.Sprintf("%s:%s", cfg.Host, cfg.Port), grpc.WithTransportCredentials(insecure.NewCredentials())) + if err != nil { + panic(err) + } + svr := &AIService{ + grpc: aiv1.NewAiClient(conn), + log: slog.New(slogpretty.NewHandler(&slog.HandlerOptions{Level: slog.LevelDebug})), + cfg: cfg, + } + + for _, o := range fn { + o(svr) + } + + return svr +} +func WithLogger(log *slog.Logger) func(*AIService) { + return func(s *AIService) { + s.log = log + } +} +func WithClient(client aiv1.AiClient) func(*AIService) { + return func(s *AIService) { + s.grpc = client + } +} + +func (a *AIService) GetAIInfo(traceID interface{}) (models.AIInfo, error) { + const op = "grpc.AIService.GetAIInfo" + log := a.log.With( + slog.String("op", op), + slog.Any("trace_id", traceID), + ) + + ctx, cancel := context.WithTimeout(context.Background(), a.cfg.Timeout) + defer cancel() + + information, err := a.grpc.GetInformation(ctx, &aiv1.GetInformationRequest{}) + if err != nil { + log.Warn("error while calling gRPC GetInformation", sl.Err(err)) + return models.AIInfo{}, fmt.Errorf("%s error while calling gRPC GetInformation: %w", op, err) + } + + return models.AIInfo{ + TextModel: information.GetChatModel(), + ImageModel: information.GetImageModel(), + }, nil +} diff --git a/internal/services/grpc/chatAI.go b/internal/services/grpc/chatAI.go new file mode 100644 index 0000000..0cb5ec7 --- /dev/null +++ b/internal/services/grpc/chatAI.go @@ -0,0 +1,35 @@ +package grpc + +import ( + "algobot/internal/lib/logger/sl" + aiv1 "algobot/protos" + "context" + "fmt" + "log/slog" +) + +func (a *AIService) ChatAI(uid int64, message string, traceID interface{}) (string, error) { + const op = "grpc.AIService.ChatAI" + log := a.log.With( + slog.String("op", op), + slog.Any("trace_id", traceID), + ) + + ctx, cancel := context.WithTimeout(context.Background(), a.cfg.Timeout) + defer cancel() + + resp, err := a.grpc.GetSuggest(ctx, &aiv1.SuggestRequest{ + Uid: uid, + Suggest: message, + }) + if err != nil { + log.Warn("error while calling gRPC GetSuggest", sl.Err(err)) + return "", fmt.Errorf("%s error while calling gRPC GetSuggest: %w", op, err) + } + if !resp.GetOk() { + log.Warn("error while checking status grpc, not ok", slog.Bool("ok", resp.GetOk())) + return "", fmt.Errorf("%s error while checking status grpc, not ok: %w", op, ErrNotValidResponse) + } + + return resp.GetRequest(), nil +} diff --git a/internal/services/grpc/generateImage.go b/internal/services/grpc/generateImage.go new file mode 100644 index 0000000..73cc120 --- /dev/null +++ b/internal/services/grpc/generateImage.go @@ -0,0 +1,32 @@ +package grpc + +import ( + "algobot/internal/lib/logger/sl" + aiv1 "algobot/protos" + "context" + "fmt" + "log/slog" +) + +func (a *AIService) GenerateImage(uid int64, promt string, traceID interface{}) (string, error) { + const op = "grpc.AIService.GenerateImage" + log := a.log.With( + slog.String("op", op), + slog.Any("trace_id", traceID), + ) + + ctx, cancel := context.WithTimeout(context.Background(), a.cfg.Timeout) + defer cancel() + + resp, err := a.grpc.GenerateImage(ctx, &aiv1.GenerateImageRequest{ + Uid: uid, + Promt: promt, + }) + + if err != nil { + log.Warn("error while calling gRPC GenerateImage", sl.Err(err)) + return "", fmt.Errorf("%s error while calling gRPC GenerateImage: %w", op, err) + } + + return resp.GetUrl(), nil +} diff --git a/internal/services/grpc/resetHistory.go b/internal/services/grpc/resetHistory.go new file mode 100644 index 0000000..c10189b --- /dev/null +++ b/internal/services/grpc/resetHistory.go @@ -0,0 +1,34 @@ +package grpc + +import ( + "algobot/internal/lib/logger/sl" + aiv1 "algobot/protos" + "context" + "fmt" + "log/slog" +) + +func (a *AIService) ResetHistory(uid int64, traceID interface{}) error { + const op = "grpc.AIService.ResetHistory" + log := a.log.With( + slog.String("op", op), + slog.Any("trace_id", traceID), + ) + + ctx, cancel := context.WithTimeout(context.Background(), a.cfg.Timeout) + defer cancel() + + ok, err := a.grpc.ClearHistory(ctx, &aiv1.ClearHistoryRequest{ + Uid: uid, + }) + if err != nil { + log.Warn("error while calling gRPC ClearHistory", sl.Err(err)) + return fmt.Errorf("%s error while calling gRPC ClearHistory: %w", op, err) + } + if !ok.GetOk() { + log.Warn("error while checking status grpc, not ok", slog.Bool("ok", ok.GetOk())) + return fmt.Errorf("%s error while checking status grpc, not ok: %w", op, ErrNotValidResponse) + } + + return nil +} diff --git a/internal/services/schedule/schedule.go b/internal/services/schedule/schedule.go new file mode 100644 index 0000000..b71d36a --- /dev/null +++ b/internal/services/schedule/schedule.go @@ -0,0 +1,59 @@ +package schedule + +import ( + "algobot/internal/domain/scheduler" + "fmt" + "gopkg.in/telebot.v4" + "strings" +) + +type Sender interface { + Send(to telebot.Recipient, what interface{}, opts ...interface{}) (*telebot.Message, error) +} + +type Schedule struct { + ch chan scheduler.Message + sender Sender +} + +func NewSchedule(ch chan scheduler.Message, sender Sender) *Schedule { + return &Schedule{ch: ch, sender: sender} +} + +func (s *Schedule) Process() { + for msg := range s.ch { + if msg.From == "" { + continue + } + if msg.LinkURL != "" { + p := &telebot.Photo{File: telebot.FromURL(msg.LinkURL), Caption: getMsg(msg)} + s.sender.Send( + telebot.ChatID(msg.To), + p, + telebot.ModeMarkdown, + telebot.NoPreview, + ) + continue + } + s.sender.Send( + telebot.ChatID(msg.To), + getMsg(msg), + telebot.ModeMarkdown, + telebot.NoPreview, + ) + } +} + +func getMsg(msg scheduler.Message) string { + sb := strings.Builder{} + sb.WriteString("🔔 Новое сообщение\n\n") + sb.WriteString(fmt.Sprintf("От: %s\n", msg.From)) + sb.WriteString(fmt.Sprintf("Тема: %s\n", msg.Theme)) + sb.WriteString(fmt.Sprintf("Ссылка: %s\n\n", msg.Link)) + if msg.LinkURL == "" { + sb.WriteString("```Сообщение:\n") + sb.WriteString(msg.Text) + sb.WriteString("\n```") + } + return sb.String() +} diff --git a/internal/stateMachine/memory.go b/internal/stateMachine/memory.go deleted file mode 100644 index af63951..0000000 --- a/internal/stateMachine/memory.go +++ /dev/null @@ -1,31 +0,0 @@ -package stateMachine - -import "sync" - -type Memory struct { - mu sync.Mutex - statements map[int64]Statement -} - -func NewMemory() *Memory { - return &Memory{ - statements: make(map[int64]Statement), - } -} -func (m *Memory) SetStatement(uid int64, statement Statement) { - m.mu.Lock() - defer m.mu.Unlock() - - m.statements[uid] = statement -} -func (m *Memory) GetStatement(uid int64) Statement { - m.mu.Lock() - defer m.mu.Unlock() - - v, ok := m.statements[uid] - if ok { - return v - } - m.statements[uid] = Default - return m.statements[uid] -} diff --git a/internal/stateMachine/stateMachine.go b/internal/stateMachine/stateMachine.go deleted file mode 100644 index 0a77de9..0000000 --- a/internal/stateMachine/stateMachine.go +++ /dev/null @@ -1,18 +0,0 @@ -package stateMachine - -type Statement string - -const ( - Default Statement = "default" - SendingCookie Statement = "sendingCookie" - ChattingAI Statement = "chattingAI" -) - -func (s Statement) String() string { - return string(s) -} - -type StateMachine interface { - GetStatement(uid int64) Statement - SetStatement(uid int64, statement Statement) -} diff --git a/internal/storage/sqlite/chaneNotifDate.go b/internal/storage/sqlite/chaneNotifDate.go new file mode 100644 index 0000000..d38deff --- /dev/null +++ b/internal/storage/sqlite/chaneNotifDate.go @@ -0,0 +1,20 @@ +package sqlite + +import "fmt" + +func (s *Sqlite) ChaneNotifDate(uid int64, lastnotif string) error { + const op = "sqlite.ChaneNotifDate" + + sqlq := "UPDATE users SET last_notification_msg = ? WHERE uid = ?" + pr, err := s.db.Prepare(sqlq) + if err != nil { + return fmt.Errorf("%s error while preparing statement: %w", op, err) + } + defer pr.Close() + + if _, err := pr.Exec(lastnotif, uid); err != nil { + return fmt.Errorf("%s error while executing statement: %w", op, err) + } + + return nil +} diff --git a/internal/storage/sqlite/cookies.go b/internal/storage/sqlite/cookies.go new file mode 100644 index 0000000..506db01 --- /dev/null +++ b/internal/storage/sqlite/cookies.go @@ -0,0 +1,29 @@ +package sqlite + +import ( + "database/sql" + "fmt" +) + +func (s *Sqlite) Cookies(uid int64) (string, error) { + const op = "sqlite.Cookies" + + q := `SELECT cookie FROM main.users WHERE uid=?` + pr, err := s.db.Prepare(q) + if err != nil { + return "", fmt.Errorf("%s error while preparing sql: %w", op, err) + } + + var cookie sql.NullString + + row := pr.QueryRow(uid) + if err := row.Scan(&cookie); err != nil { + return "", fmt.Errorf("%s error while scanning sql: %w", op, err) + } + + if !cookie.Valid { + return "", nil + } + + return cookie.String, nil +} diff --git a/internal/storage/sqlite/groups.go b/internal/storage/sqlite/groups.go new file mode 100644 index 0000000..0c4887f --- /dev/null +++ b/internal/storage/sqlite/groups.go @@ -0,0 +1,49 @@ +package sqlite + +import ( + "algobot/internal/domain/models" + "database/sql" + "fmt" + "time" +) + +func (s *Sqlite) Groups(uid int64) ([]models.Group, error) { + const op = "sqlite.Groups" + + sqlq := "SELECT group_id, title, time_lesson FROM groups WHERE owner_id=?" + pr, err := s.db.Prepare(sqlq) + if err != nil { + return nil, fmt.Errorf("%s error while preparing sql: %w", op, err) + } + defer pr.Close() + + rows, err := pr.Query(uid) + if err != nil { + return nil, fmt.Errorf("%s error while executing sql: %w", op, err) + } + defer rows.Close() + + var groups []models.Group + for rows.Next() { + var groupId sql.NullInt64 + var title sql.NullString + var timeGroup sql.NullString + + if err := rows.Scan(&groupId, &title, &timeGroup); err != nil { + return nil, fmt.Errorf("%s error while scanning row: %w", op, err) + } + + parsedTime, err := time.Parse("2006-01-02 15:04:05", timeGroup.String) + if err != nil { + return nil, fmt.Errorf("%s error while parsing time: %w", op, err) + } + + groups = append(groups, models.Group{ + GroupID: int(groupId.Int64), + Title: title.String, + TimeLesson: parsedTime, + }) + } + + return groups, nil +} diff --git a/internal/storage/sqlite/isRegistered.go b/internal/storage/sqlite/isRegistered.go new file mode 100644 index 0000000..de39484 --- /dev/null +++ b/internal/storage/sqlite/isRegistered.go @@ -0,0 +1,23 @@ +package sqlite + +import "fmt" + +func (s *Sqlite) IsRegistered(uid int64) (bool, error) { + const op = "sqlite.IsRegistered" + + sql := `SELECT COUNT(*) FROM users WHERE uid = ?` + + pr, err := s.db.Prepare(sql) + if err != nil { + return false, fmt.Errorf("%s error while preparing sql: %w", op, err) + } + + var count int + + row := pr.QueryRow(uid) + if err := row.Scan(&count); err != nil { + return false, fmt.Errorf("%s error while scanning sql: %w", op, err) + } + + return count > 0, nil +} diff --git a/internal/storage/sqlite/notification.go b/internal/storage/sqlite/notification.go new file mode 100644 index 0000000..88b1555 --- /dev/null +++ b/internal/storage/sqlite/notification.go @@ -0,0 +1,24 @@ +package sqlite + +import ( + "fmt" +) + +func (s *Sqlite) Notification(uid int64) (bool, error) { + const op = "sqlite.Notification" + + q := `SELECT notification FROM main.users WHERE uid=?` + pr, err := s.db.Prepare(q) + if err != nil { + return false, fmt.Errorf("%s error while preparing sql: %w", op, err) + } + + var notification int + + row := pr.QueryRow(uid) + if err := row.Scan(¬ification); err != nil { + return false, fmt.Errorf("%s error while scanning sql: %w", op, err) + } + + return notification == 1, nil +} diff --git a/internal/storage/sqlite/register.go b/internal/storage/sqlite/register.go new file mode 100644 index 0000000..74a76c2 --- /dev/null +++ b/internal/storage/sqlite/register.go @@ -0,0 +1,26 @@ +package sqlite + +import ( + "fmt" +) + +func (s *Sqlite) Register(uid int64) error { + const op = "sqlite.Register" + + sqle := ` + INSERT INTO users (uid, cookie, last_notification_msg, notification) + VALUES (?, NULL, NULL, 0); + ` + + pr, err := s.db.Prepare(sqle) + if err != nil { + return fmt.Errorf("%s error while preparing sql: %w", op, err) + } + + _, err = pr.Exec(uid) + if err != nil { + return fmt.Errorf("%s error while exec sql: %w", op, err) + } + + return nil +} diff --git a/internal/storage/sqlite/setCookie.go b/internal/storage/sqlite/setCookie.go new file mode 100644 index 0000000..0b0eb07 --- /dev/null +++ b/internal/storage/sqlite/setCookie.go @@ -0,0 +1,20 @@ +package sqlite + +import "fmt" + +func (s *Sqlite) SetCookie(uid int64, cookie string) error { + const op = "sqlite.SetCookie" + + sqlq := "UPDATE users SET cookie=? WHERE uid=?" + pr, err := s.db.Prepare(sqlq) + if err != nil { + return fmt.Errorf("%s error while preparing statement: %w", op, err) + } + defer pr.Close() + + if _, err := pr.Exec(cookie, uid); err != nil { + return fmt.Errorf("%s error while executing statement: %w", op, err) + } + + return nil +} diff --git a/internal/storage/sqlite/setGroups.go b/internal/storage/sqlite/setGroups.go new file mode 100644 index 0000000..cecba3b --- /dev/null +++ b/internal/storage/sqlite/setGroups.go @@ -0,0 +1,45 @@ +package sqlite + +import ( + "algobot/internal/domain/models" + "fmt" +) + +func (s *Sqlite) SetGroups(uid int64, groups []models.Group) error { + const op = "sqlite.SetGroups" + + // Drop all + tx, err := s.db.Begin() + if err != nil { + return fmt.Errorf("%s error while start tx: %w", op, err) + } + pr, err := tx.Prepare(`DELETE FROM groups WHERE owner_id = ?;`) + if err != nil { + return fmt.Errorf("%s error while preparing sql: %w", op, err) + } + _, err = pr.Exec(uid) + if err != nil { + tx.Rollback() + return fmt.Errorf("%s error while exec sql: %w", op, err) + } + // Set new + pr, err = tx.Prepare(`INSERT INTO groups (group_id, owner_id, title, time_lesson) VALUES (?, ?, ?, ?);`) + if err != nil { + return fmt.Errorf("%s error while preparing groups sql: %w", op, err) + } + for _, group := range groups { + _, err = pr.Exec(group.GroupID, uid, group.Title, group.TimeLesson.Format("2006-01-02 15:04:05")) + if err != nil { + tx.Rollback() + return fmt.Errorf("%s error while exec add group sql: %w", op, err) + } + } + + err = tx.Commit() + if err != nil { + tx.Rollback() + return fmt.Errorf("%s error while commit tx: %w", op, err) + } + + return nil +} diff --git a/internal/storage/sqlite/setNotification.go b/internal/storage/sqlite/setNotification.go new file mode 100644 index 0000000..4a0d814 --- /dev/null +++ b/internal/storage/sqlite/setNotification.go @@ -0,0 +1,25 @@ +package sqlite + +import "fmt" + +func (s *Sqlite) SetNotification(uid int64, isEnable bool) error { + const op = "sqlite.SetNotification" + + digit := 0 + if isEnable { + digit = 1 + } + + sqlq := "UPDATE users SET notification = ? WHERE uid = ?" + pr, err := s.db.Prepare(sqlq) + if err != nil { + return fmt.Errorf("%s error while preparing statement: %w", op, err) + } + defer pr.Close() + + if _, err := pr.Exec(digit, uid); err != nil { + return fmt.Errorf("%s error while executing statement: %w", op, err) + } + + return nil +} diff --git a/internal/storage/sqlite/sqlite.go b/internal/storage/sqlite/sqlite.go new file mode 100644 index 0000000..66b8429 --- /dev/null +++ b/internal/storage/sqlite/sqlite.go @@ -0,0 +1,39 @@ +package sqlite + +import ( + "algobot/internal/config" + "database/sql" + "fmt" + _ "github.com/ncruces/go-sqlite3/driver" + _ "github.com/ncruces/go-sqlite3/embed" +) + +type Sqlite struct { + db *sql.DB +} + +func NewDB(cfg *config.Config) (*Sqlite, error) { + const op = "sqlite.NewDB" + + db, err := sql.Open("sqlite3", fmt.Sprintf("file:%s", cfg.StoragePath)) + if err != nil { + return nil, fmt.Errorf("%s: %w", op, err) + } + + err = db.Ping() + if err != nil { + return nil, fmt.Errorf("%s: %w", op, err) + } + + return &Sqlite{db: db}, nil +} + +func (s *Sqlite) MustClose() { + if err := s.Close(); err != nil { + panic(err) + } +} + +func (s *Sqlite) Close() error { + return s.db.Close() +} diff --git a/internal/storage/sqlite/usersByNotification.go b/internal/storage/sqlite/usersByNotification.go new file mode 100644 index 0000000..987e124 --- /dev/null +++ b/internal/storage/sqlite/usersByNotification.go @@ -0,0 +1,54 @@ +package sqlite + +import ( + "algobot/internal/domain/models" + "database/sql" + "fmt" +) + +func (s *Sqlite) UsersByNotification(wantNotif int) ([]models.User, error) { + const op = "sqlite.UsersByNotification" + + q := `SELECT * FROM users WHERE notification=?` + pr, err := s.db.Prepare(q) + if err != nil { + return nil, fmt.Errorf("%s error while preparing sql: %w", op, err) + } + + row, err := pr.Query(wantNotif) + if err != nil { + return nil, fmt.Errorf("%s error while Query sql: %w", op, err) + } + + var users []models.User + for row.Next() { + var id int + var uid int64 + var cookie sql.NullString + var lastNotif sql.NullString + var notif int + + if err := row.Scan(&id, &uid, &cookie, &lastNotif, ¬if); err != nil { + return nil, fmt.Errorf("%s error while scanning row: %w", op, err) + } + + cookieNew := "" + if cookie.Valid { + cookieNew = cookie.String + } + lastNotifNew := "" + if lastNotif.Valid { + lastNotifNew = lastNotif.String + } + users = append(users, models.User{ + ID: id, + Uid: uid, + Cookie: cookieNew, + LastNotification: lastNotifNew, + Notification: notif, + }) + + } + + return users, nil +} diff --git a/internal/storage/storage.go b/internal/storage/storage.go new file mode 100644 index 0000000..cfb5874 --- /dev/null +++ b/internal/storage/storage.go @@ -0,0 +1,7 @@ +package storage + +import "errors" + +var ( + ErrAlreadyExists = errors.New("already exists") +) diff --git a/internal/telegram/handlers/callback/changeCookie.go b/internal/telegram/handlers/callback/changeCookie.go new file mode 100644 index 0000000..6afe7dd --- /dev/null +++ b/internal/telegram/handlers/callback/changeCookie.go @@ -0,0 +1,24 @@ +package callback + +import ( + "algobot/internal/domain/telegram/keyboards" + "algobot/internal/lib/fsm" + "gopkg.in/telebot.v4" +) + +type StateChanger interface { + SetState(uid int64, state fsm.State) +} + +func NewChangeCookie(stateChanger StateChanger) telebot.HandlerFunc { + return func(ctx telebot.Context) error { + uid := ctx.Sender().ID + + stateChanger.SetState(uid, fsm.SendingCookie) + + return ctx.Send( + "Отправьте мне свои cookie 🍪\nИнструкция: https://telegra.ph/Kak-dobavit-v-bota-svoi-Cookie-02-05", + keyboards.RejectKeyboard(), + ) + } +} diff --git a/internal/telegram/handlers/callback/changeNotification.go b/internal/telegram/handlers/callback/changeNotification.go new file mode 100644 index 0000000..58310c9 --- /dev/null +++ b/internal/telegram/handlers/callback/changeNotification.go @@ -0,0 +1,38 @@ +package callback + +import ( + "algobot/internal/lib/logger/sl" + "fmt" + "gopkg.in/telebot.v4" + "log/slog" +) + +type NotificationChanger interface { + SetNotification(uid int64, isEnable bool) error + Notification(uid int64) (bool, error) +} + +func NewChangeNotification(n NotificationChanger, log *slog.Logger) telebot.HandlerFunc { + return func(ctx telebot.Context) error { + const op = "callback.NewChangeNotification" + log := log.With( + slog.String("op", op), + slog.Any("trace_id", ctx.Get("trace_id")), + ) + + uid := ctx.Sender().ID + + nstat, err := n.Notification(uid) + if err != nil { + log.Warn("error while getting notification", sl.Err(err)) + return fmt.Errorf("%s get notif: %w", op, err) + } + + if err := n.SetNotification(uid, !nstat); err != nil { + log.Warn("error while set notification", sl.Err(err)) + return fmt.Errorf("%s set notif: %w", op, err) + } + + return ctx.Edit("Уведомления переключены") + } +} diff --git a/internal/telegram/handlers/callback/getCreds.go b/internal/telegram/handlers/callback/getCreds.go new file mode 100644 index 0000000..3267761 --- /dev/null +++ b/internal/telegram/handlers/callback/getCreds.go @@ -0,0 +1,52 @@ +package callback + +import ( + "algobot/internal/domain/models" + "algobot/internal/lib/logger/sl" + "fmt" + "gopkg.in/telebot.v4" + "log/slog" + "strings" +) + +type GetterCreds interface { + Creds(uid int64, groupID string, traceID interface{}) ([]models.Credential, error) +} + +func GetCreds(creds GetterCreds, log *slog.Logger) telebot.HandlerFunc { + return func(ctx telebot.Context) error { + const op = "callback.GetCreds" + + traceID := ctx.Get("trace_id") + log := log.With( + slog.String("op", op), + slog.Any("trace_id", traceID), + ) + uid := ctx.Sender().ID + + groupID := strings.TrimPrefix(ctx.Callback().Data, "\fget_creds_") + + c, err := creds.Creds(uid, groupID, traceID) + if err != nil { + log.Warn("error while get creds", sl.Err(err)) + return fmt.Errorf("%s error while get creds: %w", op, err) + } + + return ctx.Send(getCredsMsg(c), telebot.ModeHTML) + } +} + +func getCredsMsg(c []models.Credential) string { + sb := strings.Builder{} + for _, credential := range c { + sb.WriteString("") + sb.WriteString(credential.Fullname) + sb.WriteString("") + sb.WriteString(" - ") + sb.WriteString(credential.Login) + sb.WriteString(" : ") + sb.WriteString(credential.Password) + sb.WriteString("\n") + } + return sb.String() +} diff --git a/internal/telegram/handlers/callback/lessonStatus.go b/internal/telegram/handlers/callback/lessonStatus.go new file mode 100644 index 0000000..2a0c335 --- /dev/null +++ b/internal/telegram/handlers/callback/lessonStatus.go @@ -0,0 +1,46 @@ +package callback + +import ( + "algobot/internal/lib/logger/sl" + "algobot/internal/services/backoffice" + "fmt" + "gopkg.in/telebot.v4" + "log/slog" + "strings" +) + +type LessonStatuser interface { + SetLessonStatus(uid int64, groupID string, lessonID string, status backoffice.LessonStatus, traceID interface{}) error +} + +func LessonStatus(ls LessonStatuser, status backoffice.LessonStatus, log *slog.Logger) telebot.HandlerFunc { + return func(ctx telebot.Context) error { + const op = "callback.LessonStatus" + + traceID := ctx.Get("trace_id") + log := log.With( + slog.String("op", op), + slog.Any("trace_id", traceID), + ) + uid := ctx.Sender().ID + + var data []string + switch status { + case backoffice.CloseLesson: + data = strings.Split(strings.TrimPrefix(ctx.Callback().Data, "\fclose_lesson_"), "_") + case backoffice.OpenLesson: + data = strings.Split(strings.TrimPrefix(ctx.Callback().Data, "\fopen_lesson_"), "_") + } + if len(data) != 2 { + log.Warn("data is not correct", slog.Any("data", data)) + return ctx.Send("⚠️ Ошибка при анализе данных от кнопки") + } + + if err := ls.SetLessonStatus(uid, data[0], data[1], status, traceID); err != nil { + log.Warn("error while refreshing group", sl.Err(err)) + return fmt.Errorf("%s error while refreshing group: %w", op, err) + } + + return ctx.Send("Статус переключен!") + } +} diff --git a/internal/telegram/handlers/callback/refreshGroups.go b/internal/telegram/handlers/callback/refreshGroups.go new file mode 100644 index 0000000..797235e --- /dev/null +++ b/internal/telegram/handlers/callback/refreshGroups.go @@ -0,0 +1,42 @@ +package callback + +import ( + "algobot/internal/lib/logger/sl" + "algobot/internal/services/groups" + "errors" + "fmt" + "gopkg.in/telebot.v4" + "log/slog" +) + +type GroupRefresher interface { + RefreshGroup(uid int64, traceID interface{}) error +} + +func RefreshGroup(refresher GroupRefresher, log *slog.Logger) telebot.HandlerFunc { + return func(ctx telebot.Context) error { + const op = "callback.NewChangeNotification" + + traceID := ctx.Get("trace_id") + log := log.With( + slog.String("op", op), + slog.Any("trace_id", traceID), + ) + uid := ctx.Sender().ID + + if err := ctx.Edit("⚙️ Обновляю группы..."); err != nil { + log.Warn("error while editing message", sl.Err(err)) + return fmt.Errorf("%s error while editing message: %w", op, err) + } + + if err := refresher.RefreshGroup(uid, traceID); err != nil { + if errors.Is(err, groups.ErrNoGroups) { + return ctx.Edit("У вас не нашлось ни 1 группы!\nПроверьте ваши cookie") + } + log.Warn("error while refreshing group", sl.Err(err)) + return fmt.Errorf("%s error while refreshing group: %w", op, err) + } + + return ctx.Edit("Успешно обновлено!") + } +} diff --git a/internal/telegram/handlers/text/absentKids.go b/internal/telegram/handlers/text/absentKids.go new file mode 100644 index 0000000..ec7ceb2 --- /dev/null +++ b/internal/telegram/handlers/text/absentKids.go @@ -0,0 +1,50 @@ +package text + +import ( + "algobot/internal/domain/telegram/keyboards" + "algobot/internal/lib/logger/sl" + "algobot/internal/services/groups" + "errors" + "fmt" + "gopkg.in/telebot.v4" + "log/slog" + "strings" + "time" +) + +func NewAbsentKids(actualGroup ActualGroup, log *slog.Logger) telebot.HandlerFunc { + return func(ctx telebot.Context) error { + const op = "text.NewAbsentKids" + + uid := ctx.Sender().ID + traceID := ctx.Get("trace_id") + data := getDate(ctx.Message().Text) + + log := log.With( + slog.String("op", op), + slog.Any("trace_id", traceID), + ) + + date, err := time.Parse("2006-01-02 15:04", data) + if err != nil { + return ctx.Reply("Не удалось распарсить дату, пожалуйста, введите дату в формате YYYY-MM-DD HH:MM") + } + + group, err := actualGroup.CurrentGroup(uid, date, traceID) + if err != nil { + if errors.Is(err, groups.ErrNoGroups) { + return ctx.Send("В данный момент, никакой группы не найдено!") + } + if errors.Is(err, groups.ErrNotValidCookie) { + return ctx.Send("Вам необходимо установить свои cookie!") + } + log.Warn("error while fetching CurrentGroup", sl.Err(err)) + return fmt.Errorf("%s error while fetching CurrentGroup: %w", op, err) + } + + return ctx.Reply(GetMissingMessage(group), keyboards.MissingKids(group.GroupID, group.LessonID), telebot.ModeMarkdown) + } +} +func getDate(text string) string { + return strings.TrimSpace(strings.TrimLeft(text, "/abs")) +} diff --git a/internal/telegram/handlers/text/ai.go b/internal/telegram/handlers/text/ai.go new file mode 100644 index 0000000..073016b --- /dev/null +++ b/internal/telegram/handlers/text/ai.go @@ -0,0 +1,56 @@ +package text + +import ( + "algobot/internal/domain/models" + "algobot/internal/domain/telegram/keyboards" + "algobot/internal/lib/fsm" + "algobot/internal/lib/logger/sl" + "gopkg.in/telebot.v4" + "log/slog" + "strings" +) + +type AIInformer interface { + GetAIInfo(traceID interface{}) (models.AIInfo, error) +} + +type AIStater interface { + SetState(uid int64, state fsm.State) +} + +func NewAI(ai AIInformer, log *slog.Logger, stater AIStater) telebot.HandlerFunc { + return func(ctx telebot.Context) error { + const op = "text.NewAI" + + log = log.With( + slog.String("op", op), + slog.Any("trace_id", ctx.Get("trace_id")), + ) + uid := ctx.Sender().ID + + info, err := ai.GetAIInfo(ctx.Get("trace_id")) + if err != nil { + log.Warn("error while GetAIInfo", sl.Err(err)) + return ctx.Send("Упс, AI сейчас не работает!") + } + + stater.SetState(uid, fsm.ChattingAI) + return ctx.Send(GetAIMessage(info), keyboards.RejectKeyboard(), telebot.ModeMarkdown) + } +} + +func GetAIMessage(info models.AIInfo) string { + sb := strings.Builder{} + sb.WriteString("Информация о включенных моделях:\n\n") + sb.WriteString("***Текст:*** ") + sb.WriteString(info.TextModel) + sb.WriteString(" 🗒\n***Изображение:*** ") + sb.WriteString(info.ImageModel) + sb.WriteString(" 🖼\n\n") + sb.WriteString("```guide\n") + sb.WriteString("/reset - отчистить память модели") + sb.WriteString("\n/image promt - сгенерировать изображение") + sb.WriteString("\n```") + sb.WriteString("\nДля текстового запроса - просто напиши в чат") + return sb.String() +} diff --git a/internal/telegram/handlers/text/chatAI.go b/internal/telegram/handlers/text/chatAI.go new file mode 100644 index 0000000..0b30b0d --- /dev/null +++ b/internal/telegram/handlers/text/chatAI.go @@ -0,0 +1,42 @@ +package text + +import ( + "algobot/internal/lib/logger/sl" + "fmt" + "gopkg.in/telebot.v4" + "log/slog" +) + +type Chatter interface { + ChatAI(uid int64, message string, traceID interface{}) (string, error) +} + +func ChatAI(chatter Chatter, log *slog.Logger) telebot.HandlerFunc { + return func(ctx telebot.Context) error { + const op = "text.ChatAI" + + uid := ctx.Sender().ID + message := ctx.Message().Text + traceID := ctx.Get("trace_id") + log = log.With( + slog.String("op", op), + slog.Any("trace_id", traceID), + ) + + msg, err := ctx.Bot().Reply(ctx.Message(), "⚙️ Думаю что ответить ...") + if err != nil { + log.Warn("failed to send prepare msg", sl.Err(err)) + return fmt.Errorf("%s failed to send prepare msg: %w", op, err) + } + + resp, err := chatter.ChatAI(uid, message, traceID) + if err != nil { + log.Warn("failed to ChatAI", sl.Err(err)) + ctx.Bot().Edit(msg, "⚠️ К сожалению, я не смог ответить на ваше сообщение, попробуйте снова чуть позже") + return fmt.Errorf("%s failed to chatting ai: %w", op, err) + } + + _, err = ctx.Bot().Edit(msg, resp, telebot.ModeMarkdown) + return err + } +} diff --git a/internal/telegram/handlers/text/generateImage.go b/internal/telegram/handlers/text/generateImage.go new file mode 100644 index 0000000..17269a3 --- /dev/null +++ b/internal/telegram/handlers/text/generateImage.go @@ -0,0 +1,49 @@ +package text + +import ( + "algobot/internal/lib/logger/sl" + "fmt" + "gopkg.in/telebot.v4" + "log/slog" + "strings" +) + +type GeneratorImage interface { + GenerateImage(uid int64, promt string, traceID interface{}) (string, error) +} + +func GenerateImage(generator GeneratorImage, log *slog.Logger) telebot.HandlerFunc { + return func(ctx telebot.Context) error { + const op = "text.GenerateImage" + uid := ctx.Sender().ID + traceID := ctx.Get("trace_id") + promt := getPromtFromMessage(ctx.Message().Text) + log = log.With( + slog.String("op", op), + slog.Any("trace_id", traceID), + ) + + msg, err := ctx.Bot().Reply(ctx.Message(), "⚙️ Генерирую изображение ...") + if err != nil { + log.Warn("failed to send prepare msg", sl.Err(err)) + return fmt.Errorf("%s failed to send prepare msg: %w", op, err) + } + + imgURL, err := generator.GenerateImage(uid, promt, traceID) + if err != nil { + log.Warn("failed to generate image", sl.Err(err)) + ctx.Bot().Edit(msg, "⚠️ К сожалению, я не смог сгенерировать изображение, попробуйте снова чуть позже") + return fmt.Errorf("%s failed to generate image: %w", op, err) + } + + _, err = ctx.Bot().Edit(msg, &telebot.Photo{ + File: telebot.FromURL(imgURL), + }) + + return err + } +} + +func getPromtFromMessage(text string) string { + return strings.TrimSpace(strings.TrimLeft(text, "/image")) +} diff --git a/internal/telegram/handlers/text/missingKids.go b/internal/telegram/handlers/text/missingKids.go new file mode 100644 index 0000000..094c905 --- /dev/null +++ b/internal/telegram/handlers/text/missingKids.go @@ -0,0 +1,67 @@ +package text + +import ( + "algobot/internal/domain/models" + "algobot/internal/domain/telegram/keyboards" + "algobot/internal/lib/logger/sl" + "algobot/internal/services/groups" + "errors" + "fmt" + "gopkg.in/telebot.v4" + "log/slog" + "strings" + "time" +) + +type ActualGroup interface { + CurrentGroup(uid int64, time time.Time, traceID interface{}) (models.CurrentGroup, error) +} + +func NewMissingKids(log *slog.Logger, actualGroup ActualGroup) telebot.HandlerFunc { + return func(ctx telebot.Context) error { + const op = "text.NewMissingKids" + traceID := ctx.Get("trace_id") + uid := ctx.Sender().ID + + log := log.With( + slog.String("op", op), + slog.Any("trace_id", traceID), + ) + + group, err := actualGroup.CurrentGroup(uid, time.Now(), traceID) + if err != nil { + if errors.Is(err, groups.ErrNoGroups) { + return ctx.Send("В данный момент, никакой группы не найдено!") + } + if errors.Is(err, groups.ErrNotValidCookie) { + return ctx.Send("Вам необходимо установить свои cookie!") + } + log.Warn("error while fetching CurrentGroup", sl.Err(err)) + return fmt.Errorf("%s error while fetching CurrentGroup: %w", op, err) + } + + return ctx.Send(GetMissingMessage(group), keyboards.MissingKids(group.GroupID, group.LessonID), telebot.ModeMarkdown) + } +} + +func GetMissingMessage(gr models.CurrentGroup) string { + miss := strings.Builder{} + miss.WriteString("\n```Отсутствующие\n") + for _, kid := range gr.MissingKids { + miss.WriteString(kid.Fullname) + if kid.Count > 1 { + miss.WriteString(fmt.Sprintf(" (Уже %d занятие)", kid.Count)) + } + miss.WriteString("\n") + } + miss.WriteString("```") + + sb := strings.Builder{} + sb.WriteString(fmt.Sprintf("Группа: %s", gr.Title)) + sb.WriteString(fmt.Sprintf("\nЛекция: %s\n", gr.Lesson)) + sb.WriteString(fmt.Sprintf("\nОбщее число детей: %d", len(gr.Kids))) + sb.WriteString(fmt.Sprintf("\nОтсутствуют: %d\n", len(gr.MissingKids))) + sb.WriteString(miss.String()) + + return sb.String() +} diff --git a/internal/telegram/handlers/text/myGroups.go b/internal/telegram/handlers/text/myGroups.go new file mode 100644 index 0000000..ffd47f9 --- /dev/null +++ b/internal/telegram/handlers/text/myGroups.go @@ -0,0 +1,117 @@ +package text + +import ( + "algobot/internal/domain" + "algobot/internal/domain/models" + "algobot/internal/domain/telegram/keyboards" + "algobot/internal/lib/logger/sl" + "fmt" + "gopkg.in/telebot.v4" + "log/slog" + "strconv" + "strings" + "time" +) + +var locales = map[time.Weekday]string{ + time.Monday: "пн", + time.Tuesday: "вт", + time.Wednesday: "ср", + time.Thursday: "чт", + time.Friday: "пт", + time.Saturday: "сб", + time.Sunday: "вс", +} + +type Grouper interface { + Groups(uid int64, traceID interface{}) ([]models.Group, error) +} + +type GroupSerializer interface { + Serialize(msg domain.SerializeMessage) (string, error) +} +type MyGroup struct { + log *slog.Logger + grouper Grouper + serializer GroupSerializer + botName string +} + +func NewMyGroup(log *slog.Logger, grouper Grouper, serializer GroupSerializer, name string) *MyGroup { + return &MyGroup{ + log: log, + grouper: grouper, + serializer: serializer, + botName: name, + } +} + +func (g *MyGroup) ServeContext(ctx telebot.Context) error { + const op = "text.MyGroup.ServeContext" + log := g.log.With( + slog.String("op", op), + slog.Any("trace_id", ctx.Get("trace_id")), + ) + + uid := ctx.Sender().ID + groups, err := g.grouper.Groups(uid, ctx.Get("trace_id")) + if err != nil { + log.Warn("error while getting groups", sl.Err(err)) + return ctx.Send(fmt.Sprintf("[%s] Ошибка при получении групп!", ctx.Get("trace_id")), telebot.ModeHTML) + } + + return ctx.Send(g.msgMyGroups(groups, ctx), telebot.ModeMarkdown, keyboards.RefreshGroups()) +} + +func (g *MyGroup) msgMyGroups(groups []models.Group, ctx telebot.Context) string { + s := &strings.Builder{} + s.WriteString(fmt.Sprintf("Всего групп: %d\n", len(groups))) + + if len(groups) == 0 { + s.WriteString("Попробуйте обновить группы!") + return s.String() + } + + beforeDay := groups[0].TimeLesson.Weekday() + c := 1 + for _, group := range groups { + if beforeDay != group.TimeLesson.Weekday() { + c = 1 + beforeDay = group.TimeLesson.Weekday() + s.WriteString("\n") + } + s.WriteString("\n") + s.WriteString(fmt.Sprintf( + "%d. %s 🕐 %s %s", + c, + g.getFormattedTitle(group, ctx), + g.getLocale(group.TimeLesson), + group.TimeLesson.Format("15:04"), + )) + c += 1 + } + + return s.String() +} + +func (g *MyGroup) getFormattedTitle(group models.Group, ctx telebot.Context) string { + const op = "text.MyGroup.getFormattedTitle" + log := g.log.With( + slog.String("op", op), + slog.Any("trace_id", ctx.Get("trace_id")), + ) + + serialized, err := g.serializer.Serialize(domain.SerializeMessage{ + Type: domain.GroupType, + Data: []string{strconv.Itoa(group.GroupID)}, + }) + if err != nil { + log.Warn("error while serializing group", sl.Err(err)) + return group.Title + } + return fmt.Sprintf("[%s](t.me/%s?start=%s)", group.Title, g.botName, serialized) +} + +func (g *MyGroup) getLocale(t time.Time) string { + return locales[t.Weekday()] +} diff --git a/internal/telegram/handlers/text/reset.go b/internal/telegram/handlers/text/reset.go new file mode 100644 index 0000000..321bca6 --- /dev/null +++ b/internal/telegram/handlers/text/reset.go @@ -0,0 +1,32 @@ +package text + +import ( + "algobot/internal/lib/logger/sl" + "fmt" + "gopkg.in/telebot.v4" + "log/slog" +) + +type Reseter interface { + ResetHistory(uid int64, traceID interface{}) error +} + +func NewReset(reseter Reseter, log *slog.Logger) telebot.HandlerFunc { + return func(ctx telebot.Context) error { + const op = "text.NewReset" + + uid := ctx.Sender().ID + traceID := ctx.Get("trace_id") + log = log.With( + slog.String("op", op), + slog.Any("trace_id", traceID), + ) + + if err := reseter.ResetHistory(uid, traceID); err != nil { + log.Warn("failed to reset history", sl.Err(err)) + return fmt.Errorf("%s failed to reset history: %w", op, err) + } + + return ctx.Send("История успешно отчищена") + } +} diff --git a/internal/telegram/handlers/text/sendingCookie.go b/internal/telegram/handlers/text/sendingCookie.go new file mode 100644 index 0000000..95a87a3 --- /dev/null +++ b/internal/telegram/handlers/text/sendingCookie.go @@ -0,0 +1,40 @@ +package text + +import ( + "algobot/internal/domain/telegram/keyboards" + "algobot/internal/lib/fsm" + "algobot/internal/lib/logger/sl" + "fmt" + "gopkg.in/telebot.v4" + "log/slog" +) + +type CookieSetter interface { + SetCookie(uid int64, cookie string) error +} + +type CookieStater interface { + SetState(uid int64, state fsm.State) +} + +func NewSendingCookie(log *slog.Logger, cookieSetter CookieSetter, stater CookieStater) telebot.HandlerFunc { + return func(ctx telebot.Context) error { + const op = "text.NewSendingCookie" + + log = log.With( + slog.String("op", op), + slog.Any("trace_id", ctx.Get("trace_id")), + ) + + uid := ctx.Sender().ID + cookie := ctx.Message().Text + + if err := cookieSetter.SetCookie(uid, cookie); err != nil { + log.Warn("error while setting cookie", sl.Err(err)) + return fmt.Errorf("%s error while setting cookie: %w", op, err) + } + + stater.SetState(uid, fsm.Default) + return ctx.Send("Cookie успешно установлены", keyboards.Start()) + } +} diff --git a/internal/telegram/handlers/text/settings.go b/internal/telegram/handlers/text/settings.go new file mode 100644 index 0000000..8679aa7 --- /dev/null +++ b/internal/telegram/handlers/text/settings.go @@ -0,0 +1,59 @@ +package text + +import ( + "algobot/internal/domain/telegram/keyboards" + "algobot/internal/lib/logger/sl" + "fmt" + "gopkg.in/telebot.v4" + "log/slog" + "strings" +) + +type UserInformer interface { + Cookies(uid int64) (string, error) + Notification(uid int64) (bool, error) +} + +func NewSettings(uInformer UserInformer, log *slog.Logger) telebot.HandlerFunc { + return func(ctx telebot.Context) error { + const op = "text.NewSettings" + + uid := ctx.Sender().ID + log = log.With( + slog.String("op", op), + slog.Any("trace_id", ctx.Get("trace_id")), + ) + + cookies, err := uInformer.Cookies(uid) + if err != nil { + log.Warn("error while get cookies", sl.Err(err)) + return fmt.Errorf("%s: error while get cookies %w", op, err) + } + + notification, err := uInformer.Notification(uid) + if err != nil { + log.Warn("error while get notification", sl.Err(err)) + return fmt.Errorf("%s: error while get notification %w", op, err) + } + + return ctx.Send(GetSettingsMessage(cookies, notification), keyboards.Settings()) + } +} + +func GetSettingsMessage(cookies string, notification bool) string { + sb := strings.Builder{} + sb.WriteString("🔧 Ваши настройки:\n") + sb.WriteString("\nКуки: ") + if cookies != "" { + sb.WriteString("✅") + } else { + sb.WriteString("✖️") + } + sb.WriteString("\nУведомление от чата:") + if notification { + sb.WriteString("✅") + } else { + sb.WriteString("✖️") + } + return sb.String() +} diff --git a/internal/telegram/handlers/text/start.go b/internal/telegram/handlers/text/start.go new file mode 100644 index 0000000..a1e798e --- /dev/null +++ b/internal/telegram/handlers/text/start.go @@ -0,0 +1,19 @@ +package text + +import ( + "algobot/internal/domain/telegram/keyboards" + "algobot/internal/lib/fsm" + "gopkg.in/telebot.v4" +) + +type SetStater interface { + SetState(uid int64, state fsm.State) +} + +func NewStart(setStater SetStater) telebot.HandlerFunc { + return func(ctx telebot.Context) error { + setStater.SetState(ctx.Sender().ID, fsm.Default) + + return ctx.Send("Открыто главное меню:", keyboards.Start()) + } +} diff --git a/internal/telegram/handlers/text/viewInformer.go b/internal/telegram/handlers/text/viewInformer.go new file mode 100644 index 0000000..ea65f1f --- /dev/null +++ b/internal/telegram/handlers/text/viewInformer.go @@ -0,0 +1,186 @@ +package text + +import ( + "algobot/internal/domain" + "algobot/internal/domain/models" + "algobot/internal/lib/logger/sl" + "fmt" + "gopkg.in/telebot.v4" + "log/slog" + "regexp" + "strconv" + "strings" +) + +var statuses = map[int]string{ + 0: "🟢 Учится", + 20: "🔴 Выбыл", + 10: "🟡 Переведен", +} + +type ViewFetcher interface { + GroupView(uid int64, groupID string, traceID interface{}) (models.GroupView, error) + KidView(uid int64, kidID string, groupId string, traceID interface{}) (models.KidView, error) +} + +type Serializator interface { + Serialize(msg domain.SerializeMessage) (string, error) + Deserialize(decoded string) (*domain.SerializeMessage, error) +} + +type ViewInformer struct { + serdes Serializator + viewFetcher ViewFetcher + log *slog.Logger + botName string +} + +func NewViewInformer(serdes Serializator, viewFetcher ViewFetcher, log *slog.Logger, botName string) *ViewInformer { + return &ViewInformer{serdes: serdes, viewFetcher: viewFetcher, log: log, botName: botName} +} + +func (v *ViewInformer) ServeContext(ctx telebot.Context) error { + const op = "text.GenerateImage" + + uid := ctx.Sender().ID + traceID := ctx.Get("trace_id") + data := getData(ctx.Message().Text) + + log := v.log.With( + slog.String("op", op), + slog.Any("trace_id", traceID), + ) + + encodedMsg, err := v.serdes.Deserialize(data) + if err != nil { + log.Warn("can't get ser type", sl.Err(err)) + return ctx.Send("⚠️ Ошибка при расшифровке запроса!") + } + + switch encodedMsg.Type { + case domain.UserType: + view, err := v.userInfo(encodedMsg, uid, traceID) + if err != nil { + log.Warn("can't get group info", sl.Err(err)) + return ctx.Send("⚠️ Невозможно получить данного ученика!") + } + return ctx.Send(view, telebot.ModeHTML, telebot.NoPreview) + case domain.GroupType: + view, err := v.groupInfo(encodedMsg, uid, traceID) + if err != nil { + log.Warn("can't get group info", sl.Err(err)) + return ctx.Send("⚠️ Невозможно получить данную группу!") + } + return ctx.Send(view, telebot.ModeHTML, telebot.NoPreview) + default: + return ctx.Send("⚠️ Не удалось определить обработчик!") + } +} + +func (v *ViewInformer) userInfo(data *domain.SerializeMessage, uid int64, traceID interface{}) (string, error) { + const op = "viewInformer.groupInfo" + + if len(data.Data) != 2 { + return "", fmt.Errorf("%s: kid view required 2 fields", op) + } + + full, err := v.viewFetcher.KidView(uid, data.Data[0], data.Data[1], traceID) + if err != nil { + return "", fmt.Errorf("%s: %w", op, err) + } + + return v.GetKidInfoMessage(full), nil +} + +func (v *ViewInformer) GetKidInfoMessage(full models.KidView) string { + parentPhone := regexp.MustCompile(`[^0-9+]`).ReplaceAllString(full.Kid.Phone, "") + + msg := strings.Builder{} + if full.Extra == models.NotAccessible { + msg.WriteString(fmt.Sprintf("⚠️ У вас больше нету доступа к ребенку\n")) + } + msg.WriteString(fmt.Sprintf("%s\n", full.Kid.FullName)) + msg.WriteString(fmt.Sprintf("Возраст: %d\n", full.Kid.Age)) + msg.WriteString(fmt.Sprintf("День рождения: %s\n", full.Kid.BirthDate.Format("2006-01-02"))) + msg.WriteString("\nДанные от аккаунта:\n") + msg.WriteString(fmt.Sprintf("Логин: %s\n", full.Kid.Username)) + msg.WriteString(fmt.Sprintf("Пароль: %s\n", full.Kid.Password)) + msg.WriteString("\nРодитель:\n") + msg.WriteString(fmt.Sprintf("Имя: %s\n", full.Kid.ParentName)) + + msg.WriteString(fmt.Sprintf("Телефон: %s 🟩 Whatsapp\n", parentPhone, strings.TrimPrefix(parentPhone, "+"))) + msg.WriteString(fmt.Sprintf("Почта: %s\n", full.Kid.Email)) + msg.WriteString("\nГруппы\n") + + groups := full.Kid.Groups + for i := len(groups) - 1; i >= 0; i-- { + msg.WriteString(fmt.Sprintf("%d . %s %s\n", len(groups)-i, groups[i].ID, groups[i].Title, groups[i].Content)) + v, ok := statuses[groups[i].Status] + if !ok { + v = fmt.Sprintf("Статус [%d]", groups[i].Status) + } + msg.WriteString(fmt.Sprintf("%s (%s - %s)\n\n", v, groups[i].StartTime.Format("2006-01-02"), groups[i].EndTime.Format("2006-01-02"))) + } + m := msg.String() + return m +} + +func (v *ViewInformer) groupInfo(data *domain.SerializeMessage, uid int64, traceID interface{}) (string, error) { + const op = "viewInformer.groupInfo" + + full, err := v.viewFetcher.GroupView(uid, data.Data[0], traceID) + if err != nil { + return "", fmt.Errorf("%s: %w", op, err) + } + + return v.GetGroupInfoMessage(full), nil +} + +func (v *ViewInformer) GetGroupInfoMessage(full models.GroupView) string { + msg := strings.Builder{} + msg.WriteString(fmt.Sprintf("%s %s\n", full.GroupID, full.GroupTitle, full.GroupContent)) + msg.WriteString(fmt.Sprintf("\nСледующая лекция: %s\n", full.NextLessonTime)) + msg.WriteString(fmt.Sprintf("Всего пройдено %d лекций из %d\n", full.LessonsPassed, full.LessonsTotal)) + msg.WriteString(fmt.Sprintf("\nАктивные дети: %d | Выбыло: %d | Всего: %d\n", len(full.ActiveKids), len(full.NotActiveKids), len(full.ActiveKids)+len(full.NotActiveKids))) + msg.WriteString("Активные дети:\n") + + for i, kid := range full.ActiveKids { + ser, err := v.serdes.Serialize(domain.SerializeMessage{ + Type: domain.UserType, + Data: []string{strconv.Itoa(kid.ID), strconv.Itoa(full.GroupID)}, + }) + if err != nil { + msg.WriteString(fmt.Sprintf("%d. %s\n", i+1, kid.FullName)) + continue + } + + msg.WriteString(fmt.Sprintf("%d. %s\n", i+1, v.botName, ser, kid.FullName)) + } + + msg.WriteString("Выбыли дети:\n") + for i, kid := range full.NotActiveKids { + ser, err := v.serdes.Serialize(domain.SerializeMessage{ + Type: domain.UserType, + Data: []string{strconv.Itoa(kid.ID), strconv.Itoa(full.GroupID)}, + }) + if err != nil { + if kid.LastGroup.ID == full.GroupID { + msg.WriteString(fmt.Sprintf("%d. %s (🔴 Выбыл: %s)\n", i+1, kid.FullName, kid.LastGroup.EndTime.Format("2006-01-02"))) + } else { + msg.WriteString(fmt.Sprintf("%d. %s (🟡 Переведен: %s)\n", i+1, kid.FullName, kid.LastGroup.StartTime.Format("2006-01-02"))) + } + } + + if kid.LastGroup.ID == full.GroupID { + msg.WriteString(fmt.Sprintf("%d. %s (🔴 Выбыл: %s)\n", i+1, v.botName, ser, kid.FullName, kid.LastGroup.EndTime.Format("2006-01-02"))) + } else { + msg.WriteString(fmt.Sprintf("%d. %s (🟡 Переведен: %s)\n", i+1, v.botName, ser, kid.FullName, kid.LastGroup.StartTime.Format("2006-01-02"))) + } + } + + return msg.String() +} + +func getData(text string) string { + return strings.TrimSpace(strings.TrimLeft(text, "/start")) +} diff --git a/internal/telegram/middleware/auth/auth.go b/internal/telegram/middleware/auth/auth.go new file mode 100644 index 0000000..6210b14 --- /dev/null +++ b/internal/telegram/middleware/auth/auth.go @@ -0,0 +1,44 @@ +package auth + +import ( + "algobot/internal/lib/logger/sl" + "fmt" + "gopkg.in/telebot.v4" + "log/slog" +) + +type Auther interface { + IsRegistered(uid int64) (bool, error) + Register(uid int64) error +} + +func New(auth Auther, log *slog.Logger) telebot.MiddlewareFunc { + return func(next telebot.HandlerFunc) telebot.HandlerFunc { + log = log.With( + slog.String("component", "middleware/auth"), + ) + + return func(ctx telebot.Context) error { + traceID := ctx.Get("trace_id") + log.With("trace_id", traceID) + + uid := ctx.Sender().ID + + isReg, err := auth.IsRegistered(uid) + if err != nil { + log.Warn("error while checking if user exists", sl.Err(err)) + return fmt.Errorf("error while checking if user exists: %w", err) + } + if !isReg { + if err := auth.Register(uid); err != nil { + log.Warn("error while register user", sl.Err(err)) + return fmt.Errorf("error while register user: %w", err) + } + + log.Info("new registration", slog.Int64("uid", uid)) + } + + return next(ctx) + } + } +} diff --git a/internal/telegram/middleware/logger/logger.go b/internal/telegram/middleware/logger/logger.go new file mode 100644 index 0000000..32097e4 --- /dev/null +++ b/internal/telegram/middleware/logger/logger.go @@ -0,0 +1,39 @@ +package logger + +import ( + "fmt" + tele "gopkg.in/telebot.v4" + "log/slog" +) + +func New(log *slog.Logger) tele.MiddlewareFunc { + return func(next tele.HandlerFunc) tele.HandlerFunc { + log = log.With( + slog.String("component", "middleware/logger"), + ) + + return func(c tele.Context) error { + traceID := c.Get("trace_id") + + if cb := c.Callback(); cb != nil { + log.Info("incoming callback", + slog.Int64("from_id", c.Sender().ID), + slog.String("from", c.Sender().Username), + slog.String("full_name", fmt.Sprintf("%s %s", c.Sender().FirstName, c.Sender().LastName)), + slog.String("message", cb.Data), + ) + } else { + msg := c.Message() + log.Info("incoming message", + slog.Int64("from_id", c.Sender().ID), + slog.String("from", c.Sender().Username), + slog.String("full_name", fmt.Sprintf("%s %s", c.Sender().FirstName, c.Sender().LastName)), + slog.String("message", msg.Text), + slog.Any("trace_id", traceID), + ) + } + + return next(c) + } + } +} diff --git a/internal/telegram/middleware/rate/rate.go b/internal/telegram/middleware/rate/rate.go new file mode 100644 index 0000000..189a359 --- /dev/null +++ b/internal/telegram/middleware/rate/rate.go @@ -0,0 +1,47 @@ +package rate + +import ( + "algobot/internal/config" + "golang.org/x/time/rate" + "gopkg.in/telebot.v4" + "log/slog" + "sync" +) + +var userLimits = make(map[int64]*rate.Limiter) +var mu sync.Mutex + +func New(log *slog.Logger, rateCfg config.RateLimit) telebot.MiddlewareFunc { + return func(next telebot.HandlerFunc) telebot.HandlerFunc { + log = log.With( + slog.String("component", "middleware/rate"), + ) + + return func(ctx telebot.Context) error { + traceID := ctx.Get("trace_id") + log = log.With("trace_id", traceID) + uid := ctx.Sender().ID + + limiter := getUserLimiter(uid, rateCfg) + if !limiter.Allow() { + log.Warn("user limit exceeded") + return ctx.Send("🙈 Слишком много запросов, давай помедленнее!") + } + + return next(ctx) + } + } +} + +func getUserLimiter(uid int64, cfg config.RateLimit) *rate.Limiter { + mu.Lock() + defer mu.Unlock() + + if limiter, ok := userLimits[uid]; ok { + return limiter + } + + limiter := rate.NewLimiter(rate.Every(cfg.FillPeriod), cfg.BucketLimit) + userLimits[uid] = limiter + return limiter +} diff --git a/internal/telegram/middleware/stater/stater.go b/internal/telegram/middleware/stater/stater.go new file mode 100644 index 0000000..64e636d --- /dev/null +++ b/internal/telegram/middleware/stater/stater.go @@ -0,0 +1,23 @@ +package stater + +import ( + "algobot/internal/lib/fsm" + router "github.com/LZTD1/telebot-context-router" + "gopkg.in/telebot.v4" +) + +type Stater interface { + State(uid int64) fsm.State +} + +func New(stater Stater, onState fsm.State) func(next router.RouteHandler) router.RouteHandler { + return func(next router.RouteHandler) router.RouteHandler { + return router.HandlerFunc(func(ctx telebot.Context) error { + if stater.State(ctx.Sender().ID) == onState { + return next.ServeContext(ctx) + } + + return nil + }) + } +} diff --git a/internal/telegram/middleware/trace/trace.go b/internal/telegram/middleware/trace/trace.go new file mode 100644 index 0000000..e9cd04e --- /dev/null +++ b/internal/telegram/middleware/trace/trace.go @@ -0,0 +1,30 @@ +package trace + +import ( + "github.com/google/uuid" + tele "gopkg.in/telebot.v4" + "log/slog" +) + +// New +// Generates a unique trace_id for each request, which can be accessed from the context like this: +// var context tele.Context +// traceID := context.Get("trace_id") +func New(log *slog.Logger) tele.MiddlewareFunc { + return func(next tele.HandlerFunc) tele.HandlerFunc { + log = log.With( + slog.String("component", "middleware/trace"), + ) + + return func(c tele.Context) error { + newUUID, err := uuid.NewUUID() + if err != nil { + log.Warn("failed to generate UUID") + } + + c.Set("trace_id", newUUID.String()) + + return next(c) + } + } +} diff --git a/cmd/migrations/createUsers.sql b/migrations/01_create_table_users.sql similarity index 84% rename from cmd/migrations/createUsers.sql rename to migrations/01_create_table_users.sql index 7753700..6b3243c 100644 --- a/cmd/migrations/createUsers.sql +++ b/migrations/01_create_table_users.sql @@ -1,9 +1,12 @@ +-- +goose Up CREATE TABLE users ( id INTEGER PRIMARY KEY AUTOINCREMENT, uid INTEGER NOT NULL UNIQUE, - user_agent TEXT DEFAULT NULL, cookie TEXT DEFAULT NULL, last_notification_msg TEXT DEFAULT NULL, notification INTEGER DEFAULT 0 -); \ No newline at end of file +); + +-- +goose Down +DROP TABLE users; \ No newline at end of file diff --git a/cmd/migrations/createGroups.sql b/migrations/02_create_table_groups.sql similarity index 79% rename from cmd/migrations/createGroups.sql rename to migrations/02_create_table_groups.sql index 58c2f43..9df362e 100644 --- a/cmd/migrations/createGroups.sql +++ b/migrations/02_create_table_groups.sql @@ -1,3 +1,4 @@ +-- +goose Up CREATE TABLE groups ( id INTEGER PRIMARY KEY AUTOINCREMENT, @@ -5,4 +6,7 @@ CREATE TABLE groups owner_id INTEGER, title TEXT NOT NULL, time_lesson TEXT NOT NULL -); \ No newline at end of file +); + +-- +goose Down +DROP TABLE groups; \ No newline at end of file diff --git a/protos/ai.pb.go b/protos/ai.pb.go index 28edd9c..95d7593 100644 --- a/protos/ai.pb.go +++ b/protos/ai.pb.go @@ -1,10 +1,10 @@ // Code generated by protoc-gen-go. DO NOT EDIT. // versions: -// protoc-gen-go v1.36.5 -// protoc v5.29.3 +// protoc-gen-go v1.36.6 +// protoc v6.30.0 // source: protos/ai.proto -package pkg +package aiv1 import ( protoreflect "google.golang.org/protobuf/reflect/protoreflect" @@ -21,6 +21,343 @@ const ( _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20) ) +type ModelType int32 + +const ( + ModelType_MODEL_UNSPECIFIED ModelType = 0 + ModelType_IMAGE_MODEL ModelType = 1 + ModelType_TEXT_MODEL ModelType = 2 +) + +// Enum value maps for ModelType. +var ( + ModelType_name = map[int32]string{ + 0: "MODEL_UNSPECIFIED", + 1: "IMAGE_MODEL", + 2: "TEXT_MODEL", + } + ModelType_value = map[string]int32{ + "MODEL_UNSPECIFIED": 0, + "IMAGE_MODEL": 1, + "TEXT_MODEL": 2, + } +) + +func (x ModelType) Enum() *ModelType { + p := new(ModelType) + *p = x + return p +} + +func (x ModelType) String() string { + return protoimpl.X.EnumStringOf(x.Descriptor(), protoreflect.EnumNumber(x)) +} + +func (ModelType) Descriptor() protoreflect.EnumDescriptor { + return file_protos_ai_proto_enumTypes[0].Descriptor() +} + +func (ModelType) Type() protoreflect.EnumType { + return &file_protos_ai_proto_enumTypes[0] +} + +func (x ModelType) Number() protoreflect.EnumNumber { + return protoreflect.EnumNumber(x) +} + +// Deprecated: Use ModelType.Descriptor instead. +func (ModelType) EnumDescriptor() ([]byte, []int) { + return file_protos_ai_proto_rawDescGZIP(), []int{0} +} + +type GenerateImageRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + Uid int64 `protobuf:"varint,1,opt,name=uid,proto3" json:"uid,omitempty"` + Promt string `protobuf:"bytes,2,opt,name=promt,proto3" json:"promt,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *GenerateImageRequest) Reset() { + *x = GenerateImageRequest{} + mi := &file_protos_ai_proto_msgTypes[0] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *GenerateImageRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*GenerateImageRequest) ProtoMessage() {} + +func (x *GenerateImageRequest) ProtoReflect() protoreflect.Message { + mi := &file_protos_ai_proto_msgTypes[0] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use GenerateImageRequest.ProtoReflect.Descriptor instead. +func (*GenerateImageRequest) Descriptor() ([]byte, []int) { + return file_protos_ai_proto_rawDescGZIP(), []int{0} +} + +func (x *GenerateImageRequest) GetUid() int64 { + if x != nil { + return x.Uid + } + return 0 +} + +func (x *GenerateImageRequest) GetPromt() string { + if x != nil { + return x.Promt + } + return "" +} + +type GenerateImageResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + Url string `protobuf:"bytes,1,opt,name=url,proto3" json:"url,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *GenerateImageResponse) Reset() { + *x = GenerateImageResponse{} + mi := &file_protos_ai_proto_msgTypes[1] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *GenerateImageResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*GenerateImageResponse) ProtoMessage() {} + +func (x *GenerateImageResponse) ProtoReflect() protoreflect.Message { + mi := &file_protos_ai_proto_msgTypes[1] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use GenerateImageResponse.ProtoReflect.Descriptor instead. +func (*GenerateImageResponse) Descriptor() ([]byte, []int) { + return file_protos_ai_proto_rawDescGZIP(), []int{1} +} + +func (x *GenerateImageResponse) GetUrl() string { + if x != nil { + return x.Url + } + return "" +} + +type ChangeModelRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + Type ModelType `protobuf:"varint,1,opt,name=type,proto3,enum=pypkg.ModelType" json:"type,omitempty"` + ModelName string `protobuf:"bytes,2,opt,name=model_name,json=modelName,proto3" json:"model_name,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ChangeModelRequest) Reset() { + *x = ChangeModelRequest{} + mi := &file_protos_ai_proto_msgTypes[2] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ChangeModelRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ChangeModelRequest) ProtoMessage() {} + +func (x *ChangeModelRequest) ProtoReflect() protoreflect.Message { + mi := &file_protos_ai_proto_msgTypes[2] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ChangeModelRequest.ProtoReflect.Descriptor instead. +func (*ChangeModelRequest) Descriptor() ([]byte, []int) { + return file_protos_ai_proto_rawDescGZIP(), []int{2} +} + +func (x *ChangeModelRequest) GetType() ModelType { + if x != nil { + return x.Type + } + return ModelType_MODEL_UNSPECIFIED +} + +func (x *ChangeModelRequest) GetModelName() string { + if x != nil { + return x.ModelName + } + return "" +} + +type ChangeModelResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + Ok bool `protobuf:"varint,1,opt,name=ok,proto3" json:"ok,omitempty"` + Message string `protobuf:"bytes,2,opt,name=message,proto3" json:"message,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ChangeModelResponse) Reset() { + *x = ChangeModelResponse{} + mi := &file_protos_ai_proto_msgTypes[3] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ChangeModelResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ChangeModelResponse) ProtoMessage() {} + +func (x *ChangeModelResponse) ProtoReflect() protoreflect.Message { + mi := &file_protos_ai_proto_msgTypes[3] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ChangeModelResponse.ProtoReflect.Descriptor instead. +func (*ChangeModelResponse) Descriptor() ([]byte, []int) { + return file_protos_ai_proto_rawDescGZIP(), []int{3} +} + +func (x *ChangeModelResponse) GetOk() bool { + if x != nil { + return x.Ok + } + return false +} + +func (x *ChangeModelResponse) GetMessage() string { + if x != nil { + return x.Message + } + return "" +} + +type GetInformationRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *GetInformationRequest) Reset() { + *x = GetInformationRequest{} + mi := &file_protos_ai_proto_msgTypes[4] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *GetInformationRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*GetInformationRequest) ProtoMessage() {} + +func (x *GetInformationRequest) ProtoReflect() protoreflect.Message { + mi := &file_protos_ai_proto_msgTypes[4] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use GetInformationRequest.ProtoReflect.Descriptor instead. +func (*GetInformationRequest) Descriptor() ([]byte, []int) { + return file_protos_ai_proto_rawDescGZIP(), []int{4} +} + +type GetInformationResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + ChatModel string `protobuf:"bytes,1,opt,name=chat_model,json=chatModel,proto3" json:"chat_model,omitempty"` + ImageModel string `protobuf:"bytes,2,opt,name=image_model,json=imageModel,proto3" json:"image_model,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *GetInformationResponse) Reset() { + *x = GetInformationResponse{} + mi := &file_protos_ai_proto_msgTypes[5] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *GetInformationResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*GetInformationResponse) ProtoMessage() {} + +func (x *GetInformationResponse) ProtoReflect() protoreflect.Message { + mi := &file_protos_ai_proto_msgTypes[5] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use GetInformationResponse.ProtoReflect.Descriptor instead. +func (*GetInformationResponse) Descriptor() ([]byte, []int) { + return file_protos_ai_proto_rawDescGZIP(), []int{5} +} + +func (x *GetInformationResponse) GetChatModel() string { + if x != nil { + return x.ChatModel + } + return "" +} + +func (x *GetInformationResponse) GetImageModel() string { + if x != nil { + return x.ImageModel + } + return "" +} + type SuggestRequest struct { state protoimpl.MessageState `protogen:"open.v1"` Uid int64 `protobuf:"varint,1,opt,name=uid,proto3" json:"uid,omitempty"` @@ -31,7 +368,7 @@ type SuggestRequest struct { func (x *SuggestRequest) Reset() { *x = SuggestRequest{} - mi := &file_protos_ai_proto_msgTypes[0] + mi := &file_protos_ai_proto_msgTypes[6] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -43,7 +380,7 @@ func (x *SuggestRequest) String() string { func (*SuggestRequest) ProtoMessage() {} func (x *SuggestRequest) ProtoReflect() protoreflect.Message { - mi := &file_protos_ai_proto_msgTypes[0] + mi := &file_protos_ai_proto_msgTypes[6] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -56,7 +393,7 @@ func (x *SuggestRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use SuggestRequest.ProtoReflect.Descriptor instead. func (*SuggestRequest) Descriptor() ([]byte, []int) { - return file_protos_ai_proto_rawDescGZIP(), []int{0} + return file_protos_ai_proto_rawDescGZIP(), []int{6} } func (x *SuggestRequest) GetUid() int64 { @@ -83,7 +420,7 @@ type SuggestResponse struct { func (x *SuggestResponse) Reset() { *x = SuggestResponse{} - mi := &file_protos_ai_proto_msgTypes[1] + mi := &file_protos_ai_proto_msgTypes[7] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -95,7 +432,7 @@ func (x *SuggestResponse) String() string { func (*SuggestResponse) ProtoMessage() {} func (x *SuggestResponse) ProtoReflect() protoreflect.Message { - mi := &file_protos_ai_proto_msgTypes[1] + mi := &file_protos_ai_proto_msgTypes[7] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -108,7 +445,7 @@ func (x *SuggestResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use SuggestResponse.ProtoReflect.Descriptor instead. func (*SuggestResponse) Descriptor() ([]byte, []int) { - return file_protos_ai_proto_rawDescGZIP(), []int{1} + return file_protos_ai_proto_rawDescGZIP(), []int{7} } func (x *SuggestResponse) GetOk() bool { @@ -134,7 +471,7 @@ type ClearHistoryRequest struct { func (x *ClearHistoryRequest) Reset() { *x = ClearHistoryRequest{} - mi := &file_protos_ai_proto_msgTypes[2] + mi := &file_protos_ai_proto_msgTypes[8] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -146,7 +483,7 @@ func (x *ClearHistoryRequest) String() string { func (*ClearHistoryRequest) ProtoMessage() {} func (x *ClearHistoryRequest) ProtoReflect() protoreflect.Message { - mi := &file_protos_ai_proto_msgTypes[2] + mi := &file_protos_ai_proto_msgTypes[8] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -159,7 +496,7 @@ func (x *ClearHistoryRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use ClearHistoryRequest.ProtoReflect.Descriptor instead. func (*ClearHistoryRequest) Descriptor() ([]byte, []int) { - return file_protos_ai_proto_rawDescGZIP(), []int{2} + return file_protos_ai_proto_rawDescGZIP(), []int{8} } func (x *ClearHistoryRequest) GetUid() int64 { @@ -178,7 +515,7 @@ type ClearHistoryResponse struct { func (x *ClearHistoryResponse) Reset() { *x = ClearHistoryResponse{} - mi := &file_protos_ai_proto_msgTypes[3] + mi := &file_protos_ai_proto_msgTypes[9] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -190,7 +527,7 @@ func (x *ClearHistoryResponse) String() string { func (*ClearHistoryResponse) ProtoMessage() {} func (x *ClearHistoryResponse) ProtoReflect() protoreflect.Message { - mi := &file_protos_ai_proto_msgTypes[3] + mi := &file_protos_ai_proto_msgTypes[9] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -203,7 +540,7 @@ func (x *ClearHistoryResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use ClearHistoryResponse.ProtoReflect.Descriptor instead. func (*ClearHistoryResponse) Descriptor() ([]byte, []int) { - return file_protos_ai_proto_rawDescGZIP(), []int{3} + return file_protos_ai_proto_rawDescGZIP(), []int{9} } func (x *ClearHistoryResponse) GetOk() bool { @@ -215,33 +552,49 @@ func (x *ClearHistoryResponse) GetOk() bool { var File_protos_ai_proto protoreflect.FileDescriptor -var file_protos_ai_proto_rawDesc = string([]byte{ - 0x0a, 0x0f, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x73, 0x2f, 0x61, 0x69, 0x2e, 0x70, 0x72, 0x6f, 0x74, - 0x6f, 0x12, 0x05, 0x70, 0x79, 0x70, 0x6b, 0x67, 0x22, 0x3c, 0x0a, 0x0e, 0x53, 0x75, 0x67, 0x67, - 0x65, 0x73, 0x74, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x10, 0x0a, 0x03, 0x75, 0x69, - 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x03, 0x52, 0x03, 0x75, 0x69, 0x64, 0x12, 0x18, 0x0a, 0x07, - 0x73, 0x75, 0x67, 0x67, 0x65, 0x73, 0x74, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x07, 0x73, - 0x75, 0x67, 0x67, 0x65, 0x73, 0x74, 0x22, 0x3b, 0x0a, 0x0f, 0x53, 0x75, 0x67, 0x67, 0x65, 0x73, - 0x74, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x0e, 0x0a, 0x02, 0x6f, 0x6b, 0x18, - 0x01, 0x20, 0x01, 0x28, 0x08, 0x52, 0x02, 0x6f, 0x6b, 0x12, 0x18, 0x0a, 0x07, 0x72, 0x65, 0x71, - 0x75, 0x65, 0x73, 0x74, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x07, 0x72, 0x65, 0x71, 0x75, - 0x65, 0x73, 0x74, 0x22, 0x27, 0x0a, 0x13, 0x43, 0x6c, 0x65, 0x61, 0x72, 0x48, 0x69, 0x73, 0x74, - 0x6f, 0x72, 0x79, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x10, 0x0a, 0x03, 0x75, 0x69, - 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x03, 0x52, 0x03, 0x75, 0x69, 0x64, 0x22, 0x26, 0x0a, 0x14, - 0x43, 0x6c, 0x65, 0x61, 0x72, 0x48, 0x69, 0x73, 0x74, 0x6f, 0x72, 0x79, 0x52, 0x65, 0x73, 0x70, - 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x0e, 0x0a, 0x02, 0x6f, 0x6b, 0x18, 0x01, 0x20, 0x01, 0x28, 0x08, - 0x52, 0x02, 0x6f, 0x6b, 0x32, 0x8a, 0x01, 0x0a, 0x02, 0x41, 0x69, 0x12, 0x3b, 0x0a, 0x0a, 0x47, - 0x65, 0x74, 0x53, 0x75, 0x67, 0x67, 0x65, 0x73, 0x74, 0x12, 0x15, 0x2e, 0x70, 0x79, 0x70, 0x6b, - 0x67, 0x2e, 0x53, 0x75, 0x67, 0x67, 0x65, 0x73, 0x74, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, - 0x1a, 0x16, 0x2e, 0x70, 0x79, 0x70, 0x6b, 0x67, 0x2e, 0x53, 0x75, 0x67, 0x67, 0x65, 0x73, 0x74, - 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x47, 0x0a, 0x0c, 0x43, 0x6c, 0x65, 0x61, - 0x72, 0x48, 0x69, 0x73, 0x74, 0x6f, 0x72, 0x79, 0x12, 0x1a, 0x2e, 0x70, 0x79, 0x70, 0x6b, 0x67, - 0x2e, 0x43, 0x6c, 0x65, 0x61, 0x72, 0x48, 0x69, 0x73, 0x74, 0x6f, 0x72, 0x79, 0x52, 0x65, 0x71, - 0x75, 0x65, 0x73, 0x74, 0x1a, 0x1b, 0x2e, 0x70, 0x79, 0x70, 0x6b, 0x67, 0x2e, 0x43, 0x6c, 0x65, - 0x61, 0x72, 0x48, 0x69, 0x73, 0x74, 0x6f, 0x72, 0x79, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, - 0x65, 0x42, 0x0b, 0x5a, 0x09, 0x74, 0x67, 0x62, 0x6f, 0x74, 0x2f, 0x70, 0x6b, 0x67, 0x62, 0x06, - 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33, -}) +const file_protos_ai_proto_rawDesc = "" + + "\n" + + "\x0fprotos/ai.proto\x12\x05pypkg\">\n" + + "\x14GenerateImageRequest\x12\x10\n" + + "\x03uid\x18\x01 \x01(\x03R\x03uid\x12\x14\n" + + "\x05promt\x18\x02 \x01(\tR\x05promt\")\n" + + "\x15GenerateImageResponse\x12\x10\n" + + "\x03url\x18\x01 \x01(\tR\x03url\"Y\n" + + "\x12ChangeModelRequest\x12$\n" + + "\x04type\x18\x01 \x01(\x0e2\x10.pypkg.ModelTypeR\x04type\x12\x1d\n" + + "\n" + + "model_name\x18\x02 \x01(\tR\tmodelName\"?\n" + + "\x13ChangeModelResponse\x12\x0e\n" + + "\x02ok\x18\x01 \x01(\bR\x02ok\x12\x18\n" + + "\amessage\x18\x02 \x01(\tR\amessage\"\x17\n" + + "\x15GetInformationRequest\"X\n" + + "\x16GetInformationResponse\x12\x1d\n" + + "\n" + + "chat_model\x18\x01 \x01(\tR\tchatModel\x12\x1f\n" + + "\vimage_model\x18\x02 \x01(\tR\n" + + "imageModel\"<\n" + + "\x0eSuggestRequest\x12\x10\n" + + "\x03uid\x18\x01 \x01(\x03R\x03uid\x12\x18\n" + + "\asuggest\x18\x02 \x01(\tR\asuggest\";\n" + + "\x0fSuggestResponse\x12\x0e\n" + + "\x02ok\x18\x01 \x01(\bR\x02ok\x12\x18\n" + + "\arequest\x18\x02 \x01(\tR\arequest\"'\n" + + "\x13ClearHistoryRequest\x12\x10\n" + + "\x03uid\x18\x01 \x01(\x03R\x03uid\"&\n" + + "\x14ClearHistoryResponse\x12\x0e\n" + + "\x02ok\x18\x01 \x01(\bR\x02ok*C\n" + + "\tModelType\x12\x15\n" + + "\x11MODEL_UNSPECIFIED\x10\x00\x12\x0f\n" + + "\vIMAGE_MODEL\x10\x01\x12\x0e\n" + + "\n" + + "TEXT_MODEL\x10\x022\xeb\x02\n" + + "\x02Ai\x12;\n" + + "\n" + + "GetSuggest\x12\x15.pypkg.SuggestRequest\x1a\x16.pypkg.SuggestResponse\x12G\n" + + "\fClearHistory\x12\x1a.pypkg.ClearHistoryRequest\x1a\x1b.pypkg.ClearHistoryResponse\x12M\n" + + "\x0eGetInformation\x12\x1c.pypkg.GetInformationRequest\x1a\x1d.pypkg.GetInformationResponse\x12D\n" + + "\vChangeModel\x12\x19.pypkg.ChangeModelRequest\x1a\x1a.pypkg.ChangeModelResponse\x12J\n" + + "\rGenerateImage\x12\x1b.pypkg.GenerateImageRequest\x1a\x1c.pypkg.GenerateImageResponseB\x14Z\x12algobot.ai.v1;aiv1b\x06proto3" var ( file_protos_ai_proto_rawDescOnce sync.Once @@ -255,23 +608,38 @@ func file_protos_ai_proto_rawDescGZIP() []byte { return file_protos_ai_proto_rawDescData } -var file_protos_ai_proto_msgTypes = make([]protoimpl.MessageInfo, 4) +var file_protos_ai_proto_enumTypes = make([]protoimpl.EnumInfo, 1) +var file_protos_ai_proto_msgTypes = make([]protoimpl.MessageInfo, 10) var file_protos_ai_proto_goTypes = []any{ - (*SuggestRequest)(nil), // 0: pypkg.SuggestRequest - (*SuggestResponse)(nil), // 1: pypkg.SuggestResponse - (*ClearHistoryRequest)(nil), // 2: pypkg.ClearHistoryRequest - (*ClearHistoryResponse)(nil), // 3: pypkg.ClearHistoryResponse + (ModelType)(0), // 0: pypkg.ModelType + (*GenerateImageRequest)(nil), // 1: pypkg.GenerateImageRequest + (*GenerateImageResponse)(nil), // 2: pypkg.GenerateImageResponse + (*ChangeModelRequest)(nil), // 3: pypkg.ChangeModelRequest + (*ChangeModelResponse)(nil), // 4: pypkg.ChangeModelResponse + (*GetInformationRequest)(nil), // 5: pypkg.GetInformationRequest + (*GetInformationResponse)(nil), // 6: pypkg.GetInformationResponse + (*SuggestRequest)(nil), // 7: pypkg.SuggestRequest + (*SuggestResponse)(nil), // 8: pypkg.SuggestResponse + (*ClearHistoryRequest)(nil), // 9: pypkg.ClearHistoryRequest + (*ClearHistoryResponse)(nil), // 10: pypkg.ClearHistoryResponse } var file_protos_ai_proto_depIdxs = []int32{ - 0, // 0: pypkg.Ai.GetSuggest:input_type -> pypkg.SuggestRequest - 2, // 1: pypkg.Ai.ClearHistory:input_type -> pypkg.ClearHistoryRequest - 1, // 2: pypkg.Ai.GetSuggest:output_type -> pypkg.SuggestResponse - 3, // 3: pypkg.Ai.ClearHistory:output_type -> pypkg.ClearHistoryResponse - 2, // [2:4] is the sub-list for method output_type - 0, // [0:2] is the sub-list for method input_type - 0, // [0:0] is the sub-list for extension type_name - 0, // [0:0] is the sub-list for extension extendee - 0, // [0:0] is the sub-list for field type_name + 0, // 0: pypkg.ChangeModelRequest.type:type_name -> pypkg.ModelType + 7, // 1: pypkg.Ai.GetSuggest:input_type -> pypkg.SuggestRequest + 9, // 2: pypkg.Ai.ClearHistory:input_type -> pypkg.ClearHistoryRequest + 5, // 3: pypkg.Ai.GetInformation:input_type -> pypkg.GetInformationRequest + 3, // 4: pypkg.Ai.ChangeModel:input_type -> pypkg.ChangeModelRequest + 1, // 5: pypkg.Ai.GenerateImage:input_type -> pypkg.GenerateImageRequest + 8, // 6: pypkg.Ai.GetSuggest:output_type -> pypkg.SuggestResponse + 10, // 7: pypkg.Ai.ClearHistory:output_type -> pypkg.ClearHistoryResponse + 6, // 8: pypkg.Ai.GetInformation:output_type -> pypkg.GetInformationResponse + 4, // 9: pypkg.Ai.ChangeModel:output_type -> pypkg.ChangeModelResponse + 2, // 10: pypkg.Ai.GenerateImage:output_type -> pypkg.GenerateImageResponse + 6, // [6:11] is the sub-list for method output_type + 1, // [1:6] is the sub-list for method input_type + 1, // [1:1] is the sub-list for extension type_name + 1, // [1:1] is the sub-list for extension extendee + 0, // [0:1] is the sub-list for field type_name } func init() { file_protos_ai_proto_init() } @@ -284,13 +652,14 @@ func file_protos_ai_proto_init() { File: protoimpl.DescBuilder{ GoPackagePath: reflect.TypeOf(x{}).PkgPath(), RawDescriptor: unsafe.Slice(unsafe.StringData(file_protos_ai_proto_rawDesc), len(file_protos_ai_proto_rawDesc)), - NumEnums: 0, - NumMessages: 4, + NumEnums: 1, + NumMessages: 10, NumExtensions: 0, NumServices: 1, }, GoTypes: file_protos_ai_proto_goTypes, DependencyIndexes: file_protos_ai_proto_depIdxs, + EnumInfos: file_protos_ai_proto_enumTypes, MessageInfos: file_protos_ai_proto_msgTypes, }.Build() File_protos_ai_proto = out.File diff --git a/protos/ai.proto b/protos/ai.proto index 876078d..52e0bcb 100644 --- a/protos/ai.proto +++ b/protos/ai.proto @@ -1,11 +1,43 @@ syntax = "proto3"; package pypkg; -option go_package = "tgbot/pkg"; +option go_package = "algobot.ai.v1;aiv1"; service Ai { rpc GetSuggest (SuggestRequest) returns (SuggestResponse); rpc ClearHistory (ClearHistoryRequest) returns (ClearHistoryResponse); + rpc GetInformation (GetInformationRequest) returns (GetInformationResponse); + rpc ChangeModel (ChangeModelRequest) returns (ChangeModelResponse); + rpc GenerateImage (GenerateImageRequest) returns (GenerateImageResponse); +} + +message GenerateImageRequest { + int64 uid = 1; + string promt = 2; +} +message GenerateImageResponse { + string url = 1; +} + +enum ModelType { + MODEL_UNSPECIFIED = 0; + IMAGE_MODEL = 1; + TEXT_MODEL = 2; +} + +message ChangeModelRequest { + ModelType type = 1; + string model_name = 2; +} +message ChangeModelResponse { + bool ok = 1; + string message = 2; +} + +message GetInformationRequest {} +message GetInformationResponse { + string chat_model = 1; + string image_model = 2; } message SuggestRequest { diff --git a/protos/ai_grpc.pb.go b/protos/ai_grpc.pb.go index 4a7f2d7..2c84444 100644 --- a/protos/ai_grpc.pb.go +++ b/protos/ai_grpc.pb.go @@ -1,10 +1,10 @@ // Code generated by protoc-gen-go-grpc. DO NOT EDIT. // versions: // - protoc-gen-go-grpc v1.5.1 -// - protoc v5.29.3 +// - protoc v6.30.0 // source: protos/ai.proto -package pkg +package aiv1 import ( context "context" @@ -19,8 +19,11 @@ import ( const _ = grpc.SupportPackageIsVersion9 const ( - Ai_GetSuggest_FullMethodName = "/pypkg.Ai/GetSuggest" - Ai_ClearHistory_FullMethodName = "/pypkg.Ai/ClearHistory" + Ai_GetSuggest_FullMethodName = "/pypkg.Ai/GetSuggest" + Ai_ClearHistory_FullMethodName = "/pypkg.Ai/ClearHistory" + Ai_GetInformation_FullMethodName = "/pypkg.Ai/GetInformation" + Ai_ChangeModel_FullMethodName = "/pypkg.Ai/ChangeModel" + Ai_GenerateImage_FullMethodName = "/pypkg.Ai/GenerateImage" ) // AiClient is the client API for Ai service. @@ -29,6 +32,9 @@ const ( type AiClient interface { GetSuggest(ctx context.Context, in *SuggestRequest, opts ...grpc.CallOption) (*SuggestResponse, error) ClearHistory(ctx context.Context, in *ClearHistoryRequest, opts ...grpc.CallOption) (*ClearHistoryResponse, error) + GetInformation(ctx context.Context, in *GetInformationRequest, opts ...grpc.CallOption) (*GetInformationResponse, error) + ChangeModel(ctx context.Context, in *ChangeModelRequest, opts ...grpc.CallOption) (*ChangeModelResponse, error) + GenerateImage(ctx context.Context, in *GenerateImageRequest, opts ...grpc.CallOption) (*GenerateImageResponse, error) } type aiClient struct { @@ -59,12 +65,45 @@ func (c *aiClient) ClearHistory(ctx context.Context, in *ClearHistoryRequest, op return out, nil } +func (c *aiClient) GetInformation(ctx context.Context, in *GetInformationRequest, opts ...grpc.CallOption) (*GetInformationResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(GetInformationResponse) + err := c.cc.Invoke(ctx, Ai_GetInformation_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *aiClient) ChangeModel(ctx context.Context, in *ChangeModelRequest, opts ...grpc.CallOption) (*ChangeModelResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(ChangeModelResponse) + err := c.cc.Invoke(ctx, Ai_ChangeModel_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *aiClient) GenerateImage(ctx context.Context, in *GenerateImageRequest, opts ...grpc.CallOption) (*GenerateImageResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(GenerateImageResponse) + err := c.cc.Invoke(ctx, Ai_GenerateImage_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + // AiServer is the server API for Ai service. // All implementations must embed UnimplementedAiServer // for forward compatibility. type AiServer interface { GetSuggest(context.Context, *SuggestRequest) (*SuggestResponse, error) ClearHistory(context.Context, *ClearHistoryRequest) (*ClearHistoryResponse, error) + GetInformation(context.Context, *GetInformationRequest) (*GetInformationResponse, error) + ChangeModel(context.Context, *ChangeModelRequest) (*ChangeModelResponse, error) + GenerateImage(context.Context, *GenerateImageRequest) (*GenerateImageResponse, error) mustEmbedUnimplementedAiServer() } @@ -81,6 +120,15 @@ func (UnimplementedAiServer) GetSuggest(context.Context, *SuggestRequest) (*Sugg func (UnimplementedAiServer) ClearHistory(context.Context, *ClearHistoryRequest) (*ClearHistoryResponse, error) { return nil, status.Errorf(codes.Unimplemented, "method ClearHistory not implemented") } +func (UnimplementedAiServer) GetInformation(context.Context, *GetInformationRequest) (*GetInformationResponse, error) { + return nil, status.Errorf(codes.Unimplemented, "method GetInformation not implemented") +} +func (UnimplementedAiServer) ChangeModel(context.Context, *ChangeModelRequest) (*ChangeModelResponse, error) { + return nil, status.Errorf(codes.Unimplemented, "method ChangeModel not implemented") +} +func (UnimplementedAiServer) GenerateImage(context.Context, *GenerateImageRequest) (*GenerateImageResponse, error) { + return nil, status.Errorf(codes.Unimplemented, "method GenerateImage not implemented") +} func (UnimplementedAiServer) mustEmbedUnimplementedAiServer() {} func (UnimplementedAiServer) testEmbeddedByValue() {} @@ -138,6 +186,60 @@ func _Ai_ClearHistory_Handler(srv interface{}, ctx context.Context, dec func(int return interceptor(ctx, in, info, handler) } +func _Ai_GetInformation_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(GetInformationRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(AiServer).GetInformation(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: Ai_GetInformation_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(AiServer).GetInformation(ctx, req.(*GetInformationRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _Ai_ChangeModel_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(ChangeModelRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(AiServer).ChangeModel(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: Ai_ChangeModel_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(AiServer).ChangeModel(ctx, req.(*ChangeModelRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _Ai_GenerateImage_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(GenerateImageRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(AiServer).GenerateImage(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: Ai_GenerateImage_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(AiServer).GenerateImage(ctx, req.(*GenerateImageRequest)) + } + return interceptor(ctx, in, info, handler) +} + // Ai_ServiceDesc is the grpc.ServiceDesc for Ai service. // It's only intended for direct use with grpc.RegisterService, // and not to be introspected or modified (even as a copy) @@ -153,6 +255,18 @@ var Ai_ServiceDesc = grpc.ServiceDesc{ MethodName: "ClearHistory", Handler: _Ai_ClearHistory_Handler, }, + { + MethodName: "GetInformation", + Handler: _Ai_GetInformation_Handler, + }, + { + MethodName: "ChangeModel", + Handler: _Ai_ChangeModel_Handler, + }, + { + MethodName: "GenerateImage", + Handler: _Ai_GenerateImage_Handler, + }, }, Streams: []grpc.StreamDesc{}, Metadata: "protos/ai.proto", diff --git a/storage/.gitkeep b/storage/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/test/app/schedule_test.go b/test/app/schedule_test.go new file mode 100644 index 0000000..3a49183 --- /dev/null +++ b/test/app/schedule_test.go @@ -0,0 +1,58 @@ +package test + +import ( + "algobot/internal/app/scheduler" + "algobot/internal/config" + "algobot/internal/domain/models" + scheduler2 "algobot/internal/domain/scheduler" + "algobot/test/mocks" + mocks2 "algobot/test/mocks/app" + "github.com/stretchr/testify/assert" + "go.uber.org/mock/gomock" + "testing" + "time" +) + +func TestSchedule(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + log := mocks.NewMockLogger() + domain := mocks2.NewMockDomain(ctrl) + bo := mocks2.NewMockBackoffice(ctrl) + cfg := &config.Config{Backoffice: config.Backoffice{ + MessageTimer: 5 * time.Second, + }} + + app := scheduler.New(log, cfg, domain, bo) + + go func() { + msg := <-app.Chan() + assert.Equal(t, assetMSG, msg) + app.Stop() + }() + + domain.EXPECT().UsersByNotification(1).Return([]models.User{ + { + ID: 1, + Uid: 1, + Cookie: "Cookie", + LastNotification: "LastNotification", + Notification: 1, + }, + }, nil).Times(1) + bo.EXPECT().MessagesUser(int64(1), "LastNotification").Return([]scheduler2.Message{assetMSG}, nil).Times(1) + domain.EXPECT().ChaneNotifDate(int64(1), "newTime").Return(nil).Times(1) + + app.GetMessage() +} + +var assetMSG = scheduler2.Message{ + To: 1, + From: "From", + Theme: "Theme", + Link: "Link", + Text: "Text", + LinkURL: "", + Time: "newTime", +} diff --git a/test/lib/backoffice/GroupView_example b/test/lib/backoffice/GroupView_example new file mode 100644 index 0000000..684903d --- /dev/null +++ b/test/lib/backoffice/GroupView_example @@ -0,0 +1,991 @@ +{ + "status": "success", + "data": { + "id": 12345678, + "title": "Занятие в библиотеке 5 в 14.00", + "content": "Группа по курсу Python", + "type": { + "value": "regular", + "label": "Группа", + "tag": "default" + }, + "status": { + "value": 10, + "label": "Активная", + "tag": "success" + }, + "status_changed_at": "20.09.2024 12:04", + "start_time": "22.09.2024 14:00", + "next_lesson_time": "13.04.2025 14:00", + "lessons_total": 35, + "lessons_passed": 28, + "hardware_needed": 0, + "branch": { + "id": 987, + "title": "Город Х", + "code": "city_x", + "description": "", + "phone": "+7 (491) 555-22-33", + "email": "cityx@algoritmika.org", + "site_url": "https://cityx.algoritmika.org", + "templateVersion": 2, + "use_amo": true, + "amoConfigId": 321, + "show_finance_info": true, + "lms_display_student_credentials": true, + "show_online_room_url_field": 0, + "use_sms": false, + "language_id": 2, + "order_name": 1, + "use_fully_paid_label": 0, + "brandName": "", + "max_count_students_for_show_online": 10, + "isFillPaymentSystem": false, + "firstLessonNoRoyalty": 0, + "root_branch_id": 123 + }, + "venue": { + "id": 4321, + "title": "Библиотека №5", + "address": "390000, Город Х, ул Ленина, д 10", + "contact_name": "", + "contact_email": "", + "contact_phone": "", + "_links": { + "self": "/venue/view/4321", + "update": "/venue/update/4321", + "index": "/venue" + } + }, + "curator": { + "id": 1234, + "username": "random_user", + "phone": "+7 (900) 555-12-34", + "email": "example123@mail.com", + "name": "Иван Иванов", + "profile": { + "photo_url": "/uploads/avatar/avatar/avatar_1234_1603094230-96x96.jpg", + "promo": "" + }, + "allowedUserCourses": [], + "status": 10, + "_links": { + "self": "/user/update/1234" + } + }, + "teacher": { + "id": 5678, + "username": "random_user2", + "phone": "+7 (922) 555-00-11", + "email": "randomuser2@mail.com", + "name": "Алексей Смирнов", + "profile": { + "photo_url": "", + "promo": null + }, + "allowedUserCourses": [ + { + "userId": 42407, + "courseId": 84, + "isAllowed": 1 + }, + { + "userId": 42407, + "courseId": 305, + "isAllowed": 1 + }, + { + "userId": 42407, + "courseId": 389, + "isAllowed": 1 + }, + { + "userId": 42407, + "courseId": 405, + "isAllowed": 1 + }, + { + "userId": 42407, + "courseId": 406, + "isAllowed": 1 + }, + { + "userId": 42407, + "courseId": 407, + "isAllowed": 1 + }, + { + "userId": 42407, + "courseId": 408, + "isAllowed": 1 + }, + { + "userId": 42407, + "courseId": 416, + "isAllowed": 1 + }, + { + "userId": 42407, + "courseId": 417, + "isAllowed": 1 + }, + { + "userId": 42407, + "courseId": 448, + "isAllowed": 1 + }, + { + "userId": 42407, + "courseId": 465, + "isAllowed": 1 + }, + { + "userId": 42407, + "courseId": 606, + "isAllowed": 1 + }, + { + "userId": 42407, + "courseId": 640, + "isAllowed": 1 + }, + { + "userId": 42407, + "courseId": 641, + "isAllowed": 1 + }, + { + "userId": 42407, + "courseId": 661, + "isAllowed": 1 + }, + { + "userId": 42407, + "courseId": 662, + "isAllowed": 1 + }, + { + "userId": 42407, + "courseId": 665, + "isAllowed": 1 + }, + { + "userId": 42407, + "courseId": 666, + "isAllowed": 1 + }, + { + "userId": 42407, + "courseId": 686, + "isAllowed": 1 + }, + { + "userId": 42407, + "courseId": 706, + "isAllowed": 1 + }, + { + "userId": 42407, + "courseId": 707, + "isAllowed": 1 + }, + { + "userId": 42407, + "courseId": 716, + "isAllowed": 1 + }, + { + "userId": 42407, + "courseId": 727, + "isAllowed": 1 + }, + { + "userId": 42407, + "courseId": 729, + "isAllowed": 1 + }, + { + "userId": 42407, + "courseId": 734, + "isAllowed": 1 + }, + { + "userId": 42407, + "courseId": 735, + "isAllowed": 1 + }, + { + "userId": 42407, + "courseId": 777, + "isAllowed": 1 + }, + { + "userId": 42407, + "courseId": 783, + "isAllowed": 1 + }, + { + "userId": 42407, + "courseId": 797, + "isAllowed": 1 + }, + { + "userId": 42407, + "courseId": 799, + "isAllowed": 1 + }, + { + "userId": 42407, + "courseId": 809, + "isAllowed": 1 + }, + { + "userId": 42407, + "courseId": 810, + "isAllowed": 1 + }, + { + "userId": 42407, + "courseId": 823, + "isAllowed": 1 + }, + { + "userId": 42407, + "courseId": 831, + "isAllowed": 1 + }, + { + "userId": 42407, + "courseId": 852, + "isAllowed": 1 + }, + { + "userId": 42407, + "courseId": 853, + "isAllowed": 1 + }, + { + "userId": 42407, + "courseId": 857, + "isAllowed": 1 + }, + { + "userId": 42407, + "courseId": 858, + "isAllowed": 1 + }, + { + "userId": 42407, + "courseId": 859, + "isAllowed": 1 + }, + { + "userId": 42407, + "courseId": 860, + "isAllowed": 1 + }, + { + "userId": 42407, + "courseId": 861, + "isAllowed": 1 + }, + { + "userId": 42407, + "courseId": 862, + "isAllowed": 1 + }, + { + "userId": 42407, + "courseId": 864, + "isAllowed": 1 + }, + { + "userId": 42407, + "courseId": 1338, + "isAllowed": 1 + }, + { + "userId": 42407, + "courseId": 1339, + "isAllowed": 1 + }, + { + "userId": 42407, + "courseId": 1346, + "isAllowed": 1 + }, + { + "userId": 42407, + "courseId": 1347, + "isAllowed": 1 + }, + { + "userId": 42407, + "courseId": 1387, + "isAllowed": 1 + }, + { + "userId": 42407, + "courseId": 1459, + "isAllowed": 1 + }, + { + "userId": 42407, + "courseId": 1484, + "isAllowed": 1 + }, + { + "userId": 42407, + "courseId": 1543, + "isAllowed": 1 + }, + { + "userId": 42407, + "courseId": 1554, + "isAllowed": 1 + }, + { + "userId": 42407, + "courseId": 1613, + "isAllowed": 1 + }, + { + "userId": 42407, + "courseId": 1614, + "isAllowed": 1 + }, + { + "userId": 42407, + "courseId": 1615, + "isAllowed": 1 + }, + { + "userId": 42407, + "courseId": 1616, + "isAllowed": 1 + }, + { + "userId": 42407, + "courseId": 1653, + "isAllowed": 1 + }, + { + "userId": 42407, + "courseId": 1654, + "isAllowed": 1 + }, + { + "userId": 42407, + "courseId": 1664, + "isAllowed": 1 + }, + { + "userId": 42407, + "courseId": 1665, + "isAllowed": 1 + }, + { + "userId": 42407, + "courseId": 1668, + "isAllowed": 1 + }, + { + "userId": 42407, + "courseId": 1686, + "isAllowed": 1 + }, + { + "userId": 42407, + "courseId": 1688, + "isAllowed": 1 + }, + { + "userId": 42407, + "courseId": 1692, + "isAllowed": 1 + }, + { + "userId": 42407, + "courseId": 1710, + "isAllowed": 1 + }, + { + "userId": 42407, + "courseId": 1748, + "isAllowed": 1 + }, + { + "userId": 42407, + "courseId": 1767, + "isAllowed": 1 + }, + { + "userId": 42407, + "courseId": 1810, + "isAllowed": 1 + }, + { + "userId": 42407, + "courseId": 1904, + "isAllowed": 1 + }, + { + "userId": 42407, + "courseId": 2007, + "isAllowed": 1 + }, + { + "userId": 42407, + "courseId": 2033, + "isAllowed": 1 + }, + { + "userId": 42407, + "courseId": 2125, + "isAllowed": 1 + }, + { + "userId": 42407, + "courseId": 2126, + "isAllowed": 1 + }, + { + "userId": 42407, + "courseId": 2231, + "isAllowed": 1 + }, + { + "userId": 42407, + "courseId": 2259, + "isAllowed": 1 + } + ], + "status": 10, + "_links": { + "self": "/user/update/42407" + } + }, + "teachers": [ + { + "id": 5678, + "username": "random_user2", + "phone": "+7 (922) 555-00-11", + "email": "randomuser2@mail.com", + "name": "Алексей Смирнов", + "profile": { + "photo_url": "", + "promo": null + }, + "allowedUserCourses": [ + { + "userId": 42407, + "courseId": 84, + "isAllowed": 1 + }, + { + "userId": 42407, + "courseId": 305, + "isAllowed": 1 + }, + { + "userId": 42407, + "courseId": 389, + "isAllowed": 1 + }, + { + "userId": 42407, + "courseId": 405, + "isAllowed": 1 + }, + { + "userId": 42407, + "courseId": 406, + "isAllowed": 1 + }, + { + "userId": 42407, + "courseId": 407, + "isAllowed": 1 + }, + { + "userId": 42407, + "courseId": 408, + "isAllowed": 1 + }, + { + "userId": 42407, + "courseId": 416, + "isAllowed": 1 + }, + { + "userId": 42407, + "courseId": 417, + "isAllowed": 1 + }, + { + "userId": 42407, + "courseId": 448, + "isAllowed": 1 + }, + { + "userId": 42407, + "courseId": 465, + "isAllowed": 1 + }, + { + "userId": 42407, + "courseId": 606, + "isAllowed": 1 + }, + { + "userId": 42407, + "courseId": 640, + "isAllowed": 1 + }, + { + "userId": 42407, + "courseId": 641, + "isAllowed": 1 + }, + { + "userId": 42407, + "courseId": 661, + "isAllowed": 1 + }, + { + "userId": 42407, + "courseId": 662, + "isAllowed": 1 + }, + { + "userId": 42407, + "courseId": 665, + "isAllowed": 1 + }, + { + "userId": 42407, + "courseId": 666, + "isAllowed": 1 + }, + { + "userId": 42407, + "courseId": 686, + "isAllowed": 1 + }, + { + "userId": 42407, + "courseId": 706, + "isAllowed": 1 + }, + { + "userId": 42407, + "courseId": 707, + "isAllowed": 1 + }, + { + "userId": 42407, + "courseId": 716, + "isAllowed": 1 + }, + { + "userId": 42407, + "courseId": 727, + "isAllowed": 1 + }, + { + "userId": 42407, + "courseId": 729, + "isAllowed": 1 + }, + { + "userId": 42407, + "courseId": 734, + "isAllowed": 1 + }, + { + "userId": 42407, + "courseId": 735, + "isAllowed": 1 + }, + { + "userId": 42407, + "courseId": 777, + "isAllowed": 1 + }, + { + "userId": 42407, + "courseId": 783, + "isAllowed": 1 + }, + { + "userId": 42407, + "courseId": 797, + "isAllowed": 1 + }, + { + "userId": 42407, + "courseId": 799, + "isAllowed": 1 + }, + { + "userId": 42407, + "courseId": 809, + "isAllowed": 1 + }, + { + "userId": 42407, + "courseId": 810, + "isAllowed": 1 + }, + { + "userId": 42407, + "courseId": 823, + "isAllowed": 1 + }, + { + "userId": 42407, + "courseId": 831, + "isAllowed": 1 + }, + { + "userId": 42407, + "courseId": 852, + "isAllowed": 1 + }, + { + "userId": 42407, + "courseId": 853, + "isAllowed": 1 + }, + { + "userId": 42407, + "courseId": 857, + "isAllowed": 1 + }, + { + "userId": 42407, + "courseId": 858, + "isAllowed": 1 + }, + { + "userId": 42407, + "courseId": 859, + "isAllowed": 1 + }, + { + "userId": 42407, + "courseId": 860, + "isAllowed": 1 + }, + { + "userId": 42407, + "courseId": 861, + "isAllowed": 1 + }, + { + "userId": 42407, + "courseId": 862, + "isAllowed": 1 + }, + { + "userId": 42407, + "courseId": 864, + "isAllowed": 1 + }, + { + "userId": 42407, + "courseId": 1338, + "isAllowed": 1 + }, + { + "userId": 42407, + "courseId": 1339, + "isAllowed": 1 + }, + { + "userId": 42407, + "courseId": 1346, + "isAllowed": 1 + }, + { + "userId": 42407, + "courseId": 1347, + "isAllowed": 1 + }, + { + "userId": 42407, + "courseId": 1387, + "isAllowed": 1 + }, + { + "userId": 42407, + "courseId": 1459, + "isAllowed": 1 + }, + { + "userId": 42407, + "courseId": 1484, + "isAllowed": 1 + }, + { + "userId": 42407, + "courseId": 1543, + "isAllowed": 1 + }, + { + "userId": 42407, + "courseId": 1554, + "isAllowed": 1 + }, + { + "userId": 42407, + "courseId": 1613, + "isAllowed": 1 + }, + { + "userId": 42407, + "courseId": 1614, + "isAllowed": 1 + }, + { + "userId": 42407, + "courseId": 1615, + "isAllowed": 1 + }, + { + "userId": 42407, + "courseId": 1616, + "isAllowed": 1 + }, + { + "userId": 42407, + "courseId": 1653, + "isAllowed": 1 + }, + { + "userId": 42407, + "courseId": 1654, + "isAllowed": 1 + }, + { + "userId": 42407, + "courseId": 1664, + "isAllowed": 1 + }, + { + "userId": 42407, + "courseId": 1665, + "isAllowed": 1 + }, + { + "userId": 42407, + "courseId": 1668, + "isAllowed": 1 + }, + { + "userId": 42407, + "courseId": 1686, + "isAllowed": 1 + }, + { + "userId": 42407, + "courseId": 1688, + "isAllowed": 1 + }, + { + "userId": 42407, + "courseId": 1692, + "isAllowed": 1 + }, + { + "userId": 42407, + "courseId": 1710, + "isAllowed": 1 + }, + { + "userId": 42407, + "courseId": 1748, + "isAllowed": 1 + }, + { + "userId": 42407, + "courseId": 1767, + "isAllowed": 1 + }, + { + "userId": 42407, + "courseId": 1810, + "isAllowed": 1 + }, + { + "userId": 42407, + "courseId": 1904, + "isAllowed": 1 + }, + { + "userId": 42407, + "courseId": 2007, + "isAllowed": 1 + }, + { + "userId": 42407, + "courseId": 2033, + "isAllowed": 1 + }, + { + "userId": 42407, + "courseId": 2125, + "isAllowed": 1 + }, + { + "userId": 42407, + "courseId": 2126, + "isAllowed": 1 + }, + { + "userId": 42407, + "courseId": 2231, + "isAllowed": 1 + }, + { + "userId": 42407, + "courseId": 2259, + "isAllowed": 1 + } + ], + "status": 10, + "_links": { + "self": "/user/update/42407" + } + } + ], + "client_manager": null, + "course": { + "id": 729, + "name": "Компьютерная грамотность", + "guid": "a131029b-cde5-11eb-a724-6cb31107bf10", + "description": "Курс для внеурочных занятий (дополнительное образование) с детьми в возрасте 7-9 лет, на русском языке, версия 2021/2022", + "contentType": "course", + "courseType": { + "id": 19, + "title": "компьютерная грамотность", + "code": "comp" + }, + "lessons_count": 0, + "group_lessons_amount": 0, + "lessons_count_formatted": "нет модулей", + "group_lessons_amount_formatted": "нет уроков", + "is_deleted": 0, + "_links": { + "self": "/course/view/a131029b-cde5-11eb-a724-6cb31107bf10" + } + }, + "language_id": null, + "journal": true, + "show_journal": true, + "showOnlineRoom": true, + "isOnline": false, + "active_student_count": 8, + "online_room_url": "", + "use_client_manager": 0, + "display_lesson_duration_in_minutes": 60, + "deleted_at": null, + "deleted_by": null, + "priority_level": { + "value": "normal", + "label": "Обычный приоритет", + "tag": "default" + }, + "is_full": false, + "created_at": "17.09.2024 10:41", + "created_by": { + "id": 1234, + "username": "random_user", + "phone": "+7 (900) 555-12-34", + "email": "example123@mail.com", + "name": "Иван Иванов", + "profile": { + "photo_url": "/uploads/avatar/avatar/avatar_1234_1603094230-96x96.jpg", + "promo": "" + }, + "allowedUserCourses": [], + "status": 10, + "_links": { + "self": "/user/update/1234" + } + }, + "_related": { + "statuses": [ + { + "value": 10, + "label": "Активная", + "tag": "success" + }, + { + "value": 1, + "label": "Не стартовала", + "tag": "warning" + }, + { + "value": 20, + "label": "Идет набор", + "tag": "warning" + }, + { + "value": 30, + "label": "Приостановлена", + "tag": "warning" + }, + { + "value": 0, + "label": "Окончена", + "tag": "default" + }, + { + "value": 2, + "label": "Развалилась", + "tag": "default" + } + ], + "types": [ + { + "value": "regular", + "label": "Группа", + "tag": "default" + }, + { + "value": "masterclass", + "label": "Мастер-класс", + "tag": "info" + }, + { + "value": "intensive", + "label": "Интенсив", + "tag": "warning" + }, + { + "value": "demo", + "label": "Обучение сотрудников", + "tag": "inactive" + }, + { + "value": "individual", + "label": "Индивидуальная", + "tag": "default" + } + ], + "priority_levels": [ + { + "value": "normal", + "label": "Обычный", + "tag": "default" + }, + { + "value": "high", + "label": "Высокий", + "tag": "warning" + } + ] + } + } +} \ No newline at end of file diff --git a/test/lib/backoffice/KidView_example b/test/lib/backoffice/KidView_example new file mode 100644 index 0000000..af1a94a --- /dev/null +++ b/test/lib/backoffice/KidView_example @@ -0,0 +1,50 @@ +{ + "status": "success", + "data": { + "id": 70245813, + "firstName": "Иван", + "lastName": "Петров", + "fullName": "Иван Петров", + "parentName": "Ольга", + "email": "student123@example.com", + "hasLaptop": -1, + "phone": "+7 (900) 123-45-67", + "age": 10, + "birthDate": "2014-12-16T00:00:00+03:00", + "deletedAt": null, + "hasBranchAccess": true, + "username": "student_01", + "password": "7605", + "groups": [ + { + "id": 98637162, + "groupStudentId": 6543284, + "title": "Библиотека 7 вс 14.00", + "content": "Группа по курсу КГ", + "track": 1, + "status": 20, + "startTime": "2024-11-18T11:18:45+03:00", + "endTime": "2024-11-23T16:56:45+03:00", + "courseId": 729, + "deletedAt": null + }, + { + "id": 98637162, + "groupStudentId": 6553709, + "title": "Библиотека 7 вс 14.00", + "content": "Группа по курсу КГ", + "track": 2, + "status": 0, + "startTime": "2024-11-25T10:54:55+03:00", + "endTime": "9999-12-31T00:00:00+03:00", + "courseId": 729, + "deletedAt": null + } + ], + "_links": { + "self": { + "href": "/student/update/70245813" + } + } + } +} \ No newline at end of file diff --git a/test/lib/backoffice/KidsNamesByGroup_example b/test/lib/backoffice/KidsNamesByGroup_example new file mode 100644 index 0000000..aab79ff --- /dev/null +++ b/test/lib/backoffice/KidsNamesByGroup_example @@ -0,0 +1,40 @@ +{ + "status": "success", + "data": { + "items": [ + { + "id": 70245813, + "firstName": "Иван", + "lastName": "Петров", + "fullName": "Иван Петров", + "parentName": "Ольга", + "email": "petrov_ivan@mail.ru", + "hasLaptop": -1, + "phone": "+7 (915) 123-45-67", + "age": 10, + "birthDate": "2014-12-16T00:00:00+03:00", + "deletedAt": null, + "hasBranchAccess": true, + "username": "petrov_i", + "password": "7605", + "lastGroup": { + "id": 98637162, + "groupStudentId": 6553709, + "title": "Библиотека 7 вс 14.00", + "content": "Группа по курсу КГ", + "track": 2, + "status": 0, + "startTime": "2024-11-25T10:54:55+03:00", + "endTime": "9999-12-31T00:00:00+03:00", + "courseId": 729, + "deletedAt": null + }, + "_links": { + "self": { + "href": "/student/update/70245813" + } + } + } + ] + } +} \ No newline at end of file diff --git a/test/lib/backoffice/KidsStats_example b/test/lib/backoffice/KidsStats_example new file mode 100644 index 0000000..941048e --- /dev/null +++ b/test/lib/backoffice/KidsStats_example @@ -0,0 +1,51 @@ +{ + "status": "success", + "data": [ + { + "student_id": 12345678, + "attendance": [ + { + "lesson_id": 1001, + "lesson_title": "Знакомство с компьютером и цифровым миром", + "start_time_formatted": "пн 15.01.24 10:00", + "status": "inactive" + }, + { + "lesson_id": 1002, + "lesson_title": "Файлы, папки и как ими пользоваться", + "start_time_formatted": "пн 22.01.24 10:00", + "status": "inactive" + }, + { + "lesson_id": 1003, + "lesson_title": "Интернет и как искать информацию", + "start_time_formatted": "пн 29.01.24 10:00", + "status": "inactive" + } + ] + }, + { + "student_id": 87654321, + "attendance": [ + { + "lesson_id": 1001, + "lesson_title": "Знакомство с компьютером и цифровым миром", + "start_time_formatted": "пн 15.01.24 10:00", + "status": "present" + }, + { + "lesson_id": 1002, + "lesson_title": "Файлы, папки и как ими пользоваться", + "start_time_formatted": "пн 22.01.24 10:00", + "status": "present" + }, + { + "lesson_id": 1003, + "lesson_title": "Интернет и как искать информацию", + "start_time_formatted": "пн 29.01.24 10:00", + "status": "present" + } + ] + } + ] +} diff --git a/test/lib/backoffice/backoffice_test.go b/test/lib/backoffice/backoffice_test.go new file mode 100644 index 0000000..b36cce4 --- /dev/null +++ b/test/lib/backoffice/backoffice_test.go @@ -0,0 +1,345 @@ +package backoffice + +import ( + "algobot/internal/config" + backoffice2 "algobot/internal/domain/backoffice" + "algobot/internal/domain/models" + "algobot/internal/lib/backoffice" + "github.com/stretchr/testify/assert" + "io" + "net/http" + "net/http/httptest" + "os" + "testing" + "time" +) + +func TestBackoffice(t *testing.T) { + t.Run("Group", func(t *testing.T) { + groupsExp := []models.Group{ + {GroupID: 98637162, Title: "Группа по курсу КГ", TimeLesson: time.Date(2025, time.April, 13, 14, 0, 0, 0, time.UTC)}, + {GroupID: 98623404, Title: "Группа по курсу ОЛиП МП", TimeLesson: time.Date(2025, time.April, 13, 12, 0, 0, 0, time.UTC)}, + {GroupID: 98621252, Title: "Группа по курсу Пст", TimeLesson: time.Date(2025, time.April, 13, 18, 0, 0, 0, time.UTC)}, + {GroupID: 98619913, Title: "Группа по курсу КГ", TimeLesson: time.Date(2025, time.April, 12, 10, 0, 0, 0, time.UTC)}, + {GroupID: 98619873, Title: "Группа по курсу ГД", TimeLesson: time.Date(2025, time.April, 12, 14, 0, 0, 0, time.UTC)}, + {GroupID: 98619867, Title: "Группа по курсу ВП", TimeLesson: time.Date(2025, time.April, 12, 12, 0, 0, 0, time.UTC)}, + {GroupID: 98589447, Title: "Группа по курсу ВП", TimeLesson: time.Date(2025, time.April, 13, 10, 0, 0, 0, time.UTC)}, + {GroupID: 985504, Title: "Группа по курсу Пст 2", TimeLesson: time.Date(2025, time.April, 12, 18, 0, 0, 0, time.UTC)}, + {GroupID: 978298, Title: "Группа по курсу Пст 2", TimeLesson: time.Date(2025, time.April, 13, 16, 0, 0, 0, time.UTC)}, + } + + responseHTML := readFile("group_example") + + server := httptest.NewServer(http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) { + rw.Write([]byte(responseHTML)) + rw.WriteHeader(http.StatusOK) + })) + defer server.Close() + + bo := backoffice.NewBackoffice(&config.Backoffice{ + Retries: 5, + RetriesTimeout: time.Second, + ResponseTimeout: time.Second, + }, backoffice.WithURL(server.URL)) + + group, err := bo.Group("cookie") + assert.NoError(t, err) + assert.Equal(t, groupsExp, group) + }) + t.Run("GroupView", func(t *testing.T) { + responseHTML := readFile("GroupView_example") + server := httptest.NewServer(http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) { + rw.Write([]byte(responseHTML)) + rw.WriteHeader(http.StatusOK) + })) + defer server.Close() + + bo := backoffice.NewBackoffice(&config.Backoffice{ + Retries: 5, + RetriesTimeout: time.Second, + ResponseTimeout: time.Second, + }, backoffice.WithURL(server.URL)) + + group, err := bo.GroupView("", "") + assert.NoError(t, err) + assert.Equal(t, backofficeGroupViewExpected, group) + }) + t.Run("KidsNamesByGroup", func(t *testing.T) { + responseHTML := readFile("KidsNamesByGroup_example") + server := httptest.NewServer(http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) { + rw.Write([]byte(responseHTML)) + rw.WriteHeader(http.StatusOK) + })) + defer server.Close() + + bo := backoffice.NewBackoffice(&config.Backoffice{ + Retries: 5, + RetriesTimeout: time.Second, + ResponseTimeout: time.Second, + }, backoffice.WithURL(server.URL)) + + group, err := bo.KidsNamesByGroup("", "") + assert.NoError(t, err) + assert.Equal(t, backofficeKidsByGroupExpected, group) + }) + t.Run("KidView", func(t *testing.T) { + responseHTML := readFile("KidView_example") + server := httptest.NewServer(http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) { + rw.Write([]byte(responseHTML)) + rw.WriteHeader(http.StatusOK) + })) + defer server.Close() + + bo := backoffice.NewBackoffice(&config.Backoffice{ + Retries: 5, + RetriesTimeout: time.Second, + ResponseTimeout: time.Second, + }, backoffice.WithURL(server.URL)) + + group, err := bo.KidView("", "") + assert.NoError(t, err) + assert.Equal(t, backofficeKidViewExpected, group) + }) + t.Run("KidsStats", func(t *testing.T) { + responseHTML := readFile("KidsStats_example") + server := httptest.NewServer(http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) { + rw.Write([]byte(responseHTML)) + rw.WriteHeader(http.StatusOK) + })) + defer server.Close() + + bo := backoffice.NewBackoffice(&config.Backoffice{ + Retries: 5, + RetriesTimeout: time.Second, + ResponseTimeout: time.Second, + }, backoffice.WithURL(server.URL)) + + group, err := bo.KidsStats("", 123) + assert.NoError(t, err) + assert.Equal(t, backofficeKidsStats, group) + }) + t.Run("Lesson", func(t *testing.T) { + t.Run("OpenLesson", func(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) { + rw.WriteHeader(http.StatusOK) + })) + defer server.Close() + + bo := backoffice.NewBackoffice(&config.Backoffice{ + Retries: 5, + RetriesTimeout: time.Second, + ResponseTimeout: time.Second, + }, backoffice.WithURL(server.URL)) + + err := bo.OpenLesson("", "", "") + assert.NoError(t, err) + }) + t.Run("CloseLesson", func(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) { + rw.WriteHeader(http.StatusOK) + })) + defer server.Close() + + bo := backoffice.NewBackoffice(&config.Backoffice{ + Retries: 5, + RetriesTimeout: time.Second, + ResponseTimeout: time.Second, + }, backoffice.WithURL(server.URL)) + + err := bo.CloseLesson("", "", "") + assert.NoError(t, err) + }) + }) + t.Run("KidsMessages", func(t *testing.T) { + responseHTML := readFile("kidsMessage_example") + server := httptest.NewServer(http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) { + rw.Write([]byte(responseHTML)) + rw.WriteHeader(http.StatusOK) + })) + defer server.Close() + + bo := backoffice.NewBackoffice(&config.Backoffice{ + Retries: 5, + RetriesTimeout: time.Second, + ResponseTimeout: time.Second, + }, backoffice.WithURL(server.URL)) + + msgs, err := bo.KidsMessages("") + assert.NoError(t, err) + assert.Equal(t, backofficeMsg, msgs) + }) +} + +func readFile(fileName string) string { + file, err := os.Open(fileName) + if err != nil { + panic(err) + } + b, err := io.ReadAll(file) + if err != nil { + panic(err) + } + responseHTML := string(b) + return responseHTML +} + +var backofficeKidsStats = backoffice2.KidsStats{ + Status: "success", + Data: []backoffice2.KidStat{{ + StudentID: 12345678, + Attendance: []backoffice2.Attendance{{LessonID: 1001, LessonTitle: "Знакомство с компьютером и цифровым миром", StartTimeFormatted: "пн 15.01.24 10:00", Status: "inactive"}, {LessonID: 1002, LessonTitle: "Файлы, папки и как ими пользоваться", StartTimeFormatted: "пн 22.01.24 10:00", Status: "inactive"}, {LessonID: 1003, LessonTitle: "Интернет и как искать информацию", StartTimeFormatted: "пн 29.01.24 10:00", Status: "inactive"}}, + }, {StudentID: 87654321, Attendance: []backoffice2.Attendance{{LessonID: 1001, LessonTitle: "Знакомство с компьютером и цифровым миром", StartTimeFormatted: "пн 15.01.24 10:00", Status: "present"}, {LessonID: 1002, LessonTitle: "Файлы, папки и как ими пользоваться", StartTimeFormatted: "пн 22.01.24 10:00", Status: "present"}, {LessonID: 1003, LessonTitle: "Интернет и как искать информацию", StartTimeFormatted: "пн 29.01.24 10:00", Status: "present"}}}}, +} +var backofficeGroupViewExpected = backoffice2.GroupInfo{ + Status: "success", + Data: backoffice2.GroupDataFull{ID: 12345678, Title: "Занятие в библиотеке 5 в 14.00", Content: "Группа по курсу Python", Type: backoffice2.TypeFull{Value: "regular", Label: "Группа", Tag: "default"}, Status: backoffice2.StatusFull{Value: 10, Label: "Активная", Tag: "success"}, StatusChangedAt: "20.09.2024 12:04", StartTime: "22.09.2024 14:00", NextLessonTime: "13.04.2025 14:00", LessonsTotal: 35, LessonsPassed: 28, HardwareNeeded: 0, Branch: backoffice2.BranchFull{ + ID: 987, + Title: "Город Х", + Code: "city_x", + Description: "", + Phone: "+7 (491) 555-22-33", + Email: "cityx@algoritmika.org", + SiteURL: "https://cityx.algoritmika.org", + TemplateVersion: 2, + UseAmo: true, + AmoConfigID: 321, + ShowFinanceInfo: true, + LmsDisplayStudentCredentials: true, + ShowOnlineRoomURLField: 0, + UseSms: false, + LanguageID: 2, + OrderName: 1, + UseFullyPaidLabel: 0, + BrandName: "", + MaxCountStudentsForShowOnline: 10, + IsFillPaymentSystem: false, + FirstLessonNoRoyalty: 0, + RootBranchID: 123, + }, Venue: backoffice2.VenueFull{ID: 4321, Title: "Библиотека №5", Address: "390000, Город Х, ул Ленина, д 10", ContactName: "", ContactEmail: "", ContactPhone: "", Links: backoffice2.LinksFull{Self: "/venue/view/4321"}}, Curator: backoffice2.UserFull{ID: 1234, Username: "random_user", Phone: "+7 (900) 555-12-34", Email: "example123@mail.com", Name: "Иван Иванов", Profile: backoffice2.ProfileFull{PhotoURL: "/uploads/avatar/avatar/avatar_1234_1603094230-96x96.jpg", Promo: ""}, Status: 10, Links: backoffice2.LinksFull{Self: "/user/update/1234"}}, Teacher: backoffice2.TeacherFull{ID: 5678, Username: "random_user2", Phone: "+7 (922) 555-00-11", Email: "randomuser2@mail.com", Name: "Алексей Смирнов", Profile: backoffice2.ProfileFull{PhotoURL: "", Promo: ""}, AllowedUserCourses: []backoffice2.AllowedUserCourseFull{{UserID: 42407, CourseID: 84, IsAllowed: 1}, {UserID: 42407, CourseID: 305, IsAllowed: 1}, {UserID: 42407, CourseID: 389, IsAllowed: 1}, {UserID: 42407, CourseID: 405, IsAllowed: 1}, {UserID: 42407, CourseID: 406, IsAllowed: 1}, {UserID: 42407, CourseID: 407, IsAllowed: 1}, {UserID: 42407, CourseID: 408, IsAllowed: 1}, {UserID: 42407, CourseID: 416, IsAllowed: 1}, {UserID: 42407, CourseID: 417, IsAllowed: 1}, {UserID: 42407, CourseID: 448, IsAllowed: 1}, {UserID: 42407, CourseID: 465, IsAllowed: 1}, {UserID: 42407, CourseID: 606, IsAllowed: 1}, {UserID: 42407, CourseID: 640, IsAllowed: 1}, {UserID: 42407, CourseID: 641, IsAllowed: 1}, {UserID: 42407, CourseID: 661, IsAllowed: 1}, {UserID: 42407, CourseID: 662, IsAllowed: 1}, {UserID: 42407, CourseID: 665, IsAllowed: 1}, {UserID: 42407, CourseID: 666, IsAllowed: 1}, {UserID: 42407, CourseID: 686, IsAllowed: 1}, {UserID: 42407, CourseID: 706, IsAllowed: 1}, {UserID: 42407, CourseID: 707, IsAllowed: 1}, {UserID: 42407, CourseID: 716, IsAllowed: 1}, {UserID: 42407, CourseID: 727, IsAllowed: 1}, {UserID: 42407, CourseID: 729, IsAllowed: 1}, {UserID: 42407, CourseID: 734, IsAllowed: 1}, {UserID: 42407, CourseID: 735, IsAllowed: 1}, {UserID: 42407, CourseID: 777, IsAllowed: 1}, {UserID: 42407, CourseID: 783, IsAllowed: 1}, {UserID: 42407, CourseID: 797, IsAllowed: 1}, {UserID: 42407, CourseID: 799, IsAllowed: 1}, {UserID: 42407, CourseID: 809, IsAllowed: 1}, {UserID: 42407, CourseID: 810, IsAllowed: 1}, {UserID: 42407, CourseID: 823, IsAllowed: 1}, {UserID: 42407, CourseID: 831, IsAllowed: 1}, {UserID: 42407, CourseID: 852, IsAllowed: 1}, {UserID: 42407, CourseID: 853, IsAllowed: 1}, {UserID: 42407, CourseID: 857, IsAllowed: 1}, {UserID: 42407, CourseID: 858, IsAllowed: 1}, {UserID: 42407, CourseID: 859, IsAllowed: 1}, {UserID: 42407, CourseID: 860, IsAllowed: 1}, {UserID: 42407, CourseID: 861, IsAllowed: 1}, {UserID: 42407, CourseID: 862, IsAllowed: 1}, {UserID: 42407, CourseID: 864, IsAllowed: 1}, {UserID: 42407, CourseID: 1338, IsAllowed: 1}, {UserID: 42407, CourseID: 1339, IsAllowed: 1}, {UserID: 42407, CourseID: 1346, IsAllowed: 1}, {UserID: 42407, CourseID: 1347, IsAllowed: 1}, {UserID: 42407, CourseID: 1387, IsAllowed: 1}, {UserID: 42407, CourseID: 1459, IsAllowed: 1}, {UserID: 42407, CourseID: 1484, IsAllowed: 1}, {UserID: 42407, CourseID: 1543, IsAllowed: 1}, {UserID: 42407, CourseID: 1554, IsAllowed: 1}, {UserID: 42407, CourseID: 1613, IsAllowed: 1}, {UserID: 42407, CourseID: 1614, IsAllowed: 1}, {UserID: 42407, CourseID: 1615, IsAllowed: 1}, {UserID: 42407, CourseID: 1616, IsAllowed: 1}, {UserID: 42407, CourseID: 1653, IsAllowed: 1}, {UserID: 42407, CourseID: 1654, IsAllowed: 1}, {UserID: 42407, CourseID: 1664, IsAllowed: 1}, {UserID: 42407, CourseID: 1665, IsAllowed: 1}, {UserID: 42407, CourseID: 1668, IsAllowed: 1}, {UserID: 42407, CourseID: 1686, IsAllowed: 1}, {UserID: 42407, CourseID: 1688, IsAllowed: 1}, {UserID: 42407, CourseID: 1692, IsAllowed: 1}, {UserID: 42407, CourseID: 1710, IsAllowed: 1}, {UserID: 42407, CourseID: 1748, IsAllowed: 1}, {UserID: 42407, CourseID: 1767, IsAllowed: 1}, {UserID: 42407, CourseID: 1810, IsAllowed: 1}, {UserID: 42407, CourseID: 1904, IsAllowed: 1}, {UserID: 42407, CourseID: 2007, IsAllowed: 1}, {UserID: 42407, CourseID: 2033, IsAllowed: 1}, {UserID: 42407, CourseID: 2125, IsAllowed: 1}, {UserID: 42407, CourseID: 2126, IsAllowed: 1}, {UserID: 42407, CourseID: 2231, IsAllowed: 1}, {UserID: 42407, CourseID: 2259, IsAllowed: 1}}, Status: 10, Links: backoffice2.LinksFull{Self: "/user/update/42407"}}, Teachers: []backoffice2.TeacherFull{{ + ID: 5678, + Username: "random_user2", + Phone: "+7 (922) 555-00-11", + Email: "randomuser2@mail.com", + Name: "Алексей Смирнов", + Profile: backoffice2.ProfileFull{PhotoURL: "", Promo: ""}, + AllowedUserCourses: []backoffice2.AllowedUserCourseFull{{UserID: 42407, CourseID: 84, IsAllowed: 1}, {UserID: 42407, CourseID: 305, IsAllowed: 1}, {UserID: 42407, CourseID: 389, IsAllowed: 1}, {UserID: 42407, CourseID: 405, IsAllowed: 1}, {UserID: 42407, CourseID: 406, IsAllowed: 1}, {UserID: 42407, CourseID: 407, IsAllowed: 1}, {UserID: 42407, CourseID: 408, IsAllowed: 1}, {UserID: 42407, CourseID: 416, IsAllowed: 1}, {UserID: 42407, CourseID: 417, IsAllowed: 1}, {UserID: 42407, CourseID: 448, IsAllowed: 1}, {UserID: 42407, CourseID: 465, IsAllowed: 1}, {UserID: 42407, CourseID: 606, IsAllowed: 1}, {UserID: 42407, CourseID: 640, IsAllowed: 1}, {UserID: 42407, CourseID: 641, IsAllowed: 1}, {UserID: 42407, CourseID: 661, IsAllowed: 1}, {UserID: 42407, CourseID: 662, IsAllowed: 1}, {UserID: 42407, CourseID: 665, IsAllowed: 1}, {UserID: 42407, CourseID: 666, IsAllowed: 1}, {UserID: 42407, CourseID: 686, IsAllowed: 1}, {UserID: 42407, CourseID: 706, IsAllowed: 1}, {UserID: 42407, CourseID: 707, IsAllowed: 1}, {UserID: 42407, CourseID: 716, IsAllowed: 1}, {UserID: 42407, CourseID: 727, IsAllowed: 1}, {UserID: 42407, CourseID: 729, IsAllowed: 1}, {UserID: 42407, CourseID: 734, IsAllowed: 1}, {UserID: 42407, CourseID: 735, IsAllowed: 1}, {UserID: 42407, CourseID: 777, IsAllowed: 1}, {UserID: 42407, CourseID: 783, IsAllowed: 1}, {UserID: 42407, CourseID: 797, IsAllowed: 1}, {UserID: 42407, CourseID: 799, IsAllowed: 1}, {UserID: 42407, CourseID: 809, IsAllowed: 1}, {UserID: 42407, CourseID: 810, IsAllowed: 1}, {UserID: 42407, CourseID: 823, IsAllowed: 1}, {UserID: 42407, CourseID: 831, IsAllowed: 1}, {UserID: 42407, CourseID: 852, IsAllowed: 1}, {UserID: 42407, CourseID: 853, IsAllowed: 1}, {UserID: 42407, CourseID: 857, IsAllowed: 1}, {UserID: 42407, CourseID: 858, IsAllowed: 1}, {UserID: 42407, CourseID: 859, IsAllowed: 1}, {UserID: 42407, CourseID: 860, IsAllowed: 1}, {UserID: 42407, CourseID: 861, IsAllowed: 1}, {UserID: 42407, CourseID: 862, IsAllowed: 1}, {UserID: 42407, CourseID: 864, IsAllowed: 1}, {UserID: 42407, CourseID: 1338, IsAllowed: 1}, {UserID: 42407, CourseID: 1339, IsAllowed: 1}, {UserID: 42407, CourseID: 1346, IsAllowed: 1}, {UserID: 42407, CourseID: 1347, IsAllowed: 1}, {UserID: 42407, CourseID: 1387, IsAllowed: 1}, {UserID: 42407, CourseID: 1459, IsAllowed: 1}, {UserID: 42407, CourseID: 1484, IsAllowed: 1}, {UserID: 42407, CourseID: 1543, IsAllowed: 1}, {UserID: 42407, CourseID: 1554, IsAllowed: 1}, {UserID: 42407, CourseID: 1613, IsAllowed: 1}, {UserID: 42407, CourseID: 1614, IsAllowed: 1}, {UserID: 42407, CourseID: 1615, IsAllowed: 1}, {UserID: 42407, CourseID: 1616, IsAllowed: 1}, {UserID: 42407, CourseID: 1653, IsAllowed: 1}, {UserID: 42407, CourseID: 1654, IsAllowed: 1}, {UserID: 42407, CourseID: 1664, IsAllowed: 1}, {UserID: 42407, CourseID: 1665, IsAllowed: 1}, {UserID: 42407, CourseID: 1668, IsAllowed: 1}, {UserID: 42407, CourseID: 1686, IsAllowed: 1}, {UserID: 42407, CourseID: 1688, IsAllowed: 1}, {UserID: 42407, CourseID: 1692, IsAllowed: 1}, {UserID: 42407, CourseID: 1710, IsAllowed: 1}, {UserID: 42407, CourseID: 1748, IsAllowed: 1}, {UserID: 42407, CourseID: 1767, IsAllowed: 1}, {UserID: 42407, CourseID: 1810, IsAllowed: 1}, {UserID: 42407, CourseID: 1904, IsAllowed: 1}, {UserID: 42407, CourseID: 2007, IsAllowed: 1}, {UserID: 42407, CourseID: 2033, IsAllowed: 1}, {UserID: 42407, CourseID: 2125, IsAllowed: 1}, {UserID: 42407, CourseID: 2126, IsAllowed: 1}, {UserID: 42407, CourseID: 2231, IsAllowed: 1}, {UserID: 42407, CourseID: 2259, IsAllowed: 1}}, + Status: 10, + Links: backoffice2.LinksFull{Self: "/user/update/42407"}, + }}, ClientManager: interface{}(nil), Course: backoffice2.CourseFull{ + ID: 729, + Name: "Компьютерная грамотность", + GUID: "a131029b-cde5-11eb-a724-6cb31107bf10", + Description: "Курс для внеурочных занятий (дополнительное образование) с детьми в возрасте 7-9 лет, на русском языке, версия 2021/2022", + ContentType: "course", + CourseType: backoffice2.CourseTypeFull{ID: 19, Title: "компьютерная грамотность", Code: "comp"}, + LessonsCount: 0, + GroupLessonsAmount: 0, + LessonsCountFormatted: "нет модулей", + GroupLessonsAmountFormatted: "нет уроков", + IsDeleted: 0, + Links: backoffice2.LinksFull{Self: "/course/view/a131029b-cde5-11eb-a724-6cb31107bf10"}, + }, LanguageID: interface{}(nil), Journal: true, ShowJournal: true, ShowOnlineRoom: true, IsOnline: false, ActiveStudentCount: 8, OnlineRoomURL: "", UseClientManager: 0, DisplayLessonDurationInMinutes: 60, DeletedAt: interface{}(nil), DeletedBy: interface{}(nil), PriorityLevel: backoffice2.PriorityLevelFull{Value: "normal", Label: "Обычный приоритет", Tag: "default"}, IsFull: false, CreatedAt: "17.09.2024 10:41", CreatedBy: backoffice2.UserFull{ + ID: 1234, + Username: "random_user", + Phone: "+7 (900) 555-12-34", + Email: "example123@mail.com", + Name: "Иван Иванов", + Profile: backoffice2.ProfileFull{PhotoURL: "/uploads/avatar/avatar/avatar_1234_1603094230-96x96.jpg", Promo: ""}, + Status: 10, + Links: backoffice2.LinksFull{Self: "/user/update/1234"}, + }, Related: backoffice2.RelatedFull{ + Statuses: []backoffice2.StatusFull{{Value: 10, Label: "Активная", Tag: "success"}, {Value: 1, Label: "Не стартовала", Tag: "warning"}, {Value: 20, Label: "Идет набор", Tag: "warning"}, {Value: 30, Label: "Приостановлена", Tag: "warning"}, {Value: 0, Label: "Окончена", Tag: "default"}, {Value: 2, Label: "Развалилась", Tag: "default"}}, + Types: []backoffice2.TypeFull{{Value: "regular", Label: "Группа", Tag: "default"}, {Value: "masterclass", Label: "Мастер-класс", Tag: "info"}, {Value: "intensive", Label: "Интенсив", Tag: "warning"}, {Value: "demo", Label: "Обучение сотрудников", Tag: "inactive"}, {Value: "individual", Label: "Индивидуальная", Tag: "default"}}, + PriorityLevels: []backoffice2.PriorityLevelFull{{Value: "normal", Label: "Обычный", Tag: "default"}, {Value: "high", Label: "Высокий", Tag: "warning"}}, + }}, +} +var backofficeKidsByGroupExpected = backoffice2.NamesByGroup{ + Status: "success", + Data: backoffice2.GroupData{Items: []backoffice2.Student{{ + ID: 70245813, + FirstName: "Иван", + LastName: "Петров", + FullName: "Иван Петров", + ParentName: "Ольга", + Email: "petrov_ivan@mail.ru", + HasLaptop: -1, + Phone: "+7 (915) 123-45-67", + Age: 10, + BirthDate: time.Date(2014, time.December, 16, 0, 0, 0, 0, time.Local), + HasBranchAccess: true, + Username: "petrov_i", + Password: "7605", + LastGroup: backoffice2.Group{ + ID: 98637162, + GroupStudentID: 6553709, + Title: "Библиотека 7 вс 14.00", + Content: "Группа по курсу КГ", + Track: 2, + Status: 0, + StartTime: time.Date(2024, time.November, 25, 10, 54, 55, 0, time.Local), + EndTime: time.Date(9999, time.December, 31, 0, 0, 0, 0, time.Local), + CourseID: 729, + }, + Groups: []backoffice2.Group(nil), + Links: backoffice2.Links{Self: backoffice2.SelfLink{Href: "/student/update/70245813"}}, + }}}, +} +var backofficeKidViewExpected = backoffice2.KidView{ + Status: "success", + Data: backoffice2.Student{ + ID: 70245813, + FirstName: "Иван", + LastName: "Петров", + FullName: "Иван Петров", + ParentName: "Ольга", + Email: "student123@example.com", + HasLaptop: -1, + Phone: "+7 (900) 123-45-67", + Age: 10, + BirthDate: time.Date(2014, time.December, 16, 0, 0, 0, 0, time.Local), + DeletedAt: interface{}(nil), + HasBranchAccess: true, + Username: "student_01", + Password: "7605", + LastGroup: backoffice2.Group{ + ID: 0, + GroupStudentID: 0, + Title: "", + Content: "", + Track: 0, + Status: 0, + StartTime: time.Date(1, time.January, 1, 0, 0, 0, 0, time.UTC), + EndTime: time.Date(1, time.January, 1, 0, 0, 0, 0, time.UTC), + CourseID: 0, + DeletedAt: interface{}(nil), + }, + Groups: []backoffice2.Group{{ + ID: 98637162, + GroupStudentID: 6543284, + Title: "Библиотека 7 вс 14.00", + Content: "Группа по курсу КГ", + Track: 1, + Status: 20, + StartTime: time.Date(2024, time.November, 18, 11, 18, 45, 0, time.Local), + EndTime: time.Date(2024, time.November, 23, 16, 56, 45, 0, time.Local), + CourseID: 729, + DeletedAt: interface{}(nil), + }, {ID: 98637162, GroupStudentID: 6553709, Title: "Библиотека 7 вс 14.00", Content: "Группа по курсу КГ", Track: 2, Status: 0, StartTime: time.Date(2024, time.November, 25, 10, 54, 55, 0, time.Local), EndTime: time.Date(9999, time.December, 31, 0, 0, 0, 0, time.Local), CourseID: 729, CreatedAt: time.Date(1, time.January, 1, 0, 0, 0, 0, time.UTC), UpdatedAt: time.Date(1, time.January, 1, 0, 0, 0, 0, time.UTC), DeletedAt: interface{}(nil)}}, + Links: backoffice2.Links{Self: backoffice2.SelfLink{Href: "/student/update/70245813"}}, + }, +} +var backofficeMsg = backoffice2.KidsMessages{ + Status: "success", + Data: backoffice2.MessagesData{Projects: []backoffice2.Message{{ + UID: "33123098level1123826", + New: false, + SenderID: 42407, + SenderScope: "user", + Type: "text", + Content: "content", + Name: "name", + LastTime: "15 мар. 19:26", + Title: "М5 У2. Игра \"Game\". Ч. 1", + Link: "/task-preview/link", + }}}, +} diff --git a/test/lib/backoffice/group_example b/test/lib/backoffice/group_example new file mode 100644 index 0000000..9e8f19f --- /dev/null +++ b/test/lib/backoffice/group_example @@ -0,0 +1,858 @@ + + +
+ + + +
+
+
+
+
+
+
+
+
+ Toggle navigation
+
+
+
+
+
+
+