diff --git a/go.mod b/go.mod index c6a1648..2666e8e 100644 --- a/go.mod +++ b/go.mod @@ -12,6 +12,7 @@ require ( github.com/jinzhu/configor v1.2.1 github.com/makiuchi-d/gozxing v0.0.2 github.com/nicksnyder/go-i18n/v2 v2.1.2 + github.com/orcaman/concurrent-map v1.0.0 github.com/patrickmn/go-cache v2.1.0+incompatible github.com/sethvargo/go-limiter v0.7.2 github.com/sirupsen/logrus v1.6.0 diff --git a/go.sum b/go.sum index ffd9a2e..c06a9c1 100644 --- a/go.sum +++ b/go.sum @@ -397,6 +397,8 @@ github.com/openzipkin-contrib/zipkin-go-opentracing v0.4.5/go.mod h1:/wsWhb9smxS github.com/openzipkin/zipkin-go v0.1.6/go.mod h1:QgAqvLzwWbR/WpD4A3cGpPtJrZXNIiJc5AZX7/PBEpw= github.com/openzipkin/zipkin-go v0.2.1/go.mod h1:NaW6tEwdmWMaCDZzg8sh+IBNOxHMPnhQw8ySjnjRyN4= github.com/openzipkin/zipkin-go v0.2.2/go.mod h1:NaW6tEwdmWMaCDZzg8sh+IBNOxHMPnhQw8ySjnjRyN4= +github.com/orcaman/concurrent-map v1.0.0 h1:I/2A2XPCb4IuQWcQhBhSwGfiuybl/J0ev9HDbW65HOY= +github.com/orcaman/concurrent-map v1.0.0/go.mod h1:Lu3tH6HLW3feq74c2GC+jIMS/K2CFcDWnWD9XkenwhI= github.com/pact-foundation/pact-go v1.0.4/go.mod h1:uExwJY4kCzNPcHRj+hCR/HBbOOIwwtUjcrb0b5/5kLM= github.com/pascaldekloe/goe v0.0.0-20180627143212-57f6aae5913c/go.mod h1:lzWF7FIEvWOWxwDKqyGYQf6ZUaNfKdP144TG7ZOy1lc= github.com/patrickmn/go-cache v2.1.0+incompatible h1:HRMgzkcYKYpi3C8ajMPV8OFXaaRUnok+kx1WdO15EQc= @@ -742,6 +744,8 @@ gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 h1:uRGJdciOHaEIrze2W8Q3AKkep gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw= gopkg.in/tomb.v2 v2.0.0-20161208151619-d5d1b5820637 h1:yiW+nvdHb9LVqSHQBXfZCieqV4fzYhNBql77zY0ykqs= gopkg.in/tomb.v2 v2.0.0-20161208151619-d5d1b5820637/go.mod h1:BHsqpu/nsuzkT5BpiH1EMZPLyqSMM8JbIavyFACoFNk= +gopkg.in/tucnak/telebot.v2 v2.4.1 h1:bUOFHtHhuhPekjHGe1Q1BmITvtBLdQI4yjSMC405KcU= +gopkg.in/tucnak/telebot.v2 v2.4.1/go.mod h1:BgaIIx50PSRS9pG59JH+geT82cfvoJU/IaI5TJdN3v8= gopkg.in/warnings.v0 v0.1.2/go.mod h1:jksf8JmL6Qr/oQM2OXTHunEvvTAsrWBLb6OOjuVWRNI= gopkg.in/yaml.v2 v2.0.0-20170812160011-eb3733d160e7/go.mod h1:JAlM8MvJe8wmxCU4Bli9HhUf9+ttbYbLASfIpnQbh74= gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= diff --git a/internal/config.go b/internal/config.go index ff814cc..9d7b436 100644 --- a/internal/config.go +++ b/internal/config.go @@ -30,6 +30,7 @@ type TelegramConfiguration struct { } type DatabaseConfiguration struct { DbPath string `yaml:"db_path"` + ShopBuntDbPath string `yaml:"shop_buntdb_path"` BuntDbPath string `yaml:"buntdb_path"` TransactionsPath string `yaml:"transactions_path"` } diff --git a/internal/lnbits/types.go b/internal/lnbits/types.go index e446c56..d9d4e49 100644 --- a/internal/lnbits/types.go +++ b/internal/lnbits/types.go @@ -36,6 +36,13 @@ const ( UserHasEnteredAmount UserEnterUser UserHasEnteredUser + UserEnterShopTitle + UserStateShopItemSendPhoto + UserStateShopItemSendTitle + UserStateShopItemSendDescription + UserStateShopItemSendPrice + UserStateShopItemSendItemFile + UserEnterShopsDescription ) type UserStateKey int diff --git a/internal/runtime/function.go b/internal/runtime/function.go new file mode 100644 index 0000000..96bbbc8 --- /dev/null +++ b/internal/runtime/function.go @@ -0,0 +1,76 @@ +package runtime + +import ( + cmap "github.com/orcaman/concurrent-map" + "time" +) + +var tickerMap cmap.ConcurrentMap + +func init() { + tickerMap = cmap.New() +} + +var defaultTickerCoolDown = time.Second * 10 + +// ResettableFunctionTicker will reset the user state as soon as tick is delivered. +type ResettableFunctionTicker struct { + Ticker *time.Ticker + ResetChan chan struct{} // channel used to reset the ticker + duration time.Duration + Started bool + name string +} +type ResettableFunctionTickerOption func(*ResettableFunctionTicker) + +func WithDuration(d time.Duration) ResettableFunctionTickerOption { + return func(a *ResettableFunctionTicker) { + a.duration = d + } +} +func RemoveTicker(name string) { + tickerMap.Remove(name) +} +func GetTicker(name string, option ...ResettableFunctionTickerOption) *ResettableFunctionTicker { + + if t, ok := tickerMap.Get(name); ok { + return t.(*ResettableFunctionTicker) + } else { + t := NewResettableFunctionTicker(name, option...) + tickerMap.Set(name, t) + return t + } +} +func NewResettableFunctionTicker(name string, option ...ResettableFunctionTickerOption) *ResettableFunctionTicker { + t := &ResettableFunctionTicker{ + ResetChan: make(chan struct{}, 1), + name: name, + } + + for _, opt := range option { + opt(t) + } + if t.duration == 0 { + t.duration = defaultTickerCoolDown + } + t.Ticker = time.NewTicker(t.duration) + return t +} + +func (t *ResettableFunctionTicker) Do(f func()) { + t.Started = true + tickerMap.Set(t.name, t) + go func() { + for { + select { + case <-t.Ticker.C: + // ticker delivered signal. do function f + f() + return + case <-t.ResetChan: + // reset signal received. creating new ticker. + t.Ticker = time.NewTicker(t.duration) + } + } + }() +} diff --git a/internal/runtime/mutex.go b/internal/runtime/mutex.go new file mode 100644 index 0000000..aa972c4 --- /dev/null +++ b/internal/runtime/mutex.go @@ -0,0 +1,31 @@ +package runtime + +import ( + cmap "github.com/orcaman/concurrent-map" + log "github.com/sirupsen/logrus" + "sync" +) + +var mutexMap cmap.ConcurrentMap + +func init() { + mutexMap = cmap.New() +} + +func Lock(s string) { + if m, ok := mutexMap.Get(s); ok { + m.(*sync.Mutex).Lock() + } else { + m := &sync.Mutex{} + m.Lock() + mutexMap.Set(s, m) + } + log.Tracef("[Mutex] Lock %s", s) +} + +func Unlock(s string) { + if m, ok := mutexMap.Get(s); ok { + log.Tracef("[Mutex] Unlock %s", s) + m.(*sync.Mutex).Unlock() + } +} diff --git a/internal/storage/bunt.go b/internal/storage/bunt.go index c85ec70..58b7fc0 100644 --- a/internal/storage/bunt.go +++ b/internal/storage/bunt.go @@ -2,7 +2,7 @@ package storage import ( "encoding/json" - "github.com/LightningTipBot/LightningTipBot/internal/runtime" + log "github.com/sirupsen/logrus" "github.com/tidwall/buntdb" ) @@ -78,23 +78,31 @@ func (db *DB) Set(object Storable) error { } // Delete a storable item. -// todo -- not ascend users index func (db *DB) Delete(index string, object Storable) error { return db.Update(func(tx *buntdb.Tx) error { - var delkeys []string - runtime.IgnoreError( - tx.Ascend(index, func(key, value string) bool { - if key == object.Key() { - delkeys = append(delkeys, key) - } - return true - }), - ) - for _, k := range delkeys { - if _, err := tx.Delete(k); err != nil { - return err - } + _, err := tx.Get(object.Key()) + if err != nil { + return err } + if _, err := tx.Delete(object.Key()); err != nil { + return err + } + // OLD: from gohumble: + // todo -- not ascend users index + // var delkeys []string + // runtime.IgnoreError( + // tx.Ascend(index, func(key, value string) bool { + // if key == object.Key() { + // delkeys = append(delkeys, key) + // } + // return true + // }), + // ) + // for _, k := range delkeys { + // if _, err := tx.Delete(k); err != nil { + // return err + // } + // } return nil }) } diff --git a/internal/storage/transaction/transaction.go b/internal/storage/transaction/transaction.go index 54db374..4ba86d3 100644 --- a/internal/storage/transaction/transaction.go +++ b/internal/storage/transaction/transaction.go @@ -2,7 +2,7 @@ package transaction import ( "fmt" - "sync" + "github.com/LightningTipBot/LightningTipBot/internal/runtime" "time" "github.com/LightningTipBot/LightningTipBot/internal/storage" @@ -17,14 +17,6 @@ type Base struct { UpdatedAt time.Time `json:"updated"` } -func init() { - transactionMutex = make(map[string]*sync.Mutex, 0) - transactionMapMutex = &sync.Mutex{} -} - -var transactionMutex map[string]*sync.Mutex -var transactionMapMutex *sync.Mutex - type Option func(b *Base) func ID(id string) Option { @@ -60,14 +52,12 @@ func (tx *Base) Lock(s storage.Storable, db *storage.DB) error { log.Debugf("[Lock] %s", tx.ID) return nil } -func unlock(id string) { - transactionMapMutex.Lock() - if transactionMutex[id] != nil { - transactionMutex[id].Unlock() - log.Tracef("[TX mutex] Release %s", id) - } - transactionMapMutex.Unlock() +func Unlock(id string) { + runtime.Unlock(id) +} +func Lock(id string) { + runtime.Lock(id) } func (tx *Base) Release(s storage.Storable, db *storage.DB) error { @@ -79,7 +69,7 @@ func (tx *Base) Release(s storage.Storable, db *storage.DB) error { return err } log.Debugf("[Bunt Release] %s", tx.ID) - unlock(tx.ID) + Unlock(tx.ID) return nil } @@ -95,17 +85,12 @@ func (tx *Base) Inactivate(s storage.Storable, db *storage.DB) error { } func (tx *Base) Get(s storage.Storable, db *storage.DB) (storage.Storable, error) { - transactionMapMutex.Lock() - if transactionMutex[tx.ID] == nil { - transactionMutex[tx.ID] = &sync.Mutex{} - } - transactionMapMutex.Unlock() - transactionMutex[tx.ID].Lock() + Lock(tx.ID) log.Tracef("[TX mutex] Lock %s", tx.ID) err := db.Get(s) if err != nil { - unlock(tx.ID) + Unlock(tx.ID) return s, err } // to avoid race conditions, we block the call if there is @@ -114,7 +99,7 @@ func (tx *Base) Get(s storage.Storable, db *storage.DB) (storage.Storable, error for tx.InTransaction { select { case <-ticker.C: - unlock(tx.ID) + Unlock(tx.ID) return nil, fmt.Errorf("[Bunt Lock] transaction timeout %s", tx.ID) default: time.Sleep(time.Duration(75) * time.Millisecond) @@ -122,7 +107,7 @@ func (tx *Base) Get(s storage.Storable, db *storage.DB) (storage.Storable, error } } if err != nil { - unlock(tx.ID) + Unlock(tx.ID) return nil, fmt.Errorf("could not get transaction") } @@ -133,3 +118,8 @@ func (tx *Base) Set(s storage.Storable, db *storage.DB) error { tx.UpdatedAt = time.Now() return db.Set(s) } + +func (tx *Base) Delete(s storage.Storable, db *storage.DB) error { + tx.UpdatedAt = time.Now() + return db.Delete(s.Key(), s) +} diff --git a/internal/telegram/bot.go b/internal/telegram/bot.go index cd40c52..6dcec78 100644 --- a/internal/telegram/bot.go +++ b/internal/telegram/bot.go @@ -14,7 +14,6 @@ import ( "github.com/LightningTipBot/LightningTipBot/internal/storage" gocache "github.com/patrickmn/go-cache" log "github.com/sirupsen/logrus" - "gopkg.in/lightningtipbot/telebot.v2" tb "gopkg.in/lightningtipbot/telebot.v2" "gorm.io/gorm" ) @@ -22,8 +21,9 @@ import ( type TipBot struct { Database *gorm.DB Bunt *storage.DB + ShopBunt *storage.DB logger *gorm.DB - Telegram *telebot.Bot + Telegram *tb.Bot Client *lnbits.Client limiter map[string]limiter.Limiter Cache @@ -48,7 +48,8 @@ func NewBot() TipBot { Database: db, Client: lnbits.NewClient(internal.Configuration.Lnbits.AdminKey, internal.Configuration.Lnbits.Url), logger: txLogger, - Bunt: createBunt(), + Bunt: createBunt(internal.Configuration.Database.BuntDbPath), + ShopBunt: createBunt(internal.Configuration.Database.ShopBuntDbPath), Telegram: newTelegramBot(), Cache: Cache{GoCacheStore: gocacheStore}, } @@ -90,5 +91,6 @@ func (bot *TipBot) Start() { } bot.registerTelegramHandlers() initInvoiceEventCallbacks(bot) + initializeStateCallbackMessage(bot) bot.Telegram.Start() } diff --git a/internal/telegram/buttons.go b/internal/telegram/buttons.go new file mode 100644 index 0000000..7bb392c --- /dev/null +++ b/internal/telegram/buttons.go @@ -0,0 +1,24 @@ +package telegram + +import tb "gopkg.in/lightningtipbot/telebot.v2" + +// buttonWrapper wrap buttons slice in rows of length i +func buttonWrapper(buttons []tb.Btn, markup *tb.ReplyMarkup, length int) []tb.Row { + buttonLength := len(buttons) + rows := make([]tb.Row, 0) + + if buttonLength > length { + for i := 0; i < buttonLength; i = i + length { + buttonRow := make([]tb.Btn, length) + if i+length < buttonLength { + buttonRow = buttons[i : i+length] + } else { + buttonRow = buttons[i:] + } + rows = append(rows, markup.Row(buttonRow...)) + } + return rows + } + rows = append(rows, markup.Row(buttons...)) + return rows +} diff --git a/internal/telegram/database.go b/internal/telegram/database.go index 7736b70..8bdc98a 100644 --- a/internal/telegram/database.go +++ b/internal/telegram/database.go @@ -26,9 +26,9 @@ const ( TipTooltipKeyPattern = "tip-tool-tip:*" ) -func createBunt() *storage.DB { +func createBunt(file string) *storage.DB { // create bunt database - bunt := storage.NewBunt(internal.Configuration.Database.BuntDbPath) + bunt := storage.NewBunt(file) // create bunt database index for ascending (searching) TipTooltips err := bunt.CreateIndex(MessageOrderedByReplyToFrom, TipTooltipKeyPattern, buntdb.IndexJSON(MessageOrderedByReplyToFrom)) if err != nil { diff --git a/internal/telegram/files.go b/internal/telegram/files.go new file mode 100644 index 0000000..ae45656 --- /dev/null +++ b/internal/telegram/files.go @@ -0,0 +1,32 @@ +package telegram + +import ( + "context" + "github.com/LightningTipBot/LightningTipBot/internal/runtime" + tb "gopkg.in/lightningtipbot/telebot.v2" +) + +func (bot *TipBot) fileHandler(ctx context.Context, m *tb.Message) { + if m.Chat.Type != tb.ChatPrivate { + return + } + user := LoadUser(ctx) + if c := stateCallbackMessage[user.StateKey]; c != nil { + // found handler for this state + // now looking for user state reset ticker + ticker := runtime.GetTicker(user.ID) + if !ticker.Started { + ticker.Do(func() { + ResetUserState(user, bot) + // removing ticker asap done + bot.shopViewDeleteAllStatusMsgs(ctx, user, 0) + runtime.RemoveTicker(user.ID) + }) + } else { + ticker.ResetChan <- struct{}{} + } + + c(ctx, m) + return + } +} diff --git a/internal/telegram/handler.go b/internal/telegram/handler.go index dea88c9..5461091 100644 --- a/internal/telegram/handler.go +++ b/internal/telegram/handler.go @@ -168,6 +168,37 @@ func (bot TipBot) getHandler() []Handler { }, }, }, + { + Endpoints: []interface{}{"/shops"}, + Handler: bot.shopsHandler, + Interceptor: &Interceptor{ + Type: MessageInterceptor, + Before: []intercept.Func{ + bot.logMessageInterceptor, + bot.requireUserInterceptor, + bot.loadUserInterceptor, + bot.lockInterceptor, + }, + OnDefer: []intercept.Func{ + bot.unlockInterceptor, + }}, + }, + { + Endpoints: []interface{}{"/shop"}, + Handler: bot.shopHandler, + Interceptor: &Interceptor{ + Type: MessageInterceptor, + Before: []intercept.Func{ + bot.localizerInterceptor, + bot.logMessageInterceptor, + bot.requireUserInterceptor, + bot.loadUserInterceptor, + bot.lockInterceptor, + }, + OnDefer: []intercept.Func{ + bot.unlockInterceptor, + }}, + }, { Endpoints: []interface{}{"/balance"}, Handler: bot.balanceHandler, @@ -346,6 +377,16 @@ func (bot TipBot) getHandler() []Handler { }, }, }, + { + Endpoints: []interface{}{tb.OnDocument, tb.OnVideo, tb.OnAnimation, tb.OnVoice, tb.OnAudio, tb.OnSticker, tb.OnVideoNote}, + Handler: bot.fileHandler, + Interceptor: &Interceptor{ + Type: MessageInterceptor, + Before: []intercept.Func{ + bot.requirePrivateChatInterceptor, + bot.logMessageInterceptor, + bot.loadUserInterceptor}}, + }, { Endpoints: []interface{}{tb.OnText}, Handler: bot.anyTextHandler, @@ -592,5 +633,379 @@ func (bot TipBot) getHandler() []Handler { }, }, }, + { + Endpoints: []interface{}{&shopNewShopButton}, + Handler: bot.shopNewShopHandler, + Interceptor: &Interceptor{ + Type: CallbackInterceptor, + Before: []intercept.Func{ + bot.localizerInterceptor, + bot.loadUserInterceptor, + bot.lockInterceptor, + }, + OnDefer: []intercept.Func{ + bot.unlockInterceptor, + }}, + }, + { + Endpoints: []interface{}{&shopAddItemButton}, + Handler: bot.shopNewItemHandler, + Interceptor: &Interceptor{ + Type: CallbackInterceptor, + Before: []intercept.Func{ + bot.localizerInterceptor, + bot.loadUserInterceptor, + bot.lockInterceptor, + }, + OnDefer: []intercept.Func{ + bot.unlockInterceptor, + }}, + }, + { + Endpoints: []interface{}{&shopBuyitemButton}, + Handler: bot.shopGetItemFilesHandler, + Interceptor: &Interceptor{ + Type: CallbackInterceptor, + Before: []intercept.Func{ + bot.localizerInterceptor, + bot.loadUserInterceptor, + bot.lockInterceptor, + }, + OnDefer: []intercept.Func{ + bot.unlockInterceptor, + }}, + }, + { + Endpoints: []interface{}{&shopNextitemButton}, + Handler: bot.shopNextItemButtonHandler, + Interceptor: &Interceptor{ + Type: CallbackInterceptor, + Before: []intercept.Func{ + bot.localizerInterceptor, + bot.loadUserInterceptor, + bot.lockInterceptor, + }, + OnDefer: []intercept.Func{ + bot.unlockInterceptor, + }}, + }, + { + Endpoints: []interface{}{&browseShopButton}, + Handler: bot.shopsBrowser, + Interceptor: &Interceptor{ + Type: CallbackInterceptor, + Before: []intercept.Func{ + bot.localizerInterceptor, + bot.loadUserInterceptor, + bot.lockInterceptor, + }, + OnDefer: []intercept.Func{ + bot.unlockInterceptor, + }}, + }, + { + Endpoints: []interface{}{&shopSelectButton}, + Handler: bot.shopSelect, + Interceptor: &Interceptor{ + Type: CallbackInterceptor, + Before: []intercept.Func{ + bot.localizerInterceptor, + bot.loadUserInterceptor, + bot.lockInterceptor, + }, + OnDefer: []intercept.Func{ + bot.unlockInterceptor, + }}, + }, + // button that opens selection of shops to delete + { + Endpoints: []interface{}{&shopDeleteShopButton}, + Handler: bot.shopsDeleteShopBrowser, + Interceptor: &Interceptor{ + Type: CallbackInterceptor, + Before: []intercept.Func{ + bot.localizerInterceptor, + bot.loadUserInterceptor, + bot.lockInterceptor, + }, + OnDefer: []intercept.Func{ + bot.unlockInterceptor, + }}, + }, + // button that selects which shop to delete + { + Endpoints: []interface{}{&shopDeleteSelectButton}, + Handler: bot.shopSelectDelete, + Interceptor: &Interceptor{ + Type: CallbackInterceptor, + Before: []intercept.Func{ + bot.localizerInterceptor, + bot.loadUserInterceptor, + bot.lockInterceptor, + }, + OnDefer: []intercept.Func{ + bot.unlockInterceptor, + }}, + }, + // button that opens selection of shops to get links of + { + Endpoints: []interface{}{&shopLinkShopButton}, + Handler: bot.shopsLinkShopBrowser, + Interceptor: &Interceptor{ + Type: CallbackInterceptor, + Before: []intercept.Func{ + bot.localizerInterceptor, + bot.loadUserInterceptor, + bot.lockInterceptor, + }, + OnDefer: []intercept.Func{ + bot.unlockInterceptor, + }}, + }, + // button that selects which shop to link + { + Endpoints: []interface{}{&shopLinkSelectButton}, + Handler: bot.shopSelectLink, + Interceptor: &Interceptor{ + Type: CallbackInterceptor, + Before: []intercept.Func{ + bot.localizerInterceptor, + bot.loadUserInterceptor, + bot.lockInterceptor, + }, + OnDefer: []intercept.Func{ + bot.unlockInterceptor, + }}, + }, + // button that opens selection of shops to rename + { + Endpoints: []interface{}{&shopRenameShopButton}, + Handler: bot.shopsRenameShopBrowser, + Interceptor: &Interceptor{ + Type: CallbackInterceptor, + Before: []intercept.Func{ + bot.localizerInterceptor, + bot.loadUserInterceptor, + bot.lockInterceptor, + }, + OnDefer: []intercept.Func{ + bot.unlockInterceptor, + }}, + }, + // button that selects which shop to rename + { + Endpoints: []interface{}{&shopRenameSelectButton}, + Handler: bot.shopSelectRename, + Interceptor: &Interceptor{ + Type: CallbackInterceptor, + Before: []intercept.Func{ + bot.localizerInterceptor, + bot.loadUserInterceptor, + bot.lockInterceptor, + }, + OnDefer: []intercept.Func{ + bot.unlockInterceptor, + }}, + }, + // button that opens shops settings buttons view + { + Endpoints: []interface{}{&shopSettingsButton}, + Handler: bot.shopSettingsHandler, + Interceptor: &Interceptor{ + Type: CallbackInterceptor, + Before: []intercept.Func{ + bot.localizerInterceptor, + bot.loadUserInterceptor, + bot.lockInterceptor, + }, + OnDefer: []intercept.Func{ + bot.unlockInterceptor, + }}, + }, + // button that lets user enter description for shops + { + Endpoints: []interface{}{&shopDescriptionShopButton}, + Handler: bot.shopsDescriptionHandler, + Interceptor: &Interceptor{ + Type: CallbackInterceptor, + Before: []intercept.Func{ + bot.localizerInterceptor, + bot.loadUserInterceptor, + bot.lockInterceptor, + }, + OnDefer: []intercept.Func{ + bot.unlockInterceptor, + }}, + }, + // button that resets user shops + { + Endpoints: []interface{}{&shopResetShopButton}, + Handler: bot.shopsResetHandler, + Interceptor: &Interceptor{ + Type: CallbackInterceptor, + Before: []intercept.Func{ + bot.localizerInterceptor, + bot.loadUserInterceptor, + bot.lockInterceptor, + }, + OnDefer: []intercept.Func{ + bot.unlockInterceptor, + }}, + }, + { + Endpoints: []interface{}{&shopResetShopAskButton}, + Handler: bot.shopsAskDeleteAllShopsHandler, + Interceptor: &Interceptor{ + Type: CallbackInterceptor, + Before: []intercept.Func{ + bot.localizerInterceptor, + bot.loadUserInterceptor, + bot.lockInterceptor, + }, + OnDefer: []intercept.Func{ + bot.unlockInterceptor, + }}, + }, + { + Endpoints: []interface{}{&shopPrevitemButton}, + Handler: bot.shopPrevItemButtonHandler, + Interceptor: &Interceptor{ + Type: CallbackInterceptor, + Before: []intercept.Func{ + bot.localizerInterceptor, + bot.loadUserInterceptor, + bot.lockInterceptor, + }, + OnDefer: []intercept.Func{ + bot.unlockInterceptor, + }}, + }, + { + Endpoints: []interface{}{&shopShopsButton}, + Handler: bot.shopsHandlerCallback, + Interceptor: &Interceptor{ + Type: CallbackInterceptor, + Before: []intercept.Func{ + bot.localizerInterceptor, + bot.loadUserInterceptor, + bot.lockInterceptor, + }, + OnDefer: []intercept.Func{ + bot.unlockInterceptor, + }}, + }, + // shop item settings buttons + { + Endpoints: []interface{}{&shopItemSettingsButton}, + Handler: bot.shopItemSettingsHandler, + Interceptor: &Interceptor{ + Type: CallbackInterceptor, + Before: []intercept.Func{ + bot.localizerInterceptor, + bot.loadUserInterceptor, + bot.lockInterceptor, + }, + OnDefer: []intercept.Func{ + bot.unlockInterceptor, + }}, + }, + { + Endpoints: []interface{}{&shopItemSettingsBackButton}, + Handler: bot.displayShopItemHandler, + Interceptor: &Interceptor{ + Type: CallbackInterceptor, + Before: []intercept.Func{ + bot.localizerInterceptor, + bot.loadUserInterceptor, + bot.lockInterceptor, + }, + OnDefer: []intercept.Func{ + bot.unlockInterceptor, + }}, + }, + { + Endpoints: []interface{}{&shopItemDeleteButton}, + Handler: bot.shopItemDeleteHandler, + Interceptor: &Interceptor{ + Type: CallbackInterceptor, + Before: []intercept.Func{ + bot.localizerInterceptor, + bot.loadUserInterceptor, + bot.lockInterceptor, + }, + OnDefer: []intercept.Func{ + bot.unlockInterceptor, + }}, + }, + { + Endpoints: []interface{}{&shopItemPriceButton}, + Handler: bot.shopItemPriceHandler, + Interceptor: &Interceptor{ + Type: CallbackInterceptor, + Before: []intercept.Func{ + bot.localizerInterceptor, + bot.loadUserInterceptor, + bot.lockInterceptor, + }, + OnDefer: []intercept.Func{ + bot.unlockInterceptor, + }}, + }, + { + Endpoints: []interface{}{&shopItemTitleButton}, + Handler: bot.shopItemTitleHandler, + Interceptor: &Interceptor{ + Type: CallbackInterceptor, + Before: []intercept.Func{ + bot.localizerInterceptor, + bot.loadUserInterceptor, + bot.lockInterceptor, + }, + OnDefer: []intercept.Func{ + bot.unlockInterceptor, + }}, + }, + { + Endpoints: []interface{}{&shopItemAddFileButton}, + Handler: bot.shopItemAddItemHandler, + Interceptor: &Interceptor{ + Type: CallbackInterceptor, + Before: []intercept.Func{ + bot.localizerInterceptor, + bot.loadUserInterceptor, + bot.lockInterceptor, + }, + OnDefer: []intercept.Func{ + bot.unlockInterceptor, + }}, + }, + { + Endpoints: []interface{}{&shopItemBuyButton}, + Handler: bot.shopConfirmBuyHandler, + Interceptor: &Interceptor{ + Type: CallbackInterceptor, + Before: []intercept.Func{ + bot.localizerInterceptor, + bot.loadUserInterceptor, + bot.lockInterceptor, + }, + OnDefer: []intercept.Func{ + bot.unlockInterceptor, + }}, + }, + { + Endpoints: []interface{}{&shopItemCancelBuyButton}, + Handler: bot.displayShopItemHandler, + Interceptor: &Interceptor{ + Type: CallbackInterceptor, + Before: []intercept.Func{ + bot.localizerInterceptor, + bot.loadUserInterceptor, + bot.lockInterceptor, + }, + OnDefer: []intercept.Func{ + bot.unlockInterceptor, + }}, + }, } } diff --git a/internal/telegram/interceptor.go b/internal/telegram/interceptor.go index f886fdc..29cb848 100644 --- a/internal/telegram/interceptor.go +++ b/internal/telegram/interceptor.go @@ -3,10 +3,10 @@ package telegram import ( "context" "fmt" - "sync" - "github.com/LightningTipBot/LightningTipBot/internal/i18n" + "github.com/LightningTipBot/LightningTipBot/internal/runtime" i18n2 "github.com/nicksnyder/go-i18n/v2/i18n" + "strconv" "github.com/LightningTipBot/LightningTipBot/internal/lnbits" "github.com/LightningTipBot/LightningTipBot/internal/telegram/intercept" @@ -22,11 +22,6 @@ const ( QueryInterceptor ) -func init() { - handlerMutex = make(map[int64]*sync.Mutex) - handlerMapMutex = &sync.Mutex{} -} - var invalidTypeError = fmt.Errorf("invalid type") type Interceptor struct { @@ -36,22 +31,12 @@ type Interceptor struct { OnDefer []intercept.Func } -// handlerMapMutex to prevent concurrent map read / writes on HandlerMutex map -var handlerMapMutex *sync.Mutex - -// handlerMutex map holds mutex for every telegram user. Mutex locket as first before interceptor and unlocked on defer intercept -var handlerMutex map[int64]*sync.Mutex - // unlockInterceptor invoked as onDefer interceptor func (bot TipBot) unlockInterceptor(ctx context.Context, i interface{}) (context.Context, error) { user := getTelegramUserFromInterface(i) if user != nil { - handlerMapMutex.Lock() - if handlerMutex[user.ID] != nil { - handlerMutex[user.ID].Unlock() - } - handlerMapMutex.Unlock() - log.Debugf("[mutex] Unlocked user %d", user.ID) + runtime.Unlock(strconv.FormatInt(user.ID, 10)) + log.Tracef("[User mutex] Unlocked user %d", user.ID) } return ctx, nil } @@ -60,13 +45,8 @@ func (bot TipBot) unlockInterceptor(ctx context.Context, i interface{}) (context func (bot TipBot) lockInterceptor(ctx context.Context, i interface{}) (context.Context, error) { user := getTelegramUserFromInterface(i) if user != nil { - handlerMapMutex.Lock() - if handlerMutex[user.ID] == nil { - handlerMutex[user.ID] = &sync.Mutex{} - } - handlerMapMutex.Unlock() - handlerMutex[user.ID].Lock() - log.Debugf("[mutex] Locked user %d", user.ID) + runtime.Lock(strconv.FormatInt(user.ID, 10)) + log.Tracef("[User mutex] Locked user %d", user.ID) return ctx, nil } return nil, invalidTypeError diff --git a/internal/telegram/photo.go b/internal/telegram/photo.go index 7c8a9e0..155bcb6 100644 --- a/internal/telegram/photo.go +++ b/internal/telegram/photo.go @@ -34,13 +34,19 @@ func TryRecognizeQrCode(img image.Image) (*gozxing.Result, error) { } // photoHandler is the handler function for every photo from a private chat that the bot receives -func (bot TipBot) photoHandler(ctx context.Context, m *tb.Message) { +func (bot *TipBot) photoHandler(ctx context.Context, m *tb.Message) { if m.Chat.Type != tb.ChatPrivate { return } if m.Photo == nil { return } + user := LoadUser(ctx) + if c := stateCallbackMessage[user.StateKey]; c != nil { + c(ctx, m) + ResetUserState(user, bot) + return + } // get file reader closer from Telegram api reader, err := bot.Telegram.GetFile(m.Photo.MediaFile()) diff --git a/internal/telegram/send.go b/internal/telegram/send.go index 28fb425..4145087 100644 --- a/internal/telegram/send.go +++ b/internal/telegram/send.go @@ -273,7 +273,7 @@ func (bot *TipBot) confirmSendHandler(ctx context.Context, c *tb.Callback) { // bot.trySendMessage(c.Sender, sendErrorMessage) errmsg := fmt.Sprintf("[/send] Error: Transaction failed. %s", err) log.Errorln(errmsg) - bot.tryEditMessage(c.Message, fmt.Sprintf("%s %s", i18n.Translate(sendData.LanguageCode, "sendErrorMessage"), err), &tb.ReplyMarkup{}) + bot.tryEditMessage(c.Message, i18n.Translate(sendData.LanguageCode, "sendErrorMessage"), &tb.ReplyMarkup{}) return } sendData.Inactivate(sendData, bot.Bunt) diff --git a/internal/telegram/shop.go b/internal/telegram/shop.go new file mode 100644 index 0000000..b21a072 --- /dev/null +++ b/internal/telegram/shop.go @@ -0,0 +1,1364 @@ +package telegram + +import ( + "context" + "encoding/json" + "fmt" + "strings" + "time" + + "github.com/LightningTipBot/LightningTipBot/internal/i18n" + "github.com/LightningTipBot/LightningTipBot/internal/lnbits" + "github.com/LightningTipBot/LightningTipBot/internal/runtime" + "github.com/LightningTipBot/LightningTipBot/internal/storage/transaction" + "github.com/LightningTipBot/LightningTipBot/internal/str" + "github.com/eko/gocache/store" + log "github.com/sirupsen/logrus" + tb "gopkg.in/lightningtipbot/telebot.v2" +) + +type ShopView struct { + ID string + ShopID string + ShopOwner *lnbits.User + Page int + Message *tb.Message + StatusMessages []*tb.Message +} + +type ShopItem struct { + ID string `json:"ID"` // ID of the tx object in bunt db + ShopID string `json:"shopID"` // ID of the shop + Owner *lnbits.User `json:"owner"` // Owner of the item + Type string `json:"Type"` // Type of the tx object in bunt db + FileIDs []string `json:"fileIDs"` // Telegram fileID of the item files + FileTypes []string `json:"fileTypes"` // Telegram file type of the item files + Title string `json:"title"` // Title of the item + Description string `json:"description"` // Description of the item + Price int64 `json:"price"` // price of the item + NSold int `json:"nSold"` // number of times item was sold + TbPhoto *tb.Photo `json:"tbPhoto"` // Telegram photo object + LanguageCode string `json:"languagecode"` + MaxFiles int `json:"maxFiles"` +} + +type Shop struct { + *transaction.Base + ID string `json:"ID"` // holds the ID of the tx object in bunt db + Owner *lnbits.User `json:"owner"` // owner of the shop + Type string `json:"Type"` // type of the shop + Title string `json:"title"` // Title of the item + Description string `json:"description"` // Description of the item + ItemIds []string `json:"ItemsIDs"` // + Items map[string]ShopItem `json:"Items"` // + LanguageCode string `json:"languagecode"` + ShopsID string `json:"shopsID"` + MaxItems int `json:"maxItems"` +} + +type Shops struct { + *transaction.Base + ID string `json:"ID"` // holds the ID of the tx object in bunt db + Owner *lnbits.User `json:"owner"` // owner of the shop + Shops []string `json:"shop"` // + MaxShops int `json:"maxShops"` + Description string `json:"description"` +} + +const ( + MAX_SHOPS = 10 + MAX_ITEMS_PER_SHOP = 20 + MAX_FILES_PER_ITEM = 200 + SHOP_TITLE_MAX_LENGTH = 50 + ITEM_TITLE_MAX_LENGTH = 1500 + SHOPS_DESCRIPTION_MAX_LENGTH = 1500 +) + +func (shop *Shop) getItem(itemId string) (item ShopItem, ok bool) { + item, ok = shop.Items[itemId] + return +} + +var ( + shopKeyboard = &tb.ReplyMarkup{ResizeReplyKeyboard: false} + browseShopButton = shopKeyboard.Data("Browse shops", "shops_browse") + shopNewShopButton = shopKeyboard.Data("New Shop", "shops_newshop") + shopDeleteShopButton = shopKeyboard.Data("Delete Shops", "shops_deleteshop") + shopLinkShopButton = shopKeyboard.Data("Shop links", "shops_linkshop") + shopRenameShopButton = shopKeyboard.Data("Rename shop", "shops_renameshop") + shopResetShopAskButton = shopKeyboard.Data("Delete all shops", "shops_reset_ask") + shopResetShopButton = shopKeyboard.Data("Delete all shops", "shops_reset") + shopDescriptionShopButton = shopKeyboard.Data("Shop description", "shops_description") + shopSettingsButton = shopKeyboard.Data("Settings", "shops_settings") + shopShopsButton = shopKeyboard.Data("Back", "shops_shops") + + shopAddItemButton = shopKeyboard.Data("New item", "shop_additem") + shopNextitemButton = shopKeyboard.Data(">", "shop_nextitem") + shopPrevitemButton = shopKeyboard.Data("<", "shop_previtem") + shopBuyitemButton = shopKeyboard.Data("Buy", "shop_buyitem") + + shopSelectButton = shopKeyboard.Data("SHOP SELECTOR", "select_shop") // shop slectino buttons + shopDeleteSelectButton = shopKeyboard.Data("DELETE SHOP SELECTOR", "delete_shop") // shop slectino buttons + shopLinkSelectButton = shopKeyboard.Data("LINK SHOP SELECTOR", "link_shop") // shop slectino buttons + shopRenameSelectButton = shopKeyboard.Data("RENAME SHOP SELECTOR", "rename_shop") // shop slectino buttons + shopItemPriceButton = shopKeyboard.Data("Price", "shop_itemprice") + shopItemDeleteButton = shopKeyboard.Data("Delete", "shop_itemdelete") + shopItemTitleButton = shopKeyboard.Data("Set title", "shop_itemtitle") + shopItemAddFileButton = shopKeyboard.Data("Add file", "shop_itemaddfile") + shopItemSettingsButton = shopKeyboard.Data("Item settings", "shop_itemsettings") + shopItemSettingsBackButton = shopKeyboard.Data("Back", "shop_itemsettingsback") + + shopItemBuyButton = shopKeyboard.Data("Buy", "shop_itembuy") + shopItemCancelBuyButton = shopKeyboard.Data("Cancel", "shop_itemcancelbuy") +) + +// shopItemPriceHandler is invoked when the user presses the item settings button to set a price +func (bot *TipBot) shopItemPriceHandler(ctx context.Context, c *tb.Callback) { + user := LoadUser(ctx) + shopView, err := bot.getUserShopview(ctx, user) + if err != nil { + return + } + shop, err := bot.getShop(ctx, shopView.ShopID) + if shop.Owner.Telegram.ID != c.Sender.ID { + return + } + item := shop.Items[shop.ItemIds[shopView.Page]] + // sanity check + if item.ID != c.Data { + log.Error("[shopItemPriceHandler] item id mismatch") + return + } + // We need to save the pay state in the user state so we can load the payment in the next handler + SetUserState(user, bot, lnbits.UserStateShopItemSendPrice, item.ID) + bot.sendStatusMessage(ctx, c.Sender, fmt.Sprintf("๐Ÿ’ฏ Enter a price."), tb.ForceReply) +} + +// enterShopItemPriceHandler is invoked when the user enters a price amount +func (bot *TipBot) enterShopItemPriceHandler(ctx context.Context, m *tb.Message) { + user := LoadUser(ctx) + shopView, err := bot.getUserShopview(ctx, user) + if err != nil { + return + } + shop, err := bot.getShop(ctx, shopView.ShopID) + if err != nil { + return + } + if shop.Owner.Telegram.ID != m.Sender.ID { + return + } + item := shop.Items[shop.ItemIds[shopView.Page]] + // sanity check + if item.ID != user.StateData { + log.Error("[shopItemPriceHandler] item id mismatch") + return + } + if shop.Owner.Telegram.ID != m.Sender.ID { + return + } + + var amount int64 + if m.Text == "0" { + amount = 0 + } else { + amount, err = getAmount(m.Text) + if err != nil { + log.Warnf("[enterShopItemPriceHandler] %s", err.Error()) + bot.trySendMessage(m.Sender, Translate(ctx, "lnurlInvalidAmountMessage")) + ResetUserState(user, bot) + return //err, 0 + } + } + + if amount > 200 { + bot.sendStatusMessageAndDelete(ctx, m.Sender, fmt.Sprintf("โ„น๏ธ During alpha testing, price can be max 200 sat.")) + amount = 200 + } + item.Price = amount + shop.Items[item.ID] = item + runtime.IgnoreError(shop.Set(shop, bot.ShopBunt)) + bot.tryDeleteMessage(m) + bot.sendStatusMessageAndDelete(ctx, m.Sender, fmt.Sprintf("โœ… Price set.")) + ResetUserState(user, bot) + // go func() { + // time.Sleep(time.Duration(5) * time.Second) + // bot.shopViewDeleteAllStatusMsgs(ctx, user) + // }() + bot.displayShopItem(ctx, shopView.Message, shop) +} + +// shopItemPriceHandler is invoked when the user presses the item settings button to set a item title +func (bot *TipBot) shopItemTitleHandler(ctx context.Context, c *tb.Callback) { + user := LoadUser(ctx) + shopView, err := bot.getUserShopview(ctx, user) + if err != nil { + return + } + shop, err := bot.getShop(ctx, shopView.ShopID) + if shop.Owner.Telegram.ID != c.Sender.ID { + return + } + item := shop.Items[shop.ItemIds[shopView.Page]] + // sanity check + if item.ID != c.Data { + log.Error("[shopItemTitleHandler] item id mismatch") + return + } + // We need to save the pay state in the user state so we can load the payment in the next handler + SetUserState(user, bot, lnbits.UserStateShopItemSendTitle, item.ID) + bot.sendStatusMessage(ctx, c.Sender, fmt.Sprintf("โŒจ๏ธ Enter item title."), tb.ForceReply) +} + +// enterShopItemTitleHandler is invoked when the user enters a title of the item +func (bot *TipBot) enterShopItemTitleHandler(ctx context.Context, m *tb.Message) { + user := LoadUser(ctx) + shopView, err := bot.getUserShopview(ctx, user) + if err != nil { + return + } + shop, err := bot.getShop(ctx, shopView.ShopID) + if err != nil { + return + } + if shop.Owner.Telegram.ID != m.Sender.ID { + return + } + item := shop.Items[shop.ItemIds[shopView.Page]] + // sanity check + if item.ID != user.StateData { + log.Error("[enterShopItemTitleHandler] item id mismatch") + return + } + if shop.Owner.Telegram.ID != m.Sender.ID { + return + } + if len(m.Text) == 0 { + ResetUserState(user, bot) + bot.sendStatusMessageAndDelete(ctx, m.Sender, "๐Ÿšซ Action cancelled.") + go func() { + time.Sleep(time.Duration(5) * time.Second) + bot.shopViewDeleteAllStatusMsgs(ctx, user, 1) + }() + return + } + // crop item title + if len(m.Text) > ITEM_TITLE_MAX_LENGTH { + m.Text = m.Text[:ITEM_TITLE_MAX_LENGTH] + } + item.Title = m.Text + item.TbPhoto.Caption = m.Text + shop.Items[item.ID] = item + runtime.IgnoreError(shop.Set(shop, bot.ShopBunt)) + bot.tryDeleteMessage(m) + bot.sendStatusMessageAndDelete(ctx, m.Sender, fmt.Sprintf("โœ… Title set.")) + ResetUserState(user, bot) + // go func() { + // time.Sleep(time.Duration(5) * time.Second) + // bot.shopViewDeleteAllStatusMsgs(ctx, user) + // }() + bot.displayShopItem(ctx, shopView.Message, shop) +} + +// shopItemSettingsHandler is invoked when the user presses the item settings button +func (bot *TipBot) shopItemSettingsHandler(ctx context.Context, c *tb.Callback) { + user := LoadUser(ctx) + shopView, err := bot.getUserShopview(ctx, user) + if err != nil { + return + } + shop, err := bot.getShop(ctx, shopView.ShopID) + item := shop.Items[shop.ItemIds[shopView.Page]] + // sanity check + if item.ID != c.Data { + log.Error("[shopItemSettingsHandler] item id mismatch") + return + } + if item.TbPhoto != nil { + item.TbPhoto.Caption = bot.getItemTitle(ctx, &item) + } + bot.tryEditMessage(shopView.Message, item.TbPhoto, bot.shopItemSettingsMenu(ctx, shop, &item)) +} + +// shopItemPriceHandler is invoked when the user presses the item settings button to set a item title +func (bot *TipBot) shopItemDeleteHandler(ctx context.Context, c *tb.Callback) { + user := LoadUser(ctx) + shopView, err := bot.getUserShopview(ctx, user) + if err != nil { + return + } + shop, err := bot.getShop(ctx, shopView.ShopID) + if err != nil { + return + } + if shop.Owner.Telegram.ID != c.Sender.ID { + return + } + item := shop.Items[shop.ItemIds[shopView.Page]] + if shop.Owner.Telegram.ID != c.Sender.ID { + return + } + + // delete ItemID of item + for i, itemId := range shop.ItemIds { + if itemId == item.ID { + if len(shop.ItemIds) == 1 { + shop.ItemIds = []string{} + } else { + shop.ItemIds = append(shop.ItemIds[:i], shop.ItemIds[i+1:]...) + } + break + } + } + // delete item itself + delete(shop.Items, item.ID) + runtime.IgnoreError(shop.Set(shop, bot.ShopBunt)) + + ResetUserState(user, bot) + bot.sendStatusMessageAndDelete(ctx, c.Message.Chat, fmt.Sprintf("โœ… Item deleted.")) + // go func() { + // time.Sleep(time.Duration(5) * time.Second) + // bot.shopViewDeleteAllStatusMsgs(ctx, user) + // }() + if shopView.Page > 0 { + shopView.Page-- + } + bot.Cache.Set(shopView.ID, shopView, &store.Options{Expiration: 24 * time.Hour}) + bot.displayShopItem(ctx, shopView.Message, shop) +} + +// displayShopItemHandler is invoked when the user presses the back button in the item settings +func (bot *TipBot) displayShopItemHandler(ctx context.Context, c *tb.Callback) { + user := LoadUser(ctx) + shopView, err := bot.getUserShopview(ctx, user) + if err != nil { + return + } + shop, err := bot.getShop(ctx, shopView.ShopID) + // item := shop.Items[shop.ItemIds[shopView.Page]] + // // sanity check + // if item.ID != c.Data { + // log.Error("[shopItemSettingsHandler] item id mismatch") + // return + // } + bot.displayShopItem(ctx, c.Message, shop) +} + +// shopNextItemHandler is invoked when the user presses the next item button +func (bot *TipBot) shopNextItemButtonHandler(ctx context.Context, c *tb.Callback) { + user := LoadUser(ctx) + // shopView, err := bot.Cache.Get(fmt.Sprintf("shopview-%d", user.Telegram.ID)) + shopView, err := bot.getUserShopview(ctx, user) + if err != nil { + return + } + shop, err := bot.getShop(ctx, shopView.ShopID) + if shopView.Page < len(shop.Items)-1 { + shopView.Page++ + bot.Cache.Set(shopView.ID, shopView, &store.Options{Expiration: 24 * time.Hour}) + shop, err = bot.getShop(ctx, shopView.ShopID) + bot.displayShopItem(ctx, c.Message, shop) + } +} + +// shopPrevItemButtonHandler is invoked when the user presses the previous item button +func (bot *TipBot) shopPrevItemButtonHandler(ctx context.Context, c *tb.Callback) { + user := LoadUser(ctx) + shopView, err := bot.getUserShopview(ctx, user) + if err != nil { + return + } + if shopView.Page == 0 { + c.Message.Text = "/shops " + shopView.ShopOwner.Telegram.Username + bot.shopsHandler(ctx, c.Message) + return + } + if shopView.Page > 0 { + shopView.Page-- + } + bot.Cache.Set(shopView.ID, shopView, &store.Options{Expiration: 24 * time.Hour}) + shop, err := bot.getShop(ctx, shopView.ShopID) + bot.displayShopItem(ctx, c.Message, shop) +} + +func (bot *TipBot) getItemTitle(ctx context.Context, item *ShopItem) string { + caption := "" + if len(item.Title) > 0 { + caption = fmt.Sprintf("%s", item.Title) + } + if len(item.FileIDs) > 0 { + if len(caption) > 0 { + caption += " " + } + caption += fmt.Sprintf("(%d Files)", len(item.FileIDs)) + } + if item.Price > 0 { + caption += fmt.Sprintf("\n\n๐Ÿ’ธ Price: %d sat", item.Price) + } + // item.TbPhoto.Caption = caption + return caption +} + +// displayShopItem renders the current item in the shopView +// requires that the shopview page is already set accordingly +// m is the message that will be edited +func (bot *TipBot) displayShopItem(ctx context.Context, m *tb.Message, shop *Shop) *tb.Message { + user := LoadUser(ctx) + shopView, err := bot.getUserShopview(ctx, user) + if err != nil { + log.Errorf("[displayShopItem] %s", err.Error()) + return nil + } + // failsafe: if the page is out of bounds, reset it + if shopView.Page >= len(shop.Items) { + shopView.Page = len(shop.Items) - 1 + } + + if len(shop.Items) == 0 { + no_items_message := "There are no items in this shop yet." + if len(shopView.Message.Text) > 0 { + shopView.Message = bot.tryEditMessage(shopView.Message, no_items_message, bot.shopMenu(ctx, shop, &ShopItem{})) + } else { + bot.tryDeleteMessage(shopView.Message) + shopView.Message = bot.trySendMessage(shopView.Message.Chat, no_items_message, bot.shopMenu(ctx, shop, &ShopItem{})) + } + shopView.Page = 0 + return shopView.Message + } + + item := shop.Items[shop.ItemIds[shopView.Page]] + if item.TbPhoto != nil { + item.TbPhoto.Caption = bot.getItemTitle(ctx, &item) + } + + // var msg *tb.Message + if shopView.Message != nil { + if item.TbPhoto != nil { + if shopView.Message.Photo != nil { + // can only edit photo messages with another photo + shopView.Message = bot.tryEditMessage(shopView.Message, item.TbPhoto, bot.shopMenu(ctx, shop, &item)) + } else { + // if editing failes + bot.tryDeleteMessage(shopView.Message) + shopView.Message = bot.trySendMessage(shopView.Message.Chat, item.TbPhoto, bot.shopMenu(ctx, shop, &item)) + } + } else if item.Title != "" { + shopView.Message = bot.tryEditMessage(shopView.Message, item.Title, bot.shopMenu(ctx, shop, &item)) + if shopView.Message == nil { + shopView.Message = bot.trySendMessage(shopView.Message.Chat, item.Title, bot.shopMenu(ctx, shop, &item)) + } + } + } else { + if m != nil && m.Chat != nil { + shopView.Message = bot.trySendMessage(m.Chat, item.TbPhoto, bot.shopMenu(ctx, shop, &item)) + } else { + shopView.Message = bot.trySendMessage(user.Telegram, item.TbPhoto, bot.shopMenu(ctx, shop, &item)) + } + // shopView.Page = 0 + } + // shopView.Message = msg + bot.Cache.Set(shopView.ID, shopView, &store.Options{Expiration: 24 * time.Hour}) + return shopView.Message +} + +// shopHandler is invoked when the user enters /shop +func (bot *TipBot) shopHandler(ctx context.Context, m *tb.Message) { + if !m.Private() { + return + } + user := LoadUser(ctx) + shopOwner := user + + // when no argument is given, i.e. command is only /shop, load /shops + shop := &Shop{} + if len(strings.Split(m.Text, " ")) < 2 || !strings.HasPrefix(strings.Split(m.Text, " ")[1], "shop-") { + bot.shopsHandler(ctx, m) + return + } else { + // else: get shop by shop ID + shopID := strings.Split(m.Text, " ")[1] + var err error + shop, err = bot.getShop(ctx, shopID) + if err != nil { + log.Errorf("[shopHandler] %s", err) + return + } + } + shopOwner = shop.Owner + shopView := ShopView{ + ID: fmt.Sprintf("shopview-%d", user.Telegram.ID), + ShopID: shop.ID, + Page: 0, + ShopOwner: shopOwner, + } + // bot.Cache.Set(shopView.ID, shopView, &store.Options{Expiration: 24 * time.Hour}) + shopView.Message = bot.displayShopItem(ctx, m, shop) + // shopMessage := &tb.Message{Chat: m.Chat} + // if len(shop.ItemIds) > 0 { + // // item := shop.Items[shop.ItemIds[shopView.Page]] + // // shopMessage = bot.trySendMessage(m.Chat, item.TbPhoto, bot.shopMenu(ctx, shop, &item)) + // shopMessage = bot.displayShopItem(ctx, m, shop) + // } else { + // shopMessage = bot.trySendMessage(m.Chat, "No items in shop.", bot.shopMenu(ctx, shop, &ShopItem{})) + // } + // shopView.Message = shopMessage + bot.Cache.Set(shopView.ID, shopView, &store.Options{Expiration: 24 * time.Hour}) + return +} + +// shopNewItemHandler is invoked when the user presses the new item button +func (bot *TipBot) shopNewItemHandler(ctx context.Context, c *tb.Callback) { + user := LoadUser(ctx) + shop, err := bot.getShop(ctx, c.Data) + if err != nil { + log.Errorf("[shopNewItemHandler] %s", err) + return + } + if shop.Owner.Telegram.ID != c.Sender.ID { + return + } + if len(shop.Items) >= shop.MaxItems { + bot.trySendMessage(c.Sender, fmt.Sprintf("๐Ÿšซ You can only have %d items in this shop. Delete an item to add a new one.", shop.MaxItems)) + return + } + + // We need to save the pay state in the user state so we can load the payment in the next handler + paramsJson, err := json.Marshal(shop) + if err != nil { + log.Errorf("[lnurlWithdrawHandler] Error: %s", err.Error()) + // bot.trySendMessage(m.Sender, err.Error()) + return + } + SetUserState(user, bot, lnbits.UserStateShopItemSendPhoto, string(paramsJson)) + bot.sendStatusMessage(ctx, c.Sender, fmt.Sprintf("๐ŸŒ„ Send me an image.")) +} + +// addShopItem is a helper function for creating a shop item in the database +func (bot *TipBot) addShopItem(ctx context.Context, shopId string) (*Shop, ShopItem, error) { + shop, err := bot.getShop(ctx, shopId) + if err != nil { + log.Errorf("[addShopItem] %s", err) + return shop, ShopItem{}, err + } + user := LoadUser(ctx) + // onnly the correct user can press + if shop.Owner.Telegram.ID != user.Telegram.ID { + return shop, ShopItem{}, fmt.Errorf("not owner") + } + // err = shop.Lock(shop, bot.ShopBunt) + // defer shop.Release(shop, bot.ShopBunt) + + itemId := fmt.Sprintf("item-%s-%s", shop.ID, RandStringRunes(8)) + item := ShopItem{ + ID: itemId, + ShopID: shop.ID, + Owner: user, + Type: "photo", + LanguageCode: shop.LanguageCode, + MaxFiles: MAX_FILES_PER_ITEM, + } + shop.Items[itemId] = item + shop.ItemIds = append(shop.ItemIds, itemId) + runtime.IgnoreError(shop.Set(shop, bot.ShopBunt)) + return shop, shop.Items[itemId], nil +} + +// addShopItemPhoto is invoked when the users sends a photo as a new item +func (bot *TipBot) addShopItemPhoto(ctx context.Context, m *tb.Message) { + user := LoadUser(ctx) + if user.Wallet == nil { + return // errors.New("user has no wallet"), 0 + } + + // read item from user.StateData + var state_shop Shop + err := json.Unmarshal([]byte(user.StateData), &state_shop) + if err != nil { + log.Errorf("[lnurlWithdrawHandlerWithdraw] Error: %s", err.Error()) + bot.trySendMessage(m.Sender, Translate(ctx, "errorTryLaterMessage"), Translate(ctx, "errorTryLaterMessage")) + return + } + if state_shop.Owner.Telegram.ID != m.Sender.ID { + return + } + if m.Photo == nil { + bot.sendStatusMessageAndDelete(ctx, m.Sender, fmt.Sprintf("๐Ÿšซ That didn't work. You need to send an image (not a file).")) + ResetUserState(user, bot) + return + } + + shop, item, err := bot.addShopItem(ctx, state_shop.ID) + // err = shop.Lock(shop, bot.ShopBunt) + // defer shop.Release(shop, bot.ShopBunt) + item.TbPhoto = m.Photo + item.Title = m.Caption + shop.Items[item.ID] = item + runtime.IgnoreError(shop.Set(shop, bot.ShopBunt)) + + bot.tryDeleteMessage(m) + bot.sendStatusMessageAndDelete(ctx, m.Sender, fmt.Sprintf("โœ… Image added.")) + ResetUserState(user, bot) + // go func() { + // time.Sleep(time.Duration(5) * time.Second) + // bot.shopViewDeleteAllStatusMsgs(ctx, user) + // }() + + shopView, err := bot.getUserShopview(ctx, user) + shopView.Page = len(shop.Items) - 1 + bot.Cache.Set(shopView.ID, shopView, &store.Options{Expiration: 24 * time.Hour}) + bot.displayShopItem(ctx, shopView.Message, shop) + + log.Infof("[๐Ÿ› shop] %s added an item %s:%s.", GetUserStr(user.Telegram), shop.ID, item.ID) +} + +// ------------------- item files ---------- +// shopItemAddItemHandler is invoked when the user presses the new item button +func (bot *TipBot) shopItemAddItemHandler(ctx context.Context, c *tb.Callback) { + user := LoadUser(ctx) + if user.Wallet == nil { + return // errors.New("user has no wallet"), 0 + } + shopView, err := bot.getUserShopview(ctx, user) + if err != nil { + log.Errorf("[addItemFileHandler] %s", err.Error()) + return + } + + shop, err := bot.getShop(ctx, shopView.ShopID) + if err != nil { + log.Errorf("[shopNewItemHandler] %s", err) + return + } + + itemID := c.Data + + item := shop.Items[itemID] + + if len(item.FileIDs) >= item.MaxFiles { + bot.trySendMessage(c.Sender, fmt.Sprintf("๐Ÿšซ You can only have %d files in this item.", item.MaxFiles)) + return + } + SetUserState(user, bot, lnbits.UserStateShopItemSendItemFile, c.Data) + bot.sendStatusMessage(ctx, c.Sender, fmt.Sprintf("๐Ÿ’พ Send me one or more files.")) +} + +// addItemFileHandler is invoked when the users sends a new file for the item +func (bot *TipBot) addItemFileHandler(ctx context.Context, m *tb.Message) { + user := LoadUser(ctx) + if user.Wallet == nil { + return // errors.New("user has no wallet"), 0 + } + shopView, err := bot.getUserShopview(ctx, user) + if err != nil { + log.Errorf("[addItemFileHandler] %s", err.Error()) + return + } + + shop, err := bot.getShop(ctx, shopView.ShopID) + if err != nil { + log.Errorf("[shopNewItemHandler] %s", err) + return + } + + itemID := user.StateData + + item := shop.Items[itemID] + if m.Photo != nil { + item.FileIDs = append(item.FileIDs, m.Photo.FileID) + item.FileTypes = append(item.FileTypes, "photo") + bot.sendStatusMessageAndDelete(ctx, m.Sender, fmt.Sprintf("โ„น๏ธ To send more than one photo at a time, send them as files.")) + } else if m.Document != nil { + item.FileIDs = append(item.FileIDs, m.Document.FileID) + item.FileTypes = append(item.FileTypes, "document") + } else if m.Audio != nil { + item.FileIDs = append(item.FileIDs, m.Audio.FileID) + item.FileTypes = append(item.FileTypes, "audio") + } else if m.Video != nil { + item.FileIDs = append(item.FileIDs, m.Video.FileID) + item.FileTypes = append(item.FileTypes, "video") + } else if m.Voice != nil { + item.FileIDs = append(item.FileIDs, m.Voice.FileID) + item.FileTypes = append(item.FileTypes, "voice") + } else if m.VideoNote != nil { + item.FileIDs = append(item.FileIDs, m.VideoNote.FileID) + item.FileTypes = append(item.FileTypes, "videonote") + } else if m.Sticker != nil { + item.FileIDs = append(item.FileIDs, m.Sticker.FileID) + item.FileTypes = append(item.FileTypes, "sticker") + } else { + log.Errorf("[addItemFileHandler] no file found") + return + } + shop.Items[item.ID] = item + + runtime.IgnoreError(shop.Set(shop, bot.ShopBunt)) + bot.tryDeleteMessage(m) + bot.sendStatusMessageAndDelete(ctx, m.Sender, fmt.Sprintf("โœ… File added.")) + + // ticker := runtime.GetTicker(shop.ID, runtime.WithDuration(5*time.Second)) + // if !ticker.Started { + // ticker.Do(func() { + // bot.shopViewDeleteAllStatusMsgs(ctx, user) + // // removing ticker asap done + // runtime.RemoveTicker(shop.ID) + // }) + // } else { + // ticker.ResetChan <- struct{}{} + // } + + // // start a ticker to check if the user has sent more files + // if t, ok := fileStateResetTicker.Get(shop.ID); ok { + // // state reset ticker found. resetting ticker. + // t.(*runtime.ResettableFunctionTicker).ResetChan <- struct{}{} + // } else { + // // state reset ticker not found. creating new one. + // ticker := runtime.NewResettableFunctionTicker(runtime.WithDuration(time.Second * 5)) + // // storing reset ticker in mem + // fileStateResetTicker.Set(shop.ID, ticker) + // go func() { + // // starting ticker + // ticker.Do(func() { + // // time.Sleep(time.Duration(5) * time.Second) + // bot.shopViewDeleteAllStatusMsgs(ctx, user) + // // removing ticker asap done + // fileStateResetTicker.Remove(shop.ID) + // }) + // }() + // } + + // go func() { + // time.Sleep(time.Duration(5) * time.Second) + // bot.shopViewDeleteAllStatusMsgs(ctx, user) + // }() + bot.displayShopItem(ctx, shopView.Message, shop) + log.Infof("[๐Ÿ› shop] %s added a file to shop:item %s:%s.", GetUserStr(user.Telegram), shop.ID, item.ID) +} + +func (bot *TipBot) shopGetItemFilesHandler(ctx context.Context, c *tb.Callback) { + user := LoadUser(ctx) + if user.Wallet == nil { + return // errors.New("user has no wallet"), 0 + } + shopView, err := bot.getUserShopview(ctx, user) + if err != nil { + log.Errorf("[addItemFileHandler] %s", err.Error()) + return + } + shop, err := bot.getShop(ctx, shopView.ShopID) + if err != nil { + log.Errorf("[shopNewItemHandler] %s", err) + return + } + itemID := c.Data + item := shop.Items[itemID] + + if item.Price <= 0 { + bot.shopSendItemFilesToUser(ctx, user, itemID) + } else { + if item.TbPhoto != nil { + item.TbPhoto.Caption = bot.getItemTitle(ctx, &item) + } + bot.tryEditMessage(shopView.Message, item.TbPhoto, bot.shopItemConfirmBuyMenu(ctx, shop, &item)) + } + + // // send the cover image + // bot.sendFileByID(ctx, c.Sender, item.TbPhoto.FileID, "photo") + // // and all other files + // for i, fileID := range item.FileIDs { + // bot.sendFileByID(ctx, c.Sender, fileID, item.FileTypes[i]) + // } + // log.Infof("[๐Ÿ› shop] %s got %d items from %s's item %s (for %d sat).", GetUserStr(user.Telegram), len(item.FileIDs), GetUserStr(shop.Owner.Telegram), item.ID, item.Price) + +} + +// shopConfirmBuyHandler is invoked when the user has confirmed to pay for an item +func (bot *TipBot) shopConfirmBuyHandler(ctx context.Context, c *tb.Callback) { + user := LoadUser(ctx) + if user.Wallet == nil { + return // errors.New("user has no wallet"), 0 + } + shopView, err := bot.getUserShopview(ctx, user) + if err != nil { + log.Errorf("[shopConfirmBuyHandler] %s", err.Error()) + return + } + shop, err := bot.getShop(ctx, shopView.ShopID) + if err != nil { + log.Errorf("[shopConfirmBuyHandler] %s", err) + return + } + itemID := c.Data + item := shop.Items[itemID] + if item.Owner.ID != shop.Owner.ID { + log.Errorf("[shopConfirmBuyHandler] Owners do not match.") + return + } + from := user + to := shop.Owner + + // fromUserStr := GetUserStr(from.Telegram) + // fromUserStrMd := GetUserStrMd(from.Telegram) + toUserStr := GetUserStr(to.Telegram) + toUserStrMd := GetUserStrMd(to.Telegram) + amount := item.Price + if amount <= 0 { + log.Errorf("[shopConfirmBuyHandler] item has no price.") + return + } + transactionMemo := fmt.Sprintf("Buy item %s (%d sat).", toUserStr, amount) + t := NewTransaction(bot, from, to, amount, TransactionType("shop")) + t.Memo = transactionMemo + + success, err := t.Send() + if !success || err != nil { + // bot.trySendMessage(c.Sender, sendErrorMessage) + errmsg := fmt.Sprintf("[shop] Error: Transaction failed. %s", err) + log.Errorln(errmsg) + bot.trySendMessage(user.Telegram, i18n.Translate(user.Telegram.LanguageCode, "sendErrorMessage"), &tb.ReplyMarkup{}) + return + } + // bot.trySendMessage(user.Telegram, fmt.Sprintf("๐Ÿ› %d sat sent to %s.", amount, toUserStrMd), &tb.ReplyMarkup{}) + shopItemTitle := "an item" + if len(item.Title) > 0 { + shopItemTitle = fmt.Sprintf("%s", item.Title) + } + bot.trySendMessage(to.Telegram, fmt.Sprintf("๐Ÿ› Someone bought `%s` from your shop `%s` for `%d sat`.", str.MarkdownEscape(shopItemTitle), str.MarkdownEscape(shop.Title), amount)) + bot.trySendMessage(from.Telegram, fmt.Sprintf("๐Ÿ› You bought `%s` from %s's shop `%s` for `%d sat`.", str.MarkdownEscape(shopItemTitle), toUserStrMd, str.MarkdownEscape(shop.Title), amount)) + log.Infof("[๐Ÿ› shop] %s bought `%s` from %s's shop `%s` for `%d sat`.", str.MarkdownEscape(shopItemTitle), toUserStrMd, str.MarkdownEscape(shop.Title), amount) + bot.shopSendItemFilesToUser(ctx, user, itemID) +} + +// shopSendItemFilesToUser is a handler function to send itemID's files to the user +func (bot *TipBot) shopSendItemFilesToUser(ctx context.Context, toUser *lnbits.User, itemID string) { + user := LoadUser(ctx) + if user.Wallet == nil { + return // errors.New("user has no wallet"), 0 + } + shopView, err := bot.getUserShopview(ctx, user) + if err != nil { + log.Errorf("[addItemFileHandler] %s", err.Error()) + return + } + shop, err := bot.getShop(ctx, shopView.ShopID) + if err != nil { + log.Errorf("[shopNewItemHandler] %s", err) + return + } + item := shop.Items[itemID] + // send the cover image + bot.sendFileByID(ctx, toUser.Telegram, item.TbPhoto.FileID, "photo") + // and all other files + for i, fileID := range item.FileIDs { + bot.sendFileByID(ctx, toUser.Telegram, fileID, item.FileTypes[i]) + } + log.Infof("[๐Ÿ› shop] %s got %d items from %s's item %s (for %d sat).", GetUserStr(user.Telegram), len(item.FileIDs), GetUserStr(shop.Owner.Telegram), item.ID, item.Price) + + // delete old shop and show again below the files + if shopView.Message != nil { + bot.tryDeleteMessage(shopView.Message) + } + shopView.Message = nil + bot.Cache.Set(shopView.ID, shopView, &store.Options{Expiration: 24 * time.Hour}) + bot.displayShopItem(ctx, &tb.Message{}, shop) +} + +func (bot *TipBot) sendFileByID(ctx context.Context, to tb.Recipient, fileId string, fileType string) { + switch fileType { + case "photo": + sendable := &tb.Photo{File: tb.File{FileID: fileId}} + bot.trySendMessage(to, sendable) + case "document": + sendable := &tb.Document{File: tb.File{FileID: fileId}} + bot.trySendMessage(to, sendable) + case "audio": + sendable := &tb.Audio{File: tb.File{FileID: fileId}} + bot.trySendMessage(to, sendable) + case "video": + sendable := &tb.Video{File: tb.File{FileID: fileId}} + bot.trySendMessage(to, sendable) + case "voice": + sendable := &tb.Voice{File: tb.File{FileID: fileId}} + bot.trySendMessage(to, sendable) + case "videonote": + sendable := &tb.VideoNote{File: tb.File{FileID: fileId}} + bot.trySendMessage(to, sendable) + case "sticker": + sendable := &tb.Sticker{File: tb.File{FileID: fileId}} + bot.trySendMessage(to, sendable) + } + return +} + +// -------------- shops handler -------------- +// var ShopsText = "*Welcome to %s shop.*\n%s\nThere are %d shops here.\n%s" +var ShopsText = "" +var ShopsTextWelcome = "*You are browsing %s shop.*" +var ShopsTextShopCount = "*Browse %d shops:*" +var ShopsTextHelp = "โš ๏ธ Shops are still in beta. Expect bugs." +var ShopsNoShopsText = "*There are no shops here yet.*" + +// shopsHandlerCallback is a warpper for shopsHandler for callbacks +func (bot *TipBot) shopsHandlerCallback(ctx context.Context, c *tb.Callback) { + bot.shopsHandler(ctx, c.Message) +} + +// shopsHandler is invoked when the user enters /shops +func (bot *TipBot) shopsHandler(ctx context.Context, m *tb.Message) { + if !m.Private() { + return + } + user := LoadUser(ctx) + shopOwner := user + + // if the user in the command, i.e. /shops @user + if len(strings.Split(m.Text, " ")) > 1 && strings.HasPrefix(strings.Split(m.Text, " ")[0], "/shop") { + toUserStrMention := "" + toUserStrWithoutAt := "" + + // check for user in command, accepts user mention or plain username without @ + if len(m.Entities) > 1 && m.Entities[1].Type == "mention" { + toUserStrMention = m.Text[m.Entities[1].Offset : m.Entities[1].Offset+m.Entities[1].Length] + toUserStrWithoutAt = strings.TrimPrefix(toUserStrMention, "@") + } else { + var err error + toUserStrWithoutAt, err = getArgumentFromCommand(m.Text, 1) + if err != nil { + log.Errorln(err.Error()) + return + } + toUserStrWithoutAt = strings.TrimPrefix(toUserStrWithoutAt, "@") + toUserStrMention = "@" + toUserStrWithoutAt + } + + toUserDb, err := GetUserByTelegramUsername(toUserStrWithoutAt, *bot) + if err != nil { + NewMessage(m, WithDuration(0, bot)) + // cut username if it's too long + if len(toUserStrMention) > 100 { + toUserStrMention = toUserStrMention[:100] + } + bot.trySendMessage(m.Sender, fmt.Sprintf(Translate(ctx, "sendUserHasNoWalletMessage"), str.MarkdownEscape(toUserStrMention))) + return + } + // overwrite user with the one from db + shopOwner = toUserDb + } else if !strings.HasPrefix(strings.Split(m.Text, " ")[0], "/shop") { + // otherwise, the user is returning to a shops view from a back button callback + shopView, err := bot.getUserShopview(ctx, user) + if err == nil { + shopOwner = shopView.ShopOwner + } + } + + if shopOwner == nil { + log.Error("[shopsHandler] shopOwner is nil") + return + } + shops, err := bot.getUserShops(ctx, shopOwner) + if err != nil && user.Telegram.ID == shopOwner.Telegram.ID { + shops, err = bot.initUserShops(ctx, user) + if err != nil { + log.Errorf("[shopsHandler] %s", err) + return + } + } + + if len(shops.Shops) == 0 && user.Telegram.ID != shopOwner.Telegram.ID { + bot.trySendMessage(m.Chat, fmt.Sprintf("This user has no shops yet.")) + return + } + + // build shop list + shopTitles := "" + for _, shopId := range shops.Shops { + shop, err := bot.getShop(ctx, shopId) + if err != nil { + log.Errorf("[shopsHandler] %s", err) + return + } + shopTitles += fmt.Sprintf("\nยท %s (%d items)", str.MarkdownEscape(shop.Title), len(shop.Items)) + + } + + // build shop text + + // shows "your shop" or "@other's shop" + shopOwnerText := "your" + if shopOwner.Telegram.ID != user.Telegram.ID { + shopOwnerText = fmt.Sprintf("%s's", GetUserStrMd(shopOwner.Telegram)) + } + ShopsText = fmt.Sprintf(ShopsTextWelcome, shopOwnerText) + if len(shops.Description) > 0 { + ShopsText += fmt.Sprintf("\n\n%s\n", shops.Description) + } else { + ShopsText += "\n" + } + if len(shops.Shops) > 0 { + ShopsText += fmt.Sprintf("\n%s\n", fmt.Sprintf(ShopsTextShopCount, len(shops.Shops))) + } else { + ShopsText += fmt.Sprintf("\n%s\n", ShopsNoShopsText) + } + + if len(shops.Shops) > 0 { + ShopsText += fmt.Sprintf("%s\n", shopTitles) + } + ShopsText += fmt.Sprintf("\n%s", ShopsTextHelp) + + // fmt.Sprintf(ShopsText, shopOwnerText, len(shops.Shops), shopTitles) + + // if the user used the command /shops, we will send a new message + // if the user clicked a button and has a shopview set, we will edit an old message + shopView, err := bot.getUserShopview(ctx, user) + var shopsMsg *tb.Message + if err == nil && !strings.HasPrefix(strings.Split(m.Text, " ")[0], "/shop") { + // the user is returning to a shops view from a back button callback + if shopView.Message.Photo == nil { + shopsMsg = bot.tryEditMessage(shopView.Message, ShopsText, bot.shopsMainMenu(ctx, shops)) + } + if shopsMsg == nil { + // if editing has failed, we will send a new message + bot.tryDeleteMessage(shopView.Message) + shopsMsg = bot.trySendMessage(m.Chat, ShopsText, bot.shopsMainMenu(ctx, shops)) + + } + } else { + // the user has entered /shops or + // the user has no shopview set, so we will send a new message + if shopView.Message != nil { + // delete any old shop message + bot.tryDeleteMessage(shopView.Message) + } + shopsMsg = bot.trySendMessage(m.Chat, ShopsText, bot.shopsMainMenu(ctx, shops)) + } + shopViewNew := ShopView{ + ID: fmt.Sprintf("shopview-%d", user.Telegram.ID), + Message: shopsMsg, + ShopOwner: shopOwner, + StatusMessages: shopView.StatusMessages, // keep the old status messages + } + bot.Cache.Set(shopViewNew.ID, shopViewNew, &store.Options{Expiration: 24 * time.Hour}) + return +} + +// shopsDeleteShopBrowser is invoked when the user clicks on "delete shops" and makes a list of all shops +func (bot *TipBot) shopsDeleteShopBrowser(ctx context.Context, c *tb.Callback) { + user := LoadUser(ctx) + shops, err := bot.getUserShops(ctx, user) + if err != nil { + return + } + var s []*Shop + for _, shopId := range shops.Shops { + shop, _ := bot.getShop(ctx, shopId) + if shop.Owner.Telegram.ID != c.Sender.ID { + return + } + s = append(s, shop) + } + shopShopsButton := shopKeyboard.Data("โฌ…๏ธ Back", "shops_shops", shops.ID) + + shopResetShopAskButton = shopKeyboard.Data("โš ๏ธ Delete all shops", "shops_reset_ask", shops.ID) + shopKeyboard.Inline(buttonWrapper(append(bot.makseShopSelectionButtons(s, "delete_shop"), shopResetShopAskButton, shopShopsButton), shopKeyboard, 1)...) + bot.tryEditMessage(c.Message, "Which shop do you want to delete?", shopKeyboard) +} + +func (bot *TipBot) shopsAskDeleteAllShopsHandler(ctx context.Context, c *tb.Callback) { + shopResetShopButton := shopKeyboard.Data("โš ๏ธ Delete all shops", "shops_reset", c.Data) + buttons := []tb.Row{ + shopKeyboard.Row(shopResetShopButton), + shopKeyboard.Row(shopShopsButton), + } + shopKeyboard.Inline( + buttons..., + ) + bot.tryEditMessage(c.Message, "Are you sure you want to delete all shops?\nYou will lose all items as well.", shopKeyboard) +} + +// shopsLinkShopBrowser is invoked when the user clicks on "shop links" and makes a list of all shops +func (bot *TipBot) shopsLinkShopBrowser(ctx context.Context, c *tb.Callback) { + user := LoadUser(ctx) + shops, err := bot.getUserShops(ctx, user) + if err != nil { + return + } + var s []*Shop + for _, shopId := range shops.Shops { + shop, _ := bot.getShop(ctx, shopId) + if shop.Owner.Telegram.ID != c.Sender.ID { + return + } + s = append(s, shop) + } + shopShopsButton := shopKeyboard.Data("โฌ…๏ธ Back", "shops_shops", shops.ID) + shopKeyboard.Inline(buttonWrapper(append(bot.makseShopSelectionButtons(s, "link_shop"), shopShopsButton), shopKeyboard, 1)...) + bot.tryEditMessage(c.Message, "Select the shop you want to get the link of.", shopKeyboard) +} + +// shopSelectLink is invoked when the user has chosen a shop to get the link of +func (bot *TipBot) shopSelectLink(ctx context.Context, c *tb.Callback) { + shop, _ := bot.getShop(ctx, c.Data) + if shop.Owner.Telegram.ID != c.Sender.ID { + return + } + bot.trySendMessage(c.Sender, fmt.Sprintf("*%s*: `/shop %s`", shop.Title, shop.ID)) +} + +// shopsLinkShopBrowser is invoked when the user clicks on "shop links" and makes a list of all shops +func (bot *TipBot) shopsRenameShopBrowser(ctx context.Context, c *tb.Callback) { + user := LoadUser(ctx) + shops, err := bot.getUserShops(ctx, user) + if err != nil { + return + } + var s []*Shop + for _, shopId := range shops.Shops { + shop, _ := bot.getShop(ctx, shopId) + if shop.Owner.Telegram.ID != c.Sender.ID { + return + } + s = append(s, shop) + } + shopShopsButton := shopKeyboard.Data("โฌ…๏ธ Back", "shops_shops", shops.ID) + shopKeyboard.Inline(buttonWrapper(append(bot.makseShopSelectionButtons(s, "rename_shop"), shopShopsButton), shopKeyboard, 1)...) + bot.tryEditMessage(c.Message, "Select the shop you want to rename.", shopKeyboard) +} + +// shopSelectLink is invoked when the user has chosen a shop to get the link of +func (bot *TipBot) shopSelectRename(ctx context.Context, c *tb.Callback) { + user := LoadUser(ctx) + shop, _ := bot.getShop(ctx, c.Data) + if shop.Owner.Telegram.ID != c.Sender.ID { + return + } + // We need to save the pay state in the user state so we can load the payment in the next handler + SetUserState(user, bot, lnbits.UserEnterShopTitle, shop.ID) + bot.sendStatusMessage(ctx, c.Sender, fmt.Sprintf("โŒจ๏ธ Enter the name of your shop."), tb.ForceReply) +} + +// shopsDescriptionHandler is invoked when the user clicks on "description" to set a shop description +func (bot *TipBot) shopsDescriptionHandler(ctx context.Context, c *tb.Callback) { + user := LoadUser(ctx) + shops, err := bot.getUserShops(ctx, user) + if err != nil { + log.Errorf("[shopsDescriptionHandler] %s", err) + return + } + SetUserState(user, bot, lnbits.UserEnterShopsDescription, shops.ID) + bot.sendStatusMessage(ctx, c.Sender, fmt.Sprintf("โŒจ๏ธ Enter a description."), tb.ForceReply) +} + +// enterShopsDescriptionHandler is invoked when the user enters the shop title +func (bot *TipBot) enterShopsDescriptionHandler(ctx context.Context, m *tb.Message) { + user := LoadUser(ctx) + shops, err := bot.getUserShops(ctx, user) + if err != nil { + log.Errorf("[enterShopsDescriptionHandler] %s", err) + return + } + if shops.Owner.Telegram.ID != m.Sender.ID { + return + } + if len(m.Text) == 0 { + ResetUserState(user, bot) + bot.sendStatusMessageAndDelete(ctx, m.Sender, "๐Ÿšซ Action cancelled.") + go func() { + time.Sleep(time.Duration(5) * time.Second) + bot.shopViewDeleteAllStatusMsgs(ctx, user, 1) + }() + return + } + + // crop shop title + if len(m.Text) > SHOPS_DESCRIPTION_MAX_LENGTH { + m.Text = m.Text[:SHOPS_DESCRIPTION_MAX_LENGTH] + } + shops.Description = m.Text + runtime.IgnoreError(shops.Set(shops, bot.ShopBunt)) + bot.sendStatusMessageAndDelete(ctx, m.Sender, fmt.Sprintf("โœ… Description set.")) + ResetUserState(user, bot) + // go func() { + // time.Sleep(time.Duration(5) * time.Second) + // bot.shopViewDeleteAllStatusMsgs(ctx, user) + // }() + bot.shopsHandler(ctx, m) + bot.tryDeleteMessage(m) +} + +// shopsResetHandler is invoked when the user clicks button to reset shops completely +func (bot *TipBot) shopsResetHandler(ctx context.Context, c *tb.Callback) { + user := LoadUser(ctx) + shops, err := bot.getUserShops(ctx, user) + if err != nil { + log.Errorf("[shopsResetHandler] %s", err) + return + } + if shops.Owner.Telegram.ID != c.Sender.ID { + return + } + runtime.IgnoreError(shops.Delete(shops, bot.ShopBunt)) + bot.sendStatusMessageAndDelete(ctx, c.Sender, fmt.Sprintf("โœ… Shops reset.")) + // go func() { + // time.Sleep(time.Duration(5) * time.Second) + // bot.shopViewDeleteAllStatusMsgs(ctx, user) + // }() + bot.shopsHandlerCallback(ctx, c) +} + +// shopSelect is invoked when the user has selected a shop to browse +func (bot *TipBot) shopSelect(ctx context.Context, c *tb.Callback) { + shop, _ := bot.getShop(ctx, c.Data) + user := LoadUser(ctx) + shopView, err := bot.getUserShopview(ctx, user) + if err != nil { + shopView = ShopView{ + ID: fmt.Sprintf("shopview-%d", c.Sender.ID), + ShopID: shop.ID, + Page: 0, + } + bot.Cache.Set(shopView.ID, shopView, &store.Options{Expiration: 24 * time.Hour}) + } + shopView.Page = 0 + shopView.ShopID = shop.ID + + // var shopMessage *tb.Message + shopMessage := bot.displayShopItem(ctx, c.Message, shop) + // if len(shop.ItemIds) > 0 { + // bot.tryDeleteMessage(c.Message) + // shopMessage = bot.displayShopItem(ctx, c.Message, shop) + // } else { + // shopMessage = bot.tryEditMessage(c.Message, "There are no items in this shop yet.", bot.shopMenu(ctx, shop, &ShopItem{})) + // } + shopView.Message = shopMessage + bot.Cache.Set(shopView.ID, shopView, &store.Options{Expiration: 24 * time.Hour}) + log.Infof("[๐Ÿ› shop] %s entered shop %s.", GetUserStr(user.Telegram), shop.ID) +} + +// shopSelectDelete is invoked when the user has chosen a shop to delete +func (bot *TipBot) shopSelectDelete(ctx context.Context, c *tb.Callback) { + shop, _ := bot.getShop(ctx, c.Data) + user := LoadUser(ctx) + shops, err := bot.getUserShops(ctx, user) + if err != nil { + return + } + // first, delete from Shops + for i, shopId := range shops.Shops { + if shopId == shop.ID { + if i == len(shops.Shops)-1 { + shops.Shops = shops.Shops[:i] + } else { + shops.Shops = append(shops.Shops[:i], shops.Shops[i+1:]...) + } + break + } + } + runtime.IgnoreError(shops.Set(shops, bot.ShopBunt)) + + // then, delete shop + runtime.IgnoreError(shop.Delete(shop, bot.ShopBunt)) + + // then update buttons + bot.shopsDeleteShopBrowser(ctx, c) + log.Infof("[๐Ÿ› shop] %s deleted shop %s.", GetUserStr(user.Telegram), shop.ID) +} + +// shopsBrowser makes a button list of all shops the user can browse +func (bot *TipBot) shopsBrowser(ctx context.Context, c *tb.Callback) { + user := LoadUser(ctx) + shopView, err := bot.getUserShopview(ctx, user) + if err != nil { + return + } + shops, err := bot.getUserShops(ctx, shopView.ShopOwner) + if err != nil { + return + } + var s []*Shop + for _, shopId := range shops.Shops { + shop, _ := bot.getShop(ctx, shopId) + s = append(s, shop) + } + shopShopsButton := shopKeyboard.Data("โฌ…๏ธ Back", "shops_shops", shops.ID) + shopKeyboard.Inline(buttonWrapper(append(bot.makseShopSelectionButtons(s, "select_shop"), shopShopsButton), shopKeyboard, 1)...) + shopMessage := bot.tryEditMessage(c.Message, "Select a shop you want to browse.", shopKeyboard) + shopView, err = bot.getUserShopview(ctx, user) + if err != nil { + shopView.Message = shopMessage + bot.Cache.Set(shopView.ID, shopView, &store.Options{Expiration: 24 * time.Hour}) + } + +} + +// shopItemSettingsHandler is invoked when the user presses the shop settings button +func (bot *TipBot) shopSettingsHandler(ctx context.Context, c *tb.Callback) { + user := LoadUser(ctx) + shopView, err := bot.getUserShopview(ctx, user) + if err != nil { + return + } + shops, err := bot.getUserShops(ctx, user) + if err != nil { + return + } + if shops.ID != c.Data || shops.Owner.Telegram.ID != user.Telegram.ID { + log.Error("[shopSettingsHandler] item id mismatch") + return + } + bot.tryEditMessage(shopView.Message, shopView.Message.Text, bot.shopsSettingsMenu(ctx, shops)) +} + +// shopNewShopHandler is invoked when the user presses the new shop button +func (bot *TipBot) shopNewShopHandler(ctx context.Context, c *tb.Callback) { + user := LoadUser(ctx) + shops, err := bot.getUserShops(ctx, user) + if err != nil { + log.Errorf("[shopNewShopHandler] %s", err) + return + } + if len(shops.Shops) >= shops.MaxShops { + bot.trySendMessage(c.Sender, fmt.Sprintf("๐Ÿšซ You can only have %d shops. Delete a shop to create a new one.", shops.MaxShops)) + return + } + shop, err := bot.addUserShop(ctx, user) + // We need to save the pay state in the user state so we can load the payment in the next handler + SetUserState(user, bot, lnbits.UserEnterShopTitle, shop.ID) + bot.sendStatusMessage(ctx, c.Sender, fmt.Sprintf("โŒจ๏ธ Enter the name of your shop."), tb.ForceReply) +} + +// enterShopTitleHandler is invoked when the user enters the shop title +func (bot *TipBot) enterShopTitleHandler(ctx context.Context, m *tb.Message) { + user := LoadUser(ctx) + // read item from user.StateData + shop, err := bot.getShop(ctx, user.StateData) + if err != nil { + return + } + if shop.Owner.Telegram.ID != m.Sender.ID { + return + } + if len(m.Text) == 0 { + ResetUserState(user, bot) + bot.sendStatusMessageAndDelete(ctx, m.Sender, "๐Ÿšซ Action cancelled.") + go func() { + time.Sleep(time.Duration(5) * time.Second) + bot.shopViewDeleteAllStatusMsgs(ctx, user, 1) + }() + return + } + // crop shop title + m.Text = strings.Replace(m.Text, "\n", " ", -1) + if len(m.Text) > SHOP_TITLE_MAX_LENGTH { + m.Text = m.Text[:SHOP_TITLE_MAX_LENGTH] + } + shop.Title = m.Text + runtime.IgnoreError(shop.Set(shop, bot.ShopBunt)) + bot.sendStatusMessageAndDelete(ctx, m.Sender, fmt.Sprintf("โœ… Shop added.")) + ResetUserState(user, bot) + // go func() { + // time.Sleep(time.Duration(5) * time.Second) + // bot.shopViewDeleteAllStatusMsgs(ctx, user) + // }() + bot.shopsHandler(ctx, m) + bot.tryDeleteMessage(m) + log.Infof("[๐Ÿ› shop] %s added new shop %s.", GetUserStr(user.Telegram), shop.ID) +} diff --git a/internal/telegram/shop_helpers.go b/internal/telegram/shop_helpers.go new file mode 100644 index 0000000..fc71d7b --- /dev/null +++ b/internal/telegram/shop_helpers.go @@ -0,0 +1,291 @@ +package telegram + +import ( + "context" + "fmt" + "time" + + "github.com/LightningTipBot/LightningTipBot/internal/lnbits" + "github.com/LightningTipBot/LightningTipBot/internal/runtime" + "github.com/LightningTipBot/LightningTipBot/internal/storage/transaction" + "github.com/eko/gocache/store" + log "github.com/sirupsen/logrus" + tb "gopkg.in/lightningtipbot/telebot.v2" +) + +func (bot TipBot) shopsMainMenu(ctx context.Context, shops *Shops) *tb.ReplyMarkup { + browseShopButton := shopKeyboard.Data("๐Ÿ› Browse shops", "shops_browse", shops.ID) + shopNewShopButton := shopKeyboard.Data("โœ… New Shop", "shops_newshop", shops.ID) + shopSettingsButton := shopKeyboard.Data("โš™๏ธ Settings", "shops_settings", shops.ID) + user := LoadUser(ctx) + + buttons := []tb.Row{} + if len(shops.Shops) > 0 { + buttons = append(buttons, shopKeyboard.Row(browseShopButton)) + } + if user.Telegram.ID == shops.Owner.Telegram.ID { + buttons = append(buttons, shopKeyboard.Row(shopNewShopButton, shopSettingsButton)) + } + shopKeyboard.Inline( + buttons..., + ) + return shopKeyboard +} + +func (bot TipBot) shopsSettingsMenu(ctx context.Context, shops *Shops) *tb.ReplyMarkup { + shopShopsButton := shopKeyboard.Data("โฌ…๏ธ Back", "shops_shops", shops.ID) + shopLinkShopButton := shopKeyboard.Data("๐Ÿ”— Shop links", "shops_linkshop", shops.ID) + shopRenameShopButton := shopKeyboard.Data("โŒจ๏ธ Rename a shop", "shops_renameshop", shops.ID) + shopDeleteShopButton := shopKeyboard.Data("๐Ÿšซ Delete shops", "shops_deleteshop", shops.ID) + shopDescriptionShopButton := shopKeyboard.Data("๐Ÿ’ฌ Description", "shops_description", shops.ID) + // // shopResetShopButton := shopKeyboard.Data("โš ๏ธ Delete all shops", "shops_reset", shops.ID) + // buttons := []tb.Row{ + // shopKeyboard.Row(shopLinkShopButton), + // shopKeyboard.Row(shopDescriptionShopButton), + // shopKeyboard.Row(shopRenameShopButton), + // shopKeyboard.Row(shopDeleteShopButton), + // // shopKeyboard.Row(shopResetShopButton), + // shopKeyboard.Row(shopShopsButton), + // } + // shopKeyboard.Inline( + // buttons..., + // ) + + button := []tb.Btn{ + shopLinkShopButton, + shopDescriptionShopButton, + shopRenameShopButton, + shopDeleteShopButton, + shopShopsButton, + } + shopKeyboard.Inline(buttonWrapper(button, shopKeyboard, 2)...) + return shopKeyboard +} + +// shopItemSettingsMenu builds the buttons of the item settings +func (bot TipBot) shopItemSettingsMenu(ctx context.Context, shop *Shop, item *ShopItem) *tb.ReplyMarkup { + shopItemPriceButton = shopKeyboard.Data("๐Ÿ’ฏ Set price", "shop_itemprice", item.ID) + shopItemDeleteButton = shopKeyboard.Data("๐Ÿšซ Delete item", "shop_itemdelete", item.ID) + shopItemTitleButton = shopKeyboard.Data("โŒจ๏ธ Set title", "shop_itemtitle", item.ID) + shopItemAddFileButton = shopKeyboard.Data("๐Ÿ’พ Add file", "shop_itemaddfile", item.ID) + shopItemSettingsBackButton = shopKeyboard.Data("โฌ…๏ธ Back", "shop_itemsettingsback", item.ID) + user := LoadUser(ctx) + buttons := []tb.Row{} + if user.Telegram.ID == shop.Owner.Telegram.ID { + buttons = append(buttons, shopKeyboard.Row(shopItemDeleteButton, shopItemSettingsBackButton)) + buttons = append(buttons, shopKeyboard.Row(shopItemTitleButton, shopItemPriceButton)) + buttons = append(buttons, shopKeyboard.Row(shopItemAddFileButton)) + } + shopKeyboard.Inline( + buttons..., + ) + return shopKeyboard +} + +// shopItemConfirmBuyMenu builds the buttons to confirm a purchase +func (bot TipBot) shopItemConfirmBuyMenu(ctx context.Context, shop *Shop, item *ShopItem) *tb.ReplyMarkup { + shopItemBuyButton = shopKeyboard.Data(fmt.Sprintf("๐Ÿ’ธ Pay %d sat", item.Price), "shop_itembuy", item.ID) + shopItemCancelBuyButton = shopKeyboard.Data("โฌ…๏ธ Back", "shop_itemcancelbuy", item.ID) + buttons := []tb.Row{} + buttons = append(buttons, shopKeyboard.Row(shopItemBuyButton)) + buttons = append(buttons, shopKeyboard.Row(shopItemCancelBuyButton)) + shopKeyboard.Inline( + buttons..., + ) + return shopKeyboard +} + +// shopMenu builds the buttons in the item browser +func (bot TipBot) shopMenu(ctx context.Context, shop *Shop, item *ShopItem) *tb.ReplyMarkup { + user := LoadUser(ctx) + shopView, err := bot.getUserShopview(ctx, user) + if err != nil { + return nil + } + + shopShopsButton := shopKeyboard.Data("โฌ…๏ธ Back", "shops_shops", shop.ShopsID) + shopAddItemButton = shopKeyboard.Data("โœ… New item", "shop_additem", shop.ID) + shopItemSettingsButton = shopKeyboard.Data("โš™๏ธ Settings", "shop_itemsettings", item.ID) + shopNextitemButton = shopKeyboard.Data(">", "shop_nextitem", shop.ID) + shopPrevitemButton = shopKeyboard.Data("<", "shop_previtem", shop.ID) + buyButtonText := "๐Ÿ“ฉ Get" + if item.Price > 0 { + buyButtonText = fmt.Sprintf("Buy (%d sat)", item.Price) + } + shopBuyitemButton = shopKeyboard.Data(buyButtonText, "shop_buyitem", item.ID) + + buttons := []tb.Row{} + if user.Telegram.ID == shop.Owner.Telegram.ID { + if len(shop.Items) == 0 { + buttons = append(buttons, shopKeyboard.Row(shopAddItemButton)) + } else { + buttons = append(buttons, shopKeyboard.Row(shopAddItemButton, shopItemSettingsButton)) + } + } + // publicButtons := []tb.Row{} + if len(shop.Items) > 0 { + if shopView.Page == len(shop.Items)-1 { + // last page + shopNextitemButton = shopKeyboard.Data("x", "shop_nextitem", shop.ID) + } + buttons = append(buttons, shopKeyboard.Row(shopPrevitemButton, shopBuyitemButton, shopNextitemButton)) + } + buttons = append(buttons, shopKeyboard.Row(shopShopsButton)) + shopKeyboard.Inline( + buttons..., + ) + return shopKeyboard +} + +// makseShopSelectionButtons produces a list of all buttons with a uniqueString ID +func (bot *TipBot) makseShopSelectionButtons(shops []*Shop, uniqueString string) []tb.Btn { + var buttons []tb.Btn + for _, shop := range shops { + buttons = append(buttons, shopKeyboard.Data(shop.Title, uniqueString, shop.ID)) + } + return buttons +} + +// -------------- ShopView -------------- + +// getUserShopview returns ShopView object from cache that holds information about the user's current browsing view +func (bot *TipBot) getUserShopview(ctx context.Context, user *lnbits.User) (shopView ShopView, err error) { + sv, err := bot.Cache.Get(fmt.Sprintf("shopview-%d", user.Telegram.ID)) + if err != nil { + return + } + shopView = sv.(ShopView) + return +} +func (bot *TipBot) shopViewDeleteAllStatusMsgs(ctx context.Context, user *lnbits.User, start int) (shopView ShopView, err error) { + runtime.Lock(fmt.Sprintf("shopview-delete-%d", user.Telegram.ID)) + shopView, err = bot.getUserShopview(ctx, user) + if err != nil { + return + } + + statusMessages := shopView.StatusMessages + // delete all status messages from cache + shopView.StatusMessages = append([]*tb.Message{}, statusMessages[0:start]...) + bot.Cache.Set(shopView.ID, shopView, &store.Options{Expiration: 24 * time.Hour}) + + deleteStatusMessages(start, statusMessages, bot) + runtime.Unlock(fmt.Sprintf("shopview-delete-%d", user.Telegram.ID)) + return +} + +func deleteStatusMessages(start int, messages []*tb.Message, bot *TipBot) { + // delete all status messages from telegram + for _, msg := range messages[start:] { + bot.tryDeleteMessage(msg) + } +} + +// sendStatusMessage adds a status message to the shopVoew.statusMessages +// slide and sends a status message to the user. +func (bot *TipBot) sendStatusMessage(ctx context.Context, to tb.Recipient, what interface{}, options ...interface{}) (msg *tb.Message) { + user := LoadUser(ctx) + id := fmt.Sprintf("shopview-delete-%d", user.Telegram.ID) + + // write into cache + runtime.Lock(id) + shopView, err := bot.getUserShopview(ctx, user) + if err != nil { + return nil + } + statusMsg := bot.trySendMessage(to, what, options...) + shopView.StatusMessages = append(shopView.StatusMessages, statusMsg) + bot.Cache.Set(shopView.ID, shopView, &store.Options{Expiration: 24 * time.Hour}) + runtime.Unlock(id) + return statusMsg +} + +// sendStatusMessageAndDelete invokes sendStatusMessage and creates +// a ticker to delete all status messages after 5 seconds. +func (bot *TipBot) sendStatusMessageAndDelete(ctx context.Context, to tb.Recipient, what interface{}, options ...interface{}) (msg *tb.Message) { + user := LoadUser(ctx) + id := fmt.Sprintf("shopview-delete-%d", user.Telegram.ID) + statusMsg := bot.sendStatusMessage(ctx, to, what, options...) + // kick off ticker to remove all messages + ticker := runtime.GetTicker(id, runtime.WithDuration(5*time.Second)) + if !ticker.Started { + ticker.Do(func() { + bot.shopViewDeleteAllStatusMsgs(ctx, user, 1) + // removing ticker asap done + runtime.RemoveTicker(id) + }) + } else { + ticker.ResetChan <- struct{}{} + } + return statusMsg +} + +// --------------- Shop --------------- + +// initUserShops is a helper function for creating a Shops for the user in the database +func (bot *TipBot) initUserShops(ctx context.Context, user *lnbits.User) (*Shops, error) { + id := fmt.Sprintf("shops-%d", user.Telegram.ID) + shops := &Shops{ + Base: transaction.New(transaction.ID(id)), + ID: id, + Owner: user, + Shops: []string{}, + MaxShops: MAX_SHOPS, + } + runtime.IgnoreError(shops.Set(shops, bot.ShopBunt)) + return shops, nil +} + +// getUserShops returns the Shops for the user +func (bot *TipBot) getUserShops(ctx context.Context, user *lnbits.User) (*Shops, error) { + tx := &Shops{Base: transaction.New(transaction.ID(fmt.Sprintf("shops-%d", user.Telegram.ID)))} + sn, err := tx.Get(tx, bot.ShopBunt) + if err != nil { + log.Errorf("[getUserShops] User: %s (%d): %s", GetUserStr(user.Telegram), user.Telegram.ID, err) + return &Shops{}, err + } + transaction.Unlock(tx.ID) + shops := sn.(*Shops) + return shops, nil +} + +// addUserShop adds a new Shop to the Shops of a user +func (bot *TipBot) addUserShop(ctx context.Context, user *lnbits.User) (*Shop, error) { + shops, err := bot.getUserShops(ctx, user) + if err != nil { + return &Shop{}, err + } + shopId := fmt.Sprintf("shop-%s", RandStringRunes(10)) + shop := &Shop{ + Base: transaction.New(transaction.ID(shopId)), + ID: shopId, + Title: fmt.Sprintf("Shop %d (%s)", len(shops.Shops)+1, shopId), + Owner: user, + Type: "photo", + Items: make(map[string]ShopItem), + LanguageCode: ctx.Value("publicLanguageCode").(string), + ShopsID: shops.ID, + MaxItems: MAX_ITEMS_PER_SHOP, + } + runtime.IgnoreError(shop.Set(shop, bot.ShopBunt)) + shops.Shops = append(shops.Shops, shopId) + runtime.IgnoreError(shops.Set(shops, bot.ShopBunt)) + return shop, nil +} + +// getShop returns the Shop for the given ID +func (bot *TipBot) getShop(ctx context.Context, shopId string) (*Shop, error) { + tx := &Shop{Base: transaction.New(transaction.ID(shopId))} + sn, err := tx.Get(tx, bot.ShopBunt) + // immediatelly set intransaction to block duplicate calls + if err != nil { + log.Errorf("[getShop] %s", err) + return &Shop{}, err + } + transaction.Unlock(tx.ID) + shop := sn.(*Shop) + if shop.Owner == nil { + return &Shop{}, fmt.Errorf("shop has no owner") + } + return shop, nil +} diff --git a/internal/telegram/state.go b/internal/telegram/state.go new file mode 100644 index 0000000..ed14c40 --- /dev/null +++ b/internal/telegram/state.go @@ -0,0 +1,25 @@ +package telegram + +import ( + "context" + "github.com/LightningTipBot/LightningTipBot/internal/lnbits" + tb "gopkg.in/lightningtipbot/telebot.v2" +) + +type StateCallbackMessage map[lnbits.UserStateKey]func(ctx context.Context, m *tb.Message) + +var stateCallbackMessage StateCallbackMessage + +func initializeStateCallbackMessage(bot *TipBot) { + stateCallbackMessage = StateCallbackMessage{ + lnbits.UserStateLNURLEnterAmount: bot.enterAmountHandler, + lnbits.UserEnterAmount: bot.enterAmountHandler, + lnbits.UserEnterUser: bot.enterUserHandler, + lnbits.UserEnterShopTitle: bot.enterShopTitleHandler, + lnbits.UserStateShopItemSendPhoto: bot.addShopItemPhoto, + lnbits.UserStateShopItemSendPrice: bot.enterShopItemPriceHandler, + lnbits.UserStateShopItemSendTitle: bot.enterShopItemTitleHandler, + lnbits.UserStateShopItemSendItemFile: bot.addItemFileHandler, + lnbits.UserEnterShopsDescription: bot.enterShopsDescriptionHandler, + } +} diff --git a/internal/telegram/text.go b/internal/telegram/text.go index a1d06a0..f8ce6de 100644 --- a/internal/telegram/text.go +++ b/internal/telegram/text.go @@ -12,7 +12,7 @@ import ( tb "gopkg.in/lightningtipbot/telebot.v2" ) -func (bot TipBot) anyTextHandler(ctx context.Context, m *tb.Message) { +func (bot *TipBot) anyTextHandler(ctx context.Context, m *tb.Message) { if m.Chat.Type != tb.ChatPrivate { return } @@ -36,18 +36,10 @@ func (bot TipBot) anyTextHandler(ctx context.Context, m *tb.Message) { bot.lnurlHandler(ctx, m) return } - - // could be a LNURL - // var lnurlregex = regexp.MustCompile(`.*?((lnurl)([0-9]{1,}[a-z0-9]+){1})`) - - // inputs asked for - if user.StateKey == lnbits.UserStateLNURLEnterAmount || user.StateKey == lnbits.UserEnterAmount { - bot.enterAmountHandler(ctx, m) + if c := stateCallbackMessage[user.StateKey]; c != nil { + c(ctx, m) + //ResetUserState(user, bot) } - if user.StateKey == lnbits.UserEnterUser { - bot.enterUserHandler(ctx, m) - } - } type EnterUserStateData struct { @@ -114,7 +106,6 @@ func (bot *TipBot) enterUserHandler(ctx context.Context, m *tb.Message) { switch EnterUserStateData.Type { case "CreateSendState": m.Text = fmt.Sprintf("/send %s", userstr) - SetUserState(user, bot, lnbits.UserHasEnteredAmount, "") bot.sendHandler(ctx, m) return default: