diff --git a/.gitignore b/.gitignore index ca20f47..e503e41 100644 --- a/.gitignore +++ b/.gitignore @@ -20,3 +20,5 @@ worker/worker storer/storer answerer/answerer core +/.tdlib/ +/unsetrecover.bolt diff --git a/app/app.go b/app/app.go index 9214547..d5c332d 100644 --- a/app/app.go +++ b/app/app.go @@ -4,6 +4,7 @@ import ( "context" "errors" "fmt" + "github.com/go-redis/redis/v8" "github.com/gofiber/fiber/v2" "github.com/skeris/appInit" "github.com/themakers/hlog" @@ -17,11 +18,13 @@ import ( "penahub.gitlab.yandexcloud.net/backend/quiz/common.git/model" "penahub.gitlab.yandexcloud.net/backend/quiz/core/brokers" "penahub.gitlab.yandexcloud.net/backend/quiz/core/clients/auth" + "penahub.gitlab.yandexcloud.net/backend/quiz/core/clients/telegram" "penahub.gitlab.yandexcloud.net/backend/quiz/core/initialize" "penahub.gitlab.yandexcloud.net/backend/quiz/core/models" "penahub.gitlab.yandexcloud.net/backend/quiz/core/server" "penahub.gitlab.yandexcloud.net/backend/quiz/core/service" "penahub.gitlab.yandexcloud.net/backend/quiz/core/tools" + "penahub.gitlab.yandexcloud.net/backend/quiz/core/workers" "penahub.gitlab.yandexcloud.net/external/trashlog/wrappers/zaptrashlog" "time" ) @@ -69,6 +72,10 @@ type Options struct { TrashLogHost string `env:"TRASH_LOG_HOST" default:"localhost:7113"` ModuleLogger string `env:"MODULE_LOGGER" default:"core-local"` ClickHouseCred string `env:"CLICK_HOUSE_CRED" default:"tcp://10.8.0.15:9000/default?sslmode=disable"` + RedisHost string `env:"REDIS_HOST" default:"localhost:6379"` + RedisPassword string `env:"REDIS_PASSWORD" default:"admin"` + RedisDB uint64 `env:"REDIS_DB" default:"2"` + S3Prefix string `env:"S3_PREFIX"` } func New(ctx context.Context, opts interface{}, ver appInit.Version) (appInit.CommonApp, error) { @@ -143,6 +150,16 @@ func New(ctx context.Context, opts interface{}, ver appInit.Version) (appInit.Co Logger: zapLogger, }) + redisClient := redis.NewClient(&redis.Options{ + Addr: options.RedisHost, + Password: options.RedisPassword, + DB: int(options.RedisDB), + }) + err = redisClient.Ping(ctx).Err() + if err != nil { + panic(fmt.Sprintf("error ping to redis db %v", err)) + } + clientData := privilege.Client{ URL: options.HubAdminUrl, ServiceName: options.ServiceName, @@ -152,6 +169,20 @@ func New(ctx context.Context, opts interface{}, ver appInit.Version) (appInit.Co privilegeController := privilege.NewPrivilege(clientData, fiberClient) go tools.PublishPrivilege(privilegeController, 10, 5*time.Minute) + tgClient, err := telegram.NewTelegramClient(ctx, pgdal) + if err != nil { + panic(fmt.Sprintf("failed init tg clietns: %v", err)) + } + + tgWC := workers.NewTgListenerWC(workers.Deps{ + BotID: int64(6712573453), // todo убрать + Redis: redisClient, + Dal: pgdal, + TgClient: tgClient, + }) + + go tgWC.Start(ctx) + // todo подумать над реализацией всего а то пока мне кажется что немного каша получается такой предикт что через некоторое время // сложно будет разобраться что есть где grpcControllers := initialize.InitRpcControllers(pgdal) @@ -173,11 +204,14 @@ func New(ctx context.Context, opts interface{}, ver appInit.Version) (appInit.Co app.Get("/readiness", healthchecks.Readiness(&workerErr)) //todo parametrized readiness. should discuss ready reason svc := service.New(service.Deps{ - Dal: pgdal, - AuthClient: authClient, - Producer: producer, - ServiceName: options.ServiceName, - ChDAL: chDal, + Dal: pgdal, + AuthClient: authClient, + Producer: producer, + ServiceName: options.ServiceName, + ChDAL: chDal, + TelegramClient: tgClient, + RedisClient: redisClient, + S3Prefix: options.S3Prefix, }) svc.Register(app) diff --git a/clients/telegram/tg.go b/clients/telegram/tg.go new file mode 100644 index 0000000..7f43d23 --- /dev/null +++ b/clients/telegram/tg.go @@ -0,0 +1,246 @@ +package telegram + +import ( + "context" + "errors" + "fmt" + "path/filepath" + "penahub.gitlab.yandexcloud.net/backend/quiz/common.git/dal" + "penahub.gitlab.yandexcloud.net/backend/quiz/common.git/model" + "penahub.gitlab.yandexcloud.net/backend/quiz/common.git/pj_errors" + "penahub.gitlab.yandexcloud.net/backend/tdlib/client" + "sync" + "time" +) + +type TelegramClient struct { + repo *dal.DAL + TgClients map[int64]*client.Client + WaitingClients map[string]WaitingClient + mu sync.Mutex +} + +type WaitingClient struct { + PreviousReq AuthTgUserReq + Authorizer *client.ClientAuthorizer +} + +func NewTelegramClient(ctx context.Context, repo *dal.DAL) (*TelegramClient, error) { + tgClient := &TelegramClient{ + repo: repo, + TgClients: make(map[int64]*client.Client), + WaitingClients: make(map[string]WaitingClient), + } + + allTgAccounts, err := repo.TgRepo.GetAllTgAccounts(ctx) + if err != nil { + if errors.Is(err, pj_errors.ErrNotFound) { + return tgClient, nil + } + return nil, err + } + + for _, account := range allTgAccounts { + if account.Status == model.ActiveTg { + authorizer := client.ClientAuthorizerr() + authorizer.TdlibParameters <- &client.SetTdlibParametersRequest{ + UseTestDc: false, + DatabaseDirectory: filepath.Join(".tdlib", "database"), + FilesDirectory: filepath.Join(".tdlib", "files"), + UseFileDatabase: true, + UseChatInfoDatabase: true, + UseMessageDatabase: true, + UseSecretChats: true, + ApiId: account.ApiID, + ApiHash: account.ApiHash, + SystemLanguageCode: "en", + DeviceModel: "Server", + SystemVersion: "1.0.0", + ApplicationVersion: "1.0.0", + } + + _, err := client.SetLogVerbosityLevel(&client.SetLogVerbosityLevelRequest{ + NewVerbosityLevel: 1, + }) + if err != nil { + return nil, err + } + + var tdlibClient *client.Client + var goErr error + go func() { + tdlibClient, goErr = client.NewClient(authorizer) + if goErr != nil { + fmt.Println("new client failed", err) + return + } + fmt.Println("i am down") + }() + if goErr != nil { + return nil, goErr + } + + for { + state, ok := <-authorizer.State + if !ok { + break + } + fmt.Println("currnet state:", state) + switch state.AuthorizationStateType() { + case client.TypeAuthorizationStateWaitPhoneNumber: + authorizer.PhoneNumber <- account.PhoneNumber + case client.TypeAuthorizationStateWaitCode: + err := tgClient.repo.TgRepo.UpdateStatusTg(ctx, account.ID, model.InactiveTg) + if err != nil { + return nil, err + } + case client.TypeAuthorizationStateLoggingOut, client.TypeAuthorizationStateClosing, client.TypeAuthorizationStateClosed: + err := tgClient.repo.TgRepo.UpdateStatusTg(ctx, account.ID, model.InactiveTg) + if err != nil { + return nil, err + } + case client.TypeAuthorizationStateReady: + // костыль так как в либе тож костыль стоит пока там ьд обновиться будет ниловый всегда клиент + time.Sleep(3 * time.Second) + me, err := tdlibClient.GetMe() + if err != nil { + return nil, err + } + fmt.Printf("Me: %s %s [%v]", me.FirstName, me.LastName, me.Usernames) + tgClient.mu.Lock() + tgClient.TgClients[account.ID] = tdlibClient + tgClient.mu.Unlock() + break + case client.TypeAuthorizationStateWaitPassword: + authorizer.Password <- account.Password + } + } + } + } + return tgClient, nil +} + +type AuthTgUserReq struct { + ApiID int32 `json:"api_id"` + ApiHash string `json:"api_hash"` + PhoneNumber string `json:"phone_number"` + Password string `json:"password"` +} + +func (tg *TelegramClient) AddedToMap(data WaitingClient, id string) { + fmt.Println("AddedToMap") + tg.mu.Lock() + defer tg.mu.Unlock() + tg.WaitingClients[id] = data +} + +func (tg *TelegramClient) GetFromMap(id string) (WaitingClient, bool) { + fmt.Println("GetFromMap") + tg.mu.Lock() + defer tg.mu.Unlock() + if data, ok := tg.WaitingClients[id]; ok { + delete(tg.WaitingClients, id) + return data, true + } + return WaitingClient{}, false +} + +func (tg *TelegramClient) SaveTgAccount(appID int32, appHash string, tdLibClient *client.Client) { + account, err := tg.repo.TgRepo.SearchIDByAppIDanAppHash(context.Background(), appID, appHash) + if err != nil { + fmt.Println("err SaveTgAccount", err) + return + } + if account.Status == model.ActiveTg { + tg.mu.Lock() + defer tg.mu.Unlock() + tg.TgClients[account.ID] = tdLibClient + } +} + +func (tg *TelegramClient) CreateChannel(channelName string, botID int64) (string, int64, error) { + tg.mu.Lock() + defer tg.mu.Unlock() + if len(tg.TgClients) == 0 { + return "", 0, errors.New("no active Telegram clients") + } + var lastError error + var inviteLink string + var channelId int64 + for _, activeClient := range tg.TgClients { + // todo пока не понимаю это какой то рандом? в один день бот норм находится в другой уже не находится хотя абсолютно с точки зрения тг кода этой функции и бота не менялось + _, err := activeClient.GetUser(&client.GetUserRequest{ + UserId: botID, + }) + if err != nil { + lastError = fmt.Errorf("not found this bot, make privacy off: %v", err) + continue + } + + // todo нужно поймать ошибку, при которой либо бан либо медленный редим включается для того чтобы прервать + // исполнение клиента текущего аккаунта и дать задачу следующему пока поймал 1 раз и не запомнил больше не получается + channel, err := activeClient.CreateNewSupergroupChat(&client.CreateNewSupergroupChatRequest{ + Title: channelName, + IsChannel: true, + Description: "private channel", + }) + if err != nil { + lastError = fmt.Errorf("failed to create channel: %s", err.Error()) + continue + } + + _, err = activeClient.SetChatMemberStatus(&client.SetChatMemberStatusRequest{ + ChatId: channel.Id, + MemberId: &client.MessageSenderUser{UserId: botID}, + Status: &client.ChatMemberStatusAdministrator{ + CustomTitle: "bot", + Rights: &client.ChatAdministratorRights{ + CanManageChat: true, + CanChangeInfo: true, + CanPostMessages: true, + CanEditMessages: true, + CanDeleteMessages: true, + CanInviteUsers: true, + CanRestrictMembers: true, + CanPinMessages: true, + CanManageTopics: true, + CanPromoteMembers: true, + CanManageVideoChats: true, + CanPostStories: true, + CanEditStories: true, + CanDeleteStories: true, + }, + }, + }) + if err != nil { + lastError = fmt.Errorf("failed to make bot admin: %s", err.Error()) + continue + } + + inviteLinkResp, err := activeClient.CreateChatInviteLink(&client.CreateChatInviteLinkRequest{ + ChatId: channel.Id, + Name: channelName, + ExpirationDate: 0, + MemberLimit: 0, + CreatesJoinRequest: false, + }) + if err != nil { + lastError = fmt.Errorf("failed to get invite link: %s", err.Error()) + continue + } + + _, err = activeClient.LeaveChat(&client.LeaveChatRequest{ + ChatId: channel.Id, + }) + if err != nil { + lastError = fmt.Errorf("failed to leave the channel: %s", err.Error()) + continue + } + + inviteLink = inviteLinkResp.InviteLink + channelId = channel.Id + return inviteLink, channelId, nil + } + + return "", 0, lastError +} diff --git a/deployments/main/docker-compose.yaml b/deployments/main/docker-compose.yaml index 46ada01..e32eb14 100644 --- a/deployments/main/docker-compose.yaml +++ b/deployments/main/docker-compose.yaml @@ -1,3 +1,4 @@ +version: "3" services: core: hostname: squiz-core @@ -15,5 +16,13 @@ services: PUBLIC_KEY: $PEM_PUB_USERID PRIVATE_KEY: $PEM_PRIV_USERID REDIRECT_URL: 'https://quiz.pena.digital' + KAFKA_BROKERS: 10.8.0.6:9092 + KAFKA_TOPIC: "mailnotifier" + GRPC_HOST: "0.0.0.0" + TRASH_LOG_HOST: "10.8.0.15:7113" + MODULE_LOGGER: "quiz-core-main" + CLICK_HOUSE_CRED: "clickhouse://10.8.0.15:9000/default?sslmode=disable" + S3_PREFIX: "https://s3.timeweb.cloud/3c580be9-cf31f296-d055-49cf-b39e-30c7959dc17b/squizimages/" ports: - 10.8.0.9:1488:1488 + - 10.8.0.9:9000:9000 diff --git a/deployments/staging/docker-compose.yaml b/deployments/staging/docker-compose.yaml index e2898da..e0928cd 100644 --- a/deployments/staging/docker-compose.yaml +++ b/deployments/staging/docker-compose.yaml @@ -15,6 +15,7 @@ services: AUTH_URL: 'http://10.8.0.6:59300/user' PUBLIC_KEY: $PEM_PUB_USERID PRIVATE_KEY: $PEM_PRIV_USERID + REDIRECT_URL: 'https://quiz.pena.digital' KAFKA_BROKERS: 10.8.0.6:9092 KAFKA_TOPIC: "mailnotifier" GRPC_HOST: "0.0.0.0" diff --git a/go.mod b/go.mod index d41aca4..128a6f4 100644 --- a/go.mod +++ b/go.mod @@ -1,16 +1,16 @@ module penahub.gitlab.yandexcloud.net/backend/quiz/core -go 1.22.0 - -toolchain go1.22.2 +go 1.22.4 require ( + github.com/go-redis/redis/v8 v8.11.5 github.com/gofiber/fiber/v2 v2.52.4 github.com/golang-jwt/jwt/v5 v5.2.1 github.com/grpc-ecosystem/go-grpc-middleware v1.4.0 github.com/joho/godotenv v1.5.1 github.com/lib/pq v1.10.9 github.com/pioz/faker v1.7.3 + github.com/rs/xid v1.5.0 github.com/skeris/appInit v1.0.2 github.com/stretchr/testify v1.9.0 github.com/themakers/hlog v0.0.0-20191205140925-235e0e4baddf @@ -20,10 +20,10 @@ require ( google.golang.org/grpc v1.64.0 google.golang.org/protobuf v1.34.2 penahub.gitlab.yandexcloud.net/backend/penahub_common v0.0.0-20240607202348-efe5f2bf3e8c - penahub.gitlab.yandexcloud.net/backend/quiz/common.git v0.0.0-20240703125409-25e0fe5d6051 + penahub.gitlab.yandexcloud.net/backend/quiz/common.git v0.0.0-20240711133242-0b8534fae5b2 penahub.gitlab.yandexcloud.net/backend/quiz/worker.git v0.0.0-20240421230341-0e086fcbb990 - penahub.gitlab.yandexcloud.net/devops/linters/golang.git v0.0.0-20240803124813-79e62d2acf3c - penahub.gitlab.yandexcloud.net/external/trashlog v0.1.5 + penahub.gitlab.yandexcloud.net/backend/tdlib v0.0.0-20240701075856-1731684c936f + penahub.gitlab.yandexcloud.net/external/trashlog v0.1.6-0.20240827173635-78ce9878c387 ) require ( @@ -34,7 +34,6 @@ require ( github.com/davecgh/go-spew v1.1.1 // indirect github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect github.com/dustin/go-humanize v1.0.1 // indirect - github.com/go-redis/redis/v8 v8.11.5 // indirect github.com/goccy/go-json v0.10.2 // indirect github.com/golang/protobuf v1.5.4 // indirect github.com/google/uuid v1.6.0 // indirect @@ -52,14 +51,14 @@ require ( github.com/richardlehane/mscfb v1.0.4 // indirect github.com/richardlehane/msoleps v1.0.3 // indirect github.com/rivo/uniseg v0.4.7 // indirect - github.com/rs/xid v1.5.0 // indirect + github.com/tealeg/xlsx v1.0.5 // indirect github.com/twmb/franz-go/pkg/kmsg v1.8.0 // indirect github.com/valyala/bytebufferpool v1.0.0 // indirect github.com/valyala/fasthttp v1.53.0 // indirect github.com/valyala/tcplisten v1.0.0 // indirect github.com/xuri/efp v0.0.0-20240408161823-9ad904a10d6d // indirect github.com/xuri/nfp v0.0.0-20240318013403-ab9948c2c4a7 // indirect - go.etcd.io/bbolt v1.3.10 // indirect + go.etcd.io/bbolt v1.3.6 // indirect go.uber.org/multierr v1.11.0 // indirect golang.org/x/crypto v0.24.0 // indirect golang.org/x/net v0.26.0 // indirect diff --git a/go.sum b/go.sum index 7083fe0..3cf5969 100644 --- a/go.sum +++ b/go.sum @@ -162,8 +162,8 @@ github.com/xuri/nfp v0.0.0-20240318013403-ab9948c2c4a7 h1:hPVCafDV85blFTabnqKgNh github.com/xuri/nfp v0.0.0-20240318013403-ab9948c2c4a7/go.mod h1:WwHg+CVyzlv/TX9xqBFXEZAuxOPxn2k1GNHwG41IIUQ= github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= -go.etcd.io/bbolt v1.3.10 h1:+BqfJTcCzTItrop8mq/lbzL8wSGtj94UO/3U31shqG0= -go.etcd.io/bbolt v1.3.10/go.mod h1:bK3UQLPJZly7IlNmV7uVHJDxfe5aK9Ll93e/74Y9oEQ= +go.etcd.io/bbolt v1.3.6 h1:/ecaJf0sk1l4l6V4awd65v2C3ILy7MSj+s/x1ADCIMU= +go.etcd.io/bbolt v1.3.6/go.mod h1:qXsaaIqmgQH0T+OPdb99Bf+PKfBBQVAdyD6TY9G8XM4= go.uber.org/atomic v1.6.0/go.mod h1:sABNBOSYdrvTF6hTgEIbc7YasKWGhgEQZyfxyTvoXHQ= go.uber.org/atomic v1.7.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc= go.uber.org/goleak v1.1.10/go.mod h1:8a7PlsEVH3e/a/GLqe5IIrQx6GzcnRmZEufDUTk4A7A= @@ -210,12 +210,11 @@ golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJ golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.7.0 h1:YsImfSBoP9QPYL0xyKJPq0gcaJdG3rInoqxTWbfQu9M= -golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190422165155-953cdadca894/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200923182605-d9f96fdee20d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20211025201205-69cdffdb9359/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= @@ -283,11 +282,11 @@ 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= penahub.gitlab.yandexcloud.net/backend/penahub_common v0.0.0-20240607202348-efe5f2bf3e8c h1:CWb4UcuNXhd1KTNOmy2U0TJO4+Qxgxrj5cwkyFqbgrk= penahub.gitlab.yandexcloud.net/backend/penahub_common v0.0.0-20240607202348-efe5f2bf3e8c/go.mod h1:+bPxq2wfW5S1gd+83vZYmHm33AE7nEBfznWS8AM1TKE= -penahub.gitlab.yandexcloud.net/backend/quiz/common.git v0.0.0-20240630122905-2747a8c00d81 h1:HSEcPZ8PVOrhj6d7/7MjHibxu/+3KXUeFbd/JpZ//bI= -penahub.gitlab.yandexcloud.net/backend/quiz/common.git v0.0.0-20240630122905-2747a8c00d81/go.mod h1:nfZkoj8MCYaoP+xiPeUn5D0lIzinUr1qDkNfX0ng9rk= +penahub.gitlab.yandexcloud.net/backend/quiz/common.git v0.0.0-20240711133242-0b8534fae5b2 h1:0t6pQHJvA3jMeBB3FPUmA+hw8rWlksTah8KJEtf2KD8= +penahub.gitlab.yandexcloud.net/backend/quiz/common.git v0.0.0-20240711133242-0b8534fae5b2/go.mod h1:uOuosXduBzd2WbLH6TDZO7ME7ZextulA662oZ6OsoB0= penahub.gitlab.yandexcloud.net/backend/quiz/worker.git v0.0.0-20240421230341-0e086fcbb990 h1:jiO8GWO+3sCnDAV8/NAV8tQIUwae/I6/xiDilW7zf0o= penahub.gitlab.yandexcloud.net/backend/quiz/worker.git v0.0.0-20240421230341-0e086fcbb990/go.mod h1:zswBuTwmEsFHBVRu1nkG3/Fwylk5Vcm8OUm9iWxccSE= -penahub.gitlab.yandexcloud.net/devops/linters/golang.git v0.0.0-20240803124813-79e62d2acf3c h1:imtXaIVscs8it6SfAmDxjNxqQSF44GgCTl1N6JT6unA= -penahub.gitlab.yandexcloud.net/devops/linters/golang.git v0.0.0-20240803124813-79e62d2acf3c/go.mod h1:i7M72RIpkSjcQtHID6KKj9RT/EYZ1rxS6tIPKWa/BSY= -penahub.gitlab.yandexcloud.net/external/trashlog v0.1.5 h1:amsK0bkSJxBisk334aFo5ZmVPvN1dBT0Sv5j3V5IsT8= -penahub.gitlab.yandexcloud.net/external/trashlog v0.1.5/go.mod h1:J8kQNEP4bL7ZNKHxuT4tfe6a3FHyovpAPkyytN4qllc= +penahub.gitlab.yandexcloud.net/backend/tdlib v0.0.0-20240701075856-1731684c936f h1:Qli89wgu0T7nG4VECXZOZ40fjE/hVVfxF3hTaSYS008= +penahub.gitlab.yandexcloud.net/backend/tdlib v0.0.0-20240701075856-1731684c936f/go.mod h1:AkE19hcbDwB7hoEASwImm7rUI+cK/8jMVJaTvMK4F+c= +penahub.gitlab.yandexcloud.net/external/trashlog v0.1.6-0.20240827173635-78ce9878c387 h1:G+GIhkkvUsM9No2rf2D4kvQ2ExTw9KxlA8vsSnC0ywU= +penahub.gitlab.yandexcloud.net/external/trashlog v0.1.6-0.20240827173635-78ce9878c387/go.mod h1:30nezjpGpZuNThbQOCULIfa79RoJ5sray593jhfVP/Q= diff --git a/openapi.yaml b/openapi.yaml index 13a8641..13460cc 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -784,8 +784,72 @@ components: Deleted: type: boolean description: удален? - - + LeadTarget: + type: object + properties: + ID: + type: integer + format: int64 + AccountID: + type: string + Type: + type: string + QuizID: + type: integer + format: int32 + Target: + type: string + InviteLink: + type: string + Deleted: + type: boolean + CreatedAt: + type: string + TgAccountStatus: + type: string + enum: + - active + - inactive + - ban + TgAccount: + type: object + properties: + ID: + type: integer + format: int64 + ApiID: + type: integer + format: int32 + ApiHash: + type: string + PhoneNumber: + type: string + Password: + type: string + Status: + $ref: '#/components/schemas/TgAccountStatus' + Deleted: + type: boolean + CreatedAt: + type: string + format: date-time + AuthTgUserReq: + type: object + required: + - ApiID + - ApiHash + - PhoneNumber + - Password + properties: + ApiID: + type: integer + format: int32 + ApiHash: + type: string + PhoneNumber: + type: string + Password: + type: string paths: /liveness: get: @@ -1558,6 +1622,211 @@ paths: properties: message: type: string + /account/leadtarget: + post: + description: Метод для добавления целевых мест, куда будут посылаться заявки клиенту. + security: + - Bearer: [ ] + requestBody: + content: + 'application/json': + schema: + type: object + required: + - type + - quizID + - target + properties: + type: + type: string + description: Тип цели (mail, telegram, whatsapp). + enum: + - mail + - telegram + - whatsapp + quizID: + type: integer + format: int32 + description: ID квиза, к которому прикреплено это правило (приоритет). Передавать как 0, если правило не прикрепляется к квизу и является общим. + target: + type: string + description: Адресат, куда конкретно слать (для mail - email, для telegram - ID канала, передавать не нужно канал сам создаться, для whatsapp - номер телефона, наверное). + name: + type: string + description: имя например для тг канала + responses: + '200': + description: ОК, парвило добавлено если тип mail о сразу добавляется если тг то будет добавленно в воркере если ватсап пока тодо +# content: +# application/json: +# schema: +# $ref: '#/components/schemas/LeadTarget' + '400': + description: Bad request, ошибка в теле запроса + content: + application/json: + schema: + type: object + properties: + message: + type: string + '401': + description: Unauthorized, не авторизован + content: + application/json: + schema: + type: object + properties: + message: + type: string + '500': + description: Internal Srv Error + content: + application/json: + schema: + type: object + properties: + message: + type: string + put: + description: Метод для обновления целевого места, куда будут посылаться заявки клиенту. + security: + - Bearer: [ ] + requestBody: + content: + 'application/json': + schema: + type: object + required: + - id + - target + properties: + id: + type: integer + format: int64 + description: id этой самой цели, primary key. + target: + type: string + description: Адресат, куда конкретно слать (для mail - email, для telegram - ID чата, для whatsapp - номер телефона, наверное). + responses: + '200': + description: ОК, парвило обновлено + content: + application/json: + schema: + $ref: '#/components/schemas/LeadTarget' + '400': + description: Bad request, ошибка в теле запроса + content: + application/json: + schema: + type: object + properties: + message: + type: string + '401': + description: Unauthorized, не авторизован + content: + application/json: + schema: + type: object + properties: + message: + type: string + '404': + description: NotFound, такого не существует + content: + application/json: + schema: + type: object + properties: + message: + type: string + '500': + description: Internal Srv Error + content: + application/json: + schema: + type: object + properties: + message: + type: string + /account/leadtarget/{id}: + delete: + description: удаление правила по id, primary key + security: + - Bearer: [ ] + responses: + '200': + description: ОК, парвило удалено + '400': + description: Bad request, ошибка в теле запроса + content: + application/json: + schema: + type: object + properties: + message: + type: string + '500': + description: Internal Srv Error + content: + application/json: + schema: + type: object + properties: + message: + type: string + /account/leadtarget/{quizID}: + get: + description: получение правила по quizID, так же стоит передавать 0 если правило не было привязано к определенному квизу, возвращает массив + security: + - Bearer: [ ] + responses: + '200': + description: ОК, парвила получены + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/LeadTarget' + '400': + description: Bad request, ошибка в теле запроса + content: + application/json: + schema: + type: object + properties: + message: + type: string + '401': + description: Unauthorized, не авторизован + content: + application/json: + schema: + type: object + properties: + message: + type: string + '404': + description: NotFound, такого не существует + content: + application/json: + schema: + type: object + properties: + message: + type: string + '500': + description: Internal Srv Error + content: + application/json: + schema: + type: object + properties: + message: + type: string /statistics/:quizID/pipelines: get: description: получение статистики по векторам прохождения респондентами опроса с ветвлением и без, на выход отдается мапа с ключем последний вопрос и массивом точек "точек прохождения пользователем вопросов" грубо говоря массив с векторами как двигался респондент по возможным путям, в этом массиве question id и count прошедших сессий через него @@ -1581,3 +1850,94 @@ paths: description: Bad Request '500': description: Internal Server Error + /telegram/pool: + get: + description: возвращает все неудаленные аккаунты тг, активные, не активные и баны, тело пустое + responses: + '200': + description: успех + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/TgAccount' + /telegram/create: + post: + description: метод для автторизации сервера в тг аккаунте + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/AuthTgUserReq' + responses: + '200': + description: возвращает подпись, которая является идентификатором текущей сессии авторизации нужно для метода отправки кода + content: + application/json: + schema: + type: object + properties: + signature: + type: string + example: b7gh83j2k4l0 + '400': + description: неверные данные запроса + '409': + description: аккаунт уже существует и активен + '500': + description: внутренняя ошибка сервера + /telegram/{id}: + delete: + description: метод мягкого удаления аккаунта по id primary key + parameters: + - in: path + name: id + required: true + description: id primary key + schema: + type: integer + format: int64 + responses: + '200': + description: успех + '400': + description: неверные данные запроса + '500': + description: внутренняя ошибка сервера + + /telegram/setCode: + post: + description: метод для отправки кода авторизации, который пришел от телеграмма + requestBody: + content: + application/json: + schema: + type: object + required: + - code + - signature + properties: + code: + type: string + signature: + type: string + responses: + '200': + description: возвращает id primary авторизованного аккаунта + content: + application/json: + schema: + type: object + properties: + id: + type: integer + format: int64 + '204': + description: state канал закрылся до того как перешел в состояние логина или отказа от логина, возможно стоит другой статус указывать или как то побороть эту беду + '400': + description: неверные данные запроса + '403': + description: что то пошло не так связано с тг + '500': + description: внутренняя ошибка сервера \ No newline at end of file diff --git a/service/account_svc.go b/service/account_svc.go index e19faea..3366be4 100644 --- a/service/account_svc.go +++ b/service/account_svc.go @@ -2,7 +2,9 @@ package service import ( "database/sql" + "encoding/json" "errors" + "fmt" "github.com/gofiber/fiber/v2" "penahub.gitlab.yandexcloud.net/backend/penahub_common/log_mw" "penahub.gitlab.yandexcloud.net/backend/quiz/common.git/middleware" @@ -10,6 +12,7 @@ import ( "penahub.gitlab.yandexcloud.net/backend/quiz/common.git/pj_errors" "penahub.gitlab.yandexcloud.net/backend/quiz/core/brokers" "penahub.gitlab.yandexcloud.net/backend/quiz/core/models" + "strconv" "time" ) @@ -91,7 +94,6 @@ func (s *Service) createAccount(ctx *fiber.Ctx) error { newAccount := model.Account{ UserID: accountID, CreatedAt: time.Now(), - Email: email, Deleted: false, Privileges: map[string]model.ShortPrivilege{ "quizUnlimTime": { @@ -107,6 +109,15 @@ func (s *Service) createAccount(ctx *fiber.Ctx) error { if err != nil { return ctx.Status(fiber.StatusInternalServerError).SendString(err.Error()) } + _, err = s.dal.AccountRepo.PostLeadTarget(ctx.Context(), model.LeadTarget{ + AccountID: accountID, + Target: email, + Type: model.LeadTargetEmail, + QuizID: 0, + }) + if err != nil { + return ctx.Status(fiber.StatusInternalServerError).SendString(err.Error()) + } hlogger.Emit(models.InfoAccountCreated{ CtxUserID: accountID, @@ -236,3 +247,145 @@ func (s *Service) ManualDone(ctx *fiber.Ctx) error { return ctx.SendStatus(fiber.StatusOK) } + +func (s *Service) PostLeadTarget(ctx *fiber.Ctx) error { + var req struct { + Type string `json:"type"` + QuizID int32 `json:"quizID"` + Target string `json:"target"` + Name string `json:"name"` + } + if err := ctx.BodyParser(&req); err != nil { + return ctx.Status(fiber.StatusBadRequest).SendString("Invalid request data") + } + + accountID, ok := middleware.GetAccountId(ctx) + if !ok { + return ctx.Status(fiber.StatusUnauthorized).SendString("account id is required") + } + + //accountID := "64f2cd7a7047f28fdabf6d9e" + + if _, ok := model.ValidLeadTargetTypes[req.Type]; !ok { + return ctx.Status(fiber.StatusBadRequest).SendString("Invalid type") + } + + if req.Type == "" || (req.Target == "" && req.Type != string(model.LeadTargetTg)) { + return ctx.Status(fiber.StatusBadRequest).SendString("Type and Target don't be nil") + } + + switch req.Type { + case "mail": + _, err := s.dal.AccountRepo.PostLeadTarget(ctx.Context(), model.LeadTarget{ + AccountID: accountID, + Target: req.Target, + Type: model.LeadTargetType(req.Type), + QuizID: req.QuizID, + }) + if err != nil { + return ctx.Status(fiber.StatusInternalServerError).SendString(err.Error()) + } + return ctx.SendStatus(fiber.StatusOK) + case "telegram": + targets, err := s.dal.AccountRepo.GetLeadTarget(ctx.Context(), accountID, req.QuizID) + if err != nil && !errors.Is(err, pj_errors.ErrNotFound) { + return ctx.Status(fiber.StatusInternalServerError).SendString(err.Error()) + } + if !errors.Is(err, pj_errors.ErrNotFound) { + for _, t := range targets { + if t.Type == model.LeadTargetTg { + return ctx.Status(fiber.StatusAlreadyReported).SendString("LeadTarget for this quiz already exist") + } + } + } + + task := model.TgRedisTask{ + Name: req.Name, + QuizID: req.QuizID, + AccountID: accountID, + } + + taskKey := fmt.Sprintf("telegram_task:%d", time.Now().UnixNano()) + taskData, err := json.Marshal(task) + if err != nil { + return ctx.Status(fiber.StatusInternalServerError).SendString(err.Error()) + } + + if err := s.redisClient.Set(ctx.Context(), taskKey, taskData, 0).Err(); err != nil { + return ctx.Status(fiber.StatusInternalServerError).SendString(err.Error()) + } + case "whatsapp": + return ctx.Status(fiber.StatusOK).SendString("todo") + } + + return nil +} + +func (s *Service) DeleteLeadTarget(ctx *fiber.Ctx) error { + leadIDStr := ctx.Params("id") + leadID, err := strconv.ParseInt(leadIDStr, 10, 64) + if err != nil { + return ctx.Status(fiber.StatusBadRequest).SendString("Invalid lead ID format") + } + + err = s.dal.AccountRepo.DeleteLeadTarget(ctx.Context(), leadID) + if err != nil { + return ctx.Status(fiber.StatusInternalServerError).SendString(err.Error()) + } + return ctx.SendStatus(fiber.StatusOK) +} + +func (s *Service) GetLeadTarget(ctx *fiber.Ctx) error { + accountID, ok := middleware.GetAccountId(ctx) + if !ok { + return ctx.Status(fiber.StatusUnauthorized).SendString("account id is required") + } + + quizIDStr := ctx.Params("quizID") + quizID, err := strconv.ParseInt(quizIDStr, 10, 64) + if err != nil { + return ctx.Status(fiber.StatusBadRequest).SendString("Invalid quiz ID format") + } + + result, err := s.dal.AccountRepo.GetLeadTarget(ctx.Context(), accountID, int32(quizID)) + if err != nil { + switch { + case errors.Is(err, pj_errors.ErrNotFound): + return ctx.Status(fiber.StatusNotFound).SendString("this lead target not found") + default: + return ctx.Status(fiber.StatusInternalServerError).SendString(err.Error()) + } + } + + return ctx.Status(fiber.StatusOK).JSON(result) +} + +func (s *Service) UpdateLeadTarget(ctx *fiber.Ctx) error { + var req struct { + ID int64 `json:"id"` + Target string `json:"target"` + } + + if err := ctx.BodyParser(&req); err != nil { + return ctx.Status(fiber.StatusBadRequest).SendString("Invalid request data") + } + + if req.ID == 0 || req.Target == "" { + return ctx.Status(fiber.StatusBadRequest).SendString("ID and Target don't be nil") + } + + result, err := s.dal.AccountRepo.UpdateLeadTarget(ctx.Context(), model.LeadTarget{ + ID: req.ID, + Target: req.Target, + }) + if err != nil { + switch { + case errors.Is(err, pj_errors.ErrNotFound): + return ctx.Status(fiber.StatusNotFound).SendString("this lead target not found") + default: + return ctx.Status(fiber.StatusInternalServerError).SendString(err.Error()) + } + } + + return ctx.Status(fiber.StatusOK).JSON(result) +} diff --git a/service/question_svc.go b/service/question_svc.go index ee22aa4..5111490 100644 --- a/service/question_svc.go +++ b/service/question_svc.go @@ -63,6 +63,7 @@ func (s *Service) CreateQuestion(ctx *fiber.Ctx) error { Page: req.Page, Content: req.Content, } + questionID, err := s.dal.QuestionRepo.CreateQuestion(ctx.Context(), &result) if err != nil { if e, ok := err.(*pq.Error); ok { @@ -75,7 +76,7 @@ func (s *Service) CreateQuestion(ctx *fiber.Ctx) error { hlogger.Emit(models.InfoQuestionCreate{ CtxUserID: accountID, - CtxIDInt: int64(req.QuizId), + CtxIDInt: int64(req.QuizId), CtxQuestionID: int64(questionID), }) @@ -312,7 +313,7 @@ func (s *Service) DeleteQuestion(ctx *fiber.Ctx) error { hlogger.Emit(models.InfoQuestionDelete{ CtxUserID: accountID, - CtxIDInt: int64(deleted.QuizId), + CtxIDInt: int64(deleted.QuizId), CtxQuestionID: int64(deleted.Id), }) diff --git a/service/quiz_svc.go b/service/quiz_svc.go index c8f3f3b..54610c2 100644 --- a/service/quiz_svc.go +++ b/service/quiz_svc.go @@ -9,6 +9,7 @@ import ( "penahub.gitlab.yandexcloud.net/backend/quiz/core/models" "time" "unicode/utf8" + "fmt" ) type CreateQuizReq struct { @@ -101,7 +102,7 @@ func (s *Service) CreateQuiz(ctx *fiber.Ctx) error { hlogger.Emit(models.InfoQuizCreated{ CtxUserID: accountId, - CtxIDInt: int64(quizID), + CtxIDInt: int64(quizID), }) return ctx.Status(fiber.StatusCreated).JSON(record) @@ -314,13 +315,13 @@ func (s *Service) UpdateQuiz(ctx *fiber.Ctx) error { if req.Status == model.StatusStart { hlogger.Emit(models.InfoQuizPublish{ CtxUserID: accountId, - CtxIDInt: int64(quiz.Id), + CtxIDInt: int64(quiz.Id), }) } if req.Status == model.StatusStop { hlogger.Emit(models.InfoQuizStop{ CtxUserID: accountId, - CtxIDInt: int64(quiz.Id), + CtxIDInt: int64(quiz.Id), }) } @@ -428,7 +429,7 @@ func (s *Service) DeleteQuiz(ctx *fiber.Ctx) error { hlogger.Emit(models.InfoQuizDelete{ CtxUserID: accountId, - CtxIDInt: int64(req.Id), + CtxIDInt: int64(req.Id), }) return ctx.JSON(DeactivateResp{ @@ -505,6 +506,7 @@ func (s *Service) TemplateCopy(ctx *fiber.Ctx) error { qizID, err := s.dal.QuizRepo.TemplateCopy(ctx.Context(), accountID, req.Qid) if err != nil { + fmt.Println("TEMPLERR", err) return ctx.Status(fiber.StatusInternalServerError).SendString(err.Error()) } diff --git a/service/result_svc.go b/service/result_svc.go index 59f5ab8..b06a40c 100644 --- a/service/result_svc.go +++ b/service/result_svc.go @@ -159,6 +159,11 @@ func (s *Service) ExportResultsToCSV(ctx *fiber.Ctx) error { } } + quiz, err := s.dal.QuizRepo.GetQuizById(ctx.Context(), accountID, quizID) + if err != nil { + return ctx.Status(fiber.StatusInternalServerError).SendString("failed to get quiz") + } + questions, err := s.dal.ResultRepo.GetQuestions(ctx.Context(), quizID) if err != nil { return ctx.Status(fiber.StatusInternalServerError).SendString("failed to get questions") @@ -177,7 +182,7 @@ func (s *Service) ExportResultsToCSV(ctx *fiber.Ctx) error { buffer := new(bytes.Buffer) - if err := tools.WriteDataToExcel(buffer, questions, answers); err != nil { + if err := tools.WriteDataToExcel(buffer, questions, answers, s.s3Prefix + quiz.Qid + "/"); err != nil { return ctx.Status(fiber.StatusInternalServerError).SendString("failed to write data to Excel") } diff --git a/service/service.go b/service/service.go index a99ffad..e9fb2f2 100644 --- a/service/service.go +++ b/service/service.go @@ -1,36 +1,47 @@ package service import ( + "github.com/go-redis/redis/v8" "github.com/gofiber/fiber/v2" "penahub.gitlab.yandexcloud.net/backend/quiz/common.git/dal" "penahub.gitlab.yandexcloud.net/backend/quiz/core/brokers" "penahub.gitlab.yandexcloud.net/backend/quiz/core/clients/auth" + "penahub.gitlab.yandexcloud.net/backend/quiz/core/clients/telegram" ) // Service is an entity for http requests handling type Service struct { - dal *dal.DAL - authClient *auth.AuthClient - producer *brokers.Producer - serviceName string - chDAL *dal.ClickHouseDAL + dal *dal.DAL + authClient *auth.AuthClient + producer *brokers.Producer + serviceName string + chDAL *dal.ClickHouseDAL + telegramClient *telegram.TelegramClient + redisClient *redis.Client + s3Prefix string } type Deps struct { - Dal *dal.DAL - AuthClient *auth.AuthClient - Producer *brokers.Producer - ServiceName string - ChDAL *dal.ClickHouseDAL + Dal *dal.DAL + AuthClient *auth.AuthClient + Producer *brokers.Producer + ServiceName string + ChDAL *dal.ClickHouseDAL + TelegramClient *telegram.TelegramClient + RedisClient *redis.Client + S3Prefix string } func New(deps Deps) *Service { return &Service{ - dal: deps.Dal, - authClient: deps.AuthClient, - producer: deps.Producer, - serviceName: deps.ServiceName, - chDAL: deps.ChDAL, + dal: deps.Dal, + authClient: deps.AuthClient, + producer: deps.Producer, + serviceName: deps.ServiceName, + chDAL: deps.ChDAL, + telegramClient: deps.TelegramClient, + redisClient: deps.RedisClient, + s3Prefix: deps.S3Prefix, } } @@ -63,6 +74,10 @@ func (s *Service) Register(app *fiber.App) { app.Get("/privilege/:userId", s.getPrivilegeByUserID) app.Delete("/account/:userId", s.deleteAccountByUserID) app.Post("/account/manualdone", s.ManualDone) + app.Post("/account/leadtarget", s.PostLeadTarget) + app.Delete("/account/leadtarget/:id", s.DeleteLeadTarget) + app.Get("/account/leadtarget/:quizID", s.GetLeadTarget) + app.Put("/account/leadtarget", s.UpdateLeadTarget) // result handlers app.Post("/results/getResults/:quizId", s.GetResultsByQuizID) @@ -77,4 +92,10 @@ func (s *Service) Register(app *fiber.App) { app.Post("/statistic/:quizID/questions", s.GetQuestionsStatistics) app.Post("/statistic", s.AllServiceStatistics) app.Get("/statistics/:quizID/pipelines", s.GetPipelinesStatistics) + + //telegram handlers + app.Get("/telegram/pool", s.GetPoolTgAccounts) + app.Post("/telegram/create", s.AddingTgAccount) + app.Delete("/telegram/:id", s.DeleteTgAccountByID) + app.Post("/telegram/setCode", s.SettingTgCode) } diff --git a/service/telegram_svc.go b/service/telegram_svc.go new file mode 100644 index 0000000..f2862fe --- /dev/null +++ b/service/telegram_svc.go @@ -0,0 +1,172 @@ +package service + +import ( + "errors" + "fmt" + "github.com/gofiber/fiber/v2" + "github.com/rs/xid" + "path/filepath" + "penahub.gitlab.yandexcloud.net/backend/quiz/common.git/model" + "penahub.gitlab.yandexcloud.net/backend/quiz/common.git/pj_errors" + "penahub.gitlab.yandexcloud.net/backend/quiz/core/clients/telegram" + "penahub.gitlab.yandexcloud.net/backend/tdlib/client" + "strconv" +) + +type Message struct { + Type string `json:"type"` + Data string `json:"data"` +} + +func (s *Service) GetPoolTgAccounts(ctx *fiber.Ctx) error { + allAccounts, err := s.dal.TgRepo.GetAllTgAccounts(ctx.Context()) + if err != nil { + switch { + case errors.Is(err, pj_errors.ErrNotFound): + return ctx.Status(fiber.StatusNotFound).SendString("not found") + default: + return ctx.Status(fiber.StatusInternalServerError).SendString(err.Error()) + } + } + return ctx.Status(fiber.StatusOK).JSON(allAccounts) +} + +func (s *Service) AddingTgAccount(ctx *fiber.Ctx) error { + var req telegram.AuthTgUserReq + if err := ctx.BodyParser(&req); err != nil { + return ctx.Status(fiber.StatusBadRequest).SendString("Invalid request data") + } + if req.ApiID == 0 || req.ApiHash == "" || req.Password == "" || req.PhoneNumber == "" { + return ctx.Status(fiber.StatusBadRequest).SendString("empty required fields") + } + allAccounts, err := s.dal.TgRepo.GetAllTgAccounts(ctx.Context()) + if err != nil && !errors.Is(err, pj_errors.ErrNotFound) { + return ctx.Status(fiber.StatusInternalServerError).SendString(err.Error()) + } + if !errors.Is(err, pj_errors.ErrNotFound) { + for _, account := range allAccounts { + if account.ApiID == req.ApiID && account.ApiHash == req.ApiHash && account.Status == model.ActiveTg { + return ctx.Status(fiber.StatusConflict).SendString("this account already exist and active") + } + } + } + authorizer := client.ClientAuthorizerr() + authorizer.TdlibParameters <- &client.SetTdlibParametersRequest{ + UseTestDc: false, + DatabaseDirectory: filepath.Join(".tdlib", "database"), + FilesDirectory: filepath.Join(".tdlib", "files"), + UseFileDatabase: true, + UseChatInfoDatabase: true, + UseMessageDatabase: true, + UseSecretChats: true, + ApiId: req.ApiID, + ApiHash: req.ApiHash, + SystemLanguageCode: "en", + DeviceModel: "Server", + SystemVersion: "1.0.0", + ApplicationVersion: "1.0.0", + } + + _, err = client.SetLogVerbosityLevel(&client.SetLogVerbosityLevelRequest{ + NewVerbosityLevel: 1, + }) + if err != nil { + return ctx.Status(fiber.StatusInternalServerError).SendString(err.Error()) + } + + var tdlibClient *client.Client + // завершается уже в другом контроллере + var goErr error + // todo ужно продумать завершение горутины если код вставлять не пошли + go func() { + tdlibClient, goErr = client.NewClient(authorizer) + if goErr != nil { + fmt.Println("new client failed", err) + return + } + s.telegramClient.SaveTgAccount(req.ApiID, req.ApiHash, tdlibClient) + fmt.Println("i am down") + }() + if goErr != nil { + return ctx.Status(fiber.StatusInternalServerError).SendString(goErr.Error()) + } + + for { + state, ok := <-authorizer.State + if !ok { + return ctx.Status(fiber.StatusOK).SendString("state chan is close auth maybe ok") + } + fmt.Println("currnet state:", state) + switch state.AuthorizationStateType() { + case client.TypeAuthorizationStateWaitPhoneNumber: + authorizer.PhoneNumber <- req.PhoneNumber + case client.TypeAuthorizationStateWaitCode: + signature := xid.New() + s.telegramClient.AddedToMap(telegram.WaitingClient{ + PreviousReq: req, + Authorizer: authorizer, + }, signature.String()) + return ctx.Status(fiber.StatusOK).JSON(fiber.Map{"signature": signature.String()}) + + case client.TypeAuthorizationStateLoggingOut, client.TypeAuthorizationStateClosing, client.TypeAuthorizationStateClosed: + return ctx.Status(fiber.StatusForbidden).SendString(fmt.Sprintf("auth failed, last state is %s", state)) + } + } +} + +func (s *Service) SettingTgCode(ctx *fiber.Ctx) error { + var req struct { + Code string `json:"code"` + Signature string `json:"signature"` + } + if err := ctx.BodyParser(&req); err != nil { + return ctx.Status(fiber.StatusBadRequest).SendString("Invalid request data") + } + + if req.Code == "" || req.Signature == "" { + return ctx.Status(fiber.StatusBadRequest).SendString("empty required fields") + } + + data, ok := s.telegramClient.GetFromMap(req.Signature) + if !ok { + return ctx.Status(fiber.StatusBadRequest).SendString("Invalid id, don't have data") + } + data.Authorizer.Code <- req.Code + for { + state, ok := <-data.Authorizer.State + if !ok { + return ctx.Status(fiber.StatusNoContent).SendString("state chan is close auth maybe ok") + } + fmt.Println("currnet state:", state) + switch state.AuthorizationStateType() { + case client.TypeAuthorizationStateReady: + id, err := s.dal.TgRepo.CreateTgAccount(ctx.Context(), model.TgAccount{ + ApiID: data.PreviousReq.ApiID, + ApiHash: data.PreviousReq.ApiHash, + PhoneNumber: data.PreviousReq.PhoneNumber, + Status: model.ActiveTg, + Password: data.PreviousReq.Password, + }) + if err != nil { + return ctx.Status(fiber.StatusInternalServerError).SendString(err.Error()) + } + return ctx.Status(fiber.StatusOK).JSON(fiber.Map{"id": id}) + case client.TypeAuthorizationStateWaitPassword: + data.Authorizer.Password <- data.PreviousReq.Password + case client.TypeAuthorizationStateLoggingOut, client.TypeAuthorizationStateClosing, client.TypeAuthorizationStateClosed: + return ctx.Status(fiber.StatusForbidden).SendString(fmt.Sprintf("auth failed, last state is %s", state)) + } + } +} + +func (s *Service) DeleteTgAccountByID(ctx *fiber.Ctx) error { + id, err := strconv.ParseInt(ctx.Params("id"), 10, 64) + if err != nil { + return ctx.Status(fiber.StatusBadRequest).SendString("invalid id format") + } + err = s.dal.TgRepo.SoftDeleteTgAccount(ctx.Context(), id) + if err != nil { + return ctx.Status(fiber.StatusInternalServerError).SendString(err.Error()) + } + return ctx.SendStatus(fiber.StatusOK) +} diff --git a/tools/tools.go b/tools/tools.go index e44171f..c23a27c 100644 --- a/tools/tools.go +++ b/tools/tools.go @@ -1,6 +1,7 @@ package tools import ( + "encoding/json" "fmt" "github.com/xuri/excelize/v2" _ "image/gif" @@ -27,7 +28,7 @@ const ( bucketAnswers = "squizanswer" ) -func WriteDataToExcel(buffer io.Writer, questions []model.Question, answers []model.Answer) error { +func WriteDataToExcel(buffer io.Writer, questions []model.Question, answers []model.Answer, s3Prefix string) error { file := excelize.NewFile() sheet := "Sheet1" @@ -40,6 +41,35 @@ func WriteDataToExcel(buffer io.Writer, questions []model.Question, answers []mo return questions[i].Page < questions[j].Page }) + headers, mapQueRes := prepareHeaders(questions) + for col, header := range headers { + cell := ToAlphaString(col+1) + "1" + if err := file.SetCellValue(sheet, cell, header); err != nil { + return err + } + } + + sort.Slice(answers, func(i, j int) bool { + return answers[i].QuestionId < answers[j].QuestionId + }) + standart, results := categorizeAnswers(answers) + + var wg sync.WaitGroup + row := 2 + for session := range results { + wg.Add(1) + go func(session string, response []model.Answer, row int) { + defer wg.Done() + processSession(file, sheet, session, s3Prefix, response, results, questions, mapQueRes, headers, row) + }(session, standart[session], row) + row++ + } + wg.Wait() + + return file.Write(buffer) +} + +func prepareHeaders(questions []model.Question) ([]string, map[uint64]string) { headers := []string{"Данные респондента"} mapQueRes := make(map[uint64]string) @@ -52,27 +82,14 @@ func WriteDataToExcel(buffer io.Writer, questions []model.Question, answers []mo } } } - headers = append(headers, "Результат") + return headers, mapQueRes +} - for col, header := range headers { - cell := ToAlphaString(col+1) + "1" - if err := file.SetCellValue(sheet, cell, header); err != nil { - return err - } - } - - sort.Slice(answers, func(i, j int) bool { - return answers[i].QuestionId < answers[j].QuestionId - }) - - // мапа для хранения обычных ответов респондентов +func categorizeAnswers(answers []model.Answer) (map[string][]model.Answer, map[string]model.Answer) { standart := make(map[string][]model.Answer) - - // мапа для хранения данных респондентов results := make(map[string]model.Answer) - // заполняем мапу ответами и данными респондентов for _, answer := range answers { if answer.Result { results[answer.Session] = answer @@ -80,17 +97,96 @@ func WriteDataToExcel(buffer io.Writer, questions []model.Question, answers []mo standart[answer.Session] = append(standart[answer.Session], answer) } } + return standart, results +} - processSession := func(session string, response []model.Answer, row int) { - defer func() { - if r := recover(); r != nil { - fmt.Println("Recovered from panic:", r) +func processSession(file *excelize.File, sheet, session, s3Prefix string, response []model.Answer, results map[string]model.Answer, questions []model.Question, mapQueRes map[uint64]string, headers []string, row int) { + defer func() { + if r := recover(); r != nil { + fmt.Println("Recovered from panic:", r) + } + }() + + if err := file.SetCellValue(sheet, "A"+strconv.Itoa(row), results[session].Content); err != nil { + fmt.Println(err.Error()) + } + count := 2 + for _, q := range questions { + if !q.Deleted && q.Type != model.TypeResult { + cell := ToAlphaString(count) + strconv.Itoa(row) + index := binarySearch(response, q.Id) + if index != -1 { + handleAnswer(file, sheet, cell, s3Prefix, response[index], q, count, row) + } else { + if err := file.SetCellValue(sheet, cell, "-"); err != nil { + fmt.Println(err.Error()) + } } - }() + count++ + } + } + cell := ToAlphaString(len(headers)) + strconv.Itoa(row) + if err := file.SetCellValue(sheet, cell, mapQueRes[results[session].QuestionId]); err != nil { + fmt.Println(err.Error()) + } +} - if err := file.SetCellValue(sheet, "A"+strconv.Itoa(row), results[session].Content); err != nil { +func handleAnswer(file *excelize.File, sheet, cell, s3Prefix string, answer model.Answer, question model.Question, count, row int) { + tipe := FileSearch(answer.Content) + noAccept := make(map[string]struct{}) + todoMap := make(map[string]string) + + if tipe != "Text" && (question.Type == model.TypeImages || question.Type == model.TypeVarImages) { + handleImage(file, sheet, cell, answer.Content, count, row, noAccept, todoMap, question.Title) + } else if question.Type == model.TypeFile { + handleFile(file, sheet, cell, answer.Content, s3Prefix, noAccept) + } else { + todoMap[answer.Content] = cell + } + + for cnt, cel := range todoMap { + if _, ok := noAccept[cnt]; !ok { + cntArr := strings.Split(cnt, "`,`") + resultCnt := cnt + if len(cntArr) > 1 { + resultCnt = strings.Join(cntArr, "\n") + } + + if len(resultCnt) > 1 && resultCnt[0] == '`' && resultCnt[len(resultCnt)-1] == '`' { + resultCnt = resultCnt[1 : len(resultCnt)-1] + } + + if len(resultCnt) > 1 && resultCnt[0] == '`' { + resultCnt = resultCnt[1:] + } + + if len(resultCnt) > 1 && resultCnt[len(resultCnt)-1] == '`' { + resultCnt = resultCnt[:len(resultCnt)-1] + } + + if err := file.SetCellValue(sheet, cel, resultCnt); err != nil { + fmt.Println(err.Error()) + } + } + } +} + +func handleImage(file *excelize.File, sheet, cell, content string, count, row int, noAccept map[string]struct{}, todoMap map[string]string, questionTitle string) { + multiImgArr := strings.Split(content, "`,`") + if len(multiImgArr) > 1 { + var descriptions []string + mediaSheet := "Media" + flag, err := file.GetSheetIndex(mediaSheet) + if err != nil { fmt.Println(err.Error()) } + if flag == -1 { + _, _ = file.NewSheet(mediaSheet) + err = file.SetCellValue(mediaSheet, "A1", "Вопрос") + if err != nil { + fmt.Println(err.Error()) + } + } count := 2 for _, q := range questions { if !q.Deleted && q.Type != model.TypeResult { @@ -152,38 +248,134 @@ func WriteDataToExcel(buffer io.Writer, questions []model.Question, answers []mo } } - } else { - cell := ToAlphaString(count) + strconv.Itoa(row) - if err := file.SetCellValue(sheet, cell, "-"); err != nil { + mediaRow := row + for i, imgContent := range multiImgArr { + if i == 0 && len(imgContent) > 1 && imgContent[0] == '`' { + imgContent = imgContent[1:] + } + + if i == len(multiImgArr)-1 && len(imgContent) > 1 && imgContent[len(imgContent)-1] == '`' { + imgContent = imgContent[:len(imgContent)-1] + } + + var res model.ImageContent + err := json.Unmarshal([]byte(imgContent), &res) + if err != nil { + res.Image = imgContent + } + + // чек на пустой дескрипшен, есмли пустой то отмечаем как вариант ответа номер по i + if res.Description != "" { + descriptions = append(descriptions, res.Description) + } else { + descriptions = append(descriptions, fmt.Sprintf("Вариант ответа №%d", i+1)) + } + + urle := ExtractImageURL(res.Image) + urlData := strings.Split(urle, " ") + if len(urlData) == 1 { + u, err := url.Parse(urle) + if err == nil && u.Scheme != "" && u.Host != "" { + picture, err := downloadImage(urle) + if err != nil { + fmt.Println(err.Error()) + continue + } + err = file.SetCellValue(mediaSheet, "A"+strconv.Itoa(mediaRow), questionTitle) + if err != nil { fmt.Println(err.Error()) } + + col := ToAlphaString(i + 2) + err = file.SetColWidth(mediaSheet, col, col, 50) + if err != nil { + fmt.Println(err.Error()) + } + err = file.SetRowHeight(mediaSheet, mediaRow, 150) + if err != nil { + fmt.Println(err.Error()) + } + if err := file.AddPictureFromBytes(mediaSheet, col+strconv.Itoa(mediaRow), picture); err != nil { + fmt.Println(err.Error()) + } + noAccept[content] = struct{}{} + } else { + todoMap[content] = cell } - count++ + } else { + todoMap[imgContent] = cell } + + descriptionsStr := strings.Join(descriptions, "\n") + linkText := fmt.Sprintf("%s\n Перейти в приложение %s!A%d", descriptionsStr, mediaSheet, mediaRow) + + if err := file.SetCellValue(sheet, cell, linkText); err != nil { + fmt.Println(err.Error()) + } + //if err := file.SetCellHyperLink(sheet, cell, fmt.Sprintf("%s!A%d", mediaSheet, mediaRow), "Location", excelize.HyperlinkOpts{ + // Display: &linkText, + //}); err != nil { + // fmt.Println(err.Error()) + //} } - cell := ToAlphaString(len(headers)) + strconv.Itoa(row) - if err := file.SetCellValue(sheet, cell, mapQueRes[results[session].QuestionId]); err != nil { - fmt.Println(err.Error()) + } else { + if len(content) > 1 && content[0] == '`' && content[len(content)-1] == '`' { + content = content[1 : len(content)-1] + } + var res model.ImageContent + err := json.Unmarshal([]byte(content), &res) + if err != nil { + res.Image = content + } + urle := ExtractImageURL(res.Image) + urlData := strings.Split(urle, " ") + if len(urlData) == 1 { + u, err := url.Parse(urle) + if err == nil && u.Scheme != "" && u.Host != "" { + picture, err := downloadImage(urle) + if err != nil { + fmt.Println(err.Error()) + } + err = file.SetColWidth(sheet, ToAlphaString(count), ToAlphaString(count), 50) + if err != nil { + fmt.Println(err.Error()) + } + err = file.SetRowHeight(sheet, row, 150) + if err != nil { + fmt.Println(err.Error()) + } + if err := file.AddPictureFromBytes(sheet, cell, picture); err != nil { + fmt.Println(err.Error()) + } + noAccept[content] = struct{}{} + } else { + todoMap[content] = cell + } + } else { + todoMap[content] = cell } } +} - row := 2 - var wg sync.WaitGroup - for session, _ := range results { - wg.Add(1) - go func(session string, response []model.Answer, row int) { - defer wg.Done() - processSession(session, standart[session], row) - }(session, standart[session], row) - row++ - } - wg.Wait() - - if err := file.Write(buffer); err != nil { - return err +func handleFile(file *excelize.File, sheet, cell, content, s3Prefix string, noAccept map[string]struct{}) { + urle := content + if urle != "" && !strings.HasPrefix(urle, "https") { + urle = s3Prefix + urle } - return nil + fmt.Println("ORRRRR", urle, s3Prefix) + display, tooltip := urle, urle + + if err := file.SetCellValue(sheet, cell, urle); err != nil { + fmt.Println(err.Error()) + } + if err := file.SetCellHyperLink(sheet, cell, urle, "External", excelize.HyperlinkOpts{ + Display: &display, + Tooltip: &tooltip, + }); err != nil { + fmt.Println(err.Error()) + } + noAccept[content] = struct{}{} } func binarySearch(answers []model.Answer, questionID uint64) int { @@ -294,141 +486,3 @@ func ExtractImageURL(htmlContent string) string { } return htmlContent } - -//func WriteDataToExcel(buffer io.Writer, questions []model.Question, answers []model.Answer) error { -// file := excelize.NewFile() -// sheet := "Sheet1" -// -// _, err := file.NewSheet(sheet) -// if err != nil { -// return err -// } -// -// sort.Slice(questions, func(i, j int) bool { -// return questions[i].Page > questions[j].Page -// }) -// -// headers := []string{"Данные респондента"} -// mapQueRes := make(map[uint64]string) -// -// for _, q := range questions { -// if !q.Deleted { -// if q.Type == model.TypeResult { -// mapQueRes[q.Id] = q.Title + "\n" + q.Description -// } else { -// headers = append(headers, q.Title) -// } -// } -// } -// -// headers = append(headers, "Результат") -// -// // добавляем заголовки в первую строку -// for col, header := range headers { -// cell := ToAlphaString(col+1) + "1" -// if err := file.SetCellValue(sheet, cell, header); err != nil { -// return err -// } -// } -// -// // мапа для хранения обычных ответов респондентов -// standart := make(map[string][]model.Answer) -// -// // мапа для хранения данных респондентов -// results := make(map[string]model.Answer) -// -// // заполняем мапу ответами и данными респондентов -// for _, answer := range answers { -// if answer.Result { -// results[answer.Session] = answer -// } else { -// standart[answer.Session] = append(standart[answer.Session], answer) -// } -// } -// -// // записываем данные в файл -// row := 2 -// for session, _ := range results { -// response := standart[session] -// if err := file.SetCellValue(sheet, "A"+strconv.Itoa(row), results[session].Content); err != nil { -// return err -// } -// count := 2 -// for _, q := range questions { -// if !q.Deleted && q.Type != model.TypeResult { -// sort.Slice(response, func(i, j int) bool { -// return response[i].QuestionId < response[j].QuestionId -// }) -// index := binarySearch(response, q.Id) -// if index != -1 { -// cell := ToAlphaString(count) + strconv.Itoa(row) -// typeMap := FileSearch(response[index].Content) -// noAccept := make(map[string]struct{}) -// todoMap := make(map[string]string) -// for _, tipe := range typeMap { -// if tipe != "Text" && q.Type == model.TypeImages || q.Type == model.TypeVarImages { -// urle := ExtractImageURL(response[index].Content) -// urlData := strings.Split(urle, " ") -// for _, k := range urlData { -// u, err := url.Parse(k) -// if err == nil && u.Scheme != "" && u.Host != "" { -// picture, err := downloadImage(k) -// if err != nil { -// return err -// } -// file.SetColWidth(sheet, ToAlphaString(count), ToAlphaString(count), 50) -// file.SetRowHeight(sheet, row, 150) -// if err := file.AddPictureFromBytes(sheet, cell, picture); err != nil { -// return err -// } -// noAccept[response[index].Content] = struct{}{} -// } -// } -// } else if tipe != "Text" && q.Type == model.TypeFile { -// urle := ExtractImageURL(response[index].Content) -// display, tooltip := urle, urle -// if err := file.SetCellValue(sheet, cell, response[index].Content); err != nil { -// return err -// } -// if err := file.SetCellHyperLink(sheet, cell, urle, "External", excelize.HyperlinkOpts{ -// Display: &display, -// Tooltip: &tooltip, -// }); err != nil { -// return err -// } -// noAccept[response[index].Content] = struct{}{} -// } else { -// todoMap[response[index].Content] = cell -// } -// } -// for cnt, cel := range todoMap { -// if _, ok := noAccept[cnt]; !ok { -// if err := file.SetCellValue(sheet, cel, cnt); err != nil { -// return err -// } -// } -// } -// -// } else { -// cell := ToAlphaString(count) + strconv.Itoa(row) -// if err := file.SetCellValue(sheet, cell, "-"); err != nil { -// return err -// } -// } -// count++ -// } -// } -// cell := ToAlphaString(len(headers)) + strconv.Itoa(row) -// if err := file.SetCellValue(sheet, cell, mapQueRes[results[session].QuestionId]); err != nil { -// return err -// } -// row++ -// } -// -// // cохраняем данные в буфер -// if err := file.Write(buffer); err != nil { -// return err -// } -// -// return nil -//} diff --git a/workers/tg_worker.go b/workers/tg_worker.go new file mode 100644 index 0000000..80deb41 --- /dev/null +++ b/workers/tg_worker.go @@ -0,0 +1,112 @@ +package workers + +import ( + "context" + "encoding/json" + "fmt" + "github.com/go-redis/redis/v8" + "penahub.gitlab.yandexcloud.net/backend/quiz/common.git/dal" + "penahub.gitlab.yandexcloud.net/backend/quiz/common.git/model" + "penahub.gitlab.yandexcloud.net/backend/quiz/core/clients/telegram" + "strconv" + "time" +) + +type Deps struct { + BotID int64 + Redis *redis.Client + Dal *dal.DAL + TgClient *telegram.TelegramClient +} + +type TgListenerWorker struct { + botID int64 + redis *redis.Client + dal *dal.DAL + tgClient *telegram.TelegramClient +} + +func NewTgListenerWC(deps Deps) *TgListenerWorker { + return &TgListenerWorker{ + botID: deps.BotID, + redis: deps.Redis, + dal: deps.Dal, + tgClient: deps.TgClient, + } +} + +func (wc *TgListenerWorker) Start(ctx context.Context) { + ticker := time.NewTicker(10 * time.Second) //time.Minute + defer ticker.Stop() + + for { + select { + case <-ticker.C: + wc.processTasks(ctx) + case <-ctx.Done(): + return + } + } +} + +func (wc *TgListenerWorker) processTasks(ctx context.Context) { + var cursor uint64 + for { + var keys []string + var err error + keys, cursor, err = wc.redis.Scan(ctx, cursor, "telegram_task:*", 0).Result() + if err != nil { + fmt.Println("Failed scan for telegram tasks:", err) + break + } + + for _, key := range keys { + func() { + taskBytes, err := wc.redis.GetDel(ctx, key).Result() + if err == redis.Nil { + return + } else if err != nil { + fmt.Println("Failed getdel telegram task:", err) + return + } + // todo logging into tg with trashlog + var aimErr error + defer func() { + if r := recover(); r != nil || aimErr != nil { + fmt.Println("recovering from panic or error setting redis value:", r, aimErr) + _ = wc.redis.Set(ctx, key, taskBytes, 0).Err() + } + }() + + var task model.TgRedisTask + if err = json.Unmarshal([]byte(taskBytes), &task); err != nil { + fmt.Println("Failed unmarshal telegram task:", err) + return + } + + var inviteLink string + var chatID int64 + inviteLink, chatID, aimErr = wc.tgClient.CreateChannel(task.Name, wc.botID) + if aimErr != nil { + fmt.Println("Failed create tg channel:", aimErr) + return + } + + _, aimErr = wc.dal.AccountRepo.PostLeadTarget(ctx, model.LeadTarget{ + AccountID: task.AccountID, + Type: model.LeadTargetTg, + QuizID: task.QuizID, + Target: strconv.Itoa(int(chatID)), + InviteLink: inviteLink, + }) + if aimErr != nil { + fmt.Println("Failed create lead target in db:", aimErr) + return + } + }() + } + if cursor == 0 { + break + } + } +}