alpha version
18
.vscode/launch.json
vendored
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
{
|
||||||
|
// Use IntelliSense to learn about possible attributes.
|
||||||
|
// Hover to view descriptions of existing attributes.
|
||||||
|
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
|
||||||
|
"version": "0.2.0",
|
||||||
|
"configurations": [
|
||||||
|
{
|
||||||
|
"name": "Launch Package",
|
||||||
|
"type": "go",
|
||||||
|
"request": "launch",
|
||||||
|
"mode": "auto",
|
||||||
|
"program": "${workspaceFolder}",
|
||||||
|
"args": [
|
||||||
|
"-o", "output.log"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
493
app/app.go
Normal file
@ -0,0 +1,493 @@
|
|||||||
|
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 := "<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], " ")]
|
||||||
|
}
|
216
app/hooks.go
Normal file
@ -0,0 +1,216 @@
|
|||||||
|
package app
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"log/slog"
|
||||||
|
"sync"
|
||||||
|
"sync/atomic"
|
||||||
|
|
||||||
|
"github.com/alex-savin/go-receipt-tracker/system"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
SetOptions byte = iota
|
||||||
|
OnSysInfoTick
|
||||||
|
OnStarted
|
||||||
|
OnStopped
|
||||||
|
OnWorkerConnected
|
||||||
|
OnWorkerDisconnected
|
||||||
|
// OnSysInfoTick
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
// ErrInvalidConfigType indicates a different Type of config value was expected to what was received.
|
||||||
|
ErrInvalidConfigType = errors.New("invalid config type provided")
|
||||||
|
)
|
||||||
|
|
||||||
|
// Hook provides an interface of handlers for different events which occur
|
||||||
|
// during the lifecycle of the broker.
|
||||||
|
type Hook interface {
|
||||||
|
ID() string
|
||||||
|
Provides(b byte) bool
|
||||||
|
Init(config any) error
|
||||||
|
Stop() error
|
||||||
|
SetOpts(l *slog.Logger)
|
||||||
|
OnSysInfoTick(*system.Info)
|
||||||
|
OnStarted()
|
||||||
|
OnStopped()
|
||||||
|
OnWorkerConnected()
|
||||||
|
OnWorkerDisconnected()
|
||||||
|
// OnSysInfoTick(*system.Info)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hooks is a slice of Hook interfaces to be called in sequence.
|
||||||
|
type Hooks struct {
|
||||||
|
Log *slog.Logger // a logger for the hook (from the server)
|
||||||
|
internal atomic.Value // a slice of []Hook
|
||||||
|
wg sync.WaitGroup // a waitgroup for syncing hook shutdown
|
||||||
|
qty int64 // the number of hooks in use
|
||||||
|
sync.Mutex // a mutex for locking when adding hooks
|
||||||
|
}
|
||||||
|
|
||||||
|
// Len returns the number of hooks added.
|
||||||
|
func (h *Hooks) Len() int64 {
|
||||||
|
return atomic.LoadInt64(&h.qty)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Provides returns true if any one hook provides any of the requested hook methods.
|
||||||
|
func (h *Hooks) Provides(b ...byte) bool {
|
||||||
|
for _, hook := range h.GetAll() {
|
||||||
|
for _, hb := range b {
|
||||||
|
if hook.Provides(hb) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add adds and initializes a new hook.
|
||||||
|
func (h *Hooks) Add(hook Hook, config any) error {
|
||||||
|
h.Lock()
|
||||||
|
defer h.Unlock()
|
||||||
|
|
||||||
|
err := hook.Init(config)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed initialising %s hook: %w", hook.ID(), err)
|
||||||
|
}
|
||||||
|
|
||||||
|
i, ok := h.internal.Load().([]Hook)
|
||||||
|
if !ok {
|
||||||
|
i = []Hook{}
|
||||||
|
}
|
||||||
|
|
||||||
|
i = append(i, hook)
|
||||||
|
h.internal.Store(i)
|
||||||
|
atomic.AddInt64(&h.qty, 1)
|
||||||
|
h.wg.Add(1)
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetAll returns a slice of all the hooks.
|
||||||
|
func (h *Hooks) GetAll() []Hook {
|
||||||
|
i, ok := h.internal.Load().([]Hook)
|
||||||
|
if !ok {
|
||||||
|
return []Hook{}
|
||||||
|
}
|
||||||
|
|
||||||
|
return i
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stop indicates all attached hooks to gracefully end.
|
||||||
|
func (h *Hooks) Stop() {
|
||||||
|
go func() {
|
||||||
|
for _, hook := range h.GetAll() {
|
||||||
|
h.Log.Info("stopping hook", "hook", hook.ID())
|
||||||
|
if err := hook.Stop(); err != nil {
|
||||||
|
h.Log.Debug("problem stopping hook", "error", err, "hook", hook.ID())
|
||||||
|
}
|
||||||
|
|
||||||
|
h.wg.Done()
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
h.wg.Wait()
|
||||||
|
}
|
||||||
|
|
||||||
|
// OnSysInfoTick is called when the $SYS topic values are published out.
|
||||||
|
func (h *Hooks) OnSysInfoTick(sys *system.Info) {
|
||||||
|
for _, hook := range h.GetAll() {
|
||||||
|
if hook.Provides(OnSysInfoTick) {
|
||||||
|
hook.OnSysInfoTick(sys)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// OnStarted is called when the server has successfully started.
|
||||||
|
func (h *Hooks) OnStarted() {
|
||||||
|
for _, hook := range h.GetAll() {
|
||||||
|
if hook.Provides(OnStarted) {
|
||||||
|
hook.OnStarted()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// OnStopped is called when the server has successfully stopped.
|
||||||
|
func (h *Hooks) OnStopped() {
|
||||||
|
for _, hook := range h.GetAll() {
|
||||||
|
if hook.Provides(OnStopped) {
|
||||||
|
hook.OnStopped()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// OnWorkerConnected is called when the worker has successfully connected.
|
||||||
|
func (h *Hooks) OnWorkerConnected() {
|
||||||
|
for _, hook := range h.GetAll() {
|
||||||
|
if hook.Provides(OnWorkerConnected) {
|
||||||
|
hook.OnWorkerConnected()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// OnWorkerDisconnected is called when the worker has disconnected.
|
||||||
|
func (h *Hooks) OnWorkerDisconnected() {
|
||||||
|
for _, hook := range h.GetAll() {
|
||||||
|
if hook.Provides(OnWorkerDisconnected) {
|
||||||
|
hook.OnWorkerDisconnected()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// HookBase provides a set of default methods for each hook. It should be embedded in
|
||||||
|
// all hooks.
|
||||||
|
type HookBase struct {
|
||||||
|
Hook
|
||||||
|
Log *slog.Logger
|
||||||
|
}
|
||||||
|
|
||||||
|
// ID returns the ID of the hook.
|
||||||
|
func (h *HookBase) ID() string {
|
||||||
|
return "base"
|
||||||
|
}
|
||||||
|
|
||||||
|
// Provides indicates which methods a hook provides. The default is none - this method
|
||||||
|
// should be overridden by the embedding hook.
|
||||||
|
func (h *HookBase) Provides(b byte) bool {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Init performs any pre-start initializations for the hook, such as connecting to databases
|
||||||
|
// or opening files.
|
||||||
|
func (h *HookBase) Init(config any) error {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetOpts is called by the server to propagate internal values and generally should
|
||||||
|
// not be called manually.
|
||||||
|
func (h *HookBase) SetOpts(l *slog.Logger) {
|
||||||
|
h.Log = l
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stop is called to gracefully shut down the hook.
|
||||||
|
func (h *HookBase) Stop() error {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// OnSysInfoTick is called when the server publishes system info.
|
||||||
|
func (h *HookBase) OnSysInfoTick(*system.Info) {}
|
||||||
|
|
||||||
|
// OnStarted is called when the server starts.
|
||||||
|
func (h *HookBase) OnStarted() {}
|
||||||
|
|
||||||
|
// OnStopped is called when the server stops.
|
||||||
|
func (h *HookBase) OnStopped() {}
|
||||||
|
|
||||||
|
// OnWorkerConnected is called when the worker connected.
|
||||||
|
func (h *HookBase) OnWorkerConnected() {}
|
||||||
|
|
||||||
|
// OnWorkerDisconnected is called when the worker disconnected.
|
||||||
|
func (h *HookBase) OnWorkerDisconnected() {}
|
||||||
|
|
||||||
|
// // OnSysInfoTick is called when the server publishes system info.
|
||||||
|
// func (h *HookBase) OnSysInfoTick(*system.Info) {}
|
116
app/processor.go
Normal file
@ -0,0 +1,116 @@
|
|||||||
|
package app
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/base64"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
|
||||||
|
"github.com/alex-savin/go-receipt-tracker/models"
|
||||||
|
tele "gopkg.in/telebot.v4"
|
||||||
|
)
|
||||||
|
|
||||||
|
func Parse(c *tele.Context) error {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseReceipt .
|
||||||
|
func parseReceipt(j string) (*models.Receipt, error) {
|
||||||
|
var receipt *models.Receipt
|
||||||
|
if err := json.Unmarshal([]byte(j), &receipt); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return receipt, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func imgType(i string) string {
|
||||||
|
if doesFileExist(i) {
|
||||||
|
bytes, err := os.ReadFile(i)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Determine the content type of the image file
|
||||||
|
mimeType := http.DetectContentType(bytes)
|
||||||
|
|
||||||
|
switch mimeType {
|
||||||
|
case "image/jpeg":
|
||||||
|
return "image/jpeg"
|
||||||
|
case "image/png":
|
||||||
|
return "image/png"
|
||||||
|
case "image/webp":
|
||||||
|
return "image/webp"
|
||||||
|
case "image/svg+xml":
|
||||||
|
return "image/svg+xml"
|
||||||
|
case "image/gif":
|
||||||
|
return "image/gif"
|
||||||
|
case "image/avif":
|
||||||
|
return "image/avif"
|
||||||
|
case "image/apng":
|
||||||
|
return "image/apng"
|
||||||
|
}
|
||||||
|
|
||||||
|
} else {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// readImage .
|
||||||
|
func readImage(i string) string {
|
||||||
|
if doesFileExist(i) {
|
||||||
|
bytes, err := os.ReadFile(i)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prepend the appropriate URI scheme header depending
|
||||||
|
// on the MIME type
|
||||||
|
var base64Encoding string
|
||||||
|
|
||||||
|
// Append the base64 encoded output
|
||||||
|
base64Encoding += toBase64(bytes)
|
||||||
|
|
||||||
|
return base64Encoding
|
||||||
|
} else {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// toBase64 .
|
||||||
|
func toBase64(b []byte) string {
|
||||||
|
return base64.StdEncoding.EncodeToString(b)
|
||||||
|
}
|
||||||
|
|
||||||
|
// doesFileExist function to check if file exists
|
||||||
|
func doesFileExist(fileName string) bool {
|
||||||
|
_, error := os.Stat(fileName)
|
||||||
|
|
||||||
|
// check if error is "file not exists"
|
||||||
|
if os.IsNotExist(error) {
|
||||||
|
log.Printf("%v file does not exist\n", fileName)
|
||||||
|
return false
|
||||||
|
} else {
|
||||||
|
log.Printf("%v file exist\n", fileName)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// imgType .
|
||||||
|
func (a App) imgType(fileName string) (string, error) {
|
||||||
|
f, err := os.Open(fileName)
|
||||||
|
if err != nil {
|
||||||
|
a.Log.Error("")
|
||||||
|
}
|
||||||
|
defer f.Close()
|
||||||
|
|
||||||
|
buff := make([]byte, 512) // docs tell that it take only first 512 bytes into consideration
|
||||||
|
if _, err = f.Read(buff); err != nil {
|
||||||
|
fmt.Println(err) // do something with that error
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
return http.DetectContentType(buff), nil
|
||||||
|
}
|
BIN
blobs/AQAD-64xGwABW-FHfg.jpg
Normal file
After Width: | Height: | Size: 182 KiB |
BIN
blobs/AQAD-K0xG8NvKEd-.jpg
Normal file
After Width: | Height: | Size: 187 KiB |
BIN
blobs/AQAD-a0xG50ISUR-.jpg
Normal file
After Width: | Height: | Size: 210 KiB |
BIN
blobs/AQAD-awxG42ZWUZ-.jpg
Normal file
After Width: | Height: | Size: 40 KiB |
BIN
blobs/AQAD0K8xG5FI0Ud-.jpg
Normal file
After Width: | Height: | Size: 174 KiB |
BIN
blobs/AQAD1a0xG45smUZ-.jpg
Normal file
After Width: | Height: | Size: 174 KiB |
BIN
blobs/AQAD1a4xG_axSEZ-.jpg
Normal file
After Width: | Height: | Size: 114 KiB |
BIN
blobs/AQAD2K8xG4eUOER-.jpg
Normal file
After Width: | Height: | Size: 214 KiB |
BIN
blobs/AQAD3K0xGyt2sUZ-.jpg
Normal file
After Width: | Height: | Size: 105 KiB |
BIN
blobs/AQAD3K4xG7pBeEd-.jpg
Normal file
After Width: | Height: | Size: 153 KiB |
BIN
blobs/AQAD4KwxGz80WUd-.jpg
Normal file
After Width: | Height: | Size: 146 KiB |
BIN
blobs/AQAD4a0xGzSJ0Ed-.jpg
Normal file
After Width: | Height: | Size: 202 KiB |
BIN
blobs/AQAD4q0xGzSJ0Ed-.jpg
Normal file
After Width: | Height: | Size: 198 KiB |
BIN
blobs/AQAD5a4xG7pBeEd-.jpg
Normal file
After Width: | Height: | Size: 175 KiB |
BIN
blobs/AQAD6K0xG2cZeEd-.jpg
Normal file
After Width: | Height: | Size: 153 KiB |
BIN
blobs/AQAD6K0xG78WgUZ-.jpg
Normal file
After Width: | Height: | Size: 82 KiB |
BIN
blobs/AQAD6a0xG2cZeEd-.jpg
Normal file
After Width: | Height: | Size: 78 KiB |
BIN
blobs/AQAD6q0xGzSJ0Ed-.jpg
Normal file
After Width: | Height: | Size: 196 KiB |
BIN
blobs/AQAD6qwxGz80WUd-.jpg
Normal file
After Width: | Height: | Size: 138 KiB |
BIN
blobs/AQAD6rQxGxYVEEd-.jpg
Normal file
After Width: | Height: | Size: 186 KiB |
BIN
blobs/AQAD7K0xG78WgUZ-.jpg
Normal file
After Width: | Height: | Size: 102 KiB |
BIN
blobs/AQAD7K4xG1UCiUd-.jpg
Normal file
After Width: | Height: | Size: 206 KiB |
BIN
blobs/AQAD860xG22YQUZ-.jpg
Normal file
After Width: | Height: | Size: 202 KiB |
BIN
blobs/AQAD864xG42ZUUZ-.jpg
Normal file
After Width: | Height: | Size: 110 KiB |
BIN
blobs/AQAD864xGxh44EZ-.jpg
Normal file
After Width: | Height: | Size: 126 KiB |
BIN
blobs/AQAD96wxG42ZWUZ-.jpg
Normal file
After Width: | Height: | Size: 218 KiB |
BIN
blobs/AQAD9K4xG42ZUUZ-.jpg
Normal file
After Width: | Height: | Size: 193 KiB |
BIN
blobs/AQAD9q4xG42ZUUZ-.jpg
Normal file
After Width: | Height: | Size: 189 KiB |
BIN
blobs/AQADBK4xGz80UUd-.jpg
Normal file
After Width: | Height: | Size: 169 KiB |
BIN
blobs/AQADEK8xG50v-Ed-.jpg
Normal file
After Width: | Height: | Size: 178 KiB |
BIN
blobs/AQADG68xGwABW-FHfg.jpg
Normal file
After Width: | Height: | Size: 154 KiB |
BIN
blobs/AQADGq4xG78WiUZ-.jpg
Normal file
After Width: | Height: | Size: 64 KiB |
BIN
blobs/AQADHbAxG82qAUd-.jpg
Normal file
After Width: | Height: | Size: 219 KiB |
BIN
blobs/AQADIa8xG5FI2Ud-.jpg
Normal file
After Width: | Height: | Size: 110 KiB |
BIN
blobs/AQADJ60xG42ZWUZ-.jpg
Normal file
After Width: | Height: | Size: 100 KiB |
BIN
blobs/AQADJ64xG5twKUd-.jpg
Normal file
After Width: | Height: | Size: 220 KiB |
BIN
blobs/AQADJq0xG42ZWUZ-.jpg
Normal file
After Width: | Height: | Size: 164 KiB |
BIN
blobs/AQADK7AxGzSJyEd-.jpg
Normal file
After Width: | Height: | Size: 125 KiB |
BIN
blobs/AQADK7IxGyDEMUZ-.jpg
Normal file
After Width: | Height: | Size: 76 KiB |
BIN
blobs/AQADKbIxGyDEMUZ-.jpg
Normal file
After Width: | Height: | Size: 247 KiB |
BIN
blobs/AQADKrIxGyDEMUZ-.jpg
Normal file
After Width: | Height: | Size: 153 KiB |
BIN
blobs/AQADL7IxGyDEMUZ-.jpg
Normal file
After Width: | Height: | Size: 153 KiB |
BIN
blobs/AQADLLIxGyDEMUZ-.jpg
Normal file
After Width: | Height: | Size: 160 KiB |
BIN
blobs/AQADLbIxGyDEMUZ-.jpg
Normal file
After Width: | Height: | Size: 164 KiB |
BIN
blobs/AQADLrIxGyDEMUZ-.jpg
Normal file
After Width: | Height: | Size: 190 KiB |
BIN
blobs/AQADMLIxGyDEMUZ-.jpg
Normal file
After Width: | Height: | Size: 220 KiB |
BIN
blobs/AQADMrExGyXiUUR-.jpg
Normal file
After Width: | Height: | Size: 157 KiB |
BIN
blobs/AQADNK8xGyDEOUZ-.jpg
Normal file
After Width: | Height: | Size: 190 KiB |
BIN
blobs/AQADNrAxGzSJyEd-.jpg
Normal file
After Width: | Height: | Size: 314 KiB |
BIN
blobs/AQADObIxGyDEMUZ-.jpg
Normal file
After Width: | Height: | Size: 188 KiB |
BIN
blobs/AQADOrIxGyDEMUZ-.jpg
Normal file
After Width: | Height: | Size: 247 KiB |
BIN
blobs/AQADP68xGxYVGEd-.jpg
Normal file
After Width: | Height: | Size: 223 KiB |
BIN
blobs/AQADPq8xGyDEOUZ-.jpg
Normal file
After Width: | Height: | Size: 222 KiB |
BIN
blobs/AQADPrIxGyDEMUZ-.jpg
Normal file
After Width: | Height: | Size: 196 KiB |
BIN
blobs/AQADQK8xGyDEOUZ-.jpg
Normal file
After Width: | Height: | Size: 220 KiB |
BIN
blobs/AQADQrAxG1vkeEZ-.jpg
Normal file
After Width: | Height: | Size: 146 KiB |
BIN
blobs/AQADR7IxGyDEMUZ-.jpg
Normal file
After Width: | Height: | Size: 174 KiB |
BIN
blobs/AQADRrIxGyDEMUZ-.jpg
Normal file
After Width: | Height: | Size: 67 KiB |
BIN
blobs/AQADS60xGyXiYUR-.jpg
Normal file
After Width: | Height: | Size: 193 KiB |
BIN
blobs/AQADSLIxGyDEMUZ-.jpg
Normal file
After Width: | Height: | Size: 86 KiB |
BIN
blobs/AQADT68xGyDEOUZ-.jpg
Normal file
After Width: | Height: | Size: 114 KiB |
BIN
blobs/AQADTa8xGyDEOUZ-.jpg
Normal file
After Width: | Height: | Size: 180 KiB |
BIN
blobs/AQADTq8xGyDEOUZ-.jpg
Normal file
After Width: | Height: | Size: 221 KiB |
BIN
blobs/AQADTrIxG9b46EZ-.jpg
Normal file
After Width: | Height: | Size: 171 KiB |
BIN
blobs/AQADUK8xGyDEOUZ-.jpg
Normal file
After Width: | Height: | Size: 218 KiB |
BIN
blobs/AQADULIxGyDEMUZ-.jpg
Normal file
After Width: | Height: | Size: 60 KiB |
BIN
blobs/AQADUa8xGyDEOUZ-.jpg
Normal file
After Width: | Height: | Size: 196 KiB |
BIN
blobs/AQADVMYxGxvmKER-.jpg
Normal file
After Width: | Height: | Size: 124 KiB |
BIN
blobs/AQADY6wxG50vAAFEfg.jpg
Normal file
After Width: | Height: | Size: 129 KiB |
BIN
blobs/AQADZa0xG1vkcEZ-.jpg
Normal file
After Width: | Height: | Size: 87 KiB |
BIN
blobs/AQAD_68xGwyDYUR-.jpg
Normal file
After Width: | Height: | Size: 209 KiB |
BIN
blobs/AQAD_K4xGwABW-FHfg.jpg
Normal file
After Width: | Height: | Size: 113 KiB |
BIN
blobs/AQADaq8xG869KEd-.jpg
Normal file
After Width: | Height: | Size: 261 KiB |
BIN
blobs/AQADc6wxGzr6mUd-.jpg
Normal file
After Width: | Height: | Size: 223 KiB |
BIN
blobs/AQADcK4xG7g6EER-.jpg
Normal file
After Width: | Height: | Size: 143 KiB |
BIN
blobs/AQADeqwxGzr6mUd-.jpg
Normal file
After Width: | Height: | Size: 168 KiB |
BIN
blobs/AQADf64xG2cZaEd-.jpg
Normal file
After Width: | Height: | Size: 243 KiB |
BIN
blobs/AQADfKwxGxdNMEZ-.jpg
Normal file
After Width: | Height: | Size: 188 KiB |
BIN
blobs/AQADfawxGzr6mUd-.jpg
Normal file
After Width: | Height: | Size: 161 KiB |
BIN
blobs/AQADfq8xG869KEd-.jpg
Normal file
After Width: | Height: | Size: 178 KiB |
BIN
blobs/AQADgK4xG2cZaEd-.jpg
Normal file
After Width: | Height: | Size: 162 KiB |
BIN
blobs/AQADgq4xG2cZaEd-.jpg
Normal file
After Width: | Height: | Size: 105 KiB |
BIN
blobs/AQADi64xG22YOUZ-.jpg
Normal file
After Width: | Height: | Size: 180 KiB |
BIN
blobs/AQADj8QxG5CMCVR-.jpg
Normal file
After Width: | Height: | Size: 106 KiB |
BIN
blobs/AQADkK0xGxdNIEZ-.jpg
Normal file
After Width: | Height: | Size: 247 KiB |
BIN
blobs/AQADl60xG7g6CER-.jpg
Normal file
After Width: | Height: | Size: 224 KiB |
BIN
blobs/AQADlKwxG45skUZ-.jpg
Normal file
After Width: | Height: | Size: 85 KiB |
BIN
blobs/AQADlb8xGwUICVR-.jpg
Normal file
After Width: | Height: | Size: 69 KiB |
BIN
blobs/AQADlq8xGwyDcUR-.jpg
Normal file
After Width: | Height: | Size: 73 KiB |
BIN
blobs/AQADmK0xGxdNIEZ-.jpg
Normal file
After Width: | Height: | Size: 188 KiB |
BIN
blobs/AQADn60xG1WZEEV-.jpg
Normal file
After Width: | Height: | Size: 180 KiB |
BIN
blobs/AQADna0xG7pBgEd-.jpg
Normal file
After Width: | Height: | Size: 150 KiB |
BIN
blobs/AQADqbQxGxYVEEd-.jpg
Normal file
After Width: | Height: | Size: 200 KiB |
BIN
blobs/AQADs6wxG45skUZ-.jpg
Normal file
After Width: | Height: | Size: 33 KiB |
BIN
blobs/AQADv60xG601WUd-.jpg
Normal file
After Width: | Height: | Size: 124 KiB |
BIN
blobs/AQADvK0xG50IQUR-.jpg
Normal file
After Width: | Height: | Size: 149 KiB |