Files
go-receipt-tracker/app/app.go
2025-02-03 17:50:47 -05:00

494 lines
19 KiB
Go

package app
import (
"context"
"encoding/json"
"errors"
"fmt"
"log/slog"
"runtime"
"strings"
"sync/atomic"
"time"
"github.com/uptrace/bun"
tele "gopkg.in/telebot.v4"
"git.savin.nyc/alex/go-receipt-tracker/bus"
"git.savin.nyc/alex/go-receipt-tracker/config"
"git.savin.nyc/alex/go-receipt-tracker/listeners"
"git.savin.nyc/alex/go-receipt-tracker/models"
"git.savin.nyc/alex/go-receipt-tracker/system"
"git.savin.nyc/alex/go-receipt-tracker/workers"
)
const (
AppVersion = "1.0.0" // the current application version.
AppName = "go-receipt-tracker" // the short app name
)
var (
ErrWorkerIDExists = errors.New("worker id already exists")
ErrListenerIDExists = errors.New("listener id already exists")
)
type App struct {
Options *config.Config // configurable server options
Workers *workers.Workers // worker are network interfaces which listen for new connections
Listeners *listeners.Listeners // listeners are network interfaces which listen for new connections
Info *system.Info // values about the server commonly known as $SYS topics
Log *slog.Logger // minimal no-alloc logger
db *bun.DB //
subscriptions map[string]chan bus.Event //
bus *bus.Bus //
loop *loop // loop contains tickers for the system event loop
cancel context.CancelFunc //
hooks *Hooks // hooks contains hooks for extra functionality such as auth and persistent storage
// done chan bool // indicate that the server is ending
}
// loop contains interval tickers for the system events loop.
type loop struct {
sysInfo *time.Ticker // interval ticker for sending updating $SYS topics
}
type Info struct {
AppVersion string `json:"version"` // the current version of the server
Started int64 `json:"started"` // the time the server started in unix seconds
MemoryAlloc int64 `json:"memory_alloc"` // memory currently allocated
Threads int64 `json:"threads"` // number of active goroutines, named as threads for platform ambiguity
Time int64 `json:"time"` // current time on the server
Uptime int64 `json:"uptime"` // the number of seconds the server has been online
}
// EstablishConnection establishes a new client when a listener accepts a new connection.
// func (a *App) EstablishConnection(listener string, c net.Conn) error {
// cl := a.NewClient(c, listener, "", false)
// return a.attachClient(cl, listener)
// }
// New returns a new instance of mochi mqtt broker. Optional parameters
// can be specified to override some default settings (see Options).
func New(opts *config.Config, b *bus.Bus, db *bun.DB, logger *slog.Logger) *App {
if opts == nil {
slog.Error("empty config")
}
var m runtime.MemStats
runtime.ReadMemStats(&m)
a := &App{
Options: opts,
Workers: workers.New(),
Listeners: listeners.New(),
Info: &system.Info{
Version: AppVersion,
Started: time.Now().Unix(),
MemoryAlloc: int64(m.HeapInuse),
Threads: int64(runtime.NumGoroutine()),
// Time: time.Now().Unix(),
// Uptime: time.Now().Unix() - atomic.LoadInt64(&a.Started),
},
Log: logger,
db: db,
subscriptions: make(map[string]chan bus.Event),
bus: b,
hooks: &Hooks{
Log: logger,
},
loop: &loop{
sysInfo: time.NewTicker(time.Second * time.Duration(1)),
},
// done: make(chan bool),
}
return a
}
// AddHook attaches a new Hook to the server. Ideally, this should be called
// before the server is started with s.Serve().
func (a *App) AddHook(hook Hook, config any) error {
nl := a.Log.With("hook", hook.ID())
hook.SetOpts(nl)
a.Log.Info("added hook", "hook", hook.ID())
return a.hooks.Add(hook, config)
}
// AddListener adds a new network listener to the server, for receiving incoming client connections.
func (a *App) AddWorker(w workers.Worker) error {
if _, ok := a.Workers.Get(w.ID()); ok {
return ErrWorkerIDExists
}
nl := a.Log.With(slog.String("worker", w.ID()))
err := w.Init(nl)
if err != nil {
return err
}
a.Workers.Add(w)
a.Log.Info("attached worker", "id", w.ID(), "type", w.Type())
return nil
}
// AddListener adds a new network listener to the server, for receiving incoming client connections.
func (a *App) AddListener(l listeners.Listener) error {
if _, ok := a.Listeners.Get(l.ID()); ok {
return ErrListenerIDExists
}
nl := a.Log.With(slog.String("listener", l.ID()))
err := l.Init(nl)
if err != nil {
return err
}
a.Listeners.Add(l)
a.Log.Info("attached listener", "id", l.ID(), "protocol", l.Protocol(), "address", l.Address())
return nil
}
// Serve starts the event loops responsible for establishing client connections
// on all attached listeners, publishing the system topics, and starting all hooks.
func (a *App) Serve() error {
a.Log.Info(AppName+" starting", "version", AppVersion)
defer a.Log.Info(AppName + " is started")
a.subscribe("processor:user_add")
a.subscribe("processor:user_edit")
a.subscribe("processor:user_delete")
a.subscribe("processor:image")
a.subscribe("processor:receipt_add")
a.subscribe("processor:items_list")
ctx, cancel := context.WithCancel(context.Background())
a.cancel = cancel
go a.eventLoop(ctx)
a.Workers.ServeAll() // start listening on all workers.
a.Listeners.ServeAll() // start listening on all listeners.
a.publishSysTopics()
a.hooks.OnStarted()
return nil
}
// Close attempts to gracefully shut down the server, all listeners, clients, and stores.
func (a *App) Close() error {
// close(a.done)
a.cancel()
a.Log.Info("gracefully stopping " + AppName)
a.Workers.CloseAll()
a.Listeners.CloseAll()
a.hooks.OnStopped()
a.hooks.Stop()
a.Log.Info(AppName + " is stopped")
return nil
}
func (a *App) subscribe(chn string) error {
s, err := a.bus.Subscribe(chn, AppName)
if err != nil {
a.Log.Error("couldn't subscribe to a channel", "channel", chn, "error", err.Error())
return err
}
a.subscriptions[chn] = s
return nil
}
func (a *App) eventLoop(ctx context.Context) {
a.Log.Debug(AppName + " main communication event loop started")
defer a.Log.Debug(AppName + " main communication event loop halted")
for {
select {
// case time.Time:
// a.publishSysTopics()
case event := <-a.subscriptions["processor:user_add"]:
a.Log.Debug("service got a new message from a channel", "channel", event.ChannelName, "app", AppName)
msg := event.Payload.(*bus.Message)
if models.UserExists(a.db, ctx, event.Payload.(*bus.Message).TbContext.Sender().ID) {
msg.Text = "Welcome back, " + event.Payload.(*bus.Message).TbContext.Sender().FirstName + " " + event.Payload.(*bus.Message).TbContext.Sender().LastName
msg.InlineKeyboard = &tele.ReplyMarkup{
InlineKeyboard: [][]tele.InlineButton{
{
{Text: "Edit Profile", Data: models.String(&models.TelegramBotCommand{Cmd: models.BtnCmd["user_edit"], ID: msg.TbContext.Sender().ID})},
},
{
{Text: "Create Group", Data: models.String(&models.TelegramBotCommand{Cmd: models.BtnCmd["group_add"], ID: msg.TbContext.Sender().ID})},
},
{
{Text: "List my Receipts", Data: models.String(&models.TelegramBotCommand{Cmd: models.BtnCmd["receipts_list"], ID: msg.TbContext.Sender().ID})},
},
},
}
} else {
user := &models.User{
Name: fmt.Sprintf("%s %s", event.Payload.(*bus.Message).TbContext.Sender().FirstName, event.Payload.(*bus.Message).TbContext.Sender().LastName),
TelegramID: event.Payload.(*bus.Message).TbContext.Sender().ID,
TelegramUsername: event.Payload.(*bus.Message).TbContext.Sender().Username,
}
ctx := context.Background()
user.Add(a.db, ctx)
msg.Text = config.Message_welcome
}
a.Log.Debug("publishing message to a channel", "channel", "telegram:send", "app", AppName)
err := a.bus.Publish("telegram:send", msg)
if err != nil {
a.Log.Error("couldn't publish to the channel", "channel", "telegram:send", "error", err.Error())
}
case event := <-a.subscriptions["processor:user_delete"]:
a.Log.Debug("service got a new message from a channel", "channel", event.ChannelName, "app", AppName)
msg := event.Payload.(*bus.Message)
if models.UserExists(a.db, ctx, event.Payload.(*bus.Message).TbContext.Sender().ID) {
msg.Text = "Hey " + event.Payload.(*bus.Message).TbContext.Sender().FirstName + " it looks like that you would like to DELETE your account, please confirm it:\n"
msg.InlineKeyboard = &tele.ReplyMarkup{
InlineKeyboard: [][]tele.InlineButton{
{
{Text: "Yes, DELETE IT", Data: models.String(&models.TelegramBotCommand{Cmd: models.BtnCmd["user_delete"], ID: msg.TbContext.Sender().ID, RefCmd: models.BtnCmd["user_delete"], Extra: "1"})},
{Text: "No, KEEP IT", Data: models.String(&models.TelegramBotCommand{Cmd: models.BtnCmd["user_edit"], ID: msg.TbContext.Sender().ID})},
},
},
}
} else {
msg.Text = "The user does not exist"
}
a.Log.Debug("publishing message to a channel", "channel", "telegram:user_delete", "app", AppName)
err := a.bus.Publish("telegram:user_delete", msg)
if err != nil {
a.Log.Error("couldn't publish to the channel", "channel", "telegram:user_delete", "error", err.Error())
}
case event := <-a.subscriptions["processor:image"]:
a.Log.Debug("service got a new message from a channel", "channel", event.ChannelName, "app", AppName)
msg := event.Payload.(*bus.Message)
ctx := context.Background()
if !models.UserExists(a.db, ctx, event.Payload.(*bus.Message).TbContext.Sender().ID) {
a.Log.Error("user is not registered", "user", event.Payload.(*bus.Message).TbContext.Sender().ID)
}
msg.Image.Type = imgType(msg.Image.Filename)
msg.Image.Base64 = readImage(msg.Image.Filename)
msg.Image.Parsed = make(map[string]string)
err := a.bus.Publish("parser:openai", msg)
if err != nil {
a.Log.Error("couldn't publish to the channel", "channel", "parser:openai", "error", err.Error())
}
// err = a.bus.Publish("parser:gemini", msg)
// if err != nil {
// a.Log.Error("couldn't publish to the channel", "channel", "parser:gemini", "error", err.Error())
// }
// err = a.bus.Publish("parser:claude", msg)
// if err != nil {
// a.Log.Error("couldn't publish to the channel", "channel", "parser:claude", "error", err.Error())
// }
// }
// ADDING A NEWLY PARSED RECEIPT
case event := <-a.subscriptions["processor:receipt_add"]:
a.Log.Debug("service got a new message from a channel", "channel", event.ChannelName, "app", AppName)
msg := event.Payload.(*bus.Message)
if models.UserExists(a.db, ctx, event.Payload.(*bus.Message).TbContext.Sender().ID) {
user := new(models.User)
a.db.NewSelect().Model(user).Where("telegram_id = ?", event.Payload.(*bus.Message).TbContext.Sender().ID).Scan(ctx)
group := new(models.Group)
if event.Payload.(*bus.Message).TbContext.Sender().ID != event.Payload.(*bus.Message).TbContext.Chat().ID {
if models.GroupExists(a.db, ctx, event.Payload.(*bus.Message).TbContext.Chat().ID) {
a.db.NewSelect().Model(group).Where("telegram_chat_id = ?", event.Payload.(*bus.Message).TbContext.Chat().ID).Scan(ctx)
} else {
group.TelegramChatID = event.Payload.(*bus.Message).TbContext.Chat().ID
group.Add(a.db, ctx)
group.UserAdd(a.db, ctx, user.UserID)
}
}
var receipt models.Receipt
if err := json.Unmarshal([]byte(msg.Image.Parsed["openai"]), &receipt); err != nil {
a.Log.Error("cannot parse receipt json", "error", err)
}
ctx := context.Background()
receipt.UserID = user.UserID
receipt.Merchant.Add(a.db, ctx)
receipt.MerchantID = receipt.Merchant.MerchantID
receipt.CreditCard.UserID = user.UserID
receipt.CreditCard.Add(a.db, ctx)
receipt.CreditCardID = receipt.CreditCard.CreditCardID
receipt.Add(a.db, ctx)
msg.Text = "We got your receipt"
msg.InlineKeyboard = &tele.ReplyMarkup{
InlineKeyboard: [][]tele.InlineButton{
{
{Text: fmt.Sprintf("Merchant: %s", strings.Title(strings.ToLower(receipt.Merchant.Title))), Data: models.String(&models.TelegramBotCommand{Cmd: models.BtnCmd["merchant"]})},
},
{
{Text: fmt.Sprintf("Category: %s", strings.Title(strings.ToLower(receipt.Category))), Data: models.String(&models.TelegramBotCommand{Cmd: models.BtnCmd["category"]})},
},
{
{Text: fmt.Sprintf("Tax: %.2f", receipt.Tax), Data: models.String(&models.TelegramBotCommand{Cmd: models.BtnCmd["tax"], ID: receipt.ReceiptID})},
{Text: fmt.Sprintf("Total: %.2f", receipt.Total), Data: models.String(&models.TelegramBotCommand{Cmd: models.BtnCmd["total"], ID: receipt.ReceiptID})},
},
{
{Text: fmt.Sprintf("List of the receipt items (%d)", len(receipt.Items)), Data: models.String(&models.TelegramBotCommand{Cmd: models.BtnCmd["items_list"], ID: receipt.ReceiptID})},
},
},
}
a.Log.Debug("publishing message to a channel", "channel", "telegram:send", "app", AppName)
err := a.bus.Publish("telegram:send", msg)
if err != nil {
a.Log.Error("couldn't publish to the channel", "channel", "telegram:send", "error", err.Error())
}
}
case event := <-a.subscriptions["processor:items_list"]:
a.Log.Debug("service got a new message from a channel", "channel", event.ChannelName, "app", AppName)
msg := event.Payload.(*bus.Message)
if models.UserExists(a.db, ctx, event.Payload.(*bus.Message).TbContext.Sender().ID) {
user := new(models.User)
a.db.NewSelect().Model(user).Where("telegram_id = ?", event.Payload.(*bus.Message).TbContext.Sender().ID).Scan(ctx)
r := new(models.Receipt)
err := a.db.NewSelect().Model(r).Where("receipt_id = ?", event.Payload.(*bus.Message).TBCmd.ID).Scan(ctx)
if err != nil {
panic(err)
}
r.CreditCard = new(models.CreditCard)
err = a.db.NewSelect().Model(r.CreditCard).Where("credit_card_id = ?", r.CreditCardID).Scan(ctx)
if err != nil {
panic(err)
}
r.Merchant = new(models.Merchant)
err = a.db.NewSelect().Model(r.Merchant).Where("merchant_id = ?", r.MerchantID).Scan(ctx)
if err != nil {
panic(err)
}
err = a.db.NewSelect().Model(&r.Items).Where("receipt_id = ?", r.ReceiptID).Scan(ctx)
if err != nil {
panic(err)
}
message := "<code>List of the Receipt Items:\n" + strings.Repeat("-", 32) + "\n"
merchant := truncateText(r.Merchant.Title, 16)
spaces := 32 - (len("Merchant:") + len(merchant))
message += "Merchant:" + strings.Repeat(" ", spaces) + merchant + "\n"
spaces = 32 - (len("Date/Time:") + len(r.PaidAt))
message += "Date/Time:" + strings.Repeat(" ", spaces) + r.PaidAt + "\n" + strings.Repeat("-", 32) + "\n"
keyboard := &tele.ReplyMarkup{InlineKeyboard: [][]tele.InlineButton{}}
for _, item := range r.Items {
// b := tele.InlineButton{Text: fmt.Sprintf("%s (%.2f) $%.2f", strings.Title(strings.ToLower(item.Title)), item.Quantity, item.Price), Data: String(&TelegramBotCommand{Cmd: btnCmd["item_edit"], ID: item.ItemID})}
// keyboard.InlineKeyboard = append(keyboard.InlineKeyboard, []tele.InlineButton{b})
// if item.Quantity == math.Trunc(item.Quantity) {
// fmt.Sprintf("%d", math.Trunc(item.Quantity))
// } else {
// fmt.Sprintf("%.2f", item.Quantity)
// }
title := truncateText(item.Title, 16)
spaces := 32 - (len(title) + len("x"+fmt.Sprintf("%+v", item.Quantity)) + len(fmt.Sprintf("$%.2f", item.Price)) + 3)
message += title + strings.Repeat(".", spaces) + "x" + fmt.Sprintf("%+v", item.Quantity) + "..." + fmt.Sprintf("$%.2f", item.Price) + "\n"
a.Log.Debug("list of items", "title", strings.Title(strings.ToLower(item.Title)), "quantity", item.Quantity, "price", item.Price)
}
// keyboard.InlineKeyboard = append(keyboard.InlineKeyboard, []tele.InlineButton{{Text: "«", Data: String(&TelegramBotCommand{Cmd: btnPage["prev"], ID: tbc.ID})}, {Text: "»", Data: String(&TelegramBotCommand{Cmd: btnPage["next"], ID: tbc.ID})}})
keyboard.InlineKeyboard = append(keyboard.InlineKeyboard, []tele.InlineButton{{Text: "« Back to Receipt", Data: models.String(&models.TelegramBotCommand{Cmd: models.BtnCmd["receipt_edit"], ID: r.ReceiptID})}})
message += strings.Repeat("-", 32) + "\n"
spaces = 32 - (len("Tax:") + len(fmt.Sprintf("$%.2f", r.Tax)))
message += "Tax:" + strings.Repeat(" ", spaces) + fmt.Sprintf("$%.2f", r.Tax) + "\n"
if r.CCFee > 0 {
spaces = 32 - (len("CC Fee:") + len(fmt.Sprintf("$%.2f", r.CCFee)))
message += "CC Fee:" + strings.Repeat(" ", spaces) + fmt.Sprintf("$%.2f", r.CCFee) + "\n"
}
spaces = 32 - (len("Total:") + len(fmt.Sprintf("$%.2f", r.Total)))
message += "Total:" + strings.Repeat(" ", spaces) + fmt.Sprintf("<b>$%.2f</b>", r.Total) + "\n</code>"
msg.TBParseMode = "HTML"
msg.InlineKeyboard = keyboard
a.Log.Debug("publishing message to a channel", "channel", "telegram:send", "app", AppName)
err = a.bus.Publish("telegram:send", msg)
if err != nil {
a.Log.Error("couldn't publish to the channel", "channel", "telegram:send", "error", err.Error())
}
}
case <-ctx.Done():
a.Log.Info("stopping " + AppName + " main communication event loop")
return
}
}
}
// eventLoop loops forever
// func (a *App) eventLoop(ctx context.Context, chAppWorker chan bus.Event) {
// a.Log.Info("app communication event loop started")
// defer a.Log.Info("app communication event loop halted")
// for {
// select {
// case <-a.loop.sysInfo.C:
// a.publishSysTopics()
// case event := <-chAppWorker:
// message := event.Data.(*bus.ConnectionStatus)
// a.Log.Info("got a message from worker", "worker", message.WorkerID, "type", message.WorkerType, "isconnected", message.IsConnected)
// if !message.IsConnected {
// a.hooks.OnWorkerDisconnected()
// a.Workers.Close(message.WorkerID)
// }
// a.hooks.OnWorkerConnected()
// case <-ctx.Done():
// a.Log.Info("stopping app communication event loop")
// return
// }
// }
// }
// publishSysTopics publishes the current values to the server $SYS topics.
// Due to the int to string conversions this method is not as cheap as
// some of the others so the publishing interval should be set appropriately.
func (a *App) publishSysTopics() {
var m runtime.MemStats
runtime.ReadMemStats(&m)
atomic.StoreInt64(&a.Info.MemoryAlloc, int64(m.HeapInuse))
atomic.StoreInt64(&a.Info.Threads, int64(runtime.NumGoroutine()))
atomic.StoreInt64(&a.Info.Time, time.Now().Unix())
atomic.StoreInt64(&a.Info.Uptime, time.Now().Unix()-atomic.LoadInt64(&a.Info.Started))
a.hooks.OnSysInfoTick(a.Info)
}
func truncateText(s string, max int) string {
if max > len(s) {
return s
}
return s[:strings.LastIndex(s[:max], " ")]
}