diff --git a/.env.dev b/.env.dev index 1075ede..81ca400 100644 --- a/.env.dev +++ b/.env.dev @@ -8,6 +8,9 @@ MONO_TOKENS= TELEGRAM_ADMINS= TELEGRAM_CHATS= +# More info https://github.com/rs/zerolog#leveled-logging +LOG_LEVEL=info + # Project vars PREFIX_IMAGE=monopersonalbot_img_ PREFIX_CONTAINER=monopersonalbot_ diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..186b14e --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,52 @@ +on: + push: + # Sequence of patterns matched against refs/tags + tags: + - 'v*' # Push events to matching v*, i.e. v1.0, v20.15.10 + +name: Create Release + +env: + RELNAME: mono_personal_tgbot + GOARCH: amd64 + +jobs: + build: + name: Create Release + runs-on: ubuntu-latest + steps: + - uses: actions/setup-go@v3 + with: + go-version: 1.18.x + + - uses: actions/checkout@v3 + + - uses: actions/cache@v3 + with: + path: | + ~/go/pkg/mod + ~/.cache/go-build + key: ${{ runner.os }}-go-${{ hashFiles('**/go.sum') }} + restore-keys: | + ${{ runner.OS }}-go-${{ env.cache-name }}- + ${{ runner.OS }}-go- + ${{ runner.OS }}- + + - name: Build artifacts + run: | + CGO_ENABLED=0 GOOS=linux GOARCH=${GOARCH} go build -ldflags "-s -w" -o ./release/${RELNAME}-linux-${GOARCH} *.go + CGO_ENABLED=0 GOOS=darwin GOARCH=${GOARCH} go build -ldflags "-s -w" -o ./release/${RELNAME}-darwin-${GOARCH} *.go + CGO_ENABLED=0 GOOS=windows GOARCH=${GOARCH} go build -ldflags "-s -w" -o ./release/${RELNAME}-windows-${GOARCH}.exe *.go + zip --junk-paths ./release/${RELNAME}-linux-${GOARCH}.zip ./release/${RELNAME}-linux-${GOARCH} + zip --junk-paths ./release/${RELNAME}-darwin-${GOARCH}.zip ./release/${RELNAME}-darwin-${GOARCH} + zip --junk-paths ./release/${RELNAME}-windows-${GOARCH}.zip ./release/${RELNAME}-windows-${GOARCH}.exe + + - name: Release + uses: docker://antonyurchenko/git-release:latest + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + ALLOW_EMPTY_CHANGELOG: true + DRAFT_RELEASE: true + CHANGELOG_FILE: none + with: + args: release/*.zip diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..663173a --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,34 @@ +on: [push, pull_request] + +name: Test + +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/setup-go@v3 + with: + go-version: 1.18.x + - uses: actions/checkout@v3 + + - uses: actions/cache@v3 + with: + path: | + ~/go/pkg/mod + ~/.cache/go-build + key: ${{ runner.os }}-go-${{ hashFiles('**/go.sum') }} + restore-keys: | + ${{ runner.OS }}-go-${{ env.cache-name }}- + ${{ runner.OS }}-go- + ${{ runner.OS }}- + + - name: Verify dependencies + run: go mod verify + + - name: Run Gosec + uses: securego/gosec@master + with: + args: '-exclude=G104 ./...' + + - name: Run tests + run: go test -race -vet=off ./... diff --git a/Dockerfile b/Dockerfile index f54dedc..faba789 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,5 +1,5 @@ # builder -FROM golang:alpine as builder +FROM golang:1.18-alpine as builder WORKDIR / diff --git a/README.md b/README.md index cf90014..6e65b5c 100644 --- a/README.md +++ b/README.md @@ -1,18 +1,14 @@ # Monobank personal telegram bot [![GitHub release](https://img.shields.io/github/release/vkopitsa/mono_personal_tgbot.svg)]() +[![Test](https://github.com/vkopitsa/mono_personal_tgbot/actions/workflows/test.yml/badge.svg)](https://github.com/vkopitsa/mono_personal_tgbot/actions/workflows/test.yml) ![Download](https://img.shields.io/github/downloads/vkopitsa/mono_personal_tgbot/total.svg) [![license](https://img.shields.io/github/license/vkopitsa/mono_personal_tgbot.svg)]() A simple telegram bot, written in Go with the [telegram-bot-api](https://github.com/go-telegram-bot-api/telegram-bot-api 'telegram-bot-api') library. -### Report -![mono_personal_tgbot](Resources/screenshot0.png) +![mono_personal_tgbot](Resources/screencast.gif) -### WebHook -![mono_personal_tgbot](Resources/screenshot1.png) - -### Balance -![mono_personal_tgbot](Resources/screenshot2.png) +![mono_personal_tgbot](Resources/screenshot.png) ## Usage @@ -23,14 +19,14 @@ Run `mono_personal_tgbot` execution file in your terminal with following env var `TELEGRAM_TOKEN` | [How to get telegram bot token](https://core.telegram.org/bots#3-how-do-i-create-a-bot) `TELEGRAM_ADMINS` | ids of the trusted user, example: `1234567,1234567` `TELEGRAM_CHATS` | ids of the trusted chats, example: `-1234567,-1234567` -`MONO_TOKENS` | list of the tokens and it is `number`. [How to get monobank token](https://api.monobank.ua/) +`MONO_TOKENS` | [How to get monobank token](https://api.monobank.ua/) ### Telegram commands Command | Description ------------------------ | ----------------------------------------------------------- -`/balance[_n]` | Get `UAH` balance of the default client or first one or by number. example: `/balance`, `/balance_1` -`/report` | Get a report for the period of the default client or first one or by number. example: `/report`, `/report_1` +`/balance` | Get a balance of the clients. +`/report` | Get a report for the period of the clients. `/get_webhook[_n]` | Get a status about setup webhook of the default client or first one or by number. example: `/get_webhook`, `/get_webhook_1` `/set_webhook[_n]` | Set webhook url to monobank api of the default client or first one or by number. example: `/set_webhook`, `/set_webhook_1` @@ -42,11 +38,7 @@ Rename `.env.dev` file to `.env` and edit. # docker-compose up -d ## Download -[v0.4 release, Linux](https://github.com/vkopitsa/mono_personal_tgbot/releases/download/v0.4/mono_personal_tgbot-linux-amd64) - -[v0.4 release, MacOS](https://github.com/vkopitsa/mono_personal_tgbot/releases/download/v0.4/mono_personal_tgbot-darwin-amd64) - -[v0.4 release, Windows](https://github.com/vkopitsa/mono_personal_tgbot/releases/download/v0.4/mono_personal_tgbot-windows-amd64.exe) +[Windows, Linux, MacOS](https://github.com/vkopitsa/mono_personal_tgbot/releases) ## Compatibility The bot is only available for Windows, Linux, MacOS. diff --git a/Resources/screencast.gif b/Resources/screencast.gif new file mode 100644 index 0000000..c84fffd Binary files /dev/null and b/Resources/screencast.gif differ diff --git a/Resources/screenshot.png b/Resources/screenshot.png new file mode 100644 index 0000000..ddb6e1b Binary files /dev/null and b/Resources/screenshot.png differ diff --git a/Resources/screenshot0.png b/Resources/screenshot0.png deleted file mode 100644 index 892e6df..0000000 Binary files a/Resources/screenshot0.png and /dev/null differ diff --git a/Resources/screenshot1.png b/Resources/screenshot1.png deleted file mode 100644 index 72f5042..0000000 Binary files a/Resources/screenshot1.png and /dev/null differ diff --git a/Resources/screenshot2.png b/Resources/screenshot2.png deleted file mode 100644 index 54bedfd..0000000 Binary files a/Resources/screenshot2.png and /dev/null differ diff --git a/app.go b/app.go index 3ab36ac..1a542cf 100644 --- a/app.go +++ b/app.go @@ -2,23 +2,37 @@ package main import ( "os" + + "github.com/rs/zerolog" + "github.com/rs/zerolog/log" + "github.com/rs/zerolog/pkgerrors" ) func main() { bot := New( - os.Getenv("TELEGRAM_TOKEN"), os.Getenv("TELEGRAM_ADMINS"), os.Getenv("TELEGRAM_CHATS"), - os.Getenv("MONO_TOKENS"), ) + // default level is info, unless debug flag is present + zerolog.SetGlobalLevel(zerolog.InfoLevel) + if envLogLevel, ok := os.LookupEnv("LOG_LEVEL"); ok { + zerologLevel, err := zerolog.ParseLevel(envLogLevel) + if err == nil { + zerolog.SetGlobalLevel(zerologLevel) + } + } + + zerolog.TimeFieldFormat = zerolog.TimeFormatUnix + zerolog.ErrorStackMarshaler = pkgerrors.MarshalStack + // init clients - err := bot.InitMonoClients() + err := bot.InitMonoClients(os.Getenv("MONO_TOKENS")) if err != nil { - panic(err) + log.Panic().Err(err) } - go bot.TelegramStart() + go bot.TelegramStart(os.Getenv("TELEGRAM_TOKEN")) go bot.ProcessingStart() // run http server diff --git a/bot.go b/bot.go index 1db17c9..6836e6d 100644 --- a/bot.go +++ b/bot.go @@ -7,12 +7,13 @@ import ( "fmt" "html/template" "io/ioutil" - "log" "net/http" "reflect" "strconv" "strings" + "github.com/rs/zerolog/log" + tgbotapi "github.com/go-telegram-bot-api/telegram-bot-api" ) @@ -27,15 +28,14 @@ type StatementItemData struct { // Bot is the interface representing bot object. type Bot interface { - InitMonoClients() error - TelegramStart() + InitMonoClients(monoTokens string) error + TelegramStart(token string) WebhookStart() ProcessingStart() } // bot is implementation the Bot interface type bot struct { - telegramToken string telegramAdmins string telegramChats string clients []Client @@ -50,36 +50,26 @@ type bot struct { } // New returns a bot object. -func New(telegramToken, telegramAdmins, telegramChats, monoTokens string) Bot { +func New(telegramAdmins, telegramChats string) Bot { statementTmpl, err := GetTempate(statementTemplate) if err != nil { - log.Fatalf("[template] %s", err) + log.Fatal().Err(err).Msg("[template]") } balanceTmpl, err := GetTempate(balanceTemplate) if err != nil { - log.Fatalf("[template] %s", err) + log.Fatal().Err(err).Msg("[template]") } webhookTmpl, err := GetTempate(webhookTemplate) if err != nil { - log.Fatalf("[template] %s", err) - } - - monoTokensArr := strings.Split(monoTokens, ",") - - // init clients - clients := make([]Client, 0, len(monoTokensArr)) - for _, monoToken := range monoTokensArr { - clients = append(clients, NewClient(monoToken)) + log.Fatal().Err(err).Msg("[template]") } b := bot{ - telegramToken: telegramToken, telegramAdmins: telegramAdmins, telegramChats: telegramChats, - clients: clients, ch: make(chan StatementItemData, 100), @@ -92,34 +82,51 @@ func New(telegramToken, telegramAdmins, telegramChats, monoTokens string) Bot { } // InitMonoClients gets needed client data for correct working of the bot -func (b *bot) InitMonoClients() error { - for _, client := range b.clients { +func (b *bot) InitMonoClients(monoTokens string) error { + + monoTokensArr := strings.Split(monoTokens, ",") + + // init clients + clients := make([]Client, 0, len(monoTokensArr)) + for _, monoToken := range monoTokensArr { + + client := NewClient(monoToken) if err := client.Init(); err != nil { return err } + + clients = append(clients, client) } + + b.clients = clients + return nil } // TelegramStart starts getting updates from telegram. -func (b *bot) TelegramStart() { - botAPI, err := tgbotapi.NewBotAPI(b.telegramToken) +func (b *bot) TelegramStart(token string) { + botAPI, err := tgbotapi.NewBotAPI(token) if err != nil { - log.Panic("[telegram] create bot ", err) + log.Panic().Err(err).Msg("[telegram] create bot") } b.BotAPI = botAPI - //bot.Debug = true - - log.Printf("Authorized on account %s", b.BotAPI.Self.UserName) + log.Info().Msgf("Authorized on account %s", b.BotAPI.Self.UserName) u := tgbotapi.NewUpdate(0) u.Timeout = 60 updates, err := b.BotAPI.GetUpdatesChan(u) + if err != nil { + log.Panic().Err(err).Msg("[telegram] get updates chan") + } for update := range updates { + if update.CallbackQuery == nil && update.Message == nil { + log.Warn().Msg("[telegram] received incorrect updates") + continue + } var fromID int var chatID int64 @@ -131,6 +138,8 @@ func (b *bot) TelegramStart() { chatID = update.CallbackQuery.Message.ReplyToMessage.Chat.ID } + log.Debug().Msgf("[telegram] received a message from %d in chat %d", fromID, chatID) + if !(b.isAdmin(fromID) || b.isChat(chatID)) { if update.CallbackQuery != nil { _, err = b.BotAPI.AnswerCallbackQuery(tgbotapi.CallbackConfig{ @@ -138,96 +147,45 @@ func (b *bot) TelegramStart() { Text: "Access denied", }) if err != nil { - log.Printf("[telegram] access denied, callback answer error %s", err) + log.Error().Err(err).Msg("[telegram] access denied, callback answer error") } } continue } - if update.Message != nil { - log.Printf("[telegram] received a message from %d in chat %d", - update.Message.From.ID, update.Message.Chat.ID) - } - if update.Message != nil && strings.HasPrefix(update.Message.Text, "/balance") { - - r1 := strings.Split(strings.TrimPrefix(update.Message.Text, "/balance"), "_") - idx := 0 - if len(r1) == 1 { - idx = 0 + if len(b.clients) > 1 { + _, err = b.BotAPI.Send(b.sendClientButtons("bc", update)) + if err != nil { + log.Error().Err(err).Msg("[telegram] report send msg error") + } } else { - idx, _ = strconv.Atoi(r1[1]) - } - - client, err := b.getClient(idx) - if err != nil { - msg := tgbotapi.NewMessage(update.Message.Chat.ID, err.Error()) - _, err = b.BotAPI.Send(msg) - continue - } - - clientInfo, err := client.GetInfo() - if err != nil { - msg := tgbotapi.NewMessage(update.Message.Chat.ID, err.Error()) - _, err = b.BotAPI.Send(msg) - continue - } - - var account Account - for _, _account := range clientInfo.Accounts { - if _account.CurrencyCode == 980 { - account = _account + err := b.sendBalanceByClient(b.clients[0], update.Message) + if err != nil { + log.Error().Err(err).Msg("[telegram] balance, send msg error") } } - var tpl bytes.Buffer - err = b.balanceTmpl.Execute(&tpl, account) - if err != nil { - log.Printf("[telegram] balance, template execute error %s", err) - continue - } - message := tpl.String() - - msg := tgbotapi.NewMessage(update.Message.Chat.ID, message) - msg.ReplyToMessageID = update.Message.MessageID - - _, err = b.BotAPI.Send(msg) - if err != nil { - log.Printf("[telegram] balance, send msg error %s", err) - } } else if update.Message != nil && strings.HasPrefix(update.Message.Text, "/report") { - log.Printf("[telegram] report show keyboard") - - // reset state - b.resetClientState() + log.Debug().Msg("[telegram] report") - r1 := strings.Split(strings.TrimPrefix(update.Message.Text, "/report"), "_") - idx := 0 - if len(r1) == 1 { - idx = 0 + if len(b.clients) > 1 { + _, err = b.BotAPI.Send(b.sendClientButtons("rc", update)) + if err != nil { + log.Error().Err(err).Msg("[telegram] report send msg error") + } } else { - idx, _ = strconv.Atoi(r1[1]) - } - - client, err := b.getClient(idx) - if err != nil { - msg := tgbotapi.NewMessage(update.CallbackQuery.Message.Chat.ID, err.Error()) - _, err = b.BotAPI.Send(msg) - continue - } - - _, err = b.BotAPI.Send(client.GetReport().GetKeyboardMessageConfig(update)) - if err != nil { - log.Printf("[telegram] report send msg error %s", err) - } + tmConfig, err := sendAccountButtonsMessage("ra", b.clients[0], *update.Message) + if err != nil { + log.Error().Err(err).Msg("[telegram] report send msg error") + } - if err != nil { - log.Printf("[telegram] report send msg error %s", err) + _, err = b.BotAPI.Send(tmConfig) + if err != nil { + log.Error().Err(err).Msg("[telegram] report send msg error") + } } - - // set state of the client - client.SetState(ClientStateReport) } else if update.Message != nil && strings.HasPrefix(update.Message.Text, "/get_webhook") { r1 := strings.Split(strings.TrimPrefix(update.Message.Text, "/get_webhook"), "_") @@ -241,21 +199,21 @@ func (b *bot) TelegramStart() { client, err := b.getClient(idx) if err != nil { msg := tgbotapi.NewMessage(update.Message.Chat.ID, err.Error()) - _, err = b.BotAPI.Send(msg) + b.BotAPI.Send(msg) continue } clientInfo, err := client.GetInfo() if err != nil { msg := tgbotapi.NewMessage(update.Message.Chat.ID, err.Error()) - _, err = b.BotAPI.Send(msg) + b.BotAPI.Send(msg) continue } var tpl bytes.Buffer err = b.webhookTmpl.Execute(&tpl, clientInfo) if err != nil { - log.Printf("[telegram] get webhook, template execute error %s", err) + log.Error().Err(err).Msg("[telegram] get webhook, template execute error") continue } message := tpl.String() @@ -265,7 +223,7 @@ func (b *bot) TelegramStart() { _, err = b.BotAPI.Send(msg) if err != nil { - log.Printf("[telegram] balance, send msg error %s", err) + log.Error().Err(err).Msg("[telegram] get webhook, send msg error") } } else if update.Message != nil && strings.HasPrefix(update.Message.Text, "/set_webhook") { @@ -287,21 +245,21 @@ func (b *bot) TelegramStart() { if !IsURL(r2[1]) { msg := tgbotapi.NewMessage(update.Message.Chat.ID, "Incorrect url") - _, err = b.BotAPI.Send(msg) + b.BotAPI.Send(msg) continue } client, err := b.getClient(idx) if err != nil { msg := tgbotapi.NewMessage(update.Message.Chat.ID, err.Error()) - _, err = b.BotAPI.Send(msg) + b.BotAPI.Send(msg) continue } response, err := client.SetWebHook(r2[1]) if err != nil { msg := tgbotapi.NewMessage(update.Message.Chat.ID, err.Error()) - _, err = b.BotAPI.Send(msg) + b.BotAPI.Send(msg) continue } @@ -315,104 +273,150 @@ func (b *bot) TelegramStart() { _, err = b.BotAPI.Send(msg) if err != nil { - log.Printf("[telegram] balance, send msg error %s", err) + log.Error().Err(err).Msg("[telegram] set webhook, send msg error") } } else if update.Message != nil { + log.Warn().Msg("[telegram] the messuge unsupport") + } else if update.CallbackQuery != nil { + + callbackQueryData := callbackQueryDataParser(update.CallbackQuery.Data) - client, err := b.getClientByState(ClientStateReport) + client, err := b.getClientByID(callbackQueryData.ClientID) if err != nil { - msg := tgbotapi.NewMessage(update.Message.Chat.ID, err.Error()) - _, err = b.BotAPI.Send(msg) + msg := tgbotapi.NewMessage(update.CallbackQuery.Message.Chat.ID, err.Error()) + b.BotAPI.Send(msg) continue } - log.Printf("[telegram] report grid") - - if !client.GetReport().IsExistGridData(update) { - items, err := client.GetStatement(client.GetReport().GetPeriodFromUpdate(update)) + if update.CallbackQuery.Data != "" && update.CallbackQuery.Data[:2] == "bc" { + // balance + message, err := b.buildBalanceByClient(client) if err != nil { - log.Printf("[telegram] report get statements error %s", err) - - msg := tgbotapi.NewMessage(update.Message.Chat.ID, err.Error()) - _, err = b.BotAPI.Send(msg) + log.Error().Err(err).Msg("[telegram] balance, send msg error") continue } - // set statement data - client.GetReport().SetGridData(update, items) - } - - _, err = b.BotAPI.Send(client.GetReport().GetReportGrid(update, client.GetID())) - if err != nil { - log.Printf("[telegram] report grid send error %s", err) - } + messageConfig := tgbotapi.NewEditMessageText( + update.CallbackQuery.Message.Chat.ID, + update.CallbackQuery.Message.MessageID, + message, + ) - messageConfig := tgbotapi.MessageConfig{} - messageConfig.Text = "ᅠ " - messageConfig.ChatID = update.Message.Chat.ID - messageConfig.ReplyMarkup = tgbotapi.NewRemoveKeyboard(false) - emptyMessage, err := b.BotAPI.Send(messageConfig) - if err != nil { - log.Printf("[telegram] remove keyboard error %s", err) - } + _, err = b.BotAPI.Send(messageConfig) + if err != nil { + log.Error().Err(err).Msg("[telegram] balance, send msg error") + } + } else if update.CallbackQuery.Data != "" && update.CallbackQuery.Data[:2] == "rc" { + // report account - // reset state - b.resetClientState() + mConfig, err := sendAccountButtonsEditMessage("ra", client, *update.CallbackQuery.Message) - _, err = b.BotAPI.Send(tgbotapi.NewDeleteMessage(emptyMessage.Chat.ID, emptyMessage.MessageID)) - if err != nil { - log.Printf("[telegram] remove empty message error %s", err) - } - } else if update.CallbackQuery != nil { + if err != nil { + log.Error().Err(err).Msg("[telegram] report send msg error") + } - client, err := b.getClientByID(callbackQueryDataParser(update.CallbackQuery.Data).ClientID) - if err != nil { - msg := tgbotapi.NewMessage(update.CallbackQuery.Message.Chat.ID, err.Error()) - _, err = b.BotAPI.Send(msg) - continue - } + _, err = b.BotAPI.Send(mConfig) + if err != nil { + log.Error().Err(err).Msg("[telegram] report send msg error") + } + } else if update.CallbackQuery.Data != "" && update.CallbackQuery.Data[:2] == "ra" { + account, err := client.GetAccountByID(callbackQueryData.Account) + if err != nil { + msg := tgbotapi.NewMessage(update.CallbackQuery.Message.Chat.ID, err.Error()) + b.BotAPI.Send(msg) + continue + } - log.Printf("[telegram] report grid page") + message := client.GetReport(account.ID).GetKeyboarButtonConfig(update, client.GetID()) + message.Text = fmt.Sprintf( + "%s, %s%s\n%s", + client.GetName(), + NormalizePrice(account.Balance), + GetCurrencySymbol(account.CurrencyCode), + message.Text, + ) - if !client.GetReport().IsExistGridData(update) { - items, err := client.GetStatement(client.GetReport().GetPeriodFromUpdate(update)) + _, err = b.BotAPI.Send(message) if err != nil { - log.Printf("[telegram] report grid page get statements error %s", err) + log.Error().Err(err).Msg("[telegram] report send msg error") + } + } else if update.CallbackQuery.Data != "" && (update.CallbackQuery.Data[:2] == "rp" || update.CallbackQuery.Data[:2] == "rr") { + // report + log.Debug().Msg("[telegram] report grid page") + account, err := client.GetAccountByID(callbackQueryData.Account) + if err != nil { msg := tgbotapi.NewMessage(update.CallbackQuery.Message.Chat.ID, err.Error()) - _, err = b.BotAPI.Send(msg) + b.BotAPI.Send(msg) + + log.Error().Err(err).Msg("[telegram] get account by ID") continue } - // reinit statements data if does not exist - client.GetReport().SetGridData(update, items) - } + if !client.GetReport(account.ID).IsExistGridData(update) { + items, err := client.GetStatement(strings.ReplaceAll(callbackQueryData.Period, "_", " "), account.ID) + if err != nil { + msg := tgbotapi.NewMessage(update.CallbackQuery.Message.Chat.ID, err.Error()) + b.BotAPI.Send(msg) - editMessage, err := client.GetReport().GetUpdatedReportGrid(update) - if err != nil { - _, err = b.BotAPI.AnswerCallbackQuery(tgbotapi.CallbackConfig{ - CallbackQueryID: update.CallbackQuery.ID, - Text: "Error :(", - }) - if err != nil { - log.Printf("[telegram] report grid send callback answer on update error %s", err) + log.Error().Err(err).Msg("[telegram] report grid page get statements") + continue + } + + // reinit statements data if does not exist + client.GetReport(account.ID).SetGridData(update, items) } - } - _, err = b.BotAPI.Send(editMessage) - if err != nil { - log.Printf("[telegram] report grid send error %s", err) + var editMessage tgbotapi.Chattable + + if update.CallbackQuery.Data[:2] == "rp" { + _editMessage := client.GetReport(account.ID).GetReportGrid(update, client.GetID()) + _editMessage.Text = fmt.Sprintf( + "%s, %s%s, %s\n%s", + client.GetName(), + NormalizePrice(account.Balance), + GetCurrencySymbol(account.CurrencyCode), + strings.ReplaceAll(callbackQueryData.Period, "_", " "), + _editMessage.Text, + ) + editMessage = _editMessage + + } else { + _editMessage, err := client.GetReport(account.ID).GetUpdatedReportGrid(update) + if err != nil { + _, err = b.BotAPI.AnswerCallbackQuery(tgbotapi.CallbackConfig{ + CallbackQueryID: update.CallbackQuery.ID, + Text: "Error :(", + }) + if err != nil { + log.Error().Err(err).Msg("[telegram] report grid send callback answer on update error") + } + } + _editMessage.Text = fmt.Sprintf( + "%s, %s%s, %s\n%s", + client.GetName(), + NormalizePrice(account.Balance), + GetCurrencySymbol(account.CurrencyCode), + strings.ReplaceAll(callbackQueryData.Period, "_", " "), + _editMessage.Text, + ) + editMessage = _editMessage + } + + _, err = b.BotAPI.Send(editMessage) + if err != nil { + log.Error().Err(err).Msg("[telegram] report grid send error") + } + } else { + log.Warn().Msg("[telegram] the messege unsupport") } _, err = b.BotAPI.AnswerCallbackQuery(tgbotapi.CallbackConfig{ CallbackQueryID: update.CallbackQuery.ID, }) if err != nil { - log.Printf("[telegram] report grid send callback answer error %s", err) + log.Error().Err(err).Msg("[telegram] report grid send callback answer error") } - - // reset state - b.resetClientState() } } } @@ -423,19 +427,19 @@ func (b *bot) WebhookStart() { http.HandleFunc("/web_hook", func(w http.ResponseWriter, r *http.Request) { body, err := ioutil.ReadAll(r.Body) if err != nil { - log.Printf("[webhook] error %s", err) - fmt.Fprintf(w, "Not Ok!") + + log.Error().Err(err).Msg("[webhook] read") return } - //log.Printf("[webhook] body %s", string(body)) + log.Debug().Msgf("[webhook] body %s", string(body)) var statementItemData StatementItemData if err := json.Unmarshal(body, &statementItemData); err != nil { - log.Printf("[webhook] unmarshal error %s", err) - fmt.Fprintf(w, "Not Ok!") + + log.Error().Err(err).Msg("[webhook] unmarshal") return } @@ -446,79 +450,108 @@ func (b *bot) WebhookStart() { err := http.ListenAndServe(":8080", nil) if err != nil { - log.Panic("[webhook] serve ", err) + log.Panic().Err(err).Msg("[webhook] serve") } } // ProcessingStart starts processing data that received from chennal. func (b *bot) ProcessingStart() { - for { - select { - case statementItemData := <-b.ch: - client, err := b.getClientByAccountID(statementItemData.Data.Account) + sendTo := func(chatIds, message string) error { + ids := strings.Split(strings.Trim(chatIds, " "), ",") + for _, id := range ids { + chatID, err := strconv.ParseInt(id, 10, 64) if err != nil { - log.Printf("[processing] get clietn by account error %s", err) - continue + return err } - // trigger on new StatementItem - client.AddStatementItem( - statementItemData.Data.Account, - statementItemData.Data.StatementItem, - ) - - var tpl bytes.Buffer - err = b.statementTmpl.Execute(&tpl, struct { - Name string - StatementItem StatementItem - }{ - Name: client.GetName(), - StatementItem: statementItemData.Data.StatementItem, - }) + _, err = b.BotAPI.Send(tgbotapi.NewMessage(chatID, message)) if err != nil { - log.Printf("[processing] template execute error %s", err) - continue + return err } - message := tpl.String() + } - // to chat - ids := strings.Split(strings.Trim(b.telegramChats, " "), ",") - for _, id := range ids { - chatID, err := strconv.ParseInt(id, 10, 64) - if err != nil { - log.Printf("[processing] parseInt error %s", err) - continue - } + return nil + } - msg := tgbotapi.NewMessage(chatID, message) - _, err = b.BotAPI.Send(msg) - if err != nil { - log.Printf("[processing] send message error %s", err) - continue - } - } + for { + statementItemData := <-b.ch - // to admin member - ids = strings.Split(strings.Trim(b.telegramAdmins, " "), ",") - for _, id := range ids { - chatID, err := strconv.ParseInt(id, 10, 64) - if err != nil { - log.Printf("[processing] parseInt error %s", err) - continue - } + client, err := b.getClientByAccountID(statementItemData.Data.Account) + if err != nil { + log.Error().Err(err).Msg("[processing] get client by account") + continue + } - msg := tgbotapi.NewMessage(chatID, message) - _, err = b.BotAPI.Send(msg) - if err != nil { - log.Printf("[processing] send message error %s", err) - continue - } - } + account, err := client.GetAccountByID(statementItemData.Data.Account) + if err != nil { + log.Error().Err(err).Msg("[processing] get account by id") + continue + } + + client.ResetReport(statementItemData.Data.Account) + + var tpl bytes.Buffer + err = b.statementTmpl.Execute(&tpl, struct { + Name string + StatementItem StatementItem + Account Account + }{ + Name: client.GetName(), + StatementItem: statementItemData.Data.StatementItem, + Account: *account, + }) + if err != nil { + log.Error().Err(err).Msg("[processing] template execute error") + continue + } + message := tpl.String() + + // to chat + err = sendTo(b.telegramChats, message) + if err != nil { + log.Error().Err(err).Msg("[processing] send to chat") + continue + } + + // to admin + err = sendTo(b.telegramAdmins, message) + if err != nil { + log.Error().Err(err).Msg("[processing] send to admin") + continue } } } +func (b *bot) sendClientButtons(prefix string, update tgbotapi.Update) tgbotapi.MessageConfig { + buttons := []tgbotapi.InlineKeyboardButton{} + + for _, client := range b.clients { + callbackData := callbackQueryDataBuilder(prefix, pageData{ + Page: 0, + Period: "", + ChatID: update.Message.Chat.ID, + FromID: update.Message.From.ID, + ClientID: uint32(client.GetID()), + }) + + buttons = append(buttons, tgbotapi.InlineKeyboardButton{ + Text: client.GetName(), + CallbackData: &callbackData, + }) + } + + inlineKeyboardMarkup := tgbotapi.NewInlineKeyboardMarkup(buttons) + + messageConfig := tgbotapi.MessageConfig{} + messageConfig.Text = "Виберіть клієнта:" + messageConfig.ChatID = update.Message.Chat.ID + messageConfig.ReplyToMessageID = update.Message.MessageID + messageConfig.ReplyMarkup = inlineKeyboardMarkup + + return messageConfig +} + func (b *bot) getClient(index int) (Client, error) { if len(b.clients) > index { return b.clients[index], nil @@ -546,22 +579,6 @@ func (b bot) checkIds(stringIds string, id int64) bool { return false } -func (b bot) getClientByState(state ClientState) (Client, error) { - for _, client := range b.clients { - if client.IsState(state) { - return client, nil - } - } - - return nil, errors.New("please repeat a command for client") -} - -func (b *bot) resetClientState() { - for _, client := range b.clients { - client.SetState(ClientStateNone) - } -} - func (b bot) getClientByID(id uint32) (Client, error) { for _, client := range b.clients { if client.GetID() == id { @@ -585,6 +602,83 @@ func (b bot) getClientByAccountID(id string) (Client, error) { return nil, errors.New("client does not found") } -func (b bot) normalizePrice(price int) string { - return fmt.Sprintf("%.2f₴", float64(price/100)) +func (b *bot) buildBalanceByClient(client Client) (string, error) { + clientInfo, err := client.GetInfo() + if err != nil { + return "", err + } + + var tpl bytes.Buffer + err = b.balanceTmpl.Execute(&tpl, clientInfo) + if err != nil { + return "", err + } + + return tpl.String(), err +} + +func (b *bot) sendBalanceByClient(client Client, tgMessage *tgbotapi.Message) error { + message, err := b.buildBalanceByClient(client) + if err != nil { + msg := tgbotapi.NewMessage(tgMessage.Chat.ID, err.Error()) + _, err = b.BotAPI.Send(msg) + if err != nil { + return err + } + } + + msg := tgbotapi.NewMessage(tgMessage.Chat.ID, message) + msg.ReplyToMessageID = tgMessage.MessageID + + _, err = b.BotAPI.Send(msg) + return err +} + +func sendAccountButtonsEditMessage(prefix string, client Client, message tgbotapi.Message) (*tgbotapi.EditMessageTextConfig, error) { + messageConfig, inlineKeyboardMarkup, _ := buildAccountButtons[tgbotapi.EditMessageTextConfig](prefix, client, message) + messageConfig.Text = fmt.Sprintf("%s\nВиберіть рахунок:", client.GetName()) + messageConfig.ChatID = message.Chat.ID + messageConfig.MessageID = message.MessageID + messageConfig.ReplyMarkup = inlineKeyboardMarkup + + return messageConfig, nil +} + +func sendAccountButtonsMessage(prefix string, client Client, message tgbotapi.Message) (*tgbotapi.MessageConfig, error) { + + messageConfig, inlineKeyboardMarkup, _ := buildAccountButtons[tgbotapi.MessageConfig](prefix, client, message) + messageConfig.Text = fmt.Sprintf("%s\nВиберіть рахунок:", client.GetName()) + messageConfig.ChatID = message.Chat.ID + messageConfig.ReplyToMessageID = message.MessageID + messageConfig.ReplyMarkup = inlineKeyboardMarkup + + return messageConfig, nil +} + +func buildAccountButtons[V tgbotapi.EditMessageTextConfig | tgbotapi.MessageConfig](prefix string, client Client, message tgbotapi.Message) (*V, *tgbotapi.InlineKeyboardMarkup, error) { + buttons := []tgbotapi.InlineKeyboardButton{} + + info, err := client.GetInfo() + if err != nil { + return nil, nil, err + } + + for _, account := range info.Accounts { + callbackData := callbackQueryDataBuilder(prefix, pageData{ + Page: 0, + Period: "", + ChatID: message.Chat.ID, + FromID: message.From.ID, + ClientID: uint32(client.GetID()), + Account: account.ID, + }) + + buttons = append(buttons, tgbotapi.InlineKeyboardButton{ + Text: fmt.Sprintf("%s%s", NormalizePrice(account.Balance), GetCurrencySymbol(account.CurrencyCode)), + CallbackData: &callbackData, + }) + } + + inlineKeyboardMarkup := tgbotapi.NewInlineKeyboardMarkup(buttons) + return new(V), &inlineKeyboardMarkup, nil } diff --git a/client.go b/client.go index b98f799..3e21143 100644 --- a/client.go +++ b/client.go @@ -1,17 +1,15 @@ package main import ( - "encoding/json" "errors" "fmt" "hash/fnv" - "io/ioutil" - "log" "net/http" "strings" "time" - "github.com/looplab/fsm" + "github.com/rs/zerolog/log" + "golang.org/x/time/rate" ) @@ -22,6 +20,7 @@ type StatementItem struct { Description string `json:"description"` Comment string `json:"comment,omitempty"` Mcc int `json:"mcc"` + OriginalMcc int `json:"originalMcc"` Amount int `json:"amount"` OperationAmount int `json:"operationAmount"` CurrencyCode int `json:"currencyCode"` @@ -33,11 +32,14 @@ type StatementItem struct { // Account is a account information type Account struct { - ID string `json:"id"` - CurrencyCode int `json:"currencyCode"` - CashbackType string `json:"cashbackType"` - Balance int `json:"balance"` - CreditLimit int `json:"creditLimit"` + ID string `json:"id"` + Type string `json:"type"` + CurrencyCode int `json:"currencyCode"` + CashbackType string `json:"cashbackType"` + Balance int `json:"balance"` + CreditLimit int `json:"creditLimit"` + Iban string `json:"iban"` + MaskedPan []string `json:"maskedPan"` } // ClientInfo is a client information @@ -57,38 +59,22 @@ type WebHookResponse struct { type Client interface { Init() error GetID() uint32 - GetReport() Report + GetReport(accountId string) Report GetInfo() (ClientInfo, error) - GetStatement(command string) ([]StatementItem, error) + GetStatement(command, accountId string) ([]StatementItem, error) SetWebHook(url string) (WebHookResponse, error) GetName() string - IsState(flag ClientState) bool - Can(flag ClientState) bool - SetState(flag ClientState) - AddStatementItem(string, StatementItem) + ResetReport(accountId string) + GetAccountByID(id string) (*Account, error) } -// ClientState is a state type -type ClientState uint - -const ( - // ClientStateNone is a none state - ClientStateNone ClientState = iota - // ClientStateReport is a report state - ClientStateReport - // ClientStateWebHook is a webhook state - ClientStateWebHook -) - type client struct { Info *ClientInfo id uint32 token string limiter *rate.Limiter - report Report - //state ClientState - fsm *fsm.FSM + reports map[string]Report } // NewClient returns a client object. @@ -98,19 +84,10 @@ func NewClient(token string) Client { h.Write([]byte(token)) return &client{ - limiter: rate.NewLimiter(rate.Every(time.Second*65), 1), + limiter: rate.NewLimiter(rate.Every(time.Second*30), 1), token: token, id: h.Sum32(), - report: NewReport(), - fsm: fsm.NewFSM( - "none", - fsm.Events{ - {Name: "none", Src: []string{"Report", "WebHook"}, Dst: "none"}, - {Name: "Report", Src: []string{"none"}, Dst: "Report"}, - {Name: "WebHook", Src: []string{"none"}, Dst: "WebHook"}, - }, - fsm.Callbacks{}, - ), + reports: make(map[string]Report), } } @@ -123,13 +100,17 @@ func (c client) GetID() uint32 { return c.id } -func (c *client) GetReport() Report { - return c.report +func (c *client) GetReport(accountId string) Report { + if _, ok := c.reports[accountId]; !ok { + c.reports[accountId] = NewReport(accountId, c.id) + } + + return c.reports[accountId] } func (c *client) GetInfo() (ClientInfo, error) { if c.limiter.Allow() { - log.Printf("[monoapi] get info") + log.Debug().Msg("[monoapi] get info") info, err := c.getClientInfo() c.Info = &info return info, err @@ -139,8 +120,8 @@ func (c *client) GetInfo() (ClientInfo, error) { return *c.Info, nil } - log.Printf("[monoapi] get info, waiting 1 minute") - return ClientInfo{}, errors.New("please waiting 1 minute and then try again") + log.Warn().Msg("[monoapi] get info, waiting") + return ClientInfo{}, errors.New("please waiting and then try again") } // GetName return name of the client @@ -159,94 +140,67 @@ func (c client) SetWebHook(url string) (WebHookResponse, error) { req, err := http.NewRequest("POST", "https://api.monobank.ua/personal/webhook", payload) if err != nil { - log.Printf("[monoapi] webhook, NewRequest %s", err) + log.Error().Err(err).Msg("[monoapi] webhook, NewRequest") return response, err } req.Header.Add("X-Token", c.token) req.Header.Add("content-type", "application/json") - res, err := http.DefaultClient.Do(req) - if err != nil { - log.Printf("[monoapi] webhook, error %s", err) - return response, err - } - - defer res.Body.Close() - body, err := ioutil.ReadAll(res.Body) - if err != nil { - log.Printf("[monoapi] webhook, error %s", err) - return response, err - } + return DoRequest(response, req) +} - if err := json.Unmarshal(body, &response); err != nil { - log.Printf("[monoapi] webhook, unmarshal error %s", err) - return response, err +func (c *client) GetAccountByID(id string) (*Account, error) { + if c.Info != nil { + for _, account := range c.Info.Accounts { + if account.ID == id { + return &account, nil + } + } } - log.Printf("[monoapi] webhook, responce %s", string(body)) - return response, err + return nil, errors.New("account does not found") } -func (c *client) AddStatementItem(account string, statementItem StatementItem) { - c.GetReport().ResetLastData() +func (c *client) ResetReport(accountId string) { + c.GetReport(accountId).ResetLastData() } -func (c client) GetStatement(command string) ([]StatementItem, error) { +func (c client) GetStatement(command string, accountId string) ([]StatementItem, error) { if c.limiter.Allow() { - return c.getStatement(command) + return c.getStatement(command, accountId) } - log.Printf("[monoapi] statement, waiting 1 minute") - return []StatementItem{}, errors.New("please waiting 1 minute and then try again") + log.Warn().Msg("[monoapi] statement, waiting") + return []StatementItem{}, errors.New("please waiting and then try again") } -func (c client) getStatement(command string) ([]StatementItem, error) { +func (c client) getStatement(command, account string) ([]StatementItem, error) { statementItems := []StatementItem{} from, to, err := getTimeRangeByPeriod(command) if err != nil { - log.Printf("[monoapi] statements, range error %s", err) + log.Error().Err(err).Msg("[monoapi] statements, range") return statementItems, err } - log.Printf("[monoapi] statements, range from: %d, to: %d", from, to) + log.Debug().Msgf("[monoapi] statements, range from: %d, to: %d", from, to) - url := fmt.Sprintf("https://api.monobank.ua/personal/statement/0/%d", from) + url := fmt.Sprintf("https://api.monobank.ua/personal/statement/%s/%d", account, from) if to > 0 { url = fmt.Sprintf("%s/%d", url, to) } req, err := http.NewRequest("GET", url, nil) if err != nil { - log.Printf("[monoapi] statements, NewRequest error %s", err) + log.Error().Err(err).Msg("[monoapi] statements, NewRequest") return statementItems, err } req.Header.Add("x-token", c.token) - res, err := http.DefaultClient.Do(req) - if err != nil { - log.Printf("[monoapi] statements, error %s", err) - return statementItems, err - } - - defer res.Body.Close() - body, err := ioutil.ReadAll(res.Body) - if err != nil { - log.Printf("[monoapi] statements, error %s", err) - return statementItems, err - } - - //log.Printf("[monoapi] statements, body %s", string(body)) - - if err := json.Unmarshal(body, &statementItems); err != nil { - log.Printf("[monoapi] statements, unmarshal error %s", err) - return statementItems, err - } - - return statementItems, nil + return DoRequest(statementItems, req) } func (c client) getClientInfo() (ClientInfo, error) { @@ -255,64 +209,11 @@ func (c client) getClientInfo() (ClientInfo, error) { url := "https://api.monobank.ua/personal/client-info" req, err := http.NewRequest("GET", url, nil) if err != nil { - log.Printf("[monoapi] client info, create request error %s", err) + log.Error().Err(err).Msg("[monoapi] client info, create request") return clientInfo, err } req.Header.Add("x-token", c.token) - res, err := http.DefaultClient.Do(req) - if err != nil { - log.Printf("[monoapi] client info, request error %s", err) - return clientInfo, err - } - - defer res.Body.Close() - body, err := ioutil.ReadAll(res.Body) - if err != nil { - return clientInfo, err - } - - if err := json.Unmarshal(body, &clientInfo); err != nil { - log.Printf("[monoapi] client info, unmarshal error %s", err) - return clientInfo, err - } - - return clientInfo, nil -} - -func (c *client) SetState(state ClientState) { - if !c.IsState(state) { - c.fsm.Event(c.getStringFromState(state)) - } -} - -func (c client) IsState(state ClientState) bool { - return c.fsm.Is(c.getStringFromState(state)) -} - -func (c client) Can(state ClientState) bool { - return c.fsm.Can(c.getStringFromState(state)) -} - -func (c *client) getStringFromState(state ClientState) string { - switch state { - case ClientStateReport: - return "Report" - case ClientStateWebHook: - return "WebHook" - default: - return "none" - } -} - -func (c *client) getFlagFromString(state string) ClientState { - switch state { - case "Report": - return ClientStateReport - case "WebHook": - return ClientStateWebHook - default: - return ClientStateNone - } + return DoRequest(clientInfo, req) } diff --git a/docker-compose.yml b/docker-compose.yml index 5e04227..74505ed 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -13,5 +13,6 @@ services: - MONO_TOKEN=${MONO_TOKEN} - TELEGRAM_ADMINS=${TELEGRAM_ADMINS} - TELEGRAM_CHATS=${TELEGRAM_CHATS} + - LOG_LEVEL=${LOG_LEVEL} ports: - ${APP_PORT}:8080 diff --git a/go.mod b/go.mod index b5bd39b..f2db7b9 100644 --- a/go.mod +++ b/go.mod @@ -1,10 +1,19 @@ module github.com/vkopitsa/mono_personal_tgbot -go 1.12 +go 1.18 require ( github.com/go-telegram-bot-api/telegram-bot-api v4.6.4+incompatible - github.com/looplab/fsm v0.1.0 - github.com/technoweenie/multipartstreamer v1.0.1 // indirect + github.com/rs/zerolog v1.27.0 golang.org/x/time v0.0.0-20190308202827-9d24e82272b4 ) + +require ( + github.com/mattn/go-colorable v0.1.12 // indirect + github.com/mattn/go-isatty v0.0.14 // indirect + github.com/pkg/errors v0.9.1 // indirect + github.com/snabb/isoweek v1.0.1 + github.com/technoweenie/multipartstreamer v1.0.1 // indirect + golang.org/x/exp v0.0.0-20220713135740-79cabaa25d75 + golang.org/x/sys v0.0.0-20211019181941-9d821ace8654 // indirect +) diff --git a/go.sum b/go.sum index 536a05c..ff4a832 100644 --- a/go.sum +++ b/go.sum @@ -1,8 +1,26 @@ +github.com/coreos/go-systemd/v22 v22.3.3-0.20220203105225-a9a7ef127534/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc= github.com/go-telegram-bot-api/telegram-bot-api v4.6.4+incompatible h1:2cauKuaELYAEARXRkq2LrJ0yDDv1rW7+wrTEdVL3uaU= github.com/go-telegram-bot-api/telegram-bot-api v4.6.4+incompatible/go.mod h1:qf9acutJ8cwBUhm1bqgz6Bei9/C/c93FPDljKWwsOgM= -github.com/looplab/fsm v0.1.0 h1:Qte7Zdn/5hBNbXzP7yxVU4OIFHWXBovyTT2LaBTyC20= -github.com/looplab/fsm v0.1.0/go.mod h1:m2VaOfDHxqXBBMgc26m6yUOwkFn8H2AlJDE+jd/uafI= +github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= +github.com/mattn/go-colorable v0.1.12 h1:jF+Du6AlPIjs2BiUiQlKOX0rt3SujHxPnksPKZbaA40= +github.com/mattn/go-colorable v0.1.12/go.mod h1:u5H1YNBxpqRaxsYJYSkiCWKzEfiAb1Gb520KVy5xxl4= +github.com/mattn/go-isatty v0.0.14 h1:yVuAays6BHfxijgZPzw+3Zlu5yQgKGP2/hcQbHb7S9Y= +github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94= +github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/rs/xid v1.3.0/go.mod h1:trrq9SKmegXys3aeAKXMUTdJsYXVwGY3RLcfgqegfbg= +github.com/rs/zerolog v1.27.0 h1:1T7qCieN22GVc8S4Q2yuexzBb1EqjbgjSH9RohbMjKs= +github.com/rs/zerolog v1.27.0/go.mod h1:7frBqO0oezxmnO7GF86FY++uy8I0Tk/If5ni1G9Qc0U= +github.com/snabb/isoweek v1.0.1 h1:B4IsN2GU8lCNVkaUUgOzaVpPkKC2DdY9zcnxz5yc0qg= +github.com/snabb/isoweek v1.0.1/go.mod h1:CAijAxH7NMgjqGc9baHMDE4sTHMt4B/f6X/XLiEE1iA= github.com/technoweenie/multipartstreamer v1.0.1 h1:XRztA5MXiR1TIRHxH2uNxXxaIkKQDeX7m2XsSOlQEnM= github.com/technoweenie/multipartstreamer v1.0.1/go.mod h1:jNVxdtShOxzAsukZwTSw6MDx5eUJoiEBsSvzDU9uzog= +golang.org/x/exp v0.0.0-20220713135740-79cabaa25d75 h1:x03zeu7B2B11ySp+daztnwM5oBJ/8wGUSqrwcw9L0RA= +golang.org/x/exp v0.0.0-20220713135740-79cabaa25d75/go.mod h1:Kr81I6Kryrl9sr8s2FK3vxD90NdsKWRuOIl2O4CvYbA= +golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20210927094055-39ccf1dd6fa6 h1:foEbQz/B0Oz6YIqu/69kfXPYeFQAuuMYFkjaqXzl5Wo= +golang.org/x/sys v0.0.0-20210927094055-39ccf1dd6fa6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20211019181941-9d821ace8654 h1:id054HUawV2/6IGm2IV8KZQjqtwAOo2CYlOToYqa0d0= +golang.org/x/sys v0.0.0-20211019181941-9d821ace8654/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/time v0.0.0-20190308202827-9d24e82272b4 h1:SvFZT6jyqRaOeXpc5h/JSfZenJ2O330aBsf7JfSUXmQ= golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= diff --git a/report.go b/report.go index 641e67d..e35d868 100644 --- a/report.go +++ b/report.go @@ -4,9 +4,10 @@ import ( "bytes" "fmt" "html/template" - "log" "strings" + "github.com/rs/zerolog/log" + tgbotapi "github.com/go-telegram-bot-api/telegram-bot-api" "time" @@ -36,23 +37,26 @@ var reportCommant = map[string]string{ // Report is the interface representing report object. type Report interface { - GetKeyboardMessageConfig(update tgbotapi.Update) tgbotapi.MessageConfig + GetKeyboarButtonConfig(update tgbotapi.Update, clientID uint32) tgbotapi.EditMessageTextConfig IsReportGridCommand(update tgbotapi.Update) bool IsReportGridPageCommand(update tgbotapi.Update) bool - GetReportGrid(update tgbotapi.Update, clientID uint32) tgbotapi.MessageConfig + GetReportGrid(update tgbotapi.Update, clientID uint32) tgbotapi.EditMessageTextConfig GetUpdatedReportGrid(update tgbotapi.Update) (tgbotapi.EditMessageTextConfig, error) IsExistGridData(update tgbotapi.Update) bool SetGridData(update tgbotapi.Update, items []StatementItem) GetPeriodFromUpdate(update tgbotapi.Update) string ResetLastData() + IsAccount(accountId string) bool } type report struct { cache map[string][]StatementItem - prefix string - perPage int - tmpl *template.Template + prefix string + perPage int + tmpl *template.Template + accountId string + clientId uint32 } // ReportPage is a structure to render report content the telegram @@ -60,23 +64,26 @@ type ReportPage struct { StatementItems []StatementItem // items per page SpentTotal int // total for the period AmountTotal int // total for the period + CurrencyCode int // total for the period CashbackAmountTotal int // total for the period Period string } // NewReport returns a report object. -func NewReport() Report { +func NewReport(accountId string, clientId uint32) Report { tmpl, err := GetTempate(reportPageTemplate) if err != nil { - log.Fatalf("[template] %s", err) + log.Fatal().Err(err).Msg("[template]") } return &report{ - prefix: "r:", - perPage: 5, - cache: map[string][]StatementItem{}, - tmpl: tmpl, + prefix: "rr", + perPage: 5, + cache: map[string][]StatementItem{}, + tmpl: tmpl, + accountId: accountId, + clientId: clientId, } } @@ -85,6 +92,7 @@ func (r *report) getCacheKay(update tgbotapi.Update) string { var fromID int var chatID int64 + var clientID uint32 if update.Message != nil { key = update.Message.Text @@ -94,15 +102,16 @@ func (r *report) getCacheKay(update tgbotapi.Update) string { } else { data := callbackQueryDataParser(update.CallbackQuery.Data) key = data.Period + clientID = data.ClientID fromID = update.CallbackQuery.Message.ReplyToMessage.From.ID chatID = update.CallbackQuery.Message.ReplyToMessage.Chat.ID } return fmt.Sprintf( - "%s-report-%d-%d", + "%s-report-%d-%d-%d", key, - chatID, fromID, + chatID, fromID, clientID, ) } @@ -115,31 +124,32 @@ func (r report) IsExistGridData(update tgbotapi.Update) bool { return ok } -func (r *report) GetReportGrid(update tgbotapi.Update, clientID uint32) tgbotapi.MessageConfig { +func (r *report) GetReportGrid(update tgbotapi.Update, clientID uint32) tgbotapi.EditMessageTextConfig { items := r.cache[r.getCacheKay(update)] + data := callbackQueryDataParser(update.CallbackQuery.Data) var tpl bytes.Buffer err := r.tmpl.Execute(&tpl, r.buildReportPage(items, 1, r.perPage)) if err != nil { - log.Printf("[processing] template execute error %s", err) - return tgbotapi.MessageConfig{} + log.Error().Err(err).Msg("[processing] template execute error") + return tgbotapi.EditMessageTextConfig{} } message := tpl.String() + tgMessage := update.Message + if tgMessage == nil && update.CallbackQuery != nil { + tgMessage = update.CallbackQuery.Message + } + inlineKeyboardMarkup := tgbotapi.NewInlineKeyboardMarkup( - getPaginateButtons(len(items), 1, r.perPage, callbackQueryDataBulder(r.prefix, pageData{ - Page: 1, - Period: update.Message.Text, - ChatID: update.Message.Chat.ID, - FromID: update.Message.From.ID, - ClientID: clientID, - }))) - - messageConfig := tgbotapi.MessageConfig{} + getPaginateButtons(len(items), 1, r.perPage, callbackQueryDataBuilder(r.prefix, data))) + + messageConfig := tgbotapi.EditMessageTextConfig{} messageConfig.Text = message - messageConfig.ChatID = update.Message.Chat.ID - messageConfig.ReplyToMessageID = update.Message.MessageID - messageConfig.ReplyMarkup = inlineKeyboardMarkup + messageConfig.ChatID = tgMessage.Chat.ID + // messageConfig.ReplyToMessageID = tgMessage.MessageID + messageConfig.MessageID = tgMessage.MessageID + messageConfig.ReplyMarkup = &inlineKeyboardMarkup return messageConfig } @@ -151,7 +161,7 @@ func (r report) GetUpdatedReportGrid(update tgbotapi.Update) (tgbotapi.EditMessa var tpl bytes.Buffer err := r.tmpl.Execute(&tpl, r.buildReportPage(items, data.Page, r.perPage)) if err != nil { - log.Printf("[processing] template execute error %s", err) + log.Error().Err(err).Msg("[processing] template execute error") return tgbotapi.EditMessageTextConfig{}, err } message := tpl.String() @@ -161,7 +171,7 @@ func (r report) GetUpdatedReportGrid(update tgbotapi.Update) (tgbotapi.EditMessa len(items), data.Page, r.perPage, - callbackQueryDataBulder(r.prefix, data), + callbackQueryDataBuilder(r.prefix, data), ), ) @@ -186,25 +196,30 @@ func (r report) buildReportPage(items []StatementItem, page, limit int) ReportPa var amountTotal int var cashbackAmountTotal int var spentTotal int + var currencyCode int for _, item := range items { if item.Amount < 0 { spentTotal += -item.Amount } amountTotal += abs(item.Amount) cashbackAmountTotal += item.CashbackAmount + currencyCode = item.CurrencyCode } - if page == 1 { - items = items[:limit] - } else if totalPages == page { - items = items[(totalPages-1)*limit:] - } else { - items = items[(page-1)*limit : page*limit] + if total > 0 { + if page == 1 && len(items) >= limit { + items = items[:limit] + } else if totalPages == page { + items = items[(totalPages-1)*limit:] + } else { + items = items[(page-1)*limit : page*limit] + } } return ReportPage{ StatementItems: items, AmountTotal: amountTotal, + CurrencyCode: currencyCode, SpentTotal: spentTotal, CashbackAmountTotal: cashbackAmountTotal, } @@ -220,62 +235,100 @@ func (r report) IsReportGridCommand(update tgbotapi.Update) bool { return ok } -func (r report) GetKeyboardMessageConfig(update tgbotapi.Update) tgbotapi.MessageConfig { - custom := []tgbotapi.KeyboardButton{ - tgbotapi.KeyboardButton{ - Text: "Today", +func (r report) GetKeyboarButtonConfig(update tgbotapi.Update, clientID uint32) tgbotapi.EditMessageTextConfig { + tgMessage := update.Message + if tgMessage == nil && update.CallbackQuery != nil { + tgMessage = update.CallbackQuery.Message + } + + callbackQueryDataPerion := func(p string) *string { + d := callbackQueryDataBuilder("rp", pageData{ + // Page: 1, + Period: strings.ReplaceAll(p, " ", "_"), + ChatID: tgMessage.Chat.ID, + FromID: tgMessage.From.ID, + ClientID: r.clientId, + Account: r.accountId, + }) + + // add page number + d = d + "1" + + return &d + } + + custom := []tgbotapi.InlineKeyboardButton{ + { + Text: "Today", + CallbackData: callbackQueryDataPerion("Today"), }, - tgbotapi.KeyboardButton{ - Text: "This week", + { + Text: "This week", + CallbackData: callbackQueryDataPerion("This week"), }, - tgbotapi.KeyboardButton{ - Text: "Last week", + { + Text: "Last week", + CallbackData: callbackQueryDataPerion("Last week"), }, - tgbotapi.KeyboardButton{ - Text: "This month", + { + Text: "This month", + CallbackData: callbackQueryDataPerion("This month"), }, - tgbotapi.KeyboardButton{ - Text: "Last month", + { + Text: "Last month", + CallbackData: callbackQueryDataPerion("Last month"), }, } - months := []tgbotapi.KeyboardButton{ - tgbotapi.KeyboardButton{ - Text: "January", + months := []tgbotapi.InlineKeyboardButton{ + { + Text: "January", + CallbackData: callbackQueryDataPerion("January"), }, - tgbotapi.KeyboardButton{ - Text: "February", + { + Text: "February", + CallbackData: callbackQueryDataPerion("February"), }, - tgbotapi.KeyboardButton{ - Text: "March", + { + Text: "March", + CallbackData: callbackQueryDataPerion("March"), }, - tgbotapi.KeyboardButton{ - Text: "April", + { + Text: "April", + CallbackData: callbackQueryDataPerion("April"), }, - tgbotapi.KeyboardButton{ - Text: "May", + { + Text: "May", + CallbackData: callbackQueryDataPerion("May"), }, - tgbotapi.KeyboardButton{ - Text: "June", + { + Text: "June", + CallbackData: callbackQueryDataPerion("June"), }, } - months2 := []tgbotapi.KeyboardButton{ - tgbotapi.KeyboardButton{ - Text: "July", + months2 := []tgbotapi.InlineKeyboardButton{ + { + Text: "July", + CallbackData: callbackQueryDataPerion("July"), }, - tgbotapi.KeyboardButton{ - Text: "August", + { + Text: "August", + CallbackData: callbackQueryDataPerion("August"), }, - tgbotapi.KeyboardButton{ - Text: "September", + { + Text: "September", + CallbackData: callbackQueryDataPerion("September"), }, - tgbotapi.KeyboardButton{ - Text: "October", + { + Text: "October", + CallbackData: callbackQueryDataPerion("October"), }, - tgbotapi.KeyboardButton{ - Text: "November", + { + Text: "November", + CallbackData: callbackQueryDataPerion("November"), }, - tgbotapi.KeyboardButton{ - Text: "December", + { + Text: "December", + CallbackData: callbackQueryDataPerion("December"), }, } @@ -287,14 +340,13 @@ func (r report) GetKeyboardMessageConfig(update tgbotapi.Update) tgbotapi.Messag months2 = months2[:month-6] } - replyKeyboard := tgbotapi.NewReplyKeyboard(custom, months, months2) - replyKeyboard.OneTimeKeyboard = true + inlineKeyboardMarkup := tgbotapi.NewInlineKeyboardMarkup(custom, months, months2) - messageConfig := tgbotapi.MessageConfig{} - messageConfig.Text = "Selecte a month" - messageConfig.ChatID = update.Message.Chat.ID - messageConfig.ReplyToMessageID = update.Message.MessageID - messageConfig.ReplyMarkup = replyKeyboard + messageConfig := tgbotapi.EditMessageTextConfig{} + messageConfig.Text = "Виберіть період" + messageConfig.ChatID = tgMessage.Chat.ID + messageConfig.MessageID = tgMessage.MessageID + messageConfig.ReplyMarkup = &inlineKeyboardMarkup return messageConfig } @@ -324,3 +376,7 @@ func (r *report) ResetLastData() { } } } + +func (r *report) IsAccount(accountId string) bool { + return r.accountId == accountId +} diff --git a/template.go b/template.go index 744add6..39b2a36 100644 --- a/template.go +++ b/template.go @@ -2,26 +2,31 @@ package main import ( "fmt" + "html" "html/template" ) // Statement template, use the StatementItem structure and Name field var statementTemplate = ` {{ .Name }} -{{ getIcon .StatementItem }} {{ normalizePrice .StatementItem.Amount }}{{if .StatementItem.CashbackAmount }}, Кешбек: {{ normalizePrice .StatementItem.CashbackAmount }}{{end}} -{{ .StatementItem.Description }}{{if .StatementItem.Comment }} -Коментар: {{ .StatementItem.Comment }}{{end}} -Баланс: {{ normalizePrice .StatementItem.Balance }}` +{{ getIcon .StatementItem }} {{ normalizePrice .StatementItem.Amount }}{{ getCurrencySymbol .Account.CurrencyCode }}{{ if ne .StatementItem.Amount .StatementItem.OperationAmount }} ({{ normalizePrice .StatementItem.OperationAmount }}{{ getCurrencySymbol .StatementItem.CurrencyCode }}){{end}}{{if .StatementItem.CashbackAmount }}, Кешбек: {{ normalizePrice .StatementItem.CashbackAmount }}{{ getCurrencySymbol .StatementItem.CurrencyCode }}{{end}} +{{ unescapeString .StatementItem.Description }}{{if .StatementItem.Comment }} +Коментар: {{ unescapeString .StatementItem.Comment }}{{end}} +Баланс: {{ normalizePrice .StatementItem.Balance }}{{ getCurrencySymbol .Account.CurrencyCode }}` // Balance template, use the Account structure -var balanceTemplate = `Баланс: {{ normalizePrice .Balance }}` +var balanceTemplate = `{{ .Name }} + +{{range $item := .Accounts }}- {{ .Type }} +Баланс: {{ normalizePrice $item.Balance }}{{ getCurrencySymbol $item.CurrencyCode }} +{{end}}` // Report template, Use the ReportPage structure -var reportPageTemplate = `Витрачено: {{ normalizePrice .SpentTotal }}, Кешбек: {{ normalizePrice .CashbackAmountTotal }} +var reportPageTemplate = `Витрачено: {{ normalizePrice .SpentTotal }}{{ getCurrencySymbol .CurrencyCode }}, Кешбек: {{ normalizePrice .CashbackAmountTotal }}{{ getCurrencySymbol .CurrencyCode }} -{{range $item := .StatementItems }}{{ getIcon $item }} {{ normalizePrice $item.Amount }}{{if $item.CashbackAmount }}, Кешбек: {{ normalizePrice $item.CashbackAmount }}{{end}} -{{ $item.Description }}{{if $item.Comment }} -Коментар: {{ $item.Comment }}{{end}} -Баланс: {{ normalizePrice $item.Balance }} +{{range $item := .StatementItems }}{{ getIcon $item }} {{ normalizePrice $item.Amount }}{{ getCurrencySymbol .CurrencyCode }} {{ if ne $item.Amount $item.OperationAmount }} ({{ normalizePrice $item.OperationAmount }}{{ getCurrencySymbol $item.CurrencyCode }}){{end}}{{if $item.CashbackAmount }}, Кешбек: {{ normalizePrice $item.CashbackAmount }}{{ getCurrencySymbol $item.CurrencyCode }}{{end}} +{{ unescapeString $item.Description }}{{if $item.Comment }} +Коментар: {{ unescapeString $item.Comment }}{{end}} +Баланс: {{ normalizePrice $item.Balance }}{{ getCurrencySymbol $item.CurrencyCode }} {{end}}` @@ -45,19 +50,23 @@ var mccIconMap = map[int]string{ 5912: "💊", } +// currencySymbolMap is map to help converting currency code to Symbol +var currencySymbolMap = map[int]string{ + 980: "₴", + 840: "$", + 978: "€", + 985: "zł", + 203: "Kč", +} + // GetTempate is a function to parse template with functions func GetTempate(templateBody string) (*template.Template, error) { return template.New("message"). Funcs(template.FuncMap{ - "normalizePrice": func(price int) string { - if price%100 == 0 { - return fmt.Sprintf("%d₴", price/100) - } - return fmt.Sprintf("%.2f₴", float64(price)/100.0) - }, - "getIcon": func(statementItem StatementItem) string { - return GetIconByStatementItem(statementItem) - }, + "normalizePrice": NormalizePrice, + "getIcon": GetIconByStatementItem, + "getCurrencySymbol": GetCurrencySymbol, + "unescapeString": html.UnescapeString, }). Parse(templateBody) } @@ -78,9 +87,28 @@ func GetIconByStatementItem(statementItem StatementItem) string { mccIcon, ok := mccIconMap[statementItem.Mcc] if ok { - // defoult emoji icon = mccIcon } return icon } + +// GetCurrencySymbol is a function get currency symbol by code +func GetCurrencySymbol(currencyCode int) string { + symbol := "" + + currencySymbol, ok := currencySymbolMap[currencyCode] + if ok { + symbol = currencySymbol + } + + return symbol +} + +// NormalizePrice is a function to normalize price +func NormalizePrice(price int) string { + if price%100 == 0 { + return fmt.Sprintf("%d", price/100) + } + return fmt.Sprintf("%.2f", float64(price)/100.0) +} diff --git a/tools.go b/tools.go index 9cabf05..771454d 100644 --- a/tools.go +++ b/tools.go @@ -1,14 +1,21 @@ package main import ( + "encoding/json" "errors" "fmt" + "io/ioutil" + "net/http" "net/url" "strconv" "strings" "time" tgbotapi "github.com/go-telegram-bot-api/telegram-bot-api" + "github.com/rs/zerolog/log" + + "github.com/snabb/isoweek" + "golang.org/x/exp/constraints" ) func getPaginateButtons(total int, page int, limit int, data string) []tgbotapi.InlineKeyboardButton { @@ -32,7 +39,7 @@ func getPaginateButtons(total int, page int, limit int, data string) []tgbotapi. pageCallbackData := fmt.Sprintf("%s1", data) buttons = append(buttons, tgbotapi.InlineKeyboardButton{ - Text: fmt.Sprintf("«1"), + Text: "«1", CallbackData: &pageCallbackData, }) } else if page > 3 && page > totalPages-2 { @@ -41,7 +48,7 @@ func getPaginateButtons(total int, page int, limit int, data string) []tgbotapi. pageCallbackData := fmt.Sprintf("%s1", data) buttons = append(buttons, tgbotapi.InlineKeyboardButton{ - Text: fmt.Sprintf("«1"), + Text: "«1", CallbackData: &pageCallbackData, }) } else if page > 3 { @@ -50,7 +57,7 @@ func getPaginateButtons(total int, page int, limit int, data string) []tgbotapi. pageCallbackData := fmt.Sprintf("%s1", data) buttons = append(buttons, tgbotapi.InlineKeyboardButton{ - Text: fmt.Sprintf("«1"), + Text: "«1", CallbackData: &pageCallbackData, }) } @@ -105,7 +112,7 @@ func getPaginateButtons(total int, page int, limit int, data string) []tgbotapi. return buttons } -func abs(n int) int { +func abs[T constraints.Integer | constraints.Float](n T) T { if n < 0 { return -n } @@ -117,7 +124,7 @@ func getTimeRangeByPeriod(period string) (int64, int64, error) { period, ok := reportCommant[period] if !ok { - return from, to, errors.New("Incorrect period") + return from, to, errors.New("incorrect period") } kiev, err := time.LoadLocation("Europe/Kiev") @@ -132,32 +139,26 @@ func getTimeRangeByPeriod(period string) (int64, int64, error) { case "Today": startOfDay := time.Date(year, month, day, 0, 0, 0, 0, now.Location()) from = startOfDay.Unix() - break case "This week": _, week := now.ISOWeek() - startOfWeek := firstDayOfISOWeek(year, week, now.Location()) + startOfWeek := isoweek.StartTime(year, week, now.Location()) from = startOfWeek.Unix() - break case "Last week": _, week := now.ISOWeek() - startOfLastWeek := firstDayOfISOWeek(year, week-1, now.Location()) + startOfLastWeek := isoweek.StartTime(year, week-1, now.Location()) from = startOfLastWeek.Unix() - endOfLastWeek := firstDayOfISOWeek(year, week, now.Location()) + endOfLastWeek := isoweek.StartTime(year, week, now.Location()) to = endOfLastWeek.Unix() - break case "This month": startOfMonth := time.Date(year, month, 1, 0, 0, 0, 0, now.Location()) from = startOfMonth.Unix() - - break case "Last month": startOfMonth := time.Date(year, month-1, 1, 0, 0, 0, 0, now.Location()) from = startOfMonth.Unix() endOfMonth := time.Date(year, month, 1, 0, 0, 0, 0, now.Location()) to = endOfMonth.Unix() - break default: numberOfMonth, err := strconv.Atoi(period) if err != nil { @@ -174,26 +175,9 @@ func getTimeRangeByPeriod(period string) (int64, int64, error) { return from, to, nil } -func firstDayOfISOWeek(year int, week int, timezone *time.Location) time.Time { - date := time.Date(year, 0, 0, 0, 0, 0, 0, timezone) - isoYear, isoWeek := date.ISOWeek() - for date.Weekday() != time.Monday { // iterate back to Monday - date = date.AddDate(0, 0, -1) - isoYear, isoWeek = date.ISOWeek() - } - for isoYear < year { // iterate forward to the first day of the first week - date = date.AddDate(0, 0, 1) - isoYear, isoWeek = date.ISOWeek() - } - for isoWeek < week { // iterate forward to the first day of the given week - date = date.AddDate(0, 0, 1) - isoYear, isoWeek = date.ISOWeek() - } - return date -} - type pageData struct { Page int + Account string FromID int ChatID int64 Period string @@ -207,30 +191,29 @@ func callbackQueryDataParser(data string) pageData { // not checking errors because it always will be correct numbers period := arr[1] - fromID, _ := strconv.Atoi(arr[2]) - chatID, _ := strconv.ParseInt(arr[3], 10, 64) clientID, _ := strconv.ParseUint(arr[4], 10, 32) - page, _ := strconv.Atoi(arr[5]) + account := arr[5] + page, _ := strconv.Atoi(arr[6]) return pageData{ Page: page, Period: period, - FromID: fromID, - ChatID: chatID, ClientID: uint32(clientID), + Account: account, } } -func callbackQueryDataBulder(prefix string, data pageData) string { +func callbackQueryDataBuilder(prefix string, data pageData) string { // prefix + Period + FromID + ChatID + clientID + page // example: r:1:12321324:312234234:23423432:1 - return fmt.Sprintf("%s%s:%d:%d:%d:", + return fmt.Sprintf("%s:%s:%d:%d:%d:%s:", prefix, data.Period, - data.FromID, - data.ChatID, + 0, // data.FromID, + 0, // data.ChatID, data.ClientID, + data.Account, //data.Page, ) } @@ -240,3 +223,26 @@ func IsURL(str string) bool { u, err := url.Parse(str) return err == nil && u.Scheme != "" && u.Host != "" } + +func DoRequest[D any](data D, req *http.Request) (D, error) { + res, err := http.DefaultClient.Do(req) + if err != nil { + log.Error().Err(err).Msg("[DoRequest] request") + return data, err + } + + defer res.Body.Close() + body, err := ioutil.ReadAll(res.Body) + if err != nil { + log.Error().Err(err).Msg("[DoRequest] read body") + return data, err + } + + if err := json.Unmarshal(body, &data); err != nil { + log.Error().Err(err).Msg("[DoRequest] unmarshal") + return data, err + } + + log.Debug().Msgf("[DoRequest] responce %s", string(body)) + return data, nil +}