diff --git a/internal/app/app.go b/internal/app/app.go index fcc488e..f08c9d6 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -5,6 +5,7 @@ import ( "gitea.neur0tx.site/Neur0toxine/vegapokerbot/internal/db" "gitea.neur0tx.site/Neur0toxine/vegapokerbot/internal/handler" "gitea.neur0tx.site/Neur0toxine/vegapokerbot/internal/handler/iface" + "gitea.neur0tx.site/Neur0toxine/vegapokerbot/internal/handler/wizard" "gitea.neur0tx.site/Neur0toxine/vegapokerbot/internal/locale" "gitea.neur0tx.site/Neur0toxine/vegapokerbot/internal/logger" "github.com/mymmrac/telego" @@ -113,6 +114,7 @@ func (a *App) initTelegram() error { } func (a *App) initHandlers() { + wizard.PopulateStates(a) a.Handlers = map[handler.Type]handler.Handler{ handler.Noop: handler.NewNoopHandler(a.Logger, a.Config.Debug), handler.Message: handler.NewMessageHandler(a), diff --git a/internal/fsm/machine.go b/internal/fsm/machine.go new file mode 100644 index 0000000..8d3ae44 --- /dev/null +++ b/internal/fsm/machine.go @@ -0,0 +1,128 @@ +package fsm + +import ( + "errors" + "fmt" + "sync" +) + +var ( + // ErrPreventTransition should be returned from Enter if you don't want to perform a state transition and + // everything that needed to be done has been done in Enter callback. + ErrPreventTransition = errors.New("prevents transition; this is not an error") + // ErrStateDoesNotExist will be returned if provided state ID does not exist in this machine. + 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. +// - It can act as a router by changing Machine's state via provided controls. +type MachineHandleInput[T any] func(*T, MachineControls[*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] + // Handle the state input. Handle func will accept the current payload and modify it based on user input. + Handle(MachineHandleInput[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 + state StateID + initialState StateID + initialPayload T + states map[StateID]IState[T] + errHandler ErrorState[T] +} + +// New machine. +func New[T any](initialState StateID, initialPayload T, states []IState[T], errHandler ErrorState[T]) IMachine[T] { + stateMap := make(map[StateID]IState[T], len(states)) + for _, state := range states { + stateMap[state.ID()] = state + } + pl := initialPayload + return &Machine[T]{ + state: initialState, + payload: &pl, + initialState: initialState, + initialPayload: initialPayload, + states: stateMap, + errHandler: errHandler, + } +} + +// Move to another state. +// Internal: should never be called outside state callbacks. +func (m *Machine[T]) Move(id StateID, payload *T) error { + if id == m.state { + return nil + } + next, err := m.loadState(id, payload) + if next == nil || err != nil { + return err + } + cur, err := m.loadState(m.state, payload) + if err != nil { + return err + } + defer m.lock.Unlock() + m.lock.Lock() + if cur != nil { + cur.Exit(payload) + } + if err := next.Enter(payload, m); err != nil { + if errors.Is(err, ErrPreventTransition) { + return nil + } + m.Reset() + return err + } + m.state = id + return nil +} + +// Handle the input. +func (m *Machine[T]) Handle(inputFunc MachineHandleInput[T]) error { + defer m.lock.Unlock() + m.lock.Lock() + inputFunc(m.payload, m) + st, err := m.loadState(m.state, m.payload) + if st == nil || err != nil { + return err + } + st.Handle(m.payload, m) + return nil +} + +// Reset the machine. +func (m *Machine[T]) Reset() { + pl := m.initialPayload + m.payload = &pl + m.state = m.initialState +} + +func (m *Machine[T]) loadState(id StateID, payload *T) (IState[T], error) { + if id == NilStateID { + return nil, nil + } + st, ok := m.states[id] + if !ok { + return nil, m.fatalError(fmt.Errorf("%w: %s", ErrStateDoesNotExist, id), id, payload) + } + return st, nil +} + +func (m *Machine[T]) fatalError(err error, id StateID, payload *T) error { + if m.errHandler != nil { + m.errHandler.Handle(err, m.state, id, payload, m) + } + return nil +} diff --git a/internal/fsm/state.go b/internal/fsm/state.go new file mode 100644 index 0000000..6117944 --- /dev/null +++ b/internal/fsm/state.go @@ -0,0 +1,71 @@ +package fsm + +// StateID is a state identifier. Machine with string state IDs can have *a lot of* states. +type StateID string + +// NilStateID is a noop state. Machine won't do anything for this transition. +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 + Reset() +} + +// 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 +// if Handle has called Move on MachineControls. +type IState[T any] interface { + // ID should return state identifier. + ID() StateID + // 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 + // 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]) + // 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. + // The next state's Enter callback can return an error which will reset the Machine to default state & payload. + Exit(*T) +} + +// ErrorState is the Machine's fatal error handler which you should implement yourself. +// +// Error state is used by Machine in case of a fatal error (for example, for invalid state ID). +// You can use ErrorState to make some kind of fatal error response like "Internal error has occurred" +// or something similar. Also, ErrorState is special because neither Enter nor Exit exists in it. +// +// 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]) +} + +// State is the Machine's state. This implementation doesn't do anything and only helps with the +// actual IState implementation (you don't need to write empty Enter and Exit callback if you don't use them). +type State[T any] struct { + Payload *T +} + +// ID panics because you need to implement it. +func (s *State[T]) ID() StateID { + panic("implement ID() StateID method for your state") +} + +// Enter here will immediately move the Machine to empty state. +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]) { + _ = machine.Move(NilStateID, payload) +} + +// Exit won't do anything. +func (s *State[T]) Exit(*T) {} diff --git a/internal/handler/wizard/base.go b/internal/handler/wizard/base.go new file mode 100644 index 0000000..d52f469 --- /dev/null +++ b/internal/handler/wizard/base.go @@ -0,0 +1,48 @@ +package wizard + +import ( + "gitea.neur0tx.site/Neur0toxine/vegapokerbot/internal/fsm" + "gitea.neur0tx.site/Neur0toxine/vegapokerbot/internal/handler/iface" + "gitea.neur0tx.site/Neur0toxine/vegapokerbot/internal/locale" + "github.com/mymmrac/telego" + "strings" +) + +type Wizard struct { + UserID int64 + ChatID int64 + Data telego.Update +} + +type State struct { + fsm.State[Wizard] + App iface.App +} + +func newBase(app iface.App) State { + return State{App: app} +} + +func (s State) Localizer(lang string) locale.Localizer { + lang = strings.ToLower(lang) + if len(lang) > 2 { + lang = lang[:2] + } + switch lang { + case "en", "ru": + return s.App.Localizer(lang) + default: + return s.App.Localizer("en") + } +} + +func (s State) LogError(err error) { + if err == nil { + return + } + if s.App.Conf().Debug { + s.App.Log().Errorf("handler error: %s, payload: %#v", err, s.Payload) + return + } + s.App.Log().Errorf("handler error: %s, user id: %d, chat id: %d", err, s.Payload.UserID, s.Payload.ChatID) +} diff --git a/internal/handler/wizard/error_state.go b/internal/handler/wizard/error_state.go new file mode 100644 index 0000000..6ecf782 --- /dev/null +++ b/internal/handler/wizard/error_state.go @@ -0,0 +1,21 @@ +package wizard + +import ( + "gitea.neur0tx.site/Neur0toxine/vegapokerbot/internal/fsm" + "go.uber.org/zap" +) + +type ErrorState struct { + fsm.ErrorState[Wizard] + log *zap.SugaredLogger +} + +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]) { + 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/wizard/help_state.go b/internal/handler/wizard/help_state.go new file mode 100644 index 0000000..52c94b1 --- /dev/null +++ b/internal/handler/wizard/help_state.go @@ -0,0 +1,31 @@ +package wizard + +import ( + "gitea.neur0tx.site/Neur0toxine/vegapokerbot/internal/fsm" + "gitea.neur0tx.site/Neur0toxine/vegapokerbot/internal/handler/iface" + "github.com/mymmrac/telego" + tu "github.com/mymmrac/telego/telegoutil" +) + +type HelpState struct { + State +} + +func NewHelpState(app iface.App) fsm.IState[Wizard] { + return &HelpState{newBase(app)} +} + +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). + Template("help_output", map[string]interface{}{"Name": s.App.TGProfile().Username}), + ParseMode: telego.ModeMarkdown, + }) + s.LogError(err) + return fsm.ErrPreventTransition +} + +func (s *HelpState) ID() fsm.StateID { + return "help" +} diff --git a/internal/handler/wizard/init.go b/internal/handler/wizard/init.go new file mode 100644 index 0000000..6cf07df --- /dev/null +++ b/internal/handler/wizard/init.go @@ -0,0 +1,48 @@ +package wizard + +import ( + "gitea.neur0tx.site/Neur0toxine/vegapokerbot/internal/fsm" + "gitea.neur0tx.site/Neur0toxine/vegapokerbot/internal/handler/iface" + "github.com/maypok86/otter" + "time" +) + +var ( + wizards otter.Cache[int64, fsm.IMachine[Wizard]] + states []fsm.IState[Wizard] + errorState *ErrorState +) + +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 +} + +func Get(chatID int64) fsm.IMachine[Wizard] { + if machine, ok := wizards.Get(chatID); ok { + return machine + } + machine := newWizard() + wizards.Set(chatID, machine) + return machine +} + +func newWizard() fsm.IMachine[Wizard] { + return fsm.New[Wizard](states[0].ID(), Wizard{}, states, errorState) +} + +func PopulateStates(app iface.App) { + states = []fsm.IState[Wizard]{ + NewRegisterState(app), + NewHelpState(app), + } + errorState = NewErrorState(app.Log()) +} diff --git a/internal/handler/wizard/register_state.go b/internal/handler/wizard/register_state.go new file mode 100644 index 0000000..43cf1c1 --- /dev/null +++ b/internal/handler/wizard/register_state.go @@ -0,0 +1,76 @@ +package wizard + +import ( + "gitea.neur0tx.site/Neur0toxine/vegapokerbot/internal/db/model" + "gitea.neur0tx.site/Neur0toxine/vegapokerbot/internal/fsm" + "gitea.neur0tx.site/Neur0toxine/vegapokerbot/internal/handler/iface" + "github.com/mymmrac/telego" + tu "github.com/mymmrac/telego/telegoutil" +) + +type RegisterState struct { + State +} + +func NewRegisterState(app iface.App) fsm.IState[Wizard] { + return &RegisterState{newBase(app)} +} + +func (s *RegisterState) Handle(pl *Wizard, _ 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) + if err != nil { + return + } + + if user != nil && user.ID > 0 { + var shouldUpdate bool + if user.ChatID != pl.Data.Message.Chat.ID { + user.ChatID = pl.Data.Message.Chat.ID + shouldUpdate = true + } + if user.Language != pl.Data.Message.From.LanguageCode { + user.Language = pl.Data.Message.From.LanguageCode + shouldUpdate = true + } + if shouldUpdate { + _ = userRepo.Save(user) + } + _, err := s.App.TG().SendMessage(&telego.SendMessageParams{ + ChatID: tu.ID(pl.Data.Message.Chat.ID), + Text: loc.Message("welcome"), + ParseMode: telego.ModeMarkdown, + }) + s.LogError(err) + return + } + + err = userRepo.Save(&model.User{ + TelegramID: pl.Data.Message.From.ID, + ChatID: pl.Data.Message.Chat.ID, + Language: pl.Data.Message.From.LanguageCode, + }) + if err != nil { + s.LogError(err) + _, err = s.App.TG().SendMessage(&telego.SendMessageParams{ + ChatID: tu.ID(pl.Data.Message.Chat.ID), + Text: loc.Message("internal_error"), + ParseMode: telego.ModeMarkdown, + }) + s.LogError(err) + return + } + + _, err = s.App.TG().SendMessage(&telego.SendMessageParams{ + ChatID: tu.ID(pl.Data.Message.Chat.ID), + Text: loc.Message("welcome"), + ParseMode: telego.ModeMarkdown, + }) + s.LogError(err) + return +} + +func (s *RegisterState) ID() fsm.StateID { + return "register" +}