Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
227e1bb
feat(yatgbot): Implement `fsm`
Olderestin Sep 8, 2025
d72e1d8
feat(yatgbot): Implement `message_queue`
Olderestin Sep 8, 2025
fd94634
feat(yatgbot): Implement base tg bot
Olderestin Sep 8, 2025
ab9fdec
refactor(yatgbot): Partial refactor
Olderestin Sep 24, 2025
b1fa78f
refactor(yatgbot): Separate yafsm & improve API
Olderestin Sep 25, 2025
883b995
chore(yatgbot): Add docstrings
Olderestin Sep 25, 2025
ea24f52
chore(yatgbot): Update docstrings
Olderestin Sep 25, 2025
bbff652
fix(yatgbot): Correct `stateDataKey` constant
Olderestin Sep 25, 2025
b702542
test(yatgbot): Add unit tests
Olderestin Sep 25, 2025
22c3859
Merge branch 'main' into feature/yatgbot
YaCodesDevelopment Sep 25, 2025
4605d2f
refactor(yatgbot): Add yalocales
Olderestin Sep 25, 2025
8c2ee6a
chore(yatgbot): Improve code
Olderestin Sep 25, 2025
f9d979c
Merge branch 'main' into feature/yatgbot
Olderestin Dec 2, 2025
62e6503
feat(yatgbot): Support new tg updates
Olderestin Dec 4, 2025
3c6fa58
fix(yatgbot): `messagequeqe` sorting by timestamp
Olderestin Dec 4, 2025
2d5549f
style(yatgbot): Improve code formatting and naming
Olderestin Dec 4, 2025
b778c10
chore(yatgbot): Update docstrings
Olderestin Dec 4, 2025
a8357d7
refactor(yatgbot): Remove unecessary check
Olderestin Dec 4, 2025
cc67fcc
chore(yatgbot): Ignore `unexported-return` for `fsm`
Olderestin Dec 5, 2025
c0fab37
fix(yatgbot): `messagequeqe` sorting by timestamp
Olderestin Dec 8, 2025
6d0bddd
refactor(yatgbot): Cleanup code
Olderestin Jan 20, 2026
4902f6e
chore(yatgbot): Send err in channel on deletion
Olderestin Jan 20, 2026
927f196
feat(yatgclient): Add media upload helper
Olderestin Jan 20, 2026
f11a03a
feat(yatgbot): Provide parse mode to messagequeue
Olderestin Jan 20, 2026
43b320f
fix(yatgbot): Job processing data race
Olderestin Jan 24, 2026
cf15d78
refactor(yatgbot): Partial refactor
Olderestin Jan 24, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions yafsm/constants.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
package yafsm

const (
stateKey = "state"
stateDataKey = "stateData"
)
77 changes: 77 additions & 0 deletions yafsm/entityfsm.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
package yafsm

import (
"context"

"github.com/YaCodeDev/GoYaCodeDevUtils/yaerrors"
)

// EntityFSMStorage is a wrapper over FSM to work with specific entity (user, chat, etc).
type EntityFSMStorage struct {
storage FSM
uid string
}

// NewUserFSMStorage creates a new EntityFSMStorage for a specific user ID.
//
// Example usage:
//
// userFSMStorage := NewUserFSMStorage(fsmStorage, "user123")
func NewUserFSMStorage(
storage FSM,
uid string,
) *EntityFSMStorage {
return &EntityFSMStorage{
storage: storage,
uid: uid,
}
}

// SetState sets the state for the entity.
//
// Example usage:
//
// err := userFSMStorage.SetState(ctx, &SomeState{Field: "value"})
//
// if err != nil {
// // handle error
// }
func (b *EntityFSMStorage) SetState(
ctx context.Context,
stateData State,
) yaerrors.Error {
return b.storage.SetState(ctx, b.uid, stateData)
}

// GetState retrieves the current state and its data for the entity.
//
// Example usage:
//
// stateName, stateData, err := userFSMStorage.GetState(ctx)
//
// if err != nil {
// // handle error
// }
func (b *EntityFSMStorage) GetState(
ctx context.Context,
) (string, stateDataMarshalled, yaerrors.Error) { // nolint: revive
return b.storage.GetState(ctx, b.uid)
}

