diff --git a/.env.dev b/.env.dev index 81ca400..cdaf366 100644 --- a/.env.dev +++ b/.env.dev @@ -7,6 +7,8 @@ TELEGRAM_TOKEN= MONO_TOKENS= TELEGRAM_ADMINS= TELEGRAM_CHATS= +# +SCHEDULE_TIME= 0 21 * * * # More info https://github.com/rs/zerolog#leveled-logging LOG_LEVEL=info diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 186b14e..96891ce 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -17,7 +17,7 @@ jobs: steps: - uses: actions/setup-go@v3 with: - go-version: 1.18.x + go-version: 1.21.x - uses: actions/checkout@v3 diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 663173a..58466c5 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -8,7 +8,7 @@ jobs: steps: - uses: actions/setup-go@v3 with: - go-version: 1.18.x + go-version: 1.21.x - uses: actions/checkout@v3 - uses: actions/cache@v3 diff --git a/Dockerfile b/Dockerfile index faba789..69a874b 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,5 +1,5 @@ # builder -FROM golang:1.18-alpine as builder +FROM golang:1.21-alpine as builder WORKDIR / diff --git a/README.md b/README.md index 6e65b5c..c1dd50a 100644 --- a/README.md +++ b/README.md @@ -10,6 +10,8 @@ A simple telegram bot, written in Go with the [telegram-bot-api](https://github. ![mono_personal_tgbot](Resources/screenshot.png) +![mono_personal_tgbot](Resources/screenshot1.png) + ## Usage Run `mono_personal_tgbot` execution file in your terminal with following env variables @@ -19,6 +21,7 @@ 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` +`SCHEDULE_TIME` | set time for daily report, example: `0 21 * * *` `MONO_TOKENS` | [How to get monobank token](https://api.monobank.ua/) ### Telegram commands diff --git a/Resources/screenshot1.png b/Resources/screenshot1.png new file mode 100644 index 0000000..90f94b9 Binary files /dev/null and b/Resources/screenshot1.png differ diff --git a/app.go b/app.go index 1a542cf..a28cf04 100644 --- a/app.go +++ b/app.go @@ -32,9 +32,23 @@ func main() { log.Panic().Err(err) } + // init Schedule Report + isScheduleReportEnabled := os.Getenv("SCHEDULE_TIME") != "" + var scheduleReport *ScheduleReport + if isScheduleReportEnabled { + scheduleReport, err = NewScheduleReport(os.Getenv("SCHEDULE_TIME")) + if err != nil { + log.Panic().Err(err) + } + } + go bot.TelegramStart(os.Getenv("TELEGRAM_TOKEN")) go bot.ProcessingStart() + if isScheduleReportEnabled { + go scheduleReport.Start(bot.ScheduleReport) + } + // run http server bot.WebhookStart() } diff --git a/bot.go b/bot.go index 6836e6d..599a860 100644 --- a/bot.go +++ b/bot.go @@ -2,6 +2,7 @@ package main import ( "bytes" + "context" "encoding/json" "errors" "fmt" @@ -11,6 +12,7 @@ import ( "reflect" "strconv" "strings" + "time" "github.com/rs/zerolog/log" @@ -32,6 +34,7 @@ type Bot interface { TelegramStart(token string) WebhookStart() ProcessingStart() + ScheduleReport(ctx context.Context) (int, error) } // bot is implementation the Bot interface @@ -47,6 +50,8 @@ type bot struct { statementTmpl *template.Template balanceTmpl *template.Template webhookTmpl *template.Template + + mono *Mono } // New returns a bot object. @@ -76,6 +81,7 @@ func New(telegramAdmins, telegramChats string) Bot { statementTmpl: statementTmpl, balanceTmpl: balanceTmpl, webhookTmpl: webhookTmpl, + mono: NewMono(), } return &b @@ -90,7 +96,7 @@ func (b *bot) InitMonoClients(monoTokens string) error { clients := make([]Client, 0, len(monoTokensArr)) for _, monoToken := range monoTokensArr { - client := NewClient(monoToken) + client := NewClient(monoToken, b.mono) if err := client.Init(); err != nil { return err } @@ -448,7 +454,12 @@ func (b *bot) WebhookStart() { fmt.Fprintf(w, "Ok!") }) - err := http.ListenAndServe(":8080", nil) + server := &http.Server{ + Addr: ":8080", + ReadHeaderTimeout: 5 * time.Minute, + } + + err := server.ListenAndServe() if err != nil { log.Panic().Err(err).Msg("[webhook] serve") } @@ -457,23 +468,6 @@ func (b *bot) WebhookStart() { // ProcessingStart starts processing data that received from chennal. func (b *bot) ProcessingStart() { - 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 { - return err - } - - _, err = b.BotAPI.Send(tgbotapi.NewMessage(chatID, message)) - if err != nil { - return err - } - } - - return nil - } - for { statementItemData := <-b.ch @@ -508,14 +502,14 @@ func (b *bot) ProcessingStart() { message := tpl.String() // to chat - err = sendTo(b.telegramChats, message) + err = b.sendTo(b.telegramChats, message) if err != nil { log.Error().Err(err).Msg("[processing] send to chat") continue } // to admin - err = sendTo(b.telegramAdmins, message) + err = b.sendTo(b.telegramAdmins, message) if err != nil { log.Error().Err(err).Msg("[processing] send to admin") continue @@ -634,6 +628,23 @@ func (b *bot) sendBalanceByClient(client Client, tgMessage *tgbotapi.Message) er return err } +func (b *bot) sendTo(chatIds, message string) error { + ids := strings.Split(strings.Trim(chatIds, " "), ",") + for _, id := range ids { + chatID, err := strconv.ParseInt(id, 10, 64) + if err != nil { + return err + } + + _, err = b.BotAPI.Send(tgbotapi.NewMessage(chatID, message)) + if err != nil { + return err + } + } + + return nil +} + 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()) diff --git a/client.go b/client.go index 3e21143..2f05025 100644 --- a/client.go +++ b/client.go @@ -2,15 +2,9 @@ package main import ( "errors" - "fmt" "hash/fnv" - "net/http" - "strings" - "time" "github.com/rs/zerolog/log" - - "golang.org/x/time/rate" ) // StatementItem is a statement data @@ -73,26 +67,27 @@ type client struct { Info *ClientInfo id uint32 token string - limiter *rate.Limiter reports map[string]Report + mono *Mono } // NewClient returns a client object. -func NewClient(token string) Client { +func NewClient(token string, mono *Mono) Client { h := fnv.New32a() h.Write([]byte(token)) return &client{ - limiter: rate.NewLimiter(rate.Every(time.Second*30), 1), token: token, id: h.Sum32(), reports: make(map[string]Report), + mono: mono, } } func (c *client) Init() error { - _, err := c.GetInfo() + info, err := c.GetInfo() + c.Info = &info return err } @@ -109,19 +104,8 @@ func (c *client) GetReport(accountId string) Report { } func (c *client) GetInfo() (ClientInfo, error) { - if c.limiter.Allow() { - log.Debug().Msg("[monoapi] get info") - info, err := c.getClientInfo() - c.Info = &info - return info, err - } - - if c.Info != nil { - return *c.Info, nil - } - - log.Warn().Msg("[monoapi] get info, waiting") - return ClientInfo{}, errors.New("please waiting and then try again") + log.Debug().Msg("[monoapi] get info") + return c.mono.GetClientInfo(c.token) } // GetName return name of the client @@ -134,20 +118,7 @@ func (c client) GetName() string { // SetWebHook is a function set up the monobank webhook. func (c client) SetWebHook(url string) (WebHookResponse, error) { - response := WebHookResponse{} - - payload := strings.NewReader(fmt.Sprintf("{\"webHookUrl\": \"%s\"}", url)) - - req, err := http.NewRequest("POST", "https://api.monobank.ua/personal/webhook", payload) - if err != nil { - 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") - - return DoRequest(response, req) + return c.mono.SetWebHook(url, c.token) } func (c *client) GetAccountByID(id string) (*Account, error) { @@ -167,53 +138,5 @@ func (c *client) ResetReport(accountId string) { } func (c client) GetStatement(command string, accountId string) ([]StatementItem, error) { - if c.limiter.Allow() { - return c.getStatement(command, accountId) - } - - log.Warn().Msg("[monoapi] statement, waiting") - return []StatementItem{}, errors.New("please waiting and then try again") -} - -func (c client) getStatement(command, account string) ([]StatementItem, error) { - - statementItems := []StatementItem{} - - from, to, err := getTimeRangeByPeriod(command) - if err != nil { - log.Error().Err(err).Msg("[monoapi] statements, range") - return statementItems, err - } - - log.Debug().Msgf("[monoapi] statements, range from: %d, to: %d", from, to) - - 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.Error().Err(err).Msg("[monoapi] statements, NewRequest") - return statementItems, err - } - - req.Header.Add("x-token", c.token) - - return DoRequest(statementItems, req) -} - -func (c client) getClientInfo() (ClientInfo, error) { - var clientInfo ClientInfo - - url := "https://api.monobank.ua/personal/client-info" - req, err := http.NewRequest("GET", url, nil) - if err != nil { - log.Error().Err(err).Msg("[monoapi] client info, create request") - return clientInfo, err - } - - req.Header.Add("x-token", c.token) - - return DoRequest(clientInfo, req) + return c.mono.GetStatement(command, accountId, c.token) } diff --git a/docker-compose.yml b/docker-compose.yml index 74505ed..44726bb 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -10,9 +10,14 @@ services: dockerfile: Dockerfile environment: - TELEGRAM_TOKEN=${TELEGRAM_TOKEN} - - MONO_TOKEN=${MONO_TOKEN} + - MONO_TOKENS=${MONO_TOKENS} - TELEGRAM_ADMINS=${TELEGRAM_ADMINS} - TELEGRAM_CHATS=${TELEGRAM_CHATS} - LOG_LEVEL=${LOG_LEVEL} + - SCHEDULE_TIME=${SCHEDULE_TIME} ports: - ${APP_PORT}:8080 + logging: + options: + max-file: 5 + max-size: 15m diff --git a/go.mod b/go.mod index f2db7b9..8314ad5 100644 --- a/go.mod +++ b/go.mod @@ -8,6 +8,11 @@ require ( golang.org/x/time v0.0.0-20190308202827-9d24e82272b4 ) +require ( + github.com/adhocore/gronx v1.6.5 // indirect + github.com/samber/lo v1.38.1 // indirect +) + require ( github.com/mattn/go-colorable v0.1.12 // indirect github.com/mattn/go-isatty v0.0.14 // indirect diff --git a/go.sum b/go.sum index ff4a832..7a7a9c7 100644 --- a/go.sum +++ b/go.sum @@ -1,3 +1,5 @@ +github.com/adhocore/gronx v1.6.5 h1:/pryEagBKz3WqUgpgvtL51eBN2rJLXowuW7rpS+jrew= +github.com/adhocore/gronx v1.6.5/go.mod h1:7oUY1WAU8rEJWmAxXR2DN0JaO4gi9khSgKjiRypqteg= 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= @@ -11,6 +13,8 @@ github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINE 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/samber/lo v1.38.1 h1:j2XEAqXKb09Am4ebOg31SpvzUTTs6EN3VfgeLUhPdXM= +github.com/samber/lo v1.38.1/go.mod h1:+m/ZKRl6ClXCE2Lgf3MsQlWfh4bn1bz6CXEOxnEXnEA= 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= diff --git a/mono.go b/mono.go new file mode 100644 index 0000000..92070c2 --- /dev/null +++ b/mono.go @@ -0,0 +1,128 @@ +package main + +import ( + "fmt" + "net/http" + "strings" + "time" + + "github.com/rs/zerolog/log" + "golang.org/x/time/rate" +) + +type Currency struct { + CurrencyCodeA int `json:"currencyCodeA"` + CurrencyCodeB int `json:"currencyCodeB"` + Date int `json:"date"` + RateBuy float64 `json:"rateBuy"` + RateCross float64 `json:"rateCross"` + RateSell float64 `json:"rateSell"` +} + +type Currencies []Currency + +type Mono struct { + limiter *rate.Limiter + limiter2 *rate.Limiter + limiter3 *rate.Limiter + limiter4 *rate.Limiter +} + +// NewMono returns a mono object. +func NewMono() *Mono { + return &Mono{ + limiter: rate.NewLimiter(rate.Every(time.Second*60), 1), + limiter2: rate.NewLimiter(rate.Every(time.Second*60), 1), + limiter3: rate.NewLimiter(rate.Every(time.Second*60), 1), + limiter4: rate.NewLimiter(rate.Every(time.Second*60), 1), + } +} + +// SetWebHook is a function set up the monobank webhook. +func (c Mono) SetWebHook(url, token string) (WebHookResponse, error) { + if !c.limiter.Allow() { + time.Sleep(61 * time.Second) + } + + response := WebHookResponse{} + + payload := strings.NewReader(fmt.Sprintf("{\"webHookUrl\": \"%s\"}", url)) + + req, err := http.NewRequest("POST", "https://api.monobank.ua/personal/webhook", payload) + if err != nil { + log.Error().Err(err).Msg("[monoapi] webhook, NewRequest") + return response, err + } + + req.Header.Add("X-Token", token) + req.Header.Add("content-type", "application/json") + + return DoRequest(response, req) +} + +func (c Mono) GetStatement(command, account string, token string) ([]StatementItem, error) { + if !c.limiter2.Allow() { + time.Sleep(61 * time.Second) + } + + statementItems := []StatementItem{} + + from, to, err := getTimeRangeByPeriod(command) + if err != nil { + log.Error().Err(err).Msg("[monoapi] statements, range") + return statementItems, err + } + + log.Debug().Msgf("[monoapi] statements, range from: %d, to: %d", from, to) + + 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.Error().Err(err).Msg("[monoapi] statements, NewRequest") + return statementItems, err + } + + req.Header.Add("x-token", token) + + return DoRequest(statementItems, req) +} + +func (c Mono) GetClientInfo(token string) (ClientInfo, error) { + if !c.limiter3.Allow() { + time.Sleep(61 * time.Second) + } + + var clientInfo ClientInfo + + url := "https://api.monobank.ua/personal/client-info" + req, err := http.NewRequest("GET", url, nil) + if err != nil { + log.Error().Err(err).Msg("[monoapi] client info, create request") + return clientInfo, err + } + + req.Header.Add("x-token", token) + + return DoRequest(clientInfo, req) +} + +func (c Mono) GetCurrencies() (Currencies, error) { + if !c.limiter4.Allow() { + time.Sleep(61 * time.Second) + } + + var currencies Currencies + + url := "https://api.monobank.ua/bank/currency" + req, err := http.NewRequest("GET", url, nil) + if err != nil { + log.Error().Err(err).Msg("[monoapi] currency") + return currencies, err + } + + return DoRequest(currencies, req) +} diff --git a/schedule_report.go b/schedule_report.go new file mode 100644 index 0000000..7584cc0 --- /dev/null +++ b/schedule_report.go @@ -0,0 +1,40 @@ +package main + +import ( + "context" + "errors" + + "github.com/adhocore/gronx" + "github.com/adhocore/gronx/pkg/tasker" +) + +type ScheduleReport struct { + cron *gronx.Gronx + Taskr *tasker.Tasker + scheduleTime string +} + +func NewScheduleReport(scheduleTime string) (*ScheduleReport, error) { + gron := gronx.New() + if !gron.IsValid(scheduleTime) { + return nil, errors.New("incorrect expression") + } + + taskr := tasker.New(tasker.Option{ + Verbose: true, + Tz: "Europe/Kyiv", + }) + + return &ScheduleReport{ + cron: &gron, + Taskr: taskr, + scheduleTime: scheduleTime, + }, nil +} + +func (s *ScheduleReport) Start(f func(ctx context.Context) (int, error)) { + // add task to run every minute + s.Taskr.Task(s.scheduleTime, f) + + s.Taskr.Run() +} diff --git a/schedule_report_data.go b/schedule_report_data.go new file mode 100644 index 0000000..7695a6c --- /dev/null +++ b/schedule_report_data.go @@ -0,0 +1,183 @@ +package main + +import ( + "bytes" + "context" + "fmt" + + "github.com/rs/zerolog/log" + "github.com/samber/lo" +) + +type ScheduleReportData struct { + ClientInfo ClientInfo + StatementItems []StatementItem + Sum int + CashbackSum int + Count int + Currencies Currencies +} + +func (s *ScheduleReportData) IsEmpty() bool { + return s.StatementItems == nil || len(s.StatementItems) == 0 +} + +func (s *ScheduleReportData) Prepare() { + if s.IsEmpty() { + return + } + + _, ignoreAll := s.filterAndReduceStatements() + statements := s.filterStatements(ignoreAll) + + s.Count = len(statements) + s.Sum = s.calculateSum(statements) + s.CashbackSum = s.calculateCashbackSum(statements) +} + +func (s *ScheduleReportData) filterAndReduceStatements() (map[string]string, map[string]bool) { + fromStatements := s.filter4829Statements() + fromStatementsMap := s.reduceStatements(fromStatements) + + ignoreAll := map[string]bool{} + _ = s.filterOutFromAccounts(fromStatementsMap, ignoreAll) + + return fromStatementsMap, ignoreAll +} + +func (s *ScheduleReportData) filter4829Statements() []StatementItem { + return lo.Filter[StatementItem](s.StatementItems, func(item StatementItem, index int) bool { + return item.Mcc == 4829 && item.OriginalMcc == 4829 && item.Amount < 0 && item.OperationAmount < 0 && item.Amount != item.OperationAmount + }) +} + +func (s *ScheduleReportData) reduceStatements(fromStatements []StatementItem) map[string]string { + return lo.Reduce[StatementItem, map[string]string](fromStatements, func(agg map[string]string, item StatementItem, index int) map[string]string { + agg[fmt.Sprintf("%d %d %d %d", item.Mcc, item.OriginalMcc, -item.Amount, -item.OperationAmount)] = item.ID + return agg + }, map[string]string{}) +} + +func (s *ScheduleReportData) filterOutFromAccounts(fromStatementsMap map[string]string, ignoreAll map[string]bool) []StatementItem { + return lo.Filter[StatementItem](s.StatementItems, func(item StatementItem, index int) bool { + key := fmt.Sprintf("%d %d %d %d", item.Mcc, item.OriginalMcc, item.OperationAmount, item.Amount) + + id, ok := fromStatementsMap[key] + if ok { + ignoreAll[item.ID] = true + ignoreAll[id] = true + } + return !ok + }) +} + +func (s *ScheduleReportData) filterStatements(ignoreAll map[string]bool) []StatementItem { + return lo.Filter[StatementItem](s.StatementItems, func(item StatementItem, index int) bool { + _, ok := ignoreAll[item.ID] + return !ok + }) +} + +func (s *ScheduleReportData) calculateSum(statements []StatementItem) int { + return lo.Reduce[StatementItem, int](statements, func(agg int, item StatementItem, index int) int { + if item.Amount < 0 { + agg += s.calculateAmount(item) + } + return agg + }, 0) +} + +func (s *ScheduleReportData) calculateAmount(item StatementItem) int { + if item.Amount == item.OperationAmount && item.CurrencyCode != 980 { + return s.calculateNonUAHAmount(item) + } else if item.Amount != item.OperationAmount && item.CurrencyCode == 980 { + return -item.OperationAmount + } + return -item.Amount +} + +func (s *ScheduleReportData) calculateNonUAHAmount(item StatementItem) int { + currency, ok := lo.Find[Currency](s.Currencies, func(citem Currency) bool { + return citem.CurrencyCodeA == item.CurrencyCode && citem.CurrencyCodeB == 980 + }) + if ok { + return -item.Amount * (currency.CurrencyCodeA * 100) + } + return 0 +} + +func (s *ScheduleReportData) calculateCashbackSum(statements []StatementItem) int { + return lo.Reduce[StatementItem, int](statements, func(agg int, item StatementItem, index int) int { + agg = agg + item.CashbackAmount + return agg + }, 0) +} + +func (b *bot) ScheduleReport(ctx context.Context) (int, error) { + if len(b.clients) == 0 { + return 0, nil + } + + tmpl, err := GetTempate(scheduleReportTemplate) + if err != nil { + log.Fatal().Err(err).Msg("[template] error") + } + + currencies, err := b.mono.GetCurrencies() + if err != nil { + log.Err(err) + } + + for _, client := range b.clients { + scheduleReportData := ScheduleReportData{ + StatementItems: []StatementItem{}, + Currencies: currencies, + } + + info, err := client.GetInfo() + if err != nil { + log.Err(err) + continue + } + + scheduleReportData.ClientInfo = info + + for _, account := range info.Accounts { + items, err := client.GetStatement("Today", account.ID) + if err != nil { + log.Error().Err(err).Msg("[monobank] report, get statements") + continue + } + scheduleReportData.StatementItems = append(scheduleReportData.StatementItems, items...) + } + + if scheduleReportData.IsEmpty() { + log.Info().Msg("[monobank] schedule report") + continue + } + + scheduleReportData.Prepare() + + if scheduleReportData.Count == 0 || scheduleReportData.Sum == 0 { + log.Info().Msg("[monobank] schedule report, empty after filter") + continue + } + + var tpl bytes.Buffer + err = tmpl.Execute(&tpl, scheduleReportData) + if err != nil { + log.Error().Err(err).Msg("[processing] template execute error") + continue + } + message := tpl.String() + + // to chat + err = b.sendTo(b.telegramChats, message) + if err != nil { + log.Error().Err(err).Msg("[processing] send to chat") + continue + } + } + + return 0, nil +} diff --git a/template.go b/template.go index 39b2a36..8e51ee7 100644 --- a/template.go +++ b/template.go @@ -30,6 +30,13 @@ var reportPageTemplate = `Витрачено: {{ normalizePrice .SpentTotal }}{{ {{end}}` +// Schedule Report template, Use the ScheduleReportData structure +var scheduleReportTemplate = `Щоденна статистика рахунків, {{ .ClientInfo.Name }} + +Витрачено: {{ normalizePrice .Sum }} UAH +Кешбек: {{ normalizePrice .CashbackSum }} UAH +Транзакцій: {{ .Count }}` + // WebHook template, use the ClientInfo structure var webhookTemplate = `Вебхук: {{if .WebHookURL }}{{ .WebHookURL }}{{else}} Відсутній {{end}}` diff --git a/tools.go b/tools.go index 771454d..da461d8 100644 --- a/tools.go +++ b/tools.go @@ -1,12 +1,16 @@ package main import ( + "bytes" + "encoding/gob" "encoding/json" "errors" "fmt" "io/ioutil" "net/http" "net/url" + "os" + "path/filepath" "strconv" "strings" "time" @@ -127,12 +131,12 @@ func getTimeRangeByPeriod(period string) (int64, int64, error) { return from, to, errors.New("incorrect period") } - kiev, err := time.LoadLocation("Europe/Kiev") + kyiv, err := time.LoadLocation("Europe/Kyiv") if err != nil { return from, to, err } - now := time.Now().In(kiev) + now := time.Now().In(kyiv) year, month, day := now.Date() switch period { @@ -246,3 +250,40 @@ func DoRequest[D any](data D, req *http.Request) (D, error) { log.Debug().Msgf("[DoRequest] responce %s", string(body)) return data, nil } + +func dumpToFile[T any](filePath string, data T) error { + f, err := os.Create(filepath.Clean(filePath)) + if err != nil { + return err + } + defer f.Close() + + var buf bytes.Buffer + enc := gob.NewEncoder(&buf) + + err = enc.Encode(data) + if err != nil { + return err + } + _, err = f.Write(buf.Bytes()) + return err +} + +func dumpFromFile[T any](filePath string) (*T, error) { + f, err := os.Open(filepath.Clean(filePath)) + if err != nil { + return nil, err + } + defer f.Close() + + var buf bytes.Buffer + _, err = buf.ReadFrom(f) + if err != nil { + return nil, err + } + dec := gob.NewDecoder(&buf) + + data := new(T) + err = dec.Decode(data) + return data, err +}