wip: moving setup logic to fsm
All checks were successful
continuous-integration/drone/push Build is passing
All checks were successful
continuous-integration/drone/push Build is passing
This commit is contained in:
parent
1c37735dea
commit
37b638712c
@ -5,6 +5,7 @@ import (
|
|||||||
"gitea.neur0tx.site/Neur0toxine/vegapokerbot/internal/db"
|
"gitea.neur0tx.site/Neur0toxine/vegapokerbot/internal/db"
|
||||||
"gitea.neur0tx.site/Neur0toxine/vegapokerbot/internal/handler"
|
"gitea.neur0tx.site/Neur0toxine/vegapokerbot/internal/handler"
|
||||||
"gitea.neur0tx.site/Neur0toxine/vegapokerbot/internal/handler/iface"
|
"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/locale"
|
||||||
"gitea.neur0tx.site/Neur0toxine/vegapokerbot/internal/logger"
|
"gitea.neur0tx.site/Neur0toxine/vegapokerbot/internal/logger"
|
||||||
"github.com/mymmrac/telego"
|
"github.com/mymmrac/telego"
|
||||||
@ -113,6 +114,7 @@ func (a *App) initTelegram() error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (a *App) initHandlers() {
|
func (a *App) initHandlers() {
|
||||||
|
wizard.PopulateStates(a)
|
||||||
a.Handlers = map[handler.Type]handler.Handler{
|
a.Handlers = map[handler.Type]handler.Handler{
|
||||||
handler.Noop: handler.NewNoopHandler(a.Logger, a.Config.Debug),
|
handler.Noop: handler.NewNoopHandler(a.Logger, a.Config.Debug),
|
||||||
handler.Message: handler.NewMessageHandler(a),
|
handler.Message: handler.NewMessageHandler(a),
|
||||||
|
128
internal/fsm/machine.go
Normal file
128
internal/fsm/machine.go
Normal file
@ -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
|
||||||
|
}
|
71
internal/fsm/state.go
Normal file
71
internal/fsm/state.go
Normal file
@ -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) {}
|
48
internal/handler/wizard/base.go
Normal file
48
internal/handler/wizard/base.go
Normal file
@ -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)
|
||||||
|
}
|
21
internal/handler/wizard/error_state.go
Normal file
21
internal/handler/wizard/error_state.go
Normal file
@ -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()
|
||||||
|
}
|
31
internal/handler/wizard/help_state.go
Normal file
31
internal/handler/wizard/help_state.go
Normal file
@ -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"
|
||||||
|
}
|
48
internal/handler/wizard/init.go
Normal file
48
internal/handler/wizard/init.go
Normal file
@ -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())
|
||||||
|
}
|
76
internal/handler/wizard/register_state.go
Normal file
76
internal/handler/wizard/register_state.go
Normal file
@ -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"
|
||||||
|
}
|
Loading…
Reference in New Issue
Block a user