* speed up tx lock

* add shops

* shop

* scrolling

* 🚀 add state stateCallbackMessage 🚀

* no shop

* scroll

* add shop browser

* scroll bug

* add buttons.go

* scrolling

* shop

* lnurl print after serving

* shop deletion

* adding and removing shops

* display photo captions

* refactor

* shops

* shops

* files work

* rename shop

* error handling

* fixes

* payments

* confirm delete all shops

* adding new items fix

* own bunt db for shop

* unlock transaction

* remove double print

* add transaction.Lock(id)

* unlock getShop

* add default interceptors to shop

* more files

* go sleep

* add fileStateResetTicker

* async sleep

* add runtime.ResettableFunctionTicker

* remove default duration

* generalized concurrent mutex

* simplify function ticker

* status message ticker queue

* add start index to shopViewDeleteAllStatusMsgs

Co-authored-by: callebtc <93376500+callebtc@users.noreply.github.com>
Co-authored-by: lngohumble <lngohumble@gmail.com>
This commit is contained in:
LightningTipBot 2021-12-23 10:16:13 +01:00 committed by GitHub
parent 6b5e30dbe4
commit f8fadb1729
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
20 changed files with 2335 additions and 87 deletions

1
go.mod
View file

@ -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

4
go.sum
View file

@ -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=

View file

@ -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"`
}

View file

@ -36,6 +36,13 @@ const (
UserHasEnteredAmount
UserEnterUser
UserHasEnteredUser
UserEnterShopTitle
UserStateShopItemSendPhoto
UserStateShopItemSendTitle
UserStateShopItemSendDescription
UserStateShopItemSendPrice
UserStateShopItemSendItemFile
UserEnterShopsDescription
)
type UserStateKey int

View file

@ -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)
}
}
}()
}

31
internal/runtime/mutex.go Normal file
View file

@ -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()
}
}

View file

@ -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
})
}

View file

@ -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)
}

View file

@ -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()
}

View file

@ -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
}

View file

@ -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 {

View file

@ -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
}
}

View file

@ -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,
}},
},
}
}

View file

@ -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

View file

@ -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())

View file

@ -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)

1364
internal/telegram/shop.go Normal file

File diff suppressed because it is too large Load diff

View file

@ -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
}

View file

@ -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,
}
}

View file

@ -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: