diff --git a/.air.toml b/.air.toml new file mode 100644 index 0000000..c7f9cd3 --- /dev/null +++ b/.air.toml @@ -0,0 +1,10 @@ +root = "." + +[build] +exclude_regex = ["_test\\.go"] +bin = "/usr/bin/make debug" +cmd = "/usr/bin/make build" +kill_delay = 0 +log = "build-errors.log" +send_interrupt = true +stop_on_error = true diff --git a/.gitignore b/.gitignore index 65036e6..7e28f15 100644 --- a/.gitignore +++ b/.gitignore @@ -22,6 +22,8 @@ vendor/ # Go workspace file go.work .idea/ +.config/ +tmp/ # Env files .env diff --git a/Makefile b/Makefile index 47acc47..427e66c 100644 --- a/Makefile +++ b/Makefile @@ -2,20 +2,37 @@ SHELL = /bin/bash -o pipefail export PATH := $(shell go env GOPATH)/bin:$(PATH) ROOT_DIR=$(shell dirname $(realpath $(lastword $(MAKEFILE_LIST)))) -BIN=$(ROOT_DIR)/build/vegapokerbot +BIN_NAME=vegapokerbot +BIN=$(ROOT_DIR)/build/$(BIN_NAME) GO_VERSION=$(shell go version | sed -e 's/go version //') BIN_DIR=$(ROOT_DIR)/build build: deps fmt - @echo "> building with ${GO_VERSION}" +ifeq ($(DEBUG), true) + @echo "> building debug with ${GO_VERSION}" + @CGO_ENABLED=0 go build -buildvcs=false -gcflags "all=-N -l" -tags=release -o $(BIN) . +else + @echo "> building release with ${GO_VERSION}" @CGO_ENABLED=0 go build -buildvcs=false -tags=release -o $(BIN) . +endif @echo $(BIN) docker: @docker buildx build --platform linux/amd64 --tag $CI_APP_IMAGE --file Dockerfile . run: +ifeq ($(DEBUG), true) + @killall -s 9 dlv > /dev/null 2>&1 || true + @killall -s 9 ${BIN_NAME} > /dev/null 2>&1 || true + @air +else @${BIN} run +endif + +debug: + @echo "> starting under delve" + @dlv --listen=:40000 --headless=true --api-version=2 --accept-multiclient --log exec ${BIN} run + fmt: @echo "> fmt" @gofmt -l -s -w `go list -buildvcs=false -f '{{.Dir}}' ${ROOT_DIR}/... | grep -v /vendor/` diff --git a/compose.yml b/compose.yml index 81fb1a6..3dae2f2 100644 --- a/compose.yml +++ b/compose.yml @@ -22,8 +22,10 @@ services: - db environment: GOCACHE: /go + DEBUG: true # Set to false to disable hot-reload & debugger. ports: - 3333:3333 + - 40000:40000 command: make build run networks: diff --git a/go.mod b/go.mod index e7dcb0b..e09aac0 100644 --- a/go.mod +++ b/go.mod @@ -12,6 +12,7 @@ require ( github.com/maypok86/otter v1.2.1 github.com/mymmrac/telego v0.29.2 github.com/nicksnyder/go-i18n/v2 v2.4.0 + github.com/stretchr/testify v1.9.0 go.uber.org/zap v1.27.0 golang.org/x/text v0.14.0 gopkg.in/yaml.v3 v3.0.1 @@ -26,6 +27,7 @@ require ( github.com/bytedance/sonic v1.11.3 // indirect github.com/chenzhuoyu/base64x v0.0.0-20230717121745-296ad89f973d // indirect github.com/chenzhuoyu/iasm v0.9.1 // indirect + github.com/davecgh/go-spew v1.1.1 // indirect github.com/dolthub/maphash v0.1.0 // indirect github.com/fasthttp/router v1.5.0 // indirect github.com/gammazero/deque v0.2.1 // indirect @@ -42,6 +44,7 @@ require ( github.com/klauspost/compress v1.17.6 // indirect github.com/klauspost/cpuid/v2 v2.2.6 // indirect github.com/lib/pq v1.10.9 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect github.com/savsgio/gotils v0.0.0-20240303185622-093b76447511 // indirect github.com/twitchyliquid64/golang-asm v0.15.1 // indirect github.com/valyala/bytebufferpool v1.0.0 // indirect diff --git a/internal/app/app.go b/internal/app/app.go index 07095b6..54c4cef 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -8,10 +8,12 @@ import ( "gitea.neur0tx.site/Neur0toxine/vegapokerbot/internal/handler/iface" "gitea.neur0tx.site/Neur0toxine/vegapokerbot/internal/locale" "gitea.neur0tx.site/Neur0toxine/vegapokerbot/internal/logger" + "gitea.neur0tx.site/Neur0toxine/vegapokerbot/pkg/types" "github.com/mymmrac/telego" "go.uber.org/zap" "gorm.io/gorm" "net/url" + "time" ) type App struct { @@ -21,6 +23,7 @@ type App struct { Config *config.Config Repositories *db.Repositories Handlers map[handler.Type]handler.Handler + updates types.Queue[*telego.Update] db *gorm.DB } @@ -114,6 +117,7 @@ func (a *App) initTelegram() error { } func (a *App) initHandlers() { + a.updates = types.NewQueue[*telego.Update]() fsmwizard.PopulateStates(a) a.Handlers = map[handler.Type]handler.Handler{ handler.Noop: handler.NewNoopHandler(a.Logger, a.Config.Debug), @@ -140,6 +144,17 @@ func (a *App) handler(update telego.Update) handler.Handler { return a.Handlers[handler.Noop] } +func (a *App) processUpdates() { + for { + item := a.updates.Dequeue() + if item == nil { + time.Sleep(time.Nanosecond * 200) + continue + } + a.processUpdate(*item) + } +} + func (a *App) processUpdate(update telego.Update) { defer func() { if r := recover(); r != nil { @@ -157,9 +172,11 @@ func (a *App) longPoll() error { if err != nil { return err } + go a.processUpdates() defer a.Telegram.StopLongPolling() for update := range updates { - go a.processUpdate(update) + update := update + a.updates.Enqueue(&update) } return nil } @@ -184,8 +201,10 @@ func (a *App) listenWebhook() error { defer func() { _ = a.Telegram.StopWebhook() }() + go a.processUpdates() for update := range updates { - go a.processUpdate(update) + update := update + a.updates.Enqueue(&update) } return nil } diff --git a/internal/handler/chat_member_updated_handler.go b/internal/handler/chat_member_updated_handler.go index fb785cc..3093015 100644 --- a/internal/handler/chat_member_updated_handler.go +++ b/internal/handler/chat_member_updated_handler.go @@ -2,6 +2,7 @@ package handler import ( "gitea.neur0tx.site/Neur0toxine/vegapokerbot/internal/db/model" + "gitea.neur0tx.site/Neur0toxine/vegapokerbot/internal/handler/fsmwizard" "gitea.neur0tx.site/Neur0toxine/vegapokerbot/internal/handler/iface" "gitea.neur0tx.site/Neur0toxine/vegapokerbot/internal/handler/util" "gitea.neur0tx.site/Neur0toxine/vegapokerbot/internal/locale" @@ -21,18 +22,25 @@ func NewChatMemberUpdatedHandler(app iface.App) *ChatMemberUpdatedHandler { } func (h *ChatMemberUpdatedHandler) Handle(wh telego.Update) error { - cm := wh.MyChatMember - if !cm.OldChatMember.MemberIsMember() && cm.OldChatMember.MemberUser().ID == h.App.TGProfile().ID && - cm.NewChatMember.MemberIsMember() && cm.NewChatMember.MemberUser().ID == h.App.TGProfile().ID { - return h.handleAddToChat(wh.MyChatMember.Chat) - } - if cm.OldChatMember.MemberIsMember() && cm.OldChatMember.MemberUser().ID == h.App.TGProfile().ID && - !cm.NewChatMember.MemberIsMember() && cm.NewChatMember.MemberUser().ID == h.App.TGProfile().ID { - return h.handleRemoveFromChat(wh.MyChatMember.Chat) - } - - h.leaveChat(wh.MyChatMember.Chat.ID) - return nil + return fsmwizard.Get(wh.MyChatMember.From.ID).Handle(func(w *fsmwizard.Wizard) *fsmwizard.Wizard { + if w == nil { + return &fsmwizard.Wizard{Data: wh} + } + w.Data = wh + return w + }) + // cm := wh.MyChatMember + // if !cm.OldChatMember.MemberIsMember() && cm.OldChatMember.MemberUser().ID == h.App.TGProfile().ID && + // cm.NewChatMember.MemberIsMember() && cm.NewChatMember.MemberUser().ID == h.App.TGProfile().ID { + // return h.handleAddToChat(wh.MyChatMember.Chat) + // } + // if cm.OldChatMember.MemberIsMember() && cm.OldChatMember.MemberUser().ID == h.App.TGProfile().ID && + // !cm.NewChatMember.MemberIsMember() && cm.NewChatMember.MemberUser().ID == h.App.TGProfile().ID { + // return h.handleRemoveFromChat(wh.MyChatMember.Chat) + // } + // + // h.leaveChat(wh.MyChatMember.Chat.ID) + // return nil } func (h *ChatMemberUpdatedHandler) handleAddToChat(tgChat telego.Chat) error { diff --git a/internal/handler/fsmwizard/add_chat_member_state.go b/internal/handler/fsmwizard/add_chat_member_state.go index 96672a7..f7bb2f9 100644 --- a/internal/handler/fsmwizard/add_chat_member_state.go +++ b/internal/handler/fsmwizard/add_chat_member_state.go @@ -22,14 +22,14 @@ func NewAddChatMemberState(app iface.App) fsm.IState[Wizard] { return &AddChatMemberState{newBase(app)} } -func (s *AddChatMemberState) Enter(pl *Wizard, mc fsm.MachineControls[*Wizard]) error { +func (s *AddChatMemberState) Enter(pl *Wizard, mc fsm.MachineControls[Wizard]) error { if pl.Data.MyChatMember == nil { s.Move(mc, WaitingForMemberWebhookStateID, pl) } return nil } -func (s *AddChatMemberState) Handle(pl *Wizard, mc fsm.MachineControls[*Wizard]) { +func (s *AddChatMemberState) Handle(pl *Wizard, mc fsm.MachineControls[Wizard]) { next := WaitingForMemberWebhookStateID defer func() { s.Move(mc, next, pl) @@ -78,7 +78,11 @@ func (s *AddChatMemberState) Handle(pl *Wizard, mc fsm.MachineControls[*Wizard]) }) s.LogError(err) pl.User = user + pl.UserID = user.TelegramID + pl.Chat = chat + pl.ChatID = tgChat.ID pl.TGChat = tgChat + pl.IsMaster = true next = KeyboardChooserStateID } @@ -105,14 +109,7 @@ func (s *AddChatMemberState) handleTooManyMembers(chatID int64) bool { } func (s *AddChatMemberState) getUserAndChat(tgChat telego.Chat) (*model.User, *model.Chat) { - cr := s.App.DB().ForChat() - chat, err := cr.ByTelegramID(tgChat.ID) - if err != nil { - s.curseAndLeave(tgChat.ID, err) - return nil, nil - } - - user, err := s.getRegisteredAdmin(tgChat.ID) + user, err := s.App.DB().ForUser().ByTelegramID(s.Payload.Data.MyChatMember.From.ID) if err != nil { s.curseAndLeave(tgChat.ID, err) return nil, nil @@ -128,6 +125,12 @@ func (s *AddChatMemberState) getUserAndChat(tgChat telego.Chat) (*model.User, *m s.LogError(err) return nil, nil } + cr := s.App.DB().ForChat() + chat, err := cr.ByTelegramID(tgChat.ID) + if err != nil { + s.curseAndLeave(tgChat.ID, err) + return nil, nil + } return user, chat } @@ -137,27 +140,6 @@ func (s *AddChatMemberState) curseAndLeave(chatID int64, err error) { s.LogError(err) } -func (s *AddChatMemberState) getRegisteredAdmin(chatID int64) (*model.User, error) { - admins, err := s.App.TG().GetChatAdministrators(&telego.GetChatAdministratorsParams{ - ChatID: tu.ID(chatID), - }) - if err != nil { - return nil, err - } - adminIDs := make([]int64, len(admins)) - for i, admin := range admins { - adminIDs[i] = admin.MemberUser().ID - } - dbAdmins, err := s.App.DB().ForUser().ByTelegramIDs(adminIDs) - if err != nil { - return nil, err - } - if len(dbAdmins) > 0 { - return &dbAdmins[0], nil - } - return nil, nil -} - func (s *AddChatMemberState) leaveChat(chatID int64) { s.LogError(s.App.TG().LeaveChat(&telego.LeaveChatParams{ ChatID: tu.ID(chatID), diff --git a/internal/handler/fsmwizard/base.go b/internal/handler/fsmwizard/base.go index 1fea61e..9c07f8f 100644 --- a/internal/handler/fsmwizard/base.go +++ b/internal/handler/fsmwizard/base.go @@ -3,6 +3,7 @@ package fsmwizard import ( "gitea.neur0tx.site/Neur0toxine/vegapokerbot/internal/db/model" "gitea.neur0tx.site/Neur0toxine/vegapokerbot/internal/handler/iface" + "gitea.neur0tx.site/Neur0toxine/vegapokerbot/internal/handler/util" "gitea.neur0tx.site/Neur0toxine/vegapokerbot/internal/locale" "gitea.neur0tx.site/Neur0toxine/vegapokerbot/pkg/fsm" "github.com/mymmrac/telego" @@ -11,12 +12,15 @@ import ( ) type Wizard struct { - UserID int64 - ChatID int64 - TGChat telego.Chat - User *model.User - Loc locale.Localizer - Data telego.Update + UserID int64 + ChatID int64 + TGChat telego.Chat + User *model.User + Chat *model.Chat + Loc locale.Localizer + Data telego.Update + KeyClick util.Payload + IsMaster bool } type State struct { @@ -33,16 +37,16 @@ func (s State) Localizer(langCode ...string) locale.Localizer { if len(langCode) > 0 { lang = langCode[0] } else { - if s.Payload.User != nil { + if s.Payload != nil && s.Payload.User != nil { lang = s.Payload.User.Language - } else if s.Payload.UserID > 0 { + } else if s.Payload != nil && s.Payload.UserID > 0 { user, _ := s.App.DB().ForUser().ByTelegramID(s.Payload.UserID) if user != nil && user.Language != "" { lang = user.Language } } } - if s.Payload.Loc != nil && (len(langCode) == 0 || s.Payload.Loc.Tag().String() == lang) { + if s.Payload != nil && s.Payload.Loc != nil && (len(langCode) == 0 || s.hasLocalizerWithLocale(lang)) { return s.Payload.Loc } lang = strings.ToLower(lang) @@ -57,6 +61,13 @@ func (s State) Localizer(langCode ...string) locale.Localizer { } } +func (s State) hasLocalizerWithLocale(lang string) bool { + if s.Payload.Loc == nil { + return false + } + return s.Payload.Loc.Tag().String() == lang +} + func (s State) LogError(err error) { if err == nil { return @@ -68,6 +79,6 @@ func (s State) LogError(err error) { s.App.Log().Errorf("handler error: %s, user id: %d, chat id: %d", err, s.Payload.UserID, s.Payload.ChatID) } -func (s State) Move(mc fsm.MachineControls[*Wizard], stateID fsm.StateID, pl *Wizard) { +func (s State) Move(mc fsm.MachineControls[Wizard], stateID fsm.StateID, pl *Wizard) { s.LogError(mc.Move(stateID, pl)) } diff --git a/internal/handler/fsmwizard/configure_redmine_query_state.go b/internal/handler/fsmwizard/configure_redmine_query_state.go new file mode 100644 index 0000000..fbce26f --- /dev/null +++ b/internal/handler/fsmwizard/configure_redmine_query_state.go @@ -0,0 +1,47 @@ +package fsmwizard + +import ( + "gitea.neur0tx.site/Neur0toxine/vegapokerbot/internal/handler/iface" + "gitea.neur0tx.site/Neur0toxine/vegapokerbot/internal/handler/util" + "gitea.neur0tx.site/Neur0toxine/vegapokerbot/pkg/fsm" + "github.com/mymmrac/telego" + tu "github.com/mymmrac/telego/telegoutil" +) + +const ConfigureRedmineQueryStateID fsm.StateID = "configure_redmine_query" + +type ConfigureRedmineQueryState struct { + State +} + +func NewConfigureRedmineQueryState(app iface.App) fsm.IState[Wizard] { + return &ConfigureRedmineQueryState{newBase(app)} +} + +func (s *ConfigureRedmineQueryState) Enter(pl *Wizard, _ fsm.MachineControls[Wizard]) error { + _, err := s.App.TG().SendMessage(&telego.SendMessageParams{ + ChatID: tu.ID(pl.User.ChatID), + Text: pl.Loc.Message("ask_for_redmine"), + ParseMode: telego.ModeMarkdown, + ReplyMarkup: &telego.InlineKeyboardMarkup{InlineKeyboard: [][]telego.InlineKeyboardButton{ + { + { + Text: pl.Loc.Message("yes"), + CallbackData: util.NewRedmineQuestionPayload( + pl.User.TelegramID, pl.Chat.TelegramID, true).String(), + }, + { + Text: pl.Loc.Message("no"), + CallbackData: util.NewRedmineQuestionPayload( + pl.User.TelegramID, pl.Chat.TelegramID, false).String(), + }, + }, + }}, + }) + s.LogError(err) + return err +} + +func (s *ConfigureRedmineQueryState) ID() fsm.StateID { + return ConfigureRedmineQueryStateID +} diff --git a/internal/handler/fsmwizard/error_state.go b/internal/handler/fsmwizard/error_state.go index 63bd336..1f3f1ed 100644 --- a/internal/handler/fsmwizard/error_state.go +++ b/internal/handler/fsmwizard/error_state.go @@ -14,7 +14,7 @@ func NewErrorState(log *zap.SugaredLogger) *ErrorState { return &ErrorState{log: log} } -func (s *ErrorState) Handle(err error, cur fsm.StateID, next fsm.StateID, pl *Wizard, mc fsm.MachineControls[*Wizard]) { +func (s *ErrorState) Handle(err error, cur fsm.StateID, next fsm.StateID, pl *Wizard, mc fsm.MachineControls[Wizard]) { s.log.Errorf("critical wizard error: %s, current state: %s, next state: %s, payload: %#v", err, cur, next, pl) mc.Reset() diff --git a/internal/handler/fsmwizard/help_state.go b/internal/handler/fsmwizard/help_state.go index c6ebd2f..fb9f0bd 100644 --- a/internal/handler/fsmwizard/help_state.go +++ b/internal/handler/fsmwizard/help_state.go @@ -17,7 +17,7 @@ func NewHelpState(app iface.App) fsm.IState[Wizard] { return &HelpState{newBase(app)} } -func (s *HelpState) Enter(pl *Wizard, _ fsm.MachineControls[*Wizard]) error { +func (s *HelpState) Enter(pl *Wizard, _ fsm.MachineControls[Wizard]) error { _, err := s.App.TG().SendMessage(&telego.SendMessageParams{ ChatID: tu.ID(pl.Data.Message.Chat.ID), Text: s.Localizer(pl.Data.Message.From.LanguageCode). diff --git a/internal/handler/fsmwizard/init.go b/internal/handler/fsmwizard/init.go index 2f640af..73511b1 100644 --- a/internal/handler/fsmwizard/init.go +++ b/internal/handler/fsmwizard/init.go @@ -1,35 +1,27 @@ package fsmwizard import ( + "gitea.neur0tx.site/Neur0toxine/vegapokerbot/pkg/types" "time" "gitea.neur0tx.site/Neur0toxine/vegapokerbot/internal/handler/iface" "gitea.neur0tx.site/Neur0toxine/vegapokerbot/internal/handler/util" "gitea.neur0tx.site/Neur0toxine/vegapokerbot/pkg/fsm" - "github.com/maypok86/otter" ) var ( - wizards otter.Cache[int64, fsm.IMachine[Wizard]] + wizards *types.TTLMap[int64, fsm.IMachine[Wizard]] states []fsm.IState[Wizard] errorState *ErrorState + appPointer iface.App ) func init() { - storage, err := otter.MustBuilder[int64, fsm.IMachine[Wizard]](1000). - Cost(func(key int64, value fsm.IMachine[Wizard]) uint32 { - return 1 - }). - WithTTL(time.Hour * 24 * 7). - Build() - if err != nil { - panic(err) - } - wizards = storage + wizards = types.NewTTLMap[int64, fsm.IMachine[Wizard]](time.Hour * 24 * 7) } func Get(userID int64) fsm.IMachine[Wizard] { - if machine, ok := wizards.Get(userID); ok { + if machine, ok := wizards.Get(userID); ok && machine != nil { return machine } machine := newWizard() @@ -38,16 +30,18 @@ func Get(userID int64) fsm.IMachine[Wizard] { } func newWizard() fsm.IMachine[Wizard] { - return fsm.New[Wizard](states[0].ID(), Wizard{}, wizardPreHandle, states, errorState) + return fsm.New[Wizard](states[0].ID(), wizardRouter, states, errorState) } // PopulateStates will init all state handlers for future use. func PopulateStates(app iface.App) { + appPointer = app states = []fsm.IState[Wizard]{ NewRegisterState(app), NewWaitingForMemberWebhookState(app), NewAddChatMemberState(app), NewKeyboardChooserState(app), + NewConfigureRedmineQueryState(app), NewRemoveChatMemberState(app), NewHelpState(app), NewUnknownCommandState(app), @@ -55,21 +49,51 @@ func PopulateStates(app iface.App) { errorState = NewErrorState(app.Log()) } -func wizardPreHandle(w *Wizard, mc fsm.MachineControls[*Wizard]) { +func wizardRouter(w *Wizard, mc fsm.MachineControlsWithState[Wizard]) { if w.Data.Message != nil { + if w.UserID == 0 { + w.UserID = w.Data.Message.From.ID + } + if w.ChatID == 0 { + w.ChatID = w.Data.Message.Chat.ID + } + if w.User == nil { + user, err := appPointer.DB().ForUser().ByTelegramID(w.Data.Message.From.ID) + if err != nil { + return + } + + if user != nil && user.ID > 0 { + w.User = user + } + } + if w.Chat == nil { + chat, err := appPointer.DB().ForChat().ByTelegramID(w.Data.Message.Chat.ID) + if err != nil { + return + } + + if chat != nil && chat.ID > 0 { + w.Chat = chat + } + } + if w.TGChat.ID == 0 { + w.TGChat = w.Data.Message.Chat + } + switch { case util.MatchCommand("start", w.Data.Message): - mc.Move(RegisterStateID, w) + _ = mc.Move(RegisterStateID, w) case util.MatchCommand("help", w.Data.Message): - mc.Move(HelpStateID, w) + _ = mc.Move(HelpStateID, w) case util.HasCommand(w.Data.Message): - mc.Move(UnknownCommandStateID, w) + _ = mc.Move(UnknownCommandStateID, w) default: return } } if w.Data.MyChatMember != nil { - mc.Move(WaitingForMemberWebhookStateID, w) + _ = mc.Move(WaitingForMemberWebhookStateID, w) } } diff --git a/internal/handler/fsmwizard/keyboard_chooser_state.go b/internal/handler/fsmwizard/keyboard_chooser_state.go index 3c87528..ce19ef8 100644 --- a/internal/handler/fsmwizard/keyboard_chooser_state.go +++ b/internal/handler/fsmwizard/keyboard_chooser_state.go @@ -19,7 +19,7 @@ func NewKeyboardChooserState(app iface.App) fsm.IState[Wizard] { return &KeyboardChooserState{newBase(app)} } -func (s *KeyboardChooserState) Enter(pl *Wizard, _ fsm.MachineControls[*Wizard]) error { +func (s *KeyboardChooserState) Enter(pl *Wizard, _ fsm.MachineControls[Wizard]) error { _, err := s.App.TG().SendMessage(&telego.SendMessageParams{ ChatID: tu.ID(pl.User.ChatID), Text: s.Localizer().Template("choose_keyboard", map[string]interface{}{"Name": pl.TGChat.Title}), @@ -47,8 +47,43 @@ func (s *KeyboardChooserState) Enter(pl *Wizard, _ fsm.MachineControls[*Wizard]) return err } -func (s *KeyboardChooserState) Handle(pl *Wizard, _ fsm.MachineControls[*Wizard]) { - // todo: implement this using func (h *CallbackQueryHandler) handleChooseKeyboard(pl util.Payload, msgID int, user *model.User) error +func (s *KeyboardChooserState) Handle(pl *Wizard, mc fsm.MachineControls[Wizard]) { + cr := s.App.DB().ForChat() + result := pl.KeyClick.KeyboardChoice() + if pl.KeyClick.User != pl.User.TelegramID { + return + } + chat, err := cr.ByTelegramID(pl.KeyClick.Chat) + if err != nil { + s.LogError(err) + return + } + if chat == nil || chat.ID == 0 { + return + } + chat.KeyboardType = model.KeyboardType(result.Type) + if err := cr.Save(chat); err != nil { + s.LogError(err) + return + } + + kbTypeName := "standard_vote_keyboard" + if model.KeyboardType(result.Type) == model.StoryPointsKeyboard { + kbTypeName = "sp_vote_keyboard" + } + loc := s.Localizer(pl.User.Language) + _, err = s.App.TG().EditMessageText(&telego.EditMessageTextParams{ + ChatID: tu.ID(pl.User.ChatID), + MessageID: pl.Data.CallbackQuery.Message.GetMessageID(), + Text: loc.Template("chosen_keyboard", map[string]interface{}{"Name": loc.Message(kbTypeName)}), + }) + s.LogError(err) + + if !s.Payload.IsMaster { + mc.Reset() + return + } + s.Move(mc, ConfigureRedmineQueryStateID, pl) } func (s *KeyboardChooserState) ID() fsm.StateID { diff --git a/internal/handler/fsmwizard/register_state.go b/internal/handler/fsmwizard/register_state.go index 16b31f3..f974895 100644 --- a/internal/handler/fsmwizard/register_state.go +++ b/internal/handler/fsmwizard/register_state.go @@ -18,14 +18,14 @@ func NewRegisterState(app iface.App) fsm.IState[Wizard] { return &RegisterState{newBase(app)} } -func (s *RegisterState) Enter(pl *Wizard, _ fsm.MachineControls[*Wizard]) error { +func (s *RegisterState) Enter(pl *Wizard, _ fsm.MachineControls[Wizard]) error { if pl.Data.Message != nil && pl.Data.Message.Chat.Type != telego.ChatTypePrivate { return fsm.ErrPreventTransition } return nil } -func (s *RegisterState) Handle(pl *Wizard, mc fsm.MachineControls[*Wizard]) { +func (s *RegisterState) Handle(pl *Wizard, mc fsm.MachineControls[Wizard]) { loc := s.Localizer(pl.Data.Message.From.LanguageCode) userRepo := s.App.DB().ForUser() user, err := userRepo.ByTelegramID(pl.Data.Message.From.ID) @@ -70,6 +70,9 @@ func (s *RegisterState) Handle(pl *Wizard, mc fsm.MachineControls[*Wizard]) { } func (s *RegisterState) Exit(pl *Wizard) { + if pl.Data.Message == nil { + return + } loc := s.Localizer(pl.Data.Message.From.LanguageCode) _, err := s.App.TG().SendMessage(&telego.SendMessageParams{ ChatID: tu.ID(pl.Data.Message.Chat.ID), diff --git a/internal/handler/fsmwizard/remove_chat_member_state.go b/internal/handler/fsmwizard/remove_chat_member_state.go index 91f9ece..2e4ebd3 100644 --- a/internal/handler/fsmwizard/remove_chat_member_state.go +++ b/internal/handler/fsmwizard/remove_chat_member_state.go @@ -17,22 +17,26 @@ func NewRemoveChatMemberState(app iface.App) fsm.IState[Wizard] { return &RemoveChatMemberState{newBase(app)} } -func (s *RemoveChatMemberState) Enter(pl *Wizard, mc fsm.MachineControls[*Wizard]) error { +func (s *RemoveChatMemberState) Enter(pl *Wizard, mc fsm.MachineControls[Wizard]) error { if pl.Data.MyChatMember == nil { s.Move(mc, WaitingForMemberWebhookStateID, pl) } return nil } -func (s *RemoveChatMemberState) Handle(pl *Wizard, mc fsm.MachineControls[*Wizard]) { - cr := s.App.DB().ForChat() - chat, err := cr.ByTelegramID(pl.Data.MyChatMember.Chat.ID) - if err != nil { - s.LogError(err) - return +func (s *RemoveChatMemberState) Handle(pl *Wizard, mc fsm.MachineControls[Wizard]) { + if s.Payload.Chat == nil { + chat, err := s.App.DB().ForChat().ByTelegramID(s.Payload.Data.MyChatMember.Chat.ID) + if err != nil { + return + } + + if chat != nil && chat.ID > 0 { + s.Payload.Chat = chat + } } - if chat != nil && chat.ID > 0 { - user, _ := s.App.DB().ForUser().ByID(chat.UserID) + if s.Payload.Chat != nil && s.Payload.Chat.ID > 0 { + user, _ := s.App.DB().ForUser().ByID(s.Payload.Chat.UserID) if user != nil && user.ID > 0 && user.ChatID > 0 { _, err := s.App.TG().SendMessage(&telego.SendMessageParams{ ChatID: tu.ID(user.ChatID), @@ -42,7 +46,7 @@ func (s *RemoveChatMemberState) Handle(pl *Wizard, mc fsm.MachineControls[*Wizar }) s.LogError(err) } - s.LogError(cr.Delete(chat)) + s.LogError(s.App.DB().ForChat().Delete(s.Payload.Chat)) } s.Move(mc, WaitingForMemberWebhookStateID, pl) } diff --git a/internal/handler/fsmwizard/unknown_command_state.go b/internal/handler/fsmwizard/unknown_command_state.go index 62b1c04..b445ad3 100644 --- a/internal/handler/fsmwizard/unknown_command_state.go +++ b/internal/handler/fsmwizard/unknown_command_state.go @@ -17,7 +17,7 @@ func NewUnknownCommandState(app iface.App) fsm.IState[Wizard] { return &UnknownCommandState{newBase(app)} } -func (s *UnknownCommandState) Enter(pl *Wizard, _ fsm.MachineControls[*Wizard]) error { +func (s *UnknownCommandState) Enter(pl *Wizard, _ fsm.MachineControls[Wizard]) error { _, err := s.App.TG().SendMessage(&telego.SendMessageParams{ ChatID: tu.ID(s.Payload.Data.Message.Chat.ID), Text: s.Localizer(s.Payload.Data.Message.From.LanguageCode).Message("unknown_command"), diff --git a/internal/handler/fsmwizard/waiting_for_member_webhook_state.go b/internal/handler/fsmwizard/waiting_for_member_webhook_state.go index f151f70..6ef2101 100644 --- a/internal/handler/fsmwizard/waiting_for_member_webhook_state.go +++ b/internal/handler/fsmwizard/waiting_for_member_webhook_state.go @@ -17,22 +17,33 @@ func NewWaitingForMemberWebhookState(app iface.App) fsm.IState[Wizard] { return &WaitingForMemberWebhookState{newBase(app)} } -func (s *WaitingForMemberWebhookState) Handle(pl *Wizard, mc fsm.MachineControls[*Wizard]) { +func (s *WaitingForMemberWebhookState) Enter(pl *Wizard, mc fsm.MachineControls[Wizard]) error { cm := pl.Data.MyChatMember + if cm == nil { + return nil + } if !cm.OldChatMember.MemberIsMember() && cm.OldChatMember.MemberUser().ID == s.App.TGProfile().ID && cm.NewChatMember.MemberIsMember() && cm.NewChatMember.MemberUser().ID == s.App.TGProfile().ID { s.Move(mc, AddChatMemberStateID, pl) - return + return nil } if cm.OldChatMember.MemberIsMember() && cm.OldChatMember.MemberUser().ID == s.App.TGProfile().ID && !cm.NewChatMember.MemberIsMember() && cm.NewChatMember.MemberUser().ID == s.App.TGProfile().ID { s.Move(mc, RemoveChatMemberStateID, pl) - return + return nil } s.LogError(s.App.TG().LeaveChat(&telego.LeaveChatParams{ ChatID: tu.ID(pl.Data.MyChatMember.Chat.ID), })) + return nil +} + +func (s *WaitingForMemberWebhookState) Handle(pl *Wizard, mc fsm.MachineControls[Wizard]) { + // By using this we can handle both Move from Register state and Move from waiting for webhook state. + // The first one doesn't have data yet and it will be handled by Handle, the second one has data and will be + // handled by Enter. + _ = s.Enter(pl, mc) } func (s *WaitingForMemberWebhookState) ID() fsm.StateID { diff --git a/internal/handler/new_message_handler.go b/internal/handler/new_message_handler.go index 3d3a670..cdd7ae7 100644 --- a/internal/handler/new_message_handler.go +++ b/internal/handler/new_message_handler.go @@ -1,11 +1,8 @@ package handler import ( - "gitea.neur0tx.site/Neur0toxine/vegapokerbot/internal/handler/group" + "gitea.neur0tx.site/Neur0toxine/vegapokerbot/internal/handler/fsmwizard" "gitea.neur0tx.site/Neur0toxine/vegapokerbot/internal/handler/iface" - "gitea.neur0tx.site/Neur0toxine/vegapokerbot/internal/handler/store" - "gitea.neur0tx.site/Neur0toxine/vegapokerbot/internal/handler/util" - "gitea.neur0tx.site/Neur0toxine/vegapokerbot/internal/handler/wizard" "github.com/mymmrac/telego" ) @@ -22,22 +19,30 @@ func (h *MessageHandler) Handle(wh telego.Update) error { return nil } - if util.MatchCommand("start", wh.Message) { - return wizard.NewRegister(h.App, wh.Message.From.ID, wh.Message.Chat.ID).Handle(wh) - } + return fsmwizard.Get(wh.Message.From.ID).Handle(func(w *fsmwizard.Wizard) *fsmwizard.Wizard { + if w == nil { + return &fsmwizard.Wizard{Data: wh} + } + w.Data = wh + return w + }) - if util.MatchCommand("poll", wh.Message) { - return group.NewPoll(h.App, wh.Message.From.ID, wh.Message.Chat.ID).Handle(wh) - } - - setup, found := store.RedmineSetups.Get(wh.Message.Chat.ID) - if found { - return wizard.NewRedmineSetup(h.App, wh.Message.From.ID, wh.Message.Chat.ID, setup).Handle(wh) - } - - if util.MatchCommand("help", wh.Message) { - return wizard.NewHelpCommand(h.App, wh.Message.From.ID, wh.Message.Chat.ID).Handle(wh) - } - - return wizard.NewUnknownCommand(h.App, wh.Message.From.ID, wh.Message.Chat.ID).Handle(wh) + // if util.MatchCommand("start", wh.Message) { + // return wizard.NewRegister(h.App, wh.Message.From.ID, wh.Message.Chat.ID).Handle(wh) + // } + // + // if util.MatchCommand("poll", wh.Message) { + // return group.NewPoll(h.App, wh.Message.From.ID, wh.Message.Chat.ID).Handle(wh) + // } + // + // setup, found := store.RedmineSetups.Get(wh.Message.Chat.ID) + // if found { + // return wizard.NewRedmineSetup(h.App, wh.Message.From.ID, wh.Message.Chat.ID, setup).Handle(wh) + // } + // + // if util.MatchCommand("help", wh.Message) { + // return wizard.NewHelpCommand(h.App, wh.Message.From.ID, wh.Message.Chat.ID).Handle(wh) + // } + // + // return wizard.NewUnknownCommand(h.App, wh.Message.From.ID, wh.Message.Chat.ID).Handle(wh) } diff --git a/pkg/fsm/machine.go b/pkg/fsm/machine.go index afdab9b..f84ec35 100644 --- a/pkg/fsm/machine.go +++ b/pkg/fsm/machine.go @@ -3,7 +3,7 @@ package fsm import ( "errors" "fmt" - "sync" + "gitea.neur0tx.site/Neur0toxine/vegapokerbot/pkg/types" ) var ( @@ -14,50 +14,49 @@ var ( ErrStateDoesNotExist = errors.New("state does not exist") ) -// MachineHandleInput should be provided to IMachine's Handle method. This function can do two very useful things: -// - It can modify Machine's payload with input data. +// MachineStateRouter should be provided to IMachine. This function can do two very useful things: +// - It can modify Machine's payload. // - It can act as a router by changing Machine's state via provided controls. -type MachineHandleInput[T any] func(*T, MachineControls[*T]) +type MachineStateRouter[T any] func(*T, MachineControlsWithState[T]) + +// MachineStateProvider provided to every Handle call. It can be used to set the initial state of the machine or to +// update existing machine state. +type MachineStateProvider[T any] func(*T) *T // IMachine is a Machine contract. The Machine should be able to do the following: // - Move to another state (usually called by the IState itself). // - Handle the state input. // - Reset the machine. type IMachine[T any] interface { - MachineControls[*T] + MachineControlsWithState[T] // Handle the state input. Handle func will accept the current payload and modify it based on user input. - Handle() error + Handle(MachineStateProvider[T]) error // Reset the machine to its initial state. Reset() } // Machine is a finite-state machine implementation. type Machine[T any] struct { - lock sync.Mutex - payload *T - preHandle MachineHandleInput[T] - state StateID - initialState StateID - initialPayload T - states map[StateID]IState[T] - errHandler ErrorState[T] + payload *T + stateRouter MachineStateRouter[T] + state StateID + initialState StateID + states *types.Map[StateID, IState[T]] + errHandler ErrorState[T] } // New machine. -func New[T any](initialState StateID, initialPayload T, preHandle MachineHandleInput[T], states []IState[T], errHandler ErrorState[T]) IMachine[T] { - stateMap := make(map[StateID]IState[T], len(states)) +func New[T any](initialState StateID, router MachineStateRouter[T], states []IState[T], errHandler ErrorState[T]) IMachine[T] { + stateMap := types.NewMap[StateID, IState[T]]() for _, state := range states { - stateMap[state.ID()] = state + stateMap.Set(state.ID(), state) } - pl := initialPayload return &Machine[T]{ - state: initialState, - payload: &pl, - preHandle: preHandle, - initialState: initialState, - initialPayload: initialPayload, - states: stateMap, - errHandler: errHandler, + state: initialState, + stateRouter: router, + initialState: initialState, + states: stateMap, + errHandler: errHandler, } } @@ -75,8 +74,6 @@ func (m *Machine[T]) Move(id StateID, payload *T) error { if err != nil { return err } - defer m.lock.Unlock() - m.lock.Lock() if cur != nil { cur.Exit(payload) } @@ -93,11 +90,12 @@ func (m *Machine[T]) Move(id StateID, payload *T) error { } // Handle the input. -func (m *Machine[T]) Handle() error { - defer m.lock.Unlock() - m.lock.Lock() - if m.preHandle != nil { - m.preHandle(m.payload, m) +func (m *Machine[T]) Handle(provider MachineStateProvider[T]) error { + if provider != nil { + m.payload = provider(m.payload) + } + if m.stateRouter != nil { + m.stateRouter(m.payload, m) } st, err := m.loadState(m.state, m.payload) if st == nil || err != nil { @@ -107,10 +105,14 @@ func (m *Machine[T]) Handle() error { return nil } +// State of the Machine. +func (m *Machine[T]) State() *T { + return m.payload +} + // Reset the machine. func (m *Machine[T]) Reset() { - pl := m.initialPayload - m.payload = &pl + m.payload = nil m.state = m.initialState } @@ -118,7 +120,7 @@ func (m *Machine[T]) loadState(id StateID, payload *T) (IState[T], error) { if id == NilStateID { return nil, nil } - st, ok := m.states[id] + st, ok := m.states.Get(id) if !ok { return nil, m.fatalError(fmt.Errorf("%w: %s", ErrStateDoesNotExist, id), id, payload) } diff --git a/pkg/fsm/state.go b/pkg/fsm/state.go index 6117944..9746e91 100644 --- a/pkg/fsm/state.go +++ b/pkg/fsm/state.go @@ -9,10 +9,22 @@ const NilStateID = StateID("") // MachineControls is a fragment of IMachine implementation. This one can Move between the states or Reset the machine. // It may fail with an error which should be handled by the IState implementation. type MachineControls[T any] interface { - Move(StateID, T) error + Move(StateID, *T) error Reset() } +// MachineState returns machine state. This state should be immutable. +type MachineState[T any] interface { + // State returns underlying state. This state SHOULD NOT be modified since there is no locking performed. + State() *T +} + +// MachineControlsWithState is self-explanatory. +type MachineControlsWithState[T any] interface { + MachineControls[T] + MachineState[T] +} + // IState is a State interface. This contract enforces that any state implementation should have three methods: // Enter, Handle and Exit. The first one is called right after the machine has moved to the state, the second one is // called when handling some state input, the last one is called right before leaving to a next state (which happens @@ -23,11 +35,11 @@ type IState[T any] interface { // Enter is a state enter callback. Can be used to perform some sort of input query or to move to another // state immediately. Also, if Enter fails the machine won't move to the state and MachineControls's Move will // return an error which was returned from Enter. - Enter(*T, MachineControls[*T]) error + Enter(*T, MachineControls[T]) error // Handle is called when receiving some sort of input response. This one's signature is nearly identical to Enter, // but it can wait for some sort of input (standard input? webhook) without locking inside the callback // while Enter cannot do that. - Handle(*T, MachineControls[*T]) + Handle(*T, MachineControls[T]) // Exit is called right before leaving the state. It can be used to modify state's payload (T) or for some other // miscellaneous tasks. // Note: calling Exit doesn't mean that the machine will really transition to the next state. @@ -43,7 +55,7 @@ type IState[T any] interface { // // Machine without ErrorState will not do anything in case of fatal errors. type ErrorState[T any] interface { - Handle(err error, current StateID, next StateID, payload *T, machine MachineControls[*T]) + Handle(err error, current StateID, next StateID, payload *T, machine MachineControls[T]) } // State is the Machine's state. This implementation doesn't do anything and only helps with the @@ -58,12 +70,12 @@ func (s *State[T]) ID() StateID { } // Enter here will immediately move the Machine to empty state. -func (s *State[T]) Enter(payload *T, machine MachineControls[*T]) error { +func (s *State[T]) Enter(payload *T, machine MachineControls[T]) error { return machine.Move(NilStateID, payload) } // Handle here will immediately move the Machine to empty state. -func (s *State[T]) Handle(payload *T, machine MachineControls[*T]) { +func (s *State[T]) Handle(payload *T, machine MachineControls[T]) { _ = machine.Move(NilStateID, payload) } diff --git a/pkg/types/map.go b/pkg/types/map.go new file mode 100644 index 0000000..a7b52e5 --- /dev/null +++ b/pkg/types/map.go @@ -0,0 +1,27 @@ +package types + +import ( + "sync" +) + +type Map[K comparable, V any] struct { + rw sync.RWMutex + m map[K]V +} + +func NewMap[K comparable, V any]() *Map[K, V] { + return &Map[K, V]{m: make(map[K]V)} +} + +func (t *Map[K, V]) Set(k K, v V) { + defer t.rw.Unlock() + t.rw.Lock() + t.m[k] = v +} + +func (t *Map[K, V]) Get(k K) (val V, ok bool) { + defer t.rw.RUnlock() + t.rw.RLock() + val, ok = t.m[k] + return +} diff --git a/pkg/types/queue.go b/pkg/types/queue.go new file mode 100644 index 0000000..398f691 --- /dev/null +++ b/pkg/types/queue.go @@ -0,0 +1,62 @@ +package types + +import ( + "sync/atomic" + "unsafe" +) + +type node[T any] struct { + val atomic.Pointer[T] + next atomic.Pointer[node[T]] +} + +type Queue[T any] interface { + Enqueue(T) + Dequeue() T + Len() uint64 +} + +type queue[T any] struct { + head, tail *node[T] + length atomic.Uint64 +} + +func NewQueue[T any]() Queue[T] { + n := node[T]{} + return &queue[T]{head: &n, tail: &n} +} + +func (q *queue[T]) Enqueue(v T) { + n := node[T]{val: atomic.Pointer[T]{}} + n.val.Store(&v) + for { + if q.tail.next.CompareAndSwap(nil, &n) { + atomic.StorePointer((*unsafe.Pointer)(unsafe.Pointer(&q.tail)), unsafe.Pointer(&n)) + q.length.Add(1) + return + } + } +} + +func (q *queue[T]) Dequeue() T { + headAddr := (*unsafe.Pointer)(unsafe.Pointer(&q.head)) + for { + var result T + head := atomic.LoadPointer(headAddr) + n := q.head.next.Load() + if n == nil { + return result + } + if atomic.CompareAndSwapPointer(headAddr, head, unsafe.Pointer(n)) { + q.length.Add(^uint64(0)) // Переполнение намеренное, это отнимает единицу от счетчика. + if r := n.val.Load(); r != nil { + return *r + } + return result + } + } +} + +func (q *queue[T]) Len() uint64 { + return q.length.Load() +} diff --git a/pkg/types/queue_test.go b/pkg/types/queue_test.go new file mode 100644 index 0000000..b6ca584 --- /dev/null +++ b/pkg/types/queue_test.go @@ -0,0 +1,19 @@ +package types + +import ( + "github.com/stretchr/testify/assert" + "testing" +) + +func TestQueue(t *testing.T) { + q := NewQueue[uint8]() + assert.Equal(t, uint64(0), q.Len()) + assert.Equal(t, uint8(0), q.Dequeue()) + + q.Enqueue(1) + q.Enqueue(2) + assert.Equal(t, uint64(2), q.Len()) + assert.Equal(t, uint8(1), q.Dequeue()) + assert.Equal(t, uint8(2), q.Dequeue()) + assert.Equal(t, uint8(0), q.Dequeue()) +} diff --git a/pkg/types/ttl_map.go b/pkg/types/ttl_map.go new file mode 100644 index 0000000..bbbfb10 --- /dev/null +++ b/pkg/types/ttl_map.go @@ -0,0 +1,63 @@ +package types + +import ( + "sync" + "time" +) + +type TTLMap[K comparable, V any] struct { + rw sync.RWMutex + m map[K]entry[V] + ttl time.Duration +} + +type entry[V any] struct { + value V + expireAt *time.Time +} + +func (e *entry[V]) expired() bool { + if e.expireAt == nil { + return false + } + return time.Now().After(*e.expireAt) +} + +func NewTTLMap[K comparable, V any](ttl time.Duration) *TTLMap[K, V] { + return &TTLMap[K, V]{m: make(map[K]entry[V]), ttl: ttl} +} + +func (t *TTLMap[K, V]) Set(k K, v V) { + t.SetWithTTL(k, v, t.ttl) +} + +func (t *TTLMap[K, V]) SetWithTTL(k K, v V, ttl time.Duration) { + defer t.rw.Unlock() + t.rw.Lock() + var expireAt *time.Time + if ttl > 0 { + at := time.Now().Add(ttl) + expireAt = &at + } + t.m[k] = entry[V]{value: v, expireAt: expireAt} +} + +func (t *TTLMap[K, V]) Get(k K) (val V, ok bool) { + defer t.rw.RUnlock() + t.rw.RLock() + v, ok := t.m[k] + if !ok { + return + } + if v.expired() { + defer func() { + t.rw.RUnlock() + t.rw.Lock() + delete(t.m, k) + t.rw.Unlock() + t.rw.RLock() + }() + return + } + return v.value, true +}