// GetStateData unmarshals the state data into the provided empty state struct.
//
// Example usage:
//
// var stateData SomeState
//
// err := userFSMStorage.GetStateData(marshalledData, &stateData)
//
// if err != nil {
// // handle error
// }
func (b *EntityFSMStorage) GetStateData(
stateData stateDataMarshalled,
emptyState State,
) yaerrors.Error {
return b.storage.GetStateData(stateData, emptyState)
}
196 changes: 196 additions & 0 deletions yafsm/fsm.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,196 @@
package yafsm

import (
"context"
"encoding/json"
"net/http"
"reflect"

"github.com/YaCodeDev/GoYaCodeDevUtils/yacache"
"github.com/YaCodeDev/GoYaCodeDevUtils/yaerrors"
)

// State is an interface that all states must implement.
type State interface {
StateName() string
}

// BaseState provides a default implementation of the State interface.
type BaseState[T State] struct{}

// StateName returns the name of the state type.
func (BaseState[T]) StateName() string {
var zero T

t := reflect.TypeOf(zero)
if t.Kind() == reflect.Pointer {
t = t.Elem()
}

return t.Name()
}

// Empty state is implementation of State interface with no data.
type EmptyState struct {
BaseState[EmptyState]
}

// stateDataMarshalled is a type alias for marshalled state data.
type stateDataMarshalled string

// StateAndData is a struct that holds the state name and its marshalled data.
type StateAndData struct {
State string `json:"state"`
StateData string `json:"stateData"`
}

// FSM is an interface for finite state machine storage.\
type FSM interface {
SetState(ctx context.Context, uid string, state State) yaerrors.Error
GetState(ctx context.Context, uid string) (string, stateDataMarshalled, yaerrors.Error)
GetStateData(stateData stateDataMarshalled, emptyState State) yaerrors.Error
}

// DefaultFSMStorage is a default implementation of the FSM interface using yacache.
type DefaultFSMStorage[T yacache.Container] struct {
storage yacache.Cache[T]
defaultState State
}

// NewDefaultFSMStorage creates a new instance of DefaultFSMStorage.
//
// Example usage:
//
// cache := yacache.NewCache(redisClient)
//
// fsmStorage := fsm.NewDefaultFSMStorage(cache, fsm.EmptyState{})
func NewDefaultFSMStorage[T yacache.Container](
storage yacache.Cache[T],
defaultState State,
) *DefaultFSMStorage[T] {
return &DefaultFSMStorage[T]{
storage: storage,
defaultState: defaultState,
}
}

// SetState sets the state for a given user ID.
// The state data is marshalled to JSON before being stored.
//
// Example usage:
//
// err := fsmStorage.SetState(ctx, "123", &SomeState{Field: "value"})
func (b *DefaultFSMStorage[T]) SetState(
ctx context.Context,
uid string,
stateData State,
) yaerrors.Error {
val, err := json.Marshal(stateData)
if err != nil {
return yaerrors.FromError(
http.StatusInternalServerError,
err,
"failed to marshal state data",
)
}

val, err = json.Marshal(StateAndData{
State: stateData.StateName(),
StateData: string(val),
})
if err != nil {
return yaerrors.FromError(
http.StatusInternalServerError,
err,
"failed to marshal state data",
)
}

return b.storage.Set(ctx, uid, string(val), 0)
}

// GetState retrieves the current state and its marshalled data for a given user ID.
// If no state is found, it returns the default state.
//
// Example usage:
//
// stateName, stateData, err := fsmStorage.GetState(ctx, "123")
func (b *DefaultFSMStorage[T]) GetState(
ctx context.Context,
uid string,
) (string, stateDataMarshalled, yaerrors.Error) { // nolint: revive
data, err := b.storage.Get(ctx, uid)
if err != nil {
return b.defaultState.StateName(), "", nil
}

var stateAndData map[string]string

if err := json.Unmarshal([]byte(data), &stateAndData); err != nil {
return "", "", yaerrors.FromError(
http.StatusInternalServerError,
err,
"failed to unmarshal state data map",
)
}

state, ok := stateAndData[stateKey]

if !ok {
return "", "", yaerrors.FromString(
http.StatusNotFound,
"failed to get state",
)
}

return state, stateDataMarshalled(data), nil
}

