package app import ( "context" "encoding/json" "errors" "fmt" "log/slog" "runtime" "strings" "sync/atomic" "time" "github.com/uptrace/bun" tele "gopkg.in/telebot.v4" "github.com/alex-savin/go-receipt-tracker/bus" "github.com/alex-savin/go-receipt-tracker/config" "github.com/alex-savin/go-receipt-tracker/listeners" "github.com/alex-savin/go-receipt-tracker/models" "github.com/alex-savin/go-receipt-tracker/system" "github.com/alex-savin/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 := "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("$%.2f", r.Total) + "\n" 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], " ")] }