// GetStateData unmarshals the state data into the provided empty state struct.
//
// Example usage:
//
// var stateData SomeState
//
// err := fsmStorage.GetStateData(marshalledData, &stateData)
//
// if err != nil {
// // handle error
// }
func (b *DefaultFSMStorage[T]) GetStateData(
stateData stateDataMarshalled,
emptyState State,
) yaerrors.Error {
if stateData == "" {
return nil
}

var stateAndData map[string]string

if err := json.Unmarshal([]byte(stateData), &stateAndData); err != nil {
return yaerrors.FromError(
http.StatusInternalServerError,
err,
"failed to unmarshal state data map",
)
}

stateDataMarshalled, ok := stateAndData[stateDataKey]

if !ok {
return yaerrors.FromString(
http.StatusNotFound,
"failed to get state data",
)
}

if err := json.Unmarshal([]byte(stateDataMarshalled), emptyState); err != nil {
return yaerrors.FromError(
http.StatusInternalServerError,
err,
"failed to unmarshal state data",
)
}

return nil
}
101 changes: 101 additions & 0 deletions yafsm/fsm_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
package yafsm_test

import (
"context"
"encoding/json"
"errors"
"testing"

"github.com/YaCodeDev/GoYaCodeDevUtils/yacache"
"github.com/YaCodeDev/GoYaCodeDevUtils/yafsm"
)

type ExampleState struct {
yafsm.BaseState[ExampleState]

Param string `json:"param"`
}

func TestFSMStorage_SetGetRoundTrip(t *testing.T) {
ctx := context.Background()

cache := yacache.NewCache(yacache.NewMemoryContainer())

fsm := yafsm.NewDefaultFSMStorage(cache, yafsm.EmptyState{})

uid := "12345"
wantParam := "exampleparam"

// 1) set
if err := fsm.SetState(ctx, uid, ExampleState{Param: wantParam}); err != nil {
t.Fatalf("SetState failed: %v", err)
}

// 2) get state name + raw payload
stateName, raw, err := fsm.GetState(ctx, uid)
if err != nil {
t.Fatalf("GetState failed: %v", err)
}

if stateName != (ExampleState{}).StateName() {
t.Fatalf("unexpected state name: want %q, got %q",
(ExampleState{}).StateName(), stateName)
}

// 3) unmarshal into struct
var got ExampleState
if err := fsm.GetStateData(raw, &got); err != nil {
t.Fatalf("GetStateData failed: %v", err)
}

if got.Param != wantParam {
t.Fatalf("unexpected param: want %q, got %q", wantParam, got.Param)
}
}

func TestFSMStorage_DefaultStateReturned(t *testing.T) {
ctx := context.Background()

cache := yacache.NewCache(yacache.NewMemoryContainer())
fsm := yafsm.NewDefaultFSMStorage(cache, yafsm.EmptyState{})

uid := "non-existent"

name, raw, err := fsm.GetState(ctx, uid)
if err != nil {
t.Fatalf("GetState failed: %v", err)
}

if name != (yafsm.EmptyState{}).StateName() {
t.Fatalf("expected default state name %q, got %q",
(yafsm.EmptyState{}).StateName(), name)
}

if raw != "" {
t.Fatalf("expected empty raw data, got %q", raw)
}
}

func TestFSMStorage_CorruptedPayload(t *testing.T) {
ctx := context.Background()

cache := yacache.NewCache(yacache.NewMemoryContainer())
fsm := yafsm.NewDefaultFSMStorage(cache, yafsm.EmptyState{})

uid := "bad:user"

err := cache.Set(ctx, uid, "{not:a:json}", 0)
if err != nil {
t.Fatalf("failed to set corrupted data: %v", err)
}

_, _, err = fsm.GetState(ctx, uid)
if err == nil {
t.Fatal("expected error on corrupted JSON, got nil")
}

var syntaxErr *json.SyntaxError
if !errors.As(err, &syntaxErr) {
t.Fatalf("expected json.SyntaxError, got %v", err)
}
}
Loading